mirror of
https://github.com/twinksoftware/Gearslip.git
synced 2026-11-10 16:02:25 +01:00
Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5f9701ccff | ||
|
|
043dc11251 |
No files matched your search
@@ -0,0 +1,42 @@
|
|||||||
|
name: Release
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
tags:
|
||||||
|
- "v*"
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- uses: actions/setup-java@v4
|
||||||
|
with:
|
||||||
|
distribution: temurin
|
||||||
|
java-version: "21"
|
||||||
|
|
||||||
|
- name: Write signing key and properties
|
||||||
|
run: |
|
||||||
|
echo "${{ secrets.KEYSTORE_BASE64 }}" | base64 -d > gearslip-release.jks
|
||||||
|
cat > keystore.properties <<EOF
|
||||||
|
storeFile=gearslip-release.jks
|
||||||
|
storePassword=${{ secrets.KEYSTORE_STORE_PASSWORD }}
|
||||||
|
keyAlias=${{ secrets.KEYSTORE_KEY_ALIAS }}
|
||||||
|
keyPassword=${{ secrets.KEYSTORE_KEY_PASSWORD }}
|
||||||
|
EOF
|
||||||
|
|
||||||
|
- name: Build release APK
|
||||||
|
run: ./gradlew :gearslip:assembleRelease
|
||||||
|
|
||||||
|
- name: Locate APK
|
||||||
|
id: apk
|
||||||
|
run: |
|
||||||
|
apk=$(find gearslip/build/outputs/apk/release -name "*.apk" | head -n1)
|
||||||
|
echo "path=$apk" >> "$GITHUB_OUTPUT"
|
||||||
|
|
||||||
|
- name: Create release
|
||||||
|
uses: softprops/action-gh-release@v2
|
||||||
|
with:
|
||||||
|
files: ${{ steps.apk.outputs.path }}
|
||||||
|
generate_release_notes: true
|
||||||
+3
-1
@@ -22,4 +22,6 @@ keystore.properties
|
|||||||
/docs/cert-sweep.sh
|
/docs/cert-sweep.sh
|
||||||
archive/
|
archive/
|
||||||
CLAUDE.md
|
CLAUDE.md
|
||||||
*.pdf
|
*.pdf
|
||||||
|
extraction/
|
||||||
|
.claude/
|
||||||
@@ -30,10 +30,10 @@ android {
|
|||||||
|
|
||||||
defaultConfig {
|
defaultConfig {
|
||||||
applicationId = "app.seb3thehacker.gearslip"
|
applicationId = "app.seb3thehacker.gearslip"
|
||||||
minSdk = 35
|
minSdk = 31
|
||||||
targetSdk = 37
|
targetSdk = 37
|
||||||
versionCode = 3
|
versionCode = 5
|
||||||
versionName = "0.0.03-pre"
|
versionName = "0.0.05-alpha"
|
||||||
}
|
}
|
||||||
|
|
||||||
signingConfigs {
|
signingConfigs {
|
||||||
|
|||||||
@@ -39,6 +39,21 @@
|
|||||||
android:name="android.permission.WRITE_SECURE_SETTINGS"
|
android:name="android.permission.WRITE_SECURE_SETTINGS"
|
||||||
tools:ignore="ProtectedPermissions" />
|
tools:ignore="ProtectedPermissions" />
|
||||||
|
|
||||||
|
<!--
|
||||||
|
Phone calls on the car screen. READ_PHONE_STATE and READ_CALL_LOG together are what a
|
||||||
|
call's own state (ringing, in progress) and the incoming number need; READ_CONTACTS
|
||||||
|
turns that number into a name; ANSWER_PHONE_CALLS lets the driver pick up from here.
|
||||||
|
Declining a call, and the caller ID itself, need Gearslip set as the phone's caller ID
|
||||||
|
and spam app too - an on-device consent, not a permission declared here.
|
||||||
|
-->
|
||||||
|
<uses-permission android:name="android.permission.READ_PHONE_STATE" />
|
||||||
|
<uses-permission android:name="android.permission.READ_CALL_LOG" />
|
||||||
|
<uses-permission android:name="android.permission.READ_CONTACTS" />
|
||||||
|
<uses-permission android:name="android.permission.ANSWER_PHONE_CALLS" />
|
||||||
|
|
||||||
|
<!-- Placing a call from the car screen's dial pad or contacts list needs this. -->
|
||||||
|
<uses-permission android:name="android.permission.CALL_PHONE" />
|
||||||
|
|
||||||
<!--
|
<!--
|
||||||
Declared, not just requested. Templated apps decide whether to talk to a host by asking
|
Declared, not just requested. Templated apps decide whether to talk to a host by asking
|
||||||
whether it holds this permission (HostValidator.isValidHost checks it before falling back
|
whether it holds this permission (HostValidator.isValidHost checks it before falling back
|
||||||
@@ -54,6 +69,10 @@
|
|||||||
<uses-feature android:name="android.hardware.usb.accessory" android:required="true" />
|
<uses-feature android:name="android.hardware.usb.accessory" android:required="true" />
|
||||||
|
|
||||||
<queries>
|
<queries>
|
||||||
|
<!-- Named directly: current builds have no launcher-category entry point for the MAIN/
|
||||||
|
LAUNCHER query below to find, so the setup guide's Android Auto check needs this to see
|
||||||
|
whether the package is installed at all. -->
|
||||||
|
<package android:name="com.google.android.projection.gearhead" />
|
||||||
<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" />
|
||||||
@@ -158,5 +177,21 @@
|
|||||||
<action android:name="android.service.notification.NotificationListenerService" />
|
<action android:name="android.service.notification.NotificationListenerService" />
|
||||||
</intent-filter>
|
</intent-filter>
|
||||||
</service>
|
</service>
|
||||||
|
|
||||||
|
<!--
|
||||||
|
Sees incoming calls before they ring, so the car screen can show the caller and
|
||||||
|
decline it. Inert until the driver sets Gearslip as the phone's caller ID and spam
|
||||||
|
app, under Settings > Apps > Default apps - the same consent a call-blocking app asks
|
||||||
|
for, not a privileged one.
|
||||||
|
-->
|
||||||
|
<service
|
||||||
|
android:name=".call.GearslipCallScreening"
|
||||||
|
android:exported="true"
|
||||||
|
android:label="Gearslip calls"
|
||||||
|
android:permission="android.permission.BIND_SCREENING_SERVICE">
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="android.telecom.CallScreeningService" />
|
||||||
|
</intent-filter>
|
||||||
|
</service>
|
||||||
</application>
|
</application>
|
||||||
</manifest>
|
</manifest>
|
||||||
@@ -8,7 +8,9 @@ object AppSettings {
|
|||||||
private const val PREFS = "gearslip_settings"
|
private const val PREFS = "gearslip_settings"
|
||||||
private const val KEY_STARTUP_URL = "startup_url"
|
private const val KEY_STARTUP_URL = "startup_url"
|
||||||
private const val KEY_SEEN_COMPAT_WARNING = "seen_compat_warning"
|
private const val KEY_SEEN_COMPAT_WARNING = "seen_compat_warning"
|
||||||
|
private const val KEY_SKIPPED_CERT_SETUP = "skipped_cert_setup"
|
||||||
private const val KEY_DEBUG_MODE = "debug_mode"
|
private const val KEY_DEBUG_MODE = "debug_mode"
|
||||||
|
private const val KEY_SEEN_PERMISSIONS_SETUP = "seen_permissions_setup"
|
||||||
|
|
||||||
private fun prefs(context: Context) =
|
private fun prefs(context: Context) =
|
||||||
context.applicationContext.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
|
context.applicationContext.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
|
||||||
@@ -36,6 +38,22 @@ object AppSettings {
|
|||||||
prefs(context).edit().putBoolean(KEY_SEEN_COMPAT_WARNING, true).apply()
|
prefs(context).edit().putBoolean(KEY_SEEN_COMPAT_WARNING, true).apply()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Whether the certificate setup screen has been deliberately skipped. */
|
||||||
|
fun hasSkippedCertSetup(context: Context): Boolean =
|
||||||
|
prefs(context).getBoolean(KEY_SKIPPED_CERT_SETUP, false)
|
||||||
|
|
||||||
|
fun setSkippedCertSetup(context: Context) {
|
||||||
|
prefs(context).edit().putBoolean(KEY_SKIPPED_CERT_SETUP, true).apply()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Whether the one-by-one permissions wizard has already run once. */
|
||||||
|
fun hasSeenPermissionsSetup(context: Context): Boolean =
|
||||||
|
prefs(context).getBoolean(KEY_SEEN_PERMISSIONS_SETUP, false)
|
||||||
|
|
||||||
|
fun setSeenPermissionsSetup(context: Context) {
|
||||||
|
prefs(context).edit().putBoolean(KEY_SEEN_PERMISSIONS_SETUP, true).apply()
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Debug mode: full protocol tracing in the log, and the developer screens (live logs, car
|
* Debug mode: full protocol tracing in the log, and the developer screens (live logs, car
|
||||||
* preview) on the home screen. On by default while the project is pre-release.
|
* preview) on the home screen. On by default while the project is pre-release.
|
||||||
|
|||||||
@@ -0,0 +1,52 @@
|
|||||||
|
package app.seb3thehacker.gearslip
|
||||||
|
|
||||||
|
import java.io.ByteArrayOutputStream
|
||||||
|
import java.net.HttpURLConnection
|
||||||
|
import java.net.URL
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetches the publicly available head unit certificate and key from the opencardev/aasdk
|
||||||
|
* repository on GitHub. The app ships no certificates; it downloads them only when the
|
||||||
|
* user chooses to, the same way a browser would.
|
||||||
|
*/
|
||||||
|
object CertDownloader {
|
||||||
|
|
||||||
|
private val log = GearslipLog.tagged("DOWNL")
|
||||||
|
|
||||||
|
private const val BASE = "https://raw.githubusercontent.com/opencardev/aasdk/main/cert"
|
||||||
|
private const val CRT = "$BASE/headunit.crt"
|
||||||
|
private const val KEY = "$BASE/headunit.key"
|
||||||
|
private const val CONNECT_TIMEOUT_MS = 10_000
|
||||||
|
private const val READ_TIMEOUT_MS = 15_000
|
||||||
|
|
||||||
|
class Downloaded(val certificatePem: String, val privateKeyPem: String)
|
||||||
|
|
||||||
|
/** Fetches both files; throws with a readable message on any failure. */
|
||||||
|
fun fetch(): Downloaded {
|
||||||
|
log.i("fetching certificate from $BASE")
|
||||||
|
val crt = fetch(CRT, "certificate")
|
||||||
|
val key = fetch(KEY, "private key")
|
||||||
|
log.i("downloaded certificate (${crt.length} bytes) and key (${key.length} bytes)")
|
||||||
|
return Downloaded(crt, key)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun fetch(url: String, label: String): String {
|
||||||
|
val body = try {
|
||||||
|
val conn = (URL(url).openConnection() as HttpURLConnection).apply {
|
||||||
|
connectTimeout = CONNECT_TIMEOUT_MS
|
||||||
|
readTimeout = READ_TIMEOUT_MS
|
||||||
|
}
|
||||||
|
conn.inputStream.use { input ->
|
||||||
|
val out = ByteArrayOutputStream()
|
||||||
|
val buf = ByteArray(4096)
|
||||||
|
var n: Int
|
||||||
|
while (input.read(buf).also { n = it } != -1) out.write(buf, 0, n)
|
||||||
|
out.toString(Charsets.UTF_8)
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
throw RuntimeException("could not download the $label: ${e.message}", e)
|
||||||
|
}
|
||||||
|
if (body.isBlank()) throw RuntimeException("the $label file is empty")
|
||||||
|
return body
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@ package app.seb3thehacker.gearslip
|
|||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
|
import java.io.ByteArrayOutputStream
|
||||||
import java.io.File
|
import java.io.File
|
||||||
import java.security.KeyStore
|
import java.security.KeyStore
|
||||||
import java.security.cert.X509Certificate
|
import java.security.cert.X509Certificate
|
||||||
@@ -14,7 +15,9 @@ import java.security.cert.X509Certificate
|
|||||||
*
|
*
|
||||||
* 1. **Imported** in the app (Settings > Certificate): a PKCS#12 file plus its password,
|
* 1. **Imported** in the app (Settings > Certificate): a PKCS#12 file plus its password,
|
||||||
* copied into the app's private storage.
|
* copied into the app's private storage.
|
||||||
* 2. **Staged over adb** into the app's external files directory, the way the spike has
|
* 2. **Downloaded** from the public opencardev/aasdk repository. Fetched only when the
|
||||||
|
* user asks; the app never ships or hosts the certificate.
|
||||||
|
* 3. **Staged over adb** into the app's external files directory, the way the spike has
|
||||||
* always done it. Still honoured, so existing setups keep working untouched:
|
* always done it. Still honoured, so existing setups keep working untouched:
|
||||||
*
|
*
|
||||||
* openssl pkcs12 -export -in headunit.crt -inkey headunit.key \
|
* openssl pkcs12 -export -in headunit.crt -inkey headunit.key \
|
||||||
@@ -23,7 +26,7 @@ import java.security.cert.X509Certificate
|
|||||||
*
|
*
|
||||||
* Default password is "aaspike" (kept as-is so an already-staged phone.p12 doesn't need
|
* Default password is "aaspike" (kept as-is so an already-staged phone.p12 doesn't need
|
||||||
* regenerating); a `phone.pass` file next to it overrides it.
|
* regenerating); a `phone.pass` file next to it overrides it.
|
||||||
* 3. A freshly generated self-signed cert, which a real head unit rejects (see
|
* 4. A freshly generated self-signed cert, which a real head unit rejects (see
|
||||||
* SPIKE_FINDINGS.md).
|
* SPIKE_FINDINGS.md).
|
||||||
*/
|
*/
|
||||||
object CertProvider {
|
object CertProvider {
|
||||||
@@ -34,7 +37,7 @@ object CertProvider {
|
|||||||
private const val DEFAULT_PASSWORD = "aaspike"
|
private const val DEFAULT_PASSWORD = "aaspike"
|
||||||
private const val IMPORT_DIR = "identity"
|
private const val IMPORT_DIR = "identity"
|
||||||
|
|
||||||
enum class Kind { IMPORTED, ADB_STAGED, SELF_SIGNED }
|
enum class Kind { IMPORTED, DOWNLOADED, ADB_STAGED, SELF_SIGNED }
|
||||||
|
|
||||||
class Identity(
|
class Identity(
|
||||||
val keyStore: KeyStore,
|
val keyStore: KeyStore,
|
||||||
@@ -70,6 +73,13 @@ object CertProvider {
|
|||||||
}.onFailure { log.e("could not load the imported certificate - trying the next source", it) }
|
}.onFailure { log.e("could not load the imported certificate - trying the next source", it) }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
val downloaded = downloadedFile(context, P12_NAME)
|
||||||
|
if (downloaded.isFile) {
|
||||||
|
runCatching {
|
||||||
|
return loadP12(downloaded, DEFAULT_PASSWORD, "downloaded certificate", Kind.DOWNLOADED)
|
||||||
|
}.onFailure { log.e("could not load the downloaded certificate - trying the next source", it) }
|
||||||
|
}
|
||||||
|
|
||||||
val dir = context.getExternalFilesDir(null)
|
val dir = context.getExternalFilesDir(null)
|
||||||
val staged = dir?.let { File(it, P12_NAME) }
|
val staged = dir?.let { File(it, P12_NAME) }
|
||||||
if (dir != null && staged != null && staged.isFile) {
|
if (dir != null && staged != null && staged.isFile) {
|
||||||
@@ -113,17 +123,84 @@ object CertProvider {
|
|||||||
|
|
||||||
fun hasImported(context: Context): Boolean = importedFile(context, P12_NAME).isFile
|
fun hasImported(context: Context): Boolean = importedFile(context, P12_NAME).isFile
|
||||||
|
|
||||||
/** Drops the imported identity; the next source in line (adb-staged, then self-signed) takes over. */
|
/** Whether a downloaded certificate is stored (may or may not also be imported). */
|
||||||
|
fun hasDownloaded(context: Context): Boolean = downloadedFile(context, P12_NAME).isFile
|
||||||
|
|
||||||
|
/** Drops the imported identity; the next source in line (downloaded, adb-staged, then self-signed) takes over. */
|
||||||
fun removeImported(context: Context) {
|
fun removeImported(context: Context) {
|
||||||
importedFile(context, P12_NAME).delete()
|
importedFile(context, P12_NAME).delete()
|
||||||
importedFile(context, PASS_NAME).delete()
|
importedFile(context, PASS_NAME).delete()
|
||||||
log.i("removed the imported certificate")
|
log.i("removed the imported certificate")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Drops the downloaded identity. */
|
||||||
|
fun removeDownloaded(context: Context) {
|
||||||
|
downloadedFile(context, P12_NAME).delete()
|
||||||
|
downloadedFile(context, PASS_NAME).delete()
|
||||||
|
log.i("removed the downloaded certificate")
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Whether any certificate is available (imported, downloaded, or adb-staged). */
|
||||||
|
fun hasAnyCert(context: Context): Boolean =
|
||||||
|
hasImported(context) || hasDownloaded(context) || hasAdbStaged(context)
|
||||||
|
|
||||||
|
private fun hasAdbStaged(context: Context): Boolean =
|
||||||
|
context.getExternalFilesDir(null)?.let { File(it, P12_NAME).isFile } == true
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validates raw PKCS#12 bytes with [password] and, if good, writes them as the imported
|
||||||
|
* identity. Used when the app assembles a certificate in memory (e.g. from a download)
|
||||||
|
* rather than importing from a file picker.
|
||||||
|
*/
|
||||||
|
fun importFromBytes(context: Context, p12Bytes: ByteArray, password: String): Identity {
|
||||||
|
val staging = File(context.cacheDir, "import-$P12_NAME")
|
||||||
|
try {
|
||||||
|
staging.writeBytes(p12Bytes)
|
||||||
|
val identity = loadP12(staging, password, "imported certificate", Kind.IMPORTED)
|
||||||
|
|
||||||
|
val target = importedFile(context, P12_NAME)
|
||||||
|
target.parentFile?.mkdirs()
|
||||||
|
staging.copyTo(target, overwrite = true)
|
||||||
|
importedFile(context, PASS_NAME).writeText(password)
|
||||||
|
log.i("imported a certificate from bytes: ${identity.certificate.subjectX500Principal}")
|
||||||
|
return identity
|
||||||
|
} finally {
|
||||||
|
staging.delete()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Downloads the publicly available head unit certificate from the opencardev/aasdk
|
||||||
|
* repository, converts it from PEM to PKCS#12, and stores it ready for use.
|
||||||
|
* Runs synchronously (caller is responsible for threading). Throws on any failure.
|
||||||
|
*/
|
||||||
|
fun downloadAndImport(context: Context): Identity {
|
||||||
|
val download = CertDownloader.fetch()
|
||||||
|
val result = PemKeyParser.build(download.certificatePem, download.privateKeyPem)
|
||||||
|
|
||||||
|
val target = downloadedFile(context, P12_NAME)
|
||||||
|
target.parentFile?.mkdirs()
|
||||||
|
val p12Password = DEFAULT_PASSWORD
|
||||||
|
val keyStore = result.keyStore
|
||||||
|
val p12Bytes = ByteArrayOutputStream().use { stream ->
|
||||||
|
keyStore.store(stream, p12Password.toCharArray())
|
||||||
|
stream.toByteArray()
|
||||||
|
}
|
||||||
|
target.writeBytes(p12Bytes)
|
||||||
|
downloadedFile(context, PASS_NAME).writeText(p12Password)
|
||||||
|
|
||||||
|
log.i("downloaded and stored certificate: ${result.certificate.subjectX500Principal}")
|
||||||
|
return Identity(keyStore, result.certificate, p12Password.toCharArray(),
|
||||||
|
"downloaded certificate (opencardev/aasdk)", Kind.DOWNLOADED)
|
||||||
|
}
|
||||||
|
|
||||||
// The imported files live in the app's private internal storage (not the external files
|
// The imported files live in the app's private internal storage (not the external files
|
||||||
// dir), so other apps cannot read the key, and `adb uninstall` clearing it is expected.
|
// dir), so other apps cannot read the key, and `adb uninstall` clearing it is expected.
|
||||||
private fun importedFile(context: Context, name: String) = File(File(context.filesDir, IMPORT_DIR), name)
|
private fun importedFile(context: Context, name: String) = File(File(context.filesDir, IMPORT_DIR), name)
|
||||||
|
|
||||||
|
private fun downloadedFile(context: Context, name: String) =
|
||||||
|
File(File(context.filesDir, "$IMPORT_DIR-downloaded"), name)
|
||||||
|
|
||||||
private fun importedPassword(context: Context): String =
|
private fun importedPassword(context: Context): String =
|
||||||
importedFile(context, PASS_NAME).takeIf { it.isFile }?.readText().orEmpty()
|
importedFile(context, PASS_NAME).takeIf { it.isFile }?.readText().orEmpty()
|
||||||
|
|
||||||
|
|||||||
@@ -94,32 +94,18 @@ class GearslipActivity : ComponentActivity(), Projection {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Hosted map apps need Gearslip to hold location so they can keep it in the background. */
|
/** Setting Gearslip as the phone's caller ID and spam app - what lets it decline a ringing call. */
|
||||||
private val locationPermission = registerForActivityResult(
|
private val callScreeningRole = registerForActivityResult(StartActivityForResult()) { result ->
|
||||||
androidx.activity.result.contract.ActivityResultContracts.RequestPermission(),
|
if (result.resultCode != RESULT_OK) GearslipLog.w("calls: call screening role declined")
|
||||||
) { granted -> if (granted) Prefetch.warm(this, force = true) }
|
}
|
||||||
|
|
||||||
/** The car dashboard's agenda card; declined just leaves it empty rather than asking again. */
|
|
||||||
private val calendarPermission = registerForActivityResult(
|
|
||||||
androidx.activity.result.contract.ActivityResultContracts.RequestPermission(),
|
|
||||||
) { granted -> if (granted) Prefetch.warm(this, force = true) }
|
|
||||||
|
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
super.onCreate(savedInstanceState)
|
super.onCreate(savedInstanceState)
|
||||||
if (checkSelfPermission(android.Manifest.permission.ACCESS_FINE_LOCATION) !=
|
// Location, calendar, the phone/contacts group, and the microphone are asked for one at
|
||||||
android.content.pm.PackageManager.PERMISSION_GRANTED
|
// a time by PermissionsSetupScreen instead of here, each only after the driver taps
|
||||||
) locationPermission.launch(android.Manifest.permission.ACCESS_FINE_LOCATION)
|
// Allow on a screen explaining what it is for. Firing them all from onCreate, unprompted,
|
||||||
if (checkSelfPermission(android.Manifest.permission.READ_CALENDAR) !=
|
// is both a bad first impression and unreliable: Android does not consistently show more
|
||||||
android.content.pm.PackageManager.PERMISSION_GRANTED
|
// than one system permission dialog fired back-to-back, which was silently dropping some.
|
||||||
) calendarPermission.launch(android.Manifest.permission.READ_CALENDAR)
|
|
||||||
// Playback capture needs the microphone even though it never records one. Ask for it here,
|
|
||||||
// at the kerb, rather than the first time a song plays: a permission dialog that appears
|
|
||||||
// mid-drive is one the driver has to read and answer at the wheel, and missing it is
|
|
||||||
// indistinguishable from the car link being broken.
|
|
||||||
if (CarSettings.pipeAudio.value &&
|
|
||||||
checkSelfPermission(android.Manifest.permission.RECORD_AUDIO) !=
|
|
||||||
android.content.pm.PackageManager.PERMISSION_GRANTED
|
|
||||||
) audioPermission.launch(android.Manifest.permission.RECORD_AUDIO)
|
|
||||||
enableEdgeToEdge()
|
enableEdgeToEdge()
|
||||||
window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
|
window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
|
||||||
|
|
||||||
@@ -137,7 +123,7 @@ class GearslipActivity : ComponentActivity(), Projection {
|
|||||||
|
|
||||||
setContent {
|
setContent {
|
||||||
GearslipTheme {
|
GearslipTheme {
|
||||||
GearslipApp(onDisconnect = ::closeAccessory)
|
GearslipApp(onDisconnect = ::closeAccessory, onRequestCallScreening = ::requestCallScreeningRole)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -181,6 +167,19 @@ class GearslipActivity : ComponentActivity(), Projection {
|
|||||||
GearslipLog.flush()
|
GearslipLog.flush()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Launches the system dialog that offers Gearslip the call-screening role, if it isn't held already. */
|
||||||
|
private fun requestCallScreeningRole() {
|
||||||
|
val roleManager = getSystemService(android.app.role.RoleManager::class.java)
|
||||||
|
if (roleManager == null || !roleManager.isRoleAvailable(android.app.role.RoleManager.ROLE_CALL_SCREENING)) {
|
||||||
|
GearslipLog.w("calls: call screening role unavailable on this phone")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (roleManager.isRoleHeld(android.app.role.RoleManager.ROLE_CALL_SCREENING)) return
|
||||||
|
runCatching {
|
||||||
|
callScreeningRole.launch(roleManager.createRequestRoleIntent(android.app.role.RoleManager.ROLE_CALL_SCREENING))
|
||||||
|
}.onFailure { GearslipLog.e("could not ask for the call screening role", it) }
|
||||||
|
}
|
||||||
|
|
||||||
// --- Media audio --------------------------------------------------------------------
|
// --- Media audio --------------------------------------------------------------------
|
||||||
|
|
||||||
private fun requestAudioCapture() {
|
private fun requestAudioCapture() {
|
||||||
|
|||||||
@@ -181,16 +181,22 @@ class GearslipRunner(
|
|||||||
log.i("<- VersionRequest: head unit speaks $major.$minor")
|
log.i("<- VersionRequest: head unit speaks $major.$minor")
|
||||||
SessionReport.protocolVersion(major, minor)
|
SessionReport.protocolVersion(major, minor)
|
||||||
|
|
||||||
// Echo the head unit's own version with STATUS_SUCCESS (0). Echoing rather than
|
// PROTOCOL-DOWNGRADE EXPERIMENT. The 2018 Uconnect speaks 1.3 and ACCEPTS the JVC cert;
|
||||||
// asserting 1.6 is the permissive choice: we only need to reach service discovery,
|
// the 2025 unit speaks 4.3 and rejects it at AuthComplete (-3). If the 4.3 identity check
|
||||||
// not to honour whatever that version implies.
|
// is tied to the *negotiated* protocol version rather than baked into the firmware,
|
||||||
|
// claiming an older version makes the newer unit skip the check entirely. CLAIM_VERSION
|
||||||
|
// = null restores the previous echo-the-head-unit behaviour.
|
||||||
|
val claim = CLAIM_VERSION ?: (major to minor)
|
||||||
val response = byteArrayOf(
|
val response = byteArrayOf(
|
||||||
((major shr 8) and 0xFF).toByte(), (major and 0xFF).toByte(),
|
((claim.first shr 8) and 0xFF).toByte(), (claim.first and 0xFF).toByte(),
|
||||||
((minor shr 8) and 0xFF).toByte(), (minor and 0xFF).toByte(),
|
((claim.second shr 8) and 0xFF).toByte(), (claim.second and 0xFF).toByte(),
|
||||||
0, 0, // STATUS_SUCCESS
|
0, 0, // STATUS_SUCCESS
|
||||||
)
|
)
|
||||||
send(MSG_VERSION_RESPONSE, response, encrypted = false)
|
send(MSG_VERSION_RESPONSE, response, encrypted = false)
|
||||||
log.i("-> VersionResponse: $major.$minor status=0 (STATUS_SUCCESS)")
|
log.i(
|
||||||
|
"-> VersionResponse: ${claim.first}.${claim.second} status=0 (STATUS_SUCCESS)" +
|
||||||
|
if (CLAIM_VERSION != null) " [FORCED; head unit offered $major.$minor]" else "",
|
||||||
|
)
|
||||||
state = State.TLS_HANDSHAKE
|
state = State.TLS_HANDSHAKE
|
||||||
SessionStatus.connecting("Securing the link")
|
SessionStatus.connecting("Securing the link")
|
||||||
log.i("awaiting ClientHello - the head unit is the TLS client, we are the server")
|
log.i("awaiting ClientHello - the head unit is the TLS client, we are the server")
|
||||||
@@ -787,6 +793,13 @@ class GearslipRunner(
|
|||||||
}
|
}
|
||||||
|
|
||||||
private companion object {
|
private companion object {
|
||||||
|
/**
|
||||||
|
* Force the protocol version Gearslip claims in VersionResponse, as (major, minor), or
|
||||||
|
* null to echo the head unit. 1.3 is what the 2018 Uconnect (which accepts the JVC cert)
|
||||||
|
* speaks. This is the 2025 identity-check downgrade experiment: see onVersionRequest.
|
||||||
|
*/
|
||||||
|
val CLAIM_VERSION: Pair<Int, Int>? = 1 to 3
|
||||||
|
|
||||||
// protobuf/aap_protobuf/service/control/ControlMessageType.proto:7-17
|
// protobuf/aap_protobuf/service/control/ControlMessageType.proto:7-17
|
||||||
const val MSG_VERSION_REQUEST = 1
|
const val MSG_VERSION_REQUEST = 1
|
||||||
const val MSG_VERSION_RESPONSE = 2
|
const val MSG_VERSION_RESPONSE = 2
|
||||||
|
|||||||
@@ -0,0 +1,125 @@
|
|||||||
|
package app.seb3thehacker.gearslip
|
||||||
|
|
||||||
|
import java.io.ByteArrayInputStream
|
||||||
|
import java.math.BigInteger
|
||||||
|
import java.security.KeyFactory
|
||||||
|
import java.security.KeyStore
|
||||||
|
import java.security.cert.CertificateFactory
|
||||||
|
import java.security.cert.X509Certificate
|
||||||
|
import java.security.spec.RSAPrivateCrtKeySpec
|
||||||
|
import java.security.spec.RSAPublicKeySpec
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Converts a PEM-encoded X.509 certificate and PEM-encoded RSA PKCS#1 private key into a
|
||||||
|
* PKCS#12 [KeyStore], using only standard JCA APIs — no BouncyCastle.
|
||||||
|
*
|
||||||
|
* The DER parsing of the PKCS#1 key is done by hand (matching the same approach used in
|
||||||
|
* [SelfSignedCert] for writing). Android's Conscrypt [KeyFactory] only accepts PKCS#8,
|
||||||
|
* so we translate PKCS#1 → [RSAPrivateCrtKeySpec] ourselves.
|
||||||
|
*/
|
||||||
|
object PemKeyParser {
|
||||||
|
|
||||||
|
private const val CERT_HEADER = "-----BEGIN CERTIFICATE-----"
|
||||||
|
private const val CERT_FOOTER = "-----END CERTIFICATE-----"
|
||||||
|
private const val KEY_HEADER = "-----BEGIN RSA PRIVATE KEY-----"
|
||||||
|
private const val KEY_FOOTER = "-----END RSA PRIVATE KEY-----"
|
||||||
|
|
||||||
|
class Result(val keyStore: KeyStore, val certificate: X509Certificate)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parses PEM strings for the certificate and private key, verifies they match, and
|
||||||
|
* returns a PKCS#12 [KeyStore] ready for [CertProvider] to consume.
|
||||||
|
*/
|
||||||
|
fun build(certificatePem: String, privateKeyPem: String, alias: String = "phone"): Result {
|
||||||
|
val cert = parseCertificate(certificatePem)
|
||||||
|
val keySpec = parsePrivateKey(privateKeyPem)
|
||||||
|
|
||||||
|
val kf = KeyFactory.getInstance("RSA")
|
||||||
|
val pubSpec = RSAPublicKeySpec(keySpec.spec.modulus, keySpec.spec.publicExponent)
|
||||||
|
require(kf.generatePublic(pubSpec) == cert.publicKey) {
|
||||||
|
"the certificate's public key does not match the private key"
|
||||||
|
}
|
||||||
|
|
||||||
|
val keyStore = KeyStore.getInstance("PKCS12").apply {
|
||||||
|
load(null, null)
|
||||||
|
setKeyEntry(alias, keySpec.getPrivate(), CharArray(0), arrayOf(cert))
|
||||||
|
}
|
||||||
|
return Result(keyStore, cert)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Strips PEM headers/footers and decodes the base64 body. */
|
||||||
|
fun parseCertificate(pem: String): X509Certificate {
|
||||||
|
val der = pemToDer(pem, CERT_HEADER, CERT_FOOTER)
|
||||||
|
return CertificateFactory.getInstance("X.509")
|
||||||
|
.generateCertificate(ByteArrayInputStream(der)) as X509Certificate
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Parses an RSA PKCS#1 private key (ASN.1 DER) into a key spec. */
|
||||||
|
fun parsePrivateKey(pem: String): KeySpec {
|
||||||
|
val der = pemToDer(pem, KEY_HEADER, KEY_FOOTER)
|
||||||
|
val integers = readPkcs1Sequence(der)
|
||||||
|
require(integers.size == 9) { "PKCS#1 RSA key needs 9 integers, got ${integers.size}" }
|
||||||
|
val spec = RSAPrivateCrtKeySpec(integers[1], integers[2], integers[3], integers[4], integers[5], integers[6], integers[7], integers[8])
|
||||||
|
val privateKey = KeyFactory.getInstance("RSA").generatePrivate(spec)
|
||||||
|
return KeySpec(spec, privateKey)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Holds both the spec and the constructed key for matching against a certificate's public key. */
|
||||||
|
class KeySpec(val spec: RSAPrivateCrtKeySpec, private val privateKey: java.security.PrivateKey) {
|
||||||
|
fun getPrivate() = privateKey
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Strips headers and decodes base64. */
|
||||||
|
private fun pemToDer(pem: String, header: String, footer: String): ByteArray {
|
||||||
|
val start = pem.indexOf(header)
|
||||||
|
val end = pem.indexOf(footer)
|
||||||
|
if (start == -1 || end == -1 || end <= start) {
|
||||||
|
throw IllegalArgumentException("not a valid PEM $header block")
|
||||||
|
}
|
||||||
|
val b64 = pem.substring(start + header.length, end)
|
||||||
|
.replace(Regex("\\s+"), "")
|
||||||
|
return java.util.Base64.getDecoder().decode(b64)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parses the inner SEQUENCE of an RSA PKCS#1 DER blob into a list of [BigInteger]s.
|
||||||
|
* Throws if the ASN.1 structure is wrong, so caller gets a clear error rather than a
|
||||||
|
* silent wrong key.
|
||||||
|
*/
|
||||||
|
private fun readPkcs1Sequence(der: ByteArray): List<BigInteger> {
|
||||||
|
var pos = 0
|
||||||
|
fun readTag(): Int {
|
||||||
|
require(pos < der.size) { "unexpected end of DER at tag" }
|
||||||
|
return der[pos++].toInt() and 0xFF
|
||||||
|
}
|
||||||
|
fun readLength(): Int {
|
||||||
|
require(pos < der.size) { "unexpected end of DER at length" }
|
||||||
|
val b0 = der[pos++].toInt() and 0xFF
|
||||||
|
if (b0 < 0x80) return b0
|
||||||
|
val numBytes = b0 and 0x7F
|
||||||
|
require(pos + numBytes <= der.size) { "truncated long-form length" }
|
||||||
|
var result = 0
|
||||||
|
repeat(numBytes) {
|
||||||
|
result = (result shl 8) or (der[pos++].toInt() and 0xFF)
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
val outerTag = readTag()
|
||||||
|
require(outerTag == 0x30) { "expected SEQUENCE (0x30), got 0x${outerTag.toString(16)}" }
|
||||||
|
val outerLen = readLength()
|
||||||
|
require(pos + outerLen <= der.size) { "SEQUENCE length $outerLen exceeds remaining data" }
|
||||||
|
|
||||||
|
val integers = mutableListOf<BigInteger>()
|
||||||
|
while (pos < der.size) {
|
||||||
|
val tag = readTag()
|
||||||
|
require(tag == 0x02) { "expected INTEGER (0x02), got 0x${tag.toString(16)} at offset ${pos - 1}" }
|
||||||
|
val len = readLength()
|
||||||
|
require(pos + len <= der.size) { "INTEGER length $len exceeds remaining data" }
|
||||||
|
integers.add(BigInteger(der.copyOfRange(pos, pos + len)))
|
||||||
|
pos += len
|
||||||
|
}
|
||||||
|
require(integers.size == 9) { "PKCS#1 RSA key needs 9 integers, got ${integers.size}" }
|
||||||
|
return integers
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,135 @@
|
|||||||
|
package app.seb3thehacker.gearslip.call
|
||||||
|
|
||||||
|
import android.Manifest
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.pm.PackageManager
|
||||||
|
import android.net.Uri
|
||||||
|
import android.os.Handler
|
||||||
|
import android.os.Looper
|
||||||
|
import android.provider.ContactsContract
|
||||||
|
import android.telecom.TelecomManager
|
||||||
|
import android.telephony.TelephonyCallback
|
||||||
|
import android.telephony.TelephonyManager
|
||||||
|
import app.seb3thehacker.gearslip.GearslipLog
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
|
|
||||||
|
/** A caller as the car shows it: whatever the contacts app knows, or just the number. */
|
||||||
|
data class Caller(val number: String, val name: String?) {
|
||||||
|
val label: String get() = name ?: number.ifBlank { "Unknown number" }
|
||||||
|
}
|
||||||
|
|
||||||
|
sealed interface CallUiState {
|
||||||
|
data object None : CallUiState
|
||||||
|
data class Ringing(val caller: Caller, val canDecline: Boolean) : CallUiState
|
||||||
|
data class Active(val caller: Caller?) : CallUiState
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tracks the phone's call state for the car screen, and answers or declines through the two
|
||||||
|
* doors a sideloaded app can use: [android.telecom.TelecomManager.acceptRingingCall] to answer,
|
||||||
|
* and [GearslipCallScreening]'s response to decline one that is still ringing.
|
||||||
|
*
|
||||||
|
* Ending a call already answered needs MODIFY_PHONE_STATE, a privileged permission Gearslip
|
||||||
|
* cannot hold, so a call taken here is finished the way it would be without Gearslip at all -
|
||||||
|
* from the phone itself, a paired headset, or the car's own Bluetooth if it has any. Call audio
|
||||||
|
* stays on the phone's normal voice path too; nothing here sends it over the USB link, which
|
||||||
|
* only ever carries media playback.
|
||||||
|
*/
|
||||||
|
object CarCalls {
|
||||||
|
|
||||||
|
private val _state = MutableStateFlow<CallUiState>(CallUiState.None)
|
||||||
|
val state: StateFlow<CallUiState> = _state.asStateFlow()
|
||||||
|
|
||||||
|
private var telephony: TelephonyManager? = null
|
||||||
|
private var watcher: TelephonyCallback? = null
|
||||||
|
private var pendingRespond: ((allow: Boolean) -> Unit)? = null
|
||||||
|
private var ringingCaller: Caller? = null
|
||||||
|
|
||||||
|
fun start(context: Context) {
|
||||||
|
if (watcher != null) return
|
||||||
|
if (context.checkSelfPermission(Manifest.permission.READ_PHONE_STATE) != PackageManager.PERMISSION_GRANTED) return
|
||||||
|
val manager = context.getSystemService(TelephonyManager::class.java) ?: return
|
||||||
|
val callback = object : TelephonyCallback(), TelephonyCallback.CallStateListener {
|
||||||
|
override fun onCallStateChanged(state: Int) = onPhoneState(state)
|
||||||
|
}
|
||||||
|
runCatching { manager.registerTelephonyCallback(context.mainExecutor, callback) }
|
||||||
|
.onSuccess { telephony = manager; watcher = callback }
|
||||||
|
.onFailure { GearslipLog.w("calls: could not watch call state: ${it.message}") }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun stop() {
|
||||||
|
val callback = watcher ?: return
|
||||||
|
runCatching { telephony?.unregisterTelephonyCallback(callback) }
|
||||||
|
telephony = null
|
||||||
|
watcher = null
|
||||||
|
pendingRespond = null
|
||||||
|
ringingCaller = null
|
||||||
|
_state.value = CallUiState.None
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun onPhoneState(state: Int) {
|
||||||
|
when (state) {
|
||||||
|
TelephonyManager.CALL_STATE_IDLE -> {
|
||||||
|
pendingRespond = null
|
||||||
|
ringingCaller = null
|
||||||
|
_state.value = CallUiState.None
|
||||||
|
}
|
||||||
|
TelephonyManager.CALL_STATE_OFFHOOK -> {
|
||||||
|
pendingRespond = null
|
||||||
|
_state.value = CallUiState.Active(ringingCaller)
|
||||||
|
}
|
||||||
|
TelephonyManager.CALL_STATE_RINGING -> {
|
||||||
|
// The number itself only ever arrives through the screening service; if this
|
||||||
|
// fires first, the ringing card appears once incomingCall() catches up.
|
||||||
|
ringingCaller?.let { _state.value = CallUiState.Ringing(it, pendingRespond != null) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Called by [GearslipCallScreening] the moment a call comes in - the only place with a number. */
|
||||||
|
fun incomingCall(context: Context, number: String?, respond: (allow: Boolean) -> Unit) {
|
||||||
|
val caller = Caller(number.orEmpty(), number?.let { lookUpName(context, it) })
|
||||||
|
ringingCaller = caller
|
||||||
|
pendingRespond = respond
|
||||||
|
_state.value = CallUiState.Ringing(caller, canDecline = true)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The screening window closed by itself - the call rings normally now, and can't be declined from here. */
|
||||||
|
fun screeningExpired() {
|
||||||
|
pendingRespond = null
|
||||||
|
val caller = ringingCaller ?: return
|
||||||
|
if (_state.value is CallUiState.Ringing) _state.value = CallUiState.Ringing(caller, canDecline = false)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun decline() {
|
||||||
|
val respond = pendingRespond ?: return
|
||||||
|
pendingRespond = null
|
||||||
|
runCatching { respond(false) }.onFailure { GearslipLog.w("calls: decline failed: ${it.message}") }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun answer(context: Context) {
|
||||||
|
if (context.checkSelfPermission(Manifest.permission.ANSWER_PHONE_CALLS) != PackageManager.PERMISSION_GRANTED) {
|
||||||
|
GearslipLog.w("calls: answer needs ANSWER_PHONE_CALLS, which was never granted")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
val telecom = context.getSystemService(TelecomManager::class.java) ?: return
|
||||||
|
runCatching { telecom.acceptRingingCall() }
|
||||||
|
.onFailure { GearslipLog.w("calls: answer failed: ${it.message}") }
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun lookUpName(context: Context, number: String): String? {
|
||||||
|
if (context.checkSelfPermission(Manifest.permission.READ_CONTACTS) != PackageManager.PERMISSION_GRANTED) return null
|
||||||
|
return runCatching {
|
||||||
|
val uri = Uri.withAppendedPath(ContactsContract.PhoneLookup.CONTENT_FILTER_URI, Uri.encode(number))
|
||||||
|
context.contentResolver.query(uri, arrayOf(ContactsContract.PhoneLookup.DISPLAY_NAME), null, null, null)
|
||||||
|
?.use { c -> if (c.moveToFirst()) c.getString(0) else null }
|
||||||
|
}.getOrNull()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Posts to the main thread after a delay - used to give up on a screening response Gearslip never sent. */
|
||||||
|
internal fun mainThreadDelay(ms: Long, action: () -> Unit) {
|
||||||
|
Handler(Looper.getMainLooper()).postDelayed(action, ms)
|
||||||
|
}
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
package app.seb3thehacker.gearslip.call
|
||||||
|
|
||||||
|
import android.telecom.Call
|
||||||
|
import android.telecom.CallScreeningService
|
||||||
|
import app.seb3thehacker.gearslip.GearslipLog
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sees every incoming call before it rings, so the car screen can show who is calling and
|
||||||
|
* decline it in the same window a spam-blocker would use. Inert until the driver sets Gearslip
|
||||||
|
* as the phone's caller ID and spam app - the same on-device consent Truecaller-style apps ask
|
||||||
|
* for, not a privileged permission.
|
||||||
|
*
|
||||||
|
* The system gives an app a few seconds to answer before it lets the call ring on its own, so a
|
||||||
|
* response is sent either when the driver taps a button or when that window is about to close,
|
||||||
|
* whichever comes first. Answering a call that has already passed this point still works, since
|
||||||
|
* [CarCalls.answer] goes through the ordinary ringing-call API rather than through screening.
|
||||||
|
*/
|
||||||
|
class GearslipCallScreening : CallScreeningService() {
|
||||||
|
|
||||||
|
override fun onScreenCall(callDetails: Call.Details) {
|
||||||
|
val number = callDetails.handle?.schemeSpecificPart
|
||||||
|
var responded = false
|
||||||
|
|
||||||
|
val respond: (Boolean) -> Unit = { allow ->
|
||||||
|
if (!responded) {
|
||||||
|
responded = true
|
||||||
|
runCatching {
|
||||||
|
respondToCall(
|
||||||
|
callDetails,
|
||||||
|
CallResponse.Builder()
|
||||||
|
.setDisallowCall(!allow)
|
||||||
|
.setRejectCall(!allow)
|
||||||
|
.setSkipNotification(false)
|
||||||
|
.build(),
|
||||||
|
)
|
||||||
|
}.onFailure { GearslipLog.w("calls: screening response failed: ${it.message}") }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
CarCalls.incomingCall(applicationContext, number, respond)
|
||||||
|
|
||||||
|
// Answer for the system if nobody has by the time its own patience runs out, so a call
|
||||||
|
// Gearslip is slow to show never gets silently blocked.
|
||||||
|
mainThreadDelay(SCREENING_WINDOW_MS) {
|
||||||
|
respond(true)
|
||||||
|
CarCalls.screeningExpired()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private companion object {
|
||||||
|
const val SCREENING_WINDOW_MS = 3_500L
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,89 +0,0 @@
|
|||||||
package app.seb3thehacker.gearslip.car
|
|
||||||
|
|
||||||
import androidx.compose.foundation.Canvas
|
|
||||||
import androidx.compose.foundation.background
|
|
||||||
import androidx.compose.foundation.gestures.awaitEachGesture
|
|
||||||
import androidx.compose.foundation.gestures.awaitFirstDown
|
|
||||||
import androidx.compose.foundation.layout.Box
|
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
|
||||||
import androidx.compose.material3.Button
|
|
||||||
import androidx.compose.material3.Text
|
|
||||||
import androidx.compose.runtime.Composable
|
|
||||||
import androidx.compose.runtime.getValue
|
|
||||||
import androidx.compose.runtime.mutableStateOf
|
|
||||||
import androidx.compose.runtime.remember
|
|
||||||
import androidx.compose.runtime.setValue
|
|
||||||
import androidx.compose.ui.Alignment
|
|
||||||
import androidx.compose.ui.Modifier
|
|
||||||
import androidx.compose.ui.geometry.Offset
|
|
||||||
import androidx.compose.ui.graphics.Color
|
|
||||||
import androidx.compose.ui.input.pointer.pointerInput
|
|
||||||
import androidx.compose.ui.text.TextStyle
|
|
||||||
import androidx.compose.ui.text.drawText
|
|
||||||
import androidx.compose.ui.text.rememberTextMeasurer
|
|
||||||
import androidx.compose.ui.unit.sp
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Draws the raw video frame with pixel rulers, for measuring what the head unit actually shows.
|
|
||||||
*
|
|
||||||
* Numbers are frame pixels. Read the last number visible on each edge to see how much the head
|
|
||||||
* unit crops there (the frame is 800 wide by 480 tall when the ruler runs to those values), and
|
|
||||||
* tap anywhere: the crosshair is drawn where the phone believes the touch landed, so a mismatch
|
|
||||||
* with your finger is the touch offset. Ignores vehicle insets on purpose.
|
|
||||||
*/
|
|
||||||
@Composable
|
|
||||||
fun CalibrationApp() {
|
|
||||||
val navigator = LocalCarNavigator.current
|
|
||||||
val measurer = rememberTextMeasurer()
|
|
||||||
var touch by remember { mutableStateOf<Offset?>(null) }
|
|
||||||
|
|
||||||
Box(
|
|
||||||
Modifier
|
|
||||||
.fillMaxSize()
|
|
||||||
.background(Color.Black)
|
|
||||||
.pointerInput(Unit) {
|
|
||||||
awaitEachGesture {
|
|
||||||
val down = awaitFirstDown()
|
|
||||||
touch = down.position
|
|
||||||
}
|
|
||||||
},
|
|
||||||
) {
|
|
||||||
Canvas(Modifier.fillMaxSize()) {
|
|
||||||
val label = TextStyle(color = Color.White, fontSize = 11.sp)
|
|
||||||
val faint = Color(0x55FFFFFF)
|
|
||||||
val w = size.width
|
|
||||||
val h = size.height
|
|
||||||
|
|
||||||
var y = 0
|
|
||||||
while (y <= h) {
|
|
||||||
drawLine(if (y % 100 == 0) Color.White else faint, Offset(0f, y.toFloat()), Offset(w, y.toFloat()), strokeWidth = 1f)
|
|
||||||
if (y % 20 == 0) {
|
|
||||||
drawText(measurer, "$y", Offset(2f, y.toFloat()), style = label)
|
|
||||||
drawText(measurer, "$y", Offset(w - 30f, y.toFloat()), style = label)
|
|
||||||
}
|
|
||||||
y += 20
|
|
||||||
}
|
|
||||||
var x = 0
|
|
||||||
while (x <= w) {
|
|
||||||
drawLine(if (x % 100 == 0) Color.White else faint, Offset(x.toFloat(), 0f), Offset(x.toFloat(), h), strokeWidth = 1f)
|
|
||||||
if (x % 50 == 0 && x > 0) {
|
|
||||||
drawText(measurer, "$x", Offset(x.toFloat() + 2f, 2f), style = label)
|
|
||||||
drawText(measurer, "$x", Offset(x.toFloat() + 2f, h - 46f), style = label)
|
|
||||||
}
|
|
||||||
x += 20
|
|
||||||
}
|
|
||||||
// Frame border, drawn last and inset by 1px so it is the outermost thing that can show.
|
|
||||||
drawRect(Color.Red, topLeft = Offset(1f, 1f), size = androidx.compose.ui.geometry.Size(w - 2f, h - 2f), style = androidx.compose.ui.graphics.drawscope.Stroke(2f))
|
|
||||||
drawText(measurer, "frame ${w.toInt()}x${h.toInt()}", Offset(w / 2 - 50f, h / 2 - 60f), style = TextStyle(color = Color.Yellow, fontSize = 14.sp))
|
|
||||||
|
|
||||||
touch?.let { t ->
|
|
||||||
drawLine(Color.Green, Offset(t.x, 0f), Offset(t.x, h), strokeWidth = 2f)
|
|
||||||
drawLine(Color.Green, Offset(0f, t.y), Offset(w, t.y), strokeWidth = 2f)
|
|
||||||
drawText(measurer, "touch ${t.x.toInt()},${t.y.toInt()}", Offset(w / 2 - 50f, h / 2 + 40f), style = TextStyle(color = Color.Green, fontSize = 14.sp))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Button(onClick = { navigator.home() }, modifier = Modifier.align(Alignment.Center)) {
|
|
||||||
Text("Exit calibration")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,132 @@
|
|||||||
|
package app.seb3thehacker.gearslip.car
|
||||||
|
|
||||||
|
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.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.Call
|
||||||
|
import androidx.compose.material.icons.filled.Close
|
||||||
|
import androidx.compose.material3.FilledIconButton
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.IconButtonDefaults
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Surface
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.collectAsState
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.graphics.vector.ImageVector
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import app.seb3thehacker.gearslip.call.CallUiState
|
||||||
|
import app.seb3thehacker.gearslip.call.CarCalls
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sits above whatever the car is showing while a call is ringing or under way. Ringing takes the
|
||||||
|
* whole screen, because nothing else matters more right now; an active call is a thin band along
|
||||||
|
* the top, since from here the driver can only see who it is, not end it - see [CarCalls].
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun CallOverlay(modifier: Modifier = Modifier) {
|
||||||
|
val state by CarCalls.state.collectAsState()
|
||||||
|
val context = LocalContext.current.applicationContext
|
||||||
|
|
||||||
|
when (val s = state) {
|
||||||
|
// Covers the box regardless of where the caller aligned it, so the alignment below is
|
||||||
|
// only ever felt by the active-call banner, which is the one that isn't full-screen.
|
||||||
|
is CallUiState.Ringing -> IncomingCall(s, onAnswer = { CarCalls.answer(context) }, onDecline = CarCalls::decline)
|
||||||
|
is CallUiState.Active -> ActiveCallBanner(s, modifier)
|
||||||
|
CallUiState.None -> {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun IncomingCall(s: CallUiState.Ringing, onAnswer: () -> Unit, onDecline: () -> Unit) {
|
||||||
|
Surface(Modifier.fillMaxSize(), color = MaterialTheme.colorScheme.surfaceContainerLowest) {
|
||||||
|
Column(
|
||||||
|
Modifier.fillMaxSize().padding(32.dp),
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
verticalArrangement = Arrangement.Center,
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
Icons.Filled.Call,
|
||||||
|
contentDescription = null,
|
||||||
|
modifier = Modifier.size(64.dp),
|
||||||
|
tint = MaterialTheme.colorScheme.primary,
|
||||||
|
)
|
||||||
|
Spacer(Modifier.height(16.dp))
|
||||||
|
Text(
|
||||||
|
"Incoming call",
|
||||||
|
style = MaterialTheme.typography.titleMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
Spacer(Modifier.height(8.dp))
|
||||||
|
Text(
|
||||||
|
s.caller.label,
|
||||||
|
style = MaterialTheme.typography.displaySmall,
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
textAlign = TextAlign.Center,
|
||||||
|
)
|
||||||
|
Spacer(Modifier.height(56.dp))
|
||||||
|
Row(horizontalArrangement = Arrangement.spacedBy(48.dp)) {
|
||||||
|
CallButton(Icons.Filled.Close, "Decline", MaterialTheme.colorScheme.error, enabled = s.canDecline, onClick = onDecline)
|
||||||
|
CallButton(Icons.Filled.Call, "Answer", MaterialTheme.colorScheme.primary, enabled = true, onClick = onAnswer)
|
||||||
|
}
|
||||||
|
if (!s.canDecline) {
|
||||||
|
Spacer(Modifier.height(20.dp))
|
||||||
|
Text(
|
||||||
|
"Already ringing out loud - answer here, or let it ring.",
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun CallButton(icon: ImageVector, label: String, color: Color, enabled: Boolean, onClick: () -> Unit) {
|
||||||
|
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||||
|
FilledIconButton(
|
||||||
|
onClick = onClick,
|
||||||
|
enabled = enabled,
|
||||||
|
modifier = Modifier.size(72.dp),
|
||||||
|
colors = IconButtonDefaults.filledIconButtonColors(containerColor = color, contentColor = Color.White),
|
||||||
|
) { Icon(icon, contentDescription = label, modifier = Modifier.size(30.dp)) }
|
||||||
|
Spacer(Modifier.height(8.dp))
|
||||||
|
Text(label, style = MaterialTheme.typography.labelLarge)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun ActiveCallBanner(s: CallUiState.Active, modifier: Modifier = Modifier) {
|
||||||
|
Surface(
|
||||||
|
color = MaterialTheme.colorScheme.primaryContainer,
|
||||||
|
contentColor = MaterialTheme.colorScheme.onPrimaryContainer,
|
||||||
|
modifier = modifier.fillMaxWidth(),
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
Modifier.padding(horizontal = 20.dp, vertical = 10.dp),
|
||||||
|
horizontalArrangement = Arrangement.Center,
|
||||||
|
) {
|
||||||
|
Icon(Icons.Filled.Call, contentDescription = null, modifier = Modifier.size(20.dp))
|
||||||
|
Spacer(Modifier.height(0.dp))
|
||||||
|
Text(
|
||||||
|
" On a call" + (s.caller?.let { " with ${it.label}" } ?: ""),
|
||||||
|
style = MaterialTheme.typography.labelLarge,
|
||||||
|
fontWeight = FontWeight.SemiBold,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
package app.seb3thehacker.gearslip.car
|
package app.seb3thehacker.gearslip.car
|
||||||
|
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.filled.Info
|
import androidx.compose.material.icons.filled.Call
|
||||||
import androidx.compose.material.icons.filled.Search
|
import androidx.compose.material.icons.filled.Search
|
||||||
import androidx.compose.material.icons.filled.Share
|
import androidx.compose.material.icons.filled.Share
|
||||||
import androidx.compose.material.icons.filled.Settings
|
import androidx.compose.material.icons.filled.Settings
|
||||||
@@ -28,8 +28,8 @@ object CarApps {
|
|||||||
CarApp("phone", "Screen sharing", Icons.Filled.Share) {
|
CarApp("phone", "Screen sharing", Icons.Filled.Share) {
|
||||||
PhoneAppsScreen()
|
PhoneAppsScreen()
|
||||||
},
|
},
|
||||||
CarApp("calibrate", "Display test", Icons.Filled.Info) {
|
CarApp("dialer", "Phone", Icons.Filled.Call) {
|
||||||
CalibrationApp()
|
PhoneDialerScreen()
|
||||||
},
|
},
|
||||||
CarApp("settings", "Settings", Icons.Filled.Settings) {
|
CarApp("settings", "Settings", Icons.Filled.Settings) {
|
||||||
CarSettingsScreen()
|
CarSettingsScreen()
|
||||||
|
|||||||
@@ -1,10 +1,8 @@
|
|||||||
package app.seb3thehacker.gearslip.car
|
package app.seb3thehacker.gearslip.car
|
||||||
|
|
||||||
import androidx.compose.foundation.Image
|
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.PaddingValues
|
|
||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
import androidx.compose.foundation.layout.Spacer
|
import androidx.compose.foundation.layout.Spacer
|
||||||
import androidx.compose.foundation.layout.fillMaxHeight
|
import androidx.compose.foundation.layout.fillMaxHeight
|
||||||
@@ -13,11 +11,8 @@ import androidx.compose.foundation.layout.fillMaxWidth
|
|||||||
import androidx.compose.foundation.layout.height
|
import androidx.compose.foundation.layout.height
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.layout.size
|
import androidx.compose.foundation.layout.size
|
||||||
import androidx.compose.foundation.layout.width
|
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.filled.PlayArrow
|
import androidx.compose.material.icons.filled.Close
|
||||||
import androidx.compose.material3.FilledIconButton
|
|
||||||
import androidx.compose.material3.FilledTonalButton
|
|
||||||
import androidx.compose.material3.Icon
|
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
|
||||||
@@ -27,32 +22,26 @@ import androidx.compose.material3.TextButton
|
|||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableStateOf
|
|
||||||
import androidx.compose.runtime.produceState
|
|
||||||
import androidx.compose.runtime.remember
|
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.ui.draw.clip
|
import androidx.compose.ui.draw.clip
|
||||||
import android.media.session.PlaybackState
|
|
||||||
import androidx.compose.ui.graphics.asImageBitmap
|
|
||||||
import androidx.compose.ui.layout.ContentScale
|
|
||||||
import androidx.compose.ui.platform.LocalContext
|
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.text.style.TextOverflow
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
import app.seb3thehacker.gearslip.host.CarAppConnection
|
import app.seb3thehacker.gearslip.host.CarAppConnection
|
||||||
import app.seb3thehacker.gearslip.media.CarMedia
|
import app.seb3thehacker.gearslip.media.CarMedia
|
||||||
import app.seb3thehacker.gearslip.media.MediaArt
|
import app.seb3thehacker.gearslip.media.NowPlaying
|
||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The home screen: the map on the left, what is playing on the right.
|
* The home screen: the map on the left, lyrics on the right when asked for.
|
||||||
*
|
*
|
||||||
* The map is whatever navigation app was used last, brought back on its own. The player only
|
* The map is whatever navigation app was used last, brought back on its own. Playback itself is
|
||||||
* takes its share of the screen only while something is playing or paused; otherwise the map has all of it.
|
* controlled from the nav bar's pill everywhere, including here - this column only opens when the
|
||||||
|
* pill's lyrics button is on, and closes itself back to a full-width map once nothing is playing.
|
||||||
*/
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
fun CarHome() {
|
fun CarHome() {
|
||||||
@@ -65,21 +54,19 @@ fun CarHome() {
|
|||||||
LaunchedEffect(phase) { if (phase == CarMedia.Phase.READY) CarServices.autoplayIfDue() }
|
LaunchedEffect(phase) { if (phase == CarMedia.Phase.READY) CarServices.autoplayIfDue() }
|
||||||
|
|
||||||
val navigator = LocalCarNavigator.current
|
val navigator = LocalCarNavigator.current
|
||||||
val showPlayer = phase == CarMedia.Phase.READY && now.isActive && !navigator.playerMinimised
|
val showLyrics = phase == CarMedia.Phase.READY && now.isActive && navigator.lyricsOpen
|
||||||
// Once playback ends there is nothing left to minimise; the next song starts in place.
|
|
||||||
LaunchedEffect(now.isActive) { if (!now.isActive) navigator.restorePlayer() }
|
|
||||||
|
|
||||||
Row(Modifier.fillMaxSize().padding(8.dp), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
Row(Modifier.fillMaxSize().padding(8.dp), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
Box(
|
Box(
|
||||||
Modifier
|
Modifier
|
||||||
.weight(if (showPlayer) 2.1f else 1f)
|
.weight(if (showLyrics) 2.1f else 1f)
|
||||||
.fillMaxHeight()
|
.fillMaxHeight()
|
||||||
.clip(RoundedCornerShape(EMBEDDED_RADIUS)),
|
.clip(RoundedCornerShape(EMBEDDED_RADIUS)),
|
||||||
) {
|
) {
|
||||||
MapPane(navStatus, frame)
|
MapPane(navStatus, frame)
|
||||||
}
|
}
|
||||||
if (showPlayer) {
|
if (showLyrics) {
|
||||||
MiniPlayer(Modifier.weight(1f).fillMaxHeight())
|
LyricsColumn(now, Modifier.weight(1f).fillMaxHeight())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -98,6 +85,7 @@ private fun MapPane(status: CarAppConnection.Status, frame: CarEnvironment.Frame
|
|||||||
horizontalAlignment = Alignment.CenterHorizontally,
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
) {
|
) {
|
||||||
val hasLast = CarSettings.lastNav.value != null
|
val hasLast = CarSettings.lastNav.value != null
|
||||||
|
val scope = androidx.compose.runtime.rememberCoroutineScope()
|
||||||
when (status.phase) {
|
when (status.phase) {
|
||||||
CarAppConnection.Phase.BINDING, CarAppConnection.Phase.HANDSHAKE ->
|
CarAppConnection.Phase.BINDING, CarAppConnection.Phase.HANDSHAKE ->
|
||||||
Text("Starting ${status.app ?: "the map"}…", style = MaterialTheme.typography.titleMedium)
|
Text("Starting ${status.app ?: "the map"}…", style = MaterialTheme.typography.titleMedium)
|
||||||
@@ -107,110 +95,56 @@ private fun MapPane(status: CarAppConnection.Status, frame: CarEnvironment.Frame
|
|||||||
TextButton(onClick = navigator::apps) { Text("Choose another app") }
|
TextButton(onClick = navigator::apps) { Text("Choose another app") }
|
||||||
}
|
}
|
||||||
else -> {
|
else -> {
|
||||||
|
// IDLE with a last app on record means the driver stopped it themselves - it will
|
||||||
|
// not come back on its own, so the screen must offer a way to bring it back rather
|
||||||
|
// than sit on a "starting" message that never resolves.
|
||||||
Text(
|
Text(
|
||||||
if (hasLast) "Starting the map…" else "No map app chosen yet.",
|
if (hasLast) "Map stopped." else "No map app chosen yet.",
|
||||||
style = MaterialTheme.typography.titleMedium,
|
style = MaterialTheme.typography.titleMedium,
|
||||||
)
|
)
|
||||||
if (!hasLast) {
|
if (hasLast) {
|
||||||
|
TextButton(onClick = { scope.launch { CarServices.reconnectNav(frame) } }) { Text("Reopen") }
|
||||||
|
} else {
|
||||||
Text(
|
Text(
|
||||||
"Open Car apps and connect a navigation app; it will start here next time.",
|
"Open Car apps and connect a navigation app; it will start here next time.",
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
)
|
)
|
||||||
TextButton(onClick = { navigator.apps() }) { Text("Car apps") }
|
|
||||||
}
|
}
|
||||||
|
TextButton(onClick = { navigator.apps() }) { Text("Car apps") }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Previous, play/pause, next and a seek bar, and nothing else: the full controls are in the media screen. */
|
/**
|
||||||
|
* Lyrics for whatever the nav bar's pill is playing, beside the map. Transport controls live in
|
||||||
|
* the pill itself now, not here - this column has exactly one job.
|
||||||
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
private fun MiniPlayer(modifier: Modifier) {
|
private fun LyricsColumn(now: NowPlaying, modifier: Modifier) {
|
||||||
val media = CarServices.media
|
|
||||||
val now by media.now.collectAsStateWithLifecycle()
|
|
||||||
val context = LocalContext.current
|
|
||||||
val art by produceState(now.art, now.art, now.artUri) { value = now.art ?: MediaArt.load(context, now.artUri) }
|
|
||||||
|
|
||||||
var position by remember { mutableStateOf(0L) }
|
|
||||||
LaunchedEffect(now) {
|
|
||||||
while (true) {
|
|
||||||
position = now.currentPosition()
|
|
||||||
delay(500)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
val navigator = LocalCarNavigator.current
|
val navigator = LocalCarNavigator.current
|
||||||
Surface(
|
Surface(
|
||||||
onClick = { navigator.media() },
|
|
||||||
modifier = modifier,
|
modifier = modifier,
|
||||||
shape = RoundedCornerShape(EMBEDDED_RADIUS),
|
shape = RoundedCornerShape(EMBEDDED_RADIUS),
|
||||||
color = MaterialTheme.colorScheme.surfaceVariant,
|
color = MaterialTheme.colorScheme.surfaceVariant,
|
||||||
) {
|
) {
|
||||||
Column(
|
Column(Modifier.fillMaxSize().padding(16.dp)) {
|
||||||
Modifier.fillMaxSize().padding(18.dp),
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
horizontalAlignment = Alignment.CenterHorizontally,
|
Text(
|
||||||
verticalArrangement = Arrangement.Center,
|
now.title.ifEmpty { "Lyrics" },
|
||||||
) {
|
style = MaterialTheme.typography.titleMedium,
|
||||||
Box(Modifier.size(128.dp).clip(RoundedCornerShape(EMBEDDED_RADIUS))) {
|
fontWeight = FontWeight.SemiBold,
|
||||||
art?.let { Image(it.asImageBitmap(), null, Modifier.fillMaxSize(), contentScale = ContentScale.Crop) }
|
maxLines = 1,
|
||||||
}
|
overflow = TextOverflow.Ellipsis,
|
||||||
Spacer(Modifier.height(10.dp))
|
modifier = Modifier.weight(1f),
|
||||||
Text(
|
|
||||||
now.title.ifEmpty { "Nothing playing" },
|
|
||||||
style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold,
|
|
||||||
maxLines = 1, overflow = TextOverflow.Ellipsis,
|
|
||||||
)
|
|
||||||
Text(
|
|
||||||
now.artist,
|
|
||||||
style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
||||||
maxLines = 1, overflow = TextOverflow.Ellipsis,
|
|
||||||
)
|
|
||||||
|
|
||||||
if (now.durationMs > 0) {
|
|
||||||
WavyProgress(
|
|
||||||
fraction = position.toFloat() / now.durationMs,
|
|
||||||
playing = now.state == PlaybackState.STATE_PLAYING,
|
|
||||||
onSeek = { media.seek((it * now.durationMs).toLong()) },
|
|
||||||
modifier = Modifier.padding(vertical = 4.dp),
|
|
||||||
)
|
)
|
||||||
} else {
|
IconButton(onClick = navigator::toggleLyrics, modifier = Modifier.size(36.dp)) {
|
||||||
Spacer(Modifier.height(16.dp))
|
Icon(Icons.Filled.Close, "Hide lyrics", Modifier.size(20.dp))
|
||||||
}
|
|
||||||
|
|
||||||
Row(
|
|
||||||
horizontalArrangement = Arrangement.spacedBy(6.dp),
|
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
|
||||||
) {
|
|
||||||
IconButton(onClick = { media.previous() }, modifier = Modifier.size(48.dp)) {
|
|
||||||
Icon(MediaIcons.Previous, "Previous", Modifier.size(30.dp))
|
|
||||||
}
|
|
||||||
FilledIconButton(onClick = media::togglePlay, modifier = Modifier.size(60.dp)) {
|
|
||||||
Icon(
|
|
||||||
if (now.playing) MediaIcons.Pause else Icons.Filled.PlayArrow,
|
|
||||||
if (now.playing) "Pause" else "Play",
|
|
||||||
Modifier.size(36.dp),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
IconButton(onClick = { media.next() }, modifier = Modifier.size(48.dp)) {
|
|
||||||
Icon(MediaIcons.Next, "Next", Modifier.size(30.dp))
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
Spacer(Modifier.height(4.dp))
|
||||||
Spacer(Modifier.height(8.dp))
|
LyricsPanel(now, Modifier.weight(1f).fillMaxWidth())
|
||||||
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
|
||||||
val compact = PaddingValues(horizontal = 8.dp)
|
|
||||||
FilledTonalButton(
|
|
||||||
onClick = { navigator.media(lyrics = true) },
|
|
||||||
modifier = Modifier.weight(1f),
|
|
||||||
contentPadding = compact,
|
|
||||||
) { Text("Lyrics", maxLines = 1, softWrap = false) }
|
|
||||||
FilledTonalButton(
|
|
||||||
onClick = navigator::minimisePlayer,
|
|
||||||
modifier = Modifier.weight(1f),
|
|
||||||
contentPadding = compact,
|
|
||||||
) { Text("Minimise", maxLines = 1, softWrap = false) }
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,6 +1,9 @@
|
|||||||
package app.seb3thehacker.gearslip.car
|
package app.seb3thehacker.gearslip.car
|
||||||
|
|
||||||
import androidx.compose.foundation.Image
|
import androidx.compose.foundation.Image
|
||||||
|
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||||
|
import androidx.compose.foundation.border
|
||||||
|
import androidx.compose.foundation.combinedClickable
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.PaddingValues
|
import androidx.compose.foundation.layout.PaddingValues
|
||||||
@@ -13,6 +16,7 @@ import androidx.compose.foundation.lazy.grid.GridCells
|
|||||||
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
|
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
|
||||||
import androidx.compose.foundation.lazy.grid.items
|
import androidx.compose.foundation.lazy.grid.items
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.Call
|
||||||
import androidx.compose.material.icons.filled.Search
|
import androidx.compose.material.icons.filled.Search
|
||||||
import androidx.compose.material.icons.filled.Settings
|
import androidx.compose.material.icons.filled.Settings
|
||||||
import androidx.compose.material.icons.filled.Share
|
import androidx.compose.material.icons.filled.Share
|
||||||
@@ -45,6 +49,7 @@ import androidx.compose.foundation.background
|
|||||||
import androidx.compose.foundation.shape.CircleShape
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
import androidx.compose.foundation.lazy.grid.GridItemSpan
|
import androidx.compose.foundation.lazy.grid.GridItemSpan
|
||||||
import androidx.compose.material.icons.filled.Check
|
import androidx.compose.material.icons.filled.Check
|
||||||
|
import androidx.compose.material.icons.filled.Close
|
||||||
import app.seb3thehacker.gearslip.host.KnownApps
|
import app.seb3thehacker.gearslip.host.KnownApps
|
||||||
import app.seb3thehacker.gearslip.host.CarAppCatalog
|
import app.seb3thehacker.gearslip.host.CarAppCatalog
|
||||||
import app.seb3thehacker.gearslip.host.TemplateApp
|
import app.seb3thehacker.gearslip.host.TemplateApp
|
||||||
@@ -60,23 +65,31 @@ private class Tile(
|
|||||||
val glyph: ImageVector? = null,
|
val glyph: ImageVector? = null,
|
||||||
/** Tested from start to finish with Gearslip; drawn with a check mark. */
|
/** Tested from start to finish with Gearslip; drawn with a check mark. */
|
||||||
val verified: Boolean = false,
|
val verified: Boolean = false,
|
||||||
|
/** Known not to work; drawn with a red X instead of being left off the launcher. */
|
||||||
|
val broken: Boolean = false,
|
||||||
|
val pinned: Boolean = false,
|
||||||
|
/** Null for the built-in tiles (Web, Screen sharing, Settings) - nothing to pin them as. */
|
||||||
|
val onLongClick: (() -> Unit)? = null,
|
||||||
val onClick: () -> Unit,
|
val onClick: () -> Unit,
|
||||||
)
|
)
|
||||||
|
|
||||||
/** A launcher app's data, kept apart from [Tile] so it can be cached without capturing click state. */
|
/** A launcher app's data, kept apart from [Tile] so it can be cached without capturing click state. */
|
||||||
private class Entry(
|
internal class Entry(
|
||||||
val label: String,
|
val label: String,
|
||||||
val icon: ImageBitmap?,
|
val icon: ImageBitmap?,
|
||||||
val template: TemplateApp? = null,
|
val template: TemplateApp? = null,
|
||||||
val media: MediaApp? = null,
|
val media: MediaApp? = null,
|
||||||
)
|
) {
|
||||||
|
/** What a pin or a nav-bar shortcut remembers this entry as; null for nothing pinnable. */
|
||||||
|
val componentId: String? get() = (template?.component ?: media?.component)?.flattenToString()
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The installed car and media apps with their icons. Scanning the package manager and decoding
|
* The installed car and media apps with their icons. Scanning the package manager and decoding
|
||||||
* icons is slow, so [Prefetch] does it ahead of time and again at the start of each car session,
|
* icons is slow, so [Prefetch] does it ahead of time and again at the start of each car session,
|
||||||
* which is when new installs are picked up.
|
* which is when new installs are picked up.
|
||||||
*/
|
*/
|
||||||
private object LauncherCache {
|
internal object LauncherCache {
|
||||||
@Volatile var entries: List<Entry>? = null
|
@Volatile var entries: List<Entry>? = null
|
||||||
|
|
||||||
/** Returns the scan if there is one; [force] rescans. Synchronized so two callers share one scan. */
|
/** Returns the scan if there is one; [force] rescans. Synchronized so two callers share one scan. */
|
||||||
@@ -96,6 +109,16 @@ private object LauncherCache {
|
|||||||
/** Rescans the car and media apps now, replacing the cached list; the launcher keeps showing the old one meanwhile. */
|
/** Rescans the car and media apps now, replacing the cached list; the launcher keeps showing the old one meanwhile. */
|
||||||
fun warmLauncherApps(context: Context) { LauncherCache.load(context, force = true) }
|
fun warmLauncherApps(context: Context) { LauncherCache.load(context, force = true) }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Connects or opens [entry] - what tapping its tile does, shared with the nav bar's pinned
|
||||||
|
* shortcuts and its open-app icon so there is exactly one place that knows how to launch either
|
||||||
|
* kind of app.
|
||||||
|
*/
|
||||||
|
internal fun launchEntry(entry: Entry, navigator: CarNavigator, frame: CarEnvironment.Frame) {
|
||||||
|
entry.template?.let { CarServices.connectNav(it, frame); navigator.home() }
|
||||||
|
entry.media?.let { CarServices.openMedia(it); navigator.media() }
|
||||||
|
}
|
||||||
|
|
||||||
/** The app launcher: Web and Screen sharing, every car app the phone has, then Settings, as an icon grid. */
|
/** The app launcher: Web and Screen sharing, every car app the phone has, then Settings, as an icon grid. */
|
||||||
@Composable
|
@Composable
|
||||||
fun CarLauncher() {
|
fun CarLauncher() {
|
||||||
@@ -108,15 +131,22 @@ fun CarLauncher() {
|
|||||||
value = LauncherCache.entries ?: withContext(Dispatchers.IO) { LauncherCache.load(context) }
|
value = LauncherCache.entries ?: withContext(Dispatchers.IO) { LauncherCache.load(context) }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
val pinned by CarSettings.pinnedApps.collectAsState()
|
||||||
|
|
||||||
val tiles = listOf(
|
val tiles = listOf(
|
||||||
Tile("Web", glyph = Icons.Filled.Search) { navigator.open("web") },
|
Tile("Web", glyph = Icons.Filled.Search) { navigator.open("web") },
|
||||||
Tile("Screen sharing", glyph = Icons.Filled.Share) { navigator.open("phone") },
|
Tile("Screen sharing", glyph = Icons.Filled.Share) { navigator.open("phone") },
|
||||||
|
Tile("Phone", glyph = Icons.Filled.Call) { navigator.open("dialer") },
|
||||||
) + installed.map { entry ->
|
) + installed.map { entry ->
|
||||||
val pkg = entry.template?.component?.packageName ?: entry.media?.component?.packageName
|
val pkg = entry.template?.component?.packageName ?: entry.media?.component?.packageName
|
||||||
Tile(entry.label, entry.icon, verified = pkg != null && KnownApps.works(pkg)) {
|
val id = entry.componentId
|
||||||
entry.template?.let { CarServices.connectNav(it, frame); navigator.home() }
|
Tile(
|
||||||
entry.media?.let { CarServices.openMedia(it); navigator.media() }
|
entry.label, entry.icon,
|
||||||
}
|
verified = pkg != null && KnownApps.works(pkg),
|
||||||
|
broken = pkg != null && KnownApps.isBroken(pkg),
|
||||||
|
pinned = id != null && id in pinned,
|
||||||
|
onLongClick = id?.let { { CarSettings.togglePin(it) } },
|
||||||
|
) { launchEntry(entry, navigator, frame) }
|
||||||
} + listOf(
|
} + listOf(
|
||||||
Tile("Settings", glyph = Icons.Filled.Settings) { navigator.settings() },
|
Tile("Settings", glyph = Icons.Filled.Settings) { navigator.settings() },
|
||||||
)
|
)
|
||||||
@@ -164,12 +194,30 @@ private fun VerifiedBadge(size: androidx.compose.ui.unit.Dp, modifier: Modifier
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** A red disc with a white X: marks a tile known not to work with Gearslip yet. */
|
||||||
|
@Composable
|
||||||
|
private fun BrokenBadge(size: androidx.compose.ui.unit.Dp, modifier: Modifier = Modifier) {
|
||||||
|
Box(
|
||||||
|
modifier.size(size).background(Color(0xFFD32F2F), CircleShape),
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
) {
|
||||||
|
Icon(Icons.Filled.Close, contentDescription = "Doesn't work with Gearslip", tint = Color.White, modifier = Modifier.size(size * 0.66f))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@OptIn(ExperimentalFoundationApi::class)
|
||||||
@Composable
|
@Composable
|
||||||
private fun AppTile(tile: Tile) {
|
private fun AppTile(tile: Tile) {
|
||||||
|
val shape = MaterialTheme.shapes.extraLarge
|
||||||
Surface(
|
Surface(
|
||||||
onClick = tile.onClick,
|
modifier = Modifier
|
||||||
modifier = Modifier.height(120.dp),
|
.height(120.dp)
|
||||||
shape = MaterialTheme.shapes.extraLarge,
|
.let { if (tile.pinned) it.border(2.dp, MaterialTheme.colorScheme.primary, shape) else it }
|
||||||
|
// combinedClickable, not Surface's own onClick, so a long press can mean something
|
||||||
|
// different from a tap - pinning is deliberately the same gesture everywhere apps
|
||||||
|
// are shown (here and, once pinned, the shortcut itself on the nav bar).
|
||||||
|
.combinedClickable(onClick = tile.onClick, onLongClick = tile.onLongClick),
|
||||||
|
shape = shape,
|
||||||
color = MaterialTheme.colorScheme.surfaceVariant,
|
color = MaterialTheme.colorScheme.surfaceVariant,
|
||||||
contentColor = MaterialTheme.colorScheme.onSurfaceVariant,
|
contentColor = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
) {
|
) {
|
||||||
@@ -196,6 +244,9 @@ private fun AppTile(tile: Tile) {
|
|||||||
if (tile.verified) {
|
if (tile.verified) {
|
||||||
VerifiedBadge(24.dp, Modifier.align(Alignment.TopEnd).padding(8.dp))
|
VerifiedBadge(24.dp, Modifier.align(Alignment.TopEnd).padding(8.dp))
|
||||||
}
|
}
|
||||||
|
if (tile.broken) {
|
||||||
|
BrokenBadge(24.dp, Modifier.align(Alignment.TopEnd).padding(8.dp))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
package app.seb3thehacker.gearslip.car
|
package app.seb3thehacker.gearslip.car
|
||||||
|
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateListOf
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.runtime.staticCompositionLocalOf
|
import androidx.compose.runtime.staticCompositionLocalOf
|
||||||
@@ -24,58 +25,81 @@ sealed interface CarScreen {
|
|||||||
data class App(val id: String) : CarScreen
|
data class App(val id: String) : CarScreen
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** One step on the breadcrumb trail: the screen it shows and the word for it in the trail. */
|
||||||
|
data class Crumb(val screen: CarScreen, val label: String)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Where the car UI is. A car touchscreen has no hardware back button, so [back] is driven by
|
* Where the car UI is, and how it got there. A car touchscreen has no hardware back button, so
|
||||||
* the on-screen one; an open app can claim it first (a browser going back a page) through
|
* every screen that isn't reached from [home] is pushed onto a trail (like a browser's history,
|
||||||
* [backInterceptor].
|
* or a file manager's breadcrumb bar) rather than replacing the last one outright - that's what
|
||||||
|
* lets the breadcrumb bar show "Home > Apps > Settings" and jump straight back to any of them,
|
||||||
|
* and lets [back] undo exactly one step instead of always dumping you back at Home. An open app
|
||||||
|
* can claim a back step first (a browser going back a page) through [backInterceptor].
|
||||||
*/
|
*/
|
||||||
class CarNavigator(startId: String) {
|
class CarNavigator(startId: String) {
|
||||||
|
|
||||||
var current: CarScreen by mutableStateOf(
|
private val stack = mutableStateListOf(
|
||||||
if (CarApps.find(startId) != null) CarScreen.App(startId) else CarScreen.Home,
|
Crumb(
|
||||||
|
if (CarApps.find(startId) != null) CarScreen.App(startId) else CarScreen.Home,
|
||||||
|
"Home",
|
||||||
|
),
|
||||||
)
|
)
|
||||||
private set
|
|
||||||
|
/** The whole trail, root first. Size 1 means "at Home" - the breadcrumb bar hides then. */
|
||||||
|
val trail: List<Crumb> get() = stack
|
||||||
|
|
||||||
|
val current: CarScreen get() = stack.last().screen
|
||||||
|
|
||||||
var backInterceptor: (() -> Boolean)? = null
|
var backInterceptor: (() -> Boolean)? = null
|
||||||
|
|
||||||
fun home() { current = CarScreen.Home }
|
/** Pushes a new step, unless it's the same screen already on top - tapping the button for
|
||||||
|
* where you already are shouldn't grow the trail. */
|
||||||
/**
|
private fun push(screen: CarScreen, label: String) {
|
||||||
* Minimised, the home screen's player folds into the nav bar and the map takes the width.
|
if (stack.last().screen == screen) return
|
||||||
* Opening the media screen from the bar restores it, so leaving that screen lands on the
|
stack.add(Crumb(screen, label))
|
||||||
* home screen with the player back in place.
|
|
||||||
*/
|
|
||||||
var playerMinimised by mutableStateOf(false)
|
|
||||||
private set
|
|
||||||
|
|
||||||
/** Set by the player's Lyrics button; the media screen reads it once when it opens. */
|
|
||||||
var lyricsRequested = false
|
|
||||||
|
|
||||||
fun minimisePlayer() { playerMinimised = true }
|
|
||||||
|
|
||||||
fun restorePlayer() { playerMinimised = false }
|
|
||||||
|
|
||||||
fun media(lyrics: Boolean = false) {
|
|
||||||
lyricsRequested = lyrics
|
|
||||||
playerMinimised = false
|
|
||||||
current = CarScreen.Media
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fun apps() { current = CarScreen.Apps }
|
/** The one way back to a clean slate: resets the whole trail to just Home. */
|
||||||
|
fun home() {
|
||||||
|
stack.clear()
|
||||||
|
stack.add(Crumb(CarScreen.Home, "Home"))
|
||||||
|
}
|
||||||
|
|
||||||
fun settings() { current = CarScreen.Settings }
|
/**
|
||||||
|
* The nav bar's now-playing pill is the player everywhere; this is only whether the home
|
||||||
|
* screen is additionally showing lyrics for it in the column beside the map. Toggled by the
|
||||||
|
* pill's own lyrics button, so it means the same thing wherever the pill is visible, even
|
||||||
|
* though only the home screen actually has the room to show it.
|
||||||
|
*/
|
||||||
|
var lyricsOpen by mutableStateOf(false)
|
||||||
|
private set
|
||||||
|
|
||||||
fun dashboard() { current = CarScreen.Dashboard }
|
fun toggleLyrics() { lyricsOpen = !lyricsOpen }
|
||||||
|
|
||||||
fun weather() { current = CarScreen.Weather }
|
fun media() { push(CarScreen.Media, "Media") }
|
||||||
|
|
||||||
fun notifications(replyTo: String? = null) { current = CarScreen.Notifications(replyTo) }
|
fun apps() { push(CarScreen.Apps, "Apps") }
|
||||||
|
|
||||||
fun open(id: String) { current = CarScreen.App(id) }
|
fun settings() { push(CarScreen.Settings, "Settings") }
|
||||||
|
|
||||||
|
fun dashboard() { push(CarScreen.Dashboard, "Dashboard") }
|
||||||
|
|
||||||
|
fun weather() { push(CarScreen.Weather, "Weather") }
|
||||||
|
|
||||||
|
fun notifications(replyTo: String? = null) { push(CarScreen.Notifications(replyTo), "Notifications") }
|
||||||
|
|
||||||
|
fun open(id: String) { push(CarScreen.App(id), CarApps.find(id)?.label ?: id) }
|
||||||
|
|
||||||
|
/** Jumps straight to a step already on the trail, dropping everything after it - what
|
||||||
|
* tapping a breadcrumb does. Index 0 (Home) is always on the trail, so this always succeeds. */
|
||||||
|
fun jumpTo(index: Int) {
|
||||||
|
while (stack.size > index + 1) stack.removeAt(stack.lastIndex)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One step back, same as tapping the crumb before the current one. */
|
||||||
fun back() {
|
fun back() {
|
||||||
if (backInterceptor?.invoke() == true) return
|
if (backInterceptor?.invoke() == true) return
|
||||||
current = CarScreen.Home
|
if (stack.size > 1) stack.removeAt(stack.lastIndex)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -39,6 +39,7 @@ object CarServices {
|
|||||||
fun init(appContext: Context) {
|
fun init(appContext: Context) {
|
||||||
context = appContext.applicationContext
|
context = appContext.applicationContext
|
||||||
app.seb3thehacker.gearslip.notify.CarMotion.start(appContext)
|
app.seb3thehacker.gearslip.notify.CarMotion.start(appContext)
|
||||||
|
app.seb3thehacker.gearslip.call.CarCalls.start(appContext)
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- map -----------------------------------------------------------------------------
|
// --- map -----------------------------------------------------------------------------
|
||||||
@@ -54,6 +55,12 @@ object CarServices {
|
|||||||
nav.disconnect()
|
nav.disconnect()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Undoes [stopNav]'s hold so the last map app comes back, same as it would after a restart. */
|
||||||
|
suspend fun reconnectNav(frame: CarEnvironment.Frame) {
|
||||||
|
navStopped = false
|
||||||
|
autostart(frame)
|
||||||
|
}
|
||||||
|
|
||||||
// --- media ---------------------------------------------------------------------------
|
// --- media ---------------------------------------------------------------------------
|
||||||
|
|
||||||
fun openMedia(app: MediaApp, autoplay: Boolean = false) {
|
fun openMedia(app: MediaApp, autoplay: Boolean = false) {
|
||||||
@@ -105,6 +112,7 @@ object CarServices {
|
|||||||
/** 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) }
|
||||||
|
app.seb3thehacker.gearslip.call.CarCalls.stop()
|
||||||
runCatching { nav.disconnect() }
|
runCatching { nav.disconnect() }
|
||||||
runCatching { media.disconnect() }
|
runCatching { media.disconnect() }
|
||||||
CarAudio.release()
|
CarAudio.release()
|
||||||
|
|||||||
@@ -24,6 +24,11 @@ object CarSettings {
|
|||||||
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"
|
||||||
private const val KEY_THEME = "car_app_theme"
|
private const val KEY_THEME = "car_app_theme"
|
||||||
|
private const val KEY_PINNED = "car_pinned_apps"
|
||||||
|
|
||||||
|
/** How many shortcuts the nav bar makes room for; a driver reaching for one needs it to
|
||||||
|
* still be on the bar, not off the edge of a long list. */
|
||||||
|
const val MAX_PINNED = 3
|
||||||
|
|
||||||
/** Id of the app to open on connect; [HOME] opens the launcher. */
|
/** Id of the app to open on connect; [HOME] opens the launcher. */
|
||||||
const val HOME = "home"
|
const val HOME = "home"
|
||||||
@@ -39,6 +44,10 @@ object CarSettings {
|
|||||||
private val autoplayFlow = MutableStateFlow(false)
|
private val autoplayFlow = MutableStateFlow(false)
|
||||||
private val themeFlow = MutableStateFlow(AppTheme.PHONE)
|
private val themeFlow = MutableStateFlow(AppTheme.PHONE)
|
||||||
val appTheme: StateFlow<AppTheme> = themeFlow
|
val appTheme: StateFlow<AppTheme> = themeFlow
|
||||||
|
private val pinnedFlow = MutableStateFlow<Set<String>>(emptySet())
|
||||||
|
|
||||||
|
/** Flattened component names of the apps long-pressed onto the nav bar as shortcuts. */
|
||||||
|
val pinnedApps: StateFlow<Set<String>> = pinnedFlow
|
||||||
|
|
||||||
/** Flattened component names of the map and media apps used last, brought back on connect. */
|
/** Flattened component names of the map and media apps used last, brought back on connect. */
|
||||||
val lastNav: StateFlow<String?> = lastNavFlow
|
val lastNav: StateFlow<String?> = lastNavFlow
|
||||||
@@ -73,6 +82,8 @@ object CarSettings {
|
|||||||
nightFlow.value = runCatching {
|
nightFlow.value = runCatching {
|
||||||
NightMode.valueOf(AppSettings.getString(app, KEY_NIGHT, NightMode.AUTO.name))
|
NightMode.valueOf(AppSettings.getString(app, KEY_NIGHT, NightMode.AUTO.name))
|
||||||
}.getOrDefault(NightMode.AUTO)
|
}.getOrDefault(NightMode.AUTO)
|
||||||
|
pinnedFlow.value = AppSettings.getString(app, KEY_PINNED, "")
|
||||||
|
.split(",").filter { it.isNotEmpty() }.toSet()
|
||||||
}
|
}
|
||||||
|
|
||||||
fun setScale(value: Float) {
|
fun setScale(value: Float) {
|
||||||
@@ -115,4 +126,17 @@ object CarSettings {
|
|||||||
AppSettings.putString(app, KEY_NIGHT, mode.name)
|
AppSettings.putString(app, KEY_NIGHT, mode.name)
|
||||||
CarEnvironment.refresh()
|
CarEnvironment.refresh()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Long-pressed onto or off the nav bar. Silently does nothing past [MAX_PINNED] rather than
|
||||||
|
* bumping the oldest pin - a driver who filled the bar on purpose shouldn't lose one by accident. */
|
||||||
|
fun togglePin(component: String) {
|
||||||
|
val current = pinnedFlow.value
|
||||||
|
val next = when {
|
||||||
|
component in current -> current - component
|
||||||
|
current.size >= MAX_PINNED -> return
|
||||||
|
else -> current + component
|
||||||
|
}
|
||||||
|
pinnedFlow.value = next
|
||||||
|
AppSettings.putString(app, KEY_PINNED, next.joinToString(","))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -117,9 +117,6 @@ fun CarSettingsScreen() {
|
|||||||
Info("Video", display.ifEmpty { "-" })
|
Info("Video", display.ifEmpty { "-" })
|
||||||
Info("Certificate", cert?.headline ?: "Checking…")
|
Info("Certificate", cert?.headline ?: "Checking…")
|
||||||
}
|
}
|
||||||
val navigator = LocalCarNavigator.current
|
|
||||||
Button(onClick = { navigator.open("calibrate") }) { Text("Display test") }
|
|
||||||
Spacer(Modifier.height(8.dp))
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import androidx.compose.foundation.Image
|
|||||||
import androidx.compose.foundation.clickable
|
import androidx.compose.foundation.clickable
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.BoxWithConstraints
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.PaddingValues
|
import androidx.compose.foundation.layout.PaddingValues
|
||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
@@ -19,9 +20,10 @@ import androidx.compose.foundation.layout.width
|
|||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||||
|
import androidx.compose.material.icons.filled.Home
|
||||||
import androidx.compose.material.icons.filled.Notifications
|
import androidx.compose.material.icons.filled.Notifications
|
||||||
import androidx.compose.material.icons.filled.PlayArrow
|
import androidx.compose.material.icons.filled.PlayArrow
|
||||||
import androidx.compose.material.icons.filled.Home
|
|
||||||
import androidx.compose.material3.FilledIconButton
|
import androidx.compose.material3.FilledIconButton
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.IconButton
|
import androidx.compose.material3.IconButton
|
||||||
@@ -44,6 +46,7 @@ import androidx.compose.ui.Modifier
|
|||||||
import androidx.compose.ui.draw.clip
|
import androidx.compose.ui.draw.clip
|
||||||
import androidx.compose.ui.input.pointer.PointerEventPass
|
import androidx.compose.ui.input.pointer.PointerEventPass
|
||||||
import androidx.compose.ui.input.pointer.pointerInput
|
import androidx.compose.ui.input.pointer.pointerInput
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.graphics.asImageBitmap
|
import androidx.compose.ui.graphics.asImageBitmap
|
||||||
import androidx.compose.ui.graphics.vector.ImageVector
|
import androidx.compose.ui.graphics.vector.ImageVector
|
||||||
import androidx.compose.ui.layout.ContentScale
|
import androidx.compose.ui.layout.ContentScale
|
||||||
@@ -55,8 +58,12 @@ import androidx.compose.ui.platform.LocalDensity
|
|||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.unit.Density
|
import androidx.compose.ui.unit.Density
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
|
import app.seb3thehacker.gearslip.host.CarAppConnection
|
||||||
import app.seb3thehacker.gearslip.notify.CarNotifications
|
import app.seb3thehacker.gearslip.notify.CarNotifications
|
||||||
|
import androidx.core.graphics.drawable.toBitmap
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.delay
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
import java.text.DateFormat
|
import java.text.DateFormat
|
||||||
import java.util.Date
|
import java.util.Date
|
||||||
|
|
||||||
@@ -123,30 +130,29 @@ fun CarUi() {
|
|||||||
end = insets.right.toDp(), bottom = insets.bottom.toDp(),
|
end = insets.right.toDp(), bottom = insets.bottom.toDp(),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
// The calibration screen is the raw frame: no insets, no nav bar.
|
|
||||||
val screen = navigator.current
|
val screen = navigator.current
|
||||||
if (screen == CarScreen.App("calibrate")) {
|
Column(Modifier.fillMaxSize().padding(pad)) {
|
||||||
CarApps.find("calibrate")?.content?.invoke()
|
// Header navigation (breadcrumb trail) - off for now, kept to tweak later.
|
||||||
} else {
|
// BreadcrumbBar(navigator)
|
||||||
Column(Modifier.fillMaxSize().padding(pad)) {
|
Box(Modifier.weight(1f).fillMaxWidth()) {
|
||||||
Box(Modifier.weight(1f).fillMaxWidth()) {
|
when (screen) {
|
||||||
when (screen) {
|
CarScreen.Home -> CarHome()
|
||||||
CarScreen.Home -> CarHome()
|
CarScreen.Apps -> CarLauncher()
|
||||||
CarScreen.Apps -> CarLauncher()
|
CarScreen.Media -> mediaApp?.let { MediaScreen(it) { navigator.back() } }
|
||||||
CarScreen.Media -> mediaApp?.let { MediaScreen(it) { navigator.home() } }
|
CarScreen.Settings -> CarSettingsScreen()
|
||||||
CarScreen.Settings -> CarSettingsScreen()
|
CarScreen.Dashboard -> CarDashboardScreen()
|
||||||
CarScreen.Dashboard -> CarDashboardScreen()
|
CarScreen.Weather -> WeatherScreen()
|
||||||
CarScreen.Weather -> WeatherScreen()
|
is CarScreen.Notifications -> NotificationsScreen(screen.replyTo)
|
||||||
is CarScreen.Notifications -> NotificationsScreen(screen.replyTo)
|
is CarScreen.App -> CarApps.find(screen.id)?.content?.invoke()
|
||||||
is CarScreen.App -> CarApps.find(screen.id)?.content?.invoke()
|
|
||||||
}
|
|
||||||
// The history screen is already showing them, so it needs no popup.
|
|
||||||
if (screen !is CarScreen.Notifications) {
|
|
||||||
NotificationPopup(Modifier.align(Alignment.TopCenter))
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
CarNavBar(navigator)
|
// The history screen is already showing them, so it needs no popup.
|
||||||
|
if (screen !is CarScreen.Notifications) {
|
||||||
|
NotificationPopup(Modifier.align(Alignment.TopCenter))
|
||||||
|
}
|
||||||
|
// Above the popup: a call is more urgent than any notification.
|
||||||
|
CallOverlay(Modifier.align(Alignment.TopCenter))
|
||||||
}
|
}
|
||||||
|
CarNavBar(navigator)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -154,68 +160,166 @@ fun CarUi() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Home and Apps on the left; now-playing pill in the middle; clock (tap for calendar and weather), phone battery and the
|
* "Home > Apps > Settings", like a file manager's address bar: hidden at Home itself (nothing to
|
||||||
* darkness signal on the right. Icon-only: a driver reads a glyph faster than a label, and it
|
* show yet), and grows by one crumb for every screen pushed from there. The back arrow undoes one
|
||||||
* keeps the bar short enough to leave the app underneath more room.
|
* step; a crumb jumps straight to it, dropping everything after - this is the only on-screen way
|
||||||
|
* back at all, since a car touchscreen has no hardware button for it.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
private fun BreadcrumbBar(navigator: CarNavigator) {
|
||||||
|
val trail = navigator.trail
|
||||||
|
if (trail.size <= 1) return
|
||||||
|
Row(
|
||||||
|
Modifier.fillMaxWidth().height(40.dp).padding(start = 2.dp, end = 10.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
IconButton(onClick = navigator::back, modifier = Modifier.size(36.dp)) {
|
||||||
|
Icon(Icons.AutoMirrored.Filled.ArrowBack, "Back", Modifier.size(20.dp))
|
||||||
|
}
|
||||||
|
Spacer(Modifier.width(2.dp))
|
||||||
|
trail.forEachIndexed { index, crumb ->
|
||||||
|
val isLast = index == trail.lastIndex
|
||||||
|
Text(
|
||||||
|
crumb.label,
|
||||||
|
style = MaterialTheme.typography.labelLarge,
|
||||||
|
fontWeight = if (isLast) FontWeight.SemiBold else FontWeight.Normal,
|
||||||
|
color = if (isLast) MaterialTheme.colorScheme.onSurface else MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
modifier = if (isLast) Modifier else Modifier
|
||||||
|
.clip(RoundedCornerShape(6.dp))
|
||||||
|
.clickable { navigator.jumpTo(index) }
|
||||||
|
.padding(horizontal = 4.dp, vertical = 2.dp),
|
||||||
|
)
|
||||||
|
if (!isLast) {
|
||||||
|
Text(
|
||||||
|
"›",
|
||||||
|
style = MaterialTheme.typography.labelLarge,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
modifier = Modifier.padding(horizontal = 2.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Apps and open/pinned shortcuts on the left; now-playing pill against them; clock (tap for
|
||||||
|
* calendar and weather), phone battery and the darkness signal on the right. Icon-only: a driver
|
||||||
|
* reads a glyph faster than a label, and it keeps the bar short enough to leave the app underneath
|
||||||
|
* more room.
|
||||||
*/
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
private fun CarNavBar(navigator: CarNavigator) {
|
private fun CarNavBar(navigator: CarNavigator) {
|
||||||
val battery by CarEnvironment.battery.collectAsState()
|
val battery by CarEnvironment.battery.collectAsState()
|
||||||
val darkOutside by CarEnvironment.darkOutside.collectAsState()
|
|
||||||
val now by rememberNow()
|
val now by rememberNow()
|
||||||
|
|
||||||
// The home screen shows what's playing itself, unless it was minimised; everywhere else it lives in this bar.
|
|
||||||
val showNowPlaying = navigator.current != CarScreen.Home || navigator.playerMinimised
|
|
||||||
val weather by Weather.state.collectAsState()
|
val weather by Weather.state.collectAsState()
|
||||||
|
val weatherData = (weather as? WeatherState.Ready)?.data
|
||||||
|
|
||||||
|
// The one app that's actually open and not otherwise reachable from this bar: media has its
|
||||||
|
// own pill already, so the only "open app" icon worth showing is whichever map is connected.
|
||||||
|
val navStatus by CarServices.nav.status.collectAsState()
|
||||||
|
val lastNav by CarSettings.lastNav.collectAsState()
|
||||||
|
val openMapComponent = lastNav?.takeIf { navStatus.phase == CarAppConnection.Phase.RUNNING }
|
||||||
|
|
||||||
|
val context = LocalContext.current
|
||||||
|
val pinnedIds by CarSettings.pinnedApps.collectAsState()
|
||||||
|
val entries by produceState(LauncherCache.entries ?: emptyList(), context) {
|
||||||
|
value = LauncherCache.entries ?: withContext(Dispatchers.IO) { LauncherCache.load(context) }
|
||||||
|
}
|
||||||
|
val pinnedEntries = remember(entries, pinnedIds) {
|
||||||
|
pinnedIds.mapNotNull { id -> entries.find { it.componentId == id } }
|
||||||
|
}
|
||||||
|
val frame by CarEnvironment.frame.collectAsState()
|
||||||
|
|
||||||
// One row, so nothing can sit on top of anything else: the player takes whatever width the
|
|
||||||
// fixed items leave and shortens its title to fit.
|
|
||||||
Surface(color = MaterialTheme.colorScheme.surfaceContainer) {
|
Surface(color = MaterialTheme.colorScheme.surfaceContainer) {
|
||||||
Row(
|
BoxWithConstraints(Modifier.fillMaxWidth().height(56.dp).padding(horizontal = 10.dp)) {
|
||||||
Modifier.fillMaxWidth().height(56.dp).padding(horizontal = 10.dp),
|
Row(Modifier.fillMaxSize(), verticalAlignment = Alignment.CenterVertically) {
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
// Home and Apps are two different places now, not a toggle on one button - the
|
||||||
) {
|
// breadcrumb trail is how you retrace your steps, but reaching for either of
|
||||||
NavItem(Icons.Filled.Home, navigator.current == CarScreen.Home) { navigator.home() }
|
// these two from three screens deep in a media app shouldn't mean reading it
|
||||||
Spacer(Modifier.width(6.dp))
|
// first.
|
||||||
NavItem(MediaIcons.Apps, navigator.current == CarScreen.Apps) { navigator.apps() }
|
NavItem(Icons.Filled.Home, navigator.current == CarScreen.Home) { navigator.home() }
|
||||||
|
Spacer(Modifier.width(6.dp))
|
||||||
|
NavItem(MediaIcons.Apps, navigator.current == CarScreen.Apps) { navigator.apps() }
|
||||||
|
|
||||||
Box(Modifier.weight(1f).padding(horizontal = 10.dp), contentAlignment = Alignment.CenterStart) {
|
openMapComponent?.let { component ->
|
||||||
if (showNowPlaying) NavNowPlaying(navigator, selected = navigator.current == CarScreen.Media)
|
Spacer(Modifier.width(6.dp))
|
||||||
}
|
NavAppIcon(component, contentDescription = "Map", onClick = navigator::home)
|
||||||
|
}
|
||||||
(weather as? WeatherState.Ready)?.data?.let { w ->
|
pinnedEntries.forEach { entry ->
|
||||||
NavChip(navigator.current == CarScreen.Weather, { navigator.weather() }) {
|
Spacer(Modifier.width(6.dp))
|
||||||
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(6.dp)) {
|
entry.componentId?.let { id ->
|
||||||
WeatherIcon(w.code, w.isDay, size = 28.dp)
|
NavAppIcon(id, contentDescription = entry.label) { launchEntry(entry, navigator, frame) }
|
||||||
Text("${w.temp}°", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Spacer(Modifier.width(4.dp))
|
|
||||||
}
|
Box(Modifier.weight(1f).padding(horizontal = 10.dp), contentAlignment = Alignment.CenterStart) {
|
||||||
NavChip(navigator.current == CarScreen.Dashboard, { navigator.dashboard() }) {
|
NavNowPlaying(navigator, selected = navigator.current == CarScreen.Media)
|
||||||
Column(horizontalAlignment = Alignment.End) {
|
|
||||||
Text(
|
|
||||||
DateFormat.getTimeInstance(DateFormat.SHORT).format(Date(now)),
|
|
||||||
style = MaterialTheme.typography.titleMedium,
|
|
||||||
fontWeight = FontWeight.SemiBold,
|
|
||||||
)
|
|
||||||
val phone = if (battery.percent >= 0) "${battery.percent}%${if (battery.charging) " charging" else ""}" else ""
|
|
||||||
val light = if (darkOutside) "Night" else "Day"
|
|
||||||
Text(
|
|
||||||
listOf(phone, light).filter { it.isNotEmpty() }.joinToString(" · "),
|
|
||||||
style = MaterialTheme.typography.labelMedium,
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Weather no longer has its own chip - the temperature alone (no icon, no room
|
||||||
|
// for one) sits where "Night"/"Day" used to, freeing a whole chip's width for the
|
||||||
|
// Home button above.
|
||||||
|
NavChip(navigator.current == CarScreen.Dashboard, { navigator.dashboard() }) {
|
||||||
|
Column(horizontalAlignment = Alignment.End) {
|
||||||
|
Text(
|
||||||
|
DateFormat.getTimeInstance(DateFormat.SHORT).format(Date(now)),
|
||||||
|
style = MaterialTheme.typography.titleMedium,
|
||||||
|
fontWeight = FontWeight.SemiBold,
|
||||||
|
)
|
||||||
|
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(3.dp)) {
|
||||||
|
if (battery.percent >= 0) {
|
||||||
|
Text(
|
||||||
|
"${battery.percent}%",
|
||||||
|
style = MaterialTheme.typography.labelMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
if (battery.charging) {
|
||||||
|
ChargingGlyph(Modifier.size(13.dp))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
weatherData?.let { w ->
|
||||||
|
Text(
|
||||||
|
" · ${w.temp}°",
|
||||||
|
style = MaterialTheme.typography.labelMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Spacer(Modifier.width(10.dp))
|
||||||
|
NavItem(
|
||||||
|
Icons.Filled.Notifications, navigator.current is CarScreen.Notifications, padding = 8.dp,
|
||||||
|
) { navigator.notifications() }
|
||||||
}
|
}
|
||||||
Spacer(Modifier.width(10.dp))
|
|
||||||
NavItem(
|
|
||||||
Icons.Filled.Notifications, navigator.current is CarScreen.Notifications,
|
|
||||||
) { navigator.notifications() }
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** One open or pinned app's icon on the nav bar - the same fixed size and shape either way. */
|
||||||
|
@Composable
|
||||||
|
private fun NavAppIcon(component: String, contentDescription: String?, onClick: () -> Unit) {
|
||||||
|
val context = LocalContext.current
|
||||||
|
val pkg = remember(component) { android.content.ComponentName.unflattenFromString(component)?.packageName }
|
||||||
|
val icon by produceState<androidx.compose.ui.graphics.ImageBitmap?>(null, pkg) {
|
||||||
|
value = pkg?.let {
|
||||||
|
runCatching { context.packageManager.getApplicationIcon(it).toBitmap(96, 96).asImageBitmap() }.getOrNull()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Box(
|
||||||
|
Modifier
|
||||||
|
.size(44.dp)
|
||||||
|
.clip(RoundedCornerShape(14.dp))
|
||||||
|
.background(MaterialTheme.colorScheme.surfaceVariant)
|
||||||
|
.clickable(onClick = onClick),
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
) {
|
||||||
|
icon?.let { Image(it, contentDescription, Modifier.size(30.dp).clip(RoundedCornerShape(8.dp))) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/** A tappable group in the bar that stays highlighted while its screen is open, like [NavItem]. */
|
/** A tappable group in the bar that stays highlighted while its screen is open, like [NavItem]. */
|
||||||
@Composable
|
@Composable
|
||||||
private fun NavChip(selected: Boolean, onClick: () -> Unit, content: @Composable () -> Unit) {
|
private fun NavChip(selected: Boolean, onClick: () -> Unit, content: @Composable () -> Unit) {
|
||||||
@@ -231,14 +335,14 @@ private fun NavChip(selected: Boolean, onClick: () -> Unit, content: @Composable
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun NavItem(icon: ImageVector, selected: Boolean, onClick: () -> Unit) {
|
private fun NavItem(icon: ImageVector, selected: Boolean, padding: androidx.compose.ui.unit.Dp = 12.dp, onClick: () -> Unit) {
|
||||||
val scheme = MaterialTheme.colorScheme
|
val scheme = MaterialTheme.colorScheme
|
||||||
Box(
|
Box(
|
||||||
Modifier
|
Modifier
|
||||||
.clip(RoundedCornerShape(18.dp))
|
.clip(RoundedCornerShape(18.dp))
|
||||||
.background(if (selected) scheme.secondaryContainer else scheme.surfaceContainer)
|
.background(if (selected) scheme.secondaryContainer else scheme.surfaceContainer)
|
||||||
.clickable(onClick = onClick)
|
.clickable(onClick = onClick)
|
||||||
.padding(12.dp),
|
.padding(padding),
|
||||||
contentAlignment = Alignment.Center,
|
contentAlignment = Alignment.Center,
|
||||||
) {
|
) {
|
||||||
Icon(icon, contentDescription = null, modifier = Modifier.size(28.dp))
|
Icon(icon, contentDescription = null, modifier = Modifier.size(28.dp))
|
||||||
@@ -254,7 +358,11 @@ internal fun rememberNow(): State<Long> = produceState(System.currentTimeMillis(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Art, title and play/pause in the nav bar; tapping the art or title opens the full media screen. */
|
/**
|
||||||
|
* The one persistent player, everywhere - not just a Home-screen extra. Art and title open the
|
||||||
|
* full media screen; skip and play/pause work right here; the last button toggles the lyrics
|
||||||
|
* panel beside the map on Home (see [CarHome]), the pill's own lyrics affordance wherever it sits.
|
||||||
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
private fun NavNowPlaying(navigator: CarNavigator, selected: Boolean, modifier: Modifier = Modifier) {
|
private fun NavNowPlaying(navigator: CarNavigator, selected: Boolean, modifier: Modifier = Modifier) {
|
||||||
val media = CarServices.media
|
val media = CarServices.media
|
||||||
@@ -302,9 +410,65 @@ private fun NavNowPlaying(navigator: CarNavigator, selected: Boolean, modifier:
|
|||||||
Modifier.size(26.dp),
|
Modifier.size(26.dp),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
IconButton(onClick = { media.next() }, modifier = Modifier.size(44.dp)) {
|
FilledIconButton(
|
||||||
Icon(MediaIcons.Next, "Next", Modifier.size(28.dp))
|
onClick = navigator::toggleLyrics,
|
||||||
|
modifier = Modifier.size(44.dp),
|
||||||
|
colors = androidx.compose.material3.IconButtonDefaults.filledIconButtonColors(
|
||||||
|
containerColor = if (navigator.lyricsOpen) MaterialTheme.colorScheme.primary
|
||||||
|
else MaterialTheme.colorScheme.surfaceContainerHighest,
|
||||||
|
contentColor = if (navigator.lyricsOpen) MaterialTheme.colorScheme.onPrimary
|
||||||
|
else MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
) {
|
||||||
|
LyricsGlyph(Modifier.size(22.dp))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A card with two lines of text on it - read as "lyrics"/"captions" at a glance, which no icon in
|
||||||
|
* the core Material set does (the closest, a bullet list, reads as a menu, not song text). Drawn
|
||||||
|
* rather than a vector asset since the whole glyph is two shapes.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
private fun LyricsGlyph(modifier: Modifier = Modifier) {
|
||||||
|
val tint = androidx.compose.material3.LocalContentColor.current
|
||||||
|
androidx.compose.foundation.Canvas(modifier) {
|
||||||
|
val stroke = size.minDimension * 0.11f
|
||||||
|
drawRoundRect(
|
||||||
|
color = tint,
|
||||||
|
cornerRadius = androidx.compose.ui.geometry.CornerRadius(size.minDimension * 0.22f),
|
||||||
|
style = androidx.compose.ui.graphics.drawscope.Stroke(width = stroke),
|
||||||
|
)
|
||||||
|
val lineStyle = androidx.compose.ui.graphics.drawscope.Stroke(width = stroke * 0.85f, cap = androidx.compose.ui.graphics.StrokeCap.Round)
|
||||||
|
val startX = size.width * 0.24f
|
||||||
|
listOf(0.40f, 0.64f).forEach { fy ->
|
||||||
|
val endX = size.width * (if (fy < 0.5f) 0.76f else 0.62f)
|
||||||
|
drawLine(tint, androidx.compose.ui.geometry.Offset(startX, size.height * fy), androidx.compose.ui.geometry.Offset(endX, size.height * fy), strokeWidth = lineStyle.width, cap = lineStyle.cap)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A filled lightning bolt, next to the battery percentage instead of the word "charging" - the
|
||||||
|
* core Material icon set this project ships (no extended pack, to keep the app small) has no
|
||||||
|
* bolt, so this is drawn the same way [LyricsGlyph] is.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
private fun ChargingGlyph(modifier: Modifier = Modifier, tint: Color = MaterialTheme.colorScheme.onSurfaceVariant) {
|
||||||
|
androidx.compose.foundation.Canvas(modifier) {
|
||||||
|
val w = size.width
|
||||||
|
val h = size.height
|
||||||
|
val bolt = androidx.compose.ui.graphics.Path().apply {
|
||||||
|
moveTo(w * 0.58f, 0f)
|
||||||
|
lineTo(w * 0.12f, h * 0.58f)
|
||||||
|
lineTo(w * 0.46f, h * 0.58f)
|
||||||
|
lineTo(w * 0.42f, h)
|
||||||
|
lineTo(w * 0.88f, h * 0.40f)
|
||||||
|
lineTo(w * 0.54f, h * 0.40f)
|
||||||
|
close()
|
||||||
|
}
|
||||||
|
drawPath(bolt, tint)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,10 +2,13 @@ package app.seb3thehacker.gearslip.car
|
|||||||
|
|
||||||
import androidx.compose.foundation.Image
|
import androidx.compose.foundation.Image
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.rememberScrollState
|
||||||
|
import androidx.compose.foundation.verticalScroll
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import android.provider.Settings
|
import android.provider.Settings
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.BoxWithConstraints
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
import androidx.compose.foundation.layout.Spacer
|
import androidx.compose.foundation.layout.Spacer
|
||||||
@@ -62,6 +65,7 @@ import app.seb3thehacker.gearslip.media.LyricsResult
|
|||||||
import app.seb3thehacker.gearslip.media.Lyrics
|
import app.seb3thehacker.gearslip.media.Lyrics
|
||||||
import app.seb3thehacker.gearslip.media.MediaEntry
|
import app.seb3thehacker.gearslip.media.MediaEntry
|
||||||
import app.seb3thehacker.gearslip.media.NowPlaying
|
import app.seb3thehacker.gearslip.media.NowPlaying
|
||||||
|
import app.seb3thehacker.gearslip.media.QueueTrack
|
||||||
import android.media.session.PlaybackState
|
import android.media.session.PlaybackState
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -74,13 +78,11 @@ fun MediaScreen(app: MediaApp, onExit: () -> Unit) {
|
|||||||
val media = CarServices.media
|
val media = CarServices.media
|
||||||
val phase by media.phase.collectAsStateWithLifecycle()
|
val phase by media.phase.collectAsStateWithLifecycle()
|
||||||
val now by media.now.collectAsStateWithLifecycle()
|
val now by media.now.collectAsStateWithLifecycle()
|
||||||
|
val queue by media.queue.collectAsStateWithLifecycle()
|
||||||
val browse by media.browse.collectAsStateWithLifecycle()
|
val browse by media.browse.collectAsStateWithLifecycle()
|
||||||
val rejection by media.rejection.collectAsStateWithLifecycle()
|
val rejection by media.rejection.collectAsStateWithLifecycle()
|
||||||
val capture by CarAudio.capture.collectAsStateWithLifecycle()
|
val capture by CarAudio.capture.collectAsStateWithLifecycle()
|
||||||
val navigator = LocalCarNavigator.current
|
var tab by remember { mutableStateOf(Tab.NOW_PLAYING) }
|
||||||
var tab by remember {
|
|
||||||
mutableStateOf(if (navigator.lyricsRequested) Tab.LYRICS else Tab.BROWSE).also { navigator.lyricsRequested = false }
|
|
||||||
}
|
|
||||||
|
|
||||||
// The connection outlives this screen: the home screen's player is the same one.
|
// The connection outlives this screen: the home screen's player is the same one.
|
||||||
LaunchedEffect(app) { CarServices.openMedia(app) }
|
LaunchedEffect(app) { CarServices.openMedia(app) }
|
||||||
@@ -118,19 +120,27 @@ fun MediaScreen(app: MediaApp, onExit: () -> Unit) {
|
|||||||
Modifier.fillMaxSize().padding(top = 6.dp),
|
Modifier.fillMaxSize().padding(top = 6.dp),
|
||||||
horizontalArrangement = Arrangement.spacedBy(16.dp),
|
horizontalArrangement = Arrangement.spacedBy(16.dp),
|
||||||
) {
|
) {
|
||||||
NowPlayingPanel(now, media, Modifier.width(250.dp).fillMaxHeight())
|
// The art rail sits at the top, level with the tab row beside it, on every tab -
|
||||||
|
// not under a full-width tab bar the way it used to be. Transport and the
|
||||||
|
// progress bar stay on it always; only the app's extra buttons (shuffle, repeat,
|
||||||
|
// like) are exclusive to the "Now playing" tab's own content area, since those are
|
||||||
|
// the one thing that isn't already always visible here.
|
||||||
|
NowPlayingRail(now, media, Modifier.width(220.dp).fillMaxHeight())
|
||||||
Column(Modifier.weight(1f).fillMaxHeight()) {
|
Column(Modifier.weight(1f).fillMaxHeight()) {
|
||||||
Row {
|
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||||
TextButton(onClick = { tab = Tab.BROWSE }) {
|
MediaTab("Now playing", tab == Tab.NOW_PLAYING, Modifier.weight(1f)) { tab = Tab.NOW_PLAYING }
|
||||||
Text("Browse", fontWeight = if (tab == Tab.BROWSE) FontWeight.Bold else FontWeight.Normal)
|
MediaTab("Browse", tab == Tab.BROWSE, Modifier.weight(1f)) { tab = Tab.BROWSE }
|
||||||
}
|
MediaTab("Up next", tab == Tab.QUEUE, Modifier.weight(1f)) { tab = Tab.QUEUE }
|
||||||
TextButton(onClick = { tab = Tab.LYRICS }) {
|
MediaTab("Lyrics", tab == Tab.LYRICS, Modifier.weight(1f)) { tab = Tab.LYRICS }
|
||||||
Text("Lyrics", fontWeight = if (tab == Tab.LYRICS) FontWeight.Bold else FontWeight.Normal)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
when (tab) {
|
Spacer(Modifier.height(8.dp))
|
||||||
Tab.BROWSE -> BrowsePanel(browse, media, app.label, Modifier.fillMaxSize())
|
Box(Modifier.weight(1f).fillMaxWidth()) {
|
||||||
Tab.LYRICS -> LyricsPanel(now, Modifier.fillMaxSize())
|
when (tab) {
|
||||||
|
Tab.NOW_PLAYING -> NowPlayingExtras(now, media, Modifier.fillMaxSize())
|
||||||
|
Tab.BROWSE -> BrowsePanel(browse, media, app.label, Modifier.fillMaxSize())
|
||||||
|
Tab.QUEUE -> QueuePanel(queue, now, media, Modifier.fillMaxSize())
|
||||||
|
Tab.LYRICS -> LyricsPanel(now, Modifier.fillMaxSize())
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -138,14 +148,30 @@ fun MediaScreen(app: MediaApp, onExit: () -> Unit) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private enum class Tab { BROWSE, LYRICS }
|
private enum class Tab { NOW_PLAYING, BROWSE, QUEUE, LYRICS }
|
||||||
|
|
||||||
|
/** A bigger, easier-to-hit tab than a plain [TextButton] - this row is the only navigation on the screen. */
|
||||||
|
@Composable
|
||||||
|
private fun MediaTab(label: String, selected: Boolean, modifier: Modifier, onClick: () -> Unit) {
|
||||||
|
Surface(
|
||||||
|
onClick = onClick,
|
||||||
|
modifier = modifier.height(48.dp),
|
||||||
|
shape = MaterialTheme.shapes.large,
|
||||||
|
color = if (selected) MaterialTheme.colorScheme.primaryContainer else MaterialTheme.colorScheme.surfaceContainerHigh,
|
||||||
|
contentColor = if (selected) MaterialTheme.colorScheme.onPrimaryContainer else MaterialTheme.colorScheme.onSurface,
|
||||||
|
) {
|
||||||
|
Row(Modifier.fillMaxSize(), horizontalArrangement = Arrangement.Center, verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
Text(label, style = MaterialTheme.typography.titleMedium, fontWeight = if (selected) FontWeight.Bold else FontWeight.Normal)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The song's lyrics, fetched when this tab opens. Synced lyrics follow the playback position with
|
* The song's lyrics, fetched when this tab opens. Synced lyrics follow the playback position with
|
||||||
* the current line in the accent colour; unsynced ones are plain scrolling text.
|
* the current line in the accent colour; unsynced ones are plain scrolling text.
|
||||||
*/
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
private fun LyricsPanel(now: NowPlaying, modifier: Modifier) {
|
internal fun LyricsPanel(now: NowPlaying, modifier: Modifier) {
|
||||||
val lyrics by produceState<LyricsState>(LyricsState.Loading, now.title, now.artist, now.album, now.durationMs) {
|
val lyrics by produceState<LyricsState>(LyricsState.Loading, now.title, now.artist, now.album, now.durationMs) {
|
||||||
value = LyricsState.Loading
|
value = LyricsState.Loading
|
||||||
value = Lyrics.find(now.title, now.artist, now.album, now.durationMs)
|
value = Lyrics.find(now.title, now.artist, now.album, now.durationMs)
|
||||||
@@ -230,10 +256,24 @@ private fun Notice(title: String, detail: String?, action: Pair<String, () -> Un
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** "1:05" for under a minute past the hour, "1:01:05" once an hour is involved. */
|
||||||
|
private fun formatDuration(ms: Long): String {
|
||||||
|
val totalSeconds = (ms / 1000).coerceAtLeast(0)
|
||||||
|
val hours = totalSeconds / 3600
|
||||||
|
val minutes = (totalSeconds % 3600) / 60
|
||||||
|
val seconds = totalSeconds % 60
|
||||||
|
return if (hours > 0) "%d:%02d:%02d".format(hours, minutes, seconds) else "%d:%02d".format(minutes, seconds)
|
||||||
|
}
|
||||||
|
|
||||||
// --- now playing -----------------------------------------------------------------------------
|
// --- now playing -----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The art rail beside every tab: art, title, transport and the seek bar - always visible no
|
||||||
|
* matter which tab is open. Only the app's extra buttons (shuffle, repeat, like) are held back
|
||||||
|
* for [NowPlayingExtras], since those are the one thing that isn't already always shown here.
|
||||||
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
private fun NowPlayingPanel(now: NowPlaying, media: CarMedia, modifier: Modifier) {
|
private fun NowPlayingRail(now: NowPlaying, media: CarMedia, modifier: Modifier) {
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
val art by produceState(now.art, now.art, now.artUri) {
|
val art by produceState(now.art, now.art, now.artUri) {
|
||||||
value = now.art ?: MediaArt.load(context, now.artUri)
|
value = now.art ?: MediaArt.load(context, now.artUri)
|
||||||
@@ -246,55 +286,76 @@ private fun NowPlayingPanel(now: NowPlaying, media: CarMedia, modifier: Modifier
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Column(modifier, horizontalAlignment = Alignment.CenterHorizontally) {
|
// Whether the progress bar (and its duration text) is showing depends on whether the app has
|
||||||
Surface(
|
// reported a duration yet, which can arrive a beat after everything else - so this rail's
|
||||||
shape = MaterialTheme.shapes.large,
|
// content height isn't fixed. A plain fillMaxHeight Column just clips whatever doesn't fit
|
||||||
color = MaterialTheme.colorScheme.surfaceVariant,
|
// once that row appears; scrolling means it's always reachable instead.
|
||||||
modifier = Modifier.size(170.dp),
|
BoxWithConstraints(modifier) {
|
||||||
) {
|
// Off the space actually available rather than a fixed dp value, so it doesn't overrun a
|
||||||
art?.let {
|
// short car frame the way a flat size did on anything shorter than a phone screen.
|
||||||
Image(it.asImageBitmap(), null, Modifier.fillMaxSize().clip(MaterialTheme.shapes.large), contentScale = ContentScale.Crop)
|
val artSize = minOf(maxWidth, maxHeight * 0.45f).coerceAtLeast(96.dp)
|
||||||
|
Column(Modifier.verticalScroll(rememberScrollState())) {
|
||||||
|
Surface(
|
||||||
|
shape = MaterialTheme.shapes.large,
|
||||||
|
color = MaterialTheme.colorScheme.surfaceVariant,
|
||||||
|
modifier = Modifier.size(artSize),
|
||||||
|
) {
|
||||||
|
art?.let {
|
||||||
|
Image(it.asImageBitmap(), null, Modifier.fillMaxSize().clip(MaterialTheme.shapes.large), contentScale = ContentScale.Crop)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
Spacer(Modifier.height(16.dp))
|
||||||
Spacer(Modifier.height(8.dp))
|
Text(
|
||||||
Text(
|
now.title.ifEmpty { "Nothing playing" },
|
||||||
now.title.ifEmpty { "Nothing playing" },
|
style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold,
|
||||||
style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold,
|
maxLines = 2, overflow = TextOverflow.Ellipsis,
|
||||||
maxLines = 1, overflow = TextOverflow.Ellipsis,
|
|
||||||
)
|
|
||||||
Text(
|
|
||||||
listOf(now.artist, now.album).filter { it.isNotEmpty() }.joinToString(" - "),
|
|
||||||
style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
||||||
maxLines = 1, overflow = TextOverflow.Ellipsis,
|
|
||||||
)
|
|
||||||
now.error?.let { Text(it, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.error, maxLines = 2) }
|
|
||||||
|
|
||||||
if (now.durationMs > 0) {
|
|
||||||
WavyProgress(
|
|
||||||
fraction = position.toFloat() / now.durationMs,
|
|
||||||
playing = now.state == PlaybackState.STATE_PLAYING,
|
|
||||||
onSeek = { media.seek((it * now.durationMs).toLong()) },
|
|
||||||
modifier = Modifier.padding(vertical = 4.dp),
|
|
||||||
)
|
)
|
||||||
} else {
|
Text(
|
||||||
Spacer(Modifier.height(14.dp))
|
listOf(now.artist, now.album).filter { it.isNotEmpty() }.joinToString(" - "),
|
||||||
}
|
style = MaterialTheme.typography.bodyLarge, color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
maxLines = 1, overflow = TextOverflow.Ellipsis,
|
||||||
|
)
|
||||||
|
now.error?.let { Text(it, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.error, maxLines = 2) }
|
||||||
|
Spacer(Modifier.height(16.dp))
|
||||||
|
|
||||||
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
if (now.durationMs > 0) {
|
||||||
IconButton(onClick = media::previous, enabled = now.canDo(PlaybackState.ACTION_SKIP_TO_PREVIOUS), modifier = Modifier.size(56.dp)) {
|
WavyProgress(
|
||||||
Icon(MediaIcons.Previous, "Previous", Modifier.size(32.dp))
|
fraction = position.toFloat() / now.durationMs,
|
||||||
|
playing = now.state == PlaybackState.STATE_PLAYING,
|
||||||
|
onSeek = { media.seek((it * now.durationMs).toLong()) },
|
||||||
|
modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp),
|
||||||
|
)
|
||||||
|
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {
|
||||||
|
Text(formatDuration(position), style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||||
|
Text(formatDuration(now.durationMs), style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||||
|
}
|
||||||
|
Spacer(Modifier.height(12.dp))
|
||||||
}
|
}
|
||||||
FilledIconButton(onClick = media::togglePlay, modifier = Modifier.size(68.dp)) {
|
|
||||||
Icon(if (now.playing) MediaIcons.Pause else Icons.Filled.PlayArrow, if (now.playing) "Pause" else "Play", Modifier.size(40.dp))
|
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||||
}
|
IconButton(onClick = media::previous, enabled = now.canDo(PlaybackState.ACTION_SKIP_TO_PREVIOUS), modifier = Modifier.size(56.dp)) {
|
||||||
IconButton(onClick = media::next, enabled = now.canDo(PlaybackState.ACTION_SKIP_TO_NEXT), modifier = Modifier.size(56.dp)) {
|
Icon(MediaIcons.Previous, "Previous", Modifier.size(32.dp))
|
||||||
Icon(MediaIcons.Next, "Next", Modifier.size(32.dp))
|
}
|
||||||
|
FilledIconButton(onClick = media::togglePlay, modifier = Modifier.size(68.dp)) {
|
||||||
|
Icon(if (now.playing) MediaIcons.Pause else Icons.Filled.PlayArrow, if (now.playing) "Pause" else "Play", Modifier.size(40.dp))
|
||||||
|
}
|
||||||
|
IconButton(onClick = media::next, enabled = now.canDo(PlaybackState.ACTION_SKIP_TO_NEXT), modifier = Modifier.size(56.dp)) {
|
||||||
|
Icon(MediaIcons.Next, "Next", Modifier.size(32.dp))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Whatever extra buttons the app publishes (shuffle, repeat, like), by name: their
|
/**
|
||||||
// icons are resources inside the app's own package.
|
* The "Now playing" tab's own content: just the app's extra buttons (shuffle, repeat, like), by
|
||||||
Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) {
|
* name - their icons are resources inside the app's own package, so there's nothing to draw but
|
||||||
|
* the label. Transport and progress already live on [NowPlayingRail], visible on every tab.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
private fun NowPlayingExtras(now: NowPlaying, media: CarMedia, modifier: Modifier) {
|
||||||
|
Column(modifier, verticalArrangement = Arrangement.Center) {
|
||||||
|
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
now.custom.take(3).forEach { action ->
|
now.custom.take(3).forEach { action ->
|
||||||
TextButton(onClick = { media.custom(action) }) { Text(action.name, maxLines = 1, overflow = TextOverflow.Ellipsis) }
|
TextButton(onClick = { media.custom(action) }) { Text(action.name, maxLines = 1, overflow = TextOverflow.Ellipsis) }
|
||||||
}
|
}
|
||||||
@@ -307,15 +368,22 @@ private fun NowPlayingPanel(now: NowPlaying, media: CarMedia, modifier: Modifier
|
|||||||
@Composable
|
@Composable
|
||||||
private fun BrowsePanel(browse: app.seb3thehacker.gearslip.media.BrowseState, media: CarMedia, appLabel: String, modifier: Modifier) {
|
private fun BrowsePanel(browse: app.seb3thehacker.gearslip.media.BrowseState, media: CarMedia, appLabel: String, modifier: Modifier) {
|
||||||
Column(modifier) {
|
Column(modifier) {
|
||||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
// At the library root there's no trail and nowhere to go up to, so there's nothing this
|
||||||
if (media.canGoUp) {
|
// row could say that the app's name in the top-left corner hasn't already - showing it
|
||||||
IconButton(onClick = media::up) { Icon(Icons.Filled.ArrowBack, "Up") }
|
// again here (as "Browse", or as the app's own name) was just the same label twice.
|
||||||
|
if (media.canGoUp || browse.trail.isNotEmpty()) {
|
||||||
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
if (media.canGoUp) {
|
||||||
|
IconButton(onClick = media::up) { Icon(Icons.Filled.ArrowBack, "Up") }
|
||||||
|
}
|
||||||
|
browse.trail.lastOrNull()?.let {
|
||||||
|
Text(
|
||||||
|
it,
|
||||||
|
style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold,
|
||||||
|
maxLines = 1, overflow = TextOverflow.Ellipsis,
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
Text(
|
|
||||||
browse.trail.lastOrNull() ?: "Browse",
|
|
||||||
style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold,
|
|
||||||
maxLines = 1, overflow = TextOverflow.Ellipsis,
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
when {
|
when {
|
||||||
browse.loading -> Notice("Loading…", null)
|
browse.loading -> Notice("Loading…", null)
|
||||||
@@ -332,6 +400,61 @@ private fun BrowsePanel(browse: app.seb3thehacker.gearslip.media.BrowseState, me
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- up next -----------------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whatever the app has queued up, starting right after the track that's playing now - not the
|
||||||
|
* whole queue from the top, which would just repeat what [NowPlayingRail] already shows. Apps
|
||||||
|
* that never publish a queue (many don't) show an empty notice rather than a permanently blank tab.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
private fun QueuePanel(queue: List<QueueTrack>, now: NowPlaying, media: CarMedia, modifier: Modifier) {
|
||||||
|
val upNext = remember(queue, now.activeQueueItemId) {
|
||||||
|
val current = queue.indexOfFirst { it.queueId == now.activeQueueItemId }
|
||||||
|
if (current >= 0) queue.drop(current + 1) else queue
|
||||||
|
}
|
||||||
|
Column(modifier) {
|
||||||
|
if (upNext.isEmpty()) {
|
||||||
|
Notice(
|
||||||
|
"Nothing queued.",
|
||||||
|
"${if (now.title.isEmpty()) "Nothing playing" else "Only what's already playing"} - " +
|
||||||
|
"not every app tells Gearslip what comes next.",
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
LazyColumn(Modifier.fillMaxSize(), verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
|
items(upNext, key = { it.queueId }) { track -> QueueRow(track, media) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun QueueRow(track: QueueTrack, media: CarMedia) {
|
||||||
|
val context = LocalContext.current
|
||||||
|
val icon by produceState(track.iconBitmap, track.iconUri) {
|
||||||
|
value = track.iconBitmap ?: MediaArt.load(context, track.iconUri)
|
||||||
|
}
|
||||||
|
Surface(
|
||||||
|
onClick = { media.playQueueItem(track) },
|
||||||
|
shape = MaterialTheme.shapes.large,
|
||||||
|
color = MaterialTheme.colorScheme.surfaceVariant,
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
) {
|
||||||
|
Row(Modifier.padding(10.dp), verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
Box(Modifier.size(52.dp).clip(MaterialTheme.shapes.medium).background(MaterialTheme.colorScheme.surface)) {
|
||||||
|
icon?.let { Image(it.asImageBitmap(), null, Modifier.fillMaxSize(), contentScale = ContentScale.Crop) }
|
||||||
|
}
|
||||||
|
Column(Modifier.weight(1f).padding(horizontal = 12.dp)) {
|
||||||
|
Text(track.title, style = MaterialTheme.typography.titleSmall, maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||||
|
if (track.subtitle.isNotEmpty()) {
|
||||||
|
Text(track.subtitle, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Icon(Icons.Filled.PlayArrow, "Play", tint = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun EntryRow(entry: MediaEntry, media: CarMedia) {
|
private fun EntryRow(entry: MediaEntry, media: CarMedia) {
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
|
|||||||
@@ -0,0 +1,374 @@
|
|||||||
|
package app.seb3thehacker.gearslip.car
|
||||||
|
|
||||||
|
import android.Manifest
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
import android.content.pm.PackageManager
|
||||||
|
import android.net.Uri
|
||||||
|
import android.provider.ContactsContract
|
||||||
|
import android.provider.Settings
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.fillMaxHeight
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
|
import androidx.compose.foundation.lazy.items
|
||||||
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.Call
|
||||||
|
import androidx.compose.material3.Button
|
||||||
|
import androidx.compose.material3.FilledIconButton
|
||||||
|
import androidx.compose.material3.FilledTonalButton
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.OutlinedButton
|
||||||
|
import androidx.compose.material3.Surface
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.produceState
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
|
||||||
|
/** A phone contact with at least one number, as read off the device's contacts. */
|
||||||
|
private data class Contact(val name: String, val number: String)
|
||||||
|
|
||||||
|
private val CALL_PERMISSIONS = arrayOf(Manifest.permission.CALL_PHONE, Manifest.permission.READ_CONTACTS)
|
||||||
|
|
||||||
|
private fun hasCallPermissions(context: Context): Boolean =
|
||||||
|
CALL_PERMISSIONS.all { context.checkSelfPermission(it) == PackageManager.PERMISSION_GRANTED }
|
||||||
|
|
||||||
|
private fun loadContacts(context: Context): List<Contact> {
|
||||||
|
if (context.checkSelfPermission(Manifest.permission.READ_CONTACTS) != PackageManager.PERMISSION_GRANTED) return emptyList()
|
||||||
|
val out = mutableListOf<Contact>()
|
||||||
|
val projection = arrayOf(
|
||||||
|
ContactsContract.CommonDataKinds.Phone.DISPLAY_NAME,
|
||||||
|
ContactsContract.CommonDataKinds.Phone.NUMBER,
|
||||||
|
)
|
||||||
|
context.contentResolver.query(
|
||||||
|
ContactsContract.CommonDataKinds.Phone.CONTENT_URI,
|
||||||
|
projection, null, null,
|
||||||
|
"${ContactsContract.CommonDataKinds.Phone.DISPLAY_NAME} ASC",
|
||||||
|
)?.use { cursor ->
|
||||||
|
val nameIndex = cursor.getColumnIndex(ContactsContract.CommonDataKinds.Phone.DISPLAY_NAME)
|
||||||
|
val numberIndex = cursor.getColumnIndex(ContactsContract.CommonDataKinds.Phone.NUMBER)
|
||||||
|
while (cursor.moveToNext()) {
|
||||||
|
val name = cursor.getString(nameIndex) ?: continue
|
||||||
|
val number = cursor.getString(numberIndex) ?: continue
|
||||||
|
out += Contact(name, number)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out.distinctBy { it.number }
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun placeCall(context: Context, number: String) {
|
||||||
|
if (number.isBlank()) return
|
||||||
|
if (context.checkSelfPermission(Manifest.permission.CALL_PHONE) != PackageManager.PERMISSION_GRANTED) return
|
||||||
|
runCatching {
|
||||||
|
context.startActivity(
|
||||||
|
Intent(Intent.ACTION_CALL, Uri.parse("tel:${Uri.encode(number)}")).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private const val TAB_CONTACTS = 0
|
||||||
|
private const val TAB_KEYPAD = 1
|
||||||
|
|
||||||
|
/** Dialing a number or calling a contact from the car screen: a contacts tab and a keypad tab. */
|
||||||
|
@Composable
|
||||||
|
fun PhoneDialerScreen() {
|
||||||
|
val context = LocalContext.current
|
||||||
|
val hasPermissions = hasCallPermissions(context)
|
||||||
|
var tab by remember { mutableStateOf(TAB_CONTACTS) }
|
||||||
|
|
||||||
|
Column(Modifier.fillMaxSize().padding(16.dp), verticalArrangement = Arrangement.spacedBy(16.dp)) {
|
||||||
|
Text(
|
||||||
|
"Phone",
|
||||||
|
style = MaterialTheme.typography.headlineSmall,
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
modifier = Modifier.padding(vertical = 4.dp),
|
||||||
|
)
|
||||||
|
|
||||||
|
if (!hasPermissions) {
|
||||||
|
MissingPermission(context)
|
||||||
|
return@Column
|
||||||
|
}
|
||||||
|
|
||||||
|
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
|
DialerTab("Contacts", tab == TAB_CONTACTS, Modifier.weight(1f)) { tab = TAB_CONTACTS }
|
||||||
|
DialerTab("Keypad", tab == TAB_KEYPAD, Modifier.weight(1f)) { tab = TAB_KEYPAD }
|
||||||
|
}
|
||||||
|
|
||||||
|
Box(Modifier.weight(1f).fillMaxWidth()) {
|
||||||
|
if (tab == TAB_CONTACTS) ContactsList(context) else Keypad(context)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun MissingPermission(context: Context) {
|
||||||
|
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||||
|
Text(
|
||||||
|
"Gearslip needs the Phone and Contacts permissions to dial from here. Grant them " +
|
||||||
|
"from the app's settings.",
|
||||||
|
style = MaterialTheme.typography.bodyLarge,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
FilledTonalButton(onClick = {
|
||||||
|
context.startActivity(
|
||||||
|
Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS, Uri.fromParts("package", context.packageName, null)),
|
||||||
|
)
|
||||||
|
}) { Text("Open app settings") }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun DialerTab(label: String, selected: Boolean, modifier: Modifier, onClick: () -> Unit) {
|
||||||
|
Surface(
|
||||||
|
onClick = onClick,
|
||||||
|
modifier = modifier.height(52.dp),
|
||||||
|
shape = MaterialTheme.shapes.large,
|
||||||
|
color = if (selected) MaterialTheme.colorScheme.primaryContainer else MaterialTheme.colorScheme.surfaceContainerHigh,
|
||||||
|
contentColor = if (selected) MaterialTheme.colorScheme.onPrimaryContainer else MaterialTheme.colorScheme.onSurface,
|
||||||
|
) {
|
||||||
|
Row(Modifier.fillMaxSize(), horizontalArrangement = Arrangement.Center, verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
Text(label, style = MaterialTheme.typography.titleMedium, fontWeight = if (selected) FontWeight.Bold else FontWeight.Normal)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A 3-column digit grid beside a fourth column holding backspace and call, stacked - four equal
|
||||||
|
* columns in all. Every row and both side buttons take an equal weighted share of whatever height
|
||||||
|
* is available, so this fits the car's short landscape frame without scrolling instead of
|
||||||
|
* overflowing off the bottom the way fixed-height rows did.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
private fun Keypad(context: Context) {
|
||||||
|
var number by remember { mutableStateOf("") }
|
||||||
|
val rows = listOf(
|
||||||
|
listOf("1", "2", "3"),
|
||||||
|
listOf("4", "5", "6"),
|
||||||
|
listOf("7", "8", "9"),
|
||||||
|
listOf("*", "0", "#"),
|
||||||
|
)
|
||||||
|
|
||||||
|
Column(Modifier.fillMaxSize(), verticalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||||
|
Text(
|
||||||
|
number.ifEmpty { "Enter a number" },
|
||||||
|
style = MaterialTheme.typography.headlineSmall,
|
||||||
|
color = if (number.isEmpty()) MaterialTheme.colorScheme.onSurfaceVariant else MaterialTheme.colorScheme.onSurface,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
modifier = Modifier.padding(vertical = 6.dp),
|
||||||
|
)
|
||||||
|
Row(Modifier.weight(1f).fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
|
Column(Modifier.weight(3f).fillMaxHeight(), verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
|
rows.forEach { row ->
|
||||||
|
Row(Modifier.weight(1f).fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
|
row.forEach { digit ->
|
||||||
|
KeypadButton(digit, Modifier.weight(1f).fillMaxHeight()) { number += digit }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Column(Modifier.weight(1f).fillMaxHeight(), verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
|
FilledTonalButton(
|
||||||
|
onClick = { if (number.isNotEmpty()) number = number.dropLast(1) },
|
||||||
|
enabled = number.isNotEmpty(),
|
||||||
|
modifier = Modifier.weight(1f).fillMaxWidth(),
|
||||||
|
) { Text("⌫") }
|
||||||
|
FilledIconButton(
|
||||||
|
onClick = { placeCall(context, number) },
|
||||||
|
enabled = number.isNotEmpty(),
|
||||||
|
modifier = Modifier.weight(1f).fillMaxWidth(),
|
||||||
|
colors = androidx.compose.material3.IconButtonDefaults.filledIconButtonColors(
|
||||||
|
containerColor = MaterialTheme.colorScheme.primary,
|
||||||
|
contentColor = MaterialTheme.colorScheme.onPrimary,
|
||||||
|
),
|
||||||
|
) { Icon(Icons.Filled.Call, "Call") }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun KeypadButton(digit: String, modifier: Modifier, onClick: () -> Unit) {
|
||||||
|
Surface(
|
||||||
|
onClick = onClick,
|
||||||
|
modifier = modifier,
|
||||||
|
shape = MaterialTheme.shapes.large,
|
||||||
|
color = MaterialTheme.colorScheme.surfaceContainerHigh,
|
||||||
|
) {
|
||||||
|
Row(Modifier.fillMaxSize(), horizontalArrangement = Arrangement.Center, verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
Text(digit, style = MaterialTheme.typography.titleLarge)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun ContactsList(context: Context) {
|
||||||
|
val contacts by produceState(emptyList<Contact>(), context) {
|
||||||
|
value = withContext(Dispatchers.IO) { loadContacts(context) }
|
||||||
|
}
|
||||||
|
var query by remember { mutableStateOf("") }
|
||||||
|
var searching by remember { mutableStateOf(false) }
|
||||||
|
var pendingCall by remember { mutableStateOf<Contact?>(null) }
|
||||||
|
|
||||||
|
val filtered = remember(contacts, query) {
|
||||||
|
if (query.isBlank()) contacts
|
||||||
|
else contacts.filter { it.name.contains(query, ignoreCase = true) || it.number.contains(query) }
|
||||||
|
}
|
||||||
|
|
||||||
|
Column(Modifier.fillMaxSize(), verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
|
if (contacts.isNotEmpty()) {
|
||||||
|
ContactSearchField(query, searching, onToggle = { searching = !searching })
|
||||||
|
}
|
||||||
|
// The list (and its empty states) sit between the search field and the keyboard, never
|
||||||
|
// under either - the keyboard is docked at the bottom instead of jammed in right below
|
||||||
|
// the field, so results stay visible while typing rather than getting squeezed out.
|
||||||
|
Box(Modifier.weight(1f).fillMaxWidth()) {
|
||||||
|
when {
|
||||||
|
contacts.isEmpty() -> Text(
|
||||||
|
"No contacts with a phone number found.",
|
||||||
|
style = MaterialTheme.typography.bodyLarge,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
filtered.isEmpty() -> Text(
|
||||||
|
"No contacts match \"$query\".",
|
||||||
|
style = MaterialTheme.typography.bodyLarge,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
else -> LazyColumn(Modifier.fillMaxSize(), verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
|
items(filtered) { contact ->
|
||||||
|
ContactRow(contact) { pendingCall = contact }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (searching) {
|
||||||
|
CarKeyboard(query, onTextChange = { query = it }, onSubmit = { searching = false })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pendingCall?.let { contact ->
|
||||||
|
ConfirmCallOverlay(
|
||||||
|
contact = contact,
|
||||||
|
onConfirm = { placeCall(context, contact.number); pendingCall = null },
|
||||||
|
onDismiss = { pendingCall = null },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A plain Compose overlay, not [androidx.compose.material3.AlertDialog] - that opens a real
|
||||||
|
* Android Dialog/window, and this UI lives in a Presentation on a virtual display where a second
|
||||||
|
* window of the wrong type crashes outright ("Window type mismatch"). Every other popup in the
|
||||||
|
* car UI is drawn in-composition for the same reason; this just follows suit.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
private fun ConfirmCallOverlay(contact: Contact, onConfirm: () -> Unit, onDismiss: () -> Unit) {
|
||||||
|
Box(
|
||||||
|
Modifier.fillMaxSize().background(Color.Black.copy(alpha = 0.6f)).clickable(
|
||||||
|
indication = null,
|
||||||
|
interactionSource = remember { MutableInteractionSource() },
|
||||||
|
onClick = onDismiss,
|
||||||
|
),
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
) {
|
||||||
|
Surface(
|
||||||
|
onClick = {},
|
||||||
|
shape = MaterialTheme.shapes.extraLarge,
|
||||||
|
color = MaterialTheme.colorScheme.surfaceContainerHigh,
|
||||||
|
modifier = Modifier.fillMaxWidth(0.85f).padding(16.dp),
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
Modifier.padding(32.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(20.dp),
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
) {
|
||||||
|
Text("Call ${contact.name}?", style = MaterialTheme.typography.headlineSmall, fontWeight = FontWeight.Bold)
|
||||||
|
Text(contact.number, style = MaterialTheme.typography.titleLarge, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||||
|
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(16.dp)) {
|
||||||
|
OutlinedButton(onClick = onDismiss, modifier = Modifier.weight(1f).height(56.dp)) {
|
||||||
|
Text("Cancel", style = MaterialTheme.typography.titleMedium)
|
||||||
|
}
|
||||||
|
Button(onClick = onConfirm, modifier = Modifier.weight(1f).height(56.dp)) {
|
||||||
|
Text("Call", style = MaterialTheme.typography.titleMedium)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun ContactSearchField(query: String, active: Boolean, onToggle: () -> Unit) {
|
||||||
|
Surface(
|
||||||
|
onClick = onToggle,
|
||||||
|
shape = MaterialTheme.shapes.large,
|
||||||
|
color = if (active) MaterialTheme.colorScheme.primaryContainer else MaterialTheme.colorScheme.surfaceContainerHigh,
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
query.ifEmpty { "Search contacts" },
|
||||||
|
style = MaterialTheme.typography.bodyLarge,
|
||||||
|
color = if (query.isEmpty()) MaterialTheme.colorScheme.onSurfaceVariant else MaterialTheme.colorScheme.onSurface,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
modifier = Modifier.padding(horizontal = 16.dp, vertical = 14.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun ContactRow(contact: Contact, onClick: () -> Unit) {
|
||||||
|
Surface(
|
||||||
|
onClick = onClick,
|
||||||
|
shape = MaterialTheme.shapes.large,
|
||||||
|
color = MaterialTheme.colorScheme.surfaceContainerHigh,
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 12.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(16.dp),
|
||||||
|
) {
|
||||||
|
Box(
|
||||||
|
Modifier.size(40.dp).background(MaterialTheme.colorScheme.primaryContainer, CircleShape),
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
contact.name.take(1).uppercase(),
|
||||||
|
style = MaterialTheme.typography.titleMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onPrimaryContainer,
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Column(Modifier.weight(1f)) {
|
||||||
|
Text(contact.name, style = MaterialTheme.typography.titleMedium, maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||||
|
Text(contact.number, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||||
|
}
|
||||||
|
Icon(Icons.Filled.Call, contentDescription = "Call", tint = MaterialTheme.colorScheme.primary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,9 @@
|
|||||||
package app.seb3thehacker.gearslip.car
|
package app.seb3thehacker.gearslip.car
|
||||||
|
|
||||||
import android.graphics.Rect
|
import android.graphics.Rect
|
||||||
|
import androidx.compose.foundation.Image
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
@@ -13,6 +16,12 @@ import androidx.compose.foundation.layout.padding
|
|||||||
import androidx.compose.foundation.layout.heightIn
|
import androidx.compose.foundation.layout.heightIn
|
||||||
import androidx.compose.foundation.layout.size
|
import androidx.compose.foundation.layout.size
|
||||||
import androidx.compose.foundation.layout.width
|
import androidx.compose.foundation.layout.width
|
||||||
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.Close
|
||||||
|
import androidx.compose.material.icons.filled.KeyboardArrowUp
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.Surface
|
import androidx.compose.material3.Surface
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
@@ -21,20 +30,29 @@ import androidx.compose.runtime.getValue
|
|||||||
import androidx.compose.runtime.collectAsState
|
import androidx.compose.runtime.collectAsState
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.clip
|
||||||
|
import androidx.compose.ui.draw.rotate
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.layout.ContentScale
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import androidx.compose.ui.text.style.TextOverflow
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.car.app.model.CarIcon
|
||||||
import androidx.car.app.model.Template
|
import androidx.car.app.model.Template
|
||||||
import androidx.car.app.model.PlaceListMapTemplate
|
import androidx.car.app.model.PlaceListMapTemplate
|
||||||
|
import androidx.car.app.navigation.model.Lane
|
||||||
|
import androidx.car.app.navigation.model.LaneDirection
|
||||||
import androidx.car.app.navigation.model.MapTemplate
|
import androidx.car.app.navigation.model.MapTemplate
|
||||||
import androidx.car.app.navigation.model.MapWithContentTemplate
|
import androidx.car.app.navigation.model.MapWithContentTemplate
|
||||||
import androidx.car.app.navigation.model.NavigationTemplate
|
import androidx.car.app.navigation.model.NavigationTemplate
|
||||||
import androidx.car.app.navigation.model.PlaceListNavigationTemplate
|
import androidx.car.app.navigation.model.PlaceListNavigationTemplate
|
||||||
import androidx.car.app.navigation.model.RoutePreviewNavigationTemplate
|
import androidx.car.app.navigation.model.RoutePreviewNavigationTemplate
|
||||||
import androidx.car.app.navigation.model.RoutingInfo
|
import androidx.car.app.navigation.model.RoutingInfo
|
||||||
|
import androidx.car.app.navigation.model.Step
|
||||||
import androidx.car.app.navigation.model.TravelEstimate
|
import androidx.car.app.navigation.model.TravelEstimate
|
||||||
|
import app.seb3thehacker.gearslip.host.clockTime
|
||||||
import app.seb3thehacker.gearslip.host.color
|
import app.seb3thehacker.gearslip.host.color
|
||||||
|
import app.seb3thehacker.gearslip.host.image
|
||||||
import app.seb3thehacker.gearslip.host.display
|
import app.seb3thehacker.gearslip.host.display
|
||||||
import app.seb3thehacker.gearslip.host.remainingTime
|
import app.seb3thehacker.gearslip.host.remainingTime
|
||||||
import app.seb3thehacker.gearslip.host.text
|
import app.seb3thehacker.gearslip.host.text
|
||||||
@@ -96,7 +114,7 @@ private fun NavigationChrome(template: NavigationTemplate, modifier: Modifier) {
|
|||||||
Modifier.align(Alignment.TopEnd).padding(12.dp),
|
Modifier.align(Alignment.TopEnd).padding(12.dp),
|
||||||
)
|
)
|
||||||
if (!pan) template.destinationTravelEstimate?.let {
|
if (!pan) template.destinationTravelEstimate?.let {
|
||||||
TravelEstimateBar(it, Modifier.align(Alignment.BottomStart))
|
TravelEstimateBar(it, Modifier.align(Alignment.BottomStart).padding(12.dp))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -106,7 +124,7 @@ private fun NavigationChrome(template: NavigationTemplate, modifier: Modifier) {
|
|||||||
private fun RoutingCard(info: RoutingInfo, modifier: Modifier = Modifier) {
|
private fun RoutingCard(info: RoutingInfo, modifier: Modifier = Modifier) {
|
||||||
if (info.isLoading) {
|
if (info.isLoading) {
|
||||||
ChromeSurface(modifier) {
|
ChromeSurface(modifier) {
|
||||||
Text("Finding a route…", Modifier.padding(16.dp), style = MaterialTheme.typography.titleMedium)
|
Text("Finding a route…", Modifier.padding(16.dp), style = ChromeType.title)
|
||||||
}
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -120,89 +138,174 @@ private fun RoutingCard(info: RoutingInfo, modifier: Modifier = Modifier) {
|
|||||||
Column(Modifier.weight(1f)) {
|
Column(Modifier.weight(1f)) {
|
||||||
val distance = info.currentDistance.display()
|
val distance = info.currentDistance.display()
|
||||||
if (distance.isNotEmpty()) {
|
if (distance.isNotEmpty()) {
|
||||||
Text(
|
Text(distance, style = ChromeType.headline)
|
||||||
distance,
|
|
||||||
style = MaterialTheme.typography.headlineSmall,
|
|
||||||
fontWeight = FontWeight.Bold,
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
Text(
|
Text(step.cue.text(), style = ChromeType.title, maxLines = 2, overflow = TextOverflow.Ellipsis)
|
||||||
step.cue.text(),
|
|
||||||
style = MaterialTheme.typography.titleMedium,
|
|
||||||
maxLines = 2,
|
|
||||||
overflow = TextOverflow.Ellipsis,
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
val road = step.road.text()
|
val road = step.road.text()
|
||||||
if (road.isNotEmpty()) {
|
if (road.isNotEmpty()) {
|
||||||
Text(
|
Text(
|
||||||
road,
|
road,
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
style = ChromeType.body,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
maxLines = 1,
|
maxLines = 1,
|
||||||
overflow = TextOverflow.Ellipsis,
|
overflow = TextOverflow.Ellipsis,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
// Neither is sent by every app - most say only "turn left" and trust the driver to
|
||||||
|
// find the lane themselves - so both are drawn only when there is something to draw.
|
||||||
|
info.junctionImage?.let { junction ->
|
||||||
|
Spacer(Modifier.height(10.dp))
|
||||||
|
JunctionImage(junction)
|
||||||
|
}
|
||||||
|
LaneGuidance(step, Modifier.padding(top = 10.dp))
|
||||||
info.nextStep?.let { next ->
|
info.nextStep?.let { next ->
|
||||||
Spacer(Modifier.height(8.dp))
|
Spacer(Modifier.height(8.dp))
|
||||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
Text(
|
Text("then", style = ChromeType.label, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||||
"then",
|
|
||||||
style = MaterialTheme.typography.labelMedium,
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
||||||
)
|
|
||||||
Spacer(Modifier.width(8.dp))
|
Spacer(Modifier.width(8.dp))
|
||||||
CarGlyph(next.maneuver?.icon, Modifier.size(20.dp))
|
CarGlyph(next.maneuver?.icon, Modifier.size(20.dp))
|
||||||
Spacer(Modifier.width(8.dp))
|
Spacer(Modifier.width(8.dp))
|
||||||
Text(
|
Text(next.cue.text(), style = ChromeType.body, maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||||
next.cue.text(),
|
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
|
||||||
maxLines = 1,
|
|
||||||
overflow = TextOverflow.Ellipsis,
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Time and distance left, along the bottom where it stays out of the map's way. */
|
/** The zoomed-in intersection an app draws for a junction too complex for an arrow to explain. */
|
||||||
|
@Composable
|
||||||
|
private fun JunctionImage(icon: CarIcon, modifier: Modifier = Modifier) {
|
||||||
|
val bitmap = icon.image(LocalContext.current) ?: return
|
||||||
|
Image(
|
||||||
|
bitmap,
|
||||||
|
contentDescription = null,
|
||||||
|
contentScale = ContentScale.Fit,
|
||||||
|
modifier = modifier.fillMaxWidth().heightIn(max = 120.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Which lane to be in before the manoeuvre, either as the app's own picture ([Step.getLanesImage])
|
||||||
|
* or as a row of arrows built from its structured lane data - whichever it sent. The recommended
|
||||||
|
* lane, or lanes, stand out in the theme's own colour; the rest sit on the same muted, solid tile
|
||||||
|
* everything else uses, never dimmed by transparency.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
private fun LaneGuidance(step: Step, modifier: Modifier = Modifier) {
|
||||||
|
step.lanesImage?.let {
|
||||||
|
JunctionImage(it, modifier)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
val lanes = step.lanes.orEmpty()
|
||||||
|
if (lanes.isEmpty()) return
|
||||||
|
Row(modifier, horizontalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||||
|
lanes.forEach { lane -> LaneTile(lane) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun LaneTile(lane: Lane) {
|
||||||
|
val directions = lane.directions.orEmpty()
|
||||||
|
val shown = directions.firstOrNull { it.isRecommended } ?: directions.firstOrNull()
|
||||||
|
val recommended = shown?.isRecommended == true
|
||||||
|
Box(
|
||||||
|
Modifier
|
||||||
|
.size(36.dp)
|
||||||
|
.clip(RoundedCornerShape(8.dp))
|
||||||
|
.background(
|
||||||
|
if (recommended) MaterialTheme.colorScheme.primaryContainer
|
||||||
|
else MaterialTheme.colorScheme.surfaceContainerHighest,
|
||||||
|
),
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
Icons.Filled.KeyboardArrowUp,
|
||||||
|
contentDescription = if (recommended) "Recommended lane" else "Lane",
|
||||||
|
tint = if (recommended) MaterialTheme.colorScheme.onPrimaryContainer
|
||||||
|
else MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
modifier = Modifier.size(24.dp).rotate(shown.turnDegrees()),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The one arrow glyph this app draws, turned to stand in for every shape a lane can permit. */
|
||||||
|
private fun LaneDirection?.turnDegrees(): Float = when (this?.shape) {
|
||||||
|
LaneDirection.SHAPE_SLIGHT_RIGHT -> 30f
|
||||||
|
LaneDirection.SHAPE_NORMAL_RIGHT -> 90f
|
||||||
|
LaneDirection.SHAPE_SHARP_RIGHT -> 135f
|
||||||
|
LaneDirection.SHAPE_U_TURN_RIGHT -> 170f
|
||||||
|
LaneDirection.SHAPE_SLIGHT_LEFT -> -30f
|
||||||
|
LaneDirection.SHAPE_NORMAL_LEFT -> -90f
|
||||||
|
LaneDirection.SHAPE_SHARP_LEFT -> -135f
|
||||||
|
LaneDirection.SHAPE_U_TURN_LEFT -> -170f
|
||||||
|
else -> 0f
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The trip at a glance: time and distance left, the clock time it ends at, and - beside them,
|
||||||
|
* where a thumb reaches - the app's own way to stop navigating. A floating card rather than a
|
||||||
|
* full-width bar, so it reads as one thing beside the manoeuvre card above it instead of a strip
|
||||||
|
* that happens to hold three numbers. The close button is Gearslip's own, not the app's: an
|
||||||
|
* actionStrip's actions carry no flag saying which one (if any) means "stop navigating" - the
|
||||||
|
* first button here turned out to be a mute toggle, not an exit - so this leaves the app the way
|
||||||
|
* going Home already does, rather than guess at the app's own controls and risk getting it wrong.
|
||||||
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
private fun TravelEstimateBar(estimate: TravelEstimate, modifier: Modifier = Modifier) {
|
private fun TravelEstimateBar(estimate: TravelEstimate, modifier: Modifier = Modifier) {
|
||||||
val dark = MaterialTheme.colorScheme.surface.luminanceIsDark()
|
|
||||||
val time = remainingTime(estimate.remainingTimeSeconds)
|
val time = remainingTime(estimate.remainingTimeSeconds)
|
||||||
val distance = estimate.remainingDistance.display()
|
val distance = estimate.remainingDistance.display()
|
||||||
|
val arrival = estimate.arrivalTimeAtDestination.clockTime()
|
||||||
val trip = estimate.tripText.text()
|
val trip = estimate.tripText.text()
|
||||||
if (time.isEmpty() && distance.isEmpty() && trip.isEmpty()) return
|
if (time.isEmpty() && distance.isEmpty() && arrival.isEmpty() && trip.isEmpty()) return
|
||||||
|
|
||||||
Surface(
|
val detail = listOfNotNull(
|
||||||
color = Color.Black.copy(alpha = 0.6f),
|
arrival.takeIf { it.isNotEmpty() },
|
||||||
contentColor = Color.White,
|
distance.takeIf { it.isNotEmpty() },
|
||||||
modifier = modifier.fillMaxWidth(),
|
trip.takeIf { it.isNotEmpty() },
|
||||||
) {
|
).joinToString(" • ")
|
||||||
|
|
||||||
|
// The same solid card the manoeuvre card above it uses - one surface for the whole map
|
||||||
|
// chrome, rather than this one spot styled on its own as a bar over the map.
|
||||||
|
ChromeSurface(modifier, shadowElevation = 8.dp) {
|
||||||
|
val dark = MaterialTheme.colorScheme.surface.luminanceIsDark()
|
||||||
Row(
|
Row(
|
||||||
Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
|
Modifier.padding(start = 8.dp, end = 18.dp, top = 10.dp, bottom = 10.dp),
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
horizontalArrangement = Arrangement.spacedBy(20.dp),
|
|
||||||
) {
|
) {
|
||||||
if (time.isNotEmpty()) {
|
Box(
|
||||||
Text(
|
Modifier
|
||||||
time,
|
.size(40.dp)
|
||||||
style = MaterialTheme.typography.titleLarge,
|
.clip(CircleShape)
|
||||||
fontWeight = FontWeight.Bold,
|
.background(MaterialTheme.colorScheme.surfaceContainerHighest)
|
||||||
color = estimate.remainingTimeColor.color(dark, Color.White),
|
// Not a disconnect: the connection stays up and the app decides its own next
|
||||||
|
// screen. Ending navigation is its own call, separate from a back press - a
|
||||||
|
// back press only pops the on-screen card, leaving the app still believing it
|
||||||
|
// is navigating (so a freshly picked destination silently does nothing); this
|
||||||
|
// is the call that actually clears that flag on the app's side.
|
||||||
|
.clickable { CarServices.nav.stopNavigating() },
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
Icons.Filled.Close,
|
||||||
|
contentDescription = "Stop navigating",
|
||||||
|
tint = MaterialTheme.colorScheme.onSurface,
|
||||||
|
modifier = Modifier.size(20.dp),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
if (distance.isNotEmpty()) {
|
Spacer(Modifier.width(10.dp))
|
||||||
Text(
|
Column {
|
||||||
distance,
|
if (time.isNotEmpty()) {
|
||||||
style = MaterialTheme.typography.titleMedium,
|
Text(
|
||||||
color = estimate.remainingDistanceColor.color(dark, Color.White),
|
time,
|
||||||
)
|
style = ChromeType.headline,
|
||||||
}
|
color = estimate.remainingTimeColor.color(dark, MaterialTheme.colorScheme.onSurface),
|
||||||
if (trip.isNotEmpty()) {
|
)
|
||||||
Text(trip, style = MaterialTheme.typography.bodyMedium, maxLines = 1)
|
}
|
||||||
|
if (detail.isNotEmpty()) {
|
||||||
|
Text(detail, style = ChromeType.body, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -258,7 +361,10 @@ private fun MapWithContentChrome(template: MapWithContentTemplate, modifier: Mod
|
|||||||
template.mapController?.mapActionStrip,
|
template.mapController?.mapActionStrip,
|
||||||
fullHeight = template.contentTemplate.needsFullHeight(),
|
fullHeight = template.contentTemplate.needsFullHeight(),
|
||||||
) {
|
) {
|
||||||
ContentTemplate(template.contentTemplate, Modifier)
|
// A pane floating over the map is a place or a route the app is proposing, so its one
|
||||||
|
// action - Start - counts itself down rather than waiting to be pressed; the same pane
|
||||||
|
// filling a whole screen elsewhere gets no such push.
|
||||||
|
ContentTemplate(template.contentTemplate, Modifier, autoStartPane = true)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -276,7 +382,7 @@ private fun MapChrome(template: MapTemplate, modifier: Modifier) {
|
|||||||
template.header?.startHeaderAction,
|
template.header?.startHeaderAction,
|
||||||
template.header?.endHeaderActions.orEmpty(),
|
template.header?.endHeaderActions.orEmpty(),
|
||||||
)
|
)
|
||||||
if (template.pane != null) PaneRows(template.pane)
|
if (template.pane != null) PaneRows(template.pane, autoStart = true)
|
||||||
else ItemListColumn(template.itemList)
|
else ItemListColumn(template.itemList)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -322,13 +428,13 @@ private fun RoutePreviewChrome(template: RoutePreviewNavigationTemplate, modifie
|
|||||||
)
|
)
|
||||||
Box(Modifier.weight(1f, fill = false)) {
|
Box(Modifier.weight(1f, fill = false)) {
|
||||||
if (template.isLoading) {
|
if (template.isLoading) {
|
||||||
Text("Finding routes…", Modifier.padding(14.dp))
|
Text("Finding routes…", Modifier.padding(14.dp), style = ChromeType.title)
|
||||||
} else {
|
} else {
|
||||||
ItemListColumn(template.itemList)
|
ItemListColumn(template.itemList)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
template.navigateAction?.let {
|
template.navigateAction?.let {
|
||||||
ActionRow(listOf(it), Modifier.padding(12.dp))
|
AutoStartButton(it, Modifier.padding(12.dp))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -346,7 +452,7 @@ private fun ListPane(
|
|||||||
ChromeSurface(modifier, shape = androidx.compose.foundation.shape.RoundedCornerShape(0.dp)) {
|
ChromeSurface(modifier, shape = androidx.compose.foundation.shape.RoundedCornerShape(0.dp)) {
|
||||||
Column {
|
Column {
|
||||||
HeaderBar(title, startAction)
|
HeaderBar(title, startAction)
|
||||||
if (loading) Text("Loading…", Modifier.padding(14.dp))
|
if (loading) Text("Loading…", Modifier.padding(14.dp), style = ChromeType.title)
|
||||||
else ItemListColumn(itemList)
|
else ItemListColumn(itemList)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ import androidx.compose.runtime.setValue
|
|||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
import androidx.compose.ui.text.style.TextOverflow
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.car.app.model.GridTemplate
|
import androidx.car.app.model.GridTemplate
|
||||||
@@ -51,6 +52,7 @@ import androidx.car.app.media.model.MediaPlaybackTemplate
|
|||||||
import androidx.car.app.OnDoneCallback
|
import androidx.car.app.OnDoneCallback
|
||||||
import androidx.car.app.serialization.Bundleable
|
import androidx.car.app.serialization.Bundleable
|
||||||
import app.seb3thehacker.gearslip.GearslipLog
|
import app.seb3thehacker.gearslip.GearslipLog
|
||||||
|
import app.seb3thehacker.gearslip.host.isDrawable
|
||||||
import app.seb3thehacker.gearslip.host.inputChanged
|
import app.seb3thehacker.gearslip.host.inputChanged
|
||||||
import app.seb3thehacker.gearslip.host.inputSubmitted
|
import app.seb3thehacker.gearslip.host.inputSubmitted
|
||||||
import app.seb3thehacker.gearslip.host.submitted
|
import app.seb3thehacker.gearslip.host.submitted
|
||||||
@@ -65,12 +67,12 @@ import app.seb3thehacker.gearslip.host.textChanged
|
|||||||
* the same function either way - the only difference is how much room they get.
|
* the same function either way - the only difference is how much room they get.
|
||||||
*/
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
fun ContentTemplate(template: Template?, modifier: Modifier = Modifier) {
|
fun ContentTemplate(template: Template?, modifier: Modifier = Modifier, autoStartPane: Boolean = false) {
|
||||||
when (template) {
|
when (template) {
|
||||||
null -> Unit
|
null -> Unit
|
||||||
is ListTemplate -> ListContent(template, modifier)
|
is ListTemplate -> ListContent(template, modifier)
|
||||||
is GridTemplate -> GridContent(template, modifier)
|
is GridTemplate -> GridContent(template, modifier)
|
||||||
is PaneTemplate -> PaneContent(template, modifier)
|
is PaneTemplate -> PaneContent(template, modifier, autoStartPane)
|
||||||
is MessageTemplate -> MessageContent(template, modifier)
|
is MessageTemplate -> MessageContent(template, modifier)
|
||||||
is LongMessageTemplate -> LongMessageContent(template, modifier)
|
is LongMessageTemplate -> LongMessageContent(template, modifier)
|
||||||
is SearchTemplate -> SearchContent(template, modifier)
|
is SearchTemplate -> SearchContent(template, modifier)
|
||||||
@@ -126,7 +128,7 @@ private fun GridContent(template: GridTemplate, modifier: Modifier) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun PaneContent(template: PaneTemplate, modifier: Modifier) {
|
private fun PaneContent(template: PaneTemplate, modifier: Modifier, autoStart: Boolean = false) {
|
||||||
ContentSurface(modifier) {
|
ContentSurface(modifier) {
|
||||||
HeaderBar(
|
HeaderBar(
|
||||||
headerTitle(template.header, template.title.text()),
|
headerTitle(template.header, template.title.text()),
|
||||||
@@ -134,7 +136,7 @@ private fun PaneContent(template: PaneTemplate, modifier: Modifier) {
|
|||||||
template.header?.endHeaderActions.orEmpty(),
|
template.header?.endHeaderActions.orEmpty(),
|
||||||
template.actionStrip,
|
template.actionStrip,
|
||||||
)
|
)
|
||||||
if (template.pane?.isLoading == true) Loading() else PaneRows(template.pane)
|
if (template.pane?.isLoading == true) Loading() else PaneRows(template.pane, autoStart = autoStart)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -143,27 +145,45 @@ private fun PaneContent(template: PaneTemplate, modifier: Modifier) {
|
|||||||
@Composable
|
@Composable
|
||||||
private fun MessageContent(template: MessageTemplate, modifier: Modifier) {
|
private fun MessageContent(template: MessageTemplate, modifier: Modifier) {
|
||||||
ContentSurface(modifier) {
|
ContentSurface(modifier) {
|
||||||
HeaderBar(
|
// The header carries only the back arrow, not a title - a message template's own title
|
||||||
headerTitle(template.header, template.title.text()),
|
// is usually the long-form question ("Would you like to download the map...") that
|
||||||
template.header?.startHeaderAction ?: template.headerAction,
|
// reads fine centred in the body but doesn't fit a one-line header without truncating.
|
||||||
template.header?.endHeaderActions.orEmpty(),
|
val backAction = template.header?.startHeaderAction ?: template.headerAction
|
||||||
template.actionStrip,
|
HeaderBar("", backAction, template.header?.endHeaderActions.orEmpty(), template.actionStrip)
|
||||||
)
|
|
||||||
if (template.isLoading) {
|
if (template.isLoading) {
|
||||||
Loading()
|
Loading()
|
||||||
return@ContentSurface
|
return@ContentSurface
|
||||||
}
|
}
|
||||||
Row(
|
// A back arrow already undoes this screen, so an app's own "Cancel" button would just
|
||||||
Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
|
// be a second way to do the one thing the header already offers.
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
val actions = template.actions.orEmpty().filter { it.isDrawable() }.let { list ->
|
||||||
|
if (backAction != null) list.filterNot { it.title.text().equals("Cancel", ignoreCase = true) } else list
|
||||||
|
}
|
||||||
|
Column(
|
||||||
|
Modifier.weight(1f).fillMaxWidth().padding(24.dp),
|
||||||
|
verticalArrangement = Arrangement.Center,
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
) {
|
) {
|
||||||
template.icon?.let {
|
template.icon?.let {
|
||||||
CarGlyph(it, Modifier.size(32.dp))
|
CarGlyph(it, Modifier.size(40.dp))
|
||||||
Spacer(Modifier.width(12.dp))
|
Spacer(Modifier.height(12.dp))
|
||||||
|
}
|
||||||
|
Text(template.title.text(), style = ChromeType.title, textAlign = TextAlign.Center)
|
||||||
|
val detail = template.message.text()
|
||||||
|
if (detail.isNotEmpty()) {
|
||||||
|
Spacer(Modifier.height(8.dp))
|
||||||
|
Text(
|
||||||
|
detail,
|
||||||
|
style = ChromeType.body,
|
||||||
|
textAlign = TextAlign.Center,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (actions.isNotEmpty()) {
|
||||||
|
Spacer(Modifier.height(24.dp))
|
||||||
|
ActionRow(actions, Modifier.fillMaxWidth(0.6f), large = true)
|
||||||
}
|
}
|
||||||
Text(template.message.text(), style = MaterialTheme.typography.bodyLarge)
|
|
||||||
}
|
}
|
||||||
ActionRow(template.actions.orEmpty(), Modifier.padding(16.dp))
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -181,7 +201,7 @@ private fun LongMessageContent(template: LongMessageTemplate, modifier: Modifier
|
|||||||
)
|
)
|
||||||
Text(
|
Text(
|
||||||
template.message.text(),
|
template.message.text(),
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
style = ChromeType.body,
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.weight(1f)
|
.weight(1f)
|
||||||
.verticalScroll(rememberScrollState())
|
.verticalScroll(rememberScrollState())
|
||||||
@@ -245,7 +265,7 @@ private fun SearchField(query: String, hint: String, modifier: Modifier = Modifi
|
|||||||
) {
|
) {
|
||||||
Text(
|
Text(
|
||||||
query.ifEmpty { hint.ifEmpty { "Search" } },
|
query.ifEmpty { hint.ifEmpty { "Search" } },
|
||||||
style = MaterialTheme.typography.titleMedium,
|
style = ChromeType.body,
|
||||||
color = if (query.isEmpty()) MaterialTheme.colorScheme.onSurfaceVariant
|
color = if (query.isEmpty()) MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
else MaterialTheme.colorScheme.onSurface,
|
else MaterialTheme.colorScheme.onSurface,
|
||||||
maxLines = 1,
|
maxLines = 1,
|
||||||
@@ -279,7 +299,7 @@ private fun SignInContent(template: SignInTemplate, modifier: Modifier) {
|
|||||||
if (instructions.isNotEmpty()) {
|
if (instructions.isNotEmpty()) {
|
||||||
Text(
|
Text(
|
||||||
instructions,
|
instructions,
|
||||||
style = MaterialTheme.typography.bodyLarge,
|
style = ChromeType.body,
|
||||||
modifier = Modifier.padding(horizontal = 16.dp, vertical = 6.dp),
|
modifier = Modifier.padding(horizontal = 16.dp, vertical = 6.dp),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -298,7 +318,7 @@ private fun SignInContent(template: SignInTemplate, modifier: Modifier) {
|
|||||||
if (additional.isNotEmpty()) {
|
if (additional.isNotEmpty()) {
|
||||||
Text(
|
Text(
|
||||||
additional,
|
additional,
|
||||||
style = MaterialTheme.typography.bodySmall,
|
style = ChromeType.label,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
modifier = Modifier.padding(horizontal = 16.dp),
|
modifier = Modifier.padding(horizontal = 16.dp),
|
||||||
)
|
)
|
||||||
@@ -319,7 +339,7 @@ private fun InputSignIn(method: InputSignInMethod, modifier: Modifier) {
|
|||||||
if (error.isNotEmpty()) {
|
if (error.isNotEmpty()) {
|
||||||
Text(
|
Text(
|
||||||
error,
|
error,
|
||||||
style = MaterialTheme.typography.bodySmall,
|
style = ChromeType.label,
|
||||||
color = MaterialTheme.colorScheme.error,
|
color = MaterialTheme.colorScheme.error,
|
||||||
modifier = Modifier.padding(horizontal = 16.dp),
|
modifier = Modifier.padding(horizontal = 16.dp),
|
||||||
)
|
)
|
||||||
@@ -343,8 +363,7 @@ private fun Callout(value: String, modifier: Modifier) {
|
|||||||
Box(modifier.fillMaxWidth(), contentAlignment = Alignment.Center) {
|
Box(modifier.fillMaxWidth(), contentAlignment = Alignment.Center) {
|
||||||
Text(
|
Text(
|
||||||
value,
|
value,
|
||||||
style = MaterialTheme.typography.headlineMedium,
|
style = ChromeType.headline,
|
||||||
fontWeight = FontWeight.Bold,
|
|
||||||
textAlign = androidx.compose.ui.text.style.TextAlign.Center,
|
textAlign = androidx.compose.ui.text.style.TextAlign.Center,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -385,7 +404,7 @@ private fun TabContent(template: TabTemplate, modifier: Modifier) {
|
|||||||
CarGlyph(it, Modifier.size(20.dp))
|
CarGlyph(it, Modifier.size(20.dp))
|
||||||
Spacer(Modifier.width(6.dp))
|
Spacer(Modifier.width(6.dp))
|
||||||
}
|
}
|
||||||
Text(tab.title.text(), style = MaterialTheme.typography.labelLarge, maxLines = 1)
|
Text(tab.title.text(), style = ChromeType.label, maxLines = 1)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -450,7 +469,7 @@ private fun SectionRows(section: Section<*>) {
|
|||||||
|
|
||||||
if (items.isEmpty()) {
|
if (items.isEmpty()) {
|
||||||
val message = section.noItemsMessage.text()
|
val message = section.noItemsMessage.text()
|
||||||
if (message.isNotEmpty()) Text(message, Modifier.padding(14.dp))
|
if (message.isNotEmpty()) Text(message, Modifier.padding(14.dp), style = ChromeType.body)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
items.filterIsInstance<CarRow>().forEach { RowItem(it) }
|
items.filterIsInstance<CarRow>().forEach { RowItem(it) }
|
||||||
@@ -474,7 +493,7 @@ private fun MediaPlaybackContent(template: MediaPlaybackTemplate, modifier: Modi
|
|||||||
Box(Modifier.weight(1f).fillMaxWidth(), contentAlignment = Alignment.Center) {
|
Box(Modifier.weight(1f).fillMaxWidth(), contentAlignment = Alignment.Center) {
|
||||||
Text(
|
Text(
|
||||||
"Playback controls come from the app's media session,\nwhich Gearslip doesn't carry yet.",
|
"Playback controls come from the app's media session,\nwhich Gearslip doesn't carry yet.",
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
style = ChromeType.body,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
textAlign = androidx.compose.ui.text.style.TextAlign.Center,
|
textAlign = androidx.compose.ui.text.style.TextAlign.Center,
|
||||||
)
|
)
|
||||||
@@ -496,6 +515,6 @@ private typealias ColumnScopeAlias = androidx.compose.foundation.layout.ColumnSc
|
|||||||
@Composable
|
@Composable
|
||||||
private fun Loading() {
|
private fun Loading() {
|
||||||
Box(Modifier.fillMaxWidth().height(80.dp), contentAlignment = Alignment.Center) {
|
Box(Modifier.fillMaxWidth().height(80.dp), contentAlignment = Alignment.Center) {
|
||||||
Text("Loading…", style = MaterialTheme.typography.bodyLarge)
|
Text("Loading…", style = ChromeType.title)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -4,12 +4,14 @@ import androidx.compose.runtime.remember
|
|||||||
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.foundation.Image
|
import androidx.compose.foundation.Image
|
||||||
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.clickable
|
import androidx.compose.foundation.clickable
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.PaddingValues
|
import androidx.compose.foundation.layout.PaddingValues
|
||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
import androidx.compose.foundation.layout.Spacer
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxHeight
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.height
|
import androidx.compose.foundation.layout.height
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
@@ -82,19 +84,21 @@ import app.seb3thehacker.gearslip.host.text
|
|||||||
/**
|
/**
|
||||||
* The app keeps drawing its map across the whole surface - the visible area we report tells it
|
* The app keeps drawing its map across the whole surface - the visible area we report tells it
|
||||||
* where to put its own markers, not where to stop painting. So anything the host lays over the
|
* where to put its own markers, not where to stop painting. So anything the host lays over the
|
||||||
* map is opaque by default, or the map reads through it.
|
* map is opaque, always, in the same one colour: a card that lets the map show through in places
|
||||||
|
* reads as broken glass, not as a deliberate choice, and a driver has no way to tell which was
|
||||||
|
* meant. There is no alpha knob here on purpose - every card, bar and strip in the template
|
||||||
|
* chrome shares this surface, so nothing drawn over a map is ever see-through.
|
||||||
*/
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
internal fun ChromeSurface(
|
internal fun ChromeSurface(
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
shape: Shape = RoundedCornerShape(16.dp),
|
shape: Shape = RoundedCornerShape(16.dp),
|
||||||
alpha: Float = 1f,
|
|
||||||
shadowElevation: androidx.compose.ui.unit.Dp = 0.dp,
|
shadowElevation: androidx.compose.ui.unit.Dp = 0.dp,
|
||||||
content: @Composable () -> Unit,
|
content: @Composable () -> Unit,
|
||||||
) {
|
) {
|
||||||
Surface(
|
Surface(
|
||||||
shadowElevation = shadowElevation,
|
shadowElevation = shadowElevation,
|
||||||
color = MaterialTheme.colorScheme.surface.copy(alpha = alpha),
|
color = MaterialTheme.colorScheme.surface,
|
||||||
contentColor = MaterialTheme.colorScheme.onSurface,
|
contentColor = MaterialTheme.colorScheme.onSurface,
|
||||||
shape = shape,
|
shape = shape,
|
||||||
modifier = modifier,
|
modifier = modifier,
|
||||||
@@ -102,6 +106,32 @@ internal fun ChromeSurface(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The one type scale for everything a templated app's screen draws. Apps hand the host plain
|
||||||
|
* strings with no size of their own - the Car App Library deliberately keeps that decision out of
|
||||||
|
* their hands - so without a shared scale, each template's text ends up whatever size felt right
|
||||||
|
* when that template was written, and two apps' otherwise-identical rows read as different sizes
|
||||||
|
* of importance. Every car chrome file should reach for one of these four rather than naming a
|
||||||
|
* Material size directly.
|
||||||
|
*/
|
||||||
|
internal object ChromeType {
|
||||||
|
/** The one number or word a card exists to show: distance to a turn, minutes left, a PIN. */
|
||||||
|
val headline: androidx.compose.ui.text.TextStyle
|
||||||
|
@Composable get() = MaterialTheme.typography.headlineMedium.copy(fontWeight = FontWeight.Bold)
|
||||||
|
|
||||||
|
/** A row's name, a header, a manoeuvre's instruction - the line the rest is supporting. */
|
||||||
|
val title: androidx.compose.ui.text.TextStyle
|
||||||
|
@Composable get() = MaterialTheme.typography.titleLarge.copy(fontWeight = FontWeight.SemiBold)
|
||||||
|
|
||||||
|
/** Supporting detail: a road, an address, a message's body, a second line under a title. */
|
||||||
|
val body: androidx.compose.ui.text.TextStyle
|
||||||
|
@Composable get() = MaterialTheme.typography.titleMedium
|
||||||
|
|
||||||
|
/** The smallest text Gearslip draws over a map - still large enough to read while moving. */
|
||||||
|
val label: androidx.compose.ui.text.TextStyle
|
||||||
|
@Composable get() = MaterialTheme.typography.bodyLarge
|
||||||
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
internal fun UnsupportedChrome(template: Template, modifier: Modifier = Modifier) {
|
internal fun UnsupportedChrome(template: Template, modifier: Modifier = Modifier) {
|
||||||
androidx.compose.foundation.layout.Box(modifier, contentAlignment = Alignment.Center) {
|
androidx.compose.foundation.layout.Box(modifier, contentAlignment = Alignment.Center) {
|
||||||
@@ -109,7 +139,7 @@ internal fun UnsupportedChrome(template: Template, modifier: Modifier = Modifier
|
|||||||
Text(
|
Text(
|
||||||
"${template.javaClass.simpleName} isn't drawn yet",
|
"${template.javaClass.simpleName} isn't drawn yet",
|
||||||
Modifier.padding(16.dp),
|
Modifier.padding(16.dp),
|
||||||
style = MaterialTheme.typography.titleMedium,
|
style = ChromeType.title,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -142,8 +172,7 @@ internal fun HeaderBar(
|
|||||||
startAction?.takeIf { it.isDrawable() }?.let { ActionButton(it) }
|
startAction?.takeIf { it.isDrawable() }?.let { ActionButton(it) }
|
||||||
Text(
|
Text(
|
||||||
title,
|
title,
|
||||||
style = MaterialTheme.typography.titleLarge,
|
style = ChromeType.title,
|
||||||
fontWeight = FontWeight.SemiBold,
|
|
||||||
color = MaterialTheme.colorScheme.onSurface,
|
color = MaterialTheme.colorScheme.onSurface,
|
||||||
maxLines = 1,
|
maxLines = 1,
|
||||||
overflow = TextOverflow.Ellipsis,
|
overflow = TextOverflow.Ellipsis,
|
||||||
@@ -285,12 +314,7 @@ internal fun ActionButton(action: Action, large: Boolean = false, modifier: Modi
|
|||||||
contentAlignment = Alignment.Center,
|
contentAlignment = Alignment.Center,
|
||||||
) {
|
) {
|
||||||
if (hasGlyph) CarGlyph(action.icon, Modifier.size(30.dp).align(Alignment.CenterStart), standardFor(action))
|
if (hasGlyph) CarGlyph(action.icon, Modifier.size(30.dp).align(Alignment.CenterStart), standardFor(action))
|
||||||
Text(
|
Text(title, style = ChromeType.title, maxLines = 1)
|
||||||
title,
|
|
||||||
style = MaterialTheme.typography.titleLarge,
|
|
||||||
fontWeight = FontWeight.SemiBold,
|
|
||||||
maxLines = 1,
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
} else if (title.isEmpty()) {
|
} else if (title.isEmpty()) {
|
||||||
// Every icon-only button - back, settings, the map's zoom and locate - is the same size.
|
// Every icon-only button - back, settings, the map's zoom and locate - is the same size.
|
||||||
@@ -304,12 +328,82 @@ internal fun ActionButton(action: Action, large: Boolean = false, modifier: Modi
|
|||||||
) {
|
) {
|
||||||
if (hasGlyph) CarGlyph(action.icon, Modifier.size(24.dp), standardFor(action))
|
if (hasGlyph) CarGlyph(action.icon, Modifier.size(24.dp), standardFor(action))
|
||||||
if (hasGlyph) Spacer(Modifier.width(8.dp))
|
if (hasGlyph) Spacer(Modifier.width(8.dp))
|
||||||
Text(title, style = MaterialTheme.typography.titleMedium, maxLines = 1)
|
Text(title, style = ChromeType.body, maxLines = 1)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A large action that fires on its own after five seconds, filling to show the countdown - for
|
||||||
|
* the one button on a map-preview pane, where the app is proposing a single place or route and a
|
||||||
|
* driver glancing at the car screen has already decided. Tapping it, same as any other button,
|
||||||
|
* fires it at once; the fill is only ever a preview of what a touch already does.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
internal fun AutoStartButton(action: Action, modifier: Modifier = Modifier) {
|
||||||
|
var fraction by remember(action.identity()) { mutableStateOf(0f) }
|
||||||
|
var fired by remember(action.identity()) { mutableStateOf(false) }
|
||||||
|
fun fire() {
|
||||||
|
if (fired) return
|
||||||
|
fired = true
|
||||||
|
action.onClickDelegate.click("auto-start")
|
||||||
|
}
|
||||||
|
androidx.compose.runtime.LaunchedEffect(action.identity()) {
|
||||||
|
val steps = 60
|
||||||
|
repeat(steps) { step ->
|
||||||
|
kotlinx.coroutines.delay(AUTO_START_MS / steps)
|
||||||
|
fraction = (step + 1) / steps.toFloat()
|
||||||
|
}
|
||||||
|
fire()
|
||||||
|
}
|
||||||
|
|
||||||
|
val dark = MaterialTheme.colorScheme.surface.luminanceIsDark()
|
||||||
|
val custom = action.backgroundColor?.type?.let { it != androidx.car.app.model.CarColor.TYPE_DEFAULT } == true
|
||||||
|
val background = action.backgroundColor.color(dark, MaterialTheme.colorScheme.primary)
|
||||||
|
val content = if (custom) Color.White else MaterialTheme.colorScheme.onPrimary
|
||||||
|
val hasGlyph = action.icon != null
|
||||||
|
|
||||||
|
Surface(
|
||||||
|
// An unfilled track, solid, not the button's own colour dimmed with alpha - so the map
|
||||||
|
// behind never shows through however much of the five seconds is left.
|
||||||
|
color = MaterialTheme.colorScheme.surfaceContainerHighest,
|
||||||
|
contentColor = content,
|
||||||
|
shape = RoundedCornerShape(28.dp),
|
||||||
|
modifier = modifier.fillMaxWidth().clickable(enabled = action.isEnabled) { fire() },
|
||||||
|
) {
|
||||||
|
androidx.compose.foundation.layout.Box {
|
||||||
|
// The countdown itself: a plain fill sweeping left to right underneath the label.
|
||||||
|
// matchParentSize(), not fillMaxHeight() - this Box sits beside a label Box that
|
||||||
|
// wraps its own content, and the outer Box sizes itself to its tallest child; a
|
||||||
|
// plain fillMaxHeight() here has nothing bounded to fill up to but the whole screen,
|
||||||
|
// which is what was stretching the whole button that tall. matchParentSize() instead
|
||||||
|
// takes whatever size the label ends up being, without itself voting on what that is.
|
||||||
|
androidx.compose.foundation.layout.Box(Modifier.matchParentSize()) {
|
||||||
|
androidx.compose.foundation.layout.Box(
|
||||||
|
Modifier
|
||||||
|
.fillMaxWidth(fraction)
|
||||||
|
.fillMaxHeight()
|
||||||
|
.background(background),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
androidx.compose.foundation.layout.Box(
|
||||||
|
Modifier.fillMaxWidth().padding(horizontal = 20.dp, vertical = 16.dp),
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
) {
|
||||||
|
if (hasGlyph) CarGlyph(action.icon, Modifier.size(24.dp).align(Alignment.CenterStart))
|
||||||
|
Text(
|
||||||
|
action.title.text().ifEmpty { "Start" },
|
||||||
|
style = ChromeType.title,
|
||||||
|
maxLines = 1,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private const val AUTO_START_MS = 5_000L
|
||||||
|
|
||||||
/** The connected app's own launcher icon, which is what the app-icon action stands for. */
|
/** The connected app's own launcher icon, which is what the app-icon action stands for. */
|
||||||
@Composable
|
@Composable
|
||||||
private fun AppIconBadge() {
|
private fun AppIconBadge() {
|
||||||
@@ -376,8 +470,7 @@ internal fun PaneTitle(title: String) {
|
|||||||
if (title.isEmpty()) return
|
if (title.isEmpty()) return
|
||||||
Text(
|
Text(
|
||||||
title,
|
title,
|
||||||
style = MaterialTheme.typography.titleMedium,
|
style = ChromeType.title,
|
||||||
fontWeight = FontWeight.SemiBold,
|
|
||||||
modifier = Modifier.padding(horizontal = 14.dp, vertical = 10.dp),
|
modifier = Modifier.padding(horizontal = 14.dp, vertical = 10.dp),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -394,7 +487,7 @@ internal fun ItemListColumn(
|
|||||||
}
|
}
|
||||||
if (rows.isEmpty()) {
|
if (rows.isEmpty()) {
|
||||||
val message = single?.noItemsMessage.text()
|
val message = single?.noItemsMessage.text()
|
||||||
if (message.isNotEmpty()) Text(message, Modifier.padding(14.dp))
|
if (message.isNotEmpty()) Text(message, Modifier.padding(14.dp), style = ChromeType.body)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
// A list the app made selectable is a single-choice list: one option chosen, shown as a radio.
|
// A list the app made selectable is a single-choice list: one option chosen, shown as a radio.
|
||||||
@@ -430,7 +523,7 @@ internal fun GridItems(list: ItemList?, modifier: Modifier = Modifier) {
|
|||||||
Spacer(Modifier.height(6.dp))
|
Spacer(Modifier.height(6.dp))
|
||||||
Text(
|
Text(
|
||||||
item.title.text(),
|
item.title.text(),
|
||||||
style = MaterialTheme.typography.labelLarge,
|
style = ChromeType.label,
|
||||||
maxLines = 1,
|
maxLines = 1,
|
||||||
overflow = TextOverflow.Ellipsis,
|
overflow = TextOverflow.Ellipsis,
|
||||||
)
|
)
|
||||||
@@ -438,7 +531,7 @@ internal fun GridItems(list: ItemList?, modifier: Modifier = Modifier) {
|
|||||||
if (subtitle.isNotEmpty()) {
|
if (subtitle.isNotEmpty()) {
|
||||||
Text(
|
Text(
|
||||||
subtitle,
|
subtitle,
|
||||||
style = MaterialTheme.typography.labelSmall,
|
style = ChromeType.label,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
maxLines = 1,
|
maxLines = 1,
|
||||||
overflow = TextOverflow.Ellipsis,
|
overflow = TextOverflow.Ellipsis,
|
||||||
@@ -449,17 +542,24 @@ internal fun GridItems(list: ItemList?, modifier: Modifier = Modifier) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* [autoStart] is for a pane floating over a map - a place or a route the app is proposing - where
|
||||||
|
* a single action counts itself down rather than waiting to be pressed, on the theory that a
|
||||||
|
* driver glancing at the car screen has already decided. It never applies to more than one
|
||||||
|
* action: a pane offering a real choice (Start vs. something else) always waits to be tapped.
|
||||||
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
internal fun PaneRows(pane: Pane?, modifier: Modifier = Modifier) {
|
internal fun PaneRows(pane: Pane?, modifier: Modifier = Modifier, autoStart: Boolean = false) {
|
||||||
// Some apps pad a pane with a row of blank strings; drawn, it is just a gap.
|
// Some apps pad a pane with a row of blank strings; drawn, it is just a gap.
|
||||||
val rows = pane?.rows.orEmpty().filterNot { it.isBlank() }
|
val rows = pane?.rows.orEmpty().filterNot { it.isBlank() }
|
||||||
|
val actions = pane?.actions.orEmpty().filter { it.isDrawable() }
|
||||||
Column(modifier) {
|
Column(modifier) {
|
||||||
rows.forEach { RowItem(it, large = true) }
|
rows.forEach { RowItem(it, large = true) }
|
||||||
ActionRow(
|
if (autoStart && actions.size == 1) {
|
||||||
pane?.actions.orEmpty(),
|
AutoStartButton(actions.single(), Modifier.padding(horizontal = 14.dp, vertical = 10.dp))
|
||||||
Modifier.padding(horizontal = 14.dp, vertical = 10.dp),
|
} else {
|
||||||
large = true,
|
ActionRow(actions, Modifier.padding(horizontal = 14.dp, vertical = 10.dp), large = true)
|
||||||
)
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -496,18 +596,20 @@ internal fun RowItem(row: CarRow, selection: RowSelection? = null, large: Boolea
|
|||||||
CarGlyph(it, Modifier.size(28.dp))
|
CarGlyph(it, Modifier.size(28.dp))
|
||||||
Spacer(Modifier.width(12.dp))
|
Spacer(Modifier.width(12.dp))
|
||||||
}
|
}
|
||||||
|
// large only changes how much room a row gets (more lines, a bigger icon above) - the
|
||||||
|
// type itself is the same scale as every other row, so a pane's one row and a plain
|
||||||
|
// list's tenth row read as the same kind of text.
|
||||||
Column(Modifier.weight(1f)) {
|
Column(Modifier.weight(1f)) {
|
||||||
Text(
|
Text(
|
||||||
row.title.text(),
|
row.title.text(),
|
||||||
style = if (large) MaterialTheme.typography.headlineSmall else MaterialTheme.typography.bodyLarge,
|
style = ChromeType.title,
|
||||||
fontWeight = if (large) FontWeight.SemiBold else null,
|
|
||||||
maxLines = if (large) 2 else 1,
|
maxLines = if (large) 2 else 1,
|
||||||
overflow = TextOverflow.Ellipsis,
|
overflow = TextOverflow.Ellipsis,
|
||||||
)
|
)
|
||||||
row.texts.orEmpty().take(if (large) 3 else 2).forEach { line ->
|
row.texts.orEmpty().take(if (large) 3 else 2).forEach { line ->
|
||||||
Text(
|
Text(
|
||||||
line.text(),
|
line.text(),
|
||||||
style = if (large) MaterialTheme.typography.titleMedium else MaterialTheme.typography.bodyMedium,
|
style = ChromeType.body,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
maxLines = if (large) 2 else 1,
|
maxLines = if (large) 2 else 1,
|
||||||
overflow = TextOverflow.Ellipsis,
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
|||||||
@@ -1,10 +1,14 @@
|
|||||||
package app.seb3thehacker.gearslip.car
|
package app.seb3thehacker.gearslip.car
|
||||||
|
|
||||||
|
import android.os.Handler
|
||||||
|
import android.os.Looper
|
||||||
|
import android.webkit.JavascriptInterface
|
||||||
import android.webkit.WebResourceError
|
import android.webkit.WebResourceError
|
||||||
import android.webkit.WebResourceRequest
|
import android.webkit.WebResourceRequest
|
||||||
import android.webkit.WebResourceResponse
|
import android.webkit.WebResourceResponse
|
||||||
import android.webkit.WebView
|
import android.webkit.WebView
|
||||||
import android.webkit.WebViewClient
|
import android.webkit.WebViewClient
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.DisposableEffect
|
import androidx.compose.runtime.DisposableEffect
|
||||||
@@ -15,16 +19,25 @@ import androidx.compose.runtime.setValue
|
|||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
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
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A browser inside the car UI. [content] is a URL, or raw HTML when it does not look like one.
|
* A browser inside the car UI. [content] is a URL, or raw HTML when it does not look like one.
|
||||||
* The bottom bar's Back walks the page history first and only leaves the app at the start.
|
* The bottom bar's Back walks the page history first and only leaves the app at the start.
|
||||||
|
*
|
||||||
|
* A page's own text inputs can't call up the phone's IME: the car UI lives in a Presentation on
|
||||||
|
* a virtual display, and the platform's soft keyboard has nowhere sensible to appear there (see
|
||||||
|
* [CarKeyboard]'s doc comment - every other text field in this app already draws its own for the
|
||||||
|
* same reason). [FOCUS_TRACKING_JS] reports focus/blur on the page's editable elements, and
|
||||||
|
* [CarKeyboard] edits the focused one back through injected JS instead of through the DOM's own
|
||||||
|
* native input path, which this Presentation can't reach.
|
||||||
*/
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
fun WebApp(content: String) {
|
fun WebApp(content: String) {
|
||||||
val navigator = LocalCarNavigator.current
|
val navigator = LocalCarNavigator.current
|
||||||
val darkOutside = LocalDarkOutside.current
|
val darkOutside = LocalDarkOutside.current
|
||||||
var web by remember { mutableStateOf<WebView?>(null) }
|
var web by remember { mutableStateOf<WebView?>(null) }
|
||||||
|
var fieldText by remember { mutableStateOf<String?>(null) }
|
||||||
|
|
||||||
DisposableEffect(web) {
|
DisposableEffect(web) {
|
||||||
navigator.backInterceptor = {
|
navigator.backInterceptor = {
|
||||||
@@ -39,32 +52,49 @@ fun WebApp(content: String) {
|
|||||||
onDispose { navigator.backInterceptor = null }
|
onDispose { navigator.backInterceptor = null }
|
||||||
}
|
}
|
||||||
|
|
||||||
AndroidView(
|
Column(Modifier.fillMaxSize()) {
|
||||||
modifier = Modifier.fillMaxSize(),
|
AndroidView(
|
||||||
factory = { context ->
|
modifier = Modifier.weight(1f).fillMaxSize(),
|
||||||
WebView(context).apply {
|
factory = { context ->
|
||||||
settings.javaScriptEnabled = true
|
WebView(context).apply {
|
||||||
settings.domStorageEnabled = true
|
settings.javaScriptEnabled = true
|
||||||
settings.loadWithOverviewMode = true
|
settings.domStorageEnabled = true
|
||||||
settings.useWideViewPort = true
|
settings.loadWithOverviewMode = true
|
||||||
webViewClient = LoggingClient()
|
settings.useWideViewPort = true
|
||||||
if (content.startsWith("http://") || content.startsWith("https://")) {
|
webViewClient = LoggingClient()
|
||||||
loadUrl(content)
|
addJavascriptInterface(
|
||||||
} else {
|
FieldFocusBridge(onFocus = { fieldText = it }, onBlur = { fieldText = null }),
|
||||||
loadDataWithBaseURL(null, content, "text/html", "utf-8", null)
|
"GearslipField",
|
||||||
|
)
|
||||||
|
if (content.startsWith("http://") || content.startsWith("https://")) {
|
||||||
|
loadUrl(content)
|
||||||
|
} else {
|
||||||
|
loadDataWithBaseURL(null, content, "text/html", "utf-8", null)
|
||||||
|
}
|
||||||
|
web = this
|
||||||
}
|
}
|
||||||
web = this
|
},
|
||||||
}
|
// Pages that honour it follow the darkness outside; a first, tweakable use of the signal.
|
||||||
},
|
update = { it.settings.isAlgorithmicDarkeningAllowed = darkOutside },
|
||||||
// Pages that honour it follow the darkness outside; a first, tweakable use of the signal.
|
onRelease = { it.destroy() },
|
||||||
update = { it.settings.isAlgorithmicDarkeningAllowed = darkOutside },
|
)
|
||||||
onRelease = { it.destroy() },
|
fieldText?.let { text ->
|
||||||
)
|
CarKeyboard(
|
||||||
|
text = text,
|
||||||
|
onTextChange = { updated ->
|
||||||
|
fieldText = updated
|
||||||
|
web?.evaluateJavascript(setFieldValueJs(updated), null)
|
||||||
|
},
|
||||||
|
onSubmit = { web?.evaluateJavascript(SUBMIT_FIELD_JS, null) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private class LoggingClient : WebViewClient() {
|
private class LoggingClient : 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)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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
|
||||||
@@ -81,3 +111,70 @@ private class LoggingClient : WebViewClient() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Tells Kotlin when an editable element on the page gains or loses focus, so [CarKeyboard] can show. */
|
||||||
|
private class FieldFocusBridge(private val onFocus: (String) -> Unit, private val onBlur: () -> Unit) {
|
||||||
|
private val main = Handler(Looper.getMainLooper())
|
||||||
|
|
||||||
|
// WebView calls into this from its own thread, never Compose's.
|
||||||
|
@JavascriptInterface
|
||||||
|
fun focused(value: String) { main.post { onFocus(value) } }
|
||||||
|
|
||||||
|
@JavascriptInterface
|
||||||
|
fun blurred() { main.post { onBlur() } }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Plain `el.value = x` never reaches a React-controlled input: React replaces the native value
|
||||||
|
// setter with its own to track edits, so a direct assignment sets the DOM attribute but leaves
|
||||||
|
// React's own state (and anything bound to it, like a search box's query) unaware. Calling the
|
||||||
|
// *native* setter through the prototype, the same trick browser extensions use, is what actually
|
||||||
|
// notifies it once the 'input' event fires.
|
||||||
|
private fun setFieldValueJs(value: String) = """
|
||||||
|
(function() {
|
||||||
|
var el = document.activeElement;
|
||||||
|
if (!el) return;
|
||||||
|
var proto = el.tagName === 'TEXTAREA' ? window.HTMLTextAreaElement.prototype : window.HTMLInputElement.prototype;
|
||||||
|
var setter = Object.getOwnPropertyDescriptor(proto, 'value');
|
||||||
|
if (setter && setter.set) { setter.set.call(el, ${JSONObject.quote(value)}); } else { el.value = ${JSONObject.quote(value)}; }
|
||||||
|
el.dispatchEvent(new Event('input', { bubbles: true }));
|
||||||
|
el.dispatchEvent(new Event('change', { bubbles: true }));
|
||||||
|
})();
|
||||||
|
""".trimIndent()
|
||||||
|
|
||||||
|
// Fires a real Enter keydown first - the same event a site's own JS would see from a physical
|
||||||
|
// keyboard - and only falls back to submitting the form directly if nothing handled it, so a
|
||||||
|
// search-as-you-type page and a plain <form> both work without submitting twice.
|
||||||
|
private const val SUBMIT_FIELD_JS = """
|
||||||
|
(function() {
|
||||||
|
var el = document.activeElement;
|
||||||
|
if (!el) return;
|
||||||
|
var enter = new KeyboardEvent('keydown', {
|
||||||
|
key: 'Enter', code: 'Enter', keyCode: 13, which: 13, bubbles: true, cancelable: true,
|
||||||
|
});
|
||||||
|
var notHandled = el.dispatchEvent(enter);
|
||||||
|
if (notHandled && el.form) {
|
||||||
|
if (el.form.requestSubmit) el.form.requestSubmit(); else el.form.submit();
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
"""
|
||||||
|
|
||||||
|
/** Reports focus/blur on text inputs, textareas and contenteditable elements to [FieldFocusBridge]. */
|
||||||
|
private const val FOCUS_TRACKING_JS = """
|
||||||
|
(function() {
|
||||||
|
function isEditable(el) {
|
||||||
|
if (!el) return false;
|
||||||
|
var tag = el.tagName;
|
||||||
|
if (tag === 'TEXTAREA') return true;
|
||||||
|
if (el.isContentEditable) return true;
|
||||||
|
if (tag !== 'INPUT') return false;
|
||||||
|
var skip = ['button', 'submit', 'checkbox', 'radio', 'range', 'color', 'file', 'image', 'reset'];
|
||||||
|
return skip.indexOf((el.type || 'text').toLowerCase()) === -1;
|
||||||
|
}
|
||||||
|
document.addEventListener('focusin', function(e) {
|
||||||
|
if (isEditable(e.target)) window.GearslipField.focused(e.target.value || '');
|
||||||
|
});
|
||||||
|
document.addEventListener('focusout', function(e) {
|
||||||
|
if (isEditable(e.target)) window.GearslipField.blurred();
|
||||||
|
});
|
||||||
|
})();
|
||||||
|
"""
|
||||||
@@ -1,11 +1,19 @@
|
|||||||
package app.seb3thehacker.gearslip.host
|
package app.seb3thehacker.gearslip.host
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.pm.PackageManager
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Google's own Android Auto, in whatever guise it appears. Gearslip replaces it, so its entries
|
* Google's own Android Auto, in whatever guise it appears. Gearslip replaces it, so its entries
|
||||||
* (there are many: one per screen it registers) are noise in every list of apps to open.
|
* (there are many: one per screen it registers) are noise in every list of apps to open.
|
||||||
*/
|
*/
|
||||||
object AndroidAuto {
|
object AndroidAuto {
|
||||||
|
|
||||||
|
/** Kept in sync with the `<queries><package>` entries in the manifest: package visibility
|
||||||
|
* hides an app's presence entirely unless one names it (or a broader query matches it), and
|
||||||
|
* this one has no launcher icon on current builds for a launcher-category query to find. */
|
||||||
|
private val KNOWN_PACKAGES = listOf("com.google.android.projection.gearhead")
|
||||||
|
|
||||||
private val PACKAGE_MARKERS = listOf("gearhead", "projection.gearhead", "android.auto", "androidauto")
|
private val PACKAGE_MARKERS = listOf("gearhead", "projection.gearhead", "android.auto", "androidauto")
|
||||||
|
|
||||||
fun matches(packageName: String, label: String): Boolean {
|
fun matches(packageName: String, label: String): Boolean {
|
||||||
@@ -14,4 +22,26 @@ object AndroidAuto {
|
|||||||
val name = label.lowercase().replace(Regex("[^a-z ]"), " ")
|
val name = label.lowercase().replace(Regex("[^a-z ]"), " ")
|
||||||
return "android auto" in name || "androidauto" in name.replace(" ", "")
|
return "android auto" in name || "androidauto" in name.replace(" ", "")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** The package name of Google's Android Auto, if it is installed. */
|
||||||
|
fun installedPackage(context: Context): String? {
|
||||||
|
val manager = context.packageManager
|
||||||
|
return KNOWN_PACKAGES.firstOrNull { pkg ->
|
||||||
|
runCatching { manager.getPackageInfo(pkg, 0) }.isSuccess
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether [packageName] can still fight Gearslip for the USB connection: installed, and not
|
||||||
|
* already disabled for this user. `ApplicationInfo.enabled` only reflects the manifest's
|
||||||
|
* static default, not a driver's own Settings toggle, so this reads the actual runtime
|
||||||
|
* setting instead - a driver who disabled it instead of uninstalling it (the app info screen
|
||||||
|
* offers both) has already done what this step asks.
|
||||||
|
*/
|
||||||
|
fun isActive(context: Context, packageName: String): Boolean {
|
||||||
|
val setting = runCatching { context.packageManager.getApplicationEnabledSetting(packageName) }
|
||||||
|
.getOrDefault(PackageManager.COMPONENT_ENABLED_STATE_DEFAULT)
|
||||||
|
return setting == PackageManager.COMPONENT_ENABLED_STATE_DEFAULT ||
|
||||||
|
setting == PackageManager.COMPONENT_ENABLED_STATE_ENABLED
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -41,6 +41,7 @@ object CarAppCatalog {
|
|||||||
.asSequence()
|
.asSequence()
|
||||||
.filter { it.serviceInfo.packageName != context.packageName }
|
.filter { it.serviceInfo.packageName != context.packageName }
|
||||||
.filterNot { AndroidAuto.matches(it.serviceInfo.packageName, it.loadLabel(manager).toString()) }
|
.filterNot { AndroidAuto.matches(it.serviceInfo.packageName, it.loadLabel(manager).toString()) }
|
||||||
|
.filterNot { KnownApps.isHidden(it.serviceInfo.packageName) }
|
||||||
.map { resolved ->
|
.map { resolved ->
|
||||||
val info = resolved.serviceInfo
|
val info = resolved.serviceInfo
|
||||||
TemplateApp(
|
TemplateApp(
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ import androidx.car.app.suggestion.ISuggestionHost
|
|||||||
import androidx.car.app.hardware.ICarHardwareHost
|
import androidx.car.app.hardware.ICarHardwareHost
|
||||||
import androidx.car.app.hardware.ICarHardwareResult
|
import androidx.car.app.hardware.ICarHardwareResult
|
||||||
import androidx.car.app.navigation.INavigationHost
|
import androidx.car.app.navigation.INavigationHost
|
||||||
|
import androidx.car.app.navigation.INavigationManager
|
||||||
import androidx.car.app.serialization.Bundleable
|
import androidx.car.app.serialization.Bundleable
|
||||||
import androidx.car.app.versioning.CarAppApiLevels
|
import androidx.car.app.versioning.CarAppApiLevels
|
||||||
import app.seb3thehacker.gearslip.GearslipLog
|
import app.seb3thehacker.gearslip.GearslipLog
|
||||||
@@ -81,6 +82,8 @@ class CarAppConnection(private val context: Context) {
|
|||||||
|
|
||||||
private var carApp: ICarApp? = null
|
private var carApp: ICarApp? = null
|
||||||
private var appManager: IAppManager? = null
|
private var appManager: IAppManager? = null
|
||||||
|
/** Only present for an app that asked for it - most apps aren't navigation apps. */
|
||||||
|
private var navigationManager: INavigationManager? = null
|
||||||
/** Main thread only, both of these - the handoff is a race between them either way round. */
|
/** Main thread only, both of these - the handoff is a race between them either way round. */
|
||||||
private var surfaceCallback: ISurfaceCallback? = null
|
private var surfaceCallback: ISurfaceCallback? = null
|
||||||
private var lent: Lent? = null
|
private var lent: Lent? = null
|
||||||
@@ -189,6 +192,7 @@ class CarAppConnection(private val context: Context) {
|
|||||||
binding = null
|
binding = null
|
||||||
this.carApp = null
|
this.carApp = null
|
||||||
appManager = null
|
appManager = null
|
||||||
|
navigationManager = null
|
||||||
_panMode.value = false
|
_panMode.value = false
|
||||||
surfaceCallback = null
|
surfaceCallback = null
|
||||||
component = null
|
component = null
|
||||||
@@ -269,6 +273,37 @@ class CarAppConnection(private val context: Context) {
|
|||||||
if (appManager == null) GearslipLog.e("host: app manager was ${value?.javaClass?.name}")
|
if (appManager == null) GearslipLog.e("host: app manager was ${value?.javaClass?.name}")
|
||||||
requestTemplate()
|
requestTemplate()
|
||||||
}) { app.getManager(CarContext.APP_SERVICE, it) }
|
}) { app.getManager(CarContext.APP_SERVICE, it) }
|
||||||
|
|
||||||
|
// Best-effort and silent: an app that never asked for NavigationManager (not every
|
||||||
|
// templated app navigates) simply has no binder to hand back, which is normal, not a
|
||||||
|
// failure worth surfacing.
|
||||||
|
runCatching {
|
||||||
|
app.getManager(CarContext.NAVIGATION_SERVICE, object : IOnDoneCallback.Stub() {
|
||||||
|
override fun onSuccess(response: Bundleable?) {
|
||||||
|
val value = response?.let { runCatching { it.get() }.getOrNull() }
|
||||||
|
navigationManager = when (value) {
|
||||||
|
is INavigationManager -> value
|
||||||
|
is IBinder -> INavigationManager.Stub.asInterface(value)
|
||||||
|
else -> null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
override fun onFailure(response: Bundleable?) = Unit
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tells the app itself to stop navigating - the proper end of a route, not just leaving its
|
||||||
|
* screen. Ending only the on-screen guidance (a back press) leaves the app's own
|
||||||
|
* "am I navigating" flag set, which is what stopped a fresh route from starting afterwards;
|
||||||
|
* this is the call [NavigationManagerCallback.onStopNavigation] on the app's side answers,
|
||||||
|
* and it is expected to clear that flag and present its own post-navigation screen.
|
||||||
|
*/
|
||||||
|
fun stopNavigating() {
|
||||||
|
val manager = navigationManager ?: return
|
||||||
|
GearslipLog.i("host: asking the app to stop navigating")
|
||||||
|
runCatching { manager.onStopNavigation(noop("stop navigation")) }
|
||||||
|
.onFailure { GearslipLog.w("host: could not stop navigation: ${it.message}") }
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The app describes its current screen; this is what the host draws. */
|
/** The app describes its current screen; this is what the host draws. */
|
||||||
|
|||||||
@@ -91,6 +91,17 @@ fun remainingTime(seconds: Long): String {
|
|||||||
return if (hours > 0) "$hours hr $minutes min" else "$minutes min"
|
return if (hours > 0) "$hours hr $minutes min" else "$minutes min"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** "1:10 AM" - the clock at the destination, not the phone's own, in case a route crosses a zone. */
|
||||||
|
fun androidx.car.app.model.DateTimeWithZone?.clockTime(): String {
|
||||||
|
val at = this ?: return ""
|
||||||
|
val shifted = java.util.Date(at.timeSinceEpochMillis + at.zoneOffsetSeconds * 1000L)
|
||||||
|
return runCatching {
|
||||||
|
java.text.SimpleDateFormat("h:mm a", java.util.Locale.getDefault()).apply {
|
||||||
|
timeZone = java.util.TimeZone.getTimeZone("UTC")
|
||||||
|
}.format(shifted)
|
||||||
|
}.getOrDefault("")
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Tells the app something was tapped.
|
* Tells the app something was tapped.
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -14,7 +14,28 @@ object KnownApps {
|
|||||||
"app.organicmaps", // Organic Maps: map, search, routing, settings
|
"app.organicmaps", // Organic Maps: map, search, routing, settings
|
||||||
"net.osmand.plus", // OsmAnd: map, search, routing, settings
|
"net.osmand.plus", // OsmAnd: map, search, routing, settings
|
||||||
"in.krosbits.musicolet", // Musicolet: browse, play, seek, lyrics
|
"in.krosbits.musicolet", // Musicolet: browse, play, seek, lyrics
|
||||||
|
"com.kododake.aabrowser", // Aardvark: browser template, starts, draws, takes touches
|
||||||
|
"com.vivi.vivimusic", // Vivi Music: browse, play, seek, works well
|
||||||
)
|
)
|
||||||
|
|
||||||
fun works(packageName: String): Boolean = packageName in working
|
fun works(packageName: String): Boolean = packageName in working
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Registers a car template service but never gives Gearslip a usable screen - left off the
|
||||||
|
* launcher entirely rather than shown as a tile that goes nowhere.
|
||||||
|
*/
|
||||||
|
private val hidden = setOf(
|
||||||
|
"com.google.android.dialer", // Google Phone
|
||||||
|
"com.google.android.googlequicksearchbox", // Google Assistant driving surfaces
|
||||||
|
)
|
||||||
|
|
||||||
|
fun isHidden(packageName: String): Boolean = packageName in hidden
|
||||||
|
|
||||||
|
/** Shown on the launcher with a red X: known broken, but expected enough that hiding it outright would look like a bug. */
|
||||||
|
private val broken = setOf(
|
||||||
|
"com.google.android.apps.messaging", // Google Messages
|
||||||
|
"com.waze", // Waze
|
||||||
|
)
|
||||||
|
|
||||||
|
fun isBroken(packageName: String): Boolean = packageName in broken
|
||||||
}
|
}
|
||||||
@@ -6,6 +6,7 @@ import android.graphics.Bitmap
|
|||||||
import android.media.MediaMetadata
|
import android.media.MediaMetadata
|
||||||
import android.media.browse.MediaBrowser
|
import android.media.browse.MediaBrowser
|
||||||
import android.media.session.MediaController
|
import android.media.session.MediaController
|
||||||
|
import android.media.session.MediaSession
|
||||||
import android.media.session.MediaSessionManager
|
import android.media.session.MediaSessionManager
|
||||||
import android.media.session.PlaybackState
|
import android.media.session.PlaybackState
|
||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
@@ -33,6 +34,15 @@ class MediaEntry(
|
|||||||
|
|
||||||
class CustomAction(val id: String, val name: String, val extras: Bundle?)
|
class CustomAction(val id: String, val name: String, val extras: Bundle?)
|
||||||
|
|
||||||
|
/** One entry in the app's own queue - not its library, whatever it has lined up to play next. */
|
||||||
|
class QueueTrack(
|
||||||
|
val queueId: Long,
|
||||||
|
val title: String,
|
||||||
|
val subtitle: String,
|
||||||
|
val iconUri: Uri?,
|
||||||
|
val iconBitmap: Bitmap?,
|
||||||
|
)
|
||||||
|
|
||||||
/** What is playing, as the car's now-playing panel needs it. */
|
/** What is playing, as the car's now-playing panel needs it. */
|
||||||
class NowPlaying(
|
class NowPlaying(
|
||||||
val title: String = "",
|
val title: String = "",
|
||||||
@@ -48,6 +58,8 @@ class NowPlaying(
|
|||||||
val actions: Long = 0,
|
val actions: Long = 0,
|
||||||
val custom: List<CustomAction> = emptyList(),
|
val custom: List<CustomAction> = emptyList(),
|
||||||
val error: String? = null,
|
val error: String? = null,
|
||||||
|
/** Which [QueueTrack.queueId] is playing now, so "up next" can show what follows it. */
|
||||||
|
val activeQueueItemId: Long = MediaSession.QueueItem.UNKNOWN_ID.toLong(),
|
||||||
) {
|
) {
|
||||||
val playing get() = state == PlaybackState.STATE_PLAYING || state == PlaybackState.STATE_BUFFERING
|
val playing get() = state == PlaybackState.STATE_PLAYING || state == PlaybackState.STATE_BUFFERING
|
||||||
val hasTrack get() = title.isNotEmpty() || state != PlaybackState.STATE_NONE
|
val hasTrack get() = title.isNotEmpty() || state != PlaybackState.STATE_NONE
|
||||||
@@ -109,6 +121,9 @@ class CarMedia(private val context: Context) {
|
|||||||
private val _now = MutableStateFlow(NowPlaying())
|
private val _now = MutableStateFlow(NowPlaying())
|
||||||
val now: StateFlow<NowPlaying> = _now.asStateFlow()
|
val now: StateFlow<NowPlaying> = _now.asStateFlow()
|
||||||
|
|
||||||
|
private val _queue = MutableStateFlow<List<QueueTrack>>(emptyList())
|
||||||
|
val queue: StateFlow<List<QueueTrack>> = _queue.asStateFlow()
|
||||||
|
|
||||||
private var browser: MediaBrowser? = null
|
private var browser: MediaBrowser? = null
|
||||||
private var controller: MediaController? = null
|
private var controller: MediaController? = null
|
||||||
private var subscribed: String? = null
|
private var subscribed: String? = null
|
||||||
@@ -171,6 +186,7 @@ class CarMedia(private val context: Context) {
|
|||||||
controller = session.also {
|
controller = session.also {
|
||||||
it.registerCallback(callback)
|
it.registerCallback(callback)
|
||||||
update(it.metadata, it.playbackState)
|
update(it.metadata, it.playbackState)
|
||||||
|
_queue.value = queueOf(it.queue)
|
||||||
}
|
}
|
||||||
_browse.value = BrowseState(loading = false, unavailable = true)
|
_browse.value = BrowseState(loading = false, unavailable = true)
|
||||||
_phase.value = Phase.READY
|
_phase.value = Phase.READY
|
||||||
@@ -182,6 +198,7 @@ class CarMedia(private val context: Context) {
|
|||||||
controller = MediaController(context, mb.sessionToken).also {
|
controller = MediaController(context, mb.sessionToken).also {
|
||||||
it.registerCallback(callback)
|
it.registerCallback(callback)
|
||||||
update(it.metadata, it.playbackState)
|
update(it.metadata, it.playbackState)
|
||||||
|
_queue.value = queueOf(it.queue)
|
||||||
}
|
}
|
||||||
rootId = mb.root
|
rootId = mb.root
|
||||||
path.clear()
|
path.clear()
|
||||||
@@ -192,6 +209,20 @@ class CarMedia(private val context: Context) {
|
|||||||
private val callback = object : MediaController.Callback() {
|
private val callback = object : MediaController.Callback() {
|
||||||
override fun onMetadataChanged(metadata: MediaMetadata?) = update(metadata, controller?.playbackState)
|
override fun onMetadataChanged(metadata: MediaMetadata?) = update(metadata, controller?.playbackState)
|
||||||
override fun onPlaybackStateChanged(state: PlaybackState?) = update(controller?.metadata, state)
|
override fun onPlaybackStateChanged(state: PlaybackState?) = update(controller?.metadata, state)
|
||||||
|
override fun onQueueChanged(queue: MutableList<MediaSession.QueueItem>?) {
|
||||||
|
_queue.value = queueOf(queue)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun queueOf(items: List<MediaSession.QueueItem>?): List<QueueTrack> = items.orEmpty().map { item ->
|
||||||
|
val d = item.description
|
||||||
|
QueueTrack(
|
||||||
|
queueId = item.queueId,
|
||||||
|
title = d.title?.toString().orEmpty(),
|
||||||
|
subtitle = d.subtitle?.toString().orEmpty(),
|
||||||
|
iconUri = d.iconUri,
|
||||||
|
iconBitmap = d.iconBitmap,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun update(metadata: MediaMetadata?, state: PlaybackState?) {
|
private fun update(metadata: MediaMetadata?, state: PlaybackState?) {
|
||||||
@@ -212,6 +243,7 @@ class CarMedia(private val context: Context) {
|
|||||||
actions = state?.actions ?: 0,
|
actions = state?.actions ?: 0,
|
||||||
custom = state?.customActions.orEmpty().map { CustomAction(it.action, it.name.toString(), it.extras) },
|
custom = state?.customActions.orEmpty().map { CustomAction(it.action, it.name.toString(), it.extras) },
|
||||||
error = state?.errorMessage?.toString(),
|
error = state?.errorMessage?.toString(),
|
||||||
|
activeQueueItemId = state?.activeQueueItemId ?: MediaSession.QueueItem.UNKNOWN_ID.toLong(),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -286,6 +318,7 @@ class CarMedia(private val context: Context) {
|
|||||||
fun previous() = controller?.transportControls?.skipToPrevious()
|
fun previous() = controller?.transportControls?.skipToPrevious()
|
||||||
fun seek(ms: Long) = controller?.transportControls?.seekTo(ms)
|
fun seek(ms: Long) = controller?.transportControls?.seekTo(ms)
|
||||||
fun custom(action: CustomAction) = controller?.transportControls?.sendCustomAction(action.id, action.extras)
|
fun custom(action: CustomAction) = controller?.transportControls?.sendCustomAction(action.id, action.extras)
|
||||||
|
fun playQueueItem(track: QueueTrack) = controller?.transportControls?.skipToQueueItem(track.queueId)
|
||||||
|
|
||||||
fun disconnect() {
|
fun disconnect() {
|
||||||
connecting = null
|
connecting = null
|
||||||
@@ -298,6 +331,7 @@ class CarMedia(private val context: Context) {
|
|||||||
subscribed = null
|
subscribed = null
|
||||||
_phase.value = Phase.IDLE
|
_phase.value = Phase.IDLE
|
||||||
_now.value = NowPlaying()
|
_now.value = NowPlaying()
|
||||||
|
_queue.value = emptyList()
|
||||||
}
|
}
|
||||||
|
|
||||||
private companion object {
|
private companion object {
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package app.seb3thehacker.gearslip.media
|
package app.seb3thehacker.gearslip.media
|
||||||
|
|
||||||
import app.seb3thehacker.gearslip.host.AndroidAuto
|
import app.seb3thehacker.gearslip.host.AndroidAuto
|
||||||
|
import app.seb3thehacker.gearslip.host.KnownApps
|
||||||
import android.content.ComponentName
|
import android.content.ComponentName
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
@@ -33,6 +34,7 @@ object MediaCatalog {
|
|||||||
.asSequence()
|
.asSequence()
|
||||||
.filter { it.serviceInfo.packageName != context.packageName }
|
.filter { it.serviceInfo.packageName != context.packageName }
|
||||||
.filterNot { AndroidAuto.matches(it.serviceInfo.packageName, it.loadLabel(manager).toString()) }
|
.filterNot { AndroidAuto.matches(it.serviceInfo.packageName, it.loadLabel(manager).toString()) }
|
||||||
|
.filterNot { KnownApps.isHidden(it.serviceInfo.packageName) }
|
||||||
.filter { declaresCar(manager, it.serviceInfo) }
|
.filter { declaresCar(manager, it.serviceInfo) }
|
||||||
.map {
|
.map {
|
||||||
val info = it.serviceInfo
|
val info = it.serviceInfo
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ package app.seb3thehacker.gearslip.ui
|
|||||||
import android.app.Activity
|
import android.app.Activity
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.content.ContextWrapper
|
import android.content.ContextWrapper
|
||||||
import android.content.pm.ActivityInfo
|
import android.content.res.Configuration
|
||||||
import android.graphics.Bitmap
|
import android.graphics.Bitmap
|
||||||
import android.graphics.PixelFormat
|
import android.graphics.PixelFormat
|
||||||
import android.hardware.HardwareBuffer
|
import android.hardware.HardwareBuffer
|
||||||
@@ -43,6 +43,7 @@ import androidx.compose.ui.graphics.asImageBitmap
|
|||||||
import androidx.compose.ui.input.pointer.pointerInteropFilter
|
import androidx.compose.ui.input.pointer.pointerInteropFilter
|
||||||
import androidx.compose.ui.layout.ContentScale
|
import androidx.compose.ui.layout.ContentScale
|
||||||
import androidx.compose.ui.layout.onSizeChanged
|
import androidx.compose.ui.layout.onSizeChanged
|
||||||
|
import androidx.compose.ui.platform.LocalConfiguration
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.core.view.WindowInsetsCompat
|
import androidx.core.view.WindowInsetsCompat
|
||||||
@@ -70,7 +71,9 @@ fun CarPreviewScreen(onBack: () -> Unit) {
|
|||||||
val frame by CarEnvironment.frame.collectAsStateWithLifecycle()
|
val frame by CarEnvironment.frame.collectAsStateWithLifecycle()
|
||||||
var bitmap by remember { mutableStateOf<Bitmap?>(null) }
|
var bitmap by remember { mutableStateOf<Bitmap?>(null) }
|
||||||
var viewSize by remember { mutableStateOf(0 to 0) }
|
var viewSize by remember { mutableStateOf(0 to 0) }
|
||||||
var fullscreen by remember { mutableStateOf(false) }
|
// Tied to how the phone itself is held, not a button: turn it sideways and the preview goes
|
||||||
|
// fullscreen, turn it back and the phone's own chrome (the Back/title above the frame) returns.
|
||||||
|
val fullscreen = LocalConfiguration.current.orientation == Configuration.ORIENTATION_LANDSCAPE
|
||||||
|
|
||||||
val width = frame.width
|
val width = frame.width
|
||||||
val height = frame.height
|
val height = frame.height
|
||||||
@@ -149,7 +152,7 @@ fun CarPreviewScreen(onBack: () -> Unit) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (fullscreen) {
|
if (fullscreen) {
|
||||||
Fullscreen(landscape = width > height, onExit = { fullscreen = false }) {
|
Fullscreen(onBack) {
|
||||||
// No fillMax here: left to the bounds alone, aspectRatio takes the whole width or the
|
// No fillMax here: left to the bounds alone, aspectRatio takes the whole width or the
|
||||||
// whole height, whichever the car's shape allows, and the rest stays black.
|
// whole height, whichever the car's shape allows, and the rest stays black.
|
||||||
frameContent(Modifier)
|
frameContent(Modifier)
|
||||||
@@ -174,7 +177,6 @@ fun CarPreviewScreen(onBack: () -> Unit) {
|
|||||||
|
|
||||||
Row {
|
Row {
|
||||||
TextButton(onClick = onBack) { Text("Back") }
|
TextButton(onClick = onBack) { Text("Back") }
|
||||||
TextButton(onClick = { fullscreen = true }) { Text("Fullscreen") }
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -184,34 +186,32 @@ fun CarPreviewScreen(onBack: () -> Unit) {
|
|||||||
* Fills the phone screen with [content], centred and letterboxed.
|
* Fills the phone screen with [content], centred and letterboxed.
|
||||||
*
|
*
|
||||||
* A car screen is wider than it is tall, so a portrait phone would waste most of its height on
|
* A car screen is wider than it is tall, so a portrait phone would waste most of its height on
|
||||||
* one. While this is showing, the activity is turned to match and the system bars go away - both
|
* one. This only shows once the phone is already turned sideways (the caller drives it off the
|
||||||
* are undone on the way out, so the rest of the phone UI is unaffected.
|
* phone's own orientation), so there is no rotation left to force here - just the system bars to
|
||||||
|
* hide while it's up and restore on the way out.
|
||||||
*/
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
private fun Fullscreen(
|
private fun Fullscreen(
|
||||||
landscape: Boolean,
|
onBack: () -> Unit,
|
||||||
onExit: () -> Unit,
|
|
||||||
content: @Composable BoxScope.() -> Unit,
|
content: @Composable BoxScope.() -> Unit,
|
||||||
) {
|
) {
|
||||||
val activity = LocalContext.current.activity()
|
val activity = LocalContext.current.activity()
|
||||||
|
|
||||||
DisposableEffect(activity, landscape) {
|
DisposableEffect(activity) {
|
||||||
if (activity == null) return@DisposableEffect onDispose { }
|
if (activity == null) return@DisposableEffect onDispose { }
|
||||||
|
|
||||||
val insets = WindowInsetsControllerCompat(activity.window, activity.window.decorView).apply {
|
val insets = WindowInsetsControllerCompat(activity.window, activity.window.decorView).apply {
|
||||||
systemBarsBehavior = WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE
|
systemBarsBehavior = WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE
|
||||||
hide(WindowInsetsCompat.Type.systemBars())
|
hide(WindowInsetsCompat.Type.systemBars())
|
||||||
}
|
}
|
||||||
val previousOrientation = activity.requestedOrientation
|
|
||||||
if (landscape) activity.requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_SENSOR_LANDSCAPE
|
|
||||||
|
|
||||||
onDispose {
|
onDispose { insets.show(WindowInsetsCompat.Type.systemBars()) }
|
||||||
insets.show(WindowInsetsCompat.Type.systemBars())
|
|
||||||
activity.requestedOrientation = previousOrientation
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
BackHandler(onBack = onExit)
|
// Turning the phone back upright is what normally leaves fullscreen; back still leaves the
|
||||||
|
// preview screen entirely rather than doing nothing, in case the phone's own rotation lock
|
||||||
|
// is on and turning it sideways/back isn't an option.
|
||||||
|
BackHandler(onBack = onBack)
|
||||||
|
|
||||||
Box(
|
Box(
|
||||||
Modifier.fillMaxSize().background(Color.Black),
|
Modifier.fillMaxSize().background(Color.Black),
|
||||||
@@ -222,10 +222,10 @@ private fun Fullscreen(
|
|||||||
// The frame swallows every touch inside it, so leaving needs either the back gesture or
|
// The frame swallows every touch inside it, so leaving needs either the back gesture or
|
||||||
// a button of its own. This one sits in the corner the car UI leaves emptiest.
|
// a button of its own. This one sits in the corner the car UI leaves emptiest.
|
||||||
FilledTonalIconButton(
|
FilledTonalIconButton(
|
||||||
onClick = onExit,
|
onClick = onBack,
|
||||||
modifier = Modifier.align(Alignment.BottomStart).padding(8.dp),
|
modifier = Modifier.align(Alignment.BottomStart).padding(8.dp),
|
||||||
) {
|
) {
|
||||||
Icon(Icons.Filled.Close, contentDescription = "Leave fullscreen")
|
Icon(Icons.Filled.Close, contentDescription = "Leave the car preview")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,265 @@
|
|||||||
|
package app.seb3thehacker.gearslip.ui
|
||||||
|
|
||||||
|
import android.net.Uri
|
||||||
|
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||||
|
import androidx.activity.result.contract.ActivityResultContracts
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.RowScope
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.rememberScrollState
|
||||||
|
import androidx.compose.foundation.text.KeyboardOptions
|
||||||
|
import androidx.compose.foundation.verticalScroll
|
||||||
|
import androidx.compose.material3.AlertDialog
|
||||||
|
import androidx.compose.material3.Button
|
||||||
|
import androidx.compose.material3.CircularProgressIndicator
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.OutlinedButton
|
||||||
|
import androidx.compose.material3.OutlinedTextField
|
||||||
|
import androidx.compose.material3.Scaffold
|
||||||
|
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.rememberCoroutineScope
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.text.input.KeyboardType
|
||||||
|
import androidx.compose.ui.text.input.PasswordVisualTransformation
|
||||||
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import app.seb3thehacker.gearslip.AppSettings
|
||||||
|
import app.seb3thehacker.gearslip.CertProvider
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shown when Gearslip has no certificate loaded. Download is the one clear path; importing your
|
||||||
|
* own file is offered as a smaller, secondary option underneath it.
|
||||||
|
*
|
||||||
|
* The app ships no certificates. Skipping generates a self-signed one, which a real head unit
|
||||||
|
* rejects.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun CertSetupScreen(onDone: () -> Unit) {
|
||||||
|
val context = LocalContext.current
|
||||||
|
val scope = rememberCoroutineScope()
|
||||||
|
|
||||||
|
var downloadState by remember { mutableStateOf<DownloadState>(DownloadState.Idle) }
|
||||||
|
var showImport by remember { mutableStateOf(false) }
|
||||||
|
var pendingUri by remember { mutableStateOf<Uri?>(null) }
|
||||||
|
var manualProgress by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
|
val importLauncher = rememberLauncherForActivityResult(ActivityResultContracts.OpenDocument()) { uri ->
|
||||||
|
if (uri != null) pendingUri = uri
|
||||||
|
}
|
||||||
|
|
||||||
|
val installed = manualProgress || downloadState is DownloadState.Success
|
||||||
|
|
||||||
|
Scaffold(containerColor = MaterialTheme.colorScheme.background) { padding ->
|
||||||
|
Column(
|
||||||
|
Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.padding(padding)
|
||||||
|
.padding(horizontal = 24.dp)
|
||||||
|
.verticalScroll(rememberScrollState()),
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
verticalArrangement = Arrangement.Center,
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
"Certificate Setup",
|
||||||
|
style = MaterialTheme.typography.headlineLarge,
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
color = MaterialTheme.colorScheme.onBackground,
|
||||||
|
)
|
||||||
|
Spacer(Modifier.height(8.dp))
|
||||||
|
Text(
|
||||||
|
if (installed) {
|
||||||
|
"Cars accept only Google-issued certificates, and you have one loaded."
|
||||||
|
} else {
|
||||||
|
"Cars accept only Google-issued certificates. Download one to connect."
|
||||||
|
},
|
||||||
|
style = MaterialTheme.typography.bodyLarge,
|
||||||
|
textAlign = TextAlign.Center,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
Spacer(Modifier.height(32.dp))
|
||||||
|
|
||||||
|
if (installed) {
|
||||||
|
Text(
|
||||||
|
if (manualProgress) "Certificate imported." else "Certificate installed.",
|
||||||
|
style = MaterialTheme.typography.titleMedium,
|
||||||
|
fontWeight = FontWeight.SemiBold,
|
||||||
|
color = MaterialTheme.colorScheme.primary,
|
||||||
|
)
|
||||||
|
Spacer(Modifier.height(20.dp))
|
||||||
|
Button(onClick = onDone, modifier = Modifier.fillMaxWidth().height(56.dp)) {
|
||||||
|
Text("Continue")
|
||||||
|
}
|
||||||
|
Spacer(Modifier.height(40.dp))
|
||||||
|
return@Column
|
||||||
|
}
|
||||||
|
|
||||||
|
when (downloadState) {
|
||||||
|
is DownloadState.Idle -> {
|
||||||
|
Button(
|
||||||
|
onClick = {
|
||||||
|
downloadState = DownloadState.Loading
|
||||||
|
scope.launch {
|
||||||
|
val result = withContext(Dispatchers.IO) {
|
||||||
|
runCatching { CertProvider.downloadAndImport(context) }
|
||||||
|
}
|
||||||
|
downloadState = result.fold(
|
||||||
|
onSuccess = { DownloadState.Success(it.certificate.subjectX500Principal.name) },
|
||||||
|
onFailure = { DownloadState.Error(it.message ?: "Unknown error") },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
modifier = Modifier.fillMaxWidth().height(56.dp),
|
||||||
|
) {
|
||||||
|
Text("Download certificate")
|
||||||
|
}
|
||||||
|
Text(
|
||||||
|
"Works with most cars built before 2020. Newer cars reject it.",
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
textAlign = TextAlign.Center,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
modifier = Modifier.padding(top = 8.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
is DownloadState.Loading -> {
|
||||||
|
Box(Modifier.fillMaxWidth().height(56.dp), contentAlignment = Alignment.Center) {
|
||||||
|
DownloadingRow {
|
||||||
|
CircularProgressIndicator(Modifier.size(20.dp), strokeWidth = 2.dp)
|
||||||
|
Text("Downloading…", style = MaterialTheme.typography.bodyLarge)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
is DownloadState.Success -> Unit // handled above via `installed`
|
||||||
|
|
||||||
|
is DownloadState.Error -> {
|
||||||
|
Surface(color = MaterialTheme.colorScheme.errorContainer, shape = MaterialTheme.shapes.medium) {
|
||||||
|
Column(Modifier.fillMaxWidth().padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
|
Text(
|
||||||
|
"Download failed",
|
||||||
|
style = MaterialTheme.typography.titleMedium,
|
||||||
|
fontWeight = FontWeight.SemiBold,
|
||||||
|
color = MaterialTheme.colorScheme.onErrorContainer,
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
(downloadState as DownloadState.Error).message,
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onErrorContainer,
|
||||||
|
)
|
||||||
|
Button(onClick = { downloadState = DownloadState.Idle }, modifier = Modifier.fillMaxWidth()) {
|
||||||
|
Text("Retry")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(Modifier.height(16.dp))
|
||||||
|
|
||||||
|
if (showImport) {
|
||||||
|
OutlinedButton(
|
||||||
|
onClick = { importLauncher.launch(arrayOf("*/*")) },
|
||||||
|
modifier = Modifier.fillMaxWidth().height(52.dp),
|
||||||
|
) { Text("Import your own .p12 cert") }
|
||||||
|
Spacer(Modifier.height(16.dp))
|
||||||
|
} else {
|
||||||
|
TextButton(onClick = { showImport = true }) { Text("Import your own certificate instead") }
|
||||||
|
Spacer(Modifier.height(8.dp))
|
||||||
|
}
|
||||||
|
|
||||||
|
OutlinedButton(
|
||||||
|
onClick = {
|
||||||
|
AppSettings.setSkippedCertSetup(context)
|
||||||
|
onDone()
|
||||||
|
},
|
||||||
|
modifier = Modifier.fillMaxWidth().height(52.dp),
|
||||||
|
) {
|
||||||
|
Text("Skip car connection setup")
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(Modifier.height(40.dp))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pendingUri?.let { uri ->
|
||||||
|
PasswordDialog(
|
||||||
|
onDismiss = { pendingUri = null },
|
||||||
|
onConfirm = { password, reportError ->
|
||||||
|
scope.launch {
|
||||||
|
val result = withContext(Dispatchers.IO) {
|
||||||
|
runCatching { CertProvider.importFrom(context, uri, password) }
|
||||||
|
}
|
||||||
|
result.onSuccess {
|
||||||
|
pendingUri = null
|
||||||
|
manualProgress = true
|
||||||
|
}.onFailure {
|
||||||
|
reportError("Wrong password or not a valid PKCS#12 file with a private key.")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A tight horizontal row for the inline "Downloading…" spinner state. */
|
||||||
|
@Composable
|
||||||
|
private fun DownloadingRow(content: @Composable RowScope.() -> Unit) {
|
||||||
|
Row(
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(10.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
content = content,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class DownloadState {
|
||||||
|
data object Idle : DownloadState()
|
||||||
|
data object Loading : DownloadState()
|
||||||
|
data class Success(val subjectName: String) : DownloadState()
|
||||||
|
data class Error(val message: String) : DownloadState()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun PasswordDialog(onDismiss: () -> Unit, onConfirm: (String, (String) -> Unit) -> Unit) {
|
||||||
|
var password by remember { mutableStateOf("") }
|
||||||
|
var error by remember { mutableStateOf<String?>(null) }
|
||||||
|
|
||||||
|
AlertDialog(
|
||||||
|
onDismissRequest = onDismiss,
|
||||||
|
title = { Text("Certificate password") },
|
||||||
|
text = {
|
||||||
|
OutlinedTextField(
|
||||||
|
value = password,
|
||||||
|
onValueChange = { password = it; error = null },
|
||||||
|
singleLine = true,
|
||||||
|
label = { Text("Password") },
|
||||||
|
visualTransformation = PasswordVisualTransformation(),
|
||||||
|
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password),
|
||||||
|
isError = error != null,
|
||||||
|
supportingText = { Text(error ?: "Leave empty if the file has none.") },
|
||||||
|
)
|
||||||
|
},
|
||||||
|
confirmButton = { TextButton(onClick = { onConfirm(password) { error = it } }) { Text("Import") } },
|
||||||
|
dismissButton = { TextButton(onClick = onDismiss) { Text("Cancel") } },
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -19,12 +19,17 @@ data class CertSummary(
|
|||||||
val identity = CertProvider.loadSupplied(context)
|
val identity = CertProvider.loadSupplied(context)
|
||||||
?: return CertSummary(
|
?: return CertSummary(
|
||||||
CertProvider.Kind.SELF_SIGNED,
|
CertProvider.Kind.SELF_SIGNED,
|
||||||
"None loaded - a self-signed one is generated, which real head units reject",
|
"None loaded (self-signed; cars reject it)",
|
||||||
null, null, null,
|
null, null, null,
|
||||||
)
|
)
|
||||||
val cert = identity.certificate
|
val cert = identity.certificate
|
||||||
val name = friendlyName(cert.subjectX500Principal.name)
|
val name = friendlyName(cert.subjectX500Principal.name)
|
||||||
val prefix = if (identity.kind == CertProvider.Kind.IMPORTED) "Imported" else "Staged over adb"
|
val prefix = when (identity.kind) {
|
||||||
|
CertProvider.Kind.IMPORTED -> "Imported"
|
||||||
|
CertProvider.Kind.DOWNLOADED -> "Downloaded"
|
||||||
|
CertProvider.Kind.ADB_STAGED -> "Staged over adb"
|
||||||
|
CertProvider.Kind.SELF_SIGNED -> "Self-signed"
|
||||||
|
}
|
||||||
return CertSummary(
|
return CertSummary(
|
||||||
identity.kind,
|
identity.kind,
|
||||||
"$prefix: $name",
|
"$prefix: $name",
|
||||||
|
|||||||
@@ -0,0 +1,50 @@
|
|||||||
|
package app.seb3thehacker.gearslip.ui
|
||||||
|
|
||||||
|
import android.widget.Toast
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Surface
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.platform.LocalClipboardManager
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.compose.ui.text.AnnotatedString
|
||||||
|
import androidx.compose.ui.text.font.FontFamily
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A command to run on a computer, shown so it can be copied rather than retyped. Tapping it puts
|
||||||
|
* it on the clipboard, which is the only way it reaches the computer from here anyway.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun CopyableCommand(command: String) {
|
||||||
|
val clipboard = LocalClipboardManager.current
|
||||||
|
val context = LocalContext.current
|
||||||
|
Surface(
|
||||||
|
color = MaterialTheme.colorScheme.surfaceContainerHighest,
|
||||||
|
shape = MaterialTheme.shapes.small,
|
||||||
|
onClick = {
|
||||||
|
clipboard.setText(AnnotatedString(command))
|
||||||
|
Toast.makeText(context, "Command copied", Toast.LENGTH_SHORT).show()
|
||||||
|
},
|
||||||
|
modifier = Modifier.fillMaxWidth().padding(top = 4.dp),
|
||||||
|
) {
|
||||||
|
Column(Modifier.padding(horizontal = 12.dp, vertical = 10.dp), verticalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||||
|
Text(
|
||||||
|
command,
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
fontFamily = FontFamily.Monospace,
|
||||||
|
color = MaterialTheme.colorScheme.onSurface,
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
"Tap to copy",
|
||||||
|
style = MaterialTheme.typography.labelSmall,
|
||||||
|
color = MaterialTheme.colorScheme.primary,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,17 +7,32 @@ import androidx.compose.material3.TextButton
|
|||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.runtime.saveable.rememberSaveable
|
import androidx.compose.runtime.saveable.rememberSaveable
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import app.seb3thehacker.gearslip.AppSettings
|
import app.seb3thehacker.gearslip.AppSettings
|
||||||
|
import app.seb3thehacker.gearslip.CertProvider
|
||||||
|
|
||||||
private enum class Screen { HOME, LOGS, SETTINGS, CAR_PREVIEW }
|
private enum class Screen { HOME, LOGS, SETTINGS, CAR_PREVIEW, CERT_SETUP, SETUP_GUIDE, HELP }
|
||||||
|
|
||||||
/** Three screens and a back stack of depth one: no navigation library needed. */
|
/** Three screens and a back stack of depth one: no navigation library needed. */
|
||||||
@Composable
|
@Composable
|
||||||
fun GearslipApp(onDisconnect: () -> Unit) {
|
fun GearslipApp(onDisconnect: () -> Unit, onRequestCallScreening: () -> Unit) {
|
||||||
var screen by rememberSaveable { mutableStateOf(Screen.HOME) }
|
val context = LocalContext.current
|
||||||
|
val needsCertSetup = remember {
|
||||||
|
!AppSettings.hasSkippedCertSetup(context) && !CertProvider.hasAnyCert(context)
|
||||||
|
}
|
||||||
|
val needsSetupGuide = remember { !AppSettings.hasSeenPermissionsSetup(context) }
|
||||||
|
var screen by rememberSaveable {
|
||||||
|
mutableStateOf(
|
||||||
|
when {
|
||||||
|
needsCertSetup -> Screen.CERT_SETUP
|
||||||
|
needsSetupGuide -> Screen.SETUP_GUIDE
|
||||||
|
else -> Screen.HOME
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
BackHandler(enabled = screen != Screen.HOME) { screen = Screen.HOME }
|
BackHandler(enabled = screen != Screen.HOME) { screen = Screen.HOME }
|
||||||
|
|
||||||
when (screen) {
|
when (screen) {
|
||||||
@@ -25,11 +40,22 @@ fun GearslipApp(onDisconnect: () -> Unit) {
|
|||||||
onOpenLogs = { screen = Screen.LOGS },
|
onOpenLogs = { screen = Screen.LOGS },
|
||||||
onOpenSettings = { screen = Screen.SETTINGS },
|
onOpenSettings = { screen = Screen.SETTINGS },
|
||||||
onOpenCarPreview = { screen = Screen.CAR_PREVIEW },
|
onOpenCarPreview = { screen = Screen.CAR_PREVIEW },
|
||||||
|
onOpenHelp = { screen = Screen.HELP },
|
||||||
onDisconnect = onDisconnect,
|
onDisconnect = onDisconnect,
|
||||||
)
|
)
|
||||||
Screen.LOGS -> LogsScreen(onBack = { screen = Screen.HOME })
|
Screen.LOGS -> LogsScreen(onBack = { screen = Screen.HOME })
|
||||||
Screen.SETTINGS -> SettingsScreen(onBack = { screen = Screen.HOME })
|
Screen.SETTINGS -> SettingsScreen(onBack = { screen = Screen.HOME })
|
||||||
Screen.CAR_PREVIEW -> CarPreviewScreen(onBack = { screen = Screen.HOME })
|
Screen.CAR_PREVIEW -> CarPreviewScreen(onBack = { screen = Screen.HOME })
|
||||||
|
Screen.HELP -> HelpScreen(onBack = { screen = Screen.HOME }, onRequestCallScreening = onRequestCallScreening)
|
||||||
|
Screen.CERT_SETUP -> CertSetupScreen(
|
||||||
|
onDone = {
|
||||||
|
screen = if (AppSettings.hasSeenPermissionsSetup(context)) Screen.HOME else Screen.SETUP_GUIDE
|
||||||
|
},
|
||||||
|
)
|
||||||
|
Screen.SETUP_GUIDE -> SetupGuideScreen(
|
||||||
|
onRequestCallScreening = onRequestCallScreening,
|
||||||
|
onDone = { screen = Screen.HOME },
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
CompatWarning()
|
CompatWarning()
|
||||||
@@ -47,9 +73,8 @@ private fun CompatWarning() {
|
|||||||
title = { Text("Before you plug in") },
|
title = { Text("Before you plug in") },
|
||||||
text = {
|
text = {
|
||||||
Text(
|
Text(
|
||||||
"Gearslip may not connect on cars or head units newer than about 2020. " +
|
"Gearslip works with cars built before 2020. " +
|
||||||
"Older systems are more likely to work; newer firmware often rejects the " +
|
"Newer firmware often blocks the connection.",
|
||||||
"connection outright.",
|
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
confirmButton = {
|
confirmButton = {
|
||||||
|
|||||||
@@ -0,0 +1,157 @@
|
|||||||
|
package app.seb3thehacker.gearslip.ui
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.width
|
||||||
|
import androidx.compose.foundation.rememberScrollState
|
||||||
|
import androidx.compose.foundation.verticalScroll
|
||||||
|
import androidx.compose.material3.FilledTonalButton
|
||||||
|
import androidx.compose.material3.IconButton
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Scaffold
|
||||||
|
import androidx.compose.material3.Surface
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One step of the setup list: a sentence that says what to do, and optionally how.
|
||||||
|
*
|
||||||
|
* [command] is for a step that cannot be done on the phone; it is shown as something to copy
|
||||||
|
* rather than to read, because a mistyped package name fails silently. [onClick] is for a step
|
||||||
|
* that can, in one tap, in which case it is shown as a button instead.
|
||||||
|
*/
|
||||||
|
private class Step(
|
||||||
|
val action: String,
|
||||||
|
val detail: String? = null,
|
||||||
|
val command: String? = null,
|
||||||
|
val buttonLabel: String? = null,
|
||||||
|
val onClick: (() -> Unit)? = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
private fun setupSteps(onRequestCallScreening: () -> Unit) = listOf(
|
||||||
|
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("Load a certificate.", "Open Settings, then download or import a certificate."),
|
||||||
|
Step(
|
||||||
|
"Allow the microphone, so music can reach the car.",
|
||||||
|
"Android asks for the microphone before it will let any app pass music along. " +
|
||||||
|
"Gearslip never listens to you: it copies what a music app is playing, and nothing else.",
|
||||||
|
),
|
||||||
|
Step(
|
||||||
|
"Optional: stop Android asking about audio every drive.",
|
||||||
|
"Connect the phone to a computer with USB debugging turned on, and run this command. " +
|
||||||
|
"It tells Android to trust Gearslip with audio from now on. Skip it and everything " +
|
||||||
|
"still works, but you have to tap Start once each time you set off.",
|
||||||
|
command = "adb shell appops set app.seb3thehacker.gearslip PROJECT_MEDIA allow",
|
||||||
|
),
|
||||||
|
Step(
|
||||||
|
"Optional: keep notifications readable while music plays.",
|
||||||
|
"Android treats sending audio to the car like sharing your screen, and hides what your " +
|
||||||
|
"notifications say. Run this command too, and Gearslip switches that off only while " +
|
||||||
|
"music is going to the car, then puts it back.",
|
||||||
|
command = "adb shell pm grant app.seb3thehacker.gearslip android.permission.WRITE_SECURE_SETTINGS",
|
||||||
|
),
|
||||||
|
Step(
|
||||||
|
"Optional: let the car screen answer and decline calls.",
|
||||||
|
"This sets Gearslip as your phone's caller ID and spam-blocking app, the same kind of " +
|
||||||
|
"consent a call-blocking app asks for. Without it, calls still ring, but only the " +
|
||||||
|
"phone itself can answer or decline them.",
|
||||||
|
buttonLabel = "Set up",
|
||||||
|
onClick = onRequestCallScreening,
|
||||||
|
),
|
||||||
|
Step("Plug the phone into the car.", "Gearslip opens by itself when the head unit connects."),
|
||||||
|
)
|
||||||
|
|
||||||
|
/** The full setup reference, behind the help button: what the one-time guide walks through, kept
|
||||||
|
* here too so it can be reread without repeating the guide itself. */
|
||||||
|
@Composable
|
||||||
|
fun HelpScreen(onBack: () -> Unit, onRequestCallScreening: () -> Unit) {
|
||||||
|
val steps = remember(onRequestCallScreening) { setupSteps(onRequestCallScreening) }
|
||||||
|
|
||||||
|
Scaffold(containerColor = MaterialTheme.colorScheme.background) { padding ->
|
||||||
|
Column(
|
||||||
|
Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.padding(padding)
|
||||||
|
.padding(horizontal = 24.dp)
|
||||||
|
.verticalScroll(rememberScrollState()),
|
||||||
|
) {
|
||||||
|
Spacer(Modifier.height(24.dp))
|
||||||
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
IconButton(onClick = onBack) { Text("‹", style = MaterialTheme.typography.headlineMedium) }
|
||||||
|
Text(
|
||||||
|
"How to connect",
|
||||||
|
style = MaterialTheme.typography.headlineSmall,
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
color = MaterialTheme.colorScheme.onBackground,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Spacer(Modifier.height(16.dp))
|
||||||
|
Column(verticalArrangement = Arrangement.spacedBy(18.dp)) {
|
||||||
|
steps.forEachIndexed { index, step ->
|
||||||
|
Row(horizontalArrangement = Arrangement.spacedBy(16.dp)) {
|
||||||
|
Text(
|
||||||
|
"${index + 1}",
|
||||||
|
style = MaterialTheme.typography.titleMedium,
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
color = MaterialTheme.colorScheme.primary,
|
||||||
|
modifier = Modifier.width(16.dp),
|
||||||
|
)
|
||||||
|
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||||
|
Text(step.action, style = MaterialTheme.typography.bodyLarge, fontWeight = FontWeight.SemiBold)
|
||||||
|
step.detail?.let {
|
||||||
|
Text(
|
||||||
|
it,
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
step.command?.let { CopyableCommand(it) }
|
||||||
|
if (step.onClick != null) {
|
||||||
|
FilledTonalButton(onClick = step.onClick, modifier = Modifier.padding(top = 4.dp)) {
|
||||||
|
Text(step.buttonLabel ?: "Open")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Spacer(Modifier.height(32.dp))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The small circular "?" button that opens [HelpScreen] from the top right of the home screen. */
|
||||||
|
@Composable
|
||||||
|
fun HelpButton(onClick: () -> Unit) {
|
||||||
|
Surface(
|
||||||
|
onClick = onClick,
|
||||||
|
shape = MaterialTheme.shapes.extraLarge,
|
||||||
|
color = MaterialTheme.colorScheme.surfaceContainerHigh,
|
||||||
|
) {
|
||||||
|
Box(Modifier.padding(10.dp), contentAlignment = Alignment.Center) {
|
||||||
|
Text(
|
||||||
|
"?",
|
||||||
|
style = MaterialTheme.typography.titleMedium,
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
color = MaterialTheme.colorScheme.onSurface,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,8 +1,6 @@
|
|||||||
package app.seb3thehacker.gearslip.ui
|
package app.seb3thehacker.gearslip.ui
|
||||||
|
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.width
|
|
||||||
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
|
||||||
@@ -30,11 +28,7 @@ import androidx.compose.runtime.remember
|
|||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import android.widget.Toast
|
|
||||||
import androidx.compose.ui.platform.LocalClipboardManager
|
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import androidx.compose.ui.text.AnnotatedString
|
|
||||||
import androidx.compose.ui.text.font.FontFamily
|
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
@@ -46,6 +40,7 @@ fun HomeScreen(
|
|||||||
onOpenLogs: () -> Unit,
|
onOpenLogs: () -> Unit,
|
||||||
onOpenSettings: () -> Unit,
|
onOpenSettings: () -> Unit,
|
||||||
onOpenCarPreview: () -> Unit,
|
onOpenCarPreview: () -> Unit,
|
||||||
|
onOpenHelp: () -> Unit,
|
||||||
onDisconnect: () -> Unit,
|
onDisconnect: () -> Unit,
|
||||||
) {
|
) {
|
||||||
val status by SessionStatus.state.collectAsStateWithLifecycle()
|
val status by SessionStatus.state.collectAsStateWithLifecycle()
|
||||||
@@ -65,22 +60,25 @@ fun HomeScreen(
|
|||||||
.verticalScroll(rememberScrollState()),
|
.verticalScroll(rememberScrollState()),
|
||||||
) {
|
) {
|
||||||
Spacer(Modifier.height(24.dp))
|
Spacer(Modifier.height(24.dp))
|
||||||
Text(
|
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {
|
||||||
"Gearslip",
|
Column {
|
||||||
style = MaterialTheme.typography.displayLarge,
|
Text(
|
||||||
fontWeight = FontWeight.Bold,
|
"Gearslip",
|
||||||
color = MaterialTheme.colorScheme.onBackground,
|
style = MaterialTheme.typography.displayLarge,
|
||||||
)
|
fontWeight = FontWeight.Bold,
|
||||||
Text(
|
color = MaterialTheme.colorScheme.onBackground,
|
||||||
"v${LocalContext.current.appVersionName()}",
|
)
|
||||||
style = MaterialTheme.typography.labelLarge,
|
Text(
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
"v${LocalContext.current.appVersionName()}",
|
||||||
)
|
style = MaterialTheme.typography.labelLarge,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
HelpButton(onClick = onOpenHelp)
|
||||||
|
}
|
||||||
Spacer(Modifier.height(16.dp))
|
Spacer(Modifier.height(16.dp))
|
||||||
StatusCard(status)
|
StatusCard(status)
|
||||||
Spacer(Modifier.height(24.dp))
|
Spacer(Modifier.height(24.dp))
|
||||||
SetupSteps()
|
|
||||||
Spacer(Modifier.height(24.dp))
|
|
||||||
Row(
|
Row(
|
||||||
Modifier.fillMaxWidth(),
|
Modifier.fillMaxWidth(),
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
@@ -181,116 +179,3 @@ private fun StatusCard(status: SessionStatus.Snapshot) {
|
|||||||
private fun Context.appVersionName(): String =
|
private fun Context.appVersionName(): String =
|
||||||
runCatching { packageManager.getPackageInfo(packageName, 0).versionName }.getOrNull() ?: "?"
|
runCatching { packageManager.getPackageInfo(packageName, 0).versionName }.getOrNull() ?: "?"
|
||||||
|
|
||||||
/**
|
|
||||||
* One step of the setup list: a sentence that says what to do, and optionally how.
|
|
||||||
*
|
|
||||||
* [command] is for the one step that cannot be done on the phone. It is shown as something to
|
|
||||||
* copy rather than to read, because a mistyped package name fails silently.
|
|
||||||
*/
|
|
||||||
private class Step(val action: String, val detail: String? = null, val command: String? = null)
|
|
||||||
|
|
||||||
private val SETUP_STEPS = listOf(
|
|
||||||
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("Load the certificate.", "Open Settings and choose Import certificate."),
|
|
||||||
Step(
|
|
||||||
"Allow the microphone, so music can reach the car.",
|
|
||||||
"Android asks for the microphone before it will let any app pass music along. " +
|
|
||||||
"Gearslip never listens to you: it copies what a music app is playing, and nothing else.",
|
|
||||||
),
|
|
||||||
Step(
|
|
||||||
"Optional: stop Android asking about audio every drive.",
|
|
||||||
"Connect the phone to a computer with USB debugging turned on, and run this command. " +
|
|
||||||
"It tells Android to trust Gearslip with audio from now on. Skip it and everything " +
|
|
||||||
"still works, but you have to tap Start once each time you set off.",
|
|
||||||
command = "adb shell appops set app.seb3thehacker.gearslip PROJECT_MEDIA allow",
|
|
||||||
),
|
|
||||||
Step(
|
|
||||||
"Optional: keep notifications readable while music plays.",
|
|
||||||
"Android treats sending audio to the car like sharing your screen, and hides what your " +
|
|
||||||
"notifications say. Run this command too, and Gearslip switches that off only while " +
|
|
||||||
"music is going to the car, then puts it back.",
|
|
||||||
command = "adb shell pm grant app.seb3thehacker.gearslip android.permission.WRITE_SECURE_SETTINGS",
|
|
||||||
),
|
|
||||||
Step("Plug the phone into the car.", "Gearslip opens by itself when the head unit connects."),
|
|
||||||
)
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A command to run on a computer, shown so it can be copied rather than retyped. Tapping it puts
|
|
||||||
* it on the clipboard, which is the only way it reaches the computer from here anyway.
|
|
||||||
*/
|
|
||||||
@Composable
|
|
||||||
private fun CopyableCommand(command: String) {
|
|
||||||
val clipboard = LocalClipboardManager.current
|
|
||||||
val context = LocalContext.current
|
|
||||||
Surface(
|
|
||||||
color = MaterialTheme.colorScheme.surfaceContainerHighest,
|
|
||||||
shape = MaterialTheme.shapes.small,
|
|
||||||
onClick = {
|
|
||||||
clipboard.setText(AnnotatedString(command))
|
|
||||||
Toast.makeText(context, "Command copied", Toast.LENGTH_SHORT).show()
|
|
||||||
},
|
|
||||||
modifier = Modifier.fillMaxWidth().padding(top = 4.dp),
|
|
||||||
) {
|
|
||||||
Column(Modifier.padding(horizontal = 12.dp, vertical = 10.dp), verticalArrangement = Arrangement.spacedBy(6.dp)) {
|
|
||||||
Text(
|
|
||||||
command,
|
|
||||||
style = MaterialTheme.typography.bodySmall,
|
|
||||||
fontFamily = FontFamily.Monospace,
|
|
||||||
color = MaterialTheme.colorScheme.onSurface,
|
|
||||||
)
|
|
||||||
Text(
|
|
||||||
"Tap to copy",
|
|
||||||
style = MaterialTheme.typography.labelSmall,
|
|
||||||
color = MaterialTheme.colorScheme.primary,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** What to do before the first drive, in order. The same width as the status card above it. */
|
|
||||||
@Composable
|
|
||||||
private fun SetupSteps() {
|
|
||||||
Box(Modifier.fillMaxWidth(), contentAlignment = Alignment.Center) {
|
|
||||||
Surface(
|
|
||||||
color = MaterialTheme.colorScheme.surfaceContainerLow,
|
|
||||||
shape = MaterialTheme.shapes.extraLarge,
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
) {
|
|
||||||
Column(Modifier.padding(horizontal = 24.dp, vertical = 22.dp), verticalArrangement = Arrangement.spacedBy(18.dp)) {
|
|
||||||
Text(
|
|
||||||
"How to connect",
|
|
||||||
style = MaterialTheme.typography.titleLarge,
|
|
||||||
fontWeight = FontWeight.SemiBold,
|
|
||||||
color = MaterialTheme.colorScheme.onSurface,
|
|
||||||
)
|
|
||||||
SETUP_STEPS.forEachIndexed { index, step ->
|
|
||||||
Row(horizontalArrangement = Arrangement.spacedBy(16.dp)) {
|
|
||||||
Text(
|
|
||||||
"${index + 1}",
|
|
||||||
style = MaterialTheme.typography.titleMedium,
|
|
||||||
fontWeight = FontWeight.Bold,
|
|
||||||
color = MaterialTheme.colorScheme.primary,
|
|
||||||
modifier = Modifier.width(16.dp),
|
|
||||||
)
|
|
||||||
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
|
|
||||||
Text(step.action, style = MaterialTheme.typography.bodyLarge, fontWeight = FontWeight.SemiBold)
|
|
||||||
step.detail?.let {
|
|
||||||
Text(
|
|
||||||
it,
|
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
step.command?.let { CopyableCommand(it) }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -18,6 +18,7 @@ import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
|||||||
import androidx.compose.material3.AlertDialog
|
import androidx.compose.material3.AlertDialog
|
||||||
import androidx.compose.material3.Button
|
import androidx.compose.material3.Button
|
||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
|
import androidx.compose.material3.FilledTonalButton
|
||||||
import androidx.compose.material3.Icon
|
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
|
||||||
@@ -124,6 +125,26 @@ private fun CertificateSection() {
|
|||||||
Button(onClick = { picker.launch(arrayOf("*/*")) }, modifier = Modifier.fillMaxWidth().height(52.dp)) {
|
Button(onClick = { picker.launch(arrayOf("*/*")) }, modifier = Modifier.fillMaxWidth().height(52.dp)) {
|
||||||
Text("Import certificate (.p12)")
|
Text("Import certificate (.p12)")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
FilledTonalButton(
|
||||||
|
onClick = {
|
||||||
|
scope.launch {
|
||||||
|
val result = withContext(Dispatchers.IO) {
|
||||||
|
runCatching { CertProvider.downloadAndImport(context) }
|
||||||
|
}
|
||||||
|
result.onSuccess {
|
||||||
|
notice = "Certificate downloaded."
|
||||||
|
refresh++
|
||||||
|
}.onFailure {
|
||||||
|
notice = "Download failed: ${it.message}"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
modifier = Modifier.fillMaxWidth().height(52.dp),
|
||||||
|
) {
|
||||||
|
Text("Download certificate")
|
||||||
|
}
|
||||||
|
|
||||||
if (summary?.kind == CertProvider.Kind.IMPORTED) {
|
if (summary?.kind == CertProvider.Kind.IMPORTED) {
|
||||||
OutlinedButton(
|
OutlinedButton(
|
||||||
onClick = {
|
onClick = {
|
||||||
@@ -134,9 +155,21 @@ private fun CertificateSection() {
|
|||||||
modifier = Modifier.fillMaxWidth().height(52.dp),
|
modifier = Modifier.fillMaxWidth().height(52.dp),
|
||||||
) { Text("Remove imported certificate") }
|
) { Text("Remove imported certificate") }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (summary?.kind == CertProvider.Kind.DOWNLOADED) {
|
||||||
|
OutlinedButton(
|
||||||
|
onClick = {
|
||||||
|
CertProvider.removeDownloaded(context)
|
||||||
|
notice = "Removed the downloaded certificate."
|
||||||
|
refresh++
|
||||||
|
},
|
||||||
|
modifier = Modifier.fillMaxWidth().height(52.dp),
|
||||||
|
) { Text("Remove downloaded certificate") }
|
||||||
|
}
|
||||||
|
|
||||||
notice?.let { Text(it, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant) }
|
notice?.let { Text(it, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant) }
|
||||||
Text(
|
Text(
|
||||||
"An imported certificate takes priority over one staged over adb. Takes effect on the next connection.",
|
"Changes take effect on the next connection.",
|
||||||
style = MaterialTheme.typography.bodySmall,
|
style = MaterialTheme.typography.bodySmall,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -0,0 +1,379 @@
|
|||||||
|
package app.seb3thehacker.gearslip.ui
|
||||||
|
|
||||||
|
import android.Manifest
|
||||||
|
import android.app.AppOpsManager
|
||||||
|
import android.app.NotificationManager
|
||||||
|
import android.app.role.RoleManager
|
||||||
|
import android.content.ComponentName
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
import android.content.pm.PackageManager
|
||||||
|
import android.net.Uri
|
||||||
|
import android.provider.Settings
|
||||||
|
import androidx.activity.compose.BackHandler
|
||||||
|
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||||
|
import androidx.activity.result.contract.ActivityResultContracts
|
||||||
|
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.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.material3.Button
|
||||||
|
import androidx.compose.material3.FilledTonalButton
|
||||||
|
import androidx.compose.material3.LinearProgressIndicator
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.OutlinedButton
|
||||||
|
import androidx.compose.material3.Scaffold
|
||||||
|
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.mutableIntStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.clip
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import app.seb3thehacker.gearslip.AppSettings
|
||||||
|
import app.seb3thehacker.gearslip.call.CarCalls
|
||||||
|
import app.seb3thehacker.gearslip.car.CarSettings
|
||||||
|
import app.seb3thehacker.gearslip.car.Prefetch
|
||||||
|
import app.seb3thehacker.gearslip.host.AndroidAuto
|
||||||
|
import app.seb3thehacker.gearslip.notify.GearslipNotificationListener
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One page of the guide. [permissions] drives a system permission dialog; [command] is an adb
|
||||||
|
* command to copy; [actionLabel] with [onAction] is a button that does something else (opens a
|
||||||
|
* settings screen) without by itself finishing the page. Everything is skippable: none of this
|
||||||
|
* can be verified as done, only offered.
|
||||||
|
*/
|
||||||
|
private class GuidePage(
|
||||||
|
val title: String,
|
||||||
|
val explanation: String,
|
||||||
|
val permissions: Array<String>? = null,
|
||||||
|
val command: String? = null,
|
||||||
|
val actionLabel: String? = null,
|
||||||
|
val onAction: (() -> Unit)? = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
/** True once every permission in [permissions] is already granted - nothing left for this page to do. */
|
||||||
|
private fun granted(context: Context, permissions: Array<String>): Boolean =
|
||||||
|
permissions.all { context.checkSelfPermission(it) == PackageManager.PERMISSION_GRANTED }
|
||||||
|
|
||||||
|
/** Whether Gearslip already holds the hidden `PROJECT_MEDIA` app-op an adb command grants -
|
||||||
|
* checked the same way the platform checks it, since there is no public permission for it. */
|
||||||
|
private fun hasProjectMediaOp(context: Context): Boolean {
|
||||||
|
val ops = context.getSystemService(AppOpsManager::class.java) ?: return false
|
||||||
|
val mode = runCatching {
|
||||||
|
ops.unsafeCheckOpNoThrow("android:project_media", android.os.Process.myUid(), context.packageName)
|
||||||
|
}.getOrDefault(AppOpsManager.MODE_IGNORED)
|
||||||
|
return mode == AppOpsManager.MODE_ALLOWED
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun hasCallScreeningRole(context: Context): Boolean {
|
||||||
|
val roleManager = context.getSystemService(RoleManager::class.java) ?: return false
|
||||||
|
return runCatching { roleManager.isRoleHeld(RoleManager.ROLE_CALL_SCREENING) }.getOrDefault(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Whether the driver has already granted Gearslip's [GearslipNotificationListener] access. */
|
||||||
|
private fun hasNotificationAccess(context: Context): Boolean {
|
||||||
|
val manager = context.getSystemService(NotificationManager::class.java) ?: return false
|
||||||
|
val component = ComponentName(context, GearslipNotificationListener::class.java)
|
||||||
|
return runCatching { manager.isNotificationListenerAccessGranted(component) }.getOrDefault(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun pages(context: Context, onRequestCallScreening: () -> Unit): List<GuidePage> = buildList {
|
||||||
|
// --- Permissions: skipped outright once already granted, nothing left to ask -----
|
||||||
|
if (CarSettings.pipeAudio.value && !granted(context, arrayOf(Manifest.permission.RECORD_AUDIO))) {
|
||||||
|
add(
|
||||||
|
GuidePage(
|
||||||
|
title = "Microphone",
|
||||||
|
explanation = "Android will not pass a music app's audio to the car without this " +
|
||||||
|
"permission. Gearslip does not listen to you: it copies what is already " +
|
||||||
|
"playing, nothing else.",
|
||||||
|
permissions = arrayOf(Manifest.permission.RECORD_AUDIO),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (!granted(context, arrayOf(Manifest.permission.ACCESS_FINE_LOCATION))) {
|
||||||
|
add(
|
||||||
|
GuidePage(
|
||||||
|
title = "Location",
|
||||||
|
explanation = "The car's map and nearby places need to know where you are. " +
|
||||||
|
"Without it, the dashboard has no map card.",
|
||||||
|
permissions = arrayOf(Manifest.permission.ACCESS_FINE_LOCATION),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (!granted(context, arrayOf(Manifest.permission.READ_CALENDAR))) {
|
||||||
|
add(
|
||||||
|
GuidePage(
|
||||||
|
title = "Calendar",
|
||||||
|
explanation = "Gearslip reads your next event to show an agenda card on the car " +
|
||||||
|
"screen. It only reads; it never edits or deletes anything.",
|
||||||
|
permissions = arrayOf(Manifest.permission.READ_CALENDAR),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
val callPermissions = arrayOf(
|
||||||
|
Manifest.permission.READ_PHONE_STATE,
|
||||||
|
Manifest.permission.READ_CALL_LOG,
|
||||||
|
Manifest.permission.READ_CONTACTS,
|
||||||
|
Manifest.permission.ANSWER_PHONE_CALLS,
|
||||||
|
Manifest.permission.CALL_PHONE,
|
||||||
|
)
|
||||||
|
if (!granted(context, callPermissions)) {
|
||||||
|
add(
|
||||||
|
GuidePage(
|
||||||
|
title = "Phone and contacts",
|
||||||
|
explanation = "When a call comes in, Gearslip matches the number against your " +
|
||||||
|
"contacts, so the car screen can show a name instead of just digits. The " +
|
||||||
|
"same permissions let the car screen's Phone app dial a number or call " +
|
||||||
|
"someone from your contacts.",
|
||||||
|
permissions = callPermissions,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!hasNotificationAccess(context)) {
|
||||||
|
add(
|
||||||
|
GuidePage(
|
||||||
|
title = "Notification access",
|
||||||
|
explanation = "The car screen shows your phone's notifications and lets you " +
|
||||||
|
"reply from there. Android only allows that once you grant Gearslip access, " +
|
||||||
|
"from the list this opens - it needs this to work at all.",
|
||||||
|
actionLabel = "Open notification access",
|
||||||
|
onAction = {
|
||||||
|
context.startActivity(Intent(Settings.ACTION_NOTIFICATION_LISTENER_SETTINGS))
|
||||||
|
},
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- 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 ------------------------------------------------------------------
|
||||||
|
val androidAutoPackage = AndroidAuto.installedPackage(context)
|
||||||
|
?.takeIf { AndroidAuto.isActive(context, it) }
|
||||||
|
if (androidAutoPackage != null) {
|
||||||
|
add(
|
||||||
|
GuidePage(
|
||||||
|
title = "Remove Android Auto",
|
||||||
|
explanation = "Android Auto is still installed. It claims the same USB " +
|
||||||
|
"connection Gearslip needs, so the two fight over it. Disable or uninstall " +
|
||||||
|
"it from the app info screen this opens.",
|
||||||
|
actionLabel = "Open Android Auto's app info",
|
||||||
|
onAction = {
|
||||||
|
context.startActivity(
|
||||||
|
Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS, Uri.fromParts("package", androidAutoPackage, null)),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Default handler ---------------------------------------------------------------
|
||||||
|
add(
|
||||||
|
GuidePage(
|
||||||
|
title = "Make Gearslip the default",
|
||||||
|
explanation = "Android has no separate default-app setting for a USB accessory like " +
|
||||||
|
"the car. It just launches whichever single app matches - which is Gearslip, " +
|
||||||
|
"now that Android Auto is out of the way. If a chooser still appears when you " +
|
||||||
|
"plug in, pick Gearslip and tap Always so Android stops asking.",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
// --- Optional: each is skipped once its actual effect is already in place --------
|
||||||
|
if (!hasProjectMediaOp(context)) {
|
||||||
|
add(
|
||||||
|
GuidePage(
|
||||||
|
title = "Optional: trust audio every drive",
|
||||||
|
explanation = "Connect the phone to a computer with USB debugging on, and run this " +
|
||||||
|
"command. It tells Android to trust Gearslip with audio from now on. Skip it " +
|
||||||
|
"and everything still works, but you tap Start once each time you set off.",
|
||||||
|
command = "adb shell appops set app.seb3thehacker.gearslip PROJECT_MEDIA allow",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (!granted(context, arrayOf(Manifest.permission.WRITE_SECURE_SETTINGS))) {
|
||||||
|
add(
|
||||||
|
GuidePage(
|
||||||
|
title = "Optional: keep notifications readable",
|
||||||
|
explanation = "Android treats sending audio to the car like sharing your screen, and " +
|
||||||
|
"hides what your notifications say. This command switches that off only while " +
|
||||||
|
"music is going to the car, then puts it back.",
|
||||||
|
command = "adb shell pm grant app.seb3thehacker.gearslip android.permission.WRITE_SECURE_SETTINGS",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (!hasCallScreeningRole(context)) {
|
||||||
|
add(
|
||||||
|
GuidePage(
|
||||||
|
title = "Optional: answer and decline calls",
|
||||||
|
explanation = "This sets Gearslip as your phone's caller ID and spam-blocking app, " +
|
||||||
|
"the same kind of consent a call-blocking app asks for. Without it, calls still " +
|
||||||
|
"ring, but only the phone itself can answer or decline them.",
|
||||||
|
actionLabel = "Set up",
|
||||||
|
onAction = onRequestCallScreening,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The first-run guide, one page at a time: permissions, then GrapheneOS's exploit-protection
|
||||||
|
* 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
|
||||||
|
* driver takes on trust, with "Skip" sitting right next to it.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun SetupGuideScreen(onRequestCallScreening: () -> Unit, onDone: () -> Unit) {
|
||||||
|
val context = LocalContext.current
|
||||||
|
val pages = remember { pages(context, onRequestCallScreening) }
|
||||||
|
var index by remember { mutableIntStateOf(0) }
|
||||||
|
|
||||||
|
// Only ours to handle once there is a previous page to return to; at the first page, the
|
||||||
|
// system back press falls through to whatever the screen beneath this one does with it.
|
||||||
|
BackHandler(enabled = index > 0) { index -= 1 }
|
||||||
|
|
||||||
|
val launcher = rememberLauncherForActivityResult(
|
||||||
|
ActivityResultContracts.RequestMultiplePermissions(),
|
||||||
|
) { result ->
|
||||||
|
if (result.values.any { it }) {
|
||||||
|
Prefetch.warm(context, force = true)
|
||||||
|
CarCalls.start(context)
|
||||||
|
}
|
||||||
|
index += 1
|
||||||
|
}
|
||||||
|
|
||||||
|
val page = pages.getOrNull(index)
|
||||||
|
if (page == null) {
|
||||||
|
AppSettings.setSeenPermissionsSetup(context)
|
||||||
|
onDone()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
val alreadyGranted = page.permissions?.all {
|
||||||
|
context.checkSelfPermission(it) == PackageManager.PERMISSION_GRANTED
|
||||||
|
} ?: false
|
||||||
|
|
||||||
|
Scaffold(containerColor = MaterialTheme.colorScheme.background) { padding ->
|
||||||
|
Column(
|
||||||
|
Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.padding(padding)
|
||||||
|
.padding(horizontal = 24.dp),
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
verticalArrangement = Arrangement.Center,
|
||||||
|
) {
|
||||||
|
LinearProgressIndicator(
|
||||||
|
progress = { (index + 1f) / pages.size },
|
||||||
|
modifier = Modifier.fillMaxWidth().height(10.dp).clip(MaterialTheme.shapes.extraLarge),
|
||||||
|
)
|
||||||
|
Spacer(Modifier.height(12.dp))
|
||||||
|
Text(
|
||||||
|
"${index + 1} of ${pages.size}",
|
||||||
|
style = MaterialTheme.typography.titleMedium,
|
||||||
|
fontWeight = FontWeight.SemiBold,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
Spacer(Modifier.height(48.dp))
|
||||||
|
|
||||||
|
Surface(color = MaterialTheme.colorScheme.surfaceContainerLow, shape = MaterialTheme.shapes.extraLarge) {
|
||||||
|
Column(Modifier.fillMaxWidth().padding(24.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||||
|
Text(
|
||||||
|
page.title,
|
||||||
|
style = MaterialTheme.typography.headlineSmall,
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
color = MaterialTheme.colorScheme.onSurface,
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
page.explanation,
|
||||||
|
style = MaterialTheme.typography.bodyLarge,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
page.command?.let { CopyableCommand(it) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(Modifier.height(32.dp))
|
||||||
|
|
||||||
|
when {
|
||||||
|
page.permissions != null && alreadyGranted -> {
|
||||||
|
Text(
|
||||||
|
"Already allowed.",
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.primary,
|
||||||
|
)
|
||||||
|
Spacer(Modifier.height(16.dp))
|
||||||
|
PrimaryRow(showBack = index > 0, onBack = { index -= 1 }) {
|
||||||
|
Button(onClick = { index += 1 }, modifier = it) { Text("Next") }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
page.permissions != null -> {
|
||||||
|
PrimaryRow(showBack = index > 0, onBack = { index -= 1 }) {
|
||||||
|
Button(onClick = { launcher.launch(page.permissions) }, modifier = it) { Text("Allow") }
|
||||||
|
}
|
||||||
|
Spacer(Modifier.height(8.dp))
|
||||||
|
TextButton(onClick = { index += 1 }) { Text("Skip for now") }
|
||||||
|
}
|
||||||
|
page.onAction != null -> {
|
||||||
|
FilledTonalButton(
|
||||||
|
onClick = page.onAction,
|
||||||
|
modifier = Modifier.fillMaxWidth().height(52.dp),
|
||||||
|
) { Text(page.actionLabel ?: "Open") }
|
||||||
|
Spacer(Modifier.height(8.dp))
|
||||||
|
PrimaryRow(showBack = index > 0, onBack = { index -= 1 }) {
|
||||||
|
Button(onClick = { index += 1 }, modifier = it) { Text("Next") }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else -> {
|
||||||
|
PrimaryRow(showBack = index > 0, onBack = { index -= 1 }) {
|
||||||
|
Button(onClick = { index += 1 }, modifier = it) { Text("Next") }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The bottom action row: a lower-emphasis Back on the left once there is a previous page, and the
|
||||||
|
* page's own primary button - passed [content] so the caller keeps the button's own label and
|
||||||
|
* click behavior - filling the rest of the width.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
private fun PrimaryRow(showBack: Boolean, onBack: () -> Unit, content: @Composable (Modifier) -> Unit) {
|
||||||
|
if (showBack) {
|
||||||
|
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||||
|
OutlinedButton(
|
||||||
|
onClick = onBack,
|
||||||
|
modifier = Modifier.weight(1f).height(52.dp),
|
||||||
|
) { Text("Back") }
|
||||||
|
content(Modifier.weight(1f).height(52.dp))
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
content(Modifier.fillMaxWidth().height(52.dp))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -25,4 +25,5 @@ dependencyResolutionManagement {
|
|||||||
|
|
||||||
rootProject.name = "Gearslip"
|
rootProject.name = "Gearslip"
|
||||||
include(":gearslip")
|
include(":gearslip")
|
||||||
|
// include(":ksdump")
|
||||||
|
|
||||||
Reference in new issue
Block a user