Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

navigation specific route options builder #5427

Closed
wants to merge 1 commit into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ Mapbox welcomes participation and contributions from everyone.
## Unreleased

#### Features
- Added `MapboxNavigation:requestRoutes` that is an experimentation API to safely request a route. [#5427](https://github.com/mapbox/mapbox-navigation-android/pull/5427)

#### Bug fixes and improvements
- Fixed `HistoryEventMapper#mapNavigationRoute` for when `SetRouteHistoryRecord` has empty `routeRequest`. [#5614](https://github.com/mapbox/mapbox-navigation-android/pull/5614)
Expand Down
1 change: 1 addition & 0 deletions examples/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,7 @@ dependencies {

//Coroutines
implementation dependenciesList.coroutinesAndroid
implementation dependenciesList.androidXLifecycleRuntime

// Support libraries
implementation dependenciesList.androidXCore
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,7 @@ import android.view.View.VISIBLE
import android.widget.Toast
import androidx.appcompat.app.AppCompatActivity
import androidx.core.content.ContextCompat
import com.mapbox.api.directions.v5.models.DirectionsRoute
import com.mapbox.api.directions.v5.models.RouteOptions
import androidx.lifecycle.lifecycleScope
import com.mapbox.bindgen.Expected
import com.mapbox.geojson.Point
import com.mapbox.maps.CameraOptions
Expand All @@ -22,17 +21,15 @@ import com.mapbox.maps.plugin.LocationPuck2D
import com.mapbox.maps.plugin.animation.camera
import com.mapbox.maps.plugin.gestures.gestures
import com.mapbox.maps.plugin.locationcomponent.location
import com.mapbox.navigation.base.ExperimentalPreviewMapboxNavigationAPI
import com.mapbox.navigation.base.TimeFormat
import com.mapbox.navigation.base.extensions.applyDefaultNavigationOptions
import com.mapbox.navigation.base.extensions.applyLanguageAndVoiceUnitOptions
import com.mapbox.navigation.base.formatter.DistanceFormatterOptions
import com.mapbox.navigation.base.options.EventsAppMetadata
import com.mapbox.navigation.base.options.NavigationOptions
import com.mapbox.navigation.base.route.RouterCallback
import com.mapbox.navigation.base.route.RouterFailure
import com.mapbox.navigation.base.route.RouterOrigin
import com.mapbox.navigation.base.route.NavigationRoute
import com.mapbox.navigation.core.MapboxNavigation
import com.mapbox.navigation.core.MapboxNavigationProvider
import com.mapbox.navigation.core.RequestRoutesResult
import com.mapbox.navigation.core.directions.session.RoutesObserver
import com.mapbox.navigation.core.formatter.MapboxDistanceFormatter
import com.mapbox.navigation.core.trip.session.LocationMatcherResult
Expand Down Expand Up @@ -459,43 +456,23 @@ class MapboxNavigationActivity : AppCompatActivity() {
voiceInstructionsPlayer.shutdown()
}

@OptIn(ExperimentalPreviewMapboxNavigationAPI::class)
private fun findRoute(destination: Point) {
val origin = navigationLocationProvider.lastLocation?.let {
Point.fromLngLat(it.longitude, it.latitude)
} ?: return

mapboxNavigation.requestRoutes(
RouteOptions.builder()
.applyDefaultNavigationOptions()
.applyLanguageAndVoiceUnitOptions(this)
.coordinatesList(listOf(origin, destination))
.layersList(listOf(mapboxNavigation.getZLevel(), null))
.build(),
object : RouterCallback {
override fun onRoutesReady(
routes: List<DirectionsRoute>,
routerOrigin: RouterOrigin
) {
setRouteAndStartNavigation(routes.first())
}

override fun onFailure(
reasons: List<RouterFailure>,
routeOptions: RouteOptions
) {
// no impl
}

override fun onCanceled(routeOptions: RouteOptions, routerOrigin: RouterOrigin) {
// no impl
}
lifecycleScope.launchWhenCreated {
val result = mapboxNavigation.requestRoutes { builder ->
builder
.fromCurrentLocation()
.toDestination(destination)
}
)
if (result is RequestRoutesResult.Successful) {
setRouteAndStartNavigation(result.routes.first())
}
}
}

private fun setRouteAndStartNavigation(route: DirectionsRoute) {
private fun setRouteAndStartNavigation(route: NavigationRoute) {
// set route
mapboxNavigation.setRoutes(listOf(route))
mapboxNavigation.setNavigationRoutes(listOf(route))

// show UI elements
binding.soundButton.visibility = VISIBLE
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
package com.mapbox.navigation.instrumentation_tests.core

import android.location.Location
import androidx.test.platform.app.InstrumentationRegistry
import com.mapbox.api.directions.v5.DirectionsCriteria
import com.mapbox.geojson.Point
import com.mapbox.navigation.base.ExperimentalPreviewMapboxNavigationAPI
import com.mapbox.navigation.base.options.NavigationOptions
import com.mapbox.navigation.core.MapboxNavigationProvider
import com.mapbox.navigation.core.RequestRoutesResult
import com.mapbox.navigation.instrumentation_tests.R
import com.mapbox.navigation.instrumentation_tests.activity.EmptyTestActivity
import com.mapbox.navigation.instrumentation_tests.utils.MapboxNavigationRule
import com.mapbox.navigation.instrumentation_tests.utils.http.MockDirectionsRequestHandler
import com.mapbox.navigation.instrumentation_tests.utils.readRawFileText
import com.mapbox.navigation.instrumentation_tests.utils.runOnMainSync
import com.mapbox.navigation.testing.ui.BaseTest
import com.mapbox.navigation.testing.ui.utils.getMapboxAccessTokenFromResources
import junit.framework.Assert.assertEquals
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.runBlocking
import org.junit.Rule
import org.junit.Test

@OptIn(ExperimentalPreviewMapboxNavigationAPI::class)
class RouteOptionsBuilderTest : BaseTest<EmptyTestActivity>(EmptyTestActivity::class.java) {

@get:Rule
val mapboxNavigationRule = MapboxNavigationRule()

@Test
fun navigateFromCurrentLocation() {
mockWebServerRule.requestHandlers.add(
MockDirectionsRequestHandler(
profile = DirectionsCriteria.PROFILE_DRIVING,
jsonResponse = readRawFileText(activity, R.raw.reroute_response_dc_very_short),
expectedCoordinates = null,
relaxedExpectedCoordinates = true
)
)

val mapboxNavigation = runOnMainSync {
val context = InstrumentationRegistry.getInstrumentation().getTargetContext()
MapboxNavigationProvider.create(
NavigationOptions.Builder(context)
.accessToken(getMapboxAccessTokenFromResources(context))
.build()
)
}

val routeRequest = runBlocking(Dispatchers.Main) {
mapboxNavigation.startTripSession()
mapboxNavigation.requestRoutes { builder ->
builder
.fromCurrentLocation()
.toDestination(
coordinate = Point.fromLngLat(2.0, 2.0)
)
.profileDriving()
.baseUrl(mockWebServerRule.baseUrl)
} as RequestRoutesResult.Successful
}

val routeOptions = routeRequest.routes.first().routeOptions
assertEquals(
listOf(
Point.fromLngLat(1.0, 1.0),
Point.fromLngLat(2.0, 2.0),
),
routeOptions.coordinatesList()
)
}

override fun setupMockLocation(): Location {
return mockLocationUpdatesRule.generateLocationUpdate {
longitude = 1.0
latitude = 1.0
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -17,8 +17,13 @@ fun runOnMainSync(runnable: Runnable) =
/**
* Runs the code block on the app's main thread and blocks until the block returns.
*/
fun runOnMainSync(fn: () -> Unit) =
InstrumentationRegistry.getInstrumentation().runOnMainSync(fn)
fun <T> runOnMainSync(fn: () -> T): T {
var result: T? = null
InstrumentationRegistry.getInstrumentation().runOnMainSync {
result = fn()
}
return result ?: error("got no result")
}

fun Int.loopFor(millis: Long) {
Espresso.onView(ViewMatchers.withId(this)).perform(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,10 @@ import com.mapbox.navigation.core.routealternatives.RouteAlternativesError
import com.mapbox.navigation.core.routealternatives.RouteAlternativesObserver
import com.mapbox.navigation.core.routealternatives.RouteAlternativesRequestCallback
import com.mapbox.navigation.core.routeoptions.RouteOptionsUpdater
import com.mapbox.navigation.core.routeoptions.builder.LocationFromTripSessionProvider
import com.mapbox.navigation.core.routeoptions.builder.NavRouteOptionsBuilder
import com.mapbox.navigation.core.routeoptions.builder.NoWaypointsOptionsBuilder
import com.mapbox.navigation.core.routeoptions.builder.RouteOptionsBuilderWithWaypoints
import com.mapbox.navigation.core.routerefresh.RouteRefreshController
import com.mapbox.navigation.core.routerefresh.RouteRefreshControllerProvider
import com.mapbox.navigation.core.telemetry.MapboxNavigationTelemetry
Expand Down Expand Up @@ -120,8 +124,10 @@ import com.mapbox.navigator.TilesConfig
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.channels.ReceiveChannel
import kotlinx.coroutines.launch
import kotlinx.coroutines.suspendCancellableCoroutine
import java.lang.reflect.Field
import java.util.Locale
import kotlin.coroutines.resume

private const val MAPBOX_NAVIGATION_USER_AGENT_BASE = "mapbox-navigation-android"
private const val MAPBOX_NAVIGATION_TOKEN_EXCEPTION_ROUTER =
Expand Down Expand Up @@ -709,6 +715,61 @@ class MapboxNavigation @VisibleForTesting internal constructor(
return directionsSession.requestRoutes(routeOptions, callback)
}

@ExperimentalPreviewMapboxNavigationAPI
suspend fun requestRoutes(
optionsBlock: (NoWaypointsOptionsBuilder) -> RouteOptionsBuilderWithWaypoints
): RequestRoutesResult {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
): RequestRoutesResult {
): Expected<RequestRoutesFailure, RequestRoutesSuccess> {

Let's keep it consistent with other APIs that are in the Nav SDK.

if (tripSession.getState() != TripSessionState.STARTED) {
error("trip session should be started")
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This shouldn't throw but rather return an error message.

}
val builder = NavRouteOptionsBuilder(LocationFromTripSessionProvider(tripSession))
optionsBlock(builder)
builder.applyLanguageAndVoiceUnitOptions(navigationOptions.applicationContext)
val routeOptions = builder.build()
return suspendCancellableCoroutine { continuation ->
val requestId = requestRoutes(
routeOptions,
object : NavigationRouterCallback {
override fun onRoutesReady(
routes: List<NavigationRoute>,
routerOrigin: RouterOrigin
) {
continuation.resume(
RequestRoutesResult.Successful(
routes,
routerOrigin
)
)
}

override fun onFailure(
reasons: List<RouterFailure>,
routeOptions: RouteOptions
) {
continuation.resume(
RequestRoutesResult.Failed(
reasons,
routeOptions
)
)
}

override fun onCanceled(
routeOptions: RouteOptions,
routerOrigin: RouterOrigin
) {
if (!continuation.isCancelled) {
error("request was unexpectedly cancelled from outside")
}
}
}
)
continuation.invokeOnCancellation {
cancelRouteRequest(requestId)
}
}
}

/**
* Cancels a specific route request using the ID returned by [requestRoutes].
*/
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
package com.mapbox.navigation.core

import com.mapbox.api.directions.v5.models.RouteOptions
import com.mapbox.navigation.base.ExperimentalPreviewMapboxNavigationAPI
import com.mapbox.navigation.base.route.NavigationRoute
import com.mapbox.navigation.base.route.RouterFailure
import com.mapbox.navigation.base.route.RouterOrigin

@ExperimentalPreviewMapboxNavigationAPI
sealed class RequestRoutesResult {
@ExperimentalPreviewMapboxNavigationAPI
data class Successful(
val routes: List<NavigationRoute>,
val routerOrigin: RouterOrigin,
) : RequestRoutesResult()

@ExperimentalPreviewMapboxNavigationAPI
data class Failed(
val reasons: List<RouterFailure>,
val routeOptions: RouteOptions
) : RequestRoutesResult()
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
package com.mapbox.navigation.core.routeoptions.builder

import android.location.Location
import com.mapbox.geojson.Point
import com.mapbox.navigation.core.trip.session.LocationMatcherResult
import com.mapbox.navigation.core.trip.session.LocationObserver
import com.mapbox.navigation.core.trip.session.TripSessionLocationProvider
import com.mapbox.navigation.utils.internal.toPoint
import kotlinx.coroutines.suspendCancellableCoroutine
import kotlinx.coroutines.withTimeout
import kotlin.coroutines.resume

internal interface LocationProvider {
suspend fun getCurrentLocation(): CurrentLocation
}

internal data class CurrentLocation(
val point: Point,
val bearing: Double?,
val zLevel: Int?
)

internal class LocationFromTripSessionProvider(
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This class and LocationProvider interface can be replaced by an extension method on TripSessionLocationProvider

internal suspend fun TripSessionLocationProvider.currentLocation() = suspendCancellableCoroutine<CurrentLocation> { continuation ->
    // return immediately if locationMatcherResult has already been set
    locationMatcherResult?.also {
        continuation.resume(it.toCurrentLocation())
        return@suspendCancellableCoroutine
    }

    // subscribe for the next locationMatcherResult
    val observer = object : LocationObserver {
        override fun onNewRawLocation(rawLocation: Location) = Unit

        override fun onNewLocationMatcherResult(locationMatcherResult: LocationMatcherResult) {
            unregisterLocationObserver(this)
            continuation.resume(locationMatcherResult.toCurrentLocation())
        }
    }
    registerLocationObserver(observer)
    continuation.invokeOnCancellation {
        unregisterLocationObserver(observer)
    }
}

private fun LocationMatcherResult.toCurrentLocation() = CurrentLocation(
    point = enhancedLocation.toPoint(),
    bearing = enhancedLocation.bearing.toDouble(),
    zLevel = zLevel
)

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thank you. I agree it can. I'm not sure I understand why an extension function is better than a wrapper class. Please share your thoughts about exception function vs wrapper class .

Not sure if the provided code is expected to be fully functional but it will crash on unregisterLocationObserver(this). You can reed and share your opinion about the crash here

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please share your thoughts about exception function vs wrapper class .

There is nothing wrong with wrapper classes. They are extremely useful in languages that doesn't provide any other mechanism to add new functionality to an existing class or when we need to store some state.

In this specific case, we can extend TripSessionLocationProvider functionality by utilizing Kotlin extensions.

Not sure if the provided code is expected to be fully functional but it will crash on unregisterLocationObserver(this).

I am assuming the crash is caused by ConcurrentModificationException. We should create new ticket for patching unregisterLocationObserver().

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If I replace wrapper by an extension function then NavRouteOptionsBuilder will start to depend on the TripSessionLocationProvider directly. But I would prefer NavRouteOptionsBuilder to work with an interface LocationProvider which can be implemented by other classes.

private val tripSessionLocationProvider: TripSessionLocationProvider
) : LocationProvider {
override suspend fun getCurrentLocation(): CurrentLocation {
val currentLocation = tripSessionLocationProvider.locationMatcherResult
return currentLocation?.toCurrentLocation() ?: waitForTheFirstLocationEventWithTimeout()
}

private suspend fun waitForTheFirstLocationEventWithTimeout() =
withTimeout(GETTING_LOCATION_TIMEOUT_MILLISECONDS) {
waitForTheFirstLocationEvent()
}

private suspend fun waitForTheFirstLocationEvent(): CurrentLocation {
val (result, cleanup) = suspendCancellableCoroutine<Pair<CurrentLocation, () -> Unit>>
{ continuation ->
val observer = object : LocationObserver {
override fun onNewRawLocation(rawLocation: Location) {
}

override fun onNewLocationMatcherResult(
locationMatcherResult: LocationMatcherResult
) {
continuation.resume(
Pair(
locationMatcherResult.toCurrentLocation(),
{
tripSessionLocationProvider.unregisterLocationObserver(this)
}
)
)
}
}
tripSessionLocationProvider.registerLocationObserver(observer)
continuation.invokeOnCancellation {
tripSessionLocationProvider.unregisterLocationObserver(observer)
}
}
cleanup()
return result
}

private fun LocationMatcherResult.toCurrentLocation() = CurrentLocation(
point = enhancedLocation.toPoint(),
bearing = enhancedLocation.bearing.toDouble(),
zLevel = zLevel
)

private companion object {
private const val GETTING_LOCATION_TIMEOUT_MILLISECONDS = 30_000L
}
}
Loading