chore: rename project

This commit is contained in:
Stefan Zollinger
2024-04-10 10:39:07 +02:00
parent c94ca3f40f
commit e53a269b4f
16 changed files with 19 additions and 19 deletions

View File

@@ -0,0 +1,69 @@
package com.logitech.vc.kirbytest
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.app.Service
import android.content.Context
import android.content.Intent
import android.os.Build
import android.os.IBinder
import androidx.core.app.NotificationCompat
import androidx.core.content.ContextCompat
class BLEService : Service() {
private val CHANNEL_ID = "BLEService Kotlin"
companion object {
fun startService(context: Context, message: String) {
val startIntent = Intent(context,
BLEService::class.java)
startIntent.putExtra("inputExtra", message)
ContextCompat.startForegroundService(context, startIntent)
}
fun stopService(context: Context) {
val stopIntent = Intent(context, BLEService::class.java)
context.stopService(stopIntent)
}
}
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
//do heavy work on a background thread
val input = intent?.getStringExtra("inputExtra")
createNotificationChannel()
val notificationIntent = Intent(this, MainActivity::class.java)
val pendingIntent: PendingIntent =
Intent(this, MainActivity::class.java).let { notificationIntent ->
PendingIntent.getActivity(this, 0, notificationIntent,
PendingIntent.FLAG_IMMUTABLE)
}
val notification = NotificationCompat.Builder(this, CHANNEL_ID)
.setContentTitle("BLEService Service Kotlin Example")
.setContentText(input)
//.setSmallIcon(R.drawable.ic_notification)
.setContentIntent(pendingIntent)
.build()
startForeground(1, notification)
//stopSelf();
return START_NOT_STICKY
}
override fun onBind(intent: Intent): IBinder? {
return null
}
private fun createNotificationChannel() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val serviceChannel = NotificationChannel(CHANNEL_ID, "BLEService Service Channel",
NotificationManager.IMPORTANCE_DEFAULT)
val manager = getSystemService(NotificationManager::class.java)
manager!!.createNotificationChannel(serviceChannel)
}
}
}

View File

