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