Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3d3e7669ac | ||
|
|
e72e99d699 | ||
|
|
5ea62b4bd6 | ||
|
|
9e1295d0de | ||
|
|
38a02974d5 | ||
|
|
d857670c9f | ||
|
|
3cf41d3fc2 | ||
|
|
6822abba90 | ||
|
|
43f9259803 | ||
|
|
f139ac48c4 | ||
|
|
bb7f2b93ea | ||
|
|
af4e979ab0 | ||
|
|
9938efb706 | ||
|
|
6e6a77d554 | ||
|
|
bc22691f81 | ||
|
|
2d3de20682 | ||
|
|
6d32fe2bd3 | ||
|
|
be92ff0b3d | ||
|
|
dd6dd742b5 | ||
|
|
5d0a924c6c | ||
|
|
542dd6751f | ||
|
|
59a2d38480 | ||
|
|
2a692cda84 | ||
|
|
cb1e6cd19d | ||
|
|
564bf005c7 | ||
|
|
62facda5d9 | ||
|
|
afd05ad8c0 | ||
|
|
ef5365b9ed | ||
|
|
cec98fcde6 | ||
|
|
6ec0325f7d | ||
|
|
7719a5fff2 | ||
|
|
2934ce2684 | ||
|
|
f0871eb4b1 | ||
|
|
18a655e990 | ||
|
|
617b91ef23 | ||
|
|
73ffa7925e | ||
|
|
c389c1c586 | ||
|
|
5d1d2337f0 | ||
|
|
163e206715 | ||
|
|
2a14c780fa | ||
|
|
99407dfcb2 | ||
|
|
31d8232978 | ||
|
|
9d6d18bc47 | ||
|
|
20cab97a55 | ||
|
|
e8025113e9 | ||
|
|
585b50ac72 | ||
|
|
3e2c087483 | ||
|
|
a186b0c7d0 | ||
|
|
e9aa6debb6 | ||
|
|
12baaca79a | ||
|
|
8693e35873 | ||
|
|
bd189fea38 | ||
|
|
6c9ff6edd2 | ||
|
|
554bb4922f | ||
|
|
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
@@ -16,6 +16,15 @@ body:
|
|||||||
validations:
|
validations:
|
||||||
required: true
|
required: true
|
||||||
|
|
||||||
|
- type: input
|
||||||
|
id: version
|
||||||
|
attributes:
|
||||||
|
label: Gearslip version
|
||||||
|
description: Shown under the Gearslip name on the app's home screen.
|
||||||
|
placeholder: v0.3.40-alpha
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
|
||||||
- type: dropdown
|
- type: dropdown
|
||||||
id: head-unit
|
id: head-unit
|
||||||
attributes:
|
attributes:
|
||||||
|
|||||||
@@ -16,6 +16,15 @@ body:
|
|||||||
validations:
|
validations:
|
||||||
required: true
|
required: true
|
||||||
|
|
||||||
|
- type: input
|
||||||
|
id: version
|
||||||
|
attributes:
|
||||||
|
label: Gearslip version
|
||||||
|
description: Shown under the Gearslip name on the app's home screen.
|
||||||
|
placeholder: v0.3.40-alpha
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
|
||||||
- type: dropdown
|
- type: dropdown
|
||||||
id: head-unit
|
id: head-unit
|
||||||
attributes:
|
attributes:
|
||||||
|
|||||||
@@ -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
|
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.
|
||||||
- Navigation cards sit down the left side, with a bigger turn card that shows your lane. The map keeps the right side.
|
|
||||||
- The car screen and the phone app take their colors from your phone's Material You palette.
|
|
||||||
- The car's settings sit on the phone's home screen, each group on its own screen.
|
|
||||||
- Put the home screen's player left or right of the map.
|
|
||||||
- The browser shows the page title, and it has an address bar, forward, reload, and a start page.
|
|
||||||
- If Gearslip lacks call permissions, the car's Phone screen asks for them on the phone.
|
|
||||||
|
|
||||||
## Changed
|
|
||||||
- Gearslip uses Car App Library 1.8. Map apps show the right turn arrows and route times, and Vela works fully.
|
|
||||||
- Setup ends with recommended apps, then the button picker, then usage notes.
|
|
||||||
|
|
||||||
## Fixed
|
## 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.
|
- 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.
|
||||||
- Gearslip no longer crashes when a wireless connection ends.
|
- 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).
|
||||||
- Opening a second music app without playing anything keeps the player on the song that's still playing.
|
- 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.
|
||||||
- Vela's speed sign is full size again.
|
|
||||||
- RF Sentinel and other non-media car apps get their own tile in the launcher.
|
## 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/).
|
||||||
@@ -12,6 +12,9 @@ jobs:
|
|||||||
contents: write
|
contents: write
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
# The build names itself after the newest tag, which a shallow clone doesn't have.
|
||||||
|
fetch-depth: 0
|
||||||
|
|
||||||
- uses: actions/setup-java@v4
|
- uses: actions/setup-java@v4
|
||||||
with:
|
with:
|
||||||
|
|||||||
@@ -0,0 +1,20 @@
|
|||||||
|
name: Translations
|
||||||
|
|
||||||
|
# Weblate is the only thing that writes translated strings: it pushes straight to experimental,
|
||||||
|
# never through a pull request. A PR that edits them collides with Weblate's own copy, and
|
||||||
|
# Weblate locks itself until someone resets it. Edit the English strings.xml only.
|
||||||
|
# Weblate's own pull requests are the exception: they are the translations.
|
||||||
|
on:
|
||||||
|
pull_request:
|
||||||
|
paths:
|
||||||
|
- "gearslip/src/main/res/values-*/strings.xml"
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
weblate-only:
|
||||||
|
if: github.event.pull_request.user.login != 'weblate'
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Translations go through Weblate
|
||||||
|
run: |
|
||||||
|
echo "::error::This pull request edits a translation. Please make translation changes on Weblate instead: https://hosted.weblate.org/engage/gearslip/ (it can upload a whole strings.xml under Files > Upload translation). See CONTRIBUTING.md."
|
||||||
|
exit 1
|
||||||
@@ -23,6 +23,7 @@ and some don't say.
|
|||||||
| Jeep Cherokee | 2019 | Uconnect 8.4 (Delphi) |
|
| Jeep Cherokee | 2019 | Uconnect 8.4 (Delphi) |
|
||||||
| MG | 2019 | Desay SV |
|
| MG | 2019 | Desay SV |
|
||||||
| Ram 1500 | 2018 | Uconnect 8.4 (Panasonic) |
|
| Ram 1500 | 2018 | Uconnect 8.4 (Panasonic) |
|
||||||
|
| Renault Clio V | 2019-2024 | Easy Link |
|
||||||
| Škoda | 2018 | MIB Standard2 (TechniSat) |
|
| Škoda | 2018 | MIB Standard2 (TechniSat) |
|
||||||
| Subaru Outback | 2020 | Denso |
|
| Subaru Outback | 2020 | Denso |
|
||||||
| Toyota RAV4 | 2020 | Pioneer T1 |
|
| 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,
|
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.
|
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
|
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).
|
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
|
- Gearslip asks for no adb, root, or Shizuku. A feature must work with ordinary Android
|
||||||
permissions and on-device consent dialogs.
|
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
|
## Writing
|
||||||
|
|
||||||
Text that users read, such as screens, release notes, and docs, follows Strunk's
|
Text that users read, such as screens, release notes, and docs, follows Strunk's
|
||||||
|
|||||||
@@ -4,107 +4,93 @@
|
|||||||
|
|
||||||
An Android Auto alternative built for everyone.
|
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)
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
[](https://forthebadge.com)
|
[](https://forthebadge.com)
|
||||||
|
|
||||||
[<img src="https://raw.githubusercontent.com/ImranR98/Obtainium/main/assets/graphics/badge_obtainium.png" alt="Get it on Obtainium" height="80">](https://apps.obtainium.imranr.dev/redirect?r=obtainium://add/https://github.com/twinksoftware/gearslip)
|
Gearslip speaks the Android Auto protocol straight to your car, over USB or wireless, with no
|
||||||
|
Google software in the path. Map, music, and messaging apps draw their car screens through
|
||||||
|
Gearslip, and it adds a player, calls, voice, and a web browser of its own.
|
||||||
|
|
||||||
Questions, ideas?
|
> [!WARNING]
|
||||||
[Join the discussion on Matrix](https://matrix.to/#/!mf0_mgBd5KqKpc9FfX_wHp1jFCcwAtPrndIn_oGIyMk?via=matrix.org).
|
> 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.
|
||||||
|
|
||||||
## Which cars work
|
Questions, ideas? [Join the discussion on Matrix](https://matrix.to/#/!mf0_mgBd5KqKpc9FfX_wHp1jFCcwAtPrndIn_oGIyMk?via=matrix.org).
|
||||||
|
|
||||||
Most cars work. Starting around 2020, some cars added newer security standards that reject
|
## What works
|
||||||
Gearslip's certificate, and Gearslip can't run in those cars. [CARS.md](CARS.md) lists the
|
|
||||||
cars drivers have confirmed.
|
|
||||||
|
|
||||||
## What Gearslip is
|
|
||||||
|
|
||||||
Gearslip is an Android Auto client for the phone. It speaks the Android Auto protocol
|
|
||||||
directly to a car head unit, over USB or wirelessly, with no Google software in the path.
|
|
||||||
Gearslip acts as a Car App Library host: an app that ships a car interface hands Gearslip
|
|
||||||
a template, and Gearslip renders that template natively on the car screen. Nothing is
|
|
||||||
mirrored from the phone. For apps without a car interface, Gearslip offers a separate
|
|
||||||
screen-sharing mode.
|
|
||||||
|
|
||||||
| | |
|
| | |
|
||||||
|---|---|
|
|---|---|
|
||||||
|  |  |
|
| **Cars** | Most cars, over USB or wireless. Some cars from about 2020 on need a certificate of your own. [CARS.md](CARS.md) lists the cars drivers have confirmed. |
|
||||||
| 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. |
|
| **Maps** | Organic Maps, Vela, OsmAnd, Waze, Flitsmeister, MAPS.ME, and MapQuest on the car screen, alerts included. Turns from Google Maps or Waze on the phone show in the nav bar and on the instrument cluster. |
|
||||||
|  |  |
|
| **Music** | Any app built for car screens, with Now Playing, Up Next, search, and lyrics. Spotify plays through Gearslip's own player. |
|
||||||
| Metrolist in Gearslip's player. | A new message, read aloud at a tap, over the map. |
|
| **Calls** | Dial or search contacts, and answer or decline on the car screen. Calls use the car's Bluetooth. |
|
||||||
|  |  |
|
| **Messages** | Read aloud on arrival, with a reply by voice or a quick reply. |
|
||||||
| The weather, your week, and what you missed. | Browse the web while parked. |
|
| **Voice** | "Navigate to the nearest gas station", "play Daft Punk", "call Mom". Speech stays on the phone. Commands are in English for now. |
|
||||||
|
| **Controls** | Touch, steering wheel buttons, control knobs, and D-pads. |
|
||||||
|
| **Languages** | English and 19 more, some only partly translated. |
|
||||||
|
| **Head units with Android** | Gearslip can be the home screen of an Android head unit, or of a spare phone or tablet on the dash. Turn on Use as launcher in Settings > Connection. |
|
||||||
|
| **Doesn't work** | Spotify's and Google Messages' own car screens, which accept only Android Auto. TIDAL lets only approved apps browse its library. |
|
||||||
|
|
||||||
|
## See it
|
||||||
|
|
||||||
|
| | |
|
||||||
|
|---|---|
|
||||||
|
|  |  |
|
||||||
|
| Your car apps in one place. | Metrolist in Gearslip's player. |
|
||||||
|
|  |  |
|
||||||
|
| A new message, read aloud at a tap, over the map. | Lyrics light up word by word as the song plays. |
|
||||||
|  |  |
|
|  |  |
|
||||||
| Ask for a place, a song, or an app. | Pick a route to coffee nearby. Each one shows its time and distance. |
|
| Ask for a place, a song, or an app. | Pick a route. Each one shows its time and distance. |
|
||||||
|
|  | |
|
||||||
|
| Browse the web while parked. | |
|
||||||
|
|
||||||
## Features
|
## Features
|
||||||
|
|
||||||
- **Phone calls.** Dial from a keypad, or search your contacts, then confirm before the
|
- **Navigation.** Gearslip hosts real map apps on the car screen. While you navigate, a big
|
||||||
call goes out. When a call comes in, answer or decline it on the car screen. During a
|
turn card shows the arrow, the distance, and your lane, and the map keeps the rest.
|
||||||
call, the nav bar shows who you're talking to, and you mute or hang up from there.
|
- **Music.** Browse, play, and queue from any car-ready media app. Type a search, and apps
|
||||||
- **Navigation.** Gearslip hosts real Car App Library map apps on the car screen.
|
that accept one play the best match.
|
||||||
Organic Maps, Vela, OsmAnd, Flitsmeister, MAPS.ME, and MapQuest work today. While you
|
- **Phone calls.** During a call, the nav bar shows who you're talking to, and you mute or
|
||||||
navigate, a big turn card on the left shows the arrow, the distance, and your lane, and
|
hang up from there.
|
||||||
the map keeps the right side.
|
- **Messages and notifications.** Only messages, missed calls, alarms, and reminders
|
||||||
- **Music.** Browse, play, and queue tracks from a car-enabled media app, with a Now
|
interrupt you. The rest wait on the dashboard. Music pauses while Gearslip speaks or
|
||||||
Playing view, an Up Next queue, and lyrics. Type a search, and apps that accept one
|
listens.
|
||||||
play the best match.
|
- **Voice.** Tap the mic or press the talk button on the wheel. Say "help" for the full list.
|
||||||
- **The web.** A browser with an address bar, back and forward, and its own on-screen
|
- **The web.** A browser with an address bar, back and forward, and its own keyboard.
|
||||||
keyboard, for anything that doesn't ship a car interface of its own.
|
- **Weather and the day ahead.** A dashboard shows the week's calendar beside the weather.
|
||||||
- **Weather and the day ahead.** A dashboard screen shows the week's calendar next to
|
- **Your apps, your order.** Apps group by kind. Press and hold one to move it, and hide
|
||||||
the current and coming weather.
|
the ones you don't use from the Apps card on the phone.
|
||||||
- **Voice.** Tap the mic, or press the talk button on the wheel, and say "navigate to the
|
- **Your look.** The car screen takes your phone's Material You colors. Choose flat
|
||||||
nearest gas station", "play Daft Punk", or "call Mom". Say "help" for the full list.
|
buttons or ones that look like real keys, and make text bigger on small screens.
|
||||||
Speech turns into text on the phone, and nothing you say leaves it.
|
- **Settings on the phone.** Set things up before you drive. A change on either screen
|
||||||
- **Messages.** New messages appear as a small card on the car screen. Gearslip reads
|
shows on both.
|
||||||
each one aloud, and you reply by voice or with a quick reply. Music pauses while
|
|
||||||
Gearslip speaks or listens. Speech runs on the phone, so it needs no Google account.
|
|
||||||
- **Notifications.** Only messages, missed calls, alarms, and reminders interrupt you.
|
|
||||||
The rest wait on the dashboard.
|
|
||||||
- **Your apps, your order.** The launcher groups apps by kind: phone, maps, music,
|
|
||||||
messaging, and the rest. Press and hold an app to move it.
|
|
||||||
- **Car controls.** Steering wheel buttons, control knobs, and D-pads all work.
|
|
||||||
- **Your phone's colors.** The car screen and the phone app take their colors from your
|
|
||||||
phone's Material You palette, and every card and button matches.
|
|
||||||
- **Themes.** Choose flat buttons or skeuomorphic ones that look like real keys.
|
|
||||||
- **Settings on the phone.** The car's settings sit on the phone's home screen too, so you
|
|
||||||
can set things up before you drive. A change on either screen shows on both.
|
|
||||||
|
|
||||||
## Status
|
## Install
|
||||||
|
|
||||||
- **Wired or wireless.** Plug the phone into the car's USB port, or, in a car with wireless
|
1. Install Gearslip from [the releases page](https://github.com/twinksoftware/Gearslip/releases)
|
||||||
Android Auto, tap Connect wirelessly and pick the car. Pair the phone with the car over
|
or through Obtainium.
|
||||||
Bluetooth first. After the first wireless drive, Android asks once whether Gearslip may
|
2. Open it. Setup asks where your Android Auto certificate comes from, then for one
|
||||||
watch for that car; from then on, Gearslip connects whenever the car's Bluetooth does.
|
permission at a time, each with a plain reason. A help screen in the car answers the rest.
|
||||||
- **Setup walks you through it.** First launch asks for the Android Auto certificate,
|
3. Plug the phone into the car's USB port. In a car with wireless Android Auto, pair the
|
||||||
then one permission at a time, each with a plain reason and an Allow or Skip. A help
|
phone with the car over Bluetooth, then tap Connect wirelessly and pick the car.
|
||||||
screen inside the car answers anything setup didn't cover.
|
|
||||||
- **Templated apps work.** Gearslip renders the Car App Library's templates on the car
|
|
||||||
screen, and it draws its own keyboard for search, sign-in, and text fields. It has been
|
|
||||||
tested against several real navigation and media apps.
|
|
||||||
- **Gearslip works with [LIVI](https://github.com/f-io/LIVI),** the open-source head unit
|
|
||||||
used in homemade car dashboards.
|
|
||||||
- **Some apps refuse Gearslip.** Spotify, Waze, and Google Messages use a version of
|
|
||||||
Google's car library that accepts only Android Auto itself. Spotify still plays through
|
|
||||||
Gearslip's own player. TIDAL lets only approved apps browse its library.
|
|
||||||
- **Calls use the car's Bluetooth,** as they do in Android Auto. Pair the phone with the
|
|
||||||
car to hear calls through its speakers.
|
|
||||||
- **Music reaches the car over Bluetooth or USB.** Setup asks which. Bluetooth is the
|
|
||||||
default: Android never asks for permission, and your map app's voice reaches the car
|
|
||||||
too. USB sounds a little clearer, with no delay, but each drive Android asks you to
|
|
||||||
share your screen, which lets Gearslip send the sound. Gearslip never reads the screen; capturing playback is
|
|
||||||
the only way an ordinary app can pass another app's audio along.
|
|
||||||
- **Gearslip needs no adb, root, or Shizuku.** Every feature relies on an ordinary
|
|
||||||
Android permission or an on-device consent dialog. For USB audio, setup offers two
|
|
||||||
optional adb commands: one removes the audio dialog for good, and the other keeps
|
|
||||||
notifications visible while audio plays.
|
|
||||||
- **Updates come from GitHub.** Once a day, Gearslip checks this repository's latest
|
|
||||||
release. When a new version is out, it downloads the APK, checks it against GitHub's
|
|
||||||
SHA-256, and installs it through Android's own installer. Turn off automatic checks in
|
|
||||||
Settings.
|
|
||||||
|
|
||||||
## Verify your download
|
After the first wireless drive, Android asks once whether Gearslip may watch for that car.
|
||||||
|
From then on, Gearslip connects whenever the car's Bluetooth does.
|
||||||
|
|
||||||
|
> [!NOTE]
|
||||||
|
> On GrapheneOS, make sure **USB-C port control** isn't set to **Charging-only**. That
|
||||||
|
> setting blocks USB data, so the car never sees the phone, even with a data cable.
|
||||||
|
|
||||||
|
Gearslip updates itself from this repository. Once a day it checks the latest release,
|
||||||
|
downloads the APK, checks it against GitHub's SHA-256, and installs it through Android's
|
||||||
|
own installer. Turn off automatic checks in Settings.
|
||||||
|
|
||||||
|
### Verify your download
|
||||||
|
|
||||||
Check that your copy of Gearslip is genuine with
|
Check that your copy of Gearslip is genuine with
|
||||||
[AppVerifier](https://github.com/soupslurpr/AppVerifier): share this block to it, or compare it
|
[AppVerifier](https://github.com/soupslurpr/AppVerifier): share this block to it, or compare it
|
||||||
@@ -115,6 +101,24 @@ app.seb3thehacker.gearslip
|
|||||||
4C:C5:6E:BD:D1:3A:54:D2:60:EC:A9:B7:E2:B2:AE:6B:30:E2:F2:25:BF:DA:8F:50:8F:07:09:ED:F0:84:94:8B
|
4C:C5:6E:BD:D1:3A:54:D2:60:EC:A9:B7:E2:B2:AE:6B:30:E2:F2:25:BF:DA:8F:50:8F:07:09:ED:F0:84:94:8B
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Good to know
|
||||||
|
|
||||||
|
- **Music reaches the car over Bluetooth or through Gearslip.** Setup asks which.
|
||||||
|
Bluetooth is the default. Android never asks for permission, and your map app's voice
|
||||||
|
reaches the car too. Through Gearslip, the sound travels over the car connection, cable
|
||||||
|
or wireless. It sounds a little clearer, with no delay, but each drive Android asks you
|
||||||
|
to share your screen, which lets Gearslip send the sound. Gearslip never reads the
|
||||||
|
screen. Capturing playback is the only way an ordinary app can pass another app's audio
|
||||||
|
along.
|
||||||
|
- **Gearslip needs no adb, root, or Shizuku.** Every feature relies on an ordinary
|
||||||
|
Android permission or an on-device consent dialog. For audio through Gearslip, setup
|
||||||
|
offers two optional adb commands. One removes the audio dialog for good, and the other
|
||||||
|
keeps notifications visible while audio plays.
|
||||||
|
- **Apps without a car screen** can still reach the car through a separate screen-sharing
|
||||||
|
mode.
|
||||||
|
- **Gearslip works with [LIVI](https://github.com/f-io/LIVI),** the open-source head unit
|
||||||
|
used in homemade car dashboards.
|
||||||
|
|
||||||
## Usage notes
|
## Usage notes
|
||||||
|
|
||||||
Gearslip can send short notes that show which cars it works in. They stay off unless you
|
Gearslip can send short notes that show which cars it works in. They stay off unless you
|
||||||
@@ -126,9 +130,9 @@ turn them on, in setup or in Settings.
|
|||||||
those boxes cover: the Android version and whether the phone runs GrapheneOS, the phone
|
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
|
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
|
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
|
||||||
the connection got, how long it lasted, and how it ended. Settings shows the exact note. Untick a phone detail, and
|
went: how far the connection got, how long it lasted, and how it ended. Settings shows
|
||||||
the server drops the copy it kept.
|
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
|
- **What they leave out.** Your name, accounts, location, contacts, messages, and
|
||||||
anything you type or play.
|
anything you type or play.
|
||||||
- **How phones are counted.** A random number counts each phone once. The server keeps
|
- **How phones are counted.** A random number counts each phone once. The server keeps
|
||||||
@@ -138,6 +142,19 @@ turn them on, in setup or in Settings.
|
|||||||
|
|
||||||
Turn the notes off, and they stop at once.
|
Turn the notes off, and they stop at once.
|
||||||
|
|
||||||
|
## Help out
|
||||||
|
|
||||||
|
Bug reports from real cars help most, and pull requests are welcome.
|
||||||
|
[CONTRIBUTING.md](CONTRIBUTING.md) explains what to put in a report, which branch to work
|
||||||
|
on, and the one-time agreement you sign before your first pull request merges.
|
||||||
|
|
||||||
|
To translate Gearslip into your language, join
|
||||||
|
[Gearslip on Weblate](https://hosted.weblate.org/engage/gearslip/). No coding needed.
|
||||||
|
Translations go through Weblate only, since it holds its own copy of every language and
|
||||||
|
changes made in git collide with it.
|
||||||
|
|
||||||
|
[](https://hosted.weblate.org/engage/gearslip/)
|
||||||
|
|
||||||
## How Gearslip is made
|
## How Gearslip is made
|
||||||
|
|
||||||
Gearslip is my beloved child. I decide what it does and how it looks, and Claude writes nearly
|
Gearslip is my beloved child. I decide what it does and how it looks, and Claude writes nearly
|
||||||
@@ -147,12 +164,6 @@ open-source head unit software. I watch the phone's temperature too, so a new fe
|
|||||||
cook it. Then I beg my friends to try it in their cars, so it works in as many makes, models,
|
cook it. Then I beg my friends to try it in their cars, so it works in as many makes, models,
|
||||||
and years as possible. If something breaks, that's on me, so please open an issue.
|
and years as possible. If something breaks, that's on me, so please open an issue.
|
||||||
|
|
||||||
## Contributing
|
|
||||||
|
|
||||||
Bug reports from real cars help most, and pull requests are welcome.
|
|
||||||
[CONTRIBUTING.md](CONTRIBUTING.md) explains what to put in a report, which branch to work
|
|
||||||
on, and the one-time agreement you sign before your first pull request merges.
|
|
||||||
|
|
||||||
## Building
|
## Building
|
||||||
|
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -469,6 +469,44 @@ home-screen pane comes later. Anything that needs its own rendering belongs in a
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## Works with Gearslip
|
||||||
|
|
||||||
|
Once your app runs well on Gearslip, say so in two places: your manifest and your README.
|
||||||
|
|
||||||
|
### In your manifest
|
||||||
|
|
||||||
|
Add these lines to `<application>`:
|
||||||
|
|
||||||
|
```xml
|
||||||
|
<meta-data
|
||||||
|
android:name="app.seb3thehacker.gearslip.works_with"
|
||||||
|
android:value="true" />
|
||||||
|
<meta-data
|
||||||
|
android:name="app.seb3thehacker.gearslip.about"
|
||||||
|
android:resource="@string/gearslip_about" />
|
||||||
|
```
|
||||||
|
|
||||||
|
Gearslip then marks your app "Works with Gearslip" in its Recommended apps list and on your app's
|
||||||
|
page in Gearslip. An installed app with the flag gets its own place at the top of the list.
|
||||||
|
|
||||||
|
The second entry is optional. It gives your page one sentence on what your app does in the car,
|
||||||
|
such as "Spots trackers and cameras nearby." Write it in place with `android:value` if you have no
|
||||||
|
string resource for it.
|
||||||
|
|
||||||
|
The flag is a label. Gearslip finds and runs your app the same way with or without it.
|
||||||
|
|
||||||
|
### In your README
|
||||||
|
|
||||||
|
Add the badge:
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
[](https://github.com/twinksoftware/gearslip)
|
||||||
|
```
|
||||||
|
|
||||||
|
It matches the flat badges most READMEs already show.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Further reading
|
## Further reading
|
||||||
|
|
||||||
- [README.md](../README.md) - what Gearslip is.
|
- [README.md](../README.md) - what Gearslip is.
|
||||||
|
|||||||
@@ -0,0 +1,16 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="147" height="20" role="img" aria-label="Works with Gearslip">
|
||||||
|
<title>Works with Gearslip</title>
|
||||||
|
<clipPath id="r"><rect width="147" height="20" rx="3" fill="#fff"/></clipPath>
|
||||||
|
<g clip-path="url(#r)">
|
||||||
|
<rect width="88" height="20" fill="#555"/>
|
||||||
|
<rect x="88" width="59" height="20" fill="#8C1F1F"/>
|
||||||
|
</g>
|
||||||
|
<g transform="translate(5 3.5) scale(0.228) translate(-29.5 -25.5)" fill="#fff">
|
||||||
|
<path fill-rule="evenodd" d="M54 36.18 58.64 30.7 67.2 34.24 66.6 41.4 71.82 54 77.3 58.64 73.76 67.2 66.6 66.6 67.2 73.76 58.64 77.3 54 71.82 49.36 77.3 40.8 73.76 41.4 66.6 34.24 67.2 30.7 58.64 36.18 54 30.7 49.36 34.24 40.8 41.4 41.4 40.8 34.24 49.36 30.7ZM54 46.08A7.92 7.92 0 1 1 53.99 46.08Z"/>
|
||||||
|
<path d="M78.61 40.7 85.31 43.29 84.74 52.55 77.78 54.31Z"/>
|
||||||
|
</g>
|
||||||
|
<g fill="#fff" font-family="Verdana,Geneva,DejaVu Sans,sans-serif" font-size="11" text-rendering="geometricPrecision">
|
||||||
|
<text x="22" y="14" textLength="60">works with</text>
|
||||||
|
<text x="94" y="14" textLength="47">Gearslip</text>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.0 KiB |
|
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 |
@@ -24,6 +24,36 @@ val keystoreProperties = Properties().apply {
|
|||||||
).firstOrNull { it.exists() }?.inputStream()?.use { load(it) }
|
).firstOrNull { it.exists() }?.inputStream()?.use { load(it) }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The version comes from the newest v* git tag, so a build can never disagree with its tag. A
|
||||||
|
// build of the tagged commit is named after it ("0.4.0-beta"); anything later adds the commits
|
||||||
|
// since and the commit ("0.4.0-beta+3.ba4374e"), so a test build can't pass for a release.
|
||||||
|
// The version code is worked out from the numbers, ((major*100 + minor)*100 + patch)*10 + stage,
|
||||||
|
// where stage is 1 for alpha, 2 for beta and 9 for a finished release. It only ever goes up as
|
||||||
|
// long as minor and patch stay under 100. Changelog entries use the same number.
|
||||||
|
class GitVersion(val name: String, val code: Int)
|
||||||
|
|
||||||
|
val gitVersion: GitVersion = run {
|
||||||
|
val described = runCatching {
|
||||||
|
providers.exec {
|
||||||
|
commandLine("git", "describe", "--tags", "--match", "v*", "--abbrev=7")
|
||||||
|
isIgnoreExitValue = true
|
||||||
|
}.standardOutput.asText.get().trim()
|
||||||
|
}.getOrNull().orEmpty()
|
||||||
|
val match = Regex("""^v((\d+)\.(\d+)\.(\d+)(?:-([A-Za-z]+))?)(?:-(\d+)-g([0-9a-f]+))?$""").find(described)
|
||||||
|
?: return@run GitVersion("0.0.0-dev", 1) // no git or no tags, e.g. a source download
|
||||||
|
val (base, major, minor, patch, suffix, since, commit) = match.destructured
|
||||||
|
val stage = when (suffix.lowercase()) {
|
||||||
|
"" -> 9
|
||||||
|
"beta" -> 2
|
||||||
|
"alpha" -> 1
|
||||||
|
else -> 0
|
||||||
|
}
|
||||||
|
GitVersion(
|
||||||
|
name = if (since.isEmpty()) base else "$base+$since.$commit",
|
||||||
|
code = ((major.toInt() * 100 + minor.toInt()) * 100 + patch.toInt()) * 10 + stage,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
// Keeps build output out of the module folder: <root>/build/gearslip/... instead of
|
// Keeps build output out of the module folder: <root>/build/gearslip/... instead of
|
||||||
// <root>/gearslip/build/..., so everything this project produces lands under one
|
// <root>/gearslip/build/..., so everything this project produces lands under one
|
||||||
// root-level build/ directory. Set before android {}, which works out the path to R8's default
|
// root-level build/ directory. Set before android {}, which works out the path to R8's default
|
||||||
@@ -38,9 +68,9 @@ android {
|
|||||||
applicationId = "app.seb3thehacker.gearslip"
|
applicationId = "app.seb3thehacker.gearslip"
|
||||||
minSdk = 31
|
minSdk = 31
|
||||||
targetSdk = 37
|
targetSdk = 37
|
||||||
versionCode = 12
|
versionCode = gitVersion.code
|
||||||
// -PgearslipVersionName=0.1.99-test makes a build that thinks it's older, to try the updater.
|
// -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() ?: gitVersion.name
|
||||||
manifestPlaceholders["appLabel"] = "Gearslip"
|
manifestPlaceholders["appLabel"] = "Gearslip"
|
||||||
// Where opted-in phones send their daily usage note: the stats-worker/ Worker's address,
|
// 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.
|
// set as gearslipStatsUrl in gradle.properties. Left empty, nothing is ever sent.
|
||||||
@@ -74,6 +104,9 @@ android {
|
|||||||
applicationIdSuffix = ".dev"
|
applicationIdSuffix = ".dev"
|
||||||
signingConfigs.findByName("release")?.let { signingConfig = it }
|
signingConfigs.findByName("release")?.let { signingConfig = it }
|
||||||
manifestPlaceholders["appLabel"] = "Gearslip Debug"
|
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
|
// 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.
|
// rule missing for release shows up here first). -PnoR8 skips it for a quicker build.
|
||||||
if (!project.hasProperty("noR8")) {
|
if (!project.hasProperty("noR8")) {
|
||||||
@@ -88,17 +121,41 @@ android {
|
|||||||
buildConfig = true
|
buildConfig = true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Lists each language that has a translation in the app's locale config, so Android 13+
|
||||||
|
// offers it under the system's per-app languages. English is the unqualified default
|
||||||
|
// (res/resources.properties).
|
||||||
|
//
|
||||||
|
// Only languages that are mostly translated ship, so the app never comes up half in English.
|
||||||
|
// Danish and Estonian are still early on Weblate; add them here once they're close to done.
|
||||||
|
androidResources {
|
||||||
|
generateLocaleConfig = true
|
||||||
|
localeFilters += listOf(
|
||||||
|
"en", "ar", "cs", "de", "es", "fr", "it", "ja", "ko", "nl", "pl", "pt-rBR", "ro", "ru",
|
||||||
|
"sv", "tr", "uk", "zh-rCN",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
lint {
|
||||||
|
// Weblate fills languages in a string at a time; the rest fall back to English.
|
||||||
|
disable += "MissingTranslation"
|
||||||
|
}
|
||||||
|
|
||||||
compileOptions {
|
compileOptions {
|
||||||
sourceCompatibility = JavaVersion.VERSION_21
|
sourceCompatibility = JavaVersion.VERSION_21
|
||||||
targetCompatibility = JavaVersion.VERSION_21
|
targetCompatibility = JavaVersion.VERSION_21
|
||||||
}
|
}
|
||||||
|
|
||||||
defaultConfig {
|
defaultConfig {
|
||||||
// Speech-to-text (sherpa-onnx, in jniLibs) ships for 64-bit ARM only: every phone
|
// Speech-to-text (sherpa-onnx 1.13.8, static-link-onnxruntime build, in jniLibs) is the
|
||||||
// Gearslip runs on, and one copy of the native library instead of four.
|
// only native code. One APK for all three, since the updater takes the release's only APK;
|
||||||
ndk { abiFilters += "arm64-v8a" }
|
// 32-bit x86 is left out, nothing that drives a car runs it.
|
||||||
|
ndk { abiFilters += listOf("arm64-v8a", "armeabi-v7a", "x86_64") }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Compressed, the three copies cost a 33 MB download instead of 75 MB; the phone unpacks
|
||||||
|
// only its own at install.
|
||||||
|
packaging { jniLibs { useLegacyPackaging = true } }
|
||||||
|
|
||||||
testOptions {
|
testOptions {
|
||||||
// GearslipLog calls android.util.Log, which is a stub on the local JVM.
|
// GearslipLog calls android.util.Log, which is a stub on the local JVM.
|
||||||
unitTests.isReturnDefaultValues = true
|
unitTests.isReturnDefaultValues = true
|
||||||
@@ -139,6 +196,10 @@ dependencies {
|
|||||||
implementation(libs.androidx.car.app)
|
implementation(libs.androidx.car.app)
|
||||||
implementation(libs.androidx.car.app.projected)
|
implementation(libs.androidx.car.app.projected)
|
||||||
implementation(libs.androidx.media)
|
implementation(libs.androidx.media)
|
||||||
|
// Document-start scripts, so the ad blocker hides ad slots before a page first paints.
|
||||||
|
implementation(libs.androidx.webkit)
|
||||||
|
// The map behind a place list (PlaceListMapTemplate), which the host draws, not the app.
|
||||||
|
implementation(libs.maplibre.android)
|
||||||
testImplementation(libs.junit)
|
testImplementation(libs.junit)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,8 @@
|
|||||||
<!-- The projected WebView loads real pages; without this every load fails silently. -->
|
<!-- The projected WebView loads real pages; without this every load fails silently. -->
|
||||||
<uses-permission android:name="android.permission.INTERNET" />
|
<uses-permission android:name="android.permission.INTERNET" />
|
||||||
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
|
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
|
||||||
|
<!-- Lets an update Gearslip installed itself skip Android's prompt. -->
|
||||||
|
<uses-permission android:name="android.permission.UPDATE_PACKAGES_WITHOUT_USER_ACTION" />
|
||||||
|
|
||||||
<!--
|
<!--
|
||||||
Phone mirroring. Android refuses to hand over a MediaProjection unless a foreground
|
Phone mirroring. Android refuses to hand over a MediaProjection unless a foreground
|
||||||
@@ -19,6 +21,8 @@
|
|||||||
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
|
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
|
||||||
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
|
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
|
||||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_LOCATION" />
|
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_LOCATION" />
|
||||||
|
<!-- Keeps a wireless start going with the phone locked; see WirelessKeepAlive. -->
|
||||||
|
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_CONNECTED_DEVICE" />
|
||||||
|
|
||||||
<!--
|
<!--
|
||||||
Wireless Android Auto: Bluetooth reaches the paired car's Android Auto service, which
|
Wireless Android Auto: Bluetooth reaches the paired car's Android Auto service, which
|
||||||
@@ -62,7 +66,8 @@
|
|||||||
|
|
||||||
<!--
|
<!--
|
||||||
Phone calls on the car screen. READ_PHONE_STATE and READ_CALL_LOG together are what a
|
Phone calls on the car screen. READ_PHONE_STATE and READ_CALL_LOG together are what a
|
||||||
call's own state (ringing, in progress) and the incoming number need; READ_CONTACTS
|
call's own state (ringing, in progress) and the incoming number need, and READ_CALL_LOG
|
||||||
|
also lists recent calls on the car's Phone screen, read on the phone only; READ_CONTACTS
|
||||||
turns that number into a name; ANSWER_PHONE_CALLS lets the driver pick up from here.
|
turns that number into a name; ANSWER_PHONE_CALLS lets the driver pick up from here.
|
||||||
Declining a call, and the caller ID itself, need Gearslip set as the phone's caller ID
|
Declining a call, and the caller ID itself, need Gearslip set as the phone's caller ID
|
||||||
and spam app too - an on-device consent, not a permission declared here.
|
and spam app too - an on-device consent, not a permission declared here.
|
||||||
@@ -126,6 +131,7 @@
|
|||||||
android:roundIcon="@mipmap/ic_launcher"
|
android:roundIcon="@mipmap/ic_launcher"
|
||||||
android:label="${appLabel}"
|
android:label="${appLabel}"
|
||||||
android:supportsRtl="true"
|
android:supportsRtl="true"
|
||||||
|
android:manageSpaceActivity="app.seb3thehacker.gearslip.ui.ManageSpaceActivity"
|
||||||
android:theme="@style/Theme.Gearslip">
|
android:theme="@style/Theme.Gearslip">
|
||||||
|
|
||||||
<provider
|
<provider
|
||||||
@@ -157,11 +163,52 @@
|
|||||||
|
|
||||||
</activity>
|
</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
|
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
|
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.
|
the head unit to Android Auto, without disabling the screen the switch is on.
|
||||||
-->
|
-->
|
||||||
|
<!-- App info › Storage › Manage space: clears lyrics, voice models and logs one at a time. -->
|
||||||
|
<!-- "Share" a page from any browser to add it to the car browser's start page (issue #85). -->
|
||||||
|
<activity
|
||||||
|
android:name=".browser.ShareToBrowserActivity"
|
||||||
|
android:exported="true"
|
||||||
|
android:excludeFromRecents="true"
|
||||||
|
android:label="@string/browser_share_label"
|
||||||
|
android:noHistory="true"
|
||||||
|
android:theme="@android:style/Theme.Translucent.NoTitleBar">
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="android.intent.action.SEND" />
|
||||||
|
<category android:name="android.intent.category.DEFAULT" />
|
||||||
|
<data android:mimeType="text/plain" />
|
||||||
|
</intent-filter>
|
||||||
|
</activity>
|
||||||
|
|
||||||
|
<activity
|
||||||
|
android:name=".ui.ManageSpaceActivity"
|
||||||
|
android:exported="true"
|
||||||
|
android:label="Gearslip storage" />
|
||||||
|
|
||||||
<activity
|
<activity
|
||||||
android:name=".PermissionActivity"
|
android:name=".PermissionActivity"
|
||||||
android:exported="false"
|
android:exported="false"
|
||||||
@@ -187,6 +234,14 @@
|
|||||||
android:name=".notify.TestMessageReceiver"
|
android:name=".notify.TestMessageReceiver"
|
||||||
android:exported="false" />
|
android:exported="false" />
|
||||||
|
|
||||||
|
<receiver
|
||||||
|
android:name=".update.UpdateReceiver"
|
||||||
|
android:exported="false">
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="android.intent.action.MY_PACKAGE_REPLACED" />
|
||||||
|
</intent-filter>
|
||||||
|
</receiver>
|
||||||
|
|
||||||
<service
|
<service
|
||||||
android:name=".mirror.MirrorService"
|
android:name=".mirror.MirrorService"
|
||||||
android:exported="false"
|
android:exported="false"
|
||||||
@@ -197,6 +252,11 @@
|
|||||||
android:exported="false"
|
android:exported="false"
|
||||||
android:foregroundServiceType="location" />
|
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
|
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.
|
the microphone, so voice replies can hear the driver with the phone locked.
|
||||||
|
|||||||
@@ -4,12 +4,36 @@ package app.seb3thehacker.gearslip
|
|||||||
* What changed in each release, newest first, shown on the car screen after an update (and on
|
* What changed in each release, newest first, shown on the car screen after an update (and on
|
||||||
* the phone). Only the changes a driver would notice. Plain words, short lines: this is read in
|
* the phone). Only the changes a driver would notice. Plain words, short lines: this is read in
|
||||||
* a parked car.
|
* a parked car.
|
||||||
|
*
|
||||||
|
* After 0.3.40, the version code is the one the build works out from the tag (see gitVersion in
|
||||||
|
* build.gradle.kts): ((major*100 + minor)*100 + patch)*10 + stage, so 0.4.0-beta is 4002. Older
|
||||||
|
* entries keep the hand-set codes they shipped with, which are all lower.
|
||||||
*/
|
*/
|
||||||
object Changelog {
|
object Changelog {
|
||||||
|
|
||||||
class Release(val versionCode: Int, val versionName: String, val changes: List<String>)
|
class Release(val versionCode: Int, val versionName: String, val changes: List<String>)
|
||||||
|
|
||||||
val releases = listOf(
|
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(
|
Release(
|
||||||
9, "0.3.00-alpha",
|
9, "0.3.00-alpha",
|
||||||
listOf(
|
listOf(
|
||||||
|
|||||||
@@ -0,0 +1,226 @@
|
|||||||
|
package app.seb3thehacker.gearslip
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.os.Handler
|
||||||
|
import android.os.Looper
|
||||||
|
import app.seb3thehacker.gearslip.car.ClusterScreen
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gearslip's picture on the car's instrument-cluster screen: a second video sink, for a car that
|
||||||
|
* offers one, showing the turn card ([ClusterScreen]). The same exchange as the main screen's,
|
||||||
|
* on its own channel, with its own encoder and its own private display:
|
||||||
|
*
|
||||||
|
* -> CHANNEL_OPEN_REQUEST <- CHANNEL_OPEN_RESPONSE
|
||||||
|
* -> Setup { H.264 } <- Config { status, max_unacked, configuration_indices }
|
||||||
|
* -> VideoFocusRequest <- VideoFocusNotification { projected }
|
||||||
|
* -> Start, CodecConfig, Data... <- Ack
|
||||||
|
*
|
||||||
|
* No touch: a cluster has none. Experimental, behind [car.CarSettings.clusterVideoEnabled].
|
||||||
|
*
|
||||||
|
* Gearslip holds the cluster only while there's a route, as the card has nothing else to show.
|
||||||
|
* Otherwise the car keeps its own picture there: an Audi's virtual cockpit went black instead of
|
||||||
|
* showing its map (issue #56). [watchRoute] asks for the screen when a route starts and hands it
|
||||||
|
* back once there has been none for [NO_ROUTE_MS].
|
||||||
|
*/
|
||||||
|
internal class ClusterVideo(
|
||||||
|
private val context: Context,
|
||||||
|
private val service: ServiceDiscovery.VideoService,
|
||||||
|
private val send: (messageId: Int, body: ByteArray) -> Unit,
|
||||||
|
) {
|
||||||
|
private val log = GearslipLog.tagged("CLUSTER")
|
||||||
|
private val main = Handler(Looper.getMainLooper())
|
||||||
|
|
||||||
|
val channelId get() = service.serviceId
|
||||||
|
|
||||||
|
private var configIndex = 0
|
||||||
|
@Volatile private var started = false
|
||||||
|
/** Configured and ready for focus requests. */
|
||||||
|
@Volatile private var ready = false
|
||||||
|
/** The car shows Gearslip's picture now; frames aren't sent while it shows its own. */
|
||||||
|
@Volatile private var projected = false
|
||||||
|
/** Gearslip has asked for the screen and not handed it back since. */
|
||||||
|
private var wanted = false
|
||||||
|
private var noRouteSince = 0L
|
||||||
|
@Volatile private var running = true
|
||||||
|
@Volatile private var source: VideoSource? = null
|
||||||
|
private var projector: ScreenProjector? = null
|
||||||
|
|
||||||
|
private val ackLock = Object()
|
||||||
|
@Volatile private var inFlight = 0
|
||||||
|
@Volatile private var maxUnacked = 1
|
||||||
|
@Volatile private var lastAckAt = 0L
|
||||||
|
private var frames = 0
|
||||||
|
/** The part of the picture the car's own gauges and bars leave clear, as the car last said. */
|
||||||
|
private val clearArea = MutableStateFlow<Insets?>(null)
|
||||||
|
|
||||||
|
fun onMessage(messageId: Int, body: ByteArray) {
|
||||||
|
when (messageId) {
|
||||||
|
GearslipRunner.MSG_CHANNEL_OPEN_RESPONSE -> {
|
||||||
|
val status = Protobuf.readInt32Field(body, 1) ?: 0
|
||||||
|
log.i("<- ChannelOpenResponse: status=$status")
|
||||||
|
if (status != 0) return
|
||||||
|
send(GearslipRunner.MSG_MEDIA_SETUP, Protobuf.varintField(1, GearslipRunner.CODEC_H264_BP.toLong()))
|
||||||
|
log.i("-> Setup(codec=VIDEO_H264_BP)")
|
||||||
|
}
|
||||||
|
|
||||||
|
GearslipRunner.MSG_MEDIA_CONFIG -> {
|
||||||
|
val fields = Wire.fields(body)
|
||||||
|
Wire.varint(fields, 2)?.toInt()?.let { maxUnacked = maxOf(1, it) }
|
||||||
|
val offered = fields.filter { it.number == 3 && it.wireType == 0 }.map { it.varint.toInt() }
|
||||||
|
// The cluster gets the smallest picture it offers: a turn card needs no more.
|
||||||
|
configIndex = offered.minByOrNull { service.configs.getOrNull(it)?.pixelCount() ?: Int.MAX_VALUE } ?: 0
|
||||||
|
log.i("<- Config status=${Wire.varint(fields, 1)} max_unacked=$maxUnacked offered=$offered - using $configIndex")
|
||||||
|
ready = true
|
||||||
|
main.post(watchRoute)
|
||||||
|
}
|
||||||
|
|
||||||
|
GearslipRunner.MSG_VIDEO_FOCUS_NOTIFICATION -> {
|
||||||
|
val mode = Wire.varint(Wire.fields(body), 1)?.toInt() ?: GearslipRunner.VIDEO_FOCUS_PROJECTED
|
||||||
|
log.i("<- VideoFocusNotification: mode=$mode")
|
||||||
|
val now = mode == GearslipRunner.VIDEO_FOCUS_PROJECTED || mode == GearslipRunner.VIDEO_FOCUS_PROJECTED_NO_INPUT
|
||||||
|
val back = now && !projected
|
||||||
|
projected = now
|
||||||
|
if (now && !started) startStream()
|
||||||
|
// Back from the car's own picture: a fresh key frame, so it doesn't start on a partial one.
|
||||||
|
else if (back) source?.requestSyncFrame()
|
||||||
|
}
|
||||||
|
|
||||||
|
GearslipRunner.MSG_MEDIA_ACK -> {
|
||||||
|
val acked = Wire.varint(Wire.fields(body), 2)?.toInt() ?: 1
|
||||||
|
synchronized(ackLock) {
|
||||||
|
inFlight = maxOf(0, inFlight - maxOf(1, acked))
|
||||||
|
lastAckAt = System.currentTimeMillis()
|
||||||
|
ackLock.notifyAll()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateUiConfigRequest { AdditionalVideoConfig { display_insets = 1 } }: an Audi's
|
||||||
|
// virtual cockpit sends one as the picture starts, and draws its dials over the frame.
|
||||||
|
GearslipRunner.MSG_VIDEO_UI_CONFIG -> {
|
||||||
|
log.i("<- UpdateUiConfigRequest (${body.size} bytes)\n${Protobuf.describe(body)}")
|
||||||
|
clearArea.value = insetsFrom(body)
|
||||||
|
}
|
||||||
|
|
||||||
|
else -> log.i("<- unhandled message id=$messageId (${body.size} bytes)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Once a second: the cluster follows whether there's a route. */
|
||||||
|
private val watchRoute = object : Runnable {
|
||||||
|
override fun run() {
|
||||||
|
if (!running || !ready) return
|
||||||
|
val route = app.seb3thehacker.gearslip.car.NavTurn.current() != null
|
||||||
|
val now = System.currentTimeMillis()
|
||||||
|
if (route) noRouteSince = 0L else if (noRouteSince == 0L) noRouteSince = now
|
||||||
|
when {
|
||||||
|
route && !wanted -> requestFocus(GearslipRunner.VIDEO_FOCUS_PROJECTED, "a route started")
|
||||||
|
!route && wanted && now - noRouteSince >= NO_ROUTE_MS -> requestFocus(GearslipRunner.VIDEO_FOCUS_NATIVE, "no route")
|
||||||
|
}
|
||||||
|
main.postDelayed(this, WATCH_MS)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun requestFocus(mode: Int, why: String) {
|
||||||
|
wanted = mode == GearslipRunner.VIDEO_FOCUS_PROJECTED
|
||||||
|
// VideoFocusRequestNotification { disp_channel_id = 1; mode = 2; reason = 3 }, as the main
|
||||||
|
// screen asks for its own picture back.
|
||||||
|
val reason = if (wanted) 1L else GearslipRunner.VIDEO_FOCUS_REASON_LAUNCH_NATIVE.toLong()
|
||||||
|
val body = Protobuf.varintField(1, channelId.toLong()) + Protobuf.varintField(2, mode.toLong()) + Protobuf.varintField(3, reason)
|
||||||
|
runCatching { send(GearslipRunner.MSG_VIDEO_FOCUS_REQUEST, body) }
|
||||||
|
log.i("-> VideoFocusRequest(mode=${if (wanted) "PROJECTED" else "NATIVE"}): $why")
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun startStream() {
|
||||||
|
val config = service.configs.getOrNull(configIndex) ?: service.configs.firstOrNull() ?: return
|
||||||
|
val (width, height) = config.pixelSize() ?: run {
|
||||||
|
log.w("unsupported cluster resolution ${config.resolutionName}")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
started = true
|
||||||
|
send(
|
||||||
|
GearslipRunner.MSG_MEDIA_START,
|
||||||
|
Protobuf.varintField(1, GearslipRunner.SESSION_ID.toLong()) + Protobuf.varintField(2, configIndex.toLong()),
|
||||||
|
)
|
||||||
|
log.i("-> Start(config=$configIndex -> $config)")
|
||||||
|
lastAckAt = System.currentTimeMillis()
|
||||||
|
val video = VideoSource(
|
||||||
|
width = width,
|
||||||
|
height = height,
|
||||||
|
frameRate = FRAME_RATE,
|
||||||
|
mode = VideoSource.Mode.SURFACE,
|
||||||
|
wireless = true, // a small share of the link: the main screen comes first
|
||||||
|
onCodecConfig = { csd ->
|
||||||
|
send(GearslipRunner.MSG_MEDIA_CODEC_CONFIG, csd)
|
||||||
|
log.i("-> CodecConfig (${csd.size} bytes)")
|
||||||
|
},
|
||||||
|
onFrame = { data, pts, _ -> sendFrame(data, pts) },
|
||||||
|
)
|
||||||
|
source = video
|
||||||
|
runCatching { video.start() }.onFailure {
|
||||||
|
log.e("cluster encoder failed to start", it)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
val surface = video.inputSurface ?: return
|
||||||
|
val density = config.density.takeIf { it > 0 } ?: 160
|
||||||
|
main.post {
|
||||||
|
if (!running) return@post
|
||||||
|
projector = ScreenProjector(context).also {
|
||||||
|
it.start(surface, width, height, density, name = "gearslip-cluster") {
|
||||||
|
// The margins the car crops off are kept clear, as on the main screen.
|
||||||
|
ClusterScreen(config.widthMargin, config.heightMargin, density, clearArea)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** As on the main screen: wait for the car's acks rather than drop a frame. */
|
||||||
|
private fun sendFrame(data: ByteArray, presentationTimeUs: Long) {
|
||||||
|
synchronized(ackLock) {
|
||||||
|
while (running && inFlight >= maxUnacked) {
|
||||||
|
val since = System.currentTimeMillis() - lastAckAt
|
||||||
|
if (since > GearslipRunner.ACK_STALL_MS) {
|
||||||
|
inFlight = 0
|
||||||
|
lastAckAt = System.currentTimeMillis()
|
||||||
|
break
|
||||||
|
}
|
||||||
|
ackLock.wait(GearslipRunner.ACK_STALL_MS - since + 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!running || !projected) return
|
||||||
|
val timestamp = ByteArray(8)
|
||||||
|
for (i in 0 until 8) timestamp[i] = ((presentationTimeUs shr ((7 - i) * 8)) and 0xFF).toByte()
|
||||||
|
synchronized(ackLock) { inFlight++ }
|
||||||
|
runCatching { send(GearslipRunner.MSG_MEDIA_DATA, timestamp + data) }.onFailure { running = false }
|
||||||
|
if (++frames <= 2) log.i("-> Data frame #$frames (${data.size} bytes)")
|
||||||
|
}
|
||||||
|
|
||||||
|
/** VideoInsets { top = 1; bottom = 2; left = 3; right = 4 }, or null when the car sent none. */
|
||||||
|
private fun insetsFrom(body: ByteArray): Insets? {
|
||||||
|
val config = Wire.bytes(Wire.fields(body), 1) ?: return null
|
||||||
|
val insets = Wire.fields(Wire.bytes(Wire.fields(config), 1) ?: return null)
|
||||||
|
fun side(n: Int) = Wire.varint(insets, n)?.toInt() ?: 0
|
||||||
|
return Insets(top = side(1), bottom = side(2), left = side(3), right = side(4))
|
||||||
|
.also { log.i("the car keeps clear: $it") }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun stop() {
|
||||||
|
running = false
|
||||||
|
main.removeCallbacks(watchRoute)
|
||||||
|
synchronized(ackLock) { ackLock.notifyAll() }
|
||||||
|
main.post {
|
||||||
|
projector?.stop()
|
||||||
|
projector = null
|
||||||
|
}
|
||||||
|
source?.stop()
|
||||||
|
source = null
|
||||||
|
}
|
||||||
|
|
||||||
|
private companion object {
|
||||||
|
// A turn card changes a few times a minute; the encoder repeats the last frame between.
|
||||||
|
const val FRAME_RATE = 15
|
||||||
|
const val WATCH_MS = 1_000L
|
||||||
|
/** A gap in the turns this long is the route over, not a reroute or a moment between steps. */
|
||||||
|
const val NO_ROUTE_MS = 10_000L
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -118,6 +118,10 @@ class GearslipActivity : ComponentActivity(), Projection {
|
|||||||
|
|
||||||
/** Android's one-time "Allow" for a car to connect by itself; see [WirelessAuto]. */
|
/** Android's one-time "Allow" for a car to connect by itself; see [WirelessAuto]. */
|
||||||
private var approvingCar: String? = null
|
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(
|
private val carApproval = registerForActivityResult(
|
||||||
androidx.activity.result.contract.ActivityResultContracts.StartIntentSenderForResult(),
|
androidx.activity.result.contract.ActivityResultContracts.StartIntentSenderForResult(),
|
||||||
) { result ->
|
) { result ->
|
||||||
@@ -147,6 +151,7 @@ class GearslipActivity : ComponentActivity(), Projection {
|
|||||||
app.seb3thehacker.gearslip.wireless.WirelessLink.onConnected = { socket ->
|
app.seb3thehacker.gearslip.wireless.WirelessLink.onConnected = { socket ->
|
||||||
runOnUiThread { startWireless(socket) }
|
runOnUiThread { startWireless(socket) }
|
||||||
}
|
}
|
||||||
|
app.seb3thehacker.gearslip.wireless.WirelessLink.sessionWaiting = { runner?.waitingForVersion == true }
|
||||||
app.seb3thehacker.gearslip.wireless.WirelessAuto.showApproval = { chooser, address ->
|
app.seb3thehacker.gearslip.wireless.WirelessAuto.showApproval = { chooser, address ->
|
||||||
runOnUiThread {
|
runOnUiThread {
|
||||||
approvingCar = address
|
approvingCar = address
|
||||||
@@ -154,6 +159,10 @@ class GearslipActivity : ComponentActivity(), Projection {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
CarSettings.init(this)
|
CarSettings.init(this)
|
||||||
|
// Before any car connects: CarServices only starts once the car shows Gearslip's screen,
|
||||||
|
// and a car that waits to know the phone before it shows it would never hear (issue #43).
|
||||||
|
app.seb3thehacker.gearslip.car.CarBluetooth.init(this)
|
||||||
|
app.seb3thehacker.gearslip.browser.BrowserSettings.init(this)
|
||||||
AppSettings.applyGearslipEnabled(this)
|
AppSettings.applyGearslipEnabled(this)
|
||||||
// A capture that died without tidying up would leave notifications exposed to every
|
// A capture that died without tidying up would leave notifications exposed to every
|
||||||
// later screen share. Nothing is capturing if the service is not running, so undo it.
|
// later screen share. Nothing is capturing if the service is not running, so undo it.
|
||||||
@@ -223,6 +232,16 @@ class GearslipActivity : ComponentActivity(), Projection {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override fun onResume() {
|
||||||
|
super.onResume()
|
||||||
|
shownOnPhone = true
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onStop() {
|
||||||
|
super.onStop()
|
||||||
|
shownOnPhone = false
|
||||||
|
}
|
||||||
|
|
||||||
override fun onNewIntent(intent: Intent) {
|
override fun onNewIntent(intent: Intent) {
|
||||||
super.onNewIntent(intent)
|
super.onNewIntent(intent)
|
||||||
handleIntent(intent)
|
handleIntent(intent)
|
||||||
@@ -231,6 +250,7 @@ class GearslipActivity : ComponentActivity(), Projection {
|
|||||||
override fun onDestroy() {
|
override fun onDestroy() {
|
||||||
super.onDestroy()
|
super.onDestroy()
|
||||||
app.seb3thehacker.gearslip.wireless.WirelessLink.onConnected = null
|
app.seb3thehacker.gearslip.wireless.WirelessLink.onConnected = null
|
||||||
|
app.seb3thehacker.gearslip.wireless.WirelessLink.sessionWaiting = null
|
||||||
app.seb3thehacker.gearslip.wireless.WirelessAuto.showApproval = null
|
app.seb3thehacker.gearslip.wireless.WirelessAuto.showApproval = null
|
||||||
runCatching { unregisterReceiver(detachReceiver) }
|
runCatching { unregisterReceiver(detachReceiver) }
|
||||||
PhoneMirror.onStartRequested = null
|
PhoneMirror.onStartRequested = null
|
||||||
@@ -296,7 +316,8 @@ class GearslipActivity : ComponentActivity(), Projection {
|
|||||||
val target = surface ?: return
|
val target = surface ?: return
|
||||||
if (screenProjector != null) return
|
if (screenProjector != null) return
|
||||||
val projector = ScreenProjector(this).also { screenProjector = it }
|
val projector = ScreenProjector(this).also { screenProjector = it }
|
||||||
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 +341,10 @@ class GearslipActivity : ComponentActivity(), Projection {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private fun handleIntent(intent: Intent?) {
|
private fun handleIntent(intent: Intent?) {
|
||||||
|
// Debug builds only: opens the audio choice for a look, without turning down a real prompt.
|
||||||
|
if (BuildConfig.DEBUG && intent?.getBooleanExtra("show_audio_choice", false) == true) offerBluetooth.value = true
|
||||||
if (intent?.action == app.seb3thehacker.gearslip.wireless.WirelessAuto.ACTION_CAR_NEARBY) {
|
if (intent?.action == app.seb3thehacker.gearslip.wireless.WirelessAuto.ACTION_CAR_NEARBY) {
|
||||||
|
stepAsideOnVideo = !shownOnPhone
|
||||||
connectToNearbyCar(intent.getStringExtra(app.seb3thehacker.gearslip.wireless.WirelessAuto.EXTRA_ADDRESS))
|
connectToNearbyCar(intent.getStringExtra(app.seb3thehacker.gearslip.wireless.WirelessAuto.EXTRA_ADDRESS))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -338,6 +362,7 @@ class GearslipActivity : ComponentActivity(), Projection {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
GearslipLog.i("accessory attached by the system - permission is implicit on this path")
|
GearslipLog.i("accessory attached by the system - permission is implicit on this path")
|
||||||
|
stepAsideOnVideo = !shownOnPhone
|
||||||
start(accessory)
|
start(accessory)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -399,9 +424,11 @@ class GearslipActivity : ComponentActivity(), Projection {
|
|||||||
identityProvider = { CertProvider.load(this) },
|
identityProvider = { CertProvider.load(this) },
|
||||||
projection = this,
|
projection = this,
|
||||||
vehicleProfileFor = { info -> VehicleProfiles.find(VehicleProfiles.load(this), info) },
|
vehicleProfileFor = { info -> VehicleProfiles.find(VehicleProfiles.load(this), info) },
|
||||||
|
phoneIcons = { PhoneIcons.pngs(this) },
|
||||||
)
|
)
|
||||||
this.runner = runner
|
this.runner = runner
|
||||||
worker = Thread { runner.run() }.apply { name = "gearslip"; start() }
|
worker = Thread { runner.run() }.apply { name = "gearslip"; start() }
|
||||||
|
holdLocation()
|
||||||
}
|
}
|
||||||
|
|
||||||
/** A wireless session: the same runner, on the socket [WirelessLink] opened to the car. */
|
/** A wireless session: the same runner, on the socket [WirelessLink] opened to the car. */
|
||||||
@@ -421,9 +448,25 @@ class GearslipActivity : ComponentActivity(), Projection {
|
|||||||
projection = this,
|
projection = this,
|
||||||
vehicleProfileFor = { info -> VehicleProfiles.find(VehicleProfiles.load(this), info) },
|
vehicleProfileFor = { info -> VehicleProfiles.find(VehicleProfiles.load(this), info) },
|
||||||
wireless = true,
|
wireless = true,
|
||||||
|
phoneIcons = { PhoneIcons.pngs(this) },
|
||||||
)
|
)
|
||||||
this.runner = runner
|
this.runner = runner
|
||||||
worker = Thread { runner.run() }.apply { name = "gearslip"; start() }
|
worker = Thread { runner.run() }.apply { name = "gearslip"; start() }
|
||||||
|
holdLocation()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Whether this session holds [app.seb3thehacker.gearslip.host.LocationKeepAlive]. */
|
||||||
|
private var holdingLocation = false
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Android lets a foreground service started from the background use location only in the few
|
||||||
|
* seconds after one of the app's screens opens. The car just opened this one, so take the
|
||||||
|
* location service now: a map bound later, with the phone already locked, got no position.
|
||||||
|
*/
|
||||||
|
private fun holdLocation() {
|
||||||
|
if (holdingLocation) return
|
||||||
|
holdingLocation = true
|
||||||
|
app.seb3thehacker.gearslip.host.LocationKeepAlive.acquire(this) {}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun describe(accessory: UsbAccessory) {
|
private fun describe(accessory: UsbAccessory) {
|
||||||
@@ -443,10 +486,27 @@ class GearslipActivity : ComponentActivity(), Projection {
|
|||||||
wirelessSocket = null
|
wirelessSocket = null
|
||||||
if (stopWireless) app.seb3thehacker.gearslip.wireless.WirelessLink.stop(this)
|
if (stopWireless) app.seb3thehacker.gearslip.wireless.WirelessLink.stop(this)
|
||||||
runner = null
|
runner = null
|
||||||
|
if (holdingLocation) {
|
||||||
|
holdingLocation = false
|
||||||
|
app.seb3thehacker.gearslip.host.LocationKeepAlive.release(this)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Projection ---------------------------------------------------------------------
|
// --- Projection ---------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The car opens Gearslip on the phone, since Android hands a USB car only to an activity and
|
||||||
|
* auto-connect has to start one too. Once the car's screen is up, go back to whatever the
|
||||||
|
* phone showed, as Home would; the session carries on as it does with the phone locked. A
|
||||||
|
* question for the driver keeps Gearslip in front, and so does the phone already showing it.
|
||||||
|
*/
|
||||||
|
private fun stepAside() {
|
||||||
|
stepAsideOnVideo = false
|
||||||
|
if (offerBluetooth.value || micBlockedNotice.value || approvingCar != null) return
|
||||||
|
GearslipLog.i("the car's screen is up - moving Gearslip off the phone's screen")
|
||||||
|
moveTaskToBack(true)
|
||||||
|
}
|
||||||
|
|
||||||
override fun onSurfaceReady(surface: Surface, width: Int, height: Int, densityDpi: Int) {
|
override fun onSurfaceReady(surface: Surface, width: Int, height: Int, densityDpi: Int) {
|
||||||
runOnUiThread {
|
runOnUiThread {
|
||||||
this.surface = surface
|
this.surface = surface
|
||||||
@@ -456,6 +516,7 @@ class GearslipActivity : ComponentActivity(), Projection {
|
|||||||
CarEnvironment.setDisplay(width, height, densityDpi)
|
CarEnvironment.setDisplay(width, height, densityDpi)
|
||||||
CarEnvironment.start(this)
|
CarEnvironment.start(this)
|
||||||
showCarUi()
|
showCarUi()
|
||||||
|
if (stepAsideOnVideo) stepAside()
|
||||||
app.seb3thehacker.gearslip.car.CarKeys.focusSink = { code, down ->
|
app.seb3thehacker.gearslip.car.CarKeys.focusSink = { code, down ->
|
||||||
runOnUiThread { screenProjector?.dispatchKey(code, down) }
|
runOnUiThread { screenProjector?.dispatchKey(code, down) }
|
||||||
}
|
}
|
||||||
@@ -472,6 +533,10 @@ class GearslipActivity : ComponentActivity(), Projection {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override fun onStill() {
|
||||||
|
screenProjector?.redraw()
|
||||||
|
}
|
||||||
|
|
||||||
override fun onProjectionStopped() {
|
override fun onProjectionStopped() {
|
||||||
// Before the car UI goes, while the media connection is still up.
|
// Before the car UI goes, while the media connection is still up.
|
||||||
app.seb3thehacker.gearslip.car.CarServices.pausePhoneMedia()
|
app.seb3thehacker.gearslip.car.CarServices.pausePhoneMedia()
|
||||||
|
|||||||
@@ -47,6 +47,8 @@ object GearslipLog {
|
|||||||
val version: StateFlow<Int> = liveVersion
|
val version: StateFlow<Int> = liveVersion
|
||||||
|
|
||||||
fun init(context: Context) {
|
fun init(context: Context) {
|
||||||
|
// The phone app and the launcher both start here; one file per process.
|
||||||
|
if (file != null) return
|
||||||
val dir = context.getExternalFilesDir(null) ?: return
|
val dir = context.getExternalFilesDir(null) ?: return
|
||||||
val name = SimpleDateFormat("yyyyMMdd-HHmmss", Locale.US).format(Date())
|
val name = SimpleDateFormat("yyyyMMdd-HHmmss", Locale.US).format(Date())
|
||||||
file = File(dir, "gearslip-$name.log")
|
file = File(dir, "gearslip-$name.log")
|
||||||
@@ -54,9 +56,65 @@ object GearslipLog {
|
|||||||
SessionReport.init(context)
|
SessionReport.init(context)
|
||||||
// One file per launch adds up; the newest are the ones anyone asks for.
|
// One file per launch adds up; the newest are the ones anyone asks for.
|
||||||
io.execute { logFiles(dir).drop(KEEP_FILES).forEach { it.delete() } }
|
io.execute { logFiles(dir).drop(KEEP_FILES).forEach { it.delete() } }
|
||||||
|
lastExit(context, dir)
|
||||||
}
|
}
|
||||||
|
|
||||||
private const val KEEP_FILES = 30
|
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> =
|
private fun logFiles(dir: File): List<File> =
|
||||||
dir.listFiles { f -> f.isFile && f.name.startsWith("gearslip-") && f.name.endsWith(".log") }
|
dir.listFiles { f -> f.isFile && f.name.startsWith("gearslip-") && f.name.endsWith(".log") }
|
||||||
@@ -77,7 +135,7 @@ object GearslipLog {
|
|||||||
PastLog(
|
PastLog(
|
||||||
file = f,
|
file = f,
|
||||||
started = runCatching { parse.parse(f.name.removePrefix("gearslip-").removeSuffix(".log")) }.getOrNull(),
|
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),
|
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
|
* 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.
|
* system's own handler run. Without it the file ends mid-sentence and the reason is lost.
|
||||||
*/
|
*/
|
||||||
|
@Volatile private var crashHandlerInstalled = false
|
||||||
|
|
||||||
fun installCrashHandler() {
|
fun installCrashHandler() {
|
||||||
|
if (crashHandlerInstalled) return
|
||||||
|
crashHandlerInstalled = true
|
||||||
val previous = Thread.getDefaultUncaughtExceptionHandler()
|
val previous = Thread.getDefaultUncaughtExceptionHandler()
|
||||||
Thread.setDefaultUncaughtExceptionHandler { thread, throwable ->
|
Thread.setDefaultUncaughtExceptionHandler { thread, throwable ->
|
||||||
runCatching {
|
runCatching {
|
||||||
|
|||||||
@@ -48,6 +48,8 @@ class GearslipRunner(
|
|||||||
private val vehicleProfileFor: (ServiceDiscovery.HeadUnitInfo) -> VehicleProfile? = { null },
|
private val vehicleProfileFor: (ServiceDiscovery.HeadUnitInfo) -> VehicleProfile? = { null },
|
||||||
/** Over Wi-Fi rather than USB: the video is sent smaller so the link keeps up. */
|
/** Over Wi-Fi rather than USB: the video is sent smaller so the link keeps up. */
|
||||||
private val wireless: Boolean = false,
|
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")
|
private val log = GearslipLog.tagged("PROTO")
|
||||||
|
|
||||||
@@ -56,6 +58,8 @@ class GearslipRunner(
|
|||||||
private val parser = Frames.Parser()
|
private val parser = Frames.Parser()
|
||||||
private val assembler = Frames.Assembler()
|
private val assembler = Frames.Assembler()
|
||||||
private lateinit var identityTask: FutureTask<PhoneTls>
|
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]. */
|
/** 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 {
|
private val writer = java.util.concurrent.Executors.newSingleThreadExecutor {
|
||||||
@@ -70,6 +74,9 @@ class GearslipRunner(
|
|||||||
@Volatile private var stopped = false
|
@Volatile private var stopped = false
|
||||||
|
|
||||||
@Volatile private var state = State.WAIT_VERSION
|
@Volatile private var state = State.WAIT_VERSION
|
||||||
|
|
||||||
|
/** Connected, but the car hasn't sent its first message yet. */
|
||||||
|
val waitingForVersion: Boolean get() = running && state == State.WAIT_VERSION
|
||||||
@Volatile private var lastProgress = System.currentTimeMillis()
|
@Volatile private var lastProgress = System.currentTimeMillis()
|
||||||
@Volatile private var running = true
|
@Volatile private var running = true
|
||||||
|
|
||||||
@@ -78,6 +85,8 @@ class GearslipRunner(
|
|||||||
private var selectedConfigIndex: Int? = null
|
private var selectedConfigIndex: Int? = null
|
||||||
private var vehicleProfile: VehicleProfile? = null
|
private var vehicleProfile: VehicleProfile? = null
|
||||||
@Volatile private var videoStarted = false
|
@Volatile private var videoStarted = false
|
||||||
|
/** The car's screen has been asked for once, so asking again is in order. */
|
||||||
|
@Volatile private var focusAsked = false
|
||||||
private var videoSource: VideoSource? = null
|
private var videoSource: VideoSource? = null
|
||||||
|
|
||||||
/** Between the car UI and the encoder when the car squashes the frame (see [screenShape]). */
|
/** Between the car UI and the encoder when the car squashes the frame (see [screenShape]). */
|
||||||
@@ -96,11 +105,15 @@ class GearslipRunner(
|
|||||||
@Volatile private var inputKeycodes: List<Int> = emptyList()
|
@Volatile private var inputKeycodes: List<Int> = emptyList()
|
||||||
@Volatile private var sensorChannelId: Int = -1
|
@Volatile private var sensorChannelId: Int = -1
|
||||||
@Volatile private var mediaStatus: CarMediaStatus? = null
|
@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
|
@Volatile private var clusterNav: ClusterNav? = null
|
||||||
private var sensorTypes: List<Int> = emptyList()
|
private var sensorTypes: List<Int> = emptyList()
|
||||||
@Volatile private var audioLink: AudioLink? = null
|
@Volatile private var audioLink: AudioLink? = null
|
||||||
@Volatile private var guidanceLink: AudioLink? = null
|
@Volatile private var guidanceLink: AudioLink? = null
|
||||||
@Volatile private var vendorChannelId = -1
|
@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 audioMessagesSeen = 0
|
||||||
private var touchWidth = 0
|
private var touchWidth = 0
|
||||||
private var touchHeight = 0
|
private var touchHeight = 0
|
||||||
@@ -126,6 +139,7 @@ class GearslipRunner(
|
|||||||
identityTask = FutureTask {
|
identityTask = FutureTask {
|
||||||
val identity = identityProvider()
|
val identity = identityProvider()
|
||||||
val cert = identity.certificate
|
val cert = identity.certificate
|
||||||
|
phoneCert = cert
|
||||||
log.i("phone certificate source: ${identity.source}")
|
log.i("phone certificate source: ${identity.source}")
|
||||||
SessionReport.certificate(identity.source.toString(), cert.issuerX500Principal.toString())
|
SessionReport.certificate(identity.source.toString(), cert.issuerX500Principal.toString())
|
||||||
log.i(" subject = ${cert.subjectX500Principal}")
|
log.i(" subject = ${cert.subjectX500Principal}")
|
||||||
@@ -156,10 +170,15 @@ class GearslipRunner(
|
|||||||
} catch (t: Throwable) {
|
} catch (t: Throwable) {
|
||||||
if (stopped) return
|
if (stopped) return
|
||||||
log.e("transport failed in state $state", t)
|
log.e("transport failed in state $state", t)
|
||||||
SessionReport.fail(SessionReport.Category.USB, "transport failed", t, state.name)
|
val link = if (wireless) SessionReport.Category.WIFI else SessionReport.Category.USB
|
||||||
|
SessionReport.fail(link, "transport failed", t, state.name)
|
||||||
reportDisconnect()
|
reportDisconnect()
|
||||||
} finally {
|
} finally {
|
||||||
running = false
|
running = false
|
||||||
|
// The car is gone, but nothing calls stop() when a wireless session drops: it waits
|
||||||
|
// for the next one. Close the music channel now, so the audio capture behind it,
|
||||||
|
// and the screen sharing Android shows for it, stop too (issue #43).
|
||||||
|
if (!stopped) audioLink?.close()
|
||||||
writer.shutdown()
|
writer.shutdown()
|
||||||
if (drainWriterOnExit) writer.awaitTermination(2, TimeUnit.SECONDS)
|
if (drainWriterOnExit) writer.awaitTermination(2, TimeUnit.SECONDS)
|
||||||
writer.shutdownNow()
|
writer.shutdownNow()
|
||||||
@@ -184,12 +203,18 @@ class GearslipRunner(
|
|||||||
guidanceLink?.close()
|
guidanceLink?.close()
|
||||||
guidanceLink = null
|
guidanceLink = null
|
||||||
CarSensors.clear()
|
CarSensors.clear()
|
||||||
|
app.seb3thehacker.gearslip.car.CarData.clear()
|
||||||
CarMic.current = null
|
CarMic.current = null
|
||||||
CarBluetooth.onCarDisconnected()
|
CarBluetooth.onCarDisconnected()
|
||||||
|
CarSettings.carDisconnected()
|
||||||
BluetoothAudioCheck.onCarDisconnected()
|
BluetoothAudioCheck.onCarDisconnected()
|
||||||
LocationCheck.onCarDisconnected()
|
LocationCheck.onCarDisconnected()
|
||||||
mediaStatus?.stop()
|
mediaStatus?.stop()
|
||||||
mediaStatus = null
|
mediaStatus = null
|
||||||
|
phoneStatus?.stop()
|
||||||
|
phoneStatus = null
|
||||||
|
clusterVideo?.stop()
|
||||||
|
clusterVideo = null
|
||||||
clusterNav?.stop()
|
clusterNav?.stop()
|
||||||
clusterNav = null
|
clusterNav = null
|
||||||
lastSensorNight = null
|
lastSensorNight = null
|
||||||
@@ -237,12 +262,18 @@ class GearslipRunner(
|
|||||||
clusterNav?.onMessage(messageId, body)
|
clusterNav?.onMessage(messageId, body)
|
||||||
} else if (frame.channel == mediaStatus?.channelId) {
|
} else if (frame.channel == mediaStatus?.channelId) {
|
||||||
mediaStatus?.onMessage(messageId, body)
|
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) {
|
} else if (frame.channel == guidanceLink?.channelId) {
|
||||||
guidanceLink?.onMessage(messageId, body)
|
guidanceLink?.onMessage(messageId, body)
|
||||||
} else if (frame.channel == CarBluetooth.channelId) {
|
} else if (frame.channel == CarBluetooth.channelId) {
|
||||||
CarBluetooth.onMessage(messageId, body)
|
CarBluetooth.onMessage(messageId, body)
|
||||||
} else if (frame.channel == vendorChannelId) {
|
} else if (frame.channel == vendorChannelId) {
|
||||||
onVendorMessage(messageId, body)
|
onVendorMessage(messageId, body)
|
||||||
|
} else if (frame.channel == wifiChannelId) {
|
||||||
|
onWifiProjectionMessage(messageId, body)
|
||||||
} else if (frame.channel == CarMic.current?.channelId) {
|
} else if (frame.channel == CarMic.current?.channelId) {
|
||||||
CarMic.current?.onMessage(messageId, body)
|
CarMic.current?.onMessage(messageId, body)
|
||||||
} else if (frame.channel == audioLink?.channelId) {
|
} else if (frame.channel == audioLink?.channelId) {
|
||||||
@@ -272,6 +303,7 @@ class GearslipRunner(
|
|||||||
MSG_AUTH_COMPLETE -> onAuthComplete(body)
|
MSG_AUTH_COMPLETE -> onAuthComplete(body)
|
||||||
MSG_SERVICE_DISCOVERY_RESPONSE -> onServiceDiscoveryResponse(body)
|
MSG_SERVICE_DISCOVERY_RESPONSE -> onServiceDiscoveryResponse(body)
|
||||||
MSG_PING_REQUEST -> onPingRequest(body)
|
MSG_PING_REQUEST -> onPingRequest(body)
|
||||||
|
MSG_PING_RESPONSE -> onPingResponse()
|
||||||
MSG_BYEBYE_REQUEST -> onByeByeRequest(body)
|
MSG_BYEBYE_REQUEST -> onByeByeRequest(body)
|
||||||
MSG_UNEXPECTED_MESSAGE, MSG_FRAMING_ERROR -> onProtocolError(messageId)
|
MSG_UNEXPECTED_MESSAGE, MSG_FRAMING_ERROR -> onProtocolError(messageId)
|
||||||
AudioLink.MSG_FOCUS_RESPONSE -> {
|
AudioLink.MSG_FOCUS_RESPONSE -> {
|
||||||
@@ -393,8 +425,7 @@ class GearslipRunner(
|
|||||||
}
|
}
|
||||||
-2, -23, -24 -> {
|
-2, -23, -24 -> {
|
||||||
val reason = when (status) {
|
val reason = when (status) {
|
||||||
-23 -> "The phone's certificate is not valid yet."
|
-23, -24 -> certificateDates(status)
|
||||||
-24 -> "The phone's certificate has expired."
|
|
||||||
else -> "The head unit refused the phone's certificate."
|
else -> "The head unit refused the phone's certificate."
|
||||||
}
|
}
|
||||||
SessionReport.fail(SessionReport.Category.CERTIFICATE, "AuthComplete status $status", state.name, "auth $status")
|
SessionReport.fail(SessionReport.Category.CERTIFICATE, "AuthComplete status $status", state.name, "auth $status")
|
||||||
@@ -435,11 +466,44 @@ class GearslipRunner(
|
|||||||
if (authenticationFailed) running = false
|
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() {
|
private fun sendServiceDiscoveryRequest() {
|
||||||
val body = Protobuf.stringField(4, "Gearslip") + // label_text
|
val icons = runCatching(phoneIcons).getOrDefault(emptyList())
|
||||||
Protobuf.stringField(5, Build.MODEL) // device_name
|
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)
|
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) {
|
private fun onServiceDiscoveryResponse(body: ByteArray) {
|
||||||
@@ -458,10 +522,12 @@ class GearslipRunner(
|
|||||||
"possible against THIS unit - portability to newer units is still unproven.",
|
"possible against THIS unit - portability to newer units is still unproven.",
|
||||||
)
|
)
|
||||||
state = State.DONE
|
state = State.DONE
|
||||||
|
startPhonePings()
|
||||||
val info = ServiceDiscovery.findHeadUnitInfo(body)
|
val info = ServiceDiscovery.findHeadUnitInfo(body)
|
||||||
log.i("head unit: $info")
|
log.i("head unit: $info")
|
||||||
SessionReport.headUnit(info.toString())
|
SessionReport.headUnit(info.toString())
|
||||||
SessionReport.headUnitInfo = info
|
SessionReport.headUnitInfo = info
|
||||||
|
app.seb3thehacker.gearslip.car.CarData.onModel(info.carModel, info.carYear)
|
||||||
vehicleProfile = runCatching { vehicleProfileFor(info) }.getOrNull()
|
vehicleProfile = runCatching { vehicleProfileFor(info) }.getOrNull()
|
||||||
val profile = vehicleProfile
|
val profile = vehicleProfile
|
||||||
SessionReport.profile(profile?.name)
|
SessionReport.profile(profile?.name)
|
||||||
@@ -512,6 +578,8 @@ class GearslipRunner(
|
|||||||
startSensorChannel(serviceDiscoveryResponse)
|
startSensorChannel(serviceDiscoveryResponse)
|
||||||
startMicChannel(serviceDiscoveryResponse)
|
startMicChannel(serviceDiscoveryResponse)
|
||||||
startMediaStatusChannel(serviceDiscoveryResponse)
|
startMediaStatusChannel(serviceDiscoveryResponse)
|
||||||
|
startPhoneStatusChannel(serviceDiscoveryResponse)
|
||||||
|
startClusterVideo(serviceDiscoveryResponse)
|
||||||
startClusterNavChannel(serviceDiscoveryResponse)
|
startClusterNavChannel(serviceDiscoveryResponse)
|
||||||
startBluetooth(serviceDiscoveryResponse)
|
startBluetooth(serviceDiscoveryResponse)
|
||||||
startVendorChannel(serviceDiscoveryResponse)
|
startVendorChannel(serviceDiscoveryResponse)
|
||||||
@@ -563,8 +631,13 @@ class GearslipRunner(
|
|||||||
// The driver picked the radio or another source on the car: pause the phone's music
|
// 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.
|
// as Android Auto does, instead of letting it play on to no one.
|
||||||
onTakenBack = { CarServices.pausePhoneMedia("the car switched to another source") },
|
onTakenBack = { CarServices.pausePhoneMedia("the car switched to another source") },
|
||||||
|
onPausedForNow = { CarServices.pausePhoneMediaForNow() },
|
||||||
|
onGivenBack = { CarServices.resumePhoneMedia() },
|
||||||
)
|
)
|
||||||
if (CarSettings.pipeAudio.value) {
|
// A car known to switch Bluetooth music off gets it through Gearslip, whatever the setting.
|
||||||
|
CarSettings.carConnected(ServiceDiscovery.findBluetoothService(serviceDiscoveryResponse)?.carAddress)
|
||||||
|
if (CarSettings.pipesNow) {
|
||||||
|
if (!CarSettings.pipeAudio.value) log.i("this car turns Bluetooth music off, so music goes through Gearslip")
|
||||||
openChannel(media.serviceId)
|
openChannel(media.serviceId)
|
||||||
} else {
|
} else {
|
||||||
// Many cars switch their source to Android Auto the moment this channel opens, which
|
// Many cars switch their source to Android Auto the moment this channel opens, which
|
||||||
@@ -592,7 +665,9 @@ class GearslipRunner(
|
|||||||
tag = "guidance",
|
tag = "guidance",
|
||||||
sessionId = GUIDANCE_SESSION_ID,
|
sessionId = GUIDANCE_SESSION_ID,
|
||||||
asksFocus = false,
|
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) },
|
onClose = { DirectionsVoice.detach(it) },
|
||||||
)
|
)
|
||||||
openChannel(guidance.serviceId)
|
openChannel(guidance.serviceId)
|
||||||
@@ -628,6 +703,32 @@ class GearslipRunner(
|
|||||||
openChannel(channel)
|
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. */
|
/** 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
|
* GM's own channel ("com.gm.vip"), opened in GM cars only. Issue #18: a 2021 Bolt ends every
|
||||||
@@ -642,6 +743,23 @@ class GearslipRunner(
|
|||||||
vendorChannelId = gm.serviceId
|
vendorChannelId = gm.serviceId
|
||||||
log.i("-> ChannelOpenRequest on GM's channel ${gm.serviceId}")
|
log.i("-> ChannelOpenRequest on GM's channel ${gm.serviceId}")
|
||||||
openChannel(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) {
|
private fun onVendorMessage(messageId: Int, body: ByteArray) {
|
||||||
@@ -661,6 +779,13 @@ class GearslipRunner(
|
|||||||
bluetooth,
|
bluetooth,
|
||||||
open = { openChannel(bluetooth.serviceId) },
|
open = { openChannel(bluetooth.serviceId) },
|
||||||
send = { id, body -> send(id, body, encrypted = true, channel = bluetooth.serviceId) },
|
send = { id, body -> send(id, body, encrypted = true, channel = bluetooth.serviceId) },
|
||||||
|
// A car keeping its screen until it knows the phone may give it up now; ask again.
|
||||||
|
answered = {
|
||||||
|
if (running && focusAsked && !videoStarted) {
|
||||||
|
log.i("the car answered on Bluetooth while keeping its screen - asking for it again")
|
||||||
|
requestProjectedFocus(withDisplay = true)
|
||||||
|
}
|
||||||
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -766,6 +891,7 @@ class GearslipRunner(
|
|||||||
|
|
||||||
MSG_SENSOR_EVENT_INDICATION -> {
|
MSG_SENSOR_EVENT_INDICATION -> {
|
||||||
CarSensors.onEvent(body)
|
CarSensors.onEvent(body)
|
||||||
|
app.seb3thehacker.gearslip.car.CarData.onEvent(body)
|
||||||
logSensorEvent(body)
|
logSensorEvent(body)
|
||||||
onNightSensor(body)
|
onNightSensor(body)
|
||||||
}
|
}
|
||||||
@@ -954,11 +1080,8 @@ class GearslipRunner(
|
|||||||
"${videoConfigs.getOrNull(selectedConfigIndex ?: -1)?.resolutionName ?: "?"} " +
|
"${videoConfigs.getOrNull(selectedConfigIndex ?: -1)?.resolutionName ?: "?"} " +
|
||||||
"(head unit offered ${offered.size}), max_unacked=$maxUnacked",
|
"(head unit offered ${offered.size}), max_unacked=$maxUnacked",
|
||||||
)
|
)
|
||||||
// VideoFocusRequestNotification { mode = 2; reason = 3 }
|
requestProjectedFocus()
|
||||||
val focus = Protobuf.varintField(2, VIDEO_FOCUS_PROJECTED.toLong()) +
|
watchForWithheldScreen()
|
||||||
Protobuf.varintField(3, 1L)
|
|
||||||
send(MSG_VIDEO_FOCUS_REQUEST, focus, encrypted = true, channel = videoChannelId)
|
|
||||||
log.i("-> VideoFocusRequest(mode=PROJECTED)")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
MSG_VIDEO_FOCUS_NOTIFICATION -> {
|
MSG_VIDEO_FOCUS_NOTIFICATION -> {
|
||||||
@@ -989,6 +1112,16 @@ class GearslipRunner(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// UpdateUiConfigRequest { AdditionalVideoConfig }: new margins or day/night. A LuChi
|
||||||
|
// box sent it as video began, showed our first frame, then black (issue #58), as if
|
||||||
|
// it rebuilt its decoder. A rebuilt decoder needs SPS/PPS and a keyframe again.
|
||||||
|
MSG_VIDEO_UI_CONFIG -> {
|
||||||
|
log.i("<- UpdateUiConfigRequest (${body.size} bytes) - sending the stream's start again")
|
||||||
|
log.hex(" ui config", body, limit = 64)
|
||||||
|
lastCodecConfig?.let { send(MSG_MEDIA_CODEC_CONFIG, it, encrypted = true, channel = videoChannelId) }
|
||||||
|
videoSource?.requestSyncFrame()
|
||||||
|
}
|
||||||
|
|
||||||
else -> log.i("<- unhandled video message id=$messageId (${body.size} bytes)")
|
else -> log.i("<- unhandled video message id=$messageId (${body.size} bytes)")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1111,6 +1244,7 @@ class GearslipRunner(
|
|||||||
onFrame = { data, presentationTimeUs, keyFrame ->
|
onFrame = { data, presentationTimeUs, keyFrame ->
|
||||||
sendVideoFrame(data, presentationTimeUs, keyFrame)
|
sendVideoFrame(data, presentationTimeUs, keyFrame)
|
||||||
},
|
},
|
||||||
|
onStill = { projection?.onStill() },
|
||||||
).also { source ->
|
).also { source ->
|
||||||
runCatching {
|
runCatching {
|
||||||
source.start()
|
source.start()
|
||||||
@@ -1234,6 +1368,55 @@ 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) {
|
||||||
|
focusAsked = true
|
||||||
|
// 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.
|
||||||
|
* This runs whether or not the media channel was open: a 2020 Civic with music through
|
||||||
|
* Gearslip opened it at connect, so on 0.3.40 it never heard the second request (issue #43).
|
||||||
|
* The car also hears which phone this is over Bluetooth (see [CarBluetooth.onScreenWithheld]),
|
||||||
|
* which Hondas may wait for; its answer asks for the screen once more.
|
||||||
|
*/
|
||||||
|
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()
|
||||||
|
} else {
|
||||||
|
log.w("no screen from the car after ${WITHHELD_SCREEN_MS / 1000}s - asking again")
|
||||||
|
}
|
||||||
|
val toldBluetooth = CarBluetooth.onScreenWithheld()
|
||||||
|
// Give the car the channels' open responses before it hears the request again.
|
||||||
|
if (open != null || toldBluetooth) Thread.sleep(1_000)
|
||||||
|
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. */
|
/** Android Auto's Exit: asks the car for its own interface, leaving the session running. */
|
||||||
private fun requestNativeFocus() {
|
private fun requestNativeFocus() {
|
||||||
if (!videoStarted) return
|
if (!videoStarted) return
|
||||||
@@ -1266,6 +1449,38 @@ class GearslipRunner(
|
|||||||
log.i("touch -> frame: ${draw + encode}ms (draw ${draw}ms, encode and wait ${encode}ms)")
|
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) {
|
private fun onPingRequest(body: ByteArray) {
|
||||||
// The timestamp is an int64 and has to come back unchanged. Read as an int32 it was cut
|
// 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.
|
// short, and a car that checks the echo (an Audi MIB2+) gave up at its second ping.
|
||||||
@@ -1359,6 +1574,9 @@ class GearslipRunner(
|
|||||||
SessionReport.print()
|
SessionReport.print()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private val startedAt = System.currentTimeMillis()
|
||||||
|
private var toldStuck = false
|
||||||
|
|
||||||
private fun startWatchdog() {
|
private fun startWatchdog() {
|
||||||
Thread {
|
Thread {
|
||||||
var lastFrames = 0
|
var lastFrames = 0
|
||||||
@@ -1370,6 +1588,11 @@ class GearslipRunner(
|
|||||||
val idle = System.currentTimeMillis() - lastProgress
|
val idle = System.currentTimeMillis() - lastProgress
|
||||||
if (idle > 10_000) log.w("stalled in state $state for ${idle / 1000}s")
|
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
|
// Stream health. Without this a drive cannot tell "streaming fine" from
|
||||||
// "stalled after three frames" - the per-frame logging is deliberately sparse.
|
// "stalled after three frames" - the per-frame logging is deliberately sparse.
|
||||||
@@ -1458,10 +1681,13 @@ class GearslipRunner(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private companion object {
|
internal companion object {
|
||||||
/** The stats window adaptive bitrate reads, matching the watchdog's sleep. */
|
/** The stats window adaptive bitrate reads, matching the watchdog's sleep. */
|
||||||
const val ADAPT_WINDOW_MS = 5_000L
|
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
|
// protobuf/aap_protobuf/service/control/ControlMessageType.proto:7-17
|
||||||
const val MSG_VERSION_REQUEST = 1
|
const val MSG_VERSION_REQUEST = 1
|
||||||
const val MSG_VERSION_RESPONSE = 2
|
const val MSG_VERSION_RESPONSE = 2
|
||||||
@@ -1471,6 +1697,8 @@ class GearslipRunner(
|
|||||||
const val MSG_SERVICE_DISCOVERY_RESPONSE = 6
|
const val MSG_SERVICE_DISCOVERY_RESPONSE = 6
|
||||||
const val MSG_PING_REQUEST = 11
|
const val MSG_PING_REQUEST = 11
|
||||||
const val MSG_PING_RESPONSE = 12
|
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_REQUEST = 15
|
||||||
const val MSG_BYEBYE_RESPONSE = 16
|
const val MSG_BYEBYE_RESPONSE = 16
|
||||||
const val MSG_VOICE_SESSION_NOTIFICATION = 17
|
const val MSG_VOICE_SESSION_NOTIFICATION = 17
|
||||||
@@ -1489,6 +1717,7 @@ class GearslipRunner(
|
|||||||
const val MSG_MEDIA_ACK = 32772
|
const val MSG_MEDIA_ACK = 32772
|
||||||
const val MSG_VIDEO_FOCUS_REQUEST = 32775
|
const val MSG_VIDEO_FOCUS_REQUEST = 32775
|
||||||
const val MSG_VIDEO_FOCUS_NOTIFICATION = 32776
|
const val MSG_VIDEO_FOCUS_NOTIFICATION = 32776
|
||||||
|
const val MSG_VIDEO_UI_CONFIG = 32777
|
||||||
|
|
||||||
// aap_protobuf/service/sensor/... - same "first specific message on this channel type
|
// aap_protobuf/service/sensor/... - same "first specific message on this channel type
|
||||||
// starts at 0x8000" numbering as media/input above.
|
// starts at 0x8000" numbering as media/input above.
|
||||||
@@ -1515,6 +1744,7 @@ class GearslipRunner(
|
|||||||
const val ACTION_UP = 1
|
const val ACTION_UP = 1
|
||||||
|
|
||||||
const val ACK_STALL_MS = 2_000L
|
const val ACK_STALL_MS = 2_000L
|
||||||
|
const val WITHHELD_SCREEN_MS = 5_000L
|
||||||
const val AUDIO_TRACE_MESSAGES = 6
|
const val AUDIO_TRACE_MESSAGES = 6
|
||||||
const val GUIDANCE_SESSION_ID = 1
|
const val GUIDANCE_SESSION_ID = 1
|
||||||
const val PERIODIC_KEYFRAME_MS = 5_000L
|
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 }
|
||||||
|
}
|
||||||
@@ -8,7 +8,8 @@ object ProjectedContent {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Precedence: the URL saved in Settings, then a `url.txt` in the external files dir (the
|
* Precedence: the URL saved in Settings, then a `url.txt` in the external files dir (the
|
||||||
* pre-Settings way, still honoured so existing setups keep working), then [DEFAULT_URL].
|
* pre-Settings way, still honoured so existing setups keep working). Blank opens the
|
||||||
|
* browser's own start page.
|
||||||
*/
|
*/
|
||||||
fun resolve(context: Context): String {
|
fun resolve(context: Context): String {
|
||||||
val saved = AppSettings.startupUrl(context)
|
val saved = AppSettings.startupUrl(context)
|
||||||
@@ -25,12 +26,9 @@ object ProjectedContent {
|
|||||||
return url
|
return url
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return DEFAULT_URL
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
/** What the Web app opens until a page is chosen in Settings. */
|
|
||||||
const val DEFAULT_URL = "https://duckduckgo.com"
|
|
||||||
|
|
||||||
/** A bare "example.com" is a URL the user meant; raw HTML is passed through untouched. */
|
/** A bare "example.com" is a URL the user meant; raw HTML is passed through untouched. */
|
||||||
private fun normalize(value: String): String =
|
private fun normalize(value: String): String =
|
||||||
if (value.startsWith("<") || "://" in value) value else "https://$value"
|
if (value.startsWith("<") || "://" in value) value else "https://$value"
|
||||||
|
|||||||
@@ -24,6 +24,12 @@ interface Projection {
|
|||||||
fun onTouch(action: Int, actionIndex: Int, points: List<TouchPoint>)
|
fun onTouch(action: Int, actionIndex: Int, points: List<TouchPoint>)
|
||||||
|
|
||||||
fun onProjectionStopped()
|
fun onProjectionStopped()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The screen has been still for a moment and the encoder has sent nothing since. Draw it once
|
||||||
|
* more, so the car gets a fresh frame (see [VideoSource]'s onStill).
|
||||||
|
*/
|
||||||
|
fun onStill() {}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** One finger on the car's screen: a stable [id] for as long as it stays down, and where it is. */
|
/** One finger on the car's screen: a stable [id] for as long as it stays down, and where it is. */
|
||||||
|
|||||||
@@ -1,15 +1,18 @@
|
|||||||
package app.seb3thehacker.gearslip
|
package app.seb3thehacker.gearslip
|
||||||
|
|
||||||
import android.app.ActivityOptions
|
import android.app.ActivityOptions
|
||||||
|
import android.app.LocaleManager
|
||||||
import android.app.Presentation
|
import android.app.Presentation
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
|
import android.content.res.Configuration
|
||||||
import android.graphics.Color
|
import android.graphics.Color
|
||||||
import android.hardware.display.DisplayManager
|
import android.hardware.display.DisplayManager
|
||||||
import android.hardware.display.VirtualDisplay
|
import android.hardware.display.VirtualDisplay
|
||||||
import android.os.SystemClock
|
import android.os.SystemClock
|
||||||
import android.view.MotionEvent
|
import android.view.MotionEvent
|
||||||
import android.view.Surface
|
import android.view.Surface
|
||||||
|
import android.view.ContextThemeWrapper
|
||||||
import android.view.View
|
import android.view.View
|
||||||
import android.view.ViewGroup
|
import android.view.ViewGroup
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
@@ -96,7 +99,7 @@ class ScreenProjector(private val context: Context) {
|
|||||||
decor.setViewTreeViewModelStoreOwner(treeOwner)
|
decor.setViewTreeViewModelStoreOwner(treeOwner)
|
||||||
decor.setBackgroundColor(Color.BLACK)
|
decor.setBackgroundColor(Color.BLACK)
|
||||||
|
|
||||||
show.setContentView(ComposeView(show.context).apply { setContent(content) })
|
show.setContentView(ComposeView(withAppLanguage(show.context)).apply { setContent(content) })
|
||||||
show.show()
|
show.show()
|
||||||
|
|
||||||
presentation = show
|
presentation = show
|
||||||
@@ -106,6 +109,18 @@ class ScreenProjector(private val context: Context) {
|
|||||||
GearslipLog.i("projecting ${width}x$height @ ${densityDpi}dpi")
|
GearslipLog.i("projecting ${width}x$height @ ${densityDpi}dpi")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* [context] in the language the driver picked for Gearslip, when it differs from the phone's
|
||||||
|
* (issue #80). Android builds a virtual display's context from the system locale and leaves
|
||||||
|
* the per-app one out, so the car screen stayed in the phone's language.
|
||||||
|
*/
|
||||||
|
private fun withAppLanguage(context: Context): Context {
|
||||||
|
val locales = this.context.getSystemService(LocaleManager::class.java)?.applicationLocales
|
||||||
|
if (locales == null || locales.isEmpty) return context
|
||||||
|
val config = Configuration(context.resources.configuration).apply { setLocales(locales) }
|
||||||
|
return ContextThemeWrapper(context, context.theme).apply { applyOverrideConfiguration(config) }
|
||||||
|
}
|
||||||
|
|
||||||
/** The projected display's id, for placing other apps' activities on it. */
|
/** The projected display's id, for placing other apps' activities on it. */
|
||||||
val displayId: Int? get() = virtualDisplay?.display?.displayId
|
val displayId: Int? get() = virtualDisplay?.display?.displayId
|
||||||
|
|
||||||
@@ -223,6 +238,11 @@ class ScreenProjector(private val context: Context) {
|
|||||||
* Points the display at another surface, or none, without restarting what it shows. The
|
* 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.
|
* 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?) {
|
fun setSurface(surface: Surface?) {
|
||||||
virtualDisplay?.surface = surface
|
virtualDisplay?.surface = surface
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -51,8 +51,17 @@ object ServiceDiscovery {
|
|||||||
"$resolutionName @ $frameRateName, density=$density, margin=${widthMargin}x$heightMargin"
|
"$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)"
|
val codecName: String get() = CODECS[codecType] ?: "unknown($codecType)"
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -116,6 +125,12 @@ object ServiceDiscovery {
|
|||||||
*/
|
*/
|
||||||
fun findMediaStatusChannel(response: ByteArray): Int? = findChannelWith(response, 9)
|
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)
|
class NavStatusService(val serviceId: Int, val minimumIntervalMs: Int)
|
||||||
|
|
||||||
/** The cluster advertises its minimum update interval in NavigationStatusService field 1. */
|
/** The cluster advertises its minimum update interval in NavigationStatusService field 1. */
|
||||||
@@ -185,6 +200,13 @@ object ServiceDiscovery {
|
|||||||
VendorService(id, name)
|
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? {
|
fun findBluetoothService(response: ByteArray): BluetoothService? {
|
||||||
for (channel in Wire.allBytes(Wire.fields(response), 1)) {
|
for (channel in Wire.allBytes(Wire.fields(response), 1)) {
|
||||||
val service = Wire.fields(channel)
|
val service = Wire.fields(channel)
|
||||||
@@ -277,6 +299,19 @@ object ServiceDiscovery {
|
|||||||
return sinks.firstOrNull { it.displayId == 0 } ?: sinks.firstOrNull()
|
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 {
|
private fun videoServices(response: ByteArray): List<VideoService> = buildList {
|
||||||
for (channel in Wire.allBytes(Wire.fields(response), 1)) {
|
for (channel in Wire.allBytes(Wire.fields(response), 1)) {
|
||||||
val service = Wire.fields(channel)
|
val service = Wire.fields(channel)
|
||||||
@@ -298,7 +333,7 @@ object ServiceDiscovery {
|
|||||||
heightMargin = Wire.varint(f, 4)?.toInt() ?: 0,
|
heightMargin = Wire.varint(f, 4)?.toInt() ?: 0,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
add(VideoService(id, codecType, configs, Wire.varint(sinkFields, 6)?.toInt() ?: 0))
|
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) {
|
enum class Category(val label: String) {
|
||||||
NONE("none"),
|
NONE("none"),
|
||||||
USB("USB transport"),
|
USB("USB transport"),
|
||||||
|
WIFI("Wi-Fi transport"),
|
||||||
NO_HANDSHAKE("head unit never started talking"),
|
NO_HANDSHAKE("head unit never started talking"),
|
||||||
TLS("TLS handshake failed"),
|
TLS("TLS handshake failed"),
|
||||||
CERTIFICATE("certificate rejected by the head unit"),
|
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.
|
// switching the car off at the end shows up as a USB or ByeBye "failure" that isn't one.
|
||||||
val reached = carScreen.isNotEmpty()
|
val reached = carScreen.isNotEmpty()
|
||||||
val outcome = when {
|
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}"
|
reached -> "connected, then ${category.label}"
|
||||||
category == Category.NONE -> "ended before video"
|
category == Category.NONE -> "ended before video"
|
||||||
else -> category.label
|
else -> category.label
|
||||||
|
|||||||
@@ -14,7 +14,10 @@ object SessionStatus {
|
|||||||
|
|
||||||
enum class Phase { IDLE, CONNECTING, PROJECTING, FAILED, DISCONNECTED }
|
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(
|
private val flow = MutableStateFlow(
|
||||||
Snapshot(
|
Snapshot(
|
||||||
@@ -30,6 +33,12 @@ object SessionStatus {
|
|||||||
flow.value = Snapshot(Phase.CONNECTING, "Connecting", stage)
|
flow.value = Snapshot(Phase.CONNECTING, "Connecting", stage)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Still connecting after [GearslipRunner]'s patience ran out: Home says what to try. */
|
||||||
|
fun stillConnecting(link: Link) {
|
||||||
|
val now = flow.value
|
||||||
|
if (now.phase == Phase.CONNECTING) flow.value = now.copy(stuckOn = link)
|
||||||
|
}
|
||||||
|
|
||||||
fun projecting() {
|
fun projecting() {
|
||||||
flow.value = Snapshot(Phase.PROJECTING, "Projecting", "Streaming to the head unit.")
|
flow.value = Snapshot(Phase.PROJECTING, "Projecting", "Streaming to the head unit.")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -27,6 +27,12 @@ class VideoSource(
|
|||||||
private val wireless: Boolean = false,
|
private val wireless: Boolean = false,
|
||||||
private val onCodecConfig: (ByteArray) -> Unit,
|
private val onCodecConfig: (ByteArray) -> Unit,
|
||||||
private val onFrame: (data: ByteArray, presentationTimeUs: Long, keyFrame: Boolean) -> Unit,
|
private val onFrame: (data: ByteArray, presentationTimeUs: Long, keyFrame: Boolean) -> Unit,
|
||||||
|
/**
|
||||||
|
* SURFACE mode, nothing encoded for [STILL_MS]. The repeat setting below should cover a still
|
||||||
|
* screen, but on a Pixel 10 it didn't: a LuChi box got 11 frames, then none, and stayed black
|
||||||
|
* (issue #58). Whoever draws the screen can draw it again, and that is a frame.
|
||||||
|
*/
|
||||||
|
private val onStill: () -> Unit = {},
|
||||||
) {
|
) {
|
||||||
private val log = GearslipLog.tagged("VIDEO")
|
private val log = GearslipLog.tagged("VIDEO")
|
||||||
|
|
||||||
@@ -170,6 +176,8 @@ class VideoSource(
|
|||||||
private fun pump(encoder: MediaCodec) {
|
private fun pump(encoder: MediaCodec) {
|
||||||
val info = MediaCodec.BufferInfo()
|
val info = MediaCodec.BufferInfo()
|
||||||
val frameIntervalUs = 1_000_000L / frameRate
|
val frameIntervalUs = 1_000_000L / frameRate
|
||||||
|
var lastOutputAt = 0L
|
||||||
|
var lastNudgeAt = 0L
|
||||||
|
|
||||||
while (running) {
|
while (running) {
|
||||||
// SURFACE mode has no input buffers to fill - frames arrive from the display.
|
// SURFACE mode has no input buffers to fill - frames arrive from the display.
|
||||||
@@ -208,8 +216,16 @@ class VideoSource(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
encoder.releaseOutputBuffer(outIndex, false)
|
encoder.releaseOutputBuffer(outIndex, false)
|
||||||
|
lastOutputAt = System.currentTimeMillis()
|
||||||
outIndex = encoder.dequeueOutputBuffer(info, 0)
|
outIndex = encoder.dequeueOutputBuffer(info, 0)
|
||||||
}
|
}
|
||||||
|
if (mode == Mode.SURFACE && lastOutputAt > 0) {
|
||||||
|
val now = System.currentTimeMillis()
|
||||||
|
if (now - lastOutputAt > STILL_MS && now - lastNudgeAt > STILL_MS) {
|
||||||
|
lastNudgeAt = now
|
||||||
|
onStill()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (mode != Mode.SURFACE) Thread.sleep(maxOf(1L, frameIntervalUs / 2000))
|
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 MIME = MediaFormat.MIMETYPE_VIDEO_AVC
|
||||||
const val TIMEOUT_US = 10_000L
|
const val TIMEOUT_US = 10_000L
|
||||||
const val SURFACE_TIMEOUT_US = 100_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
|
* 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() }
|
runCatching { codec.release() }
|
||||||
}
|
}
|
||||||
|
|
||||||
private companion object {
|
companion object {
|
||||||
const val INPUT_WAIT_US = 10_000L
|
private const val INPUT_WAIT_US = 10_000L
|
||||||
val FREQUENCIES = listOf(96000, 88200, 64000, 48000, 44100, 32000, 24000, 22050, 16000, 12000, 11025, 8000, 7350)
|
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(),
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -157,6 +157,8 @@ class AudioCaptureService : Service() {
|
|||||||
val startedAt = heardAt
|
val startedAt = heardAt
|
||||||
var everHeard = false
|
var everHeard = false
|
||||||
var warnedSilent = false
|
var warnedSilent = false
|
||||||
|
var lastSound = startedAt
|
||||||
|
var silenceReported = false
|
||||||
try {
|
try {
|
||||||
record.startRecording()
|
record.startRecording()
|
||||||
GearslipLog.i("audio: capturing media playback at ${rate}Hz x$channels")
|
GearslipLog.i("audio: capturing media playback at ${rate}Hz x$channels")
|
||||||
@@ -174,6 +176,14 @@ class AudioCaptureService : Service() {
|
|||||||
}
|
}
|
||||||
if (peak > 0) everHeard = true
|
if (peak > 0) everHeard = true
|
||||||
val now = System.currentTimeMillis()
|
val now = System.currentTimeMillis()
|
||||||
|
// A stretch of silence: CarAudio says why on the car if a blocking app is playing.
|
||||||
|
if (peak > 0) {
|
||||||
|
lastSound = now
|
||||||
|
silenceReported = false
|
||||||
|
} else if (!silenceReported && now - lastSound >= SILENCE_MS) {
|
||||||
|
silenceReported = true
|
||||||
|
CarAudio.silent(this@AudioCaptureService)
|
||||||
|
}
|
||||||
val level = peak / 32768f
|
val level = peak / 32768f
|
||||||
if (now - heardAt >= LEVEL_LOG_MS) {
|
if (now - heardAt >= LEVEL_LOG_MS) {
|
||||||
GearslipLog.i("audio: peak %.3f".format(level))
|
GearslipLog.i("audio: peak %.3f".format(level))
|
||||||
|
|||||||
@@ -36,6 +36,9 @@ class AudioLink(
|
|||||||
private val onClose: (AudioLink) -> Unit = { CarAudio.detach(it) },
|
private val onClose: (AudioLink) -> Unit = { CarAudio.detach(it) },
|
||||||
/** The car took the sound back after giving it, as when the driver switches to the radio. */
|
/** The car took the sound back after giving it, as when the driver switches to the radio. */
|
||||||
private val onTakenBack: () -> Unit = {},
|
private val onTakenBack: () -> Unit = {},
|
||||||
|
/** The car wants the speakers for a moment (a call, its own voice), and then [onGivenBack]. */
|
||||||
|
private val onPausedForNow: () -> Unit = {},
|
||||||
|
private val onGivenBack: () -> Unit = {},
|
||||||
) : CarAudio.Link {
|
) : CarAudio.Link {
|
||||||
|
|
||||||
private var configIndex = 0
|
private var configIndex = 0
|
||||||
@@ -43,15 +46,36 @@ class AudioLink(
|
|||||||
@Volatile private var started = false
|
@Volatile private var started = false
|
||||||
@Volatile private var focusGranted = false
|
@Volatile private var focusGranted = false
|
||||||
@Volatile private var focusRequested = false
|
@Volatile private var focusRequested = false
|
||||||
|
/** The car took the speakers for a moment, and we paused the phone's music until it's done. */
|
||||||
|
@Volatile private var pausedForNow = false
|
||||||
@Volatile private var inFlight = 0
|
@Volatile private var inFlight = 0
|
||||||
// The Uconnect asks for 4 on its video channel; assume the same until its Config says otherwise.
|
// The Uconnect asks for 4 on its video channel; assume the same until its Config says otherwise.
|
||||||
@Volatile private var maxUnacked = 4
|
@Volatile private var maxUnacked = 4
|
||||||
@Volatile private var lastAckAt = 0L
|
@Volatile private var lastAckAt = 0L
|
||||||
|
/**
|
||||||
|
* Bytes of PCM per Data message, or 0 to send each capture read as it comes. Raised by
|
||||||
|
* [adaptChunkSize] for a car that acks fewer chunks than it plays.
|
||||||
|
*/
|
||||||
|
@Volatile private var chunkBytes = 0
|
||||||
|
/** Capture reads gathered until they fill [chunkBytes]. Only the capture thread touches it. */
|
||||||
|
private val pending = java.io.ByteArrayOutputStream()
|
||||||
|
/** Set by a new stream, so the capture thread drops what an older one left in [pending]. */
|
||||||
|
@Volatile private var pendingStale = false
|
||||||
|
/** The size of the last chunk sent, for [adaptChunkSize] to know what each one holds. */
|
||||||
|
@Volatile private var lastPayloadBytes = 0
|
||||||
|
/** Acks that came while the window was full, and the time they took: the car's own pace. */
|
||||||
|
private var slowAcks = 0
|
||||||
|
private var slowAckMs = 0L
|
||||||
|
private var lastAckFull = false
|
||||||
private var stalls = 0
|
private var stalls = 0
|
||||||
private var chunks = 0
|
private var chunks = 0
|
||||||
private var bytes = 0L
|
private var bytes = 0L
|
||||||
private var dropped = 0
|
private var dropped = 0
|
||||||
@Volatile private var configTimer: Thread? = null
|
@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()
|
private val ackLock = Object()
|
||||||
/** Only for a sink that takes AAC and nothing else; PCM sinks get the capture as it is. */
|
/** Only for a sink that takes AAC and nothing else; PCM sinks get the capture as it is. */
|
||||||
@Volatile private var aac: AacEncoder? = null
|
@Volatile private var aac: AacEncoder? = null
|
||||||
@@ -66,6 +90,9 @@ class AudioLink(
|
|||||||
/** Sending audio now, which for media means the car has handed us the speakers. */
|
/** Sending audio now, which for media means the car has handed us the speakers. */
|
||||||
val streaming get() = started
|
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 holdsSpeakers = false
|
||||||
@Volatile private var focusAnswer: CountDownLatch? = null
|
@Volatile private var focusAnswer: CountDownLatch? = null
|
||||||
@Volatile private var answerDueBy = 0L
|
@Volatile private var answerDueBy = 0L
|
||||||
@@ -104,6 +131,21 @@ class AudioLink(
|
|||||||
GearslipLog.i("$tag: -> AudioFocusRequest(RELEASE)")
|
GearslipLog.i("$tag: -> AudioFocusRequest(RELEASE)")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A spoken turn on the music channel while no music plays (see DirectionsVoice): the stream
|
||||||
|
* runs for the turn only, under ducking focus like the directions channel's, so the radio
|
||||||
|
* comes back after it rather than giving way to Android Auto for good.
|
||||||
|
*/
|
||||||
|
fun startForTurn() {
|
||||||
|
askForSpeakers()
|
||||||
|
startStream()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun stopForTurn() {
|
||||||
|
synchronized(startLock) { stopLocked(release = false) }
|
||||||
|
handBackSpeakers()
|
||||||
|
}
|
||||||
|
|
||||||
fun opened(status: Int) {
|
fun opened(status: Int) {
|
||||||
GearslipLog.i("$tag: <- ChannelOpenResponse: status=$status")
|
GearslipLog.i("$tag: <- ChannelOpenResponse: status=$status")
|
||||||
if (status != 0) {
|
if (status != 0) {
|
||||||
@@ -180,13 +222,25 @@ class AudioLink(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
MSG_UNDERFLOW -> onUnderflow()
|
||||||
|
|
||||||
MSG_ACK -> {
|
MSG_ACK -> {
|
||||||
val acked = Wire.varint(Wire.fields(body), 2)?.toInt() ?: 1
|
val acked = Wire.varint(Wire.fields(body), 2)?.toInt() ?: 1
|
||||||
|
val now = System.currentTimeMillis()
|
||||||
synchronized(ackLock) {
|
synchronized(ackLock) {
|
||||||
|
// A full window means the sender is waiting on this ack. When it was waiting on
|
||||||
|
// the one before too, the gap between them is the car's own pace.
|
||||||
|
val full = inFlight >= maxUnacked
|
||||||
|
if (full && lastAckFull) {
|
||||||
|
slowAcks++
|
||||||
|
slowAckMs += now - lastAckAt
|
||||||
|
}
|
||||||
|
lastAckFull = full
|
||||||
inFlight = maxOf(0, inFlight - maxOf(1, acked))
|
inFlight = maxOf(0, inFlight - maxOf(1, acked))
|
||||||
lastAckAt = System.currentTimeMillis()
|
lastAckAt = now
|
||||||
ackLock.notifyAll()
|
ackLock.notifyAll()
|
||||||
}
|
}
|
||||||
|
adaptChunkSize()
|
||||||
}
|
}
|
||||||
|
|
||||||
// Whatever else the head unit answers Setup with, we need its bytes to identify it.
|
// Whatever else the head unit answers Setup with, we need its bytes to identify it.
|
||||||
@@ -207,7 +261,7 @@ class AudioLink(
|
|||||||
fun onFocus(state: Int) {
|
fun onFocus(state: Int) {
|
||||||
val name = FOCUS_STATES[state] ?: "unknown($state)"
|
val name = FOCUS_STATES[state] ?: "unknown($state)"
|
||||||
GearslipLog.i("$tag: <- AudioFocusResponse state=$name")
|
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.
|
// Directions speak whatever the answer: a lost turn is worse than a loud one.
|
||||||
answerDueBy = 0L
|
answerDueBy = 0L
|
||||||
focusAnswer?.countDown()
|
focusAnswer?.countDown()
|
||||||
@@ -216,11 +270,88 @@ class AudioLink(
|
|||||||
val had = focusGranted
|
val had = focusGranted
|
||||||
focusGranted = state == FOCUS_STATE_GAIN || state == FOCUS_STATE_GAIN_TRANSIENT ||
|
focusGranted = state == FOCUS_STATE_GAIN || state == FOCUS_STATE_GAIN_TRANSIENT ||
|
||||||
state == FOCUS_STATE_GAIN_MEDIA_ONLY
|
state == FOCUS_STATE_GAIN_MEDIA_ONLY
|
||||||
if (focusGranted) startStream() else stop()
|
when {
|
||||||
// A ducking request leaves the music playing; a loss means another source has the speakers.
|
focusGranted -> {
|
||||||
if (had && (state == FOCUS_STATE_LOSS || state == FOCUS_STATE_LOSS_TRANSIENT)) onTakenBack()
|
startStream()
|
||||||
|
if (pausedForNow) {
|
||||||
|
pausedForNow = false
|
||||||
|
onGivenBack()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// A call or the car's own voice. Releasing focus here meant the car never handed it
|
||||||
|
// back, so the music stayed paused after the call: keep the claim and wait for GAIN.
|
||||||
|
state == FOCUS_STATE_LOSS_TRANSIENT -> {
|
||||||
|
synchronized(startLock) { stopLocked(release = false) }
|
||||||
|
if (had) {
|
||||||
|
pausedForNow = true
|
||||||
|
onPausedForNow()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else -> {
|
||||||
|
stop()
|
||||||
|
// A ducking request leaves the music playing; a loss means another source has the speakers.
|
||||||
|
if (state == FOCUS_STATE_LOSS && (had || pausedForNow)) {
|
||||||
|
pausedForNow = false
|
||||||
|
onTakenBack()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The car ran out of sound to play (AudioUnderflowNotification, no body): its buffer emptied
|
||||||
|
* before the next chunk arrived, which the driver hears as a gap. Capture runs in real time, so
|
||||||
|
* the car's buffer only ever holds what the window lets through. A little silence slipped in
|
||||||
|
* ahead of the next chunk puts it that far ahead, so the next slow patch is soaked up rather
|
||||||
|
* than heard. Capped, as each step adds a little delay to the music.
|
||||||
|
*/
|
||||||
|
private fun onUnderflow() {
|
||||||
|
underflows++
|
||||||
|
val step = if (started && padMs < MAX_PAD_MS) PAD_STEP_MS else 0
|
||||||
|
padMs += step
|
||||||
|
pendingPadMs += step
|
||||||
|
if (underflows <= 3 || underflows % 20 == 0) {
|
||||||
|
GearslipLog.w("$tag: <- AudioUnderflow ($underflows this drive) - running ${padMs}ms ahead now")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A 2020 Civic acks a chunk about every 83 ms whatever the chunk holds, so 21 ms chunks
|
||||||
|
* reached it at a quarter of real time: a second of music, then silence, over and over
|
||||||
|
* (issue #43). When the car keeps the sender waiting and its acks come much further apart
|
||||||
|
* than the audio in each chunk, gather the capture into chunks that last at least that gap,
|
||||||
|
* rounded up to a power of two in frames: 83 ms is about 4096 frames at 48 kHz, which looks
|
||||||
|
* like the car's own buffer. A car that keeps up never fills the window, so it never gets here.
|
||||||
|
*/
|
||||||
|
private fun adaptChunkSize() {
|
||||||
|
if (aac != null) return
|
||||||
|
val interval = synchronized(ackLock) {
|
||||||
|
if (slowAcks < ADAPT_ACKS) return
|
||||||
|
(slowAckMs.toDouble() / slowAcks).also {
|
||||||
|
slowAcks = 0
|
||||||
|
slowAckMs = 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val frame = channels * 2
|
||||||
|
val bytesPerMs = sampleRate * frame / 1000.0
|
||||||
|
val current = if (chunkBytes > 0) chunkBytes else lastPayloadBytes
|
||||||
|
if (current <= 0 || current >= MAX_CHUNK_BYTES) return
|
||||||
|
val heldMs = current / bytesPerMs
|
||||||
|
if (interval < heldMs * SLOW_ACK_RATIO) return
|
||||||
|
val gapFrames = Math.ceil(interval * sampleRate / 1000.0).toInt().coerceAtLeast(1)
|
||||||
|
val frames = Integer.highestOneBit(gapFrames).let { if (it < gapFrames) it shl 1 else it }
|
||||||
|
val target = minOf(MAX_CHUNK_BYTES / frame * frame, frames * frame)
|
||||||
|
if (target <= current) return
|
||||||
|
chunkBytes = target
|
||||||
|
GearslipLog.i(
|
||||||
|
"$tag: the car acks a chunk every ${interval.toInt()}ms, but each holds ${heldMs.toInt()}ms - " +
|
||||||
|
"sending $target-byte chunks (${(target / bytesPerMs).toInt()}ms) from now on",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Silence for [ms], whole frames of this sink's format. */
|
||||||
|
private fun silence(ms: Int): ByteArray = ByteArray(sampleRate * ms / 1000 * channels * 2)
|
||||||
|
|
||||||
override fun start() {
|
override fun start() {
|
||||||
if (!ready || started) return
|
if (!ready || started) return
|
||||||
if (asksFocus && !focusRequested) {
|
if (asksFocus && !focusRequested) {
|
||||||
@@ -251,8 +382,20 @@ class AudioLink(
|
|||||||
MSG_START,
|
MSG_START,
|
||||||
Protobuf.varintField(1, sessionId.toLong()) + Protobuf.varintField(2, configIndex.toLong()),
|
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
|
inFlight = 0
|
||||||
lastAckAt = System.currentTimeMillis()
|
lastAckAt = System.currentTimeMillis()
|
||||||
|
pendingStale = true
|
||||||
|
synchronized(ackLock) {
|
||||||
|
slowAcks = 0
|
||||||
|
slowAckMs = 0
|
||||||
|
lastAckFull = false
|
||||||
|
}
|
||||||
if (aacSink && aac == null) {
|
if (aacSink && aac == null) {
|
||||||
aac = runCatching { AacEncoder(sampleRate, channels, adts = service.codecType == ServiceDiscovery.CODEC_AAC_LC_ADTS) }
|
aac = runCatching { AacEncoder(sampleRate, channels, adts = service.codecType == ServiceDiscovery.CODEC_AAC_LC_ADTS) }
|
||||||
.onSuccess { GearslipLog.i("$tag: the car takes ${service.codecName} only - encoding to AAC") }
|
.onSuccess { GearslipLog.i("$tag: the car takes ${service.codecName} only - encoding to AAC") }
|
||||||
@@ -267,28 +410,55 @@ class AudioLink(
|
|||||||
synchronized(startLock) { stopLocked() }
|
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
|
if (!started) return
|
||||||
started = false
|
started = false
|
||||||
synchronized(ackLock) { ackLock.notifyAll() }
|
synchronized(ackLock) { ackLock.notifyAll() }
|
||||||
runCatching {
|
runCatching {
|
||||||
sendOnChannel(MSG_STOP, Protobuf.varintField(1, sessionId.toLong()))
|
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
|
focusGranted = false
|
||||||
aac?.release()
|
aac?.release()
|
||||||
aac = null
|
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
|
chunks = 0
|
||||||
bytes = 0
|
bytes = 0
|
||||||
|
// A new stream starts with the car's buffer empty again: earn the lead back if it's needed.
|
||||||
|
padMs = 0
|
||||||
|
pendingPadMs = 0
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun write(pcm: ByteArray, size: Int) {
|
override fun write(pcm: ByteArray, size: Int) {
|
||||||
if (!started) return
|
if (!started) return
|
||||||
|
val pad = pendingPadMs
|
||||||
|
if (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
|
val encoder = aac
|
||||||
if (encoder == null) {
|
if (encoder == null) {
|
||||||
send(pcm.copyOf(size))
|
if (pendingStale) {
|
||||||
|
pendingStale = false
|
||||||
|
pending.reset()
|
||||||
|
}
|
||||||
|
val want = chunkBytes
|
||||||
|
if (want <= 0 && pending.size() == 0) {
|
||||||
|
send(pcm.copyOf(size))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
pending.write(pcm, 0, size)
|
||||||
|
if (pending.size() >= want) {
|
||||||
|
send(pending.toByteArray())
|
||||||
|
pending.reset()
|
||||||
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
val frames = try {
|
val frames = try {
|
||||||
@@ -338,6 +508,7 @@ class AudioLink(
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
synchronized(ackLock) { inFlight++ }
|
synchronized(ackLock) { inFlight++ }
|
||||||
|
lastPayloadBytes = payload.size
|
||||||
chunks++
|
chunks++
|
||||||
bytes += payload.size
|
bytes += payload.size
|
||||||
// The first chunk proves the whole path; after that, an occasional line is enough to show
|
// The first chunk proves the whole path; after that, an occasional line is enough to show
|
||||||
@@ -364,12 +535,16 @@ class AudioLink(
|
|||||||
const val MSG_FOCUS_RESPONSE = 19
|
const val MSG_FOCUS_RESPONSE = 19
|
||||||
private const val MSG_CHANNEL_OPEN_RESPONSE = 8
|
private const val MSG_CHANNEL_OPEN_RESPONSE = 8
|
||||||
private const val MSG_DATA = 0
|
private const val MSG_DATA = 0
|
||||||
|
private const val MSG_CODEC_CONFIG = 1
|
||||||
private const val MSG_SETUP = 32768
|
private const val MSG_SETUP = 32768
|
||||||
private const val MSG_START = 32769
|
private const val MSG_START = 32769
|
||||||
private const val MSG_STOP = 32770
|
private const val MSG_STOP = 32770
|
||||||
private const val MSG_CONFIG = 32771
|
private const val MSG_CONFIG = 32771
|
||||||
private const val MSG_ACK = 32772
|
private const val MSG_ACK = 32772
|
||||||
private const val MSG_SETUP_ANSWER = 255
|
private const val MSG_SETUP_ANSWER = 255
|
||||||
|
private const val MSG_UNDERFLOW = 32779 // 0x800B, car to phone, no body
|
||||||
|
private const val PAD_STEP_MS = 60
|
||||||
|
private const val MAX_PAD_MS = 240
|
||||||
private const val STATUS_READY = 2
|
private const val STATUS_READY = 2
|
||||||
private const val FOCUS_GAIN = 1
|
private const val FOCUS_GAIN = 1
|
||||||
private const val FOCUS_GAIN_TRANSIENT_MAY_DUCK = 3
|
private const val FOCUS_GAIN_TRANSIENT_MAY_DUCK = 3
|
||||||
@@ -382,6 +557,12 @@ class AudioLink(
|
|||||||
private const val FOCUS_STATE_LOSS_TRANSIENT = 5
|
private const val FOCUS_STATE_LOSS_TRANSIENT = 5
|
||||||
private const val SESSION_ID = 2
|
private const val SESSION_ID = 2
|
||||||
private const val STALL_MS = 2_000L
|
private const val STALL_MS = 2_000L
|
||||||
|
/** Acks measured with the window full before the chunk size is judged. */
|
||||||
|
private const val ADAPT_ACKS = 20
|
||||||
|
/** Acks this much further apart than a chunk lasts mean the car is the one holding back. */
|
||||||
|
private const val SLOW_ACK_RATIO = 1.5
|
||||||
|
/** About 170 ms at 48 kHz stereo: the most delay a bigger chunk may add. */
|
||||||
|
private const val MAX_CHUNK_BYTES = 32_768
|
||||||
private const val CHUNK_LOG_EVERY = 200 // about every four seconds
|
private const val CHUNK_LOG_EVERY = 200 // about every four seconds
|
||||||
private const val CONFIG_WAIT_MS = 1_000L
|
private const val CONFIG_WAIT_MS = 1_000L
|
||||||
|
|
||||||
|
|||||||
@@ -47,11 +47,28 @@ object CarAudio {
|
|||||||
/** Opens the car's media channel, left closed while music went over Bluetooth; null once open. */
|
/** Opens the car's media channel, left closed while music went over Bluetooth; null once open. */
|
||||||
@Volatile var openSink: (() -> Unit)? = null
|
@Volatile var openSink: (() -> Unit)? = null
|
||||||
|
|
||||||
|
/** The car offers a music channel, open or waiting to be. */
|
||||||
|
val sinkOffered: Boolean get() = link != null || openSink != null
|
||||||
|
|
||||||
/** Rate and channel count the capture should use: the car's, or a sensible default. */
|
/** Rate and channel count the capture should use: the car's, or a sensible default. */
|
||||||
val sampleRate get() = link?.sampleRate ?: 48_000
|
val sampleRate get() = link?.sampleRate ?: 48_000
|
||||||
val channels get() = link?.channels ?: 2
|
val channels get() = link?.channels ?: 2
|
||||||
|
|
||||||
|
private val main by lazy { android.os.Handler(android.os.Looper.getMainLooper()) }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The car left and none came back: stop capturing, which ends the screen sharing Android
|
||||||
|
* shows for it and gives the phone its speaker back. Nothing else stopped it once the drive
|
||||||
|
* was over; with a wireless dongle Gearslip's screen stays open, so it ran on (issue #43).
|
||||||
|
*/
|
||||||
|
private val releaseWhenGone = Runnable {
|
||||||
|
if (link != null || _capture.value == Capture.OFF) return@Runnable
|
||||||
|
GearslipLog.i("audio: no car for ${CAR_GONE_MS / 1000}s - stopping the capture and screen sharing")
|
||||||
|
release()
|
||||||
|
}
|
||||||
|
|
||||||
fun attach(link: Link) {
|
fun attach(link: Link) {
|
||||||
|
main.removeCallbacks(releaseWhenGone)
|
||||||
this.link = link
|
this.link = link
|
||||||
GearslipLog.i("audio: the car offered a sink at ${link.sampleRate}Hz x${link.channels}")
|
GearslipLog.i("audio: the car offered a sink at ${link.sampleRate}Hz x${link.channels}")
|
||||||
when {
|
when {
|
||||||
@@ -60,10 +77,24 @@ object CarAudio {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The car's music channel closed. A car that comes straight back (a cable that slips) keeps
|
||||||
|
* the capture it had, so the driver isn't asked again mid-drive.
|
||||||
|
*/
|
||||||
fun detach(link: Link) {
|
fun detach(link: Link) {
|
||||||
if (this.link === link) this.link = null
|
if (this.link !== link) return
|
||||||
|
this.link = null
|
||||||
|
main.removeCallbacks(releaseWhenGone)
|
||||||
|
main.postDelayed(releaseWhenGone, CAR_GONE_MS)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* How long after the car leaves the capture waits for it to come back: a cable that slips, or a
|
||||||
|
* wireless link that drops and reconnects, which can take well over 3 seconds. Stopping sooner
|
||||||
|
* would have Android ask the driver to share the screen again in the middle of a drive.
|
||||||
|
*/
|
||||||
|
private const val CAR_GONE_MS = 30_000L
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The car wants sound. Safe to call as often as the session likes: the consent dialog appears
|
* The car wants sound. Safe to call as often as the session likes: the consent dialog appears
|
||||||
* at most once, and only once there is a sink to play through - asking on the bench, where the
|
* at most once, and only once there is a sink to play through - asking on the bench, where the
|
||||||
@@ -93,9 +124,30 @@ object CarAudio {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Capture has heard nothing for a while. If a media app that blocks capture is the one
|
||||||
|
* playing, the car screen says why the music is silent, once a drive: Spotify does this.
|
||||||
|
*/
|
||||||
|
internal fun silent(context: android.content.Context) = android.os.Handler(android.os.Looper.getMainLooper()).post {
|
||||||
|
if (toldBlocked) return@post
|
||||||
|
val media = app.seb3thehacker.gearslip.car.CarServices.mediaOrNull() ?: return@post
|
||||||
|
if (!media.now.value.playing) return@post
|
||||||
|
val name = BLOCKS_CAPTURE[media.packageName] ?: return@post
|
||||||
|
toldBlocked = true
|
||||||
|
GearslipLog.i("audio: $name is playing and capture hears nothing: it blocks other apps from playing its sound")
|
||||||
|
app.seb3thehacker.gearslip.car.CarToasts.show(this, context.getString(app.seb3thehacker.gearslip.R.string.car_audio_blocked, name), androidx.car.app.CarToast.LENGTH_LONG)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Volatile private var toldBlocked = false
|
||||||
|
|
||||||
|
/** Media apps known to keep other apps from capturing their sound, by package. */
|
||||||
|
private val BLOCKS_CAPTURE = mapOf("com.spotify.music" to "Spotify")
|
||||||
|
|
||||||
/** The car went away. Everything is torn down; the next connection asks again. */
|
/** The car went away. Everything is torn down; the next connection asks again. */
|
||||||
fun release() {
|
fun release() {
|
||||||
|
main.removeCallbacks(releaseWhenGone)
|
||||||
wanted = false
|
wanted = false
|
||||||
|
toldBlocked = false
|
||||||
AudioCaptureService.stop()
|
AudioCaptureService.stop()
|
||||||
_capture.value = Capture.OFF
|
_capture.value = Capture.OFF
|
||||||
}
|
}
|
||||||
@@ -137,6 +189,59 @@ object CarAudio {
|
|||||||
|
|
||||||
internal fun deliver(pcm: ByteArray, size: Int, peak: Float) {
|
internal fun deliver(pcm: ByteArray, size: Int, peak: Float) {
|
||||||
_level.value = peak
|
_level.value = peak
|
||||||
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,212 @@
|
|||||||
|
package app.seb3thehacker.gearslip.browser
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.SharedPreferences
|
||||||
|
import java.net.URLEncoder
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import org.json.JSONArray
|
||||||
|
import org.json.JSONObject
|
||||||
|
|
||||||
|
/** A page saved to the browser's start page. */
|
||||||
|
data class Bookmark(val title: String, val url: String) {
|
||||||
|
/** The host without "www.", for labels and icons. */
|
||||||
|
val host: String get() = runCatching { android.net.Uri.parse(url).host.orEmpty() }.getOrDefault("").removePrefix("www.")
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Where typed searches go. [query] is the address with the search terms appended, encoded. */
|
||||||
|
enum class SearchEngine(val id: String, val label: String, private val query: String) {
|
||||||
|
DUCKDUCKGO("duckduckgo", "DuckDuckGo", "https://duckduckgo.com/?q="),
|
||||||
|
STARTPAGE("startpage", "Startpage", "https://www.startpage.com/do/search?q="),
|
||||||
|
BRAVE("brave", "Brave Search", "https://search.brave.com/search?q="),
|
||||||
|
ECOSIA("ecosia", "Ecosia", "https://www.ecosia.org/search?q="),
|
||||||
|
GOOGLE("google", "Google", "https://www.google.com/search?q="),
|
||||||
|
BING("bing", "Bing", "https://www.bing.com/search?q=");
|
||||||
|
|
||||||
|
fun search(text: String): String = query + URLEncoder.encode(text, "UTF-8")
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A filter list the ad blocker can use: one of the two it ships with, or one the driver added by
|
||||||
|
* its address. [id] names the cached copy on disk.
|
||||||
|
*/
|
||||||
|
data class FilterList(val id: String, val title: String, val url: String, val builtIn: Boolean, val enabled: Boolean)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The browser's own settings: bookmarks, the search engine, and the ad blocker's switches and
|
||||||
|
* lists. Kept in a preferences file of their own and mirrored into flows, so the car's browser
|
||||||
|
* and the phone's settings show the same thing. The ad blocker reads only the flows' values,
|
||||||
|
* never the file, since it answers on WebView's request thread (see [adblock.AdBlockManager]).
|
||||||
|
*/
|
||||||
|
object BrowserSettings {
|
||||||
|
|
||||||
|
private const val FILE = "browser"
|
||||||
|
private const val KEY_BOOKMARKS = "bookmarks"
|
||||||
|
private const val KEY_SEARCH = "search_engine"
|
||||||
|
private const val KEY_ADBLOCK = "adblock"
|
||||||
|
private const val KEY_YOUTUBE = "adblock_youtube"
|
||||||
|
private const val KEY_LISTS_OFF = "adblock_lists_off"
|
||||||
|
private const val KEY_CUSTOM_LISTS = "adblock_custom_lists"
|
||||||
|
private const val KEY_LAST_CHECK = "adblock_last_check"
|
||||||
|
|
||||||
|
/** The lists Gearslip ships a snapshot of, on unless turned off. */
|
||||||
|
val BUILT_IN_LISTS = listOf(
|
||||||
|
FilterList("easylist", "EasyList", "https://easylist.to/easylist/easylist.txt", builtIn = true, enabled = true),
|
||||||
|
FilterList("easyprivacy", "EasyPrivacy", "https://easylist.to/easylist/easyprivacy.txt", builtIn = true, enabled = true),
|
||||||
|
)
|
||||||
|
|
||||||
|
/** What a new install's start page offers, until the driver changes it. */
|
||||||
|
private val DEFAULT_BOOKMARKS = listOf(
|
||||||
|
Bookmark("YouTube", "https://m.youtube.com"),
|
||||||
|
Bookmark("Wikipedia", "https://en.m.wikipedia.org"),
|
||||||
|
)
|
||||||
|
|
||||||
|
private val bookmarksFlow = MutableStateFlow<List<Bookmark>>(emptyList())
|
||||||
|
private val searchFlow = MutableStateFlow(SearchEngine.DUCKDUCKGO)
|
||||||
|
private val adBlockFlow = MutableStateFlow(true)
|
||||||
|
private val youTubeFlow = MutableStateFlow(true)
|
||||||
|
private val listsFlow = MutableStateFlow(BUILT_IN_LISTS)
|
||||||
|
|
||||||
|
val bookmarks: StateFlow<List<Bookmark>> = bookmarksFlow
|
||||||
|
val searchEngine: StateFlow<SearchEngine> = searchFlow
|
||||||
|
val adBlock: StateFlow<Boolean> = adBlockFlow
|
||||||
|
val youTubeAdBlock: StateFlow<Boolean> = youTubeFlow
|
||||||
|
val filterLists: StateFlow<List<FilterList>> = listsFlow
|
||||||
|
|
||||||
|
@Volatile private var prefs: SharedPreferences? = null
|
||||||
|
|
||||||
|
/** Loads everything once per process. Safe to call from anywhere that has a context. */
|
||||||
|
fun init(context: Context) {
|
||||||
|
if (prefs != null) return
|
||||||
|
synchronized(this) {
|
||||||
|
if (prefs != null) return
|
||||||
|
val p = context.applicationContext.getSharedPreferences(FILE, Context.MODE_PRIVATE)
|
||||||
|
bookmarksFlow.value = if (p.contains(KEY_BOOKMARKS)) readBookmarks(p.getString(KEY_BOOKMARKS, "[]").orEmpty()) else DEFAULT_BOOKMARKS
|
||||||
|
searchFlow.value = SearchEngine.entries.firstOrNull { it.id == p.getString(KEY_SEARCH, null) } ?: SearchEngine.DUCKDUCKGO
|
||||||
|
adBlockFlow.value = p.getBoolean(KEY_ADBLOCK, true)
|
||||||
|
youTubeFlow.value = p.getBoolean(KEY_YOUTUBE, true)
|
||||||
|
listsFlow.value = readLists(p)
|
||||||
|
prefs = p
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bookmarks
|
||||||
|
|
||||||
|
fun isBookmarked(url: String?): Boolean = url != null && bookmarksFlow.value.any { same(it.url, url) }
|
||||||
|
|
||||||
|
/** Adds [url], or renames it when it's already saved. Returns false for something that isn't a web address. */
|
||||||
|
fun addBookmark(title: String, url: String): Boolean {
|
||||||
|
val clean = url.trim()
|
||||||
|
if (!clean.startsWith("http://") && !clean.startsWith("https://")) return false
|
||||||
|
val name = title.trim().ifEmpty { Bookmark("", clean).host.ifEmpty { clean } }
|
||||||
|
val list = bookmarksFlow.value
|
||||||
|
val existing = list.indexOfFirst { same(it.url, clean) }
|
||||||
|
saveBookmarks(if (existing >= 0) list.toMutableList().also { it[existing] = Bookmark(name, list[existing].url) } else list + Bookmark(name, clean))
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
fun removeBookmark(url: String) = saveBookmarks(bookmarksFlow.value.filterNot { same(it.url, url) })
|
||||||
|
|
||||||
|
/** Moves the bookmark at [from] to [to], for reordering from the phone. */
|
||||||
|
fun moveBookmark(from: Int, to: Int) {
|
||||||
|
val list = bookmarksFlow.value.toMutableList()
|
||||||
|
if (from !in list.indices || to !in list.indices) return
|
||||||
|
list.add(to, list.removeAt(from))
|
||||||
|
saveBookmarks(list)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The same page whatever the trailing slash or scheme. */
|
||||||
|
private fun same(a: String, b: String): Boolean {
|
||||||
|
fun key(u: String) = u.trim().removePrefix("https://").removePrefix("http://").removePrefix("www.").trimEnd('/')
|
||||||
|
return key(a).equals(key(b), ignoreCase = true)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun saveBookmarks(list: List<Bookmark>) {
|
||||||
|
bookmarksFlow.value = list
|
||||||
|
val array = JSONArray()
|
||||||
|
list.forEach { array.put(JSONObject().put("title", it.title).put("url", it.url)) }
|
||||||
|
prefs?.edit()?.putString(KEY_BOOKMARKS, array.toString())?.apply()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun readBookmarks(json: String): List<Bookmark> = runCatching {
|
||||||
|
val array = JSONArray(json)
|
||||||
|
(0 until array.length()).map { array.getJSONObject(it) }.map { Bookmark(it.optString("title"), it.optString("url")) }
|
||||||
|
.filter { it.url.isNotEmpty() }
|
||||||
|
}.getOrDefault(emptyList())
|
||||||
|
|
||||||
|
// Search
|
||||||
|
|
||||||
|
fun setSearchEngine(engine: SearchEngine) {
|
||||||
|
searchFlow.value = engine
|
||||||
|
prefs?.edit()?.putString(KEY_SEARCH, engine.id)?.apply()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Something that looks like an address opens as one; anything else is searched for. */
|
||||||
|
fun addressToUrl(typed: String): String {
|
||||||
|
val text = typed.trim()
|
||||||
|
return when {
|
||||||
|
text.startsWith("http://") || text.startsWith("https://") -> text
|
||||||
|
' ' !in text && '.' in text -> "https://$text"
|
||||||
|
else -> searchFlow.value.search(text)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ad blocking
|
||||||
|
|
||||||
|
fun setAdBlock(on: Boolean) {
|
||||||
|
adBlockFlow.value = on
|
||||||
|
prefs?.edit()?.putBoolean(KEY_ADBLOCK, on)?.apply()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setYouTubeAdBlock(on: Boolean) {
|
||||||
|
youTubeFlow.value = on
|
||||||
|
prefs?.edit()?.putBoolean(KEY_YOUTUBE, on)?.apply()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setListEnabled(id: String, on: Boolean) {
|
||||||
|
val p = prefs ?: return
|
||||||
|
val off = p.getStringSet(KEY_LISTS_OFF, emptySet()).orEmpty().toMutableSet()
|
||||||
|
if (on) off.remove(id) else off.add(id)
|
||||||
|
p.edit().putStringSet(KEY_LISTS_OFF, off).apply()
|
||||||
|
listsFlow.value = readLists(p)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Adds a list by its address. Returns false for something that isn't one, or one already there. */
|
||||||
|
fun addCustomList(url: String): Boolean {
|
||||||
|
val p = prefs ?: return false
|
||||||
|
val clean = url.trim()
|
||||||
|
if (!clean.startsWith("https://") && !clean.startsWith("http://")) return false
|
||||||
|
if (listsFlow.value.any { it.url.equals(clean, ignoreCase = true) }) return false
|
||||||
|
p.edit().putString(KEY_CUSTOM_LISTS, (customUrls(p) + clean).joinToString("\n")).apply()
|
||||||
|
listsFlow.value = readLists(p)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
fun removeCustomList(id: String) {
|
||||||
|
val p = prefs ?: return
|
||||||
|
val list = listsFlow.value.firstOrNull { it.id == id && !it.builtIn } ?: return
|
||||||
|
p.edit().putString(KEY_CUSTOM_LISTS, customUrls(p).filterNot { it == list.url }.joinToString("\n")).apply()
|
||||||
|
listsFlow.value = readLists(p)
|
||||||
|
}
|
||||||
|
|
||||||
|
var lastUpdateCheck: Long
|
||||||
|
get() = prefs?.getLong(KEY_LAST_CHECK, 0L) ?: 0L
|
||||||
|
set(value) { prefs?.edit()?.putLong(KEY_LAST_CHECK, value)?.apply() }
|
||||||
|
|
||||||
|
private fun customUrls(p: SharedPreferences) = p.getString(KEY_CUSTOM_LISTS, "").orEmpty().lines().filter { it.isNotBlank() }
|
||||||
|
|
||||||
|
private fun readLists(p: SharedPreferences): List<FilterList> {
|
||||||
|
val off = p.getStringSet(KEY_LISTS_OFF, emptySet()).orEmpty()
|
||||||
|
val custom = customUrls(p).map { url ->
|
||||||
|
val name = runCatching { android.net.Uri.parse(url).lastPathSegment }.getOrNull()?.takeIf { it.isNotBlank() } ?: url
|
||||||
|
FilterList(customId(url), name, url, builtIn = false, enabled = true)
|
||||||
|
}
|
||||||
|
return (BUILT_IN_LISTS + custom).map { it.copy(enabled = it.id !in off) }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A stable file name for a list known only by its address. */
|
||||||
|
private fun customId(url: String): String {
|
||||||
|
val digest = java.security.MessageDigest.getInstance("SHA-256").digest(url.toByteArray())
|
||||||
|
return "custom-" + digest.take(8).joinToString("") { "%02x".format(it) }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
package app.seb3thehacker.gearslip.browser
|
||||||
|
|
||||||
|
import android.app.Activity
|
||||||
|
import android.content.Intent
|
||||||
|
import android.os.Bundle
|
||||||
|
import android.widget.Toast
|
||||||
|
import app.seb3thehacker.gearslip.R
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The "Add to Gearslip" entry in Android's share sheet. A page shared from any browser becomes a
|
||||||
|
* tile on the car browser's start page, so nobody has to type an address on the car's keyboard.
|
||||||
|
* It shows nothing of its own: a toast says whether it worked, and it closes.
|
||||||
|
*/
|
||||||
|
class ShareToBrowserActivity : Activity() {
|
||||||
|
|
||||||
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
|
super.onCreate(savedInstanceState)
|
||||||
|
BrowserSettings.init(this)
|
||||||
|
val text = intent?.takeIf { it.action == Intent.ACTION_SEND }?.getStringExtra(Intent.EXTRA_TEXT).orEmpty()
|
||||||
|
// Browsers share the bare address; some apps wrap it in a sentence, so take the first link.
|
||||||
|
val url = URL_PATTERN.find(text)?.value
|
||||||
|
val title = intent?.getStringExtra(Intent.EXTRA_SUBJECT) ?: intent?.getStringExtra(Intent.EXTRA_TITLE) ?: ""
|
||||||
|
val added = url != null && BrowserSettings.addBookmark(title, url)
|
||||||
|
Toast.makeText(this, getString(if (added) R.string.browser_shared_added else R.string.browser_shared_not_url), Toast.LENGTH_SHORT).show()
|
||||||
|
finish()
|
||||||
|
}
|
||||||
|
|
||||||
|
private companion object {
|
||||||
|
val URL_PATTERN = Regex("""https?://\S+""")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
package app.seb3thehacker.gearslip.browser
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.graphics.Bitmap
|
||||||
|
import android.graphics.BitmapFactory
|
||||||
|
import app.seb3thehacker.gearslip.GearslipLog
|
||||||
|
import java.io.File
|
||||||
|
import java.net.HttpURLConnection
|
||||||
|
import java.net.URL
|
||||||
|
import java.util.concurrent.ConcurrentHashMap
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Each site's icon for the start page's tiles, kept on disk by host. The browser saves the icon
|
||||||
|
* a page hands it while loading ([save]); a bookmark that hasn't been opened yet asks its own
|
||||||
|
* site once ([fetch]), for its touch icon and then its favicon. Nothing goes to a third-party
|
||||||
|
* icon service, so no list of the driver's bookmarks leaves the phone.
|
||||||
|
*/
|
||||||
|
object SiteIcons {
|
||||||
|
|
||||||
|
private const val DIRECTORY = "site-icons"
|
||||||
|
/** Smaller than this is a 16px favicon that would look blurry on a tile; a bigger one wins. */
|
||||||
|
private const val GOOD_ENOUGH_PX = 96
|
||||||
|
|
||||||
|
private val memory = ConcurrentHashMap<String, Bitmap>()
|
||||||
|
private val asked = ConcurrentHashMap.newKeySet<String>()
|
||||||
|
private val changed = MutableStateFlow(0)
|
||||||
|
|
||||||
|
/** Bumps whenever an icon is saved, so tiles look again. */
|
||||||
|
val version: StateFlow<Int> = changed
|
||||||
|
|
||||||
|
fun icon(context: Context, host: String): Bitmap? {
|
||||||
|
if (host.isEmpty()) return null
|
||||||
|
memory[host]?.let { return it }
|
||||||
|
val file = file(context, host)
|
||||||
|
if (!file.isFile) return null
|
||||||
|
return runCatching { BitmapFactory.decodeFile(file.path) }.getOrNull()?.also { memory[host] = it }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** An icon the page gave the browser. Kept when it's bigger than the one already saved. */
|
||||||
|
fun save(context: Context, host: String, bitmap: Bitmap) {
|
||||||
|
if (host.isEmpty()) return
|
||||||
|
val current = icon(context, host)
|
||||||
|
if (current != null && current.width >= bitmap.width) return
|
||||||
|
store(context, host, bitmap)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Asks [host] for its icon in the background, once per run, when none is saved or it's small. */
|
||||||
|
fun fetch(context: Context, host: String) {
|
||||||
|
if (host.isEmpty() || !asked.add(host)) return
|
||||||
|
val current = icon(context, host)
|
||||||
|
if (current != null && current.width >= GOOD_ENOUGH_PX) return
|
||||||
|
val app = context.applicationContext
|
||||||
|
Thread({
|
||||||
|
for (path in listOf("/apple-touch-icon.png", "/favicon.ico")) {
|
||||||
|
val bitmap = runCatching { download("https://$host$path") }.getOrNull() ?: continue
|
||||||
|
if (current == null || bitmap.width > current.width) store(app, host, bitmap)
|
||||||
|
return@Thread
|
||||||
|
}
|
||||||
|
}, "site-icon").apply { isDaemon = true }.start()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun store(context: Context, host: String, bitmap: Bitmap) {
|
||||||
|
memory[host] = bitmap
|
||||||
|
runCatching {
|
||||||
|
val file = file(context, host)
|
||||||
|
file.parentFile?.mkdirs()
|
||||||
|
file.outputStream().use { bitmap.compress(Bitmap.CompressFormat.PNG, 100, it) }
|
||||||
|
}.onFailure { GearslipLog.w("browser: couldn't save the icon for $host: ${it.message}") }
|
||||||
|
changed.value++
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun download(url: String): Bitmap? {
|
||||||
|
val connection = (URL(url).openConnection() as HttpURLConnection).apply {
|
||||||
|
connectTimeout = 10_000
|
||||||
|
readTimeout = 10_000
|
||||||
|
instanceFollowRedirects = true
|
||||||
|
}
|
||||||
|
return try {
|
||||||
|
if (connection.responseCode !in 200..299) return null
|
||||||
|
val bytes = connection.inputStream.use { it.readBytes() }
|
||||||
|
BitmapFactory.decodeByteArray(bytes, 0, bytes.size)
|
||||||
|
} finally {
|
||||||
|
connection.disconnect()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun file(context: Context, host: String) =
|
||||||
|
File(File(context.filesDir, DIRECTORY), host.replace(Regex("[^A-Za-z0-9.-]"), "_") + ".png")
|
||||||
|
}
|
||||||
@@ -0,0 +1,417 @@
|
|||||||
|
package app.seb3thehacker.gearslip.browser.adblock
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.net.ConnectivityManager
|
||||||
|
import android.net.NetworkCapabilities
|
||||||
|
import android.util.Base64
|
||||||
|
import android.webkit.WebResourceRequest
|
||||||
|
import android.webkit.WebResourceResponse
|
||||||
|
import app.seb3thehacker.gearslip.GearslipLog
|
||||||
|
import app.seb3thehacker.gearslip.browser.BrowserSettings
|
||||||
|
import app.seb3thehacker.gearslip.browser.FilterList
|
||||||
|
import java.io.ByteArrayInputStream
|
||||||
|
import java.io.File
|
||||||
|
import java.net.HttpURLConnection
|
||||||
|
import java.net.URL
|
||||||
|
import java.util.concurrent.atomic.AtomicBoolean
|
||||||
|
import java.util.concurrent.atomic.AtomicLong
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Owns the filter lists: downloading them, keeping a parsed [FilterEngine] in memory, and
|
||||||
|
* answering the two questions the WebView asks: block this request, and what to hide on
|
||||||
|
* this page.
|
||||||
|
*
|
||||||
|
* Everything the request path touches is cached in memory. `shouldInterceptRequest` runs on a
|
||||||
|
* background thread for every subresource on the page, so it must never read preferences or
|
||||||
|
* touch disk. The switches come from [BrowserSettings]'s flows, which are plain memory reads.
|
||||||
|
*
|
||||||
|
* Downloading a list contacts the server it comes from (easylist.to for the two built in), and
|
||||||
|
* nothing else: no page the driver visits is ever sent anywhere.
|
||||||
|
*/
|
||||||
|
object AdBlockManager {
|
||||||
|
|
||||||
|
sealed class RefreshResult {
|
||||||
|
data class Success(val networkRules: Int, val cosmeticRules: Int) : RefreshResult()
|
||||||
|
data class Failure(val message: String) : RefreshResult()
|
||||||
|
}
|
||||||
|
|
||||||
|
private const val DIRECTORY = "adblock"
|
||||||
|
private const val BUNDLED_ASSET = "adblock/bundled.txt"
|
||||||
|
private const val MAX_LIST_BYTES = 12L * 1024 * 1024
|
||||||
|
|
||||||
|
/**
|
||||||
|
* How stale the lists may get before the browser refreshes them on its own. EasyList
|
||||||
|
* publishes several times a day, but the parts that matter, the domains ads are served from,
|
||||||
|
* turn over slowly, and each update costs a few megabytes.
|
||||||
|
*/
|
||||||
|
private const val UPDATE_INTERVAL_MS = 7L * 24 * 60 * 60 * 1000
|
||||||
|
|
||||||
|
// 1x1 transparent GIF, returned for blocked images so pages show a gap rather than a
|
||||||
|
// broken-image icon.
|
||||||
|
private const val TRANSPARENT_GIF_BASE64 =
|
||||||
|
"R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7"
|
||||||
|
|
||||||
|
private val EMPTY = ByteArray(0)
|
||||||
|
private val EMPTY_JSON = "{}".toByteArray()
|
||||||
|
|
||||||
|
val transparentGifBytes: ByteArray by lazy {
|
||||||
|
Base64.decode(TRANSPARENT_GIF_BASE64, Base64.DEFAULT)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Volatile
|
||||||
|
private var engine: FilterEngine? = null
|
||||||
|
|
||||||
|
/** No per-site allowlist yet; kept so the request path reads the same as AABrowser's. */
|
||||||
|
private val allowlist: Set<String> = emptySet()
|
||||||
|
|
||||||
|
private val enabled: Boolean get() = BrowserSettings.adBlock.value
|
||||||
|
private val youTubeEnabled: Boolean get() = BrowserSettings.youTubeAdBlock.value
|
||||||
|
|
||||||
|
private val loadStarted = AtomicBoolean(false)
|
||||||
|
private val refreshRunning = AtomicBoolean(false)
|
||||||
|
private val autoUpdateAttempted = AtomicBoolean(false)
|
||||||
|
private val blockedCount = AtomicLong(0)
|
||||||
|
// Time spent deciding requests since the last page load, for the log line that tells whether
|
||||||
|
// the blocker is what makes a page or a video slow to start.
|
||||||
|
private val checkedCount = AtomicLong(0)
|
||||||
|
private val checkedNanos = AtomicLong(0)
|
||||||
|
private val blockedSincePage = AtomicLong(0)
|
||||||
|
|
||||||
|
val isEngineReady: Boolean get() = engine != null
|
||||||
|
val blockedRequestCount: Long get() = blockedCount.get()
|
||||||
|
val networkRuleCount: Int get() = engine?.networkRuleCount ?: 0
|
||||||
|
val cosmeticRuleCount: Int get() = engine?.cosmeticRuleCount ?: 0
|
||||||
|
|
||||||
|
/** Loads the cached lists from disk once per process, on a background thread. */
|
||||||
|
fun ensureInitialized(context: Context) {
|
||||||
|
BrowserSettings.init(context)
|
||||||
|
if (!enabled) return
|
||||||
|
if (!loadStarted.compareAndSet(false, true)) return
|
||||||
|
|
||||||
|
val appContext = context.applicationContext
|
||||||
|
Thread({
|
||||||
|
runCatching { loadFromDisk(appContext) }
|
||||||
|
.onFailure { GearslipLog.w("adblock: could not load the filter lists: ${it.message}") }
|
||||||
|
}, "adblock-load").apply {
|
||||||
|
// Blocking does nothing until this finishes, so it gets a normal share of the CPU.
|
||||||
|
// On a Pixel 6 a release build parses both lists in about 250ms.
|
||||||
|
priority = Thread.NORM_PRIORITY
|
||||||
|
start()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Rebuilds the engine from what's on disk after a list was turned on or off. */
|
||||||
|
fun reload(context: Context) {
|
||||||
|
val appContext = context.applicationContext
|
||||||
|
loadStarted.set(true)
|
||||||
|
Thread({ runCatching { loadFromDisk(appContext) } }, "adblock-load").start()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun lastUpdatedAt(context: Context): Long =
|
||||||
|
BrowserSettings.filterLists.value.mapNotNull { cacheFile(context, it).takeIf { file -> file.length() > 0 } }
|
||||||
|
.maxOfOrNull { it.lastModified() } ?: 0L
|
||||||
|
|
||||||
|
private fun cacheFile(context: Context, source: FilterList): File =
|
||||||
|
File(File(context.filesDir, DIRECTORY), "${source.id}.txt")
|
||||||
|
|
||||||
|
private fun loadFromDisk(context: Context) {
|
||||||
|
val lists = BrowserSettings.filterLists.value.filter { it.enabled }
|
||||||
|
val files = lists.map { cacheFile(context, it) }.filter { it.length() > 0 }
|
||||||
|
val builder = FilterEngine.Builder()
|
||||||
|
|
||||||
|
// A built-in list that hasn't downloaded yet comes from the snapshot in the APK, so
|
||||||
|
// blocking works on the very first page rather than only after a download.
|
||||||
|
val needsBundled = lists.any { it.builtIn && cacheFile(context, it).length() == 0L }
|
||||||
|
if (needsBundled) {
|
||||||
|
context.assets.open(BUNDLED_ASSET).bufferedReader(Charsets.UTF_8).use { reader ->
|
||||||
|
while (true) {
|
||||||
|
val line = reader.readLine() ?: break
|
||||||
|
builder.addLine(line)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (file in files) {
|
||||||
|
file.bufferedReader(bufferSize = 64 * 1024).use { reader ->
|
||||||
|
while (true) {
|
||||||
|
val line = reader.readLine() ?: break
|
||||||
|
builder.addLine(line)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
engine = builder.finish()
|
||||||
|
GearslipLog.i("adblock: ${networkRuleCount} network and ${cosmeticRuleCount} hiding rules from ${lists.size} lists")
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Downloads every list that's on, replaces the cached copies, and rebuilds the engine. */
|
||||||
|
fun refreshLists(context: Context): RefreshResult {
|
||||||
|
// The automatic refresh and the button in settings can both land here; two downloads
|
||||||
|
// writing the same files would leave one of them truncated.
|
||||||
|
val appContext = context.applicationContext
|
||||||
|
if (!refreshRunning.compareAndSet(false, true)) {
|
||||||
|
return RefreshResult.Failure("An update is already running.")
|
||||||
|
}
|
||||||
|
return try {
|
||||||
|
refresh(appContext).also {
|
||||||
|
// A manual update counts as a check, so the automatic one waits a full interval.
|
||||||
|
if (it is RefreshResult.Success) BrowserSettings.lastUpdateCheck = System.currentTimeMillis()
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
refreshRunning.set(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Updates the lists when they have gone stale and the phone is on Wi-Fi, on a background
|
||||||
|
* thread. Runs when the car's browser opens; there is no scheduled job. Only one attempt per
|
||||||
|
* process, and the time is written on success alone, so a phone that was offline tries again
|
||||||
|
* next time rather than in a week.
|
||||||
|
*/
|
||||||
|
fun refreshIfDue(context: Context) {
|
||||||
|
val appContext = context.applicationContext
|
||||||
|
BrowserSettings.init(appContext)
|
||||||
|
if (!enabled || !isUpdateDue(appContext) || !onUnmeteredNetwork(appContext)) return
|
||||||
|
if (!autoUpdateAttempted.compareAndSet(false, true)) return
|
||||||
|
Thread({
|
||||||
|
val result = refreshLists(appContext)
|
||||||
|
GearslipLog.i("adblock: weekly list update - $result")
|
||||||
|
}, "adblock-update").start()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun isUpdateDue(context: Context): Boolean {
|
||||||
|
val now = System.currentTimeMillis()
|
||||||
|
val lastCheck = BrowserSettings.lastUpdateCheck
|
||||||
|
if (lastCheck > 0 && now - lastCheck < UPDATE_INTERVAL_MS) return false
|
||||||
|
val baseline = lastUpdatedAt(context)
|
||||||
|
// Nothing downloaded yet: the snapshot in the APK may be months old, so fetch now.
|
||||||
|
if (baseline <= 0L) return true
|
||||||
|
// A clock that has jumped backwards would otherwise freeze the lists indefinitely.
|
||||||
|
if (baseline > now) return true
|
||||||
|
return now - baseline >= UPDATE_INTERVAL_MS
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun onUnmeteredNetwork(context: Context): Boolean {
|
||||||
|
val manager = context.getSystemService(ConnectivityManager::class.java) ?: return false
|
||||||
|
val caps = manager.getNetworkCapabilities(manager.activeNetwork) ?: return false
|
||||||
|
return caps.hasCapability(NetworkCapabilities.NET_CAPABILITY_NOT_METERED)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun refresh(context: Context): RefreshResult {
|
||||||
|
val directory = File(context.filesDir, DIRECTORY)
|
||||||
|
if (!directory.exists() && !directory.mkdirs()) {
|
||||||
|
return RefreshResult.Failure("Could not create the filter list folder.")
|
||||||
|
}
|
||||||
|
|
||||||
|
for (source in BrowserSettings.filterLists.value.filter { it.enabled }) {
|
||||||
|
val result = runCatching { download(source, cacheFile(context, source)) }
|
||||||
|
val error = result.exceptionOrNull()
|
||||||
|
if (error != null) return RefreshResult.Failure("${source.title}: ${error.message ?: "download failed"}")
|
||||||
|
val failure = result.getOrNull()
|
||||||
|
if (failure != null) return RefreshResult.Failure("${source.title}: $failure")
|
||||||
|
}
|
||||||
|
|
||||||
|
loadStarted.set(true)
|
||||||
|
val loadError = runCatching { loadFromDisk(context) }.exceptionOrNull()
|
||||||
|
if (loadError != null) return RefreshResult.Failure(loadError.message ?: "Could not read the lists.")
|
||||||
|
|
||||||
|
val current = engine ?: return RefreshResult.Failure("The lists downloaded but contained no rules.")
|
||||||
|
return RefreshResult.Success(current.networkRuleCount, current.cosmeticRuleCount)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Returns null on success, or a human-readable reason for failure. */
|
||||||
|
private fun download(source: FilterList, destination: File): String? {
|
||||||
|
val connection = (URL(source.url).openConnection() as HttpURLConnection).apply {
|
||||||
|
connectTimeout = 15_000
|
||||||
|
readTimeout = 30_000
|
||||||
|
setRequestProperty("Accept", "text/plain")
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
if (connection.responseCode !in 200..299) return "HTTP ${connection.responseCode}"
|
||||||
|
val temporary = File(destination.parentFile, destination.name + ".tmp")
|
||||||
|
var total = 0L
|
||||||
|
|
||||||
|
temporary.outputStream().use { output ->
|
||||||
|
connection.inputStream.use { input ->
|
||||||
|
val buffer = ByteArray(16 * 1024)
|
||||||
|
while (true) {
|
||||||
|
val read = input.read(buffer)
|
||||||
|
if (read < 0) break
|
||||||
|
total += read
|
||||||
|
if (total > MAX_LIST_BYTES) {
|
||||||
|
temporary.delete()
|
||||||
|
return "list is unexpectedly large"
|
||||||
|
}
|
||||||
|
output.write(buffer, 0, read)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (total == 0L) {
|
||||||
|
temporary.delete()
|
||||||
|
return "empty response"
|
||||||
|
}
|
||||||
|
if (!temporary.renameTo(destination)) {
|
||||||
|
temporary.delete()
|
||||||
|
return "could not save the list"
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
connection.disconnect()
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Deletes the cached copy of a list the driver removed. */
|
||||||
|
fun forget(context: Context, list: FilterList) {
|
||||||
|
cacheFile(context, list).delete()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun isAllowlisted(host: String?): Boolean {
|
||||||
|
if (host.isNullOrEmpty()) return false
|
||||||
|
val current = allowlist
|
||||||
|
if (current.isEmpty()) return false
|
||||||
|
val normalized = host.lowercase()
|
||||||
|
return current.any { DomainUtils.hostMatchesDomain(normalized, it) }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Decides a single subresource request and, if it should be blocked, builds the response for
|
||||||
|
* it. [pageUrl] is the document the request belongs to, tracked by the WebView client because
|
||||||
|
* a WebView's own url can only be read on the UI thread. Null means let the request through.
|
||||||
|
*/
|
||||||
|
fun response(request: WebResourceRequest, pageUrl: String?): WebResourceResponse? {
|
||||||
|
if (!enabled) return null
|
||||||
|
// A ranged request is a video or audio stream fetching its next piece: ads never come
|
||||||
|
// that way, and a stream makes hundreds of them, so they skip the lists entirely.
|
||||||
|
if (request.requestHeaders?.keys?.any { it.equals("Range", ignoreCase = true) } == true) return null
|
||||||
|
val started = System.nanoTime()
|
||||||
|
try {
|
||||||
|
return decide(request, pageUrl)
|
||||||
|
} finally {
|
||||||
|
checkedCount.incrementAndGet()
|
||||||
|
checkedNanos.addAndGet(System.nanoTime() - started)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Logs what the blocker cost the page that just finished loading, and starts counting afresh. */
|
||||||
|
fun logPageCost(url: String?) {
|
||||||
|
val count = checkedCount.getAndSet(0)
|
||||||
|
val ms = checkedNanos.getAndSet(0) / 1_000_000
|
||||||
|
val blocked = blockedSincePage.getAndSet(0)
|
||||||
|
if (count > 0) GearslipLog.i("adblock: $count requests checked in ${ms}ms, $blocked blocked, for ${url?.substringBefore('?')}")
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun decide(request: WebResourceRequest, pageUrl: String?): WebResourceResponse? {
|
||||||
|
val current = engine ?: return null
|
||||||
|
|
||||||
|
// Never interfere with top-level navigation: a mistake there costs the whole page.
|
||||||
|
if (request.isForMainFrame) return null
|
||||||
|
|
||||||
|
val url = request.url ?: return null
|
||||||
|
val scheme = url.scheme?.lowercase()
|
||||||
|
if (scheme != "http" && scheme != "https") return null
|
||||||
|
|
||||||
|
val documentHost = pageUrl?.let { runCatching { android.net.Uri.parse(it).host }.getOrNull() }
|
||||||
|
if (isAllowlisted(documentHost)) return null
|
||||||
|
|
||||||
|
val requestHost = url.host?.lowercase()
|
||||||
|
val type = ResourceType.infer(request)
|
||||||
|
val isThirdParty = DomainUtils.isThirdParty(requestHost, documentHost)
|
||||||
|
|
||||||
|
val match = runCatching {
|
||||||
|
current.matchBlockingRule(url.toString(), requestHost, documentHost, type, isThirdParty)
|
||||||
|
}.getOrNull() ?: return null
|
||||||
|
|
||||||
|
blockedCount.incrementAndGet()
|
||||||
|
blockedSincePage.incrementAndGet()
|
||||||
|
return blockedResponse(request, match.redirectResource)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The stand-in served for a blocked request.
|
||||||
|
*
|
||||||
|
* The content type matters as much as the emptiness. A page that asked for a script and
|
||||||
|
* receives `text/plain` does not get a script that does nothing — it gets a resource the
|
||||||
|
* browser refuses to execute, and any code waiting on that script waits until it times
|
||||||
|
* out. YouTube's player does exactly this with its ad-status check, which showed up as a
|
||||||
|
* five to ten second stall before playback. So each blocked request is answered with a
|
||||||
|
* valid, empty thing of the kind it asked for, which is what uBlock Origin's `noop`
|
||||||
|
* redirect resources do.
|
||||||
|
*
|
||||||
|
* When the matched rule named a specific `$redirect=` resource we recognise, that exact
|
||||||
|
* stub is served instead of the generic empty one -- see [RedirectResources] for why that
|
||||||
|
* sometimes matters.
|
||||||
|
*/
|
||||||
|
private fun blockedResponse(request: WebResourceRequest, redirectResource: String?): WebResourceResponse {
|
||||||
|
val headers = mapOf(
|
||||||
|
"Access-Control-Allow-Origin" to "*",
|
||||||
|
"Cache-Control" to "no-store"
|
||||||
|
)
|
||||||
|
|
||||||
|
if (redirectResource != null) {
|
||||||
|
RedirectResources.response(redirectResource, headers)?.let { return it }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun response(mimeType: String, encoding: String?, body: ByteArray) =
|
||||||
|
WebResourceResponse(mimeType, encoding, 200, "OK", headers, ByteArrayInputStream(body))
|
||||||
|
|
||||||
|
return when (ResourceType.infer(request)) {
|
||||||
|
ResourceType.IMAGE -> response("image/gif", null, transparentGifBytes)
|
||||||
|
ResourceType.SCRIPT -> response("application/javascript", "utf-8", EMPTY)
|
||||||
|
ResourceType.STYLESHEET -> response("text/css", "utf-8", EMPTY)
|
||||||
|
ResourceType.SUBDOCUMENT, ResourceType.DOCUMENT -> response("text/html", "utf-8", EMPTY)
|
||||||
|
// Callers parse this; an empty body would throw where "{}" quietly yields nothing.
|
||||||
|
ResourceType.XHR -> response("application/json", "utf-8", EMPTY_JSON)
|
||||||
|
ResourceType.MEDIA -> response("video/mp4", null, EMPTY)
|
||||||
|
ResourceType.FONT -> response("font/woff2", null, EMPTY)
|
||||||
|
else -> response("text/plain", "utf-8", EMPTY)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** CSS injected at document start, or an empty string when nothing applies. */
|
||||||
|
fun cssForHost(host: String?): String {
|
||||||
|
if (!enabled) return ""
|
||||||
|
if (isAllowlisted(host)) return ""
|
||||||
|
return engine?.cssForHost(host).orEmpty()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Whether the YouTube-specific ad-stripping scripts should run for [host]. */
|
||||||
|
fun isYouTubeBlockingEnabled(host: String?): Boolean {
|
||||||
|
if (!enabled || !youTubeEnabled) return false
|
||||||
|
return !isAllowlisted(host)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether non-ad cosmetic site tweaks (hiding a redundant footer nav, a Shorts shelf, ...)
|
||||||
|
* should run. Independent of ad blocking: a user may want one without the other, and unlike
|
||||||
|
* ad blocking there is no per-host allowlist for it, since it isn't blocking anything.
|
||||||
|
*/
|
||||||
|
fun isCustomSiteTweaksEnabled(): Boolean = false
|
||||||
|
|
||||||
|
/** Whether a page's own fixed bottom bar should be hidden, per the in-drawer toggle. */
|
||||||
|
fun isHideSiteFootersEnabled(): Boolean = false
|
||||||
|
|
||||||
|
/** CSS for generic rules whose class or id turned up in the page's DOM. */
|
||||||
|
fun cssForTokens(host: String?, classNames: String, ids: String): String {
|
||||||
|
if (!enabled) return ""
|
||||||
|
if (isAllowlisted(host)) return ""
|
||||||
|
return engine?.cssForTokens(host, classNames, ids).orEmpty()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The `:has-text()` rules for [host], as a JSON array of `[selector, regexSource]` pairs, for
|
||||||
|
* the page's own JavaScript to apply -- there is no CSS equivalent for a text match, unlike
|
||||||
|
* every other cosmetic rule this engine serves.
|
||||||
|
*/
|
||||||
|
fun hasTextRulesForHost(host: String?): String {
|
||||||
|
if (!enabled) return "[]"
|
||||||
|
if (isAllowlisted(host)) return "[]"
|
||||||
|
val rules = engine?.hasTextRulesForHost(host) ?: return "[]"
|
||||||
|
if (rules.isEmpty()) return "[]"
|
||||||
|
|
||||||
|
val array = org.json.JSONArray()
|
||||||
|
for ((selector, pattern) in rules) {
|
||||||
|
array.put(org.json.JSONArray().put(selector).put(pattern))
|
||||||
|
}
|
||||||
|
return array.toString()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,170 @@
|
|||||||
|
package app.seb3thehacker.gearslip.browser.adblock
|
||||||
|
|
||||||
|
import android.webkit.WebResourceRequest
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resource types used by filter list options ($script, $image, ...).
|
||||||
|
*
|
||||||
|
* WebResourceRequest does not expose the resource type directly, so [infer] reconstructs it
|
||||||
|
* from the Sec-Fetch-Dest request header where WebView sends one, falling back to the file
|
||||||
|
* extension and then the Accept header.
|
||||||
|
*/
|
||||||
|
internal object ResourceType {
|
||||||
|
const val NONE = 0
|
||||||
|
const val DOCUMENT = 1 shl 0
|
||||||
|
const val SUBDOCUMENT = 1 shl 1
|
||||||
|
const val SCRIPT = 1 shl 2
|
||||||
|
const val IMAGE = 1 shl 3
|
||||||
|
const val STYLESHEET = 1 shl 4
|
||||||
|
const val XHR = 1 shl 5
|
||||||
|
const val FONT = 1 shl 6
|
||||||
|
const val MEDIA = 1 shl 7
|
||||||
|
const val WEBSOCKET = 1 shl 8
|
||||||
|
const val PING = 1 shl 9
|
||||||
|
const val OBJECT = 1 shl 10
|
||||||
|
const val OTHER = 1 shl 11
|
||||||
|
|
||||||
|
fun fromOptionName(name: String): Int = when (name) {
|
||||||
|
"document" -> DOCUMENT
|
||||||
|
"subdocument", "frame" -> SUBDOCUMENT
|
||||||
|
"script" -> SCRIPT
|
||||||
|
"image" -> IMAGE
|
||||||
|
"stylesheet", "css" -> STYLESHEET
|
||||||
|
"xmlhttprequest", "xhr" -> XHR
|
||||||
|
"font" -> FONT
|
||||||
|
"media" -> MEDIA
|
||||||
|
"websocket" -> WEBSOCKET
|
||||||
|
"ping", "beacon" -> PING
|
||||||
|
"object", "object-subrequest" -> OBJECT
|
||||||
|
"other" -> OTHER
|
||||||
|
else -> NONE
|
||||||
|
}
|
||||||
|
|
||||||
|
fun infer(request: WebResourceRequest): Int {
|
||||||
|
if (request.isForMainFrame) return DOCUMENT
|
||||||
|
|
||||||
|
val headers = request.requestHeaders
|
||||||
|
val dest = headers?.entries
|
||||||
|
?.firstOrNull { it.key.equals("Sec-Fetch-Dest", ignoreCase = true) }
|
||||||
|
?.value
|
||||||
|
?.lowercase()
|
||||||
|
|
||||||
|
if (!dest.isNullOrEmpty() && dest != "empty") {
|
||||||
|
val fromDest = when (dest) {
|
||||||
|
"document" -> DOCUMENT
|
||||||
|
"iframe", "frame", "embed" -> SUBDOCUMENT
|
||||||
|
"script", "worker", "sharedworker", "serviceworker" -> SCRIPT
|
||||||
|
"image" -> IMAGE
|
||||||
|
"style" -> STYLESHEET
|
||||||
|
"font" -> FONT
|
||||||
|
"audio", "video", "track", "audioworklet" -> MEDIA
|
||||||
|
"object" -> OBJECT
|
||||||
|
"report" -> PING
|
||||||
|
"manifest" -> OTHER
|
||||||
|
else -> NONE
|
||||||
|
}
|
||||||
|
if (fromDest != NONE) return fromDest
|
||||||
|
}
|
||||||
|
|
||||||
|
val fromExtension = extensionType(request.url?.path)
|
||||||
|
if (fromExtension != NONE) return fromExtension
|
||||||
|
|
||||||
|
val accept = headers?.entries
|
||||||
|
?.firstOrNull { it.key.equals("Accept", ignoreCase = true) }
|
||||||
|
?.value
|
||||||
|
?.lowercase()
|
||||||
|
if (accept != null) {
|
||||||
|
when {
|
||||||
|
accept.startsWith("image/") || accept.contains("image/avif") -> return IMAGE
|
||||||
|
accept.startsWith("text/css") -> return STYLESHEET
|
||||||
|
accept.startsWith("video/") || accept.startsWith("audio/") -> return MEDIA
|
||||||
|
accept.contains("application/javascript") -> return SCRIPT
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// "empty" is what fetch()/XMLHttpRequest report, and is also the fallback when
|
||||||
|
// WebView sends no Sec-Fetch-Dest at all.
|
||||||
|
return if (dest == "empty") XHR else OTHER
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun extensionType(path: String?): Int {
|
||||||
|
if (path.isNullOrEmpty()) return NONE
|
||||||
|
val dot = path.lastIndexOf('.')
|
||||||
|
if (dot < 0 || dot == path.length - 1) return NONE
|
||||||
|
return when (path.substring(dot + 1).lowercase()) {
|
||||||
|
"js", "mjs" -> SCRIPT
|
||||||
|
"css" -> STYLESHEET
|
||||||
|
"png", "jpg", "jpeg", "gif", "webp", "svg", "ico", "bmp", "avif" -> IMAGE
|
||||||
|
"woff", "woff2", "ttf", "otf", "eot" -> FONT
|
||||||
|
"mp4", "webm", "ogg", "mp3", "m4a", "m4s", "ts", "m3u8", "mpd" -> MEDIA
|
||||||
|
"json" -> XHR
|
||||||
|
"swf" -> OBJECT
|
||||||
|
else -> NONE
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Helpers for comparing hosts without pulling in a full public suffix list. */
|
||||||
|
internal object DomainUtils {
|
||||||
|
/**
|
||||||
|
* Common multi-label public suffixes. This is deliberately a short list rather than the
|
||||||
|
* full PSL: it is only used to decide first-party vs third-party, where being wrong means
|
||||||
|
* a $third-party rule misfires on an uncommon TLD, not a broken page.
|
||||||
|
*/
|
||||||
|
private val MULTI_LABEL_SUFFIXES = hashSetOf(
|
||||||
|
"co.uk", "org.uk", "me.uk", "ac.uk", "gov.uk", "net.uk", "sch.uk",
|
||||||
|
"co.jp", "ne.jp", "or.jp", "ac.jp", "go.jp",
|
||||||
|
"com.au", "net.au", "org.au", "edu.au", "gov.au",
|
||||||
|
"co.nz", "net.nz", "org.nz",
|
||||||
|
"com.br", "net.br", "org.br",
|
||||||
|
"co.in", "net.in", "org.in",
|
||||||
|
"com.cn", "net.cn", "org.cn",
|
||||||
|
"co.kr", "or.kr",
|
||||||
|
"com.mx", "com.ar", "com.tr", "com.tw", "com.hk", "com.sg", "com.my",
|
||||||
|
"co.za", "com.pl", "com.ua", "co.il", "com.vn", "com.ph", "co.id",
|
||||||
|
"com.es", "com.pt", "com.gr", "com.ro", "com.ru", "org.ru", "net.ru"
|
||||||
|
)
|
||||||
|
|
||||||
|
fun registrableDomain(host: String?): String? {
|
||||||
|
if (host.isNullOrEmpty()) return null
|
||||||
|
val normalized = host.trimEnd('.').lowercase()
|
||||||
|
if (normalized.isEmpty()) return null
|
||||||
|
if (normalized.indexOf(':') >= 0) return normalized
|
||||||
|
|
||||||
|
val labels = normalized.split('.')
|
||||||
|
if (labels.size <= 2) return normalized
|
||||||
|
if (labels.all { label -> label.isNotEmpty() && label.all { it in '0'..'9' } }) return normalized
|
||||||
|
|
||||||
|
val lastTwo = labels[labels.size - 2] + "." + labels[labels.size - 1]
|
||||||
|
if (lastTwo in MULTI_LABEL_SUFFIXES && labels.size >= 3) {
|
||||||
|
return labels[labels.size - 3] + "." + lastTwo
|
||||||
|
}
|
||||||
|
return lastTwo
|
||||||
|
}
|
||||||
|
|
||||||
|
fun isThirdParty(requestHost: String?, documentHost: String?): Boolean {
|
||||||
|
if (requestHost.isNullOrEmpty() || documentHost.isNullOrEmpty()) return false
|
||||||
|
val request = registrableDomain(requestHost) ?: return false
|
||||||
|
val document = registrableDomain(documentHost) ?: return false
|
||||||
|
return !request.equals(document, ignoreCase = true)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** True when [host] is [domain] itself or a subdomain of it. */
|
||||||
|
fun hostMatchesDomain(host: String, domain: String): Boolean {
|
||||||
|
if (host == domain) return true
|
||||||
|
return host.length > domain.length &&
|
||||||
|
host.endsWith(domain) &&
|
||||||
|
host[host.length - domain.length - 1] == '.'
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Emits `a.b.example.com`, `b.example.com`, `example.com`, `com`. */
|
||||||
|
inline fun forEachHostSuffix(host: String, action: (String) -> Unit) {
|
||||||
|
var index = 0
|
||||||
|
while (true) {
|
||||||
|
action(if (index == 0) host else host.substring(index))
|
||||||
|
val dot = host.indexOf('.', index)
|
||||||
|
if (dot < 0) return
|
||||||
|
index = dot + 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,336 @@
|
|||||||
|
package app.seb3thehacker.gearslip.browser.adblock
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.webkit.JavascriptInterface
|
||||||
|
import android.webkit.WebView
|
||||||
|
import androidx.webkit.WebViewCompat
|
||||||
|
import androidx.webkit.WebViewFeature
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Applies element-hiding rules to a page.
|
||||||
|
*
|
||||||
|
* Which rules apply depends on the page's hostname and on what is actually in its DOM, neither
|
||||||
|
* of which is known until the document starts loading, so the page fetches its own stylesheet
|
||||||
|
* through a JavaScript bridge. The bridge exposes two pure functions that return CSS: they read
|
||||||
|
* no page state and perform no action, so handing them to untrusted page content costs nothing.
|
||||||
|
*
|
||||||
|
* Hiding happens in two passes. At document start the page gets the rules written for its own
|
||||||
|
* host, which must land before anything renders — injecting on page finish would let ads appear
|
||||||
|
* and then vanish, which is worse than not hiding them. The generic rules follow once the DOM
|
||||||
|
* exists, keyed on the classes and ids actually present, because shipping all of them up front
|
||||||
|
* would mean roughly 400 KB of CSS per page.
|
||||||
|
*/
|
||||||
|
object CosmeticFilterBridge {
|
||||||
|
|
||||||
|
const val BRIDGE_OBJECT_NAME = "GearslipContentBlocker"
|
||||||
|
|
||||||
|
/** Stop watching for new elements after this long, to spare the CPU on a head unit. */
|
||||||
|
private const val OBSERVE_DURATION_MS = 30000
|
||||||
|
|
||||||
|
/** Upper bound on how many new class/id names are sent to the bridge in one pass. */
|
||||||
|
private const val MAX_TOKENS_PER_PASS = 4000
|
||||||
|
|
||||||
|
/** True when document-start injection is unavailable and the client must inject later. */
|
||||||
|
val needsPageStartFallback: Boolean
|
||||||
|
get() = !WebViewFeature.isFeatureSupported(WebViewFeature.DOCUMENT_START_SCRIPT)
|
||||||
|
|
||||||
|
val INJECTION_JS: String = """
|
||||||
|
(function () {
|
||||||
|
try {
|
||||||
|
if (window.__gearslipCosmetic) { return; }
|
||||||
|
var bridge = window.$BRIDGE_OBJECT_NAME;
|
||||||
|
if (!bridge || typeof bridge.cssForHost !== 'function') { return; }
|
||||||
|
|
||||||
|
var host = location.hostname;
|
||||||
|
var seenClass = Object.create(null);
|
||||||
|
var seenId = Object.create(null);
|
||||||
|
window.__gearslipCosmetic = true;
|
||||||
|
|
||||||
|
function addStyle(css) {
|
||||||
|
if (!css) { return; }
|
||||||
|
var style = document.createElement('style');
|
||||||
|
style.setAttribute('data-gearslip', 'content-blocker');
|
||||||
|
style.textContent = css;
|
||||||
|
var root = document.head || document.documentElement;
|
||||||
|
if (root) { root.appendChild(style); }
|
||||||
|
}
|
||||||
|
|
||||||
|
// The engine loads from disk in the background and may not be ready when the
|
||||||
|
// first page starts. Keep asking until it answers, so a page that loses the race
|
||||||
|
// still gets its hiding rules rather than none at all.
|
||||||
|
var hostCssApplied = false;
|
||||||
|
function applyHostCss() {
|
||||||
|
if (hostCssApplied) { return true; }
|
||||||
|
var css = bridge.cssForHost(host);
|
||||||
|
if (!css) { return false; }
|
||||||
|
hostCssApplied = true;
|
||||||
|
addStyle(css);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
applyHostCss();
|
||||||
|
var retries = [250, 750, 1500, 3000, 5000, 8000, 12000];
|
||||||
|
for (var r = 0; r < retries.length; r++) {
|
||||||
|
setTimeout(function () {
|
||||||
|
try {
|
||||||
|
if (!hostCssApplied && applyHostCss()) { collect(); }
|
||||||
|
} catch (e) {}
|
||||||
|
}, retries[r]);
|
||||||
|
}
|
||||||
|
|
||||||
|
var classes = [];
|
||||||
|
var ids = [];
|
||||||
|
|
||||||
|
function scan(root, budget) {
|
||||||
|
if (!root || root.nodeType !== 1) { return budget; }
|
||||||
|
var elements = root.getElementsByTagName('*');
|
||||||
|
var total = elements.length + 1;
|
||||||
|
for (var i = 0; i < total && budget > 0; i++) {
|
||||||
|
var element = (i === 0) ? root : elements[i - 1];
|
||||||
|
var id = element.id;
|
||||||
|
if (id && !seenId[id]) { seenId[id] = true; ids.push(id); budget--; }
|
||||||
|
// SVG elements expose className as an SVGAnimatedString, not a string.
|
||||||
|
var names = element.className;
|
||||||
|
if (names && typeof names === 'string') {
|
||||||
|
var parts = names.split(/\s+/);
|
||||||
|
for (var j = 0; j < parts.length && budget > 0; j++) {
|
||||||
|
var name = parts[j];
|
||||||
|
if (name && !seenClass[name]) {
|
||||||
|
seenClass[name] = true;
|
||||||
|
classes.push(name);
|
||||||
|
budget--;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return budget;
|
||||||
|
}
|
||||||
|
|
||||||
|
function collect() {
|
||||||
|
classes.length = 0;
|
||||||
|
ids.length = 0;
|
||||||
|
var budget = $MAX_TOKENS_PER_PASS;
|
||||||
|
if (!scannedOnce) {
|
||||||
|
scannedOnce = true;
|
||||||
|
budget = scan(document.documentElement, budget);
|
||||||
|
} else {
|
||||||
|
// Only look at what the page actually added. Re-walking the whole document
|
||||||
|
// on every mutation costs far more than it finds: on a large single-page app
|
||||||
|
// it means a full DOM traversal every 50ms while the page is starting up.
|
||||||
|
for (var i = 0; i < pending.length && budget > 0; i++) {
|
||||||
|
budget = scan(pending[i], budget);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
pending.length = 0;
|
||||||
|
if (classes.length || ids.length) {
|
||||||
|
addStyle(bridge.cssForTokens(host, classes.join(' '), ids.join(' ')));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var scannedOnce = false;
|
||||||
|
var pending = [];
|
||||||
|
var scheduled = false;
|
||||||
|
function schedule() {
|
||||||
|
if (scheduled) { return; }
|
||||||
|
scheduled = true;
|
||||||
|
setTimeout(function () {
|
||||||
|
scheduled = false;
|
||||||
|
try { collect(); } catch (e) {}
|
||||||
|
}, 50);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (document.readyState === 'loading') {
|
||||||
|
document.addEventListener('DOMContentLoaded', schedule);
|
||||||
|
} else {
|
||||||
|
schedule();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof MutationObserver === 'function' && document.documentElement) {
|
||||||
|
var observer = new MutationObserver(function (records) {
|
||||||
|
for (var i = 0; i < records.length; i++) {
|
||||||
|
var added = records[i].addedNodes;
|
||||||
|
for (var j = 0; j < added.length; j++) {
|
||||||
|
if (added[j].nodeType === 1) { pending.push(added[j]); }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// A burst larger than this is a page rebuilding itself; the tail is enough.
|
||||||
|
if (pending.length > 300) { pending.splice(0, pending.length - 300); }
|
||||||
|
if (pending.length) { schedule(); }
|
||||||
|
});
|
||||||
|
observer.observe(document.documentElement, { childList: true, subtree: true });
|
||||||
|
setTimeout(function () {
|
||||||
|
try { observer.disconnect(); } catch (e) {}
|
||||||
|
}, $OBSERVE_DURATION_MS);
|
||||||
|
}
|
||||||
|
} catch (e) {}
|
||||||
|
})();
|
||||||
|
(function () {
|
||||||
|
// Hides a site's own fixed bottom nav bar, which duplicates navigation the app
|
||||||
|
// already provides and costs a sixth of the screen on a ~480px-tall car display.
|
||||||
|
// Found by measuring rather than by a list of known selectors, so it works on any
|
||||||
|
// site. Toggled from the drawer.
|
||||||
|
//
|
||||||
|
// Hiding rather than repositioning is deliberate: an earlier version moved these
|
||||||
|
// bars instead, which fought sites that animate the bar's own position (Twitch
|
||||||
|
// slides its nav bar on scroll) and left it stranded mid-page. display:none has
|
||||||
|
// nothing to fight.
|
||||||
|
try {
|
||||||
|
if (window.__gearslipFooterHiding) { return; }
|
||||||
|
window.__gearslipFooterHiding = true;
|
||||||
|
var bridge = window.$BRIDGE_OBJECT_NAME;
|
||||||
|
if (!bridge || typeof bridge.hideSiteFootersEnabled !== 'function') { return; }
|
||||||
|
if (!bridge.hideSiteFootersEnabled()) { return; }
|
||||||
|
|
||||||
|
var MAX_BAR_HEIGHT = 140;
|
||||||
|
|
||||||
|
function hideBottomBars(root, depth) {
|
||||||
|
if (!root || depth < 0 || !root.children) { return; }
|
||||||
|
for (var i = 0; i < root.children.length; i++) {
|
||||||
|
var el = root.children[i];
|
||||||
|
if (el.__gearslipFooterHidden) { continue; }
|
||||||
|
var cs;
|
||||||
|
try { cs = getComputedStyle(el); } catch (e) { continue; }
|
||||||
|
if (cs.display === 'none') { continue; }
|
||||||
|
if (cs.position === 'fixed' || cs.position === 'sticky') {
|
||||||
|
var rect;
|
||||||
|
try { rect = el.getBoundingClientRect(); } catch (e) { rect = null; }
|
||||||
|
if (rect && rect.width >= window.innerWidth * 0.6 &&
|
||||||
|
rect.height > 0 && rect.height <= MAX_BAR_HEIGHT &&
|
||||||
|
Math.abs(window.innerHeight - rect.bottom) < 6) {
|
||||||
|
el.__gearslipFooterHidden = true;
|
||||||
|
el.style.setProperty('display', 'none', 'important');
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
hideBottomBars(el, depth - 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function run() {
|
||||||
|
try {
|
||||||
|
hideBottomBars(document.body, 3);
|
||||||
|
} catch (e) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (document.readyState === 'loading') {
|
||||||
|
document.addEventListener('DOMContentLoaded', run);
|
||||||
|
} else {
|
||||||
|
run();
|
||||||
|
}
|
||||||
|
// Single-page apps mount their nav bar well after first paint, and swap it out
|
||||||
|
// again on navigation, so one pass at load isn't enough.
|
||||||
|
var passes = 0;
|
||||||
|
var timer = setInterval(function () {
|
||||||
|
run();
|
||||||
|
if (++passes > 40) { clearInterval(timer); }
|
||||||
|
}, 500);
|
||||||
|
} catch (e) {}
|
||||||
|
})();
|
||||||
|
(function () {
|
||||||
|
// ":has-text(pattern)" cosmetic rules: hides elements matching a base selector whose
|
||||||
|
// own text also matches a pattern. There is no CSS equivalent for the text half, so
|
||||||
|
// this runs the check in JavaScript instead of the plain <style> the rest of the
|
||||||
|
// cosmetic filters use.
|
||||||
|
try {
|
||||||
|
if (window.__gearslipHasText) { return; }
|
||||||
|
var bridge = window.$BRIDGE_OBJECT_NAME;
|
||||||
|
if (!bridge || typeof bridge.hasTextRulesForHost !== 'function') { return; }
|
||||||
|
|
||||||
|
var rules;
|
||||||
|
try {
|
||||||
|
rules = JSON.parse(bridge.hasTextRulesForHost(location.hostname) || '[]');
|
||||||
|
} catch (e) {
|
||||||
|
rules = [];
|
||||||
|
}
|
||||||
|
if (!rules.length) { return; }
|
||||||
|
window.__gearslipHasText = true;
|
||||||
|
|
||||||
|
var compiled = [];
|
||||||
|
for (var i = 0; i < rules.length; i++) {
|
||||||
|
try {
|
||||||
|
compiled.push({ selector: rules[i][0], regex: new RegExp(rules[i][1], 'i') });
|
||||||
|
} catch (e) {}
|
||||||
|
}
|
||||||
|
if (!compiled.length) { return; }
|
||||||
|
|
||||||
|
function run() {
|
||||||
|
for (var i = 0; i < compiled.length; i++) {
|
||||||
|
var rule = compiled[i];
|
||||||
|
var elements;
|
||||||
|
try { elements = document.querySelectorAll(rule.selector); } catch (e) { continue; }
|
||||||
|
for (var j = 0; j < elements.length; j++) {
|
||||||
|
var el = elements[j];
|
||||||
|
if (el.__gearslipHasTextHidden) { continue; }
|
||||||
|
var text = el.textContent;
|
||||||
|
if (text && rule.regex.test(text)) {
|
||||||
|
el.__gearslipHasTextHidden = true;
|
||||||
|
el.style.setProperty('display', 'none', 'important');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (document.readyState === 'loading') {
|
||||||
|
document.addEventListener('DOMContentLoaded', run);
|
||||||
|
} else {
|
||||||
|
run();
|
||||||
|
}
|
||||||
|
// Matches uBlock Origin's own behaviour for this rule type: it re-evaluates on a
|
||||||
|
// timer rather than per-mutation, since a text match can only be judged once an
|
||||||
|
// element's subtree has actually finished changing.
|
||||||
|
var passes = 0;
|
||||||
|
var timer = setInterval(function () {
|
||||||
|
run();
|
||||||
|
if (++passes > 40) { clearInterval(timer); }
|
||||||
|
}, 500);
|
||||||
|
} catch (e) {}
|
||||||
|
})();
|
||||||
|
""".trimIndent()
|
||||||
|
|
||||||
|
fun attach(webView: WebView) {
|
||||||
|
webView.addJavascriptInterface(Bridge(webView.context.applicationContext), BRIDGE_OBJECT_NAME)
|
||||||
|
if (!needsPageStartFallback) {
|
||||||
|
runCatching {
|
||||||
|
WebViewCompat.addDocumentStartJavaScript(webView, INJECTION_JS, setOf("*"))
|
||||||
|
}
|
||||||
|
runCatching {
|
||||||
|
WebViewCompat.addDocumentStartJavaScript(
|
||||||
|
webView,
|
||||||
|
YouTubeAdBlock.INJECTION_JS,
|
||||||
|
setOf("*")
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class Bridge(private val context: Context) {
|
||||||
|
@JavascriptInterface
|
||||||
|
fun cssForHost(host: String?): String = runCatching {
|
||||||
|
AdBlockManager.cssForHost(host)
|
||||||
|
}.getOrDefault("")
|
||||||
|
|
||||||
|
@JavascriptInterface
|
||||||
|
fun youtubeBlockingEnabled(host: String?): Boolean = runCatching {
|
||||||
|
AdBlockManager.isYouTubeBlockingEnabled(host)
|
||||||
|
}.getOrDefault(false)
|
||||||
|
|
||||||
|
@JavascriptInterface
|
||||||
|
fun customSiteTweaksEnabled(): Boolean = runCatching {
|
||||||
|
AdBlockManager.isCustomSiteTweaksEnabled()
|
||||||
|
}.getOrDefault(false)
|
||||||
|
|
||||||
|
@JavascriptInterface
|
||||||
|
fun hideSiteFootersEnabled(): Boolean = runCatching {
|
||||||
|
AdBlockManager.isHideSiteFootersEnabled()
|
||||||
|
}.getOrDefault(false)
|
||||||
|
|
||||||
|
@JavascriptInterface
|
||||||
|
fun hasTextRulesForHost(host: String?): String = runCatching {
|
||||||
|
AdBlockManager.hasTextRulesForHost(host)
|
||||||
|
}.getOrDefault("[]")
|
||||||
|
|
||||||
|
@JavascriptInterface
|
||||||
|
fun cssForTokens(host: String?, classNames: String?, ids: String?): String = runCatching {
|
||||||
|
AdBlockManager.cssForTokens(host, classNames.orEmpty(), ids.orEmpty())
|
||||||
|
}.getOrDefault("")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,610 @@
|
|||||||
|
package app.seb3thehacker.gearslip.browser.adblock
|
||||||
|
|
||||||
|
import android.util.LruCache
|
||||||
|
|
||||||
|
/**
|
||||||
|
* An immutable, queryable index over a set of parsed filter rules.
|
||||||
|
*
|
||||||
|
* Network rules are bucketed so that a request only ever tests a handful of them:
|
||||||
|
* - `||host^` rules are indexed by host and found by walking the request host's suffixes;
|
||||||
|
* - every other rule is indexed under one literal token from its pattern, and a request only
|
||||||
|
* tests rules whose token appears in the URL;
|
||||||
|
* - the few rules with no usable token are tested on every request.
|
||||||
|
*
|
||||||
|
* Element-hiding rules are split the same way. Nearly all generic hiding rules are anchored on
|
||||||
|
* a single class or id (`.ad-banner`, `#sponsored`), so they are indexed by that name and only
|
||||||
|
* handed to a page once that class or id actually appears in its DOM. Injecting all of them
|
||||||
|
* unconditionally would mean roughly 400 KB of CSS on every page load.
|
||||||
|
*/
|
||||||
|
internal class FilterEngine private constructor(
|
||||||
|
private val blockRules: RuleSet,
|
||||||
|
private val allowRules: RuleSet,
|
||||||
|
private val genericComplexSelectors: List<String>,
|
||||||
|
private val genericByClass: HashMap<String, MutableList<String>>,
|
||||||
|
private val genericById: HashMap<String, MutableList<String>>,
|
||||||
|
private val specificSelectors: HashMap<String, MutableList<String>>,
|
||||||
|
private val cosmeticExceptions: HashMap<String, MutableList<String>>,
|
||||||
|
private val genericHasText: List<Pair<String, String>>,
|
||||||
|
private val specificHasText: HashMap<String, MutableList<Pair<String, String>>>,
|
||||||
|
val networkRuleCount: Int,
|
||||||
|
val cosmeticRuleCount: Int
|
||||||
|
) {
|
||||||
|
|
||||||
|
private val hostCssCache = LruCache<String, String>(32)
|
||||||
|
|
||||||
|
/** True when the request should be blocked at all, regardless of which rule matched. */
|
||||||
|
fun shouldBlock(
|
||||||
|
url: String,
|
||||||
|
requestHost: String?,
|
||||||
|
documentHost: String?,
|
||||||
|
type: Int,
|
||||||
|
isThirdParty: Boolean
|
||||||
|
): Boolean = matchBlockingRule(url, requestHost, documentHost, type, isThirdParty) != null
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The block match for this request, or null when it is not blocked. Split out from
|
||||||
|
* [shouldBlock] so the caller can also read the matched rule's `$redirect=` resource name
|
||||||
|
* without a second, separate walk over the same rule sets. A plain `||host^` match (the
|
||||||
|
* common case) carries no [NetworkRule] object at all, hence [BlockMatch] rather than
|
||||||
|
* returning the rule directly.
|
||||||
|
*/
|
||||||
|
fun matchBlockingRule(
|
||||||
|
url: String,
|
||||||
|
requestHost: String?,
|
||||||
|
documentHost: String?,
|
||||||
|
type: Int,
|
||||||
|
isThirdParty: Boolean
|
||||||
|
): BlockMatch? {
|
||||||
|
val lowerUrl = url.lowercase()
|
||||||
|
val hostStart = hostStart(lowerUrl)
|
||||||
|
val hostEnd = hostEnd(lowerUrl, hostStart)
|
||||||
|
val tokens = ArrayList<String>(16)
|
||||||
|
tokenize(lowerUrl, tokens)
|
||||||
|
|
||||||
|
val blockingMatch = matchingRule(blockRules, lowerUrl, hostStart, hostEnd, requestHost, documentHost, type, isThirdParty, tokens)
|
||||||
|
?: return null
|
||||||
|
if (matchesAny(allowRules, lowerUrl, hostStart, hostEnd, requestHost, documentHost, type, isThirdParty, tokens)) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
return blockingMatch
|
||||||
|
}
|
||||||
|
|
||||||
|
class BlockMatch(val redirectResource: String?) {
|
||||||
|
companion object {
|
||||||
|
val PLAIN = BlockMatch(null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The stylesheet injected at document start: rules written for this site, plus the handful
|
||||||
|
* of generic rules that are not anchored on a class or id.
|
||||||
|
*/
|
||||||
|
fun cssForHost(host: String?): String {
|
||||||
|
val normalized = host?.lowercase().orEmpty()
|
||||||
|
hostCssCache.get(normalized)?.let { return it }
|
||||||
|
|
||||||
|
val exceptions = exceptionsFor(normalized)
|
||||||
|
val selectors = ArrayList<String>(genericComplexSelectors.size + 32)
|
||||||
|
if (normalized.isNotEmpty()) {
|
||||||
|
DomainUtils.forEachHostSuffix(normalized) { suffix ->
|
||||||
|
specificSelectors[suffix]?.let { selectors.addAll(it) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
selectors.addAll(genericComplexSelectors)
|
||||||
|
|
||||||
|
val css = buildHidingCss(selectors, exceptions)
|
||||||
|
hostCssCache.put(normalized, css)
|
||||||
|
return css
|
||||||
|
}
|
||||||
|
|
||||||
|
private val hasTextCache = LruCache<String, List<Pair<String, String>>>(32)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The `:has-text()` rules (base selector, JS regex source) that apply to [host]. Delivered
|
||||||
|
* separately from [cssForHost] because there is no CSS equivalent for a text match -- the
|
||||||
|
* caller runs these with JavaScript instead of a `<style>` element.
|
||||||
|
*/
|
||||||
|
fun hasTextRulesForHost(host: String?): List<Pair<String, String>> {
|
||||||
|
val normalized = host?.lowercase().orEmpty()
|
||||||
|
hasTextCache.get(normalized)?.let { return it }
|
||||||
|
|
||||||
|
val rules = ArrayList<Pair<String, String>>(genericHasText.size + 4)
|
||||||
|
if (normalized.isNotEmpty()) {
|
||||||
|
DomainUtils.forEachHostSuffix(normalized) { suffix ->
|
||||||
|
specificHasText[suffix]?.let { rules.addAll(it) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
rules.addAll(genericHasText)
|
||||||
|
|
||||||
|
hasTextCache.put(normalized, rules)
|
||||||
|
return rules
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The stylesheet for generic rules whose class or id has turned up in the page. [classNames]
|
||||||
|
* and [ids] are whitespace-separated, as collected from the live DOM.
|
||||||
|
*/
|
||||||
|
fun cssForTokens(host: String?, classNames: String, ids: String): String {
|
||||||
|
val normalized = host?.lowercase().orEmpty()
|
||||||
|
val exceptions = exceptionsFor(normalized)
|
||||||
|
val selectors = ArrayList<String>()
|
||||||
|
|
||||||
|
collectByToken(classNames, genericByClass, selectors)
|
||||||
|
collectByToken(ids, genericById, selectors)
|
||||||
|
if (selectors.isEmpty()) return ""
|
||||||
|
|
||||||
|
return buildHidingCss(selectors, exceptions)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun collectByToken(
|
||||||
|
tokens: String,
|
||||||
|
index: HashMap<String, MutableList<String>>,
|
||||||
|
out: MutableList<String>
|
||||||
|
) {
|
||||||
|
if (tokens.isEmpty() || index.isEmpty()) return
|
||||||
|
var start = 0
|
||||||
|
val length = tokens.length
|
||||||
|
while (start < length) {
|
||||||
|
while (start < length && tokens[start].isWhitespace()) start++
|
||||||
|
if (start >= length) break
|
||||||
|
var end = start
|
||||||
|
while (end < length && !tokens[end].isWhitespace()) end++
|
||||||
|
index[tokens.substring(start, end)]?.let { out.addAll(it) }
|
||||||
|
start = end
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun exceptionsFor(host: String): Set<String> {
|
||||||
|
if (host.isEmpty() || cosmeticExceptions.isEmpty()) return emptySet()
|
||||||
|
var result: MutableSet<String>? = null
|
||||||
|
DomainUtils.forEachHostSuffix(host) { suffix ->
|
||||||
|
cosmeticExceptions[suffix]?.let { exceptions ->
|
||||||
|
val target = result ?: HashSet<String>().also { result = it }
|
||||||
|
target.addAll(exceptions)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result ?: emptySet()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun buildHidingCss(selectors: List<String>, exceptions: Set<String>): String {
|
||||||
|
val usable = if (exceptions.isEmpty()) {
|
||||||
|
selectors
|
||||||
|
} else {
|
||||||
|
selectors.filterNot { it in exceptions }
|
||||||
|
}
|
||||||
|
if (usable.isEmpty()) return ""
|
||||||
|
|
||||||
|
val builder = StringBuilder(usable.size * 24)
|
||||||
|
// One rule per chunk rather than one rule for everything: a browser drops an entire
|
||||||
|
// rule if any selector in it fails to parse, so a chunk limits that blast radius.
|
||||||
|
for (chunk in usable.chunked(SELECTORS_PER_RULE)) {
|
||||||
|
builder.append(chunk.joinToString(","))
|
||||||
|
builder.append("{display:none!important}")
|
||||||
|
}
|
||||||
|
return builder.toString()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun matchesAny(
|
||||||
|
ruleSet: RuleSet,
|
||||||
|
url: String,
|
||||||
|
hostStart: Int,
|
||||||
|
hostEnd: Int,
|
||||||
|
requestHost: String?,
|
||||||
|
documentHost: String?,
|
||||||
|
type: Int,
|
||||||
|
isThirdParty: Boolean,
|
||||||
|
tokens: List<String>
|
||||||
|
): Boolean {
|
||||||
|
if (!requestHost.isNullOrEmpty()) {
|
||||||
|
var found = false
|
||||||
|
DomainUtils.forEachHostSuffix(requestHost) { suffix ->
|
||||||
|
if (!found) {
|
||||||
|
if (suffix in ruleSet.plainHosts) {
|
||||||
|
found = true
|
||||||
|
} else {
|
||||||
|
val candidates = ruleSet.hostIndex[suffix]
|
||||||
|
if (candidates != null) {
|
||||||
|
for (rule in candidates) {
|
||||||
|
if (optionsMatch(rule, type, isThirdParty, documentHost)) {
|
||||||
|
found = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (found) return true
|
||||||
|
}
|
||||||
|
|
||||||
|
for (token in tokens) {
|
||||||
|
val candidates = ruleSet.tokenIndex[token] ?: continue
|
||||||
|
for (rule in candidates) {
|
||||||
|
if (optionsMatch(rule, type, isThirdParty, documentHost) &&
|
||||||
|
matchesUrl(rule, url, hostStart, hostEnd)
|
||||||
|
) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (rule in ruleSet.catchAll) {
|
||||||
|
if (optionsMatch(rule, type, isThirdParty, documentHost) &&
|
||||||
|
matchesUrl(rule, url, hostStart, hostEnd)
|
||||||
|
) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Same walk as [matchesAny], but returns the match itself instead of a plain boolean. */
|
||||||
|
private fun matchingRule(
|
||||||
|
ruleSet: RuleSet,
|
||||||
|
url: String,
|
||||||
|
hostStart: Int,
|
||||||
|
hostEnd: Int,
|
||||||
|
requestHost: String?,
|
||||||
|
documentHost: String?,
|
||||||
|
type: Int,
|
||||||
|
isThirdParty: Boolean,
|
||||||
|
tokens: List<String>
|
||||||
|
): BlockMatch? {
|
||||||
|
if (!requestHost.isNullOrEmpty()) {
|
||||||
|
var result: BlockMatch? = null
|
||||||
|
DomainUtils.forEachHostSuffix(requestHost) { suffix ->
|
||||||
|
if (result == null) {
|
||||||
|
if (suffix in ruleSet.plainHosts) {
|
||||||
|
result = BlockMatch.PLAIN
|
||||||
|
} else {
|
||||||
|
val candidates = ruleSet.hostIndex[suffix]
|
||||||
|
if (candidates != null) {
|
||||||
|
for (rule in candidates) {
|
||||||
|
if (optionsMatch(rule, type, isThirdParty, documentHost)) {
|
||||||
|
result = BlockMatch(rule.redirectResource)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (result != null) return result
|
||||||
|
}
|
||||||
|
|
||||||
|
for (token in tokens) {
|
||||||
|
val candidates = ruleSet.tokenIndex[token] ?: continue
|
||||||
|
for (rule in candidates) {
|
||||||
|
if (optionsMatch(rule, type, isThirdParty, documentHost) &&
|
||||||
|
matchesUrl(rule, url, hostStart, hostEnd)
|
||||||
|
) {
|
||||||
|
return BlockMatch(rule.redirectResource)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (rule in ruleSet.catchAll) {
|
||||||
|
if (optionsMatch(rule, type, isThirdParty, documentHost) &&
|
||||||
|
matchesUrl(rule, url, hostStart, hostEnd)
|
||||||
|
) {
|
||||||
|
return BlockMatch(rule.redirectResource)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun optionsMatch(
|
||||||
|
rule: NetworkRule,
|
||||||
|
type: Int,
|
||||||
|
isThirdParty: Boolean,
|
||||||
|
documentHost: String?
|
||||||
|
): Boolean {
|
||||||
|
if (rule.typeMask != 0 && (rule.typeMask and type) == 0) return false
|
||||||
|
if (rule.excludedTypeMask != 0 && (rule.excludedTypeMask and type) != 0) return false
|
||||||
|
|
||||||
|
val thirdParty = rule.thirdParty
|
||||||
|
if (thirdParty != null && thirdParty != isThirdParty) return false
|
||||||
|
|
||||||
|
val include = rule.includeDomains
|
||||||
|
val exclude = rule.excludeDomains
|
||||||
|
if (include != null || exclude != null) {
|
||||||
|
val host = documentHost ?: return false
|
||||||
|
if (exclude != null && exclude.any { DomainUtils.hostMatchesDomain(host, it) }) return false
|
||||||
|
if (include != null && !include.any { DomainUtils.hostMatchesDomain(host, it) }) return false
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun matchesUrl(rule: NetworkRule, url: String, hostStart: Int, hostEnd: Int): Boolean {
|
||||||
|
if (rule.anchorDomain) {
|
||||||
|
if (matchFrom(url, hostStart, rule)) return true
|
||||||
|
var index = hostStart
|
||||||
|
while (index < hostEnd) {
|
||||||
|
if (url[index] == '.' && matchFrom(url, index + 1, rule)) return true
|
||||||
|
index++
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if (rule.anchorStart) return matchFrom(url, 0, rule)
|
||||||
|
|
||||||
|
val first = rule.segments[0]
|
||||||
|
if (first.isNotEmpty() && first.indexOf('^') < 0) {
|
||||||
|
var index = url.indexOf(first)
|
||||||
|
while (index >= 0) {
|
||||||
|
if (matchFrom(url, index, rule)) return true
|
||||||
|
index = url.indexOf(first, index + 1)
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
var index = 0
|
||||||
|
while (index <= url.length) {
|
||||||
|
if (matchFrom(url, index, rule)) return true
|
||||||
|
index++
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun matchFrom(url: String, start: Int, rule: NetworkRule): Boolean {
|
||||||
|
val segments = rule.segments
|
||||||
|
var position = matchSegmentAt(url, start, segments[0])
|
||||||
|
if (position < 0) return false
|
||||||
|
|
||||||
|
for (i in 1 until segments.size) {
|
||||||
|
val segment = segments[i]
|
||||||
|
if (segment.isEmpty()) continue
|
||||||
|
var probe = position
|
||||||
|
var matchedEnd = -1
|
||||||
|
while (probe <= url.length) {
|
||||||
|
val end = matchSegmentAt(url, probe, segment)
|
||||||
|
if (end >= 0) {
|
||||||
|
matchedEnd = end
|
||||||
|
break
|
||||||
|
}
|
||||||
|
probe++
|
||||||
|
}
|
||||||
|
if (matchedEnd < 0) return false
|
||||||
|
position = matchedEnd
|
||||||
|
}
|
||||||
|
|
||||||
|
if (rule.anchorEnd && segments[segments.size - 1].isNotEmpty() && position != url.length) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Returns the index just past the match, or -1. `^` matches one separator or end-of-URL. */
|
||||||
|
private fun matchSegmentAt(url: String, start: Int, segment: String): Int {
|
||||||
|
var position = start
|
||||||
|
for (i in segment.indices) {
|
||||||
|
val expected = segment[i]
|
||||||
|
if (expected == '^') {
|
||||||
|
if (position >= url.length) {
|
||||||
|
// A separator may stand for the end of the address, but only as the last
|
||||||
|
// character of the segment.
|
||||||
|
return if (i == segment.length - 1) position else -1
|
||||||
|
}
|
||||||
|
if (!isSeparator(url[position])) return -1
|
||||||
|
position++
|
||||||
|
} else {
|
||||||
|
if (position >= url.length || url[position] != expected) return -1
|
||||||
|
position++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return position
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private const val MIN_TOKEN_LENGTH = 3
|
||||||
|
private const val SELECTORS_PER_RULE = 100
|
||||||
|
|
||||||
|
private fun isSeparator(c: Char): Boolean = when {
|
||||||
|
c in 'a'..'z' -> false
|
||||||
|
c in 'A'..'Z' -> false
|
||||||
|
c in '0'..'9' -> false
|
||||||
|
c == '_' || c == '-' || c == '.' || c == '%' -> false
|
||||||
|
else -> true
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun hostStart(url: String): Int {
|
||||||
|
val scheme = url.indexOf("://")
|
||||||
|
return if (scheme < 0) 0 else scheme + 3
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun hostEnd(url: String, hostStart: Int): Int {
|
||||||
|
var index = hostStart
|
||||||
|
while (index < url.length) {
|
||||||
|
val c = url[index]
|
||||||
|
if (c == '/' || c == '?' || c == '#' || c == ':') return index
|
||||||
|
index++
|
||||||
|
}
|
||||||
|
return url.length
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun tokenize(text: String, out: MutableList<String>) {
|
||||||
|
var index = 0
|
||||||
|
val length = text.length
|
||||||
|
while (index < length) {
|
||||||
|
val c = text[index]
|
||||||
|
if (c.isLetterOrDigit()) {
|
||||||
|
var end = index + 1
|
||||||
|
while (end < length && text[end].isLetterOrDigit()) end++
|
||||||
|
if (end - index >= MIN_TOKEN_LENGTH) out.add(text.substring(index, end))
|
||||||
|
index = end
|
||||||
|
} else {
|
||||||
|
index++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The longest literal token in the pattern, used as the rule's index key. */
|
||||||
|
private fun bestToken(rule: NetworkRule): String? {
|
||||||
|
var best: String? = null
|
||||||
|
val tokens = ArrayList<String>(4)
|
||||||
|
for (segment in rule.segments) {
|
||||||
|
tokens.clear()
|
||||||
|
tokenize(segment, tokens)
|
||||||
|
for (token in tokens) {
|
||||||
|
val current = best
|
||||||
|
if (current == null || token.length > current.length) best = token
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return best
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The leading class or id a selector is anchored on, as (`.` or `#`) to name, or null
|
||||||
|
* when the selector does not start with a plain class or id.
|
||||||
|
*/
|
||||||
|
private fun anchorToken(selector: String): Pair<Char, String>? {
|
||||||
|
if (selector.length < 2) return null
|
||||||
|
val kind = selector[0]
|
||||||
|
if (kind != '.' && kind != '#') return null
|
||||||
|
var end = 1
|
||||||
|
while (end < selector.length) {
|
||||||
|
val c = selector[end]
|
||||||
|
if (c.isLetterOrDigit() || c == '-' || c == '_') end++ else break
|
||||||
|
}
|
||||||
|
if (end == 1) return null
|
||||||
|
return kind to selector.substring(1, end)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun build(lines: Sequence<String>): FilterEngine {
|
||||||
|
val builder = Builder()
|
||||||
|
for (line in lines) builder.addLine(line)
|
||||||
|
return builder.finish()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Accumulates rules one line at a time, fed straight from a reader so that the whole list
|
||||||
|
* never exists in memory as a collection of lines. Parsing EasyList and EasyPrivacy
|
||||||
|
* together takes roughly 250ms on a Pixel 6 in a release build. Be wary of profiling this
|
||||||
|
* in a debug build: `android:debuggable` disables ART's inlining and made the same work
|
||||||
|
* take over nine seconds, which looks like an algorithmic problem but is not one.
|
||||||
|
*/
|
||||||
|
internal class Builder {
|
||||||
|
private val blockRules = RuleSet()
|
||||||
|
private val allowRules = RuleSet()
|
||||||
|
private val genericComplex = LinkedHashSet<String>()
|
||||||
|
// Pre-sized: EasyList and EasyPrivacy together fill these to roughly these counts, and
|
||||||
|
// rehashing a 90k-entry map on the way there is pure waste.
|
||||||
|
private val genericByClass = HashMap<String, MutableList<String>>(16384)
|
||||||
|
private val genericById = HashMap<String, MutableList<String>>(8192)
|
||||||
|
private val specificSelectors = HashMap<String, MutableList<String>>(16384)
|
||||||
|
private val cosmeticExceptions = HashMap<String, MutableList<String>>(1024)
|
||||||
|
private val genericHasText = mutableListOf<Pair<String, String>>()
|
||||||
|
private val specificHasText = HashMap<String, MutableList<Pair<String, String>>>()
|
||||||
|
private var networkRuleCount = 0
|
||||||
|
private var cosmeticRuleCount = 0
|
||||||
|
|
||||||
|
fun addLine(rawLine: String) {
|
||||||
|
if (rawLine.isEmpty()) return
|
||||||
|
// Only pay for trim() on the rare line that actually has surrounding whitespace.
|
||||||
|
val needsTrim = rawLine[0] == ' ' || rawLine[0] == '\t' ||
|
||||||
|
rawLine[rawLine.length - 1] == ' ' || rawLine[rawLine.length - 1] == '\t'
|
||||||
|
val line = if (needsTrim) rawLine.trim() else rawLine
|
||||||
|
if (line.isEmpty()) return
|
||||||
|
if (line[0] == '!' || line[0] == '[') return
|
||||||
|
|
||||||
|
val separator = FilterParser.cosmeticSeparatorIndex(line)
|
||||||
|
if (separator >= 0) {
|
||||||
|
addCosmeticRule(line, separator)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
val isException = line.startsWith("@@")
|
||||||
|
val rule = FilterParser.parseNetworkRule(line) ?: return
|
||||||
|
networkRuleCount++
|
||||||
|
val target = if (isException) allowRules else blockRules
|
||||||
|
|
||||||
|
val hostAnchor = rule.hostAnchor
|
||||||
|
if (hostAnchor != null) {
|
||||||
|
if (rule.hasNoOptions) {
|
||||||
|
target.plainHosts.add(hostAnchor)
|
||||||
|
} else {
|
||||||
|
target.hostIndex.getOrPut(hostAnchor) { mutableListOf() }.add(rule)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
val token = bestToken(rule)
|
||||||
|
if (token != null) {
|
||||||
|
target.tokenIndex.getOrPut(token) { mutableListOf() }.add(rule)
|
||||||
|
} else {
|
||||||
|
target.catchAll.add(rule)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun addCosmeticRule(line: String, separator: Int) {
|
||||||
|
val rule = FilterParser.parseCosmeticRule(line, separator) ?: return
|
||||||
|
cosmeticRuleCount++
|
||||||
|
|
||||||
|
// :has-text() has no CSS equivalent, so it is matched by JavaScript from its own
|
||||||
|
// list instead of joining the CSS selector strings above. Exceptions for it are not
|
||||||
|
// supported -- rare enough in EasyList/EasyPrivacy that dropping them outright (by
|
||||||
|
// not indexing this rule) is a safe simplification rather than a real gap.
|
||||||
|
val textPattern = rule.hasTextPattern
|
||||||
|
if (textPattern != null) {
|
||||||
|
if (rule.isException) return
|
||||||
|
val entry = rule.selector to textPattern
|
||||||
|
if (rule.includeDomains != null) {
|
||||||
|
rule.includeDomains.forEach { domain ->
|
||||||
|
specificHasText.getOrPut(domain) { mutableListOf() }.add(entry)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
genericHasText.add(entry)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
when {
|
||||||
|
rule.isException -> {
|
||||||
|
rule.includeDomains?.forEach { domain ->
|
||||||
|
cosmeticExceptions.getOrPut(domain) { mutableListOf() }.add(rule.selector)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
rule.includeDomains != null -> {
|
||||||
|
rule.includeDomains.forEach { domain ->
|
||||||
|
specificSelectors.getOrPut(domain) { mutableListOf() }.add(rule.selector)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else -> {
|
||||||
|
val anchor = anchorToken(rule.selector)
|
||||||
|
if (anchor == null) {
|
||||||
|
genericComplex.add(rule.selector)
|
||||||
|
} else {
|
||||||
|
val index = if (anchor.first == '.') genericByClass else genericById
|
||||||
|
index.getOrPut(anchor.second) { mutableListOf() }.add(rule.selector)
|
||||||
|
}
|
||||||
|
// `~example.com##.ad` hides everywhere except the excluded domains.
|
||||||
|
rule.excludeDomains?.forEach { domain ->
|
||||||
|
cosmeticExceptions.getOrPut(domain) { mutableListOf() }.add(rule.selector)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun finish(): FilterEngine = FilterEngine(
|
||||||
|
blockRules = blockRules,
|
||||||
|
allowRules = allowRules,
|
||||||
|
genericComplexSelectors = genericComplex.toList(),
|
||||||
|
genericByClass = genericByClass,
|
||||||
|
genericById = genericById,
|
||||||
|
specificSelectors = specificSelectors,
|
||||||
|
cosmeticExceptions = cosmeticExceptions,
|
||||||
|
genericHasText = genericHasText,
|
||||||
|
specificHasText = specificHasText,
|
||||||
|
networkRuleCount = networkRuleCount,
|
||||||
|
cosmeticRuleCount = cosmeticRuleCount
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
internal class RuleSet {
|
||||||
|
/** Bare `||host^` blocks with no options: matching the host is the whole test. */
|
||||||
|
val plainHosts = HashSet<String>()
|
||||||
|
val hostIndex = HashMap<String, MutableList<NetworkRule>>()
|
||||||
|
val tokenIndex = HashMap<String, MutableList<NetworkRule>>()
|
||||||
|
val catchAll = ArrayList<NetworkRule>()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,330 @@
|
|||||||
|
package app.seb3thehacker.gearslip.browser.adblock
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A parsed network filter rule (Adblock Plus syntax subset).
|
||||||
|
*
|
||||||
|
* [segments] is the pattern split on `*`; each segment is matched literally except for `^`,
|
||||||
|
* which stands for a single separator character (or the end of the URL).
|
||||||
|
*/
|
||||||
|
internal class NetworkRule(
|
||||||
|
val segments: Array<String>,
|
||||||
|
val anchorDomain: Boolean,
|
||||||
|
val anchorStart: Boolean,
|
||||||
|
val anchorEnd: Boolean,
|
||||||
|
val typeMask: Int,
|
||||||
|
val excludedTypeMask: Int,
|
||||||
|
val thirdParty: Boolean?,
|
||||||
|
val includeDomains: Array<String>?,
|
||||||
|
val excludeDomains: Array<String>?,
|
||||||
|
/** Set when the whole pattern is a bare `||host^`, which lets the engine index it by host. */
|
||||||
|
val hostAnchor: String?,
|
||||||
|
/** Set for `$redirect=name` / `$redirect-rule=name` rules with a resource we can serve. */
|
||||||
|
val redirectResource: String?
|
||||||
|
) {
|
||||||
|
/**
|
||||||
|
* True when the rule carries no options, so matching the host is the whole test. The great
|
||||||
|
* majority of list entries are of this shape and the engine stores them as bare strings.
|
||||||
|
*/
|
||||||
|
val hasNoOptions: Boolean
|
||||||
|
get() = typeMask == 0 && excludedTypeMask == 0 && thirdParty == null &&
|
||||||
|
includeDomains == null && excludeDomains == null && redirectResource == null
|
||||||
|
}
|
||||||
|
|
||||||
|
internal class CosmeticRule(
|
||||||
|
val selector: String,
|
||||||
|
val includeDomains: List<String>?,
|
||||||
|
val excludeDomains: List<String>?,
|
||||||
|
val isException: Boolean,
|
||||||
|
/** Set for a `selector:has-text(pattern)` rule: the text [pattern] to match within
|
||||||
|
* [selector]'s matches, applied by JavaScript rather than as plain CSS. */
|
||||||
|
val hasTextPattern: String? = null
|
||||||
|
)
|
||||||
|
|
||||||
|
internal object FilterParser {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Options that change *how* a request is answered rather than whether it is blocked.
|
||||||
|
* A rule carrying one of these is dropped: honouring only its matching half would block
|
||||||
|
* requests the list author meant to rewrite or merely restrict.
|
||||||
|
*/
|
||||||
|
private val UNSUPPORTED_OPTIONS = hashSetOf(
|
||||||
|
"csp", "removeparam", "rewrite", "replace", "empty", "mp4",
|
||||||
|
"inline-script", "inline-font", "genericblock", "generichide", "specifichide", "elemhide",
|
||||||
|
"badfilter", "denyallow", "method", "header", "to", "from", "ipaddress", "permissions",
|
||||||
|
"uritransform", "urlskip", "urltransform", "strict1p", "strict3p", "popup", "popunder",
|
||||||
|
"cname", "webrtc", "stealth", "cookie", "network", "app", "extension", "jsonprune",
|
||||||
|
"hls", "referrerpolicy", "match-case-path"
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Procedural cosmetic syntax that needs uBlock Origin's own runtime to evaluate. These
|
||||||
|
* selectors cannot be expressed as plain CSS, so the rules are skipped.
|
||||||
|
*
|
||||||
|
* `:has()` is not in this list: modern WebView's Chromium supports it natively as plain CSS,
|
||||||
|
* so those selectors are just passed straight through. `:has-text()` is handled separately in
|
||||||
|
* [parseCosmeticRule] rather than being skipped outright -- the simple, common form (a plain
|
||||||
|
* selector followed by one `:has-text(...)`) is split out and matched by JavaScript instead of
|
||||||
|
* CSS, since there is no CSS equivalent for a text match.
|
||||||
|
*/
|
||||||
|
private val PROCEDURAL_MARKERS = arrayOf(
|
||||||
|
// ":has-text(" stays here as a safety net: it's stripped out before this check runs
|
||||||
|
// when it's the simple trailing form [extractHasText] understands, so a leftover
|
||||||
|
// occurrence here means that extraction either wasn't attempted (not a cosmetic rule
|
||||||
|
// that reached it) or failed (nested/combined usage), and the rule should be dropped
|
||||||
|
// rather than shipped as literal, invalid CSS.
|
||||||
|
":has-text(",
|
||||||
|
":matches-css", ":matches-media", ":matches-path", ":matches-attr",
|
||||||
|
":xpath(", ":upward(", ":nth-ancestor(", ":style(", ":remove(", ":remove-attr(",
|
||||||
|
":remove-class(", ":watch-attr(", ":min-text-length(", ":others(", ":shadow(", ":if(",
|
||||||
|
":if-not(", ":not(:has"
|
||||||
|
)
|
||||||
|
|
||||||
|
private fun isHostCharacter(c: Char): Boolean =
|
||||||
|
c in 'a'..'z' || c in '0'..'9' || c == '.' || c == '-' || c == '_'
|
||||||
|
|
||||||
|
fun parseNetworkRule(line: String): NetworkRule? {
|
||||||
|
var body = line
|
||||||
|
if (body.startsWith("@@")) body = body.substring(2)
|
||||||
|
if (body.isEmpty()) return null
|
||||||
|
|
||||||
|
var pattern = body
|
||||||
|
var optionText: String? = null
|
||||||
|
val dollar = body.lastIndexOf('$')
|
||||||
|
if (dollar >= 0) {
|
||||||
|
pattern = body.substring(0, dollar)
|
||||||
|
optionText = body.substring(dollar + 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Regular-expression rules (/pattern/) are not supported.
|
||||||
|
if (pattern.length > 2 && pattern.startsWith("/") && pattern.endsWith("/")) return null
|
||||||
|
if (pattern.isEmpty()) return null
|
||||||
|
|
||||||
|
var anchorDomain = false
|
||||||
|
var anchorStart = false
|
||||||
|
var anchorEnd = false
|
||||||
|
if (pattern.startsWith("||")) {
|
||||||
|
anchorDomain = true
|
||||||
|
pattern = pattern.substring(2)
|
||||||
|
} else if (pattern.startsWith("|")) {
|
||||||
|
anchorStart = true
|
||||||
|
pattern = pattern.substring(1)
|
||||||
|
}
|
||||||
|
if (pattern.endsWith("|")) {
|
||||||
|
anchorEnd = true
|
||||||
|
pattern = pattern.dropLast(1)
|
||||||
|
}
|
||||||
|
if (pattern.isEmpty()) return null
|
||||||
|
pattern = pattern.lowercase()
|
||||||
|
|
||||||
|
var typeMask = 0
|
||||||
|
var excludedTypeMask = 0
|
||||||
|
var thirdParty: Boolean? = null
|
||||||
|
var includeDomains: MutableList<String>? = null
|
||||||
|
var excludeDomains: MutableList<String>? = null
|
||||||
|
var redirectResource: String? = null
|
||||||
|
|
||||||
|
if (optionText != null) {
|
||||||
|
for (rawOption in optionText.split(',')) {
|
||||||
|
var option = rawOption.trim().lowercase()
|
||||||
|
if (option.isEmpty()) continue
|
||||||
|
|
||||||
|
var negated = false
|
||||||
|
if (option.startsWith("~")) {
|
||||||
|
negated = true
|
||||||
|
option = option.substring(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
val equals = option.indexOf('=')
|
||||||
|
val key = if (equals >= 0) option.substring(0, equals) else option
|
||||||
|
val value = if (equals >= 0) option.substring(equals + 1) else null
|
||||||
|
|
||||||
|
if (key.isEmpty()) return null
|
||||||
|
if (key in UNSUPPORTED_OPTIONS) return null
|
||||||
|
|
||||||
|
when (key) {
|
||||||
|
"domain" -> {
|
||||||
|
if (value.isNullOrEmpty()) return null
|
||||||
|
for (entry in value.split('|')) {
|
||||||
|
val domain = entry.trim()
|
||||||
|
if (domain.isEmpty()) continue
|
||||||
|
if (domain.startsWith("~")) {
|
||||||
|
val excluded = domain.substring(1)
|
||||||
|
if (excluded.isNotEmpty()) {
|
||||||
|
(excludeDomains ?: mutableListOf<String>()
|
||||||
|
.also { excludeDomains = it }).add(excluded)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
(includeDomains ?: mutableListOf<String>()
|
||||||
|
.also { includeDomains = it }).add(domain)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"third-party", "3p" -> thirdParty = !negated
|
||||||
|
"first-party", "1p" -> thirdParty = negated
|
||||||
|
// Patterns are lower-cased for matching, so $match-case degrades to
|
||||||
|
// case-insensitive. It only ever widens the match.
|
||||||
|
"match-case", "important" -> Unit
|
||||||
|
"all" -> Unit
|
||||||
|
// `redirect-rule` only fires when another rule would already block the
|
||||||
|
// request; treating it the same as `redirect` is a harmless simplification
|
||||||
|
// since we only reach here once a block decision is otherwise made.
|
||||||
|
"redirect", "redirect-rule" -> {
|
||||||
|
// AdGuard's priority suffix (`name:10`) doesn't apply to a single-engine
|
||||||
|
// blocker with no other redirect rule to rank against.
|
||||||
|
val name = value?.substringBefore(':')
|
||||||
|
if (name.isNullOrEmpty()) return null
|
||||||
|
redirectResource = name
|
||||||
|
}
|
||||||
|
else -> {
|
||||||
|
val bit = ResourceType.fromOptionName(key)
|
||||||
|
if (bit == ResourceType.NONE) return null
|
||||||
|
if (negated) excludedTypeMask = excludedTypeMask or bit
|
||||||
|
else typeMask = typeMask or bit
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val segments = splitOnWildcards(pattern)
|
||||||
|
if (segments.isEmpty()) return null
|
||||||
|
|
||||||
|
var hostAnchor: String? = null
|
||||||
|
if (anchorDomain && !anchorEnd && segments.size == 1) {
|
||||||
|
val candidate = if (pattern.endsWith("^")) pattern.dropLast(1) else pattern
|
||||||
|
if (candidate.isNotEmpty() && candidate.all { isHostCharacter(it) }) {
|
||||||
|
hostAnchor = candidate
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return NetworkRule(
|
||||||
|
segments = segments,
|
||||||
|
anchorDomain = anchorDomain,
|
||||||
|
anchorStart = anchorStart,
|
||||||
|
anchorEnd = anchorEnd,
|
||||||
|
typeMask = typeMask,
|
||||||
|
excludedTypeMask = excludedTypeMask,
|
||||||
|
thirdParty = thirdParty,
|
||||||
|
includeDomains = includeDomains?.toTypedArray(),
|
||||||
|
excludeDomains = excludeDomains?.toTypedArray(),
|
||||||
|
hostAnchor = hostAnchor,
|
||||||
|
redirectResource = redirectResource
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun splitOnWildcards(pattern: String): Array<String> {
|
||||||
|
if (pattern.indexOf('*') < 0) return arrayOf(pattern)
|
||||||
|
return pattern.split('*').toTypedArray()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Returns the index of the cosmetic separator, or -1 when the line is not a cosmetic rule. */
|
||||||
|
fun cosmeticSeparatorIndex(line: String): Int {
|
||||||
|
var search = 0
|
||||||
|
while (true) {
|
||||||
|
val hash = line.indexOf('#', search)
|
||||||
|
if (hash < 0) return -1
|
||||||
|
if (hash + 1 < line.length) {
|
||||||
|
val next = line[hash + 1]
|
||||||
|
if (next == '#') return hash
|
||||||
|
if (next == '@' && hash + 2 < line.length && line[hash + 2] == '#') return hash
|
||||||
|
// #?#, #$#, #$?#, #%# and friends are procedural or scriptlet syntax.
|
||||||
|
if (next == '?' || next == '$' || next == '%') return hash
|
||||||
|
}
|
||||||
|
search = hash + 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun parseCosmeticRule(line: String, separatorIndex: Int): CosmeticRule? {
|
||||||
|
val isException: Boolean
|
||||||
|
val selectorStart: Int
|
||||||
|
when {
|
||||||
|
line.startsWith("#@#", separatorIndex) -> {
|
||||||
|
isException = true
|
||||||
|
selectorStart = separatorIndex + 3
|
||||||
|
}
|
||||||
|
line.startsWith("##", separatorIndex) -> {
|
||||||
|
isException = false
|
||||||
|
selectorStart = separatorIndex + 2
|
||||||
|
}
|
||||||
|
// Procedural / scriptlet separators cannot be represented as CSS.
|
||||||
|
else -> return null
|
||||||
|
}
|
||||||
|
|
||||||
|
if (selectorStart >= line.length) return null
|
||||||
|
var selector = line.substring(selectorStart).trim()
|
||||||
|
if (selector.isEmpty()) return null
|
||||||
|
if (selector.startsWith("+js(") || selector.startsWith("script:")) return null
|
||||||
|
|
||||||
|
var hasTextPattern: String? = null
|
||||||
|
val hasTextSplit = extractHasText(selector)
|
||||||
|
if (hasTextSplit != null) {
|
||||||
|
selector = hasTextSplit.first
|
||||||
|
hasTextPattern = hasTextSplit.second
|
||||||
|
}
|
||||||
|
|
||||||
|
val lowerSelector = selector.lowercase()
|
||||||
|
if (PROCEDURAL_MARKERS.any { lowerSelector.contains(it) }) return null
|
||||||
|
// A stray brace would let a selector escape its own rule when concatenated.
|
||||||
|
if (selector.contains('{') || selector.contains('}')) return null
|
||||||
|
|
||||||
|
var includeDomains: MutableList<String>? = null
|
||||||
|
var excludeDomains: MutableList<String>? = null
|
||||||
|
val domainText = line.substring(0, separatorIndex).trim()
|
||||||
|
if (domainText.isNotEmpty()) {
|
||||||
|
for (entry in domainText.split(',')) {
|
||||||
|
val domain = entry.trim().lowercase()
|
||||||
|
if (domain.isEmpty()) continue
|
||||||
|
if (domain.startsWith("~")) {
|
||||||
|
val excluded = domain.substring(1)
|
||||||
|
if (excluded.isNotEmpty()) {
|
||||||
|
(excludeDomains ?: mutableListOf<String>().also { excludeDomains = it })
|
||||||
|
.add(excluded)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
(includeDomains ?: mutableListOf<String>().also { includeDomains = it })
|
||||||
|
.add(domain)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return CosmeticRule(selector, includeDomains, excludeDomains, isException, hasTextPattern)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Splits `selector:has-text(pattern)` into (`selector`, JS regex source). Only the simple,
|
||||||
|
* common form is handled -- one `:has-text(...)` as the very last thing in the selector, with
|
||||||
|
* a non-empty selector before it. Anything else (nested inside `:has()`, combined with other
|
||||||
|
* procedural syntax, an empty base selector matching every element) returns null and the
|
||||||
|
* caller falls back to discarding the rule, same as before this existed.
|
||||||
|
*/
|
||||||
|
private fun extractHasText(selector: String): Pair<String, String>? {
|
||||||
|
val markerIndex = selector.indexOf(":has-text(")
|
||||||
|
if (markerIndex <= 0) return null
|
||||||
|
|
||||||
|
val argStart = markerIndex + ":has-text(".length
|
||||||
|
var depth = 1
|
||||||
|
var index = argStart
|
||||||
|
while (index < selector.length && depth > 0) {
|
||||||
|
when (selector[index]) {
|
||||||
|
'(' -> depth++
|
||||||
|
')' -> depth--
|
||||||
|
}
|
||||||
|
if (depth > 0) index++
|
||||||
|
}
|
||||||
|
// The closing paren must be the last character: a trailing combinator or pseudo after
|
||||||
|
// it means this is a more complex selector than the simple form handled here.
|
||||||
|
if (depth != 0 || index != selector.length - 1) return null
|
||||||
|
|
||||||
|
val base = selector.substring(0, markerIndex).trim()
|
||||||
|
if (base.isEmpty() || base.contains(":has-text(")) return null
|
||||||
|
|
||||||
|
var arg = selector.substring(argStart, index).trim()
|
||||||
|
if (arg.isEmpty()) return null
|
||||||
|
|
||||||
|
val pattern = if (arg.length > 1 && arg.startsWith("/") && arg.lastIndexOf('/') > 0) {
|
||||||
|
arg.substring(1, arg.lastIndexOf('/'))
|
||||||
|
} else {
|
||||||
|
Regex.escape(arg)
|
||||||
|
}
|
||||||
|
return base to pattern
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
package app.seb3thehacker.gearslip.browser.adblock
|
||||||
|
|
||||||
|
import android.webkit.WebResourceResponse
|
||||||
|
import java.io.ByteArrayInputStream
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A small subset of uBlock Origin's / AdGuard's named `$redirect=` resources.
|
||||||
|
*
|
||||||
|
* Blocking most tracking scripts outright is safe -- the page never had a live reference to
|
||||||
|
* them, so nothing calls into the empty response [AdBlockManager.blockedResponse] already
|
||||||
|
* serves. But a handful of scripts are different: page code assumes the object they define
|
||||||
|
* (`ga`, `dataLayer`, `googletag`, ...) exists and calls it immediately, so an empty response
|
||||||
|
* leaves that call throwing a `ReferenceError` that can break unrelated script on the same page.
|
||||||
|
* These stubs define just enough of that surface to no-op safely, which is what the named
|
||||||
|
* `$redirect=` resource is for in the filter list that requested it.
|
||||||
|
*
|
||||||
|
* Anything not in this map still gets blocked -- just with the generic typed-empty response
|
||||||
|
* instead of an exact stub, same as before this existed.
|
||||||
|
*/
|
||||||
|
internal object RedirectResources {
|
||||||
|
|
||||||
|
private val EMPTY_JS = "".toByteArray()
|
||||||
|
|
||||||
|
private val GOOGLE_ANALYTICS_GA = """
|
||||||
|
(function(){function g(){}var t=function(){t.c[t.q.push(arguments)-1]=null};
|
||||||
|
t.q=[];t.c=[];window.ga=window.ga||t;window._gaq=window._gaq||{push:g};
|
||||||
|
window.GoogleAnalyticsObject=window.GoogleAnalyticsObject||'ga';})();
|
||||||
|
""".trimIndent().toByteArray()
|
||||||
|
|
||||||
|
private val GOOGLE_ANALYTICS_ANALYTICS = """
|
||||||
|
(function(){function s(){}var g={
|
||||||
|
create:function(){return{get:s,set:s,send:s}},getAll:function(){return[]},
|
||||||
|
getByName:function(){return null},remove:s};
|
||||||
|
window.ga=window.ga||function(){(window.ga.q=window.ga.q||[]).push(arguments)};
|
||||||
|
window.ga.create=g.create;window.ga.getAll=g.getAll;window.ga.getByName=g.getByName;
|
||||||
|
window.ga.remove=g.remove;window.gaplugins={Linker:function(){this.decorate=s;this.autoLink=s}};
|
||||||
|
})();
|
||||||
|
""".trimIndent().toByteArray()
|
||||||
|
|
||||||
|
private val GOOGLETAGMANAGER_GTM = """
|
||||||
|
(function(){window.dataLayer=window.dataLayer||[];
|
||||||
|
window.dataLayer.push=function(){};
|
||||||
|
window.google_tag_manager=window.google_tag_manager||{};})();
|
||||||
|
""".trimIndent().toByteArray()
|
||||||
|
|
||||||
|
private val GOOGLETAGSERVICES_GPT = """
|
||||||
|
(function(){function s(){}function u(){return{addService:function(){return this},
|
||||||
|
setTargeting:s,setCollapseEmptyDiv:s,addEventListener:function(){return this},
|
||||||
|
removeEventListener:s,get:s,set:s,getSlotElementId:function(){return''},
|
||||||
|
getSlotId:function(){return{getDomId:function(){return''}}}}}
|
||||||
|
window.googletag=window.googletag||{};
|
||||||
|
window.googletag.cmd=window.googletag.cmd||{push:function(fn){try{fn()}catch(e){}}};
|
||||||
|
var pubads=u();
|
||||||
|
window.googletag.pubads=function(){return pubads};
|
||||||
|
window.googletag.defineSlot=function(){return u()};
|
||||||
|
window.googletag.defineOutOfPageSlot=function(){return u()};
|
||||||
|
window.googletag.enableServices=s;window.googletag.display=s;
|
||||||
|
window.googletag.apiReady=true;})();
|
||||||
|
""".trimIndent().toByteArray()
|
||||||
|
|
||||||
|
private val NOOP_CSS = "".toByteArray()
|
||||||
|
private val NOOP_HTML = "<!doctype html><html><head></head><body></body></html>".toByteArray()
|
||||||
|
private val NOOP_JSON = "{}".toByteArray()
|
||||||
|
|
||||||
|
private val STUBS: Map<String, Pair<String, ByteArray>> = buildMap {
|
||||||
|
// Names as they appear across uBO's and AdGuard's resources.txt / redirect syntax.
|
||||||
|
for (name in listOf("noopjs", "noop.js", "noop-js")) put(name, "application/javascript" to EMPTY_JS)
|
||||||
|
for (name in listOf("noopcss", "noop.css", "noop-css")) put(name, "text/css" to NOOP_CSS)
|
||||||
|
for (name in listOf("noop.txt", "nooptext")) put(name, "text/plain" to "".toByteArray())
|
||||||
|
for (name in listOf("noop.json", "empty")) put(name, "application/json" to NOOP_JSON)
|
||||||
|
for (name in listOf("noop.html", "click2load.html")) put(name, "text/html" to NOOP_HTML)
|
||||||
|
for (name in listOf("google-analytics_ga.js", "google-analytics.com/ga.js", "ga.js"))
|
||||||
|
put(name, "application/javascript" to GOOGLE_ANALYTICS_GA)
|
||||||
|
for (name in listOf(
|
||||||
|
"google-analytics_analytics.js", "google-analytics.com/analytics.js",
|
||||||
|
"googletagmanager_gtag.js", "googletagmanager-gtag"
|
||||||
|
)) put(name, "application/javascript" to GOOGLE_ANALYTICS_ANALYTICS)
|
||||||
|
for (name in listOf("googletagmanager_gtm.js", "googletagmanager.com/gtm.js", "gtm.js"))
|
||||||
|
put(name, "application/javascript" to GOOGLETAGMANAGER_GTM)
|
||||||
|
for (name in listOf("googletagservices_gpt.js", "googletagservices.com/gpt.js", "gpt.js"))
|
||||||
|
put(name, "application/javascript" to GOOGLETAGSERVICES_GPT)
|
||||||
|
for (name in listOf("1x1.gif", "1x1-transparent.gif", "2x2.png", "3x2.png", "32x32.png"))
|
||||||
|
put(name, "image/gif" to AdBlockManager.transparentGifBytes)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Null when [name] is not one of the resources we ship a stub for. */
|
||||||
|
fun response(name: String, headers: Map<String, String>): WebResourceResponse? {
|
||||||
|
val stub = STUBS[name.lowercase()] ?: return null
|
||||||
|
val (mimeType, body) = stub
|
||||||
|
val encoding = if (mimeType.startsWith("image/")) null else "utf-8"
|
||||||
|
return WebResourceResponse(mimeType, encoding, 200, "OK", headers, ByteArrayInputStream(body))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,239 @@
|
|||||||
|
package app.seb3thehacker.gearslip.browser.adblock
|
||||||
|
|
||||||
|
/**
|
||||||
|
* YouTube ad blocking, plus the cosmetic cleanup that rides along with it.
|
||||||
|
*
|
||||||
|
* Filter lists cannot touch YouTube's video ads. The ad and the video are delivered by the same
|
||||||
|
* hosts, so there is no request to block and no element to hide: the player decides to show an
|
||||||
|
* ad because its own API response told it to. What can be done is what uBlock Origin's
|
||||||
|
* scriptlets do — edit that response before the player reads it.
|
||||||
|
*
|
||||||
|
* Three hooks, all installed before any page script runs:
|
||||||
|
* - `ytInitialPlayerResponse`, the JSON embedded in the page for the first video watched;
|
||||||
|
* - `fetch`, used by the single-page app for every later video;
|
||||||
|
* - `JSON.parse`, a backstop for whatever path the two above miss.
|
||||||
|
*
|
||||||
|
* Each strips the keys the player uses to schedule ads. A periodic sweep then clicks any skip
|
||||||
|
* button and seeks past anything that still slipped through, which also covers ads stitched
|
||||||
|
* into the stream server-side, where nothing client-side can remove them cleanly. All of this,
|
||||||
|
* plus hiding the ad's own banner/in-feed markup, is gated on "Block YouTube video ads": it's
|
||||||
|
* inherently maintenance-prone (YouTube changes these field and class names when it wants to,
|
||||||
|
* and may respond with an anti-adblock notice), so it needs to be switchable off as a unit
|
||||||
|
* without losing ordinary filter-list blocking.
|
||||||
|
*
|
||||||
|
* The Shorts shelf and filter chip row are a separate concern -- cosmetic preference, not ad
|
||||||
|
* blocking -- so they sit behind their own "Custom site tweaks" setting instead, independent of
|
||||||
|
* whether YouTube ad blocking is on. The floating menu button covering a page's own fixed
|
||||||
|
* bottom bar is handled generically for every site in [CosmeticFilterBridge], not here.
|
||||||
|
*/
|
||||||
|
object YouTubeAdBlock {
|
||||||
|
|
||||||
|
val INJECTION_JS: String = """
|
||||||
|
(function () {
|
||||||
|
try {
|
||||||
|
var host = location.hostname || '';
|
||||||
|
if (!/(^|\.)(youtube\.com|youtube-nocookie\.com|youtubekids\.com)${'$'}/.test(host)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
var bridge = window.${CosmeticFilterBridge.BRIDGE_OBJECT_NAME};
|
||||||
|
if (!bridge) { return; }
|
||||||
|
var adBlockingOn = typeof bridge.youtubeBlockingEnabled === 'function' &&
|
||||||
|
bridge.youtubeBlockingEnabled(host);
|
||||||
|
var tweaksOn = typeof bridge.customSiteTweaksEnabled === 'function' &&
|
||||||
|
bridge.customSiteTweaksEnabled();
|
||||||
|
if (!adBlockingOn && !tweaksOn) { return; }
|
||||||
|
if (window.__gearslipYouTube) { return; }
|
||||||
|
window.__gearslipYouTube = true;
|
||||||
|
|
||||||
|
var styleRules = '';
|
||||||
|
|
||||||
|
if (adBlockingOn) {
|
||||||
|
// The fields the player consults to schedule ad breaks.
|
||||||
|
var AD_KEYS = [
|
||||||
|
'adPlacements', 'playerAds', 'adSlots', 'adBreakHeartbeatParams'
|
||||||
|
];
|
||||||
|
|
||||||
|
var stripKeys = function (object) {
|
||||||
|
if (!object || typeof object !== 'object') { return; }
|
||||||
|
for (var i = 0; i < AD_KEYS.length; i++) {
|
||||||
|
if (AD_KEYS[i] in object) {
|
||||||
|
try { delete object[AD_KEYS[i]]; } catch (e) {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Deliberately shallow. A player response is a large object and walking all of
|
||||||
|
// it on every parse would cost more than the ads do.
|
||||||
|
var prune = function (payload) {
|
||||||
|
if (!payload || typeof payload !== 'object') { return payload; }
|
||||||
|
if (Array.isArray(payload)) {
|
||||||
|
for (var i = 0; i < payload.length && i < 50; i++) { prune(payload[i]); }
|
||||||
|
return payload;
|
||||||
|
}
|
||||||
|
stripKeys(payload);
|
||||||
|
if (payload.playerResponse) { stripKeys(payload.playerResponse); }
|
||||||
|
if (payload.response) { stripKeys(payload.response); }
|
||||||
|
if (payload.playerData) { stripKeys(payload.playerData); }
|
||||||
|
return payload;
|
||||||
|
};
|
||||||
|
|
||||||
|
// 1. The first video: YouTube assigns this from an inline script in the document.
|
||||||
|
try {
|
||||||
|
var initial;
|
||||||
|
Object.defineProperty(window, 'ytInitialPlayerResponse', {
|
||||||
|
configurable: true,
|
||||||
|
get: function () { return initial; },
|
||||||
|
set: function (value) { initial = prune(value); }
|
||||||
|
});
|
||||||
|
} catch (e) {}
|
||||||
|
|
||||||
|
// 2. Every later video in the single-page app.
|
||||||
|
var originalFetch = window.fetch;
|
||||||
|
if (typeof originalFetch === 'function') {
|
||||||
|
window.fetch = function (input, init) {
|
||||||
|
var url = '';
|
||||||
|
try {
|
||||||
|
url = (typeof input === 'string') ? input : ((input && input.url) || '');
|
||||||
|
} catch (e) {}
|
||||||
|
var pending = originalFetch.apply(this, arguments);
|
||||||
|
// Only the player call carries ad scheduling. /next is the comments and
|
||||||
|
// related-video payload: large, and re-serialising it buys nothing.
|
||||||
|
if (url.indexOf('/youtubei/v1/player') < 0) {
|
||||||
|
return pending;
|
||||||
|
}
|
||||||
|
return pending.then(function (response) {
|
||||||
|
return response.text().then(function (text) {
|
||||||
|
var body = text;
|
||||||
|
// Cheap string test first: most responses have nothing to strip, and
|
||||||
|
// parsing plus re-serialising a player response is not free.
|
||||||
|
if (text.indexOf('"adPlacements"') >= 0 ||
|
||||||
|
text.indexOf('"playerAds"') >= 0 ||
|
||||||
|
text.indexOf('"adSlots"') >= 0) {
|
||||||
|
try {
|
||||||
|
var data = JSON.parse(text);
|
||||||
|
prune(data);
|
||||||
|
body = JSON.stringify(data);
|
||||||
|
} catch (e) {
|
||||||
|
body = text;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Content-Length describes the original bytes, and Content-Encoding a
|
||||||
|
// compression this body no longer has. Copying either onto the edited
|
||||||
|
// body leaves the player waiting on bytes that never arrive.
|
||||||
|
var headers;
|
||||||
|
try {
|
||||||
|
headers = new Headers(response.headers);
|
||||||
|
headers.delete('content-length');
|
||||||
|
headers.delete('content-encoding');
|
||||||
|
} catch (e) {
|
||||||
|
headers = undefined;
|
||||||
|
}
|
||||||
|
return new Response(body, {
|
||||||
|
status: response.status,
|
||||||
|
statusText: response.statusText,
|
||||||
|
headers: headers
|
||||||
|
});
|
||||||
|
}).catch(function () { return response; });
|
||||||
|
});
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Backstop for any path the first two miss.
|
||||||
|
var originalParse = JSON.parse;
|
||||||
|
JSON.parse = function (text, reviver) {
|
||||||
|
var result = originalParse.apply(this, arguments);
|
||||||
|
try { prune(result); } catch (e) {}
|
||||||
|
return result;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Hide the banner, overlay and in-feed ads, which are ordinary elements. The
|
||||||
|
// :has() rules collapse the wrapping feed section too, instead of leaving an
|
||||||
|
// empty gap where the ad used to be.
|
||||||
|
styleRules +=
|
||||||
|
'ytd-promoted-sparkles-web-renderer,ytd-promoted-video-renderer,' +
|
||||||
|
'ytd-display-ad-renderer,ytd-ad-slot-renderer,ytd-in-feed-ad-layout-renderer,' +
|
||||||
|
'ytd-banner-promo-renderer,ytd-statement-banner-renderer,' +
|
||||||
|
'ytd-companion-slot-renderer,ytd-action-companion-ad-renderer,' +
|
||||||
|
'ytm-promoted-video-renderer,ytm-companion-ad-renderer,' +
|
||||||
|
'ytm-promoted-sparkles-web-renderer,ytm-search-promoted-video-renderer,' +
|
||||||
|
'.ytp-ad-overlay-container,.ytp-ad-overlay-slot,.ytp-ad-message-container,' +
|
||||||
|
'#masthead-ad,#player-ads,' +
|
||||||
|
'ytm-item-section-renderer:has(>*>ytm-companion-ad-renderer),' +
|
||||||
|
'ytm-item-section-renderer:has(>*>ytm-promoted-sparkles-web-renderer),' +
|
||||||
|
'ytm-item-section-renderer:has(>*>ytm-promoted-video-renderer),' +
|
||||||
|
'ytm-item-section-renderer:has(>*>ytm-search-promoted-video-renderer),' +
|
||||||
|
'ytm-item-section-renderer:has(>*>ytd-ad-slot-renderer),' +
|
||||||
|
// The "desktop site" grid feed uses different wrapper elements entirely: each
|
||||||
|
// card is a ytd-rich-item-renderer grid cell, so hiding just the ad renderer
|
||||||
|
// inside it still leaves an empty cell-shaped hole in the grid.
|
||||||
|
'ytd-rich-item-renderer:has(ytd-display-ad-renderer),' +
|
||||||
|
'ytd-rich-item-renderer:has(ytd-ad-slot-renderer),' +
|
||||||
|
'ytd-rich-item-renderer:has(ytd-promoted-sparkles-web-renderer),' +
|
||||||
|
'ytd-rich-item-renderer:has(ytd-statement-banner-renderer),' +
|
||||||
|
'ytd-rich-item-renderer:has(ytd-banner-promo-renderer),' +
|
||||||
|
// Ads can also occupy a whole grid row, or a section above/between rows, not
|
||||||
|
// just a single cell -- same empty-hole problem, one level up each time.
|
||||||
|
'ytd-rich-grid-row:has(ytd-display-ad-renderer),' +
|
||||||
|
'ytd-rich-grid-row:has(ytd-ad-slot-renderer),' +
|
||||||
|
'ytd-rich-grid-row:has(ytd-promoted-sparkles-web-renderer),' +
|
||||||
|
'ytd-rich-grid-row:has(ytd-statement-banner-renderer),' +
|
||||||
|
'ytd-rich-grid-row:has(ytd-banner-promo-renderer),' +
|
||||||
|
'ytd-rich-section-renderer:has(ytd-ad-slot-renderer),' +
|
||||||
|
'ytd-rich-section-renderer:has(ytd-display-ad-renderer),' +
|
||||||
|
'ytd-rich-section-renderer:has(ytd-statement-banner-renderer),' +
|
||||||
|
'ytd-rich-section-renderer:has(ytd-banner-promo-renderer),' +
|
||||||
|
'ytd-ad-slot-renderer,ytd-primetime-promo-renderer' +
|
||||||
|
'{display:none!important}';
|
||||||
|
|
||||||
|
// Sweep for anything still playing: press skip, or seek to the end of the ad.
|
||||||
|
var adTicks = 0;
|
||||||
|
var sweep = function () {
|
||||||
|
try {
|
||||||
|
var skip = document.querySelector(
|
||||||
|
'.ytp-ad-skip-button,.ytp-ad-skip-button-modern,.ytp-skip-ad-button,' +
|
||||||
|
'.ytp-ad-skip-button-container button'
|
||||||
|
);
|
||||||
|
if (skip) { skip.click(); }
|
||||||
|
var player = document.querySelector('.html5-video-player');
|
||||||
|
if (player && player.classList.contains('ad-showing')) {
|
||||||
|
// Only seek once the ad state has held for two ticks. Seeking during the
|
||||||
|
// player's own transition makes it re-buffer, which looks like a stall.
|
||||||
|
adTicks++;
|
||||||
|
var video = player.querySelector('video');
|
||||||
|
if (adTicks >= 2 && video && isFinite(video.duration) && video.duration > 0) {
|
||||||
|
video.currentTime = video.duration;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
adTicks = 0;
|
||||||
|
}
|
||||||
|
} catch (e) {}
|
||||||
|
};
|
||||||
|
setInterval(sweep, 700);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (tweaksOn) {
|
||||||
|
styleRules +=
|
||||||
|
// The mobile site's fixed Home/Shorts/You bar and the "desktop site" grid's
|
||||||
|
// reel shelf section it sits inside of.
|
||||||
|
'ytm-pivot-bar-renderer,' +
|
||||||
|
'ytd-rich-section-renderer:has(ytd-reel-shelf-renderer),' +
|
||||||
|
// Shorts: a different, faster-cut format than the rest of the feed, and its
|
||||||
|
// vertical-video shelf doesn't fit a landscape car display well either way.
|
||||||
|
'ytm-reel-shelf-renderer,ytm-rich-shelf-renderer:has(ytm-reel-item-renderer),' +
|
||||||
|
'ytm-reel-item-renderer,ytd-reel-shelf-renderer,' +
|
||||||
|
// The "All / Podcasts / News / ..." filter chip row above the feed.
|
||||||
|
'ytm-chip-bar-renderer,ytm-feed-filter-chip-bar-renderer' +
|
||||||
|
'{display:none!important}';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (styleRules) {
|
||||||
|
var style = document.createElement('style');
|
||||||
|
style.setAttribute('data-gearslip', 'youtube');
|
||||||
|
style.textContent = styleRules;
|
||||||
|
var root = document.head || document.documentElement;
|
||||||
|
if (root) { root.appendChild(style); }
|
||||||
|
}
|
||||||
|
} catch (e) {}
|
||||||
|
})();
|
||||||
|
""".trimIndent()
|
||||||
|
}
|
||||||
@@ -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> {
|
private suspend fun allEntries(context: Context): List<Entry> {
|
||||||
val vehicleData = CarSettings.experimentalFeaturesEnabled.value && CarSettings.carSensorsEnabled.value
|
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) })
|
return builtIn + (LauncherCache.entries ?: withContext(Dispatchers.IO) { LauncherCache.load(context) })
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -95,7 +95,12 @@ object AssistantCommands {
|
|||||||
heard: String,
|
heard: String,
|
||||||
): String? {
|
): String? {
|
||||||
// "use Organic Maps to get to X", "open OsmAnd and navigate to X": the app comes first.
|
// "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
|
var leadingApp: String? = null
|
||||||
Regex("^(?:use|using|with|open|in|on) (.+?) (?:to |and )(.+)$").find(text)?.let { m ->
|
Regex("^(?:use|using|with|open|in|on) (.+?) (?:to |and )(.+)$").find(text)?.let { m ->
|
||||||
if (after(m.groupValues[2], NAVIGATE_TRIGGERS) != null) {
|
if (after(m.groupValues[2], NAVIGATE_TRIGGERS) != null) {
|
||||||
@@ -135,6 +140,20 @@ object AssistantCommands {
|
|||||||
val (place, nearby) = plainPlace(destination)
|
val (place, nearby) = plainPlace(destination)
|
||||||
destination = place
|
destination = place
|
||||||
if (destination.isBlank()) return "Where to?"
|
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 {
|
val chosen = chosenApp ?: when {
|
||||||
navApps.size == 1 -> navApps.first()
|
navApps.size == 1 -> navApps.first()
|
||||||
@@ -144,6 +163,7 @@ object AssistantCommands {
|
|||||||
|
|
||||||
CarServices.connectNav(template, frame, destination)
|
CarServices.connectNav(template, frame, destination)
|
||||||
navigator.home()
|
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}."
|
return if (nearby) "Finding the nearest $destination with ${chosen.label}." else "Navigating to $destination with ${chosen.label}."
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -39,6 +39,7 @@ import androidx.compose.ui.Modifier
|
|||||||
import androidx.compose.ui.graphics.vector.ImageVector
|
import androidx.compose.ui.graphics.vector.ImageVector
|
||||||
import androidx.compose.ui.res.vectorResource
|
import androidx.compose.ui.res.vectorResource
|
||||||
import app.seb3thehacker.gearslip.R
|
import app.seb3thehacker.gearslip.R
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
@@ -69,7 +70,8 @@ fun AssistantOverlay(modifier: Modifier = Modifier) {
|
|||||||
modifier
|
modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.drawBehind {
|
.drawBehind {
|
||||||
drawRect(Brush.verticalGradient(0f to Color.Transparent, 0.45f to Color.Black.copy(alpha = 0.6f), 1f to Color.Black.copy(alpha = 0.85f)))
|
// Dark enough by the text that nothing on the screen behind reads through it.
|
||||||
|
drawRect(Brush.verticalGradient(0f to Color.Transparent, 0.3f to Color.Black.copy(alpha = 0.8f), 1f to Color.Black.copy(alpha = 0.92f)))
|
||||||
// A wide, flat oval of light rising from the bottom edge.
|
// A wide, flat oval of light rising from the bottom edge.
|
||||||
scale(1f, 0.22f, Offset(size.width / 2, size.height)) {
|
scale(1f, 0.22f, Offset(size.width / 2, size.height)) {
|
||||||
drawCircle(
|
drawCircle(
|
||||||
@@ -86,8 +88,12 @@ fun AssistantOverlay(modifier: Modifier = Modifier) {
|
|||||||
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(16.dp)) {
|
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(16.dp)) {
|
||||||
PulsingMic(listening)
|
PulsingMic(listening)
|
||||||
Column(Modifier.weight(1f), horizontalAlignment = Alignment.CenterHorizontally) {
|
Column(Modifier.weight(1f), horizontalAlignment = Alignment.CenterHorizontally) {
|
||||||
|
val listeningText = stringResource(R.string.assist_listening)
|
||||||
|
val momentText = stringResource(R.string.assist_one_moment)
|
||||||
|
val waiting = state.heard.isBlank() &&
|
||||||
|
(state.phase == CarAssistant.Phase.LISTENING || state.phase == CarAssistant.Phase.THINKING)
|
||||||
val (small, big) = when (state.phase) {
|
val (small, big) = when (state.phase) {
|
||||||
CarAssistant.Phase.LISTENING -> "" to state.heard.ifBlank { if (state.ready) "Listening…" else "One moment…" }
|
CarAssistant.Phase.LISTENING -> "" to state.heard.ifBlank { if (state.ready) listeningText else momentText }
|
||||||
CarAssistant.Phase.THINKING -> "" to state.heard.ifBlank { "…" }
|
CarAssistant.Phase.THINKING -> "" to state.heard.ifBlank { "…" }
|
||||||
else -> state.heard to state.reply
|
else -> state.heard to state.reply
|
||||||
}
|
}
|
||||||
@@ -96,22 +102,33 @@ fun AssistantOverlay(modifier: Modifier = Modifier) {
|
|||||||
}
|
}
|
||||||
Text(
|
Text(
|
||||||
big,
|
big,
|
||||||
color = if (big == "Listening…" || big == "One moment…" || big == "…") Color.White.copy(alpha = 0.7f) else Color.White,
|
color = if (waiting) Color.White.copy(alpha = 0.7f) else Color.White,
|
||||||
style = MaterialTheme.typography.headlineSmall,
|
style = MaterialTheme.typography.headlineSmall,
|
||||||
fontWeight = FontWeight.SemiBold,
|
fontWeight = FontWeight.SemiBold,
|
||||||
textAlign = TextAlign.Center,
|
textAlign = TextAlign.Center,
|
||||||
maxLines = 2,
|
maxLines = 2,
|
||||||
overflow = TextOverflow.Ellipsis,
|
overflow = TextOverflow.Ellipsis,
|
||||||
)
|
)
|
||||||
|
// What to say, until the driver starts saying it.
|
||||||
|
if (state.phase == CarAssistant.Phase.LISTENING && state.ready && state.heard.isBlank()) {
|
||||||
|
Text(
|
||||||
|
stringResource(R.string.assist_examples),
|
||||||
|
color = Color.White.copy(alpha = 0.6f),
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
textAlign = TextAlign.Center,
|
||||||
|
maxLines = 2,
|
||||||
|
modifier = Modifier.padding(top = 4.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if (state.missingPermission) {
|
if (state.missingPermission) {
|
||||||
GsButton(tone = GsTone.Tonal, onClick = {
|
GsButton(tone = GsTone.Tonal, onClick = {
|
||||||
context.startActivity(
|
context.startActivity(
|
||||||
Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS, Uri.fromParts("package", context.packageName, null)),
|
Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS, Uri.fromParts("package", context.packageName, null)),
|
||||||
)
|
)
|
||||||
}) { Text("Grant") }
|
}) { Text(stringResource(R.string.assist_grant)) }
|
||||||
}
|
}
|
||||||
GsIconButton(Icons.Filled.Close, "Dismiss", CarAssistant::cancel)
|
GsIconButton(Icons.Filled.Close, stringResource(R.string.msg_dismiss), CarAssistant::cancel)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import android.content.Context
|
|||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
import android.content.IntentFilter
|
import android.content.IntentFilter
|
||||||
import android.content.pm.PackageManager
|
import android.content.pm.PackageManager
|
||||||
|
import app.seb3thehacker.gearslip.R
|
||||||
import android.os.Handler
|
import android.os.Handler
|
||||||
import android.os.Looper
|
import android.os.Looper
|
||||||
import app.seb3thehacker.gearslip.GearslipLog
|
import app.seb3thehacker.gearslip.GearslipLog
|
||||||
@@ -25,6 +26,11 @@ import kotlinx.coroutines.flow.StateFlow
|
|||||||
*
|
*
|
||||||
* When the car named its Bluetooth address, only that device counts. Otherwise any audio
|
* When the car named its Bluetooth address, only that device counts. Otherwise any audio
|
||||||
* connection does, since there's no telling which device is the car.
|
* connection does, since there's no telling which device is the car.
|
||||||
|
*
|
||||||
|
* Many cars switch Bluetooth music off while Android Auto runs and want it over Android Auto
|
||||||
|
* instead (a Skoda, a VW, an Audi, a Bolt). If the phone's Bluetooth audio to the car was up when
|
||||||
|
* the car showed Gearslip and drops after, that's what happened: this car's music goes through
|
||||||
|
* Gearslip from now on, and the car screen says so.
|
||||||
*/
|
*/
|
||||||
object BluetoothAudioCheck {
|
object BluetoothAudioCheck {
|
||||||
|
|
||||||
@@ -41,6 +47,8 @@ object BluetoothAudioCheck {
|
|||||||
private var appContext: Context? = null
|
private var appContext: Context? = null
|
||||||
private var armed = false
|
private var armed = false
|
||||||
private var receiver: BroadcastReceiver? = null
|
private var receiver: BroadcastReceiver? = null
|
||||||
|
/** Whether Bluetooth audio to the car was up when the car showed Gearslip; null until known. */
|
||||||
|
private var connectedAtStart: Boolean? = null
|
||||||
|
|
||||||
fun init(context: Context) {
|
fun init(context: Context) {
|
||||||
appContext = context.applicationContext
|
appContext = context.applicationContext
|
||||||
@@ -50,11 +58,14 @@ object BluetoothAudioCheck {
|
|||||||
fun onProjecting() = main.post {
|
fun onProjecting() = main.post {
|
||||||
if (armed) return@post
|
if (armed) return@post
|
||||||
armed = true
|
armed = true
|
||||||
|
connectedAtStart = null
|
||||||
|
audioToCar { connectedAtStart = it }
|
||||||
main.postDelayed(::start, WAIT_MS)
|
main.postDelayed(::start, WAIT_MS)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun onCarDisconnected() = main.post {
|
fun onCarDisconnected() = main.post {
|
||||||
armed = false
|
armed = false
|
||||||
|
connectedAtStart = null
|
||||||
main.removeCallbacksAndMessages(null)
|
main.removeCallbacksAndMessages(null)
|
||||||
stopWatching()
|
stopWatching()
|
||||||
_missing.value = false
|
_missing.value = false
|
||||||
@@ -97,36 +108,58 @@ object BluetoothAudioCheck {
|
|||||||
private fun check() {
|
private fun check() {
|
||||||
if (!armed) return
|
if (!armed) return
|
||||||
val context = appContext ?: return
|
val context = appContext ?: return
|
||||||
if (CarSettings.pipeAudio.value) {
|
if (CarSettings.pipesNow) {
|
||||||
set(false, "USB audio is on")
|
set(false, "audio goes through Gearslip")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (!CarSettings.bluetoothWarning.value) {
|
audioToCar { ok ->
|
||||||
set(false, "the warning is turned off")
|
if (ok == null) return@audioToCar
|
||||||
return
|
if (!ok && connectedAtStart == true) return@audioToCar switchToGearslip()
|
||||||
|
if (!CarSettings.bluetoothWarning.value) {
|
||||||
|
set(false, "the warning is turned off")
|
||||||
|
return@audioToCar
|
||||||
|
}
|
||||||
|
set(!ok, if (ok) "connected to the car" else "not connected to the car")
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The car dropped Bluetooth music once Gearslip was up: send this car's music through Gearslip. */
|
||||||
|
private fun switchToGearslip() {
|
||||||
|
val car = CarBluetooth.carAddress ?: return
|
||||||
|
val context = appContext ?: return
|
||||||
|
if (!app.seb3thehacker.gearslip.audio.CarAudio.sinkOffered) return
|
||||||
|
log.i("Bluetooth audio: the car turned it off with Gearslip running, so this car's music goes through Gearslip")
|
||||||
|
CarSettings.rememberAudioCar(car)
|
||||||
|
set(false, "audio goes through Gearslip")
|
||||||
|
app.seb3thehacker.gearslip.audio.CarAudio.request()
|
||||||
|
CarToasts.show(this, context.getString(R.string.car_audio_switched), androidx.car.app.CarToast.LENGTH_LONG)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether the phone's Bluetooth audio is connected to the car, on the main thread: null when
|
||||||
|
* that can't be told (no permission). Bluetooth switched off counts as not connected.
|
||||||
|
*/
|
||||||
|
@SuppressLint("MissingPermission")
|
||||||
|
private fun audioToCar(result: (Boolean?) -> Unit) {
|
||||||
|
val context = appContext ?: return result(null)
|
||||||
if (context.checkSelfPermission(Manifest.permission.BLUETOOTH_CONNECT) != PackageManager.PERMISSION_GRANTED) {
|
if (context.checkSelfPermission(Manifest.permission.BLUETOOTH_CONNECT) != PackageManager.PERMISSION_GRANTED) {
|
||||||
log.i("can't check the audio connection: the Bluetooth permission isn't granted")
|
log.i("can't check the audio connection: the Bluetooth permission isn't granted")
|
||||||
return
|
return result(null)
|
||||||
}
|
|
||||||
val adapter = context.getSystemService(BluetoothManager::class.java)?.adapter ?: return
|
|
||||||
if (!adapter.isEnabled) {
|
|
||||||
set(true, "Bluetooth is off")
|
|
||||||
return
|
|
||||||
}
|
}
|
||||||
|
val adapter = context.getSystemService(BluetoothManager::class.java)?.adapter ?: return result(null)
|
||||||
|
if (!adapter.isEnabled) return result(false)
|
||||||
val car = CarBluetooth.carAddress?.uppercase()
|
val car = CarBluetooth.carAddress?.uppercase()
|
||||||
adapter.getProfileProxy(context, object : BluetoothProfile.ServiceListener {
|
val asked = adapter.getProfileProxy(context, object : BluetoothProfile.ServiceListener {
|
||||||
override fun onServiceConnected(profile: Int, proxy: BluetoothProfile) {
|
override fun onServiceConnected(profile: Int, proxy: BluetoothProfile) {
|
||||||
val connected = runCatching { proxy.connectedDevices }.getOrDefault(emptyList())
|
val connected = runCatching { proxy.connectedDevices }.getOrDefault(emptyList())
|
||||||
adapter.closeProfileProxy(profile, proxy)
|
adapter.closeProfileProxy(profile, proxy)
|
||||||
val ok = if (car != null) connected.any { it.address.uppercase() == car } else connected.isNotEmpty()
|
val ok = if (car != null) connected.any { it.address.uppercase() == car } else connected.isNotEmpty()
|
||||||
main.post {
|
main.post { result(ok) }
|
||||||
set(!ok, if (ok) "connected to the car" else "not connected to the car")
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onServiceDisconnected(profile: Int) {}
|
override fun onServiceDisconnected(profile: Int) {}
|
||||||
}, BluetoothProfile.A2DP)
|
}, BluetoothProfile.A2DP)
|
||||||
|
if (!asked) result(null)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun set(missing: Boolean, why: String) {
|
private fun set(missing: Boolean, why: String) {
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
package app.seb3thehacker.gearslip.car
|
package app.seb3thehacker.gearslip.car
|
||||||
|
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import app.seb3thehacker.gearslip.R
|
||||||
import app.seb3thehacker.gearslip.car.theme.*
|
import app.seb3thehacker.gearslip.car.theme.*
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.clickable
|
import androidx.compose.foundation.clickable
|
||||||
@@ -36,7 +38,7 @@ import androidx.compose.ui.unit.dp
|
|||||||
@Composable
|
@Composable
|
||||||
internal fun BluetoothPairOverlay() {
|
internal fun BluetoothPairOverlay() {
|
||||||
val prompt by CarBluetooth.prompt.collectAsState()
|
val prompt by CarBluetooth.prompt.collectAsState()
|
||||||
val bluetoothAudio = !CarSettings.pipeAudio.collectAsState().value
|
val bluetoothAudio = !CarSettings.pipeAudio.collectAsState().value && !CarSettings.thisCarPipes.collectAsState().value
|
||||||
when (prompt) {
|
when (prompt) {
|
||||||
CarBluetooth.Prompt.NONE -> {
|
CarBluetooth.Prompt.NONE -> {
|
||||||
val missing by BluetoothAudioCheck.missing.collectAsState()
|
val missing by BluetoothAudioCheck.missing.collectAsState()
|
||||||
@@ -45,32 +47,28 @@ internal fun BluetoothPairOverlay() {
|
|||||||
val locationOff by LocationCheck.off.collectAsState()
|
val locationOff by LocationCheck.off.collectAsState()
|
||||||
val locationDismissed by LocationCheck.carDismissed.collectAsState()
|
val locationDismissed by LocationCheck.carDismissed.collectAsState()
|
||||||
if (missing && bluetoothAudio && warn && !dismissed) PairCard(
|
if (missing && bluetoothAudio && warn && !dismissed) PairCard(
|
||||||
title = "Bluetooth isn't connected",
|
title = stringResource(R.string.home_bt_title),
|
||||||
body = "Music plays over Bluetooth, but your phone isn't connected to the car. " +
|
body = stringResource(R.string.overlay_bt_body),
|
||||||
"Connect it from the car's Bluetooth menu or your phone's.",
|
secondary = stringResource(R.string.overlay_dont_show) to BluetoothAudioCheck::neverAgain,
|
||||||
secondary = "Don't show again" to BluetoothAudioCheck::neverAgain,
|
primary = stringResource(R.string.overlay_ok) to BluetoothAudioCheck::dismissOnCar,
|
||||||
primary = "OK" to BluetoothAudioCheck::dismissOnCar,
|
|
||||||
) else if (locationOff && !locationDismissed) PairCard(
|
) else if (locationOff && !locationDismissed) PairCard(
|
||||||
title = "Location is off",
|
title = stringResource(R.string.overlay_location_off),
|
||||||
body = "Map apps can't show where you are or give directions. Turn on location " +
|
body = stringResource(R.string.overlay_location_body),
|
||||||
"in your phone's quick settings when you're parked.",
|
secondary = stringResource(R.string.overlay_dont_show) to LocationCheck::neverAgain,
|
||||||
secondary = "Don't show again" to LocationCheck::neverAgain,
|
primary = stringResource(R.string.overlay_ok) to LocationCheck::dismissOnCar,
|
||||||
primary = "OK" to LocationCheck::dismissOnCar,
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
CarBluetooth.Prompt.ASK -> PairCard(
|
CarBluetooth.Prompt.ASK -> PairCard(
|
||||||
title = "Pair with this car?",
|
title = stringResource(R.string.overlay_pair_title),
|
||||||
body = (if (bluetoothAudio) "Calls and music play" else "Calls play") +
|
body = stringResource(if (bluetoothAudio) R.string.overlay_pair_body_music else R.string.overlay_pair_body),
|
||||||
" through the car's speakers once your phone and the car are paired. Your phone " +
|
secondary = stringResource(R.string.worked_not_now) to CarBluetooth::notNow,
|
||||||
"then asks you to confirm a code.",
|
primary = stringResource(R.string.overlay_pair) to CarBluetooth::pair,
|
||||||
secondary = "Not now" to CarBluetooth::notNow,
|
|
||||||
primary = "Pair" to CarBluetooth::pair,
|
|
||||||
)
|
)
|
||||||
CarBluetooth.Prompt.PAIRING -> PairCard(
|
CarBluetooth.Prompt.PAIRING -> PairCard(
|
||||||
title = "Confirm on your phone",
|
title = stringResource(R.string.overlay_confirm_title),
|
||||||
body = "Check the code on your phone and tap Pair. The car may ask too.",
|
body = stringResource(R.string.overlay_confirm_body),
|
||||||
secondary = null,
|
secondary = null,
|
||||||
primary = "Hide" to CarBluetooth::hide,
|
primary = stringResource(R.string.carset_hide) to CarBluetooth::hide,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,182 @@
|
|||||||
|
package app.seb3thehacker.gearslip.car
|
||||||
|
|
||||||
|
import androidx.compose.foundation.Image
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.PaddingValues
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.offset
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.layout.width
|
||||||
|
import androidx.compose.foundation.layout.widthIn
|
||||||
|
import androidx.compose.foundation.lazy.grid.GridCells
|
||||||
|
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
|
||||||
|
import androidx.compose.foundation.lazy.grid.items
|
||||||
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.Add
|
||||||
|
import androidx.compose.material.icons.filled.Close
|
||||||
|
import androidx.compose.material.icons.filled.Search
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.collectAsState
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.clip
|
||||||
|
import androidx.compose.ui.graphics.asImageBitmap
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import app.seb3thehacker.gearslip.R
|
||||||
|
import app.seb3thehacker.gearslip.browser.Bookmark
|
||||||
|
import app.seb3thehacker.gearslip.browser.BrowserSettings
|
||||||
|
import app.seb3thehacker.gearslip.browser.SiteIcons
|
||||||
|
import app.seb3thehacker.gearslip.car.theme.GsIconBox
|
||||||
|
import app.seb3thehacker.gearslip.car.theme.GsTone
|
||||||
|
import app.seb3thehacker.gearslip.car.theme.gsColors
|
||||||
|
import app.seb3thehacker.gearslip.car.theme.gsShape
|
||||||
|
|
||||||
|
/** How wide a tile is, icon and label together; the grid fits as many as the screen allows. */
|
||||||
|
private val TILE_WIDTH = 112.dp
|
||||||
|
private val ICON_SIZE = 64.dp
|
||||||
|
private val SEARCH_MAX_WIDTH = 560.dp
|
||||||
|
/** Five tiles across at most, so a short list sits in the middle instead of along the left. */
|
||||||
|
private val GRID_MAX_WIDTH = TILE_WIDTH * 5
|
||||||
|
/** Room above each tile for the remove button, which sits on the icon's corner. */
|
||||||
|
private val REMOVE_ROOM = 10.dp
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What the browser shows before a page is open: a search bar, and the bookmarks as big tiles.
|
||||||
|
* Holding a tile shows a remove button on every tile, as a launcher does; tapping anywhere
|
||||||
|
* else, or Done, puts them away. Adding happens from the browser's star, or on the phone.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
internal fun BrowserStartPage(onOpen: (String) -> Unit, onSearch: () -> Unit, onAdd: () -> Unit) {
|
||||||
|
val bookmarks by BrowserSettings.bookmarks.collectAsState()
|
||||||
|
var editing by remember { mutableStateOf(false) }
|
||||||
|
if (bookmarks.isEmpty()) editing = false
|
||||||
|
|
||||||
|
// A narrow, centred column: on a wide car screen a bar from edge to edge reads as a toolbar
|
||||||
|
// rather than a search box, and tiles spread across it look scattered.
|
||||||
|
Column(
|
||||||
|
Modifier.fillMaxSize().padding(horizontal = 16.dp).padding(top = 24.dp),
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
verticalArrangement = Arrangement.spacedBy(20.dp),
|
||||||
|
) {
|
||||||
|
SearchBar(onSearch)
|
||||||
|
LazyVerticalGrid(
|
||||||
|
columns = GridCells.Adaptive(TILE_WIDTH),
|
||||||
|
contentPadding = PaddingValues(bottom = 16.dp),
|
||||||
|
horizontalArrangement = Arrangement.Center,
|
||||||
|
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
modifier = Modifier.widthIn(max = GRID_MAX_WIDTH).fillMaxSize(),
|
||||||
|
) {
|
||||||
|
items(bookmarks, key = { it.url }) { bookmark ->
|
||||||
|
BookmarkTile(
|
||||||
|
bookmark,
|
||||||
|
editing = editing,
|
||||||
|
onClick = { if (editing) editing = false else onOpen(bookmark.url) },
|
||||||
|
onHold = { editing = true },
|
||||||
|
onRemove = { BrowserSettings.removeBookmark(bookmark.url) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
item(key = "add") {
|
||||||
|
Box(Modifier.padding(top = REMOVE_ROOM)) {
|
||||||
|
if (editing) {
|
||||||
|
Tile(stringResource(R.string.web_done), onClick = { editing = false }) {
|
||||||
|
Icon(Icons.Filled.Close, null, Modifier.size(28.dp))
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Tile(stringResource(R.string.web_add_bookmark), onClick = onAdd) {
|
||||||
|
Icon(Icons.Filled.Add, null, Modifier.size(28.dp))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Looks like a text field, but opens the car keyboard: the same as the header's Search. */
|
||||||
|
@Composable
|
||||||
|
private fun SearchBar(onSearch: () -> Unit) {
|
||||||
|
val colors = gsColors(GsTone.Neutral)
|
||||||
|
GsIconBox(onSearch, Modifier.widthIn(max = SEARCH_MAX_WIDTH).fillMaxWidth().height(56.dp), colors, shape = CircleShape) {
|
||||||
|
Row(Modifier.fillMaxWidth().padding(horizontal = 20.dp), verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
Icon(Icons.Filled.Search, null, Modifier.size(24.dp))
|
||||||
|
Text(
|
||||||
|
stringResource(R.string.web_search),
|
||||||
|
style = MaterialTheme.typography.bodyLarge,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
modifier = Modifier.padding(start = 12.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun BookmarkTile(bookmark: Bookmark, editing: Boolean, onClick: () -> Unit, onHold: () -> Unit, onRemove: () -> Unit) {
|
||||||
|
val context = LocalContext.current
|
||||||
|
val host = bookmark.host
|
||||||
|
val version by SiteIcons.version.collectAsState()
|
||||||
|
LaunchedEffect(host) { SiteIcons.fetch(context, host) }
|
||||||
|
val icon = remember(host, version) { SiteIcons.icon(context, host) }
|
||||||
|
// The remove button overhangs the icon's corner, into the room left above it, so nothing
|
||||||
|
// clips it: neither the grid's edge nor the next row.
|
||||||
|
Box(Modifier.width(TILE_WIDTH).padding(top = REMOVE_ROOM)) {
|
||||||
|
Tile(bookmark.title, onClick = onClick, onHold = onHold) {
|
||||||
|
if (icon != null) {
|
||||||
|
Image(icon.asImageBitmap(), null, Modifier.size(40.dp).clip(gsShape()))
|
||||||
|
} else {
|
||||||
|
Text(
|
||||||
|
bookmark.title.firstOrNull()?.uppercase() ?: "?",
|
||||||
|
style = MaterialTheme.typography.headlineMedium,
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (editing) {
|
||||||
|
GsIconBox(
|
||||||
|
onRemove,
|
||||||
|
Modifier.align(Alignment.TopCenter).offset(x = ICON_SIZE / 2, y = -REMOVE_ROOM).size(28.dp),
|
||||||
|
gsColors(GsTone.Danger),
|
||||||
|
shape = CircleShape,
|
||||||
|
) {
|
||||||
|
Icon(Icons.Filled.Close, stringResource(R.string.web_remove_bookmark, bookmark.title), Modifier.size(18.dp))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** An icon box with its label under it, the start page's one shape. */
|
||||||
|
@Composable
|
||||||
|
private fun Tile(label: String, onClick: () -> Unit, onHold: (() -> Unit)? = null, icon: @Composable () -> Unit) {
|
||||||
|
Column(Modifier.width(TILE_WIDTH), horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||||
|
GsIconBox(onClick, Modifier.size(ICON_SIZE), gsColors(GsTone.Tonal), onLongClick = onHold) { icon() }
|
||||||
|
Text(
|
||||||
|
label,
|
||||||
|
style = MaterialTheme.typography.labelLarge,
|
||||||
|
color = MaterialTheme.colorScheme.onSurface,
|
||||||
|
textAlign = TextAlign.Center,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
modifier = Modifier.fillMaxWidth().padding(horizontal = 4.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,5 +1,7 @@
|
|||||||
package app.seb3thehacker.gearslip.car
|
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.CircleShape
|
||||||
import app.seb3thehacker.gearslip.car.theme.*
|
import app.seb3thehacker.gearslip.car.theme.*
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
@@ -63,7 +65,7 @@ private fun IncomingCall(s: CallUiState.Ringing, onAnswer: () -> Unit, onDecline
|
|||||||
)
|
)
|
||||||
Spacer(Modifier.height(16.dp))
|
Spacer(Modifier.height(16.dp))
|
||||||
Text(
|
Text(
|
||||||
"Incoming call",
|
stringResource(R.string.call_incoming),
|
||||||
style = MaterialTheme.typography.titleMedium,
|
style = MaterialTheme.typography.titleMedium,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
)
|
)
|
||||||
@@ -76,13 +78,13 @@ private fun IncomingCall(s: CallUiState.Ringing, onAnswer: () -> Unit, onDecline
|
|||||||
)
|
)
|
||||||
Spacer(Modifier.height(56.dp))
|
Spacer(Modifier.height(56.dp))
|
||||||
Row(horizontalArrangement = Arrangement.spacedBy(48.dp)) {
|
Row(horizontalArrangement = Arrangement.spacedBy(48.dp)) {
|
||||||
CallButton(Icons.Filled.Close, "Decline", MaterialTheme.colorScheme.error, enabled = s.canDecline, onClick = onDecline)
|
CallButton(Icons.Filled.Close, stringResource(R.string.call_decline), MaterialTheme.colorScheme.error, enabled = s.canDecline, onClick = onDecline)
|
||||||
CallButton(Icons.Filled.Call, "Answer", MaterialTheme.colorScheme.primary, enabled = true, onClick = onAnswer)
|
CallButton(Icons.Filled.Call, stringResource(R.string.call_answer), MaterialTheme.colorScheme.primary, enabled = true, onClick = onAnswer)
|
||||||
}
|
}
|
||||||
if (!s.canDecline) {
|
if (!s.canDecline) {
|
||||||
Spacer(Modifier.height(20.dp))
|
Spacer(Modifier.height(20.dp))
|
||||||
Text(
|
Text(
|
||||||
"Already ringing out loud - answer here, or let it ring.",
|
stringResource(R.string.call_ringing),
|
||||||
style = MaterialTheme.typography.bodySmall,
|
style = MaterialTheme.typography.bodySmall,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
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
|
package app.seb3thehacker.gearslip.car
|
||||||
|
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import app.seb3thehacker.gearslip.R
|
||||||
import app.seb3thehacker.gearslip.car.theme.*
|
import app.seb3thehacker.gearslip.car.theme.*
|
||||||
import android.view.SurfaceHolder
|
import android.view.SurfaceHolder
|
||||||
import android.view.SurfaceView
|
import android.view.SurfaceView
|
||||||
@@ -11,6 +13,8 @@ import androidx.compose.ui.input.pointer.PointerEventPass
|
|||||||
import androidx.compose.ui.input.pointer.util.VelocityTracker
|
import androidx.compose.ui.input.pointer.util.VelocityTracker
|
||||||
import kotlin.math.hypot
|
import kotlin.math.hypot
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.fillMaxHeight
|
||||||
|
import androidx.compose.foundation.layout.width
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
@@ -70,13 +74,35 @@ fun CarAppStage(
|
|||||||
// The app's own drawing goes underneath; everything else is the host's rendering of
|
// The app's own drawing goes underneath; everything else is the host's rendering of
|
||||||
// the template it described, laid over the top.
|
// the template it described, laid over the top.
|
||||||
androidx.compose.runtime.CompositionLocalProvider(LocalChromeCover provides cover, LocalCarAppConnection provides connection) {
|
androidx.compose.runtime.CompositionLocalProvider(LocalChromeCover provides cover, LocalCarAppConnection provides connection) {
|
||||||
if (status.surfaceRequested) {
|
// A place list's map is the host's to draw; any other map is the app's own surface.
|
||||||
AppSurface(connection, frame, template, Modifier.fillMaxSize())
|
val placeList = template as? androidx.car.app.model.PlaceListMapTemplate
|
||||||
|
if (placeList != null) {
|
||||||
|
HostPlaceMap(placeList, Modifier.fillMaxSize())
|
||||||
|
} else if (status.surfaceRequested) {
|
||||||
|
AppSurface(connection, frame, template, embedded, Modifier.fillMaxSize())
|
||||||
}
|
}
|
||||||
|
// The docked player floats over one side of the map: the chrome keeps clear of it,
|
||||||
|
// and the app hears that side is covered, so its map centres in the rest.
|
||||||
|
val panel = LocalSidePanelInset.current
|
||||||
|
val panelAtStart = LocalCardsAtEnd.current
|
||||||
TemplateChrome(
|
TemplateChrome(
|
||||||
template,
|
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))
|
BottomCover(navBar, Modifier.align(Alignment.BottomCenter))
|
||||||
}
|
}
|
||||||
if (!embedded) ChromeBar(status, onDisconnect, Modifier.align(Alignment.TopCenter))
|
if (!embedded) ChromeBar(status, onDisconnect, Modifier.align(Alignment.TopCenter))
|
||||||
@@ -94,6 +120,7 @@ private fun AppSurface(
|
|||||||
connection: CarAppConnection,
|
connection: CarAppConnection,
|
||||||
frame: CarEnvironment.Frame,
|
frame: CarEnvironment.Frame,
|
||||||
template: Template?,
|
template: Template?,
|
||||||
|
embedded: Boolean,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
var size by remember { mutableStateOf(0 to 0) }
|
var size by remember { mutableStateOf(0 to 0) }
|
||||||
@@ -103,15 +130,37 @@ private fun AppSurface(
|
|||||||
// The app lays its map out around whatever the host covers, so a template change or a card
|
// The app lays its map out around whatever the host covers, so a template change or a card
|
||||||
// changing size moves the visible area even though the surface itself hasn't changed.
|
// changing size moves the visible area even though the surface itself hasn't changed.
|
||||||
val (width, height) = size
|
val (width, height) = size
|
||||||
|
// A full-screen map takes the knob: turn to zoom, nudge to pan, press for its buttons. The
|
||||||
|
// home screen's map pane leaves it alone, so the knob still moves between the cards there.
|
||||||
|
val focusManager = androidx.compose.ui.platform.LocalFocusManager.current
|
||||||
|
val isMap = !embedded && !isContentTemplate(template)
|
||||||
|
DisposableEffect(connection, isMap, width, height) {
|
||||||
|
if (!isMap || width == 0 || height == 0) return@DisposableEffect onDispose {}
|
||||||
|
val knob = MapKnob(connection, width, height) { focusManager.clearFocus() }
|
||||||
|
CarKeys.knobTaker = knob
|
||||||
|
onDispose {
|
||||||
|
knob.release()
|
||||||
|
if (CarKeys.knobTaker === knob) CarKeys.knobTaker = null
|
||||||
|
}
|
||||||
|
}
|
||||||
val measured = bounds?.takeIf { width > 0 && height > 0 }?.let { cover?.visibleWithin(it, width, height) }
|
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
|
// 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.
|
// 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) }
|
val cardsAtEnd = LocalCardsAtEnd.current
|
||||||
LaunchedEffect(template, size, measured, measuredStable) {
|
val cardEdge = cardEdge
|
||||||
|
val measuredStable = bounds?.takeIf { width > 0 && height > 0 }?.let { cover?.visibleWithin(it, width, height, skip = cardEdge) }
|
||||||
|
// A little room above whatever ends the map at the bottom (the nav bar, or the screen's edge):
|
||||||
|
// apps draw their own corner pieces (Waze's speedometer) right on the area's bottom edge,
|
||||||
|
// which left them sitting flush on the bar.
|
||||||
|
val bottomGap = with(androidx.compose.ui.platform.LocalDensity.current) { MAP_BOTTOM_GAP.roundToPx() }
|
||||||
|
LaunchedEffect(template, size, measured, measuredStable, cardsAtEnd, bottomGap) {
|
||||||
if (width > 0 && height > 0) {
|
if (width > 0 && height > 0) {
|
||||||
val visible = visibleAreaFor(template, width, height, measured)
|
fun android.graphics.Rect.clearOfBar() = if (isEmpty) this else android.graphics.Rect(left, top, right, maxOf(top, bottom - bottomGap))
|
||||||
val stable = if (!isContentTemplate(template)) {
|
val visible = visibleAreaFor(template, width, height, measured, cardsAtEnd).clearOfBar()
|
||||||
visibleAreaFor(template, width, height, measuredStable)
|
// Waze seems to centre its car in the stable area rather than the visible one, so it
|
||||||
|
// gets the cards taken out of that too. Test: see whether its car moves out from under them.
|
||||||
|
val stable = if (!isContentTemplate(template) && connection.appPackage != "com.waze") {
|
||||||
|
visibleAreaFor(template, width, height, measuredStable, cardsAtEnd).clearOfBar()
|
||||||
} else {
|
} else {
|
||||||
visible
|
visible
|
||||||
}
|
}
|
||||||
@@ -227,14 +276,14 @@ private fun ChromeBar(
|
|||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
) {
|
) {
|
||||||
Text(
|
Text(
|
||||||
status.app ?: "Car app",
|
status.app ?: stringResource(R.string.stage_car_app),
|
||||||
style = MaterialTheme.typography.titleSmall,
|
style = MaterialTheme.typography.titleSmall,
|
||||||
fontWeight = FontWeight.SemiBold,
|
fontWeight = FontWeight.SemiBold,
|
||||||
modifier = Modifier.weight(1f),
|
modifier = Modifier.weight(1f),
|
||||||
)
|
)
|
||||||
GsIconButton(
|
GsIconButton(
|
||||||
androidx.compose.material.icons.Icons.Filled.Close,
|
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,
|
onDisconnect,
|
||||||
size = 36.dp,
|
size = 36.dp,
|
||||||
iconSize = 20.dp,
|
iconSize = 20.dp,
|
||||||
@@ -244,6 +293,9 @@ private fun ChromeBar(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** How far above the bottom of the map the area an app may draw in stops. */
|
||||||
|
private val MAP_BOTTOM_GAP = 12.dp
|
||||||
|
|
||||||
/** Height of [ChromeBar]; the template's own chrome starts below it. */
|
/** Height of [ChromeBar]; the template's own chrome starts below it. */
|
||||||
private val CHROME_BAR = 44.dp
|
private val CHROME_BAR = 44.dp
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
package app.seb3thehacker.gearslip.car
|
package app.seb3thehacker.gearslip.car
|
||||||
|
|
||||||
|
import androidx.annotation.StringRes
|
||||||
|
import app.seb3thehacker.gearslip.R
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.List
|
||||||
import androidx.compose.material.icons.filled.Call
|
import androidx.compose.material.icons.filled.Call
|
||||||
import androidx.compose.material.icons.filled.Search
|
import androidx.compose.material.icons.filled.Search
|
||||||
import androidx.compose.material.icons.filled.Share
|
import androidx.compose.material.icons.filled.Share
|
||||||
@@ -13,7 +16,9 @@ import app.seb3thehacker.gearslip.ProjectedContent
|
|||||||
/** One tile in the launcher and the screen it opens. */
|
/** One tile in the launcher and the screen it opens. */
|
||||||
class CarApp(
|
class CarApp(
|
||||||
val id: String,
|
val id: String,
|
||||||
|
/** English, for matching what a driver says; [labelRes] is what the screen shows. */
|
||||||
val label: String,
|
val label: String,
|
||||||
|
@param:StringRes val labelRes: Int,
|
||||||
val icon: ImageVector,
|
val icon: ImageVector,
|
||||||
val content: @Composable () -> Unit,
|
val content: @Composable () -> Unit,
|
||||||
)
|
)
|
||||||
@@ -22,19 +27,28 @@ class CarApp(
|
|||||||
object CarApps {
|
object CarApps {
|
||||||
|
|
||||||
val all: List<CarApp> = listOf(
|
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))
|
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()
|
PhoneAppsScreen()
|
||||||
},
|
},
|
||||||
CarApp("dialer", "Phone", Icons.Filled.Call) {
|
CarApp("dialer", "Phone", R.string.carset_phone, Icons.Filled.Call) {
|
||||||
PhoneDialerScreen()
|
PhoneDialerScreen()
|
||||||
},
|
},
|
||||||
CarApp("settings", "Settings", Icons.Filled.Settings) {
|
CarApp("settings", "Settings", R.string.settings_title, Icons.Filled.Settings) {
|
||||||
CarSettingsScreen()
|
CarSettingsScreen()
|
||||||
},
|
},
|
||||||
|
CarApp("allapps", "All apps", R.string.app_all_apps, Icons.Filled.List) {
|
||||||
|
AllAppsScreen()
|
||||||
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
fun find(id: String): CarApp? = all.firstOrNull { it.id == id }
|
fun find(id: String): CarApp? = all.firstOrNull { it.id == id }
|
||||||
|
|
||||||
|
/** The screens [BuiltInApps.entries] offers here, so a choice list never names a hidden one. */
|
||||||
|
fun shown(launcher: Boolean, canCall: Boolean): List<CarApp> {
|
||||||
|
val ids = BuiltInApps.entries(launcher, canCall).mapNotNull { (it.builtIn?.screen as? CarScreen.App)?.id }.toSet()
|
||||||
|
return all.filter { it.id in ids }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -1,5 +1,7 @@
|
|||||||
package app.seb3thehacker.gearslip.car
|
package app.seb3thehacker.gearslip.car
|
||||||
|
|
||||||
|
import androidx.annotation.StringRes
|
||||||
|
import app.seb3thehacker.gearslip.R
|
||||||
import android.Manifest
|
import android.Manifest
|
||||||
import android.annotation.SuppressLint
|
import android.annotation.SuppressLint
|
||||||
import android.bluetooth.BluetoothAdapter
|
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
|
* 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
|
* 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.
|
* 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 {
|
object CarBluetooth {
|
||||||
|
|
||||||
@@ -50,49 +57,154 @@ object CarBluetooth {
|
|||||||
private val main = Handler(Looper.getMainLooper())
|
private val main = Handler(Looper.getMainLooper())
|
||||||
private var appContext: Context? = null
|
private var appContext: Context? = null
|
||||||
|
|
||||||
/** This session's car: its channel, and how to open and write to it. */
|
/** This session's car: its channel, how to open and write to it, and who hears its answers. */
|
||||||
private class Car(
|
private class Car(
|
||||||
val service: ServiceDiscovery.BluetoothService,
|
val service: ServiceDiscovery.BluetoothService,
|
||||||
val open: () -> Unit,
|
val open: () -> Unit,
|
||||||
val send: (messageId: Int, body: ByteArray) -> Unit,
|
val send: (messageId: Int, body: ByteArray) -> Unit,
|
||||||
|
val answered: () -> Unit,
|
||||||
)
|
)
|
||||||
|
|
||||||
@Volatile private var car: Car? = null
|
@Volatile private var car: Car? = null
|
||||||
@Volatile private var bondStarted = false
|
@Volatile private var bondStarted = false
|
||||||
/** From the Pair tap until it succeeds or fails, whether or not the card still shows. */
|
/** From the Pair tap until it succeeds or fails, whether or not the card still shows. */
|
||||||
@Volatile private var pairing = false
|
@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
|
private var receiver: BroadcastReceiver? = null
|
||||||
|
|
||||||
/** The car's Bluetooth address, when it named one in its service list. */
|
/** The car's Bluetooth address, when it named one in its service list. */
|
||||||
val carAddress: String? get() = car?.service?.carAddress
|
val carAddress: String? get() = car?.service?.carAddress
|
||||||
|
|
||||||
/** The car's Bluetooth channel once the driver has asked to pair, -1 otherwise. */
|
/** 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) {
|
fun init(context: Context) {
|
||||||
appContext = context.applicationContext
|
appContext = context.applicationContext
|
||||||
|
_addressWanted.value = AppSettings.getString(context, KEY_ADDRESS_ASK, "").ifEmpty { null }
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Called once the car lists its services. Asks on the car screen only if there's pairing to do. */
|
private val _addressWanted = MutableStateFlow<String?>(null)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A car paired over Bluetooth connected while Gearslip didn't know the phone's address: the
|
||||||
|
* car's name, for the phone's home screen to ask for it. Null when there's nothing to ask.
|
||||||
|
* Issue #18: a 2021 Bolt hung up 25 seconds in until it was told which phone this is.
|
||||||
|
*/
|
||||||
|
val addressWanted: StateFlow<String?> = _addressWanted
|
||||||
|
|
||||||
|
/** Debug builds: shows the phone's card as if a car had just asked. */
|
||||||
|
fun askForAddressNow(context: Context) {
|
||||||
|
AppSettings.putString(context, KEY_ADDRESS_ASK, "Test car")
|
||||||
|
_addressWanted.value = "Test car"
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The phone's card was answered or turned away: it doesn't come back for this car. */
|
||||||
|
fun addressAsked(context: Context) {
|
||||||
|
AppSettings.putString(context, KEY_ADDRESS_ASK, "")
|
||||||
|
_addressWanted.value = null
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Once per car: it waits on the phone, and the car screen says so once. A car that keeps its
|
||||||
|
* screen isn't asked again on every drive: plenty of cars just wait for a tap on their icon.
|
||||||
|
*/
|
||||||
|
@SuppressLint("MissingPermission")
|
||||||
|
private fun askForAddress(device: BluetoothDevice, carAddress: String) {
|
||||||
|
val context = appContext ?: return
|
||||||
|
val asked = AppSettings.getString(context, KEY_ADDRESS_ASKED, "").split(",").filter { it.isNotEmpty() }
|
||||||
|
if (hash(carAddress) in asked) return
|
||||||
|
AppSettings.putString(context, KEY_ADDRESS_ASKED, (asked + hash(carAddress)).distinct().joinToString(","))
|
||||||
|
val name = runCatching { device.alias ?: device.name }.getOrNull()?.takeIf { it.isNotBlank() }
|
||||||
|
?: context.getString(R.string.bt_address_your_car)
|
||||||
|
AppSettings.putString(context, KEY_ADDRESS_ASK, name)
|
||||||
|
_addressWanted.value = name
|
||||||
|
log.i("paired, but the phone's address isn't known: asking on the phone")
|
||||||
|
main.post { CarToasts.show(this, context.getString(R.string.bt_address_car_note), CarToast.LENGTH_LONG) }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Called once the car lists its services. Asks on the car screen only if there's pairing to do.
|
||||||
|
* [answered] runs when the car answers a pairing request or the pairing finishes.
|
||||||
|
*/
|
||||||
fun onCarConnected(
|
fun onCarConnected(
|
||||||
service: ServiceDiscovery.BluetoothService,
|
service: ServiceDiscovery.BluetoothService,
|
||||||
open: () -> Unit,
|
open: () -> Unit,
|
||||||
send: (messageId: Int, body: ByteArray) -> Unit,
|
send: (messageId: Int, body: ByteArray) -> Unit,
|
||||||
|
answered: () -> Unit = {},
|
||||||
) {
|
) {
|
||||||
car = Car(service, open, send)
|
car = Car(service, open, send, answered)
|
||||||
bondStarted = false
|
bondStarted = false
|
||||||
|
announcing = false
|
||||||
log.i("the car offers Bluetooth pairing, methods=${service.pairingMethods}")
|
log.i("the car offers Bluetooth pairing, methods=${service.pairingMethods}")
|
||||||
val device = device() ?: return
|
val device = device() ?: return
|
||||||
when {
|
when {
|
||||||
device.bondState == BluetoothDevice.BOND_BONDED -> log.i("already paired with this car")
|
device.bondState == BluetoothDevice.BOND_BONDED && typedAddress() != null -> {
|
||||||
|
log.i("already paired with this car - telling it which phone this is")
|
||||||
|
announcing = true
|
||||||
|
open()
|
||||||
|
}
|
||||||
|
device.bondState == BluetoothDevice.BOND_BONDED -> {
|
||||||
|
log.i("already paired with this car")
|
||||||
|
askForAddress(device, service.carAddress)
|
||||||
|
}
|
||||||
declined(service.carAddress) -> log.i("not paired, and the driver said not now for this car")
|
declined(service.carAddress) -> log.i("not paired, and the driver said not now for this car")
|
||||||
else -> _prompt.value = Prompt.ASK
|
else -> _prompt.value = Prompt.ASK
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Opens the car's Bluetooth channel though there's no pairing to do, as a real phone does on
|
||||||
|
* every connection. Issue #18: a 2021 Bolt hangs up about 20 seconds in, and this is one of
|
||||||
|
* the two channels it offers that Gearslip never opened. Without an address the driver typed,
|
||||||
|
* it only opens the channel: a made-up address could set the car pairing with the wrong phone.
|
||||||
|
*/
|
||||||
|
fun announce(why: String) {
|
||||||
|
val c = car ?: return
|
||||||
|
if (pairing || announcing) return
|
||||||
|
announcing = true
|
||||||
|
log.i("-> ChannelOpenRequest on channel ${c.service.serviceId} ($why)")
|
||||||
|
c.open()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The car is keeping its own screen: do what Android Auto does on every connection, and tell
|
||||||
|
* it over Bluetooth which phone this is. Every Honda on file keeps its screen (a 2017 Pilot, a
|
||||||
|
* 2017 and a 2020 Civic; issues #43 and #57), and in each log the car's Bluetooth channel was
|
||||||
|
* never opened. Honda starts Android Auto with what its manuals call auto pairing, which runs
|
||||||
|
* on that channel, and a 2021 Bolt kept hanging up until it heard the phone's address (#18).
|
||||||
|
*
|
||||||
|
* - Paired, with the address typed in: the car already heard it at connect.
|
||||||
|
* - Paired, no address: open the channel, and ask for the address on the phone again.
|
||||||
|
* - Not paired: the Pair card waits on the car's screen, which this car won't give up, so
|
||||||
|
* pair as if the driver had tapped it. Android's pairing dialog still asks on the phone.
|
||||||
|
*
|
||||||
|
* True when the car was sent something it may answer.
|
||||||
|
*/
|
||||||
|
fun onScreenWithheld(): Boolean {
|
||||||
|
val c = car ?: return false
|
||||||
|
if (pairing || announcing) return false
|
||||||
|
val device = device() ?: return false
|
||||||
|
return when {
|
||||||
|
device.bondState == BluetoothDevice.BOND_BONDED -> {
|
||||||
|
askForAddress(device, c.service.carAddress)
|
||||||
|
announce("the car is keeping its screen")
|
||||||
|
true
|
||||||
|
}
|
||||||
|
device.bondState != BluetoothDevice.BOND_NONE -> false
|
||||||
|
declined(c.service.carAddress) -> false
|
||||||
|
else -> {
|
||||||
|
log.i("the car is keeping its screen and isn't paired with this phone - pairing, as Android Auto does")
|
||||||
|
main.post { pair() }
|
||||||
|
true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fun onCarDisconnected() {
|
fun onCarDisconnected() {
|
||||||
car = null
|
car = null
|
||||||
pairing = false
|
pairing = false
|
||||||
|
announcing = false
|
||||||
main.post {
|
main.post {
|
||||||
stopWatching()
|
stopWatching()
|
||||||
_prompt.value = Prompt.NONE
|
_prompt.value = Prompt.NONE
|
||||||
@@ -111,7 +223,7 @@ object CarBluetooth {
|
|||||||
// If the car never answers on its channel, pair from the phone anyway.
|
// 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({ startBond("the car didn't answer the pairing request") }, CAR_REPLY_WAIT_MS)
|
||||||
main.postDelayed({
|
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)
|
}, GIVE_UP_MS)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -137,7 +249,12 @@ object CarBluetooth {
|
|||||||
val status = Protobuf.readInt32Field(body, 1) ?: 0
|
val status = Protobuf.readInt32Field(body, 1) ?: 0
|
||||||
log.i("<- ChannelOpenResponse: status=$status")
|
log.i("<- ChannelOpenResponse: status=$status")
|
||||||
if (status != 0) {
|
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
|
return
|
||||||
}
|
}
|
||||||
val method = c.service.pairingMethods.firstOrNull { it == METHOD_NUMERIC_COMPARISON }
|
val method = c.service.pairingMethods.firstOrNull { it == METHOD_NUMERIC_COMPARISON }
|
||||||
@@ -151,6 +268,8 @@ object CarBluetooth {
|
|||||||
MSG_PAIRING_RESPONSE -> {
|
MSG_PAIRING_RESPONSE -> {
|
||||||
val fields = Wire.fields(body)
|
val fields = Wire.fields(body)
|
||||||
log.i("<- PairingResponse status=${Wire.varint(fields, 1)} already_paired=${Wire.varint(fields, 2) == 1L}")
|
log.i("<- PairingResponse status=${Wire.varint(fields, 1)} already_paired=${Wire.varint(fields, 2) == 1L}")
|
||||||
|
c.answered()
|
||||||
|
if (!pairing) return
|
||||||
// Some cars start pairing themselves once they answer; give them a moment first.
|
// Some cars start pairing themselves once they answer; give them a moment first.
|
||||||
main.postDelayed({ startBond("the car answered") }, CAR_BOND_WAIT_MS)
|
main.postDelayed({ startBond("the car answered") }, CAR_BOND_WAIT_MS)
|
||||||
}
|
}
|
||||||
@@ -168,7 +287,7 @@ object CarBluetooth {
|
|||||||
bondStarted = true
|
bondStarted = true
|
||||||
val started = runCatching { device.createBond() }.getOrDefault(false)
|
val started = runCatching { device.createBond() }.getOrDefault(false)
|
||||||
log.i("pairing from the phone ($why): ${if (started) "started" else "refused"}")
|
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) {
|
private fun watchBonding(device: BluetoothDevice) {
|
||||||
@@ -184,8 +303,11 @@ object CarBluetooth {
|
|||||||
log.i("bond state $previous -> $state")
|
log.i("bond state $previous -> $state")
|
||||||
when {
|
when {
|
||||||
state == BluetoothDevice.BOND_BONDING -> bondStarted = true
|
state == BluetoothDevice.BOND_BONDING -> bondStarted = true
|
||||||
state == BluetoothDevice.BOND_BONDED -> finish("Paired. Calls now play through the car.")
|
state == BluetoothDevice.BOND_BONDED -> {
|
||||||
previous == BluetoothDevice.BOND_BONDING -> finish("Pairing didn't finish. Pair from the car's Bluetooth menu instead.")
|
finish(R.string.bt_paired)
|
||||||
|
car?.answered?.invoke()
|
||||||
|
}
|
||||||
|
previous == BluetoothDevice.BOND_BONDING -> finish(R.string.bt_pair_unfinished)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -193,12 +315,12 @@ object CarBluetooth {
|
|||||||
receiver = r
|
receiver = r
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun finish(message: String) {
|
private fun finish(@StringRes message: Int) {
|
||||||
pairing = false
|
pairing = false
|
||||||
stopWatching()
|
stopWatching()
|
||||||
main.removeCallbacksAndMessages(null)
|
main.removeCallbacksAndMessages(null)
|
||||||
_prompt.value = Prompt.NONE
|
_prompt.value = Prompt.NONE
|
||||||
CarToasts.show(this, message, CarToast.LENGTH_LONG)
|
CarToasts.show(this, appContext?.getString(message) ?: return, CarToast.LENGTH_LONG)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun stopWatching() {
|
private fun stopWatching() {
|
||||||
@@ -209,7 +331,10 @@ object CarBluetooth {
|
|||||||
|
|
||||||
/** The car as a Bluetooth device, or null (and logged) when Gearslip can't reach Bluetooth. */
|
/** The car as a Bluetooth device, or null (and logged) when Gearslip can't reach Bluetooth. */
|
||||||
private fun device(): BluetoothDevice? {
|
private fun device(): BluetoothDevice? {
|
||||||
val context = appContext ?: return null
|
val context = appContext ?: run {
|
||||||
|
log.w("can't check pairing: Bluetooth isn't set up yet")
|
||||||
|
return null
|
||||||
|
}
|
||||||
val address = car?.service?.carAddress ?: return null
|
val address = car?.service?.carAddress ?: return null
|
||||||
if (context.checkSelfPermission(Manifest.permission.BLUETOOTH_CONNECT) != PackageManager.PERMISSION_GRANTED) {
|
if (context.checkSelfPermission(Manifest.permission.BLUETOOTH_CONNECT) != PackageManager.PERMISSION_GRANTED) {
|
||||||
log.i("can't check pairing: the Bluetooth permission isn't granted")
|
log.i("can't check pairing: the Bluetooth permission isn't granted")
|
||||||
@@ -227,12 +352,31 @@ object CarBluetooth {
|
|||||||
return adapter.getRemoteDevice(address)
|
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")
|
@SuppressLint("MissingPermission", "HardwareIds")
|
||||||
private fun phoneAddress(): String =
|
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"
|
?: "02:00:00:00:00:00"
|
||||||
|
|
||||||
|
private fun typedAddress(): String? = appContext?.let { typedAddress(it) }
|
||||||
|
|
||||||
|
/** The phone's own address as the driver typed it in Settings, or null. */
|
||||||
|
fun typedAddress(context: Context): String? =
|
||||||
|
AppSettings.getString(context, KEY_PHONE_ADDRESS, "").takeIf { BluetoothAdapter.checkBluetoothAddress(it) }
|
||||||
|
|
||||||
|
/** Saves [address] after tidying it up; false when it isn't one. Empty clears it. */
|
||||||
|
fun setTypedAddress(context: Context, address: String): Boolean {
|
||||||
|
val tidy = address.trim().uppercase().replace('-', ':')
|
||||||
|
if (tidy.isNotEmpty() && !BluetoothAdapter.checkBluetoothAddress(tidy)) return false
|
||||||
|
AppSettings.putString(context, KEY_PHONE_ADDRESS, tidy)
|
||||||
|
if (tidy.isNotEmpty()) addressAsked(context)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
private fun declined(address: String): Boolean {
|
private fun declined(address: String): Boolean {
|
||||||
val context = appContext ?: return false
|
val context = appContext ?: return false
|
||||||
return hash(address) in AppSettings.getString(context, KEY_DECLINED, "").split(",")
|
return hash(address) in AppSettings.getString(context, KEY_DECLINED, "").split(",")
|
||||||
@@ -244,6 +388,11 @@ object CarBluetooth {
|
|||||||
.take(8).joinToString("") { "%02x".format(it) }
|
.take(8).joinToString("") { "%02x".format(it) }
|
||||||
|
|
||||||
private const val KEY_DECLINED = "bluetooth_pair_declined"
|
private const val KEY_DECLINED = "bluetooth_pair_declined"
|
||||||
|
/** The car's name while the phone's card asks for the address; empty when it doesn't. */
|
||||||
|
private const val KEY_ADDRESS_ASK = "bluetooth_address_ask"
|
||||||
|
/** Hashes of the cars already asked about, so each asks once. */
|
||||||
|
private const val KEY_ADDRESS_ASKED = "bluetooth_address_asked"
|
||||||
|
private const val KEY_PHONE_ADDRESS = "phone_bluetooth_address"
|
||||||
private const val CAR_REPLY_WAIT_MS = 5_000L
|
private const val CAR_REPLY_WAIT_MS = 5_000L
|
||||||
private const val CAR_BOND_WAIT_MS = 3_000L
|
private const val CAR_BOND_WAIT_MS = 3_000L
|
||||||
private const val GIVE_UP_MS = 90_000L
|
private const val GIVE_UP_MS = 90_000L
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
package app.seb3thehacker.gearslip.car
|
package app.seb3thehacker.gearslip.car
|
||||||
|
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import app.seb3thehacker.gearslip.R
|
||||||
import android.Manifest
|
import android.Manifest
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.content.pm.PackageManager
|
import android.content.pm.PackageManager
|
||||||
@@ -12,6 +14,10 @@ import androidx.compose.foundation.layout.Row
|
|||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.width
|
||||||
import androidx.compose.foundation.rememberScrollState
|
import androidx.compose.foundation.rememberScrollState
|
||||||
import androidx.compose.foundation.verticalScroll
|
import androidx.compose.foundation.verticalScroll
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
@@ -60,7 +66,7 @@ fun CarDashboardScreen() {
|
|||||||
.padding(horizontal = 24.dp, vertical = 16.dp),
|
.padding(horizontal = 24.dp, vertical = 16.dp),
|
||||||
verticalArrangement = Arrangement.spacedBy(24.dp),
|
verticalArrangement = Arrangement.spacedBy(24.dp),
|
||||||
) {
|
) {
|
||||||
ScreenHeader("Today")
|
ScreenHeader(stringResource(R.string.dash_today))
|
||||||
WeatherCard(context)
|
WeatherCard(context)
|
||||||
AgendaCard(context)
|
AgendaCard(context)
|
||||||
NotificationsSection()
|
NotificationsSection()
|
||||||
@@ -84,27 +90,74 @@ private fun WeatherCard(context: Context) {
|
|||||||
horizontalArrangement = Arrangement.spacedBy(16.dp),
|
horizontalArrangement = Arrangement.spacedBy(16.dp),
|
||||||
) {
|
) {
|
||||||
when (val s = state) {
|
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(
|
is WeatherState.Unavailable -> Text(
|
||||||
s.reason, style = MaterialTheme.typography.bodyLarge,
|
stringResource(s.reason), style = MaterialTheme.typography.bodyLarge,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
)
|
)
|
||||||
is WeatherState.Ready -> {
|
is WeatherState.Ready -> {
|
||||||
val w = s.data
|
val w = s.data
|
||||||
WeatherIcon(w.code, w.isDay, size = 44.dp)
|
WeatherIcon(w.code, w.isDay, size = 44.dp)
|
||||||
Column {
|
Column(Modifier.weight(1f)) {
|
||||||
Text(
|
Text(
|
||||||
"${w.temp}${w.tempUnit} · ${Weather.describe(w.code)}",
|
"${w.temp}${w.tempUnit} · ${Weather.condition(w.code)}",
|
||||||
style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.SemiBold,
|
style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.SemiBold,
|
||||||
|
maxLines = 1, overflow = androidx.compose.ui.text.style.TextOverflow.Ellipsis,
|
||||||
)
|
)
|
||||||
Text(w.place, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
Text(w.place, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, maxLines = 1)
|
||||||
}
|
}
|
||||||
|
NextHours(w.hours, Modifier.weight(1.3f))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The next few hours beside the weather now, in the same card, so a glance answers "will it rain
|
||||||
|
* on the way" without opening Weather. As many as fit, up to five; none if fewer than three would.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
private fun NextHours(all: List<HourForecast>, modifier: Modifier) {
|
||||||
|
val now = System.currentTimeMillis()
|
||||||
|
// The forecast is cached, so its first hours may already be past.
|
||||||
|
val hours = all.filter { it.time > now }
|
||||||
|
androidx.compose.foundation.layout.BoxWithConstraints(modifier) {
|
||||||
|
val fit = (maxWidth / HOUR_WIDTH).toInt().coerceAtMost(5)
|
||||||
|
if (fit < 3 || hours.size < 3) return@BoxWithConstraints
|
||||||
|
Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
Box(Modifier.width(1.dp).height(64.dp).background(MaterialTheme.colorScheme.outlineVariant))
|
||||||
|
hours.take(fit).forEach { h ->
|
||||||
|
Column(
|
||||||
|
Modifier.weight(1f),
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
verticalArrangement = Arrangement.spacedBy(2.dp),
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
DateFormat.getTimeInstance(DateFormat.SHORT).format(Date(h.time)).replace(":00", ""),
|
||||||
|
style = MaterialTheme.typography.labelMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
maxLines = 1,
|
||||||
|
)
|
||||||
|
WeatherIcon(h.code, h.isDay, size = 24.dp)
|
||||||
|
Text("${h.temp}°", style = MaterialTheme.typography.titleSmall, fontWeight = FontWeight.SemiBold)
|
||||||
|
// Kept as a blank line when dry, so every column lines up.
|
||||||
|
Text(
|
||||||
|
if (h.rainChance >= RAIN_SHOWN) "${h.rainChance}%" else " ",
|
||||||
|
style = MaterialTheme.typography.labelSmall,
|
||||||
|
color = MaterialTheme.colorScheme.primary,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private val HOUR_WIDTH = 64.dp
|
||||||
|
|
||||||
|
/** Below this a chance of rain is noise on a glance card; the Weather screen still shows it. */
|
||||||
|
private const val RAIN_SHOWN = 20
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun AgendaCard(context: Context) {
|
private fun AgendaCard(context: Context) {
|
||||||
val events by produceState<List<AgendaEvent>?>(cachedAgenda) {
|
val events by produceState<List<AgendaEvent>?>(cachedAgenda) {
|
||||||
@@ -112,12 +165,12 @@ private fun AgendaCard(context: Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
|
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
|
val list = events
|
||||||
when {
|
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(
|
list.isEmpty() -> Text(
|
||||||
"Nothing in the next week.",
|
stringResource(R.string.dash_nothing_week),
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
)
|
)
|
||||||
@@ -135,7 +188,7 @@ private fun EventRow(event: AgendaEvent) {
|
|||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
) {
|
) {
|
||||||
Text(
|
Text(
|
||||||
eventWhen(event),
|
eventWhen(event, stringResource(R.string.dash_all_day)),
|
||||||
style = MaterialTheme.typography.bodyLarge,
|
style = MaterialTheme.typography.bodyLarge,
|
||||||
fontWeight = FontWeight.SemiBold,
|
fontWeight = FontWeight.SemiBold,
|
||||||
)
|
)
|
||||||
@@ -149,14 +202,14 @@ private fun EventRow(event: AgendaEvent) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** "Today" events show just a time; later days get a weekday so a week's list stays readable. */
|
/** 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): String {
|
private fun eventWhen(event: AgendaEvent, allDay: String): String {
|
||||||
val zone = if (event.allDay) TimeZone.getTimeZone("UTC") else TimeZone.getDefault()
|
val zone = if (event.allDay) TimeZone.getTimeZone("UTC") else TimeZone.getDefault()
|
||||||
val start = Calendar.getInstance(zone).apply { timeInMillis = event.startMillis }
|
val start = Calendar.getInstance(zone).apply { timeInMillis = event.startMillis }
|
||||||
val today = Calendar.getInstance()
|
val today = Calendar.getInstance()
|
||||||
val sameDay = start.get(Calendar.YEAR) == today.get(Calendar.YEAR) &&
|
val sameDay = start.get(Calendar.YEAR) == today.get(Calendar.YEAR) &&
|
||||||
start.get(Calendar.DAY_OF_YEAR) == today.get(Calendar.DAY_OF_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
|
if (sameDay || (event.allDay && start.before(today))) return time
|
||||||
return SimpleDateFormat("EEE", Locale.getDefault()).apply { timeZone = zone }.format(Date(event.startMillis)) + " " + 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))
|
private val frameFlow = MutableStateFlow(Frame(800, 480, 160))
|
||||||
val frame: StateFlow<Frame> = frameFlow
|
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 phoneDark: StateFlow<Boolean> = phoneDarkFlow
|
||||||
val darkOutside: StateFlow<Boolean> = darkOutsideFlow
|
val darkOutside: StateFlow<Boolean> = darkOutsideFlow
|
||||||
val battery: StateFlow<Battery> = batteryFlow
|
val battery: StateFlow<Battery> = batteryFlow
|
||||||
@@ -64,7 +67,9 @@ object CarEnvironment {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Volatile private var sensorNight: Boolean? = null
|
@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 var running = false
|
||||||
|
|
||||||
private val handler = Handler(Looper.getMainLooper())
|
private val handler = Handler(Looper.getMainLooper())
|
||||||
@@ -93,8 +98,18 @@ object CarEnvironment {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fun setDisplay(width: Int, height: Int, densityDpi: Int) {
|
fun setDisplay(width: Int, height: Int, densityDpi: Int) {
|
||||||
displayFlow.value = "${width}x$height @ ${densityDpi}dpi"
|
carDensityDpi = densityDpi
|
||||||
frameFlow.value = Frame(width, height, 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) {
|
fun start(context: Context) {
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
package app.seb3thehacker.gearslip.car
|
package app.seb3thehacker.gearslip.car
|
||||||
|
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
import app.seb3thehacker.gearslip.car.theme.*
|
import app.seb3thehacker.gearslip.car.theme.*
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Box
|
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.height
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.layout.size
|
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.Icons
|
||||||
import androidx.compose.material.icons.filled.Close
|
import androidx.compose.material.icons.filled.Close
|
||||||
import androidx.compose.material.icons.filled.PlayArrow
|
import androidx.compose.material.icons.filled.PlayArrow
|
||||||
@@ -64,7 +67,9 @@ import app.seb3thehacker.gearslip.media.NowPlaying
|
|||||||
import kotlinx.coroutines.launch
|
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 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
|
* 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 splitScreen = phase == CarMedia.Phase.READY && now.isActive && panel != SidePanel.NONE
|
||||||
val playerLeft by CarSettings.playerLeft.collectAsStateWithLifecycle()
|
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.
|
// The map runs edge to edge underneath; the player floats over one side of it as a card, with
|
||||||
Row(Modifier.fillMaxSize()) {
|
// a gap all round. The map's surface keeps its size when the card comes and goes, so the app
|
||||||
val side = Modifier.weight(1f).fillMaxHeight().padding(bottom = LocalNavBarInset.current)
|
// only re-centres its map instead of drawing onto a new surface.
|
||||||
if (splitScreen && playerLeft) {
|
BoxWithConstraints(Modifier.fillMaxSize()) {
|
||||||
if (panel == SidePanel.LYRICS) SideLyrics(now, side) else DockedPlayer(now, side)
|
val column = (maxWidth / PLAYER_SHARE).coerceIn(PLAYER_MIN_WIDTH, PLAYER_MAX_WIDTH)
|
||||||
}
|
androidx.compose.runtime.CompositionLocalProvider(
|
||||||
Box(
|
LocalCardsAtEnd provides (splitScreen && playerLeft),
|
||||||
Modifier
|
LocalSidePanelInset provides if (splitScreen) column else 0.dp,
|
||||||
.weight(if (splitScreen) 2.1f else 1f)
|
|
||||||
.fillMaxHeight(),
|
|
||||||
) {
|
) {
|
||||||
MapPane(navStatus, frame)
|
MapPane(navStatus, frame)
|
||||||
}
|
}
|
||||||
if (splitScreen && !playerLeft) {
|
androidx.compose.animation.AnimatedVisibility(
|
||||||
if (panel == SidePanel.LYRICS) SideLyrics(now, side) else DockedPlayer(now, side)
|
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
|
@Composable
|
||||||
private fun MapPane(status: CarAppConnection.Status, frame: CarEnvironment.Frame) {
|
private fun MapPane(status: CarAppConnection.Status, frame: CarEnvironment.Frame) {
|
||||||
val navigator = LocalCarNavigator.current
|
val navigator = LocalCarNavigator.current
|
||||||
@@ -123,8 +158,11 @@ private fun MapPane(status: CarAppConnection.Status, frame: CarEnvironment.Frame
|
|||||||
}
|
}
|
||||||
|
|
||||||
Column(
|
Column(
|
||||||
Modifier.fillMaxSize().padding(24.dp),
|
Modifier.fillMaxSize().padding(
|
||||||
verticalArrangement = Arrangement.Center,
|
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,
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
) {
|
) {
|
||||||
val hasLast = CarSettings.lastNav.value != null
|
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()
|
val scope = androidx.compose.runtime.rememberCoroutineScope()
|
||||||
when (status.phase) {
|
when (status.phase) {
|
||||||
CarAppConnection.Phase.BINDING, CarAppConnection.Phase.HANDSHAKE ->
|
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 -> {
|
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) }
|
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) {
|
else -> if (!hasNavApps) {
|
||||||
// Nothing to auto-select and nothing to pick from Car apps either - this is the
|
// 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.
|
// 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(
|
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,
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
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
|
// 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
|
// 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.
|
// than sit on a "starting" message that never resolves.
|
||||||
Text("Map stopped.", style = MaterialTheme.typography.titleMedium)
|
Text(stringResource(R.string.home_map_stopped), style = MaterialTheme.typography.headlineSmall, fontWeight = FontWeight.Bold)
|
||||||
GsButton(onClick = { scope.launch { CarServices.reconnectNav(frame) } }, tone = GsTone.Tonal) { Text("Reopen") }
|
Text(
|
||||||
GsButton(onClick = { navigator.apps() }, tone = GsTone.Tonal) { Text("Car apps") }
|
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 {
|
} else {
|
||||||
// Autostart is still picking one - a navigation app is installed, so this resolves
|
// Autostart is still picking one - a navigation app is installed, so this resolves
|
||||||
// to a "Starting..." state itself, in the next frame or two.
|
// 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
|
* The player, docked beside the map: the art as large as it fits, then title, progress, transport,
|
||||||
* and lyrics. While it's here the nav bar's pill steps aside on Home, so the controls never show
|
* 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.
|
* twice. The X sends it back to the nav bar.
|
||||||
*/
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
@@ -175,28 +231,31 @@ private fun DockedPlayer(now: NowPlaying, modifier: Modifier) {
|
|||||||
val scheme = MaterialTheme.colorScheme
|
val scheme = MaterialTheme.colorScheme
|
||||||
val art by produceState(now.art, now.art, now.artUri) { value = now.art ?: MediaArt.load(context, now.artUri) }
|
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)) {
|
// The theme's surface, with a faint wash of the cover's colours behind the top of it. A solid
|
||||||
BoxWithConstraints(Modifier.fillMaxSize().padding(12.dp)) {
|
// slab of album colour beside the map fought it for attention; this only hints at the song.
|
||||||
// Whatever height the controls below leave over, so the art grows on a taller
|
Surface(modifier = modifier.gsPanelShadow(CARD_SHAPE), shape = CARD_SHAPE, color = scheme.surfaceContainer) {
|
||||||
// screen and shrinks rather than pushing the buttons off a short one.
|
AlbumGlow(art, Modifier.fillMaxSize())
|
||||||
val artSize = minOf(maxWidth - 44.dp, maxHeight - 250.dp).coerceIn(56.dp, 200.dp)
|
Box(Modifier.fillMaxSize().padding(12.dp)) {
|
||||||
Column(Modifier.fillMaxSize(), horizontalAlignment = Alignment.CenterHorizontally) {
|
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.
|
// Art opens the full media screen, same as on the pill.
|
||||||
|
val coverShape = RoundedCornerShape(16.dp)
|
||||||
Box(
|
Box(
|
||||||
Modifier
|
Modifier
|
||||||
.align(Alignment.TopCenter)
|
|
||||||
.size(artSize)
|
.size(artSize)
|
||||||
.clip(RoundedCornerShape(12.dp))
|
.gsPanelShadow(coverShape)
|
||||||
|
.clip(coverShape)
|
||||||
.background(scheme.surface)
|
.background(scheme.surface)
|
||||||
.clickable { navigator.media() },
|
.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))
|
Spacer(Modifier.height(10.dp))
|
||||||
Text(
|
Text(
|
||||||
@@ -212,46 +271,53 @@ private fun DockedPlayer(now: NowPlaying, modifier: Modifier) {
|
|||||||
} else {
|
} else {
|
||||||
Spacer(Modifier.height(10.dp))
|
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))
|
Spacer(Modifier.height(10.dp))
|
||||||
PlayModeRow(now, media, size = 42.dp, modifier = Modifier.fillMaxWidth()) {
|
PlayModeRow(now, media, size = 50.dp, modifier = Modifier.fillMaxWidth()) {
|
||||||
GsIconButton(
|
GsIconButton(
|
||||||
ImageVector.vectorResource(R.drawable.lyrics_24), "Lyrics", navigator::showSideLyrics,
|
ImageVector.vectorResource(R.drawable.lyrics_24), stringResource(R.string.storage_lyrics), navigator::showSideLyrics,
|
||||||
size = 42.dp, iconSize = 22.dp,
|
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
|
* Lyrics alone beside the map, big enough to read at a glance. The header is slim, since the
|
||||||
* larger title and artist instead. The player goes back to the nav bar meanwhile, whose dock
|
* player goes back to the nav bar meanwhile and names the song there. Its note button swaps the
|
||||||
* button swaps these back out for the controls.
|
* lyrics back for the player; the X closes the panel.
|
||||||
*/
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
private fun SideLyrics(now: NowPlaying, modifier: Modifier) {
|
private fun SideLyrics(now: NowPlaying, modifier: Modifier) {
|
||||||
val navigator = LocalCarNavigator.current
|
val navigator = LocalCarNavigator.current
|
||||||
val context = LocalContext.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) }
|
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)) {
|
Surface(modifier = modifier.gsPanelShadow(CARD_SHAPE), shape = CARD_SHAPE, color = scheme.surfaceContainer) {
|
||||||
Column(Modifier.fillMaxSize().padding(16.dp)) {
|
Column(Modifier.fillMaxSize().padding(start = 18.dp, end = 12.dp, top = 12.dp, bottom = 8.dp)) {
|
||||||
Row(verticalAlignment = Alignment.Top) {
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
Column(Modifier.weight(1f)) {
|
Box(Modifier.size(36.dp).clip(RoundedCornerShape(8.dp)).background(scheme.surface)) {
|
||||||
Text(
|
art?.let { Image(it.asImageBitmap(), null, Modifier.fillMaxSize(), contentScale = ContentScale.Crop) }
|
||||||
now.title, style = MaterialTheme.typography.headlineSmall, fontWeight = FontWeight.Bold,
|
|
||||||
maxLines = 2, overflow = TextOverflow.Ellipsis,
|
|
||||||
)
|
|
||||||
Text(
|
|
||||||
now.artist, style = MaterialTheme.typography.titleMedium,
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
||||||
maxLines = 1, overflow = TextOverflow.Ellipsis,
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
GsIconButton(Icons.Filled.Close, "Hide lyrics", navigator::closeSidePanel, size = 36.dp, iconSize = 20.dp)
|
Spacer(Modifier.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)
|
LyricsPanel(now, Modifier.weight(1f).fillMaxWidth(), lineStyle = MaterialTheme.typography.headlineSmall)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
package app.seb3thehacker.gearslip.car
|
package app.seb3thehacker.gearslip.car
|
||||||
|
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import app.seb3thehacker.gearslip.R
|
||||||
import app.seb3thehacker.gearslip.car.theme.*
|
import app.seb3thehacker.gearslip.car.theme.*
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
@@ -153,7 +155,7 @@ fun CarKeyboard(
|
|||||||
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
||||||
) {
|
) {
|
||||||
Key(if (symbols) "abc" else "?123", Modifier.weight(1.4f)) { symbols = !symbols }
|
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) {
|
if (onDismiss != null) {
|
||||||
DismissKey(onDismiss, Modifier.weight(1.4f))
|
DismissKey(onDismiss, Modifier.weight(1.4f))
|
||||||
}
|
}
|
||||||
@@ -200,7 +202,7 @@ private fun DismissKey(onClick: () -> Unit, modifier: Modifier = Modifier) {
|
|||||||
horizontalArrangement = Arrangement.Center,
|
horizontalArrangement = Arrangement.Center,
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
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(
|
Icon(
|
||||||
image ?: if (search) Icons.Filled.Search else Icons.Filled.ArrowBack,
|
image ?: if (search) Icons.Filled.Search else Icons.Filled.ArrowBack,
|
||||||
contentDescription = "Submit",
|
contentDescription = stringResource(R.string.keyboard_submit),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -35,11 +35,35 @@ object CarKeys {
|
|||||||
/** Delivers an Android key event to the car display; set while the car UI is showing. */
|
/** Delivers an Android key event to the car display; set while the car UI is showing. */
|
||||||
@Volatile var focusSink: ((keyCode: Int, down: Boolean) -> Unit)? = null
|
@Volatile var focusSink: ((keyCode: Int, down: Boolean) -> Unit)? = null
|
||||||
|
|
||||||
|
/** What a [KnobTaker] is offered: a turn of the knob, a focus key edge, or Back. */
|
||||||
|
sealed interface KnobEvent {
|
||||||
|
data class Turn(val steps: Int) : KnobEvent
|
||||||
|
data class Key(val keyCode: Int, val down: Boolean) : KnobEvent
|
||||||
|
data object Back : KnobEvent
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Something on screen that wants the knob before focus moves: the full-screen map, which
|
||||||
|
* zooms and pans with it ([MapKnob]), and the app grid, which starts on the app opened last.
|
||||||
|
* Returns true for an event it kept. Called on the thread the keys arrive on, not the main one.
|
||||||
|
*/
|
||||||
|
fun interface KnobTaker {
|
||||||
|
fun take(event: KnobEvent): Boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
@Volatile var knobTaker: KnobTaker? = null
|
||||||
|
|
||||||
private var appContext: Context? = null
|
private var appContext: Context? = null
|
||||||
private val main = Handler(Looper.getMainLooper())
|
private val main = Handler(Looper.getMainLooper())
|
||||||
|
|
||||||
fun init(context: Context) { appContext = context.applicationContext }
|
fun init(context: Context) { appContext = context.applicationContext }
|
||||||
|
|
||||||
|
/** The keys that act on the media app. */
|
||||||
|
private val MEDIA_KEYS = setOf(
|
||||||
|
KeyEvent.KEYCODE_MEDIA_PLAY_PAUSE, KeyEvent.KEYCODE_MEDIA_PLAY, KeyEvent.KEYCODE_MEDIA_PAUSE,
|
||||||
|
KeyEvent.KEYCODE_MEDIA_STOP, KeyEvent.KEYCODE_MEDIA_NEXT, KeyEvent.KEYCODE_MEDIA_PREVIOUS,
|
||||||
|
)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Every key worth asking the head unit for. The binding request lists what the head unit
|
* Every key worth asking the head unit for. The binding request lists what the head unit
|
||||||
* offered; when it offers nothing (some units leave the list empty and send keys anyway),
|
* offered; when it offers nothing (some units leave the list empty and send keys anyway),
|
||||||
@@ -63,22 +87,30 @@ object CarKeys {
|
|||||||
*/
|
*/
|
||||||
fun onKey(keyCode: Int, down: Boolean, longPress: Boolean): Boolean {
|
fun onKey(keyCode: Int, down: Boolean, longPress: Boolean): Boolean {
|
||||||
if (keyCode in FOCUS_KEYS) {
|
if (keyCode in FOCUS_KEYS) {
|
||||||
|
if (knobTaker?.take(KnobEvent.Key(keyCode, down)) == true) return true
|
||||||
_focusMoves.tryEmit(Unit)
|
_focusMoves.tryEmit(Unit)
|
||||||
focusSink?.invoke(keyCode, down)
|
focusSink?.invoke(keyCode, down)
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
if (!down) return keyCode in WANTED
|
if (!down) return keyCode in WANTED
|
||||||
val media = CarServices.media
|
// Null until the car shows Gearslip's screen. A 2020 Civic sends Play as soon as it hears
|
||||||
|
// the phone's Bluetooth address, while still on its own screen, and reaching for media
|
||||||
|
// then ended the session (issue #43).
|
||||||
|
val media = CarServices.mediaOrNull()
|
||||||
|
if (media == null && keyCode in MEDIA_KEYS) {
|
||||||
|
GearslipLog.i("keys: $keyCode before the car shows Gearslip - no media app to send it to yet")
|
||||||
|
return true
|
||||||
|
}
|
||||||
when (keyCode) {
|
when (keyCode) {
|
||||||
KeyEvent.KEYCODE_MEDIA_PLAY_PAUSE -> media.togglePlay()
|
KeyEvent.KEYCODE_MEDIA_PLAY_PAUSE -> media?.togglePlay()
|
||||||
KeyEvent.KEYCODE_MEDIA_PLAY -> if (!media.now.value.playing) media.togglePlay()
|
KeyEvent.KEYCODE_MEDIA_PLAY -> if (media != null && !media.now.value.playing) media.togglePlay()
|
||||||
KeyEvent.KEYCODE_MEDIA_PAUSE, KeyEvent.KEYCODE_MEDIA_STOP -> if (media.now.value.playing) media.togglePlay()
|
KeyEvent.KEYCODE_MEDIA_PAUSE, KeyEvent.KEYCODE_MEDIA_STOP -> if (media != null && media.now.value.playing) media.togglePlay()
|
||||||
KeyEvent.KEYCODE_MEDIA_NEXT -> media.next()
|
KeyEvent.KEYCODE_MEDIA_NEXT -> media?.next()
|
||||||
KeyEvent.KEYCODE_MEDIA_PREVIOUS -> media.previous()
|
KeyEvent.KEYCODE_MEDIA_PREVIOUS -> media?.previous()
|
||||||
KeyEvent.KEYCODE_SEARCH, KeyEvent.KEYCODE_VOICE_ASSIST -> voice()
|
KeyEvent.KEYCODE_SEARCH, KeyEvent.KEYCODE_VOICE_ASSIST -> voice()
|
||||||
KeyEvent.KEYCODE_CALL -> call()
|
KeyEvent.KEYCODE_CALL -> call()
|
||||||
KeyEvent.KEYCODE_ENDCALL -> endCall()
|
KeyEvent.KEYCODE_ENDCALL -> endCall()
|
||||||
KeyEvent.KEYCODE_BACK -> screen(ScreenKey.BACK)
|
KeyEvent.KEYCODE_BACK -> if (knobTaker?.take(KnobEvent.Back) != true) screen(ScreenKey.BACK)
|
||||||
KeyEvent.KEYCODE_HOME -> screen(ScreenKey.HOME)
|
KeyEvent.KEYCODE_HOME -> screen(ScreenKey.HOME)
|
||||||
KEYCODE_NAVIGATION -> screen(ScreenKey.MAP)
|
KEYCODE_NAVIGATION -> screen(ScreenKey.MAP)
|
||||||
KEYCODE_MEDIA -> screen(ScreenKey.MEDIA)
|
KEYCODE_MEDIA -> screen(ScreenKey.MEDIA)
|
||||||
@@ -92,6 +124,7 @@ object CarKeys {
|
|||||||
/** A turn of the rotary knob: each click moves focus one step, like Tab or Shift+Tab. */
|
/** A turn of the rotary knob: each click moves focus one step, like Tab or Shift+Tab. */
|
||||||
fun onRotate(delta: Int) {
|
fun onRotate(delta: Int) {
|
||||||
val sink = focusSink ?: return
|
val sink = focusSink ?: return
|
||||||
|
if (knobTaker?.take(KnobEvent.Turn(delta)) == true) return
|
||||||
_focusMoves.tryEmit(Unit)
|
_focusMoves.tryEmit(Unit)
|
||||||
val code = if (delta > 0) KeyEvent.KEYCODE_TAB else KEYCODE_SHIFT_TAB
|
val code = if (delta > 0) KeyEvent.KEYCODE_TAB else KEYCODE_SHIFT_TAB
|
||||||
repeat(kotlin.math.abs(delta).coerceAtMost(MAX_ROTARY_STEPS)) {
|
repeat(kotlin.math.abs(delta).coerceAtMost(MAX_ROTARY_STEPS)) {
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
package app.seb3thehacker.gearslip.car
|
package app.seb3thehacker.gearslip.car
|
||||||
|
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import app.seb3thehacker.gearslip.R
|
||||||
import app.seb3thehacker.gearslip.car.theme.*
|
import app.seb3thehacker.gearslip.car.theme.*
|
||||||
import androidx.compose.foundation.Image
|
import androidx.compose.foundation.Image
|
||||||
import androidx.compose.foundation.ExperimentalFoundationApi
|
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||||
@@ -14,6 +16,16 @@ import androidx.compose.foundation.layout.size
|
|||||||
import androidx.compose.foundation.lazy.grid.GridCells
|
import androidx.compose.foundation.lazy.grid.GridCells
|
||||||
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
|
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
|
||||||
import androidx.compose.foundation.lazy.grid.items
|
import androidx.compose.foundation.lazy.grid.items
|
||||||
|
import androidx.compose.foundation.lazy.grid.itemsIndexed
|
||||||
|
import androidx.compose.foundation.lazy.grid.rememberLazyGridState
|
||||||
|
import androidx.compose.runtime.DisposableEffect
|
||||||
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
|
import androidx.compose.runtime.rememberUpdatedState
|
||||||
|
import androidx.compose.runtime.withFrameNanos
|
||||||
|
import androidx.compose.ui.focus.FocusRequester
|
||||||
|
import androidx.compose.ui.focus.focusRequester
|
||||||
|
import app.seb3thehacker.gearslip.AppSettings
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.filled.Info
|
import androidx.compose.material.icons.filled.Info
|
||||||
import androidx.compose.material.icons.filled.KeyboardArrowDown
|
import androidx.compose.material.icons.filled.KeyboardArrowDown
|
||||||
@@ -73,6 +85,8 @@ private class Tile(
|
|||||||
/** Known not to work; drawn with a red X instead of being left off the launcher. */
|
/** Known not to work; drawn with a red X instead of being left off the launcher. */
|
||||||
val broken: Boolean = false,
|
val broken: Boolean = false,
|
||||||
val onLongClick: (() -> Unit)? = null,
|
val onLongClick: (() -> Unit)? = null,
|
||||||
|
/** The entry's [Entry.componentId], for finding the app opened last. */
|
||||||
|
val id: String? = null,
|
||||||
val onClick: () -> Unit,
|
val onClick: () -> Unit,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -127,7 +141,7 @@ internal object LauncherCache {
|
|||||||
// notifications - rather than a filter per kind that has to know about the others.
|
// 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 tiles = (carApps.keys + mediaApps.keys + messagingApps.keys).flatMap { pkg ->
|
||||||
val icon by lazy { iconOf(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) {
|
when (tile) {
|
||||||
is AppTile.Screen -> Entry(tile.label, icon, template = tile.app)
|
is AppTile.Screen -> Entry(tile.label, icon, template = tile.app)
|
||||||
is AppTile.Player -> Entry(tile.app.label, icon, media = tile.app)
|
is AppTile.Player -> Entry(tile.app.label, icon, media = tile.app)
|
||||||
@@ -135,6 +149,10 @@ internal object LauncherCache {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
CarSettings.hideOnFirstSight(tiles.mapNotNull { entry ->
|
||||||
|
val pkg = (entry.template?.component ?: entry.media?.component)?.packageName ?: entry.messaging?.packageName
|
||||||
|
entry.componentId?.takeIf { pkg != null && KnownApps.startsHidden(context, pkg, entry.messaging != null) }
|
||||||
|
})
|
||||||
return tiles.sortedBy { it.label.lowercase() }.also { flow.value = it }
|
return tiles.sortedBy { it.label.lowercase() }.also { flow.value = it }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -163,6 +181,7 @@ private fun tilesFor(
|
|||||||
car: TemplateApp?,
|
car: TemplateApp?,
|
||||||
media: app.seb3thehacker.gearslip.media.MediaApp?,
|
media: app.seb3thehacker.gearslip.media.MediaApp?,
|
||||||
messaging: app.seb3thehacker.gearslip.notify.MessagingApp?,
|
messaging: app.seb3thehacker.gearslip.notify.MessagingApp?,
|
||||||
|
browse: String,
|
||||||
): List<AppTile> {
|
): List<AppTile> {
|
||||||
if (car != null && !car.isMediaScreen && car.kind != "messaging") return listOf(AppTile.Screen(car, car.label))
|
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()
|
val pkg = car?.component?.packageName ?: media?.component?.packageName ?: messaging?.packageName ?: return emptyList()
|
||||||
@@ -170,7 +189,7 @@ private fun tilesFor(
|
|||||||
val screenRefused = KnownApps.screenBroken(pkg) && KnownApps.playerWorks(pkg)
|
val screenRefused = KnownApps.screenBroken(pkg) && KnownApps.playerWorks(pkg)
|
||||||
val messengerScreen = messaging != null && (car?.kind == "messaging" || car?.kind == "app")
|
val messengerScreen = messaging != null && (car?.kind == "messaging" || car?.kind == "app")
|
||||||
if (car != null && !messengerScreen && !(media != null && screenRefused)) {
|
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))
|
if (media != null) add(AppTile.Player(media))
|
||||||
else if (messaging != null) add(AppTile.Messages(messaging))
|
else if (messaging != null) add(AppTile.Messages(messaging))
|
||||||
@@ -182,10 +201,13 @@ private fun tilesFor(
|
|||||||
internal fun rememberAllEntries(): List<Entry> {
|
internal fun rememberAllEntries(): List<Entry> {
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
val installed by LauncherCache.state.collectAsState()
|
val installed by LauncherCache.state.collectAsState()
|
||||||
|
val launcher by LauncherMode.active.collectAsState()
|
||||||
LaunchedEffect(context) {
|
LaunchedEffect(context) {
|
||||||
if (LauncherCache.entries == null) withContext(Dispatchers.IO) { LauncherCache.load(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. */
|
/** The launcher's groups, in the order they're laid out. */
|
||||||
@@ -221,9 +243,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
|
* 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.
|
* 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.
|
* 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> {
|
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 }
|
val shown = all.filter { (it.builtIn !== BuiltInApps.VehicleData || showVehicleData) && it.componentId !in hidden }
|
||||||
val mediaPackages = shown.mapNotNull { it.media?.component?.packageName }.toSet()
|
val mediaPackages = shown.mapNotNull { it.media?.component?.packageName }.toSet()
|
||||||
val grouped = shown.sortedWith(
|
val grouped = shown.sortedWith(
|
||||||
compareBy<Entry>({ groupOf(it, mediaPackages) }, { it.builtIn === BuiltInApps.Settings }, { it.label.lowercase() }),
|
compareBy<Entry>({ groupOf(it, mediaPackages) }, { it.builtIn === BuiltInApps.Settings }, { it.label.lowercase() }),
|
||||||
@@ -237,7 +260,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. */
|
/** An app by the id a pin or the running list knows it by, built-in or installed. */
|
||||||
internal fun findEntry(id: String): Entry? =
|
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. */
|
/** 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) }
|
fun warmLauncherApps(context: Context) { LauncherCache.load(context, force = true) }
|
||||||
@@ -280,23 +303,56 @@ fun CarLauncher() {
|
|||||||
val pkg = entry.packageName?.takeIf { entry.messaging == null }
|
val pkg = entry.packageName?.takeIf { entry.messaging == null }
|
||||||
val id = entry.componentId
|
val id = entry.componentId
|
||||||
return Tile(
|
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))),
|
broken = pkg != null && (KnownApps.isBroken(context, pkg) || (entry.template != null && KnownApps.screenBroken(pkg))),
|
||||||
onLongClick = id?.let { { navigator.showAppMenu(it) } },
|
onLongClick = id?.let { { navigator.showAppMenu(it) } },
|
||||||
) { openApp(entry, id?.let { running[it] }, navigator, frame) }
|
id = id,
|
||||||
|
) {
|
||||||
|
id?.let { AppSettings.putString(context, KEY_LAST_APP, it) }
|
||||||
|
openApp(entry, id?.let { running[it] }, navigator, frame)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
val appOrder by CarSettings.appOrder.collectAsState()
|
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)
|
||||||
|
|
||||||
|
// With the knob, the first move on this screen lands on the app opened last rather than on
|
||||||
|
// the first button anywhere on screen (issue #74). After that the knob moves as usual, out to
|
||||||
|
// the nav bar and back; nudging up or down already jumps a row.
|
||||||
|
val gridState = rememberLazyGridState()
|
||||||
|
val scope = rememberCoroutineScope()
|
||||||
|
val lastFocus = remember { FocusRequester() }
|
||||||
|
val lastId = remember { AppSettings.getString(context, KEY_LAST_APP, "") }
|
||||||
|
val lastIndex = tiles.indexOfFirst { it.id == lastId }.coerceAtLeast(0)
|
||||||
|
val focusIndex by rememberUpdatedState(lastIndex)
|
||||||
|
DisposableEffect(Unit) {
|
||||||
|
var used = false
|
||||||
|
val taker = CarKeys.KnobTaker { event ->
|
||||||
|
if (used || event == CarKeys.KnobEvent.Back || (event is CarKeys.KnobEvent.Key && !event.down)) {
|
||||||
|
return@KnobTaker false
|
||||||
|
}
|
||||||
|
used = true
|
||||||
|
scope.launch {
|
||||||
|
gridState.scrollToItem(focusIndex)
|
||||||
|
withFrameNanos { }
|
||||||
|
runCatching { lastFocus.requestFocus() }
|
||||||
|
}
|
||||||
|
true
|
||||||
|
}
|
||||||
|
CarKeys.knobTaker = taker
|
||||||
|
onDispose { if (CarKeys.knobTaker === taker) CarKeys.knobTaker = null }
|
||||||
|
}
|
||||||
|
|
||||||
LazyVerticalGrid(
|
LazyVerticalGrid(
|
||||||
columns = GridCells.Adaptive(minSize = 108.dp),
|
columns = GridCells.Adaptive(minSize = 108.dp),
|
||||||
|
state = gridState,
|
||||||
modifier = Modifier.fillMaxSize(),
|
modifier = Modifier.fillMaxSize(),
|
||||||
contentPadding = PaddingValues(20.dp),
|
contentPadding = PaddingValues(20.dp),
|
||||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||||
verticalArrangement = Arrangement.spacedBy(12.dp),
|
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||||
) {
|
) {
|
||||||
items(tiles) { AppTile(it) }
|
itemsIndexed(tiles) { i, tile -> AppTile(tile, if (i == lastIndex) Modifier.focusRequester(lastFocus) else Modifier) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -307,20 +363,20 @@ private fun BrokenBadge(size: androidx.compose.ui.unit.Dp, modifier: Modifier =
|
|||||||
modifier.size(size).background(Color(0xFFD32F2F), CircleShape),
|
modifier.size(size).background(Color(0xFFD32F2F), CircleShape),
|
||||||
contentAlignment = Alignment.Center,
|
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))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@OptIn(ExperimentalFoundationApi::class)
|
@OptIn(ExperimentalFoundationApi::class)
|
||||||
@Composable
|
@Composable
|
||||||
private fun AppTile(tile: Tile) {
|
private fun AppTile(tile: Tile, modifier: Modifier = Modifier) {
|
||||||
val shape = MaterialTheme.shapes.extraLarge
|
val shape = MaterialTheme.shapes.extraLarge
|
||||||
// A long press opens the app's menu (pin, close) - the same gesture everywhere apps are
|
// A long press opens the app's menu (pin, close) - the same gesture everywhere apps are
|
||||||
// shown, here and on the nav bar. GsIconBox marks it in the theme's style.
|
// shown, here and on the nav bar. GsIconBox marks it in the theme's style.
|
||||||
GsIconBox(
|
GsIconBox(
|
||||||
onClick = tile.onClick,
|
onClick = tile.onClick,
|
||||||
onLongClick = tile.onLongClick,
|
onLongClick = tile.onLongClick,
|
||||||
modifier = Modifier.height(120.dp),
|
modifier = modifier.height(120.dp),
|
||||||
colors = GsColors(MaterialTheme.colorScheme.surfaceVariant, MaterialTheme.colorScheme.onSurfaceVariant),
|
colors = GsColors(MaterialTheme.colorScheme.surfaceVariant, MaterialTheme.colorScheme.onSurfaceVariant),
|
||||||
shape = shape,
|
shape = shape,
|
||||||
) {
|
) {
|
||||||
@@ -369,3 +425,6 @@ private fun TileLabel(tile: Tile, maxLines: Int = 2, modifier: Modifier = Modifi
|
|||||||
overflow = TextOverflow.Ellipsis,
|
overflow = TextOverflow.Ellipsis,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** The app opened from the launcher last, where the knob starts on the Apps screen. */
|
||||||
|
private const val KEY_LAST_APP = "car_last_app"
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
package app.seb3thehacker.gearslip.car
|
package app.seb3thehacker.gearslip.car
|
||||||
|
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.material.icons.filled.Call
|
import androidx.compose.material.icons.filled.Call
|
||||||
import app.seb3thehacker.gearslip.call.CarCalls
|
import app.seb3thehacker.gearslip.call.CarCalls
|
||||||
import app.seb3thehacker.gearslip.call.CallUiState
|
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.res.vectorResource
|
||||||
import androidx.compose.ui.text.style.TextOverflow
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
import app.seb3thehacker.gearslip.R
|
import app.seb3thehacker.gearslip.R
|
||||||
|
import app.seb3thehacker.gearslip.notify.PhoneNavigation
|
||||||
import app.seb3thehacker.gearslip.car.theme.GsColors
|
import app.seb3thehacker.gearslip.car.theme.GsColors
|
||||||
import app.seb3thehacker.gearslip.car.theme.withTintedSurfaces
|
import app.seb3thehacker.gearslip.car.theme.withTintedSurfaces
|
||||||
import app.seb3thehacker.gearslip.car.theme.GsIconBox
|
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),
|
Modifier.fillMaxWidth().height(40.dp).padding(start = 2.dp, end = 10.dp),
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
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))
|
Spacer(Modifier.width(2.dp))
|
||||||
trail.forEachIndexed { index, crumb ->
|
trail.forEachIndexed { index, crumb ->
|
||||||
val isLast = index == trail.lastIndex
|
val isLast = index == trail.lastIndex
|
||||||
Text(
|
Text(
|
||||||
crumb.label,
|
crumb.shown(),
|
||||||
style = MaterialTheme.typography.labelLarge,
|
style = MaterialTheme.typography.labelLarge,
|
||||||
fontWeight = if (isLast) FontWeight.SemiBold else FontWeight.Normal,
|
fontWeight = if (isLast) FontWeight.SemiBold else FontWeight.Normal,
|
||||||
color = if (isLast) MaterialTheme.colorScheme.onSurface else MaterialTheme.colorScheme.onSurfaceVariant,
|
color = if (isLast) MaterialTheme.colorScheme.onSurface else MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
@@ -164,7 +166,12 @@ internal fun CarNavBar(navigator: CarNavigator) {
|
|||||||
val frame by CarEnvironment.frame.collectAsState()
|
val frame by CarEnvironment.frame.collectAsState()
|
||||||
val running = rememberRunningApps()
|
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
|
||||||
|
val navMic by CarSettings.navMic.collectAsState()
|
||||||
|
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)) {
|
BoxWithConstraints(Modifier.fillMaxWidth().height(NAV_BAR_HEIGHT).padding(horizontal = 10.dp)) {
|
||||||
Row(Modifier.fillMaxSize(), verticalAlignment = Alignment.CenterVertically) {
|
Row(Modifier.fillMaxSize(), verticalAlignment = Alignment.CenterVertically) {
|
||||||
// Home and Apps are two different places now, not a toggle on one button - the
|
// Home and Apps are two different places now, not a toggle on one button - the
|
||||||
@@ -174,8 +181,10 @@ internal fun CarNavBar(navigator: CarNavigator) {
|
|||||||
NavHomeButton(navigator, entries, running)
|
NavHomeButton(navigator, entries, running)
|
||||||
Spacer(Modifier.width(6.dp))
|
Spacer(Modifier.width(6.dp))
|
||||||
NavItem(MediaIcons.Apps, navigator.current == CarScreen.Apps) { navigator.apps() }
|
NavItem(MediaIcons.Apps, navigator.current == CarScreen.Apps) { navigator.apps() }
|
||||||
Spacer(Modifier.width(6.dp))
|
if (navMic) {
|
||||||
NavAssistantIcon()
|
Spacer(Modifier.width(6.dp))
|
||||||
|
NavAssistantIcon()
|
||||||
|
}
|
||||||
|
|
||||||
// Pinned shortcuts, then whatever else is open right now. A pinned app that's also
|
// Pinned shortcuts, then whatever else is open right now. A pinned app that's also
|
||||||
// open stays in its pinned spot, marked with a dot, instead of showing up twice.
|
// open stays in its pinned spot, marked with a dot, instead of showing up twice.
|
||||||
@@ -192,12 +201,13 @@ internal fun CarNavBar(navigator: CarNavigator) {
|
|||||||
// Next turn first, off the map only; the player shares the space when it fits,
|
// 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.
|
// and gives way to the turn when it doesn't.
|
||||||
// A call takes the player's place until it ends.
|
// 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
|
val call = CarCalls.state.collectAsState().value as? CallUiState.Active
|
||||||
BoxWithConstraints(Modifier.weight(1f).padding(horizontal = 16.dp), contentAlignment = Alignment.Center) {
|
BoxWithConstraints(Modifier.weight(1f).padding(horizontal = 16.dp), contentAlignment = Alignment.Center) {
|
||||||
val roomForBoth = maxWidth >= TURN_AND_PLAYER_MIN_WIDTH
|
val roomForBoth = maxWidth >= TURN_AND_PLAYER_MIN_WIDTH
|
||||||
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(10.dp)) {
|
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) {
|
if (call != null) {
|
||||||
NavCallPill(call, Modifier.weight(1f, fill = false))
|
NavCallPill(call, Modifier.weight(1f, fill = false))
|
||||||
} else if (turn == null || roomForBoth) {
|
} else if (turn == null || roomForBoth) {
|
||||||
@@ -280,7 +290,7 @@ private fun NavHomeButton(navigator: CarNavigator, entries: List<Entry>, running
|
|||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
Icon(
|
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),
|
modifier = Modifier.padding(NAV_ITEM_PADDING).size(28.dp),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -341,7 +351,7 @@ private fun NavAppIcon(
|
|||||||
onLongClick = onLongClick,
|
onLongClick = onLongClick,
|
||||||
) {
|
) {
|
||||||
entry.icon?.let { Image(it, entry.label, Modifier.size(30.dp).clip(RoundedCornerShape(8.dp))) }
|
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.
|
// Only running apps get the line along the bottom edge, the usual taskbar mark for it.
|
||||||
if (open) {
|
if (open) {
|
||||||
Box(
|
Box(
|
||||||
@@ -411,8 +421,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. */
|
/** 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
|
private val TURN_AND_PLAYER_MIN_WIDTH = 520.dp
|
||||||
|
|
||||||
/** The next manoeuvre, as the running nav app last described it. */
|
/** 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)
|
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
|
* Read straight off the nav app's own template: a navigation app keeps sending its routing card
|
||||||
@@ -424,10 +434,10 @@ private fun rememberNextTurn(): NextTurn? {
|
|||||||
val template by CarServices.nav.template.collectAsState()
|
val template by CarServices.nav.template.collectAsState()
|
||||||
// Read so a change of units redraws the distance straight away, not at the next update.
|
// Read so a change of units redraws the distance straight away, not at the next update.
|
||||||
CarSettings.units.collectAsState().value
|
CarSettings.units.collectAsState().value
|
||||||
if (status.phase != CarAppConnection.Phase.RUNNING) return null
|
val phone by PhoneNavigation.turn.collectAsState()
|
||||||
val routing = (template as? NavigationTemplate)?.navigationInfo as? RoutingInfo ?: return null
|
val routing = ((template as? NavigationTemplate)?.navigationInfo as? RoutingInfo)
|
||||||
if (routing.isLoading) return null
|
?.takeIf { status.phase == CarAppConnection.Phase.RUNNING }
|
||||||
val step = routing.currentStep ?: return null
|
val step = routing?.takeIf { !it.isLoading }?.currentStep ?: return phone?.let { phoneTurn(it) }
|
||||||
return NextTurn(
|
return NextTurn(
|
||||||
icon = step.maneuver?.icon,
|
icon = step.maneuver?.icon,
|
||||||
distance = routing.currentDistance.display(),
|
distance = routing.currentDistance.display(),
|
||||||
@@ -435,6 +445,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. */
|
/** The next turn in the nav bar: arrow, how far, onto what. Tapping goes back to the map. */
|
||||||
@Composable
|
@Composable
|
||||||
private fun NavTurnPill(turn: NextTurn, onClick: () -> Unit) {
|
private fun NavTurnPill(turn: NextTurn, onClick: () -> Unit) {
|
||||||
@@ -492,7 +511,7 @@ private fun NavCallPill(call: CallUiState.Active, modifier: Modifier = Modifier)
|
|||||||
) {
|
) {
|
||||||
Icon(Icons.Filled.Call, contentDescription = null, modifier = Modifier.size(20.dp))
|
Icon(Icons.Filled.Call, contentDescription = null, modifier = Modifier.size(20.dp))
|
||||||
Text(
|
Text(
|
||||||
call.caller?.label ?: "On a call",
|
call.caller?.label ?: stringResource(R.string.navbar_on_call),
|
||||||
style = MaterialTheme.typography.titleSmall,
|
style = MaterialTheme.typography.titleSmall,
|
||||||
fontWeight = FontWeight.SemiBold,
|
fontWeight = FontWeight.SemiBold,
|
||||||
maxLines = 1,
|
maxLines = 1,
|
||||||
@@ -507,12 +526,12 @@ private fun NavCallPill(call: CallUiState.Active, modifier: Modifier = Modifier)
|
|||||||
Spacer(Modifier.width(2.dp))
|
Spacer(Modifier.width(2.dp))
|
||||||
GsIconButton(
|
GsIconButton(
|
||||||
if (muted) MediaIcons.MicOff else MediaIcons.Mic,
|
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,
|
CarCalls::toggleMute,
|
||||||
tone = if (muted) GsTone.Primary else GsTone.Neutral, size = 36.dp, iconSize = 22.dp,
|
tone = if (muted) GsTone.Primary else GsTone.Neutral, size = 36.dp, iconSize = 22.dp,
|
||||||
)
|
)
|
||||||
GsIconButton(
|
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,
|
colors = GsColors(scheme.error, scheme.onError), size = 36.dp, iconSize = 22.dp,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -520,15 +539,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. */
|
/** "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 =
|
private fun nowPlayingLabel(title: String, artist: String): String =
|
||||||
listOf(title.trim(), artist.trim()).filter { it.isNotEmpty() }.joinToString(" - ")
|
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;
|
* 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
|
* 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
|
* 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
|
@Composable
|
||||||
private fun NavNowPlaying(navigator: CarNavigator, selected: Boolean, modifier: Modifier = Modifier) {
|
private fun NavNowPlaying(navigator: CarNavigator, selected: Boolean, modifier: Modifier = Modifier) {
|
||||||
@@ -604,24 +624,34 @@ private fun NavNowPlaying(navigator: CarNavigator, selected: Boolean, modifier:
|
|||||||
}
|
}
|
||||||
if (!showTitle) {
|
if (!showTitle) {
|
||||||
GsIconButton(
|
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,
|
enabled = now.canDo(PlaybackState.ACTION_SKIP_TO_PREVIOUS), size = 36.dp, iconSize = 22.dp,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
GsIconButton(
|
GsIconButton(
|
||||||
if (now.playing) MediaIcons.Pause else Icons.Filled.PlayArrow,
|
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,
|
media::togglePlay,
|
||||||
tone = GsTone.Primary, size = 36.dp, iconSize = 22.dp,
|
tone = GsTone.Primary, size = 36.dp, iconSize = 22.dp,
|
||||||
)
|
)
|
||||||
if (!showTitle) {
|
if (!showTitle) {
|
||||||
GsIconButton(
|
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,
|
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(
|
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,
|
size = 36.dp, iconSize = 22.dp,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,9 @@
|
|||||||
package app.seb3thehacker.gearslip.car
|
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.getValue
|
||||||
import androidx.compose.runtime.mutableStateListOf
|
import androidx.compose.runtime.mutableStateListOf
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
@@ -41,7 +45,11 @@ sealed interface CarScreen {
|
|||||||
enum class SidePanel { NONE, CONTROLS, LYRICS }
|
enum class SidePanel { NONE, CONTROLS, LYRICS }
|
||||||
|
|
||||||
/** One step on the breadcrumb trail: the screen it shows and the word for it in the trail. */
|
/** 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
|
* 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(
|
Crumb(
|
||||||
if (CarApps.find(startId) != null) CarScreen.App(startId) else CarScreen.Home,
|
if (CarApps.find(startId) != null) CarScreen.App(startId) else CarScreen.Home,
|
||||||
"Home",
|
"Home",
|
||||||
|
R.string.crumb_home,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -69,15 +78,23 @@ class CarNavigator(startId: String) {
|
|||||||
|
|
||||||
/** Pushes a new step, unless it's the same screen already on top - tapping the button for
|
/** 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. */
|
* 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
|
if (stack.last().screen == screen) return
|
||||||
stack.add(Crumb(screen, label))
|
stack.add(Crumb(screen, label, labelRes))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether a back button on this screen leads anywhere the nav bar doesn't: false when the
|
||||||
|
* screen under it is Home or the app grid, which the nav bar's own buttons already reach.
|
||||||
|
* Android Auto shows no back button on an app's first screen for the same reason.
|
||||||
|
*/
|
||||||
|
val backLeadsSomewhere: Boolean
|
||||||
|
get() = stack.size >= 2 && stack[stack.size - 2].screen.let { it != CarScreen.Home && it != CarScreen.Apps }
|
||||||
|
|
||||||
/** The one way back to a clean slate: resets the whole trail to just Home. */
|
/** The one way back to a clean slate: resets the whole trail to just Home. */
|
||||||
fun home() {
|
fun home() {
|
||||||
stack.clear()
|
stack.clear()
|
||||||
stack.add(Crumb(CarScreen.Home, "Home"))
|
stack.add(Crumb(CarScreen.Home, "Home", R.string.crumb_home))
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -90,8 +107,9 @@ class CarNavigator(startId: String) {
|
|||||||
private set
|
private set
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The pill's dock button: moves the player beside the map, going Home first if need be. With
|
* The pill's dock button, an open/close switch for the panel: moves the player beside the map,
|
||||||
* lyrics already there it swaps them for the controls; with the controls there it closes.
|
* 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() {
|
fun togglePlayerDock() {
|
||||||
if (current != CarScreen.Home) {
|
if (current != CarScreen.Home) {
|
||||||
@@ -99,9 +117,11 @@ class CarNavigator(startId: String) {
|
|||||||
home()
|
home()
|
||||||
return
|
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 showSideLyrics() { sidePanel = SidePanel.LYRICS }
|
||||||
|
|
||||||
fun closeSidePanel() { sidePanel = SidePanel.NONE }
|
fun closeSidePanel() { sidePanel = SidePanel.NONE }
|
||||||
@@ -114,17 +134,17 @@ class CarNavigator(startId: String) {
|
|||||||
|
|
||||||
fun dismissAppMenu() { appMenu = null }
|
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 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") }
|
fun vehicleData() { push(CarScreen.VehicleData, "Vehicle data") }
|
||||||
|
|
||||||
@@ -132,10 +152,10 @@ class CarNavigator(startId: String) {
|
|||||||
|
|
||||||
/** The list lives on the dashboard; only a reply gets a screen of its own. */
|
/** The list lives on the dashboard; only a reply gets a screen of its own. */
|
||||||
fun notifications(replyTo: String? = null) {
|
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
|
/** 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. */
|
* 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.ComponentName
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
|
import android.media.session.MediaController
|
||||||
import android.media.session.MediaSessionManager
|
import android.media.session.MediaSessionManager
|
||||||
import android.media.session.PlaybackState
|
import android.media.session.PlaybackState
|
||||||
import app.seb3thehacker.gearslip.audio.CarAudio
|
import app.seb3thehacker.gearslip.audio.CarAudio
|
||||||
@@ -30,8 +31,19 @@ object CarServices {
|
|||||||
|
|
||||||
private var context: Context? = null
|
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 nav: CarAppConnection by lazy { CarAppConnection(requireNotNull(context)) }
|
||||||
val media: CarMedia by lazy { CarMedia(requireNotNull(context)) }
|
val media: CarMedia by lazy {
|
||||||
|
CarMedia(requireNotNull(context)).also { m ->
|
||||||
|
// Remembered only once it connects: an app that refuses (NewPipe without Notification
|
||||||
|
// access) must not replace the one the driver was listening to on the next drive.
|
||||||
|
m.onReady = { app -> CarSettings.setLastMedia(app.component.flattenToString()) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/** [nav], or null before the car screen has set things up. */
|
/** [nav], or null before the car screen has set things up. */
|
||||||
fun navOrNull(): CarAppConnection? = if (context != null) nav else null
|
fun navOrNull(): CarAppConnection? = if (context != null) nav else null
|
||||||
@@ -61,7 +73,7 @@ object CarServices {
|
|||||||
private var autoplayPending = false
|
private var autoplayPending = false
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The app that was playing when the driver opened another one. Opening an app is only a look
|
* The app that was playing, or paused with a track loaded, when the driver opened another one. Opening an app is only a look
|
||||||
* at its library: until something plays there, the music carries on in this one, so leaving
|
* at its library: until something plays there, the music carries on in this one, so leaving
|
||||||
* the new app's screen goes back to it, as Android Auto does.
|
* the new app's screen goes back to it, as Android Auto does.
|
||||||
*/
|
*/
|
||||||
@@ -132,19 +144,20 @@ object CarServices {
|
|||||||
// --- media ---------------------------------------------------------------------------
|
// --- media ---------------------------------------------------------------------------
|
||||||
|
|
||||||
fun openMedia(app: MediaApp, autoplay: Boolean = false) {
|
fun openMedia(app: MediaApp, autoplay: Boolean = false) {
|
||||||
CarSettings.setLastMedia(app.component.flattenToString())
|
|
||||||
val phase = media.phase.value
|
val phase = media.phase.value
|
||||||
val live = phase == CarMedia.Phase.READY || phase == CarMedia.Phase.CONNECTING
|
val live = phase == CarMedia.Phase.READY || phase == CarMedia.Phase.CONNECTING
|
||||||
if (_mediaApp.value?.component == app.component && live) return
|
if (_mediaApp.value?.component == app.component && live) return
|
||||||
|
|
||||||
val previous = _mediaApp.value
|
val previous = _mediaApp.value
|
||||||
stillPlaying = previous?.takeIf { it.component != app.component && media.now.value.playing && !autoplay }
|
// Paused counts too: a track loaded there is still the music the driver is listening to.
|
||||||
|
val loaded = media.now.value.let { it.playing || it.title.isNotEmpty() }
|
||||||
|
stillPlaying = previous?.takeIf { it.component != app.component && loaded && !autoplay }
|
||||||
_mediaApp.value = app
|
_mediaApp.value = app
|
||||||
autoplayPending = autoplay
|
autoplayPending = autoplay
|
||||||
media.connect(app)
|
media.connect(app)
|
||||||
// Not torn down and rebuilt per app: the capture covers whatever the phone plays, so
|
// Not torn down and rebuilt per app: the capture covers whatever the phone plays, so
|
||||||
// switching media apps never costs the driver another consent dialog.
|
// switching media apps never costs the driver another consent dialog.
|
||||||
if (CarSettings.pipeAudio.value) CarAudio.request()
|
if (CarSettings.pipesNow) CarAudio.request()
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -252,25 +265,76 @@ object CarServices {
|
|||||||
* the phone's speaker. Unplugging a USB accessory isn't "audio becoming noisy" the way
|
* 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
|
* 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.
|
* 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") {
|
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 manager = ctx.getSystemService(MediaSessionManager::class.java)
|
||||||
val listener = ComponentName(ctx, GearslipNotificationListener::class.java)
|
val listener = ComponentName(ctx, GearslipNotificationListener::class.java)
|
||||||
val playing = try {
|
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) {
|
} catch (e: SecurityException) {
|
||||||
null
|
null
|
||||||
}
|
}
|
||||||
if (playing == null) {
|
if (playing == null) {
|
||||||
// No notification access: the app Gearslip itself is driving is all it can reach.
|
// No notification access: the app Gearslip itself is driving is all it can reach.
|
||||||
mediaOrNull()?.let { if (it.now.value.playing) it.togglePlay() }
|
mediaOrNull()?.let {
|
||||||
return
|
if (it.now.value.playing) {
|
||||||
|
it.togglePlay()
|
||||||
|
pausedOwnForNow = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return emptyList()
|
||||||
}
|
}
|
||||||
playing.forEach { runCatching { it.transportControls.pause() } }
|
playing.forEach { runCatching { it.transportControls.pause() } }
|
||||||
if (playing.isNotEmpty()) {
|
if (playing.isNotEmpty()) {
|
||||||
app.seb3thehacker.gearslip.GearslipLog.i("media: paused ${playing.joinToString { it.packageName }}: $why")
|
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. */
|
/** The car went away: nothing should keep running for a screen that no longer exists. */
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
package app.seb3thehacker.gearslip.car
|
package app.seb3thehacker.gearslip.car
|
||||||
|
|
||||||
|
import app.seb3thehacker.gearslip.car.theme.DarkStyle
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import app.seb3thehacker.gearslip.AppSettings
|
import app.seb3thehacker.gearslip.AppSettings
|
||||||
import app.seb3thehacker.gearslip.audio.CarAudio
|
import app.seb3thehacker.gearslip.audio.CarAudio
|
||||||
@@ -52,10 +53,13 @@ enum class NavBarMode { SHOW, HIDE_NAVIGATING, HIDE }
|
|||||||
object CarSettings {
|
object CarSettings {
|
||||||
|
|
||||||
private const val KEY_SCALE = "car_scale"
|
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_OPEN = "car_open_on_connect"
|
||||||
private const val KEY_NIGHT = "car_night_mode"
|
private const val KEY_NIGHT = "car_night_mode"
|
||||||
private const val KEY_PIPE_AUDIO = "car_pipe_audio"
|
private const val KEY_PIPE_AUDIO = "car_pipe_audio"
|
||||||
|
private const val KEY_AUDIO_CARS = "car_audio_through_gearslip_cars"
|
||||||
private const val KEY_SPEAK_DIRECTIONS = "car_speak_directions"
|
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_BLUETOOTH_WARNING = "car_bluetooth_warning"
|
||||||
private const val KEY_LOCATION_WARNING = "car_location_warning"
|
private const val KEY_LOCATION_WARNING = "car_location_warning"
|
||||||
private const val KEY_AUDIO_CHOICE_ASKED = "car_audio_choice_asked"
|
private const val KEY_AUDIO_CHOICE_ASKED = "car_audio_choice_asked"
|
||||||
@@ -64,14 +68,19 @@ private const val KEY_LOCATION_WARNING = "car_location_warning"
|
|||||||
private const val KEY_AUTOPLAY = "car_autoplay"
|
private const val KEY_AUTOPLAY = "car_autoplay"
|
||||||
private const val KEY_PLAYER_TITLE = "car_player_title"
|
private const val KEY_PLAYER_TITLE = "car_player_title"
|
||||||
private const val KEY_PLAYER_LEFT = "car_player_left"
|
private const val KEY_PLAYER_LEFT = "car_player_left"
|
||||||
|
private const val KEY_NAV_MIC = "car_nav_mic"
|
||||||
|
private const val KEY_DEFAULT_HIDDEN = "car_default_hidden"
|
||||||
private const val KEY_THEME = "car_app_theme"
|
private const val KEY_THEME = "car_app_theme"
|
||||||
private const val KEY_UI_THEME = "car_ui_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_PINNED = "car_pinned_apps"
|
||||||
private const val KEY_APP_ORDER = "car_app_order"
|
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_EXPERIMENTAL_FEATURES = "car_experimental_features"
|
||||||
private const val KEY_SYSTEM_SPEECH = "car_system_speech"
|
private const val KEY_SYSTEM_SPEECH = "car_system_speech"
|
||||||
private const val KEY_CAR_SENSORS = "car_sensors_enabled"
|
private const val KEY_CAR_SENSORS = "car_sensors_enabled"
|
||||||
private const val KEY_ADAPTIVE_VIDEO = "adaptive_video_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_DEBUG_BADGE = "debug_badge"
|
||||||
private const val KEY_SEEN_SAFETY_WARNING = "car_seen_safety_warning"
|
private const val KEY_SEEN_SAFETY_WARNING = "car_seen_safety_warning"
|
||||||
private const val KEY_UNITS = "car_units"
|
private const val KEY_UNITS = "car_units"
|
||||||
@@ -93,10 +102,12 @@ private const val KEY_LOCATION_WARNING = "car_location_warning"
|
|||||||
private lateinit var app: Context
|
private lateinit var app: Context
|
||||||
|
|
||||||
private val scaleFlow = MutableStateFlow(1f)
|
private val scaleFlow = MutableStateFlow(1f)
|
||||||
|
private val carDensityFlow = MutableStateFlow(DENSITY_FROM_CAR)
|
||||||
private val openFlow = MutableStateFlow(HOME)
|
private val openFlow = MutableStateFlow(HOME)
|
||||||
private val nightFlow = MutableStateFlow(NightMode.AUTO)
|
private val nightFlow = MutableStateFlow(NightMode.AUTO)
|
||||||
private val pipeAudioFlow = MutableStateFlow(true)
|
private val pipeAudioFlow = MutableStateFlow(true)
|
||||||
private val speakDirectionsFlow = MutableStateFlow(true)
|
private val speakDirectionsFlow = MutableStateFlow(true)
|
||||||
|
private val directionsOverMusicFlow = MutableStateFlow(false)
|
||||||
private val bluetoothWarningFlow = MutableStateFlow(true)
|
private val bluetoothWarningFlow = MutableStateFlow(true)
|
||||||
private val locationWarningFlow = MutableStateFlow(true)
|
private val locationWarningFlow = MutableStateFlow(true)
|
||||||
private val lastNavFlow = MutableStateFlow<String?>(null)
|
private val lastNavFlow = MutableStateFlow<String?>(null)
|
||||||
@@ -104,18 +115,25 @@ private const val KEY_LOCATION_WARNING = "car_location_warning"
|
|||||||
private val autoplayFlow = MutableStateFlow(false)
|
private val autoplayFlow = MutableStateFlow(false)
|
||||||
private val playerTitleFlow = MutableStateFlow(false)
|
private val playerTitleFlow = MutableStateFlow(false)
|
||||||
private val playerLeftFlow = MutableStateFlow(false)
|
private val playerLeftFlow = MutableStateFlow(false)
|
||||||
|
private val navMicFlow = MutableStateFlow(true)
|
||||||
private val themeFlow = MutableStateFlow(AppTheme.PHONE)
|
private val themeFlow = MutableStateFlow(AppTheme.PHONE)
|
||||||
val appTheme: StateFlow<AppTheme> = themeFlow
|
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)
|
private val uiThemeFlow = MutableStateFlow(GsThemes.default.id)
|
||||||
|
|
||||||
/** Id of the [GsTheme] controls are drawn in; an unknown id falls back to the default. */
|
/** Id of the [GsTheme] controls are drawn in; an unknown id falls back to the default. */
|
||||||
val uiTheme: StateFlow<String> = uiThemeFlow
|
val uiTheme: StateFlow<String> = uiThemeFlow
|
||||||
private val pinnedFlow = MutableStateFlow<Set<String>>(emptySet())
|
private val pinnedFlow = MutableStateFlow<Set<String>>(emptySet())
|
||||||
private val appOrderFlow = MutableStateFlow<List<String>>(emptyList())
|
private val appOrderFlow = MutableStateFlow<List<String>>(emptyList())
|
||||||
|
private val hiddenAppsFlow = MutableStateFlow<Set<String>>(emptySet())
|
||||||
private val experimentalFeaturesFlow = MutableStateFlow(false)
|
private val experimentalFeaturesFlow = MutableStateFlow(false)
|
||||||
private val systemSpeechFlow = MutableStateFlow(false)
|
private val systemSpeechFlow = MutableStateFlow(false)
|
||||||
private val carSensorsFlow = MutableStateFlow(false)
|
private val carSensorsFlow = MutableStateFlow(false)
|
||||||
private val adaptiveVideoFlow = MutableStateFlow(false)
|
private val adaptiveVideoFlow = MutableStateFlow(false)
|
||||||
|
private val clusterVideoFlow = MutableStateFlow(false)
|
||||||
private val debugBadgeFlow = MutableStateFlow(true)
|
private val debugBadgeFlow = MutableStateFlow(true)
|
||||||
private val seenSafetyWarningFlow = MutableStateFlow(false)
|
private val seenSafetyWarningFlow = MutableStateFlow(false)
|
||||||
private val unitsFlow = MutableStateFlow(Units.AUTO)
|
private val unitsFlow = MutableStateFlow(Units.AUTO)
|
||||||
@@ -208,6 +226,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. */
|
/** The launcher's order once the driver has moved a tile; empty keeps the grouped default. */
|
||||||
val appOrder: StateFlow<List<String>> = appOrderFlow
|
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. */
|
/** Flattened component names of the map and media apps used last, brought back on connect. */
|
||||||
val lastNav: StateFlow<String?> = lastNavFlow
|
val lastNav: StateFlow<String?> = lastNavFlow
|
||||||
val lastMedia: StateFlow<String?> = lastMediaFlow
|
val lastMedia: StateFlow<String?> = lastMediaFlow
|
||||||
@@ -224,7 +245,24 @@ private const val KEY_LOCATION_WARNING = "car_location_warning"
|
|||||||
/** The home screen's player sits left of the map instead of right of it. */
|
/** The home screen's player sits left of the map instead of right of it. */
|
||||||
val playerLeft: StateFlow<Boolean> = playerLeftFlow
|
val playerLeft: StateFlow<Boolean> = playerLeftFlow
|
||||||
|
|
||||||
|
/** The assistant's mic button in the nav bar. Off hides only the button: the car's voice key still works. */
|
||||||
|
val navMic: StateFlow<Boolean> = navMicFlow
|
||||||
|
|
||||||
|
/** Lyrics from BetterLyrics and KuGou as well as LRCLIB; off sends song details to LRCLIB only. */
|
||||||
|
|
||||||
val scale: StateFlow<Float> = scaleFlow
|
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)
|
||||||
|
/** The range a typed-in density may take (issue #82): small enough for more on screen, big enough to read. */
|
||||||
|
val CUSTOM_DENSITIES = 60..480
|
||||||
|
|
||||||
|
/** 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 openOnConnect: StateFlow<String> = openFlow
|
||||||
val nightMode: StateFlow<NightMode> = nightFlow
|
val nightMode: StateFlow<NightMode> = nightFlow
|
||||||
|
|
||||||
@@ -237,9 +275,47 @@ private const val KEY_LOCATION_WARNING = "car_location_warning"
|
|||||||
*/
|
*/
|
||||||
val pipeAudio: StateFlow<Boolean> = pipeAudioFlow
|
val pipeAudio: StateFlow<Boolean> = pipeAudioFlow
|
||||||
|
|
||||||
|
private val thisCarPipesFlow = MutableStateFlow(false)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The car connected now switches Bluetooth music off while Android Auto runs, so its music
|
||||||
|
* goes through Gearslip whatever [pipeAudio] says. Learned by [BluetoothAudioCheck] and
|
||||||
|
* remembered per car: a Skoda, a VW, an Audi and a Bolt all did this.
|
||||||
|
*/
|
||||||
|
val thisCarPipes: StateFlow<Boolean> = thisCarPipesFlow
|
||||||
|
|
||||||
|
/** Whether music goes through Gearslip in this car: the driver's choice, or what the car needs. */
|
||||||
|
val pipesNow: Boolean get() = pipeAudioFlow.value || thisCarPipesFlow.value
|
||||||
|
|
||||||
|
/** A car connected: whether it's one that needs music through Gearslip. Null for none known. */
|
||||||
|
fun carConnected(carAddress: String?) {
|
||||||
|
thisCarPipesFlow.value = carAddress != null && carKey(carAddress) in audioCars()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun carDisconnected() {
|
||||||
|
thisCarPipesFlow.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
/** This car turned Bluetooth music off: from now on its music goes through Gearslip. */
|
||||||
|
fun rememberAudioCar(carAddress: String) {
|
||||||
|
AppSettings.putString(app, KEY_AUDIO_CARS, (audioCars() + carKey(carAddress)).distinct().joinToString(","))
|
||||||
|
thisCarPipesFlow.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun audioCars(): List<String> =
|
||||||
|
AppSettings.getString(app, KEY_AUDIO_CARS, "").split(",").filter { it.isNotEmpty() }
|
||||||
|
|
||||||
|
/** Settings keep a short hash, not the car's address. */
|
||||||
|
private fun carKey(address: String): String =
|
||||||
|
java.security.MessageDigest.getInstance("SHA-256").digest(address.uppercase().toByteArray())
|
||||||
|
.take(8).joinToString("") { "%02x".format(it) }
|
||||||
|
|
||||||
/** Gearslip reads the map app's turns aloud on the car's directions channel; see DirectionsVoice. */
|
/** Gearslip reads the map app's turns aloud on the car's directions channel; see DirectionsVoice. */
|
||||||
val speakDirections: StateFlow<Boolean> = speakDirectionsFlow
|
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. */
|
/** With Bluetooth audio, warn when the phone isn't connected to the car; see BluetoothAudioCheck. */
|
||||||
val bluetoothWarning: StateFlow<Boolean> = bluetoothWarningFlow
|
val bluetoothWarning: StateFlow<Boolean> = bluetoothWarningFlow
|
||||||
|
|
||||||
@@ -282,6 +358,13 @@ private const val KEY_LOCATION_WARNING = "car_location_warning"
|
|||||||
*/
|
*/
|
||||||
val adaptiveVideoEnabled: StateFlow<Boolean> = adaptiveVideoFlow
|
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. */
|
/** Debug builds only: the car screen's DEBUG BUILD badge, which screenshots for the README leave out. */
|
||||||
val debugBadge: StateFlow<Boolean> = debugBadgeFlow
|
val debugBadge: StateFlow<Boolean> = debugBadgeFlow
|
||||||
|
|
||||||
@@ -306,20 +389,26 @@ private const val KEY_LOCATION_WARNING = "car_location_warning"
|
|||||||
fun init(context: Context) {
|
fun init(context: Context) {
|
||||||
app = context.applicationContext
|
app = context.applicationContext
|
||||||
scaleFlow.value = AppSettings.getString(app, KEY_SCALE, "1.0").toFloatOrNull() ?: 1f
|
scaleFlow.value = AppSettings.getString(app, KEY_SCALE, "1.0").toFloatOrNull() ?: 1f
|
||||||
|
carDensityFlow.value = AppSettings.getString(app, KEY_DENSITY, "").toIntOrNull()?.takeIf { it in DENSITIES || it in CUSTOM_DENSITIES } ?: DENSITY_FROM_CAR
|
||||||
openFlow.value = AppSettings.getString(app, KEY_OPEN, HOME)
|
openFlow.value = AppSettings.getString(app, KEY_OPEN, HOME)
|
||||||
lastNavFlow.value = AppSettings.getString(app, KEY_LAST_NAV, "").ifEmpty { null }
|
lastNavFlow.value = AppSettings.getString(app, KEY_LAST_NAV, "").ifEmpty { null }
|
||||||
lastMediaFlow.value = AppSettings.getString(app, KEY_LAST_MEDIA, "").ifEmpty { null }
|
lastMediaFlow.value = AppSettings.getString(app, KEY_LAST_MEDIA, "").ifEmpty { null }
|
||||||
themeFlow.value = runCatching {
|
themeFlow.value = runCatching {
|
||||||
AppTheme.valueOf(AppSettings.getString(app, KEY_THEME, AppTheme.PHONE.name))
|
AppTheme.valueOf(AppSettings.getString(app, KEY_THEME, AppTheme.PHONE.name))
|
||||||
}.getOrDefault(AppTheme.PHONE)
|
}.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)
|
uiThemeFlow.value = AppSettings.getString(app, KEY_UI_THEME, GsThemes.default.id)
|
||||||
autoplayFlow.value = AppSettings.getString(app, KEY_AUTOPLAY, "false") == "true"
|
autoplayFlow.value = AppSettings.getString(app, KEY_AUTOPLAY, "false") == "true"
|
||||||
playerTitleFlow.value = AppSettings.getString(app, KEY_PLAYER_TITLE, "false") == "true"
|
playerTitleFlow.value = AppSettings.getString(app, KEY_PLAYER_TITLE, "false") == "true"
|
||||||
playerLeftFlow.value = AppSettings.getString(app, KEY_PLAYER_LEFT, "false") == "true"
|
playerLeftFlow.value = AppSettings.getString(app, KEY_PLAYER_LEFT, "false") == "true"
|
||||||
|
navMicFlow.value = AppSettings.getString(app, KEY_NAV_MIC, "true") == "true"
|
||||||
pipeAudioFlow.value = AppSettings.getString(app, KEY_PIPE_AUDIO, "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
|
// Until the driver sets it, on for USB audio and off for Bluetooth, where the map app's
|
||||||
// own voice already reaches the car.
|
// own voice already reaches the car.
|
||||||
speakDirectionsFlow.value = AppSettings.getString(app, KEY_SPEAK_DIRECTIONS, pipeAudioFlow.value.toString()) == "true"
|
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"
|
bluetoothWarningFlow.value = AppSettings.getString(app, KEY_BLUETOOTH_WARNING, "true") == "true"
|
||||||
locationWarningFlow.value = AppSettings.getString(app, KEY_LOCATION_WARNING, "true") == "true"
|
locationWarningFlow.value = AppSettings.getString(app, KEY_LOCATION_WARNING, "true") == "true"
|
||||||
nightFlow.value = runCatching {
|
nightFlow.value = runCatching {
|
||||||
@@ -329,10 +418,13 @@ private const val KEY_LOCATION_WARNING = "car_location_warning"
|
|||||||
.split(",").filter { it.isNotEmpty() }.toSet()
|
.split(",").filter { it.isNotEmpty() }.toSet()
|
||||||
appOrderFlow.value = AppSettings.getString(app, KEY_APP_ORDER, "")
|
appOrderFlow.value = AppSettings.getString(app, KEY_APP_ORDER, "")
|
||||||
.split(",").filter { it.isNotEmpty() }
|
.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"
|
experimentalFeaturesFlow.value = AppSettings.getString(app, KEY_EXPERIMENTAL_FEATURES, "false") == "true"
|
||||||
systemSpeechFlow.value = AppSettings.getString(app, KEY_SYSTEM_SPEECH, "false") == "true"
|
systemSpeechFlow.value = AppSettings.getString(app, KEY_SYSTEM_SPEECH, "false") == "true"
|
||||||
carSensorsFlow.value = AppSettings.getString(app, KEY_CAR_SENSORS, "false") == "true"
|
carSensorsFlow.value = AppSettings.getString(app, KEY_CAR_SENSORS, "false") == "true"
|
||||||
adaptiveVideoFlow.value = AppSettings.getString(app, KEY_ADAPTIVE_VIDEO, "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"
|
debugBadgeFlow.value = AppSettings.getString(app, KEY_DEBUG_BADGE, "true") == "true"
|
||||||
seenSafetyWarningFlow.value = AppSettings.getString(app, KEY_SEEN_SAFETY_WARNING, "false") == "true"
|
seenSafetyWarningFlow.value = AppSettings.getString(app, KEY_SEEN_SAFETY_WARNING, "false") == "true"
|
||||||
unitsFlow.value = runCatching {
|
unitsFlow.value = runCatching {
|
||||||
@@ -358,6 +450,11 @@ private const val KEY_LOCATION_WARNING = "car_location_warning"
|
|||||||
AppSettings.putString(app, KEY_SCALE, value.toString())
|
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) {
|
fun setOpenOnConnect(id: String) {
|
||||||
openFlow.value = id
|
openFlow.value = id
|
||||||
AppSettings.putString(app, KEY_OPEN, id)
|
AppSettings.putString(app, KEY_OPEN, id)
|
||||||
@@ -378,6 +475,11 @@ private const val KEY_LOCATION_WARNING = "car_location_warning"
|
|||||||
AppSettings.putString(app, KEY_THEME, theme.name)
|
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) {
|
fun setUiTheme(id: String) {
|
||||||
uiThemeFlow.value = id
|
uiThemeFlow.value = id
|
||||||
AppSettings.putString(app, KEY_UI_THEME, id)
|
AppSettings.putString(app, KEY_UI_THEME, id)
|
||||||
@@ -388,6 +490,11 @@ private const val KEY_LOCATION_WARNING = "car_location_warning"
|
|||||||
AppSettings.putString(app, KEY_PLAYER_TITLE, on.toString())
|
AppSettings.putString(app, KEY_PLAYER_TITLE, on.toString())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun setNavMic(on: Boolean) {
|
||||||
|
navMicFlow.value = on
|
||||||
|
AppSettings.putString(app, KEY_NAV_MIC, on.toString())
|
||||||
|
}
|
||||||
|
|
||||||
fun setPlayerLeft(on: Boolean) {
|
fun setPlayerLeft(on: Boolean) {
|
||||||
playerLeftFlow.value = on
|
playerLeftFlow.value = on
|
||||||
AppSettings.putString(app, KEY_PLAYER_LEFT, on.toString())
|
AppSettings.putString(app, KEY_PLAYER_LEFT, on.toString())
|
||||||
@@ -405,6 +512,11 @@ private const val KEY_LOCATION_WARNING = "car_location_warning"
|
|||||||
fun setPipeAudio(on: Boolean) {
|
fun setPipeAudio(on: Boolean) {
|
||||||
// Saved even when unchanged, so picking the default still counts as a choice.
|
// Saved even when unchanged, so picking the default still counts as a choice.
|
||||||
AppSettings.putString(app, KEY_PIPE_AUDIO, on.toString())
|
AppSettings.putString(app, KEY_PIPE_AUDIO, on.toString())
|
||||||
|
// Choosing Bluetooth by hand overrules what Gearslip learned about each car.
|
||||||
|
if (!on) {
|
||||||
|
AppSettings.putString(app, KEY_AUDIO_CARS, "")
|
||||||
|
thisCarPipesFlow.value = false
|
||||||
|
}
|
||||||
if (pipeAudioFlow.value == on) return
|
if (pipeAudioFlow.value == on) return
|
||||||
pipeAudioFlow.value = on
|
pipeAudioFlow.value = on
|
||||||
if (AppSettings.getString(app, KEY_SPEAK_DIRECTIONS, "") == "") speakDirectionsFlow.value = on
|
if (AppSettings.getString(app, KEY_SPEAK_DIRECTIONS, "") == "") speakDirectionsFlow.value = on
|
||||||
@@ -416,6 +528,11 @@ private const val KEY_LOCATION_WARNING = "car_location_warning"
|
|||||||
AppSettings.putString(app, KEY_SPEAK_DIRECTIONS, on.toString())
|
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) {
|
fun setBluetoothWarning(on: Boolean) {
|
||||||
bluetoothWarningFlow.value = on
|
bluetoothWarningFlow.value = on
|
||||||
AppSettings.putString(app, KEY_BLUETOOTH_WARNING, on.toString())
|
AppSettings.putString(app, KEY_BLUETOOTH_WARNING, on.toString())
|
||||||
@@ -441,6 +558,11 @@ private const val KEY_LOCATION_WARNING = "car_location_warning"
|
|||||||
AppSettings.putString(app, KEY_ADAPTIVE_VIDEO, on.toString())
|
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) {
|
fun setCarSensorsEnabled(on: Boolean) {
|
||||||
carSensorsFlow.value = on
|
carSensorsFlow.value = on
|
||||||
AppSettings.putString(app, KEY_CAR_SENSORS, on.toString())
|
AppSettings.putString(app, KEY_CAR_SENSORS, on.toString())
|
||||||
@@ -465,6 +587,27 @@ private const val KEY_LOCATION_WARNING = "car_location_warning"
|
|||||||
AppSettings.putString(app, KEY_PINNED, next.joinToString(","))
|
AppSettings.putString(app, KEY_PINNED, next.joinToString(","))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Unticks each of [ids] the first time it's seen, and never again: a known-broken app starts
|
||||||
|
* off the drawer, but once the driver ticks it back on it stays on.
|
||||||
|
*/
|
||||||
|
fun hideOnFirstSight(ids: List<String>) {
|
||||||
|
if (!::app.isInitialized) return
|
||||||
|
val seen = AppSettings.getString(app, KEY_DEFAULT_HIDDEN, "").split(",").filter { it.isNotEmpty() }.toSet()
|
||||||
|
val fresh = ids.filter { it !in seen }
|
||||||
|
if (fresh.isEmpty()) return
|
||||||
|
AppSettings.putString(app, KEY_DEFAULT_HIDDEN, (seen + fresh).joinToString(","))
|
||||||
|
val next = hiddenAppsFlow.value + fresh
|
||||||
|
hiddenAppsFlow.value = next
|
||||||
|
AppSettings.putString(app, KEY_HIDDEN_APPS, 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>) {
|
fun setAppOrder(ids: List<String>) {
|
||||||
appOrderFlow.value = ids
|
appOrderFlow.value = ids
|
||||||
AppSettings.putString(app, KEY_APP_ORDER, ids.joinToString(","))
|
AppSettings.putString(app, KEY_APP_ORDER, ids.joinToString(","))
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
package app.seb3thehacker.gearslip.car
|
package app.seb3thehacker.gearslip.car
|
||||||
|
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import app.seb3thehacker.gearslip.R
|
||||||
|
|
||||||
|
import app.seb3thehacker.gearslip.car.theme.DarkStyle
|
||||||
import app.seb3thehacker.gearslip.car.theme.*
|
import app.seb3thehacker.gearslip.car.theme.*
|
||||||
import android.os.Build
|
import android.os.Build
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
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.layout.PaddingValues
|
||||||
import androidx.compose.foundation.lazy.LazyColumn
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
import androidx.compose.foundation.layout.Row
|
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.Spacer
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
@@ -26,7 +32,6 @@ import androidx.compose.runtime.Composable
|
|||||||
import androidx.compose.runtime.collectAsState
|
import androidx.compose.runtime.collectAsState
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.produceState
|
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
@@ -39,9 +44,6 @@ import app.seb3thehacker.gearslip.car.theme.GsColors
|
|||||||
import app.seb3thehacker.gearslip.car.theme.GsIconBox
|
import app.seb3thehacker.gearslip.car.theme.GsIconBox
|
||||||
import app.seb3thehacker.gearslip.car.theme.GsThemes
|
import app.seb3thehacker.gearslip.car.theme.GsThemes
|
||||||
import app.seb3thehacker.gearslip.AppSettings
|
import app.seb3thehacker.gearslip.AppSettings
|
||||||
import app.seb3thehacker.gearslip.ui.CertSummary
|
|
||||||
import kotlinx.coroutines.Dispatchers
|
|
||||||
import kotlinx.coroutines.withContext
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Settings that make sense while sitting in the car. The web page URL is edited here, with the
|
* Settings that make sense while sitting in the car. The web page URL is edited here, with the
|
||||||
@@ -52,6 +54,7 @@ import kotlinx.coroutines.withContext
|
|||||||
fun CarSettingsScreen() {
|
fun CarSettingsScreen() {
|
||||||
val scale by CarSettings.scale.collectAsState()
|
val scale by CarSettings.scale.collectAsState()
|
||||||
val open by CarSettings.openOnConnect.collectAsState()
|
val open by CarSettings.openOnConnect.collectAsState()
|
||||||
|
val launcher by LauncherMode.active.collectAsState()
|
||||||
val night by CarSettings.nightMode.collectAsState()
|
val night by CarSettings.nightMode.collectAsState()
|
||||||
val units by CarSettings.units.collectAsState()
|
val units by CarSettings.units.collectAsState()
|
||||||
val navBar by CarSettings.navBarMode.collectAsState()
|
val navBar by CarSettings.navBarMode.collectAsState()
|
||||||
@@ -60,256 +63,236 @@ fun CarSettingsScreen() {
|
|||||||
val whatsNew by CarSettings.whatsNewAfterUpdates.collectAsState()
|
val whatsNew by CarSettings.whatsNewAfterUpdates.collectAsState()
|
||||||
val pipeAudio by CarSettings.pipeAudio.collectAsState()
|
val pipeAudio by CarSettings.pipeAudio.collectAsState()
|
||||||
val speakDirections by CarSettings.speakDirections.collectAsState()
|
val speakDirections by CarSettings.speakDirections.collectAsState()
|
||||||
|
val directionsOverMusic by CarSettings.directionsOverMusic.collectAsState()
|
||||||
val bluetoothWarning by CarSettings.bluetoothWarning.collectAsState()
|
val bluetoothWarning by CarSettings.bluetoothWarning.collectAsState()
|
||||||
val locationWarning by CarSettings.locationWarning.collectAsState()
|
val locationWarning by CarSettings.locationWarning.collectAsState()
|
||||||
val autoplay by CarSettings.autoplay.collectAsState()
|
val autoplay by CarSettings.autoplay.collectAsState()
|
||||||
val playerTitle by CarSettings.playerTitle.collectAsState()
|
val playerTitle by CarSettings.playerTitle.collectAsState()
|
||||||
val playerLeft by CarSettings.playerLeft.collectAsState()
|
val playerLeft by CarSettings.playerLeft.collectAsState()
|
||||||
|
val navMic by CarSettings.navMic.collectAsState()
|
||||||
val theme by CarSettings.appTheme.collectAsState()
|
val theme by CarSettings.appTheme.collectAsState()
|
||||||
val uiTheme by CarSettings.uiTheme.collectAsState()
|
val uiTheme by CarSettings.uiTheme.collectAsState()
|
||||||
|
val darkStyle by CarSettings.darkStyle.collectAsState()
|
||||||
val experimentalFeatures by CarSettings.experimentalFeaturesEnabled.collectAsState()
|
val experimentalFeatures by CarSettings.experimentalFeaturesEnabled.collectAsState()
|
||||||
val carSensors by CarSettings.carSensorsEnabled.collectAsState()
|
val carSensors by CarSettings.carSensorsEnabled.collectAsState()
|
||||||
val adaptiveVideo by CarSettings.adaptiveVideoEnabled.collectAsState()
|
val adaptiveVideo by CarSettings.adaptiveVideoEnabled.collectAsState()
|
||||||
|
val clusterVideo by CarSettings.clusterVideoEnabled.collectAsState()
|
||||||
val systemSpeech by CarSettings.systemSpeech.collectAsState()
|
val systemSpeech by CarSettings.systemSpeech.collectAsState()
|
||||||
val display by CarEnvironment.display.collectAsState()
|
val display by CarEnvironment.display.collectAsState()
|
||||||
val vehicle by CarEnvironment.vehicle.collectAsState()
|
val vehicle by CarEnvironment.vehicle.collectAsState()
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
val cert by produceState<CertSummary?>(null) {
|
|
||||||
value = withContext(Dispatchers.Default) { CertSummary.read(context) }
|
|
||||||
}
|
|
||||||
|
|
||||||
LazyColumn(
|
// A rail of the groups down the left, and the chosen group's settings beside it: five short
|
||||||
Modifier.fillMaxSize(),
|
// lists instead of one long scroll. The groups and their order match Settings on the phone.
|
||||||
// Built as it scrolls into view rather than all at once: every row here is a set of
|
var chosen by androidx.compose.runtime.saveable.rememberSaveable { androidx.compose.runtime.mutableIntStateOf(0) }
|
||||||
// buttons, and building the whole screen made opening Settings visibly slow.
|
val listState = androidx.compose.foundation.lazy.rememberLazyListState()
|
||||||
contentPadding = PaddingValues(horizontal = 24.dp, vertical = 16.dp),
|
androidx.compose.runtime.LaunchedEffect(chosen) { listState.scrollToItem(0) }
|
||||||
verticalArrangement = Arrangement.spacedBy(20.dp),
|
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 }
|
||||||
item {
|
LazyColumn(
|
||||||
ScreenHeader("Settings")
|
Modifier.weight(1f).fillMaxHeight(),
|
||||||
}
|
state = listState,
|
||||||
|
// Built as it scrolls into view rather than all at once: every row here is a set of
|
||||||
item { Group("Screen") }
|
// buttons, and building the whole screen made opening Settings visibly slow.
|
||||||
|
contentPadding = PaddingValues(horizontal = 24.dp, vertical = 16.dp),
|
||||||
item {
|
verticalArrangement = Arrangement.spacedBy(20.dp),
|
||||||
ChoiceRow(
|
) {
|
||||||
"Open on connect",
|
when (chosen) {
|
||||||
listOf(CarSettings.HOME to "Home") + CarApps.all.filter { it.id != "settings" }.map { it.id to it.label },
|
0 -> {
|
||||||
open,
|
item { Group(stringResource(R.string.car_group_screen)) }
|
||||||
) { CarSettings.setOpenOnConnect(it) }
|
item {
|
||||||
}
|
ChoiceRow(
|
||||||
|
stringResource(R.string.car_open_on_connect),
|
||||||
item {
|
listOf(CarSettings.HOME to stringResource(R.string.choice_open_home)) + CarApps.shown(launcher, LauncherMode.canCall(context)).map { it.id to stringResource(it.labelRes) },
|
||||||
ChoiceRow(
|
open,
|
||||||
"Size",
|
) { CarSettings.setOpenOnConnect(it) }
|
||||||
listOf(1f to "Normal", 1.25f to "Large", 1.5f to "Extra large"),
|
}
|
||||||
scale,
|
item {
|
||||||
) { CarSettings.setScale(it) }
|
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)),
|
||||||
item {
|
scale,
|
||||||
ChoiceRow(
|
) { CarSettings.setScale(it) }
|
||||||
"App theme",
|
}
|
||||||
listOf(AppTheme.PHONE to "Follow phone", AppTheme.LIGHT to "Light", AppTheme.DARK to "Dark"),
|
item {
|
||||||
theme,
|
ChoiceRow(
|
||||||
) { CarSettings.setAppTheme(it) }
|
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,
|
||||||
item {
|
) { CarSettings.setAppTheme(it) }
|
||||||
ChoiceRow(
|
}
|
||||||
"Theme",
|
if (theme != AppTheme.LIGHT) item {
|
||||||
GsThemes.builtIn.map { it.id to it.name },
|
ChoiceRow(
|
||||||
uiTheme,
|
stringResource(R.string.car_dark_mode),
|
||||||
) { CarSettings.setUiTheme(it) }
|
listOf(
|
||||||
}
|
DarkStyle.BLACK to stringResource(R.string.choice_dark_black),
|
||||||
|
DarkStyle.BLACK_COLOUR to stringResource(R.string.choice_dark_black_colour),
|
||||||
item {
|
DarkStyle.DEEP_COLOUR to stringResource(R.string.choice_dark_deep_colour),
|
||||||
ChoiceRow(
|
DarkStyle.GREY to stringResource(R.string.choice_dark_grey),
|
||||||
"Map day/night",
|
),
|
||||||
listOf(NightMode.AUTO to "Auto", NightMode.DAY to "Day", NightMode.NIGHT to "Night"),
|
darkStyle,
|
||||||
night,
|
) { CarSettings.setDarkStyle(it) }
|
||||||
) { CarSettings.setNightMode(it) }
|
}
|
||||||
}
|
item {
|
||||||
|
ChoiceRow(
|
||||||
item {
|
stringResource(R.string.car_buttons),
|
||||||
ChoiceRow(
|
GsThemes.builtIn.map { it.id to it.name },
|
||||||
"Units",
|
uiTheme,
|
||||||
listOf(Units.AUTO to "Auto", Units.IMPERIAL to "Imperial (mi, ft, °F)", Units.METRIC to "Metric (km, m, °C)"),
|
) { CarSettings.setUiTheme(it) }
|
||||||
units,
|
}
|
||||||
) { CarSettings.setUnits(it) }
|
item {
|
||||||
}
|
ChoiceRow(
|
||||||
|
stringResource(R.string.car_map_day_night),
|
||||||
// For a head unit that reports the wrong size and stretches the picture (issue #12).
|
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)),
|
||||||
item {
|
night,
|
||||||
ChoiceRow(
|
) { CarSettings.setNightMode(it) }
|
||||||
"Screen size (next connection)",
|
}
|
||||||
listOf<ScreenSize?>(null).plus(SCREEN_PRESETS).plus(screenSize).distinct()
|
item {
|
||||||
.map { it to (it?.toString() ?: "From the car") },
|
ChoiceRow(
|
||||||
screenSize,
|
stringResource(R.string.car_units),
|
||||||
) { CarSettings.setScreenSize(it) }
|
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) }
|
||||||
if (screenSize != null) item {
|
}
|
||||||
ChoiceRow(
|
// Fixes for a head unit that gets its own screen wrong (issue #12), together
|
||||||
"Fit to the screen",
|
// and last, since most cars never need them.
|
||||||
listOf(ScreenFit.CROP to "Crop the edges", ScreenFit.SQUASH to "Squash to fit"),
|
item { SubGroup(stringResource(R.string.carset_advanced)) }
|
||||||
screenFit,
|
item {
|
||||||
) { CarSettings.setScreenFit(it) }
|
ChoiceRow(
|
||||||
}
|
stringResource(R.string.carset_screen_size),
|
||||||
|
listOf<ScreenSize?>(null).plus(SCREEN_PRESETS).plus(screenSize).distinct()
|
||||||
item { Group("Nav bar and player") }
|
.map { it to (it?.toString() ?: stringResource(R.string.screen_from_car)) },
|
||||||
|
screenSize,
|
||||||
item {
|
) { CarSettings.setScreenSize(it) }
|
||||||
ChoiceRow(
|
}
|
||||||
"Nav bar",
|
if (screenSize != null) item {
|
||||||
listOf(
|
ChoiceRow(
|
||||||
NavBarMode.SHOW to "Always show",
|
stringResource(R.string.carset_fit),
|
||||||
NavBarMode.HIDE_NAVIGATING to "Hide while navigating",
|
listOf(ScreenFit.CROP to stringResource(R.string.screen_crop), ScreenFit.SQUASH to stringResource(R.string.screen_squash)),
|
||||||
NavBarMode.HIDE to "Hide until tapped",
|
screenFit,
|
||||||
),
|
) { CarSettings.setScreenFit(it) }
|
||||||
navBar,
|
}
|
||||||
) { CarSettings.setNavBarMode(it) }
|
item { ScreenFit(vehicle) }
|
||||||
}
|
}
|
||||||
|
1 -> {
|
||||||
item {
|
item { Group(stringResource(R.string.car_group_nav_bar)) }
|
||||||
ChoiceRow(
|
item {
|
||||||
"Player in the nav bar",
|
ChoiceRow(
|
||||||
listOf(false to "Skip buttons", true to "Song title"),
|
stringResource(R.string.car_nav_bar),
|
||||||
playerTitle,
|
listOf(
|
||||||
) { CarSettings.setPlayerTitle(it) }
|
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),
|
||||||
item {
|
),
|
||||||
ChoiceRow(
|
navBar,
|
||||||
"Player beside the map",
|
) { CarSettings.setNavBarMode(it) }
|
||||||
listOf(false to "Right", true to "Left"),
|
}
|
||||||
playerLeft,
|
item {
|
||||||
) { CarSettings.setPlayerLeft(it) }
|
SwitchRow(stringResource(R.string.car_nav_mic), stringResource(R.string.car_nav_mic_note), navMic) { CarSettings.setNavMic(it) }
|
||||||
}
|
}
|
||||||
|
item {
|
||||||
item {
|
ChoiceRow(
|
||||||
ChoiceRow(
|
stringResource(R.string.car_player_nav_bar),
|
||||||
"Autoplay on connect",
|
listOf(false to stringResource(R.string.car_skip_buttons), true to stringResource(R.string.car_song_title)),
|
||||||
listOf(false to "Off", true to "On"),
|
playerTitle,
|
||||||
autoplay,
|
) { CarSettings.setPlayerTitle(it) }
|
||||||
) { CarSettings.setAutoplay(it) }
|
}
|
||||||
}
|
item {
|
||||||
|
ChoiceRow(
|
||||||
item { Group("Sound and voice") }
|
stringResource(R.string.car_player_beside_map),
|
||||||
|
listOf(false to stringResource(R.string.car_right), true to stringResource(R.string.car_left)),
|
||||||
item {
|
playerLeft,
|
||||||
ChoiceRow(
|
) { CarSettings.setPlayerLeft(it) }
|
||||||
"Car audio",
|
}
|
||||||
listOf(false to "Bluetooth", true to "USB"),
|
item { Note(stringResource(R.string.car_player_beside_map_note)) }
|
||||||
pipeAudio,
|
item {
|
||||||
) { CarSettings.setPipeAudio(it) }
|
SwitchRow(stringResource(R.string.car_autoplay), stringResource(R.string.car_autoplay_note), autoplay) { CarSettings.setAutoplay(it) }
|
||||||
}
|
}
|
||||||
|
}
|
||||||
item {
|
2 -> {
|
||||||
Text(
|
item { Group(stringResource(R.string.car_group_sound)) }
|
||||||
"USB sends the phone's sound through Gearslip. Android asks to share your screen each " +
|
item {
|
||||||
"drive, and a few apps block it. Bluetooth plays as the phone normally would, and " +
|
ChoiceRow(
|
||||||
"Android never asks.",
|
stringResource(R.string.car_audio),
|
||||||
style = MaterialTheme.typography.bodySmall,
|
listOf(false to stringResource(R.string.guide_audio_bluetooth), true to stringResource(R.string.guide_audio_usb)),
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
pipeAudio,
|
||||||
)
|
) { CarSettings.setPipeAudio(it) }
|
||||||
}
|
}
|
||||||
|
item { Note(stringResource(R.string.carset_audio_note)) }
|
||||||
item { Group("Warnings") }
|
item {
|
||||||
|
SwitchRow(stringResource(R.string.car_spoken_directions), stringResource(R.string.carset_directions_note), speakDirections) { CarSettings.setSpeakDirections(it) }
|
||||||
if (!pipeAudio) item {
|
}
|
||||||
ChoiceRow(
|
if (speakDirections) item {
|
||||||
"Warn when Bluetooth isn't connected",
|
SwitchRow(stringResource(R.string.car_directions_over_music), stringResource(R.string.car_directions_over_music_note), directionsOverMusic) { CarSettings.setDirectionsOverMusic(it) }
|
||||||
listOf(false to "Off", true to "On"),
|
}
|
||||||
bluetoothWarning,
|
item {
|
||||||
) { CarSettings.setBluetoothWarning(it) }
|
SwitchRow(stringResource(R.string.car_phone_speech), stringResource(R.string.carset_phone_speech_note), systemSpeech) { CarSettings.setSystemSpeech(it) }
|
||||||
}
|
}
|
||||||
|
}
|
||||||
item {
|
3 -> {
|
||||||
ChoiceRow(
|
item { Group(stringResource(R.string.car_group_warnings)) }
|
||||||
"Warn when location is off",
|
if (!pipeAudio) item {
|
||||||
listOf(false to "Off", true to "On"),
|
SwitchRow(stringResource(R.string.car_warn_bluetooth), null, bluetoothWarning) { CarSettings.setBluetoothWarning(it) }
|
||||||
locationWarning,
|
}
|
||||||
) { CarSettings.setLocationWarning(it) }
|
item {
|
||||||
}
|
SwitchRow(stringResource(R.string.car_warn_location), null, locationWarning) { CarSettings.setLocationWarning(it) }
|
||||||
|
}
|
||||||
item {
|
}
|
||||||
ChoiceRow(
|
else -> {
|
||||||
"Spoken directions",
|
item { Group(stringResource(R.string.car_group_more)) }
|
||||||
listOf(false to "Off", true to "On"),
|
item { WebPageSetting() }
|
||||||
speakDirections,
|
item {
|
||||||
) { CarSettings.setSpeakDirections(it) }
|
SwitchRow(stringResource(R.string.car_experimental), stringResource(R.string.car_experimental_note), experimentalFeatures) { CarSettings.setExperimentalFeaturesEnabled(it) }
|
||||||
}
|
}
|
||||||
|
if (experimentalFeatures) item {
|
||||||
item {
|
Column(verticalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||||
Text(
|
SubGroup(stringResource(R.string.carset_experimental_heading))
|
||||||
"Gearslip reads each turn aloud through the car's speakers, and the music dips while it " +
|
ExperimentalFeaturesList(carSensors, adaptiveVideo, clusterVideo)
|
||||||
"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 {
|
||||||
)
|
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
}
|
SubGroup(stringResource(R.string.settings_connection))
|
||||||
|
// The certificate is managed on the phone, which shows it in full.
|
||||||
item {
|
val none = stringResource(R.string.status_idle)
|
||||||
ExperimentalFeatureRow(
|
Info(stringResource(R.string.carset_phone), "${Build.MANUFACTURER} ${Build.MODEL}")
|
||||||
title = "Use the phone's speech recognition",
|
Info(stringResource(R.string.carset_vehicle), vehicle.ifEmpty { none })
|
||||||
description = "Listens through the phone's own speech service instead of Gearslip's. On " +
|
Info(stringResource(R.string.carset_video), display.ifEmpty { none })
|
||||||
"most phones that's Google's, which may send your voice to Google. GrapheneOS has " +
|
}
|
||||||
"none, so Gearslip keeps using its own.",
|
}
|
||||||
checked = systemSpeech,
|
item {
|
||||||
) { CarSettings.setSystemSpeech(it) }
|
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
}
|
SubGroup(stringResource(R.string.settings_about))
|
||||||
|
Info(stringResource(R.string.carset_version), app.seb3thehacker.gearslip.BuildConfig.VERSION_NAME)
|
||||||
item { Group("More") }
|
Info(stringResource(R.string.carset_build), app.seb3thehacker.gearslip.BuildConfig.VERSION_CODE.toString())
|
||||||
|
SwitchRow(
|
||||||
item {
|
stringResource(R.string.carset_whats_new_after),
|
||||||
WebPageSetting()
|
stringResource(R.string.carset_whats_new_after_sub),
|
||||||
}
|
whatsNew,
|
||||||
|
) { CarSettings.setWhatsNewAfterUpdates(it) }
|
||||||
item {
|
GsButton(onClick = CarSettings::openWhatsNew, tone = GsTone.Tonal, modifier = Modifier.height(56.dp)) {
|
||||||
ScreenFit(vehicle)
|
Text(stringResource(R.string.settings_whats_new), style = MaterialTheme.typography.titleMedium)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
item {
|
}
|
||||||
ChoiceRow(
|
|
||||||
"Experimental features",
|
|
||||||
listOf(false to "Hide", true to "Show"),
|
|
||||||
experimentalFeatures,
|
|
||||||
) { CarSettings.setExperimentalFeaturesEnabled(it) }
|
|
||||||
}
|
|
||||||
|
|
||||||
if (experimentalFeatures) item {
|
|
||||||
ExperimentalFeaturesList(carSensors, adaptiveVideo)
|
|
||||||
}
|
|
||||||
|
|
||||||
item {
|
|
||||||
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
|
||||||
Text("Connection", style = MaterialTheme.typography.titleMedium, color = MaterialTheme.colorScheme.primary)
|
|
||||||
Info("Phone", "${Build.MANUFACTURER} ${Build.MODEL}")
|
|
||||||
Info("Vehicle", vehicle.ifEmpty { "-" })
|
|
||||||
Info("Video", display.ifEmpty { "-" })
|
|
||||||
Info("Certificate", cert?.headline ?: "Checking…")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
item {
|
|
||||||
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
|
||||||
Text("About", style = MaterialTheme.typography.titleMedium, color = MaterialTheme.colorScheme.primary)
|
|
||||||
Info("Version", app.seb3thehacker.gearslip.BuildConfig.VERSION_NAME)
|
|
||||||
Info("Build", app.seb3thehacker.gearslip.BuildConfig.VERSION_CODE.toString())
|
|
||||||
ExperimentalFeatureRow(
|
|
||||||
"What's new after updates",
|
|
||||||
"Show the list of changes once, the first time the car connects after an update.",
|
|
||||||
whatsNew,
|
|
||||||
) { CarSettings.setWhatsNewAfterUpdates(it) }
|
|
||||||
GsButton(onClick = CarSettings::openWhatsNew, tone = GsTone.Tonal, modifier = Modifier.height(56.dp)) {
|
|
||||||
Text("What's new", style = MaterialTheme.typography.titleMedium)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** A heading inside a group, for a few settings that belong together. */
|
||||||
|
@Composable
|
||||||
|
private fun SubGroup(title: String) {
|
||||||
|
Text(title, style = MaterialTheme.typography.titleMedium, color = MaterialTheme.colorScheme.primary)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** What a setting above it does, in a sentence or two. */
|
||||||
|
@Composable
|
||||||
|
private fun Note(text: String) {
|
||||||
|
Text(text, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||||
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun <T> ChoiceRow(title: String, options: List<Pair<T, String>>, selected: T, onSelect: (T) -> Unit) {
|
private fun <T> ChoiceRow(title: String, options: List<Pair<T, String>>, selected: T, onSelect: (T) -> Unit) {
|
||||||
// On a wide screen the name sits beside its choices, which halves how far there is to scroll.
|
// On a wide screen the name sits beside its choices, which halves how far there is to scroll.
|
||||||
@@ -328,6 +311,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. */
|
/** A group of settings: a heading, so a long list reads as a few short ones. */
|
||||||
@Composable
|
@Composable
|
||||||
private fun Group(title: String) {
|
private fun Group(title: String) {
|
||||||
@@ -356,7 +380,16 @@ private fun <T> ChoiceButtons(options: List<Pair<T, String>>, selected: T, onSel
|
|||||||
else GsColors(scheme.surfaceContainerHigh, scheme.onSurface),
|
else GsColors(scheme.surfaceContainerHigh, scheme.onSurface),
|
||||||
latched = on,
|
latched = on,
|
||||||
) {
|
) {
|
||||||
Text(label, style = MaterialTheme.typography.titleMedium, fontWeight = if (on) FontWeight.Bold else FontWeight.Normal)
|
// Centred however many lines it takes: "Black and colour" wrapped to the left.
|
||||||
|
Text(
|
||||||
|
label,
|
||||||
|
style = MaterialTheme.typography.titleMedium,
|
||||||
|
fontWeight = if (on) FontWeight.Bold else FontWeight.Normal,
|
||||||
|
textAlign = androidx.compose.ui.text.style.TextAlign.Center,
|
||||||
|
maxLines = 2,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
modifier = Modifier.padding(horizontal = 8.dp),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -369,32 +402,33 @@ private fun <T> ChoiceButtons(options: List<Pair<T, String>>, selected: T, onSel
|
|||||||
* so the list reads fine whether there's one entry or several.
|
* so the list reads fine whether there's one entry or several.
|
||||||
*/
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
private fun ExperimentalFeaturesList(carSensors: Boolean, adaptiveVideo: Boolean) {
|
private fun ExperimentalFeaturesList(carSensors: Boolean, adaptiveVideo: Boolean, clusterVideo: Boolean) {
|
||||||
Column(
|
Column(
|
||||||
Modifier.fillMaxWidth(),
|
Modifier.fillMaxWidth(),
|
||||||
verticalArrangement = Arrangement.spacedBy(10.dp),
|
verticalArrangement = Arrangement.spacedBy(10.dp),
|
||||||
) {
|
) {
|
||||||
ExperimentalFeatureRow(
|
SwitchRow(
|
||||||
title = "Vehicle data",
|
title = stringResource(R.string.car_vehicle_data),
|
||||||
description = "Adds a Vehicle data tile: a live, raw dump of the head unit's sensor " +
|
description = stringResource(R.string.carset_vehicle_data_note),
|
||||||
"channel. Field labels (speed, RPM, fuel...) are carried over from other " +
|
|
||||||
"reverse-engineering projects and unverified against this car - treat them as a " +
|
|
||||||
"guess, not a fact, until you've watched one change for yourself.",
|
|
||||||
checked = carSensors,
|
checked = carSensors,
|
||||||
) { CarSettings.setCarSensorsEnabled(it) }
|
) { CarSettings.setCarSensorsEnabled(it) }
|
||||||
ExperimentalFeatureRow(
|
SwitchRow(
|
||||||
title = "Adaptive video quality",
|
title = stringResource(R.string.car_adaptive_video),
|
||||||
description = "Lowers the picture's quality when the car, or its Wi-Fi, can't keep " +
|
description = stringResource(R.string.carset_adaptive_note),
|
||||||
"up, and raises it again once it can. Takes effect on the next connection.",
|
|
||||||
checked = adaptiveVideo,
|
checked = adaptiveVideo,
|
||||||
) { CarSettings.setAdaptiveVideoEnabled(it) }
|
) { CarSettings.setAdaptiveVideoEnabled(it) }
|
||||||
|
SwitchRow(
|
||||||
|
title = stringResource(R.string.car_cluster_video),
|
||||||
|
description = stringResource(R.string.car_cluster_video_note),
|
||||||
|
checked = clusterVideo,
|
||||||
|
) { CarSettings.setClusterVideoEnabled(it) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun ExperimentalFeatureRow(
|
private fun SwitchRow(
|
||||||
title: String,
|
title: String,
|
||||||
description: String,
|
description: String?,
|
||||||
checked: Boolean,
|
checked: Boolean,
|
||||||
warning: Boolean = false,
|
warning: Boolean = false,
|
||||||
onCheckedChange: (Boolean) -> Unit,
|
onCheckedChange: (Boolean) -> Unit,
|
||||||
@@ -412,7 +446,7 @@ private fun ExperimentalFeatureRow(
|
|||||||
) {
|
) {
|
||||||
Column(Modifier.weight(1f)) {
|
Column(Modifier.weight(1f)) {
|
||||||
Text(title, style = MaterialTheme.typography.titleMedium)
|
Text(title, style = MaterialTheme.typography.titleMedium)
|
||||||
Text(
|
if (description != null) Text(
|
||||||
description,
|
description,
|
||||||
style = MaterialTheme.typography.bodySmall,
|
style = MaterialTheme.typography.bodySmall,
|
||||||
color = if (warning) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.onSurfaceVariant,
|
color = if (warning) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
@@ -433,7 +467,7 @@ private fun WebPageSetting() {
|
|||||||
var url by remember { mutableStateOf(AppSettings.startupUrl(context)) }
|
var url by remember { mutableStateOf(AppSettings.startupUrl(context)) }
|
||||||
|
|
||||||
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
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(
|
Surface(
|
||||||
onClick = { editing = !editing },
|
onClick = { editing = !editing },
|
||||||
shape = MaterialTheme.shapes.large,
|
shape = MaterialTheme.shapes.large,
|
||||||
@@ -441,7 +475,7 @@ private fun WebPageSetting() {
|
|||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
) {
|
) {
|
||||||
Text(
|
Text(
|
||||||
url.ifEmpty { "DuckDuckGo (default) - tap to set a different page" },
|
url.ifEmpty { stringResource(R.string.carset_web_default) },
|
||||||
style = MaterialTheme.typography.bodyLarge,
|
style = MaterialTheme.typography.bodyLarge,
|
||||||
color = if (url.isEmpty()) MaterialTheme.colorScheme.onSurfaceVariant else MaterialTheme.colorScheme.onSurface,
|
color = if (url.isEmpty()) MaterialTheme.colorScheme.onSurfaceVariant else MaterialTheme.colorScheme.onSurface,
|
||||||
maxLines = 1,
|
maxLines = 1,
|
||||||
@@ -450,7 +484,7 @@ private fun WebPageSetting() {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
Text(
|
Text(
|
||||||
"Shown by the Browser app on the launcher. Applies on the next connection.",
|
stringResource(R.string.carset_web_note),
|
||||||
style = MaterialTheme.typography.bodySmall,
|
style = MaterialTheme.typography.bodySmall,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
)
|
)
|
||||||
@@ -493,22 +527,23 @@ private fun ScreenFit(vehicle: String) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
Text("Screen fit", style = MaterialTheme.typography.titleMedium, color = MaterialTheme.colorScheme.primary)
|
Text(stringResource(R.string.carset_screen_fit), style = MaterialTheme.typography.titleMedium)
|
||||||
Stepper("Top", insets.top) { change(insets.copy(top = (insets.top + it).coerceAtLeast(0))) }
|
Stepper(stringResource(R.string.carset_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))) }
|
Stepper(stringResource(R.string.carset_bottom), insets.bottom) { change(insets.copy(bottom = (insets.bottom + it).coerceAtLeast(0))) }
|
||||||
Text(
|
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,
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
)
|
)
|
||||||
GsButton(
|
// With no car to name, a greyed-out button only said what couldn't be done.
|
||||||
|
if (known) GsButton(
|
||||||
onClick = {
|
onClick = {
|
||||||
val ok = app.seb3thehacker.gearslip.VehicleProfiles.saveInsets(context, vehicle, insets)
|
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),
|
modifier = Modifier.fillMaxWidth().height(56.dp),
|
||||||
) { Text(if (known) "Save for this vehicle" else "No vehicle profile matched") }
|
) { Text(stringResource(R.string.carset_save_vehicle)) }
|
||||||
|
else Note(stringResource(R.string.carset_fit_needs_car))
|
||||||
saved?.let { Text(it, style = MaterialTheme.typography.bodyMedium) }
|
saved?.let { Text(it, style = MaterialTheme.typography.bodyMedium) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -520,7 +555,7 @@ private fun Stepper(label: String, value: Int, onStep: (Int) -> Unit) {
|
|||||||
StepButton("−5", Modifier.weight(1f)) { onStep(-5) }
|
StepButton("−5", Modifier.weight(1f)) { onStep(-5) }
|
||||||
StepButton("−1", Modifier.weight(1f)) { onStep(-1) }
|
StepButton("−1", Modifier.weight(1f)) { onStep(-1) }
|
||||||
Text(
|
Text(
|
||||||
"${value}px",
|
stringResource(R.string.carset_px, value),
|
||||||
style = MaterialTheme.typography.titleLarge,
|
style = MaterialTheme.typography.titleLarge,
|
||||||
modifier = Modifier.weight(1.3f),
|
modifier = Modifier.weight(1.3f),
|
||||||
textAlign = androidx.compose.ui.text.style.TextAlign.Center,
|
textAlign = androidx.compose.ui.text.style.TextAlign.Center,
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
package app.seb3thehacker.gearslip.car
|
package app.seb3thehacker.gearslip.car
|
||||||
|
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.material.icons.filled.Call
|
import androidx.compose.material.icons.filled.Call
|
||||||
import app.seb3thehacker.gearslip.call.CarCalls
|
import app.seb3thehacker.gearslip.call.CarCalls
|
||||||
import app.seb3thehacker.gearslip.call.CallUiState
|
import app.seb3thehacker.gearslip.call.CallUiState
|
||||||
@@ -62,6 +63,8 @@ import androidx.compose.runtime.setValue
|
|||||||
import androidx.compose.animation.animateContentSize
|
import androidx.compose.animation.animateContentSize
|
||||||
import android.media.session.PlaybackState
|
import android.media.session.PlaybackState
|
||||||
import androidx.compose.runtime.remember
|
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.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.draw.clip
|
import androidx.compose.ui.draw.clip
|
||||||
@@ -110,6 +113,7 @@ fun CarUi() {
|
|||||||
val phoneDark by CarEnvironment.phoneDark.collectAsState()
|
val phoneDark by CarEnvironment.phoneDark.collectAsState()
|
||||||
val appTheme by CarSettings.appTheme.collectAsState()
|
val appTheme by CarSettings.appTheme.collectAsState()
|
||||||
val uiThemeId by CarSettings.uiTheme.collectAsState()
|
val uiThemeId by CarSettings.uiTheme.collectAsState()
|
||||||
|
val darkStyle by CarSettings.darkStyle.collectAsState()
|
||||||
val mediaApp by CarServices.mediaApp.collectAsState()
|
val mediaApp by CarServices.mediaApp.collectAsState()
|
||||||
val darkOutside by CarEnvironment.darkOutside.collectAsState()
|
val darkOutside by CarEnvironment.darkOutside.collectAsState()
|
||||||
val appContext = LocalContext.current.applicationContext
|
val appContext = LocalContext.current.applicationContext
|
||||||
@@ -118,10 +122,15 @@ fun CarUi() {
|
|||||||
AppTheme.LIGHT -> false
|
AppTheme.LIGHT -> false
|
||||||
AppTheme.DARK -> true
|
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 base = LocalDensity.current
|
||||||
val insets by CarEnvironment.insets.collectAsState()
|
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() }
|
val controlsWake = remember { MapControlsWake() }
|
||||||
LaunchedEffect(controlsWake) {
|
LaunchedEffect(controlsWake) {
|
||||||
while (true) {
|
while (true) {
|
||||||
@@ -137,7 +146,7 @@ fun CarUi() {
|
|||||||
// Ask for the phone's sound as soon as the car connects, not only when a media app opens:
|
// Ask for the phone's sound as soon as the car connects, not only when a media app opens:
|
||||||
// a video in the web browser or a map's voice needs it too. Asks once, after the car offers
|
// a video in the web browser or a map's voice needs it too. Asks once, after the car offers
|
||||||
// somewhere to play it.
|
// somewhere to play it.
|
||||||
LaunchedEffect(Unit) { if (CarSettings.pipeAudio.value) app.seb3thehacker.gearslip.audio.CarAudio.request() }
|
LaunchedEffect(Unit) { if (CarSettings.pipesNow) app.seb3thehacker.gearslip.audio.CarAudio.request() }
|
||||||
// A new version gets one card in the top right, a few seconds in and only while parked.
|
// A new version gets one card in the top right, a few seconds in and only while parked.
|
||||||
LaunchedEffect(Unit) { app.seb3thehacker.gearslip.update.UpdateChecker.onCarConnected(appContext) }
|
LaunchedEffect(Unit) { app.seb3thehacker.gearslip.update.UpdateChecker.onCarConnected(appContext) }
|
||||||
// The map follows the light outside, whatever the app's own theme is set to.
|
// The map follows the light outside, whatever the app's own theme is set to.
|
||||||
@@ -222,8 +231,10 @@ fun CarUi() {
|
|||||||
},
|
},
|
||||||
color = MaterialTheme.colorScheme.background,
|
color = MaterialTheme.colorScheme.background,
|
||||||
) {
|
) {
|
||||||
// Insets are in video-frame pixels, so convert with the un-scaled density.
|
// Insets are video-frame pixels. Resolve them with the live (size-scaled) density,
|
||||||
val pad = with(base) {
|
// 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(
|
PaddingValues(
|
||||||
start = insets.left.toDp(), top = insets.top.toDp(),
|
start = insets.left.toDp(), top = insets.top.toDp(),
|
||||||
end = insets.right.toDp(), bottom = insets.bottom.toDp(),
|
end = insets.right.toDp(), bottom = insets.bottom.toDp(),
|
||||||
@@ -232,12 +243,11 @@ fun CarUi() {
|
|||||||
val screen = navigator.current
|
val screen = navigator.current
|
||||||
val safetyWarningSeen by CarSettings.hasSeenSafetyWarning.collectAsState()
|
val safetyWarningSeen by CarSettings.hasSeenSafetyWarning.collectAsState()
|
||||||
val keyboardVisible by CarKeyboardVisibility.state
|
val keyboardVisible by CarKeyboardVisibility.state
|
||||||
// Off "Always show", the bar floats over the map rather than taking a row of its
|
// The bar always lies over the map and every other screen rather than taking a row
|
||||||
// own: hiding it then leaves the map's surface the size it was. Resizing it would
|
// of its own: hiding it then leaves the map's surface the size it was.
|
||||||
// hand the map app a new surface each time, and some go black on that.
|
// 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 navBarMode by CarSettings.navBarMode.collectAsState()
|
||||||
val appTemplate by CarServices.nav.template.collectAsState()
|
val appTemplate by CarServices.nav.template.collectAsState()
|
||||||
val floatingBar = navBarMode != NavBarMode.SHOW
|
|
||||||
val barMayHide = screen == CarScreen.Home && when (navBarMode) {
|
val barMayHide = screen == CarScreen.Home && when (navBarMode) {
|
||||||
NavBarMode.SHOW -> false
|
NavBarMode.SHOW -> false
|
||||||
NavBarMode.HIDE_NAVIGATING -> appTemplate is androidx.car.app.navigation.model.NavigationTemplate
|
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.
|
// 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 onCall = CarCalls.state.collectAsState().value is CallUiState.Active
|
||||||
val barShown = !keyboardVisible && (!barMayHide || controlsWake.navBarAwake || onCall)
|
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.
|
// Only the screen that asked: going Home or anywhere else ends it.
|
||||||
val fullscreen = CarFullscreen.active && screen != CarScreen.Home
|
val fullscreen = CarFullscreen.active && screen != CarScreen.Home
|
||||||
Box(Modifier.fillMaxSize().padding(pad)) {
|
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 -
|
// 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
|
// nothing on the nav bar is useful mid-type, and the screen is too small to
|
||||||
// show both.
|
// show both.
|
||||||
if (!floatingBar && !keyboardVisible) CarNavBar(navigator)
|
|
||||||
}
|
}
|
||||||
// Every other screen, and everything that pops up, in a layer over Home rather
|
// 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
|
// 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.
|
// 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) {
|
if (safetyWarningSeen) {
|
||||||
Box(
|
Box(Modifier.fillMaxSize()) {
|
||||||
Modifier
|
|
||||||
.fillMaxSize()
|
|
||||||
.padding(bottom = if (!floatingBar && !keyboardVisible && !fullscreen) NAV_BAR_HEIGHT else 0.dp),
|
|
||||||
) {
|
|
||||||
if (screen != CarScreen.Home) {
|
if (screen != CarScreen.Home) {
|
||||||
Box(
|
Box(
|
||||||
Modifier
|
Modifier
|
||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
|
.graphicsLayer { if (fromHome) alpha = enter.value }
|
||||||
.background(MaterialTheme.colorScheme.background)
|
.background(MaterialTheme.colorScheme.background)
|
||||||
// The floating bar never hides here, so keep the screen clear of it.
|
// The bar never hides here, so keep the screen clear of it. The
|
||||||
.padding(bottom = if (floatingBar && !keyboardVisible && !fullscreen) NAV_BAR_HEIGHT else 0.dp)
|
// 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.
|
// Keeps touches that miss this screen's controls off the map.
|
||||||
.pointerInput(Unit) { detectTapGestures { } },
|
.pointerInput(Unit) { detectTapGestures { } },
|
||||||
) {
|
) {
|
||||||
when (screen) {
|
Box(
|
||||||
CarScreen.Home -> Unit
|
Modifier.fillMaxSize().graphicsLayer {
|
||||||
CarScreen.Apps -> CarLauncher()
|
if (!fromHome) alpha = enter.value
|
||||||
CarScreen.Media -> mediaApp?.let { MediaScreen(it) { navigator.back() } }
|
translationY = (1f - enter.value) * 16.dp.toPx()
|
||||||
CarScreen.Browse -> BrowseAppScreen(navigator)
|
},
|
||||||
CarScreen.Settings -> CarSettingsScreen()
|
) {
|
||||||
CarScreen.Dashboard -> CarDashboardScreen()
|
when (screen) {
|
||||||
CarScreen.Weather -> WeatherScreen()
|
CarScreen.Home -> Unit
|
||||||
CarScreen.VehicleData -> VehicleDataScreen()
|
CarScreen.Apps -> CarLauncher()
|
||||||
is CarScreen.Notifications -> NotificationsScreen(screen.replyTo)
|
CarScreen.Media -> CompositionLocalProvider(
|
||||||
is CarScreen.Messages -> MessagesScreen(screen.packageName)
|
LocalNavBarInset provides if (!keyboardVisible && !fullscreen) NAV_BAR_HEIGHT else 0.dp,
|
||||||
is CarScreen.App -> CarApps.find(screen.id)?.content?.invoke()
|
) { 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))
|
CarToastOverlay(Modifier.align(Alignment.BottomCenter).padding(bottom = homeInset))
|
||||||
|
CarAlertOverlay(Modifier.align(Alignment.TopCenter))
|
||||||
CallPromptOverlay()
|
CallPromptOverlay()
|
||||||
BluetoothPairOverlay()
|
BluetoothPairOverlay()
|
||||||
PopupTimer()
|
PopupTimer()
|
||||||
@@ -325,7 +351,7 @@ fun CarUi() {
|
|||||||
// whatever the screen drew there.
|
// whatever the screen drew there.
|
||||||
DebugBuildBadge(Modifier.align(Alignment.BottomEnd).padding(bottom = homeInset))
|
DebugBuildBadge(Modifier.align(Alignment.BottomEnd).padding(bottom = homeInset))
|
||||||
}
|
}
|
||||||
if (floatingBar || fullscreen) {
|
run {
|
||||||
androidx.compose.animation.AnimatedVisibility(
|
androidx.compose.animation.AnimatedVisibility(
|
||||||
if (fullscreen) CarFullscreen.revealed || onCall else barShown,
|
if (fullscreen) CarFullscreen.revealed || onCall else barShown,
|
||||||
Modifier.align(Alignment.BottomCenter),
|
Modifier.align(Alignment.BottomCenter),
|
||||||
@@ -343,7 +369,7 @@ fun CarUi() {
|
|||||||
GsButton(onClick = CarFullscreen::exit, modifier = Modifier.height(48.dp)) {
|
GsButton(onClick = CarFullscreen::exit, modifier = Modifier.height(48.dp)) {
|
||||||
Icon(MediaIcons.FullscreenExit, contentDescription = null, modifier = Modifier.size(22.dp))
|
Icon(MediaIcons.FullscreenExit, contentDescription = null, modifier = Modifier.size(22.dp))
|
||||||
Spacer(Modifier.width(8.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(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
) {
|
) {
|
||||||
Column(Modifier.padding(24.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) {
|
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(
|
Text(
|
||||||
"Gearslip is unofficial software: it is not made or reviewed by any " +
|
stringResource(R.string.carui_unofficial),
|
||||||
"carmaker, and it can fail without warning.",
|
|
||||||
style = ChromeType.body,
|
style = ChromeType.body,
|
||||||
)
|
)
|
||||||
Text(
|
Text(
|
||||||
"Never interact with this screen while driving. Set the route or the " +
|
stringResource(R.string.carui_never_interact),
|
||||||
"song before you go, then keep your attention on the road.",
|
|
||||||
style = ChromeType.body,
|
style = ChromeType.body,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Spacer(Modifier.height(24.dp))
|
Spacer(Modifier.height(24.dp))
|
||||||
GsButton(onClick = onDone, modifier = Modifier.fillMaxWidth().height(56.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
|
val failed = status.phase == CarAppConnection.Phase.REJECTED || status.phase == CarAppConnection.Phase.FAILED
|
||||||
if (failed || timedOut) {
|
if (failed || timedOut) {
|
||||||
AppWontStart(status.app ?: "This app", leave)
|
AppWontStart(status.app ?: stringResource(R.string.carui_this_app), leave)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
CarAppStage(CarServices.browse, status, frame, onDisconnect = leave)
|
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),
|
verticalArrangement = Arrangement.spacedBy(12.dp, Alignment.CenterVertically),
|
||||||
horizontalAlignment = Alignment.CenterHorizontally,
|
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(
|
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,
|
style = MaterialTheme.typography.bodyLarge,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
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
|
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.background
|
||||||
import androidx.compose.foundation.clickable
|
import androidx.compose.foundation.clickable
|
||||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||||
@@ -76,7 +78,7 @@ fun CarWhatsNew() {
|
|||||||
) {
|
) {
|
||||||
Column(Modifier.padding(20.dp), verticalArrangement = Arrangement.spacedBy(14.dp)) {
|
Column(Modifier.padding(20.dp), verticalArrangement = Arrangement.spacedBy(14.dp)) {
|
||||||
Text(
|
Text(
|
||||||
"What's new in ${release.versionName}",
|
stringResource(R.string.whatsnew_title, release.versionName),
|
||||||
style = MaterialTheme.typography.headlineSmall,
|
style = MaterialTheme.typography.headlineSmall,
|
||||||
fontWeight = FontWeight.Bold,
|
fontWeight = FontWeight.Bold,
|
||||||
)
|
)
|
||||||
@@ -99,12 +101,12 @@ fun CarWhatsNew() {
|
|||||||
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(14.dp)) {
|
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(14.dp)) {
|
||||||
GsSwitch(afterUpdates, CarSettings::setWhatsNewAfterUpdates)
|
GsSwitch(afterUpdates, CarSettings::setWhatsNewAfterUpdates)
|
||||||
Text(
|
Text(
|
||||||
"Show after updates",
|
stringResource(R.string.whatsnew_show_after),
|
||||||
style = MaterialTheme.typography.titleMedium,
|
style = MaterialTheme.typography.titleMedium,
|
||||||
modifier = Modifier.weight(1f),
|
modifier = Modifier.weight(1f),
|
||||||
)
|
)
|
||||||
GsButton(onClick = close, modifier = Modifier.height(56.dp)) {
|
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 androidx.car.app.navigation.model.Step
|
||||||
import app.seb3thehacker.gearslip.GearslipLog
|
import app.seb3thehacker.gearslip.GearslipLog
|
||||||
import app.seb3thehacker.gearslip.Protobuf
|
import app.seb3thehacker.gearslip.Protobuf
|
||||||
|
import app.seb3thehacker.gearslip.notify.PhoneNavigation
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The next turn on the car's instrument cluster, over its NavigationStatusService (Service
|
* 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. */
|
/** The map app's next turn: from its trip (what clusters get), or its routing card. */
|
||||||
fun current(): NavTurn? {
|
fun current(): NavTurn? {
|
||||||
val nav = CarServices.navOrNull() ?: return null
|
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 ->
|
nav.trip.value?.let { trip ->
|
||||||
val step = trip.steps.firstOrNull() ?: return@let
|
val step = trip.steps.firstOrNull() ?: return@let
|
||||||
val estimate = trip.stepTravelEstimates.firstOrNull()
|
val estimate = trip.stepTravelEstimates.firstOrNull()
|
||||||
|
|||||||
@@ -0,0 +1,299 @@
|
|||||||
|
package app.seb3thehacker.gearslip.car
|
||||||
|
|
||||||
|
import android.content.ComponentName
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
import android.view.SurfaceHolder
|
||||||
|
import android.view.SurfaceView
|
||||||
|
import androidx.car.app.CarAppService
|
||||||
|
import androidx.car.app.SessionInfo
|
||||||
|
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.Box
|
||||||
|
import androidx.compose.foundation.layout.fillMaxHeight
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
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.DisposableEffect
|
||||||
|
import androidx.compose.runtime.key
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.collectAsState
|
||||||
|
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.LocalContext
|
||||||
|
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 androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.viewinterop.AndroidView
|
||||||
|
import app.seb3thehacker.gearslip.Insets
|
||||||
|
import app.seb3thehacker.gearslip.host.CarAppConnection
|
||||||
|
import app.seb3thehacker.gearslip.host.TemplateApp
|
||||||
|
import app.seb3thehacker.gearslip.host.clockTime
|
||||||
|
import app.seb3thehacker.gearslip.host.display
|
||||||
|
import app.seb3thehacker.gearslip.host.remainingTime
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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. Many cars also draw their own dials and bars over what's left, so the
|
||||||
|
* card keeps to [clearArea] when the car says where that is, and otherwise to the middle of a
|
||||||
|
* wide strip: on an Audi's virtual cockpit it ran under the rev counter and the bottom bar (#56).
|
||||||
|
*
|
||||||
|
* When the main screen's navigation app has a session for clusters, it draws its map to the
|
||||||
|
* right of the card, on a surface Gearslip lends that session as it does the main one.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
internal fun ClusterScreen(marginX: Int, marginY: Int, densityDpi: Int, clearArea: StateFlow<Insets?>) {
|
||||||
|
val carInsets by clearArea.collectAsState()
|
||||||
|
val context = LocalContext.current
|
||||||
|
// Which app draws the map: whatever navigation app the main screen runs, if it can.
|
||||||
|
val navApp by produceState<ComponentName?>(null) {
|
||||||
|
while (true) {
|
||||||
|
value = CarServices.navOrNull()?.connectedComponent
|
||||||
|
delay(2_000)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val mapApp = navApp?.let { app -> remember(app) { app.takeIf { drawsOnCluster(context, it) } } }
|
||||||
|
// 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() },
|
||||||
|
vertical = with(density) { (marginY / 2).toDp() },
|
||||||
|
),
|
||||||
|
) {
|
||||||
|
val clear = clearPadding(maxWidth, maxHeight, carInsets?.let { with(density) { toDp(it) } })
|
||||||
|
BoxWithConstraints(
|
||||||
|
Modifier.fillMaxSize().padding(start = clear[0], top = clear[1], end = clear[2], bottom = clear[3]),
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
) {
|
||||||
|
val t = turn
|
||||||
|
val map = mapApp?.let { clusterMap(it, densityDpi, constraints.maxWidth, constraints.maxHeight) }
|
||||||
|
if (map != null && mapShowing(map)) {
|
||||||
|
Row(Modifier.fillMaxSize(), verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
if (t != null) {
|
||||||
|
BoxWithConstraints(Modifier.weight(0.42f).fillMaxHeight(), contentAlignment = Alignment.CenterStart) {
|
||||||
|
// Narrower than alone: the arrow and distance alone are about 12 units wide.
|
||||||
|
TurnCard(t, minOf(maxHeight / t.rows, maxWidth / 12))
|
||||||
|
}
|
||||||
|
Spacer(Modifier.width(12.dp))
|
||||||
|
}
|
||||||
|
MapSurface(map, densityDpi, Modifier.weight(0.58f).fillMaxHeight())
|
||||||
|
}
|
||||||
|
return@BoxWithConstraints
|
||||||
|
}
|
||||||
|
if (t == null) {
|
||||||
|
NowPlayingLine(Modifier.align(Alignment.Center))
|
||||||
|
return@BoxWithConstraints
|
||||||
|
}
|
||||||
|
// The card is t.rows units tall and about 14 wide: fit both ways.
|
||||||
|
TurnCard(t, minOf(maxHeight / t.rows, maxWidth / 14))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The navigation app's cluster session, open while this is on screen. Sized for the clear area
|
||||||
|
* it shares with the card, so the app lays its map out for a cluster-shaped screen.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
private fun clusterMap(app: ComponentName, densityDpi: Int, width: Int, height: Int): CarAppConnection {
|
||||||
|
val context = LocalContext.current.applicationContext
|
||||||
|
val connection = remember(app) {
|
||||||
|
CarAppConnection(context, SessionInfo(SessionInfo.DISPLAY_TYPE_CLUSTER, CLUSTER_SESSION))
|
||||||
|
}
|
||||||
|
DisposableEffect(connection) {
|
||||||
|
connection.connect(TemplateApp(app, app.packageName, emptyList()), (width * 0.58f).toInt(), height, densityDpi)
|
||||||
|
onDispose { connection.disconnect() }
|
||||||
|
}
|
||||||
|
return connection
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether the map's place is kept: while the session comes up, and once running only if the app
|
||||||
|
* asked for a surface. An app whose cluster session draws nothing leaves the card on its own.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
private fun mapShowing(map: CarAppConnection): Boolean {
|
||||||
|
val status by map.status.collectAsState()
|
||||||
|
return when (status.phase) {
|
||||||
|
CarAppConnection.Phase.BINDING, CarAppConnection.Phase.HANDSHAKE -> true
|
||||||
|
CarAppConnection.Phase.RUNNING -> status.surfaceRequested || status.template == null
|
||||||
|
else -> false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A surface lent to the cluster session; a fresh one for every size, as on the main screen. */
|
||||||
|
@Composable
|
||||||
|
private fun MapSurface(map: CarAppConnection, densityDpi: Int, modifier: Modifier) {
|
||||||
|
BoxWithConstraints(modifier) {
|
||||||
|
key(constraints.maxWidth, constraints.maxHeight) {
|
||||||
|
AndroidView(
|
||||||
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
factory = { context ->
|
||||||
|
SurfaceView(context).apply {
|
||||||
|
holder.addCallback(object : SurfaceHolder.Callback {
|
||||||
|
override fun surfaceCreated(holder: SurfaceHolder) = Unit
|
||||||
|
override fun surfaceChanged(holder: SurfaceHolder, format: Int, width: Int, height: Int) =
|
||||||
|
map.attachSurface(holder.surface, width, height, densityDpi)
|
||||||
|
override fun surfaceDestroyed(holder: SurfaceHolder) = map.detachSurface(holder.surface)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
DisposableEffect(map) { onDispose { map.detachSurface() } }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Whether [component] says, in its manifest, that it has a session for instrument clusters. */
|
||||||
|
private fun drawsOnCluster(context: Context, component: ComponentName): Boolean = runCatching {
|
||||||
|
val intent = Intent(CarAppService.SERVICE_INTERFACE).addCategory(FEATURE_CLUSTER).setPackage(component.packageName)
|
||||||
|
context.packageManager.queryIntentServices(intent, 0).any { it.serviceInfo.name == component.className }
|
||||||
|
}.getOrDefault(false)
|
||||||
|
|
||||||
|
private const val FEATURE_CLUSTER = "androidx.car.app.category.FEATURE_CLUSTER"
|
||||||
|
private const val CLUSTER_SESSION = "gearslip-cluster"
|
||||||
|
|
||||||
|
/** The lane to be in, on the cockpit: green, like the main screen's header, against the black. */
|
||||||
|
private val LANE_FILL = Color(0xFF2F7D6D)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Start, top, end and bottom padding around the card inside the visible picture. The car's own
|
||||||
|
* insets win when they leave a usable area; a wide strip, where dials sit at both ends and a bar
|
||||||
|
* along the bottom, keeps to the upper middle; anything squarer just keeps off the edges.
|
||||||
|
*/
|
||||||
|
private fun clearPadding(width: Dp, height: Dp, car: List<Dp>?): List<Dp> {
|
||||||
|
if (car != null && width - car[0] - car[2] >= width * 0.3f && height - car[1] - car[3] >= height * 0.3f) return car
|
||||||
|
if (width >= height * 2) return listOf(width * 0.22f, height * 0.08f, width * 0.22f, height * 0.33f)
|
||||||
|
return listOf(24.dp, 16.dp, 24.dp, 16.dp)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Insets in frame pixels as start, top, end and bottom padding. */
|
||||||
|
private fun androidx.compose.ui.unit.Density.toDp(insets: Insets): List<Dp> =
|
||||||
|
listOf(insets.left.toDp(), insets.top.toDp(), insets.right.toDp(), insets.bottom.toDp())
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun TurnCard(t: ClusterTurn, unit: Dp) {
|
||||||
|
val density = LocalDensity.current
|
||||||
|
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.lanes.isNotEmpty()) {
|
||||||
|
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||||
|
LaneRoad(t.lanes, Modifier.width(unit * 10).height(unit * 2.4f), LANE_FILL, Color.White, Color.Black)
|
||||||
|
laneHint(t.lanes)?.let {
|
||||||
|
Text(
|
||||||
|
it,
|
||||||
|
Modifier.padding(top = unit * 0.3f),
|
||||||
|
fontSize = with(density) { (unit * 0.8f).toSp() },
|
||||||
|
color = Color.White.copy(alpha = 0.85f),
|
||||||
|
maxLines = 1,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
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,
|
||||||
|
val lanes: List<androidx.car.app.navigation.model.Lane>,
|
||||||
|
) {
|
||||||
|
/** How many units tall the card is: two lines of directions, and the lanes when there are some. */
|
||||||
|
val rows: Float get() = if (lanes.isEmpty()) 8.5f else 12f
|
||||||
|
|
||||||
|
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, step.lanes.orEmpty())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,7 +1,11 @@
|
|||||||
package app.seb3thehacker.gearslip.car
|
package app.seb3thehacker.gearslip.car
|
||||||
|
|
||||||
|
import app.seb3thehacker.gearslip.R
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.os.Bundle
|
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.TextToSpeech
|
||||||
import android.speech.tts.UtteranceProgressListener
|
import android.speech.tts.UtteranceProgressListener
|
||||||
import androidx.car.app.model.CarText
|
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 androidx.car.app.navigation.model.Step
|
||||||
import app.seb3thehacker.gearslip.GearslipLog
|
import app.seb3thehacker.gearslip.GearslipLog
|
||||||
import app.seb3thehacker.gearslip.audio.AudioLink
|
import app.seb3thehacker.gearslip.audio.AudioLink
|
||||||
|
import app.seb3thehacker.gearslip.audio.CarAudio
|
||||||
import java.io.File
|
import java.io.File
|
||||||
import java.nio.ByteBuffer
|
import java.nio.ByteBuffer
|
||||||
import java.nio.ByteOrder
|
import java.nio.ByteOrder
|
||||||
@@ -39,11 +44,16 @@ object DirectionsVoice {
|
|||||||
private var appContext: Context? = null
|
private var appContext: Context? = null
|
||||||
private var tts: TextToSpeech? = null
|
private var tts: TextToSpeech? = null
|
||||||
@Volatile private var ttsReady = false
|
@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 pending = ConcurrentHashMap<String, CountDownLatch>()
|
||||||
|
private val main = Handler(Looper.getMainLooper())
|
||||||
|
|
||||||
@Volatile private var link: AudioLink? = null
|
@Volatile private var link: AudioLink? = null
|
||||||
@Volatile private var running = false
|
@Volatile private var running = false
|
||||||
@Volatile private var musicIsOurs: () -> Boolean = { 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 } }
|
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. */
|
/** Counts announcements, so a hand-back scheduled after one is dropped if another has begun. */
|
||||||
private var spoken = 0
|
private var spoken = 0
|
||||||
@@ -55,7 +65,19 @@ object DirectionsVoice {
|
|||||||
fun init(context: Context) {
|
fun init(context: Context) {
|
||||||
if (tts != null) return
|
if (tts != null) return
|
||||||
appContext = context.applicationContext
|
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() {
|
setOnUtteranceProgressListener(object : UtteranceProgressListener() {
|
||||||
override fun onStart(utteranceId: String?) {}
|
override fun onStart(utteranceId: String?) {}
|
||||||
override fun onDone(utteranceId: String?) { pending.remove(utteranceId)?.countDown() }
|
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. */
|
/** 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.link = link
|
||||||
this.musicIsOurs = musicIsOurs
|
this.musicIsOurs = musicIsOurs
|
||||||
|
this.musicSink = musicSink
|
||||||
GearslipLog.i("guidance: the car offered a directions sink at ${link.sampleRate}Hz x${link.channels}")
|
GearslipLog.i("guidance: the car offered a directions sink at ${link.sampleRate}Hz x${link.channels}")
|
||||||
if (running) return
|
if (running) return
|
||||||
running = true
|
running = true
|
||||||
@@ -121,7 +154,7 @@ object DirectionsVoice {
|
|||||||
say(instruction)
|
say(instruction)
|
||||||
stage = Stage.DONE
|
stage = Stage.DONE
|
||||||
} else {
|
} 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
|
stage = if (meters != null && meters > PREPARE_FROM_METERS) Stage.FAR else Stage.PREPARED
|
||||||
}
|
}
|
||||||
return
|
return
|
||||||
@@ -132,7 +165,7 @@ object DirectionsVoice {
|
|||||||
stage = Stage.DONE
|
stage = Stage.DONE
|
||||||
}
|
}
|
||||||
stage == Stage.FAR && meters != null && meters <= PREPARE_AT_METERS -> {
|
stage == Stage.FAR && meters != null && meters <= PREPARE_AT_METERS -> {
|
||||||
say("In ${spoken(turn.distance)}, $instruction")
|
say(ahead(turn.distance, instruction))
|
||||||
stage = Stage.PREPARED
|
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. */
|
/** 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? {
|
private fun instruction(step: Step): String? {
|
||||||
|
val res = appContext?.resources ?: return null
|
||||||
val road = step.road.text()
|
val road = step.road.text()
|
||||||
val onto = if (road.isBlank()) "" else " onto $road"
|
// Each manoeuvre is a whole sentence, with and without the road, so a translation can
|
||||||
val toward = if (road.isBlank()) "" else " toward $road"
|
// 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 maneuver = step.maneuver
|
||||||
val built = when (maneuver?.type) {
|
val built = when (maneuver?.type) {
|
||||||
Maneuver.TYPE_TURN_SLIGHT_LEFT -> "Turn slightly left$onto"
|
Maneuver.TYPE_TURN_SLIGHT_LEFT -> say(R.string.dir_slight_left, R.string.dir_slight_left_onto)
|
||||||
Maneuver.TYPE_TURN_SLIGHT_RIGHT -> "Turn slightly right$onto"
|
Maneuver.TYPE_TURN_SLIGHT_RIGHT -> say(R.string.dir_slight_right, R.string.dir_slight_right_onto)
|
||||||
Maneuver.TYPE_TURN_NORMAL_LEFT -> "Turn left$onto"
|
Maneuver.TYPE_TURN_NORMAL_LEFT -> say(R.string.dir_left, R.string.dir_left_onto)
|
||||||
Maneuver.TYPE_TURN_NORMAL_RIGHT -> "Turn right$onto"
|
Maneuver.TYPE_TURN_NORMAL_RIGHT -> say(R.string.dir_right, R.string.dir_right_onto)
|
||||||
Maneuver.TYPE_TURN_SHARP_LEFT -> "Turn sharply left$onto"
|
Maneuver.TYPE_TURN_SHARP_LEFT -> say(R.string.dir_sharp_left, R.string.dir_sharp_left_onto)
|
||||||
Maneuver.TYPE_TURN_SHARP_RIGHT -> "Turn sharply right$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 -> "Make a U-turn"
|
Maneuver.TYPE_U_TURN_LEFT, Maneuver.TYPE_U_TURN_RIGHT -> say(R.string.dir_u_turn, 0)
|
||||||
Maneuver.TYPE_KEEP_LEFT -> "Keep left$toward"
|
Maneuver.TYPE_KEEP_LEFT -> say(R.string.dir_keep_left, R.string.dir_keep_left_toward)
|
||||||
Maneuver.TYPE_KEEP_RIGHT -> "Keep right$toward"
|
Maneuver.TYPE_KEEP_RIGHT -> say(R.string.dir_keep_right, R.string.dir_keep_right_toward)
|
||||||
Maneuver.TYPE_FORK_LEFT -> "Keep left at the fork$toward"
|
Maneuver.TYPE_FORK_LEFT -> say(R.string.dir_fork_left, R.string.dir_fork_left_toward)
|
||||||
Maneuver.TYPE_FORK_RIGHT -> "Keep right at the fork$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_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_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_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 -> "Take the exit on the left$toward"
|
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 -> "Take the exit on the right$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 -> "Merge left$onto"
|
Maneuver.TYPE_MERGE_LEFT -> say(R.string.dir_merge_left, R.string.dir_merge_left_onto)
|
||||||
Maneuver.TYPE_MERGE_RIGHT -> "Merge right$onto"
|
Maneuver.TYPE_MERGE_RIGHT -> say(R.string.dir_merge_right, R.string.dir_merge_right_onto)
|
||||||
Maneuver.TYPE_MERGE_SIDE_UNSPECIFIED -> "Merge$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_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.TYPE_ROUNDABOUT_ENTER_AND_EXIT_CCW, Maneuver.TYPE_ROUNDABOUT_ENTER_AND_EXIT_CCW_WITH_ANGLE ->
|
||||||
maneuver.roundaboutExitNumber.takeIf { it in 1..ORDINALS.size }
|
maneuver.roundaboutExitNumber.takeIf { it in 1..ORDINALS.size }
|
||||||
?.let { "At the roundabout, take the ${ORDINALS[it - 1]} exit$onto" }
|
?.let { res.getString(ORDINALS[it - 1]) }
|
||||||
?: "Go through the roundabout$onto"
|
?.let { exit ->
|
||||||
Maneuver.TYPE_ROUNDABOUT_ENTER_CW, Maneuver.TYPE_ROUNDABOUT_ENTER_CCW -> "Enter the roundabout"
|
if (road.isBlank()) res.getString(R.string.dir_roundabout_take, exit)
|
||||||
Maneuver.TYPE_ROUNDABOUT_EXIT_CW, Maneuver.TYPE_ROUNDABOUT_EXIT_CCW -> "Exit the roundabout$onto"
|
else res.getString(R.string.dir_roundabout_take_onto, exit, road)
|
||||||
Maneuver.TYPE_STRAIGHT -> "Continue straight$onto"
|
}
|
||||||
Maneuver.TYPE_NAME_CHANGE -> "Continue$onto"
|
?: say(R.string.dir_roundabout_through, R.string.dir_roundabout_through_onto)
|
||||||
Maneuver.TYPE_FERRY_BOAT, Maneuver.TYPE_FERRY_BOAT_LEFT, Maneuver.TYPE_FERRY_BOAT_RIGHT -> "Take the ferry"
|
Maneuver.TYPE_ROUNDABOUT_ENTER_CW, Maneuver.TYPE_ROUNDABOUT_ENTER_CCW -> say(R.string.dir_roundabout_enter, 0)
|
||||||
Maneuver.TYPE_FERRY_TRAIN, Maneuver.TYPE_FERRY_TRAIN_LEFT, Maneuver.TYPE_FERRY_TRAIN_RIGHT -> "Take the train"
|
Maneuver.TYPE_ROUNDABOUT_EXIT_CW, Maneuver.TYPE_ROUNDABOUT_EXIT_CCW -> say(R.string.dir_roundabout_exit, R.string.dir_roundabout_exit_onto)
|
||||||
Maneuver.TYPE_DESTINATION, Maneuver.TYPE_DESTINATION_STRAIGHT -> "Your destination is ahead"
|
Maneuver.TYPE_STRAIGHT -> say(R.string.dir_straight, R.string.dir_straight_onto)
|
||||||
Maneuver.TYPE_DESTINATION_LEFT -> "Your destination is on the left"
|
Maneuver.TYPE_NAME_CHANGE -> say(R.string.dir_continue, R.string.dir_continue_onto)
|
||||||
Maneuver.TYPE_DESTINATION_RIGHT -> "Your destination is on the right"
|
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
|
else -> null
|
||||||
}
|
}
|
||||||
return built ?: step.cue.text().ifBlank { 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. */
|
/** The app's own rounded distance, read the way a person would say it. */
|
||||||
private fun spoken(d: Distance): String {
|
private fun spoken(d: Distance): String {
|
||||||
|
val res = appContext?.resources ?: return ""
|
||||||
val n = d.displayDistance
|
val n = d.displayDistance
|
||||||
val number = if (n % 1.0 == 0.0) n.toInt().toString() else "%.1f".format(n)
|
val whole = n % 1.0 == 0.0
|
||||||
val one = n == 1.0
|
val number = if (whole) n.toInt().toString() else "%.1f".format(n)
|
||||||
return when (d.displayUnit) {
|
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) {
|
Distance.UNIT_MILES, Distance.UNIT_MILES_P1 -> when (n) {
|
||||||
0.25 -> "a quarter mile"
|
0.25 -> res.getString(R.string.dir_quarter_mile)
|
||||||
0.5 -> "half a mile"
|
0.5 -> res.getString(R.string.dir_half_mile)
|
||||||
0.75 -> "three quarters of a mile"
|
0.75 -> res.getString(R.string.dir_three_quarter_mile)
|
||||||
1.0 -> "1 mile"
|
else -> if (whole) res.getQuantityString(R.plurals.dir_miles, n.toInt(), number) else res.getString(R.string.dir_miles_decimal, number)
|
||||||
else -> "$number miles"
|
|
||||||
}
|
}
|
||||||
Distance.UNIT_FEET -> "$number feet"
|
Distance.UNIT_FEET -> res.getQuantityString(R.plurals.dir_feet, n.toInt(), number)
|
||||||
Distance.UNIT_YARDS -> "$number yards"
|
Distance.UNIT_YARDS -> res.getQuantityString(R.plurals.dir_yards, n.toInt(), number)
|
||||||
else -> "${n.roundToInt()} meters"
|
else -> res.getQuantityString(R.plurals.dir_meters, n.roundToInt(), n.roundToInt())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -219,6 +264,16 @@ object DirectionsVoice {
|
|||||||
val sink = link ?: return
|
val sink = link ?: return
|
||||||
if (!ttsReady) {
|
if (!ttsReady) {
|
||||||
GearslipLog.w("guidance: text-to-speech isn't ready, a turn went unspoken")
|
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
|
return
|
||||||
}
|
}
|
||||||
val file = File(context.cacheDir, "directions.wav")
|
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")
|
GearslipLog.w("guidance: text-to-speech took too long, a turn went unspoken")
|
||||||
return
|
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")
|
GearslipLog.w("guidance: text-to-speech gave audio Gearslip can't read")
|
||||||
return
|
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.
|
// Logged before speaking too, so a car that dies mid-announcement shows what it was doing.
|
||||||
GearslipLog.i("guidance: speaking a turn")
|
GearslipLog.i("guidance: speaking a turn")
|
||||||
// Held through back-to-back prompts rather than asked for and given back each time.
|
// 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)")
|
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. */
|
/** 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 frame = 2 * sink.channels
|
||||||
val chunk = sink.sampleRate / 50 * frame
|
val chunk = sink.sampleRate / 50 * frame
|
||||||
val chunkMs = 20L
|
val chunkMs = 20L
|
||||||
val padded = pcm + ByteArray(sink.sampleRate / 5 * frame) // 200 ms tail so the last word isn't clipped
|
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 {
|
try {
|
||||||
val began = System.currentTimeMillis()
|
val began = System.currentTimeMillis()
|
||||||
var offset = 0
|
var offset = 0
|
||||||
var sent = 0
|
var sent = 0
|
||||||
while (offset < padded.size && link === sink) {
|
while (offset < padded.size && alive()) {
|
||||||
val size = minOf(chunk, padded.size - offset)
|
val size = minOf(chunk, padded.size - offset)
|
||||||
sink.write(padded.copyOfRange(offset, offset + size), size)
|
sink.write(padded.copyOfRange(offset, offset + size), size)
|
||||||
offset += size
|
offset += size
|
||||||
@@ -273,7 +360,7 @@ object DirectionsVoice {
|
|||||||
// Let the car play what it has before closing the stream on it.
|
// Let the car play what it has before closing the stream on it.
|
||||||
Thread.sleep(LEAD_MS)
|
Thread.sleep(LEAD_MS)
|
||||||
} finally {
|
} finally {
|
||||||
sink.stop()
|
if (startAndStop) sink.stop()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -347,5 +434,9 @@ object DirectionsVoice {
|
|||||||
private const val LEAD_MS = 100L
|
private const val LEAD_MS = 100L
|
||||||
private const val HOLD_MS = 2_000L
|
private const val HOLD_MS = 2_000L
|
||||||
private const val SYNTH_TIMEOUT_S = 10L
|
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,182 @@
|
|||||||
|
package app.seb3thehacker.gearslip.car
|
||||||
|
|
||||||
|
import android.graphics.Color as AndroidColor
|
||||||
|
import androidx.car.app.model.Place
|
||||||
|
import androidx.car.app.model.PlaceListMapTemplate
|
||||||
|
import androidx.car.app.model.Row
|
||||||
|
import androidx.compose.foundation.layout.BoxWithConstraints
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.DisposableEffect
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.graphics.luminance
|
||||||
|
import androidx.compose.ui.graphics.toArgb
|
||||||
|
import androidx.compose.ui.platform.LocalDensity
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.viewinterop.AndroidView
|
||||||
|
import app.seb3thehacker.gearslip.GearslipLog
|
||||||
|
import org.maplibre.android.MapLibre
|
||||||
|
import org.maplibre.android.camera.CameraUpdateFactory
|
||||||
|
import org.maplibre.android.geometry.LatLng
|
||||||
|
import org.maplibre.android.geometry.LatLngBounds
|
||||||
|
import org.maplibre.android.maps.MapLibreMap
|
||||||
|
import org.maplibre.android.maps.MapLibreMapOptions
|
||||||
|
import org.maplibre.android.maps.MapView
|
||||||
|
import org.maplibre.android.maps.Style
|
||||||
|
import org.maplibre.android.style.expressions.Expression
|
||||||
|
import org.maplibre.android.style.layers.CircleLayer
|
||||||
|
import org.maplibre.android.style.layers.PropertyFactory
|
||||||
|
import org.maplibre.android.style.layers.SymbolLayer
|
||||||
|
import org.maplibre.android.style.sources.GeoJsonSource
|
||||||
|
import org.maplibre.geojson.Feature
|
||||||
|
import org.maplibre.geojson.FeatureCollection
|
||||||
|
import org.maplibre.geojson.Point
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The map behind a [PlaceListMapTemplate]. For this template the host draws the map, not the
|
||||||
|
* app: Android Auto puts Google's map there, with a numbered pin for each place in the list and
|
||||||
|
* the anchor marked. RF Sentinel lists its nearby places this way, and with nothing drawn the
|
||||||
|
* area stayed empty.
|
||||||
|
*
|
||||||
|
* Tiles come from OpenFreeMap: free, no key, and no account, so nothing ties a drive to anyone.
|
||||||
|
* The camera fits every pin into the part of the map the list card doesn't cover.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
internal fun HostPlaceMap(template: PlaceListMapTemplate, modifier: Modifier = Modifier) {
|
||||||
|
val dark = MaterialTheme.colorScheme.background.luminance() < 0.5f
|
||||||
|
val pin = MaterialTheme.colorScheme.primary.toArgb()
|
||||||
|
val pinText = MaterialTheme.colorScheme.onPrimary.toArgb()
|
||||||
|
val anchorColor = MaterialTheme.colorScheme.tertiary.toArgb()
|
||||||
|
val cardsAtEnd = LocalCardsAtEnd.current
|
||||||
|
val places = remember(template) { placesOf(template) }
|
||||||
|
var map by remember { mutableStateOf<MapLibreMap?>(null) }
|
||||||
|
var style by remember { mutableStateOf<Style?>(null) }
|
||||||
|
|
||||||
|
BoxWithConstraints(modifier) {
|
||||||
|
val density = LocalDensity.current
|
||||||
|
// Keep the pins out from under the list card, the way the app's own map would.
|
||||||
|
val card = with(density) { cardColumnWidth(maxWidth).roundToPx() }
|
||||||
|
val margin = with(density) { 48.dp.roundToPx() }
|
||||||
|
|
||||||
|
AndroidView(
|
||||||
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
factory = { context ->
|
||||||
|
MapLibre.getInstance(context)
|
||||||
|
// A TextureView, so the map composites with the cards drawn over it inside the
|
||||||
|
// car's virtual display, as any other view does.
|
||||||
|
val options = MapLibreMapOptions.createFromAttributes(context).textureMode(true)
|
||||||
|
MapView(context, options).apply {
|
||||||
|
onCreate(null)
|
||||||
|
onStart()
|
||||||
|
onResume()
|
||||||
|
getMapAsync { m ->
|
||||||
|
m.uiSettings.isLogoEnabled = false
|
||||||
|
m.uiSettings.isCompassEnabled = false
|
||||||
|
m.uiSettings.isRotateGesturesEnabled = false
|
||||||
|
m.uiSettings.isTiltGesturesEnabled = false
|
||||||
|
map = m
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onRelease = { view ->
|
||||||
|
runCatching {
|
||||||
|
view.onPause()
|
||||||
|
view.onStop()
|
||||||
|
view.onDestroy()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
// The style follows the car's theme; the pins go in once it's loaded.
|
||||||
|
LaunchedEffect(map, dark) {
|
||||||
|
val m = map ?: return@LaunchedEffect
|
||||||
|
style = null
|
||||||
|
m.setStyle(Style.Builder().fromUri(if (dark) DARK_STYLE else LIGHT_STYLE)) { loaded -> style = loaded }
|
||||||
|
}
|
||||||
|
LaunchedEffect(style, places, pin, card, cardsAtEnd) {
|
||||||
|
val m = map ?: return@LaunchedEffect
|
||||||
|
val s = style ?: return@LaunchedEffect
|
||||||
|
showPins(s, places, pin, pinText, anchorColor)
|
||||||
|
val points = places.map { it.latLng }
|
||||||
|
when {
|
||||||
|
points.isEmpty() -> Unit
|
||||||
|
points.size == 1 -> m.moveCamera(CameraUpdateFactory.newLatLngZoom(points.first(), SINGLE_PLACE_ZOOM))
|
||||||
|
else -> {
|
||||||
|
val bounds = LatLngBounds.Builder().includes(points).build()
|
||||||
|
val left = if (cardsAtEnd) margin else card + margin
|
||||||
|
val right = if (cardsAtEnd) card + margin else margin
|
||||||
|
runCatching { m.moveCamera(CameraUpdateFactory.newLatLngBounds(bounds, left, margin, right, margin)) }
|
||||||
|
.onFailure { GearslipLog.w("host map: couldn't fit ${points.size} places: ${it.message}") }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
DisposableEffect(Unit) { onDispose { map = null } }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One pin: where, and the label on it (the list's number, or the app's own marker text). */
|
||||||
|
private class Pin(val latLng: LatLng, val label: String, val anchor: Boolean)
|
||||||
|
|
||||||
|
private fun placesOf(template: PlaceListMapTemplate): List<Pin> {
|
||||||
|
val rows = template.itemList?.items.orEmpty().filterIsInstance<Row>()
|
||||||
|
val pins = rows.mapIndexedNotNull { i, row ->
|
||||||
|
val place = row.metadata?.place ?: return@mapIndexedNotNull null
|
||||||
|
pinOf(place, (i + 1).toString(), anchor = false)
|
||||||
|
}
|
||||||
|
return pins + listOfNotNull(template.anchor?.let { pinOf(it, "", anchor = true) })
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun pinOf(place: Place, fallback: String, anchor: Boolean): Pin? {
|
||||||
|
val location = place.location
|
||||||
|
if (location.latitude == 0.0 && location.longitude == 0.0) return null
|
||||||
|
val label = place.marker?.label?.toString()?.takeIf { it.isNotBlank() } ?: fallback
|
||||||
|
return Pin(LatLng(location.latitude, location.longitude), label, anchor)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun showPins(style: Style, pins: List<Pin>, pin: Int, pinText: Int, anchorColor: Int) {
|
||||||
|
val features = FeatureCollection.fromFeatures(
|
||||||
|
pins.map { p ->
|
||||||
|
Feature.fromGeometry(Point.fromLngLat(p.latLng.longitude, p.latLng.latitude)).apply {
|
||||||
|
addStringProperty("label", p.label)
|
||||||
|
addBooleanProperty("anchor", p.anchor)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
val source = style.getSourceAs<GeoJsonSource>(SOURCE)
|
||||||
|
if (source != null) {
|
||||||
|
source.setGeoJson(features)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
style.addSource(GeoJsonSource(SOURCE, features))
|
||||||
|
style.addLayer(
|
||||||
|
CircleLayer(PIN_LAYER, SOURCE).withProperties(
|
||||||
|
PropertyFactory.circleRadius(Expression.switchCase(Expression.get("anchor"), Expression.literal(9f), Expression.literal(14f))),
|
||||||
|
PropertyFactory.circleColor(Expression.switchCase(Expression.get("anchor"), Expression.color(anchorColor), Expression.color(pin))),
|
||||||
|
PropertyFactory.circleStrokeColor(AndroidColor.WHITE),
|
||||||
|
PropertyFactory.circleStrokeWidth(2f),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
style.addLayer(
|
||||||
|
SymbolLayer(LABEL_LAYER, SOURCE).withProperties(
|
||||||
|
PropertyFactory.textField(Expression.get("label")),
|
||||||
|
PropertyFactory.textFont(arrayOf("Noto Sans Bold")),
|
||||||
|
PropertyFactory.textSize(13f),
|
||||||
|
PropertyFactory.textColor(pinText),
|
||||||
|
PropertyFactory.textAllowOverlap(true),
|
||||||
|
PropertyFactory.textIgnorePlacement(true),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private const val LIGHT_STYLE = "https://tiles.openfreemap.org/styles/positron"
|
||||||
|
private const val DARK_STYLE = "https://tiles.openfreemap.org/styles/dark"
|
||||||
|
private const val SOURCE = "gearslip-places"
|
||||||
|
private const val PIN_LAYER = "gearslip-place-pins"
|
||||||
|
private const val LABEL_LAYER = "gearslip-place-labels"
|
||||||
|
private const val SINGLE_PLACE_ZOOM = 14.0
|
||||||
@@ -0,0 +1,258 @@
|
|||||||
|
package app.seb3thehacker.gearslip.car
|
||||||
|
|
||||||
|
import androidx.car.app.navigation.model.Lane
|
||||||
|
import androidx.car.app.navigation.model.LaneDirection
|
||||||
|
import androidx.compose.foundation.Canvas
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.geometry.Offset
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.graphics.Path
|
||||||
|
import androidx.compose.ui.graphics.PathEffect
|
||||||
|
import androidx.compose.ui.graphics.StrokeCap
|
||||||
|
import androidx.compose.ui.graphics.StrokeJoin
|
||||||
|
import androidx.compose.ui.graphics.drawscope.Stroke
|
||||||
|
import androidx.compose.ui.graphics.lerp
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.semantics.contentDescription
|
||||||
|
import androidx.compose.ui.semantics.semantics
|
||||||
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import app.seb3thehacker.gearslip.R
|
||||||
|
import kotlin.math.PI
|
||||||
|
import kotlin.math.abs
|
||||||
|
import kotlin.math.cos
|
||||||
|
import kotlin.math.sin
|
||||||
|
|
||||||
|
/** The dark band the lanes sit on under the turn card's header, tinted toward the theme. */
|
||||||
|
@Composable
|
||||||
|
internal fun laneBandColor(): Color = lerp(Color(0xFF0E1417), MaterialTheme.colorScheme.primary, 0.14f)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The lanes before a manoeuvre as a dark band under the turn card's header: the road ahead in
|
||||||
|
* perspective, with the lane or lanes to be in filled with the header's colour.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
internal fun LaneBand(lanes: List<Lane>, modifier: Modifier = Modifier) {
|
||||||
|
val scheme = MaterialTheme.colorScheme
|
||||||
|
val band = laneBandColor()
|
||||||
|
LaneRoad(
|
||||||
|
lanes,
|
||||||
|
modifier.background(band).padding(horizontal = 16.dp, vertical = 10.dp).fillMaxWidth().height(52.dp),
|
||||||
|
scheme.primaryContainer,
|
||||||
|
scheme.onPrimaryContainer,
|
||||||
|
band,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Which lanes to use, in words, on the same band: "Use the left lane". Nothing when it can't say. */
|
||||||
|
@Composable
|
||||||
|
internal fun LaneHintLine(lanes: List<Lane>, modifier: Modifier = Modifier) {
|
||||||
|
val hint = laneHint(lanes) ?: return
|
||||||
|
Text(
|
||||||
|
hint,
|
||||||
|
modifier.background(laneBandColor()).padding(start = 16.dp, end = 16.dp, bottom = 10.dp),
|
||||||
|
style = ChromeType.label,
|
||||||
|
color = Color.White,
|
||||||
|
textAlign = TextAlign.Center,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The road ahead, narrowing toward the top: solid edges, dashed lines between lanes, the
|
||||||
|
* recommended lanes filled with [fill] and every lane's arrows drawn from its directions, the
|
||||||
|
* recommended ones in [onFill] and the rest faded toward [ground], the colour behind the road.
|
||||||
|
* The arrows are painted on the road, so they narrow with it. Lanes run left to right, as the app
|
||||||
|
* sends them.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
internal fun LaneRoad(lanes: List<Lane>, modifier: Modifier, fill: Color, onFill: Color, ground: Color, line: Color = Color.White) {
|
||||||
|
val named = lanes.map {
|
||||||
|
stringResource(if (it.recommended()) R.string.nav_lane_recommended else R.string.nav_lane)
|
||||||
|
}.joinToString(", ")
|
||||||
|
Canvas(modifier.semantics { contentDescription = named }) {
|
||||||
|
val n = lanes.size
|
||||||
|
if (n == 0) return@Canvas
|
||||||
|
val h = size.height
|
||||||
|
// Each lane at most a little wider than the road is tall, so two lanes don't fill a whole card.
|
||||||
|
val bottomWidth = minOf(size.width, h * 1.6f * n)
|
||||||
|
val topWidth = bottomWidth * 0.86f
|
||||||
|
fun width(y: Float) = topWidth + (bottomWidth - topWidth) * (y / h)
|
||||||
|
// The x of lane edge [edge] (0 to n, fractions for the middle of a lane) at height [y].
|
||||||
|
fun x(edge: Float, y: Float): Float = size.width / 2 - width(y) / 2 + width(y) * edge / n
|
||||||
|
lanes.forEachIndexed { i, lane ->
|
||||||
|
if (!lane.recommended()) return@forEachIndexed
|
||||||
|
drawPath(
|
||||||
|
Path().apply {
|
||||||
|
moveTo(x(i.toFloat(), 0f), 0f)
|
||||||
|
lineTo(x(i + 1f, 0f), 0f)
|
||||||
|
lineTo(x(i + 1f, h), h)
|
||||||
|
lineTo(x(i.toFloat(), h), h)
|
||||||
|
close()
|
||||||
|
},
|
||||||
|
fill,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
val edge = 2.dp.toPx()
|
||||||
|
drawLine(line, Offset(x(0f, 0f), 0f), Offset(x(0f, h), h), edge)
|
||||||
|
drawLine(line, Offset(x(n.toFloat(), 0f), 0f), Offset(x(n.toFloat(), h), h), edge)
|
||||||
|
val dashes = PathEffect.dashPathEffect(floatArrayOf(h * 0.22f, h * 0.14f))
|
||||||
|
for (i in 1 until n) {
|
||||||
|
drawLine(lerp(ground, line, 0.7f), Offset(x(i.toFloat(), 0f), 0f), Offset(x(i.toFloat(), h), h), edge, pathEffect = dashes)
|
||||||
|
}
|
||||||
|
|
||||||
|
val top = h * 0.12f
|
||||||
|
val bottom = h * 0.9f
|
||||||
|
// Half the room across a lane the arrows may use, as at the bottom: well inside the lines
|
||||||
|
// even where the lane narrows at the top.
|
||||||
|
val room = bottomWidth / n * 0.31f
|
||||||
|
lanes.forEachIndexed { i, lane ->
|
||||||
|
val directions = lane.directions.orEmpty().ifEmpty { listOf(null) }
|
||||||
|
// Faded first, so the one to take draws on top where they share a stem.
|
||||||
|
val arrows = directions.sortedBy { it?.isRecommended == true }
|
||||||
|
.map { it to laneArrow(it?.shape ?: LaneDirection.SHAPE_STRAIGHT, top, bottom) }
|
||||||
|
// The lane's arrows as one shape: centred in the lane, and shrunk toward the stem's
|
||||||
|
// foot when they'd reach past [room].
|
||||||
|
val xs = arrows.flatMap { (_, a) -> a.line.map { it.x } + a.head.map { it.x } }
|
||||||
|
val shift = -(xs.min() + xs.max()) / 2
|
||||||
|
val scale = minOf(1f, room / ((xs.max() - xs.min()) / 2))
|
||||||
|
val lift = (bottom - top) * (1 - scale) / 2
|
||||||
|
// Flat to the road: centred, scaled, then narrowed with the lane toward the top.
|
||||||
|
val onRoad = { p: Offset ->
|
||||||
|
val y = bottom - (bottom - p.y) * scale - lift
|
||||||
|
Offset(x(i + 0.5f, y) + (p.x + shift) * scale * width(y) / bottomWidth, y)
|
||||||
|
}
|
||||||
|
val stroke = (bottom - top) * 0.085f * scale * width((top + bottom) / 2) / bottomWidth
|
||||||
|
val behind = if (lane.recommended()) fill else ground
|
||||||
|
val on = if (lane.recommended()) onFill else line
|
||||||
|
arrows.forEach { (d, arrow) ->
|
||||||
|
// Solid colours, faded by mixing rather than transparency, so where a stem runs
|
||||||
|
// into its head nothing shows through twice.
|
||||||
|
val color = if (d?.isRecommended == true) on else lerp(behind, on, 0.38f)
|
||||||
|
val points = arrow.line.map(onRoad)
|
||||||
|
drawPath(
|
||||||
|
Path().apply {
|
||||||
|
moveTo(points[0].x, points[0].y)
|
||||||
|
points.drop(1).forEach { lineTo(it.x, it.y) }
|
||||||
|
},
|
||||||
|
color,
|
||||||
|
style = Stroke(stroke, cap = StrokeCap.Round, join = StrokeJoin.Round),
|
||||||
|
)
|
||||||
|
val corners = arrow.head.map(onRoad)
|
||||||
|
drawPath(
|
||||||
|
Path().apply {
|
||||||
|
moveTo(corners[0].x, corners[0].y)
|
||||||
|
lineTo(corners[1].x, corners[1].y)
|
||||||
|
lineTo(corners[2].x, corners[2].y)
|
||||||
|
close()
|
||||||
|
},
|
||||||
|
color,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** An arrow laid out flat: the stem as points, and the head's tip and two back corners. */
|
||||||
|
private class LaneArrow(val line: List<Offset>, val head: List<Offset>)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One direction's arrow, flat, its stem rising from x 0 at [bottom]: on straight to [top], bending
|
||||||
|
* off to a side from a little above the middle, or looping back for a U-turn. A lane's arrows
|
||||||
|
* share the stem, so a straight-and-right lane reads as one fork.
|
||||||
|
*/
|
||||||
|
private fun laneArrow(shape: Int, top: Float, bottom: Float): LaneArrow {
|
||||||
|
val tall = bottom - top
|
||||||
|
val head = tall * 0.17f
|
||||||
|
val start = Offset(0f, bottom)
|
||||||
|
val degrees = when (shape) {
|
||||||
|
LaneDirection.SHAPE_SLIGHT_LEFT -> -45f
|
||||||
|
LaneDirection.SHAPE_SLIGHT_RIGHT -> 45f
|
||||||
|
LaneDirection.SHAPE_NORMAL_LEFT -> -90f
|
||||||
|
LaneDirection.SHAPE_NORMAL_RIGHT -> 90f
|
||||||
|
LaneDirection.SHAPE_SHARP_LEFT -> -135f
|
||||||
|
LaneDirection.SHAPE_SHARP_RIGHT -> 135f
|
||||||
|
LaneDirection.SHAPE_U_TURN_LEFT -> -180f
|
||||||
|
LaneDirection.SHAPE_U_TURN_RIGHT -> 180f
|
||||||
|
else -> 0f
|
||||||
|
}
|
||||||
|
val side = if (degrees < 0) -1f else 1f
|
||||||
|
val line = mutableListOf(start)
|
||||||
|
val end: Offset
|
||||||
|
val dir: Offset
|
||||||
|
when {
|
||||||
|
degrees == 0f -> {
|
||||||
|
end = Offset(0f, top + head)
|
||||||
|
line += end
|
||||||
|
dir = Offset(0f, -1f)
|
||||||
|
}
|
||||||
|
abs(degrees) == 180f -> {
|
||||||
|
// Up, over the top and back down beside the stem.
|
||||||
|
val r = tall * 0.2f
|
||||||
|
line += Offset(0f, top + r)
|
||||||
|
line += curve(Offset(0f, top + r), Offset(0f, top), Offset(side * r, top))
|
||||||
|
line += curve(Offset(side * r, top), Offset(side * 2 * r, top), Offset(side * 2 * r, top + r))
|
||||||
|
end = Offset(side * 2 * r, top + tall * 0.5f)
|
||||||
|
line += end
|
||||||
|
dir = Offset(0f, 1f)
|
||||||
|
}
|
||||||
|
else -> {
|
||||||
|
val rad = degrees / 180f * PI.toFloat()
|
||||||
|
val d = Offset(sin(rad), -cos(rad))
|
||||||
|
val length = tall * 0.32f
|
||||||
|
val fork = Offset(0f, top + tall * 0.6f)
|
||||||
|
val bend = Offset(0f, fork.y - length * 0.45f)
|
||||||
|
end = bend + d * length
|
||||||
|
line += fork
|
||||||
|
line += curve(fork, bend, end)
|
||||||
|
dir = d
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val tip = end + dir * head
|
||||||
|
val base = tip - dir * (head * 1.5f)
|
||||||
|
val across = Offset(-dir.y, dir.x) * (head * 0.9f)
|
||||||
|
return LaneArrow(line, listOf(tip, base + across, base - across))
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Points along a quadratic curve from [a] to [c] bending toward [b], [a] itself left out. */
|
||||||
|
private fun curve(a: Offset, b: Offset, c: Offset): List<Offset> = (1..10).map { k ->
|
||||||
|
val t = k / 10f
|
||||||
|
a * ((1 - t) * (1 - t)) + b * (2 * (1 - t) * t) + c * (t * t)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Which lanes to be in, in words, when the recommended ones sit together: the left or right
|
||||||
|
* lanes, the middle one, a lane counted from the nearer side, or any. Null when nothing is
|
||||||
|
* recommended or the lanes to use aren't next to each other.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
internal fun laneHint(lanes: List<Lane>): String? {
|
||||||
|
val n = lanes.size
|
||||||
|
val use = lanes.indices.filter { lanes[it].recommended() }
|
||||||
|
if (n < 2 || use.isEmpty()) return null
|
||||||
|
if (use.size == n) return stringResource(R.string.nav_lane_any)
|
||||||
|
if (use.last() - use.first() + 1 != use.size) return null
|
||||||
|
val resources = LocalContext.current.resources
|
||||||
|
val k = use.size
|
||||||
|
val only = use.first()
|
||||||
|
return when {
|
||||||
|
only == 0 -> resources.getQuantityString(R.plurals.nav_lane_left, k, k)
|
||||||
|
use.last() == n - 1 -> resources.getQuantityString(R.plurals.nav_lane_right, k, k)
|
||||||
|
k == 1 && n % 2 == 1 && only == n / 2 -> stringResource(R.string.nav_lane_middle)
|
||||||
|
k == 1 && only < n - 1 - only -> stringResource(R.string.nav_lane_from_left, only + 1)
|
||||||
|
k == 1 -> stringResource(R.string.nav_lane_from_right, n - only)
|
||||||
|
else -> stringResource(R.string.nav_lane_middle_lanes)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun Lane.recommended(): Boolean = directions.orEmpty().any { it.isRecommended }
|
||||||
@@ -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})") }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,111 @@
|
|||||||
|
package app.seb3thehacker.gearslip.car
|
||||||
|
|
||||||
|
import android.os.Handler
|
||||||
|
import android.os.Looper
|
||||||
|
import android.view.KeyEvent
|
||||||
|
import app.seb3thehacker.gearslip.host.CarAppConnection
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlin.math.min
|
||||||
|
import kotlin.math.pow
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The knob on a full-screen map, as in Android Auto: turning zooms, nudging pans, and pressing
|
||||||
|
* brings up the map's buttons for the knob to move between. Back puts them away and gives the
|
||||||
|
* knob back to the map. Issue #74: on a Mazda 3, every turn stepped through each map button.
|
||||||
|
*
|
||||||
|
* [width] and [height] are the app's surface in pixels; zooms centre on its middle.
|
||||||
|
* [clearFocus] drops focus off whatever button the knob was on.
|
||||||
|
*/
|
||||||
|
internal class MapKnob(
|
||||||
|
private val connection: CarAppConnection,
|
||||||
|
private val width: Int,
|
||||||
|
private val height: Int,
|
||||||
|
private val clearFocus: () -> Unit,
|
||||||
|
) : CarKeys.KnobTaker {
|
||||||
|
|
||||||
|
private val main = Handler(Looper.getMainLooper())
|
||||||
|
/** The press that opened the buttons lifts afterwards; that lift belongs to no button. */
|
||||||
|
@Volatile private var swallowLift = false
|
||||||
|
|
||||||
|
override fun take(event: CarKeys.KnobEvent): Boolean {
|
||||||
|
// A card over the map with buttons of its own (an app's alert, a call, a message, the
|
||||||
|
// assistant, Bluetooth pairing) needs the knob more than the map does.
|
||||||
|
if (overlayUp()) return false
|
||||||
|
if (_buttonsOpen.value) {
|
||||||
|
if (event is CarKeys.KnobEvent.Key && !event.down && swallowLift && event.keyCode in PRESS_KEYS) {
|
||||||
|
swallowLift = false
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if (event == CarKeys.KnobEvent.Back) {
|
||||||
|
main.post { close() }
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
when (event) {
|
||||||
|
is CarKeys.KnobEvent.Turn -> main.post { zoom(event.steps) }
|
||||||
|
is CarKeys.KnobEvent.Key -> when (event.keyCode) {
|
||||||
|
in PRESS_KEYS -> if (event.down) main.post { open() }
|
||||||
|
else -> if (event.down) main.post { pan(event.keyCode) }
|
||||||
|
}
|
||||||
|
CarKeys.KnobEvent.Back -> return false
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun overlayUp(): Boolean =
|
||||||
|
CarAlerts.shown.value != null ||
|
||||||
|
app.seb3thehacker.gearslip.call.CarCalls.state.value is app.seb3thehacker.gearslip.call.CallUiState.Ringing ||
|
||||||
|
app.seb3thehacker.gearslip.notify.CarNotifications.popup.value != null ||
|
||||||
|
CarAssistant.state.value.phase != CarAssistant.Phase.IDLE ||
|
||||||
|
CarBluetooth.prompt.value != CarBluetooth.Prompt.NONE
|
||||||
|
|
||||||
|
private fun zoom(steps: Int) {
|
||||||
|
val clicks = steps.coerceIn(-MAX_STEPS, MAX_STEPS)
|
||||||
|
connection.surfaceScale(width / 2f, height / 2f, ZOOM_STEP.pow(clicks))
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One nudge moves the map a sixth of the screen. Scroll distances run previous minus current. */
|
||||||
|
private fun pan(keyCode: Int) {
|
||||||
|
val step = min(width, height) / 6f
|
||||||
|
when (keyCode) {
|
||||||
|
KeyEvent.KEYCODE_DPAD_UP -> connection.surfaceScroll(0f, -step)
|
||||||
|
KeyEvent.KEYCODE_DPAD_DOWN -> connection.surfaceScroll(0f, step)
|
||||||
|
KeyEvent.KEYCODE_DPAD_LEFT -> connection.surfaceScroll(-step, 0f)
|
||||||
|
KeyEvent.KEYCODE_DPAD_RIGHT -> connection.surfaceScroll(step, 0f)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun open() {
|
||||||
|
swallowLift = true
|
||||||
|
_buttonsOpen.value = true
|
||||||
|
// The map's buttons take focus when they see this. A map showing none gets a Tab, so the
|
||||||
|
// knob still lands somewhere.
|
||||||
|
if (controls == 0) CarKeys.focusSink?.let { it(KeyEvent.KEYCODE_TAB, true); it(KeyEvent.KEYCODE_TAB, false) }
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun close() {
|
||||||
|
_buttonsOpen.value = false
|
||||||
|
clearFocus()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Puts the buttons away when the map leaves the screen. */
|
||||||
|
fun release() {
|
||||||
|
_buttonsOpen.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private val _buttonsOpen = MutableStateFlow(false)
|
||||||
|
|
||||||
|
/** The knob pressed on the map: its buttons show and take focus, until Back. */
|
||||||
|
val buttonsOpen: StateFlow<Boolean> = _buttonsOpen
|
||||||
|
|
||||||
|
/** How many stacks of map buttons are on screen, for [open] to know whether any will take focus. */
|
||||||
|
@Volatile var controls = 0
|
||||||
|
|
||||||
|
private const val ZOOM_STEP = 1.25f
|
||||||
|
private const val MAX_STEPS = 5
|
||||||
|
private val PRESS_KEYS = setOf(KeyEvent.KEYCODE_DPAD_CENTER, KeyEvent.KEYCODE_ENTER)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,5 +1,7 @@
|
|||||||
package app.seb3thehacker.gearslip.car
|
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.CircleShape
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
import app.seb3thehacker.gearslip.car.theme.*
|
import app.seb3thehacker.gearslip.car.theme.*
|
||||||
@@ -11,6 +13,7 @@ import androidx.compose.foundation.layout.Arrangement
|
|||||||
import android.provider.Settings
|
import android.provider.Settings
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.aspectRatio
|
||||||
import androidx.compose.foundation.layout.BoxWithConstraints
|
import androidx.compose.foundation.layout.BoxWithConstraints
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.Row
|
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
|
// 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.
|
// again here (as "Browse", or as the app's own name) was just the same label twice.
|
||||||
if (media.canGoUp || browse.trail.isNotEmpty()) {
|
if (media.canGoUp || browse.trail.isNotEmpty()) {
|
||||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
Row(Modifier.padding(bottom = 12.dp), verticalAlignment = Alignment.CenterVertically) {
|
||||||
if (media.canGoUp) {
|
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 {
|
browse.trail.lastOrNull()?.let {
|
||||||
Text(
|
Text(
|
||||||
it,
|
it,
|
||||||
style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold,
|
style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold,
|
||||||
maxLines = 1, overflow = TextOverflow.Ellipsis,
|
maxLines = 1, overflow = TextOverflow.Ellipsis,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
when {
|
when {
|
||||||
browse.loading -> Notice("Loading…", null)
|
browse.loading -> Notice(stringResource(R.string.media_loading), null)
|
||||||
browse.unavailable -> Notice(
|
browse.unavailable -> Notice(
|
||||||
"$appLabel keeps its library to itself.",
|
stringResource(R.string.media_keeps_library, appLabel),
|
||||||
"Start music in the app on your phone. Play, pause, skip and seek work from here.",
|
stringResource(R.string.media_browse_start),
|
||||||
)
|
)
|
||||||
browse.failed -> Notice("Could not load this list.", null)
|
browse.failed -> Notice(stringResource(R.string.media_load_failed), null)
|
||||||
browse.entries.isEmpty() -> Notice("Nothing here.", null)
|
browse.entries.isEmpty() -> Notice(stringResource(R.string.media_nothing_here), null)
|
||||||
else -> LazyColumn(Modifier.fillMaxSize(), verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
else -> BrowseList(browse.entries, media)
|
||||||
items(browse.entries, key = { it.id }) { entry -> EntryRow(entry, 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 -----------------------------------------------------------------------------------
|
// --- up next -----------------------------------------------------------------------------------
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -127,12 +234,15 @@ internal fun QueuePanel(queue: List<QueueTrack>, now: NowPlaying, media: CarMedi
|
|||||||
Column(modifier) {
|
Column(modifier) {
|
||||||
if (upNext.isEmpty()) {
|
if (upNext.isEmpty()) {
|
||||||
Notice(
|
Notice(
|
||||||
"Nothing queued.",
|
stringResource(R.string.media_nothing_queued),
|
||||||
"${if (now.title.isEmpty()) "Nothing playing" else "Only what's already playing"} - " +
|
stringResource(if (now.title.isEmpty()) R.string.media_queue_empty_idle else R.string.media_queue_empty_playing),
|
||||||
"not every app tells Gearslip what comes next.",
|
|
||||||
)
|
)
|
||||||
} else {
|
} 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) }
|
items(upNext, key = { it.queueId }) { track -> QueueRow(track, media) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -148,18 +258,18 @@ private fun QueueRow(track: QueueTrack, media: CarMedia) {
|
|||||||
Surface(
|
Surface(
|
||||||
onClick = { media.playQueueItem(track) },
|
onClick = { media.playQueueItem(track) },
|
||||||
shape = MaterialTheme.shapes.large,
|
shape = MaterialTheme.shapes.large,
|
||||||
color = MaterialTheme.colorScheme.surfaceVariant,
|
color = MaterialTheme.colorScheme.surfaceContainer,
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
) {
|
) {
|
||||||
Row(Modifier.padding(10.dp), verticalAlignment = Alignment.CenterVertically) {
|
Row(Modifier.padding(8.dp), verticalAlignment = Alignment.CenterVertically) {
|
||||||
RowArt(icon, MediaIcons.MusicNote)
|
RowArt(icon, MediaIcons.MusicNote)
|
||||||
Column(Modifier.weight(1f).padding(horizontal = 12.dp)) {
|
Column(Modifier.weight(1f).padding(horizontal = 14.dp)) {
|
||||||
Text(track.title, style = MaterialTheme.typography.titleSmall, maxLines = 1, overflow = TextOverflow.Ellipsis)
|
Text(track.title, style = MaterialTheme.typography.titleMedium, maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||||
if (track.subtitle.isNotEmpty()) {
|
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(
|
Surface(
|
||||||
onClick = onClick,
|
onClick = onClick,
|
||||||
shape = MaterialTheme.shapes.large,
|
shape = MaterialTheme.shapes.large,
|
||||||
color = MaterialTheme.colorScheme.surfaceVariant,
|
color = MaterialTheme.colorScheme.surfaceContainer,
|
||||||
modifier = Modifier.fillMaxWidth(),
|
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)
|
RowArt(icon, if (entry.browsable) MediaIcons.Folder else MediaIcons.MusicNote)
|
||||||
Column(Modifier.weight(1f).padding(horizontal = 12.dp)) {
|
Column(Modifier.weight(1f).padding(horizontal = 14.dp)) {
|
||||||
Text(entry.title, style = MaterialTheme.typography.titleSmall, maxLines = 1, overflow = TextOverflow.Ellipsis)
|
Text(entry.title, style = MaterialTheme.typography.titleMedium, maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||||
if (entry.subtitle.isNotEmpty()) {
|
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) {
|
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)
|
if (entry.browsable) Icon(Icons.AutoMirrored.Filled.KeyboardArrowRight, null)
|
||||||
}
|
}
|
||||||
@@ -196,7 +306,7 @@ internal fun EntryRow(entry: MediaEntry, media: CarMedia, onClick: () -> Unit =
|
|||||||
@Composable
|
@Composable
|
||||||
private fun RowArt(icon: android.graphics.Bitmap?, fallback: ImageVector) {
|
private fun RowArt(icon: android.graphics.Bitmap?, fallback: ImageVector) {
|
||||||
Box(
|
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,
|
contentAlignment = Alignment.Center,
|
||||||
) {
|
) {
|
||||||
if (icon != null) Image(icon.asImageBitmap(), null, Modifier.fillMaxSize(), contentScale = ContentScale.Crop)
|
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 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 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")
|
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 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")
|
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
|
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.CircleShape
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
import app.seb3thehacker.gearslip.car.theme.*
|
import app.seb3thehacker.gearslip.car.theme.*
|
||||||
import androidx.compose.foundation.Image
|
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.background
|
||||||
import androidx.compose.foundation.rememberScrollState
|
import androidx.compose.foundation.rememberScrollState
|
||||||
import androidx.compose.foundation.verticalScroll
|
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.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.height
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.heightIn
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.layout.size
|
import androidx.compose.foundation.layout.size
|
||||||
import androidx.compose.foundation.layout.width
|
import androidx.compose.foundation.layout.width
|
||||||
@@ -49,6 +55,9 @@ import androidx.compose.runtime.setValue
|
|||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.draw.clip
|
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.PathFillType
|
||||||
import androidx.compose.ui.graphics.SolidColor
|
import androidx.compose.ui.graphics.SolidColor
|
||||||
import androidx.compose.foundation.layout.widthIn
|
import androidx.compose.foundation.layout.widthIn
|
||||||
@@ -97,18 +106,18 @@ fun MediaScreen(app: MediaApp, onExit: () -> Unit) {
|
|||||||
|
|
||||||
val art = rememberArt(now)
|
val art = rememberArt(now)
|
||||||
|
|
||||||
// The art's colour behind everything, header included; the buttons keep their own.
|
// The theme's background, with the cover's colours glowing faintly behind the cover on the left.
|
||||||
Surface(Modifier.fillMaxSize(), color = rememberArtColor(art, MaterialTheme.colorScheme.background, darkLightness = 0.15f, lightLightness = 0.9f)) {
|
Surface(Modifier.fillMaxSize(), color = MaterialTheme.colorScheme.background) {
|
||||||
|
if (phase == CarMedia.Phase.READY && !searching) AlbumGlow(art, Modifier.fillMaxSize(), wide = true)
|
||||||
if (phase != CarMedia.Phase.READY) {
|
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))
|
AppIdentity(app, onExit, Modifier.padding(horizontal = 16.dp, vertical = 10.dp))
|
||||||
when (phase) {
|
when (phase) {
|
||||||
CarMedia.Phase.REJECTED -> when (rejection) {
|
CarMedia.Phase.REJECTED -> when (rejection) {
|
||||||
CarMedia.Rejection.NEEDS_NOTIFICATION_ACCESS -> Notice(
|
CarMedia.Rejection.NEEDS_NOTIFICATION_ACCESS -> Notice(
|
||||||
"${app.label} keeps its library to itself.",
|
stringResource(R.string.media_keeps_library, app.label),
|
||||||
"Gearslip can still control it once it has Notification access on the phone. " +
|
stringResource(R.string.media_needs_access),
|
||||||
"Allow it under Settings > Notifications > Notification access.",
|
action = stringResource(R.string.allow_on_phone) to {
|
||||||
action = "Open on phone" to {
|
|
||||||
val app = context.applicationContext
|
val app = context.applicationContext
|
||||||
runCatching {
|
runCatching {
|
||||||
app.startActivity(
|
app.startActivity(
|
||||||
@@ -118,24 +127,23 @@ fun MediaScreen(app: MediaApp, onExit: () -> Unit) {
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
CarMedia.Rejection.NEEDS_APP_RUNNING -> Notice(
|
CarMedia.Rejection.NEEDS_APP_RUNNING -> Notice(
|
||||||
"${app.label} isn't running.",
|
stringResource(R.string.media_not_running, app.label),
|
||||||
"${app.label} plays through Gearslip's own player, but only while it's " +
|
stringResource(R.string.media_not_running_body, app.label),
|
||||||
"open on the phone. Open it once and it keeps playing here in the car.",
|
action = stringResource(R.string.media_open_app, app.label) to { media.launchAndConnect(app) },
|
||||||
action = "Open ${app.label}" to { media.launchAndConnect(app) },
|
|
||||||
)
|
)
|
||||||
CarMedia.Rejection.REFUSED -> Notice(
|
CarMedia.Rejection.REFUSED -> Notice(
|
||||||
"${app.label} would not let Gearslip browse it.",
|
stringResource(R.string.media_refused, app.label),
|
||||||
"Some media apps only accept Google's own host.",
|
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
|
return@Surface
|
||||||
}
|
}
|
||||||
|
|
||||||
if (searching) {
|
if (searching) {
|
||||||
SearchPanel(
|
Box(Modifier.padding(bottom = LocalNavBarInset.current)) { SearchPanel(
|
||||||
appLabel = app.label,
|
appLabel = app.label,
|
||||||
media = media,
|
media = media,
|
||||||
canPickForYou = now.canSearch,
|
canPickForYou = now.canSearch,
|
||||||
@@ -153,52 +161,91 @@ fun MediaScreen(app: MediaApp, onExit: () -> Unit) {
|
|||||||
tab = Tab.NOW_PLAYING
|
tab = Tab.NOW_PLAYING
|
||||||
},
|
},
|
||||||
onClose = { searching = false },
|
onClose = { searching = false },
|
||||||
)
|
) }
|
||||||
return@Surface
|
return@Surface
|
||||||
}
|
}
|
||||||
|
|
||||||
// Two columns, split the same way on every tab so nothing jumps when switching: the app
|
// The page runs down under the see-through nav bar, so the cover's glow carries on behind
|
||||||
// and the art (or, beside a list, the compact player) on the left; the tabs across the
|
// it; the content stops at the bar's top edge. Lists run right to it; the rest keeps a gap.
|
||||||
// top of the right column, over whatever the tab shows.
|
val barTop = LocalNavBarInset.current
|
||||||
Row(
|
val barInset = barTop + 8.dp
|
||||||
Modifier.fillMaxSize().padding(start = 16.dp, end = 16.dp, top = 10.dp, bottom = 8.dp),
|
val tabs: @Composable (Modifier) -> Unit = { tabModifier ->
|
||||||
horizontalArrangement = Arrangement.spacedBy(24.dp),
|
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 }
|
||||||
Column(Modifier.weight(0.8f).fillMaxHeight()) {
|
MediaTab(stringResource(R.string.media_browse), tab == Tab.BROWSE, tabModifier) { tab = Tab.BROWSE }
|
||||||
AppIdentity(app, onExit)
|
MediaTab(stringResource(R.string.storage_lyrics), tab == Tab.LYRICS, tabModifier) { tab = Tab.LYRICS }
|
||||||
Spacer(Modifier.height(12.dp))
|
}
|
||||||
if (tab == Tab.NOW_PLAYING) {
|
val searchButton: @Composable () -> Unit = {
|
||||||
CoverArt(art, Modifier.weight(1f).fillMaxWidth())
|
if (now.canSearch || media.canSearchLibrary) {
|
||||||
} else {
|
GsIconButton(
|
||||||
CompactPlayer(now, media, Modifier.weight(1f).fillMaxWidth())
|
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 }
|
BoxWithConstraints(Modifier.fillMaxSize().padding(start = 16.dp, end = 16.dp, top = 10.dp)) {
|
||||||
MediaTab("Browse", tab == Tab.BROWSE, Modifier.weight(1f)) { tab = Tab.BROWSE }
|
// A small screen, or a big one at Extra large size, has no room for two columns: the
|
||||||
MediaTab("Up next", tab == Tab.QUEUE, Modifier.weight(1f)) { tab = Tab.QUEUE }
|
// app and the tabs share one bar, and a list takes the whole width. The nav bar keeps
|
||||||
MediaTab("Lyrics", tab == Tab.LYRICS, Modifier.weight(1f)) { tab = Tab.LYRICS }
|
// the controls, so nothing is lost.
|
||||||
if (now.canSearch || media.canSearchLibrary) {
|
if (maxWidth < NARROW_WIDTH || maxHeight - barInset < NARROW_HEIGHT) {
|
||||||
GsIconButton(
|
Column(Modifier.fillMaxSize(), verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
Icons.Filled.Search,
|
Row(
|
||||||
"Search ${app.label}",
|
Modifier.fillMaxWidth(),
|
||||||
{ searching = true },
|
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||||
colors = GsColors(MaterialTheme.colorScheme.surfaceContainerHigh, MaterialTheme.colorScheme.onSurface),
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
size = 44.dp,
|
) {
|
||||||
shape = MaterialTheme.shapes.large,
|
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) }
|
Column(Modifier.weight(1.2f).fillMaxHeight()) {
|
||||||
Spacer(Modifier.height(12.dp))
|
// The tabs share the row evenly; a long word (a translation, a big text size)
|
||||||
Box(Modifier.weight(1f).fillMaxWidth()) {
|
// shrinks to fit its tab instead.
|
||||||
when (tab) {
|
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
Tab.NOW_PLAYING -> NowPlayingControls(now, media, Modifier.fillMaxSize())
|
tabs(Modifier.weight(1f))
|
||||||
Tab.BROWSE -> BrowsePanel(browse, media, app.label, Modifier.fillMaxSize())
|
searchButton()
|
||||||
Tab.QUEUE -> QueuePanel(queue, now, media, Modifier.fillMaxSize())
|
|
||||||
Tab.LYRICS -> LyricsPanel(now, Modifier.fillMaxSize())
|
|
||||||
}
|
}
|
||||||
|
search?.let { SearchStatus(it, app.label, media::dismissSearch) }
|
||||||
|
Spacer(Modifier.height(12.dp))
|
||||||
|
tabContent(Modifier.weight(1f).fillMaxWidth())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -212,15 +259,21 @@ fun MediaScreen(app: MediaApp, onExit: () -> Unit) {
|
|||||||
@Composable
|
@Composable
|
||||||
private fun AppIdentity(app: MediaApp, onExit: () -> Unit, modifier: Modifier = Modifier) {
|
private fun AppIdentity(app: MediaApp, onExit: () -> Unit, modifier: Modifier = Modifier) {
|
||||||
val icon = remember(app) { findEntry(app.component.flattenToString())?.icon }
|
val icon = remember(app) { findEntry(app.component.flattenToString())?.icon }
|
||||||
|
val navigator = LocalCarNavigator.current
|
||||||
Row(modifier, verticalAlignment = Alignment.CenterVertically) {
|
Row(modifier, verticalAlignment = Alignment.CenterVertically) {
|
||||||
GsIconButton(Icons.Filled.ArrowBack, "Back", onExit, size = 44.dp)
|
// Shown on the same terms as every other screen's header: only when it leads somewhere
|
||||||
Spacer(Modifier.width(14.dp))
|
// the nav bar doesn't.
|
||||||
|
if (navigator.backLeadsSomewhere) {
|
||||||
|
GsIconButton(Icons.Filled.ArrowBack, stringResource(R.string.back), onExit)
|
||||||
|
Spacer(Modifier.width(12.dp))
|
||||||
|
}
|
||||||
icon?.let {
|
icon?.let {
|
||||||
Image(it, null, Modifier.size(26.dp).clip(RoundedCornerShape(7.dp)))
|
Image(it, null, Modifier.size(26.dp).clip(RoundedCornerShape(7.dp)))
|
||||||
Spacer(Modifier.width(10.dp))
|
Spacer(Modifier.width(10.dp))
|
||||||
}
|
}
|
||||||
Text(
|
Text(
|
||||||
app.label.lowercase().replaceFirstChar { it.titlecase() },
|
// As the app writes it: recasing turned NewPipe into "Newpipe" and TIDAL into "Tidal".
|
||||||
|
app.label,
|
||||||
style = MaterialTheme.typography.titleMedium,
|
style = MaterialTheme.typography.titleMedium,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
maxLines = 1,
|
maxLines = 1,
|
||||||
@@ -273,10 +326,10 @@ private fun SearchPanel(
|
|||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
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(
|
SearchField(
|
||||||
query,
|
query,
|
||||||
"Search $appLabel",
|
stringResource(R.string.media_search, appLabel),
|
||||||
Modifier.weight(1f).clickable { typing = true },
|
Modifier.weight(1f).clickable { typing = true },
|
||||||
onClear = {
|
onClear = {
|
||||||
query = ""
|
query = ""
|
||||||
@@ -287,12 +340,12 @@ private fun SearchPanel(
|
|||||||
Box(Modifier.weight(1f).fillMaxWidth().padding(horizontal = 16.dp)) {
|
Box(Modifier.weight(1f).fillMaxWidth().padding(horizontal = 16.dp)) {
|
||||||
val entries = current?.entries
|
val entries = current?.entries
|
||||||
when {
|
when {
|
||||||
trimmed.isEmpty() -> SearchNote("Type a song, artist, album, or playlist.")
|
trimmed.isEmpty() -> SearchNote(stringResource(R.string.media_search_hint))
|
||||||
unsupported && canPickForYou ->
|
unsupported && canPickForYou ->
|
||||||
SearchNote("$appLabel can't list results. Press search, and $appLabel picks what to play.")
|
SearchNote(stringResource(R.string.media_search_no_list, appLabel))
|
||||||
unsupported -> SearchNote("$appLabel can't search from the car.")
|
unsupported -> SearchNote(stringResource(R.string.media_search_unsupported, appLabel))
|
||||||
entries == null -> SearchNote("Searching $appLabel…")
|
entries == null -> SearchNote(stringResource(R.string.media_searching, appLabel))
|
||||||
entries.isEmpty() -> SearchNote("$appLabel found nothing for \"$trimmed\".")
|
entries.isEmpty() -> SearchNote(stringResource(R.string.media_search_nothing, appLabel, trimmed))
|
||||||
else -> LazyColumn(verticalArrangement = Arrangement.spacedBy(8.dp), contentPadding = PaddingValues(bottom = 8.dp)) {
|
else -> LazyColumn(verticalArrangement = Arrangement.spacedBy(8.dp), contentPadding = PaddingValues(bottom = 8.dp)) {
|
||||||
items(entries) { entry ->
|
items(entries) { entry ->
|
||||||
EntryRow(entry, media) {
|
EntryRow(entry, media) {
|
||||||
@@ -350,8 +403,8 @@ private fun SearchStatus(search: CarMedia.Search, appLabel: String, onDismiss: (
|
|||||||
modifier = Modifier.fillMaxWidth().padding(top = 8.dp),
|
modifier = Modifier.fillMaxWidth().padding(top = 8.dp),
|
||||||
) {
|
) {
|
||||||
Text(
|
Text(
|
||||||
if (search.ignored) "$appLabel didn't play anything for \"${search.query}\"."
|
if (search.ignored) stringResource(R.string.media_search_ignored, appLabel, search.query)
|
||||||
else "Searching $appLabel for \"${search.query}\"…",
|
else stringResource(R.string.media_searching_for, appLabel, search.query),
|
||||||
style = MaterialTheme.typography.bodyLarge,
|
style = MaterialTheme.typography.bodyLarge,
|
||||||
maxLines = 2,
|
maxLines = 2,
|
||||||
overflow = TextOverflow.Ellipsis,
|
overflow = TextOverflow.Ellipsis,
|
||||||
@@ -376,14 +429,17 @@ private fun MediaTab(label: String, selected: Boolean, modifier: Modifier, onCli
|
|||||||
style = MaterialTheme.typography.titleSmall,
|
style = MaterialTheme.typography.titleSmall,
|
||||||
fontWeight = if (selected) FontWeight.Bold else FontWeight.Normal,
|
fontWeight = if (selected) FontWeight.Bold else FontWeight.Normal,
|
||||||
maxLines = 1,
|
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 song's lyrics, fetched when this tab opens. Synced lyrics follow playback: the line being
|
||||||
* the current line in the accent colour; unsynced ones are plain scrolling text.
|
* 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
|
@Composable
|
||||||
internal fun LyricsPanel(
|
internal fun LyricsPanel(
|
||||||
@@ -394,43 +450,89 @@ internal fun LyricsPanel(
|
|||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
val lyrics by produceState<LyricsState>(LyricsState.Loading, now.title, now.artist, now.album, now.durationMs) {
|
val lyrics by produceState<LyricsState>(LyricsState.Loading, now.title, now.artist, now.album, now.durationMs) {
|
||||||
value = LyricsState.Loading
|
value = LyricsState.Loading
|
||||||
value = Lyrics.find(context, now.title, now.artist, now.album, now.durationMs)
|
val found = Lyrics.find(context, now.title, now.artist, now.album, now.durationMs)
|
||||||
?.let { LyricsState.Found(it) } ?: LyricsState.Missing
|
value = found?.let { LyricsState.Found(it) } ?: LyricsState.Missing
|
||||||
}
|
// Cached lyrics show at once; if a source now has better ones, they replace them.
|
||||||
val list = rememberLazyListState()
|
if (found != null) {
|
||||||
|
Lyrics.upgrade(context, now.title, now.artist, now.album, now.durationMs, found)
|
||||||
// Ticks with playback so the highlighted line moves; only synced lyrics need it.
|
?.let { value = LyricsState.Found(it) }
|
||||||
var position by remember { mutableLongStateOf(0L) }
|
|
||||||
LaunchedEffect(now) {
|
|
||||||
while (true) {
|
|
||||||
position = now.currentPosition()
|
|
||||||
kotlinx.coroutines.delay(300)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
when (val state = lyrics) {
|
when (val state = lyrics) {
|
||||||
LyricsState.Loading -> Notice("Looking for lyrics…", null)
|
LyricsState.Loading -> Notice(stringResource(R.string.media_looking_lyrics), null)
|
||||||
LyricsState.Missing -> Notice(
|
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,
|
null,
|
||||||
)
|
)
|
||||||
is LyricsState.Found -> {
|
is LyricsState.Found -> Column(modifier) {
|
||||||
val lines = state.result.lines
|
if (state.result.synced) SyncedLyrics(now, state.result, lineStyle, Modifier.weight(1f).fillMaxWidth())
|
||||||
val current = if (state.result.synced) lines.indexOfLast { it.timeMs <= position } else -1
|
else PlainLyrics(state.result, lineStyle, Modifier.weight(1f).fillMaxWidth())
|
||||||
LaunchedEffect(current) {
|
// Debug builds name the service the lyrics came from, to see which one answers.
|
||||||
if (current >= 0) list.animateScrollToItem((current - 1).coerceAtLeast(0))
|
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(
|
Text(
|
||||||
line.text.ifEmpty { " " },
|
line.text.ifEmpty { " " },
|
||||||
style = lineStyle,
|
style = lineStyle,
|
||||||
fontWeight = if (index == current) FontWeight.Bold else FontWeight.Normal,
|
color = if (index < current) sung else scheme.onSurfaceVariant,
|
||||||
color = when {
|
|
||||||
!state.result.synced -> MaterialTheme.colorScheme.onSurface
|
|
||||||
index == current -> MaterialTheme.colorScheme.primary
|
|
||||||
else -> MaterialTheme.colorScheme.onSurfaceVariant
|
|
||||||
},
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -438,6 +540,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 {
|
private sealed interface LyricsState {
|
||||||
data object Loading : LyricsState
|
data object Loading : LyricsState
|
||||||
data object Missing : LyricsState
|
data object Missing : LyricsState
|
||||||
@@ -468,29 +636,39 @@ internal fun Notice(title: String, detail: String?, action: Pair<String, () -> U
|
|||||||
// --- now playing -----------------------------------------------------------------------------
|
// --- now playing -----------------------------------------------------------------------------
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The now-playing view on its own, for places with no tabs around it (an app's own
|
* The now-playing view on its own: an app's own now-playing template, or the Now playing tab on a
|
||||||
* now-playing template): the cover on the left, [NowPlayingControls] on the right.
|
* 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
|
@Composable
|
||||||
internal fun NowPlayingPage(now: NowPlaying, media: CarMedia, modifier: Modifier) {
|
internal fun NowPlayingPage(now: NowPlaying, media: CarMedia, modifier: Modifier, padded: Boolean = true) {
|
||||||
Row(
|
BoxWithConstraints(if (padded) modifier.padding(horizontal = 24.dp, vertical = 8.dp) else modifier) {
|
||||||
modifier.padding(horizontal = 24.dp, vertical = 8.dp),
|
val art = rememberArt(now)
|
||||||
horizontalArrangement = Arrangement.spacedBy(24.dp),
|
if (maxWidth >= 520.dp && maxHeight >= 260.dp) {
|
||||||
) {
|
val artWidth = minOf(maxHeight, (maxWidth - 24.dp) * 0.4f)
|
||||||
CoverArt(rememberArt(now), Modifier.weight(0.8f).fillMaxHeight())
|
Row(Modifier.fillMaxSize(), horizontalArrangement = Arrangement.spacedBy(24.dp)) {
|
||||||
NowPlayingControls(now, media, Modifier.weight(1.2f).fillMaxHeight())
|
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. */
|
/** The cover, as big a square as its space allows, centred in it. */
|
||||||
@Composable
|
@Composable
|
||||||
private fun CoverArt(art: android.graphics.Bitmap?, modifier: Modifier) {
|
private fun CoverArt(art: android.graphics.Bitmap?, modifier: Modifier, alignment: Alignment = Alignment.Center) {
|
||||||
BoxWithConstraints(modifier, contentAlignment = Alignment.Center) {
|
BoxWithConstraints(modifier, contentAlignment = alignment) {
|
||||||
|
val shape = RoundedCornerShape(22.dp)
|
||||||
Surface(
|
Surface(
|
||||||
shape = MaterialTheme.shapes.large,
|
shape = shape,
|
||||||
color = MaterialTheme.colorScheme.surfaceVariant,
|
color = MaterialTheme.colorScheme.surfaceContainerHigh,
|
||||||
shadowElevation = 6.dp,
|
modifier = Modifier.size(minOf(maxWidth, maxHeight)).gsPanelShadow(shape),
|
||||||
modifier = Modifier.size(minOf(maxWidth, maxHeight)),
|
|
||||||
) {
|
) {
|
||||||
if (art != null) {
|
if (art != null) {
|
||||||
Image(art.asImageBitmap(), null, Modifier.fillMaxSize(), contentScale = ContentScale.Crop)
|
Image(art.asImageBitmap(), null, Modifier.fillMaxSize(), contentScale = ContentScale.Crop)
|
||||||
@@ -504,65 +682,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,
|
* 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.
|
* the seek bar, the transport, then like, shuffle and repeat as big icon-only buttons.
|
||||||
*/
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
private fun NowPlayingControls(now: NowPlaying, media: CarMedia, modifier: Modifier) {
|
private fun NowPlayingControls(now: NowPlaying, media: CarMedia, modifier: Modifier) {
|
||||||
Column(
|
BoxWithConstraints(modifier) {
|
||||||
modifier.verticalScroll(rememberScrollState()),
|
val compact = maxHeight < 360.dp || maxWidth < 420.dp
|
||||||
verticalArrangement = Arrangement.spacedBy(14.dp, Alignment.CenterVertically),
|
val showTimes = maxHeight >= 260.dp
|
||||||
) {
|
Column(
|
||||||
Column {
|
Modifier.fillMaxSize(),
|
||||||
Text(
|
verticalArrangement = Arrangement.spacedBy(if (compact) 8.dp else 14.dp, Alignment.CenterVertically),
|
||||||
now.title.ifEmpty { "Nothing playing" },
|
) {
|
||||||
style = MaterialTheme.typography.headlineMedium, fontWeight = FontWeight.Bold,
|
// The title gives up its height first and scrolls, so the controls never leave the screen.
|
||||||
maxLines = 2, overflow = TextOverflow.Ellipsis,
|
Column(Modifier.weight(1f, fill = false).verticalScroll(rememberScrollState())) {
|
||||||
)
|
|
||||||
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(
|
Text(
|
||||||
"Press play to pick up where you left off, or find something in Browse.",
|
now.title.ifEmpty { stringResource(R.string.media_nothing_playing) },
|
||||||
style = MaterialTheme.typography.titleMedium, color = MaterialTheme.colorScheme.onSurfaceVariant,
|
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
|
* The player beside the browse list, the queue and the lyrics: the cover as big as the controls
|
||||||
* room for, just what's playing, the seek bar and 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
|
@Composable
|
||||||
private fun CompactPlayer(now: NowPlaying, media: CarMedia, modifier: Modifier) {
|
private fun CompactPlayer(now: NowPlaying, media: CarMedia, art: android.graphics.Bitmap?, modifier: Modifier) {
|
||||||
Column(
|
Column(modifier) {
|
||||||
modifier.verticalScroll(rememberScrollState()),
|
BoxWithConstraints(Modifier.weight(1f).fillMaxWidth()) {
|
||||||
verticalArrangement = Arrangement.spacedBy(14.dp),
|
val cover = minOf(maxHeight, maxWidth * 0.62f).coerceAtLeast(0.dp)
|
||||||
) {
|
Row(Modifier.fillMaxSize(), verticalAlignment = Alignment.Bottom) {
|
||||||
Column {
|
CoverArt(art, Modifier.size(cover))
|
||||||
Text(
|
Spacer(Modifier.width(16.dp))
|
||||||
now.title.ifEmpty { "Nothing playing" },
|
Column(Modifier.weight(1f).padding(bottom = 4.dp)) {
|
||||||
style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold,
|
Text(
|
||||||
maxLines = 2, overflow = TextOverflow.Ellipsis,
|
now.title.ifEmpty { stringResource(R.string.media_nothing_playing) },
|
||||||
)
|
style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold,
|
||||||
Text(
|
maxLines = 3, overflow = TextOverflow.Ellipsis,
|
||||||
now.artist,
|
)
|
||||||
style = MaterialTheme.typography.bodyLarge, color = MaterialTheme.colorScheme.onSurfaceVariant,
|
Text(
|
||||||
maxLines = 1, overflow = TextOverflow.Ellipsis,
|
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())
|
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())
|
PlayModeRow(now, media, size = 48.dp, modifier = Modifier.fillMaxWidth())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
package app.seb3thehacker.gearslip.car
|
package app.seb3thehacker.gearslip.car
|
||||||
|
|
||||||
|
import androidx.compose.ui.res.pluralStringResource
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
import android.os.SystemClock
|
import android.os.SystemClock
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.horizontalScroll
|
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)
|
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. */
|
/** "Alex", or "Alex · 3 new" when a burst of messages from one chat has been merged. */
|
||||||
|
@Composable
|
||||||
private fun CarNotification.heading(): String {
|
private fun CarNotification.heading(): String {
|
||||||
val who = title.ifBlank { appLabel }
|
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. */
|
/** 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.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)
|
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)) {
|
Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||||
GsButton(onClick = { VoiceReply.read(n) }, tone = GsTone.Neutral, modifier = Modifier.weight(1f)) {
|
GsButton(onClick = { VoiceReply.read(n) }, tone = GsTone.Neutral, modifier = Modifier.weight(1f)) {
|
||||||
ReadGlyph(Modifier.size(20.dp))
|
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) {
|
if (n.reply != null) {
|
||||||
GsButton(onClick = { VoiceReply.reply(n) }, modifier = Modifier.weight(1f)) {
|
GsButton(onClick = { VoiceReply.reply(n) }, modifier = Modifier.weight(1f)) {
|
||||||
ReplyGlyph(Modifier.size(20.dp))
|
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) {
|
when (s.phase) {
|
||||||
VoiceReply.Phase.READING, VoiceReply.Phase.READ -> {
|
VoiceReply.Phase.READING, VoiceReply.Phase.READ -> {
|
||||||
GsButton(onClick = VoiceReply::cancel, tone = GsTone.Neutral, modifier = wide) {
|
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) {
|
if (n.reply != null) {
|
||||||
GsButton(onClick = { VoiceReply.reply(n, readFirst = false) }, modifier = wide) {
|
GsButton(onClick = { VoiceReply.reply(n, readFirst = false) }, modifier = wide) {
|
||||||
ReplyGlyph(Modifier.size(20.dp))
|
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 -> {
|
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) {
|
GsButton(onClick = { VoiceReply.reply(n, readFirst = false) }, modifier = wide) {
|
||||||
ReplyGlyph(Modifier.size(20.dp))
|
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 ->
|
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 -> {
|
VoiceReply.Phase.CONFIRMING -> {
|
||||||
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)) }
|
||||||
GsButton(onClick = VoiceReply::sendNow, modifier = wide) { Text("Send now") }
|
GsButton(onClick = VoiceReply::sendNow, modifier = wide) { Text(stringResource(R.string.msg_send_now)) }
|
||||||
}
|
}
|
||||||
VoiceReply.Phase.ERROR -> {
|
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) {
|
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(
|
GsButton(
|
||||||
onClick = { VoiceReply.cancel(); navigator.notifications(replyTo = n.key) },
|
onClick = { VoiceReply.cancel(); navigator.notifications(replyTo = n.key) },
|
||||||
tone = GsTone.Neutral, modifier = wide,
|
tone = GsTone.Neutral, modifier = wide,
|
||||||
) { Text("Type") }
|
) { Text(stringResource(R.string.msg_type)) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
VoiceReply.Phase.SENT, VoiceReply.Phase.IDLE -> Unit
|
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) {
|
private fun voiceHeading(s: VoiceReply.State, who: String): String = when (s.phase) {
|
||||||
VoiceReply.Phase.READING, VoiceReply.Phase.READ -> who
|
VoiceReply.Phase.READING, VoiceReply.Phase.READ -> who
|
||||||
VoiceReply.Phase.OFFERING, VoiceReply.Phase.ASKING -> "Reply to $who"
|
VoiceReply.Phase.OFFERING, VoiceReply.Phase.ASKING -> stringResource(R.string.msg_reply_to, who)
|
||||||
VoiceReply.Phase.LISTENING -> "Listening"
|
VoiceReply.Phase.LISTENING -> stringResource(R.string.msg_listening)
|
||||||
VoiceReply.Phase.CONFIRMING -> "Sending to $who"
|
VoiceReply.Phase.CONFIRMING -> stringResource(R.string.msg_sending_to, who)
|
||||||
VoiceReply.Phase.SENT -> "Sent to $who"
|
VoiceReply.Phase.SENT -> stringResource(R.string.msg_sent_to, who)
|
||||||
VoiceReply.Phase.ERROR -> "Couldn't reply"
|
VoiceReply.Phase.ERROR -> stringResource(R.string.msg_reply_failed)
|
||||||
VoiceReply.Phase.IDLE -> ""
|
VoiceReply.Phase.IDLE -> ""
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
package app.seb3thehacker.gearslip.car
|
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 app.seb3thehacker.gearslip.car.theme.*
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
import android.provider.Settings
|
import android.provider.Settings
|
||||||
@@ -8,6 +11,8 @@ import androidx.compose.foundation.Image
|
|||||||
import androidx.compose.foundation.clickable
|
import androidx.compose.foundation.clickable
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Column
|
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.PaddingValues
|
||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
@@ -60,14 +65,14 @@ fun MessagesScreen(packageName: String) {
|
|||||||
val navigator = LocalCarNavigator.current
|
val navigator = LocalCarNavigator.current
|
||||||
val context = LocalContext.current.applicationContext
|
val context = LocalContext.current.applicationContext
|
||||||
val entry = remember(packageName) { findEntry(MESSAGING_PREFIX + packageName) }
|
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 }
|
val threads = history.filter { it.packageName == packageName }
|
||||||
|
|
||||||
LaunchedEffect(history) { CarNotifications.dismissPopupFrom(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)) {
|
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||||
GsIconButton(Icons.AutoMirrored.Filled.ArrowBack, "Back", navigator::back)
|
if (navigator.backLeadsSomewhere) 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))) }
|
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)
|
Text(label, style = MaterialTheme.typography.headlineSmall, fontWeight = FontWeight.Bold)
|
||||||
}
|
}
|
||||||
@@ -82,7 +87,7 @@ fun MessagesScreen(packageName: String) {
|
|||||||
|
|
||||||
if (threads.isEmpty()) {
|
if (threads.isEmpty()) {
|
||||||
Text(
|
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,
|
style = MaterialTheme.typography.bodyLarge,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
)
|
)
|
||||||
@@ -104,6 +109,7 @@ fun MessagesScreen(packageName: String) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@OptIn(ExperimentalLayoutApi::class)
|
||||||
@Composable
|
@Composable
|
||||||
private fun ConversationCard(n: CarNotification, onRead: () -> Unit, onReply: () -> Unit, onMarkRead: () -> Unit) {
|
private fun ConversationCard(n: CarNotification, onRead: () -> Unit, onReply: () -> Unit, onMarkRead: () -> Unit) {
|
||||||
var expanded by rememberSaveable(n.key) { mutableStateOf(false) }
|
var expanded by rememberSaveable(n.key) { mutableStateOf(false) }
|
||||||
@@ -143,40 +149,48 @@ private fun ConversationCard(n: CarNotification, onRead: () -> Unit, onReply: ()
|
|||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
maxLines = 1,
|
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) {
|
if (!expanded && lines.size > COLLAPSED_LINES) {
|
||||||
Text(
|
Text(
|
||||||
"${lines.size - COLLAPSED_LINES} earlier",
|
pluralStringResource(R.plurals.msg_earlier, lines.size - COLLAPSED_LINES, lines.size - COLLAPSED_LINES),
|
||||||
style = MaterialTheme.typography.labelMedium,
|
style = MaterialTheme.typography.labelMedium,
|
||||||
color = MaterialTheme.colorScheme.primary,
|
color = MaterialTheme.colorScheme.primary,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
shown.forEach { MessageLine(it, showSender = n.isGroup) }
|
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))
|
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
|
@Composable
|
||||||
private fun MessageLine(line: ChatLine, showSender: Boolean) {
|
private fun MessageLine(line: ChatLine, showSender: Boolean) {
|
||||||
val who = when {
|
val who = when {
|
||||||
line.sender == null -> "You: "
|
line.sender == null -> stringResource(R.string.msg_you)
|
||||||
showSender -> "${line.sender}: "
|
showSender -> stringResource(R.string.msg_sender, line.sender)
|
||||||
else -> ""
|
else -> ""
|
||||||
}
|
}
|
||||||
Text(
|
Text(
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
package app.seb3thehacker.gearslip.car
|
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.Image
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.clickable
|
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.layout.widthIn
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.List
|
||||||
import androidx.compose.material.icons.filled.Call
|
import androidx.compose.material.icons.filled.Call
|
||||||
import androidx.compose.material.icons.filled.Info
|
import androidx.compose.material.icons.filled.Info
|
||||||
import androidx.compose.material.icons.filled.Search
|
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(
|
internal class BuiltInApp(
|
||||||
val id: String,
|
val id: String,
|
||||||
|
/** English, for matching what a driver says; [labelRes] is what the screen shows. */
|
||||||
val label: String,
|
val label: String,
|
||||||
|
@param:StringRes val labelRes: Int,
|
||||||
val glyph: ImageVector,
|
val glyph: ImageVector,
|
||||||
val screen: CarScreen?,
|
val screen: CarScreen?,
|
||||||
val open: (CarNavigator) -> Unit,
|
val open: (CarNavigator) -> Unit,
|
||||||
)
|
)
|
||||||
|
|
||||||
internal object BuiltInApps {
|
internal object BuiltInApps {
|
||||||
val Web = BuiltInApp("web", "Browser", Icons.Filled.Search, CarScreen.App("web")) { it.open("web") }
|
val Web = BuiltInApp("web", "Browser", R.string.app_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 ScreenSharing = BuiltInApp("phone", "Screen sharing", R.string.app_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 Phone = BuiltInApp("dialer", "Phone", R.string.carset_phone, Icons.Filled.Call, CarScreen.App("dialer")) { it.open("dialer") }
|
||||||
val VehicleData = BuiltInApp("vehicle", "Vehicle data", Icons.Filled.Info, CarScreen.VehicleData) { it.vehicleData() }
|
val VehicleData = BuiltInApp("vehicle", "Vehicle data", R.string.car_vehicle_data, Icons.Filled.Info, CarScreen.VehicleData) { it.vehicleData() }
|
||||||
val Settings = BuiltInApp("settings", "Settings", Icons.Filled.Settings, CarScreen.Settings) { it.settings() }
|
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. */
|
/** 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 all = listOf(Web, ScreenSharing, Phone, VehicleData, Settings, Exit, AllApps)
|
||||||
val entries: List<Entry> = all.map { Entry(it.label, icon = null, builtIn = it) }
|
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]). */
|
/** Vehicle data is experimental: raw, unverified sensor labels (see [CarSensors]). */
|
||||||
@Composable
|
@Composable
|
||||||
internal fun rememberVehicleDataShown(): Boolean {
|
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.icon?.let { Image(it, null, Modifier.size(44.dp).clip(RoundedCornerShape(10.dp))) }
|
||||||
entry.builtIn?.let { Icon(it.glyph, null, Modifier.size(36.dp)) }
|
entry.builtIn?.let { Icon(it.glyph, null, Modifier.size(36.dp)) }
|
||||||
Text(
|
Text(
|
||||||
entry.label,
|
entry.shownLabel(),
|
||||||
style = MaterialTheme.typography.titleLarge,
|
style = MaterialTheme.typography.titleLarge,
|
||||||
fontWeight = FontWeight.SemiBold,
|
fontWeight = FontWeight.SemiBold,
|
||||||
modifier = Modifier.padding(start = 14.dp),
|
modifier = Modifier.padding(start = 14.dp),
|
||||||
@@ -232,9 +259,9 @@ internal fun AppMenu(navigator: CarNavigator, running: Map<String, RunningSlot>)
|
|||||||
) {
|
) {
|
||||||
Text(
|
Text(
|
||||||
when {
|
when {
|
||||||
isPinned -> "Unpin from nav bar"
|
isPinned -> stringResource(R.string.navapps_unpin)
|
||||||
barFull -> "Nav bar is full (${CarSettings.MAX_PINNED} pinned)"
|
barFull -> stringResource(R.string.navapps_full, CarSettings.MAX_PINNED)
|
||||||
else -> "Pin to nav bar"
|
else -> stringResource(R.string.navapps_pin)
|
||||||
},
|
},
|
||||||
style = MaterialTheme.typography.titleMedium,
|
style = MaterialTheme.typography.titleMedium,
|
||||||
)
|
)
|
||||||
@@ -248,13 +275,13 @@ internal fun AppMenu(navigator: CarNavigator, running: Map<String, RunningSlot>)
|
|||||||
},
|
},
|
||||||
tone = GsTone.Danger,
|
tone = GsTone.Danger,
|
||||||
modifier = Modifier.fillMaxWidth().height(56.dp),
|
modifier = Modifier.fillMaxWidth().height(56.dp),
|
||||||
) { Text("Close app", style = MaterialTheme.typography.titleMedium) }
|
) { Text(stringResource(R.string.navapps_close), style = MaterialTheme.typography.titleMedium) }
|
||||||
}
|
}
|
||||||
GsButton(
|
GsButton(
|
||||||
onClick = navigator::dismissAppMenu,
|
onClick = navigator::dismissAppMenu,
|
||||||
tone = GsTone.Neutral,
|
tone = GsTone.Neutral,
|
||||||
modifier = Modifier.fillMaxWidth().height(56.dp),
|
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
|
@Composable
|
||||||
private fun MoveButtons(id: String) {
|
private fun MoveButtons(id: String) {
|
||||||
val custom by CarSettings.appOrder.collectAsState()
|
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)
|
val index = order.indexOf(id)
|
||||||
if (index < 0) return
|
if (index < 0) return
|
||||||
fun move(by: Int) {
|
fun move(by: Int) {
|
||||||
@@ -281,19 +309,19 @@ private fun MoveButtons(id: String) {
|
|||||||
tone = GsTone.Tonal,
|
tone = GsTone.Tonal,
|
||||||
enabled = index > 0,
|
enabled = index > 0,
|
||||||
modifier = Modifier.weight(1f).height(56.dp),
|
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(
|
GsButton(
|
||||||
onClick = { move(1) },
|
onClick = { move(1) },
|
||||||
tone = GsTone.Tonal,
|
tone = GsTone.Tonal,
|
||||||
enabled = index < order.lastIndex,
|
enabled = index < order.lastIndex,
|
||||||
modifier = Modifier.weight(1f).height(56.dp),
|
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()) {
|
if (custom.isNotEmpty()) {
|
||||||
GsButton(
|
GsButton(
|
||||||
onClick = { CarSettings.setAppOrder(emptyList()) },
|
onClick = { CarSettings.setAppOrder(emptyList()) },
|
||||||
tone = GsTone.Neutral,
|
tone = GsTone.Neutral,
|
||||||
modifier = Modifier.fillMaxWidth().height(56.dp),
|
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
|
package app.seb3thehacker.gearslip.car
|
||||||
|
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import app.seb3thehacker.gearslip.R
|
||||||
import app.seb3thehacker.gearslip.car.theme.*
|
import app.seb3thehacker.gearslip.car.theme.*
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
import android.provider.Settings
|
import android.provider.Settings
|
||||||
@@ -79,12 +81,12 @@ fun NotificationsSection() {
|
|||||||
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
Text(
|
Text(
|
||||||
"Notifications",
|
stringResource(R.string.notif_title),
|
||||||
style = MaterialTheme.typography.titleMedium,
|
style = MaterialTheme.typography.titleMedium,
|
||||||
color = MaterialTheme.colorScheme.primary,
|
color = MaterialTheme.colorScheme.primary,
|
||||||
modifier = Modifier.weight(1f),
|
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) {
|
if (!listening) {
|
||||||
@@ -95,9 +97,10 @@ fun NotificationsSection() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (history.isEmpty()) {
|
// With access off, "nothing yet" would promise notifications that can't come.
|
||||||
|
if (history.isEmpty() && listening) {
|
||||||
Text(
|
Text(
|
||||||
"Nothing yet. New notifications from the phone will show up here.",
|
stringResource(R.string.notif_empty),
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
)
|
)
|
||||||
@@ -111,10 +114,11 @@ fun NotificationsSection() {
|
|||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
internal fun AccessBanner(onOpen: () -> Unit) {
|
internal fun AccessBanner(onOpen: () -> Unit) {
|
||||||
|
// A setting left off, not a fault: a quiet card, as Android Auto shows one, rather than red.
|
||||||
Surface(
|
Surface(
|
||||||
shape = MaterialTheme.shapes.large,
|
shape = MaterialTheme.shapes.large,
|
||||||
color = MaterialTheme.colorScheme.errorContainer,
|
color = MaterialTheme.colorScheme.surfaceContainerHigh,
|
||||||
contentColor = MaterialTheme.colorScheme.onErrorContainer,
|
contentColor = MaterialTheme.colorScheme.onSurface,
|
||||||
modifier = Modifier.fillMaxWidth().padding(vertical = 8.dp),
|
modifier = Modifier.fillMaxWidth().padding(vertical = 8.dp),
|
||||||
) {
|
) {
|
||||||
Row(
|
Row(
|
||||||
@@ -122,13 +126,13 @@ internal fun AccessBanner(onOpen: () -> Unit) {
|
|||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||||
) {
|
) {
|
||||||
Icon(Icons.Filled.Warning, contentDescription = null)
|
Icon(Icons.Filled.Notifications, contentDescription = null, tint = MaterialTheme.colorScheme.primary)
|
||||||
Text(
|
Text(
|
||||||
"Notifications are off. Allow Gearslip under Settings > Notifications > Notification access on the phone.",
|
stringResource(R.string.notif_off),
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
modifier = Modifier.weight(1f),
|
modifier = Modifier.weight(1f),
|
||||||
)
|
)
|
||||||
GsButton(onClick = onOpen) { Text("Open") }
|
GsButton(onClick = onOpen, tone = GsTone.Tonal) { Text(stringResource(R.string.allow_on_phone)) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -160,10 +164,10 @@ private fun NotificationRow(n: CarNotification, onReply: (CarNotification) -> Un
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
when {
|
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) {
|
n.reply != null -> GsButton(onClick = { VoiceReply.reply(n) }, tone = GsTone.Tonal) {
|
||||||
ReplyGlyph(Modifier.size(20.dp))
|
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 +202,14 @@ private fun ReplyPane(n: CarNotification, onDone: () -> Unit) {
|
|||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
) {
|
) {
|
||||||
GsIconButton(Icons.AutoMirrored.Filled.ArrowBack, "Back", onDone)
|
GsIconButton(Icons.AutoMirrored.Filled.ArrowBack, stringResource(R.string.back), onDone)
|
||||||
Surface(
|
Surface(
|
||||||
shape = RoundedCornerShape(24.dp),
|
shape = RoundedCornerShape(24.dp),
|
||||||
color = MaterialTheme.colorScheme.surfaceContainerHighest,
|
color = MaterialTheme.colorScheme.surfaceContainerHighest,
|
||||||
modifier = Modifier.weight(1f),
|
modifier = Modifier.weight(1f),
|
||||||
) {
|
) {
|
||||||
Text(
|
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,
|
color = if (text.isEmpty()) MaterialTheme.colorScheme.onSurfaceVariant else MaterialTheme.colorScheme.onSurface,
|
||||||
maxLines = 1,
|
maxLines = 1,
|
||||||
overflow = TextOverflow.Ellipsis,
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
package app.seb3thehacker.gearslip.car
|
package app.seb3thehacker.gearslip.car
|
||||||
|
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import app.seb3thehacker.gearslip.R
|
||||||
import app.seb3thehacker.gearslip.car.theme.*
|
import app.seb3thehacker.gearslip.car.theme.*
|
||||||
import androidx.compose.foundation.Image
|
import androidx.compose.foundation.Image
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
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) } }
|
value = PhoneApps.cached() ?: withContext(Dispatchers.IO) { PhoneApps.installed(context).also { PhoneApps.warm(context) } }
|
||||||
}
|
}
|
||||||
|
|
||||||
Column(Modifier.fillMaxSize().padding(horizontal = 20.dp, vertical = 12.dp)) {
|
Column(Modifier.fillMaxSize().padding(start = 20.dp, end = 20.dp, top = 12.dp)) {
|
||||||
ScreenHeader("Screen sharing")
|
ScreenHeader(stringResource(R.string.app_screen_sharing))
|
||||||
Spacer(Modifier.height(12.dp))
|
Spacer(Modifier.height(12.dp))
|
||||||
if (!TouchRelayService.enabled) {
|
if (!TouchRelayService.enabled) {
|
||||||
TouchRelayWarning()
|
TouchRelayWarning()
|
||||||
@@ -63,7 +65,7 @@ fun PhoneAppsScreen() {
|
|||||||
LazyVerticalGrid(
|
LazyVerticalGrid(
|
||||||
columns = GridCells.Adaptive(minSize = 108.dp),
|
columns = GridCells.Adaptive(minSize = 108.dp),
|
||||||
modifier = Modifier.fillMaxSize(),
|
modifier = Modifier.fillMaxSize(),
|
||||||
contentPadding = PaddingValues(bottom = 8.dp),
|
contentPadding = PaddingValues(bottom = 16.dp),
|
||||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||||
verticalArrangement = Arrangement.spacedBy(12.dp),
|
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||||
) {
|
) {
|
||||||
@@ -94,17 +96,17 @@ private fun TouchRelayWarning() {
|
|||||||
) {
|
) {
|
||||||
Icon(Icons.Filled.Warning, contentDescription = null)
|
Icon(Icons.Filled.Warning, contentDescription = null)
|
||||||
Text(
|
Text(
|
||||||
"Touch is off. Enable Gearslip under Settings > Accessibility on the phone.",
|
stringResource(R.string.phoneapps_touch_off),
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
modifier = Modifier.weight(1f),
|
modifier = Modifier.weight(1f),
|
||||||
)
|
)
|
||||||
GsButton(onClick = { PhoneApps.openAccessibilitySettings(context) }) { Text("Open") }
|
GsButton(onClick = { PhoneApps.openAccessibilitySettings(context) }) { Text(stringResource(R.string.guide_open)) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun PhoneAppTile(app: PhoneApp, onClick: () -> Unit) {
|
internal fun PhoneAppTile(app: PhoneApp, onClick: () -> Unit) {
|
||||||
GsIconBox(
|
GsIconBox(
|
||||||
onClick = onClick,
|
onClick = onClick,
|
||||||
modifier = Modifier.height(120.dp),
|
modifier = Modifier.height(120.dp),
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
package app.seb3thehacker.gearslip.car
|
package app.seb3thehacker.gearslip.car
|
||||||
|
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import app.seb3thehacker.gearslip.R
|
||||||
import app.seb3thehacker.gearslip.car.theme.*
|
import app.seb3thehacker.gearslip.car.theme.*
|
||||||
import android.Manifest
|
import android.Manifest
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
@@ -12,6 +14,7 @@ import app.seb3thehacker.gearslip.GearslipLog
|
|||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.clickable
|
import androidx.compose.foundation.clickable
|
||||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||||
|
import androidx.compose.foundation.layout.PaddingValues
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
@@ -126,23 +129,63 @@ internal fun CallPromptOverlay() {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
private const val TAB_CONTACTS = 0
|
private const val TAB_RECENTS = 0
|
||||||
private const val TAB_KEYPAD = 1
|
private const val TAB_CONTACTS = 1
|
||||||
|
private const val TAB_KEYPAD = 2
|
||||||
|
|
||||||
/** Dialing a number or calling a contact from the car screen: a contacts tab and a keypad tab. */
|
/** One number from the call log: its latest call, whether that was missed, and when. */
|
||||||
|
private data class RecentCall(val contact: Contact, val missed: Boolean, val at: Long)
|
||||||
|
|
||||||
|
/** The latest call to or from each number, newest first, as Android Auto lists them. */
|
||||||
|
private fun loadRecents(context: Context): List<RecentCall>? {
|
||||||
|
if (context.checkSelfPermission(Manifest.permission.READ_CALL_LOG) != PackageManager.PERMISSION_GRANTED) return null
|
||||||
|
val out = mutableListOf<RecentCall>()
|
||||||
|
val seen = mutableSetOf<String>()
|
||||||
|
runCatching {
|
||||||
|
context.contentResolver.query(
|
||||||
|
android.provider.CallLog.Calls.CONTENT_URI,
|
||||||
|
arrayOf(
|
||||||
|
android.provider.CallLog.Calls.NUMBER,
|
||||||
|
android.provider.CallLog.Calls.CACHED_NAME,
|
||||||
|
android.provider.CallLog.Calls.TYPE,
|
||||||
|
android.provider.CallLog.Calls.DATE,
|
||||||
|
),
|
||||||
|
null, null,
|
||||||
|
"${android.provider.CallLog.Calls.DATE} DESC",
|
||||||
|
)?.use { cursor ->
|
||||||
|
while (cursor.moveToNext() && out.size < RECENTS_SHOWN) {
|
||||||
|
val number = cursor.getString(0)?.takeIf { it.isNotBlank() } ?: continue
|
||||||
|
if (!seen.add(number)) continue
|
||||||
|
val name = cursor.getString(1)?.takeIf { it.isNotBlank() } ?: number
|
||||||
|
val type = cursor.getInt(2)
|
||||||
|
val missed = type == android.provider.CallLog.Calls.MISSED_TYPE || type == android.provider.CallLog.Calls.REJECTED_TYPE
|
||||||
|
out += RecentCall(Contact(name, number), missed, cursor.getLong(3))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}.onFailure { GearslipLog.w("calls: reading the call log failed: ${it.message}") }
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
private const val RECENTS_SHOWN = 50
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calling from the car screen, laid out as Android Auto's dialer: recent calls first, since
|
||||||
|
* calling back is the usual reason to open it, then contacts and a keypad.
|
||||||
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
fun PhoneDialerScreen() {
|
fun PhoneDialerScreen() {
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
var hasPermissions by remember { mutableStateOf(hasCallPermissions(context)) }
|
var hasPermissions by remember { mutableStateOf(hasCallPermissions(context)) }
|
||||||
var tab by remember { mutableStateOf(TAB_CONTACTS) }
|
var tab by remember { mutableStateOf(TAB_RECENTS) }
|
||||||
|
|
||||||
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.
|
// 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) {
|
if (hasPermissions) {
|
||||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
DialerTab("Contacts", tab == TAB_CONTACTS, Modifier.width(140.dp)) { tab = TAB_CONTACTS }
|
DialerTab(stringResource(R.string.dialer_recents), tab == TAB_RECENTS, Modifier.width(140.dp)) { tab = TAB_RECENTS }
|
||||||
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 +196,11 @@ fun PhoneDialerScreen() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Box(Modifier.weight(1f).fillMaxWidth()) {
|
Box(Modifier.weight(1f).fillMaxWidth()) {
|
||||||
if (tab == TAB_CONTACTS) ContactsList(context) else Keypad(context)
|
when (tab) {
|
||||||
|
TAB_RECENTS -> RecentsList(context)
|
||||||
|
TAB_CONTACTS -> ContactsList(context)
|
||||||
|
else -> Box(Modifier.padding(bottom = 16.dp)) { Keypad(context) }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -168,14 +215,13 @@ private fun MissingPermission(context: Context, onGranted: () -> Unit) {
|
|||||||
}
|
}
|
||||||
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
|
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||||
Text(
|
Text(
|
||||||
"Gearslip needs the Phone and Contacts permissions to dial from here. Allow them on " +
|
stringResource(R.string.dialer_needs_permission),
|
||||||
"the phone when you're parked.",
|
|
||||||
style = MaterialTheme.typography.bodyLarge,
|
style = MaterialTheme.typography.bodyLarge,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
)
|
)
|
||||||
GsButton(tone = GsTone.Tonal, onClick = {
|
GsButton(tone = GsTone.Tonal, onClick = {
|
||||||
app.seb3thehacker.gearslip.PermissionActivity.ask(context, CALL_PERMISSIONS)
|
app.seb3thehacker.gearslip.PermissionActivity.ask(context, CALL_PERMISSIONS)
|
||||||
}) { Text("Ask on the phone") }
|
}) { Text(stringResource(R.string.dialer_ask)) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -211,7 +257,7 @@ private fun Keypad(context: Context) {
|
|||||||
|
|
||||||
Column(Modifier.fillMaxSize(), verticalArrangement = Arrangement.spacedBy(12.dp)) {
|
Column(Modifier.fillMaxSize(), verticalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||||
Text(
|
Text(
|
||||||
number.ifEmpty { "Enter a number" },
|
number.ifEmpty { stringResource(R.string.dialer_enter_number) },
|
||||||
style = MaterialTheme.typography.headlineSmall,
|
style = MaterialTheme.typography.headlineSmall,
|
||||||
color = if (number.isEmpty()) MaterialTheme.colorScheme.onSurfaceVariant else MaterialTheme.colorScheme.onSurface,
|
color = if (number.isEmpty()) MaterialTheme.colorScheme.onSurfaceVariant else MaterialTheme.colorScheme.onSurface,
|
||||||
maxLines = 1,
|
maxLines = 1,
|
||||||
@@ -240,7 +286,7 @@ private fun Keypad(context: Context) {
|
|||||||
colors = gsColors(GsTone.Primary),
|
colors = gsColors(GsTone.Primary),
|
||||||
enabled = number.isNotEmpty(),
|
enabled = number.isNotEmpty(),
|
||||||
modifier = Modifier.weight(1f).fillMaxWidth(),
|
modifier = Modifier.weight(1f).fillMaxWidth(),
|
||||||
) { Icon(Icons.Filled.Call, "Call") }
|
) { Icon(Icons.Filled.Call, stringResource(R.string.dialer_call)) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -258,6 +304,53 @@ private fun KeypadButton(digit: String, modifier: Modifier, onClick: () -> Unit)
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun RecentsList(context: Context) {
|
||||||
|
// Null until read, then null again only when the call log isn't allowed.
|
||||||
|
var loaded by remember { mutableStateOf(false) }
|
||||||
|
val recents by produceState<List<RecentCall>?>(null, context) {
|
||||||
|
value = withContext(Dispatchers.IO) { loadRecents(context) }
|
||||||
|
loaded = true
|
||||||
|
}
|
||||||
|
var pendingCall by remember { mutableStateOf<Contact?>(null) }
|
||||||
|
val list = recents
|
||||||
|
|
||||||
|
when {
|
||||||
|
!loaded -> Unit
|
||||||
|
list == null -> Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||||
|
Text(
|
||||||
|
stringResource(R.string.dialer_recents_needs_permission),
|
||||||
|
style = MaterialTheme.typography.bodyLarge,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
GsButton(tone = GsTone.Tonal, onClick = {
|
||||||
|
app.seb3thehacker.gearslip.PermissionActivity.ask(context, arrayOf(Manifest.permission.READ_CALL_LOG))
|
||||||
|
}) { Text(stringResource(R.string.dialer_ask)) }
|
||||||
|
}
|
||||||
|
list.isEmpty() -> Text(
|
||||||
|
stringResource(R.string.dialer_no_recents),
|
||||||
|
style = MaterialTheme.typography.bodyLarge,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
else -> LazyColumn(Modifier.fillMaxSize(), contentPadding = PaddingValues(bottom = 16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
|
items(list) { call ->
|
||||||
|
val now = System.currentTimeMillis()
|
||||||
|
val ago = android.text.format.DateUtils.getRelativeTimeSpanString(call.at, now, android.text.format.DateUtils.MINUTE_IN_MILLIS).toString()
|
||||||
|
val detail = if (call.missed) stringResource(R.string.dialer_missed, ago) else ago
|
||||||
|
ContactRow(call.contact, detail = detail, alert = call.missed) { pendingCall = call.contact }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pendingCall?.let { contact ->
|
||||||
|
ConfirmCallOverlay(
|
||||||
|
contact = contact,
|
||||||
|
onConfirm = { placeCall(context, contact.number); pendingCall = null },
|
||||||
|
onDismiss = { pendingCall = null },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun ContactsList(context: Context) {
|
private fun ContactsList(context: Context) {
|
||||||
val contacts by produceState(emptyList<Contact>(), context) {
|
val contacts by produceState(emptyList<Contact>(), context) {
|
||||||
@@ -282,16 +375,16 @@ private fun ContactsList(context: Context) {
|
|||||||
Box(Modifier.weight(1f).fillMaxWidth()) {
|
Box(Modifier.weight(1f).fillMaxWidth()) {
|
||||||
when {
|
when {
|
||||||
contacts.isEmpty() -> Text(
|
contacts.isEmpty() -> Text(
|
||||||
"No contacts with a phone number found.",
|
stringResource(R.string.dialer_no_contacts),
|
||||||
style = MaterialTheme.typography.bodyLarge,
|
style = MaterialTheme.typography.bodyLarge,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
)
|
)
|
||||||
filtered.isEmpty() -> Text(
|
filtered.isEmpty() -> Text(
|
||||||
"No contacts match \"$query\".",
|
stringResource(R.string.dialer_no_match, query),
|
||||||
style = MaterialTheme.typography.bodyLarge,
|
style = MaterialTheme.typography.bodyLarge,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
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 ->
|
items(filtered) { contact ->
|
||||||
ContactRow(contact) { pendingCall = contact }
|
ContactRow(contact) { pendingCall = contact }
|
||||||
}
|
}
|
||||||
@@ -344,14 +437,14 @@ private fun ConfirmCallOverlay(contact: Contact, onConfirm: () -> Unit, onDismis
|
|||||||
verticalArrangement = Arrangement.spacedBy(20.dp),
|
verticalArrangement = Arrangement.spacedBy(20.dp),
|
||||||
horizontalAlignment = Alignment.CenterHorizontally,
|
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)
|
Text(contact.number, style = MaterialTheme.typography.titleLarge, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||||
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(16.dp)) {
|
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(16.dp)) {
|
||||||
GsButton(onClick = onDismiss, tone = GsTone.Outline, modifier = Modifier.weight(1f).height(56.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)) {
|
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 +461,7 @@ private fun ContactSearchField(query: String, active: Boolean, onToggle: () -> U
|
|||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
) {
|
) {
|
||||||
Text(
|
Text(
|
||||||
query.ifEmpty { "Search contacts" },
|
query.ifEmpty { stringResource(R.string.dialer_search) },
|
||||||
style = MaterialTheme.typography.bodyLarge,
|
style = MaterialTheme.typography.bodyLarge,
|
||||||
color = if (query.isEmpty()) MaterialTheme.colorScheme.onSurfaceVariant else MaterialTheme.colorScheme.onSurface,
|
color = if (query.isEmpty()) MaterialTheme.colorScheme.onSurfaceVariant else MaterialTheme.colorScheme.onSurface,
|
||||||
maxLines = 1,
|
maxLines = 1,
|
||||||
@@ -379,7 +472,7 @@ private fun ContactSearchField(query: String, active: Boolean, onToggle: () -> U
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun ContactRow(contact: Contact, onClick: () -> Unit) {
|
private fun ContactRow(contact: Contact, detail: String = contact.number, alert: Boolean = false, onClick: () -> Unit) {
|
||||||
Surface(
|
Surface(
|
||||||
onClick = onClick,
|
onClick = onClick,
|
||||||
shape = MaterialTheme.shapes.large,
|
shape = MaterialTheme.shapes.large,
|
||||||
@@ -404,9 +497,13 @@ private fun ContactRow(contact: Contact, onClick: () -> Unit) {
|
|||||||
}
|
}
|
||||||
Column(Modifier.weight(1f)) {
|
Column(Modifier.weight(1f)) {
|
||||||
Text(contact.name, style = MaterialTheme.typography.titleMedium, maxLines = 1, overflow = TextOverflow.Ellipsis)
|
Text(contact.name, style = MaterialTheme.typography.titleMedium, maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||||
Text(contact.number, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
Text(
|
||||||
|
detail,
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = if (alert) MaterialTheme.colorScheme.error else 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
|
package app.seb3thehacker.gearslip.car
|
||||||
|
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import app.seb3thehacker.gearslip.R
|
||||||
import android.media.session.PlaybackState
|
import android.media.session.PlaybackState
|
||||||
import android.support.v4.media.session.PlaybackStateCompat
|
import android.support.v4.media.session.PlaybackStateCompat
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
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)
|
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
|
@Composable
|
||||||
internal fun TransportRow(now: NowPlaying, media: CarMedia, skip: Dp, modifier: Modifier = Modifier) {
|
internal fun TransportRow(now: NowPlaying, media: CarMedia, skip: Dp, modifier: Modifier = Modifier, playScale: Float = 1.25f) {
|
||||||
val play = skip * 1.25f
|
val play = skip * playScale
|
||||||
Row(modifier, horizontalArrangement = Arrangement.SpaceEvenly, verticalAlignment = Alignment.CenterVertically) {
|
Row(modifier, horizontalArrangement = Arrangement.SpaceEvenly, verticalAlignment = Alignment.CenterVertically) {
|
||||||
GsIconButton(
|
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,
|
enabled = now.canDo(PlaybackState.ACTION_SKIP_TO_PREVIOUS), size = skip, iconSize = skip * 0.58f, shape = CircleShape,
|
||||||
)
|
)
|
||||||
GsIconButton(
|
GsIconButton(
|
||||||
if (now.playing) MediaIcons.Pause else Icons.Filled.PlayArrow,
|
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,
|
media::togglePlay,
|
||||||
tone = GsTone.Primary, size = play, iconSize = play * 0.58f, shape = CircleShape,
|
tone = GsTone.Primary, size = play, iconSize = play * 0.58f, shape = CircleShape,
|
||||||
)
|
)
|
||||||
GsIconButton(
|
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,
|
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.
|
// Gearslip's own stand in for apps that report a heart rating or shuffle and repeat modes.
|
||||||
val likeAction = now.likeAction
|
val likeAction = now.likeAction
|
||||||
if (likeAction != null) {
|
if (likeAction != null) {
|
||||||
CustomActionButton(likeAction, media, size, toggle = true)
|
CustomActionButton(likeAction, media, size, on = liked == true)
|
||||||
} else {
|
} else {
|
||||||
GsIconButton(
|
GsIconButton(
|
||||||
if (liked == true) Icons.Filled.Favorite else Icons.Filled.FavoriteBorder,
|
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,
|
media::toggleLike,
|
||||||
enabled = liked != null,
|
enabled = liked != null,
|
||||||
tone = if (liked == true) GsTone.Primary else GsTone.Neutral,
|
tone = if (liked == true) GsTone.Primary else GsTone.Neutral,
|
||||||
@@ -156,10 +158,10 @@ internal fun PlayModeRow(
|
|||||||
}
|
}
|
||||||
val shuffleAction = now.shuffleAction
|
val shuffleAction = now.shuffleAction
|
||||||
if (shuffleAction != null) {
|
if (shuffleAction != null) {
|
||||||
CustomActionButton(shuffleAction, media, size, toggle = true)
|
CustomActionButton(shuffleAction, media, size, on = shuffleAction.on)
|
||||||
} else {
|
} else {
|
||||||
GsIconButton(
|
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,
|
media::toggleShuffle,
|
||||||
enabled = shuffleKnown,
|
enabled = shuffleKnown,
|
||||||
tone = if (shuffleOn) GsTone.Primary else GsTone.Neutral,
|
tone = if (shuffleOn) GsTone.Primary else GsTone.Neutral,
|
||||||
@@ -168,11 +170,11 @@ internal fun PlayModeRow(
|
|||||||
}
|
}
|
||||||
val repeatAction = now.repeatAction
|
val repeatAction = now.repeatAction
|
||||||
if (repeatAction != null) {
|
if (repeatAction != null) {
|
||||||
CustomActionButton(repeatAction, media, size, toggle = true)
|
CustomActionButton(repeatAction, media, size, on = repeatAction.on)
|
||||||
} else {
|
} else {
|
||||||
GsIconButton(
|
GsIconButton(
|
||||||
if (modes.repeat == PlaybackStateCompat.REPEAT_MODE_ONE) MediaIcons.RepeatOne else MediaIcons.Repeat,
|
if (modes.repeat == PlaybackStateCompat.REPEAT_MODE_ONE) MediaIcons.RepeatOne else MediaIcons.Repeat,
|
||||||
"Repeat",
|
stringResource(R.string.player_repeat),
|
||||||
media::cycleRepeat,
|
media::cycleRepeat,
|
||||||
enabled = repeatKnown,
|
enabled = repeatKnown,
|
||||||
tone = if (repeatOn) GsTone.Primary else GsTone.Neutral,
|
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.
|
* not Gearslip's, so it's loaded from there. Its name stands in if the icon won't load.
|
||||||
*/
|
*/
|
||||||
@Composable
|
@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)
|
val icon = rememberActionIcon(action)
|
||||||
GsIconBox(
|
GsIconBox(
|
||||||
onClick = { media.custom(action) },
|
onClick = { media.custom(action) },
|
||||||
modifier = Modifier.size(size),
|
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) {
|
if (icon != null) {
|
||||||
Icon(icon, action.name, Modifier.size(size * 0.52f))
|
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
|
* Three soft pools of the cover's colours: top left, top right and the middle, read off a 3x3
|
||||||
* capped amount of its saturation come from the art; the lightness stays close to the theme's
|
* shrink of it. Still, and redrawn only while a new song's colours fade in, so it costs the video
|
||||||
* own ([darkLightness] or [lightLightness]), so text drawn in the theme's colours reads the same
|
* stream nothing between songs. Fainter on a light card, where colour shows more. [wide] is for
|
||||||
* on any cover. Art with no real colour in it keeps [fallback]. Fades between songs rather than
|
* a whole screen: the pools gather on its left, behind the cover, and reach less far across it.
|
||||||
* jumping.
|
|
||||||
*/
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
internal fun rememberArtColor(
|
internal fun AlbumGlow(art: android.graphics.Bitmap?, modifier: Modifier, wide: Boolean = false) {
|
||||||
art: android.graphics.Bitmap?,
|
val spots by produceState<List<Color>>(emptyList(), art) {
|
||||||
fallback: Color,
|
value = art?.let { kotlinx.coroutines.withContext(kotlinx.coroutines.Dispatchers.Default) { glowColours(it) } }.orEmpty()
|
||||||
darkLightness: Float = 0.2f,
|
}
|
||||||
lightLightness: Float = 0.86f,
|
val strength = if (MaterialTheme.colorScheme.surface.luminance() < 0.5f) 0.30f else 0.18f
|
||||||
): Color {
|
val fade = tween<Color>(600)
|
||||||
val dark = fallback.luminance() < 0.5f
|
val first by androidx.compose.animation.animateColorAsState(spots.getOrNull(0)?.copy(alpha = strength) ?: Color.Transparent, fade, label = "glow")
|
||||||
val tint by produceState<ArtColor.Tint?>(null, art) {
|
val second by androidx.compose.animation.animateColorAsState(spots.getOrNull(1)?.copy(alpha = strength) ?: Color.Transparent, fade, label = "glow")
|
||||||
value = art?.let { withContext(Dispatchers.Default) { runCatching { ArtColor.of(it) }.getOrNull() } }
|
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)
|
/** The cover's colours at its top left, top right and middle. */
|
||||||
} ?: fallback
|
private fun glowColours(art: android.graphics.Bitmap): List<Color> {
|
||||||
return animateColorAsState(target, tween(600), label = "art colour").value
|
// 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
|
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.Arrangement
|
||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
import androidx.compose.foundation.layout.RowScope
|
import androidx.compose.foundation.layout.RowScope
|
||||||
@@ -17,9 +19,9 @@ import androidx.compose.ui.unit.dp
|
|||||||
import app.seb3thehacker.gearslip.car.theme.GsIconButton
|
import app.seb3thehacker.gearslip.car.theme.GsIconButton
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The top of every screen reached from Home: a back button in the current theme, the title, and
|
* The top of every screen reached from Home: the title, room on the right for the screen's own
|
||||||
* room on the right for the screen's own controls. A car touchscreen has no hardware back button,
|
* controls, and a back button when the screen under this one is more than Home or the app grid -
|
||||||
* so this is the way out of a screen.
|
* those the nav bar already reaches, so a back button there only repeated it.
|
||||||
*/
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
fun ScreenHeader(title: String, modifier: Modifier = Modifier, trailing: @Composable RowScope.() -> Unit = {}) {
|
fun ScreenHeader(title: String, modifier: Modifier = Modifier, trailing: @Composable RowScope.() -> Unit = {}) {
|
||||||
@@ -29,7 +31,7 @@ fun ScreenHeader(title: String, modifier: Modifier = Modifier, trailing: @Compos
|
|||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||||
) {
|
) {
|
||||||
GsIconButton(Icons.AutoMirrored.Filled.ArrowBack, "Back", navigator::back)
|
if (navigator.backLeadsSomewhere) GsIconButton(Icons.AutoMirrored.Filled.ArrowBack, stringResource(R.string.back), navigator::back)
|
||||||
Text(
|
Text(
|
||||||
title,
|
title,
|
||||||
style = MaterialTheme.typography.headlineSmall,
|
style = MaterialTheme.typography.headlineSmall,
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
package app.seb3thehacker.gearslip.car
|
package app.seb3thehacker.gearslip.car
|
||||||
|
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import app.seb3thehacker.gearslip.R
|
||||||
import app.seb3thehacker.gearslip.car.theme.*
|
import app.seb3thehacker.gearslip.car.theme.*
|
||||||
import android.graphics.Rect
|
import android.graphics.Rect
|
||||||
import androidx.compose.foundation.layout.widthIn
|
import androidx.compose.foundation.layout.widthIn
|
||||||
@@ -28,7 +30,6 @@ import androidx.compose.foundation.shape.CircleShape
|
|||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.filled.Close
|
import androidx.compose.material.icons.filled.Close
|
||||||
import androidx.compose.material.icons.filled.KeyboardArrowUp
|
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
@@ -37,8 +38,11 @@ import androidx.compose.runtime.getValue
|
|||||||
import androidx.compose.runtime.collectAsState
|
import androidx.compose.runtime.collectAsState
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.unit.sp
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.layout.layoutId
|
||||||
import androidx.compose.ui.draw.clip
|
import androidx.compose.ui.draw.clip
|
||||||
import androidx.compose.ui.draw.rotate
|
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.layout.ContentScale
|
import androidx.compose.ui.layout.ContentScale
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
@@ -47,8 +51,6 @@ import androidx.compose.ui.unit.dp
|
|||||||
import androidx.car.app.model.CarIcon
|
import androidx.car.app.model.CarIcon
|
||||||
import androidx.car.app.model.Template
|
import androidx.car.app.model.Template
|
||||||
import androidx.car.app.model.PlaceListMapTemplate
|
import androidx.car.app.model.PlaceListMapTemplate
|
||||||
import androidx.car.app.navigation.model.Lane
|
|
||||||
import androidx.car.app.navigation.model.LaneDirection
|
|
||||||
import androidx.car.app.navigation.model.MapTemplate
|
import androidx.car.app.navigation.model.MapTemplate
|
||||||
import androidx.car.app.navigation.model.MapWithContentTemplate
|
import androidx.car.app.navigation.model.MapWithContentTemplate
|
||||||
import androidx.car.app.navigation.model.NavigationTemplate
|
import androidx.car.app.navigation.model.NavigationTemplate
|
||||||
@@ -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
|
* 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.
|
* 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 NavigationTemplate -> measured ?: Rect(0, 0, width, height)
|
||||||
is MapWithContentTemplate, is MapTemplate, is PlaceListNavigationTemplate,
|
is MapWithContentTemplate, is MapTemplate, is PlaceListNavigationTemplate,
|
||||||
is RoutePreviewNavigationTemplate, is PlaceListMapTemplate,
|
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)
|
null -> Rect(0, 0, width, height)
|
||||||
else -> Rect(0, 0, 0, 0)
|
else -> Rect(0, 0, 0, 0)
|
||||||
}
|
}
|
||||||
@@ -152,6 +155,24 @@ internal class ChromeCover {
|
|||||||
|
|
||||||
internal val LocalChromeCover = compositionLocalOf<ChromeCover?> { null }
|
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
|
* 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.
|
* than following the slide, so the app is told once instead of every frame.
|
||||||
@@ -182,8 +203,7 @@ private fun NavigationChrome(template: NavigationTemplate, modifier: Modifier) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Cards down the left, map on the right: the turn on top, the trip along the bottom, the map
|
* The drive's card down the left, map on the right, the map buttons at the right edge. The whole column counts as covered, so the app centres its map in
|
||||||
* buttons at the right edge. The whole column counts as covered, so the app centres its map in
|
|
||||||
* what is left over and the car sits right of the cards rather than under them. Pan mode drops
|
* what is left over and the car sits right of the cards rather than under them. Pan mode drops
|
||||||
* the cards and gives the map the whole width.
|
* the cards and gives the map the whole width.
|
||||||
*/
|
*/
|
||||||
@@ -196,101 +216,180 @@ private fun NavigationBars(
|
|||||||
) {
|
) {
|
||||||
val connection = LocalCarAppConnection.current
|
val connection = LocalCarAppConnection.current
|
||||||
val pan by connection.panMode.collectAsState()
|
val pan by connection.panMode.collectAsState()
|
||||||
|
val atEnd = LocalCardsAtEnd.current
|
||||||
|
val cardEdge = cardEdge
|
||||||
|
val buttonEdge = buttonEdge
|
||||||
androidx.compose.foundation.layout.BoxWithConstraints(modifier.fillMaxSize()) {
|
androidx.compose.foundation.layout.BoxWithConstraints(modifier.fillMaxSize()) {
|
||||||
val column = cardColumnWidth(maxWidth)
|
val column = cardColumnWidth(maxWidth)
|
||||||
Row(Modifier.fillMaxSize()) {
|
val cards = @Composable {
|
||||||
if (routing != null || estimate != null) {
|
if (routing != null || estimate != null) {
|
||||||
Column(
|
Column(Modifier.width(column).fillMaxHeight().covers("nav-cards", cardEdge).padding(12.dp)) {
|
||||||
Modifier.width(column).fillMaxHeight().covers("nav-cards", CoverEdge.START).padding(12.dp),
|
// As tall as what it holds, up to the column's height.
|
||||||
verticalArrangement = Arrangement.spacedBy(12.dp),
|
NavigationCard(
|
||||||
) {
|
routing,
|
||||||
routing?.let { RoutingCard(it, Modifier.fillMaxWidth()) }
|
estimate,
|
||||||
Spacer(Modifier.weight(1f))
|
showStop = !template.actionStrip.hasStop(),
|
||||||
estimate?.let { TravelEstimateCard(it, Modifier.fillMaxWidth()) }
|
Modifier.fillMaxWidth().weight(1f, fill = false),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Spacer(Modifier.weight(1f))
|
}
|
||||||
|
val buttons = @Composable {
|
||||||
RightEdgeControls(
|
RightEdgeControls(
|
||||||
if (pan) null else template.actionStrip,
|
if (pan) null else template.actionStrip,
|
||||||
template.mapActionStrip,
|
template.mapActionStrip,
|
||||||
Modifier.padding(12.dp),
|
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()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The manoeuvre, big enough to read at a glance: the arrow and the distance to it, what to do
|
* The drive in one card, top to bottom: the manoeuvre big enough to read at a glance, the lanes
|
||||||
* and onto which road, the lanes to be in, and the turn after it along the bottom.
|
* to be in, the turn after it, and the trip. On a short screen parts drop out whole, the lanes'
|
||||||
|
* words first, then the next turn, then the lanes; the manoeuvre and the trip always stay.
|
||||||
*/
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
private fun RoutingCard(info: RoutingInfo, modifier: Modifier = Modifier) {
|
private fun NavigationCard(info: RoutingInfo?, estimate: TravelEstimate?, showStop: Boolean, modifier: Modifier) {
|
||||||
if (info.isLoading) {
|
val step = info?.currentStep?.takeUnless { info.isLoading }
|
||||||
ChromeSurface(modifier) {
|
|
||||||
Text("Finding a route…", Modifier.padding(horizontal = 18.dp, vertical = 16.dp), style = ChromeType.title)
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
val step = info.currentStep ?: return
|
|
||||||
val scheme = MaterialTheme.colorScheme
|
|
||||||
ChromeSurface(modifier) {
|
ChromeSurface(modifier) {
|
||||||
Column {
|
FitColumn {
|
||||||
Column(
|
if (info?.isLoading == true) {
|
||||||
Modifier.fillMaxWidth().background(scheme.primaryContainer).padding(16.dp),
|
Text(
|
||||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
stringResource(R.string.nav_finding_route),
|
||||||
) {
|
Modifier.layoutId(0).padding(horizontal = 18.dp, vertical = 16.dp),
|
||||||
androidx.compose.runtime.CompositionLocalProvider(
|
style = ChromeType.title,
|
||||||
androidx.compose.material3.LocalContentColor provides scheme.onPrimaryContainer,
|
)
|
||||||
) {
|
}
|
||||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
if (info != null && step != null) {
|
||||||
CarGlyph(step.maneuver?.icon, Modifier.size(64.dp))
|
ManeuverHeader(info, step, Modifier.layoutId(0).fillMaxWidth())
|
||||||
Spacer(Modifier.width(14.dp))
|
// Drawn from the lane data whenever there is some, even beside the app's own picture.
|
||||||
val distance = info.currentDistance.display()
|
step.lanes.orEmpty().takeIf { it.isNotEmpty() }?.let {
|
||||||
if (distance.isNotEmpty()) {
|
LaneBand(it, Modifier.layoutId(2).fillMaxWidth())
|
||||||
Text(
|
LaneHintLine(it, Modifier.layoutId(4).fillMaxWidth())
|
||||||
distance,
|
}
|
||||||
style = MaterialTheme.typography.displaySmall.copy(fontWeight = androidx.compose.ui.text.font.FontWeight.Bold),
|
info.nextStep?.let { NextTurn(it, Modifier.layoutId(3).fillMaxWidth()) }
|
||||||
maxLines = 1,
|
}
|
||||||
)
|
estimate?.let { TripRow(it, showStop, Modifier.layoutId(1).fillMaxWidth()) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
val cue = step.cue.text()
|
}
|
||||||
if (cue.isNotEmpty()) {
|
|
||||||
Text(cue, style = ChromeType.title, maxLines = 2, overflow = TextOverflow.Ellipsis)
|
/** The manoeuvre on the theme's colour: the arrow and the distance to it, what to do, onto which road. */
|
||||||
}
|
@Composable
|
||||||
val road = step.road.text()
|
private fun ManeuverHeader(info: RoutingInfo, step: Step, modifier: Modifier) {
|
||||||
// "Turn left onto Queensberry Drive" already says the road; saying it twice is noise.
|
val scheme = MaterialTheme.colorScheme
|
||||||
if (road.isNotEmpty() && !cue.contains(road, ignoreCase = true)) {
|
Column(
|
||||||
Text(
|
modifier.background(scheme.primaryContainer).padding(16.dp),
|
||||||
road,
|
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
style = ChromeType.body,
|
) {
|
||||||
color = scheme.onPrimaryContainer.copy(alpha = 0.8f),
|
androidx.compose.runtime.CompositionLocalProvider(
|
||||||
maxLines = 1,
|
androidx.compose.material3.LocalContentColor provides scheme.onPrimaryContainer,
|
||||||
overflow = TextOverflow.Ellipsis,
|
) {
|
||||||
)
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
}
|
CarGlyph(step.maneuver?.icon, Modifier.size(64.dp))
|
||||||
if (step.lanesImage != null || step.lanes.orEmpty().isNotEmpty()) {
|
Spacer(Modifier.width(14.dp))
|
||||||
LaneGuidance(step, Modifier.fillMaxWidth().heightIn(max = 56.dp))
|
val distance = info.currentDistance.display()
|
||||||
}
|
if (distance.isNotEmpty()) {
|
||||||
// Rare, and only where an arrow can't explain the junction: worth the card growing for.
|
Text(
|
||||||
info.junctionImage?.let { JunctionImage(it) }
|
distance,
|
||||||
|
style = MaterialTheme.typography.displaySmall.copy(fontWeight = FontWeight.Bold),
|
||||||
|
maxLines = 1,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
info.nextStep?.let { next ->
|
val cue = step.cue.text()
|
||||||
Row(
|
if (cue.isNotEmpty()) {
|
||||||
Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 10.dp),
|
Text(cue, style = ChromeType.title, maxLines = 2, overflow = TextOverflow.Ellipsis)
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
}
|
||||||
) {
|
val road = step.road.text()
|
||||||
Text("Then", style = ChromeType.label, color = scheme.onSurfaceVariant)
|
// "Turn left onto Queensberry Drive" already says the road; saying it twice is noise.
|
||||||
Spacer(Modifier.width(10.dp))
|
if (road.isNotEmpty() && !cue.contains(road, ignoreCase = true)) {
|
||||||
CarGlyph(next.maneuver?.icon, Modifier.size(28.dp))
|
Text(
|
||||||
val nextText = next.cue.text().ifEmpty { next.road.text() }
|
road,
|
||||||
if (nextText.isNotEmpty()) {
|
style = ChromeType.body,
|
||||||
Spacer(Modifier.width(10.dp))
|
color = scheme.onPrimaryContainer.copy(alpha = 0.8f),
|
||||||
Text(nextText, style = ChromeType.body, maxLines = 1, overflow = TextOverflow.Ellipsis)
|
maxLines = 1,
|
||||||
}
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
// An app that sends only its own picture of the lanes gets it shown as it is.
|
||||||
|
if (step.lanes.orEmpty().isEmpty()) {
|
||||||
|
step.lanesImage?.let { JunctionImage(it, Modifier.heightIn(max = 56.dp)) }
|
||||||
|
}
|
||||||
|
// Rare, and only where an arrow can't explain the junction: worth the card growing for.
|
||||||
|
info.junctionImage?.let { JunctionImage(it) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The turn after this one, as large as the instruction above it and wrapping rather than cut
|
||||||
|
* short: leaving a motorway, the next move comes quickly and has to read in a glance.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
private fun NextTurn(next: Step, modifier: Modifier) {
|
||||||
|
val scheme = MaterialTheme.colorScheme
|
||||||
|
// A shade apart from the trip below, so the two never read as one block of text.
|
||||||
|
Row(
|
||||||
|
modifier.background(scheme.surfaceContainerHighest).padding(horizontal = 16.dp, vertical = 12.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
CarGlyph(next.maneuver?.icon, Modifier.size(40.dp))
|
||||||
|
Spacer(Modifier.width(14.dp))
|
||||||
|
Column {
|
||||||
|
Text(
|
||||||
|
stringResource(R.string.nav_then).uppercase(java.util.Locale.getDefault()),
|
||||||
|
style = ChromeType.label.copy(fontWeight = FontWeight.Bold, letterSpacing = 1.2.sp),
|
||||||
|
color = scheme.primary,
|
||||||
|
)
|
||||||
|
val text = next.cue.text().ifEmpty { next.road.text() }
|
||||||
|
if (text.isNotEmpty()) {
|
||||||
|
Text(
|
||||||
|
text,
|
||||||
|
style = ChromeType.body.copy(fontWeight = FontWeight.SemiBold),
|
||||||
|
maxLines = 2,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A column that leaves whole parts out when they don't all fit, rather than cutting the last one
|
||||||
|
* in half. Each part's layoutId is its rank: the lowest is kept first and always shown, then each
|
||||||
|
* next one if there's still room for it. What's kept stays in its written order.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
private fun FitColumn(modifier: Modifier = Modifier, content: @Composable () -> Unit) {
|
||||||
|
androidx.compose.ui.layout.Layout(content, modifier) { measurables, constraints ->
|
||||||
|
val loose = constraints.copy(minHeight = 0, maxHeight = androidx.compose.ui.unit.Constraints.Infinity)
|
||||||
|
val parts = measurables.map { it.measure(loose) }
|
||||||
|
val ranks = measurables.map { it.layoutId as? Int ?: 0 }
|
||||||
|
val kept = BooleanArray(parts.size)
|
||||||
|
var room = if (constraints.hasBoundedHeight) constraints.maxHeight else Int.MAX_VALUE
|
||||||
|
parts.indices.sortedBy { ranks[it] }.forEachIndexed { order, i ->
|
||||||
|
if (order == 0 || parts[i].height <= room) {
|
||||||
|
kept[i] = true
|
||||||
|
room -= parts[i].height
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val width = (parts.maxOfOrNull { it.width } ?: 0).coerceIn(constraints.minWidth, constraints.maxWidth)
|
||||||
|
val height = parts.indices.filter { kept[it] }.sumOf { parts[it].height }
|
||||||
|
layout(width, height.coerceAtLeast(constraints.minHeight)) {
|
||||||
|
var y = 0
|
||||||
|
parts.forEachIndexed { i, part ->
|
||||||
|
if (kept[i]) {
|
||||||
|
part.place(0, y)
|
||||||
|
y += part.height
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -310,120 +409,95 @@ private fun JunctionImage(icon: CarIcon, modifier: Modifier = Modifier) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Which lane to be in before the manoeuvre, either as the app's own picture ([Step.getLanesImage])
|
* The trip along the bottom of the card: time left, coloured as the app colours it (by traffic,
|
||||||
* or as a row of arrows built from its structured lane data - whichever it sent. The recommended
|
* in Vela), then the arrival and the distance, each with what it is under it. Gearslip's stop
|
||||||
* lane, or lanes, stand out in the theme's own colour; the rest sit on the same muted, solid tile
|
* button sits at the end unless the app shows its own ([hasStop]). It ends navigation the way
|
||||||
* everything else uses, never dimmed by transparency.
|
* going Home does: the connection stays up and the app decides its own next screen.
|
||||||
*/
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
private fun LaneGuidance(step: Step, modifier: Modifier = Modifier) {
|
private fun TripRow(estimate: TravelEstimate, showStop: Boolean, modifier: Modifier) {
|
||||||
step.lanesImage?.let {
|
|
||||||
JunctionImage(it, modifier)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
val lanes = step.lanes.orEmpty()
|
|
||||||
if (lanes.isEmpty()) return
|
|
||||||
Row(modifier, horizontalArrangement = Arrangement.spacedBy(6.dp)) {
|
|
||||||
lanes.forEach { lane -> LaneTile(lane) }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
private fun LaneTile(lane: Lane) {
|
|
||||||
val directions = lane.directions.orEmpty()
|
|
||||||
val shown = directions.firstOrNull { it.isRecommended } ?: directions.firstOrNull()
|
|
||||||
val recommended = shown?.isRecommended == true
|
|
||||||
Box(
|
|
||||||
Modifier
|
|
||||||
.size(36.dp)
|
|
||||||
.clip(RoundedCornerShape(8.dp))
|
|
||||||
.background(
|
|
||||||
if (recommended) MaterialTheme.colorScheme.primaryContainer
|
|
||||||
else MaterialTheme.colorScheme.surfaceContainerHighest,
|
|
||||||
),
|
|
||||||
contentAlignment = Alignment.Center,
|
|
||||||
) {
|
|
||||||
Icon(
|
|
||||||
Icons.Filled.KeyboardArrowUp,
|
|
||||||
contentDescription = if (recommended) "Recommended lane" else "Lane",
|
|
||||||
tint = if (recommended) MaterialTheme.colorScheme.onPrimaryContainer
|
|
||||||
else MaterialTheme.colorScheme.onSurfaceVariant,
|
|
||||||
modifier = Modifier.size(24.dp).rotate(shown.turnDegrees()),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** The one arrow glyph this app draws, turned to stand in for every shape a lane can permit. */
|
|
||||||
private fun LaneDirection?.turnDegrees(): Float = when (this?.shape) {
|
|
||||||
LaneDirection.SHAPE_SLIGHT_RIGHT -> 30f
|
|
||||||
LaneDirection.SHAPE_NORMAL_RIGHT -> 90f
|
|
||||||
LaneDirection.SHAPE_SHARP_RIGHT -> 135f
|
|
||||||
LaneDirection.SHAPE_U_TURN_RIGHT -> 170f
|
|
||||||
LaneDirection.SHAPE_SLIGHT_LEFT -> -30f
|
|
||||||
LaneDirection.SHAPE_NORMAL_LEFT -> -90f
|
|
||||||
LaneDirection.SHAPE_SHARP_LEFT -> -135f
|
|
||||||
LaneDirection.SHAPE_U_TURN_LEFT -> -170f
|
|
||||||
else -> 0f
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The trip at a glance, stacked: time left on top, then the clock time it ends at and the
|
|
||||||
* distance, with Gearslip's own stop button beside them. The close button is Gearslip's, not the
|
|
||||||
* app's: an actionStrip's actions carry no flag saying which one (if any) means "stop
|
|
||||||
* navigating" - the first button turned out to be a mute toggle, not an exit - so this ends
|
|
||||||
* navigation the way going Home already does rather than guess at the app's own controls.
|
|
||||||
*/
|
|
||||||
@Composable
|
|
||||||
private fun TravelEstimateCard(estimate: TravelEstimate, modifier: Modifier = Modifier) {
|
|
||||||
val connection = LocalCarAppConnection.current
|
val connection = LocalCarAppConnection.current
|
||||||
|
val scheme = MaterialTheme.colorScheme
|
||||||
val time = remainingTime(estimate.remainingTimeSeconds)
|
val time = remainingTime(estimate.remainingTimeSeconds)
|
||||||
val distance = estimate.remainingDistance.display()
|
val distance = estimate.remainingDistance.display()
|
||||||
val arrival = estimate.arrivalTimeAtDestination.clockTime()
|
val arrival = estimate.arrivalTimeAtDestination.clockTime()
|
||||||
val trip = estimate.tripText.text()
|
val trip = estimate.tripText.text()
|
||||||
if (time.isEmpty() && distance.isEmpty() && arrival.isEmpty() && trip.isEmpty()) return
|
if (time.isEmpty() && distance.isEmpty() && arrival.isEmpty() && trip.isEmpty()) return
|
||||||
|
val dark = scheme.surface.luminanceIsDark()
|
||||||
ChromeSurface(modifier) {
|
Column(modifier.padding(start = 18.dp, end = 12.dp, top = 12.dp, bottom = 12.dp)) {
|
||||||
val dark = MaterialTheme.colorScheme.surface.luminanceIsDark()
|
Row(Modifier.height(androidx.compose.foundation.layout.IntrinsicSize.Min), verticalAlignment = Alignment.CenterVertically) {
|
||||||
Row(
|
// The time leads, large and coloured; arrival and distance are smaller, each in its
|
||||||
Modifier.padding(start = 18.dp, end = 12.dp, top = 12.dp, bottom = 12.dp),
|
// own column behind a thin rule, with what it is under it.
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
TripFigure(time, null, estimate.remainingTimeColor.color(dark, scheme.onSurface), ChromeType.headline, Modifier.weight(1.1f))
|
||||||
) {
|
TripRule()
|
||||||
Column(Modifier.weight(1f)) {
|
TripFigure(arrival, stringResource(R.string.nav_arrival), scheme.onSurface, ChromeType.body, Modifier.weight(1f))
|
||||||
if (time.isNotEmpty()) {
|
TripRule()
|
||||||
Text(
|
TripFigure(distance, stringResource(R.string.nav_to_go), scheme.onSurface, ChromeType.body, Modifier.weight(1f))
|
||||||
time,
|
if (showStop) {
|
||||||
style = ChromeType.headline,
|
GsIconButton(
|
||||||
color = estimate.remainingTimeColor.color(dark, MaterialTheme.colorScheme.onSurface),
|
Icons.Filled.Close,
|
||||||
maxLines = 1,
|
stringResource(R.string.nav_stop),
|
||||||
)
|
{ connection.stopNavigating() },
|
||||||
}
|
colors = GsColors(scheme.surfaceContainerHighest, scheme.onSurface),
|
||||||
val detail = listOfNotNull(
|
size = 52.dp,
|
||||||
arrival.takeIf { it.isNotEmpty() }?.let { "Arrive $it" },
|
iconSize = 26.dp,
|
||||||
distance.takeIf { it.isNotEmpty() },
|
shape = CircleShape,
|
||||||
).joinToString(" • ")
|
)
|
||||||
if (detail.isNotEmpty()) {
|
|
||||||
Text(detail, style = ChromeType.body, color = MaterialTheme.colorScheme.onSurfaceVariant, maxLines = 1, overflow = TextOverflow.Ellipsis)
|
|
||||||
}
|
|
||||||
if (trip.isNotEmpty()) {
|
|
||||||
Text(trip, style = ChromeType.body, color = MaterialTheme.colorScheme.onSurfaceVariant, maxLines = 1, overflow = TextOverflow.Ellipsis)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
// Not a disconnect: the connection stays up and the app decides its own next screen.
|
}
|
||||||
// Ending navigation is its own call, separate from a back press - a back press only
|
if (trip.isNotEmpty()) {
|
||||||
// pops the on-screen card, leaving the app still believing it is navigating.
|
Text(
|
||||||
GsIconButton(
|
trip,
|
||||||
Icons.Filled.Close,
|
Modifier.padding(top = 6.dp),
|
||||||
"Stop navigating",
|
style = ChromeType.body,
|
||||||
{ connection.stopNavigating() },
|
color = scheme.onSurfaceVariant,
|
||||||
colors = GsColors(MaterialTheme.colorScheme.surfaceContainerHighest, MaterialTheme.colorScheme.onSurface),
|
maxLines = 1,
|
||||||
size = 52.dp,
|
overflow = TextOverflow.Ellipsis,
|
||||||
iconSize = 26.dp,
|
|
||||||
shape = CircleShape,
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun TripRule() {
|
||||||
|
Box(
|
||||||
|
Modifier.padding(horizontal = 12.dp, vertical = 4.dp).width(1.dp).fillMaxHeight()
|
||||||
|
.background(MaterialTheme.colorScheme.outlineVariant),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One of the trip's figures with its label under it, shrinking to stay on one line in a narrow column. */
|
||||||
|
@Composable
|
||||||
|
private fun TripFigure(value: String, label: String?, color: Color, style: androidx.compose.ui.text.TextStyle, modifier: Modifier) {
|
||||||
|
Column(modifier) {
|
||||||
|
if (value.isEmpty()) return@Column
|
||||||
|
var size by androidx.compose.runtime.remember(value, style) { androidx.compose.runtime.mutableStateOf(style.fontSize) }
|
||||||
|
Text(
|
||||||
|
value,
|
||||||
|
style = style.copy(fontSize = size, fontWeight = FontWeight.Bold),
|
||||||
|
color = color,
|
||||||
|
maxLines = 1,
|
||||||
|
softWrap = false,
|
||||||
|
onTextLayout = { if (it.hasVisualOverflow && size.value > 12f) size *= 0.9f },
|
||||||
|
)
|
||||||
|
if (label != null) {
|
||||||
|
Text(label, style = ChromeType.label, color = MaterialTheme.colorScheme.onSurfaceVariant, maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether the app's own buttons include one that ends the drive: a red primary button, as
|
||||||
|
* Vela's is, or one named for it. Actions carry no flag that says so, so this is the best sign.
|
||||||
|
*/
|
||||||
|
private fun androidx.car.app.model.ActionStrip?.hasStop(): Boolean = this?.actions.orEmpty().any { action ->
|
||||||
|
val red = action.flags and androidx.car.app.model.Action.FLAG_PRIMARY != 0 &&
|
||||||
|
action.backgroundColor?.type == androidx.car.app.model.CarColor.TYPE_RED
|
||||||
|
red || action.title?.toString()?.let { STOP_WORDS.containsMatchIn(it) } == true
|
||||||
|
}
|
||||||
|
|
||||||
|
private val STOP_WORDS = Regex("\\b(stop|end|exit|quit|cancel)\\b", RegexOption.IGNORE_CASE)
|
||||||
|
|
||||||
// --- map with a side pane ----------------------------------------------------------------------
|
// --- map with a side pane ----------------------------------------------------------------------
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -445,12 +519,14 @@ private fun MapPaneLayout(
|
|||||||
// Pan mode hands the whole screen to the map, leaving only its own controls.
|
// Pan mode hands the whole screen to the map, leaving only its own controls.
|
||||||
val pan by connection.panMode.collectAsState()
|
val pan by connection.panMode.collectAsState()
|
||||||
val edgeActions = (actionStrip?.actions.orEmpty() + mapActionStrip?.actions.orEmpty()).map { it.identity() }.toSet()
|
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()) {
|
androidx.compose.foundation.layout.BoxWithConstraints(modifier.fillMaxWidth()) {
|
||||||
if (!pan) {
|
if (!pan) {
|
||||||
ChromeSurface(
|
ChromeSurface(
|
||||||
Modifier
|
Modifier
|
||||||
.align(Alignment.TopStart)
|
.align(if (atEnd) Alignment.TopEnd else Alignment.TopStart)
|
||||||
.covers("map-pane", CoverEdge.START)
|
.covers("map-pane", cardEdge)
|
||||||
.padding(12.dp)
|
.padding(12.dp)
|
||||||
.width(cardColumnWidth(maxWidth) - 24.dp)
|
.width(cardColumnWidth(maxWidth) - 24.dp)
|
||||||
.let { if (fullHeight) it.height(maxHeight - 24.dp) else it.heightIn(max = maxHeight - 24.dp) },
|
.let { if (fullHeight) it.height(maxHeight - 24.dp) else it.heightIn(max = maxHeight - 24.dp) },
|
||||||
@@ -463,7 +539,11 @@ private fun MapPaneLayout(
|
|||||||
RightEdgeControls(
|
RightEdgeControls(
|
||||||
if (pan) null else actionStrip,
|
if (pan) null else actionStrip,
|
||||||
mapActionStrip,
|
mapActionStrip,
|
||||||
Modifier.align(Alignment.TopEnd).padding(12.dp),
|
Modifier.align(if (atEnd) Alignment.TopStart else Alignment.TopEnd).padding(12.dp),
|
||||||
|
// Counted as covering the map, as in navigation, or Waze's speedometer slid under
|
||||||
|
// them and off the screen's edge whenever a card was up.
|
||||||
|
mapActionsModifier = Modifier.covers("pane-buttons", buttonEdge),
|
||||||
|
alignment = if (atEnd) Alignment.Start else Alignment.End,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -543,7 +623,7 @@ private fun RoutePreviewChrome(template: RoutePreviewNavigationTemplate, modifie
|
|||||||
)
|
)
|
||||||
Box(Modifier.weight(1f, fill = false)) {
|
Box(Modifier.weight(1f, fill = false)) {
|
||||||
if (template.isLoading) {
|
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 {
|
} else {
|
||||||
ItemListColumn(template.itemList)
|
ItemListColumn(template.itemList)
|
||||||
}
|
}
|
||||||
@@ -567,7 +647,7 @@ private fun ListPane(
|
|||||||
ChromeSurface(modifier, shape = androidx.compose.foundation.shape.RoundedCornerShape(0.dp)) {
|
ChromeSurface(modifier, shape = androidx.compose.foundation.shape.RoundedCornerShape(0.dp)) {
|
||||||
Column {
|
Column {
|
||||||
HeaderBar(title, startAction)
|
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)
|
else ItemListColumn(itemList)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
package app.seb3thehacker.gearslip.car
|
package app.seb3thehacker.gearslip.car
|
||||||
|
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import app.seb3thehacker.gearslip.R
|
||||||
import app.seb3thehacker.gearslip.car.theme.*
|
import app.seb3thehacker.gearslip.car.theme.*
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.material.icons.filled.Close
|
import androidx.compose.material.icons.filled.Close
|
||||||
@@ -317,7 +319,7 @@ internal fun SearchField(
|
|||||||
Spacer(Modifier.width(12.dp))
|
Spacer(Modifier.width(12.dp))
|
||||||
}
|
}
|
||||||
Text(
|
Text(
|
||||||
query.ifEmpty { hint.ifEmpty { "Search" } },
|
query.ifEmpty { hint.ifEmpty { stringResource(R.string.template_search) } },
|
||||||
style = MaterialTheme.typography.titleMedium,
|
style = MaterialTheme.typography.titleMedium,
|
||||||
color = if (query.isEmpty()) muted else MaterialTheme.colorScheme.onSurface,
|
color = if (query.isEmpty()) muted else MaterialTheme.colorScheme.onSurface,
|
||||||
maxLines = 1,
|
maxLines = 1,
|
||||||
@@ -327,7 +329,7 @@ internal fun SearchField(
|
|||||||
if (onClear != null && query.isNotEmpty()) {
|
if (onClear != null && query.isNotEmpty()) {
|
||||||
GsIconButton(
|
GsIconButton(
|
||||||
androidx.compose.material.icons.Icons.Filled.Close,
|
androidx.compose.material.icons.Icons.Filled.Close,
|
||||||
"Clear",
|
stringResource(R.string.storage_clear),
|
||||||
onClear,
|
onClear,
|
||||||
colors = GsColors(androidx.compose.ui.graphics.Color.Transparent, muted),
|
colors = GsColors(androidx.compose.ui.graphics.Color.Transparent, muted),
|
||||||
size = 42.dp,
|
size = 42.dp,
|
||||||
@@ -623,7 +625,7 @@ private fun MediaPlaybackContent(template: MediaPlaybackTemplate, modifier: Modi
|
|||||||
} else {
|
} else {
|
||||||
Box(Modifier.weight(1f).fillMaxWidth(), contentAlignment = Alignment.Center) {
|
Box(Modifier.weight(1f).fillMaxWidth(), contentAlignment = Alignment.Center) {
|
||||||
Text(
|
Text(
|
||||||
"Nothing playing yet.",
|
stringResource(R.string.template_nothing_playing),
|
||||||
style = ChromeType.body,
|
style = ChromeType.body,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
textAlign = androidx.compose.ui.text.style.TextAlign.Center,
|
textAlign = androidx.compose.ui.text.style.TextAlign.Center,
|
||||||
@@ -647,6 +649,6 @@ private typealias ColumnScopeAlias = androidx.compose.foundation.layout.ColumnSc
|
|||||||
@Composable
|
@Composable
|
||||||
private fun Loading() {
|
private fun Loading() {
|
||||||
Box(Modifier.fillMaxWidth().height(80.dp), contentAlignment = Alignment.Center) {
|
Box(Modifier.fillMaxWidth().height(80.dp), contentAlignment = Alignment.Center) {
|
||||||
Text("Loading…", style = ChromeType.title)
|
Text(stringResource(R.string.media_loading), style = ChromeType.title)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,9 +1,14 @@
|
|||||||
package app.seb3thehacker.gearslip.car
|
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 app.seb3thehacker.gearslip.car.theme.*
|
||||||
import androidx.compose.runtime.produceState
|
import androidx.compose.runtime.produceState
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.ui.focus.onFocusChanged
|
import androidx.compose.ui.focus.onFocusChanged
|
||||||
|
import androidx.compose.ui.focus.focusRequester
|
||||||
|
import androidx.compose.foundation.focusGroup
|
||||||
import androidx.compose.material3.LocalContentColor
|
import androidx.compose.material3.LocalContentColor
|
||||||
import androidx.compose.ui.graphics.ColorFilter
|
import androidx.compose.ui.graphics.ColorFilter
|
||||||
import androidx.compose.ui.graphics.luminance
|
import androidx.compose.ui.graphics.luminance
|
||||||
@@ -34,6 +39,7 @@ import androidx.compose.material.icons.Icons
|
|||||||
import androidx.compose.material.icons.filled.Call
|
import androidx.compose.material.icons.filled.Call
|
||||||
import androidx.compose.material.icons.filled.ArrowBack
|
import androidx.compose.material.icons.filled.ArrowBack
|
||||||
import androidx.compose.material.icons.filled.Menu
|
import androidx.compose.material.icons.filled.Menu
|
||||||
|
import androidx.compose.material.icons.filled.MoreVert
|
||||||
import androidx.compose.material.icons.filled.Warning
|
import androidx.compose.material.icons.filled.Warning
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
@@ -48,6 +54,9 @@ import androidx.compose.foundation.lazy.itemsIndexed
|
|||||||
import androidx.compose.material3.RadioButton
|
import androidx.compose.material3.RadioButton
|
||||||
import androidx.compose.material3.Switch
|
import androidx.compose.material3.Switch
|
||||||
import androidx.core.graphics.drawable.toBitmap
|
import androidx.core.graphics.drawable.toBitmap
|
||||||
|
import androidx.core.graphics.drawable.IconCompat
|
||||||
|
import androidx.compose.ui.graphics.drawscope.clipRect
|
||||||
|
import androidx.compose.ui.draw.drawWithContent
|
||||||
import androidx.compose.ui.graphics.asImageBitmap
|
import androidx.compose.ui.graphics.asImageBitmap
|
||||||
import androidx.compose.ui.draw.clip
|
import androidx.compose.ui.draw.clip
|
||||||
import androidx.compose.ui.input.pointer.PointerEventPass
|
import androidx.compose.ui.input.pointer.PointerEventPass
|
||||||
@@ -228,6 +237,8 @@ internal fun RightEdgeControls(
|
|||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
/** For the stack of map buttons alone, without the row of the app's own above it. */
|
/** For the stack of map buttons alone, without the row of the app's own above it. */
|
||||||
mapActionsModifier: Modifier = Modifier,
|
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 connection = LocalCarAppConnection.current
|
||||||
val actions = actionStrip?.actions.orEmpty().filter { it.isDrawable() }
|
val actions = actionStrip?.actions.orEmpty().filter { it.isDrawable() }
|
||||||
@@ -237,13 +248,28 @@ internal fun RightEdgeControls(
|
|||||||
val hasPan = (actions + mapActions).any { it.type == Action.TYPE_PAN }
|
val hasPan = (actions + mapActions).any { it.type == Action.TYPE_PAN }
|
||||||
androidx.compose.runtime.LaunchedEffect(hasPan) { if (!hasPan) connection.setPanMode(false) }
|
androidx.compose.runtime.LaunchedEffect(hasPan) { if (!hasPan) connection.setPanMode(false) }
|
||||||
|
|
||||||
// Faded out when the screen has been left alone; then they are only a memory of buttons, so
|
// Faded out when the screen has been left alone, to leave the map clear, but still buttons:
|
||||||
// the first touch on one wakes them rather than pressing it.
|
// a tap on a faded one presses it straight away (and wakes the rest), as Android Auto's do.
|
||||||
val awake = LocalMapControlsWake.current.awake
|
val awake = LocalMapControlsWake.current.awake
|
||||||
val pan by connection.panMode.collectAsState()
|
val pan by connection.panMode.collectAsState()
|
||||||
// Kept up while the knob is on one of them, or the ring would fade out along with the button.
|
// 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) }
|
var holdsFocus by remember { mutableStateOf(false) }
|
||||||
val visible = awake || pan || holdsFocus
|
// Pressing the knob on the map hands it to these buttons (see MapKnob).
|
||||||
|
val knobOpen by MapKnob.buttonsOpen.collectAsState()
|
||||||
|
val knobFocus = remember { androidx.compose.ui.focus.FocusRequester() }
|
||||||
|
androidx.compose.runtime.DisposableEffect(Unit) {
|
||||||
|
MapKnob.controls++
|
||||||
|
onDispose { MapKnob.controls-- }
|
||||||
|
}
|
||||||
|
androidx.compose.runtime.LaunchedEffect(knobOpen) {
|
||||||
|
if (knobOpen) runCatching { knobFocus.requestFocus() }
|
||||||
|
}
|
||||||
|
val visible = awake || pan || holdsFocus || knobOpen
|
||||||
|
// 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(
|
val alpha by androidx.compose.animation.core.animateFloatAsState(
|
||||||
targetValue = if (visible) 1f else RESTING_ALPHA,
|
targetValue = if (visible) 1f else RESTING_ALPHA,
|
||||||
animationSpec = androidx.compose.animation.core.tween(if (visible) 150 else 500),
|
animationSpec = androidx.compose.animation.core.tween(if (visible) 150 else 500),
|
||||||
@@ -252,28 +278,48 @@ internal fun RightEdgeControls(
|
|||||||
Column(
|
Column(
|
||||||
modifier
|
modifier
|
||||||
.alpha(alpha)
|
.alpha(alpha)
|
||||||
.onFocusChanged { holdsFocus = it.hasFocus }
|
.focusRequester(knobFocus)
|
||||||
.let { base ->
|
.focusGroup()
|
||||||
if (visible) base else base.pointerInput(Unit) {
|
.onFocusChanged { holdsFocus = it.hasFocus },
|
||||||
awaitPointerEventScope {
|
horizontalAlignment = alignment,
|
||||||
while (true) {
|
|
||||||
awaitPointerEvent(PointerEventPass.Initial).changes.forEach { it.consume() }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
horizontalAlignment = Alignment.End,
|
|
||||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
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)) {
|
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
actions.forEach { ActionButton(it) }
|
actions.forEach { ActionButton(it) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (mapActions.isNotEmpty()) {
|
if (mapActions.isNotEmpty() && !(folded && open)) {
|
||||||
Column(
|
Column(
|
||||||
mapActionsModifier,
|
mapActionsModifier,
|
||||||
horizontalAlignment = Alignment.End,
|
horizontalAlignment = alignment,
|
||||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
) {
|
) {
|
||||||
mapActions.forEach { ActionButton(it) }
|
mapActions.forEach { ActionButton(it) }
|
||||||
@@ -308,6 +354,10 @@ internal fun ActionButton(action: Action, large: Boolean = false, modifier: Modi
|
|||||||
AppIconBadge()
|
AppIconBadge()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
// On an app's first screen, opened from the launcher, Gearslip's own close button is already
|
||||||
|
// the way out; a back arrow beside it did the same thing twice. Deeper in, it stays.
|
||||||
|
val atRoot by connection.atRoot.collectAsState()
|
||||||
|
if (action.type == Action.TYPE_BACK && atRoot && connection === CarServices.browse) return
|
||||||
val dark = MaterialTheme.colorScheme.surface.luminanceIsDark()
|
val dark = MaterialTheme.colorScheme.surface.luminanceIsDark()
|
||||||
val title = action.title.text()
|
val title = action.title.text()
|
||||||
val panOn by connection.panMode.collectAsState()
|
val panOn by connection.panMode.collectAsState()
|
||||||
@@ -417,16 +467,30 @@ internal fun AutoStartButton(action: Action, modifier: Modifier = Modifier) {
|
|||||||
.background(background),
|
.background(background),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
androidx.compose.foundation.layout.Box(
|
// The label twice, each clipped to its own side of the fill: dark on the filled part
|
||||||
Modifier.fillMaxWidth().padding(horizontal = 20.dp, vertical = 16.dp),
|
// and light on the empty track. One colour for both was unreadable on the track until
|
||||||
contentAlignment = Alignment.Center,
|
// the fill reached it (Vela's Start).
|
||||||
) {
|
val label = action.title.text().ifEmpty { stringResource(R.string.template_start) }
|
||||||
if (hasGlyph) CarGlyph(action.icon, Modifier.size(24.dp).align(Alignment.CenterStart))
|
val trackContent = MaterialTheme.colorScheme.onSurface
|
||||||
Text(
|
for (filled in listOf(false, true)) {
|
||||||
action.title.text().ifEmpty { "Start" },
|
androidx.compose.foundation.layout.Box(
|
||||||
style = ChromeType.title,
|
Modifier
|
||||||
maxLines = 1,
|
.fillMaxWidth()
|
||||||
)
|
.drawWithContent {
|
||||||
|
val edge = size.width * fraction
|
||||||
|
if (filled) clipRect(right = edge) { this@drawWithContent.drawContent() }
|
||||||
|
else clipRect(left = edge) { this@drawWithContent.drawContent() }
|
||||||
|
}
|
||||||
|
.padding(horizontal = 20.dp, vertical = 16.dp),
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
) {
|
||||||
|
androidx.compose.runtime.CompositionLocalProvider(
|
||||||
|
LocalContentColor provides if (filled) content else trackContent,
|
||||||
|
) {
|
||||||
|
if (hasGlyph) CarGlyph(action.icon, Modifier.size(24.dp).align(Alignment.CenterStart))
|
||||||
|
Text(label, style = ChromeType.title, maxLines = 1, color = LocalContentColor.current)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -479,7 +543,11 @@ internal fun CarGlyph(icon: CarIcon?, modifier: Modifier = Modifier, standard: I
|
|||||||
// app's own process for a content:// icon, plus a bitmap decode - done synchronously on the
|
// app's own process for a content:// icon, plus a bitmap decode - done synchronously on the
|
||||||
// composition thread for every tile on screen, that's what made a shelf of ten covers feel
|
// composition thread for every tile on screen, that's what made a shelf of ten covers feel
|
||||||
// like it was hanging rather than loading.
|
// like it was hanging rather than loading.
|
||||||
val bitmapState by produceState<ImageBitmap?>(null, icon) {
|
// CarIcon.equals calls any two bitmap icons equal, so keyed on the CarIcon alone a turn arrow
|
||||||
|
// drawn as a bitmap (Vela's, Organic Maps' lanes) never replaced the first one of the drive.
|
||||||
|
// Those are keyed on the IconCompat itself, which every new template brings fresh.
|
||||||
|
val key: Any? = icon?.icon?.takeIf { it.type != IconCompat.TYPE_RESOURCE && it.type != IconCompat.TYPE_URI } ?: icon
|
||||||
|
val bitmapState by produceState<ImageBitmap?>(null, key) {
|
||||||
value = withContext(Dispatchers.IO) { icon.image(context) }
|
value = withContext(Dispatchers.IO) { icon.image(context) }
|
||||||
}
|
}
|
||||||
val bitmap = bitmapState
|
val bitmap = bitmapState
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
package app.seb3thehacker.gearslip.car
|
package app.seb3thehacker.gearslip.car
|
||||||
|
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import app.seb3thehacker.gearslip.R
|
||||||
import app.seb3thehacker.gearslip.car.theme.*
|
import app.seb3thehacker.gearslip.car.theme.*
|
||||||
import androidx.compose.runtime.produceState
|
import androidx.compose.runtime.produceState
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
@@ -172,9 +174,20 @@ internal fun GridTile(item: GridItem, modifier: Modifier = Modifier, iconSize: a
|
|||||||
/**
|
/**
|
||||||
* [autoStart] is for a pane floating over a map - a place or a route the app is proposing - where
|
* [autoStart] is for a pane floating over a map - a place or a route the app is proposing - where
|
||||||
* a single action counts itself down rather than waiting to be pressed, on the theory that a
|
* a single action counts itself down rather than waiting to be pressed, on the theory that a
|
||||||
* driver glancing at the car screen has already decided. It never applies to more than one
|
* driver glancing at the car screen has already decided. See [autoStartLead] for when: a pane
|
||||||
* action: a pane offering a real choice (Start vs. something else) always waits to be tapped.
|
* offering a real choice (Start vs. something else) always waits to be tapped.
|
||||||
*/
|
*/
|
||||||
|
/**
|
||||||
|
* The action that counts itself down, if the pane is really proposing one thing: its only action,
|
||||||
|
* or the app's primary one when everything beside it is an icon with no words (Waze's "Go now"
|
||||||
|
* next to its routes button). Two worded choices ("New drive", "Add a stop") wait for a tap.
|
||||||
|
*/
|
||||||
|
internal fun autoStartLead(actions: List<Action>): Action? {
|
||||||
|
actions.singleOrNull()?.let { return it }
|
||||||
|
val primary = actions.filter { it.flags and Action.FLAG_PRIMARY != 0 }.singleOrNull() ?: return null
|
||||||
|
return primary.takeIf { actions.all { it === primary || it.title.text().isBlank() } }
|
||||||
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
internal fun PaneRows(pane: Pane?, modifier: Modifier = Modifier, autoStart: Boolean = false) {
|
internal fun PaneRows(pane: Pane?, modifier: Modifier = Modifier, autoStart: Boolean = false) {
|
||||||
// Some apps pad a pane with a row of blank strings; drawn, it is just a gap.
|
// Some apps pad a pane with a row of blank strings; drawn, it is just a gap.
|
||||||
@@ -184,8 +197,16 @@ internal fun PaneRows(pane: Pane?, modifier: Modifier = Modifier, autoStart: Boo
|
|||||||
Column(modifier) {
|
Column(modifier) {
|
||||||
card.heading?.let { PlaceHeading(it) }
|
card.heading?.let { PlaceHeading(it) }
|
||||||
if (card.buttons.isNotEmpty()) RowButtons(card.buttons, Modifier.padding(horizontal = 14.dp, vertical = 6.dp))
|
if (card.buttons.isNotEmpty()) RowButtons(card.buttons, Modifier.padding(horizontal = 14.dp, vertical = 6.dp))
|
||||||
if (autoStart && actions.size == 1) {
|
val lead = if (autoStart) autoStartLead(actions) else null
|
||||||
AutoStartButton(actions.single(), Modifier.padding(horizontal = 14.dp, vertical = 10.dp))
|
if (lead != null) {
|
||||||
|
Row(
|
||||||
|
Modifier.fillMaxWidth().padding(horizontal = 14.dp, vertical = 10.dp),
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
AutoStartButton(lead, Modifier.weight(1f))
|
||||||
|
actions.filter { it !== lead }.forEach { ActionButton(it, large = true, modifier = Modifier.weight(1f)) }
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
ActionRow(actions, Modifier.padding(horizontal = 14.dp, vertical = 10.dp), large = true)
|
ActionRow(actions, Modifier.padding(horizontal = 14.dp, vertical = 10.dp), large = true)
|
||||||
}
|
}
|
||||||
@@ -300,7 +321,7 @@ private fun DetailRow(row: CarRow, placeName: String) {
|
|||||||
Row(Modifier.padding(horizontal = 16.dp, vertical = 10.dp), verticalAlignment = Alignment.CenterVertically) {
|
Row(Modifier.padding(horizontal = 16.dp, vertical = 10.dp), verticalAlignment = Alignment.CenterVertically) {
|
||||||
androidx.compose.material3.Icon(Icons.Filled.Call, null, Modifier.size(20.dp))
|
androidx.compose.material3.Icon(Icons.Filled.Call, null, Modifier.size(20.dp))
|
||||||
Spacer(Modifier.width(6.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
|
package app.seb3thehacker.gearslip.car
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.PaddingValues
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
@@ -27,7 +28,7 @@ import androidx.compose.ui.unit.dp
|
|||||||
fun VehicleDataScreen() {
|
fun VehicleDataScreen() {
|
||||||
val readings by CarSensors.readings.collectAsState()
|
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")
|
ScreenHeader("Vehicle data")
|
||||||
Text(
|
Text(
|
||||||
"Raw sensor channel from the connected head unit. Field meaning is a best guess - " +
|
"Raw sensor channel from the connected head unit. Field meaning is a best guess - " +
|
||||||
@@ -46,7 +47,7 @@ fun VehicleDataScreen() {
|
|||||||
return@Column
|
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) }
|
items(readings, key = { it.typeId }) { reading -> ReadingRow(reading) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||