From eb6d3e4ef7047b3247a928001b1bf7a68cfdd40a Mon Sep 17 00:00:00 2001 From: Dimitris Date: Wed, 25 Feb 2026 11:09:31 +0100 Subject: [PATCH] Claude refactoring --- app/build.gradle.kts | 4 +- .../java/com/kouros/navigation/ui/MapView.kt | 1 - .../kouros/navigation/ui/NavigationScreen.kt | 100 +++-- .../kouros/navigation/car/CarSensorManager.kt | 190 +++++++++ .../navigation/car/DeviceLocationManager.kt | 105 +++++ .../navigation/car/NavigationSession.kt | 388 +++++++----------- .../navigation/car/screen/NavigationScreen.kt | 3 - .../car/screen/NavigationSettings.kt | 2 +- .../navigation/car/screen/PasswordSettings.kt | 4 +- .../kouros/navigation/data/NavigationState.kt | 21 + .../data/tomtom/TomTomRepository.kt | 2 +- .../com/kouros/navigation/model/RouteModel.kt | 17 +- .../data/src/main/res/values-de/strings.xml | 2 + common/data/src/main/res/values/strings.xml | 2 + 14 files changed, 553 insertions(+), 288 deletions(-) create mode 100644 common/car/src/main/java/com/kouros/navigation/car/CarSensorManager.kt create mode 100644 common/car/src/main/java/com/kouros/navigation/car/DeviceLocationManager.kt create mode 100644 common/data/src/main/java/com/kouros/navigation/data/NavigationState.kt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index d016153..43f52e6 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -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" } diff --git a/app/src/main/java/com/kouros/navigation/ui/MapView.kt b/app/src/main/java/com/kouros/navigation/ui/MapView.kt index 34b663e..6c42ee3 100644 --- a/app/src/main/java/com/kouros/navigation/ui/MapView.kt +++ b/app/src/main/java/com/kouros/navigation/ui/MapView.kt @@ -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 diff --git a/app/src/main/java/com/kouros/navigation/ui/NavigationScreen.kt b/app/src/main/java/com/kouros/navigation/ui/NavigationScreen.kt index abeb3c7..3a0ea84 100644 --- a/app/src/main/java/com/kouros/navigation/ui/NavigationScreen.kt +++ b/app/src/main/java/com/kouros/navigation/ui/NavigationScreen.kt @@ -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) } } } } -} \ No newline at end of file +} + +@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 \ No newline at end of file diff --git a/common/car/src/main/java/com/kouros/navigation/car/CarSensorManager.kt b/common/car/src/main/java/com/kouros/navigation/car/CarSensorManager.kt new file mode 100644 index 0000000..207fc8a --- /dev/null +++ b/common/car/src/main/java/com/kouros/navigation/car/CarSensorManager.kt @@ -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 = + 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 = + 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 { 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() + } +} diff --git a/common/car/src/main/java/com/kouros/navigation/car/DeviceLocationManager.kt b/common/car/src/main/java/com/kouros/navigation/car/DeviceLocationManager.kt new file mode 100644 index 0000000..d08469e --- /dev/null +++ b/common/car/src/main/java/com/kouros/navigation/car/DeviceLocationManager.kt @@ -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, + 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 +} diff --git a/common/car/src/main/java/com/kouros/navigation/car/NavigationSession.kt b/common/car/src/main/java/com/kouros/navigation/car/NavigationSession.kt index 2ac4cd5..246e4f5 100644 --- a/common/car/src/main/java/com/kouros/navigation/car/NavigationSession.kt +++ b/common/car/src/main/java/com/kouros/navigation/car/NavigationSession.kt @@ -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 = - 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 = - 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 { 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 = 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) } } diff --git a/common/car/src/main/java/com/kouros/navigation/car/screen/NavigationScreen.kt b/common/car/src/main/java/com/kouros/navigation/car/screen/NavigationScreen.kt index 0bad036..aaeb8e6 100644 --- a/common/car/src/main/java/com/kouros/navigation/car/screen/NavigationScreen.kt +++ b/common/car/src/main/java/com/kouros/navigation/car/screen/NavigationScreen.kt @@ -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 = ArrayList() permissions.add(permission) diff --git a/common/car/src/main/java/com/kouros/navigation/car/screen/NavigationSettings.kt b/common/car/src/main/java/com/kouros/navigation/car/screen/NavigationSettings.kt index 6b310ad..165bb23 100644 --- a/common/car/src/main/java/com/kouros/navigation/car/screen/NavigationSettings.kt +++ b/common/car/src/main/java/com/kouros/navigation/car/screen/NavigationSettings.kt @@ -84,7 +84,7 @@ class NavigationSettings( ) .addItem( buildRowForScreenTemplate( - PasswordSettings(carContext, navigationViewModel), + PasswordSettings(carContext), R.string.tomtom_api_key ) ) diff --git a/common/car/src/main/java/com/kouros/navigation/car/screen/PasswordSettings.kt b/common/car/src/main/java/com/kouros/navigation/car/screen/PasswordSettings.kt index e30cabb..0808856 100644 --- a/common/car/src/main/java/com/kouros/navigation/car/screen/PasswordSettings.kt +++ b/common/car/src/main/java/com/kouros/navigation/car/screen/PasswordSettings.kt @@ -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() diff --git a/common/data/src/main/java/com/kouros/navigation/data/NavigationState.kt b/common/data/src/main/java/com/kouros/navigation/data/NavigationState.kt new file mode 100644 index 0000000..d924f08 --- /dev/null +++ b/common/data/src/main/java/com/kouros/navigation/data/NavigationState.kt @@ -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, +) \ No newline at end of file diff --git a/common/data/src/main/java/com/kouros/navigation/data/tomtom/TomTomRepository.kt b/common/data/src/main/java/com/kouros/navigation/data/tomtom/TomTomRepository.kt index 36dffd2..16cec5b 100644 --- a/common/data/src/main/java/com/kouros/navigation/data/tomtom/TomTomRepository.kt +++ b/common/data/src/main/java/com/kouros/navigation/data/tomtom/TomTomRepository.kt @@ -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" + diff --git a/common/data/src/main/java/com/kouros/navigation/model/RouteModel.kt b/common/data/src/main/java/com/kouros/navigation/model/RouteModel.kt index 0f78e87..474bb32 100644 --- a/common/data/src/main/java/com/kouros/navigation/model/RouteModel.kt +++ b/common/data/src/main/java/com/kouros/navigation/model/RouteModel.kt @@ -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 diff --git a/common/data/src/main/res/values-de/strings.xml b/common/data/src/main/res/values-de/strings.xml index 97ef1dd..9fe0ed1 100644 --- a/common/data/src/main/res/values-de/strings.xml +++ b/common/data/src/main/res/values-de/strings.xml @@ -52,5 +52,7 @@ Optionen TomTom ApiKey Verwende Auto Einstellungen + Ausfahrt nummer + Navigations Icon diff --git a/common/data/src/main/res/values/strings.xml b/common/data/src/main/res/values/strings.xml index a4cf39a..4a1ffb0 100644 --- a/common/data/src/main/res/values/strings.xml +++ b/common/data/src/main/res/values/strings.xml @@ -38,4 +38,6 @@ Options TomTom ApiKey Use car settings + Exit number + Navigation icon \ No newline at end of file