mirror of
https://github.com/twinksoftware/Gearslip.git
synced 2026-11-10 16:02:25 +01:00
car audio: with Bluetooth audio chosen, warn on the car and on Home when the phone isn't connected to the car 20 seconds in
This commit is contained in:
1 parent
a84d36f17a
commit
88e1885b2d
8 files changed
+221
-2
No files matched your search
@@ -4,6 +4,7 @@ import android.os.Build
|
|||||||
import app.seb3thehacker.gearslip.audio.AudioLink
|
import app.seb3thehacker.gearslip.audio.AudioLink
|
||||||
import app.seb3thehacker.gearslip.audio.CarMic
|
import app.seb3thehacker.gearslip.audio.CarMic
|
||||||
import app.seb3thehacker.gearslip.media.CarMediaStatus
|
import app.seb3thehacker.gearslip.media.CarMediaStatus
|
||||||
|
import app.seb3thehacker.gearslip.car.BluetoothAudioCheck
|
||||||
import app.seb3thehacker.gearslip.car.CarBluetooth
|
import app.seb3thehacker.gearslip.car.CarBluetooth
|
||||||
import app.seb3thehacker.gearslip.car.ClusterNav
|
import app.seb3thehacker.gearslip.car.ClusterNav
|
||||||
import app.seb3thehacker.gearslip.car.DirectionsVoice
|
import app.seb3thehacker.gearslip.car.DirectionsVoice
|
||||||
@@ -165,6 +166,7 @@ class GearslipRunner(
|
|||||||
CarSensors.clear()
|
CarSensors.clear()
|
||||||
CarMic.current = null
|
CarMic.current = null
|
||||||
CarBluetooth.onCarDisconnected()
|
CarBluetooth.onCarDisconnected()
|
||||||
|
BluetoothAudioCheck.onCarDisconnected()
|
||||||
mediaStatus?.stop()
|
mediaStatus?.stop()
|
||||||
mediaStatus = null
|
mediaStatus = null
|
||||||
clusterNav?.stop()
|
clusterNav?.stop()
|
||||||
@@ -1013,6 +1015,7 @@ class GearslipRunner(
|
|||||||
if (projection != null && surface != null) {
|
if (projection != null && surface != null) {
|
||||||
projection.onSurfaceReady(surface, width, height, config.density)
|
projection.onSurfaceReady(surface, width, height, config.density)
|
||||||
SessionStatus.projecting()
|
SessionStatus.projecting()
|
||||||
|
BluetoothAudioCheck.onProjecting()
|
||||||
}
|
}
|
||||||
}.onFailure { e -> log.e("encoder start failed", e) }
|
}.onFailure { e -> log.e("encoder start failed", e) }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,146 @@
|
|||||||
|
package app.seb3thehacker.gearslip.car
|
||||||
|
|
||||||
|
import android.Manifest
|
||||||
|
import android.annotation.SuppressLint
|
||||||
|
import android.bluetooth.BluetoothA2dp
|
||||||
|
import android.bluetooth.BluetoothAdapter
|
||||||
|
import android.bluetooth.BluetoothManager
|
||||||
|
import android.bluetooth.BluetoothProfile
|
||||||
|
import android.content.BroadcastReceiver
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
import android.content.IntentFilter
|
||||||
|
import android.content.pm.PackageManager
|
||||||
|
import android.os.Handler
|
||||||
|
import android.os.Looper
|
||||||
|
import app.seb3thehacker.gearslip.GearslipLog
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
|
||||||
|
/**
|
||||||
|
* With Bluetooth audio chosen, music only reaches the car if the phone is connected to it over
|
||||||
|
* Bluetooth. Twenty seconds after the car shows Gearslip, long enough for a car to connect on its
|
||||||
|
* own, this checks. If the phone isn't connected, the car and the phone's Home screen both say so,
|
||||||
|
* until it connects or the drive ends.
|
||||||
|
*
|
||||||
|
* When the car named its Bluetooth address, only that device counts. Otherwise any audio
|
||||||
|
* connection does, since there's no telling which device is the car.
|
||||||
|
*/
|
||||||
|
object BluetoothAudioCheck {
|
||||||
|
|
||||||
|
private val _missing = MutableStateFlow(false)
|
||||||
|
/** True while Bluetooth audio is chosen and the phone isn't connected to the car for it. */
|
||||||
|
val missing: StateFlow<Boolean> = _missing
|
||||||
|
|
||||||
|
private val _carDismissed = MutableStateFlow(false)
|
||||||
|
/** The driver tapped OK on the car's card; it stays away for the rest of this drive. */
|
||||||
|
val carDismissed: StateFlow<Boolean> = _carDismissed
|
||||||
|
|
||||||
|
private val log = GearslipLog.tagged("bluetooth")
|
||||||
|
private val main = Handler(Looper.getMainLooper())
|
||||||
|
private var appContext: Context? = null
|
||||||
|
private var armed = false
|
||||||
|
private var receiver: BroadcastReceiver? = null
|
||||||
|
|
||||||
|
fun init(context: Context) {
|
||||||
|
appContext = context.applicationContext
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The car shows Gearslip. Called again whenever the video restarts, so it arms once per drive. */
|
||||||
|
fun onProjecting() = main.post {
|
||||||
|
if (armed) return@post
|
||||||
|
armed = true
|
||||||
|
main.postDelayed(::start, WAIT_MS)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun onCarDisconnected() = main.post {
|
||||||
|
armed = false
|
||||||
|
main.removeCallbacksAndMessages(null)
|
||||||
|
stopWatching()
|
||||||
|
_missing.value = false
|
||||||
|
_carDismissed.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
fun dismissOnCar() {
|
||||||
|
_carDismissed.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
/** "Don't show again" on the car: turns the warning off in Settings, for every car. */
|
||||||
|
fun neverAgain() {
|
||||||
|
CarSettings.setBluetoothWarning(false)
|
||||||
|
log.i("Bluetooth audio warning turned off")
|
||||||
|
_carDismissed.value = true
|
||||||
|
_missing.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun start() {
|
||||||
|
if (!armed) return
|
||||||
|
check()
|
||||||
|
// Connecting later clears the warning, and losing the connection brings it back.
|
||||||
|
val context = appContext ?: return
|
||||||
|
val r = object : BroadcastReceiver() {
|
||||||
|
override fun onReceive(context: Context, intent: Intent) {
|
||||||
|
main.postDelayed(::check, SETTLE_MS)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
context.registerReceiver(
|
||||||
|
r,
|
||||||
|
IntentFilter(BluetoothA2dp.ACTION_CONNECTION_STATE_CHANGED).apply {
|
||||||
|
addAction(BluetoothAdapter.ACTION_STATE_CHANGED)
|
||||||
|
},
|
||||||
|
Context.RECEIVER_EXPORTED,
|
||||||
|
)
|
||||||
|
receiver = r
|
||||||
|
}
|
||||||
|
|
||||||
|
@SuppressLint("MissingPermission")
|
||||||
|
private fun check() {
|
||||||
|
if (!armed) return
|
||||||
|
val context = appContext ?: return
|
||||||
|
if (CarSettings.pipeAudio.value) {
|
||||||
|
set(false, "USB audio is on")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!CarSettings.bluetoothWarning.value) {
|
||||||
|
set(false, "the warning is turned off")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (context.checkSelfPermission(Manifest.permission.BLUETOOTH_CONNECT) != PackageManager.PERMISSION_GRANTED) {
|
||||||
|
log.i("can't check the audio connection: the Bluetooth permission isn't granted")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
val adapter = context.getSystemService(BluetoothManager::class.java)?.adapter ?: return
|
||||||
|
if (!adapter.isEnabled) {
|
||||||
|
set(true, "Bluetooth is off")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
val car = CarBluetooth.carAddress?.uppercase()
|
||||||
|
adapter.getProfileProxy(context, object : BluetoothProfile.ServiceListener {
|
||||||
|
override fun onServiceConnected(profile: Int, proxy: BluetoothProfile) {
|
||||||
|
val connected = runCatching { proxy.connectedDevices }.getOrDefault(emptyList())
|
||||||
|
adapter.closeProfileProxy(profile, proxy)
|
||||||
|
val ok = if (car != null) connected.any { it.address.uppercase() == car } else connected.isNotEmpty()
|
||||||
|
main.post {
|
||||||
|
set(!ok, if (ok) "connected to the car" else "not connected to the car")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onServiceDisconnected(profile: Int) {}
|
||||||
|
}, BluetoothProfile.A2DP)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun set(missing: Boolean, why: String) {
|
||||||
|
if (!armed || _missing.value == missing) return
|
||||||
|
_missing.value = missing
|
||||||
|
log.i("Bluetooth audio: $why")
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun stopWatching() {
|
||||||
|
val r = receiver ?: return
|
||||||
|
receiver = null
|
||||||
|
runCatching { appContext?.unregisterReceiver(r) }
|
||||||
|
}
|
||||||
|
|
||||||
|
private const val WAIT_MS = 20_000L
|
||||||
|
private const val SETTLE_MS = 1_000L
|
||||||
|
}
|
||||||
@@ -28,7 +28,8 @@ import androidx.compose.ui.unit.dp
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Asks once per car whether to pair it over Bluetooth, then waits while the phone shows its
|
* Asks once per car whether to pair it over Bluetooth, then waits while the phone shows its
|
||||||
* pairing dialog. See [CarBluetooth]. Unlike the call prompt, a tap outside the card does nothing:
|
* pairing dialog. See [CarBluetooth]. With no pairing to do, says when Bluetooth audio isn't
|
||||||
|
* connected; see [BluetoothAudioCheck]. Unlike the call prompt, a tap outside the card does nothing:
|
||||||
* the driver picks Pair or Not now.
|
* the driver picks Pair or Not now.
|
||||||
*/
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
@@ -36,7 +37,18 @@ internal fun BluetoothPairOverlay() {
|
|||||||
val prompt by CarBluetooth.prompt.collectAsState()
|
val prompt by CarBluetooth.prompt.collectAsState()
|
||||||
val bluetoothAudio = !CarSettings.pipeAudio.collectAsState().value
|
val bluetoothAudio = !CarSettings.pipeAudio.collectAsState().value
|
||||||
when (prompt) {
|
when (prompt) {
|
||||||
CarBluetooth.Prompt.NONE -> {}
|
CarBluetooth.Prompt.NONE -> {
|
||||||
|
val missing by BluetoothAudioCheck.missing.collectAsState()
|
||||||
|
val dismissed by BluetoothAudioCheck.carDismissed.collectAsState()
|
||||||
|
val warn by CarSettings.bluetoothWarning.collectAsState()
|
||||||
|
if (missing && bluetoothAudio && warn && !dismissed) PairCard(
|
||||||
|
title = "Bluetooth isn't connected",
|
||||||
|
body = "Music plays over Bluetooth, but your phone isn't connected to the car. " +
|
||||||
|
"Connect it from the car's Bluetooth menu or your phone's.",
|
||||||
|
secondary = "Don't show again" to BluetoothAudioCheck::neverAgain,
|
||||||
|
primary = "OK" to BluetoothAudioCheck::dismissOnCar,
|
||||||
|
)
|
||||||
|
}
|
||||||
CarBluetooth.Prompt.ASK -> PairCard(
|
CarBluetooth.Prompt.ASK -> PairCard(
|
||||||
title = "Pair with this car?",
|
title = "Pair with this car?",
|
||||||
body = (if (bluetoothAudio) "Calls and music play" else "Calls play") +
|
body = (if (bluetoothAudio) "Calls and music play" else "Calls play") +
|
||||||
|
|||||||
@@ -63,6 +63,9 @@ object CarBluetooth {
|
|||||||
@Volatile private var pairing = false
|
@Volatile private var pairing = false
|
||||||
private var receiver: BroadcastReceiver? = null
|
private var receiver: BroadcastReceiver? = null
|
||||||
|
|
||||||
|
/** The car's Bluetooth address, when it named one in its service list. */
|
||||||
|
val carAddress: String? get() = car?.service?.carAddress
|
||||||
|
|
||||||
/** The car's Bluetooth channel once the driver has asked to pair, -1 otherwise. */
|
/** The car's Bluetooth channel once the driver has asked to pair, -1 otherwise. */
|
||||||
val channelId: Int get() = car?.takeIf { pairing }?.service?.serviceId ?: -1
|
val channelId: Int get() = car?.takeIf { pairing }?.service?.serviceId ?: -1
|
||||||
|
|
||||||
|
|||||||
@@ -67,6 +67,7 @@ object CarServices {
|
|||||||
CarAssistant.init(appContext)
|
CarAssistant.init(appContext)
|
||||||
DirectionsVoice.init(appContext)
|
DirectionsVoice.init(appContext)
|
||||||
CarBluetooth.init(appContext)
|
CarBluetooth.init(appContext)
|
||||||
|
BluetoothAudioCheck.init(appContext)
|
||||||
CarKeys.init(appContext)
|
CarKeys.init(appContext)
|
||||||
VoiceReply.init(appContext)
|
VoiceReply.init(appContext)
|
||||||
// Loaded now, while nothing's waiting on it, so the first reply isn't slower than the rest.
|
// Loaded now, while nothing's waiting on it, so the first reply isn't slower than the rest.
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ object CarSettings {
|
|||||||
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_SPEAK_DIRECTIONS = "car_speak_directions"
|
private const val KEY_SPEAK_DIRECTIONS = "car_speak_directions"
|
||||||
|
private const val KEY_BLUETOOTH_WARNING = "car_bluetooth_warning"
|
||||||
private const val KEY_AUDIO_CHOICE_ASKED = "car_audio_choice_asked"
|
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"
|
||||||
@@ -60,6 +61,7 @@ private const val KEY_SPEAK_DIRECTIONS = "car_speak_directions"
|
|||||||
private val nightFlow = MutableStateFlow(NightMode.AUTO)
|
private val nightFlow = MutableStateFlow(NightMode.AUTO)
|
||||||
private val pipeAudioFlow = MutableStateFlow(true)
|
private val pipeAudioFlow = MutableStateFlow(true)
|
||||||
private val speakDirectionsFlow = MutableStateFlow(true)
|
private val speakDirectionsFlow = MutableStateFlow(true)
|
||||||
|
private val bluetoothWarningFlow = MutableStateFlow(true)
|
||||||
private val lastNavFlow = MutableStateFlow<String?>(null)
|
private val lastNavFlow = MutableStateFlow<String?>(null)
|
||||||
private val lastMediaFlow = MutableStateFlow<String?>(null)
|
private val lastMediaFlow = MutableStateFlow<String?>(null)
|
||||||
private val autoplayFlow = MutableStateFlow(false)
|
private val autoplayFlow = MutableStateFlow(false)
|
||||||
@@ -158,6 +160,9 @@ private const val KEY_SPEAK_DIRECTIONS = "car_speak_directions"
|
|||||||
/** Gearslip reads the map app's turns aloud on the car's directions channel; see DirectionsVoice. */
|
/** Gearslip reads the map app's turns aloud on the car's directions channel; see DirectionsVoice. */
|
||||||
val speakDirections: StateFlow<Boolean> = speakDirectionsFlow
|
val speakDirections: StateFlow<Boolean> = speakDirectionsFlow
|
||||||
|
|
||||||
|
/** With Bluetooth audio, warn when the phone isn't connected to the car; see BluetoothAudioCheck. */
|
||||||
|
val bluetoothWarning: StateFlow<Boolean> = bluetoothWarningFlow
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Whether the driver has already been offered Bluetooth after turning down the capture
|
* 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.
|
* dialog. Offered once only: someone who chose to keep the USB route shouldn't be nagged.
|
||||||
@@ -217,6 +222,7 @@ private const val KEY_SPEAK_DIRECTIONS = "car_speak_directions"
|
|||||||
// Until the driver sets it, on for USB audio and off for Bluetooth, where the map app's
|
// Until the driver sets it, on for USB audio and off for Bluetooth, where the map app's
|
||||||
// own voice already reaches the car.
|
// own voice already reaches the car.
|
||||||
speakDirectionsFlow.value = AppSettings.getString(app, KEY_SPEAK_DIRECTIONS, pipeAudioFlow.value.toString()) == "true"
|
speakDirectionsFlow.value = AppSettings.getString(app, KEY_SPEAK_DIRECTIONS, pipeAudioFlow.value.toString()) == "true"
|
||||||
|
bluetoothWarningFlow.value = AppSettings.getString(app, KEY_BLUETOOTH_WARNING, "true") == "true"
|
||||||
nightFlow.value = runCatching {
|
nightFlow.value = runCatching {
|
||||||
NightMode.valueOf(AppSettings.getString(app, KEY_NIGHT, NightMode.AUTO.name))
|
NightMode.valueOf(AppSettings.getString(app, KEY_NIGHT, NightMode.AUTO.name))
|
||||||
}.getOrDefault(NightMode.AUTO)
|
}.getOrDefault(NightMode.AUTO)
|
||||||
@@ -293,6 +299,11 @@ private const val KEY_SPEAK_DIRECTIONS = "car_speak_directions"
|
|||||||
AppSettings.putString(app, KEY_SPEAK_DIRECTIONS, on.toString())
|
AppSettings.putString(app, KEY_SPEAK_DIRECTIONS, on.toString())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun setBluetoothWarning(on: Boolean) {
|
||||||
|
bluetoothWarningFlow.value = on
|
||||||
|
AppSettings.putString(app, KEY_BLUETOOTH_WARNING, on.toString())
|
||||||
|
}
|
||||||
|
|
||||||
fun setExperimentalFeaturesEnabled(on: Boolean) {
|
fun setExperimentalFeaturesEnabled(on: Boolean) {
|
||||||
experimentalFeaturesFlow.value = on
|
experimentalFeaturesFlow.value = on
|
||||||
AppSettings.putString(app, KEY_EXPERIMENTAL_FEATURES, on.toString())
|
AppSettings.putString(app, KEY_EXPERIMENTAL_FEATURES, on.toString())
|
||||||
|
|||||||
@@ -54,6 +54,7 @@ fun CarSettingsScreen() {
|
|||||||
val whatsNew by CarSettings.whatsNewAfterUpdates.collectAsState()
|
val whatsNew by CarSettings.whatsNewAfterUpdates.collectAsState()
|
||||||
val pipeAudio by CarSettings.pipeAudio.collectAsState()
|
val pipeAudio by CarSettings.pipeAudio.collectAsState()
|
||||||
val speakDirections by CarSettings.speakDirections.collectAsState()
|
val speakDirections by CarSettings.speakDirections.collectAsState()
|
||||||
|
val bluetoothWarning by CarSettings.bluetoothWarning.collectAsState()
|
||||||
val autoplay by CarSettings.autoplay.collectAsState()
|
val autoplay by CarSettings.autoplay.collectAsState()
|
||||||
val playerTitle by CarSettings.playerTitle.collectAsState()
|
val playerTitle by CarSettings.playerTitle.collectAsState()
|
||||||
val theme by CarSettings.appTheme.collectAsState()
|
val theme by CarSettings.appTheme.collectAsState()
|
||||||
@@ -137,6 +138,13 @@ fun CarSettingsScreen() {
|
|||||||
style = MaterialTheme.typography.bodySmall,
|
style = MaterialTheme.typography.bodySmall,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
)
|
)
|
||||||
|
if (!pipeAudio) {
|
||||||
|
ChoiceRow(
|
||||||
|
"Warn when Bluetooth isn't connected",
|
||||||
|
listOf(false to "Off", true to "On"),
|
||||||
|
bluetoothWarning,
|
||||||
|
) { CarSettings.setBluetoothWarning(it) }
|
||||||
|
}
|
||||||
|
|
||||||
ChoiceRow(
|
ChoiceRow(
|
||||||
"Spoken directions",
|
"Spoken directions",
|
||||||
|
|||||||
@@ -16,6 +16,10 @@ import androidx.compose.foundation.layout.height
|
|||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.layout.size
|
import androidx.compose.foundation.layout.size
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
|
import app.seb3thehacker.gearslip.car.CarSettings
|
||||||
|
import app.seb3thehacker.gearslip.car.BluetoothAudioCheck
|
||||||
|
import android.provider.Settings
|
||||||
|
import android.content.Intent
|
||||||
import androidx.compose.foundation.rememberScrollState
|
import androidx.compose.foundation.rememberScrollState
|
||||||
import androidx.compose.foundation.verticalScroll
|
import androidx.compose.foundation.verticalScroll
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
@@ -104,6 +108,7 @@ fun HomeScreen(
|
|||||||
)
|
)
|
||||||
Spacer(Modifier.height(16.dp))
|
Spacer(Modifier.height(16.dp))
|
||||||
StatusCard(status)
|
StatusCard(status)
|
||||||
|
if (live) BluetoothMissingCard()
|
||||||
VoiceDownloadCard()
|
VoiceDownloadCard()
|
||||||
// The first drive that worked asks once to share it, right under the result.
|
// The first drive that worked asks once to share it, right under the result.
|
||||||
val car = workedCar
|
val car = workedCar
|
||||||
@@ -149,6 +154,36 @@ fun HomeScreen(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Bluetooth audio is chosen, but the phone isn't connected to the car. See [BluetoothAudioCheck]. */
|
||||||
|
@Composable
|
||||||
|
private fun BluetoothMissingCard() {
|
||||||
|
val missing by BluetoothAudioCheck.missing.collectAsStateWithLifecycle()
|
||||||
|
val usb by CarSettings.pipeAudio.collectAsStateWithLifecycle()
|
||||||
|
val warn by CarSettings.bluetoothWarning.collectAsStateWithLifecycle()
|
||||||
|
if (!missing || usb || !warn) return
|
||||||
|
val context = LocalContext.current
|
||||||
|
val scheme = MaterialTheme.colorScheme
|
||||||
|
Spacer(Modifier.height(16.dp))
|
||||||
|
Surface(color = scheme.errorContainer, contentColor = scheme.onErrorContainer, shape = MaterialTheme.shapes.extraLarge) {
|
||||||
|
Column(Modifier.fillMaxWidth().padding(20.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
|
Text("Bluetooth isn't connected", style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.SemiBold)
|
||||||
|
Text(
|
||||||
|
"Music plays over Bluetooth, but your phone isn't connected to the car. Connect it " +
|
||||||
|
"here or from the car's Bluetooth menu.",
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
)
|
||||||
|
Button(
|
||||||
|
onClick = {
|
||||||
|
runCatching {
|
||||||
|
context.startActivity(Intent(Settings.ACTION_BLUETOOTH_SETTINGS).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
modifier = Modifier.padding(top = 4.dp),
|
||||||
|
) { Text("Bluetooth settings") }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/** While the voice models download, how far along they are; nothing otherwise. */
|
/** While the voice models download, how far along they are; nothing otherwise. */
|
||||||
@Composable
|
@Composable
|
||||||
private fun VoiceDownloadCard() {
|
private fun VoiceDownloadCard() {
|
||||||
|
|||||||
Reference in new issue
Block a user