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

Implement annotation publisher on Android #324

Merged
merged 5 commits into from
Nov 12, 2024
Merged
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
Original file line number Diff line number Diff line change
Expand Up @@ -220,8 +220,6 @@ class FerrostarCore(
* @param route the route to navigate.
* @param config change the configuration in the core before staring navigation. This was
* originally provided on init, but you can set a new value for future sessions.
* @return a view model tied to the navigation session. This can be ignored if you're injecting
* the [NavigationViewModel]/[DefaultNavigationViewModel].
* @throws UserLocationUnknown if the location provider has no last known location.
*/
@Throws(UserLocationUnknown::class)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@ package com.stadiamaps.ferrostar.core
import android.util.Log
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.stadiamaps.ferrostar.core.annotation.AnnotationPublisher
import com.stadiamaps.ferrostar.core.annotation.NoOpAnnotationPublisher
import com.stadiamaps.ferrostar.core.extensions.currentRoadName
import com.stadiamaps.ferrostar.core.extensions.deviation
import com.stadiamaps.ferrostar.core.extensions.progress
Expand Down Expand Up @@ -104,14 +106,17 @@ interface NavigationViewModel {
*/
class DefaultNavigationViewModel(
private val ferrostarCore: FerrostarCore,
private val annotationPublisher: AnnotationPublisher<*> = NoOpAnnotationPublisher()
) : ViewModel(), NavigationViewModel {

private val muteState: StateFlow<Boolean?> =
ferrostarCore.spokenInstructionObserver?.muteState ?: MutableStateFlow(null)

override val uiState =
combine(ferrostarCore.state, muteState) { a, b -> a to b }
.map { (coreState, muteState) ->
.map { (coreState, muteState) -> annotationPublisher.map(coreState) to muteState }
.map { (stateWrapper, muteState) ->
ianthetechie marked this conversation as resolved.
Show resolved Hide resolved
val coreState = stateWrapper.state
val location = ferrostarCore.locationProvider.lastLocation
val userLocation =
when (coreState.tripState) {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
package com.stadiamaps.ferrostar.core.annotation

import com.stadiamaps.ferrostar.core.NavigationState

interface AnnotationPublisher<T> {
fun map(state: NavigationState): AnnotationWrapper<T>
Archdoog marked this conversation as resolved.
Show resolved Hide resolved
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
package com.stadiamaps.ferrostar.core.annotation

import com.stadiamaps.ferrostar.core.NavigationState

data class AnnotationWrapper<T>(
val annotation: T? = null,
val speed: Speed? = null,
val state: NavigationState
Copy link
Collaborator

Choose a reason for hiding this comment

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

I think we either need to:

  1. Make this the full NavigationStateWrapper<AnnotationType> which includes everything related to navigation (including annotation stuff).
  2. Remove val state: NavigationState and publish annotations separately to navigation state on the ViewModel(s).

This decision can be made here, then it'll be applied to the AnnotationPublishers

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Agreed, and wanted to share some thoughts:

  • the first solution would imply that NavigationViewModel now is typed as well, returning whatever AnnotationWrapper is there.
  • the second solution could, in theory, cause issues of being out of sync (since you have 2 sources of truth).

I mentioned to Ian before that our current solution is to skip the NavigationViewModel entirely and just apply something similar to wrap the state with parsed annotations before emitting it in a single Flow on our side. The first solution would allow usages that use FerrostarCore directly without using NavigationViewModel to benefit from this by just mapping the FerrostarCore state to the wrapped state.

Other thoughts:

  • performance for people who don't need annotations - they can use some sort of NoAnnotationPublisher that just sets them to null automagically. We can provide that here if so.
  • publishing annotations for the entire route (as per the bonus part of Android Annotation Flow Publisher #316) - we can have DefaultAnnotationPublisher (faster since it'd only parse the current annotation, as the PR does today), and DefaultFullRouteAnnotationPublisher to allow choosing whether or not to also publish annotations for the entire route.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Took another look at this today and am torn between two different approaches and their implications:

Returning a NavigationStateWrapper<AnnotationType> from the DefaultNavigationViewModel (instead of a NavigationUiState directly as is the case today) has some cons:

  • it's not intuitive - a ViewModel should return the view model ready for rendering, and this is just wrapping it with annotation stuff that cannot (directly) be rendered (well, minus speed)
  • it encourages bad practice of business logic in the ui layer (if I have the annotations and need to do something to get a specific subset to get congestion, for example, I'll be tempted to do this in the ui, which is not good).

It seems like it'd be better to continue returning NavigationUiState, enriched with whatever annotation data from the parsed annotations that we'd like to use.

On the flip side, doing this makes it really difficult to make NavigationViewModel and DefaultNavigationViewModel customizable, since the mapping of NavigationState plus annotations from the annotation publisher all happen inside of the DefaultNavigationViewModel. I thought of having a lambda parameter to specify how to map NavigationStateWrapper into a NavigationUiState, but that requires parameterized types to bubble up to FerrostarCore, (due to the startNavigation method calls).

I think it makes sense to do one of 2 things:

  1. make DefaultNavigationViewModel the opinionated / not customizable default. using this, you can't modify annotations beyond what we put in for the app demo to support the features we want to support (speed, lanes, traffic, etc). People who want to customize can either implement their own implementation of NavigationViewModel (though they'll need to skip FerrostarCore.startNavigation() call in favor of their own), or just rely on FerrostarCore.state directly and do whatever they want with it.

  2. if we want to make it customizable, we can add a lambda parameter (with a default value) of how to map a NavigationStateWrapper<AnnotationType> into a NavigationUiState. This would imply making NavigationViewModel a parameterized type, and would bubble to FerrostarCore, due to startNavigation() needing to know what type to make without losing type info. in that case though, why even force NavigationUiState if we go with this, instead of a NavigationViewModel<ANNOTATION_TYPE, STATE_TYPE>?

In my opinion, i'd vote for option 1 because:

  • DefaultNavigationViewModel is small - 158 lines including the state object itself (which is ~35 lines), plus imports (~20 lines) - meaning it's less than 100 lines.
  • DefaultNavigationViewModel itself is an Android ViewModel, and while many use them, many opt to not using them. Making it super customizable would still let people who don't want to use Android ViewModels roll their own solution on top of FerrostarCore directly.

Finally, should we consider moving startNavigation out of FerrostarCore?

Copy link
Contributor Author

@ahmedre ahmedre Nov 8, 2024

Choose a reason for hiding this comment

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

Side note - the implementation of this patch is the implementation of point 1 above. So when we come to add speed, we'd not change the signature of NavigationViewModel, and we'd only update the NavigationUiState to have a field for speed which we'd wire in the ferrostarCore.map that we have there. Consequently marking this as Ready for Review but happy to revisit it accordingly.

Copy link
Contributor

Choose a reason for hiding this comment

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

Some relevant bits over here: #345 (review).

@Archdoog and I have been discussing that the current situation is a bit of a mess on Android. I am fairly convinced now that returning a viewmodel from startNavigation was a very bad idea (and it was thoroughly mine to be clear :P). Instead, every app will create their own view model anyways that reflects their way of looking at things.

That said, we should include a "default" view model so that it is still possible to drop Ferrostar into a minimalist "modal" activity triggered by an application which is not primarily map-centric. This ironically no longer applies to our demo app, but I think we should still include it.

So TL;DR I'm endorsing @ahmedre's first suggestion with the commits I made last night (not even noticing the thread haha). That both of us came to the same conclusion independently seems to indicate that it's the right one. My current code it #345 gets its state from the state property of FerrostarCore and a few other flows, coalescing into a single UI state.

Finally, should we consider moving startNavigation out of FerrostarCore?

Can you elaborate on this?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Can you elaborate on this?

What you did in #345 :)

Copy link
Contributor Author

Choose a reason for hiding this comment

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

@ianthetechie after rebasing, am now am wondering whether my changes to NavigationViewModel should go into DemoNavigationViewModel instead, since no one is using DefaultNavigationViewModel anymore anyway? it seems confusing to me for both of these to be there as recommendations though?

Copy link
Contributor

Choose a reason for hiding this comment

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

I'll look again in the morning with a clearer head, but re: why both view models exist, the point of the default on is to be functional for apps that are just doing the "modal navigation" style of fetching a route somewhere in app code based on user action, and then switching to a navigation view that has no other purpose.

I think it still makes sense to have this, but I'm far from the resident expert on view models 😅 Does this make sense? Or do you think we sholud drop that one too?

cc @Archdoog

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I didn't wire this up into the DemoNavigationViewModel for now, but can do so in the next PR where we connect the speed limit.

)
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
package com.stadiamaps.ferrostar.core.annotation

import com.squareup.moshi.JsonAdapter
import com.stadiamaps.ferrostar.core.NavigationState
import uniffi.ferrostar.TripState

class DefaultAnnotationPublisher<T>(
private val adapter: JsonAdapter<T>,
private val speedLimitMapper: (T?) -> Speed?,
) : AnnotationPublisher<T> {

override fun map(state: NavigationState): AnnotationWrapper<T> {
val annotations = decodeAnnotations(state)
return AnnotationWrapper(annotations, speedLimitMapper(annotations), state)
}

private fun decodeAnnotations(state: NavigationState): T? {
return if (state.tripState is TripState.Navigating) {
val json = state.tripState.annotationJson
if (json != null) {
adapter.fromJson(json)
} else {
null
}
} else {
null
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
package com.stadiamaps.ferrostar.core.annotation

import com.stadiamaps.ferrostar.core.NavigationState

class NoOpAnnotationPublisher : AnnotationPublisher<Unit> {
override fun map(state: NavigationState): AnnotationWrapper<Unit> {
return AnnotationWrapper(state = state)
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
package com.stadiamaps.ferrostar.core.annotation

sealed class Speed {
data object NoLimit : Speed()

data object Unknown : Speed()

data class Value(val value: Double, val unit: SpeedUnit) : Speed()
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
package com.stadiamaps.ferrostar.core.annotation

import com.squareup.moshi.FromJson
import com.squareup.moshi.JsonAdapter
import com.squareup.moshi.JsonReader
import com.squareup.moshi.JsonWriter
import com.squareup.moshi.ToJson

class SpeedSerializationAdapter : JsonAdapter<Speed>() {

@ToJson
override fun toJson(writer: JsonWriter, speed: Speed?) {
if (speed == null) {
writer.nullValue()
} else {
writer.beginObject()
when (speed) {
is Speed.NoLimit -> writer.name("none").value(true)
is Speed.Unknown -> writer.name("unknown").value(true)
is Speed.Value ->
writer.name("value").value(speed.value).name("unit").value(speed.unit.text)
}
writer.endObject()
}
}

@FromJson
override fun fromJson(reader: JsonReader): Speed {
reader.beginObject()
var unknown: Boolean? = null
var none: Boolean? = null
var value: Double? = null
var unit: String? = null

while (reader.hasNext()) {
when (reader.selectName(JsonReader.Options.of("none", "unknown", "value", "unit"))) {
0 -> none = reader.nextBoolean()
1 -> unknown = reader.nextBoolean()
2 -> value = reader.nextDouble()
3 -> unit = reader.nextString()
else -> reader.skipName()
}
}
reader.endObject()

return if (none == true) {
Speed.NoLimit
} else if (unknown == true) {
Speed.Unknown
} else if (value != null && unit != null) {
val speed = SpeedUnit.fromString(unit)
if (speed != null) {
Speed.Value(value, speed)
} else {
throw IllegalArgumentException("Invalid speed unit: $unit")
}
} else {
throw IllegalArgumentException("Invalid max speed")
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
package com.stadiamaps.ferrostar.core.annotation

enum class SpeedUnit(val text: String) {
KILOMETERS_PER_HOUR("km/h"),
MILES_PER_HOUR("mph"),
KNOTS("knots");

companion object {
fun fromString(text: String): SpeedUnit? {
return SpeedUnit.entries.firstOrNull { it.text == text }
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
package com.stadiamaps.ferrostar.core.annotation.valhalla

import com.squareup.moshi.Moshi
import com.squareup.moshi.kotlin.reflect.KotlinJsonAdapterFactory
import com.stadiamaps.ferrostar.core.annotation.AnnotationPublisher
import com.stadiamaps.ferrostar.core.annotation.DefaultAnnotationPublisher
import com.stadiamaps.ferrostar.core.annotation.SpeedSerializationAdapter

fun valhallaExtendedOSRMAnnotationPublisher(): AnnotationPublisher<ValhallaOSRMExtendedAnnotation> {
val moshi =
Moshi.Builder().add(SpeedSerializationAdapter()).add(KotlinJsonAdapterFactory()).build()
val adapter = moshi.adapter(ValhallaOSRMExtendedAnnotation::class.java)
return DefaultAnnotationPublisher<ValhallaOSRMExtendedAnnotation>(adapter) { it?.speedLimit }
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
package com.stadiamaps.ferrostar.core.annotation.valhalla

import com.squareup.moshi.Json
import com.stadiamaps.ferrostar.core.annotation.Speed

data class ValhallaOSRMExtendedAnnotation(
/** The speed limit of the segment. */
@Json(name = "maxspeed") val speedLimit: Speed?,
/** The estimated speed of travel for the segment, in meters per second. */
val speed: Double?,
Copy link
Collaborator

Choose a reason for hiding this comment

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

Is this double still needed? I think this was initial pass note, but probably not relevant now that Speed exists.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I was checking, on iOS there is:

    /// The speed limit of the segment.
    public let speedLimit: MaxSpeed?

    /// The estimated speed of travel for the segment, in meters per second.
    public let speed: Double?

should I comment them as such and leave it instead?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I added the comments.

/** The distance in meters of the segment. */
val distance: Double?,
/** The estimated time to traverse the segment, in seconds. */
val duration: Double?
)
Loading