mirror of
https://github.com/twinksoftware/Gearslip.git
synced 2026-11-10 16:02:25 +01:00
car audio: choose Bluetooth or USB, offer Bluetooth after a refused screen share
USB audio needs Android's screen-sharing consent every drive. A new Car audio setting on the phone and car screens picks Bluetooth instead, and the first refusal opens a dialog offering Bluetooth, asking again, or the one-time adb command.
This commit is contained in:
1 parent
f9e38cbc45
commit
261e6a719a
5 files changed
+122
-6
No files matched your search
@@ -15,6 +15,8 @@ import android.view.Surface
|
|||||||
import android.view.WindowManager
|
import android.view.WindowManager
|
||||||
import androidx.activity.ComponentActivity
|
import androidx.activity.ComponentActivity
|
||||||
import androidx.activity.compose.setContent
|
import androidx.activity.compose.setContent
|
||||||
|
import androidx.compose.runtime.collectAsState
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.activity.enableEdgeToEdge
|
import androidx.activity.enableEdgeToEdge
|
||||||
import androidx.activity.result.contract.ActivityResultContracts.StartActivityForResult
|
import androidx.activity.result.contract.ActivityResultContracts.StartActivityForResult
|
||||||
import android.content.res.Configuration
|
import android.content.res.Configuration
|
||||||
@@ -85,9 +87,13 @@ class GearslipActivity : ComponentActivity(), Projection {
|
|||||||
} else {
|
} else {
|
||||||
GearslipLog.w("audio: capture consent declined")
|
GearslipLog.w("audio: capture consent declined")
|
||||||
CarAudio.denied()
|
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() {
|
private val detachReceiver = object : BroadcastReceiver() {
|
||||||
override fun onReceive(context: Context?, intent: Intent?) {
|
override fun onReceive(context: Context?, intent: Intent?) {
|
||||||
if (intent?.action == UsbManager.ACTION_USB_ACCESSORY_DETACHED) {
|
if (intent?.action == UsbManager.ACTION_USB_ACCESSORY_DETACHED) {
|
||||||
@@ -128,6 +134,20 @@ class GearslipActivity : ComponentActivity(), Projection {
|
|||||||
setContent {
|
setContent {
|
||||||
GearslipTheme {
|
GearslipTheme {
|
||||||
GearslipApp(onDisconnect = ::closeAccessory, onRequestCallScreening = ::requestCallScreeningRole)
|
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,
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package app.seb3thehacker.gearslip.car
|
|||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import app.seb3thehacker.gearslip.AppSettings
|
import app.seb3thehacker.gearslip.AppSettings
|
||||||
|
import app.seb3thehacker.gearslip.audio.CarAudio
|
||||||
import app.seb3thehacker.gearslip.car.theme.GsTheme
|
import app.seb3thehacker.gearslip.car.theme.GsTheme
|
||||||
import app.seb3thehacker.gearslip.car.theme.GsThemes
|
import app.seb3thehacker.gearslip.car.theme.GsThemes
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
@@ -27,6 +28,7 @@ object CarSettings {
|
|||||||
private const val KEY_OPEN = "car_open_on_connect"
|
private const val KEY_OPEN = "car_open_on_connect"
|
||||||
private const val KEY_NIGHT = "car_night_mode"
|
private const val KEY_NIGHT = "car_night_mode"
|
||||||
private const val KEY_PIPE_AUDIO = "car_pipe_audio"
|
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_NAV = "car_last_nav"
|
||||||
private const val KEY_LAST_MEDIA = "car_last_media"
|
private const val KEY_LAST_MEDIA = "car_last_media"
|
||||||
private const val KEY_AUTOPLAY = "car_autoplay"
|
private const val KEY_AUTOPLAY = "car_autoplay"
|
||||||
@@ -148,10 +150,20 @@ object CarSettings {
|
|||||||
* Whether a media app's sound is captured and sent over the car link. On by default, since
|
* 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
|
* 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
|
* 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<Boolean> = pipeAudioFlow
|
val pipeAudio: StateFlow<Boolean> = 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
|
* 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.
|
* them - the voice assistant included - show up to turn on individually.
|
||||||
@@ -265,9 +277,15 @@ object CarSettings {
|
|||||||
AppSettings.putString(app, KEY_AUTOPLAY, on.toString())
|
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) {
|
fun setPipeAudio(on: Boolean) {
|
||||||
|
if (pipeAudioFlow.value == on) return
|
||||||
pipeAudioFlow.value = on
|
pipeAudioFlow.value = on
|
||||||
AppSettings.putString(app, KEY_PIPE_AUDIO, on.toString())
|
AppSettings.putString(app, KEY_PIPE_AUDIO, on.toString())
|
||||||
|
if (on) CarAudio.request() else CarAudio.release()
|
||||||
}
|
}
|
||||||
|
|
||||||
fun setExperimentalFeaturesEnabled(on: Boolean) {
|
fun setExperimentalFeaturesEnabled(on: Boolean) {
|
||||||
|
|||||||
@@ -126,14 +126,14 @@ fun CarSettingsScreen() {
|
|||||||
) { CarSettings.setAutoplay(it) }
|
) { CarSettings.setAutoplay(it) }
|
||||||
|
|
||||||
ChoiceRow(
|
ChoiceRow(
|
||||||
"Media audio",
|
"Car audio",
|
||||||
listOf(false to "Phone audio", true to "Car audio"),
|
listOf(false to "Bluetooth", true to "USB"),
|
||||||
pipeAudio,
|
pipeAudio,
|
||||||
) { CarSettings.setPipeAudio(it) }
|
) { CarSettings.setPipeAudio(it) }
|
||||||
Text(
|
Text(
|
||||||
"Car audio sends the phone's sound to the car over USB. Android asks for permission " +
|
"USB sends the phone's sound through Gearslip. Android asks to share your screen each " +
|
||||||
"when the car connects, and a few apps block it. Phone audio plays as the phone " +
|
"drive, and a few apps block it. Bluetooth plays as the phone normally would, and " +
|
||||||
"normally would, for example over Bluetooth to the car.",
|
"Android never asks.",
|
||||||
style = MaterialTheme.typography.bodySmall,
|
style = MaterialTheme.typography.bodySmall,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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") } },
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -12,6 +12,7 @@ import androidx.compose.foundation.layout.fillMaxSize
|
|||||||
import androidx.compose.foundation.layout.height
|
import androidx.compose.foundation.layout.height
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.rememberScrollState
|
import androidx.compose.foundation.rememberScrollState
|
||||||
|
import androidx.compose.foundation.selection.selectable
|
||||||
import androidx.compose.foundation.selection.toggleable
|
import androidx.compose.foundation.selection.toggleable
|
||||||
import androidx.compose.foundation.text.KeyboardOptions
|
import androidx.compose.foundation.text.KeyboardOptions
|
||||||
import androidx.compose.foundation.verticalScroll
|
import androidx.compose.foundation.verticalScroll
|
||||||
@@ -33,12 +34,14 @@ import androidx.compose.material3.Icon
|
|||||||
import androidx.compose.material3.IconButton
|
import androidx.compose.material3.IconButton
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.OutlinedTextField
|
import androidx.compose.material3.OutlinedTextField
|
||||||
|
import androidx.compose.material3.RadioButton
|
||||||
import androidx.compose.material3.Scaffold
|
import androidx.compose.material3.Scaffold
|
||||||
import androidx.compose.material3.Switch
|
import androidx.compose.material3.Switch
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.material3.TextButton
|
import androidx.compose.material3.TextButton
|
||||||
import androidx.compose.material3.TopAppBar
|
import androidx.compose.material3.TopAppBar
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.collectAsState
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableIntStateOf
|
import androidx.compose.runtime.mutableIntStateOf
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
@@ -55,6 +58,7 @@ import androidx.compose.ui.unit.dp
|
|||||||
import app.seb3thehacker.gearslip.AppSettings
|
import app.seb3thehacker.gearslip.AppSettings
|
||||||
import app.seb3thehacker.gearslip.BuildConfig
|
import app.seb3thehacker.gearslip.BuildConfig
|
||||||
import app.seb3thehacker.gearslip.CertProvider
|
import app.seb3thehacker.gearslip.CertProvider
|
||||||
|
import app.seb3thehacker.gearslip.car.CarSettings
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
@@ -103,6 +107,7 @@ fun SettingsScreen(
|
|||||||
Spacer(Modifier.height(0.dp))
|
Spacer(Modifier.height(0.dp))
|
||||||
AboutSection(onOpenWhatsNew, onReplayTutorial)
|
AboutSection(onOpenWhatsNew, onReplayTutorial)
|
||||||
ConnectionSection()
|
ConnectionSection()
|
||||||
|
CarAudioSection()
|
||||||
if (SHOW_RECOMMENDED_APPS) AppsSection(onOpenRecommendedApps)
|
if (SHOW_RECOMMENDED_APPS) AppsSection(onOpenRecommendedApps)
|
||||||
CertificateSection()
|
CertificateSection()
|
||||||
SettingsSection("Usage notes", header = USAGE_STATS_INTRO) { UsageStatsRows() }
|
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
|
@Composable
|
||||||
private fun AppsSection(onOpenRecommendedApps: () -> Unit) {
|
private fun AppsSection(onOpenRecommendedApps: () -> Unit) {
|
||||||
SettingsSection("Apps") {
|
SettingsSection("Apps") {
|
||||||
|
|||||||
Reference in new issue
Block a user