Skip to main content

Route Simulation

Last updated: June 19, 2026 | 7 minutes read

This example simulates turn-by-turn navigation along a computed route rendered on an interactive map, driving a virtual position from a departure point to a destination. It is the simulation counterpart of the Route Navigation example: the on-screen experience is identical - a top panel with the next-turn icon, distance and street name, a traffic panel when an event lies ahead, and a bottom panel with the estimated time of arrival, remaining travel time and remaining travel distance - but the position comes from the SDK's simulator instead of the device GPS.

Because the position is simulated, there is no need to request location permissions, enable location services, or wait for a valid GPS fix before starting.

Simulated turn-by-turn navigation

MainActivity retains a single NavigationService and a NavigationListener. The listener receives the stream of navigation events: when the simulation starts, when the next instruction changes, when the status changes, when the destination is reached, on errors, and when a voice instruction should be played. The navRoute helper returns the route currently being simulated, which the UI uses to read the estimated time of arrival, remaining time and distance.

MainActivity.ktView on Github
private val navigationListener: NavigationListener = NavigationListener.create(
onNavigationStarted = {
SdkCall.execute {
binding.gemSurfaceView.mapView?.let { mapView ->
mapView.preferences?.enableCursor = false
navRoute?.let { route ->
mapView.presentRoute(route)
}

enableGPSButton()
mapView.followPosition()
}
}
applyCameraFocus()
endOfSectionBmp = ContextCompat.getDrawable(this@MainActivity, R.drawable.end_of_traffic_section)
?.toBitmap(navigationImageSize, navigationImageSize)
setNavigationPanelsVisible(isVisible = true)
},
onNavigationInstructionUpdated = { instr -> updateNavigationInstruction(instr) },
onDestinationReached = { onNavigationEnded() },
onNotifyStatusChange = { status ->
navigationStatus = status
refreshStatusMessage()
},
onNavigationError = { error -> onNavigationEnded(error) },
onNavigationSound = { sound ->
SdkCall.execute {
SoundPlayingService.play(sound, playingListener, soundPreference)
}
},
canPlayNavigationSound = true,
)

A separate ProgressListener toggles the progress bar while the route is being calculated.

MainActivity.ktView on Github
private val routingProgressListener = ProgressListener.create(
onStarted = {
binding.progressBar.visibility = View.VISIBLE
},

onCompleted = { _, _ ->
binding.progressBar.visibility = View.GONE
},

postOnMain = true,
)

Starting the simulation

The simulation is started once the worldwide road map is downloaded and up to date. Unlike real navigation, there is no permission or GPS gating - the road-map listener clears itself and calls startSimulation directly.

MainActivity.ktView on Github
SdkSettings.onWorldwideRoadMapSupportStatus = { status ->
if (status == EOffboardListenerStatus.UpToDate) {
SdkSettings.onWorldwideRoadMapSupportStatus = {}
startSimulation()
}
}

startSimulation defines the departure and destination waypoints and calls navigationService.startSimulation with the navigation and routing-progress listeners. The call returns synchronously whether the simulation could be started; on failure the error is reported in a dialog. Here the simulated trip runs from London to Paris.

MainActivity.ktView on Github
private fun startSimulation() = SdkCall.execute {
val waypoints = arrayListOf(
Landmark("London", 51.5073204, -0.1276475),
Landmark("Paris", 48.8566932, 2.3514616),
)

val error = navigationService.startSimulation(waypoints, navigationListener, routingProgressListener)
if (error != GemError.NoError) {
runOnUiThread {
showDialog(
getString(R.string.route_simulation_error, SdkCall.runSynced { GemError.getMessage(error, this) }),
)
}
}
}

Presenting the route and following the position

When the simulation starts (the onNavigationStarted callback above), the route is drawn on the map with presentRoute, the map selection cursor is disabled, the camera enters follow mode with followPosition, and the navigation panels are made visible. enableGPSButton wires the follow button and the follow-mode transitions: when the user pans away from the simulated position the button reappears and the panels are hidden; tapping the button calls followPosition to re-center and resume following. The active-state check here uses navigationService.isSimulationActive() rather than the real-navigation equivalent.

MainActivity.ktView on Github
private fun enableGPSButton() {
// Set actions for entering/ exiting following position mode.
binding.apply {
gemSurfaceView.mapView?.apply {
onExitFollowingPosition = {
followGpsButton.visibility = View.VISIBLE
setNavigationPanelsVisible(isVisible = false)
}

onEnterFollowingPosition = {
followGpsButton.visibility = View.GONE

val simulationIsActive = SdkCall.execute { navigationService.isSimulationActive() } ?: false
if (simulationIsActive) {
setNavigationPanelsVisible(isVisible = true)
}
}

// Set on click action for the GPS button.
followGpsButton.setOnClickListener {
SdkCall.execute { followPosition() }
}
}
}
}

Updating the instruction panel

