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 @@ -