Merge branch 'experimental' into wireless

# Conflicts:
#	gearslip/src/main/java/app/seb3thehacker/gearslip/GearslipActivity.kt
#	gearslip/src/main/java/app/seb3thehacker/gearslip/ui/HomeScreen.kt
This commit is contained in:
Seb3thehacker committed 2026-10-11 17:22:58 -07:00
commit 669bf1f7ce
54 files changed
+2819 -206

No files matched your search

+8 -1
View File
@@ -30,4 +30,11 @@ CLAUDE.md
# Other # Other
archive/ archive/
.wrangler/ .wrangler/
stats-worker/ stats-worker/
# Certificates and keys never go in the repo; the app downloads or imports them on the phone.
*.p12
*.pfx
*.pem
*.crt
*.key
+102
View File
@@ -0,0 +1,102 @@
# Gearslip Contributor License Agreement
Version 1.0, October 2026
Thank you for contributing to Gearslip. This agreement lets Twink Software LLC, the company
behind Gearslip, use your contribution. You keep the copyright in your work. You sign once,
and the agreement covers every contribution you make to Gearslip, past and future.
## Why we ask
Gearslip is free software under the GNU Affero General Public License, version 3 (the
"AGPL"). Twink Software LLC also sells commercial licenses to companies that cannot meet the
AGPL's terms, and that income helps pay for the project. Offering your code on those terms
needs your permission, and this agreement gives it.
## 1. Definitions
- **"We"** and **"us"** mean Twink Software LLC, a California limited liability company
(Secretary of State entity number B20260081700), under that name or any other, and its
successors and assigns.
- **"You"** means the person who signs this agreement.
- **"Contribution"** means any work of authorship, such as code, documentation, or images,
that you submit to Gearslip, whether before or after you sign. A submission includes a
pull request, patch, or issue attachment sent to a repository or channel we manage. It
excludes anything you mark in writing as "Not a Contribution".
## 2. Copyright license
You grant us, and anyone who receives software from us, a perpetual, worldwide,
non-exclusive, no-charge, royalty-free, irrevocable license to reproduce, prepare derivative
works of, publicly display, publicly perform, sublicense, and distribute your Contributions
and derivative works of them.
This license lets us offer your Contributions under licenses other than the AGPL, including
commercial licenses.
## 3. Patent license
You grant us, and anyone who receives software from us, a perpetual, worldwide,
non-exclusive, no-charge, royalty-free, irrevocable patent license to make, have made, use,
offer to sell, sell, import, and otherwise transfer your Contributions. This license covers
only the patent claims you can license that your Contribution, alone or combined with
Gearslip, would infringe.
If anyone sues claiming that Gearslip or a Contribution infringes a patent, the patent
licenses granted to that party for Gearslip end on the date the suit is filed.
## 4. Our promise to you
As long as we distribute a Contribution, we will also offer it under the AGPL or another
license approved by the Open Source Initiative. Your work will always stay available as
free software.
## 5. Your promises
You promise that:
1. **The work is yours.** Each Contribution is your original creation, and you have the
right to grant these licenses.
2. **Your employer agrees.** If your employer has rights in what you create, you have its
permission to contribute on its behalf, or it has waived its rights in your
Contributions.
3. **You name other people's work.** If any part of a Contribution is not your own, you say
so when you submit it, and you name its source and its license.
4. **You'll tell us of changes.** If any of these promises stops being true, you will tell
us.
## 6. No warranty
You provide your Contributions "as is", without warranties or conditions of any kind. You do
not have to provide support for them.
## 7. No obligation
We are not required to use or include any Contribution.
## 8. General
- California law governs this agreement.
- This agreement does not change the rights you have under the AGPL in Gearslip as
released.
- We may publish a new version of this agreement. A new version applies only to
Contributions you make after you sign it; this version keeps covering the ones you made
under it.
## Signing
Most contributors sign on GitHub: the CLA Assistant bot comments on your first pull request,
and you sign there with one click. Your GitHub account then serves as your signature.
To sign by hand instead, fill in the following and send it to us on Matrix. The address is
on the [Seb3thehacker GitHub profile](https://github.com/Seb3thehacker).
Name: ______________________________
GitHub username: ______________________________
Email: ______________________________
Signature: ______________________________
Date: ______________________________
+7 -3
View File
@@ -46,6 +46,10 @@ voice, state things positively, cut needless words, and use the serial comma.
## License ## License
Gearslip is licensed under AGPL-3.0, and your contributions are too. The author also sells a Gearslip is licensed under AGPL-3.0, and your contributions are too. Twink Software LLC, which
commercial license, described in [NOTICE.md](NOTICE.md). By opening a pull request, you owns Gearslip, also sells a commercial license, described in [NOTICE.md](NOTICE.md).
agree that your contribution may be offered under that license as well.
Before we can merge your first pull request, you sign the
[Contributor License Agreement](CLA.md). You keep the copyright in your work, and it stays
available as free software; the agreement lets us offer it under the commercial license too.
A bot comments on your pull request with a link, and signing takes one click.
+5 -2
View File
@@ -6,6 +6,11 @@ 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="54">](https://apps.obtainium.imranr.dev/redirect?r=obtainium://add/https://github.com/Seb3thehacker/gearslip) [<img src="https://raw.githubusercontent.com/ImranR98/Obtainium/main/assets/graphics/badge_obtainium.png" alt="Get it on Obtainium" height="54">](https://apps.obtainium.imranr.dev/redirect?r=obtainium://add/https://github.com/Seb3thehacker/gearslip)
## Which cars work
Most cars work. Starting around 2020, some cars added newer security standards that reject
Gearslip's certificate, and Gearslip can't run in those cars.
## What Gearslip is ## What Gearslip is
Gearslip is an Android Auto client for the phone. It speaks the Android Auto protocol Gearslip is an Android Auto client for the phone. It speaks the Android Auto protocol
@@ -63,8 +68,6 @@ mode.
- **Some apps refuse Gearslip.** Spotify, Waze, and Google Messages use a version of - **Some apps refuse Gearslip.** Spotify, Waze, and Google Messages use a version of
Google's car library that accepts only Android Auto itself. Spotify still plays through Google's car library that accepts only Android Auto itself. Spotify still plays through
Gearslip's own player. Gearslip's own player.
- **Gearslip works best with cars from before 2020.** Some newer head units reject its
certificate.
- **Calls use the car's Bluetooth,** as they do in Android Auto. Pair the phone with the - **Calls use the car's Bluetooth,** as they do in Android Auto. Pair the phone with the
car to hear calls through its speakers. car to hear calls through its speakers.
- **Audio reaches the car by capturing playback, not by mirroring the screen.** Android - **Audio reaches the car by capturing playback, not by mirroring the screen.** Android
Binary file not shown.

Before

Width:  |  Height:  |  Size: 17 KiB

@@ -1,14 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" width="432" height="432" viewBox="0 0 108 108">
<defs>
<linearGradient id="trail" x1="73.4" y1="46.0" x2="95.8" y2="44.5" gradientUnits="userSpaceOnUse">
<stop offset="0%" stop-color="#8C1F1F" stop-opacity="0"/>
<stop offset="100%" stop-color="#8C1F1F" stop-opacity="0.7"/>
</linearGradient>
</defs>
<rect width="108" height="108" fill="#0c0c0d"/>
<circle cx="54" cy="54" r="52" fill="#131415"/>
<path d="M 73.23,47.15 L 94.77,51.90 L 96.89,37.05 L 73.57,44.78 Z" fill="url(#trail)"/>
<path d="M 54.00,27.00 L 59.99,19.91 Q 61.02,18.69 62.50,19.30 L 72.52,23.45 Q 74.00,24.07 73.87,25.66 L 73.23,33.31 Q 73.09,34.91 73.70,36.39 L 80.39,52.52 Q 81.00,54.00 82.22,55.03 L 88.09,59.99 Q 89.31,61.02 88.70,62.50 L 84.55,72.52 Q 83.93,74.00 82.34,73.87 L 73.09,73.09 Q 73.09,73.09 73.09,73.09 L 73.09,73.09 Q 73.09,73.09 73.09,73.09 L 73.87,82.34 Q 74.00,83.93 72.52,84.55 L 62.50,88.70 Q 61.02,89.31 59.99,88.09 L 54.00,81.00 Q 54.00,81.00 54.00,81.00 L 54.00,81.00 Q 54.00,81.00 54.00,81.00 L 48.01,88.09 Q 46.98,89.31 45.50,88.70 L 35.48,84.55 Q 34.00,83.93 34.13,82.34 L 34.91,73.09 Q 34.91,73.09 34.91,73.09 L 34.91,73.09 Q 34.91,73.09 34.91,73.09 L 25.66,73.87 Q 24.07,74.00 23.45,72.52 L 19.30,62.50 Q 18.69,61.02 19.91,59.99 L 27.00,54.00 Q 27.00,54.00 27.00,54.00 L 27.00,54.00 Q 27.00,54.00 27.00,54.00 L 19.91,48.01 Q 18.69,46.98 19.30,45.50 L 23.45,35.48 Q 24.07,34.00 25.66,34.13 L 34.91,34.91 Q 34.91,34.91 34.91,34.91 L 34.91,34.91 Q 34.91,34.91 34.91,34.91 L 34.13,25.66 Q 34.00,24.07 35.48,23.45 L 45.50,19.30 Q 46.98,18.69 48.01,19.91 L 54.00,27.00 Q 54.00,27.00 54.00,27.00 L 54.00,27.00 Q 54.00,27.00 54.00,27.00 Z" fill="#8B9096"/>
<path d="M 92.78,34.42 L 99.94,37.20 Q 101.43,37.78 101.34,39.37 L 100.67,50.20 Q 100.58,51.80 99.03,52.19 L 91.58,54.08 Q 90.03,54.47 90.13,52.87 L 91.19,35.44 Q 91.29,33.84 92.78,34.42 Z" fill="#8C1F1F"/>
<circle cx="54" cy="54" r="12" fill="#0c0c0d"/>
</svg>

Before

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

After

Width:  |  Height:  |  Size: 10 KiB

-1
View File
@@ -5,7 +5,6 @@
<stop offset="100%" stop-color="#8C1F1F" stop-opacity="0.7"/> <stop offset="100%" stop-color="#8C1F1F" stop-opacity="0.7"/>
</linearGradient> </linearGradient>
</defs> </defs>
<rect width="108" height="108" fill="#0c0c0d"/>
<circle cx="54" cy="54" r="52" fill="#131415"/> <circle cx="54" cy="54" r="52" fill="#131415"/>
<path d="M 73.23,47.15 L 94.77,51.90 L 96.89,37.05 L 73.57,44.78 Z" fill="url(#trail)"/> <path d="M 73.23,47.15 L 94.77,51.90 L 96.89,37.05 L 73.57,44.78 Z" fill="url(#trail)"/>
<path d="M 54.00,27.00 L 61.02,18.69 L 74.00,24.07 L 73.09,34.91 L 81.00,54.00 L 89.31,61.02 L 83.93,74.00 L 73.09,73.09 L 73.09,73.09 L 74.00,83.93 L 61.02,89.31 L 54.00,81.00 L 54.00,81.00 L 46.98,89.31 L 34.00,83.93 L 34.91,73.09 L 34.91,73.09 L 24.07,74.00 L 18.69,61.02 L 27.00,54.00 L 27.00,54.00 L 18.69,46.98 L 24.07,34.00 L 34.91,34.91 L 34.91,34.91 L 34.00,24.07 L 46.98,18.69 L 54.00,27.00 Z" fill="#8B9096"/> <path d="M 54.00,27.00 L 61.02,18.69 L 74.00,24.07 L 73.09,34.91 L 81.00,54.00 L 89.31,61.02 L 83.93,74.00 L 73.09,73.09 L 73.09,73.09 L 74.00,83.93 L 61.02,89.31 L 54.00,81.00 L 54.00,81.00 L 46.98,89.31 L 34.00,83.93 L 34.91,73.09 L 34.91,73.09 L 24.07,74.00 L 18.69,61.02 L 27.00,54.00 L 27.00,54.00 L 18.69,46.98 L 24.07,34.00 L 34.91,34.91 L 34.91,34.91 L 34.00,24.07 L 46.98,18.69 L 54.00,27.00 Z" fill="#8B9096"/>

Before

Width:  |  Height:  |  Size: 1.1 KiB

After

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 79 KiB

+2 -1
View File
@@ -33,7 +33,8 @@ android {
minSdk = 31 minSdk = 31
targetSdk = 37 targetSdk = 37
versionCode = 8 versionCode = 8
versionName = "0.2.00-alpha" // -PgearslipVersionName=0.1.99-test makes a build that thinks it's older, to try the updater.
versionName = project.findProperty("gearslipVersionName")?.toString() ?: "0.2.00-alpha"
manifestPlaceholders["appLabel"] = "Gearslip" manifestPlaceholders["appLabel"] = "Gearslip"
// Where opted-in phones send their daily usage note: the stats-worker/ Worker's address, // Where opted-in phones send their daily usage note: the stats-worker/ Worker's address,
// set as gearslipStatsUrl in gradle.properties. Left empty, nothing is ever sent. // set as gearslipStatsUrl in gradle.properties. Left empty, nothing is ever sent.
+9 -1
View File
@@ -4,6 +4,7 @@
<!-- The projected WebView loads real pages; without this every load fails silently. --> <!-- The projected WebView loads real pages; without this every load fails silently. -->
<uses-permission android:name="android.permission.INTERNET" /> <uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
<!-- <!--
Phone mirroring. Android refuses to hand over a MediaProjection unless a foreground Phone mirroring. Android refuses to hand over a MediaProjection unless a foreground
@@ -88,8 +89,10 @@
LAUNCHER query below to find, so the setup guide's Android Auto check needs this to see LAUNCHER query below to find, so the setup guide's Android Auto check needs this to see
whether the package is installed at all. --> whether the package is installed at all. -->
<package android:name="com.google.android.projection.gearhead" /> <package android:name="com.google.android.projection.gearhead" />
<!-- Every GrapheneOS install has this; the opt-in usage note uses it to say "GrapheneOS". --> <!-- The opt-in usage note names the OS: GrapheneOS's setup app and /e/OS's app store give
them away (UsageStats.osName). -->
<package android:name="app.grapheneos.setupwizard" /> <package android:name="app.grapheneos.setupwizard" />
<package android:name="foundation.e.apps" />
<intent> <intent>
<action android:name="android.intent.action.MAIN" /> <action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" /> <category android:name="android.intent.category.LAUNCHER" />
@@ -173,6 +176,11 @@
android:name=".notify.TestMessageReceiver" android:name=".notify.TestMessageReceiver"
android:exported="false" /> android:exported="false" />
<!-- The one-time "Gearslip worked with your car" ask: its Share and Not now buttons. -->
<receiver
android:name=".stats.WorkedPromptReceiver"
android:exported="false" />
<service <service
android:name=".mirror.MirrorService" android:name=".mirror.MirrorService"
android:exported="false" android:exported="false"
@@ -15,6 +15,8 @@ import android.view.Surface
import android.view.WindowManager import android.view.WindowManager
import androidx.activity.ComponentActivity import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent import androidx.activity.compose.setContent
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.activity.enableEdgeToEdge import androidx.activity.enableEdgeToEdge
import androidx.activity.result.contract.ActivityResultContracts.StartActivityForResult import androidx.activity.result.contract.ActivityResultContracts.StartActivityForResult
import android.content.res.Configuration import android.content.res.Configuration
@@ -87,9 +89,13 @@ class GearslipActivity : ComponentActivity(), Projection {
} else { } else {
GearslipLog.w("audio: capture consent declined") GearslipLog.w("audio: capture consent declined")
CarAudio.denied() CarAudio.denied()
if (!CarSettings.audioChoiceAsked) offerBluetooth.value = true
} }
} }
/** Raised once, after the first "no" to the capture dialog; see [AudioChoiceDialog]. */
private val offerBluetooth = kotlinx.coroutines.flow.MutableStateFlow(false)
private val detachReceiver = object : BroadcastReceiver() { private val detachReceiver = object : BroadcastReceiver() {
override fun onReceive(context: Context?, intent: Intent?) { override fun onReceive(context: Context?, intent: Intent?) {
if (intent?.action == UsbManager.ACTION_USB_ACCESSORY_DETACHED) { if (intent?.action == UsbManager.ACTION_USB_ACCESSORY_DETACHED) {
@@ -134,6 +140,20 @@ class GearslipActivity : ComponentActivity(), Projection {
setContent { setContent {
GearslipTheme { GearslipTheme {
GearslipApp(onDisconnect = { closeAccessory() }, onRequestCallScreening = ::requestCallScreeningRole) GearslipApp(onDisconnect = { closeAccessory() }, onRequestCallScreening = ::requestCallScreeningRole)
val offer by offerBluetooth.collectAsState()
if (offer) {
fun close() {
CarSettings.markAudioChoiceAsked()
offerBluetooth.value = false
}
app.seb3thehacker.gearslip.ui.AudioChoiceDialog(
onUseBluetooth = {
CarSettings.setPipeAudio(false)
close()
},
onKeepAsking = ::close,
)
}
} }
} }
@@ -158,6 +178,7 @@ class GearslipActivity : ComponentActivity(), Projection {
super.onStart() super.onStart()
Prefetch.warm(this) Prefetch.warm(this)
UsageStats.onAppOpened(this) UsageStats.onAppOpened(this)
app.seb3thehacker.gearslip.update.UpdateChecker.onAppOpened(this)
} }
override fun onNewIntent(intent: Intent) { override fun onNewIntent(intent: Intent) {
@@ -371,6 +392,8 @@ class GearslipActivity : ComponentActivity(), Projection {
} }
override fun onProjectionStopped() { override fun onProjectionStopped() {
// Before the car UI goes, while the media connection is still up.
app.seb3thehacker.gearslip.car.CarServices.pausePhoneMedia()
runOnUiThread { runOnUiThread {
PhoneMirror.requestStop() PhoneMirror.requestStop()
app.seb3thehacker.gearslip.car.CarKeys.focusSink = null app.seb3thehacker.gearslip.car.CarKeys.focusSink = null
@@ -3,6 +3,8 @@ package app.seb3thehacker.gearslip
import android.os.Build import android.os.Build
import app.seb3thehacker.gearslip.audio.AudioLink import app.seb3thehacker.gearslip.audio.AudioLink
import app.seb3thehacker.gearslip.audio.CarMic import app.seb3thehacker.gearslip.audio.CarMic
import app.seb3thehacker.gearslip.media.CarMediaStatus
import app.seb3thehacker.gearslip.car.ClusterNav
import app.seb3thehacker.gearslip.car.CarAssistant import app.seb3thehacker.gearslip.car.CarAssistant
import app.seb3thehacker.gearslip.car.CarEnvironment import app.seb3thehacker.gearslip.car.CarEnvironment
import app.seb3thehacker.gearslip.car.CarKeys import app.seb3thehacker.gearslip.car.CarKeys
@@ -47,6 +49,8 @@ class GearslipRunner(
/** Waits for the certificate only when TLS is first needed; see [run]. */ /** Waits for the certificate only when TLS is first needed; see [run]. */
private val tls: PhoneTls by lazy { identityTask.get() } private val tls: PhoneTls by lazy { identityTask.get() }
private var authenticationFailed = false
@Volatile private var stopped = false
@Volatile private var state = State.WAIT_VERSION @Volatile private var state = State.WAIT_VERSION
@Volatile private var lastProgress = System.currentTimeMillis() @Volatile private var lastProgress = System.currentTimeMillis()
@@ -68,6 +72,8 @@ class GearslipRunner(
@Volatile private var inputChannelId: Int = -1 @Volatile private var inputChannelId: Int = -1
@Volatile private var inputKeycodes: List<Int> = emptyList() @Volatile private var inputKeycodes: List<Int> = emptyList()
@Volatile private var sensorChannelId: Int = -1 @Volatile private var sensorChannelId: Int = -1
@Volatile private var mediaStatus: CarMediaStatus? = null
@Volatile private var clusterNav: ClusterNav? = null
private var sensorTypes: List<Int> = emptyList() private var sensorTypes: List<Int> = emptyList()
@Volatile private var audioLink: AudioLink? = null @Volatile private var audioLink: AudioLink? = null
private var audioMessagesSeen = 0 private var audioMessagesSeen = 0
@@ -107,6 +113,7 @@ class GearslipRunner(
try { try {
while (running) { while (running) {
val read = input.read(chunk) val read = input.read(chunk)
if (stopped) return
if (read < 0) { if (read < 0) {
log.w("head unit closed the connection (EOF) while in state $state") log.w("head unit closed the connection (EOF) while in state $state")
reportDisconnect() reportDisconnect()
@@ -120,16 +127,19 @@ class GearslipRunner(
} }
} }
} catch (t: Throwable) { } catch (t: Throwable) {
if (stopped) return
log.e("transport failed in state $state", t) log.e("transport failed in state $state", t)
SessionReport.fail(SessionReport.Category.USB, "transport failed", t, state.name) SessionReport.fail(SessionReport.Category.USB, "transport failed", t, state.name)
reportDisconnect() reportDisconnect()
} finally { } finally {
running = false running = false
writer.shutdownNow()
log.flush() log.flush()
} }
} }
fun stop() { fun stop() {
stopped = true
running = false running = false
writer.shutdownNow() writer.shutdownNow()
CarFocus.request = null CarFocus.request = null
@@ -141,6 +151,10 @@ class GearslipRunner(
audioLink = null audioLink = null
CarSensors.clear() CarSensors.clear()
CarMic.current = null CarMic.current = null
mediaStatus?.stop()
mediaStatus = null
clusterNav?.stop()
clusterNav = null
lastSensorNight = null lastSensorNight = null
CarEnvironment.setSensorNight(null) // back to the clock until a car says otherwise CarEnvironment.setSensorNight(null) // back to the clock until a car says otherwise
projection?.onProjectionStopped() projection?.onProjectionStopped()
@@ -148,6 +162,8 @@ class GearslipRunner(
} }
private fun handleFrame(frame: Frames.Frame) { private fun handleFrame(frame: Frames.Frame) {
// A rejected handshake cannot consume later messages as if authentication succeeded.
if (authenticationFailed || stopped) return
val payload = if (frame.encrypted) { val payload = if (frame.encrypted) {
if (!tls.handshakeComplete) { if (!tls.handshakeComplete) {
log.w("encrypted frame arrived before the handshake finished - ignoring") log.w("encrypted frame arrived before the handshake finished - ignoring")
@@ -176,6 +192,10 @@ class GearslipRunner(
onInputMessage(messageId, body) onInputMessage(messageId, body)
} else if (frame.channel == sensorChannelId) { } else if (frame.channel == sensorChannelId) {
onSensorMessage(messageId, body) onSensorMessage(messageId, body)
} else if (frame.channel == clusterNav?.channelId) {
clusterNav?.onMessage(messageId, body)
} else if (frame.channel == mediaStatus?.channelId) {
mediaStatus?.onMessage(messageId, body)
} else if (frame.channel == CarMic.current?.channelId) { } else if (frame.channel == CarMic.current?.channelId) {
CarMic.current?.onMessage(messageId, body) CarMic.current?.onMessage(messageId, body)
} else if (frame.channel == audioLink?.channelId) { } else if (frame.channel == audioLink?.channelId) {
@@ -225,7 +245,7 @@ class GearslipRunner(
} }
log.w("<- ByeByeRequest: reason=$reason ($name) in state $state") log.w("<- ByeByeRequest: reason=$reason ($name) in state $state")
log.hex(" byebye raw", body) log.hex(" byebye raw", body)
SessionReport.fail(SessionReport.Category.BYEBYE, "ByeByeRequest reason=$reason ($name)", state.name) SessionReport.fail(SessionReport.Category.BYEBYE, "ByeByeRequest reason=$reason ($name)", state.name, "bye $reason")
send(MSG_BYEBYE_RESPONSE, ByteArray(0), encrypted = true) send(MSG_BYEBYE_RESPONSE, ByteArray(0), encrypted = true)
log.i("-> ByeByeResponse") log.i("-> ByeByeResponse")
} }
@@ -262,10 +282,14 @@ class GearslipRunner(
} }
private fun onHandshake(body: ByteArray) { private fun onHandshake(body: ByteArray) {
if (state != State.TLS_HANDSHAKE && state != State.WAIT_AUTH) return
log.i("<- EncapsulatedSSL (${body.size} bytes)") log.i("<- EncapsulatedSSL (${body.size} bytes)")
val replies = try { val replies = try {
tls.pumpHandshake(body) val preparedTls = tls
if (stopped) return
preparedTls.pumpHandshake(body)
} catch (t: Throwable) { } catch (t: Throwable) {
if (stopped) return
log.e("TLS handshake threw", t) log.e("TLS handshake threw", t)
SessionReport.fail(SessionReport.Category.TLS, "handshake threw", t, state.name) SessionReport.fail(SessionReport.Category.TLS, "handshake threw", t, state.name)
log.verdict( log.verdict(
@@ -278,9 +302,11 @@ class GearslipRunner(
"The head unit aborted the secure handshake. It validates the phone's certificate.", "The head unit aborted the secure handshake. It validates the phone's certificate.",
) )
state = State.DONE state = State.DONE
authenticationFailed = true
SessionReport.print() SessionReport.print()
return return
} }
if (stopped) return
replies.forEach { replies.forEach {
send(MSG_ENCAPSULATED_SSL, it, encrypted = false) send(MSG_ENCAPSULATED_SSL, it, encrypted = false)
log.i("-> EncapsulatedSSL (${it.size} bytes)") log.i("-> EncapsulatedSSL (${it.size} bytes)")
@@ -293,6 +319,7 @@ class GearslipRunner(
private fun onAuthComplete(body: ByteArray) { private fun onAuthComplete(body: ByteArray) {
val status = Protobuf.readInt32Field(body, 1) val status = Protobuf.readInt32Field(body, 1)
if (state != State.WAIT_AUTH && !(state == State.TLS_HANDSHAKE && status in listOf(-2, -3))) return
log.i("<- AuthComplete: status=$status") log.i("<- AuthComplete: status=$status")
status?.let { SessionReport.auth(it) } status?.let { SessionReport.auth(it) }
@@ -304,7 +331,7 @@ class GearslipRunner(
sendServiceDiscoveryRequest() sendServiceDiscoveryRequest()
} }
-2 -> { -2 -> {
SessionReport.fail(SessionReport.Category.CERTIFICATE, "AuthComplete status -2 (certificate error)", state.name) SessionReport.fail(SessionReport.Category.CERTIFICATE, "AuthComplete status -2 (certificate error)", state.name, "auth -2")
log.verdict( log.verdict(
"NOT VIABLE (STATUS_CERTIFICATE_ERROR)", "NOT VIABLE (STATUS_CERTIFICATE_ERROR)",
"The head unit explicitly rejected the phone's self-signed certificate. " + "The head unit explicitly rejected the phone's self-signed certificate. " +
@@ -316,9 +343,10 @@ class GearslipRunner(
"The head unit refused the phone's certificate (certificate error).", "The head unit refused the phone's certificate (certificate error).",
) )
state = State.DONE state = State.DONE
authenticationFailed = true
} }
-3 -> { -3 -> {
SessionReport.fail(SessionReport.Category.AUTH, "AuthComplete status -3 (authentication failure)", state.name) SessionReport.fail(SessionReport.Category.AUTH, "AuthComplete status -3 (authentication failure)", state.name, "auth -3")
log.verdict( log.verdict(
"NOT VIABLE (STATUS_AUTHENTICATION_FAILURE)", "NOT VIABLE (STATUS_AUTHENTICATION_FAILURE)",
"Authentication rejected. Broader than a pure certificate error, but the " + "Authentication rejected. Broader than a pure certificate error, but the " +
@@ -329,9 +357,10 @@ class GearslipRunner(
"Authentication failed. The head unit does not trust the certificate in use.", "Authentication failed. The head unit does not trust the certificate in use.",
) )
state = State.DONE state = State.DONE
authenticationFailed = true
} }
else -> { else -> {
SessionReport.fail(SessionReport.Category.AUTH, "AuthComplete status $status (unexpected)", state.name) SessionReport.fail(SessionReport.Category.AUTH, "AuthComplete status $status (unexpected)", state.name, "auth $status")
log.verdict( log.verdict(
"INCONCLUSIVE (AuthComplete status=$status)", "INCONCLUSIVE (AuthComplete status=$status)",
"Unexpected status. Check MessageStatus.proto in aasdk for the meaning " + "Unexpected status. Check MessageStatus.proto in aasdk for the meaning " +
@@ -339,6 +368,7 @@ class GearslipRunner(
) )
SessionStatus.failed("Unexpected response", "The head unit answered authentication with status $status.") SessionStatus.failed("Unexpected response", "The head unit answered authentication with status $status.")
state = State.DONE state = State.DONE
authenticationFailed = true
} }
} }
} }
@@ -399,7 +429,7 @@ class GearslipRunner(
val video = ServiceDiscovery.findVideoService(serviceDiscoveryResponse) val video = ServiceDiscovery.findVideoService(serviceDiscoveryResponse)
if (video == null) { if (video == null) {
log.w("no video service in the discovery response - cannot start Phase A") log.w("no video service in the discovery response - cannot start Phase A")
SessionReport.fail(SessionReport.Category.VIDEO, "head unit advertised no video service", state.name) SessionReport.fail(SessionReport.Category.VIDEO, "head unit advertised no video service", state.name, "no video service")
return return
} }
videoChannelId = video.serviceId videoChannelId = video.serviceId
@@ -417,6 +447,8 @@ class GearslipRunner(
startAudioChannel(serviceDiscoveryResponse) startAudioChannel(serviceDiscoveryResponse)
startSensorChannel(serviceDiscoveryResponse) startSensorChannel(serviceDiscoveryResponse)
startMicChannel(serviceDiscoveryResponse) startMicChannel(serviceDiscoveryResponse)
startMediaStatusChannel(serviceDiscoveryResponse)
startClusterNavChannel(serviceDiscoveryResponse)
val input = ServiceDiscovery.findInputService(serviceDiscoveryResponse, video.displayId) val input = ServiceDiscovery.findInputService(serviceDiscoveryResponse, video.displayId)
if (input == null) { if (input == null) {
@@ -478,6 +510,28 @@ class GearslipRunner(
openChannel(mic.serviceId) openChannel(mic.serviceId)
} }
/** What's playing, for the car's own screens: the cluster, the media source page. */
private fun startMediaStatusChannel(serviceDiscoveryResponse: ByteArray) {
val channel = ServiceDiscovery.findMediaStatusChannel(serviceDiscoveryResponse) ?: run {
log.i("no media status channel - the car's own screens won't show what's playing")
return
}
log.i("media status channel: $channel")
mediaStatus = CarMediaStatus(channel) { id, body -> send(id, body, encrypted = true, channel = channel) }
openChannel(channel)
}
/** Turn-by-turn for the instrument cluster: the next turn, its road, and how far. */
private fun startClusterNavChannel(serviceDiscoveryResponse: ByteArray) {
val channel = ServiceDiscovery.findNavStatusChannel(serviceDiscoveryResponse) ?: run {
log.i("no navigation status channel - the cluster won't show turns")
return
}
log.i("navigation status channel: $channel")
clusterNav = ClusterNav(channel) { id, body -> send(id, body, encrypted = true, channel = channel) }
openChannel(channel)
}
/** /**
* Every sensor type the head unit advertised, subscribed to at once - there is no screen * Every sensor type the head unit advertised, subscribed to at once - there is no screen
* to pick and choose yet, this is meant to show everything reachable. [CarSensors] answers * to pick and choose yet, this is meant to show everything reachable. [CarSensors] answers
@@ -1065,10 +1119,10 @@ class GearslipRunner(
private fun reportDisconnect() { private fun reportDisconnect() {
when (state) { when (state) {
State.WAIT_VERSION -> SessionReport.fail(SessionReport.Category.NO_HANDSHAKE, "no VersionRequest arrived", state.name) State.WAIT_VERSION -> SessionReport.fail(SessionReport.Category.NO_HANDSHAKE, "no VersionRequest arrived", state.name, "closed")
State.TLS_HANDSHAKE -> SessionReport.fail(SessionReport.Category.TLS, "head unit dropped the link during TLS", state.name) State.TLS_HANDSHAKE -> SessionReport.fail(SessionReport.Category.TLS, "head unit dropped the link during TLS", state.name, "closed")
State.WAIT_AUTH -> SessionReport.fail(SessionReport.Category.AUTH, "dropped after TLS, before AuthComplete", state.name) State.WAIT_AUTH -> SessionReport.fail(SessionReport.Category.AUTH, "dropped after TLS, before AuthComplete", state.name, "closed")
State.WAIT_SDR -> SessionReport.fail(SessionReport.Category.SERVICE_DISCOVERY, "accepted, then dropped before service discovery", state.name) State.WAIT_SDR -> SessionReport.fail(SessionReport.Category.SERVICE_DISCOVERY, "accepted, then dropped before service discovery", state.name, "closed")
State.DONE -> {} State.DONE -> {}
} }
when (state) { when (state) {
@@ -31,6 +31,9 @@ object Protobuf {
return varint(((field shl 3) or 2).toLong()) + varint(bytes.size.toLong()) + bytes return varint(((field shl 3) or 2).toLong()) + varint(bytes.size.toLong()) + bytes
} }
fun bytesField(field: Int, bytes: ByteArray): ByteArray =
varint(((field shl 3) or 2).toLong()) + varint(bytes.size.toLong()) + bytes
fun varintField(field: Int, value: Long): ByteArray = fun varintField(field: Int, value: Long): ByteArray =
varint((field shl 3).toLong()) + varint(value) varint((field shl 3).toLong()) + varint(value)
@@ -198,6 +198,12 @@ class ScreenProjector(private val context: Context) {
*/ */
fun dispatchKey(keyCode: Int, down: Boolean) { fun dispatchKey(keyCode: Int, down: Boolean) {
val target = root ?: return val target = root ?: return
// Keys handed straight to the view skip the step where Android leaves touch mode, and in
// touch mode Compose's buttons refuse focus, so the knob had nothing to move between.
if (target.isInTouchMode) {
target.requestFocusFromTouch()
GearslipLog.i("keys: left touch mode for the knob/D-pad (now in touch mode: ${target.isInTouchMode})")
}
// Nothing on the display takes keys until something in it holds focus. // Nothing on the display takes keys until something in it holds focus.
if (target.findFocus() == null) target.requestFocus() if (target.findFocus() == null) target.requestFocus()
val shift = keyCode == app.seb3thehacker.gearslip.car.CarKeys.KEYCODE_SHIFT_TAB val shift = keyCode == app.seb3thehacker.gearslip.car.CarKeys.KEYCODE_SHIFT_TAB
@@ -110,6 +110,21 @@ object ServiceDiscovery {
return media.firstOrNull { it.codecType == CODEC_PCM } ?: media.firstOrNull() return media.firstOrNull { it.codecType == CODEC_PCM } ?: media.firstOrNull()
} }
/**
* The channel the car takes "now playing" on (MediaPlaybackStatusService, Service field 9),
* which feeds its cluster and media screens. It carries no settings. Null when the car has none.
*/
fun findMediaStatusChannel(response: ByteArray): Int? = findChannelWith(response, 9)
/** The instrument cluster's turn-by-turn channel (NavigationStatusService, Service field 8). */
fun findNavStatusChannel(response: ByteArray): Int? = findChannelWith(response, 8)
private fun findChannelWith(response: ByteArray, serviceField: Int): Int? =
Wire.allBytes(Wire.fields(response), 1).firstNotNullOfOrNull { channel ->
val service = Wire.fields(channel)
if (service.any { it.number == serviceField }) Wire.varint(service, 1)?.toInt() else null
}
/** /**
* The car's microphone, from MediaSourceService (Service field 5): * The car's microphone, from MediaSourceService (Service field 5):
* MediaSourceService { MediaCodecType available_type = 1; AudioConfiguration audio_config = 2; * MediaSourceService { MediaCodecType available_type = 1; AudioConfiguration audio_config = 2;
@@ -29,12 +29,30 @@ object SessionReport {
UNKNOWN("unclassified"), UNKNOWN("unclassified"),
} }
/**
* How far a session got, in the order a connection is built. The usage note's "Where
* connections fail" share sends the furthest one reached, so a failure says where it broke.
*/
enum class Stage(val key: String) {
USB("usb"), // the link opened; the car may have said nothing
VERSIONS("versions"), // the car said which protocol it speaks
TLS("tls"), // the secure link is up
ACCEPTED("accepted"), // the car accepted the certificate
CHANNELS("channels"), // the car listed its channels and said who it is
VIDEO("video"), // the car's screen is set up for Gearslip
STREAMING("streaming"), // video frames are reaching the car
}
private var appVersion = "?" private var appVersion = "?"
private var appCode = 0L private var appCode = 0L
@Volatile private var startedAt = 0L @Volatile private var startedAt = 0L
@Volatile var category = Category.NONE; private set @Volatile var category = Category.NONE; private set
@Volatile private var failure = "" @Volatile private var failure = ""
@Volatile var stage = Stage.USB; private set
/** What the car or the phone said when it broke: a TLS alert, an auth status, a ByeBye reason, an errno. */
@Volatile private var failCode = ""
@Volatile private var failedAfter = 0L
@Volatile private var protocolVersion = "" @Volatile private var protocolVersion = ""
@Volatile private var certSource = "" @Volatile private var certSource = ""
@Volatile private var certIssuer = "" @Volatile private var certIssuer = ""
@@ -62,6 +80,7 @@ object SessionReport {
startedAt = System.currentTimeMillis() startedAt = System.currentTimeMillis()
category = Category.NONE category = Category.NONE
failure = ""; protocolVersion = ""; certSource = ""; certIssuer = "" failure = ""; protocolVersion = ""; certSource = ""; certIssuer = ""
stage = Stage.USB; failCode = ""; failedAfter = 0
tlsProtocol = ""; tlsCipher = ""; authStatus = null tlsProtocol = ""; tlsCipher = ""; authStatus = null
headUnitInfo = null headUnitInfo = null
carScreen = ""; carDpi = 0 carScreen = ""; carDpi = 0
@@ -69,34 +88,87 @@ object SessionReport {
printed = false printed = false
} }
fun protocolVersion(major: Int, minor: Int) { protocolVersion = "$major.$minor" } /** Moves the session's furthest stage on; never back. */
private fun reached(next: Stage) { if (next > stage) stage = next }
fun protocolVersion(major: Int, minor: Int) { protocolVersion = "$major.$minor"; reached(Stage.VERSIONS) }
fun certificate(source: String, issuer: String) { certSource = source; certIssuer = issuer } fun certificate(source: String, issuer: String) { certSource = source; certIssuer = issuer }
fun tls(protocol: String, cipher: String) { tlsProtocol = protocol; tlsCipher = cipher } fun tls(protocol: String, cipher: String) { tlsProtocol = protocol; tlsCipher = cipher; reached(Stage.TLS) }
fun auth(status: Int) { authStatus = status } fun auth(status: Int) { authStatus = status; if (status == 0) reached(Stage.ACCEPTED) }
fun headUnit(info: String) { headUnit = info } fun headUnit(info: String) { headUnit = info }
/** Who the car said it is, for the opt-in usage note; null until service discovery. */ /** Who the car said it is, for the opt-in usage note; null until service discovery. */
@Volatile var headUnitInfo: ServiceDiscovery.HeadUnitInfo? = null @Volatile var headUnitInfo: ServiceDiscovery.HeadUnitInfo? = null
set(value) { field = value; if (value != null) reached(Stage.CHANNELS) }
/** The car screen's usable size once its margins are cropped, and its density; blank before video starts. */ /** The car screen's usable size once its margins are cropped, and its density; blank before video starts. */
@Volatile var carScreen = ""; private set @Volatile var carScreen = ""; private set
@Volatile var carDpi = 0; private set @Volatile var carDpi = 0; private set
fun carScreen(size: String, dpi: Int) { carScreen = size; carDpi = dpi } fun carScreen(size: String, dpi: Int) { carScreen = size; carDpi = dpi; reached(Stage.VIDEO) }
fun profile(name: String?) { profile = name ?: "none matched" } fun profile(name: String?) { profile = name ?: "none matched" }
fun video(summary: String) { video = summary } fun video(summary: String) { video = summary }
fun audio(summary: String) { audio = summary } fun audio(summary: String) { audio = summary }
fun stream(summary: String) { lastStream = summary } fun stream(summary: String) { lastStream = summary; reached(Stage.STREAMING) }
/** Records the first failure only: later ones are usually fallout from it. */ /**
fun fail(category: Category, detail: String, state: String = "") { * Records the first failure only: later ones are usually fallout from it. [code] is the short
* thing the car or the phone said, like "auth -3" or "bye 1", for the usage note.
*/
fun fail(category: Category, detail: String, state: String = "", code: String = "") {
if (this.category != Category.NONE) return if (this.category != Category.NONE) return
this.category = category this.category = category
failure = detail failure = detail
lastState = state lastState = state
failCode = code
failedAfter = if (startedAt == 0L) 0 else (System.currentTimeMillis() - startedAt) / 1000
} }
fun fail(category: Category, detail: String, t: Throwable, state: String = "") = fun fail(category: Category, detail: String, t: Throwable, state: String = "") =
fail(category, "$detail (${GearslipLog.rootCause(t)})", state) fail(category, "$detail (${GearslipLog.rootCause(t)})", state, codeOf(t))
/**
* A short code from an exception, never its whole message: the TLS alert's name, the errno
* of a USB failure, or else the exception's class.
*/
private fun codeOf(t: Throwable): String {
var root = t
while (root.cause != null && root.cause !== root) root = root.cause!!
val text = "${t.message} ${root.message}"
Regex("(?i)alert[ _:]*\\(?([a-z_ ]{3,40}?)\\)?(?:\\s|$|[,;.])").find(text)?.let {
return "alert ${it.groupValues[1].trim().lowercase().replace(' ', '_')}"
}
Regex("\\b(E[A-Z]{2,12})\\b").find(text)?.let { return it.groupValues[1] }
return root.javaClass.simpleName.take(40)
}
/**
* One session for the usage note's "Where connections fail" share: how far it got, how and
* when it ended, and with which kind of certificate. Counts and codes only, nothing personal.
*/
fun sessionNote(): org.json.JSONObject {
val seconds = if (startedAt == 0L) 0 else (System.currentTimeMillis() - startedAt) / 1000
val source = certSource.lowercase()
return org.json.JSONObject()
.put("stage", stage.key)
.put("ended", category.name.lowercase())
.put("code", failCode)
.put("failed_at", if (category == Category.NONE) 0 else rounded(failedAfter))
.put("seconds", rounded(seconds))
.put("cert", when {
"imported" in source -> "imported"
"downloaded" in source -> "downloaded"
"self" in source -> "self-signed"
source.isEmpty() -> ""
else -> "other"
})
}
/** Seconds rounded so a drive's length can't be matched to anything: 5 s, then minutes, then 5 minutes. */
private fun rounded(s: Long): Long = when {
s < 60 -> (s + 2) / 5 * 5
s < 3600 -> (s + 30) / 60 * 60
else -> (s + 150) / 300 * 300
}
/** The report as plain text, for the log file and for anything that gets sent. */ /** The report as plain text, for the log file and for anything that gets sent. */
fun render(): String { fun render(): String {
@@ -145,7 +217,13 @@ object SessionReport {
category == Category.NONE -> "ended before video" category == Category.NONE -> "ended before video"
else -> category.label else -> category.label
} }
runCatching { UsageStats.recordSession(context, headUnitInfo, protocolVersion, outcome, carScreen, carDpi) } runCatching {
UsageStats.recordSession(context, headUnitInfo, protocolVersion, outcome, carScreen, carDpi, sessionNote())
}
if (outcome == "connected") runCatching {
app.seb3thehacker.gearslip.stats.WorkedPrompt.onDriveWorked(context, headUnitInfo, protocolVersion, carScreen, carDpi)
}
runCatching { app.seb3thehacker.gearslip.update.UpdateChecker.onDriveEnded(context) }
} }
GearslipLog.flush() GearslipLog.flush()
} }
@@ -45,7 +45,8 @@ class VideoSource(
private var frameIndex = 0L private var frameIndex = 0L
fun start() { fun start() {
val bitRate = (width.toLong() * height * frameRate * BITS_PER_PIXEL).toInt().coerceIn(MIN_BIT_RATE, MAX_BIT_RATE) val level = levelFor(width, height, frameRate)
val bitRate = defaultBitRate(width, height, frameRate, level)
fun format(tuned: Boolean) = MediaFormat.createVideoFormat(MIME, width, height).apply { fun format(tuned: Boolean) = MediaFormat.createVideoFormat(MIME, width, height).apply {
setInteger( setInteger(
MediaFormat.KEY_COLOR_FORMAT, MediaFormat.KEY_COLOR_FORMAT,
@@ -74,7 +75,7 @@ class VideoSource(
setFloat(MediaFormat.KEY_MAX_FPS_TO_ENCODER, frameRate.toFloat()) setFloat(MediaFormat.KEY_MAX_FPS_TO_ENCODER, frameRate.toFloat())
} }
setInteger(MediaFormat.KEY_PROFILE, MediaCodecInfo.CodecProfileLevel.AVCProfileBaseline) setInteger(MediaFormat.KEY_PROFILE, MediaCodecInfo.CodecProfileLevel.AVCProfileBaseline)
setInteger(MediaFormat.KEY_LEVEL, MediaCodecInfo.CodecProfileLevel.AVCLevel31) setInteger(MediaFormat.KEY_LEVEL, level.codecLevel)
if (tuned) { if (tuned) {
// Hints only: a car screen wants each frame now, not the best frame a moment later. // Hints only: a car screen wants each frame now, not the best frame a moment later.
setInteger(MediaFormat.KEY_BITRATE_MODE, MediaCodecInfo.EncoderCapabilities.BITRATE_MODE_VBR) setInteger(MediaFormat.KEY_BITRATE_MODE, MediaCodecInfo.EncoderCapabilities.BITRATE_MODE_VBR)
@@ -98,7 +99,7 @@ class VideoSource(
codec = encoder codec = encoder
running = true running = true
log.i("encoder started: ${width}x$height @ ${frameRate}fps, ${bitRate / 1000} kbps, H.264 Baseline, mode=$mode") log.i("encoder started: ${width}x$height @ ${frameRate}fps, ${bitRate / 1000} kbps, H.264 Baseline level ${level.label}, mode=$mode")
worker = Thread { worker = Thread {
runCatching { pump(encoder) } runCatching { pump(encoder) }
@@ -223,10 +224,40 @@ class VideoSource(
const val TIMEOUT_US = 10_000L const val TIMEOUT_US = 10_000L
const val SURFACE_TIMEOUT_US = 100_000L const val SURFACE_TIMEOUT_US = 100_000L
/** Enough for sharp text and map lines; 800x480 at 30fps comes out at 4 Mbps. */ /**
const val BITS_PER_PIXEL = 0.3 * 800x480 at 30fps gets 8 Mbps: about the most that size can show, enough that a moving
const val MIN_BIT_RATE = 4_000_000 * video in the browser stays sharp. Bigger screens get more, but less than in proportion,
const val MAX_BIT_RATE = 12_000_000 * since large frames compress better: bitrate grows with pixels^0.75. 60fps gets 1.5x,
* because frames that close together are mostly alike.
*/
const val BASE_BIT_RATE = 8_000_000.0
const val BASE_PIXELS = 800.0 * 480
/** No car's decoder is asked for more than this, whatever its level allows. */
const val MAX_BIT_RATE = 25_000_000
/**
* The H.264 levels a car screen can need, smallest first: macroblocks a second each
* decodes, and the Baseline bitrate it must handle. The encoder declares the smallest
* that fits, and the bitrate stays within it, so a car decoder isn't asked for more than
* the level it was told about.
*/
enum class Level(val label: String, val codecLevel: Int, val macroblocksPerSecond: Int, val maxBitRate: Int) {
L31("3.1", MediaCodecInfo.CodecProfileLevel.AVCLevel31, 108_000, 14_000_000),
L32("3.2", MediaCodecInfo.CodecProfileLevel.AVCLevel32, 216_000, 20_000_000),
L4("4", MediaCodecInfo.CodecProfileLevel.AVCLevel4, 245_760, 20_000_000),
L42("4.2", MediaCodecInfo.CodecProfileLevel.AVCLevel42, 522_240, 50_000_000),
}
fun levelFor(width: Int, height: Int, frameRate: Int): Level {
val perSecond = ((width + 15) / 16) * ((height + 15) / 16) * frameRate
return Level.entries.firstOrNull { perSecond <= it.macroblocksPerSecond } ?: Level.L42
}
fun defaultBitRate(width: Int, height: Int, frameRate: Int, level: Level): Int {
val size = Math.pow(width.toDouble() * height / BASE_PIXELS, 0.75)
val rate = if (frameRate > 30) 1.5 else 1.0
return (BASE_BIT_RATE * size * rate).toInt().coerceAtMost(minOf(level.maxBitRate, MAX_BIT_RATE))
}
const val I_FRAME_INTERVAL_S = 5 const val I_FRAME_INTERVAL_S = 5
private const val REPEAT_AFTER_US = 100_000L private const val REPEAT_AFTER_US = 100_000L
@@ -60,14 +60,14 @@ class AudioLink(
} }
/** /**
* The 2018 Uconnect answers our Setup with a two-byte message we cannot identify (id 255, no * The 2018 Uconnect and a Dacia ULC 4.5 both answer our Setup with a two-byte message we
* body) rather than the AVChannelSetupResponse the video channel gets, so the sink never * cannot identify (id 255, no body) rather than the AVChannelSetupResponse the video channel
* reaches READY and no audio is ever sent. * gets, so the sink never reaches READY. [MSG_SETUP_ANSWER] takes that as the go-ahead.
* *
* Service discovery already told us everything that message would have: the sink is PCM at * Service discovery already told us everything a Config would have: the sink's rate, channels
* 48000Hz stereo and it has exactly one configuration. So rather than wait forever for a * and its one configuration. So for a car that answers nothing at all, wait a moment and then
* message that is not coming, assume the advertised configuration and carry on. If that guess * assume the advertised configuration. If that guess is wrong the head unit simply will not
* is wrong the head unit simply will not ack, which the write path already reports. * ack, which the write path already reports.
*/ */
private fun armConfigFallback() { private fun armConfigFallback() {
val timer = Thread({ val timer = Thread({
@@ -76,19 +76,25 @@ class AudioLink(
} catch (e: InterruptedException) { } catch (e: InterruptedException) {
return@Thread return@Thread
} }
if (ready) return@Thread useAdvertisedSink("no usable Config after ${CONFIG_WAIT_MS}ms")
GearslipLog.w(
"audio: no usable Config after ${CONFIG_WAIT_MS}ms - falling back to the sink the " +
"head unit advertised (${sampleRate}Hz x$channels, config_index=0, max_unacked=$maxUnacked)",
)
configIndex = 0
ready = true
CarAudio.attach(this)
}, "gearslip-audio-config").apply { isDaemon = true } }, "gearslip-audio-config").apply { isDaemon = true }
configTimer = timer configTimer = timer
timer.start() timer.start()
} }
@Synchronized
private fun useAdvertisedSink(why: String) {
if (ready) return
configTimer?.interrupt()
GearslipLog.i(
"audio: $why - using the sink the head unit advertised " +
"(${sampleRate}Hz x$channels, config_index=0, max_unacked=$maxUnacked)",
)
configIndex = 0
ready = true
CarAudio.attach(this)
}
fun onMessage(messageId: Int, body: ByteArray) { fun onMessage(messageId: Int, body: ByteArray) {
when (messageId) { when (messageId) {
MSG_CHANNEL_OPEN_RESPONSE -> opened(Protobuf.readInt32Field(body, 1) ?: 0) MSG_CHANNEL_OPEN_RESPONSE -> opened(Protobuf.readInt32Field(body, 1) ?: 0)
@@ -123,8 +129,10 @@ class AudioLink(
} }
} }
// Whatever the head unit answers Setup with, we need its bytes to identify it. // Whatever else the head unit answers Setup with, we need its bytes to identify it.
else -> { else -> if (messageId == MSG_SETUP_ANSWER && body.isEmpty()) {
useAdvertisedSink("<- empty Setup answer (id $messageId)")
} else {
GearslipLog.i("audio: <- unhandled message id=$messageId (${body.size} bytes)") GearslipLog.i("audio: <- unhandled message id=$messageId (${body.size} bytes)")
GearslipLog.hex(" audio msg", body, limit = 64) GearslipLog.hex(" audio msg", body, limit = 64)
} }
@@ -243,6 +251,7 @@ class AudioLink(
private const val MSG_STOP = 32770 private const val MSG_STOP = 32770
private const val MSG_CONFIG = 32771 private const val MSG_CONFIG = 32771
private const val MSG_ACK = 32772 private const val MSG_ACK = 32772
private const val MSG_SETUP_ANSWER = 255
private const val STATUS_READY = 2 private const val STATUS_READY = 2
private const val FOCUS_GAIN = 1 private const val FOCUS_GAIN = 1
private const val FOCUS_RELEASE = 4 private const val FOCUS_RELEASE = 4
@@ -251,7 +260,7 @@ class AudioLink(
private const val SESSION_ID = 2 private const val SESSION_ID = 2
private const val STALL_MS = 2_000L private const val STALL_MS = 2_000L
private const val CHUNK_LOG_EVERY = 200 // about every four seconds private const val CHUNK_LOG_EVERY = 200 // about every four seconds
private const val CONFIG_WAIT_MS = 2_500L private const val CONFIG_WAIT_MS = 1_000L
private val FOCUS_STATES = mapOf( private val FOCUS_STATES = mapOf(
1 to "GAIN", 2 to "GAIN_TRANSIENT", 3 to "LOSS", 4 to "LOSS_TRANSIENT", 1 to "GAIN", 2 to "GAIN_TRANSIENT", 3 to "LOSS", 4 to "LOSS_TRANSIENT",
@@ -27,6 +27,11 @@ object CarKeys {
private val _screenKeys = MutableSharedFlow<ScreenKey>(extraBufferCapacity = 8) private val _screenKeys = MutableSharedFlow<ScreenKey>(extraBufferCapacity = 8)
val screenKeys: SharedFlow<ScreenKey> = _screenKeys val screenKeys: SharedFlow<ScreenKey> = _screenKeys
private val _focusMoves = MutableSharedFlow<Unit>(extraBufferCapacity = 1)
/** A knob turn or D-pad press, so controls that hide when left alone can come back. */
val focusMoves: SharedFlow<Unit> = _focusMoves
/** Delivers an Android key event to the car display; set while the car UI is showing. */ /** Delivers an Android key event to the car display; set while the car UI is showing. */
@Volatile var focusSink: ((keyCode: Int, down: Boolean) -> Unit)? = null @Volatile var focusSink: ((keyCode: Int, down: Boolean) -> Unit)? = null
@@ -58,6 +63,7 @@ object CarKeys {
*/ */
fun onKey(keyCode: Int, down: Boolean, longPress: Boolean): Boolean { fun onKey(keyCode: Int, down: Boolean, longPress: Boolean): Boolean {
if (keyCode in FOCUS_KEYS) { if (keyCode in FOCUS_KEYS) {
_focusMoves.tryEmit(Unit)
focusSink?.invoke(keyCode, down) focusSink?.invoke(keyCode, down)
return true return true
} }
@@ -86,6 +92,7 @@ object CarKeys {
/** A turn of the rotary knob: each click moves focus one step, like Tab or Shift+Tab. */ /** A turn of the rotary knob: each click moves focus one step, like Tab or Shift+Tab. */
fun onRotate(delta: Int) { fun onRotate(delta: Int) {
val sink = focusSink ?: return val sink = focusSink ?: return
_focusMoves.tryEmit(Unit)
val code = if (delta > 0) KeyEvent.KEYCODE_TAB else KEYCODE_SHIFT_TAB val code = if (delta > 0) KeyEvent.KEYCODE_TAB else KEYCODE_SHIFT_TAB
repeat(kotlin.math.abs(delta).coerceAtMost(MAX_ROTARY_STEPS)) { repeat(kotlin.math.abs(delta).coerceAtMost(MAX_ROTARY_STEPS)) {
sink(code, true) sink(code, true)
@@ -187,6 +187,8 @@ private fun groupOf(entry: Entry, mediaPackages: Set<String>): AppGroup {
return when (app.kind) { return when (app.kind) {
"calling" -> AppGroup.PHONE "calling" -> AppGroup.PHONE
"navigation", "poi", "parking", "charging" -> AppGroup.MAPS "navigation", "poi", "parking", "charging" -> AppGroup.MAPS
// Template-only media apps may have no legacy browser/player tile to identify them.
"media" -> AppGroup.MUSIC
"messaging" -> AppGroup.MESSAGING "messaging" -> AppGroup.MESSAGING
"settings" -> AppGroup.SETTINGS "settings" -> AppGroup.SETTINGS
else -> AppGroup.OTHER else -> AppGroup.OTHER
@@ -33,6 +33,12 @@ object CarServices {
val nav: CarAppConnection by lazy { CarAppConnection(requireNotNull(context)) } val nav: CarAppConnection by lazy { CarAppConnection(requireNotNull(context)) }
val media: CarMedia by lazy { CarMedia(requireNotNull(context)) } val media: CarMedia by lazy { CarMedia(requireNotNull(context)) }
/** [nav], or null before the car screen has set things up. */
fun navOrNull(): CarAppConnection? = if (context != null) nav else null
/** [media], or null before the car screen has set things up: for code outside the car UI. */
fun mediaOrNull(): CarMedia? = if (context != null) media else null
/** /**
* A second, independent connection for templated apps that aren't navigation apps - Spotify's * A second, independent connection for templated apps that aren't navigation apps - Spotify's
* own Car App Library service is the first of these. Kept apart from [nav] so opening one * own Car App Library service is the first of these. Kept apart from [nav] so opening one
@@ -121,6 +127,19 @@ object CarServices {
if (CarSettings.pipeAudio.value) CarAudio.request() if (CarSettings.pipeAudio.value) CarAudio.request()
} }
/**
* Reopens the media app used last, for the car's media button when nothing is open. False
* when there's none on record or it's been uninstalled since.
*/
suspend fun reopenLastMedia(): Boolean {
val ctx = context ?: return false
val last = CarSettings.lastMedia.value?.let(ComponentName::unflattenFromString) ?: return false
val app = withContext(Dispatchers.IO) { MediaCatalog.installed(ctx) }
.firstOrNull { it.component == last } ?: return false
openMedia(app)
return true
}
/** Closes the media app from the car: stops what it's playing and drops the connection. */ /** Closes the media app from the car: stops what it's playing and drops the connection. */
fun closeMedia() { fun closeMedia() {
if (media.now.value.playing) media.togglePlay() if (media.now.value.playing) media.togglePlay()
@@ -194,6 +213,32 @@ object CarServices {
return playingPackages.firstNotNullOfOrNull { pkg -> catalog.firstOrNull { it.component.packageName == pkg } } return playingPackages.firstNotNullOfOrNull { pkg -> catalog.firstOrNull { it.component.packageName == pkg } }
} }
/**
* The car went away mid-song: pause it, as Android Auto does, so it doesn't carry on out of
* the phone's speaker. Unplugging a USB accessory isn't "audio becoming noisy" the way
* headphones are, so the apps won't pause themselves. Every playing session is paused, not
* just the one Gearslip opened: anything the car was playing was going through the car.
*/
fun pausePhoneMedia() {
val ctx = context ?: return
val manager = ctx.getSystemService(MediaSessionManager::class.java)
val listener = ComponentName(ctx, GearslipNotificationListener::class.java)
val playing = try {
manager.getActiveSessions(listener).filter { it.playbackState?.state == PlaybackState.STATE_PLAYING }
} catch (e: SecurityException) {
null
}
if (playing == null) {
// No notification access: the app Gearslip itself is driving is all it can reach.
mediaOrNull()?.let { if (it.now.value.playing) it.togglePlay() }
return
}
playing.forEach { runCatching { it.transportControls.pause() } }
if (playing.isNotEmpty()) {
app.seb3thehacker.gearslip.GearslipLog.i("media: paused ${playing.joinToString { it.packageName }} as the car left")
}
}
/** The car went away: nothing should keep running for a screen that no longer exists. */ /** The car went away: nothing should keep running for a screen that no longer exists. */
fun shutdown() { fun shutdown() {
context?.let { app.seb3thehacker.gearslip.notify.CarMotion.stop(it) } context?.let { app.seb3thehacker.gearslip.notify.CarMotion.stop(it) }
@@ -2,6 +2,7 @@ package app.seb3thehacker.gearslip.car
import android.content.Context import android.content.Context
import app.seb3thehacker.gearslip.AppSettings import app.seb3thehacker.gearslip.AppSettings
import app.seb3thehacker.gearslip.audio.CarAudio
import app.seb3thehacker.gearslip.car.theme.GsTheme import app.seb3thehacker.gearslip.car.theme.GsTheme
import app.seb3thehacker.gearslip.car.theme.GsThemes import app.seb3thehacker.gearslip.car.theme.GsThemes
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
@@ -27,6 +28,7 @@ object CarSettings {
private const val KEY_OPEN = "car_open_on_connect" private const val KEY_OPEN = "car_open_on_connect"
private const val KEY_NIGHT = "car_night_mode" private const val KEY_NIGHT = "car_night_mode"
private const val KEY_PIPE_AUDIO = "car_pipe_audio" private const val KEY_PIPE_AUDIO = "car_pipe_audio"
private const val KEY_AUDIO_CHOICE_ASKED = "car_audio_choice_asked"
private const val KEY_LAST_NAV = "car_last_nav" private const val KEY_LAST_NAV = "car_last_nav"
private const val KEY_LAST_MEDIA = "car_last_media" private const val KEY_LAST_MEDIA = "car_last_media"
private const val KEY_AUTOPLAY = "car_autoplay" private const val KEY_AUTOPLAY = "car_autoplay"
@@ -148,10 +150,20 @@ object CarSettings {
* Whether a media app's sound is captured and sent over the car link. On by default, since * Whether a media app's sound is captured and sent over the car link. On by default, since
* sound in the car is the point of the exercise; the platform allows the capture only behind a * sound in the car is the point of the exercise; the platform allows the capture only behind a
* consent dialog, which [app.seb3thehacker.gearslip.audio.CarAudio] raises once the car has * consent dialog, which [app.seb3thehacker.gearslip.audio.CarAudio] raises once the car has
* somewhere to play it. Turned off, the app plays through the phone's normal output instead. * somewhere to play it. Turned off ("Bluetooth" in the settings), the app plays through the
* phone's normal output instead, and Android never asks.
*/ */
val pipeAudio: StateFlow<Boolean> = pipeAudioFlow val pipeAudio: StateFlow<Boolean> = pipeAudioFlow
/**
* Whether the driver has already been offered Bluetooth after turning down the capture
* dialog. Offered once only: someone who chose to keep the USB route shouldn't be nagged.
*/
val audioChoiceAsked: Boolean
get() = AppSettings.getString(app, KEY_AUDIO_CHOICE_ASKED, "false") == "true"
fun markAudioChoiceAsked() = AppSettings.putString(app, KEY_AUDIO_CHOICE_ASKED, "true")
/** /**
* Off by default: a driver has to opt into experimental features as a whole before any of * Off by default: a driver has to opt into experimental features as a whole before any of
* them - the voice assistant included - show up to turn on individually. * them - the voice assistant included - show up to turn on individually.
@@ -265,9 +277,15 @@ object CarSettings {
AppSettings.putString(app, KEY_AUTOPLAY, on.toString()) AppSettings.putString(app, KEY_AUTOPLAY, on.toString())
} }
/**
* Takes effect at once: switching to Bluetooth mid-drive stops the capture, and switching
* back asks for it again (or waits for the car, if none is connected).
*/
fun setPipeAudio(on: Boolean) { fun setPipeAudio(on: Boolean) {
if (pipeAudioFlow.value == on) return
pipeAudioFlow.value = on pipeAudioFlow.value = on
AppSettings.putString(app, KEY_PIPE_AUDIO, on.toString()) AppSettings.putString(app, KEY_PIPE_AUDIO, on.toString())
if (on) CarAudio.request() else CarAudio.release()
} }
fun setExperimentalFeaturesEnabled(on: Boolean) { fun setExperimentalFeaturesEnabled(on: Boolean) {
@@ -126,14 +126,14 @@ fun CarSettingsScreen() {
) { CarSettings.setAutoplay(it) } ) { CarSettings.setAutoplay(it) }
ChoiceRow( ChoiceRow(
"Media audio", "Car audio",
listOf(false to "Phone audio", true to "Car audio"), listOf(false to "Bluetooth", true to "USB"),
pipeAudio, pipeAudio,
) { CarSettings.setPipeAudio(it) } ) { CarSettings.setPipeAudio(it) }
Text( Text(
"Car audio sends the phone's sound to the car over USB. Android asks for permission " + "USB sends the phone's sound through Gearslip. Android asks to share your screen each " +
"when the car connects, and a few apps block it. Phone audio plays as the phone " + "drive, and a few apps block it. Bluetooth plays as the phone normally would, and " +
"normally would, for example over Bluetooth to the car.", "Android never asks.",
style = MaterialTheme.typography.bodySmall, style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant, color = MaterialTheme.colorScheme.onSurfaceVariant,
) )
@@ -0,0 +1,62 @@
package app.seb3thehacker.gearslip.car
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.shape.RoundedCornerShape
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.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.platform.LocalAccessibilityManager
import androidx.compose.ui.semantics.LiveRegionMode
import androidx.compose.ui.semantics.liveRegion
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.withTimeoutOrNull
/** Draw inside the encoded car display; a normal Android Toast would appear on the phone. */
@Composable
internal fun CarToastOverlay(modifier: Modifier = Modifier) {
val current by CarToasts.message.collectAsState()
val message = current ?: return
val accessibility = LocalAccessibilityManager.current
LaunchedEffect(message, accessibility) {
val timeout = accessibility?.calculateRecommendedTimeoutMillis(
message.durationMs, containsIcons = false, containsText = true, containsControls = false,
) ?: message.durationMs
// The full time, unless another toast is waiting: then it gives way after the minimum.
delay(minOf(timeout, CarToasts.MIN_SHOWN_MS))
withTimeoutOrNull((timeout - CarToasts.MIN_SHOWN_MS).coerceAtLeast(0)) {
CarToasts.waiting.first { it > 0 }
}
CarToasts.dismiss(message)
}
// A non-interactive overlay leaves the map's touch handling and focus intact.
Box(
modifier
.padding(horizontal = 24.dp, vertical = 16.dp)
.widthIn(max = 560.dp)
.clip(RoundedCornerShape(20.dp))
.background(MaterialTheme.colorScheme.surfaceContainerHigh)
.semantics { liveRegion = LiveRegionMode.Polite },
) {
Text(
message.text,
modifier = Modifier.padding(horizontal = 20.dp, vertical = 12.dp),
color = MaterialTheme.colorScheme.onSurface,
style = MaterialTheme.typography.titleMedium,
maxLines = 3,
overflow = TextOverflow.Ellipsis,
)
}
}
@@ -0,0 +1,65 @@
package app.seb3thehacker.gearslip.car
import androidx.annotation.MainThread
import androidx.car.app.CarToast
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
/**
* One toast at a time across navigation and browse apps. A newer one waits its turn instead of
* replacing the one on screen, which gets at least [MIN_SHOWN_MS] before it gives way.
*/
@MainThread
internal object CarToasts {
// Identity matters: repeating identical text must restart its display timer.
class Message(val owner: Any, val text: String, val durationMs: Long)
/** Long enough to read a short toast at a glance; a waiting toast cuts the rest short. */
const val MIN_SHOWN_MS = 2_000L
/** A burst beyond this many is stale by the time it would show; the oldest waiting go first. */
private const val MAX_WAITING = 3
private val current = MutableStateFlow<Message?>(null)
val message: StateFlow<Message?> = current.asStateFlow()
private val queue = ArrayDeque<Message>()
private val _waiting = MutableStateFlow(0)
val waiting: StateFlow<Int> = _waiting.asStateFlow()
fun show(owner: Any, text: String, duration: Int) {
if (text.isBlank()) return
// Longer than a phone toast: the driver only glances at the screen now and then.
val durationMs = if (duration == CarToast.LENGTH_LONG) 7_000L else 4_000L
val next = Message(owner, text, durationMs)
val shown = current.value
when {
shown == null -> current.value = next
// The same words again restart the one on screen rather than queueing a copy.
shown.text == text -> current.value = next
queue.lastOrNull()?.text == text -> Unit
else -> {
queue.addLast(next)
while (queue.size > MAX_WAITING) queue.removeFirst()
}
}
_waiting.value = queue.size
}
fun dismiss(message: Message) {
// An old timer must not dismiss a newer toast, even if its text is identical.
if (current.value === message) advance()
}
fun clear(owner: Any) {
// Disconnecting the map must not clear a toast belonging to the browse app.
queue.removeAll { it.owner === owner }
if (current.value?.owner === owner) advance() else _waiting.value = queue.size
}
private fun advance() {
current.value = queue.removeFirstOrNull()
_waiting.value = queue.size
}
}
@@ -2,6 +2,7 @@ package app.seb3thehacker.gearslip.car
import androidx.compose.foundation.Image import androidx.compose.foundation.Image
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.LocalIndication
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.foundation.layout.BoxWithConstraints
@@ -43,6 +44,10 @@ import androidx.compose.material3.dynamicDarkColorScheme
import androidx.compose.material3.dynamicLightColorScheme import androidx.compose.material3.dynamicLightColorScheme
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.ui.hapticfeedback.HapticFeedback
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
import androidx.compose.ui.platform.LocalHapticFeedback
import androidx.compose.ui.platform.LocalView
import androidx.compose.runtime.State import androidx.compose.runtime.State
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
@@ -120,12 +125,16 @@ fun CarUi() {
controlsWake.tick() controlsWake.tick()
} }
} }
// The knob counts as a touch: the map's buttons can't be found while they're faded out.
LaunchedEffect(controlsWake) { CarKeys.focusMoves.collect { controlsWake.touch() } }
remember(appContext) { Prefetch.warm(appContext, force = true); CarServices.init(appContext) } remember(appContext) { Prefetch.warm(appContext, force = true); CarServices.init(appContext) }
DisposableEffect(Unit) { onDispose { CarServices.shutdown() } } DisposableEffect(Unit) { onDispose { CarServices.shutdown() } }
// Ask for the phone's sound as soon as the car connects, not only when a media app opens: // Ask for the phone's sound as soon as the car connects, not only when a media app opens:
// a video in the web browser or a map's voice needs it too. Asks once, after the car offers // a video in the web browser or a map's voice needs it too. Asks once, after the car offers
// somewhere to play it. // somewhere to play it.
LaunchedEffect(Unit) { if (CarSettings.pipeAudio.value) app.seb3thehacker.gearslip.audio.CarAudio.request() } LaunchedEffect(Unit) { if (CarSettings.pipeAudio.value) 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. // The map follows the light outside, whatever the app's own theme is set to.
LaunchedEffect(darkOutside) { CarServices.nav.pushConfiguration() } LaunchedEffect(darkOutside) { CarServices.nav.pushConfiguration() }
@@ -150,14 +159,27 @@ fun CarUi() {
VoiceReply.state.value.phase != VoiceReply.Phase.IDLE -> VoiceReply.cancel() VoiceReply.state.value.phase != VoiceReply.Phase.IDLE -> VoiceReply.cancel()
else -> navigator.back() else -> navigator.back()
} }
CarKeys.ScreenKey.HOME, CarKeys.ScreenKey.MAP -> navigator.home() // Home is the dashboard, over the map, so Back from it lands on the map.
CarKeys.ScreenKey.MEDIA -> if (CarServices.mediaApp.value != null) navigator.media() else navigator.apps() CarKeys.ScreenKey.HOME -> { navigator.home(); navigator.dashboard() }
CarKeys.ScreenKey.MAP -> navigator.home()
CarKeys.ScreenKey.MEDIA ->
if (CarServices.mediaApp.value != null || CarServices.reopenLastMedia()) navigator.media()
else navigator.apps()
CarKeys.ScreenKey.PHONE -> BuiltInApps.Phone.open(navigator) CarKeys.ScreenKey.PHONE -> BuiltInApps.Phone.open(navigator)
} }
} }
} }
// The car screen is drawn on the phone, so every long press or text-handle drag would buzz
// the phone in its mount. Nobody feels that while driving; it's only noise.
val view = LocalView.current
DisposableEffect(view) {
view.isHapticFeedbackEnabled = false
onDispose { }
}
CompositionLocalProvider( CompositionLocalProvider(
LocalHapticFeedback provides NoHaptics,
LocalDensity provides Density(base.density * scale, base.fontScale), LocalDensity provides Density(base.density * scale, base.fontScale),
LocalCarNavigator provides navigator, LocalCarNavigator provides navigator,
LocalDarkOutside provides darkOutside, LocalDarkOutside provides darkOutside,
@@ -165,6 +187,7 @@ fun CarUi() {
LocalGsTheme provides GsThemes.byId(uiThemeId), LocalGsTheme provides GsThemes.byId(uiThemeId),
) { ) {
MaterialTheme(colorScheme = colors) { MaterialTheme(colorScheme = colors) {
CompositionLocalProvider(LocalIndication provides FocusRingIndication(MaterialTheme.colorScheme.primary)) {
Surface( Surface(
Modifier Modifier
.fillMaxSize() .fillMaxSize()
@@ -224,6 +247,8 @@ fun CarUi() {
} }
} }
} }
CarToastOverlay(Modifier.align(Alignment.BottomCenter))
CallPromptOverlay()
PopupTimer() PopupTimer()
// The dashboard is already listing them, so it needs no popup. // The dashboard is already listing them, so it needs no popup.
NotificationPopup( NotificationPopup(
@@ -252,6 +277,7 @@ fun CarUi() {
if (safetyWarningSeen) CarWhatsNew() if (safetyWarningSeen) CarWhatsNew()
} }
} }
}
} }
} }
} }
@@ -879,3 +905,8 @@ private fun ChargingGlyph(modifier: Modifier = Modifier, tint: Color = MaterialT
drawPath(bolt, tint) drawPath(bolt, tint)
} }
} }
/** Stands in for the phone's vibration motor on the car screen: does nothing. */
private object NoHaptics : HapticFeedback {
override fun performHapticFeedback(hapticFeedbackType: HapticFeedbackType) {}
}
@@ -0,0 +1,196 @@
package app.seb3thehacker.gearslip.car
import androidx.car.app.model.CarText
import androidx.car.app.model.Distance
import androidx.car.app.navigation.model.Maneuver
import androidx.car.app.navigation.model.NavigationTemplate
import androidx.car.app.navigation.model.RoutingInfo
import androidx.car.app.navigation.model.Step
import app.seb3thehacker.gearslip.GearslipLog
import app.seb3thehacker.gearslip.Protobuf
/**
* The next turn on the car's instrument cluster, over its NavigationStatusService (Service
* field 8), the way Android Auto feeds it:
*
* -> NavigationStatus { status = 1 (1 active, 2 inactive, 3 rerouting) }
* -> NavigationNextTurnEvent { road = 1; turn_side = 2; event = 3; turn_number = 5; turn_angle = 6 }
* -> NavigationNextTurnDistanceEvent { distance_meters = 1; time_to_turn_seconds = 2;
* display_distance_e3 = 3; display_distance_unit = 4 }
*
* Newer cars also take a NavigationState message; the deprecated pair above is what a 2018
* Uconnect understands. The turn comes from the map app's trip (what the Car App Library hands
* to clusters), or from its routing card when it sends no trip.
*/
class ClusterNav(
val channelId: Int,
private val sendOnChannel: (messageId: Int, body: ByteArray) -> Unit,
) {
@Volatile private var running = false
private var sentStatus = -1
private var sentTurn: String? = null
private var sentDistance: String? = null
fun onMessage(messageId: Int, body: ByteArray) {
when (messageId) {
MSG_CHANNEL_OPEN_RESPONSE -> {
val status = Protobuf.readInt32Field(body, 1) ?: 0
GearslipLog.i("cluster: <- ChannelOpenResponse: status=$status")
if (status == 0) start()
}
// The car saying when it wants turns. Sending all along is harmless, so these are only logged.
MSG_START -> GearslipLog.i("cluster: <- start")
MSG_STOP -> GearslipLog.i("cluster: <- stop")
else -> GearslipLog.i("cluster: <- unhandled message id=$messageId (${body.size} bytes)")
}
}
private fun start() {
if (running) return
running = true
Thread({
while (running) {
runCatching { tick() }.onFailure { GearslipLog.w("cluster: ${it.message}") }
Thread.sleep(TICK_MS)
}
}, "gearslip-cluster").start()
}
fun stop() {
running = false
}
/** One turn as the car wants it, from whichever source the map app fills. */
private class Turn(val step: Step, val distance: Distance?, val seconds: Long?, val rerouting: Boolean)
private fun current(): Turn? {
val nav = CarServices.navOrNull() ?: return null
nav.trip.value?.let { trip ->
val step = trip.steps.firstOrNull() ?: return@let
val estimate = trip.stepTravelEstimates.firstOrNull()
return Turn(step, estimate?.remainingDistance, estimate?.remainingTimeSeconds?.takeIf { it >= 0 }, trip.isLoading)
}
val info = (nav.template.value as? NavigationTemplate)?.navigationInfo as? RoutingInfo ?: return null
val step = info.currentStep ?: return null
return Turn(step, info.currentDistance, null, info.isLoading)
}
private fun tick() {
val turn = current()
val status = when {
turn == null -> STATUS_INACTIVE
turn.rerouting -> STATUS_REROUTING
else -> STATUS_ACTIVE
}
if (status != sentStatus) {
sendOnChannel(MSG_STATUS, Protobuf.varintField(1, status.toLong()))
GearslipLog.i("cluster: -> ${STATUS_NAMES[status]}")
sentStatus = status
if (status == STATUS_INACTIVE) { sentTurn = null; sentDistance = null }
}
if (turn == null || status == STATUS_REROUTING) return
sendTurn(turn.step)
turn.distance?.let { sendDistance(it, turn.seconds) }
}
private fun sendTurn(step: Step) {
val maneuver = step.maneuver
val (event, side) = maneuver?.let { eventFor(it.type) } ?: (EVENT_UNKNOWN to SIDE_UNSPECIFIED)
val road = step.road.text().ifBlank { step.cue.text() }
val key = "$road|$event|$side|${maneuver?.roundaboutExitNumber}"
if (key == sentTurn) return
sentTurn = key
var body = Protobuf.stringField(1, road) +
Protobuf.varintField(2, side.toLong()) +
Protobuf.varintField(3, event.toLong())
maneuver?.roundaboutExitNumber?.takeIf { it > 0 }?.let { body += Protobuf.varintField(5, it.toLong()) }
maneuver?.roundaboutExitAngle?.takeIf { it > 0 }?.let { body += Protobuf.varintField(6, it.toLong()) }
sendOnChannel(MSG_TURN, body)
GearslipLog.i("cluster: -> turn event=$event side=$side road=\"$road\"")
}
/**
* The Car App Library's distance units use the same numbers as the protocol's (1 metres ...
* 7 yards), so the app's own display choice passes straight through.
*/
private fun sendDistance(distance: Distance, seconds: Long?) {
val meters = toMeters(distance)
val key = "${distance.displayDistance}|${distance.displayUnit}|${seconds ?: -1}"
if (key == sentDistance) return
sentDistance = key
sendOnChannel(
MSG_DISTANCE,
Protobuf.varintField(1, meters.toLong()) +
Protobuf.varintField(2, (seconds ?: 0L).coerceAtLeast(0)) +
Protobuf.varintField(3, (distance.displayDistance * 1000).toLong()) +
Protobuf.varintField(4, distance.displayUnit.toLong()),
)
}
private fun toMeters(d: Distance): Int = (d.displayDistance * when (d.displayUnit) {
Distance.UNIT_KILOMETERS, Distance.UNIT_KILOMETERS_P1 -> 1000.0
Distance.UNIT_MILES, Distance.UNIT_MILES_P1 -> 1609.344
Distance.UNIT_FEET -> 0.3048
Distance.UNIT_YARDS -> 0.9144
else -> 1.0
}).toInt()
private fun CarText?.text() = this?.toString().orEmpty()
companion object {
private const val TICK_MS = 500L
private const val MSG_CHANNEL_OPEN_RESPONSE = 8
private const val MSG_START = 32769
private const val MSG_STOP = 32770
private const val MSG_STATUS = 32771
private const val MSG_TURN = 32772
private const val MSG_DISTANCE = 32773
private const val STATUS_ACTIVE = 1
private const val STATUS_INACTIVE = 2
private const val STATUS_REROUTING = 3
private val STATUS_NAMES = mapOf(1 to "navigating", 2 to "not navigating", 3 to "rerouting")
private const val SIDE_LEFT = 1
private const val SIDE_RIGHT = 2
private const val SIDE_UNSPECIFIED = 3
private const val EVENT_UNKNOWN = 0
/** The car's turn icon and side for a Car App Library manoeuvre. */
fun eventFor(type: Int): Pair<Int, Int> = when (type) {
Maneuver.TYPE_DEPART -> 1 to SIDE_UNSPECIFIED
Maneuver.TYPE_NAME_CHANGE -> 2 to SIDE_UNSPECIFIED
Maneuver.TYPE_KEEP_LEFT, Maneuver.TYPE_TURN_SLIGHT_LEFT -> 3 to SIDE_LEFT
Maneuver.TYPE_KEEP_RIGHT, Maneuver.TYPE_TURN_SLIGHT_RIGHT -> 3 to SIDE_RIGHT
Maneuver.TYPE_TURN_NORMAL_LEFT -> 4 to SIDE_LEFT
Maneuver.TYPE_TURN_NORMAL_RIGHT -> 4 to SIDE_RIGHT
Maneuver.TYPE_TURN_SHARP_LEFT -> 5 to SIDE_LEFT
Maneuver.TYPE_TURN_SHARP_RIGHT -> 5 to SIDE_RIGHT
Maneuver.TYPE_U_TURN_LEFT -> 6 to SIDE_LEFT
Maneuver.TYPE_U_TURN_RIGHT -> 6 to SIDE_RIGHT
Maneuver.TYPE_ON_RAMP_SLIGHT_LEFT, Maneuver.TYPE_ON_RAMP_NORMAL_LEFT, Maneuver.TYPE_ON_RAMP_SHARP_LEFT,
Maneuver.TYPE_ON_RAMP_U_TURN_LEFT -> 7 to SIDE_LEFT
Maneuver.TYPE_ON_RAMP_SLIGHT_RIGHT, Maneuver.TYPE_ON_RAMP_NORMAL_RIGHT, Maneuver.TYPE_ON_RAMP_SHARP_RIGHT,
Maneuver.TYPE_ON_RAMP_U_TURN_RIGHT -> 7 to SIDE_RIGHT
Maneuver.TYPE_OFF_RAMP_SLIGHT_LEFT, Maneuver.TYPE_OFF_RAMP_NORMAL_LEFT -> 8 to SIDE_LEFT
Maneuver.TYPE_OFF_RAMP_SLIGHT_RIGHT, Maneuver.TYPE_OFF_RAMP_NORMAL_RIGHT -> 8 to SIDE_RIGHT
Maneuver.TYPE_FORK_LEFT -> 9 to SIDE_LEFT
Maneuver.TYPE_FORK_RIGHT -> 9 to SIDE_RIGHT
Maneuver.TYPE_MERGE_LEFT -> 10 to SIDE_LEFT
Maneuver.TYPE_MERGE_RIGHT -> 10 to SIDE_RIGHT
Maneuver.TYPE_MERGE_SIDE_UNSPECIFIED -> 10 to SIDE_UNSPECIFIED
Maneuver.TYPE_ROUNDABOUT_ENTER_CW, Maneuver.TYPE_ROUNDABOUT_ENTER_CCW -> 11 to SIDE_UNSPECIFIED
Maneuver.TYPE_ROUNDABOUT_EXIT_CW, Maneuver.TYPE_ROUNDABOUT_EXIT_CCW -> 12 to SIDE_UNSPECIFIED
Maneuver.TYPE_ROUNDABOUT_ENTER_AND_EXIT_CW, Maneuver.TYPE_ROUNDABOUT_ENTER_AND_EXIT_CW_WITH_ANGLE,
Maneuver.TYPE_ROUNDABOUT_ENTER_AND_EXIT_CCW, Maneuver.TYPE_ROUNDABOUT_ENTER_AND_EXIT_CCW_WITH_ANGLE -> 13 to SIDE_UNSPECIFIED
Maneuver.TYPE_STRAIGHT -> 14 to SIDE_UNSPECIFIED
Maneuver.TYPE_FERRY_BOAT, Maneuver.TYPE_FERRY_BOAT_LEFT, Maneuver.TYPE_FERRY_BOAT_RIGHT -> 16 to SIDE_UNSPECIFIED
Maneuver.TYPE_FERRY_TRAIN, Maneuver.TYPE_FERRY_TRAIN_LEFT, Maneuver.TYPE_FERRY_TRAIN_RIGHT -> 17 to SIDE_UNSPECIFIED
Maneuver.TYPE_DESTINATION, Maneuver.TYPE_DESTINATION_STRAIGHT -> 19 to SIDE_UNSPECIFIED
Maneuver.TYPE_DESTINATION_LEFT -> 19 to SIDE_LEFT
Maneuver.TYPE_DESTINATION_RIGHT -> 19 to SIDE_RIGHT
else -> EVENT_UNKNOWN to SIDE_UNSPECIFIED
}
}
}
@@ -0,0 +1,84 @@
package app.seb3thehacker.gearslip.car
import androidx.compose.foundation.IndicationNodeFactory
import androidx.compose.foundation.interaction.FocusInteraction
import androidx.compose.foundation.interaction.InteractionSource
import androidx.compose.material3.ripple
import androidx.compose.ui.geometry.CornerRadius
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.drawscope.ContentDrawScope
import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.compose.ui.node.DelegatableNode
import androidx.compose.ui.node.DelegatingNode
import androidx.compose.ui.node.DrawModifierNode
import androidx.compose.ui.node.invalidateDraw
import androidx.compose.ui.unit.dp
import kotlinx.coroutines.launch
/**
* What every car button shows: the usual ripple when pressed, and a ring in the accent color
* while the knob or D-pad is on it. Cars without a knob never see the ring, since nothing takes
* focus while the screen is in touch mode.
*
* It replaces the theme's ripple (LocalIndication) for the whole car UI, so any `clickable` that
* doesn't pass its own indication gets the ring for free.
*/
internal class FocusRingIndication(private val color: Color) : IndicationNodeFactory {
private val ripple = ripple()
override fun create(interactionSource: InteractionSource): DelegatableNode =
FocusRingNode(ripple.create(interactionSource), interactionSource, color)
override fun equals(other: Any?) = other is FocusRingIndication && other.color == color
override fun hashCode() = color.hashCode()
}
private class FocusRingNode(
rippleNode: DelegatableNode,
private val source: InteractionSource,
private val color: Color,
) : DelegatingNode(), DrawModifierNode {
private var focused = false
init { delegate(rippleNode) }
override fun onAttach() {
coroutineScope.launch {
var holding = 0
source.interactions.collect {
when (it) {
is FocusInteraction.Focus -> holding++
is FocusInteraction.Unfocus -> holding--
}
val now = holding > 0
if (now != focused) {
focused = now
invalidateDraw()
}
}
}
}
override fun ContentDrawScope.draw() {
drawContent()
if (!focused) return
// Inside the button's bounds, since most buttons clip to their shape: drawn outside, a
// rounded tile would cut its own ring off.
val width = RING_WIDTH.toPx()
val radius = minOf(CORNER.toPx(), size.minDimension / 2)
drawRoundRect(
color,
topLeft = Offset(width / 2, width / 2),
size = Size(size.width - width, size.height - width),
cornerRadius = CornerRadius(radius),
style = Stroke(width),
)
}
private companion object {
val RING_WIDTH = 3.dp
val CORNER = 16.dp
}
}
@@ -32,6 +32,7 @@ import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface import androidx.compose.material3.Surface
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.produceState import androidx.compose.runtime.produceState
@@ -89,6 +90,31 @@ internal fun placeCall(context: Context, number: String) {
} }
} }
/** A call another car screen wants to place - a number on a place page - waiting on the driver. */
internal object CallPrompt {
val pending = kotlinx.coroutines.flow.MutableStateFlow<Contact?>(null)
fun ask(name: String, number: String) {
pending.value = Contact(name, number)
}
}
/** The dialer's own confirmation, drawn over the whole car screen for [CallPrompt]. */
@Composable
internal fun CallPromptOverlay() {
val context = LocalContext.current
val contact by CallPrompt.pending.collectAsState()
val c = contact ?: return
ConfirmCallOverlay(
c,
onConfirm = {
CallPrompt.pending.value = null
placeCall(context, c.number)
},
onDismiss = { CallPrompt.pending.value = null },
)
}
private const val TAB_CONTACTS = 0 private const val TAB_CONTACTS = 0
private const val TAB_KEYPAD = 1 private const val TAB_KEYPAD = 1
@@ -3,8 +3,10 @@ package app.seb3thehacker.gearslip.car
import app.seb3thehacker.gearslip.car.theme.* import app.seb3thehacker.gearslip.car.theme.*
import androidx.compose.runtime.produceState import androidx.compose.runtime.produceState
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.material3.LocalContentColor import androidx.compose.material3.LocalContentColor
import androidx.compose.ui.graphics.ColorFilter import androidx.compose.ui.graphics.ColorFilter
import androidx.compose.ui.graphics.luminance
import androidx.compose.ui.graphics.ImageBitmap import androidx.compose.ui.graphics.ImageBitmap
import androidx.compose.foundation.Image import androidx.compose.foundation.Image
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
@@ -29,6 +31,7 @@ import androidx.compose.foundation.lazy.grid.items
import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Call
import androidx.compose.material.icons.filled.ArrowBack import androidx.compose.material.icons.filled.ArrowBack
import androidx.compose.material.icons.filled.Menu import androidx.compose.material.icons.filled.Menu
import androidx.compose.material.icons.filled.Warning import androidx.compose.material.icons.filled.Warning
@@ -231,7 +234,9 @@ internal fun RightEdgeControls(
// the first touch on one wakes them rather than pressing it. // the first touch on one wakes them rather than pressing it.
val awake = LocalMapControlsWake.current.awake val awake = LocalMapControlsWake.current.awake
val pan by CarServices.nav.panMode.collectAsState() val pan by CarServices.nav.panMode.collectAsState()
val visible = awake || pan // 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
val alpha by androidx.compose.animation.core.animateFloatAsState( val alpha by androidx.compose.animation.core.animateFloatAsState(
targetValue = if (visible) 1f else 0f, targetValue = if (visible) 1f else 0f,
animationSpec = androidx.compose.animation.core.tween(if (visible) 150 else 500), animationSpec = androidx.compose.animation.core.tween(if (visible) 150 else 500),
@@ -240,6 +245,7 @@ internal fun RightEdgeControls(
Column( Column(
modifier modifier
.alpha(alpha) .alpha(alpha)
.onFocusChanged { holdsFocus = it.hasFocus }
.let { base -> .let { base ->
if (visible) base else base.pointerInput(Unit) { if (visible) base else base.pointerInput(Unit) {
awaitPointerEventScope { awaitPointerEventScope {
@@ -468,7 +474,10 @@ internal fun CarGlyph(icon: CarIcon?, modifier: Modifier = Modifier, standard: I
val white = remember(bitmap) { bitmap.isPlainWhite() } val white = remember(bitmap) { bitmap.isPlainWhite() }
// Untinted white artwork is a template-app habit from dark surfaces; it is coloured like // Untinted white artwork is a template-app habit from dark surfaces; it is coloured like
// text so it stays readable on a light one. // text so it stays readable on a light one.
val tint = icon?.tint?.color(dark, content) ?: content.takeIf { white } // An app's own tint is kept only if it can be seen: MAPS.ME asks for dark grey on dark
// buttons. Android Auto drops colours that fail contrast the same way.
val tint = icon?.tint?.color(dark, content)?.takeIf { it.readableOn(content) }
?: content.takeIf { white || icon?.tint != null }
Image( Image(
bitmap, contentDescription = null, modifier = modifier, bitmap, contentDescription = null, modifier = modifier,
colorFilter = tint?.let { ColorFilter.tint(it) }, colorFilter = tint?.let { ColorFilter.tint(it) },
@@ -587,16 +596,141 @@ internal fun PaneRows(pane: Pane?, modifier: Modifier = Modifier, autoStart: Boo
// Some apps pad a pane with a row of blank strings; drawn, it is just a gap. // Some apps pad a pane with a row of blank strings; drawn, it is just a gap.
val rows = pane?.rows.orEmpty().filterNot { it.isBlank() } val rows = pane?.rows.orEmpty().filterNot { it.isBlank() }
val actions = pane?.actions.orEmpty().filter { it.isDrawable() } val actions = pane?.actions.orEmpty().filter { it.isDrawable() }
val card = PlaceCard.of(rows)
Column(modifier) { Column(modifier) {
rows.forEach { RowItem(it, large = true) } 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) { if (autoStart && actions.size == 1) {
AutoStartButton(actions.single(), Modifier.padding(horizontal = 14.dp, vertical = 10.dp)) AutoStartButton(actions.single(), Modifier.padding(horizontal = 14.dp, vertical = 10.dp))
} else { } else {
ActionRow(actions, Modifier.padding(horizontal = 14.dp, vertical = 10.dp), large = true) ActionRow(actions, Modifier.padding(horizontal = 14.dp, vertical = 10.dp), large = true)
} }
if (card.heading == null && card.buttons.isEmpty()) {
card.details.forEach { RowItem(it, large = true) }
} else {
card.details.forEach { DetailRow(it, card.heading?.title?.text().orEmpty()) }
}
} }
} }
/**
* A pane split the way a place page reads: a name on top, the things to do with it as buttons,
* then the facts. Apps send all three as plain rows, so the split is worked out from the rows'
* shape alone. A pane that doesn't fit the shape - all of it tappable, like a menu - gets no
* heading and no buttons, and draws exactly as a plain list of rows.
*/
private class PlaceCard(val heading: CarRow?, val buttons: List<CarRow>, val details: List<CarRow>) {
companion object {
fun of(rows: List<CarRow>): PlaceCard {
// A short tappable row with an icon and nothing under it is a button in all but name:
// MAPS.ME's "Let's go" and "Save".
val buttons = rows.filter {
it.onClickDelegate != null && it.toggle == null && it.image != null &&
it.texts.orEmpty().isEmpty() && it.title.text().length <= 24
}.takeIf { it.size in 1..4 && it.size < rows.size }.orEmpty()
// A first row that can't be tapped and has no icon is the place's name and kind.
val heading = rows.firstOrNull()?.takeIf {
buttons.isNotEmpty() && it.onClickDelegate == null && it.image == null && it.toggle == null
}
return PlaceCard(heading, buttons, rows - buttons.toSet() - listOfNotNull(heading).toSet())
}
}
}
@Composable
private fun PlaceHeading(row: CarRow) {
Column(Modifier.fillMaxWidth().padding(horizontal = 14.dp, vertical = 6.dp)) {
Text(row.title.text(), style = ChromeType.title, maxLines = 2, overflow = TextOverflow.Ellipsis)
row.texts.orEmpty().take(2).forEach {
Text(it.text(), style = ChromeType.body, color = MaterialTheme.colorScheme.onSurfaceVariant, maxLines = 1, overflow = TextOverflow.Ellipsis)
}
}
}
/** Button-shaped rows side by side, the first one filled as the thing most drivers came to do. */
@Composable
private fun RowButtons(rows: List<CarRow>, modifier: Modifier) {
val scheme = MaterialTheme.colorScheme
Row(modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
rows.forEachIndexed { i, row ->
GsIconBox(
onClick = { row.onClickDelegate.click("row") },
modifier = Modifier.weight(1f),
colors = if (i == 0) GsColors(scheme.primary, scheme.onPrimary) else GsColors(scheme.surfaceContainerHighest, scheme.onSurface),
enabled = row.isEnabled,
shape = RoundedCornerShape(24.dp),
) {
Row(
Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 14.dp),
horizontalArrangement = Arrangement.Center,
verticalAlignment = Alignment.CenterVertically,
) {
CarGlyph(row.image, Modifier.size(24.dp))
Spacer(Modifier.width(8.dp))
Text(row.title.text(), style = ChromeType.body, maxLines = 1, overflow = TextOverflow.Ellipsis)
}
}
}
}
}
/**
* One fact about a place, tighter than a list row: a small label over its value, or just the
* value when the row has no label. A phone number gets a Call button that asks first.
*/
@Composable
private fun DetailRow(row: CarRow, placeName: String) {
val context = LocalContext.current
val texts = row.texts.orEmpty().map { it.text() }.filter { it.isNotBlank() }
val label = row.title.text().takeIf { texts.isNotEmpty() }
val values = texts.ifEmpty { listOf(row.title.text()) }
val number = values.firstOrNull { it.isPhoneNumber() }
val canCall = number != null &&
context.checkSelfPermission(android.Manifest.permission.CALL_PHONE) == android.content.pm.PackageManager.PERMISSION_GRANTED
Row(
Modifier
.fillMaxWidth()
.let { if (row.onClickDelegate != null && row.isEnabled) it.clickable { row.onClickDelegate.click("row") } else it }
.padding(horizontal = 14.dp, vertical = 6.dp),
verticalAlignment = Alignment.CenterVertically,
) {
row.image?.let {
CarGlyph(it, Modifier.size(24.dp))
Spacer(Modifier.width(12.dp))
}
Column(Modifier.weight(1f)) {
label?.let {
Text(it, style = MaterialTheme.typography.labelLarge, color = MaterialTheme.colorScheme.onSurfaceVariant, maxLines = 1)
}
values.take(3).forEach {
Text(it, style = ChromeType.body, maxLines = 2, overflow = TextOverflow.Ellipsis)
}
}
if (canCall) {
Spacer(Modifier.width(8.dp))
GsIconBox(
onClick = { CallPrompt.ask(placeName.ifBlank { number!! }, number!!) },
colors = GsColors(MaterialTheme.colorScheme.primary, MaterialTheme.colorScheme.onPrimary),
shape = RoundedCornerShape(24.dp),
) {
Row(Modifier.padding(horizontal = 16.dp, vertical = 10.dp), verticalAlignment = Alignment.CenterVertically) {
androidx.compose.material3.Icon(Icons.Filled.Call, null, Modifier.size(20.dp))
Spacer(Modifier.width(6.dp))
Text("Call", style = ChromeType.body)
}
}
}
ActionRow(row.actions.orEmpty())
}
}
/** A line that is only a phone number: digits with the usual separators, 7 to 15 digits long. */
private fun String.isPhoneNumber(): Boolean {
val t = trim()
if (!Regex("""^\+?[0-9 ().\-]+$""").matches(t)) return false
return t.count { it.isDigit() } in 7..15
}
@Composable @Composable
internal fun RowItem(row: CarRow, selection: RowSelection? = null, large: Boolean = false) { internal fun RowItem(row: CarRow, selection: RowSelection? = null, large: Boolean = false) {
val toggle = row.toggle val toggle = row.toggle
@@ -661,6 +795,17 @@ internal fun RowItem(row: CarRow, selection: RowSelection? = null, large: Boolea
/** A row's place in a single-choice list: whether it is the chosen one, and what picking it does. */ /** A row's place in a single-choice list: whether it is the chosen one, and what picking it does. */
internal class RowSelection(val selected: Boolean, val onPick: () -> Unit) internal class RowSelection(val selected: Boolean, val onPick: () -> Unit)
/**
* Whether this colour stands out against the background that [content] was chosen for: at least
* 3:1, the WCAG minimum for icons. The background is taken as the opposite of the content colour.
*/
private fun Color.readableOn(content: Color): Boolean {
val background = if (content.luminanceIsDark()) Color.White else Color(0xFF121212)
val a = luminance() + 0.05f
val b = background.luminance() + 0.05f
return maxOf(a, b) / minOf(a, b) >= 3f
}
internal fun Color.luminanceIsDark(): Boolean = internal fun Color.luminanceIsDark(): Boolean =
(red * 0.299f + green * 0.587f + blue * 0.114f) < 0.5f (red * 0.299f + green * 0.587f + blue * 0.114f) < 0.5f
@@ -12,6 +12,7 @@ import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
@@ -19,6 +20,7 @@ import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.viewinterop.AndroidView import androidx.compose.ui.viewinterop.AndroidView
import app.seb3thehacker.gearslip.GearslipLog import app.seb3thehacker.gearslip.GearslipLog
import org.json.JSONObject import org.json.JSONObject
@@ -38,6 +40,7 @@ import org.json.JSONObject
fun WebApp(content: String) { fun WebApp(content: String) {
val navigator = LocalCarNavigator.current val navigator = LocalCarNavigator.current
val darkOutside = LocalDarkOutside.current val darkOutside = LocalDarkOutside.current
val ring = String.format("#%06X", MaterialTheme.colorScheme.primary.toArgb() and 0xFFFFFF)
var web by remember { mutableStateOf<WebView?>(null) } var web by remember { mutableStateOf<WebView?>(null) }
var fieldText by remember { mutableStateOf<String?>(null) } var fieldText by remember { mutableStateOf<String?>(null) }
@@ -65,7 +68,7 @@ fun WebApp(content: String) {
settings.domStorageEnabled = true settings.domStorageEnabled = true
settings.loadWithOverviewMode = true settings.loadWithOverviewMode = true
settings.useWideViewPort = true settings.useWideViewPort = true
webViewClient = LoggingClient() webViewClient = LoggingClient(ring)
addJavascriptInterface( addJavascriptInterface(
FieldFocusBridge(onFocus = { fieldText = it }, onBlur = { fieldText = null }), FieldFocusBridge(onFocus = { fieldText = it }, onBlur = { fieldText = null }),
"GearslipField", "GearslipField",
@@ -99,10 +102,11 @@ fun WebApp(content: String) {
} }
} }
private class LoggingClient : WebViewClient() { private class LoggingClient(private val ring: String) : WebViewClient() {
override fun onPageFinished(view: WebView?, url: String?) { override fun onPageFinished(view: WebView?, url: String?) {
GearslipLog.i("car web page loaded: $url") GearslipLog.i("car web page loaded: $url")
view?.evaluateJavascript(FOCUS_TRACKING_JS, null) view?.evaluateJavascript(FOCUS_TRACKING_JS, null)
view?.evaluateJavascript(focusRingJs(ring), null)
} }
// onPageFinished fires for error pages too, so without this a blocked load (missing // onPageFinished fires for error pages too, so without this a blocked load (missing
@@ -170,6 +174,20 @@ private const val SUBMIT_FIELD_JS = """
})(); })();
""" """
/**
* The same ring the rest of the car UI shows, on whatever link or field the knob is on. Many
* sites hide the browser's own focus outline, which leaves a knob driver with no idea where they are.
*/
private fun focusRingJs(color: String) = """
(function() {
if (document.getElementById('gearslip-focus-ring')) return;
var style = document.createElement('style');
style.id = 'gearslip-focus-ring';
style.textContent = ':focus-visible { outline: 3px solid $color !important; outline-offset: 2px !important; }';
(document.head || document.documentElement).appendChild(style);
})();
"""
/** Reports focus/blur on text inputs, textareas and contenteditable elements to [FieldFocusBridge]. */ /** Reports focus/blur on text inputs, textareas and contenteditable elements to [FieldFocusBridge]. */
private const val FOCUS_TRACKING_JS = """ private const val FOCUS_TRACKING_JS = """
(function() { (function() {
@@ -50,6 +50,8 @@ enum class HoldMarkerStyle {
* colour, so the peel's flap matches whatever it sits on. Round and pill-shaped controls get the * colour, so the peel's flap matches whatever it sits on. Round and pill-shaped controls get the
* dot whatever the theme says: a circle has no corner to peel. * dot whatever the theme says: a circle has no corner to peel.
*/ */
private const val SHOW_HOLD_DOT = false
@Composable @Composable
internal fun BoxScope.GsHoldMarker(style: HoldMarkerStyle, container: Color, shape: Shape, press: InteractionSource) { internal fun BoxScope.GsHoldMarker(style: HoldMarkerStyle, container: Color, shape: Shape, press: InteractionSource) {
if (style == HoldMarkerStyle.NONE) return if (style == HoldMarkerStyle.NONE) return
@@ -68,8 +70,9 @@ internal fun BoxScope.GsHoldMarker(style: HoldMarkerStyle, container: Color, sha
val side = min(size.width, size.height) val side = min(size.width, size.height)
val r = (shape as? CornerBasedShape)?.topEnd?.toPx(size, this) ?: 0f val r = (shape as? CornerBasedShape)?.topEnd?.toPx(size, this) ?: 0f
val round = r >= side * 0.4f val round = r >= side * 0.4f
// The dot is off until it's redesigned: flat themes and round controls show nothing.
if (style == HoldMarkerStyle.DOT || round) { if (style == HoldMarkerStyle.DOT || round) {
drawHoldDot(r, side, ink) if (SHOW_HOLD_DOT) drawHoldDot(r, side, ink)
} else { } else {
// Drawn for the bottom-right corner, then flipped up to the top-right. // Drawn for the bottom-right corner, then flipped up to the top-right.
scale(1f, -1f, pivot = center) { scale(1f, -1f, pivot = center) {
@@ -78,5 +78,7 @@ object CarAppCatalog {
CarAppService.CATEGORY_SETTINGS_APP, CarAppService.CATEGORY_SETTINGS_APP,
CarAppService.CATEGORY_IOT_APP, CarAppService.CATEGORY_IOT_APP,
CarAppService.CATEGORY_WEATHER_APP, CarAppService.CATEGORY_WEATHER_APP,
// Newer apps declare MEDIA, but our AndroidX 1.7 dependency has no constant for it.
"androidx.car.app.category.MEDIA",
) )
} }
@@ -1,6 +1,7 @@
package app.seb3thehacker.gearslip.host package app.seb3thehacker.gearslip.host
import app.seb3thehacker.gearslip.car.CarEnvironment import app.seb3thehacker.gearslip.car.CarEnvironment
import app.seb3thehacker.gearslip.car.CarToasts
import android.content.ComponentName import android.content.ComponentName
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
@@ -9,6 +10,7 @@ import android.content.res.Configuration
import android.graphics.Rect import android.graphics.Rect
import android.location.Location import android.location.Location
import android.net.Uri import android.net.Uri
import android.os.Build
import android.os.Handler import android.os.Handler
import android.os.IBinder import android.os.IBinder
import android.os.Looper import android.os.Looper
@@ -80,6 +82,8 @@ class CarAppConnection(private val context: Context) {
val template: StateFlow<Template?> = _template.asStateFlow() val template: StateFlow<Template?> = _template.asStateFlow()
private val main = Handler(Looper.getMainLooper()) private val main = Handler(Looper.getMainLooper())
/** Captured by each host Binder so callbacks queued by a replaced app cannot show a toast. */
private var toastOwner = Any()
private var carApp: ICarApp? = null private var carApp: ICarApp? = null
private var appManager: IAppManager? = null private var appManager: IAppManager? = null
@@ -139,7 +143,11 @@ class CarAppConnection(private val context: Context) {
// Replaced by another connect (or a disconnect) while waiting on the location service. // Replaced by another connect (or a disconnect) while waiting on the location service.
if (binding !== connection) return@bind if (binding !== connection) return@bind
var bindError: Throwable? = null var bindError: Throwable? = null
val flags = Context.BIND_AUTO_CREATE or LocationKeepAlive.BIND_INCLUDE_CAPABILITIES // BIND_ALLOW_ACTIVITY_STARTS lets the app start its own location service while
// Gearslip's car window is showing. Since Android 14 a bound app only inherits that
// from a visible host that opts in, and MapQuest crashes as navigation starts without it.
val flags = Context.BIND_AUTO_CREATE or LocationKeepAlive.BIND_INCLUDE_CAPABILITIES or
(if (Build.VERSION.SDK_INT >= 34) Context.BIND_ALLOW_ACTIVITY_STARTS else 0)
val bound = runCatching { val bound = runCatching {
// Without BIND_FOREGROUND_SERVICE, an app bound by a foreground service (which is // Without BIND_FOREGROUND_SERVICE, an app bound by a foreground service (which is
// all Gearslip is once the phone screen leaves it) only reaches "important // all Gearslip is once the phone screen leaves it) only reaches "important
@@ -215,6 +223,7 @@ class CarAppConnection(private val context: Context) {
fun disconnect() = disconnect(keepLocation = false) fun disconnect() = disconnect(keepLocation = false)
private fun disconnect(keepLocation: Boolean) { private fun disconnect(keepLocation: Boolean) {
clearToasts()
lent?.let { sendSurfaceDestroyed(it) } lent?.let { sendSurfaceDestroyed(it) }
lent = null lent = null
val carApp = this.carApp val carApp = this.carApp
@@ -285,7 +294,7 @@ class CarAppConnection(private val context: Context) {
?: Intent(Intent.ACTION_MAIN).setComponent(component) ?: Intent(Intent.ACTION_MAIN).setComponent(component)
pendingDestination = null pendingDestination = null
call("onAppCreate", onValue = { onCreated() }) { call("onAppCreate", onValue = { onCreated() }) {
app.onAppCreate(carHost, intent, carConfiguration(), it) app.onAppCreate(createCarHost(toastOwner), intent, carConfiguration(), it)
} }
} }
@@ -558,14 +567,19 @@ class CarAppConnection(private val context: Context) {
// --- host interfaces the app calls back into ----------------------------------------- // --- host interfaces the app calls back into -----------------------------------------
private val appHost = object : IAppHost.Stub() { private fun createAppHost(owner: Any) = object : IAppHost.Stub() {
override fun invalidate() { override fun invalidate() {
GearslipLog.i("host: app invalidated its template") GearslipLog.i("host: app invalidated its template")
main.post { requestTemplate() } main.post { requestTemplate() }
} }
override fun showToast(text: CharSequence?, duration: Int) { override fun showToast(text: CharSequence?, duration: Int) {
GearslipLog.i("host: toast \"$text\"") val message = text?.toString() ?: return
main.post {
if (toastOwner !== owner) return@post
GearslipLog.i("host: toast \"$message\"")
CarToasts.show(owner, message, duration)
}
} }
/** /**
@@ -587,10 +601,19 @@ class CarAppConnection(private val context: Context) {
override fun openMicrophone(request: Bundleable?): Bundleable? = null override fun openMicrophone(request: Bundleable?): Bundleable? = null
} }
private val _trip = MutableStateFlow<androidx.car.app.navigation.model.Trip?>(null)
/** The route as the app tells clusters about it: steps and how far to each. Null when not navigating. */
val trip: StateFlow<androidx.car.app.navigation.model.Trip?> = _trip.asStateFlow()
private val navigationHost = object : INavigationHost.Stub() { private val navigationHost = object : INavigationHost.Stub() {
override fun navigationStarted() = GearslipLog.i("host: navigation started") override fun navigationStarted() = GearslipLog.i("host: navigation started")
override fun navigationEnded() = GearslipLog.i("host: navigation ended") override fun navigationEnded() {
override fun updateTrip(trip: Bundleable?) = Unit GearslipLog.i("host: navigation ended")
_trip.value = null
}
override fun updateTrip(trip: Bundleable?) {
_trip.value = runCatching { trip?.get() as? androidx.car.app.navigation.model.Trip }.getOrNull()
}
} }
/** /**
@@ -656,7 +679,9 @@ class CarAppConnection(private val context: Context) {
override fun updateSuggestions(suggestions: Bundleable?) = Unit override fun updateSuggestions(suggestions: Bundleable?) = Unit
} }
private val carHost = object : ICarHost.Stub() { private fun createCarHost(owner: Any) = object : ICarHost.Stub() {
private val appHost = createAppHost(owner)
override fun getHost(type: String?): IBinder? = when (type) { override fun getHost(type: String?): IBinder? = when (type) {
CarContext.APP_SERVICE -> appHost.asBinder() CarContext.APP_SERVICE -> appHost.asBinder()
CarContext.NAVIGATION_SERVICE -> navigationHost.asBinder() CarContext.NAVIGATION_SERVICE -> navigationHost.asBinder()
@@ -745,10 +770,16 @@ class CarAppConnection(private val context: Context) {
} }
private fun fail(phase: Phase, detail: String) { private fun fail(phase: Phase, detail: String) {
clearToasts()
GearslipLog.e("host: $detail") GearslipLog.e("host: $detail")
update { it.copy(phase = phase, detail = detail) } update { it.copy(phase = phase, detail = detail) }
} }
private fun clearToasts() {
CarToasts.clear(toastOwner)
toastOwner = Any()
}
private fun update(block: (Status) -> Status) { private fun update(block: (Status) -> Status) {
_status.value = block(_status.value) _status.value = block(_status.value)
} }
@@ -18,6 +18,8 @@ object KnownApps {
"com.vivi.vivimusic", // Vivi Music: browse, play, seek, works well "com.vivi.vivimusic", // Vivi Music: browse, play, seek, works well
"com.metrolist.music", // Metrolist: browse, play, works "com.metrolist.music", // Metrolist: browse, play, works
"nl.flitsmeister", // Flitsmeister: map, routing, alerts - works well "nl.flitsmeister", // Flitsmeister: map, routing, alerts - works well
"com.mapquest.android.ace", // MapQuest: map, routing, navigation
"com.mapswithme.maps.pro", // MAPS.ME: map, search, place pages, navigation
) )
fun works(packageName: String): Boolean = packageName in working fun works(packageName: String): Boolean = packageName in working
@@ -71,7 +73,6 @@ object KnownApps {
*/ */
private val partial = setOf( private val partial = setOf(
"app.vela", // Vela Maps: connects and draws, but rough enough not to call it working yet "app.vela", // Vela Maps: connects and draws, but rough enough not to call it working yet
"com.mapquest.android.ace", // MapQuest: map, search and routing run, but not cleanly
"com.spotify.music", // Spotify: plays through Gearslip's player, but the app has to be opened on the phone first "com.spotify.music", // Spotify: plays through Gearslip's player, but the app has to be opened on the phone first
"deezer.android.app", // Deezer: the same - it has to be opened on the phone first "deezer.android.app", // Deezer: the same - it has to be opened on the phone first
) )
@@ -0,0 +1,119 @@
package app.seb3thehacker.gearslip.media
import android.graphics.Bitmap
import android.media.session.PlaybackState
import app.seb3thehacker.gearslip.GearslipLog
import app.seb3thehacker.gearslip.Protobuf
import app.seb3thehacker.gearslip.car.CarServices
import java.io.ByteArrayOutputStream
/**
* Tells the car what's playing, so its own screens show it: the instrument cluster, the media
* source screen, the steering wheel display. Android Auto does the same over the car's
* MediaPlaybackStatusService (Service field 9):
*
* -> MediaPlaybackMetadata { song = 1; artist = 2; album = 3; album_art = 4; duration_seconds = 6 }
* -> MediaPlaybackStatus { state = 1 (1 stopped, 2 playing, 3 paused); media_source = 2;
* playback_seconds = 3; shuffle = 4; repeat = 5; repeat_one = 6 }
*
* Read once a second from the media app Gearslip is driving. The track goes only when it changes;
* the status goes every second while playing, so the car's position counts along.
*/
class CarMediaStatus(
val channelId: Int,
private val sendOnChannel: (messageId: Int, body: ByteArray) -> Unit,
) {
@Volatile private var running = false
private var sentTrack: String? = null
private var sentState = -1
private var inputsLogged = 0
fun onMessage(messageId: Int, body: ByteArray) {
when (messageId) {
MSG_CHANNEL_OPEN_RESPONSE -> {
val status = Protobuf.readInt32Field(body, 1) ?: 0
GearslipLog.i("media status: <- ChannelOpenResponse: status=$status")
if (status == 0) start()
}
// The car's own buttons for the media source (play, next...). Logged to learn what
// this car sends; the steering wheel keys already arrive on the input channel.
MSG_INPUT -> if (inputsLogged++ < 10) GearslipLog.i("media status: <- input from the car:\n" + Protobuf.describe(body))
else -> GearslipLog.i("media status: <- unhandled message id=$messageId (${body.size} bytes)")
}
}
private fun start() {
if (running) return
running = true
Thread({
while (running) {
runCatching { tick() }.onFailure { GearslipLog.w("media status: ${it.message}") }
Thread.sleep(TICK_MS)
}
}, "gearslip-media-status").start()
}
fun stop() {
running = false
}
private fun tick() {
val now = CarServices.mediaOrNull()?.now?.value
val active = now != null && now.isActive
val state = when {
!active -> STATE_STOPPED
now!!.state == PlaybackState.STATE_PAUSED -> STATE_PAUSED
else -> STATE_PLAYING
}
if (active) sendTrack(now!!)
// Nothing to say while stopped, beyond saying so once.
if (state == STATE_STOPPED && sentState == STATE_STOPPED) return
if (state == STATE_PAUSED && sentState == STATE_PAUSED) return
val source = CarServices.mediaApp.value?.label ?: "Gearslip"
val seconds = if (active) (now!!.currentPosition() / 1000).coerceAtLeast(0) else 0
sendOnChannel(
MSG_STATUS,
Protobuf.varintField(1, state.toLong()) +
Protobuf.stringField(2, source) +
Protobuf.varintField(3, seconds) +
Protobuf.varintField(4, 0L) +
Protobuf.varintField(5, 0L) +
Protobuf.varintField(6, 0L),
)
if (state != sentState) GearslipLog.i("media status: -> ${STATE_NAMES[state]} ($source)")
sentState = state
}
private fun sendTrack(now: NowPlaying) {
val key = "${now.title}\u0000${now.artist}\u0000${now.album}\u0000${now.durationMs}\u0000${now.art?.generationId}"
if (key == sentTrack) return
sentTrack = key
var body = Protobuf.stringField(1, now.title) +
Protobuf.stringField(2, now.artist) +
Protobuf.stringField(3, now.album)
now.art?.let { art -> artBytes(art)?.let { body += Protobuf.bytesField(4, it) } }
if (now.durationMs > 0) body += Protobuf.varintField(6, now.durationMs / 1000)
sendOnChannel(MSG_METADATA, body)
GearslipLog.i("media status: -> track \"${now.title}\" by ${now.artist.ifBlank { "?" }}")
}
/** Album art as a small PNG: car screens show it at thumbnail size, and the link is shared with video. */
private fun artBytes(art: Bitmap): ByteArray? = runCatching {
val scale = minOf(1f, ART_PX.toFloat() / maxOf(art.width, art.height))
val small = if (scale < 1f) Bitmap.createScaledBitmap(art, (art.width * scale).toInt(), (art.height * scale).toInt(), true) else art
ByteArrayOutputStream().also { small.compress(Bitmap.CompressFormat.PNG, 100, it) }.toByteArray()
}.getOrNull()
companion object {
private const val TICK_MS = 1_000L
private const val ART_PX = 256
private const val MSG_CHANNEL_OPEN_RESPONSE = 8
private const val MSG_STATUS = 32769
private const val MSG_INPUT = 32770
private const val MSG_METADATA = 32771
private const val STATE_STOPPED = 1
private const val STATE_PLAYING = 2
private const val STATE_PAUSED = 3
private val STATE_NAMES = mapOf(1 to "stopped", 2 to "playing", 3 to "paused")
}
}
@@ -32,6 +32,8 @@ object UsageStats {
val android: Boolean = true, val android: Boolean = true,
val phone: Boolean = true, val phone: Boolean = true,
val cars: Boolean = true, val cars: Boolean = true,
/** How each car session went: how far it got, how and when it ended. Off until ticked. */
val failures: Boolean = false,
) )
private const val PREFS = "usage_stats" private const val PREFS = "usage_stats"
@@ -49,6 +51,10 @@ object UsageStats {
/** The daily background job of earlier builds, cancelled wherever it's still scheduled. */ /** The daily background job of earlier builds, cancelled wherever it's still scheduled. */
private const val OLD_JOB_ID = 4_207 private const val OLD_JOB_ID = 4_207
private const val MAX_CARS = 10 private const val MAX_CARS = 10
/** Each session for the "Where connections fail" share, waiting for the next note. */
private const val KEY_SESSIONS = "sessions"
private const val KEY_LAST_SESSIONS = "last_sessions"
private const val MAX_SESSIONS = 20
private fun prefs(context: Context) = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE) private fun prefs(context: Context) = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
@@ -69,20 +75,27 @@ object UsageStats {
android = p.getBoolean("share_android", true), android = p.getBoolean("share_android", true),
phone = p.getBoolean("share_phone", true), phone = p.getBoolean("share_phone", true),
cars = p.getBoolean("share_cars", true), cars = p.getBoolean("share_cars", true),
failures = p.getBoolean("share_failures", false),
) )
} }
fun set(context: Context, enabled: Boolean, choices: Choices = choices(context)) { fun set(context: Context, enabled: Boolean, choices: Choices = choices(context)) {
val wasEnabled = enabled(context)
prefs(context).edit() prefs(context).edit()
.putBoolean(KEY_ENABLED, enabled) .putBoolean(KEY_ENABLED, enabled)
.putBoolean(KEY_ASKED, true) .putBoolean(KEY_ASKED, true)
.putBoolean("share_android", choices.android) .putBoolean("share_android", choices.android)
.putBoolean("share_phone", choices.phone) .putBoolean("share_phone", choices.phone)
.putBoolean("share_cars", choices.cars) .putBoolean("share_cars", choices.cars)
.putBoolean("share_failures", choices.failures)
.apply() .apply()
// Turning it on sends nothing yet: the driver may still be ticking boxes. The first note // Turning it on sends nothing yet: the driver may still be ticking boxes. The first note
// waits for [choicesClosed]. // waits for [choicesClosed].
if (!enabled) prefs(context).edit().remove(KEY_CARS).remove(KEY_LAST_CARS).remove(KEY_SENT_DAY).apply() if (!enabled) prefs(context).edit().remove(KEY_CARS).remove(KEY_LAST_CARS).remove(KEY_SENT_DAY)
.remove(KEY_SESSIONS).remove(KEY_LAST_SESSIONS).apply()
if (!choices.failures) prefs(context).edit().remove(KEY_SESSIONS).remove(KEY_LAST_SESSIONS).apply()
// Turned on anywhere - Settings, Home, or the ask itself - answers the one-time ask.
if (enabled && !wasEnabled) WorkedPrompt.onSharingOn(context)
} }
/** The driver left the screen with the usage note choices: send today's note if it's due. */ /** The driver left the screen with the usage note choices: send today's note if it's due. */
@@ -90,10 +103,15 @@ object UsageStats {
if (prefs(context).getString(KEY_SENT_DAY, null) != LocalDate.now().toString()) sendSoon(context) if (prefs(context).getString(KEY_SENT_DAY, null) != LocalDate.now().toString()) sendSoon(context)
} }
/** Gearslip was opened: send today's note unless one already went out. */ /**
* Gearslip was opened: send today's note unless one already went out, or sooner if a session
* is still waiting, like one that ended in a crash before its note could go.
*/
fun onAppOpened(context: Context) { fun onAppOpened(context: Context) {
context.getSystemService(JobScheduler::class.java)?.cancel(OLD_JOB_ID) context.getSystemService(JobScheduler::class.java)?.cancel(OLD_JOB_ID)
if (prefs(context).getString(KEY_SENT_DAY, null) != LocalDate.now().toString()) sendSoon(context) val p = prefs(context)
val waiting = p.getString(KEY_CARS, "[]") != "[]" || p.getString(KEY_SESSIONS, "[]") != "[]"
if (waiting || p.getString(KEY_SENT_DAY, null) != LocalDate.now().toString()) sendSoon(context)
} }
private val sending = AtomicBoolean(false) private val sending = AtomicBoolean(false)
@@ -134,13 +152,35 @@ object UsageStats {
outcome: String, outcome: String,
screen: String = "", screen: String = "",
dpi: Int = 0, dpi: Int = 0,
session: JSONObject? = null,
) { ) {
if (!enabled(context)) return if (!enabled(context)) return
val p = prefs(context) val p = prefs(context)
synchronized(carsLock) { addCar(p, info, protocol, outcome, screen, dpi) } synchronized(carsLock) {
addCar(p, info, protocol, outcome, screen, dpi)
if (session != null && choices(context).failures) addSession(p, info, protocol, session)
}
sendSoon(context) sendSoon(context)
} }
private fun addSession(
p: android.content.SharedPreferences,
info: ServiceDiscovery.HeadUnitInfo?,
protocol: String,
session: JSONObject,
) {
val sessions = runCatching { JSONArray(p.getString(KEY_SESSIONS, "[]")) }.getOrDefault(JSONArray())
if (sessions.length() >= MAX_SESSIONS) return
// The same car fields as the cars list, so the dashboard can line the two up.
session.put("name", info?.headUnitName.orEmpty())
.put("car", info?.carModel.orEmpty())
.put("year", info?.carYear.orEmpty())
.put("protocol", protocol)
sessions.put(session)
// Written at once: after a crash the app is about to die, and a deferred write would be lost.
p.edit().putString(KEY_SESSIONS, sessions.toString()).commit()
}
private fun addCar( private fun addCar(
p: android.content.SharedPreferences, p: android.content.SharedPreferences,
info: ServiceDiscovery.HeadUnitInfo?, info: ServiceDiscovery.HeadUnitInfo?,
@@ -162,15 +202,40 @@ object UsageStats {
// The same car and outcome twice in a day is one fact, not two. // The same car and outcome twice in a day is one fact, not two.
val seen = (0 until cars.length()).any { cars.optJSONObject(it)?.toString() == car.toString() } val seen = (0 until cars.length()).any { cars.optJSONObject(it)?.toString() == car.toString() }
if (!seen && cars.length() < MAX_CARS) cars.put(car) if (!seen && cars.length() < MAX_CARS) cars.put(car)
p.edit().putString(KEY_CARS, cars.toString()).apply() p.edit().putString(KEY_CARS, cars.toString()).commit()
} }
// GrapheneOS reports the same build fields as stock Pixel firmware; its own apps give it away. /**
private fun isGrapheneOs(context: Context) = * Which Android this is: a privacy OS by name, or "Stock" with the phone's brand.
runCatching { context.packageManager.getPackageInfo("app.grapheneos.setupwizard", 0) }.isSuccess *
* GrapheneOS reports the same build fields as stock Pixel firmware, so its own setup app gives
* it away; /e/OS is told apart from the LineageOS it's built on by its app store. The rest set
* build properties of their own. The iodéOS and DivestOS checks are best guesses from their
* build names, not confirmed on a real phone.
*/
fun osName(context: Context): String {
fun installed(pkg: String) = runCatching { context.packageManager.getPackageInfo(pkg, 0) }.isSuccess
val names = listOf("ro.build.flavor", "ro.build.display.id", "ro.lineage.version")
.joinToString(" ") { prop(it) } + " " + Build.DISPLAY + " " + Build.FINGERPRINT
return when {
installed("app.grapheneos.setupwizard") -> "GrapheneOS"
prop("ro.calyxos.version").isNotEmpty() -> "CalyxOS"
installed("foundation.e.apps") -> "/e/OS"
names.contains("iode", ignoreCase = true) -> "iodéOS"
names.contains("divest", ignoreCase = true) -> "DivestOS"
prop("ro.lineage.version").isNotEmpty() -> "LineageOS"
else -> "Stock (${Build.BRAND.replaceFirstChar { it.uppercase() }})"
}
}
/** A system property, or "" when it isn't set or can't be read. */
private fun prop(name: String): String = runCatching {
Class.forName("android.os.SystemProperties").getMethod("get", String::class.java)
.invoke(null, name) as String
}.getOrDefault("")
private fun details(context: Context, c: Choices) = JSONObject().apply { private fun details(context: Context, c: Choices) = JSONObject().apply {
if (c.android) put("android", Build.VERSION.RELEASE).put("os", if (isGrapheneOs(context)) "GrapheneOS" else "other") if (c.android) put("android", Build.VERSION.RELEASE).put("os", osName(context))
if (c.phone) put("phone", "${Build.MANUFACTURER} ${Build.MODEL}") if (c.phone) put("phone", "${Build.MANUFACTURER} ${Build.MODEL}")
} }
@@ -203,9 +268,33 @@ object UsageStats {
} }
note.put("cars", cars) note.put("cars", cars)
} }
if (c.failures) {
var sessions = runCatching { JSONArray(p.getString(KEY_SESSIONS, "[]")) }.getOrDefault(JSONArray())
if (everything && sessions.length() == 0) {
sessions = runCatching { JSONArray(p.getString(KEY_LAST_SESSIONS, "[]")) }.getOrDefault(JSONArray())
}
note.put("sessions", sessions)
}
return note return note
} }
/**
* A made-up session, for "What gets sent" to show before the first real one: a 25-minute
* drive that ended with the car switched off.
*/
val exampleSession: JSONObject
get() = JSONObject()
.put("stage", "streaming")
.put("ended", "byebye")
.put("code", "bye 1")
.put("failed_at", 1500)
.put("seconds", 1500)
.put("cert", "downloaded")
.put("name", "Head unit name")
.put("car", "Car model")
.put("year", "2021")
.put("protocol", "1.5")
/** Sends the note now; false when it didn't get through. */ /** Sends the note now; false when it didn't get through. */
private fun send(context: Context): Boolean { private fun send(context: Context): Boolean {
if (!sends || !enabled(context)) return true if (!sends || !enabled(context)) return true
@@ -213,6 +302,7 @@ object UsageStats {
val p = prefs(context) val p = prefs(context)
val note = synchronized(carsLock) { preview(context) } val note = synchronized(carsLock) { preview(context) }
val sentCars = note.optJSONArray("cars")?.toString() val sentCars = note.optJSONArray("cars")?.toString()
val sentSessions = note.optJSONArray("sessions")?.toString()
val sentDetails = if (note.has("android") || note.has("phone")) details(context, choices(context)).toString() else null val sentDetails = if (note.has("android") || note.has("phone")) details(context, choices(context)).toString() else null
val body = note.toString().toByteArray() val body = note.toString().toByteArray()
return runCatching { return runCatching {
@@ -234,6 +324,16 @@ object UsageStats {
if (sentDetails != null) edit.putString(KEY_SENT_DETAILS, sentDetails).putString(KEY_DETAILS_DAY, LocalDate.now().toString()) if (sentDetails != null) edit.putString(KEY_SENT_DETAILS, sentDetails).putString(KEY_DETAILS_DAY, LocalDate.now().toString())
if (sentCars != null && sentCars != "[]") edit.putString(KEY_LAST_CARS, sentCars) if (sentCars != null && sentCars != "[]") edit.putString(KEY_LAST_CARS, sentCars)
if (sentCars == null || p.getString(KEY_CARS, "[]") == sentCars) edit.remove(KEY_CARS) if (sentCars == null || p.getString(KEY_CARS, "[]") == sentCars) edit.remove(KEY_CARS)
if (sentSessions != null && sentSessions != "[]") edit.putString(KEY_LAST_SESSIONS, sentSessions)
if (sentSessions == null || p.getString(KEY_SESSIONS, "[]") == sentSessions) {
edit.remove(KEY_SESSIONS)
} else if (sentSessions != "[]") {
// A session ended mid-send: keep only the ones this note didn't carry.
val now = JSONArray(p.getString(KEY_SESSIONS, "[]"))
val sent = JSONArray(sentSessions).length()
val left = JSONArray().also { for (i in sent until now.length()) it.put(now.get(i)) }
edit.putString(KEY_SESSIONS, left.toString())
}
edit.apply() edit.apply()
} }
GearslipLog.i("stats: note sent") GearslipLog.i("stats: note sent")
@@ -0,0 +1,179 @@
package app.seb3thehacker.gearslip.stats
import android.Manifest
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.content.pm.PackageManager
import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat
import app.seb3thehacker.gearslip.GearslipActivity
import app.seb3thehacker.gearslip.GearslipLog
import app.seb3thehacker.gearslip.ServiceDiscovery
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import org.json.JSONObject
/**
* The one-time "Gearslip worked with your car. Share that?" ask, for drivers who don't share
* usage notes. It comes after the first drive that worked, when a driver is happiest to help, as
* a notification the moment the car leaves and as a card on Home. Answering either clears both,
* and it never comes back.
*
* Saying yes turns on the same usage notes switch as Settings, and that drive's car goes in the
* first note. Until the driver answers, the car waits here on the phone and is sent nowhere.
*/
object WorkedPrompt {
private const val PREFS = "worked_prompt"
private const val KEY_DONE = "done"
private const val KEY_CAR = "car"
private const val CHANNEL = "worked"
private const val NOTIFICATION_ID = 4_311
private const val ACTION_SHARE = "app.seb3thehacker.gearslip.WORKED_SHARE"
private const val ACTION_NOT_NOW = "app.seb3thehacker.gearslip.WORKED_NOT_NOW"
private fun prefs(context: Context) = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
private var loaded = false
private val flow = MutableStateFlow<String?>(null)
/** The car's name while the ask is waiting for an answer, otherwise null. */
fun pending(context: Context): StateFlow<String?> {
if (!loaded) {
loaded = true
flow.value = saved(context)?.let(::carName)
}
return flow
}
/** A drive that reached the car's screen ended cleanly. Asks, if it's never asked before. */
fun onDriveWorked(context: Context, info: ServiceDiscovery.HeadUnitInfo?, protocol: String, screen: String, dpi: Int) {
val p = prefs(context)
if (UsageStats.enabled(context) || p.getBoolean(KEY_DONE, false) || p.contains(KEY_CAR)) return
val car = JSONObject()
.put("name", info?.headUnitName.orEmpty())
.put("car", info?.carModel.orEmpty())
.put("year", info?.carYear.orEmpty())
.put("make", info?.headUnitMake.orEmpty())
.put("model", info?.headUnitModel.orEmpty())
.put("protocol", protocol)
.put("screen", screen)
.put("dpi", dpi)
p.edit().putString(KEY_CAR, car.toString()).apply()
loaded = true
flow.value = carName(car)
notify(context, carName(car))
}
/**
* True once a driver turns sharing on from the ask, until Home has thrown its confetti. It
* waits for Home: a Share tapped in the notification celebrates the next time Gearslip opens.
*/
private val celebrateFlow = MutableStateFlow(false)
val celebrate: StateFlow<Boolean> = celebrateFlow
fun celebrated() { celebrateFlow.value = false }
/** The driver answered, here or on Home. Either way it's never asked again. */
fun answer(context: Context, share: Boolean) {
if (share) {
// Turning sharing on lands in [onSharingOn], which clears the ask.
UsageStats.set(context, true)
return
}
clear(context)
GearslipLog.i("worked prompt: not now")
}
/**
* Usage notes were just turned on, from the ask or anywhere else. With the ask still waiting,
* that answers it, and the car it was about goes in the first note.
*/
internal fun onSharingOn(context: Context) {
val car = saved(context) ?: return
clear(context)
celebrateFlow.value = true
GearslipLog.i("worked prompt: usage notes turned on")
val info = ServiceDiscovery.HeadUnitInfo(
car.optString("name"), car.optString("car"), car.optString("year"),
car.optString("make"), car.optString("model"),
)
UsageStats.recordSession(
context, info, car.optString("protocol"), "connected",
car.optString("screen"), car.optInt("dpi"),
)
}
private fun clear(context: Context) {
prefs(context).edit().remove(KEY_CAR).putBoolean(KEY_DONE, true).apply()
loaded = true
flow.value = null
NotificationManagerCompat.from(context).cancel(NOTIFICATION_ID)
}
private fun saved(context: Context): JSONObject? =
prefs(context).getString(KEY_CAR, null)?.let { runCatching { JSONObject(it) }.getOrNull() }
/** "your 2019 Clio", "your Renault Media Nav", or "your car" when it didn't say. */
private fun carName(car: JSONObject): String {
val model = car.optString("car").trim()
val year = car.optString("year").trim().takeIf { it.isNotEmpty() && it != "0" }
val unit = "${car.optString("make")} ${car.optString("model")}".trim()
return when {
model.isNotEmpty() -> "your " + listOfNotNull(year, model).joinToString(" ")
unit.isNotEmpty() -> "your $unit"
else -> "your car"
}
}
private fun notify(context: Context, car: String) {
if (context.checkSelfPermission(Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED) return
context.getSystemService(NotificationManager::class.java).createNotificationChannel(
NotificationChannel(CHANNEL, "After a drive", NotificationManager.IMPORTANCE_DEFAULT),
)
val open = PendingIntent.getActivity(
context, NOTIFICATION_ID,
Intent(context, GearslipActivity::class.java).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP),
PendingIntent.FLAG_IMMUTABLE,
)
val text = "Share that so the next driver knows it works? No name, account, or location."
val notification = NotificationCompat.Builder(context, CHANNEL)
.setSmallIcon(android.R.drawable.ic_menu_send)
.setContentTitle(TITLE_START + car)
.setContentText(text)
.setStyle(NotificationCompat.BigTextStyle().bigText(text))
.setContentIntent(open)
.setAutoCancel(true)
.addAction(0, "Not now", broadcast(context, ACTION_NOT_NOW))
.addAction(0, "Share", broadcast(context, ACTION_SHARE))
.build()
runCatching { NotificationManagerCompat.from(context).notify(NOTIFICATION_ID, notification) }
.onSuccess { GearslipLog.i("worked prompt: asked after the first good drive") }
.onFailure { GearslipLog.w("worked prompt: could not post the notification (${it.message})") }
}
private fun broadcast(context: Context, action: String): PendingIntent =
PendingIntent.getBroadcast(
context, action.hashCode(),
Intent(context, WorkedPromptReceiver::class.java).setAction(action),
PendingIntent.FLAG_IMMUTABLE,
)
internal fun onAction(context: Context, intent: Intent) {
when (intent.action) {
ACTION_SHARE -> answer(context, share = true)
ACTION_NOT_NOW -> answer(context, share = false)
}
}
const val TITLE_START = "Gearslip worked with "
}
/** The notification's Share and Not now buttons land here. */
class WorkedPromptReceiver : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) = WorkedPrompt.onAction(context, intent)
}
@@ -0,0 +1,49 @@
package app.seb3thehacker.gearslip.ui
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.unit.dp
import app.seb3thehacker.gearslip.BuildConfig
/**
* Shown once, the first time the driver turns down Android's screen-sharing dialog: that dialog
* is the price of sending music over USB, and most people who refuse it would rather not see it
* again. Offers the two ways out: Bluetooth, or the one-time adb command.
*/
@Composable
fun AudioChoiceDialog(onUseBluetooth: () -> Unit, onKeepAsking: () -> Unit) {
var showCommand by remember { mutableStateOf(false) }
AlertDialog(
onDismissRequest = onKeepAsking,
title = { Text("Play car audio over Bluetooth?") },
text = {
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
Text(
"Android treats music sent over USB like sharing your screen, so it asks every " +
"drive. Over Bluetooth it never asks. You can change this later in Settings.",
)
if (showCommand) {
Text(
"Or keep USB and stop the question for good: plug the phone into a " +
"computer with USB debugging on, and run this once.",
style = MaterialTheme.typography.bodyMedium,
)
CopyableCommand("adb shell appops set ${BuildConfig.APPLICATION_ID} PROJECT_MEDIA allow")
} else {
TextButton(onClick = { showCommand = true }) { Text("Never ask, using a computer") }
}
}
},
confirmButton = { TextButton(onClick = onUseBluetooth) { Text("Use Bluetooth") } },
dismissButton = { TextButton(onClick = onKeepAsking) { Text("Keep asking") } },
)
}
@@ -60,7 +60,6 @@ fun CertSetupScreen(onDone: () -> Unit) {
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
var downloadState by remember { mutableStateOf<DownloadState>(DownloadState.Idle) } var downloadState by remember { mutableStateOf<DownloadState>(DownloadState.Idle) }
var showImport by remember { mutableStateOf(false) }
var pendingUri by remember { mutableStateOf<Uri?>(null) } var pendingUri by remember { mutableStateOf<Uri?>(null) }
var manualProgress by remember { mutableStateOf(false) } var manualProgress by remember { mutableStateOf(false) }
@@ -91,7 +90,7 @@ fun CertSetupScreen(onDone: () -> Unit) {
if (installed) { if (installed) {
"Cars accept only Google-issued certificates, and you have one loaded." "Cars accept only Google-issued certificates, and you have one loaded."
} else { } else {
"Cars accept only Google-issued certificates. Download one to connect." "Cars accept only Google-issued certificates. Gearslip doesn't include one, so choose where yours comes from."
}, },
style = MaterialTheme.typography.bodyLarge, style = MaterialTheme.typography.bodyLarge,
textAlign = TextAlign.Center, textAlign = TextAlign.Center,
@@ -131,10 +130,11 @@ fun CertSetupScreen(onDone: () -> Unit) {
}, },
modifier = Modifier.fillMaxWidth().height(56.dp), modifier = Modifier.fillMaxWidth().height(56.dp),
) { ) {
Text("Download certificate") Text("Download from aasdk")
} }
Text( Text(
"Works with most cars built before 2020. Newer cars reject it.", "The open-source aasdk project publishes this certificate at github.com/opencardev/aasdk. " +
"Most cars accept it; some from around 2020 on don't.",
style = MaterialTheme.typography.bodySmall, style = MaterialTheme.typography.bodySmall,
textAlign = TextAlign.Center, textAlign = TextAlign.Center,
color = MaterialTheme.colorScheme.onSurfaceVariant, color = MaterialTheme.colorScheme.onSurfaceVariant,
@@ -177,16 +177,18 @@ fun CertSetupScreen(onDone: () -> Unit) {
Spacer(Modifier.height(16.dp)) Spacer(Modifier.height(16.dp))
if (showImport) { OutlinedButton(
OutlinedButton( onClick = { importLauncher.launch(arrayOf("*/*")) },
onClick = { importLauncher.launch(arrayOf("*/*")) }, modifier = Modifier.fillMaxWidth().height(52.dp),
modifier = Modifier.fillMaxWidth().height(52.dp), ) { Text("Import my own file") }
) { Text("Import your own .p12 cert") } Text(
Spacer(Modifier.height(16.dp)) "A .p12 file with its private key, from wherever you choose.",
} else { style = MaterialTheme.typography.bodySmall,
TextButton(onClick = { showImport = true }) { Text("Import your own certificate instead") } textAlign = TextAlign.Center,
Spacer(Modifier.height(8.dp)) color = MaterialTheme.colorScheme.onSurfaceVariant,
} modifier = Modifier.padding(top = 8.dp),
)
Spacer(Modifier.height(16.dp))
OutlinedButton( OutlinedButton(
onClick = { onClick = {
@@ -14,8 +14,17 @@ data class CertSummary(
val validUntil: String?, val validUntil: String?,
) { ) {
companion object { companion object {
/**
* The last [read], so Settings draws the certificate row at once instead of a second
* later, which shifted everything under it. Null until the first read.
*/
@Volatile var cached: CertSummary? = null
private set
/** Blocking (parses a key store): call from a background dispatcher. */ /** Blocking (parses a key store): call from a background dispatcher. */
fun read(context: Context): CertSummary { fun read(context: Context): CertSummary = readNow(context).also { cached = it }
private fun readNow(context: Context): CertSummary {
val identity = CertProvider.loadSupplied(context) val identity = CertProvider.loadSupplied(context)
?: return CertSummary( ?: return CertSummary(
CertProvider.Kind.SELF_SIGNED, CertProvider.Kind.SELF_SIGNED,
@@ -0,0 +1,88 @@
package app.seb3thehacker.gearslip.ui
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.runtime.withFrameNanos
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.drawscope.rotate
import kotlin.math.sin
import kotlin.random.Random
private class Piece(
val x: Float, // across the screen, 0..1
val delay: Float, // seconds before it starts falling
val speed: Float, // screen heights per second
val sway: Float, // how far it drifts side to side, in screen widths
val swaySpeed: Float,
val spin: Float, // degrees per second
val width: Float, // dp
val height: Float,
val color: Color,
)
/**
* Confetti falling from the top of the screen once, for about three seconds. Draws over whatever
* is under it and takes no touches; [onDone] fires when the last piece has left the screen.
*/
@Composable
internal fun ConfettiFall(onDone: () -> Unit, modifier: Modifier = Modifier) {
val scheme = MaterialTheme.colorScheme
val colors = listOf(
scheme.primary, scheme.tertiary, scheme.secondary,
Color(0xFFFFC107), Color(0xFFE91E63), Color(0xFF4CAF50), Color(0xFF2196F3),
)
val pieces = remember {
List(PIECES) {
Piece(
x = Random.nextFloat(),
delay = Random.nextFloat() * 0.9f,
speed = 0.35f + Random.nextFloat() * 0.35f,
sway = 0.01f + Random.nextFloat() * 0.03f,
swaySpeed = 2f + Random.nextFloat() * 4f,
spin = (Random.nextFloat() - 0.5f) * 720f,
width = 4f + Random.nextFloat() * 4f,
height = 8f + Random.nextFloat() * 6f,
color = colors[Random.nextInt(colors.size)],
)
}
}
var t by remember { mutableFloatStateOf(0f) }
LaunchedEffect(Unit) {
val start = withFrameNanos { it }
// The slowest piece, starting last, clears the screen by this time.
val end = pieces.maxOf { it.delay + 1.1f / it.speed }
while (t < end) {
withFrameNanos { t = (it - start) / 1e9f }
}
onDone()
}
Canvas(modifier.fillMaxSize()) {
val density = density
for (p in pieces) {
val age = t - p.delay
if (age < 0f) continue
val y = (age * p.speed - 0.05f) * size.height
if (y > size.height + 40f) continue
val x = (p.x + sin(age * p.swaySpeed) * p.sway) * size.width
rotate(age * p.spin, pivot = Offset(x, y)) {
drawRect(
p.color,
topLeft = Offset(x - p.width * density / 2, y - p.height * density / 2),
size = Size(p.width * density, p.height * density),
)
}
}
}
}
private const val PIECES = 140
@@ -60,6 +60,8 @@ fun GearslipApp(onDisconnect: () -> Unit, onRequestCallScreening: () -> Unit) {
}, },
) )
} }
// Home's usage notes row opens Settings at that section rather than at the top.
var settingsAtUsageNotes by rememberSaveable { mutableStateOf(false) }
BackHandler(enabled = screen != Screen.HOME) { BackHandler(enabled = screen != Screen.HOME) {
screen = if (screen == Screen.RECOMMENDED_APPS) Screen.SETTINGS else Screen.HOME screen = if (screen == Screen.RECOMMENDED_APPS) Screen.SETTINGS else Screen.HOME
} }
@@ -75,6 +77,10 @@ fun GearslipApp(onDisconnect: () -> Unit, onRequestCallScreening: () -> Unit) {
// The guide asks for this now, but anyone who finished it before that never saw the page. // The guide asks for this now, but anyone who finished it before that never saw the page.
val notificationPermission = rememberLauncherForActivityResult(ActivityResultContracts.RequestPermission()) {} val notificationPermission = rememberLauncherForActivityResult(ActivityResultContracts.RequestPermission()) {}
// Reads the certificate once in the background, so Settings has it ready when opened.
LaunchedEffect(Unit) {
kotlinx.coroutines.withContext(kotlinx.coroutines.Dispatchers.Default) { runCatching { CertSummary.read(context) } }
}
LaunchedEffect(Unit) { LaunchedEffect(Unit) {
if (!needsSetupGuide && if (!needsSetupGuide &&
context.checkSelfPermission(Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED context.checkSelfPermission(Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED
@@ -95,18 +101,20 @@ fun GearslipApp(onDisconnect: () -> Unit, onRequestCallScreening: () -> Unit) {
when (screen) { when (screen) {
Screen.HOME -> HomeScreen( Screen.HOME -> HomeScreen(
onOpenLogs = { screen = Screen.LOGS }, onOpenLogs = { screen = Screen.LOGS },
onOpenSettings = { screen = Screen.SETTINGS }, onOpenSettings = { settingsAtUsageNotes = false; screen = Screen.SETTINGS },
onOpenCarPreview = { screen = Screen.CAR_PREVIEW }, onOpenCarPreview = { screen = Screen.CAR_PREVIEW },
onOpenHelp = { screen = Screen.HELP }, onOpenHelp = { screen = Screen.HELP },
onOpenUsageNotes = { settingsAtUsageNotes = true; screen = Screen.SETTINGS },
onDisconnect = onDisconnect, onDisconnect = onDisconnect,
) )
Screen.WHATS_NEW -> WhatsNewScreen(onBack = { screen = Screen.SETTINGS }) Screen.WHATS_NEW -> WhatsNewScreen(onBack = { screen = Screen.SETTINGS })
Screen.LOGS -> LogsScreen(onBack = { screen = Screen.HOME }) Screen.LOGS -> LogsScreen(onBack = { screen = Screen.HOME })
Screen.SETTINGS -> SettingsScreen( Screen.SETTINGS -> SettingsScreen(
onBack = { screen = Screen.HOME }, onBack = { screen = Screen.HOME },
onOpenRecommendedApps = { screen = Screen.RECOMMENDED_APPS }, onOpenRecommendedApps = { settingsAtUsageNotes = false; screen = Screen.RECOMMENDED_APPS },
onOpenWhatsNew = { screen = Screen.WHATS_NEW }, onOpenWhatsNew = { settingsAtUsageNotes = false; screen = Screen.WHATS_NEW },
onReplayTutorial = { screen = Screen.SETUP_GUIDE }, onReplayTutorial = { screen = Screen.SETUP_GUIDE },
atUsageNotes = settingsAtUsageNotes,
) )
Screen.RECOMMENDED_APPS -> RecommendedAppsScreen(onBack = { screen = Screen.SETTINGS }) Screen.RECOMMENDED_APPS -> RecommendedAppsScreen(onBack = { screen = Screen.SETTINGS })
Screen.CAR_PREVIEW -> CarPreviewScreen(onBack = { screen = Screen.HOME }) Screen.CAR_PREVIEW -> CarPreviewScreen(onBack = { screen = Screen.HOME })
@@ -45,10 +45,6 @@ private class Step(
private fun setupSteps(onRequestCallScreening: () -> Unit) = listOf( private fun setupSteps(onRequestCallScreening: () -> Unit) = listOf(
Step("Use a car from 2020 or earlier.", "Newer head units reject the certificate."), Step("Use a car from 2020 or earlier.", "Newer head units reject the certificate."),
Step(
"On GrapheneOS, exempt Gearslip from exploit protections.",
"Open Settings, then Apps, then Gearslip, and turn on Exploit protection compatibility mode.",
),
Step("Disable or uninstall the Android Auto app."), Step("Disable or uninstall the Android Auto app."),
Step("Load a certificate.", "Open Settings, then download or import a certificate."), Step("Load a certificate.", "Open Settings, then download or import a certificate."),
Step( Step(
@@ -2,6 +2,7 @@ package app.seb3thehacker.gearslip.ui
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
@@ -31,6 +32,7 @@ import androidx.compose.material3.Surface
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.graphics.vector.ImageVector
@@ -41,6 +43,7 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
import app.seb3thehacker.gearslip.BuildConfig import app.seb3thehacker.gearslip.BuildConfig
import app.seb3thehacker.gearslip.SessionStatus import app.seb3thehacker.gearslip.SessionStatus
import app.seb3thehacker.gearslip.notify.TestMessage import app.seb3thehacker.gearslip.notify.TestMessage
import app.seb3thehacker.gearslip.stats.WorkedPrompt
@Composable @Composable
fun HomeScreen( fun HomeScreen(
@@ -48,81 +51,104 @@ fun HomeScreen(
onOpenSettings: () -> Unit, onOpenSettings: () -> Unit,
onOpenCarPreview: () -> Unit, onOpenCarPreview: () -> Unit,
onOpenHelp: () -> Unit, onOpenHelp: () -> Unit,
onOpenUsageNotes: () -> Unit,
onDisconnect: () -> Unit, onDisconnect: () -> Unit,
) { ) {
val status by SessionStatus.state.collectAsStateWithLifecycle() val status by SessionStatus.state.collectAsStateWithLifecycle()
val context = LocalContext.current
val workedCar by remember { WorkedPrompt.pending(context) }.collectAsStateWithLifecycle()
val live = status.phase == SessionStatus.Phase.CONNECTING ||
status.phase == SessionStatus.Phase.PROJECTING
Scaffold(containerColor = MaterialTheme.colorScheme.background) { padding -> val celebrate by WorkedPrompt.celebrate.collectAsStateWithLifecycle()
Column( Box {
Modifier Scaffold(containerColor = MaterialTheme.colorScheme.background) { padding ->
.fillMaxSize()
.padding(padding)
.padding(horizontal = 24.dp),
) {
Column( Column(
Modifier Modifier
.weight(1f) .fillMaxSize()
.verticalScroll(rememberScrollState()), .padding(padding)
.padding(horizontal = 24.dp),
) { ) {
Spacer(Modifier.height(24.dp)) Column(
Row( Modifier
Modifier.fillMaxWidth(), .weight(1f)
verticalAlignment = Alignment.CenterVertically, .verticalScroll(rememberScrollState()),
horizontalArrangement = Arrangement.SpaceBetween,
) { ) {
Text( Spacer(Modifier.height(24.dp))
"Gearslip", Row(
style = MaterialTheme.typography.displayLarge, Modifier.fillMaxWidth(),
fontWeight = FontWeight.Bold, verticalAlignment = Alignment.CenterVertically,
color = MaterialTheme.colorScheme.onBackground, horizontalArrangement = Arrangement.SpaceBetween,
)
IconButton(
onClick = onOpenSettings,
modifier = Modifier.size(56.dp),
) { ) {
Icon(Icons.Filled.Settings, contentDescription = "Settings", modifier = Modifier.size(40.dp)) Text(
"Gearslip",
style = MaterialTheme.typography.displayLarge,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.onBackground,
)
IconButton(
onClick = onOpenSettings,
modifier = Modifier.size(56.dp),
) {
Icon(Icons.Filled.Settings, contentDescription = "Settings", modifier = Modifier.size(40.dp))
}
} }
} Text(
Text( "v${LocalContext.current.appVersionName()}",
"v${LocalContext.current.appVersionName()}", style = MaterialTheme.typography.labelLarge,
style = MaterialTheme.typography.labelLarge, color = MaterialTheme.colorScheme.onSurfaceVariant,
color = MaterialTheme.colorScheme.onSurfaceVariant, )
) Spacer(Modifier.height(16.dp))
Spacer(Modifier.height(16.dp)) StatusCard(status)
StatusCard(status) // The first drive that worked asks once to share it, right under the result.
Spacer(Modifier.height(16.dp)) val car = workedCar
if (status.phase != SessionStatus.Phase.PROJECTING) WirelessCard() if (car != null && !live) {
} Spacer(Modifier.height(16.dp))
WorkedCard(car, onSeeWhatsSent = onOpenUsageNotes)
// Sits at the bottom, where a thumb reaches it.
Column(Modifier.padding(vertical = 16.dp)) {
val live = status.phase == SessionStatus.Phase.CONNECTING ||
status.phase == SessionStatus.Phase.PROJECTING
if (live) {
Button(onClick = onDisconnect, modifier = Modifier.fillMaxWidth().height(56.dp)) {
Text("Disconnect")
} }
Spacer(Modifier.height(16.dp)) Spacer(Modifier.height(16.dp))
UpdateCard()
if (status.phase != SessionStatus.Phase.PROJECTING) {
Spacer(Modifier.height(16.dp))
WirelessCard()
}
} }
Surface(color = MaterialTheme.colorScheme.surfaceContainerHigh, shape = MaterialTheme.shapes.extraLarge) { // Sits at the bottom, where a thumb reaches it.
Column { Column(Modifier.padding(vertical = 16.dp)) {
if (!live) { if (live) {
ActionRow(Icons.Filled.PlayArrow, "Car preview", onOpenCarPreview) Button(onClick = onDisconnect, modifier = Modifier.fillMaxWidth().height(56.dp)) {
RowDivider() Text("Disconnect")
} }
ActionRow(Icons.Filled.List, "Live logs", onOpenLogs) Spacer(Modifier.height(16.dp))
RowDivider() }
ActionRow(Icons.Filled.Info, "Connection help", onOpenHelp)
if (BuildConfig.DEBUG) { // Not while the card above is asking the same thing.
val context = LocalContext.current if (workedCar == null || live) {
UsageNotesHomeCard(onOpen = onOpenUsageNotes)
Spacer(Modifier.height(16.dp))
}
Surface(color = MaterialTheme.colorScheme.surfaceContainerHigh, shape = MaterialTheme.shapes.extraLarge) {
Column {
if (!live) {
ActionRow(Icons.Filled.PlayArrow, "Car preview", onOpenCarPreview)
RowDivider()
}
ActionRow(Icons.Filled.List, "Live logs", onOpenLogs)
RowDivider() RowDivider()
ActionRow(Icons.Filled.Send, "Send a test message") { TestMessage.post(context) } ActionRow(Icons.Filled.Info, "Connection help", onOpenHelp)
if (BuildConfig.DEBUG) {
RowDivider()
ActionRow(Icons.Filled.Send, "Send a test message") { TestMessage.post(context) }
}
} }
} }
} }
} }
} }
// Sharing just went on from the "it worked" ask: a little thanks.
if (celebrate) ConfettiFall(onDone = WorkedPrompt::celebrated)
} }
} }
@@ -12,6 +12,7 @@ import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.selection.selectable
import androidx.compose.foundation.selection.toggleable import androidx.compose.foundation.selection.toggleable
import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll import androidx.compose.foundation.verticalScroll
@@ -33,12 +34,14 @@ import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.RadioButton
import androidx.compose.material3.Scaffold import androidx.compose.material3.Scaffold
import androidx.compose.material3.Switch import androidx.compose.material3.Switch
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TextButton import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBar import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
@@ -55,8 +58,12 @@ import androidx.compose.ui.unit.dp
import app.seb3thehacker.gearslip.AppSettings import app.seb3thehacker.gearslip.AppSettings
import app.seb3thehacker.gearslip.BuildConfig import app.seb3thehacker.gearslip.BuildConfig
import app.seb3thehacker.gearslip.CertProvider import app.seb3thehacker.gearslip.CertProvider
import app.seb3thehacker.gearslip.car.CarSettings
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.snapshotFlow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@@ -66,7 +73,16 @@ fun SettingsScreen(
onOpenRecommendedApps: () -> Unit, onOpenRecommendedApps: () -> Unit,
onOpenWhatsNew: () -> Unit, onOpenWhatsNew: () -> Unit,
onReplayTutorial: () -> Unit, onReplayTutorial: () -> Unit,
atUsageNotes: Boolean = false,
) { ) {
val scroll = rememberScrollState()
// Opened from Home's usage notes row: usage notes is the last section, so start at the end.
if (atUsageNotes) {
LaunchedEffect(Unit) {
snapshotFlow { scroll.maxValue }.first { it > 0 && it < Int.MAX_VALUE }
scroll.scrollTo(scroll.maxValue)
}
}
Scaffold( Scaffold(
containerColor = MaterialTheme.colorScheme.background, containerColor = MaterialTheme.colorScheme.background,
topBar = { topBar = {
@@ -85,12 +101,13 @@ fun SettingsScreen(
.fillMaxSize() .fillMaxSize()
.padding(padding) .padding(padding)
.padding(horizontal = 24.dp) .padding(horizontal = 24.dp)
.verticalScroll(rememberScrollState()), .verticalScroll(scroll),
verticalArrangement = Arrangement.spacedBy(24.dp), verticalArrangement = Arrangement.spacedBy(24.dp),
) { ) {
Spacer(Modifier.height(0.dp)) Spacer(Modifier.height(0.dp))
AboutSection(onOpenWhatsNew, onReplayTutorial) AboutSection(onOpenWhatsNew, onReplayTutorial)
ConnectionSection() ConnectionSection()
CarAudioSection()
if (SHOW_RECOMMENDED_APPS) AppsSection(onOpenRecommendedApps) if (SHOW_RECOMMENDED_APPS) AppsSection(onOpenRecommendedApps)
CertificateSection() CertificateSection()
SettingsSection("Usage notes", header = USAGE_STATS_INTRO) { UsageStatsRows() } SettingsSection("Usage notes", header = USAGE_STATS_INTRO) { UsageStatsRows() }
@@ -106,7 +123,7 @@ private fun Arrow() = Icon(
tint = MaterialTheme.colorScheme.onSurfaceVariant, tint = MaterialTheme.colorScheme.onSurfaceVariant,
) )
/** What's new and the setup guide, one tap from the top. */ /** What's new, updates and the setup guide, one tap from the top. */
@Composable @Composable
private fun AboutSection(onOpenWhatsNew: () -> Unit, onReplayTutorial: () -> Unit) { private fun AboutSection(onOpenWhatsNew: () -> Unit, onReplayTutorial: () -> Unit) {
SettingsSection { SettingsSection {
@@ -118,6 +135,12 @@ private fun AboutSection(onOpenWhatsNew: () -> Unit, onReplayTutorial: () -> Uni
trailing = { Arrow() }, trailing = { Arrow() },
) )
SettingsDivider() SettingsDivider()
UpdateRow()
SettingsDivider()
UpdateAutoRow()
SettingsDivider()
UpdateBetaRow()
SettingsDivider()
SettingsRow( SettingsRow(
"Replay the setup guide", "Replay the setup guide",
Modifier.clickable(onClick = onReplayTutorial), Modifier.clickable(onClick = onReplayTutorial),
@@ -147,6 +170,30 @@ private fun ConnectionSection() {
} }
} }
/** How music reaches the car: through Gearslip over USB, or by Bluetooth as usual. */
@Composable
private fun CarAudioSection() {
val usb by CarSettings.pipeAudio.collectAsState()
SettingsSection(
"Car audio",
footer = "Run the command in Help once, and USB stops asking.",
) {
SettingsRow(
"USB",
Modifier.selectable(usb, role = Role.RadioButton) { CarSettings.setPipeAudio(true) },
subtitle = "Sound goes through Gearslip. Android asks to share your screen each drive",
trailing = { RadioButton(selected = usb, onClick = null) },
)
SettingsDivider()
SettingsRow(
"Bluetooth",
Modifier.selectable(!usb, role = Role.RadioButton) { CarSettings.setPipeAudio(false) },
subtitle = "Sound plays over the phone's Bluetooth link to the car. Android never asks",
trailing = { RadioButton(selected = !usb, onClick = null) },
)
}
}
@Composable @Composable
private fun AppsSection(onOpenRecommendedApps: () -> Unit) { private fun AppsSection(onOpenRecommendedApps: () -> Unit) {
SettingsSection("Apps") { SettingsSection("Apps") {
@@ -165,7 +212,8 @@ private fun CertificateSection() {
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
var refresh by remember { mutableIntStateOf(0) } var refresh by remember { mutableIntStateOf(0) }
val summary by produceState<CertSummary?>(null, refresh) { // The cached summary shows at once; the read still runs, in case the cert changed since.
val summary by produceState(CertSummary.cached, refresh) {
value = withContext(Dispatchers.Default) { CertSummary.read(context) } value = withContext(Dispatchers.Default) { CertSummary.read(context) }
} }
@@ -208,7 +256,7 @@ private fun CertificateSection() {
} }
SettingsDivider() SettingsDivider()
SettingsRow( SettingsRow(
if (downloading) "Downloading…" else "Download certificate", if (downloading) "Downloading…" else "Download from aasdk",
Modifier.clickable(enabled = !downloading) { Modifier.clickable(enabled = !downloading) {
downloading = true downloading = true
scope.launch { scope.launch {
@@ -222,13 +270,14 @@ private fun CertificateSection() {
} }
} }
}, },
subtitle = "The certificate the open-source aasdk project publishes on GitHub. Gearslip doesn't include one",
icon = Icons.Filled.Refresh, icon = Icons.Filled.Refresh,
) )
SettingsDivider() SettingsDivider()
SettingsRow( SettingsRow(
"Import a certificate", "Import my own file",
Modifier.clickable { picker.launch(arrayOf("*/*")) }, Modifier.clickable { picker.launch(arrayOf("*/*")) },
subtitle = "A .p12 file with its private key", subtitle = "A .p12 file with its private key, from wherever you choose",
icon = Icons.Filled.Add, icon = Icons.Filled.Add,
) )
val removable = when (s?.kind) { val removable = when (s?.kind) {
@@ -206,22 +206,6 @@ private fun pages(context: Context, onRequestCallScreening: () -> Unit): List<Gu
) )
} }
// --- GrapheneOS ------------------------------------------------------------------
add(
GuidePage(
title = "GrapheneOS exploit protection",
explanation = "On GrapheneOS only: open Gearslip's app settings and turn on Exploit " +
"protection compatibility mode. Gearslip's native USB and video code trips " +
"GrapheneOS's hardened memory checks otherwise. Skip this on any other Android.",
actionLabel = "Open app settings",
onAction = {
context.startActivity(
Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS, Uri.fromParts("package", context.packageName, null)),
)
},
),
)
// --- Android Auto ------------------------------------------------------------------ // --- Android Auto ------------------------------------------------------------------
val androidAutoPackage = AndroidAuto.installedPackage(context) val androidAutoPackage = AndroidAuto.installedPackage(context)
?.takeIf { AndroidAuto.isActive(context, it) } ?.takeIf { AndroidAuto.isActive(context, it) }
@@ -306,8 +290,7 @@ private fun pages(context: Context, onRequestCallScreening: () -> Unit): List<Gu
} }
/** /**
* The first-run guide, one page at a time: permissions, then GrapheneOS's exploit-protection * The first-run guide, one page at a time: permissions, then removing Android Auto if it is still installed, then the optional extras. Nothing
* toggle, then removing Android Auto if it is still installed, then the optional extras. Nothing
* here can be verified as done except a permission grant, so every other page is a "Next" a * here can be verified as done except a permission grant, so every other page is a "Next" a
* driver takes on trust, with "Skip" sitting right next to it. * driver takes on trust, with "Skip" sitting right next to it.
*/ */
@@ -0,0 +1,168 @@
package app.seb3thehacker.gearslip.ui
import android.content.Intent
import android.net.Uri
import androidx.compose.foundation.clickable
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.foundation.selection.toggleable
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.CheckCircle
import androidx.compose.material.icons.filled.Notifications
import androidx.compose.material.icons.filled.Build
import androidx.compose.material3.Button
import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Switch
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import app.seb3thehacker.gearslip.BuildConfig
import app.seb3thehacker.gearslip.SessionStatus
import app.seb3thehacker.gearslip.update.UpdateChecker
import app.seb3thehacker.gearslip.update.UpdateChecker.State
private const val UNPLUG_FIRST = "Unplug from the car to install. Installing closes Gearslip."
/** Shown on Home once a newer version is found; hidden otherwise. */
@Composable
fun UpdateCard() {
val context = LocalContext.current
val state by UpdateChecker.state.collectAsStateWithLifecycle()
SessionStatus.state.collectAsStateWithLifecycle().value // redraw when the car comes or goes
val release = when (val s = state) {
is State.Available -> s.release
is State.Downloading -> s.release
is State.Ready -> s.release
is State.Failed -> s.release
else -> null
} ?: return
Surface(
color = MaterialTheme.colorScheme.tertiaryContainer,
contentColor = MaterialTheme.colorScheme.onTertiaryContainer,
shape = MaterialTheme.shapes.extraLarge,
) {
Column(Modifier.fillMaxWidth().padding(20.dp)) {
Text("Gearslip ${release.version} is out", style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.SemiBold)
Spacer(Modifier.height(4.dp))
when (val s = state) {
is State.Downloading -> {
Text("Downloading… ${(s.fraction * 100).toInt()}%", style = MaterialTheme.typography.bodyMedium)
Spacer(Modifier.height(12.dp))
LinearProgressIndicator(progress = { s.fraction }, modifier = Modifier.fillMaxWidth())
}
is State.Ready -> {
val connected = UpdateChecker.carConnected
Text(
if (connected) UNPLUG_FIRST else "Downloaded. Android asks before it installs.",
style = MaterialTheme.typography.bodyMedium,
)
Spacer(Modifier.height(12.dp))
Button(onClick = { UpdateChecker.install(context) }, enabled = !connected) { Text("Install") }
}
else -> {
Text(
(s as? State.Failed)?.message ?: "Download it now. Android asks before it installs.",
style = MaterialTheme.typography.bodyMedium,
)
Spacer(Modifier.height(12.dp))
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
Button(onClick = { UpdateChecker.download(context, release) }) {
Text(if (s is State.Failed) "Try again" else "Update")
}
if (release.pageUrl.isNotBlank()) {
TextButton(onClick = {
context.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(release.pageUrl)))
}) { Text("What's new") }
}
}
}
}
}
}
// Room before whatever Home shows next; nothing when the card is hidden.
Spacer(Modifier.height(16.dp))
}
/** The Settings row: checks on tap, and carries on to download and install once something is found. */
@Composable
internal fun UpdateRow() {
val context = LocalContext.current
val state by UpdateChecker.state.collectAsStateWithLifecycle()
SessionStatus.state.collectAsStateWithLifecycle().value
val subtitle = when (val s = state) {
State.Idle -> if (BuildConfig.DEBUG) "Dev builds check only when you tap" else "Gearslip checks once a day"
State.Checking -> "Checking…"
State.UpToDate -> "You have the latest version"
is State.Available -> "Version ${s.release.version} is out. Tap to download."
is State.Downloading -> "Downloading… ${(s.fraction * 100).toInt()}%"
is State.Ready -> if (UpdateChecker.carConnected) UNPLUG_FIRST else "Tap to install version ${s.release.version}"
is State.Failed -> s.message
}
SettingsRow(
"Check for updates",
Modifier.clickable {
when (val s = state) {
is State.Available -> UpdateChecker.download(context, s.release)
is State.Ready -> UpdateChecker.install(context)
is State.Failed -> s.release?.let { UpdateChecker.download(context, it) } ?: UpdateChecker.check(context)
else -> UpdateChecker.check(context)
}
},
subtitle = subtitle,
icon = Icons.Filled.CheckCircle,
)
}
/** Whether Gearslip checks for updates on its own. */
@Composable
internal fun UpdateAutoRow() {
val context = LocalContext.current
var on by remember { mutableStateOf(UpdateChecker.autoCheckEnabled(context)) }
SettingsRow(
"Check automatically",
Modifier.toggleable(on, role = Role.Switch) {
on = it
UpdateChecker.setAutoCheckEnabled(context, it)
},
subtitle = if (BuildConfig.DEBUG) "Does nothing in dev builds. They check only when you tap Check for updates"
else if (on) "Once a day, with a note on the car screen and the phone when one is out"
else "Gearslip checks only when you tap Check for updates",
icon = Icons.Filled.Notifications,
trailing = { Switch(checked = on, onCheckedChange = null) },
)
}
/** Whether pre-releases count as updates. */
@Composable
internal fun UpdateBetaRow() {
val context = LocalContext.current
var on by remember { mutableStateOf(UpdateChecker.betaEnabled(context)) }
SettingsRow(
"Beta updates",
Modifier.toggleable(on, role = Role.Switch) {
on = it
UpdateChecker.setBetaEnabled(context, it)
},
subtitle = "Get test versions before they're released. They may have more bugs.",
icon = Icons.Filled.Build,
trailing = { Switch(checked = on, onCheckedChange = null) },
)
}
@@ -1,14 +1,24 @@
package app.seb3thehacker.gearslip.ui package app.seb3thehacker.gearslip.ui
import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.AnimatedVisibility
import android.content.Context
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ColumnScope import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Spacer
import androidx.compose.material3.Button
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.TextButton
import app.seb3thehacker.gearslip.stats.WorkedPrompt
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.selection.toggleable import androidx.compose.foundation.selection.toggleable
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight
import androidx.compose.material.icons.filled.Check
import androidx.compose.material.icons.filled.Info import androidx.compose.material.icons.filled.Info
import androidx.compose.material.icons.filled.KeyboardArrowDown import androidx.compose.material.icons.filled.KeyboardArrowDown
import androidx.compose.material.icons.filled.KeyboardArrowUp import androidx.compose.material.icons.filled.KeyboardArrowUp
@@ -83,11 +93,7 @@ internal fun ColumnScope.UsageStatsRows() {
SettingsRow( SettingsRow(
"Send usage notes", "Send usage notes",
Modifier.toggleable(on, role = Role.Switch) { on = it; save() }, Modifier.toggleable(on, role = Role.Switch) { on = it; save() },
subtitle = when { subtitle = buildNote(),
BuildConfig.DEBUG -> "Dev build: nothing is ever sent"
BuildConfig.STATS_URL.isBlank() -> "This build has no stats server, so it sends nothing"
else -> null
},
icon = Icons.Filled.Send, icon = Icons.Filled.Send,
trailing = { Switch(checked = on, onCheckedChange = null) }, trailing = { Switch(checked = on, onCheckedChange = null) },
) )
@@ -100,12 +106,16 @@ internal fun ColumnScope.UsageStatsRows() {
color = MaterialTheme.colorScheme.primary, color = MaterialTheme.colorScheme.primary,
modifier = Modifier.padding(start = 56.dp, end = 16.dp, top = 12.dp, bottom = 4.dp), modifier = Modifier.padding(start = 56.dp, end = 16.dp, top = 12.dp, bottom = 4.dp),
) )
Choice("Android version, and whether it's GrapheneOS", choices.android) { choices = choices.copy(android = it); save() } Choice("Android version and system, such as GrapheneOS or stock Samsung", choices.android) { choices = choices.copy(android = it); save() }
Choice("Phone model", choices.phone) { choices = choices.copy(phone = it); save() } Choice("Phone model", choices.phone) { choices = choices.copy(phone = it); save() }
Choice("Each car's head unit, model, year, and screen size, and whether it connected", choices.cars) { Choice("Each car's head unit, model, year, and screen size, and whether it connected", choices.cars) {
choices = choices.copy(cars = it) choices = choices.copy(cars = it)
save() save()
} }
Choice("How each drive went: how far the connection got, how long it lasted, and how it ended, crashes included", choices.failures) {
choices = choices.copy(failures = it)
save()
}
SettingsDivider() SettingsDivider()
SettingsRow( SettingsRow(
"What gets sent", "What gets sent",
@@ -122,7 +132,7 @@ internal fun ColumnScope.UsageStatsRows() {
) )
AnimatedVisibility(noteOpen) { AnimatedVisibility(noteOpen) {
// Rebuilt from the saved choices each time one changes, so it always matches. // Rebuilt from the saved choices each time one changes, so it always matches.
val note = remember(choices) { UsageStats.preview(context, everything = true).toString(2) } val note = remember(choices) { notePreview(context) }
Surface( Surface(
color = MaterialTheme.colorScheme.surfaceContainerLowest, color = MaterialTheme.colorScheme.surfaceContainerLowest,
shape = MaterialTheme.shapes.medium, shape = MaterialTheme.shapes.medium,
@@ -152,3 +162,109 @@ private fun Choice(label: String, checked: Boolean, onChange: (Boolean) -> Unit)
Checkbox(checked = checked, onCheckedChange = null, modifier = Modifier.padding(vertical = 8.dp)) Checkbox(checked = checked, onCheckedChange = null, modifier = Modifier.padding(vertical = 8.dp))
} }
} }
/** The note as it would go out now, with an example drive until a real one is in it. */
private fun notePreview(context: Context): String {
val preview = UsageStats.preview(context, everything = true)
// Until the first drive the list is empty, so show what one entry looks like.
val example = if (preview.optJSONArray("sessions")?.length() == 0) {
"\n\nExample, not sent: one drive adds this to \"sessions\"\n" + UsageStats.exampleSession.toString(2)
} else ""
return preview.toString(2) + example
}
/** Why this build sends nothing, when it doesn't; null for a normal release. */
private fun buildNote(): String? = when {
BuildConfig.DEBUG -> "Dev build: nothing is ever sent"
BuildConfig.STATS_URL.isBlank() -> "This build has no stats server, so it sends nothing"
else -> null
}
/**
* The one-time ask after the first drive that worked, in the status card's place on Home. Share
* turns on usage notes like the switch does; the link shows exactly what that sends first.
*/
@Composable
internal fun WorkedCard(car: String, onSeeWhatsSent: () -> Unit, modifier: Modifier = Modifier) {
val context = LocalContext.current
val scheme = MaterialTheme.colorScheme
Surface(
modifier,
color = scheme.primaryContainer,
contentColor = scheme.onPrimaryContainer,
shape = MaterialTheme.shapes.extraLarge,
) {
Column(Modifier.fillMaxWidth().padding(20.dp)) {
Row(verticalAlignment = Alignment.CenterVertically) {
Icon(Icons.Filled.Check, contentDescription = null)
Text(
WorkedPrompt.TITLE_START + car,
style = MaterialTheme.typography.titleLarge,
fontWeight = FontWeight.SemiBold,
modifier = Modifier.padding(start = 12.dp),
)
}
Text(
"Share that so the next driver knows it works? No name, account, or location.",
style = MaterialTheme.typography.bodyMedium,
modifier = Modifier.padding(top = 8.dp),
)
buildNote()?.let {
Text(it, style = MaterialTheme.typography.bodySmall, modifier = Modifier.padding(top = 4.dp))
}
Row(
Modifier.fillMaxWidth().padding(top = 12.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
TextButton(onClick = onSeeWhatsSent, contentPadding = PaddingValues(horizontal = 0.dp)) {
Text("What gets sent")
}
Spacer(Modifier.weight(1f))
OutlinedButton(onClick = { WorkedPrompt.answer(context, share = false) }) { Text("Not now") }
Button(onClick = { WorkedPrompt.answer(context, share = true) }) { Text("Share") }
}
}
}
}
/**
* Usage notes on Home, one row with one action either way. While they're off, the row is the
* switch, so opting in is a single tap. Once they're on, it shrinks to a quiet line that opens
* Settings at its usage notes section, so a driver already sharing isn't pitched again.
*/
@Composable
internal fun UsageNotesHomeCard(onOpen: () -> Unit, modifier: Modifier = Modifier) {
val context = LocalContext.current
var on by remember { mutableStateOf(UsageStats.enabled(context)) }
SettingsCard(modifier) {
if (on) {
SettingsRow(
"Sharing usage notes",
Modifier.clickable(onClick = onOpen),
subtitle = buildNote(),
icon = Icons.Filled.Check,
trailing = {
Icon(
Icons.AutoMirrored.Filled.KeyboardArrowRight,
contentDescription = null,
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
},
)
} else {
SettingsRow(
USAGE_STATS_TITLE,
Modifier.toggleable(false, role = Role.Switch) {
UsageStats.set(context, true, UsageStats.choices(context))
on = true
},
subtitle = buildNote() ?: ("A short note after each drive tells the next driver " +
"whether their car works. No name, account, or location"),
icon = Icons.Filled.Send,
trailing = { Switch(checked = false, onCheckedChange = null) },
)
}
}
}
@@ -0,0 +1,410 @@
package app.seb3thehacker.gearslip.update
import android.Manifest
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.content.Context
import android.content.Intent
import android.content.pm.PackageManager
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.SessionStatus
import app.seb3thehacker.gearslip.notify.CarMotion
import app.seb3thehacker.gearslip.notify.CarNotification
import app.seb3thehacker.gearslip.notify.CarNotifications
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import org.json.JSONObject
import java.io.File
import java.net.HttpURLConnection
import java.net.URL
import java.security.MessageDigest
/**
* Looks for a newer Gearslip on GitHub and installs it through Android's own installer.
*
* Only GitHub's "latest" release counts, which leaves out pre-releases and drafts, unless the
* driver opted into betas: then pre-releases count too. The app checks at most once a day: when
* it opens, and when a drive ends. With automatic checks off it never asks GitHub on its own,
* only when the driver taps Check for updates. After a drive a new version also gets
* a notification, once per version, since the driver is parked and the app is likely closed. The
* car screen shows the same news once per version, at plug-in and only while parked.
* Nothing runs in the background otherwise, and nothing else is sent: the request is the same one
* a browser makes for the release page. Dev builds never check on their own.
*
* 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.
*/
object UpdateChecker {
data class Release(val version: String, val apkUrl: String, val size: Long, val sha256: String?, val pageUrl: String)
sealed interface State {
data object Idle : State
data object Checking : State
data object UpToDate : State
data class Available(val release: Release) : State
data class Downloading(val release: Release, val fraction: Float) : State
data class Ready(val release: Release, val apk: File) : State
data class Failed(val message: String, val release: Release? = null) : State
}
private val flow = MutableStateFlow<State>(State.Idle)
val state: StateFlow<State> = flow.asStateFlow()
private const val PREFS = "updates"
private const val KEY_CHECKED_AT = "checked_at"
private const val KEY_FOUND = "found"
/** Named for the notifications toggle it grew out of; it now covers every automatic check. */
private const val KEY_AUTO = "notify"
private const val KEY_BETA = "beta"
private const val KEY_NOTIFIED = "notified_version"
private const val KEY_CAR_NOTIFIED = "car_notified_version"
/** 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"
private const val NOTIFICATION_ID = 4100
private const val DAY_MS = 24 * 60 * 60 * 1000L
private const val LATEST = "https://api.github.com/repos/Seb3thehacker/gearslip/releases/latest"
/** Newest first, pre-releases included. Ten covers any run of betas between two releases. */
private const val ALL = "https://api.github.com/repos/Seb3thehacker/gearslip/releases?per_page=10"
/** Set when the driver tapped Install but first had to allow installs from Gearslip. */
@Volatile private var installWhenAllowed = false
private fun prefs(context: Context) = context.applicationContext.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
/**
* Whether Gearslip checks on its own, and tells the driver what it finds on the phone and the
* car screen. On by default: the check sends nothing about the phone.
*/
fun autoCheckEnabled(context: Context): Boolean = prefs(context).getBoolean(KEY_AUTO, true)
fun setAutoCheckEnabled(context: Context, on: Boolean) {
prefs(context).edit().putBoolean(KEY_AUTO, on).apply()
if (!on) NotificationManagerCompat.from(context).cancel(NOTIFICATION_ID)
}
/** Whether GitHub pre-releases count as updates. */
fun betaEnabled(context: Context): Boolean = prefs(context).getBoolean(KEY_BETA, false)
/**
* Switching channels forgets what the old one found: a beta found earlier shouldn't still be
* offered once betas are off. Checks again right away if automatic checks are on.
*/
fun setBetaEnabled(context: Context, on: Boolean) {
prefs(context).edit().putBoolean(KEY_BETA, on).remove(KEY_FOUND).remove(KEY_CHECKED_AT).apply()
val current = flow.value
if (current is State.Checking || current is State.Downloading) return
updatesDir(context).deleteRecursively()
flow.value = State.Idle
if (autoCheckEnabled(context) && !BuildConfig.DEBUG) check(context)
}
private fun dueForCheck(context: Context) =
!BuildConfig.DEBUG && autoCheckEnabled(context) && System.currentTimeMillis() - prefs(context).getLong(KEY_CHECKED_AT, 0) >= DAY_MS
/** Gearslip came to the front: check if a day has passed, and finish an install that waited on permission. */
fun onAppOpened(context: Context) {
val current = flow.value
if (installWhenAllowed && current is State.Ready && context.packageManager.canRequestPackageInstalls()) {
installWhenAllowed = false
install(context)
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)
}
/**
* A car session ended, so the driver is parked. Checks if a day has passed, then posts a
* notification for a new version they haven't been told about yet.
*/
fun onDriveEnded(context: Context) {
if (!autoCheckEnabled(context)) return
val app = context.applicationContext
Thread({
val current = flow.value
if (dueForCheck(app) && current !is State.Checking && current !is State.Downloading) {
flow.value = State.Checking
flow.value = checkNow(app)
}
val release = foundRelease(app) ?: 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()
}
/**
* The car screen just came up. A new version gets a card in the top right, like a message,
* once per version. Skipped while moving; the next plug-in tries again.
*/
fun onCarConnected(context: Context) {
if (!autoCheckEnabled(context)) return
val app = context.applicationContext
Thread({
val current = flow.value
if (dueForCheck(app) && current !is State.Checking && current !is State.Downloading) {
flow.value = State.Checking
flow.value = checkNow(app)
}
val release = foundRelease(app) ?: return@Thread
if (prefs(app).getString(KEY_CAR_NOTIFIED, null) == release.version) return@Thread
Thread.sleep(CAR_DELAY_MS)
if (!carConnected || CarMotion.moving) return@Thread
CarNotifications.post(
CarNotification(
key = "gearslip-update",
id = CarNotifications.nextId(),
packageName = app.packageName,
appLabel = "Gearslip",
title = "Gearslip ${release.version} is out",
text = "Update in the Gearslip app or from GitHub",
postedAt = System.currentTimeMillis(),
icon = runCatching { app.packageManager.getApplicationIcon(app.packageName).toBitmap(96, 96) }.getOrNull(),
reply = null,
),
quiet = false,
)
prefs(app).edit().putString(KEY_CAR_NOTIFIED, release.version).apply()
GearslipLog.i("update: showed ${release.version} on the car screen")
}, "gearslip-update").start()
}
private fun foundRelease(app: Context): Release? = when (val s = flow.value) {
is State.Available -> s.release
is State.Downloading -> s.release
is State.Ready -> s.release
else -> savedRelease(app)
}
/** Posts "Gearslip X is out"; tapping it opens the app, where the Home card has the button. */
private fun notify(context: Context, release: Release): Boolean {
if (context.checkSelfPermission(Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED) return false
context.getSystemService(NotificationManager::class.java).createNotificationChannel(
NotificationChannel(CHANNEL, "Updates", NotificationManager.IMPORTANCE_DEFAULT),
)
val open = PendingIntent.getActivity(
context, NOTIFICATION_ID,
Intent(context, GearslipActivity::class.java).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP),
PendingIntent.FLAG_IMMUTABLE,
)
val notification = NotificationCompat.Builder(context, CHANNEL)
.setSmallIcon(android.R.drawable.stat_sys_download_done)
.setContentTitle("Gearslip ${release.version} is out")
.setContentText("Tap to update.")
.setContentIntent(open)
.setAutoCancel(true)
.build()
return runCatching { NotificationManagerCompat.from(context).notify(NOTIFICATION_ID, notification) }
.onSuccess { GearslipLog.i("update: told the driver about ${release.version}") }
.onFailure { GearslipLog.w("update: could not post the notification (${it.message})") }
.isSuccess
}
/** Asks GitHub now. Ignored while a check or download is under way. */
fun check(context: Context) {
val current = flow.value
if (current is State.Checking || current is State.Downloading) return
val app = context.applicationContext
flow.value = State.Checking
Thread({ flow.value = checkNow(app) }, "gearslip-update").start()
}
private fun checkNow(app: Context): State = runCatching { fetchNewest(betaEnabled(app)) }.fold(
onSuccess = { release ->
prefs(app).edit().putLong(KEY_CHECKED_AT, System.currentTimeMillis()).apply()
if (release != null && isNewer(release.version, BuildConfig.VERSION_NAME)) {
GearslipLog.i("update: ${release.version} is out (this is ${BuildConfig.VERSION_NAME})")
save(app, release)
State.Available(release)
} else {
prefs(app).edit().remove(KEY_FOUND).apply()
updatesDir(app).deleteRecursively()
State.UpToDate
}
},
onFailure = {
GearslipLog.w("update: could not check: ${it.message}")
State.Failed("Couldn't reach GitHub. Check the connection and try again.")
},
)
/** Downloads [release], then opens the installer once the file checks out. */
fun download(context: Context, release: Release) {
if (flow.value is State.Downloading) return
val app = context.applicationContext
flow.value = State.Downloading(release, 0f)
Thread({
val dir = updatesDir(app).apply { deleteRecursively(); mkdirs() }
val apk = File(dir, "Gearslip-${release.version}.apk")
flow.value = runCatching {
fetchApk(release, apk)
GearslipLog.i("update: downloaded ${release.version}")
State.Ready(release, apk)
}.getOrElse {
apk.delete()
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)
}, "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.
*/
fun install(context: Context) {
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()) {
installWhenAllowed = true
context.startActivity(
Intent(Settings.ACTION_MANAGE_UNKNOWN_APP_SOURCES, Uri.parse("package:${context.packageName}"))
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK),
)
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),
)
}
/** The newest release with an APK: the "latest" one, or with [beta] the newest of any kind but drafts. */
private fun fetchNewest(beta: Boolean): Release? {
val conn = (URL(if (beta) ALL else LATEST).openConnection() as HttpURLConnection).apply {
connectTimeout = 15_000
readTimeout = 15_000
setRequestProperty("Accept", "application/vnd.github+json")
setRequestProperty("User-Agent", "Gearslip/${BuildConfig.VERSION_NAME}")
}
try {
if (conn.responseCode == 404) return null // no stable release yet
if (conn.responseCode !in 200..299) error("GitHub answered ${conn.responseCode}")
val body = conn.inputStream.bufferedReader().use { it.readText() }
if (!beta) return release(JSONObject(body))
val list = org.json.JSONArray(body)
return (0 until list.length()).map { list.getJSONObject(it) }
.filterNot { it.optBoolean("draft") }
.mapNotNull(::release)
.reduceOrNull { best, next -> if (isNewer(next.version, best.version)) next else best }
} finally {
conn.disconnect()
}
}
/** A release as GitHub lists it, or null when it has no APK attached. */
private fun release(json: JSONObject): Release? {
val assets = json.optJSONArray("assets") ?: return null
val apk = (0 until assets.length()).map { assets.getJSONObject(it) }
.firstOrNull { it.optString("name").endsWith(".apk") && !it.optString("name").contains("debug") }
?: return null
return Release(
version = json.getString("tag_name").removePrefix("v"),
apkUrl = apk.getString("browser_download_url"),
size = apk.optLong("size", -1),
sha256 = apk.optString("digest").takeIf { it.startsWith("sha256:") }?.removePrefix("sha256:"),
pageUrl = json.optString("html_url"),
)
}
private fun fetchApk(release: Release, into: File) {
val conn = (URL(release.apkUrl).openConnection() as HttpURLConnection).apply {
connectTimeout = 15_000
readTimeout = 30_000
setRequestProperty("User-Agent", "Gearslip/${BuildConfig.VERSION_NAME}")
}
try {
if (conn.responseCode !in 200..299) error("GitHub answered ${conn.responseCode}")
val total = release.size.takeIf { it > 0 } ?: conn.contentLengthLong
val digest = MessageDigest.getInstance("SHA-256")
var done = 0L
var shown = -1
conn.inputStream.use { input ->
into.outputStream().use { output ->
val buffer = ByteArray(64 * 1024)
while (true) {
val n = input.read(buffer)
if (n < 0) break
output.write(buffer, 0, n)
digest.update(buffer, 0, n)
done += n
// Whole percents only, so the screen isn't redrawn for every buffer.
val percent = if (total > 0) (done * 100 / total).toInt() else 0
if (percent != shown) {
shown = percent
flow.value = State.Downloading(release, percent / 100f)
}
}
}
}
if (release.size > 0 && done != release.size) error("The download stopped early. Try again.")
val hex = digest.digest().joinToString("") { "%02x".format(it) }
if (release.sha256 != null && !hex.equals(release.sha256, ignoreCase = true)) {
error("The download came out damaged. Try again.")
}
} finally {
conn.disconnect()
}
}
/** Installing waits until the car is unplugged. */
val carConnected: Boolean
get() = SessionStatus.state.value.phase.let { it == SessionStatus.Phase.CONNECTING || it == SessionStatus.Phase.PROJECTING }
private fun updatesDir(context: Context) = File(context.cacheDir, "updates")
private fun save(context: Context, release: Release) {
val json = JSONObject()
.put("version", release.version)
.put("apkUrl", release.apkUrl)
.put("size", release.size)
.put("sha256", release.sha256 ?: "")
.put("pageUrl", release.pageUrl)
prefs(context).edit().putString(KEY_FOUND, json.toString()).apply()
}
/** The release found last time, if it is still newer than this build (it isn't after updating). */
private fun savedRelease(context: Context): Release? {
val json = prefs(context).getString(KEY_FOUND, null)?.let { runCatching { JSONObject(it) }.getOrNull() } ?: return null
val release = Release(
version = json.optString("version"),
apkUrl = json.optString("apkUrl"),
size = json.optLong("size", -1),
sha256 = json.optString("sha256").ifBlank { null },
pageUrl = json.optString("pageUrl"),
)
return release.takeIf { isNewer(it.version, BuildConfig.VERSION_NAME) }
}
/** "0.2.00-alpha" beats "0.1.10-alpha": the numbers before the dash compare one by one. */
internal fun isNewer(candidate: String, installed: String): Boolean {
fun parts(v: String) = v.removePrefix("v").substringBefore('-').split('.').map { it.toIntOrNull() ?: 0 }
val a = parts(candidate)
val b = parts(installed)
for (i in 0 until maxOf(a.size, b.size)) {
val x = a.getOrElse(i) { 0 }
val y = b.getOrElse(i) { 0 }
if (x != y) return x > y
}
return false
}
}
+2 -1
View File
@@ -1,5 +1,6 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<paths> <paths>
<!-- Only the log copies made for sharing; nothing else in the app is exposed. --> <!-- Only log copies made for sharing and downloaded updates; nothing else in the app is exposed. -->
<cache-path name="shared_logs" path="shared/" /> <cache-path name="shared_logs" path="shared/" />
<cache-path name="updates" path="updates/" />
</paths> </paths>
@@ -0,0 +1,136 @@
package app.seb3thehacker.gearslip
import java.io.ByteArrayInputStream
import java.io.ByteArrayOutputStream
import java.io.IOException
import java.io.InputStream
import java.nio.ByteBuffer
import java.util.concurrent.CountDownLatch
import java.util.concurrent.ExecutorService
import java.util.concurrent.TimeUnit
import javax.net.ssl.SSLContext
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
class ConnectionLifecycleTest {
private val version = message(1, byteArrayOf(0, 1, 0, 3))
private fun message(id: Int, body: ByteArray = byteArrayOf()) = Frames.build(
Frames.CHANNEL_CONTROL, false, byteArrayOf(0, id.toByte()) + body,
)
private fun hello(): ByteArray {
val client = SSLContext.getDefault().createSSLEngine().apply {
useClientMode = true
enabledProtocols = arrayOf("TLSv1.2")
beginHandshake()
}
val packet = ByteBuffer.allocate(client.session.packetBufferSize)
client.wrap(ByteBuffer.allocate(0), packet)
return message(3, packet.array().copyOf(packet.position()))
}
private fun identity(): CertProvider.Identity {
val generated = SelfSignedCert.generate()
return CertProvider.Identity(
generated.keyStore, generated.certificate, SelfSignedCert.PASSWORD, "generated test identity",
)
}
private fun runner(input: InputStream) = GearslipRunner(input, ByteArrayOutputStream(), ::identity)
@Test fun `rejected authentication ignores subsequent success and service discovery in the same read`() {
for (status in listOf(-2, -3)) {
runner(ByteArrayInputStream(
version + hello() + message(4, Protobuf.varintField(1, status.toLong())) +
message(4, Protobuf.varintField(1, 0)) + message(6) + version,
)).run()
assertEquals(
if (status == -2) SessionReport.Category.CERTIFICATE else SessionReport.Category.AUTH,
SessionReport.category,
)
assertEquals(SessionStatus.Phase.FAILED, SessionStatus.state.value.phase)
assertEquals("Head unit rejected the certificate", SessionStatus.state.value.headline)
assertTrue(SessionReport.stage < SessionReport.Stage.ACCEPTED)
}
}
@Test fun `TLS failure ignores later protocol messages`() {
val fatalAlert = message(3, byteArrayOf(21, 3, 3, 0, 2, 2, 42))
runner(ByteArrayInputStream(version + hello() + fatalAlert + version + message(6))).run()
assertEquals(SessionReport.Category.TLS, SessionReport.category)
assertEquals("Head unit rejected the certificate", SessionStatus.state.value.headline)
assertTrue(SessionReport.stage < SessionReport.Stage.ACCEPTED)
}
@Test fun `authentication success before TLS completes is ignored`() {
runner(ByteArrayInputStream(version + message(4, Protobuf.varintField(1, 0)))).run()
assertEquals(SessionReport.Category.TLS, SessionReport.category)
assertEquals(SessionReport.Stage.VERSIONS, SessionReport.stage)
}
@Test fun `stop followed by EOF does not report a handshake failure`() {
stopDuringRead { -1 }
}
@Test fun `stop followed by a read exception does not report a USB failure`() {
stopDuringRead { throw IOException("transport closed by owner") }
}
@Test fun `stop ignores bytes returned by an in-flight read`() {
stopDuringRead { bytes -> version.copyInto(bytes); version.size }
}
private fun stopDuringRead(result: (ByteArray) -> Int) {
lateinit var runner: GearslipRunner
val input = object : InputStream() {
override fun read(): Int = error("bulk reads only")
override fun read(bytes: ByteArray, offset: Int, length: Int): Int {
runner.stop()
return result(bytes)
}
}
runner = runner(input)
runner.run()
assertEquals(SessionReport.Category.NONE, SessionReport.category)
assertEquals(SessionStatus.Phase.DISCONNECTED, SessionStatus.state.value.phase)
}
@Test fun `stop while preparing TLS leaves the session disconnected`() {
val releaseIdentity = CountDownLatch(1)
val runner = GearslipRunner(ByteArrayInputStream(version + hello()), ByteArrayOutputStream(), {
check(releaseIdentity.await(5, TimeUnit.SECONDS))
identity()
})
val reader = Thread { runner.run() }
reader.start()
try {
// The reader waits on FutureTask.get while the identity provider is held above.
val deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(5)
while (reader.state != Thread.State.WAITING && reader.isAlive && System.nanoTime() < deadline) {
reader.join(1)
}
assertEquals(Thread.State.WAITING, reader.state)
runner.stop()
} finally {
releaseIdentity.countDown()
reader.join(5_000)
}
assertTrue("reader must finish", !reader.isAlive)
assertEquals(SessionReport.Category.NONE, SessionReport.category)
assertEquals(SessionStatus.Phase.DISCONNECTED, SessionStatus.state.value.phase)
}
@Test fun `EOF and transport failure shut down the writer`() {
val failing = object : InputStream() {
override fun read(): Int = throw IOException("test disconnect")
}
for (input in listOf(ByteArrayInputStream(version), failing)) {
val runner = runner(input)
runner.run()
val field = GearslipRunner::class.java.getDeclaredField("writer").apply { isAccessible = true }
assertTrue((field.get(runner) as ExecutorService).isShutdown)
}
}
}
@@ -0,0 +1,109 @@
package app.seb3thehacker.gearslip.car
import androidx.car.app.CarToast
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotSame
import org.junit.Assert.assertNull
import org.junit.Assert.assertSame
import org.junit.Before
import org.junit.Test
class CarToastsTest {
@Before
@After
fun clearToast() {
while (true) CarToasts.message.value?.let(CarToasts::dismiss) ?: break
}
@Test
fun aNewToastWaitsForTheOneOnScreen() {
val owner = Any()
CarToasts.show(owner, "First", CarToast.LENGTH_SHORT)
val first = requireNotNull(CarToasts.message.value)
CarToasts.show(owner, "Second", CarToast.LENGTH_LONG)
assertSame(first, CarToasts.message.value)
assertEquals(1, CarToasts.waiting.value)
CarToasts.dismiss(first)
assertEquals("Second", CarToasts.message.value?.text)
assertEquals(0, CarToasts.waiting.value)
}
@Test
fun expiredTimerCannotDismissItsReplacement() {
val owner = Any()
CarToasts.show(owner, "First", CarToast.LENGTH_SHORT)
val first = requireNotNull(CarToasts.message.value)
CarToasts.show(owner, "Second", CarToast.LENGTH_LONG)
CarToasts.dismiss(first)
val second = requireNotNull(CarToasts.message.value)
CarToasts.dismiss(first)
assertSame(second, CarToasts.message.value)
CarToasts.dismiss(second)
assertNull(CarToasts.message.value)
}
@Test
fun identicalMessagesStillRestartTheDisplayTimer() {
val owner = Any()
CarToasts.show(owner, "Saved", CarToast.LENGTH_SHORT)
val first = requireNotNull(CarToasts.message.value)
CarToasts.show(owner, "Saved", CarToast.LENGTH_SHORT)
val repeated = requireNotNull(CarToasts.message.value)
assertNotSame(first, repeated)
assertEquals(0, CarToasts.waiting.value)
CarToasts.dismiss(first)
assertSame(repeated, CarToasts.message.value)
}
@Test
fun disconnectOnlyClearsItsOwnToast() {
val nav = Any()
val browse = Any()
CarToasts.show(nav, "Route saved", CarToast.LENGTH_SHORT)
CarToasts.show(browse, "Added to playlist", CarToast.LENGTH_SHORT)
CarToasts.clear(nav)
assertEquals("Added to playlist", CarToasts.message.value?.text)
CarToasts.clear(browse)
assertNull(CarToasts.message.value)
}
@Test
fun aBurstKeepsOnlyTheNewestFewWaiting() {
val owner = Any()
listOf("1", "2", "3", "4", "5", "6").forEach { CarToasts.show(owner, it, CarToast.LENGTH_SHORT) }
assertEquals("1", CarToasts.message.value?.text)
assertEquals(3, CarToasts.waiting.value)
CarToasts.message.value?.let(CarToasts::dismiss)
assertEquals("4", CarToasts.message.value?.text)
}
@Test
fun blankRequestsDoNotReplaceAVisibleMessage() {
val owner = Any()
CarToasts.show(owner, "Saved", CarToast.LENGTH_SHORT)
val visible = CarToasts.message.value
CarToasts.show(owner, " \n\t", CarToast.LENGTH_LONG)
assertSame(visible, CarToasts.message.value)
assertEquals(0, CarToasts.waiting.value)
}
@Test
fun unknownDurationsFallBackToShortRatherThanStayingVisible() {
val owner = Any()
CarToasts.show(owner, "Short", CarToast.LENGTH_SHORT)
assertEquals(4_000L, CarToasts.message.value?.durationMs)
CarToasts.message.value?.let(CarToasts::dismiss)
CarToasts.show(owner, "Long", CarToast.LENGTH_LONG)
assertEquals(7_000L, CarToasts.message.value?.durationMs)
CarToasts.message.value?.let(CarToasts::dismiss)
CarToasts.show(owner, "Unknown", Int.MAX_VALUE)
assertEquals(4_000L, CarToasts.message.value?.durationMs)
}
}