Each time the SDK reports a new instruction through onNavigationInstructionUpdated, updateNavigationInstruction refreshes the top panel. The displayable values are gathered on the SDK thread by collectNavigationUiData: the next street name (or turn instruction), the next-turn icon, the distance to the turn, and the estimated time of arrival / remaining travel time (RTT) / remaining travel distance (RTD) read from the simulated route. It also resolves a signpost image, falling back to a road-code shield when there is no signpost. The remaining travel time is colored orange or red according to the traffic severity; if it stays black there is no traffic delay on the route.

MainActivity.ktView on Github
private fun collectNavigationUiData(instruction: NavigationInstruction, availableWidth: Int): NavigationUiData {
val instructionText = instruction.nextStreetName?.takeIf {
it.isNotEmpty()
} ?: instruction.nextTurnInstruction.orEmpty()

var hasSameTurnImage = false
val instructionIcon = getNextTurnImage(instruction, turnImageSize, turnImageSize) { isSame ->
hasSameTurnImage = isSame
}

val trafficDelayInMinutes = navRoute?.let {
GemUtil.getTrafficEventsDelay(it, true) / 60
} ?: 0
val (etaText, rttText, rtdText) = navRoute?.let {
Triple(it.getEta(), it.getRtt(), it.getRtd())
} ?: Triple("", "", "")

val signPostBitmap = getSignpostImage(instruction, availableWidth, signPostImageSize)
val roadCodeBitmap = if (signPostBitmap == null) {
getRoadCodeImage(instruction, availableWidth, navigationImageSize)
} else {
null
}

return NavigationUiData(
instructionText = instructionText,
instructionIcon = instructionIcon,
hasSameTurnImage = hasSameTurnImage,
instructionDistance = instruction.getDistanceInMeters(),
etaText = etaText,
rttText = rttText,
rttColor = getTrafficColor(trafficDelayInMinutes),
rtdText = rtdText,
signPostBitmap = signPostBitmap,
roadCodeBitmap = roadCodeBitmap,
)
}

The estimated time of arrival, remaining travel time and remaining travel distance are derived from the route's timeDistance, adding any traffic delay where relevant.

MainActivity.ktView on Github
@SuppressLint("DefaultLocale")
private fun Route.getEta(): String {
val etaNumber = (this.getTimeDistance(true)?.totalTime ?: 0) + GemUtil.getTrafficEventsDelay(this, true)

val time = Time()
time.setLocalTime()
time.longValue += etaNumber * 1000
return String.format("%d:%02d", time.hour, time.minute)
}

private fun Route.getRtt(): String {
return GemUtil.getTimeText(
(this.getTimeDistance(true)?.totalTime ?: 0) + GemUtil.getTrafficEventsDelay(this, true),
).let { pair ->
pair.first + " " + pair.second
}
}

private fun Route.getRtd(): String {
return GemUtil.getDistText(
this.getTimeDistance(true)?.totalDistance ?: 0,
EUnitSystem.Metric,
).let { pair ->
pair.first + " " + pair.second
}
}

The traffic panel

After updating the instruction, updateNavigationInstruction also refreshes the traffic panel via updateTrafficPanel. It looks for the nearest traffic event ahead on the route that adds a delay; if none is found (or the top panel is hidden) the panel is hidden. getTrafficEvent walks the route's traffic events and computes, from the remaining travel distance, whether each event is still ahead or whether the simulated position is already inside it - which determines whether the panel shows the distance to the event or the distance remaining inside it.

MainActivity.ktView on Github
private fun getTrafficEvent(navInstr: NavigationInstruction, route: Route): RouteTrafficEvent? = SdkCall.execute {
if (navInstr.navigationStatus != ENavigationStatus.Running) return@execute null
val trafficEventsList = route.trafficEvents ?: return@execute null

val remainingTravelDistance = navInstr.remainingTravelTimeDistance?.totalDistance ?: 0

for (event in trafficEventsList) {
if (event.delay != 0) {
val distToDest = event.distanceToDestination
// Positive value means the event is ahead; negative means we have already passed
// the start of the event and are potentially inside it.
distToTrafficEvent = remainingTravelDistance - distToDest

insideTrafficEvent = false

if (distToTrafficEvent <= 0) {
// How many metres of the event still remain in front of us.
remainingDistInsideTrafficEvent = event.length - (distToDest - remainingTravelDistance)
if (remainingDistInsideTrafficEvent >= 0) {
insideTrafficEvent = true
}
}

if ((distToTrafficEvent >= 0) || (remainingDistInsideTrafficEvent >= 0)) {
return@execute event
}
}
}

return@execute null
}

Ending the simulation

When the destination is reached (onDestinationReached) or an error occurs (onNavigationError), onNavigationEnded hides the navigation panels, disables the follow button and removes the route, showing a dialog only for genuine errors (not for a normal cancellation).

MainActivity.ktView on Github
private fun onNavigationEnded(errorCode: ErrorCode = GemError.NoError) {
runOnUiThread {
if ((errorCode != GemError.NoError) && (errorCode != GemError.Cancel)) {
val message = SdkCall.runSynced { GemError.getMessage(errorCode, this) } ?: ""
if (message.isNotEmpty()) {
showDialog(message)
}
}
setNavigationPanelsVisible(isVisible = false)
disableGPSButton()
}

SdkCall.execute {
binding.gemSurfaceView.mapView?.hideRoutes()
}
}