Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add multiplatform BLE stack for ISO/IEC 18013-5:2021 proximity. #814

Merged
merged 1 commit into from
Dec 16, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion gradle/libs.versions.toml
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,8 @@ navigation-plugin = "2.7.7"
androidx-preference = "1.2.1"
exif = "1.3.7"
ausweis-sdk = "2.1.1"
jetbrains-navigation = "2.7.0-alpha07"
jetbrains-navigation = "2.8.0-alpha10"
jetbrains-lifecycle = "2.8.2"
cameraLifecycle = "1.3.4"
buildconfig = "5.3.5"
qrose = "1.0.1"
Expand Down Expand Up @@ -99,12 +100,14 @@ androidx-navigation-runtime = { group = "androidx.navigation", name = "navigatio
androidx-navigation-compose = { group = "androidx.navigation", name = "navigation-compose", version.ref = "androidx-navigation" }
code-scanner = { module = "com.github.yuriy-budiyev:code-scanner", version.ref = "code-scanner" }
ktor-client-android = { module = "io.ktor:ktor-client-android", version.ref = "ktor" }
ktor-client-darwin = { module = "io.ktor:ktor-client-darwin", version.ref = "ktor" }
ktor-client-core = { module = "io.ktor:ktor-client-core", version.ref = "ktor" }
ktor-client-java = { module = "io.ktor:ktor-client-java", version.ref = "ktor" }
ktor-client-content-negotiation = { module = "io.ktor:ktor-client-content-negotiation", version.ref = "ktor" }
ktor-serialization-kotlinx-json = { module = "io.ktor:ktor-serialization-kotlinx-json", version.ref = "ktor" }
ktor-client-mock = { module = "io.ktor:ktor-client-mock", version.ref = "ktor" }
javax-servlet-api = { module = "javax.servlet:javax.servlet-api", version.ref = "javax-servlet-api" }
ktor-network = { module = "io.ktor:ktor-network", version.ref = "ktor" }
androidx-lifecycle-extensions = { module = "androidx.lifecycle:lifecycle-extensions", version.ref = "androidx-lifecycle" }
androidx-lifecycle-livedata = { module = "androidx.lifecycle:lifecycle-livedata-ktx", version.ref = "androidx-lifecycle-ktx" }
androidx-lifecycle-viewmodel = { module = "androidx.lifecycle:lifecycle-viewmodel-ktx", version.ref = "androidx-lifecycle-ktx" }
Expand All @@ -127,6 +130,7 @@ exifinterface = { module = "androidx.exifinterface:exifinterface", version.ref =
ausweis-sdk = { module = "com.governikus:ausweisapp", version.ref = "ausweis-sdk"}
jetbrains-navigation-compose = { module = "org.jetbrains.androidx.navigation:navigation-compose", version.ref="jetbrains-navigation" }
jetbrains-navigation-runtime = { module = "org.jetbrains.androidx.navigation:navigation-runtime", version.ref="jetbrains-navigation" }
jetbrains-lifecycle-viewmodel-compose = { module = "org.jetbrains.androidx.lifecycle:lifecycle-viewmodel-compose", version.ref="jetbrains-lifecycle" }
camera-lifecycle = { group = "androidx.camera", name = "camera-lifecycle", version.ref = "cameraLifecycle" }
qrose = { group = "io.github.alexzhirkevich", name = "qrose", version.ref="qrose"}
easyqrscan = { module = "io.github.kalinjul.easyqrscan:scanner", version.ref = "easyqrscan" }
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,10 +21,10 @@ import android.bluetooth.BluetoothSocket
import android.content.Context
import android.os.Build
import androidx.annotation.RequiresApi
import com.android.identity.cbor.Cbor
import com.android.identity.util.Logger
import java.io.ByteArrayOutputStream
import kotlinx.io.bytestring.ByteStringBuilder
import java.io.IOException
import java.io.InputStream
import java.util.concurrent.BlockingQueue
import java.util.concurrent.LinkedTransferQueue
import java.util.concurrent.TimeUnit
Expand Down Expand Up @@ -121,7 +121,6 @@ internal class L2CAPClient(private val context: Context, val listener: Listener)

private fun readFromSocket() {
Logger.d(TAG, "Start reading socket input")
val pendingDataBaos = ByteArrayOutputStream()

// Keep listening to the InputStream until an exception occurs.
val inputStream = try {
Expand All @@ -131,24 +130,19 @@ internal class L2CAPClient(private val context: Context, val listener: Listener)
return
}
while (true) {
val buf = ByteArray(DataTransportBle.L2CAP_BUF_SIZE)
try {
val numBytesRead = inputStream.read(buf)
if (numBytesRead == -1) {
val length = try {
val encodedLength = inputStream.readNOctets(4U)
(encodedLength[0].toUInt().and(0xffU) shl 24) +
(encodedLength[1].toUInt().and(0xffU) shl 16) +
(encodedLength[2].toUInt().and(0xffU) shl 8) +
(encodedLength[3].toUInt().and(0xffU) shl 0)
} catch (e: Throwable) {
reportPeerDisconnected()
break
}
pendingDataBaos.write(buf, 0, numBytesRead)
try {
val pendingData = pendingDataBaos.toByteArray()
val (endOffset, _) = Cbor.decode(pendingData, 0)
val dataItemBytes = pendingData.sliceArray(IntRange(0, endOffset - 1))
pendingDataBaos.reset()
pendingDataBaos.write(pendingData, endOffset, pendingData.size - endOffset)
reportMessageReceived(dataItemBytes)
} catch (e: Exception) {
/* not enough data to decode item, do nothing */
}
val message = inputStream.readNOctets(length)
reportMessageReceived(message)
} catch (e: IOException) {
reportError(Error("Error on listening input stream from socket L2CAP", e))
break
Expand All @@ -157,7 +151,16 @@ internal class L2CAPClient(private val context: Context, val listener: Listener)
}

fun sendMessage(data: ByteArray) {
writerQueue.add(data)
val bsb = ByteStringBuilder()
val length = data.size.toUInt()
bsb.apply {
append((length shr 24).and(0xffU).toByte())
append((length shr 16).and(0xffU).toByte())
append((length shr 8).and(0xffU).toByte())
append((length shr 0).and(0xffU).toByte())
}
bsb.append(data)
writerQueue.add(bsb.toByteString().toByteArray())
}

fun reportPeerConnected() {
Expand Down Expand Up @@ -194,4 +197,22 @@ internal class L2CAPClient(private val context: Context, val listener: Listener)
companion object {
private const val TAG = "L2CAPClient"
}
}
}

// Cannot call it readNBytes() b/c that's taken on API >= 33
//
internal fun InputStream.readNOctets(len: UInt): ByteArray {
val bsb = ByteStringBuilder()
var remaining = len
while (remaining > 0U) {
val buf = ByteArray(remaining.toInt())
val numBytesRead = this.read(buf, 0, remaining.toInt())
if (numBytesRead == -1) {
throw IllegalStateException("Failed reading from input stream")
}
bsb.append(buf)
remaining -= numBytesRead.toUInt()
}
return bsb.toByteString().toByteArray()
}

Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,8 @@ import android.os.Build
import androidx.annotation.RequiresApi
import com.android.identity.cbor.Cbor
import com.android.identity.util.Logger
import kotlinx.io.bytestring.ByteString
import kotlinx.io.bytestring.ByteStringBuilder
import java.io.ByteArrayOutputStream
import java.io.IOException
import java.util.concurrent.BlockingQueue
Expand Down Expand Up @@ -130,7 +132,6 @@ internal class L2CAPServer(val listener: Listener) {

private fun readFromSocket() {
Logger.d(TAG, "Start reading socket input")
val pendingDataBaos = ByteArrayOutputStream()

// Keep listening to the InputStream until an exception occurs.
val inputStream = try {
Expand All @@ -140,25 +141,19 @@ internal class L2CAPServer(val listener: Listener) {
return
}
while (true) {
val buf = ByteArray(DataTransportBle.L2CAP_BUF_SIZE)
try {
val numBytesRead = inputStream.read(buf)
if (numBytesRead == -1) {
Logger.d(TAG, "End of stream reading from socket")
val length = try {
val encodedLength = inputStream.readNOctets(4U)
(encodedLength[0].toUInt().and(0xffU) shl 24) +
(encodedLength[1].toUInt().and(0xffU) shl 16) +
(encodedLength[2].toUInt().and(0xffU) shl 8) +
(encodedLength[3].toUInt().and(0xffU) shl 0)
} catch (e: Throwable) {
reportPeerDisconnected()
break
}
pendingDataBaos.write(buf, 0, numBytesRead)
try {
val pendingData = pendingDataBaos.toByteArray()
val (endOffset, _) = Cbor.decode(pendingData, 0)
val dataItemBytes = pendingData.sliceArray(IntRange(0, endOffset - 1))
pendingDataBaos.reset()
pendingDataBaos.write(pendingData, endOffset, pendingData.size - endOffset)
reportMessageReceived(dataItemBytes)
} catch (e: Exception) {
/* not enough data to decode item, do nothing */
}
val message = inputStream.readNOctets(length)
reportMessageReceived(message)
} catch (e: IOException) {
reportError(Error("Error on listening input stream from socket L2CAP", e))
break
Expand All @@ -167,7 +162,16 @@ internal class L2CAPServer(val listener: Listener) {
}

fun sendMessage(data: ByteArray) {
writerQueue.add(data)
val bsb = ByteStringBuilder()
val length = data.size.toUInt()
bsb.apply {
davidz25 marked this conversation as resolved.
Show resolved Hide resolved
append((length shr 24).and(0xffU).toByte())
append((length shr 16).and(0xffU).toByte())
append((length shr 8).and(0xffU).toByte())
append((length shr 0).and(0xffU).toByte())
}
bsb.append(data)
writerQueue.add(bsb.toByteString().toByteArray())
}

fun reportPeerConnected() {
Expand Down
12 changes: 2 additions & 10 deletions identity-appsupport/build.gradle.kts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import org.gradle.kotlin.dsl.implementation
import org.jetbrains.kotlin.gradle.ExperimentalKotlinGradlePluginApi
import org.jetbrains.kotlin.gradle.dsl.JvmTarget

Expand All @@ -11,8 +12,6 @@ plugins {
kotlin {
jvmToolchain(17)

jvm()

androidTarget {
@OptIn(ExperimentalKotlinGradlePluginApi::class)
compilerOptions {
Expand Down Expand Up @@ -60,19 +59,12 @@ kotlin {
}
}

val jvmMain by getting {
dependencies {
implementation(libs.bouncy.castle.bcprov)
implementation(libs.bouncy.castle.bcpkix)
implementation(libs.tink)
}
}

val androidMain by getting {
dependencies {
implementation(libs.bouncy.castle.bcprov)
implementation(libs.bouncy.castle.bcpkix)
implementation(libs.tink)
implementation(libs.accompanist.permissions)
}
}
}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
package com.android.identity.appsupport.ui

import android.graphics.BitmapFactory
import android.os.Build
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.material3.MaterialTheme
Expand All @@ -8,6 +9,8 @@ import androidx.compose.material3.dynamicDarkColorScheme
import androidx.compose.material3.dynamicLightColorScheme
import androidx.compose.material3.lightColorScheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.graphics.ImageBitmap
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.platform.LocalContext

@Composable
Expand All @@ -31,4 +34,8 @@ actual fun AppTheme(content: @Composable () -> Unit) {
colorScheme = colorScheme,
content = content
)
}
}

actual fun decodeImage(encodedData: ByteArray): ImageBitmap {
return BitmapFactory.decodeByteArray(encodedData, 0, encodedData.size).asImageBitmap()
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
package com.android.identity.appsupport.ui.permissions

import android.Manifest
import androidx.compose.runtime.Composable
import com.google.accompanist.permissions.ExperimentalPermissionsApi
import com.google.accompanist.permissions.MultiplePermissionsState
import com.google.accompanist.permissions.rememberMultiplePermissionsState

@OptIn(ExperimentalPermissionsApi::class)
private class AccompanistBluetoothPermissionState(
val multiplePermissionsState: MultiplePermissionsState
) : BluetoothPermissionState {

override val isGranted: Boolean
get() = multiplePermissionsState.allPermissionsGranted

override fun launchPermissionRequest() {
multiplePermissionsState.launchMultiplePermissionRequest()
}
}

@OptIn(ExperimentalPermissionsApi::class)
@Composable
actual fun rememberBluetoothPermissionState(): BluetoothPermissionState {
return AccompanistBluetoothPermissionState(
rememberMultiplePermissionsState(
listOf(
Manifest.permission.BLUETOOTH_SCAN,
Manifest.permission.BLUETOOTH_CONNECT,
Manifest.permission.BLUETOOTH_ADVERTISE,
)
)
)
}
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.darkColorScheme
import androidx.compose.material3.lightColorScheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.graphics.ImageBitmap

@Composable
expect fun AppTheme(content: @Composable () -> Unit)
Expand All @@ -16,3 +17,5 @@ fun AppThemeDefault(content: @Composable () -> Unit) {
content = content
)
}

expect fun decodeImage(encodedData: ByteArray): ImageBitmap
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
package com.android.identity.appsupport.ui.permissions

import androidx.compose.runtime.Composable

interface BluetoothPermissionState {

val isGranted: Boolean

fun launchPermissionRequest()
}

@Composable
expect fun rememberBluetoothPermissionState(): BluetoothPermissionState
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,8 @@ import org.publicvalue.multiplatform.qrcode.ScannerWithPermissions
* If the application doesn't have the necessary permission, the user is prompted to grant it.
*
* @param title The title of the dialog.
* @param description The description text to include in the dialog.
* @param text The description to include in the dialog, displayed above the QR scanner window.
* @param additionalContent Content which is displayed below the QR scanner window.
* @param dismissButton The text for the dismiss button.
* @param onCodeScanned called when a QR code is scanned, the parameter is the parsed data. Should
* return `true` to stop scanning, `false` to continue scanning.
Expand All @@ -27,21 +28,22 @@ import org.publicvalue.multiplatform.qrcode.ScannerWithPermissions
*/
@Composable
fun ScanQrCodeDialog(
title: String,
description: String,
title: (@Composable () -> Unit)? = null,
text: (@Composable () -> Unit)? = null,
additionalContent: (@Composable () -> Unit)? = null,
dismissButton: String,
onCodeScanned: (data: String) -> Boolean,
onDismiss: () -> Unit,
modifier: Modifier? = null
) {
AlertDialog(
modifier = modifier ?: Modifier,
title = { Text(text = title) },
title = title,
text = {
Column(
verticalArrangement = Arrangement.spacedBy(16.dp),
) {
Text(text = description)
text?.invoke()

ScannerWithPermissions(
modifier = Modifier.height(300.dp),
Expand All @@ -50,6 +52,8 @@ fun ScanQrCodeDialog(
},
types = listOf(CodeType.QR)
)

additionalContent?.invoke()
}
},
onDismissRequest = onDismiss,
Expand Down
Loading
Loading