Author SHA1 Message Date
Seb3thehacker 8e1df8dc34 A site's icon is only ever saved inside the icons folder, and the translations check runs with no token
Fixes the code scanning alerts: a host made of dots got through the file name filter, and
the workflow didn't say it needs no permissions.
2026-11-11 13:24:34 -08:00
Seb3thehacker 80a5d68d4c 0.4.0-alpha: release notes 2026-11-11 13:10:03 -08:00
Seb3thehacker 3155f26648 usage notes privacy 2026-11-11 13:06:04 -08:00
Seb3thehacker 623eee8723 Going back to the map, the screen that was up fades out instead of vanishing 2026-11-11 12:58:31 -08:00
Seb3thehacker a8486d531a Debug builds send the test message a few seconds after the tap, time to open the car preview 2026-11-11 12:54:36 -08:00
Seb3thehacker 806ffdf2b8 The song's times are larger on the media screen, and show under the player beside the map 2026-11-11 12:27:05 -08:00
Seb3thehacker 4aeaaee875 With the player beside the map, like, shuffle, repeat and lyrics move into the nav bar
The nav bar's pill carries them while the player is docked, with its dock button in the
same place to close it, so the player itself is just the cover, the song and play/skip.
The player's own close button, which sat over the corner of the cover, is gone.
2026-11-11 12:21:55 -08:00
Seb3thehacker fa33e7c650 The cover no longer dims for a moment every few seconds
Each update from the music app brought the same cover as a new picture, which the player
faded in again.
2026-11-11 12:10:51 -08:00
Seb3thehacker c265d137c8 The trip gets its own card at the bottom again, and the side player's buttons fit with room between them
The player's buttons are sized from the column's width instead of fixed, its close button
is round and tucked into the corner, and the trip's figures shrink to stay whole when the
player narrows the column.
2026-11-11 12:10:51 -08:00
Seb3thehacker b047da37c0 Broken apps show their red X in the phone's app list too, and Google Maps gets one
Maps lists itself only as a messenger, and messengers never got the X, so it had none
anywhere. Patched copies, like Waze through Morphe, still get none.
2026-11-11 11:45:43 -08:00
Seb3thehacker 0ca828de4a The car audio choice moves to Settings, and says map apps' spoken directions stay on the phone with Through Gearslip
The phone's ID in the usage note can be selected and copied, for a request to see or
delete what was sent.
2026-11-11 11:29:22 -08:00
Seb3thehacker f10ffa4142 The Wi-Fi off warning opens the Wi-Fi panel and reads from strings.xml 2026-11-11 11:10:38 -08:00
Seb3thehacker 618075f1d0 Merge the Wi-Fi check before a wireless connect (#102) 2026-11-11 11:09:00 -08:00
Seb3thehacker be9d170469 Merge translations from Weblate (#76) 2026-11-11 11:08:48 -08:00
Seb3thehacker 3860e978ac Car maps keep their GPS with the phone locked once location is allowed all the time
A car that connected by itself with the phone locked left navigation apps without a fix
until Gearslip was opened (#99). Home now asks for Allow all the time while a car is
connected, and the location service starts as soon as it's granted. The weather no
longer reads the location when Gearslip is opened with no car connected.
2026-11-11 11:07:42 -08:00
grmasa be6ef93dfe Add Wi-fi check 2026-11-11 20:45:12 +02:00
Seb3thehacker ba86685935 A certificate can be downloaded from any link and converted, and fetched again later
A .p12, with or without a password, or a certificate and key as PEM or DER, in one file or two.
Gearslip ships no address of its own; the driver pastes one, and it's kept for Download again.
2026-11-08 21:52:24 -08:00
Seb3thehacker 85c8133442 Home has a one-tap switch between Bluetooth and Through Gearslip for the car's sound 2026-11-08 21:47:16 -08:00
Seb3thehacker 5f87840e7e A car that hangs up on the full service discovery request gets the short one next time
The 2019 Fiat 500's Uconnect drops the link within a second of the request with icons that 0.3.39
started sending, while a 2017 Hyundai needs them. Each car is remembered by its head unit's certificate.
2026-11-08 21:31:59 -08:00
Seb3thehacker 7fd19758a4 The music browser no longer crashes when an app lists the same item twice 2026-11-08 21:29:12 -08:00
Seb3thehacker 3d3e7669ac Apps can say they work with Gearslip, and each recommended app gets its own page
An app adds one meta-data line to its manifest and Gearslip marks it Works with Gearslip in the
Recommended apps list. Tapping an app opens its page, with an Open or Get button. A README badge
for app developers is in docs/badge, documented in BUILDING_APPS.md.
2026-11-08 01:13:18 -08:00
Seb3thehacker e72e99d699 Place lists get a map behind them, drawn by Gearslip as Android Auto draws one
RF Sentinel and other point-of-interest apps leave this map to the host. It uses MapLibre with
OpenFreeMap's tiles, which need no key or account, with a numbered pin for each place.
2026-11-06 15:58:13 -08:00
Seb3thehacker 5ea62b4bd6 Any installed app can be added to the car as a music app or a messenger
Music plays through Gearslip's player from the app's playback session, pressing its Play key when
it isn't running; messages show from its notifications. Added apps can be removed from the Apps card.
2026-11-06 15:38:11 -08:00
Seb3thehacker 9e1295d0de Browser videos start without waiting for a second tap, and video streams skip the ad lists
The blocker also logs what it cost each page, to tell whether it slows anything down.
2026-11-06 15:38:11 -08:00
Seb3thehacker 38a02974d5 Known-broken apps show with a red X and start unticked instead of being hidden 2026-11-06 15:38:11 -08:00
LibreTranslate 28e92632f1 chore(l10n): update German translation
Translation: Gearslip/App
Language: German
Progress: 100.0% (746 of 746 strings)
Translate-URL: https://hosted.weblate.org/projects/gearslip/app/de/
2026-11-06 23:51:22 +01:00
icke68746 a668d2b0bd chore(l10n): update German translation
Translation: Gearslip/App
Language: German
Progress: 100.0% (746 of 746 strings)
Translate-URL: https://hosted.weblate.org/projects/gearslip/app/de/
2026-11-06 23:51:21 +01:00
Seb3thehacker d857670c9f The browser's start page sits centred and narrower, and its remove buttons aren't cut off 2026-11-06 14:31:54 -08:00
Seb3thehacker 3cf41d3fc2 The nav bar's mic button can be hidden; the car's voice key still works 2026-11-06 14:31:54 -08:00
Seb3thehacker 6822abba90 Back from the car preview returns to Home, where it was opened 2026-11-06 14:31:54 -08:00
Seb3thehacker 43f9259803 The car browser gets a start page with bookmark tiles, a search engine choice, and ad blocking
Bookmarks are added from the star in the car, from the phone's settings, or by sharing a page to
Gearslip from any browser. Ads and trackers are blocked with EasyList and EasyPrivacy, or any list
added by its address, refreshed weekly on Wi-Fi, and YouTube's video ads are skipped.
2026-11-06 13:55:19 -08:00
Seb3thehacker f139ac48c4 Car display density takes any value from 60 to 480 DPI, typed in under Custom 2026-11-06 13:30:29 -08:00
Seb3thehacker bb7f2b93ea The car screen follows the language picked for Gearslip, not only the phone's 2026-11-06 13:15:07 -08:00
Seb3thehacker af4e979ab0 Screen sharing waits 30 seconds for the car to come back, and the address card shows once per car again
A wireless reconnect takes longer than 3 seconds, which had the driver asked to share the screen
again mid-drive. Cars that wait for a tap on their icon no longer get the address card every drive.
2026-11-06 13:12:20 -08:00
Seb3thehacker 9938efb706 The version now comes from the git tag, so a build can't disagree with its release
Builds after a tag name themselves after it plus the commits since, and the version code is
worked out from the numbers instead of set by hand.
2026-11-06 12:04:28 -08:00
Seb3thehacker 6e6a77d554 Waze's Go now counts down and starts the drive on its own, like a single Start does 2026-11-06 12:04:28 -08:00
Seb3thehacker bc22691f81 Map apps keep their speedometer clear of the nav bar and the map buttons 2026-11-06 12:04:28 -08:00
Seb3thehacker 2d3de20682 Faded map buttons press on the first tap instead of only waking up 2026-11-06 12:04:28 -08:00
Seb3thehacker 6d32fe2bd3 App screens drop the back button when it would only lead to Home or the app grid, which the nav bar already does 2026-11-06 12:04:28 -08:00
Seb3thehacker be92ff0b3d Car apps opened from the launcher show no back arrow on their first screen, where the close button already is 2026-11-06 12:04:28 -08:00
Seb3thehacker dd6dd742b5 A countdown button's label stays readable before the fill reaches it, like Vela's Start 2026-11-06 12:04:28 -08:00
Seb3thehacker 5d0a924c6c The voice prompt sits on a darker backdrop and suggests what to say until you start talking 2026-11-06 12:04:28 -08:00
Seb3thehacker 542dd6751f Today's weather card shows the next few hours beside the weather now 2026-11-06 12:04:28 -08:00
Seb3thehacker 59a2d38480 Every shipped language has every string again, Romanian included, for Weblate to review 2026-11-06 12:04:28 -08:00
Seb3thehacker 2a692cda84 Danish and Estonian wait on Weblate until they're mostly translated, so the app never comes up half in English 2026-11-06 12:04:28 -08:00
Seb3thehacker cb1e6cd19d Phone in the car opens on recent calls, with missed calls marked, like Android Auto 2026-11-06 12:04:28 -08:00
Seb3thehacker 564bf005c7 Notification access asks calmly in the car and points to the phone for when you're parked 2026-11-06 12:04:28 -08:00
Seb3thehacker 62facda5d9 Media apps keep their own name on the player, NewPipe and TIDAL included 2026-11-06 12:04:28 -08:00
Seb3thehacker afd05ad8c0 Car settings match the phone's: voice settings under Sound and voice, switches for on and off, screen fixes together under Screen, and one lyrics setup for everyone 2026-11-06 12:04:28 -08:00
Seb3thehacker ef5365b9ed Back in the car preview steps back in the car screens instead of closing the preview 2026-11-06 12:04:28 -08:00
Seb3thehacker cec98fcde6 Opening a media app that can't connect no longer drops the music you were listening to 2026-11-06 12:04:28 -08:00
Seb3thehacker 6ec0325f7d Turn arrows change with each step again for map apps that draw their own 2026-11-06 12:04:28 -08:00
Seb3thehacker 7719a5fff2 Merge pull request #88 from davideresigotti/honda-civic-capture-stop
Stop the audio capture and screen sharing when the car leaves
2026-11-06 12:03:12 -08:00
Seb3thehacker 2934ce2684 Merge pull request #87 from davideresigotti/honda-civic-audio-chunks
Honda Civic: send bigger audio chunks to a car that acks slowly
2026-11-06 12:02:53 -08:00
Seb3thehacker f0871eb4b1 Merge pull request #86 from davideresigotti/honda-civic-screen
Honda Civic: tell the car which phone this is, so it gives up its screen
2026-11-06 12:02:25 -08:00
Henrik f6188bd1e9 chore(l10n): update Swedish translation
Translation: Gearslip/App
Language: Swedish
Progress: 71.0% (530 of 746 strings)
Translate-URL: https://hosted.weblate.org/projects/gearslip/app/sv/
2026-11-06 14:51:22 +01:00
Davide-Resigotti 18a655e990 Screen sharing stops when the car leaves
Nothing stopped the audio capture once the drive was over: CarAudio.release() only ran when
the activity went away, and through a wireless dongle it stays open, so the phone kept sharing
its screen and stayed muted (issue #43). A wireless session that drops also never closed the
music channel, since nothing calls stop() until the next session. run() now closes it, and
when the music channel closes the capture stops unless a car comes back within 3 seconds.
2026-11-06 14:09:20 +01:00
Davide-Resigotti 617b91ef23 Music reaches a car that acks slowly in chunks as long as its gap
The 2020 Civic (issue #43) acks one audio chunk every 45 to 85 ms whatever it holds, so the
21 ms chunks Gearslip sends reached it at a quarter to half of real time: a second of music,
then silence. While the window is full, AudioLink now measures the car's gap between acks, and
when it's well over what a chunk holds, gathers the capture into chunks that last the gap,
rounded up to a power of two in frames. The Civic gets 4096 frames. A car that keeps up never
fills the window and gets the capture as it comes.
2026-11-06 14:08:39 +01:00
Davide-Resigotti 73ffa7925e A car that keeps its screen until it knows the phone hears its Bluetooth address
The 2020 Civic (issue #43) answers every request for its screen with its own, and waits to
hear which phone is connected over the car's Bluetooth channel. Gearslip never told it:
CarBluetooth was only set up once the car screen drew, so on a car that never shows it the
typed address never left the phone. It's now set up when Gearslip starts.

Once the car hears the address it sends Play while still on its own screen, and reaching for
the media app before the car UI exists ended the session; such keys are now logged and ignored.
The second request for the screen now runs whether or not the media channel is open, and a car
that keeps its screen gets the Bluetooth channel opened, and pairing started, as a real phone
does. When the car answers there, it is asked for its screen again.
2026-11-06 14:08:06 +01:00
Igor Sorocean 70bfe3b7b0 chore(l10n): update Romanian translation
Translation: Gearslip/App
Language: Romanian
Progress: 100.0% (746 of 746 strings)
Translate-URL: https://hosted.weblate.org/projects/gearslip/app/ro/
2026-11-06 09:51:26 +00:00
Henrik 937d658dfb chore(l10n): update Swedish translation
Translation: Gearslip/App
Language: Swedish
Progress: 70.2% (524 of 746 strings)
Translate-URL: https://hosted.weblate.org/projects/gearslip/app/sv/
2026-11-06 09:51:24 +00:00
Igor Sorocean 7e544c2b52 chore(l10n): update Romanian translation
Translation: Gearslip/App
Language: Romanian
Progress: 100.0% (731 of 731 strings)
Translate-URL: https://hosted.weblate.org/projects/gearslip/app/ro/
2026-11-06 07:51:46 +01:00
Yoann Verminck fa109d7956 chore(l10n): update French translation
Translation: Gearslip/App
Language: French
Progress: 80.3% (587 of 731 strings)
Translate-URL: https://hosted.weblate.org/projects/gearslip/app/fr/
2026-11-06 07:51:45 +01:00
nono mino 44f29ac914 chore(l10n): update Dutch translation
Translation: Gearslip/App
Language: Dutch
Progress: 86.7% (634 of 731 strings)
Translate-URL: https://hosted.weblate.org/projects/gearslip/app/nl/
2026-11-06 07:51:44 +01:00
nono mino 3dba0a6e42 chore(l10n): update French translation
Translation: Gearslip/App
Language: French
Progress: 79.8% (584 of 731 strings)
Translate-URL: https://hosted.weblate.org/projects/gearslip/app/fr/
2026-11-06 07:51:43 +01:00
Voltox 118e75cc41 chore(l10n): update German translation
Translation: Gearslip/App
Language: German
Progress: 100.0% (731 of 731 strings)
Translate-URL: https://hosted.weblate.org/projects/gearslip/app/de/
2026-11-06 07:51:42 +01:00
Kay OS 907e49b386 chore(l10n): update German translation
Translation: Gearslip/App
Language: German
Progress: 100.0% (718 of 718 strings)
Translate-URL: https://hosted.weblate.org/projects/gearslip/app/de/
2026-11-06 07:51:41 +01:00
Igor Sorocean 49ec535dff chore(l10n): update Romanian translation
Translation: Gearslip/App
Language: Romanian
Progress: 100.0% (718 of 718 strings)
Translate-URL: https://hosted.weblate.org/projects/gearslip/app/ro/
2026-11-06 07:51:40 +01:00
Seb3thehacker c389c1c586 One navigation card: turn, lanes, a clearer next turn and the trip, with the app's own stop button when it has one 2026-11-05 16:44:22 -08:00
Seb3thehacker 5d1d2337f0 Lane guidance draws the road ahead, with the lanes to use filled in and said in words 2026-11-05 16:21:36 -08:00
Seb3thehacker 163e206715 The cockpit shows the navigation app's own map beside the turn card, for apps with a cluster session 2026-11-05 14:46:50 -08:00
Seb3thehacker 2a14c780fa Bug and car reports ask for the Gearslip version 2026-11-05 14:42:30 -08:00
Seb3thehacker 99407dfcb2 The cockpit turn card stays clear of the car's own dials and bars 2026-11-05 14:09:42 -08:00
Seb3thehacker 31d8232978 Stable updates install by themselves on Wi-Fi when no car is connected 2026-11-05 12:56:39 -08:00
Seb3thehacker 9d6d18bc47 Help says to connect Bluetooth before the cable when car music won't connect 2026-11-05 12:45:27 -08:00
Seb3thehacker 20cab97a55 Gearslip installs on 32-bit ARM and x86_64 phones too 2026-11-05 12:41:56 -08:00
Seb3thehacker e8025113e9 Video starts over when the car changes its screen layout mid-stream 2026-11-05 12:23:13 -08:00
Seb3thehacker 585b50ac72 Cars that turn Bluetooth music off get it through Gearslip, and Spotify's silence is explained
A Skoda, a VW, an Audi and a Bolt all dropped the phone's Bluetooth audio once Android Auto was
up. If Bluetooth audio to the car was connected when it showed Gearslip and drops after, that
car's music goes through Gearslip from then on, remembered per car, and the car screen says so.
Picking Bluetooth by hand clears what was learned. When capture stays silent while Spotify
plays, the car screen says Spotify doesn't let its sound be passed on.
2026-11-05 03:08:20 -08:00
Seb3thehacker 3e2c087483 The cluster screen goes back to the car when there's no route
Gearslip took an Audi's virtual cockpit and showed black there with nothing to navigate,
instead of the car's own map (#56). It now asks for the cluster when a route starts and hands
it back once there has been none for 10 seconds.
2026-11-05 02:55:26 -08:00
Seb3thehacker a186b0c7d0 Wireless connects again when the car asks again before it has spoken
A Bolt opened the TCP connection but never sent its first message, then sent a new
StartRequest over Bluetooth. Gearslip kept waiting on the dead connection for 88 seconds. Now a
repeat StartRequest while the session still waits for the car opens a fresh connection. A
session already under way is left alone.
2026-11-05 02:43:52 -08:00
Seb3thehacker e9aa6debb6 A paired car that doesn't know the phone's address gets it asked for on the phone
The Bolt in #18 stayed connected only once Gearslip passed it the phone's Bluetooth address,
which was hidden in Settings. After a paired car connects without it, Home asks once per car
and opens About phone. Coming back, Gearslip offers the address it finds on the clipboard. The
car screen says once to finish a step on the phone when parked.
2026-11-04 23:53:06 -08:00
Seb3thehacker 12baaca79a Settings and setup link to the privacy policy
The usage notes card, which setup and Settings share, ends with it, and Settings' About links
list it under the source code.
2026-11-04 23:21:43 -08:00
Seb3thehacker 8693e35873 README: the badge sits under the screenshot 2026-11-04 23:15:23 -08:00
Seb3thehacker bd189fea38 Weblate's own pull requests pass the translations check
The check fails any pull request that edits a translation, which included Weblate's when it
opens one instead of pushing.
2026-11-04 23:14:37 -08:00
Seb3thehacker fb0736a3b3 README leads with the car screen and a table of what works
The screenshot of Vela navigating comes first, then one warning and a short table. Install
steps, the GrapheneOS note and the download check sit together, and the longer notes move
lower.
2026-11-04 23:06:24 -08:00
Seb3thehacker 6c9ff6edd2 README leads with the car screen and a table of what works
The screenshot of Vela navigating comes first, then one warning and a short table. Install
steps, the GrapheneOS note and the download check sit together, and the longer notes move
lower.
2026-11-04 23:00:38 -08:00
Seb3thehacker 554bb4922f The knob zooms and pans a full-screen map, and starts on the last app in the grid
As in Android Auto, turning zooms the map, nudging pans it, and pressing brings up its buttons
for the knob to move between; Back puts them away. A card over the map with buttons of its own
keeps the knob. On the Apps screen the first knob move lands on the app opened last. Issue #74:
a Mazda 3 driver had to turn through every button to get anywhere.
2026-11-04 22:59:22 -08:00
118 changed files with 143113 additions and 1414 deletions

No files matched your search

+9
View File
@@ -16,6 +16,15 @@ body:
validations:
required: true
- type: input
id: version
attributes:
label: Gearslip version
description: Shown under the Gearslip name on the app's home screen.
placeholder: v0.3.40-alpha
validations:
required: true
- type: dropdown
id: head-unit
attributes:
+9
View File
@@ -16,6 +16,15 @@ body:
validations:
required: true
- type: input
id: version
attributes:
label: Gearslip version
description: Shown under the Gearslip name on the app's home screen.
placeholder: v0.3.40-alpha
validations:
required: true
- type: dropdown
id: head-unit
attributes:
+30 -7
View File
@@ -1,13 +1,36 @@
Gearslip 0.3.40 is a test build for a few cars that still don't connect.
Gearslip 0.4.0 brings a web browser with ad blocking, a map behind place lists, any music app or messenger in the car, and a cleaner player beside the map.
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.
## New
- The car's web browser opens on a start page with bookmark tiles, the search engine you choose, and ad blocking with EasyList and EasyPrivacy (#85). Manage bookmarks in the car or on the phone.
- Add any installed app to the car as a music app or a messenger, under Car > Apps > Add an app (#84).
- Apps that list places, such as RF Sentinel, show them on a map, as Android Auto does.
- Download a certificate from a link, as a .p12 file or as PEM or DER, and fetch it again later from Settings (#15).
- Car display density takes any value from 60 to 480 DPI, under Screen > Custom (#82).
- Hide the nav bar's mic button. The car's voice key still works.
- Phone in the car opens on recent calls, with missed calls marked.
- Today's weather card shows the next few hours.
## Player and navigation
- With the player beside the map, like, shuffle, repeat, and lyrics sit in the nav bar, and the dock button there closes the player.
- The player's buttons fit narrow screens, and the song's times show beside the map and in larger type on the media screen.
- The trip's time, arrival, and distance have their own card at the bottom again.
- The cover stays steady instead of dimming every few seconds.
- Waze's Go button counts down and starts the drive by itself.
## Fixed
- A car that holds back its screen is asked for it again with the detail older cars expect. This is for the 2017 Pilot (#57) and other cars on the oldest Android Auto version.
- Music no longer carries on from the phone's speaker after you turn the car off. An app that started playing again by itself is paused a second time (#39).
- A liked song lights up the whole like button, as shuffle and repeat do. Before, only the heart filled in for apps such as Metrolist.
- Map apps keep their GPS fix with the phone locked (#99). Gearslip asks for location "Allow all the time" while a car is connected, and uses it only then.
- Gearslip tells Honda cars which phone it is, so they hand over their screen, and sends sound in bigger chunks to cars that take it slowly (#86, #87). Thanks to @davideresigotti.
- A car that hangs up during setup gets a shorter request the next time (#91).
- The car screen follows the language you pick for Gearslip (#80).
- The music browser handles apps that list the same song twice (#90).
- Screen sharing stops when the car leaves (#88).
- Apps known not to work show a red X and start unticked, in the car and in the phone's app list. Patched copies that work, such as Waze through Morphe, show none.
## 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.
## Changed
- The car audio choice moved to Settings > Connection. With Through Gearslip, map apps' spoken directions play on the phone.
- Connecting wirelessly with Wi-Fi off now asks you to turn it on (#102). Thanks to @grmasa.
## Privacy
Gearslip now has a [privacy policy](https://github.com/twinksoftware/gearslip/blob/main/PRIVACY.md). It lists every service Gearslip contacts and what each one receives.
Help translate Gearslip on [Weblate](https://hosted.weblate.org/engage/gearslip/).
+3
View File
@@ -12,6 +12,9 @@ jobs:
contents: write
steps:
- uses: actions/checkout@v4
with:
# The build names itself after the newest tag, which a shallow clone doesn't have.
fetch-depth: 0
- uses: actions/setup-java@v4
with:
+5
View File
@@ -3,13 +3,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.
# Weblate's own pull requests are the exception: they are the translations.
on:
pull_request:
paths:
- "gearslip/src/main/res/values-*/strings.xml"
# It only reads the pull request's own details from the event, so it needs no token at all.
permissions: {}
jobs:
weblate-only:
if: github.event.pull_request.user.login != 'weblate'
runs-on: ubuntu-latest
steps:
- name: Translations go through Weblate
+95
View File
@@ -0,0 +1,95 @@
# Privacy policy
Gearslip belongs to Twink Software LLC ("we"). This policy says what Gearslip sends off your
phone, to whom, and why. Most of what Gearslip does stays on the phone.
Last updated: November 11, 2026
## What stays on your phone
- **Your voice.** Speech turns into text on the phone. Gearslip never records or sends what
you say.
- **Messages, calls, and contacts.** Gearslip reads them to show and speak them in the car,
and it never sends them anywhere.
- **The certificate.** It stays on your phone. Gearslip never uploads, backs up, or logs it.
- **Logs.** Gearslip writes a log of each connection to the phone. It leaves the phone only
when you share it yourself, for example by attaching it to a GitHub issue.
- **Your location.** Gearslip uses it only while a car is connected, so the car's map and
weather work, and with Allow all the time, so the map keeps working with the phone locked.
It sends the location nowhere except to the weather service listed below.
- **What your car reports.** Speed, fuel, location, and the like go only to the apps on
your phone that ask for them.
## Usage notes
Gearslip can send us short notes that show which cars it works in. They stay off unless you
turn them on, in setup or in Settings. Turn them off, and they stop at once.
- **What they carry.** The Gearslip version and which boxes you ticked, plus whatever those
boxes cover: the Android version and whether the phone runs GrapheneOS, the phone model,
whether a car connected that day, and each car's head unit, model, year, screen size, and
connection result, with whether that drive used the cable or wireless and whether music
went through Gearslip or over Bluetooth. If you tick it, how each drive went: how far the
connection got, how long it lasted, and how it ended. Settings shows the exact note.
- **What they leave out.** Your name, accounts, location, contacts, messages, and anything
you type or play.
- **How phones are counted.** A random number counts each phone once. We keep only a salted
hash of it.
- **Your country.** Our server works out the country from the connection a note arrives on,
and keeps the country only. It sees your IP address, as any website does, and doesn't
store it.
- **When they go out.** When you open Gearslip, at most once a day, and after each drive.
- **Why.** To find out which cars, phones, and releases work, and to fix the ones that don't.
We never sell the notes, show ads, or share them with anyone, and nothing in them is used
to build a profile of you.
- **Where they're kept.** On our server, which runs on Cloudflare. Cloudflare stores the
notes for us and doesn't use them for anything else.
- **How long.** Two years. Older notes are deleted automatically.
## Other services Gearslip contacts
Each of these gets only what it needs, and each has its own privacy policy.
| Service | When | What it receives |
|---|---|---|
| [Open-Meteo](https://open-meteo.com/en/terms) | When the car's dashboard shows the weather | Your phone's location |
| BetterLyrics (lyrics-api.boidu.dev), [LRCLIB](https://lrclib.net), and KuGou | While the lyrics panel is open, for each song that plays | The song's title, artist, album, and length |
| [GitHub](https://docs.github.com/en/site-policy/privacy-policies/github-general-privacy-statement) | Once a day, to check for an update, unless you turn checks off | A request for the latest release |
| GitHub | In setup, if you choose to download the certificate | A request for the certificate file |
| [Hugging Face](https://huggingface.co/privacy) | When you download a speech model | A request for the model file |
| [OpenFreeMap](https://openfreemap.org) | When an app in the car shows a list of places on a map | Requests for the map tiles of the area shown |
| [EasyList](https://easylist.to) | About once a week on Wi-Fi, while the browser's ad blocker is on | A request for the filter lists |
| Any address you give | When you download a certificate from a link, or add your own filter list | A request for that file |
Every request also carries your IP address, as any connection to the internet does.
The web browser in the car searches with the engine you pick (DuckDuckGo unless you change
it) and opens whatever sites you visit, like any browser. A bookmark's icon comes from that
site itself. The map, music, and messaging apps you use through Gearslip are other
companies' apps, with their own policies.
## Your rights
You can turn usage notes off at any time. Untick a phone detail in Settings, and our server
drops the copy it kept. To see the notes we hold for your phone, or to have them all
deleted, write to 9xgpevy3@anonaddy.me and include your phone's ID. It's the "id" line at the
top of the note in Settings, under Usage notes, and you can copy it from there. We answer
within 30 days.
If you live in the European Union or the United Kingdom, you also have the right to
complain to your data protection authority. We rely on your consent to collect usage notes,
and you can withdraw it by turning them off.
## Children
Gearslip is for drivers, not for children under 13. We don't knowingly collect anything from
them.
## Changes
When this policy changes, the new version goes here with a new date, and the release notes
say so.
## Contact
Twink Software LLC, 9xgpevy3@anonaddy.me.
+101 -121
View File
@@ -4,133 +4,93 @@
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)
![Vela Maps navigating on the car screen, with the music player beside the map](docs/screenshots/navigation.png)
[![forthebadge](https://forthebadge.com/badges/compatibility-club-penguin.svg)](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)
[Fix or add your language on Weblate](https://hosted.weblate.org/engage/gearslip/).
Translations go through Weblate only; pull requests that edit them get closed.
[![Translation status by language](https://hosted.weblate.org/widget/gearslip/app/multi-auto.svg)](https://hosted.weblate.org/engage/gearslip/)
Questions, ideas?
[Join the discussion on Matrix](https://matrix.to/#/!mf0_mgBd5KqKpc9FfX_wHp1jFCcwAtPrndIn_oGIyMk?via=matrix.org).
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.
> [!WARNING]
> Gearslip is **alpha software**. It can drop the connection, freeze the car screen, or fail
> to start, and a new release can break a car that worked before. Set it up while parked, and
> keep another way to navigate.
> [!WARNING]
> On GrapheneOS, make sure **USB-C port control** isn't set to **Charging-only**. That setting
> blocks USB data, so the car never sees the phone, even with a data cable. Set it to allow
> data transfer.
Questions, ideas? [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.
## What works
| | |
|---|---|
| ![The launcher, with every car app on the phone](docs/screenshots/launcher.png) | ![Vela Maps showing the route, with the music player beside the map](docs/screenshots/navigation.png) |
| Your car apps in one place. | Vela Maps navigating, with the player beside it. |
| ![Metrolist's song, art, and controls in Gearslip's player](docs/screenshots/music.png) | ![A new message card over Vela Maps](docs/screenshots/message.png) |
| Metrolist in Gearslip's player. | A new message, read aloud at a tap, over the map. |
| ![Synced lyrics beside the player, lit up to the word being sung](docs/screenshots/lyrics.png) | ![DuckDuckGo open in Gearslip's web browser](docs/screenshots/web.png) |
| Lyrics light up word by word as the song plays. | Browse the web while parked. |
| **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. |
| **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. |
| **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. |
| **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
| | |
|---|---|
| ![The launcher, with every car app on the phone](docs/screenshots/launcher.png) | ![Metrolist's song, art, and controls in Gearslip's player](docs/screenshots/music.png) |
| Your car apps in one place. | Metrolist in Gearslip's player. |
| ![A new message card over Vela Maps](docs/screenshots/message.png) | ![Synced lyrics beside the player, lit up to the word being sung](docs/screenshots/lyrics.png) |
| A new message, read aloud at a tap, over the map. | Lyrics light up word by word as the song plays. |
| ![The voice assistant listening over Vela Maps](docs/screenshots/assistant.png) | ![Three routes to a coffee shop in Vela Maps, each with its time and distance](docs/screenshots/places.png) |
| Ask for a place, a song, or an app. | Pick a route to a coffee shop. 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. |
| ![DuckDuckGo open in Gearslip's web browser](docs/screenshots/web.png) | |
| Browse the web while parked. | |
## Features
- **Phone calls.** Dial from a keypad, or search your contacts, then confirm before the
call goes out. When a call comes in, answer or decline it on the car screen. During a
call, the nav bar shows who you're talking to, and you mute or hang up from there.
- **Navigation.** Gearslip hosts real Car App Library map apps on the car screen.
Organic Maps, Vela, OsmAnd, Waze, Flitsmeister, MAPS.ME, and MapQuest work today. While you
navigate, a big turn card on the left shows the arrow, the distance, and your lane, and
the map keeps the right side. Turns from Google Maps or Waze running on the phone show in
the nav bar, and on the instrument cluster in cars with a screen there.
- **Music.** Browse, play, and queue tracks from a car-enabled media app, with a Now
Playing view, an Up Next queue, and lyrics. Type a search, and apps that accept one
play the best match.
- **The web.** A browser with an address bar, back and forward, and its own on-screen
keyboard, for anything that doesn't ship a car interface of its own.
- **Weather and the day ahead.** A dashboard screen shows the week's calendar next to
the current and coming weather.
- **Voice.** Tap the mic, or press the talk button on the wheel, and say "navigate to the
nearest gas station", "play Daft Punk", or "call Mom". Say "help" for the full list.
Speech turns into text on the phone, and nothing you say leaves it.
- **Messages.** New messages appear as a small card on the car screen. Gearslip reads
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, and hide the ones you don't
use from the Apps card on the phone.
- **Car controls.** Steering wheel buttons, control knobs, and D-pads all work.
- **Your phone's colors.** The car screen and the phone app take their colors from your
phone's Material You palette, and every card and button matches.
- **Themes.** Choose flat buttons or skeuomorphic ones that look like real keys.
- **Small screens.** Car display density makes text and buttons bigger in car apps.
- **Your language.** Gearslip comes in 19 languages besides English, some only partly
translated. Pick one in Settings. Voice commands understand English only for now.
- **Settings on the phone.** The car's settings sit on the phone's home screen too, so you
can set things up before you drive. A change on either screen shows on both.
- **Navigation.** Gearslip hosts real map apps on the car screen. While you navigate, a big
turn card shows the arrow, the distance, and your lane, and the map keeps the rest.
- **Music.** Browse, play, and queue from any car-ready media app. Type a search, and apps
that accept one play the best match.
- **Phone calls.** During a call, the nav bar shows who you're talking to, and you mute or
hang up from there.
- **Messages and notifications.** Only messages, missed calls, alarms, and reminders
interrupt you. The rest wait on the dashboard. Music pauses while Gearslip speaks or
listens.
- **Voice.** Tap the mic or press the talk button on the wheel. Say "help" for the full list.
- **The web.** A browser with an address bar, back and forward, and its own keyboard.
- **Weather and the day ahead.** A dashboard shows the week's calendar beside the weather.
- **Your apps, your order.** Apps group by kind. Press and hold one to move it, and hide
the ones you don't use from the Apps card on the phone.
- **Your look.** The car screen takes your phone's Material You colors. Choose flat
buttons or ones that look like real keys, and make text bigger on small screens.
- **Settings on the phone.** Set things up before you drive. A change on either screen
shows on both.
## Status
## Install
- **Wired or wireless.** Plug the phone into the car's USB port, or, in a car with wireless
Android Auto, tap Connect wirelessly and pick the car. Pair the phone with the car over
Bluetooth first. 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.
- **Setup walks you through it.** First launch asks for the Android Auto certificate,
then one permission at a time, each with a plain reason and an Allow or Skip. A help
screen inside the car answers anything setup didn't cover.
- **Templated apps work.** Gearslip renders the Car App Library's templates on the car
screen, and it draws its own keyboard for search, sign-in, and text fields. Alerts, such
as a map app asking whether a hazard is still there, show on the car screen too. It has
been tested against several real navigation and media apps.
- **Gearslip can be a head unit's home screen.** On a head unit that runs Android, or a
spare phone or tablet on the dash, turn on Use as launcher in Settings > Connection. The
car screen fills the display, and an All apps page opens everything else.
- **Gearslip works with [LIVI](https://github.com/f-io/LIVI),** the open-source head unit
used in homemade car dashboards.
- **Some apps refuse Gearslip.** Spotify 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.
1. Install Gearslip from [the releases page](https://github.com/twinksoftware/Gearslip/releases)
or through Obtainium.
2. Open it. Setup asks where your Android Auto certificate comes from, then for one
permission at a time, each with a plain reason. A help screen in the car answers the rest.
3. Plug the phone into the car's USB port. In a car with wireless Android Auto, pair the
phone with the car over Bluetooth, then tap Connect wirelessly and pick the car.
## 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
[AppVerifier](https://github.com/soupslurpr/AppVerifier): share this block to it, or compare it
@@ -141,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
```
## Good to know
- **Music reaches the car over Bluetooth or through Gearslip.** Setup asks which.
Bluetooth is the default. Android never asks for permission, and your map app's voice
reaches the car too. Through Gearslip, the sound travels over the car connection, cable
or wireless. It sounds a little clearer, with no delay, but each drive Android asks you
to share your screen, which lets Gearslip send the sound. Gearslip never reads the
screen. Capturing playback is the only way an ordinary app can pass another app's audio
along.
- **Gearslip needs no adb, root, or Shizuku.** Every feature relies on an ordinary
Android permission or an on-device consent dialog. For audio through Gearslip, setup
offers two optional adb commands. One removes the audio dialog for good, and the other
keeps notifications visible while audio plays.
- **Apps without a car screen** can still reach the car through a separate screen-sharing
mode.
- **Gearslip works with [LIVI](https://github.com/f-io/LIVI),** the open-source head unit
used in homemade car dashboards.
## Usage notes
Gearslip can send short notes that show which cars it works in. They stay off unless you
@@ -152,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
model, whether a car connected that day, and each car's head unit, model, year, screen
size, and connection result, with whether that drive used the cable or wireless and
whether music went through Gearslip or over Bluetooth. If you tick it, how each drive went: how far
the connection got, how long it lasted, and how it ended. Settings shows the exact note. Untick a phone detail, and
the server drops the copy it kept.
whether music went through Gearslip or over Bluetooth. If you tick it, how each drive
went: how far the connection got, how long it lasted, and how it ended. Settings shows
the exact note. Untick a phone detail, and the server drops the copy it kept.
- **What they leave out.** Your name, accounts, location, contacts, messages, and
anything you type or play.
- **How phones are counted.** A random number counts each phone once. The server keeps
@@ -164,6 +142,19 @@ turn them on, in setup or in Settings.
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.
[![Translation status by language](https://hosted.weblate.org/widget/gearslip/app/multi-auto.svg)](https://hosted.weblate.org/engage/gearslip/)
## How Gearslip is made
Gearslip is my beloved child. I decide what it does and how it looks, and Claude writes nearly
@@ -173,17 +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,
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. Please
don't send translations as pull requests: Weblate holds its own copy of every language, and
changes made in git collide with it.
## Building
```
+38
View File
@@ -469,6 +469,44 @@ home-screen pane comes later. Anything that needs its own rendering belongs in a
---
## Works with Gearslip
Once your app runs well on Gearslip, say so in two places: your manifest and your README.
### In your manifest
Add these lines to `<application>`:
```xml
<meta-data
android:name="app.seb3thehacker.gearslip.works_with"
android:value="true" />
<meta-data
android:name="app.seb3thehacker.gearslip.about"
android:resource="@string/gearslip_about" />
```
Gearslip then marks your app "Works with Gearslip" in its Recommended apps list and on your app's
page in Gearslip. An installed app with the flag gets its own place at the top of the list.
The second entry is optional. It gives your page one sentence on what your app does in the car,
such as "Spots trackers and cameras nearby." Write it in place with `android:value` if you have no
string resource for it.
The flag is a label. Gearslip finds and runs your app the same way with or without it.
### In your README
Add the badge:
```markdown
[![Works with Gearslip](https://raw.githubusercontent.com/twinksoftware/gearslip/main/docs/badge/works-with-gearslip.svg)](https://github.com/twinksoftware/gearslip)
```
It matches the flat badges most READMEs already show.
---
## Further reading
- [README.md](../README.md) - what Gearslip is.
+16
View File
@@ -0,0 +1,16 @@
<svg xmlns="http://www.w3.org/2000/svg" width="147" height="20" role="img" aria-label="Works with Gearslip">
<title>Works with Gearslip</title>
<clipPath id="r"><rect width="147" height="20" rx="3" fill="#fff"/></clipPath>
<g clip-path="url(#r)">
<rect width="88" height="20" fill="#555"/>
<rect x="88" width="59" height="20" fill="#8C1F1F"/>
</g>
<g transform="translate(5 3.5) scale(0.228) translate(-29.5 -25.5)" fill="#fff">
<path fill-rule="evenodd" d="M54 36.18 58.64 30.7 67.2 34.24 66.6 41.4 71.82 54 77.3 58.64 73.76 67.2 66.6 66.6 67.2 73.76 58.64 77.3 54 71.82 49.36 77.3 40.8 73.76 41.4 66.6 34.24 67.2 30.7 58.64 36.18 54 30.7 49.36 34.24 40.8 41.4 41.4 40.8 34.24 49.36 30.7ZM54 46.08A7.92 7.92 0 1 1 53.99 46.08Z"/>
<path d="M78.61 40.7 85.31 43.29 84.74 52.55 77.78 54.31Z"/>
</g>
<g fill="#fff" font-family="Verdana,Geneva,DejaVu Sans,sans-serif" font-size="11" text-rendering="geometricPrecision">
<text x="22" y="14" textLength="60">works with</text>
<text x="94" y="14" textLength="47">Gearslip</text>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.0 KiB

+51 -5
View File
@@ -24,6 +24,36 @@ val keystoreProperties = Properties().apply {
).firstOrNull { it.exists() }?.inputStream()?.use { load(it) }
}
// The version comes from the newest v* git tag, so a build can never disagree with its tag. A
// build of the tagged commit is named after it ("0.4.0-beta"); anything later adds the commits
// since and the commit ("0.4.0-beta+3.ba4374e"), so a test build can't pass for a release.
// The version code is worked out from the numbers, ((major*100 + minor)*100 + patch)*10 + stage,
// where stage is 1 for alpha, 2 for beta and 9 for a finished release. It only ever goes up as
// long as minor and patch stay under 100. Changelog entries use the same number.
class GitVersion(val name: String, val code: Int)
val gitVersion: GitVersion = run {
val described = runCatching {
providers.exec {
commandLine("git", "describe", "--tags", "--match", "v*", "--abbrev=7")
isIgnoreExitValue = true
}.standardOutput.asText.get().trim()
}.getOrNull().orEmpty()
val match = Regex("""^v((\d+)\.(\d+)\.(\d+)(?:-([A-Za-z]+))?)(?:-(\d+)-g([0-9a-f]+))?$""").find(described)
?: return@run GitVersion("0.0.0-dev", 1) // no git or no tags, e.g. a source download
val (base, major, minor, patch, suffix, since, commit) = match.destructured
val stage = when (suffix.lowercase()) {
"" -> 9
"beta" -> 2
"alpha" -> 1
else -> 0
}
GitVersion(
name = if (since.isEmpty()) base else "$base+$since.$commit",
code = ((major.toInt() * 100 + minor.toInt()) * 100 + patch.toInt()) * 10 + stage,
)
}
// Keeps build output out of the module folder: <root>/build/gearslip/... instead of
// <root>/gearslip/build/..., so everything this project produces lands under one
// root-level build/ directory. Set before android {}, which works out the path to R8's default
@@ -38,9 +68,9 @@ android {
applicationId = "app.seb3thehacker.gearslip"
minSdk = 31
targetSdk = 37
versionCode = 15
versionCode = gitVersion.code
// -PgearslipVersionName=0.1.99-test makes a build that thinks it's older, to try the updater.
versionName = project.findProperty("gearslipVersionName")?.toString() ?: "0.3.40-alpha"
versionName = project.findProperty("gearslipVersionName")?.toString() ?: gitVersion.name
manifestPlaceholders["appLabel"] = "Gearslip"
// Where opted-in phones send their daily usage note: the stats-worker/ Worker's address,
// set as gearslipStatsUrl in gradle.properties. Left empty, nothing is ever sent.
@@ -94,8 +124,15 @@ android {
// Lists each language that has a translation in the app's locale config, so Android 13+
// offers it under the system's per-app languages. English is the unqualified default
// (res/resources.properties).
//
// Only languages that are mostly translated ship, so the app never comes up half in English.
// Danish and Estonian are still early on Weblate; add them here once they're close to done.
androidResources {
generateLocaleConfig = true
localeFilters += listOf(
"en", "ar", "cs", "de", "es", "fr", "it", "ja", "ko", "nl", "pl", "pt-rBR", "ro", "ru",
"sv", "tr", "uk", "zh-rCN",
)
}
lint {
@@ -109,11 +146,16 @@ android {
}
defaultConfig {
// Speech-to-text (sherpa-onnx, in jniLibs) ships for 64-bit ARM only: every phone
// Gearslip runs on, and one copy of the native library instead of four.
ndk { abiFilters += "arm64-v8a" }
// Speech-to-text (sherpa-onnx 1.13.8, static-link-onnxruntime build, in jniLibs) is the
// only native code. One APK for all three, since the updater takes the release's only APK;
// 32-bit x86 is left out, nothing that drives a car runs it.
ndk { abiFilters += listOf("arm64-v8a", "armeabi-v7a", "x86_64") }
}
// Compressed, the three copies cost a 33 MB download instead of 75 MB; the phone unpacks
// only its own at install.
packaging { jniLibs { useLegacyPackaging = true } }
testOptions {
// GearslipLog calls android.util.Log, which is a stub on the local JVM.
unitTests.isReturnDefaultValues = true
@@ -154,6 +196,10 @@ dependencies {
implementation(libs.androidx.car.app)
implementation(libs.androidx.car.app.projected)
implementation(libs.androidx.media)
// Document-start scripts, so the ad blocker hides ad slots before a page first paints.
implementation(libs.androidx.webkit)
// The map behind a place list (PlaceListMapTemplate), which the host draws, not the app.
implementation(libs.maplibre.android)
testImplementation(libs.junit)
}
+32 -1
View File
@@ -5,6 +5,8 @@
<!-- The projected WebView loads real pages; without this every load fails silently. -->
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
<!-- Lets an update Gearslip installed itself skip Android's prompt. -->
<uses-permission android:name="android.permission.UPDATE_PACKAGES_WITHOUT_USER_ACTION" />
<!--
Phone mirroring. Android refuses to hand over a MediaProjection unless a foreground
@@ -19,6 +21,11 @@
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_LOCATION" />
<!--
Lets LocationKeepAlive start with the phone locked, when a car connects by itself. Only
asked for from Home while a car is connected, and only used while one is (issue #99).
-->
<uses-permission android:name="android.permission.ACCESS_BACKGROUND_LOCATION" />
<!-- Keeps a wireless start going with the phone locked; see WirelessKeepAlive. -->
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_CONNECTED_DEVICE" />
@@ -64,7 +71,8 @@
<!--
Phone calls on the car screen. READ_PHONE_STATE and READ_CALL_LOG together are what a
call's own state (ringing, in progress) and the incoming number need; READ_CONTACTS
call's own state (ringing, in progress) and the incoming number need, and READ_CALL_LOG
also lists recent calls on the car's Phone screen, read on the phone only; READ_CONTACTS
turns that number into a name; ANSWER_PHONE_CALLS lets the driver pick up from here.
Declining a call, and the caller ID itself, need Gearslip set as the phone's caller ID
and spam app too - an on-device consent, not a permission declared here.
@@ -186,6 +194,21 @@
the head unit to Android Auto, without disabling the screen the switch is on.
-->
<!-- App info › Storage › Manage space: clears lyrics, voice models and logs one at a time. -->
<!-- "Share" a page from any browser to add it to the car browser's start page (issue #85). -->
<activity
android:name=".browser.ShareToBrowserActivity"
android:exported="true"
android:excludeFromRecents="true"
android:label="@string/browser_share_label"
android:noHistory="true"
android:theme="@android:style/Theme.Translucent.NoTitleBar">
<intent-filter>
<action android:name="android.intent.action.SEND" />
<category android:name="android.intent.category.DEFAULT" />
<data android:mimeType="text/plain" />
</intent-filter>
</activity>
<activity
android:name=".ui.ManageSpaceActivity"
android:exported="true"
@@ -216,6 +239,14 @@
android:name=".notify.TestMessageReceiver"
android:exported="false" />
<receiver
android:name=".update.UpdateReceiver"
android:exported="false">
<intent-filter>
<action android:name="android.intent.action.MY_PACKAGE_REPLACED" />
</intent-filter>
</receiver>
<service
android:name=".mirror.MirrorService"
android:exported="false"
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,175 @@
package app.seb3thehacker.gearslip
import android.content.Context
import java.io.ByteArrayInputStream
import java.io.ByteArrayOutputStream
import java.net.HttpURLConnection
import java.net.URL
import java.security.KeyFactory
import java.security.KeyStore
import java.security.PrivateKey
import java.security.cert.CertificateFactory
import java.security.cert.X509Certificate
import java.security.spec.PKCS8EncodedKeySpec
import java.util.Base64
/**
* A certificate fetched from a link the driver pastes (issue #15), in whatever form it comes:
* a PKCS#12 file, with or without a password, or a certificate and its private key as PEM or
* DER, together in one file or in two. Whatever arrives is converted to the PKCS#12 identity
* Gearslip presents to the car, and stored as an imported certificate.
*
* Gearslip ships no address of its own. The links are kept on the phone so the same files can
* be fetched again from Settings, and they're only ever fetched when the driver asks.
*/
object CertFromLink {
sealed interface Outcome {
/** Imported, and in use from the next connection. */
data object Done : Outcome
/** A PKCS#12 file that needs its password. */
data object NeedsPassword : Outcome
/** A certificate on its own: its private key is at another address. */
data object NeedsKey : Outcome
}
private const val KEY_CERT_LINK = "cert_link"
private const val KEY_KEY_LINK = "cert_key_link"
private const val MAX_BYTES = 1L * 1024 * 1024
/** The password the converted file is stored under; it never leaves the app's storage. */
private const val STORE_PASSWORD = "gearslip"
/** The link used last, for Download again, or null when none was ever used. */
fun savedLink(context: Context): Pair<String, String?>? {
val cert = AppSettings.getString(context, KEY_CERT_LINK, "").ifEmpty { return null }
return cert to AppSettings.getString(context, KEY_KEY_LINK, "").ifEmpty { null }
}
/** Runs on a background thread. Throws with a message a driver can read when it can't be used. */
fun import(context: Context, certLink: String, keyLink: String?, password: String?): Outcome {
val first = download(certLink)
// The key's link is fetched only when the first file turns out to hold no key.
var second: ByteArray? = null
var converted = convert(first, null, password)
if (converted is Converted.NeedsKey && !keyLink.isNullOrBlank()) {
second = download(keyLink)
converted = convert(first, second, password)
}
return when (converted) {
Converted.NeedsKey -> Outcome.NeedsKey
Converted.NeedsPassword -> Outcome.NeedsPassword
is Converted.Ready -> {
CertProvider.importFromBytes(context, converted.p12, converted.password)
remember(context, certLink, keyLink.takeIf { second != null })
Outcome.Done
}
}
}
internal sealed interface Converted {
class Ready(val p12: ByteArray, val password: String) : Converted
data object NeedsPassword : Converted
data object NeedsKey : Converted
}
/**
* Turns what was downloaded into a PKCS#12 file with its password. [first] is the file at
* the first link; [second] the key from the second, once asked for.
*/
internal fun convert(first: ByteArray, second: ByteArray?, password: String?): Converted {
// A PKCS#12 file is DER too, so the certificate parser gets the first try.
val certs = certificates(first)
if (certs.isEmpty()) {
val pass = password.orEmpty()
val loaded = runCatching { KeyStore.getInstance("PKCS12").apply { load(ByteArrayInputStream(first), pass.toCharArray()) } }
if (loaded.isFailure) {
if (password == null) return Converted.NeedsPassword
error("That password doesn't open the file, or it isn't a certificate.")
}
val store = loaded.getOrThrow()
require(store.aliases().asSequence().any { store.isKeyEntry(it) }) { "The file holds no private key, so the car can't be shown it." }
return Converted.Ready(first, pass)
}
val key = privateKey(first) ?: second?.let {
privateKey(it) ?: error("The second link isn't a private key in a form Gearslip reads.")
} ?: return Converted.NeedsKey
// The certificate that goes with the key comes first, and any issuers after it.
val leaf = certs.firstOrNull { belongs(key, it) } ?: error("The private key doesn't belong to this certificate.")
val chain = listOf(leaf) + certs.filter { it !== leaf }
val store = KeyStore.getInstance("PKCS12").apply {
load(null, null)
setKeyEntry("phone", key, STORE_PASSWORD.toCharArray(), chain.toTypedArray())
}
val bytes = ByteArrayOutputStream().use { store.store(it, STORE_PASSWORD.toCharArray()); it.toByteArray() }
GearslipLog.i("certificate from a link: ${leaf.subjectX500Principal}")
return Converted.Ready(bytes, STORE_PASSWORD)
}
private fun remember(context: Context, cert: String, key: String?) {
AppSettings.putString(context, KEY_CERT_LINK, cert)
AppSettings.putString(context, KEY_KEY_LINK, key.orEmpty())
}
private fun download(link: String): ByteArray {
val url = link.trim()
require(url.startsWith("https://") || url.startsWith("http://")) { "That isn't a web address." }
val connection = (URL(url).openConnection() as HttpURLConnection).apply {
connectTimeout = 15_000
readTimeout = 15_000
instanceFollowRedirects = true
}
try {
if (connection.responseCode !in 200..299) error("The server answered ${connection.responseCode}.")
val out = ByteArrayOutputStream()
connection.inputStream.use { input ->
val buffer = ByteArray(16 * 1024)
while (true) {
val read = input.read(buffer)
if (read < 0) break
out.write(buffer, 0, read)
require(out.size() <= MAX_BYTES) { "That file is far too big to be a certificate." }
}
}
return out.toByteArray()
} finally {
connection.disconnect()
}
}
/** Every certificate in PEM text, or the one certificate a DER file holds. */
private fun certificates(bytes: ByteArray): List<X509Certificate> {
val factory = CertificateFactory.getInstance("X.509")
val pem = pemBlocks(bytes, "CERTIFICATE")
if (pem.isNotEmpty()) return pem.map { factory.generateCertificate(ByteArrayInputStream(it)) as X509Certificate }
return listOfNotNull(runCatching { factory.generateCertificate(ByteArrayInputStream(bytes)) as X509Certificate }.getOrNull())
}
/** A PKCS#8 or PKCS#1 key, as PEM or as PKCS#8 DER; null when there's none. */
private fun privateKey(bytes: ByteArray): PrivateKey? {
require(pemBlocks(bytes, "ENCRYPTED PRIVATE KEY").isEmpty()) { "The private key is encrypted. Gearslip needs one without a passphrase, or a .p12 file." }
pemBlocks(bytes, "PRIVATE KEY").firstOrNull()?.let { return pkcs8(it) }
if (pemBlocks(bytes, "RSA PRIVATE KEY").isNotEmpty()) return PemKeyParser.parsePrivateKey(String(bytes)).getPrivate()
if (String(bytes).contains("-----BEGIN")) return null
return runCatching { pkcs8(bytes) }.getOrNull()
}
private fun pkcs8(der: ByteArray): PrivateKey = runCatching {
KeyFactory.getInstance("RSA").generatePrivate(PKCS8EncodedKeySpec(der))
}.getOrElse { KeyFactory.getInstance("EC").generatePrivate(PKCS8EncodedKeySpec(der)) }
/**
* Whether [key] is [cert]'s. RSA keys are matched by modulus, which every Android provider
* exposes, even one that hides the rest of a PKCS#8 key. Any other kind is taken on trust.
*/
private fun belongs(key: PrivateKey, cert: X509Certificate): Boolean {
val public = cert.publicKey
return if (key is java.security.interfaces.RSAKey && public is java.security.interfaces.RSAKey) key.modulus == public.modulus else key.algorithm == public.algorithm
}
private fun pemBlocks(bytes: ByteArray, type: String): List<ByteArray> {
val text = runCatching { String(bytes, Charsets.US_ASCII) }.getOrDefault("")
return Regex("-----BEGIN $type-----([A-Za-z0-9+/=\\s]+)-----END $type-----").findAll(text)
.map { Base64.getMimeDecoder().decode(it.groupValues[1]) }.toList()
}
}
@@ -4,12 +4,29 @@ package app.seb3thehacker.gearslip
* What changed in each release, newest first, shown on the car screen after an update (and on
* the phone). Only the changes a driver would notice. Plain words, short lines: this is read in
* a parked car.
*
* After 0.3.40, the version code is the one the build works out from the tag (see gitVersion in
* build.gradle.kts): ((major*100 + minor)*100 + patch)*10 + stage, so 0.4.0-beta is 4002. Older
* entries keep the hand-set codes they shipped with, which are all lower.
*/
object Changelog {
class Release(val versionCode: Int, val versionName: String, val changes: List<String>)
val releases = listOf(
Release(
4001, "0.4.0-alpha",
listOf(
"The web browser opens on your bookmarks, with the search engine you choose and ads blocked.",
"Add any app to the car as a music app or a messenger, under Apps on the phone.",
"Apps that list places show them on a map.",
"Like, shuffle, repeat, and lyrics sit in the nav bar while the player is beside the map.",
"Map apps keep their GPS with the phone locked once location is allowed all the time.",
"Apps that don't work show a red X and start unticked.",
"Download a certificate from a link, under Certificate in Settings.",
"Car audio moved to Settings > Connection.",
),
),
Release(
15, "0.3.40-alpha",
listOf(
@@ -4,6 +4,7 @@ import android.content.Context
import android.os.Handler
import android.os.Looper
import app.seb3thehacker.gearslip.car.ClusterScreen
import kotlinx.coroutines.flow.MutableStateFlow
/**
* Gearslip's picture on the car's instrument-cluster screen: a second video sink, for a car that
@@ -15,8 +16,12 @@ import app.seb3thehacker.gearslip.car.ClusterScreen
* -> VideoFocusRequest <- VideoFocusNotification { projected }
* -> Start, CodecConfig, Data... <- Ack
*
* No touch: a cluster has none. Experimental, behind [car.CarSettings.clusterVideoEnabled], and
* untested in a car: none in the notes has a cluster sink.
* No touch: a cluster has none. Experimental, behind [car.CarSettings.clusterVideoEnabled].
*
* Gearslip holds the cluster only while there's a route, as the card has nothing else to show.
* Otherwise the car keeps its own picture there: an Audi's virtual cockpit went black instead of
* showing its map (issue #56). [watchRoute] asks for the screen when a route starts and hands it
* back once there has been none for [NO_ROUTE_MS].
*/
internal class ClusterVideo(
private val context: Context,
@@ -30,6 +35,13 @@ internal class ClusterVideo(
private var configIndex = 0
@Volatile private var started = false
/** Configured and ready for focus requests. */
@Volatile private var ready = false
/** The car shows Gearslip's picture now; frames aren't sent while it shows its own. */
@Volatile private var projected = false
/** Gearslip has asked for the screen and not handed it back since. */
private var wanted = false
private var noRouteSince = 0L
@Volatile private var running = true
@Volatile private var source: VideoSource? = null
private var projector: ScreenProjector? = null
@@ -39,6 +51,8 @@ internal class ClusterVideo(
@Volatile private var maxUnacked = 1
@Volatile private var lastAckAt = 0L
private var frames = 0
/** The part of the picture the car's own gauges and bars leave clear, as the car last said. */
private val clearArea = MutableStateFlow<Insets?>(null)
fun onMessage(messageId: Int, body: ByteArray) {
when (messageId) {
@@ -57,16 +71,19 @@ internal class ClusterVideo(
// The cluster gets the smallest picture it offers: a turn card needs no more.
configIndex = offered.minByOrNull { service.configs.getOrNull(it)?.pixelCount() ?: Int.MAX_VALUE } ?: 0
log.i("<- Config status=${Wire.varint(fields, 1)} max_unacked=$maxUnacked offered=$offered - using $configIndex")
val focus = Protobuf.varintField(2, GearslipRunner.VIDEO_FOCUS_PROJECTED.toLong()) + Protobuf.varintField(3, 1L)
send(GearslipRunner.MSG_VIDEO_FOCUS_REQUEST, focus)
log.i("-> VideoFocusRequest(mode=PROJECTED)")
ready = true
main.post(watchRoute)
}
GearslipRunner.MSG_VIDEO_FOCUS_NOTIFICATION -> {
val mode = Wire.varint(Wire.fields(body), 1)?.toInt() ?: GearslipRunner.VIDEO_FOCUS_PROJECTED
log.i("<- VideoFocusNotification: mode=$mode")
val projected = mode == GearslipRunner.VIDEO_FOCUS_PROJECTED || mode == GearslipRunner.VIDEO_FOCUS_PROJECTED_NO_INPUT
if (projected && !started) startStream()
val now = mode == GearslipRunner.VIDEO_FOCUS_PROJECTED || mode == GearslipRunner.VIDEO_FOCUS_PROJECTED_NO_INPUT
val back = now && !projected
projected = now
if (now && !started) startStream()
// Back from the car's own picture: a fresh key frame, so it doesn't start on a partial one.
else if (back) source?.requestSyncFrame()
}
GearslipRunner.MSG_MEDIA_ACK -> {
@@ -78,10 +95,42 @@ internal class ClusterVideo(
}
}
// UpdateUiConfigRequest { AdditionalVideoConfig { display_insets = 1 } }: an Audi's
// virtual cockpit sends one as the picture starts, and draws its dials over the frame.
GearslipRunner.MSG_VIDEO_UI_CONFIG -> {
log.i("<- UpdateUiConfigRequest (${body.size} bytes)\n${Protobuf.describe(body)}")
clearArea.value = insetsFrom(body)
}
else -> log.i("<- unhandled message id=$messageId (${body.size} bytes)")
}
}
/** Once a second: the cluster follows whether there's a route. */
private val watchRoute = object : Runnable {
override fun run() {
if (!running || !ready) return
val route = app.seb3thehacker.gearslip.car.NavTurn.current() != null
val now = System.currentTimeMillis()
if (route) noRouteSince = 0L else if (noRouteSince == 0L) noRouteSince = now
when {
route && !wanted -> requestFocus(GearslipRunner.VIDEO_FOCUS_PROJECTED, "a route started")
!route && wanted && now - noRouteSince >= NO_ROUTE_MS -> requestFocus(GearslipRunner.VIDEO_FOCUS_NATIVE, "no route")
}
main.postDelayed(this, WATCH_MS)
}
}
private fun requestFocus(mode: Int, why: String) {
wanted = mode == GearslipRunner.VIDEO_FOCUS_PROJECTED
// VideoFocusRequestNotification { disp_channel_id = 1; mode = 2; reason = 3 }, as the main
// screen asks for its own picture back.
val reason = if (wanted) 1L else GearslipRunner.VIDEO_FOCUS_REASON_LAUNCH_NATIVE.toLong()
val body = Protobuf.varintField(1, channelId.toLong()) + Protobuf.varintField(2, mode.toLong()) + Protobuf.varintField(3, reason)
runCatching { send(GearslipRunner.MSG_VIDEO_FOCUS_REQUEST, body) }
log.i("-> VideoFocusRequest(mode=${if (wanted) "PROJECTED" else "NATIVE"}): $why")
}
private fun startStream() {
val config = service.configs.getOrNull(configIndex) ?: service.configs.firstOrNull() ?: return
val (width, height) = config.pixelSize() ?: run {
@@ -119,7 +168,7 @@ internal class ClusterVideo(
projector = ScreenProjector(context).also {
it.start(surface, width, height, density, name = "gearslip-cluster") {
// The margins the car crops off are kept clear, as on the main screen.
ClusterScreen(config.widthMargin, config.heightMargin)
ClusterScreen(config.widthMargin, config.heightMargin, density, clearArea)
}
}
}
@@ -138,7 +187,7 @@ internal class ClusterVideo(
ackLock.wait(GearslipRunner.ACK_STALL_MS - since + 1)
}
}
if (!running) return
if (!running || !projected) return
val timestamp = ByteArray(8)
for (i in 0 until 8) timestamp[i] = ((presentationTimeUs shr ((7 - i) * 8)) and 0xFF).toByte()
synchronized(ackLock) { inFlight++ }
@@ -146,8 +195,18 @@ internal class ClusterVideo(
if (++frames <= 2) log.i("-> Data frame #$frames (${data.size} bytes)")
}
/** VideoInsets { top = 1; bottom = 2; left = 3; right = 4 }, or null when the car sent none. */
private fun insetsFrom(body: ByteArray): Insets? {
val config = Wire.bytes(Wire.fields(body), 1) ?: return null
val insets = Wire.fields(Wire.bytes(Wire.fields(config), 1) ?: return null)
fun side(n: Int) = Wire.varint(insets, n)?.toInt() ?: 0
return Insets(top = side(1), bottom = side(2), left = side(3), right = side(4))
.also { log.i("the car keeps clear: $it") }
}
fun stop() {
running = false
main.removeCallbacks(watchRoute)
synchronized(ackLock) { ackLock.notifyAll() }
main.post {
projector?.stop()
@@ -160,5 +219,8 @@ internal class ClusterVideo(
private companion object {
// A turn card changes a few times a minute; the encoder repeats the last frame between.
const val FRAME_RATE = 15
const val WATCH_MS = 1_000L
/** A gap in the turns this long is the route over, not a reroute or a moment between steps. */
const val NO_ROUTE_MS = 10_000L
}
}
@@ -151,6 +151,7 @@ class GearslipActivity : ComponentActivity(), Projection {
app.seb3thehacker.gearslip.wireless.WirelessLink.onConnected = { socket ->
runOnUiThread { startWireless(socket) }
}
app.seb3thehacker.gearslip.wireless.WirelessLink.sessionWaiting = { runner?.waitingForVersion == true }
app.seb3thehacker.gearslip.wireless.WirelessAuto.showApproval = { chooser, address ->
runOnUiThread {
approvingCar = address
@@ -158,6 +159,10 @@ class GearslipActivity : ComponentActivity(), Projection {
}
}
CarSettings.init(this)
// Before any car connects: CarServices only starts once the car shows Gearslip's screen,
// and a car that waits to know the phone before it shows it would never hear (issue #43).
app.seb3thehacker.gearslip.car.CarBluetooth.init(this)
app.seb3thehacker.gearslip.browser.BrowserSettings.init(this)
AppSettings.applyGearslipEnabled(this)
// A capture that died without tidying up would leave notifications exposed to every
// later screen share. Nothing is capturing if the service is not running, so undo it.
@@ -245,6 +250,7 @@ class GearslipActivity : ComponentActivity(), Projection {
override fun onDestroy() {
super.onDestroy()
app.seb3thehacker.gearslip.wireless.WirelessLink.onConnected = null
app.seb3thehacker.gearslip.wireless.WirelessLink.sessionWaiting = null
app.seb3thehacker.gearslip.wireless.WirelessAuto.showApproval = null
runCatching { unregisterReceiver(detachReceiver) }
PhoneMirror.onStartRequested = null
@@ -69,11 +69,16 @@ class GearslipRunner(
/** Waits for the certificate only when TLS is first needed; see [run]. */
private val tls: PhoneTls by lazy { identityTask.get() }
/** This session sent the short service discovery request. */
@Volatile private var plainDiscovery = false
private var authenticationFailed = false
private var drainWriterOnExit = false
@Volatile private var stopped = false
@Volatile private var state = State.WAIT_VERSION
/** Connected, but the car hasn't sent its first message yet. */
val waitingForVersion: Boolean get() = running && state == State.WAIT_VERSION
@Volatile private var lastProgress = System.currentTimeMillis()
@Volatile private var running = true
@@ -82,6 +87,8 @@ class GearslipRunner(
private var selectedConfigIndex: Int? = null
private var vehicleProfile: VehicleProfile? = null
@Volatile private var videoStarted = false
/** The car's screen has been asked for once, so asking again is in order. */
@Volatile private var focusAsked = false
private var videoSource: VideoSource? = null
/** Between the car UI and the encoder when the car squashes the frame (see [screenShape]). */
@@ -169,7 +176,14 @@ class GearslipRunner(
SessionReport.fail(link, "transport failed", t, state.name)
reportDisconnect()
} finally {
// The car hung up between our service discovery request and its answer: try the
// other kind of request with this car next time (see [ServiceDiscoveryQuirks]).
if (!stopped && state == State.WAIT_SDR) ServiceDiscoveryQuirks.droppedAfter(tls.headUnitKey, plainDiscovery)
running = false
// The car is gone, but nothing calls stop() when a wireless session drops: it waits
// for the next one. Close the music channel now, so the audio capture behind it,
// and the screen sharing Android shows for it, stop too (issue #43).
if (!stopped) audioLink?.close()
writer.shutdown()
if (drainWriterOnExit) writer.awaitTermination(2, TimeUnit.SECONDS)
writer.shutdownNow()
@@ -197,6 +211,7 @@ class GearslipRunner(
app.seb3thehacker.gearslip.car.CarData.clear()
CarMic.current = null
CarBluetooth.onCarDisconnected()
CarSettings.carDisconnected()
BluetoothAudioCheck.onCarDisconnected()
LocationCheck.onCarDisconnected()
mediaStatus?.stop()
@@ -486,14 +501,17 @@ class GearslipRunner(
* never answered the request when it had only the label and model (issue #47).
*/
private fun sendServiceDiscoveryRequest() {
val icons = runCatching(phoneIcons).getOrDefault(emptyList())
// A 2019 Fiat 500's Uconnect hangs up on the full request (issue #91), so a car that has
// done that before gets the short one Gearslip sent until 0.3.39: label and model only.
plainDiscovery = ServiceDiscoveryQuirks.wantsPlain(tls.headUnitKey)
val icons = if (plainDiscovery) emptyList() else runCatching(phoneIcons).getOrDefault(emptyList())
var body = ByteArray(0)
icons.take(3).forEachIndexed { i, png -> body += Protobuf.bytesField(i + 1, png) } // small, medium, large icon
body += Protobuf.stringField(4, "Gearslip") + // label_text
Protobuf.stringField(5, Build.MODEL) + // device_name
Protobuf.bytesField(6, Protobuf.stringField(1, java.util.UUID.randomUUID().toString())) // session ID
Protobuf.stringField(5, Build.MODEL) // device_name
if (!plainDiscovery) body += Protobuf.bytesField(6, Protobuf.stringField(1, java.util.UUID.randomUUID().toString())) // session ID
send(MSG_SERVICE_DISCOVERY_REQUEST, body, encrypted = true)
log.i("-> ServiceDiscoveryRequest (encrypted, ${icons.size} icons, ${body.size} bytes)")
log.i("-> ServiceDiscoveryRequest (encrypted, ${if (plainDiscovery) "short, as this car asked" else "${icons.size} icons"}, ${body.size} bytes)")
}
private fun onServiceDiscoveryResponse(body: ByteArray) {
@@ -624,7 +642,10 @@ class GearslipRunner(
onPausedForNow = { CarServices.pausePhoneMediaForNow() },
onGivenBack = { CarServices.resumePhoneMedia() },
)
if (CarSettings.pipeAudio.value) {
// A car known to switch Bluetooth music off gets it through Gearslip, whatever the setting.
CarSettings.carConnected(ServiceDiscovery.findBluetoothService(serviceDiscoveryResponse)?.carAddress)
if (CarSettings.pipesNow) {
if (!CarSettings.pipeAudio.value) log.i("this car turns Bluetooth music off, so music goes through Gearslip")
openChannel(media.serviceId)
} else {
// Many cars switch their source to Android Auto the moment this channel opens, which
@@ -766,6 +787,13 @@ class GearslipRunner(
bluetooth,
open = { openChannel(bluetooth.serviceId) },
send = { id, body -> send(id, body, encrypted = true, channel = bluetooth.serviceId) },
// A car keeping its screen until it knows the phone may give it up now; ask again.
answered = {
if (running && focusAsked && !videoStarted) {
log.i("the car answered on Bluetooth while keeping its screen - asking for it again")
requestProjectedFocus(withDisplay = true)
}
},
)
}
@@ -1061,7 +1089,7 @@ class GearslipRunner(
"(head unit offered ${offered.size}), max_unacked=$maxUnacked",
)
requestProjectedFocus()
if (CarAudio.openSink != null) watchForWithheldScreen()
watchForWithheldScreen()
}
MSG_VIDEO_FOCUS_NOTIFICATION -> {
@@ -1092,6 +1120,16 @@ class GearslipRunner(
}
}
// UpdateUiConfigRequest { AdditionalVideoConfig }: new margins or day/night. A LuChi
// box sent it as video began, showed our first frame, then black (issue #58), as if
// it rebuilt its decoder. A rebuilt decoder needs SPS/PPS and a keyframe again.
MSG_VIDEO_UI_CONFIG -> {
log.i("<- UpdateUiConfigRequest (${body.size} bytes) - sending the stream's start again")
log.hex(" ui config", body, limit = 64)
lastCodecConfig?.let { send(MSG_MEDIA_CODEC_CONFIG, it, encrypted = true, channel = videoChannelId) }
videoSource?.requestSyncFrame()
}
else -> log.i("<- unhandled video message id=$messageId (${body.size} bytes)")
}
}
@@ -1344,6 +1382,7 @@ class GearslipRunner(
* the field is deprecated, but a car older than that may ignore a request without it.
*/
private fun requestProjectedFocus(withDisplay: Boolean = false) {
focusAsked = true
// VideoFocusRequestNotification { disp_channel_id = 1; mode = 2; reason = 3 }. Reason 1
// means "the phone's screen is off", which a 2020 Civic may have taken as a reason to
// keep its own screen (issue #43); 0 is UNKNOWN.
@@ -1363,6 +1402,10 @@ class GearslipRunner(
* straight over keep their own source as before.
* The 2017 Pilot (protocol 1.0) still kept its screen with the channel open, so the second
* request also names the display's channel, as cars of the Pilot's age may need.
* This runs whether or not the media channel was open: a 2020 Civic with music through
* Gearslip opened it at connect, so on 0.3.40 it never heard the second request (issue #43).
* The car also hears which phone this is over Bluetooth (see [CarBluetooth.onScreenWithheld]),
* which Hondas may wait for; its answer asks for the screen once more.
*/
private fun watchForWithheldScreen() {
Thread({
@@ -1372,11 +1415,12 @@ class GearslipRunner(
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")
}
val toldBluetooth = CarBluetooth.onScreenWithheld()
// Give the car the channels' open responses before it hears the request again.
if (open != null || toldBluetooth) Thread.sleep(1_000)
if (running && !videoStarted) requestProjectedFocus(withDisplay = true)
}, "gearslip-focus-fallback").apply { isDaemon = true }.start()
}
@@ -1681,6 +1725,7 @@ class GearslipRunner(
const val MSG_MEDIA_ACK = 32772
const val MSG_VIDEO_FOCUS_REQUEST = 32775
const val MSG_VIDEO_FOCUS_NOTIFICATION = 32776
const val MSG_VIDEO_UI_CONFIG = 32777
// aap_protobuf/service/sensor/... - same "first specific message on this channel type
// starts at 0x8000" numbering as media/input above.
@@ -25,6 +25,12 @@ class PhoneTls(keyStore: KeyStore, password: CharArray) {
private var netBuffer = ByteBuffer.allocate(32 * 1024)
@Volatile var handshakeComplete: Boolean = false
/**
* A short fingerprint of the head unit's certificate, known once TLS completes: the first
* thing that tells one car model from another, before it has described itself.
*/
@Volatile var headUnitKey: String? = null
private set
init {
@@ -166,6 +172,9 @@ class PhoneTls(keyStore: KeyStore, password: CharArray) {
try {
val peers = session.peerCertificates
log.i("head unit presented ${peers.size} certificate(s):")
headUnitKey = peers.firstOrNull()?.encoded?.let { bytes ->
java.security.MessageDigest.getInstance("SHA-256").digest(bytes).take(8).joinToString("") { "%02x".format(it) }
}
peers.forEachIndexed { index, certificate ->
if (certificate is X509Certificate) {
log.i(" [$index] subject=${certificate.subjectX500Principal}")
@@ -8,7 +8,8 @@ object ProjectedContent {
/**
* Precedence: the URL saved in Settings, then a `url.txt` in the external files dir (the
* pre-Settings way, still honoured so existing setups keep working), then [DEFAULT_URL].
* pre-Settings way, still honoured so existing setups keep working). Blank opens the
* browser's own start page.
*/
fun resolve(context: Context): String {
val saved = AppSettings.startupUrl(context)
@@ -25,12 +26,9 @@ object ProjectedContent {
return url
}
}
return DEFAULT_URL
return ""
}
/** What the Web app opens until a page is chosen in Settings. */
const val DEFAULT_URL = "https://duckduckgo.com"
/** A bare "example.com" is a URL the user meant; raw HTML is passed through untouched. */
private fun normalize(value: String): String =
if (value.startsWith("<") || "://" in value) value else "https://$value"
@@ -1,15 +1,18 @@
package app.seb3thehacker.gearslip
import android.app.ActivityOptions
import android.app.LocaleManager
import android.app.Presentation
import android.content.Context
import android.content.Intent
import android.content.res.Configuration
import android.graphics.Color
import android.hardware.display.DisplayManager
import android.hardware.display.VirtualDisplay
import android.os.SystemClock
import android.view.MotionEvent
import android.view.Surface
import android.view.ContextThemeWrapper
import android.view.View
import android.view.ViewGroup
import androidx.compose.runtime.Composable
@@ -96,7 +99,7 @@ class ScreenProjector(private val context: Context) {
decor.setViewTreeViewModelStoreOwner(treeOwner)
decor.setBackgroundColor(Color.BLACK)
show.setContentView(ComposeView(show.context).apply { setContent(content) })
show.setContentView(ComposeView(withAppLanguage(show.context)).apply { setContent(content) })
show.show()
presentation = show
@@ -106,6 +109,18 @@ class ScreenProjector(private val context: Context) {
GearslipLog.i("projecting ${width}x$height @ ${densityDpi}dpi")
}
/**
* [context] in the language the driver picked for Gearslip, when it differs from the phone's
* (issue #80). Android builds a virtual display's context from the system locale and leaves
* the per-app one out, so the car screen stayed in the phone's language.
*/
private fun withAppLanguage(context: Context): Context {
val locales = this.context.getSystemService(LocaleManager::class.java)?.applicationLocales
if (locales == null || locales.isEmpty) return context
val config = Configuration(context.resources.configuration).apply { setLocales(locales) }
return ContextThemeWrapper(context, context.theme).apply { applyOverrideConfiguration(config) }
}
/** The projected display's id, for placing other apps' activities on it. */
val displayId: Int? get() = virtualDisplay?.display?.displayId
@@ -0,0 +1,33 @@
package app.seb3thehacker.gearslip
import app.seb3thehacker.gearslip.car.CarEnvironment
/**
* Which cars want the short service discovery request. Since 0.3.39 Gearslip sends what a real
* phone does: its icon at three sizes and a session ID. A 2017 Hyundai on protocol 1.0 ignored
* the request without them (issue #47), but a 2019 Fiat 500's Uconnect hangs up on it within a
* second (issue #91). A car that hangs up after one kind is sent the other next time, remembered
* by its head unit's certificate, so each car settles on whichever it answers.
*/
object ServiceDiscoveryQuirks {
private const val KEY = "sdr_plain_cars"
fun wantsPlain(car: String?): Boolean = car != null && car in cars()
/** The car hung up after [plain] (or the full) request: switch it to the other one. */
fun droppedAfter(car: String?, plain: Boolean) {
val context = CarEnvironment.appContext ?: return
car ?: return
val now = cars()
val next = if (plain) now - car else now + car
if (next == now) return
AppSettings.putString(context, KEY, next.joinToString(","))
GearslipLog.i("service discovery: the car hung up after the ${if (plain) "short" else "full"} request, so next time it gets the ${if (plain) "full" else "short"} one", "PROTO")
}
private fun cars(): Set<String> {
val context = CarEnvironment.appContext ?: return emptySet()
return AppSettings.getString(context, KEY, "").split(",").filter { it.isNotEmpty() }.toSet()
}
}
@@ -157,6 +157,8 @@ class AudioCaptureService : Service() {
val startedAt = heardAt
var everHeard = false
var warnedSilent = false
var lastSound = startedAt
var silenceReported = false
try {
record.startRecording()
GearslipLog.i("audio: capturing media playback at ${rate}Hz x$channels")
@@ -174,6 +176,14 @@ class AudioCaptureService : Service() {
}
if (peak > 0) everHeard = true
val now = System.currentTimeMillis()
// A stretch of silence: CarAudio says why on the car if a blocking app is playing.
if (peak > 0) {
lastSound = now
silenceReported = false
} else if (!silenceReported && now - lastSound >= SILENCE_MS) {
silenceReported = true
CarAudio.silent(this@AudioCaptureService)
}
val level = peak / 32768f
if (now - heardAt >= LEVEL_LOG_MS) {
GearslipLog.i("audio: peak %.3f".format(level))
@@ -52,6 +52,21 @@ class AudioLink(
// The Uconnect asks for 4 on its video channel; assume the same until its Config says otherwise.
@Volatile private var maxUnacked = 4
@Volatile private var lastAckAt = 0L
/**
* Bytes of PCM per Data message, or 0 to send each capture read as it comes. Raised by
* [adaptChunkSize] for a car that acks fewer chunks than it plays.
*/
@Volatile private var chunkBytes = 0
/** Capture reads gathered until they fill [chunkBytes]. Only the capture thread touches it. */
private val pending = java.io.ByteArrayOutputStream()
/** Set by a new stream, so the capture thread drops what an older one left in [pending]. */
@Volatile private var pendingStale = false
/** The size of the last chunk sent, for [adaptChunkSize] to know what each one holds. */
@Volatile private var lastPayloadBytes = 0
/** Acks that came while the window was full, and the time they took: the car's own pace. */
private var slowAcks = 0
private var slowAckMs = 0L
private var lastAckFull = false
private var stalls = 0
private var chunks = 0
private var bytes = 0L
@@ -211,11 +226,21 @@ class AudioLink(
MSG_ACK -> {
val acked = Wire.varint(Wire.fields(body), 2)?.toInt() ?: 1
val now = System.currentTimeMillis()
synchronized(ackLock) {
// A full window means the sender is waiting on this ack. When it was waiting on
// the one before too, the gap between them is the car's own pace.
val full = inFlight >= maxUnacked
if (full && lastAckFull) {
slowAcks++
slowAckMs += now - lastAckAt
}
lastAckFull = full
inFlight = maxOf(0, inFlight - maxOf(1, acked))
lastAckAt = System.currentTimeMillis()
lastAckAt = now
ackLock.notifyAll()
}
adaptChunkSize()
}
// Whatever else the head unit answers Setup with, we need its bytes to identify it.
@@ -290,6 +315,40 @@ class AudioLink(
}
}
/**
* A 2020 Civic acks a chunk about every 83 ms whatever the chunk holds, so 21 ms chunks
* reached it at a quarter of real time: a second of music, then silence, over and over
* (issue #43). When the car keeps the sender waiting and its acks come much further apart
* than the audio in each chunk, gather the capture into chunks that last at least that gap,
* rounded up to a power of two in frames: 83 ms is about 4096 frames at 48 kHz, which looks
* like the car's own buffer. A car that keeps up never fills the window, so it never gets here.
*/
private fun adaptChunkSize() {
if (aac != null) return
val interval = synchronized(ackLock) {
if (slowAcks < ADAPT_ACKS) return
(slowAckMs.toDouble() / slowAcks).also {
slowAcks = 0
slowAckMs = 0
}
}
val frame = channels * 2
val bytesPerMs = sampleRate * frame / 1000.0
val current = if (chunkBytes > 0) chunkBytes else lastPayloadBytes
if (current <= 0 || current >= MAX_CHUNK_BYTES) return
val heldMs = current / bytesPerMs
if (interval < heldMs * SLOW_ACK_RATIO) return
val gapFrames = Math.ceil(interval * sampleRate / 1000.0).toInt().coerceAtLeast(1)
val frames = Integer.highestOneBit(gapFrames).let { if (it < gapFrames) it shl 1 else it }
val target = minOf(MAX_CHUNK_BYTES / frame * frame, frames * frame)
if (target <= current) return
chunkBytes = target
GearslipLog.i(
"$tag: the car acks a chunk every ${interval.toInt()}ms, but each holds ${heldMs.toInt()}ms - " +
"sending $target-byte chunks (${(target / bytesPerMs).toInt()}ms) from now on",
)
}
/** Silence for [ms], whole frames of this sink's format. */
private fun silence(ms: Int): ByteArray = ByteArray(sampleRate * ms / 1000 * channels * 2)
@@ -331,6 +390,12 @@ class AudioLink(
}
inFlight = 0
lastAckAt = System.currentTimeMillis()
pendingStale = true
synchronized(ackLock) {
slowAcks = 0
slowAckMs = 0
lastAckFull = false
}
if (aacSink && aac == null) {
aac = runCatching { AacEncoder(sampleRate, channels, adts = service.codecType == ServiceDiscovery.CODEC_AAC_LC_ADTS) }
.onSuccess { GearslipLog.i("$tag: the car takes ${service.codecName} only - encoding to AAC") }
@@ -380,7 +445,20 @@ class AudioLink(
private fun writeNow(pcm: ByteArray, size: Int) {
val encoder = aac
if (encoder == null) {
send(pcm.copyOf(size))
if (pendingStale) {
pendingStale = false
pending.reset()
}
val want = chunkBytes
if (want <= 0 && pending.size() == 0) {
send(pcm.copyOf(size))
return
}
pending.write(pcm, 0, size)
if (pending.size() >= want) {
send(pending.toByteArray())
pending.reset()
}
return
}
val frames = try {
@@ -430,6 +508,7 @@ class AudioLink(
return
}
synchronized(ackLock) { inFlight++ }
lastPayloadBytes = payload.size
chunks++
bytes += payload.size
// The first chunk proves the whole path; after that, an occasional line is enough to show
@@ -478,6 +557,12 @@ class AudioLink(
private const val FOCUS_STATE_LOSS_TRANSIENT = 5
private const val SESSION_ID = 2
private const val STALL_MS = 2_000L
/** Acks measured with the window full before the chunk size is judged. */
private const val ADAPT_ACKS = 20
/** Acks this much further apart than a chunk lasts mean the car is the one holding back. */
private const val SLOW_ACK_RATIO = 1.5
/** About 170 ms at 48 kHz stereo: the most delay a bigger chunk may add. */
private const val MAX_CHUNK_BYTES = 32_768
private const val CHUNK_LOG_EVERY = 200 // about every four seconds
private const val CONFIG_WAIT_MS = 1_000L
@@ -47,11 +47,28 @@ object CarAudio {
/** Opens the car's media channel, left closed while music went over Bluetooth; null once open. */
@Volatile var openSink: (() -> Unit)? = null
/** The car offers a music channel, open or waiting to be. */
val sinkOffered: Boolean get() = link != null || openSink != null
/** Rate and channel count the capture should use: the car's, or a sensible default. */
val sampleRate get() = link?.sampleRate ?: 48_000
val channels get() = link?.channels ?: 2
private val main by lazy { android.os.Handler(android.os.Looper.getMainLooper()) }
/**
* The car left and none came back: stop capturing, which ends the screen sharing Android
* shows for it and gives the phone its speaker back. Nothing else stopped it once the drive
* was over; with a wireless dongle Gearslip's screen stays open, so it ran on (issue #43).
*/
private val releaseWhenGone = Runnable {
if (link != null || _capture.value == Capture.OFF) return@Runnable
GearslipLog.i("audio: no car for ${CAR_GONE_MS / 1000}s - stopping the capture and screen sharing")
release()
}
fun attach(link: Link) {
main.removeCallbacks(releaseWhenGone)
this.link = link
GearslipLog.i("audio: the car offered a sink at ${link.sampleRate}Hz x${link.channels}")
when {
@@ -60,10 +77,24 @@ object CarAudio {
}
}
/**
* The car's music channel closed. A car that comes straight back (a cable that slips) keeps
* the capture it had, so the driver isn't asked again mid-drive.
*/
fun detach(link: Link) {
if (this.link === link) this.link = null
if (this.link !== link) return
this.link = null
main.removeCallbacks(releaseWhenGone)
main.postDelayed(releaseWhenGone, CAR_GONE_MS)
}
/**
* How long after the car leaves the capture waits for it to come back: a cable that slips, or a
* wireless link that drops and reconnects, which can take well over 3 seconds. Stopping sooner
* would have Android ask the driver to share the screen again in the middle of a drive.
*/
private const val CAR_GONE_MS = 30_000L
/**
* The car wants sound. Safe to call as often as the session likes: the consent dialog appears
* at most once, and only once there is a sink to play through - asking on the bench, where the
@@ -93,9 +124,30 @@ object CarAudio {
}
}
/**
* Capture has heard nothing for a while. If a media app that blocks capture is the one
* playing, the car screen says why the music is silent, once a drive: Spotify does this.
*/
internal fun silent(context: android.content.Context) = android.os.Handler(android.os.Looper.getMainLooper()).post {
if (toldBlocked) return@post
val media = app.seb3thehacker.gearslip.car.CarServices.mediaOrNull() ?: return@post
if (!media.now.value.playing) return@post
val name = BLOCKS_CAPTURE[media.packageName] ?: return@post
toldBlocked = true
GearslipLog.i("audio: $name is playing and capture hears nothing: it blocks other apps from playing its sound")
app.seb3thehacker.gearslip.car.CarToasts.show(this, context.getString(app.seb3thehacker.gearslip.R.string.car_audio_blocked, name), androidx.car.app.CarToast.LENGTH_LONG)
}
@Volatile private var toldBlocked = false
/** Media apps known to keep other apps from capturing their sound, by package. */
private val BLOCKS_CAPTURE = mapOf("com.spotify.music" to "Spotify")
/** The car went away. Everything is torn down; the next connection asks again. */
fun release() {
main.removeCallbacks(releaseWhenGone)
wanted = false
toldBlocked = false
AudioCaptureService.stop()
_capture.value = Capture.OFF
}
@@ -0,0 +1,212 @@
package app.seb3thehacker.gearslip.browser
import android.content.Context
import android.content.SharedPreferences
import java.net.URLEncoder
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import org.json.JSONArray
import org.json.JSONObject
/** A page saved to the browser's start page. */
data class Bookmark(val title: String, val url: String) {
/** The host without "www.", for labels and icons. */
val host: String get() = runCatching { android.net.Uri.parse(url).host.orEmpty() }.getOrDefault("").removePrefix("www.")
}
/** Where typed searches go. [query] is the address with the search terms appended, encoded. */
enum class SearchEngine(val id: String, val label: String, private val query: String) {
DUCKDUCKGO("duckduckgo", "DuckDuckGo", "https://duckduckgo.com/?q="),
STARTPAGE("startpage", "Startpage", "https://www.startpage.com/do/search?q="),
BRAVE("brave", "Brave Search", "https://search.brave.com/search?q="),
ECOSIA("ecosia", "Ecosia", "https://www.ecosia.org/search?q="),
GOOGLE("google", "Google", "https://www.google.com/search?q="),
BING("bing", "Bing", "https://www.bing.com/search?q=");
fun search(text: String): String = query + URLEncoder.encode(text, "UTF-8")
}
/**
* A filter list the ad blocker can use: one of the two it ships with, or one the driver added by
* its address. [id] names the cached copy on disk.
*/
data class FilterList(val id: String, val title: String, val url: String, val builtIn: Boolean, val enabled: Boolean)
/**
* The browser's own settings: bookmarks, the search engine, and the ad blocker's switches and
* lists. Kept in a preferences file of their own and mirrored into flows, so the car's browser
* and the phone's settings show the same thing. The ad blocker reads only the flows' values,
* never the file, since it answers on WebView's request thread (see [adblock.AdBlockManager]).
*/
object BrowserSettings {
private const val FILE = "browser"
private const val KEY_BOOKMARKS = "bookmarks"
private const val KEY_SEARCH = "search_engine"
private const val KEY_ADBLOCK = "adblock"
private const val KEY_YOUTUBE = "adblock_youtube"
private const val KEY_LISTS_OFF = "adblock_lists_off"
private const val KEY_CUSTOM_LISTS = "adblock_custom_lists"
private const val KEY_LAST_CHECK = "adblock_last_check"
/** The lists Gearslip ships a snapshot of, on unless turned off. */
val BUILT_IN_LISTS = listOf(
FilterList("easylist", "EasyList", "https://easylist.to/easylist/easylist.txt", builtIn = true, enabled = true),
FilterList("easyprivacy", "EasyPrivacy", "https://easylist.to/easylist/easyprivacy.txt", builtIn = true, enabled = true),
)
/** What a new install's start page offers, until the driver changes it. */
private val DEFAULT_BOOKMARKS = listOf(
Bookmark("YouTube", "https://m.youtube.com"),
Bookmark("Wikipedia", "https://en.m.wikipedia.org"),
)
private val bookmarksFlow = MutableStateFlow<List<Bookmark>>(emptyList())
private val searchFlow = MutableStateFlow(SearchEngine.DUCKDUCKGO)
private val adBlockFlow = MutableStateFlow(true)
private val youTubeFlow = MutableStateFlow(true)
private val listsFlow = MutableStateFlow(BUILT_IN_LISTS)
val bookmarks: StateFlow<List<Bookmark>> = bookmarksFlow
val searchEngine: StateFlow<SearchEngine> = searchFlow
val adBlock: StateFlow<Boolean> = adBlockFlow
val youTubeAdBlock: StateFlow<Boolean> = youTubeFlow
val filterLists: StateFlow<List<FilterList>> = listsFlow
@Volatile private var prefs: SharedPreferences? = null
/** Loads everything once per process. Safe to call from anywhere that has a context. */
fun init(context: Context) {
if (prefs != null) return
synchronized(this) {
if (prefs != null) return
val p = context.applicationContext.getSharedPreferences(FILE, Context.MODE_PRIVATE)
bookmarksFlow.value = if (p.contains(KEY_BOOKMARKS)) readBookmarks(p.getString(KEY_BOOKMARKS, "[]").orEmpty()) else DEFAULT_BOOKMARKS
searchFlow.value = SearchEngine.entries.firstOrNull { it.id == p.getString(KEY_SEARCH, null) } ?: SearchEngine.DUCKDUCKGO
adBlockFlow.value = p.getBoolean(KEY_ADBLOCK, true)
youTubeFlow.value = p.getBoolean(KEY_YOUTUBE, true)
listsFlow.value = readLists(p)
prefs = p
}
}
// Bookmarks
fun isBookmarked(url: String?): Boolean = url != null && bookmarksFlow.value.any { same(it.url, url) }
/** Adds [url], or renames it when it's already saved. Returns false for something that isn't a web address. */
fun addBookmark(title: String, url: String): Boolean {
val clean = url.trim()
if (!clean.startsWith("http://") && !clean.startsWith("https://")) return false
val name = title.trim().ifEmpty { Bookmark("", clean).host.ifEmpty { clean } }
val list = bookmarksFlow.value
val existing = list.indexOfFirst { same(it.url, clean) }
saveBookmarks(if (existing >= 0) list.toMutableList().also { it[existing] = Bookmark(name, list[existing].url) } else list + Bookmark(name, clean))
return true
}
fun removeBookmark(url: String) = saveBookmarks(bookmarksFlow.value.filterNot { same(it.url, url) })
/** Moves the bookmark at [from] to [to], for reordering from the phone. */
fun moveBookmark(from: Int, to: Int) {
val list = bookmarksFlow.value.toMutableList()
if (from !in list.indices || to !in list.indices) return
list.add(to, list.removeAt(from))
saveBookmarks(list)
}
/** The same page whatever the trailing slash or scheme. */
private fun same(a: String, b: String): Boolean {
fun key(u: String) = u.trim().removePrefix("https://").removePrefix("http://").removePrefix("www.").trimEnd('/')
return key(a).equals(key(b), ignoreCase = true)
}
private fun saveBookmarks(list: List<Bookmark>) {
bookmarksFlow.value = list
val array = JSONArray()
list.forEach { array.put(JSONObject().put("title", it.title).put("url", it.url)) }
prefs?.edit()?.putString(KEY_BOOKMARKS, array.toString())?.apply()
}
private fun readBookmarks(json: String): List<Bookmark> = runCatching {
val array = JSONArray(json)
(0 until array.length()).map { array.getJSONObject(it) }.map { Bookmark(it.optString("title"), it.optString("url")) }
.filter { it.url.isNotEmpty() }
}.getOrDefault(emptyList())
// Search
fun setSearchEngine(engine: SearchEngine) {
searchFlow.value = engine
prefs?.edit()?.putString(KEY_SEARCH, engine.id)?.apply()
}
/** Something that looks like an address opens as one; anything else is searched for. */
fun addressToUrl(typed: String): String {
val text = typed.trim()
return when {
text.startsWith("http://") || text.startsWith("https://") -> text
' ' !in text && '.' in text -> "https://$text"
else -> searchFlow.value.search(text)
}
}
// Ad blocking
fun setAdBlock(on: Boolean) {
adBlockFlow.value = on
prefs?.edit()?.putBoolean(KEY_ADBLOCK, on)?.apply()
}
fun setYouTubeAdBlock(on: Boolean) {
youTubeFlow.value = on
prefs?.edit()?.putBoolean(KEY_YOUTUBE, on)?.apply()
}
fun setListEnabled(id: String, on: Boolean) {
val p = prefs ?: return
val off = p.getStringSet(KEY_LISTS_OFF, emptySet()).orEmpty().toMutableSet()
if (on) off.remove(id) else off.add(id)
p.edit().putStringSet(KEY_LISTS_OFF, off).apply()
listsFlow.value = readLists(p)
}
/** Adds a list by its address. Returns false for something that isn't one, or one already there. */
fun addCustomList(url: String): Boolean {
val p = prefs ?: return false
val clean = url.trim()
if (!clean.startsWith("https://") && !clean.startsWith("http://")) return false
if (listsFlow.value.any { it.url.equals(clean, ignoreCase = true) }) return false
p.edit().putString(KEY_CUSTOM_LISTS, (customUrls(p) + clean).joinToString("\n")).apply()
listsFlow.value = readLists(p)
return true
}
fun removeCustomList(id: String) {
val p = prefs ?: return
val list = listsFlow.value.firstOrNull { it.id == id && !it.builtIn } ?: return
p.edit().putString(KEY_CUSTOM_LISTS, customUrls(p).filterNot { it == list.url }.joinToString("\n")).apply()
listsFlow.value = readLists(p)
}
var lastUpdateCheck: Long
get() = prefs?.getLong(KEY_LAST_CHECK, 0L) ?: 0L
set(value) { prefs?.edit()?.putLong(KEY_LAST_CHECK, value)?.apply() }
private fun customUrls(p: SharedPreferences) = p.getString(KEY_CUSTOM_LISTS, "").orEmpty().lines().filter { it.isNotBlank() }
private fun readLists(p: SharedPreferences): List<FilterList> {
val off = p.getStringSet(KEY_LISTS_OFF, emptySet()).orEmpty()
val custom = customUrls(p).map { url ->
val name = runCatching { android.net.Uri.parse(url).lastPathSegment }.getOrNull()?.takeIf { it.isNotBlank() } ?: url
FilterList(customId(url), name, url, builtIn = false, enabled = true)
}
return (BUILT_IN_LISTS + custom).map { it.copy(enabled = it.id !in off) }
}
/** A stable file name for a list known only by its address. */
private fun customId(url: String): String {
val digest = java.security.MessageDigest.getInstance("SHA-256").digest(url.toByteArray())
return "custom-" + digest.take(8).joinToString("") { "%02x".format(it) }
}
}
@@ -0,0 +1,31 @@
package app.seb3thehacker.gearslip.browser
import android.app.Activity
import android.content.Intent
import android.os.Bundle
import android.widget.Toast
import app.seb3thehacker.gearslip.R
/**
* The "Add to Gearslip" entry in Android's share sheet. A page shared from any browser becomes a
* tile on the car browser's start page, so nobody has to type an address on the car's keyboard.
* It shows nothing of its own: a toast says whether it worked, and it closes.
*/
class ShareToBrowserActivity : Activity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
BrowserSettings.init(this)
val text = intent?.takeIf { it.action == Intent.ACTION_SEND }?.getStringExtra(Intent.EXTRA_TEXT).orEmpty()
// Browsers share the bare address; some apps wrap it in a sentence, so take the first link.
val url = URL_PATTERN.find(text)?.value
val title = intent?.getStringExtra(Intent.EXTRA_SUBJECT) ?: intent?.getStringExtra(Intent.EXTRA_TITLE) ?: ""
val added = url != null && BrowserSettings.addBookmark(title, url)
Toast.makeText(this, getString(if (added) R.string.browser_shared_added else R.string.browser_shared_not_url), Toast.LENGTH_SHORT).show()
finish()
}
private companion object {
val URL_PATTERN = Regex("""https?://\S+""")
}
}
@@ -0,0 +1,101 @@
package app.seb3thehacker.gearslip.browser
import android.content.Context
import android.graphics.Bitmap
import android.graphics.BitmapFactory
import app.seb3thehacker.gearslip.GearslipLog
import java.io.File
import java.net.HttpURLConnection
import java.net.URL
import java.util.concurrent.ConcurrentHashMap
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
/**
* Each site's icon for the start page's tiles, kept on disk by host. The browser saves the icon
* a page hands it while loading ([save]); a bookmark that hasn't been opened yet asks its own
* site once ([fetch]), for its touch icon and then its favicon. Nothing goes to a third-party
* icon service, so no list of the driver's bookmarks leaves the phone.
*/
object SiteIcons {
private const val DIRECTORY = "site-icons"
/** Smaller than this is a 16px favicon that would look blurry on a tile; a bigger one wins. */
private const val GOOD_ENOUGH_PX = 96
private val memory = ConcurrentHashMap<String, Bitmap>()
private val asked = ConcurrentHashMap.newKeySet<String>()
private val changed = MutableStateFlow(0)
/** Bumps whenever an icon is saved, so tiles look again. */
val version: StateFlow<Int> = changed
fun icon(context: Context, host: String): Bitmap? {
if (host.isEmpty()) return null
memory[host]?.let { return it }
val file = file(context, host) ?: return null
if (!file.isFile) return null
return runCatching { BitmapFactory.decodeFile(file.path) }.getOrNull()?.also { memory[host] = it }
}
/** An icon the page gave the browser. Kept when it's bigger than the one already saved. */
fun save(context: Context, host: String, bitmap: Bitmap) {
if (host.isEmpty()) return
val current = icon(context, host)
if (current != null && current.width >= bitmap.width) return
store(context, host, bitmap)
}
/** Asks [host] for its icon in the background, once per run, when none is saved or it's small. */
fun fetch(context: Context, host: String) {
if (host.isEmpty() || !asked.add(host)) return
val current = icon(context, host)
if (current != null && current.width >= GOOD_ENOUGH_PX) return
val app = context.applicationContext
Thread({
for (path in listOf("/apple-touch-icon.png", "/favicon.ico")) {
val bitmap = runCatching { download("https://$host$path") }.getOrNull() ?: continue
if (current == null || bitmap.width > current.width) store(app, host, bitmap)
return@Thread
}
}, "site-icon").apply { isDaemon = true }.start()
}
private fun store(context: Context, host: String, bitmap: Bitmap) {
memory[host] = bitmap
runCatching {
val file = file(context, host) ?: return@runCatching
file.parentFile?.mkdirs()
file.outputStream().use { bitmap.compress(Bitmap.CompressFormat.PNG, 100, it) }
}.onFailure { GearslipLog.w("browser: couldn't save the icon for $host: ${it.message}") }
changed.value++
}
private fun download(url: String): Bitmap? {
val connection = (URL(url).openConnection() as HttpURLConnection).apply {
connectTimeout = 10_000
readTimeout = 10_000
instanceFollowRedirects = true
}
return try {
if (connection.responseCode !in 200..299) return null
val bytes = connection.inputStream.use { it.readBytes() }
BitmapFactory.decodeByteArray(bytes, 0, bytes.size)
} finally {
connection.disconnect()
}
}
/**
* Where [host]'s icon is kept, or null for a host that can't name a file in the icons folder.
* The host comes from a web page, so the name is cut down to letters, digits, dots and dashes,
* a host made of dots only is refused, and the result has to land inside the folder.
*/
private fun file(context: Context, host: String): File? {
val name = host.replace(Regex("[^A-Za-z0-9.-]"), "_")
if (name.isEmpty() || name.all { it == '.' }) return null
val dir = File(context.filesDir, DIRECTORY).canonicalFile
val file = File(dir, "$name.png").canonicalFile
return file.takeIf { it.toPath().normalize().startsWith(dir.toPath()) && it.parentFile == dir }
}
}
@@ -0,0 +1,417 @@
package app.seb3thehacker.gearslip.browser.adblock
import android.content.Context
import android.net.ConnectivityManager
import android.net.NetworkCapabilities
import android.util.Base64
import android.webkit.WebResourceRequest
import android.webkit.WebResourceResponse
import app.seb3thehacker.gearslip.GearslipLog
import app.seb3thehacker.gearslip.browser.BrowserSettings
import app.seb3thehacker.gearslip.browser.FilterList
import java.io.ByteArrayInputStream
import java.io.File
import java.net.HttpURLConnection
import java.net.URL
import java.util.concurrent.atomic.AtomicBoolean
import java.util.concurrent.atomic.AtomicLong
/**
* Owns the filter lists: downloading them, keeping a parsed [FilterEngine] in memory, and
* answering the two questions the WebView asks: block this request, and what to hide on
* this page.
*
* Everything the request path touches is cached in memory. `shouldInterceptRequest` runs on a
* background thread for every subresource on the page, so it must never read preferences or
* touch disk. The switches come from [BrowserSettings]'s flows, which are plain memory reads.
*
* Downloading a list contacts the server it comes from (easylist.to for the two built in), and
* nothing else: no page the driver visits is ever sent anywhere.
*/
object AdBlockManager {
sealed class RefreshResult {
data class Success(val networkRules: Int, val cosmeticRules: Int) : RefreshResult()
data class Failure(val message: String) : RefreshResult()
}
private const val DIRECTORY = "adblock"
private const val BUNDLED_ASSET = "adblock/bundled.txt"
private const val MAX_LIST_BYTES = 12L * 1024 * 1024
/**
* How stale the lists may get before the browser refreshes them on its own. EasyList
* publishes several times a day, but the parts that matter, the domains ads are served from,
* turn over slowly, and each update costs a few megabytes.
*/
private const val UPDATE_INTERVAL_MS = 7L * 24 * 60 * 60 * 1000
// 1x1 transparent GIF, returned for blocked images so pages show a gap rather than a
// broken-image icon.
private const val TRANSPARENT_GIF_BASE64 =
"R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7"
private val EMPTY = ByteArray(0)
private val EMPTY_JSON = "{}".toByteArray()
val transparentGifBytes: ByteArray by lazy {
Base64.decode(TRANSPARENT_GIF_BASE64, Base64.DEFAULT)
}
@Volatile
private var engine: FilterEngine? = null
/** No per-site allowlist yet; kept so the request path reads the same as AABrowser's. */
private val allowlist: Set<String> = emptySet()
private val enabled: Boolean get() = BrowserSettings.adBlock.value
private val youTubeEnabled: Boolean get() = BrowserSettings.youTubeAdBlock.value
private val loadStarted = AtomicBoolean(false)
private val refreshRunning = AtomicBoolean(false)
private val autoUpdateAttempted = AtomicBoolean(false)
private val blockedCount = AtomicLong(0)
// Time spent deciding requests since the last page load, for the log line that tells whether
// the blocker is what makes a page or a video slow to start.
private val checkedCount = AtomicLong(0)
private val checkedNanos = AtomicLong(0)
private val blockedSincePage = AtomicLong(0)
val isEngineReady: Boolean get() = engine != null
val blockedRequestCount: Long get() = blockedCount.get()
val networkRuleCount: Int get() = engine?.networkRuleCount ?: 0
val cosmeticRuleCount: Int get() = engine?.cosmeticRuleCount ?: 0
/** Loads the cached lists from disk once per process, on a background thread. */
fun ensureInitialized(context: Context) {
BrowserSettings.init(context)
if (!enabled) return
if (!loadStarted.compareAndSet(false, true)) return
val appContext = context.applicationContext
Thread({
runCatching { loadFromDisk(appContext) }
.onFailure { GearslipLog.w("adblock: could not load the filter lists: ${it.message}") }
}, "adblock-load").apply {
// Blocking does nothing until this finishes, so it gets a normal share of the CPU.
// On a Pixel 6 a release build parses both lists in about 250ms.
priority = Thread.NORM_PRIORITY
start()
}
}
/** Rebuilds the engine from what's on disk after a list was turned on or off. */
fun reload(context: Context) {
val appContext = context.applicationContext
loadStarted.set(true)
Thread({ runCatching { loadFromDisk(appContext) } }, "adblock-load").start()
}
fun lastUpdatedAt(context: Context): Long =
BrowserSettings.filterLists.value.mapNotNull { cacheFile(context, it).takeIf { file -> file.length() > 0 } }
.maxOfOrNull { it.lastModified() } ?: 0L
private fun cacheFile(context: Context, source: FilterList): File =
File(File(context.filesDir, DIRECTORY), "${source.id}.txt")
private fun loadFromDisk(context: Context) {
val lists = BrowserSettings.filterLists.value.filter { it.enabled }
val files = lists.map { cacheFile(context, it) }.filter { it.length() > 0 }
val builder = FilterEngine.Builder()
// A built-in list that hasn't downloaded yet comes from the snapshot in the APK, so
// blocking works on the very first page rather than only after a download.
val needsBundled = lists.any { it.builtIn && cacheFile(context, it).length() == 0L }
if (needsBundled) {
context.assets.open(BUNDLED_ASSET).bufferedReader(Charsets.UTF_8).use { reader ->
while (true) {
val line = reader.readLine() ?: break
builder.addLine(line)
}
}
}
for (file in files) {
file.bufferedReader(bufferSize = 64 * 1024).use { reader ->
while (true) {
val line = reader.readLine() ?: break
builder.addLine(line)
}
}
}
engine = builder.finish()
GearslipLog.i("adblock: ${networkRuleCount} network and ${cosmeticRuleCount} hiding rules from ${lists.size} lists")
}
/** Downloads every list that's on, replaces the cached copies, and rebuilds the engine. */
fun refreshLists(context: Context): RefreshResult {
// The automatic refresh and the button in settings can both land here; two downloads
// writing the same files would leave one of them truncated.
val appContext = context.applicationContext
if (!refreshRunning.compareAndSet(false, true)) {
return RefreshResult.Failure("An update is already running.")
}
return try {
refresh(appContext).also {
// A manual update counts as a check, so the automatic one waits a full interval.
if (it is RefreshResult.Success) BrowserSettings.lastUpdateCheck = System.currentTimeMillis()
}
} finally {
refreshRunning.set(false)
}
}
/**
* Updates the lists when they have gone stale and the phone is on Wi-Fi, on a background
* thread. Runs when the car's browser opens; there is no scheduled job. Only one attempt per
* process, and the time is written on success alone, so a phone that was offline tries again
* next time rather than in a week.
*/
fun refreshIfDue(context: Context) {
val appContext = context.applicationContext
BrowserSettings.init(appContext)
if (!enabled || !isUpdateDue(appContext) || !onUnmeteredNetwork(appContext)) return
if (!autoUpdateAttempted.compareAndSet(false, true)) return
Thread({
val result = refreshLists(appContext)
GearslipLog.i("adblock: weekly list update - $result")
}, "adblock-update").start()
}
private fun isUpdateDue(context: Context): Boolean {
val now = System.currentTimeMillis()
val lastCheck = BrowserSettings.lastUpdateCheck
if (lastCheck > 0 && now - lastCheck < UPDATE_INTERVAL_MS) return false
val baseline = lastUpdatedAt(context)
// Nothing downloaded yet: the snapshot in the APK may be months old, so fetch now.
if (baseline <= 0L) return true
// A clock that has jumped backwards would otherwise freeze the lists indefinitely.
if (baseline > now) return true
return now - baseline >= UPDATE_INTERVAL_MS
}
private fun onUnmeteredNetwork(context: Context): Boolean {
val manager = context.getSystemService(ConnectivityManager::class.java) ?: return false
val caps = manager.getNetworkCapabilities(manager.activeNetwork) ?: return false
return caps.hasCapability(NetworkCapabilities.NET_CAPABILITY_NOT_METERED)
}
private fun refresh(context: Context): RefreshResult {
val directory = File(context.filesDir, DIRECTORY)
if (!directory.exists() && !directory.mkdirs()) {
return RefreshResult.Failure("Could not create the filter list folder.")
}
for (source in BrowserSettings.filterLists.value.filter { it.enabled }) {
val result = runCatching { download(source, cacheFile(context, source)) }
val error = result.exceptionOrNull()
if (error != null) return RefreshResult.Failure("${source.title}: ${error.message ?: "download failed"}")
val failure = result.getOrNull()
if (failure != null) return RefreshResult.Failure("${source.title}: $failure")
}
loadStarted.set(true)
val loadError = runCatching { loadFromDisk(context) }.exceptionOrNull()
if (loadError != null) return RefreshResult.Failure(loadError.message ?: "Could not read the lists.")
val current = engine ?: return RefreshResult.Failure("The lists downloaded but contained no rules.")
return RefreshResult.Success(current.networkRuleCount, current.cosmeticRuleCount)
}
/** Returns null on success, or a human-readable reason for failure. */
private fun download(source: FilterList, destination: File): String? {
val connection = (URL(source.url).openConnection() as HttpURLConnection).apply {
connectTimeout = 15_000
readTimeout = 30_000
setRequestProperty("Accept", "text/plain")
}
try {
if (connection.responseCode !in 200..299) return "HTTP ${connection.responseCode}"
val temporary = File(destination.parentFile, destination.name + ".tmp")
var total = 0L
temporary.outputStream().use { output ->
connection.inputStream.use { input ->
val buffer = ByteArray(16 * 1024)
while (true) {
val read = input.read(buffer)
if (read < 0) break
total += read
if (total > MAX_LIST_BYTES) {
temporary.delete()
return "list is unexpectedly large"
}
output.write(buffer, 0, read)
}
}
}
if (total == 0L) {
temporary.delete()
return "empty response"
}
if (!temporary.renameTo(destination)) {
temporary.delete()
return "could not save the list"
}
} finally {
connection.disconnect()
}
return null
}
/** Deletes the cached copy of a list the driver removed. */
fun forget(context: Context, list: FilterList) {
cacheFile(context, list).delete()
}
fun isAllowlisted(host: String?): Boolean {
if (host.isNullOrEmpty()) return false
val current = allowlist
if (current.isEmpty()) return false
val normalized = host.lowercase()
return current.any { DomainUtils.hostMatchesDomain(normalized, it) }
}
/**
* Decides a single subresource request and, if it should be blocked, builds the response for
* it. [pageUrl] is the document the request belongs to, tracked by the WebView client because
* a WebView's own url can only be read on the UI thread. Null means let the request through.
*/
fun response(request: WebResourceRequest, pageUrl: String?): WebResourceResponse? {
if (!enabled) return null
// A ranged request is a video or audio stream fetching its next piece: ads never come
// that way, and a stream makes hundreds of them, so they skip the lists entirely.
if (request.requestHeaders?.keys?.any { it.equals("Range", ignoreCase = true) } == true) return null
val started = System.nanoTime()
try {
return decide(request, pageUrl)
} finally {
checkedCount.incrementAndGet()
checkedNanos.addAndGet(System.nanoTime() - started)
}
}
/** Logs what the blocker cost the page that just finished loading, and starts counting afresh. */
fun logPageCost(url: String?) {
val count = checkedCount.getAndSet(0)
val ms = checkedNanos.getAndSet(0) / 1_000_000
val blocked = blockedSincePage.getAndSet(0)
if (count > 0) GearslipLog.i("adblock: $count requests checked in ${ms}ms, $blocked blocked, for ${url?.substringBefore('?')}")
}
private fun decide(request: WebResourceRequest, pageUrl: String?): WebResourceResponse? {
val current = engine ?: return null
// Never interfere with top-level navigation: a mistake there costs the whole page.
if (request.isForMainFrame) return null
val url = request.url ?: return null
val scheme = url.scheme?.lowercase()
if (scheme != "http" && scheme != "https") return null
val documentHost = pageUrl?.let { runCatching { android.net.Uri.parse(it).host }.getOrNull() }
if (isAllowlisted(documentHost)) return null
val requestHost = url.host?.lowercase()
val type = ResourceType.infer(request)
val isThirdParty = DomainUtils.isThirdParty(requestHost, documentHost)
val match = runCatching {
current.matchBlockingRule(url.toString(), requestHost, documentHost, type, isThirdParty)
}.getOrNull() ?: return null
blockedCount.incrementAndGet()
blockedSincePage.incrementAndGet()
return blockedResponse(request, match.redirectResource)
}
/**
* The stand-in served for a blocked request.
*
* The content type matters as much as the emptiness. A page that asked for a script and
* receives `text/plain` does not get a script that does nothing — it gets a resource the
* browser refuses to execute, and any code waiting on that script waits until it times
* out. YouTube's player does exactly this with its ad-status check, which showed up as a
* five to ten second stall before playback. So each blocked request is answered with a
* valid, empty thing of the kind it asked for, which is what uBlock Origin's `noop`
* redirect resources do.
*
* When the matched rule named a specific `$redirect=` resource we recognise, that exact
* stub is served instead of the generic empty one -- see [RedirectResources] for why that
* sometimes matters.
*/
private fun blockedResponse(request: WebResourceRequest, redirectResource: String?): WebResourceResponse {
val headers = mapOf(
"Access-Control-Allow-Origin" to "*",
"Cache-Control" to "no-store"
)
if (redirectResource != null) {
RedirectResources.response(redirectResource, headers)?.let { return it }
}
fun response(mimeType: String, encoding: String?, body: ByteArray) =
WebResourceResponse(mimeType, encoding, 200, "OK", headers, ByteArrayInputStream(body))
return when (ResourceType.infer(request)) {
ResourceType.IMAGE -> response("image/gif", null, transparentGifBytes)
ResourceType.SCRIPT -> response("application/javascript", "utf-8", EMPTY)
ResourceType.STYLESHEET -> response("text/css", "utf-8", EMPTY)
ResourceType.SUBDOCUMENT, ResourceType.DOCUMENT -> response("text/html", "utf-8", EMPTY)
// Callers parse this; an empty body would throw where "{}" quietly yields nothing.
ResourceType.XHR -> response("application/json", "utf-8", EMPTY_JSON)
ResourceType.MEDIA -> response("video/mp4", null, EMPTY)
ResourceType.FONT -> response("font/woff2", null, EMPTY)
else -> response("text/plain", "utf-8", EMPTY)
}
}
/** CSS injected at document start, or an empty string when nothing applies. */
fun cssForHost(host: String?): String {
if (!enabled) return ""
if (isAllowlisted(host)) return ""
return engine?.cssForHost(host).orEmpty()
}
/** Whether the YouTube-specific ad-stripping scripts should run for [host]. */
fun isYouTubeBlockingEnabled(host: String?): Boolean {
if (!enabled || !youTubeEnabled) return false
return !isAllowlisted(host)
}
/**
* Whether non-ad cosmetic site tweaks (hiding a redundant footer nav, a Shorts shelf, ...)
* should run. Independent of ad blocking: a user may want one without the other, and unlike
* ad blocking there is no per-host allowlist for it, since it isn't blocking anything.
*/
fun isCustomSiteTweaksEnabled(): Boolean = false
/** Whether a page's own fixed bottom bar should be hidden, per the in-drawer toggle. */
fun isHideSiteFootersEnabled(): Boolean = false
/** CSS for generic rules whose class or id turned up in the page's DOM. */
fun cssForTokens(host: String?, classNames: String, ids: String): String {
if (!enabled) return ""
if (isAllowlisted(host)) return ""
return engine?.cssForTokens(host, classNames, ids).orEmpty()
}
/**
* The `:has-text()` rules for [host], as a JSON array of `[selector, regexSource]` pairs, for
* the page's own JavaScript to apply -- there is no CSS equivalent for a text match, unlike
* every other cosmetic rule this engine serves.
*/
fun hasTextRulesForHost(host: String?): String {
if (!enabled) return "[]"
if (isAllowlisted(host)) return "[]"
val rules = engine?.hasTextRulesForHost(host) ?: return "[]"
if (rules.isEmpty()) return "[]"
val array = org.json.JSONArray()
for ((selector, pattern) in rules) {
array.put(org.json.JSONArray().put(selector).put(pattern))
}
return array.toString()
}
}
@@ -0,0 +1,170 @@
package app.seb3thehacker.gearslip.browser.adblock
import android.webkit.WebResourceRequest
/**
* Resource types used by filter list options ($script, $image, ...).
*
* WebResourceRequest does not expose the resource type directly, so [infer] reconstructs it
* from the Sec-Fetch-Dest request header where WebView sends one, falling back to the file
* extension and then the Accept header.
*/
internal object ResourceType {
const val NONE = 0
const val DOCUMENT = 1 shl 0
const val SUBDOCUMENT = 1 shl 1
const val SCRIPT = 1 shl 2
const val IMAGE = 1 shl 3
const val STYLESHEET = 1 shl 4
const val XHR = 1 shl 5
const val FONT = 1 shl 6
const val MEDIA = 1 shl 7
const val WEBSOCKET = 1 shl 8
const val PING = 1 shl 9
const val OBJECT = 1 shl 10
const val OTHER = 1 shl 11
fun fromOptionName(name: String): Int = when (name) {
"document" -> DOCUMENT
"subdocument", "frame" -> SUBDOCUMENT
"script" -> SCRIPT
"image" -> IMAGE
"stylesheet", "css" -> STYLESHEET
"xmlhttprequest", "xhr" -> XHR
"font" -> FONT
"media" -> MEDIA
"websocket" -> WEBSOCKET
"ping", "beacon" -> PING
"object", "object-subrequest" -> OBJECT
"other" -> OTHER
else -> NONE
}
fun infer(request: WebResourceRequest): Int {
if (request.isForMainFrame) return DOCUMENT
val headers = request.requestHeaders
val dest = headers?.entries
?.firstOrNull { it.key.equals("Sec-Fetch-Dest", ignoreCase = true) }
?.value
?.lowercase()
if (!dest.isNullOrEmpty() && dest != "empty") {
val fromDest = when (dest) {
"document" -> DOCUMENT
"iframe", "frame", "embed" -> SUBDOCUMENT
"script", "worker", "sharedworker", "serviceworker" -> SCRIPT
"image" -> IMAGE
"style" -> STYLESHEET
"font" -> FONT
"audio", "video", "track", "audioworklet" -> MEDIA
"object" -> OBJECT
"report" -> PING
"manifest" -> OTHER
else -> NONE
}
if (fromDest != NONE) return fromDest
}
val fromExtension = extensionType(request.url?.path)
if (fromExtension != NONE) return fromExtension
val accept = headers?.entries
?.firstOrNull { it.key.equals("Accept", ignoreCase = true) }
?.value
?.lowercase()
if (accept != null) {
when {
accept.startsWith("image/") || accept.contains("image/avif") -> return IMAGE
accept.startsWith("text/css") -> return STYLESHEET
accept.startsWith("video/") || accept.startsWith("audio/") -> return MEDIA
accept.contains("application/javascript") -> return SCRIPT
}
}
// "empty" is what fetch()/XMLHttpRequest report, and is also the fallback when
// WebView sends no Sec-Fetch-Dest at all.
return if (dest == "empty") XHR else OTHER
}
private fun extensionType(path: String?): Int {
if (path.isNullOrEmpty()) return NONE
val dot = path.lastIndexOf('.')
if (dot < 0 || dot == path.length - 1) return NONE
return when (path.substring(dot + 1).lowercase()) {
"js", "mjs" -> SCRIPT
"css" -> STYLESHEET
"png", "jpg", "jpeg", "gif", "webp", "svg", "ico", "bmp", "avif" -> IMAGE
"woff", "woff2", "ttf", "otf", "eot" -> FONT
"mp4", "webm", "ogg", "mp3", "m4a", "m4s", "ts", "m3u8", "mpd" -> MEDIA
"json" -> XHR
"swf" -> OBJECT
else -> NONE
}
}
}
/** Helpers for comparing hosts without pulling in a full public suffix list. */
internal object DomainUtils {
/**
* Common multi-label public suffixes. This is deliberately a short list rather than the
* full PSL: it is only used to decide first-party vs third-party, where being wrong means
* a $third-party rule misfires on an uncommon TLD, not a broken page.
*/
private val MULTI_LABEL_SUFFIXES = hashSetOf(
"co.uk", "org.uk", "me.uk", "ac.uk", "gov.uk", "net.uk", "sch.uk",
"co.jp", "ne.jp", "or.jp", "ac.jp", "go.jp",
"com.au", "net.au", "org.au", "edu.au", "gov.au",
"co.nz", "net.nz", "org.nz",
"com.br", "net.br", "org.br",
"co.in", "net.in", "org.in",
"com.cn", "net.cn", "org.cn",
"co.kr", "or.kr",
"com.mx", "com.ar", "com.tr", "com.tw", "com.hk", "com.sg", "com.my",
"co.za", "com.pl", "com.ua", "co.il", "com.vn", "com.ph", "co.id",
"com.es", "com.pt", "com.gr", "com.ro", "com.ru", "org.ru", "net.ru"
)
fun registrableDomain(host: String?): String? {
if (host.isNullOrEmpty()) return null
val normalized = host.trimEnd('.').lowercase()
if (normalized.isEmpty()) return null
if (normalized.indexOf(':') >= 0) return normalized
val labels = normalized.split('.')
if (labels.size <= 2) return normalized
if (labels.all { label -> label.isNotEmpty() && label.all { it in '0'..'9' } }) return normalized
val lastTwo = labels[labels.size - 2] + "." + labels[labels.size - 1]
if (lastTwo in MULTI_LABEL_SUFFIXES && labels.size >= 3) {
return labels[labels.size - 3] + "." + lastTwo
}
return lastTwo
}
fun isThirdParty(requestHost: String?, documentHost: String?): Boolean {
if (requestHost.isNullOrEmpty() || documentHost.isNullOrEmpty()) return false
val request = registrableDomain(requestHost) ?: return false
val document = registrableDomain(documentHost) ?: return false
return !request.equals(document, ignoreCase = true)
}
/** True when [host] is [domain] itself or a subdomain of it. */
fun hostMatchesDomain(host: String, domain: String): Boolean {
if (host == domain) return true
return host.length > domain.length &&
host.endsWith(domain) &&
host[host.length - domain.length - 1] == '.'
}
/** Emits `a.b.example.com`, `b.example.com`, `example.com`, `com`. */
inline fun forEachHostSuffix(host: String, action: (String) -> Unit) {
var index = 0
while (true) {
action(if (index == 0) host else host.substring(index))
val dot = host.indexOf('.', index)
if (dot < 0) return
index = dot + 1
}
}
}
@@ -0,0 +1,336 @@
package app.seb3thehacker.gearslip.browser.adblock
import android.content.Context
import android.webkit.JavascriptInterface
import android.webkit.WebView
import androidx.webkit.WebViewCompat
import androidx.webkit.WebViewFeature
/**
* Applies element-hiding rules to a page.
*
* Which rules apply depends on the page's hostname and on what is actually in its DOM, neither
* of which is known until the document starts loading, so the page fetches its own stylesheet
* through a JavaScript bridge. The bridge exposes two pure functions that return CSS: they read
* no page state and perform no action, so handing them to untrusted page content costs nothing.
*
* Hiding happens in two passes. At document start the page gets the rules written for its own
* host, which must land before anything renders — injecting on page finish would let ads appear
* and then vanish, which is worse than not hiding them. The generic rules follow once the DOM
* exists, keyed on the classes and ids actually present, because shipping all of them up front
* would mean roughly 400 KB of CSS per page.
*/
object CosmeticFilterBridge {
const val BRIDGE_OBJECT_NAME = "GearslipContentBlocker"
/** Stop watching for new elements after this long, to spare the CPU on a head unit. */
private const val OBSERVE_DURATION_MS = 30000
/** Upper bound on how many new class/id names are sent to the bridge in one pass. */
private const val MAX_TOKENS_PER_PASS = 4000
/** True when document-start injection is unavailable and the client must inject later. */
val needsPageStartFallback: Boolean
get() = !WebViewFeature.isFeatureSupported(WebViewFeature.DOCUMENT_START_SCRIPT)
val INJECTION_JS: String = """
(function () {
try {
if (window.__gearslipCosmetic) { return; }
var bridge = window.$BRIDGE_OBJECT_NAME;
if (!bridge || typeof bridge.cssForHost !== 'function') { return; }
var host = location.hostname;
var seenClass = Object.create(null);
var seenId = Object.create(null);
window.__gearslipCosmetic = true;
function addStyle(css) {
if (!css) { return; }
var style = document.createElement('style');
style.setAttribute('data-gearslip', 'content-blocker');
style.textContent = css;
var root = document.head || document.documentElement;
if (root) { root.appendChild(style); }
}
// The engine loads from disk in the background and may not be ready when the
// first page starts. Keep asking until it answers, so a page that loses the race
// still gets its hiding rules rather than none at all.
var hostCssApplied = false;
function applyHostCss() {
if (hostCssApplied) { return true; }
var css = bridge.cssForHost(host);
if (!css) { return false; }
hostCssApplied = true;
addStyle(css);
return true;
}
applyHostCss();
var retries = [250, 750, 1500, 3000, 5000, 8000, 12000];
for (var r = 0; r < retries.length; r++) {
setTimeout(function () {
try {
if (!hostCssApplied && applyHostCss()) { collect(); }
} catch (e) {}
}, retries[r]);
}
var classes = [];
var ids = [];
function scan(root, budget) {
if (!root || root.nodeType !== 1) { return budget; }
var elements = root.getElementsByTagName('*');
var total = elements.length + 1;
for (var i = 0; i < total && budget > 0; i++) {
var element = (i === 0) ? root : elements[i - 1];
var id = element.id;
if (id && !seenId[id]) { seenId[id] = true; ids.push(id); budget--; }
// SVG elements expose className as an SVGAnimatedString, not a string.
var names = element.className;
if (names && typeof names === 'string') {
var parts = names.split(/\s+/);
for (var j = 0; j < parts.length && budget > 0; j++) {
var name = parts[j];
if (name && !seenClass[name]) {
seenClass[name] = true;
classes.push(name);
budget--;
}
}
}
}
return budget;
}
function collect() {
classes.length = 0;
ids.length = 0;
var budget = $MAX_TOKENS_PER_PASS;
if (!scannedOnce) {
scannedOnce = true;
budget = scan(document.documentElement, budget);
} else {
// Only look at what the page actually added. Re-walking the whole document
// on every mutation costs far more than it finds: on a large single-page app
// it means a full DOM traversal every 50ms while the page is starting up.
for (var i = 0; i < pending.length && budget > 0; i++) {
budget = scan(pending[i], budget);
}
}
pending.length = 0;
if (classes.length || ids.length) {
addStyle(bridge.cssForTokens(host, classes.join(' '), ids.join(' ')));
}
}
var scannedOnce = false;
var pending = [];
var scheduled = false;
function schedule() {
if (scheduled) { return; }
scheduled = true;
setTimeout(function () {
scheduled = false;
try { collect(); } catch (e) {}
}, 50);
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', schedule);
} else {
schedule();
}
if (typeof MutationObserver === 'function' && document.documentElement) {
var observer = new MutationObserver(function (records) {
for (var i = 0; i < records.length; i++) {
var added = records[i].addedNodes;
for (var j = 0; j < added.length; j++) {
if (added[j].nodeType === 1) { pending.push(added[j]); }
}
}
// A burst larger than this is a page rebuilding itself; the tail is enough.
if (pending.length > 300) { pending.splice(0, pending.length - 300); }
if (pending.length) { schedule(); }
});
observer.observe(document.documentElement, { childList: true, subtree: true });
setTimeout(function () {
try { observer.disconnect(); } catch (e) {}
}, $OBSERVE_DURATION_MS);
}
} catch (e) {}
})();
(function () {
// Hides a site's own fixed bottom nav bar, which duplicates navigation the app
// already provides and costs a sixth of the screen on a ~480px-tall car display.
// Found by measuring rather than by a list of known selectors, so it works on any
// site. Toggled from the drawer.
//
// Hiding rather than repositioning is deliberate: an earlier version moved these
// bars instead, which fought sites that animate the bar's own position (Twitch
// slides its nav bar on scroll) and left it stranded mid-page. display:none has
// nothing to fight.
try {
if (window.__gearslipFooterHiding) { return; }
window.__gearslipFooterHiding = true;
var bridge = window.$BRIDGE_OBJECT_NAME;
if (!bridge || typeof bridge.hideSiteFootersEnabled !== 'function') { return; }
if (!bridge.hideSiteFootersEnabled()) { return; }
var MAX_BAR_HEIGHT = 140;
function hideBottomBars(root, depth) {
if (!root || depth < 0 || !root.children) { return; }
for (var i = 0; i < root.children.length; i++) {
var el = root.children[i];
if (el.__gearslipFooterHidden) { continue; }
var cs;
try { cs = getComputedStyle(el); } catch (e) { continue; }
if (cs.display === 'none') { continue; }
if (cs.position === 'fixed' || cs.position === 'sticky') {
var rect;
try { rect = el.getBoundingClientRect(); } catch (e) { rect = null; }
if (rect && rect.width >= window.innerWidth * 0.6 &&
rect.height > 0 && rect.height <= MAX_BAR_HEIGHT &&
Math.abs(window.innerHeight - rect.bottom) < 6) {
el.__gearslipFooterHidden = true;
el.style.setProperty('display', 'none', 'important');
continue;
}
}
hideBottomBars(el, depth - 1);
}
}
function run() {
try {
hideBottomBars(document.body, 3);
} catch (e) {}
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', run);
} else {
run();
}
// Single-page apps mount their nav bar well after first paint, and swap it out
// again on navigation, so one pass at load isn't enough.
var passes = 0;
var timer = setInterval(function () {
run();
if (++passes > 40) { clearInterval(timer); }
}, 500);
} catch (e) {}
})();
(function () {
// ":has-text(pattern)" cosmetic rules: hides elements matching a base selector whose
// own text also matches a pattern. There is no CSS equivalent for the text half, so
// this runs the check in JavaScript instead of the plain <style> the rest of the
// cosmetic filters use.
try {
if (window.__gearslipHasText) { return; }
var bridge = window.$BRIDGE_OBJECT_NAME;
if (!bridge || typeof bridge.hasTextRulesForHost !== 'function') { return; }
var rules;
try {
rules = JSON.parse(bridge.hasTextRulesForHost(location.hostname) || '[]');
} catch (e) {
rules = [];
}
if (!rules.length) { return; }
window.__gearslipHasText = true;
var compiled = [];
for (var i = 0; i < rules.length; i++) {
try {
compiled.push({ selector: rules[i][0], regex: new RegExp(rules[i][1], 'i') });
} catch (e) {}
}
if (!compiled.length) { return; }
function run() {
for (var i = 0; i < compiled.length; i++) {
var rule = compiled[i];
var elements;
try { elements = document.querySelectorAll(rule.selector); } catch (e) { continue; }
for (var j = 0; j < elements.length; j++) {
var el = elements[j];
if (el.__gearslipHasTextHidden) { continue; }
var text = el.textContent;
if (text && rule.regex.test(text)) {
el.__gearslipHasTextHidden = true;
el.style.setProperty('display', 'none', 'important');
}
}
}
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', run);
} else {
run();
}
// Matches uBlock Origin's own behaviour for this rule type: it re-evaluates on a
// timer rather than per-mutation, since a text match can only be judged once an
// element's subtree has actually finished changing.
var passes = 0;
var timer = setInterval(function () {
run();
if (++passes > 40) { clearInterval(timer); }
}, 500);
} catch (e) {}
})();
""".trimIndent()
fun attach(webView: WebView) {
webView.addJavascriptInterface(Bridge(webView.context.applicationContext), BRIDGE_OBJECT_NAME)
if (!needsPageStartFallback) {
runCatching {
WebViewCompat.addDocumentStartJavaScript(webView, INJECTION_JS, setOf("*"))
}
runCatching {
WebViewCompat.addDocumentStartJavaScript(
webView,
YouTubeAdBlock.INJECTION_JS,
setOf("*")
)
}
}
}
class Bridge(private val context: Context) {
@JavascriptInterface
fun cssForHost(host: String?): String = runCatching {
AdBlockManager.cssForHost(host)
}.getOrDefault("")
@JavascriptInterface
fun youtubeBlockingEnabled(host: String?): Boolean = runCatching {
AdBlockManager.isYouTubeBlockingEnabled(host)
}.getOrDefault(false)
@JavascriptInterface
fun customSiteTweaksEnabled(): Boolean = runCatching {
AdBlockManager.isCustomSiteTweaksEnabled()
}.getOrDefault(false)
@JavascriptInterface
fun hideSiteFootersEnabled(): Boolean = runCatching {
AdBlockManager.isHideSiteFootersEnabled()
}.getOrDefault(false)
@JavascriptInterface
fun hasTextRulesForHost(host: String?): String = runCatching {
AdBlockManager.hasTextRulesForHost(host)
}.getOrDefault("[]")
@JavascriptInterface
fun cssForTokens(host: String?, classNames: String?, ids: String?): String = runCatching {
AdBlockManager.cssForTokens(host, classNames.orEmpty(), ids.orEmpty())
}.getOrDefault("")
}
}
@@ -0,0 +1,610 @@
package app.seb3thehacker.gearslip.browser.adblock
import android.util.LruCache
/**
* An immutable, queryable index over a set of parsed filter rules.
*
* Network rules are bucketed so that a request only ever tests a handful of them:
* - `||host^` rules are indexed by host and found by walking the request host's suffixes;
* - every other rule is indexed under one literal token from its pattern, and a request only
* tests rules whose token appears in the URL;
* - the few rules with no usable token are tested on every request.
*
* Element-hiding rules are split the same way. Nearly all generic hiding rules are anchored on
* a single class or id (`.ad-banner`, `#sponsored`), so they are indexed by that name and only
* handed to a page once that class or id actually appears in its DOM. Injecting all of them
* unconditionally would mean roughly 400 KB of CSS on every page load.
*/
internal class FilterEngine private constructor(
private val blockRules: RuleSet,
private val allowRules: RuleSet,
private val genericComplexSelectors: List<String>,
private val genericByClass: HashMap<String, MutableList<String>>,
private val genericById: HashMap<String, MutableList<String>>,
private val specificSelectors: HashMap<String, MutableList<String>>,
private val cosmeticExceptions: HashMap<String, MutableList<String>>,
private val genericHasText: List<Pair<String, String>>,
private val specificHasText: HashMap<String, MutableList<Pair<String, String>>>,
val networkRuleCount: Int,
val cosmeticRuleCount: Int
) {
private val hostCssCache = LruCache<String, String>(32)
/** True when the request should be blocked at all, regardless of which rule matched. */
fun shouldBlock(
url: String,
requestHost: String?,
documentHost: String?,
type: Int,
isThirdParty: Boolean
): Boolean = matchBlockingRule(url, requestHost, documentHost, type, isThirdParty) != null
/**
* The block match for this request, or null when it is not blocked. Split out from
* [shouldBlock] so the caller can also read the matched rule's `$redirect=` resource name
* without a second, separate walk over the same rule sets. A plain `||host^` match (the
* common case) carries no [NetworkRule] object at all, hence [BlockMatch] rather than
* returning the rule directly.
*/
fun matchBlockingRule(
url: String,
requestHost: String?,
documentHost: String?,
type: Int,
isThirdParty: Boolean
): BlockMatch? {
val lowerUrl = url.lowercase()
val hostStart = hostStart(lowerUrl)
val hostEnd = hostEnd(lowerUrl, hostStart)
val tokens = ArrayList<String>(16)
tokenize(lowerUrl, tokens)
val blockingMatch = matchingRule(blockRules, lowerUrl, hostStart, hostEnd, requestHost, documentHost, type, isThirdParty, tokens)
?: return null
if (matchesAny(allowRules, lowerUrl, hostStart, hostEnd, requestHost, documentHost, type, isThirdParty, tokens)) {
return null
}
return blockingMatch
}
class BlockMatch(val redirectResource: String?) {
companion object {
val PLAIN = BlockMatch(null)
}
}
/**
* The stylesheet injected at document start: rules written for this site, plus the handful
* of generic rules that are not anchored on a class or id.
*/
fun cssForHost(host: String?): String {
val normalized = host?.lowercase().orEmpty()
hostCssCache.get(normalized)?.let { return it }
val exceptions = exceptionsFor(normalized)
val selectors = ArrayList<String>(genericComplexSelectors.size + 32)
if (normalized.isNotEmpty()) {
DomainUtils.forEachHostSuffix(normalized) { suffix ->
specificSelectors[suffix]?.let { selectors.addAll(it) }
}
}
selectors.addAll(genericComplexSelectors)
val css = buildHidingCss(selectors, exceptions)
hostCssCache.put(normalized, css)
return css
}
private val hasTextCache = LruCache<String, List<Pair<String, String>>>(32)
/**
* The `:has-text()` rules (base selector, JS regex source) that apply to [host]. Delivered
* separately from [cssForHost] because there is no CSS equivalent for a text match -- the
* caller runs these with JavaScript instead of a `<style>` element.
*/
fun hasTextRulesForHost(host: String?): List<Pair<String, String>> {
val normalized = host?.lowercase().orEmpty()
hasTextCache.get(normalized)?.let { return it }
val rules = ArrayList<Pair<String, String>>(genericHasText.size + 4)
if (normalized.isNotEmpty()) {
DomainUtils.forEachHostSuffix(normalized) { suffix ->
specificHasText[suffix]?.let { rules.addAll(it) }
}
}
rules.addAll(genericHasText)
hasTextCache.put(normalized, rules)
return rules
}
/**
* The stylesheet for generic rules whose class or id has turned up in the page. [classNames]
* and [ids] are whitespace-separated, as collected from the live DOM.
*/
fun cssForTokens(host: String?, classNames: String, ids: String): String {
val normalized = host?.lowercase().orEmpty()
val exceptions = exceptionsFor(normalized)
val selectors = ArrayList<String>()
collectByToken(classNames, genericByClass, selectors)
collectByToken(ids, genericById, selectors)
if (selectors.isEmpty()) return ""
return buildHidingCss(selectors, exceptions)
}
private fun collectByToken(
tokens: String,
index: HashMap<String, MutableList<String>>,
out: MutableList<String>
) {
if (tokens.isEmpty() || index.isEmpty()) return
var start = 0
val length = tokens.length
while (start < length) {
while (start < length && tokens[start].isWhitespace()) start++
if (start >= length) break
var end = start
while (end < length && !tokens[end].isWhitespace()) end++
index[tokens.substring(start, end)]?.let { out.addAll(it) }
start = end
}
}
private fun exceptionsFor(host: String): Set<String> {
if (host.isEmpty() || cosmeticExceptions.isEmpty()) return emptySet()
var result: MutableSet<String>? = null
DomainUtils.forEachHostSuffix(host) { suffix ->
cosmeticExceptions[suffix]?.let { exceptions ->
val target = result ?: HashSet<String>().also { result = it }
target.addAll(exceptions)
}
}
return result ?: emptySet()
}
private fun buildHidingCss(selectors: List<String>, exceptions: Set<String>): String {
val usable = if (exceptions.isEmpty()) {
selectors
} else {
selectors.filterNot { it in exceptions }
}
if (usable.isEmpty()) return ""
val builder = StringBuilder(usable.size * 24)
// One rule per chunk rather than one rule for everything: a browser drops an entire
// rule if any selector in it fails to parse, so a chunk limits that blast radius.
for (chunk in usable.chunked(SELECTORS_PER_RULE)) {
builder.append(chunk.joinToString(","))
builder.append("{display:none!important}")
}
return builder.toString()
}
private fun matchesAny(
ruleSet: RuleSet,
url: String,
hostStart: Int,
hostEnd: Int,
requestHost: String?,
documentHost: String?,
type: Int,
isThirdParty: Boolean,
tokens: List<String>
): Boolean {
if (!requestHost.isNullOrEmpty()) {
var found = false
DomainUtils.forEachHostSuffix(requestHost) { suffix ->
if (!found) {
if (suffix in ruleSet.plainHosts) {
found = true
} else {
val candidates = ruleSet.hostIndex[suffix]
if (candidates != null) {
for (rule in candidates) {
if (optionsMatch(rule, type, isThirdParty, documentHost)) {
found = true
break
}
}
}
}
}
}
if (found) return true
}
for (token in tokens) {
val candidates = ruleSet.tokenIndex[token] ?: continue
for (rule in candidates) {
if (optionsMatch(rule, type, isThirdParty, documentHost) &&
matchesUrl(rule, url, hostStart, hostEnd)
) {
return true
}
}
}
for (rule in ruleSet.catchAll) {
if (optionsMatch(rule, type, isThirdParty, documentHost) &&
matchesUrl(rule, url, hostStart, hostEnd)
) {
return true
}
}
return false
}
/** Same walk as [matchesAny], but returns the match itself instead of a plain boolean. */
private fun matchingRule(
ruleSet: RuleSet,
url: String,
hostStart: Int,
hostEnd: Int,
requestHost: String?,
documentHost: String?,
type: Int,
isThirdParty: Boolean,
tokens: List<String>
): BlockMatch? {
if (!requestHost.isNullOrEmpty()) {
var result: BlockMatch? = null
DomainUtils.forEachHostSuffix(requestHost) { suffix ->
if (result == null) {
if (suffix in ruleSet.plainHosts) {
result = BlockMatch.PLAIN
} else {
val candidates = ruleSet.hostIndex[suffix]
if (candidates != null) {
for (rule in candidates) {
if (optionsMatch(rule, type, isThirdParty, documentHost)) {
result = BlockMatch(rule.redirectResource)
break
}
}
}
}
}
}
if (result != null) return result
}
for (token in tokens) {
val candidates = ruleSet.tokenIndex[token] ?: continue
for (rule in candidates) {
if (optionsMatch(rule, type, isThirdParty, documentHost) &&
matchesUrl(rule, url, hostStart, hostEnd)
) {
return BlockMatch(rule.redirectResource)
}
}
}
for (rule in ruleSet.catchAll) {
if (optionsMatch(rule, type, isThirdParty, documentHost) &&
matchesUrl(rule, url, hostStart, hostEnd)
) {
return BlockMatch(rule.redirectResource)
}
}
return null
}
private fun optionsMatch(
rule: NetworkRule,
type: Int,
isThirdParty: Boolean,
documentHost: String?
): Boolean {
if (rule.typeMask != 0 && (rule.typeMask and type) == 0) return false
if (rule.excludedTypeMask != 0 && (rule.excludedTypeMask and type) != 0) return false
val thirdParty = rule.thirdParty
if (thirdParty != null && thirdParty != isThirdParty) return false
val include = rule.includeDomains
val exclude = rule.excludeDomains
if (include != null || exclude != null) {
val host = documentHost ?: return false
if (exclude != null && exclude.any { DomainUtils.hostMatchesDomain(host, it) }) return false
if (include != null && !include.any { DomainUtils.hostMatchesDomain(host, it) }) return false
}
return true
}
private fun matchesUrl(rule: NetworkRule, url: String, hostStart: Int, hostEnd: Int): Boolean {
if (rule.anchorDomain) {
if (matchFrom(url, hostStart, rule)) return true
var index = hostStart
while (index < hostEnd) {
if (url[index] == '.' && matchFrom(url, index + 1, rule)) return true
index++
}
return false
}
if (rule.anchorStart) return matchFrom(url, 0, rule)
val first = rule.segments[0]
if (first.isNotEmpty() && first.indexOf('^') < 0) {
var index = url.indexOf(first)
while (index >= 0) {
if (matchFrom(url, index, rule)) return true
index = url.indexOf(first, index + 1)
}
return false
}
var index = 0
while (index <= url.length) {
if (matchFrom(url, index, rule)) return true
index++
}
return false
}
private fun matchFrom(url: String, start: Int, rule: NetworkRule): Boolean {
val segments = rule.segments
var position = matchSegmentAt(url, start, segments[0])
if (position < 0) return false
for (i in 1 until segments.size) {
val segment = segments[i]
if (segment.isEmpty()) continue
var probe = position
var matchedEnd = -1
while (probe <= url.length) {
val end = matchSegmentAt(url, probe, segment)
if (end >= 0) {
matchedEnd = end
break
}
probe++
}
if (matchedEnd < 0) return false
position = matchedEnd
}
if (rule.anchorEnd && segments[segments.size - 1].isNotEmpty() && position != url.length) {
return false
}
return true
}
/** Returns the index just past the match, or -1. `^` matches one separator or end-of-URL. */
private fun matchSegmentAt(url: String, start: Int, segment: String): Int {
var position = start
for (i in segment.indices) {
val expected = segment[i]
if (expected == '^') {
if (position >= url.length) {
// A separator may stand for the end of the address, but only as the last
// character of the segment.
return if (i == segment.length - 1) position else -1
}
if (!isSeparator(url[position])) return -1
position++
} else {
if (position >= url.length || url[position] != expected) return -1
position++
}
}
return position
}
companion object {
private const val MIN_TOKEN_LENGTH = 3
private const val SELECTORS_PER_RULE = 100
private fun isSeparator(c: Char): Boolean = when {
c in 'a'..'z' -> false
c in 'A'..'Z' -> false
c in '0'..'9' -> false
c == '_' || c == '-' || c == '.' || c == '%' -> false
else -> true
}
private fun hostStart(url: String): Int {
val scheme = url.indexOf("://")
return if (scheme < 0) 0 else scheme + 3
}
private fun hostEnd(url: String, hostStart: Int): Int {
var index = hostStart
while (index < url.length) {
val c = url[index]
if (c == '/' || c == '?' || c == '#' || c == ':') return index
index++
}
return url.length
}
private fun tokenize(text: String, out: MutableList<String>) {
var index = 0
val length = text.length
while (index < length) {
val c = text[index]
if (c.isLetterOrDigit()) {
var end = index + 1
while (end < length && text[end].isLetterOrDigit()) end++
if (end - index >= MIN_TOKEN_LENGTH) out.add(text.substring(index, end))
index = end
} else {
index++
}
}
}
/** The longest literal token in the pattern, used as the rule's index key. */
private fun bestToken(rule: NetworkRule): String? {
var best: String? = null
val tokens = ArrayList<String>(4)
for (segment in rule.segments) {
tokens.clear()
tokenize(segment, tokens)
for (token in tokens) {
val current = best
if (current == null || token.length > current.length) best = token
}
}
return best
}
/**
* The leading class or id a selector is anchored on, as (`.` or `#`) to name, or null
* when the selector does not start with a plain class or id.
*/
private fun anchorToken(selector: String): Pair<Char, String>? {
if (selector.length < 2) return null
val kind = selector[0]
if (kind != '.' && kind != '#') return null
var end = 1
while (end < selector.length) {
val c = selector[end]
if (c.isLetterOrDigit() || c == '-' || c == '_') end++ else break
}
if (end == 1) return null
return kind to selector.substring(1, end)
}
fun build(lines: Sequence<String>): FilterEngine {
val builder = Builder()
for (line in lines) builder.addLine(line)
return builder.finish()
}
}
/**
* Accumulates rules one line at a time, fed straight from a reader so that the whole list
* never exists in memory as a collection of lines. Parsing EasyList and EasyPrivacy
* together takes roughly 250ms on a Pixel 6 in a release build. Be wary of profiling this
* in a debug build: `android:debuggable` disables ART's inlining and made the same work
* take over nine seconds, which looks like an algorithmic problem but is not one.
*/
internal class Builder {
private val blockRules = RuleSet()
private val allowRules = RuleSet()
private val genericComplex = LinkedHashSet<String>()
// Pre-sized: EasyList and EasyPrivacy together fill these to roughly these counts, and
// rehashing a 90k-entry map on the way there is pure waste.
private val genericByClass = HashMap<String, MutableList<String>>(16384)
private val genericById = HashMap<String, MutableList<String>>(8192)
private val specificSelectors = HashMap<String, MutableList<String>>(16384)
private val cosmeticExceptions = HashMap<String, MutableList<String>>(1024)
private val genericHasText = mutableListOf<Pair<String, String>>()
private val specificHasText = HashMap<String, MutableList<Pair<String, String>>>()
private var networkRuleCount = 0
private var cosmeticRuleCount = 0
fun addLine(rawLine: String) {
if (rawLine.isEmpty()) return
// Only pay for trim() on the rare line that actually has surrounding whitespace.
val needsTrim = rawLine[0] == ' ' || rawLine[0] == '\t' ||
rawLine[rawLine.length - 1] == ' ' || rawLine[rawLine.length - 1] == '\t'
val line = if (needsTrim) rawLine.trim() else rawLine
if (line.isEmpty()) return
if (line[0] == '!' || line[0] == '[') return
val separator = FilterParser.cosmeticSeparatorIndex(line)
if (separator >= 0) {
addCosmeticRule(line, separator)
return
}
val isException = line.startsWith("@@")
val rule = FilterParser.parseNetworkRule(line) ?: return
networkRuleCount++
val target = if (isException) allowRules else blockRules
val hostAnchor = rule.hostAnchor
if (hostAnchor != null) {
if (rule.hasNoOptions) {
target.plainHosts.add(hostAnchor)
} else {
target.hostIndex.getOrPut(hostAnchor) { mutableListOf() }.add(rule)
}
return
}
val token = bestToken(rule)
if (token != null) {
target.tokenIndex.getOrPut(token) { mutableListOf() }.add(rule)
} else {
target.catchAll.add(rule)
}
}
private fun addCosmeticRule(line: String, separator: Int) {
val rule = FilterParser.parseCosmeticRule(line, separator) ?: return
cosmeticRuleCount++
// :has-text() has no CSS equivalent, so it is matched by JavaScript from its own
// list instead of joining the CSS selector strings above. Exceptions for it are not
// supported -- rare enough in EasyList/EasyPrivacy that dropping them outright (by
// not indexing this rule) is a safe simplification rather than a real gap.
val textPattern = rule.hasTextPattern
if (textPattern != null) {
if (rule.isException) return
val entry = rule.selector to textPattern
if (rule.includeDomains != null) {
rule.includeDomains.forEach { domain ->
specificHasText.getOrPut(domain) { mutableListOf() }.add(entry)
}
} else {
genericHasText.add(entry)
}
return
}
when {
rule.isException -> {
rule.includeDomains?.forEach { domain ->
cosmeticExceptions.getOrPut(domain) { mutableListOf() }.add(rule.selector)
}
}
rule.includeDomains != null -> {
rule.includeDomains.forEach { domain ->
specificSelectors.getOrPut(domain) { mutableListOf() }.add(rule.selector)
}
}
else -> {
val anchor = anchorToken(rule.selector)
if (anchor == null) {
genericComplex.add(rule.selector)
} else {
val index = if (anchor.first == '.') genericByClass else genericById
index.getOrPut(anchor.second) { mutableListOf() }.add(rule.selector)
}
// `~example.com##.ad` hides everywhere except the excluded domains.
rule.excludeDomains?.forEach { domain ->
cosmeticExceptions.getOrPut(domain) { mutableListOf() }.add(rule.selector)
}
}
}
}
fun finish(): FilterEngine = FilterEngine(
blockRules = blockRules,
allowRules = allowRules,
genericComplexSelectors = genericComplex.toList(),
genericByClass = genericByClass,
genericById = genericById,
specificSelectors = specificSelectors,
cosmeticExceptions = cosmeticExceptions,
genericHasText = genericHasText,
specificHasText = specificHasText,
networkRuleCount = networkRuleCount,
cosmeticRuleCount = cosmeticRuleCount
)
}
internal class RuleSet {
/** Bare `||host^` blocks with no options: matching the host is the whole test. */
val plainHosts = HashSet<String>()
val hostIndex = HashMap<String, MutableList<NetworkRule>>()
val tokenIndex = HashMap<String, MutableList<NetworkRule>>()
val catchAll = ArrayList<NetworkRule>()
}
}
@@ -0,0 +1,330 @@
package app.seb3thehacker.gearslip.browser.adblock
/**
* A parsed network filter rule (Adblock Plus syntax subset).
*
* [segments] is the pattern split on `*`; each segment is matched literally except for `^`,
* which stands for a single separator character (or the end of the URL).
*/
internal class NetworkRule(
val segments: Array<String>,
val anchorDomain: Boolean,
val anchorStart: Boolean,
val anchorEnd: Boolean,
val typeMask: Int,
val excludedTypeMask: Int,
val thirdParty: Boolean?,
val includeDomains: Array<String>?,
val excludeDomains: Array<String>?,
/** Set when the whole pattern is a bare `||host^`, which lets the engine index it by host. */
val hostAnchor: String?,
/** Set for `$redirect=name` / `$redirect-rule=name` rules with a resource we can serve. */
val redirectResource: String?
) {
/**
* True when the rule carries no options, so matching the host is the whole test. The great
* majority of list entries are of this shape and the engine stores them as bare strings.
*/
val hasNoOptions: Boolean
get() = typeMask == 0 && excludedTypeMask == 0 && thirdParty == null &&
includeDomains == null && excludeDomains == null && redirectResource == null
}
internal class CosmeticRule(
val selector: String,
val includeDomains: List<String>?,
val excludeDomains: List<String>?,
val isException: Boolean,
/** Set for a `selector:has-text(pattern)` rule: the text [pattern] to match within
* [selector]'s matches, applied by JavaScript rather than as plain CSS. */
val hasTextPattern: String? = null
)
internal object FilterParser {
/**
* Options that change *how* a request is answered rather than whether it is blocked.
* A rule carrying one of these is dropped: honouring only its matching half would block
* requests the list author meant to rewrite or merely restrict.
*/
private val UNSUPPORTED_OPTIONS = hashSetOf(
"csp", "removeparam", "rewrite", "replace", "empty", "mp4",
"inline-script", "inline-font", "genericblock", "generichide", "specifichide", "elemhide",
"badfilter", "denyallow", "method", "header", "to", "from", "ipaddress", "permissions",
"uritransform", "urlskip", "urltransform", "strict1p", "strict3p", "popup", "popunder",
"cname", "webrtc", "stealth", "cookie", "network", "app", "extension", "jsonprune",
"hls", "referrerpolicy", "match-case-path"
)
/**
* Procedural cosmetic syntax that needs uBlock Origin's own runtime to evaluate. These
* selectors cannot be expressed as plain CSS, so the rules are skipped.
*
* `:has()` is not in this list: modern WebView's Chromium supports it natively as plain CSS,
* so those selectors are just passed straight through. `:has-text()` is handled separately in
* [parseCosmeticRule] rather than being skipped outright -- the simple, common form (a plain
* selector followed by one `:has-text(...)`) is split out and matched by JavaScript instead of
* CSS, since there is no CSS equivalent for a text match.
*/
private val PROCEDURAL_MARKERS = arrayOf(
// ":has-text(" stays here as a safety net: it's stripped out before this check runs
// when it's the simple trailing form [extractHasText] understands, so a leftover
// occurrence here means that extraction either wasn't attempted (not a cosmetic rule
// that reached it) or failed (nested/combined usage), and the rule should be dropped
// rather than shipped as literal, invalid CSS.
":has-text(",
":matches-css", ":matches-media", ":matches-path", ":matches-attr",
":xpath(", ":upward(", ":nth-ancestor(", ":style(", ":remove(", ":remove-attr(",
":remove-class(", ":watch-attr(", ":min-text-length(", ":others(", ":shadow(", ":if(",
":if-not(", ":not(:has"
)
private fun isHostCharacter(c: Char): Boolean =
c in 'a'..'z' || c in '0'..'9' || c == '.' || c == '-' || c == '_'
fun parseNetworkRule(line: String): NetworkRule? {
var body = line
if (body.startsWith("@@")) body = body.substring(2)
if (body.isEmpty()) return null
var pattern = body
var optionText: String? = null
val dollar = body.lastIndexOf('$')
if (dollar >= 0) {
pattern = body.substring(0, dollar)
optionText = body.substring(dollar + 1)
}
// Regular-expression rules (/pattern/) are not supported.
if (pattern.length > 2 && pattern.startsWith("/") && pattern.endsWith("/")) return null
if (pattern.isEmpty()) return null
var anchorDomain = false
var anchorStart = false
var anchorEnd = false
if (pattern.startsWith("||")) {
anchorDomain = true
pattern = pattern.substring(2)
} else if (pattern.startsWith("|")) {
anchorStart = true
pattern = pattern.substring(1)
}
if (pattern.endsWith("|")) {
anchorEnd = true
pattern = pattern.dropLast(1)
}
if (pattern.isEmpty()) return null
pattern = pattern.lowercase()
var typeMask = 0
var excludedTypeMask = 0
var thirdParty: Boolean? = null
var includeDomains: MutableList<String>? = null
var excludeDomains: MutableList<String>? = null
var redirectResource: String? = null
if (optionText != null) {
for (rawOption in optionText.split(',')) {
var option = rawOption.trim().lowercase()
if (option.isEmpty()) continue
var negated = false
if (option.startsWith("~")) {
negated = true
option = option.substring(1)
}
val equals = option.indexOf('=')
val key = if (equals >= 0) option.substring(0, equals) else option
val value = if (equals >= 0) option.substring(equals + 1) else null
if (key.isEmpty()) return null
if (key in UNSUPPORTED_OPTIONS) return null
when (key) {
"domain" -> {
if (value.isNullOrEmpty()) return null
for (entry in value.split('|')) {
val domain = entry.trim()
if (domain.isEmpty()) continue
if (domain.startsWith("~")) {
val excluded = domain.substring(1)
if (excluded.isNotEmpty()) {
(excludeDomains ?: mutableListOf<String>()
.also { excludeDomains = it }).add(excluded)
}
} else {
(includeDomains ?: mutableListOf<String>()
.also { includeDomains = it }).add(domain)
}
}
}
"third-party", "3p" -> thirdParty = !negated
"first-party", "1p" -> thirdParty = negated
// Patterns are lower-cased for matching, so $match-case degrades to
// case-insensitive. It only ever widens the match.
"match-case", "important" -> Unit
"all" -> Unit
// `redirect-rule` only fires when another rule would already block the
// request; treating it the same as `redirect` is a harmless simplification
// since we only reach here once a block decision is otherwise made.
"redirect", "redirect-rule" -> {
// AdGuard's priority suffix (`name:10`) doesn't apply to a single-engine
// blocker with no other redirect rule to rank against.
val name = value?.substringBefore(':')
if (name.isNullOrEmpty()) return null
redirectResource = name
}
else -> {
val bit = ResourceType.fromOptionName(key)
if (bit == ResourceType.NONE) return null
if (negated) excludedTypeMask = excludedTypeMask or bit
else typeMask = typeMask or bit
}
}
}
}
val segments = splitOnWildcards(pattern)
if (segments.isEmpty()) return null
var hostAnchor: String? = null
if (anchorDomain && !anchorEnd && segments.size == 1) {
val candidate = if (pattern.endsWith("^")) pattern.dropLast(1) else pattern
if (candidate.isNotEmpty() && candidate.all { isHostCharacter(it) }) {
hostAnchor = candidate
}
}
return NetworkRule(
segments = segments,
anchorDomain = anchorDomain,
anchorStart = anchorStart,
anchorEnd = anchorEnd,
typeMask = typeMask,
excludedTypeMask = excludedTypeMask,
thirdParty = thirdParty,
includeDomains = includeDomains?.toTypedArray(),
excludeDomains = excludeDomains?.toTypedArray(),
hostAnchor = hostAnchor,
redirectResource = redirectResource
)
}
private fun splitOnWildcards(pattern: String): Array<String> {
if (pattern.indexOf('*') < 0) return arrayOf(pattern)
return pattern.split('*').toTypedArray()
}
/** Returns the index of the cosmetic separator, or -1 when the line is not a cosmetic rule. */
fun cosmeticSeparatorIndex(line: String): Int {
var search = 0
while (true) {
val hash = line.indexOf('#', search)
if (hash < 0) return -1
if (hash + 1 < line.length) {
val next = line[hash + 1]
if (next == '#') return hash
if (next == '@' && hash + 2 < line.length && line[hash + 2] == '#') return hash
// #?#, #$#, #$?#, #%# and friends are procedural or scriptlet syntax.
if (next == '?' || next == '$' || next == '%') return hash
}
search = hash + 1
}
}
fun parseCosmeticRule(line: String, separatorIndex: Int): CosmeticRule? {
val isException: Boolean
val selectorStart: Int
when {
line.startsWith("#@#", separatorIndex) -> {
isException = true
selectorStart = separatorIndex + 3
}
line.startsWith("##", separatorIndex) -> {
isException = false
selectorStart = separatorIndex + 2
}
// Procedural / scriptlet separators cannot be represented as CSS.
else -> return null
}
if (selectorStart >= line.length) return null
var selector = line.substring(selectorStart).trim()
if (selector.isEmpty()) return null
if (selector.startsWith("+js(") || selector.startsWith("script:")) return null
var hasTextPattern: String? = null
val hasTextSplit = extractHasText(selector)
if (hasTextSplit != null) {
selector = hasTextSplit.first
hasTextPattern = hasTextSplit.second
}
val lowerSelector = selector.lowercase()
if (PROCEDURAL_MARKERS.any { lowerSelector.contains(it) }) return null
// A stray brace would let a selector escape its own rule when concatenated.
if (selector.contains('{') || selector.contains('}')) return null
var includeDomains: MutableList<String>? = null
var excludeDomains: MutableList<String>? = null
val domainText = line.substring(0, separatorIndex).trim()
if (domainText.isNotEmpty()) {
for (entry in domainText.split(',')) {
val domain = entry.trim().lowercase()
if (domain.isEmpty()) continue
if (domain.startsWith("~")) {
val excluded = domain.substring(1)
if (excluded.isNotEmpty()) {
(excludeDomains ?: mutableListOf<String>().also { excludeDomains = it })
.add(excluded)
}
} else {
(includeDomains ?: mutableListOf<String>().also { includeDomains = it })
.add(domain)
}
}
}
return CosmeticRule(selector, includeDomains, excludeDomains, isException, hasTextPattern)
}
/**
* Splits `selector:has-text(pattern)` into (`selector`, JS regex source). Only the simple,
* common form is handled -- one `:has-text(...)` as the very last thing in the selector, with
* a non-empty selector before it. Anything else (nested inside `:has()`, combined with other
* procedural syntax, an empty base selector matching every element) returns null and the
* caller falls back to discarding the rule, same as before this existed.
*/
private fun extractHasText(selector: String): Pair<String, String>? {
val markerIndex = selector.indexOf(":has-text(")
if (markerIndex <= 0) return null
val argStart = markerIndex + ":has-text(".length
var depth = 1
var index = argStart
while (index < selector.length && depth > 0) {
when (selector[index]) {
'(' -> depth++
')' -> depth--
}
if (depth > 0) index++
}
// The closing paren must be the last character: a trailing combinator or pseudo after
// it means this is a more complex selector than the simple form handled here.
if (depth != 0 || index != selector.length - 1) return null
val base = selector.substring(0, markerIndex).trim()
if (base.isEmpty() || base.contains(":has-text(")) return null
var arg = selector.substring(argStart, index).trim()
if (arg.isEmpty()) return null
val pattern = if (arg.length > 1 && arg.startsWith("/") && arg.lastIndexOf('/') > 0) {
arg.substring(1, arg.lastIndexOf('/'))
} else {
Regex.escape(arg)
}
return base to pattern
}
}
@@ -0,0 +1,93 @@
package app.seb3thehacker.gearslip.browser.adblock
import android.webkit.WebResourceResponse
import java.io.ByteArrayInputStream
/**
* A small subset of uBlock Origin's / AdGuard's named `$redirect=` resources.
*
* Blocking most tracking scripts outright is safe -- the page never had a live reference to
* them, so nothing calls into the empty response [AdBlockManager.blockedResponse] already
* serves. But a handful of scripts are different: page code assumes the object they define
* (`ga`, `dataLayer`, `googletag`, ...) exists and calls it immediately, so an empty response
* leaves that call throwing a `ReferenceError` that can break unrelated script on the same page.
* These stubs define just enough of that surface to no-op safely, which is what the named
* `$redirect=` resource is for in the filter list that requested it.
*
* Anything not in this map still gets blocked -- just with the generic typed-empty response
* instead of an exact stub, same as before this existed.
*/
internal object RedirectResources {
private val EMPTY_JS = "".toByteArray()
private val GOOGLE_ANALYTICS_GA = """
(function(){function g(){}var t=function(){t.c[t.q.push(arguments)-1]=null};
t.q=[];t.c=[];window.ga=window.ga||t;window._gaq=window._gaq||{push:g};
window.GoogleAnalyticsObject=window.GoogleAnalyticsObject||'ga';})();
""".trimIndent().toByteArray()
private val GOOGLE_ANALYTICS_ANALYTICS = """
(function(){function s(){}var g={
create:function(){return{get:s,set:s,send:s}},getAll:function(){return[]},
getByName:function(){return null},remove:s};
window.ga=window.ga||function(){(window.ga.q=window.ga.q||[]).push(arguments)};
window.ga.create=g.create;window.ga.getAll=g.getAll;window.ga.getByName=g.getByName;
window.ga.remove=g.remove;window.gaplugins={Linker:function(){this.decorate=s;this.autoLink=s}};
})();
""".trimIndent().toByteArray()
private val GOOGLETAGMANAGER_GTM = """
(function(){window.dataLayer=window.dataLayer||[];
window.dataLayer.push=function(){};
window.google_tag_manager=window.google_tag_manager||{};})();
""".trimIndent().toByteArray()
private val GOOGLETAGSERVICES_GPT = """
(function(){function s(){}function u(){return{addService:function(){return this},
setTargeting:s,setCollapseEmptyDiv:s,addEventListener:function(){return this},
removeEventListener:s,get:s,set:s,getSlotElementId:function(){return''},
getSlotId:function(){return{getDomId:function(){return''}}}}}
window.googletag=window.googletag||{};
window.googletag.cmd=window.googletag.cmd||{push:function(fn){try{fn()}catch(e){}}};
var pubads=u();
window.googletag.pubads=function(){return pubads};
window.googletag.defineSlot=function(){return u()};
window.googletag.defineOutOfPageSlot=function(){return u()};
window.googletag.enableServices=s;window.googletag.display=s;
window.googletag.apiReady=true;})();
""".trimIndent().toByteArray()
private val NOOP_CSS = "".toByteArray()
private val NOOP_HTML = "<!doctype html><html><head></head><body></body></html>".toByteArray()
private val NOOP_JSON = "{}".toByteArray()
private val STUBS: Map<String, Pair<String, ByteArray>> = buildMap {
// Names as they appear across uBO's and AdGuard's resources.txt / redirect syntax.
for (name in listOf("noopjs", "noop.js", "noop-js")) put(name, "application/javascript" to EMPTY_JS)
for (name in listOf("noopcss", "noop.css", "noop-css")) put(name, "text/css" to NOOP_CSS)
for (name in listOf("noop.txt", "nooptext")) put(name, "text/plain" to "".toByteArray())
for (name in listOf("noop.json", "empty")) put(name, "application/json" to NOOP_JSON)
for (name in listOf("noop.html", "click2load.html")) put(name, "text/html" to NOOP_HTML)
for (name in listOf("google-analytics_ga.js", "google-analytics.com/ga.js", "ga.js"))
put(name, "application/javascript" to GOOGLE_ANALYTICS_GA)
for (name in listOf(
"google-analytics_analytics.js", "google-analytics.com/analytics.js",
"googletagmanager_gtag.js", "googletagmanager-gtag"
)) put(name, "application/javascript" to GOOGLE_ANALYTICS_ANALYTICS)
for (name in listOf("googletagmanager_gtm.js", "googletagmanager.com/gtm.js", "gtm.js"))
put(name, "application/javascript" to GOOGLETAGMANAGER_GTM)
for (name in listOf("googletagservices_gpt.js", "googletagservices.com/gpt.js", "gpt.js"))
put(name, "application/javascript" to GOOGLETAGSERVICES_GPT)
for (name in listOf("1x1.gif", "1x1-transparent.gif", "2x2.png", "3x2.png", "32x32.png"))
put(name, "image/gif" to AdBlockManager.transparentGifBytes)
}
/** Null when [name] is not one of the resources we ship a stub for. */
fun response(name: String, headers: Map<String, String>): WebResourceResponse? {
val stub = STUBS[name.lowercase()] ?: return null
val (mimeType, body) = stub
val encoding = if (mimeType.startsWith("image/")) null else "utf-8"
return WebResourceResponse(mimeType, encoding, 200, "OK", headers, ByteArrayInputStream(body))
}
}
@@ -0,0 +1,239 @@
package app.seb3thehacker.gearslip.browser.adblock
/**
* YouTube ad blocking, plus the cosmetic cleanup that rides along with it.
*
* Filter lists cannot touch YouTube's video ads. The ad and the video are delivered by the same
* hosts, so there is no request to block and no element to hide: the player decides to show an
* ad because its own API response told it to. What can be done is what uBlock Origin's
* scriptlets do — edit that response before the player reads it.
*
* Three hooks, all installed before any page script runs:
* - `ytInitialPlayerResponse`, the JSON embedded in the page for the first video watched;
* - `fetch`, used by the single-page app for every later video;
* - `JSON.parse`, a backstop for whatever path the two above miss.
*
* Each strips the keys the player uses to schedule ads. A periodic sweep then clicks any skip
* button and seeks past anything that still slipped through, which also covers ads stitched
* into the stream server-side, where nothing client-side can remove them cleanly. All of this,
* plus hiding the ad's own banner/in-feed markup, is gated on "Block YouTube video ads": it's
* inherently maintenance-prone (YouTube changes these field and class names when it wants to,
* and may respond with an anti-adblock notice), so it needs to be switchable off as a unit
* without losing ordinary filter-list blocking.
*
* The Shorts shelf and filter chip row are a separate concern -- cosmetic preference, not ad
* blocking -- so they sit behind their own "Custom site tweaks" setting instead, independent of
* whether YouTube ad blocking is on. The floating menu button covering a page's own fixed
* bottom bar is handled generically for every site in [CosmeticFilterBridge], not here.
*/
object YouTubeAdBlock {
val INJECTION_JS: String = """
(function () {
try {
var host = location.hostname || '';
if (!/(^|\.)(youtube\.com|youtube-nocookie\.com|youtubekids\.com)${'$'}/.test(host)) {
return;
}
var bridge = window.${CosmeticFilterBridge.BRIDGE_OBJECT_NAME};
if (!bridge) { return; }
var adBlockingOn = typeof bridge.youtubeBlockingEnabled === 'function' &&
bridge.youtubeBlockingEnabled(host);
var tweaksOn = typeof bridge.customSiteTweaksEnabled === 'function' &&
bridge.customSiteTweaksEnabled();
if (!adBlockingOn && !tweaksOn) { return; }
if (window.__gearslipYouTube) { return; }
window.__gearslipYouTube = true;
var styleRules = '';
if (adBlockingOn) {
// The fields the player consults to schedule ad breaks.
var AD_KEYS = [
'adPlacements', 'playerAds', 'adSlots', 'adBreakHeartbeatParams'
];
var stripKeys = function (object) {
if (!object || typeof object !== 'object') { return; }
for (var i = 0; i < AD_KEYS.length; i++) {
if (AD_KEYS[i] in object) {
try { delete object[AD_KEYS[i]]; } catch (e) {}
}
}
};
// Deliberately shallow. A player response is a large object and walking all of
// it on every parse would cost more than the ads do.
var prune = function (payload) {
if (!payload || typeof payload !== 'object') { return payload; }
if (Array.isArray(payload)) {
for (var i = 0; i < payload.length && i < 50; i++) { prune(payload[i]); }
return payload;
}
stripKeys(payload);
if (payload.playerResponse) { stripKeys(payload.playerResponse); }
if (payload.response) { stripKeys(payload.response); }
if (payload.playerData) { stripKeys(payload.playerData); }
return payload;
};
// 1. The first video: YouTube assigns this from an inline script in the document.
try {
var initial;
Object.defineProperty(window, 'ytInitialPlayerResponse', {
configurable: true,
get: function () { return initial; },
set: function (value) { initial = prune(value); }
});
} catch (e) {}
// 2. Every later video in the single-page app.
var originalFetch = window.fetch;
if (typeof originalFetch === 'function') {
window.fetch = function (input, init) {
var url = '';
try {
url = (typeof input === 'string') ? input : ((input && input.url) || '');
} catch (e) {}
var pending = originalFetch.apply(this, arguments);
// Only the player call carries ad scheduling. /next is the comments and
// related-video payload: large, and re-serialising it buys nothing.
if (url.indexOf('/youtubei/v1/player') < 0) {
return pending;
}
return pending.then(function (response) {
return response.text().then(function (text) {
var body = text;
// Cheap string test first: most responses have nothing to strip, and
// parsing plus re-serialising a player response is not free.
if (text.indexOf('"adPlacements"') >= 0 ||
text.indexOf('"playerAds"') >= 0 ||
text.indexOf('"adSlots"') >= 0) {
try {
var data = JSON.parse(text);
prune(data);
body = JSON.stringify(data);
} catch (e) {
body = text;
}
}
// Content-Length describes the original bytes, and Content-Encoding a
// compression this body no longer has. Copying either onto the edited
// body leaves the player waiting on bytes that never arrive.
var headers;
try {
headers = new Headers(response.headers);
headers.delete('content-length');
headers.delete('content-encoding');
} catch (e) {
headers = undefined;
}
return new Response(body, {
status: response.status,
statusText: response.statusText,
headers: headers
});
}).catch(function () { return response; });
});
};
}
// 3. Backstop for any path the first two miss.
var originalParse = JSON.parse;
JSON.parse = function (text, reviver) {
var result = originalParse.apply(this, arguments);
try { prune(result); } catch (e) {}
return result;
};
// Hide the banner, overlay and in-feed ads, which are ordinary elements. The
// :has() rules collapse the wrapping feed section too, instead of leaving an
// empty gap where the ad used to be.
styleRules +=
'ytd-promoted-sparkles-web-renderer,ytd-promoted-video-renderer,' +
'ytd-display-ad-renderer,ytd-ad-slot-renderer,ytd-in-feed-ad-layout-renderer,' +
'ytd-banner-promo-renderer,ytd-statement-banner-renderer,' +
'ytd-companion-slot-renderer,ytd-action-companion-ad-renderer,' +
'ytm-promoted-video-renderer,ytm-companion-ad-renderer,' +
'ytm-promoted-sparkles-web-renderer,ytm-search-promoted-video-renderer,' +
'.ytp-ad-overlay-container,.ytp-ad-overlay-slot,.ytp-ad-message-container,' +
'#masthead-ad,#player-ads,' +
'ytm-item-section-renderer:has(>*>ytm-companion-ad-renderer),' +
'ytm-item-section-renderer:has(>*>ytm-promoted-sparkles-web-renderer),' +
'ytm-item-section-renderer:has(>*>ytm-promoted-video-renderer),' +
'ytm-item-section-renderer:has(>*>ytm-search-promoted-video-renderer),' +
'ytm-item-section-renderer:has(>*>ytd-ad-slot-renderer),' +
// The "desktop site" grid feed uses different wrapper elements entirely: each
// card is a ytd-rich-item-renderer grid cell, so hiding just the ad renderer
// inside it still leaves an empty cell-shaped hole in the grid.
'ytd-rich-item-renderer:has(ytd-display-ad-renderer),' +
'ytd-rich-item-renderer:has(ytd-ad-slot-renderer),' +
'ytd-rich-item-renderer:has(ytd-promoted-sparkles-web-renderer),' +
'ytd-rich-item-renderer:has(ytd-statement-banner-renderer),' +
'ytd-rich-item-renderer:has(ytd-banner-promo-renderer),' +
// Ads can also occupy a whole grid row, or a section above/between rows, not
// just a single cell -- same empty-hole problem, one level up each time.
'ytd-rich-grid-row:has(ytd-display-ad-renderer),' +
'ytd-rich-grid-row:has(ytd-ad-slot-renderer),' +
'ytd-rich-grid-row:has(ytd-promoted-sparkles-web-renderer),' +
'ytd-rich-grid-row:has(ytd-statement-banner-renderer),' +
'ytd-rich-grid-row:has(ytd-banner-promo-renderer),' +
'ytd-rich-section-renderer:has(ytd-ad-slot-renderer),' +
'ytd-rich-section-renderer:has(ytd-display-ad-renderer),' +
'ytd-rich-section-renderer:has(ytd-statement-banner-renderer),' +
'ytd-rich-section-renderer:has(ytd-banner-promo-renderer),' +
'ytd-ad-slot-renderer,ytd-primetime-promo-renderer' +
'{display:none!important}';
// Sweep for anything still playing: press skip, or seek to the end of the ad.
var adTicks = 0;
var sweep = function () {
try {
var skip = document.querySelector(
'.ytp-ad-skip-button,.ytp-ad-skip-button-modern,.ytp-skip-ad-button,' +
'.ytp-ad-skip-button-container button'
);
if (skip) { skip.click(); }
var player = document.querySelector('.html5-video-player');
if (player && player.classList.contains('ad-showing')) {
// Only seek once the ad state has held for two ticks. Seeking during the
// player's own transition makes it re-buffer, which looks like a stall.
adTicks++;
var video = player.querySelector('video');
if (adTicks >= 2 && video && isFinite(video.duration) && video.duration > 0) {
video.currentTime = video.duration;
}
} else {
adTicks = 0;
}
} catch (e) {}
};
setInterval(sweep, 700);
}
if (tweaksOn) {
styleRules +=
// The mobile site's fixed Home/Shorts/You bar and the "desktop site" grid's
// reel shelf section it sits inside of.
'ytm-pivot-bar-renderer,' +
'ytd-rich-section-renderer:has(ytd-reel-shelf-renderer),' +
// Shorts: a different, faster-cut format than the rest of the feed, and its
// vertical-video shelf doesn't fit a landscape car display well either way.
'ytm-reel-shelf-renderer,ytm-rich-shelf-renderer:has(ytm-reel-item-renderer),' +
'ytm-reel-item-renderer,ytd-reel-shelf-renderer,' +
// The "All / Podcasts / News / ..." filter chip row above the feed.
'ytm-chip-bar-renderer,ytm-feed-filter-chip-bar-renderer' +
'{display:none!important}';
}
if (styleRules) {
var style = document.createElement('style');
style.setAttribute('data-gearslip', 'youtube');
style.textContent = styleRules;
var root = document.head || document.documentElement;
if (root) { root.appendChild(style); }
}
} catch (e) {}
})();
""".trimIndent()
}
@@ -39,6 +39,7 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.res.vectorResource
import app.seb3thehacker.gearslip.R
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
@@ -69,7 +70,8 @@ fun AssistantOverlay(modifier: Modifier = Modifier) {
modifier
.fillMaxWidth()
.drawBehind {
drawRect(Brush.verticalGradient(0f to Color.Transparent, 0.45f to Color.Black.copy(alpha = 0.6f), 1f to Color.Black.copy(alpha = 0.85f)))
// Dark enough by the text that nothing on the screen behind reads through it.
drawRect(Brush.verticalGradient(0f to Color.Transparent, 0.3f to Color.Black.copy(alpha = 0.8f), 1f to Color.Black.copy(alpha = 0.92f)))
// A wide, flat oval of light rising from the bottom edge.
scale(1f, 0.22f, Offset(size.width / 2, size.height)) {
drawCircle(
@@ -86,8 +88,12 @@ fun AssistantOverlay(modifier: Modifier = Modifier) {
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(16.dp)) {
PulsingMic(listening)
Column(Modifier.weight(1f), horizontalAlignment = Alignment.CenterHorizontally) {
val listeningText = stringResource(R.string.assist_listening)
val momentText = stringResource(R.string.assist_one_moment)
val waiting = state.heard.isBlank() &&
(state.phase == CarAssistant.Phase.LISTENING || state.phase == CarAssistant.Phase.THINKING)
val (small, big) = when (state.phase) {
CarAssistant.Phase.LISTENING -> "" to state.heard.ifBlank { if (state.ready) "Listening…" else "One moment…" }
CarAssistant.Phase.LISTENING -> "" to state.heard.ifBlank { if (state.ready) listeningText else momentText }
CarAssistant.Phase.THINKING -> "" to state.heard.ifBlank { "…" }
else -> state.heard to state.reply
}
@@ -96,22 +102,33 @@ fun AssistantOverlay(modifier: Modifier = Modifier) {
}
Text(
big,
color = if (big == "Listening…" || big == "One moment…" || big == "…") Color.White.copy(alpha = 0.7f) else Color.White,
color = if (waiting) Color.White.copy(alpha = 0.7f) else Color.White,
style = MaterialTheme.typography.headlineSmall,
fontWeight = FontWeight.SemiBold,
textAlign = TextAlign.Center,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
)
// What to say, until the driver starts saying it.
if (state.phase == CarAssistant.Phase.LISTENING && state.ready && state.heard.isBlank()) {
Text(
stringResource(R.string.assist_examples),
color = Color.White.copy(alpha = 0.6f),
style = MaterialTheme.typography.bodyMedium,
textAlign = TextAlign.Center,
maxLines = 2,
modifier = Modifier.padding(top = 4.dp),
)
}
}
if (state.missingPermission) {
GsButton(tone = GsTone.Tonal, onClick = {
context.startActivity(
Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS, Uri.fromParts("package", context.packageName, null)),
)
}) { Text("Grant") }
}) { Text(stringResource(R.string.assist_grant)) }
}
GsIconButton(Icons.Filled.Close, "Dismiss", CarAssistant::cancel)
GsIconButton(Icons.Filled.Close, stringResource(R.string.msg_dismiss), CarAssistant::cancel)
}
}
}
@@ -11,6 +11,7 @@ import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import android.content.pm.PackageManager
import app.seb3thehacker.gearslip.R
import android.os.Handler
import android.os.Looper
import app.seb3thehacker.gearslip.GearslipLog
@@ -25,6 +26,11 @@ import kotlinx.coroutines.flow.StateFlow
*
* When the car named its Bluetooth address, only that device counts. Otherwise any audio
* connection does, since there's no telling which device is the car.
*
* Many cars switch Bluetooth music off while Android Auto runs and want it over Android Auto
* instead (a Skoda, a VW, an Audi, a Bolt). If the phone's Bluetooth audio to the car was up when
* the car showed Gearslip and drops after, that's what happened: this car's music goes through
* Gearslip from now on, and the car screen says so.
*/
object BluetoothAudioCheck {
@@ -41,6 +47,8 @@ object BluetoothAudioCheck {
private var appContext: Context? = null
private var armed = false
private var receiver: BroadcastReceiver? = null
/** Whether Bluetooth audio to the car was up when the car showed Gearslip; null until known. */
private var connectedAtStart: Boolean? = null
fun init(context: Context) {
appContext = context.applicationContext
@@ -50,11 +58,14 @@ object BluetoothAudioCheck {
fun onProjecting() = main.post {
if (armed) return@post
armed = true
connectedAtStart = null
audioToCar { connectedAtStart = it }
main.postDelayed(::start, WAIT_MS)
}
fun onCarDisconnected() = main.post {
armed = false
connectedAtStart = null
main.removeCallbacksAndMessages(null)
stopWatching()
_missing.value = false
@@ -97,36 +108,58 @@ object BluetoothAudioCheck {
private fun check() {
if (!armed) return
val context = appContext ?: return
if (CarSettings.pipeAudio.value) {
if (CarSettings.pipesNow) {
set(false, "audio goes through Gearslip")
return
}
if (!CarSettings.bluetoothWarning.value) {
set(false, "the warning is turned off")
return
audioToCar { ok ->
if (ok == null) return@audioToCar
if (!ok && connectedAtStart == true) return@audioToCar switchToGearslip()
if (!CarSettings.bluetoothWarning.value) {
set(false, "the warning is turned off")
return@audioToCar
}
set(!ok, if (ok) "connected to the car" else "not connected to the car")
}
}
/** The car dropped Bluetooth music once Gearslip was up: send this car's music through Gearslip. */
private fun switchToGearslip() {
val car = CarBluetooth.carAddress ?: return
val context = appContext ?: return
if (!app.seb3thehacker.gearslip.audio.CarAudio.sinkOffered) return
log.i("Bluetooth audio: the car turned it off with Gearslip running, so this car's music goes through Gearslip")
CarSettings.rememberAudioCar(car)
set(false, "audio goes through Gearslip")
app.seb3thehacker.gearslip.audio.CarAudio.request()
CarToasts.show(this, context.getString(R.string.car_audio_switched), androidx.car.app.CarToast.LENGTH_LONG)
}
/**
* Whether the phone's Bluetooth audio is connected to the car, on the main thread: null when
* that can't be told (no permission). Bluetooth switched off counts as not connected.
*/
@SuppressLint("MissingPermission")
private fun audioToCar(result: (Boolean?) -> Unit) {
val context = appContext ?: return result(null)
if (context.checkSelfPermission(Manifest.permission.BLUETOOTH_CONNECT) != PackageManager.PERMISSION_GRANTED) {
log.i("can't check the audio connection: the Bluetooth permission isn't granted")
return
}
val adapter = context.getSystemService(BluetoothManager::class.java)?.adapter ?: return
if (!adapter.isEnabled) {
set(true, "Bluetooth is off")
return
return result(null)
}
val adapter = context.getSystemService(BluetoothManager::class.java)?.adapter ?: return result(null)
if (!adapter.isEnabled) return result(false)
val car = CarBluetooth.carAddress?.uppercase()
adapter.getProfileProxy(context, object : BluetoothProfile.ServiceListener {
val asked = adapter.getProfileProxy(context, object : BluetoothProfile.ServiceListener {
override fun onServiceConnected(profile: Int, proxy: BluetoothProfile) {
val connected = runCatching { proxy.connectedDevices }.getOrDefault(emptyList())
adapter.closeProfileProxy(profile, proxy)
val ok = if (car != null) connected.any { it.address.uppercase() == car } else connected.isNotEmpty()
main.post {
set(!ok, if (ok) "connected to the car" else "not connected to the car")
}
main.post { result(ok) }
}
override fun onServiceDisconnected(profile: Int) {}
}, BluetoothProfile.A2DP)
if (!asked) result(null)
}
private fun set(missing: Boolean, why: String) {
@@ -38,7 +38,7 @@ import androidx.compose.ui.unit.dp
@Composable
internal fun BluetoothPairOverlay() {
val prompt by CarBluetooth.prompt.collectAsState()
val bluetoothAudio = !CarSettings.pipeAudio.collectAsState().value
val bluetoothAudio = !CarSettings.pipeAudio.collectAsState().value && !CarSettings.thisCarPipes.collectAsState().value
when (prompt) {
CarBluetooth.Prompt.NONE -> {
val missing by BluetoothAudioCheck.missing.collectAsState()
@@ -0,0 +1,182 @@
package app.seb3thehacker.gearslip.car
import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.lazy.grid.GridCells
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
import androidx.compose.foundation.lazy.grid.items
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.Search
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import app.seb3thehacker.gearslip.R
import app.seb3thehacker.gearslip.browser.Bookmark
import app.seb3thehacker.gearslip.browser.BrowserSettings
import app.seb3thehacker.gearslip.browser.SiteIcons
import app.seb3thehacker.gearslip.car.theme.GsIconBox
import app.seb3thehacker.gearslip.car.theme.GsTone
import app.seb3thehacker.gearslip.car.theme.gsColors
import app.seb3thehacker.gearslip.car.theme.gsShape
/** How wide a tile is, icon and label together; the grid fits as many as the screen allows. */
private val TILE_WIDTH = 112.dp
private val ICON_SIZE = 64.dp
private val SEARCH_MAX_WIDTH = 560.dp
/** Five tiles across at most, so a short list sits in the middle instead of along the left. */
private val GRID_MAX_WIDTH = TILE_WIDTH * 5
/** Room above each tile for the remove button, which sits on the icon's corner. */
private val REMOVE_ROOM = 10.dp
/**
* What the browser shows before a page is open: a search bar, and the bookmarks as big tiles.
* Holding a tile shows a remove button on every tile, as a launcher does; tapping anywhere
* else, or Done, puts them away. Adding happens from the browser's star, or on the phone.
*/
@Composable
internal fun BrowserStartPage(onOpen: (String) -> Unit, onSearch: () -> Unit, onAdd: () -> Unit) {
val bookmarks by BrowserSettings.bookmarks.collectAsState()
var editing by remember { mutableStateOf(false) }
if (bookmarks.isEmpty()) editing = false
// A narrow, centred column: on a wide car screen a bar from edge to edge reads as a toolbar
// rather than a search box, and tiles spread across it look scattered.
Column(
Modifier.fillMaxSize().padding(horizontal = 16.dp).padding(top = 24.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(20.dp),
) {
SearchBar(onSearch)
LazyVerticalGrid(
columns = GridCells.Adaptive(TILE_WIDTH),
contentPadding = PaddingValues(bottom = 16.dp),
horizontalArrangement = Arrangement.Center,
verticalArrangement = Arrangement.spacedBy(8.dp),
modifier = Modifier.widthIn(max = GRID_MAX_WIDTH).fillMaxSize(),
) {
items(bookmarks, key = { it.url }) { bookmark ->
BookmarkTile(
bookmark,
editing = editing,
onClick = { if (editing) editing = false else onOpen(bookmark.url) },
onHold = { editing = true },
onRemove = { BrowserSettings.removeBookmark(bookmark.url) },
)
}
item(key = "add") {
Box(Modifier.padding(top = REMOVE_ROOM)) {
if (editing) {
Tile(stringResource(R.string.web_done), onClick = { editing = false }) {
Icon(Icons.Filled.Close, null, Modifier.size(28.dp))
}
} else {
Tile(stringResource(R.string.web_add_bookmark), onClick = onAdd) {
Icon(Icons.Filled.Add, null, Modifier.size(28.dp))
}
}
}
}
}
}
}
/** Looks like a text field, but opens the car keyboard: the same as the header's Search. */
@Composable
private fun SearchBar(onSearch: () -> Unit) {
val colors = gsColors(GsTone.Neutral)
GsIconBox(onSearch, Modifier.widthIn(max = SEARCH_MAX_WIDTH).fillMaxWidth().height(56.dp), colors, shape = CircleShape) {
Row(Modifier.fillMaxWidth().padding(horizontal = 20.dp), verticalAlignment = Alignment.CenterVertically) {
Icon(Icons.Filled.Search, null, Modifier.size(24.dp))
Text(
stringResource(R.string.web_search),
style = MaterialTheme.typography.bodyLarge,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.padding(start = 12.dp),
)
}
}
}
@Composable
private fun BookmarkTile(bookmark: Bookmark, editing: Boolean, onClick: () -> Unit, onHold: () -> Unit, onRemove: () -> Unit) {
val context = LocalContext.current
val host = bookmark.host
val version by SiteIcons.version.collectAsState()
LaunchedEffect(host) { SiteIcons.fetch(context, host) }
val icon = remember(host, version) { SiteIcons.icon(context, host) }
// The remove button overhangs the icon's corner, into the room left above it, so nothing
// clips it: neither the grid's edge nor the next row.
Box(Modifier.width(TILE_WIDTH).padding(top = REMOVE_ROOM)) {
Tile(bookmark.title, onClick = onClick, onHold = onHold) {
if (icon != null) {
Image(icon.asImageBitmap(), null, Modifier.size(40.dp).clip(gsShape()))
} else {
Text(
bookmark.title.firstOrNull()?.uppercase() ?: "?",
style = MaterialTheme.typography.headlineMedium,
fontWeight = FontWeight.Bold,
)
}
}
if (editing) {
GsIconBox(
onRemove,
Modifier.align(Alignment.TopCenter).offset(x = ICON_SIZE / 2, y = -REMOVE_ROOM).size(28.dp),
gsColors(GsTone.Danger),
shape = CircleShape,
) {
Icon(Icons.Filled.Close, stringResource(R.string.web_remove_bookmark, bookmark.title), Modifier.size(18.dp))
}
}
}
}
/** An icon box with its label under it, the start page's one shape. */
@Composable
private fun Tile(label: String, onClick: () -> Unit, onHold: (() -> Unit)? = null, icon: @Composable () -> Unit) {
Column(Modifier.width(TILE_WIDTH), horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(6.dp)) {
GsIconBox(onClick, Modifier.size(ICON_SIZE), gsColors(GsTone.Tonal), onLongClick = onHold) { icon() }
Text(
label,
style = MaterialTheme.typography.labelLarge,
color = MaterialTheme.colorScheme.onSurface,
textAlign = TextAlign.Center,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.fillMaxWidth().padding(horizontal = 4.dp),
)
}
}
@@ -74,8 +74,12 @@ fun CarAppStage(
// The app's own drawing goes underneath; everything else is the host's rendering of
// the template it described, laid over the top.
androidx.compose.runtime.CompositionLocalProvider(LocalChromeCover provides cover, LocalCarAppConnection provides connection) {
if (status.surfaceRequested) {
AppSurface(connection, frame, template, Modifier.fillMaxSize())
// A place list's map is the host's to draw; any other map is the app's own surface.
val placeList = template as? androidx.car.app.model.PlaceListMapTemplate
if (placeList != null) {
HostPlaceMap(placeList, Modifier.fillMaxSize())
} else if (status.surfaceRequested) {
AppSurface(connection, frame, template, embedded, Modifier.fillMaxSize())
}
// The docked player floats over one side of the map: the chrome keeps clear of it,
// and the app hears that side is covered, so its map centres in the rest.
@@ -116,6 +120,7 @@ private fun AppSurface(
connection: CarAppConnection,
frame: CarEnvironment.Frame,
template: Template?,
embedded: Boolean,
modifier: Modifier = Modifier,
) {
var size by remember { mutableStateOf(0 to 0) }
@@ -125,19 +130,37 @@ private fun AppSurface(
// The app lays its map out around whatever the host covers, so a template change or a card
// changing size moves the visible area even though the surface itself hasn't changed.
val (width, height) = size
// A full-screen map takes the knob: turn to zoom, nudge to pan, press for its buttons. The
// home screen's map pane leaves it alone, so the knob still moves between the cards there.
val focusManager = androidx.compose.ui.platform.LocalFocusManager.current
val isMap = !embedded && !isContentTemplate(template)
DisposableEffect(connection, isMap, width, height) {
if (!isMap || width == 0 || height == 0) return@DisposableEffect onDispose {}
val knob = MapKnob(connection, width, height) { focusManager.clearFocus() }
CarKeys.knobTaker = knob
onDispose {
knob.release()
if (CarKeys.knobTaker === knob) CarKeys.knobTaker = null
}
}
val measured = bounds?.takeIf { width > 0 && height > 0 }?.let { cover?.visibleWithin(it, width, height) }
// The card column moves the map's centre but isn't taken out of the stable area: apps size
// their own overlays from it, and Vela's speedometer shrank to fit the strip right of the cards.
val cardsAtEnd = LocalCardsAtEnd.current
val cardEdge = cardEdge
val measuredStable = bounds?.takeIf { width > 0 && height > 0 }?.let { cover?.visibleWithin(it, width, height, skip = cardEdge) }
LaunchedEffect(template, size, measured, measuredStable, cardsAtEnd) {
// A little room above whatever ends the map at the bottom (the nav bar, or the screen's edge):
// apps draw their own corner pieces (Waze's speedometer) right on the area's bottom edge,
// which left them sitting flush on the bar.
val bottomGap = with(androidx.compose.ui.platform.LocalDensity.current) { MAP_BOTTOM_GAP.roundToPx() }
LaunchedEffect(template, size, measured, measuredStable, cardsAtEnd, bottomGap) {
if (width > 0 && height > 0) {
val visible = visibleAreaFor(template, width, height, measured, cardsAtEnd)
fun android.graphics.Rect.clearOfBar() = if (isEmpty) this else android.graphics.Rect(left, top, right, maxOf(top, bottom - bottomGap))
val visible = visibleAreaFor(template, width, height, measured, cardsAtEnd).clearOfBar()
// Waze seems to centre its car in the stable area rather than the visible one, so it
// gets the cards taken out of that too. Test: see whether its car moves out from under them.
val stable = if (!isContentTemplate(template) && connection.appPackage != "com.waze") {
visibleAreaFor(template, width, height, measuredStable, cardsAtEnd)
visibleAreaFor(template, width, height, measuredStable, cardsAtEnd).clearOfBar()
} else {
visible
}
@@ -270,6 +293,9 @@ private fun ChromeBar(
}
}
/** How far above the bottom of the map the area an app may draw in stops. */
private val MAP_BOTTOM_GAP = 12.dp
/** Height of [ChromeBar]; the template's own chrome starts below it. */
private val CHROME_BAR = 44.dp
@@ -57,11 +57,12 @@ object CarBluetooth {
private val main = Handler(Looper.getMainLooper())
private var appContext: Context? = null
/** This session's car: its channel, and how to open and write to it. */
/** This session's car: its channel, how to open and write to it, and who hears its answers. */
private class Car(
val service: ServiceDiscovery.BluetoothService,
val open: () -> Unit,
val send: (messageId: Int, body: ByteArray) -> Unit,
val answered: () -> Unit,
)
@Volatile private var car: Car? = null
@@ -80,15 +81,59 @@ object CarBluetooth {
fun init(context: Context) {
appContext = context.applicationContext
_addressWanted.value = AppSettings.getString(context, KEY_ADDRESS_ASK, "").ifEmpty { null }
}
/** Called once the car lists its services. Asks on the car screen only if there's pairing to do. */
private val _addressWanted = MutableStateFlow<String?>(null)
/**
* A car paired over Bluetooth connected while Gearslip didn't know the phone's address: the
* car's name, for the phone's home screen to ask for it. Null when there's nothing to ask.
* Issue #18: a 2021 Bolt hung up 25 seconds in until it was told which phone this is.
*/
val addressWanted: StateFlow<String?> = _addressWanted
/** Debug builds: shows the phone's card as if a car had just asked. */
fun askForAddressNow(context: Context) {
AppSettings.putString(context, KEY_ADDRESS_ASK, "Test car")
_addressWanted.value = "Test car"
}
/** The phone's card was answered or turned away: it doesn't come back for this car. */
fun addressAsked(context: Context) {
AppSettings.putString(context, KEY_ADDRESS_ASK, "")
_addressWanted.value = null
}
/**
* Once per car: it waits on the phone, and the car screen says so once. A car that keeps its
* screen isn't asked again on every drive: plenty of cars just wait for a tap on their icon.
*/
@SuppressLint("MissingPermission")
private fun askForAddress(device: BluetoothDevice, carAddress: String) {
val context = appContext ?: return
val asked = AppSettings.getString(context, KEY_ADDRESS_ASKED, "").split(",").filter { it.isNotEmpty() }
if (hash(carAddress) in asked) return
AppSettings.putString(context, KEY_ADDRESS_ASKED, (asked + hash(carAddress)).distinct().joinToString(","))
val name = runCatching { device.alias ?: device.name }.getOrNull()?.takeIf { it.isNotBlank() }
?: context.getString(R.string.bt_address_your_car)
AppSettings.putString(context, KEY_ADDRESS_ASK, name)
_addressWanted.value = name
log.i("paired, but the phone's address isn't known: asking on the phone")
main.post { CarToasts.show(this, context.getString(R.string.bt_address_car_note), CarToast.LENGTH_LONG) }
}
/**
* Called once the car lists its services. Asks on the car screen only if there's pairing to do.
* [answered] runs when the car answers a pairing request or the pairing finishes.
*/
fun onCarConnected(
service: ServiceDiscovery.BluetoothService,
open: () -> Unit,
send: (messageId: Int, body: ByteArray) -> Unit,
answered: () -> Unit = {},
) {
car = Car(service, open, send)
car = Car(service, open, send, answered)
bondStarted = false
announcing = false
log.i("the car offers Bluetooth pairing, methods=${service.pairingMethods}")
@@ -99,7 +144,10 @@ object CarBluetooth {
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")
askForAddress(device, service.carAddress)
}
declined(service.carAddress) -> log.i("not paired, and the driver said not now for this car")
else -> _prompt.value = Prompt.ASK
}
@@ -119,6 +167,40 @@ object CarBluetooth {
c.open()
}
/**
* The car is keeping its own screen: do what Android Auto does on every connection, and tell
* it over Bluetooth which phone this is. Every Honda on file keeps its screen (a 2017 Pilot, a
* 2017 and a 2020 Civic; issues #43 and #57), and in each log the car's Bluetooth channel was
* never opened. Honda starts Android Auto with what its manuals call auto pairing, which runs
* on that channel, and a 2021 Bolt kept hanging up until it heard the phone's address (#18).
*
* - Paired, with the address typed in: the car already heard it at connect.
* - Paired, no address: open the channel, and ask for the address on the phone again.
* - Not paired: the Pair card waits on the car's screen, which this car won't give up, so
* pair as if the driver had tapped it. Android's pairing dialog still asks on the phone.
*
* True when the car was sent something it may answer.
*/
fun onScreenWithheld(): Boolean {
val c = car ?: return false
if (pairing || announcing) return false
val device = device() ?: return false
return when {
device.bondState == BluetoothDevice.BOND_BONDED -> {
askForAddress(device, c.service.carAddress)
announce("the car is keeping its screen")
true
}
device.bondState != BluetoothDevice.BOND_NONE -> false
declined(c.service.carAddress) -> false
else -> {
log.i("the car is keeping its screen and isn't paired with this phone - pairing, as Android Auto does")
main.post { pair() }
true
}
}
}
fun onCarDisconnected() {
car = null
pairing = false
@@ -186,6 +268,7 @@ object CarBluetooth {
MSG_PAIRING_RESPONSE -> {
val fields = Wire.fields(body)
log.i("<- PairingResponse status=${Wire.varint(fields, 1)} already_paired=${Wire.varint(fields, 2) == 1L}")
c.answered()
if (!pairing) return
// Some cars start pairing themselves once they answer; give them a moment first.
main.postDelayed({ startBond("the car answered") }, CAR_BOND_WAIT_MS)
@@ -220,7 +303,10 @@ object CarBluetooth {
log.i("bond state $previous -> $state")
when {
state == BluetoothDevice.BOND_BONDING -> bondStarted = true
state == BluetoothDevice.BOND_BONDED -> finish(R.string.bt_paired)
state == BluetoothDevice.BOND_BONDED -> {
finish(R.string.bt_paired)
car?.answered?.invoke()
}
previous == BluetoothDevice.BOND_BONDING -> finish(R.string.bt_pair_unfinished)
}
}
@@ -245,7 +331,10 @@ object CarBluetooth {
/** The car as a Bluetooth device, or null (and logged) when Gearslip can't reach Bluetooth. */
private fun device(): BluetoothDevice? {
val context = appContext ?: return null
val context = appContext ?: run {
log.w("can't check pairing: Bluetooth isn't set up yet")
return null
}
val address = car?.service?.carAddress ?: return null
if (context.checkSelfPermission(Manifest.permission.BLUETOOTH_CONNECT) != PackageManager.PERMISSION_GRANTED) {
log.i("can't check pairing: the Bluetooth permission isn't granted")
@@ -284,6 +373,7 @@ object CarBluetooth {
val tidy = address.trim().uppercase().replace('-', ':')
if (tidy.isNotEmpty() && !BluetoothAdapter.checkBluetoothAddress(tidy)) return false
AppSettings.putString(context, KEY_PHONE_ADDRESS, tidy)
if (tidy.isNotEmpty()) addressAsked(context)
return true
}
@@ -298,6 +388,10 @@ object CarBluetooth {
.take(8).joinToString("") { "%02x".format(it) }
private const val KEY_DECLINED = "bluetooth_pair_declined"
/** The car's name while the phone's card asks for the address; empty when it doesn't. */
private const val KEY_ADDRESS_ASK = "bluetooth_address_ask"
/** Hashes of the cars already asked about, so each asks once. */
private const val KEY_ADDRESS_ASKED = "bluetooth_address_asked"
private const val KEY_PHONE_ADDRESS = "phone_bluetooth_address"
private const val CAR_REPLY_WAIT_MS = 5_000L
private const val CAR_BOND_WAIT_MS = 3_000L
@@ -14,6 +14,10 @@ import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.MaterialTheme
@@ -94,19 +98,66 @@ private fun WeatherCard(context: Context) {
is WeatherState.Ready -> {
val w = s.data
WeatherIcon(w.code, w.isDay, size = 44.dp)
Column {
Column(Modifier.weight(1f)) {
Text(
"${w.temp}${w.tempUnit} · ${Weather.condition(w.code)}",
style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.SemiBold,
maxLines = 1, overflow = androidx.compose.ui.text.style.TextOverflow.Ellipsis,
)
Text(w.place, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant)
Text(w.place, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, maxLines = 1)
}
NextHours(w.hours, Modifier.weight(1.3f))
}
}
}
}
}
/**
* The next few hours beside the weather now, in the same card, so a glance answers "will it rain
* on the way" without opening Weather. As many as fit, up to five; none if fewer than three would.
*/
@Composable
private fun NextHours(all: List<HourForecast>, modifier: Modifier) {
val now = System.currentTimeMillis()
// The forecast is cached, so its first hours may already be past.
val hours = all.filter { it.time > now }
androidx.compose.foundation.layout.BoxWithConstraints(modifier) {
val fit = (maxWidth / HOUR_WIDTH).toInt().coerceAtMost(5)
if (fit < 3 || hours.size < 3) return@BoxWithConstraints
Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
Box(Modifier.width(1.dp).height(64.dp).background(MaterialTheme.colorScheme.outlineVariant))
hours.take(fit).forEach { h ->
Column(
Modifier.weight(1f),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(2.dp),
) {
Text(
DateFormat.getTimeInstance(DateFormat.SHORT).format(Date(h.time)).replace(":00", ""),
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1,
)
WeatherIcon(h.code, h.isDay, size = 24.dp)
Text("${h.temp}°", style = MaterialTheme.typography.titleSmall, fontWeight = FontWeight.SemiBold)
// Kept as a blank line when dry, so every column lines up.
Text(
if (h.rainChance >= RAIN_SHOWN) "${h.rainChance}%" else " ",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.primary,
)
}
}
}
}
}
private val HOUR_WIDTH = 64.dp
/** Below this a chance of rain is noise on a glance card; the Weather screen still shows it. */
private const val RAIN_SHOWN = 20
@Composable
private fun AgendaCard(context: Context) {
val events by produceState<List<AgendaEvent>?>(cachedAgenda) {
@@ -1,5 +1,6 @@
package app.seb3thehacker.gearslip.car
import androidx.compose.foundation.layout.offset
import androidx.compose.ui.res.stringResource
import app.seb3thehacker.gearslip.car.theme.*
import androidx.compose.foundation.layout.Arrangement
@@ -267,23 +268,25 @@ private fun DockedPlayer(now: NowPlaying, modifier: Modifier) {
maxLines = 1, overflow = TextOverflow.Ellipsis,
)
if (now.durationMs > 0) {
PlayerProgress(now, media, Modifier.fillMaxWidth().padding(vertical = 2.dp), showTimes = false)
PlayerProgress(
now, media, Modifier.fillMaxWidth().padding(vertical = 2.dp),
timeStyle = MaterialTheme.typography.labelLarge,
)
} else {
Spacer(Modifier.height(10.dp))
}
TransportRow(now, media, skip = 56.dp, modifier = Modifier.fillMaxWidth())
Spacer(Modifier.height(10.dp))
PlayModeRow(now, media, size = 50.dp, modifier = Modifier.fillMaxWidth()) {
GsIconButton(
ImageVector.vectorResource(R.drawable.lyrics_24), stringResource(R.string.storage_lyrics), navigator::showSideLyrics,
size = 50.dp, iconSize = 26.dp,
)
// Sized from the column's width rather than fixed: fixed sizes filled a narrow
// column edge to edge, so the buttons sat squeezed against each other. Each row
// keeps a clear gap between buttons, and none grows past a comfortable size.
// Like, shuffle, repeat and lyrics are in the nav bar's pill while the player is
// here (NavNowPlaying), which leaves the cover more room.
BoxWithConstraints(Modifier.fillMaxWidth()) {
// Two skips, play at 1.25 times a skip, and two gaps of at least 20dp.
val skip = ((maxWidth - 40.dp) / 3.25f).coerceIn(36.dp, 48.dp)
TransportRow(now, media, skip = skip, modifier = Modifier.fillMaxWidth().padding(bottom = 4.dp))
}
}
GsIconButton(
Icons.Filled.Close, stringResource(R.string.home_back_nav), navigator::closeSidePanel,
Modifier.align(Alignment.TopEnd), size = 36.dp, iconSize = 20.dp,
)
// No close button here: the nav bar's dock button closes the player (NavNowPlaying).
}
}
}
@@ -35,11 +35,35 @@ object CarKeys {
/** Delivers an Android key event to the car display; set while the car UI is showing. */
@Volatile var focusSink: ((keyCode: Int, down: Boolean) -> Unit)? = null
/** What a [KnobTaker] is offered: a turn of the knob, a focus key edge, or Back. */
sealed interface KnobEvent {
data class Turn(val steps: Int) : KnobEvent
data class Key(val keyCode: Int, val down: Boolean) : KnobEvent
data object Back : KnobEvent
}
/**
* Something on screen that wants the knob before focus moves: the full-screen map, which
* zooms and pans with it ([MapKnob]), and the app grid, which starts on the app opened last.
* Returns true for an event it kept. Called on the thread the keys arrive on, not the main one.
*/
fun interface KnobTaker {
fun take(event: KnobEvent): Boolean
}
@Volatile var knobTaker: KnobTaker? = null
private var appContext: Context? = null
private val main = Handler(Looper.getMainLooper())
fun init(context: Context) { appContext = context.applicationContext }
/** The keys that act on the media app. */
private val MEDIA_KEYS = setOf(
KeyEvent.KEYCODE_MEDIA_PLAY_PAUSE, KeyEvent.KEYCODE_MEDIA_PLAY, KeyEvent.KEYCODE_MEDIA_PAUSE,
KeyEvent.KEYCODE_MEDIA_STOP, KeyEvent.KEYCODE_MEDIA_NEXT, KeyEvent.KEYCODE_MEDIA_PREVIOUS,
)
/**
* Every key worth asking the head unit for. The binding request lists what the head unit
* offered; when it offers nothing (some units leave the list empty and send keys anyway),
@@ -63,22 +87,30 @@ object CarKeys {
*/
fun onKey(keyCode: Int, down: Boolean, longPress: Boolean): Boolean {
if (keyCode in FOCUS_KEYS) {
if (knobTaker?.take(KnobEvent.Key(keyCode, down)) == true) return true
_focusMoves.tryEmit(Unit)
focusSink?.invoke(keyCode, down)
return true
}
if (!down) return keyCode in WANTED
val media = CarServices.media
// Null until the car shows Gearslip's screen. A 2020 Civic sends Play as soon as it hears
// the phone's Bluetooth address, while still on its own screen, and reaching for media
// then ended the session (issue #43).
val media = CarServices.mediaOrNull()
if (media == null && keyCode in MEDIA_KEYS) {
GearslipLog.i("keys: $keyCode before the car shows Gearslip - no media app to send it to yet")
return true
}
when (keyCode) {
KeyEvent.KEYCODE_MEDIA_PLAY_PAUSE -> media.togglePlay()
KeyEvent.KEYCODE_MEDIA_PLAY -> if (!media.now.value.playing) media.togglePlay()
KeyEvent.KEYCODE_MEDIA_PAUSE, KeyEvent.KEYCODE_MEDIA_STOP -> if (media.now.value.playing) media.togglePlay()
KeyEvent.KEYCODE_MEDIA_NEXT -> media.next()
KeyEvent.KEYCODE_MEDIA_PREVIOUS -> media.previous()
KeyEvent.KEYCODE_MEDIA_PLAY_PAUSE -> media?.togglePlay()
KeyEvent.KEYCODE_MEDIA_PLAY -> if (media != null && !media.now.value.playing) media.togglePlay()
KeyEvent.KEYCODE_MEDIA_PAUSE, KeyEvent.KEYCODE_MEDIA_STOP -> if (media != null && media.now.value.playing) media.togglePlay()
KeyEvent.KEYCODE_MEDIA_NEXT -> media?.next()
KeyEvent.KEYCODE_MEDIA_PREVIOUS -> media?.previous()
KeyEvent.KEYCODE_SEARCH, KeyEvent.KEYCODE_VOICE_ASSIST -> voice()
KeyEvent.KEYCODE_CALL -> call()
KeyEvent.KEYCODE_ENDCALL -> endCall()
KeyEvent.KEYCODE_BACK -> screen(ScreenKey.BACK)
KeyEvent.KEYCODE_BACK -> if (knobTaker?.take(KnobEvent.Back) != true) screen(ScreenKey.BACK)
KeyEvent.KEYCODE_HOME -> screen(ScreenKey.HOME)
KEYCODE_NAVIGATION -> screen(ScreenKey.MAP)
KEYCODE_MEDIA -> screen(ScreenKey.MEDIA)
@@ -92,6 +124,7 @@ object CarKeys {
/** A turn of the rotary knob: each click moves focus one step, like Tab or Shift+Tab. */
fun onRotate(delta: Int) {
val sink = focusSink ?: return
if (knobTaker?.take(KnobEvent.Turn(delta)) == true) return
_focusMoves.tryEmit(Unit)
val code = if (delta > 0) KeyEvent.KEYCODE_TAB else KEYCODE_SHIFT_TAB
repeat(kotlin.math.abs(delta).coerceAtMost(MAX_ROTARY_STEPS)) {
@@ -16,6 +16,16 @@ import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.grid.GridCells
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
import androidx.compose.foundation.lazy.grid.items
import androidx.compose.foundation.lazy.grid.itemsIndexed
import androidx.compose.foundation.lazy.grid.rememberLazyGridState
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.withFrameNanos
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import app.seb3thehacker.gearslip.AppSettings
import kotlinx.coroutines.launch
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Info
import androidx.compose.material.icons.filled.KeyboardArrowDown
@@ -75,6 +85,8 @@ private class Tile(
/** Known not to work; drawn with a red X instead of being left off the launcher. */
val broken: Boolean = false,
val onLongClick: (() -> Unit)? = null,
/** The entry's [Entry.componentId], for finding the app opened last. */
val id: String? = null,
val onClick: () -> Unit,
)
@@ -137,6 +149,10 @@ internal object LauncherCache {
}
}
}
CarSettings.hideOnFirstSight(tiles.mapNotNull { entry ->
val pkg = (entry.template?.component ?: entry.media?.component)?.packageName ?: entry.messaging?.packageName
entry.componentId?.takeIf { pkg != null && KnownApps.startsHidden(context, pkg, entry.messaging != null) }
})
return tiles.sortedBy { it.label.lowercase() }.also { flow.value = it }
}
}
@@ -282,35 +298,77 @@ fun CarLauncher() {
val showVehicleData = rememberVehicleDataShown()
fun tileOf(entry: Entry): Tile {
// Only apps that don't work get a mark. The X rates an app's own car screens; a messenger
// shown on Gearslip's own Messages screen has none, so it never gets one.
val pkg = entry.packageName?.takeIf { entry.messaging == null }
val id = entry.componentId
return Tile(
entry.builtIn?.let { context.getString(it.labelRes) } ?: entry.label, entry.icon, entry.builtIn?.glyph,
broken = pkg != null && (KnownApps.isBroken(context, pkg) || (entry.template != null && KnownApps.screenBroken(pkg))),
broken = markedBroken(context, entry),
onLongClick = id?.let { { navigator.showAppMenu(it) } },
) { openApp(entry, id?.let { running[it] }, navigator, frame) }
id = id,
) {
id?.let { AppSettings.putString(context, KEY_LAST_APP, it) }
openApp(entry, id?.let { running[it] }, navigator, frame)
}
}
val appOrder by CarSettings.appOrder.collectAsState()
val hidden by CarSettings.hiddenApps.collectAsState()
val tiles = launcherOrder(all, showVehicleData, appOrder, hidden).map(::tileOf)
// With the knob, the first move on this screen lands on the app opened last rather than on
// the first button anywhere on screen (issue #74). After that the knob moves as usual, out to
// the nav bar and back; nudging up or down already jumps a row.
val gridState = rememberLazyGridState()
val scope = rememberCoroutineScope()
val lastFocus = remember { FocusRequester() }
val lastId = remember { AppSettings.getString(context, KEY_LAST_APP, "") }
val lastIndex = tiles.indexOfFirst { it.id == lastId }.coerceAtLeast(0)
val focusIndex by rememberUpdatedState(lastIndex)
DisposableEffect(Unit) {
var used = false
val taker = CarKeys.KnobTaker { event ->
if (used || event == CarKeys.KnobEvent.Back || (event is CarKeys.KnobEvent.Key && !event.down)) {
return@KnobTaker false
}
used = true
scope.launch {
gridState.scrollToItem(focusIndex)
withFrameNanos { }
runCatching { lastFocus.requestFocus() }
}
true
}
CarKeys.knobTaker = taker
onDispose { if (CarKeys.knobTaker === taker) CarKeys.knobTaker = null }
}
LazyVerticalGrid(
columns = GridCells.Adaptive(minSize = 108.dp),
state = gridState,
modifier = Modifier.fillMaxSize(),
contentPadding = PaddingValues(20.dp),
horizontalArrangement = Arrangement.spacedBy(12.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
items(tiles) { AppTile(it) }
itemsIndexed(tiles) { i, tile -> AppTile(tile, if (i == lastIndex) Modifier.focusRequester(lastFocus) else Modifier) }
}
}
/** A red disc with a white X: marks a tile known not to work with Gearslip yet. */
/**
* Whether [entry] gets the red X, in the car and in the phone's app list alike. Only apps that
* don't work get one, and a patched copy that does (Waze through Morphe) never does, since
* [KnownApps.isBroken] knows it by its signer. The X rates an app's own car screens; a messenger
* shown on Gearslip's own Messages screen has none, so it gets one only when it's one of
* Google's apps, which never work (Google Maps lists itself only as a messenger).
*/
internal fun markedBroken(context: android.content.Context, entry: Entry): Boolean {
val pkg = entry.packageName ?: return false
if (entry.messaging != null) return KnownApps.brokenAsMessenger(context, pkg)
return KnownApps.isBroken(context, pkg) || (entry.template != null && KnownApps.screenBroken(pkg))
}
@Composable
private fun BrokenBadge(size: androidx.compose.ui.unit.Dp, modifier: Modifier = Modifier) {
internal fun BrokenBadge(size: androidx.compose.ui.unit.Dp, modifier: Modifier = Modifier) {
Box(
modifier.size(size).background(Color(0xFFD32F2F), CircleShape),
contentAlignment = Alignment.Center,
@@ -321,14 +379,14 @@ private fun BrokenBadge(size: androidx.compose.ui.unit.Dp, modifier: Modifier =
@OptIn(ExperimentalFoundationApi::class)
@Composable
private fun AppTile(tile: Tile) {
private fun AppTile(tile: Tile, modifier: Modifier = Modifier) {
val shape = MaterialTheme.shapes.extraLarge
// A long press opens the app's menu (pin, close) - the same gesture everywhere apps are
// shown, here and on the nav bar. GsIconBox marks it in the theme's style.
GsIconBox(
onClick = tile.onClick,
onLongClick = tile.onLongClick,
modifier = Modifier.height(120.dp),
modifier = modifier.height(120.dp),
colors = GsColors(MaterialTheme.colorScheme.surfaceVariant, MaterialTheme.colorScheme.onSurfaceVariant),
shape = shape,
) {
@@ -377,3 +435,6 @@ private fun TileLabel(tile: Tile, maxLines: Int = 2, modifier: Modifier = Modifi
overflow = TextOverflow.Ellipsis,
)
}
/** The app opened from the launcher last, where the knob starts on the Apps screen. */
private const val KEY_LAST_APP = "car_last_app"
@@ -170,6 +170,7 @@ internal fun CarNavBar(navigator: CarNavigator) {
// the screen above rather than a panel on it. Over the player it's see-through, so the
// cover's glow carries on behind it.
val overPlayer = navigator.current == CarScreen.Media
val navMic by CarSettings.navMic.collectAsState()
Surface(color = if (overPlayer) androidx.compose.ui.graphics.Color.Transparent else MaterialTheme.colorScheme.background) {
BoxWithConstraints(Modifier.fillMaxWidth().height(NAV_BAR_HEIGHT).padding(horizontal = 10.dp)) {
Row(Modifier.fillMaxSize(), verticalAlignment = Alignment.CenterVertically) {
@@ -180,8 +181,10 @@ internal fun CarNavBar(navigator: CarNavigator) {
NavHomeButton(navigator, entries, running)
Spacer(Modifier.width(6.dp))
NavItem(MediaIcons.Apps, navigator.current == CarScreen.Apps) { navigator.apps() }
Spacer(Modifier.width(6.dp))
NavAssistantIcon()
if (navMic) {
Spacer(Modifier.width(6.dp))
NavAssistantIcon()
}
// Pinned shortcuts, then whatever else is open right now. A pinned app that's also
// open stays in its pinned spot, marked with a dot, instead of showing up twice.
@@ -553,8 +556,13 @@ private fun NavNowPlaying(navigator: CarNavigator, selected: Boolean, modifier:
val phase by media.phase.collectAsState()
val now by media.now.collectAsState()
if (phase != CarMedia.Phase.READY || !now.isActive) return
// Docked beside the map, the player is already on screen - the pill steps aside for it.
if (navigator.sidePanel == SidePanel.CONTROLS && navigator.current == CarScreen.Home) return
// Docked beside the map, the player is already on screen, so the pill carries what the
// player leaves out - like, shuffle, repeat and lyrics - and its dock button, filled in, in
// the same place: tapped again, it closes the player.
if (navigator.sidePanel == SidePanel.CONTROLS && navigator.current == CarScreen.Home) {
DockedPill(navigator, now, selected, modifier)
return
}
// The media screen is the full player already; the pill would only repeat its controls.
if (navigator.current == CarScreen.Media) return
val context = LocalContext.current
@@ -667,6 +675,42 @@ private fun NavNowPlaying(navigator: CarNavigator, selected: Boolean, modifier:
}
}
/**
* The pill while the player is docked: like, shuffle, repeat and lyrics, then the dock button,
* shown open, which closes it. Sits on the player's side of the bar, under the player.
*/
@Composable
private fun DockedPill(navigator: CarNavigator, now: app.seb3thehacker.gearslip.media.NowPlaying, selected: Boolean, modifier: Modifier) {
val scheme = MaterialTheme.colorScheme
val playerLeft by CarSettings.playerLeft.collectAsState()
Box(modifier.fillMaxWidth(), contentAlignment = if (playerLeft) Alignment.CenterStart else Alignment.CenterEnd) {
Surface(
modifier = Modifier.height(44.dp),
shape = RoundedCornerShape(14.dp),
color = if (selected) scheme.primaryContainer else scheme.secondaryContainer,
contentColor = if (selected) scheme.onPrimaryContainer else scheme.onSecondaryContainer,
) {
PlayModeRow(
now, CarServices.media, size = 36.dp,
modifier = Modifier.fillMaxHeight().padding(horizontal = 4.dp),
arrangement = Arrangement.spacedBy(6.dp),
) {
GsIconButton(
androidx.compose.ui.graphics.vector.ImageVector.vectorResource(R.drawable.lyrics_24),
stringResource(R.string.storage_lyrics), navigator::showSideLyrics,
size = 36.dp, iconSize = 20.dp,
)
GsIconButton(
if (playerLeft) MediaIcons.DockLeftOpen else MediaIcons.DockRightOpen,
stringResource(R.string.navbar_close_panel),
navigator::togglePlayerDock,
size = 36.dp, iconSize = 22.dp,
)
}
}
}
}
/**
* A filled lightning bolt, next to the battery percentage instead of the word "charging" - the
* core Material icon set this project ships (no extended pack, to keep the app small) has no
@@ -83,6 +83,14 @@ class CarNavigator(startId: String) {
stack.add(Crumb(screen, label, labelRes))
}
/**
* Whether a back button on this screen leads anywhere the nav bar doesn't: false when the
* screen under it is Home or the app grid, which the nav bar's own buttons already reach.
* Android Auto shows no back button on an app's first screen for the same reason.
*/
val backLeadsSomewhere: Boolean
get() = stack.size >= 2 && stack[stack.size - 2].screen.let { it != CarScreen.Home && it != CarScreen.Apps }
/** The one way back to a clean slate: resets the whole trail to just Home. */
fun home() {
stack.clear()
@@ -37,7 +37,13 @@ object CarServices {
private const val REPAUSE_MS = 2_000L
val nav: CarAppConnection by lazy { CarAppConnection(requireNotNull(context)) }
val media: CarMedia by lazy { CarMedia(requireNotNull(context)) }
val media: CarMedia by lazy {
CarMedia(requireNotNull(context)).also { m ->
// Remembered only once it connects: an app that refuses (NewPipe without Notification
// access) must not replace the one the driver was listening to on the next drive.
m.onReady = { app -> CarSettings.setLastMedia(app.component.flattenToString()) }
}
}
/** [nav], or null before the car screen has set things up. */
fun navOrNull(): CarAppConnection? = if (context != null) nav else null
@@ -67,7 +73,7 @@ object CarServices {
private var autoplayPending = false
/**
* The app that was playing when the driver opened another one. Opening an app is only a look
* The app that was playing, or paused with a track loaded, when the driver opened another one. Opening an app is only a look
* at its library: until something plays there, the music carries on in this one, so leaving
* the new app's screen goes back to it, as Android Auto does.
*/
@@ -138,19 +144,20 @@ object CarServices {
// --- media ---------------------------------------------------------------------------
fun openMedia(app: MediaApp, autoplay: Boolean = false) {
CarSettings.setLastMedia(app.component.flattenToString())
val phase = media.phase.value
val live = phase == CarMedia.Phase.READY || phase == CarMedia.Phase.CONNECTING
if (_mediaApp.value?.component == app.component && live) return
val previous = _mediaApp.value
stillPlaying = previous?.takeIf { it.component != app.component && media.now.value.playing && !autoplay }
// Paused counts too: a track loaded there is still the music the driver is listening to.
val loaded = media.now.value.let { it.playing || it.title.isNotEmpty() }
stillPlaying = previous?.takeIf { it.component != app.component && loaded && !autoplay }
_mediaApp.value = app
autoplayPending = autoplay
media.connect(app)
// Not torn down and rebuilt per app: the capture covers whatever the phone plays, so
// switching media apps never costs the driver another consent dialog.
if (CarSettings.pipeAudio.value) CarAudio.request()
if (CarSettings.pipesNow) CarAudio.request()
}
/**
@@ -57,6 +57,7 @@ object CarSettings {
private const val KEY_OPEN = "car_open_on_connect"
private const val KEY_NIGHT = "car_night_mode"
private const val KEY_PIPE_AUDIO = "car_pipe_audio"
private const val KEY_AUDIO_CARS = "car_audio_through_gearslip_cars"
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"
@@ -67,7 +68,8 @@ private const val KEY_LOCATION_WARNING = "car_location_warning"
private const val KEY_AUTOPLAY = "car_autoplay"
private const val KEY_PLAYER_TITLE = "car_player_title"
private const val KEY_PLAYER_LEFT = "car_player_left"
private const val KEY_MORE_LYRICS = "car_more_lyrics"
private const val KEY_NAV_MIC = "car_nav_mic"
private const val KEY_DEFAULT_HIDDEN = "car_default_hidden"
private const val KEY_THEME = "car_app_theme"
private const val KEY_UI_THEME = "car_ui_theme"
private const val KEY_DARK_STYLE = "car_dark_style"
@@ -113,7 +115,7 @@ private const val KEY_CLUSTER_VIDEO = "cluster_video_enabled"
private val autoplayFlow = MutableStateFlow(false)
private val playerTitleFlow = MutableStateFlow(false)
private val playerLeftFlow = MutableStateFlow(false)
private val moreLyricsFlow = MutableStateFlow(true)
private val navMicFlow = MutableStateFlow(true)
private val themeFlow = MutableStateFlow(AppTheme.PHONE)
val appTheme: StateFlow<AppTheme> = themeFlow
private val darkStyleFlow = MutableStateFlow(DarkStyle.BLACK)
@@ -243,14 +245,18 @@ private const val KEY_CLUSTER_VIDEO = "cluster_video_enabled"
/** The home screen's player sits left of the map instead of right of it. */
val playerLeft: StateFlow<Boolean> = playerLeftFlow
/** The assistant's mic button in the nav bar. Off hides only the button: the car's voice key still works. */
val navMic: StateFlow<Boolean> = navMicFlow
/** Lyrics from BetterLyrics and KuGou as well as LRCLIB; off sends song details to LRCLIB only. */
val moreLyrics: StateFlow<Boolean> = moreLyricsFlow
val scale: StateFlow<Float> = scaleFlow
/** [carDensity] when it leaves the choice to the car. */
const val DENSITY_FROM_CAR = 0
val DENSITIES = listOf(160, 180, 200, 220, 240)
/** The range a typed-in density may take (issue #82): small enough for more on screen, big enough to read. */
val CUSTOM_DENSITIES = 60..480
/** An absolute DPI for the car UI and its apps; the video's resolution doesn't change with it. */
val carDensity: StateFlow<Int> = carDensityFlow
@@ -269,6 +275,41 @@ private const val KEY_CLUSTER_VIDEO = "cluster_video_enabled"
*/
val pipeAudio: StateFlow<Boolean> = pipeAudioFlow
private val thisCarPipesFlow = MutableStateFlow(false)
/**
* The car connected now switches Bluetooth music off while Android Auto runs, so its music
* goes through Gearslip whatever [pipeAudio] says. Learned by [BluetoothAudioCheck] and
* remembered per car: a Skoda, a VW, an Audi and a Bolt all did this.
*/
val thisCarPipes: StateFlow<Boolean> = thisCarPipesFlow
/** Whether music goes through Gearslip in this car: the driver's choice, or what the car needs. */
val pipesNow: Boolean get() = pipeAudioFlow.value || thisCarPipesFlow.value
/** A car connected: whether it's one that needs music through Gearslip. Null for none known. */
fun carConnected(carAddress: String?) {
thisCarPipesFlow.value = carAddress != null && carKey(carAddress) in audioCars()
}
fun carDisconnected() {
thisCarPipesFlow.value = false
}
/** This car turned Bluetooth music off: from now on its music goes through Gearslip. */
fun rememberAudioCar(carAddress: String) {
AppSettings.putString(app, KEY_AUDIO_CARS, (audioCars() + carKey(carAddress)).distinct().joinToString(","))
thisCarPipesFlow.value = true
}
private fun audioCars(): List<String> =
AppSettings.getString(app, KEY_AUDIO_CARS, "").split(",").filter { it.isNotEmpty() }
/** Settings keep a short hash, not the car's address. */
private fun carKey(address: String): String =
java.security.MessageDigest.getInstance("SHA-256").digest(address.uppercase().toByteArray())
.take(8).joinToString("") { "%02x".format(it) }
/** Gearslip reads the map app's turns aloud on the car's directions channel; see DirectionsVoice. */
val speakDirections: StateFlow<Boolean> = speakDirectionsFlow
@@ -348,7 +389,7 @@ private const val KEY_CLUSTER_VIDEO = "cluster_video_enabled"
fun init(context: Context) {
app = context.applicationContext
scaleFlow.value = AppSettings.getString(app, KEY_SCALE, "1.0").toFloatOrNull() ?: 1f
carDensityFlow.value = AppSettings.getString(app, KEY_DENSITY, "").toIntOrNull()?.takeIf { it in DENSITIES } ?: DENSITY_FROM_CAR
carDensityFlow.value = AppSettings.getString(app, KEY_DENSITY, "").toIntOrNull()?.takeIf { it in DENSITIES || it in CUSTOM_DENSITIES } ?: DENSITY_FROM_CAR
openFlow.value = AppSettings.getString(app, KEY_OPEN, HOME)
lastNavFlow.value = AppSettings.getString(app, KEY_LAST_NAV, "").ifEmpty { null }
lastMediaFlow.value = AppSettings.getString(app, KEY_LAST_MEDIA, "").ifEmpty { null }
@@ -362,7 +403,7 @@ private const val KEY_CLUSTER_VIDEO = "cluster_video_enabled"
autoplayFlow.value = AppSettings.getString(app, KEY_AUTOPLAY, "false") == "true"
playerTitleFlow.value = AppSettings.getString(app, KEY_PLAYER_TITLE, "false") == "true"
playerLeftFlow.value = AppSettings.getString(app, KEY_PLAYER_LEFT, "false") == "true"
moreLyricsFlow.value = AppSettings.getString(app, KEY_MORE_LYRICS, "true") == "true"
navMicFlow.value = AppSettings.getString(app, KEY_NAV_MIC, "true") == "true"
pipeAudioFlow.value = AppSettings.getString(app, KEY_PIPE_AUDIO, "true") == "true"
// Until the driver sets it, on for USB audio and off for Bluetooth, where the map app's
// own voice already reaches the car.
@@ -449,16 +490,16 @@ private const val KEY_CLUSTER_VIDEO = "cluster_video_enabled"
AppSettings.putString(app, KEY_PLAYER_TITLE, on.toString())
}
fun setNavMic(on: Boolean) {
navMicFlow.value = on
AppSettings.putString(app, KEY_NAV_MIC, on.toString())
}
fun setPlayerLeft(on: Boolean) {
playerLeftFlow.value = on
AppSettings.putString(app, KEY_PLAYER_LEFT, on.toString())
}
fun setMoreLyrics(on: Boolean) {
moreLyricsFlow.value = on
AppSettings.putString(app, KEY_MORE_LYRICS, on.toString())
}
fun setAutoplay(on: Boolean) {
autoplayFlow.value = on
AppSettings.putString(app, KEY_AUTOPLAY, on.toString())
@@ -471,6 +512,11 @@ private const val KEY_CLUSTER_VIDEO = "cluster_video_enabled"
fun setPipeAudio(on: Boolean) {
// Saved even when unchanged, so picking the default still counts as a choice.
AppSettings.putString(app, KEY_PIPE_AUDIO, on.toString())
// Choosing Bluetooth by hand overrules what Gearslip learned about each car.
if (!on) {
AppSettings.putString(app, KEY_AUDIO_CARS, "")
thisCarPipesFlow.value = false
}
if (pipeAudioFlow.value == on) return
pipeAudioFlow.value = on
if (AppSettings.getString(app, KEY_SPEAK_DIRECTIONS, "") == "") speakDirectionsFlow.value = on
@@ -541,6 +587,21 @@ private const val KEY_CLUSTER_VIDEO = "cluster_video_enabled"
AppSettings.putString(app, KEY_PINNED, next.joinToString(","))
}
/**
* Unticks each of [ids] the first time it's seen, and never again: a known-broken app starts
* off the drawer, but once the driver ticks it back on it stays on.
*/
fun hideOnFirstSight(ids: List<String>) {
if (!::app.isInitialized) return
val seen = AppSettings.getString(app, KEY_DEFAULT_HIDDEN, "").split(",").filter { it.isNotEmpty() }.toSet()
val fresh = ids.filter { it !in seen }
if (fresh.isEmpty()) return
AppSettings.putString(app, KEY_DEFAULT_HIDDEN, (seen + fresh).joinToString(","))
val next = hiddenAppsFlow.value + fresh
hiddenAppsFlow.value = next
AppSettings.putString(app, KEY_HIDDEN_APPS, next.joinToString(","))
}
fun setAppHidden(id: String, hide: Boolean) {
val next = if (hide) hiddenAppsFlow.value + id else hiddenAppsFlow.value - id
hiddenAppsFlow.value = next
@@ -32,7 +32,6 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.produceState
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
@@ -45,9 +44,6 @@ import app.seb3thehacker.gearslip.car.theme.GsColors
import app.seb3thehacker.gearslip.car.theme.GsIconBox
import app.seb3thehacker.gearslip.car.theme.GsThemes
import app.seb3thehacker.gearslip.AppSettings
import app.seb3thehacker.gearslip.ui.CertSummary
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
/**
* Settings that make sense while sitting in the car. The web page URL is edited here, with the
@@ -73,7 +69,7 @@ fun CarSettingsScreen() {
val autoplay by CarSettings.autoplay.collectAsState()
val playerTitle by CarSettings.playerTitle.collectAsState()
val playerLeft by CarSettings.playerLeft.collectAsState()
val moreLyrics by CarSettings.moreLyrics.collectAsState()
val navMic by CarSettings.navMic.collectAsState()
val theme by CarSettings.appTheme.collectAsState()
val uiTheme by CarSettings.uiTheme.collectAsState()
val darkStyle by CarSettings.darkStyle.collectAsState()
@@ -85,12 +81,9 @@ fun CarSettingsScreen() {
val display by CarEnvironment.display.collectAsState()
val vehicle by CarEnvironment.vehicle.collectAsState()
val context = LocalContext.current
val cert by produceState<CertSummary?>(null) {
value = withContext(Dispatchers.Default) { CertSummary.read(context) }
}
// A rail of the groups down the left, and the chosen group's settings beside it: five short
// lists instead of one long scroll.
// lists instead of one long scroll. The groups and their order match Settings on the phone.
var chosen by androidx.compose.runtime.saveable.rememberSaveable { androidx.compose.runtime.mutableIntStateOf(0) }
val listState = androidx.compose.foundation.lazy.rememberLazyListState()
androidx.compose.runtime.LaunchedEffect(chosen) { listState.scrollToItem(0) }
@@ -104,259 +97,183 @@ fun CarSettingsScreen() {
contentPadding = PaddingValues(horizontal = 24.dp, vertical = 16.dp),
verticalArrangement = Arrangement.spacedBy(20.dp),
) {
var section: Int
section = 0
if (section == chosen) item { Group(stringResource(R.string.car_group_screen)) }
if (section == chosen) item {
ChoiceRow(
stringResource(R.string.car_open_on_connect),
listOf(CarSettings.HOME to stringResource(R.string.choice_open_home)) + CarApps.shown(launcher, LauncherMode.canCall(context)).map { it.id to stringResource(it.labelRes) },
open,
) { CarSettings.setOpenOnConnect(it) }
}
if (section == chosen) item {
ChoiceRow(
stringResource(R.string.car_size),
listOf(1f to stringResource(R.string.choice_size_normal), 1.25f to stringResource(R.string.choice_size_large), 1.5f to stringResource(R.string.choice_size_xlarge)),
scale,
) { CarSettings.setScale(it) }
}
if (section == chosen) item {
ChoiceRow(
stringResource(R.string.car_app_theme),
listOf(AppTheme.PHONE to stringResource(R.string.choice_theme_phone), AppTheme.LIGHT to stringResource(R.string.choice_theme_light), AppTheme.DARK to stringResource(R.string.choice_theme_dark)),
theme,
) { CarSettings.setAppTheme(it) }
}
if (section == chosen && theme != AppTheme.LIGHT) item {
ChoiceRow(
stringResource(R.string.car_dark_mode),
listOf(
DarkStyle.BLACK to stringResource(R.string.choice_dark_black),
DarkStyle.BLACK_COLOUR to stringResource(R.string.choice_dark_black_colour),
DarkStyle.DEEP_COLOUR to stringResource(R.string.choice_dark_deep_colour),
DarkStyle.GREY to stringResource(R.string.choice_dark_grey),
),
darkStyle,
) { CarSettings.setDarkStyle(it) }
}
if (section == chosen) item {
ChoiceRow(
stringResource(R.string.carset_theme),
GsThemes.builtIn.map { it.id to it.name },
uiTheme,
) { CarSettings.setUiTheme(it) }
}
if (section == chosen) item {
ChoiceRow(
stringResource(R.string.car_map_day_night),
listOf(NightMode.AUTO to stringResource(R.string.choice_auto), NightMode.DAY to stringResource(R.string.choice_night_day), NightMode.NIGHT to stringResource(R.string.choice_night_night)),
night,
) { CarSettings.setNightMode(it) }
}
if (section == chosen) item {
ChoiceRow(
stringResource(R.string.car_units),
listOf(Units.AUTO to stringResource(R.string.choice_auto), Units.IMPERIAL to stringResource(R.string.carset_imperial), Units.METRIC to stringResource(R.string.carset_metric)),
units,
) { CarSettings.setUnits(it) }
}
// For a head unit that reports the wrong size and stretches the picture (issue #12).
if (section == chosen) item {
ChoiceRow(
stringResource(R.string.carset_screen_size),
listOf<ScreenSize?>(null).plus(SCREEN_PRESETS).plus(screenSize).distinct()
.map { it to (it?.toString() ?: stringResource(R.string.screen_from_car)) },
screenSize,
) { CarSettings.setScreenSize(it) }
}
if (section == chosen && screenSize != null) item {
ChoiceRow(
stringResource(R.string.carset_fit),
listOf(ScreenFit.CROP to stringResource(R.string.screen_crop), ScreenFit.SQUASH to stringResource(R.string.screen_squash)),
screenFit,
) { CarSettings.setScreenFit(it) }
}
section = 1
if (section == chosen) item { Group(stringResource(R.string.car_group_nav_bar)) }
if (section == chosen) item {
ChoiceRow(
stringResource(R.string.car_nav_bar),
listOf(
NavBarMode.SHOW to stringResource(R.string.choice_nav_show),
NavBarMode.HIDE_NAVIGATING to stringResource(R.string.choice_nav_hide_navigating),
NavBarMode.HIDE to stringResource(R.string.choice_nav_hide),
),
navBar,
) { CarSettings.setNavBarMode(it) }
}
if (section == chosen) item {
ChoiceRow(
stringResource(R.string.car_player_nav_bar),
listOf(false to stringResource(R.string.car_skip_buttons), true to stringResource(R.string.car_song_title)),
playerTitle,
) { CarSettings.setPlayerTitle(it) }
}
if (section == chosen) item {
ChoiceRow(
stringResource(R.string.car_player_beside_map),
listOf(false to stringResource(R.string.car_right), true to stringResource(R.string.car_left)),
playerLeft,
) { CarSettings.setPlayerLeft(it) }
}
if (section == chosen) item {
ChoiceRow(
stringResource(R.string.car_autoplay),
listOf(false to stringResource(R.string.off), true to stringResource(R.string.on)),
autoplay,
) { CarSettings.setAutoplay(it) }
}
if (section == chosen) item {
ChoiceRow(
stringResource(R.string.carset_lyrics_from),
listOf(false to "LRCLIB", true to stringResource(R.string.carset_more_sources)),
moreLyrics,
) { CarSettings.setMoreLyrics(it) }
}
section = 2
if (section == chosen) item { Group(stringResource(R.string.car_group_sound)) }
if (section == chosen) item {
ChoiceRow(
stringResource(R.string.car_audio),
listOf(false to stringResource(R.string.guide_audio_bluetooth), true to stringResource(R.string.guide_audio_usb)),
pipeAudio,
) { CarSettings.setPipeAudio(it) }
}
if (section == chosen) item {
Text(
stringResource(R.string.carset_audio_note),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
section = 3
if (section == chosen) item { Group(stringResource(R.string.car_group_warnings)) }
if (section == chosen && !pipeAudio) item {
ChoiceRow(
stringResource(R.string.car_warn_bluetooth),
listOf(false to stringResource(R.string.off), true to stringResource(R.string.on)),
bluetoothWarning,
) { CarSettings.setBluetoothWarning(it) }
}
if (section == chosen) item {
ChoiceRow(
stringResource(R.string.car_warn_location),
listOf(false to stringResource(R.string.off), true to stringResource(R.string.on)),
locationWarning,
) { CarSettings.setLocationWarning(it) }
}
if (section == chosen) item {
ChoiceRow(
stringResource(R.string.car_spoken_directions),
listOf(false to stringResource(R.string.off), true to stringResource(R.string.on)),
speakDirections,
) { CarSettings.setSpeakDirections(it) }
}
if (section == chosen) item {
Text(
stringResource(R.string.carset_directions_note),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
if (section == chosen && speakDirections) item {
ChoiceRow(
stringResource(R.string.car_directions_over_music),
listOf(false to stringResource(R.string.off), true to stringResource(R.string.on)),
directionsOverMusic,
) { CarSettings.setDirectionsOverMusic(it) }
}
if (section == chosen && speakDirections) item {
Text(
stringResource(R.string.car_directions_over_music_note),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
if (section == chosen) item {
ExperimentalFeatureRow(
title = stringResource(R.string.car_phone_speech),
description = stringResource(R.string.carset_phone_speech_note),
checked = systemSpeech,
) { CarSettings.setSystemSpeech(it) }
}
section = 4
if (section == chosen) item { Group(stringResource(R.string.car_group_more)) }
if (section == chosen) item {
WebPageSetting()
}
if (section == chosen) item {
ScreenFit(vehicle)
}
if (section == chosen) item {
ChoiceRow(
stringResource(R.string.car_experimental),
listOf(false to stringResource(R.string.carset_hide), true to stringResource(R.string.carset_show)),
experimentalFeatures,
) { CarSettings.setExperimentalFeaturesEnabled(it) }
}
if (section == chosen && experimentalFeatures) item {
ExperimentalFeaturesList(carSensors, adaptiveVideo, clusterVideo)
}
if (section == chosen) item {
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
Text(stringResource(R.string.settings_connection), style = MaterialTheme.typography.titleMedium, color = MaterialTheme.colorScheme.primary)
Info(stringResource(R.string.carset_phone), "${Build.MANUFACTURER} ${Build.MODEL}")
Info(stringResource(R.string.carset_vehicle), vehicle.ifEmpty { "-" })
Info(stringResource(R.string.carset_video), display.ifEmpty { "-" })
Info(stringResource(R.string.settings_certificate), cert?.headline ?: stringResource(R.string.cert_checking))
when (chosen) {
0 -> {
item { Group(stringResource(R.string.car_group_screen)) }
item {
ChoiceRow(
stringResource(R.string.car_open_on_connect),
listOf(CarSettings.HOME to stringResource(R.string.choice_open_home)) + CarApps.shown(launcher, LauncherMode.canCall(context)).map { it.id to stringResource(it.labelRes) },
open,
) { CarSettings.setOpenOnConnect(it) }
}
item {
ChoiceRow(
stringResource(R.string.car_size),
listOf(1f to stringResource(R.string.choice_size_normal), 1.25f to stringResource(R.string.choice_size_large), 1.5f to stringResource(R.string.choice_size_xlarge)),
scale,
) { CarSettings.setScale(it) }
}
item {
ChoiceRow(
stringResource(R.string.car_app_theme),
listOf(AppTheme.PHONE to stringResource(R.string.choice_theme_phone), AppTheme.LIGHT to stringResource(R.string.choice_theme_light), AppTheme.DARK to stringResource(R.string.choice_theme_dark)),
theme,
) { CarSettings.setAppTheme(it) }
}
if (theme != AppTheme.LIGHT) item {
ChoiceRow(
stringResource(R.string.car_dark_mode),
listOf(
DarkStyle.BLACK to stringResource(R.string.choice_dark_black),
DarkStyle.BLACK_COLOUR to stringResource(R.string.choice_dark_black_colour),
DarkStyle.DEEP_COLOUR to stringResource(R.string.choice_dark_deep_colour),
DarkStyle.GREY to stringResource(R.string.choice_dark_grey),
),
darkStyle,
) { CarSettings.setDarkStyle(it) }
}
item {
ChoiceRow(
stringResource(R.string.car_buttons),
GsThemes.builtIn.map { it.id to it.name },
uiTheme,
) { CarSettings.setUiTheme(it) }
}
item {
ChoiceRow(
stringResource(R.string.car_map_day_night),
listOf(NightMode.AUTO to stringResource(R.string.choice_auto), NightMode.DAY to stringResource(R.string.choice_night_day), NightMode.NIGHT to stringResource(R.string.choice_night_night)),
night,
) { CarSettings.setNightMode(it) }
}
item {
ChoiceRow(
stringResource(R.string.car_units),
listOf(Units.AUTO to stringResource(R.string.choice_auto), Units.IMPERIAL to stringResource(R.string.carset_imperial), Units.METRIC to stringResource(R.string.carset_metric)),
units,
) { CarSettings.setUnits(it) }
}
// Fixes for a head unit that gets its own screen wrong (issue #12), together
// and last, since most cars never need them.
item { SubGroup(stringResource(R.string.carset_advanced)) }
item {
ChoiceRow(
stringResource(R.string.carset_screen_size),
listOf<ScreenSize?>(null).plus(SCREEN_PRESETS).plus(screenSize).distinct()
.map { it to (it?.toString() ?: stringResource(R.string.screen_from_car)) },
screenSize,
) { CarSettings.setScreenSize(it) }
}
if (screenSize != null) item {
ChoiceRow(
stringResource(R.string.carset_fit),
listOf(ScreenFit.CROP to stringResource(R.string.screen_crop), ScreenFit.SQUASH to stringResource(R.string.screen_squash)),
screenFit,
) { CarSettings.setScreenFit(it) }
}
item { ScreenFit(vehicle) }
}
}
if (section == chosen) item {
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
Text(stringResource(R.string.settings_about), style = MaterialTheme.typography.titleMedium, color = MaterialTheme.colorScheme.primary)
Info(stringResource(R.string.carset_version), app.seb3thehacker.gearslip.BuildConfig.VERSION_NAME)
Info(stringResource(R.string.carset_build), app.seb3thehacker.gearslip.BuildConfig.VERSION_CODE.toString())
ExperimentalFeatureRow(
stringResource(R.string.carset_whats_new_after),
stringResource(R.string.carset_whats_new_after_sub),
whatsNew,
) { CarSettings.setWhatsNewAfterUpdates(it) }
GsButton(onClick = CarSettings::openWhatsNew, tone = GsTone.Tonal, modifier = Modifier.height(56.dp)) {
Text(stringResource(R.string.settings_whats_new), style = MaterialTheme.typography.titleMedium)
1 -> {
item { Group(stringResource(R.string.car_group_nav_bar)) }
item {
ChoiceRow(
stringResource(R.string.car_nav_bar),
listOf(
NavBarMode.SHOW to stringResource(R.string.choice_nav_show),
NavBarMode.HIDE_NAVIGATING to stringResource(R.string.choice_nav_hide_navigating),
NavBarMode.HIDE to stringResource(R.string.choice_nav_hide),
),
navBar,
) { CarSettings.setNavBarMode(it) }
}
item {
SwitchRow(stringResource(R.string.car_nav_mic), stringResource(R.string.car_nav_mic_note), navMic) { CarSettings.setNavMic(it) }
}
item {
ChoiceRow(
stringResource(R.string.car_player_nav_bar),
listOf(false to stringResource(R.string.car_skip_buttons), true to stringResource(R.string.car_song_title)),
playerTitle,
) { CarSettings.setPlayerTitle(it) }
}
item {
ChoiceRow(
stringResource(R.string.car_player_beside_map),
listOf(false to stringResource(R.string.car_right), true to stringResource(R.string.car_left)),
playerLeft,
) { CarSettings.setPlayerLeft(it) }
}
item { Note(stringResource(R.string.car_player_beside_map_note)) }
item {
SwitchRow(stringResource(R.string.car_autoplay), stringResource(R.string.car_autoplay_note), autoplay) { CarSettings.setAutoplay(it) }
}
}
2 -> {
item { Group(stringResource(R.string.car_group_sound)) }
item {
ChoiceRow(
stringResource(R.string.car_audio),
listOf(false to stringResource(R.string.guide_audio_bluetooth), true to stringResource(R.string.guide_audio_usb)),
pipeAudio,
) { CarSettings.setPipeAudio(it) }
}
item { Note(stringResource(R.string.carset_audio_note)) }
item {
SwitchRow(stringResource(R.string.car_spoken_directions), stringResource(R.string.carset_directions_note), speakDirections) { CarSettings.setSpeakDirections(it) }
}
if (speakDirections) item {
SwitchRow(stringResource(R.string.car_directions_over_music), stringResource(R.string.car_directions_over_music_note), directionsOverMusic) { CarSettings.setDirectionsOverMusic(it) }
}
item {
SwitchRow(stringResource(R.string.car_phone_speech), stringResource(R.string.carset_phone_speech_note), systemSpeech) { CarSettings.setSystemSpeech(it) }
}
}
3 -> {
item { Group(stringResource(R.string.car_group_warnings)) }
if (!pipeAudio) item {
SwitchRow(stringResource(R.string.car_warn_bluetooth), null, bluetoothWarning) { CarSettings.setBluetoothWarning(it) }
}
item {
SwitchRow(stringResource(R.string.car_warn_location), null, locationWarning) { CarSettings.setLocationWarning(it) }
}
}
else -> {
item { Group(stringResource(R.string.car_group_more)) }
item { WebPageSetting() }
item {
SwitchRow(stringResource(R.string.car_experimental), stringResource(R.string.car_experimental_note), experimentalFeatures) { CarSettings.setExperimentalFeaturesEnabled(it) }
}
if (experimentalFeatures) item {
Column(verticalArrangement = Arrangement.spacedBy(10.dp)) {
SubGroup(stringResource(R.string.carset_experimental_heading))
ExperimentalFeaturesList(carSensors, adaptiveVideo, clusterVideo)
}
}
item {
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
SubGroup(stringResource(R.string.settings_connection))
// The certificate is managed on the phone, which shows it in full.
val none = stringResource(R.string.status_idle)
Info(stringResource(R.string.carset_phone), "${Build.MANUFACTURER} ${Build.MODEL}")
Info(stringResource(R.string.carset_vehicle), vehicle.ifEmpty { none })
Info(stringResource(R.string.carset_video), display.ifEmpty { none })
}
}
item {
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
SubGroup(stringResource(R.string.settings_about))
Info(stringResource(R.string.carset_version), app.seb3thehacker.gearslip.BuildConfig.VERSION_NAME)
Info(stringResource(R.string.carset_build), app.seb3thehacker.gearslip.BuildConfig.VERSION_CODE.toString())
SwitchRow(
stringResource(R.string.carset_whats_new_after),
stringResource(R.string.carset_whats_new_after_sub),
whatsNew,
) { CarSettings.setWhatsNewAfterUpdates(it) }
GsButton(onClick = CarSettings::openWhatsNew, tone = GsTone.Tonal, modifier = Modifier.height(56.dp)) {
Text(stringResource(R.string.settings_whats_new), style = MaterialTheme.typography.titleMedium)
}
}
}
}
}
@@ -364,6 +281,18 @@ fun CarSettingsScreen() {
}
}
/** A heading inside a group, for a few settings that belong together. */
@Composable
private fun SubGroup(title: String) {
Text(title, style = MaterialTheme.typography.titleMedium, color = MaterialTheme.colorScheme.primary)
}
/** What a setting above it does, in a sentence or two. */
@Composable
private fun Note(text: String) {
Text(text, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
}
@Composable
private fun <T> ChoiceRow(title: String, options: List<Pair<T, String>>, selected: T, onSelect: (T) -> Unit) {
// On a wide screen the name sits beside its choices, which halves how far there is to scroll.
@@ -451,7 +380,16 @@ private fun <T> ChoiceButtons(options: List<Pair<T, String>>, selected: T, onSel
else GsColors(scheme.surfaceContainerHigh, scheme.onSurface),
latched = on,
) {
Text(label, style = MaterialTheme.typography.titleMedium, fontWeight = if (on) FontWeight.Bold else FontWeight.Normal)
// Centred however many lines it takes: "Black and colour" wrapped to the left.
Text(
label,
style = MaterialTheme.typography.titleMedium,
fontWeight = if (on) FontWeight.Bold else FontWeight.Normal,
textAlign = androidx.compose.ui.text.style.TextAlign.Center,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.padding(horizontal = 8.dp),
)
}
}
}
@@ -469,17 +407,17 @@ private fun ExperimentalFeaturesList(carSensors: Boolean, adaptiveVideo: Boolean
Modifier.fillMaxWidth(),
verticalArrangement = Arrangement.spacedBy(10.dp),
) {
ExperimentalFeatureRow(
SwitchRow(
title = stringResource(R.string.car_vehicle_data),
description = stringResource(R.string.carset_vehicle_data_note),
checked = carSensors,
) { CarSettings.setCarSensorsEnabled(it) }
ExperimentalFeatureRow(
SwitchRow(
title = stringResource(R.string.car_adaptive_video),
description = stringResource(R.string.carset_adaptive_note),
checked = adaptiveVideo,
) { CarSettings.setAdaptiveVideoEnabled(it) }
ExperimentalFeatureRow(
SwitchRow(
title = stringResource(R.string.car_cluster_video),
description = stringResource(R.string.car_cluster_video_note),
checked = clusterVideo,
@@ -488,9 +426,9 @@ private fun ExperimentalFeaturesList(carSensors: Boolean, adaptiveVideo: Boolean
}
@Composable
private fun ExperimentalFeatureRow(
private fun SwitchRow(
title: String,
description: String,
description: String?,
checked: Boolean,
warning: Boolean = false,
onCheckedChange: (Boolean) -> Unit,
@@ -508,7 +446,7 @@ private fun ExperimentalFeatureRow(
) {
Column(Modifier.weight(1f)) {
Text(title, style = MaterialTheme.typography.titleMedium)
Text(
if (description != null) Text(
description,
style = MaterialTheme.typography.bodySmall,
color = if (warning) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.onSurfaceVariant,
@@ -589,7 +527,7 @@ private fun ScreenFit(vehicle: String) {
}
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
Text(stringResource(R.string.carset_screen_fit), style = MaterialTheme.typography.titleMedium, color = MaterialTheme.colorScheme.primary)
Text(stringResource(R.string.carset_screen_fit), style = MaterialTheme.typography.titleMedium)
Stepper(stringResource(R.string.carset_top), insets.top) { change(insets.copy(top = (insets.top + it).coerceAtLeast(0))) }
Stepper(stringResource(R.string.carset_bottom), insets.bottom) { change(insets.copy(bottom = (insets.bottom + it).coerceAtLeast(0))) }
Text(
@@ -597,14 +535,15 @@ private fun ScreenFit(vehicle: String) {
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
GsButton(
// With no car to name, a greyed-out button only said what couldn't be done.
if (known) GsButton(
onClick = {
val ok = app.seb3thehacker.gearslip.VehicleProfiles.saveInsets(context, vehicle, insets)
saved = if (ok) context.getString(R.string.carset_saved_to, vehicle) else context.getString(R.string.carset_save_failed)
},
enabled = known,
modifier = Modifier.fillMaxWidth().height(56.dp),
) { Text(if (known) stringResource(R.string.carset_save_vehicle) else stringResource(R.string.carset_no_profile)) }
) { Text(stringResource(R.string.carset_save_vehicle)) }
else Note(stringResource(R.string.carset_fit_needs_car))
saved?.let { Text(it, style = MaterialTheme.typography.bodyMedium) }
}
}
@@ -146,7 +146,7 @@ fun CarUi() {
// Ask for the phone's sound as soon as the car connects, not only when a media app opens:
// a video in the web browser or a map's voice needs it too. Asks once, after the car offers
// somewhere to play it.
LaunchedEffect(Unit) { if (CarSettings.pipeAudio.value) app.seb3thehacker.gearslip.audio.CarAudio.request() }
LaunchedEffect(Unit) { if (CarSettings.pipesNow) app.seb3thehacker.gearslip.audio.CarAudio.request() }
// A new version gets one card in the top right, a few seconds in and only while parked.
LaunchedEffect(Unit) { app.seb3thehacker.gearslip.update.UpdateChecker.onCarConnected(appContext) }
// The map follows the light outside, whatever the app's own theme is set to.
@@ -291,9 +291,19 @@ fun CarUi() {
SideEffect { lastScreen[0] = screen }
val enter = remember(screen) { androidx.compose.animation.core.Animatable(0f) }
LaunchedEffect(screen) { enter.animateTo(1f, androidx.compose.animation.core.tween(180)) }
// Going back to Home, the screen that was up fades out and sinks a little over the
// same fifth of a second, rather than vanishing: it stays drawn until it's gone.
val lastAway = remember { arrayOf<CarScreen>(CarScreen.Home) }
val shown = if (screen != CarScreen.Home) screen else lastAway[0]
SideEffect { if (screen != CarScreen.Home) lastAway[0] = screen }
if (safetyWarningSeen) {
Box(Modifier.fillMaxSize()) {
if (screen != CarScreen.Home) {
androidx.compose.animation.AnimatedVisibility(
visible = screen != CarScreen.Home,
enter = androidx.compose.animation.EnterTransition.None,
exit = androidx.compose.animation.fadeOut(androidx.compose.animation.core.tween(180)) +
androidx.compose.animation.slideOutVertically(androidx.compose.animation.core.tween(180)) { it / 40 },
) {
Box(
Modifier
.fillMaxSize()
@@ -301,7 +311,7 @@ fun CarUi() {
.background(MaterialTheme.colorScheme.background)
// The bar never hides here, so keep the screen clear of it. The
// player runs on under the see-through bar and keeps clear itself.
.padding(bottom = if (!keyboardVisible && !fullscreen && screen != CarScreen.Media) NAV_BAR_HEIGHT else 0.dp)
.padding(bottom = if (!keyboardVisible && !fullscreen && shown != CarScreen.Media) NAV_BAR_HEIGHT else 0.dp)
// Keeps touches that miss this screen's controls off the map.
.pointerInput(Unit) { detectTapGestures { } },
) {
@@ -311,7 +321,7 @@ fun CarUi() {
translationY = (1f - enter.value) * 16.dp.toPx()
},
) {
when (screen) {
when (shown) {
CarScreen.Home -> Unit
CarScreen.Apps -> CarLauncher()
CarScreen.Media -> CompositionLocalProvider(
@@ -322,9 +332,9 @@ fun CarUi() {
CarScreen.Dashboard -> CarDashboardScreen()
CarScreen.Weather -> WeatherScreen()
CarScreen.VehicleData -> VehicleDataScreen()
is CarScreen.Notifications -> NotificationsScreen(screen.replyTo)
is CarScreen.Messages -> MessagesScreen(screen.packageName)
is CarScreen.App -> CarApps.find(screen.id)?.content?.invoke()
is CarScreen.Notifications -> NotificationsScreen(shown.replyTo)
is CarScreen.Messages -> MessagesScreen(shown.packageName)
is CarScreen.App -> CarApps.find(shown.id)?.content?.invoke()
}
}
}
@@ -1,11 +1,21 @@
package app.seb3thehacker.gearslip.car
import android.content.ComponentName
import android.content.Context
import android.content.Intent
import android.view.SurfaceHolder
import android.view.SurfaceView
import androidx.car.app.CarAppService
import androidx.car.app.SessionInfo
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
@@ -16,19 +26,30 @@ import androidx.compose.material3.Text
import androidx.compose.material3.darkColorScheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.key
import androidx.compose.runtime.remember
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.produceState
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.compose.ui.viewinterop.AndroidView
import app.seb3thehacker.gearslip.Insets
import app.seb3thehacker.gearslip.host.CarAppConnection
import app.seb3thehacker.gearslip.host.TemplateApp
import app.seb3thehacker.gearslip.host.clockTime
import app.seb3thehacker.gearslip.host.display
import app.seb3thehacker.gearslip.host.remainingTime
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.StateFlow
/**
* The instrument-cluster screen: the next turn, big enough to read in a glance past the wheel.
@@ -36,10 +57,25 @@ import kotlinx.coroutines.delay
* along the bottom. With no route it shows what's playing, quietly, or nothing.
*
* Always dark, like the dials around it. [marginX] and [marginY] are the pixels the car crops off
* the frame, kept clear.
* the frame, kept clear. Many cars also draw their own dials and bars over what's left, so the
* card keeps to [clearArea] when the car says where that is, and otherwise to the middle of a
* wide strip: on an Audi's virtual cockpit it ran under the rev counter and the bottom bar (#56).
*
* When the main screen's navigation app has a session for clusters, it draws its map to the
* right of the card, on a surface Gearslip lends that session as it does the main one.
*/
@Composable
internal fun ClusterScreen(marginX: Int, marginY: Int) {
internal fun ClusterScreen(marginX: Int, marginY: Int, densityDpi: Int, clearArea: StateFlow<Insets?>) {
val carInsets by clearArea.collectAsState()
val context = LocalContext.current
// Which app draws the map: whatever navigation app the main screen runs, if it can.
val navApp by produceState<ComponentName?>(null) {
while (true) {
value = CarServices.navOrNull()?.connectedComponent
delay(2_000)
}
}
val mapApp = navApp?.let { app -> remember(app) { app.takeIf { drawsOnCluster(context, it) } } }
// The map app's turn changes a few times a minute: read it once a second.
val turn by produceState<ClusterTurn?>(null) {
while (true) {
@@ -52,52 +88,171 @@ internal fun ClusterScreen(marginX: Int, marginY: Int) {
CompositionLocalProvider(LocalContentColor provides Color.White) {
BoxWithConstraints(
Modifier.fillMaxSize().background(Color.Black).padding(
horizontal = with(density) { (marginX / 2).toDp() } + 24.dp,
vertical = with(density) { (marginY / 2).toDp() } + 16.dp,
horizontal = with(density) { (marginX / 2).toDp() },
vertical = with(density) { (marginY / 2).toDp() },
),
contentAlignment = Alignment.CenterStart,
) {
// Sized off the height: clusters run from short strips to near squares.
val unit = maxHeight / 12
val t = turn
if (t == null) {
NowPlayingLine(Modifier.align(Alignment.Center))
return@BoxWithConstraints
}
Column(verticalArrangement = Arrangement.spacedBy(unit * 0.4f)) {
Row(verticalAlignment = Alignment.CenterVertically) {
CarGlyph(t.icon, Modifier.size(unit * 3.4f))
Spacer(Modifier.width(unit))
Text(
t.distance,
fontSize = with(density) { (unit * 2.2f).toSp() },
fontWeight = FontWeight.Bold,
maxLines = 1,
)
val clear = clearPadding(maxWidth, maxHeight, carInsets?.let { with(density) { toDp(it) } })
BoxWithConstraints(
Modifier.fillMaxSize().padding(start = clear[0], top = clear[1], end = clear[2], bottom = clear[3]),
contentAlignment = Alignment.Center,
) {
val t = turn
val map = mapApp?.let { clusterMap(it, densityDpi, constraints.maxWidth, constraints.maxHeight) }
if (map != null && mapShowing(map)) {
Row(Modifier.fillMaxSize(), verticalAlignment = Alignment.CenterVertically) {
if (t != null) {
BoxWithConstraints(Modifier.weight(0.42f).fillMaxHeight(), contentAlignment = Alignment.CenterStart) {
// Narrower than alone: the arrow and distance alone are about 12 units wide.
TurnCard(t, minOf(maxHeight / t.rows, maxWidth / 12))
}
Spacer(Modifier.width(12.dp))
}
MapSurface(map, densityDpi, Modifier.weight(0.58f).fillMaxHeight())
}
return@BoxWithConstraints
}
if (t.what.isNotEmpty()) {
Text(
t.what,
fontSize = with(density) { (unit * 1.1f).toSp() },
fontWeight = FontWeight.SemiBold,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
)
}
if (t.trip.isNotEmpty()) {
Text(
t.trip,
fontSize = with(density) { (unit * 0.85f).toSp() },
color = Color.White.copy(alpha = 0.7f),
maxLines = 1,
)
if (t == null) {
NowPlayingLine(Modifier.align(Alignment.Center))
return@BoxWithConstraints
}
// The card is t.rows units tall and about 14 wide: fit both ways.
TurnCard(t, minOf(maxHeight / t.rows, maxWidth / 14))
}
}
}
}
}
/**
* The navigation app's cluster session, open while this is on screen. Sized for the clear area
* it shares with the card, so the app lays its map out for a cluster-shaped screen.
*/
@Composable
private fun clusterMap(app: ComponentName, densityDpi: Int, width: Int, height: Int): CarAppConnection {
val context = LocalContext.current.applicationContext
val connection = remember(app) {
CarAppConnection(context, SessionInfo(SessionInfo.DISPLAY_TYPE_CLUSTER, CLUSTER_SESSION))
}
DisposableEffect(connection) {
connection.connect(TemplateApp(app, app.packageName, emptyList()), (width * 0.58f).toInt(), height, densityDpi)
onDispose { connection.disconnect() }
}
return connection
}
/**
* Whether the map's place is kept: while the session comes up, and once running only if the app
* asked for a surface. An app whose cluster session draws nothing leaves the card on its own.
*/
@Composable
private fun mapShowing(map: CarAppConnection): Boolean {
val status by map.status.collectAsState()
return when (status.phase) {
CarAppConnection.Phase.BINDING, CarAppConnection.Phase.HANDSHAKE -> true
CarAppConnection.Phase.RUNNING -> status.surfaceRequested || status.template == null
else -> false
}
}
/** A surface lent to the cluster session; a fresh one for every size, as on the main screen. */
@Composable
private fun MapSurface(map: CarAppConnection, densityDpi: Int, modifier: Modifier) {
BoxWithConstraints(modifier) {
key(constraints.maxWidth, constraints.maxHeight) {
AndroidView(
modifier = Modifier.fillMaxSize(),
factory = { context ->
SurfaceView(context).apply {
holder.addCallback(object : SurfaceHolder.Callback {
override fun surfaceCreated(holder: SurfaceHolder) = Unit
override fun surfaceChanged(holder: SurfaceHolder, format: Int, width: Int, height: Int) =
map.attachSurface(holder.surface, width, height, densityDpi)
override fun surfaceDestroyed(holder: SurfaceHolder) = map.detachSurface(holder.surface)
})
}
},
)
}
}
DisposableEffect(map) { onDispose { map.detachSurface() } }
}
/** Whether [component] says, in its manifest, that it has a session for instrument clusters. */
private fun drawsOnCluster(context: Context, component: ComponentName): Boolean = runCatching {
val intent = Intent(CarAppService.SERVICE_INTERFACE).addCategory(FEATURE_CLUSTER).setPackage(component.packageName)
context.packageManager.queryIntentServices(intent, 0).any { it.serviceInfo.name == component.className }
}.getOrDefault(false)
private const val FEATURE_CLUSTER = "androidx.car.app.category.FEATURE_CLUSTER"
private const val CLUSTER_SESSION = "gearslip-cluster"
/** The lane to be in, on the cockpit: green, like the main screen's header, against the black. */
private val LANE_FILL = Color(0xFF2F7D6D)
/**
* Start, top, end and bottom padding around the card inside the visible picture. The car's own
* insets win when they leave a usable area; a wide strip, where dials sit at both ends and a bar
* along the bottom, keeps to the upper middle; anything squarer just keeps off the edges.
*/
private fun clearPadding(width: Dp, height: Dp, car: List<Dp>?): List<Dp> {
if (car != null && width - car[0] - car[2] >= width * 0.3f && height - car[1] - car[3] >= height * 0.3f) return car
if (width >= height * 2) return listOf(width * 0.22f, height * 0.08f, width * 0.22f, height * 0.33f)
return listOf(24.dp, 16.dp, 24.dp, 16.dp)
}
/** Insets in frame pixels as start, top, end and bottom padding. */
private fun androidx.compose.ui.unit.Density.toDp(insets: Insets): List<Dp> =
listOf(insets.left.toDp(), insets.top.toDp(), insets.right.toDp(), insets.bottom.toDp())
@Composable
private fun TurnCard(t: ClusterTurn, unit: Dp) {
val density = LocalDensity.current
Column(verticalArrangement = Arrangement.spacedBy(unit * 0.4f)) {
Row(verticalAlignment = Alignment.CenterVertically) {
CarGlyph(t.icon, Modifier.size(unit * 3.4f))
Spacer(Modifier.width(unit))
Text(
t.distance,
fontSize = with(density) { (unit * 2.2f).toSp() },
fontWeight = FontWeight.Bold,
maxLines = 1,
)
}
if (t.what.isNotEmpty()) {
Text(
t.what,
fontSize = with(density) { (unit * 1.1f).toSp() },
fontWeight = FontWeight.SemiBold,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
)
}
if (t.lanes.isNotEmpty()) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
LaneRoad(t.lanes, Modifier.width(unit * 10).height(unit * 2.4f), LANE_FILL, Color.White, Color.Black)
laneHint(t.lanes)?.let {
Text(
it,
Modifier.padding(top = unit * 0.3f),
fontSize = with(density) { (unit * 0.8f).toSp() },
color = Color.White.copy(alpha = 0.85f),
maxLines = 1,
)
}
}
}
if (t.trip.isNotEmpty()) {
Text(
t.trip,
fontSize = with(density) { (unit * 0.85f).toSp() },
color = Color.White.copy(alpha = 0.7f),
maxLines = 1,
)
}
}
}
@Composable
private fun NowPlayingLine(modifier: Modifier) {
val now = CarServices.mediaOrNull()?.now?.value
@@ -118,7 +273,11 @@ private class ClusterTurn(
val distance: String,
val what: String,
val trip: String,
val lanes: List<androidx.car.app.navigation.model.Lane>,
) {
/** How many units tall the card is: two lines of directions, and the lanes when there are some. */
val rows: Float get() = if (lanes.isEmpty()) 8.5f else 12f
companion object {
fun now(): ClusterTurn? {
val turn = NavTurn.current() ?: return null
@@ -134,7 +293,7 @@ private class ClusterTurn(
e.arrivalTimeAtDestination.clockTime(),
).filter { it.isNotEmpty() }.joinToString(" · ")
}.orEmpty()
return ClusterTurn(step.maneuver?.icon, turn.distance.display(), what, trip)
return ClusterTurn(step.maneuver?.icon, turn.distance.display(), what, trip, step.lanes.orEmpty())
}
}
}
@@ -0,0 +1,182 @@
package app.seb3thehacker.gearslip.car
import android.graphics.Color as AndroidColor
import androidx.car.app.model.Place
import androidx.car.app.model.PlaceListMapTemplate
import androidx.car.app.model.Row
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.luminance
import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.unit.dp
import androidx.compose.ui.viewinterop.AndroidView
import app.seb3thehacker.gearslip.GearslipLog
import org.maplibre.android.MapLibre
import org.maplibre.android.camera.CameraUpdateFactory
import org.maplibre.android.geometry.LatLng
import org.maplibre.android.geometry.LatLngBounds
import org.maplibre.android.maps.MapLibreMap
import org.maplibre.android.maps.MapLibreMapOptions
import org.maplibre.android.maps.MapView
import org.maplibre.android.maps.Style
import org.maplibre.android.style.expressions.Expression
import org.maplibre.android.style.layers.CircleLayer
import org.maplibre.android.style.layers.PropertyFactory
import org.maplibre.android.style.layers.SymbolLayer
import org.maplibre.android.style.sources.GeoJsonSource
import org.maplibre.geojson.Feature
import org.maplibre.geojson.FeatureCollection
import org.maplibre.geojson.Point
/**
* The map behind a [PlaceListMapTemplate]. For this template the host draws the map, not the
* app: Android Auto puts Google's map there, with a numbered pin for each place in the list and
* the anchor marked. RF Sentinel lists its nearby places this way, and with nothing drawn the
* area stayed empty.
*
* Tiles come from OpenFreeMap: free, no key, and no account, so nothing ties a drive to anyone.
* The camera fits every pin into the part of the map the list card doesn't cover.
*/
@Composable
internal fun HostPlaceMap(template: PlaceListMapTemplate, modifier: Modifier = Modifier) {
val dark = MaterialTheme.colorScheme.background.luminance() < 0.5f
val pin = MaterialTheme.colorScheme.primary.toArgb()
val pinText = MaterialTheme.colorScheme.onPrimary.toArgb()
val anchorColor = MaterialTheme.colorScheme.tertiary.toArgb()
val cardsAtEnd = LocalCardsAtEnd.current
val places = remember(template) { placesOf(template) }
var map by remember { mutableStateOf<MapLibreMap?>(null) }
var style by remember { mutableStateOf<Style?>(null) }
BoxWithConstraints(modifier) {
val density = LocalDensity.current
// Keep the pins out from under the list card, the way the app's own map would.
val card = with(density) { cardColumnWidth(maxWidth).roundToPx() }
val margin = with(density) { 48.dp.roundToPx() }
AndroidView(
modifier = Modifier.fillMaxSize(),
factory = { context ->
MapLibre.getInstance(context)
// A TextureView, so the map composites with the cards drawn over it inside the
// car's virtual display, as any other view does.
val options = MapLibreMapOptions.createFromAttributes(context).textureMode(true)
MapView(context, options).apply {
onCreate(null)
onStart()
onResume()
getMapAsync { m ->
m.uiSettings.isLogoEnabled = false
m.uiSettings.isCompassEnabled = false
m.uiSettings.isRotateGesturesEnabled = false
m.uiSettings.isTiltGesturesEnabled = false
map = m
}
}
},
onRelease = { view ->
runCatching {
view.onPause()
view.onStop()
view.onDestroy()
}
},
)
// The style follows the car's theme; the pins go in once it's loaded.
LaunchedEffect(map, dark) {
val m = map ?: return@LaunchedEffect
style = null
m.setStyle(Style.Builder().fromUri(if (dark) DARK_STYLE else LIGHT_STYLE)) { loaded -> style = loaded }
}
LaunchedEffect(style, places, pin, card, cardsAtEnd) {
val m = map ?: return@LaunchedEffect
val s = style ?: return@LaunchedEffect
showPins(s, places, pin, pinText, anchorColor)
val points = places.map { it.latLng }
when {
points.isEmpty() -> Unit
points.size == 1 -> m.moveCamera(CameraUpdateFactory.newLatLngZoom(points.first(), SINGLE_PLACE_ZOOM))
else -> {
val bounds = LatLngBounds.Builder().includes(points).build()
val left = if (cardsAtEnd) margin else card + margin
val right = if (cardsAtEnd) card + margin else margin
runCatching { m.moveCamera(CameraUpdateFactory.newLatLngBounds(bounds, left, margin, right, margin)) }
.onFailure { GearslipLog.w("host map: couldn't fit ${points.size} places: ${it.message}") }
}
}
}
DisposableEffect(Unit) { onDispose { map = null } }
}
}
/** One pin: where, and the label on it (the list's number, or the app's own marker text). */
private class Pin(val latLng: LatLng, val label: String, val anchor: Boolean)
private fun placesOf(template: PlaceListMapTemplate): List<Pin> {
val rows = template.itemList?.items.orEmpty().filterIsInstance<Row>()
val pins = rows.mapIndexedNotNull { i, row ->
val place = row.metadata?.place ?: return@mapIndexedNotNull null
pinOf(place, (i + 1).toString(), anchor = false)
}
return pins + listOfNotNull(template.anchor?.let { pinOf(it, "", anchor = true) })
}
private fun pinOf(place: Place, fallback: String, anchor: Boolean): Pin? {
val location = place.location
if (location.latitude == 0.0 && location.longitude == 0.0) return null
val label = place.marker?.label?.toString()?.takeIf { it.isNotBlank() } ?: fallback
return Pin(LatLng(location.latitude, location.longitude), label, anchor)
}
private fun showPins(style: Style, pins: List<Pin>, pin: Int, pinText: Int, anchorColor: Int) {
val features = FeatureCollection.fromFeatures(
pins.map { p ->
Feature.fromGeometry(Point.fromLngLat(p.latLng.longitude, p.latLng.latitude)).apply {
addStringProperty("label", p.label)
addBooleanProperty("anchor", p.anchor)
}
},
)
val source = style.getSourceAs<GeoJsonSource>(SOURCE)
if (source != null) {
source.setGeoJson(features)
return
}
style.addSource(GeoJsonSource(SOURCE, features))
style.addLayer(
CircleLayer(PIN_LAYER, SOURCE).withProperties(
PropertyFactory.circleRadius(Expression.switchCase(Expression.get("anchor"), Expression.literal(9f), Expression.literal(14f))),
PropertyFactory.circleColor(Expression.switchCase(Expression.get("anchor"), Expression.color(anchorColor), Expression.color(pin))),
PropertyFactory.circleStrokeColor(AndroidColor.WHITE),
PropertyFactory.circleStrokeWidth(2f),
),
)
style.addLayer(
SymbolLayer(LABEL_LAYER, SOURCE).withProperties(
PropertyFactory.textField(Expression.get("label")),
PropertyFactory.textFont(arrayOf("Noto Sans Bold")),
PropertyFactory.textSize(13f),
PropertyFactory.textColor(pinText),
PropertyFactory.textAllowOverlap(true),
PropertyFactory.textIgnorePlacement(true),
),
)
}
private const val LIGHT_STYLE = "https://tiles.openfreemap.org/styles/positron"
private const val DARK_STYLE = "https://tiles.openfreemap.org/styles/dark"
private const val SOURCE = "gearslip-places"
private const val PIN_LAYER = "gearslip-place-pins"
private const val LABEL_LAYER = "gearslip-place-labels"
private const val SINGLE_PLACE_ZOOM = 14.0
@@ -0,0 +1,258 @@
package app.seb3thehacker.gearslip.car
import androidx.car.app.navigation.model.Lane
import androidx.car.app.navigation.model.LaneDirection
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Path
import androidx.compose.ui.graphics.PathEffect
import androidx.compose.ui.graphics.StrokeCap
import androidx.compose.ui.graphics.StrokeJoin
import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.compose.ui.graphics.lerp
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import app.seb3thehacker.gearslip.R
import kotlin.math.PI
import kotlin.math.abs
import kotlin.math.cos
import kotlin.math.sin
/** The dark band the lanes sit on under the turn card's header, tinted toward the theme. */
@Composable
internal fun laneBandColor(): Color = lerp(Color(0xFF0E1417), MaterialTheme.colorScheme.primary, 0.14f)
/**
* The lanes before a manoeuvre as a dark band under the turn card's header: the road ahead in
* perspective, with the lane or lanes to be in filled with the header's colour.
*/
@Composable
internal fun LaneBand(lanes: List<Lane>, modifier: Modifier = Modifier) {
val scheme = MaterialTheme.colorScheme
val band = laneBandColor()
LaneRoad(
lanes,
modifier.background(band).padding(horizontal = 16.dp, vertical = 10.dp).fillMaxWidth().height(52.dp),
scheme.primaryContainer,
scheme.onPrimaryContainer,
band,
)
}
/** Which lanes to use, in words, on the same band: "Use the left lane". Nothing when it can't say. */
@Composable
internal fun LaneHintLine(lanes: List<Lane>, modifier: Modifier = Modifier) {
val hint = laneHint(lanes) ?: return
Text(
hint,
modifier.background(laneBandColor()).padding(start = 16.dp, end = 16.dp, bottom = 10.dp),
style = ChromeType.label,
color = Color.White,
textAlign = TextAlign.Center,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
/**
* The road ahead, narrowing toward the top: solid edges, dashed lines between lanes, the
* recommended lanes filled with [fill] and every lane's arrows drawn from its directions, the
* recommended ones in [onFill] and the rest faded toward [ground], the colour behind the road.
* The arrows are painted on the road, so they narrow with it. Lanes run left to right, as the app
* sends them.
*/
@Composable
internal fun LaneRoad(lanes: List<Lane>, modifier: Modifier, fill: Color, onFill: Color, ground: Color, line: Color = Color.White) {
val named = lanes.map {
stringResource(if (it.recommended()) R.string.nav_lane_recommended else R.string.nav_lane)
}.joinToString(", ")
Canvas(modifier.semantics { contentDescription = named }) {
val n = lanes.size
if (n == 0) return@Canvas
val h = size.height
// Each lane at most a little wider than the road is tall, so two lanes don't fill a whole card.
val bottomWidth = minOf(size.width, h * 1.6f * n)
val topWidth = bottomWidth * 0.86f
fun width(y: Float) = topWidth + (bottomWidth - topWidth) * (y / h)
// The x of lane edge [edge] (0 to n, fractions for the middle of a lane) at height [y].
fun x(edge: Float, y: Float): Float = size.width / 2 - width(y) / 2 + width(y) * edge / n
lanes.forEachIndexed { i, lane ->
if (!lane.recommended()) return@forEachIndexed
drawPath(
Path().apply {
moveTo(x(i.toFloat(), 0f), 0f)
lineTo(x(i + 1f, 0f), 0f)
lineTo(x(i + 1f, h), h)
lineTo(x(i.toFloat(), h), h)
close()
},
fill,
)
}
val edge = 2.dp.toPx()
drawLine(line, Offset(x(0f, 0f), 0f), Offset(x(0f, h), h), edge)
drawLine(line, Offset(x(n.toFloat(), 0f), 0f), Offset(x(n.toFloat(), h), h), edge)
val dashes = PathEffect.dashPathEffect(floatArrayOf(h * 0.22f, h * 0.14f))
for (i in 1 until n) {
drawLine(lerp(ground, line, 0.7f), Offset(x(i.toFloat(), 0f), 0f), Offset(x(i.toFloat(), h), h), edge, pathEffect = dashes)
}
val top = h * 0.12f
val bottom = h * 0.9f
// Half the room across a lane the arrows may use, as at the bottom: well inside the lines
// even where the lane narrows at the top.
val room = bottomWidth / n * 0.31f
lanes.forEachIndexed { i, lane ->
val directions = lane.directions.orEmpty().ifEmpty { listOf(null) }
// Faded first, so the one to take draws on top where they share a stem.
val arrows = directions.sortedBy { it?.isRecommended == true }
.map { it to laneArrow(it?.shape ?: LaneDirection.SHAPE_STRAIGHT, top, bottom) }
// The lane's arrows as one shape: centred in the lane, and shrunk toward the stem's
// foot when they'd reach past [room].
val xs = arrows.flatMap { (_, a) -> a.line.map { it.x } + a.head.map { it.x } }
val shift = -(xs.min() + xs.max()) / 2
val scale = minOf(1f, room / ((xs.max() - xs.min()) / 2))
val lift = (bottom - top) * (1 - scale) / 2
// Flat to the road: centred, scaled, then narrowed with the lane toward the top.
val onRoad = { p: Offset ->
val y = bottom - (bottom - p.y) * scale - lift
Offset(x(i + 0.5f, y) + (p.x + shift) * scale * width(y) / bottomWidth, y)
}
val stroke = (bottom - top) * 0.085f * scale * width((top + bottom) / 2) / bottomWidth
val behind = if (lane.recommended()) fill else ground
val on = if (lane.recommended()) onFill else line
arrows.forEach { (d, arrow) ->
// Solid colours, faded by mixing rather than transparency, so where a stem runs
// into its head nothing shows through twice.
val color = if (d?.isRecommended == true) on else lerp(behind, on, 0.38f)
val points = arrow.line.map(onRoad)
drawPath(
Path().apply {
moveTo(points[0].x, points[0].y)
points.drop(1).forEach { lineTo(it.x, it.y) }
},
color,
style = Stroke(stroke, cap = StrokeCap.Round, join = StrokeJoin.Round),
)
val corners = arrow.head.map(onRoad)
drawPath(
Path().apply {
moveTo(corners[0].x, corners[0].y)
lineTo(corners[1].x, corners[1].y)
lineTo(corners[2].x, corners[2].y)
close()
},
color,
)
}
}
}
}
/** An arrow laid out flat: the stem as points, and the head's tip and two back corners. */
private class LaneArrow(val line: List<Offset>, val head: List<Offset>)
/**
* One direction's arrow, flat, its stem rising from x 0 at [bottom]: on straight to [top], bending
* off to a side from a little above the middle, or looping back for a U-turn. A lane's arrows
* share the stem, so a straight-and-right lane reads as one fork.
*/
private fun laneArrow(shape: Int, top: Float, bottom: Float): LaneArrow {
val tall = bottom - top
val head = tall * 0.17f
val start = Offset(0f, bottom)
val degrees = when (shape) {
LaneDirection.SHAPE_SLIGHT_LEFT -> -45f
LaneDirection.SHAPE_SLIGHT_RIGHT -> 45f
LaneDirection.SHAPE_NORMAL_LEFT -> -90f
LaneDirection.SHAPE_NORMAL_RIGHT -> 90f
LaneDirection.SHAPE_SHARP_LEFT -> -135f
LaneDirection.SHAPE_SHARP_RIGHT -> 135f
LaneDirection.SHAPE_U_TURN_LEFT -> -180f
LaneDirection.SHAPE_U_TURN_RIGHT -> 180f
else -> 0f
}
val side = if (degrees < 0) -1f else 1f
val line = mutableListOf(start)
val end: Offset
val dir: Offset
when {
degrees == 0f -> {
end = Offset(0f, top + head)
line += end
dir = Offset(0f, -1f)
}
abs(degrees) == 180f -> {
// Up, over the top and back down beside the stem.
val r = tall * 0.2f
line += Offset(0f, top + r)
line += curve(Offset(0f, top + r), Offset(0f, top), Offset(side * r, top))
line += curve(Offset(side * r, top), Offset(side * 2 * r, top), Offset(side * 2 * r, top + r))
end = Offset(side * 2 * r, top + tall * 0.5f)
line += end
dir = Offset(0f, 1f)
}
else -> {
val rad = degrees / 180f * PI.toFloat()
val d = Offset(sin(rad), -cos(rad))
val length = tall * 0.32f
val fork = Offset(0f, top + tall * 0.6f)
val bend = Offset(0f, fork.y - length * 0.45f)
end = bend + d * length
line += fork
line += curve(fork, bend, end)
dir = d
}
}
val tip = end + dir * head
val base = tip - dir * (head * 1.5f)
val across = Offset(-dir.y, dir.x) * (head * 0.9f)
return LaneArrow(line, listOf(tip, base + across, base - across))
}
/** Points along a quadratic curve from [a] to [c] bending toward [b], [a] itself left out. */
private fun curve(a: Offset, b: Offset, c: Offset): List<Offset> = (1..10).map { k ->
val t = k / 10f
a * ((1 - t) * (1 - t)) + b * (2 * (1 - t) * t) + c * (t * t)
}
/**
* Which lanes to be in, in words, when the recommended ones sit together: the left or right
* lanes, the middle one, a lane counted from the nearer side, or any. Null when nothing is
* recommended or the lanes to use aren't next to each other.
*/
@Composable
internal fun laneHint(lanes: List<Lane>): String? {
val n = lanes.size
val use = lanes.indices.filter { lanes[it].recommended() }
if (n < 2 || use.isEmpty()) return null
if (use.size == n) return stringResource(R.string.nav_lane_any)
if (use.last() - use.first() + 1 != use.size) return null
val resources = LocalContext.current.resources
val k = use.size
val only = use.first()
return when {
only == 0 -> resources.getQuantityString(R.plurals.nav_lane_left, k, k)
use.last() == n - 1 -> resources.getQuantityString(R.plurals.nav_lane_right, k, k)
k == 1 && n % 2 == 1 && only == n / 2 -> stringResource(R.string.nav_lane_middle)
k == 1 && only < n - 1 - only -> stringResource(R.string.nav_lane_from_left, only + 1)
k == 1 -> stringResource(R.string.nav_lane_from_right, n - only)
else -> stringResource(R.string.nav_lane_middle_lanes)
}
}
private fun Lane.recommended(): Boolean = directions.orEmpty().any { it.isRecommended }
@@ -0,0 +1,111 @@
package app.seb3thehacker.gearslip.car
import android.os.Handler
import android.os.Looper
import android.view.KeyEvent
import app.seb3thehacker.gearslip.host.CarAppConnection
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlin.math.min
import kotlin.math.pow
/**
* The knob on a full-screen map, as in Android Auto: turning zooms, nudging pans, and pressing
* brings up the map's buttons for the knob to move between. Back puts them away and gives the
* knob back to the map. Issue #74: on a Mazda 3, every turn stepped through each map button.
*
* [width] and [height] are the app's surface in pixels; zooms centre on its middle.
* [clearFocus] drops focus off whatever button the knob was on.
*/
internal class MapKnob(
private val connection: CarAppConnection,
private val width: Int,
private val height: Int,
private val clearFocus: () -> Unit,
) : CarKeys.KnobTaker {
private val main = Handler(Looper.getMainLooper())
/** The press that opened the buttons lifts afterwards; that lift belongs to no button. */
@Volatile private var swallowLift = false
override fun take(event: CarKeys.KnobEvent): Boolean {
// A card over the map with buttons of its own (an app's alert, a call, a message, the
// assistant, Bluetooth pairing) needs the knob more than the map does.
if (overlayUp()) return false
if (_buttonsOpen.value) {
if (event is CarKeys.KnobEvent.Key && !event.down && swallowLift && event.keyCode in PRESS_KEYS) {
swallowLift = false
return true
}
if (event == CarKeys.KnobEvent.Back) {
main.post { close() }
return true
}
return false
}
when (event) {
is CarKeys.KnobEvent.Turn -> main.post { zoom(event.steps) }
is CarKeys.KnobEvent.Key -> when (event.keyCode) {
in PRESS_KEYS -> if (event.down) main.post { open() }
else -> if (event.down) main.post { pan(event.keyCode) }
}
CarKeys.KnobEvent.Back -> return false
}
return true
}
private fun overlayUp(): Boolean =
CarAlerts.shown.value != null ||
app.seb3thehacker.gearslip.call.CarCalls.state.value is app.seb3thehacker.gearslip.call.CallUiState.Ringing ||
app.seb3thehacker.gearslip.notify.CarNotifications.popup.value != null ||
CarAssistant.state.value.phase != CarAssistant.Phase.IDLE ||
CarBluetooth.prompt.value != CarBluetooth.Prompt.NONE
private fun zoom(steps: Int) {
val clicks = steps.coerceIn(-MAX_STEPS, MAX_STEPS)
connection.surfaceScale(width / 2f, height / 2f, ZOOM_STEP.pow(clicks))
}
/** One nudge moves the map a sixth of the screen. Scroll distances run previous minus current. */
private fun pan(keyCode: Int) {
val step = min(width, height) / 6f
when (keyCode) {
KeyEvent.KEYCODE_DPAD_UP -> connection.surfaceScroll(0f, -step)
KeyEvent.KEYCODE_DPAD_DOWN -> connection.surfaceScroll(0f, step)
KeyEvent.KEYCODE_DPAD_LEFT -> connection.surfaceScroll(-step, 0f)
KeyEvent.KEYCODE_DPAD_RIGHT -> connection.surfaceScroll(step, 0f)
}
}
private fun open() {
swallowLift = true
_buttonsOpen.value = true
// The map's buttons take focus when they see this. A map showing none gets a Tab, so the
// knob still lands somewhere.
if (controls == 0) CarKeys.focusSink?.let { it(KeyEvent.KEYCODE_TAB, true); it(KeyEvent.KEYCODE_TAB, false) }
}
private fun close() {
_buttonsOpen.value = false
clearFocus()
}
/** Puts the buttons away when the map leaves the screen. */
fun release() {
_buttonsOpen.value = false
}
companion object {
private val _buttonsOpen = MutableStateFlow(false)
/** The knob pressed on the map: its buttons show and take focus, until Back. */
val buttonsOpen: StateFlow<Boolean> = _buttonsOpen
/** How many stacks of map buttons are on screen, for [open] to know whether any will take focus. */
@Volatile var controls = 0
private const val ZOOM_STEP = 1.25f
private const val MAX_STEPS = 5
private val PRESS_KEYS = setOf(KeyEvent.KEYCODE_DPAD_CENTER, KeyEvent.KEYCODE_ENTER)
}
}
@@ -120,7 +120,9 @@ internal fun BrowsePanel(browse: app.seb3thehacker.gearslip.media.BrowseState, m
*/
@Composable
private fun BrowseList(entries: List<MediaEntry>, media: CarMedia) {
val sections = remember(entries) { browseSections(entries) }
// A list's keys must be unique, and some apps list the same item twice (Music Assistant's
// playlists, issue #90): the repeat would do the same thing anyway, so it's dropped.
val sections = remember(entries) { browseSections(entries.distinctBy { it.id }) }
BoxWithConstraints(Modifier.fillMaxSize()) {
val onlyGrid = sections.size == 1 && sections[0].grid && sections[0].heading == null
val columns = ((maxWidth + SHELF_GAP) / (SHELF_COVER + SHELF_GAP)).toInt().coerceAtLeast(2)
@@ -243,7 +245,7 @@ internal fun QueuePanel(queue: List<QueueTrack>, now: NowPlaying, media: CarMedi
contentPadding = PaddingValues(bottom = 8.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
items(upNext, key = { it.queueId }) { track -> QueueRow(track, media) }
items(upNext.distinctBy { it.queueId }, key = { it.queueId }) { track -> QueueRow(track, media) }
}
}
}
@@ -117,7 +117,7 @@ fun MediaScreen(app: MediaApp, onExit: () -> Unit) {
CarMedia.Rejection.NEEDS_NOTIFICATION_ACCESS -> Notice(
stringResource(R.string.media_keeps_library, app.label),
stringResource(R.string.media_needs_access),
action = stringResource(R.string.media_open_on_phone) to {
action = stringResource(R.string.allow_on_phone) to {
val app = context.applicationContext
runCatching {
app.startActivity(
@@ -259,15 +259,21 @@ fun MediaScreen(app: MediaApp, onExit: () -> Unit) {
@Composable
private fun AppIdentity(app: MediaApp, onExit: () -> Unit, modifier: Modifier = Modifier) {
val icon = remember(app) { findEntry(app.component.flattenToString())?.icon }
val navigator = LocalCarNavigator.current
Row(modifier, verticalAlignment = Alignment.CenterVertically) {
GsIconButton(Icons.Filled.ArrowBack, stringResource(R.string.back), onExit, size = 44.dp)
Spacer(Modifier.width(14.dp))
// Shown on the same terms as every other screen's header: only when it leads somewhere
// the nav bar doesn't.
if (navigator.backLeadsSomewhere) {
GsIconButton(Icons.Filled.ArrowBack, stringResource(R.string.back), onExit)
Spacer(Modifier.width(12.dp))
}
icon?.let {
Image(it, null, Modifier.size(26.dp).clip(RoundedCornerShape(7.dp)))
Spacer(Modifier.width(10.dp))
}
Text(
app.label.lowercase().replaceFirstChar { it.titlecase() },
// As the app writes it: recasing turned NewPipe into "Newpipe" and TIDAL into "Tidal".
app.label,
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1,
@@ -715,7 +721,7 @@ private fun StageControls(now: NowPlaying, media: CarMedia, modifier: Modifier)
)
}
}
PlayerProgress(now, media, Modifier.fillMaxWidth().padding(top = if (compact) 4.dp else 12.dp), showTimes = showTimes)
PlayerProgress(now, media, Modifier.fillMaxWidth().padding(top = if (compact) 4.dp else 12.dp), showTimes = showTimes, timeStyle = MaterialTheme.typography.titleLarge)
// What's left goes to the buttons: play takes about half of it, the row below a third,
// and neither grows past what a row of them fits across.
BoxWithConstraints(Modifier.weight(1f).fillMaxWidth(), contentAlignment = Alignment.Center) {
@@ -764,7 +770,7 @@ private fun NowPlayingControls(now: NowPlaying, media: CarMedia, modifier: Modif
)
}
}
PlayerProgress(now, media, Modifier.fillMaxWidth(), showTimes = showTimes)
PlayerProgress(now, media, Modifier.fillMaxWidth(), showTimes = showTimes, timeStyle = MaterialTheme.typography.titleLarge)
TransportRow(now, media, skip = if (compact) 48.dp else 60.dp, modifier = Modifier.fillMaxWidth())
PlayModeRow(now, media, size = if (compact) 46.dp else 56.dp, modifier = Modifier.fillMaxWidth(), maxOther = 2)
}
@@ -804,7 +810,7 @@ private fun CompactPlayer(now: NowPlaying, media: CarMedia, art: android.graphic
}
}
Spacer(Modifier.height(10.dp))
PlayerProgress(now, media, Modifier.fillMaxWidth())
PlayerProgress(now, media, Modifier.fillMaxWidth(), timeStyle = MaterialTheme.typography.titleLarge)
Spacer(Modifier.height(6.dp))
TransportRow(now, media, skip = 54.dp, modifier = Modifier.fillMaxWidth())
Spacer(Modifier.height(10.dp))
@@ -72,7 +72,7 @@ fun MessagesScreen(packageName: String) {
Column(Modifier.fillMaxSize().padding(start = 16.dp, end = 16.dp, top = 16.dp), verticalArrangement = Arrangement.spacedBy(16.dp)) {
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp)) {
GsIconButton(Icons.AutoMirrored.Filled.ArrowBack, stringResource(R.string.back), navigator::back)
if (navigator.backLeadsSomewhere) GsIconButton(Icons.AutoMirrored.Filled.ArrowBack, stringResource(R.string.back), navigator::back)
entry?.icon?.let { Image(it, contentDescription = null, modifier = Modifier.size(36.dp).clip(RoundedCornerShape(8.dp))) }
Text(label, style = MaterialTheme.typography.headlineSmall, fontWeight = FontWeight.Bold)
}
@@ -97,7 +97,8 @@ fun NotificationsSection() {
}
}
if (history.isEmpty()) {
// With access off, "nothing yet" would promise notifications that can't come.
if (history.isEmpty() && listening) {
Text(
stringResource(R.string.notif_empty),
style = MaterialTheme.typography.bodyMedium,
@@ -113,10 +114,11 @@ fun NotificationsSection() {
@Composable
internal fun AccessBanner(onOpen: () -> Unit) {
// A setting left off, not a fault: a quiet card, as Android Auto shows one, rather than red.
Surface(
shape = MaterialTheme.shapes.large,
color = MaterialTheme.colorScheme.errorContainer,
contentColor = MaterialTheme.colorScheme.onErrorContainer,
color = MaterialTheme.colorScheme.surfaceContainerHigh,
contentColor = MaterialTheme.colorScheme.onSurface,
modifier = Modifier.fillMaxWidth().padding(vertical = 8.dp),
) {
Row(
@@ -124,13 +126,13 @@ internal fun AccessBanner(onOpen: () -> Unit) {
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp),
) {
Icon(Icons.Filled.Warning, contentDescription = null)
Icon(Icons.Filled.Notifications, contentDescription = null, tint = MaterialTheme.colorScheme.primary)
Text(
stringResource(R.string.notif_off),
style = MaterialTheme.typography.bodyMedium,
modifier = Modifier.weight(1f),
)
GsButton(onClick = onOpen) { Text(stringResource(R.string.guide_open)) }
GsButton(onClick = onOpen, tone = GsTone.Tonal) { Text(stringResource(R.string.allow_on_phone)) }
}
}
}
@@ -129,21 +129,61 @@ internal fun CallPromptOverlay() {
)
}
private const val TAB_CONTACTS = 0
private const val TAB_KEYPAD = 1
private const val TAB_RECENTS = 0
private const val TAB_CONTACTS = 1
private const val TAB_KEYPAD = 2
/** Dialing a number or calling a contact from the car screen: a contacts tab and a keypad tab. */
/** One number from the call log: its latest call, whether that was missed, and when. */
private data class RecentCall(val contact: Contact, val missed: Boolean, val at: Long)
/** The latest call to or from each number, newest first, as Android Auto lists them. */
private fun loadRecents(context: Context): List<RecentCall>? {
if (context.checkSelfPermission(Manifest.permission.READ_CALL_LOG) != PackageManager.PERMISSION_GRANTED) return null
val out = mutableListOf<RecentCall>()
val seen = mutableSetOf<String>()
runCatching {
context.contentResolver.query(
android.provider.CallLog.Calls.CONTENT_URI,
arrayOf(
android.provider.CallLog.Calls.NUMBER,
android.provider.CallLog.Calls.CACHED_NAME,
android.provider.CallLog.Calls.TYPE,
android.provider.CallLog.Calls.DATE,
),
null, null,
"${android.provider.CallLog.Calls.DATE} DESC",
)?.use { cursor ->
while (cursor.moveToNext() && out.size < RECENTS_SHOWN) {
val number = cursor.getString(0)?.takeIf { it.isNotBlank() } ?: continue
if (!seen.add(number)) continue
val name = cursor.getString(1)?.takeIf { it.isNotBlank() } ?: number
val type = cursor.getInt(2)
val missed = type == android.provider.CallLog.Calls.MISSED_TYPE || type == android.provider.CallLog.Calls.REJECTED_TYPE
out += RecentCall(Contact(name, number), missed, cursor.getLong(3))
}
}
}.onFailure { GearslipLog.w("calls: reading the call log failed: ${it.message}") }
return out
}
private const val RECENTS_SHOWN = 50
/**
* Calling from the car screen, laid out as Android Auto's dialer: recent calls first, since
* calling back is the usual reason to open it, then contacts and a keypad.
*/
@Composable
fun PhoneDialerScreen() {
val context = LocalContext.current
var hasPermissions by remember { mutableStateOf(hasCallPermissions(context)) }
var tab by remember { mutableStateOf(TAB_CONTACTS) }
var tab by remember { mutableStateOf(TAB_RECENTS) }
Column(Modifier.fillMaxSize().padding(start = 16.dp, end = 16.dp, top = 16.dp), verticalArrangement = Arrangement.spacedBy(16.dp)) {
// The tabs share the title's row, so the list and keypad get the height back.
ScreenHeader(stringResource(R.string.carset_phone)) {
if (hasPermissions) {
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
DialerTab(stringResource(R.string.dialer_recents), tab == TAB_RECENTS, Modifier.width(140.dp)) { tab = TAB_RECENTS }
DialerTab(stringResource(R.string.dialer_contacts), tab == TAB_CONTACTS, Modifier.width(140.dp)) { tab = TAB_CONTACTS }
DialerTab(stringResource(R.string.dialer_keypad), tab == TAB_KEYPAD, Modifier.width(140.dp)) { tab = TAB_KEYPAD }
}
@@ -156,7 +196,11 @@ fun PhoneDialerScreen() {
}
Box(Modifier.weight(1f).fillMaxWidth()) {
if (tab == TAB_CONTACTS) ContactsList(context) else Box(Modifier.padding(bottom = 16.dp)) { Keypad(context) }
when (tab) {
TAB_RECENTS -> RecentsList(context)
TAB_CONTACTS -> ContactsList(context)
else -> Box(Modifier.padding(bottom = 16.dp)) { Keypad(context) }
}
}
}
}
@@ -260,6 +304,53 @@ private fun KeypadButton(digit: String, modifier: Modifier, onClick: () -> Unit)
}
}
@Composable
private fun RecentsList(context: Context) {
// Null until read, then null again only when the call log isn't allowed.
var loaded by remember { mutableStateOf(false) }
val recents by produceState<List<RecentCall>?>(null, context) {
value = withContext(Dispatchers.IO) { loadRecents(context) }
loaded = true
}
var pendingCall by remember { mutableStateOf<Contact?>(null) }
val list = recents
when {
!loaded -> Unit
list == null -> Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
Text(
stringResource(R.string.dialer_recents_needs_permission),
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
GsButton(tone = GsTone.Tonal, onClick = {
app.seb3thehacker.gearslip.PermissionActivity.ask(context, arrayOf(Manifest.permission.READ_CALL_LOG))
}) { Text(stringResource(R.string.dialer_ask)) }
}
list.isEmpty() -> Text(
stringResource(R.string.dialer_no_recents),
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
else -> LazyColumn(Modifier.fillMaxSize(), contentPadding = PaddingValues(bottom = 16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
items(list) { call ->
val now = System.currentTimeMillis()
val ago = android.text.format.DateUtils.getRelativeTimeSpanString(call.at, now, android.text.format.DateUtils.MINUTE_IN_MILLIS).toString()
val detail = if (call.missed) stringResource(R.string.dialer_missed, ago) else ago
ContactRow(call.contact, detail = detail, alert = call.missed) { pendingCall = call.contact }
}
}
}
pendingCall?.let { contact ->
ConfirmCallOverlay(
contact = contact,
onConfirm = { placeCall(context, contact.number); pendingCall = null },
onDismiss = { pendingCall = null },
)
}
}
@Composable
private fun ContactsList(context: Context) {
val contacts by produceState(emptyList<Contact>(), context) {
@@ -381,7 +472,7 @@ private fun ContactSearchField(query: String, active: Boolean, onToggle: () -> U
}
@Composable
private fun ContactRow(contact: Contact, onClick: () -> Unit) {
private fun ContactRow(contact: Contact, detail: String = contact.number, alert: Boolean = false, onClick: () -> Unit) {
Surface(
onClick = onClick,
shape = MaterialTheme.shapes.large,
@@ -406,7 +497,11 @@ private fun ContactRow(contact: Contact, onClick: () -> Unit) {
}
Column(Modifier.weight(1f)) {
Text(contact.name, style = MaterialTheme.typography.titleMedium, maxLines = 1, overflow = TextOverflow.Ellipsis)
Text(contact.number, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant)
Text(
detail,
style = MaterialTheme.typography.bodyMedium,
color = if (alert) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.onSurfaceVariant,
)
}
Icon(Icons.Filled.Call, contentDescription = stringResource(R.string.dialer_call), tint = MaterialTheme.colorScheme.primary)
}
@@ -59,9 +59,18 @@ import kotlinx.coroutines.withContext
* the map - so a button looks and behaves the same wherever it turns up.
*/
/** The seek bar, with elapsed and total time under it when [showTimes]. */
/**
* The seek bar, with elapsed and total time under it when [showTimes]. The times default to a
* size readable at a glance from the driver's seat; the narrow side column passes a smaller one.
*/
@Composable
internal fun PlayerProgress(now: NowPlaying, media: CarMedia, modifier: Modifier = Modifier, showTimes: Boolean = true) {
internal fun PlayerProgress(
now: NowPlaying,
media: CarMedia,
modifier: Modifier = Modifier,
showTimes: Boolean = true,
timeStyle: androidx.compose.ui.text.TextStyle = MaterialTheme.typography.titleMedium,
) {
if (now.durationMs <= 0) return
var position by remember { mutableLongStateOf(now.currentPosition()) }
LaunchedEffect(now) {
@@ -80,8 +89,10 @@ internal fun PlayerProgress(now: NowPlaying, media: CarMedia, modifier: Modifier
)
if (showTimes) {
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {
Text(formatDuration(position), style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.onSurfaceVariant)
Text(formatDuration(now.durationMs), style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.onSurfaceVariant)
// Tabular figures, so the elapsed time doesn't jitter sideways as it counts.
val style = timeStyle.copy(fontFeatureSettings = "tnum")
Text(formatDuration(position), style = style, color = MaterialTheme.colorScheme.onSurfaceVariant)
Text(formatDuration(now.durationMs), style = style, color = MaterialTheme.colorScheme.onSurfaceVariant)
}
}
}
@@ -130,6 +141,7 @@ internal fun PlayModeRow(
size: Dp,
modifier: Modifier = Modifier,
maxOther: Int = 0,
arrangement: Arrangement.Horizontal = Arrangement.SpaceEvenly,
trailing: @Composable RowScope.() -> Unit = {},
) {
val modes by media.modes.collectAsState()
@@ -139,7 +151,7 @@ internal fun PlayModeRow(
val shuffleOn = shuffleKnown && modes.shuffle != PlaybackStateCompat.SHUFFLE_MODE_NONE
val repeatOn = repeatKnown && modes.repeat != PlaybackStateCompat.REPEAT_MODE_NONE
val icon = size * 0.52f
Row(modifier, horizontalArrangement = Arrangement.SpaceEvenly, verticalAlignment = Alignment.CenterVertically) {
Row(modifier, horizontalArrangement = arrangement, verticalAlignment = Alignment.CenterVertically) {
// An app's own like, shuffle or repeat button shows whether it's on only through its icon,
// so that icon is drawn, as Android Auto does, and lit when its name says it's on.
// Gearslip's own stand in for apps that report a heart rating or shuffle and repeat modes.
@@ -19,9 +19,9 @@ import androidx.compose.ui.unit.dp
import app.seb3thehacker.gearslip.car.theme.GsIconButton
/**
* The top of every screen reached from Home: a back button in the current theme, the title, and
* room on the right for the screen's own controls. A car touchscreen has no hardware back button,
* so this is the way out of a screen.
* The top of every screen reached from Home: the title, room on the right for the screen's own
* controls, and a back button when the screen under this one is more than Home or the app grid -
* those the nav bar already reaches, so a back button there only repeated it.
*/
@Composable
fun ScreenHeader(title: String, modifier: Modifier = Modifier, trailing: @Composable RowScope.() -> Unit = {}) {
@@ -31,7 +31,7 @@ fun ScreenHeader(title: String, modifier: Modifier = Modifier, trailing: @Compos
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp),
) {
GsIconButton(Icons.AutoMirrored.Filled.ArrowBack, stringResource(R.string.back), navigator::back)
if (navigator.backLeadsSomewhere) GsIconButton(Icons.AutoMirrored.Filled.ArrowBack, stringResource(R.string.back), navigator::back)
Text(
title,
style = MaterialTheme.typography.headlineSmall,
@@ -30,7 +30,6 @@ import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.KeyboardArrowUp
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
@@ -39,8 +38,11 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.collectAsState
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.sp
import androidx.compose.runtime.setValue
import androidx.compose.ui.layout.layoutId
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.rotate
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
@@ -49,8 +51,6 @@ import androidx.compose.ui.unit.dp
import androidx.car.app.model.CarIcon
import androidx.car.app.model.Template
import androidx.car.app.model.PlaceListMapTemplate
import androidx.car.app.navigation.model.Lane
import androidx.car.app.navigation.model.LaneDirection
import androidx.car.app.navigation.model.MapTemplate
import androidx.car.app.navigation.model.MapWithContentTemplate
import androidx.car.app.navigation.model.NavigationTemplate
@@ -203,8 +203,7 @@ private fun NavigationChrome(template: NavigationTemplate, modifier: Modifier) {
}
/**
* Cards down the left, map on the right: the turn on top, the trip along the bottom, the map
* buttons at the right edge. The whole column counts as covered, so the app centres its map in
* The drive's card down the left, map on the right, the map buttons at the right edge. The whole column counts as covered, so the app centres its map in
* what is left over and the car sits right of the cards rather than under them. Pan mode drops
* the cards and gives the map the whole width.
*/
@@ -224,13 +223,21 @@ private fun NavigationBars(
val column = cardColumnWidth(maxWidth)
val cards = @Composable {
if (routing != null || estimate != null) {
// The turn at the top and the trip in a card of its own at the bottom, with the
// map showing between them. The trip is measured first, so the turn card shrinks
// (dropping parts, see NavigationCard) rather than run into it on a short screen.
Column(
Modifier.width(column).fillMaxHeight().covers("nav-cards", cardEdge).padding(12.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
verticalArrangement = Arrangement.SpaceBetween,
) {
routing?.let { RoutingCard(it, Modifier.fillMaxWidth()) }
Spacer(Modifier.weight(1f))
estimate?.let { TravelEstimateCard(it, Modifier.fillMaxWidth()) }
if (routing != null) {
NavigationCard(routing, Modifier.fillMaxWidth().weight(1f, fill = false))
}
estimate?.let {
ChromeSurface(Modifier.fillMaxWidth().padding(top = if (routing != null) 12.dp else 0.dp)) {
TripRow(it, showStop = !template.actionStrip.hasStop(), Modifier.fillMaxWidth())
}
}
}
}
}
@@ -252,76 +259,143 @@ private fun NavigationBars(
}
/**
* The manoeuvre, big enough to read at a glance: the arrow and the distance to it, what to do
* and onto which road, the lanes to be in, and the turn after it along the bottom.
* The turn, top to bottom: the manoeuvre big enough to read at a glance, the lanes to be in, and
* the turn after it. On a short screen parts drop out whole, the lanes' words first, then the
* next turn, then the lanes; the manoeuvre always stays. The trip has its own card (TripRow).
*/
@Composable
private fun RoutingCard(info: RoutingInfo, modifier: Modifier = Modifier) {
if (info.isLoading) {
ChromeSurface(modifier) {
Text(stringResource(R.string.nav_finding_route), Modifier.padding(horizontal = 18.dp, vertical = 16.dp), style = ChromeType.title)
}
return
}
val step = info.currentStep ?: return
val scheme = MaterialTheme.colorScheme
private fun NavigationCard(info: RoutingInfo, modifier: Modifier) {
val step = info.currentStep?.takeUnless { info.isLoading }
ChromeSurface(modifier) {
Column {
Column(
Modifier.fillMaxWidth().background(scheme.primaryContainer).padding(16.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
androidx.compose.runtime.CompositionLocalProvider(
androidx.compose.material3.LocalContentColor provides scheme.onPrimaryContainer,
) {
Row(verticalAlignment = Alignment.CenterVertically) {
CarGlyph(step.maneuver?.icon, Modifier.size(64.dp))
Spacer(Modifier.width(14.dp))
val distance = info.currentDistance.display()
if (distance.isNotEmpty()) {
Text(
distance,
style = MaterialTheme.typography.displaySmall.copy(fontWeight = androidx.compose.ui.text.font.FontWeight.Bold),
maxLines = 1,
)
}
}
val cue = step.cue.text()
if (cue.isNotEmpty()) {
Text(cue, style = ChromeType.title, maxLines = 2, overflow = TextOverflow.Ellipsis)
}
val road = step.road.text()
// "Turn left onto Queensberry Drive" already says the road; saying it twice is noise.
if (road.isNotEmpty() && !cue.contains(road, ignoreCase = true)) {
Text(
road,
style = ChromeType.body,
color = scheme.onPrimaryContainer.copy(alpha = 0.8f),
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
if (step.lanesImage != null || step.lanes.orEmpty().isNotEmpty()) {
LaneGuidance(step, Modifier.fillMaxWidth().heightIn(max = 56.dp))
}
// Rare, and only where an arrow can't explain the junction: worth the card growing for.
info.junctionImage?.let { JunctionImage(it) }
FitColumn {
if (info.isLoading) {
Text(
stringResource(R.string.nav_finding_route),
Modifier.layoutId(0).padding(horizontal = 18.dp, vertical = 16.dp),
style = ChromeType.title,
)
}
if (step != null) {
ManeuverHeader(info, step, Modifier.layoutId(0).fillMaxWidth())
// Drawn from the lane data whenever there is some, even beside the app's own picture.
step.lanes.orEmpty().takeIf { it.isNotEmpty() }?.let {
LaneBand(it, Modifier.layoutId(2).fillMaxWidth())
LaneHintLine(it, Modifier.layoutId(4).fillMaxWidth())
}
info.nextStep?.let { NextTurn(it, Modifier.layoutId(3).fillMaxWidth()) }
}
}
}
}
/** The manoeuvre on the theme's colour: the arrow and the distance to it, what to do, onto which road. */
@Composable
private fun ManeuverHeader(info: RoutingInfo, step: Step, modifier: Modifier) {
val scheme = MaterialTheme.colorScheme
Column(
modifier.background(scheme.primaryContainer).padding(16.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
androidx.compose.runtime.CompositionLocalProvider(
androidx.compose.material3.LocalContentColor provides scheme.onPrimaryContainer,
) {
Row(verticalAlignment = Alignment.CenterVertically) {
CarGlyph(step.maneuver?.icon, Modifier.size(64.dp))
Spacer(Modifier.width(14.dp))
val distance = info.currentDistance.display()
if (distance.isNotEmpty()) {
Text(
distance,
style = MaterialTheme.typography.displaySmall.copy(fontWeight = FontWeight.Bold),
maxLines = 1,
)
}
}
info.nextStep?.let { next ->
Row(
Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 10.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Text(stringResource(R.string.nav_then), style = ChromeType.label, color = scheme.onSurfaceVariant)
Spacer(Modifier.width(10.dp))
CarGlyph(next.maneuver?.icon, Modifier.size(28.dp))
val nextText = next.cue.text().ifEmpty { next.road.text() }
if (nextText.isNotEmpty()) {
Spacer(Modifier.width(10.dp))
Text(nextText, style = ChromeType.body, maxLines = 1, overflow = TextOverflow.Ellipsis)
}
val cue = step.cue.text()
if (cue.isNotEmpty()) {
Text(cue, style = ChromeType.title, maxLines = 2, overflow = TextOverflow.Ellipsis)
}
val road = step.road.text()
// "Turn left onto Queensberry Drive" already says the road; saying it twice is noise.
if (road.isNotEmpty() && !cue.contains(road, ignoreCase = true)) {
Text(
road,
style = ChromeType.body,
color = scheme.onPrimaryContainer.copy(alpha = 0.8f),
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
// An app that sends only its own picture of the lanes gets it shown as it is.
if (step.lanes.orEmpty().isEmpty()) {
step.lanesImage?.let { JunctionImage(it, Modifier.heightIn(max = 56.dp)) }
}
// Rare, and only where an arrow can't explain the junction: worth the card growing for.
info.junctionImage?.let { JunctionImage(it) }
}
}
}
/**
* The turn after this one, as large as the instruction above it and wrapping rather than cut
* short: leaving a motorway, the next move comes quickly and has to read in a glance.
*/
@Composable
private fun NextTurn(next: Step, modifier: Modifier) {
val scheme = MaterialTheme.colorScheme
// A shade apart from the trip below, so the two never read as one block of text.
Row(
modifier.background(scheme.surfaceContainerHighest).padding(horizontal = 16.dp, vertical = 12.dp),
verticalAlignment = Alignment.CenterVertically,
) {
CarGlyph(next.maneuver?.icon, Modifier.size(40.dp))
Spacer(Modifier.width(14.dp))
Column {
Text(
stringResource(R.string.nav_then).uppercase(java.util.Locale.getDefault()),
style = ChromeType.label.copy(fontWeight = FontWeight.Bold, letterSpacing = 1.2.sp),
color = scheme.primary,
)
val text = next.cue.text().ifEmpty { next.road.text() }
if (text.isNotEmpty()) {
Text(
text,
style = ChromeType.body.copy(fontWeight = FontWeight.SemiBold),
maxLines = 2,
overflow = TextOverflow.Ellipsis,
)
}
}
}
}
/**
* A column that leaves whole parts out when they don't all fit, rather than cutting the last one
* in half. Each part's layoutId is its rank: the lowest is kept first and always shown, then each
* next one if there's still room for it. What's kept stays in its written order.
*/
@Composable
private fun FitColumn(modifier: Modifier = Modifier, content: @Composable () -> Unit) {
androidx.compose.ui.layout.Layout(content, modifier) { measurables, constraints ->
val loose = constraints.copy(minHeight = 0, maxHeight = androidx.compose.ui.unit.Constraints.Infinity)
val parts = measurables.map { it.measure(loose) }
val ranks = measurables.map { it.layoutId as? Int ?: 0 }
val kept = BooleanArray(parts.size)
var room = if (constraints.hasBoundedHeight) constraints.maxHeight else Int.MAX_VALUE
parts.indices.sortedBy { ranks[it] }.forEachIndexed { order, i ->
if (order == 0 || parts[i].height <= room) {
kept[i] = true
room -= parts[i].height
}
}
val width = (parts.maxOfOrNull { it.width } ?: 0).coerceIn(constraints.minWidth, constraints.maxWidth)
val height = parts.indices.filter { kept[it] }.sumOf { parts[it].height }
layout(width, height.coerceAtLeast(constraints.minHeight)) {
var y = 0
parts.forEachIndexed { i, part ->
if (kept[i]) {
part.place(0, y)
y += part.height
}
}
}
@@ -341,120 +415,105 @@ private fun JunctionImage(icon: CarIcon, modifier: Modifier = Modifier) {
}
/**
* Which lane to be in before the manoeuvre, either as the app's own picture ([Step.getLanesImage])
* or as a row of arrows built from its structured lane data - whichever it sent. The recommended
* lane, or lanes, stand out in the theme's own colour; the rest sit on the same muted, solid tile
* everything else uses, never dimmed by transparency.
* The trip along the bottom of the card: time left, coloured as the app colours it (by traffic,
* in Vela), then the arrival and the distance, each with what it is under it. Gearslip's stop
* button sits at the end unless the app shows its own ([hasStop]). It ends navigation the way
* going Home does: the connection stays up and the app decides its own next screen.
*/
@Composable
private fun LaneGuidance(step: Step, modifier: Modifier = Modifier) {
step.lanesImage?.let {
JunctionImage(it, modifier)
return
}
val lanes = step.lanes.orEmpty()
if (lanes.isEmpty()) return
Row(modifier, horizontalArrangement = Arrangement.spacedBy(6.dp)) {
lanes.forEach { lane -> LaneTile(lane) }
}
}
@Composable
private fun LaneTile(lane: Lane) {
val directions = lane.directions.orEmpty()
val shown = directions.firstOrNull { it.isRecommended } ?: directions.firstOrNull()
val recommended = shown?.isRecommended == true
Box(
Modifier
.size(36.dp)
.clip(RoundedCornerShape(8.dp))
.background(
if (recommended) MaterialTheme.colorScheme.primaryContainer
else MaterialTheme.colorScheme.surfaceContainerHighest,
),
contentAlignment = Alignment.Center,
) {
Icon(
Icons.Filled.KeyboardArrowUp,
contentDescription = stringResource(if (recommended) R.string.nav_lane_recommended else R.string.nav_lane),
tint = if (recommended) MaterialTheme.colorScheme.onPrimaryContainer
else MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.size(24.dp).rotate(shown.turnDegrees()),
)
}
}
/** The one arrow glyph this app draws, turned to stand in for every shape a lane can permit. */
private fun LaneDirection?.turnDegrees(): Float = when (this?.shape) {
LaneDirection.SHAPE_SLIGHT_RIGHT -> 30f
LaneDirection.SHAPE_NORMAL_RIGHT -> 90f
LaneDirection.SHAPE_SHARP_RIGHT -> 135f
LaneDirection.SHAPE_U_TURN_RIGHT -> 170f
LaneDirection.SHAPE_SLIGHT_LEFT -> -30f
LaneDirection.SHAPE_NORMAL_LEFT -> -90f
LaneDirection.SHAPE_SHARP_LEFT -> -135f
LaneDirection.SHAPE_U_TURN_LEFT -> -170f
else -> 0f
}
/**
* The trip at a glance, stacked: time left on top, then the clock time it ends at and the
* distance, with Gearslip's own stop button beside them. The close button is Gearslip's, not the
* app's: an actionStrip's actions carry no flag saying which one (if any) means "stop
* navigating" - the first button turned out to be a mute toggle, not an exit - so this ends
* navigation the way going Home already does rather than guess at the app's own controls.
*/
@Composable
private fun TravelEstimateCard(estimate: TravelEstimate, modifier: Modifier = Modifier) {
private fun TripRow(estimate: TravelEstimate, showStop: Boolean, modifier: Modifier) {
val connection = LocalCarAppConnection.current
val scheme = MaterialTheme.colorScheme
val time = remainingTime(estimate.remainingTimeSeconds)
val distance = estimate.remainingDistance.display()
val arrival = estimate.arrivalTimeAtDestination.clockTime()
val trip = estimate.tripText.text()
if (time.isEmpty() && distance.isEmpty() && arrival.isEmpty() && trip.isEmpty()) return
ChromeSurface(modifier) {
val dark = MaterialTheme.colorScheme.surface.luminanceIsDark()
Row(
Modifier.padding(start = 18.dp, end = 12.dp, top = 12.dp, bottom = 12.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Column(Modifier.weight(1f)) {
if (time.isNotEmpty()) {
Text(
time,
style = ChromeType.headline,
color = estimate.remainingTimeColor.color(dark, MaterialTheme.colorScheme.onSurface),
maxLines = 1,
)
}
val detail = listOfNotNull(
arrival.takeIf { it.isNotEmpty() }?.let { stringResource(R.string.nav_arrive, it) },
distance.takeIf { it.isNotEmpty() },
).joinToString(" • ")
if (detail.isNotEmpty()) {
Text(detail, style = ChromeType.body, color = MaterialTheme.colorScheme.onSurfaceVariant, maxLines = 1, overflow = TextOverflow.Ellipsis)
}
if (trip.isNotEmpty()) {
Text(trip, style = ChromeType.body, color = MaterialTheme.colorScheme.onSurfaceVariant, maxLines = 1, overflow = TextOverflow.Ellipsis)
}
val dark = scheme.surface.luminanceIsDark()
Column(modifier.padding(start = 18.dp, end = 12.dp, top = 12.dp, bottom = 12.dp)) {
Row(Modifier.height(androidx.compose.foundation.layout.IntrinsicSize.Min), verticalAlignment = Alignment.CenterVertically) {
// The time leads, large and coloured; arrival and distance are smaller, each in its
// own column behind a thin rule, with what it is under it.
TripFigure(time, null, estimate.remainingTimeColor.color(dark, scheme.onSurface), ChromeType.headline, Modifier.weight(1.1f))
TripRule()
TripFigure(arrival, stringResource(R.string.nav_arrival), scheme.onSurface, ChromeType.body, Modifier.weight(1f))
TripRule()
TripFigure(distance, stringResource(R.string.nav_to_go), scheme.onSurface, ChromeType.body, Modifier.weight(1f))
if (showStop) {
GsIconButton(
Icons.Filled.Close,
stringResource(R.string.nav_stop),
{ connection.stopNavigating() },
colors = GsColors(scheme.surfaceContainerHighest, scheme.onSurface),
size = 46.dp,
iconSize = 24.dp,
shape = CircleShape,
)
}
// Not a disconnect: the connection stays up and the app decides its own next screen.
// Ending navigation is its own call, separate from a back press - a back press only
// pops the on-screen card, leaving the app still believing it is navigating.
GsIconButton(
Icons.Filled.Close,
stringResource(R.string.nav_stop),
{ connection.stopNavigating() },
colors = GsColors(MaterialTheme.colorScheme.surfaceContainerHighest, MaterialTheme.colorScheme.onSurface),
size = 52.dp,
iconSize = 26.dp,
shape = CircleShape,
}
if (trip.isNotEmpty()) {
Text(
trip,
Modifier.padding(top = 6.dp),
style = ChromeType.body,
color = scheme.onSurfaceVariant,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
}
}
@Composable
private fun TripRule() {
Box(
Modifier.padding(horizontal = 8.dp, vertical = 4.dp).width(1.dp).fillMaxHeight()
.background(MaterialTheme.colorScheme.outlineVariant),
)
}
/** One of the trip's figures with its label under it, shrinking to stay on one line in a narrow column. */
@Composable
private fun TripFigure(value: String, label: String?, color: Color, style: androidx.compose.ui.text.TextStyle, modifier: Modifier) {
Column(modifier) {
if (value.isEmpty()) return@Column
var size by androidx.compose.runtime.remember(value, style) { androidx.compose.runtime.mutableStateOf(style.fontSize) }
Text(
value,
style = style.copy(fontSize = size, fontWeight = FontWeight.Bold),
color = color,
maxLines = 1,
softWrap = false,
onTextLayout = { if (it.hasVisualOverflow && size.value > 10f) size *= 0.9f },
)
if (label != null) {
// Shrinks like the figure, so "arrival" stays whole when the player narrows the column.
val labelStyle = ChromeType.label
var labelSize by androidx.compose.runtime.remember(label, labelStyle) { androidx.compose.runtime.mutableStateOf(labelStyle.fontSize) }
Text(
label,
style = labelStyle.copy(fontSize = labelSize),
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1,
softWrap = false,
onTextLayout = { if (it.hasVisualOverflow && labelSize.value > 10f) labelSize *= 0.9f },
)
}
}
}
/**
* Whether the app's own buttons include one that ends the drive: a red primary button, as
* Vela's is, or one named for it. Actions carry no flag that says so, so this is the best sign.
*/
private fun androidx.car.app.model.ActionStrip?.hasStop(): Boolean = this?.actions.orEmpty().any { action ->
val red = action.flags and androidx.car.app.model.Action.FLAG_PRIMARY != 0 &&
action.backgroundColor?.type == androidx.car.app.model.CarColor.TYPE_RED
red || action.title?.toString()?.let { STOP_WORDS.containsMatchIn(it) } == true
}
private val STOP_WORDS = Regex("\\b(stop|end|exit|quit|cancel)\\b", RegexOption.IGNORE_CASE)
// --- map with a side pane ----------------------------------------------------------------------
/**
@@ -497,6 +556,9 @@ private fun MapPaneLayout(
if (pan) null else actionStrip,
mapActionStrip,
Modifier.align(if (atEnd) Alignment.TopStart else Alignment.TopEnd).padding(12.dp),
// Counted as covering the map, as in navigation, or Waze's speedometer slid under
// them and off the screen's edge whenever a card was up.
mapActionsModifier = Modifier.covers("pane-buttons", buttonEdge),
alignment = if (atEnd) Alignment.Start else Alignment.End,
)
}
@@ -7,6 +7,8 @@ import app.seb3thehacker.gearslip.car.theme.*
import androidx.compose.runtime.produceState
import androidx.compose.runtime.remember
import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.focus.focusRequester
import androidx.compose.foundation.focusGroup
import androidx.compose.material3.LocalContentColor
import androidx.compose.ui.graphics.ColorFilter
import androidx.compose.ui.graphics.luminance
@@ -52,6 +54,9 @@ import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.material3.RadioButton
import androidx.compose.material3.Switch
import androidx.core.graphics.drawable.toBitmap
import androidx.core.graphics.drawable.IconCompat
import androidx.compose.ui.graphics.drawscope.clipRect
import androidx.compose.ui.draw.drawWithContent
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.draw.clip
import androidx.compose.ui.input.pointer.PointerEventPass
@@ -243,13 +248,23 @@ internal fun RightEdgeControls(
val hasPan = (actions + mapActions).any { it.type == Action.TYPE_PAN }
androidx.compose.runtime.LaunchedEffect(hasPan) { if (!hasPan) connection.setPanMode(false) }
// Faded out when the screen has been left alone; then they are only a memory of buttons, so
// the first touch on one wakes them rather than pressing it.
// Faded out when the screen has been left alone, to leave the map clear, but still buttons:
// a tap on a faded one presses it straight away (and wakes the rest), as Android Auto's do.
val awake = LocalMapControlsWake.current.awake
val pan by connection.panMode.collectAsState()
// Kept up while the knob is on one of them, or the ring would fade out along with the button.
var holdsFocus by remember { mutableStateOf(false) }
val visible = awake || pan || holdsFocus
// Pressing the knob on the map hands it to these buttons (see MapKnob).
val knobOpen by MapKnob.buttonsOpen.collectAsState()
val knobFocus = remember { androidx.compose.ui.focus.FocusRequester() }
androidx.compose.runtime.DisposableEffect(Unit) {
MapKnob.controls++
onDispose { MapKnob.controls-- }
}
androidx.compose.runtime.LaunchedEffect(knobOpen) {
if (knobOpen) runCatching { knobFocus.requestFocus() }
}
val visible = awake || pan || holdsFocus || knobOpen
// With the player beside the map there is no room for the app's row and the map controls
// both, so the app's own buttons fold behind one; opening it swaps them in for the map's.
val folded = LocalSidePanelInset.current > 0.dp && actions.size > 1
@@ -263,16 +278,9 @@ internal fun RightEdgeControls(
Column(
modifier
.alpha(alpha)
.onFocusChanged { holdsFocus = it.hasFocus }
.let { base ->
if (visible) base else base.pointerInput(Unit) {
awaitPointerEventScope {
while (true) {
awaitPointerEvent(PointerEventPass.Initial).changes.forEach { it.consume() }
}
}
}
},
.focusRequester(knobFocus)
.focusGroup()
.onFocusChanged { holdsFocus = it.hasFocus },
horizontalAlignment = alignment,
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
@@ -346,6 +354,10 @@ internal fun ActionButton(action: Action, large: Boolean = false, modifier: Modi
AppIconBadge()
return
}
// On an app's first screen, opened from the launcher, Gearslip's own close button is already
// the way out; a back arrow beside it did the same thing twice. Deeper in, it stays.
val atRoot by connection.atRoot.collectAsState()
if (action.type == Action.TYPE_BACK && atRoot && connection === CarServices.browse) return
val dark = MaterialTheme.colorScheme.surface.luminanceIsDark()
val title = action.title.text()
val panOn by connection.panMode.collectAsState()
@@ -455,16 +467,30 @@ internal fun AutoStartButton(action: Action, modifier: Modifier = Modifier) {
.background(background),
)
}
androidx.compose.foundation.layout.Box(
Modifier.fillMaxWidth().padding(horizontal = 20.dp, vertical = 16.dp),
contentAlignment = Alignment.Center,
) {
if (hasGlyph) CarGlyph(action.icon, Modifier.size(24.dp).align(Alignment.CenterStart))
Text(
action.title.text().ifEmpty { stringResource(R.string.template_start) },
style = ChromeType.title,
maxLines = 1,
)
// The label twice, each clipped to its own side of the fill: dark on the filled part
// and light on the empty track. One colour for both was unreadable on the track until
// the fill reached it (Vela's Start).
val label = action.title.text().ifEmpty { stringResource(R.string.template_start) }
val trackContent = MaterialTheme.colorScheme.onSurface
for (filled in listOf(false, true)) {
androidx.compose.foundation.layout.Box(
Modifier
.fillMaxWidth()
.drawWithContent {
val edge = size.width * fraction
if (filled) clipRect(right = edge) { this@drawWithContent.drawContent() }
else clipRect(left = edge) { this@drawWithContent.drawContent() }
}
.padding(horizontal = 20.dp, vertical = 16.dp),
contentAlignment = Alignment.Center,
) {
androidx.compose.runtime.CompositionLocalProvider(
LocalContentColor provides if (filled) content else trackContent,
) {
if (hasGlyph) CarGlyph(action.icon, Modifier.size(24.dp).align(Alignment.CenterStart))
Text(label, style = ChromeType.title, maxLines = 1, color = LocalContentColor.current)
}
}
}
}
}
@@ -517,7 +543,11 @@ internal fun CarGlyph(icon: CarIcon?, modifier: Modifier = Modifier, standard: I
// app's own process for a content:// icon, plus a bitmap decode - done synchronously on the
// composition thread for every tile on screen, that's what made a shelf of ten covers feel
// like it was hanging rather than loading.
val bitmapState by produceState<ImageBitmap?>(null, icon) {
// CarIcon.equals calls any two bitmap icons equal, so keyed on the CarIcon alone a turn arrow
// drawn as a bitmap (Vela's, Organic Maps' lanes) never replaced the first one of the drive.
// Those are keyed on the IconCompat itself, which every new template brings fresh.
val key: Any? = icon?.icon?.takeIf { it.type != IconCompat.TYPE_RESOURCE && it.type != IconCompat.TYPE_URI } ?: icon
val bitmapState by produceState<ImageBitmap?>(null, key) {
value = withContext(Dispatchers.IO) { icon.image(context) }
}
val bitmap = bitmapState
@@ -174,9 +174,20 @@ internal fun GridTile(item: GridItem, modifier: Modifier = Modifier, iconSize: a
/**
* [autoStart] is for a pane floating over a map - a place or a route the app is proposing - where
* a single action counts itself down rather than waiting to be pressed, on the theory that a
* driver glancing at the car screen has already decided. It never applies to more than one
* action: a pane offering a real choice (Start vs. something else) always waits to be tapped.
* driver glancing at the car screen has already decided. See [autoStartLead] for when: a pane
* offering a real choice (Start vs. something else) always waits to be tapped.
*/
/**
* The action that counts itself down, if the pane is really proposing one thing: its only action,
* or the app's primary one when everything beside it is an icon with no words (Waze's "Go now"
* next to its routes button). Two worded choices ("New drive", "Add a stop") wait for a tap.
*/
internal fun autoStartLead(actions: List<Action>): Action? {
actions.singleOrNull()?.let { return it }
val primary = actions.filter { it.flags and Action.FLAG_PRIMARY != 0 }.singleOrNull() ?: return null
return primary.takeIf { actions.all { it === primary || it.title.text().isBlank() } }
}
@Composable
internal fun PaneRows(pane: Pane?, modifier: Modifier = Modifier, autoStart: Boolean = false) {
// Some apps pad a pane with a row of blank strings; drawn, it is just a gap.
@@ -186,8 +197,16 @@ internal fun PaneRows(pane: Pane?, modifier: Modifier = Modifier, autoStart: Boo
Column(modifier) {
card.heading?.let { PlaceHeading(it) }
if (card.buttons.isNotEmpty()) RowButtons(card.buttons, Modifier.padding(horizontal = 14.dp, vertical = 6.dp))
if (autoStart && actions.size == 1) {
AutoStartButton(actions.single(), Modifier.padding(horizontal = 14.dp, vertical = 10.dp))
val lead = if (autoStart) autoStartLead(actions) else null
if (lead != null) {
Row(
Modifier.fillMaxWidth().padding(horizontal = 14.dp, vertical = 10.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
AutoStartButton(lead, Modifier.weight(1f))
actions.filter { it !== lead }.forEach { ActionButton(it, large = true, modifier = Modifier.weight(1f)) }
}
} else {
ActionRow(actions, Modifier.padding(horizontal = 14.dp, vertical = 10.dp), large = true)
}
@@ -11,6 +11,7 @@ import android.location.Geocoder
import android.location.Location
import android.location.LocationManager
import app.seb3thehacker.gearslip.GearslipLog
import app.seb3thehacker.gearslip.SessionStatus
import java.net.HttpURLConnection
import java.net.URL
import java.util.Locale
@@ -66,8 +67,21 @@ object Weather {
private var placeCache: Pair<String, String>? = null // rounded "lat,lon" to place name
/** Set while the car preview is on the phone's screen, where the dashboard shows the weather too. */
@Volatile var previewOpen = false
/**
* Location is read only while a car is connected or the preview is open. Opening Gearslip on
* the phone used to warm the forecast too, which read the location with no car anywhere.
*/
private val mayLocate: Boolean
get() = previewOpen || SessionStatus.state.value.phase.let {
it == SessionStatus.Phase.CONNECTING || it == SessionStatus.Phase.PROJECTING
}
/** Runs off the main thread. Fresh data younger than [TTL_MS] is kept unless [force]. */
fun refresh(context: Context, force: Boolean = false) {
if (!mayLocate) return
val current = flow.value
if (!force && current is WeatherState.Ready && System.currentTimeMillis() - current.data.fetchedAt < TTL_MS) return
val app = context.applicationContext
@@ -10,6 +10,11 @@ import android.webkit.JavascriptInterface
import android.webkit.WebChromeClient
import android.widget.FrameLayout
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.size
import app.seb3thehacker.gearslip.car.theme.GsIconBox
import app.seb3thehacker.gearslip.car.theme.GsTone
import app.seb3thehacker.gearslip.car.theme.gsColors
import androidx.compose.runtime.LaunchedEffect
import app.seb3thehacker.gearslip.car.theme.GsIconButton
import android.webkit.WebResourceError
@@ -33,14 +38,29 @@ import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.viewinterop.AndroidView
import app.seb3thehacker.gearslip.GearslipLog
import org.json.JSONObject
import android.graphics.Bitmap
import androidx.car.app.CarToast
import androidx.compose.runtime.collectAsState
import androidx.compose.ui.platform.LocalContext
import app.seb3thehacker.gearslip.browser.BrowserSettings
import app.seb3thehacker.gearslip.browser.SiteIcons
import app.seb3thehacker.gearslip.browser.adblock.AdBlockManager
import app.seb3thehacker.gearslip.browser.adblock.CosmeticFilterBridge
import app.seb3thehacker.gearslip.browser.adblock.YouTubeAdBlock
import java.util.concurrent.atomic.AtomicReference
import androidx.compose.material.icons.filled.Star
import androidx.compose.material.icons.filled.Search
import androidx.compose.material.icons.filled.Refresh
import androidx.compose.material.icons.filled.Home
import androidx.compose.material.icons.automirrored.filled.ArrowForward
/**
* A browser inside the car UI. [content] is a URL, or raw HTML when it does not look like one.
* The bottom bar's Back walks the page history first and only leaves the app at the start.
* A browser inside the car UI. [content] is a URL, or raw HTML when it does not look like one;
* blank opens [BrowserStartPage], the search bar and bookmark tiles. The bottom bar's Back walks
* the page history first, then returns to the start page, and only then leaves the app.
*
* Every request a page makes passes [AdBlockManager] first, and [CosmeticFilterBridge] hides the
* ad slots that are left (issue #85). Both are switched from the phone's browser settings.
*
* A page's own text inputs can't call up the phone's IME: the car UI lives in a Presentation on
* a virtual display, and the platform's soft keyboard has nowhere sensible to appear there (see
@@ -67,6 +87,29 @@ fun WebApp(content: String) {
var canGoForward by remember { mutableStateOf(false) }
// The address being typed, or null while the header shows the page title.
var address by remember { mutableStateOf<String?>(null) }
// An address being typed to save as a bookmark, from the start page's Add tile.
var newBookmark by remember { mutableStateOf<String?>(null) }
// The start page covers the WebView until something is opened.
var atStart by remember { mutableStateOf(content.isBlank()) }
var pageUrl by remember { mutableStateOf<String?>(null) }
val bookmarks by BrowserSettings.bookmarks.collectAsState()
val context = LocalContext.current
LaunchedEffect(Unit) {
AdBlockManager.ensureInitialized(context)
AdBlockManager.refreshIfDue(context)
}
val open: (String) -> Unit = { url ->
atStart = false
web?.loadUrl(url)
}
val goHome: () -> Unit = {
if (content.isBlank()) {
atStart = true
pageTitle = ""
} else {
web?.let { view -> loadContent(view, content) }
}
}
// Leaving the browser ends full screen, whoever started it.
DisposableEffect(Unit) { onDispose { CarFullscreen.exit() } }
@@ -78,15 +121,18 @@ fun WebApp(content: String) {
}
}
DisposableEffect(web) {
DisposableEffect(web, atStart) {
navigator.backInterceptor = {
val view = web
if (CarFullscreen.active) {
CarFullscreen.exit()
true
} else if (view != null && view.canGoBack()) {
} else if (!atStart && view != null && view.canGoBack()) {
view.goBack()
true
} else if (!atStart && content.isBlank()) {
goHome()
true
} else {
false
}
@@ -97,19 +143,39 @@ fun WebApp(content: String) {
Column(Modifier.fillMaxSize()) {
// Back steps through the page's history first (the interceptor above), then leaves.
if (!fullscreen) {
ScreenHeader(pageTitle.ifEmpty { stringResource(R.string.app_browser) }, Modifier.padding(horizontal = 16.dp, vertical = 8.dp)) {
ScreenHeader(pageTitle.takeUnless { atStart }.orEmpty().ifEmpty { stringResource(R.string.app_browser) }, Modifier.padding(horizontal = 16.dp, vertical = 8.dp)) {
val url = pageUrl
if (!atStart && url != null && url.startsWith("http")) {
val saved = remember(bookmarks, url) { BrowserSettings.isBookmarked(url) }
GsIconBox(
{
if (saved) BrowserSettings.removeBookmark(url) else BrowserSettings.addBookmark(pageTitle, url)
CarToasts.show(BrowserSettings, context.getString(if (saved) R.string.web_bookmark_removed else R.string.web_bookmark_added), CarToast.LENGTH_SHORT)
},
Modifier.size(48.dp),
gsColors(if (saved) GsTone.Tonal else GsTone.Neutral),
latched = saved,
) {
androidx.compose.material3.Icon(
androidx.compose.material.icons.Icons.Filled.Star,
stringResource(if (saved) R.string.web_remove_this_bookmark else R.string.web_bookmark_this),
Modifier.size(24.dp),
tint = if (saved) MaterialTheme.colorScheme.primary else androidx.compose.material3.LocalContentColor.current.copy(alpha = 0.6f),
)
}
}
GsIconButton(androidx.compose.material.icons.Icons.Filled.Search, stringResource(R.string.web_search), {
fieldText = null
address = ""
})
if (canGoForward) {
if (canGoForward && !atStart) {
GsIconButton(androidx.compose.material.icons.Icons.AutoMirrored.Filled.ArrowForward, stringResource(R.string.web_forward), { web?.goForward() })
}
GsIconButton(androidx.compose.material.icons.Icons.Filled.Refresh, stringResource(R.string.web_reload), { web?.reload() })
GsIconButton(androidx.compose.material.icons.Icons.Filled.Home, stringResource(R.string.web_start_page), {
web?.let { view -> loadContent(view, content) }
})
GsIconButton(MediaIcons.Fullscreen, stringResource(R.string.web_fullscreen), CarFullscreen::enter)
if (!atStart) {
GsIconButton(androidx.compose.material.icons.Icons.Filled.Refresh, stringResource(R.string.web_reload), { web?.reload() })
GsIconButton(androidx.compose.material.icons.Icons.Filled.Home, stringResource(R.string.web_start_page), goHome)
GsIconButton(MediaIcons.Fullscreen, stringResource(R.string.web_fullscreen), CarFullscreen::enter)
}
}
}
Box(Modifier.weight(1f).fillMaxSize()) {
@@ -117,12 +183,23 @@ fun WebApp(content: String) {
modifier = Modifier.fillMaxSize(),
factory = { context ->
WebView(context).apply {
// The page a request belongs to, for telling first-party requests from
// third-party ones: shouldInterceptRequest runs off the UI thread, where a
// WebView's own url can't be read.
val documentUrl = AtomicReference<String?>(null)
CosmeticFilterBridge.attach(this)
settings.javaScriptEnabled = true
settings.domStorageEnabled = true
settings.loadWithOverviewMode = true
settings.useWideViewPort = true
webViewClient = LoggingClient(ring) { view ->
// A video starts when the page asks, as in a normal browser. Left at Android's
// default, YouTube's player waits for a second tap before it will play.
settings.mediaPlaybackRequiresUserGesture = false
// Draws the page before it's on screen, so the first frame is ready sooner.
settings.offscreenPreRaster = true
webViewClient = LoggingClient(ring, documentUrl, onStarted = { pageUrl = it }) { view ->
canGoForward = view.canGoForward()
pageUrl = view.url
pageTitle = view.title.orEmpty().takeUnless { it.startsWith("http") || it == "about:blank" }.orEmpty()
}
webChromeClient = object : WebChromeClient() {
@@ -130,6 +207,12 @@ fun WebApp(content: String) {
pageTitle = title.orEmpty().takeUnless { it.startsWith("http") || it == "about:blank" }.orEmpty()
}
// Kept for the start page's tiles, so a bookmark shows its own icon.
override fun onReceivedIcon(view: WebView?, icon: Bitmap?) {
val host = view?.url?.let { runCatching { android.net.Uri.parse(it).host }.getOrNull() }?.removePrefix("www.")
if (icon != null && host != null) SiteIcons.save(view.context, host, icon)
}
override fun onShowCustomView(view: View, callback: CustomViewCallback) {
videoMadeFullscreen = !CarFullscreen.active
pageFullscreen = view to callback
@@ -146,7 +229,7 @@ fun WebApp(content: String) {
FieldFocusBridge(onFocus = { fieldText = it }, onBlur = { fieldText = null }),
"GearslipField",
)
loadContent(this, content)
if (content.isNotBlank()) loadContent(this, content)
web = this
}
},
@@ -154,6 +237,18 @@ fun WebApp(content: String) {
update = { it.settings.isAlgorithmicDarkeningAllowed = darkOutside },
onRelease = { it.destroy() },
)
if (atStart) {
androidx.compose.foundation.layout.Box(Modifier.fillMaxSize().background(MaterialTheme.colorScheme.surface)) {
BrowserStartPage(
onOpen = open,
onSearch = {
fieldText = null
address = ""
},
onAdd = { newBookmark = "https://" },
)
}
}
pageFullscreen?.let { (view, _) ->
androidx.compose.runtime.key(view) {
AndroidView(
@@ -175,12 +270,32 @@ fun WebApp(content: String) {
text = typed,
onTextChange = { address = it },
onSubmit = {
web?.loadUrl(addressToUrl(typed))
open(BrowserSettings.addressToUrl(typed))
address = null
},
onDismiss = { address = null },
)
}
newBookmark?.let { typed ->
CarKeyboard(
text = typed,
onTextChange = { newBookmark = it },
onSubmit = {
// Only an address: words typed here would otherwise save a search as a bookmark.
val text = typed.trim()
val url = when {
text.startsWith("http://") || text.startsWith("https://") -> text
' ' !in text && '.' in text -> "https://$text"
else -> null
}
if (url != null && BrowserSettings.addBookmark("", url)) {
CarToasts.show(BrowserSettings, context.getString(R.string.web_bookmark_added), CarToast.LENGTH_SHORT)
}
newBookmark = null
},
onDismiss = { newBookmark = null },
)
}
fieldText?.let { text ->
CarKeyboard(
text = text,
@@ -207,19 +322,29 @@ private fun loadContent(view: WebView, content: String) {
}
}
/** Something that looks like an address opens as one; anything else is searched for. */
private fun addressToUrl(typed: String): String {
val text = typed.trim()
return when {
text.startsWith("http://") || text.startsWith("https://") -> text
' ' !in text && '.' in text -> "https://$text"
else -> "https://duckduckgo.com/?q=" + java.net.URLEncoder.encode(text, "UTF-8")
private class LoggingClient(
private val ring: String,
private val documentUrl: AtomicReference<String?>,
private val onStarted: (String?) -> Unit,
private val onLoaded: (WebView) -> Unit,
) : WebViewClient() {
override fun shouldInterceptRequest(view: WebView?, request: WebResourceRequest?): WebResourceResponse? =
request?.let { AdBlockManager.response(it, documentUrl.get()) }
override fun onPageStarted(view: WebView?, url: String?, favicon: Bitmap?) {
documentUrl.set(url)
onStarted(url)
// Without document-start scripts, inject as early as this WebView allows.
if (CosmeticFilterBridge.needsPageStartFallback) {
view?.evaluateJavascript(CosmeticFilterBridge.INJECTION_JS, null)
view?.evaluateJavascript(YouTubeAdBlock.INJECTION_JS, null)
}
}
}
private class LoggingClient(private val ring: String, private val onLoaded: (WebView) -> Unit) : WebViewClient() {
override fun onPageFinished(view: WebView?, url: String?) {
documentUrl.set(url)
GearslipLog.i("car web page loaded: $url")
AdBlockManager.logPageCost(url)
view?.let(onLoaded)
view?.evaluateJavascript(FOCUS_TRACKING_JS, null)
view?.evaluateJavascript(focusRingJs(ring), null)
@@ -44,7 +44,6 @@ object CarAppCatalog {
.asSequence()
.filter { it.serviceInfo.packageName != context.packageName }
.filterNot { AndroidAuto.matches(it.serviceInfo.packageName, it.loadLabel(manager).toString()) }
.filterNot { KnownApps.isHidden(it.serviceInfo.packageName) }
.map { resolved ->
val info = resolved.serviceInfo
TemplateApp(
@@ -18,6 +18,8 @@ import android.view.Surface
import androidx.car.app.CarAppService
import androidx.car.app.CarContext
import androidx.car.app.HandshakeInfo
import androidx.car.app.SessionInfo
import androidx.car.app.SessionInfoIntentEncoder
import androidx.car.app.IAppHost
import androidx.car.app.IAppManager
import androidx.car.app.ICarApp
@@ -56,8 +58,12 @@ import kotlinx.coroutines.flow.asStateFlow
*
* Every binder callback lands on a binder thread and is hopped to the main thread, because
* the state here feeds Compose.
*
* With a cluster [session], this is the same app's second session, for the car's instrument
* cluster: a bind of its own, which the app answers with a session of its own. It leans on the
* main session for location, so it holds none itself.
*/
class CarAppConnection(private val context: Context) {
class CarAppConnection(private val context: Context, private val session: SessionInfo? = null) {
enum class Phase { IDLE, BINDING, HANDSHAKE, RUNNING, REJECTED, FAILED }
@@ -126,6 +132,7 @@ class CarAppConnection(private val context: Context) {
update { Status(phase = Phase.BINDING, app = app.label) }
val intent = Intent(CarAppService.SERVICE_INTERFACE).setComponent(app.component)
session?.let { SessionInfoIntentEncoder.encode(it, intent) }
val connection = object : ServiceConnection {
override fun onServiceConnected(name: ComponentName, service: IBinder) {
main.post { onBound(service) }
@@ -179,7 +186,7 @@ class CarAppConnection(private val context: Context) {
// BIND_INCLUDE_CAPABILITIES only lends the location capability Gearslip holds at bind
// time, so the keep-alive has to be running first - a nav app that starts its own
// location service before then is refused, and MapQuest crashes on that.
if (holdsLocation) {
if (holdsLocation || session != null) {
bind()
} else {
holdsLocation = true
@@ -444,12 +451,19 @@ class CarAppConnection(private val context: Context) {
return@call
}
GearslipLog.i("host: template = ${template.javaClass.simpleName}")
// The app's ScreenManager lists every screen on its stack; one or none is its first.
_atRoot.value = ((value as? TemplateWrapper)?.templateInfosForScreenStack?.size ?: 1) <= 1
_template.value = template
update { it.copy(template = template.javaClass.simpleName) }
autoRoute.onTemplate(template)
}) { manager.getTemplate(it) }
}
private val _atRoot = MutableStateFlow(true)
/** True while the app shows its first screen, where its own back would only close it. */
val atRoot: StateFlow<Boolean> = _atRoot
private val _panMode = MutableStateFlow(false)
/**
@@ -0,0 +1,48 @@
package app.seb3thehacker.gearslip.host
import android.content.Context
import app.seb3thehacker.gearslip.AppSettings
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
/**
* Apps the driver added to the car by hand, for apps that never declared Android Auto support
* (issue #84). A music app is driven through its playback session, the way Spotify's player is;
* a messenger's notifications show on Gearslip's Messages screen, as any car messenger's do.
* Neither needs anything from the app's developer.
*/
object CustomCarApps {
enum class Kind(val id: String) { MEDIA("media"), MESSAGING("messaging") }
data class App(val packageName: String, val kind: Kind)
private const val KEY = "custom_car_apps"
private val flow = MutableStateFlow<List<App>?>(null)
/** Null until [all] has read the settings once. */
val state: StateFlow<List<App>?> = flow
fun all(context: Context): List<App> = flow.value ?: read(context).also { flow.value = it }
fun of(context: Context, kind: Kind): List<App> = all(context).filter { it.kind == kind }
fun isCustom(context: Context, packageName: String): Boolean = all(context).any { it.packageName == packageName }
fun add(context: Context, packageName: String, kind: Kind) = save(context, all(context).filterNot { it.packageName == packageName } + App(packageName, kind))
fun remove(context: Context, packageName: String) = save(context, all(context).filterNot { it.packageName == packageName })
private fun save(context: Context, apps: List<App>) {
flow.value = apps
AppSettings.putString(context, KEY, apps.joinToString(",") { "${it.packageName}:${it.kind.id}" })
}
private fun read(context: Context): List<App> =
AppSettings.getString(context, KEY, "").split(",").mapNotNull { item ->
val pkg = item.substringBefore(':').takeIf { it.isNotBlank() } ?: return@mapNotNull null
val kind = Kind.entries.firstOrNull { it.id == item.substringAfter(':') } ?: return@mapNotNull null
App(pkg, kind)
}
}
@@ -52,27 +52,38 @@ object KnownApps {
fun screenBroken(packageName: String): Boolean = packageName in brokenScreen
/**
* Registers a car template service but never gives Gearslip a usable screen - left off the
* launcher entirely rather than shown as a tile that goes nowhere.
* Google's own car apps, which never give Gearslip a usable screen. They used to be left off
* the launcher, which looked like a bug to anyone who had them; now they're listed like any
* other broken app, starting unticked (see [startsHidden]).
*/
private val hidden = setOf(
private val google = setOf(
"com.google.android.dialer", // Google Phone
"com.google.android.googlequicksearchbox", // Google Assistant driving surfaces
"com.google.android.apps.maps", // Google Maps: shows as a messenger, and its car UI is Android Auto's own
)
fun isHidden(packageName: String): Boolean = packageName in hidden
/** Shown on the launcher with a red X: known broken, but expected enough that hiding it outright would look like a bug. */
private val broken = setOf(
/** Shown on the launcher with a red X, and unticked to start with. */
private val broken = google + setOf(
"com.google.android.apps.messaging", // Google Messages
"com.waze", // Waze
"com.waze", // Waze: the official app refuses Gearslip; the Morphe-patched one works (see patched())
"com.generalmagic.magicearth", // Magic Earth: never gets past connecting
)
fun isBroken(context: Context, packageName: String): Boolean =
packageName in broken && !patched(context, packageName)
/**
* Whether a tile starts unticked in the phone's list of apps to show, so the drawer opens
* with only what works. Gearslip's own Messages screen works for any messenger, so a broken
* app's messaging tile still shows, except Google Maps', which has no messages worth reading.
*/
fun startsHidden(context: Context, packageName: String, messaging: Boolean): Boolean =
isBroken(context, packageName) && (!messaging || packageName in google)
/** Google's own apps get the red X even as a messenger: Google Maps shows only as one. */
fun brokenAsMessenger(context: Context, packageName: String): Boolean =
packageName in google && isBroken(context, packageName)
/**
* The certificate each broken app's own publisher signs it with. A copy signed with another
* key has been patched to accept Gearslip, like Waze with the waze-gearslip Morphe patch, and
@@ -46,7 +46,8 @@ class LocationKeepAlive : Service() {
}.onSuccess {
settle(State.ON)
}.onFailure {
GearslipLog.w("location keep-alive: could not start the foreground service - ${it.message}")
val background = if (allowedInBackground(this)) "allowed all the time" else "not allowed all the time"
GearslipLog.w("location keep-alive: could not start the foreground service ($background) - ${it.message}")
settle(State.FAILED)
stopSelf()
}
@@ -101,6 +102,21 @@ class LocationKeepAlive : Service() {
}
}
/**
* Starts the service again if a car still holds it but it was refused, as it is when a car
* connects with the phone locked and "Allow all the time" isn't granted. Called once the
* driver grants it, so the map gets its fix without reconnecting.
*/
fun retry(context: Context) {
main.post {
if (holders > 0 && state == State.FAILED) begin(context.applicationContext)
}
}
/** Whether Android lets this service start with the phone locked (issue #99). */
fun allowedInBackground(context: Context) =
context.checkSelfPermission(Manifest.permission.ACCESS_BACKGROUND_LOCATION) == PackageManager.PERMISSION_GRANTED
/** Lets go of one hold; the last one stops the service. */
fun release(context: Context) {
main.post {
@@ -4,6 +4,7 @@ import app.seb3thehacker.gearslip.R
import android.content.ComponentName
import android.content.Context
import android.content.Intent
import android.view.KeyEvent
import android.graphics.Bitmap
import android.media.MediaMetadata
import android.media.Rating
@@ -188,6 +189,9 @@ class CarMedia(private val context: Context) {
/** The music app Gearslip is attached to, for the assistant to name; null when there's none. */
val appLabel: String? get() = connecting?.label?.takeIf { controller != null }
/** Called once an app is connected and can be played, library or not. */
var onReady: ((MediaApp) -> Unit)? = null
private val _phase = MutableStateFlow(Phase.IDLE)
val phase: StateFlow<Phase> = _phase.asStateFlow()
@@ -278,6 +282,14 @@ class CarMedia(private val context: Context) {
attachToSession(app)
return
}
// Added by hand: no library at all. Wake the app with its Play key if it isn't running,
// then wait for its session as after opening it.
if (app.sessionOnly) {
val running = hasSession(app)
if (!running) pressPlay(app)
if (running) attachToSession(app) else main.postDelayed({ attachToSession(app, launched = true) }, PLAY_WAKE_MS)
return
}
val mb = MediaBrowser(context, app.component, object : MediaBrowser.ConnectionCallback() {
override fun onConnected() = this@CarMedia.onConnected(app)
override fun onConnectionFailed() {
@@ -341,7 +353,7 @@ class CarMedia(private val context: Context) {
GearslipLog.w("media: ${app.label} has no active session to control")
// A session-only app (Spotify) just isn't running - say so and offer to open it,
// rather than calling it unsupported.
_rejection.value = if (KnownApps.playerWorks(app.component.packageName)) {
_rejection.value = if (app.sessionOnly || KnownApps.playerWorks(app.component.packageName)) {
Rejection.NEEDS_APP_RUNNING
} else {
Rejection.REFUSED
@@ -354,6 +366,7 @@ class CarMedia(private val context: Context) {
attach(session)
_browse.value = BrowseState(loading = false, unavailable = true)
_phase.value = Phase.READY
onReady?.invoke(app)
}
private fun onConnected(app: MediaApp) {
@@ -366,6 +379,7 @@ class CarMedia(private val context: Context) {
mb.extras?.let { childStyles[mb.root] = it.getInt(STYLE_BROWSABLE, 0) to it.getInt(STYLE_PLAYABLE, 0) }
path.clear()
_phase.value = Phase.READY
onReady?.invoke(app)
open(mb.root)
}
@@ -390,7 +404,16 @@ class CarMedia(private val context: Context) {
private fun update(metadata: MediaMetadata?, state: PlaybackState?) {
fun text(vararg keys: String) = keys.firstNotNullOfOrNull { metadata?.getString(it)?.takeIf(String::isNotEmpty) }.orEmpty()
fun art(vararg keys: String) = keys.firstNotNullOfOrNull { metadata?.getBitmap(it) }
// The cover comes over IPC as a new Bitmap with every update, a play state change as much
// as a new song. The same picture keeps the Bitmap already shown, or the player fades the
// cover in again every few seconds and it looks like it's flickering.
fun art(vararg keys: String): Bitmap? {
val fresh = keys.firstNotNullOfOrNull { metadata?.getBitmap(it) } ?: return null
val shown = _now.value.art
val same = shown != null && !shown.isRecycled && shown.width == fresh.width && shown.height == fresh.height &&
runCatching { shown.sameAs(fresh) }.getOrDefault(false)
return if (same) shown else fresh
}
fun uri(vararg keys: String) = keys.firstNotNullOfOrNull { metadata?.getString(it)?.takeIf(String::isNotEmpty) }?.let(Uri::parse)
_now.value = NowPlaying(
title = text(MediaMetadata.METADATA_KEY_DISPLAY_TITLE, MediaMetadata.METADATA_KEY_TITLE),
@@ -418,6 +441,9 @@ class CarMedia(private val context: Context) {
checkSearch()
}
/** The package of the app Gearslip controls now, or null. */
val packageName: String? get() = controller?.packageName
private var appResources: Pair<String, android.content.res.Resources?>? = null
/**
@@ -826,6 +852,33 @@ class CarMedia(private val context: Context) {
main.postDelayed({ attachToSession(app, launched = true) }, LAUNCH_WAIT_MS)
}
private fun hasSession(app: MediaApp): Boolean = runCatching {
context.getSystemService(MediaSessionManager::class.java)
.getActiveSessions(ComponentName(context, GearslipNotificationListener::class.java))
.any { it.packageName == app.component.packageName }
}.getOrDefault(false)
/**
* Sends [app] a Play key press, as a headset button would. Most players start their service
* for it, even when closed, and pick up where they left off; one that doesn't is offered to
* open on the phone once the wait runs out.
*/
private fun pressPlay(app: MediaApp) {
val pkg = app.component.packageName
val receiver = context.packageManager.queryBroadcastReceivers(Intent(Intent.ACTION_MEDIA_BUTTON).setPackage(pkg), 0)
.firstOrNull()?.activityInfo ?: run {
GearslipLog.i("media: ${app.label} takes no media keys, so it has to be opened on the phone")
return
}
GearslipLog.i("media: pressing Play for ${app.label}, which isn't running")
for (action in listOf(KeyEvent.ACTION_DOWN, KeyEvent.ACTION_UP)) {
val press = Intent(Intent.ACTION_MEDIA_BUTTON)
.setClassName(pkg, receiver.name)
.putExtra(Intent.EXTRA_KEY_EVENT, KeyEvent(action, KeyEvent.KEYCODE_MEDIA_PLAY))
runCatching { context.sendBroadcast(press) }.onFailure { GearslipLog.w("media: Play for ${app.label} failed: ${it.message}") }
}
}
/** Brings [app] to the foreground on the phone so it publishes its session. */
private fun openApp(app: MediaApp): Boolean {
val intent = context.packageManager.getLaunchIntentForPackage(app.component.packageName) ?: return false
@@ -845,6 +898,8 @@ class CarMedia(private val context: Context) {
const val SESSION_RETRY_MS = 500L
/** After opening a cold app, how long to wait before the first look for its session. */
const val LAUNCH_WAIT_MS = 1_500L
/** How long a player woken by its Play key gets before its session is looked for. */
const val PLAY_WAKE_MS = 1_000L
/** Retries once the app has been opened: a cold start can take a few seconds to publish. */
const val SESSION_RETRIES_LAUNCHED = 16
/** How long an app gets to start playing a search before Gearslip says it didn't. */
@@ -4,7 +4,6 @@ import android.content.Context
import android.util.Base64
import android.util.Xml
import app.seb3thehacker.gearslip.GearslipLog
import app.seb3thehacker.gearslip.car.CarSettings
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.async
@@ -48,8 +47,7 @@ class LyricsResult(val lines: List<LyricLine>, val synced: Boolean, val source:
*
* Media apps almost never publish lyrics in their session metadata, so these are the only general
* source. Each lookup sends the track's title, artist, album and length to these services, which
* is why it happens only when the driver opens the lyrics view, never in the background. With
* [CarSettings.moreLyrics] off, only LRCLIB is asked.
* is why it happens only when the driver opens the lyrics view, never in the background.
*
* Results are cached twice over: in memory for the life of the process, and on disk under the
* cache directory so a song looked up on a past drive doesn't need the network again on this one.
@@ -79,13 +77,12 @@ object Lyrics {
private class Source(val name: String, val best: Int, val lookup: () -> LyricsResult?)
private fun sources(title: String, artist: String, album: String, durationMs: Long): List<Source> {
val more = CarSettings.moreLyrics.value
val seconds = (durationMs / 1000).toInt()
return buildList {
if (more) add(Source("BetterLyrics", BEST) { betterLyrics(title, artist, album, seconds) })
add(Source("LRCLIB", 2) { exact(title, artist, album, durationMs) ?: search(title, artist) })
if (more) add(Source("KuGou", 2) { kuGou(title, artist, album, seconds) })
}
return listOf(
Source("BetterLyrics", BEST) { betterLyrics(title, artist, album, seconds) },
Source("LRCLIB", 2) { exact(title, artist, album, durationMs) ?: search(title, artist) },
Source("KuGou", 2) { kuGou(title, artist, album, seconds) },
)
}
private fun key(title: String, artist: String, album: String, durationMs: Long) = "$title|$artist|$album|$durationMs"
@@ -93,8 +90,7 @@ object Lyrics {
suspend fun find(context: Context, title: String, artist: String, album: String, durationMs: Long): LyricsResult? {
if (title.isBlank()) return null
val key = key(title, artist, album, durationMs)
// A miss with only LRCLIB isn't a miss once more sources are on, so the mode is part of it.
val memoryKey = "${CarSettings.moreLyrics.value}|$key"
val memoryKey = key
synchronized(cache) { if (cache.containsKey(memoryKey)) return cache[memoryKey] }
val fromDisk = withContext(Dispatchers.IO) { readDisk(context, key) }
@@ -129,7 +125,7 @@ object Lyrics {
withContext(Dispatchers.IO) { writeDisk(context, key, better ?: current, System.currentTimeMillis()) }
if (better == null) return null
GearslipLog.i("lyrics: replaced ${current.source.ifEmpty { "cached" }} lyrics with ${better.source}'s")
synchronized(cache) { cache["${CarSettings.moreLyrics.value}|$key"] = better }
synchronized(cache) { cache[key] = better }
return better
}
@@ -1,5 +1,7 @@
package app.seb3thehacker.gearslip.media
import app.seb3thehacker.gearslip.host.CustomCarApps
import app.seb3thehacker.gearslip.host.AndroidAuto
import app.seb3thehacker.gearslip.host.CarDescriptor
import app.seb3thehacker.gearslip.host.KnownApps
@@ -15,6 +17,11 @@ class MediaApp(
val label: String,
/** Whose audio to capture: playback capture is scoped to one uid so nothing else leaks in. */
val uid: Int,
/**
* Added by the driver rather than found (issue #84): there's no library to connect to, so
* the player goes straight to the app's playback session, as it does for Spotify.
*/
val sessionOnly: Boolean = false,
)
/**
@@ -33,7 +40,6 @@ object MediaCatalog {
.asSequence()
.filter { it.serviceInfo.packageName != context.packageName }
.filterNot { AndroidAuto.matches(it.serviceInfo.packageName, it.loadLabel(manager).toString()) }
.filterNot { KnownApps.isHidden(it.serviceInfo.packageName) }
.filter { declaresMedia(manager, it.serviceInfo) }
.map {
val info = it.serviceInfo
@@ -44,10 +50,29 @@ object MediaCatalog {
)
}
.distinctBy { it.component.packageName }
.sortedBy { it.label.lowercase() }
.toList()
.let { found -> found + added(context, found.map { it.component.packageName }.toSet()) }
.sortedBy { it.label.lowercase() }
}
/** The music apps the driver added by hand, unless the app has since become a car app itself. */
private fun added(context: Context, found: Set<String>): List<MediaApp> {
val manager = context.packageManager
return CustomCarApps.of(context, CustomCarApps.Kind.MEDIA).filter { it.packageName !in found }.mapNotNull { custom ->
val info = runCatching { manager.getApplicationInfo(custom.packageName, 0) }.getOrNull() ?: return@mapNotNull null
MediaApp(
// No service to bind: the class name only keeps the component unique and readable.
component = ComponentName(custom.packageName, SESSION_ONLY_CLASS),
label = info.loadLabel(manager).toString(),
uid = info.uid,
sessionOnly = true,
)
}
}
/** What an added app's component names, since it has no browser service. */
const val SESSION_ONLY_CLASS = "gearslip.session-only"
/** A descriptor that can't be read keeps the app, rather than dropping a real player over an odd manifest. */
private fun declaresMedia(manager: PackageManager, service: android.content.pm.ServiceInfo): Boolean {
if (CarDescriptor.meta(manager, service.packageName, service) == null) return false
@@ -1,5 +1,7 @@
package app.seb3thehacker.gearslip.notify
import app.seb3thehacker.gearslip.host.CustomCarApps
import android.content.Context
import android.content.Intent
import app.seb3thehacker.gearslip.host.AndroidAuto
@@ -25,7 +27,7 @@ object MessagingCatalog {
.map { it.activityInfo.packageName to it.loadLabel(manager).toString() }
.distinctBy { it.first }
.filter { (pkg, _) -> pkg != context.packageName }
.filterNot { (pkg, label) -> AndroidAuto.matches(pkg, label) || KnownApps.isHidden(pkg) }
.filterNot { (pkg, label) -> AndroidAuto.matches(pkg, label) }
.filter { (pkg, _) -> CarDescriptor.uses(manager, pkg)?.contains("notification") == true }
.map { (pkg, _) ->
val label = runCatching {
@@ -33,7 +35,17 @@ object MessagingCatalog {
}.getOrDefault(pkg)
MessagingApp(pkg, label)
}
.sortedBy { it.label.lowercase() }
.toList()
.let { found -> found + added(context, found.map { it.packageName }.toSet()) }
.sortedBy { it.label.lowercase() }
}
/** The messengers the driver added by hand (issue #84); their notifications are all it takes. */
private fun added(context: Context, found: Set<String>): List<MessagingApp> {
val manager = context.packageManager
return CustomCarApps.of(context, CustomCarApps.Kind.MESSAGING).filter { it.packageName !in found }.mapNotNull { custom ->
val info = runCatching { manager.getApplicationInfo(custom.packageName, 0) }.getOrNull() ?: return@mapNotNull null
MessagingApp(custom.packageName, info.loadLabel(manager).toString())
}
}
}
@@ -12,6 +12,7 @@ import androidx.compose.foundation.clickable
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight
import androidx.compose.material.icons.automirrored.filled.Send
import androidx.compose.material.icons.filled.Lock
import androidx.compose.material.icons.filled.Build
import androidx.compose.material.icons.filled.Edit
import androidx.compose.material.icons.filled.Favorite
@@ -47,6 +48,7 @@ internal object GearslipLinks {
const val MATRIX = "https://matrix.to/#/!mf0_mgBd5KqKpc9FfX_wHp1jFCcwAtPrndIn_oGIyMk?via=matrix.org"
const val SOURCE = "https://github.com/twinksoftware/gearslip"
const val CARS = "$SOURCE/blob/main/CARS.md"
const val PRIVACY = "$SOURCE/blob/main/PRIVACY.md"
const val TRANSLATE = "https://hosted.weblate.org/engage/gearslip/"
/** This version's release notes on GitHub; every release is tagged v plus its version name. */
@@ -195,6 +197,14 @@ internal fun AboutLinkRows() {
trailing = arrow,
)
SettingsDivider()
SettingsRow(
stringResource(R.string.privacy_policy),
Modifier.clickable { GearslipLinks.open(context, GearslipLinks.PRIVACY) },
subtitle = stringResource(R.string.privacy_policy_sub),
icon = Icons.Filled.Lock,
trailing = arrow,
)
SettingsDivider()
SettingsRow(
stringResource(R.string.about_voice_credits),
subtitle = stringResource(R.string.about_voice_credits_sub),
@@ -0,0 +1,167 @@
package app.seb3thehacker.gearslip.ui
import android.content.Intent
import androidx.compose.foundation.Image
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.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.CheckCircle
import androidx.compose.material3.Button
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.DialogProperties
import androidx.core.graphics.drawable.toBitmap
import app.seb3thehacker.gearslip.R
/** One app as its page shows it. [url] is where to get it; apps found by their flag have none. */
class PageApp(val name: String, val packageName: String, val url: String?, val about: String?)
/**
* One app's page, over whatever opened it: its icon, its own sentence, the Works with Gearslip
* mark when it declares the flag, and one button, to open it or to get it.
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun AppPage(app: PageApp, onDismiss: () -> Unit) {
val context = LocalContext.current
val icon = remember(app.packageName) { runCatching { context.packageManager.getApplicationIcon(app.packageName) }.getOrNull() }
val declares = remember(app.packageName) { WorksWith.declares(context.packageManager, app.packageName) }
val about = remember(app.packageName) { app.about ?: WorksWith.about(context.packageManager, app.packageName) }
val installed = icon != null
Dialog(onDismissRequest = onDismiss, properties = DialogProperties(usePlatformDefaultWidth = false)) {
Scaffold(
containerColor = MaterialTheme.colorScheme.background,
topBar = {
TopAppBar(
title = {},
navigationIcon = {
IconButton(onClick = onDismiss) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = stringResource(R.string.back))
}
},
)
},
) { padding ->
Column(
Modifier
.fillMaxSize()
.padding(padding)
.padding(horizontal = 24.dp)
.verticalScroll(rememberScrollState()),
horizontalAlignment = Alignment.CenterHorizontally,
) {
Spacer(Modifier.height(16.dp))
Box(
Modifier.size(96.dp).clip(CircleShape).background(MaterialTheme.colorScheme.surfaceContainerHigh),
contentAlignment = Alignment.Center,
) {
if (icon != null) {
Image(icon.toBitmap(192, 192).asImageBitmap(), contentDescription = null, Modifier.fillMaxSize())
} else {
Text(app.name.take(1), style = MaterialTheme.typography.headlineLarge, fontWeight = FontWeight.Bold, color = MaterialTheme.colorScheme.onSurface)
}
}
Spacer(Modifier.height(16.dp))
Text(app.name, style = MaterialTheme.typography.headlineSmall, color = MaterialTheme.colorScheme.onSurface, textAlign = TextAlign.Center)
if (declares) {
Spacer(Modifier.height(8.dp))
WorksWithMark()
}
if (about != null) {
Spacer(Modifier.height(16.dp))
Text(about, style = MaterialTheme.typography.bodyLarge, color = MaterialTheme.colorScheme.onSurfaceVariant, textAlign = TextAlign.Center)
}
Spacer(Modifier.height(24.dp))
if (installed) {
Row(verticalAlignment = Alignment.CenterVertically) {
Icon(Icons.Filled.CheckCircle, contentDescription = null, tint = INSTALLED_GREEN, modifier = Modifier.size(18.dp))
Text(
stringResource(R.string.recommended_installed),
style = MaterialTheme.typography.labelLarge,
color = INSTALLED_GREEN,
modifier = Modifier.padding(start = 6.dp),
)
}
Spacer(Modifier.height(4.dp))
Text(
stringResource(R.string.app_page_on_car),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center,
)
val launch = remember(app.packageName) { context.packageManager.getLaunchIntentForPackage(app.packageName) }
if (launch != null) {
Spacer(Modifier.height(16.dp))
Button(onClick = { runCatching { context.startActivity(launch.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)) } }, Modifier.fillMaxWidth()) {
Text(stringResource(R.string.app_page_open, app.name))
}
}
if (app.url != null) {
TextButton(onClick = { openPage(context, app.url) }) { Text(stringResource(R.string.app_page_github)) }
}
} else if (app.url != null) {
Text(
stringResource(R.string.app_page_get_hint),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center,
)
Spacer(Modifier.height(16.dp))
Button(onClick = { openPage(context, app.url) }, Modifier.fillMaxWidth()) {
Text(stringResource(R.string.app_page_get, app.name))
}
}
Spacer(Modifier.height(24.dp))
}
}
}
}
/** The Works with Gearslip mark: the logo's gear and the words, on a tinted pill. */
@Composable
fun WorksWithMark(modifier: Modifier = Modifier) {
Row(
modifier
.clip(RoundedCornerShape(50))
.background(MaterialTheme.colorScheme.primaryContainer)
.padding(start = 8.dp, end = 10.dp, top = 3.dp, bottom = 3.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(5.dp),
) {
Icon(painterResource(R.drawable.works_with_gear), contentDescription = null, tint = MaterialTheme.colorScheme.onPrimaryContainer, modifier = Modifier.size(14.dp))
Text(stringResource(R.string.works_with_gearslip), style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.onPrimaryContainer)
}
}
@@ -0,0 +1,142 @@
package app.seb3thehacker.gearslip.ui
import android.content.ClipboardManager
import android.content.Context
import android.content.Intent
import android.provider.Settings
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Info
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.compose.LifecycleEventEffect
import app.seb3thehacker.gearslip.R
import app.seb3thehacker.gearslip.car.CarBluetooth
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
/**
* Asks for the phone's Bluetooth address on Home, once per car, after a car paired over
* Bluetooth connected without it. Android keeps the address from apps, so the card opens About
* phone for the driver to copy it, and when they come back offers what's on the clipboard.
*/
@Composable
internal fun BluetoothAddressCard(modifier: Modifier = Modifier) {
val context = LocalContext.current
val car by CarBluetooth.addressWanted.collectAsState()
// Off to About phone; on the way back, the clipboard is looked at once.
var waiting by rememberSaveable { mutableStateOf(false) }
var found by rememberSaveable { mutableStateOf<String?>(null) }
var typing by rememberSaveable { mutableStateOf<String?>(null) }
val scope = rememberCoroutineScope()
LifecycleEventEffect(Lifecycle.Event.ON_RESUME) {
if (!waiting) return@LifecycleEventEffect
waiting = false
// Android hands an app the clipboard only once its window has focus, a moment after resume.
scope.launch {
delay(CLIPBOARD_WAIT_MS)
val copied = copiedAddress(context)
if (copied != null) found = copied else typing = ""
}
}
found?.let { address ->
AlertDialog(
onDismissRequest = { found = null },
title = { Text(stringResource(R.string.bt_address_use_title)) },
text = {
Column {
Text(address, style = MaterialTheme.typography.headlineSmall, modifier = Modifier.padding(bottom = 12.dp))
Text(stringResource(R.string.bt_address_use_body))
}
},
confirmButton = {
TextButton(onClick = { CarBluetooth.setTypedAddress(context, address); found = null }) { Text(stringResource(R.string.save)) }
},
dismissButton = { TextButton(onClick = { typing = address; found = null }) { Text(stringResource(R.string.bt_address_type)) } },
)
}
typing?.let { initial ->
AddressDialog(initial, onDismiss = { typing = null }) { typed ->
CarBluetooth.setTypedAddress(context, typed).also { if (it) typing = null }
}
}
val name = car ?: return
if (CarBluetooth.typedAddress(context) != null) return
val scheme = MaterialTheme.colorScheme
Column(modifier) {
Spacer(Modifier.height(16.dp))
Surface(
color = scheme.secondaryContainer,
contentColor = scheme.onSecondaryContainer,
shape = MaterialTheme.shapes.extraLarge,
) {
Column(Modifier.fillMaxWidth().padding(20.dp)) {
Row(verticalAlignment = Alignment.CenterVertically) {
Icon(Icons.Filled.Info, contentDescription = null)
Text(
stringResource(R.string.bt_address_card_title, name),
style = MaterialTheme.typography.titleLarge,
fontWeight = FontWeight.SemiBold,
modifier = Modifier.padding(start = 12.dp),
)
}
Text(
stringResource(R.string.bt_address_card_body),
style = MaterialTheme.typography.bodyMedium,
modifier = Modifier.padding(top = 8.dp),
)
Row(
Modifier.fillMaxWidth().padding(top = 12.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp, Alignment.End),
) {
TextButton(onClick = { CarBluetooth.addressAsked(context) }) { Text(stringResource(R.string.bt_address_not_now)) }
Button(onClick = {
waiting = true
runCatching {
context.startActivity(Intent(Settings.ACTION_DEVICE_INFO_SETTINGS).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK))
}.onFailure { waiting = false; typing = "" }
}) { Text(stringResource(R.string.bt_address_open)) }
}
}
}
}
}
/** A Bluetooth address on the clipboard, tidied up; null when there's none. */
private fun copiedAddress(context: Context): String? {
val clip = runCatching { context.getSystemService(ClipboardManager::class.java)?.primaryClip }.getOrNull() ?: return null
if (clip.itemCount == 0) return null
val text = clip.getItemAt(0).coerceToText(context)?.toString() ?: return null
return ADDRESS.find(text)?.value?.uppercase()?.replace('-', ':')
}
private val ADDRESS = Regex("""[0-9A-Fa-f]{2}([:-][0-9A-Fa-f]{2}){5}""")
private const val CLIPBOARD_WAIT_MS = 400L
@@ -0,0 +1,264 @@
package app.seb3thehacker.gearslip.ui
import android.text.format.DateUtils
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.Delete
import androidx.compose.material.icons.filled.Refresh
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Checkbox
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Switch
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.unit.dp
import app.seb3thehacker.gearslip.R
import app.seb3thehacker.gearslip.browser.BrowserSettings
import app.seb3thehacker.gearslip.browser.SearchEngine
import app.seb3thehacker.gearslip.browser.adblock.AdBlockManager
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
/**
* The phone's settings for the car's browser (issue #85): its bookmarks, where searches go, and
* the ad blocker with its filter lists. Typing addresses is easier here than on the car screen.
*/
@Composable
internal fun BrowserSettingsRows(webPageRow: @Composable () -> Unit) {
val context = LocalContext.current
LaunchedEffect(Unit) { AdBlockManager.ensureInitialized(context) }
val bookmarks by BrowserSettings.bookmarks.collectAsState()
val engine by BrowserSettings.searchEngine.collectAsState()
val adBlock by BrowserSettings.adBlock.collectAsState()
val youTube by BrowserSettings.youTubeAdBlock.collectAsState()
val lists by BrowserSettings.filterLists.collectAsState()
var addingBookmark by remember { mutableStateOf(false) }
var addingList by remember { mutableStateOf(false) }
webPageRow()
SettingsDivider()
SearchEngineRow(engine)
SettingsDivider()
Text(
stringResource(R.string.browser_bookmarks),
style = MaterialTheme.typography.titleSmall,
color = MaterialTheme.colorScheme.primary,
modifier = Modifier.padding(start = 16.dp, end = 16.dp, top = 12.dp),
)
bookmarks.forEach { bookmark ->
SettingsRow(
bookmark.title,
subtitle = bookmark.host,
trailing = {
IconButton({ BrowserSettings.removeBookmark(bookmark.url) }) {
Icon(Icons.Filled.Delete, stringResource(R.string.web_remove_bookmark, bookmark.title))
}
},
)
}
SettingsRow(
stringResource(R.string.browser_add_bookmark),
Modifier.clickable { addingBookmark = true },
subtitle = stringResource(R.string.browser_bookmarks_note),
icon = Icons.Filled.Add,
)
SettingsDivider()
SwitchRow(stringResource(R.string.browser_adblock), adBlock, BrowserSettings::setAdBlock, stringResource(R.string.browser_adblock_note))
if (adBlock) {
SettingsDivider()
SwitchRow(stringResource(R.string.browser_youtube), youTube, BrowserSettings::setYouTubeAdBlock, stringResource(R.string.browser_youtube_note))
SettingsDivider()
Text(
stringResource(R.string.browser_filter_lists),
style = MaterialTheme.typography.titleSmall,
color = MaterialTheme.colorScheme.primary,
modifier = Modifier.padding(start = 16.dp, end = 16.dp, top = 12.dp),
)
lists.forEach { list ->
SettingsRow(
list.title,
Modifier.clickable {
BrowserSettings.setListEnabled(list.id, !list.enabled)
AdBlockManager.reload(context)
},
subtitle = if (list.builtIn) null else list.url,
leading = { Checkbox(list.enabled, onCheckedChange = null) },
trailing = if (list.builtIn) null else {
{
IconButton({
AdBlockManager.forget(context, list)
BrowserSettings.removeCustomList(list.id)
AdBlockManager.reload(context)
}) { Icon(Icons.Filled.Delete, stringResource(R.string.browser_remove)) }
}
},
)
}
SettingsRow(stringResource(R.string.browser_add_list), Modifier.clickable { addingList = true }, icon = Icons.Filled.Add)
UpdateListsRow()
}
if (addingBookmark) {
BookmarkDialog(onDismiss = { addingBookmark = false }) { title, url ->
BrowserSettings.addBookmark(title, url).also { if (it) addingBookmark = false }
}
}
if (addingList) {
AddressDialog(
title = stringResource(R.string.browser_add_list),
label = stringResource(R.string.browser_list_address),
note = stringResource(R.string.browser_list_note),
onDismiss = { addingList = false },
) { url ->
BrowserSettings.addCustomList(url).also { if (it) addingList = false }
}
}
}
@Composable
private fun SearchEngineRow(engine: SearchEngine) {
var menu by remember { mutableStateOf(false) }
SettingsRow(
stringResource(R.string.browser_search_engine),
Modifier.clickable { menu = true },
trailing = {
Box {
Text(engine.label, style = MaterialTheme.typography.bodyLarge, color = MaterialTheme.colorScheme.primary)
DropdownMenu(menu, onDismissRequest = { menu = false }) {
SearchEngine.entries.forEach { choice ->
DropdownMenuItem(
text = { Text(choice.label, fontWeight = if (choice == engine) FontWeight.Bold else FontWeight.Normal) },
onClick = {
BrowserSettings.setSearchEngine(choice)
menu = false
},
)
}
}
}
},
)
}
/** How many rules are loaded and when the lists last came down, with a button to fetch them now. */
@Composable
private fun UpdateListsRow() {
val context = LocalContext.current
val scope = rememberCoroutineScope()
var updating by remember { mutableStateOf(false) }
var error by remember { mutableStateOf<String?>(null) }
// Bumped after an update, so the counts and the date are read again.
var refreshed by remember { mutableIntStateOf(0) }
val rules = remember(refreshed, updating) { AdBlockManager.networkRuleCount + AdBlockManager.cosmeticRuleCount }
val updatedAt = remember(refreshed, updating) { AdBlockManager.lastUpdatedAt(context) }
val status = when {
updating -> stringResource(R.string.browser_updating)
error != null -> stringResource(R.string.browser_update_failed, error.orEmpty())
updatedAt > 0 -> stringResource(R.string.browser_lists_status, rules, DateUtils.getRelativeTimeSpanString(updatedAt).toString())
else -> stringResource(R.string.browser_lists_never, rules)
}
SettingsRow(
stringResource(R.string.browser_update_lists),
Modifier.clickable(enabled = !updating) {
updating = true
error = null
scope.launch {
val result = withContext(Dispatchers.IO) { AdBlockManager.refreshLists(context) }
error = (result as? AdBlockManager.RefreshResult.Failure)?.message
updating = false
refreshed++
}
},
subtitle = status,
icon = Icons.Filled.Refresh,
)
}
@Composable
private fun SwitchRow(title: String, on: Boolean, onChange: (Boolean) -> Unit, note: String) {
SettingsRow(title, Modifier.clickable { onChange(!on) }, subtitle = note, trailing = { Switch(checked = on, onCheckedChange = null) })
}
@Composable
private fun BookmarkDialog(onDismiss: () -> Unit, onSave: (String, String) -> Boolean) {
var title by remember { mutableStateOf("") }
var url by remember { mutableStateOf("https://") }
var invalid by remember { mutableStateOf(false) }
AlertDialog(
onDismissRequest = onDismiss,
title = { Text(stringResource(R.string.browser_add_bookmark)) },
text = {
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
OutlinedTextField(title, { title = it }, label = { Text(stringResource(R.string.browser_bookmark_name)) }, singleLine = true)
OutlinedTextField(
url, { url = it; invalid = false },
label = { Text(stringResource(R.string.browser_bookmark_address)) },
singleLine = true,
isError = invalid,
supportingText = if (invalid) ({ Text(stringResource(R.string.browser_shared_not_url)) }) else null,
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Uri),
)
}
},
confirmButton = { TextButton({ invalid = !onSave(title, withScheme(url)) }) { Text(stringResource(R.string.save)) } },
dismissButton = { TextButton(onDismiss) { Text(stringResource(R.string.cancel)) } },
)
}
@Composable
private fun AddressDialog(title: String, label: String, note: String, onDismiss: () -> Unit, onSave: (String) -> Boolean) {
var url by remember { mutableStateOf("https://") }
var invalid by remember { mutableStateOf(false) }
AlertDialog(
onDismissRequest = onDismiss,
title = { Text(title) },
text = {
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
Text(note)
OutlinedTextField(
url, { url = it; invalid = false },
label = { Text(label) },
singleLine = true,
isError = invalid,
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Uri),
)
}
},
confirmButton = { TextButton({ invalid = !onSave(withScheme(url)) }) { Text(stringResource(R.string.save)) } },
dismissButton = { TextButton(onDismiss) { Text(stringResource(R.string.cancel)) } },
)
}
/** "example.com" is what someone typing an address on a phone meant. */
private fun withScheme(text: String): String {
val t = text.trim()
return if ("://" in t || t.isEmpty()) t else "https://$t"
}
@@ -72,6 +72,11 @@ fun CarPreviewScreen(onBack: () -> Unit) {
// The preview is something to watch, like a map on a dash mount, so the phone stays awake on
// it. Only here: the flag goes with this screen, and every other screen sleeps as usual.
val view = androidx.compose.ui.platform.LocalView.current
// Set during composition, before the car UI below warms the forecast.
remember { app.seb3thehacker.gearslip.car.Weather.previewOpen = true }
DisposableEffect(Unit) {
onDispose { app.seb3thehacker.gearslip.car.Weather.previewOpen = false }
}
DisposableEffect(view) {
view.keepScreenOn = true
onDispose { view.keepScreenOn = false }
@@ -226,11 +231,6 @@ private fun Fullscreen(
onDispose { insets.show(WindowInsetsCompat.Type.systemBars()) }
}
// Turning the phone back upright is what normally leaves fullscreen; back still leaves the
// preview screen entirely rather than doing nothing, in case the phone's own rotation lock
// is on and turning it sideways/back isn't an option.
BackHandler(onBack = onBack)
androidx.compose.foundation.layout.BoxWithConstraints(
Modifier.fillMaxSize().background(Color.Black),
contentAlignment = Alignment.Center,
@@ -249,6 +249,14 @@ private fun Fullscreen(
maxHeight - frameHeight >= room * 2 -> Alignment.TopStart
else -> null
}
// Back steps back inside the car UI, as it does in a car, so one press no longer ends
// the preview and the drive's route with it. Leaving is the button beside the frame or
// turning the phone upright. A frame with no room for that button keeps back as the
// way out, in case the phone's rotation lock is on.
BackHandler {
if (corner == null) onBack()
else app.seb3thehacker.gearslip.car.CarKeys.onKey(android.view.KeyEvent.KEYCODE_BACK, down = true, longPress = false)
}
if (corner != null) {
FilledTonalIconButton(
onClick = onBack,
@@ -72,8 +72,9 @@ fun GearslipApp(onDisconnect: () -> Unit, onRequestCallScreening: () -> Unit) {
var carGroup by rememberSaveable { mutableStateOf(CarGroup.SCREEN) }
BackHandler(enabled = screen != Screen.HOME) {
screen = when (screen) {
Screen.RECOMMENDED_APPS, Screen.LOGS, Screen.CAR_PREVIEW -> Screen.SETTINGS
Screen.HELP, Screen.WHATS_NEW -> backTo
Screen.RECOMMENDED_APPS -> Screen.SETTINGS
// The same place their own back arrows go: Home or Settings, whichever opened them.
Screen.HELP, Screen.WHATS_NEW, Screen.LOGS, Screen.CAR_PREVIEW -> backTo
else -> Screen.HOME
}
}
@@ -71,6 +71,8 @@ private fun inTheCar(grapheneOs: Boolean, onRequestCallScreening: () -> Unit) =
R.string.help_pair,
R.string.help_pair_detail,
),
// A Renault Clio V wouldn't take Bluetooth audio when the cable went in first (#74).
Step(R.string.help_bluetooth_first, R.string.help_bluetooth_first_detail),
Step(
R.string.help_answer,
R.string.help_answer_detail,
@@ -1,5 +1,7 @@
package app.seb3thehacker.gearslip.ui
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.offset
import androidx.annotation.StringRes
import androidx.compose.ui.res.stringResource
import app.seb3thehacker.gearslip.R
@@ -21,6 +23,17 @@ import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Checkbox
import androidx.core.graphics.drawable.toBitmap
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.Delete
import androidx.compose.ui.graphics.asImageBitmap
import kotlinx.coroutines.launch
import androidx.compose.foundation.layout.heightIn
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material3.TextButton
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.Icon
@@ -59,14 +72,51 @@ enum class CarGroup(@param:StringRes val title: Int) {
NAV_BAR(R.string.car_group_nav_bar),
SOUND(R.string.car_group_sound),
PLACES(R.string.places_title),
BROWSER(R.string.app_browser),
WARNINGS(R.string.car_group_warnings),
MORE(R.string.car_group_more),
}
@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))
/** Marks the menu's Custom entry, which opens [CustomDensityDialog] instead of choosing. */
private const val DENSITY_CUSTOM = -1
/** The presets, a typed-in value when that's what's set, and Custom to type one. */
@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) }
private fun densityChoices(current: Int): List<Pair<Int, String>> {
val values = CarSettings.DENSITIES.let { if (current != CarSettings.DENSITY_FROM_CAR && current !in it) (it + current).sorted() else it }
return listOf(CarSettings.DENSITY_FROM_CAR to stringResource(R.string.choice_density_car)) +
values.map { it to stringResource(R.string.choice_density_dpi, it) } +
(DENSITY_CUSTOM to stringResource(R.string.choice_density_custom))
}
/** Types in any density in [CarSettings.CUSTOM_DENSITIES] (issue #82). */
@Composable
private fun CustomDensityDialog(current: Int, onDismiss: () -> Unit, onSave: (Int) -> Unit) {
var text by remember { mutableStateOf(current.takeIf { it != CarSettings.DENSITY_FROM_CAR }?.toString().orEmpty()) }
val value = text.toIntOrNull()?.takeIf { it in CarSettings.CUSTOM_DENSITIES }
val range = CarSettings.CUSTOM_DENSITIES
AlertDialog(
onDismissRequest = onDismiss,
title = { Text(stringResource(R.string.density_custom_title)) },
text = {
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
Text(stringResource(R.string.density_custom_note, range.first, range.last))
OutlinedTextField(
text, { text = it.filter(Char::isDigit).take(3) },
label = { Text(stringResource(R.string.density_custom_label)) },
singleLine = true,
isError = text.isNotEmpty() && value == null,
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
)
}
},
confirmButton = { TextButton(onClick = { value?.let(onSave) }, enabled = value != null) { Text(stringResource(R.string.save)) } },
dismissButton = { TextButton(onClick = onDismiss) { Text(stringResource(R.string.cancel)) } },
)
}
@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))
@Composable
@@ -110,6 +160,8 @@ internal fun HomeCarSettings(onOpen: (CarGroup) -> Unit, modifier: Modifier = Mo
val playerLeft by CarSettings.playerLeft.collectAsState()
val theme by CarSettings.appTheme.collectAsState()
val experimental by CarSettings.experimentalFeaturesEnabled.collectAsState()
val browserEngine by app.seb3thehacker.gearslip.browser.BrowserSettings.searchEngine.collectAsState()
val browserAdBlock by app.seb3thehacker.gearslip.browser.BrowserSettings.adBlock.collectAsState()
val opens = openChoices()
val sizes = sizeChoices()
@@ -139,8 +191,11 @@ internal fun HomeCarSettings(onOpen: (CarGroup) -> Unit, modifier: Modifier = Mo
str(if (bluetoothWarning && !pipeAudio) R.string.summary_bluetooth_on else R.string.summary_bluetooth_off),
str(if (locationWarning) R.string.summary_location_on else R.string.summary_location_off),
)
CarGroup.BROWSER -> listOf(
browserEngine.label,
str(if (browserAdBlock) R.string.summary_adblock_on else R.string.summary_adblock_off),
)
CarGroup.MORE -> listOf(
str(R.string.web_page),
str(if (experimental) R.string.summary_experimental_shown else R.string.summary_experimental_hidden),
)
}.filter { it.isNotEmpty() }.joinToString(" · ")
@@ -228,7 +283,7 @@ private fun GroupRows(group: CarGroup) {
val autoplay by CarSettings.autoplay.collectAsState()
val playerTitle by CarSettings.playerTitle.collectAsState()
val playerLeft by CarSettings.playerLeft.collectAsState()
val moreLyrics by CarSettings.moreLyrics.collectAsState()
val navMic by CarSettings.navMic.collectAsState()
val theme by CarSettings.appTheme.collectAsState()
val uiTheme by CarSettings.uiTheme.collectAsState()
val darkStyle by CarSettings.darkStyle.collectAsState()
@@ -244,7 +299,18 @@ private fun GroupRows(group: CarGroup) {
SettingsDivider()
Choice(stringResource(R.string.car_size), sizeChoices(), scale, CarSettings::setScale)
SettingsDivider()
Choice(stringResource(R.string.car_density), densityChoices(), density, CarSettings::setCarDensity, note = stringResource(R.string.car_density_note))
var customDensity by remember { mutableStateOf(false) }
Choice(
stringResource(R.string.car_density), densityChoices(density), density,
{ if (it == DENSITY_CUSTOM) customDensity = true else CarSettings.setCarDensity(it) },
note = stringResource(R.string.car_density_note),
)
if (customDensity) {
CustomDensityDialog(density, onDismiss = { customDensity = false }) {
CarSettings.setCarDensity(it)
customDensity = false
}
}
SettingsDivider()
Choice(stringResource(R.string.car_app_theme), themeChoices(), theme, CarSettings::setAppTheme)
SettingsDivider()
@@ -261,18 +327,13 @@ private fun GroupRows(group: CarGroup) {
CarGroup.NAV_BAR -> {
Choice(stringResource(R.string.car_nav_bar), navBarChoices(), navBar, CarSettings::setNavBarMode)
SettingsDivider()
Toggle(stringResource(R.string.car_nav_mic), navMic, CarSettings::setNavMic, note = stringResource(R.string.car_nav_mic_note))
SettingsDivider()
Choice(stringResource(R.string.car_player_nav_bar), listOf(false to stringResource(R.string.car_skip_buttons), true to stringResource(R.string.car_song_title)), playerTitle, CarSettings::setPlayerTitle)
SettingsDivider()
Choice(stringResource(R.string.car_player_beside_map), listOf(false to stringResource(R.string.car_right), true to stringResource(R.string.car_left)), playerLeft, CarSettings::setPlayerLeft)
Choice(stringResource(R.string.car_player_beside_map), listOf(false to stringResource(R.string.car_right), true to stringResource(R.string.car_left)), playerLeft, CarSettings::setPlayerLeft, note = stringResource(R.string.car_player_beside_map_note))
SettingsDivider()
Toggle(stringResource(R.string.car_autoplay), autoplay, CarSettings::setAutoplay)
SettingsDivider()
Toggle(
stringResource(R.string.car_more_lyrics),
moreLyrics,
CarSettings::setMoreLyrics,
note = stringResource(R.string.car_more_lyrics_note),
)
Toggle(stringResource(R.string.car_autoplay), autoplay, CarSettings::setAutoplay, note = stringResource(R.string.car_autoplay_note))
}
CarGroup.SOUND -> {
Toggle(
@@ -306,10 +367,9 @@ private fun GroupRows(group: CarGroup) {
}
Toggle(stringResource(R.string.car_warn_location), locationWarning, CarSettings::setLocationWarning)
}
CarGroup.BROWSER -> BrowserSettingsRows { WebPageRow() }
CarGroup.MORE -> {
WebPageRow()
SettingsDivider()
Toggle(stringResource(R.string.car_experimental), experimental, CarSettings::setExperimentalFeaturesEnabled)
Toggle(stringResource(R.string.car_experimental), experimental, CarSettings::setExperimentalFeaturesEnabled, note = stringResource(R.string.car_experimental_note))
if (experimental) {
SettingsDivider()
Toggle(
@@ -347,16 +407,144 @@ private fun AppRows() {
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))
val custom by app.seb3thehacker.gearslip.host.CustomCarApps.state.collectAsState()
val scope = androidx.compose.runtime.rememberCoroutineScope()
var adding by remember { mutableStateOf(false) }
fun rescan() = scope.launch { withContext(Dispatchers.IO) { LauncherCache.load(context, force = true) } }
rows.forEachIndexed { i, (id, entry) ->
val shown = id !in hidden
val pkg = entry.media?.component?.packageName ?: entry.messaging?.packageName
val added = pkg != null && custom.orEmpty().any { it.packageName == pkg }
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) },
subtitle = if (added) stringResource(R.string.car_apps_added_by_you) else null,
leading = {
Box {
entry.icon?.let { Image(it, contentDescription = null, Modifier.size(40.dp)) }
// The same red X the car shows, so it's clear why the app starts unticked.
if (app.seb3thehacker.gearslip.car.markedBroken(context, entry)) {
app.seb3thehacker.gearslip.car.BrokenBadge(
16.dp,
Modifier.align(Alignment.BottomEnd)
.offset(x = 4.dp, y = 3.dp)
.border(2.dp, MaterialTheme.colorScheme.surfaceContainerHigh, androidx.compose.foundation.shape.CircleShape),
)
}
}
},
trailing = {
Row(verticalAlignment = Alignment.CenterVertically) {
if (added && pkg != null) {
androidx.compose.material3.IconButton({
app.seb3thehacker.gearslip.host.CustomCarApps.remove(context, pkg)
rescan()
}) { Icon(androidx.compose.material.icons.Icons.Filled.Delete, stringResource(R.string.car_apps_remove, entry.label)) }
}
Checkbox(checked = shown, onCheckedChange = null)
}
},
)
}
if (apps != null) {
SettingsDivider()
SettingsRow(
stringResource(R.string.car_apps_add),
Modifier.clickable { adding = true },
subtitle = stringResource(R.string.car_apps_add_note),
icon = androidx.compose.material.icons.Icons.Filled.Add,
)
}
if (adding) {
AddCarAppDialog(
listed = rows.mapNotNull { (_, e) -> e.media?.component?.packageName ?: e.messaging?.packageName ?: e.template?.component?.packageName }.toSet(),
onDismiss = { adding = false },
) { pkg, kind ->
app.seb3thehacker.gearslip.host.CustomCarApps.add(context, pkg, kind)
adding = false
rescan()
}
}
}
/**
* Picks an installed app the car doesn't list, then whether it's music or messages (issue #84).
* Music plays through Gearslip's player from the app's playback session; messages show from its
* notifications. Neither needs the app to support Android Auto.
*/
@Composable
private fun AddCarAppDialog(
listed: Set<String>,
onDismiss: () -> Unit,
onAdd: (String, app.seb3thehacker.gearslip.host.CustomCarApps.Kind) -> Unit,
) {
val context = androidx.compose.ui.platform.LocalContext.current
var picked by remember { mutableStateOf<Pair<String, String>?>(null) }
val candidates by androidx.compose.runtime.produceState<List<Triple<String, String, androidx.compose.ui.graphics.ImageBitmap?>>?>(null) {
value = withContext(Dispatchers.IO) {
val manager = context.packageManager
manager.queryIntentActivities(android.content.Intent(android.content.Intent.ACTION_MAIN).addCategory(android.content.Intent.CATEGORY_LAUNCHER), 0)
.map { it.activityInfo.packageName to it.loadLabel(manager).toString() }
.distinctBy { it.first }
.filter { (pkg, _) -> pkg != context.packageName && pkg !in listed }
.sortedBy { it.second.lowercase() }
.map { (pkg, label) ->
val icon = runCatching { manager.getApplicationIcon(pkg).toBitmap(96, 96).asImageBitmap() }.getOrNull()
Triple(pkg, label, icon)
}
}
}
androidx.compose.material3.AlertDialog(
onDismissRequest = onDismiss,
title = { Text(picked?.let { stringResource(R.string.car_apps_add_as, it.second) } ?: stringResource(R.string.car_apps_add_pick)) },
text = {
val choice = picked
if (choice == null) {
val list = candidates
if (list == null) {
Text(stringResource(R.string.car_apps_finding))
} else {
androidx.compose.foundation.lazy.LazyColumn(Modifier.heightIn(max = 420.dp)) {
items(list.size) { i ->
val (pkg, label, icon) = list[i]
Row(
Modifier.fillMaxWidth().clickable { picked = pkg to label }.padding(vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(16.dp),
) {
icon?.let { Image(it, contentDescription = null, Modifier.size(36.dp)) }
Text(label, style = MaterialTheme.typography.bodyLarge)
}
}
}
}
} else {
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
KindRow(stringResource(R.string.car_apps_as_media), stringResource(R.string.car_apps_as_media_note)) {
onAdd(choice.first, app.seb3thehacker.gearslip.host.CustomCarApps.Kind.MEDIA)
}
KindRow(stringResource(R.string.car_apps_as_messaging), stringResource(R.string.car_apps_as_messaging_note)) {
onAdd(choice.first, app.seb3thehacker.gearslip.host.CustomCarApps.Kind.MESSAGING)
}
}
}
},
confirmButton = {},
dismissButton = {
androidx.compose.material3.TextButton({ if (picked != null) picked = null else onDismiss() }) {
Text(stringResource(if (picked != null) R.string.back else R.string.cancel))
}
},
)
}
@Composable
private fun KindRow(title: String, note: String, onClick: () -> Unit) {
Column(Modifier.fillMaxWidth().clickable(onClick = onClick).padding(vertical = 12.dp)) {
Text(title, style = MaterialTheme.typography.bodyLarge, color = MaterialTheme.colorScheme.primary)
Text(note, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant)
}
}
private fun <T> List<Pair<T, String>>.label(value: T): String = firstOrNull { it.first == value }?.second.orEmpty()
@@ -57,6 +57,8 @@ import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.collectAsState
import androidx.compose.material3.SegmentedButton
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
@@ -108,6 +110,8 @@ fun HomeScreen(
Spacer(Modifier.height(16.dp))
StatusCard(status)
if (live) BluetoothMissingCard()
if (live) BackgroundLocationCard()
BluetoothAddressCard()
VoiceDownloadCard()
// The first drive that worked asks once to share it, right under the result.
val car = workedCar
@@ -234,6 +238,60 @@ private fun BluetoothMissingCard() {
}
}
/**
* Asks for "Allow all the time" location, shown only while a car is connected (issue #99).
* Without it, a car that connects with the phone locked leaves the car's map without GPS
* until Gearslip is opened. Once granted, the location service starts at once.
*/
@Composable
private fun BackgroundLocationCard() {
val context = LocalContext.current
var allowed by remember { mutableStateOf(app.seb3thehacker.gearslip.host.LocationKeepAlive.allowedInBackground(context)) }
var dismissed by remember { mutableStateOf(false) }
if (allowed || dismissed) return
fun recheck() {
allowed = app.seb3thehacker.gearslip.host.LocationKeepAlive.allowedInBackground(context)
if (allowed) app.seb3thehacker.gearslip.host.LocationKeepAlive.retry(context)
}
// On Android 11 and later this opens the app's location settings, where "Allow all the time" is.
val askBackground = androidx.activity.compose.rememberLauncherForActivityResult(
androidx.activity.result.contract.ActivityResultContracts.RequestPermission(),
) { recheck() }
// Android only offers all the time once the ordinary location permission is granted.
val askForeground = androidx.activity.compose.rememberLauncherForActivityResult(
androidx.activity.result.contract.ActivityResultContracts.RequestMultiplePermissions(),
) { result ->
if (result[android.Manifest.permission.ACCESS_FINE_LOCATION] == true) {
askBackground.launch(android.Manifest.permission.ACCESS_BACKGROUND_LOCATION)
}
}
val dark = gearslipDark()
val container = if (dark) Color(0xFF4A3B00) else Color(0xFFFFE08A)
val content = if (dark) Color(0xFFFFE08A) else Color(0xFF3A2E00)
Spacer(Modifier.height(16.dp))
Surface(color = container, contentColor = content, shape = MaterialTheme.shapes.extraLarge) {
Column(Modifier.fillMaxWidth().padding(20.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
Text(stringResource(R.string.home_location_title), style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.SemiBold)
Text(stringResource(R.string.home_location_body), style = MaterialTheme.typography.bodyMedium)
Row(Modifier.padding(top = 4.dp), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
Button(
colors = ButtonDefaults.buttonColors(containerColor = content, contentColor = container),
onClick = {
val fine = context.checkSelfPermission(android.Manifest.permission.ACCESS_FINE_LOCATION) ==
android.content.pm.PackageManager.PERMISSION_GRANTED
if (fine) askBackground.launch(android.Manifest.permission.ACCESS_BACKGROUND_LOCATION)
else askForeground.launch(arrayOf(android.Manifest.permission.ACCESS_FINE_LOCATION, android.Manifest.permission.ACCESS_COARSE_LOCATION))
},
) { Text(stringResource(R.string.home_location_allow)) }
androidx.compose.material3.TextButton(
colors = ButtonDefaults.textButtonColors(contentColor = content),
onClick = { dismissed = true },
) { Text(stringResource(R.string.home_location_not_now)) }
}
}
}
}
/** While the voice models download, how far along they are; nothing otherwise. */
@Composable
private fun VoiceDownloadCard() {
@@ -9,6 +9,7 @@ import android.graphics.drawable.Drawable
import android.net.Uri
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
@@ -34,7 +35,10 @@ import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
@@ -105,32 +109,60 @@ fun RecommendedAppsScreen(onBack: () -> Unit) {
internal const val SHOW_RECOMMENDED_APPS = false
/** The same green as the launcher's "works" check. */
private val INSTALLED_GREEN = Color(0xFF43A047)
internal val INSTALLED_GREEN = Color(0xFF43A047)
/** The apps by section, each with a GitHub button. Installed ones say so in green. */
/**
* The apps by section, each with a GitHub button; tapping the rest of a row opens the app's page.
* Installed ones say so in green. Installed apps that declare the Works with Gearslip flag come
* first, under their own heading, unless the list already has them.
*/
@Composable
internal fun RecommendedAppsList(modifier: Modifier = Modifier) {
val context = LocalContext.current
var page by remember { mutableStateOf<PageApp?>(null) }
val declared = remember {
val listed = recommended.flatMap { (_, apps) -> apps.map { it.packageName } }.toSet()
WorksWith.installed(context).filter { it.packageName !in listed }
}
Column(modifier, verticalArrangement = Arrangement.spacedBy(20.dp)) {
for ((section, apps) in recommended) {
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
Text(
stringResource(section),
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold,
color = MaterialTheme.colorScheme.primary,
)
for (app in apps) AppRow(app) { openPage(context, app.url) }
if (declared.isNotEmpty()) {
Section(stringResource(R.string.works_with_gearslip)) {
for (app in declared) {
AppRow(app.label, app.packageName, app.about, onPage = { page = PageApp(app.label, app.packageName, null, app.about) }, onGitHub = null)
}
}
}
for ((section, apps) in recommended) {
Section(stringResource(section)) {
for (app in apps) {
val text = stringResource(app.about)
AppRow(app.name, app.packageName, text, onPage = { page = PageApp(app.name, app.packageName, app.url, text) }, onGitHub = { openPage(context, app.url) })
}
}
}
}
page?.let { AppPage(it, onDismiss = { page = null }) }
}
@Composable
private fun Section(title: String, content: @Composable () -> Unit) {
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
Text(
title,
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold,
color = MaterialTheme.colorScheme.primary,
)
content()
}
}
@Composable
private fun AppRow(app: RecommendedApp, onOpen: () -> Unit) {
private fun AppRow(name: String, packageName: String, about: String?, onPage: () -> Unit, onGitHub: (() -> Unit)?) {
val context = LocalContext.current
val icon = remember(app.packageName) { installedIcon(context, app.packageName) }
Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
val icon = remember(packageName) { installedIcon(context, packageName) }
val declares = remember(packageName) { WorksWith.declares(context.packageManager, packageName) }
Row(Modifier.fillMaxWidth().clip(MaterialTheme.shapes.medium).clickable(onClick = onPage), verticalAlignment = Alignment.CenterVertically) {
Box(
Modifier.size(44.dp).clip(CircleShape).background(MaterialTheme.colorScheme.surfaceContainerHigh),
contentAlignment = Alignment.Center,
@@ -138,14 +170,15 @@ private fun AppRow(app: RecommendedApp, onOpen: () -> Unit) {
if (icon != null) {
Image(icon.toBitmap(96, 96).asImageBitmap(), contentDescription = null, Modifier.fillMaxSize())
} else {
Text(app.name.take(1), style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold, color = MaterialTheme.colorScheme.onSurface)
Text(name.take(1), style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold, color = MaterialTheme.colorScheme.onSurface)
}
}
Column(Modifier.weight(1f).padding(horizontal = 14.dp)) {
// Colours set outright: the setup guide draws this on its own background, with no
// Surface to hand down a content colour, and unset text came out dark on dark.
Text(app.name, style = MaterialTheme.typography.titleMedium, color = MaterialTheme.colorScheme.onSurface)
Text(stringResource(app.about), style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant)
Text(name, style = MaterialTheme.typography.titleMedium, color = MaterialTheme.colorScheme.onSurface)
if (about != null) Text(about, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant)
if (declares) WorksWithMark(Modifier.padding(top = 4.dp))
if (icon != null) {
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.padding(top = 4.dp)) {
Icon(Icons.Filled.CheckCircle, contentDescription = null, tint = INSTALLED_GREEN, modifier = Modifier.size(16.dp))
@@ -158,14 +191,14 @@ private fun AppRow(app: RecommendedApp, onOpen: () -> Unit) {
}
}
}
OutlinedButton(onClick = onOpen) { Text("GitHub") }
if (onGitHub != null) OutlinedButton(onClick = onGitHub) { Text("GitHub") }
}
}
private fun installedIcon(context: Context, packageName: String): Drawable? =
runCatching { context.packageManager.getApplicationIcon(packageName) }.getOrNull()
private fun openPage(context: Context, url: String) {
internal fun openPage(context: Context, url: String) {
runCatching {
context.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(url)).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK))
}
@@ -1,5 +1,10 @@
package app.seb3thehacker.gearslip.ui
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.semantics.selected
import androidx.compose.material.icons.filled.Create
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.res.vectorResource
import app.seb3thehacker.gearslip.R
@@ -248,6 +253,7 @@ private fun ConnectionSection() {
icon = Icons.Filled.Home,
trailing = { Switch(checked = launcher, onCheckedChange = null) },
)
CarAudioSetting()
var address by remember { mutableStateOf(CarBluetooth.typedAddress(context)) }
var editing by remember { mutableStateOf(false) }
SettingsRow(
@@ -273,9 +279,45 @@ private fun ConnectionSection() {
}
}
/**
* Where the car's sound comes from: Bluetooth, or through Gearslip over the car link. Takes
* effect at once when a car is connected. The chosen pill is filled in the main colour, the
* other tonal, like the buttons on Home.
*/
@Composable
private fun CarAudioSetting() {
val pipe by app.seb3thehacker.gearslip.car.CarSettings.pipeAudio.collectAsState()
val thisCar by app.seb3thehacker.gearslip.car.CarSettings.thisCarPipes.collectAsState()
val through = pipe || thisCar
val scheme = MaterialTheme.colorScheme
// Android won't let Gearslip capture navigation voices, only media, so they stay on the phone.
SettingsRow(
stringResource(R.string.car_audio),
subtitle = if (through) stringResource(R.string.home_audio_usb_warning) else null,
icon = app.seb3thehacker.gearslip.car.MediaIcons.MusicNote,
)
androidx.compose.foundation.layout.Row(
Modifier.fillMaxWidth().padding(start = 56.dp, end = 16.dp, bottom = 12.dp),
horizontalArrangement = androidx.compose.foundation.layout.Arrangement.spacedBy(8.dp),
) {
listOf(false to R.string.guide_audio_bluetooth, true to R.string.guide_audio_usb).forEach { (value, label) ->
val chosen = through == value
androidx.compose.material3.Button(
onClick = { app.seb3thehacker.gearslip.car.CarSettings.setPipeAudio(value) },
colors = androidx.compose.material3.ButtonDefaults.buttonColors(
containerColor = if (chosen) scheme.primary else scheme.secondaryContainer,
contentColor = if (chosen) scheme.onPrimary else scheme.onSecondaryContainer,
),
contentPadding = androidx.compose.foundation.layout.PaddingValues(horizontal = 12.dp),
modifier = Modifier.weight(1f).height(48.dp).semantics { selected = chosen },
) { Text(stringResource(label), maxLines = 1) }
}
}
}
/** 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) {
internal fun AddressDialog(initial: String, onDismiss: () -> Unit, onSave: (String) -> Boolean) {
var text by remember { mutableStateOf(initial) }
var invalid by remember { mutableStateOf(false) }
AlertDialog(
@@ -304,11 +346,24 @@ private fun DebugSection() {
SettingsSection("Debug build") {
SettingsRow(
"Send a test message",
Modifier.clickable { TestMessage.post(context) },
subtitle = "Posts a message notification to read on the car",
// After a few seconds, so there's time to open the car preview and watch it arrive.
Modifier.clickable {
android.os.Handler(android.os.Looper.getMainLooper()).postDelayed({ TestMessage.post(context.applicationContext) }, 5_000)
},
subtitle = "Posts a message notification in 5 seconds, time to open the car preview",
icon = Icons.AutoMirrored.Filled.Send,
)
SettingsDivider()
SettingsRow(
"Ask for the Bluetooth address",
Modifier.clickable {
CarBluetooth.setTypedAddress(context, "")
CarBluetooth.askForAddressNow(context)
},
subtitle = "Clears the saved address and shows Home's card",
icon = Icons.Filled.Info,
)
SettingsDivider()
val badge by CarSettings.debugBadge.collectAsState()
SettingsRow(
"Debug badge on the car screen",
@@ -346,6 +401,8 @@ private fun CertificateSection() {
var notice by remember { mutableStateOf<String?>(null) }
var downloading by remember { mutableStateOf(false) }
var detailsOpen by remember { mutableStateOf(false) }
var linkDialog by remember { mutableStateOf<LinkRequest?>(null) }
val savedLink = remember(refresh) { app.seb3thehacker.gearslip.CertFromLink.savedLink(context) }
val picker = rememberLauncherForActivityResult(ActivityResultContracts.OpenDocument()) { uri ->
if (uri != null) pendingUri = uri
}
@@ -403,6 +460,22 @@ private fun CertificateSection() {
icon = Icons.Filled.Refresh,
)
SettingsDivider()
SettingsRow(
stringResource(R.string.cert_link),
Modifier.clickable { linkDialog = LinkRequest() },
subtitle = stringResource(R.string.cert_link_sub),
icon = Icons.Filled.Create,
)
savedLink?.let { (cert, key) ->
SettingsDivider()
SettingsRow(
stringResource(R.string.cert_link_again),
Modifier.clickable { linkDialog = LinkRequest(cert, key, start = true) },
subtitle = cert.substringAfter("://").substringBefore('/'),
icon = Icons.Filled.Refresh,
)
}
SettingsDivider()
SettingsRow(
stringResource(R.string.cert_import),
Modifier.clickable { picker.launch(arrayOf("*/*")) },
@@ -430,6 +503,14 @@ private fun CertificateSection() {
}
}
linkDialog?.let { request ->
LinkDialog(request, onDismiss = { linkDialog = null }) {
linkDialog = null
notice = context.getString(R.string.cert_link_done)
refresh++
}
}
pendingUri?.let { uri ->
PasswordDialog(
onDismiss = { pendingUri = null },
@@ -451,6 +532,75 @@ private fun CertificateSection() {
}
}
/** What the link dialog opens with: blank, or the saved link to fetch again straight away. */
private class LinkRequest(val cert: String = "https://", val key: String? = null, val start: Boolean = false)
/**
* Fetches a certificate from a link (issue #15). It asks for more only when the file needs it:
* the password of a .p12, or the link to a key that came separately.
*/
@Composable
private fun LinkDialog(request: LinkRequest, onDismiss: () -> Unit, onDone: () -> Unit) {
val context = LocalContext.current
val scope = rememberCoroutineScope()
var cert by remember { mutableStateOf(request.cert) }
var key by remember { mutableStateOf(request.key) }
var password by remember { mutableStateOf<String?>(null) }
var error by remember { mutableStateOf<String?>(null) }
var busy by remember { mutableStateOf(false) }
fun go() {
busy = true
error = null
scope.launch {
val result = withContext(Dispatchers.IO) {
runCatching { app.seb3thehacker.gearslip.CertFromLink.import(context, cert, key?.takeIf { it.isNotBlank() }, password) }
}
busy = false
result.onSuccess { outcome ->
when (outcome) {
app.seb3thehacker.gearslip.CertFromLink.Outcome.Done -> onDone()
app.seb3thehacker.gearslip.CertFromLink.Outcome.NeedsKey -> if (key == null) key = "https://" else error = context.getString(R.string.cert_link_key_note)
app.seb3thehacker.gearslip.CertFromLink.Outcome.NeedsPassword -> if (password == null) password = "" else error = context.getString(R.string.cert_import_failed)
}
}.onFailure { error = context.getString(R.string.cert_link_failed, it.message.orEmpty()) }
}
}
LaunchedEffect(request) { if (request.start) go() }
AlertDialog(
onDismissRequest = onDismiss,
title = { Text(stringResource(R.string.cert_link_title)) },
text = {
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
Text(stringResource(R.string.cert_link_note))
OutlinedTextField(
cert, { cert = it }, label = { Text(stringResource(R.string.cert_link_label)) }, singleLine = true,
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Uri),
)
key?.let { k ->
Text(stringResource(R.string.cert_link_key_note), style = MaterialTheme.typography.bodySmall)
OutlinedTextField(
k, { key = it }, label = { Text(stringResource(R.string.cert_link_key)) }, singleLine = true,
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Uri),
)
}
password?.let { p ->
Text(stringResource(R.string.cert_link_password_note), style = MaterialTheme.typography.bodySmall)
OutlinedTextField(
p, { password = it }, label = { Text(stringResource(R.string.cert_link_password)) }, singleLine = true,
visualTransformation = androidx.compose.ui.text.input.PasswordVisualTransformation(),
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password),
)
}
error?.let { Text(it, color = MaterialTheme.colorScheme.error, style = MaterialTheme.typography.bodySmall) }
}
},
confirmButton = {
TextButton(::go, enabled = !busy) { Text(stringResource(if (busy) R.string.cert_downloading else R.string.cert_link_download)) }
},
dismissButton = { TextButton(onDismiss) { Text(stringResource(R.string.cancel)) } },
)
}
@Composable
private fun Detail(label: String, value: String) {
Column {
@@ -171,6 +171,7 @@ private fun UpdateDialog(
) {
val context = LocalContext.current
var auto by remember { mutableStateOf(UpdateChecker.autoCheckEnabled(context)) }
var silent by remember { mutableStateOf(UpdateChecker.silentEnabled(context)) }
val actionLabels = listOf(R.string.update_download, R.string.update_install, R.string.update_try_again, R.string.update_check_now).map { stringResource(it) }
val action: Pair<String, () -> Unit>? = when (state) {
State.Checking, is State.Downloading -> null
@@ -230,6 +231,29 @@ private fun UpdateDialog(
}
Switch(checked = auto, onCheckedChange = null)
}
// Silent installs ride on the automatic check, so the switch only shows with it on.
if (auto && !BuildConfig.DEBUG) {
Row(
Modifier
.fillMaxWidth()
.toggleable(silent, role = Role.Switch) {
silent = it
UpdateChecker.setSilentEnabled(context, it)
}
.padding(vertical = 4.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Column(Modifier.weight(1f)) {
Text(stringResource(R.string.update_silent), style = MaterialTheme.typography.bodyLarge)
Text(
stringResource(R.string.update_silent_sub),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
Switch(checked = silent, onCheckedChange = null)
}
}
}
},
confirmButton = {
@@ -21,6 +21,7 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.selection.toggleable
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight
import androidx.compose.material.icons.filled.Lock
import androidx.compose.material.icons.filled.Check
import androidx.compose.material.icons.filled.Info
import androidx.compose.material.icons.filled.KeyboardArrowDown
@@ -128,16 +129,27 @@ internal fun ColumnScope.UsageStatsRows() {
shape = MaterialTheme.shapes.medium,
modifier = Modifier.fillMaxWidth().padding(start = 16.dp, end = 16.dp, bottom = 16.dp),
) {
Text(
note,
style = MaterialTheme.typography.bodySmall,
fontFamily = FontFamily.Monospace,
modifier = Modifier.padding(12.dp),
)
// Selectable, so the phone's "id" can be copied into a request to see or delete its notes.
androidx.compose.foundation.text.selection.SelectionContainer {
Text(
note,
style = MaterialTheme.typography.bodySmall,
fontFamily = FontFamily.Monospace,
modifier = Modifier.padding(12.dp),
)
}
}
}
}
}
// Shown whether the notes are on or off, in setup and in Settings alike.
SettingsDivider()
SettingsRow(
stringResource(R.string.privacy_policy),
Modifier.clickable { GearslipLinks.open(context, GearslipLinks.PRIVACY) },
subtitle = stringResource(R.string.privacy_policy_sub),
icon = Icons.Filled.Lock,
)
}
/** A share choice: the label lines up with the rows' titles, the checkbox sits at the end. */
@@ -9,6 +9,8 @@ import android.provider.Settings
import android.bluetooth.BluetoothDevice
import android.content.pm.PackageManager
import android.os.Build
import android.content.Context
import android.net.wifi.WifiManager
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.clickable
@@ -57,6 +59,8 @@ internal fun WirelessButton() {
val context = LocalContext.current
var devices by remember { mutableStateOf<List<BluetoothDevice>?>(null) }
var denied by remember { mutableStateOf(false) }
var showWifiDialog by remember { mutableStateOf(false) }
fun granted() = WIRELESS_PERMISSIONS.all {
ContextCompat.checkSelfPermission(context, it) == PackageManager.PERMISSION_GRANTED
}
@@ -83,8 +87,13 @@ internal fun WirelessButton() {
}
Button(
onClick = {
denied = false
if (granted()) devices = WirelessLink.pairedDevices(context) else ask.launch(WIRELESS_PERMISSIONS)
val wifiManager = context.getSystemService(Context.WIFI_SERVICE) as WifiManager
if (!wifiManager.isWifiEnabled) {
showWifiDialog = true
} else {
denied = false
if (granted()) devices = WirelessLink.pairedDevices(context) else ask.launch(WIRELESS_PERMISSIONS)
}
},
modifier = Modifier.fillMaxWidth().height(64.dp),
) {
@@ -154,4 +163,20 @@ internal fun WirelessButton() {
confirmButton = { TextButton(onClick = { devices = null }) { Text(stringResource(R.string.cancel)) } },
)
}
// A wireless car hands over its own Wi-Fi network, which the phone can't join with Wi-Fi off.
if (showWifiDialog) {
AlertDialog(
onDismissRequest = { showWifiDialog = false },
title = { Text(stringResource(R.string.wireless_wifi_off_title)) },
text = { Text(stringResource(R.string.wireless_wifi_off_body)) },
confirmButton = {
TextButton(onClick = {
showWifiDialog = false
runCatching { context.startActivity(Intent(Settings.Panel.ACTION_WIFI)) }
}) { Text(stringResource(R.string.wireless_wifi_turn_on)) }
},
dismissButton = { TextButton(onClick = { showWifiDialog = false }) { Text(stringResource(R.string.cancel)) } },
)
}
}
@@ -0,0 +1,54 @@
package app.seb3thehacker.gearslip.ui
import android.content.Context
import android.content.Intent
import android.content.pm.PackageManager
import android.os.Bundle
/**
* Apps that say they work with Gearslip, by a line in their manifest's `<application>`:
*
* ```xml
* <meta-data android:name="app.seb3thehacker.gearslip.works_with" android:value="true" />
* <meta-data android:name="app.seb3thehacker.gearslip.about" android:resource="@string/gearslip_about" />
* ```
*
* The second line is optional: one sentence for the app's page in Gearslip. Gearslip already sees
* every app with a launcher icon (the manifest's MAIN/LAUNCHER query), so reading these needs
* nothing more.
*/
object WorksWith {
const val FLAG = "app.seb3thehacker.gearslip.works_with"
const val ABOUT = "app.seb3thehacker.gearslip.about"
class Declared(val packageName: String, val label: String, val about: String?)
private fun meta(manager: PackageManager, pkg: String): Bundle? =
runCatching { manager.getApplicationInfo(pkg, PackageManager.GET_META_DATA).metaData }.getOrNull()
fun declares(manager: PackageManager, pkg: String): Boolean = meta(manager, pkg)?.getBoolean(FLAG) == true
/** The app's own sentence about itself, from a string resource or written in place. */
fun about(manager: PackageManager, pkg: String): String? {
val meta = meta(manager, pkg) ?: return null
val res = meta.getInt(ABOUT)
if (res != 0) return runCatching { manager.getResourcesForApplication(pkg).getString(res) }.getOrNull()
return meta.getString(ABOUT)?.takeIf { it.isNotBlank() }
}
/** Every installed app that declares the flag, by name. */
fun installed(context: Context): List<Declared> {
val manager = context.packageManager
val launcher = Intent(Intent.ACTION_MAIN).addCategory(Intent.CATEGORY_LAUNCHER)
return manager.queryIntentActivities(launcher, 0)
.map { it.activityInfo.packageName }
.distinct()
.filter { it != context.packageName && declares(manager, it) }
.map { pkg ->
val label = runCatching { manager.getApplicationInfo(pkg, 0).loadLabel(manager).toString() }.getOrDefault(pkg)
Declared(pkg, label, about(manager, pkg))
}
.sortedBy { it.label.lowercase() }
}
}
@@ -1,25 +1,31 @@
package app.seb3thehacker.gearslip.update
import android.Manifest
import android.app.Activity
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.content.Context
import android.content.Intent
import android.content.pm.PackageInstaller
import android.content.pm.PackageManager
import android.hardware.usb.UsbManager
import android.net.ConnectivityManager
import android.net.NetworkCapabilities
import android.net.Uri
import android.provider.Settings
import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat
import androidx.core.content.FileProvider
import androidx.core.graphics.drawable.toBitmap
import app.seb3thehacker.gearslip.BuildConfig
import app.seb3thehacker.gearslip.GearslipActivity
import app.seb3thehacker.gearslip.GearslipLog
import app.seb3thehacker.gearslip.R
import app.seb3thehacker.gearslip.SessionStatus
import app.seb3thehacker.gearslip.notify.CarMotion
import app.seb3thehacker.gearslip.notify.CarNotification
import app.seb3thehacker.gearslip.notify.CarNotifications
import app.seb3thehacker.gearslip.wireless.WirelessAuto
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
@@ -42,6 +48,10 @@ import java.security.MessageDigest
* Nothing runs in the background otherwise, and nothing else is sent: the request is the same one
* a browser makes for the release page. Debug builds never check on their own.
*
* On Stable, a new version also installs by itself on Wi-Fi while no car is connected, without
* Android asking, once Gearslip has installed itself before. Android allows that only to the app
* that installed the current version, so the first update from a browser download still asks.
*
* The APK is checked against the size and SHA-256 GitHub lists for it, and Android refuses an
* update signed with a different key, so a bad download can't replace the app.
*/
@@ -72,6 +82,12 @@ object UpdateChecker {
private const val KEY_CHANNEL = "channel"
private const val KEY_NOTIFIED = "notified_version"
private const val KEY_CAR_NOTIFIED = "car_notified_version"
private const val KEY_SILENT = "silent"
/** The version installing without a prompt, so the new one can say it updated. */
private const val KEY_SILENT_VERSION = "silent_version"
/** A version Android wanted confirmed: it isn't tried quietly again, the card and notification take over. */
private const val KEY_SILENT_REFUSED = "silent_refused"
private const val UPDATED_NOTIFICATION_ID = 4101
/** Lets the car screen settle after plug-in, so the card isn't lost behind the first frames. */
private const val CAR_DELAY_MS = 8_000L
private const val CHANNEL = "updates"
@@ -97,6 +113,13 @@ object UpdateChecker {
if (!on) NotificationManagerCompat.from(context).cancel(NOTIFICATION_ID)
}
/** Whether a Stable update installs by itself on Wi-Fi with no car connected. On by default. */
fun silentEnabled(context: Context): Boolean = prefs(context).getBoolean(KEY_SILENT, true)
fun setSilentEnabled(context: Context, on: Boolean) {
prefs(context).edit().putBoolean(KEY_SILENT, on).apply()
}
/** Which releases count as updates. Each channel also takes everything the one before it does. */
enum class Channel { STABLE, BETA, ALPHA }
@@ -130,10 +153,27 @@ object UpdateChecker {
install(context)
return
}
// Opened for a car (plugged in, or wireless found it): installing now would close Gearslip
// just as it connects.
val forCar = (context as? Activity)?.intent?.action in
setOf(UsbManager.ACTION_USB_ACCESSORY_ATTACHED, WirelessAuto.ACTION_CAR_NEARBY)
if (current is State.Ready) {
if (!forCar) installQuietly(context.applicationContext, current.release)
return
}
if (current != State.Idle) return
// A release found earlier still shows after Android closed the app.
savedRelease(context)?.let { flow.value = State.Available(it) }
if (dueForCheck(context)) check(context)
val app = context.applicationContext
if (dueForCheck(context)) {
flow.value = State.Checking
Thread({
flow.value = checkNow(app)
(flow.value as? State.Available)?.let { if (!forCar) installQuietly(app, it.release) }
}, "gearslip-update").start()
} else if (!forCar) {
savedRelease(app)?.let { installQuietly(app, it) }
}
}
/**
@@ -150,6 +190,7 @@ object UpdateChecker {
flow.value = checkNow(app)
}
val release = foundRelease(app) ?: return@Thread
if (installQuietly(app, release)) return@Thread
if (prefs(app).getString(KEY_NOTIFIED, null) == release.version) return@Thread
if (notify(app, release)) prefs(app).edit().putString(KEY_NOTIFIED, release.version).apply()
}, "gearslip-update").start()
@@ -250,8 +291,34 @@ object UpdateChecker {
},
)
/** Downloads [release], then opens the installer once the file checks out. */
fun download(context: Context, release: Release) {
/**
* Downloads and installs [release] without asking, when everything allows it: Stable, the
* switch on, Wi-Fi, no car, and Android already letting Gearslip install. Returns false when it
* didn't start, so the caller tells the driver the usual way.
*/
private fun installQuietly(app: Context, release: Release): Boolean {
val ok = !BuildConfig.DEBUG && autoCheckEnabled(app) && silentEnabled(app) &&
channel(app) == Channel.STABLE && !carConnected && onWifi(app) &&
app.packageManager.canRequestPackageInstalls() &&
prefs(app).getString(KEY_SILENT_REFUSED, null) != release.version
if (!ok) return false
GearslipLog.i("update: installing ${release.version} without asking")
val current = flow.value
if (current is State.Ready && current.release == release) install(app, quiet = true)
else download(app, release, quiet = true)
return true
}
/** Not metered: Wi-Fi or ethernet, where 34 MB doesn't cost anyone. */
private fun onWifi(context: Context): Boolean {
val cm = context.getSystemService(ConnectivityManager::class.java) ?: return false
val caps = cm.getNetworkCapabilities(cm.activeNetwork) ?: return false
return caps.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) &&
caps.hasCapability(NetworkCapabilities.NET_CAPABILITY_NOT_METERED)
}
/** Downloads [release], then installs it once the file checks out. */
fun download(context: Context, release: Release, quiet: Boolean = false) {
if (flow.value is State.Downloading) return
val app = context.applicationContext
flow.value = State.Downloading(release, 0f)
@@ -267,21 +334,23 @@ object UpdateChecker {
GearslipLog.w("update: download failed: ${it.message}")
State.Failed(it.message ?: "The download failed. Try again.", release)
}
if (flow.value is State.Ready) install(app)
if (flow.value is State.Ready) install(app, quiet)
}, "gearslip-update").start()
}
/**
* Hands the downloaded APK to Android's installer. The first time, Android wants the driver to
* allow installs from Gearslip; that opens its settings page, and the install carries on when
* they come back.
* they come back. A [quiet] install never opens anything: if Android wants a confirmation, it
* falls back to the card and the notification.
*/
fun install(context: Context) {
fun install(context: Context, quiet: Boolean = false) {
val ready = flow.value as? State.Ready ?: return
// Installing closes Gearslip, which would drop the car screen mid-drive.
if (carConnected) return
val pm = context.packageManager
if (!pm.canRequestPackageInstalls()) {
if (quiet) return
installWhenAllowed = true
context.startActivity(
Intent(Settings.ACTION_MANAGE_UNKNOWN_APP_SOURCES, Uri.parse("package:${context.packageName}"))
@@ -289,14 +358,111 @@ object UpdateChecker {
)
return
}
val uri = FileProvider.getUriForFile(context, "${context.packageName}.logshare", ready.apk)
context.startActivity(
Intent(Intent.ACTION_VIEW)
.setDataAndType(uri, "application/vnd.android.package-archive")
.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_ACTIVITY_NEW_TASK),
)
val app = context.applicationContext
Thread({
runCatching { commit(app, ready, quiet) }.onFailure {
GearslipLog.w("update: install failed to start: ${it.message}")
if (!quiet) flow.value = State.Failed("Android couldn't start the install. Try again.", ready.release)
}
}, "gearslip-update").start()
}
/**
* Writes the APK into an install session. Android skips its prompt when Gearslip installed the
* version being replaced; otherwise [onInstallStatus] gets a screen to show for the driver.
*/
private fun commit(app: Context, ready: State.Ready, quiet: Boolean) {
val installer = app.packageManager.packageInstaller
val params = PackageInstaller.SessionParams(PackageInstaller.SessionParams.MODE_FULL_INSTALL).apply {
setAppPackageName(app.packageName)
setSize(ready.apk.length())
setRequireUserAction(PackageInstaller.SessionParams.USER_ACTION_NOT_REQUIRED)
}
val id = installer.createSession(params)
installer.openSession(id).use { session ->
session.openWrite("gearslip.apk", 0, ready.apk.length()).use { out ->
ready.apk.inputStream().use { it.copyTo(out) }
session.fsync(out)
}
if (quiet) prefs(app).edit().putString(KEY_SILENT_VERSION, ready.release.version).commit()
val status = Intent(app, UpdateReceiver::class.java)
.setAction(UpdateReceiver.ACTION_STATUS)
.putExtra(EXTRA_QUIET, quiet)
.putExtra(EXTRA_VERSION, ready.release.version)
// Mutable: the installer adds the outcome to it.
val pending = PendingIntent.getBroadcast(app, id, status, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_MUTABLE)
session.commit(pending.intentSender)
}
GearslipLog.i("update: handed ${ready.release.version} to Android's installer")
}
/** What the installer said about a session; on success Gearslip is replaced and this never runs. */
internal fun onInstallStatus(context: Context, intent: Intent) {
val app = context.applicationContext
val quiet = intent.getBooleanExtra(EXTRA_QUIET, false)
val version = intent.getStringExtra(EXTRA_VERSION)
val status = intent.getIntExtra(PackageInstaller.EXTRA_STATUS, PackageInstaller.STATUS_FAILURE)
when (status) {
PackageInstaller.STATUS_PENDING_USER_ACTION -> {
if (quiet) {
// Android wants a tap, maybe because a browser installed this version. Don't
// pop a screen up unasked: drop it, and the card and notification take over.
GearslipLog.i("update: Android wants $version confirmed; leaving it to the driver")
val session = intent.getIntExtra(PackageInstaller.EXTRA_SESSION_ID, -1)
if (session >= 0) runCatching { app.packageManager.packageInstaller.abandonSession(session) }
prefs(app).edit().putString(KEY_SILENT_REFUSED, version).remove(KEY_SILENT_VERSION).apply()
val release = foundRelease(app) ?: return
if (prefs(app).getString(KEY_NOTIFIED, null) != release.version && notify(app, release)) {
prefs(app).edit().putString(KEY_NOTIFIED, release.version).apply()
}
} else {
@Suppress("DEPRECATION")
val confirm = intent.getParcelableExtra<Intent>(Intent.EXTRA_INTENT) ?: return
runCatching { app.startActivity(confirm.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)) }
}
}
PackageInstaller.STATUS_SUCCESS -> GearslipLog.i("update: $version installed")
else -> {
val message = intent.getStringExtra(PackageInstaller.EXTRA_STATUS_MESSAGE)
GearslipLog.w("update: install of $version failed ($status, $message)")
prefs(app).edit().remove(KEY_SILENT_VERSION).apply()
if (quiet) prefs(app).edit().putString(KEY_SILENT_REFUSED, version).apply()
else if (status != PackageInstaller.STATUS_FAILURE_ABORTED) {
flow.value = State.Failed("Android didn't install it. Try again.", foundRelease(app))
}
}
}
}
/** The new version's first breath after updating itself: say so, since nobody was asked. */
internal fun onReplaced(context: Context) {
val app = context.applicationContext
val version = prefs(app).getString(KEY_SILENT_VERSION, null) ?: return
prefs(app).edit().remove(KEY_SILENT_VERSION).apply()
if (version != BuildConfig.VERSION_NAME) return
GearslipLog.i("update: now on $version, installed without asking")
if (app.checkSelfPermission(Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED) return
app.getSystemService(NotificationManager::class.java).createNotificationChannel(
NotificationChannel(CHANNEL, "Updates", NotificationManager.IMPORTANCE_DEFAULT),
)
val open = PendingIntent.getActivity(
app, UPDATED_NOTIFICATION_ID,
Intent(app, GearslipActivity::class.java).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP),
PendingIntent.FLAG_IMMUTABLE,
)
val notification = NotificationCompat.Builder(app, CHANNEL)
.setSmallIcon(android.R.drawable.stat_sys_download_done)
.setContentTitle(app.getString(R.string.update_installed_title, version))
.setContentText(app.getString(R.string.update_installed_text))
.setContentIntent(open)
.setAutoCancel(true)
.build()
runCatching { NotificationManagerCompat.from(app).notify(UPDATED_NOTIFICATION_ID, notification) }
}
private const val EXTRA_QUIET = "quiet"
private const val EXTRA_VERSION = "version"
/** The newest release with an APK that [channel] takes. Drafts never count. */
private fun fetchNewest(channel: Channel): Release? {
val stable = channel == Channel.STABLE
@@ -412,12 +578,14 @@ object UpdateChecker {
/**
* "0.2.00-alpha" beats "0.1.10-alpha": the numbers before the dash compare one by one. With the
* same numbers, a plain release beats a beta, and a beta beats an alpha, so "0.3.0" reaches
* someone on "0.3.0-beta".
* someone on "0.3.0-beta". Anything after a "+" only says which commit a test build came from,
* so "0.3.0-beta+4.ba4374e" counts the same as "0.3.0-beta".
*/
internal fun isNewer(candidate: String, installed: String): Boolean {
fun parts(v: String) = v.removePrefix("v").substringBefore('-').split('.').map { it.toIntOrNull() ?: 0 }
fun core(v: String) = v.removePrefix("v").substringBefore('+')
fun parts(v: String) = core(v).substringBefore('-').split('.').map { it.toIntOrNull() ?: 0 }
fun stage(v: String): Int {
val tag = v.substringAfter('-', "").lowercase()
val tag = core(v).substringAfter('-', "").lowercase()
return when {
tag.isEmpty() -> 3
"beta" in tag -> 2
@@ -0,0 +1,19 @@
package app.seb3thehacker.gearslip.update
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
/** The installer's answer about an update, and the new version's first start after one. */
class UpdateReceiver : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
when (intent.action) {
ACTION_STATUS -> UpdateChecker.onInstallStatus(context, intent)
Intent.ACTION_MY_PACKAGE_REPLACED -> UpdateChecker.onReplaced(context)
}
}
companion object {
const val ACTION_STATUS = "app.seb3thehacker.gearslip.update.STATUS"
}
}
@@ -80,6 +80,9 @@ object WirelessLink {
/** Set by the activity: starts a session on the car's socket. */
@Volatile var onConnected: ((Socket) -> Unit)? = null
/** Set by the activity: whether the session on the socket still waits for the car's first message. */
@Volatile var sessionWaiting: (() -> Boolean)? = null
@Volatile private var rfcomm: BluetoothSocket? = null
@Volatile private var listener: BluetoothServerSocket? = null
@Volatile private var tcp: Socket? = null
@@ -92,6 +95,8 @@ object WirelessLink {
@Volatile private var lostNetwork: Network? = null
/** The car network asked for or joined, until Android says it's gone or never came. */
@Volatile private var activeSsid: String? = null
/** The car's network once joined, for connecting again on it. */
@Volatile private var joinedNet: Network? = null
/** 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
/** The socket reached the car's projection: whatever ends the start-up now, it isn't retried. */
@@ -195,6 +200,7 @@ object WirelessLink {
}
network = null
activeSsid = null
joinedNet = null
}
/** A stable id for this phone as this app sees it, standing in for the serial a real phone sends. */
@@ -272,6 +278,17 @@ object WirelessLink {
val port = Wire.varint(f, 2)?.toInt() ?: 0
log.i("<- StartRequest: projection at $ip:$port")
target = ip to port
// The car asks again while the connection it got still hasn't heard from
// it: it started its side over, and waits on a fresh connection. Issue #18:
// a Bolt left Gearslip waiting 88 seconds on the old one, then hung up.
// A session already under way is left alone.
val net = joinedNet
if (net != null && activeSsid != null && handedOver && sessionWaiting?.invoke() == true) {
log.w("the car asked again while the connection still waits for it - connecting again")
val run = ++tcpRun
Thread({ connectTcp(context, output, name, net, ip, port, run) }, "gearslip-wireless-tcp").start()
continue
}
val join = waitingJoin
if (join != null) {
waitingJoin = null
@@ -423,6 +440,7 @@ object WirelessLink {
val callback = object : ConnectivityManager.NetworkCallback() {
override fun onAvailable(net: Network) {
log.i("Wi-Fi: joined \"$ssid\"")
joinedNet = net
// Real phones report the join over Bluetooth before opening TCP, and some cars
// only start listening once they hear it.
val phoneIp = manager.getLinkProperties(net)?.linkAddresses
@@ -463,6 +481,7 @@ object WirelessLink {
override fun onLost(net: Network) {
lostNetwork = net
if (joinedNet == net) joinedNet = null
if (activeSsid == ssid) activeSsid = null
log.w(
"Wi-Fi: lost \"$ssid\" - Android dropped the car's network. Picking it by hand " +
@@ -0,0 +1,17 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- The launcher icon's gear, cropped to its edges, for the "Works with Gearslip" mark. -->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="57"
android:viewportHeight="57">
<group android:translateX="-29.5" android:translateY="-25.5">
<path
android:pathData="M 54.00,36.18 L 58.64,30.70 L 67.20,34.24 L 66.60,41.40 L 71.82,54.00 L 77.30,58.64 L 73.76,67.20 L 66.60,66.60 L 66.60,66.60 L 67.20,73.76 L 58.64,77.30 L 54.00,71.82 L 54.00,71.82 L 49.36,77.30 L 40.80,73.76 L 41.40,66.60 L 41.40,66.60 L 34.24,67.20 L 30.70,58.64 L 36.18,54.00 L 36.18,54.00 L 30.70,49.36 L 34.24,40.80 L 41.40,41.40 L 41.40,41.40 L 40.80,34.24 L 49.36,30.70 L 54.00,36.18 Z M54,46.08 A7.92,7.92 0 1,1 53.99,46.08 Z"
android:fillType="evenOdd"
android:fillColor="#FF000000"/>
<path
android:pathData="M 78.61,40.70 L 85.31,43.29 L 84.74,52.55 L 77.78,54.31 Z"
android:fillColor="#FF000000"/>
</group>
</vector>
+148 -9
View File
@@ -200,8 +200,6 @@
<string name="car_right">يمين</string>
<string name="car_left">يسار</string>
<string name="car_autoplay">التشغيل التلقائي عند الاتصال</string>
<string name="car_more_lyrics">مصادر أكثر لكلمات الأغاني</string>
<string name="car_more_lyrics_note">يسأل أيضًا BetterLyrics وKuGou، فيحصلان على اسم الأغنية والفنان. عند الإيقاف يسأل LRCLIB فقط.</string>
<string name="car_spoken_directions">الإرشادات الصوتية</string>
<string name="car_spoken_directions_note">أوقفها إذا كان صوت تطبيق الخرائط نفسه يُسمع في السيارة بالفعل.</string>
<string name="car_phone_speech">استخدام التعرّف على الكلام في الهاتف</string>
@@ -213,7 +211,7 @@
<string name="car_vehicle_data_note">يضيف مربع «بيانات المركبة»: عرض مباشر لحساسات السيارة. أسماء الحقول تخمين إلى أن ترى أحدها يتغير.</string>
<string name="car_adaptive_video">جودة فيديو متكيفة</string>
<string name="car_adaptive_video_note">يخفض جودة الصورة عندما لا تواكب السيارة أو شبكة Wi-Fi فيها، ويرفعها مجددًا حين تستطيع. يسري عند الاتصال التالي.</string>
<string name="web_page_note">يعرضها تطبيق المتصفح في السيارة. إذا تُركت فارغة يُفتح DuckDuckGo. يسري عند الاتصال التالي.</string>
<string name="web_page_note">يعرضها تطبيق المتصفح في السيارة. إذا تُركت فارغة تُفتح صفحة البدء مع إشاراتك المرجعية. تُطبَّق عند الاتصال التالي.</string>
<string name="car_audio">صوت السيارة</string>
<string name="car_audio_bt">بلوتوث (موصى به)</string>
<string name="car_audio_bt_sub">لا سؤال في كل رحلة، ويصل صوت تطبيق الخرائط إلى السيارة</string>
@@ -433,8 +431,6 @@
<string name="carset_fit">ملاءمة الشاشة</string>
<string name="off">متوقف</string>
<string name="on">مفعّل</string>
<string name="carset_lyrics_from">الكلمات من</string>
<string name="carset_more_sources">مصادر أكثر</string>
<string name="carset_hide">إخفاء</string>
<string name="carset_show">إظهار</string>
<string name="carset_phone">الهاتف</string>
@@ -444,7 +440,7 @@
<string name="carset_build">البنية</string>
<string name="carset_whats_new_after">الجديد بعد التحديثات</string>
<string name="carset_whats_new_after_sub">عرض قائمة التغييرات مرة واحدة، في أول اتصال للسيارة بعد التحديث.</string>
<string name="carset_web_default">DuckDuckGo (افتراضي) - انقر لتعيين صفحة أخرى</string>
<string name="carset_web_default">صفحة البدء (افتراضي) - انقر لتعيين صفحة</string>
<string name="carset_web_note">يعرضها تطبيق المتصفح في المشغّل. يسري عند الاتصال التالي.</string>
<string name="carset_screen_fit">ملاءمة الشاشة</string>
<string name="carset_top">الأعلى</string>
@@ -463,7 +459,6 @@
<string name="carset_px">%1$dpx</string>
<!-- Car media app -->
<string name="media_open_on_phone">فتح على الهاتف</string>
<string name="media_now_playing">قيد التشغيل</string>
<string name="media_up_next">التالي</string>
<string name="media_browse">تصفّح</string>
@@ -473,7 +468,7 @@
<string name="media_press_play">اضغط تشغيل لتكمل من حيث توقفت، أو ابحث عن شيء في «تصفّح».</string>
<string name="media_keeps_library">يحتفظ %1$s بمكتبته لنفسه.</string>
<string name="media_needs_access">لا يزال بإمكان Gearslip التحكم به بعد منحه الوصول إلى الإشعارات على الهاتف. اسمح بذلك من الإعدادات &gt; الإشعارات &gt; الوصول إلى الإشعارات.</string>
<string name="media_needs_access">ما زال Gearslip قادرًا على تشغيله بمجرد حصوله على إذن الوصول إلى الإشعارات. اسمح بذلك على الهاتف عندما تركن السيارة.</string>
<string name="media_not_running">%1$s لا يعمل.</string>
<string name="media_not_running_body">يُشغَّل %1$s عبر مشغّل Gearslip، لكن فقط أثناء فتحه على الهاتف. افتحه مرة ويواصل التشغيل هنا في السيارة.</string>
<string name="media_open_app">فتح %1$s</string>
@@ -668,7 +663,7 @@
<string name="notif_title">الإشعارات</string>
<string name="notif_clear_all">مسح الكل</string>
<string name="notif_empty">لا شيء بعد. ستظهر هنا الإشعارات الجديدة من الهاتف.</string>
<string name="notif_off">الإشعارات متوقفة. اسمح لـ Gearslip من الإعدادات &gt; الإشعارات &gt; الوصول إلى الإشعارات على الهاتف.</string>
<string name="notif_off">يحتاج Gearslip إلى إذن الوصول إلى الإشعارات لعرضها هنا. اسمح بذلك على الهاتف عندما تركن السيارة.</string>
<string name="notif_replied">تم الرد</string>
<!-- Messages -->
@@ -866,4 +861,148 @@
<string name="settings_language">اللغة</string>
<string name="settings_language_phone">مثل لغة الهاتف</string>
<string name="player_next_chapter">الفصل التالي</string>
<string name="allow_on_phone">السماح على الهاتف</string>
<string name="app_all_apps">كل التطبيقات</string>
<string name="bt_address_car_note">أكمل خطوة إعداد واحدة على هاتفك عندما تركن السيارة.</string>
<string name="bt_address_card_body">بعض السيارات لا تبقى متصلة إلا إذا عرفت عنوان بلوتوث الهاتف. يخفيه أندرويد عن التطبيقات، لذا انسخه من «لمحة عن الهاتف». اضغط مطولًا على عنوان البلوتوث هناك، ثم انقر على «نسخ» وعد إلى هنا.</string>
<string name="bt_address_card_title">ساعد %1$s على التعرف على هذا الهاتف</string>
<string name="bt_address_not_now">ليس الآن</string>
<string name="bt_address_open">فتح «لمحة عن الهاتف»</string>
<string name="bt_address_type">اكتبه بدلًا من ذلك</string>
<string name="bt_address_use_body">يمرره Gearslip إلى سيارتك، فتعرف أن هذا هو الهاتف المقترن بها.</string>
<string name="bt_address_use_title">هل تريد استخدام عنوان البلوتوث هذا؟</string>
<string name="bt_address_your_car">سيارتك</string>
<string name="car_apps_finding">جارٍ البحث عن التطبيقات…</string>
<string name="car_apps_none">لم يُعثر على تطبيقات للسيارة</string>
<string name="car_apps_sub">اختر التطبيقات التي تظهر في السيارة</string>
<string name="car_audio_blocked">لا يسمح %1$s للتطبيقات الأخرى بتشغيل صوته، لذا لا يصل إلى السيارة عبر Gearslip. جرّب تطبيق موسيقى آخر.</string>
<string name="car_audio_switched">تُطفئ هذه السيارة موسيقى البلوتوث أثناء عمل Gearslip، لذا تمر الموسيقى الآن عبر Gearslip. اسمح بالطلب على هاتفك.</string>
<string name="car_autoplay_note">يستأنف الموسيقى من حيث توقفت بمجرد اتصال السيارة.</string>
<string name="car_density">كثافة شاشة السيارة</string>
<string name="car_density_note">نص وأزرار أكبر، حتى داخل التطبيقات. جرّب 200 DPI مع ضبط الحجم على عادي. أعد توصيل السيارة أو افتح المعاينة من جديد لتطبيقه. إذا اقتُطعت الأزرار، فاختر DPI أقل أو «من السيارة».</string>
<string name="car_directions_over_music">الإرشادات عبر قناة الموسيقى</string>
<string name="car_directions_over_music_note">لسيارة تفقد الاتصال أو تُعيد التشغيل بعد إرشاد منطوق. يمزج Gearslip كل إرشاد مع الموسيقى بدلًا من استخدام قناة الملاحة في السيارة.</string>
<string name="car_experimental_note">ميزات ما زالت قيد الاختبار. قد لا تعمل في كل سيارة.</string>
<string name="car_player_beside_map_note">جهة الخريطة التي يُثبَّت فيها المشغل عند النقر على زر التثبيت.</string>
<string name="carset_advanced">متقدم</string>
<string name="carset_experimental_heading">تجريبي</string>
<string name="carset_fit_needs_car">وصّل سيارة لحفظ هذا الضبط لها.</string>
<string name="choice_density_car">من السيارة</string>
<string name="choice_density_dpi">%1$d DPI</string>
<string name="choice_density_custom">مخصص…</string>
<string name="dialer_missed">فائتة · %1$s</string>
<string name="dialer_no_recents">لا توجد مكالمات حديثة.</string>
<string name="dialer_recents">الأخيرة</string>
<string name="dialer_recents_needs_permission">يحتاج Gearslip إلى إذن سجلات المكالمات لعرض المكالمات الأخيرة.</string>
<string name="help_bluetooth_first">وصّل البلوتوث قبل الكابل.</string>
<string name="help_bluetooth_first_detail">إذا لم تتصل الموسيقى عبر البلوتوث، فدع السيارة تجد الهاتف عبر البلوتوث أولًا، ثم وصّل الكابل. بعض السيارات تختلط عليها الأمور إذا جاء الكابل أولًا.</string>
<string name="help_usb_data">اسمح ببيانات USB.</string>
<string name="help_usb_data_detail">قد يحظر GrapheneOS البيانات على منفذ USB. إذا كان التحكم في منفذ USB-C مضبوطًا على «الشحن فقط»، فلن ترى السيارة الهاتف أبدًا، حتى مع كابل بيانات. اضبطه للسماح بالبيانات.</string>
<string name="launcher_in_car">هذا الجهاز متصل بسيارة، لذا يظهر Gearslip على شاشة السيارة.</string>
<string name="launcher_open_gearslip">فتح Gearslip</string>
<string name="nav_arrival">الوصول</string>
<string name="nav_lane_any">أي مسار</string>
<string name="nav_lane_from_left">استخدم المسار %1$d من اليسار</string>
<string name="nav_lane_from_right">استخدم المسار %1$d من اليمين</string>
<plurals name="nav_lane_left">
<item quantity="zero">استخدم المسارات اليسرى</item>
<item quantity="one">استخدم المسار الأيسر</item>
<item quantity="two">استخدم المسارين الأيسرين</item>
<item quantity="few">استخدم المسارات الـ %d اليسرى</item>
<item quantity="many">استخدم المسارات الـ %d اليسرى</item>
<item quantity="other">استخدم المسارات الـ %d اليسرى</item>
</plurals>
<string name="nav_lane_middle">استخدم المسار الأوسط</string>
<string name="nav_lane_middle_lanes">استخدم المسارات الوسطى</string>
<plurals name="nav_lane_right">
<item quantity="zero">استخدم المسارات اليمنى</item>
<item quantity="one">استخدم المسار الأيمن</item>
<item quantity="two">استخدم المسارين الأيمنين</item>
<item quantity="few">استخدم المسارات الـ %d اليمنى</item>
<item quantity="many">استخدم المسارات الـ %d اليمنى</item>
<item quantity="other">استخدم المسارات الـ %d اليمنى</item>
</plurals>
<string name="nav_to_go">متبقية</string>
<string name="privacy_policy">سياسة الخصوصية</string>
<string name="privacy_policy_sub">ما يرسله Gearslip، ولمن، ولماذا</string>
<string name="settings_bt_address">عنوان بلوتوث الهاتف</string>
<string name="settings_bt_address_hint">انسخه من إعدادات أندرويد، ضمن «لمحة عن الهاتف». يمرره Gearslip إلى السيارة، فتعرف أن الهاتف الموصول بالكابل هو المقترن بها.</string>
<string name="settings_bt_address_invalid">هذا ليس عنوان بلوتوث. يبدو مثل 12:34:56:78:9A:BC.</string>
<string name="settings_bt_address_off">غير مضبوط. فقط لسيارة تقطع البلوتوث عندما يتصل Gearslip.</string>
<string name="settings_use_as_launcher">الاستخدام كمشغّل</string>
<string name="settings_use_as_launcher_off">لوحدة رأسية أو جهاز لوحي على لوحة القيادة. تصبح شاشة السيارة في Gearslip الشاشة الرئيسية لهذا الجهاز.</string>
<string name="settings_use_as_launcher_on">يمكن أن يكون Gearslip الشاشة الرئيسية لهذا الجهاز. اختره ضمن «تطبيق الشاشة الرئيسية التلقائي».</string>
<string name="status_no_car">الهاتف يُشحن، لكن لم تستجب أي سيارة. بعض الكابلات تشحن فقط؛ جرّب كابلًا ينقل البيانات.</string>
<string name="status_no_car_graphene">الهاتف يُشحن، لكن لم تستجب أي سيارة. تحقّق من أن التحكم في منفذ USB-C، ضمن الأمان والخصوصية، يسمح بالبيانات وليس مضبوطًا على «الشحن فقط».</string>
<string name="status_open_settings">فتح الإعدادات</string>
<string name="status_stuck_cable">لم تعرض السيارة Gearslip بعد. افصل الكابل، وانتظر بضع ثوانٍ، ثم وصّله من جديد. إذا تكرر ذلك، فجرّب كابلًا أو منفذ USB آخر، وشارك السجل في بلاغ.</string>
<string name="status_stuck_wireless">لم تعرض السيارة Gearslip بعد. أطفئ السيارة وشغّلها من جديد، أو اتصل بكابل. إذا تكرر ذلك، فشارك السجل في بلاغ.</string>
<string name="update_installed_text">ثُبّت عبر Wi-Fi بينما لم تكن أي سيارة متصلة.</string>
<string name="update_installed_title">حُدّث Gearslip إلى %1$s</string>
<string name="update_silent">تثبيت التحديثات تلقائيًا</string>
<string name="update_silent_sub">الإصدارات النهائية فقط، عبر Wi-Fi، عندما لا تكون أي سيارة متصلة</string>
<string name="assist_listening">جارٍ الاستماع…</string>
<string name="assist_one_moment">لحظة…</string>
<string name="assist_examples">جرّب “navigate home” أو “play” ثم اسم فنان. قل “help” للمزيد.</string>
<string name="assist_grant">منح الإذن</string>
<string name="density_custom_title">كثافة مخصصة</string>
<string name="density_custom_label">DPI</string>
<string name="density_custom_note">أي قيمة من %1$d إلى %2$d. القيمة الأقل تُظهر محتوى أكثر على الشاشة، والأعلى تُكبّر كل شيء.</string>
<string name="web_add_bookmark">إضافة</string>
<string name="web_done">تم</string>
<string name="web_remove_bookmark">إزالة %1$s</string>
<string name="web_bookmark_added">أُضيفت إلى صفحة البدء</string>
<string name="web_bookmark_removed">أُزيلت من صفحة البدء</string>
<string name="web_bookmark_this">إضافة إلى صفحة البدء</string>
<string name="web_remove_this_bookmark">إزالة من صفحة البدء</string>
<string name="browser_search_engine">محرك البحث</string>
<string name="browser_bookmarks">الإشارات المرجعية</string>
<string name="browser_bookmarks_note">تظهر كمربعات في صفحة البدء بالمتصفح. يمكنك أيضًا مشاركة صفحة مع Gearslip من أي متصفح.</string>
<string name="browser_add_bookmark">إضافة إشارة مرجعية</string>
<string name="browser_bookmark_name">الاسم</string>
<string name="browser_bookmark_address">عنوان الويب</string>
<string name="browser_remove">إزالة</string>
<string name="browser_adblock">حظر الإعلانات وأدوات التتبع</string>
<string name="browser_adblock_note">يستخدم قوائم فلترة تُحدَّث أسبوعيًا عبر Wi-Fi من المواقع التي تنشرها.</string>
<string name="browser_youtube">حظر إعلانات فيديوهات YouTube</string>
<string name="browser_youtube_note">يتخطى الإعلانات داخل الفيديوهات. يتغير YouTube كثيرًا، لذا أوقف هذا الخيار إذا توقفت الفيديوهات عن التشغيل.</string>
<string name="browser_filter_lists">قوائم الفلترة</string>
<string name="browser_add_list">إضافة قائمة فلترة</string>
<string name="browser_list_address">عنوان القائمة</string>
<string name="browser_list_note">أي قائمة بتنسيق Adblock Plus، مثل قائمة للغتك أو منطقتك.</string>
<string name="browser_update_lists">تحديث القوائم الآن</string>
<string name="browser_lists_status">%1$d قاعدة، حُدّثت %2$s</string>
<string name="browser_lists_never">%1$d قاعدة، من النسخة المضمّنة في Gearslip</string>
<string name="browser_updating">جارٍ التحديث…</string>
<string name="browser_update_failed">تعذّر التحديث. %1$s</string>
<string name="browser_shared_added">أُضيفت إلى صفحة البدء في Gearslip</string>
<string name="browser_shared_not_url">هذا ليس عنوان ويب</string>
<string name="browser_share_label">صفحة البدء في Gearslip</string>
<string name="summary_adblock_on">الإعلانات محظورة</string>
<string name="summary_adblock_off">الإعلانات غير محظورة</string>
<string name="car_nav_mic">زر الميكروفون</string>
<string name="car_nav_mic_note">زر المساعد في شريط التنقل. يعمل زر الصوت في السيارة في كل الأحوال.</string>
<string name="car_apps_add">إضافة تطبيق</string>
<string name="car_apps_add_note">لتطبيق لا تعرضه السيارة: تُشغَّل موسيقاه عبر مشغّل Gearslip، أو تظهر رسائله في شاشة الرسائل.</string>
<string name="car_apps_add_pick">اختر تطبيقًا</string>
<string name="car_apps_add_as">عرض %1$s بوصفه</string>
<string name="car_apps_as_media">الموسيقى والصوت</string>
<string name="car_apps_as_media_note">ما يُشغَّل الآن، مع التشغيل والإيقاف المؤقت والتخطي. لا توجد مكتبة للتصفح.</string>
<string name="car_apps_as_messaging">الرسائل</string>
<string name="car_apps_as_messaging_note">إشعاراته، تُقرأ بصوت عالٍ، مع الردود.</string>
<string name="car_apps_added_by_you">أضفته أنت</string>
<string name="car_apps_remove">إزالة %1$s</string>
<string name="cert_link">تنزيل من رابط</string>
<string name="cert_link_sub">ملف شهادة تثق به، من عنوان تلصقه</string>
<string name="cert_link_title">شهادة من رابط</string>
<string name="cert_link_note">الصق عنوان ملف ‎.p12، أو عنوان شهادة ومفتاحها بصيغة PEM أو DER. يحوّلها Gearslip إلى الصيغة التي تحتاجها السيارة.</string>
<string name="cert_link_label">رابط الشهادة أو ملف ‎.p12</string>
<string name="cert_link_key">رابط المفتاح الخاص</string>
<string name="cert_link_key_note">هذه شهادة بلا مفتاح. الصق رابط المفتاح أيضًا.</string>
<string name="cert_link_password">كلمة المرور</string>
<string name="cert_link_password_note">لهذا الملف كلمة مرور.</string>
<string name="cert_link_download">تنزيل</string>
<string name="cert_link_again">التنزيل مجددًا</string>
<string name="cert_link_done">نُزّلت وحُوّلت. تُستخدم من الاتصال التالي.</string>
<string name="cert_link_failed">تعذّر استخدامها. %1$s</string>
</resources>
+144 -9
View File
@@ -200,8 +200,6 @@
<string name="car_right">Vpravo</string>
<string name="car_left">Vlevo</string>
<string name="car_autoplay">Přehrát po připojení</string>
<string name="car_more_lyrics">Více zdrojů textů</string>
<string name="car_more_lyrics_note">Ptá se také BetterLyrics a KuGou, které pak dostanou název a interpreta skladby. Vypnuto se ptá jen LRCLIB.</string>
<string name="car_spoken_directions">Hlasové pokyny</string>
<string name="car_spoken_directions_note">Vypněte je, pokud hlas vaší mapové aplikace už v autě hraje.</string>
<string name="car_phone_speech">Používat rozpoznávání řeči telefonu</string>
@@ -213,7 +211,7 @@
<string name="car_vehicle_data_note">Přidá dlaždici Data vozidla: živý výpis senzorů auta. Názvy polí jsou odhady, dokud neuvidíte, jak se některé mění.</string>
<string name="car_adaptive_video">Adaptivní kvalita obrazu</string>
<string name="car_adaptive_video_note">Sníží kvalitu obrazu, když auto nebo jeho Wi-Fi nestíhá, a zase ji zvýší, jakmile to jde. Platí od příštího připojení.</string>
<string name="web_page_note">Zobrazuje ji aplikace Prohlížeč v autě. Prázdné pole otevře DuckDuckGo. Platí od příštího připojení.</string>
<string name="web_page_note">Zobrazuje ji aplikace Prohlížeč v autě. Prázdné otevře úvodní stránku se záložkami. Platí od dalšího připojení.</string>
<string name="car_audio">Zvuk v autě</string>
<string name="car_audio_bt">Bluetooth (doporučeno)</string>
<string name="car_audio_bt_sub">Žádný dotaz při každé jízdě a hlas mapové aplikace se dostane do auta</string>
@@ -431,8 +429,6 @@
<string name="carset_fit">Přizpůsobit obrazovce</string>
<string name="off">Vyp.</string>
<string name="on">Zap.</string>
<string name="carset_lyrics_from">Texty z</string>
<string name="carset_more_sources">Více zdrojů</string>
<string name="carset_hide">Skrýt</string>
<string name="carset_show">Zobrazit</string>
<string name="carset_phone">Telefon</string>
@@ -442,7 +438,7 @@
<string name="carset_build">Sestavení</string>
<string name="carset_whats_new_after">Novinky po aktualizacích</string>
<string name="carset_whats_new_after_sub">Zobrazit seznam změn jednou, při prvním připojení auta po aktualizaci.</string>
<string name="carset_web_default">DuckDuckGo (výchozí) - klepnutím nastavíte jinou stránku</string>
<string name="carset_web_default">Úvodní stránka (výchozí) - klepnutím nastavíte stránku</string>
<string name="carset_web_note">Zobrazuje ji aplikace Prohlížeč v launcheru. Platí od příštího připojení.</string>
<string name="carset_screen_fit">Přizpůsobení obrazovky</string>
<string name="carset_top">Nahoře</string>
@@ -461,7 +457,6 @@
<string name="carset_px">%1$dpx</string>
<!-- Car media app -->
<string name="media_open_on_phone">Otevřít v telefonu</string>
<string name="media_now_playing">Právě hraje</string>
<string name="media_up_next">Další na řadě</string>
<string name="media_browse">Procházet</string>
@@ -471,7 +466,7 @@
<string name="media_press_play">Stiskněte přehrát a pokračujte, kde jste skončili, nebo něco najděte v Procházet.</string>
<string name="media_keeps_library">%1$s si svou knihovnu nechává pro sebe.</string>
<string name="media_needs_access">Gearslip ji přesto může ovládat, jakmile bude mít v telefonu přístup k oznámením. Povolte ho v Nastavení &gt; Oznámení &gt; Přístup k oznámením.</string>
<string name="media_needs_access">Gearslip ho i tak dokáže přehrávat, jakmile bude mít přístup k oznámením. Povolte ho v telefonu, až zaparkujete.</string>
<string name="media_not_running">%1$s neběží.</string>
<string name="media_not_running_body">%1$s hraje přes přehrávač Gearslipu, ale jen když je otevřená v telefonu. Jednou ji otevřete a bude hrát dál tady v autě.</string>
<string name="media_open_app">Otevřít %1$s</string>
@@ -664,7 +659,7 @@
<string name="notif_title">Oznámení</string>
<string name="notif_clear_all">Vymazat vše</string>
<string name="notif_empty">Zatím nic. Nová oznámení z telefonu se objeví tady.</string>
<string name="notif_off">Oznámení jsou vypnutá. Povolte Gearslip v telefonu v Nastavení &gt; Oznámení &gt; Přístup k oznámením.</string>
<string name="notif_off">Gearslip potřebuje přístup k oznámením, aby je tu mohl zobrazit. Povolte ho v telefonu, až zaparkujete.</string>
<string name="notif_replied">Odpovězeno</string>
<!-- Messages -->
@@ -850,4 +845,144 @@
<string name="settings_language">Jazyk</string>
<string name="settings_language_phone">Stejný jako v telefonu</string>
<string name="player_next_chapter">Další kapitola</string>
<string name="allow_on_phone">Povolit v telefonu</string>
<string name="app_all_apps">Všechny aplikace</string>
<string name="bt_address_car_note">Až zaparkujete, dokončete v telefonu jeden krok nastavení.</string>
<string name="bt_address_card_body">Některá auta zůstanou připojená, jen když znají adresu Bluetooth telefonu. Android ji před aplikacemi skrývá, proto ji zkopírujte v sekci Informace o telefonu. Podržte tam adresu Bluetooth, klepněte na Kopírovat a vraťte se.</string>
<string name="bt_address_card_title">Pomozte %1$s poznat tento telefon</string>
<string name="bt_address_not_now">Teď ne</string>
<string name="bt_address_open">Otevřít Informace o telefonu</string>
<string name="bt_address_type">Raději ji napsat</string>
<string name="bt_address_use_body">Gearslip ji předá vašemu autu, aby vědělo, že je to spárovaný telefon.</string>
<string name="bt_address_use_title">Použít tuto adresu Bluetooth?</string>
<string name="bt_address_your_car">vašemu autu</string>
<string name="car_apps_finding">Hledání aplikací…</string>
<string name="car_apps_none">Nenašly se žádné aplikace do auta</string>
<string name="car_apps_sub">Vyberte, které aplikace se v autě zobrazí</string>
<string name="car_audio_blocked">%1$s nedovolí jiným aplikacím přehrávat svůj zvuk, takže se přes Gearslip do auta nedostane. Zkuste jinou hudební aplikaci.</string>
<string name="car_audio_switched">Toto auto vypíná hudbu přes Bluetooth, když běží Gearslip, takže hudba teď jde přes Gearslip. Povolte výzvu v telefonu.</string>
<string name="car_autoplay_note">Jakmile se auto připojí, pustí hudbu tam, kde skončila.</string>
<string name="car_density">Hustota displeje auta</string>
<string name="car_density_note">Větší text a ovládací prvky, i v aplikacích. Zkuste 200 DPI s Velikostí na Normální. Pro použití auto znovu připojte nebo znovu otevřete náhled. Pokud se ovládací prvky ořezávají, zvolte méně DPI nebo Z auta.</string>
<string name="car_directions_over_music">Pokyny přes hudební kanál</string>
<string name="car_directions_over_music_note">Pro auto, které po hlasovém pokynu ztratí spojení nebo se restartuje. Gearslip každý pokyn přimíchá do hudby místo kanálu navigace auta.</string>
<string name="car_experimental_note">Funkce, které se ještě testují. Nemusí fungovat v každém autě.</string>
<string name="car_player_beside_map_note">Strana mapy, ke které se přehrávač ukotví, když klepnete na jeho tlačítko ukotvení.</string>
<string name="carset_advanced">Pokročilé</string>
<string name="carset_experimental_heading">Experimentální</string>
<string name="carset_fit_needs_car">Připojte auto, abyste pro něj tuto úpravu uložili.</string>
<string name="choice_density_car">Z auta</string>
<string name="choice_density_dpi">%1$d DPI</string>
<string name="choice_density_custom">Vlastní…</string>
<string name="dialer_missed">Zmeškaný · %1$s</string>
<string name="dialer_no_recents">Žádné nedávné hovory.</string>
<string name="dialer_recents">Nedávné</string>
<string name="dialer_recents_needs_permission">Gearslip potřebuje oprávnění Seznamy hovorů, aby mohl zobrazit nedávné hovory.</string>
<string name="help_bluetooth_first">Připojte Bluetooth dřív než kabel.</string>
<string name="help_bluetooth_first_detail">Pokud se hudba přes Bluetooth nepřipojí, nechte auto nejdřív najít telefon přes Bluetooth a pak zapojte kabel. Některá auta se zmatou, když je kabel první.</string>
<string name="help_usb_data">Povolte data přes USB.</string>
<string name="help_usb_data_detail">GrapheneOS může blokovat data na portu USB. Pokud je ovládání portu USB-C nastavené na Pouze nabíjení, auto telefon nikdy neuvidí, ani s datovým kabelem. Nastavte povolení dat.</string>
<string name="launcher_in_car">Toto zařízení je připojené k autu, takže Gearslip je na obrazovce auta.</string>
<string name="launcher_open_gearslip">Otevřít Gearslip</string>
<string name="nav_arrival">příjezd</string>
<string name="nav_lane_any">Libovolný pruh</string>
<string name="nav_lane_from_left">Jeďte %1$d. pruhem zleva</string>
<string name="nav_lane_from_right">Jeďte %1$d. pruhem zprava</string>
<plurals name="nav_lane_left">
<item quantity="one">Jeďte levým pruhem</item>
<item quantity="few">Jeďte jedním z %d levých pruhů</item>
<item quantity="many">Jeďte jedním z %d levých pruhů</item>
<item quantity="other">Jeďte jedním z %d levých pruhů</item>
</plurals>
<string name="nav_lane_middle">Jeďte prostředním pruhem</string>
<string name="nav_lane_middle_lanes">Jeďte prostředními pruhy</string>
<plurals name="nav_lane_right">
<item quantity="one">Jeďte pravým pruhem</item>
<item quantity="few">Jeďte jedním z %d pravých pruhů</item>
<item quantity="many">Jeďte jedním z %d pravých pruhů</item>
<item quantity="other">Jeďte jedním z %d pravých pruhů</item>
</plurals>
<string name="nav_to_go">zbývá</string>
<string name="privacy_policy">Zásady ochrany soukromí</string>
<string name="privacy_policy_sub">Co Gearslip odesílá, komu a proč</string>
<string name="settings_bt_address">Adresa Bluetooth telefonu</string>
<string name="settings_bt_address_hint">Zkopírujte ji z nastavení Androidu v sekci Informace o telefonu. Gearslip ji předá autu, aby vědělo, že telefon na kabelu je ten spárovaný.</string>
<string name="settings_bt_address_invalid">To není adresa Bluetooth. Vypadá takto: 12:34:56:78:9A:BC.</string>
<string name="settings_bt_address_off">Nenastaveno. Jen pro auto, které při připojení Gearslipu odpojí Bluetooth.</string>
<string name="settings_use_as_launcher">Používat jako spouštěč</string>
<string name="settings_use_as_launcher_off">Pro autorádio nebo tablet na palubní desce. Obrazovka auta z Gearslipu se stane domovskou obrazovkou tohoto zařízení.</string>
<string name="settings_use_as_launcher_on">Gearslip může být domovskou obrazovkou tohoto zařízení. Vyberte ho v Výchozí aplikace plochy.</string>
<string name="status_no_car">Telefon se nabíjí, ale žádné auto neodpovědělo. Některé kabely jen nabíjejí; zkuste kabel, který přenáší data.</string>
<string name="status_no_car_graphene">Telefon se nabíjí, ale žádné auto neodpovědělo. Zkontrolujte, že ovládání portu USB-C v sekci Zabezpečení a ochrana soukromí povoluje data a není nastavené na Pouze nabíjení.</string>
<string name="status_open_settings">Otevřít nastavení</string>
<string name="status_stuck_cable">Auto zatím Gearslip nezobrazilo. Odpojte kabel, počkejte pár sekund a zapojte ho znovu. Pokud se to opakuje, zkuste jiný kabel nebo port USB a sdílejte protokol v issue.</string>
<string name="status_stuck_wireless">Auto zatím Gearslip nezobrazilo. Vypněte a znovu zapněte auto, nebo se připojte kabelem. Pokud se to opakuje, sdílejte protokol v issue.</string>
<string name="update_installed_text">Nainstalováno přes Wi-Fi, když nebylo připojené žádné auto.</string>
<string name="update_installed_title">Gearslip aktualizován na %1$s</string>
<string name="update_silent">Instalovat aktualizace samo</string>
<string name="update_silent_sub">Jen hotová vydání, přes Wi-Fi, když není připojené žádné auto</string>
<string name="assist_listening">Poslouchám…</string>
<string name="assist_one_moment">Okamžik…</string>
<string name="assist_examples">Zkuste „navigate home“ nebo „play“ a interpreta. Pro víc řekněte „help“.</string>
<string name="assist_grant">Povolit</string>
<string name="density_custom_title">Vlastní hustota</string>
<string name="density_custom_label">DPI</string>
<string name="density_custom_note">Libovolná hodnota od %1$d do %2$d. Nižší vejde víc na obrazovku, vyšší vše zvětší.</string>
<string name="web_add_bookmark">Přidat</string>
<string name="web_done">Hotovo</string>
<string name="web_remove_bookmark">Odebrat %1$s</string>
<string name="web_bookmark_added">Přidáno na úvodní stránku</string>
<string name="web_bookmark_removed">Odebráno z úvodní stránky</string>
<string name="web_bookmark_this">Přidat na úvodní stránku</string>
<string name="web_remove_this_bookmark">Odebrat z úvodní stránky</string>
<string name="browser_search_engine">Vyhledávač</string>
<string name="browser_bookmarks">Záložky</string>
<string name="browser_bookmarks_note">Zobrazují se jako dlaždice na úvodní stránce prohlížeče. Stránku můžete do Gearslip sdílet i z libovolného prohlížeče.</string>
<string name="browser_add_bookmark">Přidat záložku</string>
<string name="browser_bookmark_name">Název</string>
<string name="browser_bookmark_address">Webová adresa</string>
<string name="browser_remove">Odebrat</string>
<string name="browser_adblock">Blokovat reklamy a sledovací prvky</string>
<string name="browser_adblock_note">Používá seznamy filtrů, které se jednou týdně přes Wi-Fi aktualizují ze stránek, kde vycházejí.</string>
<string name="browser_youtube">Blokovat reklamy ve videích YouTube</string>
<string name="browser_youtube_note">Přeskakuje reklamy ve videích. YouTube se často mění, takže to vypněte, pokud se videa přestanou přehrávat.</string>
<string name="browser_filter_lists">Seznamy filtrů</string>
<string name="browser_add_list">Přidat seznam filtrů</string>
<string name="browser_list_address">Adresa seznamu</string>
<string name="browser_list_note">Jakýkoli seznam ve formátu Adblock Plus, třeba pro váš jazyk nebo region.</string>
<string name="browser_update_lists">Aktualizovat seznamy</string>
<string name="browser_lists_status">Pravidel: %1$d, aktualizováno %2$s</string>
<string name="browser_lists_never">Pravidel: %1$d, z kopie v Gearslip</string>
<string name="browser_updating">Aktualizace…</string>
<string name="browser_update_failed">Aktualizace se nezdařila. %1$s</string>
<string name="browser_shared_added">Přidáno na úvodní stránku Gearslip</string>
<string name="browser_shared_not_url">To není webová adresa</string>
<string name="browser_share_label">Úvodní stránka Gearslip</string>
<string name="summary_adblock_on">Reklamy blokovány</string>
<string name="summary_adblock_off">Reklamy neblokovány</string>
<string name="car_nav_mic">Tlačítko mikrofonu</string>
<string name="car_nav_mic_note">Tlačítko asistenta na navigační liště. Hlasové tlačítko auta funguje tak jako tak.</string>
<string name="car_apps_add">Přidat aplikaci</string>
<string name="car_apps_add_note">Pro aplikaci, kterou auto nezobrazuje: její hudba hraje přes přehrávač Gearslip, nebo se její zprávy zobrazí ve Zprávách.</string>
<string name="car_apps_add_pick">Vyberte aplikaci</string>
<string name="car_apps_add_as">Zobrazit %1$s jako</string>
<string name="car_apps_as_media">Hudba a zvuk</string>
<string name="car_apps_as_media_note">Právě hraje, s přehráním, pozastavením a přeskočením. Bez knihovny k procházení.</string>
<string name="car_apps_as_messaging">Zprávy</string>
<string name="car_apps_as_messaging_note">Její oznámení, předčítaná nahlas, s odpověďmi.</string>
<string name="car_apps_added_by_you">Přidáno vámi</string>
<string name="car_apps_remove">Odebrat %1$s</string>
<string name="cert_link">Stáhnout z odkazu</string>
<string name="cert_link_sub">Soubor certifikátu, kterému věříte, z adresy, kterou vložíte</string>
<string name="cert_link_title">Certifikát z odkazu</string>
<string name="cert_link_note">Vložte adresu souboru .p12 nebo certifikátu a klíče ve formátu PEM či DER. Gearslip ho převede do podoby, kterou auto potřebuje.</string>
<string name="cert_link_label">Odkaz na certifikát nebo .p12</string>
<string name="cert_link_key">Odkaz na soukromý klíč</string>
<string name="cert_link_key_note">Je to certifikát bez klíče. Vložte i odkaz na klíč.</string>
<string name="cert_link_password">Heslo</string>
<string name="cert_link_password_note">Tento soubor má heslo.</string>
<string name="cert_link_download">Stáhnout</string>
<string name="cert_link_again">Stáhnout znovu</string>
<string name="cert_link_done">Staženo a převedeno. Použije se od dalšího připojení.</string>
<string name="cert_link_failed">Nelze použít. %1$s</string>
</resources>
@@ -187,8 +187,6 @@
<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>
File diff suppressed because it is too large. Load diff
Loaded 100 of 118 files, more files were not shown because too many files have changed in this diff. Show more