@@ -0,0 +1,779 @@
package com.logitech.vc.kirbytest
import android.annotation.SuppressLint
import android.app.Service
import android.bluetooth.BluetoothAdapter
import android.bluetooth.BluetoothDevice
import android.bluetooth.BluetoothDevice.BOND_BONDED
import android.bluetooth.BluetoothDevice.BOND_BONDING
import android.bluetooth.BluetoothDevice.BOND_NONE
import android.bluetooth.BluetoothGatt
import android.bluetooth.BluetoothGatt.GATT_SUCCESS
import android.bluetooth.BluetoothGattCallback
import android.bluetooth.BluetoothGattCharacteristic
import android.bluetooth.BluetoothGattDescriptor
import android.bluetooth.BluetoothProfile
import android.bluetooth.BluetoothProfile.STATE_DISCONNECTED
import android.bluetooth.le.ScanCallback
import android.bluetooth.le.ScanFilter
import android.bluetooth.le.ScanResult
import android.bluetooth.le.ScanSettings
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import android.os.ParcelUuid
import android.util.Log
import java.util.UUID
import java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.ConcurrentLinkedQueue
val CCC_DESCRIPTOR_UUID = UUID.fromString("00002902-0000-1000-8000-00805F9B34FB")
private const val GATT_MIN_MTU_SIZE = 23
/** Maximum BLE MTU size as defined in gatt_api.h. */
private const val GATT_MAX_MTU_SIZE = 517
/** Abstract sealed class representing a type of BLE operation */
sealed class BleOperationType {
abstract val device: BluetoothDevice
}
/** Connect to [device] and perform service discovery */
data class Connect(override val device: BluetoothDevice, val context: Context) : BleOperationType()
/** Disconnect from [device] and release all connection resources */
data class Disconnect(override val device: BluetoothDevice) : BleOperationType()
/** Request for an MTU of [mtu] */
data class MtuRequest(
override val device: BluetoothDevice,
val mtu: Int
) : BleOperationType()
data class ReadChar(
override val device: BluetoothDevice,
val serviceId: UUID,
val charId: UUID
) : BleOperationType()
data class SetNotification(
override val device: BluetoothDevice,
val serviceId: UUID,
val charId: UUID,
val enable: Boolean
) : BleOperationType()
data class DiscoverServicesRequest(
override val device: BluetoothDevice,
) : BleOperationType()
data class BondRequest(
override val device: BluetoothDevice,
) : BleOperationType()
data class UnbondRequest(
override val device: BluetoothDevice,
) : BleOperationType()
data class ReadRemoteRssi(
override val device: BluetoothDevice,
) : BleOperationType()
open class BleListener(private val deviceAddress: String?) {
open fun isRelevantMessage(address: String?): Boolean {
if (deviceAddress != null && deviceAddress == address) {
return true;
}
return deviceAddress == null
}
open fun onConnect(gatt: BluetoothGatt) {}
open fun onConnectToBondedFailed(gatt: BluetoothGatt) {}
open fun onDisconnect(gatt: BluetoothGatt) {}
open fun onSuccessfulCharRead(
gatt: BluetoothGatt,
characteristic: BluetoothGattCharacteristic
) {
}
open fun onCharChange(
gatt: BluetoothGatt,
characteristic: BluetoothGattCharacteristic
) {
}
open fun onSubscribe(
gatt: BluetoothGatt,
descriptor: BluetoothGattDescriptor,
) {
}
open fun onUnsubscribe(
gatt: BluetoothGatt,
descriptor: BluetoothGattDescriptor,
) {
}
open fun onScanningStateChange(isScanning: Boolean) {
}
open fun onScanResult(callbackType: Int, result: ScanResult) {
}
open fun onQueueSizeChange(groupedOps: Map<String, List<BleOperationType>>) {
}
open fun onBonded(device: BluetoothDevice) {
}
open fun onUnbonded(device: BluetoothDevice) {
}
open fun onReadRemoteRssi(gatt: BluetoothGatt, rssi: Int) {
}
}
private fun BluetoothGatt.printGattTable() {
if (services.isEmpty()) {
Log.i(
"printGattTable",
"No service and characteristic available, call discoverServices() first?"
)
return
}
services.forEach { service ->
val characteristicsTable = service.characteristics.joinToString(
separator = "\n|--",
prefix = "|--"
) {
"${it.uuid.toString()} | " +
"readable: ${it.isReadable()}, " +
"writable: ${it.isWritable()}, " +
"writableWithoutResponse: ${it.isWritableWithoutResponse()}, " +
"notifiable: ${it.isNotifiable()}, " +
"indicatable: ${it.isIndicatable()}, "
}
Log.i(
"printGattTable", "\nService ${service.uuid}\nCharacteristics:\n$characteristicsTable"
)
}
}
fun BluetoothGattCharacteristic.isReadable(): Boolean =
containsProperty(BluetoothGattCharacteristic.PROPERTY_READ)
fun BluetoothGattCharacteristic.isWritable(): Boolean =
containsProperty(BluetoothGattCharacteristic.PROPERTY_WRITE)
fun BluetoothGattCharacteristic.isWritableWithoutResponse(): Boolean =
containsProperty(BluetoothGattCharacteristic.PROPERTY_WRITE_NO_RESPONSE)
fun BluetoothGattCharacteristic.isIndicatable(): Boolean =
containsProperty(BluetoothGattCharacteristic.PROPERTY_INDICATE)
fun BluetoothGattCharacteristic.isNotifiable(): Boolean =
containsProperty(BluetoothGattCharacteristic.PROPERTY_NOTIFY)
fun BluetoothGattCharacteristic.containsProperty(property: Int): Boolean =
properties and property != 0
fun ByteArray.toHexString(): String =
joinToString(separator = "", prefix = "0x") { String.format("%02X", it) }
@SuppressLint("MissingPermission")
class ConnectionManager(val context: Context, bleAdapter: BluetoothAdapter) {
private val deviceGattMap = ConcurrentHashMap<BluetoothDevice, BluetoothGatt>()
private val operationQueue = ConcurrentLinkedQueue<BleOperationType>()
private var pendingOperation: BleOperationType? = null
private var listeners = ArrayList<BleListener>()
private val scanSettings = ScanSettings.Builder()
.setScanMode(ScanSettings.SCAN_MODE_LOW_LATENCY)
.build()
private val scanFilters = listOf( ScanFilter.Builder().setServiceUuid(ParcelUuid.fromString(BuildConfig.SERVICE_UUID)).build())
private val bleScanner by lazy {
bleAdapter.bluetoothLeScanner
}
init {
val broadcastReceiver = object : BroadcastReceiver() {
private fun successfulBondingAttempt(prev: Int, curr: Int): Boolean {
return prev == BOND_BONDING && curr == BOND_BONDED || prev == BOND_NONE && curr == BOND_BONDED
}
private fun unsuccessfulBondingAttempt(prev: Int, curr: Int): Boolean {
return prev == BOND_BONDING && curr == BOND_NONE
}
override fun onReceive(context: Context, intent: Intent) {
if (intent.action == BluetoothDevice.ACTION_BOND_STATE_CHANGED) {
val device =
intent.getParcelableExtra<BluetoothDevice>(BluetoothDevice.EXTRA_DEVICE)!!
val previousBondState =
intent.getIntExtra(BluetoothDevice.EXTRA_PREVIOUS_BOND_STATE, -1)
val bondState = intent.getIntExtra(BluetoothDevice.EXTRA_BOND_STATE, -1)
val bondTransition = "${previousBondState.toBondStateDescription()} to " +
bondState.toBondStateDescription()
Log.i(
"ConnectionManager",
"${device.address} bond state changed | $bondTransition"
)
if (bondState == BOND_BONDED) {
notifyListeners(device.address) { it.onBonded(device) }
}
if (bondState == BOND_NONE) {
notifyListeners(device.address) { it.onUnbonded(device) }
}
val operation = pendingOperation
if (
(operation is BondRequest && (successfulBondingAttempt(
previousBondState, bondState
) || unsuccessfulBondingAttempt(
previousBondState, bondState
)) || operation is UnbondRequest && bondState == BOND_NONE)
&& operation.device.address == device.address
) {
signalEndOfOperation(operation)
}
}
}
private fun Int.toBondStateDescription() = when (this) {
BOND_BONDED -> "BONDED"
BOND_BONDING -> "BONDING"
BOND_NONE -> "NOT BONDED"
else -> "ERROR: $this"
}
}
context.registerReceiver(
broadcastReceiver,
IntentFilter(BluetoothDevice.ACTION_BOND_STATE_CHANGED)
)
}
var isScanning = false
set(value) {
field = value
notifyListeners(null) {
it.onScanningStateChange(value)
}
}
@SuppressLint("MissingPermission")
val scanCallback = object : ScanCallback() {
override fun onScanResult(callbackType: Int, result: ScanResult) {
Log.d(
"ScanCallback",
"Found BLE device with address ${result.device.address} (name: ${result.device.name}, rssi: ${result.rssi})"
)
notifyListeners(result.device.address) { it.onScanResult(callbackType, result) }
}
override fun onScanFailed(errorCode: Int) {
Log.e("ScanCallback", "onScanFailed: code $errorCode")
}
}
fun register(listener: BleListener) {
listeners.add(listener)
}
fun startScan() {
if (!isScanning) {
isScanning = true
bleScanner.startScan( null, scanSettings, scanCallback)
}
}
fun stopScan() {
bleScanner.stopScan(scanCallback)
isScanning = false
}
fun connect(device: BluetoothDevice) {
if (device.isConnected()) {
Log.e("ConnectionManager", "Already connected to ${device.address}!")
} else {
enqueueOperation(Connect(device, context))
}
}
fun teardownConnection(device: BluetoothDevice) {
if (device.isConnected()) {
enqueueOperation(Disconnect(device))
} else {
Log.e(
"ConnectionManager",
"Not connected to ${device.address}, cannot teardown connection!"
)
}
}
fun requestMtu(device: BluetoothDevice, mtu: Int = Int.MAX_VALUE) {
enqueueOperation(MtuRequest(device, mtu.coerceIn(GATT_MIN_MTU_SIZE, GATT_MAX_MTU_SIZE)))
}
fun discoverServices(device: BluetoothDevice) {
enqueueOperation(DiscoverServicesRequest(device))
}
fun readChar(device: BluetoothDevice, service: UUID, char: UUID) {
enqueueOperation(ReadChar(device, service, char))
}
fun enableNotification(device: BluetoothDevice, service: UUID, char: UUID) {
enqueueOperation(SetNotification(device, service, char, true))
}
fun disableNotification(device: BluetoothDevice, service: UUID, char: UUID) {
enqueueOperation(SetNotification(device, service, char, false))
}
fun bond(device: BluetoothDevice) {
enqueueOperation(BondRequest(device))
}
fun unbond(device: BluetoothDevice) {
enqueueOperation(UnbondRequest(device))
}
fun readRemoteRssi(device: BluetoothDevice) {
enqueueOperation(ReadRemoteRssi(device))
}
// - Beginning of PRIVATE functions
private fun notifyListeners(address: String?, notifier: (listener: BleListener) -> Unit) {
listeners.filter { n -> n.isRelevantMessage(address) }.forEach(notifier)
}
private fun notifyListenersOfQueueChange(address: String) {
notifyListeners(address) { listener ->
listener.onQueueSizeChange(
operationQueue.groupBy { o -> o.device.address }
)
}
}
@Synchronized
private fun enqueueOperation(operation: BleOperationType) {
if (isScanning) {
stopScan()
}
operationQueue.add(operation)
notifyListenersOfQueueChange(operation.device.address)
if (pendingOperation == null) {
doNextOperation()
}
}
@Synchronized
private fun signalEndOfOperation(op: BleOperationType) {
Log.d("ConnectionManager", "End of $op")
pendingOperation = null
notifyListenersOfQueueChange(op.device.address)
if (operationQueue.isNotEmpty()) {
doNextOperation()
}
}
@Synchronized
private fun doNextOperation() {
if (pendingOperation != null) {
Log.e(
"ConnectionManager",
"doNextOperation() called when an operation is pending! Aborting."
)
return
}
val operation = operationQueue.poll() ?: run {
Log.d("ConnectionManager", "Operation queue empty, returning")
return
}
pendingOperation = operation
// Handle Connect separately from other operations that require device to be connected
if (operation is Connect) {
with(operation) {
Log.w("ConnectionManager", "Connecting to ${device.address}")
device.connectGatt(
context, true, callback, BluetoothDevice.TRANSPORT_LE
)
}
return
}
if (operation is BondRequest) {
if (operation.device.bondState == BOND_NONE) {
if (!operation.device.createBond()) {
Log.e(
"ConnectionManager", "createBond() returned false " +
"for device ${operation.device.address}"
)
signalEndOfOperation(operation)
}
} else {
Log.e(
"ConnectionManager",
"BondRequest for device ${operation.device.address} aborted. " +
"operation.device.bondState (${operation.device.bondState}) == BOND_NONE (${BOND_NONE})"
)
signalEndOfOperation(operation)
}
return
}
if (operation is UnbondRequest) {
if (operation.device.bondState == BOND_BONDED) {
val method = operation.device.javaClass.getMethod("removeBond")
val removeBondSuccessful = method.invoke(operation.device) as Boolean
if (!removeBondSuccessful) {
Log.e(
"ConnectionManager", "removeBond() returned false " +
"for device ${operation.device.address}"
)
signalEndOfOperation(operation)
}
} else {
Log.e(
"ConnectionManager",
"UnbondRequest for device ${operation.device.address} aborted. " +
"operation.device.bondState (${operation.device.bondState}) == BOND_BONDED (${BOND_BONDED})"
)
signalEndOfOperation(operation)
}
return
}
// Check BluetoothGatt availability for other operations
val gatt = deviceGattMap[operation.device]
?: this@ConnectionManager.run {
Log.w(
"ConnectionManager",
"Not connected to ${operation.device.address}! Aborting $operation operation."
)
signalEndOfOperation(operation)
return
}
when (operation) {
is Disconnect -> with(operation) {
Log.w("ConnectionManager", "Disconnecting from ${device.address}")
gatt.close()
deviceGattMap.remove(device)
notifyListeners(gatt.device.address) {
it.onDisconnect(gatt)
}
signalEndOfOperation(operation)
}
is MtuRequest -> with(operation) {
gatt.requestMtu(mtu)
}
is DiscoverServicesRequest -> with(operation) {
gatt.discoverServices()
}
is ReadChar -> with(operation) {
val characteristic = gatt.getService(serviceId)?.getCharacteristic(charId)
if (characteristic?.isReadable() == true) {
gatt.readCharacteristic(characteristic)
} else {
Log.e("ConnectionManager", "Char $charId (${serviceId}) is not readable!")
signalEndOfOperation(operation)
}
}
is SetNotification -> with(operation) {
val characteristic = gatt.getService(serviceId)?.getCharacteristic(charId);
if (characteristic == null) {
Log.e("ConnectionManager", "Char $charId (${serviceId}) not found!")
signalEndOfOperation(operation)
return
}
val descriptor = characteristic.getDescriptor(CCC_DESCRIPTOR_UUID)
if (!characteristic.isNotifiable() && !characteristic.isIndicatable()) {
Log.e(
"ConnectionManager", "Char ${characteristic.uuid} (service: $serviceId)" +
" doesn't support notifications/indications"
)
signalEndOfOperation(operation)
return
}
val payload: ByteArray
if (enable) {
if (!gatt.setCharacteristicNotification(characteristic, true)) {
Log.e(
"ConnectionManager", "setCharacteristicNotification to true " +
"failed for ${characteristic.uuid} (service: $serviceId)"
)
signalEndOfOperation(operation)
return
}
payload =
if (characteristic.isIndicatable())
BluetoothGattDescriptor.ENABLE_INDICATION_VALUE
else BluetoothGattDescriptor.ENABLE_NOTIFICATION_VALUE;
} else {
if (!gatt.setCharacteristicNotification(characteristic, false)) {
Log.e(
"ConnectionManager", "setCharacteristicNotification to false " +
"failed for ${characteristic.uuid} (service: $serviceId)"
)
signalEndOfOperation(operation)
return
}
payload = BluetoothGattDescriptor.DISABLE_NOTIFICATION_VALUE
}
gatt.let { gatt ->
descriptor.value = payload
gatt.writeDescriptor(descriptor)
}
}
is ReadRemoteRssi -> {
gatt.readRemoteRssi()
}
// necessary because of IDE type inference bug
// https://youtrack.jetbrains.com/issue/KTIJ-20749/Exhaustive-when-check-does-not-take-into-account-the-values-excluded-by-previous-if-conditions
is Connect, is BondRequest, is UnbondRequest -> {
Log.e("ConnectionManager", "Shouldn't get here: $operation")
}
}
}
private val callback = object : BluetoothGattCallback() {
override fun onConnectionStateChange(gatt: BluetoothGatt, status: Int, newState: Int) {
val deviceAddress = gatt.device.address
val operation = pendingOperation
if (operation is Connect
&& operation.device.bondState == BOND_BONDED
&& deviceAddress == operation.device.address
&& status == 133
&& newState == STATE_DISCONNECTED
) {
Log.i(
"ConnectionManager",
"onConnectionStateChange: Timeout when connecting to bonded device $deviceAddress. " +
"Device probably not in vicinity."
)
notifyListeners(gatt.device.address) {
it.onConnectToBondedFailed(gatt)
}
signalEndOfOperation(operation)
} else if (status == BluetoothGatt.GATT_SUCCESS) {
if (newState == BluetoothProfile.STATE_CONNECTED) {
Log.i(
"ConnectionManager",
"onConnectionStateChange: connected to $deviceAddress"
)
deviceGattMap[gatt.device] = gatt
notifyListeners(gatt.device.address) {
it.onConnect(gatt)
}
if (operation is Connect) {
signalEndOfOperation(operation)
}
} else if (newState == STATE_DISCONNECTED) {
Log.e(
"ConnectionManager",
"onConnectionStateChange: disconnected from $deviceAddress"
)
notifyListeners(gatt.device.address) {
it.onDisconnect(gatt)
}
teardownConnection(gatt.device)
}
} else {
Log.e(
"ConnectionManager",
"onConnectionStateChange: status $status encountered for $deviceAddress!"
)
teardownConnection(gatt.device)
}
}
override fun onServicesDiscovered(gatt: BluetoothGatt, status: Int) {
with(gatt) {
if (status == BluetoothGatt.GATT_SUCCESS) {
Log.w(
"ConnectionManager",
"Discovered ${services.size} services for ${device.address}."
)
printGattTable()
notifyListeners(gatt.device.address) {
it.onConnect(gatt)
}
} else {
Log.e("ConnectionManager", "Service discovery failed due to status $status")
teardownConnection(gatt.device)
}
}
val operation = pendingOperation
if (operation is DiscoverServicesRequest) {
signalEndOfOperation(operation)
}
}
override fun onMtuChanged(gatt: BluetoothGatt, mtu: Int, status: Int) {
Log.w(
"ConnectionManager",
"ATT MTU changed to $mtu, success: ${status == BluetoothGatt.GATT_SUCCESS}"
)
val operation = pendingOperation
if (operation is MtuRequest) {
signalEndOfOperation(operation)
}
}
override fun onCharacteristicRead(
gatt: BluetoothGatt,
characteristic: BluetoothGattCharacteristic,
status: Int
) {
with(characteristic) {
when (status) {
BluetoothGatt.GATT_SUCCESS -> {
Log.i(
"ConnectionManager",
"Read characteristic $uuid (service: ${service.uuid}): ${value.toHexString()}"
)
notifyListeners(gatt.device.address) { listener ->
listener.onSuccessfulCharRead(
gatt,
characteristic
)
}
}
BluetoothGatt.GATT_READ_NOT_PERMITTED -> {
Log.e(
"BluetoothGattCallback",
"Read not permitted for $uuid (service: ${service.uuid})!"
)
}
else -> {
Log.e(
"BluetoothGattCallback",
"Characteristic read failed for $uuid (service: ${service.uuid}), error: $status"
)
}
}
}
val op = pendingOperation
if (op is ReadChar && op.charId == characteristic.uuid && op.serviceId == characteristic.service.uuid) {
signalEndOfOperation(op)
}
}
override fun onDescriptorWrite(
gatt: BluetoothGatt,
descriptor: BluetoothGattDescriptor,
status: Int
) {
val op = pendingOperation
if (op is SetNotification &&
descriptor.uuid == CCC_DESCRIPTOR_UUID
&& op.charId == descriptor.characteristic.uuid && op.serviceId == descriptor.characteristic.service.uuid
) {
if (!descriptor.value.contentEquals(BluetoothGattDescriptor.DISABLE_NOTIFICATION_VALUE)) {
notifyListeners(gatt.device.address) {
it.onSubscribe(gatt, descriptor)
}
} else {
notifyListeners(gatt.device.address) {
it.onUnsubscribe(gatt, descriptor)
}
}
signalEndOfOperation(op)
}
}
override fun onCharacteristicChanged(
gatt: BluetoothGatt,
characteristic: BluetoothGattCharacteristic
) {
notifyListeners(gatt.device.address) { listener ->
listener.onCharChange(
gatt,
characteristic
)
}
}
override fun onReadRemoteRssi(gatt: BluetoothGatt, rssi: Int, status: Int) {
if (status == GATT_SUCCESS) {
notifyListeners(gatt.device.address) { it.onReadRemoteRssi(gatt, rssi) }
} else {
Log.e(
"BluetoothGattCallback",
"ReadRemoteRssi failed for ${gatt.device.address}, error: $status"
)
}
val op = pendingOperation
if (op is ReadRemoteRssi && gatt.device.address == op.device.address) {
signalEndOfOperation(op)
}
}
}
private fun BluetoothDevice.isConnected() = deviceGattMap.containsKey(this)
}

