Audio guidance

This commit is contained in:
Dimitris
2026-03-04 15:50:21 +01:00
parent 11e9dbb21e
commit e582c1e0dc
37 changed files with 614 additions and 162 deletions

View File

@@ -179,9 +179,12 @@ class RouteModelTest {
val curLocation = location(waypoint[0], waypoint[1])
if (routeModel.isNavigating()) {
if (index in 0..routeModel.curRoute.waypoints.size) {
routeModel.updateLocation(curLocation, NavigationViewModel(TomTomRepository()))
//runBlocking { delay(1000) }
val start = System.currentTimeMillis()
routeModel.updateLocation(curLocation, NavigationViewModel(TomTomRepository()))
val stepData = routeModel.currentStep()
val nextData = routeModel.nextStep()
println("${stepData.instruction} ${System.currentTimeMillis() - start}")
// val nextData = routeModel.nextStep()
}
}
}

View File

@@ -4,6 +4,7 @@ import android.Manifest.permission
import android.content.Intent
import android.content.pm.PackageManager
import android.location.Location
import android.speech.tts.TextToSpeech
import android.util.Log
import androidx.car.app.CarContext
import androidx.car.app.Screen
@@ -16,9 +17,12 @@ import androidx.car.app.navigation.model.Trip
import androidx.lifecycle.DefaultLifecycleObserver
import androidx.lifecycle.LifecycleObserver
import androidx.lifecycle.LifecycleOwner
import androidx.lifecycle.Observer
import androidx.lifecycle.ViewModelStore
import androidx.lifecycle.ViewModelStoreOwner
import androidx.lifecycle.asLiveData
import androidx.lifecycle.lifecycleScope
import com.kouros.navigation.car.navigation.NavigationUtils
import com.kouros.navigation.car.navigation.RouteCarModel
import com.kouros.navigation.car.screen.NavigationScreen
import com.kouros.navigation.car.screen.RequestPermissionScreen
@@ -35,8 +39,11 @@ import com.kouros.navigation.data.valhalla.ValhallaRepository
import com.kouros.navigation.model.NavigationViewModel
import com.kouros.navigation.utils.GeoUtils.snapLocation
import com.kouros.navigation.utils.NavigationUtils.getViewModel
import com.kouros.navigation.utils.getSettingsRepository
import kotlinx.coroutines.awaitCancellation
import kotlinx.coroutines.launch
import org.maplibre.compose.expressions.dsl.step
import java.util.Locale
/**
@@ -67,6 +74,8 @@ class NavigationSession : Session(), NavigationScreen.Listener {
lateinit var navigationManager: NavigationManager
lateinit var textToSpeechManager: TextToSpeechManager
/**
* Lifecycle observer for managing session lifecycle events.
* Cleans up resources when the session is destroyed.
@@ -82,6 +91,9 @@ class NavigationSession : Session(), NavigationScreen.Listener {
if (::deviceLocationManager.isInitialized) {
deviceLocationManager.stopLocationUpdates()
}
if (::textToSpeechManager.isInitialized) {
textToSpeechManager.cleanup()
}
Log.i(TAG, "NavigationSession destroyed")
}
}
@@ -92,6 +104,10 @@ class NavigationSession : Session(), NavigationScreen.Listener {
// Store for ViewModels to survive configuration changes
lateinit var viewModelStoreOwner: ViewModelStoreOwner
var lastStepIndex = -1
var guidanceAudio = 0
init {
lifecycle.addObserver(lifecycleObserver)
}
@@ -130,6 +146,7 @@ class NavigationSession : Session(), NavigationScreen.Listener {
CarConnection.CONNECTION_TYPE_NATIVE -> {
navigationScreen.checkPermission(AUTOMOTIVE_CAR_SPEED_PERMISSION)
}
CarConnection.CONNECTION_TYPE_PROJECTION -> {
navigationScreen.checkPermission(GMS_CAR_SPEED_PERMISSION)
}
@@ -221,6 +238,12 @@ class NavigationSession : Session(), NavigationScreen.Listener {
)
}
)
textToSpeechManager = TextToSpeechManager(carContext)
val repository = getSettingsRepository(carContext)
repository.guidanceAudioFlow.asLiveData().observe(this, Observer {
guidanceAudio = it
})
}
/**
@@ -319,7 +342,6 @@ class NavigationSession : Session(), NavigationScreen.Listener {
*/
fun updateLocation(location: Location) {
updateBearing(location)
if (routeModel.isNavigating()) {
handleNavigationLocation(location)
} else {
@@ -341,6 +363,9 @@ class NavigationSession : Session(), NavigationScreen.Listener {
* Snaps location to route and checks for deviation requiring reroute.
*/
private fun handleNavigationLocation(location: Location) {
if (guidanceAudio == 1) {
handleGuidanceAudio()
}
navigationScreen.updateTrip(location)
if (routeModel.navState.arrived) return
val snappedLocation = snapLocation(location, routeModel.route.maneuverLocations())
@@ -349,11 +374,9 @@ class NavigationSession : Session(), NavigationScreen.Listener {
distance > MAXIMAL_ROUTE_DEVIATION -> {
navigationScreen.calculateNewRoute(routeModel.navState.destination)
}
distance < MAXIMAL_SNAP_CORRECTION -> {
surfaceRenderer.updateLocation(snappedLocation)
}
else -> {
surfaceRenderer.updateLocation(location)
}
@@ -382,6 +405,21 @@ class NavigationSession : Session(), NavigationScreen.Listener {
navigationManager.updateTrip(trip)
}
/**
* Handle guidance audio
* Called when user wants to hear the step by step instructions
*/
private fun handleGuidanceAudio() {
val currentStep = routeModel.route.currentStep()
val stepData = routeModel.currentStep()
if (currentStep.index > lastStepIndex && stepData.leftStepDistance < 50) {
if (textToSpeechManager.initialized) {
textToSpeechManager.speak(stepData.message)
}
lastStepIndex = currentStep.index
}
}
companion object {
// URI host for deep linking
var uriHost: String = "navigation"

View File

@@ -0,0 +1,61 @@
package com.kouros.navigation.car
import android.media.AudioAttributes
import android.media.AudioFocusRequest
import android.media.AudioManager
import android.speech.tts.TextToSpeech
import android.util.Log
import androidx.car.app.CarContext
import java.util.Locale
class TextToSpeechManager(private val carContext: CarContext) {
var textToSpeech: TextToSpeech
var initialized = false
init {
textToSpeech = TextToSpeech(carContext) { status ->
if (status == TextToSpeech.SUCCESS) {
Log.d("TTS", "Initialization Success")
val audioAttributes =
AudioAttributes.Builder()
.setContentType(AudioAttributes.CONTENT_TYPE_SPEECH)
.setUsage(AudioAttributes.USAGE_ASSISTANCE_NAVIGATION_GUIDANCE)
.build()
val request =
AudioFocusRequest.Builder(AudioManager.AUDIOFOCUS_GAIN_TRANSIENT_MAY_DUCK)
.setAudioAttributes(audioAttributes)
.build()
val audioManager: AudioManager =
carContext.getSystemService<AudioManager?>(AudioManager::class.java)!!
// Requesting the audio focus.
if (audioManager.requestAudioFocus(request) == AudioManager.AUDIOFOCUS_REQUEST_GRANTED) {
textToSpeech.setAudioAttributes(audioAttributes)
}
initialized = true
} else {
Log.d("TTS", "Initialization Failed")
}
}
}
fun speak(text: String) {
try {
val cs: CharSequence = text
textToSpeech.speak(cs, TextToSpeech.QUEUE_FLUSH, null, "1233455")
} catch (e: Throwable) {
Log.d("TTS", "speak error", e)
}
}
/**
* Cleans up manager.
* Should be called when the session is destroyed.
*/
fun cleanup() {
if (initialized) {
textToSpeech.shutdown()
}
}
}

View File

@@ -1,19 +1,27 @@
package com.kouros.navigation.car.navigation
import android.media.AudioAttributes
import android.media.AudioFocusRequest
import android.media.AudioManager
import android.media.MediaPlayer
import android.media.MediaPlayer.OnCompletionListener
import android.speech.tts.TextToSpeech
import android.util.Log
import androidx.annotation.DrawableRes
import androidx.annotation.RawRes
import androidx.annotation.StringRes
import androidx.car.app.CarContext
import androidx.car.app.CarToast
import androidx.car.app.model.Action
import androidx.car.app.model.Alert
import androidx.car.app.model.AlertCallback
import androidx.car.app.model.CarIcon
import androidx.car.app.model.CarText
import androidx.car.app.model.OnClickListener
import androidx.car.app.model.Row
import androidx.core.graphics.drawable.IconCompat
import com.kouros.android.cars.carappservice.R
import com.kouros.navigation.data.Constants.TAG
import java.io.IOException
import java.util.Locale
class NavigationMessage (private var carContext: CarContext) {
class NavigationUtils(private var carContext: CarContext) {
private fun createToastAction(
@StringRes titleRes: Int, @StringRes toastStringRes: Int,
@@ -35,6 +43,7 @@ class NavigationMessage (private var carContext: CarContext) {
)
.show()
}
fun createCarText(@StringRes stringRes: Int): CarText {
return CarText.create(carContext.getString(stringRes))
}
@@ -42,4 +51,19 @@ class NavigationMessage (private var carContext: CarContext) {
fun createCarIcon(@DrawableRes iconRes: Int): CarIcon {
return CarIcon.Builder(IconCompat.createWithResource(carContext, iconRes)).build()
}
fun buildRowForTemplate(title: Int, resource: Int): Row {
return Row.Builder()
.setTitle(carContext.getString(title))
.setImage(
CarIcon.Builder(
IconCompat.createWithResource(
carContext,
resource
)
)
.build()
)
.build()
}
}

View File

@@ -1,6 +1,8 @@
package com.kouros.navigation.car.navigation
import android.speech.tts.TextToSpeech
import android.text.SpannableString
import android.util.Log
import androidx.annotation.DrawableRes
import androidx.annotation.StringRes
import androidx.car.app.AppManager
@@ -26,15 +28,18 @@ import com.kouros.data.R
import com.kouros.navigation.data.StepData
import com.kouros.navigation.model.RouteModel
import com.kouros.navigation.utils.formattedDistance
import java.util.Locale
import java.util.TimeZone
import java.util.concurrent.TimeUnit
/** A class that provides models for the routing demos. */
class RouteCarModel() : RouteModel() {
class RouteCarModel : RouteModel() {
/** Returns the current [Step] with information such as the cue text and images. */
fun currentStep(carContext: CarContext): Step {
val stepData = currentStep()
val currentStepCueWithImage: SpannableString =
createString(stepData.instruction)

View File

@@ -17,7 +17,7 @@ import androidx.car.app.navigation.model.MapWithContentTemplate
import androidx.lifecycle.Observer
import com.kouros.data.R
import com.kouros.navigation.car.SurfaceRenderer
import com.kouros.navigation.car.navigation.NavigationMessage
import com.kouros.navigation.car.navigation.NavigationUtils
import com.kouros.navigation.data.Constants
import com.kouros.navigation.data.Place
import com.kouros.navigation.data.overpass.Elements
@@ -124,7 +124,7 @@ class CategoryScreen(
} else {
row.addText(carText("${it.tags.openingHours}"))
}
val navigationMessage = NavigationMessage(carContext)
val navigationUtils = NavigationUtils(carContext)
row.addAction(
Action.Builder()
.setOnClickListener {
@@ -144,7 +144,7 @@ class CategoryScreen(
)
finish()
}
.setIcon(navigationMessage.createCarIcon(R.drawable.navigation_48px))
.setIcon(navigationUtils.createCarIcon(R.drawable.navigation_48px))
.build())
return row.build()
}
@@ -180,10 +180,10 @@ class CategoryScreen(
@DrawableRes iconRes: Int,
scale: Int
): Action {
val navigationMessage = NavigationMessage(carContext)
val navigationUtils = NavigationUtils(carContext)
return Action.Builder()
.setOnClickListener { surfaceRenderer.handleScale(scale) }
.setIcon(navigationMessage.createCarIcon(iconRes))
.setIcon(navigationUtils.createCarIcon(iconRes))
.build()
}
}

View File

@@ -34,6 +34,7 @@ import com.kouros.navigation.car.ViewStyle
import com.kouros.navigation.car.navigation.RouteCarModel
import com.kouros.navigation.car.screen.observers.NavigationObserverCallback
import com.kouros.navigation.car.screen.observers.NavigationObserverManager
import com.kouros.navigation.car.screen.settings.SettingsScreen
import com.kouros.navigation.data.Constants.DESTINATION_ARRIVAL_DISTANCE
import com.kouros.navigation.data.Place
import com.kouros.navigation.data.overpass.Elements

View File

@@ -16,7 +16,6 @@ import androidx.car.app.model.Header
import androidx.car.app.model.ItemList
import androidx.car.app.model.ListTemplate
import androidx.car.app.model.MessageTemplate
import androidx.car.app.model.OnClickListener
import androidx.car.app.model.Row
import androidx.car.app.model.Template
import androidx.car.app.navigation.model.MapController
@@ -25,12 +24,11 @@ import androidx.core.graphics.drawable.IconCompat
import androidx.lifecycle.Observer
import com.kouros.data.R
import com.kouros.navigation.car.SurfaceRenderer
import com.kouros.navigation.car.navigation.NavigationMessage
import com.kouros.navigation.car.navigation.NavigationUtils
import com.kouros.navigation.car.navigation.RouteCarModel
import com.kouros.navigation.data.Place
import com.kouros.navigation.model.NavigationViewModel
import com.kouros.navigation.utils.getSettingsRepository
import com.kouros.navigation.utils.getSettingsViewModel
import com.kouros.navigation.utils.location
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.runBlocking
@@ -49,7 +47,7 @@ class RoutePreviewScreen(
val routeModel = RouteCarModel()
val navigationMessage = NavigationMessage(carContext)
val navigationUtils = NavigationUtils(carContext)
val observer = Observer<String> { route ->
if (route.isNotEmpty()) {
val repository = getSettingsRepository(carContext)
@@ -228,8 +226,6 @@ class RoutePreviewScreen(
private fun onRouteSelected(index: Int) {
routeModel.navState = routeModel.navState.copy(currentRouteIndex = index)
surfaceRenderer.setPreviewRouteData(routeModel)
//setResult(destination)
//finish()
}
fun getMapActionStrip(): ActionStrip {
@@ -249,7 +245,7 @@ class RoutePreviewScreen(
): Action {
return Action.Builder()
.setOnClickListener { surfaceRenderer.handleScale(-1) }
.setIcon(navigationMessage.createCarIcon(iconRes))
.setIcon(navigationUtils.createCarIcon(iconRes))
.build()
}
}

View File

@@ -0,0 +1,87 @@
package com.kouros.navigation.car.screen.settings
import androidx.car.app.CarContext
import androidx.car.app.Screen
import androidx.car.app.model.Action
import androidx.car.app.model.CarIcon
import androidx.car.app.model.Header
import androidx.car.app.model.ItemList
import androidx.car.app.model.ListTemplate
import androidx.car.app.model.Row
import androidx.car.app.model.SectionedItemList
import androidx.car.app.model.Template
import androidx.core.graphics.drawable.IconCompat
import androidx.lifecycle.lifecycleScope
import com.kouros.data.R
import com.kouros.navigation.car.navigation.NavigationUtils
import com.kouros.navigation.utils.getSettingsViewModel
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
class AudioSettings(
private val carContext: CarContext,
) :
Screen(carContext) {
private var guidanceAudioSettings = 0
val settingsViewModel = getSettingsViewModel(carContext)
init {
lifecycleScope.launch {
settingsViewModel.guidanceAudio.first()
}
}
override fun onGetTemplate(): Template {
guidanceAudioSettings = settingsViewModel.guidanceAudio.value
val templateBuilder = ListTemplate.Builder()
val radioList =
ItemList.Builder()
.addItem(
NavigationUtils(carContext).buildRowForTemplate(
R.string.muted,
R.drawable.volume_off_24px
)
)
.addItem(
NavigationUtils(carContext).buildRowForTemplate(
R.string.unmuted,
R.drawable.volume_up_24px,
)
)
.addItem(
NavigationUtils(carContext).buildRowForTemplate(
R.string.alerts_only,
R.drawable.warning_24px,
)
)
.setOnSelectedListener { index: Int ->
this.onSelected(index)
}
.setSelectedIndex(guidanceAudioSettings)
.build()
return templateBuilder
.addSectionedList(
SectionedItemList.create(
radioList,
carContext.getString(R.string.audio_settings)
)
)
.setHeader(
Header.Builder()
.setTitle(carContext.getString(R.string.audio_settings))
.setStartHeaderAction(Action.BACK)
.build()
)
.build()
}
private fun onSelected(index: Int) {
settingsViewModel.onGuidanceAudioChanged(index)
}
}

View File

@@ -1,7 +1,6 @@
package com.kouros.navigation.car.screen
package com.kouros.navigation.car.screen.settings
import androidx.car.app.CarContext
import androidx.car.app.CarToast
import androidx.car.app.Screen
import androidx.car.app.model.Action
import androidx.car.app.model.Header
@@ -12,6 +11,7 @@ import androidx.car.app.model.SectionedItemList
import androidx.car.app.model.Template
import androidx.lifecycle.lifecycleScope
import com.kouros.data.R
import com.kouros.navigation.car.navigation.NavigationUtils
import com.kouros.navigation.utils.getSettingsViewModel
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
@@ -34,18 +34,21 @@ class DarkModeSettings(private val carContext: CarContext) : Screen(carContext)
val radioList =
ItemList.Builder()
.addItem(
buildRowForTemplate(
NavigationUtils(carContext).buildRowForTemplate(
R.string.off_action_title,
R.drawable.light_mode_24px
)
)
.addItem(
buildRowForTemplate(
NavigationUtils(carContext).buildRowForTemplate(
R.string.on_action_title,
R.drawable.dark_mode_24px
)
)
.addItem(
buildRowForTemplate(
NavigationUtils(carContext).buildRowForTemplate(
R.string.use_car_settings,
R.drawable.directions_car_24px
)
)
.setOnSelectedListener { index: Int ->
@@ -74,11 +77,4 @@ class DarkModeSettings(private val carContext: CarContext) : Screen(carContext)
private fun onSelected(index: Int) {
settingsViewModel.onDarkModeChanged(index)
}
private fun buildRowForTemplate(title: Int): Row {
return Row.Builder()
.setTitle(carContext.getString(title))
.build()
}
}

View File

@@ -1,4 +1,4 @@
package com.kouros.navigation.car.screen
package com.kouros.navigation.car.screen.settings
import androidx.car.app.CarContext
import androidx.car.app.Screen
@@ -11,6 +11,7 @@ import androidx.car.app.model.Template
import androidx.car.app.model.Toggle
import androidx.lifecycle.lifecycleScope
import com.kouros.data.R
import com.kouros.navigation.car.screen.settings.DistanceSettings
import com.kouros.navigation.utils.getSettingsViewModel
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch

View File

@@ -1,7 +1,6 @@
package com.kouros.navigation.car.screen
package com.kouros.navigation.car.screen.settings
import androidx.car.app.CarContext
import androidx.car.app.CarToast
import androidx.car.app.Screen
import androidx.car.app.model.Action
import androidx.car.app.model.Header

View File

@@ -1,4 +1,4 @@
package com.kouros.navigation.car.screen
package com.kouros.navigation.car.screen.settings
import androidx.car.app.CarContext
import androidx.car.app.Screen
@@ -11,12 +11,13 @@ import androidx.car.app.model.Template
import androidx.car.app.model.Toggle
import androidx.lifecycle.lifecycleScope
import com.kouros.data.R
import com.kouros.navigation.car.screen.settings.PasswordSettings
import com.kouros.navigation.car.screen.settings.RoutingSettings
import com.kouros.navigation.model.NavigationViewModel
import com.kouros.navigation.utils.getSettingsViewModel
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
class NavigationSettings(
private val carContext: CarContext,
private var navigationViewModel: NavigationViewModel

View File

@@ -1,4 +1,4 @@
package com.kouros.navigation.car.screen
package com.kouros.navigation.car.screen.settings
import androidx.car.app.CarContext
import androidx.car.app.Screen
@@ -10,7 +10,6 @@ import androidx.car.app.model.signin.InputSignInMethod
import androidx.car.app.model.signin.SignInTemplate
import androidx.lifecycle.lifecycleScope
import com.kouros.data.R
import com.kouros.navigation.model.NavigationViewModel
import com.kouros.navigation.utils.getSettingsViewModel
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch

View File

@@ -1,7 +1,6 @@
package com.kouros.navigation.car.screen
package com.kouros.navigation.car.screen.settings
import androidx.car.app.CarContext
import androidx.car.app.CarToast
import androidx.car.app.Screen
import androidx.car.app.model.Action
import androidx.car.app.model.Header
@@ -56,7 +55,8 @@ class RoutingSettings(private val carContext: CarContext, private var navigation
.build()
return templateBuilder
.addSectionedList(SectionedItemList.create(
.addSectionedList(
SectionedItemList.create(
radioList,
carContext.getString(R.string.routing_engine)
))

View File

@@ -1,19 +1,4 @@
/*
* Copyright 2022 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kouros.navigation.car.screen
package com.kouros.navigation.car.screen.settings
import androidx.car.app.CarContext
import androidx.car.app.Screen
@@ -34,6 +19,12 @@ class SettingsScreen(
override fun onGetTemplate(): Template {
val listBuilder = ItemList.Builder()
listBuilder.addItem(
buildRowForTemplate(
AudioSettings(carContext),
R.string.audio_settings
)
)
listBuilder.addItem(
buildRowForTemplate(
DisplaySettings(carContext),
@@ -67,4 +58,4 @@ class SettingsScreen(
.setBrowsable(true)
.build()
}
}
}