diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..5043aaf --- /dev/null +++ b/.github/workflows/release.yml @@ -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 <> "$GITHUB_OUTPUT" + + - name: Create release + uses: softprops/action-gh-release@v2 + with: + files: ${{ steps.apk.outputs.path }} + generate_release_notes: true diff --git a/.gitignore b/.gitignore index b7dfe85..4079af0 100644 --- a/.gitignore +++ b/.gitignore @@ -23,4 +23,5 @@ keystore.properties archive/ CLAUDE.md *.pdf -extraction/ \ No newline at end of file +extraction/ +.claude/ \ No newline at end of file diff --git a/gearslip/build.gradle.kts b/gearslip/build.gradle.kts index 0129d6c..288b0ca 100644 --- a/gearslip/build.gradle.kts +++ b/gearslip/build.gradle.kts @@ -32,8 +32,8 @@ android { applicationId = "app.seb3thehacker.gearslip" minSdk = 31 targetSdk = 37 - versionCode = 4 - versionName = "0.0.04-pre" + versionCode = 5 + versionName = "0.0.05-alpha" } signingConfigs { diff --git a/gearslip/src/main/AndroidManifest.xml b/gearslip/src/main/AndroidManifest.xml index ae95693..3b98919 100644 --- a/gearslip/src/main/AndroidManifest.xml +++ b/gearslip/src/main/AndroidManifest.xml @@ -51,6 +51,9 @@ + + + + diff --git a/gearslip/src/main/java/app/seb3thehacker/gearslip/AppSettings.kt b/gearslip/src/main/java/app/seb3thehacker/gearslip/AppSettings.kt index c2001ad..2b5adb1 100644 --- a/gearslip/src/main/java/app/seb3thehacker/gearslip/AppSettings.kt +++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/AppSettings.kt @@ -8,7 +8,9 @@ object AppSettings { private const val PREFS = "gearslip_settings" private const val KEY_STARTUP_URL = "startup_url" 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_SEEN_PERMISSIONS_SETUP = "seen_permissions_setup" private fun prefs(context: Context) = context.applicationContext.getSharedPreferences(PREFS, Context.MODE_PRIVATE) @@ -36,6 +38,22 @@ object AppSettings { 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 * preview) on the home screen. On by default while the project is pre-release. diff --git a/gearslip/src/main/java/app/seb3thehacker/gearslip/CertDownloader.kt b/gearslip/src/main/java/app/seb3thehacker/gearslip/CertDownloader.kt new file mode 100644 index 0000000..d49adac --- /dev/null +++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/CertDownloader.kt @@ -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 + } +} \ No newline at end of file diff --git a/gearslip/src/main/java/app/seb3thehacker/gearslip/CertProvider.kt b/gearslip/src/main/java/app/seb3thehacker/gearslip/CertProvider.kt index d79f60a..a51c543 100644 --- a/gearslip/src/main/java/app/seb3thehacker/gearslip/CertProvider.kt +++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/CertProvider.kt @@ -2,6 +2,7 @@ package app.seb3thehacker.gearslip import android.content.Context import android.net.Uri +import java.io.ByteArrayOutputStream import java.io.File import java.security.KeyStore 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, * 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: * * 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 * 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). */ object CertProvider { @@ -34,7 +37,7 @@ object CertProvider { private const val DEFAULT_PASSWORD = "aaspike" 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( val keyStore: KeyStore, @@ -70,6 +73,13 @@ object CertProvider { }.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 staged = dir?.let { File(it, P12_NAME) } if (dir != null && staged != null && staged.isFile) { @@ -113,17 +123,84 @@ object CertProvider { 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) { importedFile(context, P12_NAME).delete() importedFile(context, PASS_NAME).delete() 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 // 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 downloadedFile(context: Context, name: String) = + File(File(context.filesDir, "$IMPORT_DIR-downloaded"), name) + private fun importedPassword(context: Context): String = importedFile(context, PASS_NAME).takeIf { it.isFile }?.readText().orEmpty() diff --git a/gearslip/src/main/java/app/seb3thehacker/gearslip/GearslipActivity.kt b/gearslip/src/main/java/app/seb3thehacker/gearslip/GearslipActivity.kt index d442192..9e95598 100644 --- a/gearslip/src/main/java/app/seb3thehacker/gearslip/GearslipActivity.kt +++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/GearslipActivity.kt @@ -94,24 +94,6 @@ class GearslipActivity : ComponentActivity(), Projection { } } - /** Hosted map apps need Gearslip to hold location so they can keep it in the background. */ - private val locationPermission = registerForActivityResult( - androidx.activity.result.contract.ActivityResultContracts.RequestPermission(), - ) { 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) } - - /** - * Caller name, incoming number and call state; declined just means no phone card on the car - * screen. Asked for up front, like location and the calendar, so nothing prompts mid-drive. - */ - private val callPermissions = registerForActivityResult( - androidx.activity.result.contract.ActivityResultContracts.RequestMultiplePermissions(), - ) { granted -> if (granted.values.any { it }) app.seb3thehacker.gearslip.call.CarCalls.start(this) } - /** Setting Gearslip as the phone's caller ID and spam app - what lets it decline a ringing call. */ private val callScreeningRole = registerForActivityResult(StartActivityForResult()) { result -> if (result.resultCode != RESULT_OK) GearslipLog.w("calls: call screening role declined") @@ -119,29 +101,11 @@ class GearslipActivity : ComponentActivity(), Projection { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) - if (checkSelfPermission(android.Manifest.permission.ACCESS_FINE_LOCATION) != - android.content.pm.PackageManager.PERMISSION_GRANTED - ) locationPermission.launch(android.Manifest.permission.ACCESS_FINE_LOCATION) - if (checkSelfPermission(android.Manifest.permission.READ_CALENDAR) != - android.content.pm.PackageManager.PERMISSION_GRANTED - ) 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) - val callPerms = arrayOf( - android.Manifest.permission.READ_PHONE_STATE, - android.Manifest.permission.READ_CALL_LOG, - android.Manifest.permission.READ_CONTACTS, - android.Manifest.permission.ANSWER_PHONE_CALLS, - ) - if (callPerms.any { checkSelfPermission(it) != android.content.pm.PackageManager.PERMISSION_GRANTED }) { - callPermissions.launch(callPerms) - } + // Location, calendar, the phone/contacts group, and the microphone are asked for one at + // a time by PermissionsSetupScreen instead of here, each only after the driver taps + // Allow on a screen explaining what it is for. Firing them all from onCreate, unprompted, + // is both a bad first impression and unreliable: Android does not consistently show more + // than one system permission dialog fired back-to-back, which was silently dropping some. enableEdgeToEdge() window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON) diff --git a/gearslip/src/main/java/app/seb3thehacker/gearslip/PemKeyParser.kt b/gearslip/src/main/java/app/seb3thehacker/gearslip/PemKeyParser.kt new file mode 100644 index 0000000..f822b92 --- /dev/null +++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/PemKeyParser.kt @@ -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 { + 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() + 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 + } +} \ No newline at end of file diff --git a/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CalibrationApp.kt b/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CalibrationApp.kt deleted file mode 100644 index ca5d244..0000000 --- a/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CalibrationApp.kt +++ /dev/null @@ -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(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") - } - } -} diff --git a/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CarApps.kt b/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CarApps.kt index 3e32423..51e41f8 100644 --- a/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CarApps.kt +++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CarApps.kt @@ -1,7 +1,7 @@ package app.seb3thehacker.gearslip.car 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.Share import androidx.compose.material.icons.filled.Settings @@ -28,8 +28,8 @@ object CarApps { CarApp("phone", "Screen sharing", Icons.Filled.Share) { PhoneAppsScreen() }, - CarApp("calibrate", "Display test", Icons.Filled.Info) { - CalibrationApp() + CarApp("dialer", "Phone", Icons.Filled.Call) { + PhoneDialerScreen() }, CarApp("settings", "Settings", Icons.Filled.Settings) { CarSettingsScreen() diff --git a/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CarLauncher.kt b/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CarLauncher.kt index 987d9a7..8351939 100644 --- a/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CarLauncher.kt +++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CarLauncher.kt @@ -16,6 +16,7 @@ import androidx.compose.foundation.lazy.grid.GridCells import androidx.compose.foundation.lazy.grid.LazyVerticalGrid import androidx.compose.foundation.lazy.grid.items 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.Settings import androidx.compose.material.icons.filled.Share @@ -48,6 +49,7 @@ import androidx.compose.foundation.background import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.lazy.grid.GridItemSpan 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.CarAppCatalog import app.seb3thehacker.gearslip.host.TemplateApp @@ -63,6 +65,8 @@ private class Tile( val glyph: ImageVector? = null, /** Tested from start to finish with Gearslip; drawn with a check mark. */ 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, @@ -132,12 +136,14 @@ fun CarLauncher() { val tiles = listOf( Tile("Web", glyph = Icons.Filled.Search) { navigator.open("web") }, Tile("Screen sharing", glyph = Icons.Filled.Share) { navigator.open("phone") }, + Tile("Phone", glyph = Icons.Filled.Call) { navigator.open("dialer") }, ) + installed.map { entry -> val pkg = entry.template?.component?.packageName ?: entry.media?.component?.packageName val id = entry.componentId Tile( 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) } @@ -188,6 +194,17 @@ 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 private fun AppTile(tile: Tile) { @@ -227,6 +244,9 @@ private fun AppTile(tile: Tile) { if (tile.verified) { VerifiedBadge(24.dp, Modifier.align(Alignment.TopEnd).padding(8.dp)) } + if (tile.broken) { + BrokenBadge(24.dp, Modifier.align(Alignment.TopEnd).padding(8.dp)) + } } } } diff --git a/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CarSettingsScreen.kt b/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CarSettingsScreen.kt index f9aa367..9198632 100644 --- a/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CarSettingsScreen.kt +++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CarSettingsScreen.kt @@ -117,9 +117,6 @@ fun CarSettingsScreen() { Info("Video", display.ifEmpty { "-" }) Info("Certificate", cert?.headline ?: "Checking…") } - val navigator = LocalCarNavigator.current - Button(onClick = { navigator.open("calibrate") }) { Text("Display test") } - Spacer(Modifier.height(8.dp)) } } diff --git a/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CarUi.kt b/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CarUi.kt index ef562fd..d5b1901 100644 --- a/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CarUi.kt +++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CarUi.kt @@ -130,33 +130,29 @@ fun CarUi() { end = insets.right.toDp(), bottom = insets.bottom.toDp(), ) } - // The calibration screen is the raw frame: no insets, no nav bar. val screen = navigator.current - if (screen == CarScreen.App("calibrate")) { - CarApps.find("calibrate")?.content?.invoke() - } else { - Column(Modifier.fillMaxSize().padding(pad)) { - BreadcrumbBar(navigator) - Box(Modifier.weight(1f).fillMaxWidth()) { - when (screen) { - CarScreen.Home -> CarHome() - CarScreen.Apps -> CarLauncher() - CarScreen.Media -> mediaApp?.let { MediaScreen(it) { navigator.back() } } - CarScreen.Settings -> CarSettingsScreen() - CarScreen.Dashboard -> CarDashboardScreen() - CarScreen.Weather -> WeatherScreen() - is CarScreen.Notifications -> NotificationsScreen(screen.replyTo) - 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)) - } - // Above the popup: a call is more urgent than any notification. - CallOverlay(Modifier.align(Alignment.TopCenter)) + Column(Modifier.fillMaxSize().padding(pad)) { + // Header navigation (breadcrumb trail) - off for now, kept to tweak later. + // BreadcrumbBar(navigator) + Box(Modifier.weight(1f).fillMaxWidth()) { + when (screen) { + CarScreen.Home -> CarHome() + CarScreen.Apps -> CarLauncher() + CarScreen.Media -> mediaApp?.let { MediaScreen(it) { navigator.back() } } + CarScreen.Settings -> CarSettingsScreen() + CarScreen.Dashboard -> CarDashboardScreen() + CarScreen.Weather -> WeatherScreen() + is CarScreen.Notifications -> NotificationsScreen(screen.replyTo) + is CarScreen.App -> CarApps.find(screen.id)?.content?.invoke() } - 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) } } } diff --git a/gearslip/src/main/java/app/seb3thehacker/gearslip/car/MediaScreen.kt b/gearslip/src/main/java/app/seb3thehacker/gearslip/car/MediaScreen.kt index 00017be..8ef39fb 100644 --- a/gearslip/src/main/java/app/seb3thehacker/gearslip/car/MediaScreen.kt +++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/car/MediaScreen.kt @@ -2,10 +2,13 @@ package app.seb3thehacker.gearslip.car import androidx.compose.foundation.Image import androidx.compose.foundation.background +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll import androidx.compose.foundation.layout.Arrangement import android.provider.Settings import android.content.Intent import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row 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.MediaEntry import app.seb3thehacker.gearslip.media.NowPlaying +import app.seb3thehacker.gearslip.media.QueueTrack import android.media.session.PlaybackState /** @@ -74,10 +78,11 @@ fun MediaScreen(app: MediaApp, onExit: () -> Unit) { val media = CarServices.media val phase by media.phase.collectAsStateWithLifecycle() val now by media.now.collectAsStateWithLifecycle() + val queue by media.queue.collectAsStateWithLifecycle() val browse by media.browse.collectAsStateWithLifecycle() val rejection by media.rejection.collectAsStateWithLifecycle() val capture by CarAudio.capture.collectAsStateWithLifecycle() - var tab by remember { mutableStateOf(Tab.BROWSE) } + var tab by remember { mutableStateOf(Tab.NOW_PLAYING) } // The connection outlives this screen: the home screen's player is the same one. LaunchedEffect(app) { CarServices.openMedia(app) } @@ -115,19 +120,27 @@ fun MediaScreen(app: MediaApp, onExit: () -> Unit) { Modifier.fillMaxSize().padding(top = 6.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()) { - Row { - TextButton(onClick = { tab = Tab.BROWSE }) { - Text("Browse", fontWeight = if (tab == Tab.BROWSE) FontWeight.Bold else FontWeight.Normal) - } - TextButton(onClick = { tab = Tab.LYRICS }) { - Text("Lyrics", fontWeight = if (tab == Tab.LYRICS) FontWeight.Bold else FontWeight.Normal) - } + Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(12.dp)) { + MediaTab("Now playing", tab == Tab.NOW_PLAYING, Modifier.weight(1f)) { tab = Tab.NOW_PLAYING } + MediaTab("Browse", tab == Tab.BROWSE, Modifier.weight(1f)) { tab = Tab.BROWSE } + MediaTab("Up next", tab == Tab.QUEUE, Modifier.weight(1f)) { tab = Tab.QUEUE } + MediaTab("Lyrics", tab == Tab.LYRICS, Modifier.weight(1f)) { tab = Tab.LYRICS } } - when (tab) { - Tab.BROWSE -> BrowsePanel(browse, media, app.label, Modifier.fillMaxSize()) - Tab.LYRICS -> LyricsPanel(now, Modifier.fillMaxSize()) + Spacer(Modifier.height(8.dp)) + Box(Modifier.weight(1f).fillMaxWidth()) { + 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()) + } } } } @@ -135,7 +148,23 @@ 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 @@ -227,10 +256,24 @@ private fun Notice(title: String, detail: String?, action: Pair 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 ----------------------------------------------------------------------------- +/** + * 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 -private fun NowPlayingPanel(now: NowPlaying, media: CarMedia, modifier: Modifier) { +private fun NowPlayingRail(now: NowPlaying, media: CarMedia, modifier: Modifier) { val context = LocalContext.current val art by produceState(now.art, now.art, now.artUri) { value = now.art ?: MediaArt.load(context, now.artUri) @@ -243,55 +286,76 @@ private fun NowPlayingPanel(now: NowPlaying, media: CarMedia, modifier: Modifier } } - Column(modifier, horizontalAlignment = Alignment.CenterHorizontally) { - Surface( - shape = MaterialTheme.shapes.large, - color = MaterialTheme.colorScheme.surfaceVariant, - modifier = Modifier.size(170.dp), - ) { - art?.let { - Image(it.asImageBitmap(), null, Modifier.fillMaxSize().clip(MaterialTheme.shapes.large), contentScale = ContentScale.Crop) + // Whether the progress bar (and its duration text) is showing depends on whether the app has + // reported a duration yet, which can arrive a beat after everything else - so this rail's + // content height isn't fixed. A plain fillMaxHeight Column just clips whatever doesn't fit + // once that row appears; scrolling means it's always reachable instead. + BoxWithConstraints(modifier) { + // Off the space actually available rather than a fixed dp value, so it doesn't overrun a + // short car frame the way a flat size did on anything shorter than a phone screen. + 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(8.dp)) - Text( - now.title.ifEmpty { "Nothing playing" }, - style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold, - 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), + Spacer(Modifier.height(16.dp)) + Text( + now.title.ifEmpty { "Nothing playing" }, + style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold, + maxLines = 2, overflow = TextOverflow.Ellipsis, ) - } else { - Spacer(Modifier.height(14.dp)) - } + Text( + 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)) { - IconButton(onClick = media::previous, enabled = now.canDo(PlaybackState.ACTION_SKIP_TO_PREVIOUS), modifier = Modifier.size(56.dp)) { - Icon(MediaIcons.Previous, "Previous", Modifier.size(32.dp)) + 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.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)) - } - 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)) + + 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)) { + Icon(MediaIcons.Previous, "Previous", 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. - Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) { +/** + * The "Now playing" tab's own content: just the app's extra buttons (shuffle, repeat, like), by + * 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 -> TextButton(onClick = { media.custom(action) }) { Text(action.name, maxLines = 1, overflow = TextOverflow.Ellipsis) } } @@ -304,15 +368,22 @@ private fun NowPlayingPanel(now: NowPlaying, media: CarMedia, modifier: Modifier @Composable private fun BrowsePanel(browse: app.seb3thehacker.gearslip.media.BrowseState, media: CarMedia, appLabel: String, modifier: Modifier) { Column(modifier) { - Row(verticalAlignment = Alignment.CenterVertically) { - if (media.canGoUp) { - IconButton(onClick = media::up) { Icon(Icons.Filled.ArrowBack, "Up") } + // At the library root there's no trail and nowhere to go up to, so there's nothing this + // row could say that the app's name in the top-left corner hasn't already - showing it + // 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 { browse.loading -> Notice("Loading…", null) @@ -329,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, 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 private fun EntryRow(entry: MediaEntry, media: CarMedia) { val context = LocalContext.current diff --git a/gearslip/src/main/java/app/seb3thehacker/gearslip/car/PhoneDialerScreen.kt b/gearslip/src/main/java/app/seb3thehacker/gearslip/car/PhoneDialerScreen.kt new file mode 100644 index 0000000..eb3d510 --- /dev/null +++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/car/PhoneDialerScreen.kt @@ -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 { + if (context.checkSelfPermission(Manifest.permission.READ_CONTACTS) != PackageManager.PERMISSION_GRANTED) return emptyList() + val out = mutableListOf() + 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(), context) { + value = withContext(Dispatchers.IO) { loadContacts(context) } + } + var query by remember { mutableStateOf("") } + var searching by remember { mutableStateOf(false) } + var pendingCall by remember { mutableStateOf(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) + } + } +} diff --git a/gearslip/src/main/java/app/seb3thehacker/gearslip/car/WebApp.kt b/gearslip/src/main/java/app/seb3thehacker/gearslip/car/WebApp.kt index 32e01bb..abeb867 100644 --- a/gearslip/src/main/java/app/seb3thehacker/gearslip/car/WebApp.kt +++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/car/WebApp.kt @@ -1,10 +1,14 @@ package app.seb3thehacker.gearslip.car +import android.os.Handler +import android.os.Looper +import android.webkit.JavascriptInterface import android.webkit.WebResourceError import android.webkit.WebResourceRequest import android.webkit.WebResourceResponse import android.webkit.WebView import android.webkit.WebViewClient +import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect @@ -15,16 +19,25 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.viewinterop.AndroidView 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. * 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 fun WebApp(content: String) { val navigator = LocalCarNavigator.current val darkOutside = LocalDarkOutside.current var web by remember { mutableStateOf(null) } + var fieldText by remember { mutableStateOf(null) } DisposableEffect(web) { navigator.backInterceptor = { @@ -39,32 +52,49 @@ fun WebApp(content: String) { onDispose { navigator.backInterceptor = null } } - AndroidView( - modifier = Modifier.fillMaxSize(), - factory = { context -> - WebView(context).apply { - settings.javaScriptEnabled = true - settings.domStorageEnabled = true - settings.loadWithOverviewMode = true - settings.useWideViewPort = true - webViewClient = LoggingClient() - if (content.startsWith("http://") || content.startsWith("https://")) { - loadUrl(content) - } else { - loadDataWithBaseURL(null, content, "text/html", "utf-8", null) + Column(Modifier.fillMaxSize()) { + AndroidView( + modifier = Modifier.weight(1f).fillMaxSize(), + factory = { context -> + WebView(context).apply { + settings.javaScriptEnabled = true + settings.domStorageEnabled = true + settings.loadWithOverviewMode = true + settings.useWideViewPort = true + webViewClient = LoggingClient() + addJavascriptInterface( + FieldFocusBridge(onFocus = { fieldText = it }, onBlur = { fieldText = 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 }, - onRelease = { it.destroy() }, - ) + }, + // Pages that honour it follow the darkness outside; a first, tweakable use of the signal. + 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() { override fun onPageFinished(view: WebView?, url: String?) { 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 @@ -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
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(); + }); + })(); +""" diff --git a/gearslip/src/main/java/app/seb3thehacker/gearslip/host/AndroidAuto.kt b/gearslip/src/main/java/app/seb3thehacker/gearslip/host/AndroidAuto.kt index 7c1069f..58a0192 100644 --- a/gearslip/src/main/java/app/seb3thehacker/gearslip/host/AndroidAuto.kt +++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/host/AndroidAuto.kt @@ -1,11 +1,19 @@ 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 * (there are many: one per screen it registers) are noise in every list of apps to open. */ object AndroidAuto { + /** Kept in sync with the `` 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") fun matches(packageName: String, label: String): Boolean { @@ -14,4 +22,26 @@ object AndroidAuto { val name = label.lowercase().replace(Regex("[^a-z ]"), " ") 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 + } } diff --git a/gearslip/src/main/java/app/seb3thehacker/gearslip/host/CarAppCatalog.kt b/gearslip/src/main/java/app/seb3thehacker/gearslip/host/CarAppCatalog.kt index a23337b..a9b2ca6 100644 --- a/gearslip/src/main/java/app/seb3thehacker/gearslip/host/CarAppCatalog.kt +++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/host/CarAppCatalog.kt @@ -41,6 +41,7 @@ object CarAppCatalog { .asSequence() .filter { it.serviceInfo.packageName != context.packageName } .filterNot { AndroidAuto.matches(it.serviceInfo.packageName, it.loadLabel(manager).toString()) } + .filterNot { KnownApps.isHidden(it.serviceInfo.packageName) } .map { resolved -> val info = resolved.serviceInfo TemplateApp( diff --git a/gearslip/src/main/java/app/seb3thehacker/gearslip/host/KnownApps.kt b/gearslip/src/main/java/app/seb3thehacker/gearslip/host/KnownApps.kt index 79144a5..b1cd4a2 100644 --- a/gearslip/src/main/java/app/seb3thehacker/gearslip/host/KnownApps.kt +++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/host/KnownApps.kt @@ -14,7 +14,28 @@ object KnownApps { "app.organicmaps", // Organic Maps: map, search, routing, settings "net.osmand.plus", // OsmAnd: map, search, routing, settings "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 + + /** + * 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 } diff --git a/gearslip/src/main/java/app/seb3thehacker/gearslip/media/CarMedia.kt b/gearslip/src/main/java/app/seb3thehacker/gearslip/media/CarMedia.kt index a2b6b53..1cb52a3 100644 --- a/gearslip/src/main/java/app/seb3thehacker/gearslip/media/CarMedia.kt +++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/media/CarMedia.kt @@ -6,6 +6,7 @@ import android.graphics.Bitmap import android.media.MediaMetadata import android.media.browse.MediaBrowser import android.media.session.MediaController +import android.media.session.MediaSession import android.media.session.MediaSessionManager import android.media.session.PlaybackState import android.net.Uri @@ -33,6 +34,15 @@ class MediaEntry( 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. */ class NowPlaying( val title: String = "", @@ -48,6 +58,8 @@ class NowPlaying( val actions: Long = 0, val custom: List = emptyList(), 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 hasTrack get() = title.isNotEmpty() || state != PlaybackState.STATE_NONE @@ -109,6 +121,9 @@ class CarMedia(private val context: Context) { private val _now = MutableStateFlow(NowPlaying()) val now: StateFlow = _now.asStateFlow() + private val _queue = MutableStateFlow>(emptyList()) + val queue: StateFlow> = _queue.asStateFlow() + private var browser: MediaBrowser? = null private var controller: MediaController? = null private var subscribed: String? = null @@ -171,6 +186,7 @@ class CarMedia(private val context: Context) { controller = session.also { it.registerCallback(callback) update(it.metadata, it.playbackState) + _queue.value = queueOf(it.queue) } _browse.value = BrowseState(loading = false, unavailable = true) _phase.value = Phase.READY @@ -182,6 +198,7 @@ class CarMedia(private val context: Context) { controller = MediaController(context, mb.sessionToken).also { it.registerCallback(callback) update(it.metadata, it.playbackState) + _queue.value = queueOf(it.queue) } rootId = mb.root path.clear() @@ -192,6 +209,20 @@ class CarMedia(private val context: Context) { private val callback = object : MediaController.Callback() { override fun onMetadataChanged(metadata: MediaMetadata?) = update(metadata, controller?.playbackState) override fun onPlaybackStateChanged(state: PlaybackState?) = update(controller?.metadata, state) + override fun onQueueChanged(queue: MutableList?) { + _queue.value = queueOf(queue) + } + } + + private fun queueOf(items: List?): List = 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?) { @@ -212,6 +243,7 @@ class CarMedia(private val context: Context) { actions = state?.actions ?: 0, custom = state?.customActions.orEmpty().map { CustomAction(it.action, it.name.toString(), it.extras) }, 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 seek(ms: Long) = controller?.transportControls?.seekTo(ms) fun custom(action: CustomAction) = controller?.transportControls?.sendCustomAction(action.id, action.extras) + fun playQueueItem(track: QueueTrack) = controller?.transportControls?.skipToQueueItem(track.queueId) fun disconnect() { connecting = null @@ -298,6 +331,7 @@ class CarMedia(private val context: Context) { subscribed = null _phase.value = Phase.IDLE _now.value = NowPlaying() + _queue.value = emptyList() } private companion object { diff --git a/gearslip/src/main/java/app/seb3thehacker/gearslip/media/MediaCatalog.kt b/gearslip/src/main/java/app/seb3thehacker/gearslip/media/MediaCatalog.kt index 97ff21f..09c4f3b 100644 --- a/gearslip/src/main/java/app/seb3thehacker/gearslip/media/MediaCatalog.kt +++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/media/MediaCatalog.kt @@ -1,6 +1,7 @@ package app.seb3thehacker.gearslip.media import app.seb3thehacker.gearslip.host.AndroidAuto +import app.seb3thehacker.gearslip.host.KnownApps import android.content.ComponentName import android.content.Context import android.content.Intent @@ -33,6 +34,7 @@ object MediaCatalog { .asSequence() .filter { it.serviceInfo.packageName != context.packageName } .filterNot { AndroidAuto.matches(it.serviceInfo.packageName, it.loadLabel(manager).toString()) } + .filterNot { KnownApps.isHidden(it.serviceInfo.packageName) } .filter { declaresCar(manager, it.serviceInfo) } .map { val info = it.serviceInfo diff --git a/gearslip/src/main/java/app/seb3thehacker/gearslip/ui/CertSetupScreen.kt b/gearslip/src/main/java/app/seb3thehacker/gearslip/ui/CertSetupScreen.kt new file mode 100644 index 0000000..870d7b6 --- /dev/null +++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/ui/CertSetupScreen.kt @@ -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.Idle) } + var showImport by remember { mutableStateOf(false) } + var pendingUri by remember { mutableStateOf(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(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") } }, + ) +} diff --git a/gearslip/src/main/java/app/seb3thehacker/gearslip/ui/CertSummary.kt b/gearslip/src/main/java/app/seb3thehacker/gearslip/ui/CertSummary.kt index f529e8f..f06c7f9 100644 --- a/gearslip/src/main/java/app/seb3thehacker/gearslip/ui/CertSummary.kt +++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/ui/CertSummary.kt @@ -19,12 +19,17 @@ data class CertSummary( val identity = CertProvider.loadSupplied(context) ?: return CertSummary( 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, ) val cert = identity.certificate 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( identity.kind, "$prefix: $name", diff --git a/gearslip/src/main/java/app/seb3thehacker/gearslip/ui/CopyableCommand.kt b/gearslip/src/main/java/app/seb3thehacker/gearslip/ui/CopyableCommand.kt new file mode 100644 index 0000000..9667541 --- /dev/null +++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/ui/CopyableCommand.kt @@ -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, + ) + } + } +} diff --git a/gearslip/src/main/java/app/seb3thehacker/gearslip/ui/GearslipApp.kt b/gearslip/src/main/java/app/seb3thehacker/gearslip/ui/GearslipApp.kt index 1a430d1..d29decf 100644 --- a/gearslip/src/main/java/app/seb3thehacker/gearslip/ui/GearslipApp.kt +++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/ui/GearslipApp.kt @@ -7,17 +7,32 @@ 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.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.platform.LocalContext 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. */ @Composable 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 } when (screen) { @@ -25,12 +40,22 @@ fun GearslipApp(onDisconnect: () -> Unit, onRequestCallScreening: () -> Unit) { onOpenLogs = { screen = Screen.LOGS }, onOpenSettings = { screen = Screen.SETTINGS }, onOpenCarPreview = { screen = Screen.CAR_PREVIEW }, + onOpenHelp = { screen = Screen.HELP }, onDisconnect = onDisconnect, - onRequestCallScreening = onRequestCallScreening, ) Screen.LOGS -> LogsScreen(onBack = { screen = Screen.HOME }) Screen.SETTINGS -> SettingsScreen(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() @@ -48,9 +73,8 @@ private fun CompatWarning() { title = { Text("Before you plug in") }, text = { Text( - "Gearslip may not connect on cars or head units newer than about 2020. " + - "Older systems are more likely to work; newer firmware often rejects the " + - "connection outright.", + "Gearslip works with cars built before 2020. " + + "Newer firmware often blocks the connection.", ) }, confirmButton = { diff --git a/gearslip/src/main/java/app/seb3thehacker/gearslip/ui/HelpScreen.kt b/gearslip/src/main/java/app/seb3thehacker/gearslip/ui/HelpScreen.kt new file mode 100644 index 0000000..e76e349 --- /dev/null +++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/ui/HelpScreen.kt @@ -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, + ) + } + } +} diff --git a/gearslip/src/main/java/app/seb3thehacker/gearslip/ui/HomeScreen.kt b/gearslip/src/main/java/app/seb3thehacker/gearslip/ui/HomeScreen.kt index 66a98ea..5cbdd41 100644 --- a/gearslip/src/main/java/app/seb3thehacker/gearslip/ui/HomeScreen.kt +++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/ui/HomeScreen.kt @@ -1,8 +1,6 @@ package app.seb3thehacker.gearslip.ui 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.Row import androidx.compose.foundation.layout.Spacer @@ -30,11 +28,7 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment 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.text.AnnotatedString -import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle @@ -46,8 +40,8 @@ fun HomeScreen( onOpenLogs: () -> Unit, onOpenSettings: () -> Unit, onOpenCarPreview: () -> Unit, + onOpenHelp: () -> Unit, onDisconnect: () -> Unit, - onRequestCallScreening: () -> Unit, ) { val status by SessionStatus.state.collectAsStateWithLifecycle() val context = LocalContext.current @@ -66,22 +60,25 @@ fun HomeScreen( .verticalScroll(rememberScrollState()), ) { Spacer(Modifier.height(24.dp)) - Text( - "Gearslip", - style = MaterialTheme.typography.displayLarge, - fontWeight = FontWeight.Bold, - color = MaterialTheme.colorScheme.onBackground, - ) - Text( - "v${LocalContext.current.appVersionName()}", - style = MaterialTheme.typography.labelLarge, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) + Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) { + Column { + Text( + "Gearslip", + style = MaterialTheme.typography.displayLarge, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.onBackground, + ) + Text( + "v${LocalContext.current.appVersionName()}", + style = MaterialTheme.typography.labelLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + HelpButton(onClick = onOpenHelp) + } Spacer(Modifier.height(16.dp)) StatusCard(status) Spacer(Modifier.height(24.dp)) - SetupSteps(onRequestCallScreening) - Spacer(Modifier.height(24.dp)) Row( Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically, @@ -182,137 +179,3 @@ private fun StatusCard(status: SessionStatus.Snapshot) { private fun Context.appVersionName(): String = 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 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 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( - "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."), -) - -/** - * 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(onRequestCallScreening: () -> Unit) { - val steps = remember(onRequestCallScreening) { setupSteps(onRequestCallScreening) } - 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, - ) - 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") - } - } - } - } - } - } - } - } -} diff --git a/gearslip/src/main/java/app/seb3thehacker/gearslip/ui/SettingsScreen.kt b/gearslip/src/main/java/app/seb3thehacker/gearslip/ui/SettingsScreen.kt index a89dc7c..950b2ce 100644 --- a/gearslip/src/main/java/app/seb3thehacker/gearslip/ui/SettingsScreen.kt +++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/ui/SettingsScreen.kt @@ -18,6 +18,7 @@ import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material3.AlertDialog import androidx.compose.material3.Button import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.FilledTonalButton import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme @@ -124,6 +125,26 @@ private fun CertificateSection() { Button(onClick = { picker.launch(arrayOf("*/*")) }, modifier = Modifier.fillMaxWidth().height(52.dp)) { 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) { OutlinedButton( onClick = { @@ -134,9 +155,21 @@ private fun CertificateSection() { modifier = Modifier.fillMaxWidth().height(52.dp), ) { 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) } 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, color = MaterialTheme.colorScheme.onSurfaceVariant, ) diff --git a/gearslip/src/main/java/app/seb3thehacker/gearslip/ui/SetupGuideScreen.kt b/gearslip/src/main/java/app/seb3thehacker/gearslip/ui/SetupGuideScreen.kt new file mode 100644 index 0000000..9abcb2d --- /dev/null +++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/ui/SetupGuideScreen.kt @@ -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? = 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): 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 = 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)) + } +} diff --git a/settings.gradle.kts b/settings.gradle.kts index 46d5e0e..2201c80 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -25,5 +25,5 @@ dependencyResolutionManagement { rootProject.name = "Gearslip" include(":gearslip") -include(":ksdump") +// include(":ksdump") \ No newline at end of file