View File

@@ -0,0 +1,161 @@
package com.logitech.vc.kirbytest
import android.content.ContentValues
import android.content.Context
import android.database.sqlite.SQLiteDatabase
import android.database.sqlite.SQLiteOpenHelper
import android.net.Uri
import android.provider.BaseColumns
import android.util.Log
import com.google.gson.Gson
import com.google.gson.JsonSyntaxException
import com.google.gson.reflect.TypeToken
import java.time.LocalDateTime
import java.time.format.DateTimeFormatter
object LoggerContract {
// Table contents are grouped together in an anonymous object.
object LogEntry : BaseColumns {
const val TABLE_NAME = "measurements"
const val COLUMN_NAME_TS = "ts"
const val COLUMN_NAME_PAYLOAD = "payload"
}
private const val SQL_CREATE_ENTRIES =
"CREATE TABLE ${LogEntry.TABLE_NAME} (" +
"${BaseColumns._ID} INTEGER PRIMARY KEY," +
"${LogEntry.COLUMN_NAME_TS} TEXT," +
"${LogEntry.COLUMN_NAME_PAYLOAD} TEXT)"
private const val SQL_DELETE_ENTRIES = "DROP TABLE IF EXISTS ${LogEntry.TABLE_NAME}"
class LoggerDbHelper(context: Context) :
SQLiteOpenHelper(context, DATABASE_NAME, null, DATABASE_VERSION) {
override fun onCreate(db: SQLiteDatabase) {
db.execSQL(SQL_CREATE_ENTRIES)
}
override fun onUpgrade(db: SQLiteDatabase, oldVersion: Int, newVersion: Int) {
// This database is only a cache for online data, so its upgrade policy is
// to simply to discard the data and start over
db.execSQL(SQL_DELETE_ENTRIES)
onCreate(db)
}
override fun onDowngrade(db: SQLiteDatabase, oldVersion: Int, newVersion: Int) {
onUpgrade(db, oldVersion, newVersion)
}
companion object {
// If you change the database schema, you must increment the database version.
const val DATABASE_VERSION = 2
const val DATABASE_NAME = "Logger.db"
}
}
class LoggerDb(context: Context) {
private val dbHelper = LoggerDbHelper(context)
private val dbWrite = dbHelper.writableDatabase
private val dbRead = dbHelper.writableDatabase
val context: Context = context
fun writeLog(payload: Any): Long? {
val gson = Gson()
val jsonString = gson.toJson(payload)
val date = LocalDateTime.now()
val ts = date.format(DateTimeFormatter.ISO_DATE_TIME)
Log.i("Database", jsonString)
val values = ContentValues().apply {
put(LogEntry.COLUMN_NAME_TS, ts)
put(LogEntry.COLUMN_NAME_PAYLOAD, jsonString)
}
return dbWrite?.insert(LogEntry.TABLE_NAME, null, values)
}
fun exportToUri(uri: Uri) {
val projection =
arrayOf(BaseColumns._ID, LogEntry.COLUMN_NAME_PAYLOAD, LogEntry.COLUMN_NAME_TS)
val sortOrder = "${BaseColumns._ID} ASC"
val cursor = dbRead.query(
LogEntry.TABLE_NAME, // The table to query
projection, // The array of columns to return (pass null to get all)
null, // The columns for the WHERE clause
null, // The values for the WHERE clause
null, // don't group the rows
null, // don't filter by row groups
sortOrder // The sort order
)
try {
val gson = Gson()
val type = object : TypeToken<HashMap<String, Any>>() {}.type
var headerWritten = false
val sep = ","
context.contentResolver.openOutputStream(uri)?.use { writer ->
val newLine = '\n'
with(cursor) {
while (moveToNext()) {
val ts = getString(getColumnIndexOrThrow(LogEntry.COLUMN_NAME_TS))
val storedField =
getString(getColumnIndexOrThrow(LogEntry.COLUMN_NAME_PAYLOAD))
try {
val payload: HashMap<String, Any> = gson.fromJson(storedField, type)
if (!headerWritten) {
val headerRow =
"timestamp" + sep + payload.keys.joinToString(sep) + newLine
writer.write(headerRow.toByteArray())
headerWritten = true
}
val row = ts + sep + payload.values.joinToString(sep) + newLine
writer.write(row.toByteArray())
} catch (exception: JsonSyntaxException) {
Log.e("db", exception.toString())
}
}
}
}
truncate()
} catch (e: Exception) {
e.printStackTrace()
}
cursor.close()
}
private fun truncate() {
dbWrite.execSQL("DELETE FROM ${LogEntry.TABLE_NAME}");
dbWrite.execSQL("VACUUM");
}
fun close() {
dbHelper.close()
}
}
}

