Before TomTom Routing

This commit is contained in:
Dimitris
2026-01-29 12:13:37 +01:00
parent 7db7cba4fb
commit eac5b56bcb
51 changed files with 5825 additions and 212 deletions

View File

@@ -30,21 +30,18 @@ import com.kouros.navigation.car.navigation.RouteCarModel
import com.kouros.navigation.car.screen.NavigationScreen
import com.kouros.navigation.car.screen.RequestPermissionScreen
import com.kouros.navigation.car.screen.SearchScreen
import com.kouros.navigation.data.Constants
import com.kouros.navigation.data.Constants.CAR_LOCATION
import com.kouros.navigation.data.Constants.MAXIMAL_ROUTE_DEVIATION
import com.kouros.navigation.data.Constants.MAXIMAL_SNAP_CORRECTION
import com.kouros.navigation.data.Constants.TAG
import com.kouros.navigation.data.RouteEngine
import com.kouros.navigation.data.osrm.OsrmRepository
import com.kouros.navigation.data.tomtom.TomTomRepository
import com.kouros.navigation.data.valhalla.ValhallaRepository
import com.kouros.navigation.model.BaseStyleModel
import com.kouros.navigation.model.ViewModel
import com.kouros.navigation.utils.GeoUtils.snapLocation
import com.kouros.navigation.utils.NavigationUtils.getBooleanKeyValue
import com.kouros.navigation.utils.NavigationUtils.getIntKeyValue
import com.kouros.navigation.utils.NavigationUtils.getViewModel
import org.maplibre.compose.style.BaseStyle
class NavigationSession : Session(), NavigationScreen.Listener {
@@ -93,8 +90,6 @@ class NavigationSession : Session(), NavigationScreen.Listener {
lateinit var navigationViewModel: ViewModel
lateinit var baseStyle: BaseStyle.Json
val carLocationListener: OnCarDataAvailableListener<CarHardwareLocation?> =
OnCarDataAvailableListener { data ->
if (data.location.status == CarValue.STATUS_SUCCESS) {
@@ -129,21 +124,21 @@ class NavigationSession : Session(), NavigationScreen.Listener {
fun onRoutingEngineStateUpdated(routeEngine : Int) {
navigationViewModel = when (routeEngine) {
RouteEngine.VALHALLA.ordinal -> ViewModel(ValhallaRepository())
else -> ViewModel(OsrmRepository())
RouteEngine.OSRM.ordinal -> ViewModel(OsrmRepository())
else -> ViewModel(TomTomRepository())
}
}
override fun onCreateScreen(intent: Intent): Screen {
navigationViewModel = getViewModel(carContext)
navigationViewModel.routingEngine.observe(this, ::onRoutingEngineStateUpdated)
routeModel = RouteCarModel()
val darkMode = getIntKeyValue(carContext, Constants.DARK_MODE_SETTINGS)
baseStyle = BaseStyleModel().readStyle(carContext, darkMode, carContext.isDarkMode)
surfaceRenderer = SurfaceRenderer(carContext, lifecycle, routeModel, baseStyle)
surfaceRenderer = SurfaceRenderer(carContext, lifecycle, routeModel)
navigationScreen =
NavigationScreen(carContext, surfaceRenderer, routeModel, this, navigationViewModel)

View File

@@ -29,10 +29,13 @@ import com.kouros.navigation.car.map.cameraState
import com.kouros.navigation.car.map.getPaddingValues
import com.kouros.navigation.car.map.rememberBaseStyle
import com.kouros.navigation.car.navigation.RouteCarModel
import com.kouros.navigation.data.Constants
import com.kouros.navigation.data.Constants.ROUTING_ENGINE
import com.kouros.navigation.data.Constants.homeVogelhart
import com.kouros.navigation.data.ObjectBox
import com.kouros.navigation.data.RouteEngine
import com.kouros.navigation.data.tomtom.TrafficData
import com.kouros.navigation.model.BaseStyleModel
import com.kouros.navigation.model.RouteModel
import com.kouros.navigation.utils.NavigationUtils.getIntKeyValue
import com.kouros.navigation.utils.bearing
@@ -49,8 +52,7 @@ import org.maplibre.spatialk.geojson.Position
class SurfaceRenderer(
private var carContext: CarContext, lifecycle: Lifecycle,
private var routeModel: RouteCarModel,
private var baseStyle: BaseStyle.Json
private var routeModel: RouteCarModel
) : DefaultLifecycleObserver {
var lastLocation = location(0.0, 0.0)
@@ -70,14 +72,20 @@ class SurfaceRenderer(
var height = 0
var lastBearing = 0.0
val routeData = MutableLiveData("")
val trafficData = MutableLiveData(emptyMap<String, String>())
val speedCamerasData = MutableLiveData("")
val speed = MutableLiveData(0F)
lateinit var centerLocation: Location
var viewStyle = ViewStyle.VIEW
lateinit var centerLocation: Location
var previewDistance = 0.0
lateinit var mapView: ComposeView
var tilt = 55.0
var countDownTimerActive = false
val style: MutableLiveData<BaseStyle> by lazy {
MutableLiveData()
}
val mSurfaceCallback: SurfaceCallback = object : SurfaceCallback {
lateinit var lifecycleOwner: CustomLifecycleOwner
@@ -160,6 +168,7 @@ class SurfaceRenderer(
init {
lifecycle.addObserver(this)
speed.value = 0F
}
fun onConnectionStateUpdated(connectionState: Int) {
@@ -170,16 +179,24 @@ class SurfaceRenderer(
}
}
fun onBaseStyleStateUpdated(style: BaseStyle) {
}
@Composable
fun MapView() {
//println("DarkMode ${carContext.isDarkMode}")
val darkMode = getIntKeyValue(carContext, Constants.DARK_MODE_SETTINGS)
val baseStyle = BaseStyleModel().readStyle(carContext, darkMode, carContext.isDarkMode)
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 paddingValues = getPaddingValues(height, viewStyle)
val cameraState = cameraState(paddingValues, position, tilt)
val rememberBaseStyle = rememberBaseStyle(baseStyle)
MapLibre(carContext, cameraState, rememberBaseStyle, route, viewStyle, speedCameras)
MapLibre(carContext, cameraState, rememberBaseStyle, route, traffic, viewStyle, speedCameras)
ShowPosition(cameraState, position, paddingValues)
}
@@ -217,6 +234,7 @@ class SurfaceRenderer(
override fun onCreate(owner: LifecycleOwner) {
CarConnection(carContext).type.observe(owner, ::onConnectionStateUpdated)
style.observe(owner, :: onBaseStyleStateUpdated)
Log.i(TAG, "SurfaceRenderer created")
carContext.getCarService(AppManager::class.java)
.setSurfaceCallback(mSurfaceCallback)
@@ -288,6 +306,10 @@ class SurfaceRenderer(
viewStyle = ViewStyle.VIEW
}
fun setTrafficData(traffic: Map<String, String> ) {
trafficData.value = traffic as MutableMap<String, String>?
}
fun setPreviewRouteData(routeModel: RouteModel) {
viewStyle = ViewStyle.PREVIEW
with(routeModel) {

View File

@@ -2,6 +2,7 @@ package com.kouros.navigation.car.map
import android.content.Context
import android.location.Location
import androidx.car.app.connection.CarConnection
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.PaddingValues
@@ -10,18 +11,15 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.material3.Icon
import androidx.compose.runtime.Composable
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.scale
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.drawscope.scale
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.drawText
@@ -34,19 +32,21 @@ import com.kouros.navigation.car.ViewStyle
import com.kouros.navigation.data.Constants
import com.kouros.navigation.data.Constants.SHOW_THREED_BUILDING
import com.kouros.navigation.data.NavigationColor
import com.kouros.navigation.data.ObjectBox
import com.kouros.navigation.data.RouteColor
import com.kouros.navigation.data.SpeedColor
import com.kouros.navigation.model.BaseStyleModel
import com.kouros.navigation.data.tomtom.TrafficData
import com.kouros.navigation.utils.NavigationUtils.getBooleanKeyValue
import com.kouros.navigation.utils.NavigationUtils.getIntKeyValue
import org.maplibre.compose.camera.CameraPosition
import org.maplibre.compose.camera.CameraState
import org.maplibre.compose.camera.rememberCameraState
import org.maplibre.compose.expressions.ast.Expression
import org.maplibre.compose.expressions.dsl.const
import org.maplibre.compose.expressions.dsl.exponential
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.layers.Anchor
import org.maplibre.compose.layers.FillLayer
import org.maplibre.compose.layers.LineLayer
@@ -92,10 +92,10 @@ fun MapLibre(
cameraState: CameraState,
baseStyle: BaseStyle.Json,
route: String?,
traffic: Map<String, String> ?,
viewStyle: ViewStyle,
speedCameras: String? = ""
) {
MaplibreMap(
options = MapOptions(
ornamentOptions =
@@ -111,7 +111,7 @@ fun MapLibre(
if (viewStyle == ViewStyle.AMENITY_VIEW) {
AmenityLayer(route)
} else {
RouteLayer(route)
RouteLayer(route, traffic!!)
}
SpeedCameraLayer(speedCameras)
}
@@ -121,7 +121,7 @@ fun MapLibre(
}
@Composable
fun RouteLayer(routeData: String?) {
fun RouteLayer(routeData: String?, trafficData: Map<String, String>) {
if (routeData != null && routeData.isNotEmpty()) {
val routes = rememberGeoJsonSource(GeoJsonData.JsonString(routeData))
LineLayer(
@@ -153,6 +153,49 @@ fun RouteLayer(routeData: String?) {
),
)
}
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.8.dp),
7 to const(2.0.dp),
20 to const(24.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.7.dp),
7 to const(1.75.dp),
20 to const(22.dp),
),
)
}
}
fun trafficColor(key: String): Expression<ColorValue> {
when (key) {
"queuing" -> return const(Color(0xFFD24417))
"stationary" -> return const(Color(0xFFFF0000))
"heavy" -> return const(Color(0xFF6B0404))
"slow" -> return const(Color(0xFFC41F1F))
"roadworks" -> return const(Color(0xFF7A631A))
}
return const(Color.Blue)
}
@Composable
@@ -270,7 +313,7 @@ private fun CurrentSpeed(
curSpeed: Float,
maxSpeed: Int
) {
val radius = 32
val radius = 34
Box(
modifier = Modifier
.padding(
@@ -336,7 +379,7 @@ private fun MaxSpeed(
height: Int,
maxSpeed: Int,
) {
val radius = 20
val radius = 24
Box(
modifier = Modifier
.padding(

View File

@@ -16,6 +16,7 @@
package com.kouros.navigation.car.navigation
import android.text.SpannableString
import android.text.Spanned
import androidx.annotation.DrawableRes
import androidx.annotation.StringRes
import androidx.car.app.AppManager
@@ -26,22 +27,25 @@ import androidx.car.app.model.Alert
import androidx.car.app.model.AlertCallback
import androidx.car.app.model.CarColor
import androidx.car.app.model.CarIcon
import androidx.car.app.model.CarIconSpan
import androidx.car.app.model.CarText
import androidx.car.app.model.DateTimeWithZone
import androidx.car.app.model.Distance
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.core.graphics.drawable.IconCompat
import com.kouros.data.R
import com.kouros.navigation.data.StepData
import com.kouros.navigation.model.RouteModel
import org.maplibre.compose.expressions.dsl.step
import java.util.Collections
import java.util.TimeZone
import java.util.concurrent.TimeUnit
import kotlin.text.trim
/** A class that provides models for the routing demos. */
class RouteCarModel() : RouteModel() {
@@ -50,13 +54,18 @@ class RouteCarModel() : RouteModel() {
fun currentStep(carContext: CarContext): Step {
val stepData = currentStep()
val currentStepCueWithImage: SpannableString =
createString(stepData.instruction)
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) {
maneuver.setRoundaboutExitNumber(stepData.exitNumber)
}
val step =
Step.Builder(currentStepCueWithImage)
.setManeuver(
Maneuver.Builder(stepData.currentManeuverType)
.setIcon(createCarIcon(carContext, stepData.icon))
.build()
maneuver.build()
)
if (destination.street != null) {
step.setRoad(destination.street!!)
@@ -72,12 +81,16 @@ class RouteCarModel() : RouteModel() {
val stepData = nextStep()
val currentStepCueWithImage: SpannableString =
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) {
maneuver.setRoundaboutExitNumber(stepData.exitNumber)
}
val step =
Step.Builder(currentStepCueWithImage)
.setManeuver(
Maneuver.Builder(stepData.currentManeuverType)
.setIcon(createCarIcon(carContext, stepData.icon))
.build()
maneuver.build()
)
.build()
return step
@@ -109,8 +122,8 @@ class RouteCarModel() : RouteModel() {
timeToDestinationMillis
)
)
.setRemainingTimeColor(CarColor.YELLOW)
.setRemainingDistanceColor(CarColor.RED)
.setRemainingTimeColor(CarColor.GREEN)
.setRemainingDistanceColor(CarColor.BLUE)
if (travelMessage.isNotEmpty()) {
travelBuilder.setTripIcon(createCarIcon(carContext, R.drawable.warning_24px))
@@ -203,6 +216,18 @@ class RouteCarModel() : RouteModel() {
}
}
private fun createStringWithIcon(
carContext: CarContext,
text: String,
@DrawableRes iconRes: Int
): SpannableString {
val start = 0
val end = text.length
val span = CarIconSpan.create(createCarIcon(carContext, iconRes), CarIconSpan.ALIGN_CENTER)
val spannableString = SpannableString(text)
spannableString.setSpan(span, start, end, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE)
return spannableString
}
fun createString(
text: String
): SpannableString {
@@ -222,7 +247,7 @@ class RouteCarModel() : RouteModel() {
return CarIcon.Builder(iconCompat).build()
}
fun showSpeedCamera(carContext: CarContext, distance: Double, maxSpeed: String?) {
fun showSpeedCamera(carContext: CarContext, distance: Double, maxSpeed: String) {
carContext.getCarService<AppManager?>(AppManager::class.java)
.showAlert(createAlert(carContext, distance, maxSpeed, createCarIcon(carContext, R.drawable.speed_camera_24px)))
}

View File

@@ -31,10 +31,16 @@ import com.kouros.navigation.data.Constants.DESTINATION_ARRIVAL_DISTANCE
import com.kouros.navigation.data.Place
import com.kouros.navigation.data.nominatim.SearchResult
import com.kouros.navigation.data.overpass.Elements
import com.kouros.navigation.data.tomtom.Features
import com.kouros.navigation.data.tomtom.Geometry
import com.kouros.navigation.model.ViewModel
import com.kouros.navigation.utils.GeoUtils
import com.kouros.navigation.utils.location
import java.time.LocalDateTime
import java.time.Period
import java.time.ZoneOffset
import kotlin.math.absoluteValue
import kotlin.time.Duration
class NavigationScreen(
carContext: CarContext,
@@ -55,6 +61,7 @@ class NavigationScreen(
var recentPlace = Place()
var navigationType = NavigationType.VIEW
var lastTrafficDate = LocalDateTime.of(1960, 6, 21, 0, 0)
val observer = Observer<String> { route ->
if (route.isNotEmpty()) {
navigationType = NavigationType.NAVIGATION
@@ -71,6 +78,10 @@ class NavigationScreen(
invalidate()
}
}
val trafficObserver = Observer<Map<String, String> > { traffic ->
surfaceRenderer.setTrafficData(traffic)
invalidate()
}
val placeObserver = Observer<SearchResult> { searchResult ->
val place = Place(
@@ -101,6 +112,7 @@ class NavigationScreen(
init {
viewModel.route.observe(this, observer)
viewModel.traffic.observe(this, trafficObserver);
viewModel.recentPlace.observe(this, recentObserver)
viewModel.placeLocation.observe(this, placeObserver)
viewModel.speedCameras.observe(this, speedObserver)
@@ -471,7 +483,14 @@ class NavigationScreen(
}
fun updateTrip(location: Location) {
updateSpeedCamera(surfaceRenderer.lastLocation)
val current = LocalDateTime.now(ZoneOffset.UTC)
val duration = java.time.Duration.between(current, lastTrafficDate)
if (duration.abs().seconds > 360) {
lastTrafficDate = current
viewModel.loadTraffic(carContext, location, surfaceRenderer.carOrientation)
}
//updateTraffic(location)
updateSpeedCamera(location)
with(routeModel) {
updateLocation(location, viewModel)
if ((maneuverType == Maneuver.TYPE_DESTINATION
@@ -512,11 +531,14 @@ class NavigationScreen(
}
val sortedList = updatedCameras.sortedWith(compareBy { it.distance })
val camera = sortedList.first()
val bearingSpeedCamera = location.bearingTo(location(camera.lon!!, camera.lat!!))
val bearingRoute = surfaceRenderer.lastLocation.bearingTo(location)
val bearingSpeedCamera = if (camera.tags.direction != null) {
camera.tags.direction!!.toFloat()
} else {
location.bearingTo(location(camera.lon, camera.lat)).absoluteValue
}
if (camera.distance < 80) {
if ((bearingSpeedCamera.absoluteValue - bearingRoute.absoluteValue).absoluteValue < 20.0) {
if ((bearingSpeedCamera - bearingRoute.absoluteValue).absoluteValue < 15.0) {
routeModel.showSpeedCamera(carContext, camera.distance, camera.tags.maxspeed)
}
}

View File

@@ -190,7 +190,6 @@ class RoutePreviewScreen(
private fun createRouteText(index: Int): CarText {
val time = routeModel.route.routes[index].summary.duration
println("Duration $time")
val length =
BigDecimal(routeModel.route.routes[index].summary.distance).setScale(
1,

View File

@@ -23,7 +23,7 @@ import com.kouros.navigation.utils.NavigationUtils.setBooleanKeyValue
import com.kouros.navigation.utils.NavigationUtils.setIntKeyValue
class RoutingSettings(private val carContext: CarContext, private var viewModel: ViewModel) : Screen(carContext) {
private var routingEngine = RouteEngine.VALHALLA.ordinal
private var routingEngine = RouteEngine.OSRM.ordinal
init {
@@ -45,6 +45,11 @@ class RoutingSettings(private val carContext: CarContext, private var viewModel:
R.string.osrm,
)
)
.addItem(
buildRowForTemplate(
R.string.tomtom,
)
)
.setOnSelectedListener { index: Int ->
this.onSelected(index)
}

View File

@@ -68,6 +68,7 @@ data class StepData (
var leftDistance: Double,
var lane: List<Lane> = listOf(Lane(location(0.0, 0.0), valid = false, indications = emptyList())),
var exitNumber: Int = 0,
)
@@ -150,5 +151,5 @@ object Constants {
enum class RouteEngine {
VALHALLA, OSRM, GRAPHHOPPER
VALHALLA, OSRM, TOMTOM, GRAPHHOPPER
}

View File

@@ -18,16 +18,13 @@ package com.kouros.navigation.data
import android.content.Context
import android.location.Location
import com.kouros.navigation.data.overpass.Elements
import com.kouros.data.R
import com.kouros.navigation.model.RouteModel
import com.kouros.navigation.utils.GeoUtils.calculateSquareRadius
import com.kouros.navigation.utils.GeoUtils.getBoundingBox
import org.json.JSONArray
import java.net.Authenticator
import java.net.HttpURLConnection
import java.net.PasswordAuthentication
import java.net.URL
import kotlinx.serialization.json.Json
abstract class NavigationRepository {
@@ -36,27 +33,69 @@ abstract class NavigationRepository {
private val nominatimUrl = "https://kouros-online.de/nominatim/"
private val tomtomApiKey = "678k5v6940cSXXIS5oD92qIrDgW3RBZ3"
abstract fun getRoute(currentLocation: Location, location: Location, carOrientation: Float, searchFilter: SearchFilter): String
private val tomtomUrl = "https://api.tomtom.com/traffic/services/5/incidentDetails"
fun getRouteDistance(currentLocation: Location, location: Location, carOrientation : Float, searchFilter: SearchFilter, context: Context): Double {
val route = getRoute(currentLocation, location, carOrientation, searchFilter)
private val tomtomFields =
"{incidents{type,geometry{type,coordinates},properties{iconCategory,events{description}}}}"
abstract fun getRoute(
context: Context,
currentLocation: Location,
location: Location,
carOrientation: Float,
searchFilter: SearchFilter
): String
fun getRouteDistance(
currentLocation: Location,
location: Location,
carOrientation: Float,
searchFilter: SearchFilter,
context: Context
): Double {
val route = getRoute(context, currentLocation, location, carOrientation, searchFilter)
val routeModel = RouteModel()
routeModel.startNavigation(route, context)
return routeModel.curRoute.summary.distance
}
fun searchPlaces(search: String, location: Location) : String {
val box = calculateSquareRadius(location.latitude, location.longitude, 20.0)
val viewbox = "&bounded=1&viewbox=${box[2]},${box[0]},${box[3]},${box[1]}"
return fetchUrl("${nominatimUrl}search?q=$search&format=jsonv2&addressdetails=true$viewbox", false)
fun searchPlaces(search: String, location: Location): String {
val box = calculateSquareRadius(location.latitude, location.longitude, 20.0)
val viewbox = "&bounded=1&viewbox=${box}"
return fetchUrl(
"${nominatimUrl}search?q=$search&format=jsonv2&addressdetails=true$viewbox",
false
)
}
fun reverseAddress(location: Location) : String {
return fetchUrl("${nominatimUrl}reverse?lat=${location.latitude}&lon=${location.longitude}&format=jsonv2&addressdetails=true", false)
fun reverseAddress(location: Location): String {
return fetchUrl(
"${nominatimUrl}reverse?lat=${location.latitude}&lon=${location.longitude}&format=jsonv2&addressdetails=true",
false
)
}
fun fetchUrl(url: String, authenticator : Boolean): String {
fun getTraffic(context: Context, location: Location, carOrientation: Float): String {
val useAsset = false
val bbox = calculateSquareRadius(location.latitude, location.longitude, 15.0)
return if (useAsset) {
val trafficJson = context.resources.openRawResource(R.raw.tomtom_traffic)
trafficJson.bufferedReader().use { it.readText() }
} else {
val trafficResult = fetchUrl(
"$tomtomUrl?key=$tomtomApiKey&bbox=$bbox&fields=$tomtomFields&language=en-GB&timeValidityFilter=present",
false
)
trafficResult.replace(
"{\"incidents\":",
"{\"type\": \"FeatureCollection\", \"features\":"
)
}
}
fun fetchUrl(url: String, authenticator: Boolean): String {
try {
if (authenticator) {
Authenticator.setDefault(object : Authenticator() {
@@ -79,7 +118,7 @@ abstract class NavigationRepository {
val responseCode = httpURLConnection.responseCode
if (responseCode == HttpURLConnection.HTTP_OK) {
val response = httpURLConnection.inputStream.bufferedReader()
.use { it.readText() } // defaults to UTF-8
.use { it.readText() }
return response
}
} catch (e: Exception) {

View File

@@ -1,6 +1,5 @@
package com.kouros.navigation.data
import android.location.Location
import com.google.gson.GsonBuilder
import com.kouros.navigation.data.osrm.OsrmResponse
import com.kouros.navigation.data.osrm.OsrmRoute
@@ -8,9 +7,10 @@ import com.kouros.navigation.data.route.Leg
import com.kouros.navigation.data.route.Maneuver
import com.kouros.navigation.data.route.Step
import com.kouros.navigation.data.route.Summary
import com.kouros.navigation.data.tomtom.TomTomResponse
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.GeoUtils.createCenterLocation
import com.kouros.navigation.utils.location
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonElement
@@ -38,6 +38,7 @@ data class Route(
}
fun routeEngine(routeEngine: Int) = apply { this.routeEngine = routeEngine }
fun route(route: String) = apply {
if (route.isNotEmpty() && route != "[]") {
val gson = GsonBuilder().serializeNulls().create()
@@ -52,11 +53,14 @@ data class Route(
)
ValhallaRoute().mapJsonToValhalla(routeJson, this)
}
else -> {
RouteEngine.OSRM.ordinal -> {
val osrmJson = gson.fromJson(route, OsrmResponse::class.java)
OsrmRoute().mapToOsrm(osrmJson, this)
}
else -> {
val tomtomJson = gson.fromJson(route, TomTomResponse::class.java)
TomTomRoute().mapToOsrm(tomtomJson, this)
}
}
}
}
@@ -71,9 +75,7 @@ data class Route(
fun buildEmpty(): Route {
return Route(
routeEngine = 0,
//summary = Summary(0.0, 0.0),
routes = emptyList(),
// legs = emptyList(),
//waypoints = emptyList(),
//routeGeoJson = "",
)
@@ -81,14 +83,18 @@ data class Route(
}
val legs: List<Leg>
get() = routes.first().legs
fun legs(): List<Leg> {
return if (routes.isNotEmpty()) {
routes.first().legs
} else {
emptyList()
}
}
fun currentStep(): Step {
return if (legs.isNotEmpty()) {
legs.first().steps[currentStep]
return if (routes.isNotEmpty() && legs().isNotEmpty()) {
legs().first().steps[currentStep]
} else {
Step(maneuver = Maneuver(waypoints = emptyList(), location = location(0.0, 0.0)))
}
@@ -96,8 +102,8 @@ data class Route(
fun nextStep(): Step {
val nextIndex = currentStep + 1
return if (nextIndex < legs.first().steps.size) {
legs.first().steps[nextIndex]
return if (nextIndex < legs().first().steps.size) {
legs().first().steps[nextIndex]
} else {
throw IndexOutOfBoundsException("No next maneuver available.")
}

View File

@@ -5,8 +5,8 @@ import com.google.gson.annotations.SerializedName
data class Intersections(
@SerializedName("in") var inV: Int? = null,
@SerializedName("out") var out: Int? = null,
@SerializedName("in") var inV: Int = 0,
@SerializedName("out") var out: Int = 0,
@SerializedName("entry") var entry: ArrayList<Boolean> = arrayListOf(),
@SerializedName("bearings") var bearings: ArrayList<Int> = arrayListOf(),
@SerializedName("location") var location: ArrayList<Double> = arrayListOf(),

View File

@@ -6,9 +6,9 @@ import com.google.gson.annotations.SerializedName
data class Legs (
@SerializedName("steps" ) var steps : ArrayList<Steps> = arrayListOf(),
@SerializedName("weight" ) var weight : Double? = null,
@SerializedName("summary" ) var summary : String? = null,
@SerializedName("duration" ) var duration : Double? = null,
@SerializedName("distance" ) var distance : Double? = null
@SerializedName("weight" ) var weight : Double = 0.0,
@SerializedName("summary" ) var summary : String = "",
@SerializedName("duration" ) var duration : Double = 0.0,
@SerializedName("distance" ) var distance : Double = 0.0
)

View File

@@ -3,12 +3,13 @@ package com.kouros.navigation.data.osrm
import com.google.gson.annotations.SerializedName
data class Maneuver (
data class Maneuver(
@SerializedName("bearing_after" ) var bearingAfter : Int? = null,
@SerializedName("bearing_before" ) var bearingBefore : Int? = null,
@SerializedName("location" ) var location : ArrayList<Double> = arrayListOf(),
@SerializedName("modifier" ) var modifier : String? = null,
@SerializedName("type" ) var type : String? = null
@SerializedName("bearing_after") var bearingAfter: Int = 0,
@SerializedName("bearing_before") var bearingBefore: Int = 0,
@SerializedName("location") var location: ArrayList<Double> = arrayListOf(),
@SerializedName("modifier") var modifier: String = "",
@SerializedName("type") var type: String = "",
@SerializedName("exit") var exit: Int = 0,
)
)

View File

@@ -1,5 +1,6 @@
package com.kouros.navigation.data.osrm
import android.content.Context
import android.location.Location
import com.kouros.navigation.data.NavigationRepository
import com.kouros.navigation.data.SearchFilter
@@ -8,6 +9,7 @@ private const val routeUrl = "https://kouros-online.de/osrm/route/v1/driving/"
class OsrmRepository : NavigationRepository() {
override fun getRoute(
context: Context,
currentLocation: Location,
location: Location,
carOrientation: Float,

View File

@@ -5,7 +5,7 @@ import com.google.gson.annotations.SerializedName
data class OsrmResponse (
@SerializedName("code" ) var code : String? = null,
@SerializedName("code" ) var code : String = "",
@SerializedName("routes" ) var routes : ArrayList<Routes> = arrayListOf(),
@SerializedName("waypoints" ) var waypoints : ArrayList<Waypoints> = arrayListOf()

View File

@@ -16,57 +16,55 @@ class OsrmRoute {
fun mapToOsrm(routeJson: OsrmResponse, builder: Route.Builder) {
val routes = mutableListOf<com.kouros.navigation.data.route.Routes>()
var stepIndex = 0
routeJson.routes.forEach { route ->
val legs = mutableListOf<Leg>()
val waypoints = mutableListOf<List<Double>>()
val summary = Summary(route.duration!!, route.distance!! / 1000)
val summary = Summary(route.duration, route.distance / 1000)
route.legs.forEach { leg ->
val steps = mutableListOf<Step>()
leg.steps.forEach { step ->
val intersections = mutableListOf<Intersection>()
if (step.maneuver != null) {
val points = decodePolyline(step.geometry!!, 5)
waypoints.addAll(points)
val maneuver = RouteManeuver(
bearingBefore = step.maneuver.bearingBefore ?: 0,
bearingAfter = step.maneuver.bearingAfter ?: 0,
type = convertType(step.maneuver),
waypoints = points,
location = location(
step.maneuver.location[0],
step.maneuver.location[1]
)
val points = decodePolyline(step.geometry, 5)
waypoints.addAll(points)
val maneuver = RouteManeuver(
bearingBefore = step.maneuver.bearingBefore,
bearingAfter = step.maneuver.bearingAfter,
type = convertType(step.maneuver),
waypoints = points,
exit = step.maneuver.exit,
location = location(
step.maneuver.location[0],
step.maneuver.location[1]
)
step.intersections.forEach { it2 ->
if (it2.location[0] != 0.0) {
val lanes = mutableListOf<Lane>()
it2.lanes.forEach { it3 ->
if (it3.indications.isNotEmpty() && it3.indications.first() != "none") {
val lane = Lane(
location(it2.location[0], it2.location[1]),
it3.valid,
it3.indications
)
lanes.add(lane)
}
)
step.intersections.forEach { it2 ->
if (it2.location[0] != 0.0) {
val lanes = mutableListOf<Lane>()
it2.lanes.forEach { it3 ->
if (it3.indications.isNotEmpty() && it3.indications.first() != "none") {
val lane = Lane(
location(it2.location[0], it2.location[1]),
it3.valid,
it3.indications
)
lanes.add(lane)
}
intersections.add(Intersection(it2.location, lanes))
}
intersections.add(Intersection(it2.location, lanes))
}
val step = Step(
index = stepIndex,
name = step.name!!,
distance = step.distance!! / 1000,
duration = step.duration!!,
maneuver = maneuver,
intersection = intersections
)
steps.add(step)
stepIndex += 1
}
val step = Step(
index = stepIndex,
name = step.name,
distance = step.distance / 1000,
duration = step.duration,
maneuver = maneuver,
intersection = intersections
)
steps.add(step)
stepIndex += 1
}
legs.add(Leg(steps))
}

View File

@@ -8,8 +8,8 @@ data class Routes (
@SerializedName("legs" ) var legs : ArrayList<Legs> = arrayListOf(),
@SerializedName("weight_name" ) var weightName : String? = null,
@SerializedName("geometry" ) var geometry : String? = null,
@SerializedName("weight" ) var weight : Double? = null,
@SerializedName("duration" ) var duration : Double? = null,
@SerializedName("distance" ) var distance : Double? = null
@SerializedName("weight" ) var weight : Double = 0.0,
@SerializedName("duration" ) var duration : Double = 0.0,
@SerializedName("distance" ) var distance : Double = 0.0
)

View File

@@ -6,13 +6,13 @@ import com.google.gson.annotations.SerializedName
data class Steps (
@SerializedName("intersections" ) var intersections : ArrayList<Intersections> = arrayListOf(),
@SerializedName("driving_side" ) var drivingSide : String? = null,
@SerializedName("geometry" ) var geometry : String? = null,
@SerializedName("maneuver" ) val maneuver : Maneuver? = Maneuver(),
@SerializedName("name" ) var name : String? = null,
@SerializedName("mode" ) var mode : String? = null,
@SerializedName("weight" ) var weight : Double? = null,
@SerializedName("duration" ) var duration : Double? = null,
@SerializedName("distance" ) var distance : Double? = null
@SerializedName("driving_side" ) var drivingSide : String = "",
@SerializedName("geometry" ) var geometry : String = "",
@SerializedName("maneuver" ) val maneuver : Maneuver = Maneuver(),
@SerializedName("name" ) var name : String = "",
@SerializedName("mode" ) var mode : String = "",
@SerializedName("weight" ) var weight : Double = 0.0,
@SerializedName("duration" ) var duration : Double = 0.0,
@SerializedName("distance" ) var distance : Double = 0.0,
)

View File

@@ -5,9 +5,9 @@ import com.google.gson.annotations.SerializedName
data class Waypoints (
@SerializedName("hint" ) var hint : String? = null,
@SerializedName("hint" ) var hint : String = "",
@SerializedName("location" ) var location : ArrayList<Double> = arrayListOf(),
@SerializedName("name" ) var name : String? = null,
@SerializedName("distance" ) var distance : Double? = null
@SerializedName("name" ) var name : String = "",
@SerializedName("distance" ) var distance : Double = 0.0,
)

View File

@@ -5,10 +5,10 @@ import com.google.gson.annotations.SerializedName
data class Elements (
@SerializedName("type" ) var type : String? = null,
@SerializedName("id" ) var id : Long? = null,
@SerializedName("lat" ) var lat : Double? = null,
@SerializedName("lon" ) var lon : Double? = null,
@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

View File

@@ -2,8 +2,7 @@ package com.kouros.navigation.data.overpass
import android.location.Location
import com.google.gson.GsonBuilder
import com.kouros.navigation.utils.GeoUtils.getOverpassBbox
import kotlinx.serialization.json.Json
import com.kouros.navigation.utils.GeoUtils.getBoundingBox
import java.io.OutputStreamWriter
import java.net.HttpURLConnection
import java.net.URL
@@ -41,7 +40,7 @@ class Overpass {
location: Location,
radius: Double
): List<Elements> {
val boundingBox = getOverpassBbox(location, radius)
val boundingBox = getBoundingBox(location.latitude, location.longitude, radius)
val httpURLConnection = URL(overpassUrl).openConnection() as HttpURLConnection
httpURLConnection.requestMethod = "POST"
httpURLConnection.setRequestProperty(

View File

@@ -18,7 +18,7 @@ data class Tags(
@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? = null,
@SerializedName("maxspeed") var maxspeed: String = "0",
@SerializedName("direction") var direction: String? = null,
)

View File

@@ -8,4 +8,5 @@ data class Maneuver(
val type: Int = 0,
val waypoints: List<List<Double>>,
val location: Location,
val exit: Int = 0,
)

View File

@@ -0,0 +1,10 @@
package com.kouros.navigation.data.tomtom
import com.google.gson.annotations.SerializedName
data class Events (
@SerializedName("description" ) var description : String? = null
)

View File

@@ -0,0 +1,12 @@
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()
)

View File

@@ -0,0 +1,11 @@
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()
)

View File

@@ -0,0 +1,12 @@
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()
)

View File

@@ -0,0 +1,11 @@
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()
)

View File

@@ -0,0 +1,5 @@
package com.kouros.navigation.data.tomtom
data class Report(
val effectiveSettings: List<EffectiveSetting>
)

View File

@@ -0,0 +1,72 @@
package com.kouros.navigation.data.tomtom
import com.kouros.navigation.data.Route
import com.kouros.navigation.data.osrm.OsrmResponse
import com.kouros.navigation.data.osrm.OsrmRoute.ManeuverType
import com.kouros.navigation.data.route.Leg
import com.kouros.navigation.data.route.Maneuver
import com.kouros.navigation.data.route.Step
import com.kouros.navigation.data.route.Summary
import com.kouros.navigation.utils.GeoUtils.decodePolyline
import com.kouros.navigation.utils.location
import com.kouros.navigation.data.route.Maneuver as RouteManeuver
/**
curl -X GET "https://api.tomtom.com/routing/1/calculateRoute/\
48.1856548,11.57928:48.1183,11.59485/json?\
vehicleHeading=90&sectionType=traffic\
&report=effectiveSettings&routeType=eco\
&traffic=true&avoid=unpavedRoadimport com.kouros.navigation.data.route.Maneuver as RouteManeuvers&travelMode=car\
&vehicleMaxSpeed=120&vehicleCommercial=false\
&instructionsType=text&language=en-GB&sectionType=lanes\
&routeRepresentation=encodedPolyline\
&vehicleEngineType=combustion&key=678k5v6940cSXXIS5oD92qIrDgW3RBZ3"
*/
class TomTomRoute {
fun mapToOsrm(routeJson: TomTomResponse, builder: Route.Builder) {
routeJson.routes.forEach { route ->
val legs = mutableListOf<Leg>()
val waypoints = mutableListOf<List<Double>>()
var points = listOf<List<Double>>()
val summary = Summary(
route.summary.travelTimeInSeconds.toDouble(),
route.summary.lengthInMeters.toDouble() / 1000
)
route.legs.forEach { leg ->
points = decodePolyline(leg.encodedPolyline, leg.encodedPolylinePrecision)
waypoints.addAll(points)
}
route.guidance.instructions.forEach { instruction ->
instruction.exitNumber
// val maneuver = RouteManeuver(
// // bearingBefore = step.maneuver.bearingBefore,
// //bearingAfter = step.maneuver.bearingAfter,
// type = convertType(instruction.maneuver),
// waypoints = points.subList(section.startPointIndex, section.endPointIndex + 1),
// exit = instruction.exitNumber.toInt(),
// location = location(
// instruction.point.longitude, instruction.point.latitude
// )
// )
}
route.sections.forEach { section ->
}
}
println(routeJson)
}
fun convertType(type: String): Int {
var newType = 0
when (type) {
"DEPART" -> {
newType = androidx.car.app.navigation.model.Maneuver.TYPE_DEPART
}
}
return newType
}
}

View File

@@ -0,0 +1,12 @@
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()
)

View File

@@ -0,0 +1,6 @@
package com.kouros.navigation.data.tomtom
data class TrafficData (
var traffic : Traffic ,
var trafficData: String = ""
)

View File

@@ -1,5 +1,6 @@
package com.kouros.navigation.data.valhalla
import android.content.Context
import android.location.Location
import com.kouros.navigation.data.Locations
import com.kouros.navigation.data.NavigationRepository
@@ -13,6 +14,7 @@ private const val routeUrl = "https://kouros-online.de/valhalla/route?json="
class ValhallaRepository : NavigationRepository() {
override fun getRoute(
context: Context,
currentLocation: Location,
location: Location,
carOrientation: Float,

View File

@@ -21,6 +21,7 @@ import com.kouros.navigation.data.route.Lane
import com.kouros.navigation.data.route.Leg
import com.kouros.navigation.data.route.Routes
import com.kouros.navigation.data.valhalla.ManeuverType
import com.kouros.navigation.utils.Levenshtein
import com.kouros.navigation.utils.NavigationUtils.getIntKeyValue
import com.kouros.navigation.utils.location
import kotlinx.coroutines.CoroutineScope
@@ -128,16 +129,24 @@ open class RouteModel() {
fun updateSpeedLimit(location: Location, viewModel: ViewModel) = runBlocking {
CoroutineScope(Dispatchers.IO).launch {
val instruction = currentStep().instruction
val levenshtein = Levenshtein()
// speed limit
val distance = lastSpeedLocation.distanceTo(location)
if (distance > 500 || lastSpeedIndex < route.currentStep) {
lastSpeedIndex = route.currentStep
lastSpeedLocation = location
val elements = viewModel.getMaxSpeed(location)
elements.forEach {
if (it.tags.name != null && it.tags.maxspeed != null) {
val speed = it.tags.maxspeed!!.toInt()
maxSpeed = speed
if (it.tags.name != null) {
if (isNavigating()) {
val distance =
levenshtein.distance(it.tags.name!!, instruction)
if (distance < 5) {
val speed = it.tags.maxspeed.toInt()
maxSpeed = speed
lastSpeedLocation = location
}
}
}
}
}
@@ -150,7 +159,7 @@ open class RouteModel() {
val distanceToNextStep = leftStepDistance()
val isNearNextManeuver = distanceToNextStep in 0.0..NEXT_STEP_THRESHOLD
val shouldAdvance =
isNearNextManeuver && route.currentStep < (route.legs.first().steps.size)
isNearNextManeuver && route.currentStep < (route.legs().first().steps.size)
// Determine the maneuver type and corresponding icon
var curManeuverType = if (hasArrived(currentStep.maneuver.type)) {
@@ -166,17 +175,16 @@ open class RouteModel() {
}
// Safely get the street name from the maneuver
val streetName = relevantStep.name
var exitNumber = currentStep.maneuver.exit
if (shouldAdvance) {
curManeuverType = relevantStep.maneuver.type
exitNumber = relevantStep.maneuver.exit
}
val maneuverIcon = maneuverIcon(curManeuverType)
maneuverType = curManeuverType
val lanes = currentLanes(location)
if (lanes.isNotEmpty())
println("Street: $streetName Dist: $distanceToNextStep Lane: ${lanes.size}")
// Construct and return the final StepData object
return StepData(
streetName,
@@ -185,9 +193,9 @@ open class RouteModel() {
maneuverIcon,
arrivalTime(),
travelLeftDistance(),
lanes
lanes,
exitNumber
)
}
@@ -215,7 +223,10 @@ open class RouteModel() {
maneuverType,
maneuverIcon,
arrivalTime(),
travelLeftDistance()
travelLeftDistance(),
listOf(Lane(location(0.0, 0.0), valid = false, indications = emptyList())),
step.maneuver.exit
)
}
@@ -263,7 +274,7 @@ open class RouteModel() {
fun travelLeftDistance(): Double {
var leftDistance = 0.0
for (i in route.currentStep + 1..<curLeg.steps.size) {
val step = route.legs!![0].steps[i]
val step = route.legs()[0].steps[i]
leftDistance += step.distance
}
leftDistance += leftStepDistance() / 1000

View File

@@ -9,7 +9,6 @@ import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.google.gson.GsonBuilder
import com.kouros.navigation.data.Constants
import com.kouros.navigation.data.Constants.ROUTING_ENGINE
import com.kouros.navigation.data.NavigationRepository
import com.kouros.navigation.data.ObjectBox.boxStore
import com.kouros.navigation.data.Place
@@ -19,12 +18,16 @@ import com.kouros.navigation.data.nominatim.Search
import com.kouros.navigation.data.nominatim.SearchResult
import com.kouros.navigation.data.overpass.Elements
import com.kouros.navigation.data.overpass.Overpass
import com.kouros.navigation.data.tomtom.Features
import com.kouros.navigation.data.tomtom.Traffic
import com.kouros.navigation.data.tomtom.TrafficData
import com.kouros.navigation.utils.GeoUtils.createPointCollection
import com.kouros.navigation.utils.NavigationUtils
import com.kouros.navigation.utils.NavigationUtils.getIntKeyValue
import com.kouros.navigation.utils.location
import io.objectbox.kotlin.boxFor
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import org.maplibre.geojson.FeatureCollection
import java.time.LocalDateTime
import java.time.ZoneOffset
@@ -34,6 +37,11 @@ class ViewModel(private val repository: NavigationRepository) : ViewModel() {
MutableLiveData()
}
val traffic: MutableLiveData<Map<String, String> > by lazy {
MutableLiveData()
}
val previewRoute: MutableLiveData<String> by lazy {
MutableLiveData()
}
@@ -156,6 +164,7 @@ class ViewModel(private val repository: NavigationRepository) : ViewModel() {
try {
route.postValue(
repository.getRoute(
context,
currentLocation,
location,
carOrientation,
@@ -168,11 +177,46 @@ class ViewModel(private val repository: NavigationRepository) : ViewModel() {
}
}
fun loadTraffic(context: Context, currentLocation: Location, carOrientation : Float) {
viewModelScope.launch(Dispatchers.IO) {
try {
val data = repository.getTraffic(
context,
currentLocation,
carOrientation
)
val trafficData = rebuildTraffic(data)
traffic.postValue(
trafficData
)
} catch (e: Exception) {
e.printStackTrace()
}
}
}
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
}
fun loadPreviewRoute(context: Context, currentLocation: Location, location: Location, carOrientation: Float) {
viewModelScope.launch(Dispatchers.IO) {
try {
previewRoute.postValue(
repository.getRoute(
context,
currentLocation,
location,
carOrientation,

View File

@@ -115,45 +115,31 @@ object GeoUtils {
return featureCollection.toJson()
}
fun getOverpassBbox(location: Location, radius: Double): String {
val bbox = getBoundingBox(location.longitude, location.latitude, radius)
val neLon = bbox["ne"]?.get("lon")
val neLat = bbox["ne"]?.get("lat")
val swLon = bbox["sw"]?.get("lon")
val swLat = bbox["sw"]?.get("lat")
return "$swLon,$swLat,$neLon,$neLat"
}
/**
* Calculate the lat and len of a square around a point.
* @return latMin, latMax, lngMin, lngMax
*/
fun calculateSquareRadius(lat: Double, lng: Double, radius: Double): DoubleArray {
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)))
return doubleArrayOf(latMin, latMax, lngMin, lngMax)
return "$lngMin,$latMin,$lngMax,$latMax"
}
fun getBoundingBox(
lat: Double,
lon: Double,
radius: Double
): Map<String, Map<String, 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)))
return mapOf(
"nw" to mapOf("lat" to maxLat, "lon" to minLon),
"ne" to mapOf("lat" to maxLat, "lon" to maxLon),
"sw" to mapOf("lat" to minLat, "lon" to minLon),
"se" to mapOf("lat" to minLat, "lon" to maxLon)
)
return "$minLat,$minLon,$maxLat,$maxLon"
}
}

View File

@@ -0,0 +1,63 @@
package com.kouros.navigation.utils
import kotlin.math.min
/**
* The Levenshtein distance between two words is the minimum number of single-character edits (insertions, deletions or
* substitutions) required to change one string into the other.
*
* This implementation uses dynamic programming (WagnerFischer algorithm).
*
* [Levenshtein Distance](https://en.wikipedia.org/wiki/Levenshtein_distance)
*/
class Levenshtein {
/**
* The Levenshtein distance, or edit distance, between two words is the minimum number of single-character edits
* (insertions, deletions or substitutions) required to change one word into the other.
*
* It is always at least the difference of the sizes of the two strings.
* It is at most the length of the longer string.
* It is `0` if and only if the strings are equal.
*
* @param first first string to compare.
* @param second second string to compare.
* @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 {
if (first == second) return 0
if (first.isEmpty()) return second.length
if (second.isEmpty()) return first.length
// 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)
var cost = IntArray(first.length + 1) { it }
var newCost = IntArray(first.length + 1)
for (i in 1..second.length) {
// calculate new costs from the previous row.
// the first element of the new row is the edit distance (deletes) to match empty string
newCost[0] = i
var minCost = i
// fill in the rest of the row
for (j in 1..first.length) {
// if it's the same char at the same position, no edit cost.
val edit = if (first[j - 1] == second[i - 1]) 0 else 1
val replace = cost[j - 1] + edit
val insert = cost[j] + 1
val delete = newCost[j - 1] + 1
newCost[j] = minOf(insert, delete, replace)
minCost = min(minCost, newCost[j])
}
if (minCost >= limit) return limit
// flip references of current and previous row
val swap = cost
cost = newCost
newCost = swap
}
return cost.last()
}
}

View File

@@ -8,6 +8,7 @@ import com.kouros.navigation.data.Constants.ROUTING_ENGINE
import com.kouros.navigation.data.Constants.SHARED_PREF_KEY
import com.kouros.navigation.data.RouteEngine
import com.kouros.navigation.data.osrm.OsrmRepository
import com.kouros.navigation.data.tomtom.TomTomRepository
import com.kouros.navigation.data.valhalla.ValhallaRepository
import com.kouros.navigation.model.ViewModel
import java.time.LocalDateTime
@@ -29,7 +30,8 @@ object NavigationUtils {
val routeEngine = getIntKeyValue(context = context, ROUTING_ENGINE)
return when (routeEngine) {
RouteEngine.VALHALLA.ordinal -> ViewModel(ValhallaRepository())
else -> ViewModel(OsrmRepository())
RouteEngine.OSRM.ordinal -> ViewModel(OsrmRepository())
else -> ViewModel(TomTomRepository())
}
}

View File

@@ -69,17 +69,7 @@
"source-layer": "landuse",
"maxzoom": 12,
"filter": ["==", ["get", "class"], "residential"],
"paint": {
"fill-color": [
"interpolate",
["linear"],
["zoom"],
9,
"hsla(0,3%,85%,0.84)",
12,
"hsla(35,57%,88%,0.49)"
]
}
"paint": {"fill-color": "rgba(48, 43, 57, 1)"}
},
{
"id": "landcover_wood",
@@ -89,7 +79,7 @@
"filter": ["==", ["get", "class"], "wood"],
"paint": {
"fill-antialias": false,
"fill-color": "hsla(98,61%,72%,0.7)",
"fill-color": "rgba(21, 28, 16, 0.7)",
"fill-opacity": 0.4
}
},
@@ -1303,15 +1293,7 @@
],
"layout": {"line-cap": "round", "line-join": "round"},
"paint": {
"line-color": [
"interpolate",
["linear"],
["zoom"],
5,
"hsl(26,87%,62%)",
6,
"#ab9"
],
"line-color": "rgba(12, 84, 84, 1)",
"line-width": [
"interpolate",
["exponential", 1.2],
@@ -2457,7 +2439,8 @@
"text-field": ["to-string", ["get", "ref"]],
"text-font": ["Noto Sans Regular"],
"text-rotation-alignment": "viewport",
"text-size": 10
"text-size": 10,
"visibility": "none"
}
},
{
@@ -2585,7 +2568,8 @@
"text-letter-spacing": 0.1,
"text-max-width": 9,
"text-size": ["interpolate", ["linear"], ["zoom"], 8, 9, 12, 10],
"text-transform": "uppercase"
"text-transform": "uppercase",
"visibility": "none"
},
"paint": {
"text-color": "#333",

View File

@@ -0,0 +1,603 @@
{
"formatVersion": "0.0.12",
"report": {
"effectiveSettings": [
{
"key": "avoid",
"value": "unpavedRoads"
},
{
"key": "computeBestOrder",
"value": "false"
},
{
"key": "computeTollAmounts",
"value": "none"
},
{
"key": "computeTravelTimeFor",
"value": "none"
},
{
"key": "contentType",
"value": "json"
},
{
"key": "departAt",
"value": "2026-01-29T08:43:35.397Z"
},
{
"key": "guidanceVersion",
"value": "1"
},
{
"key": "includeTollPaymentTypes",
"value": "none"
},
{
"key": "instructionsType",
"value": "text"
},
{
"key": "language",
"value": "en-GB"
},
{
"key": "locations",
"value": "48.18565,11.57928:48.11830,11.59485"
},
{
"key": "maxAlternatives",
"value": "0"
},
{
"key": "routeRepresentation",
"value": "encodedPolyline"
},
{
"key": "routeType",
"value": "eco"
},
{
"key": "sectionType",
"value": "lanes"
},
{
"key": "sectionType",
"value": "traffic"
},
{
"key": "traffic",
"value": "true"
},
{
"key": "travelMode",
"value": "car"
},
{
"key": "vehicleAxleWeight",
"value": "0"
},
{
"key": "vehicleCommercial",
"value": "false"
},
{
"key": "vehicleEngineType",
"value": "combustion"
},
{
"key": "vehicleHeading",
"value": "90"
},
{
"key": "vehicleHeight",
"value": "0.00"
},
{
"key": "vehicleLength",
"value": "0.00"
},
{
"key": "vehicleMaxSpeed",
"value": "120"
},
{
"key": "vehicleNumberOfAxles",
"value": "0"
},
{
"key": "vehicleWeight",
"value": "0"
},
{
"key": "vehicleWidth",
"value": "0.00"
}
]
},
"routes": [
{
"summary": {
"lengthInMeters": 10879,
"travelTimeInSeconds": 1170,
"trafficDelayInSeconds": 76,
"trafficLengthInMeters": 1727,
"departureTime": "2026-01-29T09:43:35+01:00",
"arrivalTime": "2026-01-29T10:03:05+01:00"
},
"legs": [
{
"summary": {
"lengthInMeters": 10879,
"travelTimeInSeconds": 1170,
"trafficDelayInSeconds": 76,
"trafficLengthInMeters": 1727,
"departureTime": "2026-01-29T09:43:35+01:00",
"arrivalTime": "2026-01-29T10:03:05+01:00"
},
"encodedPolyline": "sfbeHmqteAEjDQEy@GQ?wDQFkEH{G?M?sA@kB?_FAeC?o@?[@_@\\Ab@CVAz@CJA@?dBGhAGjAExAGlBEvBKdCKTAfCKLAv@ELA|AGnAGt@ClCKjDQpBIf@BXDPDPBF@ZB?S@SAYAUKi@Go@Cc@BgBBs@Bg@JyAJiAXqBDWNs@TaA\\mAFa@Po@`BwF?YL]FSFSl@iB^kALc@L]Ro@f@yAf@{AFQNe@dAoCdBgEx@qBTi@BITe@L[L_@^}@HSXu@pB}El@eAb@e@f@[h@QZCRAL?j@HRFh@Vf@XLJhAn@lAv@TLtAz@r@`@bAn@pAv@f@X~@j@z@h@vBpA`@VHDFFJFf@X`CzApAh@f@L`@Fz@@f@AXEVEVEhA[h@Yn@e@PQFEJKRWV[PW`@w@t@}AHQN]~BiFP]`AoBh@aADGTa@t@aAt@{@PQJKJGFG@Cd@]XSxDmCf@a@n@o@TY\\g@LQHMJSLUP[f@iAPg@b@yAFODMNi@FS|@qCVaAHUHUn@wBHYh@eBpAkEjBiGfAeDj@yADMFQd@sAf@kAJUh@qAf@eAt@sAn@iALSN[p@kAVc@JOLSj@w@z@}@x@q@pAu@p@_@j@Sl@MLCRCb@E`@?^?L@`ABz@?N@~AFdADJ@rAH`DVpCVrAJd@BfHp@zGl@pAJ|ALnGp@jEh@fBJpAFF?P@N@ZCtC]r@GJCFCLCD?TEVEXGhAYzAg@NGv@]`@QJEvB_AXMVK\\Qb@Qn@QJCNAZC^ENA`@FnBb@xEtA^H^JnCl@z@r@`@Pr@TtBlA~C`Bn@\\xAl@PF`@LrAVlCh@bBLl@BlBJdG\\RDjCHn@?pB?xB?R@`@@GxAC^?ZInBIfCAvC?r@@dD@n@@b@@^D`C?TDxAFbBHdB@VHp@RjAJb@NNH`@VlBFf@PzARhBFd@@LRbBFh@\\nC@FNhAb@lEj@tDPpABTBRZlBTdBXjBn@xEBLDTRpAR~@Fb@",
"encodedPolylinePrecision": 5
}
],
"sections": [
{
"startPointIndex": 83,
"endPointIndex": 147,
"sectionType": "TRAFFIC",
"simpleCategory": "JAM",
"effectiveSpeedInKmh": 35,
"delayInSeconds": 76,
"magnitudeOfDelay": 1,
"tec": {
"causes": [
{
"mainCauseCode": 1
}
],
"effectCode": 4
},
"eventId": "TTL41048054144049000"
},
{
"lanes": [
{
"directions": [
"SLIGHT_LEFT"
],
"follow": "SLIGHT_LEFT"
},
{
"directions": [
"STRAIGHT"
]
},
{
"directions": [
"STRAIGHT"
]
},
{
"directions": [
"STRAIGHT"
]
}
],
"laneSeparators": [
"SINGLE_SOLID",
"SINGLE_SOLID",
"LONG_DASHED",
"LONG_DASHED",
"SINGLE_SOLID"
],
"startPointIndex": 42,
"endPointIndex": 45,
"sectionType": "LANES"
},
{
"lanes": [
{
"directions": [
"STRAIGHT"
],
"follow": "STRAIGHT"
},
{
"directions": [
"SLIGHT_RIGHT"
]
}
],
"laneSeparators": [
"SINGLE_SOLID",
"SHORT_DASHED",
"SINGLE_SOLID"
],
"startPointIndex": 61,
"endPointIndex": 62,
"sectionType": "LANES"
},
{
"lanes": [
{
"directions": [
"SLIGHT_LEFT"
],
"follow": "SLIGHT_LEFT"
},
{
"directions": [
"SLIGHT_LEFT"
],
"follow": "SLIGHT_LEFT"
},
{
"directions": [
"SLIGHT_RIGHT"
]
}
],
"laneSeparators": [
"SINGLE_SOLID",
"LONG_DASHED",
"SHORT_DASHED",
"SINGLE_SOLID"
],
"startPointIndex": 74,
"endPointIndex": 75,
"sectionType": "LANES"
},
{
"lanes": [
{
"directions": [
"STRAIGHT"
]
},
{
"directions": [
"SLIGHT_RIGHT"
],
"follow": "SLIGHT_RIGHT"
}
],
"laneSeparators": [
"SINGLE_SOLID",
"LONG_DASHED",
"SINGLE_SOLID"
],
"startPointIndex": 265,
"endPointIndex": 266,
"sectionType": "LANES"
},
{
"lanes": [
{
"directions": [
"LEFT"
]
},
{
"directions": [
"STRAIGHT"
]
},
{
"directions": [
"STRAIGHT"
]
},
{
"directions": [
"RIGHT"
],
"follow": "RIGHT"
}
],
"laneSeparators": [
"SINGLE_SOLID",
"SINGLE_SOLID",
"SINGLE_SOLID",
"SINGLE_SOLID",
"SINGLE_SOLID"
],
"startPointIndex": 287,
"endPointIndex": 288,
"sectionType": "LANES"
},
{
"lanes": [
{
"directions": [
"LEFT"
]
},
{
"directions": [
"STRAIGHT"
],
"follow": "STRAIGHT"
},
{
"directions": [
"STRAIGHT"
],
"follow": "STRAIGHT"
},
{
"directions": [
"RIGHT"
]
}
],
"laneSeparators": [
"SINGLE_SOLID",
"SHORT_DASHED",
"LONG_DASHED",
"SHORT_DASHED",
"SINGLE_SOLID"
],
"startPointIndex": 302,
"endPointIndex": 304,
"sectionType": "LANES"
}
],
"guidance": {
"instructions": [
{
"routeOffsetInMeters": 0,
"travelTimeInSeconds": 0,
"point": {
"latitude": 48.18554,
"longitude": 11.57927
},
"exitNumber": "",
"pointIndex": 0,
"instructionType": "LOCATION_DEPARTURE",
"street": "Vogelhartstraße",
"countryCode": "DEU",
"possibleCombineWithNext": false,
"drivingSide": "RIGHT",
"maneuver": "DEPART",
"message": "Leave from Vogelhartstraße"
},
{
"routeOffsetInMeters": 64,
"travelTimeInSeconds": 14,
"point": {
"latitude": 48.18557,
"longitude": 11.57841
},
"pointIndex": 1,
"instructionType": "TURN",
"street": "Silcherstraße",
"countryCode": "DEU",
"junctionType": "REGULAR",
"turnAngleInDecimalDegrees": 90,
"possibleCombineWithNext": false,
"drivingSide": "RIGHT",
"maneuver": "TURN_RIGHT",
"message": "Turn right onto Silcherstraße"
},
{
"routeOffsetInMeters": 218,
"travelTimeInSeconds": 57,
"point": {
"latitude": 48.18696,
"longitude": 11.57857
},
"pointIndex": 5,
"instructionType": "TURN",
"street": "Schmalkaldener Straße",
"countryCode": "DEU",
"junctionType": "REGULAR",
"turnAngleInDecimalDegrees": 90,
"possibleCombineWithNext": false,
"drivingSide": "RIGHT",
"maneuver": "TURN_RIGHT",
"message": "Turn right onto Schmalkaldener Straße"
},
{
"routeOffsetInMeters": 650,
"travelTimeInSeconds": 131,
"point": {
"latitude": 48.18686,
"longitude": 11.58437
},
"pointIndex": 15,
"instructionType": "TURN",
"roadNumbers": [
"B13"
],
"street": "Ingolstädter Straße",
"countryCode": "DEU",
"junctionType": "REGULAR",
"turnAngleInDecimalDegrees": 90,
"possibleCombineWithNext": false,
"drivingSide": "RIGHT",
"maneuver": "TURN_RIGHT",
"message": "Turn right onto Ingolstädter Straße/B13"
},
{
"routeOffsetInMeters": 1713,
"travelTimeInSeconds": 266,
"point": {
"latitude": 48.17733,
"longitude": 11.58503
},
"pointIndex": 45,
"instructionType": "TURN",
"roadNumbers": [
"B2R"
],
"street": "Schenkendorfstraße",
"countryCode": "DEU",
"junctionType": "REGULAR",
"turnAngleInDecimalDegrees": -90,
"possibleCombineWithNext": true,
"drivingSide": "RIGHT",
"maneuver": "TURN_LEFT",
"message": "Turn left onto Schenkendorfstraße/B2R",
"combinedMessage": "Turn left onto Schenkendorfstraße/B2R then keep left at Schenkendorfstraße/B2R toward Messe / ICM"
},
{
"routeOffsetInMeters": 2067,
"travelTimeInSeconds": 309,
"point": {
"latitude": 48.17678,
"longitude": 11.58957
},
"pointIndex": 62,
"instructionType": "TURN",
"roadNumbers": [
"B2R"
],
"street": "Schenkendorfstraße",
"countryCode": "DEU",
"signpostText": "Messe / ICM",
"junctionType": "BIFURCATION",
"turnAngleInDecimalDegrees": -45,
"possibleCombineWithNext": true,
"drivingSide": "RIGHT",
"maneuver": "KEEP_LEFT",
"message": "Keep left at Schenkendorfstraße/B2R toward Messe / ICM",
"combinedMessage": "Keep left at Schenkendorfstraße/B2R toward Messe / ICM then keep left at Schenkendorfstraße/B2R toward Passau"
},
{
"routeOffsetInMeters": 2419,
"travelTimeInSeconds": 332,
"point": {
"latitude": 48.17518,
"longitude": 11.59363
},
"pointIndex": 75,
"instructionType": "TURN",
"roadNumbers": [
"B2R"
],
"street": "Schenkendorfstraße",
"countryCode": "DEU",
"signpostText": "Passau",
"junctionType": "BIFURCATION",
"turnAngleInDecimalDegrees": -45,
"possibleCombineWithNext": false,
"drivingSide": "RIGHT",
"maneuver": "KEEP_LEFT",
"message": "Keep left at Schenkendorfstraße/B2R toward Passau"
},
{
"routeOffsetInMeters": 2774,
"travelTimeInSeconds": 357,
"point": {
"latitude": 48.17329,
"longitude": 11.59747
},
"pointIndex": 86,
"instructionType": "DIRECTION_INFO",
"roadNumbers": [
"B2R"
],
"street": "Isarring",
"countryCode": "DEU",
"signpostText": "München-Ost",
"possibleCombineWithNext": false,
"drivingSide": "RIGHT",
"maneuver": "FOLLOW",
"message": "Follow Isarring/B2R toward München-Ost"
},
{
"routeOffsetInMeters": 8425,
"travelTimeInSeconds": 806,
"point": {
"latitude": 48.13017,
"longitude": 11.61541
},
"pointIndex": 266,
"instructionType": "TURN",
"street": "Ampfingstraße",
"countryCode": "DEU",
"junctionType": "REGULAR",
"turnAngleInDecimalDegrees": 45,
"possibleCombineWithNext": false,
"drivingSide": "RIGHT",
"maneuver": "BEAR_RIGHT",
"message": "Bear right at Ampfingstraße"
},
{
"routeOffsetInMeters": 9487,
"travelTimeInSeconds": 953,
"point": {
"latitude": 48.12089,
"longitude": 11.61285
},
"pointIndex": 288,
"instructionType": "TURN",
"street": "Anzinger Straße",
"countryCode": "DEU",
"junctionType": "REGULAR",
"turnAngleInDecimalDegrees": 90,
"possibleCombineWithNext": true,
"drivingSide": "RIGHT",
"maneuver": "TURN_RIGHT",
"message": "Turn right onto Anzinger Straße",
"combinedMessage": "Turn right onto Anzinger Straße then keep straight on at Sankt-Martin-Straße"
},
{
"routeOffsetInMeters": 9983,
"travelTimeInSeconds": 1044,
"point": {
"latitude": 48.12087,
"longitude": 11.60621
},
"pointIndex": 304,
"instructionType": "TURN",
"street": "Sankt-Martin-Straße",
"countryCode": "DEU",
"junctionType": "REGULAR",
"turnAngleInDecimalDegrees": 0,
"possibleCombineWithNext": false,
"drivingSide": "RIGHT",
"maneuver": "STRAIGHT",
"message": "Keep straight on at Sankt-Martin-Straße"
},
{
"routeOffsetInMeters": 10879,
"travelTimeInSeconds": 1170,
"point": {
"latitude": 48.1183,
"longitude": 11.59485
},
"pointIndex": 335,
"instructionType": "LOCATION_ARRIVAL",
"street": "Sankt-Martin-Straße",
"countryCode": "DEU",
"possibleCombineWithNext": false,
"drivingSide": "RIGHT",
"maneuver": "ARRIVE",
"message": "You have arrived at Sankt-Martin-Straße"
}
],
"instructionGroups": [
{
"firstInstructionIndex": 0,
"lastInstructionIndex": 3,
"groupMessage": "Leave from Vogelhartstraße. Take the Ingolstädter Straße/B13",
"groupLengthInMeters": 1713
},
{
"firstInstructionIndex": 4,
"lastInstructionIndex": 7,
"groupMessage": "Take the Schenkendorfstraße, Isarring/B2R toward Messe / ICM, Passau, München-Ost",
"groupLengthInMeters": 6712
},
{
"firstInstructionIndex": 8,
"lastInstructionIndex": 11,
"groupMessage": "Take the Ampfingstraße, Anzinger Straße. Continue to your destination at Sankt-Martin-Straße",
"groupLengthInMeters": 2454
}
]
}
}
]
}

File diff suppressed because it is too large Load Diff

View File

@@ -48,5 +48,6 @@
<string name="charging_station">Ladestation</string>
<string name="speed_camera">Speed camera</string>
<string name="use_car_location">Auto GPS verwenden</string>
<string name="tomtom">TomTom\t</string>
</resources>

View File

@@ -34,4 +34,5 @@
<string name="osrm" translatable="false">Osrm</string>
<string name="routing_engine" translatable="false">Routing engine</string>
<string name="use_car_location">Use car location</string>
<string name="tomtom">TomTom\t</string>
</resources>