diff --git a/changelog/unreleased/features/6855.md b/changelog/unreleased/features/6855.md new file mode 100644 index 00000000000..af7da2d15d7 --- /dev/null +++ b/changelog/unreleased/features/6855.md @@ -0,0 +1 @@ +- Introduced `ReplayHistorySession` and `ReplayHistorySessionOptions` to simplify the implementation for replaying history files. History can also be enabled with `MapboxTripStarter.enableReplayHistory()`. This can replay large history files in a memory efficient way. diff --git a/examples/src/main/java/com/mapbox/navigation/examples/MainActivity.kt b/examples/src/main/java/com/mapbox/navigation/examples/MainActivity.kt index 61a60f6ca05..303d89ab7f1 100644 --- a/examples/src/main/java/com/mapbox/navigation/examples/MainActivity.kt +++ b/examples/src/main/java/com/mapbox/navigation/examples/MainActivity.kt @@ -11,6 +11,8 @@ import androidx.core.app.ActivityCompat import androidx.core.content.ContextCompat import androidx.recyclerview.widget.LinearLayoutManager import com.mapbox.android.core.permissions.PermissionsListener +import com.mapbox.common.LogConfiguration +import com.mapbox.common.LoggingLevel import com.mapbox.navigation.examples.core.IndependentRouteGenerationActivity import com.mapbox.navigation.examples.core.MapboxBuildingHighlightActivity import com.mapbox.navigation.examples.core.MapboxCustomStyleActivity @@ -39,6 +41,7 @@ class MainActivity : AppCompatActivity(), PermissionsListener { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) + LogConfiguration.setLoggingLevel(LoggingLevel.DEBUG) binding = LayoutActivityMainBinding.inflate(layoutInflater) setContentView(binding.root) diff --git a/examples/src/main/java/com/mapbox/navigation/examples/core/ReplayHistoryActivity.kt b/examples/src/main/java/com/mapbox/navigation/examples/core/ReplayHistoryActivity.kt index 8a1a8657ac1..aa4be51e3ed 100644 --- a/examples/src/main/java/com/mapbox/navigation/examples/core/ReplayHistoryActivity.kt +++ b/examples/src/main/java/com/mapbox/navigation/examples/core/ReplayHistoryActivity.kt @@ -6,11 +6,11 @@ import android.content.res.Configuration import android.content.res.Resources import android.location.Location import android.os.Bundle -import android.view.View import android.widget.Button import android.widget.SeekBar import androidx.appcompat.app.AppCompatActivity import androidx.core.content.ContextCompat +import androidx.lifecycle.lifecycleScope import com.mapbox.maps.CameraOptions import com.mapbox.maps.EdgeInsets import com.mapbox.maps.extension.observable.eventdata.MapLoadingErrorEventData @@ -23,11 +23,8 @@ import com.mapbox.maps.plugin.locationcomponent.location import com.mapbox.navigation.base.ExperimentalPreviewMapboxNavigationAPI import com.mapbox.navigation.base.options.NavigationOptions import com.mapbox.navigation.core.MapboxNavigation -import com.mapbox.navigation.core.MapboxNavigationProvider import com.mapbox.navigation.core.directions.session.RoutesObserver -import com.mapbox.navigation.core.replay.MapboxReplayer -import com.mapbox.navigation.core.replay.history.ReplayEventBase -import com.mapbox.navigation.core.replay.history.ReplaySetNavigationRoute +import com.mapbox.navigation.core.replay.history.ReplayHistorySession import com.mapbox.navigation.core.trip.session.LocationMatcherResult import com.mapbox.navigation.core.trip.session.LocationObserver import com.mapbox.navigation.core.trip.session.RouteProgressObserver @@ -50,21 +47,17 @@ import com.mapbox.navigation.ui.maps.route.line.model.MapboxRouteLineOptions import com.mapbox.navigation.ui.maps.route.line.model.RouteLine import com.mapbox.navigation.ui.maps.route.line.model.RouteLineColorResources import com.mapbox.navigation.ui.maps.route.line.model.RouteLineResources -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.Job +import com.mapbox.navigation.utils.internal.logI import kotlinx.coroutines.launch -import java.util.Collections private const val DEFAULT_INITIAL_ZOOM = 15.0 +@OptIn(ExperimentalPreviewMapboxNavigationAPI::class) class ReplayHistoryActivity : AppCompatActivity() { - private var loadNavigationJob: Job? = null private val navigationLocationProvider = NavigationLocationProvider() private lateinit var historyFileLoader: HistoryFileLoader private lateinit var mapboxNavigation: MapboxNavigation - private lateinit var mapboxReplayer: MapboxReplayer private lateinit var locationComponent: LocationComponentPlugin private lateinit var navigationCamera: NavigationCamera private lateinit var viewportDataSource: MapboxNavigationViewportDataSource @@ -103,6 +96,7 @@ class ReplayHistoryActivity : AppCompatActivity() { 40.0 * pixelDensity ) } + private val replayHistorySession = ReplayHistorySession() private val initialCameraOptions: CameraOptions? = CameraOptions.Builder() .zoom(DEFAULT_INITIAL_ZOOM) @@ -163,7 +157,7 @@ class ReplayHistoryActivity : AppCompatActivity() { super.onDestroy() routeLineApi.cancel() routeLineView.cancel() - mapboxReplayer.finish() + replayHistorySession.onDetached(mapboxNavigation) mapboxNavigation.onDestroy() if (::locationComponent.isInitialized) { locationComponent.removeOnIndicatorPositionChangedListener(onPositionChangedListener) @@ -212,15 +206,16 @@ class ReplayHistoryActivity : AppCompatActivity() { viewportDataSource.onLocationChanged(locationMatcherResult.enhancedLocation) viewportDataSource.evaluate() if (!isLocationInitialized) { + logI("ReplayHistoryActivity") { + "onNewLocationMatcherResult initialize location" + } isLocationInitialized = true - val instantTransition = NavigationCameraTransitionOptions.Builder() - .maxDuration(0) - .build() - navigationCamera.requestNavigationCameraToOverview( - stateTransitionOptions = instantTransition, + navigationCamera.requestNavigationCameraToFollowing( + stateTransitionOptions = NavigationCameraTransitionOptions.Builder() + .maxDuration(0) + .build(), ) } - navigationLocationProvider.changePosition( locationMatcherResult.enhancedLocation, locationMatcherResult.keyPoints, @@ -308,21 +303,12 @@ class ReplayHistoryActivity : AppCompatActivity() { @SuppressLint("MissingPermission") private fun initNavigation() { historyFileLoader = HistoryFileLoader() - mapboxNavigation = MapboxNavigationProvider.create( + mapboxNavigation = MapboxNavigation( NavigationOptions.Builder(this) .accessToken(Utils.getMapboxAccessToken(this)) .build() ) - startReplayTripSession() - } - - /** - * This is showcasing a new way to replay rides at runtime. - */ - @OptIn(ExperimentalPreviewMapboxNavigationAPI::class) - private fun startReplayTripSession() { - mapboxReplayer = mapboxNavigation.mapboxReplayer - mapboxNavigation.startReplayTripSession() + replayHistorySession.onAttached(mapboxNavigation) } override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) { @@ -335,25 +321,10 @@ class ReplayHistoryActivity : AppCompatActivity() { @SuppressLint("MissingPermission") private fun handleHistoryFileSelected() { - loadNavigationJob = CoroutineScope(Dispatchers.Main).launch { - val events = historyFileLoader - .loadReplayHistory(this@ReplayHistoryActivity) - mapboxReplayer.clearEvents() - mapboxReplayer.pushEvents(events) - binding.playReplay.visibility = View.VISIBLE - mapboxNavigation.resetTripSession() - mapboxNavigation.setRoutes(emptyList()) + lifecycleScope.launch { + val historyReader = historyFileLoader.loadReplayHistory(this@ReplayHistoryActivity) + replayHistorySession.setHistoryFile(historyReader.filePath) isLocationInitialized = false - mapboxReplayer.playFirstLocation() - } - } - - @SuppressLint("SetTextI18n") - private fun updateReplayStatus(playbackEvents: List) { - playbackEvents.lastOrNull()?.eventTimestamp?.let { - val currentSecond = mapboxReplayer.eventSeconds(it).toInt() - val durationSecond = mapboxReplayer.durationSeconds().toInt() - binding.playerStatus.text = "$currentSecond:$durationSecond" } } @@ -368,7 +339,7 @@ class ReplayHistoryActivity : AppCompatActivity() { binding.seekBar.setOnSeekBarChangeListener( object : SeekBar.OnSeekBarChangeListener { override fun onProgressChanged(seekBar: SeekBar, progress: Int, fromUser: Boolean) { - mapboxReplayer.playbackSpeed(progress.toDouble()) + mapboxNavigation.mapboxReplayer.playbackSpeed(progress.toDouble()) binding.seekBarText.text = getString( R.string.replay_playback_speed_seekbar, progress @@ -379,26 +350,5 @@ class ReplayHistoryActivity : AppCompatActivity() { override fun onStopTrackingTouch(seekBar: SeekBar) {} } ) - - binding.playReplay.setOnClickListener { - mapboxReplayer.play() - binding.playReplay.visibility = View.GONE - navigationCamera.requestNavigationCameraToFollowing() - } - - mapboxReplayer.registerObserver { events -> - updateReplayStatus(events) - events.forEach { - when (it) { - is ReplaySetNavigationRoute -> setRoute(it) - } - } - } - } - - private fun setRoute(replaySetRoute: ReplaySetNavigationRoute) { - replaySetRoute.route?.let { directionRoute -> - mapboxNavigation.setNavigationRoutes(Collections.singletonList(directionRoute)) - } } } diff --git a/examples/src/main/java/com/mapbox/navigation/examples/core/replay/HistoryFileLoader.kt b/examples/src/main/java/com/mapbox/navigation/examples/core/replay/HistoryFileLoader.kt index a038b52de8a..a5ef9561a68 100644 --- a/examples/src/main/java/com/mapbox/navigation/examples/core/replay/HistoryFileLoader.kt +++ b/examples/src/main/java/com/mapbox/navigation/examples/core/replay/HistoryFileLoader.kt @@ -3,37 +3,22 @@ package com.mapbox.navigation.examples.core.replay import android.annotation.SuppressLint import android.content.Context import com.mapbox.navigation.core.history.MapboxHistoryReader -import com.mapbox.navigation.core.replay.history.ReplayEventBase -import com.mapbox.navigation.core.replay.history.ReplayHistoryMapper -import com.mapbox.navigation.core.replay.history.ReplaySetNavigationRoute import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext class HistoryFileLoader { - private val replayHistoryMapper = ReplayHistoryMapper.Builder().setRouteMapper { - ReplaySetNavigationRoute.Builder(eventTimestamp = it.eventTimestamp) - .route(it.navigationRoute) - .build() - }.build() private val historyFilesDirectory = HistoryFilesDirectory() @SuppressLint("MissingPermission") suspend fun loadReplayHistory( context: Context - ): List = withContext(Dispatchers.IO) { - loadSelectedHistory() ?: loadDefaultReplayHistory(context) + ): MapboxHistoryReader = withContext(Dispatchers.IO) { + HistoryFilesActivity.selectedHistory ?: loadDefaultReplayHistory(context) } - private suspend fun loadSelectedHistory(): List? = - withContext(Dispatchers.IO) { - HistoryFilesActivity.selectedHistory?.asSequence()?.mapNotNull { historyEvent -> - replayHistoryMapper.mapToReplayEvent(historyEvent) - }?.toList() - } - private suspend fun loadDefaultReplayHistory( context: Context - ): List = withContext(Dispatchers.IO) { + ): MapboxHistoryReader = withContext(Dispatchers.IO) { val fileName = "replay-history-activity.json" val inputStream = context.assets.open(fileName) val outputFile = historyFilesDirectory.outputFile(context, fileName) @@ -41,8 +26,5 @@ class HistoryFileLoader { inputStream.copyTo(fileOut) } MapboxHistoryReader(outputFile.absolutePath) - .asSequence() - .mapNotNull { replayHistoryMapper.mapToReplayEvent(it) } - .toList() } } diff --git a/examples/src/main/res/layout/activity_replay_history_layout.xml b/examples/src/main/res/layout/activity_replay_history_layout.xml index e7b911d69b7..10503dd9c26 100644 --- a/examples/src/main/res/layout/activity_replay_history_layout.xml +++ b/examples/src/main/res/layout/activity_replay_history_layout.xml @@ -34,23 +34,11 @@ android:text="@string/select_history" /> - - @@ -71,17 +59,10 @@ android:id="@+id/seekBar" android:layout_width="match_parent" android:layout_height="wrap_content" - android:paddingBottom="6dp" + android:paddingBottom="30dp" android:paddingTop="6dp" /> - - diff --git a/libnavigation-core/api/current.txt b/libnavigation-core/api/current.txt index a44335082fc..c5b38fde81d 100644 --- a/libnavigation-core/api/current.txt +++ b/libnavigation-core/api/current.txt @@ -529,6 +529,9 @@ package com.mapbox.navigation.core.replay.history { property public final Double? time; } + public final class ReplayEventLocationMapperKt { + } + public final class ReplayEventUpdateLocation implements com.mapbox.navigation.core.replay.history.ReplayEventBase { ctor public ReplayEventUpdateLocation(@com.google.gson.annotations.SerializedName("event_timestamp") double eventTimestamp, @com.google.gson.annotations.SerializedName("location") com.mapbox.navigation.core.replay.history.ReplayEventLocation location); method public double component1(); @@ -570,6 +573,33 @@ package com.mapbox.navigation.core.replay.history { method public com.mapbox.navigation.core.replay.history.ReplayHistoryMapper.Builder statusMapper(com.mapbox.navigation.core.replay.history.ReplayHistoryEventMapper? statusMapper); } + @com.mapbox.navigation.base.ExperimentalPreviewMapboxNavigationAPI public final class ReplayHistorySession implements com.mapbox.navigation.core.lifecycle.MapboxNavigationObserver { + ctor public ReplayHistorySession(); + method public kotlinx.coroutines.flow.StateFlow getOptions(); + method public void onAttached(com.mapbox.navigation.core.MapboxNavigation mapboxNavigation); + method public void onDetached(com.mapbox.navigation.core.MapboxNavigation mapboxNavigation); + method public void setHistoryFile(String absolutePath); + method public void setOptions(com.mapbox.navigation.core.replay.history.ReplayHistorySessionOptions options); + } + + @com.mapbox.navigation.base.ExperimentalPreviewMapboxNavigationAPI public final class ReplayHistorySessionOptions { + method public boolean getEnableSetRoute(); + method public String? getFilePath(); + method public com.mapbox.navigation.core.replay.history.ReplayHistoryMapper getReplayHistoryMapper(); + method public com.mapbox.navigation.core.replay.history.ReplayHistorySessionOptions.Builder toBuilder(); + property public final boolean enableSetRoute; + property public final String? filePath; + property public final com.mapbox.navigation.core.replay.history.ReplayHistoryMapper replayHistoryMapper; + } + + @com.mapbox.navigation.base.ExperimentalPreviewMapboxNavigationAPI public static final class ReplayHistorySessionOptions.Builder { + ctor public ReplayHistorySessionOptions.Builder(); + method public com.mapbox.navigation.core.replay.history.ReplayHistorySessionOptions build(); + method public com.mapbox.navigation.core.replay.history.ReplayHistorySessionOptions.Builder enableSetRoute(boolean enableSetRoute); + method public com.mapbox.navigation.core.replay.history.ReplayHistorySessionOptions.Builder filePath(String? filePath); + method public com.mapbox.navigation.core.replay.history.ReplayHistorySessionOptions.Builder replayHistoryMapper(com.mapbox.navigation.core.replay.history.ReplayHistoryMapper replayHistoryMapper); + } + public final class ReplaySetNavigationRoute implements com.mapbox.navigation.core.replay.history.ReplayEventBase { method public double getEventTimestamp(); method public com.mapbox.navigation.base.route.NavigationRoute? getRoute(); @@ -1027,8 +1057,10 @@ package com.mapbox.navigation.core.trip { @com.mapbox.navigation.base.ExperimentalPreviewMapboxNavigationAPI public final class MapboxTripStarter implements com.mapbox.navigation.core.lifecycle.MapboxNavigationObserver { method public static com.mapbox.navigation.core.trip.MapboxTripStarter create(); method public com.mapbox.navigation.core.trip.MapboxTripStarter enableMapMatching(); + method public com.mapbox.navigation.core.trip.MapboxTripStarter enableReplayHistory(com.mapbox.navigation.core.replay.history.ReplayHistorySessionOptions? options = null); method public com.mapbox.navigation.core.trip.MapboxTripStarter enableReplayRoute(com.mapbox.navigation.core.replay.route.ReplayRouteSessionOptions? options = null); method public static com.mapbox.navigation.core.trip.MapboxTripStarter getRegisteredInstance(); + method public com.mapbox.navigation.core.replay.history.ReplayHistorySessionOptions getReplayHistorySessionOptions(); method public com.mapbox.navigation.core.replay.route.ReplayRouteSessionOptions getReplayRouteSessionOptions(); method public void onAttached(com.mapbox.navigation.core.MapboxNavigation mapboxNavigation); method public void onDetached(com.mapbox.navigation.core.MapboxNavigation mapboxNavigation); diff --git a/libnavigation-core/src/main/java/com/mapbox/navigation/core/history/MapboxHistoryReaderProvider.kt b/libnavigation-core/src/main/java/com/mapbox/navigation/core/history/MapboxHistoryReaderProvider.kt new file mode 100644 index 00000000000..4415c0a11f0 --- /dev/null +++ b/libnavigation-core/src/main/java/com/mapbox/navigation/core/history/MapboxHistoryReaderProvider.kt @@ -0,0 +1,8 @@ +package com.mapbox.navigation.core.history + +import androidx.annotation.VisibleForTesting + +@VisibleForTesting +internal object MapboxHistoryReaderProvider { + fun create(filePath: String) = MapboxHistoryReader(filePath) +} diff --git a/libnavigation-core/src/main/java/com/mapbox/navigation/core/replay/history/ReplayHistorySession.kt b/libnavigation-core/src/main/java/com/mapbox/navigation/core/replay/history/ReplayHistorySession.kt new file mode 100644 index 00000000000..dac2fea9eaa --- /dev/null +++ b/libnavigation-core/src/main/java/com/mapbox/navigation/core/replay/history/ReplayHistorySession.kt @@ -0,0 +1,184 @@ +package com.mapbox.navigation.core.replay.history + +import com.mapbox.navigation.base.ExperimentalPreviewMapboxNavigationAPI +import com.mapbox.navigation.core.MapboxNavigation +import com.mapbox.navigation.core.history.MapboxHistoryReader +import com.mapbox.navigation.core.history.MapboxHistoryReaderProvider +import com.mapbox.navigation.core.history.MapboxHistoryRecorder +import com.mapbox.navigation.core.history.model.HistoryEvent +import com.mapbox.navigation.core.history.model.HistoryEventUpdateLocation +import com.mapbox.navigation.core.lifecycle.MapboxNavigationObserver +import com.mapbox.navigation.core.replay.MapboxReplayer +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.flow.update + +/** + * Testing and previewing the navigation experience is an important part of building with the + * Navigation SDK. You can use the SDK's replay functionality to go back in time and replay + * real experiences. + * + * The [ReplayHistorySession] will push the history events into the [MapboxReplayer]. Some + * customizations can found in the [ReplayHistorySessionOptions]. In order to record history files + * refer to the [MapboxHistoryRecorder]. + * + * Typically you would save a history file to a registry, and then select history files to replay + * in order to improve or demo an experience. But for the sake of example, this is how you would + * replay an experience that just happened. + * ``` + * mapboxNavigation.historyRecorder.stopRecording { historyFile -> + * historyFile?.let { + * replayHistorySession.setHistoryFile(historyFile) + * replayHistorySession.onAttached(mapboxNavigation) + * } + * } + * ``` + */ +@ExperimentalPreviewMapboxNavigationAPI +class ReplayHistorySession : MapboxNavigationObserver { + + private val optionsFlow = MutableStateFlow(ReplayHistorySessionOptions.Builder().build()) + private var mapboxHistoryReader: MapboxHistoryReader? = null + private var mapboxNavigation: MapboxNavigation? = null + private var lastHistoryEvent: ReplayEventBase? = null + private var coroutineScope: CoroutineScope? = null + + private val replayEventsObserver = ReplayEventsObserver { events -> + if (optionsFlow.value.enableSetRoute) { + events.filterIsInstance().forEach(::setRoute) + } + if (isLastEventPlayed(events)) { + pushMorePoints() + } + } + + /** + * Signals that the [mapboxNavigation] instance is ready for use. + * + * @param mapboxNavigation + */ + override fun onAttached(mapboxNavigation: MapboxNavigation) { + val coroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate) + .also { this.coroutineScope = it } + this.mapboxNavigation = mapboxNavigation + this.lastHistoryEvent = null + mapboxNavigation.startReplayTripSession() + mapboxNavigation.mapboxReplayer.stop() + mapboxNavigation.mapboxReplayer.registerObserver(replayEventsObserver) + observeStateFlow(mapboxNavigation).launchIn(coroutineScope) + } + + /** + * Signals that the [mapboxNavigation] instance is being detached. + * + * @param mapboxNavigation + */ + override fun onDetached(mapboxNavigation: MapboxNavigation) { + coroutineScope?.cancel() + coroutineScope = null + this.mapboxNavigation = null + mapboxNavigation.mapboxReplayer.unregisterObserver(replayEventsObserver) + mapboxNavigation.mapboxReplayer.stop() + mapboxNavigation.mapboxReplayer.clearEvents() + } + + /** + * Allows you to get or observe the [ReplayHistorySessionOptions]. + */ + fun getOptions(): StateFlow = optionsFlow.asStateFlow() + + /** + * Update the [ReplayHistorySessionOptions]. + */ + fun setOptions(options: ReplayHistorySessionOptions) { + optionsFlow.value = options + } + + /** + * Change the history file to replay, if changed after [onAttached], the previous trip will + * be reset and the new file will start. + */ + fun setHistoryFile(absolutePath: String) { + optionsFlow.update { it.toBuilder().filePath(absolutePath).build() } + } + + private fun observeStateFlow(mapboxNavigation: MapboxNavigation): Flow<*> { + return optionsFlow.mapDistinct { it.filePath }.onEach { historyFile -> + mapboxNavigation.mapboxReplayer.clearEvents() + mapboxNavigation.setNavigationRoutes(emptyList()) + mapboxHistoryReader = historyFile?.let { MapboxHistoryReaderProvider.create(it) } + mapboxNavigation.resetTripSession { + mapboxNavigation.mapboxReplayer.play() + pushMorePoints() + } + } + } + + private inline fun Flow.mapDistinct( + crossinline transform: suspend (value: T) -> R + ): Flow = map(transform).distinctUntilChanged() + + private fun isLastEventPlayed(events: List): Boolean { + val currentEvent = events.lastOrNull() ?: return false + val lastEventTimestamp = this.lastHistoryEvent?.eventTimestamp ?: 0.0 + return currentEvent.eventTimestamp >= lastEventTimestamp + } + + private fun pushMorePoints() { + val mapboxNavigation = mapboxNavigation ?: return + val replayEvents = mapboxHistoryReader?.takeLocations(TAKE_EVENT_COUNT) + ?.mapNotNull(::toReplayEvent) + ?.run { if (isNullOrEmpty()) null else this } + ?: return + lastHistoryEvent = replayEvents.lastOrNull() + mapboxNavigation.mapboxReplayer.clearPlayedEvents() + mapboxNavigation.mapboxReplayer.pushEvents(replayEvents) + } + + private fun toReplayEvent(historyEvent: HistoryEvent): ReplayEventBase? = + optionsFlow.value.replayHistoryMapper.mapToReplayEvent(historyEvent) + + private fun setRoute(replaySetRoute: ReplaySetNavigationRoute) { + replaySetRoute.route?.let { directionRoute -> + mapboxNavigation?.setNavigationRoutes(listOf(directionRoute)) + } + } + + /** + * Loads the next [count] location events from the history file and returns all of the + * [HistoryEvent] in a list. The size of the list returned will often be larger than [count] + * because there are other types of events in the history file. + * + * @param count the maximum number of [HistoryEventUpdateLocation] to take + */ + private fun MapboxHistoryReader.takeLocations(count: Int): List { + val historyEvents = mutableListOf() + var locationCount = 0 + while (locationCount < count && hasNext()) { + val event = next() + if (event is HistoryEventUpdateLocation) { + locationCount++ + } + historyEvents.add(event) + } + return historyEvents + } + + private companion object { + + /** + * Number of events to read from the history reader at a time. + */ + private const val TAKE_EVENT_COUNT = 10 + } +} diff --git a/libnavigation-core/src/main/java/com/mapbox/navigation/core/replay/history/ReplayHistorySessionOptions.kt b/libnavigation-core/src/main/java/com/mapbox/navigation/core/replay/history/ReplayHistorySessionOptions.kt new file mode 100644 index 00000000000..83ed7ebae58 --- /dev/null +++ b/libnavigation-core/src/main/java/com/mapbox/navigation/core/replay/history/ReplayHistorySessionOptions.kt @@ -0,0 +1,121 @@ +package com.mapbox.navigation.core.replay.history + +import com.mapbox.navigation.base.ExperimentalPreviewMapboxNavigationAPI +import com.mapbox.navigation.core.MapboxNavigation + +/** + * Options to use with the [ReplayHistorySession]. + * + * @param filePath absolute path to the history file + * @param replayHistoryMapper converts history events into replayable events + * @param enableSetRoute relays the set route events into [MapboxNavigation.setNavigationRoutes] + */ +@ExperimentalPreviewMapboxNavigationAPI +class ReplayHistorySessionOptions private constructor( + val filePath: String?, + val replayHistoryMapper: ReplayHistoryMapper, + val enableSetRoute: Boolean +) { + /** + * @return the builder that created the [ReplayHistorySessionOptions] + */ + fun toBuilder(): Builder = Builder().apply { + filePath(filePath) + replayHistoryMapper(replayHistoryMapper) + enableSetRoute(enableSetRoute) + } + + /** + * Regenerate whenever a change is made + */ + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as ReplayHistorySessionOptions + + if (filePath != other.filePath) return false + if (replayHistoryMapper != other.replayHistoryMapper) return false + if (enableSetRoute != other.enableSetRoute) return false + + return true + } + + /** + * Regenerate whenever a change is made + */ + override fun hashCode(): Int { + var result = filePath.hashCode() + result = 31 * result + replayHistoryMapper.hashCode() + result = 31 * result + enableSetRoute.hashCode() + return result + } + + /** + * Regenerate whenever a change is made + */ + override fun toString(): String { + return "ReplayHistorySessionOptions(" + + "filePath=$filePath, " + + "replayHistoryMapper=$replayHistoryMapper, " + + "enableSetRoute=$enableSetRoute" + + ")" + } + + /** + * Used to build [ReplayHistorySession]. + */ + @ExperimentalPreviewMapboxNavigationAPI + class Builder { + private var filePath: String? = null + private var replayHistoryMapper = ReplayHistoryMapper.Builder() + .setRouteMapper { + ReplaySetNavigationRoute.Builder(it.eventTimestamp) + .route(it.navigationRoute) + .build() + } + .build() + private var enableSetRoute: Boolean = true + + /** + * Build your [ReplayHistorySessionOptions]. + * + * @return [ReplayHistorySessionOptions] + */ + fun build(): ReplayHistorySessionOptions = ReplayHistorySessionOptions( + filePath = filePath, + replayHistoryMapper = replayHistoryMapper, + enableSetRoute = enableSetRoute, + ) + + /** + * Set a path to the history file. + * + * @param filePath absolute path to the history file. + * @return [Builder] + */ + fun filePath(filePath: String?) = apply { + this.filePath = filePath + } + + /** + * Set the [ReplayHistoryMapper]. Converts history events into replayable events. + * + * @param replayHistoryMapper [ReplayHistoryMapper] + * @return [Builder] + */ + fun replayHistoryMapper(replayHistoryMapper: ReplayHistoryMapper): Builder = apply { + this.replayHistoryMapper = replayHistoryMapper + } + + /** + * Relays the set route events into [MapboxNavigation.setNavigationRoutes] + * + * @param enableSetRoute + * @return [Builder] + */ + fun enableSetRoute(enableSetRoute: Boolean): Builder = apply { + this.enableSetRoute = enableSetRoute + } + } +} diff --git a/libnavigation-core/src/main/java/com/mapbox/navigation/core/trip/MapboxTripStarter.kt b/libnavigation-core/src/main/java/com/mapbox/navigation/core/trip/MapboxTripStarter.kt index 1ca87c4df7e..e960f555c3f 100644 --- a/libnavigation-core/src/main/java/com/mapbox/navigation/core/trip/MapboxTripStarter.kt +++ b/libnavigation-core/src/main/java/com/mapbox/navigation/core/trip/MapboxTripStarter.kt @@ -1,11 +1,15 @@ package com.mapbox.navigation.core.trip import android.annotation.SuppressLint +import androidx.annotation.VisibleForTesting import com.mapbox.android.core.permissions.PermissionsManager import com.mapbox.navigation.base.ExperimentalPreviewMapboxNavigationAPI import com.mapbox.navigation.core.MapboxNavigation +import com.mapbox.navigation.core.history.MapboxHistoryRecorder import com.mapbox.navigation.core.lifecycle.MapboxNavigationApp import com.mapbox.navigation.core.lifecycle.MapboxNavigationObserver +import com.mapbox.navigation.core.replay.history.ReplayHistorySession +import com.mapbox.navigation.core.replay.history.ReplayHistorySessionOptions import com.mapbox.navigation.core.replay.route.ReplayRouteSession import com.mapbox.navigation.core.replay.route.ReplayRouteSessionOptions import com.mapbox.navigation.core.trip.MapboxTripStarter.Companion.getRegisteredInstance @@ -32,7 +36,9 @@ import kotlinx.coroutines.flow.onEach * [getRegisteredInstance]. */ @ExperimentalPreviewMapboxNavigationAPI -class MapboxTripStarter internal constructor() : MapboxNavigationObserver { +class MapboxTripStarter internal constructor( + private val services: MapboxTripStarterServices = MapboxTripStarterServices() +) : MapboxNavigationObserver { private val tripType = MutableStateFlow( MapboxTripStarterType.MapMatching @@ -41,7 +47,8 @@ class MapboxTripStarter internal constructor() : MapboxNavigationObserver { ReplayRouteSessionOptions.Builder().build() ) private val isLocationPermissionGranted = MutableStateFlow(false) - private var replayRouteTripSession: ReplayRouteSession? = null + private var replayRouteSession: ReplayRouteSession? = null + private val replayHistorySession = services.getReplayHistorySession() private var mapboxNavigation: MapboxNavigation? = null private lateinit var coroutineScope: CoroutineScope @@ -118,58 +125,101 @@ class MapboxTripStarter internal constructor() : MapboxNavigationObserver { tripType.value = MapboxTripStarterType.ReplayRoute } + /** + * Get the current [ReplayHistorySessionOptions]. This can be used with [enableReplayHistory] + * to make minor adjustments to the current options. + */ + fun getReplayHistorySessionOptions(): ReplayHistorySessionOptions = + replayHistorySession.getOptions().value + + /** + * Enables a mode where history files will be replayed. Set an absolute path to the history + * file in the [options]. Use [MapboxNavigation.historyRecorder] to get an instance of the + * [MapboxHistoryRecorder] to record a history file. + * + * @param options used for the history session. + */ + fun enableReplayHistory( + options: ReplayHistorySessionOptions? = null + ) = apply { + options?.let { options -> replayHistorySession.setOptions(options) } + tripType.value = MapboxTripStarterType.ReplayHistory + } + @OptIn(ExperimentalCoroutinesApi::class) private fun observeStateFlow(mapboxNavigation: MapboxNavigation): Flow<*> { return tripType.flatMapLatest { tripType -> when (tripType) { - MapboxTripStarterType.ReplayRoute -> - replayRouteSessionOptions.onEach { options -> - onReplayTripEnabled(mapboxNavigation, options) - } MapboxTripStarterType.MapMatching -> isLocationPermissionGranted.onEach { granted -> onMapMatchingEnabled(mapboxNavigation, granted) } + MapboxTripStarterType.ReplayRoute -> + replayRouteSessionOptions.onEach { options -> + onReplayRouteEnabled(mapboxNavigation, options) + } + MapboxTripStarterType.ReplayHistory -> + replayHistorySession.getOptions().onEach { options -> + onReplayHistoryEnabled(mapboxNavigation, options) + } } } } + /** + * Internally called when the trip type has been set to map matching. + * + * @param mapboxNavigation + * @param granted true when location permissions are accepted, false otherwise + */ + @SuppressLint("MissingPermission") + private fun onMapMatchingEnabled(mapboxNavigation: MapboxNavigation, granted: Boolean) { + if (granted) { + replayRouteSession?.onDetached(mapboxNavigation) + replayRouteSession = null + replayHistorySession.onDetached(mapboxNavigation) + mapboxNavigation.startTripSession() + } else { + logI(LOG_CATEGORY) { + "startTripSession was not called. Accept location permissions and call " + + "mapboxTripStarter.refreshLocationPermissions()" + } + onTripDisabled(mapboxNavigation) + } + } + /** * Internally called when the trip type has been set to replay route. * * @param mapboxNavigation * @param options parameters for the [ReplayRouteSession] */ - private fun onReplayTripEnabled( + private fun onReplayRouteEnabled( mapboxNavigation: MapboxNavigation, options: ReplayRouteSessionOptions ) { - replayRouteTripSession?.onDetached(mapboxNavigation) - replayRouteTripSession = ReplayRouteSession().also { + replayHistorySession.onDetached(mapboxNavigation) + replayRouteSession?.onDetached(mapboxNavigation) + replayRouteSession = services.getReplayRouteSession().also { it.setOptions(options) it.onAttached(mapboxNavigation) } } /** - * Internally called when the trip type has been set to map matching. + * Internally called when the trip type has been set to replay history. * * @param mapboxNavigation - * @param granted true when location permissions are accepted, false otherwise + * @param options parameters for the [ReplayHistorySession] */ - @SuppressLint("MissingPermission") - private fun onMapMatchingEnabled(mapboxNavigation: MapboxNavigation, granted: Boolean) { - if (granted) { - replayRouteTripSession?.onDetached(mapboxNavigation) - replayRouteTripSession = null - mapboxNavigation.startTripSession() - } else { - logI(LOG_CATEGORY) { - "startTripSession was not called. Accept location permissions and call " + - "mapboxTripStarter.refreshLocationPermissions()" - } - onTripDisabled(mapboxNavigation) - } + private fun onReplayHistoryEnabled( + mapboxNavigation: MapboxNavigation, + options: ReplayHistorySessionOptions + ) { + replayRouteSession?.onDetached(mapboxNavigation) + replayRouteSession = null + replayHistorySession.setOptions(options) + replayHistorySession.onAttached(mapboxNavigation) } /** @@ -178,8 +228,9 @@ class MapboxTripStarter internal constructor() : MapboxNavigationObserver { * @param mapboxNavigation */ private fun onTripDisabled(mapboxNavigation: MapboxNavigation) { - replayRouteTripSession?.onDetached(mapboxNavigation) - replayRouteTripSession = null + replayRouteSession?.onDetached(mapboxNavigation) + replayRouteSession = null + replayHistorySession.onDetached(mapboxNavigation) mapboxNavigation.stopTripSession() } @@ -201,3 +252,10 @@ class MapboxTripStarter internal constructor() : MapboxNavigationObserver { .firstOrNull() ?: MapboxTripStarter().also { MapboxNavigationApp.registerObserver(it) } } } + +@VisibleForTesting +@OptIn(ExperimentalPreviewMapboxNavigationAPI::class) +internal class MapboxTripStarterServices { + fun getReplayRouteSession() = ReplayRouteSession() + fun getReplayHistorySession() = ReplayHistorySession() +} diff --git a/libnavigation-core/src/main/java/com/mapbox/navigation/core/trip/MapboxTripStarterType.kt b/libnavigation-core/src/main/java/com/mapbox/navigation/core/trip/MapboxTripStarterType.kt index 0bc5784c670..84b719bb0ce 100644 --- a/libnavigation-core/src/main/java/com/mapbox/navigation/core/trip/MapboxTripStarterType.kt +++ b/libnavigation-core/src/main/java/com/mapbox/navigation/core/trip/MapboxTripStarterType.kt @@ -11,7 +11,12 @@ internal sealed class MapboxTripStarterType { object MapMatching : MapboxTripStarterType() /** - * The [MapboxTripStarter] will enable replay for the navigation routes. + * The [MapboxTripStarter] will replay navigation routes with an artificial driver. */ object ReplayRoute : MapboxTripStarterType() + + /** + * The [MapboxTripStarter] will use history files to replay navigation experiences. + */ + object ReplayHistory : MapboxTripStarterType() } diff --git a/libnavigation-core/src/test/java/com/mapbox/navigation/core/replay/history/ReplayHistorySessionOptionsTest.kt b/libnavigation-core/src/test/java/com/mapbox/navigation/core/replay/history/ReplayHistorySessionOptionsTest.kt new file mode 100644 index 00000000000..cf759a8a149 --- /dev/null +++ b/libnavigation-core/src/test/java/com/mapbox/navigation/core/replay/history/ReplayHistorySessionOptionsTest.kt @@ -0,0 +1,26 @@ +package com.mapbox.navigation.core.replay.history + +import com.mapbox.navigation.base.ExperimentalPreviewMapboxNavigationAPI +import com.mapbox.navigation.testing.BuilderTest +import io.mockk.mockk +import org.junit.Test +import kotlin.reflect.KClass + +@OptIn(ExperimentalPreviewMapboxNavigationAPI::class) +class ReplayHistorySessionOptionsTest : + BuilderTest() { + override fun getImplementationClass(): KClass = + ReplayHistorySessionOptions::class + + override fun getFilledUpBuilder(): ReplayHistorySessionOptions.Builder { + return ReplayHistorySessionOptions.Builder() + .filePath("test_path") + .replayHistoryMapper(mockk(relaxed = true)) + .enableSetRoute(false) + } + + @Test + override fun trigger() { + // only used to trigger JUnit4 to run this class if all test cases come from the parent + } +} diff --git a/libnavigation-core/src/test/java/com/mapbox/navigation/core/replay/history/ReplayHistorySessionTest.kt b/libnavigation-core/src/test/java/com/mapbox/navigation/core/replay/history/ReplayHistorySessionTest.kt new file mode 100644 index 00000000000..e5308437ae7 --- /dev/null +++ b/libnavigation-core/src/test/java/com/mapbox/navigation/core/replay/history/ReplayHistorySessionTest.kt @@ -0,0 +1,257 @@ +package com.mapbox.navigation.core.replay.history + +import android.content.Context +import com.mapbox.navigation.base.ExperimentalPreviewMapboxNavigationAPI +import com.mapbox.navigation.base.options.NavigationOptions +import com.mapbox.navigation.core.MapboxNavigation +import com.mapbox.navigation.core.TripSessionResetCallback +import com.mapbox.navigation.core.directions.session.RoutesObserver +import com.mapbox.navigation.core.history.MapboxHistoryReader +import com.mapbox.navigation.core.history.MapboxHistoryReaderProvider +import com.mapbox.navigation.core.history.model.HistoryEvent +import com.mapbox.navigation.core.history.model.HistoryEventSetRoute +import com.mapbox.navigation.core.history.model.HistoryEventUpdateLocation +import com.mapbox.navigation.core.replay.MapboxReplayer +import com.mapbox.navigation.core.trip.session.RouteProgressObserver +import com.mapbox.navigation.testing.LoggingFrontendTestRule +import io.mockk.every +import io.mockk.just +import io.mockk.mockk +import io.mockk.mockkObject +import io.mockk.runs +import io.mockk.slot +import io.mockk.unmockkAll +import io.mockk.verify +import io.mockk.verifyOrder +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Before +import org.junit.Rule +import org.junit.Test + +@OptIn(ExperimentalPreviewMapboxNavigationAPI::class) +class ReplayHistorySessionTest { + + @get:Rule + val loggerRule = LoggingFrontendTestRule() + + private val replayer: MapboxReplayer = mockk(relaxed = true) + private val historyReader: MapboxHistoryReader = mockk(relaxed = true) + + private val sut = ReplayHistorySession() + + @Before + fun setup() { + mockkObject(MapboxHistoryReaderProvider) + every { MapboxHistoryReaderProvider.create(any()) } returns historyReader + } + + @After + fun teardown() { + unmockkAll() + } + + @Test + fun `onAttached will call startReplayTripSession`() { + val mapboxNavigation = mockMapboxNavigation() + + sut.onAttached(mapboxNavigation) + + verify(exactly = 1) { mapboxNavigation.startReplayTripSession() } + } + + @Test + fun `onAttached will call MapboxReplayer#play`() { + val mapboxNavigation = mockMapboxNavigation() + + sut.onAttached(mapboxNavigation) + + verify(exactly = 1) { replayer.play() } + } + + @Test + fun `setHistoryFile after onAttached will clear events and reset the trip`() { + val mapboxNavigation = mockMapboxNavigation() + + sut.onAttached(mapboxNavigation) + sut.setHistoryFile("test_file_path") + + verifyOrder { + mapboxNavigation.startReplayTripSession() + replayer.clearEvents() + mapboxNavigation.setNavigationRoutes(emptyList()) + mapboxNavigation.resetTripSession(any()) + replayer.play() + // setHistoryFile was called + replayer.clearEvents() + mapboxNavigation.setNavigationRoutes(emptyList()) + MapboxHistoryReaderProvider.create("test_file_path") + mapboxNavigation.resetTripSession(any()) + replayer.play() + } + verify(exactly = 1) { + mapboxNavigation.startReplayTripSession() + MapboxHistoryReaderProvider.create(any()) + } + } + + @Test + fun `setHistoryFile before onAttached will initialize once`() { + val mapboxNavigation = mockMapboxNavigation() + + sut.setHistoryFile("test_file_path") + sut.onAttached(mapboxNavigation) + + verifyOrder { + mapboxNavigation.startReplayTripSession() + replayer.clearEvents() + mapboxNavigation.setNavigationRoutes(emptyList()) + MapboxHistoryReaderProvider.create("test_file_path") + mapboxNavigation.resetTripSession(any()) + replayer.play() + } + verify(exactly = 1) { + mapboxNavigation.startReplayTripSession() + replayer.clearEvents() + mapboxNavigation.setNavigationRoutes(any()) + MapboxHistoryReaderProvider.create(any()) + mapboxNavigation.resetTripSession(any()) + replayer.play() + } + } + + @Test + fun `onDetached will clean up but will not stopTripSession`() { + val mapboxNavigation = mockMapboxNavigation() + + sut.onAttached(mapboxNavigation) + sut.onDetached(mapboxNavigation) + + verifyOrder { + mapboxNavigation.startReplayTripSession() + replayer.stop() + replayer.registerObserver(any()) + replayer.clearEvents() + mapboxNavigation.setNavigationRoutes(emptyList()) + mapboxNavigation.resetTripSession(any()) + replayer.play() + // onDetached called + replayer.unregisterObserver(any()) + replayer.stop() + replayer.clearEvents() + } + verify(exactly = 1) { + replayer.play() + } + } + + @Test + fun `should push events from history file`() { + val mapboxNavigation = mockMapboxNavigation() + val eventCount = 100 + every { historyReader.hasNext() } returnsMany (0..eventCount) + .map { it != eventCount } + every { historyReader.next() } returnsMany (1..eventCount) + .map { value -> + mockk { + every { eventTimestamp } returns value.toDouble() + every { location } returns mockk() + } + } + val eventObserver = slot() + every { replayer.registerObserver(capture(eventObserver)) } just runs + val eventSlot = mutableListOf>() + every { replayer.pushEvents(capture(eventSlot)) } answers { + eventObserver.captured.replayEvents(firstArg()) + replayer + } + + sut.setOptions(mockOptions()) + sut.onAttached(mapboxNavigation) + + val capturedEvents = eventSlot.flatten() + assertEquals(100, capturedEvents.size) + } + + @Test + fun `should setNavigationRoutes from history file when option is enabled`() { + val mapboxNavigation = mockMapboxNavigation() + val options = mockOptions() + every { options.enableSetRoute } returns true + every { historyReader.hasNext() } returnsMany listOf(true, false) + every { historyReader.next() } returnsMany listOf( + mockk { + every { eventTimestamp } returns 11.0 + every { navigationRoute } returns mockk() + } + ) + val eventObserver = slot() + every { replayer.registerObserver(capture(eventObserver)) } just runs + val eventSlot = mutableListOf>() + every { replayer.pushEvents(capture(eventSlot)) } answers { + eventObserver.captured.replayEvents(firstArg()) + replayer + } + + sut.setOptions(options) + sut.onAttached(mapboxNavigation) + + verify { mapboxNavigation.setNavigationRoutes(any()) } + } + + @Test + fun `should not setNavigationRoutes from history file when option is disabled`() { + val mapboxNavigation = mockMapboxNavigation() + val options = mockOptions() + every { options.enableSetRoute } returns false + every { historyReader.hasNext() } returnsMany listOf(true, false) + every { historyReader.next() } returnsMany listOf( + mockk { + every { eventTimestamp } returns 11.0 + every { navigationRoute } returns mockk() + } + ) + val eventObserver = slot() + every { replayer.registerObserver(capture(eventObserver)) } just runs + val eventSlot = mutableListOf>() + every { replayer.pushEvents(capture(eventSlot)) } answers { + eventObserver.captured.replayEvents(firstArg()) + replayer + } + + sut.setOptions(options) + sut.onAttached(mapboxNavigation) + + verify { mapboxNavigation.setNavigationRoutes(any()) } + } + + private fun mockMapboxNavigation(): MapboxNavigation { + val context: Context = mockk(relaxed = true) + val options: NavigationOptions = mockk { + every { applicationContext } returns context + } + val routesObserver = slot() + val routeProgressObserver = slot() + return mockk(relaxed = true) { + every { mapboxReplayer } returns replayer + every { navigationOptions } returns options + every { registerRoutesObserver(capture(routesObserver)) } just runs + every { registerRouteProgressObserver(capture(routeProgressObserver)) } just runs + every { resetTripSession(any()) } answers { + firstArg().onTripSessionReset() + } + } + } + + private fun mockOptions(): ReplayHistorySessionOptions = mockk { + every { filePath } returns "test_file_path" + every { replayHistoryMapper } returns mockk { + every { mapToReplayEvent(any()) } answers { + mockk { + every { eventTimestamp } returns firstArg().eventTimestamp + } + } + } + every { enableSetRoute } returns true + } +} diff --git a/libnavigation-core/src/test/java/com/mapbox/navigation/core/trip/MapboxTripStarterTest.kt b/libnavigation-core/src/test/java/com/mapbox/navigation/core/trip/MapboxTripStarterTest.kt index 45c4f2ce608..0dbcb58463f 100644 --- a/libnavigation-core/src/test/java/com/mapbox/navigation/core/trip/MapboxTripStarterTest.kt +++ b/libnavigation-core/src/test/java/com/mapbox/navigation/core/trip/MapboxTripStarterTest.kt @@ -5,6 +5,9 @@ import com.mapbox.common.LoggingLevel import com.mapbox.navigation.base.ExperimentalPreviewMapboxNavigationAPI import com.mapbox.navigation.core.MapboxNavigation import com.mapbox.navigation.core.directions.session.RoutesObserver +import com.mapbox.navigation.core.replay.history.ReplayHistorySession +import com.mapbox.navigation.core.replay.history.ReplayHistorySessionOptions +import com.mapbox.navigation.core.replay.route.ReplayRouteSession import com.mapbox.navigation.core.trip.session.TripSessionState import com.mapbox.navigation.testing.LoggingFrontendTestRule import com.mapbox.navigation.testing.MainCoroutineRule @@ -19,6 +22,7 @@ import io.mockk.unmockkAll import io.mockk.verify import io.mockk.verifyOrder import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.MutableStateFlow import org.junit.After import org.junit.Assert.assertTrue import org.junit.Before @@ -40,7 +44,21 @@ class MapboxTripStarterTest { @get:Rule val coroutineRule = MainCoroutineRule() - private val sut = MapboxTripStarter() + private val replayRouteSession = mockk(relaxed = true) + private var historyOptions = MutableStateFlow(ReplayHistorySessionOptions.Builder().build()) + private val replayHistorySession = mockk(relaxed = true) { + every { getOptions() } returns historyOptions + every { setOptions(any()) } answers { + historyOptions.value = firstArg() + } + } + + private val sut = MapboxTripStarter( + mockk { + every { getReplayRouteSession() } returns replayRouteSession + every { getReplayHistorySession() } returns replayHistorySession + } + ) @Before fun setup() { @@ -121,18 +139,19 @@ class MapboxTripStarterTest { } @Test - fun `enableReplayRoute will startReplayTripSession without location permissions`() { + fun `enableReplayRoute will attach ReplayRouteSession without location permissions`() { every { PermissionsManager.areLocationPermissionsGranted(any()) } returns false val mapboxNavigation = mockMapboxNavigation() sut.enableReplayRoute() sut.onAttached(mapboxNavigation) - verify(exactly = 1) { mapboxNavigation.startReplayTripSession() } + verify(exactly = 1) { replayRouteSession.onAttached(mapboxNavigation) } + verify(exactly = 0) { replayRouteSession.onDetached(mapboxNavigation) } } @Test - fun `enableReplayRoute will resetTripSession when the options change`() { + fun `enableReplayRoute will set options before onAttached`() { every { PermissionsManager.areLocationPermissionsGranted(any()) } returns false val mapboxNavigation = mockMapboxNavigation() @@ -144,10 +163,11 @@ class MapboxTripStarterTest { sut.enableReplayRoute(nextOptions) verifyOrder { - mapboxNavigation.startReplayTripSession() - mapboxNavigation.resetTripSession(any()) - mapboxNavigation.startReplayTripSession() - mapboxNavigation.resetTripSession(any()) + replayRouteSession.setOptions(any()) + replayRouteSession.onAttached(mapboxNavigation) + replayRouteSession.onDetached(mapboxNavigation) + replayRouteSession.setOptions(nextOptions) + replayRouteSession.onAttached(mapboxNavigation) } verify(exactly = 0) { mapboxNavigation.stopTripSession() } } @@ -163,14 +183,16 @@ class MapboxTripStarterTest { sut.onDetached(mapboxNavigation) verifyOrder { - mapboxNavigation.startReplayTripSession() + replayHistorySession.onDetached(mapboxNavigation) + replayRouteSession.onAttached(mapboxNavigation) + replayRouteSession.onDetached(mapboxNavigation) mapboxNavigation.startTripSession() mapboxNavigation.stopTripSession() } } @Test - fun `setLocationPermissionGranted will not restart startReplayTripSession`() { + fun `setLocationPermissionGranted will not restart ReplayRouteSession`() { every { PermissionsManager.areLocationPermissionsGranted(any()) } returns false val mapboxNavigation = mockMapboxNavigation() @@ -179,11 +201,11 @@ class MapboxTripStarterTest { every { PermissionsManager.areLocationPermissionsGranted(any()) } returns true sut.refreshLocationPermissions() - verify(exactly = 1) { mapboxNavigation.startReplayTripSession() } + verify(exactly = 1) { replayRouteSession.onAttached(mapboxNavigation) } } @Test - fun `update will not stop a trip session that has been started`() { + fun `enableReplayRoute will not stop a trip session that has been started`() { val mapboxNavigation = mockMapboxNavigation() every { mapboxNavigation.getTripSessionState() } returns TripSessionState.STARTED every { mapboxNavigation.isReplayEnabled() } returns false @@ -192,11 +214,11 @@ class MapboxTripStarterTest { sut.enableReplayRoute() verify(exactly = 0) { mapboxNavigation.stopTripSession() } - verify(exactly = 1) { mapboxNavigation.startReplayTripSession() } + verify(exactly = 1) { replayRouteSession.onAttached(mapboxNavigation) } } @Test - fun `update before onAttached will not startTripSession`() { + fun `enableReplayRoute before onAttached will not startTripSession`() { val mapboxNavigation = mockMapboxNavigation() sut.enableReplayRoute() @@ -204,7 +226,42 @@ class MapboxTripStarterTest { verify(exactly = 0) { mapboxNavigation.stopTripSession() } verify(exactly = 0) { mapboxNavigation.startTripSession() } - verify(exactly = 1) { mapboxNavigation.startReplayTripSession() } + verify(exactly = 1) { replayRouteSession.onAttached(mapboxNavigation) } + } + + @Test + fun `enableReplayHistory before onAttached will not startTripSession`() { + val mapboxNavigation = mockMapboxNavigation() + + sut.enableReplayHistory() + sut.onAttached(mapboxNavigation) + + verify(exactly = 0) { mapboxNavigation.stopTripSession() } + verify(exactly = 0) { mapboxNavigation.startTripSession() } + verify(exactly = 1) { replayHistorySession.onAttached(mapboxNavigation) } + } + + @Test + fun `enableReplayHistory will set options before onAttached`() { + every { PermissionsManager.areLocationPermissionsGranted(any()) } returns false + + val mapboxNavigation = mockMapboxNavigation() + sut.enableReplayHistory() + sut.onAttached(mapboxNavigation) + val nextOptions = sut.getReplayHistorySessionOptions().toBuilder() + .enableSetRoute(false) + .build() + sut.enableReplayHistory(nextOptions) + + verifyOrder { + replayHistorySession.setOptions(any()) + replayHistorySession.onAttached(mapboxNavigation) + replayHistorySession.setOptions(nextOptions) + } + verify(exactly = 0) { + mapboxNavigation.stopTripSession() + replayHistorySession.onDetached(mapboxNavigation) + } } private fun mockMapboxNavigation(): MapboxNavigation { diff --git a/qa-test-app/src/main/java/com/mapbox/navigation/qa_test_app/view/RoadObjectsActivity.kt b/qa-test-app/src/main/java/com/mapbox/navigation/qa_test_app/view/RoadObjectsActivity.kt index eddadca9dba..e2f8a21bbf0 100644 --- a/qa-test-app/src/main/java/com/mapbox/navigation/qa_test_app/view/RoadObjectsActivity.kt +++ b/qa-test-app/src/main/java/com/mapbox/navigation/qa_test_app/view/RoadObjectsActivity.kt @@ -27,6 +27,7 @@ import com.mapbox.navigation.base.trip.model.roadobject.tollcollection.TollColle import com.mapbox.navigation.base.trip.model.roadobject.tollcollection.TollCollectionType import com.mapbox.navigation.core.MapboxNavigation import com.mapbox.navigation.core.directions.session.RoutesObserver +import com.mapbox.navigation.core.replay.history.ReplayHistorySessionOptions import com.mapbox.navigation.core.replay.route.ReplayRouteMapper import com.mapbox.navigation.core.trip.MapboxTripStarter import com.mapbox.navigation.core.trip.session.LocationMatcherResult @@ -48,6 +49,7 @@ import com.mapbox.navigation.ui.maps.route.line.model.RouteLine import com.mapbox.navigation.ui.maps.route.line.model.RouteLineResources import com.mapbox.navigation.ui.tripprogress.model.DistanceRemainingFormatter import com.mapbox.navigation.utils.internal.ifNonNull +import java.io.File class RoadObjectsActivity : AppCompatActivity() { @@ -79,7 +81,16 @@ class RoadObjectsActivity : AppCompatActivity() { @OptIn(ExperimentalPreviewMapboxNavigationAPI::class) private val mapboxTripStarter = MapboxTripStarter.create() - .enableReplayRoute() + .enableReplayHistory( + options = ReplayHistorySessionOptions.Builder() + .filePath(getFirstHistoryFile()) + .build() + ) + + fun getFirstHistoryFile(): String? { + return mapboxNavigation.historyRecorder.fileDirectory()?.let { File(it) } + ?.listFiles()?.firstOrNull()?.absolutePath + } private val mapCamera: CameraAnimationsPlugin by lazy { binding.mapView.camera