Claude refactoring
This commit is contained in:
@@ -14,8 +14,8 @@ android {
|
||||
applicationId = "com.kouros.navigation"
|
||||
minSdk = 33
|
||||
targetSdk = 36
|
||||
versionCode = 49
|
||||
versionName = "0.2.0.49"
|
||||
versionCode = 50
|
||||
versionName = "0.2.0.50"
|
||||
base.archivesName = "navi-$versionName"
|
||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||
}
|
||||
|
||||
@@ -4,7 +4,6 @@ import android.content.Context
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
|
||||
@@ -6,13 +6,13 @@ import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.CardDefaults
|
||||
import androidx.compose.material3.ElevatedCard
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
@@ -22,45 +22,83 @@ import com.kouros.data.R
|
||||
import com.kouros.navigation.data.StepData
|
||||
import com.kouros.navigation.utils.round
|
||||
|
||||
private const val MANEUVER_TYPE_EXIT_RIGHT = 45
|
||||
private const val MANEUVER_TYPE_EXIT_LEFT = 46
|
||||
private const val METERS_PER_KILOMETER = 1000.0
|
||||
private const val DISTANCE_THRESHOLD_METERS = 1000
|
||||
|
||||
private val CardTopPadding = 60.dp
|
||||
private val CardElevation = 6.dp
|
||||
private val IconSize = 48.dp
|
||||
private val ExitTextSize = 18.sp
|
||||
private val PrimaryTextSize = 24.sp
|
||||
private val SpacerWidth = 8.dp
|
||||
private val CardPadding = 16.dp
|
||||
private val ElementSpacing = 8.dp
|
||||
|
||||
@Composable
|
||||
fun NavigationInfo(step: StepData?, nextStep: StepData?) {
|
||||
if (step != null && step.instruction.isNotEmpty()) {
|
||||
fun NavigationInfo(
|
||||
step: StepData?,
|
||||
nextStep: StepData?
|
||||
) {
|
||||
step?.takeIf { it.instruction.isNotEmpty() }?.let { currentStep ->
|
||||
ElevatedCard(
|
||||
elevation = CardDefaults.cardElevation(
|
||||
defaultElevation = 6.dp
|
||||
|
||||
), modifier = Modifier
|
||||
.padding(top = 60.dp)
|
||||
elevation = CardDefaults.cardElevation(defaultElevation = CardElevation),
|
||||
modifier = Modifier
|
||||
.padding(top = CardTopPadding)
|
||||
.fillMaxWidth()
|
||||
) {
|
||||
Column {
|
||||
Column(
|
||||
modifier = Modifier.padding(CardPadding),
|
||||
horizontalAlignment = Alignment.Start
|
||||
) {
|
||||
Icon(
|
||||
painter = painterResource(step.icon),
|
||||
contentDescription = stringResource(id = R.string.accept_action_title),
|
||||
modifier = Modifier.size(48.dp, 48.dp),
|
||||
painter = painterResource(currentStep.icon),
|
||||
contentDescription = stringResource(id = R.string.navigation_icon_description),
|
||||
modifier = Modifier.size(IconSize),
|
||||
tint = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
if (step.currentManeuverType == 46
|
||||
|| step.currentManeuverType == 45
|
||||
) {
|
||||
Text(text = "Exit ${step.exitNumber}", fontSize = 18.sp)
|
||||
}
|
||||
Row {
|
||||
if (step.leftStepDistance < 1000) {
|
||||
Text(text = "${step.leftStepDistance.toInt()} m", fontSize = 24.sp, color = MaterialTheme.colorScheme.primary)
|
||||
} else {
|
||||
Text(
|
||||
text = "${(step.leftStepDistance / 1000).round(1)} km",
|
||||
fontSize = 24.sp,
|
||||
color = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
}
|
||||
Spacer(
|
||||
modifier = Modifier.padding(5.dp)
|
||||
|
||||
if (currentStep.isExitManeuver) {
|
||||
Text(
|
||||
text = stringResource(R.string.exit_number, currentStep.exitNumber),
|
||||
fontSize = ExitTextSize,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
modifier = Modifier.padding(top = ElementSpacing)
|
||||
)
|
||||
}
|
||||
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.padding(top = ElementSpacing)
|
||||
) {
|
||||
DistanceText(distance = currentStep.leftStepDistance)
|
||||
Spacer(modifier = Modifier.padding(horizontal = SpacerWidth))
|
||||
Text(
|
||||
text = currentStep.instruction,
|
||||
fontSize = PrimaryTextSize,
|
||||
color = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
Text(text = step.instruction, fontSize = 24.sp, color = MaterialTheme.colorScheme.primary)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun DistanceText(distance: Double) {
|
||||
val formattedDistance = when {
|
||||
distance < DISTANCE_THRESHOLD_METERS -> "${distance.toInt()} m"
|
||||
else -> "${(distance / METERS_PER_KILOMETER).round(1)} km"
|
||||
}
|
||||
|
||||
Text(
|
||||
text = formattedDistance,
|
||||
fontSize = PrimaryTextSize,
|
||||
color = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
}
|
||||
|
||||
private val StepData.isExitManeuver: Boolean
|
||||
get() = currentManeuverType == MANEUVER_TYPE_EXIT_RIGHT ||
|
||||
currentManeuverType == MANEUVER_TYPE_EXIT_LEFT
|
||||
@@ -0,0 +1,190 @@
|
||||
package com.kouros.navigation.car
|
||||
|
||||
import android.location.Location
|
||||
import androidx.car.app.CarContext
|
||||
import androidx.car.app.connection.CarConnection
|
||||
import androidx.car.app.hardware.CarHardwareManager
|
||||
import androidx.car.app.hardware.common.CarValue
|
||||
import androidx.car.app.hardware.common.OnCarDataAvailableListener
|
||||
import androidx.car.app.hardware.info.CarHardwareLocation
|
||||
import androidx.car.app.hardware.info.CarSensors
|
||||
import androidx.car.app.hardware.info.Compass
|
||||
import androidx.car.app.hardware.info.Speed
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.LifecycleOwner
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import androidx.lifecycle.repeatOnLifecycle
|
||||
import com.kouros.navigation.utils.getSettingsRepository
|
||||
import kotlinx.coroutines.flow.collectLatest
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
/**
|
||||
* Manages car hardware sensor listeners for navigation.
|
||||
* Handles location, compass, and speed sensors from the car hardware.
|
||||
*
|
||||
* @param carContext The car context for accessing hardware services
|
||||
* @param lifecycleOwner Owner of the lifecycle for coroutine management
|
||||
* @param onLocationUpdate Callback for location updates
|
||||
* @param onCompassUpdate Callback for compass/orientation updates
|
||||
* @param onSpeedUpdate Callback for speed updates
|
||||
*/
|
||||
class CarSensorManager(
|
||||
private val carContext: CarContext,
|
||||
private val lifecycleOwner: LifecycleOwner,
|
||||
private val onLocationUpdate: (Location) -> Unit,
|
||||
private val onCompassUpdate: (Float) -> Unit,
|
||||
private val onSpeedUpdate: (Float) -> Unit
|
||||
) {
|
||||
|
||||
private val carHardwareManager: CarHardwareManager =
|
||||
carContext.getCarService(CarHardwareManager::class.java)
|
||||
|
||||
private val settingsRepository = getSettingsRepository(carContext)
|
||||
|
||||
private var carConnection: Int = CarConnection.CONNECTION_TYPE_NOT_CONNECTED
|
||||
private var isLocationSensorActive = false
|
||||
private var isSpeedSensorActive = false
|
||||
|
||||
/**
|
||||
* Car hardware location listener.
|
||||
* Receives location data from the car's GPS system.
|
||||
*/
|
||||
private val carLocationListener: OnCarDataAvailableListener<CarHardwareLocation?> =
|
||||
OnCarDataAvailableListener { data ->
|
||||
if (data.location.status == CarValue.STATUS_SUCCESS) {
|
||||
val location = data.location.value
|
||||
if (location != null) {
|
||||
onLocationUpdate(location)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Car compass/orientation sensor listener.
|
||||
* Updates orientation for map rotation.
|
||||
*/
|
||||
private val carCompassListener: OnCarDataAvailableListener<Compass?> =
|
||||
OnCarDataAvailableListener { data ->
|
||||
if (data.orientations.status == CarValue.STATUS_SUCCESS) {
|
||||
val orientation = data.orientations.value
|
||||
if (!orientation.isNullOrEmpty()) {
|
||||
onCompassUpdate(orientation[0])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Car speed sensor listener.
|
||||
* Receives speed in meters per second from car hardware.
|
||||
*/
|
||||
private val carSpeedListener = OnCarDataAvailableListener<Speed> { data ->
|
||||
if (data.displaySpeedMetersPerSecond.status == CarValue.STATUS_SUCCESS) {
|
||||
val speed = data.displaySpeedMetersPerSecond.value
|
||||
if (speed != null) {
|
||||
onSpeedUpdate(speed)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
init {
|
||||
// Observe car location setting changes
|
||||
lifecycleOwner.lifecycleScope.launch {
|
||||
lifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) {
|
||||
settingsRepository.carLocationFlow.collectLatest { useCarLocation ->
|
||||
if (useCarLocation) {
|
||||
addLocationSensors()
|
||||
} else {
|
||||
removeLocationSensors()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates the car connection state and manages speed sensor accordingly.
|
||||
*
|
||||
* @param connectionState The current car connection type
|
||||
*/
|
||||
fun updateConnectionState(connectionState: Int) {
|
||||
carConnection = connectionState
|
||||
when (connectionState) {
|
||||
CarConnection.CONNECTION_TYPE_NATIVE,
|
||||
CarConnection.CONNECTION_TYPE_PROJECTION -> addSpeedSensor()
|
||||
CarConnection.CONNECTION_TYPE_NOT_CONNECTED -> removeSpeedSensor()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if car location sensors should be used based on settings.
|
||||
*
|
||||
* @return Flow of boolean indicating if car location should be used
|
||||
*/
|
||||
fun shouldUseCarLocation() = settingsRepository.carLocationFlow
|
||||
|
||||
/**
|
||||
* Adds location and compass sensors if not already active.
|
||||
*/
|
||||
private fun addLocationSensors() {
|
||||
if (isLocationSensorActive) return
|
||||
|
||||
val carSensors = carHardwareManager.carSensors
|
||||
carSensors.addCompassListener(
|
||||
CarSensors.UPDATE_RATE_NORMAL,
|
||||
carContext.mainExecutor,
|
||||
carCompassListener
|
||||
)
|
||||
carSensors.addCarHardwareLocationListener(
|
||||
CarSensors.UPDATE_RATE_FASTEST,
|
||||
carContext.mainExecutor,
|
||||
carLocationListener
|
||||
)
|
||||
isLocationSensorActive = true
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes location and compass sensors.
|
||||
*/
|
||||
private fun removeLocationSensors() {
|
||||
if (!isLocationSensorActive) return
|
||||
|
||||
val carSensors = carHardwareManager.carSensors
|
||||
carSensors.removeCarHardwareLocationListener(carLocationListener)
|
||||
carSensors.removeCompassListener(carCompassListener)
|
||||
isLocationSensorActive = false
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds speed sensor if not already active.
|
||||
*/
|
||||
private fun addSpeedSensor() {
|
||||
if (isSpeedSensorActive) return
|
||||
|
||||
if (carConnection == CarConnection.CONNECTION_TYPE_NATIVE ||
|
||||
carConnection == CarConnection.CONNECTION_TYPE_PROJECTION) {
|
||||
val carInfo = carHardwareManager.carInfo
|
||||
carInfo.addSpeedListener(carContext.mainExecutor, carSpeedListener)
|
||||
isSpeedSensorActive = true
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes speed sensor.
|
||||
*/
|
||||
private fun removeSpeedSensor() {
|
||||
if (!isSpeedSensorActive) return
|
||||
|
||||
val carInfo = carHardwareManager.carInfo
|
||||
carInfo.removeSpeedListener(carSpeedListener)
|
||||
isSpeedSensorActive = false
|
||||
}
|
||||
|
||||
/**
|
||||
* Cleans up all sensor listeners.
|
||||
* Should be called when the session is destroyed.
|
||||
*/
|
||||
fun cleanup() {
|
||||
removeLocationSensors()
|
||||
removeSpeedSensor()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
package com.kouros.navigation.car
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.content.Context
|
||||
import android.location.Location
|
||||
import android.location.LocationManager
|
||||
import androidx.car.app.CarContext
|
||||
import androidx.core.location.LocationListenerCompat
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.LifecycleOwner
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import androidx.lifecycle.repeatOnLifecycle
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.collectLatest
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
/**
|
||||
* Manages device GPS location updates for navigation.
|
||||
* Coordinates with car hardware sensors to avoid duplicate location sources.
|
||||
*
|
||||
* @param carContext The car context for accessing system services
|
||||
* @param lifecycleOwner Owner of the lifecycle for coroutine management
|
||||
* @param shouldUseCarLocationFlow Flow indicating whether car location hardware should be used
|
||||
* @param onLocationUpdate Callback invoked when location updates are received
|
||||
* @param onInitialLocation Callback invoked with the last known location when starting
|
||||
*/
|
||||
class DeviceLocationManager(
|
||||
private val carContext: CarContext,
|
||||
private val lifecycleOwner: LifecycleOwner,
|
||||
private val shouldUseCarLocationFlow: Flow<Boolean>,
|
||||
private val onLocationUpdate: (Location) -> Unit,
|
||||
private val onInitialLocation: (Location) -> Unit
|
||||
) {
|
||||
|
||||
private val locationManager: LocationManager =
|
||||
carContext.getSystemService(Context.LOCATION_SERVICE) as LocationManager
|
||||
|
||||
private var shouldUseDeviceLocation = true
|
||||
private var isListening = false
|
||||
|
||||
/**
|
||||
* Location listener that receives GPS updates from the device.
|
||||
* Only processes location if car location hardware is not being used.
|
||||
*/
|
||||
private val locationListener: LocationListenerCompat = LocationListenerCompat { location ->
|
||||
if (location != null && shouldUseDeviceLocation) {
|
||||
onLocationUpdate(location)
|
||||
}
|
||||
}
|
||||
|
||||
init {
|
||||
// Observe car location setting to toggle device location usage
|
||||
lifecycleOwner.lifecycleScope.launch {
|
||||
lifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) {
|
||||
shouldUseCarLocationFlow.collectLatest { useCarLocation ->
|
||||
shouldUseDeviceLocation = !useCarLocation
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Starts requesting location updates from device GPS.
|
||||
* Provides initial location via callback and then starts continuous updates.
|
||||
*
|
||||
* @param minTimeMs Minimum time interval between updates in milliseconds (default: 500ms)
|
||||
* @param minDistanceM Minimum distance between updates in meters (default: 5m)
|
||||
*/
|
||||
@SuppressLint("MissingPermission")
|
||||
fun startLocationUpdates(minTimeMs: Long = 500, minDistanceM: Float = 5f) {
|
||||
if (isListening) return
|
||||
|
||||
// Get and deliver last known location first
|
||||
val lastLocation = locationManager.getLastKnownLocation(LocationManager.GPS_PROVIDER)
|
||||
if (lastLocation != null) {
|
||||
onInitialLocation(lastLocation)
|
||||
onLocationUpdate(lastLocation)
|
||||
}
|
||||
|
||||
// Start continuous location updates
|
||||
locationManager.requestLocationUpdates(
|
||||
LocationManager.GPS_PROVIDER,
|
||||
minTimeMs,
|
||||
minDistanceM,
|
||||
locationListener
|
||||
)
|
||||
isListening = true
|
||||
}
|
||||
|
||||
/**
|
||||
* Stops receiving location updates from device GPS.
|
||||
* Should be called when the session is destroyed to prevent memory leaks.
|
||||
*/
|
||||
fun stopLocationUpdates() {
|
||||
if (!isListening) return
|
||||
|
||||
locationManager.removeUpdates(locationListener)
|
||||
isListening = false
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if location updates are currently active.
|
||||
*/
|
||||
fun isListeningForUpdates(): Boolean = isListening
|
||||
}
|
||||
@@ -1,27 +1,16 @@
|
||||
package com.kouros.navigation.car
|
||||
|
||||
import android.Manifest
|
||||
import android.annotation.SuppressLint
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.pm.PackageManager
|
||||
import android.content.res.Configuration
|
||||
import android.location.Location
|
||||
import android.location.LocationManager
|
||||
import android.util.Log
|
||||
import androidx.car.app.CarContext
|
||||
import androidx.car.app.Screen
|
||||
import androidx.car.app.ScreenManager
|
||||
import androidx.car.app.Session
|
||||
import androidx.car.app.connection.CarConnection
|
||||
import androidx.car.app.hardware.CarHardwareManager
|
||||
import androidx.car.app.hardware.common.CarValue
|
||||
import androidx.car.app.hardware.common.OnCarDataAvailableListener
|
||||
import androidx.car.app.hardware.info.CarHardwareLocation
|
||||
import androidx.car.app.hardware.info.CarSensors
|
||||
import androidx.car.app.hardware.info.Compass
|
||||
import androidx.car.app.hardware.info.Speed
|
||||
import androidx.core.location.LocationListenerCompat
|
||||
import androidx.core.net.toUri
|
||||
import androidx.lifecycle.DefaultLifecycleObserver
|
||||
import androidx.lifecycle.Lifecycle
|
||||
@@ -45,11 +34,8 @@ 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.flow.first
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import android.Manifest.permission
|
||||
|
||||
/**
|
||||
@@ -64,7 +50,7 @@ class NavigationSession : Session(), NavigationScreen.Listener {
|
||||
val useContacts = false
|
||||
|
||||
// Model for managing route state and navigation logic for Android Auto
|
||||
lateinit var routeModel: RouteCarModel;
|
||||
lateinit var routeModel: RouteCarModel
|
||||
|
||||
// Main navigation screen displayed to the user
|
||||
lateinit var navigationScreen: NavigationScreen
|
||||
@@ -72,41 +58,25 @@ class NavigationSession : Session(), NavigationScreen.Listener {
|
||||
// Handles map surface rendering on the car display
|
||||
lateinit var surfaceRenderer: SurfaceRenderer
|
||||
|
||||
/**
|
||||
* Location listener that receives GPS updates from the device.
|
||||
* Only processes location if car location hardware is not being used.
|
||||
*/
|
||||
var mLocationListener: LocationListenerCompat = LocationListenerCompat { location: Location? ->
|
||||
val repository = getSettingsRepository(carContext)
|
||||
val useCarLocation = runBlocking { repository.carLocationFlow.first() }
|
||||
if (!useCarLocation) {
|
||||
updateLocation(location!!)
|
||||
}
|
||||
}
|
||||
// Manages car hardware sensors (location, compass, speed)
|
||||
lateinit var carSensorManager: CarSensorManager
|
||||
|
||||
// Manages device GPS location updates
|
||||
lateinit var deviceLocationManager: DeviceLocationManager
|
||||
|
||||
/**
|
||||
* Lifecycle observer for managing session lifecycle events.
|
||||
* Cleans up resources when the session is destroyed.
|
||||
*/
|
||||
private val mLifeCycleObserver: LifecycleObserver = object : DefaultLifecycleObserver {
|
||||
override fun onCreate(owner: LifecycleOwner) {
|
||||
}
|
||||
|
||||
override fun onResume(owner: LifecycleOwner) {
|
||||
}
|
||||
|
||||
override fun onPause(owner: LifecycleOwner) {
|
||||
}
|
||||
|
||||
override fun onStop(owner: LifecycleOwner) {
|
||||
}
|
||||
|
||||
private val lifecycleObserver: LifecycleObserver = object : DefaultLifecycleObserver {
|
||||
override fun onDestroy(owner: LifecycleOwner) {
|
||||
removeSensors()
|
||||
Log.i(TAG, "In onDestroy()")
|
||||
val locationManager =
|
||||
carContext.getSystemService(Context.LOCATION_SERVICE) as LocationManager
|
||||
locationManager.removeUpdates(mLocationListener)
|
||||
if (::carSensorManager.isInitialized) {
|
||||
carSensorManager.cleanup()
|
||||
}
|
||||
if (::deviceLocationManager.isInitialized) {
|
||||
deviceLocationManager.stopLocationUpdates()
|
||||
}
|
||||
Log.i(TAG, "NavigationSession destroyed")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -116,48 +86,8 @@ class NavigationSession : Session(), NavigationScreen.Listener {
|
||||
// Store for ViewModels to survive configuration changes
|
||||
lateinit var viewModelStoreOwner : ViewModelStoreOwner
|
||||
|
||||
/**
|
||||
* Listener for car hardware location updates.
|
||||
* Receives location data from the car's GPS system.
|
||||
*/
|
||||
val carLocationListener: OnCarDataAvailableListener<CarHardwareLocation?> =
|
||||
OnCarDataAvailableListener { data ->
|
||||
if (data.location.status == CarValue.STATUS_SUCCESS) {
|
||||
val location = data.location.value
|
||||
if (location != null) {
|
||||
updateLocation(location)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Listener for car compass/orientation sensor.
|
||||
* Updates the surface renderer with car orientation for map rotation.
|
||||
*/
|
||||
val carCompassListener: OnCarDataAvailableListener<Compass?> =
|
||||
OnCarDataAvailableListener { data ->
|
||||
if (data.orientations.status == CarValue.STATUS_SUCCESS) {
|
||||
val orientation = data.orientations.value
|
||||
if (orientation != null) {
|
||||
surfaceRenderer.carOrientation = orientation[0]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Listener for car speed sensor updates.
|
||||
* Receives speed in meters per second from car hardware.
|
||||
*/
|
||||
val carSpeedListener = OnCarDataAvailableListener<Speed> { data ->
|
||||
if (data.displaySpeedMetersPerSecond.status == CarValue.STATUS_SUCCESS) {
|
||||
val speed = data.displaySpeedMetersPerSecond.value
|
||||
surfaceRenderer.updateCarSpeed(speed!!)
|
||||
}
|
||||
}
|
||||
|
||||
init {
|
||||
val lifecycle: Lifecycle = lifecycle
|
||||
lifecycle.addObserver(mLifeCycleObserver)
|
||||
lifecycle.addObserver(lifecycleObserver)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -177,7 +107,9 @@ class NavigationSession : Session(), NavigationScreen.Listener {
|
||||
* Initializes car hardware sensors if available.
|
||||
*/
|
||||
fun onPermissionGranted(permission : Boolean) {
|
||||
addSensors(routeModel.navState.carConnection)
|
||||
if (::carSensorManager.isInitialized) {
|
||||
carSensorManager.updateConnectionState(routeModel.navState.carConnection)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -187,14 +119,16 @@ class NavigationSession : Session(), NavigationScreen.Listener {
|
||||
*/
|
||||
fun onConnectionStateUpdated(connectionState: Int) {
|
||||
routeModel.navState = routeModel.navState.copy(carConnection = connectionState)
|
||||
if (::carSensorManager.isInitialized) {
|
||||
carSensorManager.updateConnectionState(connectionState)
|
||||
}
|
||||
when (connectionState) {
|
||||
CarConnection.CONNECTION_TYPE_NOT_CONNECTED -> "Not connected to a head unit"
|
||||
CarConnection.CONNECTION_TYPE_NOT_CONNECTED -> Unit
|
||||
CarConnection.CONNECTION_TYPE_NATIVE -> {
|
||||
ObjectBox.init(carContext)
|
||||
navigationScreen.checkPermission("android.car.permission.CAR_SPEED")
|
||||
} // Automotive OS
|
||||
}
|
||||
CarConnection.CONNECTION_TYPE_PROJECTION -> {
|
||||
"Connected to Android Auto"
|
||||
navigationScreen.checkPermission("com.google.android.gms.permission.CAR_SPEED")
|
||||
}
|
||||
}
|
||||
@@ -206,8 +140,17 @@ class NavigationSession : Session(), NavigationScreen.Listener {
|
||||
* and returns appropriate starting screen.
|
||||
*/
|
||||
override fun onCreateScreen(intent: Intent): Screen {
|
||||
setupViewModelStore()
|
||||
initializeViewModels()
|
||||
initializeManagers()
|
||||
initializeScreen()
|
||||
return checkPermissionsAndGetScreen()
|
||||
}
|
||||
|
||||
// Create ViewModelStoreOwner to manage ViewModels across lifecycle
|
||||
/**
|
||||
* Sets up ViewModelStoreOwner and manages its lifecycle.
|
||||
*/
|
||||
private fun setupViewModelStore() {
|
||||
viewModelStoreOwner = object : ViewModelStoreOwner {
|
||||
override val viewModelStore = ViewModelStore()
|
||||
}
|
||||
@@ -218,195 +161,180 @@ class NavigationSession : Session(), NavigationScreen.Listener {
|
||||
viewModelStoreOwner.viewModelStore.clear()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize ViewModel with saved routing engine preference
|
||||
/**
|
||||
* Initializes ViewModels and observes their state changes.
|
||||
*/
|
||||
private fun initializeViewModels() {
|
||||
navigationViewModel = getViewModel(carContext)
|
||||
|
||||
navigationViewModel.routingEngine.observe(this, ::onRoutingEngineStateUpdated)
|
||||
|
||||
navigationViewModel.permissionGranted.observe(this, ::onPermissionGranted)
|
||||
|
||||
routeModel = RouteCarModel()
|
||||
|
||||
// Monitor car connection state
|
||||
CarConnection(carContext).type.observe(this, ::onConnectionStateUpdated)
|
||||
}
|
||||
|
||||
// Initialize surface renderer for map display
|
||||
/**
|
||||
* Initializes managers for rendering, sensors, and location.
|
||||
*/
|
||||
private fun initializeManagers() {
|
||||
surfaceRenderer = SurfaceRenderer(carContext, lifecycle, routeModel, viewModelStoreOwner)
|
||||
|
||||
// Create main navigation screen
|
||||
navigationScreen =
|
||||
NavigationScreen(carContext, surfaceRenderer, routeModel, this, navigationViewModel)
|
||||
carSensorManager = CarSensorManager(
|
||||
carContext = carContext,
|
||||
lifecycleOwner = this,
|
||||
onLocationUpdate = ::updateLocation,
|
||||
onCompassUpdate = { orientation -> surfaceRenderer.carOrientation = orientation },
|
||||
onSpeedUpdate = { speed -> surfaceRenderer.updateCarSpeed(speed) }
|
||||
)
|
||||
|
||||
// Check for required permissions before starting
|
||||
if ( carContext.checkSelfPermission(Manifest.permission.ACCESS_FINE_LOCATION)
|
||||
== PackageManager.PERMISSION_GRANTED
|
||||
&& !useContacts
|
||||
|| (useContacts && carContext.checkSelfPermission(Manifest.permission.READ_CONTACTS)
|
||||
== PackageManager.PERMISSION_GRANTED)
|
||||
) {
|
||||
requestLocationUpdates()
|
||||
deviceLocationManager = DeviceLocationManager(
|
||||
carContext = carContext,
|
||||
lifecycleOwner = this,
|
||||
shouldUseCarLocationFlow = carSensorManager.shouldUseCarLocation(),
|
||||
onLocationUpdate = ::updateLocation,
|
||||
onInitialLocation = { location ->
|
||||
navigationViewModel.loadRecentPlace(location, surfaceRenderer.carOrientation, carContext)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates the main navigation screen.
|
||||
*/
|
||||
private fun initializeScreen() {
|
||||
navigationScreen = NavigationScreen(
|
||||
carContext,
|
||||
surfaceRenderer,
|
||||
routeModel,
|
||||
this,
|
||||
navigationViewModel
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks required permissions and returns appropriate screen.
|
||||
* Shows permission request screen if needed, otherwise starts location updates.
|
||||
*/
|
||||
private fun checkPermissionsAndGetScreen(): Screen {
|
||||
val hasLocationPermission = carContext.checkSelfPermission(permission.ACCESS_FINE_LOCATION) ==
|
||||
PackageManager.PERMISSION_GRANTED
|
||||
val hasContactsPermission = !useContacts ||
|
||||
carContext.checkSelfPermission(permission.READ_CONTACTS) == PackageManager.PERMISSION_GRANTED
|
||||
|
||||
return if (hasLocationPermission && hasContactsPermission) {
|
||||
deviceLocationManager.startLocationUpdates()
|
||||
navigationScreen
|
||||
} else {
|
||||
// If we do not have the location permission, show the request permission screen.
|
||||
val permissions: MutableList<String?> = ArrayList()
|
||||
permissions.add(permission.ACCESS_FINE_LOCATION)
|
||||
val screenManager =
|
||||
carContext.getCarService(ScreenManager::class.java)
|
||||
screenManager
|
||||
.push(navigationScreen)
|
||||
return RequestPermissionScreen(
|
||||
carContext,
|
||||
permissionCheckCallback = {
|
||||
screenManager.pop()
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
return navigationScreen
|
||||
}
|
||||
|
||||
/**
|
||||
* Registers listeners for car hardware sensors.
|
||||
* Only adds location and compass sensors if useCarLocation setting is enabled.
|
||||
* Speed sensor is added for both native and projection connections.
|
||||
*/
|
||||
fun addSensors(connectionState: Int) {
|
||||
val carInfo = carContext.getCarService(CarHardwareManager::class.java).carInfo
|
||||
val repository = getSettingsRepository(carContext)
|
||||
val useCarLocation = runBlocking { repository.carLocationFlow.first() }
|
||||
if (useCarLocation) {
|
||||
val carSensors = carContext.getCarService(CarHardwareManager::class.java).carSensors
|
||||
carSensors.addCompassListener(CarSensors.UPDATE_RATE_NORMAL,
|
||||
carContext.mainExecutor,
|
||||
carCompassListener)
|
||||
carSensors.addCarHardwareLocationListener(
|
||||
CarSensors.UPDATE_RATE_FASTEST,
|
||||
carContext.mainExecutor,
|
||||
carLocationListener
|
||||
)
|
||||
}
|
||||
if (connectionState == CarConnection.CONNECTION_TYPE_NATIVE
|
||||
|| connectionState == CarConnection.CONNECTION_TYPE_PROJECTION) {
|
||||
carInfo.addSpeedListener(carContext.mainExecutor, carSpeedListener)
|
||||
showPermissionScreen()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Unregisters all car hardware sensor listeners.
|
||||
* Called when session is being destroyed to prevent memory leaks.
|
||||
* Shows the permission request screen.
|
||||
*/
|
||||
fun removeSensors() {
|
||||
val carInfo = carContext.getCarService(CarHardwareManager::class.java).carInfo
|
||||
val repository = getSettingsRepository(carContext)
|
||||
val useCarLocation = runBlocking { repository.carLocationFlow.first() }
|
||||
if (useCarLocation) {
|
||||
val carSensors = carContext.getCarService(CarHardwareManager::class.java).carSensors
|
||||
carSensors.removeCarHardwareLocationListener(carLocationListener)
|
||||
}
|
||||
if (routeModel.navState.carConnection == CarConnection.CONNECTION_TYPE_NATIVE
|
||||
|| routeModel.navState.carConnection == CarConnection.CONNECTION_TYPE_PROJECTION) {
|
||||
carInfo.removeSpeedListener(carSpeedListener)
|
||||
}
|
||||
private fun showPermissionScreen(): Screen {
|
||||
val screenManager = carContext.getCarService(ScreenManager::class.java)
|
||||
screenManager.push(navigationScreen)
|
||||
return RequestPermissionScreen(
|
||||
carContext,
|
||||
permissionCheckCallback = { screenManager.pop() }
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Handles new intents, primarily for navigation deep links from other apps.
|
||||
* Supports ACTION_NAVIGATE for starting navigation to a specific location.
|
||||
*/
|
||||
override fun onNewIntent(intent: Intent) {
|
||||
val screenManager = carContext.getCarService(ScreenManager::class.java)
|
||||
if ((CarContext.ACTION_NAVIGATE == intent.action)) {
|
||||
val uri = ("http://" + intent.dataString).toUri()
|
||||
val location = Location(LocationManager.GPS_PROVIDER)
|
||||
screenManager.popToRoot()
|
||||
screenManager.pushForResult(
|
||||
SearchScreen(
|
||||
carContext,
|
||||
surfaceRenderer,
|
||||
navigationViewModel
|
||||
// TODO: Uri
|
||||
)
|
||||
) { obj: Any? ->
|
||||
if (obj != null) {
|
||||
|
||||
}
|
||||
}
|
||||
// Handle Android Auto ACTION_NAVIGATE intent
|
||||
if (CarContext.ACTION_NAVIGATE == intent.action) {
|
||||
handleNavigateIntent(screenManager)
|
||||
return
|
||||
}
|
||||
|
||||
val uri = intent.data
|
||||
if (uri != null && uriScheme == uri.scheme
|
||||
&& uriHost == uri.schemeSpecificPart
|
||||
) {
|
||||
val top = screenManager.getTop()
|
||||
when (uri.fragment) {
|
||||
"DEEP_LINK_ACTION" -> if (top !is NavigationScreen) {
|
||||
screenManager.popToRoot()
|
||||
}
|
||||
// Handle custom deep links
|
||||
handleDeepLink(intent, screenManager)
|
||||
}
|
||||
|
||||
else -> {}
|
||||
}
|
||||
/**
|
||||
* Handles ACTION_NAVIGATE intent by showing search screen.
|
||||
*/
|
||||
private fun handleNavigateIntent(screenManager: ScreenManager) {
|
||||
screenManager.popToRoot()
|
||||
screenManager.pushForResult(
|
||||
SearchScreen(carContext, surfaceRenderer, navigationViewModel)
|
||||
) { result ->
|
||||
// Handle search result if needed
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Called when car configuration changes (e.g., day/night mode).
|
||||
* Handles custom deep link URIs.
|
||||
*/
|
||||
override fun onCarConfigurationChanged(newConfiguration: Configuration) {
|
||||
println("Configuration: ${newConfiguration.isNightModeActive}")
|
||||
super.onCarConfigurationChanged(newConfiguration)
|
||||
}
|
||||
private fun handleDeepLink(intent: Intent, screenManager: ScreenManager) {
|
||||
val uri = intent.data ?: return
|
||||
if (uri.scheme != uriScheme || uri.schemeSpecificPart != uriHost) return
|
||||
|
||||
/**
|
||||
* Requests GPS location updates from the device.
|
||||
* Updates with last known location and starts listening for updates every 500ms or 5 meters.
|
||||
*/
|
||||
@SuppressLint("MissingPermission")
|
||||
fun requestLocationUpdates() {
|
||||
val locationManager =
|
||||
carContext.getSystemService(Context.LOCATION_SERVICE) as LocationManager
|
||||
val location = locationManager.getLastKnownLocation(LocationManager.GPS_PROVIDER)
|
||||
if (location != null) {
|
||||
navigationViewModel.loadRecentPlace(location = location, surfaceRenderer.carOrientation, carContext)
|
||||
updateLocation(location)
|
||||
locationManager.requestLocationUpdates(
|
||||
LocationManager.GPS_PROVIDER,
|
||||
/* minTimeMs= */ 500,
|
||||
/* minDistanceM= */ 5f,
|
||||
mLocationListener
|
||||
)
|
||||
when (uri.fragment) {
|
||||
"DEEP_LINK_ACTION" -> {
|
||||
if (screenManager.getTop() !is NavigationScreen) {
|
||||
screenManager.popToRoot()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates navigation state with new location.
|
||||
* Handles route snapping, deviation detection for rerouting, and map updates.
|
||||
* Snaps location to nearest point on route if within threshold.
|
||||
* Triggers reroute calculation if deviated too far from route.
|
||||
*/
|
||||
fun updateLocation(location: Location) {
|
||||
updateBearing(location)
|
||||
|
||||
if (routeModel.isNavigating()) {
|
||||
handleNavigationLocation(location)
|
||||
} else {
|
||||
surfaceRenderer.updateLocation(location)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates route bearing if location has bearing information.
|
||||
*/
|
||||
private fun updateBearing(location: Location) {
|
||||
if (location.hasBearing()) {
|
||||
routeModel.navState = routeModel.navState.copy(routeBearing = location.bearing)
|
||||
}
|
||||
if (routeModel.isNavigating()) {
|
||||
navigationScreen.updateTrip(location)
|
||||
if (!routeModel.navState.arrived) {
|
||||
val snapedLocation = snapLocation(location, routeModel.route.maneuverLocations())
|
||||
val distance = location.distanceTo(snapedLocation)
|
||||
// Check if user has deviated too far from route
|
||||
if (distance > MAXIMAL_ROUTE_DEVIATION) {
|
||||
navigationScreen.calculateNewRoute(routeModel.navState.destination)
|
||||
return
|
||||
}
|
||||
// Snap to route if close enough, otherwise use raw location
|
||||
if (distance < MAXIMAL_SNAP_CORRECTION) {
|
||||
surfaceRenderer.updateLocation(snapedLocation)
|
||||
} else {
|
||||
surfaceRenderer.updateLocation(location)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles location updates during active navigation.
|
||||
* Snaps location to route and checks for deviation requiring reroute.
|
||||
*/
|
||||
private fun handleNavigationLocation(location: Location) {
|
||||
navigationScreen.updateTrip(location)
|
||||
|
||||
if (routeModel.navState.arrived) return
|
||||
|
||||
val snappedLocation = snapLocation(location, routeModel.route.maneuverLocations())
|
||||
val distance = location.distanceTo(snappedLocation)
|
||||
|
||||
when {
|
||||
distance > MAXIMAL_ROUTE_DEVIATION -> {
|
||||
navigationScreen.calculateNewRoute(routeModel.navState.destination)
|
||||
}
|
||||
distance < MAXIMAL_SNAP_CORRECTION -> {
|
||||
surfaceRenderer.updateLocation(snappedLocation)
|
||||
}
|
||||
else -> {
|
||||
surfaceRenderer.updateLocation(location)
|
||||
}
|
||||
} else {
|
||||
surfaceRenderer.updateLocation(location)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -33,11 +33,9 @@ import com.kouros.navigation.car.navigation.RouteCarModel
|
||||
import com.kouros.navigation.data.Constants
|
||||
import com.kouros.navigation.data.Constants.DESTINATION_ARRIVAL_DISTANCE
|
||||
import com.kouros.navigation.data.Place
|
||||
import com.kouros.navigation.data.datastore.DataStoreManager
|
||||
import com.kouros.navigation.data.nominatim.SearchResult
|
||||
import com.kouros.navigation.data.overpass.Elements
|
||||
import com.kouros.navigation.model.NavigationViewModel
|
||||
import com.kouros.navigation.repository.SettingsRepository
|
||||
import com.kouros.navigation.utils.GeoUtils
|
||||
import com.kouros.navigation.utils.getSettingsViewModel
|
||||
import com.kouros.navigation.utils.location
|
||||
@@ -561,7 +559,6 @@ class NavigationScreen(
|
||||
}
|
||||
|
||||
fun checkPermission(permission: String) {
|
||||
println("Car connection permission: $permission")
|
||||
if (carContext.checkSelfPermission(permission) != PackageManager.PERMISSION_GRANTED) {
|
||||
val permissions: MutableList<String?> = ArrayList()
|
||||
permissions.add(permission)
|
||||
|
||||
@@ -84,7 +84,7 @@ class NavigationSettings(
|
||||
)
|
||||
.addItem(
|
||||
buildRowForScreenTemplate(
|
||||
PasswordSettings(carContext, navigationViewModel),
|
||||
PasswordSettings(carContext),
|
||||
R.string.tomtom_api_key
|
||||
)
|
||||
)
|
||||
|
||||
@@ -15,8 +15,7 @@ import com.kouros.navigation.utils.getSettingsViewModel
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
class PasswordSettings(
|
||||
private val carContext: CarContext,
|
||||
private var navigationViewModel: NavigationViewModel
|
||||
private val carContext: CarContext
|
||||
) : Screen(carContext) {
|
||||
|
||||
var errorMessage: String? = null
|
||||
@@ -51,7 +50,6 @@ class PasswordSettings(
|
||||
val pinSignInAction = Action.Builder()
|
||||
.setTitle(carContext.getString(R.string.stop_action_title))
|
||||
.setOnClickListener(ParkedOnlyOnClickListener.create {
|
||||
println("Sign")
|
||||
invalidate()
|
||||
})
|
||||
.build()
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
package com.kouros.navigation.data
|
||||
|
||||
import android.location.Location
|
||||
import com.kouros.navigation.model.IconMapper
|
||||
import com.kouros.navigation.utils.location
|
||||
|
||||
// Immutable Data Class
|
||||
data class NavigationState (
|
||||
val route: Route = Route.Builder().buildEmpty(),
|
||||
val iconMapper: IconMapper = IconMapper(),
|
||||
val navigating: Boolean = false,
|
||||
val arrived: Boolean = false,
|
||||
val travelMessage: String = "",
|
||||
val maneuverType: Int = 0,
|
||||
val lastLocation: Location = location(0.0, 0.0),
|
||||
val currentLocation: Location = location(0.0, 0.0),
|
||||
val routeBearing: Float = 0F,
|
||||
val currentRouteIndex: Int = 0,
|
||||
val destination: Place = Place(),
|
||||
val carConnection: Int = 0,
|
||||
)
|
||||
@@ -37,7 +37,7 @@ class TomTomRepository : NavigationRepository() {
|
||||
val tomtomApiKey = runBlocking { repository.tomTomApiKeyFlow.first() }
|
||||
val url =
|
||||
routeUrl + "${currentLocation.latitude},${currentLocation.longitude}:${location.latitude},${location.longitude}" +
|
||||
"/json?vehicleHeading=90§ionType=traffic&report=effectiveSettings&routeType=eco" +
|
||||
"/json?sectionType=traffic&report=effectiveSettings&routeType=eco" +
|
||||
"&traffic=true&avoid=unpavedRoads&travelMode=car" +
|
||||
"&vehicleMaxSpeed=120&vehicleCommercial=false" +
|
||||
"&instructionsType=text&language=en-GB§ionType=lanes" +
|
||||
|
||||
@@ -6,6 +6,7 @@ import androidx.car.app.connection.CarConnection.CONNECTION_TYPE_NATIVE
|
||||
import androidx.car.app.connection.CarConnection.CONNECTION_TYPE_PROJECTION
|
||||
import androidx.car.app.navigation.model.Maneuver
|
||||
import com.kouros.navigation.data.Constants.NEXT_STEP_THRESHOLD
|
||||
import com.kouros.navigation.data.NavigationState
|
||||
import com.kouros.navigation.data.Place
|
||||
import com.kouros.navigation.data.Route
|
||||
import com.kouros.navigation.data.StepData
|
||||
@@ -23,22 +24,6 @@ import kotlin.math.absoluteValue
|
||||
|
||||
open class RouteModel {
|
||||
|
||||
// Immutable Data Class
|
||||
data class NavigationState(
|
||||
val route: Route = Route.Builder().buildEmpty(),
|
||||
val iconMapper: IconMapper = IconMapper(),
|
||||
val navigating: Boolean = false,
|
||||
val arrived: Boolean = false,
|
||||
val travelMessage: String = "",
|
||||
val maneuverType: Int = 0,
|
||||
val lastLocation: Location = location(0.0, 0.0),
|
||||
val currentLocation: Location = location(0.0, 0.0),
|
||||
val routeBearing: Float = 0F,
|
||||
val currentRouteIndex: Int = 0,
|
||||
val destination: Place = Place(),
|
||||
val carConnection: Int = 0,
|
||||
)
|
||||
|
||||
var navState = NavigationState()
|
||||
|
||||
val route: Route
|
||||
|
||||
@@ -52,5 +52,7 @@
|
||||
<string name="options">Optionen</string>
|
||||
<string name="tomtom_api_key">TomTom ApiKey</string>
|
||||
<string name="use_car_settings">Verwende Auto Einstellungen</string>
|
||||
<string name="exit_number">Ausfahrt nummer</string>
|
||||
<string name="navigation_icon_description">Navigations Icon</string>
|
||||
|
||||
</resources>
|
||||
|
||||
@@ -38,4 +38,6 @@
|
||||
<string name="options">Options</string>
|
||||
<string name="tomtom_api_key">TomTom ApiKey</string>
|
||||
<string name="use_car_settings">Use car settings</string>
|
||||
<string name="exit_number">Exit number</string>
|
||||
<string name="navigation_icon_description">Navigation icon</string>
|
||||
</resources>
|
||||
Reference in New Issue
Block a user