diff --git a/gearslip/src/main/java/app/seb3thehacker/gearslip/GearslipActivity.kt b/gearslip/src/main/java/app/seb3thehacker/gearslip/GearslipActivity.kt index 41010b5..b74175a 100644 --- a/gearslip/src/main/java/app/seb3thehacker/gearslip/GearslipActivity.kt +++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/GearslipActivity.kt @@ -15,6 +15,8 @@ import android.view.Surface import android.view.WindowManager import androidx.activity.ComponentActivity import androidx.activity.compose.setContent +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue import androidx.activity.enableEdgeToEdge import androidx.activity.result.contract.ActivityResultContracts.StartActivityForResult import android.content.res.Configuration @@ -85,9 +87,13 @@ class GearslipActivity : ComponentActivity(), Projection { } else { GearslipLog.w("audio: capture consent declined") CarAudio.denied() + if (!CarSettings.audioChoiceAsked) offerBluetooth.value = true } } + /** Raised once, after the first "no" to the capture dialog; see [AudioChoiceDialog]. */ + private val offerBluetooth = kotlinx.coroutines.flow.MutableStateFlow(false) + private val detachReceiver = object : BroadcastReceiver() { override fun onReceive(context: Context?, intent: Intent?) { if (intent?.action == UsbManager.ACTION_USB_ACCESSORY_DETACHED) { @@ -128,6 +134,20 @@ class GearslipActivity : ComponentActivity(), Projection { setContent { GearslipTheme { GearslipApp(onDisconnect = ::closeAccessory, onRequestCallScreening = ::requestCallScreeningRole) + val offer by offerBluetooth.collectAsState() + if (offer) { + fun close() { + CarSettings.markAudioChoiceAsked() + offerBluetooth.value = false + } + app.seb3thehacker.gearslip.ui.AudioChoiceDialog( + onUseBluetooth = { + CarSettings.setPipeAudio(false) + close() + }, + onKeepAsking = ::close, + ) + } } } diff --git a/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CarSettings.kt b/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CarSettings.kt index 99616be..f59ddbd 100644 --- a/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CarSettings.kt +++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CarSettings.kt @@ -2,6 +2,7 @@ package app.seb3thehacker.gearslip.car import android.content.Context import app.seb3thehacker.gearslip.AppSettings +import app.seb3thehacker.gearslip.audio.CarAudio import app.seb3thehacker.gearslip.car.theme.GsTheme import app.seb3thehacker.gearslip.car.theme.GsThemes import kotlinx.coroutines.flow.MutableStateFlow @@ -27,6 +28,7 @@ object CarSettings { private const val KEY_OPEN = "car_open_on_connect" private const val KEY_NIGHT = "car_night_mode" private const val KEY_PIPE_AUDIO = "car_pipe_audio" + private const val KEY_AUDIO_CHOICE_ASKED = "car_audio_choice_asked" private const val KEY_LAST_NAV = "car_last_nav" private const val KEY_LAST_MEDIA = "car_last_media" private const val KEY_AUTOPLAY = "car_autoplay" @@ -148,10 +150,20 @@ object CarSettings { * Whether a media app's sound is captured and sent over the car link. On by default, since * sound in the car is the point of the exercise; the platform allows the capture only behind a * consent dialog, which [app.seb3thehacker.gearslip.audio.CarAudio] raises once the car has - * somewhere to play it. Turned off, the app plays through the phone's normal output instead. + * somewhere to play it. Turned off ("Bluetooth" in the settings), the app plays through the + * phone's normal output instead, and Android never asks. */ val pipeAudio: StateFlow = pipeAudioFlow + /** + * Whether the driver has already been offered Bluetooth after turning down the capture + * dialog. Offered once only: someone who chose to keep the USB route shouldn't be nagged. + */ + val audioChoiceAsked: Boolean + get() = AppSettings.getString(app, KEY_AUDIO_CHOICE_ASKED, "false") == "true" + + fun markAudioChoiceAsked() = AppSettings.putString(app, KEY_AUDIO_CHOICE_ASKED, "true") + /** * Off by default: a driver has to opt into experimental features as a whole before any of * them - the voice assistant included - show up to turn on individually. @@ -265,9 +277,15 @@ object CarSettings { AppSettings.putString(app, KEY_AUTOPLAY, on.toString()) } + /** + * Takes effect at once: switching to Bluetooth mid-drive stops the capture, and switching + * back asks for it again (or waits for the car, if none is connected). + */ fun setPipeAudio(on: Boolean) { + if (pipeAudioFlow.value == on) return pipeAudioFlow.value = on AppSettings.putString(app, KEY_PIPE_AUDIO, on.toString()) + if (on) CarAudio.request() else CarAudio.release() } fun setExperimentalFeaturesEnabled(on: Boolean) { 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 fe1a617..6f0f53f 100644 --- a/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CarSettingsScreen.kt +++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CarSettingsScreen.kt @@ -126,14 +126,14 @@ fun CarSettingsScreen() { ) { CarSettings.setAutoplay(it) } ChoiceRow( - "Media audio", - listOf(false to "Phone audio", true to "Car audio"), + "Car audio", + listOf(false to "Bluetooth", true to "USB"), pipeAudio, ) { CarSettings.setPipeAudio(it) } Text( - "Car audio sends the phone's sound to the car over USB. Android asks for permission " + - "when the car connects, and a few apps block it. Phone audio plays as the phone " + - "normally would, for example over Bluetooth to the car.", + "USB sends the phone's sound through Gearslip. Android asks to share your screen each " + + "drive, and a few apps block it. Bluetooth plays as the phone normally would, and " + + "Android never asks.", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, ) diff --git a/gearslip/src/main/java/app/seb3thehacker/gearslip/ui/AudioChoiceDialog.kt b/gearslip/src/main/java/app/seb3thehacker/gearslip/ui/AudioChoiceDialog.kt new file mode 100644 index 0000000..f53ecde --- /dev/null +++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/ui/AudioChoiceDialog.kt @@ -0,0 +1,49 @@ +package app.seb3thehacker.gearslip.ui + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.MaterialTheme +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.setValue +import androidx.compose.ui.unit.dp +import app.seb3thehacker.gearslip.BuildConfig + +/** + * Shown once, the first time the driver turns down Android's screen-sharing dialog: that dialog + * is the price of sending music over USB, and most people who refuse it would rather not see it + * again. Offers the two ways out: Bluetooth, or the one-time adb command. + */ +@Composable +fun AudioChoiceDialog(onUseBluetooth: () -> Unit, onKeepAsking: () -> Unit) { + var showCommand by remember { mutableStateOf(false) } + AlertDialog( + onDismissRequest = onKeepAsking, + title = { Text("Play car audio over Bluetooth?") }, + text = { + Column(verticalArrangement = Arrangement.spacedBy(12.dp)) { + Text( + "Android treats music sent over USB like sharing your screen, so it asks every " + + "drive. Over Bluetooth it never asks. You can change this later in Settings.", + ) + if (showCommand) { + Text( + "Or keep USB and stop the question for good: plug the phone into a " + + "computer with USB debugging on, and run this once.", + style = MaterialTheme.typography.bodyMedium, + ) + CopyableCommand("adb shell appops set ${BuildConfig.APPLICATION_ID} PROJECT_MEDIA allow") + } else { + TextButton(onClick = { showCommand = true }) { Text("Never ask, using a computer") } + } + } + }, + confirmButton = { TextButton(onClick = onUseBluetooth) { Text("Use Bluetooth") } }, + dismissButton = { TextButton(onClick = onKeepAsking) { Text("Keep asking") } }, + ) +} 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 2d8ef0b..cd8bb1f 100644 --- a/gearslip/src/main/java/app/seb3thehacker/gearslip/ui/SettingsScreen.kt +++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/ui/SettingsScreen.kt @@ -12,6 +12,7 @@ import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.selection.selectable import androidx.compose.foundation.selection.toggleable import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.foundation.verticalScroll @@ -33,12 +34,14 @@ import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.RadioButton import androidx.compose.material3.Scaffold import androidx.compose.material3.Switch import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.material3.TopAppBar import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateOf @@ -55,6 +58,7 @@ import androidx.compose.ui.unit.dp import app.seb3thehacker.gearslip.AppSettings import app.seb3thehacker.gearslip.BuildConfig import app.seb3thehacker.gearslip.CertProvider +import app.seb3thehacker.gearslip.car.CarSettings import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import androidx.compose.runtime.LaunchedEffect @@ -103,6 +107,7 @@ fun SettingsScreen( Spacer(Modifier.height(0.dp)) AboutSection(onOpenWhatsNew, onReplayTutorial) ConnectionSection() + CarAudioSection() if (SHOW_RECOMMENDED_APPS) AppsSection(onOpenRecommendedApps) CertificateSection() SettingsSection("Usage notes", header = USAGE_STATS_INTRO) { UsageStatsRows() } @@ -163,6 +168,30 @@ private fun ConnectionSection() { } } +/** How music reaches the car: through Gearslip over USB, or by Bluetooth as usual. */ +@Composable +private fun CarAudioSection() { + val usb by CarSettings.pipeAudio.collectAsState() + SettingsSection( + "Car audio", + footer = "Run the command in Help once, and USB stops asking.", + ) { + SettingsRow( + "USB", + Modifier.selectable(usb, role = Role.RadioButton) { CarSettings.setPipeAudio(true) }, + subtitle = "Sound goes through Gearslip. Android asks to share your screen each drive", + trailing = { RadioButton(selected = usb, onClick = null) }, + ) + SettingsDivider() + SettingsRow( + "Bluetooth", + Modifier.selectable(!usb, role = Role.RadioButton) { CarSettings.setPipeAudio(false) }, + subtitle = "Sound plays over the phone's Bluetooth link to the car. Android never asks", + trailing = { RadioButton(selected = !usb, onClick = null) }, + ) + } +} + @Composable private fun AppsSection(onOpenRecommendedApps: () -> Unit) { SettingsSection("Apps") {