diff --git a/.gitignore b/.gitignore index 824c0a963..1cd0cfb1c 100644 --- a/.gitignore +++ b/.gitignore @@ -4,3 +4,4 @@ .kotlin/ build/ local.properties +repo diff --git a/build.gradle.kts b/build.gradle.kts index 724fbc69c..34a1a7ec9 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -13,6 +13,9 @@ plugins { alias(libs.plugins.atomicfu) apply false alias(libs.plugins.dokka) alias(libs.plugins.api) + alias(libs.plugins.kotlin.android) apply false + alias(libs.plugins.android.application) apply false + alias(libs.plugins.compose.compiler) apply false } tasks.dokkaHtmlMultiModule.configure { diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index e32e6657d..ba9bf84d1 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -6,6 +6,16 @@ coroutines = "1.8.1" jvm-toolchain = "11" kotlin = "2.0.20" tuulbox = "8.0.0" +kotlin-version = "1.9.0" +junit = "4.13.2" +junit-version = "1.2.1" +espresso-core = "3.6.1" +appcompat = "1.7.0" +material = "1.12.0" +agp = "8.5.1" +lifecycle-runtime-ktx = "2.8.6" +activity-compose = "1.9.2" +compose-bom = "2024.04.01" [libraries] androidx-core = { module = "androidx.core:core-ktx", version = "1.13.1" } @@ -22,12 +32,30 @@ tuulbox-coroutines = { module = "com.juul.tuulbox:coroutines", version.ref = "tu uuid = { module = "com.benasher44:uuid", version = "0.8.4" } wrappers-bom = { module = "org.jetbrains.kotlin-wrappers:kotlin-wrappers-bom", version = "1.0.0-pre.814" } wrappers-web = { module = "org.jetbrains.kotlin-wrappers:kotlin-web" } +junit = { group = "junit", name = "junit", version.ref = "junit" } +androidx-junit = { group = "androidx.test.ext", name = "junit", version.ref = "junit-version" } +androidx-espresso-core = { group = "androidx.test.espresso", name = "espresso-core", version.ref = "espresso-core" } +androidx-appcompat = { group = "androidx.appcompat", name = "appcompat", version.ref = "appcompat" } +material = { group = "com.google.android.material", name = "material", version.ref = "material" } +androidx-lifecycle-runtime-ktx = { group = "androidx.lifecycle", name = "lifecycle-runtime-ktx", version.ref = "lifecycle-runtime-ktx" } +androidx-activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activity-compose" } +androidx-compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "compose-bom" } +androidx-ui = { group = "androidx.compose.ui", name = "ui" } +androidx-ui-graphics = { group = "androidx.compose.ui", name = "ui-graphics" } +androidx-ui-tooling = { group = "androidx.compose.ui", name = "ui-tooling" } +androidx-ui-tooling-preview = { group = "androidx.compose.ui", name = "ui-tooling-preview" } +androidx-ui-test-manifest = { group = "androidx.compose.ui", name = "ui-test-manifest" } +androidx-ui-test-junit4 = { group = "androidx.compose.ui", name = "ui-test-junit4" } +androidx-material3 = { group = "androidx.compose.material3", name = "material3" } [plugins] -android-library = { id = "com.android.library", version = "8.6.1" } +android-library = { id = "com.android.library", version = "8.5.1" } api = { id = "org.jetbrains.kotlinx.binary-compatibility-validator", version = "0.16.3" } atomicfu = { id = "org.jetbrains.kotlinx.atomicfu", version.ref = "atomicfu" } dokka = { id = "org.jetbrains.dokka", version = "1.9.20" } kotlin-multiplatform = { id = "org.jetbrains.kotlin.multiplatform", version.ref = "kotlin" } kotlinter = { id = "org.jmailen.kotlinter", version = "4.4.1" } maven-publish = { id = "com.vanniktech.maven.publish", version = "0.29.0" } +kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin-version" } +android-application = { id = "com.android.application", version.ref = "agp" } +compose-compiler = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" } diff --git a/kable-core/build.gradle.kts b/kable-core/build.gradle.kts index 749163e35..6789fb3bb 100644 --- a/kable-core/build.gradle.kts +++ b/kable-core/build.gradle.kts @@ -9,6 +9,15 @@ plugins { id("com.vanniktech.maven.publish") } +publishing { + repositories { + maven { + name = "LocalRepo" + url = uri(project.rootDir.resolve("repo").absolutePath) + } + } +} + kotlin { explicitApi() jvmToolchain(libs.versions.jvm.toolchain.get().toInt()) diff --git a/kable-core/src/androidMain/kotlin/AndroidPeripheral.kt b/kable-core/src/androidMain/kotlin/AndroidPeripheral.kt index e59d6c544..30610084e 100644 --- a/kable-core/src/androidMain/kotlin/AndroidPeripheral.kt +++ b/kable-core/src/androidMain/kotlin/AndroidPeripheral.kt @@ -7,6 +7,7 @@ import android.bluetooth.BluetoothGatt import android.bluetooth.BluetoothStatusCodes import android.os.Build import androidx.annotation.RequiresPermission +import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.StateFlow @Deprecated( @@ -160,4 +161,11 @@ public interface AndroidPeripheral : Peripheral { * is negotiated. */ public val mtu: StateFlow + + + + /** + * [Flow] of the most recent [BondState] of the [AndroidPeripheral]. + */ + public val bondState: Flow } diff --git a/kable-core/src/androidMain/kotlin/BluetoothDeviceAndroidPeripheral.kt b/kable-core/src/androidMain/kotlin/BluetoothDeviceAndroidPeripheral.kt index eb0bc7375..43dc0c6b3 100644 --- a/kable-core/src/androidMain/kotlin/BluetoothDeviceAndroidPeripheral.kt +++ b/kable-core/src/androidMain/kotlin/BluetoothDeviceAndroidPeripheral.kt @@ -3,10 +3,13 @@ package com.juul.kable import android.bluetooth.BluetoothAdapter.STATE_OFF import android.bluetooth.BluetoothAdapter.STATE_TURNING_OFF import android.bluetooth.BluetoothDevice +import android.bluetooth.BluetoothDevice.ACTION_BOND_STATE_CHANGED import android.bluetooth.BluetoothDevice.DEVICE_TYPE_CLASSIC import android.bluetooth.BluetoothDevice.DEVICE_TYPE_DUAL import android.bluetooth.BluetoothDevice.DEVICE_TYPE_LE import android.bluetooth.BluetoothDevice.DEVICE_TYPE_UNKNOWN +import android.bluetooth.BluetoothDevice.EXTRA_BOND_STATE +import android.bluetooth.BluetoothDevice.EXTRA_DEVICE import android.bluetooth.BluetoothGatt import android.bluetooth.BluetoothGattCharacteristic.PROPERTY_INDICATE import android.bluetooth.BluetoothGattCharacteristic.PROPERTY_NOTIFY @@ -15,6 +18,8 @@ import android.bluetooth.BluetoothGattCharacteristic.WRITE_TYPE_NO_RESPONSE import android.bluetooth.BluetoothGattDescriptor.DISABLE_NOTIFICATION_VALUE import android.bluetooth.BluetoothGattDescriptor.ENABLE_INDICATION_VALUE import android.bluetooth.BluetoothGattDescriptor.ENABLE_NOTIFICATION_VALUE +import android.content.IntentFilter +import androidx.core.content.IntentCompat import com.juul.kable.AndroidPeripheral.Priority import com.juul.kable.AndroidPeripheral.Type import com.juul.kable.State.Disconnected @@ -37,9 +42,22 @@ import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.filter import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.onEach import kotlin.coroutines.cancellation.CancellationException import kotlin.time.Duration +import android.bluetooth.BluetoothDevice.ERROR +import android.bluetooth.BluetoothDevice.BOND_BONDED +import android.bluetooth.BluetoothDevice.BOND_BONDING +import android.bluetooth.BluetoothDevice.BOND_NONE +import androidx.core.content.ContextCompat +import com.juul.tuulbox.coroutines.flow.broadcastReceiverFlow +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.channels.BufferOverflow +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.launch // Number of service discovery attempts to make if no services are discovered. // https://github.com/JuulLabs/kable/issues/295 @@ -57,6 +75,15 @@ internal class BluetoothDeviceAndroidPeripheral( private val disconnectTimeout: Duration, ) : BasePeripheral(bluetoothDevice.toString()), AndroidPeripheral { + private fun mapBondState(state: Int): PlatformAdvertisement.BondState { + return when (state) { + BOND_NONE -> PlatformAdvertisement.BondState.None + BOND_BONDING -> PlatformAdvertisement.BondState.Bonding + BOND_BONDED -> PlatformAdvertisement.BondState.Bonded + else -> error("Unsupported bond state: $state") + } + } + init { onBluetoothDisabled { state -> logger.debug { @@ -65,9 +92,36 @@ internal class BluetoothDeviceAndroidPeripheral( } disconnect() } + + this.launch(Dispatchers.Main) { + broadcastReceiverFlow( + IntentFilter(ACTION_BOND_STATE_CHANGED), + flags = ContextCompat.RECEIVER_EXPORTED + ) + .onEach { + logger.debug { message = "Bond state changed ${it.data}" } + } + .filter { intent -> + bluetoothDevice == IntentCompat.getParcelableExtra( + intent, + EXTRA_DEVICE, + BluetoothDevice::class.java, + ) + } + .map { intent -> intent.getIntExtra(EXTRA_BOND_STATE, ERROR) } + .map { + mapBondState(it) + }.onEach { + logger.debug { message = "Bond state changed" } + } + .collect { + logger.debug { message = "Bond state collected" } + _bondState.tryEmit(it) + } + + } } - private val connectAction = sharedRepeatableAction(::establishConnection) override val identifier: String = bluetoothDevice.address private val logger = Logger(logging, "Kable/Peripheral", bluetoothDevice.toString()) @@ -98,9 +152,27 @@ internal class BluetoothDeviceAndroidPeripheral( override val name: String? get() = bluetoothDevice.name - private suspend fun establishConnection(scope: CoroutineScope): CoroutineScope { - checkBluetoothIsOn() + val _bondState = MutableSharedFlow( + replay = 1, + extraBufferCapacity = 2, + onBufferOverflow = BufferOverflow.DROP_OLDEST, + ) + + //Init with the initial state + override val bondState: Flow = + _bondState.asSharedFlow() + + + /** + * While connection exists, we will listen for bond state changes + * That way we can update the bond state and avoid race conditions + */ + private suspend fun establishConnection( + scope: CoroutineScope, + waitBonding: Boolean, + ): CoroutineScope { + checkBluetoothIsOn() logger.info { message = "Connecting" } _state.value = State.Connecting.Bluetooth @@ -120,6 +192,12 @@ internal class BluetoothDeviceAndroidPeripheral( disconnectTimeout, ) ?: throw ConnectionRejectedException() + if (waitBonding) { + logger.debug { message = "Awaiting bond state" } + val bond = bondState.first { it != PlatformAdvertisement.BondState.Bonded } + logger.debug { message = "Bond state: $bond" } + } + suspendUntil() discoverServices() configureCharacteristicObservations() @@ -141,11 +219,13 @@ internal class BluetoothDeviceAndroidPeripheral( observers.onConnected() } - override suspend fun connect(): CoroutineScope = - connectAction.await() + override suspend fun connect(waitBonding: Boolean): CoroutineScope = + sharedRepeatableAction { scope -> + establishConnection(scope, waitBonding) + }.await() override suspend fun disconnect() { - connectAction.cancelAndJoin( + sharedRepeatableAction {}.cancelAndJoin( CancellationException(NotConnectedException("Disconnect requested")), ) } @@ -194,8 +274,20 @@ internal class BluetoothDeviceAndroidPeripheral( } val platformCharacteristic = servicesOrThrow().obtain(characteristic, writeType.properties) - connectionOrThrow().execute { - writeCharacteristicOrThrow(platformCharacteristic, data, writeType.intValue) + try { + connectionOrThrow().execute { + writeCharacteristicOrThrow(platformCharacteristic, data, writeType.intValue) + } + } catch (_: BondRequiredException) { + awaitBond() + logger.debug { + message = "Retrying write" + detail(platformCharacteristic) + detail(data, Operation.Write) + } + connectionOrThrow().execute { + writeCharacteristicOrThrow(platformCharacteristic, data, writeType.intValue) + } } } @@ -206,11 +298,21 @@ internal class BluetoothDeviceAndroidPeripheral( message = "read" detail(characteristic) } - val platformCharacteristic = servicesOrThrow().obtain(characteristic, Read) - return connectionOrThrow().execute { - readCharacteristicOrThrow(platformCharacteristic) - }.value!! + return try { + return connectionOrThrow().execute { + readCharacteristicOrThrow(platformCharacteristic) + }.value!! + } catch (_: BondRequiredException) { + awaitBond() + logger.debug { + message = "Retrying read" + detail(platformCharacteristic) + } + connectionOrThrow().execute { + readCharacteristicOrThrow(platformCharacteristic) + }.value!! + } } override suspend fun write( @@ -249,6 +351,12 @@ internal class BluetoothDeviceAndroidPeripheral( }.value!! } + private suspend fun awaitBond() { + logger.warn { message = "Insufficient authentication, awaiting bond" } + bondState.first { it == PlatformAdvertisement.BondState.Bonded } + logger.debug { message = "Bond established" } + } + override fun observe( characteristic: Characteristic, onSubscription: OnSubscriptionAction, @@ -307,6 +415,7 @@ internal class BluetoothDeviceAndroidPeripheral( } write(configDescriptor, ENABLE_NOTIFICATION_VALUE) } + characteristic.supportsIndicate -> { logger.verbose { message = "Writing ENABLE_INDICATION_VALUE to CCCD" @@ -314,6 +423,7 @@ internal class BluetoothDeviceAndroidPeripheral( } write(configDescriptor, ENABLE_INDICATION_VALUE) } + else -> logger.warn { message = "Characteristic supports neither notification nor indication" detail(characteristic) diff --git a/kable-core/src/androidMain/kotlin/Connection.kt b/kable-core/src/androidMain/kotlin/Connection.kt index 1d069455e..2da03def6 100644 --- a/kable-core/src/androidMain/kotlin/Connection.kt +++ b/kable-core/src/androidMain/kotlin/Connection.kt @@ -1,7 +1,10 @@ package com.juul.kable import android.bluetooth.BluetoothGatt +import android.bluetooth.BluetoothGatt.GATT_INSUFFICIENT_AUTHENTICATION +import android.bluetooth.BluetoothGatt.GATT_INSUFFICIENT_ENCRYPTION import android.bluetooth.BluetoothGatt.GATT_SUCCESS +import com.juul.kable.external.GATT_AUTH_FAIL import android.os.Handler import com.juul.kable.State.Disconnected import com.juul.kable.coroutines.childSupervisor @@ -43,6 +46,13 @@ import kotlin.time.Duration import kotlin.time.Duration.Companion.ZERO private val GattSuccess = GattStatus(GATT_SUCCESS) +private val BondingStatuses = listOf( + GattStatus(GATT_AUTH_FAIL), + GattStatus(GATT_INSUFFICIENT_AUTHENTICATION), + GattStatus(GATT_INSUFFICIENT_ENCRYPTION), +) + +internal class BondRequiredException : IllegalStateException() /** * Represents a Bluetooth Low Energy connection. [Connection] should be initialized with the @@ -178,8 +188,10 @@ internal class Connection( coroutineContext.ensureActive() throw e.unwrapCancellationException() } - }.also(::checkResponse) - + } + .also { + checkResponse(it.status) + } // `guard` should always enforce a 1:1 matching of request-to-response, but if an Android // `BluetoothGattCallback` method is called out-of-order then we'll cast to the wrong type. return response as? T @@ -207,7 +219,10 @@ internal class Connection( coroutineContext.ensureActive() throw e.unwrapCancellationException() } - }.also(::checkResponse).mtu + } + .also { + checkResponse(it.status) + }.mtu private suspend fun disconnect() { if (callback.state.value is Disconnected) return @@ -225,7 +240,9 @@ internal class Connection( } logger.info { message = "Disconnected" } } catch (e: TimeoutCancellationException) { - logger.warn { message = "Timed out after $disconnectTimeout waiting for disconnect" } + logger.warn { + message = "Timed out after $disconnectTimeout waiting for disconnect" + } } } } @@ -271,6 +288,10 @@ internal class Connection( private fun dispose(cause: Throwable) = connectionJob.completeExceptionally(cause) } -private fun checkResponse(response: Response) { - if (response.status != GattSuccess) throw GattStatusException(response.toString()) +internal fun checkResponse(response: GattStatus) { + when (response) { + GattSuccess -> return + in BondingStatuses -> throw BondRequiredException() + else -> throw GattStatusException(response.toString()) + } } diff --git a/kable-core/src/appleMain/kotlin/CBPeripheralCoreBluetoothPeripheral.kt b/kable-core/src/appleMain/kotlin/CBPeripheralCoreBluetoothPeripheral.kt index aa1dfb59a..13ebb337e 100644 --- a/kable-core/src/appleMain/kotlin/CBPeripheralCoreBluetoothPeripheral.kt +++ b/kable-core/src/appleMain/kotlin/CBPeripheralCoreBluetoothPeripheral.kt @@ -135,7 +135,7 @@ internal class CBPeripheralCoreBluetoothPeripheral( observers.onConnected() } - override suspend fun connect(): CoroutineScope = + override suspend fun connect(waitBonding: Boolean): CoroutineScope = connectAction.await() override suspend fun disconnect() { diff --git a/kable-core/src/commonMain/kotlin/Peripheral.kt b/kable-core/src/commonMain/kotlin/Peripheral.kt index 34a61954d..bef02124e 100644 --- a/kable-core/src/commonMain/kotlin/Peripheral.kt +++ b/kable-core/src/commonMain/kotlin/Peripheral.kt @@ -122,7 +122,7 @@ public interface Peripheral : CoroutineScope { * @throws ConnectionRejectedException when a connection request is rejected by the system (e.g. bluetooth hardware unavailable). * @throws CancellationException if [Peripheral]'s [CoroutineScope] has been [cancelled][Peripheral.cancel]. */ - public suspend fun connect(): CoroutineScope + public suspend fun connect(waitBonding: Boolean = false): CoroutineScope /** * Disconnects the active connection, or cancels an in-flight [connection][connect] attempt, diff --git a/kable-core/src/jsMain/kotlin/BluetoothDeviceWebBluetoothPeripheral.kt b/kable-core/src/jsMain/kotlin/BluetoothDeviceWebBluetoothPeripheral.kt index 9d0adc837..06d8355d6 100644 --- a/kable-core/src/jsMain/kotlin/BluetoothDeviceWebBluetoothPeripheral.kt +++ b/kable-core/src/jsMain/kotlin/BluetoothDeviceWebBluetoothPeripheral.kt @@ -105,7 +105,7 @@ internal class BluetoothDeviceWebBluetoothPeripheral( observers.onConnected() } - override suspend fun connect(): CoroutineScope = + override suspend fun connect(waitBonding:Boolean): CoroutineScope = connectAction.await() override suspend fun disconnect() { diff --git a/kable-exceptions/build.gradle.kts b/kable-exceptions/build.gradle.kts index 113ada399..874497096 100644 --- a/kable-exceptions/build.gradle.kts +++ b/kable-exceptions/build.gradle.kts @@ -5,6 +5,16 @@ plugins { id("com.vanniktech.maven.publish") } + +publishing { + repositories { + maven { + name = "LocalRepo" + url = uri(project.rootDir.resolve("repo").absolutePath) + } + } +} + kotlin { explicitApi() jvmToolchain(libs.versions.jvm.toolchain.get().toInt()) diff --git a/kable-log-engine-khronicle/build.gradle.kts b/kable-log-engine-khronicle/build.gradle.kts index c93aba4bd..36a8a5505 100644 --- a/kable-log-engine-khronicle/build.gradle.kts +++ b/kable-log-engine-khronicle/build.gradle.kts @@ -5,6 +5,16 @@ plugins { id("com.vanniktech.maven.publish") } + + +publishing { + repositories { + maven { + name = "LocalRepo" + url = uri(project.rootDir.resolve("repo").absolutePath) + } + } +} kotlin { explicitApi() jvmToolchain(libs.versions.jvm.toolchain.get().toInt()) diff --git a/sample/android-sample/.gitignore b/sample/android-sample/.gitignore new file mode 100644 index 000000000..42afabfd2 --- /dev/null +++ b/sample/android-sample/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/sample/android-sample/build.gradle.kts b/sample/android-sample/build.gradle.kts new file mode 100644 index 000000000..11b791fc5 --- /dev/null +++ b/sample/android-sample/build.gradle.kts @@ -0,0 +1,72 @@ +plugins { + alias(libs.plugins.android.application) + alias(libs.plugins.kotlin.android) + alias(libs.plugins.compose.compiler) +} + +android { + namespace = "com.kable.androidsample" + compileSdk = 34 + + defaultConfig { + applicationId = "com.kable.androidsample" + minSdk = 24 + targetSdk = 34 + versionCode = 1 + versionName = "1.0" + + testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + vectorDrawables { + useSupportLibrary = true + } + } + + buildTypes { + release { + isMinifyEnabled = false + proguardFiles( + getDefaultProguardFile("proguard-android-optimize.txt"), + "proguard-rules.pro" + ) + } + } + compileOptions { + sourceCompatibility = JavaVersion.VERSION_1_8 + targetCompatibility = JavaVersion.VERSION_1_8 + } + kotlinOptions { + jvmTarget = "1.8" + } + buildFeatures { + compose = true + } + composeOptions { + kotlinCompilerExtensionVersion = "1.5.1" + } + packaging { + resources { + excludes += "/META-INF/{AL2.0,LGPL2.1}" + } + } +} + +dependencies { + + implementation(libs.androidx.core) + implementation(libs.androidx.lifecycle.runtime.ktx) + implementation(libs.androidx.activity.compose) + implementation(platform(libs.androidx.compose.bom)) + implementation(libs.androidx.ui) + implementation(libs.androidx.ui.graphics) + implementation(libs.androidx.ui.tooling.preview) + implementation(libs.androidx.material3) + testImplementation(libs.junit) + androidTestImplementation(libs.androidx.junit) + androidTestImplementation(libs.androidx.espresso.core) + androidTestImplementation(platform(libs.androidx.compose.bom)) + androidTestImplementation(libs.androidx.ui.test.junit4) + debugImplementation(libs.androidx.ui.tooling) + debugImplementation(libs.androidx.ui.test.manifest) + + implementation(project(":kable-core")) +} \ No newline at end of file diff --git a/sample/android-sample/proguard-rules.pro b/sample/android-sample/proguard-rules.pro new file mode 100644 index 000000000..481bb4348 --- /dev/null +++ b/sample/android-sample/proguard-rules.pro @@ -0,0 +1,21 @@ +# Add project specific ProGuard rules here. +# You can control the set of applied configuration files using the +# proguardFiles setting in build.gradle. +# +# For more details, see +# http://developer.android.com/guide/developing/tools/proguard.html + +# If your project uses WebView with JS, uncomment the following +# and specify the fully qualified class name to the JavaScript interface +# class: +#-keepclassmembers class fqcn.of.javascript.interface.for.webview { +# public *; +#} + +# Uncomment this to preserve the line number information for +# debugging stack traces. +#-keepattributes SourceFile,LineNumberTable + +# If you keep the line number information, uncomment this to +# hide the original source file name. +#-renamesourcefileattribute SourceFile \ No newline at end of file diff --git a/sample/android-sample/src/androidTest/java/com/kable/androidsample/ExampleInstrumentedTest.kt b/sample/android-sample/src/androidTest/java/com/kable/androidsample/ExampleInstrumentedTest.kt new file mode 100644 index 000000000..64fae130d --- /dev/null +++ b/sample/android-sample/src/androidTest/java/com/kable/androidsample/ExampleInstrumentedTest.kt @@ -0,0 +1,24 @@ +package com.kable.androidsample + +import androidx.test.platform.app.InstrumentationRegistry +import androidx.test.ext.junit.runners.AndroidJUnit4 + +import org.junit.Test +import org.junit.runner.RunWith + +import org.junit.Assert.* + +/** + * Instrumented test, which will execute on an Android device. + * + * See [testing documentation](http://d.android.com/tools/testing). + */ +@RunWith(AndroidJUnit4::class) +class ExampleInstrumentedTest { + @Test + fun useAppContext() { + // Context of the app under test. + val appContext = InstrumentationRegistry.getInstrumentation().targetContext + assertEquals("com.kable.androidsample", appContext.packageName) + } +} \ No newline at end of file diff --git a/sample/android-sample/src/main/AndroidManifest.xml b/sample/android-sample/src/main/AndroidManifest.xml new file mode 100644 index 000000000..a25263034 --- /dev/null +++ b/sample/android-sample/src/main/AndroidManifest.xml @@ -0,0 +1,29 @@ + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/sample/android-sample/src/main/java/com/kable/androidsample/MainActivity.kt b/sample/android-sample/src/main/java/com/kable/androidsample/MainActivity.kt new file mode 100644 index 000000000..b8de12c74 --- /dev/null +++ b/sample/android-sample/src/main/java/com/kable/androidsample/MainActivity.kt @@ -0,0 +1,117 @@ +package com.kable.androidsample + +import android.content.pm.PackageManager +import android.os.Bundle +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.activity.enableEdgeToEdge +import androidx.activity.result.contract.ActivityResultContracts +import androidx.activity.viewModels +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.core.content.ContextCompat +import com.kable.androidsample.ui.ConnectedView +import com.kable.androidsample.ui.ScannerView +import com.kable.androidsample.ui.theme.KableTheme + +class MainActivity : ComponentActivity() { + + private val requestPermissionLauncher = registerForActivityResult( + ActivityResultContracts.RequestMultiplePermissions(), + ) { permissions -> + permissions.entries.forEach { + val isGranted = it.value + if (!isGranted) { + vm.permissionGranted() + } + } + } + private val vm: MainViewModel by viewModels() + + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + enableEdgeToEdge() + + + // Check and request permissions + if (ContextCompat.checkSelfPermission( + this, + android.Manifest.permission.BLUETOOTH, + ) != PackageManager.PERMISSION_GRANTED || + ContextCompat.checkSelfPermission( + this, + android.Manifest.permission.BLUETOOTH_SCAN, + ) != PackageManager.PERMISSION_GRANTED || + ContextCompat.checkSelfPermission( + this, + android.Manifest.permission.BLUETOOTH_CONNECT, + ) != PackageManager.PERMISSION_GRANTED + ) { + requestPermissionLauncher.launch( + arrayOf( + android.Manifest.permission.BLUETOOTH, + android.Manifest.permission.BLUETOOTH_SCAN, + android.Manifest.permission.BLUETOOTH_CONNECT, + ), + ) + } + + setContent { + KableTheme { + val hasPermissions by vm.permissions.collectAsState() + val loading by vm.loading.collectAsState() + val peripheral by vm.peripheral.collectAsState() + val services by vm.services.collectAsState() + val serviceSelected by vm.serviceSelected.collectAsState() + + if (hasPermissions) { + Scaffold( + modifier = Modifier.fillMaxSize(), + ) { innerPadding -> + if (loading) { + Column( + modifier = Modifier + .padding(innerPadding) + .fillMaxSize(), + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Text("Loading") + } + } else if (peripheral != null) { + // When device is connected + ConnectedView( + modifier = Modifier.padding(innerPadding), + services = services, + selectedService = serviceSelected, + select = { vm.selectService(it) }, + readCharacteristic = { vm.readCharacteristic(it) }, + ) + } else { + // When No device scan to find devices + LaunchedEffect("") { + vm.startScan() + } + val advertisement by vm.advertisement.collectAsState() + ScannerView( + modifier = Modifier.padding(innerPadding), + advertisement = advertisement, + connect = { vm.connect(it) }, + ) + } + } + } + } + } + } +} diff --git a/sample/android-sample/src/main/java/com/kable/androidsample/MainViewModel.kt b/sample/android-sample/src/main/java/com/kable/androidsample/MainViewModel.kt new file mode 100644 index 000000000..d43420b6a --- /dev/null +++ b/sample/android-sample/src/main/java/com/kable/androidsample/MainViewModel.kt @@ -0,0 +1,121 @@ +package com.kable.androidsample + +import android.util.Log +import android.widget.Toast +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.juul.kable.Advertisement +import com.juul.kable.Characteristic +import com.juul.kable.DiscoveredService +import com.juul.kable.Filter +import com.juul.kable.Peripheral +import com.juul.kable.Scanner +import com.juul.kable.logs.Logging +import com.juul.kable.logs.SystemLogEngine +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.filter +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.launch + +class MainViewModel : ViewModel() { + + val _advertisement: MutableStateFlow> = MutableStateFlow(emptyList()) + val advertisement: StateFlow> = _advertisement + val _permissions: MutableStateFlow = MutableStateFlow(false) + val permissions: StateFlow = _permissions + + val _peripheral: MutableStateFlow = MutableStateFlow(null) + val peripheral: StateFlow = _peripheral + + val _services: MutableStateFlow> = MutableStateFlow(emptyList()) + val services: StateFlow> = _services + + + val _serviceSelected: MutableStateFlow = MutableStateFlow(null) + val serviceSelected: StateFlow = _serviceSelected + + val _loading: MutableStateFlow = MutableStateFlow(false) + val loading: StateFlow = _loading + + + val scanner = Scanner { + /* filters { + match { + name = Filter.Name.Prefix("The") + } + }*/ + logging { + engine = SystemLogEngine + level = Logging.Level.Warnings + format = Logging.Format.Multiline + } + } + + fun selectService(serviceDiscovered: DiscoveredService) { + _serviceSelected.value = serviceDiscovered + } + + fun startScan() { + viewModelScope.launch { + scanner.advertisements + .filter { _peripheral.value == null } + .onEach { + //replace or insert and log + Log.d("MainViewModel", "Advertisement: ${it.name}") + _advertisement.value = _advertisement.value + .associateBy { it.name } + .toMutableMap() + .apply { put(it.name, it) } + .values + .toList() + } + .launchIn(viewModelScope) + } + } + + + fun connect(adv: Advertisement) { + _loading.value = true + viewModelScope.launch { + try { + _peripheral.value = Peripheral(adv) { + logging { + engine = SystemLogEngine + level = Logging.Level.Warnings + format = Logging.Format.Multiline + } + }.apply { + connect(false) + services + .onEach { + _services.value = it?.toList() ?: emptyList() + } + .launchIn( + viewModelScope, + ) + } + + + } catch (e: Exception) { + e.printStackTrace() + } + _loading.value = false + } + } + + + fun permissionGranted() { + _permissions.value = true + } + + fun readCharacteristic(characteristic: Characteristic) { + viewModelScope.launch { + val readed = _peripheral.value?.read(characteristic) + Log.d("MainViewModel", "Read characteristic: ${readed?.toString()}") + } + } + + +} \ No newline at end of file diff --git a/sample/android-sample/src/main/java/com/kable/androidsample/ui/ConnectedView.kt b/sample/android-sample/src/main/java/com/kable/androidsample/ui/ConnectedView.kt new file mode 100644 index 000000000..5b575e6aa --- /dev/null +++ b/sample/android-sample/src/main/java/com/kable/androidsample/ui/ConnectedView.kt @@ -0,0 +1,98 @@ +package com.kable.androidsample.ui + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.material3.Button +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import com.juul.kable.Advertisement +import com.juul.kable.Characteristic +import com.juul.kable.DiscoveredService +import com.juul.kable.ServicesDiscoveredPeripheral + + +@Composable +fun ConnectedView( + modifier: Modifier = Modifier, + services: List, + selectedService: DiscoveredService? = null, + select: (DiscoveredService) -> Unit = {}, + readCharacteristic: (Characteristic) -> Unit = { }, +) { + Column( + modifier = modifier, + ) { + services.forEach { + ServiceRow( + it.serviceUuid.toString(), + select = { + select(it) + }, + ) + if (selectedService == it) { + it.characteristics.forEach { + Row(modifier = Modifier.fillMaxWidth() + .background(Color.Blue.copy(alpha = 0.3f))) { + Text( + modifier = Modifier.weight(1f), + text = it.characteristicUuid.toString().plus(" - ").plus(it.properties.toString()) + ) + Spacer(Modifier.width(20.dp)) + Button( + { + readCharacteristic(it) + }, + ) { + Text("Read") + } + Spacer(Modifier.width(20.dp)) + } + } + + } + } + } +} + + +@Composable +fun ServiceRow( + name: String, + select: () -> Unit, +) { + Row( + modifier = Modifier.fillMaxWidth() + .background(Color.Gray.copy(alpha = 0.3f)), + horizontalArrangement = Arrangement.Center, + verticalAlignment = Alignment.CenterVertically, + ) { + Spacer(Modifier.width(20.dp)) + Text( + modifier = Modifier.weight(1f), + text = name, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + ) + + Spacer(Modifier.width(20.dp)) + Button( + onClick = { select() }, + ) { + Text("Select") + } + Spacer(Modifier.width(20.dp)) + } +} diff --git a/sample/android-sample/src/main/java/com/kable/androidsample/ui/ScannerView.kt b/sample/android-sample/src/main/java/com/kable/androidsample/ui/ScannerView.kt new file mode 100644 index 000000000..27a393840 --- /dev/null +++ b/sample/android-sample/src/main/java/com/kable/androidsample/ui/ScannerView.kt @@ -0,0 +1,68 @@ +package com.kable.androidsample.ui + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.material3.Button +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import com.juul.kable.Advertisement + + +@Composable +fun ScannerView( + modifier: Modifier = Modifier, + advertisement:List, + connect: (Advertisement) -> Unit +) { + Column( + modifier =modifier, + ) { + advertisement.forEach { + //mix peripheral name and name only iths exist of not unknown + val title = "Name ${it.name ?: "Unknown"}" + + AdvRow( + name = title, + ) { + connect(it) + } + } + } +} + + +@Composable +fun AdvRow(name: String, connect: () -> Unit) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.Center, + verticalAlignment = Alignment.CenterVertically, + ) { + Spacer(Modifier.width(20.dp)) + Text( + modifier = Modifier.weight(1f), + text = name, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + ) + + Spacer(Modifier.width(20.dp)) + Button( + onClick = connect, + ) { + Text("Connect") + } + Spacer(Modifier.width(20.dp)) + } +} diff --git a/sample/android-sample/src/main/java/com/kable/androidsample/ui/theme/Color.kt b/sample/android-sample/src/main/java/com/kable/androidsample/ui/theme/Color.kt new file mode 100644 index 000000000..6eddf0eea --- /dev/null +++ b/sample/android-sample/src/main/java/com/kable/androidsample/ui/theme/Color.kt @@ -0,0 +1,11 @@ +package com.kable.androidsample.ui.theme + +import androidx.compose.ui.graphics.Color + +val Purple80 = Color(0xFFD0BCFF) +val PurpleGrey80 = Color(0xFFCCC2DC) +val Pink80 = Color(0xFFEFB8C8) + +val Purple40 = Color(0xFF6650a4) +val PurpleGrey40 = Color(0xFF625b71) +val Pink40 = Color(0xFF7D5260) \ No newline at end of file diff --git a/sample/android-sample/src/main/java/com/kable/androidsample/ui/theme/Theme.kt b/sample/android-sample/src/main/java/com/kable/androidsample/ui/theme/Theme.kt new file mode 100644 index 000000000..55cb5c267 --- /dev/null +++ b/sample/android-sample/src/main/java/com/kable/androidsample/ui/theme/Theme.kt @@ -0,0 +1,58 @@ +package com.kable.androidsample.ui.theme + +import android.app.Activity +import android.os.Build +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.darkColorScheme +import androidx.compose.material3.dynamicDarkColorScheme +import androidx.compose.material3.dynamicLightColorScheme +import androidx.compose.material3.lightColorScheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.platform.LocalContext + +private val DarkColorScheme = darkColorScheme( + primary = Purple80, + secondary = PurpleGrey80, + tertiary = Pink80 +) + +private val LightColorScheme = lightColorScheme( + primary = Purple40, + secondary = PurpleGrey40, + tertiary = Pink40 + + /* Other default colors to override + background = Color(0xFFFFFBFE), + surface = Color(0xFFFFFBFE), + onPrimary = Color.White, + onSecondary = Color.White, + onTertiary = Color.White, + onBackground = Color(0xFF1C1B1F), + onSurface = Color(0xFF1C1B1F), + */ +) + +@Composable +fun KableTheme( + darkTheme: Boolean = isSystemInDarkTheme(), + // Dynamic color is available on Android 12+ + dynamicColor: Boolean = true, + content: @Composable () -> Unit +) { + val colorScheme = when { + dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> { + val context = LocalContext.current + if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context) + } + + darkTheme -> DarkColorScheme + else -> LightColorScheme + } + + MaterialTheme( + colorScheme = colorScheme, + typography = Typography, + content = content + ) +} \ No newline at end of file diff --git a/sample/android-sample/src/main/java/com/kable/androidsample/ui/theme/Type.kt b/sample/android-sample/src/main/java/com/kable/androidsample/ui/theme/Type.kt new file mode 100644 index 000000000..b5a2cc6f6 --- /dev/null +++ b/sample/android-sample/src/main/java/com/kable/androidsample/ui/theme/Type.kt @@ -0,0 +1,34 @@ +package com.kable.androidsample.ui.theme + +import androidx.compose.material3.Typography +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.sp + +// Set of Material typography styles to start with +val Typography = Typography( + bodyLarge = TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.Normal, + fontSize = 16.sp, + lineHeight = 24.sp, + letterSpacing = 0.5.sp + ) + /* Other default text styles to override + titleLarge = TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.Normal, + fontSize = 22.sp, + lineHeight = 28.sp, + letterSpacing = 0.sp + ), + labelSmall = TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.Medium, + fontSize = 11.sp, + lineHeight = 16.sp, + letterSpacing = 0.5.sp + ) + */ +) \ No newline at end of file diff --git a/sample/android-sample/src/main/res/drawable/ic_launcher_background.xml b/sample/android-sample/src/main/res/drawable/ic_launcher_background.xml new file mode 100644 index 000000000..07d5da9cb --- /dev/null +++ b/sample/android-sample/src/main/res/drawable/ic_launcher_background.xml @@ -0,0 +1,170 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/sample/android-sample/src/main/res/drawable/ic_launcher_foreground.xml b/sample/android-sample/src/main/res/drawable/ic_launcher_foreground.xml new file mode 100644 index 000000000..2b068d114 --- /dev/null +++ b/sample/android-sample/src/main/res/drawable/ic_launcher_foreground.xml @@ -0,0 +1,30 @@ + + + + + + + + + + + \ No newline at end of file diff --git a/sample/android-sample/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/sample/android-sample/src/main/res/mipmap-anydpi-v26/ic_launcher.xml new file mode 100644 index 000000000..6f3b755bf --- /dev/null +++ b/sample/android-sample/src/main/res/mipmap-anydpi-v26/ic_launcher.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/sample/android-sample/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml b/sample/android-sample/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml new file mode 100644 index 000000000..6f3b755bf --- /dev/null +++ b/sample/android-sample/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/sample/android-sample/src/main/res/mipmap-hdpi/ic_launcher.webp b/sample/android-sample/src/main/res/mipmap-hdpi/ic_launcher.webp new file mode 100644 index 000000000..c209e78ec Binary files /dev/null and b/sample/android-sample/src/main/res/mipmap-hdpi/ic_launcher.webp differ diff --git a/sample/android-sample/src/main/res/mipmap-hdpi/ic_launcher_round.webp b/sample/android-sample/src/main/res/mipmap-hdpi/ic_launcher_round.webp new file mode 100644 index 000000000..b2dfe3d1b Binary files /dev/null and b/sample/android-sample/src/main/res/mipmap-hdpi/ic_launcher_round.webp differ diff --git a/sample/android-sample/src/main/res/mipmap-mdpi/ic_launcher.webp b/sample/android-sample/src/main/res/mipmap-mdpi/ic_launcher.webp new file mode 100644 index 000000000..4f0f1d64e Binary files /dev/null and b/sample/android-sample/src/main/res/mipmap-mdpi/ic_launcher.webp differ diff --git a/sample/android-sample/src/main/res/mipmap-mdpi/ic_launcher_round.webp b/sample/android-sample/src/main/res/mipmap-mdpi/ic_launcher_round.webp new file mode 100644 index 000000000..62b611da0 Binary files /dev/null and b/sample/android-sample/src/main/res/mipmap-mdpi/ic_launcher_round.webp differ diff --git a/sample/android-sample/src/main/res/mipmap-xhdpi/ic_launcher.webp b/sample/android-sample/src/main/res/mipmap-xhdpi/ic_launcher.webp new file mode 100644 index 000000000..948a3070f Binary files /dev/null and b/sample/android-sample/src/main/res/mipmap-xhdpi/ic_launcher.webp differ diff --git a/sample/android-sample/src/main/res/mipmap-xhdpi/ic_launcher_round.webp b/sample/android-sample/src/main/res/mipmap-xhdpi/ic_launcher_round.webp new file mode 100644 index 000000000..1b9a6956b Binary files /dev/null and b/sample/android-sample/src/main/res/mipmap-xhdpi/ic_launcher_round.webp differ diff --git a/sample/android-sample/src/main/res/mipmap-xxhdpi/ic_launcher.webp b/sample/android-sample/src/main/res/mipmap-xxhdpi/ic_launcher.webp new file mode 100644 index 000000000..28d4b77f9 Binary files /dev/null and b/sample/android-sample/src/main/res/mipmap-xxhdpi/ic_launcher.webp differ diff --git a/sample/android-sample/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp b/sample/android-sample/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp new file mode 100644 index 000000000..9287f5083 Binary files /dev/null and b/sample/android-sample/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp differ diff --git a/sample/android-sample/src/main/res/mipmap-xxxhdpi/ic_launcher.webp b/sample/android-sample/src/main/res/mipmap-xxxhdpi/ic_launcher.webp new file mode 100644 index 000000000..aa7d6427e Binary files /dev/null and b/sample/android-sample/src/main/res/mipmap-xxxhdpi/ic_launcher.webp differ diff --git a/sample/android-sample/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp b/sample/android-sample/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp new file mode 100644 index 000000000..9126ae37c Binary files /dev/null and b/sample/android-sample/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp differ diff --git a/sample/android-sample/src/main/res/values/colors.xml b/sample/android-sample/src/main/res/values/colors.xml new file mode 100644 index 000000000..f8c6127d3 --- /dev/null +++ b/sample/android-sample/src/main/res/values/colors.xml @@ -0,0 +1,10 @@ + + + #FFBB86FC + #FF6200EE + #FF3700B3 + #FF03DAC5 + #FF018786 + #FF000000 + #FFFFFFFF + \ No newline at end of file diff --git a/sample/android-sample/src/main/res/values/strings.xml b/sample/android-sample/src/main/res/values/strings.xml new file mode 100644 index 000000000..c938b49b6 --- /dev/null +++ b/sample/android-sample/src/main/res/values/strings.xml @@ -0,0 +1,3 @@ + + AndroidSAmple + \ No newline at end of file diff --git a/sample/android-sample/src/main/res/values/themes.xml b/sample/android-sample/src/main/res/values/themes.xml new file mode 100644 index 000000000..913be1cb5 --- /dev/null +++ b/sample/android-sample/src/main/res/values/themes.xml @@ -0,0 +1,5 @@ + + + +