Garmin connection (DIRTY)

This commit is contained in:
Piotr Dec 2023-08-04 02:31:31 +02:00
parent 78922546ab
commit 0c6e305aea
10 changed files with 638 additions and 59 deletions

View file

@ -1,16 +1,17 @@
plugins {
id 'com.android.application'
id 'org.jetbrains.kotlin.android'
id "de.mannodermaus.android-junit5"
}
android {
namespace 'eu.ztsh.garmin'
compileSdk 33
compileSdk 31
defaultConfig {
applicationId "eu.ztsh.garmin"
minSdk 29
targetSdk 33
targetSdk 31
versionCode 1
versionName "1.0"
@ -36,17 +37,18 @@ 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'
//
// // 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.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'
// testImplementation 'junit:junit:4.13.2'
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'

View file

@ -1,25 +1,43 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<application
android:allowBackup="true"
android:dataExtractionRules="@xml/data_extraction_rules"
android:fullBackupContent="@xml/backup_rules"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:supportsRtl="true"
android:theme="@style/Theme.Garmin"
tools:targetApi="31">
<activity
android:name=".MainActivity"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.MAIN"/>
<category android:name="android.intent.category.LAUNCHER"/>
</intent-filter>
</activity>
</application>
</manifest>
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<!-- Request legacy Bluetooth permissions on older devices. -->
<uses-permission android:name="android.permission.BLUETOOTH"
android:maxSdkVersion="30" />
<uses-permission android:name="android.permission.BLUETOOTH_ADMIN"
android:maxSdkVersion="30" />
<!-- Needed only if your app looks for Bluetooth devices.
If your app doesn't use Bluetooth scan results to derive physical
location information, you can strongly assert that your app
doesn't derive physical location. -->
<uses-permission android:name="android.permission.BLUETOOTH_SCAN" />
<!-- Needed only if your app communicates with already-paired Bluetooth
devices. -->
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT" />
<uses-feature android:name="android.hardware.bluetooth" android:required="true"/>
<application
android:allowBackup="true"
android:dataExtractionRules="@xml/data_extraction_rules"
android:fullBackupContent="@xml/backup_rules"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:supportsRtl="true"
android:theme="@style/Theme.Garmin"
tools:targetApi="31">
<activity
android:name=".MainActivity"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.MAIN"/>
<category android:name="android.intent.category.LAUNCHER"/>
</intent-filter>
</activity>
</application>
</manifest>

View file

@ -0,0 +1,112 @@
package eu.ztsh.garmin
import android.annotation.SuppressLint
import android.bluetooth.BluetoothAdapter
import android.bluetooth.BluetoothDevice
import android.bluetooth.BluetoothSocket
import android.util.Log
import java.io.IOException
import java.util.*
@SuppressLint("MissingPermission")
class Garmin(
val context: MainActivity,
val device: BluetoothDevice,
val adapter: BluetoothAdapter
) {
private lateinit var thread: ConnectThread
fun start() {
thread = ConnectThread()
thread.start()
}
fun close() {
thread.close()
}
private inner class ConnectThread : Thread() {
private val socket: BluetoothSocket? by lazy(LazyThreadSafetyMode.NONE) {
context.checkBt()
device.createRfcommSocketToServiceRecord(UUID.fromString("00001101-0000-1000-8000-00805F9B34FB"))
}
override fun run() {
// Cancel discovery because it otherwise slows down the connection.
context.checkBt()
adapter.cancelDiscovery()
socket?.connect()
sleep(3000)
readAll()
send(intArrayOf(1, 1, 0, 16))
send(intArrayOf(3, 0, 1, 10, 0, 10, 3))
send(intArrayOf(3, 0, 0, 9, 0, 9, 3))
send(intArrayOf(0x04, 0x01))
}
// Closes the client socket and causes the thread to finish.
fun close() {
try {
socket?.close()
} catch (e: IOException) {
Log.e(TAG, "Could not close the client socket", e)
}
}
fun readAll() {
val buffer = ByteArray(64)
val istr = socket!!.inputStream
istr!!.let {
if (it.available() > 0) {
it.read(buffer)
}
}
}
fun send(hex: IntArray) {
sendRaw(prepareData(hex))
}
private fun sendRaw(buff: IntArray) {
buff.forEach { socket!!.outputStream.write(it) }
socket!!.outputStream.flush()
sleep(2000)
readAll()
}
}
companion object {
fun prepareData(input: IntArray): IntArray {
val n = input.size
var crc = (0xeb + n + n).toUInt()
val chars = ArrayList<Int>()
chars.add(0x10)
chars.add(0x7b)
chars.add((n + 0x06))
if (n == 0xa)
chars.add(0x10)
chars.add(n)
chars.add(0x00)
chars.add(0x00)
chars.add(0x00)
chars.add(0x55)
chars.add(0x15)
for (char in input) {
crc = (crc + char.toUInt())
chars.add(char)
if (char == 0x10)
chars.add(0x10)
}
chars.add((-(crc.toInt()) and 0xff))
chars.add(0x10)
chars.add(0x03)
return chars.toIntArray()
}
private const val TAG = "GARMIN"
}
}

View file

@ -1,11 +1,125 @@
package eu.ztsh.garmin
import androidx.appcompat.app.AppCompatActivity
import android.Manifest
import android.annotation.SuppressLint
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 androidx.activity.result.ActivityResultCallback
import androidx.activity.result.contract.ActivityResultContracts.StartActivityForResult
import androidx.appcompat.app.AppCompatActivity
import androidx.core.app.ActivityCompat
import java.io.IOException
import java.util.*
@SuppressLint("MissingPermission")
class MainActivity : AppCompatActivity() {
lateinit var garmin: Garmin
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main)
bluetoothInit()
}
}
override fun onPause() {
super.onPause()
// bluetoothSerial.onPause()
}
override fun onResume() {
super.onResume()
// bluetoothSerial.onResume()
}
private fun bluetoothInit() {
val bluetoothManager: BluetoothManager = getSystemService(BluetoothManager::class.java)
val bluetoothAdapter: BluetoothAdapter = bluetoothManager.adapter
?: // Device doesn't support Bluetooth
throw Exception()
if (!bluetoothAdapter.isEnabled) {
// TODO: Start intent
val enableBtIntent = Intent(BluetoothAdapter.ACTION_REQUEST_ENABLE)
registerForActivityResult(StartActivityForResult(), ActivityResultCallback { })
}
checkBt()
val pairedDevices: Set<BluetoothDevice>? = bluetoothAdapter.bondedDevices
val context = this
pairedDevices?.firstOrNull { device ->
Log.d(TAG, device.name)
device.name.equals("GARMIN HUD")
}?.apply {
garmin = Garmin(context, this, bluetoothAdapter)
garmin.start()
}
}
fun checkBt(): Boolean {
if (Build.VERSION.SDK_INT > Build.VERSION_CODES.S) {
checkBt(Manifest.permission.BLUETOOTH_SCAN)
checkBt(Manifest.permission.BLUETOOTH_CONNECT)
} else {
checkBt(Manifest.permission.BLUETOOTH)
checkBt(Manifest.permission.BLUETOOTH_ADMIN)
}
return true
}
private fun checkBt(permission: String): Boolean {
if (ActivityCompat.checkSelfPermission(
this,
permission
) != PackageManager.PERMISSION_GRANTED
) {
ActivityCompat.requestPermissions(this, arrayOf(permission), 1)
}
return true
}
private inner class ConnectThread(val device: BluetoothDevice, val adapter: BluetoothAdapter) : Thread() {
private val mmSocket: BluetoothSocket? by lazy(LazyThreadSafetyMode.NONE) {
checkBt()
// device.createInsecureRfcommSocketToServiceRecord(UUID.fromString("7d00d7f5-921b-450c-8eda-26e1d4a15c61"))
device.createRfcommSocketToServiceRecord(UUID.fromString("00001101-0000-1000-8000-00805F9B34FB"))
}
override fun run() {
// Cancel discovery because it otherwise slows down the connection.
checkBt()
adapter.cancelDiscovery()
mmSocket?.let { socket ->
// Connect to the remote device through the socket. This call blocks
// until it succeeds or throws an exception.
socket.connect()
// The connection attempt succeeded. Perform work associated with
// the connection in a separate thread.
// manageMyConnectedSocket(socket)
}
}
// Closes the client socket and causes the thread to finish.
fun cancel() {
try {
mmSocket?.close()
} catch (e: IOException) {
Log.e(Companion.TAG, "Could not close the client socket", e)
}
}
}
companion object {
private const val TAG = "bt"
}
}

