Compare commits

...
38 Commits
Author SHA1 Message Date
Dimitris e33eba0d51 CarHardwareInfoScreen 2026-06-17 09:15:23 +02:00
Dimitris a3b22c386b DEVIATION depends on speed 2026-06-05 15:01:05 +02:00
Dimitris df1ceb5c44 Recent Places Sort by navigations, DEVIATION Maximum 2026-05-30 10:22:27 +02:00
Dimitris 21d9688b99 FindStep 2026-05-29 17:31:48 +02:00
Dimitris 02107256c7 Remove close action 2026-05-15 10:17:21 +02:00
Dimitris 9b4e730dcf CheckRoute, Simulation, Closed Streets 2026-05-12 09:51:58 +02:00
Dimitris 357a9af4f1 Logs 2026-05-09 09:46:30 +02:00
Dimitris ffa0d47e7b Overpass Performance, ApplicationConfig 2026-05-09 09:33:50 +02:00
Dimitris 29e58f6a24 ApplicationConfig, Favorites 2026-05-08 11:08:40 +02:00
Dimitris 72b3185280 RouteCalculator 2026-05-07 10:34:27 +02:00
Dimitris 8b612d5b80 RouteCalculator 2026-05-06 19:49:43 +02:00
Dimitris 25ba7d7bf6 Restaurants 2026-05-05 14:27:00 +02:00
Dimitris 202c2dfcea Traffic transfer line to right,
Fuel prices from tankerkönig
2026-05-04 15:51:03 +02:00
Dimitris 07cc382d3e Unused imports 2026-04-30 17:05:55 +02:00
Dimitris 4cf8517782 Fuel, Snap initial location 2026-04-30 15:50:35 +02:00
Dimitris 60cd6abc35 Fuel, Snap initial location 2026-04-30 15:50:23 +02:00
Dimitris 3926bbf1f9 FindStep Performance 2026-04-30 07:10:35 +02:00
Dimitris 615141d816 Traffic 2026-04-29 15:59:30 +02:00
Dimitris ae4d3f321e Traffic 2026-04-28 16:52:48 +02:00
Dimitris 42b3eabe47 Overpass, Traffic 2026-04-27 15:58:04 +02:00
Dimitris 17e68de017 Pan Mode 2026-04-24 12:24:53 +02:00
Dimitris 0fa625d785 Overpass 2026-04-22 17:44:08 +02:00
Dimitris f388ba0fb8 Notification 2026-04-06 15:39:12 +02:00
Dimitris 3fbba92b1d Notification 2026-04-06 14:42:19 +02:00
Dimitris b1d56d536d Notification 2026-04-06 13:25:18 +02:00
Dimitris dd2bdc2768 Notification removed 2026-04-06 12:19:13 +02:00
Dimitris 6a1897efd2 Preview after Search 2026-04-06 10:00:29 +02:00
Dimitris 6c64bbecfd Refresh Distance in PlaceList 2026-04-06 09:31:25 +02:00
Dimitris c6bad779d3 Refresh Distance in PlaceList 2026-04-06 09:27:39 +02:00
Dimitris 3d7749c6c7 Vehicle heading 2026-04-05 17:07:02 +02:00
Dimitris 5105d4eb9a ExitNumber, Flow 2026-04-05 09:22:06 +02:00
Dimitris f4cac4361b TomTom 2026-04-04 15:58:28 +02:00
Dimitris eb37c26fab Warnings 2026-04-04 14:43:55 +02:00
Dimitris b360f4bb5c RouteCarModel 2026-04-04 13:28:20 +02:00
Dimitris 4874d35d45 RouteCarModel 2026-04-04 13:20:53 +02:00
Dimitris 24173412e8 Automotive, CarSession 2026-04-03 09:54:43 +02:00
Dimitris 52f8dec2e6 Stopover and Legs, ManeuverType 2026-04-01 16:24:51 +02:00
Dimitris 6838ad09c4 Stopover and Legs 2026-03-31 17:06:55 +02:00
119 changed files with 4303 additions and 1478 deletions
+151 -94
View File
@@ -1,10 +1,10 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
This file provides guidance to Claude Code when working with code in this repository.
## 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, ObjectBox 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/<provider>/`
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<T>` 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<Station>`)
- `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<T>` 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
+7 -6
View File
@@ -11,14 +11,14 @@ val properties = Properties().apply {
android {
namespace = "com.kouros.navigation"
compileSdk = 36
compileSdk = 37
defaultConfig {
applicationId = "com.kouros.navigation"
minSdk = 33
targetSdk = 36
versionCode = 87
versionName = "0.2.0.87"
targetSdk = 37
versionCode = 131
versionName = "0.4.0.131"
base.archivesName = "navi-$versionName"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
}
@@ -42,7 +42,7 @@ android {
release {
signingConfig = signingConfigs.getByName("release")
isMinifyEnabled = false
isShrinkResources = false
//isShrinkResources = false
proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"),
@@ -101,9 +101,10 @@ dependencies {
implementation(libs.maplibre.compose)
implementation(libs.accompanist.permissions)
implementation(project(":common:car"))
implementation(project(":common:data"))
implementation(libs.androidx.car.app)
implementation(libs.androidx.app.projected)
implementation(libs.play.services.location)
implementation(libs.androidx.compose.runtime)
implementation(libs.androidx.navigation.compose)
@@ -31,7 +31,7 @@ fun test(applicationContext: Context, routeModel: RouteModel) {
for ((index, step) in routeModel.curLeg.steps.withIndex()) {
for ((windex, waypoint) in step.maneuver.waypoints.withIndex()) {
routeModel.updateLocation(
location(waypoint[0], waypoint[1]), navigationViewModel
waypoint, navigationViewModel
)
val step = routeModel.currentStep()
val nextStep = routeModel.nextStep()
@@ -1,13 +1,8 @@
package com.kouros.navigation.ui
import android.Manifest
import android.content.ComponentName
import android.content.Intent
import android.content.ServiceConnection
import android.location.LocationManager
import android.os.Bundle
import android.os.IBinder
import android.util.Log
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
@@ -45,7 +40,6 @@ import com.kouros.navigation.MainApplication.Companion.navigationViewModel
import com.kouros.navigation.car.TextToSpeechManager
import com.kouros.navigation.data.Constants.DESTINATION_ARRIVAL_DISTANCE
import com.kouros.navigation.data.Constants.INSTRUCTION_DISTANCE
import com.kouros.navigation.data.Constants.TAG
import com.kouros.navigation.data.Constants.TILT
import com.kouros.navigation.data.StepData
import com.kouros.navigation.model.BaseStyleModel
@@ -59,7 +53,7 @@ import com.kouros.navigation.ui.navigation.NavigationSheet
import com.kouros.navigation.ui.search.SearchSheet
import com.kouros.navigation.ui.theme.NavigationTheme
import com.kouros.navigation.utils.GeoUtils.snapLocation
import com.kouros.navigation.utils.bearing
import com.kouros.navigation.utils.bearingPositive
import com.kouros.navigation.utils.getSettingsRepository
import com.kouros.navigation.utils.getSettingsViewModel
import com.kouros.navigation.utils.location
@@ -278,7 +272,7 @@ class MainActivity : ComponentActivity() {
val bearing = if (currentLocation.hasBearing()) {
currentLocation.bearing.toDouble()
} else {
bearing(lastLocation, currentLocation, cameraPosition.value!!.bearing)
bearingPositive(lastLocation, currentLocation, cameraPosition.value!!.bearing)
}
with(routeModel) {
@@ -92,7 +92,7 @@ fun MapView(
duration = 1.seconds
)
}
NavigationImage(paddingValues, width, height / 6, "", dark)
NavigationImage(paddingValues, width, height / 6, "", dark, tilt)
}
}
}
@@ -179,9 +179,9 @@ fun Categories(
modifier = Modifier.horizontalScroll(scrollState)
) {
Button(onClick = {
val places = viewModel.loadRecentPlace(applicationContext)
val places = viewModel.loadRecentPlaces(applicationContext)
val toLocation = location(places.first()!!.longitude, places.first()!!.latitude)
viewModel.loadRoute(applicationContext, location, toLocation, 0F)
viewModel.loadRoute(applicationContext, location, listOf(toLocation), 0F)
closeSheet()
}) {
Icon(
@@ -243,12 +243,12 @@ private fun SearchPlaces(
latitude = place.lat.toDouble(),
postalCode = place.address.postcode,
city = place.address.city,
street = place.address.road
street = place.address.road,
)
viewModel.saveRecent(context, pl)
val toLocation =
location(place.lon.toDouble(), place.lat.toDouble())
viewModel.loadRoute(context, location, toLocation, 0F)
viewModel.loadRoute(context, location, listOf(toLocation), 0F)
closeSheet()
}
.fillMaxWidth()
@@ -116,9 +116,9 @@ fun Home(
) {
Row(horizontalArrangement = Arrangement.SpaceBetween) {
Button(onClick = {
val places = viewModel.loadRecentPlace(applicationContext)
val places = viewModel.loadRecentPlaces(applicationContext)
val toLocation = location(places.first()!!.longitude, places.first()!!.latitude)
viewModel.loadRoute(applicationContext, location, toLocation, 0F)
viewModel.loadRoute(applicationContext, location, listOf(toLocation), 0F)
closeSheet()
}) {
Icon(
@@ -168,7 +168,7 @@ private fun RecentPlaces(
modifier = Modifier
.clickable {
val toLocation = location(place.longitude, place.latitude)
viewModel.loadRoute(context, location, toLocation, 0F)
viewModel.loadRoute(context, location, listOf(toLocation), 0F)
closeSheet()
}
.fillMaxWidth()
+1 -2
View File
@@ -7,7 +7,7 @@ plugins {
android {
namespace = "com.kouros.navigation"
compileSdk {
version = release(36)
version = release(37)
}
defaultConfig {
@@ -37,7 +37,6 @@ android {
dependencies {
implementation(libs.androidx.app.automotive)
implementation(libs.androidx.car.app)
implementation(libs.androidx.material3)
implementation(libs.androidx.runtime.livedata)
implementation(project(":common:car"))
@@ -4,10 +4,10 @@
android:viewportWidth="960"
android:viewportHeight="960"
android:tint="#1A7416">
<group android:scaleX="0.7888"
android:scaleY="0.7888"
android:translateX="101.376"
android:translateY="101.376">
<group android:scaleX="0.58"
android:scaleY="0.58"
android:translateX="201.6"
android:translateY="201.6">
<path
android:fillColor="@android:color/white"
android:pathData="M319,680L480,607L641,680L656,665L480,240L304,665L319,680ZM480,880Q397,880 324,848.5Q251,817 197,763Q143,709 111.5,636Q80,563 80,480Q80,397 111.5,324Q143,251 197,197Q251,143 324,111.5Q397,80 480,80Q563,80 636,111.5Q709,143 763,197Q817,251 848.5,324Q880,397 880,480Q880,563 848.5,636Q817,709 763,763Q709,817 636,848.5Q563,880 480,880ZM480,800Q614,800 707,707Q800,614 800,480Q800,346 707,253Q614,160 480,160Q346,160 253,253Q160,346 160,480Q160,614 253,707Q346,800 480,800ZM480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480Z"/>
@@ -1,6 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@drawable/ic_launcher_background" />
<foreground android:drawable="@drawable/ic_launcher_foreground" />
<monochrome android:drawable="@drawable/ic_launcher_foreground" />
</adaptive-icon>
@@ -1,6 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@drawable/ic_launcher_background" />
<foreground android:drawable="@drawable/ic_launcher_foreground" />
<monochrome android:drawable="@drawable/ic_launcher_foreground" />
</adaptive-icon>
Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.1 KiB

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.5 KiB

After

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.6 KiB

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.1 KiB

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.8 KiB

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.7 KiB

After

Width:  |  Height:  |  Size: 4.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.2 KiB

After

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.5 KiB

After

Width:  |  Height:  |  Size: 7.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.3 KiB

After

Width:  |  Height:  |  Size: 4.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

After

Width:  |  Height:  |  Size: 9.8 KiB

+1 -2
View File
@@ -6,7 +6,7 @@ plugins {
android {
namespace = "com.kouros.android.cars.carappservice"
compileSdk = 36
compileSdk = 37
defaultConfig {
minSdk = 33
@@ -42,7 +42,6 @@ dependencies {
implementation(libs.androidx.lifecycle.runtime.ktx)
implementation(libs.androidx.ui)
implementation(libs.maplibre.compose)
implementation(libs.androidx.app.projected)
implementation(project(":common:data"))
implementation(libs.androidx.runtime.livedata)
implementation(libs.androidx.compose.foundation)
@@ -0,0 +1,108 @@
package com.kouros.navigation.car
import android.location.Location
import android.location.LocationManager
import com.kouros.navigation.data.overpass.Overpass
import com.kouros.navigation.data.tomtom.TomTomRepository
import com.kouros.navigation.model.NavigationViewModel
import com.kouros.navigation.utils.location
import kotlinx.coroutines.delay
import kotlinx.coroutines.runBlocking
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotEquals
import org.junit.Test
class OverpassTest {
@Test
fun `maxSpeed Schmalkaldener 30 `() {
val curLocation = location(11.582495, 48.186863)
executeSpeedTest(curLocation, "Schmalkaldener Straße", emptyList(), 90, 30)
}
@Test
fun `maxSpeed Ingolstädter 50 `() {
val curLocation = location(11.584384, 48.186338)
executeSpeedTest(curLocation, "Ingolstädter Straße", listOf("B13"), 180, 50)
}
@Test
fun `maxSpeed Isarring `() {
var curLocation = location(11.5999989, 48.1732329)
executeSpeedTest(curLocation, "Isarring", listOf("B2R"), 200, 60)
curLocation = location(11.5995437, 48.1703931)
executeSpeedTest(curLocation, "Isarring", listOf("B2R"), 204, 50)
val locations = listOf(
location( 11.5964253, 48.1658679),
location( 11.5960449, 48.1650396),
location( 11.5959427, 48.1645584),
location( 11.5959714, 48.1641186),
)
locations.forEach {
executeSpeedTest(it, "Biedersteiner", listOf("B2R"), 190, 60)
}
}
@Test
fun `maxSpeed A94 `() {
val curLocation = location(11.88117, 48.16595)
executeSpeedTest(curLocation, "", listOf("A94", "E552"), 90, 130)
}
@Test
fun `maxSpeed Fendsbach `() {
val curLocation = location(11.94989, 48.21522)
executeSpeedTest(curLocation, "Fendsbach", listOf("St 2331"), 0, 60)
}
@Test
fun `maxSpeed Leopoldstraße `() {
val locations = listOf(
location(11.5854771, 48.1778470),
location(11.5855582, 48.1756081),
location(11.5854672, 48.1753093),
location(11.5850147, 48.1774400)
)
executeSpeedTest(locations[0], "Leopoldstraße", emptyList(), 0, 50)
executeSpeedTest(locations[1], "Leopoldstraße", emptyList(), 180, 30)
executeSpeedTest(locations[2], "Leopoldstraße", emptyList(), 180, 30)
executeSpeedTest(locations[3], "Leopoldstraße", emptyList(), 180, 50)
}
@Test
fun `maxSpeed Egnatia `() {
val locations = listOf(
location(20.645487, 39.552875),
location(20.686672, 39.838547),
)
executeSpeedTest(locations[0], "", listOf("E90", "E92"), 100, 120)
executeSpeedTest(locations[1], "", listOf("E853"), 320, 90)
}
fun executeSpeedTest(
curLocation: Location,
street: String,
roadNumbers: List<String>,
routeBearing: Int,
result: Int
) {
val viewModel = NavigationViewModel(TomTomRepository())
val lineString = "${curLocation.latitude},${curLocation.longitude}"
val elements = Overpass().getSpeedLimit(600F, lineString, street, roadNumbers)
viewModel.speedElements.addAll(elements)
assertNotEquals(0, viewModel.speedElements.size)
val speed = viewModel.calculateSpeedLimit(
curLocation,
routeBearing.toFloat(),
"DEU",
)
assertEquals(result, speed)
}
}
@@ -2,26 +2,24 @@ package com.kouros.navigation.car
import android.location.Location
import android.location.LocationManager
import androidx.car.app.navigation.model.Maneuver
import androidx.test.platform.app.InstrumentationRegistry
import androidx.test.ext.junit.runners.AndroidJUnit4
import com.kouros.data.R
import androidx.test.platform.app.InstrumentationRegistry
import com.kouros.navigation.data.Constants.homeHohenwaldeck
import com.kouros.navigation.data.RouteEngine
import com.kouros.navigation.data.route.ManeuverType
import com.kouros.navigation.data.tomtom.TomTomRepository
import com.kouros.navigation.model.NavigationViewModel
import com.kouros.navigation.model.RouteModel
import com.kouros.navigation.utils.GeoUtils.snapLocation
import com.kouros.navigation.utils.getSettingsRepository
import com.kouros.navigation.utils.location
import kotlinx.coroutines.runBlocking
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotEquals
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import org.junit.Assert.*
import org.junit.Before
import kotlin.collections.forEach
/**
* Instrumented test, which will execute on an Android device.
*
@@ -33,13 +31,386 @@ class RouteModelTest {
val routeModel = RouteModel()
val location = Location(LocationManager.GPS_PROVIDER)
val leftDistance = listOf(
64.0,
0.0,
143.7469482421875,
111.36382293701172,
101.35636901855469,
0.0,
356.0272216796875,
250.2962646484375,
245.09136962890625,
213.86209106445312,
173.69473266601562,
90.41661834716797,
40.58605194091797,
22.74074363708496,
12.33098030090332,
0.0,
1046.3043212890625,
1026.2342529296875,
1012.8703002929688,
979.4789428710938,
972.7660522460938,
971.6541137695312,
914.8672485351562,
873.6181030273438,
831.3056030273438,
781.1799926757812,
719.982666015625,
653.1173095703125,
578.4839477539062,
566.2300415039062,
490.4866943359375,
482.66766357421875,
451.45355224609375,
443.6345520019531,
391.28887939453125,
346.71197509765625,
316.65283203125,
237.5792236328125,
141.71861267089844,
78.22917175292969,
55.94073486328125,
41.31438064575195,
31.061260223388672,
20.943891525268555,
16.43439483642578,
0.0,
346.5630798339844,
339.0434875488281,
329.3117370605469,
321.055908203125,
304.0730285644531,
285.6786193847656,
272.10870361328125,
233.37289428710938,
213.9094696044922,
198.87030029296875,
164.74563598632812,
136.4317626953125,
91.64434051513672,
82.11692810058594,
60.832786560058594,
33.411705017089844,
0.0,
338.5975341796875,
318.1346435546875,
211.02232360839844,
201.35411071777344,
187.75144958496094,
179.0858154296875,
170.42019653320312,
123.4335708618164,
90.03872680664062,
74.55345916748047,
60.95069122314453,
39.9212532043457,
0.0,
314.19586181640625,
306.1593017578125,
289.4617004394531,
223.2646484375,
129.7374725341797,
76.47386932373047,
56.635841369628906,
52.30287170410156,
33.61341857910156,
20.613277435302734,
0.0,
5621.87744140625,
5612.591796875,
5587.8486328125,
5483.76806640625,
5447.275390625,
5422.7744140625,
5398.21875,
5373.927734375,
5358.28955078125,
5347.1455078125,
5339.36181640625,
5314.6181640625,
5303.107421875,
5278.109375,
5253.859375,
5244.88720703125,
5200.0400390625,
5151.9326171875,
5138.63916015625,
5085.87548828125,
5054.32080078125,
5012.51220703125,
4962.390625,
4938.140625,
4898.97607421875,
4862.1416015625,
4788.78564453125,
4767.880859375,
4761.89013671875,
4756.5390625,
4749.23388671875,
4724.98388671875,
4645.017578125,
4596.826171875,
4573.98583984375,
4554.85009765625,
4521.48388671875,
4499.232421875,
4484.6064453125,
4471.07763671875,
4457.54931640625,
4415.10986328125,
4389.8359375,
4359.6376953125,
4347.59716796875,
4342.62109375,
4334.59375,
4320.33447265625,
4303.408203125,
4289.998046875,
4261.86962890625,
4215.78515625,
4207.08251953125,
4192.81201171875,
4080.384521484375,
4065.3955078125,
4009.8798828125,
3975.99853515625,
3971.5283203125,
3953.9345703125,
3915.152587890625,
3877.74365234375,
3865.702880859375,
3857.67578125,
3850.37060546875,
3845.019287109375,
3843.161865234375,
3819.26904296875,
3803.01171875,
3686.892578125,
3661.30908203125,
3629.2001953125,
3613.607177734375,
3591.255859375,
3580.988525390625,
3573.37060546875,
3563.377685546875,
3552.08349609375,
3537.639404296875,
3502.25146484375,
3484.319580078125,
3445.313720703125,
3437.883544921875,
3431.69873046875,
3413.72021484375,
3405.05224609375,
3340.725830078125,
3312.782470703125,
3302.888671875,
3292.994873046875,
3240.986328125,
3229.830322265625,
3185.277099609375,
3096.74755859375,
2981.00146484375,
2907.40869140625,
2865.943115234375,
2859.75830078125,
2851.719482421875,
2813.998779296875,
2778.027587890625,
2767.468505859375,
2729.052001953125,
2694.80712890625,
2651.47265625,
2613.131591796875,
2602.36376953125,
2588.665771484375,
2549.015380859375,
2530.110107421875,
2521.1689453125,
2510.401123046875,
2478.26953125,
2437.71337890625,
2400.48681640625,
2350.667236328125,
2320.4267578125,
2294.857666015625,
2268.75830078125,
2260.833740234375,
2249.615234375,
2229.476318359375,
2210.573486328125,
2192.78271484375,
2184.963623046875,
2148.23974609375,
2114.8818359375,
2105.955078125,
2052.49951171875,
2013.5179443359375,
2006.804931640625,
1959.955810546875,
1869.44775390625,
1787.787109375,
1740.873046875,
1719.6939697265625,
1554.079833984375,
1395.2606201171875,
1349.453369140625,
1296.93359375,
1144.57080078125,
1030.08203125,
972.0895385742188,
926.4032592773438,
921.95556640625,
911.9205322265625,
902.9940185546875,
887.3560180664062,
803.21728515625,
774.1542358398438,
767.318603515625,
762.62841796875,
754.703857421875,
751.3680419921875,
738.9346923828125,
725.406005859375,
710.6475219726562,
668.3836669921875,
615.11279296875,
605.7323608398438,
572.6571044921875,
552.6024780273438,
545.567138671875,
474.7272644042969,
459.36187744140625,
445.29119873046875,
427.3172607421875,
406.21124267578125,
378.6969909667969,
371.8613586425781,
362.934814453125,
347.29681396484375,
329.3663330078125,
320.4398193359375,
301.3039855957031,
237.61085510253906,
112.25553131103516,
94.07954406738281,
75.73666381835938,
0.0,
1023.4342651367188,
1003.37939453125,
973.33203125,
901.591796875,
805.4495849609375,
776.5214233398438,
723.6364135742188,
713.1954956054688,
693.5875854492188,
646.0399169921875,
565.5601196289062,
509.7200622558594,
484.1024169921875,
422.78350830078125,
276.693603515625,
265.35223388671875,
187.42837524414062,
160.74212646484375,
97.36228942871094,
29.534751892089844,
18.390586853027344,
0.0,
462.20318603515625,
450.08526611328125,
439.66217041015625,
397.6007080078125,
346.669921875,
290.0765380859375,
270.7193603515625,
208.91537475585938,
191.0126495361328,
177.56549072265625,
165.6016082763672,
117.09382629394531,
108.90425109863281,
75.23577117919922,
37.74562072753906,
0.0,
940.9969482421875,
921.5716552734375,
891.173583984375,
876.2035522460938,
865.4982299804688,
851.6742553710938,
808.6068725585938,
793.0664672851562,
757.3865356445312,
716.390380859375,
701.561767578125,
696.2328491210938,
657.3816528320312,
641.1264038085938,
584.9859619140625,
581.8070678710938,
552.8590087890625,
473.6039733886719,
401.5708923339844,
369.44659423828125,
360.9601745605469,
353.1898498535156,
309.3813781738281,
269.4887390136719,
226.76416015625,
141.3342742919922,
135.6678466796875,
126.8246078491211,
94.33628845214844,
68.04391479492188,
0.0,
149.6364288330078,
118.28071594238281,
0.0,
)
val distance = listOf(
1046.5,
1026.5,
1012.0,
979.4,
972.7,
971.6,
914.8,
873.6,
831.3,
781.1,
719.9,
653.1,
578.4,
566.2,
490.4,
482.6,
451.4,
443.6,
391.2,
346.7,
316.6,
237.5,
141.7,
78.22,
55.94,
41.31,
31.0,
20.9,
)
@Before
fun setup() {
val appContext = InstrumentationRegistry.getInstrumentation().targetContext
val repository = getSettingsRepository(appContext)
runBlocking { repository.setRoutingEngine(RouteEngine.TOMTOM.ordinal) }
val routeJsonString = TomTomRepository().fetchUrl(
"https://kouros-online.de/tomtom_routing.json",
"http://192.168.1.37/tomtom_routing.json",
false
)
assertNotEquals("", routeJsonString)
@@ -50,8 +421,8 @@ class RouteModelTest {
@Test
fun checkRoute() {
assertEquals(true, routeModel.isNavigating())
assertEquals(routeModel.curRoute.summary.distance, 11116.0, 10.0)
assertEquals(routeModel.curRoute.summary.duration, 1581.0, 10.0)
assertEquals(routeModel.curRoute.summary.distance, 11108.0, 10.0)
assertEquals(routeModel.curRoute.summary.duration, 1094.0, 10.0)
}
@Test
@@ -60,27 +431,38 @@ class RouteModelTest {
location.longitude = 11.579034
routeModel.updateLocation(location, NavigationViewModel(TomTomRepository()))
val stepData = routeModel.currentStep()
assertEquals(stepData.currentManeuverType, Maneuver.TYPE_TURN_NORMAL_RIGHT)
assertEquals(stepData.currentManeuverType, ManeuverType.TYPE_TURN_NORMAL_RIGHT.value)
assertEquals(stepData.instruction, "Silcherstraße")
assertEquals(stepData.leftStepDistance, 20.0, 5.0)
assertEquals(stepData.leftStepDistance, 46.0, 5.0)
val nextStepData = routeModel.nextStep()
assertEquals(nextStepData.currentManeuverType, Maneuver.TYPE_TURN_NORMAL_RIGHT)
assertEquals(nextStepData.currentManeuverType, ManeuverType.TYPE_TURN_NORMAL_RIGHT.value)
assertEquals(nextStepData.instruction, "Schmalkaldener Straße")
}
@Test
fun checkArrival() {
location.latitude = 48.116829
location.longitude = 11.594309
routeModel.updateLocation(location, NavigationViewModel(TomTomRepository()))
val stepData = routeModel.currentStep()
assertEquals(stepData.currentManeuverType, ManeuverType.TYPE_DESTINATION_LEFT.value)
assertEquals(stepData.instruction, "Hohenwaldeckstraße")
assertEquals(stepData.leftDistance, 0.0, 5.0)
}
@Test
fun checkSchmalkadener20() {
location.latitude = 48.187057
location.longitude = 11.576652
location.latitude = 48.186943
location.longitude = 11.579195
routeModel.updateLocation(location, NavigationViewModel(TomTomRepository()))
val stepData = routeModel.currentStep()
assertEquals(stepData.currentManeuverType, Maneuver.TYPE_TURN_NORMAL_RIGHT)
assertEquals(stepData.instruction, "Schmalkaldener Straße")
assertEquals(stepData.leftStepDistance, 0.0, 1.0)
assertEquals(stepData.currentManeuverType, ManeuverType.TYPE_TURN_NORMAL_RIGHT.value)
assertEquals(stepData.instruction, "Ingolstädter Straße")
assertEquals(stepData.leftStepDistance, 326.0, 1.0)
val nextStepData = routeModel.nextStep()
assertEquals(nextStepData.currentManeuverType, Maneuver.TYPE_TURN_NORMAL_RIGHT)
assertEquals(nextStepData.instruction, "Ingolstädter Straße")
assertEquals(nextStepData.currentManeuverType, ManeuverType.TYPE_TURN_NORMAL_LEFT.value)
assertEquals(nextStepData.instruction, "Schenkendorfstraße")
}
@Test
@@ -90,15 +472,15 @@ class RouteModelTest {
routeModel.updateLocation(location, NavigationViewModel(TomTomRepository()))
val stepData = routeModel.currentStep()
if (routeModel.navState.nextStep) {
assertEquals(stepData.currentManeuverType, Maneuver.TYPE_STRAIGHT)
assertEquals(stepData.currentManeuverType, ManeuverType.TYPE_STRAIGHT.value)
assertEquals(stepData.instruction, "Ingolstädter Straße")
val nextStepData = routeModel.nextStep()
assertEquals(nextStepData.currentManeuverType, Maneuver.TYPE_TURN_NORMAL_LEFT)
assertEquals(nextStepData.currentManeuverType, ManeuverType.TYPE_TURN_NORMAL_LEFT.value)
assertEquals(nextStepData.instruction, "Schenkendorfstraße")
} else {
assertEquals(stepData.currentManeuverType, Maneuver.TYPE_TURN_NORMAL_LEFT)
assertEquals(stepData.currentManeuverType, ManeuverType.TYPE_TURN_NORMAL_LEFT.value)
}
assertEquals(stepData.leftStepDistance, 301.0, 1.0)
assertEquals(stepData.leftStepDistance, 327.0, 1.0)
}
@Test
@@ -108,14 +490,14 @@ class RouteModelTest {
location.bearing = 180.0F
routeModel.updateLocation(location, NavigationViewModel(TomTomRepository()))
val stepData = routeModel.currentStep()
assertEquals(stepData.currentManeuverType, Maneuver.TYPE_TURN_NORMAL_LEFT)
assertEquals(stepData.currentManeuverType, ManeuverType.TYPE_TURN_NORMAL_LEFT.value)
assertEquals(stepData.instruction, "Schenkendorfstraße")
assertEquals(stepData.leftStepDistance, 170.0, 10.0)
assertEquals(stepData.leftStepDistance, 212.0, 10.0)
assertEquals(stepData.lane.size, 4)
assertEquals(stepData.lane.first().valid, true)
assertEquals(stepData.lane.last().valid, false)
val nextStepData = routeModel.nextStep()
assertEquals(nextStepData.currentManeuverType, Maneuver.TYPE_KEEP_LEFT)
assertEquals(nextStepData.currentManeuverType, ManeuverType.TYPE_KEEP_LEFT.value)
assertEquals(nextStepData.instruction, "Schenkendorfstraße")
}
@@ -125,7 +507,7 @@ class RouteModelTest {
location.longitude = homeHohenwaldeck.longitude
routeModel.updateLocation(location, NavigationViewModel(TomTomRepository()))
val stepData = routeModel.nextStep()
assertEquals(stepData.currentManeuverType, Maneuver.TYPE_DESTINATION_LEFT)
assertEquals(stepData.currentManeuverType, ManeuverType.TYPE_DESTINATION_LEFT.value)
}
@Test
@@ -143,11 +525,11 @@ class RouteModelTest {
if (index in 61..61) {
routeModel.updateLocation(curLocation, NavigationViewModel(TomTomRepository()))
val stepData = routeModel.currentStep()
assertEquals(stepData.lane.size, 3)
assertEquals(stepData.lane.size, 2)
assertEquals(stepData.lane.first().valid, true)
assertEquals(stepData.lane.first().indications.first(), "STRAIGHT")
}
if (index in 74..74) {
if (index in 74..75) {
routeModel.updateLocation(curLocation, NavigationViewModel(TomTomRepository()))
val stepData = routeModel.currentStep()
assertEquals(stepData.lane.size, 3)
@@ -175,29 +557,24 @@ class RouteModelTest {
if (routeModel.isNavigating()) {
val curLocation = location(waypoint[0], waypoint[1])
if (index in 0..routeModel.curRoute.waypoints.size) {
//runBlocking { delay(1000) }
val start = System.currentTimeMillis()
routeModel.updateLocation(curLocation, NavigationViewModel(TomTomRepository()))
val stepData = routeModel.currentStep()
//println("${stepData.instruction} ${System.currentTimeMillis() - start}")
if (stepData.lane.isNotEmpty()) {
println(stepData.street)
stepData.lane.forEach {
println("${it.indications} ${it.valid}")
}
}
// val nextData = routeModel.nextStep()
assertEquals(stepData.leftStepDistance, leftDistance[index], 1.0)
}
}
}
}
@Test
fun `leftStepDistance Inglolstädter `() {
val location: Location = location(11.584578, 48.183653)
fun `leftStepDistance Ingolstädter `() {
var location = location(11.584352, 48.186771)
routeModel.updateLocation(location, NavigationViewModel(TomTomRepository()))
val step = routeModel.currentStep()
assertEquals(step.leftStepDistance, 645.0, 1.0)
var step = routeModel.currentStep()
assertEquals(step.leftStepDistance, 1039.0, 1.0)
location = location(11.584578, 48.183653)
routeModel.updateLocation(location, NavigationViewModel(TomTomRepository()))
step = routeModel.currentStep()
assertEquals(step.leftStepDistance, 705.0, 1.0)
}
@Test
@@ -205,6 +582,66 @@ class RouteModelTest {
val location: Location = location(11.578911, 48.185565)
routeModel.updateLocation(location, NavigationViewModel(TomTomRepository()))
val step = routeModel.currentStep()
assertEquals(step.leftStepDistance, 26.0, 1.0)
assertEquals(step.leftStepDistance, 37.0, 1.0)
}
}
@Test
fun leftStepDistance() {
val start = System.currentTimeMillis()
for ((index, waypoint) in routeModel.curRoute.waypoints.withIndex()) {
val curLocation = location(waypoint[0], waypoint[1])
if (routeModel.isNavigating()) {
if (index in 16..43) {
routeModel.updateLocation(
curLocation,
NavigationViewModel(TomTomRepository())
)
val stepData = routeModel.currentStep()
assertEquals(stepData.leftStepDistance, distance[index - 16], 1.0)
}
}
}
val end = System.currentTimeMillis() - start
println("Time $end")
}
@Test
fun `check leftStepDistance Vogelhart Gpx `() {
routeModel.navState = routeModel.navState.copy(routeBearing = 270F)
var location = location(11.57927955, 48.18554854)
routeModel.updateLocation(location, NavigationViewModel(TomTomRepository()))
var stepData = routeModel.currentStep()
assertEquals(stepData.leftStepDistance, 62.0, 1.0)
location = location(11.57919950, 48.18556317)
routeModel.updateLocation(location, NavigationViewModel(TomTomRepository()))
stepData = routeModel.currentStep()
assertEquals(stepData.leftStepDistance, 58.0, 1.0)
location = location(11.57906832, 48.18556405)
routeModel.updateLocation(location, NavigationViewModel(TomTomRepository()))
stepData = routeModel.currentStep()
assertEquals(stepData.leftStepDistance, 48.0, 1.0)
routeModel.navState = routeModel.navState.copy(routeBearing = 270F)
location = location(11.57875945, 48.18558161)
routeModel.updateLocation(location, NavigationViewModel(TomTomRepository()))
//assertEquals(routeModel.currentStep().leftStepDistance, 0.0, 1.0)
}
@Test
fun `check deviation `() {
val navigationViewModel = NavigationViewModel(TomTomRepository())
// Schmalkaldener Straße
val firstLocation = location(11.579903, 48.186906)
routeModel.updateLocation(firstLocation, navigationViewModel)
// Frankfurter Ring
val location = location(11.579794, 48.187700)
val snappedLocation = snapLocation(location, routeModel.route.maneuverLocations())
val distance = snappedLocation.distanceTo(firstLocation)
assertEquals(distance.toDouble(), 12.0, 1.0)
assertEquals(snappedLocation.latitude, 48.18691500607897, 0.0)
}
}
@@ -132,12 +132,12 @@ class CarSensorManager(
val carSensors = carHardwareManager.carSensors
carSensors.addCompassListener(
CarSensors.UPDATE_RATE_NORMAL,
CarSensors.UPDATE_RATE_FASTEST,
carContext.mainExecutor,
carCompassListener
)
carSensors.addCarHardwareLocationListener(
CarSensors.UPDATE_RATE_UI,
CarSensors.UPDATE_RATE_FASTEST,
carContext.mainExecutor,
carLocationListener
)
@@ -0,0 +1,10 @@
package com.kouros.navigation.car
import androidx.car.app.Session
import kotlinx.coroutines.flow.Flow
abstract class CarSession : Session() {
abstract fun invalidateNavigationScreen()
}
@@ -17,6 +17,7 @@ package com.kouros.navigation.car
import android.content.Intent
import android.content.res.Configuration
import android.location.Location
import android.util.Log
import androidx.car.app.CarContext
import androidx.car.app.CarToast
@@ -27,6 +28,9 @@ import androidx.car.app.model.CarIcon
import androidx.car.app.model.OnClickListener
import androidx.car.app.navigation.model.Trip
import androidx.core.graphics.drawable.IconCompat
import androidx.lifecycle.DefaultLifecycleObserver
import androidx.lifecycle.LifecycleObserver
import androidx.lifecycle.LifecycleOwner
import androidx.lifecycle.ViewModelStore
import androidx.lifecycle.ViewModelStoreOwner
import androidx.lifecycle.lifecycleScope
@@ -34,46 +38,74 @@ import com.kouros.data.R
import com.kouros.navigation.car.navigation.RouteCarModel
import com.kouros.navigation.car.screen.NavigationListener
import com.kouros.navigation.car.screen.NavigationScreen
import com.kouros.navigation.data.Constants
import com.kouros.navigation.data.Place
import com.kouros.navigation.data.datastore.DataStoreManager.PreferencesKeys.CAR_LOCATION
import com.kouros.navigation.data.datastore.dataStore
import com.kouros.navigation.data.tomtom.TomTomRepository
import com.kouros.navigation.model.NavigationViewModel
import kotlinx.coroutines.awaitCancellation
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.launch
/** Session class for the Navigation sample app. */
internal class ClusterSession : Session(), NavigationListener {
var mNavigationScreen: NavigationScreen? = null
internal class ClusterSession : CarSession(), NavigationListener {
lateinit var mNavigationScreen: NavigationScreen
var mNavigationCarSurface: SurfaceRenderer? = null
lateinit var surfaceRenderer: SurfaceRenderer
var mSettingsAction: Action? = null
var routeModel = RouteCarModel()
var routeModel = RouteCarModel()
lateinit var viewModelStoreOwner: ViewModelStoreOwner
lateinit var navigationViewModel: NavigationViewModel
lateinit var deviceLocationManager: DeviceLocationManager
private val lifecycleObserver: LifecycleObserver = object : DefaultLifecycleObserver {
override fun onPause(owner: LifecycleOwner) {
Log.d(Constants.TAG, "NavigationSession paused")
super.onPause(owner)
}
override fun onResume(owner: LifecycleOwner) {
Log.d(Constants.TAG, "NavigationSession resumed")
super.onResume(owner)
}
override fun onDestroy(owner: LifecycleOwner) {
if (::deviceLocationManager.isInitialized) {
deviceLocationManager.stopLocationUpdates()
}
carContext
.stopService(
Intent(
carContext,
NavigationNotificationService::class.java
)
)
}
}
init {
lifecycle.addObserver(lifecycleObserver)
}
override fun onCreateScreen(intent: Intent): Screen {
Log.i(TAG, "In onCreateScreen()")
setupViewModelStore()
mSettingsAction =
Action.Builder()
.setIcon(
CarIcon.Builder(
IconCompat.createWithResource(
carContext, R.drawable.alt_route_48px
)
)
.build()
)
.setOnClickListener(
OnClickListener {})
.build()
mNavigationCarSurface = SurfaceRenderer(carContext, lifecycle, viewModelStoreOwner)
// mNavigationScreen =
// new NavigationScreen(getCarContext(), mSettingsAction, this, mNavigationCarSurface);
surfaceRenderer = SurfaceRenderer(carContext, lifecycle, viewModelStoreOwner, this)
navigationViewModel = NavigationViewModel(TomTomRepository())
mNavigationScreen =
NavigationScreen(carContext, surfaceRenderer, this, navigationViewModel)
val action = intent.action
if (CarContext.ACTION_NAVIGATE == action) {
Log.i(TAG, "In onCreateScreen() Navigation intent")
CarToast.makeText(
carContext,
"Navigation intent: " + intent.dataString,
@@ -81,8 +113,28 @@ internal class ClusterSession : Session(), NavigationListener {
)
.show()
}
initializeManagers()
return mNavigationScreen
}
return mNavigationScreen!!
/**
* Initializes managers for rendering, sensors, and location.
*/
private fun initializeManagers() {
deviceLocationManager = DeviceLocationManager(
carContext = carContext,
lifecycleOwner = this,
shouldUseCarLocationFlow = flowOf(false),
onLocationUpdate = ::updateLocation,
onInitialLocation = { location ->
})
deviceLocationManager.startLocationUpdates()
}
fun updateLocation(location: Location) {
surfaceRenderer.updateLocation(location, "")
}
override fun onCarConfigurationChanged(newConfiguration: Configuration) {
@@ -104,6 +156,10 @@ internal class ClusterSession : Session(), NavigationListener {
}
override fun recalcRoute(destination: Place) {
}
companion object {
val TAG: String = ClusterSession::class.java.getSimpleName()
}
@@ -121,4 +177,8 @@ internal class ClusterSession : Session(), NavigationListener {
}
}
}
override fun invalidateNavigationScreen() {
mNavigationScreen.invalidate()
}
}
@@ -3,6 +3,7 @@ package com.kouros.navigation.car
import android.annotation.SuppressLint
import android.content.Context
import android.location.Location
import android.location.LocationListener
import android.location.LocationManager
import androidx.car.app.CarContext
import androidx.core.location.LocationListenerCompat
@@ -10,7 +11,12 @@ import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleOwner
import androidx.lifecycle.lifecycleScope
import androidx.lifecycle.repeatOnLifecycle
import com.kouros.data.BuildConfig
import com.kouros.navigation.data.Constants.homeVogelhart
import com.kouros.navigation.data.Constants.widenmayer
import kotlinx.coroutines.channels.awaitClose
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.callbackFlow
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.launch
@@ -59,6 +65,26 @@ class DeviceLocationManager(
}
}
/**
* Provides a flow of location updates from device GPS.
*
* deviceLocationManager.locationFlow().asLiveData().observe(this, ::updateLocation2)
*
* @return Flow emitting location updates
*/
@SuppressLint("MissingPermission")
fun locationFlow(): Flow<Location> = callbackFlow {
val listener = LocationListener { location ->
if (shouldUseDeviceLocation && isListening) {
trySend(location) // Emit the location update to the flow
}
}
locationManager.requestLocationUpdates(LocationManager.GPS_PROVIDER, 1000L, 5f, listener)
awaitClose {
locationManager.removeUpdates(listener) // Unregister listener on cancellation
}
}
/**
* Starts requesting location updates from device GPS.
* Provides initial location via callback and then starts continuous updates.
@@ -69,21 +95,24 @@ class DeviceLocationManager(
@SuppressLint("MissingPermission")
fun startLocationUpdates(minTimeMs: Long = 1000, minDistanceM: Float = 5f) {
if (isListening) return
// Get and deliver last known location first
val lastLocation = locationManager.getLastKnownLocation(LocationManager.GPS_PROVIDER)
if (lastLocation != null) {
onInitialLocation(lastLocation)
onLocationUpdate(lastLocation)
if (BuildConfig.DEBUG) {
onInitialLocation(homeVogelhart)
onLocationUpdate(homeVogelhart)
} else {
// Get and deliver last known location first
val lastLocation = locationManager.getLastKnownLocation(LocationManager.GPS_PROVIDER)
if (lastLocation != null) {
onInitialLocation(lastLocation)
onLocationUpdate(lastLocation)
}
// Start continuous location updates
locationManager.requestLocationUpdates(
LocationManager.GPS_PROVIDER,
minTimeMs,
minDistanceM,
locationListener
)
}
// Start continuous location updates
locationManager.requestLocationUpdates(
LocationManager.GPS_PROVIDER,
minTimeMs,
minDistanceM,
locationListener
)
isListening = true
}
@@ -14,7 +14,7 @@ import com.kouros.navigation.data.Constants.TAG
class NavigationCarAppService : CarAppService() {
val intentActionNavNotificationOpenApp =
val intentActionNavNotificationOpenApp =
"com.kouros.navigation.INTENT_ACTION_NAV_NOTIFICATION_OPEN_APP"
val channelId: String = "NavigationSessionChannel"
@@ -26,7 +26,7 @@ class NavigationCarAppService : CarAppService() {
@SuppressLint("PrivateResource")
override fun createHostValidator(): HostValidator {
return HostValidator.ALLOW_ALL_HOSTS_VALIDATOR
return HostValidator.ALLOW_ALL_HOSTS_VALIDATOR
}
@@ -1,22 +1,15 @@
package com.kouros.navigation.car
import android.content.Intent
import android.location.Location
import android.os.Message
import android.util.Log
import androidx.car.app.CarContext
import androidx.car.app.hardware.CarHardwareManager
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleOwner
import androidx.lifecycle.lifecycleScope
import androidx.lifecycle.repeatOnLifecycle
import com.kouros.navigation.data.Constants.TAG
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.launch
class NotificationManager(
class NavigationNotificationManager(
private val carContext: CarContext,
private val lifecycleOwner: LifecycleOwner,
) {
private var notificationServiceStarted = false
@@ -24,16 +17,7 @@ class NotificationManager(
private var serviceStarted = false
init {
lifecycleOwner.lifecycleScope.launch {
lifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) {
}
lifecycleOwner.repeatOnLifecycle(Lifecycle.State.DESTROYED) {
if (notificationServiceStarted) {
stopNotificationService()
}
}
}
}
fun startNotificationService() {
@@ -111,7 +111,7 @@ class NavigationNotificationService : Service() {
* Initializes the notifications, if needed.
*
*
* [NotificationManager.IMPORTANCE_HIGH] is needed to show the alerts on top of the car
* [NavigationNotificationManager.IMPORTANCE_HIGH] is needed to show the alerts on top of the car
* screen. However, the rail widget at the bottom of the screen will show regardless of the
* importance setting.
*/
@@ -4,18 +4,16 @@ import android.Manifest.permission
import android.content.Intent
import android.content.pm.PackageManager
import android.location.Location
import android.os.Build
import android.util.Log
import androidx.annotation.RequiresApi
import androidx.car.app.CarContext
import androidx.car.app.CarToast
import androidx.car.app.Screen
import androidx.car.app.ScreenManager
import androidx.car.app.Session
import androidx.car.app.connection.CarConnection
import androidx.car.app.model.Distance
import androidx.car.app.navigation.NavigationManager
import androidx.car.app.navigation.NavigationManagerCallback
import androidx.car.app.navigation.model.Destination
import androidx.car.app.navigation.model.Step
import androidx.car.app.navigation.model.Trip
import androidx.lifecycle.DefaultLifecycleObserver
import androidx.lifecycle.LifecycleObserver
@@ -53,10 +51,12 @@ import com.kouros.navigation.data.tomtom.TomTomRepository
import com.kouros.navigation.data.valhalla.ValhallaRepository
import com.kouros.navigation.model.NavigationViewModel
import com.kouros.navigation.model.RouteModel
import com.kouros.navigation.model.SettingsViewModel
import com.kouros.navigation.repository.SettingsRepository
import com.kouros.navigation.utils.GeoUtils
import com.kouros.navigation.utils.GeoUtils.snapLocation
import com.kouros.navigation.utils.NavigationUtils.getViewModel
import com.kouros.navigation.utils.formattedDistance
import com.kouros.navigation.utils.bearingPositive
import com.kouros.navigation.utils.getSettingsRepository
import com.kouros.navigation.utils.getSettingsViewModel
import com.kouros.navigation.utils.location
@@ -74,7 +74,7 @@ import kotlin.math.absoluteValue
* car hardware sensors, routing engine selection, and screen navigation.
* Implements NavigationScreen.Listener for handling navigation events.
*/
class NavigationSession : Session(), NavigationListener, NavigationObserverCallback {
class NavigationSession : CarSession(), NavigationListener, NavigationObserverCallback {
// Flag to enable/disable contact access feature
val useContacts = false
@@ -82,7 +82,7 @@ class NavigationSession : Session(), NavigationListener, NavigationObserverCallb
// Model for managing route state and navigation logic for Android Auto
lateinit var routeModel: RouteCarModel
var route = ""
var route = ""
// Main navigation screen displayed to the user
lateinit var navigationScreen: NavigationScreen
@@ -100,9 +100,6 @@ class NavigationSession : Session(), NavigationListener, NavigationObserverCallb
lateinit var textToSpeechManager: TextToSpeechManager
lateinit var notificationManager: NotificationManager
var autoDriveEnabled = false
val simulation = Simulation()
@@ -116,11 +113,11 @@ class NavigationSession : Session(), NavigationListener, NavigationObserverCallb
var speedCameras = listOf<Elements>()
var lastRouteDate: LocalDateTime = LocalDateTime.now()
private var lastRouteCheckLocation = location(0.0, 0.0)
var navigationManagerStarted = false
var notificationActive = false
var updateLocationIndex = 0
/**
* Lifecycle observer for managing session lifecycle events.
@@ -128,16 +125,6 @@ class NavigationSession : Session(), NavigationListener, NavigationObserverCallb
*/
private val lifecycleObserver: LifecycleObserver = object : DefaultLifecycleObserver {
override fun onPause(owner: LifecycleOwner) {
Log.d(TAG, "NavigationSession paused")
super.onPause(owner)
}
override fun onResume(owner: LifecycleOwner) {
Log.d(TAG, "NavigationSession resumed")
super.onResume(owner)
}
override fun onDestroy(owner: LifecycleOwner) {
if (::navigationManager.isInitialized) {
navigationManager.clearNavigationManagerCallback()
@@ -151,13 +138,6 @@ class NavigationSession : Session(), NavigationListener, NavigationObserverCallb
if (::textToSpeechManager.isInitialized) {
textToSpeechManager.cleanup()
}
carContext
.stopService(
Intent(
carContext,
NavigationNotificationService::class.java
)
)
Log.i(TAG, "NavigationSession destroyed")
}
}
@@ -175,23 +155,12 @@ class NavigationSession : Session(), NavigationListener, NavigationObserverCallb
var lastTrafficDate: LocalDateTime = LocalDateTime.MIN
lateinit var observerManager: NavigationObserverManager
val repository = getSettingsRepository(carContext)
lateinit var repository: SettingsRepository
val settingsViewModel = getSettingsViewModel(carContext)
lateinit var settingsViewModel: SettingsViewModel
init {
lifecycle.addObserver(lifecycleObserver)
repository.routingEngineFlow.asLiveData().observe(this, Observer {
routingEngine = it
})
repository.trafficFlow.asLiveData().observe(this, Observer {
showTraffic = it
})
repository.distanceModeFlow.asLiveData().observe(this, Observer {
distanceMode = it
})
}
/**
@@ -199,13 +168,15 @@ class NavigationSession : Session(), NavigationListener, NavigationObserverCallb
* Creates appropriate repository based on user selection.
*/
fun onRoutingEngineStateUpdated(routeEngine: Int) {
navigationViewModel = when (routeEngine) {
RouteEngine.VALHALLA.ordinal -> NavigationViewModel(ValhallaRepository())
RouteEngine.OSRM.ordinal -> NavigationViewModel(OsrmRepository())
else -> NavigationViewModel(TomTomRepository())
if (!::navigationViewModel.isInitialized || routeEngine != routingEngine) {
navigationViewModel = when (routeEngine) {
RouteEngine.VALHALLA.ordinal -> NavigationViewModel(ValhallaRepository())
RouteEngine.OSRM.ordinal -> NavigationViewModel(OsrmRepository())
else -> NavigationViewModel(TomTomRepository())
}
observerManager = NavigationObserverManager(navigationViewModel, this)
observerManager.attachAllObservers(this)
}
observerManager = NavigationObserverManager(navigationViewModel, this)
observerManager.attachAllObservers(this)
}
/**
@@ -241,10 +212,11 @@ class NavigationSession : Session(), NavigationListener, NavigationObserverCallb
/**
* Creates the initial screen for the session.
* Sets up ViewModel store, initializes components, checks permissions,
* Sets up ViewModel store, initializes settings, components, checks permissions,
* and returns appropriate starting screen.
*/
override fun onCreateScreen(intent: Intent): Screen {
initializeSettings()
setupViewModelStore()
initializeViewModels()
initializeManagers()
@@ -252,6 +224,26 @@ class NavigationSession : Session(), NavigationListener, NavigationObserverCallb
return checkPermissionsAndGetScreen()
}
/*
* Initializes the settings repository and ViewModel.
*/
private fun initializeSettings() {
repository = getSettingsRepository(carContext)
settingsViewModel = getSettingsViewModel(carContext)
repository.routingEngineFlow.asLiveData().observe(this, Observer {
onRoutingEngineStateUpdated(it)
routingEngine = it
})
repository.trafficFlow.asLiveData().observe(this, Observer {
showTraffic = it
})
repository.distanceModeFlow.asLiveData().observe(this, Observer {
distanceMode = it
})
}
/**
* Sets up ViewModelStoreOwner and manages its lifecycle.
*/
@@ -280,6 +272,10 @@ class NavigationSession : Session(), NavigationListener, NavigationObserverCallb
routeModel = RouteCarModel()
CarConnection(carContext).type.observe(this, ::onConnectionStateUpdated)
navigationViewModel.initialSnapLocation.observe(this, Observer {
surfaceRenderer.updateLocation(it, "")
})
}
/**
@@ -306,7 +302,7 @@ class NavigationSession : Session(), NavigationListener, NavigationObserverCallb
}
}
})
surfaceRenderer = SurfaceRenderer(carContext, lifecycle, viewModelStoreOwner)
surfaceRenderer = SurfaceRenderer(carContext, lifecycle, viewModelStoreOwner, this)
carSensorManager = CarSensorManager(
carContext = carContext,
@@ -322,6 +318,7 @@ class NavigationSession : Session(), NavigationListener, NavigationObserverCallb
shouldUseCarLocationFlow = carSensorManager.shouldUseCarLocation(),
onLocationUpdate = ::updateLocation,
onInitialLocation = { location ->
navigationViewModel.loadCurrentLocation(location)
navigationViewModel.loadRecentPlaces(
carContext,
location,
@@ -330,13 +327,10 @@ class NavigationSession : Session(), NavigationListener, NavigationObserverCallb
}
)
textToSpeechManager = TextToSpeechManager(carContext)
val repository = getSettingsRepository(carContext)
repository.guidanceAudioFlow.asLiveData().observe(this, Observer {
guidanceAudio = it
})
notificationManager = NotificationManager(carContext, this)
}
/**
@@ -378,6 +372,10 @@ class NavigationSession : Session(), NavigationListener, NavigationObserverCallb
screenManager.push(navigationScreen)
return RequestPermissionScreen(
carContext,
listOf(
permission.ACCESS_COARSE_LOCATION,
permission.ACCESS_FINE_LOCATION,
),
permissionCheckCallback = { screenManager.pop() }
)
}
@@ -407,7 +405,7 @@ class NavigationSession : Session(), NavigationListener, NavigationObserverCallb
screenManager.popToRoot()
screenManager.pushForResult(
SearchScreen(carContext, surfaceRenderer, navigationViewModel, mutableListOf())
) { result ->
) { _ ->
// Handle search result if needed
}
}
@@ -448,6 +446,7 @@ class NavigationSession : Session(), NavigationListener, NavigationObserverCallb
checkTraffic(LocalDateTime.now(ZoneOffset.UTC), location)
surfaceRenderer.updateLocation(location, streetName)
}
updateLocationIndex++
}
/**
@@ -460,38 +459,30 @@ class NavigationSession : Session(), NavigationListener, NavigationObserverCallb
}
/**
* Handles location updates during active navigation.
* Snaps location to route and checks for deviation requiring reroute.
* Checks if the location deviation is acceptable.
*/
private fun handleNavigationLocation(location: Location) {
routeModel.updateLocation(location, navigationViewModel)
if (routeModel.navState.arrived) return
if (guidanceAudio == 1) {
handleGuidanceAudio()
private fun checkLocationDeviation(
location: Location,
snappedLocation: Location,
streetName: String
): Boolean {
var maximalRouteDeviation = MAXIMAL_ROUTE_DEVIATION
val speed = surfaceRenderer.speed.value
if (speed != null) {
when (speed) {
in 0.0..10.0 -> maximalRouteDeviation = MAXIMAL_ROUTE_DEVIATION
in 10.0..20.0 -> maximalRouteDeviation = MAXIMAL_ROUTE_DEVIATION + 200
in 20.0..30.0 -> maximalRouteDeviation = MAXIMAL_ROUTE_DEVIATION + 400
in 30.0..100.0 -> maximalRouteDeviation = MAXIMAL_ROUTE_DEVIATION + 500
}
}
val streetName = routeModel.currentStep().street
val currentDate = LocalDateTime.now(ZoneOffset.UTC)
snapLocation(location, streetName)
checkTraffic(currentDate, location)
updateSpeedCamera(location)
checkRoute(currentDate, location)
updateNavigationScreen()
checkArrival()
}
/**
* Updates the surface renderer with snapped location and street name.
* Checks if maximal route deviation is exceeded and reroutes if needed.
*/
private fun snapLocation(location: Location, streetName: String) {
val snappedLocation = snapLocation(location, routeModel.route.maneuverLocations())
val distance = location.distanceTo(snappedLocation)
Log.d(TAG, "Distance: $distance $maximalRouteDeviation $speed")
when {
distance > MAXIMAL_ROUTE_DEVIATION -> {
distance > maximalRouteDeviation -> {
stopNavigation()
navigationScreen.calculateNewRoute(routeModel.navState.destination)
navigationScreen.calculateNewRoute(routeModel.navState.destination, distance)
return false
}
distance < MAXIMAL_SNAP_CORRECTION -> {
@@ -502,6 +493,32 @@ class NavigationSession : Session(), NavigationListener, NavigationObserverCallb
surfaceRenderer.updateLocation(location, streetName)
}
}
return true
}
/**
* Handles location updates during active navigation.
* Snaps location to route and checks for deviation requiring reroute.
*/
private fun handleNavigationLocation(location: Location) {
val start = System.currentTimeMillis()
val snappedLocation = snapLocation(location, routeModel.route.maneuverLocations())
routeModel.updateLocation(snappedLocation, navigationViewModel)
val streetName = routeModel.currentStep().street
if (checkLocationDeviation(location, snappedLocation, streetName)) {
if (routeModel.navState.arrived) return
if (guidanceAudio == 1) {
handleGuidanceAudio()
}
val currentDate = LocalDateTime.now(ZoneOffset.UTC)
checkTraffic(currentDate, snappedLocation)
updateSpeedCamera(snappedLocation)
checkRoute(snappedLocation)
updateNavigationScreen()
checkArrival()
}
val end = System.currentTimeMillis()
//Log.d(TAG, "UpdateLocation ${end-start} ms")
}
/**
@@ -509,59 +526,41 @@ class NavigationSession : Session(), NavigationListener, NavigationObserverCallb
*/
fun updateNavigationScreen() {
if (routeModel.isNavigating() && routeModel.navState.destination.name.isEmpty()
&& routeModel.navState.destination.street.isEmpty()) {
&& routeModel.navState.destination.street.isEmpty()
) {
return
}
val travelEstimateTrip = routeModel.travelEstimateTrip(carContext, distanceMode)
val travelEstimateStep = routeModel.travelEstimateStep(carContext, distanceMode)
val steps = mutableListOf<Step>()
val destination = Destination.Builder()
.setName(routeModel.navState.destination.name)
.setAddress(routeModel.navState.destination.street)
.build()
val distance =
formattedDistance(0, routeModel.routeCalculator.leftStepDistance())
steps.add(routeModel.currentStep(carContext))
if (routeModel.navState.nextStep) {
steps.add(routeModel.nextStep(carContext = carContext))
}
val stepData = routeModel.currentStep()
navigationScreen.updateTrip(
isNavigating = routeModel.isNavigating(),
isRerouting = false,
hasArrived = routeModel.isArrival(),
destinationTravelEstimate = travelEstimateTrip,
stepTravelEstimate = travelEstimateStep,
destinations = mutableListOf(destination),
steps = steps,
stepRemainingDistance = Distance.create(distance.first, distance.second),
hasArrived = routeModel.isManeuverArrival(),
destinationTravelEstimate = routeModel.getTravelEstimateTrip(carContext),
stepTravelEstimate = routeModel.getTravelEstimateStep(carContext),
destinations = mutableListOf(routeModel.getDestination()),
steps = routeModel.getSteps(carContext),
stepRemainingDistance = routeModel.getDistance(),
shouldShowNextStep = false,
shouldShowLanes = true,
junctionImage = null,
backGroundColor = routeModel.backGroundColor()
backGroundColor = routeModel.backGroundColor(),
message = stepData.message,
)
/**
* Updates the trip information and notifies the listener with a new Trip object.
* This includes destination name, address, travel estimate, and loading status.
*/
val tripBuilder = Trip.Builder()
tripBuilder.addDestination(
destination,
travelEstimateTrip
)
tripBuilder.setLoading(false)
tripBuilder.setCurrentRoad(destination.name.toString())
tripBuilder.addStep(steps.first(), travelEstimateStep)
updateTrip(tripBuilder.build())
updateTrip(routeModel.getTrip(carContext))
}
/**
* Checks for arrival
*/
fun checkArrival() {
if (routeModel.isArrival()
if (routeModel.isManeuverArrival()
&& routeModel.routeCalculator.leftStepDistance() < DESTINATION_ARRIVAL_DISTANCE
) {
stopNavigation()
@@ -578,6 +577,7 @@ class NavigationSession : Session(), NavigationListener, NavigationObserverCallb
* Called when user starts navigation
*/
override fun startNavigation() {
surfaceRenderer.navigation = true
surfaceRenderer.viewStyle = ViewStyle.VIEW
navigationManager.navigationStarted()
navigationManagerStarted = true
@@ -588,8 +588,6 @@ class NavigationSession : Session(), NavigationListener, NavigationObserverCallb
updateLocation(location)
}
}
if (notificationActive)
notificationManager.startNotificationService()
}
/**
@@ -597,6 +595,7 @@ class NavigationSession : Session(), NavigationListener, NavigationObserverCallb
* Called when user exits navigation or arrives at destination.
*/
override fun stopNavigation() {
surfaceRenderer.navigation = false
routeModel.stopNavigation()
navigationManager.navigationEnded()
if (autoDriveEnabled) {
@@ -607,8 +606,7 @@ class NavigationSession : Session(), NavigationListener, NavigationObserverCallb
lastCameraSearch = 0
surfaceRenderer.viewStyle = ViewStyle.VIEW
navigationScreen.navigationType = NavigationType.VIEW
if (notificationActive)
notificationManager.stopNotificationService()
navigationScreen.invalidate()
}
override fun updateTrip(trip: Trip) {
@@ -617,6 +615,19 @@ class NavigationSession : Session(), NavigationListener, NavigationObserverCallb
}
}
/**
* Recalculates a route for the specified place.
*/
override fun recalcRoute(destination: Place) {
val destination = location(destination.longitude, destination.latitude)
navigationViewModel.loadRoute(
carContext,
surfaceRenderer.lastLocation,
listOf(destination),
surfaceRenderer.cameraPosition.value!!.bearing.toFloat()
)
}
/**
* Handle guidance audio
* Called when user wants to hear the step-by-step instructions
@@ -627,9 +638,6 @@ class NavigationSession : Session(), NavigationListener, NavigationObserverCallb
if (currentStep.index > lastStepIndex && stepData.leftStepDistance < INSTRUCTION_DISTANCE) {
textToSpeechManager.speak(stepData.message)
lastStepIndex = currentStep.index
if (notificationActive) {
notificationManager.sendMessage(stepData.message)
}
}
}
@@ -670,8 +678,10 @@ class NavigationSession : Session(), NavigationListener, NavigationObserverCallb
val newRouteModel = RouteModel()
newRouteModel.navState = routeModel.navState.copy(routingEngine = routingEngine)
newRouteModel.startNavigation(route)
routeModel.curRoute.summary.trafficDelay = newRouteModel.curRoute.summary.trafficDelay
updateNavigationScreen()
if ((routeModel.curRoute.summary.trafficDelay - newRouteModel.curRoute.summary.trafficDelay).absoluteValue > 300) {
routeModel.startNavigation(route)
updateNavigationScreen()
}
}
@@ -704,8 +714,10 @@ class NavigationSession : Session(), NavigationListener, NavigationObserverCallb
cameras.forEach {
coordinates.add(listOf(it.lon, it.lat))
}
val speedData = GeoUtils.createPointCollection(coordinates, "radar")
surfaceRenderer.speedCamerasData.value = speedData
synchronized(this) {
val speedData = GeoUtils.createPointCollection(coordinates, "radar")
surfaceRenderer.speedCameraData.value = speedData
}
}
/**
@@ -719,13 +731,30 @@ class NavigationSession : Session(), NavigationListener, NavigationObserverCallb
navigationScreen.invalidate()
}
override fun onTrafficMessageReceived(trafficMessage: String) {
}
/**
* Loads a route to the specified place and sets it as the destination.
*/
override fun navigateToPlace(place: Place) {
var prevDestination = Place()
if (surfaceRenderer.navigation) {
prevDestination = routeModel.navState.destination
stopNavigation()
}
val preview = place.route
navigationViewModel.previewRoute.value = ""
val location = location(place.longitude, place.latitude)
val location = if (place.stopOver && prevDestination.latitude != 0.0) {
listOf(
location(place.longitude, place.latitude),
location(prevDestination.longitude, prevDestination.latitude)
)
} else {
listOf(location(place.longitude, place.latitude))
}
navigationViewModel.saveRecent(carContext, place)
routeModel.navState = routeModel.navState.copy(destination = place)
if (preview.isEmpty()) {
@@ -749,7 +778,11 @@ class NavigationSession : Session(), NavigationListener, NavigationObserverCallb
val duration = Duration.between(current, lastTrafficDate)
if (showTraffic && duration.abs().seconds > TRAFFIC_UPDATE) {
lastTrafficDate = current
navigationViewModel.loadTraffic(carContext, location, surfaceRenderer.carOrientation)
navigationViewModel.loadTraffic(
carContext,
location,
surfaceRenderer.carOrientation
)
}
}
@@ -757,12 +790,13 @@ class NavigationSession : Session(), NavigationListener, NavigationObserverCallb
* Periodically requests speed camera information near the current location.
*/
private fun updateSpeedCamera(location: Location) {
if (lastCameraSearch++ % 100 == 0) {
if (lastCameraSearch % 200 == 0) {
navigationViewModel.getSpeedCameras(location, 5.0)
}
if (speedCameras.isNotEmpty()) {
if (speedCameras.isNotEmpty() && lastCameraSearch % 15 == 0) {
updateDistance(location)
}
lastCameraSearch++
}
/**
@@ -771,55 +805,76 @@ class NavigationSession : Session(), NavigationListener, NavigationObserverCallb
private fun updateDistance(
location: Location,
) {
val updatedCameras = mutableListOf<Elements>()
speedCameras.forEach {
val plLocation =
location(longitude = it.lon, latitude = it.lat)
val distance = plLocation.distanceTo(location)
it.distance = distance.toDouble()
updatedCameras.add(it)
}
val sortedList = updatedCameras.sortedWith(compareBy { it.distance })
val camera = sortedList.firstOrNull() ?: return
val bearingRoute = surfaceRenderer.lastLocation.bearingTo(location)
val bearingSpeedCamera = if (camera.tags.direction != null) {
try {
camera.tags.direction!!.toFloat()
} catch (e: Exception) {
synchronized(this) {
val updatedCameras = mutableListOf<Elements>()
speedCameras.forEach {
val plLocation =
location(longitude = it.lon, latitude = it.lat)
val distance = plLocation.distanceTo(location)
it.distance = distance.toDouble()
updatedCameras.add(it)
}
val sortedList = updatedCameras.sortedWith(compareBy { it.distance })
val camera = sortedList.firstOrNull() ?: return
val bearingRoute = surfaceRenderer.lastLocation.bearingPositive(location)
val bearingSpeedCamera = try {
camera.tags.direction.toFloat()
} catch (_: Exception) {
0F
}
} else {
location.bearingTo(location(camera.lon, camera.lat)).absoluteValue
}
if (camera.distance < 80) {
if ((bearingSpeedCamera - bearingRoute.absoluteValue).absoluteValue < 15.0) {
routeModel.showSpeedCamera(carContext, camera.distance, camera.tags.maxspeed)
if (camera.distance < 80) {
if ((bearingSpeedCamera - bearingRoute).absoluteValue < 15.0) {
routeModel.showSpeedCamera(carContext, camera.distance, camera.tags.maxspeed)
val cameras = mutableListOf<Elements>()
speedCameras.forEach {
if (!(camera.lon == it.lon && camera.lat == it.lat)) {
cameras.add(it)
}
}
speedCameras = cameras
}
}
}
}
/**
* Checks if a new route is needed based on the time since the last update.
*/
private fun checkRoute(currentDate: LocalDateTime, location: Location) {
val duration = Duration.between(currentDate, lastRouteDate)
val routeUpdate = routeModel.curRoute.summary.duration / 4
if (duration.abs().seconds > routeUpdate) {
lastRouteDate = currentDate
val destination = location(
routeModel.navState.destination.longitude,
routeModel.navState.destination.latitude
)
navigationViewModel.loadRoute(
carContext,
location,
destination,
surfaceRenderer.carOrientation
)
private fun checkRoute(location: Location) {
val distance = location.distanceTo(lastRouteCheckLocation)
if (distance > checkDistance(routeModel)) {
if (lastRouteCheckLocation.latitude != 0.0) {
val destination = location(
routeModel.navState.destination.longitude,
routeModel.navState.destination.latitude
)
navigationViewModel.loadRoute(
carContext,
location,
listOf(destination),
surfaceRenderer.carOrientation
)
}
lastRouteCheckLocation = location
}
}
companion object {
private fun checkDistance(routeModel: RouteModel): Double {
val factor = when (routeModel.curRoute.summary.distance) {
in 0.0..20000.0 -> 4
in 20000.0..100000.0 -> 5
in 10000.0..200000.0 -> 6
else -> 7
}
return (routeModel.curRoute.summary.distance / factor)
}
override fun invalidateNavigationScreen() {
navigationScreen.invalidate()
}
companion object {
// URI host for deep linking
var uriHost: String = "navigation"
@@ -10,17 +10,12 @@ import androidx.car.app.AppManager
import androidx.car.app.CarContext
import androidx.car.app.SurfaceCallback
import androidx.car.app.SurfaceContainer
import androidx.compose.animation.core.LinearOutSlowInEasing
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.tween
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.remember
import androidx.compose.ui.platform.ComposeView
import androidx.lifecycle.DefaultLifecycleObserver
import androidx.lifecycle.Lifecycle
@@ -36,26 +31,25 @@ import com.kouros.navigation.car.map.getPaddingValues
import com.kouros.navigation.car.navigation.RouteCarModel
import com.kouros.navigation.data.Constants.TAG
import com.kouros.navigation.data.Constants.TILT
import com.kouros.navigation.data.Constants.homeVogelhart
import com.kouros.navigation.data.RouteEngine
import com.kouros.navigation.data.DarkMode
import com.kouros.navigation.data.ViewStyle
import com.kouros.navigation.model.BaseStyleModel
import com.kouros.navigation.utils.bearing
import com.kouros.navigation.utils.bearingPositive
import com.kouros.navigation.utils.calculateTilt
import com.kouros.navigation.utils.calculateZoom
import com.kouros.navigation.utils.duration
import com.kouros.navigation.utils.getSettingsRepository
import com.kouros.navigation.utils.location
import com.kouros.navigation.utils.previewZoom
import com.kouros.navigation.utils.settingsViewModel
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.runBlocking
import org.maplibre.compose.camera.CameraPosition
import org.maplibre.compose.camera.CameraState
import org.maplibre.compose.style.BaseStyle
import org.maplibre.spatialk.geojson.Position
import java.time.Duration
import java.time.LocalDateTime
import kotlin.math.PI
import kotlin.math.cos
import kotlin.math.pow
import kotlin.math.sin
/**
@@ -65,9 +59,9 @@ import java.time.LocalDateTime
*/
class SurfaceRenderer(
private var carContext: CarContext,
private var lifecycle: Lifecycle,
//private var routeModel: RouteCarModel,
private var viewModelStoreOwner: ViewModelStoreOwner
lifecycle: Lifecycle,
private var viewModelStoreOwner: ViewModelStoreOwner,
private var navigationSession: CarSession
) : DefaultLifecycleObserver {
// Last known location for bearing calculations
@@ -80,10 +74,6 @@ class SurfaceRenderer(
val cameraPosition = MutableLiveData(
CameraPosition(
zoom = 16.0,
target = Position(
latitude = homeVogelhart.latitude,
longitude = homeVogelhart.longitude
)
)
)
@@ -109,7 +99,7 @@ class SurfaceRenderer(
val trafficData = MutableLiveData(emptyMap<String, String>())
// Speed camera locations as GeoJSON
val speedCamerasData = MutableLiveData("")
val speedCameraData = MutableLiveData("")
// Current speed in km/h
val speed = MutableLiveData(0F)
@@ -123,6 +113,9 @@ class SurfaceRenderer(
// Current view mode (navigation, preview, etc.)
var viewStyle = ViewStyle.VIEW
// Flag to indicate if in navigation mode
var navigation = false
// Center location for route preview
lateinit var centerLocation: Location
@@ -166,15 +159,13 @@ class SurfaceRenderer(
Log.i(TAG, "Surface available $surfaceContainer")
lifecycleOwner = CustomLifecycleOwner()
lifecycleOwner.performRestore(null)
// technically, we only really need any one of these instead of all 3
// i add them to be consistent with the actual lifecycle.
lifecycleOwner.handleLifecycleEvent(Lifecycle.Event.ON_CREATE)
lifecycleOwner.handleLifecycleEvent(Lifecycle.Event.ON_START)
lifecycleOwner.handleLifecycleEvent(Lifecycle.Event.ON_RESUME)
virtualDisplay = carContext.getSystemService(DisplayManager::class.java)
.createVirtualDisplay(
"Maps",
"Navigation",
surfaceContainer.width,
surfaceContainer.height,
surfaceContainer.dpi,
@@ -189,7 +180,7 @@ class SurfaceRenderer(
this.setViewTreeLifecycleOwner(lifecycleOwner)
this.setViewTreeSavedStateRegistryOwner(lifecycleOwner)
setContent {
MapView()
MapView()
}
}
presentation = Presentation(carContext, virtualDisplay.display)
@@ -234,10 +225,44 @@ class SurfaceRenderer(
}
/**
* Called when user scrolls the map (not currently implemented).
* Called when user scrolls the map .
*/
override fun onScroll(distanceX: Float, distanceY: Float) {
synchronized(this@SurfaceRenderer) {
val currentCamera = cameraPosition.value ?: return@synchronized
val zoom = currentCamera.zoom
val bearing = currentCamera.bearing
// MapLibre typically uses 512px tiles.
// At zoom level z, the world (360 degrees) is 512 * 2^z pixels wide.
val pixelsPerDegreeLon = (512.0 * 2.0.pow(zoom)) / 360.0
// Latitude correction: In Mercator, the vertical scale is stretched by 1/cos(lat).
val latRad = lastLocation.latitude * PI / 180.0
val pixelsPerDegreeLat = pixelsPerDegreeLon / cos(latRad)
// Rotation compensation (bearing is degrees clockwise from North)
val bearingRad = bearing * PI / 180.0
val cosB = cos(bearingRad)
val sinB = sin(bearingRad)
// Rotate screen-space scroll to map-space scroll
val rotatedDx = distanceX * cosB - distanceY * sinB
val rotatedDy = distanceX * sinB + distanceY * cosB
viewStyle = ViewStyle.PAN_VIEW
// Update location based on rotated scroll distances and calculated factors
lastLocation.longitude += (rotatedDx / pixelsPerDegreeLon)
lastLocation.latitude -= (rotatedDy / pixelsPerDegreeLat)
val pos = Position(lastLocation.longitude, lastLocation.latitude)
updateCameraPosition(
bearing = bearing,
zoom = zoom,
target = pos
)
navigationSession.invalidateNavigationScreen()
}
}
@@ -245,7 +270,8 @@ class SurfaceRenderer(
* Called when user scales (zooms) the map (not currently implemented).
*/
override fun onScale(focusX: Float, focusY: Float, scaleFactor: Float) {
synchronized(this@SurfaceRenderer) {
}
}
}
@@ -254,10 +280,6 @@ class SurfaceRenderer(
speed.value = 0F
}
fun onBaseStyleStateUpdated(style: BaseStyle) {
}
/**
* Composable function that renders the map and navigation UI.
* Observes various LiveData sources and updates the map accordingly.
@@ -271,11 +293,12 @@ class SurfaceRenderer(
val position: CameraPosition? by cameraPosition.observeAsState()
val route: String? by routeData.observeAsState()
val traffic: Map<String, String>? by trafficData.observeAsState()
val speedCameras: String? by speedCamerasData.observeAsState()
val speedCamera: String? by speedCameraData.observeAsState()
val paddingValues = getPaddingValues(height, viewStyle)
val cameraState = cameraState(paddingValues, position, tilt)
val baseStyle = BaseStyleModel().readStyle(carContext, darkMode, carContext.isDarkMode)
val dark = darkMode == 1 || darkMode == 2 && carContext.isDarkMode
val dark = darkMode == DarkMode.DARK.ordinal
|| (darkMode == DarkMode.USE_CAR.ordinal && carContext.isDarkMode)
MapLibre(
cameraState,
@@ -283,7 +306,7 @@ class SurfaceRenderer(
route,
traffic,
viewStyle,
speedCameras,
speedCamera,
showBuildings
)
ShowPosition(cameraState, position, paddingValues, dark)
@@ -302,7 +325,7 @@ class SurfaceRenderer(
) {
val cameraDuration =
duration(
viewStyle == ViewStyle.PREVIEW,
viewStyle,
position!!.bearing,
lastBearing,
lastLocationUpdate
@@ -318,7 +341,8 @@ class SurfaceRenderer(
width,
height,
streetName,
darkMode
darkMode,
tilt
)
}
LaunchedEffect(position, viewStyle) {
@@ -337,7 +361,6 @@ class SurfaceRenderer(
}
override fun onCreate(owner: LifecycleOwner) {
style.observe(owner, ::onBaseStyleStateUpdated)
Log.i(TAG, "SurfaceRenderer created")
carContext.getCarService(AppManager::class.java)
.setSurfaceCallback(mSurfaceCallback)
@@ -353,13 +376,11 @@ class SurfaceRenderer(
viewStyle = ViewStyle.PAN_VIEW
}
val newZoom = if (zoomSign < 0) {
cameraPosition.value!!.zoom - 1.0
cameraPosition.value!!.zoom - 1
} else {
cameraPosition.value!!.zoom + 1.0
}
if (viewStyle == ViewStyle.VIEW) {
tilt = calculateTilt(newZoom, tilt)
cameraPosition.value!!.zoom + 1
}
tilt = calculateTilt(viewStyle, newZoom, tilt)
updateCameraPosition(
cameraPosition.value!!.bearing,
newZoom,
@@ -377,19 +398,23 @@ class SurfaceRenderer(
synchronized(this) {
street.value = streetName
if (viewStyle == ViewStyle.VIEW || viewStyle == ViewStyle.PAN_VIEW) {
val bearing = if (carOrientation == 999F) {
if (location.hasBearing()) {
//val bearing =
// location.bearing.toDouble()
//carOrientation = bearing.toFloat()
// val bearing = if (carOrientation == 999F) {
val bearing = if (location.hasBearing()) {
location.bearing.toDouble()
} else {
bearing(
bearingPositive(
lastLocation,
location,
cameraPosition.value!!.bearing
)
}
} else {
carOrientation.toDouble()
}
carOrientation = bearing.toFloat()
// } else {
// carOrientation.toDouble()
// }
val zoom = if (viewStyle == ViewStyle.VIEW) {
calculateZoom(location.speed.toDouble())
} else {
@@ -410,8 +435,11 @@ class SurfaceRenderer(
* Sets route data for active navigation and switches to VIEW mode.
*/
fun setRouteData(routeGeoJson: String) {
routeData.value = routeGeoJson
viewStyle = ViewStyle.VIEW
synchronized(this) {
routeData.value = routeGeoJson
viewStyle = ViewStyle.VIEW
updateLocation(lastLocation, "")
}
}
/**
@@ -427,7 +455,8 @@ class SurfaceRenderer(
* Updates camera position with new bearing, zoom, and target.
* Posts update to LiveData for UI observation.
*/
fun updateCameraPosition(bearing: Double = 0.0, zoom: Double, target: Position, tilt: Double) {
fun updateCameraPosition(bearing: Double = 0.0, zoom: Double = cameraPosition.value!!.zoom ,
target: Position, tilt: Double = 0.0) {
synchronized(this) {
cameraPosition.postValue(
cameraPosition.value!!.copy(
@@ -457,7 +486,7 @@ class SurfaceRenderer(
with(routeModel) {
routeData.value = curRoute.routeGeoJson
centerLocation = curRoute.centerLocation
previewDistance = curRoute.summary.distance
previewDistance = curLeg.summary.distance
}
tilt = 0.0
updateCameraPosition(
@@ -473,10 +502,12 @@ class SurfaceRenderer(
* Calculates appropriate zoom
*/
fun setStandardView() {
if (!navigation) {
setRouteData("")
}
viewStyle = ViewStyle.VIEW
setRouteData("")
val zoom = calculateZoom(0.0)
tilt = calculateTilt(zoom, tilt)
tilt = calculateTilt(viewStyle, zoom, tilt)
updateCameraPosition(
tilt = tilt,
zoom = zoom,
@@ -500,18 +531,6 @@ class SurfaceRenderer(
}
}
/**
* Updates car location from the connected car system.
* Only updates location when using OSRM routing engine.
*/
fun updateCarLocation(location: Location, streetName: String) {
val repository = getSettingsRepository(carContext)
val routingEngine = runBlocking { repository.routingEngineFlow.first() }
if (routingEngine == RouteEngine.OSRM.ordinal) {
updateLocation(location, streetName)
}
}
/**
* Updates current speed for display.
*/
@@ -49,7 +49,6 @@ class TextToSpeechManager(private val carContext: Context) {
})
}
initialized = true
Log.d("TTS", "Initialization Success")
} else {
Log.e("TTS", "Initialization Failed")
}
@@ -23,15 +23,25 @@ import androidx.compose.ui.text.drawText
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.rememberTextMeasurer
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.kouros.data.R
import com.kouros.navigation.data.Constants
import com.kouros.navigation.data.HeavyColor
import com.kouros.navigation.data.LaneColor
import com.kouros.navigation.data.NavigationCircle
import com.kouros.navigation.data.NavigationColorDark
import com.kouros.navigation.data.NavigationColorLight
import com.kouros.navigation.data.PharmacyColor
import com.kouros.navigation.data.QueuingColor
import com.kouros.navigation.data.RoadworksColor
import com.kouros.navigation.data.RouteColor
import com.kouros.navigation.data.SlowColor
import com.kouros.navigation.data.SpeedColor
import com.kouros.navigation.data.StationaryColor
import com.kouros.navigation.data.ViewStyle
import com.kouros.navigation.utils.GeoUtils.createPointCollection
import com.kouros.navigation.utils.isMetricSystem
import com.kouros.navigation.utils.location
import org.maplibre.compose.camera.CameraPosition
@@ -44,14 +54,14 @@ import org.maplibre.compose.expressions.dsl.image
import org.maplibre.compose.expressions.dsl.interpolate
import org.maplibre.compose.expressions.dsl.zoom
import org.maplibre.compose.expressions.value.ColorValue
import org.maplibre.compose.expressions.value.DpValue
import org.maplibre.compose.expressions.value.ImageValue
import org.maplibre.compose.layers.Anchor
import org.maplibre.compose.layers.FillLayer
import org.maplibre.compose.layers.LineLayer
import org.maplibre.compose.layers.SymbolLayer
import org.maplibre.compose.location.LocationPuck
import org.maplibre.compose.location.LocationPuckColors
import org.maplibre.compose.location.LocationPuckSizes
import org.maplibre.compose.location.UserLocationState
import org.maplibre.compose.map.MapOptions
import org.maplibre.compose.map.MaplibreMap
import org.maplibre.compose.map.OrnamentOptions
@@ -60,6 +70,8 @@ import org.maplibre.compose.sources.Source
import org.maplibre.compose.sources.getBaseSource
import org.maplibre.compose.sources.rememberGeoJsonSource
import org.maplibre.compose.style.BaseStyle
import org.maplibre.geojson.FeatureCollection
import org.maplibre.geojson.LineString
import org.maplibre.spatialk.geojson.Position
import kotlin.time.Duration.Companion.seconds
@@ -100,101 +112,190 @@ fun MapLibre(
OrnamentOptions(isScaleBarEnabled = false)
),
cameraState = cameraState,
baseStyle = baseStyle
) {
baseStyle = baseStyle,
) {
getBaseSource(id = "openmaptiles")?.let { tiles ->
if (!showBuildings) {
BuildingLayer(tiles)
}
if (viewStyle == ViewStyle.AMENITY_VIEW) {
val lastLocation = location(cameraState.position.target.longitude, cameraState.position.target.latitude)
val lastLocation = location(
cameraState.position.target.longitude,
cameraState.position.target.latitude
)
Puck(cameraState, lastLocation)
AmenityLayer(route)
} else {
RouteLayer(route, traffic!!)
//RouteLayerPoint(route )
TrafficLayer(traffic!!)
RouteLayer(route)
StartEndLayer(route)
// change also createLineStringCollection in GeoUtils
// Uncomment StartEndLayer
// RouteLayerPoint(route )
}
SpeedCameraLayer(speedCameras)
}
}
}
@Composable
fun RouteLayer(routeData: String?, trafficData: Map<String, String>) {
fun RouteLayer(routeData: String?) {
if (!routeData.isNullOrEmpty()) {
val routes = rememberGeoJsonSource(GeoJsonData.JsonString(routeData))
LineLayer(
id = "routes-casing",
source = routes,
color = const(Color.White),
width =
interpolate(
type = exponential(1.2f),
input = zoom(),
5 to const(0.4.dp),
6 to const(0.8.dp),
7 to const(2.0.dp),
20 to const(24.dp),
),
color = const(Color.Green),
width = routeLineWidth(base = 1.dp, isCasing = true),
)
LineLayer(
id = "routes",
source = routes,
color = const(RouteColor),
width =
interpolate(
type = exponential(1.2f),
input = zoom(),
5 to const(0.4.dp),
6 to const(0.7.dp),
7 to const(1.75.dp),
20 to const(22.dp),
),
width = routeLineWidth(base = 1.dp, isCasing = false),
)
}
trafficData.forEach {
val traffic = rememberGeoJsonSource(GeoJsonData.JsonString(it.value))
LineLayer(
id = "traffic-${it.key}-casing",
source = traffic,
color = const(Color.White),
width =
interpolate(
type = exponential(1.2f),
input = zoom(),
5 to const(0.4.dp),
6 to const(0.6.dp),
7 to const(1.8.dp),
20 to const(20.dp),
),
)
LineLayer(
id = "traffic-${it.key}",
source = traffic,
color = trafficColor(it.key),
width =
interpolate(
type = exponential(1.2f),
input = zoom(),
5 to const(0.4.dp),
6 to const(0.5.dp),
7 to const(1.6.dp),
20 to const(18.dp),
),
)
}
}
@Composable
fun TrafficLayer(trafficData: Map<String, String>) {
trafficData.forEach {
if (it.key == "closed") {
//ClosedLayer(it)
} else {
val traffic = rememberGeoJsonSource(GeoJsonData.JsonString(it.value))
LineLayer(
id = "traffic-${it.key}-casing",
source = traffic,
color = const(Color.White),
width = routeLineWidth(base = 2.dp, isCasing = true),
)
LineLayer(
id = "traffic-${it.key}",
source = traffic,
color = trafficColor(it.key),
width = routeLineWidth(base = 1.dp, isCasing = false),
)
}
// TrafficIcons(it)
}
}
@Composable
fun StartEndLayer(routeData: String?) {
if (!routeData.isNullOrEmpty()) {
val end = createPointCollection(routeData)
val routes = rememberGeoJsonSource(GeoJsonData.JsonString(end))
val img = image(painterResource(R.drawable.sports_score_48px), drawAsSdf = true)
SymbolLayer(
id = "end-layer",
source = routes,
iconColor = const(Color.Black),
iconImage = img,
iconSize =
interpolate(
type = exponential(2.0f),
input = zoom(),
5 to const(2.0f),
10 to const(2.0f),
15 to const(3.0f),
20 to const(4.0f),
),
)
}
}
@Composable
private fun ClosedLayer(entry: Map.Entry<String, String>) {
val routes = rememberGeoJsonSource(GeoJsonData.JsonString(entry.value))
val img = image(painterResource(R.drawable.do_not_disturb_on_24px), drawAsSdf = true)
SymbolLayer(
id = "closed-layer",
source = routes,
iconColor = const(Color.Red),
iconImage = img,
iconSize =
interpolate(
type = exponential(2.0f),
input = zoom(),
5 to const(2.0f),
10 to const(2.0f),
15 to const(3.0f),
20 to const(4.0f),
),
)
}
@Composable
private fun TrafficIcons(entry: Map.Entry<String, String>) {
val featureCollection = FeatureCollection.fromJson(entry.value)
if (featureCollection.features()!!.isNotEmpty()) {
val points = mutableListOf<List<Double>>()
featureCollection.features()!!.forEach { geo ->
val coordinates = (geo.geometry() as LineString).coordinates()
if (coordinates.size > 5) {
val first = coordinates.first()
val second = coordinates[1]
points.add(listOf(first.coordinates()[0], second.coordinates()[1]))
}
}
val collection = createPointCollection(
points, entry.key
)
TrafficPoint(entry.key, "${entry.key}-point-layer", collection)
}
}
@Composable
fun TrafficPoint(id: String, layer: String, routeData: String?) {
if (!routeData.isNullOrEmpty()) {
val routes = rememberGeoJsonSource(GeoJsonData.JsonString(routeData))
val img = trafficImage(id)
SymbolLayer(
id = layer,
source = routes,
iconColor = const(Color.Red),
iconImage = img,
iconSize =
interpolate(
type = exponential(2f),
input = zoom(),
5 to const(0.4f),
6 to const(1.6f),
7 to const(2.0f),
20 to const(5.0f),
),
)
}
}
/**
* Reusable helper for consistent line widths across all route layers.
*/
@Composable
private fun routeLineWidth(base: Dp, isCasing: Boolean = false): Expression<DpValue> {
val extra = if (isCasing) 1.dp else 0.dp
val width = base + extra
return interpolate(
type = exponential(2f),
input = zoom(),
1 to const(width + 3.dp),
15 to const(width + 13.dp),
18 to const(width + 15.dp),
24 to const(width + 24.dp),
)
}
@Composable
fun RouteLayerPoint(routeData: String?) {
if (!routeData.isNullOrEmpty()) {
val routes = rememberGeoJsonSource(GeoJsonData.JsonString(routeData))
val img = image(painterResource(R.drawable.ic_favorite_filled_white_24dp), drawAsSdf = true)
val img = image(painterResource(R.drawable.settings_48px), drawAsSdf = true)
SymbolLayer(
id = "point-layer",
id = "route-point-layer",
source = routes,
iconOpacity = const(2.0f),
iconColor = const(Color.Red),
@@ -203,23 +304,34 @@ fun RouteLayerPoint(routeData: String?) {
interpolate(
type = exponential(1.2f),
input = zoom(),
5 to const(0.4f),
6 to const(0.6f),
7 to const(0.8f),
20 to const(1.0f),
5 to const(0.8f),
6 to const(1.0f),
7 to const(1.2f),
20 to const(1.4f),
),
)
}
}
@Composable
fun trafficImage(key: String): Expression<ImageValue> {
when (key) {
"heavy", "queuing", "slow", "stationary" -> return image(painterResource(R.drawable.traffic_jam_24px), drawAsSdf = true)
"roadworks" -> return image(painterResource(R.drawable.construction_24px), drawAsSdf = true)
"closed", "lane" -> return image(painterResource(R.drawable.remove_road_24px), drawAsSdf = true)
}
return image(painterResource(R.drawable.traffic_jam_24px), drawAsSdf = true)
}
fun trafficColor(key: String): Expression<ColorValue> {
when (key) {
"queuing" -> return const(Color(0xFFC46E53))
"slow" -> return const(Color(0xFFC43E3E))
"stationary" -> return const(Color(0xFF910A0A))
"heavy" -> return const(Color(0xFF6B0404))
"roadworks" -> return const(Color(0xFF443506))
"queuing" -> return const(QueuingColor)
"slow" -> return const(SlowColor)
"stationary" -> return const(StationaryColor)
"heavy" -> return const(HeavyColor)
"roadworks" -> return const(RoadworksColor)
"lane" -> return const(LaneColor)
}
return const(Color.Blue)
}
@@ -230,11 +342,14 @@ fun AmenityLayer(routeData: String?) {
var color = const(Color.Red)
var img = image(painterResource(R.drawable.local_pharmacy_24px), drawAsSdf = true)
if (routeData.contains(Constants.CHARGING_STATION)) {
color = const(Color(0xFF054603))
color = const(PharmacyColor)
img = image(painterResource(R.drawable.ev_station_24px), drawAsSdf = true)
} else if (routeData.contains(Constants.FUEL_STATION)) {
color = const(Color.Blue)
img = image(painterResource(R.drawable.local_gas_station_24), drawAsSdf = true)
} else if (routeData.contains(Constants.RESTAURANT)) {
color = const(Color.Magenta)
img = image(painterResource(R.drawable.restaurant_24px), drawAsSdf = true)
}
val routes = rememberGeoJsonSource(GeoJsonData.JsonString(routeData))
SymbolLayer(
@@ -300,13 +415,14 @@ fun DrawNavigationImages(
height: Int,
streetName: String?,
darkMode: Boolean,
tilt: Double,
) {
NavigationImage(padding, width, height, streetName, darkMode)
NavigationImage(padding, width, height, streetName, darkMode, tilt)
if (speed != null) {
CurrentSpeed(width, height, speed, maxSpeed)
}
if (speed != null && maxSpeed > 0 && (speed * 3.6) > maxSpeed) {
MaxSpeed(width, height, maxSpeed)
if (speed != null && maxSpeed > 0) {
MaxSpeed(width, height, maxSpeed, speed)
}
//DebugInfo(width, height, lat!!)
}
@@ -317,19 +433,20 @@ fun NavigationImage(
width: Int,
height: Int,
streetName: String?,
darkMode: Boolean
darkMode: Boolean,
tilt: Double
) {
val imageSize = (height / 8)
val navigationColor = if (darkMode)
remember { NavigationColorDark }
else
remember { NavigationColorLight }
else
remember { NavigationColorDark }
val textMeasurerStreet = rememberTextMeasurer()
val street = streetName.toString()
val styleStreet = TextStyle(
fontSize = 16.sp,
fontSize = 18.sp,
fontWeight = FontWeight.Bold,
color = if (darkMode) Color.White else navigationColor,
)
@@ -337,13 +454,18 @@ fun NavigationImage(
textMeasurerStreet.measure(street, styleStreet, overflow = TextOverflow.Ellipsis)
}
val scaleY = if (tilt == 0.0) {
1F
} else {
0.7F
}
Box(contentAlignment = Alignment.Center, modifier = Modifier.padding(padding)) {
Canvas(
modifier = Modifier
.size(imageSize.dp, imageSize.dp)
) {
scale(scaleX = 1f, scaleY = 0.7f) {
drawCircle(navigationColor.copy(alpha = 0.3f))
scale(scaleX = 1f, scaleY = scaleY) {
drawCircle(NavigationCircle.copy(alpha = 0.4f))
}
}
Icon(
@@ -352,22 +474,22 @@ fun NavigationImage(
tint = navigationColor.copy(alpha = 0.7f),
modifier = Modifier
.size(imageSize.dp, imageSize.dp)
.scale(scaleX = 1f, scaleY = 0.7f),
.scale(scaleX = 1f, scaleY = scaleY),
)
Canvas(
modifier = Modifier
.size(textLayoutStreet.size.width.dp, textLayoutStreet.size.height.dp * 6 )
.size(textLayoutStreet.size.width.dp, textLayoutStreet.size.height.dp * 6)
) {
if (street.isNotEmpty()) {
val topLeftX = center.x - textLayoutStreet.size.width / 2
val topLeftY = center.y + textLayoutStreet.size.height
drawRoundRect(
topLeft = Offset(
x = topLeftX ,
x = topLeftX,
y = topLeftY,
),
color = if (darkMode) navigationColor else Color.White,
color = if (darkMode) NavigationColorLight else Color.White,
cornerRadius = CornerRadius(x = 10f, y = 10f),
)
drawText(
@@ -394,7 +516,7 @@ private fun CurrentSpeed(
maxSpeed: Int
) {
val radius = 34
val radius = 36
Box(
modifier = Modifier
.padding(
@@ -406,17 +528,18 @@ private fun CurrentSpeed(
val textMeasurerSpeed = rememberTextMeasurer()
val textMeasurerKm = rememberTextMeasurer()
val speed = if (isMetricSystem()) (curSpeed * 3.6).toInt().toString() else (curSpeed * 3.6 * 0.6214).toInt().toString()
val speed = if (isMetricSystem()) (curSpeed * 3.6).toInt()
.toString() else (curSpeed * 3.6 * 0.6214).toInt().toString()
val kmh = if (isMetricSystem()) "km/h" else "mph"
val styleSpeed = TextStyle(
fontSize = 22.sp,
fontSize = 24.sp,
fontWeight = FontWeight.Bold,
color = Color.White,
)
val styleKm = TextStyle(
fontSize = 12.sp,
fontSize = 14.sp,
color = Color.White,
)
val textLayoutSpeed = remember(speed, maxSpeed) {
@@ -461,6 +584,7 @@ private fun MaxSpeed(
width: Int,
height: Int,
maxSpeed: Int,
curSpeed: Float,
) {
val radius = 24
Box(
@@ -481,6 +605,11 @@ private fun MaxSpeed(
val textLayoutSpeed = remember(speed) {
textMeasurerSpeed.measure(speed, styleSpeed)
}
val signColor = if (curSpeed * 3.6 > (maxSpeed + 3)) {
Color.Red
} else {
Color.Green
}
Canvas(modifier = Modifier.fillMaxSize()) {
drawCircle(
center = Offset(
@@ -488,7 +617,7 @@ private fun MaxSpeed(
y = center.y
),
radius = radius * 1.3.toFloat(),
color = Color.Red,
color = signColor,
)
drawCircle(
center = Offset(
@@ -576,7 +705,7 @@ fun Puck(cameraState: CameraState, location: Location) {
locationState = location,
cameraState = cameraState,
accuracyThreshold = 10f,
oldLocationThreshold = 2.seconds,
oldLocationThreshold = 1.seconds,
showBearing = false,
sizes = LocationPuckSizes(dotRadius = 10.dp),
colors = LocationPuckColors(
@@ -3,6 +3,7 @@ package com.kouros.navigation.car.navigation
import android.text.SpannableString
import android.text.SpannableStringBuilder
import android.text.Spanned
import android.util.Log
import androidx.annotation.StringRes
import androidx.car.app.AppManager
import androidx.car.app.CarContext
@@ -17,17 +18,19 @@ import androidx.car.app.model.DateTimeWithZone
import androidx.car.app.model.Distance
import androidx.car.app.model.DurationSpan
import androidx.car.app.model.ForegroundCarColorSpan
import androidx.car.app.navigation.model.Destination
import androidx.car.app.navigation.model.Lane
import androidx.car.app.navigation.model.LaneDirection
import androidx.car.app.navigation.model.Maneuver
import androidx.car.app.navigation.model.Maneuver.TYPE_ROUNDABOUT_ENTER_AND_EXIT_CCW
import androidx.car.app.navigation.model.Maneuver.TYPE_ROUNDABOUT_ENTER_AND_EXIT_CW
import androidx.car.app.navigation.model.Step
import androidx.car.app.navigation.model.TravelEstimate
import androidx.car.app.navigation.model.Trip
import androidx.core.graphics.drawable.IconCompat
import com.kouros.data.R
import com.kouros.navigation.car.screen.createCarIcon
import com.kouros.navigation.data.Constants.TAG
import com.kouros.navigation.data.StepData
import com.kouros.navigation.data.route.ManeuverType
import com.kouros.navigation.model.RouteModel
import com.kouros.navigation.utils.formattedDistance
import java.time.Duration
@@ -47,17 +50,15 @@ class RouteCarModel : RouteModel() {
val maneuver = Maneuver.Builder(stepData.currentManeuverType)
.setIcon(createCarIcon(carContext, stepData.icon))
if (stepData.currentManeuverType == TYPE_ROUNDABOUT_ENTER_AND_EXIT_CW
|| stepData.currentManeuverType == TYPE_ROUNDABOUT_ENTER_AND_EXIT_CCW
if (stepData.currentManeuverType == ManeuverType.TYPE_ROUNDABOUT_ENTER_AND_EXIT_CW.ordinal
|| stepData.currentManeuverType == ManeuverType.TYPE_ROUNDABOUT_ENTER_AND_EXIT_CCW.ordinal
) {
maneuver.setRoundaboutExitNumber(stepData.exitNumber)
}
val step =
Step.Builder(currentStepCueWithImage)
if (navState.destination.street != null) {
step.setRoad(navState.destination.street!!)
}
step.setRoad(navState.destination.street)
if (stepData.lane.isNotEmpty()) {
val lanesAdded = addLanes(carContext, step, stepData)
if (lanesAdded) {
@@ -77,8 +78,8 @@ class RouteCarModel : RouteModel() {
createString(stepData.instruction)
val maneuver = Maneuver.Builder(stepData.currentManeuverType)
.setIcon(createCarIcon(carContext, stepData.icon))
if (stepData.currentManeuverType == TYPE_ROUNDABOUT_ENTER_AND_EXIT_CW
|| stepData.currentManeuverType == TYPE_ROUNDABOUT_ENTER_AND_EXIT_CCW
if (stepData.currentManeuverType == ManeuverType.TYPE_ROUNDABOUT_ENTER_AND_EXIT_CW.ordinal
|| stepData.currentManeuverType == ManeuverType.TYPE_ROUNDABOUT_ENTER_AND_EXIT_CCW.ordinal
) {
maneuver.setRoundaboutExitNumber(stepData.exitNumber)
}
@@ -101,7 +102,6 @@ class RouteCarModel : RouteModel() {
}
fun travelEstimate(carContext: CarContext, timeLeft: Double, distanceMode: Int): TravelEstimate {
val timeToDestinationMillis =
TimeUnit.SECONDS.toMillis(timeLeft.toLong())
val distance = formattedDistance(distanceMode, routeCalculator.travelLeftDistance())
@@ -126,16 +126,61 @@ class RouteCarModel : RouteModel() {
.setRemainingTimeColor(CarColor.GREEN)
.setRemainingDistanceColor(CarColor.BLUE)
if (traffic > 0) {
travelBuilder.setTripText(createDelay(traffic))
travelBuilder.setTripIcon(createCarIcon(carContext, R.drawable.traffic_jam_48px))
travelBuilder.setTripText(createDelay(traffic))
}
if (navState.travelMessage.isNotEmpty()) {
travelBuilder.setTripIcon(createCarIcon(carContext, R.drawable.warning_24px))
travelBuilder.setTripText(CarText.create(navState.travelMessage))
}
return travelBuilder.build()
}
fun getSteps(carContext: CarContext): MutableList<Step> {
val steps = mutableListOf<Step>()
steps.add(currentStep(carContext))
if (navState.nextStep) {
steps.add(nextStep(carContext = carContext))
}
return steps
}
fun getDistance(): Distance {
val distance =
formattedDistance(0, routeCalculator.leftStepDistance())
return Distance.create(distance.first, distance.second)
}
fun getTravelEstimateTrip(carContext: CarContext): TravelEstimate {
return travelEstimateTrip(carContext, 0)
}
fun getTravelEstimateStep(carContext: CarContext): TravelEstimate {
return travelEstimateStep(carContext, 0)
}
fun getDestination(): Destination {
return Destination.Builder()
.setName(navState.destination.name)
.setAddress(navState.destination.street)
.build()
}
/**
* Create a trip builder object
*/
fun getTrip(carContext: CarContext): Trip {
val tripBuilder = Trip.Builder()
tripBuilder.addDestination(
getDestination(),
getTravelEstimateTrip(carContext)
)
tripBuilder.setLoading(false)
tripBuilder.setCurrentRoad( getDestination().name.toString())
tripBuilder.addStep(getSteps(carContext).first(), getTravelEstimateStep(carContext))
return tripBuilder.build()
}
private fun createDelay(delay: Int): CarText {
val delayBuilder = SpannableStringBuilder()
delayBuilder.append(
@@ -221,7 +266,7 @@ class RouteCarModel : RouteModel() {
R.string.exit_action_title, R.string.exit_action_title,
FLAG_DEFAULT
)
return Alert.Builder( /* alertId: */0, title, /* durationMillis: */5000)
return Alert.Builder( /* alertId: */0, title, /* durationMillis: */4000)
.setSubtitle(subtitle)
.setIcon(icon)
.addAction(dismissAction).setCallback(object : AlertCallback {
@@ -6,6 +6,7 @@ import android.os.SystemClock
import androidx.lifecycle.LifecycleCoroutineScope
import com.kouros.data.BuildConfig
import com.kouros.navigation.data.tomtom.TomTomRepository
import com.kouros.navigation.utils.location
import io.ticofab.androidgpxparser.parser.GPXParser
import io.ticofab.androidgpxparser.parser.domain.Gpx
import io.ticofab.androidgpxparser.parser.domain.TrackSegment
@@ -27,8 +28,8 @@ class Simulation {
) {
if (routeModel.navState.route.isRouteValid()) {
if (BuildConfig.DEBUG) {
gpxSimulation(routeModel, lifecycleScope, updateLocation)
//currentSimulation(routeModel, lifecycleScope, updateLocation)
//gpxSimulation(routeModel, lifecycleScope, updateLocation)
currentSimulation(routeModel, lifecycleScope, updateLocation)
} else {
currentSimulation(routeModel, lifecycleScope, updateLocation)
}
@@ -45,20 +46,20 @@ class Simulation {
if (points.isEmpty()) return
simulationJob?.cancel()
var lastLocation = Location(LocationManager.FUSED_PROVIDER)
var curBearing = 0f
var curBearing: Float
simulationJob = lifecycleScope.launch {
for ((index, point) in points.withIndex()) {
if (index >= 0) {
curBearing = lastLocation.bearingTo(location(point[0], point[1]))
val fakeLocation = Location(LocationManager.FUSED_PROVIDER).apply {
latitude = point[1]
longitude = point[0]
bearing = curBearing
speedAccuracyMetersPerSecond = 1.0f // ~1 m/s
speed = 13.0f // ~50 km/h
speed = 10.0f
time = System.currentTimeMillis()
elapsedRealtimeNanos = SystemClock.elapsedRealtimeNanos()
}
curBearing = lastLocation.bearingTo(fakeLocation)
// Update your app's state as if a real GPS update occurred
updateLocation(fakeLocation)
// Wait before moving to the next point (e.g., every 1 second)
@@ -80,7 +81,7 @@ class Simulation {
runBlocking {
simulationJob = launch(Dispatchers.IO) {
route = TomTomRepository().fetchUrl(
"https://kouros-online.de/vh.gpx",
"https://kouros-online.de/VH.gpx",
false
)
}
@@ -89,7 +90,7 @@ class Simulation {
simulationJob?.cancel()
simulationJob =lifecycleScope.launch() {
var lastLocation = Location(LocationManager.FUSED_PROVIDER)
var curBearing = 0f
val curBearing = 0f
val parser = GPXParser()
val parsedGpx: Gpx? =
parser.parse(route.byteInputStream())
@@ -117,10 +118,7 @@ class Simulation {
// Update your app's state as if a real GPS update occurred
updateLocation(fakeLocation)
// Wait before moving to the next point (e.g., every 1 second)
if (duration > 100) {
// delay(duration / 4)
}
delay(500)
delay(200)
lastTime = p.time
lastLocation = fakeLocation
}
@@ -0,0 +1,481 @@
package com.kouros.navigation.car.screen
import android.util.Log
import androidx.annotation.GuardedBy
import androidx.car.app.CarContext
import androidx.car.app.Screen
import androidx.car.app.hardware.CarHardwareManager
import androidx.car.app.hardware.common.CarValue
import androidx.car.app.hardware.common.OnCarDataAvailableListener
import androidx.car.app.hardware.info.CarHardwareLocation
import androidx.car.app.hardware.info.CarSensors
import androidx.car.app.hardware.info.Compass
import androidx.car.app.hardware.info.EnergyProfile
import androidx.car.app.hardware.info.ExteriorDimensions
import androidx.car.app.hardware.info.Model
import androidx.car.app.hardware.info.Speed
import androidx.car.app.model.Action
import androidx.car.app.model.Header
import androidx.car.app.model.Pane
import androidx.car.app.model.PaneTemplate
import androidx.car.app.model.Row
import androidx.car.app.model.Template
import androidx.core.content.ContextCompat
import androidx.lifecycle.DefaultLifecycleObserver
import androidx.lifecycle.LifecycleOwner
import com.kouros.data.R
import java.util.concurrent.Executor
/**
* Creates a screen that show the static information (such as model and energy profile) available
* via CarHardware interfaces.
*/
class CarHardwareInfoScreen(carContext: CarContext) : Screen(carContext) {
// Package private for inner class reference
var mHasLocationPermission: Boolean = false
var mHasCompassPermission: Boolean = false
var mHasSpeedPermission: Boolean = false
var mHasModelPermission: Boolean = false
var mHasEnergyProfilePermission: Boolean = false
var mHasExteriorDimensionsPermission: Boolean = false
val mCarHardwareExecutor: Executor = ContextCompat.getMainExecutor(getCarContext())
@GuardedBy("this")
var mLocation: CarHardwareLocation? = null
@GuardedBy("this")
var mCompass: Compass? = null
@GuardedBy("this")
var mSpeed: Speed? = null
/**
* Value fetched from CarHardwareManager containing model information.
*
*
* It is requested asynchronously and can be `null` until the response is
* received.
*/
@GuardedBy("this")
var mModel: Model? = null
/**
* Value fetched from CarHardwareManager containing what type of fuel/ports the car has.
*
*
* It is requested asynchronously and can be `null` until the response is
* received.
*/
@GuardedBy("this")
var mEnergyProfile: EnergyProfile? = null
@GuardedBy("this")
var mExteriorDimensions: ExteriorDimensions? = null
val carLocationListener: OnCarDataAvailableListener<CarHardwareLocation?> =
OnCarDataAvailableListener { data ->
synchronized(this) {
Log.i(TAG, "Received locaction: " + data)
mLocation = data
invalidate()
}
}
val mCompassListener: OnCarDataAvailableListener<Compass?> =
OnCarDataAvailableListener { data: Compass? ->
synchronized(this) {
Log.i(TAG, "Received compass: " + data)
mCompass = data
invalidate()
}
}
var mModelListener: OnCarDataAvailableListener<Model?> =
OnCarDataAvailableListener { data: Model? ->
synchronized(this) {
Log.i(TAG, "Received model information: " + data)
mModel = data
invalidate()
}
}
var mSpeedListener: OnCarDataAvailableListener<Speed?> =
OnCarDataAvailableListener { data: Speed? ->
synchronized(this) {
Log.i(TAG, "Received speed information: " + data)
mSpeed = data
invalidate()
}
}
var mEnergyProfileListener: OnCarDataAvailableListener<EnergyProfile?> =
OnCarDataAvailableListener { data: EnergyProfile? ->
synchronized(this) {
Log.i(TAG, "Received energy profile information: " + data)
mEnergyProfile = data
invalidate()
}
}
var mExteriorDimensionsListener: OnCarDataAvailableListener<ExteriorDimensions?> =
OnCarDataAvailableListener { data: ExteriorDimensions? ->
synchronized(this) {
Log.i(TAG, "Received exterior dimensions: " + data)
mExteriorDimensions = data
invalidate()
}
}
init {
lifecycle.addObserver(object : DefaultLifecycleObserver {
override fun onCreate(owner: LifecycleOwner) {
val carHardwareManager =
getCarContext().getCarService(CarHardwareManager::class.java)
val carInfo = carHardwareManager.carInfo
val carSensors = carHardwareManager.carSensors
// Request any single shot values.
synchronized(this@CarHardwareInfoScreen) {
mLocation = null
try {
carSensors.addCarHardwareLocationListener(
CarSensors.UPDATE_RATE_NORMAL, mCarHardwareExecutor,
carLocationListener
)
mHasLocationPermission = true
} catch (_: SecurityException) {
mHasLocationPermission = false
}
mCompass = null
try {
carSensors.addCompassListener(
CarSensors.UPDATE_RATE_NORMAL, mCarHardwareExecutor,
mCompassListener
)
mHasCompassPermission = true
} catch (_: SecurityException) {
mHasCompassPermission = false
}
mSpeed = null
try {
carInfo.addSpeedListener(mCarHardwareExecutor, mSpeedListener)
mHasSpeedPermission = true
} catch (_: SecurityException) {
mHasSpeedPermission = false
}
mModel = null
try {
carInfo.fetchModel(mCarHardwareExecutor, mModelListener)
mHasModelPermission = true
} catch (_: SecurityException) {
mHasModelPermission = false
}
mEnergyProfile = null
try {
carInfo.fetchEnergyProfile(mCarHardwareExecutor, mEnergyProfileListener)
mHasEnergyProfilePermission = true
} catch (_: SecurityException) {
mHasEnergyProfilePermission = false
}
mExteriorDimensions = null
try {
carInfo.fetchExteriorDimensions(
mCarHardwareExecutor,
mExteriorDimensionsListener
)
mHasExteriorDimensionsPermission = true
} catch (_: SecurityException) {
mHasExteriorDimensionsPermission = false
}
}
}
})
}
override fun onGetTemplate(): Template {
val paneBuilder = Pane.Builder()
if (allInfoAvailable()) {
val carInfoRowBuilder = Row.Builder()
.setTitle(getCarContext().getString(R.string.car_sensors))
if (!mHasCompassPermission) {
carInfoRowBuilder.addText(getCarContext().getString(R.string.no_model_permission))
} else {
val info = StringBuilder()
synchronized(this@CarHardwareInfoScreen) {
if (mCompass != null) {
if (mCompass!!.orientations.status == CarValue.STATUS_SUCCESS) {
info.append(mCompass!!.orientations)
}
if (mCompass!!.orientations
.status == CarValue.STATUS_UNAVAILABLE
) {
info.append("Compass unavailable")
}
if (mCompass!!.orientations
.status == CarValue.STATUS_UNIMPLEMENTED
) {
info.append("Compass unimplemented")
}
}
}
carInfoRowBuilder.addText(info)
}
if (!mHasLocationPermission) {
carInfoRowBuilder.addText(carContext.getString(R.string.no_model_permission))
} else {
val info = StringBuilder()
synchronized(this@CarHardwareInfoScreen) {
if (mLocation != null) {
if (mLocation!!.location.status == CarValue.STATUS_SUCCESS) {
info.append(mLocation!!.location)
}
if (mLocation!!.location.status
== CarValue.STATUS_UNAVAILABLE
) {
info.append("Location unavailable")
}
if (mLocation!!.location
.status == CarValue.STATUS_UNIMPLEMENTED
) {
info.append("Location unimplemented")
}
}
}
carInfoRowBuilder.addText(info)
}
paneBuilder.addRow(carInfoRowBuilder.build())
val modelRowBuilder = Row.Builder()
.setTitle(getCarContext().getString(R.string.model_info))
if (!mHasModelPermission) {
modelRowBuilder.addText(getCarContext().getString(R.string.no_model_permission))
} else {
val info = StringBuilder()
synchronized(this@CarHardwareInfoScreen) {
checkNotNull(mModel)
if (mModel!!.manufacturer.status != CarValue.STATUS_SUCCESS) {
info.append(getCarContext().getString(R.string.manufacturer_unavailable))
info.append(", ")
} else {
info.append(mModel!!.manufacturer.getValue())
info.append(", ")
}
if (mModel!!.name.status != CarValue.STATUS_SUCCESS) {
info.append(getCarContext().getString(R.string.model_unavailable))
info.append(", ")
} else {
info.append(mModel!!.name.getValue())
info.append(", ")
}
if (mModel!!.year.status != CarValue.STATUS_SUCCESS) {
info.append(getCarContext().getString(R.string.year_unavailable))
} else {
info.append(mModel!!.year.getValue())
}
}
modelRowBuilder.addText(info)
}
paneBuilder.addRow(modelRowBuilder.build())
val speedRowBuilder = Row.Builder()
.setTitle(carContext.getString(R.string.speed))
if (!mHasSpeedPermission) {
speedRowBuilder.addText(carContext.getString(R.string.no_speed_permission))
} else {
if (mSpeed != null) {
val info = StringBuilder()
synchronized(this@CarHardwareInfoScreen) {
if (mSpeed!!.displaySpeedMetersPerSecond.status != CarValue.STATUS_SUCCESS) {
info.append(getCarContext().getString(R.string.manufacturer_unavailable))
info.append(", ")
} else {
info.append(mSpeed!!.displaySpeedMetersPerSecond.value)
info.append(", ")
}
}
speedRowBuilder.addText(info)
} else {
val info = StringBuilder()
info.append(carContext.getString(R.string.speed_unavailable))
speedRowBuilder.addText(info)
}
}
paneBuilder.addRow(speedRowBuilder.build())
val energyProfileRowBuilder = Row.Builder()
.setTitle(getCarContext().getString(R.string.energy_profile))
if (!mHasEnergyProfilePermission) {
energyProfileRowBuilder.addText(
getCarContext()
.getString(R.string.no_energy_profile_permission)
)
} else {
val fuelInfo = StringBuilder()
synchronized(this) {
if (mEnergyProfile!!.fuelTypes.status != CarValue.STATUS_SUCCESS) {
fuelInfo.append(getCarContext().getString(R.string.fuel_types))
fuelInfo.append(": ")
fuelInfo.append(getCarContext().getString(R.string.unavailable))
} else {
fuelInfo.append(getCarContext().getString(R.string.fuel_types))
fuelInfo.append(": ")
for (fuelType in mEnergyProfile!!.fuelTypes.getValue()!!) {
fuelInfo.append(fuelTypeAsString(fuelType))
fuelInfo.append(" ")
}
}
energyProfileRowBuilder.addText(fuelInfo)
val evInfo = StringBuilder()
if (mEnergyProfile!!.evConnectorTypes.status
!= CarValue.STATUS_SUCCESS
) {
evInfo.append(" ")
evInfo.append(getCarContext().getString(R.string.ev_connector_types))
evInfo.append(": ")
evInfo.append(getCarContext().getString(R.string.unavailable))
} else {
evInfo.append(getCarContext().getString(R.string.ev_connector_types))
evInfo.append(": ")
for (connectorType in mEnergyProfile!!.evConnectorTypes.getValue()!!) {
evInfo.append(evConnectorAsString(connectorType))
evInfo.append(" ")
}
}
energyProfileRowBuilder.addText(evInfo)
}
}
paneBuilder.addRow(energyProfileRowBuilder.build())
synchronized(this) {
paneBuilder.addRow(
buildExteriorDimensionsRow(
mExteriorDimensions,
mHasExteriorDimensionsPermission
)
)
}
} else {
paneBuilder.setLoading(true)
}
return PaneTemplate.Builder(paneBuilder.build())
.setHeader(
Header.Builder()
.setStartHeaderAction(Action.BACK)
.setTitle(getCarContext().getString(R.string.car_hardware_info))
.build()
)
.build()
}
private fun allInfoAvailable(): Boolean {
synchronized(this) {
if (mHasModelPermission && mModel == null) {
return false
}
if (mHasEnergyProfilePermission && mEnergyProfile == null) {
return false
}
if (mHasExteriorDimensionsPermission && mExteriorDimensions == null) {
return false
}
}
return true
}
companion object {
private const val TAG = "showcase"
private fun fuelTypeAsString(fuelType: Int): String {
when (fuelType) {
EnergyProfile.FUEL_TYPE_UNLEADED -> return "UNLEADED"
EnergyProfile.FUEL_TYPE_LEADED -> return "LEADED"
EnergyProfile.FUEL_TYPE_DIESEL_1 -> return "DIESEL_1"
EnergyProfile.FUEL_TYPE_DIESEL_2 -> return "DIESEL_2"
EnergyProfile.FUEL_TYPE_BIODIESEL -> return "BIODIESEL"
EnergyProfile.FUEL_TYPE_E85 -> return "E85"
EnergyProfile.FUEL_TYPE_LPG -> return "LPG"
EnergyProfile.FUEL_TYPE_CNG -> return "CNG"
EnergyProfile.FUEL_TYPE_LNG -> return "LNG"
EnergyProfile.FUEL_TYPE_ELECTRIC -> return "ELECTRIC"
EnergyProfile.FUEL_TYPE_HYDROGEN -> return "HYDROGEN"
EnergyProfile.FUEL_TYPE_OTHER -> return "OTHER"
EnergyProfile.FUEL_TYPE_UNKNOWN -> return "UNKNOWN"
else -> return "UNKNOWN"
}
}
private fun evConnectorAsString(evConnectorType: Int): String {
when (evConnectorType) {
EnergyProfile.EVCONNECTOR_TYPE_J1772 -> return "J1772"
EnergyProfile.EVCONNECTOR_TYPE_MENNEKES -> return "MENNEKES"
EnergyProfile.EVCONNECTOR_TYPE_CHADEMO -> return "CHADEMO"
EnergyProfile.EVCONNECTOR_TYPE_COMBO_1 -> return "COMBO_1"
EnergyProfile.EVCONNECTOR_TYPE_COMBO_2 -> return "COMBO_2"
EnergyProfile.EVCONNECTOR_TYPE_TESLA_ROADSTER -> return "TESLA_ROADSTER"
EnergyProfile.EVCONNECTOR_TYPE_TESLA_HPWC -> return "TESLA_HPWC"
EnergyProfile.EVCONNECTOR_TYPE_TESLA_SUPERCHARGER -> return "TESLA_SUPERCHARGER"
EnergyProfile.EVCONNECTOR_TYPE_GBT -> return "GBT"
EnergyProfile.EVCONNECTOR_TYPE_GBT_DC -> return "GBT_DC"
EnergyProfile.EVCONNECTOR_TYPE_SCAME -> return "SCAME"
EnergyProfile.EVCONNECTOR_TYPE_OTHER -> return "OTHER"
EnergyProfile.EVCONNECTOR_TYPE_UNKNOWN -> return "UNKNOWN"
else -> return "UNKNOWN"
}
}
private fun buildExteriorDimensionsRow(
exteriorDimensions: ExteriorDimensions?, hasPermissions: Boolean
): Row {
val builder = Row.Builder().setTitle("Exterior dimensions")
if (!hasPermissions) {
builder.addText("Permissions not granted. This vehicle property requires CAR_INFO")
return builder.build()
}
if (exteriorDimensions == null) {
builder.addText("Pending callback from vehicle fetch request")
return builder.build()
}
val carValue = exteriorDimensions.getExteriorDimensions()
if (carValue.getStatus() != CarValue.STATUS_SUCCESS) {
builder.addText("Fetch failed because the vehicle hasn't implemented this field")
return builder.build()
}
val dimensionsArray = carValue.getValue()
if (dimensionsArray == null || dimensionsArray.size != 8) {
builder.addText(
"Fetch succeeded, but the reply was not an int array of length 8: "
+ dimensionsArray.contentToString()
)
return builder.build()
}
builder.addText(
("Height: " + dimensionsArray[ExteriorDimensions.HEIGHT_INDEX]
+ ", Length: " + dimensionsArray[ExteriorDimensions.LENGTH_INDEX]
+ ", Width: " + dimensionsArray[ExteriorDimensions.WIDTH_INDEX]
+ ", Width + mirrors: "
+ dimensionsArray[ExteriorDimensions.WIDTH_INCLUDING_MIRRORS_INDEX])
)
builder.addText(
("Wheel base: " + dimensionsArray[ExteriorDimensions.WHEEL_BASE_INDEX]
+ ", Front width: " + dimensionsArray[ExteriorDimensions.TRACK_WIDTH_FRONT_INDEX]
+ ", Rear width: " + dimensionsArray[ExteriorDimensions.TRACK_WIDTH_REAR_INDEX]
+ ", Turning radius: "
+ dimensionsArray[ExteriorDimensions.CURB_TO_CURB_TURNING_RADIUS_INDEX])
)
return builder.build()
}
}
}
@@ -3,19 +3,23 @@ package com.kouros.navigation.car.screen
import androidx.car.app.CarContext
import androidx.car.app.Screen
import androidx.car.app.model.Action
import androidx.car.app.model.CarColor
import androidx.car.app.model.CarIcon
import androidx.car.app.model.GridItem
import androidx.car.app.model.GridTemplate
import androidx.car.app.model.Header
import androidx.car.app.model.ItemList
import androidx.car.app.model.ListTemplate
import androidx.car.app.model.Row
import androidx.car.app.model.Template
import androidx.compose.ui.graphics.Color
import androidx.core.graphics.drawable.IconCompat
import androidx.core.graphics.toColorInt
import com.kouros.data.R
import com.kouros.navigation.car.SurfaceRenderer
import com.kouros.navigation.data.Category
import com.kouros.navigation.data.Constants.CHARGING_STATION
import com.kouros.navigation.data.Constants.FUEL_STATION
import com.kouros.navigation.data.Constants.PHARMACY
import com.kouros.navigation.data.Constants.RESTAURANT
import com.kouros.navigation.data.ViewStyle
import com.kouros.navigation.model.NavigationViewModel
@@ -30,22 +34,18 @@ class CategoriesScreen(
var categories: List<Category> = listOf(
Category(id = FUEL_STATION, name = carContext.getString(R.string.fuel_station)),
Category(id = PHARMACY, name = carContext.getString(R.string.pharmacy)),
Category(id = CHARGING_STATION, name = carContext.getString(R.string.charging_station))
Category(id = CHARGING_STATION, name = carContext.getString(R.string.charging_station)),
Category(id = RESTAURANT, name = carContext.getString(R.string.restaurant))
)
init {
}
override fun onGetTemplate(): Template {
val itemListBuilder = ItemList.Builder()
.setNoItemsMessage("No categories to show")
categories.forEach {
itemListBuilder.addItem(
Row.Builder()
.setTitle(it.name)
GridItem.Builder()
.setImage(carIcon(carContext, it.id, -1))
.setTitle(it.name)
.setOnClickListener {
category = it.id
screenManager
@@ -55,7 +55,7 @@ class CategoriesScreen(
surfaceRenderer,
category,
navigationViewModel,
)
)
) { obj: Any? ->
if (obj != null) {
setResult(obj)
@@ -63,34 +63,68 @@ class CategoriesScreen(
}
}
}
.setBrowsable(true)
.build()
)
}
surfaceRenderer.viewStyle = ViewStyle.AMENITY_VIEW
val header = Header.Builder()
.setStartHeaderAction(Action.BACK)
.setTitle(carContext.getString(R.string.category_title))
.build()
return ListTemplate.Builder()
.setHeader(header)
return GridTemplate.Builder()
.setHeader(
Header.Builder()
.setStartHeaderAction(Action.BACK)
.setTitle(carContext.getString(R.string.category_title))
.build()
)
.setSingleList(itemListBuilder.build())
.build()
}
}
fun carIcon(context: CarContext, category: String, index: Int): CarIcon {
val customCarColor =
CarColor.createCustom(android.graphics.Color.MAGENTA, android.graphics.Color.MAGENTA)
if (index == -1) {
val resId = when (category) {
CHARGING_STATION -> R.drawable.ev_station_24px
FUEL_STATION -> R.drawable.local_gas_station_24
PHARMACY -> R.drawable.local_pharmacy_24px
else -> R.drawable.ic_place_white_24dp
val icon = when (category) {
CHARGING_STATION -> CarIcon.Builder(
IconCompat.createWithResource(
context,
R.drawable.ev_station_24px
)
).setTint(CarColor.GREEN)
FUEL_STATION -> CarIcon.Builder(
IconCompat.createWithResource(
context,
R.drawable.local_gas_station_24
)
).setTint(CarColor.BLUE)
PHARMACY -> CarIcon.Builder(
IconCompat.createWithResource(
context,
R.drawable.local_pharmacy_24px
)
).setTint(CarColor.RED)
RESTAURANT -> CarIcon.Builder(
IconCompat.createWithResource(
context,
R.drawable.restaurant_24px
)
).setTint(customCarColor)
else -> CarIcon.Builder(
IconCompat.createWithResource(
context,
R.drawable.traffic_jam_48px
)
).setTint(CarColor.YELLOW)
}
return CarIcon.Builder(IconCompat.createWithResource(context, resId)).build()
return icon.build()
} else {
return CarIcon.Builder(
createNumberIcon(
@@ -1,5 +1,6 @@
package com.kouros.navigation.car.screen
import android.util.Log
import androidx.annotation.DrawableRes
import androidx.car.app.CarContext
import androidx.car.app.Screen
@@ -19,6 +20,9 @@ import androidx.car.app.navigation.model.MapWithContentTemplate
import androidx.car.app.versioning.CarAppApiLevels
import androidx.lifecycle.DefaultLifecycleObserver
import androidx.lifecycle.LifecycleOwner
import androidx.lifecycle.Observer
import androidx.lifecycle.asLiveData
import androidx.lifecycle.lifecycleScope
import com.kouros.data.R
import com.kouros.navigation.car.SurfaceRenderer
import com.kouros.navigation.car.screen.observers.CategoryObserver
@@ -27,12 +31,18 @@ import com.kouros.navigation.data.Constants
import com.kouros.navigation.data.Constants.CHARGING_STATION
import com.kouros.navigation.data.Constants.FUEL_STATION
import com.kouros.navigation.data.Constants.PHARMACY
import com.kouros.navigation.data.Constants.RESTAURANT
import com.kouros.navigation.data.Constants.TAG
import com.kouros.navigation.data.Place
import com.kouros.navigation.data.overpass.Elements
import com.kouros.navigation.model.NavigationViewModel
import com.kouros.navigation.utils.GeoUtils.createPointCollection
import com.kouros.navigation.utils.getSettingsRepository
import com.kouros.navigation.utils.getSettingsViewModel
import com.kouros.navigation.utils.location
import com.kouros.navigation.utils.round
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
import kotlin.math.min
class CategoryScreen(
@@ -43,9 +53,14 @@ class CategoryScreen(
) : Screen(carContext), CategoryObserverCallback {
val repository = getSettingsRepository(carContext)
val settingsViewModel = getSettingsViewModel(carContext)
val maxListItems: Int = 30
var elements: List<Elements> = emptyList()
private val categoryObserver = CategoryObserver(this)
private var loading = true
@@ -56,8 +71,12 @@ class CategoryScreen(
navigationViewModel.elements.value = emptyList()
}
})
repository.lastFuelPricesFlow.asLiveData().observe(this, Observer {
navigationViewModel.getAmenities(carContext, category, surfaceRenderer.lastLocation, it)
})
navigationViewModel.elements.observe(this, categoryObserver)
navigationViewModel.getAmenities(category, surfaceRenderer.lastLocation)
}
override fun onGetTemplate(): Template {
@@ -74,12 +93,10 @@ class CategoryScreen(
)
)
elements.forEach {
if (it.tags.operator != null) {
if (index++ < listLimit) {
listBuilder.addItem(
createItem(it, category, index)
)
}
if (index++ < listLimit) {
listBuilder.addItem(
createItem(it, category, index)
)
}
}
}
@@ -113,18 +130,19 @@ class CategoryScreen(
CHARGING_STATION -> R.string.charging_station
FUEL_STATION -> R.string.fuel_station
PHARMACY -> R.string.pharmacy
else -> R.string.no_places
else -> R.string.restaurant
}
return carContext.getString(resId)
}
private fun createItem(it: Elements, category: String, index: Int): Row {
var name = ""
if (it.tags.name != null) {
name = it.tags.name.toString()
name = it.tags.name
if (name.isEmpty()) {
name = it.tags.operator
}
if (name.isEmpty()) {
name = it.tags.operator.toString()
name = "Empty"
}
val row = Row.Builder()
.setOnClickListener {
@@ -133,23 +151,23 @@ class CategoryScreen(
}
.setTitle(name)
.setImage(carIcon(carContext, category, index))
when (category) {
CHARGING_STATION -> row.addText("${it.tags.socketType2} X Typ 2 ${it.tags.socketType2Output}")
FUEL_STATION -> row.addText(carText("${it.e5} / ${it.e10}${it.tags.openingHours}"))
RESTAURANT, PHARMACY ->row.addText(carText(it.tags.openingHours))
}
if (it.distance < 1000) {
row.addText("${(it.distance).toInt()} m")
} else {
row.addText("${(it.distance / 1000).round(1)} km")
}
if (category == CHARGING_STATION) {
if (it.tags.socketType2 != null)
row.addText("${it.tags.socketType2} X Typ 2 ${it.tags.socketType2Output}")
} else {
row.addText(carText("${it.tags.openingHours}"))
}
row.addAction(
createAction(carContext, R.drawable.navigation_48px, FLAG_DEFAULT, {
navigationViewModel.loadRoute(
carContext,
currentLocation = surfaceRenderer.lastLocation,
location(it.lon, it.lat),
listOf(location(it.lon, it.lat)),
surfaceRenderer.carOrientation
)
setResult(
@@ -197,7 +215,7 @@ class CategoryScreen(
@DrawableRes iconRes: Int,
scale: Int
): Action {
return createAction(carContext, iconRes, FLAG_IS_PERSISTENT, {
return createAction(carContext, iconRes, FLAG_IS_PERSISTENT, {
surfaceRenderer.handleScale(scale)
})
}
@@ -16,4 +16,7 @@ interface NavigationListener {
fun updateTrip(trip: Trip)
fun navigateToPlace(place: Place)
fun recalcRoute(destination: Place)
}
@@ -37,7 +37,6 @@ import com.kouros.navigation.data.ViewStyle
import com.kouros.navigation.model.NavigationViewModel
import com.kouros.navigation.utils.getSettingsRepository
import com.kouros.navigation.utils.getSettingsViewModel
import com.kouros.navigation.utils.location
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
@@ -52,6 +51,7 @@ open class NavigationScreen(
private val navigationViewModel: NavigationViewModel
) : Screen(carContext) {
var deviation = 0F
var recentPlaces = mutableListOf<Place>()
var recentPlace: Place = Place()
@@ -79,6 +79,10 @@ open class NavigationScreen(
private lateinit var steps: MutableList<Step>
private var junctionImage: CarIcon? = null
private var backGroundColor = CarColor.BLUE
private var message = ""
private var showAlternativeRoute = false
val observerRecentPlaces = Observer<List<Place>> { newPlaces ->
recentPlaces.addAll(newPlaces)
if (newPlaces.isNotEmpty() && !tripSuggestionCalled) {
@@ -97,6 +101,9 @@ open class NavigationScreen(
navigationViewModel.recentPlaces.observe(this, observerRecentPlaces)
tripSuggestion = it
})
repository.alternativeRoutesFlow.asLiveData().observe(this, Observer {
showAlternativeRoute = it
})
lifecycle.addObserver(object : DefaultLifecycleObserver {
override fun onStop(owner: LifecycleOwner) {
arrivalTimer?.cancel()
@@ -117,26 +124,26 @@ open class NavigationScreen(
)
}, { settingsAction() })
return when (navigationType) {
NavigationType.NAVIGATION -> navigationTemplate(actionStripBuilder)
NavigationType.RECENT -> navigationRecentPlacesTemplate()
NavigationType.REROUTE -> navigationRerouteTemplate(actionStripBuilder)
NavigationType.ARRIVAL -> navigationEndTemplate(actionStripBuilder)
else -> navigationViewTemplate(actionStripBuilder)
NavigationType.NAVIGATION -> navigation(actionStripBuilder)
NavigationType.RECENT -> navigationRecentPlaces()
NavigationType.REROUTE -> navigationReroute(actionStripBuilder)
NavigationType.ARRIVAL -> navigationEnd(actionStripBuilder)
else -> navigationView(actionStripBuilder)
}
}
/**
* Creates and returns a NavigationTemplate for the active navigation state.
*/
private fun navigationTemplate(actionStripBuilder: ActionStrip.Builder): Template {
actionStripBuilder.addAction(
createAction(
carContext,
R.drawable.ic_close_white_24dp,
0,
{ stopNavigation() })
)
return NavigationTemplate.Builder()
private fun navigation(actionStripBuilder: ActionStrip.Builder): Template {
// actionStripBuilder.addAction(
// createAction(
// carContext,
// R.drawable.ic_close_white_24dp,
// 0
// ) { stopNavigation() }
// )
val navigationTemplate = NavigationTemplate.Builder()
.setNavigationInfo(
getRoutingInfo()
)
@@ -144,77 +151,77 @@ open class NavigationScreen(
.setActionStrip(actionStripBuilder.build())
.setMapActionStrip(
mapActionStrip(
carContext,
surfaceRenderer.viewStyle,
{ zoomPlus() }, { zoomMinus() }, {
createAction(
carContext = carContext, R.drawable.ic_pan_24,
0,
onClickAction = {
surfaceRenderer.viewStyle = ViewStyle.VIEW
Action.Builder()
.setIcon(createCarIcon(carContext, R.drawable.ic_recenter_24))
.setFlags(0)
.setOnClickListener {
surfaceRenderer.setStandardView()
invalidate()
}
)
.build()
})
)
.setBackgroundColor(backGroundColor)
.build()
return navigationTemplate
}
/**
* Creates and returns a template for the default view state.
*/
private fun navigationViewTemplate(actionStripBuilder: ActionStrip.Builder): Template {
private fun navigationView(actionStripBuilder: ActionStrip.Builder): Template {
val mapActionStrip = mapActionStrip(
carContext,
surfaceRenderer.viewStyle,
{ zoomPlus() }, { zoomMinus() }, {
createAction(
carContext = carContext, R.drawable.ic_recenter_24,
0,
onClickAction = {
surfaceRenderer.setStandardView()
invalidate()
})
})
return NavigationTemplate.Builder()
.setBackgroundColor(backGroundColor)
.setActionStrip(actionStripBuilder.build())
.setMapActionStrip(
mapActionStrip(
surfaceRenderer.viewStyle,
{ zoomPlus() }, { zoomMinus() }, {
createAction(
carContext = carContext, R.drawable.ic_pan_24,
0,
onClickAction = {
surfaceRenderer.viewStyle = ViewStyle.VIEW
invalidate()
})
})
)
.build()
.setMapActionStrip(mapActionStrip)
.setPanModeListener { isInPanMode: Boolean ->
}
.build()
}
/**
* Creates and returns a template for the arrival.
*/
private fun navigationEndTemplate(actionStripBuilder: ActionStrip.Builder): Template {
private fun navigationEnd(actionStripBuilder: ActionStrip.Builder): Template {
arrivalTimer?.cancel()
arrivalTimer = object : CountDownTimer(8000, 1000) {
override fun onTick(millisUntilFinished: Long) {}
override fun onFinish() {
// routeModel.navState = routeModel.navState.copy(arrived = false)
// routeModel.navState = routeModel.navState.copy(arrived = false)
navigationType = NavigationType.VIEW
invalidate()
}
}
arrivalTimer?.start()
return navigationArrivedTemplate(actionStripBuilder)
return navigationArrived(actionStripBuilder)
}
/**
* Creates and returns a NavigationTemplate specifically for when the destination is reached.
*/
fun navigationArrivedTemplate(actionStripBuilder: ActionStrip.Builder): NavigationTemplate {
var street = ""
if (destinations.first().address != null) {
street = destinations.first().address.toString()
}
fun navigationArrived(actionStripBuilder: ActionStrip.Builder): NavigationTemplate {
return NavigationTemplate.Builder()
.setNavigationInfo(
MessageInfo.Builder(
carContext.getString(R.string.arrived_exclamation_msg)
)
.setText(street)
.setText(message)
.setImage(
CarIcon.Builder(
IconCompat.createWithResource(
@@ -226,14 +233,15 @@ open class NavigationScreen(
)
.build()
)
// .setBackgroundColor(routeModel.backGroundColor())
.setBackgroundColor(backGroundColor)
.setActionStrip(actionStripBuilder.build())
.setMapActionStrip(
mapActionStrip(
carContext,
surfaceRenderer.viewStyle,
{ zoomPlus() }, { zoomMinus() }, {
createAction(
carContext = carContext, R.drawable.ic_pan_24,
carContext = carContext, R.drawable.ic_recenter_24,
0,
onClickAction = {
surfaceRenderer.viewStyle = ViewStyle.VIEW
@@ -247,10 +255,10 @@ open class NavigationScreen(
/**
* Creates and returns a template showing recent places or destinations.
*/
fun navigationRecentPlacesTemplate(): Template {
fun navigationRecentPlaces(): Template {
if (!tripSuggestion || recentPlaces.isEmpty()) {
navigationType = NavigationType.VIEW
return navigationViewTemplate(
return navigationView(
createActionStripBuilder(
{
createAction(
@@ -263,7 +271,7 @@ open class NavigationScreen(
)
}
val listBuilder = ItemList.Builder()
recentPlaces.filter { it.category == Constants.RECENT && it.distance > 300F }.forEach {
recentPlaces.filter { it.category == Constants.RECENT && it.distance > 300F }.forEach {
val row = Row.Builder()
.setTitle(it.name)
.addAction(
@@ -291,6 +299,7 @@ open class NavigationScreen(
.setContentTemplate(contentTemplate)
.setActionStrip(
mapActionStrip(
carContext,
ViewStyle.VIEW,
{ settingsAction() },
{
@@ -302,7 +311,7 @@ open class NavigationScreen(
},
{
createAction(
carContext = carContext, R.drawable.ic_zoom_out_24,
carContext = carContext, R.drawable.ic_recenter_24,
FLAG_IS_PERSISTENT,
onClickAction = {
surfaceRenderer.viewStyle = ViewStyle.VIEW
@@ -316,7 +325,7 @@ open class NavigationScreen(
/**
* Creates and returns a template for when the route is being recalculated.
*/
fun navigationRerouteTemplate(actionStripBuilder: ActionStrip.Builder): Template {
fun navigationReroute(actionStripBuilder: ActionStrip.Builder): Template {
return NavigationTemplate.Builder()
.setNavigationInfo(RoutingInfo.Builder().setLoading(true).build())
.setActionStrip(actionStripBuilder.build())
@@ -356,6 +365,7 @@ open class NavigationScreen(
surfaceRenderer,
place,
navigationViewModel,
showAlternativeRoute
)
) { obj: Any? ->
if (obj != null) {
@@ -461,36 +471,24 @@ open class NavigationScreen(
/**
* Initiates recalculation for a new route to the destination.
*/
fun calculateNewRoute(destination: Place) {
fun calculateNewRoute(destination: Place, distance: Float) {
deviation = distance
navigationType = NavigationType.REROUTE
invalidate()
val mainThreadHandler = Handler(carContext.mainLooper)
mainThreadHandler.post {
reRouteTimer?.cancel()
reRouteTimer = object : CountDownTimer(2000, 1000) {
reRouteTimer = object : CountDownTimer(3000, 1000) {
override fun onTick(millisUntilFinished: Long) {}
override fun onFinish() {
navigationType = NavigationType.NAVIGATION
reRoute(destination)
listener.recalcRoute(destination)
}
}
reRouteTimer?.start()
}
}
/**
* Re-requests a route for the specified place.
*/
fun reRoute(place: Place) {
val destination = location(place.longitude, place.latitude)
navigationViewModel.loadRoute(
carContext,
surfaceRenderer.lastLocation,
destination,
surfaceRenderer.carOrientation
)
}
/**
* Updates navigation state with the current location, checks for arrival, and traffic updates.
*/
@@ -506,7 +504,8 @@ open class NavigationScreen(
shouldShowNextStep: Boolean,
shouldShowLanes: Boolean,
junctionImage: CarIcon?,
backGroundColor: CarColor
backGroundColor: CarColor,
message: String
) {
this.isNavigating = isNavigating
this.isRerouting = isRerouting
@@ -520,7 +519,7 @@ open class NavigationScreen(
this.shouldShowLanes = shouldShowLanes
this.junctionImage = junctionImage
this.backGroundColor = backGroundColor
this.message = message
navigationType = NavigationType.NAVIGATION
invalidate()
}
@@ -5,7 +5,6 @@ import android.text.Spannable
import android.text.SpannableString
import androidx.car.app.CarContext
import androidx.car.app.CarToast
import androidx.car.app.OnScreenResultListener
import androidx.car.app.Screen
import androidx.car.app.model.Action
import androidx.car.app.model.CarIcon
@@ -28,9 +27,9 @@ import com.kouros.navigation.data.Constants.CONTACTS
import com.kouros.navigation.data.Constants.FAVORITES
import com.kouros.navigation.data.Constants.RECENT
import com.kouros.navigation.data.Place
import com.kouros.navigation.data.ViewStyle
import com.kouros.navigation.model.NavigationViewModel
import com.kouros.navigation.utils.getSettingsRepository
import com.kouros.navigation.utils.location
class PlaceListScreen(
private val carContext: CarContext,
@@ -48,14 +47,25 @@ class PlaceListScreen(
private var routingEngine = 0
private var showAlternativeRoute = false
init {
repository.routingEngineFlow.asLiveData().observe(this, Observer {
routingEngine = it
})
repository.alternativeRoutesFlow.asLiveData().observe(this, Observer {
showAlternativeRoute = it
})
lifecycle.addObserver(object : DefaultLifecycleObserver {
override fun onStop(owner: LifecycleOwner) {
navigationViewModel.recentPlaces.value = emptyList()
}
override fun onStart(owner: LifecycleOwner) {
recentPlaces.forEach {
it.distance = location(it.longitude, it.latitude).distanceTo(surfaceRenderer.lastLocation)
}
}
})
}
@@ -65,42 +75,16 @@ class PlaceListScreen(
override fun onGetTemplate(): Template {
val itemListBuilder = ItemList.Builder()
.setNoItemsMessage(carContext.getString(R.string.no_places))
recentPlaces.filter { it.category == category }.forEach {
val street = if (it.street != null) {
it.street
} else {
""
recentPlaces.filter { it.category == category && it.distance > 500F }.forEach {
val street = it.street.ifEmpty {
it.name
}
val row = Row.Builder()
.setImage(contactIcon(null, it.category))
.setTitle("$street ${it.city}")
.setOnClickListener {
place = Place(
0,
it.name,
it.category,
it.latitude,
it.longitude,
it.postalCode,
it.city,
it.street,
// avatar = null
)
screenManager
.pushForResult(
RoutePreviewScreen(
carContext,
RoutePreviewType.MULTI_ROUTE,
surfaceRenderer,
place,
navigationViewModel,
)
) { obj: Any? ->
if (obj != null) {
setResult(obj)
finish()
}
}
clickOnPlace(it)
}
if (category != CONTACTS) {
row.addText(SpannableString(" ").apply {
@@ -138,6 +122,59 @@ class PlaceListScreen(
.build()
}
/**
* Creates an Action to navigate to a specific place.
*/
private fun clickOnPlace(itPlace: Place) {
if (surfaceRenderer.navigation) {
startStopOverScreen(itPlace)
} else {
starPreviewScreen(itPlace)
}
}
/**
* Starts preview screen for a specific place.
*/
private fun starPreviewScreen(place: Place) {
screenManager
.pushForResult(
RoutePreviewScreen(
carContext,
if (showAlternativeRoute) RoutePreviewType.MULTI_ROUTE else RoutePreviewType.SINGLE_ROUTE,
surfaceRenderer,
place,
navigationViewModel,
showAlternativeRoute
)
) { obj: Any? ->
if (obj != null) {
setResult(obj)
finish()
}
}
}
/**
* Starts preview screen for a specific place.
*/
private fun startStopOverScreen(place: Place) {
screenManager
.pushForResult(
StopOverScreen(
carContext,
surfaceRenderer,
navigationViewModel,
place,
)
) { obj: Any? ->
if (obj != null) {
setResult(obj)
finish()
}
}
}
/**
* Creates an Action to delete a place.
*/
@@ -16,9 +16,8 @@ import androidx.car.app.model.Template
/** Screen for asking the user to grant location permission. */
class RequestPermissionScreen(
carContext: CarContext,
var permissionCheckCallback: PermissionCheckCallback,
//var mContactsPermissionCheckCallback: LocationPermissionCheckCallback,
val permissions: MutableList<String?> = ArrayList()
val permissions: List<String?> = ArrayList(),
var permissionCheckCallback: PermissionCheckCallback
) : Screen(carContext) {
/** Callback called when the permission is granted. */
@@ -29,7 +28,7 @@ class RequestPermissionScreen(
override fun onGetTemplate(): Template {
var message = ""
var message = "This app needs access to location"
if (permissions.contains(permission.ACCESS_FINE_LOCATION))
message = "This app needs access to location and to car speed"
if (permissions.contains("android.car.permission.CAR_SPEED"))
@@ -83,7 +82,7 @@ fun checkPermission(carContext: CarContext, permission: String) : Boolean {
screenManager.pop()
return@RequestPermissionScreen
},
permissions
permissions = permissions
)
)
} else {
@@ -13,7 +13,6 @@ import androidx.car.app.model.Action
import androidx.car.app.model.Action.FLAG_DEFAULT
import androidx.car.app.model.Action.FLAG_IS_PERSISTENT
import androidx.car.app.model.CarColor
import androidx.car.app.model.CarIcon
import androidx.car.app.model.CarText
import androidx.car.app.model.DurationSpan
import androidx.car.app.model.ForegroundCarColorSpan
@@ -26,7 +25,6 @@ import androidx.car.app.model.Template
import androidx.car.app.navigation.model.MapController
import androidx.car.app.navigation.model.MapWithContentTemplate
import androidx.car.app.versioning.CarAppApiLevels
import androidx.core.graphics.drawable.IconCompat
import androidx.lifecycle.DefaultLifecycleObserver
import androidx.lifecycle.LifecycleOwner
import androidx.lifecycle.Observer
@@ -35,6 +33,7 @@ import androidx.lifecycle.lifecycleScope
import com.kouros.data.R
import com.kouros.navigation.car.SurfaceRenderer
import com.kouros.navigation.car.navigation.RouteCarModel
import com.kouros.navigation.data.Constants.FAVORITES
import com.kouros.navigation.data.Constants.TAG
import com.kouros.navigation.data.Place
import com.kouros.navigation.data.ViewStyle
@@ -55,9 +54,10 @@ class RoutePreviewScreen(
private var surfaceRenderer: SurfaceRenderer,
private var destination: Place,
private val navigationViewModel: NavigationViewModel,
private var showAlternativeRoute: Boolean
) :
Screen(carContext) {
private var isFavorite = false
private var isFavorite = destination.favorite
val maxListItems: Int = 3
@@ -71,6 +71,9 @@ class RoutePreviewScreen(
var loading = true
var previewReady = false;
var flag = FLAG_DEFAULT
private val backPressedCallback = object : OnBackPressedCallback(false) {
override fun handleOnBackPressed() {
}
@@ -82,9 +85,15 @@ class RoutePreviewScreen(
routeModel.startNavigation(route)
surfaceRenderer.setPreviewRouteData(routeModel)
loading = false
previewReady = true
if (routeModel.route.routes.size == 1 && showAlternativeRoute) {
routeType = RoutePreviewType.SINGLE_ROUTE
showAlternativeRoute = false
}
invalidate()
}
}
val trafficObserver = Observer<Map<String, String>> { traffic ->
if (traffic.isNotEmpty()) {
navigationViewModel.traffic.value = emptyMap()
@@ -108,7 +117,10 @@ class RoutePreviewScreen(
})
repository.routingEngineFlow.asLiveData().observe(this, Observer {
routingEngine = it
})
repository.alternativeRoutesFlow.asLiveData().observe(this, Observer {
showAlternativeRoute = it
})
lifecycleScope.launch {
navigationViewModel.loadPreviewRoute(
@@ -117,7 +129,6 @@ class RoutePreviewScreen(
location(destination.longitude, destination.latitude),
surfaceRenderer.carOrientation
)
}
}
@@ -139,28 +150,23 @@ class RoutePreviewScreen(
}
}
val street = if (destination.street.isEmpty()) {
val street = destination.street.ifEmpty {
carContext.getString((R.string.route_preview))
} else {
destination.street
}
val header = Header.Builder()
.setStartHeaderAction(Action.BACK)
.setTitle(street)
if (routeType == RoutePreviewType.SINGLE_ROUTE) {
header.addEndHeaderAction(
favoriteAction()
)
header.addEndHeaderAction(
deleteFavoriteAction()
)
}
val message =
if (routeModel.isNavigating() && routeModel.curRoute.waypoints.isNotEmpty()) {
createRouteText(routeModel.route.routes.first())
} else {
CarText.Builder("Wait")
loading = true
CarText.Builder(carContext.getString(R.string.wait))
.build()
}
val content = if (routeType == RoutePreviewType.MULTI_ROUTE) {
@@ -174,18 +180,8 @@ class RoutePreviewScreen(
.build()
listContent.build()
} else {
val navigateActionIcon: CarIcon = CarIcon.Builder(
IconCompat.createWithResource(
carContext, R.drawable.navigation_48px
)
).build()
val selectRouteIcon: CarIcon = CarIcon.Builder(
IconCompat.createWithResource(
carContext, R.drawable.alt_route_48px
)
).build()
val navigateAction =
createAction(carContext, R.drawable.navigation_48px, FLAG_DEFAULT,{
createAction(carContext, R.drawable.navigation_48px, flag,{
onNavigate(routeModel.navState.currentRouteIndex)
})
val selectRouteAction = createAction(carContext, R.drawable.alt_route_48px, FLAG_IS_PERSISTENT, {
@@ -194,8 +190,13 @@ class RoutePreviewScreen(
})
val listContent = MessageTemplate.Builder(message)
.setHeader(header.build())
.addAction(navigateAction)
.addAction(selectRouteAction)
if (previewReady) {
listContent.addAction(navigateAction)
}
if (showAlternativeRoute) {
listContent.addAction(selectRouteAction)
}
if (loading) {
listContent.setLoading(true)
}
@@ -206,20 +207,22 @@ class RoutePreviewScreen(
.setContentTemplate(content)
.setMapController(
MapController.Builder().setMapActionStrip(
mapActionStrip(ViewStyle.PREVIEW, {zoomPlus()}, { zoomMinus()}, {
mapActionStrip(carContext, ViewStyle.PREVIEW, {zoomPlus()}, { zoomMinus()}, {
zoomMinus()
} )).build()
)
if (routeType == RoutePreviewType.MULTI_ROUTE && !routeSelected) {
template.setActionStrip(createActionStrip {
createAction(
carContext, R.drawable.navigation_48px,
onClickAction = {
onNavigate(routeModel.navState.currentRouteIndex)
}
)
})
if (routeModel.route.routes.size > 1 && routeType == RoutePreviewType.MULTI_ROUTE && !routeSelected) {
if (previewReady) {
template.setActionStrip(createActionStrip {
createAction(
carContext, R.drawable.navigation_48px,
onClickAction = {
onNavigate(routeModel.navState.currentRouteIndex)
}
)
})
}
}
return template.build()
}
@@ -229,6 +232,7 @@ class RoutePreviewScreen(
carContext, R.drawable.ic_zoom_in_24,
FLAG_IS_PERSISTENT,
onClickAction = {
flag = FLAG_IS_PERSISTENT
surfaceRenderer.handleScale(1)
invalidate()
}
@@ -243,6 +247,7 @@ class RoutePreviewScreen(
carContext, R.drawable.ic_zoom_out_24,
FLAG_IS_PERSISTENT,
onClickAction = {
flag = FLAG_IS_PERSISTENT
surfaceRenderer.handleScale(-1)
invalidate()
}
@@ -256,34 +261,17 @@ class RoutePreviewScreen(
else
R.drawable.ic_favorite_white_24dp
, FLAG_IS_PERSISTENT,
) {
isFavorite = !isFavorite
CarToast.makeText(
carContext,
if (isFavorite)
carContext
.getString(R.string.favorites)
else
carContext.getString(
R.string.favorites
),
CarToast.LENGTH_SHORT
)
.show()
navigationViewModel.saveFavorite(carContext, destination)
invalidate()
}
private fun deleteFavoriteAction(): Action =
createAction(carContext, R.drawable.heart_minus_48px, FLAG_IS_PERSISTENT,{
if (isFavorite) {
navigationViewModel.deleteFavorite(carContext, destination)
onClickAction = {
isFavorite = !isFavorite
destination.favorite = isFavorite
if (isFavorite) {
navigationViewModel.saveFavorite(carContext, destination)
} else {
navigationViewModel.deleteFavorite(carContext, destination)
}
invalidate()
}
isFavorite = !isFavorite
finish()
})
)
private fun createRouteText(route: Routes): CarText {
val time = route.summary.duration
@@ -301,7 +289,7 @@ class RoutePreviewScreen(
}
private fun createRow(route: Routes, index: Int): Row {
val navigateAction = createAction(carContext, R.drawable.navigation_48px ) {
val navigateAction = createAction(carContext, R.drawable.navigation_48px, flag ) {
this.onNavigate(index)
}
val routeText = createRouteText(route)
@@ -318,7 +306,9 @@ class RoutePreviewScreen(
.setTitle(routeText)
.setOnClickListener { onRouteSelected(index) }
.addText(street)
.addAction(navigateAction)
if (previewReady) {
row.addAction(navigateAction)
}
if (route.summary.trafficDelay > 60) {
row.addText(createDelay(route))
row.setImage(createCarIcon(carContext = carContext, R.drawable.traffic_jam_48px))
@@ -344,10 +334,12 @@ class RoutePreviewScreen(
}
private fun onNavigate(index: Int) {
destination.routeIndex = index
destination.route = navigationViewModel.previewRoute.value.toString()
setResult(destination)
finish()
if (previewReady) {
destination.routeIndex = index
destination.route = navigationViewModel.previewRoute.value.toString()
setResult(destination)
finish()
}
}
private fun onRouteSelected(index: Int) {
@@ -364,7 +356,6 @@ class RoutePreviewScreen(
surfaceRenderer.carOrientation
)
}
}
enum class RoutePreviewType {
@@ -12,6 +12,7 @@ import androidx.car.app.model.CarIcon
import androidx.car.app.model.Row
import androidx.core.graphics.createBitmap
import androidx.core.graphics.drawable.IconCompat
import com.kouros.data.R
import com.kouros.navigation.data.Constants.CHARGING_STATION
import com.kouros.navigation.data.Constants.FUEL_STATION
import com.kouros.navigation.data.Constants.PHARMACY
@@ -49,7 +50,7 @@ fun createNumberIcon(category: String, number: String): IconCompat {
CHARGING_STATION -> Color.GREEN
FUEL_STATION -> Color.BLUE
PHARMACY -> Color.RED
else -> Color.WHITE
else -> Color.MAGENTA
}
paint.color = color
canvas.drawCircle(size / 2f, size / 2f, size / 2f, paint)
@@ -82,20 +83,28 @@ fun createActionStripBuilder(action1: () -> Action, action2: () -> Action): Acti
* Creates an ActionStrip builder for map-related actions like zoom and pan.
*/
fun mapActionStrip(
carContext: CarContext,
viewStyle: ViewStyle,
zoomPlus: () -> Action,
zoomMinus: () -> Action,
panAction: () -> Action
recenterAction: () -> Action
): ActionStrip {
val actionStripBuilder = ActionStrip.Builder()
.addAction(zoomPlus())
.addAction(zoomMinus())
actionStripBuilder.addAction(
Action.Builder(Action.PAN)
.setIcon(createCarIcon(carContext, R.drawable.ic_pan_24))
.setFlags(0)
.build()
)
if (viewStyle == ViewStyle.PAN_VIEW) {
actionStripBuilder
.addAction(
panAction()
recenterAction()
)
}
return actionStripBuilder.build()
}
@@ -106,7 +115,7 @@ fun createAction(
carContext: CarContext,
@DrawableRes iconRes: Int,
flag: Int = FLAG_DEFAULT,
onClickAction: () -> Unit
onClickAction: () -> Unit,
): Action {
return Action.Builder()
.setIcon(createCarIcon(carContext, iconRes))
@@ -19,7 +19,6 @@ import com.kouros.navigation.data.Constants.CATEGORIES
import com.kouros.navigation.data.Constants.FAVORITES
import com.kouros.navigation.data.Constants.RECENT
import com.kouros.navigation.data.Place
import com.kouros.navigation.data.ViewStyle
import com.kouros.navigation.data.nominatim.SearchResult
import com.kouros.navigation.model.NavigationViewModel
@@ -86,22 +85,7 @@ class SearchScreen(
}
}
} else {
screenManager
.pushForResult(
PlaceListScreen(
carContext,
surfaceRenderer,
it.id,
navigationViewModel,
recentPlaces
)
) { obj: Any? ->
surfaceRenderer.setStandardView()
if (obj != null) {
setResult(obj)
finish()
}
}
startPlaceListScreen(it)
}
}
.setBrowsable(true)
@@ -131,6 +115,24 @@ class SearchScreen(
.build()
}
fun startPlaceListScreen(it: Category) {
screenManager
.pushForResult(
PlaceListScreen(
carContext,
surfaceRenderer,
it.id,
navigationViewModel,
recentPlaces
)
) { obj: Any? ->
surfaceRenderer.setStandardView()
if (obj != null) {
setResult(obj)
finish()
}
}
}
fun categoryIcon(category: String?): CarIcon {
val resId: Int = when (category) {
RECENT -> {
@@ -182,7 +184,28 @@ class SearchScreen(
distance = result.distance
)
recentPlaces.add(place)
startPreviewScreen(place)
setResult(place)
finish()
}
fun startPreviewScreen(it: Place) {
screenManager
.pushForResult(
RoutePreviewScreen(
carContext,
RoutePreviewType.SINGLE_ROUTE,
surfaceRenderer,
destination = it,
navigationViewModel,
false
)
) { obj: Any? ->
surfaceRenderer.setStandardView()
if (obj != null) {
setResult(obj)
finish()
}
}
}
}
@@ -0,0 +1,68 @@
package com.kouros.navigation.car.screen
import androidx.car.app.CarContext
import androidx.car.app.Screen
import androidx.car.app.model.Action
import androidx.car.app.model.Action.FLAG_DEFAULT
import androidx.car.app.model.Action.FLAG_IS_PERSISTENT
import androidx.car.app.model.CarText
import androidx.car.app.model.Header
import androidx.car.app.model.MessageTemplate
import androidx.car.app.model.Template
import androidx.car.app.navigation.model.MapController
import androidx.car.app.navigation.model.MapWithContentTemplate
import com.kouros.data.R
import com.kouros.navigation.car.SurfaceRenderer
import com.kouros.navigation.data.Place
import com.kouros.navigation.data.ViewStyle
import com.kouros.navigation.model.NavigationViewModel
class StopOverScreen(
private val carContext: CarContext,
private val surfaceRenderer: SurfaceRenderer,
private val navigationViewModel: NavigationViewModel,
private val place: Place,
) : Screen(carContext) {
override fun onGetTemplate(): MapWithContentTemplate {
val cancelAction =
createAction(carContext, R.drawable.ic_close_white_24dp, FLAG_IS_PERSISTENT,{
finish()
})
val header = Header.Builder()
.setStartHeaderAction(Action.BACK)
.addEndHeaderAction(cancelAction)
.setTitle(place.street)
val message = CarText.Builder("Neue Fahrt oder Zwischenstopp einfügen")
.build()
val navigateAction = Action.Builder()
.setIcon(createCarIcon(carContext, R.drawable.navigation_48px))
.setFlags(FLAG_DEFAULT)
.setOnClickListener {
setResult(place)
finish()
}
.build()
val selectRouteAction = Action.Builder()
.setIcon(createCarIcon(carContext, R.drawable.alt_route_48px))
.setFlags(FLAG_IS_PERSISTENT)
.setOnClickListener {
place.stopOver = true
setResult(place)
finish()
}
.build()
val listContent = MessageTemplate.Builder(message)
.setHeader(header.build())
.addAction(navigateAction)
.addAction(selectRouteAction)
val template = MapWithContentTemplate.Builder()
.setContentTemplate(listContent.build())
return template.build()
}
}
@@ -29,4 +29,6 @@ interface NavigationObserverCallback {
/** Called to request UI invalidation/refresh */
fun invalidateScreen()
fun onTrafficMessageReceived(trafficMessage: String)
}
@@ -14,6 +14,8 @@ class NavigationObserverManager(
val routeObserver = RouteObserver(callback)
val trafficObserver = TrafficObserver(callback)
val trafficMessageObserver = TrafficMessageObserver(callback)
val placeSearchObserver = PlaceSearchObserver(callback)
val speedCameraObserver = SpeedCameraObserver(callback)
val maxSpeedObserver = MaxSpeedObserver(callback)
@@ -21,6 +23,7 @@ class NavigationObserverManager(
fun attachAllObservers(session: NavigationSession) {
viewModel.route.observe(session, routeObserver)
viewModel.traffic.observe(session, trafficObserver)
viewModel.trafficMessage.observe(session, trafficMessageObserver)
viewModel.placeLocation.observe(session, placeSearchObserver)
viewModel.speedCameras.observe(session, speedCameraObserver)
viewModel.maxSpeed.observe(session, maxSpeedObserver)
@@ -0,0 +1,16 @@
package com.kouros.navigation.car.screen.observers
import androidx.lifecycle.Observer
/**
* Observer for traffic data updates.
*/
class TrafficMessageObserver(
private val callback: NavigationObserverCallback
) : Observer<String> {
override fun onChanged(value: String) {
callback.onTrafficMessageReceived(value)
callback.invalidateScreen()
}
}
@@ -33,6 +33,8 @@ class NavigationSettings(
private var carLocationToggleState = false
private var alternativeRoutesToggleState = false
val settingsViewModel = getSettingsViewModel(carContext)
init {
@@ -41,6 +43,7 @@ class NavigationSettings(
settingsViewModel.avoidMotorway.first()
settingsViewModel.avoidFerry.first()
settingsViewModel.carLocation.first()
settingsViewModel.alternativeRoutes.first()
}
}
@@ -49,6 +52,7 @@ class NavigationSettings(
tollWayToggleState = settingsViewModel.avoidTollway.value
ferryToggleState = settingsViewModel.avoidFerry.value
carLocationToggleState = settingsViewModel.carLocation.value
alternativeRoutesToggleState = settingsViewModel.alternativeRoutes.value
val listBuilder = ItemList.Builder()
@@ -89,6 +93,14 @@ class NavigationSettings(
carLocationToggleState = !carLocationToggleState
}.setChecked(carLocationToggleState).build()
// Alternative routes
val alternativeRoutesToggle: Toggle =
Toggle.Builder { checked: Boolean ->
settingsViewModel.onAlternativeRoutes(checked)
alternativeRoutesToggleState = !alternativeRoutesToggleState
}.setChecked(alternativeRoutesToggleState).build()
listBuilder.addItem(
buildRowForTemplate(
R.string.use_car_location,
@@ -97,6 +109,14 @@ class NavigationSettings(
)
)
listBuilder.addItem(
buildRowForTemplate(
R.string.alternative_routes,
alternativeRoutesToggle,
createCarIcon(carContext,R.drawable.alt_route_48px)
)
)
listBuilder.addItem(
buildRowForScreenTemplate(
RoutingSettings(carContext, navigationViewModel),
@@ -109,6 +129,7 @@ class NavigationSettings(
R.string.tomtom_api_key
)
)
return ListTemplate.Builder()
.setSingleList(listBuilder.build())
.setHeader(
@@ -1,7 +1,9 @@
package com.kouros.navigation.car.screen.settings
import androidx.annotation.OptIn
import androidx.car.app.CarContext
import androidx.car.app.Screen
import androidx.car.app.annotations.ExperimentalCarApi
import androidx.car.app.model.Action
import androidx.car.app.model.CarIcon
import androidx.car.app.model.Header
@@ -16,6 +18,7 @@ import androidx.lifecycle.Observer
import androidx.lifecycle.asLiveData
import androidx.lifecycle.lifecycleScope
import com.kouros.data.R
import com.kouros.navigation.car.screen.CarHardwareInfoScreen
import com.kouros.navigation.model.NavigationViewModel
import com.kouros.navigation.utils.getSettingsViewModel
import kotlinx.coroutines.flow.first
@@ -37,6 +40,7 @@ class SettingsScreen(
}
}
@OptIn(ExperimentalCarApi::class)
override fun onGetTemplate(): Template {
settingsViewModel.guidanceAudio.asLiveData().observe(this, Observer {
audioToggleState = settingsViewModel.guidanceAudio.value == 1
@@ -94,6 +98,13 @@ class SettingsScreen(
)
)
listBuilder.addItem(
buildRowForTemplate(
CarHardwareInfoScreen(carContext),
R.string.model_info
)
)
listBuilder.addItem(
buildRowForTemplate(
CarSettings(carContext, navigationViewModel),
@@ -2,7 +2,6 @@ package com.kouros.navigation.car.screen
import androidx.car.app.testing.ScreenController
import androidx.car.app.testing.TestCarContext
import androidx.car.app.navigation.model.NavigationTemplate
import androidx.lifecycle.MutableLiveData
import androidx.test.core.app.ApplicationProvider
import com.kouros.navigation.car.SurfaceRenderer
@@ -63,7 +62,6 @@ class NavigationScreenTest {
navigationScreen = NavigationScreen(
testCarContext,
mockSurfaceRenderer,
mockRouteModel,
mockListener,
mockViewModel
)
@@ -109,7 +107,7 @@ class NavigationScreenTest {
navigationScreen.navigationType = NavigationType.NAVIGATION
// Act
navigationScreen.calculateNewRoute(Place())
navigationScreen.calculateNewRoute(Place(), distance)
// Assert
assertThat(navigationScreen.navigationType).isEqualTo(NavigationType.REROUTE)
@@ -120,14 +118,14 @@ class NavigationScreenTest {
// Arrange
navigationScreen.navigationType = NavigationType.NAVIGATION
`when`(mockRouteModel.isArrival()).thenReturn(true)
`when`(mockRouteModel.isManeuverArrival()).thenReturn(true)
`when`(mockRouteModel.routeCalculator).thenReturn(mockRouteCalculator)
`when`(mockRouteCalculator.leftStepDistance()).thenReturn(19.0)
`when`(mockRouteCalculator.leftStepDistance()).thenReturn(9.0)
`when`(mockRouteModel.navState).thenReturn(NavigationState())
// Act
navigationScreen.checkArrival()
// Assert
assertThat(navigationScreen.navigationType).isEqualTo(NavigationType.ARRIVAL)
assertThat(navigationScreen.navigationType).isEqualTo(NavigationType.NAVIGATION)
}
}
@@ -98,6 +98,9 @@ class CategoryObserverTest {
}
private fun createElement(lon: Double, lat: Double): Elements {
return Elements(lon = lon, lat = lat, tags = Tags())
return Elements(
lon = lon, lat = lat,
tags = Tags(maxspeed = "0", direction = ""),
bounds = com.kouros.navigation.data.overpass.Bounds(0.0, 0.0, 0.0, 0.0))
}
}
@@ -123,7 +123,8 @@ class ObserversTest {
return Elements(
lon = lon,
lat = lat,
tags = Tags(maxspeed = maxSpeed, direction = null)
tags = Tags(maxspeed = maxSpeed, direction = ""),
bounds = com.kouros.navigation.data.overpass.Bounds(0.0, 0.0, 0.0, 0.0),
)
}
}
+17 -1
View File
@@ -1,3 +1,7 @@
import com.android.build.gradle.internal.tasks.AarMetadataReader.Companion.load
import java.util.Properties
import kotlin.apply
plugins {
alias(libs.plugins.android.library)
alias(libs.plugins.kotlin.compose)
@@ -8,12 +12,24 @@ plugins {
android {
namespace = "com.kouros.data"
compileSdk = 36
compileSdk = 37
val properties = Properties().apply {
val localPropertiesFile = project.rootProject.file("local.properties")
if (localPropertiesFile.exists()) {
load(localPropertiesFile.inputStream())
}
}
defaultConfig {
minSdk = 33
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
consumerProguardFiles("consumer-rules.pro")
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 {
@@ -0,0 +1,20 @@
package com.kouros.navigation.data
import com.kouros.data.BuildConfig
data class ApplicationConfig(
val user: String,
val password: String,
val tankerKoenigApiKey: String
) {
companion object {
fun load(): ApplicationConfig {
return ApplicationConfig(
user = BuildConfig.USER,
password = BuildConfig.PASSWORD,
tankerKoenigApiKey = BuildConfig.TANKER_KOENIG_API_KEY
)
}
}
}
@@ -2,14 +2,24 @@ package com.kouros.navigation.data
import androidx.compose.ui.graphics.Color
val NavigationColorLight = Color(0xFF066462)
val NavigationColorLight = Color(0xFF17A119)
val NavigationColorDark = Color(0xFF2B007A)
val NavigationColorDark = Color(0xFF10DED9)
val NavigationCircle = Color(0xFFFFEB3B)
val RouteColor = Color(0xFF7B06E1)
val RouteColor = Color(0xFF5201B4)
val SpeedColor = Color(0xFF262525)
val MaxSpeedColor = Color(0xFFB71515)
val PlaceColor = Color(0xFF868005)
val PlaceColor = Color(0xFF868005)
val QueuingColor = Color(0xFFC46E53)
val SlowColor = Color(0xFFC43E3E)
val StationaryColor = Color(0xFF910A0A)
val HeavyColor = Color(0xFF6B0404)
val RoadworksColor = Color(0xFFDAA707)
val LaneColor = Color(0xFFEFBAC1)
val PharmacyColor = Color(0xFF054603)
@@ -16,16 +16,20 @@
package com.kouros.navigation.data
import android.location.Location
import android.net.Uri
import com.google.gson.annotations.Expose
import com.kouros.navigation.data.route.Lane
import com.kouros.navigation.utils.location
import kotlinx.serialization.Serializable
import kotlinx.serialization.Transient
data class Category(
val id: String,
val name: String,
)
data class StepMatch(val stepIndex: Int, val waypointIndex: Int, val location: Location)
data class Places(
val places: List<Place>,
@@ -41,11 +45,19 @@ data class Place(
var postalCode: String = "",
var city: String = "",
var street: String = "",
var navigations: Int = 0,
@Transient
var distance: Float = 0F,
//var avatar: Uri? = null,
var lastDate: Long = 0,
@Transient
var routeIndex: Int = 0,
@Transient
var route: String = "",
@Transient
var stopOver: Boolean = false,
@Transient
var favorite: Boolean = false
)
data class ContactData(
@@ -66,6 +78,8 @@ data class StepData (
var lane: List<Lane> = listOf(Lane(location(0.0, 0.0), valid = false, indications = emptyList(), 0, 0)),
var exitNumber: Int = 0,
var message: String = "",
var roadNumbers: List<String> = emptyList(),
)
@@ -112,33 +126,51 @@ object Constants {
const val CHARGING_STATION: String ="charging_station"
const val RESTAURANT: String ="restaurant"
val categories = listOf("Tankstelle", "Apotheke", "Ladestationen")
/** The initial location to use as an anchor for searches. */
val widenmayer = location( 11.595721, 48.146113)
val homeVogelhart = location(11.5793748, 48.185749)
val homeHohenwaldeck = location( 11.594322, 48.1164817)
val ioannina = location( 20.826237, 39.690174)
val subislawa = location(18.570808, 54.420647)
val a94 = location(11.947101,48.213251)
val a9 = location(11.621556, 48.204402,)
val a22 = location(10.845749, 45.602507)
val isarring = location( 11.609696, 48.155116)
const val NEXT_STEP_THRESHOLD = 500.0
const val MAXIMAL_SNAP_CORRECTION = 50.0
const val MAXIMAL_ROUTE_DEVIATION = 80.0
const val MAXIMAL_ROUTE_DEVIATION = 100.0
const val DESTINATION_ARRIVAL_DISTANCE = 20.0
const val DESTINATION_ARRIVAL_DISTANCE = 10.0
const val NEAREST_LOCATION_DISTANCE = 10F
const val MAXIMUM_LOCATION_DISTANCE = 100000F
const val MAXIMUM_LOCATION_DISTANCE = Float.MAX_VALUE
const val TRAFFIC_UPDATE = 300
const val TRAFFIC_MESSAGE_UPDATE = 10
const val SPEED_UPDATE_DISTANCE = 600F
const val INSTRUCTION_DISTANCE = 50
const val SPEED_BEARING_DEVIATION = 60
const val GMS_CAR_SPEED_PERMISSION = "com.google.android.gms.permission.CAR_SPEED"
const val AUTOMOTIVE_CAR_SPEED_PERMISSION = "android.car.permission.CAR_SPEED"
const val TILT = 60.0
const val TANKER_KOENIG_DELAY = 300000
const val LAST_CHECK_ROUTE_DISTANCE = 5000
}
/**
@@ -157,6 +189,10 @@ enum class RouteEngine {
VALHALLA, OSRM, TOMTOM
}
enum class DarkMode {
LIGHT, DARK, USE_CAR
}
enum class EngineType {
COMBUSTION, ELECTRIC
}
@@ -12,6 +12,7 @@ import java.net.URL
abstract class NavigationRepository {
private val config by lazy { ApplicationConfig.load() }
private val nominatimUrl = "https://nominatim.openstreetmap.org/"
//private val nominatimUrl = "https://kouros-online.de/nominatim/"
@@ -20,7 +21,7 @@ abstract class NavigationRepository {
abstract fun getRoute(
context: Context,
currentLocation: Location,
destination: Location,
location: List<Location>,
carOrientation: Float,
searchFilter: SearchFilter
): String
@@ -61,11 +62,15 @@ abstract class NavigationRepository {
try {
if (authenticator) {
Authenticator.setDefault(object : Authenticator() {
override fun getPasswordAuthentication(): PasswordAuthentication {
return PasswordAuthentication(
"kouros",
"eo7sbjyWpmjSVFyELgbfrryqJ6ddNeq9".toCharArray()
)
override fun getPasswordAuthentication(): PasswordAuthentication? {
return if (config.user.isEmpty() || config.password.isEmpty()) {
null
} else {
PasswordAuthentication(
config.user,
config.password.toCharArray()
)
}
}
})
}
@@ -75,7 +80,7 @@ abstract class NavigationRepository {
"Accept",
"application/json"
) // The format of response we want to get from the server
httpURLConnection.setRequestProperty("User-Agent", "email=nominatim@kouros-online.de")
httpURLConnection.setRequestProperty("User-Agent", "email=online@kouros-online.de")
httpURLConnection.requestMethod = "GET"
val responseCode = httpURLConnection.responseCode
if (responseCode == HttpURLConnection.HTTP_OK) {
@@ -13,6 +13,7 @@ import com.kouros.navigation.data.tomtom.TomTomRoute
import com.kouros.navigation.data.valhalla.ValhallaResponse
import com.kouros.navigation.data.valhalla.ValhallaRoute
import com.kouros.navigation.utils.location
import kotlinx.coroutines.selects.whileSelect
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonElement
import kotlinx.serialization.json.jsonObject
@@ -102,16 +103,22 @@ data class Route(
return if (isRouteValid()) {
legs().first().steps[currentStepIndex]
} else {
Step(maneuver = Maneuver(waypoints = emptyList(), location = location(0.0, 0.0)))
Step(maneuver = Maneuver(waypoints = emptyList(), location = location(0.0, 0.0), leftDistance = emptyList()))
}
}
/**
* Maneuver locations to snap location
*/
fun maneuverLocations(): List<Point> {
val wayPointIndex = currentStep().waypointIndex
val waypoints = currentStep().maneuver.waypoints
val points = mutableListOf<Point>()
for (loc in waypoints) {
val point = Point.fromLngLat(loc[0], loc[1])
points.add(point)
for ((index,loc) in waypoints.withIndex()) {
if (index >= wayPointIndex && points.size < 20) {
val point = Point.fromLngLat(loc.longitude, loc.latitude)
points.add(point)
}
}
return points
}
@@ -6,6 +6,7 @@ import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.booleanPreferencesKey
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.intPreferencesKey
import androidx.datastore.preferences.core.longPreferencesKey
import androidx.datastore.preferences.core.stringPreferencesKey
import androidx.datastore.preferences.preferencesDataStore
import com.kouros.navigation.data.EngineType
@@ -16,17 +17,16 @@ import kotlinx.coroutines.flow.map
private const val DATASTORE_NAME = "navigation_settings"
val Context.dataStore: DataStore<Preferences> by preferencesDataStore(name = DATASTORE_NAME)
/**
* Central manager for app settings using DataStore
*/
class DataStoreManager(private val context: Context) {
companion object {
val Context.dataStore: DataStore<Preferences> by preferencesDataStore(name = DATASTORE_NAME)
}
// Keys
object PreferencesKeys {
companion object PreferencesKeys {
val SHOW_3D = booleanPreferencesKey("Show3D")
@@ -58,181 +58,223 @@ class DataStoreManager(private val context: Context) {
val ENGINE_TYPE = intPreferencesKey("EngineType")
val ALTERNATIVE_ROUTES = booleanPreferencesKey("AlternativeRoutes")
val LAST_FUEL_PRICES = longPreferencesKey("LastFuelPrices")
val FUEL_PRICES = stringPreferencesKey("FuelPrices")
}
// Read values
val show3DFlow: Flow<Boolean> =
context.dataStore.data.map { preferences ->
preferences[PreferencesKeys.SHOW_3D] == true
preferences[SHOW_3D] == true
}
val darkModeFlow: Flow<Int> =
context.dataStore.data.map { preferences ->
preferences[PreferencesKeys.DARK_MODE]
preferences[DARK_MODE]
?: 0
}
val avoidMotorwayFlow: Flow<Boolean> =
context.dataStore.data.map { preferences ->
preferences[PreferencesKeys.AVOID_MOTORWAY] == true
preferences[AVOID_MOTORWAY] == true
}
val avoidTollwayFlow: Flow<Boolean> =
context.dataStore.data.map { preferences ->
preferences[PreferencesKeys.AVOID_TOLLWAY] == true
preferences[AVOID_TOLLWAY] == true
}
val avoidFerryFlow: Flow<Boolean> =
context.dataStore.data.map { preferences ->
preferences[PreferencesKeys.AVOID_FERRY] == true
preferences[AVOID_FERRY] == true
}
val useCarLocationFlow: Flow<Boolean> =
context.dataStore.data.map { preferences ->
preferences[PreferencesKeys.CAR_LOCATION] == true
preferences[CAR_LOCATION] == true
}
val routingEngineFlow: Flow<Int> =
context.dataStore.data.map { preferences ->
preferences[PreferencesKeys.ROUTING_ENGINE]
preferences[ROUTING_ENGINE]
?: 2
}
val lastRouteFlow: Flow<String> =
context.dataStore.data.map { preferences ->
preferences[PreferencesKeys.LAST_ROUTE]
preferences[LAST_ROUTE]
?: ""
}
val tomTomApiKeyFlow: Flow<String> =
context.dataStore.data.map { preferences ->
preferences[PreferencesKeys.TOMTOM_APIKEY]
preferences[TOMTOM_APIKEY]
?: ""
}
val recentPlacesFlow: Flow<String> =
context.dataStore.data.map { preferences ->
preferences[PreferencesKeys.RECENT_PLACES]
preferences[RECENT_PLACES]
?: ""
}
val distanceModeFlow: Flow<Int> =
context.dataStore.data.map { preferences ->
preferences[PreferencesKeys.DISTANCE_MODE]
preferences[DISTANCE_MODE]
?: 0
}
val guidanceAudioFlow: Flow<Int> =
context.dataStore.data.map { preferences ->
preferences[PreferencesKeys.GUIDANCE_AUDIO]
preferences[GUIDANCE_AUDIO]
?: 0
}
val trafficFlow: Flow<Boolean> =
context.dataStore.data.map { preferences ->
preferences[PreferencesKeys.TRAFFIC] == true
preferences[TRAFFIC] == true
}
val tripSuggestionFlow: Flow<Boolean> =
context.dataStore.data.map { preferences ->
preferences[PreferencesKeys.TRIP_SUGGESTION] == true
preferences[TRIP_SUGGESTION] == true
}
val engineTypeFlow: Flow<Int> =
context.dataStore.data.map { preferences ->
preferences[PreferencesKeys.ENGINE_TYPE]
preferences[ENGINE_TYPE]
?: EngineType.COMBUSTION.ordinal
}
val alternativeRoutesFlow: Flow<Boolean> =
context.dataStore.data.map { preferences ->
preferences[ALTERNATIVE_ROUTES] == true
}
val lastFuelPricesFlow: Flow<Long> =
context.dataStore.data.map { preferences ->
preferences[LAST_FUEL_PRICES]
?: 0
}
val fuelPricesFlow: Flow<String> =
context.dataStore.data.map { preferences ->
preferences[FUEL_PRICES]
?: ""
}
// Save values
suspend fun setShow3D(enabled: Boolean) {
context.dataStore.edit { preferences ->
preferences[PreferencesKeys.SHOW_3D] = enabled
preferences[SHOW_3D] = enabled
}
}
suspend fun setDarkMode(mode: Int) {
context.dataStore.edit { prefs ->
prefs[PreferencesKeys.DARK_MODE] = mode
prefs[DARK_MODE] = mode
}
}
suspend fun setAvoidMotorway(enabled: Boolean) {
context.dataStore.edit { preferences ->
preferences[PreferencesKeys.AVOID_MOTORWAY] = enabled
preferences[AVOID_MOTORWAY] = enabled
}
}
suspend fun setAvoidTollway(enabled: Boolean) {
context.dataStore.edit { preferences ->
preferences[PreferencesKeys.AVOID_TOLLWAY] = enabled
preferences[AVOID_TOLLWAY] = enabled
}
}
suspend fun setAvoidFerry(enabled: Boolean) {
context.dataStore.edit { preferences ->
preferences[PreferencesKeys.AVOID_FERRY] = enabled
preferences[AVOID_FERRY] = enabled
}
}
suspend fun setCarLocation(enabled: Boolean) {
context.dataStore.edit { preferences ->
preferences[PreferencesKeys.CAR_LOCATION] = enabled
preferences[CAR_LOCATION] = enabled
}
}
suspend fun setRoutingEngine(mode: Int) {
context.dataStore.edit { prefs ->
prefs[PreferencesKeys.ROUTING_ENGINE] = mode
prefs[ROUTING_ENGINE] = mode
}
}
suspend fun setLastRoute(route: String) {
context.dataStore.edit { prefs ->
prefs[PreferencesKeys.LAST_ROUTE] = route
prefs[LAST_ROUTE] = route
}
}
suspend fun setTomtomApiKey(apiKey: String) {
context.dataStore.edit { prefs ->
prefs[PreferencesKeys.TOMTOM_APIKEY] = apiKey
prefs[TOMTOM_APIKEY] = apiKey
}
}
suspend fun setRecentPlaces(apiKey: String) {
context.dataStore.edit { prefs ->
prefs[PreferencesKeys.RECENT_PLACES] = apiKey
prefs[RECENT_PLACES] = apiKey
}
}
suspend fun setDistanceMode(mode: Int) {
context.dataStore.edit { prefs ->
prefs[PreferencesKeys.DISTANCE_MODE] = mode
prefs[DISTANCE_MODE] = mode
}
}
suspend fun setGuidanceAudio(mode: Int) {
context.dataStore.edit { prefs ->
prefs[PreferencesKeys.GUIDANCE_AUDIO] = mode
prefs[GUIDANCE_AUDIO] = mode
}
}
suspend fun setTraffic(enabled: Boolean) {
context.dataStore.edit { preferences ->
preferences[PreferencesKeys.TRAFFIC] = enabled
preferences[TRAFFIC] = enabled
}
}
suspend fun setTripSuggestion(enabled: Boolean) {
context.dataStore.edit { preferences ->
preferences[PreferencesKeys.TRIP_SUGGESTION] = enabled
preferences[TRIP_SUGGESTION] = enabled
}
}
suspend fun setEngineType(mode: Int) {
context.dataStore.edit { prefs ->
prefs[PreferencesKeys.ENGINE_TYPE] = mode
prefs[ENGINE_TYPE] = mode
}
}
suspend fun setAlternativeRoutes(enabled: Boolean) {
context.dataStore.edit { preferences ->
preferences[ALTERNATIVE_ROUTES] = enabled
}
}
suspend fun setLastFuelPrices(lastFuelPrices: Long) {
context.dataStore.edit { prefs ->
prefs[LAST_FUEL_PRICES] = lastFuelPrices
}
}
suspend fun setFuelPrices(fuelPrices: String) {
context.dataStore.edit { prefs ->
prefs[FUEL_PRICES] = fuelPrices
}
}
}
@@ -0,0 +1,9 @@
package com.kouros.navigation.data.fuel
data class FuelPrice(
val `data`: String,
val license: String,
val ok: Boolean,
val stations: List<Station>,
val status: String
)
@@ -0,0 +1,63 @@
package com.kouros.navigation.data.fuel
import android.content.Context
import android.location.Location
import android.util.Log
import com.google.gson.GsonBuilder
import com.kouros.data.BuildConfig
import com.kouros.navigation.data.NavigationRepository
import com.kouros.navigation.data.SearchFilter
import com.kouros.navigation.data.overpass.Amenity
import java.io.OutputStreamWriter
import java.net.Authenticator
import java.net.HttpURLConnection
import java.net.PasswordAuthentication
import java.net.URL
const val tankerKoenigUrl = "https://creativecommons.tankerkoenig.de/json/list.php?"
private val gson = GsonBuilder().serializeNulls().create()
const val sort = "&sort=dist&type=all"
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<Station> {
val url = if (useLocal) {
"http://192.168.1.37/fuel.json"
} else {
"${tankerKoenigUrl}lat=${location.latitude}&lng=${location.longitude}&rad=${radius}$sort&apikey=${config.tankerKoenigApiKey}"
}
val prices = fetchUrl(
url,
false
)
return gson.fromJson(prices, FuelPrice::class.java).stations
}
override fun getRoute(
context: Context,
currentLocation: Location,
location: List<Location>,
carOrientation: Float,
searchFilter: SearchFilter
): String {
return ""
}
override fun getTraffic(
context: Context,
location: Location,
carOrientation: Float
): String {
TODO("Not yet implemented")
}
}
@@ -0,0 +1,23 @@
package com.kouros.navigation.data.fuel
import com.kouros.navigation.data.Place
data class Stations(
val stations: List<Station>,
)
data class Station(
val brand: String,
val diesel: Double,
val dist: Double,
val e10: Double,
val e5: Double,
val houseNumber: String,
val id: String,
val isOpen: Boolean,
val lat: Double,
val lng: Double,
val name: String,
val place: String,
val postCode: Int,
val street: String
)
@@ -13,7 +13,7 @@ class OsrmRepository : NavigationRepository() {
override fun getRoute(
context: Context,
currentLocation: Location,
location: Location,
location: List<Location>,
carOrientation: Float,
searchFilter: SearchFilter
): String {
@@ -28,7 +28,7 @@ class OsrmRepository : NavigationRepository() {
if (searchFilter.avoidFerry) {
exclude = "$exclude&exclude=ferry"
}
val routeLocation = "${currentLocation.longitude},${currentLocation.latitude};${location.longitude},${location.latitude}?steps=true&alternatives=false"
val routeLocation = "${currentLocation.longitude},${currentLocation.latitude};${location.first().longitude},${location.first().latitude}?steps=true&alternatives=false"
return fetchUrl(routeUrl + routeLocation + exclude, true)
}
@@ -12,6 +12,7 @@ import com.kouros.navigation.utils.GeoUtils.createCenterLocation
import com.kouros.navigation.utils.GeoUtils.createLineStringCollection
import com.kouros.navigation.utils.GeoUtils.decodePolyline
import com.kouros.navigation.utils.location
import kotlin.math.absoluteValue
class OsrmRoute {
@@ -27,18 +28,33 @@ class OsrmRoute {
val steps = mutableListOf<Step>()
leg.steps.forEach { step ->
val intersections = mutableListOf<Intersection>()
val leftDistance = mutableListOf<Float>()
var lastLocation = location(0.0,0.0)
val points = decodePolyline(step.geometry, 5)
waypoints.addAll(points)
// calculate left step distance for each point
var leftStepDistance = step.distance
leftDistance.add(leftStepDistance.toFloat())
points.forEach {
if (lastLocation.latitude != 0.0) {
val curLocation = location(it[0], it[1])
val dist = curLocation.distanceTo(lastLocation).absoluteValue
leftStepDistance -= dist
leftDistance.add(leftStepDistance.toFloat())
}
lastLocation = location(it[0], it[1])
}
val maneuver = RouteManeuver(
bearingBefore = step.maneuver.bearingBefore,
bearingAfter = step.maneuver.bearingAfter,
type = convertType(step.maneuver),
waypoints = points,
waypoints = points.map { location(it[0], it[1]) },
exit = step.maneuver.exit,
location = location(
step.maneuver.location[0],
step.maneuver.location[1]
)
),
leftDistance = leftDistance
)
step.intersections.forEach { it2 ->
if (it2.location[0] != 0.0) {
@@ -69,7 +85,7 @@ class OsrmRoute {
steps.add(step)
stepIndex += 1
}
legs.add(Leg(steps))
legs.add(Leg(steps, summary))
}
val routeGeoJson = createLineStringCollection(waypoints)
val centerLocation = createCenterLocation(createLineStringCollection(waypoints))
@@ -1,13 +1,5 @@
package com.kouros.navigation.data.overpass
import com.google.gson.annotations.SerializedName
data class Amenity (
@SerializedName("version" ) var version : Double? = null,
@SerializedName("generator" ) var generator : String? = null,
@SerializedName("osm3s" ) var osm3s : Osm3s? = Osm3s(),
@SerializedName("elements" ) var elements : ArrayList<Elements> = arrayListOf()
data class Amenity(
val elements: List<Elements>,
)
@@ -0,0 +1,8 @@
package com.kouros.navigation.data.overpass
data class Bounds(
val maxlat: Double,
val maxlon: Double,
val minlat: Double,
val minlon: Double
)
@@ -0,0 +1,7 @@
package com.kouros.navigation.data.overpass
data class ElementSearch(
val element: Elements,
val distance: Double,
val bearing: Float,
)
@@ -1,15 +1,17 @@
package com.kouros.navigation.data.overpass
import com.google.gson.annotations.SerializedName
data class Elements (
@SerializedName("type" ) var type : String = "",
@SerializedName("id" ) var id : Long = 0,
@SerializedName("lat" ) var lat : Double = 0.0,
@SerializedName("lon" ) var lon : Double = 0.0,
@SerializedName("tags" ) var tags : Tags = Tags(),
var distance : Double = 0.0
data class Elements(
val bounds: Bounds,
val geometry: List<Geometry> = emptyList(),
val id: Long = 0,
val lat: Double= 0.0,
val lon: Double = 0.0,
val tags: Tags,
val type: String = "",
var distance : Double = 0.0,
var e5 : Double = 0.0,
var e10: Double = 0.0,
var diesel: Double = 0.0
)
@@ -0,0 +1,6 @@
package com.kouros.navigation.data.overpass
data class Geometry(
val lat: Double = 0.0,
val lon: Double = 0.0
)
@@ -1,40 +1,96 @@
package com.kouros.navigation.data.overpass
import android.location.Location
import android.util.Log
import com.google.gson.GsonBuilder
import com.kouros.data.BuildConfig
import com.kouros.navigation.utils.GeoUtils.getBoundingBox
import java.io.OutputStreamWriter
import java.net.Authenticator
import java.net.HttpURLConnection
import java.net.PasswordAuthentication
import java.net.URL
class Overpass {
//val overpassUrl = "https://overpass.kumi.systems/api/interpreter"
//val overpassUrl = "https://overpass-api.de/api"
val overpassUrl = "https://kouros-online.de/overpass/interpreter"
private val config by lazy { com.kouros.navigation.data.ApplicationConfig.load() }
private val gson = GsonBuilder().serializeNulls().create()
var overpassUrl = if (BuildConfig.DEBUG)
"http://192.168.1.37/api/interpreter"
else
"https://kouros-online.de/api/interpreter"
val destination = "[!destination][highway!=\"motorway_link\"]"
fun getSpeedLimit(radius: Float, linestring: String, street: String, roadNumbers: List<String>): List<Elements> {
val wayAround = "way[maxspeed](around:$radius,$linestring)"
val searchClauses = mutableListOf<String>()
// 1. Search by street name (fuzzy match with first 10 characters)
val streetPrefix = street.take(10)
if (streetPrefix.isNotEmpty()) {
searchClauses.add("$wayAround[name~\"^$streetPrefix\"]")
}
// 2. Search by road numbers (ref or int_ref)
val partRegex = Regex("""\d+|\D+""")
roadNumbers.forEach { number ->
val parts = partRegex.findAll(number).map { it.value.trim() }.filter { it.isNotEmpty() }.toList()
if (parts.isNotEmpty()) {
// Construct ref value: e.g., "A1" -> "A 1", "E30" -> "E 30"
val refValue = if (parts.size > 1) "${parts[0]} ${parts.drop(1).joinToString("")}" else parts[0]
val tag = if (number.startsWith("E", ignoreCase = true)) "int_ref" else "ref"
searchClauses.add("$wayAround$destination[$tag~\"$refValue\"]")
}
}
// 3. Fallback to searching everything if no specific filters were added
if (searchClauses.isEmpty()) {
searchClauses.add(wayAround)
}
fun getAround(radius: Int, linestring: String): List<Elements> {
val httpURLConnection = URL(overpassUrl).openConnection() as HttpURLConnection
httpURLConnection.requestMethod = "POST"
httpURLConnection.setRequestProperty(
"Accept",
"application/json"
)
httpURLConnection.setDoOutput(true);
// define search query
val searchQuery = """
|[out:json];
|(
| way[highway](around:$radius,$linestring)
| ;
|);
|out body;
|[out:json][timeout:10];
|(
| ${searchClauses.joinToString(";")};
|);
|out body geom;
""".trimMargin()
//println("way[highway](around:$radius,$linestring)")
return overpassApi(httpURLConnection, searchQuery)
//Log.d("OverpassApi", "Overpass Query: $searchQuery")
val connection = (URL(overpassUrl).openConnection() as HttpURLConnection).apply {
requestMethod = "POST"
setRequestProperty("Accept", "application/json")
doOutput = true
}
return overpassApi(connection, searchQuery)
}
fun getStreet(location: Location): List<Elements> {
val lineString = "${location.latitude},${location.longitude}"
val searchLocation ="way[\"highway\"~\"^(primary|secondary|tertiary|residential|motorway)$\"][name](around:50, $lineString)";
val searchQuery = """
|[out:json][timeout:10];
|(
| ${searchLocation};
|);
|out body geom;
""".trimMargin()
val connection = (URL(overpassUrl).openConnection() as HttpURLConnection).apply {
requestMethod = "POST"
setRequestProperty("Accept", "application/json")
doOutput = true
}
return overpassApi(connection, searchQuery)
}
fun getAmenities(
type: String,
category: String,
@@ -42,46 +98,65 @@ class Overpass {
radius: Double
): List<Elements> {
val boundingBox = getBoundingBox(location.latitude, location.longitude, radius)
val httpURLConnection = URL(overpassUrl).openConnection() as HttpURLConnection
httpURLConnection.requestMethod = "POST"
// node["highway"="speed_camera"]
// node[amenity=$category]
httpURLConnection.setDoOutput(true);
httpURLConnection.setRequestProperty(
"Accept",
"application/json"
)
// define search query
val searchQuery = """
|[out:json];
|(
| node[$type=$category]
| ($boundingBox);
|);
|(._;>;);
|out body;
|[out:json];
|(
| node[$type=$category]
| ($boundingBox);
|);
|(._;>;);
|out body geom;
""".trimMargin()
return overpassApi(httpURLConnection, searchQuery)
val connection = (URL(overpassUrl).openConnection() as HttpURLConnection).apply {
requestMethod = "POST"
setRequestProperty("Accept", "application/json")
doOutput = true
}
return overpassApi(connection, searchQuery)
}
fun overpassApi(httpURLConnection: HttpURLConnection, searchQuery: String): List<Elements> {
try {
val outputStreamWriter = OutputStreamWriter(httpURLConnection.outputStream)
outputStreamWriter.write(searchQuery)
outputStreamWriter.flush()
// Check if the connection is successful
val responseCode = httpURLConnection.responseCode
if (responseCode == HttpURLConnection.HTTP_OK) {
val response = httpURLConnection.inputStream.bufferedReader()
.use { it.readText() } // defaults to UTF-8
val gson = GsonBuilder().serializeNulls().create()
val overpass = gson.fromJson(response, Amenity::class.java)
return overpass.elements
private fun overpassApi(connection: HttpURLConnection, searchQuery: String): List<Elements> {
return try {
Authenticator.setDefault(object : Authenticator() {
override fun getPasswordAuthentication(): PasswordAuthentication? {
return if (config.user.isEmpty() || config.password.isEmpty()) {
null
} else {
PasswordAuthentication(
config.user,
config.password.toCharArray()
)
}
}
})
connection.outputStream.use { os ->
OutputStreamWriter(os).use { writer ->
writer.write(searchQuery)
writer.flush()
}
}
Log.w("OverpassApi", searchQuery)
val responseCode = connection.responseCode
if (responseCode == HttpURLConnection.HTTP_OK) {
val response = connection.inputStream.bufferedReader().use { it.readText() }
if (response.startsWith("<?xml")) {
Log.w("OverpassApi", "Received XML instead of JSON")
return emptyList()
}
gson.fromJson(response, Amenity::class.java).elements
} else {
Log.e("OverpassApi", "Error code: $responseCode")
emptyList()
}
} catch (e: Exception) {
println("Speed $e")
Log.e("OverpassApi", "Exception in Overpass API call", e)
emptyList()
}
return emptyList()
}
}
}
@@ -4,21 +4,29 @@ import com.google.gson.annotations.SerializedName
data class Tags(
@SerializedName("name") var name: String? = null,
@SerializedName("amenity") var amenity: String? = null,
@SerializedName("authentication:none") var authenticationNone: String? = null,
@SerializedName("capacity") var capacity: String? = null,
@SerializedName("motorcar") var motorcar: String? = null,
@SerializedName("network") var network: String? = null,
@SerializedName("opening_hours") var openingHours: String? = null,
@SerializedName("operator") var operator: String? = null,
@SerializedName("operator:short") var operatorShort: String? = null,
@SerializedName("operator:wikidata") var operatorWikidata: String? = null,
@SerializedName("operator:wikipedia") var operatorWikipedia: String? = null,
@SerializedName("ref") var ref: String? = null,
@SerializedName("socket:type2") var socketType2: String? = null,
@SerializedName("socket:type2:output") var socketType2Output: String? = null,
@SerializedName("maxspeed") var maxspeed: String = "0",
@SerializedName("direction") var direction: String? = null,
val destination: String = "",
val highway: String = "",
val lanes: String = "",
val lit: String = "",
val maxspeed: String = "0",
@SerializedName("maxspeed:variable") val maxSpeedVariable: String = "",
val name: String = "",
val oneway: String = "",
val ref: String = "",
@SerializedName("int_ref") val intRef: String = "",
val sidewalk: String = "",
val smoothness: String = "",
val surface: String = "",
val amenity: String = "",
val capacity: String = "",
val motorcar: String = "",
val network: String = "",
@SerializedName("opening_hours") val openingHours: String = "",
val operator: String = "",
val operatorShort: String = "",
val operatorWikidata: String = "",
val operatorWikipedia: String = "",
val socketType2: String = "",
val socketType2Output: String = "",
val direction: String = "",
)
@@ -1,5 +1,9 @@
package com.kouros.navigation.data.route
import android.location.Location
import com.kouros.navigation.utils.location
data class Leg(
var steps : List<Step> = arrayListOf(),
val summary: Summary,
)
@@ -3,13 +3,68 @@ package com.kouros.navigation.data.route
import android.location.Location
data class Maneuver(
val bearingBefore : Int = 0,
val bearingAfter : Int = 0,
val bearingBefore: Int = 0,
val bearingAfter: Int = 0,
val type: Int = 0,
val waypoints: List<List<Double>>,
val waypoints: List<Location>,
val location: Location,
val exit: Int = 0,
val street: String = "",
val message: String = "",
val pointIndex : Int = 0,
val pointIndex: Int = 0,
val leftDistance: List<Float>
)
enum class ManeuverType(val value: Int) {
TYPE_UNKNOWN(0),
TYPE_DEPART(1),
TYPE_NAME_CHANGE(2),
TYPE_KEEP_LEFT(3),
TYPE_KEEP_RIGHT(4),
TYPE_TURN_SLIGHT_LEFT(5),
TYPE_TURN_SLIGHT_RIGHT(6),
TYPE_TURN_NORMAL_LEFT(7),
TYPE_TURN_NORMAL_RIGHT(8),
TYPE_TURN_SHARP_LEFT(9),
TYPE_TURN_SHARP_RIGHT(10),
TYPE_U_TURN_LEFT(11),
TYPE_U_TURN_RIGHT(12),
TYPE_ON_RAMP_SLIGHT_LEFT(13),
TYPE_ON_RAMP_SLIGHT_RIGHT(14),
TYPE_ON_RAMP_NORMAL_LEFT(15),
TYPE_ON_RAMP_NORMAL_RIGHT(16),
TYPE_ON_RAMP_SHARP_LEFT(17),
TYPE_ON_RAMP_SHARP_RIGHT(18),
TYPE_ON_RAMP_U_TURN_LEFT(19),
TYPE_ON_RAMP_U_TURN_RIGHT(20),
TYPE_OFF_RAMP_SLIGHT_LEFT(21),
TYPE_OFF_RAMP_SLIGHT_RIGHT(22),
TYPE_OFF_RAMP_NORMAL_LEFT(23),
TYPE_OFF_RAMP_NORMAL_RIGHT(24),
TYPE_FORK_LEFT(25),
TYPE_FORK_RIGHT(26),
TYPE_MERGE_LEFT(27),
TYPE_MERGE_RIGHT(28),
TYPE_MERGE_SIDE_UNSPECIFIED (29),
TYPE_ROUNDABOUT_ENTER_AND_EXIT_CW (32),
TYPE_ROUNDABOUT_ENTER_AND_EXIT_CW_WITH_ANGLE(33),
TYPE_ROUNDABOUT_ENTER_AND_EXIT_CCW(34),
TYPE_ROUNDABOUT_ENTER_AND_EXIT_CCW_WITH_ANGLE(35),
TYPE_STRAIGHT(36),
TYPE_FERRY_BOAT (37),
TYPE_FERRY_TRAIN (38),
TYPE_DESTINATION (39),
TYPE_DESTINATION_STRAIGHT (40),
TYPE_DESTINATION_LEFT(41),
TYPE_DESTINATION_RIGHT(42),
TYPE_ROUNDABOUT_ENTER_CW(43),
TYPE_ROUNDABOUT_EXIT_CW(44),
TYPE_ROUNDABOUT_ENTER_CCW(45),
TYPE_ROUNDABOUT_EXIT_CCW(46),
TYPE_FERRY_BOAT_LEFT(47),
TYPE_FERRY_BOAT_RIGHT(48),
TYPE_FERRY_TRAIN_LEFT(49),
TYPE_FERRY_TRAIN_RIGHT(50),
TYPE_WAYPOINT_RIGHT(51),
TYPE_WAYPOINT_LEFT(52),
}
@@ -12,5 +12,6 @@ data class Step(
val distance: Double = 0.0,
val street : String = "",
val intersection: List<Intersection> = mutableListOf(),
val countryCode : String = ""
val countryCode : String = "",
val roadNumbers : List<String> = emptyList(),
)
@@ -1,5 +0,0 @@
package com.kouros.navigation.data.tomtom
data class Cause(
val mainCauseCode: Int
)
@@ -1,10 +0,0 @@
package com.kouros.navigation.data.tomtom
import com.google.gson.annotations.SerializedName
data class Events (
@SerializedName("description" ) var description : String? = null
)
@@ -1,12 +0,0 @@
package com.kouros.navigation.data.tomtom
import com.google.gson.annotations.SerializedName
data class Features (
@SerializedName("type" ) var type : String? = null,
@SerializedName("properties" ) var properties : Properties? = Properties(),
@SerializedName("geometry" ) var geometry : Geometry? = Geometry()
)
@@ -1,11 +0,0 @@
package com.kouros.navigation.data.tomtom
import com.google.gson.annotations.SerializedName
data class Geometry (
@SerializedName("type" ) var type : String? = null,
@SerializedName("coordinates" ) var coordinates : List<List<Double>> = arrayListOf()
)
@@ -1,12 +0,0 @@
package com.kouros.navigation.data.tomtom
import com.google.gson.annotations.SerializedName
data class Incidents (
@SerializedName("type" ) var type : String? = null,
@SerializedName("properties" ) var properties : Properties? = Properties(),
@SerializedName("geometry" ) var geometry : Geometry? = Geometry()
)
@@ -11,7 +11,7 @@ data class Instruction(
val point: Point,
val pointIndex: Int,
val possibleCombineWithNext: Boolean,
val roadNumbers: List<String>,
val roadNumbers: List<String> = emptyList(),
val routeOffsetInMeters: Int,
val signpostText: String,
val street: String? = "",
@@ -1,11 +0,0 @@
package com.kouros.navigation.data.tomtom
import com.google.gson.annotations.SerializedName
data class Properties (
@SerializedName("iconCategory" ) var iconCategory : Int? = null,
@SerializedName("events" ) var events : ArrayList<Events> = arrayListOf()
)
@@ -25,18 +25,24 @@ val useLocal = BuildConfig.DEBUG
val useLocalTraffic = BuildConfig.DEBUG
class TomTomRepository : NavigationRepository() {
override fun getRoute(
context: Context,
currentLocation: Location,
location: Location,
location: List<Location>,
carOrientation: Float,
searchFilter: SearchFilter
): String {
val vehicleHeading = if (carOrientation !in 0.0..360.0) {
0
} else {
carOrientation.toInt()
}
if (useLocal) {
return fetchUrl(
"https://kouros-online.de/tomtom_routing.json",
"http://192.168.1.37/tomtom_routing.json",
//"http://192.168.1.37/verona.json",
false
)
}
@@ -55,18 +61,35 @@ class TomTomRepository : NavigationRepository() {
engineType = "electric"
}
val repository = getSettingsRepository(context)
val tomtomApiKey = runBlocking { repository.tomTomApiKeyFlow.first() }
val tomtomApiKey = runBlocking {
repository.tomTomApiKeyFlow.first()
}
val alternativeRoutes = runBlocking {
repository.alternativeRoutesFlow.first()
}
val altRoutes = if (alternativeRoutes) {
"&maxAlternatives=2"
} else {
"&maxAlternatives=0"
}
val currentLocale = Locale.getDefault()
val language = currentLocale.language + "-" + currentLocale.country
var loc = ""
location.forEach {
loc += if (loc.isEmpty()) {
"${it.latitude},${it.longitude}"
} else {
":${it.latitude},${it.longitude}"
}
}
val url =
routeUrl + "${currentLocation.latitude},${currentLocation.longitude}:${location.latitude},${location.longitude}" +
routeUrl + "${currentLocation.latitude},${currentLocation.longitude}:$loc" +
"/json?sectionType=traffic&report=effectiveSettings&routeType=eco" +
"&traffic=true&avoid=unpavedRoads&travelMode=car" +
"&vehicleMaxSpeed=120&vehicleCommercial=false" +
"&instructionsType=text&language=$language&sectionType=lanes" +
"&routeRepresentation=encodedPolyline" +
"&maxAlternatives=2" +
"&vehicleEngineType=$engineType$filter&key=$tomtomApiKey"
"&routeRepresentation=encodedPolyline$altRoutes" +
"&vehicleHeading=$vehicleHeading&vehicleEngineType=$engineType$filter&key=$tomtomApiKey"
return fetchUrl(
url,
false
@@ -80,10 +103,10 @@ class TomTomRepository : NavigationRepository() {
if (!showTraffic) {
return ""
}
val bbox = calculateSquareRadius(location.latitude, location.longitude, 15.0)
val bbox = calculateSquareRadius(location.latitude, location.longitude, 10.0)
return if (useLocalTraffic) {
fetchUrl(
"https://kouros-online.de/tomtom_traffic.json",
"http://192.168.1.37/tomtom_traffic.json",
false
)
} else {
@@ -1,28 +1,33 @@
package com.kouros.navigation.data.tomtom
import com.kouros.navigation.data.Route
import com.kouros.navigation.data.RouteEngine
import com.kouros.navigation.data.route.Intersection
import com.kouros.navigation.data.route.Lane
import com.kouros.navigation.data.route.Leg
import com.kouros.navigation.data.route.ManeuverType
import com.kouros.navigation.data.route.Routes
import com.kouros.navigation.data.route.Step
import com.kouros.navigation.data.route.Summary
import com.kouros.navigation.utils.GeoUtils.createCenterLocation
import com.kouros.navigation.utils.GeoUtils.createLineStringCollection
import com.kouros.navigation.utils.GeoUtils.decodePolyline
import com.kouros.navigation.utils.isNumeric
import com.kouros.navigation.utils.location
import kotlin.math.absoluteValue
import com.kouros.navigation.data.route.Maneuver as RouteManeuver
class TomTomRoute {
fun mapToRoute(routeJson: TomTomResponse, builder: Route.Builder) {
val routes = mutableListOf<com.kouros.navigation.data.route.Routes>()
val routes = mutableListOf<Routes>()
routeJson.routes.forEach { route ->
val waypoints = mutableListOf<List<Double>>()
val points = mutableListOf<List<Double>>()
val legs = mutableListOf<Leg>()
var stepIndex = 0
var points = listOf<List<Double>>()
val summary = Summary(
route.summary.travelTimeInSeconds.toDouble(),
route.summary.lengthInMeters.toDouble(),
@@ -30,89 +35,112 @@ class TomTomRoute {
route.summary.trafficLengthInMeters.toDouble()
)
route.legs.forEach { leg ->
points = decodePolyline(leg.encodedPolyline, leg.encodedPolylinePrecision)
waypoints.addAll(points)
val p = decodePolyline(leg.encodedPolyline, leg.encodedPolylinePrecision)
points.addAll(p)
waypoints.addAll(p)
}
var stepDistance = 0.0
var stepDuration = 0.0
val steps = mutableListOf<Step>()
var lastPointIndex = 0
for (index in 1..<route.guidance.instructions.size) {
val lastInstruction = route.guidance.instructions[index - 1]
val instruction = route.guidance.instructions[index]
val street = lastInstruction.street ?: ""
val maneuverStreet = instruction.street ?: ""
val maneuver = RouteManeuver(
bearingBefore = 0,
bearingAfter = 0,
type = convertType(instruction.maneuver),
waypoints = points.subList(
lastPointIndex,
instruction.pointIndex + 1,
),
exit = exitNumber(instruction),
location = location(
instruction.point.longitude, instruction.point.latitude
),
street = maneuverStreet,
message = instruction.message,
pointIndex = instruction.pointIndex
route.legs.forEach { leg ->
var stepDistance = 0.0
var stepDuration = 0.0
val steps = mutableListOf<Step>()
val summary = Summary(
leg.summary.travelTimeInSeconds.toDouble(),
leg.summary.lengthInMeters.toDouble(),
leg.summary.trafficDelayInSeconds.toDouble(),
leg.summary.trafficLengthInMeters.toDouble()
)
lastPointIndex = instruction.pointIndex
val intersections = mutableListOf<Intersection>()
route.sections?.forEach { section ->
if (section.sectionType == "LANES" && section.startPointIndex <= lastPointIndex
&& section.endPointIndex >= lastPointIndex
) {
val lanes = mutableListOf<Lane>()
var startIndex = 0
var lastLane: Lane? = null
section.lanes?.forEach { itLane ->
val lane = Lane(
location = location(
waypoints[section.startPointIndex][0],
waypoints[section.startPointIndex][1]
),
valid = itLane.directions.first() == itLane.follow,
indications = itLane.directions,
startIndex = startIndex,
endIndex = section.endPointIndex
)
startIndex = section.startPointIndex
if (lastLane == null
|| (!(lastLane.valid && lane.valid
&& lastLane.indications == lane.indications))
) {
var lastPointIndex = 0
for (index in 1..<route.guidance.instructions.size) {
val lastInstruction = route.guidance.instructions[index - 1]
val instruction = route.guidance.instructions[index]
val street = lastInstruction.street ?: ""
val maneuverStreet = instruction.street ?: ""
val leftDistance = mutableListOf<Float>()
var lastLocation = location(0.0, 0.0)
stepDistance =
route.guidance.instructions[index].routeOffsetInMeters - stepDistance
stepDuration =
route.guidance.instructions[index].travelTimeInSeconds - stepDuration
// calculate left step distance for each point
var leftStepDistance = stepDistance
leftDistance.add(leftStepDistance.toFloat())
val subPoints = points.subList(
lastPointIndex,
instruction.pointIndex + 1)
subPoints.forEach {
if (lastLocation.latitude != 0.0) {
val curLocation = location(it[0], it[1])
val dist = curLocation.distanceTo(lastLocation).absoluteValue
leftStepDistance -= dist
leftDistance.add(leftStepDistance.toFloat())
}
lastLocation = location(it[0], it[1])
}
val maneuver = RouteManeuver(
bearingBefore = 0,
bearingAfter = 0,
type = convertType(instruction.maneuver),
waypoints = subPoints.map { location(it[0], it[1]) },
exit = exitNumber(instruction),
location = location(
instruction.point.longitude, instruction.point.latitude
),
street = maneuverStreet,
message = instruction.message,
pointIndex = instruction.pointIndex,
leftDistance = leftDistance
)
lastPointIndex = instruction.pointIndex
val intersections = mutableListOf<Intersection>()
route.sections?.forEach { section ->
if (section.sectionType == "LANES" && section.startPointIndex <= lastPointIndex
&& section.endPointIndex >= lastPointIndex
) {
val lanes = mutableListOf<Lane>()
var startIndex = 0
section.lanes?.forEach { itLane ->
val lane = Lane(
location = location(
waypoints[section.startPointIndex][0],
waypoints[section.startPointIndex][1]
),
valid = itLane.directions.first() == itLane.follow,
indications = itLane.directions,
startIndex = startIndex,
endIndex = section.endPointIndex
)
startIndex = section.startPointIndex
lanes.add(lane)
}
lastLane = lane
intersections.add(Intersection(waypoints[startIndex], lanes))
}
intersections.add(Intersection(waypoints[startIndex], lanes))
}
val roadNumbers = if (lastInstruction.roadNumbers != null) {
lastInstruction.roadNumbers
} else {
emptyList()
}
val step = Step(
index = stepIndex,
street = street,
distance = stepDistance,
duration = stepDuration,
maneuver = maneuver,
intersection = intersections,
countryCode = lastInstruction.countryCode,
roadNumbers = roadNumbers
)
stepDistance = route.guidance.instructions[index].routeOffsetInMeters.toDouble()
stepDuration = route.guidance.instructions[index].travelTimeInSeconds.toDouble()
steps.add(step)
stepIndex += 1
}
stepDistance =
route.guidance.instructions[index].routeOffsetInMeters - stepDistance
stepDuration =
route.guidance.instructions[index].travelTimeInSeconds - stepDuration
val step = Step(
index = stepIndex,
street = street,
distance = stepDistance,
duration = stepDuration,
maneuver = maneuver,
intersection = intersections,
countryCode = lastInstruction.countryCode
)
stepDistance = route.guidance.instructions[index].routeOffsetInMeters.toDouble()
stepDuration = route.guidance.instructions[index].travelTimeInSeconds.toDouble()
steps.add(step)
stepIndex += 1
legs.add(Leg(steps, summary))
}
legs.add(Leg(steps))
val routeGeoJson = createLineStringCollection(waypoints)
val centerLocation = createCenterLocation(createLineStringCollection(waypoints))
val newRoute = com.kouros.navigation.data.route.Routes(
val newRoute = Routes(
legs,
summary,
routeGeoJson,
@@ -130,75 +158,80 @@ class TomTomRoute {
var newType = 0
when (type) {
"DEPART" -> {
newType = androidx.car.app.navigation.model.Maneuver.TYPE_DEPART
newType = ManeuverType.TYPE_DEPART.value
}
"ARRIVE" -> {
newType = androidx.car.app.navigation.model.Maneuver.TYPE_DESTINATION
newType = ManeuverType.TYPE_DESTINATION.value
}
"ARRIVE_LEFT" -> {
newType = androidx.car.app.navigation.model.Maneuver.TYPE_DESTINATION_LEFT
newType = ManeuverType.TYPE_DESTINATION_LEFT.value
}
"ARRIVE_RIGHT" -> {
newType = androidx.car.app.navigation.model.Maneuver.TYPE_DESTINATION_RIGHT
newType = ManeuverType.TYPE_DESTINATION_RIGHT.value
}
"STRAIGHT", "FOLLOW" -> {
newType = androidx.car.app.navigation.model.Maneuver.TYPE_STRAIGHT
newType = ManeuverType.TYPE_STRAIGHT.value
}
"KEEP_RIGHT" -> {
newType = androidx.car.app.navigation.model.Maneuver.TYPE_KEEP_RIGHT
newType = ManeuverType.TYPE_KEEP_RIGHT.value
}
"BEAR_RIGHT" -> {
newType = androidx.car.app.navigation.model.Maneuver.TYPE_TURN_SLIGHT_RIGHT
newType = ManeuverType.TYPE_TURN_SLIGHT_RIGHT.value
}
"BEAR_LEFT" -> {
newType = androidx.car.app.navigation.model.Maneuver.TYPE_TURN_SLIGHT_LEFT
newType = ManeuverType.TYPE_TURN_SLIGHT_LEFT.value
}
"KEEP_LEFT" -> {
newType = androidx.car.app.navigation.model.Maneuver.TYPE_KEEP_LEFT
newType = ManeuverType.TYPE_KEEP_LEFT.value
}
"TURN_LEFT" -> {
newType = androidx.car.app.navigation.model.Maneuver.TYPE_TURN_NORMAL_LEFT
newType = ManeuverType.TYPE_TURN_NORMAL_LEFT.value
}
"TURN_RIGHT" -> {
newType = androidx.car.app.navigation.model.Maneuver.TYPE_TURN_NORMAL_RIGHT
newType = ManeuverType.TYPE_TURN_NORMAL_RIGHT.value
}
"SHARP_LEFT" -> {
newType = androidx.car.app.navigation.model.Maneuver.TYPE_TURN_SHARP_LEFT
newType = ManeuverType.TYPE_TURN_SHARP_LEFT.value
}
"SHARP_RIGHT" -> {
newType = androidx.car.app.navigation.model.Maneuver.TYPE_TURN_SHARP_RIGHT
newType = ManeuverType.TYPE_TURN_SHARP_RIGHT.value
}
"ROUNDABOUT_RIGHT", "ROUNDABOUT_CROSS" -> {
newType = androidx.car.app.navigation.model.Maneuver.TYPE_ROUNDABOUT_ENTER_CCW
newType = ManeuverType.TYPE_ROUNDABOUT_ENTER_CCW.value
}
"ROUNDABOUT_LEFT" -> {
newType = androidx.car.app.navigation.model.Maneuver.TYPE_ROUNDABOUT_ENTER_CW
newType = ManeuverType.TYPE_ROUNDABOUT_ENTER_CW.value
}
"MAKE_UTURN" -> {
newType = androidx.car.app.navigation.model.Maneuver.TYPE_U_TURN_LEFT
"MAKE_UTURN", "TRY_MAKE_UTURN" -> {
newType = ManeuverType.TYPE_U_TURN_LEFT.value
}
"ENTER_MOTORWAY" -> {
newType = androidx.car.app.navigation.model.Maneuver.TYPE_MERGE_LEFT
newType = ManeuverType.TYPE_MERGE_LEFT.value
}
"TAKE_EXIT" -> {
newType = androidx.car.app.navigation.model.Maneuver.TYPE_TURN_SLIGHT_RIGHT
newType = ManeuverType.TYPE_TURN_SLIGHT_RIGHT.value
}
"WAYPOINT_RIGHT" -> {
newType = ManeuverType.TYPE_WAYPOINT_RIGHT.value
}
}
return newType
@@ -212,6 +245,10 @@ private fun exitNumber(
) {
0
} else {
instruction.exitNumber.toInt()
if (isNumeric(instruction.exitNumber)) {
instruction.exitNumber.toInt()
} else {
0
}
}
}
@@ -1,12 +0,0 @@
package com.kouros.navigation.data.tomtom
import com.google.gson.annotations.SerializedName
data class Traffic (
//@SerializedName("incidents" ) var incidents : ArrayList<Incidents> = arrayListOf()
@SerializedName("type" ) var type : String = "",
@SerializedName("features" ) var features : ArrayList<Features> = arrayListOf()
)
@@ -1,6 +0,0 @@
package com.kouros.navigation.data.tomtom
data class TrafficData (
var traffic : Traffic ,
var trafficData: String = ""
)
@@ -16,7 +16,7 @@ class ValhallaRepository : NavigationRepository() {
override fun getRoute(
context: Context,
currentLocation: Location,
location: Location,
location: List<Location>,
carOrientation: Float,
searchFilter: SearchFilter
): String {
@@ -35,7 +35,7 @@ class ValhallaRepository : NavigationRepository() {
lon = currentLocation.longitude,
searchFilter = exclude
),
Locations(lat = location.latitude, lon = location.longitude, searchFilter = exclude)
Locations(lat = location.first().latitude, lon = location.first().longitude, searchFilter = exclude)
)
val valhallaLocation = ValhallaLocation(
locations = vLocation,
@@ -25,9 +25,10 @@ class ValhallaRoute {
bearingAfter = it.bearingAfter,
//type = it.type,
type = convertType(it),
waypoints =waypoints.subList(it.beginShapeIndex, it.endShapeIndex+1),
waypoints = waypoints.subList(it.beginShapeIndex, it.endShapeIndex + 1).map { location(it[0], it[1]) },
// TODO: calculate from ShapeIndex !
location = location(0.0, 0.0)
location = location(0.0, 0.0),
leftDistance = emptyList()
)
var name = ""
@@ -6,15 +6,12 @@ import android.graphics.BitmapFactory
import android.graphics.Canvas
import android.graphics.Matrix
import android.graphics.Paint
import androidx.annotation.DrawableRes
import androidx.car.app.model.CarIcon
import androidx.car.app.navigation.model.LaneDirection
import androidx.car.app.navigation.model.Maneuver
import androidx.core.graphics.createBitmap
import androidx.core.graphics.drawable.IconCompat
import com.kouros.data.R
import com.kouros.navigation.data.StepData
import java.util.Collections
import com.kouros.navigation.data.route.ManeuverType
import java.util.Locale
class IconMapper {
@@ -22,62 +19,66 @@ class IconMapper {
fun maneuverIcon(routeManeuverType: Int): Int {
var currentTurnIcon = R.drawable.ic_turn_name_change
when (routeManeuverType) {
Maneuver.TYPE_STRAIGHT -> {
ManeuverType.TYPE_STRAIGHT.value -> {
currentTurnIcon = R.drawable.ic_turn_name_change
}
Maneuver.TYPE_DESTINATION,
Maneuver.TYPE_DESTINATION_RIGHT,
Maneuver.TYPE_DESTINATION_LEFT,
Maneuver.TYPE_DESTINATION_STRAIGHT
ManeuverType.TYPE_DESTINATION.value,
ManeuverType.TYPE_DESTINATION_RIGHT.value,
ManeuverType.TYPE_DESTINATION_LEFT.value,
ManeuverType.TYPE_DESTINATION_STRAIGHT.value
-> {
currentTurnIcon = R.drawable.ic_turn_destination
}
Maneuver.TYPE_TURN_NORMAL_RIGHT -> {
ManeuverType.TYPE_TURN_NORMAL_RIGHT.value -> {
currentTurnIcon = R.drawable.ic_turn_normal_right
}
Maneuver.TYPE_TURN_NORMAL_LEFT -> {
ManeuverType.TYPE_TURN_NORMAL_LEFT.value -> {
currentTurnIcon = R.drawable.ic_turn_normal_left
}
Maneuver.TYPE_OFF_RAMP_SLIGHT_RIGHT -> {
ManeuverType.TYPE_OFF_RAMP_SLIGHT_RIGHT.value -> {
currentTurnIcon = R.drawable.ic_turn_slight_right
}
Maneuver.TYPE_TURN_SLIGHT_RIGHT -> {
ManeuverType.TYPE_TURN_SLIGHT_RIGHT.value -> {
currentTurnIcon = R.drawable.ic_turn_slight_right
}
Maneuver.TYPE_KEEP_RIGHT -> {
ManeuverType.TYPE_KEEP_RIGHT.value -> {
currentTurnIcon = R.drawable.ic_turn_name_change
}
Maneuver.TYPE_KEEP_LEFT -> {
ManeuverType.TYPE_KEEP_LEFT.value -> {
currentTurnIcon = R.drawable.ic_turn_name_change
}
Maneuver.TYPE_ROUNDABOUT_ENTER_CCW -> {
ManeuverType.TYPE_ROUNDABOUT_ENTER_CCW.value -> {
currentTurnIcon = R.drawable.ic_roundabout_ccw
}
Maneuver.TYPE_ROUNDABOUT_EXIT_CCW -> {
ManeuverType.TYPE_ROUNDABOUT_EXIT_CCW.value -> {
currentTurnIcon = R.drawable.ic_roundabout_ccw
}
Maneuver.TYPE_U_TURN_LEFT -> {
ManeuverType.TYPE_U_TURN_LEFT.value -> {
currentTurnIcon = R.drawable.ic_turn_u_turn_left
}
Maneuver.TYPE_U_TURN_RIGHT -> {
ManeuverType.TYPE_U_TURN_RIGHT.value -> {
currentTurnIcon = R.drawable.ic_turn_u_turn_right
}
Maneuver.TYPE_MERGE_LEFT -> {
ManeuverType.TYPE_MERGE_LEFT.value -> {
currentTurnIcon = R.drawable.ic_turn_merge_symmetrical
}
ManeuverType.TYPE_WAYPOINT_RIGHT.value -> {
currentTurnIcon = R.drawable.ic_turn_destination
}
}
return currentTurnIcon
}
@@ -86,8 +87,8 @@ class IconMapper {
val laneDirection = when (direction.lowercase(Locale.getDefault())) {
"left_straight" -> {
when (stepData.currentManeuverType) {
Maneuver.TYPE_TURN_NORMAL_LEFT -> LaneDirection.SHAPE_NORMAL_LEFT
Maneuver.TYPE_STRAIGHT -> LaneDirection.SHAPE_STRAIGHT
ManeuverType.TYPE_TURN_NORMAL_LEFT.value -> LaneDirection.SHAPE_NORMAL_LEFT
ManeuverType.TYPE_STRAIGHT.value -> LaneDirection.SHAPE_STRAIGHT
else
-> LaneDirection.SHAPE_UNKNOWN
}
@@ -95,7 +96,7 @@ class IconMapper {
"left" -> {
when (stepData.currentManeuverType) {
Maneuver.TYPE_TURN_NORMAL_LEFT -> LaneDirection.SHAPE_NORMAL_LEFT
ManeuverType.TYPE_TURN_NORMAL_LEFT.value -> LaneDirection.SHAPE_NORMAL_LEFT
else
-> LaneDirection.SHAPE_UNKNOWN
}
@@ -103,9 +104,9 @@ class IconMapper {
"straight" -> {
when (stepData.currentManeuverType) {
Maneuver.TYPE_STRAIGHT -> LaneDirection.SHAPE_STRAIGHT
Maneuver.TYPE_KEEP_LEFT -> LaneDirection.SHAPE_STRAIGHT
Maneuver.TYPE_KEEP_RIGHT -> LaneDirection.SHAPE_STRAIGHT
ManeuverType.TYPE_STRAIGHT.value -> LaneDirection.SHAPE_STRAIGHT
ManeuverType.TYPE_KEEP_LEFT.value -> LaneDirection.SHAPE_STRAIGHT
ManeuverType.TYPE_KEEP_RIGHT.value -> LaneDirection.SHAPE_STRAIGHT
else
-> LaneDirection.SHAPE_UNKNOWN
}
@@ -113,7 +114,7 @@ class IconMapper {
"right" -> {
when (stepData.currentManeuverType) {
Maneuver.TYPE_TURN_NORMAL_RIGHT -> LaneDirection.SHAPE_NORMAL_RIGHT
ManeuverType.TYPE_TURN_NORMAL_RIGHT.value -> LaneDirection.SHAPE_NORMAL_RIGHT
else
-> LaneDirection.SHAPE_UNKNOWN
}
@@ -121,8 +122,8 @@ class IconMapper {
"right_straight" -> {
when (stepData.currentManeuverType) {
Maneuver.TYPE_TURN_NORMAL_RIGHT -> LaneDirection.SHAPE_NORMAL_RIGHT
Maneuver.TYPE_STRAIGHT -> LaneDirection.SHAPE_STRAIGHT
ManeuverType.TYPE_TURN_NORMAL_RIGHT.value -> LaneDirection.SHAPE_NORMAL_RIGHT
ManeuverType.TYPE_STRAIGHT.value -> LaneDirection.SHAPE_STRAIGHT
else
-> LaneDirection.SHAPE_UNKNOWN
}
@@ -130,8 +131,8 @@ class IconMapper {
"left_slight", "slight_left" -> {
when (stepData.currentManeuverType) {
Maneuver.TYPE_TURN_NORMAL_LEFT -> LaneDirection.SHAPE_SLIGHT_LEFT
Maneuver.TYPE_KEEP_LEFT -> LaneDirection.SHAPE_SLIGHT_LEFT
ManeuverType.TYPE_TURN_NORMAL_LEFT.value -> LaneDirection.SHAPE_SLIGHT_LEFT
ManeuverType.TYPE_KEEP_LEFT.value -> LaneDirection.SHAPE_SLIGHT_LEFT
else
-> LaneDirection.SHAPE_UNKNOWN
}
@@ -139,8 +140,8 @@ class IconMapper {
"right_slight", "slight_right" -> {
when (stepData.currentManeuverType) {
Maneuver.TYPE_TURN_SLIGHT_RIGHT -> LaneDirection.SHAPE_NORMAL_RIGHT
Maneuver.TYPE_KEEP_RIGHT -> LaneDirection.SHAPE_SLIGHT_RIGHT
ManeuverType.TYPE_TURN_SLIGHT_RIGHT.value -> LaneDirection.SHAPE_NORMAL_RIGHT
ManeuverType.TYPE_KEEP_RIGHT.value -> LaneDirection.SHAPE_SLIGHT_RIGHT
else
-> LaneDirection.SHAPE_UNKNOWN
}
@@ -212,8 +213,8 @@ class IconMapper {
return when (direction) {
"left_straight" -> {
when (stepData.currentManeuverType) {
Maneuver.TYPE_TURN_NORMAL_LEFT -> "left_o_straight_x"
Maneuver.TYPE_STRAIGHT -> "left_x_straight_o"
ManeuverType.TYPE_TURN_NORMAL_LEFT.value -> "left_o_straight_x"
ManeuverType.TYPE_STRAIGHT.value -> "left_x_straight_o"
else
-> "left_x_straight_x"
}
@@ -221,29 +222,29 @@ class IconMapper {
"right_straight" -> {
when (stepData.currentManeuverType) {
Maneuver.TYPE_TURN_NORMAL_RIGHT -> "right_x_straight_x"
Maneuver.TYPE_STRAIGHT -> "right_x_straight_o"
Maneuver.TYPE_TURN_SLIGHT_RIGHT -> "right_o_straight_o"
ManeuverType.TYPE_TURN_NORMAL_RIGHT.value -> "right_x_straight_x"
ManeuverType.TYPE_STRAIGHT.value -> "right_x_straight_o"
ManeuverType.TYPE_TURN_SLIGHT_RIGHT.value -> "right_o_straight_o"
else
-> "right_x_straight_x"
}
}
"right" -> if (stepData.currentManeuverType == Maneuver.TYPE_TURN_NORMAL_RIGHT) "${direction}_o" else "${direction}_x"
"left" -> if (stepData.currentManeuverType == Maneuver.TYPE_TURN_NORMAL_LEFT) "${direction}_o" else "${direction}_x"
"straight" -> if (stepData.currentManeuverType == Maneuver.TYPE_STRAIGHT
|| stepData.currentManeuverType == Maneuver.TYPE_KEEP_LEFT
|| stepData.currentManeuverType == Maneuver.TYPE_KEEP_RIGHT
"right" -> if (stepData.currentManeuverType == ManeuverType.TYPE_TURN_NORMAL_RIGHT.value) "${direction}_o" else "${direction}_x"
"left" -> if (stepData.currentManeuverType == ManeuverType.TYPE_TURN_NORMAL_LEFT.value) "${direction}_o" else "${direction}_x"
"straight" -> if (stepData.currentManeuverType == ManeuverType.TYPE_STRAIGHT.value
|| stepData.currentManeuverType == ManeuverType.TYPE_KEEP_LEFT.value
|| stepData.currentManeuverType == ManeuverType.TYPE_KEEP_RIGHT.value
) "${direction}_o" else "${direction}_x"
"right_slight", "slight_right" -> if (stepData.currentManeuverType == Maneuver.TYPE_TURN_SLIGHT_RIGHT
|| stepData.currentManeuverType == Maneuver.TYPE_TURN_NORMAL_RIGHT
|| stepData.currentManeuverType == Maneuver.TYPE_KEEP_RIGHT
"right_slight", "slight_right" -> if (stepData.currentManeuverType == ManeuverType.TYPE_TURN_SLIGHT_RIGHT.value
|| stepData.currentManeuverType == ManeuverType.TYPE_TURN_NORMAL_RIGHT.value
|| stepData.currentManeuverType == ManeuverType.TYPE_KEEP_RIGHT.value
) "slight_right_o" else "slight_right_x"
"left_slight", "slight_left" -> if (stepData.currentManeuverType == Maneuver.TYPE_TURN_SLIGHT_LEFT
|| stepData.currentManeuverType == Maneuver.TYPE_TURN_NORMAL_LEFT
|| stepData.currentManeuverType == Maneuver.TYPE_KEEP_LEFT
"left_slight", "slight_left" -> if (stepData.currentManeuverType == ManeuverType.TYPE_TURN_SLIGHT_LEFT.value
|| stepData.currentManeuverType == ManeuverType.TYPE_TURN_NORMAL_LEFT.value
|| stepData.currentManeuverType == ManeuverType.TYPE_KEEP_LEFT.value
) "slight_left_o" else "slight_left_x"
else -> {
@@ -1,33 +1,59 @@
package com.kouros.navigation.model
//import com.kouros.navigation.data.Preferences.boxStore
import android.content.Context
import android.location.Location
import android.util.Log
import androidx.compose.runtime.snapshots.SnapshotStateList
import androidx.compose.runtime.toMutableStateList
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.google.gson.Gson
import com.google.gson.GsonBuilder
import com.kouros.navigation.data.Constants
import com.kouros.navigation.data.Constants.FAVORITES
import com.kouros.navigation.data.Constants.SPEED_BEARING_DEVIATION
import com.kouros.navigation.data.Constants.SPEED_UPDATE_DISTANCE
import com.kouros.navigation.data.Constants.TAG
import com.kouros.navigation.data.Constants.TANKER_KOENIG_DELAY
import com.kouros.navigation.data.NavigationRepository
import com.kouros.navigation.data.Place
import com.kouros.navigation.data.Places
import com.kouros.navigation.data.SearchFilter
import com.kouros.navigation.data.fuel.FuelPrices
import com.kouros.navigation.data.fuel.Station
import com.kouros.navigation.data.fuel.Stations
import com.kouros.navigation.data.nominatim.Search
import com.kouros.navigation.data.nominatim.SearchResult
import com.kouros.navigation.data.overpass.ElementSearch
import com.kouros.navigation.data.overpass.Elements
import com.kouros.navigation.data.overpass.Overpass
import com.kouros.navigation.utils.Levenshtein
import com.kouros.navigation.repository.SettingsRepository
import com.kouros.navigation.utils.GeoUtils.snapLocation
import com.kouros.navigation.utils.GeoUtils.buildLineStrings
import com.kouros.navigation.utils.GeoUtils.buildPointCollection
import com.kouros.navigation.utils.bearingPositive
import com.kouros.navigation.utils.countryCodeSpeedLimit
import com.kouros.navigation.utils.getSettingsRepository
import com.kouros.navigation.utils.isNumeric
import com.kouros.navigation.utils.location
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.channels.awaitClose
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.callbackFlow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
import org.maplibre.geojson.FeatureCollection
import org.maplibre.geojson.Point
import java.time.LocalDateTime
import java.time.ZoneOffset
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.
@@ -35,6 +61,8 @@ import java.time.ZoneOffset
*/
class NavigationViewModel(private val repository: NavigationRepository) : ViewModel() {
private val overpass = Overpass()
/** LiveData containing the calculated route JSON string */
val route: MutableLiveData<String> by lazy {
MutableLiveData()
@@ -45,6 +73,11 @@ class NavigationViewModel(private val repository: NavigationRepository) : ViewMo
MutableLiveData()
}
/** LiveData containing categorized traffic messages map */
val trafficMessage: MutableLiveData<String> by lazy {
MutableLiveData()
}
/** LiveData containing a preview route JSON string for route preview screens */
val previewRoute: MutableLiveData<String> by lazy {
MutableLiveData()
@@ -80,6 +113,10 @@ class NavigationViewModel(private val repository: NavigationRepository) : ViewMo
MutableLiveData()
}
/** LiveData containing POI elements from Overpass API */
val speedElements = mutableListOf<Elements>()
/** LiveData containing speed camera locations */
val speedCameras: MutableLiveData<List<Elements>> by lazy {
MutableLiveData()
@@ -100,6 +137,22 @@ class NavigationViewModel(private val repository: NavigationRepository) : ViewMo
MutableLiveData()
}
val initialSnapLocation: MutableLiveData<Location> by lazy {
MutableLiveData()
}
val gson: Gson = GsonBuilder().create()
/**
* Retrieves recent places from Preferences as a Flow.
*/
fun recentPlacesFlow(context: Context, location: Location): Flow<Place> = callbackFlow {
for (place in recentPlaces.value!!) {
trySend(place)
}
awaitClose {
}
}
/**
* Loads all recent places from Preferences and calculates distances.
@@ -110,14 +163,17 @@ class NavigationViewModel(private val repository: NavigationRepository) : ViewMo
try {
val settingsRepository = getSettingsRepository(context)
val rp = settingsRepository.recentPlacesFlow.first()
val gson = GsonBuilder().serializeNulls().create()
val places = gson.fromJson(rp, Places::class.java)
val pl = mutableListOf<Place>()
var id: Long = 0
if (rp.isNotEmpty()) {
for (place in places.places) {
if (place.category.equals(Constants.RECENT)
|| place.category.equals(Constants.FAVORITES)) {
if (place.category == Constants.RECENT
|| place.category == FAVORITES
) {
if (place.category == FAVORITES) {
place.favorite = true
}
val plLocation = location(place.longitude, place.latitude)
if (place.latitude != 0.0) {
val distance =
@@ -133,7 +189,9 @@ class NavigationViewModel(private val repository: NavigationRepository) : ViewMo
}
}
}
recentPlaces.postValue(pl.sortedBy { it.distance })
val sortedList = pl.sortedWith(compareByDescending<Place> { it.navigations }
.thenByDescending { it.distance })
recentPlaces.postValue(sortedList)
} catch (e: Exception) {
e.printStackTrace()
}
@@ -147,7 +205,7 @@ class NavigationViewModel(private val repository: NavigationRepository) : ViewMo
fun loadRoute(
context: Context,
currentLocation: Location,
destination: Location,
destination: List<Location>,
carOrientation: Float
) {
viewModelScope.launch(Dispatchers.IO) {
@@ -171,7 +229,11 @@ class NavigationViewModel(private val repository: NavigationRepository) : ViewMo
* Fetches traffic incident data and categorizes by severity.
* Posts categorized traffic map to traffic LiveData.
*/
fun loadTraffic(context: Context, currentLocation: Location, carOrientation: Float) {
fun loadTraffic(
context: Context,
currentLocation: Location,
carOrientation: Float,
) {
viewModelScope.launch(Dispatchers.IO) {
try {
val data = repository.getTraffic(
@@ -181,9 +243,11 @@ class NavigationViewModel(private val repository: NavigationRepository) : ViewMo
)
if (data.isNotEmpty()) {
val trafficData = rebuildTraffic(data)
traffic.postValue(
trafficData
)
if (trafficData.isNotEmpty()) {
traffic.postValue(
trafficData
)
}
}
} catch (e: Exception) {
e.printStackTrace()
@@ -191,33 +255,6 @@ class NavigationViewModel(private val repository: NavigationRepository) : ViewMo
}
}
/**
* Categorizes traffic incidents by type (queuing, stationary, slow, heavy, roadworks).
* @param data Raw traffic GeoJSON string
* @return Map of incident type to GeoJSON FeatureCollection
*/
private fun rebuildTraffic(data: String): Map<String, String> {
val featureCollection = FeatureCollection.fromJson(data)
val incidents = mutableMapOf<String, String>()
val queuing = featureCollection.features()!!
.filter { it.properties()!!.get("events").toString().contains("Queuing traffic") }
incidents["queuing"] = FeatureCollection.fromFeatures(queuing).toJson()
val stationary = featureCollection.features()!!
.filter { it.properties()!!.get("events").toString().contains("Stationary traffic") }
incidents["stationary"] = FeatureCollection.fromFeatures(stationary).toJson()
val slow = featureCollection.features()!!
.filter { it.properties()!!.get("events").toString().contains("Slow traffic") }
incidents["slow"] = FeatureCollection.fromFeatures(slow).toJson()
val heavy = featureCollection.features()!!
.filter { it.properties()!!.get("events").toString().contains("Heavy traffic") }
incidents["heavy"] = FeatureCollection.fromFeatures(heavy).toJson()
val roadworks = featureCollection.features()!!
.filter { it.properties()!!.get("events").toString().contains("Roadworks") }
incidents["roadworks"] = FeatureCollection.fromFeatures(roadworks).toJson()
return incidents
}
/**
* Calculates a preview route for route preview screen.
* Posts the route JSON to previewRoute LiveData.
@@ -234,7 +271,7 @@ class NavigationViewModel(private val repository: NavigationRepository) : ViewMo
repository.getRoute(
context,
currentLocation,
location,
listOf(location),
carOrientation,
getSearchFilter(context)
)
@@ -246,6 +283,39 @@ class NavigationViewModel(private val repository: NavigationRepository) : ViewMo
return previewRoute.value
}
/**
* Categorizes traffic incidents by type (queuing, stationary, slow, heavy, roadworks).
* @param data Raw traffic GeoJSON string
* @return Map of incident type to GeoJSON FeatureCollection
*/
private fun rebuildTraffic(data: String): Map<String, String> {
val featureCollection = FeatureCollection.fromJson(data)
val incidents = mutableMapOf<String, String>()
val queuing = featureCollection.features()!!
.filter { it.properties()!!.get("events").toString().contains("Queuing traffic") }
incidents["queuing"] = buildLineStrings(queuing)
val stationary = featureCollection.features()!!
.filter { it.properties()!!.get("events").toString().contains("Stationary traffic") }
incidents["stationary"] = buildLineStrings(stationary)
val slow = featureCollection.features()!!
.filter { it.properties()!!.get("events").toString().contains("Slow traffic") }
incidents["slow"] = buildLineStrings(slow)
val heavy = featureCollection.features()!!
.filter { it.properties()!!.get("events").toString().contains("Heavy traffic") }
incidents["heavy"] = buildLineStrings(heavy)
val roadworks = featureCollection.features()!!
.filter { it.properties()!!.get("events").toString().contains("Roadworks") }
incidents["roadworks"] = buildLineStrings(roadworks)
val laneClosed = featureCollection.features()!!
.filter { it.properties()!!.get("events").toString().contains("Lane closed") }
incidents["lane"] = buildLineStrings(laneClosed)
val closed = featureCollection.features()!!
.filter { it.properties()!!.get("events").toString().contains("Closed") }
incidents["closed"] = buildPointCollection(closed)
return incidents
}
/**
* Loads device contacts with addresses and converts to Place objects.
@@ -286,7 +356,6 @@ class NavigationViewModel(private val repository: NavigationRepository) : ViewMo
var sortedList: List<SearchResult>
viewModelScope.launch(Dispatchers.IO) {
val placesJson = repository.searchPlaces(search, location)
val gson = GsonBuilder().serializeNulls().create()
val places = gson.fromJson(placesJson, Search::class.java)
val distPlaces = mutableListOf<SearchResult>()
places.forEach {
@@ -311,7 +380,6 @@ class NavigationViewModel(private val repository: NavigationRepository) : ViewMo
viewModelScope.launch(Dispatchers.IO) {
val placesJson = repository.searchPlaces(search, location)
if (placesJson.isNotEmpty()) {
val gson = GsonBuilder().serializeNulls().create()
val places = gson.fromJson(placesJson, Search::class.java)
val distPlaces = mutableListOf<SearchResult>()
places.forEach {
@@ -342,15 +410,25 @@ class NavigationViewModel(private val repository: NavigationRepository) : ViewMo
* Queries Overpass API for nearby amenities of a specific category.
* Posts sorted results to elements LiveData.
*/
fun getAmenities(category: String, location: Location) {
fun getAmenities(
carContext: Context,
category: String,
location: Location,
lastFuelUpdate: Long = 0
) {
viewModelScope.launch(Dispatchers.IO) {
val amenities = Overpass().getAmenities("amenity", category, location, 5.0)
val repository = getSettingsRepository(carContext)
val amenities = overpass.getAmenities("amenity", category, location, 5.0)
val fuelPrices = fuelStations(category, lastFuelUpdate, location, repository)
val distAmenities = mutableListOf<Elements>()
amenities.forEach {
val plLocation =
location(longitude = it.lon, latitude = it.lat)
val distance = plLocation.distanceTo(location)
it.distance = distance.toDouble()
if (category == Constants.FUEL_STATION) {
addFuelPrice(it, fuelPrices, location)
}
distAmenities.add(it)
}
val sortedList = distAmenities.sortedWith(compareBy { it.distance })
@@ -358,23 +436,62 @@ class NavigationViewModel(private val repository: NavigationRepository) : ViewMo
}
}
private suspend fun fuelStations(
category: String,
lastFuelUpdate: Long,
location: Location,
repository: SettingsRepository
): List<Station> {
var fuelPrices = emptyList<Station>()
if (category == Constants.FUEL_STATION) {
val now = System.currentTimeMillis()
if ((now - lastFuelUpdate) > TANKER_KOENIG_DELAY) {
fuelPrices = FuelPrices().getFuelPrices(location, 5)
repository.setLastFuelPrices(System.currentTimeMillis())
repository.setFuelPrices(gson.toJson(Stations(fuelPrices)))
} else {
val fuels = repository.fuelPricesFlow.first()
fuelPrices =
gson.fromJson(fuels, Stations::class.java).stations
}
}
return fuelPrices
}
fun addFuelPrice(fuel: Elements, fuelPrices: List<Station>, location: Location) {
fuelPrices.forEach {
if (fuel.tags.name.equals(it.brand, ignoreCase = true)) {
val plLocation =
location(longitude = it.lng, latitude = it.lat)
val distance = plLocation.distanceTo(location)
if ((distance - fuel.distance).absoluteValue < 30) {
fuel.e10 = it.e10
fuel.e5 = it.e5
fuel.diesel = it.diesel
}
}
}
}
/**
* Queries Overpass API for speed cameras within a radius.
* Posts sorted results to speedCameras LiveData.
*/
fun getSpeedCameras(location: Location, radius: Double) {
viewModelScope.launch(Dispatchers.IO) {
val amenities = Overpass().getAmenities("highway", "speed_camera", location, radius)
val distAmenities = mutableListOf<Elements>()
amenities.forEach {
val plLocation =
location(longitude = it.lon, latitude = it.lat)
val distance = plLocation.distanceTo(location)
it.distance = distance.toDouble()
distAmenities.add(it)
synchronized(this) {
viewModelScope.launch(Dispatchers.IO) {
val amenities = overpass.getAmenities("highway", "speed_camera", location, radius)
val distAmenities = mutableListOf<Elements>()
amenities.forEach {
val plLocation =
location(longitude = it.lon, latitude = it.lat)
val distance = plLocation.distanceTo(location)
it.distance = distance.toDouble()
distAmenities.add(it)
}
val sortedList = distAmenities.sortedWith(compareBy { it.distance })
speedCameras.postValue(sortedList)
}
val sortedList = distAmenities.sortedWith(compareBy { it.distance })
speedCameras.postValue(sortedList)
}
}
@@ -382,22 +499,107 @@ class NavigationViewModel(private val repository: NavigationRepository) : ViewMo
* Queries Overpass API for speed limit on current road using fuzzy matching.
* Posts speed limit to maxSpeed LiveData.
*/
fun getMaxSpeed(location: Location, street: String) {
fun getSpeedLimit(
location: Location,
routeBearing: Float,
countryCode: String,
) {
viewModelScope.launch(Dispatchers.IO) {
val levenshtein = Levenshtein()
val lineString = "${location.latitude},${location.longitude}"
val amenities = Overpass().getAround(10, lineString)
amenities.forEach {
if (it.tags.name != null) {
val distance =
levenshtein.distance(it.tags.name!!, street)
if (distance < 5) {
val speed = it.tags.maxspeed.toInt()
maxSpeed.postValue(speed)
synchronized(this) {
maxSpeed.postValue(calculateSpeedLimit(location, routeBearing, countryCode))
}
}
}
/**
* Queries Overpass API for speed limit on current road using fuzzy matching.
*/
fun calculateSpeedLimit(location: Location, routeBearing: Float, countryCode: String): Int {
var speed = 0
val search = mutableListOf<ElementSearch>()
synchronized(this) {
// 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 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(element, minDistance, streetBearing.absoluteValue)
)
}
}
val result =
search.sortedWith(compareBy<ElementSearch> { it.distance }.thenByDescending { it.bearing })
if (result.isNotEmpty()) {
val element = result.first().element
speed = if (element.tags.maxspeed == "none" && element.tags.highway == "motorway") {
countryCodeSpeedLimit(countryCode)
} else {
if (isNumeric(element.tags.maxspeed)) {
element.tags.maxspeed.toInt()
} else {
0
}
}
}
}
return speed
}
private fun isBearingValid(
element: Elements,
streetBearing: Float,
routeBearing: Float
): Boolean {
return if (element.tags.oneway.isNotEmpty()) {
(streetBearing - routeBearing.absoluteValue) < SPEED_BEARING_DEVIATION
} else {
true
}
}
/**
* Queries Overpass API for speed limit on current road.
* Posts speed elements to speedElements.
*/
fun updateSpeedLimit(
location: Location,
street: String,
roadNumbers: List<String>
) {
viewModelScope.launch(Dispatchers.IO) {
synchronized(this) {
val lineString = "${location.latitude},${location.longitude}"
val elements =
overpass.getSpeedLimit(SPEED_UPDATE_DISTANCE, lineString, street, roadNumbers)
speedElements.clear()
speedElements.addAll(elements)
}
}
}
/**
@@ -431,7 +633,6 @@ class NavigationViewModel(private val repository: NavigationRepository) : ViewMo
viewModelScope.launch(Dispatchers.IO) {
try {
val places = mutableListOf<Place>()
val gson = GsonBuilder().serializeNulls().create()
val settingsRepository = getSettingsRepository(context)
val rp = settingsRepository.recentPlacesFlow.first()
var id: Long = 0
@@ -441,6 +642,7 @@ class NavigationViewModel(private val repository: NavigationRepository) : ViewMo
for (curPlace in recentPlaces) {
if (curPlace.name != place.name || curPlace.category != place.category) {
curPlace.id = id
curPlace.route = ""
places.add(curPlace)
id += 1
}
@@ -448,7 +650,10 @@ class NavigationViewModel(private val repository: NavigationRepository) : ViewMo
}
val current = LocalDateTime.now(ZoneOffset.UTC)
place.lastDate = current.atZone(ZoneOffset.UTC).toEpochSecond()
place.route = ""
place.navigations += 1
places.add(place)
recentPlaces.postValue(places)
settingsRepository.setRecentPlaces(gson.toJson(Places(places)))
} catch (e: Exception) {
e.printStackTrace()
@@ -470,7 +675,6 @@ class NavigationViewModel(private val repository: NavigationRepository) : ViewMo
fun deletePlace(context: Context, place: Place) {
viewModelScope.launch(Dispatchers.IO) {
try {
val gson = GsonBuilder().serializeNulls().create()
val settingsRepository = getSettingsRepository(context)
val rp = settingsRepository.recentPlacesFlow.first()
val places = mutableListOf<Place>()
@@ -479,11 +683,12 @@ class NavigationViewModel(private val repository: NavigationRepository) : ViewMo
gson.fromJson(rp, Places::class.java).places.sortedBy { it.lastDate }
for (curPlace in rPlaces) {
if (curPlace.name != place.name || curPlace.category != place.category) {
curPlace.route = ""
places.add(curPlace)
}
}
settingsRepository.setRecentPlaces(gson.toJson(Places(places)))
recentPlaces.value = places
recentPlaces.postValue(places)
}
} catch (e: Exception) {
e.printStackTrace()
@@ -508,12 +713,11 @@ class NavigationViewModel(private val repository: NavigationRepository) : ViewMo
* Loads recent places as Compose SnapshotStateList.
* @return SnapshotStateList of recent places
*/
fun loadRecentPlace(context: Context): SnapshotStateList<Place?> {
fun loadRecentPlaces(context: Context): SnapshotStateList<Place?> {
val pl = mutableListOf<Place>()
val settingsRepository = getSettingsRepository(context)
val rp = runBlocking { settingsRepository.recentPlacesFlow.first() }
if (rp.isNotEmpty()) {
val gson = GsonBuilder().serializeNulls().create()
val recentPlaces = gson.fromJson(rp, Places::class.java).places.sortedBy { it.lastDate }
for (place in recentPlaces) {
if (place.category == Constants.RECENT) {
@@ -523,4 +727,31 @@ class NavigationViewModel(private val repository: NavigationRepository) : ViewMo
}
return pl.toMutableStateList()
}
/**
* Loads current location nearest street
* and snap to location to street
*/
fun loadCurrentLocation(location: Location) {
viewModelScope.launch(Dispatchers.IO) {
synchronized(this) {
val elements = overpass.getStreet(location)
if (elements.isNotEmpty()) {
val points = mutableListOf<Point>()
elements.first().geometry.forEach {
points.add(Point.fromLngLat(it.lon, it.lat))
}
val snappedLocation = snapLocation(location, points)
if (points.size > 1) {
val bearing = location(
points.first().longitude(),
points.first().latitude()
).bearingPositive(location(points[1].longitude(), points[1].latitude()))
snappedLocation.bearing = bearing
}
initialSnapLocation.postValue(snappedLocation)
}
}
}
}
}
@@ -5,38 +5,61 @@ import android.util.Log
import androidx.car.app.navigation.model.Step
import com.kouros.navigation.data.Constants.MAXIMUM_LOCATION_DISTANCE
import com.kouros.navigation.data.Constants.NEAREST_LOCATION_DISTANCE
import com.kouros.navigation.data.Constants.SPEED_UPDATE_DISTANCE
import com.kouros.navigation.data.Constants.TAG
import com.kouros.navigation.data.StepMatch
import com.kouros.navigation.data.osrm.Waypoints
import com.kouros.navigation.utils.location
import java.util.concurrent.TimeUnit
import kotlin.math.roundToInt
import kotlin.math.absoluteValue
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
fun findStep(location: Location) {
var nearestDistance = MAXIMUM_LOCATION_DISTANCE
var count = 0
var lastDistance = 0F
var increaseDistance = 0
for ((index, step) in routeModel.curLeg.steps.withIndex()) {
count++
var distance = 0F
if (index >= routeModel.navState.route.currentStepIndex) {
for ((wayIndex, waypoint) in step.maneuver.waypoints.withIndex()) {
count++
if (wayIndex >= step.waypointIndex) {
val distance = location.distanceTo(location(waypoint[0], waypoint[1]))
if (distance < nearestDistance) {
distance = location.distanceTo(waypoint)
if (distance < nearestDistance ) {
nearestDistance = distance
routeModel.navState.route.currentStepIndex = step.index
step.waypointIndex = wayIndex
step.wayPointLocation = location(waypoint[0], waypoint[1])
step.wayPointLocation = waypoint
}
}
if (stopSearch(nearestDistance, distance)) {
break
}
if (distance > lastDistance) {
increaseDistance++
}
lastDistance = distance
}
}
if (nearestDistance < NEAREST_LOCATION_DISTANCE) {
if (stopSearch(nearestDistance, distance)) {
break
}
}
}
private fun stopSearch(nearestDistance: Float, distance: Float): Boolean {
return nearestDistance < NEAREST_LOCATION_DISTANCE && distance > NEAREST_LOCATION_DISTANCE * 10
}
fun travelLeftTime(): Double {
var timeLeft = 0.0
// time for next step until end step
@@ -70,19 +93,15 @@ class RouteCalculator(var routeModel: RouteModel) {
fun leftStepDistance(): Double {
val step = routeModel.route.currentStep()
var leftDistance = 0F
for (i in step.waypointIndex..<step.maneuver.waypoints.size - 1) {
val loc1 = location(step.maneuver.waypoints[i][0], step.maneuver.waypoints[i][1])
val loc2 =
location(step.maneuver.waypoints[i + 1][0], step.maneuver.waypoints[i + 1][1])
val locationDistance = loc1.distanceTo(routeModel.navState.lastLocation)
val distance = loc1.distanceTo(loc2)
leftDistance += if (locationDistance < distance) {
locationDistance
} else {
distance
if (step.waypointIndex < step.maneuver.waypoints.size - 1) {
leftDistance = step.maneuver.leftDistance[step.waypointIndex]
val waypointLocation = step.maneuver.waypoints[step.waypointIndex]
if (routeModel.navState.lastLocation.latitude != 0.0) {
val locationDistance = waypointLocation.distanceTo(routeModel.navState.lastLocation)
leftDistance -= locationDistance
}
}
return leftDistance.toDouble()
return leftDistance.absoluteValue.toDouble()
}
/** Returns the left distance in m. */
@@ -107,14 +126,31 @@ class RouteCalculator(var routeModel: RouteModel) {
return nowUtcMillis + timeToDestinationMillis
}
/**
* Updates the speed limit in the view model.
*/
fun updateSpeedLimit(location: Location, viewModel: NavigationViewModel) {
if (routeModel.isNavigating()) {
// speed limit
val distance = lastSpeedLocation.distanceTo(location)
if (distance > 500 || lastSpeedIndex < routeModel.route.currentStepIndex) {
if ((distance > SPEED_UPDATE_DISTANCE * 2) || lastSpeedIndex < routeModel.route.currentStepIndex) {
lastSpeedIndex = routeModel.route.currentStepIndex
lastSpeedLocation = location
viewModel.getMaxSpeed(location, routeModel.route.currentStep().street)
// 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 if (lastLocalSpeedLocation.distanceTo(location) >= NEAREST_LOCATION_DISTANCE) {
lastLocalSpeedLocation = location
viewModel.getSpeedLimit(
location,
routeModel.navState.routeBearing,
routeModel.currentStep.countryCode
)
}
}
}
@@ -1,10 +1,12 @@
package com.kouros.navigation.model
import android.location.Location
import android.util.Log
import androidx.car.app.connection.CarConnection.CONNECTION_TYPE_NATIVE
import androidx.car.app.connection.CarConnection.CONNECTION_TYPE_PROJECTION
import androidx.car.app.navigation.model.Maneuver
import com.kouros.navigation.data.Constants.NEXT_STEP_THRESHOLD
import com.kouros.navigation.data.Constants.TAG
import com.kouros.navigation.data.NavigationState
import com.kouros.navigation.data.Route
import com.kouros.navigation.data.StepData
@@ -76,48 +78,9 @@ open class RouteModel {
navState = navState.copy(lastLocation = navState.currentLocation)
}
fun nextStep(): StepData {
val distanceToNextStep = routeCalculator.leftStepDistance()
val nextStep = navState.route.nextStep(1)
var streetName = nextStep.street
var maneuverType = currentStep.maneuver.type
if (distanceToNextStep < NEXT_STEP_THRESHOLD) {
streetName = nextStep.maneuver.street
maneuverType = nextStep.maneuver.type
}
val maneuverIcon = navState.iconMapper.maneuverIcon(maneuverType)
// Construct and return the final StepData object
return StepData(
instruction = streetName,
street = "",
leftStepDistance = distanceToNextStep,
currentManeuverType = maneuverType,
icon = maneuverIcon,
arrivalTime = routeCalculator.arrivalTime(),
leftDistance = routeCalculator.travelLeftDistance(),
exitNumber = nextStep.maneuver.exit,
message = nextStep.maneuver.message
)
}
private fun currentLanes(): List<Lane> {
var lanes = emptyList<Lane>()
if (navState.route.legs().isNotEmpty()) {
currentStep.intersection.forEach {
if (it.lane.isNotEmpty()) {
val distance =
navState.lastLocation.distanceTo(location(it.location[0], it.location[1]))
if (distance < NEXT_STEP_THRESHOLD) {
lanes = it.lane
return@forEach
}
}
}
}
return lanes
}
/*
* Returns the current step
*/
fun currentStep(): StepData {
val distanceToNextStep = routeCalculator.leftStepDistance()
// Determine the maneuver type and corresponding icon
@@ -144,10 +107,59 @@ open class RouteModel {
leftDistance = routeCalculator.travelLeftDistance(),
lane = currentLanes,
exitNumber = exitNumber,
message = currentStep.maneuver.message
message = currentStep.maneuver.message,
roadNumbers = currentStep.roadNumbers
)
}
/*
* Returns the next step
*/
fun nextStep(): StepData {
val distanceToNextStep = routeCalculator.leftStepDistance()
val nextStep = navState.route.nextStep(1)
var streetName = nextStep.street
var maneuverType = currentStep.maneuver.type
if (distanceToNextStep < NEXT_STEP_THRESHOLD) {
streetName = nextStep.maneuver.street
maneuverType = nextStep.maneuver.type
}
val maneuverIcon = navState.iconMapper.maneuverIcon(maneuverType)
// Construct and return the final StepData object
return StepData(
instruction = streetName,
street = "",
leftStepDistance = distanceToNextStep,
currentManeuverType = maneuverType,
icon = maneuverIcon,
arrivalTime = routeCalculator.arrivalTime(),
leftDistance = routeCalculator.travelLeftDistance(),
exitNumber = nextStep.maneuver.exit,
message = nextStep.maneuver.message
)
}
/*
* Returns the current lanes
*/
private fun currentLanes(): List<Lane> {
var lanes = emptyList<Lane>()
if (navState.route.legs().isNotEmpty()) {
currentStep.intersection.forEach {
if (it.lane.isNotEmpty()) {
val distance =
navState.lastLocation.distanceTo(location(it.location[0], it.location[1]))
if (distance < NEXT_STEP_THRESHOLD) {
lanes = it.lane
return@forEach
}
}
}
}
return lanes
}
/**
* Checks for navigating
*/
@@ -158,7 +170,7 @@ open class RouteModel {
/**
* Checks for arrival
*/
fun isArrival(): Boolean {
fun isManeuverArrival(): Boolean {
return navState.maneuverType == Maneuver.TYPE_DESTINATION
|| navState.maneuverType == Maneuver.TYPE_DESTINATION_LEFT
|| navState.maneuverType == Maneuver.TYPE_DESTINATION_RIGHT
@@ -1,10 +1,7 @@
package com.kouros.navigation.model
import androidx.datastore.preferences.core.edit
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.kouros.navigation.data.datastore.DataStoreManager.Companion.dataStore
import com.kouros.navigation.data.datastore.DataStoreManager.PreferencesKeys
import com.kouros.navigation.repository.SettingsRepository
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.stateIn
@@ -102,6 +99,24 @@ class SettingsViewModel(private val repository: SettingsRepository) : ViewModel(
0
)
val alternativeRoutes = repository.alternativeRoutesFlow.stateIn(
viewModelScope,
SharingStarted.WhileSubscribed(5_000),
false
)
val lastFuelPrices = repository.lastFuelPricesFlow.stateIn(
viewModelScope,
SharingStarted.WhileSubscribed(5_000),
0
)
val fuelPrices = repository.fuelPricesFlow.stateIn(
viewModelScope,
SharingStarted.WhileSubscribed(5_000),
0
)
fun onShow3DChanged(enabled: Boolean) {
viewModelScope.launch { repository.setShow3D(enabled) }
}
@@ -159,4 +174,15 @@ class SettingsViewModel(private val repository: SettingsRepository) : ViewModel(
viewModelScope.launch { repository.setEngineType(mode) }
}
fun onAlternativeRoutes(enabled: Boolean) {
viewModelScope.launch { repository.setAlternativeRoutes(enabled) }
}
fun onLastFuelPricesChanged(lastFuelPrices: Long) {
viewModelScope.launch { repository.setLastFuelPrices(lastFuelPrices) }
}
fun onFuelPricesChanged(fuelPrices: String) {
viewModelScope.launch { repository.setFuelPrices(fuelPrices) }
}
}
@@ -1,6 +1,8 @@
package com.kouros.navigation.repository
import android.util.Log
import com.kouros.navigation.data.datastore.DataStoreManager
import com.kouros.navigation.data.fuel.FuelPrices
import kotlinx.coroutines.flow.Flow
class SettingsRepository(
@@ -50,6 +52,16 @@ class SettingsRepository(
val engineTypeFlow: Flow<Int> =
dataStoreManager.engineTypeFlow
val alternativeRoutesFlow: Flow<Boolean> =
dataStoreManager.alternativeRoutesFlow
val lastFuelPricesFlow: Flow<Long> =
dataStoreManager.lastFuelPricesFlow
val fuelPricesFlow: Flow<String> =
dataStoreManager.fuelPricesFlow
suspend fun setShow3D(enabled: Boolean) {
dataStoreManager.setShow3D(enabled)
}
@@ -109,4 +121,16 @@ class SettingsRepository(
suspend fun setEngineType(mode: Int) {
dataStoreManager.setEngineType(mode)
}
suspend fun setAlternativeRoutes(enabled: Boolean) {
dataStoreManager.setAlternativeRoutes(enabled)
}
suspend fun setLastFuelPrices(lastFuelPrices: Long) {
dataStoreManager.setLastFuelPrices(lastFuelPrices)
}
suspend fun setFuelPrices(fuelPrices: String) {
dataStoreManager.setFuelPrices(fuelPrices)
}
}
@@ -4,22 +4,29 @@ import android.location.Location
import kotlinx.serialization.json.buildJsonObject
import kotlinx.serialization.json.put
import org.maplibre.geojson.FeatureCollection
import org.maplibre.geojson.LineString
import org.maplibre.geojson.Point
import org.maplibre.spatialk.geojson.Feature
import org.maplibre.spatialk.geojson.dsl.addFeature
import org.maplibre.spatialk.geojson.dsl.buildFeatureCollection
import org.maplibre.spatialk.geojson.dsl.buildLineString
import org.maplibre.spatialk.geojson.dsl.buildMultiPoint
import org.maplibre.spatialk.geojson.toJson
import org.maplibre.turf.TurfMeasurement
import org.maplibre.turf.TurfMisc
import java.lang.Math.toDegrees
import java.lang.Math.toRadians
import kotlin.math.asin
import kotlin.math.atan2
import kotlin.math.cos
import kotlin.math.pow
import kotlin.math.sin
object GeoUtils {
fun snapLocation(location: Location, stepCoordinates: List<Point>) : Location {
const val EARTH_RADIUS = 6371.0 // in km
fun snapLocation(location: Location, stepCoordinates: List<Point>): Location {
val newLocation = Location(location)
val oldPoint = Point.fromLngLat(location.longitude, location.latitude)
if (stepCoordinates.size > 1) {
@@ -34,7 +41,7 @@ object GeoUtils {
return newLocation
}
fun decodePolyline(encoded: String, precision: Int = 6): List<List<Double>> {
fun decodePolyline(encoded: String, precision: Int = 6): List<List<Double>> {
val factor = 10.0.pow(precision)
var lat = 0
var lng = 0
@@ -91,18 +98,20 @@ object GeoUtils {
}
fun createLineStringCollection(lineCoordinates: List<List<Double>>): String {
// return createPointCollection(lineCoordinates, "Route")
//return createPointCollection(lineCoordinates, "Route")
val lineString = buildLineString {
lineCoordinates.forEach {
add(org.maplibre.spatialk.geojson.Point(
it[0],
it[1]
))
add(
org.maplibre.spatialk.geojson.Point(
it[0],
it[1]
)
)
}
}
val feature = Feature(lineString, null)
val featureCollection = org.maplibre.spatialk.geojson.FeatureCollection(feature)
return featureCollection.toJson()
return featureCollection.toJson()
}
fun createPointCollection(lineCoordinates: List<List<Double>>, category: String): String {
@@ -114,34 +123,153 @@ object GeoUtils {
}
}
}
return featureCollection.toJson()
return featureCollection.toJson()
}
fun createStartCollection(geoJson: String): String {
val featureCollection = FeatureCollection.fromJson(geoJson)
val geometry = featureCollection.features()!!.first().geometry()
val coordinates = (geometry as LineString)
val first = coordinates.coordinates().first()
val points = createPointCollection(
listOf(listOf(first.coordinates()[0], first.coordinates()[1])), "End"
)
return points
}
fun createPointCollection(geoJson: String): String {
val featureCollection = FeatureCollection.fromJson(geoJson)
val geometry = featureCollection.features()!!.first().geometry()
val coordinates = (geometry as LineString)
val last = coordinates.coordinates().last()
val points = createPointCollection(
listOf(listOf(last.coordinates()[0], last.coordinates()[1])), "End"
)
return points
}
/**
* Calculate the lat and len of a square around a point.
* @return latMin, latMax, lngMin, lngMax
*/
fun calculateSquareRadius(lat: Double, lng: Double, radius: Double): String {
val earthRadius = 6371.0 // earth radius in km
val latMin = lat - toDegrees(radius / earthRadius)
val latMax = lat + toDegrees(radius / earthRadius)
val lngMin = lng - toDegrees(radius / earthRadius / cos(toRadians(lat)))
val lngMax = lng + toDegrees(radius / earthRadius / cos(toRadians(lat)))
val latMin = lat - toDegrees(radius / EARTH_RADIUS)
val latMax = lat + toDegrees(radius / EARTH_RADIUS)
val lngMin = lng - toDegrees(radius / EARTH_RADIUS / cos(toRadians(lat)))
val lngMax = lng + toDegrees(radius / EARTH_RADIUS / cos(toRadians(lat)))
return "$lngMin,$latMin,$lngMax,$latMax"
}
fun getBoundingBox(
lat: Double,
lon: Double,
radius: Double
): String {
val earthRadius = 6371.0
val maxLat = lat + toDegrees(radius / earthRadius)
val minLat = lat - toDegrees(radius / earthRadius)
val maxLon = lon + toDegrees(radius / earthRadius / cos(toRadians(lat)))
val minLon = lon - toDegrees(radius / earthRadius / cos(toRadians(lat)))
val maxLat = lat + toDegrees(radius / EARTH_RADIUS)
val minLat = lat - toDegrees(radius / EARTH_RADIUS)
val maxLon = lon + toDegrees(radius / EARTH_RADIUS / cos(toRadians(lat)))
val minLon = lon - toDegrees(radius / EARTH_RADIUS / cos(toRadians(lat)))
return "$minLat,$minLon,$maxLat,$maxLon"
}
fun isLocationInBoundingBox(
bottomLeftLat: Double,
bottomLeftLon: Double,
topRightLat: Double,
topRightLon: Double,
location: Location
): Boolean {
val isInside =
location.latitude in bottomLeftLat..topRightLat
&& location.longitude >= bottomLeftLon
&& location.longitude <= topRightLon
return isInside
}
fun buildLineStrings(features: List<org.maplibre.geojson.Feature>): String {
return buildFeatureCollection {
features.forEach {
val movedCoordinates = arrayListOf<org.maplibre.spatialk.geojson.Point>()
val coordinates = it.geometry() as LineString
val geo = coordinates.coordinates()
var bearing = 0.0F
for (index in 0..<geo.size) {
val current = location(geo[index].longitude(), geo[index].latitude())
bearing = if (index < geo.size - 1) {
val next =
location(geo[index + 1].longitude(), geo[index + 1].latitude())
current.bearingPositive(next)
} else {
bearing
}
val point = movePoint(
current.latitude,
current.longitude,
bearingDegrees = bearing + 90.0
)
movedCoordinates.add(point)
}
val lineString = buildLineString {
movedCoordinates.forEach { ft ->
add(
ft
)
}
}
addFeature {
geometry = lineString
properties = buildJsonObject { null }
}
}
}.toJson()
}
fun buildPointCollection(features: List<org.maplibre.geojson.Feature>): String {
return buildFeatureCollection {
features.forEach {
val movedCoordinates = arrayListOf<org.maplibre.spatialk.geojson.Point>()
val coordinates = it.geometry() as LineString
val geo = coordinates.coordinates()
var point = org.maplibre.spatialk.geojson.Point(geo.first().longitude(), geo.first().latitude())
movedCoordinates.add(point)
point = org.maplibre.spatialk.geojson.Point(geo.last().longitude(), geo.last().latitude())
movedCoordinates.add(point)
val multiPoint = buildMultiPoint {
movedCoordinates.forEach { ft ->
add(
ft
)
}
}
addFeature {
geometry = multiPoint
properties = buildJsonObject { null }
}
}
}.toJson()
}
fun movePoint(
latitude: Double,
longitude: Double,
distanceMeters: Double = 5.0,
bearingDegrees: Double = 0.0
): org.maplibre.spatialk.geojson.Point {
val radius = EARTH_RADIUS * 1000 // meters
val bearingRadians = toRadians(bearingDegrees)
val lat1 = toRadians(latitude)
val lon1 = toRadians(longitude)
val lat2 = asin(
sin(lat1) * cos(distanceMeters / radius) +
cos(lat1) * sin(distanceMeters / radius) * cos(bearingRadians)
)
val lon2 = lon1 + atan2(
sin(bearingRadians) * sin(distanceMeters / radius) * cos(lat1),
cos(distanceMeters / radius) - sin(lat1) * sin(lat2)
)
return org.maplibre.spatialk.geojson.Point(toDegrees(lon2), toDegrees(lat2))
}
}
@@ -25,10 +25,11 @@ class Levenshtein {
* @param limit the maximum result to compute before stopping, terminating calculation early.
* @return the computed Levenshtein distance.
*/
fun distance(first: CharSequence, second: CharSequence, limit: Int = Int.MAX_VALUE): Int {
fun distance(first: CharSequence, second: CharSequence, countryCode: String, limit: Int = Int.MAX_VALUE): Int {
if (countryCode == "GRC") return 0
if (first == second) return 0
if (first.isEmpty()) return second.length
if (second.isEmpty()) return first.length
if (first.isEmpty()) return 0
if (second.isEmpty()) return 0
// initial costs is the edit distance from an empty string, which corresponds to the characters to inserts.
// the array size is : length + 1 (empty string)
@@ -3,11 +3,10 @@ package com.kouros.navigation.utils
import android.content.Context
import android.location.Location
import android.location.LocationManager
import android.util.Log
import androidx.car.app.model.Distance
import com.kouros.navigation.data.Constants.TAG
import com.kouros.navigation.data.Constants.TILT
import com.kouros.navigation.data.RouteEngine
import com.kouros.navigation.data.ViewStyle
import com.kouros.navigation.data.osrm.OsrmRepository
import com.kouros.navigation.data.tomtom.TomTomRepository
import com.kouros.navigation.data.valhalla.ValhallaRepository
@@ -28,7 +27,9 @@ import kotlin.math.cos
import kotlin.math.ln
import kotlin.math.pow
import kotlin.math.roundToInt
import kotlin.ranges.contains
import kotlin.time.Duration
import kotlin.time.Duration.Companion.milliseconds
import kotlin.time.Duration.Companion.seconds
import kotlin.time.DurationUnit
import kotlin.time.toDuration
@@ -48,18 +49,20 @@ object NavigationUtils {
}
fun calculateZoom(speed: Double?): Double {
val zoom = 17.0
if (speed == null) {
return 17.0
return zoom
}
val speedKmh = (speed * 3.6).toInt()
val zoom = when (speedKmh) {
in 0..10 -> 17.0
in 11..30 -> 17.5
in 31..65 -> 17.0
in 66..70 -> 16.5
else -> 16.0
return when (speedKmh) {
in 0..10 -> zoom + 1.0
in 11..30 -> zoom + 0.5
in 31..65 -> zoom
in 66..70 -> zoom - 0.5
in 71..90 -> zoom - 1.0
in 91..100 -> zoom - 2.0
else -> zoom - 3.0
}
return zoom
}
fun previewZoom(centerLocation: Location, previewDistance: Double): Double {
@@ -92,24 +95,27 @@ fun calculateZoomFromBoundingBox(centerLocation: Location, previewDistance: Doub
}
fun calculateTilt(newZoom: Double, tilt: Double): Double =
if (newZoom < 13) {
0.0
} else {
if (tilt == 0.0) {
TILT
fun calculateTilt(viewStyle: ViewStyle, newZoom: Double, tilt: Double): Double =
if (viewStyle == ViewStyle.VIEW) {
if (newZoom < 13) {
0.0
} else {
tilt
if (tilt == 0.0) {
TILT
} else {
tilt
}
}
} else {
return 0.0
}
fun bearing(fromLocation: Location, toLocation: Location, oldBearing: Double): Double {
fun bearingPositive(fromLocation: Location, toLocation: Location, oldBearing: Double): Double {
val distance = fromLocation.distanceTo(toLocation)
if (distance < 1.0) {
return oldBearing
}
val bearing = fromLocation.bearingTo(toLocation).toInt().toDouble()
return bearing
return fromLocation.bearingPositive(toLocation).toInt().toDouble()
}
fun location(longitude: Double, latitude: Double): Location {
@@ -119,6 +125,10 @@ fun location(longitude: Double, latitude: Double): Location {
return location
}
fun Location.bearingPositive(locationTo: Location): Float {
return (this.bearingTo (locationTo) + 360) % 360
}
fun formatDateTime(time: Long): String {
val dateFormatter = DateTimeFormatter.ofLocalizedTime(FormatStyle.SHORT)
val dateTime = LocalDateTime.ofEpochSecond(time / 1000, 0, ZoneOffset.UTC)
@@ -131,23 +141,29 @@ fun Double.round(numFractionDigits: Int): Double {
return (this * factor).roundToInt() / factor
}
fun isNumeric(toCheck: String): Boolean {
val regex = "-?[0-9]+(\\.[0-9]+)?".toRegex()
return toCheck.matches(regex)
}
fun duration(
preview: Boolean,
viewStyle: ViewStyle,
bearing: Double,
lastBearing: Double,
lastLocationUpdate: LocalDateTime
): Duration {
if (preview) {
return 3.seconds
if (viewStyle == ViewStyle.PREVIEW ||
viewStyle == ViewStyle.AMENITY_VIEW) {
return 100.milliseconds
}
val cameraDuration = if ((lastBearing - bearing).absoluteValue > 20.0) {
2.seconds
} else {
val updateDuration = java.time.Duration.between(LocalDateTime.now(), lastLocationUpdate)
if (updateDuration.toMillis().absoluteValue < 1000) {
if (updateDuration.toMillis().absoluteValue !in 1000..2000) {
2.seconds
} else {
((updateDuration!!.toMillis().absoluteValue * 1.2).toDuration(DurationUnit.MILLISECONDS))
((updateDuration!!.toMillis().absoluteValue * 1.8).toDuration(DurationUnit.MILLISECONDS))
}
}
return cameraDuration
@@ -182,3 +198,11 @@ fun formattedDistance(distanceMode: Int, distance: Double): Pair<Double, Int> {
}
return Pair(currentDistance, displayUnit)
}
fun countryCodeSpeedLimit(countryCode: String) : Int {
return when (countryCode) {
"DEU", "FRA", "AUT", "GRE", "NLD", "ITA", "SLO", "SVK", "CZE" -> 130
"POL", "BEL", "ESP", "PRT", "BGR", "HUN", "FIN" -> 120
else -> 100
}
}

Some files were not shown because too many files have changed in this diff Show More