This commit is contained in:
Dimitris
2026-03-26 07:13:31 +01:00
parent 5098dad9d6
commit a3370e42a8
18 changed files with 590 additions and 101 deletions

View File

@@ -29,7 +29,6 @@ android {
}
buildFeatures {
compose = true
buildConfig = true
}
}

View File

@@ -137,7 +137,7 @@ class CarSensorManager(
carCompassListener
)
carSensors.addCarHardwareLocationListener(
CarSensors.UPDATE_RATE_NORMAL,
CarSensors.UPDATE_RATE_FASTEST,
carContext.mainExecutor,
carLocationListener
)

View File

@@ -0,0 +1,125 @@
/*
* Copyright (C) 2025 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kouros.navigation.car
import android.content.Intent
import android.content.res.Configuration
import android.util.Log
import androidx.car.app.CarContext
import androidx.car.app.CarToast
import androidx.car.app.Screen
import androidx.car.app.Session
import androidx.car.app.model.Action
import androidx.car.app.model.CarIcon
import androidx.car.app.model.OnClickListener
import androidx.car.app.navigation.model.Trip
import androidx.core.graphics.drawable.IconCompat
import androidx.lifecycle.ViewModelStore
import androidx.lifecycle.ViewModelStoreOwner
import androidx.lifecycle.lifecycleScope
import com.kouros.data.R
import com.kouros.navigation.car.navigation.NavigationService
import com.kouros.navigation.car.navigation.RouteCarModel
import com.kouros.navigation.car.screen.NavigationListener
import com.kouros.navigation.car.screen.NavigationScreen
import kotlinx.coroutines.awaitCancellation
import kotlinx.coroutines.launch
/** Session class for the Navigation sample app. */
internal class ClusterSession : Session(), NavigationListener {
var mNavigationScreen: NavigationScreen? = null
var mNavigationCarSurface: SurfaceRenderer? = null
// A reference to the navigation service used to get location updates and routing.
var service: NavigationService? = null
var mSettingsAction: Action? = null
var routeModel = RouteCarModel()
lateinit var viewModelStoreOwner: ViewModelStoreOwner
override fun onCreateScreen(intent: Intent): Screen {
Log.i(TAG, "In onCreateScreen()")
setupViewModelStore()
mSettingsAction =
Action.Builder()
.setIcon(
CarIcon.Builder(
IconCompat.createWithResource(
carContext, R.drawable.alt_route_48px
)
)
.build()
)
.setOnClickListener(
OnClickListener {})
.build()
mNavigationCarSurface = SurfaceRenderer(carContext, lifecycle, routeModel, viewModelStoreOwner)
// mNavigationScreen =
// new NavigationScreen(getCarContext(), mSettingsAction, this, mNavigationCarSurface);
val action = intent.action
if (CarContext.ACTION_NAVIGATE == action) {
CarToast.makeText(
carContext,
"Navigation intent: " + intent.dataString,
CarToast.LENGTH_LONG
)
.show()
}
return mNavigationScreen!!
}
override fun onCarConfigurationChanged(newConfiguration: Configuration) {
// mNavigationCarSurface.onCarConfigurationChanged();
}
override fun stopNavigation() {
if (service != null) {
service!!.stopNavigation()
}
}
override fun startNavigation() {
}
override fun updateTrip(trip: Trip) {
}
companion object {
val TAG: String = ClusterSession::class.java.getSimpleName()
}
private fun setupViewModelStore() {
viewModelStoreOwner = object : ViewModelStoreOwner {
override val viewModelStore = ViewModelStore()
}
lifecycleScope.launch {
try {
awaitCancellation()
} finally {
viewModelStoreOwner.viewModelStore.clear()
}
}
}
}

View File

