Headless navigation
Contents
Headless Navigation means integrating the CoPilot Native Navigation SDK without relying on the standard UI components (NavigationView). This approach allows developers to design a fully custom user interface while still leveraging the full power of the navigation engine for:
- Route calculation and guidance
- Turn-by-turn instructions
- Voice guidance triggers
- Real-time location tracking (road-snapped)
- Off-route detection
- Arrival detection
Key Difference from Standard Navigation
| Feature | Standard Navigation | Headless Navigation |
|---|---|---|
| UI Components | Pre-built NavigationView | Custom (Compose, XML, etc.) |
| Map Display | Integrated | Optional/Custom |
| Voice Playback | Built-in | Custom TTS integration |
| Turn Cards | Built-in | Custom via observers |
| Flexibility | Limited | Full control |
When to Use Headless Navigation
Use Headless Navigation When:
- Custom UI Requirements: You need a completely custom navigation interface that doesn’t fit the standard NavigationView paradigm
- Embedded Displays: Building for specialized hardware (fleet devices, embedded systems) with unique display requirements
- Voice-Only Navigation: Building apps where visual navigation is secondary or optional
- Background Navigation: Running navigation in a service without any UI
- Integration with Existing Apps: Adding navigation to an existing app with established UI patterns
- Gaming or AR Applications: Overlaying navigation data on non-traditional interfaces
Use Standard NavigationView When:
- Rapid development is prioritized
- Standard turn-by-turn UI is acceptable
- You want built-in map camera following
- You need route preview functionality out-of-the-box
Version History
- SDK Version 1.1.0: Current documented version
- Minimum SDK: 26
- Target SDK: 33/34
SDK Dependencies
Add the following dependencies to your build.gradle:
dependencies {
// Core Navigation SDK (required)
implementation "com.trimblemaps.navigation:ui-components:1.1.0"
// Maps SDK Services for geocoding/directions (required)
implementation "com.trimblemaps.mapsdk:maps-sdk-services:1.1.0"
// Optional: Route rendering plugin (if displaying routes on a map)
implementation "com.trimblemaps.mapsdk:maps-android-plugin-route:1.1.1"
}
Required Permissions
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
<!-- For background navigation -->
<uses-permission android:name="android.permission.ACCESS_BACKGROUND_LOCATION" />
Core Components
TrimbleMapsNavigationProvider
The central provider for creating and managing the navigation instance.
import com.trimblemaps.navigation.core.TrimbleMapsNavigationProvider
import com.trimblemaps.navigation.core.TrimbleMapsNavigation
// Create navigation instance
val navigation: TrimbleMapsNavigation = TrimbleMapsNavigationProvider.create(options)
// Retrieve existing instance (if already created)
val navigation = TrimbleMapsNavigationProvider.retrieve()
// Destroy instance (call when done)
TrimbleMapsNavigationProvider.destroy()
TrimbleMapsTripProvider
Manages trip configuration including stops, routing profiles, and route options.
import com.trimblemaps.navigation.core.TrimbleMapsTripProvider
import com.trimblemaps.navigation.core.TrimbleMapsTrip
// Create a new trip
val trip: TrimbleMapsTrip = TrimbleMapsTripProvider.create()
// Retrieve existing trip
val trip = TrimbleMapsTripProvider.retrieve()
// Destroy trip (call when done)
TrimbleMapsTripProvider.destroy()
NavigationOptions
Configuration for the navigation engine.
import com.trimblemaps.navigation.base.options.NavigationOptions
import com.trimblemaps.navigation.core.internal.formatter.TrimbleMapsDistanceFormatter
val options = NavigationOptions.Builder(context)
.isFromNavigationUi(false) // CRITICAL: Set to false for headless mode
.distanceFormatter(TrimbleMapsDistanceFormatter.Builder(context).build())
.build()
Important Notes:
- Setting
.isFromNavigationUi(false)is required for headless navigation. This tells the SDK not to expect a NavigationView. - A
distanceFormatteris required when using headless navigation. Without it, the navigation session will fail to start properly. Always provide aTrimbleMapsDistanceFormatteror your ownDistanceFormatterin yourNavigationOptions.
TripStop & TrimbleMapsLocation
Building blocks for defining route waypoints.
import com.trimblemaps.api.geocoding.v1.models.TrimbleMapsLocation
import com.trimblemaps.navigation.core.trip.model.TripStop
val stop = TripStop(
TrimbleMapsLocation.builder()
.coords(latitude, longitude)
.placeName("Stop Name")
.build()
)
Setup and Initialization
Step 1: Create Navigation Options
val options = NavigationOptions.Builder(context)
.isFromNavigationUi(false)
.distanceFormatter(TrimbleMapsDistanceFormatter.Builder(context).build())
.build()
Note: Both isFromNavigationUi(false) and distanceFormatter() are required for headless navigation. The navigation session will not function correctly without a distance formatter.
Step 2: Create Navigation Instance
val navigation = TrimbleMapsNavigationProvider.create(options)
Step 3: Configure the Trip
val trip = TrimbleMapsTripProvider.create()
// Add origin
trip.addStop(
TripStop(
TrimbleMapsLocation.builder()
.coords(40.361279, -74.600697)
.placeName("Origin")
.build()
)
)
// Add destination
trip.addStop(
TripStop(
TrimbleMapsLocation.builder()
.coords(40.349542, -74.660237)
.placeName("Destination")
.build()
)
)
// Optional: Configure routing profile
trip.options.apply {
routingProfile = yourRoutingProfile
language = "en"
alternatives = false
}
Step 4: Fetch Route and Start Navigation
val routeCallback = object : Callback<DirectionsResponse> {
override fun onResponse(
call: Call<DirectionsResponse>,
response: Response<DirectionsResponse>
) {
val routes = response.body()?.routes()
if (!routes.isNullOrEmpty()) {
navigation.setTrip(trip)
navigation.startTripSession()
}
}
override fun onFailure(call: Call<DirectionsResponse>, t: Throwable) {
Log.e("Navigation", "Route fetch failed: ${t.message}")
}
}
trip.fetchFullRoute(routeCallback)
Note: Use fetchFullRoute() when starting a new trip from the beginning. If a trip is already in progress (e.g., the user has passed some stops or you are resuming navigation), use fetchRemainingRoute() instead. This will calculate the route from the current position to the remaining stops, excluding any stops that have already been completed.
Observer Patterns
Headless navigation relies on observer callbacks to receive navigation updates. Register observers after creating the navigation instance.
Route Progress Observer
Provides real-time metrics about navigation progress.
navigation.registerRouteProgressObserver(object : RouteProgressObserver {
override fun onRouteProgressChanged(routeProgress: RouteProgress) {
// Distance remaining in meters
val distanceRemaining = routeProgress.distanceRemaining
// Duration remaining in seconds
val durationRemaining = routeProgress.durationRemaining
// Current step/leg progress
val legProgress = routeProgress.currentLegProgress
// Upcoming maneuver information
val upcomingStep = legProgress?.upcomingStep
if (upcomingStep != null) {
val maneuver = upcomingStep.maneuver()
val turnLocation = maneuver.location() // Lat/Lng of turn
val turnType = maneuver.type() // e.g., "turn", "arrive"
val turnModifier = maneuver.modifier() // e.g., "left", "right"
}
}
})
Key Properties of RouteProgress:
| Property | Type | Description |
|---|---|---|
| distanceRemaining | Float | Meters to destination |
| durationRemaining | Double | Seconds to destination |
| currentLegProgress | RouteLegProgress? | Progress on current leg |
| distanceTraveled | Float | Meters traveled so far |
| fractionTraveled | Float | 0.0 to 1.0 completion |
Voice Instructions Observer
Triggers when a voice announcement should be played.
navigation.registerVoiceInstructionsObserver(object : VoiceInstructionsObserver {
override fun onNewVoiceInstructions(voiceInstructions: VoiceInstructions) {
val textToSpeak = voiceInstructions.announcement()
// SSML version for enhanced TTS (if supported)
val ssmlAnnouncement = voiceInstructions.ssmlAnnouncement()
// Distance at which this instruction was triggered
val distanceAlongGeometry = voiceInstructions.distanceAlongGeometry()
// Play with Android TTS:
// textToSpeech.speak(textToSpeak, TextToSpeech.QUEUE_FLUSH, null, "nav_id")
}
})
Use Case: Integrate with android.speech.tts.TextToSpeech or third-party TTS engines.
Banner Instructions Observer
Provides visual instruction data for turn cards/banners.
navigation.registerBannerInstructionsObserver(object : BannerInstructionsObserver {
override fun onNewBannerInstructions(bannerInstructions: BannerInstructions) {
val primary = bannerInstructions.primary()
// Main instruction text
val instructionText = primary.text()
// Turn icon type (e.g., "turn", "merge", "roundabout")
val iconType = primary.type()
// Turn icon modifier (e.g., "left", "right", "sharp left")
val iconModifier = primary.modifier()
// Distance to this maneuver
val distanceToManeuver = bannerInstructions.distanceAlongGeometry()
// Secondary instruction (optional, e.g., "Then turn right")
val secondary = bannerInstructions.secondary()
if (secondary != null) {
val secondaryText = secondary.text()
}
// Sub-instruction for lane guidance (optional)
val sub = bannerInstructions.sub()
}
})
Icon Type/Modifier Combinations:
| Type | Modifiers | Description |
|---|---|---|
| turn | left, right, sharp left, sharp right, slight left, slight right, straight, uturn | Standard turns |
| merge | left, right, straight | Highway merges |
| off-ramp | left, right | Highway exits |
| fork | left, right | Road forks |
| roundabout | left, right | Roundabout navigation |
| arrive | left, right, straight | Arrival at destination |
Off-Route Observer
Detects when the user has deviated from the planned route.
navigation.registerOffRouteObserver(object : OffRouteObserver {
override fun onOffRouteStateChanged(offRoute: Boolean) {
if (offRoute) {
// User is off the planned route
// Trigger reroute logic:
// 1. Show "Rerouting..." in UI
// 2. Optionally fetch a new route from current location
}
}
})
Location Observer
Provides both raw GPS and road-snapped (enhanced) location updates.
navigation.registerLocationObserver(object : LocationObserver {
override fun onEnhancedLocationChanged(
enhancedLocation: Location,
keyPoints: List<Location>
) {
// Road-snapped location - use this for:
// - Map puck positioning
// - ETA calculations
// - Progress tracking
val lat = enhancedLocation.latitude
val lng = enhancedLocation.longitude
val bearing = enhancedLocation.bearing
val speed = enhancedLocation.speed
}
override fun onRawLocationChanged(rawLocation: Location) {
// Unprocessed GPS location
// Use for: debugging, accuracy indicators
}
})
Enhanced vs. Raw Location:
| Enhanced Location | Raw Location |
|---|---|
| Snapped to road geometry | Direct from GPS |
| Smoothed bearing | May jump/jitter |
| Better for navigation display | Better for accuracy metrics |
Arrival Observer
Detects arrival at waypoints and final destination.
navigation.registerArrivalObserver(object : ArrivalObserver {
override fun onFinalDestinationArrival(routeProgress: RouteProgress) {
// User has arrived at the final destination
// Show arrival screen, stop navigation, etc.
}
override fun onNextRouteLegStart(routeLegProgress: RouteLegProgress) {
// User has arrived at an intermediate stop
// and is starting the next leg
val legIndex = routeLegProgress.legIndex
// Update UI to show next stop info
}
})
Simulated Navigation
For testing and development purposes, you can simulate navigation along a route without requiring real GPS movement. This is useful for:
- Testing navigation logic without physical travel
- Demonstrating navigation features
- Automated testing
- QA validation
Required Imports
import com.trimblemaps.navigation.core.replay.TrimbleMapsReplayer
import com.trimblemaps.navigation.core.replay.ReplayLocationEngine
import com.trimblemaps.navigation.core.replay.route.ReplayProgressObserver
Setting Up Simulation
Step 1: Create Replay Components
// Create the replayer and location engine
private val trimbleMapsReplayer = TrimbleMapsReplayer()
private val replayLocationEngine = ReplayLocationEngine(trimbleMapsReplayer)
Step 2: Configure NavigationOptions with ReplayLocationEngine
Replace the default location engine with the replay location engine:
val options = NavigationOptions.Builder(context)
.isFromNavigationUi(false)
.distanceFormatter(TrimbleMapsDistanceFormatter.Builder(context).build())
.locationEngine(replayLocationEngine) // Use replay engine instead of real GPS
.build()
navigation = TrimbleMapsNavigationProvider.create(options)
Step 3: Register ReplayProgressObserver and Start Playback
After starting the trip session, register the ReplayProgressObserver and begin playback:
// After navigation.startTripSession()
// Register the replay observer to auto-feed route geometry as navigation progresses
navigation.registerRouteProgressObserver(ReplayProgressObserver(trimbleMapsReplayer))
// Set playback speed (1.0 = real-time, 2.0 = 2x speed, etc.)
val simulationSpeedMultiplier = 2.0
trimbleMapsReplayer.playbackSpeed(simulationSpeedMultiplier)
// Start the simulation
trimbleMapsReplayer.play()
Step 4: Cleanup on Destroy
Always finish the replayer when done:
fun onDestroy() {
trimbleMapsReplayer.finish() // Stop simulation
navigation.stopTripSession()
navigation.onDestroy()
}
Complete Simulation Example
class SimulatedNavigationManager(
private val context: Context,
private val onStatusUpdate: (String) -> Unit = {}
) {
private val navigation: TrimbleMapsNavigation
// Simulation components
private val trimbleMapsReplayer = TrimbleMapsReplayer()
private val replayLocationEngine = ReplayLocationEngine(trimbleMapsReplayer)
init {
val options = NavigationOptions.Builder(context)
.isFromNavigationUi(false)
.distanceFormatter(TrimbleMapsDistanceFormatter.Builder(context).build())
.locationEngine(replayLocationEngine)
.build()
navigation = TrimbleMapsNavigationProvider.create(options)
setupObservers()
}
fun startSimulatedTrip(trip: TrimbleMapsTrip, speedMultiplier: Double = 2.0) {
trip.fetchFullRoute(object : Callback<DirectionsResponse> {
override fun onResponse(
call: Call<DirectionsResponse>,
response: Response<DirectionsResponse>
) {
val routes = response.body()?.routes()
if (routes.isNullOrEmpty()) {
onStatusUpdate("Error: No routes found")
return
}
navigation.setTrip(trip)
navigation.startTripSession()
// Setup simulation
navigation.registerRouteProgressObserver(
ReplayProgressObserver(trimbleMapsReplayer)
)
trimbleMapsReplayer.playbackSpeed(speedMultiplier)
trimbleMapsReplayer.play()
onStatusUpdate("Simulating at ${speedMultiplier}x speed")
}
override fun onFailure(call: Call<DirectionsResponse>, t: Throwable) {
onStatusUpdate("Error: ${t.message}")
}
})
}
fun onDestroy() {
trimbleMapsReplayer.finish()
navigation.stopTripSession()
navigation.onDestroy()
}
private fun setupObservers() {
// Register your other observers here (RouteProgress, Voice, Banner, etc.)
}
}
Simulation Speed Reference
| Speed Multiplier | Description |
|---|---|
| 0.5 | Half speed (slow motion) |
| 1.0 | Real-time speed |
| 2.0 | 2x speed (recommended for testing) |
| 5.0 | 5x speed (quick testing) |
| 10.0 | 10x speed (rapid testing) |
Important Notes
- The
ReplayProgressObserverautomatically feeds simulated locations based on route progress - Simulation uses the route geometry to generate realistic location updates
- All observers (RouteProgress, Voice, Banner, etc.) will fire as if real navigation is occurring
- Off-route detection will not trigger during simulation since locations follow the route exactly
Complete Code Example
NavigationManagerSkeleton.kt
A complete, production-ready skeleton for headless navigation:
package com.example.navigation
import android.annotation.SuppressLint
import android.content.Context
import android.location.Location
import android.util.Log
import com.trimblemaps.api.directions.v1.models.BannerInstructions
import com.trimblemaps.api.directions.v1.models.DirectionsResponse
import com.trimblemaps.api.directions.v1.models.VoiceInstructions
import com.trimblemaps.navigation.base.options.NavigationOptions
import com.trimblemaps.navigation.base.trip.model.RouteLegProgress
import com.trimblemaps.navigation.base.trip.model.RouteProgress
import com.trimblemaps.navigation.core.TrimbleMapsNavigation
import com.trimblemaps.navigation.core.TrimbleMapsNavigationProvider
import com.trimblemaps.navigation.core.TrimbleMapsTrip
import com.trimblemaps.navigation.core.arrival.ArrivalObserver
import com.trimblemaps.navigation.core.trip.session.*
import com.trimblemaps.navigation.core.internal.formatter.TrimbleMapsDistanceFormatter
import retrofit2.Call
import retrofit2.Callback
import retrofit2.Response
/**
* Headless Navigation Manager
*
* Handles all navigation SDK interactions without UI components.
* Provides callbacks for integrating with custom UI frameworks.
*/
class NavigationManager(
private val context: Context,
private val callbacks: NavigationCallbacks
) {
private val navigation: TrimbleMapsNavigation
interface NavigationCallbacks {
fun onNavigationReady()
fun onNavigationError(error: String)
fun onRouteProgressUpdate(
distanceRemaining: Float,
durationRemaining: Double,
fractionComplete: Float
)
fun onVoiceInstruction(announcement: String, ssml: String?)
fun onBannerInstruction(
text: String,
type: String?,
modifier: String?,
distanceToManeuver: Float?
)
fun onLocationUpdate(location: Location)
fun onOffRoute(isOffRoute: Boolean)
fun onArrival(isFinalDestination: Boolean, legIndex: Int)
}
init {
val options = NavigationOptions.Builder(context)
.isFromNavigationUi(false)
.distanceFormatter(TrimbleMapsDistanceFormatter.Builder(context).build())
.build()
navigation = TrimbleMapsNavigationProvider.create(options)
setupObservers()
}
@SuppressLint("MissingPermission")
fun startNavigation(trip: TrimbleMapsTrip) {
trip.fetchFullRoute(object : Callback<DirectionsResponse> {
override fun onResponse(
call: Call<DirectionsResponse>,
response: Response<DirectionsResponse>
) {
val routes = response.body()?.routes()
if (routes.isNullOrEmpty()) {
callbacks.onNavigationError("No routes found")
return
}
navigation.setTrip(trip)
navigation.startTripSession()
callbacks.onNavigationReady()
}
override fun onFailure(call: Call<DirectionsResponse>, t: Throwable) {
callbacks.onNavigationError(t.message ?: "Unknown error")
}
})
}
fun stopNavigation() {
navigation.stopTripSession()
}
fun destroy() {
navigation.stopTripSession()
navigation.onDestroy()
}
private fun setupObservers() {
// Route Progress
navigation.registerRouteProgressObserver(object : RouteProgressObserver {
override fun onRouteProgressChanged(progress: RouteProgress) {
callbacks.onRouteProgressUpdate(
progress.distanceRemaining,
progress.durationRemaining,
progress.fractionTraveled
)
}
})
// Voice Instructions
navigation.registerVoiceInstructionsObserver(object : VoiceInstructionsObserver {
override fun onNewVoiceInstructions(instructions: VoiceInstructions) {
callbacks.onVoiceInstruction(
instructions.announcement() ?: "",
instructions.ssmlAnnouncement()
)
}
})
// Banner Instructions
navigation.registerBannerInstructionsObserver(object : BannerInstructionsObserver {
override fun onNewBannerInstructions(instructions: BannerInstructions) {
val primary = instructions.primary()
callbacks.onBannerInstruction(
primary.text(),
primary.type(),
primary.modifier(),
instructions.distanceAlongGeometry()?.toFloat()
)
}
})
// Location Updates
navigation.registerLocationObserver(object : LocationObserver {
override fun onEnhancedLocationChanged(
location: Location,
keyPoints: List<Location>
) {
callbacks.onLocationUpdate(location)
}
override fun onRawLocationChanged(rawLocation: Location) {
// Optionally expose raw location
}
})
// Off-Route Detection
navigation.registerOffRouteObserver(object : OffRouteObserver {
override fun onOffRouteStateChanged(offRoute: Boolean) {
callbacks.onOffRoute(offRoute)
}
})
// Arrival Detection
navigation.registerArrivalObserver(object : ArrivalObserver {
override fun onFinalDestinationArrival(progress: RouteProgress) {
callbacks.onArrival(true, -1)
}
override fun onNextRouteLegStart(legProgress: RouteLegProgress) {
callbacks.onArrival(false, legProgress.legIndex)
}
})
}
}
Usage Example (Jetpack Compose)
class HeadlessNavActivity : ComponentActivity(), NavigationManager.NavigationCallbacks {
private lateinit var navManager: NavigationManager
// Compose state
private var distance by mutableStateOf("--")
private var instruction by mutableStateOf("--")
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
navManager = NavigationManager(this, this)
// Build trip
val trip = TrimbleMapsTripProvider.create()
trip.addStop(/* origin */)
trip.addStop(/* destination */)
navManager.startNavigation(trip)
setContent {
// Your custom Compose UI using distance, instruction, etc.
}
}
override fun onRouteProgressUpdate(
distanceRemaining: Float,
durationRemaining: Double,
fractionComplete: Float
) {
distance = "%.1f km".format(distanceRemaining / 1000)
}
override fun onBannerInstruction(
text: String,
type: String?,
modifier: String?,
distanceToManeuver: Float?
) {
instruction = text
}
// Implement other callbacks...
override fun onDestroy() {
super.onDestroy()
navManager.destroy()
}
}
API Reference
TrimbleMapsNavigation Methods
| Method | Description |
|---|---|
| setTrip(trip) | Set the trip to navigate |
| startTripSession() | Begin navigation session |
| stopTripSession() | End navigation session |
| onDestroy() | Cleanup resources |
| registerRouteProgressObserver(observer) | Register progress observer |
| registerVoiceInstructionsObserver(observer) | Register voice observer |
| registerBannerInstructionsObserver(observer) | Register banner observer |
| registerLocationObserver(observer) | Register location observer |
| registerOffRouteObserver(observer) | Register off-route observer |
| registerArrivalObserver(observer) | Register arrival observer |
TrimbleMapsTrip Methods
| Method | Description |
|---|---|
| addStop(tripStop) | Add a stop to the trip |
| removeStop(index) | Remove a stop by index |
| fetchFullRoute(callback) | Fetch route from API |
| options | Access trip options (profile, language, etc.) |
| getNavigationRoutes() | Get calculated routes |
Limitations
Current Limitations
- No Built-in Map Camera Control
- You must implement your own camera following logic if displaying a map
- Use LocationObserver enhanced location for camera positioning
- No Built-in Route Line Rendering
- Must use maps-android-plugin-route separately
- Route geometry available from DirectionsResponse
- No Automatic Rerouting
- OffRouteObserver notifies when off-route
- You must implement reroute logic (fetch new route, update trip)
- No Built-in Voice Playback
- Only provides text/SSML for announcements
- Must integrate with android.speech.tts.TextToSpeech or similar
- No UI for Speed Limits or Warnings
- Must implement custom UI for speed data
- Speed available via RouteProgress
- Location Permission Handling
- Must implement location permission requests yourself
- Navigation will fail without proper permissions
Memory Considerations
- Always call
navigation.onDestroy()when done - Always call
TrimbleMapsNavigationProvider.destroy()before creating new instances - Register observers once; don’t re-register on configuration changes
Best Practices
Lifecycle Management
override fun onDestroy() {
super.onDestroy()
navigation.stopTripSession()
navigation.onDestroy()
}
Error Handling
trip.fetchFullRoute(object : Callback<DirectionsResponse> {
override fun onResponse(...) {
val body = response.body()
if (body == null) {
handleError("Null response")
return
}
val routes = body.routes()
if (routes.isNullOrEmpty()) {
handleError("No routes found")
return
}
// Continue with navigation
}
override fun onFailure(call, t) {
handleError(t.message ?: "Network error")
}
})
Threading
- All observers are called on the main thread
- Safe to update UI directly in observer callbacks
- For heavy processing, dispatch to background threads
State Preservation
- Handle configuration changes (screen rotation)
- Consider using ViewModel to survive configuration changes
- Store navigation state for process death recovery
Testing
// Use simulation mode for testing without real GPS
// Note: Simulation must be configured before starting navigation
Troubleshooting
Navigation Not Starting
- Ensure location permissions are granted
- Verify isFromNavigationUi(false) is set in NavigationOptions
- Check that route fetch succeeded before calling
startTripSession()
Observers Not Firing
- Verify observers are registered before
startTripSession() - Check that navigation session actually started (no route fetch errors)
- Ensure you’re not unregistering observers prematurely
Memory Leaks
- Always call
onDestroy()on the navigation instance - Don’t hold references to Context in observer callbacks
- Consider using WeakReference for long-lived callbacks