Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fb0736a3b3 | ||
|
|
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 | ||
|
|
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 |
No files matched your search
@@ -1,24 +1,13 @@
|
|||||||
Gearslip 0.3.31 brings saved places, a new music player and sixteen languages.
|
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.
|
||||||
- Save Home, Work and three places of your own under Saved places in the Car card on the phone's home screen. Say "navigate home" or "take me to work", and your map app gets the address.
|
|
||||||
- Calls show on the car's own screens, and the car's phone button answers them.
|
|
||||||
- A new music player: Now playing with a big cover, Browse with covers, and Up next. Beside the map, the player floats as a card; drag its edge to resize it.
|
|
||||||
- Lyrics come from BetterLyrics, LRCLIB or KuGou, whichever has the song.
|
|
||||||
- A Dark mode setting: black, black and colour, deep colour or grey. Black is the default.
|
|
||||||
- Gearslip speaks Arabic, Chinese, Czech, Dutch, French, German, Italian, Japanese, Korean, Polish, Portuguese, Russian, Spanish, Swedish, Turkish and Ukrainian. Most started as machine translations, so fix your language on [Weblate](https://hosted.weblate.org/engage/gearslip/).
|
|
||||||
- With experimental features on, car apps get the car's speed, fuel, range and compass, and cars with an instrument cluster screen get a turn card there.
|
|
||||||
- A Manage storage screen in App info clears lyrics, voice models and old logs.
|
|
||||||
|
|
||||||
## Changed
|
|
||||||
- The nav bar floats on every screen.
|
|
||||||
- The car's settings all sit under the Car card on the phone's home screen.
|
|
||||||
- The Light app theme makes the phone app light too.
|
|
||||||
- With the player beside the map, the map app's own buttons fold behind one button, so everything fits.
|
|
||||||
- The USB audio choice is now called Through Gearslip.
|
|
||||||
- Setup and Settings explain that some newer cars reject the built-in certificate and need your own.
|
|
||||||
|
|
||||||
## Fixed
|
## Fixed
|
||||||
- Older head units that take only AAC audio no longer drop the connection when music starts.
|
- 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.
|
||||||
- Long screens such as Settings, Messages and Vehicle data scroll all the way down.
|
- 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).
|
||||||
- Settings opens faster.
|
- A liked song lights up the whole like button, as shuffle and repeat do. Before, only the heart filled in for apps such as Metrolist.
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
- GM cars open two more channels, Bluetooth and Wi-Fi projection, and log what the car sends on them. This is to find out why the 2021 Bolt hangs up about 20 seconds in (#18). If you drive a GM car, type your phone's Bluetooth address under Settings > Connection > Phone's Bluetooth address. You'll find it in Android's Settings under About phone.
|
||||||
|
|
||||||
|
Help translate Gearslip on [Weblate](https://hosted.weblate.org/engage/gearslip/).
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
name: Translations
|
||||||
|
|
||||||
|
# Weblate is the only thing that writes translated strings: it pushes straight to experimental,
|
||||||
|
# never through a pull request. A PR that edits them collides with Weblate's own copy, and
|
||||||
|
# Weblate locks itself until someone resets it. Edit the English strings.xml only.
|
||||||
|
on:
|
||||||
|
pull_request:
|
||||||
|
paths:
|
||||||
|
- "gearslip/src/main/res/values-*/strings.xml"
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
weblate-only:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Translations go through Weblate
|
||||||
|
run: |
|
||||||
|
echo "::error::This pull request edits a translation. Please make translation changes on Weblate instead: https://hosted.weblate.org/engage/gearslip/ (it can upload a whole strings.xml under Files > Upload translation). See CONTRIBUTING.md."
|
||||||
|
exit 1
|
||||||
@@ -23,6 +23,7 @@ and some don't say.
|
|||||||
| Jeep Cherokee | 2019 | Uconnect 8.4 (Delphi) |
|
| 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 |
|
||||||
|
|||||||
@@ -40,10 +40,15 @@ branch on `experimental`, and open your pull request against it.
|
|||||||
|
|
||||||
## Translating
|
## Translating
|
||||||
|
|
||||||
Translations happen on [Weblate](https://hosted.weblate.org/engage/gearslip/), not in pull
|
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
|
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.
|
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`
|
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
|
and `%1$d` in place; Gearslip fills them with names and numbers. Untranslated strings show
|
||||||
in English, so a partial translation is still useful.
|
in English, so a partial translation is still useful.
|
||||||
|
|||||||
@@ -4,116 +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.
|
||||||
|
|
||||||
[](https://hosted.weblate.org/engage/gearslip/)
|
> [!WARNING]
|
||||||
|
> Gearslip is **alpha software**. It can drop the connection, freeze the car screen, or fail
|
||||||
|
> to start, and a new release can break a car that worked before. Set it up while parked, and
|
||||||
|
> keep another way to navigate.
|
||||||
|
|
||||||
Gearslip speaks English, Arabic, Chinese, Czech, Dutch, French, German, Italian, Japanese,
|
Questions, ideas? [Join the discussion on Matrix](https://matrix.to/#/!mf0_mgBd5KqKpc9FfX_wHp1jFCcwAtPrndIn_oGIyMk?via=matrix.org).
|
||||||
Korean, Polish, Portuguese, Russian, Spanish, Swedish, Turkish, and Ukrainian. Most of these
|
|
||||||
started as machine translations, so some read awkwardly.
|
|
||||||
[Fix or add your language on Weblate](https://hosted.weblate.org/engage/gearslip/).
|
|
||||||
|
|
||||||
Questions, ideas?
|
## What works
|
||||||
[Join the discussion on Matrix](https://matrix.to/#/!mf0_mgBd5KqKpc9FfX_wHp1jFCcwAtPrndIn_oGIyMk?via=matrix.org).
|
|
||||||
|
|
||||||
## Which cars work
|
|
||||||
|
|
||||||
Most cars work. Starting around 2020, some cars added newer security checks that reject
|
|
||||||
the aasdk certificate, and in those cars Gearslip needs a certificate of your own. [CARS.md](CARS.md) lists the
|
|
||||||
cars drivers have confirmed.
|
|
||||||
|
|
||||||
## What Gearslip is
|
|
||||||
|
|
||||||
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 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.
|
|
||||||
- **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
|
||||||
@@ -124,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
|
||||||
@@ -135,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 through Gearslip or over 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
|
||||||
@@ -147,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
|
||||||
@@ -156,15 +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.
|
|
||||||
|
|
||||||
To help translate Gearslip into your language, join
|
|
||||||
[Gearslip on Weblate](https://hosted.weblate.org/engage/gearslip/). No coding needed.
|
|
||||||
|
|
||||||
## Building
|
## Building
|
||||||
|
|
||||||
```
|
```
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 523 KiB After Width: | Height: | Size: 402 KiB |
|
Before Width: | Height: | Size: 95 KiB |
|
Before Width: | Height: | Size: 160 KiB After Width: | Height: | Size: 184 KiB |
|
After Width: | Height: | Size: 502 KiB |
|
Before Width: | Height: | Size: 373 KiB After Width: | Height: | Size: 313 KiB |
|
Before Width: | Height: | Size: 351 KiB After Width: | Height: | Size: 449 KiB |
|
Before Width: | Height: | Size: 397 KiB After Width: | Height: | Size: 420 KiB |
|
Before Width: | Height: | Size: 586 KiB After Width: | Height: | Size: 392 KiB |
|
Before Width: | Height: | Size: 116 KiB After Width: | Height: | Size: 107 KiB |
@@ -38,9 +38,9 @@ android {
|
|||||||
applicationId = "app.seb3thehacker.gearslip"
|
applicationId = "app.seb3thehacker.gearslip"
|
||||||
minSdk = 31
|
minSdk = 31
|
||||||
targetSdk = 37
|
targetSdk = 37
|
||||||
versionCode = 13
|
versionCode = 15
|
||||||
// -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.31-alpha"
|
versionName = project.findProperty("gearslipVersionName")?.toString() ?: "0.3.40-alpha"
|
||||||
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.
|
||||||
|
|||||||
@@ -19,6 +19,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
|
||||||
@@ -158,6 +160,26 @@
|
|||||||
|
|
||||||
</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
|
||||||
@@ -204,6 +226,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.
|
||||||
|
|||||||
@@ -10,6 +10,26 @@ 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(
|
||||||
|
|||||||
@@ -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 ->
|
||||||
@@ -223,6 +227,16 @@ class GearslipActivity : ComponentActivity(), Projection {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override fun onResume() {
|
||||||
|
super.onResume()
|
||||||
|
shownOnPhone = true
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onStop() {
|
||||||
|
super.onStop()
|
||||||
|
shownOnPhone = false
|
||||||
|
}
|
||||||
|
|
||||||
override fun onNewIntent(intent: Intent) {
|
override fun onNewIntent(intent: Intent) {
|
||||||
super.onNewIntent(intent)
|
super.onNewIntent(intent)
|
||||||
handleIntent(intent)
|
handleIntent(intent)
|
||||||
@@ -296,7 +310,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() }
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -323,6 +338,7 @@ class GearslipActivity : ComponentActivity(), Projection {
|
|||||||
// Debug builds only: opens the audio choice for a look, without turning down a real prompt.
|
// 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 (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
|
||||||
}
|
}
|
||||||
@@ -340,6 +356,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)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -401,9 +418,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. */
|
||||||
@@ -423,9 +442,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) {
|
||||||
@@ -445,10 +480,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
|
||||||
@@ -458,6 +510,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) }
|
||||||
}
|
}
|
||||||
@@ -474,6 +527,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 {
|
||||||
@@ -103,6 +107,8 @@ class GearslipRunner(
|
|||||||
@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
|
||||||
@@ -128,6 +134,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}")
|
||||||
@@ -158,7 +165,8 @@ 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
|
||||||
@@ -254,6 +262,8 @@ class GearslipRunner(
|
|||||||
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) {
|
||||||
@@ -283,6 +293,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 -> {
|
||||||
@@ -404,8 +415,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")
|
||||||
@@ -446,11 +456,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) {
|
||||||
@@ -469,6 +512,7 @@ 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())
|
||||||
@@ -577,6 +621,8 @@ 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) {
|
if (CarSettings.pipeAudio.value) {
|
||||||
openChannel(media.serviceId)
|
openChannel(media.serviceId)
|
||||||
@@ -606,7 +652,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)
|
||||||
@@ -682,6 +730,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) {
|
||||||
@@ -995,11 +1060,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()) +
|
if (CarAudio.openSink != null) 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 -> {
|
||||||
@@ -1152,6 +1214,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()
|
||||||
@@ -1275,6 +1338,49 @@ class GearslipRunner(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Asks the car for its screen; the answer comes to [onVideoFocus]. [withDisplay] names the
|
||||||
|
* display's channel too, as the request for the car's own screen does (see [requestNativeFocus]):
|
||||||
|
* the field is deprecated, but a car older than that may ignore a request without it.
|
||||||
|
*/
|
||||||
|
private fun requestProjectedFocus(withDisplay: Boolean = false) {
|
||||||
|
// VideoFocusRequestNotification { disp_channel_id = 1; mode = 2; reason = 3 }. Reason 1
|
||||||
|
// means "the phone's screen is off", which a 2020 Civic may have taken as a reason to
|
||||||
|
// keep its own screen (issue #43); 0 is UNKNOWN.
|
||||||
|
val display = if (withDisplay) Protobuf.varintField(1, videoChannelId.toLong()) else ByteArray(0)
|
||||||
|
val focus = display + Protobuf.varintField(2, VIDEO_FOCUS_PROJECTED.toLong()) +
|
||||||
|
Protobuf.varintField(3, 0L)
|
||||||
|
send(MSG_VIDEO_FOCUS_REQUEST, focus, encrypted = true, channel = videoChannelId)
|
||||||
|
log.i(if (withDisplay) "-> VideoFocusRequest(display=$videoChannelId, mode=PROJECTED)" else "-> VideoFocusRequest(mode=PROJECTED)")
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Some older head units keep their own screen until every channel the phone was offered is
|
||||||
|
* open: a 2017 Pilot, a 2020 Civic and a 2024 Mirage (issues #43, #49, #57) never showed
|
||||||
|
* ours with the media channel closed, and the Mirage worked on 0.3.01, which opened it. With
|
||||||
|
* music over Bluetooth that channel waits for the car to switch source, so if the screen
|
||||||
|
* hasn't come a few seconds after asking, open it and ask again. Cars that hand the screen
|
||||||
|
* straight over keep their own source as before.
|
||||||
|
* The 2017 Pilot (protocol 1.0) still kept its screen with the channel open, so the second
|
||||||
|
* request also names the display's channel, as cars of the Pilot's age may need.
|
||||||
|
*/
|
||||||
|
private fun watchForWithheldScreen() {
|
||||||
|
Thread({
|
||||||
|
Thread.sleep(WITHHELD_SCREEN_MS)
|
||||||
|
if (!running || videoStarted) return@Thread
|
||||||
|
val open = CarAudio.openSink
|
||||||
|
if (open != null) {
|
||||||
|
log.w("no screen from the car after ${WITHHELD_SCREEN_MS / 1000}s - opening the media channel and asking again")
|
||||||
|
open()
|
||||||
|
// Give the car the channel's open response before it hears the request again.
|
||||||
|
Thread.sleep(1_000)
|
||||||
|
} else {
|
||||||
|
log.w("no screen from the car after ${WITHHELD_SCREEN_MS / 1000}s - asking again")
|
||||||
|
}
|
||||||
|
if (running && !videoStarted) requestProjectedFocus(withDisplay = true)
|
||||||
|
}, "gearslip-focus-fallback").apply { isDaemon = true }.start()
|
||||||
|
}
|
||||||
|
|
||||||
/** Android Auto's Exit: asks the car for its own interface, leaving the session running. */
|
/** 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
|
||||||
@@ -1307,6 +1413,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.
|
||||||
@@ -1400,6 +1538,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
|
||||||
@@ -1411,6 +1552,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.
|
||||||
@@ -1503,6 +1649,9 @@ class GearslipRunner(
|
|||||||
/** 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
|
||||||
@@ -1512,6 +1661,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
|
||||||
@@ -1556,6 +1707,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 }
|
||||||
|
}
|
||||||
@@ -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. */
|
||||||
|
|||||||
@@ -223,6 +223,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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -200,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)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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,6 +46,8 @@ 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
|
||||||
@@ -70,6 +75,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
|
||||||
@@ -108,6 +116,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) {
|
||||||
@@ -213,7 +236,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()
|
||||||
@@ -222,9 +245,32 @@ 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 {
|
||||||
|
focusGranted -> {
|
||||||
|
startStream()
|
||||||
|
if (pausedForNow) {
|
||||||
|
pausedForNow = false
|
||||||
|
onGivenBack()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// A call or the car's own voice. Releasing focus here meant the car never handed it
|
||||||
|
// back, so the music stayed paused after the call: keep the claim and wait for GAIN.
|
||||||
|
state == FOCUS_STATE_LOSS_TRANSIENT -> {
|
||||||
|
synchronized(startLock) { stopLocked(release = false) }
|
||||||
|
if (had) {
|
||||||
|
pausedForNow = true
|
||||||
|
onPausedForNow()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else -> {
|
||||||
|
stop()
|
||||||
// A ducking request leaves the music playing; a loss means another source has the speakers.
|
// A ducking request leaves the music playing; a loss means another source has the speakers.
|
||||||
if (had && (state == FOCUS_STATE_LOSS || state == FOCUS_STATE_LOSS_TRANSIENT)) onTakenBack()
|
if (state == FOCUS_STATE_LOSS && (had || pausedForNow)) {
|
||||||
|
pausedForNow = false
|
||||||
|
onTakenBack()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -299,19 +345,20 @@ 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.
|
// A new stream starts with the car's buffer empty again: earn the lead back if it's needed.
|
||||||
|
|||||||
@@ -137,6 +137,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,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) })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@ package app.seb3thehacker.gearslip.car
|
|||||||
import androidx.annotation.StringRes
|
import androidx.annotation.StringRes
|
||||||
import app.seb3thehacker.gearslip.R
|
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
|
||||||
@@ -38,7 +39,16 @@ object CarApps {
|
|||||||
CarApp("settings", "Settings", R.string.settings_title, 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 }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -40,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 {
|
||||||
|
|
||||||
@@ -63,13 +68,15 @@ object CarBluetooth {
|
|||||||
@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
|
||||||
@@ -83,18 +90,39 @@ object CarBluetooth {
|
|||||||
) {
|
) {
|
||||||
car = Car(service, open, send)
|
car = Car(service, open, send)
|
||||||
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 && 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")
|
device.bondState == BluetoothDevice.BOND_BONDED -> log.i("already paired with this car")
|
||||||
declined(service.carAddress) -> log.i("not paired, and the driver said not now for this car")
|
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()
|
||||||
|
}
|
||||||
|
|
||||||
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
|
||||||
@@ -139,7 +167,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 }
|
||||||
@@ -153,6 +186,7 @@ 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}")
|
||||||
|
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)
|
||||||
}
|
}
|
||||||
@@ -229,12 +263,30 @@ 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)
|
||||||
|
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(",")
|
||||||
@@ -246,6 +298,7 @@ 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"
|
||||||
|
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
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -95,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) {
|
||||||
|
|||||||
@@ -185,10 +185,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. */
|
||||||
@@ -224,9 +227,10 @@ private fun groupOf(entry: Entry, mediaPackages: Set<String>): AppGroup {
|
|||||||
* The launcher's tiles in the order they're drawn: grouped by [AppGroup], alphabetical inside a
|
* 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() }),
|
||||||
@@ -240,7 +244,7 @@ internal fun launcherOrder(all: List<Entry>, showVehicleData: Boolean, custom: L
|
|||||||
|
|
||||||
/** An app by the id a pin or the running list knows it by, built-in or installed. */
|
/** 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) }
|
||||||
@@ -290,7 +294,8 @@ fun CarLauncher() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
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)
|
||||||
|
|
||||||
LazyVerticalGrid(
|
LazyVerticalGrid(
|
||||||
columns = GridCells.Adaptive(minSize = 108.dp),
|
columns = GridCells.Adaptive(minSize = 108.dp),
|
||||||
|
|||||||
@@ -75,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
|
||||||
@@ -197,12 +198,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) {
|
||||||
@@ -416,8 +418,8 @@ private const val TITLE_SHOWN_MS = 3_000L
|
|||||||
/** Below this, the nav bar's middle only has room for the turn, not the player beside it. */
|
/** 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
|
||||||
@@ -429,10 +431,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(),
|
||||||
@@ -440,6 +442,15 @@ private fun rememberNextTurn(): NextTurn? {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Google Maps' or Waze's own arrow, and the distance as the app wrote it. */
|
||||||
|
@Composable
|
||||||
|
private fun phoneTurn(turn: PhoneNavigation.Turn): NextTurn {
|
||||||
|
val icon = remember(turn.arrow) {
|
||||||
|
turn.arrow?.let { CarIcon.Builder(androidx.core.graphics.drawable.IconCompat.createWithBitmap(it)).build() }
|
||||||
|
}
|
||||||
|
return NextTurn(icon, turn.distance?.display() ?: turn.distanceText, turn.road, fromPhone = true)
|
||||||
|
}
|
||||||
|
|
||||||
/** The next turn in the nav bar: arrow, how far, onto what. Tapping goes back to the map. */
|
/** 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) {
|
||||||
|
|||||||
@@ -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,6 +31,11 @@ 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)) }
|
||||||
|
|
||||||
@@ -252,25 +258,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. */
|
||||||
|
|||||||
@@ -53,10 +53,12 @@ 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_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"
|
||||||
@@ -71,6 +73,7 @@ private const val KEY_LOCATION_WARNING = "car_location_warning"
|
|||||||
private const val KEY_DARK_STYLE = "car_dark_style"
|
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"
|
||||||
@@ -97,10 +100,12 @@ private const val KEY_CLUSTER_VIDEO = "cluster_video_enabled"
|
|||||||
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)
|
||||||
@@ -121,6 +126,7 @@ private const val KEY_CLUSTER_VIDEO = "cluster_video_enabled"
|
|||||||
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)
|
||||||
@@ -218,6 +224,9 @@ private const val KEY_CLUSTER_VIDEO = "cluster_video_enabled"
|
|||||||
/** 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
|
||||||
@@ -238,6 +247,16 @@ private const val KEY_CLUSTER_VIDEO = "cluster_video_enabled"
|
|||||||
val moreLyrics: StateFlow<Boolean> = moreLyricsFlow
|
val moreLyrics: StateFlow<Boolean> = moreLyricsFlow
|
||||||
|
|
||||||
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)
|
||||||
|
|
||||||
|
/** 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
|
||||||
|
|
||||||
@@ -253,6 +272,9 @@ private const val KEY_CLUSTER_VIDEO = "cluster_video_enabled"
|
|||||||
/** 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
|
||||||
|
|
||||||
@@ -326,6 +348,7 @@ private const val KEY_CLUSTER_VIDEO = "cluster_video_enabled"
|
|||||||
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 } ?: 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 }
|
||||||
@@ -344,6 +367,7 @@ private const val KEY_CLUSTER_VIDEO = "cluster_video_enabled"
|
|||||||
// 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 {
|
||||||
@@ -353,6 +377,8 @@ private const val KEY_CLUSTER_VIDEO = "cluster_video_enabled"
|
|||||||
.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"
|
||||||
@@ -383,6 +409,11 @@ private const val KEY_CLUSTER_VIDEO = "cluster_video_enabled"
|
|||||||
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)
|
||||||
@@ -451,6 +482,11 @@ private const val KEY_CLUSTER_VIDEO = "cluster_video_enabled"
|
|||||||
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())
|
||||||
@@ -505,6 +541,12 @@ private const val KEY_CLUSTER_VIDEO = "cluster_video_enabled"
|
|||||||
AppSettings.putString(app, KEY_PINNED, next.joinToString(","))
|
AppSettings.putString(app, KEY_PINNED, next.joinToString(","))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun setAppHidden(id: String, hide: Boolean) {
|
||||||
|
val next = if (hide) hiddenAppsFlow.value + id else hiddenAppsFlow.value - id
|
||||||
|
hiddenAppsFlow.value = next
|
||||||
|
AppSettings.putString(app, KEY_HIDDEN_APPS, next.joinToString(","))
|
||||||
|
}
|
||||||
|
|
||||||
fun setAppOrder(ids: List<String>) {
|
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(","))
|
||||||
|
|||||||
@@ -58,6 +58,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()
|
||||||
@@ -66,6 +67,7 @@ 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()
|
||||||
@@ -109,7 +111,7 @@ fun CarSettingsScreen() {
|
|||||||
if (section == chosen) item {
|
if (section == chosen) item {
|
||||||
ChoiceRow(
|
ChoiceRow(
|
||||||
stringResource(R.string.car_open_on_connect),
|
stringResource(R.string.car_open_on_connect),
|
||||||
listOf(CarSettings.HOME to stringResource(R.string.choice_open_home)) + CarApps.all.filter { it.id != "settings" }.map { it.id to stringResource(it.labelRes) },
|
listOf(CarSettings.HOME to stringResource(R.string.choice_open_home)) + CarApps.shown(launcher, LauncherMode.canCall(context)).map { it.id to stringResource(it.labelRes) },
|
||||||
open,
|
open,
|
||||||
) { CarSettings.setOpenOnConnect(it) }
|
) { CarSettings.setOpenOnConnect(it) }
|
||||||
}
|
}
|
||||||
@@ -286,6 +288,22 @@ fun CarSettingsScreen() {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (section == chosen && speakDirections) item {
|
||||||
|
ChoiceRow(
|
||||||
|
stringResource(R.string.car_directions_over_music),
|
||||||
|
listOf(false to stringResource(R.string.off), true to stringResource(R.string.on)),
|
||||||
|
directionsOverMusic,
|
||||||
|
) { CarSettings.setDirectionsOverMusic(it) }
|
||||||
|
}
|
||||||
|
|
||||||
|
if (section == chosen && speakDirections) item {
|
||||||
|
Text(
|
||||||
|
stringResource(R.string.car_directions_over_music_note),
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
if (section == chosen) item {
|
if (section == chosen) item {
|
||||||
ExperimentalFeatureRow(
|
ExperimentalFeatureRow(
|
||||||
title = stringResource(R.string.car_phone_speech),
|
title = stringResource(R.string.car_phone_speech),
|
||||||
|
|||||||
@@ -125,7 +125,12 @@ fun CarUi() {
|
|||||||
val colors = (if (dark) dynamicDarkColorScheme(appContext) else dynamicLightColorScheme(appContext)).withTintedSurfaces(dark, darkStyle)
|
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) {
|
||||||
@@ -325,6 +330,7 @@ fun CarUi() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
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()
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -3,6 +3,9 @@ package app.seb3thehacker.gearslip.car
|
|||||||
import app.seb3thehacker.gearslip.R
|
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
|
||||||
@@ -11,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
|
||||||
@@ -40,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
|
||||||
@@ -56,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() }
|
||||||
@@ -66,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
|
||||||
@@ -232,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")
|
||||||
@@ -247,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.
|
||||||
@@ -264,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
|
||||||
@@ -286,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()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -360,6 +434,7 @@ 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 const val RESTART_MS = 30_000L
|
||||||
private val ORDINALS = listOf(
|
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_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,
|
R.string.dir_ordinal_5, R.string.dir_ordinal_6, R.string.dir_ordinal_7, R.string.dir_ordinal_8,
|
||||||
|
|||||||
@@ -0,0 +1,79 @@
|
|||||||
|
package app.seb3thehacker.gearslip.car
|
||||||
|
|
||||||
|
import android.app.ActivityOptions
|
||||||
|
import android.content.ComponentName
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
import android.content.pm.PackageManager
|
||||||
|
import android.os.Build
|
||||||
|
import android.provider.Settings
|
||||||
|
import android.view.Display
|
||||||
|
import app.seb3thehacker.gearslip.GearslipLog
|
||||||
|
import app.seb3thehacker.gearslip.LauncherActivity
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gearslip as the home screen of the device it runs on: a head unit that runs Android itself, or
|
||||||
|
* a spare phone or tablet on the dash. The car UI fills the device's own screen, with no car in
|
||||||
|
* between. Off until it's turned on in Settings, so a phone never lists Gearslip as a home app.
|
||||||
|
*/
|
||||||
|
object LauncherMode {
|
||||||
|
|
||||||
|
private val _active = MutableStateFlow(false)
|
||||||
|
|
||||||
|
/** The car UI is up on this device's own screen, as its home screen. */
|
||||||
|
val active: StateFlow<Boolean> = _active
|
||||||
|
|
||||||
|
internal fun setActive(on: Boolean) {
|
||||||
|
_active.value = on
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun component(context: Context) = ComponentName(context, LauncherActivity::class.java)
|
||||||
|
|
||||||
|
/** Whether Android may offer Gearslip as the home app. */
|
||||||
|
fun enabled(context: Context): Boolean =
|
||||||
|
context.packageManager.getComponentEnabledSetting(component(context)) ==
|
||||||
|
PackageManager.COMPONENT_ENABLED_STATE_ENABLED
|
||||||
|
|
||||||
|
fun setEnabled(context: Context, on: Boolean) {
|
||||||
|
context.packageManager.setComponentEnabledSetting(
|
||||||
|
component(context),
|
||||||
|
if (on) PackageManager.COMPONENT_ENABLED_STATE_ENABLED else PackageManager.COMPONENT_ENABLED_STATE_DISABLED,
|
||||||
|
PackageManager.DONT_KILL_APP,
|
||||||
|
)
|
||||||
|
GearslipLog.i("launcher mode ${if (on) "on" else "off"}")
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Android's own "Default home app" choice, where Gearslip is picked once it's offered. */
|
||||||
|
fun openHomeChoice(context: Context) {
|
||||||
|
runCatching {
|
||||||
|
context.startActivity(Intent(Settings.ACTION_HOME_SETTINGS).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK))
|
||||||
|
}.onFailure { GearslipLog.w("launcher mode: no home app settings on this device (${it.message})") }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Head units and most tablets have no phone of their own to call with. */
|
||||||
|
fun canCall(context: Context): Boolean {
|
||||||
|
val feature = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||||
|
PackageManager.FEATURE_TELEPHONY_CALLING
|
||||||
|
} else {
|
||||||
|
@Suppress("DEPRECATION")
|
||||||
|
PackageManager.FEATURE_TELEPHONY
|
||||||
|
}
|
||||||
|
return context.packageManager.hasSystemFeature(feature)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Opens an ordinary app full screen, Gearslip's own phone screens included. The car UI lives
|
||||||
|
* on a display of its own, and a context from it would start the app there, which Android
|
||||||
|
* refuses; the application context and the device's own display put it on the real screen.
|
||||||
|
*/
|
||||||
|
fun launch(context: Context, packageName: String) {
|
||||||
|
val app = context.applicationContext
|
||||||
|
val intent = app.packageManager.getLaunchIntentForPackage(packageName) ?: return
|
||||||
|
intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||||
|
val options = ActivityOptions.makeBasic().setLaunchDisplayId(Display.DEFAULT_DISPLAY)
|
||||||
|
runCatching { app.startActivity(intent, options.toBundle()) }
|
||||||
|
.onFailure { GearslipLog.w("launcher mode: couldn't open $packageName (${it.message})") }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -19,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
|
||||||
@@ -83,11 +84,28 @@ internal object BuiltInApps {
|
|||||||
val VehicleData = BuiltInApp("vehicle", "Vehicle data", R.string.car_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", R.string.settings_title, 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", R.string.app_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. */
|
/** The name a driver sees: translated for Gearslip's own apps, the app's own name otherwise. */
|
||||||
@@ -276,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) {
|
||||||
|
|||||||
@@ -106,7 +106,7 @@ private fun TouchRelayWarning() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@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),
|
||||||
|
|||||||
@@ -145,7 +145,7 @@ 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,
|
||||||
@@ -158,7 +158,7 @@ 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, stringResource(if (shuffleOn) R.string.player_shuffle_off else R.string.player_shuffle_on),
|
MediaIcons.Shuffle, stringResource(if (shuffleOn) R.string.player_shuffle_off else R.string.player_shuffle_on),
|
||||||
@@ -170,7 +170,7 @@ 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,
|
||||||
@@ -191,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))
|
||||||
|
|||||||
@@ -659,8 +659,16 @@ class CarAppConnection(private val context: Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
override fun sendLocation(location: Location?) = Unit
|
override fun sendLocation(location: Location?) = Unit
|
||||||
override fun showAlert(alert: Bundleable?) = Unit
|
override fun showAlert(alert: Bundleable?) {
|
||||||
override fun dismissAlert(alertId: Int) = Unit
|
val shown = runCatching { alert?.get() as? androidx.car.app.model.Alert }
|
||||||
|
.onFailure { GearslipLog.w("host: couldn't read an alert: ${it.message}") }
|
||||||
|
.getOrNull() ?: return
|
||||||
|
main.post { if (toastOwner === owner) app.seb3thehacker.gearslip.car.CarAlerts.show(owner, shown) }
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun dismissAlert(alertId: Int) {
|
||||||
|
main.post { app.seb3thehacker.gearslip.car.CarAlerts.dismiss(owner, alertId) }
|
||||||
|
}
|
||||||
override fun openMicrophone(request: Bundleable?): Bundleable? = null
|
override fun openMicrophone(request: Bundleable?): Bundleable? = null
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -839,6 +847,7 @@ class CarAppConnection(private val context: Context) {
|
|||||||
|
|
||||||
private fun clearToasts() {
|
private fun clearToasts() {
|
||||||
CarToasts.clear(toastOwner)
|
CarToasts.clear(toastOwner)
|
||||||
|
app.seb3thehacker.gearslip.car.CarAlerts.clear(toastOwner)
|
||||||
toastOwner = Any()
|
toastOwner = Any()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -306,6 +306,20 @@ fun OnContentRefreshDelegate?.refresh() {
|
|||||||
.onFailure { GearslipLog.w("host: could not request a refresh: ${it.message}") }
|
.onFailure { GearslipLog.w("host: could not request a refresh: ${it.message}") }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** An alert left the screen without the app taking it down: timed out, or closed by the driver. */
|
||||||
|
fun androidx.car.app.model.AlertCallbackDelegate?.cancel(reason: Int) {
|
||||||
|
val delegate = this ?: return
|
||||||
|
runCatching { delegate.sendCancel(reason, doneCallback("an alert cancel")) }
|
||||||
|
.onFailure { GearslipLog.w("host: could not cancel an alert: ${it.message}") }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** An alert left the screen another way: a newer one replaced it, the app took it down, or a tap. */
|
||||||
|
fun androidx.car.app.model.AlertCallbackDelegate?.dismissed() {
|
||||||
|
val delegate = this ?: return
|
||||||
|
runCatching { delegate.sendDismiss(doneCallback("an alert dismiss")) }
|
||||||
|
.onFailure { GearslipLog.w("host: could not dismiss an alert: ${it.message}") }
|
||||||
|
}
|
||||||
|
|
||||||
private fun doneCallback(label: String) = object : OnDoneCallback {
|
private fun doneCallback(label: String) = object : OnDoneCallback {
|
||||||
// onSuccess is annotated nullable and onFailure isn't - the asymmetry is real.
|
// onSuccess is annotated nullable and onFailure isn't - the asymmetry is real.
|
||||||
override fun onSuccess(response: Bundleable?) = Unit
|
override fun onSuccess(response: Bundleable?) = Unit
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
package app.seb3thehacker.gearslip.media
|
package app.seb3thehacker.gearslip.media
|
||||||
|
|
||||||
|
import app.seb3thehacker.gearslip.R
|
||||||
import android.content.ComponentName
|
import android.content.ComponentName
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
@@ -43,15 +44,27 @@ class MediaEntry(
|
|||||||
val group: String? = null,
|
val group: String? = null,
|
||||||
)
|
)
|
||||||
|
|
||||||
/** [icon] is a drawable id inside the media app's own package, not Gearslip's; 0 for none. */
|
/**
|
||||||
class CustomAction(val id: String, val name: String, val extras: Bundle?, val icon: Int = 0, val iconName: String = "") {
|
* [icon] is a drawable id inside the media app's own package, not Gearslip's; 0 for none.
|
||||||
|
* [hasOutline] says the app also has an outline of the same icon (ic_heart beside ic_heart_outline).
|
||||||
|
*/
|
||||||
|
class CustomAction(
|
||||||
|
val id: String,
|
||||||
|
val name: String,
|
||||||
|
val extras: Bundle?,
|
||||||
|
val icon: Int = 0,
|
||||||
|
val iconName: String = "",
|
||||||
|
val hasOutline: Boolean = false,
|
||||||
|
) {
|
||||||
/**
|
/**
|
||||||
* Whether a toggle like heart or shuffle is on, read from the icon's name - Tidal swaps
|
* Whether a toggle like heart or shuffle is on, read from the icon's name - Tidal swaps
|
||||||
* ic_favorite for ic_favorite_filled and ic_shuffle for ic_shuffle_active, and keeps the
|
* ic_favorite for ic_favorite_filled and ic_shuffle for ic_shuffle_active, and keeps the
|
||||||
* button's name the same. Whole words only, so "inactive" doesn't count as "active".
|
* button's name the same. Whole words only, so "inactive" doesn't count as "active".
|
||||||
|
* Metrolist swaps ic_heart_outline for a plain ic_heart, so a plain icon that has an outline
|
||||||
|
* twin counts as on too.
|
||||||
*/
|
*/
|
||||||
val on: Boolean
|
val on: Boolean
|
||||||
get() = iconName.lowercase().split('_', '.', '-').any { it in ON_WORDS }
|
get() = hasOutline || iconName.lowercase().split('_', '.', '-').any { it in ON_WORDS }
|
||||||
|
|
||||||
private companion object {
|
private companion object {
|
||||||
val ON_WORDS = setOf("filled", "fill", "active", "on", "selected", "checked", "enabled", "solid", "one", "all")
|
val ON_WORDS = setOf("filled", "fill", "active", "on", "selected", "checked", "enabled", "solid", "one", "all")
|
||||||
@@ -117,8 +130,8 @@ class NowPlaying(
|
|||||||
* only says which way it goes next by its name ("Remove from Liked Songs"), so that's read.
|
* only says which way it goes next by its name ("Remove from Liked Songs"), so that's read.
|
||||||
*/
|
*/
|
||||||
val liked: Boolean?
|
val liked: Boolean?
|
||||||
get() = heart ?: likeAction?.name?.let { name ->
|
get() = heart ?: likeAction?.let { action ->
|
||||||
listOf("remove", "unlike", "unfav", "unheart").any { name.contains(it, ignoreCase = true) }
|
action.on || listOf("remove", "unlike", "unfav", "unheart").any { action.name.contains(it, ignoreCase = true) }
|
||||||
}
|
}
|
||||||
|
|
||||||
val playing get() = state == PlaybackState.STATE_PLAYING || state == PlaybackState.STATE_BUFFERING
|
val playing get() = state == PlaybackState.STATE_PLAYING || state == PlaybackState.STATE_BUFFERING
|
||||||
@@ -391,7 +404,10 @@ class CarMedia(private val context: Context) {
|
|||||||
speed = state?.playbackSpeed ?: 1f,
|
speed = state?.playbackSpeed ?: 1f,
|
||||||
updatedAt = state?.lastPositionUpdateTime ?: 0,
|
updatedAt = state?.lastPositionUpdateTime ?: 0,
|
||||||
actions = state?.actions ?: 0,
|
actions = state?.actions ?: 0,
|
||||||
custom = state?.customActions.orEmpty().map { CustomAction(it.action, it.name.toString(), it.extras, it.icon, iconName(it.icon)) },
|
custom = withNextChapter(state?.customActions.orEmpty().map {
|
||||||
|
val iconName = iconName(it.icon)
|
||||||
|
CustomAction(it.action, it.name.toString(), it.extras, it.icon, iconName, hasOutline(it.icon, iconName))
|
||||||
|
}),
|
||||||
error = state?.errorMessage?.toString(),
|
error = state?.errorMessage?.toString(),
|
||||||
activeQueueItemId = state?.activeQueueItemId ?: MediaSession.QueueItem.UNKNOWN_ID.toLong(),
|
activeQueueItemId = state?.activeQueueItemId ?: MediaSession.QueueItem.UNKNOWN_ID.toLong(),
|
||||||
heart = if (controller?.ratingType == Rating.RATING_HEART) {
|
heart = if (controller?.ratingType == Rating.RATING_HEART) {
|
||||||
@@ -404,16 +420,48 @@ class CarMedia(private val context: Context) {
|
|||||||
|
|
||||||
private var appResources: Pair<String, android.content.res.Resources?>? = null
|
private var appResources: Pair<String, android.content.res.Resources?>? = null
|
||||||
|
|
||||||
|
/**
|
||||||
|
* AntennaPod takes a Next chapter command whenever it's playing, but offers the button only
|
||||||
|
* when the driver turns it on in its notification settings. Gearslip offers it either way,
|
||||||
|
* with AntennaPod's own icon; on an episode without chapters it does nothing.
|
||||||
|
*/
|
||||||
|
private fun withNextChapter(actions: List<CustomAction>): List<CustomAction> {
|
||||||
|
if (controller?.packageName != ANTENNAPOD) return actions
|
||||||
|
if (actions.any { it.id in ANTENNAPOD_NEXT_CHAPTER }) return actions
|
||||||
|
val icon = resourcesOf(ANTENNAPOD)?.getIdentifier("ic_notification_next_chapter", "drawable", ANTENNAPOD) ?: 0
|
||||||
|
val nextChapter = CustomAction(ANTENNAPOD_NEXT_CHAPTER.first(), context.getString(R.string.player_next_chapter), null, icon)
|
||||||
|
// The player shows only the first two of an app's own buttons, and AntennaPod's are
|
||||||
|
// Rewind and Fast-forward: second place keeps Rewind beside it.
|
||||||
|
return actions.toMutableList().apply { add(minOf(1, size), nextChapter) }
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun resourcesOf(pkg: String): android.content.res.Resources? {
|
||||||
|
if (appResources?.first != pkg) {
|
||||||
|
appResources = pkg to runCatching { context.packageManager.getResourcesForApplication(pkg) }.getOrNull()
|
||||||
|
outlines.clear()
|
||||||
|
}
|
||||||
|
return appResources?.second
|
||||||
|
}
|
||||||
|
|
||||||
/** The icon's resource name in the app's package, or "" when it can't be read. */
|
/** The icon's resource name in the app's package, or "" when it can't be read. */
|
||||||
private fun iconName(icon: Int): String {
|
private fun iconName(icon: Int): String {
|
||||||
val pkg = controller?.packageName ?: return ""
|
val pkg = controller?.packageName ?: return ""
|
||||||
if (appResources?.first != pkg) {
|
val res = resourcesOf(pkg) ?: return ""
|
||||||
appResources = pkg to runCatching { context.packageManager.getResourcesForApplication(pkg) }.getOrNull()
|
|
||||||
}
|
|
||||||
val res = appResources?.second ?: return ""
|
|
||||||
return runCatching { res.getResourceEntryName(icon) }.getOrDefault("")
|
return runCatching { res.getResourceEntryName(icon) }.getOrDefault("")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Whether each icon has an outline twin, by icon id: looked up once, since state changes often. */
|
||||||
|
private val outlines = HashMap<Int, Boolean>()
|
||||||
|
|
||||||
|
private fun hasOutline(icon: Int, name: String): Boolean {
|
||||||
|
val pkg = controller?.packageName ?: return false
|
||||||
|
val res = resourcesOf(pkg) ?: return false
|
||||||
|
if (name.isEmpty() || OUTLINE_WORDS.any { name.contains(it, ignoreCase = true) }) return false
|
||||||
|
return outlines.getOrPut(icon) {
|
||||||
|
OUTLINE_WORDS.any { res.getIdentifier("${name}_$it", "drawable", pkg) != 0 }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private var loggedButtons: String? = null
|
private var loggedButtons: String? = null
|
||||||
|
|
||||||
/** The app's own buttons, by id, name and icon, each time they change. */
|
/** The app's own buttons, by id, name and icon, each time they change. */
|
||||||
@@ -515,7 +563,15 @@ class CarMedia(private val context: Context) {
|
|||||||
fun next() = controller?.transportControls?.skipToNext()
|
fun next() = controller?.transportControls?.skipToNext()
|
||||||
fun previous() = controller?.transportControls?.skipToPrevious()
|
fun previous() = controller?.transportControls?.skipToPrevious()
|
||||||
fun seek(ms: Long) = controller?.transportControls?.seekTo(ms)
|
fun seek(ms: Long) = controller?.transportControls?.seekTo(ms)
|
||||||
fun custom(action: CustomAction) = controller?.transportControls?.sendCustomAction(action.id, action.extras)
|
fun custom(action: CustomAction) {
|
||||||
|
val controls = controller?.transportControls ?: return
|
||||||
|
// AntennaPod's media3 service and its older one each know only their own name for it.
|
||||||
|
if (controller?.packageName == ANTENNAPOD && action.id in ANTENNAPOD_NEXT_CHAPTER) {
|
||||||
|
ANTENNAPOD_NEXT_CHAPTER.forEach { controls.sendCustomAction(it, null) }
|
||||||
|
} else {
|
||||||
|
controls.sendCustomAction(action.id, action.extras)
|
||||||
|
}
|
||||||
|
}
|
||||||
fun playQueueItem(track: QueueTrack) = controller?.transportControls?.skipToQueueItem(track.queueId)
|
fun playQueueItem(track: QueueTrack) = controller?.transportControls?.skipToQueueItem(track.queueId)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -794,10 +850,16 @@ class CarMedia(private val context: Context) {
|
|||||||
/** How long an app gets to start playing a search before Gearslip says it didn't. */
|
/** How long an app gets to start playing a search before Gearslip says it didn't. */
|
||||||
const val SEARCH_WAIT_MS = 6_000L
|
const val SEARCH_WAIT_MS = 6_000L
|
||||||
const val SEARCH_NOTICE_MS = 6_000L
|
const val SEARCH_NOTICE_MS = 6_000L
|
||||||
|
/** How apps name the empty version of an icon. */
|
||||||
|
val OUTLINE_WORDS = listOf("outline", "outlined", "border", "empty")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Android Auto's browse layout hints, which any app built for it already sends. */
|
/** Android Auto's browse layout hints, which any app built for it already sends. */
|
||||||
|
private const val ANTENNAPOD = "de.danoeh.antennapod"
|
||||||
|
/** The media3 service's name first: that's the one offered as the button's own. */
|
||||||
|
private val ANTENNAPOD_NEXT_CHAPTER = listOf("next_chapter", "action.de.danoeh.antennapod.core.service.next_chapter")
|
||||||
|
|
||||||
private const val STYLE_SUPPORTED = "android.media.browse.CONTENT_STYLE_SUPPORTED"
|
private const val STYLE_SUPPORTED = "android.media.browse.CONTENT_STYLE_SUPPORTED"
|
||||||
private const val STYLE_BROWSABLE = "android.media.browse.CONTENT_STYLE_BROWSABLE_HINT"
|
private const val STYLE_BROWSABLE = "android.media.browse.CONTENT_STYLE_BROWSABLE_HINT"
|
||||||
private const val STYLE_PLAYABLE = "android.media.browse.CONTENT_STYLE_PLAYABLE_HINT"
|
private const val STYLE_PLAYABLE = "android.media.browse.CONTENT_STYLE_PLAYABLE_HINT"
|
||||||
|
|||||||
@@ -27,7 +27,10 @@ class GearslipNotificationListener : NotificationListenerService() {
|
|||||||
|
|
||||||
override fun onNotificationPosted(sbn: StatusBarNotification) = ingest(sbn, quiet = false)
|
override fun onNotificationPosted(sbn: StatusBarNotification) = ingest(sbn, quiet = false)
|
||||||
|
|
||||||
override fun onNotificationRemoved(sbn: StatusBarNotification) = CarNotifications.gone(sbn.key)
|
override fun onNotificationRemoved(sbn: StatusBarNotification) {
|
||||||
|
PhoneNavigation.removed(sbn.key)
|
||||||
|
CarNotifications.gone(sbn.key)
|
||||||
|
}
|
||||||
|
|
||||||
private fun ingest(sbn: StatusBarNotification, quiet: Boolean) {
|
private fun ingest(sbn: StatusBarNotification, quiet: Boolean) {
|
||||||
// Gearslip's own notifications are skipped, except a debug build's test message.
|
// Gearslip's own notifications are skipped, except a debug build's test message.
|
||||||
@@ -35,6 +38,11 @@ class GearslipNotificationListener : NotificationListenerService() {
|
|||||||
!(app.seb3thehacker.gearslip.BuildConfig.DEBUG && sbn.notification.channelId == TestMessage.CHANNEL)
|
!(app.seb3thehacker.gearslip.BuildConfig.DEBUG && sbn.notification.channelId == TestMessage.CHANNEL)
|
||||||
) return
|
) return
|
||||||
val n = sbn.notification
|
val n = sbn.notification
|
||||||
|
// Google Maps or Waze guiding on the phone: the turn goes to the nav bar and the cluster.
|
||||||
|
if (n.category == Notification.CATEGORY_NAVIGATION && sbn.packageName in PhoneNavigation.APPS) {
|
||||||
|
PhoneNavigation.posted(this, sbn)
|
||||||
|
return
|
||||||
|
}
|
||||||
// Ongoing entries (media, navigation, downloads) and group headers are status, not news.
|
// Ongoing entries (media, navigation, downloads) and group headers are status, not news.
|
||||||
if (n.flags and (Notification.FLAG_ONGOING_EVENT or Notification.FLAG_GROUP_SUMMARY) != 0) return
|
if (n.flags and (Notification.FLAG_ONGOING_EVENT or Notification.FLAG_GROUP_SUMMARY) != 0) return
|
||||||
if (n.category in QUIET_CATEGORIES) return
|
if (n.category in QUIET_CATEGORIES) return
|
||||||
|
|||||||
@@ -0,0 +1,147 @@
|
|||||||
|
package app.seb3thehacker.gearslip.notify
|
||||||
|
|
||||||
|
import android.app.Notification
|
||||||
|
import android.content.Context
|
||||||
|
import android.graphics.Bitmap
|
||||||
|
import android.service.notification.StatusBarNotification
|
||||||
|
import androidx.car.app.model.Distance
|
||||||
|
import androidx.car.app.navigation.model.Maneuver
|
||||||
|
import androidx.core.graphics.drawable.toBitmap
|
||||||
|
import app.seb3thehacker.gearslip.GearslipLog
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The next turn from a map app guiding on the phone itself. Google Maps and Waze refuse to run on
|
||||||
|
* Gearslip's car screen, but while one of them navigates on the phone it keeps a notification up
|
||||||
|
* with the turn's arrow, the distance and the road. Gearslip reads it, through the notification
|
||||||
|
* access it already has, and puts the turn in the nav bar and on the instrument cluster.
|
||||||
|
*/
|
||||||
|
object PhoneNavigation {
|
||||||
|
|
||||||
|
/** One turn as the notification described it. [maneuver] is a guess from the words, English only. */
|
||||||
|
class Turn(
|
||||||
|
val key: String,
|
||||||
|
val packageName: String,
|
||||||
|
val arrow: Bitmap?,
|
||||||
|
val distance: Distance?,
|
||||||
|
val distanceText: String,
|
||||||
|
val road: String,
|
||||||
|
val maneuver: Int,
|
||||||
|
)
|
||||||
|
|
||||||
|
private val _turn = MutableStateFlow<Turn?>(null)
|
||||||
|
val turn: StateFlow<Turn?> = _turn
|
||||||
|
|
||||||
|
/** The apps whose turns are read; any other map app runs on the car screen itself. */
|
||||||
|
val APPS = setOf("com.google.android.apps.maps", "com.waze")
|
||||||
|
|
||||||
|
private val log = GearslipLog.tagged("nav")
|
||||||
|
|
||||||
|
fun posted(context: Context, sbn: StatusBarNotification) {
|
||||||
|
val turn = parse(context, sbn)
|
||||||
|
val was = _turn.value
|
||||||
|
if (turn == null) {
|
||||||
|
if (was?.key == sbn.key) _turn.value = null
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (was == null || was.road != turn.road || was.maneuver != turn.maneuver) {
|
||||||
|
log.i("phone navigation (${sbn.packageName}): ${turn.distanceText} | ${turn.road} | maneuver=${turn.maneuver}")
|
||||||
|
}
|
||||||
|
_turn.value = turn
|
||||||
|
}
|
||||||
|
|
||||||
|
fun removed(key: String) {
|
||||||
|
if (_turn.value?.key == key) {
|
||||||
|
log.i("phone navigation ended")
|
||||||
|
_turn.value = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Google Maps puts the distance in the title and the instruction in the text; Waze has been
|
||||||
|
* seen the other way round. Whichever field starts with a distance is the distance, and the
|
||||||
|
* other is the instruction.
|
||||||
|
*/
|
||||||
|
private fun parse(context: Context, sbn: StatusBarNotification): Turn? {
|
||||||
|
val extras = sbn.notification.extras
|
||||||
|
fun field(name: String) = extras.getCharSequence(name)?.toString()?.trim().orEmpty()
|
||||||
|
val fields = listOf(field(Notification.EXTRA_TITLE), field(Notification.EXTRA_TEXT), field(Notification.EXTRA_BIG_TEXT))
|
||||||
|
.filter { it.isNotEmpty() }
|
||||||
|
if (fields.isEmpty()) return null
|
||||||
|
val withDistance = fields.firstOrNull { DISTANCE.find(it)?.range?.first == 0 }
|
||||||
|
val match = withDistance?.let { DISTANCE.find(it) }
|
||||||
|
val rest = withDistance?.substring(match!!.range.last + 1)?.trimStart(' ', '·', '-', '•', ',')?.trim().orEmpty()
|
||||||
|
val instruction = fields.firstOrNull { it != withDistance }?.takeIf { it.isNotEmpty() } ?: rest
|
||||||
|
if (instruction.isEmpty() && match == null) return null
|
||||||
|
return Turn(
|
||||||
|
key = sbn.key,
|
||||||
|
packageName = sbn.packageName,
|
||||||
|
arrow = arrow(context, sbn.notification),
|
||||||
|
distance = match?.let { distanceOf(it) },
|
||||||
|
distanceText = match?.value?.trim().orEmpty(),
|
||||||
|
road = roadOf(instruction),
|
||||||
|
maneuver = maneuverOf(instruction),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun arrow(context: Context, n: Notification): Bitmap? = runCatching {
|
||||||
|
n.getLargeIcon()?.loadDrawable(context)?.toBitmap(ARROW_PX, ARROW_PX)
|
||||||
|
}.getOrNull()
|
||||||
|
|
||||||
|
private fun distanceOf(m: MatchResult): Distance? {
|
||||||
|
val value = m.groupValues[1].replace(',', '.').toDoubleOrNull() ?: return null
|
||||||
|
val unit = when (m.groupValues[2].lowercase()) {
|
||||||
|
"km" -> Distance.UNIT_KILOMETERS
|
||||||
|
"mi" -> Distance.UNIT_MILES
|
||||||
|
"ft" -> Distance.UNIT_FEET
|
||||||
|
"yd" -> Distance.UNIT_YARDS
|
||||||
|
else -> Distance.UNIT_METERS
|
||||||
|
}
|
||||||
|
val meters = value * when (unit) {
|
||||||
|
Distance.UNIT_KILOMETERS -> 1000.0
|
||||||
|
Distance.UNIT_MILES -> 1609.344
|
||||||
|
Distance.UNIT_FEET -> 0.3048
|
||||||
|
Distance.UNIT_YARDS -> 0.9144
|
||||||
|
else -> 1.0
|
||||||
|
}
|
||||||
|
return Distance.create(meters, unit)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** "Turn left onto Main St" shows as "Main St"; anything else shows as it came. */
|
||||||
|
private fun roadOf(instruction: String): String {
|
||||||
|
val lower = instruction.lowercase()
|
||||||
|
for (word in ROAD_WORDS) {
|
||||||
|
val at = lower.indexOf(word)
|
||||||
|
if (at >= 0) return instruction.substring(at + word.length).trim()
|
||||||
|
}
|
||||||
|
return instruction
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun maneuverOf(instruction: String): Int {
|
||||||
|
val s = instruction.lowercase()
|
||||||
|
val left = "left" in s
|
||||||
|
val right = "right" in s
|
||||||
|
fun side(l: Int, r: Int) = if (left) l else if (right) r else Maneuver.TYPE_UNKNOWN
|
||||||
|
return when {
|
||||||
|
"u-turn" in s || "make a u" in s -> side(Maneuver.TYPE_U_TURN_LEFT, Maneuver.TYPE_U_TURN_RIGHT)
|
||||||
|
"roundabout" in s -> Maneuver.TYPE_UNKNOWN // the car's icon needs an exit number we don't have
|
||||||
|
"destination" in s || "arrive" in s -> side(Maneuver.TYPE_DESTINATION_LEFT, Maneuver.TYPE_DESTINATION_RIGHT)
|
||||||
|
.takeIf { it != Maneuver.TYPE_UNKNOWN } ?: Maneuver.TYPE_DESTINATION
|
||||||
|
"exit" in s -> side(Maneuver.TYPE_OFF_RAMP_NORMAL_LEFT, Maneuver.TYPE_OFF_RAMP_NORMAL_RIGHT)
|
||||||
|
"merge" in s -> side(Maneuver.TYPE_MERGE_LEFT, Maneuver.TYPE_MERGE_RIGHT)
|
||||||
|
.takeIf { it != Maneuver.TYPE_UNKNOWN } ?: Maneuver.TYPE_MERGE_SIDE_UNSPECIFIED
|
||||||
|
"fork" in s -> side(Maneuver.TYPE_FORK_LEFT, Maneuver.TYPE_FORK_RIGHT)
|
||||||
|
"keep" in s || "stay" in s -> side(Maneuver.TYPE_KEEP_LEFT, Maneuver.TYPE_KEEP_RIGHT)
|
||||||
|
"slight" in s -> side(Maneuver.TYPE_TURN_SLIGHT_LEFT, Maneuver.TYPE_TURN_SLIGHT_RIGHT)
|
||||||
|
"sharp" in s -> side(Maneuver.TYPE_TURN_SHARP_LEFT, Maneuver.TYPE_TURN_SHARP_RIGHT)
|
||||||
|
"turn" in s -> side(Maneuver.TYPE_TURN_NORMAL_LEFT, Maneuver.TYPE_TURN_NORMAL_RIGHT)
|
||||||
|
"continue" in s || "straight" in s || "head " in s -> Maneuver.TYPE_STRAIGHT
|
||||||
|
else -> Maneuver.TYPE_UNKNOWN
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private const val ARROW_PX = 96
|
||||||
|
private val DISTANCE = Regex("""^\s*(\d+(?:[.,]\d+)?)\s*(km|mi|ft|yd|m)\b""", RegexOption.IGNORE_CASE)
|
||||||
|
private val ROAD_WORDS = listOf(" onto ", " on to ", " toward ", " towards ", " on ")
|
||||||
|
}
|
||||||
@@ -226,11 +226,11 @@ object UsageStats {
|
|||||||
* build names, not confirmed on a real phone.
|
* build names, not confirmed on a real phone.
|
||||||
*/
|
*/
|
||||||
fun osName(context: Context): String {
|
fun osName(context: Context): String {
|
||||||
fun installed(pkg: String) = runCatching { context.packageManager.getPackageInfo(pkg, 0) }.isSuccess
|
fun installed(pkg: String) = installed(context, pkg)
|
||||||
val names = listOf("ro.build.flavor", "ro.build.display.id", "ro.lineage.version")
|
val names = listOf("ro.build.flavor", "ro.build.display.id", "ro.lineage.version")
|
||||||
.joinToString(" ") { prop(it) } + " " + Build.DISPLAY + " " + Build.FINGERPRINT
|
.joinToString(" ") { prop(it) } + " " + Build.DISPLAY + " " + Build.FINGERPRINT
|
||||||
return when {
|
return when {
|
||||||
installed("app.grapheneos.setupwizard") -> "GrapheneOS"
|
isGrapheneOs(context) -> "GrapheneOS"
|
||||||
prop("ro.calyxos.version").isNotEmpty() -> "CalyxOS"
|
prop("ro.calyxos.version").isNotEmpty() -> "CalyxOS"
|
||||||
installed("foundation.e.apps") -> "/e/OS"
|
installed("foundation.e.apps") -> "/e/OS"
|
||||||
names.contains("iode", ignoreCase = true) -> "iodéOS"
|
names.contains("iode", ignoreCase = true) -> "iodéOS"
|
||||||
@@ -240,6 +240,12 @@ object UsageStats {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** GrapheneOS, told apart from stock Pixel firmware by its own setup app. */
|
||||||
|
fun isGrapheneOs(context: Context) = installed(context, "app.grapheneos.setupwizard")
|
||||||
|
|
||||||
|
private fun installed(context: Context, pkg: String) =
|
||||||
|
runCatching { context.packageManager.getPackageInfo(pkg, 0) }.isSuccess
|
||||||
|
|
||||||
/** A system property, or "" when it isn't set or can't be read. */
|
/** A system property, or "" when it isn't set or can't be read. */
|
||||||
private fun prop(name: String): String = runCatching {
|
private fun prop(name: String): String = runCatching {
|
||||||
Class.forName("android.os.SystemProperties").getMethod("get", String::class.java)
|
Class.forName("android.os.SystemProperties").getMethod("get", String::class.java)
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ import androidx.compose.material3.Scaffold
|
|||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.material3.TextButton
|
import androidx.compose.material3.TextButton
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.CompositionLocalProvider
|
||||||
import androidx.compose.runtime.DisposableEffect
|
import androidx.compose.runtime.DisposableEffect
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
@@ -59,8 +60,7 @@ import app.seb3thehacker.gearslip.car.CarUi
|
|||||||
@Composable
|
@Composable
|
||||||
fun CarPreviewScreen(onBack: () -> Unit) {
|
fun CarPreviewScreen(onBack: () -> Unit) {
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
val frame by CarEnvironment.frame.collectAsStateWithLifecycle()
|
val frame by remember { CarEnvironment.frame.also { CarEnvironment.applyDensity() } }.collectAsStateWithLifecycle()
|
||||||
var viewSize by remember { mutableStateOf(0 to 0) }
|
|
||||||
// Tied to how the phone itself is held, not a button: turn it sideways and the preview goes
|
// Tied to how the phone itself is held, not a button: turn it sideways and the preview goes
|
||||||
// fullscreen, turn it back and the phone's own chrome (the Back/title above the frame) returns.
|
// fullscreen, turn it back and the phone's own chrome (the Back/title above the frame) returns.
|
||||||
val fullscreen = LocalConfiguration.current.orientation == Configuration.ORIENTATION_LANDSCAPE
|
val fullscreen = LocalConfiguration.current.orientation == Configuration.ORIENTATION_LANDSCAPE
|
||||||
@@ -69,8 +69,6 @@ fun CarPreviewScreen(onBack: () -> Unit) {
|
|||||||
val custom by app.seb3thehacker.gearslip.car.CarSettings.previewCustom.collectAsStateWithLifecycle()
|
val custom by app.seb3thehacker.gearslip.car.CarSettings.previewCustom.collectAsStateWithLifecycle()
|
||||||
val (width, height, density) = previewFrame(context, frame, previewSize, custom)
|
val (width, height, density) = previewFrame(context, frame, previewSize, custom)
|
||||||
|
|
||||||
val projector = remember { ScreenProjector(context) }
|
|
||||||
|
|
||||||
// The preview is something to watch, like a map on a dash mount, so the phone stays awake on
|
// The preview is something to watch, like a map on a dash mount, so the phone stays awake on
|
||||||
// it. Only here: the flag goes with this screen, and every other screen sleeps as usual.
|
// it. Only here: the flag goes with this screen, and every other screen sleeps as usual.
|
||||||
val view = androidx.compose.ui.platform.LocalView.current
|
val view = androidx.compose.ui.platform.LocalView.current
|
||||||
@@ -79,22 +77,83 @@ fun CarPreviewScreen(onBack: () -> Unit) {
|
|||||||
onDispose { view.keepScreenOn = false }
|
onDispose { view.keepScreenOn = false }
|
||||||
}
|
}
|
||||||
|
|
||||||
DisposableEffect(width, height, density) {
|
// A launcher already shows the car UI on this screen, and two of it can't run at once.
|
||||||
CarEnvironment.start(context)
|
val launcher by app.seb3thehacker.gearslip.car.LauncherMode.active.collectAsStateWithLifecycle()
|
||||||
// No surface yet: the SurfaceView below hands its own over once it exists, and the
|
|
||||||
// display draws straight into it. Copying each frame into a Bitmap instead cost two
|
// Held here, not in the frame, so turning the phone between the two layouts keeps the car UI running.
|
||||||
// full-size allocations per frame on the main thread, and taps lagged with the size.
|
val projector = if (launcher) null else rememberCarProjector(width, height, density, "gearslip-preview") {
|
||||||
projector.start(null, width, height, density, name = "gearslip-preview") {
|
CompositionLocalProvider(app.seb3thehacker.gearslip.car.LocalCarPreview provides true) { CarUi() }
|
||||||
androidx.compose.runtime.CompositionLocalProvider(app.seb3thehacker.gearslip.car.LocalCarPreview provides true) { CarUi() }
|
|
||||||
}
|
|
||||||
onDispose { projector.stop() }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// The frame is drawn the same way in both layouts; only what surrounds it differs.
|
// The frame is drawn the same way in both layouts; only what surrounds it differs.
|
||||||
val frameContent: @Composable (Modifier) -> Unit = { modifier ->
|
val frameContent: @Composable (Modifier) -> Unit = { modifier ->
|
||||||
|
if (projector == null) {
|
||||||
|
Text("Gearslip is this device's home screen, so the car UI is already showing there.", modifier)
|
||||||
|
} else {
|
||||||
|
CarFrame(projector, width, height, modifier.aspectRatio(width.toFloat() / height))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (fullscreen) {
|
||||||
|
Fullscreen(width.toFloat() / height, onBack) {
|
||||||
|
// No fillMax here: left to the bounds alone, aspectRatio takes the whole width or the
|
||||||
|
// whole height, whichever the car's shape allows, and the rest stays black.
|
||||||
|
frameContent(Modifier)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
Scaffold(containerColor = MaterialTheme.colorScheme.background) { padding ->
|
||||||
|
Column(Modifier.fillMaxSize().padding(padding).padding(horizontal = 16.dp)) {
|
||||||
|
Text(
|
||||||
|
"Car preview",
|
||||||
|
style = MaterialTheme.typography.headlineMedium,
|
||||||
|
modifier = Modifier.padding(vertical = 12.dp),
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
"${width}x$height @ ${density}dpi - touches go to the car UI",
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
|
||||||
|
frameContent(Modifier.padding(vertical = 12.dp).fillMaxWidth())
|
||||||
|
|
||||||
|
Row {
|
||||||
|
TextButton(onClick = onBack) { Text("Back") }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Runs [content] on a virtual display [width] x [height] pixels big, the way the car gets it,
|
||||||
|
* until it leaves the composition. [CarFrame] shows it.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
internal fun rememberCarProjector(width: Int, height: Int, density: Int, name: String, content: @Composable () -> Unit): ScreenProjector {
|
||||||
|
val context = LocalContext.current
|
||||||
|
val projector = remember { ScreenProjector(context) }
|
||||||
|
val latestContent by androidx.compose.runtime.rememberUpdatedState(content)
|
||||||
|
DisposableEffect(width, height, density) {
|
||||||
|
CarEnvironment.start(context)
|
||||||
|
// No surface yet: the SurfaceView in CarFrame hands its own over once it exists, and the
|
||||||
|
// display draws straight into it. Copying each frame into a Bitmap instead cost two
|
||||||
|
// full-size allocations per frame on the main thread, and taps lagged with the size.
|
||||||
|
projector.start(null, width, height, density, name = name) { latestContent() }
|
||||||
|
onDispose { projector.stop() }
|
||||||
|
}
|
||||||
|
return projector
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The [projector]'s display through a SurfaceView, with touches scaled back to the frame's own
|
||||||
|
* [width] x [height]. The car preview letterboxes it; launcher mode fills the screen with it.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
internal fun CarFrame(projector: ScreenProjector, width: Int, height: Int, modifier: Modifier) {
|
||||||
|
var viewSize by remember { mutableStateOf(0 to 0) }
|
||||||
Box(
|
Box(
|
||||||
modifier
|
modifier
|
||||||
.aspectRatio(width.toFloat() / height)
|
|
||||||
.background(Color.Black)
|
.background(Color.Black)
|
||||||
.onSizeChanged { viewSize = it.width to it.height }
|
.onSizeChanged { viewSize = it.width to it.height }
|
||||||
.pointerInteropFilter { event ->
|
.pointerInteropFilter { event ->
|
||||||
@@ -140,37 +199,6 @@ fun CarPreviewScreen(onBack: () -> Unit) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (fullscreen) {
|
|
||||||
Fullscreen(width.toFloat() / height, onBack) {
|
|
||||||
// No fillMax here: left to the bounds alone, aspectRatio takes the whole width or the
|
|
||||||
// whole height, whichever the car's shape allows, and the rest stays black.
|
|
||||||
frameContent(Modifier)
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
Scaffold(containerColor = MaterialTheme.colorScheme.background) { padding ->
|
|
||||||
Column(Modifier.fillMaxSize().padding(padding).padding(horizontal = 16.dp)) {
|
|
||||||
Text(
|
|
||||||
"Car preview",
|
|
||||||
style = MaterialTheme.typography.headlineMedium,
|
|
||||||
modifier = Modifier.padding(vertical = 12.dp),
|
|
||||||
)
|
|
||||||
Text(
|
|
||||||
"${width}x$height @ ${density}dpi - touches go to the car UI",
|
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
||||||
)
|
|
||||||
|
|
||||||
frameContent(Modifier.padding(vertical = 12.dp).fillMaxWidth())
|
|
||||||
|
|
||||||
Row {
|
|
||||||
TextButton(onClick = onBack) { Text("Back") }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Fills the phone screen with [content], centred and letterboxed.
|
* Fills the phone screen with [content], centred and letterboxed.
|
||||||
*
|
*
|
||||||
@@ -263,7 +291,7 @@ private fun previewFrame(
|
|||||||
size: app.seb3thehacker.gearslip.car.PreviewSize,
|
size: app.seb3thehacker.gearslip.car.PreviewSize,
|
||||||
custom: app.seb3thehacker.gearslip.car.ScreenSize?,
|
custom: app.seb3thehacker.gearslip.car.ScreenSize?,
|
||||||
): Triple<Int, Int, Int> {
|
): Triple<Int, Int, Int> {
|
||||||
fun carLike(w: Int, h: Int) = Triple(w, h, (160 * h / 480).coerceIn(120, 640))
|
fun carLike(w: Int, h: Int) = Triple(w, h, app.seb3thehacker.gearslip.car.CarSettings.effectiveDensity((160 * h / 480).coerceIn(120, 640)))
|
||||||
return when (size) {
|
return when (size) {
|
||||||
app.seb3thehacker.gearslip.car.PreviewSize.LAST_CAR -> Triple(lastCar.width, lastCar.height, lastCar.densityDpi)
|
app.seb3thehacker.gearslip.car.PreviewSize.LAST_CAR -> Triple(lastCar.width, lastCar.height, lastCar.densityDpi)
|
||||||
app.seb3thehacker.gearslip.car.PreviewSize.CUSTOM ->
|
app.seb3thehacker.gearslip.car.PreviewSize.CUSTOM ->
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import androidx.compose.ui.res.stringResource
|
|||||||
import app.seb3thehacker.gearslip.R
|
import app.seb3thehacker.gearslip.R
|
||||||
import app.seb3thehacker.gearslip.BuildConfig
|
import app.seb3thehacker.gearslip.BuildConfig
|
||||||
import app.seb3thehacker.gearslip.car.CarSettings
|
import app.seb3thehacker.gearslip.car.CarSettings
|
||||||
|
import app.seb3thehacker.gearslip.stats.UsageStats
|
||||||
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
|
||||||
@@ -59,11 +60,13 @@ private val FIRST_DRIVE = listOf(
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
private fun inTheCar(onRequestCallScreening: () -> Unit) = listOf(
|
private fun inTheCar(grapheneOs: Boolean, onRequestCallScreening: () -> Unit) = listOfNotNull(
|
||||||
Step(
|
Step(
|
||||||
R.string.help_plug_in,
|
R.string.help_plug_in,
|
||||||
R.string.help_plug_in_detail,
|
R.string.help_plug_in_detail,
|
||||||
),
|
),
|
||||||
|
// GrapheneOS can turn off data on the USB port, and then the car never sees the phone (#71).
|
||||||
|
if (grapheneOs) Step(R.string.help_usb_data, R.string.help_usb_data_detail) else null,
|
||||||
Step(
|
Step(
|
||||||
R.string.help_pair,
|
R.string.help_pair,
|
||||||
R.string.help_pair_detail,
|
R.string.help_pair_detail,
|
||||||
@@ -93,9 +96,9 @@ private val USB_AUDIO = listOf(
|
|||||||
@OptIn(ExperimentalMaterial3Api::class)
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
@Composable
|
@Composable
|
||||||
fun HelpScreen(onBack: () -> Unit, onRequestCallScreening: () -> Unit) {
|
fun HelpScreen(onBack: () -> Unit, onRequestCallScreening: () -> Unit) {
|
||||||
val car = remember(onRequestCallScreening) { inTheCar(onRequestCallScreening) }
|
|
||||||
val usb by CarSettings.pipeAudio.collectAsState()
|
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
|
val car = remember(onRequestCallScreening) { inTheCar(UsageStats.isGrapheneOs(context), onRequestCallScreening) }
|
||||||
|
val usb by CarSettings.pipeAudio.collectAsState()
|
||||||
|
|
||||||
Scaffold(
|
Scaffold(
|
||||||
containerColor = MaterialTheme.colorScheme.background,
|
containerColor = MaterialTheme.colorScheme.background,
|
||||||
|
|||||||
@@ -4,19 +4,23 @@ import androidx.annotation.StringRes
|
|||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import app.seb3thehacker.gearslip.R
|
import app.seb3thehacker.gearslip.R
|
||||||
import app.seb3thehacker.gearslip.car.theme.DarkStyle
|
import app.seb3thehacker.gearslip.car.theme.DarkStyle
|
||||||
|
import androidx.compose.foundation.Image
|
||||||
import androidx.compose.foundation.clickable
|
import androidx.compose.foundation.clickable
|
||||||
import androidx.compose.foundation.selection.selectable
|
import androidx.compose.foundation.selection.selectable
|
||||||
|
import androidx.compose.foundation.selection.toggleable
|
||||||
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
|
||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight
|
import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight
|
||||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.foundation.verticalScroll
|
import androidx.compose.foundation.verticalScroll
|
||||||
|
import androidx.compose.material3.Checkbox
|
||||||
import androidx.compose.material3.DropdownMenu
|
import androidx.compose.material3.DropdownMenu
|
||||||
import androidx.compose.material3.DropdownMenuItem
|
import androidx.compose.material3.DropdownMenuItem
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
@@ -24,25 +28,33 @@ import androidx.compose.material3.MaterialTheme
|
|||||||
import androidx.compose.material3.Switch
|
import androidx.compose.material3.Switch
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
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.remember
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import androidx.compose.runtime.setValue
|
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.semantics.Role
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import app.seb3thehacker.gearslip.car.AppTheme
|
import app.seb3thehacker.gearslip.car.AppTheme
|
||||||
import app.seb3thehacker.gearslip.car.CarApps
|
import app.seb3thehacker.gearslip.car.CarApps
|
||||||
|
import app.seb3thehacker.gearslip.car.LauncherMode
|
||||||
import app.seb3thehacker.gearslip.car.CarSettings
|
import app.seb3thehacker.gearslip.car.CarSettings
|
||||||
|
import app.seb3thehacker.gearslip.car.LauncherCache
|
||||||
import app.seb3thehacker.gearslip.car.NavBarMode
|
import app.seb3thehacker.gearslip.car.NavBarMode
|
||||||
import app.seb3thehacker.gearslip.car.NightMode
|
import app.seb3thehacker.gearslip.car.NightMode
|
||||||
import app.seb3thehacker.gearslip.car.Units
|
import app.seb3thehacker.gearslip.car.Units
|
||||||
import app.seb3thehacker.gearslip.car.theme.GsThemes
|
import app.seb3thehacker.gearslip.car.theme.GsThemes
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
|
||||||
/** The car's setting groups, each opening its own screen from the phone's home screen. */
|
/** The car's setting groups, each opening its own screen from the phone's home screen. */
|
||||||
enum class CarGroup(@param:StringRes val title: Int) {
|
enum class CarGroup(@param:StringRes val title: Int) {
|
||||||
|
APPS(R.string.crumb_apps),
|
||||||
SCREEN(R.string.car_group_screen),
|
SCREEN(R.string.car_group_screen),
|
||||||
NAV_BAR(R.string.car_group_nav_bar),
|
NAV_BAR(R.string.car_group_nav_bar),
|
||||||
SOUND(R.string.car_group_sound),
|
SOUND(R.string.car_group_sound),
|
||||||
@@ -54,6 +66,8 @@ enum class CarGroup(@param:StringRes val title: Int) {
|
|||||||
@Composable
|
@Composable
|
||||||
private fun sizeChoices() = 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))
|
private fun sizeChoices() = 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))
|
||||||
@Composable
|
@Composable
|
||||||
|
private fun densityChoices() = listOf(CarSettings.DENSITY_FROM_CAR to stringResource(R.string.choice_density_car)) + CarSettings.DENSITIES.map { it to stringResource(R.string.choice_density_dpi, it) }
|
||||||
|
@Composable
|
||||||
private fun themeChoices() = 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))
|
private fun themeChoices() = 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))
|
||||||
@Composable
|
@Composable
|
||||||
private fun darkChoices() = listOf(
|
private fun darkChoices() = listOf(
|
||||||
@@ -73,7 +87,11 @@ private fun navBarChoices() = listOf(
|
|||||||
NavBarMode.HIDE to stringResource(R.string.choice_nav_hide),
|
NavBarMode.HIDE to stringResource(R.string.choice_nav_hide),
|
||||||
)
|
)
|
||||||
@Composable
|
@Composable
|
||||||
private fun openChoices() = listOf(CarSettings.HOME to stringResource(R.string.choice_open_home)) + CarApps.all.filter { it.id != "settings" }.map { it.id to stringResource(it.labelRes) }
|
private fun openChoices(): List<Pair<String, String>> {
|
||||||
|
val context = LocalContext.current
|
||||||
|
val apps = remember(context) { CarApps.shown(LauncherMode.enabled(context), LauncherMode.canCall(context)) }
|
||||||
|
return listOf(CarSettings.HOME to stringResource(R.string.choice_open_home)) + apps.map { it.id to stringResource(it.labelRes) }
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The car's settings on the phone's home screen: one card per group with a summary of where it
|
* The car's settings on the phone's home screen: one card per group with a summary of where it
|
||||||
@@ -102,6 +120,7 @@ internal fun HomeCarSettings(onOpen: (CarGroup) -> Unit, modifier: Modifier = Mo
|
|||||||
// Read again each time Home shows, so a place saved a moment ago is listed.
|
// Read again each time Home shows, so a place saved a moment ago is listed.
|
||||||
val places = androidx.compose.runtime.remember { app.seb3thehacker.gearslip.car.SavedPlaces.all(context) }
|
val places = androidx.compose.runtime.remember { app.seb3thehacker.gearslip.car.SavedPlaces.all(context) }
|
||||||
fun summary(group: CarGroup) = when (group) {
|
fun summary(group: CarGroup) = when (group) {
|
||||||
|
CarGroup.APPS -> listOf(str(R.string.car_apps_sub))
|
||||||
CarGroup.SCREEN -> listOf(opens.label(open), sizes.label(scale), themes.label(theme))
|
CarGroup.SCREEN -> listOf(opens.label(open), sizes.label(scale), themes.label(theme))
|
||||||
CarGroup.NAV_BAR -> listOf(navBars.label(navBar), str(if (playerLeft) R.string.summary_player_left else R.string.summary_player_right))
|
CarGroup.NAV_BAR -> listOf(navBars.label(navBar), str(if (playerLeft) R.string.summary_player_left else R.string.summary_player_right))
|
||||||
CarGroup.SOUND -> listOf(
|
CarGroup.SOUND -> listOf(
|
||||||
@@ -181,7 +200,8 @@ internal fun CarGroupScreen(group: CarGroup, onBack: () -> Unit) {
|
|||||||
}
|
}
|
||||||
else -> Unit
|
else -> Unit
|
||||||
}
|
}
|
||||||
Text(
|
// The car's own Settings has no list of apps.
|
||||||
|
if (group != CarGroup.APPS) Text(
|
||||||
stringResource(R.string.car_group_same),
|
stringResource(R.string.car_group_same),
|
||||||
style = MaterialTheme.typography.bodySmall,
|
style = MaterialTheme.typography.bodySmall,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
@@ -194,11 +214,13 @@ internal fun CarGroupScreen(group: CarGroup, onBack: () -> Unit) {
|
|||||||
@Composable
|
@Composable
|
||||||
private fun GroupRows(group: CarGroup) {
|
private fun GroupRows(group: CarGroup) {
|
||||||
val scale by CarSettings.scale.collectAsState()
|
val scale by CarSettings.scale.collectAsState()
|
||||||
|
val density by CarSettings.carDensity.collectAsState()
|
||||||
val open by CarSettings.openOnConnect.collectAsState()
|
val open by CarSettings.openOnConnect.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()
|
||||||
val pipeAudio by CarSettings.pipeAudio.collectAsState()
|
val pipeAudio by CarSettings.pipeAudio.collectAsState()
|
||||||
|
val directionsOverMusic by CarSettings.directionsOverMusic.collectAsState()
|
||||||
val speakDirections by CarSettings.speakDirections.collectAsState()
|
val speakDirections by CarSettings.speakDirections.collectAsState()
|
||||||
val systemSpeech by CarSettings.systemSpeech.collectAsState()
|
val systemSpeech by CarSettings.systemSpeech.collectAsState()
|
||||||
val bluetoothWarning by CarSettings.bluetoothWarning.collectAsState()
|
val bluetoothWarning by CarSettings.bluetoothWarning.collectAsState()
|
||||||
@@ -216,11 +238,14 @@ private fun GroupRows(group: CarGroup) {
|
|||||||
val clusterVideo by CarSettings.clusterVideoEnabled.collectAsState()
|
val clusterVideo by CarSettings.clusterVideoEnabled.collectAsState()
|
||||||
|
|
||||||
when (group) {
|
when (group) {
|
||||||
|
CarGroup.APPS -> AppRows()
|
||||||
CarGroup.SCREEN -> {
|
CarGroup.SCREEN -> {
|
||||||
Choice(stringResource(R.string.car_open_on_connect), openChoices(), open, CarSettings::setOpenOnConnect)
|
Choice(stringResource(R.string.car_open_on_connect), openChoices(), open, CarSettings::setOpenOnConnect)
|
||||||
SettingsDivider()
|
SettingsDivider()
|
||||||
Choice(stringResource(R.string.car_size), sizeChoices(), scale, CarSettings::setScale)
|
Choice(stringResource(R.string.car_size), sizeChoices(), scale, CarSettings::setScale)
|
||||||
SettingsDivider()
|
SettingsDivider()
|
||||||
|
Choice(stringResource(R.string.car_density), densityChoices(), density, CarSettings::setCarDensity, note = stringResource(R.string.car_density_note))
|
||||||
|
SettingsDivider()
|
||||||
Choice(stringResource(R.string.car_app_theme), themeChoices(), theme, CarSettings::setAppTheme)
|
Choice(stringResource(R.string.car_app_theme), themeChoices(), theme, CarSettings::setAppTheme)
|
||||||
SettingsDivider()
|
SettingsDivider()
|
||||||
if (theme != AppTheme.LIGHT) {
|
if (theme != AppTheme.LIGHT) {
|
||||||
@@ -256,6 +281,15 @@ private fun GroupRows(group: CarGroup) {
|
|||||||
CarSettings::setSpeakDirections,
|
CarSettings::setSpeakDirections,
|
||||||
note = stringResource(R.string.car_spoken_directions_note),
|
note = stringResource(R.string.car_spoken_directions_note),
|
||||||
)
|
)
|
||||||
|
if (speakDirections) {
|
||||||
|
SettingsDivider()
|
||||||
|
Toggle(
|
||||||
|
stringResource(R.string.car_directions_over_music),
|
||||||
|
directionsOverMusic,
|
||||||
|
CarSettings::setDirectionsOverMusic,
|
||||||
|
note = stringResource(R.string.car_directions_over_music_note),
|
||||||
|
)
|
||||||
|
}
|
||||||
SettingsDivider()
|
SettingsDivider()
|
||||||
Toggle(
|
Toggle(
|
||||||
stringResource(R.string.car_phone_speech),
|
stringResource(R.string.car_phone_speech),
|
||||||
@@ -303,15 +337,38 @@ private fun GroupRows(group: CarGroup) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** One row per tile the installed apps put in the car's drawer, ticked while it shows there. */
|
||||||
|
@Composable
|
||||||
|
private fun AppRows() {
|
||||||
|
val context = androidx.compose.ui.platform.LocalContext.current
|
||||||
|
val apps by LauncherCache.state.collectAsState()
|
||||||
|
val hidden by CarSettings.hiddenApps.collectAsState()
|
||||||
|
// Its own scan, so an app installed since the last drive is here to choose.
|
||||||
|
LaunchedEffect(Unit) { withContext(Dispatchers.IO) { LauncherCache.load(context, force = true) } }
|
||||||
|
val rows = apps.orEmpty().mapNotNull { entry -> entry.componentId?.let { it to entry } }
|
||||||
|
if (rows.isEmpty()) SettingsRow(stringResource(if (apps == null) R.string.car_apps_finding else R.string.car_apps_none))
|
||||||
|
rows.forEachIndexed { i, (id, entry) ->
|
||||||
|
val shown = id !in hidden
|
||||||
|
if (i > 0) SettingsDivider()
|
||||||
|
SettingsRow(
|
||||||
|
entry.label,
|
||||||
|
Modifier.toggleable(shown, role = Role.Checkbox) { CarSettings.setAppHidden(id, !it) },
|
||||||
|
leading = { entry.icon?.let { Image(it, contentDescription = null, Modifier.size(40.dp)) } },
|
||||||
|
trailing = { Checkbox(checked = shown, onCheckedChange = null) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private fun <T> List<Pair<T, String>>.label(value: T): String = firstOrNull { it.first == value }?.second.orEmpty()
|
private fun <T> List<Pair<T, String>>.label(value: T): String = firstOrNull { it.first == value }?.second.orEmpty()
|
||||||
|
|
||||||
/** A setting with a few named choices: its current one shown, the rest a tap away. */
|
/** A setting with a few named choices: its current one shown, the rest a tap away. */
|
||||||
@Composable
|
@Composable
|
||||||
private fun <T> Choice(title: String, choices: List<Pair<T, String>>, selected: T, onSelect: (T) -> Unit) {
|
private fun <T> Choice(title: String, choices: List<Pair<T, String>>, selected: T, onSelect: (T) -> Unit, note: String? = null) {
|
||||||
var menu by remember { mutableStateOf(false) }
|
var menu by remember { mutableStateOf(false) }
|
||||||
SettingsRow(
|
SettingsRow(
|
||||||
title,
|
title,
|
||||||
Modifier.clickable { menu = true },
|
Modifier.clickable { menu = true },
|
||||||
|
subtitle = note,
|
||||||
trailing = {
|
trailing = {
|
||||||
// The menu hangs from the value it changes, at the row's end.
|
// The menu hangs from the value it changes, at the row's end.
|
||||||
Box {
|
Box {
|
||||||
|
|||||||
@@ -25,6 +25,15 @@ import app.seb3thehacker.gearslip.car.CarSettings
|
|||||||
import app.seb3thehacker.gearslip.car.BluetoothAudioCheck
|
import app.seb3thehacker.gearslip.car.BluetoothAudioCheck
|
||||||
import android.provider.Settings
|
import android.provider.Settings
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
|
import android.content.BroadcastReceiver
|
||||||
|
import android.content.IntentFilter
|
||||||
|
import android.os.BatteryManager
|
||||||
|
import androidx.compose.runtime.DisposableEffect
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import app.seb3thehacker.gearslip.stats.UsageStats
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
import androidx.compose.foundation.rememberScrollState
|
import androidx.compose.foundation.rememberScrollState
|
||||||
import androidx.compose.foundation.verticalScroll
|
import androidx.compose.foundation.verticalScroll
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
@@ -258,6 +267,9 @@ private fun StatusCard(status: SessionStatus.Snapshot) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
val live = status.phase == SessionStatus.Phase.CONNECTING || status.phase == SessionStatus.Phase.PROJECTING
|
val live = status.phase == SessionStatus.Phase.CONNECTING || status.phase == SessionStatus.Phase.PROJECTING
|
||||||
|
val context = LocalContext.current
|
||||||
|
val grapheneOs = remember { UsageStats.isGrapheneOs(context) }
|
||||||
|
val noCar = chargingWithNoCar(status.phase == SessionStatus.Phase.IDLE)
|
||||||
Surface(color = container, contentColor = content, shape = MaterialTheme.shapes.extraLarge) {
|
Surface(color = container, contentColor = content, shape = MaterialTheme.shapes.extraLarge) {
|
||||||
Column(Modifier.fillMaxWidth().padding(24.dp)) {
|
Column(Modifier.fillMaxWidth().padding(24.dp)) {
|
||||||
Row(
|
Row(
|
||||||
@@ -286,9 +298,20 @@ private fun StatusCard(status: SessionStatus.Snapshot) {
|
|||||||
SessionStatus.Phase.FAILED -> status.headline
|
SessionStatus.Phase.FAILED -> status.headline
|
||||||
}
|
}
|
||||||
val detail = when (status.phase) {
|
val detail = when (status.phase) {
|
||||||
SessionStatus.Phase.IDLE -> stringResource(R.string.status_idle_detail)
|
SessionStatus.Phase.IDLE -> stringResource(
|
||||||
|
when {
|
||||||
|
!noCar -> R.string.status_idle_detail
|
||||||
|
grapheneOs -> R.string.status_no_car_graphene
|
||||||
|
else -> R.string.status_no_car
|
||||||
|
},
|
||||||
|
)
|
||||||
SessionStatus.Phase.PROJECTING -> stringResource(R.string.status_projecting_detail)
|
SessionStatus.Phase.PROJECTING -> stringResource(R.string.status_projecting_detail)
|
||||||
SessionStatus.Phase.DISCONNECTED -> stringResource(R.string.status_disconnected_detail)
|
SessionStatus.Phase.DISCONNECTED -> stringResource(R.string.status_disconnected_detail)
|
||||||
|
SessionStatus.Phase.CONNECTING -> when (status.stuckOn) {
|
||||||
|
SessionStatus.Link.CABLE -> stringResource(R.string.status_stuck_cable)
|
||||||
|
SessionStatus.Link.WIRELESS -> stringResource(R.string.status_stuck_wireless)
|
||||||
|
null -> status.detail
|
||||||
|
}
|
||||||
else -> status.detail
|
else -> status.detail
|
||||||
}
|
}
|
||||||
Text(headline, style = MaterialTheme.typography.headlineSmall, fontWeight = FontWeight.SemiBold)
|
Text(headline, style = MaterialTheme.typography.headlineSmall, fontWeight = FontWeight.SemiBold)
|
||||||
@@ -296,6 +319,13 @@ private fun StatusCard(status: SessionStatus.Snapshot) {
|
|||||||
Text(detail, style = MaterialTheme.typography.bodyMedium)
|
Text(detail, style = MaterialTheme.typography.bodyMedium)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// GrapheneOS keeps its USB-C port setting under Security & privacy.
|
||||||
|
if (noCar && grapheneOs) {
|
||||||
|
Spacer(Modifier.height(12.dp))
|
||||||
|
Button(onClick = {
|
||||||
|
runCatching { context.startActivity(Intent(Settings.ACTION_SECURITY_SETTINGS)) }
|
||||||
|
}) { Text(stringResource(R.string.status_open_settings)) }
|
||||||
|
}
|
||||||
// The other way in: wireless, right under the cable's instructions.
|
// The other way in: wireless, right under the cable's instructions.
|
||||||
if (!live) {
|
if (!live) {
|
||||||
Spacer(Modifier.height(20.dp))
|
Spacer(Modifier.height(20.dp))
|
||||||
@@ -305,6 +335,37 @@ private fun StatusCard(status: SessionStatus.Snapshot) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* True once the phone has charged over a cable for [NO_CAR_MS] while Gearslip sat idle. With
|
||||||
|
* USB data blocked, by a charge-only cable or GrapheneOS's port setting, Android never tells
|
||||||
|
* Gearslip a car is there, so charging with no car is the only sign there is. A wall charger
|
||||||
|
* looks the same, which is why the hint only asks the driver to check.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
private fun chargingWithNoCar(idle: Boolean): Boolean {
|
||||||
|
val context = LocalContext.current
|
||||||
|
var plugged by remember { mutableStateOf(false) }
|
||||||
|
DisposableEffect(context) {
|
||||||
|
val receiver = object : BroadcastReceiver() {
|
||||||
|
override fun onReceive(c: Context, intent: Intent) {
|
||||||
|
val how = intent.getIntExtra(BatteryManager.EXTRA_PLUGGED, 0)
|
||||||
|
plugged = how == BatteryManager.BATTERY_PLUGGED_USB || how == BatteryManager.BATTERY_PLUGGED_AC
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// The battery broadcast is sticky, so registering hands back the current state at once.
|
||||||
|
context.registerReceiver(receiver, IntentFilter(Intent.ACTION_BATTERY_CHANGED))?.let { receiver.onReceive(context, it) }
|
||||||
|
onDispose { context.unregisterReceiver(receiver) }
|
||||||
|
}
|
||||||
|
var waited by remember { mutableStateOf(false) }
|
||||||
|
LaunchedEffect(idle && plugged) {
|
||||||
|
waited = false
|
||||||
|
if (idle && plugged) { delay(NO_CAR_MS); waited = true }
|
||||||
|
}
|
||||||
|
return idle && plugged && waited
|
||||||
|
}
|
||||||
|
|
||||||
|
private const val NO_CAR_MS = 10_000L
|
||||||
|
|
||||||
/** The version shown on screen, straight from the manifest - one place to bump per release. */
|
/** The version shown on screen, straight from the manifest - one place to bump per release. */
|
||||||
private fun Context.appVersionName(): String =
|
private fun Context.appVersionName(): String =
|
||||||
runCatching { packageManager.getPackageInfo(packageName, 0).versionName }.getOrNull() ?: "?"
|
runCatching { packageManager.getPackageInfo(packageName, 0).versionName }.getOrNull() ?: "?"
|
||||||
|
|||||||
@@ -91,6 +91,7 @@ internal fun SettingsRow(
|
|||||||
icon: ImageVector? = null,
|
icon: ImageVector? = null,
|
||||||
tint: Color = MaterialTheme.colorScheme.primary,
|
tint: Color = MaterialTheme.colorScheme.primary,
|
||||||
titleColor: Color = MaterialTheme.colorScheme.onSurface,
|
titleColor: Color = MaterialTheme.colorScheme.onSurface,
|
||||||
|
leading: (@Composable () -> Unit)? = null,
|
||||||
trailing: (@Composable () -> Unit)? = null,
|
trailing: (@Composable () -> Unit)? = null,
|
||||||
) {
|
) {
|
||||||
Row(
|
Row(
|
||||||
@@ -99,6 +100,7 @@ internal fun SettingsRow(
|
|||||||
horizontalArrangement = Arrangement.spacedBy(16.dp),
|
horizontalArrangement = Arrangement.spacedBy(16.dp),
|
||||||
) {
|
) {
|
||||||
if (icon != null) Icon(icon, contentDescription = null, tint = tint, modifier = Modifier.size(24.dp))
|
if (icon != null) Icon(icon, contentDescription = null, tint = tint, modifier = Modifier.size(24.dp))
|
||||||
|
leading?.invoke()
|
||||||
Column(Modifier.weight(1f)) {
|
Column(Modifier.weight(1f)) {
|
||||||
Text(title, style = MaterialTheme.typography.bodyLarge, color = titleColor)
|
Text(title, style = MaterialTheme.typography.bodyLarge, color = titleColor)
|
||||||
subtitle?.let {
|
subtitle?.let {
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
package app.seb3thehacker.gearslip.ui
|
package app.seb3thehacker.gearslip.ui
|
||||||
|
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.res.vectorResource
|
||||||
import app.seb3thehacker.gearslip.R
|
import app.seb3thehacker.gearslip.R
|
||||||
|
import app.seb3thehacker.gearslip.car.LauncherMode
|
||||||
import androidx.compose.runtime.withFrameNanos
|
import androidx.compose.runtime.withFrameNanos
|
||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||||
@@ -18,6 +20,8 @@ import androidx.compose.foundation.rememberScrollState
|
|||||||
import androidx.compose.foundation.selection.selectable
|
import androidx.compose.foundation.selection.selectable
|
||||||
import androidx.compose.foundation.selection.toggleable
|
import androidx.compose.foundation.selection.toggleable
|
||||||
import androidx.compose.foundation.text.KeyboardOptions
|
import androidx.compose.foundation.text.KeyboardOptions
|
||||||
|
import androidx.compose.ui.text.input.KeyboardCapitalization
|
||||||
|
import app.seb3thehacker.gearslip.car.CarBluetooth
|
||||||
import androidx.compose.foundation.verticalScroll
|
import androidx.compose.foundation.verticalScroll
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||||
@@ -32,6 +36,7 @@ import androidx.compose.material.icons.filled.KeyboardArrowDown
|
|||||||
import androidx.compose.material.icons.filled.KeyboardArrowUp
|
import androidx.compose.material.icons.filled.KeyboardArrowUp
|
||||||
import androidx.compose.material.icons.filled.Lock
|
import androidx.compose.material.icons.filled.Lock
|
||||||
import androidx.compose.material.icons.filled.Refresh
|
import androidx.compose.material.icons.filled.Refresh
|
||||||
|
import androidx.compose.material.icons.filled.Home
|
||||||
import androidx.compose.material.icons.filled.Settings
|
import androidx.compose.material.icons.filled.Settings
|
||||||
import androidx.compose.material.icons.filled.Star
|
import androidx.compose.material.icons.filled.Star
|
||||||
import androidx.compose.material.icons.filled.Warning
|
import androidx.compose.material.icons.filled.Warning
|
||||||
@@ -136,6 +141,7 @@ fun SettingsScreen(
|
|||||||
icon = Icons.Filled.Star,
|
icon = Icons.Filled.Star,
|
||||||
trailing = { Arrow() },
|
trailing = { Arrow() },
|
||||||
)
|
)
|
||||||
|
LanguageRow()
|
||||||
}
|
}
|
||||||
SettingsSection(
|
SettingsSection(
|
||||||
stringResource(R.string.settings_support),
|
stringResource(R.string.settings_support),
|
||||||
@@ -167,6 +173,35 @@ private fun Arrow() = Icon(
|
|||||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Android 13 and later choose an app's language in their own settings, from the languages
|
||||||
|
* Gearslip declares; this opens that page, which few people find on their own (issue #51).
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
private fun LanguageRow() {
|
||||||
|
if (android.os.Build.VERSION.SDK_INT < android.os.Build.VERSION_CODES.TIRAMISU) return
|
||||||
|
val context = LocalContext.current
|
||||||
|
// A new choice arrives as a configuration change, since the manifest keeps the activity.
|
||||||
|
val chosen = remember(androidx.compose.ui.platform.LocalConfiguration.current) {
|
||||||
|
context.getSystemService(android.app.LocaleManager::class.java)?.applicationLocales?.get(0)
|
||||||
|
}
|
||||||
|
SettingsDivider()
|
||||||
|
SettingsRow(
|
||||||
|
stringResource(R.string.settings_language),
|
||||||
|
Modifier.clickable {
|
||||||
|
runCatching {
|
||||||
|
context.startActivity(
|
||||||
|
android.content.Intent(android.provider.Settings.ACTION_APP_LOCALE_SETTINGS, Uri.fromParts("package", context.packageName, null)),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
subtitle = chosen?.let { it.getDisplayName(it).replaceFirstChar { c -> c.titlecase(it) } }
|
||||||
|
?: stringResource(R.string.settings_language_phone),
|
||||||
|
icon = androidx.compose.ui.graphics.vector.ImageVector.vectorResource(R.drawable.ic_language),
|
||||||
|
trailing = { Arrow() },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
/** The setup guide replay, and where Gearslip lives outside the app. */
|
/** The setup guide replay, and where Gearslip lives outside the app. */
|
||||||
@Composable
|
@Composable
|
||||||
private fun AboutSection(onReplayTutorial: () -> Unit) {
|
private fun AboutSection(onReplayTutorial: () -> Unit) {
|
||||||
@@ -199,8 +234,68 @@ private fun ConnectionSection() {
|
|||||||
icon = Icons.Filled.Settings,
|
icon = Icons.Filled.Settings,
|
||||||
trailing = { Switch(checked = enabled, onCheckedChange = null) },
|
trailing = { Switch(checked = enabled, onCheckedChange = null) },
|
||||||
)
|
)
|
||||||
|
// For a head unit or a tablet on the dash: the car UI becomes the device's home screen.
|
||||||
|
var launcher by remember { mutableStateOf(LauncherMode.enabled(context)) }
|
||||||
|
SettingsRow(
|
||||||
|
stringResource(R.string.settings_use_as_launcher),
|
||||||
|
Modifier.toggleable(launcher, role = Role.Switch) {
|
||||||
|
launcher = it
|
||||||
|
LauncherMode.setEnabled(context, it)
|
||||||
|
// Android doesn't switch home apps by itself; this is where the driver picks Gearslip.
|
||||||
|
if (it) LauncherMode.openHomeChoice(context)
|
||||||
|
},
|
||||||
|
subtitle = stringResource(if (launcher) R.string.settings_use_as_launcher_on else R.string.settings_use_as_launcher_off),
|
||||||
|
icon = Icons.Filled.Home,
|
||||||
|
trailing = { Switch(checked = launcher, onCheckedChange = null) },
|
||||||
|
)
|
||||||
|
var address by remember { mutableStateOf(CarBluetooth.typedAddress(context)) }
|
||||||
|
var editing by remember { mutableStateOf(false) }
|
||||||
|
SettingsRow(
|
||||||
|
stringResource(R.string.settings_bt_address),
|
||||||
|
Modifier.clickable { editing = true },
|
||||||
|
subtitle = address ?: stringResource(R.string.settings_bt_address_off),
|
||||||
|
icon = Icons.Filled.Info,
|
||||||
|
)
|
||||||
|
if (editing) {
|
||||||
|
AddressDialog(
|
||||||
|
initial = address.orEmpty(),
|
||||||
|
onDismiss = { editing = false },
|
||||||
|
onSave = { typed ->
|
||||||
|
CarBluetooth.setTypedAddress(context, typed).also { saved ->
|
||||||
|
if (saved) {
|
||||||
|
address = CarBluetooth.typedAddress(context)
|
||||||
|
editing = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The phone's Bluetooth address, typed in by hand: Android doesn't let apps read it. */
|
||||||
|
@Composable
|
||||||
|
private fun AddressDialog(initial: String, onDismiss: () -> Unit, onSave: (String) -> Boolean) {
|
||||||
|
var text by remember { mutableStateOf(initial) }
|
||||||
|
var invalid by remember { mutableStateOf(false) }
|
||||||
|
AlertDialog(
|
||||||
|
onDismissRequest = onDismiss,
|
||||||
|
title = { Text(stringResource(R.string.settings_bt_address)) },
|
||||||
|
text = {
|
||||||
|
OutlinedTextField(
|
||||||
|
value = text,
|
||||||
|
onValueChange = { text = it; invalid = false },
|
||||||
|
singleLine = true,
|
||||||
|
placeholder = { Text("12:34:56:78:9A:BC") },
|
||||||
|
keyboardOptions = KeyboardOptions(capitalization = KeyboardCapitalization.Characters, autoCorrectEnabled = false),
|
||||||
|
isError = invalid,
|
||||||
|
supportingText = { Text(stringResource(if (invalid) R.string.settings_bt_address_invalid else R.string.settings_bt_address_hint)) },
|
||||||
|
)
|
||||||
|
},
|
||||||
|
confirmButton = { TextButton(onClick = { invalid = !onSave(text) }) { Text(stringResource(R.string.save)) } },
|
||||||
|
dismissButton = { TextButton(onClick = onDismiss) { Text(stringResource(R.string.cancel)) } },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
/** Tools for testing a debug build; release builds never show this section. */
|
/** Tools for testing a debug build; release builds never show this section. */
|
||||||
@Composable
|
@Composable
|
||||||
|
|||||||
@@ -116,6 +116,21 @@ internal fun WirelessButton() {
|
|||||||
}
|
}
|
||||||
.padding(vertical = 14.dp),
|
.padding(vertical = 14.dp),
|
||||||
)
|
)
|
||||||
|
// A car the driver once said no to never asks again, so offer the way back here.
|
||||||
|
if (app.seb3thehacker.gearslip.wireless.WirelessAuto.declinedEarlier(context, device)) {
|
||||||
|
Text(
|
||||||
|
stringResource(R.string.wireless_auto_off),
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
TextButton(
|
||||||
|
onClick = {
|
||||||
|
devices = null
|
||||||
|
app.seb3thehacker.gearslip.wireless.WirelessAuto.askAgain(context, device)
|
||||||
|
},
|
||||||
|
modifier = Modifier.padding(bottom = 4.dp),
|
||||||
|
) { Text(stringResource(R.string.wireless_auto_turn_on)) }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
// The car has to be paired before it shows up here, and pairing happens in Android's settings.
|
// The car has to be paired before it shows up here, and pairing happens in Android's settings.
|
||||||
if (list.isNotEmpty()) {
|
if (list.isNotEmpty()) {
|
||||||
|
|||||||
@@ -72,6 +72,20 @@ object WirelessAuto {
|
|||||||
}, Handler(Looper.getMainLooper()))
|
}, Handler(Looper.getMainLooper()))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** True when the driver said no to this car connecting by itself, and it still can't. */
|
||||||
|
fun declinedEarlier(context: Context, device: BluetoothDevice): Boolean {
|
||||||
|
val manager = context.getSystemService(CompanionDeviceManager::class.java) ?: return false
|
||||||
|
return !isCompanion(manager, device.address) &&
|
||||||
|
context.getSharedPreferences(PREFS, Context.MODE_PRIVATE).getBoolean(device.address, false)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Forgets the earlier no and shows Android's dialog for [device] again. */
|
||||||
|
fun askAgain(context: Context, device: BluetoothDevice) {
|
||||||
|
log.i("auto-connect: asking again about ${WirelessLink.name(device)}")
|
||||||
|
context.getSharedPreferences(PREFS, Context.MODE_PRIVATE).edit().remove(device.address).apply()
|
||||||
|
connectedByHand(context, device)
|
||||||
|
}
|
||||||
|
|
||||||
/** The driver allowed it in Android's dialog. */
|
/** The driver allowed it in Android's dialog. */
|
||||||
fun approved(context: Context, address: String) {
|
fun approved(context: Context, address: String) {
|
||||||
log.i("auto-connect: allowed")
|
log.i("auto-connect: allowed")
|
||||||
|
|||||||
@@ -0,0 +1,57 @@
|
|||||||
|
package app.seb3thehacker.gearslip.wireless
|
||||||
|
|
||||||
|
import android.app.Notification
|
||||||
|
import android.app.NotificationChannel
|
||||||
|
import android.app.NotificationManager
|
||||||
|
import android.app.Service
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
import android.content.pm.ServiceInfo
|
||||||
|
import android.os.IBinder
|
||||||
|
import app.seb3thehacker.gearslip.GearslipLog
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Keeps Gearslip in the foreground while a wireless car is connected, from the moment its
|
||||||
|
* Bluetooth channel opens until the drive ends. Android treats an app in the background as
|
||||||
|
* unwanted and may refuse its Wi-Fi request or kill it, which is what happens to a start-up with
|
||||||
|
* the phone locked in a pocket. It does no work of its own; the notification is the cost.
|
||||||
|
*/
|
||||||
|
class WirelessKeepAlive : Service() {
|
||||||
|
|
||||||
|
override fun onBind(intent: Intent?): IBinder? = null
|
||||||
|
|
||||||
|
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
||||||
|
val manager = getSystemService(NotificationManager::class.java)
|
||||||
|
manager.createNotificationChannel(
|
||||||
|
NotificationChannel(CHANNEL, "Wireless car connection", NotificationManager.IMPORTANCE_LOW),
|
||||||
|
)
|
||||||
|
val notification = Notification.Builder(this, CHANNEL)
|
||||||
|
.setSmallIcon(android.R.drawable.stat_sys_data_bluetooth)
|
||||||
|
.setContentTitle("Gearslip is connected to the car wirelessly")
|
||||||
|
.setOngoing(true)
|
||||||
|
.build()
|
||||||
|
// Refused if Android thinks Gearslip may not start one now; the start-up carries on
|
||||||
|
// without it, as it did before this service existed.
|
||||||
|
runCatching {
|
||||||
|
startForeground(NOTIFICATION_ID, notification, ServiceInfo.FOREGROUND_SERVICE_TYPE_CONNECTED_DEVICE)
|
||||||
|
}.onFailure {
|
||||||
|
GearslipLog.w("wireless keep-alive: could not start the foreground service - ${it.message}")
|
||||||
|
stopSelf()
|
||||||
|
}
|
||||||
|
return START_NOT_STICKY
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private const val CHANNEL = "gearslip_wireless"
|
||||||
|
private const val NOTIFICATION_ID = 45
|
||||||
|
|
||||||
|
fun start(context: Context) {
|
||||||
|
runCatching { context.startForegroundService(Intent(context, WirelessKeepAlive::class.java)) }
|
||||||
|
.onFailure { GearslipLog.w("wireless keep-alive: start refused - ${it.message}") }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun stop(context: Context) {
|
||||||
|
runCatching { context.stopService(Intent(context, WirelessKeepAlive::class.java)) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -73,6 +73,9 @@ object WirelessLink {
|
|||||||
private const val LISTEN_MS = 120_000 // time to reconnect the phone from the car's Bluetooth menu
|
private const val LISTEN_MS = 120_000 // time to reconnect the phone from the car's Bluetooth menu
|
||||||
private const val TCP_TRIES = 12 // about a minute: a car's projection listener can start well after its Wi-Fi
|
private const val TCP_TRIES = 12 // about a minute: a car's projection listener can start well after its Wi-Fi
|
||||||
private const val WIFI_WAIT_MS = 60_000
|
private const val WIFI_WAIT_MS = 60_000
|
||||||
|
private const val KNOCK_MS = 8_000L // some cars publish their service only a while after Bluetooth connects
|
||||||
|
private const val START_RETRIES = 3
|
||||||
|
private const val RETRY_PAUSE_MS = 2_000L
|
||||||
|
|
||||||
/** Set by the activity: starts a session on the car's socket. */
|
/** Set by the activity: starts a session on the car's socket. */
|
||||||
@Volatile var onConnected: ((Socket) -> Unit)? = null
|
@Volatile var onConnected: ((Socket) -> Unit)? = null
|
||||||
@@ -91,17 +94,21 @@ object WirelessLink {
|
|||||||
@Volatile private var activeSsid: String? = null
|
@Volatile private var activeSsid: String? = null
|
||||||
/** Bumped on each Wi-Fi join and on stop, so an older run of TCP tries ends instead of racing the new one. */
|
/** Bumped on each Wi-Fi join and on stop, so an older run of TCP tries ends instead of racing the new one. */
|
||||||
@Volatile private var tcpRun = 0
|
@Volatile private var tcpRun = 0
|
||||||
|
/** The socket reached the car's projection: whatever ends the start-up now, it isn't retried. */
|
||||||
|
@Volatile private var handedOver = false
|
||||||
|
/** A failure starting over can't fix, such as a VPN in the way. */
|
||||||
|
@Volatile private var noRetry = false
|
||||||
private val writeLock = Any()
|
private val writeLock = Any()
|
||||||
|
|
||||||
/** Paired Bluetooth devices, cars or not: the driver picks theirs. Needs BLUETOOTH_CONNECT. */
|
/** Paired Bluetooth devices, cars or not: the driver picks theirs. Needs BLUETOOTH_CONNECT. */
|
||||||
@SuppressLint("MissingPermission")
|
@SuppressLint("MissingPermission")
|
||||||
fun pairedDevices(context: Context): List<BluetoothDevice> =
|
fun pairedDevices(context: Context): List<BluetoothDevice> =
|
||||||
context.getSystemService(BluetoothManager::class.java)?.adapter?.bondedDevices
|
context.getSystemService(BluetoothManager::class.java)?.adapter?.bondedDevices
|
||||||
?.sortedBy { it.name ?: it.address }
|
?.sortedBy { name(it) }
|
||||||
.orEmpty()
|
.orEmpty()
|
||||||
|
|
||||||
@SuppressLint("MissingPermission")
|
@SuppressLint("MissingPermission")
|
||||||
fun name(device: BluetoothDevice): String = device.name ?: device.address
|
fun name(device: BluetoothDevice): String = device.alias ?: device.name ?: device.address
|
||||||
|
|
||||||
fun start(context: Context, device: BluetoothDevice) {
|
fun start(context: Context, device: BluetoothDevice) {
|
||||||
stop(context)
|
stop(context)
|
||||||
@@ -109,7 +116,7 @@ object WirelessLink {
|
|||||||
worker = Thread({
|
worker = Thread({
|
||||||
// A test feature: anything unexpected ends the attempt, not the app.
|
// A test feature: anything unexpected ends the attempt, not the app.
|
||||||
try {
|
try {
|
||||||
run(app, device)
|
startUp(app, device)
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
log.e("wireless start-up failed", e)
|
log.e("wireless start-up failed", e)
|
||||||
SessionStatus.failed("Wireless start-up failed", "${e.javaClass.simpleName}: ${e.message}")
|
SessionStatus.failed("Wireless start-up failed", "${e.javaClass.simpleName}: ${e.message}")
|
||||||
@@ -117,12 +124,66 @@ object WirelessLink {
|
|||||||
}, "gearslip-wireless").apply { isDaemon = true; start() }
|
}, "gearslip-wireless").apply { isDaemon = true; start() }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A step that fails after the car's Bluetooth channel opened (the channel closing, the Wi-Fi
|
||||||
|
* join, TCP) often works the second time, so the start-up begins again, up to
|
||||||
|
* [START_RETRIES] times. Waiting for a car that never called isn't repeated: that wait is
|
||||||
|
* two minutes already.
|
||||||
|
*/
|
||||||
|
private fun startUp(context: Context, device: BluetoothDevice) {
|
||||||
|
val me = Thread.currentThread()
|
||||||
|
try {
|
||||||
|
tryAndRetry(context, device, me)
|
||||||
|
} finally {
|
||||||
|
// A newer start-up has the service now; stop() already ended this one's.
|
||||||
|
if (worker === me) WirelessKeepAlive.stop(context)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun tryAndRetry(context: Context, device: BluetoothDevice, me: Thread) {
|
||||||
|
var tries = 0
|
||||||
|
while (true) {
|
||||||
|
handedOver = false
|
||||||
|
noRetry = false
|
||||||
|
val reached = run(context, device)
|
||||||
|
if (worker !== me || handedOver || noRetry || !reached) return
|
||||||
|
if (++tries > START_RETRIES) {
|
||||||
|
log.w("start-up failed ${tries} times; giving up")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
log.i("start-up failed before the car's screen came up; starting over ($tries of $START_RETRIES)")
|
||||||
|
release(context)
|
||||||
|
SessionStatus.connecting("Starting over with ${name(device)} ($tries of $START_RETRIES)")
|
||||||
|
try {
|
||||||
|
Thread.sleep(RETRY_PAUSE_MS)
|
||||||
|
} catch (_: InterruptedException) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (worker !== me) return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Ends this try: the Bluetooth read loop stops, and [startUp] decides whether to go again. */
|
||||||
|
private fun endTry() {
|
||||||
|
runCatching { rfcomm?.close() }
|
||||||
|
}
|
||||||
|
|
||||||
/** Closes the Bluetooth channel, the socket and the Wi-Fi request. Safe to call any time. */
|
/** Closes the Bluetooth channel, the socket and the Wi-Fi request. Safe to call any time. */
|
||||||
fun stop(context: Context) {
|
fun stop(context: Context) {
|
||||||
tcpRun++
|
|
||||||
val starting = worker != null && SessionStatus.state.value.phase == SessionStatus.Phase.CONNECTING
|
val starting = worker != null && SessionStatus.state.value.phase == SessionStatus.Phase.CONNECTING
|
||||||
worker?.interrupt()
|
worker?.interrupt()
|
||||||
worker = null
|
worker = null
|
||||||
|
release(context)
|
||||||
|
WirelessKeepAlive.stop(context)
|
||||||
|
// Stopped before the car's session began, so nothing else resets Home's card.
|
||||||
|
if (starting) {
|
||||||
|
log.i("wireless start-up stopped")
|
||||||
|
SessionStatus.disconnected()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun release(context: Context) {
|
||||||
|
tcpRun++
|
||||||
runCatching { listener?.close() }
|
runCatching { listener?.close() }
|
||||||
listener = null
|
listener = null
|
||||||
runCatching { rfcomm?.close() }
|
runCatching { rfcomm?.close() }
|
||||||
@@ -134,11 +195,6 @@ object WirelessLink {
|
|||||||
}
|
}
|
||||||
network = null
|
network = null
|
||||||
activeSsid = null
|
activeSsid = null
|
||||||
// Stopped before the car's session began, so nothing else resets Home's card.
|
|
||||||
if (starting) {
|
|
||||||
log.i("wireless start-up stopped")
|
|
||||||
SessionStatus.disconnected()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** A stable id for this phone as this app sees it, standing in for the serial a real phone sends. */
|
/** A stable id for this phone as this app sees it, standing in for the serial a real phone sends. */
|
||||||
@@ -146,8 +202,9 @@ object WirelessLink {
|
|||||||
private fun deviceId(context: Context): String =
|
private fun deviceId(context: Context): String =
|
||||||
Settings.Secure.getString(context.contentResolver, Settings.Secure.ANDROID_ID) ?: "gearslip"
|
Settings.Secure.getString(context.contentResolver, Settings.Secure.ANDROID_ID) ?: "gearslip"
|
||||||
|
|
||||||
|
/** One try. True if the car's Bluetooth channel opened, so that a failure after it is worth retrying. */
|
||||||
@SuppressLint("MissingPermission")
|
@SuppressLint("MissingPermission")
|
||||||
private fun run(context: Context, device: BluetoothDevice) {
|
private fun run(context: Context, device: BluetoothDevice): Boolean {
|
||||||
val name = name(device)
|
val name = name(device)
|
||||||
log.i("--- wireless start-up with $name (${device.address}) ---")
|
log.i("--- wireless start-up with $name (${device.address}) ---")
|
||||||
SessionStatus.connecting("Bluetooth: reaching $name")
|
SessionStatus.connecting("Bluetooth: reaching $name")
|
||||||
@@ -163,15 +220,17 @@ object WirelessLink {
|
|||||||
"Gearslip needs the Nearby devices permission to reach $name. Allow it in the app's " +
|
"Gearslip needs the Nearby devices permission to reach $name. Allow it in the app's " +
|
||||||
"settings, then try again.",
|
"settings, then try again.",
|
||||||
)
|
)
|
||||||
return
|
return false
|
||||||
} catch (e: IOException) {
|
} catch (e: IOException) {
|
||||||
// Most factory head units publish no service of their own: they look for the phone's
|
// Most factory head units publish no service of their own: they look for the phone's
|
||||||
// and connect to it, so wait for the car instead.
|
// and connect to it, so wait for the car instead.
|
||||||
log.i("Bluetooth: $name has no Android Auto service to call (${e.message}); waiting for it to call us")
|
log.i("Bluetooth: $name has no Android Auto service to call (${e.message}); waiting for it to call us")
|
||||||
waitForCar(context, name) ?: return
|
waitForCar(context, device, name) ?: return false
|
||||||
}
|
}
|
||||||
rfcomm = socket
|
rfcomm = socket
|
||||||
log.i("Bluetooth: channel open")
|
log.i("Bluetooth: channel open")
|
||||||
|
// Before the Wi-Fi request: Android may refuse one from an app it sees as in the background.
|
||||||
|
WirelessKeepAlive.start(context)
|
||||||
SessionStatus.connecting("Bluetooth: talking to $name")
|
SessionStatus.connecting("Bluetooth: talking to $name")
|
||||||
|
|
||||||
val input = DataInputStream(socket.inputStream)
|
val input = DataInputStream(socket.inputStream)
|
||||||
@@ -190,14 +249,18 @@ object WirelessLink {
|
|||||||
log.hex(" version", body)
|
log.hex(" version", body)
|
||||||
// Laid out the way a real phone's reply is (aa-proxy-rs and LIVI both
|
// Laid out the way a real phone's reply is (aa-proxy-rs and LIVI both
|
||||||
// parse it from real phones): 1 and 2 the version, echoing the car's;
|
// parse it from real phones): 1 and 2 the version, echoing the car's;
|
||||||
// 3 a serial; 4 the status; 6 the device's ids.
|
// 3 a serial; 4 the status; 5 the Wi-Fi channel type the car asked for;
|
||||||
|
// 6 the device's ids. Strict head units, such as Toyota's, hold back the
|
||||||
|
// StartRequest until the reply carries more than the status.
|
||||||
val f = Wire.fields(body)
|
val f = Wire.fields(body)
|
||||||
val serial = deviceId(context)
|
val serial = deviceId(context)
|
||||||
val ids = Protobuf.stringField(1, serial) + Protobuf.stringField(2, UUID.randomUUID().toString())
|
val ids = Protobuf.stringField(1, serial) + Protobuf.stringField(2, UUID.randomUUID().toString())
|
||||||
|
val channelType = Wire.varint(f, 3)
|
||||||
val reply = Protobuf.varintField(1, Wire.varint(f, 1) ?: 1) +
|
val reply = Protobuf.varintField(1, Wire.varint(f, 1) ?: 1) +
|
||||||
Protobuf.varintField(2, Wire.varint(f, 2) ?: 0) +
|
Protobuf.varintField(2, Wire.varint(f, 2) ?: 0) +
|
||||||
Protobuf.stringField(3, serial) +
|
Protobuf.stringField(3, serial) +
|
||||||
Protobuf.varintField(4, STATUS_SUCCESS) +
|
Protobuf.varintField(4, STATUS_SUCCESS) +
|
||||||
|
(channelType?.let { Protobuf.varintField(5, it) } ?: ByteArray(0)) +
|
||||||
Protobuf.varint(((6 shl 3) or 2).toLong()) + Protobuf.varint(ids.size.toLong()) + ids
|
Protobuf.varint(((6 shl 3) or 2).toLong()) + Protobuf.varint(ids.size.toLong()) + ids
|
||||||
send(output, MSG_VERSION_RESPONSE, reply)
|
send(output, MSG_VERSION_RESPONSE, reply)
|
||||||
log.i("-> VersionResponse")
|
log.i("-> VersionResponse")
|
||||||
@@ -257,6 +320,7 @@ object WirelessLink {
|
|||||||
} catch (e: IOException) {
|
} catch (e: IOException) {
|
||||||
if (rfcomm != null) log.w("Bluetooth: channel closed (${e.message})")
|
if (rfcomm != null) log.w("Bluetooth: channel closed (${e.message})")
|
||||||
}
|
}
|
||||||
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -265,7 +329,7 @@ object WirelessLink {
|
|||||||
* Bluetooth, so the driver may have to reconnect the phone from the car's menu.
|
* Bluetooth, so the driver may have to reconnect the phone from the car's menu.
|
||||||
*/
|
*/
|
||||||
@SuppressLint("MissingPermission")
|
@SuppressLint("MissingPermission")
|
||||||
private fun waitForCar(context: Context, name: String): BluetoothSocket? {
|
private fun waitForCar(context: Context, device: BluetoothDevice, name: String): BluetoothSocket? {
|
||||||
val adapter = context.getSystemService(BluetoothManager::class.java)?.adapter ?: return null
|
val adapter = context.getSystemService(BluetoothManager::class.java)?.adapter ?: return null
|
||||||
val server = try {
|
val server = try {
|
||||||
adapter.listenUsingRfcommWithServiceRecord("Android Auto", AA_UUID)
|
adapter.listenUsingRfcommWithServiceRecord("Android Auto", AA_UUID)
|
||||||
@@ -277,12 +341,40 @@ object WirelessLink {
|
|||||||
listener = server
|
listener = server
|
||||||
SessionStatus.connecting("Waiting for $name. If nothing happens, disconnect the phone in the car's Bluetooth menu and connect it again")
|
SessionStatus.connecting("Waiting for $name. If nothing happens, disconnect the phone in the car's Bluetooth menu and connect it again")
|
||||||
log.i("Bluetooth: Android Auto service published, waiting ${LISTEN_MS / 1000}s for $name")
|
log.i("Bluetooth: Android Auto service published, waiting ${LISTEN_MS / 1000}s for $name")
|
||||||
|
// Meanwhile, keep calling the car's own service: a car that publishes it late answers one
|
||||||
|
// of these instead of calling us. Whichever connects first is the channel.
|
||||||
|
val done = java.util.concurrent.atomic.AtomicBoolean(false)
|
||||||
|
val knocked = java.util.concurrent.atomic.AtomicReference<BluetoothSocket?>(null)
|
||||||
|
val knocker = Thread({
|
||||||
|
while (!done.get()) {
|
||||||
|
try {
|
||||||
|
Thread.sleep(KNOCK_MS)
|
||||||
|
} catch (_: InterruptedException) {
|
||||||
|
return@Thread
|
||||||
|
}
|
||||||
|
if (done.get()) return@Thread
|
||||||
|
val socket = runCatching { device.createRfcommSocketToServiceRecord(AA_UUID).also { it.connect() } }
|
||||||
|
.getOrNull() ?: continue
|
||||||
|
if (done.compareAndSet(false, true)) {
|
||||||
|
log.i("Bluetooth: $name's own Android Auto service answered")
|
||||||
|
knocked.set(socket)
|
||||||
|
runCatching { server.close() }
|
||||||
|
} else {
|
||||||
|
runCatching { socket.close() }
|
||||||
|
}
|
||||||
|
return@Thread
|
||||||
|
}
|
||||||
|
}, "gearslip-wireless-knock").apply { isDaemon = true; start() }
|
||||||
return try {
|
return try {
|
||||||
server.accept(LISTEN_MS).also {
|
server.accept(LISTEN_MS).also {
|
||||||
|
if (!done.compareAndSet(false, true)) runCatching { knocked.get()?.close() }
|
||||||
val from = runCatching { it.remoteDevice.name }.getOrNull() ?: "a device"
|
val from = runCatching { it.remoteDevice.name }.getOrNull() ?: "a device"
|
||||||
log.i("Bluetooth: $from connected to the Android Auto service")
|
log.i("Bluetooth: $from connected to the Android Auto service")
|
||||||
}
|
}
|
||||||
} catch (e: IOException) {
|
} catch (e: IOException) {
|
||||||
|
knocker.join(500)
|
||||||
|
knocked.get()?.let { return it }
|
||||||
|
done.set(true)
|
||||||
if (listener == null) return null // stopped
|
if (listener == null) return null // stopped
|
||||||
log.w("Bluetooth: no car connected within ${LISTEN_MS / 1000}s (${e.message})")
|
log.w("Bluetooth: no car connected within ${LISTEN_MS / 1000}s (${e.message})")
|
||||||
SessionStatus.failed(
|
SessionStatus.failed(
|
||||||
@@ -292,6 +384,8 @@ object WirelessLink {
|
|||||||
)
|
)
|
||||||
null
|
null
|
||||||
} finally {
|
} finally {
|
||||||
|
done.set(true)
|
||||||
|
knocker.interrupt()
|
||||||
runCatching { server.close() }
|
runCatching { server.close() }
|
||||||
listener = null
|
listener = null
|
||||||
}
|
}
|
||||||
@@ -351,6 +445,7 @@ object WirelessLink {
|
|||||||
"Android didn't connect to $name's network. If a \"Connect to device\" prompt " +
|
"Android didn't connect to $name's network. If a \"Connect to device\" prompt " +
|
||||||
"appeared, it has to be accepted.",
|
"appeared, it has to be accepted.",
|
||||||
)
|
)
|
||||||
|
endTry()
|
||||||
}
|
}
|
||||||
|
|
||||||
// Android can hold an app's traffic back on a network it has joined (battery or data
|
// Android can hold an app's traffic back on a network it has joined (battery or data
|
||||||
@@ -467,6 +562,7 @@ object WirelessLink {
|
|||||||
socket.tcpNoDelay = true
|
socket.tcpNoDelay = true
|
||||||
socket.connect(InetSocketAddress(ip, port), 5_000)
|
socket.connect(InetSocketAddress(ip, port), 5_000)
|
||||||
tcp = socket
|
tcp = socket
|
||||||
|
handedOver = true
|
||||||
log.i("TCP: connected to $ip:$port (try ${attempt + 1})")
|
log.i("TCP: connected to $ip:$port (try ${attempt + 1})")
|
||||||
val start = onConnected
|
val start = onConnected
|
||||||
if (start == null) {
|
if (start == null) {
|
||||||
@@ -494,11 +590,14 @@ object WirelessLink {
|
|||||||
"Gearslip joined $name's network, but a VPN on this phone won't let it reach the car. " +
|
"Gearslip joined $name's network, but a VPN on this phone won't let it reach the car. " +
|
||||||
"Turn the VPN off, or exclude Gearslip in its settings, then try again.",
|
"Turn the VPN off, or exclude Gearslip in its settings, then try again.",
|
||||||
)
|
)
|
||||||
|
noRetry = true
|
||||||
|
endTry()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
SessionStatus.failed(
|
SessionStatus.failed(
|
||||||
"Car's Wi-Fi joined, but no connection",
|
"Car's Wi-Fi joined, but no connection",
|
||||||
"Gearslip joined $name's network but couldn't reach Android Auto at $ip:$port.",
|
"Gearslip joined $name's network but couldn't reach Android Auto at $ip:$port.",
|
||||||
)
|
)
|
||||||
|
endTry()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
<!-- Material Icons "language" (Apache 2.0). -->
|
||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="24dp"
|
||||||
|
android:height="24dp"
|
||||||
|
android:viewportWidth="24"
|
||||||
|
android:viewportHeight="24">
|
||||||
|
<path
|
||||||
|
android:fillColor="#FF000000"
|
||||||
|
android:pathData="M11.99,2C6.47,2 2,6.48 2,12s4.47,10 9.99,10C17.52,22 22,17.52 22,12S17.52,2 11.99,2zM18.92,8h-2.95c-0.32,-1.25 -0.78,-2.45 -1.38,-3.56 1.84,0.63 3.37,1.91 4.33,3.56zM12,4.04c0.83,1.2 1.48,2.53 1.91,3.96h-3.82c0.43,-1.43 1.08,-2.76 1.91,-3.96zM4.26,14C4.1,13.36 4,12.69 4,12s0.1,-1.36 0.26,-2h3.38c-0.08,0.66 -0.14,1.32 -0.14,2 0,0.68 0.06,1.34 0.14,2L4.26,14zM5.08,16h2.95c0.32,1.25 0.78,2.45 1.38,3.56 -1.84,-0.63 -3.37,-1.9 -4.33,-3.56zM8.03,8L5.08,8c0.96,-1.66 2.49,-2.93 4.33,-3.56C8.81,5.55 8.35,6.75 8.03,8zM12,19.96c-0.83,-1.2 -1.48,-2.53 -1.91,-3.96h3.82c-0.43,1.43 -1.08,2.76 -1.91,3.96zM14.34,14L9.66,14c-0.09,-0.66 -0.16,-1.32 -0.16,-2 0,-0.68 0.07,-1.35 0.16,-2h4.68c0.09,0.65 0.16,1.32 0.16,2 0,0.68 -0.07,1.34 -0.16,2zM14.59,19.56c0.6,-1.11 1.06,-2.31 1.38,-3.56h2.95c-0.96,1.65 -2.49,2.93 -4.33,3.56zM16.36,14c0.08,-0.66 0.14,-1.32 0.14,-2 0,-0.68 -0.06,-1.34 -0.14,-2h3.38c0.16,0.64 0.26,1.31 0.26,2s-0.1,1.36 -0.26,2h-3.38z" />
|
||||||
|
</vector>
|
||||||
@@ -376,6 +376,8 @@
|
|||||||
<string name="wireless_which_car">أي سيارة؟</string>
|
<string name="wireless_which_car">أي سيارة؟</string>
|
||||||
<string name="wireless_none_paired">هاتفك غير مقترن بأي جهاز بعد. أقرنه بالسيارة أولًا.</string>
|
<string name="wireless_none_paired">هاتفك غير مقترن بأي جهاز بعد. أقرنه بالسيارة أولًا.</string>
|
||||||
<string name="wireless_not_listed">السيارة غير موجودة في القائمة؟ أقرنها بهاتفك أولًا.</string>
|
<string name="wireless_not_listed">السيارة غير موجودة في القائمة؟ أقرنها بهاتفك أولًا.</string>
|
||||||
|
<string name="wireless_auto_off">لا يتصل تلقائيًا</string>
|
||||||
|
<string name="wireless_auto_turn_on">اسمح له بالاتصال تلقائيًا</string>
|
||||||
<string name="bluetooth_settings">إعدادات البلوتوث</string>
|
<string name="bluetooth_settings">إعدادات البلوتوث</string>
|
||||||
|
|
||||||
<!-- Voice models -->
|
<!-- Voice models -->
|
||||||
@@ -861,4 +863,7 @@
|
|||||||
<string name="places_finding">جارٍ البحث…</string>
|
<string name="places_finding">جارٍ البحث…</string>
|
||||||
<string name="places_not_found">لم يعثر Gearslip على موقعك. تأكد من تشغيل الموقع.</string>
|
<string name="places_not_found">لم يعثر Gearslip على موقعك. تأكد من تشغيل الموقع.</string>
|
||||||
<string name="places_clear">مسح</string>
|
<string name="places_clear">مسح</string>
|
||||||
|
<string name="settings_language">اللغة</string>
|
||||||
|
<string name="settings_language_phone">مثل لغة الهاتف</string>
|
||||||
|
<string name="player_next_chapter">الفصل التالي</string>
|
||||||
</resources>
|
</resources>
|
||||||
@@ -374,6 +374,8 @@
|
|||||||
<string name="wireless_which_car">Které auto?</string>
|
<string name="wireless_which_car">Které auto?</string>
|
||||||
<string name="wireless_none_paired">Telefon zatím není s ničím spárovaný. Nejdřív ho spárujte s autem.</string>
|
<string name="wireless_none_paired">Telefon zatím není s ničím spárovaný. Nejdřív ho spárujte s autem.</string>
|
||||||
<string name="wireless_not_listed">Auto v seznamu není? Nejdřív ho spárujte s telefonem.</string>
|
<string name="wireless_not_listed">Auto v seznamu není? Nejdřív ho spárujte s telefonem.</string>
|
||||||
|
<string name="wireless_auto_off">Nepřipojuje se samo</string>
|
||||||
|
<string name="wireless_auto_turn_on">Povolit automatické připojení</string>
|
||||||
<string name="bluetooth_settings">Nastavení Bluetooth</string>
|
<string name="bluetooth_settings">Nastavení Bluetooth</string>
|
||||||
|
|
||||||
<!-- Voice models -->
|
<!-- Voice models -->
|
||||||
@@ -845,4 +847,7 @@
|
|||||||
<string name="places_finding">Hledám…</string>
|
<string name="places_finding">Hledám…</string>
|
||||||
<string name="places_not_found">Gearslip nenašel vaši polohu. Zkontrolujte, že je poloha zapnutá.</string>
|
<string name="places_not_found">Gearslip nenašel vaši polohu. Zkontrolujte, že je poloha zapnutá.</string>
|
||||||
<string name="places_clear">Vymazat</string>
|
<string name="places_clear">Vymazat</string>
|
||||||
|
<string name="settings_language">Jazyk</string>
|
||||||
|
<string name="settings_language_phone">Stejný jako v telefonu</string>
|
||||||
|
<string name="player_next_chapter">Další kapitola</string>
|
||||||
</resources>
|
</resources>
|
||||||
@@ -0,0 +1,260 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<resources>
|
||||||
|
<string name="touch_relay_description">Lad Gearslip afspille ved berøringer fra bilens skærm under telefon spejling. Gearslip læser ikke skærmens indhold.</string>
|
||||||
|
<string name="guide_usage_title">Hjælp andre bilister.</string>
|
||||||
|
<string name="guide_safety_title">Kør forsigtigt.</string>
|
||||||
|
<string name="guide_safety_body">Gearslip er uofficielt. Det er hverken udviklet eller testet af nogen bilproducent, og det kan svigte uden forvarsel. Indstil det, før du kører, og hold derefter øjnene på vejen.</string>
|
||||||
|
<string name="guide_mic_title">Mikrofon.</string>
|
||||||
|
<string name="guide_mic_body">Gearslip bruger den til at sende din musik til bilen og til at lytte når du spørger om noget eller svarer på en besked. Det du siger forbliver på din telefon.</string>
|
||||||
|
<string name="guide_location_title">Lokation.</string>
|
||||||
|
<string name="guide_location_body">Bilens kort og søgninger på ting i nærheden har brug for dette. Uden lokation vil der ikke være et kort.</string>
|
||||||
|
<string name="guide_calendar_title">Kalender.</string>
|
||||||
|
<string name="guide_calendar_body">Bilens skærm viser din næste aftale. Gearslip læser kun i din kalender.</string>
|
||||||
|
<string name="guide_calls_title">Opkald og kontakter.</string>
|
||||||
|
<string name="guide_calls_body">Bilen viser navnet på den der ringer, telefon appen kan lave opkald til et nummer eller sine kontakter. Gearslip læser dem kun.</string>
|
||||||
|
<string name="guide_bluetooth_title">Bluetooth.</string>
|
||||||
|
<string name="guide_bluetooth_body">Første gang du tilslutter Gearslip spørger den efter at parre din telefon med bilen, så du kan foretage opkald igennem bilen højtalere.</string>
|
||||||
|
<string name="guide_notifications_title">Notifikationer.</string>
|
||||||
|
<string name="guide_notifications_body">Imens Gearslip er forbundet vises notifikationer med knap til at slukke dem. Uden denne vil Android skulle dem.</string>
|
||||||
|
<string name="guide_messages_title">Beskeder.</string>
|
||||||
|
<string name="guide_messages_body">Bilen viser dine beskeder og læser dem højt, hvorefter du fpr mulighed for at svare. Gearslip skal startes i listen for at dette sker.</string>
|
||||||
|
<string name="guide_android_auto_title">Deaktiverer Android Auto.</string>
|
||||||
|
<string name="guide_android_auto_body">Android auto er stadig installeret og det kæmper med Gearslip om bilen USB forbindelse. Deaktiver eller afinstaller på næste skærm billede.</string>
|
||||||
|
<string name="guide_default_title">Vælg Gearslip.</string>
|
||||||
|
<string name="guide_default_body">Hvis din telefon spørg, hvilken app den skal åbne når du tilslutter bilen så vælg Gearslip og tryk på altid.</string>
|
||||||
|
<string name="guide_audio_title">Bil lyd.</string>
|
||||||
|
<string name="guide_audio_body">Vælg hvordan musik overføres til bilen. Opkaldt går altid over Bluetooth.</string>
|
||||||
|
<string name="guide_voice_title">Tale.</string>
|
||||||
|
<string name="guide_voice_body">Spørg efter rutevejledning, musik eller ring op med tale. Din telefon laver tale til tekst og intet du siger forlader den. Den har brug for at downloade %1$d MB en gang. Brug WiFi hvis du kan .</string>
|
||||||
|
<string name="guide_audio_prompt_title">Ingen lyd prompt.</string>
|
||||||
|
<string name="guide_audio_prompt_body">Kør dette fra din pc med USB debugging til, og Android acceptere Gearslip med lyd fremover. Ellers tap start ved alle kørsler.</string>
|
||||||
|
<string name="guide_readable_title">Læselige notifikationer.</string>
|
||||||
|
<string name="guide_readable_body">Mens lyden sendes til bilen, skjuler Android indholdet af notifikationerne, ligesom når du deler din skærm. På den måde kan Gearslip vise dem under kørslen og skjule dem igen bagefter.</string>
|
||||||
|
<string name="guide_answer_title">Besvar opkald.</string>
|
||||||
|
<string name="guide_answer_body">Gør Gearslip til din app til visning af opkalds-ID, så kan bilen besvare og afvise opkald. Uden dette ringer telefonen stadig, men det er kun telefonen, der kan besvare opkaldene.</string>
|
||||||
|
<string name="guide_apps_title">Godt sleskab.</string>
|
||||||
|
<string name="guide_buttons_title">Dine knapper.</string>
|
||||||
|
<string name="guide_buttons_body">Vælg, hvordan bilens knapper skal se ud. Du kan ændre det når som helst i bilens indstillinger.</string>
|
||||||
|
<string name="guide_open_notification_access">Åben notifikations indstillinger</string>
|
||||||
|
<string name="guide_open_android_auto">Åbn appoplysningerne for Android Auto</string>
|
||||||
|
<string name="guide_download_voice">Download tale</string>
|
||||||
|
<string name="guide_set_up">Opsætning</string>
|
||||||
|
<string name="guide_voice_downloading">Downloader, %1$d%%. Du kan bare fortsætte; det forsætter i baggrunden.</string>
|
||||||
|
<string name="guide_voice_ready">Tale er klar.</string>
|
||||||
|
<string name="guide_voice_failed">Downloaden blev ikke afsluttet. Tryk på Download stemme for at prøve igen.</string>
|
||||||
|
<string name="guide_audio_bluetooth">Bluetooth</string>
|
||||||
|
<string name="guide_recommended">Anbefalet</string>
|
||||||
|
<string name="guide_audio_bluetooth_body">Der vises ingen meddelelse ved hvert kørsel, og stemmen fra din kortapp kan høres i bilen.</string>
|
||||||
|
<string name="guide_audio_usb">Gennem Gearslip</string>
|
||||||
|
<string name="guide_audio_usb_body">Bedre lyd uden forsinkelse. Hver gang du starter appen, beder Android dig om at dele din skærm, hvilket gør det muligt for Gearslip at sende lyden.</string>
|
||||||
|
<string name="guide_optional">Valgfri</string>
|
||||||
|
<string name="guide_already_allowed">Allerede tilladt.</string>
|
||||||
|
<string name="guide_scroll_more">Rul ned for at se mere ↓</string>
|
||||||
|
<string name="guide_allow">Tillad</string>
|
||||||
|
<string name="guide_skip">Spring over</string>
|
||||||
|
<string name="guide_open">Åben</string>
|
||||||
|
<string name="guide_next">Næste</string>
|
||||||
|
<string name="guide_done">Færdig</string>
|
||||||
|
<string name="guide_back">Tilbage</string>
|
||||||
|
<string name="usage_intro">Aktivér denne funktion, så sender Gearslip en kort besked, når du åbner appen og efter hver køretur. <b>Beskeden indeholder hverken navn, kontooplysninger eller placering. Et tilfældigt tal tæller din telefon én gang, og serveren gemmer ingen IP-adresse.</b> Beskederne viser, hvilke biler der fungerer, og hvor de andre svigter.</string>
|
||||||
|
<string name="usage_send">Send forbrug information</string>
|
||||||
|
<string name="usage_also_share">Del også</string>
|
||||||
|
<string name="usage_share_android">Android version og system</string>
|
||||||
|
<string name="usage_share_phone">Telefon model</string>
|
||||||
|
<string name="usage_share_cars">Hver bil og om den var tilsluttet</string>
|
||||||
|
<string name="usage_share_drives">Hvert tur og hvordan det endte</string>
|
||||||
|
<string name="usage_what_sent">Hvad bliver sendt</string>
|
||||||
|
<string name="usage_what_sent_sub">Hvert felt, præcis som det vises</string>
|
||||||
|
<string name="usage_hide_note">Skjul noten</string>
|
||||||
|
<string name="usage_show_note">Vis noten</string>
|
||||||
|
<string name="usage_example">Eksempel, ikke sendt: OneDrive føjer dette til \"sessioner\"</string>
|
||||||
|
<string name="usage_no_server">Denne build har ingen statistikserver, så den sender intet</string>
|
||||||
|
<string name="usage_debug_build">Debug-version: Noter holdes adskilt og medregnes aldrig i statistikken</string>
|
||||||
|
<string name="worked_title">Gearslip har arbejdet med dine %1$s</string>
|
||||||
|
<string name="worked_title_unknown">Gearslip virker med din bil</string>
|
||||||
|
<string name="worked_body">Del dette, så den næste bilist ved, at det virker? Ingen navn, konto eller placering.</string>
|
||||||
|
<string name="worked_not_now">Ikke nu</string>
|
||||||
|
<string name="worked_share">Del</string>
|
||||||
|
<string name="recommended_intro">Disse gratis apps kører på Gearslips bilskærm. Hver knap åbner appens side på GitHub.</string>
|
||||||
|
<string name="recommended_title">Anbefalede apps</string>
|
||||||
|
<string name="back">Tilbage</string>
|
||||||
|
<string name="recommended_installed">Installaret</string>
|
||||||
|
<string name="recommended_maps">Kort</string>
|
||||||
|
<string name="recommended_music">Musik</string>
|
||||||
|
<string name="recommended_podcasts">Podcast</string>
|
||||||
|
<string name="recommended_other">Andre seje apps</string>
|
||||||
|
<string name="recommended_about_organic">Offline-kort og ruteplanlægning.</string>
|
||||||
|
<string name="recommended_about_vela">Trin-for-trin-navigation uden Google.</string>
|
||||||
|
<string name="recommended_about_ytm">En YouTube Music-afspiller.</string>
|
||||||
|
<string name="recommended_about_antennapod">Podcasts, kapitler og alt det der.</string>
|
||||||
|
<string name="recommended_about_rfsentinel">Find sporingsenheder og kameraer i nærheden.</string>
|
||||||
|
<string name="settings_title">Indstillinger</string>
|
||||||
|
<string name="settings_whats_new">Hvad er nyt</string>
|
||||||
|
<string name="settings_whats_new_sub">Læs %1$s udgivelsesnoterne på GitHub</string>
|
||||||
|
<string name="settings_support">Støt Gearslip</string>
|
||||||
|
<string name="settings_support_footer">Gearslip er gratis, uden reklamer og uden sporing. Det er jeres støtte, der holder det kørende.</string>
|
||||||
|
<string name="settings_usage_notes">Bemærkninger til brugen</string>
|
||||||
|
<string name="settings_updates">Opdateringer</string>
|
||||||
|
<string name="settings_about">Om</string>
|
||||||
|
<string name="settings_replay">Se opsætningsvejledningen igen</string>
|
||||||
|
<string name="settings_replay_sub">Gennemfør trinene til første opstart igen</string>
|
||||||
|
<string name="settings_connection">Forbindelse</string>
|
||||||
|
<string name="settings_use_in_car">Brug Gearslip i bilen</string>
|
||||||
|
<string name="settings_use_in_car_on">Gearslip reagerer, når du tilslutter enheden til en bil</string>
|
||||||
|
<string name="settings_use_in_car_off">Fra: Android Auto kan i stedet oprette forbindelsen</string>
|
||||||
|
<string name="settings_apps">Programmer (Apps)</string>
|
||||||
|
<string name="settings_certificate">Certifikat</string>
|
||||||
|
<string name="cert_next_connection">Ændringerne træder i kraft ved næste forbindelse.</string>
|
||||||
|
<string name="cert_checking">Kontrollerer…</string>
|
||||||
|
<string name="cert_valid_until">Gyldig indtil %1$s</string>
|
||||||
|
<string name="cert_hide_details">Skjul detaljer</string>
|
||||||
|
<string name="cert_show_details">Vis detaljer</string>
|
||||||
|
<string name="cert_subject">Emne emne</string>
|
||||||
|
<string name="cert_issuer">Udsteder</string>
|
||||||
|
<string name="cert_downloading">Downloader…</string>
|
||||||
|
<string name="cert_download">Hent fra aasdk</string>
|
||||||
|
<string name="cert_downloaded">Certifikat downloadet.</string>
|
||||||
|
<string name="cert_download_failed">Download mislykkedes: %1$s</string>
|
||||||
|
<string name="cert_download_sub">Certifikatet udgives på GitHub. De fleste biler accepterer det, men nogle fra omkring 2020 for at afvise det</string>
|
||||||
|
<string name="cert_import">Importér min egen fil</string>
|
||||||
|
<string name="cert_import_sub">En .p12 fil med sin private nøgle. Brug din egen, når din bil afviser aasdk-certifikatet</string>
|
||||||
|
<string name="cert_remove_imported">Fjern importeret certifikat</string>
|
||||||
|
<string name="cert_remove_downloaded">Fjern downloadede certifikat</string>
|
||||||
|
<string name="cert_removed_imported">Fjernet det importerede certifikat.</string>
|
||||||
|
<string name="cert_removed_downloaded">Fjernet den downloadede certifikat.</string>
|
||||||
|
<string name="cert_imported">Certifikat importeret.</string>
|
||||||
|
<string name="cert_import_failed">Kan ikke importere: forkert adgangskode eller ikke en PKCS#12-fil med en privat nøgle.</string>
|
||||||
|
<string name="cert_password_title">Indtast adgangskode</string>
|
||||||
|
<string name="cert_password">Adgangskode nulstilling</string>
|
||||||
|
<string name="cert_password_hint">Efterlad tom, hvis filen ikke har nogen informationer.</string>
|
||||||
|
<string name="cancel">Annuller</string>
|
||||||
|
<string name="cert_none">Ingen indlæst (selv-signed; biler afviser det)</string>
|
||||||
|
<string name="cert_kind_imported">Importeret: %1$s</string>
|
||||||
|
<string name="cert_kind_downloaded">Downloadet: %1$s</string>
|
||||||
|
<string name="cert_kind_adb">Trinet over adb: %1$s</string>
|
||||||
|
<string name="cert_kind_self">Selvsigned: %1$s</string>
|
||||||
|
<string name="car_group_screen">Skærm</string>
|
||||||
|
<string name="car_group_nav_bar">Navigations bar og afspiller</string>
|
||||||
|
<string name="car_group_sound">Lyd og stemme</string>
|
||||||
|
<string name="car_group_warnings">Advarsel</string>
|
||||||
|
<string name="car_group_more">Mere</string>
|
||||||
|
<string name="choice_size_normal">Normal</string>
|
||||||
|
<string name="choice_size_large">Stor</string>
|
||||||
|
<string name="choice_size_xlarge">Ekstra stor</string>
|
||||||
|
<string name="choice_theme_phone">Følg telefonen</string>
|
||||||
|
<string name="choice_theme_light">Lys</string>
|
||||||
|
<string name="choice_theme_dark">Mørk</string>
|
||||||
|
<string name="choice_dark_black">Sort</string>
|
||||||
|
<string name="choice_dark_black_colour">Sort og farve</string>
|
||||||
|
<string name="choice_dark_deep_colour">Dyb farve</string>
|
||||||
|
<string name="choice_dark_grey">Grå</string>
|
||||||
|
<string name="choice_auto">Automatisk</string>
|
||||||
|
<string name="choice_night_day">Dag</string>
|
||||||
|
<string name="choice_night_night">Nat</string>
|
||||||
|
<string name="choice_units_imperial">Imperisk</string>
|
||||||
|
<string name="choice_units_metric">Metrisk</string>
|
||||||
|
<string name="choice_nav_show">Vis altid</string>
|
||||||
|
<string name="choice_nav_hide_navigating">Skjul mens du navigerer</string>
|
||||||
|
<string name="choice_nav_hide">Skjul indtil tryk</string>
|
||||||
|
<string name="choice_open_home">Hjem</string>
|
||||||
|
<string name="summary_player_left">Spiller på venstre side</string>
|
||||||
|
<string name="summary_player_right">Spiller på højre side</string>
|
||||||
|
<string name="summary_usb_audio">Lyd gennem Gearslip</string>
|
||||||
|
<string name="summary_bluetooth_audio">Bluetooth lyd</string>
|
||||||
|
<string name="summary_directions_on">Anvisninger til</string>
|
||||||
|
<string name="summary_directions_off">Anvisninger fra</string>
|
||||||
|
<string name="summary_bluetooth_on">Bluetooth til</string>
|
||||||
|
<string name="summary_bluetooth_off">Bluetooth fra</string>
|
||||||
|
<string name="summary_location_on">Lokation til</string>
|
||||||
|
<string name="summary_location_off">Lokation fra</string>
|
||||||
|
<string name="web_page">Webside</string>
|
||||||
|
<string name="summary_experimental_shown">Eksperimentelle funktioner vist</string>
|
||||||
|
<string name="summary_experimental_hidden">Eksperimentelle funktioner skjult</string>
|
||||||
|
<string name="home_car">Bil</string>
|
||||||
|
<string name="car_group_same">Disse er de samme indstillinger som Indstillinger i bilen. En ændring her viser der også.</string>
|
||||||
|
<string name="car_open_on_connect">Åben forbindelse ved tilslutning</string>
|
||||||
|
<string name="car_size">Størrelse</string>
|
||||||
|
<string name="car_app_theme">App tema</string>
|
||||||
|
<string name="car_dark_mode">Mørk tilstand</string>
|
||||||
|
<string name="car_buttons">Knapper</string>
|
||||||
|
<string name="car_map_day_night">Kort dag/nat</string>
|
||||||
|
<string name="car_units">Enheder</string>
|
||||||
|
<string name="car_nav_bar">Navigationsbar</string>
|
||||||
|
<string name="car_player_nav_bar">Afspiller i navigationsbaren</string>
|
||||||
|
<string name="car_skip_buttons">Spring over knapper</string>
|
||||||
|
<string name="car_song_title">Sang titel</string>
|
||||||
|
<string name="car_player_beside_map">Spiller ved siden af kortet</string>
|
||||||
|
<string name="car_right">Højre</string>
|
||||||
|
<string name="car_left">Venstre</string>
|
||||||
|
<string name="car_autoplay">Autoplay på forbindelse</string>
|
||||||
|
<string name="car_more_lyrics">Flere tekstkilder</string>
|
||||||
|
<string name="car_more_lyrics_note">Spørg også BetterLyrics og KuGou, som derefter får sangens navn og kunstner. Ellers spørges kun LRCLIB.</string>
|
||||||
|
<string name="cert_import_button">Import</string>
|
||||||
|
<string name="car_spoken_directions">Talte rutevejledniger</string>
|
||||||
|
<string name="car_spoken_directions_note">Slå det fra, hvis dit navigations programs egen stemme allerede spiller i bilen.</string>
|
||||||
|
<string name="car_phone_speech">Brug telefonens talegenkendelse</string>
|
||||||
|
<string name="car_phone_speech_note">På de fleste telefoner, der er Googles system, som kan sende din stemme til Google.</string>
|
||||||
|
<string name="car_warn_bluetooth">Advar når Bluetooth ikke er forbundet</string>
|
||||||
|
<string name="car_warn_location">Advar når lokation er slået fra</string>
|
||||||
|
<string name="car_experimental">Eksperimentelle funktioner</string>
|
||||||
|
<string name="car_vehicle_data">Køretøjsdata</string>
|
||||||
|
<string name="car_vehicle_data_note">Tilføjer en køretøjsdata knap: dump af bilens sensorer. Feltnavnene gættes, indtil du har set en ændring.</string>
|
||||||
|
<string name="car_adaptive_video">Tilpasset videokvalitet</string>
|
||||||
|
<string name="car_adaptive_video_note">Sænker billedets kvalitet, når bilen eller dens Wi-Fi ikke kan følge med, og hæver det igen, når den kan. Gælder fra den næste forbindelse.</string>
|
||||||
|
<string name="web_page_note">Vist af Browseren i bilen. Ikke angivet åbner DuckDuckGo. Gælder fra den næste forbindelse.</string>
|
||||||
|
<string name="car_audio">Bil lyd</string>
|
||||||
|
<string name="car_audio_bt">Bluetooth (anbefalet)</string>
|
||||||
|
<string name="car_audio_bt_sub">Der vises ingen meddelelse ved hvert kørsel, og stemmen fra din kortapp kan høres i bilen</string>
|
||||||
|
<string name="car_audio_usb_sub">Bedre lyd uden forsinkelse. Hver gang du starter appen, beder Android dig om at dele din skærm, hvilket gør det muligt for Gearslip at sende lyden</string>
|
||||||
|
<string name="car_audio_usb_footer">Kør kommandoen i Hjælp én gang, og Android stopper med at spørge.</string>
|
||||||
|
<string name="car_audio_bt_footer">Opkald går altid over Bluetooth.</string>
|
||||||
|
<string name="screen_car_size">Bil skærm størrelse</string>
|
||||||
|
<string name="screen_use_cars">Brug bilens</string>
|
||||||
|
<string name="screen_preview_size">Forhåndsvisningsstørrelse</string>
|
||||||
|
<string name="screen_preview_note">Størrelsen af bilens forhåndsvisning på skærmen, såsom 1920x720.</string>
|
||||||
|
<string name="screen_car_screen">Bil skærm</string>
|
||||||
|
<string name="screen_footer">Gearslip bruger den størrelse bilens rapporter, medmindre du indstiller en.</string>
|
||||||
|
<string name="screen_size">Skærmstørrelse</string>
|
||||||
|
<string name="screen_from_car">Fra bilen</string>
|
||||||
|
<string name="screen_crop">Beskær kanterne</string>
|
||||||
|
<string name="screen_crop_sub">Til en bil, der skærer top og bund, eller siderne af billedet</string>
|
||||||
|
<string name="screen_squash">Tilpas størrelsen</string>
|
||||||
|
<string name="screen_squash_sub">Til biler, der maser billedet, så cirkler ligner ovale</string>
|
||||||
|
<string name="screen_preview_title">Bilen forhåndsvisnings størrelse</string>
|
||||||
|
<string name="screen_last_car">Seneste bil</string>
|
||||||
|
<string name="screen_last_car_sub">Størrelsen af den sidste bil, du har forbindelse til</string>
|
||||||
|
<string name="screen_this_screen">Denne skærm</string>
|
||||||
|
<string name="screen_this_screen_sub">Fylder denne enheds skærm, til en enhed, der kører Gearslip selv</string>
|
||||||
|
<string name="screen_custom">Brugertilpasset</string>
|
||||||
|
<string name="screen_enter_size">Indtast en størrelse</string>
|
||||||
|
<string name="screen_change_custom">Ændre den brugerdefinerede størrelse</string>
|
||||||
|
<string name="screen_width">Bredde</string>
|
||||||
|
<string name="screen_height">Højde</string>
|
||||||
|
<string name="save">Gem</string>
|
||||||
|
<string name="screen_car_size_note">Brug dette, hvis bilens billede er udstrakt. Indtast skærmens størrelse, såsom 1280x480. Det virker fra næste gang du opretter forbindelse.</string>
|
||||||
|
<string name="screen_set_by_you">%1$s, sat af dig</string>
|
||||||
|
<string name="help_title">Sådan opretter du forbindelse</string>
|
||||||
|
<string name="help_which_cars">Hvilke biler virker</string>
|
||||||
|
<string name="help_full_list">Se hele listen</string>
|
||||||
|
<string name="help_first_drive">Før din første tur</string>
|
||||||
|
<string name="help_in_car">I bilen</string>
|
||||||
|
<string name="help_usb_audio">Lyd gennem Gearslip</string>
|
||||||
|
<string name="help_aa_off">Deaktiverer Android Auto.</string>
|
||||||
|
<string name="help_aa_off_detail">Deaktiverer eller afinstaller Android Auto appen, så bilen taler til Gearslip i stedet.</string>
|
||||||
|
<string name="help_cert">Indlæs et certifikat.</string>
|
||||||
|
<string name="help_cert_detail">I Indstillinger skal du downloade et aasdk-certifikatet, som de fleste biler accepterer. Hvis din bil afviser det, importerer din egen i stedet.</string>
|
||||||
|
<string name="help_mic">Tillad mikrofonen.</string>
|
||||||
|
<string name="help_mic_detail">Android vil ikke lade en app sende musik til bilen uden dette. Gearslip lytter kun, når du trykker på mikrofonen eller trykker på taleknappen.</string>
|
||||||
|
<string name="help_plug_in">Tilslut eller opret forbindelse trådløst.</string>
|
||||||
|
<string name="help_plug_in_detail">Gearslip åbner sig selv, når bilen forbinder. For trådløst, par telefonen med bilen først, og tryk derefter på Tilslut trådløst i Gearslip appen.</string>
|
||||||
|
<string name="help_pair">Pair Bluetooth til opkald.</string>
|
||||||
|
<string name="help_pair_detail">Opkald virker gennem bilen, når din telefon og bilen er parret. Gearslip tilbyder at parre første gang du tilslutter.</string>
|
||||||
|
<string name="help_answer">Besvar opkald fra bilens skærm.</string>
|
||||||
|
<string name="help_answer_detail">Indstil Gearslip som din caller ID og spam app. Uden dette, ringer telefonen stadig, men det er kun telefonen kan besvare opkaldet.</string>
|
||||||
|
<string name="help_prompt">Stop lydprompten efter hver kørsel.</string>
|
||||||
|
<string name="help_prompt_detail">Tilslut telefonen til en computer med USB debugging til, og kør en gang. Ellers skal du trykke på Start hver gang du starter.</string>
|
||||||
|
<string name="help_readable">Hold meddelelser læselige.</string>
|
||||||
|
<string name="settings_language">Sprog</string>
|
||||||
|
<string name="settings_language_phone">Samme som telefonen</string>
|
||||||
|
<string name="player_next_chapter">Næste kapitel</string>
|
||||||
|
</resources>
|
||||||
@@ -69,12 +69,12 @@
|
|||||||
<string name="choice_dark_black">Negro</string>
|
<string name="choice_dark_black">Negro</string>
|
||||||
<string name="choice_dark_black_colour">Negro y color</string>
|
<string name="choice_dark_black_colour">Negro y color</string>
|
||||||
<string name="choice_dark_deep_colour">Color profundo</string>
|
<string name="choice_dark_deep_colour">Color profundo</string>
|
||||||
<string name="choice_dark_grey">Grey</string>
|
<string name="choice_dark_grey">Gris</string>
|
||||||
<string name="choice_auto">Auto</string>
|
<string name="choice_auto">Auto</string>
|
||||||
<string name="choice_night_day">Día</string>
|
<string name="choice_night_day">Día</string>
|
||||||
<string name="choice_night_night">Buenas noches</string>
|
<string name="choice_night_night">Noche</string>
|
||||||
<string name="choice_units_imperial">Imperial</string>
|
<string name="choice_units_imperial">Imperial</string>
|
||||||
<string name="choice_units_metric">Métrica</string>
|
<string name="choice_units_metric">Métrico</string>
|
||||||
<string name="choice_nav_show">Mostrar siempre</string>
|
<string name="choice_nav_show">Mostrar siempre</string>
|
||||||
<string name="choice_nav_hide_navigating">Esconder mientras navega</string>
|
<string name="choice_nav_hide_navigating">Esconder mientras navega</string>
|
||||||
<string name="choice_nav_hide">Escóndete hasta que te toquen</string>
|
<string name="choice_nav_hide">Escóndete hasta que te toquen</string>
|
||||||
@@ -88,13 +88,13 @@
|
|||||||
<string name="summary_bluetooth_on">Bluetooth activado</string>
|
<string name="summary_bluetooth_on">Bluetooth activado</string>
|
||||||
<string name="summary_bluetooth_off">Bluetooth apagado</string>
|
<string name="summary_bluetooth_off">Bluetooth apagado</string>
|
||||||
<string name="summary_location_on">Ubicación</string>
|
<string name="summary_location_on">Ubicación</string>
|
||||||
<string name="summary_location_off">Ubicación fuera</string>
|
<string name="summary_location_off">Ubicación desactivada</string>
|
||||||
<string name="web_page">Página</string>
|
<string name="web_page">Página web</string>
|
||||||
<string name="summary_experimental_shown">Características experimentales mostradas</string>
|
<string name="summary_experimental_shown">Mostrar características experimentales</string>
|
||||||
<string name="summary_experimental_hidden">Características experimentales ocultas</string>
|
<string name="summary_experimental_hidden">Características experimentales ocultas</string>
|
||||||
<string name="home_car">Coche</string>
|
<string name="home_car">Coche</string>
|
||||||
<string name="car_group_same">Estas son las mismas configuraciones que Ajustes en el coche. Un cambio aquí también muestra allí.</string>
|
<string name="car_group_same">Estas son las mismas configuraciones que Ajustes en el coche. Un cambio aquí también muestra allí.</string>
|
||||||
<string name="car_open_on_connect">Abra la conexión</string>
|
<string name="car_open_on_connect">Abrir al conectar</string>
|
||||||
<string name="car_size">Tamaño</string>
|
<string name="car_size">Tamaño</string>
|
||||||
<string name="car_app_theme">Tema de aplicación</string>
|
<string name="car_app_theme">Tema de aplicación</string>
|
||||||
<string name="car_dark_mode">Modo oscuro</string>
|
<string name="car_dark_mode">Modo oscuro</string>
|
||||||
@@ -254,6 +254,8 @@
|
|||||||
<string name="wireless_which_car">¿Qué coche?</string>
|
<string name="wireless_which_car">¿Qué coche?</string>
|
||||||
<string name="wireless_none_paired">Tu teléfono no está emparejado con nada todavía. Encima con el coche primero.</string>
|
<string name="wireless_none_paired">Tu teléfono no está emparejado con nada todavía. Encima con el coche primero.</string>
|
||||||
<string name="wireless_not_listed">¿El auto no aparece? Pásala primero con tu teléfono.</string>
|
<string name="wireless_not_listed">¿El auto no aparece? Pásala primero con tu teléfono.</string>
|
||||||
|
<string name="wireless_auto_off">No se conecta solo</string>
|
||||||
|
<string name="wireless_auto_turn_on">Dejar que se conecte solo</string>
|
||||||
<string name="bluetooth_settings">Ajustes Bluetooth</string>
|
<string name="bluetooth_settings">Ajustes Bluetooth</string>
|
||||||
<string name="voice_live_title">Voz: palabras en vivo</string>
|
<string name="voice_live_title">Voz: palabras en vivo</string>
|
||||||
<string name="voice_final_title">Voz: palabras finales</string>
|
<string name="voice_final_title">Voz: palabras finales</string>
|
||||||
@@ -589,10 +591,10 @@
|
|||||||
<item quantity="one">%1$d metro</item>
|
<item quantity="one">%1$d metro</item>
|
||||||
<item quantity="other">%1$d metros</item>
|
<item quantity="other">%1$d metros</item>
|
||||||
</plurals>
|
</plurals>
|
||||||
<string name="touch_relay_description">Permite a Gearslip reproducir toques desde la pantalla del coche en el teléfono mientras se refleja. Gearslip no lee contenido de pantalla.</string>
|
<string name="touch_relay_description">Permite a Gearslip reproducir toques desde la pantalla del coche en el teléfono mientras la conexión este activa. Gearslip no lee contenido de pantalla.</string>
|
||||||
<string name="guide_usage_title">Ayuda a otros conductores.</string>
|
<string name="guide_usage_title">Ayuda a otros conductores.</string>
|
||||||
<string name="guide_safety_title">Conduce con cuidado.</string>
|
<string name="guide_safety_title">Conduce con cuidado.</string>
|
||||||
<string name="guide_safety_body">Gearslip no es oficial. Ningún fabricante de coches lo hizo o lo comprobó, y puede fallar sin aviso. Arréglalo antes de que te vayas, entonces mantén los ojos en el camino.</string>
|
<string name="guide_safety_body">Gearslip no es una app oficial. No la ha desarrollado ni verificado ningún fabricante de coches y puede fallar sin previo aviso. Configúrala antes de arrancar y, una vez en marcha, mantén la vista en la carretera.</string>
|
||||||
<string name="guide_mic_title">Micrófono.</string>
|
<string name="guide_mic_title">Micrófono.</string>
|
||||||
<string name="guide_mic_body">Gearslip lo usa para enviar tu música al coche y escucharte cuando pides algo o respondes a un mensaje. Lo que dices se queda por teléfono.</string>
|
<string name="guide_mic_body">Gearslip lo usa para enviar tu música al coche y escucharte cuando pides algo o respondes a un mensaje. Lo que dices se queda por teléfono.</string>
|
||||||
<string name="guide_location_title">Ubicación.</string>
|
<string name="guide_location_title">Ubicación.</string>
|
||||||
@@ -602,7 +604,7 @@
|
|||||||
<string name="guide_calls_title">Llamadas y contactos.</string>
|
<string name="guide_calls_title">Llamadas y contactos.</string>
|
||||||
<string name="guide_calls_body">El coche muestra quién llama por nombre, y su aplicación Phone puede marcar un número o llamar a uno de sus contactos. Gearslip sólo los lee.</string>
|
<string name="guide_calls_body">El coche muestra quién llama por nombre, y su aplicación Phone puede marcar un número o llamar a uno de sus contactos. Gearslip sólo los lee.</string>
|
||||||
<string name="guide_bluetooth_title">Bluetooth.</string>
|
<string name="guide_bluetooth_title">Bluetooth.</string>
|
||||||
<string name="guide_bluetooth_body">La primera vez que se conecta, Gearslip ofrece para emparejar su teléfono con el coche, así que las llamadas juegan a través de sus altavoces.</string>
|
<string name="guide_bluetooth_body">La primera vez que se conecta, Gearslip ofrece emparejar su teléfono con el coche, para que las llamadas suenen a través de los altavoces.</string>
|
||||||
<string name="guide_notifications_title">Notificaciones.</string>
|
<string name="guide_notifications_title">Notificaciones.</string>
|
||||||
<string name="guide_notifications_body">Mientras que Gearslip está conectado, muestra una notificación con un botón para detenerse. Sin esto, Android lo esconde.</string>
|
<string name="guide_notifications_body">Mientras que Gearslip está conectado, muestra una notificación con un botón para detenerse. Sin esto, Android lo esconde.</string>
|
||||||
<string name="guide_messages_title">Mensajes.</string>
|
<string name="guide_messages_title">Mensajes.</string>
|
||||||
@@ -707,4 +709,11 @@
|
|||||||
<string name="places_finding">Buscando…</string>
|
<string name="places_finding">Buscando…</string>
|
||||||
<string name="places_not_found">Gearslip no encontró dónde estás. Comprueba que la ubicación esté activada.</string>
|
<string name="places_not_found">Gearslip no encontró dónde estás. Comprueba que la ubicación esté activada.</string>
|
||||||
<string name="places_clear">Borrar</string>
|
<string name="places_clear">Borrar</string>
|
||||||
|
<string name="certsetup_installed_hint">Si tu coche no se conecta, puede que rechace este certificado. Importa el tuyo en Ajustes, en Certificado.</string>
|
||||||
|
<string name="cert_downloaded_footer">¿El coche no se conecta? Algunos coches de 2020 en adelante rechazan el certificado de aasdk, así que importa el tuyo. Los cambios se aplican en la próxima conexión.</string>
|
||||||
|
<string name="car_cluster_video">Tarjeta de giro en la pantalla del cuadro</string>
|
||||||
|
<string name="car_cluster_video_note">Para coches con una segunda pantalla en el cuadro de instrumentos: Gearslip muestra ahí el próximo giro. Se aplica en la próxima conexión.</string>
|
||||||
|
<string name="settings_language">Idioma</string>
|
||||||
|
<string name="settings_language_phone">El mismo que el teléfono</string>
|
||||||
|
<string name="player_next_chapter">Capítulo siguiente</string>
|
||||||
</resources>
|
</resources>
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<resources>
|
||||||
|
<string name="back">Tagasi</string>
|
||||||
|
<string name="recommended_installed">Paigaldatud</string>
|
||||||
|
<string name="recommended_maps">Kaardid</string>
|
||||||
|
<string name="recommended_music">Muusika</string>
|
||||||
|
<string name="recommended_podcasts">Taskuhäälingud</string>
|
||||||
|
<string name="recommended_other">Muud vahvad rakendused</string>
|
||||||
|
<string name="recommended_about_organic">Kaardid ja tee juhatamine, mis ei vaja võrguühendust.</string>
|
||||||
|
<string name="recommended_about_vela">Samm-sammult tee juhatamine ilma Google\'ita.</string>
|
||||||
|
<string name="recommended_about_ytm">Kuula muusikat teenusest YouTube Music.</string>
|
||||||
|
<string name="recommended_about_antennapod">Tasukuhäälingud ja kõik mis sellega kaasneb.</string>
|
||||||
|
<string name="recommended_about_rfsentinel">Tuvastab lähikonnast jälitajad ja kaamerad.</string>
|
||||||
|
<string name="settings_title">Seadistused</string>
|
||||||
|
<string name="settings_whats_new">Mida on meil uut</string>
|
||||||
|
<string name="settings_whats_new_sub">Loe GitHubist teavet %1$s versiooni kohta</string>
|
||||||
|
<string name="settings_support">Toeta Gearslipi arendust</string>
|
||||||
|
<string name="settings_support_footer">Gearslip on vaba ja tasuta tarkvara, milles pole reklaame ega jälitamist. Sinu rahaline tugi aitab arendajatel edasi tegutseda.</string>
|
||||||
|
</resources>
|
||||||
@@ -373,6 +373,8 @@
|
|||||||
<string name="wireless_which_car">Quelle voiture ?</string>
|
<string name="wireless_which_car">Quelle voiture ?</string>
|
||||||
<string name="wireless_none_paired">Votre téléphone n’est encore associé à rien. Associez-le d’abord à la voiture.</string>
|
<string name="wireless_none_paired">Votre téléphone n’est encore associé à rien. Associez-le d’abord à la voiture.</string>
|
||||||
<string name="wireless_not_listed">La voiture n’apparaît pas ? Associez-la d’abord à votre téléphone.</string>
|
<string name="wireless_not_listed">La voiture n’apparaît pas ? Associez-la d’abord à votre téléphone.</string>
|
||||||
|
<string name="wireless_auto_off">Ne se connecte pas tout seul</string>
|
||||||
|
<string name="wireless_auto_turn_on">Le laisser se connecter tout seul</string>
|
||||||
<string name="bluetooth_settings">Paramètres Bluetooth</string>
|
<string name="bluetooth_settings">Paramètres Bluetooth</string>
|
||||||
|
|
||||||
<!-- Voice models -->
|
<!-- Voice models -->
|
||||||
@@ -837,4 +839,7 @@
|
|||||||
<string name="places_finding">Recherche…</string>
|
<string name="places_finding">Recherche…</string>
|
||||||
<string name="places_not_found">Gearslip n’a pas trouvé votre position. Vérifiez que la localisation est activée.</string>
|
<string name="places_not_found">Gearslip n’a pas trouvé votre position. Vérifiez que la localisation est activée.</string>
|
||||||
<string name="places_clear">Effacer</string>
|
<string name="places_clear">Effacer</string>
|
||||||
|
<string name="settings_language">Langue</string>
|
||||||
|
<string name="settings_language_phone">Comme le téléphone</string>
|
||||||
|
<string name="player_next_chapter">Chapitre suivant</string>
|
||||||
</resources>
|
</resources>
|
||||||
@@ -373,6 +373,8 @@
|
|||||||
<string name="wireless_which_car">Quale auto?</string>
|
<string name="wireless_which_car">Quale auto?</string>
|
||||||
<string name="wireless_none_paired">Il telefono non è ancora associato a nulla. Associalo prima all’auto.</string>
|
<string name="wireless_none_paired">Il telefono non è ancora associato a nulla. Associalo prima all’auto.</string>
|
||||||
<string name="wireless_not_listed">L’auto non è in elenco? Associala prima al telefono.</string>
|
<string name="wireless_not_listed">L’auto non è in elenco? Associala prima al telefono.</string>
|
||||||
|
<string name="wireless_auto_off">Non si collega da solo</string>
|
||||||
|
<string name="wireless_auto_turn_on">Lascia che si colleghi da solo</string>
|
||||||
<string name="bluetooth_settings">Impostazioni Bluetooth</string>
|
<string name="bluetooth_settings">Impostazioni Bluetooth</string>
|
||||||
|
|
||||||
<!-- Voice models -->
|
<!-- Voice models -->
|
||||||
@@ -837,4 +839,7 @@
|
|||||||
<string name="places_finding">Ricerca…</string>
|
<string name="places_finding">Ricerca…</string>
|
||||||
<string name="places_not_found">Gearslip non ha trovato la tua posizione. Controlla che la localizzazione sia attiva.</string>
|
<string name="places_not_found">Gearslip non ha trovato la tua posizione. Controlla che la localizzazione sia attiva.</string>
|
||||||
<string name="places_clear">Cancella</string>
|
<string name="places_clear">Cancella</string>
|
||||||
|
<string name="settings_language">Lingua</string>
|
||||||
|
<string name="settings_language_phone">Come il telefono</string>
|
||||||
|
<string name="player_next_chapter">Capitolo successivo</string>
|
||||||
</resources>
|
</resources>
|
||||||
@@ -371,6 +371,8 @@
|
|||||||
<string name="wireless_which_car">どの車ですか?</string>
|
<string name="wireless_which_car">どの車ですか?</string>
|
||||||
<string name="wireless_none_paired">スマートフォンはまだ何ともペア設定されていません。先に車とペア設定してください。</string>
|
<string name="wireless_none_paired">スマートフォンはまだ何ともペア設定されていません。先に車とペア設定してください。</string>
|
||||||
<string name="wireless_not_listed">車が表示されない場合は、先にスマートフォンとペア設定してください。</string>
|
<string name="wireless_not_listed">車が表示されない場合は、先にスマートフォンとペア設定してください。</string>
|
||||||
|
<string name="wireless_auto_off">自動では接続しません</string>
|
||||||
|
<string name="wireless_auto_turn_on">自動で接続する</string>
|
||||||
<string name="bluetooth_settings">Bluetooth の設定</string>
|
<string name="bluetooth_settings">Bluetooth の設定</string>
|
||||||
|
|
||||||
<!-- Voice models -->
|
<!-- Voice models -->
|
||||||
@@ -821,4 +823,7 @@
|
|||||||
<string name="places_finding">検索中…</string>
|
<string name="places_finding">検索中…</string>
|
||||||
<string name="places_not_found">現在地が見つかりませんでした。位置情報がオンになっているか確認してください。</string>
|
<string name="places_not_found">現在地が見つかりませんでした。位置情報がオンになっているか確認してください。</string>
|
||||||
<string name="places_clear">消去</string>
|
<string name="places_clear">消去</string>
|
||||||
|
<string name="settings_language">言語</string>
|
||||||
|
<string name="settings_language_phone">スマートフォンと同じ</string>
|
||||||
|
<string name="player_next_chapter">次のチャプター</string>
|
||||||
</resources>
|
</resources>
|
||||||
@@ -371,6 +371,8 @@
|
|||||||
<string name="wireless_which_car">어느 차량인가요?</string>
|
<string name="wireless_which_car">어느 차량인가요?</string>
|
||||||
<string name="wireless_none_paired">휴대전화가 아직 아무것과도 페어링되지 않았습니다. 먼저 차량과 페어링하세요.</string>
|
<string name="wireless_none_paired">휴대전화가 아직 아무것과도 페어링되지 않았습니다. 먼저 차량과 페어링하세요.</string>
|
||||||
<string name="wireless_not_listed">차량이 목록에 없나요? 먼저 휴대전화와 페어링하세요.</string>
|
<string name="wireless_not_listed">차량이 목록에 없나요? 먼저 휴대전화와 페어링하세요.</string>
|
||||||
|
<string name="wireless_auto_off">자동으로 연결되지 않음</string>
|
||||||
|
<string name="wireless_auto_turn_on">자동으로 연결하도록 허용</string>
|
||||||
<string name="bluetooth_settings">블루투스 설정</string>
|
<string name="bluetooth_settings">블루투스 설정</string>
|
||||||
|
|
||||||
<!-- Voice models -->
|
<!-- Voice models -->
|
||||||
@@ -821,4 +823,7 @@
|
|||||||
<string name="places_finding">찾는 중…</string>
|
<string name="places_finding">찾는 중…</string>
|
||||||
<string name="places_not_found">Gearslip이 현재 위치를 찾지 못했습니다. 위치가 켜져 있는지 확인하세요.</string>
|
<string name="places_not_found">Gearslip이 현재 위치를 찾지 못했습니다. 위치가 켜져 있는지 확인하세요.</string>
|
||||||
<string name="places_clear">지우기</string>
|
<string name="places_clear">지우기</string>
|
||||||
|
<string name="settings_language">언어</string>
|
||||||
|
<string name="settings_language_phone">휴대전화와 동일</string>
|
||||||
|
<string name="player_next_chapter">다음 챕터</string>
|
||||||
</resources>
|
</resources>
|
||||||
@@ -372,6 +372,8 @@
|
|||||||
<string name="wireless_which_car">Welke auto?</string>
|
<string name="wireless_which_car">Welke auto?</string>
|
||||||
<string name="wireless_none_paired">Je telefoon is nog met niets gekoppeld. Koppel hem eerst met de auto.</string>
|
<string name="wireless_none_paired">Je telefoon is nog met niets gekoppeld. Koppel hem eerst met de auto.</string>
|
||||||
<string name="wireless_not_listed">Auto staat er niet bij? Koppel hem eerst met je telefoon.</string>
|
<string name="wireless_not_listed">Auto staat er niet bij? Koppel hem eerst met je telefoon.</string>
|
||||||
|
<string name="wireless_auto_off">Maakt niet vanzelf verbinding</string>
|
||||||
|
<string name="wireless_auto_turn_on">Vanzelf verbinding laten maken</string>
|
||||||
<string name="bluetooth_settings">Bluetooth-instellingen</string>
|
<string name="bluetooth_settings">Bluetooth-instellingen</string>
|
||||||
|
|
||||||
<!-- Voice models -->
|
<!-- Voice models -->
|
||||||
@@ -829,4 +831,7 @@
|
|||||||
<string name="places_finding">Zoeken…</string>
|
<string name="places_finding">Zoeken…</string>
|
||||||
<string name="places_not_found">Gearslip kon je locatie niet vinden. Staat locatie aan?</string>
|
<string name="places_not_found">Gearslip kon je locatie niet vinden. Staat locatie aan?</string>
|
||||||
<string name="places_clear">Wissen</string>
|
<string name="places_clear">Wissen</string>
|
||||||
|
<string name="settings_language">Taal</string>
|
||||||
|
<string name="settings_language_phone">Zelfde als de telefoon</string>
|
||||||
|
<string name="player_next_chapter">Volgend hoofdstuk</string>
|
||||||
</resources>
|
</resources>
|
||||||
@@ -374,6 +374,8 @@
|
|||||||
<string name="wireless_which_car">Który samochód?</string>
|
<string name="wireless_which_car">Który samochód?</string>
|
||||||
<string name="wireless_none_paired">Telefon nie jest jeszcze z niczym sparowany. Najpierw sparuj go z samochodem.</string>
|
<string name="wireless_none_paired">Telefon nie jest jeszcze z niczym sparowany. Najpierw sparuj go z samochodem.</string>
|
||||||
<string name="wireless_not_listed">Nie ma samochodu na liście? Najpierw sparuj go z telefonem.</string>
|
<string name="wireless_not_listed">Nie ma samochodu na liście? Najpierw sparuj go z telefonem.</string>
|
||||||
|
<string name="wireless_auto_off">Nie łączy się samo</string>
|
||||||
|
<string name="wireless_auto_turn_on">Pozwól łączyć się samodzielnie</string>
|
||||||
<string name="bluetooth_settings">Ustawienia Bluetooth</string>
|
<string name="bluetooth_settings">Ustawienia Bluetooth</string>
|
||||||
|
|
||||||
<!-- Voice models -->
|
<!-- Voice models -->
|
||||||
@@ -845,4 +847,7 @@
|
|||||||
<string name="places_finding">Szukam…</string>
|
<string name="places_finding">Szukam…</string>
|
||||||
<string name="places_not_found">Gearslip nie znalazł Twojej lokalizacji. Sprawdź, czy lokalizacja jest włączona.</string>
|
<string name="places_not_found">Gearslip nie znalazł Twojej lokalizacji. Sprawdź, czy lokalizacja jest włączona.</string>
|
||||||
<string name="places_clear">Wyczyść</string>
|
<string name="places_clear">Wyczyść</string>
|
||||||
|
<string name="settings_language">Język</string>
|
||||||
|
<string name="settings_language_phone">Taki sam jak w telefonie</string>
|
||||||
|
<string name="player_next_chapter">Następny rozdział</string>
|
||||||
</resources>
|
</resources>
|
||||||
@@ -373,6 +373,8 @@
|
|||||||
<string name="wireless_which_car">Qual carro?</string>
|
<string name="wireless_which_car">Qual carro?</string>
|
||||||
<string name="wireless_none_paired">Seu celular ainda não está pareado com nada. Pareie com o carro primeiro.</string>
|
<string name="wireless_none_paired">Seu celular ainda não está pareado com nada. Pareie com o carro primeiro.</string>
|
||||||
<string name="wireless_not_listed">O carro não aparece? Pareie com o celular primeiro.</string>
|
<string name="wireless_not_listed">O carro não aparece? Pareie com o celular primeiro.</string>
|
||||||
|
<string name="wireless_auto_off">Não se conecta sozinho</string>
|
||||||
|
<string name="wireless_auto_turn_on">Deixar conectar sozinho</string>
|
||||||
<string name="bluetooth_settings">Configurações de Bluetooth</string>
|
<string name="bluetooth_settings">Configurações de Bluetooth</string>
|
||||||
|
|
||||||
<!-- Voice models -->
|
<!-- Voice models -->
|
||||||
@@ -837,4 +839,7 @@
|
|||||||
<string name="places_finding">Procurando…</string>
|
<string name="places_finding">Procurando…</string>
|
||||||
<string name="places_not_found">O Gearslip não encontrou onde você está. Veja se a localização está ligada.</string>
|
<string name="places_not_found">O Gearslip não encontrou onde você está. Veja se a localização está ligada.</string>
|
||||||
<string name="places_clear">Limpar</string>
|
<string name="places_clear">Limpar</string>
|
||||||
|
<string name="settings_language">Idioma</string>
|
||||||
|
<string name="settings_language_phone">O mesmo do celular</string>
|
||||||
|
<string name="player_next_chapter">Próximo capítulo</string>
|
||||||
</resources>
|
</resources>
|
||||||
@@ -0,0 +1,578 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<resources>
|
||||||
|
<string name="touch_relay_description">Permite aplicației Gearslip să redirecționeze atingerile de pe ecranul mașinii către telefon în timpul oglindirii ecranului. Gearslip nu citește conținutul ecranului.</string>
|
||||||
|
<string name="guide_usage_title">Ajută-i pe ceilalți șoferi.</string>
|
||||||
|
<string name="guide_safety_title">Condu cu prudență.</string>
|
||||||
|
<string name="guide_safety_body">Gearslip nu este o aplicație oficială. Niciun producător auto nu a creat-o sau verificat-o, iar aceasta se poate defecta fără avertisment. Configureaz-o înainte de a porni la drum, apoi concentrează-te pe șosea.</string>
|
||||||
|
<string name="guide_mic_title">Microfon.</string>
|
||||||
|
<string name="guide_mic_body">Gearslip folosește această funcție pentru a transmite muzica ta către mașină și pentru a te auzi atunci când soliciți ceva sau răspunzi la un mesaj. Ceea ce spui rămâne pe telefon.</string>
|
||||||
|
<string name="guide_location_title">Locație.</string>
|
||||||
|
<string name="guide_location_body">Harta mașinii și căutările din apropiere au nevoie de ea. Fără ea, tabloul de bord nu afișează nicio hartă.</string>
|
||||||
|
<string name="guide_calendar_title">Calendar.</string>
|
||||||
|
<string name="guide_calendar_body">Ecranul mașinii afișează următorul eveniment din agenda ta. Gearslip citește doar calendarul tău.</string>
|
||||||
|
<string name="guide_calls_title">Apeluri și contacte.</string>
|
||||||
|
<string name="guide_calls_body">Mașina afișează numele persoanei care sună, iar aplicația sa de telefon poate forma un număr sau poate apela unul dintre contactele tale. Gearslip doar le citește.</string>
|
||||||
|
<string name="guide_bluetooth_title">Bluetooth.</string>
|
||||||
|
<string name="guide_bluetooth_body">La prima conectare, Gearslip îți propune să îți conectezi telefonul la mașină, astfel încât apelurile să fie redate prin difuzoarele acesteia.</string>
|
||||||
|
<string name="guide_notifications_title">Notificări.</string>
|
||||||
|
<string name="guide_notifications_body">Atâta timp cât Gearslip este conectat, afișează o notificare cu un buton de oprire. În lipsa acestuia, Android o ascunde.</string>
|
||||||
|
<string name="guide_messages_title">Mesaje.</string>
|
||||||
|
<string name="guide_messages_body">Mașina îți afișează mesajele, ți le citește cu voce tare și îți permite să răspunzi. Activează opțiunea „Gearslip” din lista care se deschide.</string>
|
||||||
|
<string name="guide_android_auto_title">Dezactivează Android Auto.</string>
|
||||||
|
<string name="guide_android_auto_body">Android Auto este încă instalat și intră în conflict cu Gearslip pentru conexiunea USB a mașinii. Dezactivează-l sau dezinstalează-l din fereastra care se deschide.</string>
|
||||||
|
<string name="guide_default_title">Alege Gearslip.</string>
|
||||||
|
<string name="guide_default_body">Dacă telefonul te întreabă ce aplicație să deschidă atunci când îl conectezi la mașină, alege „Gearslip” și atinge „Întotdeauna”.</string>
|
||||||
|
<string name="guide_audio_title">Sunet în automobil.</string>
|
||||||
|
<string name="guide_audio_body">Alege modul în care muzica ajunge în mașină. Apelurile se transmit prin Bluetooth în ambele cazuri.</string>
|
||||||
|
<string name="guide_voice_title">Voce.</string>
|
||||||
|
<string name="guide_voice_body">Cere indicații de orientare, muzică sau un apel prin comandă vocală. Telefonul tău transformă automat vorbirea în text, iar nimic din ceea ce spui nu părăsește dispozitivul. Are nevoie de %1$d MB, se descarcă o singură dată. Folosește Wi-Fi dacă poți.</string>
|
||||||
|
<string name="guide_audio_prompt_title">Fără solicitare de sunet.</string>
|
||||||
|
<string name="guide_audio_prompt_body">Execută această comandă de pe un computer pe care este activată opțiunea de depanare USB, iar Android va încredința definitiv gestionarea sunetului aplicației Gearslip. Fără aceasta, trebuie să apeși o dată pe „Start” la fiecare cursă.</string>
|
||||||
|
<string name="guide_readable_title">Notificări ușor de citit.</string>
|
||||||
|
<string name="guide_readable_body">În timp ce sunetul este redat în mașină, Android ascunde conținutul notificărilor, ca și cum ai partaja ecranul. Astfel, Gearslip le poate afișa în timpul călătoriei și le poate ascunde din nou după aceea.</string>
|
||||||
|
<string name="guide_answer_title">Răspunde la apeluri.</string>
|
||||||
|
<string name="guide_answer_body">Setează Gearslip ca aplicație pentru identificarea apelantului, iar mașina va putea prelua sau respinge apelurile. Fără această aplicație, apelurile vor suna în continuare, dar numai telefonul le va putea prelua.</string>
|
||||||
|
<string name="guide_apps_title">O companie plăcută.</string>
|
||||||
|
<string name="guide_buttons_title">Butoanele tale.</string>
|
||||||
|
<string name="guide_buttons_body">Alege aspectul butoanelor mașinii. Poți să-l modifici oricând din setările mașinii.</string>
|
||||||
|
<string name="guide_open_notification_access">Activare acces la notificări</string>
|
||||||
|
<string name="guide_open_android_auto">Deschide informațiile despre aplicația Android Auto</string>
|
||||||
|
<string name="guide_download_voice">Descarcă vocea</string>
|
||||||
|
<string name="guide_set_up">Configurare</string>
|
||||||
|
<string name="guide_voice_downloading">Se descarcă, %1$d%%. Poți continua; procesul merge mai departe.</string>
|
||||||
|
<string name="guide_voice_ready">Vocea este pregătită.</string>
|
||||||
|
<string name="guide_voice_failed">Descărcarea nu s-a finalizat. Atinge „Descarcă vocea” pentru a încerca din nou.</string>
|
||||||
|
<string name="guide_audio_bluetooth">Bluetooth</string>
|
||||||
|
<string name="guide_recommended">Recomandat</string>
|
||||||
|
<string name="guide_audio_bluetooth_body">Nu mai primești mesaje la fiecare deplasare, iar vocea aplicației de hărți se aude direct în mașină.</string>
|
||||||
|
<string name="guide_audio_usb">Prin Gearslip</string>
|
||||||
|
<string name="guide_audio_usb_body">Un sunet mai clar, fără întârziere. La fiecare pornire, Android îți cere să partajezi ecranul, ceea ce permite aplicației Gearslip să transmită sunetul.</string>
|
||||||
|
<string name="guide_optional">OPȚIONAL</string>
|
||||||
|
<string name="guide_already_allowed">Este deja permis.</string>
|
||||||
|
<string name="guide_scroll_more">Derulează pentru mai multe ↓</string>
|
||||||
|
<string name="guide_allow">Permite</string>
|
||||||
|
<string name="guide_skip">Omite deocamdată</string>
|
||||||
|
<string name="guide_open">Deschide</string>
|
||||||
|
<string name="guide_next">Înainte</string>
|
||||||
|
<string name="guide_done">Finalizat</string>
|
||||||
|
<string name="guide_back">Înapoi</string>
|
||||||
|
<string name="usage_intro">Activează această opțiune, iar Gearslip îți va trimite o notă scurtă atunci când deschizi aplicația și după fiecare călătorie. <b>Nota nu conține niciun nume, cont sau locație. Un număr aleatoriu identifică telefonul tău o singură dată, iar serverul nu păstrează nicio adresă IP.</b> Notele indică mașinile care funcționează și locurile în care celelalte nu funcționează.</string>
|
||||||
|
<string name="usage_send">Trimite observații privind utilizarea</string>
|
||||||
|
<string name="usage_also_share">Distribuie</string>
|
||||||
|
<string name="usage_share_android">Versiunea Android și sistemul de operare</string>
|
||||||
|
<string name="usage_share_phone">Modelul telefonului</string>
|
||||||
|
<string name="usage_share_cars">Fiecare mașină și dacă aceasta este conectată</string>
|
||||||
|
<string name="usage_share_drives">Fiecare călătorie și cum s-a încheiat aceasta</string>
|
||||||
|
<string name="usage_what_sent">Ce se trimite</string>
|
||||||
|
<string name="usage_what_sent_sub">Fiecare câmp, exact așa cum este afișat</string>
|
||||||
|
<string name="usage_hide_note">Ascunde nota</string>
|
||||||
|
<string name="usage_show_note">Afișează nota</string>
|
||||||
|
<string name="usage_example">Exemplu, nu se trimite: o singură călătorie adaugă acest lucru la „sessions”</string>
|
||||||
|
<string name="usage_no_server">Această versiune nu are server de statistici, așa că nu transmite nimic</string>
|
||||||
|
<string name="usage_debug_build">Versiune de depanare: notele sunt păstrate separat și nu sunt niciodată incluse în statistici</string>
|
||||||
|
<string name="worked_title">Gearslip a funcționat cu %1$s</string>
|
||||||
|
<string name="worked_title_unknown">Gearslip a funcționat cu mașina ta</string>
|
||||||
|
<string name="worked_body">Distribui, ca următorul șofer să știe că funcționează? Fără nume, cont sau locație.</string>
|
||||||
|
<string name="worked_not_now">Nu acum</string>
|
||||||
|
<string name="worked_share">Distribuie</string>
|
||||||
|
<string name="recommended_intro">Aceste aplicații gratuite rulează pe ecranul auto Gearslip. Fiecare buton deschide pagina aplicației pe GitHub.</string>
|
||||||
|
<string name="recommended_title">Aplicații recomandate</string>
|
||||||
|
<string name="back">Înapoi</string>
|
||||||
|
<string name="recommended_installed">Instalat</string>
|
||||||
|
<string name="recommended_maps">Hărți</string>
|
||||||
|
<string name="recommended_music">Muzică</string>
|
||||||
|
<string name="recommended_podcasts">Podcasturi</string>
|
||||||
|
<string name="recommended_other">Alte aplicații interesante</string>
|
||||||
|
<string name="recommended_about_organic">Hărți și rute off-line.</string>
|
||||||
|
<string name="recommended_about_vela">Navigare pas cu pas fără Google.</string>
|
||||||
|
<string name="recommended_about_ytm">Un player YouTube Music.</string>
|
||||||
|
<string name="recommended_about_antennapod">Podcasturi, capitole și tot ce ține de ele.</string>
|
||||||
|
<string name="recommended_about_rfsentinel">Detectează dispozitive de urmărire și camere din apropiere.</string>
|
||||||
|
<string name="settings_title">Setări</string>
|
||||||
|
<string name="settings_whats_new">Noutăți</string>
|
||||||
|
<string name="settings_whats_new_sub">Citește notele de lansare ale %1$s pe GitHub</string>
|
||||||
|
<string name="settings_support">Susține Gearslip</string>
|
||||||
|
<string name="settings_support_footer">Gearslip este gratuit, fără reclame și fără urmărire. Sprijinul tău ne ajută să continuăm.</string>
|
||||||
|
<string name="settings_usage_notes">Note privind utilizarea</string>
|
||||||
|
<string name="settings_updates">Actualizări</string>
|
||||||
|
<string name="settings_about">Despre</string>
|
||||||
|
<string name="settings_replay">Vizualizează din nou ghidul de configurare</string>
|
||||||
|
<string name="settings_replay_sub">Parcurge din nou pașii de inițializare</string>
|
||||||
|
<string name="settings_connection">Conexiune</string>
|
||||||
|
<string name="settings_use_in_car">Folosește Gearslip în mașină</string>
|
||||||
|
<string name="settings_use_in_car_on">Gearslip îți răspunde când îl conectezi la mașină</string>
|
||||||
|
<string name="settings_use_in_car_off">Oprit: Android Auto poate prelua conexiunea în locul acestuia</string>
|
||||||
|
<string name="settings_apps">Aplicații</string>
|
||||||
|
<string name="settings_certificate">Certificat</string>
|
||||||
|
<string name="cert_next_connection">Modificările vor intra în vigoare la următoarea conectare.</string>
|
||||||
|
<string name="cert_checking">Se verifică…</string>
|
||||||
|
<string name="cert_valid_until">Valabil până la %1$s</string>
|
||||||
|
<string name="cert_hide_details">Ascunde detaliile</string>
|
||||||
|
<string name="cert_show_details">Afișează detaliile</string>
|
||||||
|
<string name="cert_subject">Subiect</string>
|
||||||
|
<string name="cert_issuer">Emitent</string>
|
||||||
|
<string name="cert_downloading">Se descarcă…</string>
|
||||||
|
<string name="cert_download">Descărcare de pe aasdk</string>
|
||||||
|
<string name="cert_downloaded">Certificatul a fost descărcat.</string>
|
||||||
|
<string name="cert_download_failed">Descărcarea a eșuat: %1$s</string>
|
||||||
|
<string name="cert_download_sub">Certificatul publicat de proiectul open-source aasdk pe GitHub. Majoritatea autoturismelor îl acceptă, dar unele modele fabricate începând cu anul 2020 îl resping</string>
|
||||||
|
<string name="cert_import">Importă propriul meu fișier</string>
|
||||||
|
<string name="cert_import_sub">Un fișier .p12 împreună cu cheia sa privată. Folosește-l pe al tău atunci când mașina ta respinge certificatul aasdk</string>
|
||||||
|
<string name="cert_remove_imported">Elimină certificatul importat</string>
|
||||||
|
<string name="cert_remove_downloaded">Șterge certificatul descărcat</string>
|
||||||
|
<string name="cert_removed_imported">Am eliminat certificatul importat.</string>
|
||||||
|
<string name="cert_removed_downloaded">Am șters certificatul descărcat.</string>
|
||||||
|
<string name="cert_imported">Certificatul a fost importat.</string>
|
||||||
|
<string name="cert_import_failed">Nu s-a putut importa: parolă incorectă sau fișierul nu este de tip PKCS#12 cu cheie privată.</string>
|
||||||
|
<string name="cert_password_title">Parola certificatului</string>
|
||||||
|
<string name="cert_password">Parola</string>
|
||||||
|
<string name="cert_password_hint">Lasă câmpul gol dacă fișierul nu conține niciuna.</string>
|
||||||
|
<string name="cert_import_button">Import</string>
|
||||||
|
<string name="cancel">Renunță</string>
|
||||||
|
<string name="cert_none">Nu este încărcat nimic (semnat automat; mașinile îl resping)</string>
|
||||||
|
<string name="cert_kind_imported">Importat: %1$s</string>
|
||||||
|
<string name="cert_kind_downloaded">Descărcat: %1$s</string>
|
||||||
|
<string name="cert_kind_adb">Transmis prin adb: %1$s</string>
|
||||||
|
<string name="cert_kind_self">Semnat automat: %1$s</string>
|
||||||
|
<string name="car_group_screen">Ecran</string>
|
||||||
|
<string name="car_group_nav_bar">Bara de navigare și playerul</string>
|
||||||
|
<string name="car_group_sound">Sunet și voce</string>
|
||||||
|
<string name="car_group_warnings">Avertismente</string>
|
||||||
|
<string name="car_group_more">Mai multe</string>
|
||||||
|
<string name="choice_size_normal">Normal</string>
|
||||||
|
<string name="choice_size_large">Mare</string>
|
||||||
|
<string name="choice_size_xlarge">Foarte mare</string>
|
||||||
|
<string name="choice_theme_phone">Urmărește telefonul</string>
|
||||||
|
<string name="choice_theme_light">Luminoasă</string>
|
||||||
|
<string name="choice_theme_dark">Întunecată</string>
|
||||||
|
<string name="choice_dark_black">Neagră</string>
|
||||||
|
<string name="choice_dark_black_colour">Negru cu o nuanță de culoare</string>
|
||||||
|
<string name="choice_dark_deep_colour">Culoare intensă</string>
|
||||||
|
<string name="choice_dark_grey">Gri</string>
|
||||||
|
<string name="choice_auto">Automat</string>
|
||||||
|
<string name="choice_night_day">Zi</string>
|
||||||
|
<string name="choice_night_night">Noapte</string>
|
||||||
|
<string name="choice_units_imperial">Imperiale</string>
|
||||||
|
<string name="choice_units_metric">Metrice</string>
|
||||||
|
<string name="choice_nav_show">Afișează întotdeauna</string>
|
||||||
|
<string name="choice_nav_hide_navigating">Ascunde în timpul navigării</string>
|
||||||
|
<string name="choice_nav_hide">Ascunde până când se atinge ecranul</string>
|
||||||
|
<string name="choice_open_home">Ecranul de pornire</string>
|
||||||
|
<string name="summary_player_left">Playerul în stânga</string>
|
||||||
|
<string name="summary_player_right">Playerul în dreapta</string>
|
||||||
|
<string name="summary_usb_audio">Sunet prin Gearslip</string>
|
||||||
|
<string name="summary_bluetooth_audio">Audio Bluetooth</string>
|
||||||
|
<string name="summary_directions_on">Indicații de orientare</string>
|
||||||
|
<string name="summary_directions_off">Indicații de orientare dezactivate</string>
|
||||||
|
<string name="summary_bluetooth_on">Bluetooth activat</string>
|
||||||
|
<string name="summary_bluetooth_off">Bluetooth dezactivat</string>
|
||||||
|
<string name="summary_location_on">Localizare activată</string>
|
||||||
|
<string name="summary_location_off">Localizare dezactivată</string>
|
||||||
|
<string name="web_page">Pagina web</string>
|
||||||
|
<string name="summary_experimental_shown">Caracteristici experimentale afișate</string>
|
||||||
|
<string name="summary_experimental_hidden">Caracteristici experimentale ascunse</string>
|
||||||
|
<string name="home_car">Automobil</string>
|
||||||
|
<string name="car_group_same">Acestea sunt aceleași setări ca și cele din meniul „Setări” ale mașinii. Orice modificare efectuată aici se va reflecta și acolo.</string>
|
||||||
|
<string name="car_open_on_connect">Deschide la conectare</string>
|
||||||
|
<string name="car_size">Dimensiune</string>
|
||||||
|
<string name="car_app_theme">Tema aplicației</string>
|
||||||
|
<string name="car_dark_mode">Mod întunecat</string>
|
||||||
|
<string name="car_buttons">Butoane</string>
|
||||||
|
<string name="car_map_day_night">Harta zi/noapte</string>
|
||||||
|
<string name="car_units">Unități</string>
|
||||||
|
<string name="car_nav_bar">Bara de navigare</string>
|
||||||
|
<string name="car_player_nav_bar">Player în bara de navigare</string>
|
||||||
|
<string name="car_skip_buttons">Butoane de omitere</string>
|
||||||
|
<string name="car_song_title">Titlul melodiei</string>
|
||||||
|
<string name="car_player_beside_map">Playerul de lângă hartă</string>
|
||||||
|
<string name="car_right">Dreapta</string>
|
||||||
|
<string name="car_left">Stânga</string>
|
||||||
|
<string name="car_autoplay">Redare automată la conectare</string>
|
||||||
|
<string name="car_more_lyrics">Alte surse de versuri</string>
|
||||||
|
<string name="car_more_lyrics_note">De asemenea, interoghează BetterLyrics și KuGou, care obțin apoi numele melodiei și al artistului. Opțiunea „Oprit” interoghează doar LRCLIB.</string>
|
||||||
|
<string name="car_spoken_directions">Instrucțiuni vocale</string>
|
||||||
|
<string name="car_spoken_directions_note">Dezactivează dacă vocea aplicației de hărți se aude deja în mașină.</string>
|
||||||
|
<string name="car_phone_speech">Folosește funcția de recunoaștere vocală a telefonului</string>
|
||||||
|
<string name="car_phone_speech_note">Pe majoritatea telefoanelor, aceasta este aplicația Google, care ar putea transmite vocea ta către Google.</string>
|
||||||
|
<string name="car_warn_bluetooth">Avertizează când Bluetooth este dezactivat</string>
|
||||||
|
<string name="car_warn_location">Avertizează dacă localizarea este dezactivată</string>
|
||||||
|
<string name="car_experimental">Caracteristici experimentale</string>
|
||||||
|
<string name="car_vehicle_data">Datele automobilului</string>
|
||||||
|
<string name="car_vehicle_data_note">Adaugă o casetă cu date despre vehicul: o afișare în timp real a datelor de la senzorii mașinii. Numele câmpurilor sunt doar presupuneri până când observi o schimbare a unuia dintre ele.</string>
|
||||||
|
<string name="car_adaptive_video">Calitate video adaptivă</string>
|
||||||
|
<string name="car_adaptive_video_note">Reduce calitatea imaginii atunci când mașina sau rețeaua Wi-Fi a acesteia nu pot face față, o îmbunătățește din nou odată ce acestea revin la normal. Se aplică la următoarea conexiune.</string>
|
||||||
|
<string name="web_page_note">Afișat de aplicația Browser din mașină. Dacă este gol, se deschide DuckDuckGo. Se aplică la următoarea conexiune.</string>
|
||||||
|
<string name="car_audio">Sunet în automobil</string>
|
||||||
|
<string name="car_audio_bt">Bluetooth (recomandat)</string>
|
||||||
|
<string name="car_audio_bt_sub">Nu mai primești nicio indicație la fiecare schimbare de direcție, iar vocea aplicației de hărți se aude direct în mașină</string>
|
||||||
|
<string name="car_audio_usb_sub">Un sunet mai clar, fără întârziere. La fiecare pornire, Android îți cere să partajezi ecranul, ceea ce permite aplicației Gearslip să transmită sunetul</string>
|
||||||
|
<string name="car_audio_usb_footer">Execută o singură dată comanda din secțiunea „Ajutor”, iar Android nu va mai afișa mesajul respectiv.</string>
|
||||||
|
<string name="car_audio_bt_footer">În ambele cazuri, apelurile se efectuează prin Bluetooth.</string>
|
||||||
|
<string name="screen_car_size">Dimensiunea ecranului mașinii</string>
|
||||||
|
<string name="screen_use_cars">Ca în automobil</string>
|
||||||
|
<string name="screen_preview_size">Dimensiunea previzualizării</string>
|
||||||
|
<string name="screen_preview_note">Dimensiunea la care se afișează previzualizarea, de exemplu 1920x720.</string>
|
||||||
|
<string name="screen_car_screen">Ecranul mașinii</string>
|
||||||
|
<string name="screen_footer">Gearslip utilizează dimensiunea indicată de mașină, cu excepția cazului în care setezi una.</string>
|
||||||
|
<string name="screen_size">Dimensiunea ecranului</string>
|
||||||
|
<string name="screen_from_car">Din mașină</string>
|
||||||
|
<string name="screen_crop">Decupează marginile</string>
|
||||||
|
<string name="screen_crop_sub">Pentru o mașină la care se taie partea de sus și cea de jos sau părțile laterale</string>
|
||||||
|
<string name="screen_squash">Reducere la dimensiune</string>
|
||||||
|
<string name="screen_squash_sub">Pentru o mașină care comprimă întreaga imagine, astfel încât cercurile să pară ovale</string>
|
||||||
|
<string name="screen_preview_title">Dimensiunea previzualizării mașinii</string>
|
||||||
|
<string name="screen_last_car">Ultimul automobil</string>
|
||||||
|
<string name="screen_last_car_sub">Dimensiunea ultimei mașini la care te-ai conectat</string>
|
||||||
|
<string name="screen_this_screen">Acest ecran</string>
|
||||||
|
<string name="screen_this_screen_sub">Ocupă întregul ecran al acestui dispozitiv, în cazul unui sistem de bord care rulează chiar aplicația Gearslip</string>
|
||||||
|
<string name="screen_custom">Personalizat</string>
|
||||||
|
<string name="screen_enter_size">Introdu o dimensiune</string>
|
||||||
|
<string name="screen_change_custom">Modifică dimensiunea personalizată</string>
|
||||||
|
<string name="screen_width">Lățime</string>
|
||||||
|
<string name="screen_height">Înălțime</string>
|
||||||
|
<string name="save">Salvează</string>
|
||||||
|
<string name="screen_car_size_note">Folosește această opțiune dacă imaginea mașinii pare întinsă. Introdu dimensiunea reală a ecranului, de exemplu 1280x480. Setarea va intra în vigoare la următoarea conectare.</string>
|
||||||
|
<string name="screen_set_by_you">%1$s, stabilit de tine</string>
|
||||||
|
<string name="help_title">Cum se conectează</string>
|
||||||
|
<string name="help_which_cars">Ce mașini sunt potrivite</string>
|
||||||
|
<string name="help_full_list">Vezi lista completă</string>
|
||||||
|
<string name="help_first_drive">Înainte de prima călătorie</string>
|
||||||
|
<string name="help_in_car">În mașină</string>
|
||||||
|
<string name="help_usb_audio">Sunet prin Gearslip</string>
|
||||||
|
<string name="help_aa_off">Dezactivează Android Auto.</string>
|
||||||
|
<string name="help_aa_off_detail">Dezactivează sau dezinstalează aplicația Android Auto, astfel încât mașina să comunice cu Gearslip.</string>
|
||||||
|
<string name="help_cert">Încarcă un certificat.</string>
|
||||||
|
<string name="help_cert_detail">În Setări, descarcă certificatul aasdk, care este acceptat de majoritatea mașinilor. Dacă mașina îl respinge, importă-l pe al tău în locul acestuia.</string>
|
||||||
|
<string name="help_mic">Autorizează microfonul.</string>
|
||||||
|
<string name="help_mic_detail">Android nu permite unei aplicații să transmită muzică către mașină fără aceasta. Gearslip ascultă doar atunci când atingi microfonul sau apeși butonul de convorbire de pe volan.</string>
|
||||||
|
<string name="help_plug_in">Conectează-te prin cablu sau fără fir.</string>
|
||||||
|
<string name="help_plug_in_detail">Gearslip se deschide automat atunci când mașina se conectează. Pentru conectarea fără fir, asociază mai întâi telefonul cu mașina, apoi atinge opțiunea „Conectare fără fir” din ecranul principal.</string>
|
||||||
|
<string name="help_pair">Conectează-te prin Bluetooth pentru apeluri.</string>
|
||||||
|
<string name="help_pair_detail">Apelurile se redau prin sistemul audio al mașinii odată ce telefonul și mașina sunt conectate. Aplicația Gearslip îți propune să le asociezi încă de la prima conectare.</string>
|
||||||
|
<string name="help_answer">Răspunde la apeluri de pe ecranul mașinii.</string>
|
||||||
|
<string name="help_answer_detail">Setează Gearslip ca aplicație pentru identificarea apelantului și blocarea apelurilor nedorite. Fără aceasta, apelurile continuă să sune, dar numai telefonul le poate prelua.</string>
|
||||||
|
<string name="help_prompt">Elimină solicitarea de confirmare audio la fiecare călătorie.</string>
|
||||||
|
<string name="help_prompt_detail">Conectează telefonul la un computer cu opțiunea de depanare USB activată și execută această operațiune o singură dată. Dacă o omiți, va trebui să apeși pe „Start” de fiecare dată când pornești.</string>
|
||||||
|
<string name="help_readable">Păstrează notificările lizibile.</string>
|
||||||
|
<string name="help_readable_detail">Android ascunde conținutul notificărilor în timp ce redă sunet. Dacă rulezi și această aplicație, Gearslip va afișa conținutul notificărilor doar cât timp muzica este transmisă către mașină.</string>
|
||||||
|
<string name="help_which_cars_body">Majoritatea mașinilor acceptă certificatul aasdk. Începând cu anul 2020, unele mașini au introdus verificări de securitate mai noi care îl resping, iar în cazul acestor mașini, Gearslip are nevoie de un certificat propriu.</string>
|
||||||
|
<string name="update_downloaded_ask">S-a descărcat. Android te întreabă înainte de instalare.</string>
|
||||||
|
<string name="update_install">Instalează</string>
|
||||||
|
<string name="update_download_now">Descarcă-l acum. Android te întreabă înainte de instalare.</string>
|
||||||
|
<string name="update_try_again">Reîncearcă</string>
|
||||||
|
<string name="update_update">Actualizează</string>
|
||||||
|
<string name="update_checking">Se verifică…</string>
|
||||||
|
<string name="update_latest">Ai cea mai recentă versiune</string>
|
||||||
|
<string name="update_download">Desarcă</string>
|
||||||
|
<string name="update_check_now">Verifică acum</string>
|
||||||
|
<string name="update_check_auto">Verificare automată</string>
|
||||||
|
<string name="update_once_a_day">O dată pe zi</string>
|
||||||
|
<string name="update_unplug_first">Deconectează cablul de la mașină pentru a efectua instalarea. Instalarea va închide aplicația Gearslip.</string>
|
||||||
|
<string name="update_card_title">Gearslip %1$s a fost lansat</string>
|
||||||
|
<string name="update_downloading">Se descarcă… %1$d%%</string>
|
||||||
|
<string name="update_debug_manual">Versiunile de depanare se verifică numai atunci când apeși pe „Verifică acum”</string>
|
||||||
|
<string name="update_checks_daily">Gearslip efectuează verificări o dată pe zi</string>
|
||||||
|
<string name="update_version_out">A fost lansată versiunea %1$s</string>
|
||||||
|
<string name="update_version_ready">Versiunea %1$s este gata de instalare</string>
|
||||||
|
<string name="update_dialog_status">Versiunea %1$s. %2$s.</string>
|
||||||
|
<string name="about_give_regularly">Donează săptămânal sau lunar</string>
|
||||||
|
<string name="about_bmac_sub" formatted="false">Donează o singură dată sau lunar, cu cardul. Buy Me a Coffee reține 5%</string>
|
||||||
|
<string name="about_give_once">Donează o singură dată</string>
|
||||||
|
<string name="about_any_amount">Orice sumă, cu cardul</string>
|
||||||
|
<string name="about_monero_sub">Deschide portofelul sau copiază adresa</string>
|
||||||
|
<string name="about_monero_title">Suport prin Monero</string>
|
||||||
|
<string name="about_monero_body">Trimite orice sumă la această adresă din portofelul tău Monero.</string>
|
||||||
|
<string name="about_open_wallet">Deschide în portofel</string>
|
||||||
|
<string name="about_copy_address">Copiază adresa</string>
|
||||||
|
<string name="close">Închide</string>
|
||||||
|
<string name="about_matrix">Alătură-te discuției pe Matrix</string>
|
||||||
|
<string name="about_matrix_sub">Discută cu alți șoferi despre mașini, aplicații și Gearslip</string>
|
||||||
|
<string name="about_translate">Ajută la traducerea Gearslip</string>
|
||||||
|
<string name="about_translate_sub">Corectează sau adaugă limba ta pe Weblate. Unele traduceri au fost inițial realizate automat.</string>
|
||||||
|
<string name="about_source">Cod sursă</string>
|
||||||
|
<string name="about_source_sub">Citește sau modifică codul sursă Gearslip în conformitate cu licența AGPL</string>
|
||||||
|
<string name="about_voice_credits">Mulțumiri pentru voce</string>
|
||||||
|
<string name="about_no_wallet">Nu s-a găsit nicio aplicație de portofel Monero. Copiază adresa în schimb.</string>
|
||||||
|
<string name="about_monero_copied">Adresa Monero a fost copiată</string>
|
||||||
|
<string name="about_voice_credits_sub">Recunoașterea vocală realizată de sherpa-onnx (Apache 2.0), cu Kroko ASR de la Banafo (CC BY-SA 4.0) și Parakeet și Canary de la NVIDIA (CC BY 4.0)</string>
|
||||||
|
<string name="certsetup_title">Configurarea certificatului</string>
|
||||||
|
<string name="certsetup_installed">Certificatul a fost instalat.</string>
|
||||||
|
<string name="certsetup_continue">Continuă</string>
|
||||||
|
<string name="certsetup_failed">Descărcare eșuată</string>
|
||||||
|
<string name="certsetup_retry">Reîncearcă</string>
|
||||||
|
<string name="certsetup_import_sub">Un fișier .p12 împreună cu cheia sa privată. Folosește-l pe al tău atunci când mașina ta respinge certificatul aasdk.</string>
|
||||||
|
<string name="certsetup_skip">Omite configurarea conexiunii auto</string>
|
||||||
|
<string name="certsetup_loaded">Mașinile acceptă doar certificate emise de Google, iar tu ai unul încărcat.</string>
|
||||||
|
<string name="certsetup_choose">Mașinile acceptă doar certificate emise de Google. Gearslip nu include unul, așa că alege de unde provine al tău.</string>
|
||||||
|
<string name="certsetup_aasdk_sub">Proiectul open-source aasdk publică acest certificat la adresa github.com/opencardev/aasdk. Majoritatea mașinilor îl acceptă. Unele modele fabricate începând cu anul 2020 îl resping, în acest caz va trebui să-l imporți pe al tău.</string>
|
||||||
|
<string name="storage_title">Stocarea Gearslip</string>
|
||||||
|
<string name="storage_measuring">Măsurare…</string>
|
||||||
|
<string name="storage_lyrics">Versuri</string>
|
||||||
|
<string name="storage_clear">Elimină</string>
|
||||||
|
<string name="storage_voice">Modele vocale</string>
|
||||||
|
<string name="storage_remove">Elimină</string>
|
||||||
|
<string name="storage_logs">Jurnale vechi</string>
|
||||||
|
<string name="storage_other">Alte fișiere temporare</string>
|
||||||
|
<string name="storage_delete_all">Șterge toate datele Gearslip</string>
|
||||||
|
<string name="storage_total">Datele aplicației Gearslip ocupă %1$s din spațiul de stocare al telefonului.</string>
|
||||||
|
<plurals name="storage_lyrics_detail">
|
||||||
|
<item quantity="one">%1$d melodie · %2$s. Versurile șterse se descarcă din nou atunci când le deschizi.</item>
|
||||||
|
<item quantity="few">%1$d melodii · %2$s. Versurile șterse se descarcă din nou atunci când le deschizi.</item>
|
||||||
|
<item quantity="other">%1$d de melodii · %2$s. Versurile șterse se descarcă din nou atunci când le deschizi.</item>
|
||||||
|
</plurals>
|
||||||
|
<string name="storage_voice_detail">%1$s. Comenzile vocale au nevoie de ele; descarcă-le din nou în Setări.</string>
|
||||||
|
<string name="storage_logs_detail">%1$s. Jurnalele lansărilor anterioare; jurnalul acestei lansări rămâne.</string>
|
||||||
|
<string name="storage_other_detail">%1$s. Actualizările descărcate și alte fișiere pe care Gearslip le poate genera din nou.</string>
|
||||||
|
<string name="storage_delete_armed">Atinge din Șterge pentru a șterge setările, certificatele, modelele vocale și jurnalele. Gearslip se închide și, data viitoare, va porni din ecranul de configurare.</string>
|
||||||
|
<string name="storage_delete_sub">Setări, certificate, modele vocale, versuri și jurnale.</string>
|
||||||
|
<string name="storage_delete">Șterge</string>
|
||||||
|
<string name="storage_delete_ellipsis">Ștergere…</string>
|
||||||
|
<string name="donate_title">Mă bucur că călătoria a fost reușită</string>
|
||||||
|
<string name="donate_liberapay">Sprijin pe Liberapay</string>
|
||||||
|
<string name="donate_bmac">Cumpără-mi o cafea</string>
|
||||||
|
<string name="donate_card">Fă o donație unică cu cardu</string>
|
||||||
|
<string name="donate_body">Gearslip este gratuit, fără reclame și fără urmărire. Dacă te ajută să pornești la drum, sprijinul tău îi permite să continue să funcționeze.</string>
|
||||||
|
<string name="wireless_needs_permission">Pentru aceasta, Gearslip are nevoie de conexiune Bluetooth și de acces la o rețea Wi-Fi din apropiere.</string>
|
||||||
|
<string name="wireless_enable_permission">Activează permisiunea „Dispozitive din apropiere”</string>
|
||||||
|
<string name="wireless_connect">Conectare fără fir</string>
|
||||||
|
<string name="wireless_which_car">Ce mașină?</string>
|
||||||
|
<string name="wireless_none_paired">Telefonul tău nu este încă asociat cu nimic. Asociază-l mai întâi cu mașina.</string>
|
||||||
|
<string name="wireless_not_listed">Mașina nu apare în listă? Conecteaz-o mai întâi la telefon.</string>
|
||||||
|
<string name="bluetooth_settings">Setări Bluetooth</string>
|
||||||
|
<string name="voice_live_title">Voce: cuvinte în direct</string>
|
||||||
|
<string name="voice_final_title">Voce: cuvintele de încheiere</string>
|
||||||
|
<string name="voice_none">Nimic</string>
|
||||||
|
<string name="voice_none_sub">Păstrează cuvintele din textul original. Mai rapid, mai puțin precis</string>
|
||||||
|
<string name="voice_live_body">Asistentul vocal transformă vorbirea în text pe acest telefon, iar nimic din ceea ce spui nu părăsește dispozitivul. Are nevoie de două modele, care ocupă împreună aproximativ 270 MB, descărcate o singură dată. Acesta îți transcrie cuvintele pe măsură ce vorbești.</string>
|
||||||
|
<string name="voice_final_body">Când încetezi să mai vorbești, acest model citește din nou întreaga frază. El identifică numele și locurile pe care modelul în timp real nu le sesizează.</string>
|
||||||
|
<string name="voice_model_size">%1$s · %2$d MB</string>
|
||||||
|
<string name="voice_downloaded">Descărcat. %1$s</string>
|
||||||
|
<string name="voice_downloading">Se descarcă, %1$d%%</string>
|
||||||
|
<string name="home_help">Ajutor</string>
|
||||||
|
<string name="home_preview">Previzualizare</string>
|
||||||
|
<string name="home_logs">Jurnale</string>
|
||||||
|
<string name="home_disconnect">Deconectează</string>
|
||||||
|
<string name="home_bt_title">Bluetooth-ul nu este conectat</string>
|
||||||
|
<string name="home_debug_build">%1$s · versiune de depanare</string>
|
||||||
|
<string name="home_bt_body">Muzica se redă prin Bluetooth, dar telefonul tău nu este conectat la mașină. Conectează-l de aici sau din meniul Bluetooth al mașinii.</string>
|
||||||
|
<string name="home_voice_downloading">Se descarcă vocea, %1$d%%</string>
|
||||||
|
<string name="audio_bt_title">Se redă muzică din sistemul audio al mașinii prin Bluetooth?</string>
|
||||||
|
<string name="audio_never_ask">Nu întreba niciodată, folosind un computer</string>
|
||||||
|
<string name="audio_use_bt">Folosește Bluetooth</string>
|
||||||
|
<string name="audio_keep_asking">Continuă interogarea</string>
|
||||||
|
<string name="audio_mic_title">Sistemul audio al mașinii necesită permisiunea de acces la microfon</string>
|
||||||
|
<string name="audio_open_settings">Deschide setările aplicației</string>
|
||||||
|
<string name="audio_bt_body">Android tratează muzica pe care Gearslip o transmite către mașină ca pe o partajare a ecranului, așa că solicită permisiunea la fiecare călătorie, fie că se face prin cablu, fie wireless. Prin Bluetooth nu solicită niciodată permisiunea. Poți modifica această setare ulterior în Setări.</string>
|
||||||
|
<string name="audio_keep_usb">Sau poți menține redarea sunetului prin Gearslip și să rezolvi definitiv problema: conectează telefonul la un computer cu opțiunea de depanare USB activată și rulează această procedură o singură dată.</string>
|
||||||
|
<string name="audio_mic_body">Android permite unei aplicații să transmită sunetul altor aplicații către mașină numai dacă are permisiunea de acces la microfon. Această opțiune este dezactivată pentru Gearslip, așa că muzica se redă pe telefon. Activează-o din secțiunea „Permisiuni”, apoi „Microfon”.</string>
|
||||||
|
<string name="typing_close">Închide tastatura mașinii</string>
|
||||||
|
<string name="typing_label">Introducere pe ecranul mașinii</string>
|
||||||
|
<string name="typing_search">Căutare pe ecranul mașinii</string>
|
||||||
|
<string name="command_tap_copy">Atinge pentru a copia</string>
|
||||||
|
<string name="command_copied">Comanda a fost copiată</string>
|
||||||
|
<string name="whats_new_this_version">%1$s (această versiune)</string>
|
||||||
|
<string name="carset_theme">Temă</string>
|
||||||
|
<string name="carset_imperial">Imperiale (mi, ft, °F)</string>
|
||||||
|
<string name="carset_metric">Metrice (km, m, °C)</string>
|
||||||
|
<string name="carset_screen_size">Dimensiunea ecranului (următoarea conexiune)</string>
|
||||||
|
<string name="carset_fit">Ajustare la ecran</string>
|
||||||
|
<string name="off">Oprit</string>
|
||||||
|
<string name="on">Pornit</string>
|
||||||
|
<string name="carset_lyrics_from">Versuri din</string>
|
||||||
|
<string name="carset_more_sources">Alte surse</string>
|
||||||
|
<string name="carset_hide">Ascunde</string>
|
||||||
|
<string name="carset_show">Afișează</string>
|
||||||
|
<string name="carset_phone">Telefon</string>
|
||||||
|
<string name="carset_vehicle">Vehicul</string>
|
||||||
|
<string name="carset_video">Video</string>
|
||||||
|
<string name="carset_version">Versiune</string>
|
||||||
|
<string name="carset_build">Asamblare</string>
|
||||||
|
<string name="carset_whats_new_after">Afișează noutățile după actualizări</string>
|
||||||
|
<string name="carset_whats_new_after_sub">Afișează lista modificărilor o singură dată, la prima conectare a mașinii după o actualizare.</string>
|
||||||
|
<string name="carset_web_default">DuckDuckGo (implicit) – atinge pentru a seta o altă pagină</string>
|
||||||
|
<string name="carset_web_note">Se afișează în aplicația Browser din meniul de lansare. Se aplică la următoarea conexiune.</string>
|
||||||
|
<string name="carset_screen_fit">Potrivire ecran</string>
|
||||||
|
<string name="carset_top">Sus</string>
|
||||||
|
<string name="carset_bottom">Jos</string>
|
||||||
|
<string name="carset_save_vehicle">Salvează acest vehicul</string>
|
||||||
|
<string name="carset_no_profile">Nu s-a găsit niciun profil de vehicul care să corespundă</string>
|
||||||
|
<string name="carset_audio_note">Prin intermediul aplicației Gearslip, sunetul telefonului este transmis prin conexiunea auto, fie prin cablu, fie fără fir. Sistemul Android îți solicită să partajezi ecranul la fiecare călătorie, iar unele aplicații blochează această funcție. Prin Bluetooth, sunetul se redă așa cum ar face-o în mod normal telefonul, iar sistemul Android nu îți solicită niciodată permisiunea.</string>
|
||||||
|
<string name="carset_directions_note">Gearslip citește cu voce tare fiecare viraj prin difuzoarele mașinii, iar volumul muzicii scade pe durata citirii. Dezactivează dacă vocea aplicației tale de hărți se aude deja în mașină.</string>
|
||||||
|
<string name="carset_phone_speech_note">Utilizează serviciul de recunoaștere vocală al telefonului în locul celui al aplicației Gearslip. Pe majoritatea telefoanelor, acesta este serviciul Google, care ar putea transmite vocea ta către Google. GrapheneOS nu dispune de un astfel de serviciu, așa că Gearslip continuă să-l folosească pe al său.</string>
|
||||||
|
<string name="carset_vehicle_data_note">Adaugă o casetă cu date despre vehicul: un flux în timp real, neprelucrat, provenit de la canalul senzorului unității principale. Etichetele câmpurilor (viteză, turație, combustibil...) sunt preluate din alte proiecte de inginerie inversă și nu au fost verificate în cazul acestui autoturism — tratează-le ca pe o estimare, nu ca pe un fapt cert, până când nu observi personal o schimbare a uneia dintre ele.</string>
|
||||||
|
<string name="carset_adaptive_note">Reduce calitatea imaginii atunci când mașina sau rețeaua Wi-Fi a acesteia nu reușesc să țină pasul și o restabilește odată ce acestea revin la normal. Setarea intră în vigoare la următoarea conexiune.</string>
|
||||||
|
<string name="carset_usable_height">Înălțime utilă %1$dpx din 480</string>
|
||||||
|
<string name="carset_saved_to">Salvat în %1$s</string>
|
||||||
|
<string name="carset_save_failed">Nu s-a putut salva</string>
|
||||||
|
<string name="carset_px">%1$dpx</string>
|
||||||
|
<string name="media_open_on_phone">Deschide pe telefon</string>
|
||||||
|
<string name="media_now_playing">Se redă acum</string>
|
||||||
|
<string name="media_up_next">Urmează</string>
|
||||||
|
<string name="media_browse">Răsfoire</string>
|
||||||
|
<string name="media_search_hint">Introdu numele unei melodii, al unui artist, al unui album sau al unei liste de redare.</string>
|
||||||
|
<string name="media_looking_lyrics">Se caută versuri…</string>
|
||||||
|
<string name="media_nothing_playing">Nu se redă nimic</string>
|
||||||
|
<string name="media_press_play">Apasă pe „Redare” pentru a continua de unde ai rămas sau caută ceva în secțiunea „Răsfoire”.</string>
|
||||||
|
<string name="media_keeps_library">%1$s nu își deschide biblioteca.</string>
|
||||||
|
<string name="media_needs_access">Gearslip poate continua să controleze aplicația odată ce are acces la notificări pe telefon. Permite acest lucru din Setări > Notificări > Acces la notificări.</string>
|
||||||
|
<string name="media_not_running">%1$s nu rulează.</string>
|
||||||
|
<string name="media_not_running_body">%1$s se redă prin playerul propriu al aplicației Gearslip, dar numai cât timp aceasta este deschisă pe telefon. Deschide o dată și va continua să ruleze aici, în mașină.</string>
|
||||||
|
<string name="media_open_app">Deschide %1$s</string>
|
||||||
|
<string name="media_refused">%1$s nu i-a permis aplicației Gearslip să o răsfoiască.</string>
|
||||||
|
<string name="media_refused_body">Unele aplicații media acceptă doar serverul propriu al Google.</string>
|
||||||
|
<string name="media_connecting">Se stabilește conexiunea cu %1$s…</string>
|
||||||
|
<string name="media_search">Caută: %1$s</string>
|
||||||
|
<string name="media_search_no_list">%1$s nu poate afișa rezultatele. Apasă pe „Căutare”, iar %1$s va alege ce să redea.</string>
|
||||||
|
<string name="media_search_unsupported">%1$s nu poate efectua căutări din mașină.</string>
|
||||||
|
<string name="media_searching">Căutare %1$s…</string>
|
||||||
|
<string name="media_search_nothing">%1$s nu a găsit nimic pentru „%2$s”.</string>
|
||||||
|
<string name="media_search_ignored">%1$s nu a redat nimic pentru „%2$s”.</string>
|
||||||
|
<string name="media_searching_for">Se caută „%2$s” în %1$s…</string>
|
||||||
|
<string name="media_nothing_playing_dot">Nu se redă nimic.</string>
|
||||||
|
<string name="media_no_lyrics">Nu s-au găsit versuri pentru această piesă.</string>
|
||||||
|
<string name="media_up">Sus</string>
|
||||||
|
<string name="media_loading">Se încarcă…</string>
|
||||||
|
<string name="media_browse_start">Pornește muzica în aplicația de pe telefon. De aici poți reda, pune pe pauză, omite melodiile și naviga prin ele.</string>
|
||||||
|
<string name="media_load_failed">Nu s-a putut încărca această listă.</string>
|
||||||
|
<string name="media_nothing_here">Nimic aici.</string>
|
||||||
|
<string name="media_nothing_queued">Nu există nimic în coadă.</string>
|
||||||
|
<string name="media_play">Redare</string>
|
||||||
|
<string name="media_queue_empty_idle">Nu se redă nimic – nu toate aplicațiile îi comunică aplicației Gearslip ce urmează.</string>
|
||||||
|
<string name="media_queue_empty_playing">Doar ceea ce se redă în prezent – nu toate aplicațiile îi comunică aplicației Gearslip ce urmează.</string>
|
||||||
|
<string name="player_previous">Anterior</string>
|
||||||
|
<string name="player_next">Înainte</string>
|
||||||
|
<string name="player_repeat">Repetare</string>
|
||||||
|
<string name="player_pause">Pauză</string>
|
||||||
|
<string name="player_unlike">Nu aprecia</string>
|
||||||
|
<string name="player_like">Apreciază</string>
|
||||||
|
<string name="player_shuffle_off">Amestecarea este oprită</string>
|
||||||
|
<string name="player_shuffle_on">Amestecare pornită</string>
|
||||||
|
<string name="home_choose_app">Alege o altă aplicație</string>
|
||||||
|
<string name="home_no_maps">Nu s-au găsit aplicații de hărți.</string>
|
||||||
|
<string name="home_no_maps_body">Instalează o aplicație de navigație compatibilă cu Android Auto și aceasta se va porni automat de aici.</string>
|
||||||
|
<string name="home_map_stopped">Harta s-a oprit</string>
|
||||||
|
<string name="home_map_stopped_body">Rămâne închisă până când o deschizi din nou.</string>
|
||||||
|
<string name="home_reopen_map">Redeschide harta</string>
|
||||||
|
<string name="home_car_apps">Aplicațiile mașinii</string>
|
||||||
|
<string name="home_back_nav">Înapoi la bara de navigare</string>
|
||||||
|
<string name="home_show_player">Afișează playerul</string>
|
||||||
|
<string name="home_hide_lyrics">Ascunde versurile</string>
|
||||||
|
<string name="home_starting">Pornește %1$s…</string>
|
||||||
|
<string name="home_starting_map">Se încarcă harta…</string>
|
||||||
|
<string name="home_failed">%1$s nu a pornit.</string>
|
||||||
|
<string name="home_failed_map">Aplicația de hărți nu s-a deschis.</string>
|
||||||
|
<string name="crumb_home">Ecranul de pornire</string>
|
||||||
|
<string name="crumb_media">Media</string>
|
||||||
|
<string name="crumb_apps">Aplicații</string>
|
||||||
|
<string name="crumb_dashboard">Tablou de bord</string>
|
||||||
|
<string name="crumb_weather">Vreme</string>
|
||||||
|
<string name="crumb_reply">Răspunde</string>
|
||||||
|
<string name="app_browser">Browser</string>
|
||||||
|
<string name="app_screen_sharing">Partajarea ecranului</string>
|
||||||
|
<string name="app_exit">Ieșire</string>
|
||||||
|
<string name="navapps_unpin">Dezlipește din bara de navigare</string>
|
||||||
|
<string name="navapps_full">Bara de navigare este plină (%1$d fixate)</string>
|
||||||
|
<string name="navapps_pin">Fixează în bara de navigare</string>
|
||||||
|
<string name="navapps_close">Închide aplicația</string>
|
||||||
|
<string name="navapps_move_left">Deplasează spre stânga</string>
|
||||||
|
<string name="navapps_move_right">Deplasează spre dreapta</string>
|
||||||
|
<string name="navapps_reset">Resetează ordinea aplicațiilor</string>
|
||||||
|
<string name="msg_dismiss">Respinge</string>
|
||||||
|
<string name="msg_read">Citește</string>
|
||||||
|
<string name="msg_stop">Oprește</string>
|
||||||
|
<string name="msg_no">Nu</string>
|
||||||
|
<string name="msg_send_now">Trimite acum</string>
|
||||||
|
<string name="msg_type">Formează</string>
|
||||||
|
<plurals name="msg_burst">
|
||||||
|
<item quantity="one">%1$s · %2$d nouă</item>
|
||||||
|
<item quantity="few">%1$s · %2$d noi</item>
|
||||||
|
<item quantity="other">%1$s · %2$d noi</item>
|
||||||
|
</plurals>
|
||||||
|
<string name="msg_reply_to">Răspunde %1$s</string>
|
||||||
|
<string name="msg_listening">Ascult</string>
|
||||||
|
<string name="msg_sending_to">Se trimite către %1$s</string>
|
||||||
|
<string name="msg_sent_to">Trimis către %1$s</string>
|
||||||
|
<string name="msg_reply_failed">Nu am putut răspunde</string>
|
||||||
|
<string name="bt_pair_unfinished">Conectarea nu s-a finalizat. Realizează conectarea din meniul Bluetooth al mașinii.</string>
|
||||||
|
<string name="bt_pair_not_started">Conectarea nu a început. Realizează conectarea din meniul Bluetooth al mașinii.</string>
|
||||||
|
<string name="bt_paired">Conectat. Apelurile se aud acum prin sistemul audio al mașinii.</string>
|
||||||
|
<string name="overlay_dont_show">Nu mai afișa</string>
|
||||||
|
<string name="overlay_ok">OK</string>
|
||||||
|
<string name="overlay_location_off">Locația este dezactivată</string>
|
||||||
|
<string name="overlay_pair_title">Asociere cu această mașină?</string>
|
||||||
|
<string name="overlay_pair">Asociere</string>
|
||||||
|
<string name="overlay_confirm_title">Confirmă pe telefonul tău</string>
|
||||||
|
<string name="overlay_confirm_body">Verifică codul de pe telefon și atinge „Asociere”. Este posibil ca și mașina să solicite acest lucru.</string>
|
||||||
|
<string name="overlay_bt_body">Muzica se redă prin Bluetooth, dar telefonul tău nu este conectat la mașină. Conectează-l din meniul Bluetooth al mașinii sau din cel al telefonului tău.</string>
|
||||||
|
<string name="overlay_location_body">Aplicațiile de hărți nu pot afișa locația ta și nici nu pot oferi indicații de orientare. Activează funcția de localizare din setările rapide ale telefonului atunci când ești parcat.</string>
|
||||||
|
<string name="overlay_pair_body_music">Apelurile și muzica se redau prin difuzoarele mașinii odată ce telefonul și mașina sunt conectate. Apoi, telefonul îți va solicita să confirmi un cod.</string>
|
||||||
|
<string name="overlay_pair_body">Apelurile se aud prin difuzoarele mașinii odată ce telefonul și mașina sunt conectate. Apoi, telefonul îți cere să confirmi un cod.</string>
|
||||||
|
<string name="weather_waiting_fix">Se așteaptă determinarea poziției.</string>
|
||||||
|
<string name="weather_allow_location">Permite accesul la locație pentru aplicația Gearslip de pe telefon, pentru a vedea prognoza meteo.</string>
|
||||||
|
<string name="weather_unreachable">Nu s-a putut stabili conexiunea cu serviciul de prognoză meteo.</string>
|
||||||
|
<string name="weather_clear">Senin</string>
|
||||||
|
<string name="weather_mostly_clear">În mare parte senin</string>
|
||||||
|
<string name="weather_partly_cloudy">Parțial înnorat</string>
|
||||||
|
<string name="weather_overcast">Cer înnorat</string>
|
||||||
|
<string name="weather_fog">Ceață</string>
|
||||||
|
<string name="weather_drizzle">Ploaie măruntă</string>
|
||||||
|
<string name="weather_rain">Ploaie</string>
|
||||||
|
<string name="weather_snow">Zăpadă</string>
|
||||||
|
<string name="weather_rain_showers">Averse de ploaie</string>
|
||||||
|
<string name="weather_snow_showers">Averse de zăpadă</string>
|
||||||
|
<string name="weather_thunderstorm">Furtună cu fulgere</string>
|
||||||
|
<string name="weather_getting">Se obțin datele meteo…</string>
|
||||||
|
<string name="weather_next_hours">Următoarele ore</string>
|
||||||
|
<string name="weather_this_week">Această săptămână</string>
|
||||||
|
<string name="weather_today">Astăzi</string>
|
||||||
|
<string name="weather_credit">Date meteo furnizate de Open-Meteo.com (CC BY 4.0)</string>
|
||||||
|
<string name="weather_feels_like">Se simte ca %1$s</string>
|
||||||
|
<string name="weather_humidity">Umiditate %1$d%%</string>
|
||||||
|
<string name="weather_wind">Vânt %1$s</string>
|
||||||
|
<string name="dash_today">Astăzi</string>
|
||||||
|
<string name="dash_getting_weather">Se obțin datele meteo…</string>
|
||||||
|
<string name="dash_coming_up">În curând</string>
|
||||||
|
<string name="dash_loading_calendar">Se încarcă calendarul…</string>
|
||||||
|
<string name="dash_nothing_week">Nu este nimic programat săptămâna viitoare.</string>
|
||||||
|
<string name="dash_all_day">Toată ziua</string>
|
||||||
|
<string name="dialer_contacts">Contacte</string>
|
||||||
|
<string name="dialer_keypad">Tastatură numerică</string>
|
||||||
|
<string name="dialer_ask">Întreabă pe telefon</string>
|
||||||
|
<string name="dialer_enter_number">Introdu un număr</string>
|
||||||
|
<string name="dialer_call">Apelează</string>
|
||||||
|
<string name="dialer_no_contacts">Nu s-au găsit contacte cu acest număr de telefon.</string>
|
||||||
|
<string name="dialer_search">Căutare contacte</string>
|
||||||
|
<string name="dialer_needs_permission">Aplicația Gearslip are nevoie de permisiunile „Telefon” și „Contacte” pentru a putea efectua apeluri de aici. Acordă-i aceste permisiuni pe telefon atunci când ești parcat.</string>
|
||||||
|
<string name="dialer_no_match">Nu există contacte care să corespundă cu „%1$s”.</string>
|
||||||
|
<string name="dialer_call_who">Suni pe %1$s?</string>
|
||||||
|
<string name="carui_exit_fullscreen">Ieșire din modul ecran complet</string>
|
||||||
|
<string name="carui_drive_safely">Condu cu prudență</string>
|
||||||
|
<string name="carui_understand">Am înțeles</string>
|
||||||
|
<string name="carui_unofficial">Gearslip este un program neoficial: nu este creat sau verificat de niciun producător auto și poate prezenta defecțiuni fără avertisment.</string>
|
||||||
|
<string name="carui_never_interact">Nu interacționa niciodată cu acest ecran în timp ce conduci. Setează traseul sau melodia înainte de a porni la drum, apoi concentrează-ți atenția asupra drumului.</string>
|
||||||
|
<string name="carui_this_app">Această aplicație</string>
|
||||||
|
<string name="carui_app_unsupported">%1$s nu funcționează încă cu Gearslip.</string>
|
||||||
|
<string name="carui_app_unsupported_body">Nu s-a pornit. Unele aplicații funcționează doar pe Android Auto de la Google.</string>
|
||||||
|
<string name="navbar_on_call">Apel</string>
|
||||||
|
<string name="navbar_end_call">Încheie apelul</string>
|
||||||
|
<string name="navbar_unmute">Pornește sunetul</string>
|
||||||
|
<string name="navbar_mute">Fără sunet</string>
|
||||||
|
<string name="navbar_close_panel">Închide panoul lateral</string>
|
||||||
|
<string name="navbar_dock_player">Mută playerul lângă hartă</string>
|
||||||
|
<string name="web_search">Caută sau introdu o adresă</string>
|
||||||
|
<string name="web_forward">Redirecționare</string>
|
||||||
|
<string name="web_reload">Reîncărcare</string>
|
||||||
|
<string name="web_start_page">Pagina de start</string>
|
||||||
|
<string name="web_fullscreen">Ecran complet</string>
|
||||||
|
<string name="notif_title">Notificări</string>
|
||||||
|
<string name="notif_clear_all">Șterge tot</string>
|
||||||
|
<string name="notif_empty">Deocamdată nimic. Notificările noi de pe telefon vor apărea aici.</string>
|
||||||
|
<string name="notif_off">Notificările sunt dezactivate. Permite aplicației Gearslip accesul la notificări din Setări > Notificări > Acces la notificări pe telefon.</string>
|
||||||
|
<string name="notif_replied">S-a răspuns</string>
|
||||||
|
<string name="msg_mark_read">Marcare ca citit</string>
|
||||||
|
<string name="msg_messages">Mesaje</string>
|
||||||
|
<string name="msg_none_recent">Nu există mesaje recente. Mesajele noi %1$s apar aici pe măsură ce sunt primite pe telefon.</string>
|
||||||
|
<string name="settings_language">Limbă</string>
|
||||||
|
<string name="settings_language_phone">La fel ca pe telefon</string>
|
||||||
|
<string name="player_next_chapter">Capitolul următor</string>
|
||||||
|
</resources>
|
||||||
@@ -374,6 +374,8 @@
|
|||||||
<string name="wireless_which_car">Какая машина?</string>
|
<string name="wireless_which_car">Какая машина?</string>
|
||||||
<string name="wireless_none_paired">Телефон пока ни с чем не связан. Сначала свяжите его с машиной.</string>
|
<string name="wireless_none_paired">Телефон пока ни с чем не связан. Сначала свяжите его с машиной.</string>
|
||||||
<string name="wireless_not_listed">Машины нет в списке? Сначала свяжите её с телефоном.</string>
|
<string name="wireless_not_listed">Машины нет в списке? Сначала свяжите её с телефоном.</string>
|
||||||
|
<string name="wireless_auto_off">Не подключается сам</string>
|
||||||
|
<string name="wireless_auto_turn_on">Разрешить подключаться самому</string>
|
||||||
<string name="bluetooth_settings">Настройки Bluetooth</string>
|
<string name="bluetooth_settings">Настройки Bluetooth</string>
|
||||||
|
|
||||||
<!-- Voice models -->
|
<!-- Voice models -->
|
||||||
@@ -845,4 +847,7 @@
|
|||||||
<string name="places_finding">Ищу…</string>
|
<string name="places_finding">Ищу…</string>
|
||||||
<string name="places_not_found">Gearslip не нашёл, где вы. Проверьте, что геолокация включена.</string>
|
<string name="places_not_found">Gearslip не нашёл, где вы. Проверьте, что геолокация включена.</string>
|
||||||
<string name="places_clear">Очистить</string>
|
<string name="places_clear">Очистить</string>
|
||||||
|
<string name="settings_language">Язык</string>
|
||||||
|
<string name="settings_language_phone">Как на телефоне</string>
|
||||||
|
<string name="player_next_chapter">Следующая глава</string>
|
||||||
</resources>
|
</resources>
|
||||||
@@ -372,6 +372,8 @@
|
|||||||
<string name="wireless_which_car">Vilken bil?</string>
|
<string name="wireless_which_car">Vilken bil?</string>
|
||||||
<string name="wireless_none_paired">Telefonen är inte parkopplad med något än. Parkoppla den med bilen först.</string>
|
<string name="wireless_none_paired">Telefonen är inte parkopplad med något än. Parkoppla den med bilen först.</string>
|
||||||
<string name="wireless_not_listed">Finns inte bilen med? Parkoppla den med telefonen först.</string>
|
<string name="wireless_not_listed">Finns inte bilen med? Parkoppla den med telefonen först.</string>
|
||||||
|
<string name="wireless_auto_off">Ansluter inte av sig själv</string>
|
||||||
|
<string name="wireless_auto_turn_on">Låt den ansluta av sig själv</string>
|
||||||
<string name="bluetooth_settings">Bluetooth-inställningar</string>
|
<string name="bluetooth_settings">Bluetooth-inställningar</string>
|
||||||
|
|
||||||
<!-- Voice models -->
|
<!-- Voice models -->
|
||||||
@@ -829,4 +831,7 @@
|
|||||||
<string name="places_finding">Söker…</string>
|
<string name="places_finding">Söker…</string>
|
||||||
<string name="places_not_found">Gearslip hittade inte var du är. Kontrollera att platstjänster är på.</string>
|
<string name="places_not_found">Gearslip hittade inte var du är. Kontrollera att platstjänster är på.</string>
|
||||||
<string name="places_clear">Rensa</string>
|
<string name="places_clear">Rensa</string>
|
||||||
|
<string name="settings_language">Språk</string>
|
||||||
|
<string name="settings_language_phone">Samma som telefonen</string>
|
||||||
|
<string name="player_next_chapter">Nästa kapitel</string>
|
||||||
</resources>
|
</resources>
|
||||||
@@ -372,6 +372,8 @@
|
|||||||
<string name="wireless_which_car">Hangi araç?</string>
|
<string name="wireless_which_car">Hangi araç?</string>
|
||||||
<string name="wireless_none_paired">Telefonunuz henüz hiçbir şeyle eşlenmedi. Önce araçla eşleyin.</string>
|
<string name="wireless_none_paired">Telefonunuz henüz hiçbir şeyle eşlenmedi. Önce araçla eşleyin.</string>
|
||||||
<string name="wireless_not_listed">Araç listede yok mu? Önce telefonunuzla eşleyin.</string>
|
<string name="wireless_not_listed">Araç listede yok mu? Önce telefonunuzla eşleyin.</string>
|
||||||
|
<string name="wireless_auto_off">Kendiliğinden bağlanmıyor</string>
|
||||||
|
<string name="wireless_auto_turn_on">Kendiliğinden bağlanmasına izin ver</string>
|
||||||
<string name="bluetooth_settings">Bluetooth ayarları</string>
|
<string name="bluetooth_settings">Bluetooth ayarları</string>
|
||||||
|
|
||||||
<!-- Voice models -->
|
<!-- Voice models -->
|
||||||
@@ -829,4 +831,7 @@
|
|||||||
<string name="places_finding">Aranıyor…</string>
|
<string name="places_finding">Aranıyor…</string>
|
||||||
<string name="places_not_found">Gearslip konumunuzu bulamadı. Konumun açık olduğundan emin olun.</string>
|
<string name="places_not_found">Gearslip konumunuzu bulamadı. Konumun açık olduğundan emin olun.</string>
|
||||||
<string name="places_clear">Temizle</string>
|
<string name="places_clear">Temizle</string>
|
||||||
|
<string name="settings_language">Dil</string>
|
||||||
|
<string name="settings_language_phone">Telefonla aynı</string>
|
||||||
|
<string name="player_next_chapter">Sonraki bölüm</string>
|
||||||
</resources>
|
</resources>
|
||||||
@@ -374,6 +374,8 @@
|
|||||||
<string name="wireless_which_car">Яке авто?</string>
|
<string name="wireless_which_car">Яке авто?</string>
|
||||||
<string name="wireless_none_paired">Телефон поки ні з чим не з’єднано. Спершу з’єднайте його з авто.</string>
|
<string name="wireless_none_paired">Телефон поки ні з чим не з’єднано. Спершу з’єднайте його з авто.</string>
|
||||||
<string name="wireless_not_listed">Авто немає в списку? Спершу з’єднайте його з телефоном.</string>
|
<string name="wireless_not_listed">Авто немає в списку? Спершу з’єднайте його з телефоном.</string>
|
||||||
|
<string name="wireless_auto_off">Не підключається сам</string>
|
||||||
|
<string name="wireless_auto_turn_on">Дозволити підключатися самому</string>
|
||||||
<string name="bluetooth_settings">Налаштування Bluetooth</string>
|
<string name="bluetooth_settings">Налаштування Bluetooth</string>
|
||||||
|
|
||||||
<!-- Voice models -->
|
<!-- Voice models -->
|
||||||
@@ -845,4 +847,7 @@
|
|||||||
<string name="places_finding">Шукаю…</string>
|
<string name="places_finding">Шукаю…</string>
|
||||||
<string name="places_not_found">Gearslip не знайшов, де ви. Перевірте, чи увімкнено геолокацію.</string>
|
<string name="places_not_found">Gearslip не знайшов, де ви. Перевірте, чи увімкнено геолокацію.</string>
|
||||||
<string name="places_clear">Очистити</string>
|
<string name="places_clear">Очистити</string>
|
||||||
|
<string name="settings_language">Мова</string>
|
||||||
|
<string name="settings_language_phone">Як на телефоні</string>
|
||||||
|
<string name="player_next_chapter">Наступний розділ</string>
|
||||||
</resources>
|
</resources>
|
||||||
@@ -371,6 +371,8 @@
|
|||||||
<string name="wireless_which_car">哪辆车?</string>
|
<string name="wireless_which_car">哪辆车?</string>
|
||||||
<string name="wireless_none_paired">您的手机还没有与任何设备配对。请先与车配对。</string>
|
<string name="wireless_none_paired">您的手机还没有与任何设备配对。请先与车配对。</string>
|
||||||
<string name="wireless_not_listed">没有看到您的车?请先将它与手机配对。</string>
|
<string name="wireless_not_listed">没有看到您的车?请先将它与手机配对。</string>
|
||||||
|
<string name="wireless_auto_off">不会自动连接</string>
|
||||||
|
<string name="wireless_auto_turn_on">允许自动连接</string>
|
||||||
<string name="bluetooth_settings">蓝牙设置</string>
|
<string name="bluetooth_settings">蓝牙设置</string>
|
||||||
|
|
||||||
<!-- Voice models -->
|
<!-- Voice models -->
|
||||||
@@ -821,4 +823,7 @@
|
|||||||
<string name="places_finding">正在定位…</string>
|
<string name="places_finding">正在定位…</string>
|
||||||
<string name="places_not_found">Gearslip 找不到你的位置。请检查定位是否已开启。</string>
|
<string name="places_not_found">Gearslip 找不到你的位置。请检查定位是否已开启。</string>
|
||||||
<string name="places_clear">清除</string>
|
<string name="places_clear">清除</string>
|
||||||
|
<string name="settings_language">语言</string>
|
||||||
|
<string name="settings_language_phone">与手机相同</string>
|
||||||
|
<string name="player_next_chapter">下一章</string>
|
||||||
</resources>
|
</resources>
|
||||||
@@ -148,6 +148,9 @@
|
|||||||
<string name="cert_kind_self">Self-signed: %1$s</string>
|
<string name="cert_kind_self">Self-signed: %1$s</string>
|
||||||
|
|
||||||
<!-- Car settings on the phone -->
|
<!-- Car settings on the phone -->
|
||||||
|
<string name="car_apps_sub">Choose which apps appear in the car</string>
|
||||||
|
<string name="car_apps_finding">Finding apps…</string>
|
||||||
|
<string name="car_apps_none">No car apps found</string>
|
||||||
<string name="car_group_screen">Screen</string>
|
<string name="car_group_screen">Screen</string>
|
||||||
<string name="car_group_nav_bar">Nav bar and player</string>
|
<string name="car_group_nav_bar">Nav bar and player</string>
|
||||||
<string name="car_group_sound">Sound and voice</string>
|
<string name="car_group_sound">Sound and voice</string>
|
||||||
@@ -189,6 +192,10 @@
|
|||||||
<string name="car_group_same">These are the same settings as Settings in the car. A change here shows there too.</string>
|
<string name="car_group_same">These are the same settings as Settings in the car. A change here shows there too.</string>
|
||||||
<string name="car_open_on_connect">Open on connect</string>
|
<string name="car_open_on_connect">Open on connect</string>
|
||||||
<string name="car_size">Size</string>
|
<string name="car_size">Size</string>
|
||||||
|
<string name="car_density">Car display density</string>
|
||||||
|
<string name="car_density_note">Larger text and controls, including in apps. Try 200 DPI with Size on Normal. Reconnect the car or reopen the preview to apply it. If controls clip, choose a lower DPI or From car.</string>
|
||||||
|
<string name="choice_density_car">From car</string>
|
||||||
|
<string name="choice_density_dpi">%1$d DPI</string>
|
||||||
<string name="car_app_theme">App theme</string>
|
<string name="car_app_theme">App theme</string>
|
||||||
<string name="car_dark_mode">Dark mode</string>
|
<string name="car_dark_mode">Dark mode</string>
|
||||||
<string name="car_buttons">Buttons</string>
|
<string name="car_buttons">Buttons</string>
|
||||||
@@ -269,6 +276,8 @@
|
|||||||
<string name="help_mic_detail">Android won\'t let an app pass music to the car without it. Gearslip listens only when you tap the mic or press the talk button on your wheel.</string>
|
<string name="help_mic_detail">Android won\'t let an app pass music to the car without it. Gearslip listens only when you tap the mic or press the talk button on your wheel.</string>
|
||||||
<string name="help_plug_in">Plug in, or connect wirelessly.</string>
|
<string name="help_plug_in">Plug in, or connect wirelessly.</string>
|
||||||
<string name="help_plug_in_detail">Gearslip opens by itself when the car connects. For wireless, pair the phone with the car first, then tap Connect wirelessly on Home.</string>
|
<string name="help_plug_in_detail">Gearslip opens by itself when the car connects. For wireless, pair the phone with the car first, then tap Connect wirelessly on Home.</string>
|
||||||
|
<string name="help_usb_data">Allow USB data.</string>
|
||||||
|
<string name="help_usb_data_detail">GrapheneOS can block data on the USB port. If USB-C port control is set to Charging-only, the car never sees the phone, even with a data cable. Set it to allow data.</string>
|
||||||
<string name="help_pair">Pair over Bluetooth for calls.</string>
|
<string name="help_pair">Pair over Bluetooth for calls.</string>
|
||||||
<string name="help_pair_detail">Calls play through the car once your phone and the car are paired. Gearslip offers to pair the first time you plug in.</string>
|
<string name="help_pair_detail">Calls play through the car once your phone and the car are paired. Gearslip offers to pair the first time you plug in.</string>
|
||||||
<string name="help_answer">Answer calls from the car screen.</string>
|
<string name="help_answer">Answer calls from the car screen.</string>
|
||||||
@@ -381,6 +390,8 @@
|
|||||||
<string name="wireless_which_car">Which car?</string>
|
<string name="wireless_which_car">Which car?</string>
|
||||||
<string name="wireless_none_paired">Your phone isn\'t paired with anything yet. Pair it with the car first.</string>
|
<string name="wireless_none_paired">Your phone isn\'t paired with anything yet. Pair it with the car first.</string>
|
||||||
<string name="wireless_not_listed">Car not listed? Pair it with your phone first.</string>
|
<string name="wireless_not_listed">Car not listed? Pair it with your phone first.</string>
|
||||||
|
<string name="wireless_auto_off">Doesn\'t connect on its own</string>
|
||||||
|
<string name="wireless_auto_turn_on">Let it connect on its own</string>
|
||||||
<string name="bluetooth_settings">Bluetooth settings</string>
|
<string name="bluetooth_settings">Bluetooth settings</string>
|
||||||
|
|
||||||
<!-- Voice models -->
|
<!-- Voice models -->
|
||||||
@@ -464,6 +475,8 @@
|
|||||||
|
|
||||||
<!-- -->
|
<!-- -->
|
||||||
<string name="carset_audio_note">Through Gearslip sends the phone\'s sound over the car connection, cable or wireless. Android asks to share your screen each drive, and a few apps block it. Bluetooth plays as the phone normally would, and Android never asks.</string>
|
<string name="carset_audio_note">Through Gearslip sends the phone\'s sound over the car connection, cable or wireless. Android asks to share your screen each drive, and a few apps block it. Bluetooth plays as the phone normally would, and Android never asks.</string>
|
||||||
|
<string name="car_directions_over_music">Directions over the music channel</string>
|
||||||
|
<string name="car_directions_over_music_note">For a car that drops the connection or restarts after a spoken turn. Gearslip mixes each turn into the music instead of using the car\'s directions channel.</string>
|
||||||
<string name="carset_directions_note">Gearslip reads each turn aloud through the car\'s speakers, and the music dips while it speaks. Turn it off if your map app\'s own voice already plays in the car.</string>
|
<string name="carset_directions_note">Gearslip reads each turn aloud through the car\'s speakers, and the music dips while it speaks. Turn it off if your map app\'s own voice already plays in the car.</string>
|
||||||
<string name="carset_phone_speech_note">Listens through the phone\'s own speech service instead of Gearslip\'s. On most phones that\'s Google\'s, which may send your voice to Google. GrapheneOS has none, so Gearslip keeps using its own.</string>
|
<string name="carset_phone_speech_note">Listens through the phone\'s own speech service instead of Gearslip\'s. On most phones that\'s Google\'s, which may send your voice to Google. GrapheneOS has none, so Gearslip keeps using its own.</string>
|
||||||
<string name="carset_vehicle_data_note">Adds a Vehicle data tile: a live, raw dump of the head unit\'s sensor channel. Field labels (speed, RPM, fuel...) are carried over from other reverse-engineering projects and unverified against this car - treat them as a guess, not a fact, until you\'ve watched one change for yourself.</string>
|
<string name="carset_vehicle_data_note">Adds a Vehicle data tile: a live, raw dump of the head unit\'s sensor channel. Field labels (speed, RPM, fuel...) are carried over from other reverse-engineering projects and unverified against this car - treat them as a guess, not a fact, until you\'ve watched one change for yourself.</string>
|
||||||
@@ -856,6 +869,21 @@
|
|||||||
<!-- Connection status on Home -->
|
<!-- Connection status on Home -->
|
||||||
<string name="status_idle">Not connected</string>
|
<string name="status_idle">Not connected</string>
|
||||||
<string name="status_idle_detail">Plug into the car\'s USB port, or connect wirelessly. Gearslip opens by itself when the car answers.</string>
|
<string name="status_idle_detail">Plug into the car\'s USB port, or connect wirelessly. Gearslip opens by itself when the car answers.</string>
|
||||||
|
<string name="status_no_car">The phone is charging, but no car has answered. Some cables only charge; try one that carries data.</string>
|
||||||
|
<string name="status_no_car_graphene">The phone is charging, but no car has answered. Check that USB-C port control, under Security & privacy, allows data and isn\'t set to Charging-only.</string>
|
||||||
|
<string name="status_stuck_cable">The car hasn\'t shown Gearslip yet. Unplug the cable, wait a few seconds, and plug it back in. If it keeps happening, try another cable or USB port, and share the log in an issue.</string>
|
||||||
|
<string name="status_stuck_wireless">The car hasn\'t shown Gearslip yet. Turn the car off and on again, or connect with a cable. If it keeps happening, share the log in an issue.</string>
|
||||||
|
<string name="settings_bt_address">Phone\'s Bluetooth address</string>
|
||||||
|
<string name="settings_bt_address_off">Not set. Only for a car that drops Bluetooth when Gearslip connects.</string>
|
||||||
|
<string name="settings_bt_address_hint">Copy it from Android\'s Settings, under About phone. Gearslip passes it to the car, so the car knows the phone on the cable is the one it\'s paired with.</string>
|
||||||
|
<string name="settings_bt_address_invalid">That isn\'t a Bluetooth address. It looks like 12:34:56:78:9A:BC.</string>
|
||||||
|
<string name="settings_use_as_launcher">Use as launcher</string>
|
||||||
|
<string name="settings_use_as_launcher_off">For a head unit or a tablet on the dash. Gearslip\'s car screen becomes this device\'s home screen.</string>
|
||||||
|
<string name="settings_use_as_launcher_on">Gearslip can be this device\'s home screen. Pick it under Default home app.</string>
|
||||||
|
<string name="app_all_apps">All apps</string>
|
||||||
|
<string name="launcher_in_car">This device is connected to a car, so Gearslip is on the car\'s screen.</string>
|
||||||
|
<string name="launcher_open_gearslip">Open Gearslip</string>
|
||||||
|
<string name="status_open_settings">Open settings</string>
|
||||||
<string name="status_connecting">Connecting</string>
|
<string name="status_connecting">Connecting</string>
|
||||||
<string name="status_projecting">Projecting</string>
|
<string name="status_projecting">Projecting</string>
|
||||||
<string name="status_projecting_detail">Streaming to the head unit.</string>
|
<string name="status_projecting_detail">Streaming to the head unit.</string>
|
||||||
@@ -873,4 +901,7 @@
|
|||||||
<string name="places_finding">Finding you…</string>
|
<string name="places_finding">Finding you…</string>
|
||||||
<string name="places_not_found">Gearslip couldn’t find where you are. Check that location is on.</string>
|
<string name="places_not_found">Gearslip couldn’t find where you are. Check that location is on.</string>
|
||||||
<string name="places_clear">Clear</string>
|
<string name="places_clear">Clear</string>
|
||||||
|
<string name="settings_language">Language</string>
|
||||||
|
<string name="settings_language_phone">Same as the phone</string>
|
||||||
|
<string name="player_next_chapter">Next chapter</string>
|
||||||
</resources>
|
</resources>
|
||||||
@@ -11,7 +11,10 @@ import javax.net.ssl.SSLEngineResult
|
|||||||
import javax.net.ssl.SSLEngineResult.HandshakeStatus
|
import javax.net.ssl.SSLEngineResult.HandshakeStatus
|
||||||
import javax.net.ssl.X509ExtendedTrustManager
|
import javax.net.ssl.X509ExtendedTrustManager
|
||||||
|
|
||||||
/** Checks mutual TLS and an encrypted data round trip with an in-process head-unit peer. */
|
/**
|
||||||
|
* Checks mutual TLS and an encrypted data round trip with an in-process head-unit peer. Test code
|
||||||
|
* only: the peer trusts any certificate, which is the point here and must never ship in the app.
|
||||||
|
*/
|
||||||
object TlsSelfTest {
|
object TlsSelfTest {
|
||||||
private val log = GearslipLog.tagged("TLS")
|
private val log = GearslipLog.tagged("TLS")
|
||||||
|
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
package app.seb3thehacker.gearslip.car
|
||||||
|
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Before
|
||||||
|
import org.junit.Test
|
||||||
|
|
||||||
|
class CarDensityTest {
|
||||||
|
private val context = InMemoryContext()
|
||||||
|
|
||||||
|
private fun density() = CarEnvironment.frame.value.densityDpi
|
||||||
|
|
||||||
|
@Before
|
||||||
|
fun startFromTheCar() {
|
||||||
|
CarSettings.init(context)
|
||||||
|
CarEnvironment.setDisplay(800, 480, 160)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun fromCarKeepsEachCarsOwnDensity() {
|
||||||
|
assertEquals(CarSettings.DENSITY_FROM_CAR, CarSettings.carDensity.value)
|
||||||
|
for (dpi in listOf(120, 180, 213)) {
|
||||||
|
CarEnvironment.setDisplay(800, 480, dpi)
|
||||||
|
assertEquals(dpi, density())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun aChosenDensityReachesEverythingThatReadsTheFrame() {
|
||||||
|
for (dpi in CarSettings.DENSITIES) {
|
||||||
|
CarSettings.setCarDensity(dpi)
|
||||||
|
CarEnvironment.setDisplay(800, 480, 160)
|
||||||
|
assertEquals(dpi, density())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun aChosenDensityLeavesTheResolutionAlone() {
|
||||||
|
CarSettings.setCarDensity(240)
|
||||||
|
CarEnvironment.setDisplay(1280, 720, 160)
|
||||||
|
assertEquals(CarEnvironment.Frame(1280, 720, 240), CarEnvironment.frame.value)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun aNewChoiceWaitsForTheNextSession() {
|
||||||
|
CarSettings.setCarDensity(200)
|
||||||
|
CarEnvironment.setDisplay(800, 480, 160)
|
||||||
|
CarSettings.setCarDensity(240)
|
||||||
|
assertEquals(200, density())
|
||||||
|
CarEnvironment.setDisplay(800, 480, 160)
|
||||||
|
assertEquals(240, density())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun thePreviewTakesUpANewChoiceWhenItOpens() {
|
||||||
|
CarSettings.setCarDensity(200)
|
||||||
|
CarEnvironment.applyDensity()
|
||||||
|
assertEquals(200, density())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun goingBackToFromCarRestoresTheCarsOwnDensity() {
|
||||||
|
CarSettings.setCarDensity(240)
|
||||||
|
CarEnvironment.setDisplay(800, 480, 180)
|
||||||
|
CarSettings.setCarDensity(CarSettings.DENSITY_FROM_CAR)
|
||||||
|
CarEnvironment.applyDensity()
|
||||||
|
assertEquals(180, density())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun aPreviewSizedForThePhoneFollowsTheChoiceToo() {
|
||||||
|
assertEquals(213, CarSettings.effectiveDensity(213))
|
||||||
|
CarSettings.setCarDensity(200)
|
||||||
|
assertEquals(200, CarSettings.effectiveDensity(213))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun theChoiceSurvivesARestart() {
|
||||||
|
CarSettings.setCarDensity(220)
|
||||||
|
CarSettings.init(context)
|
||||||
|
assertEquals(220, CarSettings.carDensity.value)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun aSavedDensityGearslipDoesNotOfferFallsBackToTheCar() {
|
||||||
|
for (saved in listOf("201", "-1", "broken", "")) {
|
||||||
|
context.stored["car_density"] = saved
|
||||||
|
CarSettings.init(context)
|
||||||
|
assertEquals(CarSettings.DENSITY_FROM_CAR, CarSettings.carDensity.value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
package app.seb3thehacker.gearslip.car
|
||||||
|
|
||||||
|
import app.seb3thehacker.gearslip.notify.MessagingApp
|
||||||
|
import app.seb3thehacker.gearslip.ui.CarGroup
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Assert.assertFalse
|
||||||
|
import org.junit.Assert.assertTrue
|
||||||
|
import org.junit.Before
|
||||||
|
import org.junit.Test
|
||||||
|
|
||||||
|
class HiddenAppsTest {
|
||||||
|
private val context = InMemoryContext()
|
||||||
|
|
||||||
|
private fun app(name: String) = Entry(name, icon = null, messaging = MessagingApp(name, name))
|
||||||
|
private fun id(name: String) = MESSAGING_PREFIX + name
|
||||||
|
private fun labels(entries: List<Entry>) = entries.map { it.label }
|
||||||
|
|
||||||
|
private val apps = listOf(app("signal"), app("telegram"), app("whatsapp"))
|
||||||
|
|
||||||
|
@Before
|
||||||
|
fun startWithNothingSaved() = CarSettings.init(context)
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun everyTileShowsUntilUnticked() {
|
||||||
|
assertEquals(emptySet<String>(), CarSettings.hiddenApps.value)
|
||||||
|
assertEquals(apps.map { it.label }, labels(launcherOrder(apps, false, emptyList(), CarSettings.hiddenApps.value)))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun anUntickedTileLeavesTheDrawer() {
|
||||||
|
val shown = launcherOrder(apps, false, emptyList(), setOf(id("telegram")))
|
||||||
|
assertEquals(listOf("signal", "whatsapp"), labels(shown))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun gearslipsOwnTilesStayWhenInstalledOnesAreHidden() {
|
||||||
|
val all = BuiltInApps.everyEntry + apps
|
||||||
|
val shown = launcherOrder(all, false, emptyList(), apps.mapNotNull { it.componentId }.toSet())
|
||||||
|
assertTrue(shown.isNotEmpty())
|
||||||
|
assertTrue(shown.all { it.builtIn != null })
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun aHiddenTileLeavesTheDriversOrderIntactForTheRest() {
|
||||||
|
val custom = listOf(id("whatsapp"), id("telegram"), id("signal"))
|
||||||
|
val shown = launcherOrder(apps, false, custom, setOf(id("telegram")))
|
||||||
|
assertEquals(listOf("whatsapp", "signal"), labels(shown))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun aTileComesBackWhenTickedAgain() {
|
||||||
|
CarSettings.setAppHidden(id("signal"), true)
|
||||||
|
CarSettings.setAppHidden(id("telegram"), true)
|
||||||
|
CarSettings.setAppHidden(id("signal"), false)
|
||||||
|
assertEquals(setOf(id("telegram")), CarSettings.hiddenApps.value)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun theChoicesSurviveARestart() {
|
||||||
|
CarSettings.setAppHidden(id("signal"), true)
|
||||||
|
CarSettings.setAppHidden(id("whatsapp"), true)
|
||||||
|
CarSettings.init(context)
|
||||||
|
assertEquals(setOf(id("signal"), id("whatsapp")), CarSettings.hiddenApps.value)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun appsIsTheFirstCardOnThePhone() {
|
||||||
|
assertEquals(CarGroup.APPS, CarGroup.entries.first())
|
||||||
|
assertFalse(CarGroup.entries.drop(1).contains(CarGroup.APPS))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
package app.seb3thehacker.gearslip.car
|
||||||
|
|
||||||
|
import android.content.ContextWrapper
|
||||||
|
import android.content.SharedPreferences
|
||||||
|
import java.lang.reflect.Proxy
|
||||||
|
|
||||||
|
/** Just enough Context for [CarSettings]: strings kept in a map, so a second init reads them back. */
|
||||||
|
class InMemoryContext : ContextWrapper(null) {
|
||||||
|
val stored = mutableMapOf<String, String>()
|
||||||
|
|
||||||
|
private val editor: SharedPreferences.Editor = proxy { self, name, args ->
|
||||||
|
if (name == "putString") stored[args[0] as String] = args[1] as String
|
||||||
|
self.takeIf { name == "putString" }
|
||||||
|
}
|
||||||
|
|
||||||
|
private val preferences: SharedPreferences = proxy { _, name, args ->
|
||||||
|
when (name) {
|
||||||
|
"edit" -> editor
|
||||||
|
"getString" -> stored[args[0] as String] ?: args[1]
|
||||||
|
else -> null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun getApplicationContext() = this
|
||||||
|
|
||||||
|
override fun getSharedPreferences(name: String?, mode: Int) = preferences
|
||||||
|
|
||||||
|
private inline fun <reified T> proxy(crossinline call: (Any, String, Array<out Any?>) -> Any?): T =
|
||||||
|
Proxy.newProxyInstance(T::class.java.classLoader, arrayOf(T::class.java)) { self, method, args ->
|
||||||
|
call(self, method.name, args.orEmpty())
|
||||||
|
} as T
|
||||||
|
}
|
||||||