@@ -1,11 +1,15 @@
package com.kouros.navigation.car
import android.annotation.SuppressLint
import android.app.NotificationChannel
import android.app.NotificationManager
import android.net.Uri
import android.util.Log
import androidx.car.app.CarAppService
import androidx.car.app.Session
import androidx.car.app.SessionInfo
import androidx.car.app.validation.HostValidator
import com.kouros.navigation.data.Constants.TAG
class NavigationCarAppService : CarAppService() {
@@ -13,6 +17,7 @@ class NavigationCarAppService : CarAppService() {
val INTENT_ACTION_NAV_NOTIFICATION_OPEN_APP =
"com.kouros.navigation.INTENT_ACTION_NAV_NOTIFICATION_OPEN_APP"
val channelId: String = "NavigationSessionChannel"
fun createDeepLinkUri(deepLinkAction: String): Uri {
return Uri.fromParts(NavigationSession.uriScheme, NavigationSession.uriHost, deepLinkAction)
@@ -26,7 +31,26 @@ class NavigationCarAppService : CarAppService() {
}
override fun onCreateSession(sessionInfo: SessionInfo): Session {
return NavigationSession()
Log.d(TAG, "Display Type: ${sessionInfo.displayType}")
if (sessionInfo.displayType == SessionInfo.DISPLAY_TYPE_CLUSTER) {
return ClusterSession()
} else {
createNotificationChannel()
return NavigationSession()
}
}
private fun createNotificationChannel() {
val notificationManager =
getSystemService(NotificationManager::class.java)
val name: CharSequence = "Car App Service"
val serviceChannel =
NotificationChannel(
channelId,
name,
NotificationManager.IMPORTANCE_HIGH
)
notificationManager.createNotificationChannel(serviceChannel)
}
}

View File

@@ -1,17 +1,27 @@
package com.kouros.navigation.car
import android.Manifest.permission
import android.content.ComponentName
import android.content.Context
import android.content.Intent
import android.content.ServiceConnection
import android.content.pm.PackageManager
import android.location.Location
import android.os.IBinder
import android.util.Log
import androidx.car.app.CarContext
import androidx.car.app.CarToast
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.model.CarIcon
import androidx.car.app.model.Distance
import androidx.car.app.navigation.NavigationManager
import androidx.car.app.navigation.NavigationManagerCallback
import androidx.car.app.navigation.model.Destination
import androidx.car.app.navigation.model.Step
import androidx.car.app.navigation.model.TravelEstimate
import androidx.car.app.navigation.model.Trip
import androidx.lifecycle.DefaultLifecycleObserver
import androidx.lifecycle.LifecycleObserver
@@ -22,6 +32,7 @@ import androidx.lifecycle.ViewModelStoreOwner
import androidx.lifecycle.asLiveData
import androidx.lifecycle.coroutineScope
import androidx.lifecycle.lifecycleScope
import com.kouros.navigation.car.navigation.NavigationService
import com.kouros.navigation.car.navigation.RouteCarModel
import com.kouros.navigation.car.navigation.Simulation
import com.kouros.navigation.car.screen.NavigationListener
@@ -36,7 +47,6 @@ import com.kouros.navigation.data.Constants.INSTRUCTION_DISTANCE
import com.kouros.navigation.data.Constants.MAXIMAL_ROUTE_DEVIATION
import com.kouros.navigation.data.Constants.MAXIMAL_SNAP_CORRECTION
import com.kouros.navigation.data.Constants.TAG
import com.kouros.navigation.data.Place
import com.kouros.navigation.data.RouteEngine
import com.kouros.navigation.data.ViewStyle
import com.kouros.navigation.data.osrm.OsrmRepository
@@ -47,9 +57,7 @@ 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 java.time.LocalDateTime
import java.time.ZoneOffset
@@ -90,11 +98,64 @@ class NavigationSession : Session(), NavigationListener {
var navigationManagerStarted = false
var navigationService : NavigationService? = null
val serviceListener : NavigationService.Listener = object : NavigationService.Listener {
override fun navigationStateChanged(
isNavigating: Boolean,
isRerouting: Boolean,
hasArrived: Boolean,
destinations: MutableList<Destination?>?,
steps: MutableList<Step>?,
nextDestinationTravelEstimate: TravelEstimate?,
nextStepRemainingDistance: Distance?,
shouldShowNextStep: Boolean,
shouldShowLanes: Boolean,
junctionImage: CarIcon?
) {
//navigationScreen.updateTrip()
}
}
// Monitors the state of the connection to the Navigation service.
val serviceConnection: ServiceConnection = object : ServiceConnection {
override fun onServiceConnected(name: ComponentName?, service: IBinder?) {
Log.i(TAG, "In onServiceConnected() Session component:$service")
val binder: NavigationService.LocalBinder = service as NavigationService.LocalBinder
navigationService = binder.service
navigationService!!.setCarContext(carContext, serviceListener)
}
override fun onServiceDisconnected(name: ComponentName?) {
Log.i(TAG, "In onServiceDisconnected() component: $name")
// Unhook map models here
navigationService!!.clearCarContext()
navigationService = null
}
}
/**
* Lifecycle observer for managing session lifecycle events.
* Cleans up resources when the session is destroyed.
*/
private val lifecycleObserver: LifecycleObserver = object : DefaultLifecycleObserver {
override fun onStart(owner: LifecycleOwner) {
Log.i(TAG, "In onStart() Session")
carContext
.bindService(
Intent(carContext, NavigationService::class.java),
serviceConnection,
Context.BIND_AUTO_CREATE
)
}
override fun onStop(owner: LifecycleOwner) {
Log.i(TAG, "In onStop()")
carContext.unbindService(serviceConnection)
navigationService = null
}
override fun onDestroy(owner: LifecycleOwner) {
if (::navigationManager.isInitialized) {
navigationManager.clearNavigationManagerCallback()
@@ -220,12 +281,9 @@ class NavigationSession : Session(), NavigationListener {
// Called when the app should simulate navigation (e.g., for testing)
deviceLocationManager.stopLocationUpdates()
autoDriveEnabled = true
surfaceRenderer.viewStyle = ViewStyle.VIEW
simulation.startSimulation(
routeModel, lifecycle.coroutineScope
) { location ->
updateLocation(location)
}
startNavigation()
CarToast.makeText(carContext, "Auto drive enabled", CarToast.LENGTH_LONG)
.show()
}
override fun onStopNavigation() {
@@ -427,6 +485,7 @@ class NavigationSession : Session(), NavigationListener {
surfaceRenderer.routeData.value = ""
surfaceRenderer.viewStyle = ViewStyle.VIEW
navigationScreen.navigationType = NavigationType.VIEW
navigationService!!.stopNavigation()
}
/**

View File

@@ -64,7 +64,8 @@ import java.time.LocalDateTime
* Manages camera position, zoom, tilt, and navigation state for the map view.
*/
class SurfaceRenderer(
private var carContext: CarContext, lifecycle: Lifecycle,
private var carContext: CarContext,
private var lifecycle: Lifecycle,
private var routeModel: RouteCarModel,
private var viewModelStoreOwner: ViewModelStoreOwner
) : DefaultLifecycleObserver {

View File

@@ -0,0 +1,258 @@
package com.kouros.navigation.car.navigation
import android.app.Notification
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.Service
import android.content.ComponentName
import android.content.Intent
import android.graphics.BitmapFactory
import android.graphics.Color
import android.os.Binder
import android.os.IBinder
import android.util.Log
import androidx.car.app.CarContext
import androidx.car.app.model.CarIcon
import androidx.car.app.model.Distance
import androidx.car.app.navigation.NavigationManager
import androidx.car.app.navigation.model.Destination
import androidx.car.app.navigation.model.Step
import androidx.car.app.navigation.model.TravelEstimate
import androidx.car.app.notification.CarAppExtender
import androidx.car.app.notification.CarPendingIntent
import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat
import com.kouros.data.R
import com.kouros.navigation.car.NavigationCarAppService
import com.kouros.navigation.data.Constants.TAG
import androidx.core.graphics.toColorInt
class NavigationService : Service() {
val DEEP_LINK_ACTION: String = ("com.kouros.navigation.car.navigation"
+ ".NavigationDeepLinkAction")
val channelId : String = "NavigationServiceChannel"
/** The identifier for the navigation notification displayed for the foreground service. */
val NAV_NOTIFICATION_ID: Int = 87356325
/** The identifier for the non-navigation notifications, such as a traffic accident warning. */
val NOTIFICATION_ID: Int = 71653346
// Constants for location broadcast
val PACKAGE_NAME: String =
"androidx.car.app.sample.navigation.common.nav.navigationservice"
val EXTRA_STARTED_FROM_NOTIFICATION: String = PACKAGE_NAME + ".started_from_notification"
val CANCEL_ACTION: String = "CANCEL"
private var notificationManager: NotificationManager? = null
private var carContext: CarContext? = null
private lateinit var listener: Listener
// Model for managing route state and navigation logic for Android Auto
var routeModel = RouteCarModel()
private lateinit var navigationManager: NavigationManager
private var navigationManagerInitialized = false
var binder: IBinder = LocalBinder()
override fun onBind(p0: Intent?): IBinder {
return binder
}
override fun onUnbind(intent: Intent): Boolean {
return true
}
/** A listener for the navigation state changes. */
interface Listener {
/** Callback called when the navigation state changes. */
fun navigationStateChanged(
isNavigating: Boolean,
isRerouting: Boolean,
hasArrived: Boolean,
destinations: MutableList<Destination?>?,
steps: MutableList<Step>?,
nextDestinationTravelEstimate: TravelEstimate?,
nextStepRemainingDistance: Distance?,
shouldShowNextStep: Boolean,
shouldShowLanes: Boolean,
junctionImage: CarIcon?
)
}
/**
* Class used for the client Binder. Since this service runs in the same process as its clients,
* we don't need to deal with IPC.
*/
inner class LocalBinder : Binder() {
val service: NavigationService
get() = this@NavigationService
}
override fun onCreate() {
Log.i(TAG, "In onCreate()");
createNotificationChannel();
}
/** Sets the [CarContext] to use while the service is connected. */
fun setCarContext(
carContext: CarContext,
listener: Listener
) {
Log.d(TAG, "in setCarContext")
this.carContext = carContext
navigationManagerInitialized = true
// navigationManager =
// carContext.getCarService(NavigationManager::class.java)
// navigationManager.setNavigationManagerCallback(
// object : NavigationManagerCallback {
// override fun onStopNavigation() {
// this@NavigationService.stopNavigation()
// }
//
// override fun onAutoDriveEnabled() {
// Log.d(TAG, "onAutoDriveEnabled called")
// CarToast.makeText(carContext, "Auto drive enabled", CarToast.LENGTH_LONG)
// .show()
// }
// })
this.listener = listener
// Uncomment if navigating
// mNavigationManager.navigationStarted();
}
/** Clears the currently used {@link CarContext}. */
fun clearCarContext() {
carContext = null;
// navigationManager.clearNavigationManagerCallback();
}
/** Starts navigation. */
fun startNavigation() {
Log.i(TAG, "Starting Navigation")
startService(Intent(applicationContext, NavigationService::class.java))
Log.i(TAG, "Starting foreground service")
startForeground(
NAV_NOTIFICATION_ID,
getNotification(
true,
showInCar = false,
navigatingDisplayTitle = getString(R.string.navigation_settings),
navigatingDisplayContent = null,
notificationIcon = R.drawable.navigation_48px
)
)
listener.navigationStateChanged(
isNavigating = false,
isRerouting = true,
hasArrived = false,
destinations = null,
steps = null,
nextDestinationTravelEstimate = null,
nextStepRemainingDistance = null,
shouldShowNextStep = false,
shouldShowLanes = false,
junctionImage = null
)
}
/** Starts navigation. */
fun stopNavigation() {
// if (navigationManagerInitialized)
// navigationManager.navigationEnded()
listener.navigationStateChanged(
false,
isRerouting = false,
hasArrived = false,
destinations = null,
steps = null,
nextDestinationTravelEstimate = null,
nextStepRemainingDistance = null,
shouldShowNextStep = false,
shouldShowLanes = false,
junctionImage = null,
)
stopForeground(STOP_FOREGROUND_REMOVE)
stopSelf()
}
private fun createNotificationChannel() {
notificationManager =
getSystemService(NotificationManager::class.java)
val name: CharSequence = getString(R.string.navigation_settings)
val serviceChannel =
NotificationChannel(
channelId,
name,
NotificationManager.IMPORTANCE_HIGH
)
notificationManager!!.createNotificationChannel(serviceChannel)
}
/** Returns the [NotificationCompat] used as part of the foreground service. */
private fun getNotification(
shouldNotify: Boolean,
showInCar: Boolean,
navigatingDisplayTitle: CharSequence?,
navigatingDisplayContent: CharSequence?,
notificationIcon: Int
): Notification {
val builder: NotificationCompat.Builder =
NotificationCompat.Builder(this, channelId)
//.setContentIntent(createMainActivityPendingIntent())
.setContentTitle(navigatingDisplayTitle)
.setContentText(navigatingDisplayContent)
.setOngoing(true)
.setCategory(NotificationCompat.CATEGORY_NAVIGATION)
.setOnlyAlertOnce(!shouldNotify) // Set the notification's background color on the car screen.
.setColor(
"#003000".toColorInt()
)
.setColorized(true)
.setSmallIcon(R.drawable.ic_pan_24)
.setLargeIcon(
BitmapFactory.decodeResource(resources, notificationIcon)
)
.setTicker(navigatingDisplayTitle)
.setWhen(System.currentTimeMillis())
builder.setChannelId(channelId)
builder.setPriority(NotificationManager.IMPORTANCE_HIGH)
if (showInCar) {
val intent = Intent(Intent.ACTION_VIEW)
.setComponent(ComponentName(this, NavigationCarAppService::class.java))
.setData(NavigationCarAppService().createDeepLinkUri(Intent.ACTION_VIEW))
builder.extend(
CarAppExtender.Builder()
.setImportance(NotificationManagerCompat.IMPORTANCE_HIGH)
.setContentIntent(
CarPendingIntent.getCarApp(
this, intent.hashCode(),
intent,
0
)
)
.build()
)
}
return builder.build()
}
// private fun createMainActivityPendingIntent(): PendingIntent? {
// val intent: Intent = Intent(this, MainActivity::class.java)
// intent.putExtra(EXTRA_STARTED_FROM_NOTIFICATION, true)
// return PendingIntent.getActivity(this, 0, intent, PendingIntent.FLAG_IMMUTABLE)
// }
}

View File

@@ -3,7 +3,6 @@ package com.kouros.navigation.car.navigation
import android.text.SpannableString
import android.text.SpannableStringBuilder
import android.text.Spanned
import android.util.Log
import androidx.annotation.StringRes
import androidx.car.app.AppManager
import androidx.car.app.CarContext
@@ -92,8 +91,17 @@ class RouteCarModel : RouteModel() {
return step
}
fun travelEstimate(carContext: CarContext, distanceMode: Int): TravelEstimate {
val timeLeft = routeCalculator.travelLeftTime()
fun travelEstimateTrip(carContext: CarContext, distanceMode: Int): TravelEstimate {
return travelEstimate(carContext, routeCalculator.travelLeftTime(), distanceMode)
}
fun travelEstimateStep(carContext: CarContext, distanceMode: Int): TravelEstimate {
return travelEstimate(carContext, routeCalculator.travelStepLeftTime(), distanceMode)
}
fun travelEstimate(carContext: CarContext, timeLeft: Double, distanceMode: Int): TravelEstimate {
val timeToDestinationMillis =
TimeUnit.SECONDS.toMillis(timeLeft.toLong())
val distance = formattedDistance(distanceMode, routeCalculator.travelLeftDistance())

View File

@@ -4,7 +4,7 @@ import android.location.Location
import android.location.LocationManager
import android.os.SystemClock
import androidx.lifecycle.LifecycleCoroutineScope
import com.kouros.android.cars.carappservice.BuildConfig
import com.kouros.data.BuildConfig
import com.kouros.navigation.data.tomtom.TomTomRepository
import io.ticofab.androidgpxparser.parser.GPXParser
import io.ticofab.androidgpxparser.parser.domain.Gpx

View File

@@ -169,7 +169,7 @@ open class NavigationScreen(
.setNavigationInfo(
getRoutingInfo()
)
.setDestinationTravelEstimate(routeModel.travelEstimate(carContext, distanceMode))
.setDestinationTravelEstimate(routeModel.travelEstimateTrip(carContext, distanceMode))
.setActionStrip(actionStripBuilder.build())
.setMapActionStrip(
mapActionStrip(
@@ -627,9 +627,11 @@ open class NavigationScreen(
.build()
tripBuilder.addDestination(
destination,
routeModel.travelEstimate(carContext, distanceMode)
routeModel.travelEstimateTrip(carContext, distanceMode)
)
tripBuilder.setLoading(false)
tripBuilder.setCurrentRoad(routeModel.currentStep.street)
tripBuilder.addStep(routeModel.currentStep(carContext), routeModel.travelEstimateStep(carContext, distanceMode ))
listener.updateTrip(tripBuilder.build())
}
}

View File

@@ -3,9 +3,9 @@ package com.kouros.navigation.car.screen
import android.net.Uri
import android.text.Spannable
import android.text.SpannableString
import android.util.Log
import androidx.car.app.CarContext
import androidx.car.app.CarToast
import androidx.car.app.OnScreenResultListener
import androidx.car.app.Screen
import androidx.car.app.model.Action
import androidx.car.app.model.CarIcon

View File

@@ -18,6 +18,7 @@ android {
buildFeatures {
compose = true
buildConfig = true
}
buildTypes {

View File

@@ -10,15 +10,24 @@ data class NavigationState (
val iconMapper: IconMapper = IconMapper(),
val navigating: Boolean = false,
val arrived: Boolean = false,
// travel message
val travelMessage: String = "",
// maneuver type
val maneuverType: Int = 0,
// last location
val lastLocation: Location = location(0.0, 0.0),
// current location
val currentLocation: Location = location(0.0, 0.0),
// bearing of the route
val routeBearing: Float = 0F,
// index of current route in the list of routes
val currentRouteIndex: Int = 0,
// destination name
val destination: Place = Place(),
// car connection used
val carConnection: Int = 0,
// routing engine used
val routingEngine: Int = 0,
// show next step information
val nextStep: Boolean = false,
)

View File

@@ -2,6 +2,7 @@ package com.kouros.navigation.data.tomtom
import android.content.Context
import android.location.Location
import com.kouros.data.BuildConfig
import com.kouros.data.R
import com.kouros.navigation.data.EngineType
import com.kouros.navigation.data.NavigationRepository
@@ -20,9 +21,9 @@ const val tomtomTrafficUrl = "https://api.tomtom.com/traffic/services/5/incident
private const val tomtomFields =
"{incidents{type,geometry{type,coordinates},properties{iconCategory,events{description}}}}"
const val useLocal = false
val useLocal = BuildConfig.DEBUG
const val useLocalTraffic = false
val useLocalTraffic = BuildConfig.DEBUG
class TomTomRepository : NavigationRepository() {

View File

@@ -54,6 +54,18 @@ class RouteCalculator(var routeModel: RouteModel) {
return timeLeft
}
fun travelStepLeftTime(): Double {
var timeLeft = 0.0
// time for current step
val step = routeModel.route.currentStep()
val curTime = step.duration
val percent =
100 * (step.maneuver.waypoints.size - step.waypointIndex) / (step.maneuver.waypoints.size)
val time = curTime * percent / 100
timeLeft += time
return timeLeft
}
/** Returns the current [Step] left distance in m. */
fun leftStepDistance(): Double {
val step = routeModel.route.currentStep()