diff --git a/CLAUDE.md b/CLAUDE.md index 089d458..51302e8 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -4,7 +4,7 @@ This file provides guidance to Claude Code when working with code in this reposi ## Project Overview -This is an Android navigation app built with Jetpack Compose that supports multiple routing providers (OSRM, Valhalla, TomTom) and includes Android Auto/Automotive OS integration. The app uses MapLibre for rendering, DataStore for local persistence, and Koin for dependency injection. +This is an Android navigation app built with Jetpack Compose that supports multiple routing providers (OSRM, Valhalla, TomTom) and includes Android Auto/Automotive OS integration. The app uses MapLibre for rendering, Androidx DataStore for local persistence, and Koin for dependency injection. ## Build Commands @@ -12,14 +12,15 @@ This is an Android navigation app built with Jetpack Compose that supports multi # Build the app (from repository root) ./gradlew :app:assembleDebug -# Build specific flavor +# Build a specific flavor +./gradlew :app:assemblePlayDebug ./gradlew :app:assembleDemoDebug ./gradlew :app:assembleFullDebug -# Run tests +# Run unit tests ./gradlew test -# Run tests for specific module +# Run tests for a specific module ./gradlew :common:data:test ./gradlew :common:car:test @@ -32,12 +33,12 @@ This is an Android navigation app built with Jetpack Compose that supports multi ## Module Structure -The project uses a multi-module architecture: +The project uses a multi-module architecture (see `settings.gradle.kts`): -- **app/** - Main Android app with Jetpack Compose UI for phone -- **common/data/** - Core data layer with routing logic, repositories, and data models (shared by all modules) +- **app/** - Main Android app with Jetpack Compose UI for phone (`com.kouros.navigation`) +- **common/data/** - Core data layer with routing logic, repositories, view models, persistence (`com.kouros.data`) - **common/car/** - Android Auto/Automotive OS UI implementation -- **automotive/** - Placeholder for future native Automotive OS app +- **automotive/** - Placeholder for future native Automotive OS app (no Kotlin sources yet) Dependencies flow: `app` → `common:car` → `common:data` @@ -45,156 +46,212 @@ Dependencies flow: `app` → `common:car` → `common:data` ### Routing Providers (Pluggable System) -The app supports three routing engines that implement the `NavigationRepository` abstract class: +The app supports three routing engines that extend the `NavigationRepository` abstract class (`common/data/.../data/NavigationRepository.kt`): -1. **OsrmRepository** - OSRM routing engine -2. **ValhallaRepository** - Valhalla routing engine -3. **TomTomRepository** - TomTom routing engine +1. **ValhallaRepository** - Valhalla routing engine (ordinal 0) +2. **OsrmRepository** - OSRM routing engine (ordinal 1) +3. **TomTomRepository** - TomTom routing engine (ordinal 2, default) -Each provider has a corresponding mapper class (`OsrmRoute`, `ValhallaRoute`, `TomTomRoute`) that converts provider-specific JSON responses to the universal `Route` data model. +Selection is driven by `RouteEngine` enum order (see `Data.kt`). `NavigationRepository` also exposes shared HTTP helpers (`fetchUrl`, `searchPlaces`, `reverseAddress`) that use Nominatim for geocoding and `ApplicationConfig`-backed HTTP basic auth for protected endpoints. + +Each provider has a corresponding mapper (`OsrmRoute`, `ValhallaRoute`, `TomTomRoute`) that converts provider-specific JSON into the universal `Route` model. The universal model is decomposed into a dedicated `data/route/` package: `Routes`, `Leg`, `Step`, `Maneuver`, `Intersection`, `Lane`, `Summary`. **Adding a new routing provider:** -1. Create `NewProviderRepository` extending `NavigationRepository` in `common/data/src/main/java/com/kouros/navigation/data/` -2. Implement `getRoute()` method -3. Create `NewProviderRoute.kt` with `mapToRoute()` function -4. Add provider detection logic in `Route.Builder.route()` -5. Update `NavigationUtils.getViewModel()` to return appropriate ViewModel +1. Create `NewProviderRepository` extending `NavigationRepository` under `common/data/src/main/java/com/kouros/navigation/data//` +2. Implement `getRoute()` and `getTraffic()` +3. Create `NewProviderRoute.kt` with a `mapToRoute(response, builder)` function that populates a `Route.Builder` +4. Add a `RouteEngine` enum entry and provider branch in `Route.Builder.route()` (`data/Route.kt`) +5. Update `NavigationUtils.getViewModel()` (`utils/NavigationUtils.kt`) to return a `NavigationViewModel` wired to the new repository ### Data Flow ``` -User Action (search/select destination) +User action (search / select destination) ↓ -ViewModel.loadRoute() [LiveData] +NavigationViewModel.loadRoute() [LiveData / Flow] ↓ -NavigationRepository.getRoute() [Selected provider] +NavigationRepository.getRoute() [selected provider] ↓ -*Route.mapToRoute() [Convert to universal Route model] +*Route.mapToRoute() [convert to universal Route] ↓ RouteModel.startNavigation() ↓ -RouteModel.updateLocation() [On each location update] +RouteCalculator.findStep() [on each location update] ↓ -UI observes LiveData and displays current step +NavigationState updated → UI observes and renders current step ``` ### Key Classes -**Navigation Logic:** -- `RouteModel.kt` - Core navigation engine (tracks position, calculates distances, manages steps) -- `RouteCarModel.kt` - Extends RouteModel with Android Auto-specific formatting -- `ViewModel.kt` - androidx.ViewModel with LiveData for route, traffic, places, etc. +**Navigation logic** (`common/data/.../model/`): +- `RouteModel.kt` - Core navigation engine; tracks position, manages step progression, owns `NavigationState` +- `RouteCalculator.kt` - Step-finding algorithm: snaps current location to the nearest waypoint, computes leftover distance, handles snap correction and reroute thresholds +- `RouteCarModel.kt` (`common/car/.../navigation/`) - Extends RouteModel with Android Auto-specific formatting +- `NavigationViewModel.kt` - androidx ViewModel exposing route, traffic, places (Nominatim), amenities (Overpass), and fuel prices (Tankerkönig) as LiveData +- `SettingsViewModel.kt` - State holder for DataStore-backed settings (dark mode, 3D, routing engine, avoid preferences, etc.) +- `BaseStyleModel.kt` - Map style state -**Data Models:** -- `Route.kt` - Universal route structure used by all providers -- `Place.kt` - ObjectBox entity for favorites/recent locations -- `StepData.kt` - Display data for current navigation instruction +**Data models** (`common/data/.../data/`): +- `Route.kt` - Universal route wrapper with `Route.Builder` and provider dispatch +- `data/route/*` - Decomposed route components (`Routes`, `Leg`, `Step`, `Maneuver`, `Intersection`, `Lane`, `Summary`) +- `NavigationState.kt` - Immutable navigation state (route, flags, location, bearing, maneuver, destination) +- `Data.kt` - Shared types (`Place`, `StepData`, `SearchFilter`, `Locations`, `ValhallaLocation`) plus `object Constants` and the `RouteEngine`, `DarkMode`, `EngineType`, `ViewStyle`, `NavigationThemeColor` enums +- `ApplicationConfig.kt` - Loads `USER` / `PASSWORD` from `BuildConfig` for HTTP basic auth on protected endpoints -**Repositories:** -- `NavigationRepository.kt` - Abstract base class for all routing providers -- Also handles Nominatim geocoding search and TomTom traffic incidents +**Persistence** (`common/data/.../data/datastore/` and `common/data/.../repository/`): +- `DataStoreManager.kt` - Single source of truth for preference keys; exposes `Flow` reads and `suspend` writes for each setting +- `SettingsRepository.kt` - Higher-level wrapper over `DataStoreManager` -**Android Auto:** -- `NavigationCarAppService.kt` - Entry point for Android Auto/Automotive OS -- `NavigationSession.kt` - Session management -- `NavigationScreen.kt` - Car screen templates with NavigationType state machine -- `SurfaceRenderer.kt` - Handles virtual display and map rendering +**Repositories** (`common/data/.../data/`): +- `NavigationRepository.kt` - Abstract base class for routing providers; also handles Nominatim geocoding (search and reverse) +- `osrm/`, `valhalla/`, `tomtom/` - Provider implementations and JSON DTOs +- `fuel/FuelPrices.kt` - Tankerkönig fuel-price client (returns `List`) +- `overpass/` - Overpass API client for POIs and speed limits + +**Android Auto / Automotive** (`common/car/`): +- `NavigationCarAppService.kt` - CarAppService entry point +- `CarSession.kt` (abstract) and concrete `NavigationSession.kt`, `ClusterSession.kt` +- `screen/NavigationScreen.kt` - Main navigation template; `NavigationType` enum drives template selection (VIEW, NAVIGATION, REROUTE, RECENT, ARRIVAL) +- `screen/SearchScreen.kt`, `RoutePreviewScreen.kt`, `PlaceListScreen.kt`, `CategoriesScreen.kt`, `CategoryScreen.kt`, `StopOverScreen.kt`, `RequestPermissionScreen.kt` +- `screen/settings/*` - Per-setting screens (RoutingSettings, NavigationSettings, DisplaySettings, DarkModeSettings, DistanceSettings, AudioSettings, CarSettings, PasswordSettings) +- `screen/observers/*` - Observer pattern that bridges LiveData to car screens. `NavigationObserverManager` orchestrates `RouteObserver`, `TrafficObserver`, `TrafficMessageObserver`, `PlaceSearchObserver`, `CategoryObserver`, `MaxSpeedObserver`, `SpeedCameraObserver` +- Supporting managers: `SurfaceRenderer.kt` (virtual display + map), `DeviceLocationManager.kt`, `CarSensorManager.kt`, `NavigationNotificationManager.kt` + `NavigationNotificationService.kt`, `TextToSpeechManager.kt`, `CustomLifecycleOwner.kt` +- `map/MapView.kt`, `map/LocationPuck.kt` - MapLibre rendering for the car surface ### External APIs -| Service | Purpose | Base URL | -|---------|---------|----------| -| OSRM | Routing | `https://kouros-online.de/osrm/route/v1/driving/` | -| Valhalla | Routing | `https://kouros-online.de/valhalla/route` | -| TomTom | Traffic incidents | `https://api.tomtom.com/traffic/services/5/incidentDetails` | -| Nominatim | Geocoding search | `https://kouros-online.de/nominatim/` | -| Overpass | POI & speed limits | OpenStreetMap Overpass API | +| Service | Purpose | URL | +|---------------|--------------------------|------------------------------------------------------------------------------| +| OSRM | Routing | `https://router.project-osrm.org/route/v1/driving/` | +| Valhalla | Routing | `https://kouros-online.de/valhalla/route?json=` (HTTP basic auth) | +| TomTom | Routing | `https://api.tomtom.com/routing/1/calculateRoute/` | +| TomTom | Traffic incidents | `https://api.tomtom.com/traffic/services/5/incidentDetails` | +| Nominatim | Geocoding (search/reverse) | `https://nominatim.openstreetmap.org/` | +| Overpass | POIs & speed limits | OpenStreetMap Overpass API (DEBUG builds use `https://kouros-online.de/api/interpreter`) | +| Tankerkönig | Fuel prices | `https://creativecommons.tankerkoenig.de/json/list.php?` (API key in `fuel/FuelPrices.kt`) | + +In DEBUG builds, `TomTomRepository` and `FuelPrices` can be pointed at a local fixture (see `useLocal` flags) — TomTom can fall back to `R.raw.tomom_routing`, fuel falls back to a local JSON URL. ## Important Constants -Located in `Constants.kt` (`common/data`): +Defined in `object Constants` inside `common/data/.../data/Data.kt`: ```kotlin -NEXT_STEP_THRESHOLD = 120.0 m // Distance to show next maneuver -DESTINATION_ARRIVAL_DISTANCE = 40.0 m // Distance to trigger arrival -MAXIMAL_SNAP_CORRECTION = 50.0 m // Max distance to snap to route -MAXIMAL_ROUTE_DEVIATION = 80.0 m // Max deviation before reroute +NEXT_STEP_THRESHOLD = 500.0 // Distance (m) to show next maneuver +DESTINATION_ARRIVAL_DISTANCE = 10.0 // Distance (m) to trigger arrival +MAXIMAL_SNAP_CORRECTION = 50.0 // Max distance (m) to snap to route +MAXIMAL_ROUTE_DEVIATION = 100.0 // Max deviation (m) before reroute +NEAREST_LOCATION_DISTANCE = 10F +SPEED_UPDATE_DISTANCE = 600F +INSTRUCTION_DISTANCE = 50 +SPEED_BEARING_DEVIATION = 60 +TILT = 60.0 // Map tilt in degrees during navigation +TANKER_KOENIG_DELAY = 300_000 // ms between fuel price refreshes ``` -SharedPreferences keys: -- `ROUTING_ENGINE` - Selected provider (0=Valhalla, 1=OSRM, 2=TomTom) -- `DARK_MODE_SETTINGS` - Theme preference -- `AVOID_MOTORWAY`, `AVOID_TOLLWAY` - Route preferences +DataStore keys (see `DataStoreManager.PreferencesKeys`): +- `RoutingEngine` (Int) — `0=Valhalla`, `1=OSRM`, `2=TomTom` (default 2) +- `DarkMode` (Int), `Show3D` (Bool), `CarLocation` (Bool) +- `AvoidMotorway`, `AvoidTollway`, `AvoidFerry` (Bool) +- `LastRoute`, `RecentPlaces`, `FuelPrices`, `LastFuelPrices` +- `TomTomApiKey`, `DistanceMode`, `GuidanceAudio`, `Traffic`, `TripSuggestion`, `EngineType`, `AlternativeRoutes` ## Navigation Flow -1. **Route Loading**: User searches via Nominatim → selects place → ViewModel.loadRoute() calls selected repository -2. **Route Parsing**: Provider JSON → mapper converts to universal Route → RouteModel.startNavigation() -3. **Location Tracking**: FusedLocationProviderClient provides updates → RouteModel.updateLocation() -4. **Step Calculation**: findStep() snaps location to nearest waypoint → updates current step -5. **UI Updates**: currentStep() and nextStep() provide display data (instruction, distance, icon, lanes) -6. **Arrival**: When distance < DESTINATION_ARRIVAL_DISTANCE, navigation ends +1. **Route loading** — User searches via Nominatim → selects place → `NavigationViewModel.loadRoute()` calls the selected repository +2. **Route parsing** — Provider JSON → `*Route.mapToRoute()` populates `Route.Builder` → universal `Route` → `RouteModel.startNavigation()` +3. **Location tracking** — `FusedLocationProviderClient` updates → `RouteModel.updateLocation()` +4. **Step calculation** — `RouteCalculator.findStep()` snaps location to the nearest waypoint, returns `StepMatch`, and updates the current step index +5. **UI updates** — `NavigationState` flows out via LiveData/Compose state; phone and car UIs render the current/next step (instruction, distance, icon, lanes) +6. **Arrival** — When distance < `DESTINATION_ARRIVAL_DISTANCE`, navigation ends ## Testing Navigation -The app includes mock location support for testing: +The phone app supports mock locations for testing: -- Set `useMock = true` in MainActivity +- Set `useMock = true` in `MainActivity` - Enable "Mock location app" in Android Developer Options -- Choose test mode: - - `type = 1` - Simulate movement along entire route - - `type = 2` - Test specific step range - - `type = 3` - Replay GPX track file +- Choose mode in `model/Simulation.kt` / `model/MockLocation.kt`: + - `type = 1` — Simulate movement along the entire route + - `type = 2` — Test a specific step range + - `type = 3` — Replay a GPX track file -## ObjectBox Database +The car module has its own `navigation/Simulation.kt` for car-side simulation. -ObjectBox is configured in `common/data/build.gradle.kts` with the kapt plugin. The database stores: +### Unit tests -- Recent destinations (category: "Recent") -- Favorite places (category: "Favorites") -- Imported contacts (category: "Contacts") +- `common/data/src/test/.../model/RouteCalculatorTest.kt` +- `common/data/src/test/.../model/RouteModelTest.kt` +- `common/data/src/test/.../model/IconMapperTest.kt` +- `common/data/src/test/.../model/OverpassTest.kt` +- `common/data/src/test/.../utils/GeoUtilsTest.kt` +- `common/car/src/test/.../screen/NavigationScreenTest.kt` +- `common/car/src/test/.../screen/observers/CategoryObserverTest.kt`, `ObserversTest.kt` -Queries use ObjectBox query builder pattern with generated `Place_` property accessors. +## Persistence + +All app preferences are stored via Androidx **DataStore Preferences** (`navigation_settings` data store). There is no Room/ObjectBox/SQL database — the only persisted entities are settings and serialized lists (recent places, last route, last fuel prices) kept as strings. + +`DataStoreManager` exposes a `Flow` per setting plus a matching `suspend set*` writer. `SettingsRepository` wraps it for higher-level access. `SettingsViewModel` and `AppViewModel` consume those flows for UI state. ## Compose UI Structure -**Phone App:** -- `MainActivity.kt` - Main entry with permission handling and Navigation Compose -- `NavigationScreen.kt` - Turn-by-turn navigation display -- `SearchSheet.kt` / `NavigationSheet.kt` - Bottom sheet content -- `MapView.kt` - MapLibre rendering with camera state management +**Phone app** (`app/src/main/java/com/kouros/navigation/`): +- `ui/MainActivity.kt` - Entry point with permission handling and Navigation Compose host +- `ui/MapView.kt` - MapLibre rendering with camera state management +- `ui/SheetLayout.kt` - Bottom sheet scaffold +- `ui/PermissionScreen.kt` +- `ui/navigation/AppNavGraph.kt` - Compose navigation graph +- `ui/navigation/NavigationScreen.kt`, `NavigationSheet.kt` - Turn-by-turn UI +- `ui/search/SearchScreen.kt`, `SearchSheet.kt` +- `ui/settings/SettingsScreen.kt`, `SettingsRoute.kt`, `DisplayScreen.kt`, `NavigationScreen.kt`, `CarScreen.kt`, `Settings.kt` +- `ui/components/SettingItem.kt`, `SettingSwitch.kt`, `RadioButtonSingleSelection.kt`, `SectionTitle.kt` +- `ui/app/AppViewModel.kt`, `AppViewModelProvider.kt` +- `ui/theme/{Color,Type,Shapes,Theme}.kt` +- `model/Simulation.kt`, `model/MockLocation.kt` - Mock location for testing +- `di/appModule.kt` - Koin module +- `MainApplication.kt` - Application class -**Android Auto:** -- Uses CarAppService Screen templates (NavigationTemplate, MessageTemplate, MapWithContentTemplate) -- NavigationType enum controls which template to display (VIEW, NAVIGATION, REROUTE, RECENT, ARRIVAL) +**Android Auto** uses CarAppService templates (NavigationTemplate, MessageTemplate, MapWithContentTemplate). `NavigationType` (in `screen/NavigationScreen.kt`) controls which template to render. UI state is synchronized with `NavigationViewModel` through the observers in `screen/observers/`. ## Build Flavors -Two product flavors with dimension "version": -- **demo** - applicationId: `com.kouros.navigation.demo` -- **full** - applicationId: `com.kouros.navigation.full` +`app/build.gradle.kts` defines three product flavors under the `store` dimension: +- **play** - applicationIdSuffix `.play` +- **demo** - applicationIdSuffix `.demo` +- **full** - applicationIdSuffix `.full` + +Base namespace is `com.kouros.navigation`; current versionName is `0.3.0.109`. Java/Kotlin target is 21 for the app module, 11 for `common:data`. compileSdk/targetSdk = 37, minSdk = 33. + +`signing.properties` (root, gitignored) provides the keystore credentials for both debug and release builds. `local.properties` provides `USER` / `PASSWORD` build config fields consumed by `ApplicationConfig`. ## Common Patterns -**Dependency Injection (Koin):** +**Dependency injection (Koin):** ```kotlin single { OsrmRepository() } -viewModel { ViewModel(get()) } +viewModel { NavigationViewModel(get()) } ``` -**LiveData Observation:** +**LiveData observation:** ```kotlin viewModel.route.observe(this) { routeJson -> routeModel.startNavigation(routeJson, context) } ``` -**Step Finding Algorithm:** -RouteModel iterates through all step waypoints, calculates distance to current location, and snaps to the nearest waypoint to determine current step index. +**Step-finding algorithm:** +`RouteCalculator` iterates the current step's waypoints, calculates distance from the current location to each, and snaps to the nearest waypoint. Snap is rejected if the closest point exceeds `MAXIMAL_SNAP_CORRECTION`; deviation greater than `MAXIMAL_ROUTE_DEVIATION` triggers a reroute. + +**Settings flow:** +```kotlin +val darkMode by viewModel.darkMode.collectAsStateWithLifecycle() +``` ## Known Limitations -- Valhalla route mapping is incomplete (search for TODO comments in ValhallaRoute.kt) +- Valhalla route mapping is incomplete in places (search for TODO comments in `data/valhalla/ValhallaRoute.kt`) - Rerouting logic exists but needs more testing -- Speed limit queries via Overpass API could be optimized for performance -- TomTom implementation uses local JSON file (R.raw.tomom_routing) instead of live API +- Speed-limit queries via Overpass API could be optimized for performance +- TomTom and Tankerkönig clients have DEBUG-only `useLocal` shortcuts that hit fixture URLs (`http://192.168.1.37/...` and `R.raw.tomom_routing`) — these need to be off for any real-network testing +- The `automotive` module is wired into Gradle but has no Kotlin source yet diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 832c4b5..1968a1f 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -17,8 +17,8 @@ android { applicationId = "com.kouros.navigation" minSdk = 33 targetSdk = 37 - versionCode = 109 - versionName = "0.3.0.109" + versionCode = 110 + versionName = "0.3.0.110" base.archivesName = "navi-$versionName" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" } diff --git a/common/data/build.gradle.kts b/common/data/build.gradle.kts index a6ad6b4..7f43fce 100644 --- a/common/data/build.gradle.kts +++ b/common/data/build.gradle.kts @@ -29,6 +29,7 @@ android { buildConfigField("String", "USER", "\"${properties.getProperty("USER") ?: ""}\"") buildConfigField("String", "PASSWORD", "\"${properties.getProperty("PASSWORD") ?: ""}\"") + buildConfigField("String", "TANKER_KOENIG_API_KEY", "\"${properties.getProperty("TANKER_KOENIG_API_KEY") ?: ""}\"") } buildFeatures { diff --git a/common/data/src/main/java/com/kouros/navigation/data/ApplicationConfig.kt b/common/data/src/main/java/com/kouros/navigation/data/ApplicationConfig.kt index 2f0f815..bb08bf1 100644 --- a/common/data/src/main/java/com/kouros/navigation/data/ApplicationConfig.kt +++ b/common/data/src/main/java/com/kouros/navigation/data/ApplicationConfig.kt @@ -4,14 +4,16 @@ import com.kouros.data.BuildConfig data class ApplicationConfig( val user: String, - val password: String + val password: String, + val tankerKoenigApiKey: String ) { companion object { fun load(): ApplicationConfig { return ApplicationConfig( user = BuildConfig.USER, - password = BuildConfig.PASSWORD + password = BuildConfig.PASSWORD, + tankerKoenigApiKey = BuildConfig.TANKER_KOENIG_API_KEY ) } } diff --git a/common/data/src/main/java/com/kouros/navigation/data/fuel/FuelPrices.kt b/common/data/src/main/java/com/kouros/navigation/data/fuel/FuelPrices.kt index 76cc80a..6901cb9 100644 --- a/common/data/src/main/java/com/kouros/navigation/data/fuel/FuelPrices.kt +++ b/common/data/src/main/java/com/kouros/navigation/data/fuel/FuelPrices.kt @@ -21,18 +21,19 @@ private val gson = GsonBuilder().serializeNulls().create() const val sort = "&sort=dist&type=all" -const val apiKey = "&apikey=fc9900c9-8f02-4b28-990d-dc6067228c59" val useLocal = BuildConfig.DEBUG class FuelPrices : NavigationRepository() { + private val config by lazy { com.kouros.navigation.data.ApplicationConfig.load() } + fun getFuelPrices(location: Location, radius: Int) : List { val url = if (useLocal) { "http://192.168.1.37/fuel.json" } else { - "${tankerKoenigUrl}lat=${location.latitude}&lng=${location.longitude}&rad=${radius}$sort$apiKey" + "${tankerKoenigUrl}lat=${location.latitude}&lng=${location.longitude}&rad=${radius}$sort&apikey=${config.tankerKoenigApiKey}" } val prices = fetchUrl( @@ -49,7 +50,7 @@ class FuelPrices : NavigationRepository() { carOrientation: Float, searchFilter: SearchFilter ): String { - TODO("Not yet implemented") + return "" } override fun getTraffic( diff --git a/common/data/src/main/java/com/kouros/navigation/data/overpass/Overpass.kt b/common/data/src/main/java/com/kouros/navigation/data/overpass/Overpass.kt index 592f79a..2114c18 100644 --- a/common/data/src/main/java/com/kouros/navigation/data/overpass/Overpass.kt +++ b/common/data/src/main/java/com/kouros/navigation/data/overpass/Overpass.kt @@ -51,7 +51,7 @@ class Overpass { } val searchQuery = """ - |[out:json]; + |[out:json][timeout:10]; |( | ${searchClauses.joinToString(";")}; |); @@ -75,7 +75,7 @@ class Overpass { val searchLocation ="way[\"highway\"~\"^(primary|secondary|tertiary|residential|motorway)$\"][name](around:50, $lineString)"; val searchQuery = """ - |[out:json]; + |[out:json][timeout:10]; |( | ${searchLocation}; |); diff --git a/common/data/src/main/java/com/kouros/navigation/model/NavigationViewModel.kt b/common/data/src/main/java/com/kouros/navigation/model/NavigationViewModel.kt index 8eed352..360180a 100644 --- a/common/data/src/main/java/com/kouros/navigation/model/NavigationViewModel.kt +++ b/common/data/src/main/java/com/kouros/navigation/model/NavigationViewModel.kt @@ -51,6 +51,8 @@ import kotlin.collections.first import kotlin.collections.forEach import kotlin.comparisons.compareBy import kotlin.math.absoluteValue +import kotlin.math.cos +import kotlin.math.sqrt /** * ViewModel for navigation-related data operations. @@ -58,6 +60,8 @@ import kotlin.math.absoluteValue */ class NavigationViewModel(private val repository: NavigationRepository) : ViewModel() { + private val overpass = Overpass() + /** LiveData containing the calculated route JSON string */ val route: MutableLiveData by lazy { MutableLiveData() @@ -411,7 +415,7 @@ class NavigationViewModel(private val repository: NavigationRepository) : ViewMo ) { viewModelScope.launch(Dispatchers.IO) { val repository = getSettingsRepository(carContext) - val amenities = Overpass().getAmenities("amenity", category, location, 5.0) + val amenities = overpass.getAmenities("amenity", category, location, 5.0) val fuelPrices = fuelStations(category, lastFuelUpdate, location, repository) val distAmenities = mutableListOf() amenities.forEach { @@ -473,7 +477,7 @@ class NavigationViewModel(private val repository: NavigationRepository) : ViewMo fun getSpeedCameras(location: Location, radius: Double) { synchronized(this) { viewModelScope.launch(Dispatchers.IO) { - val amenities = Overpass().getAmenities("highway", "speed_camera", location, radius) + val amenities = overpass.getAmenities("highway", "speed_camera", location, radius) val distAmenities = mutableListOf() amenities.forEach { val plLocation = @@ -509,43 +513,46 @@ class NavigationViewModel(private val repository: NavigationRepository) : ViewMo */ fun calculateSpeedLimit(location: Location, routeBearing: Float, countryCode: String): Int { var speed = 0 - var element: Elements? val search = mutableListOf() synchronized(this) { - speedElements.filter { it.type == "way" }.forEach { - var distance: Float - var maxDistance = 1000F - var geometryFirstLocation = location(0.0, 0.0) - var geometryLastLocation = location(0.0, 0.0) - for ((geoIndex, geo) in it.geometry.withIndex()) { - if (geoIndex == 0) { - geometryFirstLocation = location(geo.lon, geo.lat) - } - if (geoIndex == it.geometry.size - 1) { - geometryLastLocation = location(geo.lon, geo.lat) - } - val geometryLocation = location(geo.lon, geo.lat) - distance = geometryLocation.distanceTo(location) - if (distance < maxDistance) { - maxDistance = distance - } + // Equirectangular projection at the user's latitude. Closest-point ranking + // doesn't need geodesic accuracy, so we skip Location.distanceTo (Vincenty) + // and avoid allocating a Location per geometry vertex. + val userLat = location.latitude + val userLon = location.longitude + val metersPerDegLat = 111_320.0 + val metersPerDegLon = metersPerDegLat * cos(Math.toRadians(userLat)) + + for (element in speedElements) { + if (element.type != "way") continue + val geometry = element.geometry + if (geometry.isEmpty()) continue + + var minDistanceSq = Double.MAX_VALUE + for (geo in geometry) { + val dx = (geo.lon - userLon) * metersPerDegLon + val dy = (geo.lat - userLat) * metersPerDegLat + val sq = dx * dx + dy * dy + if (sq < minDistanceSq) minDistanceSq = sq } - val streetBearing = geometryFirstLocation.bearingPositive(geometryLastLocation) - if (isBearingValid(it, streetBearing, routeBearing)) { + val minDistance = sqrt(minDistanceSq) + + val first = geometry.first() + val last = geometry.last() + val streetBearing = location(first.lon, first.lat) + .bearingPositive(location(last.lon, last.lat)) + + if (isBearingValid(element, streetBearing, routeBearing)) { search.add( - ElementSearch( - it, - maxDistance.toDouble(), - streetBearing.absoluteValue - ) + ElementSearch(element, minDistance, streetBearing.absoluteValue) ) } } val result = search.sortedWith(compareBy { it.distance }.thenByDescending { it.bearing }) if (result.isNotEmpty()) { - element = result.first().element + val element = result.first().element speed = if (element.tags.maxspeed == "none" && element.tags.highway == "motorway") { countryCodeSpeedLimit(countryCode) } else { @@ -585,7 +592,7 @@ class NavigationViewModel(private val repository: NavigationRepository) : ViewMo synchronized(this) { val lineString = "${location.latitude},${location.longitude}" val elements = - Overpass().getSpeedLimit(SPEED_UPDATE_DISTANCE, lineString, street, roadNumbers) + overpass.getSpeedLimit(SPEED_UPDATE_DISTANCE, lineString, street, roadNumbers) speedElements.clear() speedElements.addAll(elements) } @@ -740,7 +747,7 @@ class NavigationViewModel(private val repository: NavigationRepository) : ViewMo fun loadCurrentLocation(location: Location) { viewModelScope.launch(Dispatchers.IO) { synchronized(this) { - val elements = Overpass().getStreet(location) + val elements = overpass.getStreet(location) if (elements.isNotEmpty()) { val points = mutableListOf() elements.first().geometry.forEach { diff --git a/common/data/src/main/java/com/kouros/navigation/model/RouteCalculator.kt b/common/data/src/main/java/com/kouros/navigation/model/RouteCalculator.kt index cf90e92..0a1fe10 100644 --- a/common/data/src/main/java/com/kouros/navigation/model/RouteCalculator.kt +++ b/common/data/src/main/java/com/kouros/navigation/model/RouteCalculator.kt @@ -17,6 +17,7 @@ class RouteCalculator(var routeModel: RouteModel) { var bestMatch: StepMatch? = null var lastSpeedLocation: Location = location(0.0, 0.0) + var lastLocalSpeedLocation: Location = location(0.0, 0.0) var lastSpeedIndex: Int = 0 @@ -157,13 +158,16 @@ class RouteCalculator(var routeModel: RouteModel) { if ((distance > SPEED_UPDATE_DISTANCE * 2) || lastSpeedIndex < routeModel.route.currentStepIndex) { lastSpeedIndex = routeModel.route.currentStepIndex lastSpeedLocation = location + // Force the local re-match on the next GPS fix once new elements arrive. + lastLocalSpeedLocation = location(0.0, 0.0) viewModel.updateSpeedLimit( location, routeModel.route.currentStep().street, routeModel.currentStep().roadNumbers ) - } else { + } else if (lastLocalSpeedLocation.distanceTo(location) >= NEAREST_LOCATION_DISTANCE) { + lastLocalSpeedLocation = location viewModel.getSpeedLimit( location, routeModel.navState.routeBearing, diff --git a/common/data/src/test/java/com/kouros/navigation/model/OverpassTest.kt b/common/data/src/test/java/com/kouros/navigation/model/OverpassTest.kt index 54fe426..d56a4ff 100644 --- a/common/data/src/test/java/com/kouros/navigation/model/OverpassTest.kt +++ b/common/data/src/test/java/com/kouros/navigation/model/OverpassTest.kt @@ -15,11 +15,14 @@ import org.mockito.kotlin.mock import org.mockito.kotlin.times import org.mockito.kotlin.verify import org.mockito.kotlin.whenever +import com.kouros.navigation.data.overpass.Bounds +import com.kouros.navigation.data.overpass.Tags class OverpassTest { val route = "{\n \"version\": 0.6,\n \"generator\": \"Overpass API 0.7.62.4 2390de5a\",\n \"osm3s\": {\n \"timestamp_osm_base\": \"\",\n \"copyright\": \"The data included in this document is from www.openstreetmap.org. The data is made available under ODbL.\"\n },\n \"elements\": [\n {\n \"type\": \"way\",\n \"id\": 288782966,\n \"bounds\": {\n \"minlat\": 48.1619892,\n \"minlon\": 11.8609409,\n \"maxlat\": 48.1675377,\n \"maxlon\": 11.8855018\n },\n \"geometry\": [\n {\n \"lat\": 48.1619892,\n \"lon\": 11.8609409\n },\n {\n \"lat\": 48.1620566,\n \"lon\": 11.8617319\n },\n {\n \"lat\": 48.1622095,\n \"lon\": 11.8631368\n },\n {\n \"lat\": 48.1623460,\n \"lon\": 11.8644074\n },\n {\n \"lat\": 48.1625048,\n \"lon\": 11.8656775\n },\n {\n \"lat\": 48.1627091,\n \"lon\": 11.8672028\n },\n {\n \"lat\": 48.1629402,\n \"lon\": 11.8686900\n },\n {\n \"lat\": 48.1631729,\n \"lon\": 11.8700333\n },\n {\n \"lat\": 48.1634153,\n \"lon\": 11.8713781\n },\n {\n \"lat\": 48.1637932,\n \"lon\": 11.8731800\n },\n {\n \"lat\": 48.1641213,\n \"lon\": 11.8746189\n },\n {\n \"lat\": 48.1644717,\n \"lon\": 11.8760567\n },\n {\n \"lat\": 48.1648483,\n \"lon\": 11.8774661\n },\n {\n \"lat\": 48.1650472,\n \"lon\": 11.8781814\n },\n {\n \"lat\": 48.1652483,\n \"lon\": 11.8788598\n },\n {\n \"lat\": 48.1656045,\n \"lon\": 11.8800840\n },\n {\n \"lat\": 48.1660008,\n \"lon\": 11.8812864\n },\n {\n \"lat\": 48.1665513,\n \"lon\": 11.8828570\n },\n {\n \"lat\": 48.1671206,\n \"lon\": 11.8844321\n },\n {\n \"lat\": 48.1675377,\n \"lon\": 11.8855018\n }\n ],\n \"tags\": {\n \"bdouble\": \"yes\",\n \"highway\": \"motorway\",\n \"int_ref\": \"E 552\",\n \"lanes\": \"2\",\n \"lit\": \"no\",\n \"maxheight\": \"default\",\n \"maxspeed\": \"none\",\n \"oneway\": \"yes\",\n \"ref\": \"A 94\",\n \"surface\": \"concrete\",\n \"tmc\": \"DE:12905/12906\"\n }\n },\n {\n \"type\": \"way\",\n \"id\": 288782968,\n \"bounds\": {\n \"minlat\": 48.1621875,\n \"minlon\": 11.8620178,\n \"maxlat\": 48.1694156,\n \"maxlon\": 11.8894962\n },\n \"geometry\": [\n {\n \"lat\": 48.1694156,\n \"lon\": 11.8894962\n },\n {\n \"lat\": 48.1691203,\n \"lon\": 11.8888740\n },\n {\n \"lat\": 48.1686990,\n \"lon\": 11.8879594\n },\n {\n \"lat\": 48.1683207,\n \"lon\": 11.8870930\n },\n {\n \"lat\": 48.1678958,\n \"lon\": 11.8860845\n },\n {\n \"lat\": 48.1672082,\n \"lon\": 11.8843454\n },\n {\n \"lat\": 48.1666293,\n \"lon\": 11.8827638\n },\n {\n \"lat\": 48.1660917,\n \"lon\": 11.8811913\n },\n {\n \"lat\": 48.1654436,\n \"lon\": 11.8791649\n },\n {\n \"lat\": 48.1650847,\n \"lon\": 11.8779268\n },\n {\n \"lat\": 48.1647476,\n \"lon\": 11.8766760\n },\n {\n \"lat\": 48.1644025,\n \"lon\": 11.8753593\n },\n {\n \"lat\": 48.1641792,\n \"lon\": 11.8744194\n },\n {\n \"lat\": 48.1639641,\n \"lon\": 11.8734618\n },\n {\n \"lat\": 48.1634961,\n \"lon\": 11.8712468\n },\n {\n \"lat\": 48.1632564,\n \"lon\": 11.8699398\n },\n {\n \"lat\": 48.1630250,\n \"lon\": 11.8686135\n },\n {\n \"lat\": 48.1628050,\n \"lon\": 11.8671495\n },\n {\n \"lat\": 48.1625984,\n \"lon\": 11.8656577\n },\n {\n \"lat\": 48.1624469,\n \"lon\": 11.8643635\n },\n {\n \"lat\": 48.1623072,\n \"lon\": 11.8631342\n },\n {\n \"lat\": 48.1621875,\n \"lon\": 11.8620178\n }\n ],\n \"tags\": {\n \"bdouble\": \"yes\",\n \"highway\": \"motorway\",\n \"int_ref\": \"E 552\",\n \"lanes\": \"2\",\n \"lit\": \"no\",\n \"maxheight\": \"default\",\n \"maxspeed\": \"none\",\n \"oneway\": \"yes\",\n \"ref\": \"A 94\",\n \"surface\": \"concrete\",\n \"tmc\": \"DE:12905/12906\"\n }\n }\n ]\n}" + val speedElements = mutableListOf() private lateinit var overpass: Overpass @@ -45,15 +48,56 @@ class OverpassTest { whenever(mockLocation.bearingTo(any())).thenReturn(50F) model.speedElements.addAll(speedElements) val speed = model.calculateSpeedLimit(mockLocation, 90F, "DEU") - verify(mockLocation, times(2)).bearingTo(any()) - assertEquals(speed, 0) + assertEquals(speed, 130) } @Test - fun `getSpeedLimit `() { - val mockOverpass: Overpass = mock() - whenever(mockOverpass.getSpeedLimit(100F, any(), any(), any())).thenReturn(speedElements) - overpass.getSpeedLimit(100F, eq(""), eq(""), emptyList()) - + fun `calculateSpeedLimit returns 0 when no elements matched`() { + val model = NavigationViewModel(TomTomRepository()) + val mockLocation: Location = mock() + model.speedElements.clear() + val speed = model.calculateSpeedLimit(mockLocation, 90F, "DEU") + assertEquals(0, speed) } + + @Test + fun `calculateSpeedLimit handles motorway with maxspeed none`() { + val model = NavigationViewModel(TomTomRepository()) + val mockLocation: Location = mock() + + val motorwayElement = Elements( + bounds = Bounds(0.0, 0.0, 0.0, 0.0), + geometry = listOf(com.kouros.navigation.data.overpass.Geometry(48.1619892, 11.8609409)), + tags = Tags(maxspeed = "none", highway = "motorway"), + type = "way" + ) + model.speedElements.add(motorwayElement) + + whenever(mockLocation.latitude).thenReturn(48.1619892) + whenever(mockLocation.longitude).thenReturn(11.8609409) + + val speed = model.calculateSpeedLimit(mockLocation, 90F, "DEU") + assertEquals(130, speed) + } + + @Test + fun `calculateSpeedLimit handles numeric maxspeed`() { + val model = NavigationViewModel(TomTomRepository()) + val mockLocation: Location = mock() + + val roadElement = Elements( + bounds = Bounds(0.0, 0.0, 0.0, 0.0), + geometry = listOf(com.kouros.navigation.data.overpass.Geometry(48.1619892, 11.8609409)), + tags = Tags(maxspeed = "50", highway = "residential"), + type = "way" + ) + model.speedElements.add(roadElement) + + whenever(mockLocation.latitude).thenReturn(48.1619892) + whenever(mockLocation.longitude).thenReturn(11.8609409) + + val speed = model.calculateSpeedLimit(mockLocation, 90F, "DEU") + assertEquals(50, speed) + } + } \ No newline at end of file