Notification

This commit is contained in:
Dimitris
2026-03-27 13:40:14 +01:00
parent 2348d3b633
commit 90010d91b7
6 changed files with 209 additions and 88 deletions

View File

@@ -41,8 +41,7 @@
</intent-filter> </intent-filter>
</activity> </activity>
<service <service
android:name="com.kouros.navigation.car.navigation.NavigationService" android:name=".car.NavigationNotificationService"
android:enabled="true"
android:foregroundServiceType="location" android:foregroundServiceType="location"
android:exported="true"> android:exported="true">
</service> </service>

View File

@@ -10,6 +10,7 @@
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" /> <uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
<uses-permission android:name="android.car.permission.CAR_SPEED"/> <uses-permission android:name="android.car.permission.CAR_SPEED"/>
<uses-permission android:name="androidx.car.app.ACCESS_SURFACE" /> <uses-permission android:name="androidx.car.app.ACCESS_SURFACE" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-feature <uses-feature
android:name="android.hardware.type.automotive" android:name="android.hardware.type.automotive"
@@ -71,8 +72,7 @@
android:value="true" /> android:value="true" />
</activity> </activity>
<service <service
android:name="com.kouros.navigation.car.navigation.NavigationService" android:name=".car.NavigationNotificationService"
android:enabled="true"
android:foregroundServiceType="location" android:foregroundServiceType="location"
android:exported="true"> android:exported="true">
</service> </service>

View File