View File

@@ -0,0 +1,117 @@
package com.logitech.vc.kirbytest
import android.util.Log
import org.apache.commons.codec.DecoderException
import org.apache.commons.codec.binary.Hex
import kotlin.math.min
import kotlin.math.roundToInt
object DecoderIaq {
private const val INVALID_CO2 = 16383
private const val INVALID_VOC = 2047
private const val INVALID_HUMIDITY = 1023
private const val INVALID_TEMPERATURE = 2047
private const val INVALID_PRESSURE = 65535
private const val INVALID_OCCUPANCY = 3
private const val INVALID_PM25 = 1023
private const val INVALID_PM10 = 1023
private val supportedMessageTypes = listOf<Number>(0, 1)
fun parseMeasurement(input: String): Measurement? {
val measurement = Measurement()
val inputBytes = hexStringToByteArray(input)
val msgType = inputBytes[0].toInt() and 0xFF ushr 4
if(!supportedMessageTypes.contains(msgType)) {
Log.i("Decoder", "Invalid message type: $msgType")
return null;
}
measurement.msgType = msgType
val co2 = parseUnsignedInt(inputBytes, 0, 3) ushr 6 and INVALID_CO2
measurement.co2 = if (co2 == INVALID_CO2) null else co2
val voc = parseUnsignedInt(inputBytes, 2, 4) ushr 3 and INVALID_VOC
measurement.voc = if (co2 == INVALID_VOC) null else voc
val humidity =
parseUnsignedInt(inputBytes, 3, 5) ushr 1 and INVALID_HUMIDITY
measurement.humidity = if (humidity == INVALID_HUMIDITY) null else humidity / 10
val temperature =
parseUnsignedInt(inputBytes, 4, 7) ushr 6 and INVALID_TEMPERATURE
measurement.temperature =
if (temperature == INVALID_TEMPERATURE) null else ((temperature / 10.0 - 40) * 10.0).roundToInt() / 10.0
val pressure =
parseUnsignedInt(inputBytes, 6, 9) ushr 6 and INVALID_PRESSURE
measurement.pressure =
if (pressure == INVALID_PRESSURE) null else (30000 + 19000.0 * pressure / 13107).roundToInt()
val occupancy =
parseUnsignedInt(inputBytes, 8, 9) ushr 4 and INVALID_OCCUPANCY
measurement.occupancy = if (occupancy == INVALID_OCCUPANCY) null else occupancy
if (msgType == 0) {
val pm25 =
parseUnsignedInt(inputBytes, 8, 10) ushr 2 and INVALID_PM25
measurement.pm25 = if (pm25 == INVALID_PM25) null else pm25
val pm10 = parseUnsignedInt(inputBytes, 9, 11) and INVALID_PM10
measurement.pm10 = if (pm10 == INVALID_PM10) null else pm10
}
return measurement
}
private fun parseUnsignedInt(bytes: ByteArray, startIncl: Int, endExcl: Int): Int {
val section = bytes.copyOfRange(startIncl, min(bytes.size, endExcl))
var unsignedInt = 0
for (i in section.indices) {
unsignedInt = unsignedInt shl 8
unsignedInt = unsignedInt or (section[i].toInt() and 0xFF)
}
return unsignedInt
}
private fun hexStringToByteArray(encoded: String): ByteArray {
return try {
Hex.decodeHex(encoded)
} catch (e: DecoderException) {
throw RuntimeException(e)
}
}
data class Measurement (
var msgType: Number? = null,
var co2: Number? = null,
var voc: Number? = null,
var humidity: Number? = null,
var temperature: Number? = null,
var pressure: Number? = null,
var occupancy: Number? = null,
var pm25: Number? = null,
var pm10: Number? = null
) {
override fun toString(): String {
return "M{" +
"type=" + msgType +
", co2=" + co2 +
", voc=" + voc +
", hum=" + humidity +
", temp=" + temperature +
", press=" + pressure +
", pm25=" + pm25 +
", pm10=" + pm10 +
", occ=" + occupancy +
'}'
}
fun toCsv() : String {
return "${msgType},${co2},${voc},${humidity},${temperature},${pressure}${pm25},${pm10},${occupancy}"
}
}
}

