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 b6b885370c..81f4671523 100644 --- a/plugins/sync/build.gradle.kts +++ b/plugins/sync/build.gradle.kts @@ -30,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) @@ -49,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/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..6ff37f0a62 --- /dev/null +++ b/plugins/sync/src/main/kotlin/app/aaps/plugins/sync/garmin/GarminApplication.kt @@ -0,0 +1,32 @@ +package app.aaps.plugins.sync.garmin + +data class GarminApplication( + val device: GarminDevice, + val id: String, + val name: String?) { + + val client get() = device.client + + 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 + } + + 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 new file mode 100644 index 0000000000..6333f2ae82 --- /dev/null +++ b/plugins/sync/src/main/kotlin/app/aaps/plugins/sync/garmin/GarminClient.kt @@ -0,0 +1,16 @@ +package app.aaps.plugins.sync.garmin + +import io.reactivex.rxjava3.disposables.Disposable + +interface GarminClient: Disposable { + /** Name of the client. */ + val name: 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) +} \ 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..1df255d38a --- /dev/null +++ b/plugins/sync/src/main/kotlin/app/aaps/plugins/sync/garmin/GarminDevice.kt @@ -0,0 +1,36 @@ +package app.aaps.plugins.sync.garmin + +import com.garmin.android.connectiq.IQDevice + +data class GarminDevice( + val client: GarminClient, + val id: Long, + var name: String) { + + constructor(client: GarminClient, iqDevice: IQDevice): this( + client, + iqDevice.deviceIdentifier, + iqDevice.friendlyName) {} + + override fun toString(): String = "D[$name/$id]" + + fun toIQDevice() = IQDevice(id, name) + + 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..ec81ddb512 --- /dev/null +++ b/plugins/sync/src/main/kotlin/app/aaps/plugins/sync/garmin/GarminDeviceClient.kt @@ -0,0 +1,292 @@ +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.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.lang.Thread.UncaughtExceptionHandler +import java.time.Instant +import java.util.LinkedList +import java.util.Queue +import java.util.concurrent.Executors +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 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() { + synchronized (bindLock) { + 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 + bindService() + } + 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 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) + } + + 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 + 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) } + 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 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: Intent -> onReceiveMessage(iqApp, intent) } + ciqService?.registerApp(iqApp, 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 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)?.uppercase() + 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 + } + + var errorMessage: String? = null + when (status) { + 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 -> { + errorMessage = "error $status" + } + } + queue.poll() + 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 + val creation = Instant.now() + var lastAttempt: Instant? = null + val iqApp get() = IQApp(app.id, app.name, 0) + 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 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. + 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(msg.data, context.packageName, 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_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..feaf1743fc --- /dev/null +++ b/plugins/sync/src/main/kotlin/app/aaps/plugins/sync/garmin/GarminMessenger.kt @@ -0,0 +1,123 @@ +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 + +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() + 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["SimApp"] = "SimulatorApp" + GarminSimulatorClient(aapsLogger, this) + } + } + + private fun getDevice(client: GarminClient, deviceId: Long): GarminDevice { + synchronized (devices) { + 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 { + return GarminApplication(getDevice(client, deviceId), appId, appIdNames[appId]) + } + + 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 (devices) { + val deviceIds = devices.filter { (_, d) -> d.client == client }.map { (id, _) -> id } + deviceIds.forEach { id -> devices.remove(id) } + } + client.dispose() + when (client) { + is GarminDeviceClient -> startDeviceClient() + is GarminSimulatorClient -> GarminSimulatorClient(aapsLogger, this) + else -> aapsLogger.warn(LTag.GARMIN, "onDisconnect unknown client $client") + } + } + + 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) { + appIdNames.forEach { (appId, _) -> + sendMessage(getApplication(device.client, device.id, appId), msg) + } + } + + /** Sends a message to all applications on all devices. */ + fun sendMessage(msg: Any) { + clients.forEach { cl -> cl.connectedDevices.forEach { d -> sendMessage(d, 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 ${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 1a37a3ae4b..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 @@ -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 @@ -22,6 +23,7 @@ import io.reactivex.rxjava3.schedulers.Schedulers import java.math.BigDecimal import java.math.MathContext import java.math.RoundingMode +import java.net.HttpURLConnection import java.net.SocketAddress import java.net.URI import java.time.Clock @@ -46,6 +48,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, @@ -61,7 +64,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 @@ -72,10 +87,24 @@ 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() { @@ -87,20 +116,34 @@ class GarminPlugin @Inject constructor( .observeOn(Schedulers.io()) .subscribe(::onPreferenceChange) ) + disposable.add( + rxBus + .toObservable(EventNewBG::class.java) + .observeOn(Schedulers.io()) + .subscribe(::onNewBloodGlucose) + ) setupHttpServer() + if (garminAapsKey.isNotEmpty()) + setupGarminMessenger() } private fun setupHttpServer() { + setupHttpServer(Duration.ZERO) + } + + @VisibleForTesting + fun setupHttpServer(wait: Duration) { 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("/sgv.json", ::onSgv) + registerEndpoint("/get", requestHandler(::onGetBloodGlucose)) + registerEndpoint("/carbs", requestHandler(::onPostCarbs)) + registerEndpoint("/connect", requestHandler(::onConnectPump)) + registerEndpoint("/connect", requestHandler(::onSgv)) + awaitReady(wait) } } else if (server != null) { aapsLogger.info(LTag.GARMIN, "stopping HTTP server") @@ -109,7 +152,7 @@ class GarminPlugin @Inject constructor( } } - override fun onStop() { + public override fun onStop() { disposable.clear() aapsLogger.info(LTag.GARMIN, "Stop") server?.close() @@ -133,6 +176,36 @@ class GarminPlugin @Inject constructor( } } + @VisibleForTesting + fun onConnectDevice(device: GarminDevice) { + if (garminAapsKey.isNotEmpty()) { + aapsLogger.info(LTag.GARMIN, "onConnectDevice $device sending glucose") + 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 { @@ -166,21 +239,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) @@ -194,9 +279,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 ?: "") @@ -228,6 +311,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() @@ -242,20 +338,18 @@ 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) - } else { + } else if (avg > 0) { aapsLogger.warn(LTag.GARMIN, "Skip saving invalid HR $avg $samplingStart..$samplingEnd") } } /** 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 "" } @@ -268,9 +362,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) @@ -289,8 +381,7 @@ class GarminPlugin @Inject constructor( /** Returns glucose values in Nightscout/Xdrip format. */ @VisibleForTesting - @Suppress("UNUSED_PARAMETER") - fun onSgv(call: SocketAddress, uri: URI, requestBody: String?): CharSequence { + fun onSgv(uri: URI): CharSequence { val count = getQueryParameter(uri,"count", 24L) .toInt().coerceAtMost(1000).coerceAtLeast(1) val briefMode = getQueryParameter(uri, "brief_mode", false) 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..e8e93bac51 --- /dev/null +++ b/plugins/sync/src/main/kotlin/app/aaps/plugins/sync/garmin/GarminReceiver.kt @@ -0,0 +1,23 @@ +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) + + /** + * 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..75f8d8e436 --- /dev/null +++ b/plugins/sync/src/main/kotlin/app/aaps/plugins/sync/garmin/GarminSimulatorClient.kt @@ -0,0 +1,181 @@ +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 +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("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}") + + fun start() { + executor.execute { + try { + 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}") + break + } + } + aapsLogger.info(LTag.GARMIN, "disconnect ${device.name}" ) + connections.remove(this) + } + + 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 + + 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..3497e4c900 --- /dev/null +++ b/plugins/sync/src/test/kotlin/app/aaps/plugins/sync/garmin/GarminMessengerTest.kt @@ -0,0 +1,110 @@ +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.doAnswer +import org.mockito.kotlin.doReturn +import org.mockito.kotlin.mock +import org.mockito.kotlin.stub +import org.mockito.kotlin.verify + +class GarminMessengerTest: TestBase() { + private val context = mock() + + private var appId1 = "appId1" + private val appId2 = "appId2" + + private val apps = mapOf(appId1 to "$appId1-name", appId2 to "$appId2-name") + private val outMessages = mutableListOf>() + private val inMessages = mutableListOf>() + private var messenger = GarminMessenger( + 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 onDisconnect() { + messenger.onDisconnect(client1) + 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, device1.id, appId1, data) + val (app, payload) = inMessages.removeAt(0) + assertEquals(appId1, app.id) + assertEquals("foo", payload) + } + + @Test + fun sendMessageDevice() { + 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, device1.id, appId1, null) + } + + @Test + fun onSendMessageAll() { + messenger.sendMessage(listOf("foo")) + 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/GarminPluginTest.kt b/plugins/sync/src/test/kotlin/app/aaps/plugins/sync/garmin/GarminPluginTest.kt index 447788146e..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 @@ -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,14 @@ 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.Mock import org.mockito.Mockito.atMost import org.mockito.Mockito.mock @@ -25,9 +31,12 @@ import org.mockito.kotlin.any import org.mockito.kotlin.atLeastOnce import org.mockito.kotlin.eq import org.mockito.kotlin.whenever +import java.net.ConnectException +import java.net.HttpURLConnection import java.net.SocketAddress import java.net.URI import java.time.Clock +import java.time.Duration import java.time.Instant import java.time.ZoneId import java.time.temporal.ChronoUnit @@ -39,6 +48,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")) @@ -49,9 +59,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 @@ -86,6 +101,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) @@ -129,6 +155,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(Duration.ofSeconds(10)) + 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(Duration.ofSeconds(10)) + 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(Duration.ofSeconds(10)) + 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(Duration.ofSeconds(10)) + 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), 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) @@ -139,7 +291,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," + @@ -171,7 +323,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," + @@ -194,7 +346,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) } @@ -202,7 +354,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 } @@ -211,7 +363,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 } @@ -220,7 +372,7 @@ class GarminPluginTest: TestBase() { fun onSgv_NoGlucose() { whenever(loopHub.glucoseUnit).thenReturn(GlucoseUnit.MMOL) whenever(loopHub.getGlucoseValues(any(), eq(false))).thenReturn(emptyList()) - assertEquals("[]", gp.onSgv(mock(), createUri(mapOf()), null)) + assertEquals("[]", gp.onSgv(createUri(mapOf()))) verify(loopHub).getGlucoseValues(clock.instant().minusSeconds(25L * 300L), false) } @@ -236,7 +388,7 @@ fun onSgv_NoDelta() { clock.instant().minusSeconds(100L), 99.3))) assertEquals( """[{"_id":"-900000","device":"RANDOM","deviceString":"1969-12-31T23:58:30Z","sysTime":"1969-12-31T23:58:30Z","unfiltered":90.0,"date":-90000,"sgv":99,"direction":"Flat","noise":4.5,"units_hint":"mmol","iob":5.2,"tbr":80,"cob":10.7}]""", - gp.onSgv(mock(), createUri(mapOf()), null)) + gp.onSgv(createUri(mapOf()))) verify(loopHub).getGlucoseValues(clock.instant().minusSeconds(25L * 300L), false) verify(loopHub).glucoseUnit } @@ -255,7 +407,7 @@ fun onSgv_NoDelta() { .mapIndexed { idx, ts -> createGlucoseValue(ts, 100.0+(10 * idx)) }.reversed()} assertEquals( """[{"_id":"100000","device":"RANDOM","deviceString":"1970-01-01T00:00:10Z","sysTime":"1970-01-01T00:00:10Z","unfiltered":90.0,"date":10000,"sgv":120,"delta":10,"direction":"Flat","noise":4.5,"units_hint":"mmol","iob":5.2,"tbr":80,"cob":10.7}]""", - gp.onSgv(mock(), createUri(mapOf("count" to "1")), null)) + gp.onSgv(createUri(mapOf("count" to "1")))) verify(loopHub).getGlucoseValues( clock.instant().minusSeconds(600L), false) @@ -263,14 +415,14 @@ fun onSgv_NoDelta() { assertEquals( """[{"_id":"100000","device":"RANDOM","deviceString":"1970-01-01T00:00:10Z","sysTime":"1970-01-01T00:00:10Z","unfiltered":90.0,"date":10000,"sgv":130,"delta":10,"direction":"Flat","noise":4.5,"units_hint":"mmol","iob":5.2,"tbr":80,"cob":10.7},""" + """{"_id":"-2900000","device":"RANDOM","deviceString":"1969-12-31T23:55:10Z","sysTime":"1969-12-31T23:55:10Z","unfiltered":90.0,"date":-290000,"sgv":120,"delta":10,"direction":"Flat","noise":4.5}]""", - gp.onSgv(mock(), createUri(mapOf("count" to "2")), null)) + gp.onSgv(createUri(mapOf("count" to "2")))) verify(loopHub).getGlucoseValues( clock.instant().minusSeconds(900L), false) assertEquals( """[{"date":10000,"sgv":130,"delta":10,"direction":"Flat","noise":4.5,"units_hint":"mmol","iob":5.2,"tbr":80,"cob":10.7},""" + """{"date":-290000,"sgv":120,"delta":10,"direction":"Flat","noise":4.5}]""", - gp.onSgv(mock(), createUri(mapOf("count" to "2", "brief_mode" to "true")), null)) + gp.onSgv(createUri(mapOf("count" to "2", "brief_mode" to "true")))) verify(loopHub, times(2)).getGlucoseValues( clock.instant().minusSeconds(900L), false) 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..997213304a --- /dev/null +++ b/plugins/sync/src/test/kotlin/app/aaps/plugins/sync/garmin/GarminSimulatorClientTest.kt @@ -0,0 +1,63 @@ +package app.aaps.plugins.sync.garmin + +import app.aaps.shared.tests.TestBase +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.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 lateinit var client: GarminSimulatorClient + private val receiver: GarminReceiver = mock() + + @BeforeEach + fun setup() { + client = GarminSimulatorClient(aapsLogger, receiver, 0) + } + + @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) + } + 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 + 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)) + val device: GarminDevice + val app: GarminApplication + Socket(ip, port).use { socket -> + assertTrue(socket.isConnected) + verify(receiver).onConnect(client) + assertEquals(1, client.connectedDevices.size) + device = client.connectedDevices.first() + app = GarminApplication(device, "SIMAPP", "T") + client.sendMessage(app, payload) + } + verify(receiver, timeout(1_000)).onSendMessage(eq(client), eq(device.id), 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