Home warns when the phone charges but no car answers, and Help covers GrapheneOS's USB data setting

This commit is contained in:
Seb3thehacker committed 2026-11-03 11:10:11 -08:00
1 parent ea64329f2f
commit d302caf3a4
4 files changed
+76 -6

No files matched your search

@@ -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)
@@ -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,
@@ -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() ?: "?"