Author SHA1 Message Date
Seb3thehacker 5f9701ccff Release 0.0.05-alpha: guided setup, downloadable certificate, car phone app, media rework
- First run now asks for permissions one at a time, each on its own screen with an Allow or
  Skip, instead of firing every system dialog at once from a cold start
- The head unit certificate can be downloaded straight from the opencardev/aasdk repository
  with one tap, on top of the existing adb-staged and file-import paths; Settings shows which
  source is active and lets an imported or downloaded certificate be removed
- A phone app on the car screen: a keypad and a searchable contacts list, with a confirmation
  card before every call
- The web browser draws its own on-screen keyboard for a page's text fields, so a search box or
  address bar can be typed into from the car screen
- Media player rework: a dedicated Now Playing tab with a bigger, screen-size-aware layout, an
  Up Next tab showing the app's real queue, and Browse/Lyrics tabs that share the same rail
- Waze and Google Messages are marked broken instead of pretending to work; Google's own dialer,
  Assistant, and Android Auto entries are hidden from the car launcher entirely; Aardvark and
  Vivi Music are marked tested and working
- A help screen replaces the old wall of setup text on the home screen
- The display-calibration screen is gone - it did its job
2026-09-26 16:53:28 -07:00
Seb3thehacker 043dc11251 Add phone call handling on the car screen
Incoming calls now show the caller (name via contacts, else number) on
the car UI with answer/decline, using a call screening service so the
car sees a call before it rings. Also adds pinnable nav bar shortcuts,
lowers minSdk to 31 for wider device support, and bumps to 0.0.04-pre.
2026-09-26 11:43:09 -07:00
45 changed files with 3261 additions and 710 deletions

No files matched your search

+42
View File
@@ -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
+2
View File
@@ -23,3 +23,5 @@ keystore.properties
archive/ archive/
CLAUDE.md CLAUDE.md
*.pdf *.pdf
extraction/
.claude/
+3 -3
View File
@@ -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 {
+35
View File
@@ -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))
}
}
+1
View File
@@ -25,4 +25,5 @@ dependencyResolutionManagement {
rootProject.name = "Gearslip" rootProject.name = "Gearslip"
include(":gearslip") include(":gearslip")
// include(":ksdump")