diff --git a/.gitattributes b/.gitattributes
index fcadb2c..4fbd8ef 100644
--- a/.gitattributes
+++ b/.gitattributes
@@ -1 +1,2 @@
* text eol=lf
+*.bat text eol=crlf
diff --git a/.gitignore b/.gitignore
index 10cfdbf..4d4fa7f 100644
--- a/.gitignore
+++ b/.gitignore
@@ -8,3 +8,4 @@
.externalNativeBuild
.cxx
local.properties
+__pycache__/
diff --git a/app/build.gradle b/app/build.gradle
index e3377eb..8eb3c08 100644
--- a/app/build.gradle
+++ b/app/build.gradle
@@ -6,16 +6,18 @@ plugins {
android {
namespace 'eu.ztsh.garmin'
- compileSdk 31
+ compileSdk 34
+ ndkVersion "23.2.8568313"
defaultConfig {
applicationId "eu.ztsh.garmin"
minSdk 29
- targetSdk 31
+ targetSdk 34
versionCode 1
versionName "1.0"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
+ buildConfigField "String", "MAPBOX_DOWNLOADS_TOKEN", "\"$MAPBOX_DOWNLOADS_TOKEN\""
}
buildTypes {
@@ -33,27 +35,26 @@ android {
}
buildFeatures {
viewBinding true
+ buildConfig = true
}
}
+ext {
+ mapboxVersion = '3.2.0'
+}
dependencies {
-
- // Notification parser
- implementation 'com.github.3v1n0.GMapsParser:navparser:0.2.1'
-//
-// // Bluetooth serial
-// implementation 'com.github.harry1453:android-bluetooth-serial:v1.1'
-// implementation 'io.reactivex.rxjava2:rxjava:2.1.12'
-// implementation 'io.reactivex.rxjava2:rxandroid:2.0.2'
- implementation 'androidx.activity:activity-ktx:1.4.0'
- implementation 'androidx.navigation:navigation-ui-ktx:2.3.4'
- implementation 'androidx.core:core-ktx:1.7.0'
- implementation 'androidx.appcompat:appcompat:1.4.1'
- implementation 'com.google.android.material:material:1.5.0'
- implementation 'androidx.constraintlayout:constraintlayout:2.1.3'
-// testImplementation 'junit:junit:4.13.2'
+ implementation "com.mapbox.navigationcore:navigation:$mapboxVersion"
+ implementation "com.mapbox.navigationcore:ui-maps:$mapboxVersion"
+ implementation "com.mapbox.navigationcore:voice:$mapboxVersion"
+ implementation "com.mapbox.navigationcore:tripdata:$mapboxVersion"
+ implementation "com.mapbox.navigationcore:ui-components:$mapboxVersion"
+ implementation 'androidx.core:core-ktx:1.13.1'
+ implementation 'androidx.appcompat:appcompat:1.7.0'
+ implementation 'com.google.android.material:material:1.12.0'
+ implementation 'androidx.constraintlayout:constraintlayout:2.1.4'
testImplementation 'org.junit.jupiter:junit-jupiter:5.8.1'
- androidTestImplementation 'androidx.test.ext:junit:1.1.3'
- androidTestImplementation 'androidx.test.espresso:espresso-core:3.4.0'
+ testImplementation 'org.assertj:assertj-core:3.24.2'
+ androidTestImplementation 'androidx.test.ext:junit:1.2.1'
+ androidTestImplementation 'androidx.test.espresso:espresso-core:3.6.1'
}
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index 7ad53ea..9a87620 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -8,21 +8,15 @@
-
-
-
-
-
-
-
+
+
+
+
+
-
-
-
-
-
diff --git a/app/src/main/java/eu/ztsh/garmin/Garmin.kt b/app/src/main/java/eu/ztsh/garmin/Garmin.kt
index cebaafc..aaf8f0c 100644
--- a/app/src/main/java/eu/ztsh/garmin/Garmin.kt
+++ b/app/src/main/java/eu/ztsh/garmin/Garmin.kt
@@ -5,12 +5,19 @@ import android.bluetooth.BluetoothAdapter
import android.bluetooth.BluetoothDevice
import android.bluetooth.BluetoothSocket
import android.util.Log
-import me.trevi.navparser.lib.NavigationData
+import com.mapbox.navigation.core.trip.session.LocationMatcherResult
+import com.mapbox.navigation.tripdata.maneuver.model.Maneuver
+import com.mapbox.navigation.tripdata.progress.model.TripProgressUpdateValue
+import eu.ztsh.garmin.data.Arrows
+import eu.ztsh.garmin.data.DataCache
+import eu.ztsh.garmin.data.GarminMapper
+import eu.ztsh.garmin.data.Lanes
+import eu.ztsh.garmin.data.MapboxMapper
import java.io.IOException
import java.util.*
+import java.util.concurrent.Executors
import java.util.concurrent.SynchronousQueue
-
@SuppressLint("MissingPermission")
class Garmin(
val context: MainActivity,
@@ -18,48 +25,202 @@ class Garmin(
val adapter: BluetoothAdapter
) {
- private lateinit var thread: ConnectThread
+ private lateinit var connection: ConnectThread
+ private lateinit var maneuvers: ManeuverProcessingThread
+ private lateinit var trips: TripProgressProcessingThread
+ private lateinit var locations: LocationMatcherProcessingThread
+ private val cache = DataCache()
+
+ private val processingPool = Executors.newFixedThreadPool(8)
fun start() {
- thread = ConnectThread()
- thread.start()
- }
+ connection = ConnectThread()
+ connection.start()
- fun send(data: IntArray) {
- thread.enqueue(data)
- }
+ maneuvers = ManeuverProcessingThread()
+ maneuvers.start()
- fun send(data: NavigationData) {
- Log.d("SEND_DATA", "$data.remainingDistance.distance")
+ trips = TripProgressProcessingThread()
+ trips.start()
+
+ locations = LocationMatcherProcessingThread()
+ locations.start()
}
fun close() {
- thread.close()
+ connection.close()
+
+ maneuvers.interrupt()
+ maneuvers.join(0)
+
+ trips.interrupt()
+ trips.join(0)
+
+ locations.interrupt()
+ locations.join(0)
+
+ processingPool.shutdown()
+ }
+
+ fun process(maneuver: Maneuver) {
+ processingPool.submit{maneuvers.enqueue(maneuver)}
+ }
+
+ fun process(tripProgressUpdateValue: TripProgressUpdateValue) {
+ processingPool.submit{trips.enqueue(tripProgressUpdateValue)}
+ }
+
+ fun process(locationMatcherResult: LocationMatcherResult) {
+ processingPool.submit{locations.enqueue(locationMatcherResult)}
+ }
+
+ private inner class LocationMatcherProcessingThread: ProcessingThread() {
+
+ override fun mapAndSend(maybeItem: LocationMatcherResult?): LocationMatcherResult? {
+ if (maybeItem != null && cache.hasChanged(maybeItem)) {
+ send(GarminMapper.map(MapboxMapper.asSpeed(maybeItem)))
+ return maybeItem
+ }
+ return null
+ }
+
+ override fun updateCache(item: LocationMatcherResult) {
+ cache.update(item)
+ }
+
+ }
+
+ private inner class TripProgressProcessingThread : ProcessingThread() {
+
+ override fun mapAndSend(maybeItem: TripProgressUpdateValue?): TripProgressUpdateValue? {
+ if (maybeItem != null) {
+ // it is much simplier to parse and compare model object
+ val value = MapboxMapper.asEta(maybeItem)
+ if (cache.hasChanged(value)) {
+ // TODO: traffic
+ send(GarminMapper.setTime(value.hours, value.minutes))
+ cache.update(value)
+ }
+ }
+ return null
+ }
+
+ override fun updateCache(item: TripProgressUpdateValue) {
+ // won't be used
+ }
+
+ }
+
+ private inner class ManeuverProcessingThread : ProcessingThread() {
+
+ override fun mapAndSend(maybeItem: Maneuver?): Maneuver? {
+ if (maybeItem != null) {
+ Log.d(TAG, "mapAndSend (${currentThread().name}): got new")
+ var changed = false
+ if (cache.hasChanged(maybeItem.primary)) {
+ changed = true
+ Log.d(TAG, "mapAndSend: primary")
+ send(GarminMapper.map(MapboxMapper.asDirection(maybeItem)))
+ if (maybeItem.laneGuidance == null) {
+ send(GarminMapper.map(Lanes(Arrows(setOf()), Arrows(setOf()))))
+ }
+ }
+ if (cache.hasChanged(maybeItem.laneGuidance)) {
+ changed = true
+ Log.d(TAG, "mapAndSend: lanes")
+ send(GarminMapper.map(MapboxMapper.asLanes(maybeItem)))
+ }
+ if (cache.hasChanged(maybeItem.stepDistance)) {
+ changed = true
+ Log.d(TAG, "mapAndSend: stepDistance")
+ send(GarminMapper.map(MapboxMapper.asDistance(maybeItem)))
+ }
+ if (changed) {
+ return maybeItem
+ }
+ }
+ return null
+ }
+
+ override fun updateCache(item: Maneuver) {
+ cache.update(item)
+ }
+
+ }
+
+ private abstract inner class ProcessingThread : Thread() {
+
+ private val queue: SynchronousQueue = SynchronousQueue()
+ private var stop: Boolean = false
+
+ abstract fun mapAndSend(maybeItem: T?): T?
+
+ abstract fun updateCache(item: T)
+
+ fun send(data: IntArray) {
+ connection.enqueue(data)
+ }
+
+ fun enqueue(item: T) {
+ queue.put(item)
+ }
+
+ override fun run() {
+ while (!stop) {
+ val maybeItem = queue.poll()
+ val item = mapAndSend(maybeItem)
+ if (item != null) {
+ Log.d(TAG, "run: Cache updated")
+ updateCache(item)
+ }
+ }
+ }
+
+ override fun interrupt() {
+ stop = true
+ super.interrupt()
+ }
}
private inner class ConnectThread : Thread() {
+ private val queue: SynchronousQueue = SynchronousQueue()
+ private var current: IntArray = intArrayOf()
+
private val socket: BluetoothSocket? by lazy(LazyThreadSafetyMode.NONE) {
context.checkBt()
device.createRfcommSocketToServiceRecord(UUID.fromString("00001101-0000-1000-8000-00805F9B34FB"))
}
- private val queue: SynchronousQueue = SynchronousQueue()
-
- fun enqueue(data: IntArray) {
-// queue.clear() // for immediate run
- queue.put(data)
- }
-
override fun run() {
// Cancel discovery because it otherwise slows down the connection.
context.checkBt()
adapter.cancelDiscovery()
- socket?.connect()
- sleep(3000)
- readAll()
- while (true) {
- queue.poll()?.let { send(it) }
+ try {
+ socket?.connect()
+ context.setConnectionStatus(true)
+ sleep(3000)
+ readAll()
+ send(intArrayOf(0x07, 0x01)) // Set GPS to true
+ while (true) {
+ val newCurrent = queue.poll()
+ if (newCurrent == null) {
+ Log.d(TAG, "run (${currentThread().name}): Sleep...")
+ sleep(250)
+ } else {
+ current = newCurrent
+ }
+ send(current)
+ }
+ } catch (e: IOException) {
+ Log.d(TAG, "Not connected", e)
+ context.setConnectionStatus(false)
+ while (true) {
+ // Just dequeue
+ // TODO: Add option to reconnect
+ queue.poll()
+ sleep(900)
+ }
}
}
@@ -72,7 +233,15 @@ class Garmin(
}
}
- fun readAll() {
+ fun enqueue(data: IntArray) {
+ queue.put(data)
+ }
+
+ private fun send(data: IntArray) {
+ sendRaw(prepareData(data))
+ }
+
+ private fun readAll() {
val buffer = ByteArray(64)
val istr = socket!!.inputStream
istr!!.let {
@@ -82,20 +251,18 @@ class Garmin(
}
}
- fun send(data: IntArray) {
- sendRaw(prepareData(data))
- }
-
private fun sendRaw(buff: IntArray) {
buff.forEach { socket!!.outputStream.write(it) }
socket!!.outputStream.flush()
- sleep(2000)
readAll()
}
}
companion object {
+
+ lateinit var instance: Garmin
+
fun prepareData(input: IntArray): IntArray {
val n = input.size
var crc = (0xeb + n + n).toUInt()
diff --git a/app/src/main/java/eu/ztsh/garmin/MainActivity.kt b/app/src/main/java/eu/ztsh/garmin/MainActivity.kt
index 116a47b..bb84358 100644
--- a/app/src/main/java/eu/ztsh/garmin/MainActivity.kt
+++ b/app/src/main/java/eu/ztsh/garmin/MainActivity.kt
@@ -2,94 +2,79 @@ package eu.ztsh.garmin
import android.Manifest
import android.annotation.SuppressLint
-import android.app.PendingIntent
import android.bluetooth.BluetoothAdapter
import android.bluetooth.BluetoothDevice
import android.bluetooth.BluetoothManager
-import android.bluetooth.BluetoothSocket
import android.content.Intent
import android.content.pm.PackageManager
import android.os.Build
import android.os.Bundle
import android.util.Log
-import android.widget.CompoundButton
+import android.view.WindowManager
+import android.widget.Toast
import androidx.activity.result.ActivityResultCallback
import androidx.activity.result.contract.ActivityResultContracts.StartActivityForResult
-import androidx.activity.viewModels
import androidx.appcompat.app.AppCompatActivity
import androidx.core.app.ActivityCompat
+import androidx.lifecycle.DefaultLifecycleObserver
+import com.mapbox.navigation.base.options.NavigationOptions
+import com.mapbox.navigation.core.MapboxNavigation
+import com.mapbox.navigation.core.lifecycle.MapboxNavigationApp
+import com.mapbox.navigation.core.lifecycle.MapboxNavigationObserver
+import com.mapbox.navigation.core.lifecycle.requireMapboxNavigation
import eu.ztsh.garmin.databinding.ActivityMainBinding
-import me.trevi.navparser.lib.NavigationData
-import me.trevi.navparser.service.*
-import java.io.IOException
-import java.util.*
+import eu.ztsh.garmin.mapbox.MapControl
+import eu.ztsh.garmin.util.PermissionsHelper
+import java.lang.ref.WeakReference
@SuppressLint("MissingPermission")
class MainActivity : AppCompatActivity() {
- var navigating: Boolean = false
- lateinit var garmin: Garmin
- private lateinit var binding : ActivityMainBinding
- private val navDataModel: NavigationDataModel by viewModels()
+// lateinit var garmin: Garmin
+
+ private lateinit var binding: ActivityMainBinding
+ private lateinit var mapControl: MapControl
+
+ private val permissionsHelper = PermissionsHelper(WeakReference(this))
+
+ val mapboxNavigation: MapboxNavigation by requireMapboxNavigation(
+ onResumedObserver = object : DefaultLifecycleObserver, MapboxNavigationObserver {
+ override fun onAttached(mapboxNavigation: MapboxNavigation) {
+ mapControl.onAttached(mapboxNavigation)
+ }
+
+ override fun onDetached(mapboxNavigation: MapboxNavigation) {
+ mapControl.onDetached(mapboxNavigation)
+ }
+ },
+ onInitialize = fun() {
+ mapControl.initNavigation()
+ }
+ )
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
binding = ActivityMainBinding.inflate(layoutInflater)
setContentView(binding.root)
+ binding.mapView
+ permissionsHelper.checkPermissions {
+ mapControl = MapControl(this, UI(binding), resources)
+ mapControl.init()
+
+ MapboxNavigationApp.setup(
+ NavigationOptions.Builder(applicationContext)
+ .build()
+ )
+ }
bluetoothInit()
-
- binding.enabledSwitch.setOnCheckedChangeListener {
- _, isChecked ->
- run {
- navigating = isChecked
-
- setNavigationData(NavigationData(true))
- navDataModel.liveData.observe(this) { setNavigationData(it) }
- Log.d(TAG, "OK")
- }
- }
+ window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
}
- override fun onStart() {
- super.onStart()
- startServiceListener()
- checkNotificationsAccess()
- }
-
- override fun onActivityResult(requestCode: Int, resultCode: Int, intent: Intent?) {
- super.onActivityResult(requestCode, resultCode, intent)
-
- Log.d(TAG, "Got activity result: $intent, ${intent?.extras}, ${intent?.action}")
- when (intent?.action) {
-
- NOTIFICATIONS_ACCESS_RESULT -> {
- intent.getBooleanExtra(NOTIFICATIONS_ACCESS_RESULT, false).also {
- val notificationAccess = it
-
- if (!notificationAccess) {
- Log.e(TAG, "No notification access for ${NavigationListenerEmitter::class.qualifiedName}")
- }
- }
- }
-
- NAVIGATION_DATA_UPDATED -> {
-// gotoFragment(R.id.NavigationFragment)
-
- val navData = intent.getParcelableExtra(NAVIGATION_DATA)
-
- Log.d(TAG, "Got navigation data $navData")
- navDataModel.data = navData
- }
-
- NAVIGATION_STARTED -> {
- Log.d(TAG, "Started")
- }
-
- NAVIGATION_STOPPED -> {
- Log.d(TAG, "Stopped")
- }
- }
+ override fun onDestroy() {
+ super.onDestroy()
+ mapControl.onDestroy()
+ window.clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
}
private fun bluetoothInit() {
@@ -108,10 +93,10 @@ class MainActivity : AppCompatActivity() {
val context = this
pairedDevices?.firstOrNull { device ->
Log.d(TAG, device.name)
- device.name.equals("GARMIN HUD")
+ device.name == "GARMIN HUD"
}?.apply {
- garmin = Garmin(context, this, bluetoothAdapter)
- garmin.start()
+ Garmin.instance = Garmin(context, this, bluetoothAdapter)
+ Garmin.instance.start()
}
}
@@ -138,33 +123,19 @@ class MainActivity : AppCompatActivity() {
return true
}
- private fun startServiceListener() {
- Log.d(TAG, "Start Service Listener")
- setServiceListenerIntent(createPendingResult(100, Intent(), 0))
- }
-
- fun stopServiceListener() {
- Log.d(TAG, "Stopping Service Listener")
- setServiceListenerIntent(null)
- }
-
- private fun setServiceListenerIntent(pendingIntent: PendingIntent?) {
- startService(serviceIntent(SET_INTENT).putExtra(PENDING_INTENT, pendingIntent))
- }
-
- private fun checkNotificationsAccess() {
- startService(serviceIntent(CHECK_NOTIFICATIONS_ACCESS))
- }
-
- private fun serviceIntent(action: String): Intent {
- return Intent(applicationContext, NavigationListenerEmitter::class.java).setAction(action)
- }
- private fun setNavigationData(navData: NavigationData) {
- Log.d(TAG, "Sending $navData")
- garmin.send(navData)
+ fun setConnectionStatus(success: Boolean) {
+ this.runOnUiThread {
+ if (success) {
+ Toast.makeText(this, "Garmin connected", Toast.LENGTH_LONG).show()
+ } else {
+ // TODO: Make snackbar with reconnect option
+ Toast.makeText(this, "Garmin not connected", Toast.LENGTH_LONG).show()
+ }
+ }
}
companion object {
+
private const val TAG = "bt"
}
diff --git a/app/src/main/java/eu/ztsh/garmin/UI.kt b/app/src/main/java/eu/ztsh/garmin/UI.kt
new file mode 100644
index 0000000..c27faff
--- /dev/null
+++ b/app/src/main/java/eu/ztsh/garmin/UI.kt
@@ -0,0 +1,59 @@
+package eu.ztsh.garmin
+
+import android.content.res.Resources
+import com.mapbox.maps.EdgeInsets
+import eu.ztsh.garmin.databinding.ActivityMainBinding
+
+class UI(b: ActivityMainBinding) {
+
+ companion object {
+
+ const val BUTTON_ANIMATION_DURATION = 1500L
+
+ private val pixelDensity = Resources.getSystem().displayMetrics.density
+ val overviewPadding: EdgeInsets by lazy {
+ EdgeInsets(
+ 140.0 * pixelDensity,
+ 40.0 * pixelDensity,
+ 120.0 * pixelDensity,
+ 40.0 * pixelDensity
+ )
+ }
+ val landscapeOverviewPadding: EdgeInsets by lazy {
+ EdgeInsets(
+ 30.0 * pixelDensity,
+ 380.0 * pixelDensity,
+ 110.0 * pixelDensity,
+ 20.0 * pixelDensity
+ )
+ }
+ val followingPadding: EdgeInsets by lazy {
+ EdgeInsets(
+ 180.0 * pixelDensity,
+ 40.0 * pixelDensity,
+ 150.0 * pixelDensity,
+ 40.0 * pixelDensity
+ )
+ }
+ val landscapeFollowingPadding: EdgeInsets by lazy {
+ EdgeInsets(
+ 30.0 * pixelDensity,
+ 380.0 * pixelDensity,
+ 110.0 * pixelDensity,
+ 40.0 * pixelDensity
+ )
+ }
+
+ }
+
+ val mapView = b.mapView
+ val maneuverView = b.maneuverView
+ val tripProgressView = b.tripProgressView
+ val tripProgressCard = b.tripProgressCard
+
+ val recenter = b.recenter
+ val soundButton = b.soundButton
+ val routeOverview = b.routeOverview
+ val stop = b.stop
+
+}
\ No newline at end of file
diff --git a/app/src/main/java/eu/ztsh/garmin/bt/BluetoothSerial.kt b/app/src/main/java/eu/ztsh/garmin/bt/BluetoothSerial.kt
deleted file mode 100644
index 5167e61..0000000
--- a/app/src/main/java/eu/ztsh/garmin/bt/BluetoothSerial.kt
+++ /dev/null
@@ -1,310 +0,0 @@
-package eu.ztsh.garmin.bt
-
-import android.annotation.SuppressLint
-import android.bluetooth.BluetoothAdapter
-import android.bluetooth.BluetoothDevice
-import android.bluetooth.BluetoothSocket
-import android.content.BroadcastReceiver
-import android.content.Context
-import android.content.Intent
-import android.content.IntentFilter
-import android.os.AsyncTask
-import android.util.Log
-import androidx.localbroadcastmanager.content.LocalBroadcastManager
-import eu.ztsh.garmin.MainActivity
-import java.io.IOException
-import java.io.InputStream
-import java.io.OutputStream
-import java.util.*
-
-class BluetoothSerial(var context: MainActivity, var messageHandler: MessageHandler, devicePrefix: String) {
- var connected = false
- var bluetoothDevice: BluetoothDevice? = null
- var serialSocket: BluetoothSocket? = null
- var serialInputStream: InputStream? = null
- var serialOutputStream: OutputStream? = null
- var serialReader: SerialReader? = null
- var connectionTask: AsyncTask? = null
- var devicePrefix: String
-
- /**
- * Listens for discount message from bluetooth system and restablishing a connection
- */
- private val bluetoothReceiver: BroadcastReceiver = object : BroadcastReceiver() {
- override fun onReceive(context: Context, intent: Intent) {
- val action = intent.action
- val eventDevice = intent.getParcelableExtra(BluetoothDevice.EXTRA_DEVICE)
- if (BluetoothDevice.ACTION_ACL_DISCONNECTED == action) {
- if (bluetoothDevice != null && bluetoothDevice == eventDevice) {
- Log.i(BMX_BLUETOOTH, "Received bluetooth disconnect notice")
-
- //clean up any streams
- close()
-
- //reestablish connect
- connect()
- LocalBroadcastManager.getInstance(context).sendBroadcast(Intent(BLUETOOTH_DISCONNECTED))
- }
- }
- }
- }
-
- init {
- this.devicePrefix = devicePrefix.uppercase(Locale.getDefault())
- }
-
- fun onPause() {
- context.unregisterReceiver(bluetoothReceiver)
- }
-
- fun onResume() {
- //listen for bluetooth disconnect
- val disconnectIntent = IntentFilter(BluetoothDevice.ACTION_ACL_DISCONNECTED)
- context.registerReceiver(bluetoothReceiver, disconnectIntent)
-
- //reestablishes a connection is one doesn't exist
- if (!connected) {
- connect()
- } else {
- val intent = Intent(BLUETOOTH_CONNECTED)
- LocalBroadcastManager.getInstance(context).sendBroadcast(intent)
- }
- }
-
- /**
- * Initializes the bluetooth serial connections, uses the LocalBroadcastManager when
- * connection is established
- */
- @SuppressLint("MissingPermission")
- fun connect() {
- if (connected) {
- Log.e(BMX_BLUETOOTH, "Connection request while already connected")
- return
- }
- if (connectionTask != null && connectionTask!!.status == AsyncTask.Status.RUNNING) {
- Log.e(BMX_BLUETOOTH, "Connection request while attempting connection")
- return
- }
- val bluetoothAdapter = BluetoothAdapter.getDefaultAdapter()
- if (bluetoothAdapter == null || !bluetoothAdapter.isEnabled) {
- return
- }
- context.checkBt()
- val pairedDevices: List = ArrayList(bluetoothAdapter.getBondedDevices())
- if (pairedDevices.isNotEmpty()) {
- bluetoothAdapter.cancelDiscovery()
- /**
- * AsyncTask to handle the establishing of a bluetooth connection
- */
-// connectionTask = object : AsyncTask() {
-// var MAX_ATTEMPTS = 30
-// var attemptCounter = 0
-// protected override fun doInBackground(vararg params: Void): BluetoothDevice? {
-// while (!isCancelled) { //need to kill without calling onCancel
-// for (device in pairedDevices) {
-// if (device.getName().uppercase(Locale.getDefault()).startsWith(devicePrefix)) {
-// Log.i(
-// BMX_BLUETOOTH,
-// attemptCounter.toString() + ": Attempting connection to " + device.getName()
-// )
-// try {
-// serialSocket = try {
-// // Standard SerialPortService ID
-// val uuid = UUID.fromString("00001101-0000-1000-8000-00805F9B34FB")
-// device.createRfcommSocketToServiceRecord(uuid)
-// } catch (ce: Exception) {
-// connectViaReflection(device)
-// }
-//
-// //setup the connect streams
-// serialSocket!!.connect()
-// serialInputStream = serialSocket!!.inputStream
-// serialOutputStream = serialSocket!!.outputStream
-// connected = true
-// Log.i(BMX_BLUETOOTH, "Connected to " + device.getName())
-// return device
-// } catch (e: Exception) {
-// serialSocket = null
-// serialInputStream = null
-// serialOutputStream = null
-// Log.i(BMX_BLUETOOTH, e.message!!)
-// }
-// }
-// }
-// try {
-// attemptCounter++
-// if (attemptCounter > MAX_ATTEMPTS) cancel(false) else Thread.sleep(1000)
-// } catch (e: InterruptedException) {
-// break
-// }
-// }
-// Log.i(BMX_BLUETOOTH, "Stopping connection attempts")
-// val intent = Intent(BLUETOOTH_FAILED)
-// LocalBroadcastManager.getInstance(context).sendBroadcast(intent)
-// return null
-// }
-//
-// override fun onPostExecute(result: BluetoothDevice?) {
-// super.onPostExecute(result)
-// bluetoothDevice = result
-//
-// //start thread responsible for reading from inputstream
-// serialReader = SerialReader()
-// serialReader!!.start()
-//
-// //send connection message
-// val intent = Intent(BLUETOOTH_CONNECTED)
-// LocalBroadcastManager.getInstance(context).sendBroadcast(intent)
-// }
-// }
-// connectionTask.execute()
- }
- }
-
- // see: http://stackoverflow.com/questions/3397071/service-discovery-failed-exception-using-bluetooth-on-android
- @Throws(Exception::class)
- private fun connectViaReflection(device: BluetoothDevice): BluetoothSocket {
- val m = device.javaClass.getMethod(
- "createRfcommSocket", *arrayOf?>(
- Int::class.javaPrimitiveType
- )
- )
- return m.invoke(device, 1) as BluetoothSocket
- }
-
- @Throws(IOException::class)
- fun available(): Int {
- if (connected) return serialInputStream!!.available()
- throw RuntimeException("Connection lost, reconnecting now.")
- }
-
- @Throws(IOException::class)
- fun read(): Int {
- if (connected) return serialInputStream!!.read()
- throw RuntimeException("Connection lost, reconnecting now.")
- }
-
- @Throws(IOException::class)
- fun read(buffer: ByteArray?): Int {
- if (connected) return serialInputStream!!.read(buffer)
- throw RuntimeException("Connection lost, reconnecting now.")
- }
-
- @Throws(IOException::class)
- fun read(buffer: ByteArray?, byteOffset: Int, byteCount: Int): Int {
- if (connected) return serialInputStream!!.read(buffer, byteOffset, byteCount)
- throw RuntimeException("Connection lost, reconnecting now.")
- }
-
- @Throws(IOException::class)
- fun write(buffer: ByteArray?) {
- if (connected) serialOutputStream!!.write(buffer)
- throw RuntimeException("Connection lost, reconnecting now.")
- }
-
- @Throws(IOException::class)
- fun write(oneByte: Int) {
- if (connected) serialOutputStream!!.write(oneByte)
- throw RuntimeException("Connection lost, reconnecting now.")
- }
-
- @Throws(IOException::class)
- fun write(buffer: ByteArray?, offset: Int, count: Int) {
- serialOutputStream!!.write(buffer, offset, count)
- throw RuntimeException("Connection lost, reconnecting now.")
- }
-
- inner class SerialReader : Thread() {
- var buffer = ByteArray(Companion.MAX_BYTES)
- var bufferSize = 0
- override fun run() {
- Log.i("serialReader", "Starting serial loop")
- while (!isInterrupted) {
- try {
-
- /*
- * check for some bytes, or still bytes still left in
- * buffer
- */
- if (available() > 0) {
- val newBytes = read(buffer, bufferSize, Companion.MAX_BYTES - bufferSize)
- if (newBytes > 0) bufferSize += newBytes
- Log.d(BMX_BLUETOOTH, "read $newBytes")
- }
- if (bufferSize > 0) {
- val read = messageHandler.read(bufferSize, buffer)
-
- // shift unread data to start of buffer
- if (read > 0) {
- var index = 0
- for (i in read until bufferSize) {
- buffer[index++] = buffer[i]
- }
- bufferSize = index
- }
- } else {
- try {
- sleep(10)
- } catch (ie: InterruptedException) {
- break
- }
- }
- } catch (e: Exception) {
- Log.e(BMX_BLUETOOTH, "Error reading serial data", e)
- }
- }
- Log.i(BMX_BLUETOOTH, "Shutting serial loop")
- }
-
-// companion object {
-// }
- }
-
- /**
- * Reads from the serial buffer, processing any available messages. Must return the number of bytes
- * consumer from the buffer
- *
- * @author jpetrocik
- */
- fun interface MessageHandler {
- fun read(bufferSize: Int, buffer: ByteArray?): Int
- }
-
- fun close() {
- connected = false
- if (connectionTask != null) {
- connectionTask!!.cancel(false)
- }
- if (serialReader != null) {
- serialReader!!.interrupt()
- try {
- serialReader!!.join(1000)
- } catch (ie: InterruptedException) {
- }
- }
- try {
- serialInputStream!!.close()
- } catch (e: Exception) {
- Log.e(BMX_BLUETOOTH, "Failed releasing inputstream connection")
- }
- try {
- serialOutputStream!!.close()
- } catch (e: Exception) {
- Log.e(BMX_BLUETOOTH, "Failed releasing outputstream connection")
- }
- try {
- serialSocket!!.close()
- } catch (e: Exception) {
- Log.e(BMX_BLUETOOTH, "Failed closing socket")
- }
- Log.i(BMX_BLUETOOTH, "Released bluetooth connections")
- }
-
- companion object {
- private const val BMX_BLUETOOTH = "BMXBluetooth"
- var BLUETOOTH_CONNECTED = "bluetooth-connection-started"
- var BLUETOOTH_DISCONNECTED = "bluetooth-connection-lost"
- var BLUETOOTH_FAILED = "bluetooth-connection-failed"
- private const val MAX_BYTES = 125
- }
-}
diff --git a/app/src/main/java/eu/ztsh/garmin/data/DataCache.kt b/app/src/main/java/eu/ztsh/garmin/data/DataCache.kt
new file mode 100644
index 0000000..3e61688
--- /dev/null
+++ b/app/src/main/java/eu/ztsh/garmin/data/DataCache.kt
@@ -0,0 +1,63 @@
+package eu.ztsh.garmin.data
+
+import com.mapbox.navigation.core.trip.session.LocationMatcherResult
+import com.mapbox.navigation.core.trip.session.NavigationSessionState
+import com.mapbox.navigation.tripdata.maneuver.model.Lane
+import com.mapbox.navigation.tripdata.maneuver.model.Maneuver
+import com.mapbox.navigation.tripdata.maneuver.model.PrimaryManeuver
+import com.mapbox.navigation.tripdata.maneuver.model.StepDistance
+
+class DataCache {
+
+ private var maneuverCache: Maneuver? = null
+ private var locationCache: LocationMatcherResult? = null
+ private var session: NavigationSessionState? = null
+ private var eta: Arrival? = null
+
+ // maneuver
+ fun hasChanged(guidance: Lane?): Boolean {
+ return guidance != null && maneuverCache.let { it == null || it.laneGuidance != guidance }
+ }
+
+ fun hasChanged(distance: StepDistance): Boolean {
+ return maneuverCache.let { it == null || it.stepDistance != distance }
+ }
+
+ fun hasChanged(primaryManeuver: PrimaryManeuver): Boolean {
+ return maneuverCache.let { it == null || it.primary != primaryManeuver }
+ }
+
+ fun update(maneuver: Maneuver) {
+ maneuverCache = maneuver
+ }
+
+ // eta
+ fun hasChanged(eta: Arrival): Boolean {
+ return this.eta.let { it == null || it != eta }
+ }
+
+ fun update(eta: Arrival) {
+ this.eta = eta
+ }
+
+ // location
+ fun hasChanged(locationMatcherResult: LocationMatcherResult): Boolean {
+ return locationCache.let { it == null
+ || it.enhancedLocation.speed != locationMatcherResult.enhancedLocation.speed
+ || it.speedLimitInfo.speed != locationMatcherResult.speedLimitInfo.speed }
+ }
+
+ fun update(locationMatcherResult: LocationMatcherResult) {
+ locationCache = locationMatcherResult
+ }
+
+ // session
+ fun isActive(): Boolean {
+ return session != null && session is NavigationSessionState.ActiveGuidance
+ }
+
+ fun update(sessionState: NavigationSessionState) {
+ session = sessionState
+ }
+
+}
\ No newline at end of file
diff --git a/app/src/main/java/eu/ztsh/garmin/data/GarminMapper.kt b/app/src/main/java/eu/ztsh/garmin/data/GarminMapper.kt
new file mode 100644
index 0000000..b398381
--- /dev/null
+++ b/app/src/main/java/eu/ztsh/garmin/data/GarminMapper.kt
@@ -0,0 +1,152 @@
+package eu.ztsh.garmin.data
+
+class GarminMapper {
+
+ companion object {
+
+ fun map(lanes: Lanes): IntArray {
+ return intArrayOf(0x02, lanes.lanes.lanes.sumOf { it.value }, lanes.outlines.lanes.sumOf { it.value })
+ }
+
+ fun map(direction: Direction): IntArray {
+ return toDirectionArray(direction.angle, direction.out, direction.roundabout)
+ }
+
+ fun map(distance: Distance): IntArray {
+ return setDistance(distance.distance, distance.unit)
+ }
+
+ fun map(speed: Speed): IntArray {
+ return setSpeed(speed.speed, speed.limit)
+ }
+
+ fun setTime(hours: Int, minutes: Int, traffic: Boolean = false, flag: Boolean = false): IntArray {
+ val trafficChar = asChar(traffic)
+ val flagChar = asChar(flag)
+ // TODO: needed? Displaying ETA, not remaining
+ return if (hours > 99) {
+ intArrayOf(
+ 0x05,
+ trafficChar,
+ asDigit(hours / 1000),
+ asDigit(hours / 100),
+ 0x00,
+ asDigit(hours / 10),
+ asDigit(hours),
+ 0xff,
+ flagChar
+ )
+ } else {
+ intArrayOf(
+ 0x05,
+ trafficChar,
+ asDigit(hours / 10),
+ asDigit(if (hours == 0) 10 else hours),
+ 0xff,
+ asDigit(minutes / 10),
+ asDigit(minutes),
+ 0x00,
+ flagChar
+ )
+ }
+ }
+
+ fun speedControl(state: Boolean): IntArray {
+ return intArrayOf(0x04, if (state) 0x01 else 0x02)
+ }
+
+ fun cleanDistance(): IntArray {
+ return intArrayOf(0x03, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00)
+ }
+
+ private fun toDirectionArray(
+ angle: OutAngle,
+ out: OutType = OutType.Lane,
+ roundabout: OutAngle = OutAngle.AsDirection
+ ): IntArray {
+ val param1: Int = when (angle) {
+ OutAngle.LeftDown -> 0x10
+ OutAngle.RightDown -> 0x20
+ else -> out.value
+ }
+ val param2: Int = if (out == OutType.RightRoundabout
+ || out == OutType.LeftRoundabout
+ ) {
+ if (roundabout == OutAngle.AsDirection) angle.value else roundabout.value
+ } else {
+ 0x00
+ }
+ val param3: Int =
+ if (angle == OutAngle.LeftDown || angle == OutAngle.RightDown) 0x00 else angle.value
+ return intArrayOf(0x01, param1, param2, param3)
+ }
+
+ private fun setDistance(distance: Double, unit: Unit = Unit.Any): IntArray {
+ val isDecimal = (distance * 10).toInt() != (distance.toInt() * 10)
+ val distanceValue = if (isDecimal) distance * 10 else distance
+ return intArrayOf(
+ 0x03,
+ asDigit(distanceValue / 1000), // position 1
+ asDigit(distanceValue / 100), // position 2
+ asDigit(distanceValue / 10), // position 3
+ if (isDecimal) 0xff else 0x00, // comma
+ asDigit(distanceValue), // position 4
+ unit.value // unit
+ )
+ }
+
+ private fun setSpeed(
+ speed: Int,
+ limit: Int = 0,
+ acc: Boolean = false
+ ): IntArray {
+ // TODO: car connection
+ val accChar = asChar(acc)
+ return if (limit > 0) {
+ intArrayOf(
+ 0x06,
+ asDigit(speed / 100),
+ asDigit(speed / 10),
+ asDigit(if (speed == 0) 10 else speed),
+ 0xff,
+ asDigit(limit / 100),
+ asDigit(limit / 10),
+ asDigit(limit),
+ asChar(speed > limit),
+ accChar
+ )
+ } else {
+ intArrayOf(
+ 0x06,
+ 0x00,
+ 0x00,
+ 0x00,
+ 0x00,
+ asDigit(speed / 100),
+ asDigit(speed / 10),
+ asDigit(speed),
+ asChar(false),
+ accChar
+ )
+ }
+ }
+
+ private fun asDigit(n: Double): Int {
+ return asDigit(n.toInt())
+ }
+
+ private fun asDigit(n: Int): Int {
+ if (n == 0) {
+ return 0
+ }
+ val m = n % 10
+ return if (m == 0) 10 else m
+ }
+
+ private fun asChar(boolean: Boolean): Int {
+ return if (boolean) 0xff else 0x00
+ }
+
+ }
+
+}
\ No newline at end of file
diff --git a/app/src/main/java/eu/ztsh/garmin/data/MapboxMapper.kt b/app/src/main/java/eu/ztsh/garmin/data/MapboxMapper.kt
new file mode 100644
index 0000000..445cb40
--- /dev/null
+++ b/app/src/main/java/eu/ztsh/garmin/data/MapboxMapper.kt
@@ -0,0 +1,124 @@
+package eu.ztsh.garmin.data
+
+import com.mapbox.navigation.tripdata.maneuver.model.Maneuver
+import com.mapbox.navigation.core.trip.session.LocationMatcherResult
+import com.mapbox.navigation.tripdata.progress.model.TripProgressUpdateValue
+
+class MapboxMapper {
+
+ companion object {
+
+ fun asDirection(maneuver: Maneuver): Direction {
+ val direction = Direction()
+ maneuver.primary.apply {
+ when (this.type) {
+ "roundabout" -> {
+ direction.out = OutType.RightRoundabout
+ }
+ "arrive" -> {
+ direction.out = OutType.Flag
+ direction.angle = OutAngle.Right
+ }
+ "turn" -> {
+ when (this.modifier) {
+ "straight" -> direction.angle = OutAngle.Straight
+ "slight right" -> direction.angle = OutAngle.EasyRight
+ "slight left" -> direction.angle = OutAngle.EasyLeft
+ "sharp right" -> direction.angle = OutAngle.SharpRight
+ "sharp left" -> direction.angle = OutAngle.SharpLeft
+ "uturn" -> direction.angle = OutAngle.LeftDown
+ }
+ }
+ }
+ when (this.modifier) {
+ "right" -> {
+ direction.angle = OutAngle.Right
+ when (this.type) {
+ "roundabout" -> {
+ when (this.degrees!!.toInt()) {
+ in 22..66 -> direction.angle = OutAngle.SharpRight
+ in 67..111 -> direction.angle = OutAngle.Right
+ in 112..156 -> direction.angle = OutAngle.EasyRight
+ in 157..203 -> direction.angle = OutAngle.Straight
+ in 204..248 -> direction.angle = OutAngle.EasyLeft
+ in 249..293 -> direction.angle = OutAngle.Left
+ in 294..338 -> direction.angle = OutAngle.SharpLeft
+ else -> direction.angle = OutAngle.Down
+ }
+ }
+ "fork", "off ramp" -> {
+ direction.angle = OutAngle.EasyRight
+ direction.out = OutType.LongerLane
+ }
+ }
+ }
+
+ "left" -> {
+ direction.angle = OutAngle.Left
+ when (this.type) {
+ "fork", "off ramp" -> {
+ direction.angle = OutAngle.EasyLeft
+ direction.out = OutType.LongerLane
+ }
+ }
+ }
+ }
+ }
+ return direction
+ }
+
+ fun asDistance(maneuver: Maneuver): Distance {
+ return maneuver.stepDistance.let { step ->
+ step.distanceFormatter.formatDistance(step.distanceRemaining!!).split(" ").let {
+ Distance(
+ it[0].replace(',', '.').toDouble(),
+ when (it[1]) {
+ "m" -> Unit.Metres
+ "km" -> Unit.Kilometres
+ else -> Unit.Any
+ }
+ )
+ }
+ }
+ }
+
+ fun asLanes(maneuver: Maneuver): Lanes {
+ val laneIterator = Lane.iterator()
+ val outlines = mutableSetOf()
+ val lanes = mutableSetOf()
+ maneuver.laneGuidance?.apply {
+ this.allLanes.reversed().let {
+ it.forEach{ indicator ->
+ val lane = if (laneIterator.hasNext()) laneIterator.next() else Lane.DotsLeft
+ if (lane == Lane.DotsLeft) {
+ outlines.add(Lane.DotsLeft)
+ } else {
+ outlines.add(lane)
+ if (indicator.isActive) {
+ lanes.add(lane)
+ }
+ }
+ }
+ }
+ }
+ return Lanes(Arrows(lanes), Arrows(outlines))
+ }
+
+ fun asSpeed(locationMatcherResult: LocationMatcherResult): Speed {
+ return Speed(
+ locationMatcherResult.enhancedLocation.speed.let { it?.toInt() ?: 0 },
+ locationMatcherResult.speedLimitInfo.speed.let { it ?: 0 },
+ )
+
+ }
+
+ fun asEta(trip: TripProgressUpdateValue): Arrival {
+ val eta = trip.formatter
+ .getEstimatedTimeToArrival(trip.estimatedTimeToArrival)
+ .toString().split(":")
+ return Arrival(eta[0].toInt(), eta[1].toInt())
+ }
+
+ }
+
+}
diff --git a/app/src/main/java/eu/ztsh/garmin/data/Model.kt b/app/src/main/java/eu/ztsh/garmin/data/Model.kt
new file mode 100644
index 0000000..5b76a83
--- /dev/null
+++ b/app/src/main/java/eu/ztsh/garmin/data/Model.kt
@@ -0,0 +1,149 @@
+package eu.ztsh.garmin.data
+
+enum class OutType(val value: Int) {
+
+ Off(0x00),
+ Lane(0x01),
+ LongerLane(0x02),
+ LeftRoundabout(0x04),
+ RightRoundabout(0x08),
+ Flag(0x40),
+ ArrowOnly(0x80);
+
+}
+
+enum class OutAngle(val value: Int) {
+
+ Down(0x01),
+ SharpRight(0x02),
+ Right(0x04),
+ EasyRight(0x08),
+ Straight(0x10),
+ EasyLeft(0x20),
+ Left(0x40),
+ SharpLeft(0x80),
+ LeftDown(0x81),
+ RightDown(0x82),
+ AsDirection(0x00)
+
+}
+
+enum class Unit(val value: Int) {
+
+ Any(0),
+ Metres(1),
+ Kilometres(3),
+ Miles(5),
+ Foot(8)
+
+}
+
+enum class Lane(val value: Int) {
+
+ DotsRight(0x01),
+ OuterRight(0x02),
+ MiddleRight(0x04),
+ InnerRight(0x08),
+ InnerLeft(0x10),
+ MiddleLeft(0x20),
+ OuterLeft(0x40),
+ DotsLeft(0x80);
+
+ companion object {
+ val iterator = {sortedSetOf(OuterRight, MiddleRight, InnerRight, InnerLeft, MiddleLeft, OuterLeft).iterator()}
+ }
+
+}
+
+class Arrows(val lanes: Set) {
+
+ override fun equals(other: Any?): Boolean {
+ if (this === other) return true
+ if (javaClass != other?.javaClass) return false
+
+ other as Arrows
+
+ return lanes == other.lanes
+ }
+
+ override fun hashCode(): Int {
+ return lanes.hashCode()
+ }
+}
+
+class Lanes(val outlines: Arrows, val lanes: Arrows)
+
+class Distance(val distance: Double, val unit: Unit) {
+
+ override fun equals(other: Any?): Boolean {
+ if (this === other) return true
+ if (javaClass != other?.javaClass) return false
+
+ other as Distance
+
+ if (distance != other.distance) return false
+ if (unit != other.unit) return false
+
+ return true
+ }
+
+ override fun hashCode(): Int {
+ var result = distance.hashCode()
+ result = 31 * result + unit.hashCode()
+ return result
+ }
+
+ override fun toString(): String {
+ return "Distance($distance$unit)"
+ }
+
+}
+
+class Speed(val speed: Int, val limit: Int)
+
+class Arrival(val hours: Int, val minutes: Int) {
+
+ override fun equals(other: Any?): Boolean {
+ if (this === other) return true
+ if (javaClass != other?.javaClass) return false
+
+ other as Arrival
+
+ if (hours != other.hours) return false
+ if (minutes != other.minutes) return false
+
+ return true
+ }
+
+ override fun hashCode(): Int {
+ return 61 * hours + minutes
+ }
+}
+
+class Direction(
+ var angle: OutAngle = OutAngle.AsDirection,
+ var out: OutType = OutType.Lane,
+ var roundabout: OutAngle = OutAngle.AsDirection
+) {
+
+ override fun equals(other: Any?): Boolean {
+ if (this === other) return true
+ if (javaClass != other?.javaClass) return false
+
+ other as Direction
+
+ if (angle != other.angle) return false
+ if (out != other.out) return false
+ if (roundabout != other.roundabout) return false
+
+ return true
+ }
+
+ override fun hashCode(): Int {
+ var result = angle.hashCode()
+ result = 31 * result + out.hashCode()
+ result = 31 * result + roundabout.hashCode()
+ return result
+ }
+
+}
diff --git a/app/src/main/java/eu/ztsh/garmin/mapbox/LocationObserver.kt b/app/src/main/java/eu/ztsh/garmin/mapbox/LocationObserver.kt
new file mode 100644
index 0000000..8dfefde
--- /dev/null
+++ b/app/src/main/java/eu/ztsh/garmin/mapbox/LocationObserver.kt
@@ -0,0 +1,49 @@
+package eu.ztsh.garmin.mapbox
+
+import com.mapbox.common.location.Location
+import com.mapbox.navigation.core.trip.session.LocationMatcherResult
+import com.mapbox.navigation.core.trip.session.LocationObserver
+import com.mapbox.navigation.ui.maps.camera.transition.NavigationCameraTransitionOptions
+import eu.ztsh.garmin.Garmin
+
+class LocationObserver(private val mapControl: MapControl) : LocationObserver {
+
+ /**
+ * Gets notified with location updates.
+ *
+ * Exposes raw updates coming directly from the location services
+ * and the updates enhanced by the Navigation SDK (cleaned up and matched to the road).
+ */
+ private var firstLocationUpdateReceived = false
+
+ override fun onNewRawLocation(rawLocation: Location) {
+ // not handled
+ }
+
+ override fun onNewLocationMatcherResult(locationMatcherResult: LocationMatcherResult) {
+ val enhancedLocation = locationMatcherResult.enhancedLocation
+ // update location puck's position on the map
+ mapControl.navigationLocationProvider.changePosition(
+ location = enhancedLocation,
+ keyPoints = locationMatcherResult.keyPoints,
+ )
+
+ // update camera position to account for new location
+ mapControl.viewportDataSource.onLocationChanged(enhancedLocation)
+ mapControl.viewportDataSource.evaluate()
+
+ Garmin.instance.process(locationMatcherResult)
+
+ // if this is the first location update the activity has received,
+ // it's best to immediately move the camera to the current user location
+ if (!firstLocationUpdateReceived) {
+ firstLocationUpdateReceived = true
+ mapControl.navigationCamera.requestNavigationCameraToOverview(
+ stateTransitionOptions = NavigationCameraTransitionOptions.Builder()
+ .maxDuration(0) // instant transition
+ .build()
+ )
+ }
+ }
+
+}
diff --git a/app/src/main/java/eu/ztsh/garmin/mapbox/MapControl.kt b/app/src/main/java/eu/ztsh/garmin/mapbox/MapControl.kt
new file mode 100644
index 0000000..5ae5688
--- /dev/null
+++ b/app/src/main/java/eu/ztsh/garmin/mapbox/MapControl.kt
@@ -0,0 +1,158 @@
+package eu.ztsh.garmin.mapbox
+
+import android.content.res.Configuration
+import android.content.res.Resources
+import android.view.View
+import androidx.appcompat.app.AppCompatActivity
+import com.mapbox.maps.ImageHolder
+import com.mapbox.maps.plugin.LocationPuck2D
+import com.mapbox.maps.plugin.animation.camera
+import com.mapbox.maps.plugin.locationcomponent.location
+import com.mapbox.navigation.base.options.NavigationOptions
+import com.mapbox.navigation.core.MapboxNavigation
+import com.mapbox.navigation.core.directions.session.RoutesObserver
+import com.mapbox.navigation.core.lifecycle.MapboxNavigationApp
+import com.mapbox.navigation.core.lifecycle.MapboxNavigationObserver
+import com.mapbox.navigation.core.trip.session.LocationObserver
+import com.mapbox.navigation.core.trip.session.RouteProgressObserver
+import com.mapbox.navigation.core.trip.session.VoiceInstructionsObserver
+import com.mapbox.navigation.ui.maps.camera.NavigationCamera
+import com.mapbox.navigation.ui.maps.camera.data.MapboxNavigationViewportDataSource
+import com.mapbox.navigation.ui.maps.camera.lifecycle.NavigationBasicGesturesHandler
+import com.mapbox.navigation.ui.maps.camera.state.NavigationCameraState
+import com.mapbox.navigation.ui.maps.location.NavigationLocationProvider
+import eu.ztsh.garmin.MainActivity
+import eu.ztsh.garmin.UI
+import eu.ztsh.garmin.mock.ReplayResources
+
+class MapControl(
+ val context: AppCompatActivity,
+ val ui: UI,
+ private val resources: Resources
+) : MapboxNavigationObserver {
+
+ /**
+ * Used to execute camera transitions based on the data generated by the [viewportDataSource].
+ * This includes transitions from route overview to route following and continuously updating the camera as the location changes.
+ */
+ lateinit var navigationCamera: NavigationCamera
+
+ /**
+ * Produces the camera frames based on the location and routing data for the [navigationCamera] to execute.
+ */
+ lateinit var viewportDataSource: MapboxNavigationViewportDataSource
+
+ /**
+ * [NavigationLocationProvider] is a utility class that helps to provide location updates generated by the Navigation SDK
+ * to the Maps SDK in order to update the user location indicator on the map.
+ */
+ val navigationLocationProvider = NavigationLocationProvider()
+
+ val replay = ReplayResources(this)
+
+ // Observers
+ private lateinit var routeControl: RouteControl
+ private lateinit var voiceControl: VoiceControl
+
+ private lateinit var routesObserver: RoutesObserver
+ private lateinit var locationObserver: LocationObserver
+ private lateinit var routeProgressObserver: RouteProgressObserver
+ private lateinit var voiceInstructionsObserver: VoiceInstructionsObserver
+
+ fun init() {
+ viewportDataSource = MapboxNavigationViewportDataSource(ui.mapView.mapboxMap)
+ if (this.resources.configuration.orientation == Configuration.ORIENTATION_LANDSCAPE) {
+ viewportDataSource.overviewPadding = UI.landscapeOverviewPadding
+ } else {
+ viewportDataSource.overviewPadding = UI.overviewPadding
+ }
+ if (this.resources.configuration.orientation == Configuration.ORIENTATION_LANDSCAPE) {
+ viewportDataSource.followingPadding = UI.landscapeFollowingPadding
+ } else {
+ viewportDataSource.followingPadding = UI.followingPadding
+ }
+
+ navigationCamera = NavigationCamera(
+ ui.mapView.mapboxMap,
+ ui.mapView.camera,
+ viewportDataSource
+ )
+ // set the animations lifecycle listener to ensure the NavigationCamera stops
+ // automatically following the user location when the map is interacted with
+ ui.mapView.camera.addCameraAnimationsLifecycleListener(
+ NavigationBasicGesturesHandler(navigationCamera)
+ )
+ navigationCamera.registerNavigationCameraStateChangeObserver { navigationCameraState ->
+ // shows/hide the recenter button depending on the camera state
+ when (navigationCameraState) {
+ NavigationCameraState.TRANSITION_TO_FOLLOWING,
+ NavigationCameraState.FOLLOWING -> ui.recenter.visibility = View.INVISIBLE
+
+ NavigationCameraState.TRANSITION_TO_OVERVIEW,
+ NavigationCameraState.OVERVIEW,
+ NavigationCameraState.IDLE -> ui.recenter.visibility = View.VISIBLE
+ }
+ }
+
+ routeControl = RouteControl(this, ui, context)
+ voiceControl = VoiceControl(ui, context)
+
+ routesObserver = routeControl.routesObserver
+ locationObserver = LocationObserver(this)
+ routeProgressObserver = routeControl.routeProgressObserver
+ voiceInstructionsObserver = voiceControl.voiceInstructionsObserver
+ }
+
+ fun initNavigation() {
+ MapboxNavigationApp.setup(
+ NavigationOptions.Builder(context)
+ .build()
+ )
+
+ // initialize location puck
+ ui.mapView.location.apply {
+ setLocationProvider(navigationLocationProvider)
+ this.locationPuck = LocationPuck2D(
+ bearingImage = ImageHolder.Companion.from(
+ com.mapbox.navigation.ui.maps.R.drawable.mapbox_navigation_puck_icon
+ )
+ )
+ puckBearingEnabled = true
+ enabled = true
+ }
+
+ replay.replayOriginLocation()
+ }
+
+ fun mapboxNavigation(): MapboxNavigation {
+ return (context as MainActivity).mapboxNavigation
+ }
+
+ override fun onAttached(mapboxNavigation: MapboxNavigation) {
+ mapboxNavigation.registerRoutesObserver(routesObserver)
+ mapboxNavigation.registerLocationObserver(locationObserver)
+ mapboxNavigation.registerRouteProgressObserver(routeProgressObserver)
+ mapboxNavigation.registerVoiceInstructionsObserver(voiceInstructionsObserver)
+
+ replay.onAttached(mapboxNavigation)
+ }
+
+ override fun onDetached(mapboxNavigation: MapboxNavigation) {
+ mapboxNavigation.unregisterRoutesObserver(routesObserver)
+ mapboxNavigation.unregisterLocationObserver(locationObserver)
+ mapboxNavigation.unregisterRouteProgressObserver(routeProgressObserver)
+ mapboxNavigation.unregisterVoiceInstructionsObserver(voiceInstructionsObserver)
+ replay.onDetached(mapboxNavigation)
+ }
+
+ fun onDestroy() {
+ routeControl.cancel()
+ voiceControl.cancel()
+ }
+
+ companion object {
+
+ const val TAG = "MAPCTRL"
+
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/eu/ztsh/garmin/mapbox/RouteControl.kt b/app/src/main/java/eu/ztsh/garmin/mapbox/RouteControl.kt
new file mode 100644
index 0000000..a59a899
--- /dev/null
+++ b/app/src/main/java/eu/ztsh/garmin/mapbox/RouteControl.kt
@@ -0,0 +1,309 @@
+package eu.ztsh.garmin.mapbox
+
+import android.content.Context
+import android.view.View
+import android.widget.Toast
+import com.mapbox.api.directions.v5.models.Bearing
+import com.mapbox.api.directions.v5.models.RouteOptions
+import com.mapbox.geojson.Point
+import com.mapbox.maps.plugin.gestures.gestures
+import com.mapbox.navigation.base.TimeFormat
+import com.mapbox.navigation.base.extensions.applyDefaultNavigationOptions
+import com.mapbox.navigation.base.extensions.applyLanguageAndVoiceUnitOptions
+import com.mapbox.navigation.base.formatter.DistanceFormatterOptions
+import com.mapbox.navigation.base.route.NavigationRoute
+import com.mapbox.navigation.base.route.NavigationRouterCallback
+import com.mapbox.navigation.base.route.RouterFailure
+import com.mapbox.navigation.core.MapboxNavigation
+import com.mapbox.navigation.core.directions.session.RoutesObserver
+import com.mapbox.navigation.core.formatter.MapboxDistanceFormatter
+import com.mapbox.navigation.core.trip.session.RouteProgressObserver
+import com.mapbox.navigation.tripdata.maneuver.api.MapboxManeuverApi
+import com.mapbox.navigation.tripdata.progress.api.MapboxTripProgressApi
+import com.mapbox.navigation.tripdata.progress.model.DistanceRemainingFormatter
+import com.mapbox.navigation.tripdata.progress.model.EstimatedTimeToArrivalFormatter
+import com.mapbox.navigation.tripdata.progress.model.PercentDistanceTraveledFormatter
+import com.mapbox.navigation.tripdata.progress.model.TimeRemainingFormatter
+import com.mapbox.navigation.tripdata.progress.model.TripProgressUpdateFormatter
+import com.mapbox.navigation.ui.components.maneuver.view.MapboxManeuverView
+import com.mapbox.navigation.ui.components.tripprogress.view.MapboxTripProgressView
+import com.mapbox.navigation.ui.maps.NavigationStyles
+import com.mapbox.navigation.ui.maps.route.arrow.api.MapboxRouteArrowApi
+import com.mapbox.navigation.ui.maps.route.arrow.api.MapboxRouteArrowView
+import com.mapbox.navigation.ui.maps.route.arrow.model.RouteArrowOptions
+import com.mapbox.navigation.ui.maps.route.line.api.MapboxRouteLineApi
+import com.mapbox.navigation.ui.maps.route.line.api.MapboxRouteLineView
+import com.mapbox.navigation.ui.maps.route.line.model.MapboxRouteLineApiOptions
+import com.mapbox.navigation.ui.maps.route.line.model.MapboxRouteLineViewOptions
+import eu.ztsh.garmin.Garmin
+import eu.ztsh.garmin.UI
+
+class RouteControl(private val mapControl: MapControl, ui: UI, private val context: Context) {
+
+ /**
+ * Generates updates for the [MapboxManeuverView] to display the upcoming maneuver instructions
+ * and remaining distance to the maneuver point.
+ */
+ private lateinit var maneuverApi: MapboxManeuverApi
+
+ /**
+ * Generates updates for the [MapboxTripProgressView] that include remaining time and distance to the destination.
+ */
+ private lateinit var tripProgressApi: MapboxTripProgressApi
+
+ /**
+ * Generates updates for the [routeLineView] with the geometries and properties of the routes that should be drawn on the map.
+ */
+ private lateinit var routeLineApi: MapboxRouteLineApi
+
+ /**
+ * Draws route lines on the map based on the data from the [routeLineApi]
+ */
+ private lateinit var routeLineView: MapboxRouteLineView
+
+ /**
+ * Generates updates for the [routeArrowView] with the geometries and properties of maneuver arrows that should be drawn on the map.
+ */
+ private val routeArrowApi: MapboxRouteArrowApi = MapboxRouteArrowApi()
+
+ /**
+ * Draws maneuver arrows on the map based on the data [routeArrowApi].
+ */
+ private lateinit var routeArrowView: MapboxRouteArrowView
+
+ init {
+ // make sure to use the same DistanceFormatterOptions across different features
+ val distanceFormatterOptions = DistanceFormatterOptions.Builder(context).build()
+ maneuverApi = MapboxManeuverApi(
+ MapboxDistanceFormatter(distanceFormatterOptions)
+ )
+
+ // initialize bottom progress view
+ tripProgressApi = MapboxTripProgressApi(
+ TripProgressUpdateFormatter.Builder(context)
+ .distanceRemainingFormatter(
+ DistanceRemainingFormatter(distanceFormatterOptions)
+ )
+ .timeRemainingFormatter(
+ TimeRemainingFormatter(context)
+ )
+ .percentRouteTraveledFormatter(
+ PercentDistanceTraveledFormatter()
+ )
+ .estimatedTimeToArrivalFormatter(
+ EstimatedTimeToArrivalFormatter(context, TimeFormat.TWENTY_FOUR_HOURS)
+ )
+ .build()
+ )
+ // initialize route line, the routeLineBelowLayerId is specified to place
+ // the route line below road labels layer on the map
+ // the value of this option will depend on the style that you are using
+ // and under which layer the route line should be placed on the map layers stack
+ val mapboxRouteLineViewOptions = MapboxRouteLineViewOptions.Builder(context)
+ .routeLineBelowLayerId("road-label-navigation")
+ .build()
+
+ routeLineApi = MapboxRouteLineApi(MapboxRouteLineApiOptions.Builder().build())
+ routeLineView = MapboxRouteLineView(mapboxRouteLineViewOptions)
+
+ // initialize maneuver arrow view to draw arrows on the map
+ val routeArrowOptions = RouteArrowOptions.Builder(context).build()
+ routeArrowView = MapboxRouteArrowView(routeArrowOptions)
+
+ // load map style
+ ui.mapView.mapboxMap.loadStyle(NavigationStyles.NAVIGATION_DAY_STYLE) {
+ // Ensure that the route line related layers are present before the route arrow
+ routeLineView.initializeLayers(it)
+
+ // add long click listener that search for a route to the clicked destination
+ ui.mapView.gestures.addOnMapLongClickListener { point ->
+ findRoute(point)
+ true
+ }
+ }
+
+ // initialize view interactions
+ ui.stop.setOnClickListener {
+ clearRouteAndStopNavigation()
+ }
+ ui.recenter.setOnClickListener {
+ mapControl.navigationCamera.requestNavigationCameraToFollowing()
+ ui.routeOverview.showTextAndExtend(UI.BUTTON_ANIMATION_DURATION)
+ }
+ ui.routeOverview.setOnClickListener {
+ mapControl.navigationCamera.requestNavigationCameraToOverview()
+ ui.recenter.showTextAndExtend(UI.BUTTON_ANIMATION_DURATION)
+ }
+
+ // set initial sounds button state
+ ui.soundButton.unmute()
+ }
+
+ /**
+ * Gets notified with progress along the currently active route.
+ */
+ val routeProgressObserver = RouteProgressObserver { routeProgress ->
+ // update the camera position to account for the progressed fragment of the route
+ mapControl.viewportDataSource.onRouteProgressChanged(routeProgress)
+ mapControl.viewportDataSource.evaluate()
+
+ // draw the upcoming maneuver arrow on the map
+ val style = mapControl.ui.mapView.mapboxMap.style
+ if (style != null) {
+ val maneuverArrowResult = routeArrowApi.addUpcomingManeuverArrow(routeProgress)
+ routeArrowView.renderManeuverUpdate(style, maneuverArrowResult)
+ }
+
+ // update top banner with maneuver instructions
+ val maneuvers = maneuverApi.getManeuvers(routeProgress)
+ maneuvers.fold(
+ { error ->
+ Toast.makeText(
+ mapControl.context,
+ error.errorMessage,
+ Toast.LENGTH_SHORT
+ ).show()
+ },
+ {
+ mapControl.ui.maneuverView.visibility = View.VISIBLE
+ mapControl.ui.maneuverView.renderManeuvers(maneuvers)
+ Garmin.instance.process(it[0])
+ }
+ )
+
+ // update bottom trip progress summary and send to HUD
+ tripProgressApi.getTripProgress(routeProgress).let {
+ mapControl.ui.tripProgressView.render(it)
+ Garmin.instance.process(it)
+ }
+ }
+
+ /**
+ * Gets notified whenever the tracked routes change.
+ *
+ * A change can mean:
+ * - routes get changed with [MapboxNavigation.setNavigationRoutes]
+ * - routes annotations get refreshed (for example, congestion annotation that indicate the live traffic along the route)
+ * - driver got off route and a reroute was executed
+ */
+ val routesObserver = RoutesObserver { routeUpdateResult ->
+ if (routeUpdateResult.navigationRoutes.isNotEmpty()) {
+ // generate route geometries asynchronously and render them
+ routeLineApi.setNavigationRoutes(
+ routeUpdateResult.navigationRoutes
+ ) { value ->
+ mapControl.ui.mapView.mapboxMap.style?.apply {
+ routeLineView.renderRouteDrawData(this, value)
+ }
+ }
+
+ // update the camera position to account for the new route
+ mapControl.viewportDataSource.onRouteChanged(routeUpdateResult.navigationRoutes.first())
+ mapControl.viewportDataSource.evaluate()
+ } else {
+ // remove the route line and route arrow from the map
+ val style = mapControl.ui.mapView.mapboxMap.style
+ if (style != null) {
+ routeLineApi.clearRouteLine { value ->
+ routeLineView.renderClearRouteLineValue(
+ style,
+ value
+ )
+ }
+ routeArrowView.render(style, routeArrowApi.clearArrows())
+ }
+
+ // remove the route reference from camera position evaluations
+ mapControl.viewportDataSource.clearRouteData()
+ mapControl.viewportDataSource.evaluate()
+ }
+ }
+
+ private fun findRoute(destination: Point) {
+ val originLocation = mapControl.navigationLocationProvider.lastLocation ?: return
+ val originPoint = Point.fromLngLat(originLocation.longitude, originLocation.latitude)
+
+ // execute a route request
+ // it's recommended to use the
+ // applyDefaultNavigationOptions and applyLanguageAndVoiceUnitOptions
+ // that make sure the route request is optimized
+ // to allow for support of all of the Navigation SDK features
+ mapControl.mapboxNavigation().requestRoutes(
+ RouteOptions.builder()
+ .applyDefaultNavigationOptions()
+ .applyLanguageAndVoiceUnitOptions(context)
+ .coordinatesList(listOf(originPoint, destination))
+ .apply {
+ // provide the bearing for the origin of the request to ensure
+ // that the returned route faces in the direction of the current user movement
+ originLocation.bearing?.let { bearing ->
+ bearingsList(
+ listOf(
+ Bearing.builder()
+ .angle(bearing)
+ .degrees(45.0)
+ .build(),
+ null
+ )
+ )
+ }
+ }
+ .layersList(listOf(mapControl.mapboxNavigation().getZLevel(), null))
+ .build(),
+ object : NavigationRouterCallback {
+ override fun onCanceled(routeOptions: RouteOptions, routerOrigin: String) {
+ // no impl
+ }
+
+ override fun onFailure(reasons: List, routeOptions: RouteOptions) {
+ // no impl
+ }
+
+ override fun onRoutesReady(
+ routes: List,
+ routerOrigin: String
+ ) {
+ setRouteAndStartNavigation(routes)
+ }
+ }
+ )
+ }
+
+ private fun setRouteAndStartNavigation(routes: List) {
+ // set routes, where the first route in the list is the primary route that
+ // will be used for active guidance
+ mapControl.mapboxNavigation().setNavigationRoutes(routes)
+
+ // show UI elements
+ mapControl.ui.soundButton.visibility = View.VISIBLE
+ mapControl.ui.routeOverview.visibility = View.VISIBLE
+ mapControl.ui.tripProgressCard.visibility = View.VISIBLE
+
+ // move the camera to overview when new route is available
+ mapControl.navigationCamera.requestNavigationCameraToOverview()
+
+ // start simulation
+ mapControl.replay.startSimulation(routes.first().directionsRoute)
+ }
+
+ private fun clearRouteAndStopNavigation() {
+ // clear
+ mapControl.mapboxNavigation().setNavigationRoutes(listOf())
+
+ // stop simulation
+ mapControl.replay.stopSimulation()
+
+ // hide UI elements
+ mapControl.ui.soundButton.visibility = View.INVISIBLE
+ mapControl.ui.maneuverView.visibility = View.INVISIBLE
+ mapControl.ui.routeOverview.visibility = View.INVISIBLE
+ mapControl.ui.tripProgressCard.visibility = View.INVISIBLE
+ }
+
+ fun cancel() {
+ maneuverApi.cancel()
+ routeLineApi.cancel()
+ routeLineView.cancel()
+ }
+
+}
\ No newline at end of file
diff --git a/app/src/main/java/eu/ztsh/garmin/mapbox/VoiceControl.kt b/app/src/main/java/eu/ztsh/garmin/mapbox/VoiceControl.kt
new file mode 100644
index 0000000..aa9578f
--- /dev/null
+++ b/app/src/main/java/eu/ztsh/garmin/mapbox/VoiceControl.kt
@@ -0,0 +1,111 @@
+package eu.ztsh.garmin.mapbox
+
+import android.content.Context
+import com.mapbox.bindgen.Expected
+import com.mapbox.navigation.core.trip.session.VoiceInstructionsObserver
+import com.mapbox.navigation.ui.base.util.MapboxNavigationConsumer
+import com.mapbox.navigation.voice.api.MapboxSpeechApi
+import com.mapbox.navigation.voice.api.MapboxVoiceInstructionsPlayer
+import com.mapbox.navigation.voice.model.SpeechAnnouncement
+import com.mapbox.navigation.voice.model.SpeechError
+import com.mapbox.navigation.voice.model.SpeechValue
+import com.mapbox.navigation.voice.model.SpeechVolume
+import eu.ztsh.garmin.UI
+import java.util.*
+
+class VoiceControl(private val ui: UI, context: Context) {
+
+ /**
+ * Extracts message that should be communicated to the driver about the upcoming maneuver.
+ * When possible, downloads a synthesized audio file that can be played back to the driver.
+ */
+ private lateinit var speechApi: MapboxSpeechApi
+
+ /**
+ * Plays the synthesized audio files with upcoming maneuver instructions
+ * or uses an on-device Text-To-Speech engine to communicate the message to the driver.
+ * NOTE: do not use lazy initialization for this class since it takes some time to initialize
+ * the system services required for on-device speech synthesis. With lazy initialization
+ * there is a high risk that said services will not be available when the first instruction
+ * has to be played. [MapboxVoiceInstructionsPlayer] should be instantiated in
+ * `Activity#onCreate`.
+ */
+ private lateinit var voiceInstructionsPlayer: MapboxVoiceInstructionsPlayer
+
+ init {
+ speechApi = MapboxSpeechApi(
+ context,
+ Locale("pl").language
+ )
+ voiceInstructionsPlayer = MapboxVoiceInstructionsPlayer(
+ context,
+ Locale("pl").language
+ )
+ ui.soundButton.setOnClickListener {
+ // mute/unmute voice instructions
+ isVoiceInstructionsMuted = !isVoiceInstructionsMuted
+ }
+ }
+
+ /**
+ * Stores and updates the state of whether the voice instructions should be played as they come or muted.
+ */
+ private var isVoiceInstructionsMuted = false
+ set(value) {
+ field = value
+ if (value) {
+ ui.soundButton.muteAndExtend(UI.BUTTON_ANIMATION_DURATION)
+ voiceInstructionsPlayer.volume(SpeechVolume(0f))
+ } else {
+ ui.soundButton.unmuteAndExtend(UI.BUTTON_ANIMATION_DURATION)
+ voiceInstructionsPlayer.volume(SpeechVolume(1f))
+ }
+ }
+
+ /**
+ * Observes when a new voice instruction should be played.
+ */
+ val voiceInstructionsObserver = VoiceInstructionsObserver { voiceInstructions ->
+ speechApi.generate(voiceInstructions, speechCallback)
+ }
+
+ /**
+ * Based on whether the synthesized audio file is available, the callback plays the file
+ * or uses the fall back which is played back using the on-device Text-To-Speech engine.
+ */
+ private val speechCallback =
+ MapboxNavigationConsumer> { expected ->
+ expected.fold(
+ { error ->
+ // play the instruction via fallback text-to-speech engine
+ voiceInstructionsPlayer.play(
+ error.fallback,
+ voiceInstructionsPlayerCallback
+ )
+ },
+ { value ->
+ // play the sound file from the external generator
+ voiceInstructionsPlayer.play(
+ value.announcement,
+ voiceInstructionsPlayerCallback
+ )
+ }
+ )
+ }
+
+ /**
+ * When a synthesized audio file was downloaded, this callback cleans up the disk after it was played.
+ */
+ private val voiceInstructionsPlayerCallback =
+ MapboxNavigationConsumer { value ->
+ // remove already consumed file to free-up space
+ speechApi.clean(value)
+ }
+
+ fun cancel() {
+ speechApi.cancel()
+ voiceInstructionsPlayer.shutdown()
+
+ }
+
+}
diff --git a/app/src/main/java/eu/ztsh/garmin/mock/ReplayResources.kt b/app/src/main/java/eu/ztsh/garmin/mock/ReplayResources.kt
new file mode 100644
index 0000000..b25e579
--- /dev/null
+++ b/app/src/main/java/eu/ztsh/garmin/mock/ReplayResources.kt
@@ -0,0 +1,73 @@
+package eu.ztsh.garmin.mock
+
+import com.mapbox.api.directions.v5.models.DirectionsRoute
+import com.mapbox.navigation.base.ExperimentalPreviewMapboxNavigationAPI
+import com.mapbox.navigation.core.MapboxNavigation
+import com.mapbox.navigation.core.replay.route.ReplayProgressObserver
+import com.mapbox.navigation.core.replay.route.ReplayRouteMapper
+import eu.ztsh.garmin.mapbox.MapControl
+import java.util.*
+
+@OptIn(ExperimentalPreviewMapboxNavigationAPI::class)
+class ReplayResources(private val mapControl: MapControl) {
+
+ /**
+ * Debug observer that makes sure the replayer has always an up-to-date information to generate mock updates.
+ */
+ private lateinit var replayProgressObserver: ReplayProgressObserver
+
+ /**
+ * Debug object that converts a route into events that can be replayed to navigate a route.
+ */
+ private val replayRouteMapper = ReplayRouteMapper()
+
+ fun replayOriginLocation() {
+ with(mapControl.mapboxNavigation().mapboxReplayer) {
+ play()
+ pushEvents(
+ listOf(
+ ReplayRouteMapper.mapToUpdateLocation(
+ Date().time.toDouble(),
+ com.mapbox.geojson.Point.fromLngLat(18.531478, 50.088155)
+ )
+ )
+ )
+ playFirstLocation()
+ }
+ }
+
+ fun startSimulation(route: DirectionsRoute) {
+ with(mapControl.mapboxNavigation()) {
+ mapboxReplayer.stop()
+ mapboxReplayer.clearEvents()
+ val replayData = replayRouteMapper.mapDirectionsRouteGeometry(route)
+ mapboxReplayer.pushEvents(replayData)
+ mapboxReplayer.seekTo(replayData[0])
+ mapboxReplayer.play()
+ }
+ }
+
+ fun stopSimulation() {
+ with(mapControl.mapboxNavigation()) {
+ mapboxReplayer.stop()
+ mapboxReplayer.clearEvents()
+ }
+ }
+
+ fun onAttached(mapboxNavigation: MapboxNavigation) {
+ replayProgressObserver = ReplayProgressObserver(mapboxNavigation.mapboxReplayer)
+ mapboxNavigation.registerRouteProgressObserver(replayProgressObserver)
+
+ // Start the trip session to being receiving location updates in free drive
+ // and later when a route is set also receiving route progress updates.
+ // In case of `startReplayTripSession`,
+ // location events are emitted by the `MapboxReplayer`
+ mapboxNavigation.startReplayTripSession()
+ }
+
+ fun onDetached(mapboxNavigation: MapboxNavigation) {
+ mapboxNavigation.unregisterRouteProgressObserver(replayProgressObserver)
+ mapboxNavigation.mapboxReplayer.finish()
+ }
+
+}
\ No newline at end of file
diff --git a/app/src/main/java/eu/ztsh/garmin/util/PermissionsHelper.kt b/app/src/main/java/eu/ztsh/garmin/util/PermissionsHelper.kt
new file mode 100644
index 0000000..573e457
--- /dev/null
+++ b/app/src/main/java/eu/ztsh/garmin/util/PermissionsHelper.kt
@@ -0,0 +1,50 @@
+package eu.ztsh.garmin.util
+
+import android.app.Activity
+import android.widget.Toast
+import com.mapbox.android.core.permissions.PermissionsListener
+import com.mapbox.android.core.permissions.PermissionsManager
+import java.lang.ref.WeakReference
+
+class PermissionsHelper(val activityRef: WeakReference) {
+ private lateinit var permissionsManager: PermissionsManager
+
+ fun checkPermissions(onMapReady: () -> Unit) {
+ activityRef.get()?.let { activity: Activity ->
+ if (PermissionsManager.areLocationPermissionsGranted(activity)) {
+ onMapReady()
+ } else {
+ permissionsManager = PermissionsManager(object : PermissionsListener {
+
+ override fun onExplanationNeeded(permissionsToExplain: List) {
+ activityRef.get()?.let {
+ Toast.makeText(
+ it, "You need to accept location permissions.",
+ Toast.LENGTH_SHORT
+ ).show()
+ }
+ }
+
+ override fun onPermissionResult(granted: Boolean) {
+ activityRef.get()?.let {
+ if (granted) {
+ onMapReady()
+ } else {
+ it.finish()
+ }
+ }
+ }
+ })
+ permissionsManager.requestLocationPermissions(activity)
+ }
+ }
+ }
+
+ fun onRequestPermissionsResult(
+ requestCode: Int,
+ permissions: Array,
+ grantResults: IntArray
+ ) {
+ permissionsManager.onRequestPermissionsResult(requestCode, permissions, grantResults)
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/res/layout/activity_main.xml b/app/src/main/res/layout/activity_main.xml
index 1392caf..cb6c22d 100644
--- a/app/src/main/res/layout/activity_main.xml
+++ b/app/src/main/res/layout/activity_main.xml
@@ -1,19 +1,80 @@
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+ android:layout_marginTop="8dp"
+ android:layout_marginEnd="16dp"
+ android:visibility="invisible"
+ app:layout_constraintEnd_toEndOf="parent"
+ app:layout_constraintTop_toBottomOf="@id/maneuverView" />
-
+
+
+
+
+
\ No newline at end of file
diff --git a/app/src/main/res/values-night/themes.xml b/app/src/main/res/values-night/themes.xml
index 91b45eb..b521f31 100644
--- a/app/src/main/res/values-night/themes.xml
+++ b/app/src/main/res/values-night/themes.xml
@@ -1,6 +1,6 @@
-