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
No files matched your search
@@ -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
|
||||||
@@ -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: ______________________________
|
||||||
@@ -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.
|
||||||
@@ -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
|
||||||
|
|||||||
|
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 |
|
Before Width: | Height: | Size: 11 KiB After Width: | Height: | Size: 10 KiB |
@@ -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 |
|
Before Width: | Height: | Size: 79 KiB |
@@ -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.
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||