@@ -9,6 +9,7 @@ import android.os.Handler
import android.os.IBinder import android.os.IBinder
import android.os.Looper import android.os.Looper
import android.os.Message import android.os.Message
import android.util.Log
import androidx.car.app.notification.CarAppExtender import androidx.car.app.notification.CarAppExtender
import androidx.car.app.notification.CarNotificationManager import androidx.car.app.notification.CarNotificationManager
import androidx.car.app.notification.CarPendingIntent import androidx.car.app.notification.CarPendingIntent
@@ -16,6 +17,7 @@ import androidx.core.app.NotificationChannelCompat
import androidx.core.app.NotificationCompat import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat import androidx.core.app.NotificationManagerCompat
import com.kouros.data.R import com.kouros.data.R
import com.kouros.navigation.data.Constants.TAG
import java.math.RoundingMode import java.math.RoundingMode
import java.text.DecimalFormat import java.text.DecimalFormat
import java.util.concurrent.TimeUnit import java.util.concurrent.TimeUnit
@@ -44,17 +46,12 @@ class NavigationNotificationService : Service() {
Handler(Looper.getMainLooper(), HandlerCallback()) Handler(Looper.getMainLooper(), HandlerCallback())
override fun onStartCommand(intent: Intent, flags: Int, startId: Int): Int { override fun onStartCommand(intent: Intent, flags: Int, startId: Int): Int {
val message = intent.getStringExtra("EXTRA_MESSAGE") ?: "Navigating..."
initNotifications(this) initNotifications(this)
startForeground( val notification = getNavigationNotification(this, message)
NAV_NOTIFICATION_ID, // This updates the existing notification if the service is already running
getNavigationNotification(this, mNotificationCount).build() CarNotificationManager.from(this).notify(NAV_NOTIFICATION_ID, notification)
) startForeground(NAV_NOTIFICATION_ID, notification.build())
// Start updating the notification continuously.
mHandler.sendMessageDelayed(
mHandler.obtainMessage(MSG_SEND_NOTIFICATION), NAV_NOTIFICATION_DELAY_IN_MILLIS
)
return START_NOT_STICKY return START_NOT_STICKY
} }
@@ -71,11 +68,12 @@ class NavigationNotificationService : Service() {
*/ */
internal inner class HandlerCallback : Handler.Callback { internal inner class HandlerCallback : Handler.Callback {
override fun handleMessage(msg: Message): Boolean { override fun handleMessage(msg: Message): Boolean {
Log.d(TAG, "Notification handleMessage: $msg")
if (msg.what == MSG_SEND_NOTIFICATION) { if (msg.what == MSG_SEND_NOTIFICATION) {
val context: Context = this@NavigationNotificationService val context: Context = this@NavigationNotificationService
CarNotificationManager.from(context).notify( CarNotificationManager.from(context).notify(
NAV_NOTIFICATION_ID, NAV_NOTIFICATION_ID,
getNavigationNotification(context, mNotificationCount) getNavigationNotification(context, "Nachricht")
) )
mNotificationCount++ mNotificationCount++
mHandler.sendMessageDelayed( mHandler.sendMessageDelayed(
@@ -96,6 +94,12 @@ class NavigationNotificationService : Service() {
val mOnlyAlertOnce: Boolean val mOnlyAlertOnce: Boolean
) )
fun startForeground(message: String) {
startForeground(
NAV_NOTIFICATION_ID,
getNavigationNotification(this, message).build()
)
}
companion object { companion object {
private const val MSG_SEND_NOTIFICATION = 1 private const val MSG_SEND_NOTIFICATION = 1
private const val NAV_NOTIFICATION_CHANNEL_ID = "nav_channel_00" private const val NAV_NOTIFICATION_CHANNEL_ID = "nav_channel_00"
@@ -124,13 +128,32 @@ class NavigationNotificationService : Service() {
CarNotificationManager.from(context).createNotificationChannel(navChannel) CarNotificationManager.from(context).createNotificationChannel(navChannel)
} }
/**
* Returns a [DirectionInfo] that corresponds to the given notification count.
*
*
* There are 5 directions, repeating in order. For each direction, the alert will only show
* once, but the distance will update on every count on the rail widget.
*/
private fun getDirectionInfo(context: Context, message: String): DirectionInfo {
val formatter = DecimalFormat("#.##")
formatter.setRoundingMode(RoundingMode.DOWN)
val distance = formatter.format((10) * 0.1) + "km"
return DirectionInfo(
message,
distance,
R.drawable.navigation_48px,
false
)
}
/** Returns the navigation notification that corresponds to the given notification count. */ /** Returns the navigation notification that corresponds to the given notification count. */
fun getNavigationNotification( fun getNavigationNotification(
context: Context, notificationCount: Int context: Context
): NotificationCompat.Builder { ): NotificationCompat.Builder {
val builder = val builder =
NotificationCompat.Builder(context, NAV_NOTIFICATION_CHANNEL_ID) NotificationCompat.Builder(context, NAV_NOTIFICATION_CHANNEL_ID)
val directionInfo = getDirectionInfo(context, notificationCount) val directionInfo = getDirectionInfo(context, "Test")
// Set an intent to open the car app. The app receives this intent when the user taps the // Set an intent to open the car app. The app receives this intent when the user taps the
// heads-up notification or the rail widget. // heads-up notification or the rail widget.
@@ -179,54 +202,59 @@ class NavigationNotificationService : Service() {
) )
} }
/** fun getNavigationNotification(
* Returns a [DirectionInfo] that corresponds to the given notification count. context: Context, message: String
* ): NotificationCompat.Builder {
* val builder =
* There are 5 directions, repeating in order. For each direction, the alert will only show NotificationCompat.Builder(context, NAV_NOTIFICATION_CHANNEL_ID)
* once, but the distance will update on every count on the rail widget. val directionInfo = getDirectionInfo(context, message)
*/
private fun getDirectionInfo(context: Context, notificationCount: Int): DirectionInfo { // Set an intent to open the car app. The app receives this intent when the user taps the
val formatter = DecimalFormat("#.##") // heads-up notification or the rail widget.
formatter.setRoundingMode(RoundingMode.DOWN) val pendingIntent = CarPendingIntent.getCarApp(
val repeatingCount = notificationCount % 35 context,
if (repeatingCount in 0..<10) { NavigationCarAppService().intentActionNavNotificationOpenApp.hashCode(),
// Distance decreases from 1km to 0.1km Intent(
val distance = formatter.format((10 - repeatingCount) * 0.1) + "km" NavigationCarAppService().intentActionNavNotificationOpenApp
return DirectionInfo( ).setComponent(
context.getString(R.string.stop_action_title), ComponentName(
distance, context,
R.drawable.arrow_back_24px, NavigationCarAppService()::class.java
repeatingCount > 0
) )
} else if (repeatingCount in 10..<20) { ).setData(
// Distance decreases from 5km to 0.5km NavigationCarAppService().createDeepLinkUri(
val distance = formatter.format((20 - repeatingCount) * 0.5) + "km" NavigationCarAppService().intentActionNavNotificationOpenApp
return DirectionInfo(
context.getString(R.string.route_preview),
distance,
R.drawable.ic_turn_normal_right, /* onlyAlertOnce= */
repeatingCount > 10
) )
} else if (repeatingCount in 20..<25) { ),
// Distance decreases from 200m to 40m 0
val distance = formatter.format(((25 - repeatingCount) * 40).toLong()) + "m"
return DirectionInfo(
context.getString(R.string.route_preview),
distance,
R.drawable.navigation_48px, /* onlyAlertOnce= */
repeatingCount > 20
) )
} else {
// Distance decreases from 1km to 0.1km return builder
val distance = formatter.format((35 - repeatingCount) * 0.1) + "km" // This title, text, and icon will be shown in both phone and car screen. These
return DirectionInfo( // values can
context.getString(R.string.charging_station), // be overridden in the extender below, to customize notifications in the car
distance, // screen.
R.drawable.local_gas_station_24, .setContentTitle(directionInfo.mTitle)
repeatingCount > 25 .setContentText(directionInfo.mDistance)
.setSmallIcon(directionInfo.mIcon) // The notification must be set to 'ongoing' and its category must be set to
// CATEGORY_NAVIGATION in order to show it in the rail widget when the app is
// navigating on
// the background.
// These values cannot be overridden in the extender.
.setOngoing(true)
.setCategory(NotificationCompat.CATEGORY_NAVIGATION) // If set to true, the notification will only show the alert once in both phone and
// car screen. This value cannot be overridden in the extender.
.setOnlyAlertOnce(directionInfo.mOnlyAlertOnce) // This extender must be set in order to display the notification in the car screen.
// The extender also allows various customizations, such as showing different title
// or icon on the car screen.
.extend(
CarAppExtender.Builder()
.setContentIntent(pendingIntent)
.build()
) )
} }
} }
} }
}

View File

@@ -100,6 +100,9 @@ class NavigationSession : Session(), NavigationListener, NavigationObserverCallb
lateinit var textToSpeechManager: TextToSpeechManager lateinit var textToSpeechManager: TextToSpeechManager
lateinit var notificationManager: NotificationManager
var autoDriveEnabled = false var autoDriveEnabled = false
val simulation = Simulation() val simulation = Simulation()
@@ -117,12 +120,24 @@ class NavigationSession : Session(), NavigationListener, NavigationObserverCallb
var navigationManagerStarted = false var navigationManagerStarted = false
var notificationActive = false
/** /**
* Lifecycle observer for managing session lifecycle events. * Lifecycle observer for managing session lifecycle events.
* Cleans up resources when the session is destroyed. * Cleans up resources when the session is destroyed.
*/ */
private val lifecycleObserver: LifecycleObserver = object : DefaultLifecycleObserver { private val lifecycleObserver: LifecycleObserver = object : DefaultLifecycleObserver {
override fun onPause(owner: LifecycleOwner) {
Log.d(TAG, "NavigationSession paused")
super.onPause(owner)
}
override fun onResume(owner: LifecycleOwner) {
Log.d(TAG, "NavigationSession resumed")
super.onResume(owner)
}
override fun onDestroy(owner: LifecycleOwner) { override fun onDestroy(owner: LifecycleOwner) {
if (::navigationManager.isInitialized) { if (::navigationManager.isInitialized) {
navigationManager.clearNavigationManagerCallback() navigationManager.clearNavigationManagerCallback()
@@ -136,6 +151,13 @@ class NavigationSession : Session(), NavigationListener, NavigationObserverCallb
if (::textToSpeechManager.isInitialized) { if (::textToSpeechManager.isInitialized) {
textToSpeechManager.cleanup() textToSpeechManager.cleanup()
} }
carContext
.stopService(
Intent(
carContext,
NavigationNotificationService::class.java
)
)
Log.i(TAG, "NavigationSession destroyed") Log.i(TAG, "NavigationSession destroyed")
} }
} }
@@ -314,6 +336,7 @@ class NavigationSession : Session(), NavigationListener, NavigationObserverCallb
repository.guidanceAudioFlow.asLiveData().observe(this, Observer { repository.guidanceAudioFlow.asLiveData().observe(this, Observer {
guidanceAudio = it guidanceAudio = it
}) })
notificationManager = NotificationManager(carContext, this)
} }
/** /**
@@ -548,23 +571,6 @@ class NavigationSession : Session(), NavigationListener, NavigationObserverCallb
} }
} }
/**
* Stops active navigation and clears route state.
* Called when user exits navigation or arrives at destination.
*/
override fun stopNavigation() {
routeModel.stopNavigation()
navigationManager.navigationEnded()
if (autoDriveEnabled) {
simulation.stopSimulation()
autoDriveEnabled = false
}
surfaceRenderer.routeData.value = ""
lastCameraSearch = 0
surfaceRenderer.viewStyle = ViewStyle.VIEW
navigationScreen.navigationType = NavigationType.VIEW
}
/** /**
* Start navigation process. * Start navigation process.
* Called when user starts navigation * Called when user starts navigation
@@ -580,6 +586,27 @@ class NavigationSession : Session(), NavigationListener, NavigationObserverCallb
updateLocation(location) updateLocation(location)
} }
} }
if (notificationActive)
notificationManager.startNotificationService()
}
/**
* Stops active navigation and clears route state.
* Called when user exits navigation or arrives at destination.
*/
override fun stopNavigation() {
routeModel.stopNavigation()
navigationManager.navigationEnded()
if (autoDriveEnabled) {
simulation.stopSimulation()
autoDriveEnabled = false
}
surfaceRenderer.routeData.value = ""
lastCameraSearch = 0
surfaceRenderer.viewStyle = ViewStyle.VIEW
navigationScreen.navigationType = NavigationType.VIEW
if (notificationActive)
notificationManager.stopNotificationService()
} }
override fun updateTrip(trip: Trip) { override fun updateTrip(trip: Trip) {
@@ -598,6 +625,9 @@ class NavigationSession : Session(), NavigationListener, NavigationObserverCallb
if (currentStep.index > lastStepIndex && stepData.leftStepDistance < INSTRUCTION_DISTANCE) { if (currentStep.index > lastStepIndex && stepData.leftStepDistance < INSTRUCTION_DISTANCE) {
textToSpeechManager.speak(stepData.message) textToSpeechManager.speak(stepData.message)
lastStepIndex = currentStep.index lastStepIndex = currentStep.index
if (notificationActive) {
notificationManager.sendMessage(stepData.message)
}
} }
} }
@@ -691,11 +721,11 @@ class NavigationSession : Session(), NavigationListener, NavigationObserverCallb
* Loads a route to the specified place and sets it as the destination. * Loads a route to the specified place and sets it as the destination.
*/ */
override fun navigateToPlace(place: Place) { override fun navigateToPlace(place: Place) {
val preview = place.route //navigationViewModel.previewRoute.value val preview = place.route
navigationViewModel.previewRoute.value = "" navigationViewModel.previewRoute.value = ""
val location = location(place.longitude, place.latitude) val location = location(place.longitude, place.latitude)
navigationViewModel.saveRecent(carContext, place) navigationViewModel.saveRecent(carContext, place)
//currentNavigationLocation = location routeModel.navState = routeModel.navState.copy(destination = place)
if (preview.isEmpty()) { if (preview.isEmpty()) {
navigationViewModel.loadRoute( navigationViewModel.loadRoute(
carContext, carContext,
@@ -707,7 +737,6 @@ class NavigationSession : Session(), NavigationListener, NavigationObserverCallb
routeModel.navState = routeModel.navState.copy(currentRouteIndex = place.routeIndex) routeModel.navState = routeModel.navState.copy(currentRouteIndex = place.routeIndex)
onRouteReceived(preview) onRouteReceived(preview)
} }
routeModel.navState = routeModel.navState.copy(destination = place)
surfaceRenderer.activateNavigationView() surfaceRenderer.activateNavigationView()
} }

View File

@@ -0,0 +1,62 @@
package com.kouros.navigation.car
import android.content.Intent
import android.location.Location
import android.os.Message
import android.util.Log
import androidx.car.app.CarContext
import androidx.car.app.hardware.CarHardwareManager
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleOwner
import androidx.lifecycle.lifecycleScope
import androidx.lifecycle.repeatOnLifecycle
import com.kouros.navigation.data.Constants.TAG
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.launch
class NotificationManager(
private val carContext: CarContext,
private val lifecycleOwner: LifecycleOwner,
) {
private var notificationServiceStarted = false
private var serviceStarted = false
init {
lifecycleOwner.lifecycleScope.launch {
lifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) {
}
lifecycleOwner.repeatOnLifecycle(Lifecycle.State.DESTROYED) {
if (notificationServiceStarted) {
stopNotificationService()
}
}
}
}
fun startNotificationService() {
val intent = Intent(carContext, NavigationNotificationService::class.java)
carContext.startForegroundService(intent)
notificationServiceStarted = true
}
fun stopNotificationService() {
carContext
.stopService(
Intent(
carContext,
NavigationNotificationService::class.java
)
)
notificationServiceStarted = false
}
fun sendMessage(message: String) {
val intent = Intent(carContext, NavigationNotificationService::class.java).apply {
putExtra("EXTRA_MESSAGE", message)
}
carContext.startForegroundService(intent)
}
}

View File

@@ -1,7 +1,11 @@
package com.kouros.navigation.car.screen package com.kouros.navigation.car.screen
import android.content.Context
import android.content.Intent
import android.location.Location import android.location.Location
import android.location.LocationManager import android.location.LocationManager
import android.os.Build
import android.os.Build.VERSION_CODES
import android.os.CountDownTimer import android.os.CountDownTimer
import android.os.Handler import android.os.Handler
import androidx.car.app.CarContext import androidx.car.app.CarContext
@@ -31,6 +35,7 @@ import androidx.lifecycle.Observer
import androidx.lifecycle.asLiveData import androidx.lifecycle.asLiveData
import androidx.lifecycle.lifecycleScope import androidx.lifecycle.lifecycleScope
import com.kouros.data.R import com.kouros.data.R
import com.kouros.navigation.car.NavigationNotificationService
import com.kouros.navigation.car.SurfaceRenderer import com.kouros.navigation.car.SurfaceRenderer
import com.kouros.navigation.car.screen.settings.SettingsScreen import com.kouros.navigation.car.screen.settings.SettingsScreen
import com.kouros.navigation.data.Constants import com.kouros.navigation.data.Constants
@@ -54,8 +59,6 @@ open class NavigationScreen(
private val navigationViewModel: NavigationViewModel private val navigationViewModel: NavigationViewModel
) : Screen(carContext) { ) : Screen(carContext) {
var currentNavigationLocation = Location(LocationManager.GPS_PROVIDER)
var recentPlaces = mutableListOf<Place>() var recentPlaces = mutableListOf<Place>()
var recentPlace: Place = Place() var recentPlace: Place = Place()
@@ -269,7 +272,7 @@ open class NavigationScreen(
val listBuilder = ItemList.Builder() val listBuilder = ItemList.Builder()
recentPlaces.filter { it.category == Constants.RECENT && it.distance > 300F }.forEach { recentPlaces.filter { it.category == Constants.RECENT && it.distance > 300F }.forEach {
val row = Row.Builder() val row = Row.Builder()
.setTitle(it.name!!) .setTitle(it.name)
.addAction( .addAction(
createNavigateAction(it) createNavigateAction(it)
) )
@@ -443,7 +446,7 @@ open class NavigationScreen(
if (place.longitude == 0.0) { if (place.longitude == 0.0) {
navigationViewModel.findAddress( navigationViewModel.findAddress(
"${obj.city} ${obj.street}},", "${obj.city} ${obj.street}},",
currentNavigationLocation surfaceRenderer.lastLocation
) )
// result see observer // result see observer
} else { } else {