diff --git a/gearslip/src/main/java/app/seb3thehacker/gearslip/stats/UsageStats.kt b/gearslip/src/main/java/app/seb3thehacker/gearslip/stats/UsageStats.kt
index c9374e9..b5f2b59 100644
--- a/gearslip/src/main/java/app/seb3thehacker/gearslip/stats/UsageStats.kt
+++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/stats/UsageStats.kt
@@ -226,11 +226,11 @@ object UsageStats {
* build names, not confirmed on a real phone.
*/
fun osName(context: Context): String {
- fun installed(pkg: String) = runCatching { context.packageManager.getPackageInfo(pkg, 0) }.isSuccess
+ fun installed(pkg: String) = installed(context, pkg)
val names = listOf("ro.build.flavor", "ro.build.display.id", "ro.lineage.version")
.joinToString(" ") { prop(it) } + " " + Build.DISPLAY + " " + Build.FINGERPRINT
return when {
- installed("app.grapheneos.setupwizard") -> "GrapheneOS"
+ isGrapheneOs(context) -> "GrapheneOS"
prop("ro.calyxos.version").isNotEmpty() -> "CalyxOS"
installed("foundation.e.apps") -> "/e/OS"
names.contains("iode", ignoreCase = true) -> "iodéOS"
@@ -240,6 +240,12 @@ object UsageStats {
}
}
+ /** GrapheneOS, told apart from stock Pixel firmware by its own setup app. */
+ fun isGrapheneOs(context: Context) = installed(context, "app.grapheneos.setupwizard")
+
+ private fun installed(context: Context, pkg: String) =
+ runCatching { context.packageManager.getPackageInfo(pkg, 0) }.isSuccess
+
/** A system property, or "" when it isn't set or can't be read. */
private fun prop(name: String): String = runCatching {
Class.forName("android.os.SystemProperties").getMethod("get", String::class.java)
diff --git a/gearslip/src/main/java/app/seb3thehacker/gearslip/ui/HelpScreen.kt b/gearslip/src/main/java/app/seb3thehacker/gearslip/ui/HelpScreen.kt
index c56414d..9136a96 100644
--- a/gearslip/src/main/java/app/seb3thehacker/gearslip/ui/HelpScreen.kt
+++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/ui/HelpScreen.kt
@@ -5,6 +5,7 @@ import androidx.compose.ui.res.stringResource
import app.seb3thehacker.gearslip.R
import app.seb3thehacker.gearslip.BuildConfig
import app.seb3thehacker.gearslip.car.CarSettings
+import app.seb3thehacker.gearslip.stats.UsageStats
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
@@ -59,11 +60,13 @@ private val FIRST_DRIVE = listOf(
),
)
-private fun inTheCar(onRequestCallScreening: () -> Unit) = listOf(
+private fun inTheCar(grapheneOs: Boolean, onRequestCallScreening: () -> Unit) = listOfNotNull(
Step(
R.string.help_plug_in,
R.string.help_plug_in_detail,
),
+ // GrapheneOS can turn off data on the USB port, and then the car never sees the phone (#71).
+ if (grapheneOs) Step(R.string.help_usb_data, R.string.help_usb_data_detail) else null,
Step(
R.string.help_pair,
R.string.help_pair_detail,
@@ -93,9 +96,9 @@ private val USB_AUDIO = listOf(
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun HelpScreen(onBack: () -> Unit, onRequestCallScreening: () -> Unit) {
- val car = remember(onRequestCallScreening) { inTheCar(onRequestCallScreening) }
- val usb by CarSettings.pipeAudio.collectAsState()
val context = LocalContext.current
+ val car = remember(onRequestCallScreening) { inTheCar(UsageStats.isGrapheneOs(context), onRequestCallScreening) }
+ val usb by CarSettings.pipeAudio.collectAsState()
Scaffold(
containerColor = MaterialTheme.colorScheme.background,
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 fc36ecd..20ffd8c 100644
--- a/gearslip/src/main/java/app/seb3thehacker/gearslip/ui/HomeScreen.kt
+++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/ui/HomeScreen.kt
@@ -25,6 +25,15 @@ import app.seb3thehacker.gearslip.car.CarSettings
import app.seb3thehacker.gearslip.car.BluetoothAudioCheck
import android.provider.Settings
import android.content.Intent
+import android.content.BroadcastReceiver
+import android.content.IntentFilter
+import android.os.BatteryManager
+import androidx.compose.runtime.DisposableEffect
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.setValue
+import app.seb3thehacker.gearslip.stats.UsageStats
+import kotlinx.coroutines.delay
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
@@ -258,6 +267,9 @@ private fun StatusCard(status: SessionStatus.Snapshot) {
}
val live = status.phase == SessionStatus.Phase.CONNECTING || status.phase == SessionStatus.Phase.PROJECTING
+ val context = LocalContext.current
+ val grapheneOs = remember { UsageStats.isGrapheneOs(context) }
+ val noCar = chargingWithNoCar(status.phase == SessionStatus.Phase.IDLE)
Surface(color = container, contentColor = content, shape = MaterialTheme.shapes.extraLarge) {
Column(Modifier.fillMaxWidth().padding(24.dp)) {
Row(
@@ -286,7 +298,13 @@ private fun StatusCard(status: SessionStatus.Snapshot) {
SessionStatus.Phase.FAILED -> status.headline
}
val detail = when (status.phase) {
- SessionStatus.Phase.IDLE -> stringResource(R.string.status_idle_detail)
+ SessionStatus.Phase.IDLE -> stringResource(
+ when {
+ !noCar -> R.string.status_idle_detail
+ grapheneOs -> R.string.status_no_car_graphene
+ else -> R.string.status_no_car
+ },
+ )
SessionStatus.Phase.PROJECTING -> stringResource(R.string.status_projecting_detail)
SessionStatus.Phase.DISCONNECTED -> stringResource(R.string.status_disconnected_detail)
else -> status.detail
@@ -296,6 +314,13 @@ private fun StatusCard(status: SessionStatus.Snapshot) {
Text(detail, style = MaterialTheme.typography.bodyMedium)
}
}
+ // GrapheneOS keeps its USB-C port setting under Security & privacy.
+ if (noCar && grapheneOs) {
+ Spacer(Modifier.height(12.dp))
+ Button(onClick = {
+ runCatching { context.startActivity(Intent(Settings.ACTION_SECURITY_SETTINGS)) }
+ }) { Text(stringResource(R.string.status_open_settings)) }
+ }
// The other way in: wireless, right under the cable's instructions.
if (!live) {
Spacer(Modifier.height(20.dp))
@@ -305,6 +330,37 @@ private fun StatusCard(status: SessionStatus.Snapshot) {
}
}
+/**
+ * True once the phone has charged over a cable for [NO_CAR_MS] while Gearslip sat idle. With
+ * USB data blocked, by a charge-only cable or GrapheneOS's port setting, Android never tells
+ * Gearslip a car is there, so charging with no car is the only sign there is. A wall charger
+ * looks the same, which is why the hint only asks the driver to check.
+ */
+@Composable
+private fun chargingWithNoCar(idle: Boolean): Boolean {
+ val context = LocalContext.current
+ var plugged by remember { mutableStateOf(false) }
+ DisposableEffect(context) {
+ val receiver = object : BroadcastReceiver() {
+ override fun onReceive(c: Context, intent: Intent) {
+ val how = intent.getIntExtra(BatteryManager.EXTRA_PLUGGED, 0)
+ plugged = how == BatteryManager.BATTERY_PLUGGED_USB || how == BatteryManager.BATTERY_PLUGGED_AC
+ }
+ }
+ // The battery broadcast is sticky, so registering hands back the current state at once.
+ context.registerReceiver(receiver, IntentFilter(Intent.ACTION_BATTERY_CHANGED))?.let { receiver.onReceive(context, it) }
+ onDispose { context.unregisterReceiver(receiver) }
+ }
+ var waited by remember { mutableStateOf(false) }
+ LaunchedEffect(idle && plugged) {
+ waited = false
+ if (idle && plugged) { delay(NO_CAR_MS); waited = true }
+ }
+ return idle && plugged && waited
+}
+
+private const val NO_CAR_MS = 10_000L
+
/** The version shown on screen, straight from the manifest - one place to bump per release. */
private fun Context.appVersionName(): String =
runCatching { packageManager.getPackageInfo(packageName, 0).versionName }.getOrNull() ?: "?"
diff --git a/gearslip/src/main/res/values/strings.xml b/gearslip/src/main/res/values/strings.xml
index 9fa6e27..774dc14 100644
--- a/gearslip/src/main/res/values/strings.xml
+++ b/gearslip/src/main/res/values/strings.xml
@@ -269,6 +269,8 @@
Android won\'t let an app pass music to the car without it. Gearslip listens only when you tap the mic or press the talk button on your wheel.
Plug in, or connect wirelessly.
Gearslip opens by itself when the car connects. For wireless, pair the phone with the car first, then tap Connect wirelessly on Home.
+ Allow USB data.
+ GrapheneOS can block data on the USB port. If USB-C port control is set to Charging-only, the car never sees the phone, even with a data cable. Set it to allow data.
Pair over Bluetooth for calls.
Calls play through the car once your phone and the car are paired. Gearslip offers to pair the first time you plug in.
Answer calls from the car screen.
@@ -858,6 +860,9 @@
Not connected
Plug into the car\'s USB port, or connect wirelessly. Gearslip opens by itself when the car answers.
+ The phone is charging, but no car has answered. Some cables only charge; try one that carries data.
+ The phone is charging, but no car has answered. Check that USB-C port control, under Security & privacy, allows data and isn\'t set to Charging-only.
+ Open settings
Connecting
Projecting
Streaming to the head unit.