├── .editorconfig ├── .github ├── CODEOWNERS ├── release-drafter.yml └── workflows │ ├── ci.yml │ ├── pages.yml │ ├── publish.yml │ ├── release-drafter.yml │ ├── signing.yml │ └── version-labels.yml ├── .gitignore ├── LICENSE.txt ├── README.md ├── artwork └── connection-states.png ├── build.gradle.kts ├── gradle.properties ├── gradle ├── libs.versions.toml └── wrapper │ ├── gradle-wrapper.jar │ └── gradle-wrapper.properties ├── gradlew ├── gradlew.bat ├── kable-core ├── api │ ├── android │ │ └── kable-core.api │ └── jvm │ │ └── kable-core.api ├── build.gradle.kts └── src │ ├── androidMain │ ├── AndroidManifest.xml │ └── kotlin │ │ ├── AndroidAdvertisement.kt │ │ ├── AndroidPeripheral.kt │ │ ├── AndroidScanner.kt │ │ ├── Bluetooth.kt │ │ ├── BluetoothAdapter.kt │ │ ├── BluetoothDevice.kt │ │ ├── BluetoothDeviceAndroidPeripheral.kt │ │ ├── BluetoothGatt.kt │ │ ├── BluetoothLeScannerAndroidScanner.kt │ │ ├── BluetoothState.kt │ │ ├── Connection.kt │ │ ├── Exceptions.kt │ │ ├── Identifier.kt │ │ ├── KableInitializer.kt │ │ ├── Observations.kt │ │ ├── Peripheral.deprecated.kt │ │ ├── Peripheral.kt │ │ ├── PeripheralBuilder.kt │ │ ├── PlatformAdvertisement.kt │ │ ├── Profile.kt │ │ ├── Reflection.kt │ │ ├── ScanResultAndroidAdvertisement.kt │ │ ├── ScannerBuilder.kt │ │ ├── Threading.kt │ │ ├── ThreadingStrategy.kt │ │ ├── bluetooth │ │ ├── CheckBluetoothIsOn.kt │ │ ├── CheckMacAddress.kt │ │ ├── ClientCharacteristicConfigUuid.kt │ │ └── IsSupported.kt │ │ ├── external │ │ └── Constants.kt │ │ ├── gatt │ │ ├── Callback.kt │ │ └── Response.kt │ │ ├── logs │ │ ├── LogMessage.kt │ │ └── SystemLogEngine.kt │ │ └── scan │ │ ├── ScanError.kt │ │ └── requirements │ │ ├── BluetoothLeScanner.kt │ │ ├── LocationServicesEnabled.kt │ │ └── ScanPermissions.kt │ ├── androidUnitTest │ └── kotlin │ │ └── com │ │ └── juul │ │ └── kable │ │ ├── ProfileTests.kt │ │ └── ScanFiltersTests.kt │ ├── appleMain │ └── kotlin │ │ ├── AdvertisementData.kt │ │ ├── ApplePeripheral.kt │ │ ├── Bluetooth.kt │ │ ├── CBPeripheralCoreBluetoothAdvertisement.kt │ │ ├── CBPeripheralCoreBluetoothPeripheral.kt │ │ ├── CentralManager.kt │ │ ├── CentralManagerConfiguration.kt │ │ ├── CentralManagerCoreBluetoothScanner.kt │ │ ├── CentralManagerDelegate.kt │ │ ├── Channel.kt │ │ ├── Connection.kt │ │ ├── CoreBluetoothAdvertisement.kt │ │ ├── CoreBluetoothPeripheral.kt │ │ ├── CoreBluetoothScanner.kt │ │ ├── Flow.kt │ │ ├── Identifier.kt │ │ ├── NSData.kt │ │ ├── Observations.kt │ │ ├── Peripheral.deprecated.kt │ │ ├── Peripheral.kt │ │ ├── PeripheralBuilder.kt │ │ ├── PeripheralDelegate.kt │ │ ├── PlatformAdvertisement.kt │ │ ├── Profile.kt │ │ ├── QueueDispatcher.kt │ │ ├── ScannerBuilder.kt │ │ ├── State.kt │ │ ├── Status.kt │ │ ├── Uuid.kt │ │ ├── bluetooth │ │ ├── CheckBluetoothIsOn.kt │ │ └── IsSupported.kt │ │ └── logs │ │ ├── LogMessage.kt │ │ └── SystemLogEngine.kt │ ├── appleTest │ └── kotlin │ │ ├── AdvertisementTest.kt │ │ ├── NSDataTest.kt │ │ └── NSDictionaryTests.kt │ ├── commonMain │ └── kotlin │ │ ├── Advertisement.kt │ │ ├── Annotations.kt │ │ ├── BaseConnection.kt │ │ ├── BasePeripheral.kt │ │ ├── Bluetooth.kt │ │ ├── ByteArray.kt │ │ ├── Descriptor.kt │ │ ├── Exceptions.deprecated.kt │ │ ├── ExperimentalApi.kt │ │ ├── Filter.kt │ │ ├── FilterPredicate.kt │ │ ├── FilterPredicateBuilder.kt │ │ ├── FiltersBuilder.kt │ │ ├── GattRequestRejectedException.kt │ │ ├── GattStatusException.kt │ │ ├── Identifier.kt │ │ ├── InternalError.kt │ │ ├── ManufacturerData.kt │ │ ├── NotConnectedException.kt │ │ ├── Observation.kt │ │ ├── ObservationEvent.kt │ │ ├── Observers.kt │ │ ├── Peripheral.deprecated.kt │ │ ├── Peripheral.kt │ │ ├── PeripheralBuilder.kt │ │ ├── PlatformAdvertisement.kt │ │ ├── PlatformScanner.kt │ │ ├── Profile.kt │ │ ├── Scanner.kt │ │ ├── ScannerBuilder.kt │ │ ├── SharedRepeatableAction.awaitConnect.kt │ │ ├── SharedRepeatableAction.kt │ │ ├── SilentSupervisor.kt │ │ ├── State.kt │ │ ├── Throwable.kt │ │ ├── UnmetRequirementException.kt │ │ ├── Uuid.kt │ │ ├── android │ │ └── GattStatus.kt │ │ ├── bluetooth │ │ ├── CheckBluetoothIsSupported.kt │ │ └── IsSupported.kt │ │ ├── coroutines │ │ └── CoroutineScope.kt │ │ └── logs │ │ ├── Hex.kt │ │ ├── LogEngine.kt │ │ ├── LogMessage.kt │ │ ├── Logger.kt │ │ ├── Logging.kt │ │ └── SystemLogEngine.kt │ ├── commonTest │ └── kotlin │ │ ├── BluetoothTests.kt │ │ ├── ByteArrayTests.kt │ │ ├── FilterPredicateTests.kt │ │ ├── FiltersTests.kt │ │ ├── LogMessageTests.kt │ │ ├── ObservationTest.kt │ │ ├── SharedRepeatableActionTests.kt │ │ ├── StateIsAtLeastTests.kt │ │ └── StateStringTests.kt │ ├── jsMain │ └── kotlin │ │ ├── Bluetooth.kt │ │ ├── BluetoothAdvertisingEventWebBluetoothAdvertisement.kt │ │ ├── BluetoothAvailability.kt │ │ ├── BluetoothDeviceWebBluetoothPeripheral.kt │ │ ├── BluetoothLEScanOptions.kt │ │ ├── BluetoothWebBluetoothScanner.kt │ │ ├── Bytes.kt │ │ ├── Connection.kt │ │ ├── FilterSet.kt │ │ ├── Identifier.kt │ │ ├── JsPeripheral.kt │ │ ├── Observations.kt │ │ ├── Options.deprecated.kt │ │ ├── Options.kt │ │ ├── OptionsBuilder.kt │ │ ├── Peripheral.deprecated.kt │ │ ├── Peripheral.kt │ │ ├── PeripheralBuilder.kt │ │ ├── PlatformAdvertisement.kt │ │ ├── Profile.kt │ │ ├── RequestPeripheral.kt │ │ ├── ScannerBuilder.kt │ │ ├── Uuid.kt │ │ ├── WebBluetoothAdvertisement.kt │ │ ├── WebBluetoothPeripheral.kt │ │ ├── WebBluetoothScanner.kt │ │ ├── bluetooth │ │ ├── IsSupported.kt │ │ └── WatchingAdvertisementsSupport.kt │ │ ├── external │ │ ├── Bluetooth.kt │ │ ├── BluetoothAdvertisingEvent.kt │ │ ├── BluetoothAvailabilityChanged.kt │ │ ├── BluetoothCharacteristicProperties.kt │ │ ├── BluetoothCharacteristicUUID.kt │ │ ├── BluetoothDataFilterInit.kt │ │ ├── BluetoothDescriptorUUID.kt │ │ ├── BluetoothDevice.kt │ │ ├── BluetoothLEScanFilterInit.kt │ │ ├── BluetoothLEScanOptions.kt │ │ ├── BluetoothManufacturerDataFilterInit.kt │ │ ├── BluetoothRemoteGATTCharacteristic.kt │ │ ├── BluetoothRemoteGATTDescriptor.kt │ │ ├── BluetoothRemoteGATTServer.kt │ │ ├── BluetoothRemoteGATTService.kt │ │ ├── BluetoothScan.kt │ │ ├── BluetoothServiceDataFilterInit.kt │ │ ├── BluetoothServiceDataMap.kt │ │ ├── BluetoothServiceUUID.kt │ │ ├── BluetoothUUID.kt │ │ ├── BufferSource.kt │ │ ├── JsIterator.kt │ │ ├── Navigator.kt │ │ └── RequestDeviceOptions.kt │ │ └── logs │ │ ├── LogMessage.kt │ │ └── SystemLogEngine.kt │ ├── jsTest │ └── kotlin │ │ ├── BluetoothJsTests.kt │ │ ├── Environment.kt │ │ └── RequestPeripheralTests.kt │ └── jvmMain │ └── kotlin │ └── com │ └── juul │ └── kable │ ├── Bluetooth.kt │ ├── Exceptions.kt │ ├── Identifier.kt │ ├── Observations.kt │ ├── Peripheral.deprecated.kt │ ├── Peripheral.kt │ ├── PeripheralBuilder.kt │ ├── PlatformAdvertisement.kt │ ├── Profile.kt │ ├── ScannerBuilder.kt │ ├── bluetooth │ └── IsSupported.kt │ └── logs │ ├── Log.kt │ └── SystemLogEngine.kt ├── kable-default-permissions ├── build.gradle.kts └── src │ └── main │ └── AndroidManifest.xml ├── kable-log-engine-khronicle ├── api │ └── kable-log-engine-khronicle.api ├── build.gradle.kts └── src │ └── commonMain │ └── kotlin │ └── logs │ └── KhronicleLogEngine.kt ├── kotlin-js-store └── yarn.lock └── settings.gradle.kts /.editorconfig: -------------------------------------------------------------------------------- 1 | [*.{kt,kts}] 2 | ij_kotlin_allow_trailing_comma = true 3 | ij_kotlin_allow_trailing_comma_on_call_site = true 4 | 5 | ktlint_standard_backing-property-naming = disabled 6 | ktlint_standard_blank-line-before-declaration = disabled 7 | ktlint_standard_chain-method-continuation = disabled 8 | ktlint_standard_class-signature = disabled 9 | ktlint_standard_comment-wrapping = disabled 10 | ktlint_standard_filename = disabled 11 | ktlint_standard_function-naming = disabled 12 | ktlint_standard_function-signature = disabled 13 | ktlint_standard_no-blank-line-in-list = disabled 14 | ktlint_standard_no-empty-first-line-in-class-body = disabled 15 | ktlint_standard_no-single-line-block-comment = disabled 16 | ktlint_standard_property-naming = disabled 17 | ktlint_standard_property-wrapping = disabled 18 | ktlint_standard_statement-wrapping = disabled 19 | ktlint_standard_value-argument-comment = disabled 20 | ktlint_standard_value-parameter-comment = disabled 21 | 22 | # `string-template-indent` is disabled because it depends on `multiline-expression-wrapping`. 23 | ktlint_standard_multiline-expression-wrapping = disabled 24 | ktlint_standard_string-template-indent = disabled 25 | 26 | [**/*Test/kotlin/**/*.{kt,kts}] 27 | # `if-else-wrapping` is disabled because it depends on `discouraged-comment-location`. 28 | ktlint_standard_discouraged-comment-location = disabled 29 | ktlint_standard_if-else-wrapping = disabled 30 | -------------------------------------------------------------------------------- /.github/CODEOWNERS: -------------------------------------------------------------------------------- 1 | # https://help.github.com/articles/about-codeowners/ 2 | 3 | * @JuulLabs/conx-kotlin-reviewers @twyatt 4 | -------------------------------------------------------------------------------- /.github/release-drafter.yml: -------------------------------------------------------------------------------- 1 | name-template: '$RESOLVED_VERSION' 2 | tag-template: '$RESOLVED_VERSION' 3 | categories: 4 | - title: '![Logo](https://user-images.githubusercontent.com/98017/186845159-6091676e-3923-4be6-b38f-36ee4a4a6507.png) Android' 5 | label: 'android' 6 | - title: '![Logo](https://user-images.githubusercontent.com/98017/186844398-9e8a4bfc-b63d-48dc-b87c-d0d34645295b.png) Apple' 7 | label: 'apple' 8 | - title: '![Logo](https://user-images.githubusercontent.com/98017/186845627-ad1c11bf-8727-4ff4-af14-d5ef214c8df2.png) JavaScript' 9 | label: 'javascript' 10 | - title: '🧰 Maintenance' 11 | labels: 12 | - 'maintenance' 13 | - 'renovate' 14 | change-template: '- $TITLE (#$NUMBER)' 15 | exclude-labels: 16 | - 'skip-changelog' 17 | version-resolver: 18 | major: 19 | labels: 20 | - 'major' 21 | minor: 22 | labels: 23 | - 'minor' 24 | patch: 25 | labels: 26 | - 'patch' 27 | default: patch 28 | template: $CHANGES 29 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | on: 3 | pull_request: 4 | # Trigger on merges to `main` to allow `gradle/gradle-build-action` runs to write their caches. 5 | # https://github.com/gradle/gradle-build-action#using-the-caches-read-only 6 | push: 7 | branches: 8 | - main 9 | 10 | jobs: 11 | build: 12 | runs-on: macos-latest 13 | steps: 14 | - uses: actions/checkout@v4 15 | - uses: actions/setup-java@v4 16 | with: 17 | distribution: 'temurin' 18 | java-version: '17' 19 | - uses: gradle/actions/setup-gradle@v4 20 | with: 21 | validate-wrappers: true 22 | 23 | - run: ./gradlew assemble -PsuppressWarnings=true 24 | - run: ./gradlew check 25 | - run: ./gradlew -PRELEASE_SIGNING_ENABLED=false publishToMavenLocal 26 | -------------------------------------------------------------------------------- /.github/workflows/pages.yml: -------------------------------------------------------------------------------- 1 | name: GitHub Pages 2 | on: 3 | push: 4 | branches: 5 | - main 6 | 7 | jobs: 8 | build: 9 | runs-on: ubuntu-latest 10 | steps: 11 | - uses: actions/checkout@v4 12 | - uses: actions/setup-java@v4 13 | with: 14 | distribution: 'temurin' 15 | java-version: '17' 16 | - uses: gradle/actions/setup-gradle@v4 17 | with: 18 | cache-read-only: true 19 | validate-wrappers: true 20 | 21 | - run: ./gradlew dokkaGenerate 22 | - run: > 23 | tar 24 | --dereference --hard-dereference 25 | --directory build/dokkaHtmlMultiModule 26 | -cvf '${{ runner.temp }}/artifact.tar' 27 | --exclude=.git 28 | --exclude=.github 29 | . 30 | 31 | - uses: actions/upload-artifact@v4 32 | with: 33 | name: github-pages 34 | path: ${{ runner.temp }}/artifact.tar 35 | retention-days: 1 36 | if-no-files-found: error 37 | 38 | deploy: 39 | needs: build 40 | permissions: 41 | pages: write 42 | id-token: write 43 | environment: 44 | name: github-pages 45 | url: ${{ steps.deployment.outputs.page_url }} 46 | runs-on: ubuntu-latest 47 | steps: 48 | - uses: actions/deploy-pages@v4 49 | id: deployment 50 | -------------------------------------------------------------------------------- /.github/workflows/publish.yml: -------------------------------------------------------------------------------- 1 | name: Publish 2 | on: 3 | release: 4 | types: 5 | - published 6 | 7 | jobs: 8 | publish: 9 | runs-on: macos-latest 10 | steps: 11 | - uses: actions/checkout@v4 12 | - uses: actions/setup-java@v4 13 | with: 14 | distribution: 'temurin' 15 | java-version: '17' 16 | - uses: gradle/actions/setup-gradle@v4 17 | 18 | - run: ./gradlew check 19 | - name: publish 20 | run: > 21 | ./gradlew 22 | -PVERSION_NAME='${{ github.ref_name }}' 23 | -PsigningInMemoryKey='${{ secrets.SIGNING_KEY }}' 24 | -PsigningInMemoryKeyPassword='${{ secrets.SIGNING_PASSWORD }}' 25 | -PmavenCentralUsername='${{ secrets.OSS_SONATYPE_NEXUS_USERNAME }}' 26 | -PmavenCentralPassword='${{ secrets.OSS_SONATYPE_NEXUS_PASSWORD }}' 27 | publish 28 | -------------------------------------------------------------------------------- /.github/workflows/release-drafter.yml: -------------------------------------------------------------------------------- 1 | name: Release Drafter 2 | # https://github.com/release-drafter/release-drafter#usage 3 | 4 | on: 5 | push: 6 | branches: 7 | - main 8 | 9 | jobs: 10 | update-release-draft: 11 | permissions: 12 | contents: write 13 | pull-requests: read 14 | runs-on: ubuntu-latest 15 | steps: 16 | - uses: release-drafter/release-drafter@v6 17 | env: 18 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 19 | -------------------------------------------------------------------------------- /.github/workflows/signing.yml: -------------------------------------------------------------------------------- 1 | name: Validate Maven Signing 2 | on: 3 | push: 4 | branches: 5 | - main 6 | 7 | jobs: 8 | signing: 9 | if: github.repository_owner == 'JuulLabs' 10 | runs-on: macos-latest 11 | steps: 12 | - uses: actions/checkout@v4 13 | - uses: actions/setup-java@v4 14 | with: 15 | distribution: 'temurin' 16 | java-version: '17' 17 | - uses: gradle/actions/setup-gradle@v4 18 | 19 | - name: publishToMavenLocal 20 | run: > 21 | ./gradlew 22 | -PsigningInMemoryKey='${{ secrets.SIGNING_KEY }}' 23 | -PsigningInMemoryKeyPassword='${{ secrets.SIGNING_PASSWORD }}' 24 | publishToMavenLocal 25 | -------------------------------------------------------------------------------- /.github/workflows/version-labels.yml: -------------------------------------------------------------------------------- 1 | name: Version Labels 2 | on: 3 | pull_request: 4 | types: [opened, labeled, unlabeled, synchronize] 5 | jobs: 6 | label: 7 | runs-on: ubuntu-latest 8 | steps: 9 | - uses: mheap/github-action-required-labels@v5 10 | with: 11 | mode: exactly 12 | count: 1 13 | labels: "patch, minor, major, maintenance, renovate" 14 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | .gradle 3 | .idea 4 | .kotlin/ 5 | build/ 6 | local.properties 7 | -------------------------------------------------------------------------------- /artwork/connection-states.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/JuulLabs/kable/03d8b1e19fb094b76c9fbfd6afed70d3fa2591fc/artwork/connection-states.png -------------------------------------------------------------------------------- /build.gradle.kts: -------------------------------------------------------------------------------- 1 | buildscript { 2 | repositories { 3 | google() 4 | mavenCentral() 5 | } 6 | } 7 | 8 | plugins { 9 | alias(libs.plugins.kotlin.multiplatform) apply false 10 | alias(libs.plugins.android.library) apply false 11 | alias(libs.plugins.kotlinter) apply false 12 | alias(libs.plugins.maven.publish) apply false 13 | alias(libs.plugins.atomicfu) apply false 14 | alias(libs.plugins.dokka) 15 | alias(libs.plugins.api) 16 | } 17 | 18 | dokka { 19 | dokkaPublications.html { 20 | outputDirectory.set(layout.buildDirectory.dir("dokkaHtmlMultiModule")) 21 | } 22 | } 23 | 24 | dependencies { 25 | dokka(project(":kable-core")) 26 | dokka(project(":kable-log-engine-khronicle")) 27 | } 28 | 29 | apiValidation { 30 | ignoredProjects.add("kable-default-permissions") 31 | } 32 | 33 | allprojects { 34 | group = "com.juul.kable" 35 | 36 | repositories { 37 | google() 38 | mavenCentral() 39 | } 40 | 41 | listOf( 42 | org.jetbrains.kotlin.gradle.tasks.Kotlin2JsCompile::class, 43 | org.jetbrains.kotlin.gradle.tasks.KotlinCompileCommon::class, 44 | org.jetbrains.kotlin.gradle.tasks.KotlinJvmCompile::class, 45 | org.jetbrains.kotlin.gradle.tasks.KotlinNativeCompile::class, 46 | ).forEach { kClass -> 47 | tasks.withType(kClass).configureEach { 48 | compilerOptions.suppressWarnings = (findProperty("suppressWarnings") as? String).toBoolean() 49 | } 50 | } 51 | 52 | tasks.withType().configureEach { 53 | testLogging { 54 | events("started", "passed", "skipped", "failed", "standardOut", "standardError") 55 | exceptionFormat = org.gradle.api.tasks.testing.logging.TestExceptionFormat.FULL 56 | showExceptions = true 57 | showStackTraces = true 58 | showCauses = true 59 | } 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /gradle.properties: -------------------------------------------------------------------------------- 1 | org.gradle.jvmargs=-Xmx4g -XX:+HeapDumpOnOutOfMemoryError -Dfile.encoding=UTF-8 2 | org.jetbrains.dokka.experimental.gradle.pluginMode=V2Enabled 3 | org.jetbrains.dokka.experimental.gradle.pluginMode.noWarn=true 4 | kotlin.mpp.androidSourceSetLayoutVersion=2 5 | 6 | # Publication configuration 7 | # https://github.com/vanniktech/gradle-maven-publish-plugin#setting-properties 8 | 9 | SONATYPE_HOST=DEFAULT 10 | SONATYPE_AUTOMATIC_RELEASE=true 11 | RELEASE_SIGNING_ENABLED=true 12 | 13 | POM_NAME=Kable 14 | POM_DESCRIPTION=Kotlin Asynchronous Bluetooth Low Energy 15 | POM_INCEPTION_YEAR=2020 16 | 17 | POM_URL=https://github.com/JuulLabs/kable 18 | POM_SCM_URL=https://github.com/JuulLabs/kable 19 | POM_SCM_CONNECTION=scm:git:git://github.com/JuulLabs/kable.git 20 | POM_SCM_DEV_CONNECTION=scm:git:ssh://git@github.com/JuulLabs/kable.git 21 | 22 | POM_LICENCE_NAME=The Apache Software License, Version 2.0 23 | POM_LICENCE_URL=https://www.apache.org/licenses/LICENSE-2.0.txt 24 | POM_LICENCE_DIST=repo 25 | 26 | POM_DEVELOPER_ID=twyatt 27 | POM_DEVELOPER_NAME=Travis Wyatt 28 | POM_DEVELOPER_URL=https://github.com/twyatt 29 | 30 | kotlin.js.webpack.major.version=5 31 | 32 | # Disable BuildConfig generation for all Android modules 33 | android.defaults.buildfeatures.buildconfig=false 34 | 35 | android.useAndroidX=true 36 | -------------------------------------------------------------------------------- /gradle/libs.versions.toml: -------------------------------------------------------------------------------- 1 | [versions] 2 | android-compile = "35" 3 | android-min = "21" 4 | atomicfu = "0.27.0" 5 | coroutines = "1.10.2" 6 | jvm-toolchain = "11" 7 | kotlin = "2.1.21" 8 | tuulbox = "8.0.1" 9 | 10 | [libraries] 11 | androidx-core = { module = "androidx.core:core-ktx", version = "1.16.0" } 12 | androidx-startup = { module = "androidx.startup:startup-runtime", version = "1.2.0" } 13 | atomicfu = { module = "org.jetbrains.kotlinx:atomicfu", version.ref = "atomicfu" } 14 | datetime = { module = "org.jetbrains.kotlinx:kotlinx-datetime", version = "0.6.2" } 15 | equalsverifier = { module = "nl.jqno.equalsverifier:equalsverifier", version = "3.19.4" } 16 | khronicle = { module = "com.juul.khronicle:khronicle-core", version = "0.5.1" } 17 | kotlinx-coroutines-android = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-android", version.ref = "coroutines" } 18 | kotlinx-coroutines-core = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "coroutines" } 19 | kotlinx-coroutines-test = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-test", version.ref = "coroutines" } 20 | kotlinx-io = { module = "org.jetbrains.kotlinx:kotlinx-io-core", version = "0.7.0" } 21 | mockk = { module = "io.mockk:mockk", version = "1.14.2" } 22 | robolectric = { module = "org.robolectric:robolectric", version = "4.14.1" } 23 | tuulbox-collections = { module = "com.juul.tuulbox:collections", version.ref = "tuulbox" } 24 | tuulbox-coroutines = { module = "com.juul.tuulbox:coroutines", version.ref = "tuulbox" } 25 | wrappers-bom = { module = "org.jetbrains.kotlin-wrappers:kotlin-wrappers-bom", version = "2025.6.3" } 26 | wrappers-web = { module = "org.jetbrains.kotlin-wrappers:kotlin-web" } 27 | 28 | [plugins] 29 | android-library = { id = "com.android.library", version = "8.8.1" } 30 | api = { id = "org.jetbrains.kotlinx.binary-compatibility-validator", version = "0.17.0" } 31 | atomicfu = { id = "org.jetbrains.kotlinx.atomicfu", version.ref = "atomicfu" } 32 | dokka = { id = "org.jetbrains.dokka", version = "2.0.0" } 33 | kotlin-multiplatform = { id = "org.jetbrains.kotlin.multiplatform", version.ref = "kotlin" } 34 | kotlinter = { id = "org.jmailen.kotlinter", version = "5.1.0" } 35 | maven-publish = { id = "com.vanniktech.maven.publish", version = "0.32.0" } 36 | -------------------------------------------------------------------------------- /gradle/wrapper/gradle-wrapper.jar: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/JuulLabs/kable/03d8b1e19fb094b76c9fbfd6afed70d3fa2591fc/gradle/wrapper/gradle-wrapper.jar -------------------------------------------------------------------------------- /gradle/wrapper/gradle-wrapper.properties: -------------------------------------------------------------------------------- 1 | distributionBase=GRADLE_USER_HOME 2 | distributionPath=wrapper/dists 3 | distributionUrl=https\://services.gradle.org/distributions/gradle-8.14.2-bin.zip 4 | networkTimeout=10000 5 | validateDistributionUrl=true 6 | zipStoreBase=GRADLE_USER_HOME 7 | zipStorePath=wrapper/dists 8 | -------------------------------------------------------------------------------- /gradlew.bat: -------------------------------------------------------------------------------- 1 | @rem 2 | @rem Copyright 2015 the original author or authors. 3 | @rem 4 | @rem Licensed under the Apache License, Version 2.0 (the "License"); 5 | @rem you may not use this file except in compliance with the License. 6 | @rem You may obtain a copy of the License at 7 | @rem 8 | @rem https://www.apache.org/licenses/LICENSE-2.0 9 | @rem 10 | @rem Unless required by applicable law or agreed to in writing, software 11 | @rem distributed under the License is distributed on an "AS IS" BASIS, 12 | @rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | @rem See the License for the specific language governing permissions and 14 | @rem limitations under the License. 15 | @rem 16 | @rem SPDX-License-Identifier: Apache-2.0 17 | @rem 18 | 19 | @if "%DEBUG%"=="" @echo off 20 | @rem ########################################################################## 21 | @rem 22 | @rem Gradle startup script for Windows 23 | @rem 24 | @rem ########################################################################## 25 | 26 | @rem Set local scope for the variables with windows NT shell 27 | if "%OS%"=="Windows_NT" setlocal 28 | 29 | set DIRNAME=%~dp0 30 | if "%DIRNAME%"=="" set DIRNAME=. 31 | @rem This is normally unused 32 | set APP_BASE_NAME=%~n0 33 | set APP_HOME=%DIRNAME% 34 | 35 | @rem Resolve any "." and ".." in APP_HOME to make it shorter. 36 | for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi 37 | 38 | @rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. 39 | set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" 40 | 41 | @rem Find java.exe 42 | if defined JAVA_HOME goto findJavaFromJavaHome 43 | 44 | set JAVA_EXE=java.exe 45 | %JAVA_EXE% -version >NUL 2>&1 46 | if %ERRORLEVEL% equ 0 goto execute 47 | 48 | echo. 1>&2 49 | echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 50 | echo. 1>&2 51 | echo Please set the JAVA_HOME variable in your environment to match the 1>&2 52 | echo location of your Java installation. 1>&2 53 | 54 | goto fail 55 | 56 | :findJavaFromJavaHome 57 | set JAVA_HOME=%JAVA_HOME:"=% 58 | set JAVA_EXE=%JAVA_HOME%/bin/java.exe 59 | 60 | if exist "%JAVA_EXE%" goto execute 61 | 62 | echo. 1>&2 63 | echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 64 | echo. 1>&2 65 | echo Please set the JAVA_HOME variable in your environment to match the 1>&2 66 | echo location of your Java installation. 1>&2 67 | 68 | goto fail 69 | 70 | :execute 71 | @rem Setup the command line 72 | 73 | set CLASSPATH= 74 | 75 | 76 | @rem Execute Gradle 77 | "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* 78 | 79 | :end 80 | @rem End local scope for the variables with windows NT shell 81 | if %ERRORLEVEL% equ 0 goto mainEnd 82 | 83 | :fail 84 | rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of 85 | rem the _cmd.exe /c_ return code! 86 | set EXIT_CODE=%ERRORLEVEL% 87 | if %EXIT_CODE% equ 0 set EXIT_CODE=1 88 | if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% 89 | exit /b %EXIT_CODE% 90 | 91 | :mainEnd 92 | if "%OS%"=="Windows_NT" endlocal 93 | 94 | :omega 95 | -------------------------------------------------------------------------------- /kable-core/build.gradle.kts: -------------------------------------------------------------------------------- 1 | plugins { 2 | // Android plugin must be before multiplatform plugin until https://youtrack.jetbrains.com/issue/KT-34038 is fixed. 3 | id("com.android.library") 4 | kotlin("multiplatform") 5 | id("kotlin-parcelize") 6 | alias(libs.plugins.atomicfu) 7 | id("org.jmailen.kotlinter") 8 | id("org.jetbrains.dokka") 9 | id("com.vanniktech.maven.publish") 10 | } 11 | 12 | kotlin { 13 | explicitApi() 14 | jvmToolchain(libs.versions.jvm.toolchain.get().toInt()) 15 | 16 | androidTarget().publishAllLibraryVariants() 17 | iosArm64() 18 | iosSimulatorArm64() 19 | iosX64() 20 | js().browser() 21 | macosArm64() 22 | macosX64() 23 | jvm() 24 | 25 | sourceSets { 26 | all { 27 | languageSettings { 28 | optIn("kotlin.uuid.ExperimentalUuidApi") 29 | } 30 | } 31 | 32 | commonMain.dependencies { 33 | api(libs.kotlinx.coroutines.core) 34 | api(libs.kotlinx.io) 35 | implementation(libs.datetime) 36 | implementation(libs.tuulbox.collections) 37 | } 38 | 39 | commonTest.dependencies { 40 | implementation(kotlin("reflect")) // For `assertIs`. 41 | implementation(kotlin("test")) 42 | implementation(libs.khronicle) 43 | implementation(libs.kotlinx.coroutines.test) 44 | } 45 | 46 | androidMain.dependencies { 47 | api(libs.kotlinx.coroutines.android) 48 | implementation(libs.androidx.core) 49 | implementation(libs.androidx.startup) 50 | 51 | // Workaround for AtomicFU plugin not automatically adding JVM dependency for Android. 52 | // https://github.com/Kotlin/kotlinx-atomicfu/issues/145 53 | implementation(libs.atomicfu) 54 | 55 | implementation(libs.tuulbox.coroutines) 56 | } 57 | 58 | androidUnitTest.dependencies { 59 | implementation(libs.equalsverifier) 60 | implementation(libs.mockk) 61 | implementation(libs.robolectric) 62 | } 63 | 64 | jsMain.dependencies { 65 | api(libs.wrappers.web) 66 | api(project.dependencies.platform(libs.wrappers.bom)) 67 | } 68 | } 69 | } 70 | 71 | android { 72 | compileSdk = libs.versions.android.compile.get().toInt() 73 | defaultConfig.minSdk = libs.versions.android.min.get().toInt() 74 | 75 | namespace = "com.juul.kable" 76 | 77 | lint { 78 | abortOnError = true 79 | warningsAsErrors = true 80 | 81 | disable += "AndroidGradlePluginVersion" 82 | disable += "GradleDependency" 83 | 84 | // Calls to many functions on `BluetoothDevice`, `BluetoothGatt`, etc require `BLUETOOTH_CONNECT` permission, 85 | // which has been specified in the `AndroidManifest.xml`; rather than needing to annotate a number of classes, 86 | // we disable the "missing permission" lint check. Caution must be taken during later Android version bumps to 87 | // make sure we aren't missing any newly introduced permission requirements. 88 | disable += "MissingPermission" 89 | } 90 | } 91 | 92 | dokka { 93 | pluginsConfiguration.html { 94 | footerMessage.set("(c) JUUL Labs, Inc.") 95 | } 96 | } 97 | -------------------------------------------------------------------------------- /kable-core/src/androidMain/AndroidManifest.xml: -------------------------------------------------------------------------------- 1 | 2 | 6 | 7 | 8 | 14 | 15 | 19 | 20 | 21 | 22 | -------------------------------------------------------------------------------- /kable-core/src/androidMain/kotlin/AndroidAdvertisement.kt: -------------------------------------------------------------------------------- 1 | package com.juul.kable 2 | 3 | @Deprecated( 4 | "Moved to `PlatformAdvertisement`", 5 | replaceWith = ReplaceWith("PlatformAdvertisement"), 6 | level = DeprecationLevel.HIDDEN, 7 | ) 8 | public typealias AndroidAdvertisement = PlatformAdvertisement 9 | -------------------------------------------------------------------------------- /kable-core/src/androidMain/kotlin/AndroidScanner.kt: -------------------------------------------------------------------------------- 1 | package com.juul.kable 2 | 3 | @Deprecated( 4 | "Moved to PlatformScanner.", 5 | replaceWith = ReplaceWith("PlatformScanner"), 6 | level = DeprecationLevel.HIDDEN, 7 | ) 8 | public typealias AndroidScanner = PlatformScanner 9 | -------------------------------------------------------------------------------- /kable-core/src/androidMain/kotlin/BluetoothAdapter.kt: -------------------------------------------------------------------------------- 1 | package com.juul.kable 2 | 3 | import android.bluetooth.BluetoothAdapter 4 | import android.bluetooth.BluetoothManager 5 | import androidx.core.content.ContextCompat 6 | 7 | private fun getBluetoothManagerOrNull(): BluetoothManager? = 8 | ContextCompat.getSystemService(applicationContext, BluetoothManager::class.java) 9 | 10 | /** @throws IllegalStateException If bluetooth is unavailable. */ 11 | private fun getBluetoothManager(): BluetoothManager = 12 | getBluetoothManagerOrNull() ?: error("BluetoothManager is not a supported system service") 13 | 14 | /** 15 | * Per documentation, `BluetoothAdapter.getDefaultAdapter()` returns `null` when "Bluetooth is not 16 | * supported on this hardware platform". 17 | * 18 | * https://developer.android.com/reference/android/bluetooth/BluetoothAdapter#getDefaultAdapter() 19 | */ 20 | internal fun getBluetoothAdapterOrNull(): BluetoothAdapter? = 21 | getBluetoothManagerOrNull()?.adapter 22 | 23 | /** @throws IllegalStateException If bluetooth is not supported. */ 24 | internal fun getBluetoothAdapter(): BluetoothAdapter = 25 | getBluetoothManager().adapter ?: error("Bluetooth not supported") 26 | -------------------------------------------------------------------------------- /kable-core/src/androidMain/kotlin/BluetoothDevice.kt: -------------------------------------------------------------------------------- 1 | package com.juul.kable 2 | 3 | import android.annotation.TargetApi 4 | import android.bluetooth.BluetoothDevice 5 | import android.bluetooth.BluetoothDevice.PHY_LE_1M_MASK 6 | import android.bluetooth.BluetoothDevice.PHY_LE_2M_MASK 7 | import android.bluetooth.BluetoothDevice.PHY_LE_CODED_MASK 8 | import android.bluetooth.BluetoothDevice.TRANSPORT_AUTO 9 | import android.bluetooth.BluetoothDevice.TRANSPORT_BREDR 10 | import android.bluetooth.BluetoothDevice.TRANSPORT_LE 11 | import android.bluetooth.BluetoothGatt 12 | import android.bluetooth.BluetoothGattCallback 13 | import android.content.Context 14 | import android.os.Build 15 | import com.juul.kable.gatt.Callback 16 | import com.juul.kable.logs.Logging 17 | import kotlinx.coroutines.flow.MutableSharedFlow 18 | import kotlinx.coroutines.flow.MutableStateFlow 19 | import kotlinx.io.IOException 20 | import kotlin.coroutines.CoroutineContext 21 | import kotlin.time.Duration 22 | 23 | /** 24 | * @param transport is only used on API level >= 23. 25 | * @param phy is only used on API level >= 26. 26 | */ 27 | internal fun BluetoothDevice.connect( 28 | coroutineContext: CoroutineContext, 29 | context: Context, 30 | autoConnect: Boolean, 31 | transport: Transport, 32 | phy: Phy, 33 | state: MutableStateFlow, 34 | services: MutableStateFlow?>, 35 | mtu: MutableStateFlow, 36 | onCharacteristicChanged: MutableSharedFlow>, 37 | logging: Logging, 38 | threadingStrategy: ThreadingStrategy, 39 | disconnectTimeout: Duration, 40 | ): Connection { 41 | val callback = Callback(state, mtu, onCharacteristicChanged, logging, address) 42 | val threading = threadingStrategy.acquire() 43 | 44 | val bluetoothGatt = try { 45 | when { 46 | Build.VERSION.SDK_INT >= Build.VERSION_CODES.O -> { 47 | val handler = (threading as Threading.Handler).handler 48 | connectGatt(context, autoConnect, callback, transport.intValue, phy.intValue, handler) 49 | } 50 | 51 | Build.VERSION.SDK_INT <= Build.VERSION_CODES.M && autoConnect -> 52 | connectGattWithReflection(context, true, callback, transport.intValue) 53 | ?: connectGattCompat(context, true, callback, transport.intValue) 54 | 55 | else -> connectGattCompat(context, autoConnect, callback, transport.intValue) 56 | } ?: throw IOException("Binder remote-invocation error") 57 | } catch (t: Throwable) { 58 | threading.release() 59 | throw t 60 | } 61 | 62 | return Connection(coroutineContext, bluetoothGatt, threading, callback, services, disconnectTimeout, logging) 63 | } 64 | 65 | private fun BluetoothDevice.connectGattCompat( 66 | context: Context, 67 | autoConnect: Boolean, 68 | callback: BluetoothGattCallback, 69 | transport: Int, 70 | ): BluetoothGatt? = when { 71 | Build.VERSION.SDK_INT >= Build.VERSION_CODES.M -> connectGatt(context, autoConnect, callback, transport) 72 | else -> connectGatt(context, autoConnect, callback) 73 | } 74 | 75 | private val Transport.intValue: Int 76 | @TargetApi(Build.VERSION_CODES.M) 77 | get() = when (this) { 78 | Transport.Auto -> TRANSPORT_AUTO 79 | Transport.BrEdr -> TRANSPORT_BREDR 80 | Transport.Le -> TRANSPORT_LE 81 | } 82 | 83 | private val Phy.intValue: Int 84 | @TargetApi(Build.VERSION_CODES.O) 85 | get() = when (this) { 86 | Phy.Le1M -> PHY_LE_1M_MASK 87 | Phy.Le2M -> PHY_LE_2M_MASK 88 | Phy.LeCoded -> PHY_LE_CODED_MASK 89 | } 90 | -------------------------------------------------------------------------------- /kable-core/src/androidMain/kotlin/BluetoothState.kt: -------------------------------------------------------------------------------- 1 | package com.juul.kable 2 | 3 | import android.bluetooth.BluetoothAdapter.ACTION_STATE_CHANGED 4 | import android.bluetooth.BluetoothAdapter.ERROR 5 | import android.bluetooth.BluetoothAdapter.EXTRA_STATE 6 | import android.content.IntentFilter 7 | import com.juul.tuulbox.coroutines.flow.broadcastReceiverFlow 8 | import kotlinx.coroutines.DelicateCoroutinesApi 9 | import kotlinx.coroutines.GlobalScope 10 | import kotlinx.coroutines.flow.SharingStarted.Companion.WhileSubscribed 11 | import kotlinx.coroutines.flow.map 12 | import kotlinx.coroutines.flow.shareIn 13 | 14 | private val intentFilter = IntentFilter(ACTION_STATE_CHANGED) 15 | 16 | @OptIn(DelicateCoroutinesApi::class) 17 | internal val bluetoothState = broadcastReceiverFlow(intentFilter) 18 | .map { intent -> intent.getIntExtra(EXTRA_STATE, ERROR) } 19 | .shareIn(GlobalScope, started = WhileSubscribed(replayExpirationMillis = 0)) 20 | -------------------------------------------------------------------------------- /kable-core/src/androidMain/kotlin/Exceptions.kt: -------------------------------------------------------------------------------- 1 | package com.juul.kable 2 | 3 | import android.bluetooth.BluetoothGatt 4 | import com.juul.kable.AndroidPeripheral.WriteResult 5 | 6 | /** 7 | * Thrown when underlying [BluetoothGatt] write operation call fails. 8 | * 9 | * The reason for the failure is available via the [result] property on Android 13 (API 33) and 10 | * newer. 11 | * 12 | * On Android prior to API 33, [result] is always [Unknown][WriteResult.Unknown], but the failure 13 | * may have been due to any of the conditions listed for [GattRequestRejectedException]. 14 | */ 15 | public class GattWriteException internal constructor( 16 | public val result: WriteResult, 17 | ) : GattRequestRejectedException("Write failed: $result") 18 | -------------------------------------------------------------------------------- /kable-core/src/androidMain/kotlin/Identifier.kt: -------------------------------------------------------------------------------- 1 | package com.juul.kable 2 | 3 | import android.bluetooth.BluetoothAdapter 4 | 5 | public actual typealias Identifier = String 6 | 7 | public actual fun String.toIdentifier(): Identifier { 8 | require(BluetoothAdapter.checkBluetoothAddress(this)) { 9 | "MAC Address has invalid format: $this" 10 | } 11 | return this 12 | } 13 | -------------------------------------------------------------------------------- /kable-core/src/androidMain/kotlin/KableInitializer.kt: -------------------------------------------------------------------------------- 1 | package com.juul.kable 2 | 3 | import android.content.Context 4 | import androidx.startup.Initializer 5 | 6 | internal lateinit var applicationContext: Context 7 | private set 8 | 9 | public object Kable 10 | 11 | public class KableInitializer : Initializer { 12 | 13 | override fun create(context: Context): Kable { 14 | applicationContext = context.applicationContext 15 | return Kable 16 | } 17 | 18 | override fun dependencies(): List>> = emptyList() 19 | } 20 | -------------------------------------------------------------------------------- /kable-core/src/androidMain/kotlin/Observations.kt: -------------------------------------------------------------------------------- 1 | package com.juul.kable 2 | 3 | internal actual fun Peripheral.observationHandler(): Observation.Handler = object : Observation.Handler { 4 | override suspend fun startObservation(characteristic: Characteristic) { 5 | (this@observationHandler as BluetoothDeviceAndroidPeripheral).startObservation(characteristic) 6 | } 7 | 8 | override suspend fun stopObservation(characteristic: Characteristic) { 9 | (this@observationHandler as BluetoothDeviceAndroidPeripheral).stopObservation(characteristic) 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /kable-core/src/androidMain/kotlin/Peripheral.deprecated.kt: -------------------------------------------------------------------------------- 1 | package com.juul.kable 2 | 3 | import android.bluetooth.BluetoothDevice 4 | import kotlinx.coroutines.CoroutineScope 5 | import kotlinx.coroutines.cancel 6 | import kotlinx.coroutines.job 7 | 8 | @Deprecated( 9 | message = "Replaced with `Peripheral` builder function (not a CoroutineScope extension function).", 10 | replaceWith = ReplaceWith("Peripheral(advertisement, builderAction)"), 11 | level = DeprecationLevel.ERROR, 12 | ) 13 | public actual fun CoroutineScope.peripheral( 14 | advertisement: Advertisement, 15 | builderAction: PeripheralBuilderAction, 16 | ): Peripheral { 17 | advertisement as ScanResultAndroidAdvertisement 18 | return peripheral(advertisement.bluetoothDevice, builderAction) 19 | } 20 | 21 | @Deprecated( 22 | message = "Replaced with `Peripheral` builder function (not a CoroutineScope extension function).", 23 | replaceWith = ReplaceWith("Peripheral(bluetoothDevice, builderAction)"), 24 | level = DeprecationLevel.WARNING, 25 | ) 26 | public fun CoroutineScope.peripheral( 27 | bluetoothDevice: BluetoothDevice, 28 | builderAction: PeripheralBuilderAction = {}, 29 | ): Peripheral { 30 | val builder = PeripheralBuilder().apply(builderAction) 31 | val peripheral = BluetoothDeviceAndroidPeripheral( 32 | bluetoothDevice, 33 | builder.autoConnectPredicate, 34 | builder.transport, 35 | builder.phy, 36 | builder.threadingStrategy, 37 | builder.observationExceptionHandler, 38 | builder.onServicesDiscovered, 39 | builder.logging, 40 | builder.disconnectTimeout, 41 | ) 42 | coroutineContext.job.invokeOnCompletion { 43 | peripheral.scope.cancel() 44 | } 45 | return peripheral 46 | } 47 | 48 | @Deprecated( 49 | message = "Replaced with `Peripheral` builder function (not a CoroutineScope extension function).", 50 | replaceWith = ReplaceWith("Peripheral(identifier, builderAction)"), 51 | level = DeprecationLevel.ERROR, 52 | ) 53 | public fun CoroutineScope.peripheral( 54 | identifier: Identifier, 55 | builderAction: PeripheralBuilderAction = {}, 56 | ): Peripheral { 57 | val bluetoothDevice = getBluetoothAdapter().getRemoteDevice(identifier) 58 | return peripheral(bluetoothDevice, builderAction) 59 | } 60 | -------------------------------------------------------------------------------- /kable-core/src/androidMain/kotlin/Peripheral.kt: -------------------------------------------------------------------------------- 1 | package com.juul.kable 2 | 3 | import android.bluetooth.BluetoothDevice 4 | 5 | public actual fun Peripheral( 6 | advertisement: Advertisement, 7 | builderAction: PeripheralBuilderAction, 8 | ): Peripheral { 9 | advertisement as ScanResultAndroidAdvertisement 10 | return Peripheral(advertisement.bluetoothDevice, builderAction) 11 | } 12 | 13 | /** @throws IllegalStateException If bluetooth is not supported. */ 14 | public fun Peripheral( 15 | identifier: Identifier, 16 | builderAction: PeripheralBuilderAction = {}, 17 | ): Peripheral = Peripheral(getBluetoothAdapter().getRemoteDevice(identifier), builderAction) 18 | 19 | public fun Peripheral( 20 | bluetoothDevice: BluetoothDevice, 21 | builderAction: PeripheralBuilderAction = {}, 22 | ): Peripheral { 23 | val builder = PeripheralBuilder().apply(builderAction) 24 | return BluetoothDeviceAndroidPeripheral( 25 | bluetoothDevice, 26 | builder.autoConnectPredicate, 27 | builder.transport, 28 | builder.phy, 29 | builder.threadingStrategy, 30 | builder.observationExceptionHandler, 31 | builder.onServicesDiscovered, 32 | builder.logging, 33 | builder.disconnectTimeout, 34 | ) 35 | } 36 | -------------------------------------------------------------------------------- /kable-core/src/androidMain/kotlin/PlatformAdvertisement.kt: -------------------------------------------------------------------------------- 1 | package com.juul.kable 2 | 3 | import android.os.Parcelable 4 | 5 | public actual interface PlatformAdvertisement : Advertisement, Parcelable { 6 | 7 | public enum class BondState { 8 | None, 9 | Bonding, 10 | Bonded, 11 | } 12 | 13 | public val address: String 14 | public val bondState: BondState 15 | public val bytes: ByteArray? 16 | } 17 | -------------------------------------------------------------------------------- /kable-core/src/androidMain/kotlin/Reflection.kt: -------------------------------------------------------------------------------- 1 | @file:SuppressLint("PrivateApi") 2 | 3 | package com.juul.kable 4 | 5 | import android.annotation.SuppressLint 6 | import android.bluetooth.BluetoothDevice 7 | import android.bluetooth.BluetoothGatt 8 | import android.bluetooth.BluetoothGattCallback 9 | import android.content.Context 10 | 11 | /** 12 | * Workaround for 13 | * [Race condition in BluetoothGatt when using BluetoothDevice#connectGatt()](https://issuetracker.google.com/issues/36995652). 14 | * 15 | * Kotlin adaptation of RxAndroidBle's 16 | * [`BleConnectionCompat.java`](https://github.com/dariuszseweryn/RxAndroidBle/blob/60b99f28766c208179055e96db894c66ac090ad9/rxandroidble/src/main/java/com/polidea/rxandroidble2/internal/util/BleConnectionCompat.java). 17 | */ 18 | internal fun BluetoothDevice.connectGattWithReflection( 19 | context: Context, 20 | autoConnect: Boolean, 21 | callback: BluetoothGattCallback, 22 | transport: Int, 23 | ): BluetoothGatt? { 24 | return try { 25 | val bluetoothAdapter = getBluetoothAdapterOrNull() ?: return null 26 | val iBluetoothManager = bluetoothAdapter.invoke("getBluetoothManager") ?: return null 27 | val iBluetoothGatt = iBluetoothManager.invoke("getBluetoothGatt") ?: return null 28 | 29 | val bluetoothGatt = BluetoothGatt(context, iBluetoothGatt, this, transport) 30 | .apply { setAutoConnect(autoConnect) } 31 | val successful = bluetoothGatt.connect(autoConnect, callback) 32 | bluetoothGatt.takeIf { successful } 33 | } catch (e: ReflectiveOperationException) { 34 | null 35 | } catch (e: IllegalArgumentException) { 36 | null 37 | } 38 | } 39 | 40 | private fun Any.invoke(method: String): Any? = 41 | javaClass.getDeclaredMethod(method).apply { isAccessible = true }.invoke(this) 42 | 43 | private fun BluetoothGatt( 44 | context: Context, 45 | iBluetoothGatt: Any, 46 | bluetoothDevice: BluetoothDevice, 47 | transport: Int, 48 | ): BluetoothGatt { 49 | val constructors = BluetoothGatt::class.java.declaredConstructors 50 | 51 | val withTransport = constructors.firstOrNull { it.parameterTypes.size == 4 } 52 | ?.apply { isAccessible } 53 | ?.run { newInstance(context, iBluetoothGatt, bluetoothDevice, transport) as BluetoothGatt } 54 | if (withTransport != null) return withTransport 55 | 56 | return constructors.firstOrNull { it.parameterTypes.size == 3 } 57 | ?.apply { isAccessible = true } 58 | ?.run { newInstance(context, iBluetoothGatt, bluetoothDevice) as BluetoothGatt } 59 | ?: error("Unsupported BluetoothGatt constructor.") 60 | } 61 | 62 | private fun BluetoothGatt.setAutoConnect(value: Boolean) { 63 | javaClass.getDeclaredField("mAutoConnect").apply { 64 | isAccessible = true 65 | setBoolean(this@setAutoConnect, value) 66 | } 67 | } 68 | 69 | private fun BluetoothGatt.connect( 70 | autoConnect: Boolean, 71 | callback: BluetoothGattCallback, 72 | ): Boolean = javaClass 73 | .getDeclaredMethod("connect", java.lang.Boolean::class.java, BluetoothGattCallback::class.java) 74 | .run { 75 | isAccessible = true 76 | invoke(this@connect, autoConnect, callback) as Boolean 77 | } 78 | .also { successful -> if (!successful) close() } 79 | -------------------------------------------------------------------------------- /kable-core/src/androidMain/kotlin/ScannerBuilder.kt: -------------------------------------------------------------------------------- 1 | package com.juul.kable 2 | 3 | import android.bluetooth.le.ScanSettings 4 | import com.juul.kable.logs.Logging 5 | import com.juul.kable.logs.LoggingBuilder 6 | import kotlinx.coroutines.flow.Flow 7 | import kotlinx.coroutines.flow.conflate 8 | import kotlinx.coroutines.runBlocking 9 | 10 | public actual class ScannerBuilder { 11 | 12 | @Deprecated( 13 | message = "Use filters(FiltersBuilder.() -> Unit)", 14 | replaceWith = ReplaceWith("filters { }"), 15 | level = DeprecationLevel.HIDDEN, 16 | ) 17 | public actual var filters: List? = null 18 | 19 | private var filterPredicates: List = emptyList() 20 | 21 | public actual fun filters(builderAction: FiltersBuilder.() -> Unit) { 22 | filterPredicates = FiltersBuilder().apply(builderAction).build() 23 | } 24 | 25 | /** 26 | * Allows for the [Scanner] to be configured via Android's [ScanSettings]. 27 | * 28 | * This property will be removed in a future version, and will be replaced by a Kable provided DSL for configuring 29 | * scanning. 30 | */ 31 | @ObsoleteKableApi 32 | public var scanSettings: ScanSettings = ScanSettings.Builder().build() 33 | 34 | private var logging: Logging = Logging() 35 | 36 | /** 37 | * Configures [Scanner] to pre-conflate the [advertisements][Scanner.advertisements] flow. 38 | * 39 | * Roughly equivalent to applying the [conflate][Flow.conflate] flow operator on the 40 | * [advertisements][Scanner.advertisements] property (but without [runBlocking] overhead). 41 | * 42 | * May prevent ANRs on some Android phones (observed on specific Samsung models) that have 43 | * delicate binder threads. 44 | * 45 | * See https://github.com/JuulLabs/kable/issues/654 for more details. 46 | */ 47 | public var preConflate: Boolean = false 48 | 49 | public actual fun logging(init: LoggingBuilder) { 50 | logging = Logging().apply(init) 51 | } 52 | 53 | @OptIn(ObsoleteKableApi::class) 54 | internal actual fun build(): PlatformScanner = BluetoothLeScannerAndroidScanner( 55 | filters = filterPredicates, 56 | scanSettings = scanSettings, 57 | logging = logging, 58 | preConflate = preConflate, 59 | ) 60 | } 61 | -------------------------------------------------------------------------------- /kable-core/src/androidMain/kotlin/Threading.kt: -------------------------------------------------------------------------------- 1 | package com.juul.kable 2 | 3 | import android.os.Build 4 | import android.os.Handler 5 | import android.os.HandlerThread 6 | import kotlinx.coroutines.CoroutineDispatcher 7 | import kotlinx.coroutines.DelicateCoroutinesApi 8 | import kotlinx.coroutines.ExecutorCoroutineDispatcher 9 | import kotlinx.coroutines.ExperimentalCoroutinesApi 10 | import kotlinx.coroutines.android.asCoroutineDispatcher 11 | import kotlinx.coroutines.newSingleThreadContext 12 | 13 | public sealed class Threading { 14 | 15 | internal abstract val dispatcher: CoroutineDispatcher 16 | internal abstract val strategy: ThreadingStrategy 17 | 18 | /** Used on Android O (API 26) and above. */ 19 | internal data class Handler( 20 | val thread: HandlerThread, 21 | val handler: android.os.Handler, 22 | override val dispatcher: CoroutineDispatcher, 23 | override val strategy: ThreadingStrategy, 24 | ) : Threading() 25 | 26 | /** Used on Android versions **lower** than Android O (API 26). */ 27 | internal data class SingleThreadContext( 28 | val name: String, 29 | override val dispatcher: ExecutorCoroutineDispatcher, 30 | override val strategy: ThreadingStrategy, 31 | ) : Threading() 32 | } 33 | 34 | internal fun Threading.release() { 35 | strategy.release(this) 36 | } 37 | 38 | public val Threading.name: String 39 | get() = when (this) { 40 | is Threading.Handler -> thread.name 41 | is Threading.SingleThreadContext -> name 42 | } 43 | 44 | public fun Threading.shutdown() { 45 | when (this) { 46 | is Threading.Handler -> thread.quit() 47 | is Threading.SingleThreadContext -> dispatcher.close() 48 | } 49 | } 50 | 51 | /** 52 | * Creates [Threading] that can be used for Bluetooth communication. The returned [Threading] is 53 | * returned in a started state and must be [shutdown] when no longer needed. 54 | */ 55 | public fun ThreadingStrategy.Threading(name: String): Threading = 56 | if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { 57 | val thread = HandlerThread(name).apply { start() } 58 | val handler = Handler(thread.looper) 59 | val dispatcher = handler.asCoroutineDispatcher() 60 | Threading.Handler(thread, handler, dispatcher, this) 61 | } else { // Build.VERSION.SDK_INT < Build.VERSION_CODES.O 62 | @OptIn(DelicateCoroutinesApi::class, ExperimentalCoroutinesApi::class) 63 | Threading.SingleThreadContext(name, newSingleThreadContext(name), this) 64 | } 65 | -------------------------------------------------------------------------------- /kable-core/src/androidMain/kotlin/ThreadingStrategy.kt: -------------------------------------------------------------------------------- 1 | package com.juul.kable 2 | 3 | import com.juul.kable.OnDemandThreadingStrategy.acquire 4 | import com.juul.kable.OnDemandThreadingStrategy.release 5 | import kotlinx.atomicfu.atomic 6 | import kotlinx.atomicfu.locks.reentrantLock 7 | import kotlinx.atomicfu.locks.withLock 8 | import kotlinx.coroutines.CoroutineScope 9 | import kotlinx.coroutines.GlobalScope 10 | import kotlinx.coroutines.delay 11 | import kotlinx.coroutines.launch 12 | import kotlin.coroutines.cancellation.CancellationException 13 | import kotlin.time.Duration 14 | import kotlin.time.Duration.Companion.minutes 15 | import kotlin.time.TimeMark 16 | import kotlin.time.TimeSource 17 | 18 | private val threadNumber = atomic(0) 19 | private fun generateThreadName() = "Kable#${threadNumber.incrementAndGet()}" 20 | 21 | public interface ThreadingStrategy { 22 | public fun acquire(): Threading 23 | public fun release(threading: Threading) 24 | } 25 | 26 | /** 27 | * A [ThreadingStrategy] that creates ["threads"][Threading] when [acquired][acquire] and 28 | * immediately shuts down when [released][release]. 29 | */ 30 | public object OnDemandThreadingStrategy : ThreadingStrategy { 31 | 32 | override fun acquire(): Threading = Threading(generateThreadName()) 33 | 34 | override fun release(threading: Threading) { 35 | threading.shutdown() 36 | } 37 | } 38 | 39 | /** 40 | * A [ThreadingStrategy] that pools unused ["threads"][Threading] until [evictAfter] time has 41 | * elapsed. 42 | * 43 | * In most circumstances, only a a single [PooledThreadingStrategy] instance should be created per 44 | * application run, as it holds the "shared" pool of unused ["threads"][Threading]. 45 | * 46 | * Useful for when [Peripheral] connections are quickly being spun down and up again — as they can 47 | * re-use existing ["threads"][Threading] ([acquire] their ["threads"][Threading] from the unused 48 | * pool). 49 | * 50 | * If [Peripheral] connections are expected to be long running, or for there to be long down times 51 | * between connections, [OnDemandThreadingStrategy] may be a better choice. 52 | */ 53 | public class PooledThreadingStrategy( 54 | scope: CoroutineScope = GlobalScope, 55 | private val evictAfter: Duration = 1.minutes, 56 | ) : ThreadingStrategy { 57 | 58 | private val pool = mutableListOf>() 59 | private val guard = reentrantLock() 60 | 61 | private val job = scope.launch { 62 | try { 63 | while (true) { 64 | guard.withLock { 65 | pool.removeAll { (timeMark) -> timeMark.hasPassedNow() } 66 | } 67 | delay(evictAfter / 2) 68 | } 69 | } catch (e: CancellationException) { 70 | guard.withLock { 71 | check(pool.isEmpty()) { 72 | "PooledThreadStrategy must complete with an empty pool, but had ${pool.count()} threads in pool" 73 | } 74 | } 75 | throw CancellationException(e) 76 | } 77 | } 78 | 79 | public fun cancel(): Unit = job.cancel() 80 | 81 | override fun acquire(): Threading = guard.withLock { 82 | pool.removeFirstOrNull() 83 | ?.let { (_, threading) -> threading } 84 | } ?: Threading(generateThreadName()) 85 | 86 | override fun release(threading: Threading) { 87 | guard.withLock { 88 | val evictAt = TimeSource.Monotonic.markNow() + evictAfter 89 | pool.add(evictAt to threading) 90 | } 91 | } 92 | } 93 | -------------------------------------------------------------------------------- /kable-core/src/androidMain/kotlin/bluetooth/CheckBluetoothIsOn.kt: -------------------------------------------------------------------------------- 1 | package com.juul.kable.bluetooth 2 | 3 | import android.bluetooth.BluetoothAdapter.STATE_OFF 4 | import android.bluetooth.BluetoothAdapter.STATE_ON 5 | import android.bluetooth.BluetoothAdapter.STATE_TURNING_OFF 6 | import android.bluetooth.BluetoothAdapter.STATE_TURNING_ON 7 | import com.juul.kable.InternalError 8 | import com.juul.kable.UnmetRequirementException 9 | import com.juul.kable.UnmetRequirementReason.BluetoothDisabled 10 | import com.juul.kable.getBluetoothAdapter 11 | 12 | /** 13 | * @throws IllegalStateException If bluetooth is not supported. 14 | * @throws UnmetRequirementException If bluetooth adapter state is not [STATE_ON]. 15 | */ 16 | internal fun checkBluetoothIsOn() { 17 | val actual = getBluetoothAdapter().state 18 | val expected = STATE_ON 19 | if (actual != expected) { 20 | throw UnmetRequirementException( 21 | reason = BluetoothDisabled, 22 | message = "Bluetooth was ${nameFor(actual)}, but ${nameFor(expected)} was required", 23 | ) 24 | } 25 | } 26 | 27 | private fun nameFor(state: Int) = when (state) { 28 | STATE_OFF -> "STATE_OFF" 29 | STATE_ON -> "STATE_ON" 30 | STATE_TURNING_OFF -> "STATE_TURNING_OFF" 31 | STATE_TURNING_ON -> "STATE_TURNING_ON" 32 | else -> throw InternalError("Unsupported bluetooth state: $state") 33 | } 34 | -------------------------------------------------------------------------------- /kable-core/src/androidMain/kotlin/bluetooth/CheckMacAddress.kt: -------------------------------------------------------------------------------- 1 | package com.juul.kable.bluetooth 2 | 3 | import android.bluetooth.BluetoothDevice 4 | 5 | private const val ZERO_MAC_ADDRESS = "00:00:00:00:00:00" 6 | 7 | /** Performs the same MAC address validation as performed in [BluetoothDevice.connectGatt]. */ 8 | internal fun requireNonZeroAddress(address: String): String { 9 | require(ZERO_MAC_ADDRESS != address) { "Invalid address: $address" } 10 | return address 11 | } 12 | -------------------------------------------------------------------------------- /kable-core/src/androidMain/kotlin/bluetooth/ClientCharacteristicConfigUuid.kt: -------------------------------------------------------------------------------- 1 | package com.juul.kable.bluetooth 2 | 3 | import com.juul.kable.Bluetooth 4 | import kotlin.uuid.toJavaUuid 5 | 6 | internal val clientCharacteristicConfigUuid = (Bluetooth.BaseUuid + 0x2902).toJavaUuid() 7 | -------------------------------------------------------------------------------- /kable-core/src/androidMain/kotlin/bluetooth/IsSupported.kt: -------------------------------------------------------------------------------- 1 | package com.juul.kable.bluetooth 2 | 3 | import android.content.pm.PackageManager.FEATURE_BLUETOOTH_LE 4 | import com.juul.kable.applicationContext 5 | import com.juul.kable.getBluetoothAdapterOrNull 6 | 7 | internal actual suspend fun isSupported(): Boolean = 8 | applicationContext.packageManager.hasSystemFeature(FEATURE_BLUETOOTH_LE) && 9 | getBluetoothAdapterOrNull() != null 10 | -------------------------------------------------------------------------------- /kable-core/src/androidMain/kotlin/external/Constants.kt: -------------------------------------------------------------------------------- 1 | package com.juul.kable.external 2 | 3 | // https://android.googlesource.com/platform/external/libnfc-nci/+/lollipop-release/src/include/hcidefs.h#447 4 | private const val HCI_ERR_CONNECTION_TOUT = 0x08 5 | private const val HCI_ERR_PEER_USER = 0x13 6 | private const val HCI_ERR_CONN_CAUSE_LOCAL_HOST = 0x16 7 | private const val HCI_ERR_LMP_RESPONSE_TIMEOUT = 0x22 8 | private const val HCI_ERR_CONN_FAILED_ESTABLISHMENT = 0x3E 9 | 10 | // https://android.googlesource.com/platform/external/bluetooth/bluedroid/+/lollipop-release/stack/include/l2cdefs.h#87 11 | private const val L2CAP_CONN_CANCEL = 256 12 | 13 | // https://android.googlesource.com/platform/external/bluetooth/bluedroid/+/lollipop-release/stack/include/gatt_api.h#106 14 | internal const val GATT_CONN_L2C_FAILURE = 1 15 | internal const val GATT_CONN_TIMEOUT = HCI_ERR_CONNECTION_TOUT 16 | internal const val GATT_CONN_TERMINATE_PEER_USER = HCI_ERR_PEER_USER 17 | internal const val GATT_CONN_TERMINATE_LOCAL_HOST = HCI_ERR_CONN_CAUSE_LOCAL_HOST 18 | internal const val GATT_CONN_FAIL_ESTABLISH = HCI_ERR_CONN_FAILED_ESTABLISHMENT 19 | internal const val GATT_CONN_LMP_TIMEOUT = HCI_ERR_LMP_RESPONSE_TIMEOUT 20 | internal const val GATT_CONN_CANCEL = L2CAP_CONN_CANCEL 21 | -------------------------------------------------------------------------------- /kable-core/src/androidMain/kotlin/gatt/Response.kt: -------------------------------------------------------------------------------- 1 | package com.juul.kable.gatt 2 | 3 | import android.bluetooth.BluetoothGattCharacteristic 4 | import android.bluetooth.BluetoothGattDescriptor 5 | import android.bluetooth.BluetoothGattService 6 | import com.juul.kable.android.GattStatus 7 | 8 | internal sealed class Response { 9 | 10 | abstract val status: GattStatus 11 | 12 | data class OnReadRemoteRssi( 13 | val rssi: Int, 14 | override val status: GattStatus, 15 | ) : Response() 16 | 17 | data class OnServicesDiscovered( 18 | override val status: GattStatus, 19 | val services: List, 20 | ) : Response() 21 | 22 | data class OnCharacteristicRead( 23 | val characteristic: BluetoothGattCharacteristic, 24 | val value: ByteArray?, 25 | override val status: GattStatus, 26 | ) : Response() { 27 | override fun toString(): String = 28 | "OnCharacteristicRead(characteristic=${characteristic.uuid}, value=${value?.size ?: 0} bytes, status=$status)" 29 | } 30 | 31 | data class OnCharacteristicWrite( 32 | val characteristic: BluetoothGattCharacteristic, 33 | override val status: GattStatus, 34 | ) : Response() { 35 | override fun toString(): String = 36 | "OnCharacteristicWrite(characteristic=${characteristic.uuid}, status=$status)" 37 | } 38 | 39 | data class OnDescriptorRead( 40 | val descriptor: BluetoothGattDescriptor, 41 | val value: ByteArray?, 42 | override val status: GattStatus, 43 | ) : Response() { 44 | override fun toString(): String = 45 | "OnDescriptorRead(descriptor=${descriptor.uuid}, value=${value?.size ?: 0} bytes, status=$status)" 46 | } 47 | 48 | data class OnDescriptorWrite( 49 | val descriptor: BluetoothGattDescriptor, 50 | override val status: GattStatus, 51 | ) : Response() { 52 | override fun toString(): String = 53 | "OnDescriptorWrite(descriptor=${descriptor.uuid}, status=$status)" 54 | } 55 | } 56 | 57 | internal data class OnMtuChanged( 58 | val mtu: Int, 59 | override val status: GattStatus, 60 | ) : Response() 61 | 62 | internal object OnServiceChanged 63 | -------------------------------------------------------------------------------- /kable-core/src/androidMain/kotlin/logs/LogMessage.kt: -------------------------------------------------------------------------------- 1 | @file:JvmName("AndroidLogMessage") 2 | 3 | package com.juul.kable.logs 4 | 5 | import android.bluetooth.BluetoothGattCharacteristic 6 | import android.bluetooth.BluetoothGattDescriptor 7 | import com.juul.kable.android.GattStatus 8 | import kotlin.uuid.toKotlinUuid 9 | 10 | internal actual val LOG_INDENT: String? = null 11 | 12 | internal fun LogMessage.detail(status: GattStatus) { 13 | detail("status", status.toString()) 14 | } 15 | 16 | internal fun LogMessage.detail(characteristic: BluetoothGattCharacteristic) { 17 | detail( 18 | characteristic.service.uuid.toKotlinUuid(), 19 | characteristic.uuid.toKotlinUuid(), 20 | ) 21 | } 22 | 23 | internal fun LogMessage.detail(descriptor: BluetoothGattDescriptor) { 24 | detail( 25 | descriptor.characteristic.service.uuid.toKotlinUuid(), 26 | descriptor.characteristic.uuid.toKotlinUuid(), 27 | descriptor.uuid.toKotlinUuid(), 28 | ) 29 | } 30 | -------------------------------------------------------------------------------- /kable-core/src/androidMain/kotlin/logs/SystemLogEngine.kt: -------------------------------------------------------------------------------- 1 | package com.juul.kable.logs 2 | 3 | import android.util.Log 4 | 5 | public actual object SystemLogEngine : LogEngine { 6 | actual override fun verbose(throwable: Throwable?, tag: String, message: String) { 7 | Log.v(tag, message, throwable) 8 | } 9 | 10 | actual override fun debug(throwable: Throwable?, tag: String, message: String) { 11 | Log.d(tag, message, throwable) 12 | } 13 | 14 | actual override fun info(throwable: Throwable?, tag: String, message: String) { 15 | Log.i(tag, message, throwable) 16 | } 17 | 18 | actual override fun warn(throwable: Throwable?, tag: String, message: String) { 19 | Log.w(tag, message, throwable) 20 | } 21 | 22 | actual override fun error(throwable: Throwable?, tag: String, message: String) { 23 | Log.e(tag, message, throwable) 24 | } 25 | 26 | actual override fun assert(throwable: Throwable?, tag: String, message: String) { 27 | Log.wtf(tag, message, throwable) 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /kable-core/src/androidMain/kotlin/scan/ScanError.kt: -------------------------------------------------------------------------------- 1 | package com.juul.kable.scan 2 | 3 | import android.bluetooth.le.ScanCallback.SCAN_FAILED_ALREADY_STARTED 4 | import android.bluetooth.le.ScanCallback.SCAN_FAILED_APPLICATION_REGISTRATION_FAILED 5 | import android.bluetooth.le.ScanCallback.SCAN_FAILED_FEATURE_UNSUPPORTED 6 | import android.bluetooth.le.ScanCallback.SCAN_FAILED_INTERNAL_ERROR 7 | import android.bluetooth.le.ScanCallback.SCAN_FAILED_OUT_OF_HARDWARE_RESOURCES 8 | import android.bluetooth.le.ScanCallback.SCAN_FAILED_SCANNING_TOO_FREQUENTLY 9 | 10 | @JvmInline 11 | internal value class ScanError(internal val errorCode: Int) { 12 | 13 | override fun toString(): String = when (errorCode) { 14 | SCAN_FAILED_ALREADY_STARTED -> "SCAN_FAILED_ALREADY_STARTED" 15 | SCAN_FAILED_APPLICATION_REGISTRATION_FAILED -> "SCAN_FAILED_APPLICATION_REGISTRATION_FAILED" 16 | SCAN_FAILED_INTERNAL_ERROR -> "SCAN_FAILED_INTERNAL_ERROR" 17 | SCAN_FAILED_FEATURE_UNSUPPORTED -> "SCAN_FAILED_FEATURE_UNSUPPORTED" 18 | SCAN_FAILED_OUT_OF_HARDWARE_RESOURCES -> "SCAN_FAILED_OUT_OF_HARDWARE_RESOURCES" 19 | SCAN_FAILED_SCANNING_TOO_FREQUENTLY -> "SCAN_FAILED_SCANNING_TOO_FREQUENTLY" 20 | else -> "UNKNOWN" 21 | }.let { name -> "$name($errorCode)" } 22 | } 23 | 24 | internal val ScanError.message: String 25 | get() = when (errorCode) { 26 | SCAN_FAILED_ALREADY_STARTED -> 27 | "Failed to start scan as BLE scan with the same settings is already started by the app" 28 | 29 | // Can occur if app has not been granted permission to scan (e.g. missing location permission). 30 | // https://github.com/NordicSemiconductor/Android-Scanner-Compat-Library/issues/73 31 | SCAN_FAILED_APPLICATION_REGISTRATION_FAILED -> 32 | "Failed to start scan as app cannot be registered" 33 | 34 | SCAN_FAILED_INTERNAL_ERROR -> "Failed to start scan due to an internal error" 35 | 36 | SCAN_FAILED_FEATURE_UNSUPPORTED -> 37 | "Failed to start power optimized scan as this feature is not supported" 38 | 39 | SCAN_FAILED_OUT_OF_HARDWARE_RESOURCES -> 40 | "Failed to start scan as it is out of hardware resources" 41 | 42 | SCAN_FAILED_SCANNING_TOO_FREQUENTLY -> 43 | "Failed to start scan as application tries to scan too frequently" 44 | 45 | else -> "Unknown scan error code: $errorCode" 46 | } 47 | -------------------------------------------------------------------------------- /kable-core/src/androidMain/kotlin/scan/requirements/BluetoothLeScanner.kt: -------------------------------------------------------------------------------- 1 | package com.juul.kable.scan.requirements 2 | 3 | import android.bluetooth.le.BluetoothLeScanner 4 | import com.juul.kable.UnmetRequirementException 5 | import com.juul.kable.UnmetRequirementReason.BluetoothDisabled 6 | import com.juul.kable.getBluetoothAdapter 7 | 8 | /** 9 | * @throws IllegalStateException If bluetooth is not supported. 10 | * @throws UnmetRequirementException If bluetooth is disabled. 11 | */ 12 | internal fun requireBluetoothLeScanner(): BluetoothLeScanner = 13 | getBluetoothAdapter().bluetoothLeScanner 14 | ?: throw UnmetRequirementException(BluetoothDisabled, "Bluetooth disabled") 15 | -------------------------------------------------------------------------------- /kable-core/src/androidMain/kotlin/scan/requirements/LocationServicesEnabled.kt: -------------------------------------------------------------------------------- 1 | package com.juul.kable.scan.requirements 2 | 3 | import android.content.Context 4 | import android.location.LocationManager 5 | import android.os.Build.VERSION.SDK_INT 6 | import android.os.Build.VERSION_CODES.R 7 | import androidx.core.content.ContextCompat 8 | import androidx.core.location.LocationManagerCompat 9 | import com.juul.kable.UnmetRequirementException 10 | import com.juul.kable.UnmetRequirementReason.LocationServicesDisabled 11 | import com.juul.kable.applicationContext 12 | 13 | internal fun checkLocationServicesEnabled() { 14 | if (SDK_INT > R) return 15 | val locationManager = applicationContext.getLocationManagerOrNull() 16 | ?: error("Location manager unavailable") 17 | if (!LocationManagerCompat.isLocationEnabled(locationManager)) { 18 | throw UnmetRequirementException( 19 | LocationServicesDisabled, 20 | "Location services are required for scanning but are disabled", 21 | ) 22 | } 23 | } 24 | 25 | private fun Context.getLocationManagerOrNull() = 26 | ContextCompat.getSystemService(this, LocationManager::class.java) 27 | -------------------------------------------------------------------------------- /kable-core/src/androidMain/kotlin/scan/requirements/ScanPermissions.kt: -------------------------------------------------------------------------------- 1 | package com.juul.kable.scan.requirements 2 | 3 | import android.Manifest.permission.ACCESS_COARSE_LOCATION 4 | import android.Manifest.permission.ACCESS_FINE_LOCATION 5 | import android.Manifest.permission.BLUETOOTH_SCAN 6 | import android.content.pm.PackageManager.PERMISSION_GRANTED 7 | import android.os.Build.VERSION.SDK_INT 8 | import android.os.Build.VERSION_CODES.P 9 | import android.os.Build.VERSION_CODES.R 10 | import androidx.core.content.ContextCompat 11 | import com.juul.kable.applicationContext 12 | 13 | private val requiredPermission = when { 14 | // If your app targets Android 9 (API level 28) or lower, you can declare the ACCESS_COARSE_LOCATION permission 15 | // instead of the ACCESS_FINE_LOCATION permission. 16 | // https://developer.android.com/guide/topics/connectivity/bluetooth/permissions#declare-android11-or-lower 17 | SDK_INT <= P -> ACCESS_COARSE_LOCATION 18 | 19 | // ACCESS_FINE_LOCATION is necessary because, on Android 11 (API level 30) and lower, a Bluetooth scan could 20 | // potentially be used to gather information about the location of the user. 21 | // https://developer.android.com/guide/topics/connectivity/bluetooth/permissions#declare-android11-or-lower 22 | SDK_INT <= R -> ACCESS_FINE_LOCATION 23 | 24 | // If your app targets Android 12 (API level 31) or higher, declare the following permissions in your app's 25 | // manifest file: 26 | // 27 | // 1. If your app looks for Bluetooth devices, such as BLE peripherals, declare the `BLUETOOTH_SCAN` permission. 28 | // 2. If your app makes the current device discoverable to other Bluetooth devices, declare the 29 | // `BLUETOOTH_ADVERTISE` permission. 30 | // 3. If your app communicates with already-paired Bluetooth devices, declare the BLUETOOTH_CONNECT permission. 31 | // https://developer.android.com/guide/topics/connectivity/bluetooth/permissions#declare-android12-or-higher 32 | else /* SDK_INT >= S */ -> BLUETOOTH_SCAN 33 | } 34 | 35 | /** @throws IllegalStateException If the required permissions for scanning have not been granted. */ 36 | internal fun checkScanPermissions() { 37 | if (ContextCompat.checkSelfPermission(applicationContext, requiredPermission) != PERMISSION_GRANTED) { 38 | error("Missing required $requiredPermission for scanning") 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /kable-core/src/androidUnitTest/kotlin/com/juul/kable/ProfileTests.kt: -------------------------------------------------------------------------------- 1 | package com.juul.kable 2 | 3 | import android.bluetooth.BluetoothGattCharacteristic 4 | import android.bluetooth.BluetoothGattDescriptor 5 | import android.bluetooth.BluetoothGattService 6 | import io.mockk.every 7 | import io.mockk.mockk 8 | import nl.jqno.equalsverifier.EqualsVerifier 9 | import nl.jqno.equalsverifier.api.SingleTypeEqualsVerifierApi 10 | import kotlin.test.Test 11 | import kotlin.uuid.toJavaUuid 12 | 13 | class ProfileTests { 14 | 15 | @Test 16 | fun PlatformDiscoveredService_equals_verified() { 17 | EqualsVerifier 18 | .forClass(PlatformDiscoveredService::class.java) 19 | .withIgnoredFields("characteristics") 20 | .withMocks() 21 | .verify() 22 | } 23 | 24 | @Test 25 | fun PlatformDiscoveredCharacteristic_equals_verified() { 26 | EqualsVerifier 27 | .forClass(PlatformDiscoveredCharacteristic::class.java) 28 | .withIgnoredFields("descriptors") 29 | .withMocks() 30 | .verify() 31 | } 32 | 33 | @Test 34 | fun PlatformDiscoveredDescriptor_equals_verified() { 35 | EqualsVerifier 36 | .forClass(PlatformDiscoveredDescriptor::class.java) 37 | .withMocks() 38 | .verify() 39 | } 40 | } 41 | 42 | private val redService = mockk { 43 | every { instanceId } returns 1 44 | every { uuid } returns (Bluetooth.BaseUuid + 0x1).toJavaUuid() 45 | } 46 | private val blueService = mockk { 47 | every { instanceId } returns 2 48 | every { uuid } returns (Bluetooth.BaseUuid + 0x2).toJavaUuid() 49 | } 50 | 51 | private val redCharacteristic = mockk { 52 | every { instanceId } returns 3 53 | every { service } returns redService 54 | every { uuid } returns (Bluetooth.BaseUuid + 0x3).toJavaUuid() 55 | } 56 | private val blueCharacteristic = mockk { 57 | every { instanceId } returns 4 58 | every { service } returns redService 59 | every { uuid } returns (Bluetooth.BaseUuid + 0x4).toJavaUuid() 60 | } 61 | 62 | private val redDescriptor = mockk { 63 | every { characteristic } returns redCharacteristic 64 | every { uuid } returns (Bluetooth.BaseUuid + 0x5).toJavaUuid() 65 | } 66 | private val blueDescriptor = mockk { 67 | every { characteristic } returns blueCharacteristic 68 | every { uuid } returns (Bluetooth.BaseUuid + 0x6).toJavaUuid() 69 | } 70 | 71 | private fun SingleTypeEqualsVerifierApi.withMocks(): SingleTypeEqualsVerifierApi = 72 | withPrefabValues(BluetoothGattService::class.java, redService, blueService) 73 | .withPrefabValues(BluetoothGattCharacteristic::class.java, redCharacteristic, blueCharacteristic) 74 | .withPrefabValues(BluetoothGattDescriptor::class.java, redDescriptor, blueDescriptor) 75 | -------------------------------------------------------------------------------- /kable-core/src/appleMain/kotlin/AdvertisementData.kt: -------------------------------------------------------------------------------- 1 | package com.juul.kable 2 | 3 | import platform.CoreBluetooth.CBAdvertisementDataLocalNameKey 4 | import platform.CoreBluetooth.CBAdvertisementDataManufacturerDataKey 5 | import platform.CoreBluetooth.CBAdvertisementDataServiceDataKey 6 | import platform.CoreBluetooth.CBAdvertisementDataServiceUUIDsKey 7 | import platform.CoreBluetooth.CBUUID 8 | import platform.Foundation.NSData 9 | import kotlin.uuid.Uuid 10 | 11 | internal value class AdvertisementData(private val source: Map) { 12 | 13 | val serviceUuids: List? 14 | get() = (source[CBAdvertisementDataServiceUUIDsKey] as? List)?.map(CBUUID::toUuid) 15 | 16 | val localName: String? 17 | get() = source[CBAdvertisementDataLocalNameKey] as? String 18 | 19 | val manufacturerData: ManufacturerData? 20 | get() = (source[CBAdvertisementDataManufacturerDataKey] as? NSData)?.toManufacturerData() 21 | 22 | /** 23 | * Per [CBAdvertisementDataServiceDataKey](https://developer.apple.com/documentation/corebluetooth/cbadvertisementdataservicedatakey): 24 | * 25 | * > A dictionary that contains service-specific advertisement data. 26 | * > The keys ([CBUUID] objects) represent `CBService` UUIDs, and the values ([NSData] objects) 27 | * > represent service-specific data. 28 | */ 29 | @Suppress("UNCHECKED_CAST") 30 | val serviceData: Map? 31 | get() = source[CBAdvertisementDataServiceDataKey] as Map? 32 | } 33 | 34 | internal fun Map.asAdvertisementData() = AdvertisementData(this) 35 | -------------------------------------------------------------------------------- /kable-core/src/appleMain/kotlin/ApplePeripheral.kt: -------------------------------------------------------------------------------- 1 | package com.juul.kable 2 | 3 | @Deprecated( 4 | message = "Renamed to `CoreBluetoothPeripheral`.", 5 | replaceWith = ReplaceWith( 6 | expression = "CoreBluetoothPeripheral", 7 | imports = ["com.juul.kable.CoreBluetoothPeripheral"], 8 | ), 9 | level = DeprecationLevel.HIDDEN, 10 | ) 11 | public typealias ApplePeripheral = CoreBluetoothPeripheral 12 | -------------------------------------------------------------------------------- /kable-core/src/appleMain/kotlin/Bluetooth.kt: -------------------------------------------------------------------------------- 1 | package com.juul.kable 2 | 3 | import com.juul.kable.Bluetooth.Availability.Available 4 | import com.juul.kable.Bluetooth.Availability.Unavailable 5 | import com.juul.kable.Reason.Off 6 | import com.juul.kable.Reason.Resetting 7 | import com.juul.kable.Reason.Unauthorized 8 | import com.juul.kable.Reason.Unknown 9 | import com.juul.kable.Reason.Unsupported 10 | import kotlinx.coroutines.flow.Flow 11 | import kotlinx.coroutines.flow.emitAll 12 | import kotlinx.coroutines.flow.flow 13 | import kotlinx.coroutines.flow.map 14 | import platform.CoreBluetooth.CBCentralManagerStatePoweredOff 15 | import platform.CoreBluetooth.CBCentralManagerStatePoweredOn 16 | import platform.CoreBluetooth.CBCentralManagerStateResetting 17 | import platform.CoreBluetooth.CBCentralManagerStateUnauthorized 18 | import platform.CoreBluetooth.CBCentralManagerStateUnsupported 19 | 20 | /** https://developer.apple.com/documentation/corebluetooth/cbmanagerstate */ 21 | @Deprecated( 22 | message = "`Bluetooth.availability` has inconsistent behavior across platforms. " + 23 | "Will be removed in a future release. " + 24 | "See https://github.com/JuulLabs/kable/issues/737 for more details.", 25 | ) 26 | public actual enum class Reason { 27 | @Deprecated( 28 | message = "`Bluetooth.availability` has inconsistent behavior across platforms. " + 29 | "Will be removed in a future release. " + 30 | "See https://github.com/JuulLabs/kable/issues/737 for more details.", 31 | ) 32 | Off, // CBManagerState.poweredOff 33 | 34 | @Deprecated( 35 | message = "`Bluetooth.availability` has inconsistent behavior across platforms. " + 36 | "Will be removed in a future release. " + 37 | "See https://github.com/JuulLabs/kable/issues/737 for more details.", 38 | ) 39 | Resetting, // CBManagerState.resetting 40 | 41 | @Deprecated( 42 | message = "`Bluetooth.availability` has inconsistent behavior across platforms. " + 43 | "Will be removed in a future release. " + 44 | "See https://github.com/JuulLabs/kable/issues/737 for more details.", 45 | ) 46 | Unauthorized, // CBManagerState.unauthorized 47 | 48 | @Deprecated( 49 | message = "`Bluetooth.availability` has inconsistent behavior across platforms. " + 50 | "Will be removed in a future release. " + 51 | "See https://github.com/JuulLabs/kable/issues/737 for more details.", 52 | ) 53 | Unsupported, // CBManagerState.unsupported 54 | 55 | @Deprecated( 56 | message = "`Bluetooth.availability` has inconsistent behavior across platforms. " + 57 | "Will be removed in a future release. " + 58 | "See https://github.com/JuulLabs/kable/issues/737 for more details.", 59 | ) 60 | Unknown, // CBManagerState.unknown 61 | } 62 | 63 | internal actual val bluetoothAvailability: Flow = flow { 64 | // flow + emitAll dance so that lazy `CentralManager.Default` is not initialized until this flow is active. 65 | emitAll(CentralManager.Default.delegate.state) 66 | }.map { state -> 67 | when (state) { 68 | CBCentralManagerStatePoweredOn -> Available 69 | CBCentralManagerStatePoweredOff -> Unavailable(reason = Off) 70 | CBCentralManagerStateResetting -> Unavailable(reason = Resetting) 71 | CBCentralManagerStateUnauthorized -> Unavailable(reason = Unauthorized) 72 | CBCentralManagerStateUnsupported -> Unavailable(reason = Unsupported) 73 | else -> Unavailable(reason = Unknown) 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /kable-core/src/appleMain/kotlin/CentralManagerConfiguration.kt: -------------------------------------------------------------------------------- 1 | package com.juul.kable 2 | 3 | import kotlinx.atomicfu.locks.SynchronizedObject 4 | import kotlinx.atomicfu.locks.synchronized 5 | import platform.CoreBluetooth.CBCentralManagerOptionRestoreIdentifierKey 6 | import platform.CoreBluetooth.CBCentralManagerOptionShowPowerAlertKey 7 | import platform.Foundation.NSUUID 8 | import platform.Foundation.NSUserDefaults 9 | 10 | private const val CBCENTRALMANAGER_RESTORATION_ID = "kable-central-manager" 11 | private const val CBCENTRALMANAGER_CONSUMER_ID_KEY = "kable-central-manager-consumer-id" 12 | 13 | private val guard = SynchronizedObject() 14 | 15 | // This value is needed to ensure multiple instances of Kable running on the same iOS device do not 16 | // cross pollinate restored instances of CBCentralManager. The value will live for the lifetime of 17 | // the consuming app. 18 | private val consumerId: String 19 | get() = synchronized(guard) { 20 | NSUserDefaults.standardUserDefaults.stringForKey(CBCENTRALMANAGER_CONSUMER_ID_KEY) 21 | ?: NSUUID().UUIDString().also { 22 | NSUserDefaults.standardUserDefaults.setObject(it, CBCENTRALMANAGER_CONSUMER_ID_KEY) 23 | } 24 | } 25 | 26 | internal fun CentralManager.Configuration.toOptions(): Map = if (stateRestoration) { 27 | mapOf( 28 | CBCentralManagerOptionRestoreIdentifierKey to "$CBCENTRALMANAGER_RESTORATION_ID-$consumerId", 29 | CBCentralManagerOptionShowPowerAlertKey to false, 30 | ) 31 | } else { 32 | mapOf( 33 | CBCentralManagerOptionShowPowerAlertKey to false, 34 | ) 35 | } 36 | 37 | internal fun CentralManager.Configuration.Builder.build(): CentralManager.Configuration = 38 | CentralManager.Configuration(stateRestoration) 39 | -------------------------------------------------------------------------------- /kable-core/src/appleMain/kotlin/Channel.kt: -------------------------------------------------------------------------------- 1 | package com.juul.kable 2 | 3 | import kotlinx.coroutines.channels.SendChannel 4 | import kotlinx.coroutines.runBlocking 5 | 6 | internal fun SendChannel.sendBlocking(element: E) { 7 | if (trySend(element).isSuccess) return 8 | runBlocking { send(element) } 9 | } 10 | -------------------------------------------------------------------------------- /kable-core/src/appleMain/kotlin/CoreBluetoothAdvertisement.kt: -------------------------------------------------------------------------------- 1 | package com.juul.kable 2 | 3 | @Deprecated( 4 | "Moved to PlatformAdvertisement.", 5 | replaceWith = ReplaceWith("PlatformAdvertisement"), 6 | level = DeprecationLevel.ERROR, 7 | ) 8 | public typealias CoreBluetoothAdvertisement = PlatformAdvertisement 9 | -------------------------------------------------------------------------------- /kable-core/src/appleMain/kotlin/CoreBluetoothPeripheral.kt: -------------------------------------------------------------------------------- 1 | package com.juul.kable 2 | 3 | import kotlinx.coroutines.flow.Flow 4 | import kotlinx.io.IOException 5 | import platform.Foundation.NSData 6 | import kotlin.coroutines.cancellation.CancellationException 7 | 8 | public interface CoreBluetoothPeripheral : Peripheral { 9 | 10 | @Throws(CancellationException::class, IOException::class) 11 | public suspend fun write(descriptor: Descriptor, data: NSData) 12 | 13 | @Throws(CancellationException::class, IOException::class) 14 | public suspend fun write(characteristic: Characteristic, data: NSData, writeType: WriteType) 15 | 16 | @Throws(CancellationException::class, IOException::class) 17 | public suspend fun readAsNSData(descriptor: Descriptor): NSData 18 | 19 | public fun observeAsNSData( 20 | characteristic: Characteristic, 21 | onSubscription: OnSubscriptionAction = {}, 22 | ): Flow 23 | 24 | @Throws(CancellationException::class, IOException::class) 25 | public suspend fun readAsNSData(characteristic: Characteristic): NSData 26 | } 27 | -------------------------------------------------------------------------------- /kable-core/src/appleMain/kotlin/CoreBluetoothScanner.kt: -------------------------------------------------------------------------------- 1 | package com.juul.kable 2 | 3 | @Deprecated( 4 | "Moved to PlatformScanner.", 5 | replaceWith = ReplaceWith("PlatformScanner"), 6 | level = DeprecationLevel.HIDDEN, 7 | ) 8 | public typealias CoreBluetoothScanner = PlatformScanner 9 | -------------------------------------------------------------------------------- /kable-core/src/appleMain/kotlin/Flow.kt: -------------------------------------------------------------------------------- 1 | package com.juul.kable 2 | 3 | import kotlinx.coroutines.flow.MutableSharedFlow 4 | import kotlinx.coroutines.runBlocking 5 | 6 | internal fun MutableSharedFlow.emitBlocking(value: T) { 7 | if (tryEmit(value)) return 8 | runBlocking { emit(value) } 9 | } 10 | -------------------------------------------------------------------------------- /kable-core/src/appleMain/kotlin/Identifier.kt: -------------------------------------------------------------------------------- 1 | package com.juul.kable 2 | 3 | import kotlin.uuid.Uuid 4 | 5 | public actual typealias Identifier = Uuid 6 | 7 | public actual fun String.toIdentifier(): Identifier = Uuid.parse(this) 8 | -------------------------------------------------------------------------------- /kable-core/src/appleMain/kotlin/NSData.kt: -------------------------------------------------------------------------------- 1 | @file:OptIn(ExperimentalForeignApi::class) 2 | 3 | package com.juul.kable 4 | 5 | import kotlinx.cinterop.ExperimentalForeignApi 6 | import kotlinx.cinterop.addressOf 7 | import kotlinx.cinterop.allocArrayOf 8 | import kotlinx.cinterop.convert 9 | import kotlinx.cinterop.memScoped 10 | import kotlinx.cinterop.usePinned 11 | import platform.Foundation.NSData 12 | import platform.Foundation.create 13 | import platform.posix.memcpy 14 | 15 | // https://stackoverflow.com/a/58521109 16 | internal fun NSData.toByteArray(): ByteArray = ByteArray(length.toInt()).apply { 17 | if (length > 0u) { 18 | usePinned { 19 | memcpy(it.addressOf(0), bytes, length) 20 | } 21 | } 22 | } 23 | 24 | // https://stackoverflow.com/a/58521109 25 | internal fun ByteArray.toNSData(): NSData = memScoped { 26 | NSData.create( 27 | bytes = allocArrayOf(this@toNSData), 28 | length = size.convert(), 29 | ) 30 | } 31 | -------------------------------------------------------------------------------- /kable-core/src/appleMain/kotlin/Observations.kt: -------------------------------------------------------------------------------- 1 | package com.juul.kable 2 | 3 | internal actual fun Peripheral.observationHandler(): Observation.Handler = object : Observation.Handler { 4 | override suspend fun startObservation(characteristic: Characteristic) { 5 | (this@observationHandler as CBPeripheralCoreBluetoothPeripheral).startNotifications(characteristic) 6 | } 7 | 8 | override suspend fun stopObservation(characteristic: Characteristic) { 9 | (this@observationHandler as CBPeripheralCoreBluetoothPeripheral).stopNotifications(characteristic) 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /kable-core/src/appleMain/kotlin/Peripheral.deprecated.kt: -------------------------------------------------------------------------------- 1 | package com.juul.kable 2 | 3 | import kotlinx.coroutines.CoroutineScope 4 | import platform.CoreBluetooth.CBPeripheral 5 | 6 | @Deprecated( 7 | message = "Replaced with `Peripheral` builder function (not a CoroutineScope extension function).", 8 | replaceWith = ReplaceWith("Peripheral(cbPeripheral, builderAction)"), 9 | level = DeprecationLevel.ERROR, 10 | ) 11 | public actual fun CoroutineScope.peripheral( 12 | advertisement: Advertisement, 13 | builderAction: PeripheralBuilderAction, 14 | ): Peripheral = Peripheral(advertisement, builderAction) 15 | 16 | @Deprecated( 17 | message = "Replaced with `Peripheral` builder function (not a CoroutineScope extension function).", 18 | replaceWith = ReplaceWith("Peripheral(cbPeripheral, builderAction)"), 19 | level = DeprecationLevel.ERROR, 20 | ) 21 | public fun CoroutineScope.peripheral( 22 | identifier: Identifier, 23 | builderAction: PeripheralBuilderAction = {}, 24 | ): Peripheral = Peripheral(identifier, builderAction) 25 | 26 | @Deprecated( 27 | message = "Replaced with `Peripheral` builder function (not a CoroutineScope extension function).", 28 | replaceWith = ReplaceWith("Peripheral(cbPeripheral, builderAction)"), 29 | level = DeprecationLevel.ERROR, 30 | ) 31 | public fun CoroutineScope.peripheral( 32 | cbPeripheral: CBPeripheral, 33 | builderAction: PeripheralBuilderAction, 34 | ): CoreBluetoothPeripheral = Peripheral(cbPeripheral, builderAction) 35 | -------------------------------------------------------------------------------- /kable-core/src/appleMain/kotlin/Peripheral.kt: -------------------------------------------------------------------------------- 1 | package com.juul.kable 2 | 3 | import platform.CoreBluetooth.CBPeripheral 4 | 5 | public actual fun Peripheral( 6 | advertisement: Advertisement, 7 | builderAction: PeripheralBuilderAction, 8 | ): Peripheral { 9 | advertisement as CBPeripheralCoreBluetoothAdvertisement 10 | return Peripheral(advertisement.cbPeripheral, builderAction) 11 | } 12 | 13 | @Suppress("FunctionName") // Builder function. 14 | public fun Peripheral( 15 | identifier: Identifier, 16 | builderAction: PeripheralBuilderAction = {}, 17 | ): CoreBluetoothPeripheral { 18 | val cbPeripheral = CentralManager.Default.retrievePeripheral(identifier) 19 | ?: throw NoSuchElementException("Peripheral with UUID $identifier not found") 20 | return Peripheral(cbPeripheral, builderAction) 21 | } 22 | 23 | @Suppress("FunctionName") // Builder function. 24 | public fun Peripheral( 25 | cbPeripheral: CBPeripheral, 26 | builderAction: PeripheralBuilderAction = {}, 27 | ): CoreBluetoothPeripheral { 28 | val builder = PeripheralBuilder().apply(builderAction) 29 | return CBPeripheralCoreBluetoothPeripheral( 30 | cbPeripheral, 31 | builder.observationExceptionHandler, 32 | builder.onServicesDiscovered, 33 | builder.logging, 34 | builder.disconnectTimeout, 35 | ) 36 | } 37 | -------------------------------------------------------------------------------- /kable-core/src/appleMain/kotlin/PeripheralBuilder.kt: -------------------------------------------------------------------------------- 1 | package com.juul.kable 2 | 3 | import com.juul.kable.logs.Logging 4 | import com.juul.kable.logs.LoggingBuilder 5 | import kotlinx.io.IOException 6 | import kotlin.coroutines.cancellation.CancellationException 7 | import kotlin.time.Duration 8 | 9 | public actual class ServicesDiscoveredPeripheral internal constructor( 10 | private val peripheral: CoreBluetoothPeripheral, 11 | ) { 12 | 13 | @Throws(CancellationException::class, IOException::class, NotConnectedException::class) 14 | public actual suspend fun read( 15 | characteristic: Characteristic, 16 | ): ByteArray = peripheral.read(characteristic) 17 | 18 | @Throws(CancellationException::class, IOException::class, NotConnectedException::class) 19 | public actual suspend fun read( 20 | descriptor: Descriptor, 21 | ): ByteArray = peripheral.read(descriptor) 22 | 23 | @Throws(CancellationException::class, IOException::class, NotConnectedException::class) 24 | public actual suspend fun write( 25 | characteristic: Characteristic, 26 | data: ByteArray, 27 | writeType: WriteType, 28 | ) { 29 | peripheral.write(characteristic, data, writeType) 30 | } 31 | 32 | @Throws(CancellationException::class, IOException::class, NotConnectedException::class) 33 | public actual suspend fun write( 34 | descriptor: Descriptor, 35 | data: ByteArray, 36 | ) { 37 | peripheral.write(descriptor, data) 38 | } 39 | } 40 | 41 | public actual class PeripheralBuilder internal actual constructor() { 42 | 43 | internal var logging: Logging = Logging() 44 | public actual fun logging(init: LoggingBuilder) { 45 | val logging = Logging() 46 | logging.init() 47 | this.logging = logging 48 | } 49 | 50 | internal var onServicesDiscovered: ServicesDiscoveredAction = {} 51 | public actual fun onServicesDiscovered(action: ServicesDiscoveredAction) { 52 | onServicesDiscovered = action 53 | } 54 | 55 | internal var observationExceptionHandler: ObservationExceptionHandler = { cause -> throw cause } 56 | public actual fun observationExceptionHandler(handler: ObservationExceptionHandler) { 57 | observationExceptionHandler = handler 58 | } 59 | 60 | public actual var disconnectTimeout: Duration = defaultDisconnectTimeout 61 | } 62 | -------------------------------------------------------------------------------- /kable-core/src/appleMain/kotlin/PlatformAdvertisement.kt: -------------------------------------------------------------------------------- 1 | package com.juul.kable 2 | 3 | import platform.Foundation.NSData 4 | import kotlin.uuid.Uuid 5 | 6 | public actual interface PlatformAdvertisement : Advertisement { 7 | public fun serviceDataAsNSData(uuid: Uuid): NSData? 8 | public val manufacturerDataAsNSData: NSData? 9 | public fun manufacturerDataAsNSData(companyIdentifierCode: Int): NSData? 10 | } 11 | -------------------------------------------------------------------------------- /kable-core/src/appleMain/kotlin/QueueDispatcher.kt: -------------------------------------------------------------------------------- 1 | package com.juul.kable 2 | 3 | import kotlinx.coroutines.CoroutineDispatcher 4 | import kotlinx.coroutines.Runnable 5 | import platform.darwin.dispatch_async 6 | import platform.darwin.dispatch_queue_create 7 | import platform.darwin.dispatch_queue_t 8 | import kotlin.coroutines.CoroutineContext 9 | 10 | internal class QueueDispatcher( 11 | label: String, 12 | ) : CoroutineDispatcher() { 13 | 14 | val dispatchQueue: dispatch_queue_t = dispatch_queue_create(label, attr = null) 15 | 16 | override fun dispatch(context: CoroutineContext, block: Runnable) { 17 | dispatch_async(dispatchQueue) { 18 | block.run() 19 | } 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /kable-core/src/appleMain/kotlin/ScannerBuilder.kt: -------------------------------------------------------------------------------- 1 | package com.juul.kable 2 | 3 | import com.juul.kable.logs.Logging 4 | import com.juul.kable.logs.LoggingBuilder 5 | import platform.CoreBluetooth.CBCentralManagerScanOptionAllowDuplicatesKey 6 | import platform.CoreBluetooth.CBCentralManagerScanOptionSolicitedServiceUUIDsKey 7 | import kotlin.uuid.Uuid 8 | 9 | public actual class ScannerBuilder { 10 | 11 | @Deprecated( 12 | message = "Use filters(FiltersBuilder.() -> Unit)", 13 | replaceWith = ReplaceWith("filters { }"), 14 | level = DeprecationLevel.HIDDEN, 15 | ) 16 | public actual var filters: List? = null 17 | 18 | private var filterPredicates: List = emptyList() 19 | 20 | public actual fun filters(builderAction: FiltersBuilder.() -> Unit) { 21 | filterPredicates = FiltersBuilder().apply(builderAction).build() 22 | } 23 | 24 | /** 25 | * Specifies whether the scan should run without duplicate filtering. This corresponds to 26 | * Core Bluetooth's [CBCentralManagerScanOptionAllowDuplicatesKey] scanning option. 27 | */ 28 | public var allowDuplicateKeys: Boolean? = true 29 | 30 | /** 31 | * Causes the scanner to scan for peripherals soliciting any of the services contained in the 32 | * array. This corresponds to Core Bluetooth's [CBCentralManagerScanOptionSolicitedServiceUUIDsKey] 33 | * scanning option. 34 | */ 35 | public var solicitedServiceUuids: List? = null 36 | 37 | private var logging: Logging = Logging() 38 | 39 | public actual fun logging(init: LoggingBuilder) { 40 | logging = Logging().apply(init) 41 | } 42 | 43 | internal actual fun build(): PlatformScanner { 44 | val options = mutableMapOf() 45 | allowDuplicateKeys?.also { 46 | options[CBCentralManagerScanOptionAllowDuplicatesKey] = it 47 | } 48 | solicitedServiceUuids?.also { uuids -> 49 | options[CBCentralManagerScanOptionSolicitedServiceUUIDsKey] = uuids.map(Uuid::toCBUUID) 50 | } 51 | 52 | return CentralManagerCoreBluetoothScanner( 53 | central = CentralManager.Default, 54 | filters = filterPredicates, 55 | options = options.toMap(), 56 | logging = logging, 57 | ) 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /kable-core/src/appleMain/kotlin/State.kt: -------------------------------------------------------------------------------- 1 | package com.juul.kable 2 | 3 | import com.juul.kable.CentralManagerDelegate.ConnectionEvent 4 | import com.juul.kable.CentralManagerDelegate.ConnectionEvent.DidConnect 5 | import com.juul.kable.CentralManagerDelegate.ConnectionEvent.DidDisconnect 6 | import com.juul.kable.CentralManagerDelegate.ConnectionEvent.DidFailToConnect 7 | 8 | internal fun ConnectionEvent.toState(): State = when (this) { 9 | is DidConnect -> State.Connecting.Services 10 | is DidFailToConnect -> State.Disconnected(error?.toStatus()) 11 | is DidDisconnect -> State.Disconnected(error?.toStatus()) 12 | } 13 | -------------------------------------------------------------------------------- /kable-core/src/appleMain/kotlin/Status.kt: -------------------------------------------------------------------------------- 1 | package com.juul.kable 2 | 3 | import com.juul.kable.State.Disconnected.Status.Cancelled 4 | import com.juul.kable.State.Disconnected.Status.ConnectionLimitReached 5 | import com.juul.kable.State.Disconnected.Status.EncryptionTimedOut 6 | import com.juul.kable.State.Disconnected.Status.Failed 7 | import com.juul.kable.State.Disconnected.Status.PeripheralDisconnected 8 | import com.juul.kable.State.Disconnected.Status.Timeout 9 | import com.juul.kable.State.Disconnected.Status.Unknown 10 | import com.juul.kable.State.Disconnected.Status.UnknownDevice 11 | import platform.CoreBluetooth.CBErrorConnectionFailed 12 | import platform.CoreBluetooth.CBErrorConnectionLimitReached 13 | import platform.CoreBluetooth.CBErrorConnectionTimeout 14 | import platform.CoreBluetooth.CBErrorEncryptionTimedOut 15 | import platform.CoreBluetooth.CBErrorOperationCancelled 16 | import platform.CoreBluetooth.CBErrorPeripheralDisconnected 17 | import platform.CoreBluetooth.CBErrorUnknownDevice 18 | import platform.Foundation.NSError 19 | 20 | internal fun NSError.toStatus(): State.Disconnected.Status = when (code) { 21 | CBErrorPeripheralDisconnected -> PeripheralDisconnected 22 | CBErrorConnectionFailed -> Failed 23 | CBErrorConnectionTimeout -> Timeout 24 | CBErrorUnknownDevice -> UnknownDevice 25 | CBErrorOperationCancelled -> Cancelled 26 | CBErrorConnectionLimitReached -> ConnectionLimitReached 27 | CBErrorEncryptionTimedOut -> EncryptionTimedOut 28 | else -> Unknown(code.toInt()) 29 | } 30 | -------------------------------------------------------------------------------- /kable-core/src/appleMain/kotlin/Uuid.kt: -------------------------------------------------------------------------------- 1 | package com.juul.kable 2 | 3 | import platform.CoreBluetooth.CBUUID 4 | import platform.Foundation.NSUUID 5 | import kotlin.uuid.Uuid 6 | 7 | internal fun Uuid.toNSUUID(): NSUUID = NSUUID(toString()) 8 | internal fun Uuid.toCBUUID(): CBUUID = CBUUID.UUIDWithString(toString()) 9 | internal fun CBUUID.toUuid(): Uuid = UUIDString 10 | .lowercase() 11 | .let { 12 | when (it.length) { 13 | 4 -> Uuid.parse("0000$it-0000-1000-8000-00805f9b34fb") 14 | 8 -> Uuid.parse("$it-0000-1000-8000-00805f9b34fb") 15 | else -> Uuid.parse(it) 16 | } 17 | } 18 | 19 | internal fun NSUUID.toUuid(): Uuid = Uuid.parse(UUIDString) 20 | -------------------------------------------------------------------------------- /kable-core/src/appleMain/kotlin/bluetooth/CheckBluetoothIsOn.kt: -------------------------------------------------------------------------------- 1 | package com.juul.kable.bluetooth 2 | 3 | import com.juul.kable.CentralManager 4 | import com.juul.kable.InternalError 5 | import com.juul.kable.UnmetRequirementException 6 | import com.juul.kable.UnmetRequirementReason.BluetoothDisabled 7 | import platform.CoreBluetooth.CBManagerState 8 | import platform.CoreBluetooth.CBManagerStatePoweredOff 9 | import platform.CoreBluetooth.CBManagerStatePoweredOn 10 | import platform.CoreBluetooth.CBManagerStateResetting 11 | import platform.CoreBluetooth.CBManagerStateUnauthorized 12 | import platform.CoreBluetooth.CBManagerStateUnknown 13 | import platform.CoreBluetooth.CBManagerStateUnsupported 14 | 15 | /** 16 | * @throws UnmetRequirementException If [CentralManager] state is not [CBManagerStatePoweredOn]. 17 | */ 18 | internal fun CentralManager.checkBluetoothIsOn() { 19 | val actual = delegate.state.value 20 | val expected = CBManagerStatePoweredOn 21 | if (actual != expected) { 22 | throw UnmetRequirementException( 23 | reason = BluetoothDisabled, 24 | message = "Bluetooth was ${nameFor(actual)}, but ${nameFor(expected)} was required", 25 | ) 26 | } 27 | } 28 | 29 | private fun nameFor(state: CBManagerState) = when (state) { 30 | CBManagerStatePoweredOff -> "PoweredOff" 31 | CBManagerStatePoweredOn -> "PoweredOn" 32 | CBManagerStateResetting -> "Resetting" 33 | CBManagerStateUnauthorized -> "Unauthorized" 34 | CBManagerStateUnknown -> "Unknown" 35 | CBManagerStateUnsupported -> "Unsupported" 36 | else -> throw InternalError("Unsupported bluetooth state: $state") 37 | } 38 | -------------------------------------------------------------------------------- /kable-core/src/appleMain/kotlin/bluetooth/IsSupported.kt: -------------------------------------------------------------------------------- 1 | package com.juul.kable.bluetooth 2 | 3 | internal actual suspend fun isSupported() = true 4 | -------------------------------------------------------------------------------- /kable-core/src/appleMain/kotlin/logs/LogMessage.kt: -------------------------------------------------------------------------------- 1 | package com.juul.kable.logs 2 | 3 | import com.juul.kable.logs.Logging.DataProcessor.Operation 4 | import com.juul.kable.toByteArray 5 | import com.juul.kable.toUuid 6 | import platform.CoreBluetooth.CBCharacteristic 7 | import platform.CoreBluetooth.CBDescriptor 8 | import platform.CoreBluetooth.CBService 9 | import platform.Foundation.NSData 10 | import platform.Foundation.NSError 11 | 12 | internal actual val LOG_INDENT: String? = " " 13 | 14 | internal fun LogMessage.detail(data: NSData?, operation: Operation) { 15 | detail(data?.toByteArray(), operation) 16 | } 17 | 18 | internal fun LogMessage.detail(error: NSError?) { 19 | if (error != null) detail("error", error.toString()) 20 | } 21 | 22 | internal fun LogMessage.detail(service: CBService) { 23 | detail("service", service.UUID.UUIDString) 24 | } 25 | 26 | internal fun LogMessage.detail(characteristic: CBCharacteristic) { 27 | detail( 28 | characteristic.service!!.UUID.toUuid(), 29 | characteristic.UUID.toUuid(), 30 | ) 31 | } 32 | 33 | internal fun LogMessage.detail(descriptor: CBDescriptor) { 34 | detail( 35 | descriptor.characteristic!!.service!!.UUID.toUuid(), 36 | descriptor.characteristic!!.UUID.toUuid(), 37 | descriptor.UUID.toUuid(), 38 | ) 39 | } 40 | -------------------------------------------------------------------------------- /kable-core/src/appleMain/kotlin/logs/SystemLogEngine.kt: -------------------------------------------------------------------------------- 1 | package com.juul.kable.logs 2 | 3 | import platform.Foundation.NSLog 4 | 5 | public actual object SystemLogEngine : LogEngine { 6 | actual override fun verbose(throwable: Throwable?, tag: String, message: String) { 7 | log("V", tag, message, throwable) 8 | } 9 | 10 | actual override fun debug(throwable: Throwable?, tag: String, message: String) { 11 | log("D", tag, message, throwable) 12 | } 13 | 14 | actual override fun info(throwable: Throwable?, tag: String, message: String) { 15 | log("I", tag, message, throwable) 16 | } 17 | 18 | actual override fun warn(throwable: Throwable?, tag: String, message: String) { 19 | log("W", tag, message, throwable) 20 | } 21 | 22 | actual override fun error(throwable: Throwable?, tag: String, message: String) { 23 | log("E", tag, message, throwable) 24 | } 25 | 26 | actual override fun assert(throwable: Throwable?, tag: String, message: String) { 27 | log("A", tag, message, throwable) 28 | } 29 | 30 | private fun log(level: String, tag: String, message: String, throwable: Throwable?) { 31 | if (throwable == null) { 32 | NSLog("%s/%s: %s", level, tag, message) 33 | } else { 34 | NSLog("%s/%s: %s\n%s", level, tag, message, throwable.stackTraceToString()) 35 | } 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /kable-core/src/appleTest/kotlin/AdvertisementTest.kt: -------------------------------------------------------------------------------- 1 | package com.juul.kable.test 2 | 3 | import com.juul.kable.toManufacturerData 4 | import com.juul.kable.toNSData 5 | import platform.Foundation.NSData 6 | import kotlin.test.Test 7 | import kotlin.test.assertEquals 8 | import kotlin.test.assertNotNull 9 | import kotlin.test.assertNull 10 | import kotlin.test.assertTrue 11 | 12 | // Test function naming format: 13 | // [name of item being tested]_[input conditions]_[expected results] 14 | class AdvertisementTest { 15 | @Test 16 | fun manufacturerData_advertisementWithMoreThanTwoBytes_hasCodeAndData() { 17 | val data = ubyteArrayOf( 18 | 0xc3u, 19 | 0x05u, // little-endian manufacturer id 20 | 0x042u, // data 21 | ).toNSData() 22 | val manufacturerData = data.toManufacturerData() 23 | 24 | assertNotNull(manufacturerData) 25 | assertEquals(manufacturerData.code, 0x05c3) 26 | assertEquals(manufacturerData.data.size, 1) 27 | assertEquals(manufacturerData.data[0], 0x042) 28 | } 29 | 30 | @Test 31 | fun manufacturerData_advertisementWithTwoBytes_hasCodeAndEmptyData() { 32 | val data = ubyteArrayOf( 33 | 0xc3u, 34 | 0x05u, // little-endian manufacturer id 35 | ).toNSData() 36 | val manufacturerData = data.toManufacturerData() 37 | 38 | assertNotNull(manufacturerData) 39 | assertEquals(manufacturerData.code, 0x05c3) 40 | assertTrue(manufacturerData.data.isEmpty()) 41 | } 42 | 43 | @Test 44 | fun manufacturerData_advertisementWithFewerThanTwoBytes_isNull() { 45 | val data = ubyteArrayOf(0x01u).toNSData() 46 | val manufacturerData = data.toManufacturerData() 47 | 48 | assertNull(manufacturerData) 49 | } 50 | } 51 | 52 | private fun UByteArray.toNSData(): NSData = toByteArray().toNSData() 53 | -------------------------------------------------------------------------------- /kable-core/src/appleTest/kotlin/NSDataTest.kt: -------------------------------------------------------------------------------- 1 | @file:OptIn(BetaInteropApi::class, ExperimentalForeignApi::class) 2 | 3 | package com.juul.kable.test 4 | 5 | import com.juul.kable.toByteArray 6 | import kotlinx.cinterop.BetaInteropApi 7 | import kotlinx.cinterop.ExperimentalForeignApi 8 | import platform.Foundation.NSData 9 | import platform.Foundation.NSDataBase64DecodingIgnoreUnknownCharacters 10 | import platform.Foundation.create 11 | import kotlin.test.Test 12 | import kotlin.test.assertEquals 13 | import kotlin.test.assertTrue 14 | 15 | private const val FOO = "Zm9v" // Base64("foo") 16 | private const val BAR = "YmFy" // Base64("bar") 17 | 18 | class NSDataTest { 19 | 20 | @Test 21 | fun nSDataToByteArray_emptyNSData_isEmpty() { 22 | val data = NSData.create(length = 0u, bytes = null).toByteArray() 23 | assertEquals(0, data.size) 24 | } 25 | 26 | @Test 27 | fun nsData_differentData_isNotEqual() { 28 | val data1 = NSData.create(FOO, NSDataBase64DecodingIgnoreUnknownCharacters)!! 29 | val data2 = NSData.create(BAR, NSDataBase64DecodingIgnoreUnknownCharacters)!! 30 | assertTrue { data1 != data2 } 31 | } 32 | 33 | @Test 34 | fun nsData_sameData_isEqualToData() { 35 | val data1 = NSData.create(FOO, NSDataBase64DecodingIgnoreUnknownCharacters)!! 36 | val data2 = NSData.create(FOO, NSDataBase64DecodingIgnoreUnknownCharacters)!! 37 | assertTrue { data1 == data2 } 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /kable-core/src/appleTest/kotlin/NSDictionaryTests.kt: -------------------------------------------------------------------------------- 1 | @file:OptIn(BetaInteropApi::class) 2 | 3 | package com.juul.kable.test 4 | 5 | import kotlinx.cinterop.BetaInteropApi 6 | import platform.CoreBluetooth.CBAdvertisementDataManufacturerDataKey 7 | import platform.CoreBluetooth.CBAdvertisementDataServiceUUIDsKey 8 | import platform.CoreBluetooth.CBAdvertisementDataSolicitedServiceUUIDsKey 9 | import platform.CoreBluetooth.CBUUID 10 | import platform.Foundation.NSArray 11 | import platform.Foundation.NSData 12 | import platform.Foundation.NSDataBase64DecodingIgnoreUnknownCharacters 13 | import platform.Foundation.NSDictionary 14 | import platform.Foundation.create 15 | import kotlin.test.Test 16 | import kotlin.test.assertTrue 17 | 18 | private const val FOO = "Zm9v" // Base64("foo") 19 | private const val BAR = "YmFy" // Base64("bar") 20 | 21 | class NSDictionaryTests { 22 | 23 | @Test 24 | fun twoDictionaries_asMapWithSameContents_isEqual() { 25 | val data1 = NSData.create(FOO, NSDataBase64DecodingIgnoreUnknownCharacters)!! 26 | val dictionary1 = NSDictionary.create(mapOf(CBAdvertisementDataManufacturerDataKey to data1)) 27 | 28 | val data2 = NSData.create(FOO, NSDataBase64DecodingIgnoreUnknownCharacters)!! 29 | val dictionary2 = NSDictionary.create(mapOf(CBAdvertisementDataManufacturerDataKey to data2)) 30 | 31 | assertTrue { dictionary1 == dictionary2 } 32 | } 33 | 34 | @Test 35 | fun twoDictionaries_asMapWithSameKeysAndDifferentData_isNotEqual() { 36 | val data1 = NSData.create(FOO, NSDataBase64DecodingIgnoreUnknownCharacters)!! 37 | val dictionary1 = NSDictionary.create(mapOf(CBAdvertisementDataManufacturerDataKey to data1)) 38 | 39 | val data2 = NSData.create(BAR, NSDataBase64DecodingIgnoreUnknownCharacters)!! 40 | val dictionary2 = NSDictionary.create(mapOf(CBAdvertisementDataManufacturerDataKey to data2)) 41 | 42 | assertTrue { dictionary1 != dictionary2 } 43 | } 44 | 45 | @Test 46 | fun twoDictionaries_asMapWithDifferentKeysAndDifferentData_isNotEqual() { 47 | val uuids = NSArray.create(listOf(CBUUID.UUIDWithString("07af6856-6779-4459-ba1d-085c08530931"))) 48 | val dictionary1 = NSDictionary.create(mapOf(CBAdvertisementDataServiceUUIDsKey to uuids)) 49 | 50 | val data = NSData.create(FOO, NSDataBase64DecodingIgnoreUnknownCharacters)!! 51 | val dictionary2 = NSDictionary.create(mapOf(CBAdvertisementDataManufacturerDataKey to data)) 52 | 53 | assertTrue { dictionary1 != dictionary2 } 54 | } 55 | 56 | @Test 57 | fun twoDictionaries_asMapWithDifferentKeysAndSameData_isNotEqual() { 58 | val uuids = NSArray.create(listOf(CBUUID.UUIDWithString("07af6856-6779-4459-ba1d-085c08530931"))) 59 | val dictionary1 = NSDictionary.create(mapOf(CBAdvertisementDataServiceUUIDsKey to uuids)) 60 | val dictionary2 = NSDictionary.create(mapOf(CBAdvertisementDataSolicitedServiceUUIDsKey to uuids)) 61 | assertTrue { dictionary1 != dictionary2 } 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /kable-core/src/commonMain/kotlin/Annotations.kt: -------------------------------------------------------------------------------- 1 | @file:Suppress("ktlint:standard:filename") 2 | 3 | package com.juul.kable 4 | 5 | /** 6 | * Marks declarations that are **obsolete** in Kable API, which means that the design of the corresponding declarations 7 | * has known flaws/drawbacks and they will be redesigned or replaced in the future. 8 | * 9 | * Roughly speaking, these declarations will be deprecated in the future but there is no replacement for them yet, so 10 | * they cannot be deprecated right away. 11 | */ 12 | @MustBeDocumented 13 | @Retention(value = AnnotationRetention.BINARY) 14 | @RequiresOptIn(level = RequiresOptIn.Level.WARNING) 15 | public annotation class ObsoleteKableApi 16 | -------------------------------------------------------------------------------- /kable-core/src/commonMain/kotlin/BaseConnection.kt: -------------------------------------------------------------------------------- 1 | package com.juul.kable 2 | 3 | import kotlinx.coroutines.CoroutineName 4 | import kotlinx.coroutines.CoroutineScope 5 | import kotlinx.coroutines.Job 6 | import kotlinx.coroutines.job 7 | import kotlin.coroutines.CoroutineContext 8 | 9 | internal abstract class BaseConnection( 10 | parentContext: CoroutineContext, 11 | name: String, 12 | ) : CoroutineScope { 13 | 14 | val job = Job(parentContext.job) 15 | override val coroutineContext = parentContext + job + CoroutineName(name) 16 | } 17 | -------------------------------------------------------------------------------- /kable-core/src/commonMain/kotlin/BasePeripheral.kt: -------------------------------------------------------------------------------- 1 | package com.juul.kable 2 | 3 | import kotlinx.coroutines.CoroutineName 4 | import kotlinx.coroutines.CoroutineScope 5 | 6 | internal abstract class BasePeripheral(identifier: Identifier) : Peripheral { 7 | 8 | override val scope = CoroutineScope( 9 | SilentSupervisor() + CoroutineName("Kable/Peripheral/$identifier"), 10 | ) 11 | } 12 | -------------------------------------------------------------------------------- /kable-core/src/commonMain/kotlin/Bluetooth.kt: -------------------------------------------------------------------------------- 1 | @file:JvmName("BluetoothCommon") 2 | 3 | package com.juul.kable 4 | 5 | import kotlinx.coroutines.flow.Flow 6 | import kotlin.jvm.JvmName 7 | import kotlin.uuid.Uuid 8 | import com.juul.kable.bluetooth.isSupported as isBluetoothSupported 9 | 10 | @Deprecated( 11 | message = "`Bluetooth.availability` has inconsistent behavior across platforms. " + 12 | "Will be removed in a future release. " + 13 | "See https://github.com/JuulLabs/kable/issues/737 for more details.", 14 | ) 15 | public expect enum class Reason 16 | 17 | public object Bluetooth { 18 | 19 | /** 20 | * Bluetooth Base UUID: `00000000-0000-1000-8000-00805F9B34FB` 21 | * 22 | * [Bluetooth Core Specification, Vol 3, Part B: 2.5.1 UUID](https://www.bluetooth.com/specifications/specs/?types=adopted&keyword=Core+Specification) 23 | */ 24 | public object BaseUuid { 25 | 26 | private const val mostSignificantBits = 4096L // 00000000-0000-1000 27 | private const val leastSignificantBits = -9223371485494954757L // 8000-00805F9B34FB 28 | 29 | public operator fun plus(shortUuid: Int): Uuid = plus(shortUuid.toLong()) 30 | 31 | /** @param shortUuid 32-bits (or less) short UUID (if larger than 32-bits, will be truncated to 32-bits). */ 32 | public operator fun plus(shortUuid: Long): Uuid = 33 | Uuid.fromLongs(mostSignificantBits + (shortUuid and 0xFFFF_FFFF shl 32), leastSignificantBits) 34 | 35 | override fun toString(): String = "00000000-0000-1000-8000-00805F9B34FB" 36 | } 37 | 38 | @Deprecated( 39 | message = "`Bluetooth.availability` has inconsistent behavior across platforms. " + 40 | "Will be removed in a future release. " + 41 | "See https://github.com/JuulLabs/kable/issues/737 for more details.", 42 | ) 43 | public sealed class Availability { 44 | @Deprecated( 45 | message = "`Bluetooth.availability` has inconsistent behavior across platforms. " + 46 | "Will be removed in a future release. " + 47 | "See https://github.com/JuulLabs/kable/issues/737 for more details.", 48 | ) 49 | public data object Available : Availability() 50 | 51 | @Deprecated( 52 | message = "`Bluetooth.availability` has inconsistent behavior across platforms. " + 53 | "Will be removed in a future release. " + 54 | "See https://github.com/JuulLabs/kable/issues/737 for more details.", 55 | ) 56 | public data class Unavailable(val reason: Reason?) : Availability() 57 | } 58 | 59 | @Deprecated( 60 | message = "`Bluetooth.availability` has inconsistent behavior across platforms. " + 61 | "Will be removed in a future release. " + 62 | "See https://github.com/JuulLabs/kable/issues/737 for more details.", 63 | ) 64 | public val availability: Flow = bluetoothAvailability 65 | 66 | /** 67 | * Checks if Bluetooth Low Energy is supported on the system. Being supported (a return of 68 | * `true`) does not necessarily mean that bluetooth operations will work. The radio could be off 69 | * or permissions may be denied. 70 | * 71 | * Due to Core Bluetooth limitations (unavoidable dialog upon checking if supported), this 72 | * function **always** returns `true` on Apple (even if Bluetooth is not supported). 73 | * 74 | * This function is idempotent. 75 | */ 76 | @ExperimentalApi // Due to the inability to query Bluetooth support w/o showing a dialog on Apple, this function may be removed. 77 | public suspend fun isSupported(): Boolean = isBluetoothSupported() 78 | } 79 | 80 | internal expect val bluetoothAvailability: Flow 81 | -------------------------------------------------------------------------------- /kable-core/src/commonMain/kotlin/ByteArray.kt: -------------------------------------------------------------------------------- 1 | package com.juul.kable 2 | 3 | import com.juul.kable.Endianness.BigEndian 4 | import com.juul.kable.Endianness.LittleEndian 5 | 6 | /** 7 | * Copied from Tuulbox. 8 | * https://github.com/JuulLabs/tuulbox/blob/fde4eb74d2aeb37b6aaac2002bccc553adc02c5a/encoding/src/commonMain/kotlin/HexString.kt 9 | */ 10 | internal fun ByteArray.toHexString( 11 | separator: String? = null, 12 | prefix: String? = null, 13 | lowerCase: Boolean = false, 14 | ): String { 15 | if (isEmpty()) return "" 16 | val hexCode = if (lowerCase) "0123456789abcdef" else "0123456789ABCDEF" 17 | val capacity = size * (2 + (prefix?.length ?: 0)) + (size - 1) * (separator?.length ?: 0) 18 | val r = StringBuilder(capacity) 19 | for (b in this) { 20 | if (separator != null && r.isNotEmpty()) r.append(separator) 21 | if (prefix != null) r.append(prefix) 22 | r.append(hexCode[b.toInt() shr 4 and 0xF]) 23 | r.append(hexCode[b.toInt() and 0xF]) 24 | } 25 | return r.toString() 26 | } 27 | 28 | internal fun ByteArray.toShort(): Int { 29 | require(size == 2) { "ByteArray must be size of 2 to be converted to a short, was $size" } 30 | return this[0] and 0xFF shl 8 or (this[1] and 0xFF) 31 | } 32 | 33 | private inline infix fun Byte.and(other: Int): Int = toInt() and other 34 | 35 | internal enum class Endianness { BigEndian, LittleEndian } 36 | 37 | internal fun UShort.toByteArray(endianness: Endianness = BigEndian): ByteArray = 38 | when (endianness) { 39 | BigEndian -> byteArrayOf( 40 | (toInt() and 0xFF00 shr 8).toByte(), 41 | (this and 0xFFu).toByte(), 42 | ) 43 | LittleEndian -> byteArrayOf( 44 | (this and 0xFFu).toByte(), 45 | (toInt() and 0xFF00 shr 8).toByte(), 46 | ) 47 | } 48 | 49 | internal fun ULong.toByteArray(endianness: Endianness = BigEndian): ByteArray = 50 | when (endianness) { 51 | BigEndian -> byteArrayOf( 52 | (this shr 56 and 0xFFuL).toByte(), 53 | (this shr 48 and 0xFFuL).toByte(), 54 | (this shr 40 and 0xFFuL).toByte(), 55 | (this shr 32 and 0xFFuL).toByte(), 56 | (this shr 24 and 0xFFuL).toByte(), 57 | (this shr 16 and 0xFFuL).toByte(), 58 | (this shr 8 and 0xFFuL).toByte(), 59 | (this and 0xFFuL).toByte(), 60 | ) 61 | LittleEndian -> byteArrayOf( 62 | (this and 0xFFuL).toByte(), 63 | (this shr 8 and 0xFFuL).toByte(), 64 | (this shr 16 and 0xFFuL).toByte(), 65 | (this shr 24 and 0xFFuL).toByte(), 66 | (this shr 32 and 0xFFuL).toByte(), 67 | (this shr 40 and 0xFFuL).toByte(), 68 | (this shr 48 and 0xFFuL).toByte(), 69 | (this shr 56 and 0xFFuL).toByte(), 70 | ) 71 | } 72 | -------------------------------------------------------------------------------- /kable-core/src/commonMain/kotlin/Descriptor.kt: -------------------------------------------------------------------------------- 1 | package com.juul.kable 2 | 3 | import kotlin.uuid.Uuid 4 | 5 | internal fun List.first( 6 | descriptorUuid: Uuid, 7 | ): T = firstOrNull(descriptorUuid) 8 | ?: throw NoSuchElementException("Descriptor $descriptorUuid not found") 9 | 10 | internal fun List.firstOrNull( 11 | descriptorUuid: Uuid, 12 | ): T? = firstOrNull { it.descriptorUuid == descriptorUuid } 13 | -------------------------------------------------------------------------------- /kable-core/src/commonMain/kotlin/Exceptions.deprecated.kt: -------------------------------------------------------------------------------- 1 | package com.juul.kable 2 | 3 | @Deprecated( 4 | message = "BluetoothException was removed. Closest replacement is UnmetRequirementException.", 5 | replaceWith = ReplaceWith("UnmetRequirementException"), 6 | level = DeprecationLevel.ERROR, 7 | ) 8 | public typealias BluetoothException = UnmetRequirementException 9 | 10 | @Deprecated( 11 | message = "LocationManagerUnavailableException replaced by UnmetRequirementException w/ a `reason` of `LocationServicesDisabled`.", 12 | replaceWith = ReplaceWith("UnmetRequirementException"), 13 | level = DeprecationLevel.ERROR, 14 | ) 15 | public typealias LocationManagerUnavailableException = UnmetRequirementException 16 | 17 | @Deprecated( 18 | message = "BluetoothDisabledException replaced by UnmetRequirementException w/ a `reason` of `BluetoothDisabled`.", 19 | replaceWith = ReplaceWith("UnmetRequirementException"), 20 | level = DeprecationLevel.ERROR, 21 | ) 22 | public typealias BluetoothDisabledException = UnmetRequirementException 23 | 24 | @Deprecated( 25 | message = "All connection loss exceptions are now represented as NotConnectedException.", 26 | replaceWith = ReplaceWith("NotConnectedException"), 27 | level = DeprecationLevel.ERROR, 28 | ) 29 | public typealias ConnectionLostException = NotConnectedException 30 | 31 | @Deprecated( 32 | message = "Kable now uses kotlinx-io's IOException.", 33 | replaceWith = ReplaceWith( 34 | "IOException", 35 | imports = ["kotlinx.io.IOException"], 36 | ), 37 | level = DeprecationLevel.ERROR, 38 | ) 39 | public typealias IOException = kotlinx.io.IOException 40 | 41 | @Deprecated( 42 | message = "All connection loss exceptions are now represented as NotConnectedException.", 43 | replaceWith = ReplaceWith("NotConnectedException"), 44 | level = DeprecationLevel.ERROR, 45 | ) 46 | public typealias NotReadyException = NotConnectedException 47 | -------------------------------------------------------------------------------- /kable-core/src/commonMain/kotlin/ExperimentalApi.kt: -------------------------------------------------------------------------------- 1 | package com.juul.kable 2 | 3 | import kotlin.annotation.AnnotationTarget.CLASS 4 | import kotlin.annotation.AnnotationTarget.FUNCTION 5 | import kotlin.annotation.AnnotationTarget.PROPERTY 6 | import kotlin.annotation.AnnotationTarget.TYPEALIAS 7 | 8 | /** Marks API that is experimental and/or likely to change. */ 9 | @Target(TYPEALIAS, FUNCTION, PROPERTY, CLASS) 10 | @Retention(AnnotationRetention.BINARY) 11 | @RequiresOptIn(level = RequiresOptIn.Level.ERROR) 12 | public annotation class ExperimentalApi 13 | -------------------------------------------------------------------------------- /kable-core/src/commonMain/kotlin/FilterPredicate.kt: -------------------------------------------------------------------------------- 1 | package com.juul.kable 2 | 3 | import com.juul.kable.Filter.Address 4 | import com.juul.kable.Filter.Name 5 | import com.juul.kable.Filter.Service 6 | import kotlin.jvm.JvmInline 7 | import kotlin.uuid.Uuid 8 | 9 | @JvmInline 10 | internal value class FilterPredicate( 11 | /** A non-empty list of filters, all of which must match to satisfy this predicate. */ 12 | val filters: List, 13 | ) { 14 | init { 15 | require(filters.isNotEmpty()) 16 | } 17 | } 18 | 19 | internal fun List.flatten(): List = 20 | flatMap(FilterPredicate::filters) 21 | 22 | /** 23 | * Returns `true` if at least one of the predicates match the given parameters. Also returns 24 | * `true` if empty because there are no predicates that _do not_ match the inputs. 25 | */ 26 | internal fun List.matches( 27 | services: List? = null, 28 | name: String? = null, 29 | address: String? = null, 30 | manufacturerData: ManufacturerData? = null, 31 | serviceData: Map? = null, 32 | ) = if (isEmpty()) { 33 | true 34 | } else { 35 | any { it.matches(services, name, address, manufacturerData, serviceData) } 36 | } 37 | 38 | /** Returns `true` if all of the filters on this predicate match the given parameters. */ 39 | internal fun FilterPredicate.matches( 40 | services: List? = null, 41 | name: String? = null, 42 | address: String? = null, 43 | manufacturerData: ManufacturerData? = null, 44 | serviceData: Map? = null, 45 | ): Boolean = filters.all { it.matches(services, name, address, manufacturerData, serviceData) } 46 | 47 | private fun Filter.matches( 48 | services: List?, 49 | name: String?, 50 | address: String?, 51 | manufacturerData: ManufacturerData?, 52 | serviceData: Map?, 53 | ): Boolean = when (this) { 54 | is Address -> matches(address) 55 | is Filter.ManufacturerData -> matches(manufacturerData?.code, manufacturerData?.data) 56 | is Filter.ServiceData -> serviceData != null && uuid in serviceData && matches(serviceData[uuid]) 57 | is Name -> matches(name) 58 | is Service -> matches(services) 59 | } 60 | -------------------------------------------------------------------------------- /kable-core/src/commonMain/kotlin/FilterPredicateBuilder.kt: -------------------------------------------------------------------------------- 1 | package com.juul.kable 2 | 3 | import kotlin.uuid.Uuid 4 | 5 | public class FilterPredicateBuilder internal constructor() { 6 | public var name: Filter.Name? = null 7 | public var address: String? = null 8 | public var services: List = emptyList() 9 | public var manufacturerData: List = emptyList() 10 | public var serviceData: List = emptyList() 11 | 12 | internal fun build(): FilterPredicate? = buildList { 13 | name?.let(::add) 14 | address?.let(Filter::Address)?.let(::add) 15 | addAll(services.map(Filter::Service)) 16 | addAll(manufacturerData) 17 | addAll(serviceData) 18 | }.ifEmpty { null }?.let(::FilterPredicate) 19 | } 20 | -------------------------------------------------------------------------------- /kable-core/src/commonMain/kotlin/FiltersBuilder.kt: -------------------------------------------------------------------------------- 1 | package com.juul.kable 2 | 3 | private typealias FilterBuilderAction = FilterPredicateBuilder.() -> Unit 4 | 5 | public class FiltersBuilder internal constructor() { 6 | private val filterBuilderActions: MutableList = mutableListOf() 7 | 8 | public fun match(builderAction: FilterBuilderAction) { 9 | filterBuilderActions.add(builderAction) 10 | } 11 | 12 | internal fun build() = filterBuilderActions.mapNotNull { builderAction -> 13 | FilterPredicateBuilder().apply(builderAction).build() 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /kable-core/src/commonMain/kotlin/GattRequestRejectedException.kt: -------------------------------------------------------------------------------- 1 | package com.juul.kable 2 | 3 | /** 4 | * Thrown on Android when underlying `android.bluetooth.BluetoothDevice` method call returns `false`. 5 | * This can occur under the following conditions: 6 | * 7 | * - Request isn't allowed (e.g. reading a non-readable characteristic) 8 | * - Underlying service or client interface is missing or invalid (e.g. `mService == null || mClientIf == 0`) 9 | * - Associated `BluetoothDevice` is unavailable 10 | * - Device is busy (i.e. a previous request is still in-progress) 11 | * - An Android internal failure occurred (i.e. an underlying `android.os.RemoteException` was thrown) 12 | */ 13 | public open class GattRequestRejectedException internal constructor( 14 | message: String? = null, 15 | ) : IllegalStateException(message) 16 | -------------------------------------------------------------------------------- /kable-core/src/commonMain/kotlin/GattStatusException.kt: -------------------------------------------------------------------------------- 1 | package com.juul.kable 2 | 3 | import kotlinx.io.IOException 4 | 5 | /** Represents an Android GATT status failure. */ 6 | public class GattStatusException( 7 | message: String? = null, 8 | cause: Throwable? = null, 9 | public val status: Int, 10 | ) : IOException(message, cause) 11 | -------------------------------------------------------------------------------- /kable-core/src/commonMain/kotlin/Identifier.kt: -------------------------------------------------------------------------------- 1 | package com.juul.kable 2 | 3 | public expect class Identifier 4 | 5 | public expect fun String.toIdentifier(): Identifier 6 | -------------------------------------------------------------------------------- /kable-core/src/commonMain/kotlin/InternalError.kt: -------------------------------------------------------------------------------- 1 | package com.juul.kable 2 | 3 | /** 4 | * An [Error] that signifies that an unexpected condition or state was encountered in the Kable 5 | * internals. 6 | * 7 | * May be thrown under the following (non-exhaustive) list of conditions: 8 | * - A new system level feature was added but Kable does not yet properly support it 9 | * - A programming error in Kable was encountered (e.g. a state when outside the designed bounds) 10 | * 11 | * Kable will likely be in an inconsistent state and will unlikely continue to function properly. It 12 | * is recommended that the application be restarted (e.g. by not catching this exception and 13 | * allowing the application to crash). 14 | * 15 | * If encountered, please report this exception (and provide logs) to: 16 | * https://github.com/JuulLabs/kable/issues 17 | */ 18 | @Suppress("ktlint:standard:indent") 19 | public class InternalError internal constructor( 20 | message: String, 21 | cause: Throwable? = null, 22 | ) : Error( 23 | "$message, please report issue to https://github.com/JuulLabs/kable/issues and provide logs", 24 | cause, 25 | ) 26 | -------------------------------------------------------------------------------- /kable-core/src/commonMain/kotlin/ManufacturerData.kt: -------------------------------------------------------------------------------- 1 | package com.juul.kable 2 | 3 | public class ManufacturerData( 4 | /** 5 | * Two-octet [Company Identifier Code][https://www.bluetooth.com/specifications/assigned-numbers/company-identifiers/] 6 | */ 7 | public val code: Int, 8 | 9 | /** 10 | * the Manufacturer Data (not including the leading two identifier octets) 11 | */ 12 | public val data: ByteArray, 13 | ) 14 | -------------------------------------------------------------------------------- /kable-core/src/commonMain/kotlin/NotConnectedException.kt: -------------------------------------------------------------------------------- 1 | package com.juul.kable 2 | 3 | import kotlinx.io.IOException 4 | 5 | public open class NotConnectedException( 6 | message: String? = null, 7 | cause: Throwable? = null, 8 | ) : IOException(message, cause) 9 | -------------------------------------------------------------------------------- /kable-core/src/commonMain/kotlin/Observation.kt: -------------------------------------------------------------------------------- 1 | package com.juul.kable 2 | 3 | import com.juul.kable.State.Connecting.Observes 4 | import com.juul.kable.logs.Logger 5 | import com.juul.kable.logs.Logging 6 | import kotlinx.atomicfu.atomic 7 | import kotlinx.coroutines.flow.StateFlow 8 | import kotlinx.coroutines.sync.Mutex 9 | import kotlinx.coroutines.sync.withLock 10 | 11 | internal class Observation( 12 | private val state: StateFlow, 13 | private val handler: Handler, 14 | private val characteristic: Characteristic, 15 | logging: Logging, 16 | identifier: String, 17 | ) { 18 | 19 | interface Handler { 20 | suspend fun startObservation(characteristic: Characteristic) 21 | suspend fun stopObservation(characteristic: Characteristic) 22 | } 23 | 24 | private val logger = Logger(logging, tag = "Kable/Observation", identifier) 25 | 26 | private val subscribers = mutableListOf() 27 | private val mutex = Mutex() 28 | 29 | private val _didStartObservation = atomic(false) 30 | private var didStartObservation: Boolean 31 | get() = _didStartObservation.value 32 | set(value) { _didStartObservation.value = value } 33 | 34 | private val isConnected: Boolean 35 | get() = state.value.isAtLeast() 36 | 37 | suspend fun onSubscription(action: OnSubscriptionAction) = mutex.withLock { 38 | subscribers += action 39 | val shouldStartObservation = !didStartObservation && subscribers.isNotEmpty() && isConnected 40 | if (shouldStartObservation) { 41 | suppressNotConnectedException { 42 | startObservation() 43 | action() 44 | } 45 | } 46 | } 47 | 48 | suspend fun onCompletion(action: OnSubscriptionAction) = mutex.withLock { 49 | subscribers -= action 50 | val shouldStopObservation = didStartObservation && subscribers.isEmpty() && isConnected 51 | if (shouldStopObservation) stopObservation() 52 | } 53 | 54 | suspend fun onConnected() = mutex.withLock { 55 | if (isConnected) { 56 | if (subscribers.isNotEmpty()) { 57 | suppressNotConnectedException { 58 | startObservation() 59 | subscribers.forEach { it() } 60 | } 61 | } else { 62 | didStartObservation = false 63 | } 64 | } 65 | } 66 | 67 | private suspend fun startObservation() { 68 | handler.startObservation(characteristic) 69 | didStartObservation = true 70 | } 71 | 72 | private suspend fun stopObservation() { 73 | suppressNotConnectedException { 74 | handler.stopObservation(characteristic) 75 | } 76 | didStartObservation = false 77 | } 78 | 79 | /** 80 | * While spinning up or down an observation the connection may drop, resulting in an unnecessary 81 | * [NotConnectedException] being thrown. 82 | * 83 | * Since observations are automatically cleared (by the underlying platform) on disconnect, 84 | * these exceptions can be ignored, as the corresponding [action] will be rendered unnecessary 85 | * (clearing an observation is not needed if connection has been lost). 86 | */ 87 | private inline fun suppressNotConnectedException(action: () -> Unit) { 88 | try { 89 | action.invoke() 90 | } catch (e: NotConnectedException) { 91 | logger.verbose { message = "Suppressed failure: $e" } 92 | } 93 | } 94 | } 95 | -------------------------------------------------------------------------------- /kable-core/src/commonMain/kotlin/ObservationEvent.kt: -------------------------------------------------------------------------------- 1 | package com.juul.kable 2 | 3 | internal sealed class ObservationEvent { 4 | 5 | open val characteristic: Characteristic? get() = null 6 | 7 | data class CharacteristicChange( 8 | override val characteristic: PlatformDiscoveredCharacteristic, 9 | val data: T, 10 | ) : ObservationEvent() 11 | 12 | data class Error( 13 | override val characteristic: PlatformDiscoveredCharacteristic, 14 | val cause: Exception, 15 | ) : ObservationEvent() 16 | 17 | // Only used on Apple (where characteristic change callback is used for characteristic reads). 18 | object Disconnected : ObservationEvent() 19 | } 20 | 21 | internal fun ObservationEvent.isAssociatedWith(characteristic: Characteristic): Boolean { 22 | val eventCharacteristic = this.characteristic 23 | return when { 24 | // `characteristic` is null for Disconnected, which applies to all characteristics. 25 | eventCharacteristic == null -> true 26 | 27 | eventCharacteristic is DiscoveredCharacteristic && characteristic is DiscoveredCharacteristic -> 28 | eventCharacteristic == characteristic 29 | 30 | else -> 31 | eventCharacteristic.characteristicUuid == characteristic.characteristicUuid && 32 | eventCharacteristic.serviceUuid == characteristic.serviceUuid 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /kable-core/src/commonMain/kotlin/Peripheral.deprecated.kt: -------------------------------------------------------------------------------- 1 | package com.juul.kable 2 | 3 | import kotlinx.coroutines.CoroutineScope 4 | 5 | @Deprecated( 6 | message = "Replaced with `Peripheral` builder function (not a CoroutineScope extension function).", 7 | replaceWith = ReplaceWith("Peripheral(advertisement, builderAction)"), 8 | level = DeprecationLevel.ERROR, 9 | ) 10 | public expect fun CoroutineScope.peripheral( 11 | advertisement: Advertisement, 12 | builderAction: PeripheralBuilderAction = {}, 13 | ): Peripheral 14 | -------------------------------------------------------------------------------- /kable-core/src/commonMain/kotlin/PeripheralBuilder.kt: -------------------------------------------------------------------------------- 1 | package com.juul.kable 2 | 3 | import com.juul.kable.logs.LoggingBuilder 4 | import kotlinx.coroutines.flow.StateFlow 5 | import kotlin.time.Duration 6 | import kotlin.time.Duration.Companion.seconds 7 | 8 | public expect class ServicesDiscoveredPeripheral { 9 | 10 | public suspend fun read( 11 | characteristic: Characteristic, 12 | ): ByteArray 13 | 14 | public suspend fun write( 15 | characteristic: Characteristic, 16 | data: ByteArray, 17 | writeType: WriteType = WriteType.WithoutResponse, 18 | ): Unit 19 | 20 | public suspend fun read( 21 | descriptor: Descriptor, 22 | ): ByteArray 23 | 24 | public suspend fun write( 25 | descriptor: Descriptor, 26 | data: ByteArray, 27 | ): Unit 28 | } 29 | 30 | public class ObservationExceptionPeripheral internal constructor(peripheral: Peripheral) { 31 | public val state: StateFlow = peripheral.state 32 | } 33 | 34 | internal typealias ServicesDiscoveredAction = suspend ServicesDiscoveredPeripheral.() -> Unit 35 | internal typealias ObservationExceptionHandler = suspend ObservationExceptionPeripheral.(cause: Exception) -> Unit 36 | 37 | internal val defaultDisconnectTimeout = 5.seconds 38 | 39 | public expect class PeripheralBuilder internal constructor() { 40 | public fun logging(init: LoggingBuilder) 41 | 42 | /** 43 | * Registers a [ServicesDiscoveredAction] for the [Peripheral] that is invoked after initial 44 | * service discovery (upon establishing a connection). 45 | * 46 | * Is **not** invoked upon subsequent service re-discoveries (due to peripheral service database 47 | * changing while connected). 48 | */ 49 | public fun onServicesDiscovered(action: ServicesDiscoveredAction) 50 | 51 | /** 52 | * Registers an [ObservationExceptionHandler] for the [Peripheral]. When registered, observation failures are 53 | * passed to the [ObservationExceptionHandler] instead of through [observation][Peripheral.observe] flows. Any 54 | * exceptions in the [ObservationExceptionHandler] will propagate through (and terminate) the associated 55 | * [observation][Peripheral.observe] flow. 56 | * 57 | * Some failures are due to connection drops before the connection state has propagated from the system, the 58 | * [ObservationExceptionHandler] can be useful for ignoring failures that precursor a connection drop: 59 | * 60 | * ``` 61 | * Peripheral(advertisement) { 62 | * observationExceptionHandler { cause -> 63 | * // Only propagate failure if we don't see a disconnect within a second. 64 | * withTimeoutOrNull(1_000L) { 65 | * state.first { it is Disconnected } 66 | * } ?: throw IllegalStateException("Observation failure occurred.", cause) 67 | * println("Ignored failure associated with disconnect: $cause") 68 | * } 69 | * } 70 | * ``` 71 | */ 72 | public fun observationExceptionHandler(handler: ObservationExceptionHandler) 73 | 74 | /** 75 | * Amount of time to allow system to gracefully disconnect before forcefully closing the 76 | * peripheral. 77 | * 78 | * Only applicable on Android. 79 | */ 80 | public var disconnectTimeout: Duration 81 | } 82 | -------------------------------------------------------------------------------- /kable-core/src/commonMain/kotlin/PlatformAdvertisement.kt: -------------------------------------------------------------------------------- 1 | package com.juul.kable 2 | 3 | public expect interface PlatformAdvertisement : Advertisement 4 | -------------------------------------------------------------------------------- /kable-core/src/commonMain/kotlin/PlatformScanner.kt: -------------------------------------------------------------------------------- 1 | package com.juul.kable 2 | 3 | public typealias PlatformScanner = Scanner 4 | -------------------------------------------------------------------------------- /kable-core/src/commonMain/kotlin/Scanner.kt: -------------------------------------------------------------------------------- 1 | package com.juul.kable 2 | 3 | import com.juul.kable.Bluetooth.Availability.Available 4 | import kotlinx.coroutines.flow.Flow 5 | 6 | public interface Scanner { 7 | 8 | /** 9 | * [Bluetooth.availability] flow should emit [Available] before collecting from [advertisements] flow. 10 | * 11 | * @throws IllegalStateException If scanning could not be initiated (e.g. feature unavailable or permission denied). 12 | * @throws UnmetRequirementException If a transient state was not satisfied (e.g. bluetooth disabled). 13 | */ 14 | public val advertisements: Flow 15 | } 16 | 17 | /** 18 | * @throws IllegalArgumentException If an invalid configuration is specified (e.g. using MAC address filter on Apple platforms). 19 | */ 20 | @Suppress("FunctionName") // Builder function. 21 | public fun Scanner( 22 | builderAction: ScannerBuilder.() -> Unit = {}, 23 | ): PlatformScanner = ScannerBuilder().apply(builderAction).build() 24 | -------------------------------------------------------------------------------- /kable-core/src/commonMain/kotlin/ScannerBuilder.kt: -------------------------------------------------------------------------------- 1 | package com.juul.kable 2 | 3 | import com.juul.kable.logs.LoggingBuilder 4 | 5 | public expect class ScannerBuilder internal constructor() { 6 | 7 | /** 8 | * Filters [Advertisement]s during a scan: If [filters] is `null` or empty, then no filtering is performed (i.e. all 9 | * [Advertisement]s are emitted during a scan). If filters are provided (i.e. [filters] is a list of at least one 10 | * [Filter]), then only [Advertisement]s that match at least one [Filter] are emitted during a scan. 11 | */ 12 | @Deprecated( 13 | message = "Use filters(FiltersBuilder.() -> Unit)", 14 | replaceWith = ReplaceWith("filters { }"), 15 | level = DeprecationLevel.HIDDEN, 16 | ) 17 | public var filters: List? 18 | 19 | /** 20 | * Filters [Advertisement]s during a scan. If predicates are non-empty, then only [Advertisement]s 21 | * that match at least one of the predicates are emitted during a scan. 22 | */ 23 | public fun filters(builderAction: FiltersBuilder.() -> Unit) 24 | 25 | public fun logging(init: LoggingBuilder) 26 | 27 | /** @throws IllegalStateException If bluetooth is unavailable. */ 28 | internal fun build(): PlatformScanner 29 | } 30 | 31 | // To preserve original behavior make each individual filter a separate predicate: 32 | internal fun List.convertDeprecatedFilters(): List = 33 | map(::listOf).map(::FilterPredicate) 34 | -------------------------------------------------------------------------------- /kable-core/src/commonMain/kotlin/SharedRepeatableAction.awaitConnect.kt: -------------------------------------------------------------------------------- 1 | package com.juul.kable 2 | 3 | import kotlinx.coroutines.CoroutineScope 4 | 5 | internal suspend fun SharedRepeatableAction.awaitConnect(): CoroutineScope = 6 | try { 7 | await() 8 | } catch (e: IllegalStateException) { 9 | throw IllegalStateException("Cannot connect peripheral that has been cancelled", e) 10 | } 11 | -------------------------------------------------------------------------------- /kable-core/src/commonMain/kotlin/SilentSupervisor.kt: -------------------------------------------------------------------------------- 1 | package com.juul.kable 2 | 3 | import kotlinx.coroutines.CoroutineExceptionHandler 4 | import kotlinx.coroutines.Job 5 | import kotlinx.coroutines.SupervisorJob 6 | import kotlin.coroutines.CoroutineContext 7 | 8 | /** 9 | * Supervisor with empty coroutine exception handler ignoring all exceptions. 10 | * 11 | * https://github.com/ktorio/ktor/blob/c9cd3308b3d0f9f1c3f5407036921e5d5aeb3f15/ktor-utils/common/src/io/ktor/util/CoroutinesUtils.kt#L23-L28 12 | */ 13 | @Suppress("FunctionName") 14 | internal fun SilentSupervisor(parent: Job? = null): CoroutineContext = 15 | SupervisorJob(parent) + CoroutineExceptionHandler { _, _ -> } 16 | -------------------------------------------------------------------------------- /kable-core/src/commonMain/kotlin/Throwable.kt: -------------------------------------------------------------------------------- 1 | package com.juul.kable 2 | 3 | import kotlinx.coroutines.CancellationException 4 | import kotlinx.coroutines.currentCoroutineContext 5 | import kotlinx.coroutines.ensureActive 6 | 7 | /** 8 | * If the exception contains cause that differs from [CancellationException] returns it otherwise 9 | * returns itself. 10 | * 11 | * Useful when wanting to convert a coroutine cancellation into a failure, for example: 12 | * If a failure occurs in a sibling coroutine to that of an async connection process 13 | * (e.g. `connect()` function), the connection process coroutine will cancel via a 14 | * [CancellationException] being thrown. The [CancellationException] can be 15 | * [unwrapped][unwrapCancellationException] to propagate (from the `connect()` function) the sibling 16 | * failure rather than [cancellation][CancellationException]. 17 | * 18 | * Copied from: https://github.com/ktorio/ktor/blob/bcd9de62518add3322dc0aa6d19235c551aaf315/ktor-client/ktor-client-core/jvm/src/io/ktor/client/utils/ExceptionUtilsJvm.kt 19 | */ 20 | internal fun Throwable.unwrapCancellationException(): Throwable { 21 | var exception: Throwable? = this 22 | while (exception is CancellationException) { 23 | // If there is a cycle, we return the initial exception. 24 | if (exception == exception.cause) return this 25 | exception = exception.cause 26 | } 27 | return exception ?: this 28 | } 29 | 30 | internal suspend fun unwrapCancellationExceptions(action: suspend () -> T): T = try { 31 | action() 32 | } catch (e: CancellationException) { 33 | currentCoroutineContext().ensureActive() 34 | throw e.unwrapCancellationException() 35 | } 36 | -------------------------------------------------------------------------------- /kable-core/src/commonMain/kotlin/UnmetRequirementException.kt: -------------------------------------------------------------------------------- 1 | package com.juul.kable 2 | 3 | import kotlinx.io.IOException 4 | 5 | public enum class UnmetRequirementReason { 6 | 7 | /** Not applicable on JavaScript. */ 8 | BluetoothDisabled, 9 | 10 | /** 11 | * Only applicable on Android 11 (API 30) and lower, where location services are required to 12 | * perform a scan. 13 | */ 14 | LocationServicesDisabled, 15 | } 16 | 17 | public class UnmetRequirementException internal constructor( 18 | public val reason: UnmetRequirementReason, 19 | message: String, 20 | cause: Throwable? = null, 21 | ) : IOException(message, cause) 22 | -------------------------------------------------------------------------------- /kable-core/src/commonMain/kotlin/bluetooth/CheckBluetoothIsSupported.kt: -------------------------------------------------------------------------------- 1 | package com.juul.kable.bluetooth 2 | 3 | internal suspend fun checkBluetoothIsSupported() { 4 | check(isSupported()) { "Bluetooth not supported" } 5 | } 6 | -------------------------------------------------------------------------------- /kable-core/src/commonMain/kotlin/bluetooth/IsSupported.kt: -------------------------------------------------------------------------------- 1 | package com.juul.kable.bluetooth 2 | 3 | internal expect suspend fun isSupported(): Boolean 4 | -------------------------------------------------------------------------------- /kable-core/src/commonMain/kotlin/coroutines/CoroutineScope.kt: -------------------------------------------------------------------------------- 1 | package com.juul.kable.coroutines 2 | 3 | import kotlinx.coroutines.CoroutineName 4 | import kotlinx.coroutines.CoroutineScope 5 | import kotlinx.coroutines.SupervisorJob 6 | import kotlinx.coroutines.job 7 | 8 | internal fun CoroutineScope.childSupervisor(name: String) = 9 | CoroutineScope(coroutineContext + SupervisorJob(coroutineContext.job) + CoroutineName(name)) 10 | -------------------------------------------------------------------------------- /kable-core/src/commonMain/kotlin/logs/Hex.kt: -------------------------------------------------------------------------------- 1 | package com.juul.kable.logs 2 | 3 | import com.juul.kable.toHexString 4 | 5 | public val Hex: Logging.DataProcessor = Hex() 6 | 7 | public class HexBuilder internal constructor() { 8 | 9 | /** Separator between each byte in the hex representation of data. */ 10 | public var separator: String = " " 11 | 12 | /** Configures if hex representation of data should be lower-case. */ 13 | public var lowerCase: Boolean = false 14 | } 15 | 16 | public fun Hex(init: HexBuilder.() -> Unit = {}): Logging.DataProcessor { 17 | val config = HexBuilder().apply(init) 18 | return Logging.DataProcessor { data, _, _, _, _ -> 19 | data.toHexString(separator = config.separator, lowerCase = config.lowerCase) 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /kable-core/src/commonMain/kotlin/logs/LogEngine.kt: -------------------------------------------------------------------------------- 1 | package com.juul.kable.logs 2 | 3 | public interface LogEngine { 4 | public fun verbose(throwable: Throwable?, tag: String, message: String) 5 | public fun debug(throwable: Throwable?, tag: String, message: String) 6 | public fun info(throwable: Throwable?, tag: String, message: String) 7 | public fun warn(throwable: Throwable?, tag: String, message: String) 8 | public fun error(throwable: Throwable?, tag: String, message: String) 9 | public fun assert(throwable: Throwable?, tag: String, message: String) 10 | } 11 | -------------------------------------------------------------------------------- /kable-core/src/commonMain/kotlin/logs/Logger.kt: -------------------------------------------------------------------------------- 1 | package com.juul.kable.logs 2 | 3 | import com.juul.kable.logs.Logging.Level.Data 4 | import com.juul.kable.logs.Logging.Level.Events 5 | 6 | internal class Logger( 7 | private val logging: Logging, 8 | private val tag: String = "Kable", 9 | private val identifier: String?, 10 | ) { 11 | 12 | inline fun verbose(throwable: Throwable? = null, crossinline init: LogMessage.() -> Unit) { 13 | if (logging.level == Events || logging.level == Data) { 14 | val message = LogMessage(logging, identifier) 15 | message.init() 16 | logging.engine.verbose(throwable, tag, message.build()) 17 | } 18 | } 19 | 20 | inline fun debug(throwable: Throwable? = null, crossinline init: LogMessage.() -> Unit) { 21 | if (logging.level == Events || logging.level == Data) { 22 | val message = LogMessage(logging, identifier) 23 | message.init() 24 | logging.engine.debug(throwable, tag, message.build()) 25 | } 26 | } 27 | 28 | inline fun info(throwable: Throwable? = null, crossinline init: LogMessage.() -> Unit) { 29 | if (logging.level == Events || logging.level == Data) { 30 | val message = LogMessage(logging, identifier) 31 | message.init() 32 | logging.engine.info(throwable, tag, message.build()) 33 | } 34 | } 35 | 36 | inline fun warn(throwable: Throwable? = null, crossinline init: LogMessage.() -> Unit) { 37 | val message = LogMessage(logging, identifier) 38 | message.init() 39 | logging.engine.warn(throwable, tag, message.build()) 40 | } 41 | 42 | inline fun error(throwable: Throwable? = null, crossinline init: LogMessage.() -> Unit) { 43 | val message = LogMessage(logging, identifier) 44 | message.init() 45 | logging.engine.error(throwable, tag, message.build()) 46 | } 47 | 48 | inline fun assert(throwable: Throwable? = null, crossinline init: LogMessage.() -> Unit) { 49 | val message = LogMessage(logging, identifier) 50 | message.init() 51 | logging.engine.assert(throwable, tag, message.build()) 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /kable-core/src/commonMain/kotlin/logs/Logging.kt: -------------------------------------------------------------------------------- 1 | package com.juul.kable.logs 2 | 3 | import com.juul.kable.ObsoleteKableApi 4 | import kotlin.uuid.Uuid 5 | 6 | public typealias LoggingBuilder = Logging.() -> Unit 7 | 8 | public class Logging { 9 | 10 | public enum class Level { 11 | 12 | /** Logs warnings when unexpected failures occur. */ 13 | Warnings, 14 | 15 | /** Same as [Warnings] plus all events. */ 16 | Events, 17 | 18 | /** Same as [Events] plus hex representation of I/O data. */ 19 | Data, 20 | } 21 | 22 | public enum class Format { 23 | 24 | /** 25 | * Outputs logging in compact format (on a single line per log), for example: 26 | * 27 | * ``` 28 | * example message(detail1=value1, detail2=value2, ...) 29 | * ``` 30 | */ 31 | Compact, 32 | 33 | /** 34 | * Outputs logging in multiline format (spanning multiple lines for log details), for example: 35 | * 36 | * ``` 37 | * example message 38 | * detail1: value1 39 | * detail2: value2 40 | * ... 41 | * ``` 42 | */ 43 | Multiline, 44 | } 45 | 46 | @ObsoleteKableApi // Planned to be replaced w/ I/O interceptors: https://github.com/JuulLabs/kable/issues/539 47 | public fun interface DataProcessor { 48 | 49 | public enum class Operation { Read, Write, Change } 50 | 51 | public fun process( 52 | data: ByteArray, 53 | operation: Operation?, 54 | serviceUuid: Uuid?, 55 | characteristicUuid: Uuid?, 56 | descriptorUuid: Uuid?, 57 | ): String 58 | } 59 | 60 | /** 61 | * Identifier to use in log messages. When `null`, defaults to the platform's peripheral identifier: 62 | * 63 | * - Android: Hardware (MAC) address (e.g. "00:11:22:AA:BB:CC") 64 | * - Apple: The UUID associated with the peer 65 | * - JavaScript: A `DOMString` that uniquely identifies a device 66 | */ 67 | public var identifier: String? = null 68 | 69 | public var engine: LogEngine = SystemLogEngine 70 | public var level: Level = Level.Warnings 71 | public var format: Format = Format.Multiline 72 | 73 | @ObsoleteKableApi // Planned to be replaced w/ I/O interceptors: https://github.com/JuulLabs/kable/issues/539 74 | public var data: DataProcessor = Hex 75 | } 76 | -------------------------------------------------------------------------------- /kable-core/src/commonMain/kotlin/logs/SystemLogEngine.kt: -------------------------------------------------------------------------------- 1 | package com.juul.kable.logs 2 | 3 | public expect object SystemLogEngine : LogEngine { 4 | override fun verbose(throwable: Throwable?, tag: String, message: String) 5 | override fun debug(throwable: Throwable?, tag: String, message: String) 6 | override fun info(throwable: Throwable?, tag: String, message: String) 7 | override fun warn(throwable: Throwable?, tag: String, message: String) 8 | override fun error(throwable: Throwable?, tag: String, message: String) 9 | override fun assert(throwable: Throwable?, tag: String, message: String) 10 | } 11 | -------------------------------------------------------------------------------- /kable-core/src/commonTest/kotlin/BluetoothTests.kt: -------------------------------------------------------------------------------- 1 | package com.juul.kable 2 | 3 | import kotlin.test.Test 4 | import kotlin.test.assertEquals 5 | 6 | class BluetoothTests { 7 | 8 | @Test 9 | fun baseUuid_plusInt() { 10 | assertEquals( 11 | expected = "00002909-0000-1000-8000-00805F9B34FB", 12 | actual = (Bluetooth.BaseUuid + 0x2909).toString().uppercase(), 13 | ) 14 | } 15 | 16 | @Test 17 | fun baseUuid_plusIntOfFFFF() { 18 | assertEquals( 19 | expected = "0000FFFF-0000-1000-8000-00805F9B34FB", 20 | actual = (Bluetooth.BaseUuid + 0xFFFF).toString().uppercase(), 21 | ) 22 | } 23 | 24 | @Test 25 | fun baseUuid_plusMaxInt() { 26 | assertEquals( 27 | expected = "7FFFFFFF-0000-1000-8000-00805F9B34FB", 28 | actual = (Bluetooth.BaseUuid + Int.MAX_VALUE).toString().uppercase(), 29 | ) 30 | } 31 | 32 | @Test 33 | fun baseUuid_plusLongOfFFFFFFFF() { 34 | assertEquals( 35 | expected = "FFFFFFFF-0000-1000-8000-00805F9B34FB", 36 | actual = (Bluetooth.BaseUuid + 0xFFFF_FFFF).toString().uppercase(), 37 | ) 38 | } 39 | 40 | @Test 41 | fun baseUuid_greaterThan32bits_truncates() { 42 | assertEquals( 43 | expected = "FFFFFFFF-0000-1000-8000-00805F9B34FB", 44 | actual = (Bluetooth.BaseUuid + 0x7FFFF_FFFF_FFFF_FFF).toString().uppercase(), 45 | ) 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /kable-core/src/commonTest/kotlin/ByteArrayTests.kt: -------------------------------------------------------------------------------- 1 | package com.juul.kable 2 | 3 | import com.juul.kable.Endianness.BigEndian 4 | import com.juul.kable.Endianness.LittleEndian 5 | import kotlin.test.Test 6 | import kotlin.test.assertContentEquals 7 | 8 | class ByteArrayTests { 9 | 10 | @Test 11 | fun uShort_toByteArray_BigEndian() { 12 | assertContentEquals( 13 | byteArrayOf(0x12, 0x34), 14 | 4660.toUShort().toByteArray(BigEndian), 15 | ) 16 | } 17 | 18 | @Test 19 | fun uShort_toByteArray_LittleEndian() { 20 | assertContentEquals( 21 | byteArrayOf(0x34, 0x12), 22 | 4660.toUShort().toByteArray(LittleEndian), 23 | ) 24 | } 25 | 26 | @Test 27 | fun uLong_toByteArray_BigEndian() { 28 | assertContentEquals( 29 | byteArrayOf(0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x12, 0x34), 30 | 4660uL.toByteArray(BigEndian), 31 | ) 32 | } 33 | 34 | @Test 35 | fun uLong_toByteArray_LittleEndian() { 36 | assertContentEquals( 37 | byteArrayOf(0x34, 0x12, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00), 38 | 4660uL.toByteArray(LittleEndian), 39 | ) 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /kable-core/src/commonTest/kotlin/StateStringTests.kt: -------------------------------------------------------------------------------- 1 | package com.juul.kable 2 | 3 | import com.juul.kable.State.Connected 4 | import com.juul.kable.State.Connecting.Bluetooth 5 | import com.juul.kable.State.Connecting.Observes 6 | import com.juul.kable.State.Connecting.Services 7 | import com.juul.kable.State.Disconnected 8 | import com.juul.kable.State.Disconnected.Status.Cancelled 9 | import com.juul.kable.State.Disconnected.Status.CentralDisconnected 10 | import com.juul.kable.State.Disconnected.Status.ConnectionLimitReached 11 | import com.juul.kable.State.Disconnected.Status.EncryptionTimedOut 12 | import com.juul.kable.State.Disconnected.Status.Failed 13 | import com.juul.kable.State.Disconnected.Status.L2CapFailure 14 | import com.juul.kable.State.Disconnected.Status.LinkManagerProtocolTimeout 15 | import com.juul.kable.State.Disconnected.Status.PeripheralDisconnected 16 | import com.juul.kable.State.Disconnected.Status.Timeout 17 | import com.juul.kable.State.Disconnected.Status.Unknown 18 | import com.juul.kable.State.Disconnected.Status.UnknownDevice 19 | import com.juul.kable.State.Disconnecting 20 | import kotlinx.coroutines.GlobalScope 21 | import kotlin.test.Test 22 | import kotlin.test.assertEquals 23 | 24 | public class StateStringTests { 25 | 26 | @Test 27 | fun stringRepresentation() { 28 | Connected(GlobalScope).assertEquals("Connected") 29 | Bluetooth.assertEquals("Connecting.Bluetooth") 30 | Observes.assertEquals("Connecting.Observes") 31 | Services.assertEquals("Connecting.Services") 32 | Disconnecting.assertEquals("Disconnecting") 33 | Disconnected(PeripheralDisconnected).assertEquals("Disconnected(Peripheral Disconnected)") 34 | Disconnected(CentralDisconnected).assertEquals("Disconnected(Central Disconnected)") 35 | Disconnected(Failed).assertEquals("Disconnected(Failed)") 36 | Disconnected(L2CapFailure).assertEquals("Disconnected(L2Cap Failure)") 37 | Disconnected(Timeout).assertEquals("Disconnected(Timeout)") 38 | Disconnected(LinkManagerProtocolTimeout).assertEquals("Disconnected(LinkManager Protocol Timeout)") 39 | Disconnected(UnknownDevice).assertEquals("Disconnected(Unknown Device)") 40 | Disconnected(Cancelled).assertEquals("Disconnected(Cancelled)") 41 | Disconnected(ConnectionLimitReached).assertEquals("Disconnected(Connection Limit Reached)") 42 | Disconnected(EncryptionTimedOut).assertEquals("Disconnected(Encryption Timed Out)") 43 | Disconnected(Unknown(133)).assertEquals("Disconnected(133)") 44 | 45 | assertEquals("Unknown(status=133)", Unknown(133).toString()) 46 | } 47 | } 48 | 49 | private infix fun State.assertEquals(expected: String) { 50 | assertEquals( 51 | expected = expected, 52 | actual = toString(), 53 | ) 54 | } 55 | -------------------------------------------------------------------------------- /kable-core/src/jsMain/kotlin/Bluetooth.kt: -------------------------------------------------------------------------------- 1 | package com.juul.kable 2 | 3 | import com.juul.kable.external.Bluetooth 4 | import com.juul.kable.external.bluetooth 5 | import js.errors.ReferenceError 6 | import kotlinx.browser.window 7 | 8 | /** 9 | * @return [Bluetooth] object or `null` if bluetooth is [unavailable](https://developer.mozilla.org/en-US/docs/Web/API/Bluetooth#browser_compatibility). 10 | */ 11 | internal fun bluetoothOrNull(): Bluetooth? { 12 | val navigator = try { 13 | window.navigator 14 | } catch (e: ReferenceError) { 15 | // ReferenceError: window is not defined 16 | return null 17 | } 18 | return navigator.bluetooth.takeIf { it !== undefined } 19 | } 20 | 21 | /** 22 | * @throws IllegalStateException If bluetooth is [unavailable](https://developer.mozilla.org/en-US/docs/Web/API/Bluetooth#browser_compatibility). 23 | */ 24 | internal fun bluetoothOrThrow(): Bluetooth { 25 | val navigator = try { 26 | window.navigator 27 | } catch (e: ReferenceError) { 28 | // ReferenceError: window is not defined 29 | throw IllegalStateException("Bluetooth unavailable", e) 30 | } 31 | val bluetooth = navigator.bluetooth 32 | if (bluetooth === undefined) { 33 | error("Bluetooth unavailable") 34 | } 35 | return bluetooth 36 | } 37 | -------------------------------------------------------------------------------- /kable-core/src/jsMain/kotlin/BluetoothAdvertisingEventWebBluetoothAdvertisement.kt: -------------------------------------------------------------------------------- 1 | package com.juul.kable 2 | 3 | import com.juul.kable.external.BluetoothAdvertisingEvent 4 | import com.juul.kable.external.BluetoothDevice 5 | import com.juul.kable.external.iterable 6 | import org.khronos.webgl.DataView 7 | import kotlin.uuid.Uuid 8 | 9 | internal class BluetoothAdvertisingEventWebBluetoothAdvertisement( 10 | private val advertisement: BluetoothAdvertisingEvent, 11 | ) : PlatformAdvertisement { 12 | 13 | internal val bluetoothDevice: BluetoothDevice 14 | get() = advertisement.device 15 | 16 | override val identifier: Identifier 17 | get() = advertisement.device.id 18 | 19 | override val name: String? 20 | get() = advertisement.name 21 | 22 | override val peripheralName: String? 23 | get() = advertisement.device.name 24 | 25 | /** Property is unavailable on JavaScript. Always returns `null`. */ 26 | override val isConnectable: Boolean? = null 27 | 28 | override val rssi: Int 29 | get() = advertisement.rssi ?: Int.MIN_VALUE 30 | 31 | override val txPower: Int? 32 | get() = advertisement.txPower 33 | 34 | override val uuids: List 35 | get() = advertisement.uuids.map { it.toUuid() } 36 | 37 | override fun serviceData(uuid: Uuid): ByteArray? = 38 | serviceDataAsDataView(uuid)?.buffer?.toByteArray() 39 | 40 | override fun manufacturerData(companyIdentifierCode: Int): ByteArray? = 41 | manufacturerDataAsDataView(companyIdentifierCode)?.buffer?.toByteArray() 42 | 43 | override fun serviceDataAsDataView(uuid: Uuid): DataView? = 44 | advertisement.serviceData.asDynamic().get(uuid.toString()) as? DataView 45 | 46 | override fun manufacturerDataAsDataView(companyIdentifierCode: Int): DataView? = 47 | advertisement.manufacturerData.asDynamic().get(companyIdentifierCode.toString()) as? DataView 48 | 49 | override val manufacturerData: ManufacturerData? 50 | get() = advertisement.manufacturerData.entries().iterable().firstOrNull()?.let { entry -> 51 | ManufacturerData( 52 | entry[0] as Int, 53 | (entry[1] as DataView).buffer.toByteArray(), 54 | ) 55 | } 56 | 57 | override fun equals(other: Any?): Boolean { 58 | if (this === other) return true 59 | if (other == null || this::class.js != other::class.js) return false 60 | other as BluetoothAdvertisingEventWebBluetoothAdvertisement 61 | return advertisement == other.advertisement 62 | } 63 | 64 | override fun hashCode(): Int = advertisement.hashCode() 65 | 66 | override fun toString(): String = 67 | "Advertisement(identifier=$identifier, name=$name, rssi=$rssi, txPower=$txPower)" 68 | } 69 | -------------------------------------------------------------------------------- /kable-core/src/jsMain/kotlin/BluetoothAvailability.kt: -------------------------------------------------------------------------------- 1 | package com.juul.kable 2 | 3 | import com.juul.kable.Bluetooth.Availability.Available 4 | import com.juul.kable.Bluetooth.Availability.Unavailable 5 | import com.juul.kable.Reason.BluetoothUndefined 6 | import com.juul.kable.external.BluetoothAvailabilityChanged 7 | import kotlinx.coroutines.await 8 | import kotlinx.coroutines.channels.awaitClose 9 | import kotlinx.coroutines.flow.Flow 10 | import kotlinx.coroutines.flow.callbackFlow 11 | import kotlinx.coroutines.flow.flowOf 12 | import kotlinx.coroutines.flow.onStart 13 | import org.w3c.dom.events.Event 14 | 15 | @Deprecated( 16 | message = "`Bluetooth.availability` has inconsistent behavior across platforms. " + 17 | "Will be removed in a future release. " + 18 | "See https://github.com/JuulLabs/kable/issues/737 for more details.", 19 | ) 20 | public actual enum class Reason { 21 | /** `window.navigator.bluetooth` is undefined. */ 22 | @Deprecated( 23 | message = "`Bluetooth.availability` has inconsistent behavior across platforms. " + 24 | "Will be removed in a future release. " + 25 | "See https://github.com/JuulLabs/kable/issues/737 for more details.", 26 | ) 27 | BluetoothUndefined, 28 | } 29 | 30 | private const val AVAILABILITY_CHANGED = "availabilitychanged" 31 | 32 | internal actual val bluetoothAvailability: Flow = 33 | bluetoothOrNull()?.let { bluetooth -> 34 | callbackFlow { 35 | // https://developer.mozilla.org/en-US/docs/Web/API/Bluetooth/onavailabilitychanged 36 | val listener: (Event) -> Unit = { event -> 37 | val isAvailable = event.unsafeCast().value 38 | trySend(if (isAvailable) Available else Unavailable(reason = null)) 39 | } 40 | 41 | bluetooth.apply { 42 | addEventListener(AVAILABILITY_CHANGED, listener) 43 | awaitClose { 44 | removeEventListener(AVAILABILITY_CHANGED, listener) 45 | } 46 | } 47 | }.onStart { 48 | val isAvailable = bluetooth.getAvailability().await() 49 | val availability = if (isAvailable) Available else Unavailable(reason = null) 50 | emit(availability) 51 | } 52 | } ?: flowOf(Unavailable(reason = BluetoothUndefined)) 53 | -------------------------------------------------------------------------------- /kable-core/src/jsMain/kotlin/BluetoothLEScanOptions.kt: -------------------------------------------------------------------------------- 1 | package com.juul.kable 2 | 3 | import com.juul.kable.external.BluetoothLEScanFilterInit 4 | import com.juul.kable.external.BluetoothLEScanOptions 5 | import com.juul.kable.external.BluetoothManufacturerDataFilterInit 6 | import com.juul.kable.external.BluetoothServiceDataFilterInit 7 | import js.objects.jso 8 | import kotlin.uuid.Uuid 9 | 10 | /** Convert list of public API type to Web Bluetooth (JavaScript) type. */ 11 | internal fun List.toBluetoothLEScanOptions(): BluetoothLEScanOptions = jso { 12 | if (isEmpty()) { 13 | acceptAllAdvertisements = true 14 | } else { 15 | filters = toBluetoothLEScanFilterInit() 16 | } 17 | } 18 | 19 | internal fun List.toBluetoothLEScanFilterInit(): Array = 20 | map(FilterPredicate::toBluetoothLEScanFilterInit) 21 | .toTypedArray() 22 | 23 | private fun FilterPredicate.toBluetoothLEScanFilterInit(): BluetoothLEScanFilterInit = jso { 24 | filters 25 | .filterIsInstance() 26 | .takeIf(Collection::isNotEmpty) 27 | ?.map(Filter.Service::uuid) 28 | ?.map(Uuid::toBluetoothServiceUUID) 29 | ?.toTypedArray() 30 | ?.let { services = it } 31 | filters 32 | .filterIsInstance() 33 | .firstOrNull() 34 | ?.let { name = it.exact } 35 | filters 36 | .filterIsInstance() 37 | .firstOrNull() 38 | ?.let { namePrefix = it.prefix } 39 | filters 40 | .filterIsInstance() 41 | .takeIf(Collection::isNotEmpty) 42 | ?.map(::toBluetoothManufacturerDataFilterInit) 43 | ?.toTypedArray() 44 | ?.let { manufacturerData = it } 45 | filters 46 | .filterIsInstance() 47 | .takeIf(Collection::isNotEmpty) 48 | ?.map(::toBluetoothServiceDataFilterInit) 49 | ?.toTypedArray() 50 | ?.let { serviceData = it } 51 | } 52 | 53 | private fun toBluetoothManufacturerDataFilterInit(filter: Filter.ManufacturerData) = 54 | jso { 55 | companyIdentifier = filter.id 56 | if (filter.data != null) { 57 | dataPrefix = filter.data 58 | } 59 | if (filter.dataMask != null) { 60 | mask = filter.dataMask 61 | } 62 | } 63 | 64 | private fun toBluetoothServiceDataFilterInit(filter: Filter.ServiceData) = 65 | jso { 66 | service = filter.uuid.toBluetoothServiceUUID() 67 | if (filter.data != null) { 68 | dataPrefix = filter.data 69 | } 70 | if (filter.dataMask != null) { 71 | mask = filter.dataMask 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /kable-core/src/jsMain/kotlin/Bytes.kt: -------------------------------------------------------------------------------- 1 | package com.juul.kable 2 | 3 | import org.khronos.webgl.ArrayBuffer 4 | import org.khronos.webgl.Int8Array 5 | 6 | internal fun ArrayBuffer.toByteArray(): ByteArray = Int8Array(this).unsafeCast() 7 | -------------------------------------------------------------------------------- /kable-core/src/jsMain/kotlin/FilterSet.kt: -------------------------------------------------------------------------------- 1 | package com.juul.kable 2 | 3 | /** 4 | * Filtering on Service Data is not supported because it is not implemented: 5 | * https://github.com/WebBluetoothCG/web-bluetooth/blob/main/implementation-status.md 6 | * 7 | * Filtering on Manufacturer Data is supported and a good explanation can be found here: 8 | * https://github.com/WebBluetoothCG/web-bluetooth/blob/main/data-filters-explainer.md 9 | */ 10 | @Deprecated( 11 | message = "Replaced with FilterPredicateBuilder", 12 | replaceWith = ReplaceWith( 13 | """ 14 | FilterPredicateBuilder().apply { 15 | name = name 16 | services = services 17 | manufacturerData = manufacturerData 18 | }.build()" 19 | """, 20 | ), 21 | level = DeprecationLevel.WARNING, 22 | ) 23 | public data class FilterSet( 24 | public val services: List = emptyList(), 25 | public val name: Filter.Name.Exact? = null, 26 | public val namePrefix: Filter.Name.Prefix? = null, 27 | public val manufacturerData: List = emptyList(), 28 | ) 29 | -------------------------------------------------------------------------------- /kable-core/src/jsMain/kotlin/Identifier.kt: -------------------------------------------------------------------------------- 1 | package com.juul.kable 2 | 3 | public actual typealias Identifier = String 4 | 5 | public actual fun String.toIdentifier(): Identifier = this 6 | -------------------------------------------------------------------------------- /kable-core/src/jsMain/kotlin/JsPeripheral.kt: -------------------------------------------------------------------------------- 1 | package com.juul.kable 2 | 3 | @Deprecated( 4 | message = "Renamed to `WebBluetoothPeripheral`.", 5 | replaceWith = ReplaceWith( 6 | expression = "WebBluetoothPeripheral", 7 | imports = ["com.juul.kable.WebBluetoothPeripheral"], 8 | ), 9 | level = DeprecationLevel.HIDDEN, 10 | ) 11 | public typealias JsPeripheral = WebBluetoothPeripheral 12 | -------------------------------------------------------------------------------- /kable-core/src/jsMain/kotlin/Observations.kt: -------------------------------------------------------------------------------- 1 | package com.juul.kable 2 | 3 | internal actual fun Peripheral.observationHandler(): Observation.Handler = object : Observation.Handler { 4 | override suspend fun startObservation(characteristic: Characteristic) { 5 | (this@observationHandler as BluetoothDeviceWebBluetoothPeripheral).startObservation(characteristic) 6 | } 7 | 8 | override suspend fun stopObservation(characteristic: Characteristic) { 9 | (this@observationHandler as BluetoothDeviceWebBluetoothPeripheral).stopObservation(characteristic) 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /kable-core/src/jsMain/kotlin/Options.deprecated.kt: -------------------------------------------------------------------------------- 1 | package com.juul.kable 2 | 3 | import kotlin.uuid.Uuid 4 | 5 | @Deprecated( 6 | message = "Use Options builder instead. See https://github.com/JuulLabs/kable/issues/723 for details.", 7 | replaceWith = ReplaceWith("Options { }"), 8 | level = DeprecationLevel.HIDDEN, 9 | ) 10 | public fun Options( 11 | filters: List? = null, 12 | filterSets: List? = null, 13 | optionalServices: List? = null, 14 | ): Options = throw NotImplementedError("Use Options builder instead. See https://github.com/JuulLabs/kable/issues/723 for details.") 15 | -------------------------------------------------------------------------------- /kable-core/src/jsMain/kotlin/Options.kt: -------------------------------------------------------------------------------- 1 | package com.juul.kable 2 | 3 | import com.juul.kable.external.RequestDeviceOptions 4 | import js.objects.jso 5 | import kotlin.uuid.Uuid 6 | 7 | /** https://developer.mozilla.org/en-US/docs/Web/API/Bluetooth/requestDevice */ 8 | public fun Options(builder: OptionsBuilder.() -> Unit): Options = 9 | OptionsBuilder().apply(builder).build() 10 | 11 | public data class Options internal constructor( 12 | internal val filters: List, 13 | internal val optionalServices: List, 14 | ) 15 | 16 | internal fun Options.toRequestDeviceOptions(): RequestDeviceOptions { 17 | val jsFilters = filters.toBluetoothLEScanFilterInit() 18 | val jsOptionalServices = optionalServices.toBluetoothServiceUUID() 19 | 20 | return jso { 21 | if (jsFilters.isEmpty()) { 22 | acceptAllDevices = true 23 | } else { 24 | filters = jsFilters 25 | } 26 | 27 | if (jsOptionalServices.isNotEmpty()) { 28 | optionalServices = jsOptionalServices 29 | } 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /kable-core/src/jsMain/kotlin/OptionsBuilder.kt: -------------------------------------------------------------------------------- 1 | package com.juul.kable 2 | 3 | import kotlin.uuid.Uuid 4 | 5 | public class OptionsBuilder internal constructor() { 6 | 7 | private var filters: List = emptyList() 8 | 9 | /** 10 | * Filters to apply when requesting devices. If predicates are non-empty, then only devices 11 | * that match at least one of the predicates will appear in the `requestDevice` picker. 12 | * 13 | * Filtering on Service Data is not supported because it is not implemented: 14 | * https://github.com/WebBluetoothCG/web-bluetooth/blob/main/implementation-status.md 15 | * 16 | * Filtering on Manufacturer Data is supported and a good explanation can be found here: 17 | * https://github.com/WebBluetoothCG/web-bluetooth/blob/main/data-filters-explainer.md 18 | */ 19 | public fun filters(builder: FiltersBuilder.() -> Unit) { 20 | filters = FiltersBuilder().apply(builder).build() 21 | } 22 | 23 | /** 24 | * Access is only granted to services listed as [service filters][Filter.Service] in [filters]. 25 | * If any additional services need to be accessed, they must be specified in [optionalServices]. 26 | * 27 | * https://webbluetoothcg.github.io/web-bluetooth/#device-discovery 28 | */ 29 | public var optionalServices: List = emptyList() 30 | 31 | internal fun build() = Options(filters, optionalServices) 32 | } 33 | -------------------------------------------------------------------------------- /kable-core/src/jsMain/kotlin/Peripheral.deprecated.kt: -------------------------------------------------------------------------------- 1 | package com.juul.kable 2 | 3 | import com.juul.kable.external.BluetoothDevice 4 | import kotlinx.coroutines.CoroutineScope 5 | 6 | @Deprecated( 7 | message = "Replaced with `Peripheral` builder function (not a CoroutineScope extension function).", 8 | replaceWith = ReplaceWith("Peripheral(advertisement, builderAction)"), 9 | level = DeprecationLevel.ERROR, 10 | ) 11 | public actual fun CoroutineScope.peripheral( 12 | advertisement: Advertisement, 13 | builderAction: PeripheralBuilderAction, 14 | ): Peripheral { 15 | advertisement as BluetoothAdvertisingEventWebBluetoothAdvertisement 16 | return peripheral(advertisement.bluetoothDevice, builderAction) 17 | } 18 | 19 | @Deprecated( 20 | message = "Replaced with `Peripheral` builder function (not a CoroutineScope extension function).", 21 | replaceWith = ReplaceWith("Peripheral(bluetoothDevice, builderAction)"), 22 | level = DeprecationLevel.WARNING, 23 | ) 24 | internal fun CoroutineScope.peripheral( 25 | bluetoothDevice: BluetoothDevice, 26 | builderAction: PeripheralBuilderAction = {}, 27 | ): WebBluetoothPeripheral = peripheral(bluetoothDevice, PeripheralBuilder().apply(builderAction)) 28 | 29 | internal fun CoroutineScope.peripheral( 30 | bluetoothDevice: BluetoothDevice, 31 | builder: PeripheralBuilder, 32 | ): WebBluetoothPeripheral = builder.build(bluetoothDevice) 33 | -------------------------------------------------------------------------------- /kable-core/src/jsMain/kotlin/Peripheral.kt: -------------------------------------------------------------------------------- 1 | package com.juul.kable 2 | 3 | import com.juul.kable.external.BluetoothDevice 4 | import js.errors.JsError 5 | import kotlinx.coroutines.await 6 | import kotlinx.coroutines.ensureActive 7 | import web.errors.DOMException 8 | import web.errors.DOMException.Companion.SecurityError 9 | import kotlin.coroutines.coroutineContext 10 | 11 | public actual fun Peripheral( 12 | advertisement: Advertisement, 13 | builderAction: PeripheralBuilderAction, 14 | ): Peripheral { 15 | advertisement as BluetoothAdvertisingEventWebBluetoothAdvertisement 16 | return Peripheral(advertisement.bluetoothDevice, builderAction) 17 | } 18 | 19 | @Suppress("FunctionName") // Builder function. 20 | public suspend fun Peripheral( 21 | identifier: Identifier, 22 | builderAction: PeripheralBuilderAction, 23 | ): WebBluetoothPeripheral? { 24 | val bluetooth = bluetoothOrThrow() 25 | val devices = try { 26 | bluetooth.getDevices().await() 27 | } catch (e: JsError) { 28 | coroutineContext.ensureActive() 29 | throw when { 30 | // The Web Bluetooth API can only be used in a secure context. 31 | // https://developer.mozilla.org/en-US/docs/Web/API/Web_Bluetooth_API#security_considerations 32 | e is DOMException && e.name == SecurityError -> 33 | IllegalStateException("Operation is not permitted in this context due to security concerns", e) 34 | 35 | else -> InternalError("Failed to invoke getDevices request", e) 36 | } 37 | } 38 | return devices.singleOrNull { bluetoothDevice -> 39 | bluetoothDevice.id == identifier 40 | }?.let { bluetoothDevice -> 41 | Peripheral(bluetoothDevice, builderAction) 42 | } 43 | } 44 | 45 | @Suppress("FunctionName") // Builder function. 46 | internal fun Peripheral( 47 | bluetoothDevice: BluetoothDevice, 48 | builderAction: PeripheralBuilderAction, 49 | ): WebBluetoothPeripheral = Peripheral(bluetoothDevice, PeripheralBuilder().apply(builderAction)) 50 | 51 | @Suppress("FunctionName") // Builder function. 52 | internal fun Peripheral( 53 | bluetoothDevice: BluetoothDevice, 54 | builder: PeripheralBuilder, 55 | ): WebBluetoothPeripheral = builder.build(bluetoothDevice) 56 | -------------------------------------------------------------------------------- /kable-core/src/jsMain/kotlin/PeripheralBuilder.kt: -------------------------------------------------------------------------------- 1 | package com.juul.kable 2 | 3 | import com.juul.kable.external.BluetoothDevice 4 | import com.juul.kable.logs.Logging 5 | import com.juul.kable.logs.LoggingBuilder 6 | import kotlin.time.Duration 7 | 8 | public actual class ServicesDiscoveredPeripheral internal constructor( 9 | private val peripheral: WebBluetoothPeripheral, 10 | ) { 11 | 12 | public actual suspend fun read( 13 | characteristic: Characteristic, 14 | ): ByteArray = peripheral.read(characteristic) 15 | 16 | public actual suspend fun read( 17 | descriptor: Descriptor, 18 | ): ByteArray = peripheral.read(descriptor) 19 | 20 | public actual suspend fun write( 21 | characteristic: Characteristic, 22 | data: ByteArray, 23 | writeType: WriteType, 24 | ) { 25 | peripheral.write(characteristic, data, writeType) 26 | } 27 | 28 | public actual suspend fun write( 29 | descriptor: Descriptor, 30 | data: ByteArray, 31 | ) { 32 | peripheral.write(descriptor, data) 33 | } 34 | } 35 | 36 | public actual class PeripheralBuilder internal actual constructor() { 37 | 38 | internal var logging: Logging = Logging() 39 | public actual fun logging(init: LoggingBuilder) { 40 | val logging = Logging() 41 | logging.init() 42 | this.logging = logging 43 | } 44 | 45 | internal var onServicesDiscovered: ServicesDiscoveredAction = {} 46 | public actual fun onServicesDiscovered(action: ServicesDiscoveredAction) { 47 | onServicesDiscovered = action 48 | } 49 | 50 | internal var observationExceptionHandler: ObservationExceptionHandler = { cause -> throw cause } 51 | public actual fun observationExceptionHandler(handler: ObservationExceptionHandler) { 52 | observationExceptionHandler = handler 53 | } 54 | 55 | public actual var disconnectTimeout: Duration = defaultDisconnectTimeout 56 | 57 | internal fun build(bluetoothDevice: BluetoothDevice) = 58 | BluetoothDeviceWebBluetoothPeripheral( 59 | bluetoothDevice, 60 | observationExceptionHandler, 61 | onServicesDiscovered, 62 | disconnectTimeout, 63 | logging, 64 | ) 65 | } 66 | -------------------------------------------------------------------------------- /kable-core/src/jsMain/kotlin/PlatformAdvertisement.kt: -------------------------------------------------------------------------------- 1 | package com.juul.kable 2 | 3 | import org.khronos.webgl.DataView 4 | import kotlin.uuid.Uuid 5 | 6 | public actual interface PlatformAdvertisement : Advertisement { 7 | public fun serviceDataAsDataView(uuid: Uuid): DataView? 8 | public fun manufacturerDataAsDataView(companyIdentifierCode: Int): DataView? 9 | } 10 | -------------------------------------------------------------------------------- /kable-core/src/jsMain/kotlin/ScannerBuilder.kt: -------------------------------------------------------------------------------- 1 | package com.juul.kable 2 | 3 | import com.juul.kable.logs.Logging 4 | import com.juul.kable.logs.LoggingBuilder 5 | 6 | public actual class ScannerBuilder { 7 | 8 | @Deprecated( 9 | message = "Use filters(FiltersBuilder.() -> Unit)", 10 | replaceWith = ReplaceWith("filters { }"), 11 | level = DeprecationLevel.HIDDEN, 12 | ) 13 | public actual var filters: List? = null 14 | 15 | private var filterPredicates: List = emptyList() 16 | 17 | /** 18 | * Filters [Advertisement]s during a scan. If predicates are non-empty, then only [Advertisement]s 19 | * that match at least one of the predicates are emitted during a scan. 20 | * 21 | * Filtering on Service Data is not supported because it is not implemented: 22 | * https://github.com/WebBluetoothCG/web-bluetooth/blob/main/implementation-status.md 23 | * 24 | * Filtering on Manufacturer Data is supported and a good explanation can be found here: 25 | * https://github.com/WebBluetoothCG/web-bluetooth/blob/main/data-filters-explainer.md 26 | */ 27 | public actual fun filters(builderAction: FiltersBuilder.() -> Unit) { 28 | filterPredicates = FiltersBuilder().apply(builderAction).build() 29 | } 30 | 31 | private var logging: Logging = Logging() 32 | 33 | public actual fun logging(init: LoggingBuilder) { 34 | logging = Logging().apply(init) 35 | } 36 | 37 | internal actual fun build(): PlatformScanner = BluetoothWebBluetoothScanner( 38 | filters = filterPredicates, 39 | logging = logging, 40 | ) 41 | } 42 | -------------------------------------------------------------------------------- /kable-core/src/jsMain/kotlin/Uuid.kt: -------------------------------------------------------------------------------- 1 | package com.juul.kable 2 | 3 | import com.juul.kable.external.BluetoothServiceUUID 4 | import com.juul.kable.external.BluetoothUUID 5 | import kotlin.uuid.Uuid 6 | 7 | // Number of characters in a 16-bit UUID alias in string hex representation 8 | private const val UUID_ALIAS_STRING_LENGTH = 4 9 | 10 | /** 11 | * Per [Web Bluetooth](https://webbluetoothcg.github.io/web-bluetooth/#typedefdef-uuid) Draft Community Group Report, 12 | * UUIDs are represented as `DOMString`: 13 | * 14 | * ``` 15 | * typedef DOMString UUID; 16 | * ``` 17 | */ 18 | internal typealias UUID = String 19 | 20 | internal fun UUID.toUuid(): Uuid = 21 | Uuid.parse( 22 | when (length) { 23 | UUID_ALIAS_STRING_LENGTH -> BluetoothUUID.canonicalUUID(toInt(16)) 24 | else -> this 25 | }, 26 | ) 27 | 28 | internal fun List.toBluetoothServiceUUID(): Array = 29 | map(Uuid::toBluetoothServiceUUID) 30 | .toTypedArray() 31 | 32 | // Note: Web Bluetooth requires that UUIDs be provided as lowercase strings. 33 | internal fun Uuid.toBluetoothServiceUUID(): BluetoothServiceUUID = 34 | toString().lowercase() 35 | -------------------------------------------------------------------------------- /kable-core/src/jsMain/kotlin/WebBluetoothAdvertisement.kt: -------------------------------------------------------------------------------- 1 | package com.juul.kable 2 | 3 | @Deprecated( 4 | "Moved to PlatformAdvertisement.", 5 | replaceWith = ReplaceWith("PlatformAdvertisement"), 6 | level = DeprecationLevel.HIDDEN, 7 | ) 8 | public typealias WebBluetoothAdvertisement = PlatformAdvertisement 9 | -------------------------------------------------------------------------------- /kable-core/src/jsMain/kotlin/WebBluetoothPeripheral.kt: -------------------------------------------------------------------------------- 1 | package com.juul.kable 2 | 3 | import kotlinx.coroutines.flow.Flow 4 | import org.khronos.webgl.DataView 5 | 6 | public interface WebBluetoothPeripheral : Peripheral { 7 | public suspend fun readAsDataView(characteristic: Characteristic): DataView 8 | public suspend fun readAsDataView(descriptor: Descriptor): DataView 9 | public fun observeDataView( 10 | characteristic: Characteristic, 11 | onSubscription: OnSubscriptionAction = {}, 12 | ): Flow 13 | } 14 | -------------------------------------------------------------------------------- /kable-core/src/jsMain/kotlin/WebBluetoothScanner.kt: -------------------------------------------------------------------------------- 1 | package com.juul.kable 2 | 3 | @Deprecated( 4 | "Moved to PlatformScanner.", 5 | replaceWith = ReplaceWith("PlatformScanner"), 6 | level = DeprecationLevel.HIDDEN, 7 | ) 8 | public typealias WebBluetoothScanner = PlatformScanner 9 | -------------------------------------------------------------------------------- /kable-core/src/jsMain/kotlin/bluetooth/IsSupported.kt: -------------------------------------------------------------------------------- 1 | package com.juul.kable.bluetooth 2 | 3 | import com.juul.kable.InternalError 4 | import com.juul.kable.bluetoothOrNull 5 | import js.errors.JsError 6 | import js.errors.TypeError 7 | import kotlinx.coroutines.await 8 | 9 | internal actual suspend fun isSupported(): Boolean { 10 | val bluetooth = bluetoothOrNull() ?: return false 11 | val promise = try { 12 | bluetooth.getAvailability() 13 | } catch (e: TypeError) { 14 | // > TypeError: navigator.bluetooth.getAvailability is not a function 15 | return false 16 | } 17 | return try { 18 | promise.await() 19 | } catch (e: JsError) { 20 | throw InternalError("Failed to get bluetooth availability", e) 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /kable-core/src/jsMain/kotlin/bluetooth/WatchingAdvertisementsSupport.kt: -------------------------------------------------------------------------------- 1 | package com.juul.kable.bluetooth 2 | 3 | internal val canWatchAdvertisements by lazy { 4 | js("BluetoothDevice.prototype.watchAdvertisements") != null 5 | } 6 | 7 | internal val canUnwatchAdvertisements by lazy { 8 | js("BluetoothDevice.prototype.unwatchAdvertisements") != null 9 | } 10 | 11 | internal val isWatchingAdvertisementsSupported = canWatchAdvertisements && canUnwatchAdvertisements 12 | -------------------------------------------------------------------------------- /kable-core/src/jsMain/kotlin/external/Bluetooth.kt: -------------------------------------------------------------------------------- 1 | package com.juul.kable.external 2 | 3 | import org.w3c.dom.events.EventTarget 4 | import kotlin.js.Promise 5 | 6 | /** https://developer.mozilla.org/en-US/docs/Web/API/Bluetooth */ 7 | internal abstract external class Bluetooth : EventTarget { 8 | fun getAvailability(): Promise 9 | fun requestDevice(options: RequestDeviceOptions): Promise 10 | fun requestLEScan(options: BluetoothLEScanOptions): Promise 11 | fun getDevices(): Promise> 12 | } 13 | -------------------------------------------------------------------------------- /kable-core/src/jsMain/kotlin/external/BluetoothAdvertisingEvent.kt: -------------------------------------------------------------------------------- 1 | package com.juul.kable.external 2 | 3 | import org.w3c.dom.events.Event 4 | 5 | internal interface BluetoothManufacturerDataMap { 6 | fun entries(): JsIterator> 7 | } 8 | 9 | /** 10 | * https://webbluetoothcg.github.io/web-bluetooth/#bluetoothadvertisingevent 11 | */ 12 | internal abstract external class BluetoothAdvertisingEvent : Event { 13 | val device: BluetoothDevice 14 | val uuids: Array 15 | val name: String? 16 | val rssi: Int? 17 | val txPower: Int? 18 | val manufacturerData: BluetoothManufacturerDataMap 19 | val serviceData: BluetoothServiceDataMap 20 | } 21 | -------------------------------------------------------------------------------- /kable-core/src/jsMain/kotlin/external/BluetoothAvailabilityChanged.kt: -------------------------------------------------------------------------------- 1 | package com.juul.kable.external 2 | 3 | import org.w3c.dom.events.Event 4 | 5 | /** https://webbluetoothcg.github.io/web-bluetooth/#availability */ 6 | internal external class BluetoothAvailabilityChanged : Event { 7 | val value: Boolean // Bluetooth available. 8 | } 9 | -------------------------------------------------------------------------------- /kable-core/src/jsMain/kotlin/external/BluetoothCharacteristicProperties.kt: -------------------------------------------------------------------------------- 1 | package com.juul.kable.external 2 | 3 | /** 4 | * https://developer.mozilla.org/en-US/docs/Web/API/BluetoothCharacteristicProperties 5 | * https://webbluetoothcg.github.io/web-bluetooth/#characteristicproperties-interface 6 | */ 7 | internal external interface BluetoothCharacteristicProperties { 8 | 9 | /** Returns a boolean that is `true` if signed writing to the characteristic value is permitted. */ 10 | val authenticatedSignedWrites: Boolean 11 | 12 | /** Returns a boolean that is `true` if the broadcast of the characteristic value is permitted using the Server Characteristic Configuration Descriptor. */ 13 | val broadcast: Boolean 14 | 15 | /** Returns a boolean that is `true` if indications of the characteristic value with acknowledgement is permitted. */ 16 | val indicate: Boolean 17 | 18 | /** Returns a boolean that is `true` if notifications of the characteristic value without acknowledgement is permitted. */ 19 | val notify: Boolean 20 | 21 | /** Returns a boolean that is `true` if the reading of the characteristic value is permitted. */ 22 | val read: Boolean 23 | 24 | /** Returns a boolean that is `true` if reliable writes to the characteristic is permitted. */ 25 | val reliableWrite: Boolean 26 | 27 | /** Returns a boolean that is `true` if reliable writes to the characteristic descriptor is permitted. */ 28 | val writableAuxiliaries: Boolean 29 | 30 | /** Returns a boolean that is `true` if the writing to the characteristic with response is permitted. */ 31 | val write: Boolean 32 | 33 | /** Returns a boolean that is `true` if the writing to the characteristic without response is permitted. */ 34 | val writeWithoutResponse: Boolean 35 | } 36 | -------------------------------------------------------------------------------- /kable-core/src/jsMain/kotlin/external/BluetoothCharacteristicUUID.kt: -------------------------------------------------------------------------------- 1 | package com.juul.kable.external 2 | 3 | /** 4 | * According to [Web Bluetooth](https://webbluetoothcg.github.io/web-bluetooth/#typedefdef-bluetoothcharacteristicuuid): 5 | * 6 | * > BluetoothCharacteristicUUID represents 16- and 32-bit UUID aliases, valid UUIDs, and names defined in 7 | * > [BLUETOOTH-ASSIGNED-CHARACTERISTICS](https://webbluetoothcg.github.io/web-bluetooth/#biblio-bluetooth-assigned-characteristics). 8 | */ 9 | internal typealias BluetoothCharacteristicUUID = String 10 | -------------------------------------------------------------------------------- /kable-core/src/jsMain/kotlin/external/BluetoothDataFilterInit.kt: -------------------------------------------------------------------------------- 1 | package com.juul.kable.external 2 | 3 | /** 4 | * ``` 5 | * dictionary BluetoothDataFilterInit { 6 | * BufferSource dataPrefix; 7 | * BufferSource mask; 8 | * }; 9 | * ``` 10 | * 11 | * https://webbluetoothcg.github.io/web-bluetooth/#device-discovery 12 | */ 13 | internal external interface BluetoothDataFilterInit { 14 | var dataPrefix: BufferSource? 15 | var mask: BufferSource? 16 | } 17 | -------------------------------------------------------------------------------- /kable-core/src/jsMain/kotlin/external/BluetoothDescriptorUUID.kt: -------------------------------------------------------------------------------- 1 | package com.juul.kable.external 2 | 3 | /** 4 | * According to [Web Bluetooth](https://webbluetoothcg.github.io/web-bluetooth/#typedefdef-bluetoothdescriptoruuid): 5 | * 6 | * > BluetoothDescriptorUUID represents 16- and 32-bit UUID aliases, valid UUIDs, and names defined in 7 | * > [BLUETOOTH-ASSIGNED-DESCRIPTORS](https://webbluetoothcg.github.io/web-bluetooth/#biblio-bluetooth-assigned-descriptors). 8 | */ 9 | internal typealias BluetoothDescriptorUUID = String 10 | -------------------------------------------------------------------------------- /kable-core/src/jsMain/kotlin/external/BluetoothDevice.kt: -------------------------------------------------------------------------------- 1 | package com.juul.kable.external 2 | 3 | import org.w3c.dom.events.EventTarget 4 | import kotlin.js.Promise 5 | 6 | /** 7 | * https://developer.mozilla.org/en-US/docs/Web/API/BluetoothDevice 8 | * https://webbluetoothcg.github.io/web-bluetooth/#bluetoothdevice-interface 9 | */ 10 | internal abstract external class BluetoothDevice : EventTarget { 11 | val id: String 12 | val name: String? 13 | 14 | /** 15 | * Non-`null` when: 16 | * 17 | * > [..] "bluetooth"'s extra permission data for `this`'s relevant settings object has an 18 | * > `AllowedBluetoothDevice` _allowedDevice_ in its `allowedDevices` list with 19 | * > `allowedDevice.device` the same device as `this.representedDevice` and 20 | * > `allowedDevice.mayUseGATT` equal to `true` [..] 21 | * 22 | * https://webbluetoothcg.github.io/web-bluetooth/#bluetoothdevice-interface 23 | */ 24 | val gatt: BluetoothRemoteGATTServer? 25 | 26 | // Experimental advertisement features 27 | // https://webbluetoothcg.github.io/web-bluetooth/#dom-bluetoothdevice-watchadvertisements 28 | // Requires chrome://flags/#enable-experimental-web-platform-features 29 | fun watchAdvertisements(): Promise 30 | fun unwatchAdvertisements(): Promise 31 | val watchingAdvertisements: Boolean 32 | } 33 | 34 | internal fun BluetoothDevice.string(): String = 35 | "BluetoothDevice(id=$id, name=$name, gatt=${gatt?.string()})" 36 | -------------------------------------------------------------------------------- /kable-core/src/jsMain/kotlin/external/BluetoothLEScanFilterInit.kt: -------------------------------------------------------------------------------- 1 | package com.juul.kable.external 2 | 3 | /** 4 | * ``` 5 | * dictionary BluetoothLEScanFilterInit { 6 | * sequence services; 7 | * DOMString name; 8 | * DOMString namePrefix; 9 | * sequence manufacturerData; 10 | * sequence serviceData; 11 | * }; 12 | * ``` 13 | * 14 | * https://webbluetoothcg.github.io/web-bluetooth/#device-discovery 15 | */ 16 | internal external interface BluetoothLEScanFilterInit { 17 | var services: Array? 18 | var name: String? 19 | var namePrefix: String? 20 | var manufacturerData: Array? 21 | var serviceData: Array? 22 | } 23 | -------------------------------------------------------------------------------- /kable-core/src/jsMain/kotlin/external/BluetoothLEScanOptions.kt: -------------------------------------------------------------------------------- 1 | package com.juul.kable.external 2 | 3 | /** 4 | * ``` 5 | * dictionary BluetoothLEScanOptions { 6 | * sequence filters; 7 | * boolean keepRepeatedDevices = false; 8 | * boolean acceptAllAdvertisements = false; 9 | * }; 10 | * ``` 11 | * 12 | * https://webbluetoothcg.github.io/web-bluetooth/scanning.html#scanning 13 | */ 14 | internal external interface BluetoothLEScanOptions { 15 | var filters: Array? 16 | var keepRepeatedDevices: Boolean? 17 | var acceptAllAdvertisements: Boolean? 18 | } 19 | -------------------------------------------------------------------------------- /kable-core/src/jsMain/kotlin/external/BluetoothManufacturerDataFilterInit.kt: -------------------------------------------------------------------------------- 1 | package com.juul.kable.external 2 | 3 | /** 4 | * ``` 5 | * dictionary BluetoothManufacturerDataFilterInit : BluetoothDataFilterInit { 6 | * required [EnforceRange] unsigned short companyIdentifier; 7 | * }; 8 | * ``` 9 | * 10 | * https://webbluetoothcg.github.io/web-bluetooth/#device-discovery 11 | */ 12 | internal external interface BluetoothManufacturerDataFilterInit : BluetoothDataFilterInit { 13 | var companyIdentifier: Int? 14 | } 15 | -------------------------------------------------------------------------------- /kable-core/src/jsMain/kotlin/external/BluetoothRemoteGATTCharacteristic.kt: -------------------------------------------------------------------------------- 1 | package com.juul.kable.external 2 | 3 | import com.juul.kable.UUID 4 | import org.khronos.webgl.DataView 5 | import org.w3c.dom.events.EventTarget 6 | import kotlin.js.Promise 7 | 8 | /** 9 | * https://developer.mozilla.org/en-US/docs/Web/API/BluetoothRemoteGATTCharacteristic 10 | * https://webbluetoothcg.github.io/web-bluetooth/#bluetoothgattcharacteristic-interface 11 | */ 12 | internal external class BluetoothRemoteGATTCharacteristic : EventTarget { 13 | 14 | val service: BluetoothRemoteGATTService 15 | val uuid: UUID 16 | val properties: BluetoothCharacteristicProperties 17 | val value: DataView? 18 | 19 | fun getDescriptor(descriptor: BluetoothDescriptorUUID): Promise 20 | fun getDescriptors(): Promise> 21 | 22 | fun readValue(): Promise 23 | 24 | fun writeValueWithResponse(value: BufferSource): Promise 25 | fun writeValueWithoutResponse(value: BufferSource): Promise 26 | 27 | /** 28 | * > All notifications become inactive when a device is disconnected. A site that wants to keep 29 | * > getting notifications after reconnecting needs to call [startNotifications] again, and 30 | * > there is an unavoidable risk that some notifications will be missed in the gap before 31 | * > [startNotifications] takes effect. 32 | * 33 | * https://webbluetoothcg.github.io/web-bluetooth/#active-notification-context-set 34 | */ 35 | fun startNotifications(): Promise 36 | 37 | fun stopNotifications(): Promise 38 | } 39 | -------------------------------------------------------------------------------- /kable-core/src/jsMain/kotlin/external/BluetoothRemoteGATTDescriptor.kt: -------------------------------------------------------------------------------- 1 | package com.juul.kable.external 2 | 3 | import com.juul.kable.UUID 4 | import org.khronos.webgl.DataView 5 | import org.w3c.dom.events.EventTarget 6 | import kotlin.js.Promise 7 | 8 | /** 9 | * https://developer.mozilla.org/en-US/docs/Web/API/BluetoothRemoteGATTDescriptor 10 | * https://webbluetoothcg.github.io/web-bluetooth/#bluetoothremotegattdescriptor 11 | */ 12 | internal external class BluetoothRemoteGATTDescriptor : EventTarget { 13 | val uuid: UUID 14 | val characteristic: BluetoothRemoteGATTCharacteristic 15 | fun readValue(): Promise 16 | fun writeValue(value: BufferSource): Promise 17 | } 18 | -------------------------------------------------------------------------------- /kable-core/src/jsMain/kotlin/external/BluetoothRemoteGATTServer.kt: -------------------------------------------------------------------------------- 1 | package com.juul.kable.external 2 | 3 | import kotlin.js.Promise 4 | 5 | /** 6 | * https://developer.mozilla.org/en-US/docs/Web/API/BluetoothRemoteGATTServer 7 | * https://webbluetoothcg.github.io/web-bluetooth/#bluetoothgattremoteserver-interface 8 | */ 9 | internal external interface BluetoothRemoteGATTServer { 10 | 11 | val device: BluetoothDevice 12 | val connected: Boolean 13 | 14 | fun connect(): Promise 15 | fun disconnect(): Unit 16 | 17 | fun getPrimaryServices(): Promise> 18 | 19 | fun getPrimaryServices( 20 | service: BluetoothServiceUUID, 21 | ): Promise> 22 | } 23 | 24 | internal fun BluetoothRemoteGATTServer.string() = 25 | "BluetoothRemoteGATTServer(connected=$connected)" 26 | -------------------------------------------------------------------------------- /kable-core/src/jsMain/kotlin/external/BluetoothRemoteGATTService.kt: -------------------------------------------------------------------------------- 1 | package com.juul.kable.external 2 | 3 | import com.juul.kable.UUID 4 | import org.w3c.dom.events.EventTarget 5 | import kotlin.js.Promise 6 | 7 | /** 8 | * https://developer.mozilla.org/en-US/docs/Web/API/BluetoothRemoteGATTService 9 | * https://webbluetoothcg.github.io/web-bluetooth/#bluetoothremotegattservice 10 | */ 11 | internal external class BluetoothRemoteGATTService : EventTarget { 12 | 13 | val uuid: UUID 14 | 15 | fun getCharacteristics(): Promise> 16 | } 17 | -------------------------------------------------------------------------------- /kable-core/src/jsMain/kotlin/external/BluetoothScan.kt: -------------------------------------------------------------------------------- 1 | package com.juul.kable.external 2 | 3 | /** 4 | * https://webbluetoothcg.github.io/web-bluetooth/scanning.html#bluetoothlescan 5 | */ 6 | internal external interface BluetoothScan { 7 | fun stop() 8 | } 9 | -------------------------------------------------------------------------------- /kable-core/src/jsMain/kotlin/external/BluetoothServiceDataFilterInit.kt: -------------------------------------------------------------------------------- 1 | package com.juul.kable.external 2 | 3 | /** 4 | * ``` 5 | * dictionary BluetoothServiceDataFilterInit : BluetoothDataFilterInit { 6 | * required BluetoothServiceUUID service; 7 | * }; 8 | * ``` 9 | * 10 | * https://webbluetoothcg.github.io/web-bluetooth/#device-discovery 11 | */ 12 | internal external interface BluetoothServiceDataFilterInit : BluetoothDataFilterInit { 13 | var service: BluetoothServiceUUID 14 | } 15 | -------------------------------------------------------------------------------- /kable-core/src/jsMain/kotlin/external/BluetoothServiceDataMap.kt: -------------------------------------------------------------------------------- 1 | package com.juul.kable.external 2 | 3 | import js.collections.JsMap 4 | import org.khronos.webgl.DataView 5 | 6 | /** 7 | * According to [Web Bluetooth](https://webbluetoothcg.github.io/web-bluetooth/#bluetoothservicedatamap): 8 | * 9 | * > Instances of `BluetoothServiceDataMap` have a `BackingMap` slot because they are maplike, which 10 | * > maps service UUIDs to the service’s data, converted to [DataView]s. 11 | */ 12 | internal typealias BluetoothServiceDataMap = JsMap 13 | -------------------------------------------------------------------------------- /kable-core/src/jsMain/kotlin/external/BluetoothServiceUUID.kt: -------------------------------------------------------------------------------- 1 | package com.juul.kable.external 2 | 3 | /** 4 | * According to [Web Bluetooth](https://webbluetoothcg.github.io/web-bluetooth/#typedefdef-bluetoothserviceuuid): 5 | * 6 | * > BluetoothServiceUUID represents 16- and 32-bit UUID aliases, valid UUIDs, and names defined in 7 | * > [BLUETOOTH-ASSIGNED-SERVICES](https://webbluetoothcg.github.io/web-bluetooth/#biblio-bluetooth-assigned-services). 8 | */ 9 | internal typealias BluetoothServiceUUID = String 10 | -------------------------------------------------------------------------------- /kable-core/src/jsMain/kotlin/external/BluetoothUUID.kt: -------------------------------------------------------------------------------- 1 | package com.juul.kable.external 2 | 3 | import com.juul.kable.UUID 4 | 5 | /** 6 | * According to [Web Bluetooth](https://webbluetoothcg.github.io/web-bluetooth/#uuids): 7 | * 8 | * > Note: This standard provides the BluetoothUUID.canonicalUUID(alias) function to map 9 | * > a 16- or 32-bit Bluetooth UUID alias to its 128-bit form. 10 | * 11 | * _See also: [Standardized UUIDs](https://webbluetoothcg.github.io/web-bluetooth/#standardized-uuids)_ 12 | */ 13 | internal abstract external class BluetoothUUID { 14 | internal companion object { 15 | internal fun canonicalUUID(alias: dynamic): UUID 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /kable-core/src/jsMain/kotlin/external/BufferSource.kt: -------------------------------------------------------------------------------- 1 | package com.juul.kable.external 2 | 3 | /** 4 | * Per Web IDL:. 5 | * 6 | * ``` 7 | * typedef (ArrayBufferView or ArrayBuffer) BufferSource; 8 | * typedef (Int8Array or Int16Array or Int32Array or 9 | * Uint8Array or Uint16Array or Uint32Array or Uint8ClampedArray or 10 | * Float32Array or Float64Array or DataView) ArrayBufferView; 11 | * ``` 12 | * 13 | * [kotlin.ByteArray] are mapped to JavaScript `Int8Array`; therefore we can use [ByteArray] where external Javascript 14 | * expects a [BufferSource]. 15 | * 16 | * - [BufferSource](https://heycam.github.io/webidl/#BufferSource) 17 | * - [ArrayBufferView](https://heycam.github.io/webidl/#ArrayBufferView) 18 | * - [Representing Kotlin types in JavaScript](https://kotlinlang.org/docs/reference/js-to-kotlin-interop.html#representing-kotlin-types-in-javascript) 19 | */ 20 | internal typealias BufferSource = ByteArray 21 | -------------------------------------------------------------------------------- /kable-core/src/jsMain/kotlin/external/JsIterator.kt: -------------------------------------------------------------------------------- 1 | package com.juul.kable.external 2 | 3 | internal external interface JsIterator { 4 | fun next(): JsIteratorResult 5 | } 6 | 7 | internal external interface JsIteratorResult { 8 | val done: Boolean 9 | val value: T? 10 | } 11 | 12 | internal fun JsIterator.iterable(): Iterable { 13 | return object : Iterable { 14 | override fun iterator(): Iterator = 15 | object : Iterator { 16 | private var nextElement = this@iterable.next() 17 | override fun hasNext() = !nextElement.done 18 | override fun next(): T { 19 | val value = nextElement.value ?: error("No more values") 20 | nextElement = this@iterable.next() 21 | return value 22 | } 23 | } 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /kable-core/src/jsMain/kotlin/external/Navigator.kt: -------------------------------------------------------------------------------- 1 | package com.juul.kable.external 2 | 3 | import org.w3c.dom.Navigator 4 | 5 | /** Reference to [Bluetooth] instance or [undefined] if bluetooth is unavailable. */ 6 | internal val Navigator.bluetooth: Bluetooth 7 | get() = asDynamic().bluetooth.unsafeCast() 8 | -------------------------------------------------------------------------------- /kable-core/src/jsMain/kotlin/external/RequestDeviceOptions.kt: -------------------------------------------------------------------------------- 1 | package com.juul.kable.external 2 | 3 | /** 4 | * ``` 5 | * dictionary RequestDeviceOptions { 6 | * sequence filters; 7 | * sequence optionalServices = []; 8 | * sequence optionalManufacturerData = []; 9 | * boolean acceptAllDevices = false; 10 | * }; 11 | * ``` 12 | * 13 | * https://webbluetoothcg.github.io/web-bluetooth/#device-discovery 14 | */ 15 | internal external interface RequestDeviceOptions { 16 | var filters: Array? 17 | var optionalServices: Array? 18 | var optionalManufacturerData: ByteArray? 19 | var acceptAllDevices: Boolean? 20 | } 21 | -------------------------------------------------------------------------------- /kable-core/src/jsMain/kotlin/logs/LogMessage.kt: -------------------------------------------------------------------------------- 1 | package com.juul.kable.logs 2 | 3 | import com.juul.kable.external.BluetoothRemoteGATTCharacteristic 4 | import com.juul.kable.logs.Logging.DataProcessor.Operation 5 | import com.juul.kable.toByteArray 6 | import org.khronos.webgl.DataView 7 | 8 | internal actual val LOG_INDENT: String? = " " 9 | 10 | internal fun LogMessage.detail(data: DataView?, operation: Operation) { 11 | detail(data?.buffer?.toByteArray(), operation) 12 | } 13 | 14 | internal fun LogMessage.detail(characteristic: BluetoothRemoteGATTCharacteristic) { 15 | detail("service", characteristic.service.uuid) 16 | detail("characteristic", characteristic.uuid) 17 | } 18 | -------------------------------------------------------------------------------- /kable-core/src/jsMain/kotlin/logs/SystemLogEngine.kt: -------------------------------------------------------------------------------- 1 | package com.juul.kable.logs 2 | 3 | public actual object SystemLogEngine : LogEngine { 4 | 5 | actual override fun verbose(throwable: Throwable?, tag: String, message: String) { 6 | debug(throwable, tag, message) 7 | } 8 | 9 | actual override fun debug(throwable: Throwable?, tag: String, message: String) { 10 | if (throwable == null) { 11 | console.asDynamic().debug("[%s] %s", tag, message) 12 | } else { 13 | console.asDynamic().debug("[%s] %s\n%o", tag, message, throwable) 14 | } 15 | } 16 | 17 | actual override fun info(throwable: Throwable?, tag: String, message: String) { 18 | if (throwable == null) { 19 | console.info("[%s] %s", tag, message) 20 | } else { 21 | console.info("[%s] %s\n%o", tag, message, throwable) 22 | } 23 | } 24 | 25 | actual override fun warn(throwable: Throwable?, tag: String, message: String) { 26 | if (throwable == null) { 27 | console.warn("[%s] %s", tag, message) 28 | } else { 29 | console.warn("[%s] %s\n%o", tag, message, throwable) 30 | } 31 | } 32 | 33 | actual override fun error(throwable: Throwable?, tag: String, message: String) { 34 | if (throwable == null) { 35 | console.error("[%s] %s", tag, message) 36 | } else { 37 | console.error("[%s] %s\n%o", tag, message, throwable) 38 | } 39 | } 40 | 41 | actual override fun assert(throwable: Throwable?, tag: String, message: String) { 42 | if (throwable == null) { 43 | console.asDynamic().assert(false, "[%s] %s", tag, message) 44 | } else { 45 | console.asDynamic().assert(false, "[%s] %s\n%o", tag, message, throwable) 46 | } 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /kable-core/src/jsTest/kotlin/BluetoothJsTests.kt: -------------------------------------------------------------------------------- 1 | package com.juul.kable 2 | 3 | import com.juul.kable.external.Bluetooth 4 | import kotlinx.coroutines.test.runTest 5 | import kotlin.test.Test 6 | import kotlin.test.assertFailsWith 7 | import kotlin.test.assertIs 8 | 9 | class BluetoothJsTests { 10 | 11 | @Test 12 | fun bluetoothOrThrow_browserUnitTest_returnsBluetooth() = runTest { 13 | if (isBrowser) { 14 | assertIs(bluetoothOrThrow()) 15 | } 16 | } 17 | 18 | // In Node.js unit tests, bluetooth is unavailable. 19 | @Test 20 | fun bluetoothOrThrow_nodeJsUnitTest_throwsIllegalStateException() = runTest { 21 | if (isNode) { 22 | assertFailsWith { 23 | bluetoothOrThrow() 24 | } 25 | } 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /kable-core/src/jsTest/kotlin/Environment.kt: -------------------------------------------------------------------------------- 1 | package com.juul.kable 2 | 3 | val isBrowser: Boolean 4 | get() = js("typeof window !== 'undefined'") 5 | .unsafeCast() 6 | 7 | val isNode: Boolean 8 | get() = js("typeof process !== 'undefined' && process.versions && process.versions.node") 9 | .unsafeCast() 10 | -------------------------------------------------------------------------------- /kable-core/src/jsTest/kotlin/RequestPeripheralTests.kt: -------------------------------------------------------------------------------- 1 | package com.juul.kable 2 | 3 | import kotlinx.coroutines.test.runTest 4 | import kotlin.test.Test 5 | import kotlin.test.assertFailsWith 6 | 7 | class RequestPeripheralTests { 8 | 9 | @Test 10 | fun requestPeripheral_unitTest_throwsIllegalStateException() = runTest { 11 | // In browser unit tests, bluetooth is not allowed per security restrictions. 12 | // In Node.js unit tests, bluetooth is unavailable. 13 | assertFailsWith { 14 | requestPeripheral(Options {}) 15 | } 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /kable-core/src/jvmMain/kotlin/com/juul/kable/Bluetooth.kt: -------------------------------------------------------------------------------- 1 | package com.juul.kable 2 | 3 | import kotlinx.coroutines.flow.Flow 4 | 5 | public actual enum class Reason { 6 | // Not implemented. 7 | } 8 | 9 | internal actual val bluetoothAvailability: Flow = jvmNotImplementedException() 10 | -------------------------------------------------------------------------------- /kable-core/src/jvmMain/kotlin/com/juul/kable/Exceptions.kt: -------------------------------------------------------------------------------- 1 | package com.juul.kable 2 | 3 | internal fun jvmNotImplementedException(): Nothing = throw NotImplementedError( 4 | "JVM target not yet implemented. See https://github.com/JuulLabs/kable/issues/380 for details.", 5 | ) 6 | -------------------------------------------------------------------------------- /kable-core/src/jvmMain/kotlin/com/juul/kable/Identifier.kt: -------------------------------------------------------------------------------- 1 | package com.juul.kable 2 | 3 | public actual typealias Identifier = String 4 | 5 | public actual fun String.toIdentifier(): Identifier = this 6 | -------------------------------------------------------------------------------- /kable-core/src/jvmMain/kotlin/com/juul/kable/Observations.kt: -------------------------------------------------------------------------------- 1 | package com.juul.kable 2 | 3 | internal actual fun Peripheral.observationHandler(): Observation.Handler = object : Observation.Handler { 4 | override suspend fun startObservation(characteristic: Characteristic) { 5 | jvmNotImplementedException() 6 | } 7 | 8 | override suspend fun stopObservation(characteristic: Characteristic) { 9 | jvmNotImplementedException() 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /kable-core/src/jvmMain/kotlin/com/juul/kable/Peripheral.deprecated.kt: -------------------------------------------------------------------------------- 1 | package com.juul.kable 2 | 3 | import kotlinx.coroutines.CoroutineScope 4 | 5 | @Deprecated( 6 | message = "Replaced with `Peripheral` builder function (not a CoroutineScope extension function).", 7 | replaceWith = ReplaceWith("Peripheral(advertisement, builderAction)"), 8 | level = DeprecationLevel.ERROR, 9 | ) 10 | public actual fun CoroutineScope.peripheral( 11 | advertisement: Advertisement, 12 | builderAction: PeripheralBuilderAction, 13 | ): Peripheral = Peripheral(advertisement, builderAction) 14 | -------------------------------------------------------------------------------- /kable-core/src/jvmMain/kotlin/com/juul/kable/Peripheral.kt: -------------------------------------------------------------------------------- 1 | package com.juul.kable 2 | 3 | public actual fun Peripheral( 4 | advertisement: Advertisement, 5 | builderAction: PeripheralBuilderAction, 6 | ): Peripheral { 7 | jvmNotImplementedException() 8 | } 9 | -------------------------------------------------------------------------------- /kable-core/src/jvmMain/kotlin/com/juul/kable/PeripheralBuilder.kt: -------------------------------------------------------------------------------- 1 | package com.juul.kable 2 | 3 | import com.juul.kable.logs.LoggingBuilder 4 | import kotlin.time.Duration 5 | 6 | public actual class ServicesDiscoveredPeripheral internal constructor() { 7 | 8 | public actual suspend fun read( 9 | characteristic: Characteristic, 10 | ): ByteArray = jvmNotImplementedException() 11 | 12 | public actual suspend fun read( 13 | descriptor: Descriptor, 14 | ): ByteArray = jvmNotImplementedException() 15 | 16 | public actual suspend fun write( 17 | characteristic: Characteristic, 18 | data: ByteArray, 19 | writeType: WriteType, 20 | ) { 21 | jvmNotImplementedException() 22 | } 23 | 24 | public actual suspend fun write( 25 | descriptor: Descriptor, 26 | data: ByteArray, 27 | ) { 28 | jvmNotImplementedException() 29 | } 30 | } 31 | 32 | public actual class PeripheralBuilder internal actual constructor() { 33 | 34 | public actual fun logging(init: LoggingBuilder) { 35 | jvmNotImplementedException() 36 | } 37 | 38 | public actual fun onServicesDiscovered(action: ServicesDiscoveredAction) { 39 | jvmNotImplementedException() 40 | } 41 | 42 | public actual fun observationExceptionHandler(handler: ObservationExceptionHandler) { 43 | jvmNotImplementedException() 44 | } 45 | 46 | public actual var disconnectTimeout: Duration 47 | get() = jvmNotImplementedException() 48 | set(value) { 49 | jvmNotImplementedException() 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /kable-core/src/jvmMain/kotlin/com/juul/kable/PlatformAdvertisement.kt: -------------------------------------------------------------------------------- 1 | package com.juul.kable 2 | 3 | public actual interface PlatformAdvertisement : Advertisement 4 | -------------------------------------------------------------------------------- /kable-core/src/jvmMain/kotlin/com/juul/kable/Profile.kt: -------------------------------------------------------------------------------- 1 | package com.juul.kable 2 | 3 | import kotlin.uuid.Uuid 4 | 5 | internal actual class PlatformService 6 | internal actual class PlatformCharacteristic 7 | internal actual class PlatformDescriptor 8 | 9 | internal actual class PlatformDiscoveredService : DiscoveredService { 10 | actual val service: PlatformService 11 | get() = jvmNotImplementedException() 12 | actual override val characteristics: List 13 | get() = jvmNotImplementedException() 14 | actual override val serviceUuid: Uuid 15 | get() = jvmNotImplementedException() 16 | } 17 | 18 | internal actual class PlatformDiscoveredCharacteristic : DiscoveredCharacteristic { 19 | actual val characteristic: PlatformCharacteristic 20 | get() = jvmNotImplementedException() 21 | actual override val descriptors: List 22 | get() = jvmNotImplementedException() 23 | actual override val properties: Characteristic.Properties 24 | get() = jvmNotImplementedException() 25 | actual override val serviceUuid: Uuid 26 | get() = jvmNotImplementedException() 27 | actual override val characteristicUuid: Uuid 28 | get() = jvmNotImplementedException() 29 | } 30 | 31 | internal actual class PlatformDiscoveredDescriptor : DiscoveredDescriptor { 32 | actual val descriptor: PlatformDescriptor 33 | get() = jvmNotImplementedException() 34 | actual override val serviceUuid: Uuid 35 | get() = jvmNotImplementedException() 36 | actual override val characteristicUuid: Uuid 37 | get() = jvmNotImplementedException() 38 | actual override val descriptorUuid: Uuid 39 | get() = jvmNotImplementedException() 40 | } 41 | -------------------------------------------------------------------------------- /kable-core/src/jvmMain/kotlin/com/juul/kable/ScannerBuilder.kt: -------------------------------------------------------------------------------- 1 | package com.juul.kable 2 | 3 | import com.juul.kable.logs.LoggingBuilder 4 | 5 | public actual class ScannerBuilder { 6 | 7 | @Deprecated( 8 | message = "Use filters(FiltersBuilder.() -> Unit)", 9 | replaceWith = ReplaceWith("filters { }"), 10 | level = DeprecationLevel.HIDDEN, 11 | ) 12 | public actual var filters: List? = null 13 | 14 | public actual fun filters(builderAction: FiltersBuilder.() -> Unit) { 15 | jvmNotImplementedException() 16 | } 17 | 18 | public actual fun logging(init: LoggingBuilder) { 19 | jvmNotImplementedException() 20 | } 21 | 22 | internal actual fun build(): PlatformScanner = jvmNotImplementedException() 23 | } 24 | -------------------------------------------------------------------------------- /kable-core/src/jvmMain/kotlin/com/juul/kable/bluetooth/IsSupported.kt: -------------------------------------------------------------------------------- 1 | package com.juul.kable.bluetooth 2 | 3 | import com.juul.kable.jvmNotImplementedException 4 | 5 | internal actual suspend fun isSupported(): Boolean = jvmNotImplementedException() 6 | -------------------------------------------------------------------------------- /kable-core/src/jvmMain/kotlin/com/juul/kable/logs/Log.kt: -------------------------------------------------------------------------------- 1 | package com.juul.kable.logs 2 | 3 | internal actual val LOG_INDENT: String? = " " 4 | -------------------------------------------------------------------------------- /kable-core/src/jvmMain/kotlin/com/juul/kable/logs/SystemLogEngine.kt: -------------------------------------------------------------------------------- 1 | package com.juul.kable.logs 2 | 3 | import com.juul.kable.jvmNotImplementedException 4 | 5 | public actual object SystemLogEngine : LogEngine { 6 | 7 | actual override fun verbose(throwable: Throwable?, tag: String, message: String) { 8 | jvmNotImplementedException() 9 | } 10 | 11 | actual override fun debug(throwable: Throwable?, tag: String, message: String) { 12 | jvmNotImplementedException() 13 | } 14 | 15 | actual override fun info(throwable: Throwable?, tag: String, message: String) { 16 | jvmNotImplementedException() 17 | } 18 | 19 | actual override fun warn(throwable: Throwable?, tag: String, message: String) { 20 | jvmNotImplementedException() 21 | } 22 | 23 | actual override fun error(throwable: Throwable?, tag: String, message: String) { 24 | jvmNotImplementedException() 25 | } 26 | 27 | actual override fun assert(throwable: Throwable?, tag: String, message: String) { 28 | jvmNotImplementedException() 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /kable-default-permissions/build.gradle.kts: -------------------------------------------------------------------------------- 1 | plugins { 2 | id("com.android.library") 3 | kotlin("android") 4 | id("com.vanniktech.maven.publish") 5 | } 6 | 7 | kotlin { 8 | jvmToolchain(libs.versions.jvm.toolchain.get().toInt()) 9 | } 10 | 11 | android { 12 | compileSdk = libs.versions.android.compile.get().toInt() 13 | defaultConfig.minSdk = libs.versions.android.min.get().toInt() 14 | namespace = "com.juul.kable" 15 | } 16 | -------------------------------------------------------------------------------- /kable-default-permissions/src/main/AndroidManifest.xml: -------------------------------------------------------------------------------- 1 | 2 | 6 | 7 | 9 | 13 | 17 | 18 | 19 | 23 | 27 | 28 | 33 | 34 | 35 | 36 | 37 | 43 | 44 | 45 | -------------------------------------------------------------------------------- /kable-log-engine-khronicle/api/kable-log-engine-khronicle.api: -------------------------------------------------------------------------------- 1 | public final class com/juul/kable/logs/khronicle/Kable : com/juul/khronicle/Key { 2 | public static final field INSTANCE Lcom/juul/kable/logs/khronicle/Kable; 3 | } 4 | 5 | public final class com/juul/kable/logs/khronicle/KhronicleLogEngine : com/juul/kable/logs/LogEngine, com/juul/khronicle/HideFromStackTraceTag { 6 | public static final field INSTANCE Lcom/juul/kable/logs/khronicle/KhronicleLogEngine; 7 | public fun assert (Ljava/lang/Throwable;Ljava/lang/String;Ljava/lang/String;)V 8 | public fun debug (Ljava/lang/Throwable;Ljava/lang/String;Ljava/lang/String;)V 9 | public fun error (Ljava/lang/Throwable;Ljava/lang/String;Ljava/lang/String;)V 10 | public fun info (Ljava/lang/Throwable;Ljava/lang/String;Ljava/lang/String;)V 11 | public fun verbose (Ljava/lang/Throwable;Ljava/lang/String;Ljava/lang/String;)V 12 | public fun warn (Ljava/lang/Throwable;Ljava/lang/String;Ljava/lang/String;)V 13 | } 14 | 15 | -------------------------------------------------------------------------------- /kable-log-engine-khronicle/build.gradle.kts: -------------------------------------------------------------------------------- 1 | plugins { 2 | kotlin("multiplatform") 3 | id("org.jmailen.kotlinter") 4 | id("org.jetbrains.dokka") 5 | id("com.vanniktech.maven.publish") 6 | } 7 | 8 | kotlin { 9 | explicitApi() 10 | jvmToolchain(libs.versions.jvm.toolchain.get().toInt()) 11 | 12 | iosArm64() 13 | iosX64() 14 | js().browser() 15 | macosArm64() 16 | macosX64() 17 | jvm() 18 | 19 | sourceSets { 20 | commonMain.dependencies { 21 | api(project(":kable-core")) 22 | api(libs.khronicle) 23 | } 24 | } 25 | } 26 | 27 | dokka { 28 | pluginsConfiguration.html { 29 | footerMessage.set("(c) JUUL Labs, Inc.") 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /kable-log-engine-khronicle/src/commonMain/kotlin/logs/KhronicleLogEngine.kt: -------------------------------------------------------------------------------- 1 | package com.juul.kable.logs.khronicle 2 | 3 | import com.juul.kable.logs.LogEngine 4 | import com.juul.khronicle.HideFromStackTraceTag 5 | import com.juul.khronicle.Key 6 | import com.juul.khronicle.Log 7 | 8 | public object Kable : Key 9 | 10 | public object KhronicleLogEngine : LogEngine, HideFromStackTraceTag { 11 | override fun verbose(throwable: Throwable?, tag: String, message: String) { 12 | Log.verbose(throwable, tag) { metadata -> 13 | metadata[Kable] = true 14 | message 15 | } 16 | } 17 | 18 | override fun debug(throwable: Throwable?, tag: String, message: String) { 19 | Log.debug(throwable, tag) { metadata -> 20 | metadata[Kable] = true 21 | message 22 | } 23 | } 24 | 25 | override fun info(throwable: Throwable?, tag: String, message: String) { 26 | Log.info(throwable, tag) { metadata -> 27 | metadata[Kable] = true 28 | message 29 | } 30 | } 31 | 32 | override fun warn(throwable: Throwable?, tag: String, message: String) { 33 | Log.warn(throwable, tag) { metadata -> 34 | metadata[Kable] = true 35 | message 36 | } 37 | } 38 | 39 | override fun error(throwable: Throwable?, tag: String, message: String) { 40 | Log.error(throwable, tag) { metadata -> 41 | metadata[Kable] = true 42 | message 43 | } 44 | } 45 | 46 | override fun assert(throwable: Throwable?, tag: String, message: String) { 47 | Log.assert(throwable, tag) { metadata -> 48 | metadata[Kable] = true 49 | message 50 | } 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /settings.gradle.kts: -------------------------------------------------------------------------------- 1 | rootProject.name = "kable" 2 | 3 | pluginManagement { 4 | repositories { 5 | google() 6 | gradlePluginPortal() 7 | mavenCentral() 8 | } 9 | } 10 | 11 | include( 12 | "kable-core", 13 | "kable-default-permissions", 14 | "kable-log-engine-khronicle", 15 | ) 16 | --------------------------------------------------------------------------------