Skip to content

Commit

Permalink
Refactor core to be pure functional; first movement on an Android Vie…
Browse files Browse the repository at this point in the history
…wModel
  • Loading branch information
ianthetechie committed Dec 5, 2023
1 parent 32ff425 commit b080b89
Show file tree
Hide file tree
Showing 21 changed files with 397 additions and 391 deletions.
4 changes: 4 additions & 0 deletions ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,10 @@ associated with C++, has interoperability with just about every platform. Recent
idiomatic binding generation for Swift and Kotlin (see [UniFFI](https://github.com/mozilla/uniffi-rs) from Mozilla),
and Rust has some of the most loved build tooling of any programming language.

At the moment, we are attempting to keep the core purely functional.
This decision is not completely set in stone yet,
but it seems like an extremely logical choice in terms of testability.

### Navigation Controller

This manages the lifecycle of the navigation (TODO: define this more precisely).
Expand Down
4 changes: 2 additions & 2 deletions Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,8 @@ if useLocalFramework {
path: "./common/target/ios/libferrostar-rs.xcframework"
)
} else {
let releaseTag = "0.0.15"
let releaseChecksum = "9327e79ef1ed91676e831c21db9ae28bd92d7cd7c7c9bce94dbf5e55f78babce"
let releaseTag = "0.0.16"
let releaseChecksum = "908d64c25414b30aec22a07a615a871d952babf3153f39569aa402b37dcc4a6a"
binaryTarget = .binaryTarget(
name: "FerrostarCoreRS",
url: "https://github.com/stadiamaps/ferrostar/releases/download/\(releaseTag)/libferrostar-rs.xcframework.zip",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Info
import androidx.compose.material.icons.filled.Warning
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
Expand All @@ -33,14 +34,14 @@ val VisualInstructionContent.maneuverIcon: ImageVector?
// Ideally look for some iconography licensed under CC or similar
// that we can use on all platforms.
return when (this.maneuverModifier) {
ManeuverModifier.U_TURN -> Icons.Filled.Warning
ManeuverModifier.SHARP_RIGHT -> Icons.Filled.Warning
ManeuverModifier.RIGHT -> Icons.Filled.Warning
ManeuverModifier.SLIGHT_RIGHT -> Icons.Filled.Warning
ManeuverModifier.STRAIGHT -> Icons.Filled.Warning
ManeuverModifier.SLIGHT_LEFT -> Icons.Filled.Warning
ManeuverModifier.LEFT -> Icons.Filled.Warning
ManeuverModifier.SHARP_LEFT -> Icons.Filled.Warning
ManeuverModifier.U_TURN -> Icons.Filled.Info
ManeuverModifier.SHARP_RIGHT -> Icons.Filled.Info
ManeuverModifier.RIGHT -> Icons.Filled.Info
ManeuverModifier.SLIGHT_RIGHT -> Icons.Filled.Info
ManeuverModifier.STRAIGHT -> Icons.Filled.Info
ManeuverModifier.SLIGHT_LEFT -> Icons.Filled.Info
ManeuverModifier.LEFT -> Icons.Filled.Info
ManeuverModifier.SHARP_LEFT -> Icons.Filled.Info
else -> null
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,6 @@ class FerrostarCoreTest {

val core = FerrostarCore(
routeAdapter = RouteAdapter(requestGenerator = MockRouteRequestGenerator(), responseParser = MockRouteResponseParser(routes = listOf())),
locationProvider = SimulatedLocationProvider(),
httpClient = OkHttpClient.Builder().addInterceptor(interceptor).build()
)

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -247,7 +247,6 @@ class ValhallaCoreTest {
val core = FerrostarCore(
valhallaEndpointURL = URL(valhallaEndpointUrl),
profile = "auto",
locationProvider = SimulatedLocationProvider(),
httpClient = OkHttpClient.Builder().addInterceptor(interceptor).build()
)

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,16 +4,12 @@ import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.RequestBody.Companion.toRequestBody
import uniffi.ferrostar.GeographicCoordinate
import uniffi.ferrostar.NavigationController
import uniffi.ferrostar.NavigationControllerConfig
import uniffi.ferrostar.Route
import uniffi.ferrostar.RouteAdapter
import uniffi.ferrostar.RouteAdapterInterface
import uniffi.ferrostar.RouteRequest
import uniffi.ferrostar.StepAdvanceMode
import uniffi.ferrostar.UserLocation
import java.net.URL
import java.util.concurrent.Executors

open class FerrostarCoreException : Exception {
constructor(message: String) : super(message)
Expand All @@ -25,29 +21,24 @@ class InvalidStatusCodeException(val statusCode: Int): FerrostarCoreException("R

class NoResponseBodyException: FerrostarCoreException("Route request was successful but had no body bytes")

public class FerrostarCore(
class FerrostarCore(
val routeAdapter: RouteAdapterInterface,
val locationProvider: LocationProvider,
val httpClient: OkHttpClient
) : LocationUpdateListener {
private var navigationController: NavigationController? = null

) {
constructor(
valhallaEndpointURL: URL,
profile: String,
locationProvider: LocationProvider,
httpClient: OkHttpClient,
) : this(
RouteAdapter.newValhallaHttp(
valhallaEndpointURL.toString(), profile
),
locationProvider,
httpClient,
)

suspend fun getRoutes(
initialLocation: UserLocation, waypoints: List<GeographicCoordinate>
): List<Route> {
): List<Route> =
when (val request = routeAdapter.generateRequest(initialLocation, waypoints)) {
is RouteRequest.HttpPost -> {
val httpRequest = Request.Builder()
Expand All @@ -68,37 +59,7 @@ public class FerrostarCore(
throw NoResponseBodyException()
}

return routeAdapter.parseResponse(bodyBytes)
routeAdapter.parseResponse(bodyBytes)
}
}
}

fun startNavigation(route: Route, stepAdvance: StepAdvanceMode, startingLocation: UserLocation) {
// TODO: Is this the best executor?
locationProvider.addListener(this, Executors.newSingleThreadExecutor())

// TODO: Init view model
navigationController = NavigationController(
lastUserLocation = startingLocation,
route = route,
config = NavigationControllerConfig(stepAdvance = stepAdvance)
)
}

fun stopNavigation() {
navigationController = null
// TODO: Clear ViewModel
// TODO: Is this the best executor?
locationProvider.removeListener(this)
}

override fun onLocationUpdated(location: Location) {
// TODO: Update view model and navigation controller
TODO("Not yet implemented")
}

override fun onHeadingUpdated(heading: Float) {
// TODO: Update view model
TODO("Not yet implemented")
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
package com.stadiamaps.ferrostar.core

import androidx.lifecycle.ViewModel
import kotlinx.coroutines.flow.MutableStateFlow
import uniffi.ferrostar.Disposable
import uniffi.ferrostar.NavigationControllerInterface
import uniffi.ferrostar.TripState
import uniffi.ferrostar.UserLocation
import java.util.concurrent.Executors

data class NavigationUiState(
val snappedLocation: Location,
val heading: Float?
)

/**
* A view model for integrating state into an Android application.
*
* Uses [androidx.lifecycle.ViewModel].
* Note that it is assumed that the passed in [navigationController]
* either requires no finalization OR that it conforms to [Disposable].
* In the case that it conforms to [Disposable],
* the [navigationController] will be automatically destroyed in [onCleared].
*/
class NavigationViewModel(
private val navigationController: NavigationControllerInterface,
private val locationProvider: LocationProvider,
initialUserLocation: UserLocation,
) : ViewModel(), LocationUpdateListener {
// TODO: Is this the best executor?
private val _executor = Executors.newSingleThreadExecutor()
private var _state = navigationController.getInitialState(initialUserLocation)
// TODO: UI state flow?
// private val _uiState = MutableStateFlow(NavigationUiState(snappedLocation = navigationController.))

init {
locationProvider.addListener(this, _executor)
}

override fun onLocationUpdated(location: Location) {
_state = navigationController.updateUserLocation(location = location.userLocation(), state = _state)
// TODO: Update view model
}

override fun onHeadingUpdated(heading: Float) {
// TODO: Update view model
TODO("Not yet implemented")
}

override fun onCleared() {
locationProvider.removeListener(this)
_executor.shutdown()

if (navigationController is Disposable) {
navigationController.destroy()
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -18,13 +18,13 @@ import okhttp3.OkHttpClient
import java.net.URL

class MainActivity : ComponentActivity() {
// TODO: Create a view model instead
val locationProvider = SimulatedLocationProvider()
val httpClient = OkHttpClient.Builder().build()
// TODO: Something useful. This is just a placeholder that essentially checks our ability to load the Rust library
val core = FerrostarCore(
valhallaEndpointURL = URL("https://api.stadiamaps.com/navigate/v1?api_key=YOUR-KEY-HERE"),
profile = "pedestrian",
locationProvider = locationProvider,
httpClient = httpClient
)

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -44,12 +44,17 @@ extension UniFFI.VisualInstructionContent {
}
}

struct BannerView: View {
public struct BannerView: View {
let instructions: VisualInstruction
let distanceToNextManeuver: CLLocationDistance?
let formatter = MKDistanceFormatter()

var body: some View {
public init(instructions: VisualInstruction, distanceToNextManeuver: CLLocationDistance?) {
self.instructions = instructions
self.distanceToNextManeuver = distanceToNextManeuver
}

public var body: some View {
VStack {
HStack {
VStack {
Expand Down
27 changes: 16 additions & 11 deletions apple/Sources/FerrostarCore/FerrostarCore.swift
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ public protocol FerrostarCoreDelegate: AnyObject {
/// case of a moving vehicle).
///
/// This is *probably* not the final interface for this function but it's something to start with.
func core(_ core: FerrostarCore, didUpdateNavigationState update: NavigationStateUpdate)
func core(_ core: FerrostarCore, didUpdateNavigationState update: TripState)
}


Expand Down Expand Up @@ -63,6 +63,7 @@ public protocol FerrostarCoreDelegate: AnyObject {
private let routeAdapter: UniFFI.RouteAdapterProtocol
private let locationProvider: LocationProviding
private var navigationController: UniFFI.NavigationControllerProtocol?
private var tripState: UniFFI.TripState?

public init(routeAdapter: UniFFI.RouteAdapterProtocol, locationManager: LocationProviding, networkSession: URLRequestLoading) {
self.routeAdapter = routeAdapter
Expand Down Expand Up @@ -123,48 +124,52 @@ public protocol FerrostarCoreDelegate: AnyObject {
locationProvider.startUpdating()

observableState = FerrostarObservableState(snappedLocation: location, heading: locationProvider.lastHeading, fullRoute: route.geometry, steps: route.inner.steps)
navigationController = NavigationController(lastUserLocation: location.userLocation, route: route.inner, config: NavigationControllerConfig(stepAdvance: stepAdvance.ffiValue))
navigationController = NavigationController(route: route.inner, config: NavigationControllerConfig(stepAdvance: stepAdvance.ffiValue))
}

/// Stops navigation and stops requesting location updates (to save battery).
public func stopNavigation() {
navigationController = nil
observableState = nil
tripState = nil
locationProvider.stopUpdating()
}
}

extension FerrostarCore: LocationManagingDelegate {
public func locationManager(_ manager: LocationProviding, didUpdateLocations locations: [CLLocation]) {
guard let location = locations.last else { return }
guard let location = locations.last, let state = tripState else { return }

// TODO: Decide how/where we want to handle speed info.

if let update = navigationController?.updateUserLocation(location: location.userLocation) {
switch (update) {
case .navigating(snappedUserLocation: let userLocation, remainingWaypoints: let remainingWaypoints, currentStep: let currentStep, distanceToNextManeuver: let distanceToNextManeuver):
observableState?.snappedLocation = CLLocation(userLocation: userLocation)
if let newState = navigationController?.updateUserLocation(location: location.userLocation, state: state) {
tripState = newState

switch (newState) {
case .navigating(snappedUserLocation: let snappedLocation, remainingWaypoints: let remainingWaypoints, remainingSteps: let remainingSteps, distanceToNextManeuver: let distanceToNextManeuver):
observableState?.snappedLocation = CLLocation(userLocation: snappedLocation)
observableState?.courseOverGround = location.course
observableState?.remainingWaypoints = remainingWaypoints.map { waypoint in
CLLocationCoordinate2D(geographicCoordinates: waypoint)
}
observableState?.currentStep = currentStep
observableState?.visualInstructions = currentStep.visualInstructions.last(where: { instruction in
observableState?.currentStep = remainingSteps.first
observableState?.visualInstructions = remainingSteps.first?.visualInstructions.last(where: { instruction in
distanceToNextManeuver <= instruction.triggerDistanceBeforeManeuver
})
observableState?.distanceToNextManeuver = distanceToNextManeuver
// TODO
// observableState?.spokenInstruction = currentStep.spokenInstruction.last(where: { instruction in
// currentStepRemainingDistance <= instruction.triggerDistanceBeforeManeuver
// })
case .arrived:
case .complete:
// TODO: "You have arrived"?
observableState?.visualInstructions = nil
observableState?.snappedLocation = location // We arrived; no more snapping needed
observableState?.courseOverGround = location.course
observableState?.spokenInstruction = nil
}
delegate?.core(self, didUpdateNavigationState: NavigationStateUpdate(update))

delegate?.core(self, didUpdateNavigationState: TripState(newState))
}
}

Expand Down
18 changes: 9 additions & 9 deletions apple/Sources/FerrostarCore/ModelWrappers.swift
Original file line number Diff line number Diff line change
Expand Up @@ -28,17 +28,17 @@ public struct Route {
}
}

/// A Swift wrapper around `UniFFI.NavigationStateUpdate`.
public enum NavigationStateUpdate {
case navigating(snappedUserLocation: CLLocation, remainingWaypoints: [CLLocationCoordinate2D], currentStep: UniFFI.RouteStep?, distanceToNextManeuver: Double)
case arrived
/// A Swift wrapper around `UniFFI.TripState`.
public enum TripState {
case navigating(snappedUserLocation: CLLocation, remainingWaypoints: [CLLocationCoordinate2D], remainingSteps: [UniFFI.RouteStep], distanceToNextManeuver: Double)
case complete

init(_ update: UniFFI.NavigationStateUpdate) {
init(_ update: UniFFI.TripState) {
switch (update) {
case .navigating(snappedUserLocation: let location, remainingWaypoints: let waypoints, currentStep: let currentStep, distanceToNextManeuver: let distanceToNextManeuver):
self = .navigating(snappedUserLocation: CLLocation(userLocation: location), remainingWaypoints: waypoints.map { CLLocationCoordinate2D(geographicCoordinates: $0)}, currentStep: currentStep, distanceToNextManeuver: distanceToNextManeuver)
case .arrived:
self = .arrived
case .navigating(snappedUserLocation: let location, remainingWaypoints: let waypoints, remainingSteps: let remainingSteps, distanceToNextManeuver: let distanceToNextManeuver):
self = .navigating(snappedUserLocation: CLLocation(userLocation: location), remainingWaypoints: waypoints.map { CLLocationCoordinate2D(geographicCoordinates: $0)}, remainingSteps: remainingSteps, distanceToNextManeuver: distanceToNextManeuver)
case .complete:
self = .complete
}
}
}
Expand Down
2 changes: 1 addition & 1 deletion apple/Sources/FerrostarCore/ObservableState.swift
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ public final class FerrostarObservableState: ObservableObject {
@Published public internal(set) var courseOverGround: CLLocationDirection?
@Published public internal(set) var fullRouteShape: [CLLocationCoordinate2D]
@Published public internal(set) var remainingWaypoints: [CLLocationCoordinate2D]
@Published public internal(set) var currentStep: UniFFI.RouteStep
@Published public internal(set) var currentStep: UniFFI.RouteStep?
@Published public internal(set) var visualInstructions: UniFFI.VisualInstruction?
@Published public internal(set) var spokenInstruction: UniFFI.SpokenInstruction?
@Published public internal(set) var distanceToNextManeuver: CLLocationDistance?
Expand Down
Loading

0 comments on commit b080b89

Please sign in to comment.