From 07cc0e19c750a14bcbf9cf958ce5ab7b0507e4b8 Mon Sep 17 00:00:00 2001 From: Robert Buessow Date: Mon, 13 Nov 2023 22:39:57 +0100 Subject: [PATCH 1/4] Support automatic exchange of communication key for HTTP via connectiq native comm (usually unreliable but could enough for that). --- plugins/sync/build.gradle.kts | 3 + .../connectiq/IConnectIQService.aidl | 30 ++ .../com/garmin/android/connectiq/IQApp.aidl | 12 + .../garmin/android/connectiq/IQDevice.aidl | 11 + .../garmin/android/connectiq/IQMessage.aidl | 20 ++ .../plugins/sync/garmin/GarminApplication.kt | 39 +++ .../aaps/plugins/sync/garmin/GarminClient.kt | 14 + .../aaps/plugins/sync/garmin/GarminDevice.kt | 54 ++++ .../plugins/sync/garmin/GarminDeviceClient.kt | 286 ++++++++++++++++++ .../plugins/sync/garmin/GarminMessenger.kt | 159 ++++++++++ .../aaps/plugins/sync/garmin/GarminPlugin.kt | 130 ++++++-- .../plugins/sync/garmin/GarminReceiver.kt | 36 +++ .../plugins/sync/garmin/GarminSerializer.kt | 254 ++++++++++++++++ .../sync/garmin/GarminSimulatorClient.kt | 186 ++++++++++++ .../aaps/plugins/sync/garmin/HttpServer.kt | 8 +- .../sync/garmin/GarminMessengerTest.kt | 142 +++++++++ .../plugins/sync/garmin/GarminPluginTest.kt | 164 +++++++++- .../sync/garmin/GarminSerializerTest.kt | 92 ++++++ .../sync/garmin/GarminSimulatorClientTest.kt | 86 ++++++ .../plugins/sync/garmin/HttpServerTest.kt | 2 +- 20 files changed, 1694 insertions(+), 34 deletions(-) create mode 100644 plugins/sync/src/main/aidl/com/garmin/android/apps/connectmobile/connectiq/IConnectIQService.aidl create mode 100644 plugins/sync/src/main/aidl/com/garmin/android/connectiq/IQApp.aidl create mode 100644 plugins/sync/src/main/aidl/com/garmin/android/connectiq/IQDevice.aidl create mode 100644 plugins/sync/src/main/aidl/com/garmin/android/connectiq/IQMessage.aidl create mode 100644 plugins/sync/src/main/kotlin/app/aaps/plugins/sync/garmin/GarminApplication.kt create mode 100644 plugins/sync/src/main/kotlin/app/aaps/plugins/sync/garmin/GarminClient.kt create mode 100644 plugins/sync/src/main/kotlin/app/aaps/plugins/sync/garmin/GarminDevice.kt create mode 100644 plugins/sync/src/main/kotlin/app/aaps/plugins/sync/garmin/GarminDeviceClient.kt create mode 100644 plugins/sync/src/main/kotlin/app/aaps/plugins/sync/garmin/GarminMessenger.kt create mode 100644 plugins/sync/src/main/kotlin/app/aaps/plugins/sync/garmin/GarminReceiver.kt create mode 100644 plugins/sync/src/main/kotlin/app/aaps/plugins/sync/garmin/GarminSerializer.kt create mode 100644 plugins/sync/src/main/kotlin/app/aaps/plugins/sync/garmin/GarminSimulatorClient.kt create mode 100644 plugins/sync/src/test/kotlin/app/aaps/plugins/sync/garmin/GarminMessengerTest.kt create mode 100644 plugins/sync/src/test/kotlin/app/aaps/plugins/sync/garmin/GarminSerializerTest.kt create mode 100644 plugins/sync/src/test/kotlin/app/aaps/plugins/sync/garmin/GarminSimulatorClientTest.kt diff --git a/plugins/sync/build.gradle.kts b/plugins/sync/build.gradle.kts index b6b885370c..1bea79a6c3 100644 --- a/plugins/sync/build.gradle.kts +++ b/plugins/sync/build.gradle.kts @@ -9,6 +9,9 @@ plugins { android { namespace = "app.aaps.plugins.sync" + buildFeatures { + aidl = true + } } dependencies { diff --git a/plugins/sync/src/main/aidl/com/garmin/android/apps/connectmobile/connectiq/IConnectIQService.aidl b/plugins/sync/src/main/aidl/com/garmin/android/apps/connectmobile/connectiq/IConnectIQService.aidl new file mode 100644 index 0000000000..8aaf0eb761 --- /dev/null +++ b/plugins/sync/src/main/aidl/com/garmin/android/apps/connectmobile/connectiq/IConnectIQService.aidl @@ -0,0 +1,30 @@ +/** + * Copyright (C) 2014 Garmin International Ltd. + * Subject to Garmin SDK License Agreement and Wearables Application Developer Agreement. + */ +//IConnectIQService +package com.garmin.android.apps.connectmobile.connectiq; + +import com.garmin.android.connectiq.IQDevice; +import com.garmin.android.connectiq.IQApp; +import com.garmin.android.connectiq.IQMessage; + +interface IConnectIQService { + boolean openStore(String applicationID); + List getConnectedDevices(); + List getKnownDevices(); + + // Remote device methods + int getStatus(in IQDevice device); + + // Messages and Commands + oneway void getApplicationInfo(String notificationPackage, String notificationAction, in IQDevice device, String applicationID); + oneway void openApplication(String notificationPackage, String notificationAction, in IQDevice device, in IQApp app); + + // Pending intent will be fired to let the sdk know a message has been transferred. + oneway void sendMessage(in IQMessage message, in IQDevice device, in IQApp app); + oneway void sendImage(in IQMessage image, in IQDevice device, in IQApp app); + + // registers a companion app with the remote service so that it can receive messages from remote device. + oneway void registerApp(in IQApp app, String notificationAction, String notificationPackage); +} \ No newline at end of file diff --git a/plugins/sync/src/main/aidl/com/garmin/android/connectiq/IQApp.aidl b/plugins/sync/src/main/aidl/com/garmin/android/connectiq/IQApp.aidl new file mode 100644 index 0000000000..2115dafd35 --- /dev/null +++ b/plugins/sync/src/main/aidl/com/garmin/android/connectiq/IQApp.aidl @@ -0,0 +1,12 @@ +/** + * Copyright (C) 2014 Garmin International Ltd. + * Subject to Garmin SDK License Agreement and Wearables Application Developer Agreement. + */ +package com.garmin.android.connectiq; + +parcelable IQApp { + String applicationID; + int status; + String displayName; + int version; +} \ No newline at end of file diff --git a/plugins/sync/src/main/aidl/com/garmin/android/connectiq/IQDevice.aidl b/plugins/sync/src/main/aidl/com/garmin/android/connectiq/IQDevice.aidl new file mode 100644 index 0000000000..ec1d4f3217 --- /dev/null +++ b/plugins/sync/src/main/aidl/com/garmin/android/connectiq/IQDevice.aidl @@ -0,0 +1,11 @@ +/** + * Copyright (C) 2014 Garmin International Ltd. + * Subject to Garmin SDK License Agreement and Wearables Application Developer Agreement. + */ +package com.garmin.android.connectiq; + +parcelable IQDevice { + long deviceIdentifier; + String friendlyName; + int status; +} \ No newline at end of file diff --git a/plugins/sync/src/main/aidl/com/garmin/android/connectiq/IQMessage.aidl b/plugins/sync/src/main/aidl/com/garmin/android/connectiq/IQMessage.aidl new file mode 100644 index 0000000000..cbf6335fb0 --- /dev/null +++ b/plugins/sync/src/main/aidl/com/garmin/android/connectiq/IQMessage.aidl @@ -0,0 +1,20 @@ +/** + * Copyright (C) 2014 Garmin International Ltd. + * Subject to Garmin SDK License Agreement and Wearables Application Developer Agreement. + */ +package com.garmin.android.connectiq; + +parcelable IQMessage { + const int SUCCESS = 0; + const int FAILURE_UNKNOWN = 1; + const int FAILURE_INVALID_FORMAT = 2; + const int FAILURE_MESSAGE_TOO_LARGE = 3; + const int FAILURE_UNSUPPORTED_TYPE = 4; + const int FAILURE_DURING_TRANSFER = 5; + const int FAILURE_INVALID_DEVICE = 6; + const int FAILURE_DEVICE_NOT_CONNECTED = 7; + + byte[] messageData; + String notificationPackage; + String notificationAction; +} \ No newline at end of file diff --git a/plugins/sync/src/main/kotlin/app/aaps/plugins/sync/garmin/GarminApplication.kt b/plugins/sync/src/main/kotlin/app/aaps/plugins/sync/garmin/GarminApplication.kt new file mode 100644 index 0000000000..3aec9257a9 --- /dev/null +++ b/plugins/sync/src/main/kotlin/app/aaps/plugins/sync/garmin/GarminApplication.kt @@ -0,0 +1,39 @@ +package app.aaps.plugins.sync.garmin + +data class GarminApplication( + val client: GarminClient, + val device: GarminDevice, + val id: String, + val name: String?) { + + enum class Status { + @Suppress("UNUSED") + UNKNOWN, + INSTALLED, + @Suppress("UNUSED") + NOT_INSTALLED, + @Suppress("UNUSED") + NOT_SUPPORTED; + } + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as GarminApplication + + if (client != other.client) return false + if (device != other.device) return false + if (id != other.id) return false + + return true + } + + override fun hashCode(): Int { + var result = client.hashCode() + result = 31 * result + device.hashCode() + result = 31 * result + id.hashCode() + return result + } +} + diff --git a/plugins/sync/src/main/kotlin/app/aaps/plugins/sync/garmin/GarminClient.kt b/plugins/sync/src/main/kotlin/app/aaps/plugins/sync/garmin/GarminClient.kt new file mode 100644 index 0000000000..9c1631497d --- /dev/null +++ b/plugins/sync/src/main/kotlin/app/aaps/plugins/sync/garmin/GarminClient.kt @@ -0,0 +1,14 @@ +package app.aaps.plugins.sync.garmin + +import io.reactivex.rxjava3.disposables.Disposable + +interface GarminClient: Disposable { + /** Name of the client. */ + val name: String + + /** Asynchronously retrieves status information for the given application. */ + fun retrieveApplicationInfo(device: GarminDevice, appId: String, appName: String) + + /** Asynchronously sends a message to an application. */ + fun sendMessage(app: GarminApplication, data: ByteArray) +} \ No newline at end of file diff --git a/plugins/sync/src/main/kotlin/app/aaps/plugins/sync/garmin/GarminDevice.kt b/plugins/sync/src/main/kotlin/app/aaps/plugins/sync/garmin/GarminDevice.kt new file mode 100644 index 0000000000..b0fc0ead8f --- /dev/null +++ b/plugins/sync/src/main/kotlin/app/aaps/plugins/sync/garmin/GarminDevice.kt @@ -0,0 +1,54 @@ +package app.aaps.plugins.sync.garmin + +import com.garmin.android.connectiq.IQDevice + +data class GarminDevice( + val client: GarminClient, + val id: Long, + var name: String, + var status: Status = Status.UNKNOWN) { + + constructor(client: GarminClient, iqDevice: IQDevice): this( + client, + iqDevice.deviceIdentifier, + iqDevice.friendlyName, + Status.from(iqDevice.status)) {} + + enum class Status { + NOT_PAIRED, + NOT_CONNECTED, + CONNECTED, + UNKNOWN; + + companion object { + fun from(ordinal: Int?): Status = + values().firstOrNull { s -> s.ordinal == ordinal } ?: UNKNOWN + } + } + + + override fun toString(): String = "D[$name/$id]" + + fun toIQDevice() = IQDevice().apply { + deviceIdentifier = id + friendlyName = name + status = Status.UNKNOWN.ordinal } + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as GarminDevice + + if (client != other.client) return false + if (id != other.id) return false + + return true + } + + override fun hashCode(): Int { + var result = client.hashCode() + result = 31 * result + id.hashCode() + return result + } +} \ No newline at end of file diff --git a/plugins/sync/src/main/kotlin/app/aaps/plugins/sync/garmin/GarminDeviceClient.kt b/plugins/sync/src/main/kotlin/app/aaps/plugins/sync/garmin/GarminDeviceClient.kt new file mode 100644 index 0000000000..358b7b9f16 --- /dev/null +++ b/plugins/sync/src/main/kotlin/app/aaps/plugins/sync/garmin/GarminDeviceClient.kt @@ -0,0 +1,286 @@ +package app.aaps.plugins.sync.garmin + +import android.content.BroadcastReceiver +import android.content.ComponentName +import android.content.Context +import android.content.Intent +import android.content.IntentFilter +import android.content.ServiceConnection +import android.os.IBinder +import app.aaps.core.interfaces.logging.AAPSLogger +import app.aaps.core.interfaces.logging.LTag +import app.aaps.core.utils.waitMillis +import com.garmin.android.apps.connectmobile.connectiq.IConnectIQService +import com.garmin.android.connectiq.IQApp +import com.garmin.android.connectiq.IQDevice +import com.garmin.android.connectiq.IQMessage +import io.reactivex.rxjava3.disposables.Disposable +import io.reactivex.rxjava3.schedulers.Schedulers +import org.jetbrains.annotations.VisibleForTesting +import java.time.Duration +import java.time.Instant +import java.util.LinkedList +import java.util.Locale +import java.util.Queue +import java.util.concurrent.TimeUnit + + +/** GarminClient that talks via the ConnectIQ app to a physical device. */ +class GarminDeviceClient( + private val aapsLogger: AAPSLogger, + private val context: Context, + private val receiver: GarminReceiver, + private val retryWaitFactor: Long = 5L): Disposable, GarminClient { + + override val name = "Device" + private var bindLock = Object() + private var ciqService: IConnectIQService? = null + get() { + val waitUntil = Instant.now().plusSeconds(2) + synchronized (bindLock) { + while(field?.asBinder()?.isBinderAlive != true) { + field = null + if (state !in arrayOf(State.BINDING, State.RECONNECTING)) { + aapsLogger.info(LTag.GARMIN, "reconnecting to ConnectIQ service") + state = State.RECONNECTING + context.bindService(serviceIntent, ciqServiceConnection, Context.BIND_AUTO_CREATE) + } + // Wait for the connection, that is the call to onServiceConnected. + val wait = Duration.between(Instant.now(), waitUntil) + if (wait > Duration.ZERO) bindLock.waitMillis(wait.toMillis()) + if (field == null) { + // The [serviceConnection] didn't have a chance to reassign ciqService, + // i.e. the wait timed out. Give up. + aapsLogger.warn(LTag.GARMIN, "no ciqservice $this") + return null + } + } + return field + } + } + + private val registeredActions = mutableSetOf() + private val broadcastReceiver = mutableListOf() + private var state = State.DISCONNECTED + private val serviceIntent get() = Intent(CONNECTIQ_SERVICE_ACTION).apply { + component = CONNECTIQ_SERVICE_COMPONENT } + + @VisibleForTesting + val sendMessageAction = createAction("SEND_MESSAGE") + + private enum class State { + BINDING, + CONNECTED, + DISCONNECTED, + DISPOSED, + RECONNECTING, + } + + private val ciqServiceConnection = object: ServiceConnection { + override fun onServiceConnected(name: ComponentName?, service: IBinder?) { + var notifyReceiver: Boolean + val ciq: IConnectIQService + synchronized (bindLock) { + aapsLogger.info(LTag.GARMIN, "ConnectIQ App connected") + ciq = IConnectIQService.Stub.asInterface(service) + notifyReceiver = state != State.RECONNECTING + state = State.CONNECTED + ciqService = ciq + bindLock.notifyAll() + } + if (notifyReceiver) receiver.onConnect(this@GarminDeviceClient) + ciq.connectedDevices?.forEach { d -> + receiver.onConnectDevice(this@GarminDeviceClient, d.deviceIdentifier, d.friendlyName) } + } + override fun onServiceDisconnected(name: ComponentName?) { + synchronized(bindLock) { + aapsLogger.info(LTag.GARMIN, "ConnectIQ App disconnected") + ciqService = null + if (state != State.DISPOSED) state = State.DISCONNECTED + } + broadcastReceiver.forEach { br -> context.unregisterReceiver(br) } + broadcastReceiver.clear() + registeredActions.clear() + receiver.onDisconnect(this@GarminDeviceClient) + } + } + + init { + aapsLogger.info(LTag.GARMIN, "binding to ConnectIQ service") + registerReceiver(sendMessageAction, ::onSendMessage) + state = State.BINDING + context.bindService(serviceIntent, ciqServiceConnection, Context.BIND_AUTO_CREATE) + } + + override fun isDisposed() = state == State.DISPOSED + override fun dispose() { + broadcastReceiver.forEach { context.unregisterReceiver(it) } + broadcastReceiver.clear() + registeredActions.clear() + try { + context.unbindService(ciqServiceConnection) + } catch (e: Exception) { + aapsLogger.warn(LTag.GARMIN, "unbind CIQ failed ${e.message}") + } + state = State.DISPOSED + } + + /** Creates a unique action name for ConnectIQ callbacks. */ + private fun createAction(action: String) = "${javaClass.`package`!!.name}.$action" + + /** Registers a callback [BroadcastReceiver] under the given action that will + * used by the ConnectIQ app for callbacks.*/ + private fun registerReceiver(action: String, receive: (intent: Intent) -> Unit) { + val recv = object: BroadcastReceiver() { + override fun onReceive(context: Context?, intent: Intent) { receive(intent) } + } + broadcastReceiver.add(recv) + context.registerReceiver(recv, IntentFilter(action)) + } + + override fun retrieveApplicationInfo(device: GarminDevice, appId: String, appName: String) { + val action = createAction("APPLICATION_INFO_${device.id}_$appId") + synchronized (registeredActions) { + if (!registeredActions.contains(action)) { + registerReceiver(action) { intent -> onApplicationInfo(appId, device, intent) } + } + registeredActions.add(action) + } + ciqService?.getApplicationInfo(context.packageName, action, device.toIQDevice(), appId) + } + + /** Receives application info callbacks from ConnectIQ app.*/ + private fun onApplicationInfo(appId: String, device: GarminDevice, intent: Intent) { + val receivedAppId = intent.getStringExtra(EXTRA_APPLICATION_ID)?.lowercase(Locale.getDefault()) + val version = intent.getIntExtra(EXTRA_APPLICATION_VERSION, -1) + val isInstalled = receivedAppId != null && version >= 0 && version != 65535 + + if (isInstalled) registerForMessages(device.id, appId) + receiver.onApplicationInfo(device, appId, isInstalled) + } + + private fun registerForMessages(deviceId: Long, appId: String) { + aapsLogger.info(LTag.GARMIN, "registerForMessage $name $appId") + val action = createAction("ON_MESSAGE_${deviceId}_$appId") + val app = IQApp().apply { applicationID = appId; displayName = "" } + synchronized (registeredActions) { + if (!registeredActions.contains(action)) { + registerReceiver(action) { intent: Intent -> onReceiveMessage(app, intent) } + ciqService?.registerApp(app, action, context.packageName) + registeredActions.add(action) + } else { + aapsLogger.info(LTag.GARMIN, "registerForMessage $action already registered") + } + } + } + + @Suppress("Deprecation") + private fun onReceiveMessage(iqApp: IQApp, intent: Intent) { + val iqDevice = intent.getParcelableExtra(EXTRA_REMOTE_DEVICE) as IQDevice? + val data = intent.getByteArrayExtra(EXTRA_PAYLOAD) + if (iqDevice != null && data != null) + receiver.onReceiveMessage(this, iqDevice.deviceIdentifier, iqApp.applicationID, data) + } + + /** Receives callback from ConnectIQ about message transfers. */ + private fun onSendMessage(intent: Intent) { + val status = intent.getIntExtra(EXTRA_STATUS, 0) + val deviceId = getDevice(intent) + val appId = intent.getStringExtra(EXTRA_APPLICATION_ID)?.lowercase() + if (deviceId == null || appId == null) { + aapsLogger.warn(LTag.GARMIN, "onSendMessage device='$deviceId' app='$appId'") + } else { + synchronized (messageQueues) { + val queue = messageQueues[deviceId to appId] + val msg = queue?.peek() + if (queue == null || msg == null) { + aapsLogger.warn(LTag.GARMIN, "onSendMessage unknown message $deviceId, $appId, $status") + return + } + + when (status) { + IQMessage.FAILURE_DEVICE_NOT_CONNECTED, + IQMessage.FAILURE_DURING_TRANSFER -> { + if (msg.attempt < MAX_RETRIES) { + val delaySec = retryWaitFactor * msg.attempt + Schedulers.io().scheduleDirect({ retryMessage(deviceId, appId) }, delaySec, TimeUnit.SECONDS) + return + } + } + + else -> {} + } + queue.remove(msg) + val errorMessage = status + .takeUnless { it == IQMessage.SUCCESS }?.let { s -> "error $s" } + receiver.onSendMessage(this, msg.app.device.id, msg.app.id, errorMessage) + if (queue.isNotEmpty()) { + Schedulers.io().scheduleDirect { retryMessage(deviceId, appId) } + } + } + } + } + + @Suppress("Deprecation") + private fun getDevice(intent: Intent): Long? { + val rawDevice = intent.extras?.get(EXTRA_REMOTE_DEVICE) + return if (rawDevice is Long) rawDevice else (rawDevice as IQDevice?)?.deviceIdentifier + ?: return null + } + + private class Message( + val app: GarminApplication, + val data: ByteArray) { + var attempt: Int = 0 + var lastAttempt: Instant? = null + val iqApp get() = IQApp().apply { applicationID = app.id; displayName = app.name } + val iqDevice get() = app.device.toIQDevice() + } + + private val messageQueues = mutableMapOf, Queue> () + + override fun sendMessage(app: GarminApplication, data: ByteArray) { + val msg = synchronized (messageQueues) { + val msg = Message(app, data) + val queue = messageQueues.getOrPut(app.device.id to app.id) { LinkedList() } + queue.add(msg) + // Make sure we have only one outstanding message per app, so we ensure + // that always the first message in the queue is currently send. + if (queue.size == 1) msg else null + } + if (msg != null) sendMessage(msg) + } + + private fun retryMessage(deviceId: Long, appId: String) { + val msg = synchronized (messageQueues) { + messageQueues[deviceId to appId]?.peek() ?: return + } + sendMessage(msg) + } + + private fun sendMessage(msg: Message) { + msg.attempt++ + msg.lastAttempt = Instant.now() + val iqMsg = IQMessage().apply { + messageData = msg.data + notificationPackage = context.packageName + notificationAction = sendMessageAction } + ciqService?.sendMessage(iqMsg, msg.iqDevice, msg.iqApp) + } + + override fun toString() = "$name[$state]" + + companion object { + const val CONNECTIQ_SERVICE_ACTION = "com.garmin.android.apps.connectmobile.CONNECTIQ_SERVICE_ACTION" + const val EXTRA_APPLICATION_ID = "com.garmin.android.connectiq.EXTRA_APPLICATION_ID" + const val EXTRA_APPLICATION_VERSION = "com.garmin.android.connectiq.EXTRA_APPLICATION_VERSION" + const val EXTRA_REMOTE_DEVICE = "com.garmin.android.connectiq.EXTRA_REMOTE_DEVICE" + const val EXTRA_PAYLOAD = "com.garmin.android.connectiq.EXTRA_PAYLOAD" + const val EXTRA_STATUS = "com.garmin.android.connectiq.EXTRA_STATUS" + val CONNECTIQ_SERVICE_COMPONENT = ComponentName( + "com.garmin.android.apps.connectmobile", + "com.garmin.android.apps.connectmobile.connectiq.ConnectIQService") + + const val MAX_RETRIES = 10 + } +} \ No newline at end of file diff --git a/plugins/sync/src/main/kotlin/app/aaps/plugins/sync/garmin/GarminMessenger.kt b/plugins/sync/src/main/kotlin/app/aaps/plugins/sync/garmin/GarminMessenger.kt new file mode 100644 index 0000000000..87c60ece7e --- /dev/null +++ b/plugins/sync/src/main/kotlin/app/aaps/plugins/sync/garmin/GarminMessenger.kt @@ -0,0 +1,159 @@ +package app.aaps.plugins.sync.garmin + +import android.content.Context +import app.aaps.core.interfaces.logging.AAPSLogger +import app.aaps.core.interfaces.logging.LTag +import io.reactivex.rxjava3.disposables.Disposable +import org.jetbrains.annotations.VisibleForTesting + +class GarminMessenger( + private val aapsLogger: AAPSLogger, + private val context: Context, + applicationIdNames: Map, + private val messageCallback: (app: GarminApplication, msg: Any) -> Unit, + enableConnectIq: Boolean, + enableSimulator: Boolean): Disposable, GarminReceiver { + + private var disposed: Boolean = false + /** All devices that where connected since this instance was created. */ + private val devices = mutableMapOf() + @VisibleForTesting + val liveApplications = mutableSetOf() + private val clients = mutableListOf() + private val appIdNames = mutableMapOf() + init { + aapsLogger.info(LTag.GARMIN, "init CIQ debug=$enableSimulator") + appIdNames.putAll(applicationIdNames) + if (enableConnectIq) startDeviceClient() + if (enableSimulator) { + appIdNames["SimAp"] = "SimulatorApp" + GarminSimulatorClient(aapsLogger, this) + } + } + + private fun getDevice(client: GarminClient, deviceId: Long): GarminDevice { + synchronized (devices) { + return devices.getOrPut(deviceId) { GarminDevice(client, deviceId, "unknown") } + } + } + + private fun getApplication(client: GarminClient, deviceId: Long, appId: String): GarminApplication { + synchronized (liveApplications) { + var app = liveApplications.firstOrNull { app -> + app.client == client && app.device.id == deviceId && app.id == appId } + if (app == null) { + app = GarminApplication(client, getDevice(client, deviceId), appId, appIdNames[appId]) + liveApplications.add(app) + } + return app + } + } + + private fun startDeviceClient() { + GarminDeviceClient(aapsLogger, context, this) + } + + override fun onConnect(client: GarminClient) { + aapsLogger.info(LTag.GARMIN, "onConnect $client") + clients.add(client) + } + + override fun onDisconnect(client: GarminClient) { + aapsLogger.info(LTag.GARMIN, "onDisconnect ${client.name}") + clients.remove(client) + synchronized (liveApplications) { + liveApplications.removeIf { app -> app.client == client } + } + client.dispose() + when (client.name) { + "Device" -> startDeviceClient() + "Sim"-> GarminSimulatorClient(aapsLogger, this) + else -> aapsLogger.warn(LTag.GARMIN, "onDisconnect unknown client $client") + } + } + + /** Receives notifications that a device has connected. + * + * It will retrieve status information for all applications we care about (in [appIdNames]). */ + override fun onConnectDevice(client: GarminClient, deviceId: Long, deviceName: String) { + val device = getDevice(client, deviceId).apply { name = deviceName } + aapsLogger.info(LTag.GARMIN, "onConnectDevice $device") + appIdNames.forEach { (id, name) -> client.retrieveApplicationInfo(device, id, name) } + } + + /** Receives notifications about disconnection of a device. */ + override fun onDisconnectDevice(client: GarminClient, deviceId: Long) { + val device = getDevice(client, deviceId) + aapsLogger.info(LTag.GARMIN,"onDisconnectDevice $device") + synchronized (liveApplications) { + liveApplications.removeIf { app -> app.device == device } + } + } + + /** Receives notification about applications that are installed/uninstalled + * on a device from the client. */ + override fun onApplicationInfo(device: GarminDevice, appId: String, isInstalled: Boolean) { + val app = getApplication(device.client, device.id, appId) + aapsLogger.info(LTag.GARMIN, "onApplicationInfo add $app ${if (isInstalled) "" else "un"}installed") + if (!isInstalled) { + synchronized (liveApplications) { liveApplications.remove(app) } + } + } + + override fun onReceiveMessage(client: GarminClient, deviceId: Long, appId: String, data: ByteArray) { + val app = getApplication(client, deviceId, appId) + val msg = GarminSerializer.deserialize(data) + if (msg == null) { + aapsLogger.warn(LTag.GARMIN, "receive NULL msg") + } else { + aapsLogger.info(LTag.GARMIN, "receive ${data.size} bytes") + messageCallback(app, msg) + } + } + + /** Receives status notifications for a sent message. */ + override fun onSendMessage(client: GarminClient, deviceId: Long, appId: String, errorMessage: String?) { + val app = getApplication(client, deviceId, appId) + aapsLogger.info(LTag.GARMIN, "onSendMessage $app ${errorMessage ?: "OK"}") + } + + fun sendMessage(device: GarminDevice, msg: Any) { + liveApplications + .filter { a -> a.device.id == device.id } + .forEach { a -> sendMessage(a, msg) } + } + + /** Sends a message to all applications on all devices. */ + fun sendMessage(msg: Any) { + liveApplications.forEach { app -> sendMessage(app, msg) } + } + + private fun sendMessage(app: GarminApplication, msg: Any) { + // Convert msg to string for logging. + val s = when (msg) { + is Map<*,*> -> + msg.entries.joinToString(", ", "(", ")") { (k, v) -> "$k=$v" } + is List<*> -> + msg.joinToString(", ", "(", ")") + else -> + msg.toString() + } + val data = GarminSerializer.serialize(msg) + aapsLogger.info(LTag.GARMIN, "sendMessage $app $app ${data.size} bytes $s") + try { + app.client.sendMessage(app, data) + } catch (e: IllegalStateException) { + aapsLogger.error(LTag.GARMIN, "${app.client} not connected", e) + } + } + + override fun dispose() { + if (!disposed) { + clients.forEach { c -> c.dispose() } + disposed = true + } + clients.clear() + } + + override fun isDisposed() = disposed +} \ No newline at end of file diff --git a/plugins/sync/src/main/kotlin/app/aaps/plugins/sync/garmin/GarminPlugin.kt b/plugins/sync/src/main/kotlin/app/aaps/plugins/sync/garmin/GarminPlugin.kt index 5d20fd3c70..50f9410e7e 100644 --- a/plugins/sync/src/main/kotlin/app/aaps/plugins/sync/garmin/GarminPlugin.kt +++ b/plugins/sync/src/main/kotlin/app/aaps/plugins/sync/garmin/GarminPlugin.kt @@ -1,5 +1,6 @@ package app.aaps.plugins.sync.garmin +import android.content.Context import androidx.annotation.VisibleForTesting import app.aaps.core.interfaces.db.GlucoseUnit import app.aaps.core.interfaces.logging.AAPSLogger @@ -18,6 +19,7 @@ import com.google.gson.JsonObject import dagger.android.HasAndroidInjector import io.reactivex.rxjava3.disposables.CompositeDisposable import io.reactivex.rxjava3.schedulers.Schedulers +import java.net.HttpURLConnection import java.net.SocketAddress import java.net.URI import java.time.Clock @@ -42,6 +44,7 @@ class GarminPlugin @Inject constructor( injector: HasAndroidInjector, aapsLogger: AAPSLogger, resourceHelper: ResourceHelper, + private val context: Context, private val loopHub: LoopHub, private val rxBus: RxBus, private val sp: SP, @@ -57,7 +60,19 @@ class GarminPlugin @Inject constructor( ) { /** HTTP Server for local HTTP server communication (device app requests values) .*/ private var server: HttpServer? = null + var garminMessenger: GarminMessenger? = null + /** Garmin ConnectIQ application id for native communication. Phone pushes values. */ + private val glucoseAppIds = mapOf( + "c9e90ee7e6924829a8b45e7dafff5cb4" to "GlucoseWatch_Dev", + "1107ca6c2d5644b998d4bcb3793f2b7c" to "GlucoseDataField_Dev", + "928fe19a4d3a4259b50cb6f9ddaf0f4a" to "GlucoseWidget_Dev", + "662dfcf7f5a147de8bd37f09574adb11" to "GlucoseWatch", + "815c7328c21248c493ad9ac4682fe6b3" to "GlucoseDataField", + "4bddcc1740084a1fab83a3b2e2fcf55b" to "GlucoseWidget", + ) + + @VisibleForTesting private val disposable = CompositeDisposable() @VisibleForTesting @@ -68,10 +83,25 @@ class GarminPlugin @Inject constructor( var newValue: Condition = valueLock.newCondition() private var lastGlucoseValueTimestamp: Long? = null private val glucoseUnitStr get() = if (loopHub.glucoseUnit == GlucoseUnit.MGDL) "mgdl" else "mmoll" + private val garminAapsKey get() = sp.getString("garmin_aaps_key", "") private fun onPreferenceChange(event: EventPreferenceChange) { aapsLogger.info(LTag.GARMIN, "preferences change ${event.changedKey}") - setupHttpServer() + when (event.changedKey) { + "communication_debug_mode" -> setupGarminMessenger() + "communication_http", "communication_http_port" -> setupHttpServer() + "garmin_aaps_key" -> sendPhoneAppMessage() + } + } + + private fun setupGarminMessenger() { + val enableDebug = sp.getBoolean("communication_ciq_debug_mode", false) + garminMessenger?.dispose() + garminMessenger = null + aapsLogger.info(LTag.GARMIN, "initialize IQ messenger in debug=$enableDebug") + garminMessenger = GarminMessenger( + aapsLogger, context, glucoseAppIds, {_, _ -> }, + true, enableDebug).also { disposable.add(it) } } override fun onStart() { @@ -83,19 +113,26 @@ class GarminPlugin @Inject constructor( .observeOn(Schedulers.io()) .subscribe(::onPreferenceChange) ) + disposable.add( + rxBus + .toObservable(EventNewBG::class.java) + .observeOn(Schedulers.io()) + .subscribe(::onNewBloodGlucose) + ) setupHttpServer() + setupGarminMessenger() } - private fun setupHttpServer() { + fun setupHttpServer() { if (sp.getBoolean("communication_http", false)) { val port = sp.getInt("communication_http_port", 28891) if (server != null && server?.port == port) return aapsLogger.info(LTag.GARMIN, "starting HTTP server on $port") server?.close() server = HttpServer(aapsLogger, port).apply { - registerEndpoint("/get", ::onGetBloodGlucose) - registerEndpoint("/carbs", ::onPostCarbs) - registerEndpoint("/connect", ::onConnectPump) + registerEndpoint("/get", requestHandler(::onGetBloodGlucose)) + registerEndpoint("/carbs", requestHandler(::onPostCarbs)) + registerEndpoint("/connect", requestHandler(::onConnectPump)) } } else if (server != null) { aapsLogger.info(LTag.GARMIN, "stopping HTTP server") @@ -104,7 +141,7 @@ class GarminPlugin @Inject constructor( } } - override fun onStop() { + public override fun onStop() { disposable.clear() aapsLogger.info(LTag.GARMIN, "Stop") server?.close() @@ -128,6 +165,34 @@ class GarminPlugin @Inject constructor( } } + @VisibleForTesting + fun onConnectDevice(device: GarminDevice) { + aapsLogger.info(LTag.GARMIN, "onConnectDevice $device sending glucose") + if (garminAapsKey.isNotEmpty()) sendPhoneAppMessage(device) + } + + private fun sendPhoneAppMessage(device: GarminDevice) { + garminMessenger?.sendMessage(device, getGlucoseMessage()) + } + + private fun sendPhoneAppMessage() { + garminMessenger?.sendMessage(getGlucoseMessage()) + } + + @VisibleForTesting + fun getGlucoseMessage() = mapOf( + "key" to garminAapsKey, + "command" to "glucose", + "profile" to loopHub.currentProfileName.first().toString(), + "encodedGlucose" to encodedGlucose(getGlucoseValues()), + "remainingInsulin" to loopHub.insulinOnboard, + "glucoseUnit" to glucoseUnitStr, + "temporaryBasalRate" to + (loopHub.temporaryBasal.takeIf(java.lang.Double::isFinite) ?: 1.0), + "connected" to loopHub.isConnected, + "timestamp" to clock.instant().epochSecond + ) + /** Gets the last 2+ hours of glucose values. */ @VisibleForTesting fun getGlucoseValues(): List { @@ -161,21 +226,33 @@ class GarminPlugin @Inject constructor( val glucoseMgDl: Int = glucose.value.roundToInt() encodedGlucose.add(timeSec, glucoseMgDl) } - aapsLogger.info( - LTag.GARMIN, - "retrieved ${glucoseValues.size} last ${Date(glucoseValues.lastOrNull()?.timestamp ?: 0L)} ${encodedGlucose.size}" - ) return encodedGlucose.encodedBase64() } + @VisibleForTesting + fun requestHandler(action: (URI) -> CharSequence) = { + caller: SocketAddress, uri: URI, _: String? -> + val key = garminAapsKey + val deviceKey = getQueryParameter(uri, "key") + if (key.isNotEmpty() && key != deviceKey) { + aapsLogger.warn(LTag.GARMIN, "Invalid AAPS Key from $caller, got '$deviceKey' want '$key' $uri") + sendPhoneAppMessage() + Thread.sleep(1000L) + HttpURLConnection.HTTP_UNAUTHORIZED to "{}" + } else { + aapsLogger.info(LTag.GARMIN, "get from $caller resp , req: $uri") + HttpURLConnection.HTTP_OK to action(uri).also { + aapsLogger.info(LTag.GARMIN, "get from $caller resp , req: $uri, result: $it") + } + } + } + /** Responses to get glucose value request by the device. * * Also, gets the heart rate readings from the device. */ @VisibleForTesting - @Suppress("UNUSED_PARAMETER") - fun onGetBloodGlucose(caller: SocketAddress, uri: URI, requestBody: String?): CharSequence { - aapsLogger.info(LTag.GARMIN, "get from $caller resp , req: $uri") + fun onGetBloodGlucose(uri: URI): CharSequence { receiveHeartRate(uri) val profileName = loopHub.currentProfileName val waitSec = getQueryParameter(uri, "wait", 0L) @@ -189,9 +266,7 @@ class GarminPlugin @Inject constructor( } jo.addProperty("profile", profileName.first().toString()) jo.addProperty("connected", loopHub.isConnected) - return jo.toString().also { - aapsLogger.info(LTag.GARMIN, "get from $caller resp , req: $uri, result: $it") - } + return jo.toString() } private fun getQueryParameter(uri: URI, name: String) = (uri.query ?: "") @@ -223,6 +298,19 @@ class GarminPlugin @Inject constructor( } } + private fun toLong(v: Any?) = (v as? Number?)?.toLong() ?: 0L + + @VisibleForTesting + fun receiveHeartRate(msg: Map, test: Boolean) { + val avg: Int = msg.getOrDefault("hr", 0) as Int + val samplingStartSec: Long = toLong(msg["hrStart"]) + val samplingEndSec: Long = toLong(msg["hrEnd"]) + val device: String? = msg["device"] as String? + receiveHeartRate( + Instant.ofEpochSecond(samplingStartSec), Instant.ofEpochSecond(samplingEndSec), + avg, device, test) + } + @VisibleForTesting fun receiveHeartRate(uri: URI) { val avg: Int = getQueryParameter(uri, "hr", 0L).toInt() @@ -237,7 +325,7 @@ class GarminPlugin @Inject constructor( private fun receiveHeartRate( samplingStart: Instant, samplingEnd: Instant, avg: Int, device: String?, test: Boolean) { - aapsLogger.info(LTag.GARMIN, "average heart rate $avg BPM test=$test") + aapsLogger.info(LTag.GARMIN, "average heart rate $avg BPM $samplingStart to $samplingEnd") if (test) return if (avg > 10 && samplingStart > Instant.ofEpochMilli(0L) && samplingEnd > samplingStart) { loopHub.storeHeartRate(samplingStart, samplingEnd, avg, device) @@ -248,9 +336,7 @@ class GarminPlugin @Inject constructor( /** Handles carb notification from the device. */ @VisibleForTesting - @Suppress("UNUSED_PARAMETER") - fun onPostCarbs(caller: SocketAddress, uri: URI, requestBody: String?): CharSequence { - aapsLogger.info(LTag.GARMIN, "carbs from $caller, req: $uri") + fun onPostCarbs(uri: URI): CharSequence { postCarbs(getQueryParameter(uri, "carbs", 0L).toInt()) return "" } @@ -263,9 +349,7 @@ class GarminPlugin @Inject constructor( /** Handles pump connected notification that the user entered on the Garmin device. */ @VisibleForTesting - @Suppress("UNUSED_PARAMETER") - fun onConnectPump(caller: SocketAddress, uri: URI, requestBody: String?): CharSequence { - aapsLogger.info(LTag.GARMIN, "connect from $caller, req: $uri") + fun onConnectPump(uri: URI): CharSequence { val minutes = getQueryParameter(uri, "disconnectMinutes", 0L).toInt() if (minutes > 0) { loopHub.disconnectPump(minutes) diff --git a/plugins/sync/src/main/kotlin/app/aaps/plugins/sync/garmin/GarminReceiver.kt b/plugins/sync/src/main/kotlin/app/aaps/plugins/sync/garmin/GarminReceiver.kt new file mode 100644 index 0000000000..c308e4bead --- /dev/null +++ b/plugins/sync/src/main/kotlin/app/aaps/plugins/sync/garmin/GarminReceiver.kt @@ -0,0 +1,36 @@ +package app.aaps.plugins.sync.garmin + +/** + * Callback interface for a @see ConnectIqClient. + */ +interface GarminReceiver { + /** + * Notifies that the client is ready, i.e. the app client as bound to the Garmin + * Android app. + */ + fun onConnect(client: GarminClient) + fun onDisconnect(client: GarminClient) + + /** + * Notifies that a device is connected. This will be called for all connected devices + * initially. + */ + fun onConnectDevice(client: GarminClient, deviceId: Long, deviceName: String) + fun onDisconnectDevice(client: GarminClient, deviceId: Long) + + /** + * Provides application info after a call to + * {@link ConnectIqClient#retrieveApplicationInfo retrieveApplicationInfo}. + */ + fun onApplicationInfo(device: GarminDevice, appId: String, isInstalled: Boolean) + + /** + * Delivers received device app messages. + */ + fun onReceiveMessage(client: GarminClient, deviceId: Long, appId: String, data: ByteArray) + + /** + * Delivers status of @see ConnectIqClient#sendMessage requests. + */ + fun onSendMessage(client: GarminClient, deviceId: Long, appId: String, errorMessage: String?) +} \ No newline at end of file diff --git a/plugins/sync/src/main/kotlin/app/aaps/plugins/sync/garmin/GarminSerializer.kt b/plugins/sync/src/main/kotlin/app/aaps/plugins/sync/garmin/GarminSerializer.kt new file mode 100644 index 0000000000..495278e9da --- /dev/null +++ b/plugins/sync/src/main/kotlin/app/aaps/plugins/sync/garmin/GarminSerializer.kt @@ -0,0 +1,254 @@ +package app.aaps.plugins.sync.garmin + +import java.io.ByteArrayOutputStream +import java.io.DataOutputStream +import java.nio.ByteBuffer +import java.util.ArrayDeque +import java.util.Queue + +/** + * Serialize and Deserialize objects in Garmin format. + * + * Format is as follows: + * ... + * + * Serialized data starts with an optional string block. The string block is preceded with the STRS_MARKER, + * followed by the total length of the reminder (4 bytes). Then foreach string, the string length + * (2 bytes), followed by the string bytes, followed by a \0 byte. + * + * Objects are stored starting with OBJS_MARKER, followed by the total length (4 bytes), followed + * by a flat list of objects. Each object starts with its type (1 byte), followed by the data + * for numbers in Boolean. Strings a represented by an index into the string block. Arrays only have + * the length, the actual objects will be in the list of objects. Similarly, maps only have the + * length and the entries are represented by 2 objects (key + val) in the list of objects. + */ +object GarminSerializer { + private const val NULL = 0 + private const val INT = 1 + private const val FLOAT = 2 + private const val STRING = 3 + private const val ARRAY = 5 + private const val BOOLEAN = 9 + private const val MAP = 11 + private const val LONG = 14 + private const val DOUBLE = 15 + private const val CHAR = 19 + + private const val STRINGS_MARKER = -1412584499 + private const val OBJECTS_MARKER = -629482886 + // ArrayDeque doesn't like null so we use this instead. + private val NULL_MARKER = object {} + + private interface Container { + fun read(buf: ByteBuffer, strings: Map, container: Queue) + } + + private class ListContainer( + val size: Int, + val list: MutableList + ) : Container { + + override fun read(buf: ByteBuffer, strings: Map, container: Queue) { + for (i in 0 until size) { + list.add(readObject(buf, strings, container)) + } + } + } + + private class MapContainer( + val size: Int, + val map: MutableMap + ) : Container { + + override fun read(buf: ByteBuffer, strings: Map, container: Queue) { + for (i in 0 until size) { + val k = readObject(buf, strings, container) + val v = readObject(buf, strings, container) + map[k!!] = v + } + } + } + + + fun serialize(obj: Any?): ByteArray { + val strsOut = ByteArrayOutputStream() + val strsDataOut = DataOutputStream(strsOut) + val objsOut = ByteArrayOutputStream() + val strings = mutableMapOf() + val q = ArrayDeque() + + q.add(obj ?: NULL_MARKER) + while (!q.isEmpty()) { + serialize(q.poll(), strsDataOut, DataOutputStream(objsOut), strings, q) + } + + var bufLen = 8 + objsOut.size() + if (strsOut.size() > 0) { + bufLen += 8 + strsOut.size() + } + + val buf = ByteBuffer.allocate(bufLen) + if (strsOut.size() > 0) { + buf.putInt(STRINGS_MARKER) + buf.putInt(strsOut.size()) + buf.put(strsOut.toByteArray(), 0, strsOut.size()) + } + buf.putInt(OBJECTS_MARKER) + buf.putInt(objsOut.size()) + buf.put(objsOut.toByteArray(), 0, objsOut.size()) + return buf.array() + } + + private fun serialize( + obj: Any?, + strOut: DataOutputStream, + objOut: DataOutputStream, + strings: MutableMap, + q: Queue + ) { + when (obj) { + NULL_MARKER -> objOut.writeByte(NULL) + + is Int -> { + objOut.writeByte(INT) + objOut.writeInt(obj) + } + + is Float -> { + objOut.writeByte(FLOAT) + objOut.writeFloat(obj) + } + + is String -> { + objOut.writeByte(STRING) + val offset = strings[obj] + if (offset == null) { + strings[obj] = strOut.size() + val bytes = obj.toByteArray(Charsets.UTF_8) + strOut.writeShort(bytes.size + 1) + strOut.write(bytes) + strOut.write(0) + } + objOut.writeInt(strings[obj]!!) + } + + is List<*> -> { + objOut.writeByte(ARRAY) + objOut.writeInt(obj.size) + obj.forEach { o -> q.add(o ?: NULL_MARKER) } + } + + is Boolean -> { + objOut.writeByte(BOOLEAN) + objOut.writeByte(if (obj) 1 else 0) + } + + is Map<*, *> -> { + objOut.writeByte(MAP) + objOut.writeInt(obj.size) + obj.entries.forEach { (k, v) -> + q.add(k ?: NULL_MARKER); q.add(v ?: NULL_MARKER) } + } + + is Long -> { + objOut.writeByte(LONG) + objOut.writeLong(obj) + } + + is Double -> { + objOut.writeByte(DOUBLE) + objOut.writeDouble(obj) + } + + is Char -> { + objOut.writeByte(CHAR) + objOut.writeInt(obj.code) + } + + else -> + throw IllegalArgumentException("Unsupported type ${obj?.javaClass} '$obj'") + } + } + + fun deserialize(data: ByteArray): Any? { + val buf = ByteBuffer.wrap(data) + val marker1 = buf.getInt(0) + val strings = if (marker1 == STRINGS_MARKER) { + buf.int // swallow the marker + readStrings(buf) + } else { + emptyMap() + } + val marker2 = buf.int // swallow the marker + if (marker2 != OBJECTS_MARKER) { + throw IllegalArgumentException("expected data marker, got $marker2") + } + return readObjects(buf, strings) + } + + private fun readStrings(buf: ByteBuffer): Map { + val strings = mutableMapOf() + val strBufferLen = buf.int + val offset = buf.position() + while (buf.position() - offset < strBufferLen) { + val pos = buf.position() - offset + val strLen = buf.short.toInt() - 1 // ignore \0 byte + val strBytes = ByteArray(strLen) + buf.get(strBytes) + strings[pos] = String(strBytes, Charsets.UTF_8) + buf.get() // swallow \0 byte + } + return strings + } + + private fun readObjects(buf: ByteBuffer, strings: Map): Any? { + val objBufferLen = buf.int + if (objBufferLen > buf.remaining()) { + throw IllegalArgumentException("expect $objBufferLen bytes got ${buf.remaining()}") + } + + val container = ArrayDeque() + val r = readObject(buf, strings, container) + while (container.isNotEmpty()) { + container.pollFirst()?.read(buf, strings, container) + } + + return r + } + + private fun readObject(buf: ByteBuffer, strings: Map, q: Queue): Any? { + when (buf.get().toInt()) { + NULL -> return null + INT -> return buf.int + FLOAT -> return buf.float + + STRING -> { + val offset = buf.int + return strings[offset]!! + } + + ARRAY -> { + val arraySize = buf.int + val array = mutableListOf() + // We will populate the array with arraySize objects from the object list later, + // when we take the ListContainer from the queue. + q.add(ListContainer(arraySize, array)) + return array + } + + BOOLEAN -> return buf.get() > 0 + + MAP -> { + val mapSize = buf.int + val map = mutableMapOf() + q.add(MapContainer(mapSize, map)) + return map + } + + LONG -> return buf.long + DOUBLE -> return buf.double + CHAR -> return Char(buf.int) + else -> return null + } + } +} \ No newline at end of file diff --git a/plugins/sync/src/main/kotlin/app/aaps/plugins/sync/garmin/GarminSimulatorClient.kt b/plugins/sync/src/main/kotlin/app/aaps/plugins/sync/garmin/GarminSimulatorClient.kt new file mode 100644 index 0000000000..f473773a2a --- /dev/null +++ b/plugins/sync/src/main/kotlin/app/aaps/plugins/sync/garmin/GarminSimulatorClient.kt @@ -0,0 +1,186 @@ +package app.aaps.plugins.sync.garmin + +import app.aaps.core.interfaces.logging.AAPSLogger +import app.aaps.core.interfaces.logging.LTag +import com.garmin.android.connectiq.IQApp +import io.reactivex.rxjava3.disposables.Disposable +import org.jetbrains.annotations.VisibleForTesting +import java.io.InputStream +import java.net.Inet4Address +import java.net.InetSocketAddress +import java.net.ServerSocket +import java.net.Socket +import java.net.SocketException +import java.time.Duration +import java.util.Collections +import java.util.concurrent.ExecutorService +import java.util.concurrent.Executors +import java.util.concurrent.TimeUnit +import java.util.concurrent.atomic.AtomicLong +import java.util.concurrent.locks.ReentrantLock +import kotlin.concurrent.withLock + +/** [GarminClient] that talks to the ConnectIQ simulator via HTTP. + * + * This is needed for Garmin device app development. */ +class GarminSimulatorClient( + private val aapsLogger: AAPSLogger, + private val receiver: GarminReceiver, + var port: Int = 7381 +): Disposable, GarminClient { + + override val name = "Sim" + private val executor: ExecutorService = Executors.newCachedThreadPool() + private val serverSocket = ServerSocket() + private val connections: MutableList = Collections.synchronizedList(mutableListOf()) + private var nextDeviceId = AtomicLong(1) + @VisibleForTesting + val iqApp = IQApp().apply { + applicationID = "SimApp" + status = GarminApplication.Status.INSTALLED.ordinal + displayName = "Simulator" + version = 1 } + private val readyLock = ReentrantLock() + private val readyCond = readyLock.newCondition() + + private inner class Connection(private val socket: Socket): Disposable { + val device = GarminDevice( + this@GarminSimulatorClient, + nextDeviceId.getAndAdd(1L), + "Sim@${socket.remoteSocketAddress}", + GarminDevice.Status.CONNECTED) + + fun start() { + executor.execute { + try { + receiver.onConnectDevice(this@GarminSimulatorClient, device.id, device.name) + run() + } catch (e: Throwable) { + aapsLogger.error(LTag.GARMIN, "$device failed", e) + } + } + } + + fun send(data: ByteArray) { + if (socket.isConnected && !socket.isOutputShutdown) { + aapsLogger.info(LTag.GARMIN, "sending ${data.size} bytes to $device") + socket.outputStream.write(data) + socket.outputStream.flush() + } else { + aapsLogger.warn(LTag.GARMIN, "socket closed, cannot send $device") + } + } + + private fun run() { + socket.soTimeout = 0 + socket.isInputShutdown + while (!socket.isClosed && socket.isConnected) { + try { + val data = readAvailable(socket.inputStream) ?: break + if (data.isNotEmpty()) { + kotlin.runCatching { + receiver.onReceiveMessage(this@GarminSimulatorClient, device.id, iqApp.applicationID, data) + } + } + } catch (e: SocketException) { + aapsLogger.warn(LTag.GARMIN, "socket read failed ${e.message}") + } + } + aapsLogger.info(LTag.GARMIN, "disconnect ${device.name}" ) + connections.remove(this) + receiver.onDisconnectDevice(this@GarminSimulatorClient, device.id) + } + + private fun readAvailable(input: InputStream): ByteArray? { + val buffer = ByteArray(1 shl 14) + aapsLogger.info(LTag.GARMIN, "$device reading") + val len = input.read(buffer) + aapsLogger.info(LTag.GARMIN, "$device read $len bytes") + if (len < 0) { + return null + } + val data = ByteArray(len) + System.arraycopy(buffer, 0, data, 0, data.size) + return data + } + + override fun dispose() { + aapsLogger.info(LTag.GARMIN, "close $device") + + @Suppress("EmptyCatchBlock") + try { + socket.close() + } catch (e: SocketException) { + aapsLogger.warn(LTag.GARMIN, "closing socket failed ${e.message}") + } + } + + override fun isDisposed() = socket.isClosed + } + + init { + executor.execute { + runCatching(::listen).exceptionOrNull()?.let { e-> + aapsLogger.error(LTag.GARMIN, "listen failed", e) + } + } + } + + private fun listen() { + val ip = Inet4Address.getByAddress(byteArrayOf(127, 0, 0, 1)) + aapsLogger.info(LTag.GARMIN, "bind to $ip:$port") + serverSocket.bind(InetSocketAddress(ip, port)) + port = serverSocket.localPort + receiver.onConnect(this@GarminSimulatorClient) + while (!serverSocket.isClosed) { + val s = serverSocket.accept() + aapsLogger.info(LTag.GARMIN, "accept " + s.remoteSocketAddress) + connections.add(Connection(s)) + connections.last().start() + } + receiver.onDisconnect(this@GarminSimulatorClient) + } + + /** Wait for the server to start listing to requests. */ + fun awaitReady(wait: Duration): Boolean { + var waitNanos = wait.toNanos() + readyLock.withLock { + while (!serverSocket.isBound && waitNanos > 0L) { + waitNanos = readyCond.awaitNanos(waitNanos) + } + } + return serverSocket.isBound + } + + override fun dispose() { + connections.forEach { c -> c.dispose() } + connections.clear() + serverSocket.close() + executor.awaitTermination(10, TimeUnit.SECONDS) + } + + override fun isDisposed() = serverSocket.isClosed + + override fun retrieveApplicationInfo(device: GarminDevice, appId: String, appName: String) { + receiver.onApplicationInfo(device, appId, true) + } + + private fun getConnection(device: GarminDevice): Connection? { + return connections.firstOrNull { c -> c.device.id == device.id } + } + + override fun sendMessage(app: GarminApplication, data: ByteArray) { + val c = getConnection(app.device) ?: return + try { + c.send(data) + receiver.onSendMessage(this, app.device.id, app.id, null) + } catch (e: SocketException) { + val errorMessage = "sending failed '${e.message}'" + receiver.onSendMessage(this, app.device.id, app.id, errorMessage) + c.dispose() + connections.remove(c) + } + } + + override fun toString() = name +} \ No newline at end of file diff --git a/plugins/sync/src/main/kotlin/app/aaps/plugins/sync/garmin/HttpServer.kt b/plugins/sync/src/main/kotlin/app/aaps/plugins/sync/garmin/HttpServer.kt index c349e3cb3d..82f2e0806d 100644 --- a/plugins/sync/src/main/kotlin/app/aaps/plugins/sync/garmin/HttpServer.kt +++ b/plugins/sync/src/main/kotlin/app/aaps/plugins/sync/garmin/HttpServer.kt @@ -34,7 +34,7 @@ class HttpServer internal constructor(private var aapsLogger: AAPSLogger, val po private val serverThread: Thread private val workerExecutor: Executor = Executors.newCachedThreadPool() - private val endpoints: MutableMap CharSequence> = + private val endpoints: MutableMap Pair> = ConcurrentHashMap() private var serverSocket: ServerSocket? = null private val readyLock = ReentrantLock() @@ -76,7 +76,7 @@ class HttpServer internal constructor(private var aapsLogger: AAPSLogger, val po } /** Register an endpoint (path) to handle requests. */ - fun registerEndpoint(path: String, endpoint: (SocketAddress, URI, String?) -> CharSequence) { + fun registerEndpoint(path: String, endpoint: (SocketAddress, URI, String?) -> Pair) { aapsLogger.info(LTag.GARMIN, "Register: '$path'") endpoints[path] = endpoint } @@ -127,8 +127,8 @@ class HttpServer internal constructor(private var aapsLogger: AAPSLogger, val po respond(HttpURLConnection.HTTP_NOT_FOUND, out) } else { try { - val body = endpoint(s.remoteSocketAddress, uri, reqBody) - respond(HttpURLConnection.HTTP_OK, body, "application/json", out) + val (code, body) = endpoint(s.remoteSocketAddress, uri, reqBody) + respond(code, body, "application/json", out) } catch (e: Exception) { aapsLogger.error(LTag.GARMIN, "endpoint " + uri.path + " failed", e) respond(HttpURLConnection.HTTP_INTERNAL_ERROR, out) diff --git a/plugins/sync/src/test/kotlin/app/aaps/plugins/sync/garmin/GarminMessengerTest.kt b/plugins/sync/src/test/kotlin/app/aaps/plugins/sync/garmin/GarminMessengerTest.kt new file mode 100644 index 0000000000..52962aebcb --- /dev/null +++ b/plugins/sync/src/test/kotlin/app/aaps/plugins/sync/garmin/GarminMessengerTest.kt @@ -0,0 +1,142 @@ +package app.aaps.plugins.sync.garmin + +import android.content.Context +import app.aaps.shared.tests.TestBase +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.mockito.kotlin.any +import org.mockito.kotlin.doReturn +import org.mockito.kotlin.mock +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever +import java.util.LinkedList +import java.util.Queue + +class GarminMessengerTest: TestBase() { + private val context = mock() + private val client1 = mock() { + on { name } doReturn "Mock1" + } + private val client2 = mock() { + on { name } doReturn "Mock2" + } + private var appId1 = "appId1" + private val appId2 = "appId2" + + private val apps = mapOf(appId1 to "$appId1-name", appId2 to "$appId2-name") + private val msgs: Queue> = LinkedList() + private var messenger = GarminMessenger( + aapsLogger, context, apps, { app, msg -> msgs.add(app to msg) }, false, false) + private val deviceId = 11L + private val deviceName = "$deviceId-name" + private val device = GarminDevice(client1, deviceId, deviceName) + private val device2 = GarminDevice(client2, 12L, "dev2-name") + + @BeforeEach + fun setup() { + messenger.onConnect(client1) + messenger.onConnect(client2) + } + @AfterEach + fun cleanup() { + messenger.dispose() + assertTrue(messenger.isDisposed) + } + + @Test + fun onConnectDevice() { + messenger.onConnectDevice(client1, deviceId, deviceName) + verify(client1).retrieveApplicationInfo(device, appId1, apps[appId1]!!) + verify(client1).retrieveApplicationInfo(device, appId2, apps[appId2]!!) + } + + @Test + fun onApplicationInfo() { + messenger.onApplicationInfo(device, appId1, true) + val app = messenger.liveApplications.first() + assertEquals(device, app.device) + assertEquals(appId1, app.id) + assertEquals(apps[appId1], app.name) + + messenger.onApplicationInfo(device, appId1, false) + assertEquals(0, messenger.liveApplications.size) + } + + @Test + fun onDisconnectDevice() { + messenger.onConnectDevice(client1, deviceId, deviceName) + messenger.onApplicationInfo(device, appId1, true) + messenger.onApplicationInfo(device2, appId1, true) + assertEquals(2, messenger.liveApplications.size) + messenger.onDisconnectDevice(client1, device2.id) + assertEquals(1, messenger.liveApplications.size) + assertEquals(appId1, messenger.liveApplications.first().id) + } + + @Test + fun onDisconnect() { + messenger.onApplicationInfo(device, appId1, true) + messenger.onApplicationInfo(device2, appId2, true) + assertEquals(2, messenger.liveApplications.size) + messenger.onDisconnect(client1) + assertEquals(1, messenger.liveApplications.size) + val app = messenger.liveApplications.first() + assertEquals(device2, app.device) + assertEquals(appId2, app.id) + assertEquals(apps[appId2], app.name) + } + + @Test + fun onReceiveMessage() { + val data = GarminSerializer.serialize("foo") + messenger.onReceiveMessage(client1, device.id, appId1, data) + val (app, payload) = msgs.remove() + assertEquals(appId1, app.id) + assertEquals("foo", payload) + } + + @Test + fun sendMessageDevice() { + messenger.onApplicationInfo(device, appId1, true) + messenger.onApplicationInfo(device, appId2, true) + + val msgs = mutableListOf>() + whenever(client1.sendMessage(any(), any())).thenAnswer { i -> + msgs.add(i.getArgument(0) to i.getArgument(1)) + } + + messenger.sendMessage(device, "foo") + assertEquals(2, msgs.size) + val msg1 = msgs.first { (app, _) -> app.id == appId1 }.second + val msg2 = msgs.first { (app, _) -> app.id == appId2 }.second + assertEquals("foo", GarminSerializer.deserialize(msg1)) + assertEquals("foo", GarminSerializer.deserialize(msg2)) + messenger.onSendMessage(client1, device.id, appId1, null) + } + + @Test + fun onSendMessageAll() { + messenger.onApplicationInfo(device, appId1, true) + messenger.onApplicationInfo(device2, appId2, true) + assertEquals(2, messenger.liveApplications.size) + + val msgs = mutableListOf>() + whenever(client1.sendMessage(any(), any())).thenAnswer { i -> + msgs.add(i.getArgument(0) to i.getArgument(1)) + } + whenever(client2.sendMessage(any(), any())).thenAnswer { i -> + msgs.add(i.getArgument(0) to i.getArgument(1)) + } + + messenger.sendMessage(listOf("foo")) + assertEquals(2, msgs.size) + val msg1 = msgs.first { (app, _) -> app.id == appId1 }.second + val msg2 = msgs.first { (app, _) -> app.id == appId2 }.second + assertEquals(listOf("foo"), GarminSerializer.deserialize(msg1)) + assertEquals(listOf("foo"), GarminSerializer.deserialize(msg2)) + messenger.onSendMessage(client1, device.id, appId1, null) + } +} \ No newline at end of file diff --git a/plugins/sync/src/test/kotlin/app/aaps/plugins/sync/garmin/GarminPluginTest.kt b/plugins/sync/src/test/kotlin/app/aaps/plugins/sync/garmin/GarminPluginTest.kt index 18c772877e..d60ab839d2 100644 --- a/plugins/sync/src/test/kotlin/app/aaps/plugins/sync/garmin/GarminPluginTest.kt +++ b/plugins/sync/src/test/kotlin/app/aaps/plugins/sync/garmin/GarminPluginTest.kt @@ -1,5 +1,6 @@ package app.aaps.plugins.sync.garmin +import android.content.Context import app.aaps.core.interfaces.db.GlucoseUnit import app.aaps.core.interfaces.resources.ResourceHelper import app.aaps.core.interfaces.rx.events.EventNewBG @@ -11,9 +12,15 @@ import dagger.android.HasAndroidInjector import org.junit.jupiter.api.AfterEach import org.junit.jupiter.api.Assertions.assertArrayEquals import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertThrows import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.Test +import org.mockito.ArgumentCaptor +import org.mockito.ArgumentMatchers.anyBoolean +import org.mockito.ArgumentMatchers.anyInt import org.mockito.ArgumentMatchers.anyLong +import org.mockito.ArgumentMatchers.anyString +import org.mockito.ArgumentMatchers.eq import org.mockito.Mock import org.mockito.Mockito.atMost import org.mockito.Mockito.mock @@ -21,6 +28,8 @@ import org.mockito.Mockito.times import org.mockito.Mockito.verify import org.mockito.Mockito.verifyNoMoreInteractions import org.mockito.Mockito.`when` +import java.net.ConnectException +import java.net.HttpURLConnection import java.net.SocketAddress import java.net.URI import java.time.Clock @@ -34,6 +43,7 @@ class GarminPluginTest: TestBase() { @Mock private lateinit var rh: ResourceHelper @Mock private lateinit var sp: SP + @Mock private lateinit var context: Context @Mock private lateinit var loopHub: LoopHub private val clock = Clock.fixed(Instant.ofEpochMilli(10_000), ZoneId.of("UTC")) @@ -44,9 +54,14 @@ class GarminPluginTest: TestBase() { @BeforeEach fun setup() { - gp = GarminPlugin(injector, aapsLogger, rh, loopHub, rxBus, sp) + gp = GarminPlugin(injector, aapsLogger, rh, context, loopHub, rxBus, sp) gp.clock = clock `when`(loopHub.currentProfileName).thenReturn("Default") + `when`(sp.getBoolean(anyString(), anyBoolean())).thenAnswer { i -> i.arguments[1] } + `when`(sp.getString(anyString(), anyString())).thenAnswer { i -> i.arguments[1] } + `when`(sp.getInt(anyString(), anyInt())).thenAnswer { i -> i.arguments[1] } + `when`(sp.getInt(eq("communication_http_port") ?: "", anyInt())) + .thenReturn(28890) } @AfterEach @@ -76,6 +91,17 @@ class GarminPluginTest: TestBase() { sourceSensor = GlucoseValue.SourceSensor.RANDOM ) + @Test + fun testReceiveHeartRateMap() { + val hr = createHeartRate(80) + gp.receiveHeartRate(hr, false) + verify(loopHub).storeHeartRate( + Instant.ofEpochSecond(hr["hrStart"] as Long), + Instant.ofEpochSecond(hr["hrEnd"] as Long), + 80, + hr["device"] as String) + } + @Test fun testReceiveHeartRateUri() { val hr = createHeartRate(99) @@ -119,6 +145,132 @@ class GarminPluginTest: TestBase() { verify(loopHub).getGlucoseValues(from, true) } + @Test + fun setupHttpServer_enabled() { + `when`(sp.getBoolean("communication_http", false)).thenReturn(true) + `when`(sp.getInt("communication_http_port", 28891)).thenReturn(28892) + gp.setupHttpServer() + val reqUri = URI("http://127.0.0.1:28892/get") + val resp = reqUri.toURL().openConnection() as HttpURLConnection + assertEquals(200, resp.responseCode) + + // Change port + `when`(sp.getInt("communication_http_port", 28891)).thenReturn(28893) + gp.setupHttpServer() + val reqUri2 = URI("http://127.0.0.1:28893/get") + val resp2 = reqUri2.toURL().openConnection() as HttpURLConnection + assertEquals(200, resp2.responseCode) + + `when`(sp.getBoolean("communication_http", false)).thenReturn(false) + gp.setupHttpServer() + assertThrows(ConnectException::class.java) { + (reqUri2.toURL().openConnection() as HttpURLConnection).responseCode + } + gp.onStop() + + verify(loopHub, times(2)).getGlucoseValues(anyObject(), eq(true)) + verify(loopHub, times(2)).insulinOnboard + verify(loopHub, times(2)).temporaryBasal + verify(loopHub, times(2)).isConnected + verify(loopHub, times(2)).glucoseUnit + } + + @Test + fun setupHttpServer_disabled() { + gp.setupHttpServer() + val reqUri = URI("http://127.0.0.1:28890/get") + assertThrows(ConnectException::class.java) { + (reqUri.toURL().openConnection() as HttpURLConnection).responseCode + } + } + + @Test + fun requestHandler_NoKey() { + val uri = createUri(emptyMap()) + val handler = gp.requestHandler { u: URI -> assertEquals(uri, u); "OK" } + assertEquals( + HttpURLConnection.HTTP_OK to "OK", + handler(mock(SocketAddress::class.java), uri, null)) + } + + @Test + fun requestHandler_KeyProvided() { + val uri = createUri(mapOf("key" to "foo")) + val handler = gp.requestHandler { u: URI -> assertEquals(uri, u); "OK" } + assertEquals( + HttpURLConnection.HTTP_OK to "OK", + handler(mock(SocketAddress::class.java), uri, null)) + } + + @Test + fun requestHandler_KeyRequiredAndProvided() { + `when`(sp.getString("garmin_aaps_key", "")).thenReturn("foo") + val uri = createUri(mapOf("key" to "foo")) + val handler = gp.requestHandler { u: URI -> assertEquals(uri, u); "OK" } + assertEquals( + HttpURLConnection.HTTP_OK to "OK", + handler(mock(SocketAddress::class.java), uri, null)) + + } + + @Test + fun requestHandler_KeyRequired() { + gp.garminMessenger = mock(GarminMessenger::class.java) + + `when`(sp.getString("garmin_aaps_key", "")).thenReturn("foo") + val uri = createUri(emptyMap()) + val handler = gp.requestHandler { u: URI -> assertEquals(uri, u); "OK" } + assertEquals( + HttpURLConnection.HTTP_UNAUTHORIZED to "{}", + handler(mock(SocketAddress::class.java), uri, null)) + + val captor = ArgumentCaptor.forClass(Any::class.java) + verify(gp.garminMessenger)!!.sendMessage(captor.capture() ?: "") + @Suppress("UNCHECKED_CAST") + val r = captor.value as Map + assertEquals("foo", r["key"]) + assertEquals("glucose", r["command"]) + assertEquals("D", r["profile"]) + assertEquals("", r["encodedGlucose"]) + assertEquals(0.0, r["remainingInsulin"]) + assertEquals("mmoll", r["glucoseUnit"]) + assertEquals(0.0, r["temporaryBasalRate"]) + assertEquals(false, r["connected"]) + assertEquals(clock.instant().epochSecond, r["timestamp"]) + verify(loopHub).getGlucoseValues(getGlucoseValuesFrom, true) + verify(loopHub).insulinOnboard + verify(loopHub).temporaryBasal + verify(loopHub).isConnected + verify(loopHub).glucoseUnit + } + + @Test + fun onConnectDevice() { + gp.garminMessenger = mock(GarminMessenger::class.java) + `when`(sp.getString("garmin_aaps_key", "")).thenReturn("foo") + val device = GarminDevice(mock(),1, "Edge") + gp.onConnectDevice(device) + + val captor = ArgumentCaptor.forClass(Any::class.java) + verify(gp.garminMessenger)!!.sendMessage(eq(device) ?: device, captor.capture() ?: "") + @Suppress("UNCHECKED_CAST") + val r = captor.value as Map + assertEquals("foo", r["key"]) + assertEquals("glucose", r["command"]) + assertEquals("D", r["profile"]) + assertEquals("", r["encodedGlucose"]) + assertEquals(0.0, r["remainingInsulin"]) + assertEquals("mmoll", r["glucoseUnit"]) + assertEquals(0.0, r["temporaryBasalRate"]) + assertEquals(false, r["connected"]) + assertEquals(clock.instant().epochSecond, r["timestamp"]) + verify(loopHub).getGlucoseValues(getGlucoseValuesFrom, true) + verify(loopHub).insulinOnboard + verify(loopHub).temporaryBasal + verify(loopHub).isConnected + verify(loopHub).glucoseUnit + } + @Test fun testOnGetBloodGlucose() { `when`(loopHub.isConnected).thenReturn(true) @@ -129,7 +281,7 @@ class GarminPluginTest: TestBase() { listOf(createGlucoseValue(Instant.ofEpochSecond(1_000)))) val hr = createHeartRate(99) val uri = createUri(hr) - val result = gp.onGetBloodGlucose(mock(SocketAddress::class.java), uri, null) + val result = gp.onGetBloodGlucose(uri) assertEquals( "{\"encodedGlucose\":\"0A+6AQ==\"," + "\"remainingInsulin\":3.14," + @@ -161,7 +313,7 @@ class GarminPluginTest: TestBase() { params["wait"] = 10 val uri = createUri(params) gp.newValue = mock(Condition::class.java) - val result = gp.onGetBloodGlucose(mock(SocketAddress::class.java), uri, null) + val result = gp.onGetBloodGlucose(uri) assertEquals( "{\"encodedGlucose\":\"/wS6AQ==\"," + "\"remainingInsulin\":3.14," + @@ -184,7 +336,7 @@ class GarminPluginTest: TestBase() { @Test fun testOnPostCarbs() { val uri = createUri(mapOf("carbs" to "12")) - assertEquals("", gp.onPostCarbs(mock(SocketAddress::class.java), uri, null)) + assertEquals("", gp.onPostCarbs(uri)) verify(loopHub).postCarbs(12) } @@ -192,7 +344,7 @@ class GarminPluginTest: TestBase() { fun testOnConnectPump_Disconnect() { val uri = createUri(mapOf("disconnectMinutes" to "20")) `when`(loopHub.isConnected).thenReturn(false) - assertEquals("{\"connected\":false}", gp.onConnectPump(mock(SocketAddress::class.java), uri, null)) + assertEquals("{\"connected\":false}", gp.onConnectPump(uri)) verify(loopHub).disconnectPump(20) verify(loopHub).isConnected } @@ -201,7 +353,7 @@ class GarminPluginTest: TestBase() { fun testOnConnectPump_Connect() { val uri = createUri(mapOf("disconnectMinutes" to "0")) `when`(loopHub.isConnected).thenReturn(true) - assertEquals("{\"connected\":true}", gp.onConnectPump(mock(SocketAddress::class.java), uri, null)) + assertEquals("{\"connected\":true}", gp.onConnectPump(uri)) verify(loopHub).connectPump() verify(loopHub).isConnected } diff --git a/plugins/sync/src/test/kotlin/app/aaps/plugins/sync/garmin/GarminSerializerTest.kt b/plugins/sync/src/test/kotlin/app/aaps/plugins/sync/garmin/GarminSerializerTest.kt new file mode 100644 index 0000000000..fed7e32a28 --- /dev/null +++ b/plugins/sync/src/test/kotlin/app/aaps/plugins/sync/garmin/GarminSerializerTest.kt @@ -0,0 +1,92 @@ +package app.aaps.plugins.sync.garmin + + +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Test +import kotlin.test.assertContentEquals + +class GarminSerializerTest { + + @Test fun testSerializeDeserializeString() { + val o = "Hello, world!" + val data = GarminSerializer.serialize(o) + assertContentEquals( + byteArrayOf( + -85, -51, -85, -51, 0, 0, 0, 16, 0, 14, 72, 101, 108, 108, 111, 44, 32, 119, 111, + 114, 108, 100, 33, 0, -38, 122, -38, 122, 0, 0, 0, 5, 3, 0,0, 0, 0), + data) + assertEquals(o, GarminSerializer.deserialize(data)) + } + + @Test fun testSerializeDeserializeInteger() { + val o = 3 + val data = GarminSerializer.serialize(o) + assertContentEquals( + byteArrayOf(-38, 122, -38, 122, 0, 0, 0, 5, 1, 0, 0, 0, 3), + data) + assertEquals(o, GarminSerializer.deserialize(data)) + } + + @Test fun tesSerializeDeserializeArray() { + val o = listOf("a", "b", true, 3, 3.4F, listOf(5L, 9), 42) + val data = GarminSerializer.serialize(o) + assertContentEquals( + byteArrayOf( + -85, -51, -85, -51, 0, 0, 0, 8, 0, 2, 97, 0, 0, 2, 98, 0, -38, 122, -38, 122, 0, 0, + 0, 55, 5, 0, 0, 0, 7, 3, 0, 0, 0, 0, 3, 0, 0, 0, 4, 9, 1, 1, 0, 0, 0, 3, 2, 64, 89, + -103, -102, 5, 0, 0, 0, 2, 1, 0, 0, 0, 42, 14, 0, 0, 0, 0, 0, 0, 0, 5, 14, 0, 0, 0, + 0, 0, 0, 0, 9), + data) + assertEquals(o, GarminSerializer.deserialize(data)) + } + + @Test + fun testSerializeDeserializeMap() { + val o = mapOf("a" to "abc", "c" to 3, "d" to listOf(4, 9, "abc"), true to null) + val data = GarminSerializer.serialize(o) + assertContentEquals( + byteArrayOf( + -85, -51, -85, -51, 0, 0, 0, 18, 0, 2, 97, 0, 0, 4, 97, 98, 99, 0, 0, 2, 99, 0, 0, + 2, 100, 0, -38, 122, -38, 122, 0, 0, 0, 53, 11, 0, 0, 0, 4, 3, 0, 0, 0, 0, 3, 0, 0, + 0, 4, 3, 0, 0, 0, 10, 1, 0, 0, 0, 3, 3, 0, 0, 0, 14, 5, 0, 0, 0, 3, 9, 1, 0, 1, 0, 0, + 0, 4, 1, 0, 0, 0, 9, 3, 0, 0, 0, 4), + data) + assertEquals(o, GarminSerializer.deserialize(data)) + } + + @Test fun testSerializeDeserializeNull() { + val o = null + val data = GarminSerializer.serialize(o) + assertContentEquals( + byteArrayOf(-38, 122, -38, 122, 0, 0, 0, 1, 0), + data) + assertEquals(o, GarminSerializer.deserialize(data)) + assertEquals(o, GarminSerializer.deserialize(data)) + } + + @Test fun testSerializeDeserializeAllPrimitiveTypes() { + val o = listOf(1, 1.2F, 1.3, "A", true, 2L, 'X', null) + val data = GarminSerializer.serialize(o) + assertContentEquals( + byteArrayOf( + -85, -51, -85, -51, 0, 0, 0, 4, 0, 2, 65, 0, -38, 122, -38, 122, 0, 0, 0, 46, 5, 0, + 0, 0, 8, 1, 0, 0, 0, 1, 2, 63, -103, -103, -102, 15, 63, -12, -52, -52, -52, -52, + -52, -51, 3, 0, 0, 0, 0, 9, 1, 14, 0, 0, 0, 0, 0, 0, 0, 2, 19, 0, 0, 0, 88, 0), + data) + assertEquals(o, GarminSerializer.deserialize(data)) + assertEquals(o, GarminSerializer.deserialize(data)) + } + + @Test fun testSerializeDeserializeMapNested() { + val o = mapOf("a" to "abc", "c" to 3, "d" to listOf(4, 9, "abc")) + val data = GarminSerializer.serialize(o) + assertContentEquals( + byteArrayOf( + -85, -51, -85, -51, 0, 0, 0, 18, 0, 2, 97, 0, 0, 4, 97, 98, 99, 0, 0, 2, 99, 0, 0, + 2, 100, 0, -38, 122, -38, 122, 0, 0, 0, 50, 11, 0, 0, 0, 3, 3, 0, 0, 0, 0, 3, 0, 0, + 0, 4, 3, 0, 0, 0, 10, 1, 0, 0, 0, 3, 3, 0, 0, 0, 14, 5, 0, 0, 0, 3, 1, 0, 0, 0, 4, + 1, 0, 0, 0, 9, 3, 0, 0, 0, 4), + data) + assertEquals(o, GarminSerializer.deserialize(data)) + } +} \ No newline at end of file diff --git a/plugins/sync/src/test/kotlin/app/aaps/plugins/sync/garmin/GarminSimulatorClientTest.kt b/plugins/sync/src/test/kotlin/app/aaps/plugins/sync/garmin/GarminSimulatorClientTest.kt new file mode 100644 index 0000000000..a4b422616b --- /dev/null +++ b/plugins/sync/src/test/kotlin/app/aaps/plugins/sync/garmin/GarminSimulatorClientTest.kt @@ -0,0 +1,86 @@ +package app.aaps.plugins.sync.garmin + +import app.aaps.shared.tests.TestBase +import org.junit.jupiter.api.Assertions.assertNotNull +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.mockito.kotlin.any +import org.mockito.kotlin.argThat +import org.mockito.kotlin.doAnswer +import org.mockito.kotlin.eq +import org.mockito.kotlin.isNull +import org.mockito.kotlin.mock +import org.mockito.kotlin.timeout +import org.mockito.kotlin.verify +import java.net.Inet4Address +import java.net.Socket +import java.time.Duration + +class GarminSimulatorClientTest: TestBase() { + + private var device: GarminDevice? = null + private var app: GarminApplication? = null + private lateinit var client: GarminSimulatorClient + private val receiver: GarminReceiver = mock() { + on { onConnectDevice(any(), any(), any()) }.doAnswer { i -> + device = GarminDevice(client, i.getArgument(1), i.getArgument(2)) + app = GarminApplication( + client, device!!, client.iqApp.applicationID, client.iqApp.displayName) + } + } + + @BeforeEach + fun setup() { + client = GarminSimulatorClient(aapsLogger, receiver, 0) + } + + @Test + fun retrieveApplicationInfo() { + assertTrue(client.awaitReady(Duration.ofSeconds(10))) + val port = client.port + val ip = Inet4Address.getByAddress(byteArrayOf(127, 0, 0, 1)) + Socket(ip, port).use { socket -> + assertTrue(socket.isConnected) + verify(receiver).onConnect(client) + verify(receiver, timeout(1_000)).onConnectDevice(eq(client), any(), any()) + client.retrieveApplicationInfo(app!!.device, app!!.id, app!!.name!!) + } + verify(receiver).onApplicationInfo(app!!.device, app!!.id, true) + } + + @Test + fun receiveMessage() { + val payload = "foo".toByteArray() + assertTrue(client.awaitReady(Duration.ofSeconds(10))) + val port = client.port + val ip = Inet4Address.getByAddress(byteArrayOf(127, 0, 0, 1)) + Socket(ip, port).use { socket -> + assertTrue(socket.isConnected) + socket.getOutputStream().write(payload) + socket.getOutputStream().flush() + verify(receiver).onConnect(client) + verify(receiver, timeout(1_000)).onConnectDevice(eq(client), any(), any()) + } + verify(receiver).onReceiveMessage( + eq(client), eq(device!!.id), eq("SimApp"), + argThat { p -> payload.contentEquals(p) }) + } + + @Test + fun sendMessage() { + val payload = "foo".toByteArray() + assertTrue(client.awaitReady(Duration.ofSeconds(10))) + val port = client.port + val ip = Inet4Address.getByAddress(byteArrayOf(127, 0, 0, 1)) + Socket(ip, port).use { socket -> + assertTrue(socket.isConnected) + verify(receiver).onConnect(client) + verify(receiver, timeout(1_000)).onConnectDevice(eq(client), any(), any()) + assertNotNull(device) + assertNotNull(app) + client.sendMessage(app!!, payload) + } + verify(receiver).onSendMessage(eq(client), any(), eq(app!!.id), isNull()) + } +} \ No newline at end of file diff --git a/plugins/sync/src/test/kotlin/app/aaps/plugins/sync/garmin/HttpServerTest.kt b/plugins/sync/src/test/kotlin/app/aaps/plugins/sync/garmin/HttpServerTest.kt index d89dfa9156..cfaa34b1d6 100644 --- a/plugins/sync/src/test/kotlin/app/aaps/plugins/sync/garmin/HttpServerTest.kt +++ b/plugins/sync/src/test/kotlin/app/aaps/plugins/sync/garmin/HttpServerTest.kt @@ -77,7 +77,7 @@ internal class HttpServerTest: TestBase() { HttpServer(aapsLogger, port).use { server -> server.registerEndpoint("/foo") { _: SocketAddress, uri: URI, _: String? -> assertEquals(URI("/foo"), uri) - "test" + HttpURLConnection.HTTP_OK to "test" } assertTrue(server.awaitReady(Duration.ofSeconds(10))) val resp = reqUri.toURL().openConnection() as HttpURLConnection From f3d1acffcd312431e1a915d55d7ddb460a186d1f Mon Sep 17 00:00:00 2001 From: Robert Buessow Date: Tue, 14 Nov 2023 16:42:38 +0100 Subject: [PATCH 2/4] Disable the garmin client for now. --- .../aaps/plugins/sync/garmin/GarminDeviceClient.kt | 9 +++++++-- .../app/aaps/plugins/sync/garmin/GarminMessenger.kt | 2 +- .../app/aaps/plugins/sync/garmin/GarminPlugin.kt | 11 +++++++---- .../aaps/plugins/sync/garmin/GarminSimulatorClient.kt | 5 +++-- .../plugins/sync/garmin/GarminSimulatorClientTest.kt | 2 +- 5 files changed, 19 insertions(+), 10 deletions(-) diff --git a/plugins/sync/src/main/kotlin/app/aaps/plugins/sync/garmin/GarminDeviceClient.kt b/plugins/sync/src/main/kotlin/app/aaps/plugins/sync/garmin/GarminDeviceClient.kt index 358b7b9f16..8ef347d8a6 100644 --- a/plugins/sync/src/main/kotlin/app/aaps/plugins/sync/garmin/GarminDeviceClient.kt +++ b/plugins/sync/src/main/kotlin/app/aaps/plugins/sync/garmin/GarminDeviceClient.kt @@ -89,8 +89,13 @@ class GarminDeviceClient( bindLock.notifyAll() } if (notifyReceiver) receiver.onConnect(this@GarminDeviceClient) - ciq.connectedDevices?.forEach { d -> - receiver.onConnectDevice(this@GarminDeviceClient, d.deviceIdentifier, d.friendlyName) } + try { + ciq.connectedDevices?.forEach { d -> + receiver.onConnectDevice(this@GarminDeviceClient, d.deviceIdentifier, d.friendlyName) + } + } catch (e: Exception) { + aapsLogger.error(LTag.GARMIN, "getting devices failed", e) + } } override fun onServiceDisconnected(name: ComponentName?) { synchronized(bindLock) { diff --git a/plugins/sync/src/main/kotlin/app/aaps/plugins/sync/garmin/GarminMessenger.kt b/plugins/sync/src/main/kotlin/app/aaps/plugins/sync/garmin/GarminMessenger.kt index 87c60ece7e..8f8671a6ec 100644 --- a/plugins/sync/src/main/kotlin/app/aaps/plugins/sync/garmin/GarminMessenger.kt +++ b/plugins/sync/src/main/kotlin/app/aaps/plugins/sync/garmin/GarminMessenger.kt @@ -26,7 +26,7 @@ class GarminMessenger( appIdNames.putAll(applicationIdNames) if (enableConnectIq) startDeviceClient() if (enableSimulator) { - appIdNames["SimAp"] = "SimulatorApp" + appIdNames["SimApp"] = "SimulatorApp" GarminSimulatorClient(aapsLogger, this) } } diff --git a/plugins/sync/src/main/kotlin/app/aaps/plugins/sync/garmin/GarminPlugin.kt b/plugins/sync/src/main/kotlin/app/aaps/plugins/sync/garmin/GarminPlugin.kt index 50f9410e7e..ce67a6e8bb 100644 --- a/plugins/sync/src/main/kotlin/app/aaps/plugins/sync/garmin/GarminPlugin.kt +++ b/plugins/sync/src/main/kotlin/app/aaps/plugins/sync/garmin/GarminPlugin.kt @@ -86,12 +86,13 @@ class GarminPlugin @Inject constructor( private val garminAapsKey get() = sp.getString("garmin_aaps_key", "") private fun onPreferenceChange(event: EventPreferenceChange) { - aapsLogger.info(LTag.GARMIN, "preferences change ${event.changedKey}") when (event.changedKey) { "communication_debug_mode" -> setupGarminMessenger() "communication_http", "communication_http_port" -> setupHttpServer() "garmin_aaps_key" -> sendPhoneAppMessage() + else -> return } + aapsLogger.info(LTag.GARMIN, "preferences change ${event.changedKey}") } private fun setupGarminMessenger() { @@ -120,7 +121,7 @@ class GarminPlugin @Inject constructor( .subscribe(::onNewBloodGlucose) ) setupHttpServer() - setupGarminMessenger() + // setupGarminMessenger() } fun setupHttpServer() { @@ -167,8 +168,10 @@ class GarminPlugin @Inject constructor( @VisibleForTesting fun onConnectDevice(device: GarminDevice) { - aapsLogger.info(LTag.GARMIN, "onConnectDevice $device sending glucose") - if (garminAapsKey.isNotEmpty()) sendPhoneAppMessage(device) + if (garminAapsKey.isNotEmpty()) { + aapsLogger.info(LTag.GARMIN, "onConnectDevice $device sending glucose") + sendPhoneAppMessage(device) + } } private fun sendPhoneAppMessage(device: GarminDevice) { diff --git a/plugins/sync/src/main/kotlin/app/aaps/plugins/sync/garmin/GarminSimulatorClient.kt b/plugins/sync/src/main/kotlin/app/aaps/plugins/sync/garmin/GarminSimulatorClient.kt index f473773a2a..56b1ce1449 100644 --- a/plugins/sync/src/main/kotlin/app/aaps/plugins/sync/garmin/GarminSimulatorClient.kt +++ b/plugins/sync/src/main/kotlin/app/aaps/plugins/sync/garmin/GarminSimulatorClient.kt @@ -83,7 +83,8 @@ class GarminSimulatorClient( } } } catch (e: SocketException) { - aapsLogger.warn(LTag.GARMIN, "socket read failed ${e.message}") + aapsLogger.warn(LTag.GARMIN, "socket read failed ${e.message}") + break } } aapsLogger.info(LTag.GARMIN, "disconnect ${device.name}" ) @@ -162,7 +163,7 @@ class GarminSimulatorClient( override fun isDisposed() = serverSocket.isClosed override fun retrieveApplicationInfo(device: GarminDevice, appId: String, appName: String) { - receiver.onApplicationInfo(device, appId, true) + receiver.onApplicationInfo(device, appId, appId == iqApp.applicationID) } private fun getConnection(device: GarminDevice): Connection? { diff --git a/plugins/sync/src/test/kotlin/app/aaps/plugins/sync/garmin/GarminSimulatorClientTest.kt b/plugins/sync/src/test/kotlin/app/aaps/plugins/sync/garmin/GarminSimulatorClientTest.kt index a4b422616b..b713af0f05 100644 --- a/plugins/sync/src/test/kotlin/app/aaps/plugins/sync/garmin/GarminSimulatorClientTest.kt +++ b/plugins/sync/src/test/kotlin/app/aaps/plugins/sync/garmin/GarminSimulatorClientTest.kt @@ -62,7 +62,7 @@ class GarminSimulatorClientTest: TestBase() { verify(receiver).onConnect(client) verify(receiver, timeout(1_000)).onConnectDevice(eq(client), any(), any()) } - verify(receiver).onReceiveMessage( + verify(receiver, timeout(1_000)).onReceiveMessage( eq(client), eq(device!!.id), eq("SimApp"), argThat { p -> payload.contentEquals(p) }) } From a36081d3dfa53b0b4011be45aef8e2cb2a13a1c0 Mon Sep 17 00:00:00 2001 From: Robert Buessow Date: Sun, 19 Nov 2023 23:10:20 +0100 Subject: [PATCH 3/4] Fix Garmin communication. Use Garmin lib rather than aidl, get rid of not needed functionality. --- buildSrc/src/main/kotlin/Libs.kt | 5 +- .../test-module-dependencies.gradle.kts | 3 + plugins/sync/build.gradle.kts | 8 +- .../sync/garmin/GarminDeviceClientTest.kt | 301 ++++++++++++++++++ .../connectiq/IConnectIQService.aidl | 30 -- .../com/garmin/android/connectiq/IQApp.aidl | 12 - .../garmin/android/connectiq/IQDevice.aidl | 11 - .../garmin/android/connectiq/IQMessage.aidl | 20 -- .../plugins/sync/garmin/GarminApplication.kt | 13 +- .../aaps/plugins/sync/garmin/GarminClient.kt | 6 +- .../aaps/plugins/sync/garmin/GarminDevice.kt | 24 +- .../plugins/sync/garmin/GarminDeviceClient.kt | 125 ++++---- .../plugins/sync/garmin/GarminMessenger.kt | 66 +--- .../aaps/plugins/sync/garmin/GarminPlugin.kt | 19 +- .../plugins/sync/garmin/GarminReceiver.kt | 13 - .../sync/garmin/GarminSimulatorClient.kt | 22 +- .../sync/garmin/GarminMessengerTest.kt | 138 +++----- .../sync/garmin/GarminSimulatorClientTest.kt | 49 +-- 18 files changed, 484 insertions(+), 381 deletions(-) create mode 100644 plugins/sync/src/androidTest/kotlin/app/aaps/plugins/sync/garmin/GarminDeviceClientTest.kt delete mode 100644 plugins/sync/src/main/aidl/com/garmin/android/apps/connectmobile/connectiq/IConnectIQService.aidl delete mode 100644 plugins/sync/src/main/aidl/com/garmin/android/connectiq/IQApp.aidl delete mode 100644 plugins/sync/src/main/aidl/com/garmin/android/connectiq/IQDevice.aidl delete mode 100644 plugins/sync/src/main/aidl/com/garmin/android/connectiq/IQMessage.aidl diff --git a/buildSrc/src/main/kotlin/Libs.kt b/buildSrc/src/main/kotlin/Libs.kt index 719175d1a0..db6e48f53a 100644 --- a/buildSrc/src/main/kotlin/Libs.kt +++ b/buildSrc/src/main/kotlin/Libs.kt @@ -152,8 +152,11 @@ object Libs { } object Mockito { + private const val mockitoVersion = "5.6.0" - const val jupiter = "org.mockito:mockito-junit-jupiter:5.6.0" + const val android = "org.mockito:mockito-android:$mockitoVersion" + const val core = "org.mockito:mockito-core:$mockitoVersion" + const val jupiter = "org.mockito:mockito-junit-jupiter:$mockitoVersion" const val kotlin = "org.mockito.kotlin:mockito-kotlin:5.1.0" } diff --git a/buildSrc/src/main/kotlin/test-module-dependencies.gradle.kts b/buildSrc/src/main/kotlin/test-module-dependencies.gradle.kts index 22fc2175b7..bd520b0863 100644 --- a/buildSrc/src/main/kotlin/test-module-dependencies.gradle.kts +++ b/buildSrc/src/main/kotlin/test-module-dependencies.gradle.kts @@ -22,6 +22,9 @@ dependencies { androidTestImplementation(Libs.AndroidX.Test.rules) androidTestImplementation(Libs.Google.truth) androidTestImplementation(Libs.AndroidX.Test.uiAutomator) + androidTestImplementation(Libs.Mockito.core) + androidTestImplementation(Libs.Mockito.android) + androidTestImplementation(Libs.Mockito.kotlin) } tasks.withType { diff --git a/plugins/sync/build.gradle.kts b/plugins/sync/build.gradle.kts index 1bea79a6c3..81f4671523 100644 --- a/plugins/sync/build.gradle.kts +++ b/plugins/sync/build.gradle.kts @@ -9,9 +9,6 @@ plugins { android { namespace = "app.aaps.plugins.sync" - buildFeatures { - aidl = true - } } dependencies { @@ -33,6 +30,7 @@ dependencies { testImplementation(project(":shared:tests")) testImplementation(project(":implementation")) testImplementation(project(":plugins:aps")) + androidTestImplementation(project(":shared:tests")) // OpenHuman api(Libs.Squareup.Okhttp3.okhttp) @@ -52,6 +50,10 @@ dependencies { // DataLayerListenerService api(Libs.Google.Android.PlayServices.wearable) + // Garmin + api("com.garmin.connectiq:ciq-companion-app-sdk:2.0.2@aar") + androidTestImplementation("com.garmin.connectiq:ciq-companion-app-sdk:2.0.2@aar") + kapt(Libs.Dagger.compiler) kapt(Libs.Dagger.androidProcessor) } \ No newline at end of file diff --git a/plugins/sync/src/androidTest/kotlin/app/aaps/plugins/sync/garmin/GarminDeviceClientTest.kt b/plugins/sync/src/androidTest/kotlin/app/aaps/plugins/sync/garmin/GarminDeviceClientTest.kt new file mode 100644 index 0000000000..4f63ea3671 --- /dev/null +++ b/plugins/sync/src/androidTest/kotlin/app/aaps/plugins/sync/garmin/GarminDeviceClientTest.kt @@ -0,0 +1,301 @@ +package app.aaps.plugins.sync.garmin + +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import android.content.IntentFilter +import android.content.ServiceConnection +import android.os.Binder +import android.os.IBinder +import androidx.test.ext.junit.runners.AndroidJUnit4 +import app.aaps.shared.tests.TestBase +import com.garmin.android.apps.connectmobile.connectiq.IConnectIQService +import com.garmin.android.connectiq.ConnectIQ +import com.garmin.android.connectiq.IQApp +import com.garmin.android.connectiq.IQDevice +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.ArgumentMatchers.any +import org.mockito.Mockito.timeout +import org.mockito.Mockito.times +import org.mockito.Mockito.verify +import org.mockito.kotlin.argThat +import org.mockito.kotlin.atLeastOnce +import org.mockito.kotlin.doAnswer +import org.mockito.kotlin.doReturn +import org.mockito.kotlin.eq +import org.mockito.kotlin.mock +import org.mockito.kotlin.verifyNoMoreInteractions +import org.mockito.kotlin.whenever +import java.util.concurrent.Executor + +@RunWith(AndroidJUnit4::class) +class GarminDeviceClientTest: TestBase() { + private val serviceDescriptor = "com.garmin.android.apps.connectmobile.connectiq.IConnectIQService" + private lateinit var client: GarminDeviceClient + private lateinit var serviceConnection: ServiceConnection + private lateinit var device: GarminDevice + private val packageName = "TestPackage" + private val actions = mutableMapOf() + // Maps app ids to intent actions. + private val receivers = mutableMapOf() + + private val receiver = mock() + private val binder = mock() { + on { isBinderAlive } doReturn true + } + private val ciqService = mock() { + on { asBinder() } doReturn binder + on { connectedDevices } doReturn listOf(IQDevice(1L, "TDevice")) + on { registerApp(any(), any(), any()) }.doAnswer { i -> + receivers[i.getArgument(0).applicationId] = i.getArgument(1) + } + } + private val context = mock() { + on { packageName } doReturn this@GarminDeviceClientTest.packageName + on { registerReceiver(any(), any()) } doAnswer { i -> + actions[i.getArgument(1).getAction(0)] = i.getArgument(0) + Intent() + } + on { unregisterReceiver(any()) } doAnswer { i -> + val keys = actions.entries.filter {(_, br) -> br == i.getArgument(0) }.map { (k, _) -> k } + keys.forEach { k -> actions.remove(k) } + } + on { bindService(any(), eq(Context.BIND_AUTO_CREATE), any(), any()) }. doAnswer { i -> + serviceConnection = i.getArgument(3) + i.getArgument(2).execute { + serviceConnection.onServiceConnected( + GarminDeviceClient.CONNECTIQ_SERVICE_COMPONENT, + Binder().apply { attachInterface(ciqService, serviceDescriptor) }) + } + true + } + on { bindService(any(), any(), eq(Context.BIND_AUTO_CREATE)) }. doAnswer { i -> + serviceConnection = i.getArgument(1) + serviceConnection.onServiceConnected( + GarminDeviceClient.CONNECTIQ_SERVICE_COMPONENT, + Binder().apply { attachInterface(ciqService, serviceDescriptor) }) + true + } + } + + @Before + fun setup() { + client = GarminDeviceClient(aapsLogger, context, receiver, retryWaitFactor = 0L) + device = GarminDevice(client, 1L, "TDevice") + verify(receiver, timeout(2_000L)).onConnect(client) + } + + @After + fun shutdown() { + if (::client.isInitialized) client.dispose() + assertEquals(0, actions.size) // make sure all broadcastReceivers were unregistered + verify(context).unbindService(serviceConnection) + } + + @Test + fun connect() { + } + + @Test + fun disconnect() { + serviceConnection.onServiceDisconnected(GarminDeviceClient.CONNECTIQ_SERVICE_COMPONENT) + verify(receiver).onDisconnect(client) + assertEquals(0, actions.size) + } + + @Test + fun connectedDevices() { + assertEquals(listOf(device), client.connectedDevices) + verify(ciqService).connectedDevices + } + + @Test + fun reconnectDeadBinder() { + whenever(binder.isBinderAlive).thenReturn(false, true) + assertEquals(listOf(device), client.connectedDevices) + + verify(ciqService).connectedDevices + verify(ciqService, times(2)).asBinder() + verify(context, times(2)) + .bindService(any(), eq(Context.BIND_AUTO_CREATE), any(), any()) + + verifyNoMoreInteractions(ciqService) + verifyNoMoreInteractions(receiver) + } + + @Test + fun sendMessage() { + val appId = "APPID1" + val data = "Hello, World!".toByteArray() + + client.sendMessage(GarminApplication(device, appId, "$appId-name"), data) + verify(ciqService).sendMessage( + argThat { iqMsg -> data.contentEquals(iqMsg.messageData) + && iqMsg.notificationPackage == packageName + && iqMsg.notificationAction == client.sendMessageAction }, + argThat { iqDevice -> iqDevice.deviceIdentifier == device.id }, + argThat { iqApp -> iqApp?.applicationId == appId }) + + val intent = Intent().apply { + putExtra(GarminDeviceClient.EXTRA_STATUS, ConnectIQ.IQMessageStatus.SUCCESS.ordinal) + putExtra(GarminDeviceClient.EXTRA_REMOTE_DEVICE, device.toIQDevice()) + putExtra(GarminDeviceClient.EXTRA_APPLICATION_ID, appId) + } + actions[client.sendMessageAction]!!.onReceive(context, intent) + actions[client.sendMessageAction]!!.onReceive(context, intent) // extra on receive will be ignored + verify(receiver).onSendMessage(client, device.id, appId, null) + } + + @Test + fun sendMessage_failNoRetry() { + val appId = "APPID1" + val data = "Hello, World!".toByteArray() + + client.sendMessage(GarminApplication(device, appId, "$appId-name"), data) + verify(ciqService).sendMessage( + argThat { iqMsg -> data.contentEquals(iqMsg.messageData) + && iqMsg.notificationPackage == packageName + && iqMsg.notificationAction == client.sendMessageAction }, + argThat {iqDevice -> iqDevice.deviceIdentifier == device.id }, + argThat { iqApp -> iqApp?.applicationId == appId }) + + val intent = Intent().apply { + putExtra(GarminDeviceClient.EXTRA_STATUS, ConnectIQ.IQMessageStatus.FAILURE_MESSAGE_TOO_LARGE.ordinal) + putExtra(GarminDeviceClient.EXTRA_REMOTE_DEVICE, device.toIQDevice()) + putExtra(GarminDeviceClient.EXTRA_APPLICATION_ID, appId) + } + actions[client.sendMessageAction]!!.onReceive(context, intent) + verify(receiver).onSendMessage(client, device.id, appId, "error FAILURE_MESSAGE_TOO_LARGE") + } + + @Test + fun sendMessage_failRetry() { + val appId = "APPID1" + val data = "Hello, World!".toByteArray() + + client.sendMessage(GarminApplication(device, appId, "$appId-name"), data) + verify(ciqService).sendMessage( + argThat { iqMsg -> data.contentEquals(iqMsg.messageData) + && iqMsg.notificationPackage == packageName + && iqMsg.notificationAction == client.sendMessageAction }, + argThat {iqDevice -> iqDevice.deviceIdentifier == device.id }, + argThat { iqApp -> iqApp?.applicationId == appId }) + + val intent = Intent().apply { + putExtra(GarminDeviceClient.EXTRA_STATUS, ConnectIQ.IQMessageStatus.FAILURE_DURING_TRANSFER.ordinal) + putExtra(GarminDeviceClient.EXTRA_REMOTE_DEVICE, device.toIQDevice()) + putExtra(GarminDeviceClient.EXTRA_APPLICATION_ID, appId) + } + actions[client.sendMessageAction]!!.onReceive(context, intent) + verifyNoMoreInteractions(receiver) + + // Verify retry ... + verify(ciqService, timeout(10_000L).times( 2)).sendMessage( + argThat { iqMsg -> data.contentEquals(iqMsg.messageData) + && iqMsg.notificationPackage == packageName + && iqMsg.notificationAction == client.sendMessageAction }, + argThat {iqDevice -> iqDevice.deviceIdentifier == device.id }, + argThat { iqApp -> iqApp?.applicationId == appId }) + + intent.putExtra(GarminDeviceClient.EXTRA_STATUS, ConnectIQ.IQMessageStatus.SUCCESS.ordinal) + actions[client.sendMessageAction]!!.onReceive(context, intent) + verify(receiver).onSendMessage(client, device.id, appId, null) + } + + @Test + fun sendMessage_2toSameApp() { + val appId = "APPID1" + val data1 = "m1".toByteArray() + val data2 = "m2".toByteArray() + + client.sendMessage(GarminApplication(device, appId, "$appId-name"), data1) + client.sendMessage(GarminApplication(device, appId, "$appId-name"), data2) + verify(ciqService).sendMessage( + argThat { iqMsg -> data1.contentEquals(iqMsg.messageData) + && iqMsg.notificationPackage == packageName + && iqMsg.notificationAction == client.sendMessageAction }, + argThat {iqDevice -> iqDevice.deviceIdentifier == device.id }, + argThat { iqApp -> iqApp?.applicationId == appId }) + verify(ciqService, atLeastOnce()).asBinder() + verifyNoMoreInteractions(ciqService) + + val intent = Intent().apply { + putExtra(GarminDeviceClient.EXTRA_STATUS, ConnectIQ.IQMessageStatus.SUCCESS.ordinal) + putExtra(GarminDeviceClient.EXTRA_REMOTE_DEVICE, device.toIQDevice()) + putExtra(GarminDeviceClient.EXTRA_APPLICATION_ID, appId) + } + actions[client.sendMessageAction]!!.onReceive(context, intent) + verify(receiver).onSendMessage(client, device.id, appId, null) + + verify(ciqService, timeout(5000L)).sendMessage( + argThat { iqMsg -> data2.contentEquals(iqMsg.messageData) + && iqMsg.notificationPackage == packageName + && iqMsg.notificationAction == client.sendMessageAction }, + argThat {iqDevice -> iqDevice.deviceIdentifier == device.id }, + argThat { iqApp -> iqApp?.applicationId == appId }) + + actions[client.sendMessageAction]!!.onReceive(context, intent) + verify(receiver, times(2)).onSendMessage(client, device.id, appId, null) + } + + @Test + fun sendMessage_2to2Apps() { + val appId1 = "APPID1" + val appId2 = "APPID2" + val data1 = "m1".toByteArray() + val data2 = "m2".toByteArray() + + client.sendMessage(GarminApplication(device, appId1, "$appId1-name"), data1) + client.sendMessage(GarminApplication(device, appId2, "$appId2-name"), data2) + verify(ciqService).sendMessage( + argThat { iqMsg -> data1.contentEquals(iqMsg.messageData) + && iqMsg.notificationPackage == packageName + && iqMsg.notificationAction == client.sendMessageAction }, + argThat {iqDevice -> iqDevice.deviceIdentifier == device.id }, + argThat { iqApp -> iqApp?.applicationId == appId1 }) + verify(ciqService, timeout(5000L)).sendMessage( + argThat { iqMsg -> data2.contentEquals(iqMsg.messageData) + && iqMsg.notificationPackage == packageName + && iqMsg.notificationAction == client.sendMessageAction }, + argThat {iqDevice -> iqDevice.deviceIdentifier == device.id }, + argThat { iqApp -> iqApp?.applicationId == appId2 }) + + val intent1 = Intent().apply { + putExtra(GarminDeviceClient.EXTRA_STATUS, ConnectIQ.IQMessageStatus.SUCCESS.ordinal) + putExtra(GarminDeviceClient.EXTRA_REMOTE_DEVICE, device.toIQDevice()) + putExtra(GarminDeviceClient.EXTRA_APPLICATION_ID, appId1) + } + actions[client.sendMessageAction]!!.onReceive(context, intent1) + verify(receiver).onSendMessage(client, device.id, appId1, null) + + val intent2 = Intent().apply { + putExtra(GarminDeviceClient.EXTRA_STATUS, ConnectIQ.IQMessageStatus.SUCCESS.ordinal) + putExtra(GarminDeviceClient.EXTRA_REMOTE_DEVICE, device.toIQDevice()) + putExtra(GarminDeviceClient.EXTRA_APPLICATION_ID, appId2) + } + actions[client.sendMessageAction]!!.onReceive(context, intent2) + verify(receiver).onSendMessage(client, device.id, appId2, null) + } + + @Test + fun receiveMessage() { + val app = GarminApplication(GarminDevice(client, 1L, "D1"), "APPID1", "N1") + client.registerForMessages(app) + assertTrue(receivers.contains(app.id)) + val intent = Intent().apply { + putExtra(GarminDeviceClient.EXTRA_REMOTE_DEVICE, app.device.toIQDevice()) + putExtra(GarminDeviceClient.EXTRA_PAYLOAD, "foo".toByteArray()) + } + actions[receivers[app.id]]!!.onReceive(context, intent) + verify(receiver).onReceiveMessage( + eq(client), + eq(app.device.id), + eq(app.id), + argThat { payload -> "foo" == String(payload) }) + } +} \ No newline at end of file diff --git a/plugins/sync/src/main/aidl/com/garmin/android/apps/connectmobile/connectiq/IConnectIQService.aidl b/plugins/sync/src/main/aidl/com/garmin/android/apps/connectmobile/connectiq/IConnectIQService.aidl deleted file mode 100644 index 8aaf0eb761..0000000000 --- a/plugins/sync/src/main/aidl/com/garmin/android/apps/connectmobile/connectiq/IConnectIQService.aidl +++ /dev/null @@ -1,30 +0,0 @@ -/** - * Copyright (C) 2014 Garmin International Ltd. - * Subject to Garmin SDK License Agreement and Wearables Application Developer Agreement. - */ -//IConnectIQService -package com.garmin.android.apps.connectmobile.connectiq; - -import com.garmin.android.connectiq.IQDevice; -import com.garmin.android.connectiq.IQApp; -import com.garmin.android.connectiq.IQMessage; - -interface IConnectIQService { - boolean openStore(String applicationID); - List getConnectedDevices(); - List getKnownDevices(); - - // Remote device methods - int getStatus(in IQDevice device); - - // Messages and Commands - oneway void getApplicationInfo(String notificationPackage, String notificationAction, in IQDevice device, String applicationID); - oneway void openApplication(String notificationPackage, String notificationAction, in IQDevice device, in IQApp app); - - // Pending intent will be fired to let the sdk know a message has been transferred. - oneway void sendMessage(in IQMessage message, in IQDevice device, in IQApp app); - oneway void sendImage(in IQMessage image, in IQDevice device, in IQApp app); - - // registers a companion app with the remote service so that it can receive messages from remote device. - oneway void registerApp(in IQApp app, String notificationAction, String notificationPackage); -} \ No newline at end of file diff --git a/plugins/sync/src/main/aidl/com/garmin/android/connectiq/IQApp.aidl b/plugins/sync/src/main/aidl/com/garmin/android/connectiq/IQApp.aidl deleted file mode 100644 index 2115dafd35..0000000000 --- a/plugins/sync/src/main/aidl/com/garmin/android/connectiq/IQApp.aidl +++ /dev/null @@ -1,12 +0,0 @@ -/** - * Copyright (C) 2014 Garmin International Ltd. - * Subject to Garmin SDK License Agreement and Wearables Application Developer Agreement. - */ -package com.garmin.android.connectiq; - -parcelable IQApp { - String applicationID; - int status; - String displayName; - int version; -} \ No newline at end of file diff --git a/plugins/sync/src/main/aidl/com/garmin/android/connectiq/IQDevice.aidl b/plugins/sync/src/main/aidl/com/garmin/android/connectiq/IQDevice.aidl deleted file mode 100644 index ec1d4f3217..0000000000 --- a/plugins/sync/src/main/aidl/com/garmin/android/connectiq/IQDevice.aidl +++ /dev/null @@ -1,11 +0,0 @@ -/** - * Copyright (C) 2014 Garmin International Ltd. - * Subject to Garmin SDK License Agreement and Wearables Application Developer Agreement. - */ -package com.garmin.android.connectiq; - -parcelable IQDevice { - long deviceIdentifier; - String friendlyName; - int status; -} \ No newline at end of file diff --git a/plugins/sync/src/main/aidl/com/garmin/android/connectiq/IQMessage.aidl b/plugins/sync/src/main/aidl/com/garmin/android/connectiq/IQMessage.aidl deleted file mode 100644 index cbf6335fb0..0000000000 --- a/plugins/sync/src/main/aidl/com/garmin/android/connectiq/IQMessage.aidl +++ /dev/null @@ -1,20 +0,0 @@ -/** - * Copyright (C) 2014 Garmin International Ltd. - * Subject to Garmin SDK License Agreement and Wearables Application Developer Agreement. - */ -package com.garmin.android.connectiq; - -parcelable IQMessage { - const int SUCCESS = 0; - const int FAILURE_UNKNOWN = 1; - const int FAILURE_INVALID_FORMAT = 2; - const int FAILURE_MESSAGE_TOO_LARGE = 3; - const int FAILURE_UNSUPPORTED_TYPE = 4; - const int FAILURE_DURING_TRANSFER = 5; - const int FAILURE_INVALID_DEVICE = 6; - const int FAILURE_DEVICE_NOT_CONNECTED = 7; - - byte[] messageData; - String notificationPackage; - String notificationAction; -} \ No newline at end of file diff --git a/plugins/sync/src/main/kotlin/app/aaps/plugins/sync/garmin/GarminApplication.kt b/plugins/sync/src/main/kotlin/app/aaps/plugins/sync/garmin/GarminApplication.kt index 3aec9257a9..6ff37f0a62 100644 --- a/plugins/sync/src/main/kotlin/app/aaps/plugins/sync/garmin/GarminApplication.kt +++ b/plugins/sync/src/main/kotlin/app/aaps/plugins/sync/garmin/GarminApplication.kt @@ -1,20 +1,11 @@ package app.aaps.plugins.sync.garmin data class GarminApplication( - val client: GarminClient, val device: GarminDevice, val id: String, val name: String?) { - enum class Status { - @Suppress("UNUSED") - UNKNOWN, - INSTALLED, - @Suppress("UNUSED") - NOT_INSTALLED, - @Suppress("UNUSED") - NOT_SUPPORTED; - } + val client get() = device.client override fun equals(other: Any?): Boolean { if (this === other) return true @@ -35,5 +26,7 @@ data class GarminApplication( result = 31 * result + id.hashCode() return result } + + override fun toString() = "A[$device:$id:$name]" } diff --git a/plugins/sync/src/main/kotlin/app/aaps/plugins/sync/garmin/GarminClient.kt b/plugins/sync/src/main/kotlin/app/aaps/plugins/sync/garmin/GarminClient.kt index 9c1631497d..6333f2ae82 100644 --- a/plugins/sync/src/main/kotlin/app/aaps/plugins/sync/garmin/GarminClient.kt +++ b/plugins/sync/src/main/kotlin/app/aaps/plugins/sync/garmin/GarminClient.kt @@ -6,8 +6,10 @@ interface GarminClient: Disposable { /** Name of the client. */ val name: String - /** Asynchronously retrieves status information for the given application. */ - fun retrieveApplicationInfo(device: GarminDevice, appId: String, appName: String) + val connectedDevices: List + + /** Register to receive messages from the given up. */ + fun registerForMessages(app: GarminApplication) /** Asynchronously sends a message to an application. */ fun sendMessage(app: GarminApplication, data: ByteArray) diff --git a/plugins/sync/src/main/kotlin/app/aaps/plugins/sync/garmin/GarminDevice.kt b/plugins/sync/src/main/kotlin/app/aaps/plugins/sync/garmin/GarminDevice.kt index b0fc0ead8f..1df255d38a 100644 --- a/plugins/sync/src/main/kotlin/app/aaps/plugins/sync/garmin/GarminDevice.kt +++ b/plugins/sync/src/main/kotlin/app/aaps/plugins/sync/garmin/GarminDevice.kt @@ -5,34 +5,16 @@ import com.garmin.android.connectiq.IQDevice data class GarminDevice( val client: GarminClient, val id: Long, - var name: String, - var status: Status = Status.UNKNOWN) { + var name: String) { constructor(client: GarminClient, iqDevice: IQDevice): this( client, iqDevice.deviceIdentifier, - iqDevice.friendlyName, - Status.from(iqDevice.status)) {} - - enum class Status { - NOT_PAIRED, - NOT_CONNECTED, - CONNECTED, - UNKNOWN; - - companion object { - fun from(ordinal: Int?): Status = - values().firstOrNull { s -> s.ordinal == ordinal } ?: UNKNOWN - } - } - + iqDevice.friendlyName) {} override fun toString(): String = "D[$name/$id]" - fun toIQDevice() = IQDevice().apply { - deviceIdentifier = id - friendlyName = name - status = Status.UNKNOWN.ordinal } + fun toIQDevice() = IQDevice(id, name) override fun equals(other: Any?): Boolean { if (this === other) return true diff --git a/plugins/sync/src/main/kotlin/app/aaps/plugins/sync/garmin/GarminDeviceClient.kt b/plugins/sync/src/main/kotlin/app/aaps/plugins/sync/garmin/GarminDeviceClient.kt index 8ef347d8a6..ec81ddb512 100644 --- a/plugins/sync/src/main/kotlin/app/aaps/plugins/sync/garmin/GarminDeviceClient.kt +++ b/plugins/sync/src/main/kotlin/app/aaps/plugins/sync/garmin/GarminDeviceClient.kt @@ -6,22 +6,24 @@ import android.content.Context import android.content.Intent import android.content.IntentFilter import android.content.ServiceConnection +import android.os.Build import android.os.IBinder import app.aaps.core.interfaces.logging.AAPSLogger import app.aaps.core.interfaces.logging.LTag import app.aaps.core.utils.waitMillis import com.garmin.android.apps.connectmobile.connectiq.IConnectIQService +import com.garmin.android.connectiq.ConnectIQ.IQMessageStatus import com.garmin.android.connectiq.IQApp import com.garmin.android.connectiq.IQDevice import com.garmin.android.connectiq.IQMessage import io.reactivex.rxjava3.disposables.Disposable import io.reactivex.rxjava3.schedulers.Schedulers import org.jetbrains.annotations.VisibleForTesting -import java.time.Duration +import java.lang.Thread.UncaughtExceptionHandler import java.time.Instant import java.util.LinkedList -import java.util.Locale import java.util.Queue +import java.util.concurrent.Executors import java.util.concurrent.TimeUnit @@ -33,26 +35,31 @@ class GarminDeviceClient( private val retryWaitFactor: Long = 5L): Disposable, GarminClient { override val name = "Device" + private var executor = Executors.newSingleThreadExecutor { r -> + Thread(r).apply { + name = "Garmin callback" + isDaemon = true + uncaughtExceptionHandler = UncaughtExceptionHandler { _, e -> + aapsLogger.error(LTag.GARMIN, "ConnectIQ callback failed", e) } + } + } private var bindLock = Object() private var ciqService: IConnectIQService? = null get() { - val waitUntil = Instant.now().plusSeconds(2) synchronized (bindLock) { - while(field?.asBinder()?.isBinderAlive != true) { + if (field?.asBinder()?.isBinderAlive != true) { field = null if (state !in arrayOf(State.BINDING, State.RECONNECTING)) { aapsLogger.info(LTag.GARMIN, "reconnecting to ConnectIQ service") state = State.RECONNECTING - context.bindService(serviceIntent, ciqServiceConnection, Context.BIND_AUTO_CREATE) + bindService() } - // Wait for the connection, that is the call to onServiceConnected. - val wait = Duration.between(Instant.now(), waitUntil) - if (wait > Duration.ZERO) bindLock.waitMillis(wait.toMillis()) - if (field == null) { + bindLock.waitMillis(2_000L) + if (field?.asBinder()?.isBinderAlive != true) { + field = null // The [serviceConnection] didn't have a chance to reassign ciqService, // i.e. the wait timed out. Give up. aapsLogger.warn(LTag.GARMIN, "no ciqservice $this") - return null } } return field @@ -80,7 +87,7 @@ class GarminDeviceClient( override fun onServiceConnected(name: ComponentName?, service: IBinder?) { var notifyReceiver: Boolean val ciq: IConnectIQService - synchronized (bindLock) { + synchronized(bindLock) { aapsLogger.info(LTag.GARMIN, "ConnectIQ App connected") ciq = IConnectIQService.Stub.asInterface(service) notifyReceiver = state != State.RECONNECTING @@ -89,14 +96,8 @@ class GarminDeviceClient( bindLock.notifyAll() } if (notifyReceiver) receiver.onConnect(this@GarminDeviceClient) - try { - ciq.connectedDevices?.forEach { d -> - receiver.onConnectDevice(this@GarminDeviceClient, d.deviceIdentifier, d.friendlyName) - } - } catch (e: Exception) { - aapsLogger.error(LTag.GARMIN, "getting devices failed", e) - } } + override fun onServiceDisconnected(name: ComponentName?) { synchronized(bindLock) { aapsLogger.info(LTag.GARMIN, "ConnectIQ App disconnected") @@ -114,9 +115,21 @@ class GarminDeviceClient( aapsLogger.info(LTag.GARMIN, "binding to ConnectIQ service") registerReceiver(sendMessageAction, ::onSendMessage) state = State.BINDING - context.bindService(serviceIntent, ciqServiceConnection, Context.BIND_AUTO_CREATE) + bindService() } + private fun bindService() { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + context.bindService(serviceIntent, Context.BIND_AUTO_CREATE, executor, ciqServiceConnection) + } else { + context.bindService(serviceIntent, ciqServiceConnection, Context.BIND_AUTO_CREATE) + } + } + + override val connectedDevices: List + get() = ciqService?.connectedDevices?.map { iqDevice -> GarminDevice(this, iqDevice) } + ?: emptyList() + override fun isDisposed() = state == State.DISPOSED override fun dispose() { broadcastReceiver.forEach { context.unregisterReceiver(it) } @@ -143,35 +156,14 @@ class GarminDeviceClient( context.registerReceiver(recv, IntentFilter(action)) } - override fun retrieveApplicationInfo(device: GarminDevice, appId: String, appName: String) { - val action = createAction("APPLICATION_INFO_${device.id}_$appId") + override fun registerForMessages(app: GarminApplication) { + aapsLogger.info(LTag.GARMIN, "registerForMessage $name $app") + val action = createAction("ON_MESSAGE_${app.device.id}_${app.id}") + val iqApp = IQApp(app.id) synchronized (registeredActions) { if (!registeredActions.contains(action)) { - registerReceiver(action) { intent -> onApplicationInfo(appId, device, intent) } - } - registeredActions.add(action) - } - ciqService?.getApplicationInfo(context.packageName, action, device.toIQDevice(), appId) - } - - /** Receives application info callbacks from ConnectIQ app.*/ - private fun onApplicationInfo(appId: String, device: GarminDevice, intent: Intent) { - val receivedAppId = intent.getStringExtra(EXTRA_APPLICATION_ID)?.lowercase(Locale.getDefault()) - val version = intent.getIntExtra(EXTRA_APPLICATION_VERSION, -1) - val isInstalled = receivedAppId != null && version >= 0 && version != 65535 - - if (isInstalled) registerForMessages(device.id, appId) - receiver.onApplicationInfo(device, appId, isInstalled) - } - - private fun registerForMessages(deviceId: Long, appId: String) { - aapsLogger.info(LTag.GARMIN, "registerForMessage $name $appId") - val action = createAction("ON_MESSAGE_${deviceId}_$appId") - val app = IQApp().apply { applicationID = appId; displayName = "" } - synchronized (registeredActions) { - if (!registeredActions.contains(action)) { - registerReceiver(action) { intent: Intent -> onReceiveMessage(app, intent) } - ciqService?.registerApp(app, action, context.packageName) + registerReceiver(action) { intent: Intent -> onReceiveMessage(iqApp, intent) } + ciqService?.registerApp(iqApp, action, context.packageName) registeredActions.add(action) } else { aapsLogger.info(LTag.GARMIN, "registerForMessage $action already registered") @@ -184,14 +176,15 @@ class GarminDeviceClient( val iqDevice = intent.getParcelableExtra(EXTRA_REMOTE_DEVICE) as IQDevice? val data = intent.getByteArrayExtra(EXTRA_PAYLOAD) if (iqDevice != null && data != null) - receiver.onReceiveMessage(this, iqDevice.deviceIdentifier, iqApp.applicationID, data) + receiver.onReceiveMessage(this, iqDevice.deviceIdentifier, iqApp.applicationId, data) } /** Receives callback from ConnectIQ about message transfers. */ private fun onSendMessage(intent: Intent) { - val status = intent.getIntExtra(EXTRA_STATUS, 0) + val statusOrd = intent.getIntExtra(EXTRA_STATUS, IQMessageStatus.FAILURE_UNKNOWN.ordinal) + val status = IQMessageStatus.values().firstOrNull { s -> s.ordinal == statusOrd } ?: IQMessageStatus.FAILURE_UNKNOWN val deviceId = getDevice(intent) - val appId = intent.getStringExtra(EXTRA_APPLICATION_ID)?.lowercase() + val appId = intent.getStringExtra(EXTRA_APPLICATION_ID)?.uppercase() if (deviceId == null || appId == null) { aapsLogger.warn(LTag.GARMIN, "onSendMessage device='$deviceId' app='$appId'") } else { @@ -203,21 +196,22 @@ class GarminDeviceClient( return } + var errorMessage: String? = null when (status) { - IQMessage.FAILURE_DEVICE_NOT_CONNECTED, - IQMessage.FAILURE_DURING_TRANSFER -> { + IQMessageStatus.SUCCESS -> {} + IQMessageStatus.FAILURE_DEVICE_NOT_CONNECTED, + IQMessageStatus.FAILURE_DURING_TRANSFER -> { if (msg.attempt < MAX_RETRIES) { val delaySec = retryWaitFactor * msg.attempt Schedulers.io().scheduleDirect({ retryMessage(deviceId, appId) }, delaySec, TimeUnit.SECONDS) return } } - - else -> {} + else -> { + errorMessage = "error $status" + } } - queue.remove(msg) - val errorMessage = status - .takeUnless { it == IQMessage.SUCCESS }?.let { s -> "error $s" } + queue.poll() receiver.onSendMessage(this, msg.app.device.id, msg.app.id, errorMessage) if (queue.isNotEmpty()) { Schedulers.io().scheduleDirect { retryMessage(deviceId, appId) } @@ -237,8 +231,9 @@ class GarminDeviceClient( val app: GarminApplication, val data: ByteArray) { var attempt: Int = 0 + val creation = Instant.now() var lastAttempt: Instant? = null - val iqApp get() = IQApp().apply { applicationID = app.id; displayName = app.name } + val iqApp get() = IQApp(app.id, app.name, 0) val iqDevice get() = app.device.toIQDevice() } @@ -247,7 +242,17 @@ class GarminDeviceClient( override fun sendMessage(app: GarminApplication, data: ByteArray) { val msg = synchronized (messageQueues) { val msg = Message(app, data) + val oldMessageCutOff = Instant.now().minusSeconds(30) val queue = messageQueues.getOrPut(app.device.id to app.id) { LinkedList() } + while (true) { + val oldMsg = queue.peek() ?: break + if ((oldMsg.lastAttempt ?: oldMsg.creation).isBefore(oldMessageCutOff)) { + aapsLogger.warn(LTag.GARMIN, "remove old msg ${msg.app}") + queue.poll() + } else { + break + } + } queue.add(msg) // Make sure we have only one outstanding message per app, so we ensure // that always the first message in the queue is currently send. @@ -266,10 +271,7 @@ class GarminDeviceClient( private fun sendMessage(msg: Message) { msg.attempt++ msg.lastAttempt = Instant.now() - val iqMsg = IQMessage().apply { - messageData = msg.data - notificationPackage = context.packageName - notificationAction = sendMessageAction } + val iqMsg = IQMessage(msg.data, context.packageName, sendMessageAction) ciqService?.sendMessage(iqMsg, msg.iqDevice, msg.iqApp) } @@ -278,7 +280,6 @@ class GarminDeviceClient( companion object { const val CONNECTIQ_SERVICE_ACTION = "com.garmin.android.apps.connectmobile.CONNECTIQ_SERVICE_ACTION" const val EXTRA_APPLICATION_ID = "com.garmin.android.connectiq.EXTRA_APPLICATION_ID" - const val EXTRA_APPLICATION_VERSION = "com.garmin.android.connectiq.EXTRA_APPLICATION_VERSION" const val EXTRA_REMOTE_DEVICE = "com.garmin.android.connectiq.EXTRA_REMOTE_DEVICE" const val EXTRA_PAYLOAD = "com.garmin.android.connectiq.EXTRA_PAYLOAD" const val EXTRA_STATUS = "com.garmin.android.connectiq.EXTRA_STATUS" diff --git a/plugins/sync/src/main/kotlin/app/aaps/plugins/sync/garmin/GarminMessenger.kt b/plugins/sync/src/main/kotlin/app/aaps/plugins/sync/garmin/GarminMessenger.kt index 8f8671a6ec..feaf1743fc 100644 --- a/plugins/sync/src/main/kotlin/app/aaps/plugins/sync/garmin/GarminMessenger.kt +++ b/plugins/sync/src/main/kotlin/app/aaps/plugins/sync/garmin/GarminMessenger.kt @@ -4,7 +4,6 @@ import android.content.Context import app.aaps.core.interfaces.logging.AAPSLogger import app.aaps.core.interfaces.logging.LTag import io.reactivex.rxjava3.disposables.Disposable -import org.jetbrains.annotations.VisibleForTesting class GarminMessenger( private val aapsLogger: AAPSLogger, @@ -17,8 +16,6 @@ class GarminMessenger( private var disposed: Boolean = false /** All devices that where connected since this instance was created. */ private val devices = mutableMapOf() - @VisibleForTesting - val liveApplications = mutableSetOf() private val clients = mutableListOf() private val appIdNames = mutableMapOf() init { @@ -33,20 +30,14 @@ class GarminMessenger( private fun getDevice(client: GarminClient, deviceId: Long): GarminDevice { synchronized (devices) { - return devices.getOrPut(deviceId) { GarminDevice(client, deviceId, "unknown") } + return devices.getOrPut(deviceId) { + client.connectedDevices.firstOrNull { d -> d.id == deviceId } ?: + GarminDevice(client, deviceId, "unknown") } } } private fun getApplication(client: GarminClient, deviceId: Long, appId: String): GarminApplication { - synchronized (liveApplications) { - var app = liveApplications.firstOrNull { app -> - app.client == client && app.device.id == deviceId && app.id == appId } - if (app == null) { - app = GarminApplication(client, getDevice(client, deviceId), appId, appIdNames[appId]) - liveApplications.add(app) - } - return app - } + return GarminApplication(getDevice(client, deviceId), appId, appIdNames[appId]) } private fun startDeviceClient() { @@ -61,45 +52,18 @@ class GarminMessenger( override fun onDisconnect(client: GarminClient) { aapsLogger.info(LTag.GARMIN, "onDisconnect ${client.name}") clients.remove(client) - synchronized (liveApplications) { - liveApplications.removeIf { app -> app.client == client } + synchronized (devices) { + val deviceIds = devices.filter { (_, d) -> d.client == client }.map { (id, _) -> id } + deviceIds.forEach { id -> devices.remove(id) } } client.dispose() - when (client.name) { - "Device" -> startDeviceClient() - "Sim"-> GarminSimulatorClient(aapsLogger, this) + when (client) { + is GarminDeviceClient -> startDeviceClient() + is GarminSimulatorClient -> GarminSimulatorClient(aapsLogger, this) else -> aapsLogger.warn(LTag.GARMIN, "onDisconnect unknown client $client") } } - /** Receives notifications that a device has connected. - * - * It will retrieve status information for all applications we care about (in [appIdNames]). */ - override fun onConnectDevice(client: GarminClient, deviceId: Long, deviceName: String) { - val device = getDevice(client, deviceId).apply { name = deviceName } - aapsLogger.info(LTag.GARMIN, "onConnectDevice $device") - appIdNames.forEach { (id, name) -> client.retrieveApplicationInfo(device, id, name) } - } - - /** Receives notifications about disconnection of a device. */ - override fun onDisconnectDevice(client: GarminClient, deviceId: Long) { - val device = getDevice(client, deviceId) - aapsLogger.info(LTag.GARMIN,"onDisconnectDevice $device") - synchronized (liveApplications) { - liveApplications.removeIf { app -> app.device == device } - } - } - - /** Receives notification about applications that are installed/uninstalled - * on a device from the client. */ - override fun onApplicationInfo(device: GarminDevice, appId: String, isInstalled: Boolean) { - val app = getApplication(device.client, device.id, appId) - aapsLogger.info(LTag.GARMIN, "onApplicationInfo add $app ${if (isInstalled) "" else "un"}installed") - if (!isInstalled) { - synchronized (liveApplications) { liveApplications.remove(app) } - } - } - override fun onReceiveMessage(client: GarminClient, deviceId: Long, appId: String, data: ByteArray) { val app = getApplication(client, deviceId, appId) val msg = GarminSerializer.deserialize(data) @@ -118,14 +82,14 @@ class GarminMessenger( } fun sendMessage(device: GarminDevice, msg: Any) { - liveApplications - .filter { a -> a.device.id == device.id } - .forEach { a -> sendMessage(a, msg) } + appIdNames.forEach { (appId, _) -> + sendMessage(getApplication(device.client, device.id, appId), msg) + } } /** Sends a message to all applications on all devices. */ fun sendMessage(msg: Any) { - liveApplications.forEach { app -> sendMessage(app, msg) } + clients.forEach { cl -> cl.connectedDevices.forEach { d -> sendMessage(d, msg) }} } private fun sendMessage(app: GarminApplication, msg: Any) { @@ -139,7 +103,7 @@ class GarminMessenger( msg.toString() } val data = GarminSerializer.serialize(msg) - aapsLogger.info(LTag.GARMIN, "sendMessage $app $app ${data.size} bytes $s") + aapsLogger.info(LTag.GARMIN, "sendMessage $app ${data.size} bytes $s") try { app.client.sendMessage(app, data) } catch (e: IllegalStateException) { diff --git a/plugins/sync/src/main/kotlin/app/aaps/plugins/sync/garmin/GarminPlugin.kt b/plugins/sync/src/main/kotlin/app/aaps/plugins/sync/garmin/GarminPlugin.kt index ce67a6e8bb..6981d3d58e 100644 --- a/plugins/sync/src/main/kotlin/app/aaps/plugins/sync/garmin/GarminPlugin.kt +++ b/plugins/sync/src/main/kotlin/app/aaps/plugins/sync/garmin/GarminPlugin.kt @@ -64,12 +64,12 @@ class GarminPlugin @Inject constructor( /** Garmin ConnectIQ application id for native communication. Phone pushes values. */ private val glucoseAppIds = mapOf( - "c9e90ee7e6924829a8b45e7dafff5cb4" to "GlucoseWatch_Dev", - "1107ca6c2d5644b998d4bcb3793f2b7c" to "GlucoseDataField_Dev", - "928fe19a4d3a4259b50cb6f9ddaf0f4a" to "GlucoseWidget_Dev", - "662dfcf7f5a147de8bd37f09574adb11" to "GlucoseWatch", - "815c7328c21248c493ad9ac4682fe6b3" to "GlucoseDataField", - "4bddcc1740084a1fab83a3b2e2fcf55b" to "GlucoseWidget", + "C9E90EE7E6924829A8B45E7DAFFF5CB4" to "GlucoseWatch_Dev", + "1107CA6C2D5644B998D4BCB3793F2B7C" to "GlucoseDataField_Dev", + "928FE19A4D3A4259B50CB6F9DDAF0F4A" to "GlucoseWidget_Dev", + "662DFCF7F5A147DE8BD37F09574ADB11" to "GlucoseWatch", + "815C7328C21248C493AD9AC4682FE6B3" to "GlucoseDataField", + "4BDDCC1740084A1FAB83A3B2E2FCF55B" to "GlucoseWidget", ) @VisibleForTesting @@ -90,9 +90,7 @@ class GarminPlugin @Inject constructor( "communication_debug_mode" -> setupGarminMessenger() "communication_http", "communication_http_port" -> setupHttpServer() "garmin_aaps_key" -> sendPhoneAppMessage() - else -> return } - aapsLogger.info(LTag.GARMIN, "preferences change ${event.changedKey}") } private fun setupGarminMessenger() { @@ -121,7 +119,8 @@ class GarminPlugin @Inject constructor( .subscribe(::onNewBloodGlucose) ) setupHttpServer() - // setupGarminMessenger() + if (garminAapsKey.isNotEmpty()) + setupGarminMessenger() } fun setupHttpServer() { @@ -332,7 +331,7 @@ class GarminPlugin @Inject constructor( if (test) return if (avg > 10 && samplingStart > Instant.ofEpochMilli(0L) && samplingEnd > samplingStart) { loopHub.storeHeartRate(samplingStart, samplingEnd, avg, device) - } else { + } else if (avg > 0) { aapsLogger.warn(LTag.GARMIN, "Skip saving invalid HR $avg $samplingStart..$samplingEnd") } } diff --git a/plugins/sync/src/main/kotlin/app/aaps/plugins/sync/garmin/GarminReceiver.kt b/plugins/sync/src/main/kotlin/app/aaps/plugins/sync/garmin/GarminReceiver.kt index c308e4bead..e8e93bac51 100644 --- a/plugins/sync/src/main/kotlin/app/aaps/plugins/sync/garmin/GarminReceiver.kt +++ b/plugins/sync/src/main/kotlin/app/aaps/plugins/sync/garmin/GarminReceiver.kt @@ -11,19 +11,6 @@ interface GarminReceiver { fun onConnect(client: GarminClient) fun onDisconnect(client: GarminClient) - /** - * Notifies that a device is connected. This will be called for all connected devices - * initially. - */ - fun onConnectDevice(client: GarminClient, deviceId: Long, deviceName: String) - fun onDisconnectDevice(client: GarminClient, deviceId: Long) - - /** - * Provides application info after a call to - * {@link ConnectIqClient#retrieveApplicationInfo retrieveApplicationInfo}. - */ - fun onApplicationInfo(device: GarminDevice, appId: String, isInstalled: Boolean) - /** * Delivers received device app messages. */ diff --git a/plugins/sync/src/main/kotlin/app/aaps/plugins/sync/garmin/GarminSimulatorClient.kt b/plugins/sync/src/main/kotlin/app/aaps/plugins/sync/garmin/GarminSimulatorClient.kt index 56b1ce1449..75f8d8e436 100644 --- a/plugins/sync/src/main/kotlin/app/aaps/plugins/sync/garmin/GarminSimulatorClient.kt +++ b/plugins/sync/src/main/kotlin/app/aaps/plugins/sync/garmin/GarminSimulatorClient.kt @@ -3,6 +3,7 @@ package app.aaps.plugins.sync.garmin import app.aaps.core.interfaces.logging.AAPSLogger import app.aaps.core.interfaces.logging.LTag import com.garmin.android.connectiq.IQApp +import com.garmin.android.connectiq.IQApp.IQAppStatus import io.reactivex.rxjava3.disposables.Disposable import org.jetbrains.annotations.VisibleForTesting import java.io.InputStream @@ -35,25 +36,23 @@ class GarminSimulatorClient( private val connections: MutableList = Collections.synchronizedList(mutableListOf()) private var nextDeviceId = AtomicLong(1) @VisibleForTesting - val iqApp = IQApp().apply { - applicationID = "SimApp" - status = GarminApplication.Status.INSTALLED.ordinal - displayName = "Simulator" - version = 1 } + val iqApp = IQApp("SimApp", IQAppStatus.INSTALLED, "Simulator", 1) private val readyLock = ReentrantLock() private val readyCond = readyLock.newCondition() + override val connectedDevices: List get() = connections.map { c -> c.device } + + override fun registerForMessages(app: GarminApplication) { + } private inner class Connection(private val socket: Socket): Disposable { val device = GarminDevice( this@GarminSimulatorClient, nextDeviceId.getAndAdd(1L), - "Sim@${socket.remoteSocketAddress}", - GarminDevice.Status.CONNECTED) + "Sim@${socket.remoteSocketAddress}") fun start() { executor.execute { try { - receiver.onConnectDevice(this@GarminSimulatorClient, device.id, device.name) run() } catch (e: Throwable) { aapsLogger.error(LTag.GARMIN, "$device failed", e) @@ -79,7 +78,7 @@ class GarminSimulatorClient( val data = readAvailable(socket.inputStream) ?: break if (data.isNotEmpty()) { kotlin.runCatching { - receiver.onReceiveMessage(this@GarminSimulatorClient, device.id, iqApp.applicationID, data) + receiver.onReceiveMessage(this@GarminSimulatorClient, device.id, iqApp.applicationId, data) } } } catch (e: SocketException) { @@ -89,7 +88,6 @@ class GarminSimulatorClient( } aapsLogger.info(LTag.GARMIN, "disconnect ${device.name}" ) connections.remove(this) - receiver.onDisconnectDevice(this@GarminSimulatorClient, device.id) } private fun readAvailable(input: InputStream): ByteArray? { @@ -162,10 +160,6 @@ class GarminSimulatorClient( override fun isDisposed() = serverSocket.isClosed - override fun retrieveApplicationInfo(device: GarminDevice, appId: String, appName: String) { - receiver.onApplicationInfo(device, appId, appId == iqApp.applicationID) - } - private fun getConnection(device: GarminDevice): Connection? { return connections.firstOrNull { c -> c.device.id == device.id } } diff --git a/plugins/sync/src/test/kotlin/app/aaps/plugins/sync/garmin/GarminMessengerTest.kt b/plugins/sync/src/test/kotlin/app/aaps/plugins/sync/garmin/GarminMessengerTest.kt index 52962aebcb..3497e4c900 100644 --- a/plugins/sync/src/test/kotlin/app/aaps/plugins/sync/garmin/GarminMessengerTest.kt +++ b/plugins/sync/src/test/kotlin/app/aaps/plugins/sync/garmin/GarminMessengerTest.kt @@ -8,135 +8,103 @@ import org.junit.jupiter.api.Assertions.assertTrue import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.Test import org.mockito.kotlin.any +import org.mockito.kotlin.doAnswer import org.mockito.kotlin.doReturn import org.mockito.kotlin.mock +import org.mockito.kotlin.stub import org.mockito.kotlin.verify -import org.mockito.kotlin.whenever -import java.util.LinkedList -import java.util.Queue class GarminMessengerTest: TestBase() { private val context = mock() - private val client1 = mock() { - on { name } doReturn "Mock1" - } - private val client2 = mock() { - on { name } doReturn "Mock2" - } + private var appId1 = "appId1" private val appId2 = "appId2" private val apps = mapOf(appId1 to "$appId1-name", appId2 to "$appId2-name") - private val msgs: Queue> = LinkedList() + private val outMessages = mutableListOf>() + private val inMessages = mutableListOf>() private var messenger = GarminMessenger( - aapsLogger, context, apps, { app, msg -> msgs.add(app to msg) }, false, false) - private val deviceId = 11L - private val deviceName = "$deviceId-name" - private val device = GarminDevice(client1, deviceId, deviceName) + aapsLogger, context, apps, { app, msg -> inMessages.add(app to msg) }, + enableConnectIq = false, enableSimulator = false) + private val client1 = mock() { + on { name } doReturn "Mock1" + on { sendMessage(any(), any()) } doAnswer { a -> + outMessages.add(a.getArgument(0) to a.getArgument(1)) + Unit + } + } + private val client2 = mock() { + on { name } doReturn "Mock2" + on { sendMessage(any(), any()) } doAnswer { a -> + outMessages.add(a.getArgument(0) to a.getArgument(1)) + Unit + } + } + private val device1 = GarminDevice(client1, 11L, "dev1-name") private val device2 = GarminDevice(client2, 12L, "dev2-name") @BeforeEach fun setup() { messenger.onConnect(client1) messenger.onConnect(client2) + client1.stub { + on { connectedDevices } doReturn listOf(device1) + } + client2.stub { + on { connectedDevices } doReturn listOf(device2) + } } @AfterEach fun cleanup() { messenger.dispose() + verify(client1).dispose() + verify(client2).dispose() assertTrue(messenger.isDisposed) } - @Test - fun onConnectDevice() { - messenger.onConnectDevice(client1, deviceId, deviceName) - verify(client1).retrieveApplicationInfo(device, appId1, apps[appId1]!!) - verify(client1).retrieveApplicationInfo(device, appId2, apps[appId2]!!) - } - - @Test - fun onApplicationInfo() { - messenger.onApplicationInfo(device, appId1, true) - val app = messenger.liveApplications.first() - assertEquals(device, app.device) - assertEquals(appId1, app.id) - assertEquals(apps[appId1], app.name) - - messenger.onApplicationInfo(device, appId1, false) - assertEquals(0, messenger.liveApplications.size) - } - - @Test - fun onDisconnectDevice() { - messenger.onConnectDevice(client1, deviceId, deviceName) - messenger.onApplicationInfo(device, appId1, true) - messenger.onApplicationInfo(device2, appId1, true) - assertEquals(2, messenger.liveApplications.size) - messenger.onDisconnectDevice(client1, device2.id) - assertEquals(1, messenger.liveApplications.size) - assertEquals(appId1, messenger.liveApplications.first().id) - } - @Test fun onDisconnect() { - messenger.onApplicationInfo(device, appId1, true) - messenger.onApplicationInfo(device2, appId2, true) - assertEquals(2, messenger.liveApplications.size) messenger.onDisconnect(client1) - assertEquals(1, messenger.liveApplications.size) - val app = messenger.liveApplications.first() - assertEquals(device2, app.device) - assertEquals(appId2, app.id) - assertEquals(apps[appId2], app.name) + val msg = "foo" + messenger.sendMessage(msg) + outMessages.forEach { (app, payload) -> + assertEquals(client2, app.device.client) + assertEquals(msg, GarminSerializer.deserialize(payload)) + } } @Test fun onReceiveMessage() { val data = GarminSerializer.serialize("foo") - messenger.onReceiveMessage(client1, device.id, appId1, data) - val (app, payload) = msgs.remove() + messenger.onReceiveMessage(client1, device1.id, appId1, data) + val (app, payload) = inMessages.removeAt(0) assertEquals(appId1, app.id) assertEquals("foo", payload) } @Test fun sendMessageDevice() { - messenger.onApplicationInfo(device, appId1, true) - messenger.onApplicationInfo(device, appId2, true) - - val msgs = mutableListOf>() - whenever(client1.sendMessage(any(), any())).thenAnswer { i -> - msgs.add(i.getArgument(0) to i.getArgument(1)) - } - - messenger.sendMessage(device, "foo") - assertEquals(2, msgs.size) - val msg1 = msgs.first { (app, _) -> app.id == appId1 }.second - val msg2 = msgs.first { (app, _) -> app.id == appId2 }.second + messenger.sendMessage(device1, "foo") + assertEquals(2, outMessages.size) + val msg1 = outMessages.first { (app, _) -> app.id == appId1 }.second + val msg2 = outMessages.first { (app, _) -> app.id == appId2 }.second assertEquals("foo", GarminSerializer.deserialize(msg1)) assertEquals("foo", GarminSerializer.deserialize(msg2)) - messenger.onSendMessage(client1, device.id, appId1, null) + messenger.onSendMessage(client1, device1.id, appId1, null) } @Test fun onSendMessageAll() { - messenger.onApplicationInfo(device, appId1, true) - messenger.onApplicationInfo(device2, appId2, true) - assertEquals(2, messenger.liveApplications.size) - - val msgs = mutableListOf>() - whenever(client1.sendMessage(any(), any())).thenAnswer { i -> - msgs.add(i.getArgument(0) to i.getArgument(1)) - } - whenever(client2.sendMessage(any(), any())).thenAnswer { i -> - msgs.add(i.getArgument(0) to i.getArgument(1)) - } - messenger.sendMessage(listOf("foo")) - assertEquals(2, msgs.size) - val msg1 = msgs.first { (app, _) -> app.id == appId1 }.second - val msg2 = msgs.first { (app, _) -> app.id == appId2 }.second - assertEquals(listOf("foo"), GarminSerializer.deserialize(msg1)) - assertEquals(listOf("foo"), GarminSerializer.deserialize(msg2)) - messenger.onSendMessage(client1, device.id, appId1, null) + assertEquals(4, outMessages.size) + val msg11 = outMessages.first { (app, _) -> app.device == device1 && app.id == appId1 }.second + val msg12 = outMessages.first { (app, _) -> app.device == device1 && app.id == appId2 }.second + val msg21 = outMessages.first { (app, _) -> app.device == device2 && app.id == appId1 }.second + val msg22 = outMessages.first { (app, _) -> app.device == device2 && app.id == appId2 }.second + assertEquals(listOf("foo"), GarminSerializer.deserialize(msg11)) + assertEquals(listOf("foo"), GarminSerializer.deserialize(msg12)) + assertEquals(listOf("foo"), GarminSerializer.deserialize(msg21)) + assertEquals(listOf("foo"), GarminSerializer.deserialize(msg22)) + messenger.onSendMessage(client1, device1.id, appId1, null) } } \ No newline at end of file diff --git a/plugins/sync/src/test/kotlin/app/aaps/plugins/sync/garmin/GarminSimulatorClientTest.kt b/plugins/sync/src/test/kotlin/app/aaps/plugins/sync/garmin/GarminSimulatorClientTest.kt index b713af0f05..997213304a 100644 --- a/plugins/sync/src/test/kotlin/app/aaps/plugins/sync/garmin/GarminSimulatorClientTest.kt +++ b/plugins/sync/src/test/kotlin/app/aaps/plugins/sync/garmin/GarminSimulatorClientTest.kt @@ -1,13 +1,10 @@ package app.aaps.plugins.sync.garmin import app.aaps.shared.tests.TestBase -import org.junit.jupiter.api.Assertions.assertNotNull +import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.Assertions.assertTrue import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.Test -import org.mockito.kotlin.any -import org.mockito.kotlin.argThat -import org.mockito.kotlin.doAnswer import org.mockito.kotlin.eq import org.mockito.kotlin.isNull import org.mockito.kotlin.mock @@ -19,36 +16,14 @@ import java.time.Duration class GarminSimulatorClientTest: TestBase() { - private var device: GarminDevice? = null - private var app: GarminApplication? = null private lateinit var client: GarminSimulatorClient - private val receiver: GarminReceiver = mock() { - on { onConnectDevice(any(), any(), any()) }.doAnswer { i -> - device = GarminDevice(client, i.getArgument(1), i.getArgument(2)) - app = GarminApplication( - client, device!!, client.iqApp.applicationID, client.iqApp.displayName) - } - } + private val receiver: GarminReceiver = mock() @BeforeEach fun setup() { client = GarminSimulatorClient(aapsLogger, receiver, 0) } - @Test - fun retrieveApplicationInfo() { - assertTrue(client.awaitReady(Duration.ofSeconds(10))) - val port = client.port - val ip = Inet4Address.getByAddress(byteArrayOf(127, 0, 0, 1)) - Socket(ip, port).use { socket -> - assertTrue(socket.isConnected) - verify(receiver).onConnect(client) - verify(receiver, timeout(1_000)).onConnectDevice(eq(client), any(), any()) - client.retrieveApplicationInfo(app!!.device, app!!.id, app!!.name!!) - } - verify(receiver).onApplicationInfo(app!!.device, app!!.id, true) - } - @Test fun receiveMessage() { val payload = "foo".toByteArray() @@ -60,11 +35,11 @@ class GarminSimulatorClientTest: TestBase() { socket.getOutputStream().write(payload) socket.getOutputStream().flush() verify(receiver).onConnect(client) - verify(receiver, timeout(1_000)).onConnectDevice(eq(client), any(), any()) } - verify(receiver, timeout(1_000)).onReceiveMessage( - eq(client), eq(device!!.id), eq("SimApp"), - argThat { p -> payload.contentEquals(p) }) + assertEquals(1, client.connectedDevices.size) + val device: GarminDevice = client.connectedDevices.first() + verify(receiver, timeout(1_000)) + .onReceiveMessage(eq(client), eq(device.id), eq("SIMAPP"), eq(payload)) } @Test @@ -73,14 +48,16 @@ class GarminSimulatorClientTest: TestBase() { assertTrue(client.awaitReady(Duration.ofSeconds(10))) val port = client.port val ip = Inet4Address.getByAddress(byteArrayOf(127, 0, 0, 1)) + val device: GarminDevice + val app: GarminApplication Socket(ip, port).use { socket -> assertTrue(socket.isConnected) verify(receiver).onConnect(client) - verify(receiver, timeout(1_000)).onConnectDevice(eq(client), any(), any()) - assertNotNull(device) - assertNotNull(app) - client.sendMessage(app!!, payload) + assertEquals(1, client.connectedDevices.size) + device = client.connectedDevices.first() + app = GarminApplication(device, "SIMAPP", "T") + client.sendMessage(app, payload) } - verify(receiver).onSendMessage(eq(client), any(), eq(app!!.id), isNull()) + verify(receiver, timeout(1_000)).onSendMessage(eq(client), eq(device.id), eq(app.id), isNull()) } } \ No newline at end of file From 35b3ce0300e498d71673d7ce5b80fcda58be6e8b Mon Sep 17 00:00:00 2001 From: Robert Buessow Date: Tue, 5 Dec 2023 15:37:30 +0100 Subject: [PATCH 4/4] pull upstream/dev --- .../src/main/kotlin/app/aaps/plugins/sync/garmin/GarminPlugin.kt | 1 - .../test/kotlin/app/aaps/plugins/sync/garmin/GarminPluginTest.kt | 1 - 2 files changed, 2 deletions(-) diff --git a/plugins/sync/src/main/kotlin/app/aaps/plugins/sync/garmin/GarminPlugin.kt b/plugins/sync/src/main/kotlin/app/aaps/plugins/sync/garmin/GarminPlugin.kt index 617e84cdc1..44b080b661 100644 --- a/plugins/sync/src/main/kotlin/app/aaps/plugins/sync/garmin/GarminPlugin.kt +++ b/plugins/sync/src/main/kotlin/app/aaps/plugins/sync/garmin/GarminPlugin.kt @@ -381,7 +381,6 @@ class GarminPlugin @Inject constructor( /** Returns glucose values in Nightscout/Xdrip format. */ @VisibleForTesting - @Suppress("UNUSED_PARAMETER") fun onSgv(uri: URI): CharSequence { val count = getQueryParameter(uri,"count", 24L) .toInt().coerceAtMost(1000).coerceAtLeast(1) diff --git a/plugins/sync/src/test/kotlin/app/aaps/plugins/sync/garmin/GarminPluginTest.kt b/plugins/sync/src/test/kotlin/app/aaps/plugins/sync/garmin/GarminPluginTest.kt index 8d4d6b2cc2..e1e9fa89a9 100644 --- a/plugins/sync/src/test/kotlin/app/aaps/plugins/sync/garmin/GarminPluginTest.kt +++ b/plugins/sync/src/test/kotlin/app/aaps/plugins/sync/garmin/GarminPluginTest.kt @@ -14,7 +14,6 @@ import org.junit.jupiter.api.Assertions.assertArrayEquals import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.Assertions.assertThrows import org.junit.jupiter.api.BeforeEach -import org.junit.jupiter.api.RepeatedTest import org.junit.jupiter.api.Test import org.mockito.ArgumentCaptor import org.mockito.ArgumentMatchers.anyBoolean