View File

@@ -0,0 +1,185 @@
package com.logitech.vc.kirbytest
import android.annotation.SuppressLint
import android.content.Context
import android.view.LayoutInflater
import android.view.View
import android.view.View.GONE
import android.view.View.INVISIBLE
import android.view.View.VISIBLE
import android.view.ViewGroup
import android.widget.ArrayAdapter
import android.widget.Button
import android.widget.ListView
import android.widget.PopupMenu
import android.widget.ProgressBar
import android.widget.TextView
import androidx.recyclerview.widget.RecyclerView
interface Action {
fun getLabel(): String
fun getIcon(): Int?
fun execute(): Unit
}
interface Measurement {
fun getLabel(): String
fun getIcon(): Int?
fun getFormattedValue(): String
}
interface DeviceListEntry {
val address: String
var rssi: Int?
val name: String?
val status: String?
var hasRunningOp: Boolean
fun getActions(): List<Action>
fun getMeasurements(): List<Measurement>
}
@SuppressLint("MissingPermission")
class DeviceListAdapter(
private val items: List<DeviceListEntry>,
) : RecyclerView.Adapter<DeviceListAdapter.ViewHolder>() {
/**
* Provide a reference to the type of views that you are using
* (custom ViewHolder)
*/
class ViewHolder(
private val view: View,
private val context: Context
) : RecyclerView.ViewHolder(view) {
val deviceNameView: TextView
val macAddressView: TextView
val signalStrengthView: TextView
val statusView: TextView
val measurementsListView: ListView
val deviceActions: Button
val deviceProgress: ProgressBar
init {
deviceNameView = view.findViewById(R.id.device_name)
macAddressView = view.findViewById(R.id.mac_address)
signalStrengthView = view.findViewById(R.id.signal_strength)
statusView = view.findViewById(R.id.device_status)
measurementsListView = view.findViewById(R.id.measurement_fields)
deviceActions = view.findViewById(R.id.device_actions)
deviceProgress = view.findViewById(R.id.device_progress)
}
@SuppressLint("RestrictedApi")
fun bind(result: DeviceListEntry) {
val rssi = result.rssi
deviceNameView.text = result.name ?: "<N/A>"
deviceProgress.visibility = if (result.hasRunningOp) VISIBLE else INVISIBLE
macAddressView.text = result.address
signalStrengthView.text = "${rssi ?: "-"} dBm"
var signalStrengthIcon = R.drawable.signal_strength_weak
if (rssi == null) {
signalStrengthIcon = R.drawable.signal_strength_none
} else if (rssi >= -55) {
signalStrengthIcon = R.drawable.signal_strength_strong
} else if (rssi >= -80) {
signalStrengthIcon = R.drawable.signal_strength_medium
}
signalStrengthView.setCompoundDrawablesWithIntrinsicBounds(
0,
0,
signalStrengthIcon,
0
)
statusView.text = result.status
deviceActions.setOnClickListener {
val popup = PopupMenu(context, deviceActions)
result.getActions().forEach { action ->
val menuItem = popup.menu.add(action.getLabel())
menuItem.setOnMenuItemClickListener { item ->
action.execute()
true
}
val menuIcon = action.getIcon()
if (menuIcon != null) {
menuItem.setIcon(menuIcon)
}
}
val inflater = popup.menuInflater
popup.setForceShowIcon(true)
inflater.inflate(R.menu.device_menu, popup.menu)
popup.show()
}
val measurements = result.getMeasurements()
val measurementsAdapter = object :
ArrayAdapter<Measurement>(
context,
R.layout.row_measurements_list,
measurements
) {
override fun getView(
position: Int,
convertView: View?,
parent: ViewGroup
): View {
val measurement = measurements[position]
val measurementView = convertView ?: LayoutInflater.from(parent.context)
.inflate(R.layout.row_measurements_list, parent, false)
val labelView = measurementView.findViewById<TextView>(R.id.measurement_title)
labelView.text = measurement.getLabel()
labelView.setCompoundDrawablesWithIntrinsicBounds(
measurement.getIcon() ?: 0,
0,
0,
0
)
measurementView.findViewById<TextView>(R.id.measurement_body).text =
measurement.getFormattedValue()
return measurementView
}
override fun isEnabled(position: Int): Boolean {
// make measurement rows unclickable
return false
}
}
measurementsListView.adapter = measurementsAdapter
if (measurements.isEmpty()) {
measurementsListView.visibility = GONE
} else {
measurementsListView.visibility = VISIBLE
}
}
}
override fun onCreateViewHolder(viewGroup: ViewGroup, viewType: Int): ViewHolder {
// Create a new view, which defines the UI of the list item
val view = LayoutInflater.from(viewGroup.context)
.inflate(R.layout.row_device_list, viewGroup, false)
return ViewHolder(view, viewGroup.context)
}
override fun getItemCount() = items.size
// Replace the contents of a view (invoked by the layout manager)
override fun onBindViewHolder(holder: ViewHolder, position: Int) {
// Get element from your dataset at this position and replace the
// contents of the view with that element
val item = items[position]
holder.bind(item)
}
}

View File

@@ -0,0 +1,390 @@
package com.logitech.vc.kirbytest
import android.annotation.SuppressLint
import android.bluetooth.BluetoothDevice
import android.bluetooth.BluetoothDevice.BOND_BONDED
import android.bluetooth.BluetoothGatt
import android.bluetooth.BluetoothGattCharacteristic
import android.bluetooth.BluetoothGattDescriptor
import android.bluetooth.le.ScanResult
import android.content.Context
import android.util.Log
import com.android.volley.Request
import com.android.volley.RequestQueue
import com.android.volley.toolbox.JsonObjectRequest
import com.android.volley.toolbox.Volley
import org.json.JSONException
import org.json.JSONObject
import java.nio.ByteBuffer
import java.nio.ByteOrder
import java.time.LocalDateTime
import java.time.format.DateTimeFormatter
import java.util.Base64
import java.util.EnumSet
import java.util.UUID
import java.util.stream.Collectors
private val SERVICE_UUID = UUID.fromString(BuildConfig.SERVICE_UUID)
private val CHAR_UUID = UUID.fromString(BuildConfig.CHAR_UUID)
enum class DeviceStatus {
CONNECTED, BONDED, SUBSCRIBED, MISSING
}
@SuppressLint("MissingPermission")
class KirbyDevice(
private val context: Context,
private val connectionManager: ConnectionManager,
private val bleDevice: BluetoothDevice,
private val loggerDb: LoggerContract.LoggerDb,
private val onStateChange: (device: KirbyDevice) -> Unit,
) : BleListener(bleDevice.address), DeviceListEntry {
private val queue: RequestQueue = Volley.newRequestQueue(context)
fun subscribe() {
connectionManager.enableNotification(
bleDevice, SERVICE_UUID, CHAR_UUID
)
}
fun readIaq() {
connectionManager.readChar(bleDevice, SERVICE_UUID, CHAR_UUID)
}
override fun onSuccessfulCharRead(
gatt: BluetoothGatt,
characteristic: BluetoothGattCharacteristic
) {
addMeasurement(characteristic)
onStateChange(this)
}
override fun onScanResult(callbackType: Int, result: ScanResult) {
rssi = result.rssi
onStateChange(this)
}
override fun onConnect(gatt: BluetoothGatt) {
statuses.add(DeviceStatus.CONNECTED)
statuses.remove(DeviceStatus.MISSING)
onStateChange(this)
}
override fun onConnectToBondedFailed(gatt: BluetoothGatt) {
statuses.add(DeviceStatus.MISSING)
onStateChange(this)
}
override fun onDisconnect(gatt: BluetoothGatt) {
statuses.remove(DeviceStatus.CONNECTED)
statuses.remove(DeviceStatus.SUBSCRIBED)
onStateChange(this)
}
override fun onCharChange(gatt: BluetoothGatt, characteristic: BluetoothGattCharacteristic) {
addMeasurement(characteristic)
onStateChange(this)
}
override fun onSubscribe(
gatt: BluetoothGatt,
descriptor: BluetoothGattDescriptor,
) {
statuses.add(DeviceStatus.SUBSCRIBED)
onStateChange(this)
}
override fun onUnsubscribe(gatt: BluetoothGatt, descriptor: BluetoothGattDescriptor) {
statuses.remove(DeviceStatus.SUBSCRIBED)
onStateChange(this)
}
override fun onQueueSizeChange(groupedOps: Map<String, List<BleOperationType>>) {
hasRunningOp = groupedOps.getOrDefault(bleDevice.address, emptyList()).isNotEmpty()
onStateChange(this)
}
override fun onBonded(device: BluetoothDevice) {
statuses.add(DeviceStatus.BONDED)
onStateChange(this)
}
override fun onUnbonded(device: BluetoothDevice) {
statuses.remove(DeviceStatus.BONDED)
onStateChange(this)
}
override fun onReadRemoteRssi(gatt: BluetoothGatt, rssi: Int) {
this.rssi = rssi
onStateChange(this)
}
override var hasRunningOp: Boolean = false
override var rssi: Int? = null
private fun addMeasurement(characteristic: BluetoothGattCharacteristic) {
val hexPayload = characteristic.value.toHexString().substring(2)
val measurement = DecoderIaq.parseMeasurement(hexPayload)
var payload : Payload
if (measurement == null) {
payload = Payload(hexPayload)
} else {
payload = Payload(measurement.toString())
Log.i("BleListener", "Char received: $payload")
val base64Payload = Base64.getEncoder().encodeToString(characteristic.value)
publishMeasurement(base64Payload)
loggerDb.writeLog( measurement)
}
measurements.add(payload)
if (measurements.size > maxMeasurements) {
measurements.removeFirst()
}
}
private fun publishMeasurement(payload: String) {
val accessKey = BuildConfig.API_KEY
val url = BuildConfig.API_BASE_URL
val eui = "0000${bleDevice.address.replace(":", "")}"
val postData = JSONObject()
try {
Log.i("POST", "Transmitting for $eui: $payload")
postData.put("accessKey", accessKey)
postData.put("metricPayload", payload)
postData.put("eui", eui)
} catch (e: JSONException) {
e.printStackTrace()
}
val request = JsonObjectRequest(
Request.Method.POST, url, postData,
{ response ->
Log.i("sendDataResponse", "Response is: $response")
}
) { error -> error.printStackTrace() }
queue.add(request)
}
private val measurements = ArrayList<Payload>()
private val maxMeasurements = 20
private val statuses = EnumSet.noneOf(DeviceStatus::class.java)
init {
if (bleDevice.bondState == BOND_BONDED) {
statuses.add(DeviceStatus.BONDED)
}
}
override val address: String
get() = bleDevice.address
override val name: String?
get() = bleDevice.name
override val status: String?
get() = statuses.stream().map { it.name }.collect(Collectors.joining(", "))
override fun getMeasurements(): List<Measurement> {
val result = mutableListOf<Measurement>()
measurements.reversed().forEach { m -> result.addAll(payloadToMeasurements(m))}
/*
var pl = Payload(payload = "006b04ab74a1ed0d101404", ts = "2000")
result.addAll(payloadToMeasurements(pl))
result.addAll(payloadToMeasurements(pl))
*/
return result
}
override fun getActions(): List<Action> {
val actions = mutableListOf<Action>()
if (!statuses.contains(DeviceStatus.BONDED)) {
actions.add(object : Action {
override fun getLabel(): String {
return "Bond"
}
override fun getIcon(): Int {
return R.drawable.action_icon_bond
}
override fun execute() {
connectionManager.bond(bleDevice)
}
})
}
if (!statuses.contains(DeviceStatus.CONNECTED)
) {
actions.add(object : Action {
override fun getLabel(): String {
return "Connect"
}
override fun getIcon(): Int {
return R.drawable.action_icon_connect
}
override fun execute() {
connectionManager.connect(bleDevice)
connectionManager.readRemoteRssi(bleDevice)
connectionManager.discoverServices(bleDevice)
}
})
}
if (statuses.contains(DeviceStatus.CONNECTED)) {
actions.add(object : Action {
override fun getLabel(): String {
return "Disconnect"
}
override fun getIcon(): Int {
return R.drawable.action_icon_disconnect
}
override fun execute() {
connectionManager.teardownConnection(bleDevice)
}
})
actions.add(object : Action {
override fun getLabel(): String {
return "Fetch Measurement"
}
override fun getIcon(): Int {
return R.drawable.action_icon_fetch_measurement
}
override fun execute() {
readIaq()
}
})
if (!statuses.contains(DeviceStatus.SUBSCRIBED)) {
actions.add(object : Action {
override fun getLabel(): String {
return "Subscribe"
}
override fun getIcon(): Int {
return R.drawable.action_icon_subscribe
}
override fun execute() {
subscribe()
}
})
}
actions.add(object : Action {
override fun getLabel(): String {
return "Update Signal Strength"
}
override fun getIcon(): Int {
return R.drawable.action_icon_update_signal_strength
}
override fun execute() {
connectionManager.readRemoteRssi(bleDevice)
}
})
}
if (statuses.contains(DeviceStatus.SUBSCRIBED)) {
actions.add(object : Action {
override fun getLabel(): String {
return "Unsubscribe"
}
override fun getIcon(): Int {
return R.drawable.action_icon_subscribe_disable
}
override fun execute() {
connectionManager.disableNotification(
bleDevice, SERVICE_UUID, CHAR_UUID
)
}
})
}
if (statuses.contains(DeviceStatus.BONDED)
) {
actions.add(object : Action {
override fun getLabel(): String {
return "Unbond"
}
override fun getIcon(): Int {
return R.drawable.action_icon_unbond
}
override fun execute() {
connectionManager.unbond(bleDevice)
}
})
}
return actions;
}
}
data class Payload(
val payload: String,
val ts: String = LocalDateTime.now()
.format(DateTimeFormatter.ofPattern("dd.MM.yy HH:mm:ss"))
)
fun bytesToUInt16(arr: ByteArray, start: Int): Int {
return ByteBuffer.wrap(arr, start, 2)
.order(ByteOrder.LITTLE_ENDIAN).short.toInt() and 0xFFFF
}
fun bytesToInt16(arr: ByteArray, start: Int): Short {
return ByteBuffer.wrap(arr, start, 2)
.order(ByteOrder.LITTLE_ENDIAN).short
}
fun bytesToInt32(arr: ByteArray, start: Int): Int {
return ByteBuffer.wrap(arr, start, 4)
.order(ByteOrder.LITTLE_ENDIAN).int
}
private fun payloadToMeasurements(payload: Payload): List<Measurement> {
return listOf(object : Measurement {
override fun getLabel(): String {
return payload.ts.toString()
}
override fun getFormattedValue(): String {
return payload.payload
}
override fun getIcon(): Int? {
return R.drawable.baseline_numbers_24
}
}
)
}

View File

@@ -0,0 +1,459 @@
package com.logitech.vc.kirbytest
import android.Manifest
import android.annotation.SuppressLint
import android.app.Activity
import android.app.AlertDialog
import android.bluetooth.BluetoothAdapter
import android.bluetooth.BluetoothDevice
import android.bluetooth.BluetoothManager
import android.bluetooth.le.ScanResult
import android.content.Context
import android.content.DialogInterface
import android.content.Intent
import android.content.pm.PackageManager
import android.net.Uri
import android.os.Build
import android.os.Bundle
import android.util.Log
import android.view.Menu
import android.view.MenuItem
import android.view.View.INVISIBLE
import android.view.View.VISIBLE
import androidx.activity.result.ActivityResultLauncher
import androidx.activity.result.contract.ActivityResultContracts
import androidx.annotation.RequiresApi
import androidx.appcompat.app.AppCompatActivity
import androidx.core.app.ActivityCompat
import androidx.core.content.ContextCompat
import androidx.core.view.WindowCompat
import androidx.recyclerview.widget.SimpleItemAnimator
import com.logitech.vc.kirbytest.databinding.ActivityMainBinding
import java.time.LocalDateTime
import java.time.format.DateTimeFormatter
private const val ENABLE_BLUETOOTH_REQUEST_CODE = 1
private const val RUNTIME_PERMISSION_REQUEST_CODE = 2
fun Context.hasPermission(permissionType: String): Boolean {
return ContextCompat.checkSelfPermission(this, permissionType) ==
PackageManager.PERMISSION_GRANTED
}
fun Context.hasRequiredRuntimePermissions(): Boolean {
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
hasPermission(Manifest.permission.BLUETOOTH_SCAN) &&
hasPermission(Manifest.permission.BLUETOOTH_CONNECT)
} else {
hasPermission(Manifest.permission.ACCESS_FINE_LOCATION)
}
}
@SuppressLint("MissingPermission")
class MainActivity : AppCompatActivity() {
private lateinit var binding: ActivityMainBinding
private lateinit var loggerDb: LoggerContract.LoggerDb
private lateinit var createFileLauncher: ActivityResultLauncher<String>
private val bluetoothAdapter: BluetoothAdapter by lazy {
val bluetoothManager = getSystemService(Context.BLUETOOTH_SERVICE) as BluetoothManager
bluetoothManager.adapter
}
private val connectionManager: ConnectionManager by lazy {
newConnectionManager()
}
private val kirbyDevices = mutableListOf<DeviceListEntry>();
private val deviceListAdapter: DeviceListAdapter by lazy {
DeviceListAdapter(kirbyDevices)
}
private var onPermissionsGrantedCallback: Runnable? = null
@RequiresApi(Build.VERSION_CODES.R)
override fun onCreate(savedInstanceState: Bundle?) {
WindowCompat.setDecorFitsSystemWindows(window, false)
super.onCreate(savedInstanceState)
binding = ActivityMainBinding.inflate(layoutInflater)
setContentView(binding.root)
setSupportActionBar(binding.toolbar)
BLEService.startService(applicationContext, "hello ble service")
binding.fab.setOnClickListener { view ->
if (!hasRequiredRuntimePermissions()) {
onPermissionsGrantedCallback = object : Runnable {
override fun run() {
toggleScanning()
}
}
requestRelevantRuntimePermissions()
} else {
toggleScanning()
}
}
loggerDb = LoggerContract.LoggerDb(this.applicationContext)
setupDevicesList()
createFileLauncher =
registerForActivityResult(ActivityResultContracts.CreateDocument()) { uri: Uri? ->
uri?.let {
// Use the URI to write your CSV content
loggerDb.exportToUri(uri)
}
}
}
private fun toggleScanning(): Unit {
if (connectionManager.isScanning) {
connectionManager.stopScan()
} else {
connectionManager.startScan()
}
}
private fun isKirbyDevice(device: BluetoothDevice): Boolean {
val deviceName = (device.name ?: "").lowercase()
return deviceName.contains("kirby") || deviceName.contains("krby")
}
private fun setupDevicesList() {
binding.devicesList.apply {
adapter = deviceListAdapter
}
val animator = binding.devicesList.itemAnimator
if (animator is SimpleItemAnimator) {
animator.supportsChangeAnimations = false
}
if (hasRequiredRuntimePermissions()) {
addBondedDevices()
} else {
onPermissionsGrantedCallback = object : Runnable {
override fun run() {
addBondedDevices()
}
}
requestRelevantRuntimePermissions()
}
//addDummyDevices()
}
private fun addBondedDevices(): Unit {
bluetoothAdapter.bondedDevices.filter { isKirbyDevice(it) }.forEach {
val kirbyDevice = newKirbyDevice(it)
kirbyDevice.subscribe()
}
}
@SuppressLint("NotifyDataSetChanged")
private fun addDummyDevices() {
for (i in 0..14) {
kirbyDevices.add(DummyListEntry("$i"))
}
deviceListAdapter.notifyDataSetChanged()
}
private fun newConnectionManager(): ConnectionManager {
val mngr = ConnectionManager(applicationContext, bluetoothAdapter)
mngr.register(object : BleListener(null) {
override fun onScanningStateChange(isScanning: Boolean) {
runOnUiThread {
binding.fab.setText(if (isScanning) "Stop Scan" else "Start Scan")
binding.fab.setIconResource(
if (isScanning) R.drawable.action_icon_disconnect else R.drawable.action_icon_scan
)
binding.scanProgress.visibility = if (isScanning) VISIBLE else INVISIBLE
}
}
override fun onScanResult(callbackType: Int, result: ScanResult) {
if (isKirbyDevice(result.device)) {
Log.i(
"ScanCallback",
"Found Kirby device with name ${result.device.name} (address: ${result.device.address}, rssi: ${result.rssi})"
)
val kirbyDevice = kirbyDevices.find { it.address == result.device.address }
if (kirbyDevice == null) {
newKirbyDevice(result.device).onScanResult(callbackType, result)
}
}
}
})
return mngr
}
private fun newKirbyDevice(bleDevice: BluetoothDevice): KirbyDevice {
val device = KirbyDevice(this.applicationContext, connectionManager, bleDevice, loggerDb) {
val i = kirbyDevices.indexOfFirst { d -> d === it }
runOnUiThread {
deviceListAdapter.notifyItemChanged(i)
}
}
connectionManager.register(device)
connectionManager.connect(bleDevice)
connectionManager.discoverServices(bleDevice)
kirbyDevices.add(device)
deviceListAdapter.notifyItemInserted(kirbyDevices.size - 1)
return device
}
override fun onCreateOptionsMenu(menu: Menu): Boolean {
// Inflate the menu; this adds items to the action bar if it is present.
menuInflater.inflate(R.menu.menu_main, menu)
return true
}
@RequiresApi(Build.VERSION_CODES.R)
override fun onOptionsItemSelected(item: MenuItem): Boolean {
// Handle action bar item clicks here. The action bar will
// automatically handle clicks on the Home/Up button, so long
// as you specify a parent activity in AndroidManifest.xml.
return when (item.itemId) {
R.id.action_export -> {
val date = LocalDateTime.now()
val fileName = "kirby_export_${date.format(DateTimeFormatter.ISO_DATE_TIME)}.csv"
createFileLauncher.launch(fileName)
return true
}
else -> super.onOptionsItemSelected(item)
}
}
override fun onResume() {
super.onResume()
if (!bluetoothAdapter.isEnabled) {
promptEnableBluetooth()
}
}
override fun onDestroy() {
loggerDb.close()
super.onDestroy()
}
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
super.onActivityResult(requestCode, resultCode, data)
when (requestCode) {
ENABLE_BLUETOOTH_REQUEST_CODE -> {
if (resultCode != Activity.RESULT_OK) {
promptEnableBluetooth()
}
}
}
}
override fun onRequestPermissionsResult(
requestCode: Int,
permissions: Array<out String>,
grantResults: IntArray
) {
super.onRequestPermissionsResult(requestCode, permissions, grantResults)
when (requestCode) {
RUNTIME_PERMISSION_REQUEST_CODE -> {
val containsPermanentDenial = permissions.zip(grantResults.toTypedArray()).any {
it.second == PackageManager.PERMISSION_DENIED &&
!ActivityCompat.shouldShowRequestPermissionRationale(this, it.first)
}
val containsDenial = grantResults.any { it == PackageManager.PERMISSION_DENIED }
val allGranted = grantResults.all { it == PackageManager.PERMISSION_GRANTED }
when {
containsPermanentDenial -> {
// TODO: Handle permanent denial (e.g., show AlertDialog with justification)
// Note: The user will need to navigate to App Settings and manually grant
// permissions that were permanently denied
}
containsDenial -> {
requestRelevantRuntimePermissions()
}
allGranted && hasRequiredRuntimePermissions() -> {
onPermissionsGrantedCallback?.run()
}
else -> {
// Unexpected scenario encountered when handling permissions
recreate()
}
}
}
}
}
@SuppressLint("MissingPermission")
private fun promptEnableBluetooth() {
if (!bluetoothAdapter.isEnabled) {
val enableBtIntent = Intent(BluetoothAdapter.ACTION_REQUEST_ENABLE)
startActivityForResult(enableBtIntent, ENABLE_BLUETOOTH_REQUEST_CODE)
}
}
private fun Activity.requestRelevantRuntimePermissions() {
if (hasRequiredRuntimePermissions()) {
return
}
when {
Build.VERSION.SDK_INT < Build.VERSION_CODES.S -> {
requestLocationPermission()
}
Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> {
requestBluetoothPermissions()
}
}
}
private fun requestLocationPermission() {
val onClick = { dialog: DialogInterface, which: Int ->
ActivityCompat.requestPermissions(
this,
arrayOf(Manifest.permission.ACCESS_FINE_LOCATION),
RUNTIME_PERMISSION_REQUEST_CODE
)
}
runOnUiThread {
val builder = AlertDialog.Builder(this)
with(builder)
{
setTitle("Location permission required")
setMessage(
"Starting from Android M (6.0), the system requires apps to be granted " +
"location access in order to scan for BLE devices."
)
setCancelable(false)
setPositiveButton(
android.R.string.ok,
DialogInterface.OnClickListener(function = onClick)
)
show()
}
}
}
private fun requestBluetoothPermissions() {
val onClick = { dialog: DialogInterface, which: Int ->
ActivityCompat.requestPermissions(
this,
arrayOf(
Manifest.permission.BLUETOOTH_SCAN,
Manifest.permission.BLUETOOTH_CONNECT
),
RUNTIME_PERMISSION_REQUEST_CODE
)
}
runOnUiThread {
val builder = AlertDialog.Builder(this)
with(builder)
{
setTitle("Bluetooth permission required")
setMessage(
"Starting from Android 12, the system requires apps to be granted " +
"Bluetooth access in order to scan for and connect to BLE devices."
)
setCancelable(false)
setPositiveButton(
android.R.string.ok,
DialogInterface.OnClickListener(function = onClick)
)
show()
}
}
}
}
@SuppressLint("MissingPermission")
class DummyListEntry(override val address: String) : DeviceListEntry {
override var rssi: Int? = -30
override val name: String = "Device 123"
override val status: String
get() = "statusA, statusB"
override var hasRunningOp: Boolean = true
override fun getActions(): List<Action> {
return listOf(object : Action {
override fun getLabel(): String {
return "Test action 1"
}
override fun getIcon(): Int? {
return R.drawable.action_icon_disconnect
}
override fun execute() {
}
}, object : Action {
override fun getLabel(): String {
return "Test action 2"
}
override fun getIcon(): Int? {
return R.drawable.action_icon_connect
}
override fun execute() {
}
})
}
override fun getMeasurements(): List<Measurement> {
return listOf(object : Measurement {
override fun getLabel(): String {
return "Temperature"
}
override fun getFormattedValue(): String {
return "21.2 °C"
}
override fun getIcon(): Int? {
return R.drawable.baseline_device_thermostat_24
}
}, object : Measurement {
override fun getLabel(): String {
return "Pressure"
}
override fun getFormattedValue(): String {
return "232 bar"
}
override fun getIcon(): Int? {
return R.drawable.baseline_compress_24
}
})
}
}

View File

@@ -0,0 +1,27 @@
package com.logitech.vc.kirbytest;
import android.content.Context;
import android.util.AttributeSet;
import android.view.ViewGroup;
import android.widget.ListView;
public class NonScrollListView extends ListView {
public NonScrollListView(Context context) {
super(context);
}
public NonScrollListView(Context context, AttributeSet attrs) {
super(context, attrs);
}
public NonScrollListView(Context context, AttributeSet attrs, int defStyle) {
super(context, attrs, defStyle);
}
@Override
public void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
int heightMeasureSpec_custom = MeasureSpec.makeMeasureSpec(
Integer.MAX_VALUE >> 2, MeasureSpec.AT_MOST);
super.onMeasure(widthMeasureSpec, heightMeasureSpec_custom);
ViewGroup.LayoutParams params = getLayoutParams();
params.height = getMeasuredHeight();
}
}