SheetContent Simulation

This commit is contained in:
Dimitris
2026-02-25 09:48:39 +01:00
parent e4b539c4e6
commit 5a6165dff8
13 changed files with 384 additions and 147 deletions

View File

@@ -99,6 +99,7 @@ dependencies {
implementation(libs.androidx.navigation.compose) implementation(libs.androidx.navigation.compose)
implementation(libs.kotlinx.serialization.json) implementation(libs.kotlinx.serialization.json)
implementation(libs.androidx.compose.foundation.layout) implementation(libs.androidx.compose.foundation.layout)
implementation(libs.androidx.compose.foundation)
testImplementation(libs.junit) testImplementation(libs.junit)
androidTestImplementation(libs.androidx.junit) androidTestImplementation(libs.androidx.junit)
androidTestImplementation(libs.androidx.espresso.core) androidTestImplementation(libs.androidx.espresso.core)

View File

@@ -98,4 +98,5 @@ class MockLocation (private var locationManager: LocationManager) {
} }
} }
} }

View File

@@ -0,0 +1,132 @@
package com.kouros.navigation.model
import android.content.Context
import com.kouros.data.R
import com.kouros.navigation.MainApplication.Companion.navigationViewModel
import com.kouros.navigation.utils.location
import io.ticofab.androidgpxparser.parser.GPXParser
import io.ticofab.androidgpxparser.parser.domain.Gpx
import io.ticofab.androidgpxparser.parser.domain.TrackSegment
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import org.joda.time.DateTime
import kotlin.collections.forEach
fun simulate(routeModel: RouteModel, mock: MockLocation) {
CoroutineScope(Dispatchers.IO).launch {
var lastLocation = location(0.0, 0.0)
for ((index, waypoint) in routeModel.curRoute.waypoints.withIndex()) {
val curLocation = location(waypoint[0], waypoint[1])
if (routeModel.isNavigating()) {
val deviation = 0.0
if (index in 0..routeModel.curRoute.waypoints.size) {
val bearing = lastLocation.bearingTo(curLocation)
mock.setMockLocation(waypoint[1], waypoint[0], bearing)
Thread.sleep(1000)
}
}
lastLocation = curLocation
}
}
}
fun test(applicationContext: Context, routeModel: RouteModel) {
for ((index, step) in routeModel.curLeg.steps.withIndex()) {
for ((windex, waypoint) in step.maneuver.waypoints.withIndex()) {
routeModel.updateLocation(
applicationContext,
location(waypoint[0], waypoint[1]), navigationViewModel
)
val step = routeModel.currentStep()
val nextStep = routeModel.nextStep()
println("Step: ${step.instruction} ${step.leftStepDistance} ${nextStep.currentManeuverType}")
}
}
}
fun testSingle(applicationContext: Context, routeModel: RouteModel, mock: MockLocation) {
testSingleUpdate(
applicationContext,
48.185976,
11.578463,
routeModel,
mock
) // Silcherstr. 23-13
testSingleUpdate(
applicationContext,
48.186712,
11.578574,
routeModel,
mock
) // Silcherstr. 27-33
testSingleUpdate(
applicationContext,
48.186899,
11.580480,
routeModel,
mock
) // Schmalkadenerstr. 24-28
}
fun testSingleUpdate(
applicationContext: Context,
latitude: Double,
longitude: Double,
routeModel: RouteModel,
mock: MockLocation
) {
if (1 == 1) {
mock.setMockLocation(latitude, longitude, 0F)
} else {
routeModel.updateLocation(
applicationContext,
location(longitude, latitude), navigationViewModel
)
}
val step = routeModel.currentStep()
val nextStep = routeModel.nextStep()
Thread.sleep(1_000)
}
fun gpx(context: Context, mock: MockLocation) {
CoroutineScope(Dispatchers.IO).launch {
var lastLocation = location(0.0, 0.0)
val parser = GPXParser()
val input = context.resources.openRawResource(R.raw.vh)
val parsedGpx: Gpx? = parser.parse(input) // consider using a background thread
parsedGpx?.let {
val tracks = parsedGpx.tracks
tracks.forEach { tr ->
val segments: MutableList<TrackSegment?>? = tr.trackSegments
segments!!.forEach { seg ->
var lastTime = DateTime.now()
seg!!.trackPoints.forEach { p ->
val curLocation = location(p.longitude, p.latitude)
val ext = p.extensions
val speed: Double?
if (ext != null) {
speed = ext.speed
mock.curSpeed = speed.toFloat()
}
val duration = p.time.millis - lastTime.millis
val bearing = lastLocation.bearingTo(curLocation)
println("Bearing $bearing")
mock.setMockLocation(p.latitude, p.longitude, bearing)
if (duration > 0) {
delay(duration / 5)
}
lastTime = p.time
lastLocation = curLocation
}
}
}
}
}
}
enum class SimulationType {
SIMULATE, TEST, GPX, TEST_SINGLE
}

View File

@@ -1,10 +1,8 @@
package com.kouros.navigation.ui package com.kouros.navigation.ui
import NavigationSheet
import android.Manifest import android.Manifest
import android.annotation.SuppressLint import android.annotation.SuppressLint
import android.app.AppOpsManager import android.app.AppOpsManager
import android.content.Context
import android.location.LocationManager import android.location.LocationManager
import android.os.Bundle import android.os.Bundle
import android.os.Process import android.os.Process
@@ -18,6 +16,7 @@ import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.BottomSheetScaffold import androidx.compose.material3.BottomSheetScaffold
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.FloatingActionButton import androidx.compose.material3.FloatingActionButton
@@ -56,6 +55,11 @@ import com.kouros.navigation.data.StepData
import com.kouros.navigation.model.BaseStyleModel import com.kouros.navigation.model.BaseStyleModel
import com.kouros.navigation.model.MockLocation import com.kouros.navigation.model.MockLocation
import com.kouros.navigation.model.RouteModel import com.kouros.navigation.model.RouteModel
import com.kouros.navigation.model.SimulationType
import com.kouros.navigation.model.gpx
import com.kouros.navigation.model.simulate
import com.kouros.navigation.model.test
import com.kouros.navigation.model.testSingle
import com.kouros.navigation.ui.app.AppViewModel import com.kouros.navigation.ui.app.AppViewModel
import com.kouros.navigation.ui.app.appViewModel import com.kouros.navigation.ui.app.appViewModel
import com.kouros.navigation.ui.navigation.AppNavGraph import com.kouros.navigation.ui.navigation.AppNavGraph
@@ -64,14 +68,7 @@ import com.kouros.navigation.utils.GeoUtils.snapLocation
import com.kouros.navigation.utils.bearing import com.kouros.navigation.utils.bearing
import com.kouros.navigation.utils.getSettingsViewModel import com.kouros.navigation.utils.getSettingsViewModel
import com.kouros.navigation.utils.location import com.kouros.navigation.utils.location
import io.ticofab.androidgpxparser.parser.GPXParser
import io.ticofab.androidgpxparser.parser.domain.Gpx
import io.ticofab.androidgpxparser.parser.domain.TrackSegment
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import org.joda.time.DateTime
import org.maplibre.compose.camera.CameraPosition import org.maplibre.compose.camera.CameraPosition
import org.maplibre.compose.location.DesiredAccuracy import org.maplibre.compose.location.DesiredAccuracy
import org.maplibre.compose.location.Location import org.maplibre.compose.location.Location
@@ -86,7 +83,7 @@ class MainActivity : ComponentActivity() {
val routeModel = RouteModel() val routeModel = RouteModel()
var tilt = 50.0 var tilt = 50.0
val useMock = false val useMock = false
val type = 3 // 1 simulate 2 test 3 gpx 4 testSingle val type = SimulationType.GPX
val stepData: MutableLiveData<StepData> by lazy { val stepData: MutableLiveData<StepData> by lazy {
MutableLiveData() MutableLiveData()
@@ -94,6 +91,7 @@ class MainActivity : ComponentActivity() {
val nextStepData: MutableLiveData<StepData> by lazy { val nextStepData: MutableLiveData<StepData> by lazy {
MutableLiveData() MutableLiveData()
} }
var lastLocation = location(0.0, 0.0) var lastLocation = location(0.0, 0.0)
val observer = Observer<String> { newRoute -> val observer = Observer<String> { newRoute ->
if (newRoute.isNotEmpty()) { if (newRoute.isNotEmpty()) {
@@ -101,13 +99,12 @@ class MainActivity : ComponentActivity() {
routeData.value = routeModel.curRoute.routeGeoJson routeData.value = routeModel.curRoute.routeGeoJson
if (useMock) { if (useMock) {
when (type) { when (type) {
1 -> simulate() SimulationType.SIMULATE -> simulate(routeModel, mock)
2 -> test() SimulationType.TEST -> test(applicationContext, routeModel)
3 -> gpx( SimulationType.GPX -> gpx(
context = applicationContext context = applicationContext, mock
) )
SimulationType.TEST_SINGLE -> testSingle(applicationContext, routeModel, mock)
4 -> testSingle()
} }
} }
} }
@@ -187,11 +184,12 @@ class MainActivity : ComponentActivity() {
val appViewModel: AppViewModel = appViewModel() val appViewModel: AppViewModel = appViewModel()
val darkMode by appViewModel.darkMode.collectAsState() val darkMode by appViewModel.darkMode.collectAsState()
val sheetPeekHeight = 250.dp
val baseStyle = BaseStyleModel().readStyle(applicationContext, darkMode, darkMode == 1) val baseStyle = BaseStyleModel().readStyle(applicationContext, darkMode, darkMode == 1)
val scaffoldState = rememberBottomSheetScaffoldState() val scaffoldState = rememberBottomSheetScaffoldState()
val snackbarHostState = remember { SnackbarHostState() } val snackbarHostState = remember { SnackbarHostState() }
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
val sheetPeekHeight = 250.dp
val sheetPeekHeightState = remember { mutableStateOf(sheetPeekHeight) } val sheetPeekHeightState = remember { mutableStateOf(sheetPeekHeight) }
val locationProvider = rememberDefaultLocationProvider( val locationProvider = rememberDefaultLocationProvider(
@@ -209,7 +207,7 @@ class MainActivity : ComponentActivity() {
fun closeSheet() { fun closeSheet() {
scope.launch { scope.launch {
scaffoldState.bottomSheetState.partialExpand() scaffoldState.bottomSheetState.partialExpand()
sheetPeekHeightState.value = sheetPeekHeight sheetPeekHeightState.value = 50.dp
} }
} }
NavigationTheme(useDarkTheme = darkMode == 1) { NavigationTheme(useDarkTheme = darkMode == 1) {
@@ -217,7 +215,7 @@ class MainActivity : ComponentActivity() {
snackbarHost = { snackbarHost = {
SnackbarHost(hostState = snackbarHostState) SnackbarHost(hostState = snackbarHostState)
}, },
scaffoldState = scaffoldState, scaffoldState = scaffoldState,
sheetPeekHeight = sheetPeekHeightState.value, sheetPeekHeight = sheetPeekHeightState.value,
sheetContent = { sheetContent = {
SheetContent(latitude, step, nextStep) { closeSheet() } SheetContent(latitude, step, nextStep) { closeSheet() }
@@ -363,7 +361,10 @@ class MainActivity : ComponentActivity() {
} }
fun simulateNavigation() { fun simulateNavigation() {
simulate() simulate(
routeModel = routeModel,
mock = mock
)
} }
private fun checkMockLocationEnabled() { private fun checkMockLocationEnabled() {
@@ -385,95 +386,5 @@ class MainActivity : ComponentActivity() {
e.printStackTrace() e.printStackTrace()
} }
} }
}
fun simulate() {
CoroutineScope(Dispatchers.IO).launch {
var lastLocation = location(0.0, 0.0)
for ((index, waypoint) in routeModel.curRoute.waypoints.withIndex()) {
val curLocation = location(waypoint[0], waypoint[1])
if (routeModel.isNavigating()) {
val deviation = 0.0
if (index in 0..routeModel.curRoute.waypoints.size) {
val bearing = lastLocation.bearingTo(curLocation)
mock.setMockLocation(waypoint[1], waypoint[0], bearing)
Thread.sleep(1000)
}
}
lastLocation = curLocation
}
}
}
fun test() {
for ((index, step) in routeModel.curLeg.steps.withIndex()) {
//if (index in 3..3) {
for ((windex, waypoint) in step.maneuver.waypoints.withIndex()) {
routeModel.updateLocation(
applicationContext,
location(waypoint[0], waypoint[1]), navigationViewModel
)
val step = routeModel.currentStep()
val nextStep = routeModel.nextStep()
println("Step: ${step.instruction} ${step.leftStepDistance} ${nextStep.currentManeuverType}")
}
//}
}
}
fun testSingle() {
testSingleUpdate(48.185976, 11.578463) // Silcherstr. 23-13
testSingleUpdate(48.186712, 11.578574) // Silcherstr. 27-33
testSingleUpdate(48.186899, 11.580480) // Schmalkadenerstr. 24-28
}
fun testSingleUpdate(latitude: Double, longitude: Double) {
if (1 == 1) {
mock.setMockLocation(latitude, longitude, 0F)
} else {
routeModel.updateLocation(
applicationContext,
location(longitude, latitude), navigationViewModel
)
}
val step = routeModel.currentStep()
val nextStep = routeModel.nextStep()
Thread.sleep(1_000)
}
fun gpx(context: Context) {
CoroutineScope(Dispatchers.IO).launch {
var lastLocation = location(0.0, 0.0)
val parser = GPXParser()
val input = context.resources.openRawResource(R.raw.vh)
val parsedGpx: Gpx? = parser.parse(input) // consider using a background thread
parsedGpx?.let {
val tracks = parsedGpx.tracks
tracks.forEach { tr ->
val segments: MutableList<TrackSegment?>? = tr.trackSegments
segments!!.forEach { seg ->
var lastTime = DateTime.now()
seg!!.trackPoints.forEach { p ->
val curLocation = location(p.longitude, p.latitude)
val ext = p.extensions
val speed: Double?
if (ext != null) {
speed = ext.speed
mock.curSpeed = speed.toFloat()
}
val duration = p.time.millis - lastTime.millis
val bearing = lastLocation.bearingTo(curLocation)
println("Bearing $bearing")
mock.setMockLocation(p.latitude, p.longitude, bearing)
if (duration > 0) {
delay(duration / 5)
}
lastTime = p.time
lastLocation = curLocation
}
}
}
}
}
}
}

View File

@@ -1,3 +1,5 @@
package com.kouros.navigation.ui
import android.content.Context import android.content.Context
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
@@ -13,9 +15,7 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.painterResource
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import androidx.core.graphics.drawable.IconCompat
import com.kouros.data.R import com.kouros.data.R
import com.kouros.navigation.data.Constants.NEXT_STEP_THRESHOLD
import com.kouros.navigation.data.StepData import com.kouros.navigation.data.StepData
import com.kouros.navigation.model.RouteModel import com.kouros.navigation.model.RouteModel
import com.kouros.navigation.utils.formatDateTime import com.kouros.navigation.utils.formatDateTime

View File

@@ -61,6 +61,7 @@ fun SearchSheet(
.fillMaxWidth() .fillMaxWidth()
.wrapContentHeight() .wrapContentHeight()
) { ) {
// Home(applicationContext, viewModel, location, closeSheet = { closeSheet() })
SearchBar( SearchBar(
textFieldState = textFieldState, textFieldState = textFieldState,
searchPlaces = emptyList(), searchPlaces = emptyList(),
@@ -71,7 +72,7 @@ fun SearchSheet(
closeSheet = { closeSheet() } closeSheet = { closeSheet() }
) )
//Home(applicationContext, viewModel, location, closeSheet = { closeSheet() })
if (recentPlaces.value != null) { if (recentPlaces.value != null) {
val items = listOf(recentPlaces) val items = listOf(recentPlaces)
if (items.isNotEmpty()) { if (items.isNotEmpty()) {
@@ -142,7 +143,7 @@ fun SearchBar(
SearchBarDefaults.InputField( SearchBarDefaults.InputField(
leadingIcon = { leadingIcon = {
Icon( Icon(
painter = painterResource(id = R.drawable.search_48px), painter = painterResource(id = R.drawable.speed_camera_24px),
"Search", "Search",
modifier = Modifier.size(24.dp, 24.dp), modifier = Modifier.size(24.dp, 24.dp),
) )
@@ -166,8 +167,8 @@ fun SearchBar(
RecentPlaces(searchPlaces, viewModel, context, location, closeSheet) RecentPlaces(searchPlaces, viewModel, context, location, closeSheet)
} }
if (searchResults.isNotEmpty()) { if (searchResults.isNotEmpty()) {
Text("Search places") Text("Search places")
SearchPlaces(searchResults, viewModel, context, location, closeSheet) SearchPlaces(searchResults, viewModel, context, location, closeSheet)
} }
} }
} }
@@ -186,7 +187,7 @@ private fun SearchPlaces(
) { ) {
val color = remember { PlaceColor } val color = remember { PlaceColor }
LazyColumn( LazyColumn(
contentPadding = PaddingValues(horizontal = 16.dp, vertical = 24.dp), contentPadding = PaddingValues(horizontal = 16.dp, vertical = 10.dp),
verticalArrangement = Arrangement.spacedBy(4.dp), verticalArrangement = Arrangement.spacedBy(4.dp),
) { ) {
if (searchResults.isNotEmpty()) { if (searchResults.isNotEmpty()) {
@@ -199,7 +200,7 @@ private fun SearchPlaces(
modifier = Modifier.size(24.dp, 24.dp), modifier = Modifier.size(24.dp, 24.dp),
) )
ListItem( ListItem(
headlineContent = { Text("${place.address.road} ${place.address.postcode}") }, headlineContent = { Text(place.displayName) },
modifier = Modifier modifier = Modifier
.clickable { .clickable {
val pl = Place( val pl = Place(
@@ -235,7 +236,7 @@ private fun RecentPlaces(
) { ) {
val color = remember { PlaceColor } val color = remember { PlaceColor }
LazyColumn( LazyColumn(
contentPadding = PaddingValues(horizontal = 16.dp, vertical = 24.dp), contentPadding = PaddingValues(horizontal = 16.dp, vertical = 10.dp),
verticalArrangement = Arrangement.spacedBy(4.dp), verticalArrangement = Arrangement.spacedBy(4.dp),
) { ) {
items(recentPlaces, key = { it.id }) { place -> items(recentPlaces, key = { it.id }) { place ->

View File

@@ -1,19 +1,3 @@
/*
* Copyright 2023 Google LLC
*
* 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 package com.kouros.navigation.car
import android.annotation.SuppressLint import android.annotation.SuppressLint

View File

@@ -52,16 +52,30 @@ import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking import kotlinx.coroutines.runBlocking
import android.Manifest.permission import android.Manifest.permission
/**
* Main session for Android Auto/Automotive OS navigation.
* Manages the lifecycle of the navigation session, including location updates,
* car hardware sensors, routing engine selection, and screen navigation.
* Implements NavigationScreen.Listener for handling navigation events.
*/
class NavigationSession : Session(), NavigationScreen.Listener { class NavigationSession : Session(), NavigationScreen.Listener {
// Flag to enable/disable contact access feature
val useContacts = false 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 lateinit var navigationScreen: NavigationScreen
// Handles map surface rendering on the car display
lateinit var surfaceRenderer: SurfaceRenderer 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? -> var mLocationListener: LocationListenerCompat = LocationListenerCompat { location: Location? ->
val repository = getSettingsRepository(carContext) val repository = getSettingsRepository(carContext)
val useCarLocation = runBlocking { repository.carLocationFlow.first() } val useCarLocation = runBlocking { repository.carLocationFlow.first() }
@@ -70,6 +84,10 @@ class NavigationSession : Session(), NavigationScreen.Listener {
} }
} }
/**
* Lifecycle observer for managing session lifecycle events.
* Cleans up resources when the session is destroyed.
*/
private val mLifeCycleObserver: LifecycleObserver = object : DefaultLifecycleObserver { private val mLifeCycleObserver: LifecycleObserver = object : DefaultLifecycleObserver {
override fun onCreate(owner: LifecycleOwner) { override fun onCreate(owner: LifecycleOwner) {
} }
@@ -92,9 +110,16 @@ class NavigationSession : Session(), NavigationScreen.Listener {
} }
} }
// ViewModel for navigation data and business logic
lateinit var navigationViewModel: NavigationViewModel lateinit var navigationViewModel: NavigationViewModel
// Store for ViewModels to survive configuration changes
lateinit var viewModelStoreOwner : ViewModelStoreOwner lateinit var viewModelStoreOwner : ViewModelStoreOwner
/**
* Listener for car hardware location updates.
* Receives location data from the car's GPS system.
*/
val carLocationListener: OnCarDataAvailableListener<CarHardwareLocation?> = val carLocationListener: OnCarDataAvailableListener<CarHardwareLocation?> =
OnCarDataAvailableListener { data -> OnCarDataAvailableListener { data ->
if (data.location.status == CarValue.STATUS_SUCCESS) { if (data.location.status == CarValue.STATUS_SUCCESS) {
@@ -105,6 +130,10 @@ class NavigationSession : Session(), NavigationScreen.Listener {
} }
} }
/**
* Listener for car compass/orientation sensor.
* Updates the surface renderer with car orientation for map rotation.
*/
val carCompassListener: OnCarDataAvailableListener<Compass?> = val carCompassListener: OnCarDataAvailableListener<Compass?> =
OnCarDataAvailableListener { data -> OnCarDataAvailableListener { data ->
if (data.orientations.status == CarValue.STATUS_SUCCESS) { if (data.orientations.status == CarValue.STATUS_SUCCESS) {
@@ -115,17 +144,26 @@ class NavigationSession : Session(), NavigationScreen.Listener {
} }
} }
/**
* Listener for car speed sensor updates.
* Receives speed in meters per second from car hardware.
*/
val carSpeedListener = OnCarDataAvailableListener<Speed> { data -> val carSpeedListener = OnCarDataAvailableListener<Speed> { data ->
if (data.displaySpeedMetersPerSecond.status == CarValue.STATUS_SUCCESS) { if (data.displaySpeedMetersPerSecond.status == CarValue.STATUS_SUCCESS) {
val speed = data.displaySpeedMetersPerSecond.value val speed = data.displaySpeedMetersPerSecond.value
surfaceRenderer.updateCarSpeed(speed!!) surfaceRenderer.updateCarSpeed(speed!!)
} }
} }
init { init {
val lifecycle: Lifecycle = lifecycle val lifecycle: Lifecycle = lifecycle
lifecycle.addObserver(mLifeCycleObserver) lifecycle.addObserver(mLifeCycleObserver)
} }
/**
* Called when routing engine preference changes.
* Creates appropriate repository based on user selection.
*/
fun onRoutingEngineStateUpdated(routeEngine : Int) { fun onRoutingEngineStateUpdated(routeEngine : Int) {
navigationViewModel = when (routeEngine) { navigationViewModel = when (routeEngine) {
RouteEngine.VALHALLA.ordinal -> NavigationViewModel(ValhallaRepository()) RouteEngine.VALHALLA.ordinal -> NavigationViewModel(ValhallaRepository())
@@ -134,10 +172,19 @@ class NavigationSession : Session(), NavigationScreen.Listener {
} }
} }
/**
* Called when location permission is granted.
* Initializes car hardware sensors if available.
*/
fun onPermissionGranted(permission : Boolean) { fun onPermissionGranted(permission : Boolean) {
addSensors(routeModel.navState.carConnection) addSensors(routeModel.navState.carConnection)
} }
/**
* Called when car connection state changes.
* Handles different connection types: Not Connected, Automotive OS Native, Android Auto Projection.
* Requests appropriate car speed permissions based on connection type.
*/
fun onConnectionStateUpdated(connectionState: Int) { fun onConnectionStateUpdated(connectionState: Int) {
routeModel.navState = routeModel.navState.copy(carConnection = connectionState) routeModel.navState = routeModel.navState.copy(carConnection = connectionState)
when (connectionState) { when (connectionState) {
@@ -153,8 +200,14 @@ class NavigationSession : Session(), NavigationScreen.Listener {
} }
} }
/**
* Creates the initial screen for the session.
* Sets up ViewModel store, initializes components, checks permissions,
* and returns appropriate starting screen.
*/
override fun onCreateScreen(intent: Intent): Screen { override fun onCreateScreen(intent: Intent): Screen {
// Create ViewModelStoreOwner to manage ViewModels across lifecycle
viewModelStoreOwner = object : ViewModelStoreOwner { viewModelStoreOwner = object : ViewModelStoreOwner {
override val viewModelStore = ViewModelStore() override val viewModelStore = ViewModelStore()
} }
@@ -166,6 +219,7 @@ class NavigationSession : Session(), NavigationScreen.Listener {
} }
} }
// Initialize ViewModel with saved routing engine preference
navigationViewModel = getViewModel(carContext) navigationViewModel = getViewModel(carContext)
navigationViewModel.routingEngine.observe(this, ::onRoutingEngineStateUpdated) navigationViewModel.routingEngine.observe(this, ::onRoutingEngineStateUpdated)
@@ -174,13 +228,17 @@ class NavigationSession : Session(), NavigationScreen.Listener {
routeModel = RouteCarModel() routeModel = RouteCarModel()
// Monitor car connection state
CarConnection(carContext).type.observe(this, ::onConnectionStateUpdated) CarConnection(carContext).type.observe(this, ::onConnectionStateUpdated)
// Initialize surface renderer for map display
surfaceRenderer = SurfaceRenderer(carContext, lifecycle, routeModel, viewModelStoreOwner) surfaceRenderer = SurfaceRenderer(carContext, lifecycle, routeModel, viewModelStoreOwner)
// Create main navigation screen
navigationScreen = navigationScreen =
NavigationScreen(carContext, surfaceRenderer, routeModel, this, navigationViewModel) NavigationScreen(carContext, surfaceRenderer, routeModel, this, navigationViewModel)
// Check for required permissions before starting
if ( carContext.checkSelfPermission(Manifest.permission.ACCESS_FINE_LOCATION) if ( carContext.checkSelfPermission(Manifest.permission.ACCESS_FINE_LOCATION)
== PackageManager.PERMISSION_GRANTED == PackageManager.PERMISSION_GRANTED
&& !useContacts && !useContacts
@@ -207,6 +265,11 @@ class NavigationSession : Session(), NavigationScreen.Listener {
return navigationScreen 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) { fun addSensors(connectionState: Int) {
val carInfo = carContext.getCarService(CarHardwareManager::class.java).carInfo val carInfo = carContext.getCarService(CarHardwareManager::class.java).carInfo
val repository = getSettingsRepository(carContext) val repository = getSettingsRepository(carContext)
@@ -228,6 +291,10 @@ class NavigationSession : Session(), NavigationScreen.Listener {
} }
} }
/**
* Unregisters all car hardware sensor listeners.
* Called when session is being destroyed to prevent memory leaks.
*/
fun removeSensors() { fun removeSensors() {
val carInfo = carContext.getCarService(CarHardwareManager::class.java).carInfo val carInfo = carContext.getCarService(CarHardwareManager::class.java).carInfo
val repository = getSettingsRepository(carContext) val repository = getSettingsRepository(carContext)
@@ -242,6 +309,10 @@ class NavigationSession : Session(), NavigationScreen.Listener {
} }
} }
/**
* 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) { override fun onNewIntent(intent: Intent) {
val screenManager = carContext.getCarService(ScreenManager::class.java) val screenManager = carContext.getCarService(ScreenManager::class.java)
if ((CarContext.ACTION_NAVIGATE == intent.action)) { if ((CarContext.ACTION_NAVIGATE == intent.action)) {
@@ -278,11 +349,18 @@ class NavigationSession : Session(), NavigationScreen.Listener {
} }
} }
/**
* Called when car configuration changes (e.g., day/night mode).
*/
override fun onCarConfigurationChanged(newConfiguration: Configuration) { override fun onCarConfigurationChanged(newConfiguration: Configuration) {
println("Configuration: ${newConfiguration.isNightModeActive}") println("Configuration: ${newConfiguration.isNightModeActive}")
super.onCarConfigurationChanged(newConfiguration) super.onCarConfigurationChanged(newConfiguration)
} }
/**
* Requests GPS location updates from the device.
* Updates with last known location and starts listening for updates every 500ms or 5 meters.
*/
@SuppressLint("MissingPermission") @SuppressLint("MissingPermission")
fun requestLocationUpdates() { fun requestLocationUpdates() {
val locationManager = val locationManager =
@@ -300,6 +378,12 @@ class NavigationSession : Session(), NavigationScreen.Listener {
} }
} }
/**
* 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) { fun updateLocation(location: Location) {
if (location.hasBearing()) { if (location.hasBearing()) {
routeModel.navState = routeModel.navState.copy(routeBearing = location.bearing) routeModel.navState = routeModel.navState.copy(routeBearing = location.bearing)
@@ -309,10 +393,12 @@ class NavigationSession : Session(), NavigationScreen.Listener {
if (!routeModel.navState.arrived) { if (!routeModel.navState.arrived) {
val snapedLocation = snapLocation(location, routeModel.route.maneuverLocations()) val snapedLocation = snapLocation(location, routeModel.route.maneuverLocations())
val distance = location.distanceTo(snapedLocation) val distance = location.distanceTo(snapedLocation)
// Check if user has deviated too far from route
if (distance > MAXIMAL_ROUTE_DEVIATION) { if (distance > MAXIMAL_ROUTE_DEVIATION) {
navigationScreen.calculateNewRoute(routeModel.navState.destination) navigationScreen.calculateNewRoute(routeModel.navState.destination)
return return
} }
// Snap to route if close enough, otherwise use raw location
if (distance < MAXIMAL_SNAP_CORRECTION) { if (distance < MAXIMAL_SNAP_CORRECTION) {
surfaceRenderer.updateLocation(snapedLocation) surfaceRenderer.updateLocation(snapedLocation)
} else { } else {
@@ -324,14 +410,20 @@ class NavigationSession : Session(), NavigationScreen.Listener {
} }
} }
/**
* Stops active navigation and clears route state.
* Called when user exits navigation or arrives at destination.
*/
override fun stopNavigation(context: CarContext) { override fun stopNavigation(context: CarContext) {
routeModel.stopNavigation(context) routeModel.stopNavigation(context)
} }
companion object { companion object {
// URI host for deep linking
var uriHost: String = "navigation" var uriHost: String = "navigation"
// URI scheme for deep linking
var uriScheme: String = "samples" var uriScheme: String = "samples"
} }
} }

View File

@@ -51,53 +51,100 @@ import org.maplibre.compose.style.BaseStyle
import org.maplibre.spatialk.geojson.Position import org.maplibre.spatialk.geojson.Position
/**
* Handles map rendering for Android Auto using a virtual display.
* Creates a VirtualDisplay to render Compose UI onto the car's surface.
* Manages camera position, zoom, tilt, and navigation state for the map view.
*/
class SurfaceRenderer( class SurfaceRenderer(
private var carContext: CarContext, lifecycle: Lifecycle, private var carContext: CarContext, lifecycle: Lifecycle,
private var routeModel: RouteCarModel, private var routeModel: RouteCarModel,
private var viewModelStoreOwner: ViewModelStoreOwner private var viewModelStoreOwner: ViewModelStoreOwner
) : DefaultLifecycleObserver { ) : DefaultLifecycleObserver {
// Last known location for bearing calculations
var lastLocation = location(0.0, 0.0) var lastLocation = location(0.0, 0.0)
// Car orientation sensor value (999F means no valid orientation)
var carOrientation = 999F var carOrientation = 999F
// Current camera position state for the map
private val cameraPosition = MutableLiveData( private val cameraPosition = MutableLiveData(
CameraPosition( CameraPosition(
zoom = 15.0, zoom = 15.0,
target = Position(latitude = homeVogelhart.latitude, longitude = homeVogelhart.longitude) target = Position(latitude = homeVogelhart.latitude, longitude = homeVogelhart.longitude)
) )
) )
// Visible area of the map surface (can change based on UI elements)
private var visibleArea = MutableLiveData( private var visibleArea = MutableLiveData(
Rect(0, 0, 0, 0) Rect(0, 0, 0, 0)
) )
// Stable area that won't change during scrolling
var stableArea = Rect() var stableArea = Rect()
// Surface dimensions
var width = 0 var width = 0
var height = 0 var height = 0
// Last bearing for smooth transitions
var lastBearing = 0.0 var lastBearing = 0.0
// LiveData for route GeoJSON data
val routeData = MutableLiveData("") val routeData = MutableLiveData("")
// Traffic incident data (incident ID to GeoJSON mapping)
val trafficData = MutableLiveData(emptyMap<String, String>()) val trafficData = MutableLiveData(emptyMap<String, String>())
// Speed camera locations as GeoJSON
val speedCamerasData = MutableLiveData("") val speedCamerasData = MutableLiveData("")
// Current speed in km/h
val speed = MutableLiveData(0F) val speed = MutableLiveData(0F)
// Speed limit for current road
val maxSpeed = MutableLiveData(0) val maxSpeed = MutableLiveData(0)
// Current view mode (navigation, preview, etc.)
var viewStyle = ViewStyle.VIEW var viewStyle = ViewStyle.VIEW
// Center location for route preview
lateinit var centerLocation: Location lateinit var centerLocation: Location
// Route distance for calculating preview zoom
var previewDistance = 0.0 var previewDistance = 0.0
// Compose view for rendering the map
lateinit var mapView: ComposeView lateinit var mapView: ComposeView
// Camera tilt angle (default 55 degrees for navigation)
var tilt = 55.0 var tilt = 55.0
// Map base style (day/night)
val style: MutableLiveData<BaseStyle> by lazy { val style: MutableLiveData<BaseStyle> by lazy {
MutableLiveData() MutableLiveData()
} }
/**
* SurfaceCallback implementation for handling the Android Auto surface lifecycle.
* Creates and manages the VirtualDisplay and Presentation for rendering Compose content.
*/
val mSurfaceCallback: SurfaceCallback = object : SurfaceCallback { val mSurfaceCallback: SurfaceCallback = object : SurfaceCallback {
// Custom lifecycle owner for the virtual display
lateinit var lifecycleOwner: CustomLifecycleOwner lateinit var lifecycleOwner: CustomLifecycleOwner
// Virtual display for rendering the map
lateinit var virtualDisplay: VirtualDisplay lateinit var virtualDisplay: VirtualDisplay
// Presentation that hosts the Compose view
lateinit var presentation: Presentation lateinit var presentation: Presentation
/**
* Called when the surface becomes available.
* Creates VirtualDisplay, initializes lifecycle, and sets up Compose rendering.
*/
override fun onSurfaceAvailable(surfaceContainer: SurfaceContainer) { override fun onSurfaceAvailable(surfaceContainer: SurfaceContainer) {
synchronized(this@SurfaceRenderer) { synchronized(this@SurfaceRenderer) {
Log.i(TAG, "Surface available $surfaceContainer") Log.i(TAG, "Surface available $surfaceContainer")
@@ -135,18 +182,29 @@ class SurfaceRenderer(
} }
} }
/**
* Called when the visible area changes (e.g., due to UI elements appearing).
*/
override fun onVisibleAreaChanged(newVisibleArea: Rect) { override fun onVisibleAreaChanged(newVisibleArea: Rect) {
synchronized(this@SurfaceRenderer) { synchronized(this@SurfaceRenderer) {
visibleArea.value = newVisibleArea visibleArea.value = newVisibleArea
} }
} }
/**
* Called when the stable area changes.
* Stable area is guaranteed not to change during scroll events.
*/
override fun onStableAreaChanged(newStableArea: Rect) { override fun onStableAreaChanged(newStableArea: Rect) {
synchronized(this@SurfaceRenderer) { synchronized(this@SurfaceRenderer) {
stableArea = newStableArea stableArea = newStableArea
} }
} }
/**
* Called when the surface is being destroyed.
* Cleans up resources and notifies lifecycle owner.
*/
override fun onSurfaceDestroyed(surfaceContainer: SurfaceContainer) { override fun onSurfaceDestroyed(surfaceContainer: SurfaceContainer) {
synchronized(this@SurfaceRenderer) { synchronized(this@SurfaceRenderer) {
Log.i(TAG, "SurfaceRenderer destroyed") Log.i(TAG, "SurfaceRenderer destroyed")
@@ -159,11 +217,17 @@ class SurfaceRenderer(
} }
} }
/**
* Called when user scrolls the map (not currently implemented).
*/
override fun onScroll(distanceX: Float, distanceY: Float) { override fun onScroll(distanceX: Float, distanceY: Float) {
synchronized(this@SurfaceRenderer) { synchronized(this@SurfaceRenderer) {
} }
} }
/**
* Called when user scales (zooms) the map (not currently implemented).
*/
override fun onScale(focusX: Float, focusY: Float, scaleFactor: Float) { override fun onScale(focusX: Float, focusY: Float, scaleFactor: Float) {
} }
@@ -179,6 +243,10 @@ class SurfaceRenderer(
} }
/**
* Composable function that renders the map and navigation UI.
* Observes various LiveData sources and updates the map accordingly.
*/
@Composable @Composable
fun MapView() { fun MapView() {
@@ -204,6 +272,10 @@ class SurfaceRenderer(
ShowPosition(cameraState, position, paddingValues) ShowPosition(cameraState, position, paddingValues)
} }
/**
* Composable that handles camera animations and navigation overlays.
* Displays speed indicator and navigation images during active navigation.
*/
@Composable @Composable
fun ShowPosition( fun ShowPosition(
cameraState: CameraState, cameraState: CameraState,
@@ -244,7 +316,10 @@ class SurfaceRenderer(
.setSurfaceCallback(mSurfaceCallback) .setSurfaceCallback(mSurfaceCallback)
} }
/** Handles the map zoom-in and zoom-out events. */ /**
* Handles the map zoom-in and zoom-out events.
* Switches to PAN_VIEW mode and updates camera zoom level.
*/
fun handleScale(zoomSign: Int) { fun handleScale(zoomSign: Int) {
synchronized(this) { synchronized(this) {
if (viewStyle == ViewStyle.VIEW) { if (viewStyle == ViewStyle.VIEW) {
@@ -264,6 +339,11 @@ class SurfaceRenderer(
} }
} }
/**
* Updates the camera position based on current location.
* Calculates appropriate bearing, zoom, and maintains view style.
* Uses car orientation sensor if available, otherwise falls back to location bearing.
*/
fun updateLocation(location: Location) { fun updateLocation(location: Location) {
synchronized(this) { synchronized(this) {
if (viewStyle == ViewStyle.VIEW || viewStyle == ViewStyle.PAN_VIEW) { if (viewStyle == ViewStyle.VIEW || viewStyle == ViewStyle.PAN_VIEW) {
@@ -296,6 +376,10 @@ class SurfaceRenderer(
} }
} }
/**
* Updates camera position with new bearing, zoom, and target.
* Posts update to LiveData for UI observation.
*/
private fun updateCameraPosition(bearing: Double, zoom: Double, target: Position) { private fun updateCameraPosition(bearing: Double, zoom: Double, target: Position) {
synchronized(this) { synchronized(this) {
cameraPosition.postValue( cameraPosition.postValue(
@@ -310,15 +394,25 @@ class SurfaceRenderer(
} }
} }
/**
* Sets route data for active navigation and switches to VIEW mode.
*/
fun setRouteData() { fun setRouteData() {
routeData.value = routeModel.curRoute.routeGeoJson routeData.value = routeModel.curRoute.routeGeoJson
viewStyle = ViewStyle.VIEW viewStyle = ViewStyle.VIEW
} }
/**
* Updates traffic incident data on the map.
*/
fun setTrafficData(traffic: Map<String, String> ) { fun setTrafficData(traffic: Map<String, String> ) {
trafficData.value = traffic as MutableMap<String, String>? trafficData.value = traffic as MutableMap<String, String>?
} }
/**
* Sets up route preview mode with overview camera position.
* Calculates appropriate zoom based on route distance.
*/
fun setPreviewRouteData(routeModel: RouteModel) { fun setPreviewRouteData(routeModel: RouteModel) {
viewStyle = ViewStyle.PREVIEW viewStyle = ViewStyle.PREVIEW
with(routeModel) { with(routeModel) {
@@ -333,6 +427,9 @@ class SurfaceRenderer(
) )
} }
/**
* Displays a specific location (e.g., amenity/POI) on the map.
*/
fun setCategories(location: Location, route: String) { fun setCategories(location: Location, route: String) {
synchronized(this) { synchronized(this) {
viewStyle = ViewStyle.AMENITY_VIEW viewStyle = ViewStyle.AMENITY_VIEW
@@ -345,6 +442,10 @@ class SurfaceRenderer(
} }
} }
/**
* Updates car location from the connected car system.
* Only updates location when using OSRM routing engine.
*/
fun updateCarLocation(location: Location) { fun updateCarLocation(location: Location) {
val repository = getSettingsRepository(carContext) val repository = getSettingsRepository(carContext)
val routingEngine = runBlocking { repository.routingEngineFlow.first() } val routingEngine = runBlocking { repository.routingEngineFlow.first() }
@@ -353,10 +454,16 @@ class SurfaceRenderer(
} }
} }
/**
* Updates current speed for display.
*/
fun updateCarSpeed(newSpeed: Float) { fun updateCarSpeed(newSpeed: Float) {
speed.value = newSpeed speed.value = newSpeed
} }
/**
* Centers the map on a specific category/POI location.
*/
fun setCategoryLocation(location: Location, category: String) { fun setCategoryLocation(location: Location, category: String) {
viewStyle = ViewStyle.AMENITY_VIEW viewStyle = ViewStyle.AMENITY_VIEW
cameraPosition.postValue( cameraPosition.postValue(
@@ -373,7 +480,14 @@ class SurfaceRenderer(
} }
/**
* Enum representing different map view modes.
* - VIEW: Active navigation mode with follow-car camera
* - PREVIEW: Route overview before starting navigation
* - PAN_VIEW: User-controlled map panning
* - AMENITY_VIEW: Displaying POI/amenity locations
*/
enum class ViewStyle { enum class ViewStyle {
VIEW, PREVIEW, PAN_VIEW, AMENITY_VIEW VIEW, PREVIEW, PAN_VIEW, AMENITY_VIEW
} }

View File

@@ -57,7 +57,6 @@ class SearchScreen(
.setNoItemsMessage("No search results to show") .setNoItemsMessage("No search results to show")
if (!isSearchComplete) { if (!isSearchComplete) {
categories.forEach { categories.forEach {
it.name
itemListBuilder.addItem( itemListBuilder.addItem(
Row.Builder() Row.Builder()
.setTitle(it.name) .setTitle(it.name)

View File

@@ -34,6 +34,6 @@ class OsrmRepository : NavigationRepository() {
location: Location, location: Location,
carOrientation: Float carOrientation: Float
): String { ): String {
TODO("Not yet implemented") return ""
} }
} }

View File

@@ -53,6 +53,6 @@ class ValhallaRepository : NavigationRepository() {
location: Location, location: Location,
carOrientation: Float carOrientation: Float
): String { ): String {
TODO("Not yet implemented") return ""
} }
} }

View File

@@ -250,10 +250,12 @@ class NavigationViewModel(private val repository: NavigationRepository) : ViewMo
currentLocation, currentLocation,
carOrientation carOrientation
) )
val trafficData = rebuildTraffic(data) if (data.isNotEmpty()) {
traffic.postValue( val trafficData = rebuildTraffic(data)
trafficData traffic.postValue(
) trafficData
)
}
} catch (e: Exception) { } catch (e: Exception) {
e.printStackTrace() e.printStackTrace()
} }