View file

@ -0,0 +1,310 @@
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<Void?, Void?, BluetoothDevice?>? = 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>(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<BluetoothDevice> = ArrayList(bluetoothAdapter.getBondedDevices())
if (pairedDevices.isNotEmpty()) {
bluetoothAdapter.cancelDiscovery()
/**
* AsyncTask to handle the establishing of a bluetooth connection
*/
// connectionTask = object : AsyncTask<Void?, Void?, BluetoothDevice?>() {
// 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<Class<*>?>(
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
}
}

View file

@ -1,8 +1,8 @@
package eu.ztsh.garmin
import org.junit.Test
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Test
import org.junit.Assert.*
/**
* Example local unit test, which will execute on the development machine (host).

View file

@ -0,0 +1,22 @@
package eu.ztsh.garmin
import org.junit.jupiter.api.Assertions.*
import org.junit.jupiter.api.Test
class GarminTest {
@Test
fun arayCreationTest() {
arrayTest(intArrayOf(0x04, 0x01), intArrayOf(16, 123, 8, 2, 0, 0, 0, 85, 21, 4, 1, 12, 16, 3))
arrayTest(
intArrayOf(3, 0, 0, 9, 0, 9, 3),
intArrayOf(16, 123, 13, 7, 0, 0, 0, 85, 21, 3, 0, 0, 9, 0, 9, 3, 0xef, 16, 3)
)
}
private fun arrayTest(input: IntArray, expected: IntArray) {
val output = Garmin.prepareData(input)
assertArrayEquals(expected, output)
}
}

View file

@ -1,6 +1,11 @@
// Top-level build file where you can add configuration options common to all sub-projects/modules.
buildscript {
dependencies {
classpath("de.mannodermaus.gradle.plugins:android-junit5:1.8.2.1")
}
}
plugins {
id 'com.android.application' version '7.4.1' apply true
id 'com.android.application' version '7.4.1' apply false
id 'com.android.library' version '7.4.1' apply false
id 'org.jetbrains.kotlin.android' version '1.8.21' apply false
}

View file

@ -1,23 +1,19 @@
# Project-wide Gradle settings.
# IDE (e.g. Android Studio) users:
# Gradle settings configured through the IDE *will override*
# any settings specified in this file.
# For more details on how to configure your build environment visit
## For more details on how to configure your build environment visit
# http://www.gradle.org/docs/current/userguide/build_environment.html
#
# Specifies the JVM arguments used for the daemon process.
# The setting is particularly useful for tweaking memory settings.
org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
# Default value: -Xmx1024m -XX:MaxPermSize=256m
# org.gradle.jvmargs=-Xmx2048m -XX:MaxPermSize=512m -XX:+HeapDumpOnOutOfMemoryError -Dfile.encoding=UTF-8
#
# When configured, Gradle will run in incubating parallel mode.
# This option should only be used with decoupled projects. More details, visit
# http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects
# org.gradle.parallel=true
# AndroidX package structure to make it clearer which packages are bundled with the
# Android operating system, and which are packaged with your app's APK
# https://developer.android.com/topic/libraries/support-library/androidx-rn
android.useAndroidX=true
# Kotlin code style for this project: "official" or "obsolete":
kotlin.code.style=official
# Enables namespacing of each library's R class so that its R class includes only the
# resources declared in the library itself and none from the library's dependencies,
# thereby reducing the size of the R class for that library
#Thu Jun 22 21:51:56 CEST 2023
android.enableJetifier=true
android.nonTransitiveRClass=true
android.useAndroidX=true
kotlin.code.style=official
org.gradle.jvmargs=-Xmx2048m -Dfile.encoding\=UTF-8
org.gradle.unsafe.configuration-cache=true

View file

@ -1,6 +1,6 @@
#Thu Jun 22 21:00:17 CEST 2023
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-7.5-bin.zip
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists
#Thu Jun 22 21:00:17 CEST 2023
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-7.5-bin.zip
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists