From 0c5aa3ef61c7fc228f70cc59f6cc74db5692a91b Mon Sep 17 00:00:00 2001 From: David Zeuthen Date: Tue, 5 Nov 2024 16:31:58 -0500 Subject: [PATCH] Add multiplatform BLE stack for ISO/IEC 18013-5:2021 proximity. This adds new multiplatform API to the identity-mdoc library for ISO/IEC 18013-5:2021 proximity presentations. It works on both Android and iOS and both operating systems share a lot of common code. Also add support two new pages - "ISO mdoc Proximity Sharing" and "ISO mdoc Proximity Reading" - to the multi-platform test app. This only includes QR engagement, NFC engagement will be added in a future change. These two pages represent feature complete mDL and mDL reader functionality and are designed to work with other implementations as well as with itself. Also introduce `rememberBluetoothPermissionState` composable function and `BluetoothPermissionState` object which can be used to obtain the required permissions on BLE on both Android and iOS. Also devise a new multi-testing framework for automatically testing a number of mDL presentations between two devices. This is available in a new "ISO mdc Multi-Device Testing" page in the test app and the way it works is that the 1st device offers a QR code that the 2nd device scans. This sets up a TCP/IP control plane used for initiating tests, including sharing `DeviceEngagement`. This helps ensure the code is robust and well-tested both for happy paths and with all the various termination options available in the standard. Also modify existing L2CAP support so it follows the latest draft of the upcoming second edition of ISO 18013-5:2021. In particular, this means framing the `SessionData` / `SessionEstablishment` packets sent over the L2CAP socket. Modify `ScanQrCodeDialog` and `ShowQrCodeDialog` so they are easy to extend. Include portrait ans signature images in sample data in identity-doctypes. Test: Manually tested against wallet app and well-known readers. Signed-off-by: David Zeuthen --- gradle/libs.versions.toml | 6 +- .../android/mdoc/transport/L2CAPClient.kt | 59 +- .../android/mdoc/transport/L2CAPServer.kt | 38 +- identity-appsupport/build.gradle.kts | 12 +- .../identity/appsupport/ui/Util.android.kt | 9 +- .../BluetoothPermissionState.android.kt | 34 + .../android/identity/appsupport/ui/Util.kt | 3 + .../permissions/BluetoothPermissionState.kt | 13 + .../appsupport/ui/qrcode/ScanQrCodeDialog.kt | 14 +- .../appsupport/ui/qrcode/ShowQrCodeDialog.kt | 16 +- .../identity/appsupport/ui/Util.ios.kt | 11 +- .../BluetoothPermissionState.ios.kt | 71 ++ .../identity/appsupport/ui/Util.jvm.kt | 8 - .../documenttype/knowntypes/DrivingLicense.kt | 15 +- .../knowntypes/EUCertificateOfResidence.kt | 9 +- .../documenttype/knowntypes/EUPersonalID.kt | 7 +- .../knowntypes/GermanPersonalID.kt | 7 +- .../documenttype/knowntypes/PhotoID.kt | 12 +- .../documenttype/knowntypes/SampleData.kt | 20 +- .../knowntypes/UtopiaNaturalization.kt | 5 +- identity-mdoc/build.gradle.kts | 47 ++ identity-mdoc/lint.xml | 5 + .../transport/BleCentralManagerAndroid.kt | 738 +++++++++++++++++ .../transport/BlePeripheralManagerAndroid.kt | 616 +++++++++++++++ .../transport/MdocTransportFactory.android.kt | 68 ++ .../sessionencryption/SessionEncryption.kt | 12 + .../mdoc/transport/BleCentralManager.kt | 63 ++ .../mdoc/transport/BlePeripheralManager.kt | 56 ++ .../mdoc/transport/BleTransportCentralMdoc.kt | 172 ++++ .../BleTransportCentralMdocReader.kt | 169 ++++ .../mdoc/transport/BleTransportConstants.kt | 12 + .../transport/BleTransportPeripheralMdoc.kt | 170 ++++ .../BleTransportPeripheralMdocReader.kt | 175 ++++ .../identity/mdoc/transport/MdocTransport.kt | 151 ++++ .../transport/MdocTransportClosedException.kt | 35 + .../mdoc/transport/MdocTransportException.kt | 35 + .../mdoc/transport/MdocTransportFactory.kt | 24 + .../mdoc/transport/MdocTransportOptions.kt | 10 + .../MdocTransportTerminationException.kt | 36 + .../mdoc/transport/BleCentralManagerIos.kt | 651 +++++++++++++++ .../mdoc/transport/BlePeripheralManagerIos.kt | 530 +++++++++++++ .../transport/MdocTransportFactory.ios.kt | 68 ++ .../mdoc/transport/TransportFactory.jvm.kt | 15 + identity/build.gradle.kts | 49 ++ .../identity/util/AndroidInitializer.kt | 13 + .../com/android/identity/crypto/CryptoIos.kt | 16 +- .../android/identity/crypto/X509CertIos.kt | 2 + .../com/android/identity/util/IosUtil.kt | 32 + .../com/android/identity/util/UUID.ios.kt | 13 + .../com/android/identity/util/UUIDIosTest.kt | 15 + .../com/android/identity/util/UUIDJvmTest.kt | 14 + samples/testapp/build.gradle.kts | 9 +- samples/testapp/iosApp/TestApp/Info.plist | 2 + .../src/androidMain/AndroidManifest.xml | 6 + .../android/identity/testapp/MainActivity.kt | 2 + .../identity/testapp/PlatformAndroid.kt | 15 + .../drawable/driving_license_card_art.png | Bin 0 -> 195318 bytes .../composeResources/values/strings.xml | 3 + .../com/android/identity/testapp/App.kt | 23 +- .../android/identity/testapp/Destinations.kt | 21 + .../com/android/identity/testapp/Platform.kt | 2 + .../android/identity/testapp/TestAppUtils.kt | 302 +++++++ .../MultiDeviceTestsClient.kt | 186 +++++ .../MultiDeviceTestsServer.kt | 377 +++++++++ .../identity/testapp/multidevicetests/Plan.kt | 71 ++ .../testapp/multidevicetests/Results.kt | 12 + .../identity/testapp/multidevicetests/Test.kt | 67 ++ .../testapp/multidevicetests/Timing.kt | 8 + .../ui/IsoMdocMultiDeviceTestingScreen.kt | 304 +++++++ .../ui/IsoMdocProximityReadingScreen.kt | 747 ++++++++++++++++++ .../ui/IsoMdocProximitySharingScreen.kt | 492 ++++++++++++ .../identity/testapp/ui/QrCodesScreen.kt | 15 +- .../identity/testapp/ui/StartScreen.kt | 24 + .../android/identity/testapp/PlatformIos.kt | 62 ++ .../addtowallet/AddToWalletScreen.kt | 6 +- .../ui/destination/reader/ReaderScreen.kt | 4 +- 76 files changed, 7011 insertions(+), 130 deletions(-) create mode 100644 identity-appsupport/src/androidMain/kotlin/com/android/identity/appsupport/ui/permissions/BluetoothPermissionState.android.kt create mode 100644 identity-appsupport/src/commonMain/kotlin/com/android/identity/appsupport/ui/permissions/BluetoothPermissionState.kt create mode 100644 identity-appsupport/src/iosMain/kotlin/com/android/identity/appsupport/ui/permissions/BluetoothPermissionState.ios.kt delete mode 100644 identity-appsupport/src/jvmMain/kotlin/com/android/identity/appsupport/ui/Util.jvm.kt create mode 100644 identity-mdoc/lint.xml create mode 100644 identity-mdoc/src/androidMain/kotlin/com/android/identity/mdoc/transport/BleCentralManagerAndroid.kt create mode 100644 identity-mdoc/src/androidMain/kotlin/com/android/identity/mdoc/transport/BlePeripheralManagerAndroid.kt create mode 100644 identity-mdoc/src/androidMain/kotlin/com/android/identity/mdoc/transport/MdocTransportFactory.android.kt create mode 100644 identity-mdoc/src/commonMain/kotlin/com/android/identity/mdoc/transport/BleCentralManager.kt create mode 100644 identity-mdoc/src/commonMain/kotlin/com/android/identity/mdoc/transport/BlePeripheralManager.kt create mode 100644 identity-mdoc/src/commonMain/kotlin/com/android/identity/mdoc/transport/BleTransportCentralMdoc.kt create mode 100644 identity-mdoc/src/commonMain/kotlin/com/android/identity/mdoc/transport/BleTransportCentralMdocReader.kt create mode 100644 identity-mdoc/src/commonMain/kotlin/com/android/identity/mdoc/transport/BleTransportConstants.kt create mode 100644 identity-mdoc/src/commonMain/kotlin/com/android/identity/mdoc/transport/BleTransportPeripheralMdoc.kt create mode 100644 identity-mdoc/src/commonMain/kotlin/com/android/identity/mdoc/transport/BleTransportPeripheralMdocReader.kt create mode 100644 identity-mdoc/src/commonMain/kotlin/com/android/identity/mdoc/transport/MdocTransport.kt create mode 100644 identity-mdoc/src/commonMain/kotlin/com/android/identity/mdoc/transport/MdocTransportClosedException.kt create mode 100644 identity-mdoc/src/commonMain/kotlin/com/android/identity/mdoc/transport/MdocTransportException.kt create mode 100644 identity-mdoc/src/commonMain/kotlin/com/android/identity/mdoc/transport/MdocTransportFactory.kt create mode 100644 identity-mdoc/src/commonMain/kotlin/com/android/identity/mdoc/transport/MdocTransportOptions.kt create mode 100644 identity-mdoc/src/commonMain/kotlin/com/android/identity/mdoc/transport/MdocTransportTerminationException.kt create mode 100644 identity-mdoc/src/iosMain/kotlin/com/android/identity/mdoc/transport/BleCentralManagerIos.kt create mode 100644 identity-mdoc/src/iosMain/kotlin/com/android/identity/mdoc/transport/BlePeripheralManagerIos.kt create mode 100644 identity-mdoc/src/iosMain/kotlin/com/android/identity/mdoc/transport/MdocTransportFactory.ios.kt create mode 100644 identity-mdoc/src/jvmMain/kotlin/com/android/identity/mdoc/transport/TransportFactory.jvm.kt create mode 100644 identity/src/androidMain/kotlin/com/android/identity/util/AndroidInitializer.kt create mode 100644 identity/src/iosMain/kotlin/com/android/identity/util/IosUtil.kt create mode 100644 identity/src/iosMain/kotlin/com/android/identity/util/UUID.ios.kt create mode 100644 identity/src/iosTest/kotlin/com/android/identity/util/UUIDIosTest.kt create mode 100644 identity/src/jvmTest/kotlin/com/android/identity/util/UUIDJvmTest.kt create mode 100644 samples/testapp/src/commonMain/composeResources/drawable/driving_license_card_art.png create mode 100644 samples/testapp/src/commonMain/kotlin/com/android/identity/testapp/TestAppUtils.kt create mode 100644 samples/testapp/src/commonMain/kotlin/com/android/identity/testapp/multidevicetests/MultiDeviceTestsClient.kt create mode 100644 samples/testapp/src/commonMain/kotlin/com/android/identity/testapp/multidevicetests/MultiDeviceTestsServer.kt create mode 100644 samples/testapp/src/commonMain/kotlin/com/android/identity/testapp/multidevicetests/Plan.kt create mode 100644 samples/testapp/src/commonMain/kotlin/com/android/identity/testapp/multidevicetests/Results.kt create mode 100644 samples/testapp/src/commonMain/kotlin/com/android/identity/testapp/multidevicetests/Test.kt create mode 100644 samples/testapp/src/commonMain/kotlin/com/android/identity/testapp/multidevicetests/Timing.kt create mode 100644 samples/testapp/src/commonMain/kotlin/com/android/identity/testapp/ui/IsoMdocMultiDeviceTestingScreen.kt create mode 100644 samples/testapp/src/commonMain/kotlin/com/android/identity/testapp/ui/IsoMdocProximityReadingScreen.kt create mode 100644 samples/testapp/src/commonMain/kotlin/com/android/identity/testapp/ui/IsoMdocProximitySharingScreen.kt diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index db2e71c30..19966672d 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -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" @@ -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" } @@ -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" } diff --git a/identity-android/src/main/java/com/android/identity/android/mdoc/transport/L2CAPClient.kt b/identity-android/src/main/java/com/android/identity/android/mdoc/transport/L2CAPClient.kt index 8a21d1b91..997d8b4b5 100644 --- a/identity-android/src/main/java/com/android/identity/android/mdoc/transport/L2CAPClient.kt +++ b/identity-android/src/main/java/com/android/identity/android/mdoc/transport/L2CAPClient.kt @@ -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 @@ -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 { @@ -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 @@ -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() { @@ -194,4 +197,22 @@ internal class L2CAPClient(private val context: Context, val listener: Listener) companion object { private const val TAG = "L2CAPClient" } -} \ No newline at end of file +} + +// 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() +} + diff --git a/identity-android/src/main/java/com/android/identity/android/mdoc/transport/L2CAPServer.kt b/identity-android/src/main/java/com/android/identity/android/mdoc/transport/L2CAPServer.kt index 7182b8969..b4c839a9d 100644 --- a/identity-android/src/main/java/com/android/identity/android/mdoc/transport/L2CAPServer.kt +++ b/identity-android/src/main/java/com/android/identity/android/mdoc/transport/L2CAPServer.kt @@ -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 @@ -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 { @@ -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 @@ -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 { + 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() { diff --git a/identity-appsupport/build.gradle.kts b/identity-appsupport/build.gradle.kts index bf0c5d122..8e6ac154e 100644 --- a/identity-appsupport/build.gradle.kts +++ b/identity-appsupport/build.gradle.kts @@ -1,3 +1,4 @@ +import org.gradle.kotlin.dsl.implementation import org.jetbrains.kotlin.gradle.ExperimentalKotlinGradlePluginApi import org.jetbrains.kotlin.gradle.dsl.JvmTarget @@ -11,8 +12,6 @@ plugins { kotlin { jvmToolchain(17) - jvm() - androidTarget { @OptIn(ExperimentalKotlinGradlePluginApi::class) compilerOptions { @@ -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) } } } diff --git a/identity-appsupport/src/androidMain/kotlin/com/android/identity/appsupport/ui/Util.android.kt b/identity-appsupport/src/androidMain/kotlin/com/android/identity/appsupport/ui/Util.android.kt index 940cc9b82..8c9250321 100644 --- a/identity-appsupport/src/androidMain/kotlin/com/android/identity/appsupport/ui/Util.android.kt +++ b/identity-appsupport/src/androidMain/kotlin/com/android/identity/appsupport/ui/Util.android.kt @@ -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 @@ -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 @@ -31,4 +34,8 @@ actual fun AppTheme(content: @Composable () -> Unit) { colorScheme = colorScheme, content = content ) -} \ No newline at end of file +} + +actual fun decodeImage(encodedData: ByteArray): ImageBitmap { + return BitmapFactory.decodeByteArray(encodedData, 0, encodedData.size).asImageBitmap() +} diff --git a/identity-appsupport/src/androidMain/kotlin/com/android/identity/appsupport/ui/permissions/BluetoothPermissionState.android.kt b/identity-appsupport/src/androidMain/kotlin/com/android/identity/appsupport/ui/permissions/BluetoothPermissionState.android.kt new file mode 100644 index 000000000..871220a9d --- /dev/null +++ b/identity-appsupport/src/androidMain/kotlin/com/android/identity/appsupport/ui/permissions/BluetoothPermissionState.android.kt @@ -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, + ) + ) + ) +} diff --git a/identity-appsupport/src/commonMain/kotlin/com/android/identity/appsupport/ui/Util.kt b/identity-appsupport/src/commonMain/kotlin/com/android/identity/appsupport/ui/Util.kt index a7c4aaabf..b7dd30143 100644 --- a/identity-appsupport/src/commonMain/kotlin/com/android/identity/appsupport/ui/Util.kt +++ b/identity-appsupport/src/commonMain/kotlin/com/android/identity/appsupport/ui/Util.kt @@ -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) @@ -16,3 +17,5 @@ fun AppThemeDefault(content: @Composable () -> Unit) { content = content ) } + +expect fun decodeImage(encodedData: ByteArray): ImageBitmap diff --git a/identity-appsupport/src/commonMain/kotlin/com/android/identity/appsupport/ui/permissions/BluetoothPermissionState.kt b/identity-appsupport/src/commonMain/kotlin/com/android/identity/appsupport/ui/permissions/BluetoothPermissionState.kt new file mode 100644 index 000000000..c0428a3d4 --- /dev/null +++ b/identity-appsupport/src/commonMain/kotlin/com/android/identity/appsupport/ui/permissions/BluetoothPermissionState.kt @@ -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 diff --git a/identity-appsupport/src/commonMain/kotlin/com/android/identity/appsupport/ui/qrcode/ScanQrCodeDialog.kt b/identity-appsupport/src/commonMain/kotlin/com/android/identity/appsupport/ui/qrcode/ScanQrCodeDialog.kt index ea10b2da2..f85420528 100644 --- a/identity-appsupport/src/commonMain/kotlin/com/android/identity/appsupport/ui/qrcode/ScanQrCodeDialog.kt +++ b/identity-appsupport/src/commonMain/kotlin/com/android/identity/appsupport/ui/qrcode/ScanQrCodeDialog.kt @@ -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. @@ -27,8 +28,9 @@ 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, @@ -36,12 +38,12 @@ fun ScanQrCodeDialog( ) { 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), @@ -50,6 +52,8 @@ fun ScanQrCodeDialog( }, types = listOf(CodeType.QR) ) + + additionalContent?.invoke() } }, onDismissRequest = onDismiss, diff --git a/identity-appsupport/src/commonMain/kotlin/com/android/identity/appsupport/ui/qrcode/ShowQrCodeDialog.kt b/identity-appsupport/src/commonMain/kotlin/com/android/identity/appsupport/ui/qrcode/ShowQrCodeDialog.kt index 273022a73..2ca24f0bc 100644 --- a/identity-appsupport/src/commonMain/kotlin/com/android/identity/appsupport/ui/qrcode/ShowQrCodeDialog.kt +++ b/identity-appsupport/src/commonMain/kotlin/com/android/identity/appsupport/ui/qrcode/ShowQrCodeDialog.kt @@ -27,16 +27,18 @@ import org.jetbrains.compose.resources.stringResource * Renders a QR code and shows it in a dialog. * * @param title The title of the dialog. - * @param description The description text to include in the dialog. - * @param dismissButton The text for the dismiss button. + * @param text The description to include in the dialog, displayed above the QR code. + * @param additionalContent Content which is displayed below the QR code. + * @param dismissButton The content for the dismiss button. * @param data the QR code to show, e.g. mdoc:owBjMS4... or https://github.com/.... * @param onDismiss called when the dismiss button is pressed. * @param modifier A [Modifier] or `null`. */ @Composable fun ShowQrCodeDialog( - title: String, - description: String, + title: (@Composable () -> Unit)? = null, + text: (@Composable () -> Unit)? = null, + additionalContent: (@Composable () -> Unit)? = null, dismissButton: String, data: String, onDismiss: () -> Unit, @@ -48,12 +50,12 @@ fun ShowQrCodeDialog( AlertDialog( modifier = modifier ?: Modifier, - title = { Text(text = title) }, + title = title, text = { Column( verticalArrangement = Arrangement.spacedBy(16.dp) ) { - Text(text = description) + text?.invoke() Row( modifier = Modifier.align(Alignment.CenterHorizontally) @@ -74,6 +76,8 @@ fun ShowQrCodeDialog( ) } } + + additionalContent?.invoke() } }, onDismissRequest = onDismiss, diff --git a/identity-appsupport/src/iosMain/kotlin/com/android/identity/appsupport/ui/Util.ios.kt b/identity-appsupport/src/iosMain/kotlin/com/android/identity/appsupport/ui/Util.ios.kt index 5718e8c9c..142848c68 100644 --- a/identity-appsupport/src/iosMain/kotlin/com/android/identity/appsupport/ui/Util.ios.kt +++ b/identity-appsupport/src/iosMain/kotlin/com/android/identity/appsupport/ui/Util.ios.kt @@ -1,8 +1,17 @@ package com.android.identity.appsupport.ui +import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.runtime.Composable +import androidx.compose.ui.graphics.ImageBitmap +import androidx.compose.ui.graphics.toComposeImageBitmap +import org.jetbrains.skia.Image @Composable actual fun AppTheme(content: @Composable () -> Unit) { return AppThemeDefault(content) -} \ No newline at end of file +} + +@OptIn(ExperimentalFoundationApi::class) +actual fun decodeImage(encodedData: ByteArray): ImageBitmap { + return Image.makeFromEncoded(encodedData).toComposeImageBitmap() +} diff --git a/identity-appsupport/src/iosMain/kotlin/com/android/identity/appsupport/ui/permissions/BluetoothPermissionState.ios.kt b/identity-appsupport/src/iosMain/kotlin/com/android/identity/appsupport/ui/permissions/BluetoothPermissionState.ios.kt new file mode 100644 index 000000000..859c6d596 --- /dev/null +++ b/identity-appsupport/src/iosMain/kotlin/com/android/identity/appsupport/ui/permissions/BluetoothPermissionState.ios.kt @@ -0,0 +1,71 @@ +package com.android.identity.appsupport.ui.permissions + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.MutableState +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.rememberCoroutineScope +import com.android.identity.util.Logger +import kotlinx.cinterop.ExperimentalForeignApi +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import platform.CoreBluetooth.CBCentralManager +import platform.CoreBluetooth.CBCentralManagerDelegateProtocol +import platform.CoreBluetooth.CBManager +import platform.CoreBluetooth.CBManagerAuthorization +import platform.CoreBluetooth.CBManagerAuthorizationAllowedAlways +import platform.CoreBluetooth.CBManagerAuthorizationNotDetermined +import platform.darwin.NSObject + +private const val TAG = "BluetoothPermissionState.ios" + +@OptIn(ExperimentalForeignApi::class) +private fun calcIsGranted(): Boolean { + val state: CBManagerAuthorization = CBManager.authorization + Logger.i(TAG, "state (CBManagerAuthorization): $state") + when (state) { + CBManagerAuthorizationAllowedAlways -> { + return true + } + CBManagerAuthorizationNotDetermined -> { + return false + } + else -> { + return false + } + } +} + +private class IosBluetoothPermissionState( + val recomposeToggleState: MutableState, + val scope: CoroutineScope +) : BluetoothPermissionState { + + private var iosIsGranted: Boolean = calcIsGranted() + + override val isGranted: Boolean + get() = iosIsGranted + + override fun launchPermissionRequest() { + Logger.i(TAG, "launchPermissionRequest...") + CBCentralManager(object : NSObject(), CBCentralManagerDelegateProtocol { + override fun centralManagerDidUpdateState(central: CBCentralManager) { + Logger.i(TAG, "Did update state ${central.authorization}") + scope.launch(Dispatchers.Main) { + iosIsGranted = calcIsGranted() + recomposeToggleState.value = !recomposeToggleState.value + } + } + }, null) + } +} + +@Composable +actual fun rememberBluetoothPermissionState(): BluetoothPermissionState { + val scope = rememberCoroutineScope() + val recomposeToggleState: MutableState = mutableStateOf(false) + LaunchedEffect(recomposeToggleState.value) {} + return IosBluetoothPermissionState(recomposeToggleState, scope) +} + diff --git a/identity-appsupport/src/jvmMain/kotlin/com/android/identity/appsupport/ui/Util.jvm.kt b/identity-appsupport/src/jvmMain/kotlin/com/android/identity/appsupport/ui/Util.jvm.kt deleted file mode 100644 index 5718e8c9c..000000000 --- a/identity-appsupport/src/jvmMain/kotlin/com/android/identity/appsupport/ui/Util.jvm.kt +++ /dev/null @@ -1,8 +0,0 @@ -package com.android.identity.appsupport.ui - -import androidx.compose.runtime.Composable - -@Composable -actual fun AppTheme(content: @Composable () -> Unit) { - return AppThemeDefault(content) -} \ No newline at end of file diff --git a/identity-doctypes/src/commonMain/kotlin/com/android/identity/documenttype/knowntypes/DrivingLicense.kt b/identity-doctypes/src/commonMain/kotlin/com/android/identity/documenttype/knowntypes/DrivingLicense.kt index 2ad7dce14..fdf7558bc 100644 --- a/identity-doctypes/src/commonMain/kotlin/com/android/identity/documenttype/knowntypes/DrivingLicense.kt +++ b/identity-doctypes/src/commonMain/kotlin/com/android/identity/documenttype/knowntypes/DrivingLicense.kt @@ -26,6 +26,9 @@ import com.android.identity.documenttype.DocumentType import com.android.identity.documenttype.Icon import com.android.identity.documenttype.IntegerOption import com.android.identity.documenttype.StringOption +import com.android.identity.util.fromBase64Url +import com.android.identity.util.fromHex +import kotlinx.datetime.LocalDate /** * Object containing the metadata of the Driving License @@ -74,7 +77,7 @@ object DrivingLicense { true, MDL_NAMESPACE, Icon.TODAY, - SampleData.birthDate.toDataItemFullDate() + LocalDate.parse(SampleData.BIRTH_DATE).toDataItemFullDate() ) .addAttribute( DocumentAttributeType.Date, @@ -84,7 +87,7 @@ object DrivingLicense { true, MDL_NAMESPACE, Icon.DATE_RANGE, - SampleData.issueDate.toDataItemFullDate() + LocalDate.parse(SampleData.ISSUE_DATE).toDataItemFullDate() ) .addAttribute( DocumentAttributeType.Date, @@ -94,7 +97,7 @@ object DrivingLicense { true, MDL_NAMESPACE, Icon.CALENDAR_CLOCK, - SampleData.expiryDate.toDataItemFullDate() + LocalDate.parse(SampleData.EXPIRY_DATE).toDataItemFullDate() ) .addAttribute( DocumentAttributeType.StringOptions(Options.COUNTRY_ISO_3166_1_ALPHA_2), @@ -134,7 +137,7 @@ object DrivingLicense { true, MDL_NAMESPACE, Icon.ACCOUNT_BOX, - null // TODO: include img_erika_portrait.jpg + SampleData.PORTRAIT_BASE64URL.fromBase64Url().toDataItem() ) .addAttribute( DocumentAttributeType.ComplexType, @@ -284,7 +287,7 @@ object DrivingLicense { false, MDL_NAMESPACE, Icon.TODAY, - SampleData.portraitCaptureDate.toDataItemFullDate() + LocalDate.parse(SampleData.PORTRAIT_CAPTURE_DATE).toDataItemFullDate() ) .addAttribute( DocumentAttributeType.Number, @@ -484,7 +487,7 @@ object DrivingLicense { false, MDL_NAMESPACE, Icon.SIGNATURE, - null // TODO: include img_erika_signature.jpg + SampleData.SIGNATURE_OR_USUAL_MARK_BASE64URL.fromBase64Url().toDataItem() ) .addAttribute( DocumentAttributeType.ComplexType, diff --git a/identity-doctypes/src/commonMain/kotlin/com/android/identity/documenttype/knowntypes/EUCertificateOfResidence.kt b/identity-doctypes/src/commonMain/kotlin/com/android/identity/documenttype/knowntypes/EUCertificateOfResidence.kt index 89b57564c..aedd98e22 100644 --- a/identity-doctypes/src/commonMain/kotlin/com/android/identity/documenttype/knowntypes/EUCertificateOfResidence.kt +++ b/identity-doctypes/src/commonMain/kotlin/com/android/identity/documenttype/knowntypes/EUCertificateOfResidence.kt @@ -5,6 +5,7 @@ import com.android.identity.cbor.toDataItemFullDate import com.android.identity.documenttype.DocumentAttributeType import com.android.identity.documenttype.DocumentType import com.android.identity.documenttype.Icon +import kotlinx.datetime.LocalDate /** * Object containing the metadata of the EU Certificate of Residency (COR) document. @@ -52,7 +53,7 @@ object EUCertificateOfResidence { true, NAMESPACE, Icon.TODAY, - SampleData.birthDate.toDataItemFullDate() + LocalDate.parse(SampleData.BIRTH_DATE).toDataItemFullDate() ) .addAttribute( DocumentAttributeType.Boolean, @@ -82,7 +83,7 @@ object EUCertificateOfResidence { false, NAMESPACE, Icon.DATE_RANGE, - SampleData.issueDate.toDataItemFullDate() + LocalDate.parse(SampleData.ISSUE_DATE).toDataItemFullDate() ) .addAttribute( DocumentAttributeType.String, @@ -192,7 +193,7 @@ object EUCertificateOfResidence { true, NAMESPACE, Icon.DATE_RANGE, - SampleData.issueDate.toDataItemFullDate() + LocalDate.parse(SampleData.ISSUE_DATE).toDataItemFullDate() ) .addAttribute( DocumentAttributeType.Date, @@ -202,7 +203,7 @@ object EUCertificateOfResidence { true, NAMESPACE, Icon.CALENDAR_CLOCK, - SampleData.expiryDate.toDataItemFullDate() + LocalDate.parse(SampleData.EXPIRY_DATE).toDataItemFullDate() ) .addAttribute( DocumentAttributeType.String, diff --git a/identity-doctypes/src/commonMain/kotlin/com/android/identity/documenttype/knowntypes/EUPersonalID.kt b/identity-doctypes/src/commonMain/kotlin/com/android/identity/documenttype/knowntypes/EUPersonalID.kt index 09c3027ec..a2d9cbf47 100644 --- a/identity-doctypes/src/commonMain/kotlin/com/android/identity/documenttype/knowntypes/EUPersonalID.kt +++ b/identity-doctypes/src/commonMain/kotlin/com/android/identity/documenttype/knowntypes/EUPersonalID.kt @@ -21,6 +21,7 @@ import com.android.identity.cbor.toDataItemFullDate import com.android.identity.documenttype.DocumentAttributeType import com.android.identity.documenttype.DocumentType import com.android.identity.documenttype.Icon +import kotlinx.datetime.LocalDate /** * Object containing the metadata of the EU Personal ID Document Type. @@ -67,7 +68,7 @@ object EUPersonalID { true, EUPID_NAMESPACE, Icon.TODAY, - SampleData.birthDate.toDataItemFullDate() + LocalDate.parse(SampleData.BIRTH_DATE).toDataItemFullDate() ) .addAttribute( DocumentAttributeType.Number, @@ -267,7 +268,7 @@ object EUPersonalID { true, EUPID_NAMESPACE, Icon.DATE_RANGE, - SampleData.issueDate.toDataItemFullDate() + LocalDate.parse(SampleData.ISSUE_DATE).toDataItemFullDate() ) .addAttribute( DocumentAttributeType.Date, @@ -277,7 +278,7 @@ object EUPersonalID { true, EUPID_NAMESPACE, Icon.CALENDAR_CLOCK, - SampleData.expiryDate.toDataItemFullDate() + LocalDate.parse(SampleData.EXPIRY_DATE).toDataItemFullDate() ) .addAttribute( DocumentAttributeType.String, diff --git a/identity-doctypes/src/commonMain/kotlin/com/android/identity/documenttype/knowntypes/GermanPersonalID.kt b/identity-doctypes/src/commonMain/kotlin/com/android/identity/documenttype/knowntypes/GermanPersonalID.kt index f8845bb09..ebd8dfe40 100644 --- a/identity-doctypes/src/commonMain/kotlin/com/android/identity/documenttype/knowntypes/GermanPersonalID.kt +++ b/identity-doctypes/src/commonMain/kotlin/com/android/identity/documenttype/knowntypes/GermanPersonalID.kt @@ -6,6 +6,7 @@ import com.android.identity.cbor.toDataItemFullDate import com.android.identity.documenttype.DocumentAttributeType import com.android.identity.documenttype.DocumentType import com.android.identity.documenttype.Icon +import kotlinx.datetime.LocalDate /** * Object containing the metadata of the German ID Document Type. @@ -45,7 +46,7 @@ object GermanPersonalID { "Date of Birth", "Day, month, and year on which the PID holder was born. If unknown, approximate date of birth.", Icon.TODAY, - SampleData.birthDate.toDataItemFullDate() + LocalDate.parse(SampleData.BIRTH_DATE).toDataItemFullDate() ) .addVcAttribute( DocumentAttributeType.Number, @@ -205,7 +206,7 @@ object GermanPersonalID { "Date of Issue", "Date (and possibly time) when the PID was issued.", Icon.DATE_RANGE, - SampleData.issueDate.toDataItemFullDate() + LocalDate.parse(SampleData.ISSUE_DATE).toDataItemFullDate() ) .addVcAttribute( DocumentAttributeType.Date, @@ -213,7 +214,7 @@ object GermanPersonalID { "Date of Expiry", "Date (and possibly time) when the PID will expire.", Icon.CALENDAR_CLOCK, - SampleData.expiryDate.toDataItemFullDate() + LocalDate.parse(SampleData.EXPIRY_DATE).toDataItemFullDate() ) .addVcAttribute( DocumentAttributeType.String, diff --git a/identity-doctypes/src/commonMain/kotlin/com/android/identity/documenttype/knowntypes/PhotoID.kt b/identity-doctypes/src/commonMain/kotlin/com/android/identity/documenttype/knowntypes/PhotoID.kt index 91d5d4766..0731eae17 100644 --- a/identity-doctypes/src/commonMain/kotlin/com/android/identity/documenttype/knowntypes/PhotoID.kt +++ b/identity-doctypes/src/commonMain/kotlin/com/android/identity/documenttype/knowntypes/PhotoID.kt @@ -7,6 +7,8 @@ import com.android.identity.documenttype.DocumentAttributeType import com.android.identity.documenttype.DocumentType import com.android.identity.documenttype.Icon import com.android.identity.documenttype.knowntypes.EUPersonalID.EUPID_VCT +import com.android.identity.util.fromBase64Url +import kotlinx.datetime.LocalDate /** * PhotoID according to ISO/IEC TS 23220-4 (E) operational phase - Annex C Photo ID v2 @@ -68,7 +70,7 @@ object PhotoID { ISO_23220_2_NAMESPACE, Icon.TODAY, CborMap.builder() - .put("birth_date", SampleData.birthDate.toDataItemFullDate()) + .put("birth_date", LocalDate.parse(SampleData.BIRTH_DATE).toDataItemFullDate()) .end() .build() ) @@ -80,7 +82,7 @@ object PhotoID { true, ISO_23220_2_NAMESPACE, Icon.ACCOUNT_BOX, - null // TODO: include img_erika_portrait.jpg + SampleData.PORTRAIT_BASE64URL.fromBase64Url().toDataItem() ) .addMdocAttribute( DocumentAttributeType.Date, @@ -90,7 +92,7 @@ object PhotoID { true, ISO_23220_2_NAMESPACE, Icon.DATE_RANGE, - SampleData.issueDate.toDataItemFullDate() + LocalDate.parse(SampleData.ISSUE_DATE).toDataItemFullDate() ) .addMdocAttribute( DocumentAttributeType.Date, @@ -100,7 +102,7 @@ object PhotoID { true, ISO_23220_2_NAMESPACE, Icon.CALENDAR_CLOCK, - SampleData.expiryDate.toDataItemFullDate() + LocalDate.parse(SampleData.EXPIRY_DATE).toDataItemFullDate() ) .addMdocAttribute( DocumentAttributeType.String, @@ -250,7 +252,7 @@ object PhotoID { false, ISO_23220_2_NAMESPACE, Icon.TODAY, - SampleData.portraitCaptureDate.toDataItemFullDate() + LocalDate.parse(SampleData.PORTRAIT_CAPTURE_DATE).toDataItemFullDate() ) .addMdocAttribute( DocumentAttributeType.String, diff --git a/identity-doctypes/src/commonMain/kotlin/com/android/identity/documenttype/knowntypes/SampleData.kt b/identity-doctypes/src/commonMain/kotlin/com/android/identity/documenttype/knowntypes/SampleData.kt index f17a423e2..c9b2de73f 100644 --- a/identity-doctypes/src/commonMain/kotlin/com/android/identity/documenttype/knowntypes/SampleData.kt +++ b/identity-doctypes/src/commonMain/kotlin/com/android/identity/documenttype/knowntypes/SampleData.kt @@ -1,7 +1,5 @@ package com.android.identity.documenttype.knowntypes -import kotlinx.datetime.LocalDate - /** * Sample data used across multiple document types. * @@ -9,8 +7,8 @@ import kotlinx.datetime.LocalDate * [Erika Mustermann](https://en.wiktionary.org/wiki/Erika_Mustermann) * and a fictional country called Utopia. * - * Note: The ISO-3166-1 Alpha-2 country code used for Utopia is UT. This value does not - * appear in that standard. + * Note: The ISO-3166-1 Alpha-2 country code used for Utopia is ZZ. This value is a + * user-assigned country code. */ internal object SampleData { @@ -21,10 +19,10 @@ internal object SampleData { const val GIVEN_NAMES_NATIONAL_CHARACTER = "Ерика" const val FAMILY_NAME_NATIONAL_CHARACTER = "Бабіак" - val birthDate = LocalDate.parse("1971-09-01") + const val BIRTH_DATE = "1971-09-01" const val BIRTH_COUNTRY = "ZZ" // Note: ZZ is a user-assigned country-code as per ISO 3166-1 - val issueDate = LocalDate.parse("2024-03-15") - val expiryDate = LocalDate.parse("2028-09-01") + const val ISSUE_DATE = "2024-03-15" + const val EXPIRY_DATE = "2028-09-01" const val ISSUING_COUNTRY = "ZZ" // Note: ZZ is a user-assigned country-code as per ISO 3166-1 const val ISSUING_AUTHORITY_MDL = "Utopia Department of Motor Vehicles" const val ISSUING_AUTHORITY_EU_PID = "Utopia Central Registry" @@ -41,7 +39,9 @@ internal object SampleData { const val BIRTH_STATE = "Sample State" const val BIRTH_CITY = "Sample City" const val RESIDENT_ADDRESS = "Sample Street 123, 12345 Sample City, Sample State, Utopia" - val portraitCaptureDate = LocalDate.parse("2020-03-14") + const val PORTRAIT_CAPTURE_DATE = "2020-03-14" + const val PORTRAIT_BASE64URL = "_9j_4QDKRXhpZgAATU0AKgAAAAgABgESAAMAAAABAAEAAAEaAAUAAAABAAAAVgEbAAUAAAABAAAAXgEoAAMAAAABAAIAAAITAAMAAAABAAEAAIdpAAQAAAABAAAAZgAAAAAAAABIAAAAAQAAAEgAAAABAAeQAAAHAAAABDAyMjGRAQAHAAAABAECAwCgAAAHAAAABDAxMDCgAQADAAAAAQABAACgAgAEAAAAAQAAAHigAwAEAAAAAQAAAJmkBgADAAAAAQAAAAAAAAAAAAD_2wCEAAQEBAQEBAcEBAcJBwcHCQ0JCQkJDRANDQ0NDRATEBAQEBAQExMTExMTExMXFxcXFxcbGxsbGx8fHx8fHx8fHx8BBQUFCAcIDQcHDSAWEhYgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgIP_dAAQACP_AABEIAJkAeAMBIgACEQEDEQH_xAGiAAABBQEBAQEBAQAAAAAAAAAAAQIDBAUGBwgJCgsQAAIBAwMCBAMFBQQEAAABfQECAwAEEQUSITFBBhNRYQcicRQygZGhCCNCscEVUtHwJDNicoIJChYXGBkaJSYnKCkqNDU2Nzg5OkNERUZHSElKU1RVVldYWVpjZGVmZ2hpanN0dXZ3eHl6g4SFhoeIiYqSk5SVlpeYmZqio6Slpqeoqaqys7S1tre4ubrCw8TFxsfIycrS09TV1tfY2drh4uPk5ebn6Onq8fLz9PX29_j5-gEAAwEBAQEBAQEBAQAAAAAAAAECAwQFBgcICQoLEQACAQIEBAMEBwUEBAABAncAAQIDEQQFITEGEkFRB2FxEyIygQgUQpGhscEJIzNS8BVictEKFiQ04SXxFxgZGiYnKCkqNTY3ODk6Q0RFRkdISUpTVFVWV1hZWmNkZWZnaGlqc3R1dnd4eXqCg4SFhoeIiYqSk5SVlpeYmZqio6Slpqeoqaqys7S1tre4ubrCw8TFxsfIycrS09TV1tfY2dri4-Tl5ufo6ery8_T19vf4-fr_2gAMAwEAAhEDEQA_APouZf3r8_xGosgjFSSf6x_940yus84YvI5pjccA0uQoyccVyvizxHD4c06S6lbDgcZ65_pWVWsqcbs2oUJVXyoq-KvGWl-F7dmuZA854SNT8xP-FfLviHxr4i1-RmA-zwj7qr6erHtTtQke53eIvELEBz-5RurZ7BfX-VZIt7u7YS3ceO8FonQD-_Ie_wCNef8AE-ef_APXVP2ceSGhj-ZuHmXT5z35yf606a_tY0KqspDDHzHt-NT6kbfTFJcb7jbuYfwoPf6-lcsgnmzNIM56sewreCvqYyfL7pZsZYYZCvVWOVB-Xg9efpXZ2aOIxJZPGSOOx_CvO2mUEbQCvQEVrLqF5YquNpiYDaWUEfn1qpwbFTmket6TqswcQaxGCn99RwB7iutm01Y0-26cwkUAfdHb-leNadr8_mKkkMQJ-6A2Ff2BYkKfxx24r03QLjePtmnsy4-V4XG0qe4I7VxzTidcHzaHeaH4jMSo34Nn-Veo280NzGs0DB1YcV5CunQakDcWy7Jf41HANdB4evZdLnNhe8wyn5G_ut7_AF6Vthq_I7PY5cZheePNHdHoWc0cU9SSMHtS_N6GvWPDsf_Q-jJHXzG9Mmq7MynIHWppTtkb6mrVnb7ybqXG1BkGuidRQV2cVOk5tRRRuXi0yzN9e43f8s0x-VfPfiGN9e1OW91XP2Kx-eTP3S_VU_DvXruu3M1_N5yDdhhHEn95zwPwrjPEOly3UsHh22AMMPz3Ln-N-prwJ1nOXMz6mhhFSgoI8Mkik1q7bXL9f3KfLbQnsvbA9-K3ItPa2tG1Kdd0khxEn99z93P-yPyr0I-Ho2uFVgPLT73-FUNasGvdtnGuA4KAf3U9fYtQ6vQ1WGPCYdGudc1AxwZlRn-aTH3vVz7cfKPTHrWZ4jSKGcaVYDMUXEjjHzv3_AdK9_vtPXw_oxsbMbLm6XAYdUXvj07Y_CvPoPDQWBpxHlHOAPZa6Y4lJ-hyPBtqyPKbHT_tNvIzKRj5gO-Bx_hXRaVpr-QtndLvilzsPfftyMfUDGPpXpun-GY7eKMLwGZP--S3P6U5NEeOwSQjmORT-TYz-VOeKvsEMC42PJxpxtJDbuN2BwMfK69vx9a7jw6JbG4jmi5hYbQSeV_2T6r_ACrq9Q0UuiPt7bsY_A_596NO0zY23n1_Csp1-ZFxwvKzubZvLH2yIZK8SIPT_Gt94oL6ITJgk9D2PtWNY4h2MTwPlYDuOx_LirMYl0y98oHEMvK56D2_Cs4vqVKPQ7TS7gyWyxyE7k-XFamV9f0rlomlSTzk4PQ46FaufbH9B-Zr1KOKSikzxa-AbneB_9H6OKbrggDuavXzkW62cXC4yxpsCt5jtx1xn_Cq123lQlz2P61x46rf3UdeWUftmbpdms2pSTcf6MuF9Azf_Wp1ppCGB7zqZXKpn2POPStnw_b-XpzuRlpXY_ieBW7FHDFE-4_u7aMRL7kck1wxjoes6lmeWX2nrbhl6etYlvp6fPqFx9xQP_rCu4vkfUboW8C_e_z-lY3iFYY_9FXCW1uN0re4HA_pWL01O-OyieZ6pFJdziR_vSnaq-iitLUNJgsLNLZQPlXH1LVa8MW76_r5nK4htl3D6Cujntl1HxAqsD5MH7xz268ChLQu1pcvZGLJoqRywWyckNGnPrwKSXQoxpMg248sOPwQjFb-r3kFrfwLnnzBIcdhnI_StScIfDNzNzlmc_gzD_CtF1Ri9FFnAw2EdxpsD4yVO1vxH_6qoiw8qUbR0AOP51paTc4iktycLvAGfdcf0FaDfvhDJjBw0Z-o5oJktTFuLdrWAsOdgK_h1qzcRm-0pCPvoAyketas1uJrA7QOmR-WKr6KA1kocY_oM4rSJyzRS0y6324Y_wAPBHof8K0_tfutZaxC01J4gcK3IFau9f8AOKtO2hztH__S-o0jPmFF4wWNc9rU5iRVHUnIFdYi8vjHOen1rz7xDOTqQgQ_dAQY9Wrx8Qz28HFJHodiDFYwHjIGf0_xrntU1WFY0tSdqk7j6n1rpZWSLTFwcYTAb2xivMb-6tLSzbVtQXdljgegBwqgfQVFRrY68LT5veN7SrpY7SXUZQI2l_1YbsucDPoSf0ryXX_E1vrN02n6dvMMbnc-OHYcbvf2HpUXiTxP4ikgS6s7BIbV2_dfapNrSsBydi_dVQOprJ8OeI2vGD3FnbmIrvSa0O-NwDtJB7_NxRKjJLWOhdCvTdS3Nqes-EoY9M0ieYjDP0_LiodNmjfMC7d85wTn0yMVpWwgurIQw_dI4x0NZTaWLCT90Ru61l2O9QT5r7mVrqebqz3AwUDlEx2VMAVsyaraNok1pI_R-R6fNXIavepDMEmySTz6VlyeJtDtbeVZBvCkbyBx79KunqzGtGMYxTexlzX1vaFxFIMbgy4_EY_GuvsLtLiFXzn5w4I9xg_rXna-J_Bs05tZLR7Z24zIuzP51fWb-xB9sspPMsmPzIOWU-39RROPKZqSqL3Wek2EpeCSB_4cjFQaKygGLIO1wuOn3v8AIqTTDG881xEcpJ5ci47hl5_lVbRRvu7xB_C24fg39KqDOSoiXVYPKu45e_Q_yqHePSt3X4sDdtyP_wBRrktyf3RWkjGL0P_T-rLqT7OjsODub9K8s1ObOrCcHIMikfgo_rXo2vY8lynGM_nXmGoqfLEg4KqT9PT-VeBiJH0mDhoej6rdiLRYQuSzJGB9T1_lXKPZfabeOG8QOIySvtmt7UmaWKy5yvlRn_x2qEscjDehxQ_iud-GilBHEaz4a1UWYi065_dkuFEq79okBV1BH8DjqD07VwyaNfaPp8lgBCg8swoFTy40QncdiDpluTXrk91Oo2nbxWD9njvJh5_z88AVr9YlsH1GlfmcdSz4Okvhp8AvmEkgHzPjG7AwGx2yOtTatfsbsoPp-FdBb28drDvIxt7VyGp-W1wJR3PPFYSOmmluc5rUEc6M7Kx2jIxx-FeSrLdQC5iiiQiR1MatzgLnjj8_rivdvIjl-VuQa4e90eCO4Z7b5cGt6dXlOWthlUVnscN4e0e8-0gXdu08Ucs0ubpg2_zV27C3P7sf3cV0UWg3en6e7xHfH0eIdMdsHqcdB7V1NlmNNqkk_SuhtflQhxwfaqq1-dWMsNgY0XeJj-C71Z7AwH78cSpz1wrED9DWxou6DXLmPGBIG5-p6flisG1Eem67IsX3J4_l-ua6HS3xrrNnGc9vasKLIxVPlOs10GSz3ei_yrg9yeld9qYJtZU45UVxX2Y11NdjhgrI_9T6j1BBNC8eOUk5rz7VohHZSZ9Qv4ZrvXLreTbvuk4A_GuJ8R4W0-X--BgV89X1PqMJpZG08ytY2D8fdCf4UrRO446dK5qW6CaKIc_NDtl_AnNdL9o2kccEA_nQnq0dlL4VY5nUYpY87a5_RLif-02hdeh4r0G9-zyw-YcdcCq9rpMEWZTgMe_pS5feOpVVyaouyROJHR5VztGIgRkfhXO31qqj5xgGq-teGNK1S7S_u2ZbiLBSWMlWGPcUmuP9ptxFFKQAAN3em0KnpazMyJTvyh-QYrlNXJN4QoA9AKvaXolto_mm1ldvNbc5kYsT-dTyRW5vPNYjBpTVlcum1ezItMgYoCRWncfImKsq0ESDaeKyrm4SQ4Bzip5tDTl10Oc1O4SG6trluCJNg_GupsSx1cEd8dK8y8Zz7JtPgXjMobj6gV6Pocvn39sB_FtNXRjZJnmY2SlJpdD0LV9iIU7hgp_AVzm6P0rS1e53ymP_AGi1Yu8e36V13PMSP__V-n9QAFyyL_Ex_LNcRrmJZoLePGZeeeMDoP0rtL5S927J_eKD6ZrzHX7yO3uLi_flYV2Rj3xivn6259PhvhRzV7rMUeuNppbHmwlc9v3S_LXqWjzpqOl29zGfvRr_AIf0r5X1u6afbeCQx3EZbn2PUV7H8GfEH9p6HNp07Zkspig_3G-Zf8KzpxbjdnXKSjLlXyO-v4p4biJwnmBVJVc4GazJPEFpbnF2rwMeCGGQPx6V2NwgcDP8NY11p0V2CHXNXy9jenOL-M5ifWNOlyUuFK46iubuNesh8kkyfhR4h8KQmPzYR5cqnkpxn6ivP20ba7b1yTxu_wAKnra56caFOVPmizsU1vTwSPPQegNQyanpchEPmo2_gBa5iDw_bySD92AB3Izmuut9Os4IfLiRVx7Vo0rbnDKKjoitbTXCNJbSEsq7Sj_7J7H3FTRhmcnPAqeGMx5TualaPYPrXO0XF6Hl_jmfy9VssdV5_WvT_DUmyWK4b-Bf_rV4b4t1JJ_GIthytskZb6lhx_Ova9NgltleOTjZxn2NdihaETxqk1KpM3bi682RGPG5TVfK-9VryIwxwyoPujP61W-2v6fyovYxsf_W-pLwrBbyzL95dyp_vscV4F4wlyhso-QGGT6kmvfNTjHkEHoCxr5_1iI3Fyz-rg_gK-exHxWPqMJ8NzzHV7fah_E_nx_SrXwxvLjR_FCbOY7r91IO3JypH0xWxq1sdjbRz0p-haWbe-S66FSNo98iiM_daNPZ-8mfTJIYbuxqsSY1OamWPzYRPCcEgHFVTdRYIPUUzdLsYOokuvHSuUuE2vlVUj_drrruYMh2gYrm7gYG4qBioaVzugny7GU6rn5U571Crc9Oac90gJGOlUZbuFORxQ5LZEcheHH7zvWFrurxaZZtcHl8YRfUnpVpJpLt_Lg4Hqa4Xx2oijSIHnvUwjeSRNWXJB2PEzNJc3N9eSkmR3DZ_wB09K-vtLZbywhuOv2i3WT81H9a-SbHYc8ehx9P_rV9aeF7VoNH0qN-gto1YHtvXoffivVxGlrHzeF1uaNwN9l5B6xIvPsRmue8v_aH6V2F5CttdIkudsuI_wAO39Kl_syz_vD9K5kzqaP_1_qDXmAt2_3iBXj1zbjezkcCvVNcOdsf_TRq417AzMUxwTXz1bWR9XhV7iOEvtPbYCR7_jXn_wDabx-I7SLdwsqr7HsQRX0Tf6dEZFiRRwM_jXz1478Nah4f1S312FC1sZQXbB-Q9s_U4rSFK5tKVo3R9QaVKTDgnsP5VHcWaTk9j7VxnhPxNp9_aJslG7gH69671ZFkztxxWa1VjR3i7oxbfRYwj78n8a57UdL6oGIFd680ew9iK5XUpFVTyKUkktDajOTepwF7Yp5JYMd6_KcdxWDHA7HGK6iaRGYjr7VB5I6gDNZpG7EsoxBHXlHjt3ZmP1xXsaxlY8ngCvH_AByJbm5i0vTE826nYxxovXJXr7YrajH34o5MU_3cjl_BVlb3d15ZiUxQDzJWYZPy9hX0_YW-zRbaROMgD_vk1594f8HxeG9G-zSmOSZolSTaT1YgHFen3c0NqsdmvCRhY8fQcn866q8tTz6VLkikV9Y23Noj_wAXB_EcGuW2P_tVq2l2XuzpspysiMB_vDt-Iq__AGV_sN-VRYz0Wh__0PoOWQXDlC2Srkj8yKuG1Hykkcdf61eg_wBef97-prfl-5-FeKqZ9Iq1rJIwre3i-1jONzgBavahoVpeW8kUihkddrg8gg9RitNP-PyL610B-631rspU1YwqYlpxsfIPiL4V6hpMxvvCTMqA5MDHoP8AYP8AQ1ylh8SNT0C7MGtIU28ENwf1r7N1n_j0b6V85fE__X23-5Wc8PGWp108XLkvY6HR_F-j6_CJLadAWHTIqO_eIodrhhXCeB_9afxr16X7grhnTs-U6qOL0ukeWsFQks4PP0qvNremWUW6edAR_DkV39__AKv8a88m_wCP6T6VpSw_NLluKeNtb3ShHq2r-IPKi0CDZE5wbmYbYlX1z3_CvR_Cvg2x8Plr6YtJqkq_vLs4K89owfujtXWeF_8AkXLD_rkK7KHon-6f512woxhsck8Q5bo808Qx7bUzsV3Aqc4A71wF1qJurpCSAqoT-Jr3XWv-PE_QV56f9b_wA1hWhqaRrLl2ODhuhDq0DuwXbIh5PHp_9avRv7dtf-esX51lXf8Aro_w_kaWkoHJKp5H_9k" + const val SIGNATURE_OR_USUAL_MARK_BASE64URL = "_9j_4AAQSkZJRgABAQIAOAA4AAD_4g_wSUNDX1BST0ZJTEUAAQEAAA_gYXBwbAIQAABtbnRyUkdCIFhZWiAH5gAIAB4ACAAsAB1hY3NwQVBQTAAAAABBUFBMAAAAAAAAAAAAAAAAAAAAAAAA9tYAAQAAAADTLWFwcGwAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABJkZXNjAAABXAAAAGJkc2NtAAABwAAABIJjcHJ0AAAGRAAAACN3dHB0AAAGaAAAABRyWFlaAAAGfAAAABRnWFlaAAAGkAAAABRiWFlaAAAGpAAAABRyVFJDAAAGuAAACAxhYXJnAAAOxAAAACB2Y2d0AAAO5AAAADBuZGluAAAPFAAAAD5jaGFkAAAPVAAAACxtbW9kAAAPgAAAACh2Y2dwAAAPqAAAADhiVFJDAAAGuAAACAxnVFJDAAAGuAAACAxhYWJnAAAOxAAAACBhYWdnAAAOxAAAACBkZXNjAAAAAAAAAAhEaXNwbGF5AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAbWx1YwAAAAAAAAAmAAAADGhySFIAAAAUAAAB2GtvS1IAAAAMAAAB7G5iTk8AAAASAAAB-GlkAAAAAAASAAACCmh1SFUAAAAUAAACHGNzQ1oAAAAWAAACMGRhREsAAAAcAAACRm5sTkwAAAAWAAACYmZpRkkAAAAQAAACeGl0SVQAAAAUAAACiGVzRVMAAAASAAACnHJvUk8AAAASAAACnGZyQ0EAAAAWAAACrmFyAAAAAAAUAAACxHVrVUEAAAAcAAAC2GhlSUwAAAAWAAAC9HpoVFcAAAAKAAADCnZpVk4AAAAOAAADFHNrU0sAAAAWAAADInpoQ04AAAAKAAADCnJ1UlUAAAAkAAADOGVuR0IAAAAUAAADXGZyRlIAAAAWAAADcG1zAAAAAAASAAADhmhpSU4AAAASAAADmHRoVEgAAAAMAAADqmNhRVMAAAAYAAADtmVuQVUAAAAUAAADXGVzWEwAAAASAAACnGRlREUAAAAQAAADzmVuVVMAAAASAAAD3nB0QlIAAAAYAAAD8HBsUEwAAAASAAAECGVsR1IAAAAiAAAEGnN2U0UAAAAQAAAEPHRyVFIAAAAUAAAETHB0UFQAAAAWAAAEYGphSlAAAAAMAAAEdgBMAEMARAAgAHUAIABiAG8AagBpzuy37AAgAEwAQwBEAEYAYQByAGcAZQAtAEwAQwBEAEwAQwBEACAAVwBhAHIAbgBhAFMAegDtAG4AZQBzACAATABDAEQAQgBhAHIAZQB2AG4A_QAgAEwAQwBEAEwAQwBEAC0AZgBhAHIAdgBlAHMAawDmAHIAbQBLAGwAZQB1AHIAZQBuAC0ATABDAEQAVgDkAHIAaQAtAEwAQwBEAEwAQwBEACAAYwBvAGwAbwByAGkATABDAEQAIABjAG8AbABvAHIAQQBDAEwAIABjAG8AdQBsAGUAdQByIA8ATABDAEQAIAZFBkQGSAZGBikEGgQ-BDsETAQ-BEAEPgQyBDgEOQAgAEwAQwBEIA8ATABDAEQAIAXmBdEF4gXVBeAF2V9pgnIATABDAEQATABDAEQAIABNAOAAdQBGAGEAcgBlAGIAbgD9ACAATABDAEQEJgQyBDUEQgQ9BD4EOQAgBBYEGgAtBDQEOARBBD8EOwQ1BDkAQwBvAGwAbwB1AHIAIABMAEMARABMAEMARAAgAGMAbwB1AGwAZQB1AHIAVwBhAHIAbgBhACAATABDAEQJMAkCCRcJQAkoACAATABDAEQATABDAEQAIA4qDjUATABDAEQAIABlAG4AIABjAG8AbABvAHIARgBhAHIAYgAtAEwAQwBEAEMAbwBsAG8AcgAgAEwAQwBEAEwAQwBEACAAQwBvAGwAbwByAGkAZABvAEsAbwBsAG8AcgAgAEwAQwBEA4gDswPHA8EDyQO8A7cAIAO_A7gDzAO9A7cAIABMAEMARABGAOQAcgBnAC0ATABDAEQAUgBlAG4AawBsAGkAIABMAEMARABMAEMARAAgAGEAIABDAG8AcgBlAHMwqzDpMPwATABDAEQAAHRleHQAAAAAQ29weXJpZ2h0IEFwcGxlIEluYy4sIDIwMjIAAFhZWiAAAAAAAADzFgABAAAAARbKWFlaIAAAAAAAAHHAAAA5igAAAWdYWVogAAAAAAAAYSMAALnmAAAT9lhZWiAAAAAAAAAj8gAADJAAAL3QY3VydgAAAAAAAAQAAAAABQAKAA8AFAAZAB4AIwAoAC0AMgA2ADsAQABFAEoATwBUAFkAXgBjAGgAbQByAHcAfACBAIYAiwCQAJUAmgCfAKMAqACtALIAtwC8AMEAxgDLANAA1QDbAOAA5QDrAPAA9gD7AQEBBwENARMBGQEfASUBKwEyATgBPgFFAUwBUgFZAWABZwFuAXUBfAGDAYsBkgGaAaEBqQGxAbkBwQHJAdEB2QHhAekB8gH6AgMCDAIUAh0CJgIvAjgCQQJLAlQCXQJnAnECegKEAo4CmAKiAqwCtgLBAssC1QLgAusC9QMAAwsDFgMhAy0DOANDA08DWgNmA3IDfgOKA5YDogOuA7oDxwPTA-AD7AP5BAYEEwQgBC0EOwRIBFUEYwRxBH4EjASaBKgEtgTEBNME4QTwBP4FDQUcBSsFOgVJBVgFZwV3BYYFlgWmBbUFxQXVBeUF9gYGBhYGJwY3BkgGWQZqBnsGjAadBq8GwAbRBuMG9QcHBxkHKwc9B08HYQd0B4YHmQesB78H0gflB_gICwgfCDIIRghaCG4IggiWCKoIvgjSCOcI-wkQCSUJOglPCWQJeQmPCaQJugnPCeUJ-woRCicKPQpUCmoKgQqYCq4KxQrcCvMLCwsiCzkLUQtpC4ALmAuwC8gL4Qv5DBIMKgxDDFwMdQyODKcMwAzZDPMNDQ0mDUANWg10DY4NqQ3DDd4N-A4TDi4OSQ5kDn8Omw62DtIO7g8JDyUPQQ9eD3oPlg-zD88P7BAJECYQQxBhEH4QmxC5ENcQ9RETETERTxFtEYwRqhHJEegSBxImEkUSZBKEEqMSwxLjEwMTIxNDE2MTgxOkE8UT5RQGFCcUSRRqFIsUrRTOFPAVEhU0FVYVeBWbFb0V4BYDFiYWSRZsFo8WshbWFvoXHRdBF2UXiReuF9IX9xgbGEAYZRiKGK8Y1Rj6GSAZRRlrGZEZtxndGgQaKhpRGncanhrFGuwbFBs7G2MbihuyG9ocAhwqHFIcexyjHMwc9R0eHUcdcB2ZHcMd7B4WHkAeah6UHr4e6R8THz4faR-UH78f6iAVIEEgbCCYIMQg8CEcIUghdSGhIc4h-yInIlUigiKvIt0jCiM4I2YjlCPCI_AkHyRNJHwkqyTaJQklOCVoJZclxyX3JicmVyaHJrcm6CcYJ0kneierJ9woDSg_KHEooijUKQYpOClrKZ0p0CoCKjUqaCqbKs8rAis2K2krnSvRLAUsOSxuLKIs1y0MLUEtdi2rLeEuFi5MLoIuty7uLyQvWi-RL8cv_jA1MGwwpDDbMRIxSjGCMbox8jIqMmMymzLUMw0zRjN_M7gz8TQrNGU0njTYNRM1TTWHNcI1_TY3NnI2rjbpNyQ3YDecN9c4FDhQOIw4yDkFOUI5fzm8Ofk6Njp0OrI67zstO2s7qjvoPCc8ZTykPOM9Ij1hPaE94D4gPmA-oD7gPyE_YT-iP-JAI0BkQKZA50EpQWpBrEHuQjBCckK1QvdDOkN9Q8BEA0RHRIpEzkUSRVVFmkXeRiJGZ0arRvBHNUd7R8BIBUhLSJFI10kdSWNJqUnwSjdKfUrESwxLU0uaS-JMKkxyTLpNAk1KTZNN3E4lTm5Ot08AT0lPk0_dUCdQcVC7UQZRUFGbUeZSMVJ8UsdTE1NfU6pT9lRCVI9U21UoVXVVwlYPVlxWqVb3V0RXklfgWC9YfVjLWRpZaVm4WgdaVlqmWvVbRVuVW-VcNVyGXNZdJ114XcleGl5sXr1fD19hX7NgBWBXYKpg_GFPYaJh9WJJYpxi8GNDY5dj62RAZJRk6WU9ZZJl52Y9ZpJm6Gc9Z5Nn6Wg_aJZo7GlDaZpp8WpIap9q92tPa6dr_2xXbK9tCG1gbbluEm5rbsRvHm94b9FwK3CGcOBxOnGVcfByS3KmcwFzXXO4dBR0cHTMdSh1hXXhdj52m3b4d1Z3s3gReG54zHkqeYl553pGeqV7BHtje8J8IXyBfOF9QX2hfgF-Yn7CfyN_hH_lgEeAqIEKgWuBzYIwgpKC9INXg7qEHYSAhOOFR4Wrhg6GcobXhzuHn4gEiGmIzokziZmJ_opkisqLMIuWi_yMY4zKjTGNmI3_jmaOzo82j56QBpBukNaRP5GokhGSepLjk02TtpQglIqU9JVflcmWNJaflwqXdZfgmEyYuJkkmZCZ_JpomtWbQpuvnByciZz3nWSd0p5Anq6fHZ-Ln_qgaaDYoUehtqImopajBqN2o-akVqTHpTilqaYapoum_adup-CoUqjEqTepqaocqo-rAqt1q-msXKzQrUStuK4trqGvFq-LsACwdbDqsWCx1rJLssKzOLOutCW0nLUTtYq2AbZ5tvC3aLfguFm40blKucK6O7q1uy67p7whvJu9Fb2Pvgq-hL7_v3q_9cBwwOzBZ8Hjwl_C28NYw9TEUcTOxUvFyMZGxsPHQce_yD3IvMk6ybnKOMq3yzbLtsw1zLXNNc21zjbOts83z7jQOdC60TzRvtI_0sHTRNPG1EnUy9VO1dHWVdbY11zX4Nhk2OjZbNnx2nba-9uA3AXcit0Q3ZbeHN6i3ynfr-A24L3hROHM4lPi2-Nj4-vkc-T85YTmDeaW5x_nqegy6LzpRunQ6lvq5etw6_vshu0R7ZzuKO6070DvzPBY8OXxcvH_8ozzGfOn9DT0wvVQ9d72bfb794r4Gfio-Tj5x_pX-uf7d_wH_Jj9Kf26_kv-3P9t__9wYXJhAAAAAAADAAAAAmZmAADypwAADVkAABPQAAAKW3ZjZ3QAAAAAAAAAAQABAAAAAAAAAAEAAAABAAAAAAAAAAEAAAABAAAAAAAAAAEAAG5kaW4AAAAAAAAANgAAp0AAAFWAAABMwAAAnsAAACWAAAAMwAAAUAAAAFRAAAIzMwACMzMAAjMzAAAAAAAAAABzZjMyAAAAAAABDHIAAAX4___zHQAAB7oAAP1y___7nf___aQAAAPZAADAcW1tb2QAAAAAAAAGEAAAoC8AAAAA0OXf8AAAAAAAAAAAAAAAAAAAAAB2Y2dwAAAAAAADAAAAAmZmAAMAAAACZmYAAwAAAAJmZgAAAAIzMzQAAAAAAjMzNAAAAAACMzM0AP_bAEMABQMEBAQDBQQEBAUFBQYHDAgHBwcHDwsLCQwRDxISEQ8RERMWHBcTFBoVEREYIRgaHR0fHx8TFyIkIh4kHB4fHv_AAAsIADcAeAEBEQD_xAAfAAABBQEBAQEBAQAAAAAAAAAAAQIDBAUGBwgJCgv_xAC1EAACAQMDAgQDBQUEBAAAAX0BAgMABBEFEiExQQYTUWEHInEUMoGRoQgjQrHBFVLR8CQzYnKCCQoWFxgZGiUmJygpKjQ1Njc4OTpDREVGR0hJSlNUVVZXWFlaY2RlZmdoaWpzdHV2d3h5eoOEhYaHiImKkpOUlZaXmJmaoqOkpaanqKmqsrO0tba3uLm6wsPExcbHyMnK0tPU1dbX2Nna4eLj5OXm5-jp6vHy8_T19vf4-fr_2gAIAQEAAD8A-y6KM0UUUUUUUUUUUUVzN7qGt6zeTWPh1oLO1gdorjVJ4_M-ccMkMeQGYHgux2qeMOQwEkPhGzILX-qa5qExOTLLqcsf5JEURfwUUeEJnF_rGnRXk99Y2U6RxTTv5jo5XMkW88uE-Xk5ILFSTt46KiiiiiiiiiiiuN02-u_CFt_ZmradcS6VAW8jU7VDKojJJAnjX50YZ5cBlONxK5wOssbq1vrSK7sriG5tplDxSxOHR1PQgjgj3qsH03RoLSyiiW2ilm8mCOKI7d5DN_COM4Y5OBnvk1eLKMZIGeBmkikjlTfG6uuSMqcjg4P61Ct9ZtPHALuAyy7vLQSDc-04bAzzjvjpU7MqjLEAe9NuI_Nt5IhI8e9Su9Dhlz3B9a4KxsdZ1LxbeT6X4hm-xaKfssS30QuI5LllBlPylGIRSiAljy0npXVaJHrBuZpdU1SyuAg8sQWluY1RupLFmZi2MccAA985rWpkMscyCSJ1dD0ZTkGnBgSQCMjqKWioL-7tbG0kuryeO3gjGXkkbaq_jXn_AILGpTeIfFEPhuJdM0Z72OQPdwtlLhog0_lQ8bQ2Y2O4jDs5Kkk1pDTtc0rxfPqKLquuK-npBbGW8jjhjlMjGUuvATIEWCqMcA9-tTWNB8m_GueK9aW6eTZHDbQWp3RvyfKtsEtljglgPMOPvKoAWpp9p4kuvB-sSaAq6VFNc3P9m2UBQOrFtgLyDKxqHDOQgJxnDZ4q2nhBtBbw9_ZmmnU5LAs89wWRJHkWFo4wWY5WP95IxC7jnnDFmJuar4R1XWr2K-1jVoH2RsEsVtg8EDE_eTf1kx8u91bAJ2qmTmr4Qg8Rad4LtNItrK-hvIYC99f6i4llknOWkKDcTIxcnBYhQMfextqHwVdXqeCtM0Xw6xvNRa3Et_qdwpaGKeT95KzHjzJS7N8i9D97bwDFpdj4qtPh7Lovh6ynsNY-ySyXOp3_AJZea9dSXdVBId2kJO5sIBjAYDbV7VIvFdx4NbSPDFvdaS8dqltDeX8iyXO44Tfjcw-XJdnYknBAUkgi_pnguHQ9NWHQdS1GznS2ETHzw6Tsq4VnWRWVTxyVAOOOgFY3gjw5q-l28htra9g1S8iT-0dV1a5WeR5OSxjjRiD8xOMlVUYABAC132n232SzitvPnnMa4Msz7nc9yT6n2wPQAVPXAWGoP4hdNQs_Lv8AUZCWtI2G620pCSA8uODNjkr97J2jau5j2WiabBpWmx2cBd9pLSSOcvK7El3Y92ZiSfrSa5qcGlac93MryHISKKMZeaRjhUUdyTx6dzgAmuR1v7fafZ4xKk3i_Wg0FqyjdHp8XBldAeiRqQS3WRygONyhey0mxt9M0u2060UrBbRLFGCcnaowMnueOTVqiiiiiiiiigADoKK5XxLJew-J7W6TSb3Ult7VjYxQgBDcOSrM7k4TanAY9nfAJ4q34X0KayuLjWNXmju9bvQFnlQHy4YwSVgiB5Ea5JyeWYlj1AG_RRRRRRRRRRRRRRRRRRRRRRRRX__Z" const val AGE_IN_YEARS = 53 const val AGE_BIRTH_YEAR = 1971 const val AGE_OVER = true // Generic "age over" value @@ -63,8 +63,4 @@ internal object SampleData { const val RESIDENT_STATE = "Sample State" const val RESIDENT_COUNTRY = "ZZ" // Note: ZZ is a user-assigned country-code as per ISO 3166-1 - // TODO - //val portrait - //val signatureUsualMark - } \ No newline at end of file diff --git a/identity-doctypes/src/commonMain/kotlin/com/android/identity/documenttype/knowntypes/UtopiaNaturalization.kt b/identity-doctypes/src/commonMain/kotlin/com/android/identity/documenttype/knowntypes/UtopiaNaturalization.kt index c56c36b1c..26e58c1bd 100644 --- a/identity-doctypes/src/commonMain/kotlin/com/android/identity/documenttype/knowntypes/UtopiaNaturalization.kt +++ b/identity-doctypes/src/commonMain/kotlin/com/android/identity/documenttype/knowntypes/UtopiaNaturalization.kt @@ -5,6 +5,7 @@ import com.android.identity.cbor.toDataItemFullDate import com.android.identity.documenttype.DocumentAttributeType import com.android.identity.documenttype.DocumentType import com.android.identity.documenttype.Icon +import kotlinx.datetime.LocalDate /** * Naturalization Certificate of the fictional State of Utopia. @@ -40,7 +41,7 @@ object UtopiaNaturalization { "Date of Birth", "Day, month, and year on which the naturalized person was born. If unknown, approximate date of birth.", Icon.TODAY, - SampleData.birthDate.toDataItemFullDate() + LocalDate.parse(SampleData.BIRTH_DATE).toDataItemFullDate() ) .addVcAttribute( DocumentAttributeType.Date, @@ -48,7 +49,7 @@ object UtopiaNaturalization { "Date of Naturalization", "Date (and possibly time) when the person was naturalized.", Icon.DATE_RANGE, - SampleData.issueDate.toDataItemFullDate() + LocalDate.parse(SampleData.ISSUE_DATE).toDataItemFullDate() ) .addSampleRequest( id = "full", diff --git a/identity-mdoc/build.gradle.kts b/identity-mdoc/build.gradle.kts index 95a3b2ba2..3fd28d34f 100644 --- a/identity-mdoc/build.gradle.kts +++ b/identity-mdoc/build.gradle.kts @@ -1,5 +1,11 @@ +import org.gradle.kotlin.dsl.dependencies +import org.gradle.kotlin.dsl.implementation +import org.jetbrains.kotlin.gradle.ExperimentalKotlinGradlePluginApi +import org.jetbrains.kotlin.gradle.dsl.JvmTarget + plugins { alias(libs.plugins.kotlinMultiplatform) + alias(libs.plugins.androidLibrary) id("maven-publish") } @@ -11,6 +17,13 @@ kotlin { jvm() + androidTarget { + @OptIn(ExperimentalKotlinGradlePluginApi::class) + compilerOptions { + jvmTarget.set(JvmTarget.JVM_17) + } + } + listOf( iosX64(), iosArm64(), @@ -35,6 +48,7 @@ kotlin { implementation(project(":identity")) implementation(libs.kotlinx.datetime) implementation(libs.kotlinx.io.bytestring) + implementation(libs.kotlinx.coroutines.core) } } @@ -59,6 +73,39 @@ kotlin { implementation(libs.bouncy.castle.bcpkix) } } + + val androidMain by getting { + dependencies { + implementation(libs.bouncy.castle.bcprov) + implementation(libs.bouncy.castle.bcpkix) + implementation(libs.tink) + implementation(libs.volley) + } + } + } +} + +android { + namespace = "com.android.identity.mdoc" + compileSdk = libs.versions.android.compileSdk.get().toInt() + + defaultConfig { + minSdk = 26 + testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + } + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } + dependencies { + } + + packaging { + resources { + excludes += listOf("/META-INF/{AL2.0,LGPL2.1}") + excludes += listOf("/META-INF/versions/9/OSGI-INF/MANIFEST.MF") + } } } diff --git a/identity-mdoc/lint.xml b/identity-mdoc/lint.xml new file mode 100644 index 000000000..6f0834d7a --- /dev/null +++ b/identity-mdoc/lint.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/identity-mdoc/src/androidMain/kotlin/com/android/identity/mdoc/transport/BleCentralManagerAndroid.kt b/identity-mdoc/src/androidMain/kotlin/com/android/identity/mdoc/transport/BleCentralManagerAndroid.kt new file mode 100644 index 000000000..dc73526ec --- /dev/null +++ b/identity-mdoc/src/androidMain/kotlin/com/android/identity/mdoc/transport/BleCentralManagerAndroid.kt @@ -0,0 +1,738 @@ +package com.android.identity.mdoc.transport + +import android.bluetooth.BluetoothDevice +import android.bluetooth.BluetoothGatt +import android.bluetooth.BluetoothGattCallback +import android.bluetooth.BluetoothGattCharacteristic +import android.bluetooth.BluetoothGattDescriptor +import android.bluetooth.BluetoothGattService +import android.bluetooth.BluetoothManager +import android.bluetooth.BluetoothProfile +import android.bluetooth.BluetoothSocket +import android.bluetooth.BluetoothStatusCodes +import android.bluetooth.le.ScanCallback +import android.bluetooth.le.ScanFilter +import android.bluetooth.le.ScanResult +import android.bluetooth.le.ScanSettings +import android.os.Build +import android.os.ParcelUuid +import androidx.annotation.RequiresApi +import com.android.identity.cbor.Bstr +import com.android.identity.cbor.Cbor +import com.android.identity.cbor.Tagged +import com.android.identity.crypto.Algorithm +import com.android.identity.crypto.Crypto +import com.android.identity.crypto.EcPublicKey +import com.android.identity.util.AndroidInitializer +import com.android.identity.util.Logger +import com.android.identity.util.UUID +import com.android.identity.util.toHex +import com.android.identity.util.toJavaUuid +import kotlinx.coroutines.CancellableContinuation +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.suspendCancellableCoroutine +import kotlinx.coroutines.withTimeoutOrNull +import kotlinx.io.bytestring.ByteStringBuilder +import java.io.InputStream +import kotlin.coroutines.resume +import kotlin.coroutines.resumeWithException +import kotlin.math.min +import kotlin.time.Duration.Companion.milliseconds +import kotlin.time.Duration.Companion.seconds + +internal class BleCentralManagerAndroid: BleCentralManager { + companion object { + private const val TAG = "BleCentralManagerAndroid" + } + + private lateinit var stateCharacteristicUuid: UUID + private lateinit var client2ServerCharacteristicUuid: UUID + private lateinit var server2ClientCharacteristicUuid: UUID + private var identCharacteristicUuid: UUID? = null + private var l2capCharacteristicUuid: UUID? = null + + override fun setUuids( + stateCharacteristicUuid: UUID, + client2ServerCharacteristicUuid: UUID, + server2ClientCharacteristicUuid: UUID, + identCharacteristicUuid: UUID?, + l2capCharacteristicUuid: UUID?, + ) { + this.stateCharacteristicUuid = stateCharacteristicUuid + this.client2ServerCharacteristicUuid = client2ServerCharacteristicUuid + this.server2ClientCharacteristicUuid = server2ClientCharacteristicUuid + this.identCharacteristicUuid = identCharacteristicUuid + this.l2capCharacteristicUuid = l2capCharacteristicUuid + } + + private lateinit var onError: (error: Throwable) -> Unit + private lateinit var onClosed: () -> Unit + + override fun setCallbacks(onError: (Throwable) -> Unit, onClosed: () -> Unit) { + this.onError = onError + this.onClosed = onClosed + } + + internal enum class WaitState { + PERIPHERAL_DISCOVERED, + CONNECT_TO_PERIPHERAL, + REQUEST_MTU, + PERIPHERAL_DISCOVER_SERVICES, + GET_READER_IDENT, + GET_L2CAP_PSM, + WRITE_TO_DESCRIPTOR, + CHARACTERISTIC_WRITE_COMPLETED, + } + + private data class WaitFor( + val state: WaitState, + val continuation: CancellableContinuation, + ) + + private var waitFor: WaitFor? = null + + private fun setWaitCondition( + state: WaitState, + continuation: CancellableContinuation, + ) { + check(waitFor == null) + waitFor = WaitFor( + state, + continuation, + ) + } + + private fun clearWaitCondition() { + waitFor = null + } + + private fun resumeWait() { + val continuation = waitFor!!.continuation + waitFor = null + continuation.resume(true) + } + + private fun resumeWaitWithException(exception: Throwable) { + val continuation = waitFor!!.continuation + waitFor = null + continuation.resumeWithException(exception) + } + + override val incomingMessages = Channel(Channel.UNLIMITED) + + private val context = AndroidInitializer.applicationContext + private val bluetoothManager = context.getSystemService(BluetoothManager::class.java) + private var device: BluetoothDevice? = null + private var gatt: BluetoothGatt? = null + private var service: BluetoothGattService? = null + + private var characteristicState: BluetoothGattCharacteristic? = null + private var characteristicClient2Server: BluetoothGattCharacteristic? = null + private var characteristicServer2Client: BluetoothGattCharacteristic? = null + private var characteristicIdent: BluetoothGattCharacteristic? = null + private var characteristicL2cap: BluetoothGattCharacteristic? = null + + private class ConnectionFailedException( + message: String + ) : Throwable(message) + + private val scanCallback: ScanCallback = object : ScanCallback() { + override fun onScanResult(callbackType: Int, result: ScanResult) { + Logger.d(TAG, "onScanResult: callbackType=$callbackType result=$result") + try { + if (waitFor?.state == WaitState.PERIPHERAL_DISCOVERED) { + device = result.device + resumeWait() + } else { + Logger.w(TAG, "onScanResult but not waiting") + } + } catch (error: Throwable) { + onError(Error("onScanResult failed", error)) + } + } + + override fun onScanFailed(errorCode: Int) { + Logger.d(TAG, "onScanFailed: errorCode=$errorCode") + try { + if (waitFor?.state == WaitState.PERIPHERAL_DISCOVERED) { + resumeWaitWithException(Error("BLE scan failed with error code $errorCode")) + } else { + Logger.w(TAG, "onScanFailed but not waiting") + } + } catch (error: Throwable) { + onError(Error("onScanFailed failed", error)) + } + } + } + + private val gattCallback = object : BluetoothGattCallback() { + override fun onConnectionStateChange(gatt: BluetoothGatt, status: Int, newState: Int) { + Logger.d(TAG, "onConnectionStateChange: status=$status newState=$newState") + try { + when (newState) { + BluetoothProfile.STATE_CONNECTED -> { + gatt.requestConnectionPriority(BluetoothGatt.CONNECTION_PRIORITY_HIGH) + if (waitFor?.state == WaitState.CONNECT_TO_PERIPHERAL) { + resumeWait() + } else { + Logger.w(TAG, "onConnectionStateChange but not waiting") + } + } + + BluetoothProfile.STATE_DISCONNECTED -> { + if (waitFor?.state == WaitState.CONNECT_TO_PERIPHERAL) { + // See connectToPeripheral() for retries based on this exception + resumeWaitWithException(ConnectionFailedException("Failed to connect to peripheral")) + } else { + throw Error("Peripheral unexpectedly disconnected") + } + } + + else -> { + Logger.w(TAG, "onConnectionStateChange(): Unexpected newState $newState") + } + } + } catch (error: Throwable) { + onError(Error("onConnectionStateChange failed", error)) + } + } + + override fun onMtuChanged(gatt: BluetoothGatt?, mtu: Int, status: Int) { + Logger.d(TAG, "onMtuChanged: mtu=$mtu status=$status") + try { + if (waitFor?.state == WaitState.REQUEST_MTU) { + if (status != BluetoothGatt.GATT_SUCCESS) { + resumeWaitWithException(Error("Expected GATT_SUCCESS but got $status")) + } else { + if (mtu < 22) { + resumeWaitWithException(Error("Unexpected MTU size $mtu")) + } else { + maxCharacteristicSize = min(mtu - 3, 512) + Logger.i( + TAG, + "MTU is $mtu, using $maxCharacteristicSize as maximum data size for characteristics" + ) + resumeWait() + } + } + } else { + Logger.w(TAG, "onMtuChanged but not waiting") + } + } catch (error: Throwable) { + onError(Error("onMtuChanged failed", error)) + } + } + + override fun onServicesDiscovered(gatt: BluetoothGatt, status: Int) { + Logger.d(TAG, "onServicesDiscovered: status=$status") + try { + if (waitFor?.state == WaitState.PERIPHERAL_DISCOVER_SERVICES) { + if (status != BluetoothGatt.GATT_SUCCESS) { + resumeWaitWithException(Error("Expected GATT_SUCCESS but got $status")) + } else { + resumeWait() + } + } else { + Logger.w(TAG, "onServicesDiscovered but not waiting") + } + } catch (error: Throwable) { + onError(Error("onServicesDiscovered failed", error)) + } + } + + override fun onServiceChanged(gatt: BluetoothGatt) { + Logger.d(TAG, "onServiceChanged") + // Receiving this event means that the GATT database is out of sync with the remote device. + // We assume this means that the service has vanished. + // + // If we're using L2CAP, we don't use this signal. Instead we rely on the socket being + // closed. + if (l2capSocket == null) { + onError(Error("onServiceChanged: GATT Service vanished")) + } + } + + override fun onCharacteristicRead( + gatt: BluetoothGatt, + characteristic: BluetoothGattCharacteristic, + value: ByteArray, + status: Int + ) { + Logger.d( + TAG, + "onCharacteristicRead: characteristic=${characteristic.uuid} value=[${value.size} bytes] status=$status" + ) + try { + if (characteristic.uuid == l2capCharacteristicUuid?.toJavaUuid()) { + if (waitFor?.state == WaitState.GET_L2CAP_PSM) { + if (status != BluetoothGatt.GATT_SUCCESS) { + resumeWaitWithException( + Error("onCharacteristicRead: Expected GATT_SUCCESS but got $status") + ) + } else { + if (value.size != 4) { + resumeWaitWithException( + Error("onCharacteristicRead: Expected four bytes for PSM, got ${value.size}") + ) + } + _l2capPsm = ((value[0].toUInt().and(0xffU) shl 24) + + (value[1].toUInt().and(0xffU) shl 16) + + (value[2].toUInt().and(0xffU) shl 8) + + (value[3].toUInt().and(0xffU) shl 0)).toInt() + Logger.i(TAG, "L2CAP PSM is $_l2capPsm") + resumeWait() + } + } else { + Logger.w(TAG, "onCharacteristicRead for L2CAP PSM but not waiting") + } + + } else if (characteristic.uuid == identCharacteristicUuid!!.toJavaUuid()) { + if (waitFor?.state == WaitState.GET_READER_IDENT) { + if (status != BluetoothGatt.GATT_SUCCESS) { + resumeWaitWithException( + Error("onCharacteristicRead: Expected GATT_SUCCESS but got $status") + ) + } + if (expectedIdentValue contentEquals value) { + resumeWait() + } else { + resumeWaitWithException( + Error( + "onCharacteristicRead: Expected ${expectedIdentValue!!.toHex()} " + + "for ident, got ${value.toHex()} instead" + ) + ) + } + } else { + Logger.w(TAG, "onCharacteristicRead for ident but not waiting") + } + } + } catch (error: Throwable) { + onError(Error("onCharacteristicRead failed", error)) + } + } + + override fun onCharacteristicWrite( + gatt: BluetoothGatt?, + characteristic: BluetoothGattCharacteristic?, + status: Int + ) { + Logger.d(TAG, "onCharacteristicWrite: characteristic=${characteristic?.uuid ?: ""} status=$status") + if (waitFor?.state == WaitState.CHARACTERISTIC_WRITE_COMPLETED) { + if (status != BluetoothGatt.GATT_SUCCESS) { + resumeWaitWithException(Error("onCharacteristicWrite: Expected GATT_SUCCESS but got $status")) + } else { + resumeWait() + } + } else { + Logger.w(TAG, "onCharacteristicWrite for characteristic ${characteristic?.uuid} but not waiting") + } + } + + override fun onDescriptorWrite( + gatt: BluetoothGatt?, + descriptor: BluetoothGattDescriptor?, + status: Int + ) { + Logger.d(TAG, "onDescriptorWrite: descriptor=${descriptor?.uuid ?: ""} status=$status") + if (waitFor?.state == WaitState.WRITE_TO_DESCRIPTOR) { + if (status != BluetoothGatt.GATT_SUCCESS) { + resumeWaitWithException(Error("Expected GATT_SUCCESS but got $status")) + } else { + resumeWait() + } + } else { + Logger.w(TAG, "onDescriptorWrite but not waiting") + } + } + + override fun onCharacteristicChanged( + gatt: BluetoothGatt, + characteristic: BluetoothGattCharacteristic, + value: ByteArray + ) { + Logger.d(TAG, "onCharacteristicChanged: characteristic=${characteristic.uuid} value=[${value.size} bytes]") + try { + if (characteristic.uuid == server2ClientCharacteristicUuid.toJavaUuid()) { + handleIncomingData(value) + } else if (characteristic.uuid == stateCharacteristicUuid.toJavaUuid()) { + // Transport-specific session termination as per 18013-5 clause 8.3.3.1.1.5 Connection state + if (value contentEquals byteArrayOf(BleTransportConstants.STATE_CHARACTERISTIC_END.toByte())) { + Logger.i(TAG, "Received transport-specific termination message") + runBlocking { + incomingMessages.send(byteArrayOf()) + } + } else { + Logger.w(TAG, "Ignoring unexpected write to state characteristic") + } + } + } catch (error: Throwable) { + onError(Error("onCharacteristicChanged failed", error)) + } + } + } + + var incomingMessage = ByteStringBuilder() + + var maxCharacteristicSize = -1 + + private fun handleIncomingData(chunk: ByteArray) { + if (chunk.size < 1) { + throw Error("Invalid data length ${chunk.size} for Server2Client characteristic") + } + incomingMessage.append(chunk, 1, chunk.size) + when { + chunk[0].toInt() == 0x00 -> { + // Last message. + val newMessage = incomingMessage.toByteString().toByteArray() + incomingMessage = ByteStringBuilder() + runBlocking { + incomingMessages.send(newMessage) + } + } + + chunk[0].toInt() == 0x01 -> { + if (chunk.size != maxCharacteristicSize) { + // Because this is not fatal and some buggy implementations do this, we treat + // it just as a warning, not an error. + Logger.w( + TAG, "Server2Client received ${chunk.size} bytes which is not the " + + "expected $maxCharacteristicSize bytes" + ) + } + } + + else -> { + throw Error( + "Invalid first byte ${chunk[0]} in Server2Client data chunk, " + + "expected 0 or 1" + ) + } + } + } + + + override suspend fun waitForPowerOn() { + // Not needed on Android + return + } + + override suspend fun waitForPeripheralWithUuid(uuid: UUID) { + + // The Android Bluetooth stack has protection built-in against applications scanning + // too frequently. The way this works is that Android will simply not report + // anything back to the app but will print + // + // App 'com.example.appname' is scanning too frequently + // + // to logcat. We work around this by retrying the scan operation if we haven't + // gotten a result in 10 seconds. + // + var retryCount = 0 + while (true) { + val rc = withTimeoutOrNull(10.seconds) { + suspendCancellableCoroutine { continuation -> + setWaitCondition(WaitState.PERIPHERAL_DISCOVERED, continuation) + val filter = ScanFilter.Builder() + .setServiceUuid(ParcelUuid(uuid.toJavaUuid())) + .build() + val settings = ScanSettings.Builder() + .setCallbackType(ScanSettings.CALLBACK_TYPE_ALL_MATCHES) + .setScanMode(ScanSettings.SCAN_MODE_LOW_LATENCY) + .build() + bluetoothManager.adapter.bluetoothLeScanner + .startScan(listOf(filter), settings, scanCallback) + } + true + } + bluetoothManager.adapter.bluetoothLeScanner.stopScan(scanCallback) + if (rc != null) { + break + } + clearWaitCondition() + retryCount++ + // Note: we never give up b/c it's possible this is used by a wallet app which is simply + // sitting at the "Show QR code" dialog. + // + Logger.i(TAG, "Failed to find peripheral after $retryCount attempt(s) of 10 secs. Restarting scan.") + } + } + + override suspend fun connectToPeripheral() { + check(device != null) + + // Connection attempts sometimes fail... but will work on subsequent + // tries. So we implement a simple retry loop. + var retryCount = 0 + while (true) { + try { + suspendCancellableCoroutine { continuation -> + setWaitCondition(WaitState.CONNECT_TO_PERIPHERAL, continuation) + gatt = device!!.connectGatt(context, false, gattCallback, BluetoothDevice.TRANSPORT_LE) + } + break + } catch (error: ConnectionFailedException) { + if (retryCount < 10) { + retryCount++ + Logger.w(TAG, "Failed connecting to peripheral after $retryCount attempt(s), retrying in 500 msec") + delay(500.milliseconds) + } else { + Logger.w(TAG, "Failed connecting to peripheral after $retryCount attempts. Giving up.") + throw error + } + } + } + } + + override suspend fun requestMtu() { + check(device != null && gatt != null) + suspendCancellableCoroutine { continuation -> + setWaitCondition(WaitState.REQUEST_MTU, continuation) + // 515 is the largest MTU that makes sense given that characteristics must be 512 + // bytes or less. + gatt!!.requestMtu(515) + } + } + + override suspend fun peripheralDiscoverServices(uuid: UUID) { + check(device != null && gatt != null) + suspendCancellableCoroutine { continuation -> + setWaitCondition(WaitState.PERIPHERAL_DISCOVER_SERVICES, continuation) + gatt!!.discoverServices() + } + service = gatt!!.getService(uuid.toJavaUuid()) + if (service == null) { + throw Error("No service with the given UUID") + } + } + + override suspend fun peripheralDiscoverCharacteristics() { + check(device != null && gatt != null && service != null) + characteristicState = service!!.getCharacteristic(stateCharacteristicUuid.toJavaUuid()) + if (characteristicState == null) { + throw Error("State characteristic not found") + } + characteristicClient2Server = + service!!.getCharacteristic(client2ServerCharacteristicUuid.toJavaUuid()) + if (characteristicClient2Server == null) { + throw Error("Client2Server characteristic not found") + } + characteristicServer2Client = + service!!.getCharacteristic(server2ClientCharacteristicUuid.toJavaUuid()) + if (characteristicServer2Client == null) { + throw Error("Server2Client characteristic not found") + } + if (identCharacteristicUuid != null) { + characteristicIdent = service!!.getCharacteristic(identCharacteristicUuid!!.toJavaUuid()) + if (characteristicIdent == null) { + throw Error("Ident characteristic not found") + } + } + if (l2capCharacteristicUuid != null) { + characteristicL2cap = service!!.getCharacteristic(l2capCharacteristicUuid!!.toJavaUuid()) + if (characteristicL2cap == null) { + Logger.i(TAG, "L2CAP characteristic requested but not found") + } else { + suspendCancellableCoroutine { continuation -> + setWaitCondition(WaitState.GET_L2CAP_PSM, continuation) + gatt!!.readCharacteristic(characteristicL2cap) + } + } + } + } + + var expectedIdentValue: ByteArray? = null + + override suspend fun checkReaderIdentMatches(eSenderKey: EcPublicKey) { + check(device != null && gatt != null && identCharacteristicUuid != null) + + val ikm = Cbor.encode(Tagged(24, Bstr(Cbor.encode(eSenderKey.toCoseKey().toDataItem())))) + val info = "BLEIdent".encodeToByteArray() + val salt = byteArrayOf() + expectedIdentValue = Crypto.hkdf(Algorithm.HMAC_SHA256, ikm, salt, info, 16) + + suspendCancellableCoroutine { continuation -> + setWaitCondition(WaitState.GET_READER_IDENT, continuation) + gatt!!.readCharacteristic(characteristicIdent) + } + } + + private suspend fun enableNotifications( + characteristic: BluetoothGattCharacteristic + ) { + suspendCancellableCoroutine { continuation -> + setWaitCondition(WaitState.WRITE_TO_DESCRIPTOR, continuation) + + // This is what the 16-bit UUID 0x29 0x02 is encoded like. + val clientCharacteristicConfigUuid = UUID.fromString("00002902-0000-1000-8000-00805f9b34fb") + + if (!gatt!!.setCharacteristicNotification(characteristic, true)) { + throw Error("Error setting notification") + } + val descriptor = characteristic.getDescriptor(clientCharacteristicConfigUuid.toJavaUuid()) + if (descriptor == null) { + throw Error("Error getting clientCharacteristicConfig descriptor") + } + descriptor.setValue(BluetoothGattDescriptor.ENABLE_NOTIFICATION_VALUE) + if (!gatt!!.writeDescriptor(descriptor)) { + throw Error("Error writing to clientCharacteristicConfig descriptor") + } + } + } + + override suspend fun subscribeToCharacteristics() { + enableNotifications(characteristicState!!) + enableNotifications(characteristicServer2Client!!) + } + + private suspend fun writeToCharacteristic( + characteristic: BluetoothGattCharacteristic, + value: ByteArray, + ) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { + val rc = gatt!!.writeCharacteristic( + characteristic, + value, + BluetoothGattCharacteristic.WRITE_TYPE_NO_RESPONSE + ) + if (rc != BluetoothStatusCodes.SUCCESS) { + throw Error("Error writing to characteristic ${characteristic.uuid}, rc=$rc") + } + } else { + characteristic!!.setValue(value) + if (!gatt!!.writeCharacteristic(characteristic)) { + throw Error("Error writing to characteristic ${characteristic.uuid}") + } + } + suspendCancellableCoroutine { continuation -> + setWaitCondition(WaitState.CHARACTERISTIC_WRITE_COMPLETED, continuation) + } + } + + override suspend fun writeToStateCharacteristic(value: Int) { + writeToCharacteristic(characteristicState!!, byteArrayOf(value.toByte())) + } + + override suspend fun sendMessage(message: ByteArray) { + if (l2capSocket != null) { + l2capSendMessage(message) + return + } + Logger.i(TAG, "sendMessage ${message.size} length") + val maxChunkSize = maxCharacteristicSize - 1 // Need room for the leading 0x00 or 0x01 + val offsets = 0 until message.size step maxChunkSize + for (offset in offsets) { + val moreDataComing = (offset != offsets.last) + val size = min(maxChunkSize, message.size - offset) + + val builder = ByteStringBuilder(size + 1) + builder.append(if (moreDataComing) 0x01 else 0x00) + builder.append(message, offset, offset + size) + val chunk = builder.toByteString().toByteArray() + + writeToCharacteristic(characteristicClient2Server!!, chunk) + } + Logger.i(TAG, "sendMessage completed") + } + + override fun close() { + Logger.d(TAG, "close()") + bluetoothManager.adapter.bluetoothLeScanner.stopScan(scanCallback) + gatt?.disconnect() + gatt?.close() + gatt = null + device = null + // Needed since l2capSocket.outputStream.flush() isn't working + l2capSocket?.let { + CoroutineScope(Dispatchers.IO).launch() { + delay(5000) + it.close() + } + } + l2capSocket = null + incomingMessages.close() + } + + private var _l2capPsm: Int? = null + + override val l2capPsm: Int? + get() = _l2capPsm + + override val usingL2cap: Boolean + get() = (l2capSocket != null) + + private var l2capSocket: BluetoothSocket? = null + + override suspend fun connectL2cap(psm: Int) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + connectL2capQ(psm) + } else { + throw IllegalStateException("L2CAP only support on API 29 or later") + } + } + + @RequiresApi(api = Build.VERSION_CODES.Q) + private suspend fun connectL2capQ(psm: Int) { + l2capSocket = device!!.createInsecureL2capChannel(psm) + l2capSocket!!.connect() + + // Start reading in a coroutine + CoroutineScope(Dispatchers.IO).launch { + l2capReadSocket(l2capSocket!!.inputStream) + } + } + + private suspend fun l2capReadSocket(inputStream: InputStream) { + try { + while (true) { + val encodedLength = inputStream.readNOctets(4U) + val length = (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) + val message = inputStream.readNOctets(length) + incomingMessages.send(message) + } + } catch (e: Throwable) { + onError(Error("Reading from L2CAP socket failed", e)) + } + } + + private suspend fun l2capSendMessage(message: ByteArray) { + Logger.i(TAG, "l2capSendMessage ${message.size} length") + val bsb = ByteStringBuilder() + val length = message.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(message) + l2capSocket?.outputStream?.write(bsb.toByteString().toByteArray()) + l2capSocket?.outputStream?.flush() + } +} + +// 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.toInt() + while (remaining > 0) { + val buf = ByteArray(remaining.toInt()) + val numBytesRead = this.read(buf, 0, remaining) + if (numBytesRead == -1) { + throw IllegalStateException("Failed reading from input stream") + } + bsb.append(buf, 0, numBytesRead) + remaining -= numBytesRead + } + return bsb.toByteString().toByteArray() +} + diff --git a/identity-mdoc/src/androidMain/kotlin/com/android/identity/mdoc/transport/BlePeripheralManagerAndroid.kt b/identity-mdoc/src/androidMain/kotlin/com/android/identity/mdoc/transport/BlePeripheralManagerAndroid.kt new file mode 100644 index 000000000..995a7fa38 --- /dev/null +++ b/identity-mdoc/src/androidMain/kotlin/com/android/identity/mdoc/transport/BlePeripheralManagerAndroid.kt @@ -0,0 +1,616 @@ +package com.android.identity.mdoc.transport + +import android.bluetooth.BluetoothDevice +import android.bluetooth.BluetoothGatt +import android.bluetooth.BluetoothGattCharacteristic +import android.bluetooth.BluetoothGattDescriptor +import android.bluetooth.BluetoothGattServer +import android.bluetooth.BluetoothGattServerCallback +import android.bluetooth.BluetoothGattService +import android.bluetooth.BluetoothManager +import android.bluetooth.BluetoothServerSocket +import android.bluetooth.BluetoothSocket +import android.bluetooth.BluetoothStatusCodes +import android.bluetooth.le.AdvertiseCallback +import android.bluetooth.le.AdvertiseData +import android.bluetooth.le.AdvertiseSettings +import android.bluetooth.le.BluetoothLeAdvertiser +import android.os.Build +import android.os.ParcelUuid +import com.android.identity.cbor.Bstr +import com.android.identity.cbor.Cbor +import com.android.identity.cbor.Tagged +import com.android.identity.crypto.Algorithm +import com.android.identity.crypto.Crypto +import com.android.identity.crypto.EcPublicKey +import com.android.identity.util.AndroidInitializer +import com.android.identity.util.Logger +import com.android.identity.util.UUID +import com.android.identity.util.toHex +import com.android.identity.util.toJavaUuid +import kotlinx.coroutines.CancellableContinuation +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.suspendCancellableCoroutine +import kotlinx.io.bytestring.ByteStringBuilder +import java.io.OutputStream +import kotlin.coroutines.resume +import kotlin.coroutines.resumeWithException +import kotlin.math.min + +internal class BlePeripheralManagerAndroid: BlePeripheralManager { + companion object { + private const val TAG = "BlePeripheralManagerAndroid" + } + + private lateinit var stateCharacteristicUuid: UUID + private lateinit var client2ServerCharacteristicUuid: UUID + private lateinit var server2ClientCharacteristicUuid: UUID + private var identCharacteristicUuid: UUID? = null + private var l2capCharacteristicUuid: UUID? = null + + private var negotiatedMtu = -1 + private var maxCharacteristicSizeMemoized = 0 + private val maxCharacteristicSize: Int + get() { + if (maxCharacteristicSizeMemoized > 0) { + return maxCharacteristicSizeMemoized + } + var mtuSize = negotiatedMtu + if (mtuSize == -1) { + Logger.w(TAG, "MTU not negotiated, defaulting to 23. Performance will suffer.") + mtuSize = 23 + } + maxCharacteristicSizeMemoized = min(512, mtuSize - 3) + Logger.i(TAG, "Using maxCharacteristicSize $maxCharacteristicSizeMemoized") + return maxCharacteristicSizeMemoized + } + + override fun setUuids( + stateCharacteristicUuid: UUID, + client2ServerCharacteristicUuid: UUID, + server2ClientCharacteristicUuid: UUID, + identCharacteristicUuid: UUID?, + l2capCharacteristicUuid: UUID? + ) { + this.stateCharacteristicUuid = stateCharacteristicUuid + this.client2ServerCharacteristicUuid = client2ServerCharacteristicUuid + this.server2ClientCharacteristicUuid = server2ClientCharacteristicUuid + this.identCharacteristicUuid = identCharacteristicUuid + this.l2capCharacteristicUuid = l2capCharacteristicUuid + } + + private lateinit var onError: (error: Throwable) -> Unit + private lateinit var onClosed: () -> Unit + + override fun setCallbacks(onError: (Throwable) -> Unit, onClosed: () -> Unit) { + this.onError = onError + this.onClosed = onClosed + } + + internal enum class WaitState { + SERVICE_ADDED, + STATE_CHARACTERISTIC_WRITTEN_OR_L2CAP_CLIENT, + START_ADVERTISING, + CHARACTERISTIC_WRITE_COMPLETED, + } + + private data class WaitFor( + val state: WaitState, + val continuation: CancellableContinuation, + ) + + private var waitFor: WaitFor? = null + + private fun setWaitCondition( + state: WaitState, + continuation: CancellableContinuation + ) { + check(waitFor == null) + waitFor = WaitFor( + state, + continuation, + ) + } + + private fun resumeWait() { + val continuation = waitFor!!.continuation + waitFor = null + continuation.resume(true) + } + + private fun resumeWaitWithException(exception: Throwable) { + val continuation = waitFor!!.continuation + waitFor = null + continuation.resumeWithException(exception) + } + + override val incomingMessages = Channel(Channel.UNLIMITED) + + private val context = AndroidInitializer.applicationContext + private val bluetoothManager = context.getSystemService(BluetoothManager::class.java) + private var gattServer: BluetoothGattServer? = null + private var service: BluetoothGattService? = null + private var readCharacteristic: BluetoothGattCharacteristic? = null + private var writeCharacteristic: BluetoothGattCharacteristic? = null + private var stateCharacteristic: BluetoothGattCharacteristic? = null + private var identCharacteristic: BluetoothGattCharacteristic? = null + private var l2capCharacteristic: BluetoothGattCharacteristic? = null + private var identValue: ByteArray? = null + private var advertiser: BluetoothLeAdvertiser? = null + private var device: BluetoothDevice? = null + + private val gattServerCallback = object: BluetoothGattServerCallback() { + + override fun onServiceAdded(status: Int, service: BluetoothGattService?) { + Logger.d(TAG, "onServiceAdded: $status") + if (waitFor?.state == WaitState.SERVICE_ADDED) { + if (status == BluetoothGatt.GATT_SUCCESS) { + resumeWait() + } else { + resumeWaitWithException(Error("onServiceAdded: Expected GATT_SUCCESS got $status")) + } + } else { + Logger.w(TAG, "onServiceAdded but not waiting") + } + } + + override fun onConnectionStateChange(device: BluetoothDevice, status: Int, newState: Int) { + Logger.d(TAG, "onConnectionStateChange: ${device.address} $status + $newState") + } + + override fun onCharacteristicReadRequest( + device: BluetoothDevice, requestId: Int, offset: Int, + characteristic: BluetoothGattCharacteristic + ) { + Logger.d(TAG, "onCharacteristicReadRequest: ${device.address} $requestId " + + "$offset ${characteristic.uuid}") + if (characteristic == identCharacteristic) { + if (identValue == null) { + onError(Error("Received request for ident before it's set..")) + } else { + gattServer!!.sendResponse( + device, + requestId, + BluetoothGatt.GATT_SUCCESS, + 0, + identValue ?: byteArrayOf() + ) + } + } else if (characteristic == l2capCharacteristic) { + var encodedPsm = byteArrayOf() + if (l2capServerSocket != null) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + val psm = l2capServerSocket!!.psm.toUInt() + val bsb = ByteStringBuilder() + bsb.apply { + append((psm shr 24).and(0xffU).toByte()) + append((psm shr 16).and(0xffU).toByte()) + append((psm shr 8).and(0xffU).toByte()) + append((psm shr 0).and(0xffU).toByte()) + } + encodedPsm = bsb.toByteString().toByteArray() + } + } else { + Logger.w(TAG, "Got a request for L2CAP characteristic but not listening") + } + gattServer!!.sendResponse( + device, + requestId, + BluetoothGatt.GATT_SUCCESS, + 0, + encodedPsm + ) + } else { + Logger.w(TAG, "Read on unexpected characteristic with UUID ${characteristic.uuid}") + } + + } + + override fun onCharacteristicWriteRequest( + device: BluetoothDevice, + requestId: Int, + characteristic: BluetoothGattCharacteristic, + preparedWrite: Boolean, + responseNeeded: Boolean, + offset: Int, + value: ByteArray + ) { + Logger.d(TAG, "onCharacteristicWriteRequest: ${device.address} $requestId " + + "$offset ${characteristic.uuid} ${value.toHex()}") + + if (responseNeeded) { + gattServer!!.sendResponse( + device, + requestId, + BluetoothGatt.GATT_SUCCESS, + 0, + null + ) + } + + if (characteristic.uuid == stateCharacteristicUuid.toJavaUuid()) { + if (value contentEquals byteArrayOf( + BleTransportConstants.STATE_CHARACTERISTIC_START.toByte() + )) { + if (waitFor?.state == WaitState.STATE_CHARACTERISTIC_WRITTEN_OR_L2CAP_CLIENT) { + this@BlePeripheralManagerAndroid.device = device + // Since the central found us, we can stop advertising.... + advertiser?.stopAdvertising(advertiseCallback) + // Also, stop listening for L2CAP connections... + l2capNotUsed = true + l2capServerSocket?.close() + l2capServerSocket = null + resumeWait() + } + } else if (value contentEquals byteArrayOf(BleTransportConstants.STATE_CHARACTERISTIC_END.toByte())) { + Logger.i(TAG, "Received transport-specific termination message") + runBlocking { + incomingMessages.send(byteArrayOf()) + } + } else { + Logger.w(TAG, "Ignoring unexpected write to state characteristic") + } + } else if (characteristic.uuid == client2ServerCharacteristicUuid.toJavaUuid()) { + try { + handleIncomingData(value) + } catch (e: Throwable) { + onError(Error("Error processing incoming data", e)) + } + } + } + + override fun onDescriptorWriteRequest( + device: BluetoothDevice, requestId: Int, + descriptor: BluetoothGattDescriptor, + preparedWrite: Boolean, responseNeeded: Boolean, + offset: Int, value: ByteArray + ) { + if (Logger.isDebugEnabled) { + Logger.d( + TAG, "onDescriptorWriteRequest: ${device.address}" + + "${descriptor.characteristic.uuid} $offset ${value.toHex()}" + ) + } + if (responseNeeded) { + gattServer!!.sendResponse( + device, + requestId, + BluetoothGatt.GATT_SUCCESS, + 0, + null + ) + } + } + + override fun onMtuChanged(device: BluetoothDevice, mtu: Int) { + negotiatedMtu = mtu + Logger.d(TAG, "Negotiated MTU $mtu for $${device.address}") + } + + override fun onNotificationSent(device: BluetoothDevice, status: Int) { + Logger.d(TAG, "onNotificationSent $status for ${device.address}") + if (waitFor?.state == WaitState.CHARACTERISTIC_WRITE_COMPLETED) { + if (status != BluetoothGatt.GATT_SUCCESS) { + resumeWaitWithException(Error("onNotificationSent: Expected GATT_SUCCESS but got $status")) + } else { + resumeWait() + } + } else { + Logger.w(TAG, "onNotificationSent but not waiting") + } + } + } + + private val advertiseCallback: AdvertiseCallback = object : AdvertiseCallback() { + override fun onStartSuccess(settingsInEffect: AdvertiseSettings) { + if (waitFor?.state == WaitState.START_ADVERTISING) { + resumeWait() + } else { + Logger.w(TAG, "Unexpected AdvertiseCallback.onStartSuccess() callback") + } + } + + override fun onStartFailure(errorCode: Int) { + if (waitFor?.state == WaitState.START_ADVERTISING) { + resumeWaitWithException(Error("Started advertising failed with $errorCode")) + } else { + Logger.w(TAG, "Unexpected AdvertiseCallback.onStartFailure() callback") + } + } + } + + private var incomingMessage = ByteStringBuilder() + + private fun handleIncomingData(chunk: ByteArray) { + if (chunk.size < 1) { + throw Error("Invalid data length ${chunk.size} for Client2Server characteristic") + } + incomingMessage.append(chunk, 1, chunk.size) + when { + chunk[0].toInt() == 0x00 -> { + // Last message. + val newMessage = incomingMessage.toByteString().toByteArray() + incomingMessage = ByteStringBuilder() + runBlocking { + incomingMessages.send(newMessage) + } + } + + chunk[0].toInt() == 0x01 -> { + if (chunk.size != maxCharacteristicSize) { + Logger.w(TAG, "Client2Server received ${chunk.size} bytes which is not the " + + "expected $maxCharacteristicSize bytes") + } + } + + else -> { + throw Error("Invalid first byte ${chunk[0]} in Client2Server data chunk, " + + "expected 0 or 1") + } + } + } + + override suspend fun waitForPowerOn() { + // Not needed on Android + return + } + + // This is what the 16-bit UUID 0x29 0x02 is encoded like. + private var clientCharacteristicConfigUuid = UUID.fromString("00002902-0000-1000-8000-00805f9b34fb") + + private fun addCharacteristic( + characteristicUuid: UUID, + properties: Int, + permissions: Int, + ): BluetoothGattCharacteristic { + val characteristic = BluetoothGattCharacteristic( + characteristicUuid.toJavaUuid(), + properties, + permissions + ) + if (properties and BluetoothGattCharacteristic.PROPERTY_NOTIFY != 0) { + val descriptor = BluetoothGattDescriptor( + clientCharacteristicConfigUuid.toJavaUuid(), + BluetoothGattDescriptor.PERMISSION_WRITE + ) + descriptor.setValue(BluetoothGattDescriptor.DISABLE_NOTIFICATION_VALUE) + characteristic.addDescriptor(descriptor) + } + service!!.addCharacteristic(characteristic) + return characteristic + } + + override suspend fun advertiseService(uuid: UUID) { + gattServer = bluetoothManager.openGattServer(context, gattServerCallback) + service = BluetoothGattService( + uuid.toJavaUuid(), + BluetoothGattService.SERVICE_TYPE_PRIMARY + ) + + stateCharacteristic = addCharacteristic( + characteristicUuid = stateCharacteristicUuid, + properties = BluetoothGattCharacteristic.PROPERTY_NOTIFY + BluetoothGattCharacteristic.PROPERTY_WRITE_NO_RESPONSE, + permissions = BluetoothGattCharacteristic.PERMISSION_WRITE + ) + readCharacteristic = addCharacteristic( + characteristicUuid = client2ServerCharacteristicUuid, + properties = BluetoothGattCharacteristic.PROPERTY_WRITE_NO_RESPONSE, + permissions = BluetoothGattCharacteristic.PERMISSION_WRITE + ) + writeCharacteristic = addCharacteristic( + characteristicUuid = server2ClientCharacteristicUuid, + properties = BluetoothGattCharacteristic.PROPERTY_NOTIFY, + permissions = BluetoothGattCharacteristic.PERMISSION_WRITE + ) + if (identCharacteristicUuid != null) { + identCharacteristic = addCharacteristic( + characteristicUuid = identCharacteristicUuid!!, + properties = BluetoothGattCharacteristic.PROPERTY_READ, + permissions = BluetoothGattCharacteristic.PERMISSION_READ + ) + } + if (l2capCharacteristicUuid != null) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + l2capServerSocket = bluetoothManager.adapter.listenUsingInsecureL2capChannel() + // Listen in a coroutine + CoroutineScope(Dispatchers.IO).launch { + l2capListen() + } + l2capCharacteristic = addCharacteristic( + characteristicUuid = l2capCharacteristicUuid!!, + properties = BluetoothGattCharacteristic.PROPERTY_READ, + permissions = BluetoothGattCharacteristic.PERMISSION_READ + ) + } else { + Logger.w(TAG, "L2CAP only support on API 29 or later") + } + } + suspendCancellableCoroutine { continuation -> + setWaitCondition(WaitState.SERVICE_ADDED, continuation) + + gattServer!!.addService(service!!) + } + + advertiser = bluetoothManager.adapter.bluetoothLeAdvertiser + if (advertiser == null) { + throw Error("Advertiser not available, is Bluetooth off?") + } + val settings = AdvertiseSettings.Builder() + .setAdvertiseMode(AdvertiseSettings.ADVERTISE_MODE_LOW_LATENCY) + .setConnectable(true) + .setTimeout(0) + .setTxPowerLevel(AdvertiseSettings.ADVERTISE_TX_POWER_MEDIUM) + .build() + val data = AdvertiseData.Builder() + .setIncludeTxPowerLevel(false) + .addServiceUuid(ParcelUuid(uuid.toJavaUuid())) + .build() + Logger.d(TAG, "Started advertising UUID $uuid") + suspendCancellableCoroutine { continuation -> + setWaitCondition(WaitState.START_ADVERTISING, continuation) + advertiser!!.startAdvertising(settings, data, advertiseCallback) + } + } + + override suspend fun setESenderKey(eSenderKey: EcPublicKey) { + val ikm = Cbor.encode(Tagged(24, Bstr(Cbor.encode(eSenderKey.toCoseKey().toDataItem())))) + val info = "BLEIdent".encodeToByteArray() + val salt = byteArrayOf() + identValue = Crypto.hkdf(Algorithm.HMAC_SHA256, ikm, salt, info, 16) + } + + override suspend fun waitForStateCharacteristicWriteOrL2CAPClient() { + suspendCancellableCoroutine { continuation -> + setWaitCondition(WaitState.STATE_CHARACTERISTIC_WRITTEN_OR_L2CAP_CLIENT, continuation) + } + } + + override suspend fun sendMessage(message: ByteArray) { + if (l2capSocket != null) { + l2capSendMessage(message) + return + } + Logger.i(TAG, "sendMessage ${message.size} length") + val maxChunkSize = maxCharacteristicSize - 1 // Need room for the leading 0x00 or 0x01 + val offsets = 0 until message.size step maxChunkSize + for (offset in offsets) { + val moreDataComing = (offset != offsets.last) + val size = min(maxChunkSize, message.size - offset) + + val builder = ByteStringBuilder(size + 1) + builder.append(if (moreDataComing) 0x01 else 0x00) + builder.append(message, offset, offset + size) + val chunk = builder.toByteString().toByteArray() + + writeToCharacteristic(writeCharacteristic!!, chunk) + } + Logger.i(TAG, "sendMessage completed") + } + + private suspend fun writeToCharacteristic( + characteristic: BluetoothGattCharacteristic, + value: ByteArray, + ) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { + val rc = gattServer!!.notifyCharacteristicChanged( + device!!, + characteristic, + false, + value) + if (rc != BluetoothStatusCodes.SUCCESS) { + throw Error("Error notifyCharacteristicChanged on characteristic ${characteristic.uuid} rc=$rc") + } + } else { + characteristic.setValue(value) + if (!gattServer!!.notifyCharacteristicChanged( + device!!, + characteristic, + false) + ) { + throw Error("Error notifyCharacteristicChanged on characteristic ${characteristic.uuid}") + } + } + suspendCancellableCoroutine { continuation -> + setWaitCondition(WaitState.CHARACTERISTIC_WRITE_COMPLETED, continuation) + } + } + + override suspend fun writeToStateCharacteristic(value: Int) { + writeToCharacteristic(stateCharacteristic!!, byteArrayOf(value.toByte())) + } + + override fun close() { + device = null + advertiser?.stopAdvertising(advertiseCallback) + advertiser = null + gattServer?.removeService(service) + gattServer?.close() + gattServer = null + service = null + l2capServerSocket?.close() + l2capServerSocket = null + incomingMessages.close() + l2capSocket?.let { + CoroutineScope(Dispatchers.IO).launch() { + delay(5000) + it.close() + } + } + l2capSocket = null + } + + override val usingL2cap: Boolean + get() = (l2capSocket != null) + + override val l2capPsm: Int? + get() { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + return l2capServerSocket?.psm + } else { + return null + } + } + + + private var l2capServerSocket: BluetoothServerSocket? = null + private var l2capSocket: BluetoothSocket? = null + // Set to true iff the other peer ended up using GATT instead of L2CAP + private var l2capNotUsed = false + + private suspend fun l2capListen() { + try { + l2capSocket = l2capServerSocket!!.accept() + + l2capServerSocket?.close() + l2capServerSocket = null + + if (waitFor?.state == WaitState.STATE_CHARACTERISTIC_WRITTEN_OR_L2CAP_CLIENT) { + Logger.i(TAG, "L2CAP connection") + device = l2capSocket!!.remoteDevice + // Since the central found us, we can stop advertising.... + advertiser?.stopAdvertising(advertiseCallback) + resumeWait() + } else { + Logger.w(TAG, "Got a L2CAP client but not waiting") + return + } + + val inputStream = l2capSocket!!.inputStream + while (true) { + val encodedLength = inputStream.readNOctets(4U) + val length = (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) + val message = inputStream.readNOctets(length) + incomingMessages.send(message) + } + } catch (e: Throwable) { + if (l2capNotUsed) { + Logger.d(TAG, "Ignoring error since l2capNotUsed is true", e) + } else { + onError(Error("Accepting/reading from L2CAP socket failed", e)) + } + } + } + + private suspend fun l2capSendMessage(message: ByteArray) { + Logger.i(TAG, "l2capSendMessage ${message.size} length") + val bsb = ByteStringBuilder() + val length = message.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(message) + l2capSocket?.outputStream?.write(bsb.toByteString().toByteArray()) + l2capSocket?.outputStream?.flush() + } +} \ No newline at end of file diff --git a/identity-mdoc/src/androidMain/kotlin/com/android/identity/mdoc/transport/MdocTransportFactory.android.kt b/identity-mdoc/src/androidMain/kotlin/com/android/identity/mdoc/transport/MdocTransportFactory.android.kt new file mode 100644 index 000000000..f863193da --- /dev/null +++ b/identity-mdoc/src/androidMain/kotlin/com/android/identity/mdoc/transport/MdocTransportFactory.android.kt @@ -0,0 +1,68 @@ +package com.android.identity.mdoc.transport + +import com.android.identity.mdoc.connectionmethod.ConnectionMethod +import com.android.identity.mdoc.connectionmethod.ConnectionMethodBle + +actual class MdocTransportFactory { + actual companion object { + actual fun createTransport( + connectionMethod: ConnectionMethod, + role: MdocTransport.Role, + options: MdocTransportOptions, + ): MdocTransport { + when (connectionMethod) { + is ConnectionMethodBle -> { + if (connectionMethod.supportsCentralClientMode && + connectionMethod.supportsPeripheralServerMode) { + throw IllegalArgumentException( + "Only Central Client or Peripheral Server mode is supported at one time, not both" + ) + } else if (connectionMethod.supportsCentralClientMode) { + return when (role) { + MdocTransport.Role.MDOC -> { + BleTransportCentralMdoc( + role, + options, + BleCentralManagerAndroid(), + connectionMethod.centralClientModeUuid!!, + connectionMethod.peripheralServerModePsm + ) + } + MdocTransport.Role.MDOC_READER -> { + BleTransportCentralMdocReader( + role, + options, + BlePeripheralManagerAndroid(), + connectionMethod.centralClientModeUuid!! + ) + } + } + } else { + return when (role) { + MdocTransport.Role.MDOC -> { + BleTransportPeripheralMdoc( + role, + options, + BlePeripheralManagerAndroid(), + connectionMethod.peripheralServerModeUuid!! + ) + } + MdocTransport.Role.MDOC_READER -> { + BleTransportPeripheralMdocReader( + role, + options, + BleCentralManagerAndroid(), + connectionMethod.peripheralServerModeUuid!!, + connectionMethod.peripheralServerModePsm, + ) + } + } + } + } + else -> { + throw IllegalArgumentException("$connectionMethod is not supported") + } + } + } + } +} \ No newline at end of file diff --git a/identity-mdoc/src/commonMain/kotlin/com/android/identity/mdoc/sessionencryption/SessionEncryption.kt b/identity-mdoc/src/commonMain/kotlin/com/android/identity/mdoc/sessionencryption/SessionEncryption.kt index 179ce2946..594290e6c 100644 --- a/identity-mdoc/src/commonMain/kotlin/com/android/identity/mdoc/sessionencryption/SessionEncryption.kt +++ b/identity-mdoc/src/commonMain/kotlin/com/android/identity/mdoc/sessionencryption/SessionEncryption.kt @@ -228,6 +228,18 @@ class SessionEncryption( end() Cbor.encode(end().build()) } + + /** + * Gets the ephemeral reader key in a `SessionEstablishment` message. + * + * @param the bytes of a `SessionEstablishment` message. + * @return the reader key, as a [EcPublicKey]. + */ + fun getEReaderKey(sessionEstablishmentMessage: ByteArray): EcPublicKey { + val map = Cbor.decode(sessionEstablishmentMessage) + val encodedEReaderKey = map["eReaderKey"].asTagged.asBstr + return Cbor.decode(encodedEReaderKey).asCoseKey.ecPublicKey + } } } diff --git a/identity-mdoc/src/commonMain/kotlin/com/android/identity/mdoc/transport/BleCentralManager.kt b/identity-mdoc/src/commonMain/kotlin/com/android/identity/mdoc/transport/BleCentralManager.kt new file mode 100644 index 000000000..3c964290c --- /dev/null +++ b/identity-mdoc/src/commonMain/kotlin/com/android/identity/mdoc/transport/BleCentralManager.kt @@ -0,0 +1,63 @@ +package com.android.identity.mdoc.transport + +import com.android.identity.crypto.EcPublicKey +import com.android.identity.util.UUID +import kotlinx.coroutines.channels.Channel + +internal interface BleCentralManager { + val incomingMessages: Channel + + fun setUuids( + stateCharacteristicUuid: UUID, + client2ServerCharacteristicUuid: UUID, + server2ClientCharacteristicUuid: UUID, + identCharacteristicUuid: UUID?, + l2capUuid: UUID? + ) + + fun setCallbacks( + /** + * Called if an error occurs asynchronously and the error isn't bubbled back + * to one of the methods on this object. Never invoked by any of the instance + * methods. + */ + onError: (error: Throwable) -> Unit, + + /** + * Called on transport-specific termination. Never invoked by any of the instance + * methods. + */ + onClosed: () -> Unit + ) + + suspend fun waitForPowerOn() + + suspend fun waitForPeripheralWithUuid(uuid: UUID) + + suspend fun connectToPeripheral() + + suspend fun requestMtu() + + suspend fun peripheralDiscoverServices(uuid: UUID) + + suspend fun peripheralDiscoverCharacteristics() + + suspend fun checkReaderIdentMatches(eSenderKey: EcPublicKey) + + suspend fun subscribeToCharacteristics() + + suspend fun writeToStateCharacteristic(value: Int) + + suspend fun sendMessage(message: ByteArray) + + // The PSM read from the L2CAP characteristic or null if the peripheral server does not + // support it or checking for L2CAP wasn't requested when calling [setUuids] + val l2capPsm: Int? + + suspend fun connectL2cap(psm: Int) + + // True if connected via L2CAP + val usingL2cap: Boolean + + fun close() +} \ No newline at end of file diff --git a/identity-mdoc/src/commonMain/kotlin/com/android/identity/mdoc/transport/BlePeripheralManager.kt b/identity-mdoc/src/commonMain/kotlin/com/android/identity/mdoc/transport/BlePeripheralManager.kt new file mode 100644 index 000000000..5e76e0d2d --- /dev/null +++ b/identity-mdoc/src/commonMain/kotlin/com/android/identity/mdoc/transport/BlePeripheralManager.kt @@ -0,0 +1,56 @@ +package com.android.identity.mdoc.transport + +import com.android.identity.crypto.EcPublicKey +import com.android.identity.util.UUID +import kotlinx.coroutines.channels.Channel + +internal interface BlePeripheralManager { + val incomingMessages: Channel + + fun setUuids( + stateCharacteristicUuid: UUID, + client2ServerCharacteristicUuid: UUID, + server2ClientCharacteristicUuid: UUID, + identCharacteristicUuid: UUID?, + l2capUuid: UUID? + ) + + fun setCallbacks( + /** + * Called if an error occurs asynchronously and the error isn't bubbled back + * to one of the methods on this object. Never invoked by any of the instance + * methods. + */ + onError: (error: Throwable) -> Unit, + + /** + * Called on transport-specific termination. Never invoked by any of the instance + * methods. + */ + onClosed: () -> Unit + ) + + suspend fun waitForPowerOn() + + suspend fun advertiseService(uuid: UUID) + + suspend fun setESenderKey(eSenderKey: EcPublicKey) + + suspend fun waitForStateCharacteristicWriteOrL2CAPClient() + + suspend fun writeToStateCharacteristic(value: Int) + + suspend fun sendMessage(message: ByteArray) + + fun close() + + // The PSM if listening on L2CAP. + // + // This is guaranteed to be available after [advertiseService] is called if the `l2capUuid` passed + // to [setUuids] isn't `null`. + // + val l2capPsm: Int? + + // True if connected via L2CAP + val usingL2cap: Boolean +} \ No newline at end of file diff --git a/identity-mdoc/src/commonMain/kotlin/com/android/identity/mdoc/transport/BleTransportCentralMdoc.kt b/identity-mdoc/src/commonMain/kotlin/com/android/identity/mdoc/transport/BleTransportCentralMdoc.kt new file mode 100644 index 000000000..65609934e --- /dev/null +++ b/identity-mdoc/src/commonMain/kotlin/com/android/identity/mdoc/transport/BleTransportCentralMdoc.kt @@ -0,0 +1,172 @@ +package com.android.identity.mdoc.transport + +import com.android.identity.crypto.EcPublicKey +import com.android.identity.mdoc.connectionmethod.ConnectionMethod +import com.android.identity.mdoc.connectionmethod.ConnectionMethodBle +import com.android.identity.util.Logger +import com.android.identity.util.UUID +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import kotlinx.datetime.Clock +import kotlin.time.Duration +import kotlin.time.Duration.Companion.milliseconds + +internal class BleTransportCentralMdoc( + override val role: Role, + private val options: MdocTransportOptions, + private val centralManager: BleCentralManager, + private val uuid: UUID, + private val psm: Int? +) : MdocTransport() { + companion object { + private const val TAG = "BleTransportCentralMdoc" + } + + private val mutex = Mutex() + + private val _state = MutableStateFlow(State.IDLE) + override val state: StateFlow = _state.asStateFlow() + + override val connectionMethod: ConnectionMethod + get() = ConnectionMethodBle(false, true, null, uuid) + + init { + centralManager.setUuids( + stateCharacteristicUuid = UUID.fromString("00000005-a123-48ce-896b-4c76973373e6"), + client2ServerCharacteristicUuid = UUID.fromString("00000006-a123-48ce-896b-4c76973373e6"), + server2ClientCharacteristicUuid = UUID.fromString("00000007-a123-48ce-896b-4c76973373e6"), + identCharacteristicUuid = UUID.fromString("00000008-a123-48ce-896b-4c76973373e6"), + l2capUuid = if (options.bleUseL2CAP) { + UUID.fromString("0000000b-a123-48ce-896b-4c76973373e6") + } else { + null + } + ) + centralManager.setCallbacks( + onError = { error -> + runBlocking { + mutex.withLock { + failTransport(error) + } + } + }, + onClosed = { + runBlocking { + mutex.withLock { + closeWithoutDelay() + } + } + } + ) + } + + override suspend fun advertise() { + // Nothing to do here. + } + + private var _scanningTime: Duration? = null + override val scanningTime: Duration? + get() = _scanningTime + + override suspend fun open(eSenderKey: EcPublicKey) { + mutex.withLock { + check(_state.value == State.IDLE) { "Expected state IDLE, got ${_state.value}" } + try { + _state.value = State.SCANNING + centralManager.waitForPowerOn() + val timeScanningStarted = Clock.System.now() + centralManager.waitForPeripheralWithUuid(uuid) + _scanningTime = Clock.System.now() - timeScanningStarted + _state.value = State.CONNECTING + if (psm != null) { + // If the PSM is known at engagement-time we can bypass the entire GATT server + // and just connect directly. + centralManager.connectL2cap(psm) + } else { + centralManager.connectToPeripheral() + centralManager.requestMtu() + centralManager.peripheralDiscoverServices(uuid) + centralManager.peripheralDiscoverCharacteristics() + centralManager.checkReaderIdentMatches(eSenderKey) + if (centralManager.l2capPsm != null) { + centralManager.connectL2cap(centralManager.l2capPsm!!) + } else { + centralManager.subscribeToCharacteristics() + centralManager.writeToStateCharacteristic(BleTransportConstants.STATE_CHARACTERISTIC_START) + } + } + _state.value = State.CONNECTED + } catch (error: Throwable) { + failTransport(error) + throw MdocTransportException("Failed while opening transport", error) + } + } + } + + override suspend fun waitForMessage(): ByteArray { + mutex.withLock { + check(_state.value == State.CONNECTED) { "Expected state CONNECTED, got ${_state.value}" } + } + try { + return centralManager.incomingMessages.receive() + } catch (error: Throwable) { + if (_state.value == State.CLOSED) { + throw MdocTransportClosedException("Transport was closed while waiting for message") + } else { + mutex.withLock { + failTransport(error) + } + throw MdocTransportException("Failed while waiting for message", error) + } + } + } + + override suspend fun sendMessage(message: ByteArray) { + mutex.withLock { + check(_state.value == State.CONNECTED) { "Expected state CONNECTED, got ${_state.value}" } + if (message.isEmpty() && centralManager.usingL2cap) { + throw MdocTransportTerminationException("Transport-specific termination not available with L2CAP") + } + try { + if (message.isEmpty()) { + centralManager.writeToStateCharacteristic(BleTransportConstants.STATE_CHARACTERISTIC_END) + } else { + centralManager.sendMessage(message) + } + } catch (error: Throwable) { + failTransport(error) + throw MdocTransportException("Failed while sending message", error) + } + } + } + + private fun failTransport(error: Throwable) { + check(mutex.isLocked) { "failTransport called without holding lock" } + if (_state.value == State.FAILED || _state.value == State.CLOSED) { + return + } + Logger.w(TAG, "Failing transport with error", error) + centralManager.close() + _state.value = State.FAILED + } + + private fun closeWithoutDelay() { + check(mutex.isLocked) { "closeWithoutDelay called without holding lock" } + centralManager.close() + _state.value = State.CLOSED + } + + override suspend fun close() { + mutex.withLock { + if (_state.value == State.FAILED || _state.value == State.CLOSED) { + return + } + closeWithoutDelay() + } + } +} diff --git a/identity-mdoc/src/commonMain/kotlin/com/android/identity/mdoc/transport/BleTransportCentralMdocReader.kt b/identity-mdoc/src/commonMain/kotlin/com/android/identity/mdoc/transport/BleTransportCentralMdocReader.kt new file mode 100644 index 000000000..c68450d09 --- /dev/null +++ b/identity-mdoc/src/commonMain/kotlin/com/android/identity/mdoc/transport/BleTransportCentralMdocReader.kt @@ -0,0 +1,169 @@ +package com.android.identity.mdoc.transport + +import com.android.identity.crypto.EcPublicKey +import com.android.identity.mdoc.connectionmethod.ConnectionMethod +import com.android.identity.mdoc.connectionmethod.ConnectionMethodBle +import com.android.identity.util.Logger +import com.android.identity.util.UUID +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import kotlin.time.Duration +import kotlin.time.Duration.Companion.milliseconds + +internal class BleTransportCentralMdocReader( + override val role: Role, + private val options: MdocTransportOptions, + private val peripheralManager: BlePeripheralManager, + private val uuid: UUID +) : MdocTransport() { + companion object { + private const val TAG = "BleTransportCentralMdocReader" + } + + private val mutex = Mutex() + + private val _state = MutableStateFlow(State.IDLE) + override val state: StateFlow = _state.asStateFlow() + + override val connectionMethod: ConnectionMethod + get() { + val cm = ConnectionMethodBle(false, true, null, uuid) + peripheralManager.l2capPsm?.let { cm.peripheralServerModePsm = it } + return cm + } + + init { + peripheralManager.setUuids( + stateCharacteristicUuid = UUID.fromString("00000005-a123-48ce-896b-4c76973373e6"), + client2ServerCharacteristicUuid = UUID.fromString("00000006-a123-48ce-896b-4c76973373e6"), + server2ClientCharacteristicUuid = UUID.fromString("00000007-a123-48ce-896b-4c76973373e6"), + identCharacteristicUuid = UUID.fromString("00000008-a123-48ce-896b-4c76973373e6"), + l2capUuid = if (options.bleUseL2CAP) { + UUID.fromString("0000000b-a123-48ce-896b-4c76973373e6") + } else { + null + } + ) + peripheralManager.setCallbacks( + onError = { error -> + runBlocking { + mutex.withLock { + failTransport(error) + } + } + }, + onClosed = { + runBlocking { + mutex.withLock { + closeWithoutDelay() + } + } + } + ) + } + + override suspend fun advertise() { + mutex.withLock { + check(_state.value == State.IDLE) { "Expected state IDLE, got ${_state.value}" } + peripheralManager.waitForPowerOn() + peripheralManager.advertiseService(uuid) + _state.value = State.ADVERTISING + } + } + + override val scanningTime: Duration? + get() = null + + override suspend fun open(eSenderKey: EcPublicKey) { + mutex.withLock { + check(_state.value == State.IDLE || _state.value == State.ADVERTISING) { + "Expected state IDLE or ADVERTISING, got ${_state.value}" + } + try { + if (_state.value != State.ADVERTISING) { + // Start advertising if we aren't already... + _state.value = State.ADVERTISING + peripheralManager.waitForPowerOn() + peripheralManager.advertiseService(uuid) + } + peripheralManager.setESenderKey(eSenderKey) + // Note: It's not really possible to know someone is connecting to use until they're _actually_ + // connected. I mean, for all we know, someone could be BLE scanning us. So not really possible + // to go into State.CONNECTING... + peripheralManager.waitForStateCharacteristicWriteOrL2CAPClient() + _state.value = State.CONNECTED + } catch (error: Throwable) { + failTransport(error) + throw MdocTransportException("Failed while opening transport", error) + } + } + } + + override suspend fun waitForMessage(): ByteArray { + mutex.withLock { + check(_state.value == State.CONNECTED) { "Expected state CONNECTED, got ${_state.value}" } + } + try { + return peripheralManager.incomingMessages.receive() + } catch (error: Throwable) { + if (_state.value == State.CLOSED) { + throw MdocTransportClosedException("Transport was closed while waiting for message") + } else { + mutex.withLock { + failTransport(error) + } + throw MdocTransportException("Failed while waiting for message", error) + } + } + } + + override suspend fun sendMessage(message: ByteArray) { + mutex.withLock { + check(_state.value == State.CONNECTED) { "Expected state CONNECTED, got ${_state.value}" } + if (message.isEmpty() && peripheralManager.usingL2cap) { + throw MdocTransportTerminationException("Transport-specific termination not available with L2CAP") + } + try { + if (message.isEmpty()) { + peripheralManager.writeToStateCharacteristic(BleTransportConstants.STATE_CHARACTERISTIC_END) + } else { + peripheralManager.sendMessage(message) + } + } catch (error: Throwable) { + failTransport(error) + throw MdocTransportException("Failed while sending message", error) + } + } + } + + private fun failTransport(error: Throwable) { + check(mutex.isLocked) { "failTransport called without holding lock" } + if (_state.value == State.FAILED || _state.value == State.CLOSED) { + return + } + Logger.w(TAG, "Failing transport with error", error) + peripheralManager.close() + _state.value = State.FAILED + } + + private fun closeWithoutDelay() { + check(mutex.isLocked) { "closeWithoutDelay called without holding lock" } + peripheralManager.close() + _state.value = State.CLOSED + } + + override suspend fun close() { + mutex.withLock { + if (_state.value == State.FAILED || _state.value == State.CLOSED) { + return + } + peripheralManager.close() + _state.value = State.CLOSED + } + } +} diff --git a/identity-mdoc/src/commonMain/kotlin/com/android/identity/mdoc/transport/BleTransportConstants.kt b/identity-mdoc/src/commonMain/kotlin/com/android/identity/mdoc/transport/BleTransportConstants.kt new file mode 100644 index 000000000..bfd765ee0 --- /dev/null +++ b/identity-mdoc/src/commonMain/kotlin/com/android/identity/mdoc/transport/BleTransportConstants.kt @@ -0,0 +1,12 @@ +package com.android.identity.mdoc.transport + +// Various constants used for Bluetooth Low Energy as used in ISO/IEC 18013-5:2021 +internal object BleTransportConstants { + + // This indicates that the mdoc reader may/will begin + // transmission (see ISO/IEC 18013-5:2021 Table 13). + const val STATE_CHARACTERISTIC_START = 0x01 + + // Signal to finish/terminate transaction (see ISO/IEC 18013-5:2021 Table 13).. + const val STATE_CHARACTERISTIC_END = 0x02 +} diff --git a/identity-mdoc/src/commonMain/kotlin/com/android/identity/mdoc/transport/BleTransportPeripheralMdoc.kt b/identity-mdoc/src/commonMain/kotlin/com/android/identity/mdoc/transport/BleTransportPeripheralMdoc.kt new file mode 100644 index 000000000..9941af1c8 --- /dev/null +++ b/identity-mdoc/src/commonMain/kotlin/com/android/identity/mdoc/transport/BleTransportPeripheralMdoc.kt @@ -0,0 +1,170 @@ +package com.android.identity.mdoc.transport + +import com.android.identity.crypto.EcPublicKey +import com.android.identity.mdoc.connectionmethod.ConnectionMethod +import com.android.identity.mdoc.connectionmethod.ConnectionMethodBle +import com.android.identity.util.Logger +import com.android.identity.util.UUID +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import kotlin.time.Duration +import kotlin.time.Duration.Companion.milliseconds + +internal class BleTransportPeripheralMdoc( + override val role: Role, + private val options: MdocTransportOptions, + private val peripheralManager: BlePeripheralManager, + private val uuid: UUID +) : MdocTransport() { + companion object { + private const val TAG = "BleTransportPeripheralMdoc" + } + + private val mutex = Mutex() + + private val _state = MutableStateFlow(State.IDLE) + override val state: StateFlow = _state.asStateFlow() + + override val connectionMethod: ConnectionMethod + get() { + val cm = ConnectionMethodBle(true, false, uuid, null) + peripheralManager.l2capPsm?.let { cm.peripheralServerModePsm = it } + return cm + } + + init { + peripheralManager.setUuids( + stateCharacteristicUuid = UUID.fromString("00000001-a123-48ce-896b-4c76973373e6"), + client2ServerCharacteristicUuid = UUID.fromString("00000002-a123-48ce-896b-4c76973373e6"), + server2ClientCharacteristicUuid = UUID.fromString("00000003-a123-48ce-896b-4c76973373e6"), + identCharacteristicUuid = null, + l2capUuid = if (options.bleUseL2CAP) { + UUID.fromString("0000000a-a123-48ce-896b-4c76973373e6") + } else { + null + } + ) + peripheralManager.setCallbacks( + onError = { error -> + runBlocking { + mutex.withLock { + failTransport(error) + } + } + }, + onClosed = { + Logger.w(TAG, "BlePeripheralManager close") + runBlocking { + mutex.withLock { + closeWithoutDelay() + } + } + } + ) + } + + override suspend fun advertise() { + mutex.withLock { + check(_state.value == State.IDLE) { "Expected state IDLE, got ${_state.value}" } + peripheralManager.waitForPowerOn() + peripheralManager.advertiseService(uuid) + _state.value = State.ADVERTISING + } + } + + override val scanningTime: Duration? + get() = null + + override suspend fun open(eSenderKey: EcPublicKey) { + mutex.withLock { + check(_state.value == State.IDLE || _state.value == State.ADVERTISING) { + "Expected state IDLE or ADVERTISING, got ${_state.value}" + } + try { + if (_state.value != State.ADVERTISING) { + // Start advertising if we aren't already... + _state.value = State.ADVERTISING + peripheralManager.waitForPowerOn() + peripheralManager.advertiseService(uuid) + } + peripheralManager.setESenderKey(eSenderKey) + // Note: It's not really possible to know someone is connecting to us until they're _actually_ + // connected. I mean, for all we know, someone could be BLE scanning us. So not really possible + // to go into State.CONNECTING... + peripheralManager.waitForStateCharacteristicWriteOrL2CAPClient() + _state.value = State.CONNECTED + } catch (error: Throwable) { + failTransport(error) + throw MdocTransportException("Failed while opening transport", error) + } + } + } + + override suspend fun waitForMessage(): ByteArray { + mutex.withLock { + check(_state.value == State.CONNECTED) { "Expected state CONNECTED, got ${_state.value}" } + } + try { + return peripheralManager.incomingMessages.receive() + } catch (error: Throwable) { + if (_state.value == State.CLOSED) { + throw MdocTransportClosedException("Transport was closed while waiting for message") + } else { + mutex.withLock { + failTransport(error) + } + throw MdocTransportException("Failed while waiting for message", error) + } + } + } + + override suspend fun sendMessage(message: ByteArray) { + mutex.withLock { + check(_state.value == State.CONNECTED) { "Expected state CONNECTED, got ${_state.value}" } + if (message.isEmpty() && peripheralManager.usingL2cap) { + throw MdocTransportTerminationException("Transport-specific termination not available with L2CAP") + } + try { + if (message.isEmpty()) { + peripheralManager.writeToStateCharacteristic(BleTransportConstants.STATE_CHARACTERISTIC_END) + } else { + peripheralManager.sendMessage(message) + } + } catch (error: Throwable) { + failTransport(error) + throw MdocTransportException("Failed while sending message", error) + } + } + } + + private fun failTransport(error: Throwable) { + check(mutex.isLocked) { "failTransport called without holding lock" } + if (_state.value == State.FAILED || _state.value == State.CLOSED) { + return + } + Logger.w(TAG, "Failing transport with error", error) + peripheralManager.close() + _state.value = State.FAILED + } + + private fun closeWithoutDelay() { + check(mutex.isLocked) { "closeWithoutDelay called without holding lock" } + peripheralManager.close() + _state.value = State.CLOSED + } + + override suspend fun close() { + mutex.withLock { + if (_state.value == State.FAILED || _state.value == State.CLOSED) { + return + } + peripheralManager.close() + _state.value = State.CLOSED + } + } +} diff --git a/identity-mdoc/src/commonMain/kotlin/com/android/identity/mdoc/transport/BleTransportPeripheralMdocReader.kt b/identity-mdoc/src/commonMain/kotlin/com/android/identity/mdoc/transport/BleTransportPeripheralMdocReader.kt new file mode 100644 index 000000000..7dd658c6f --- /dev/null +++ b/identity-mdoc/src/commonMain/kotlin/com/android/identity/mdoc/transport/BleTransportPeripheralMdocReader.kt @@ -0,0 +1,175 @@ +package com.android.identity.mdoc.transport + +import com.android.identity.crypto.EcPublicKey +import com.android.identity.mdoc.connectionmethod.ConnectionMethod +import com.android.identity.mdoc.connectionmethod.ConnectionMethodBle +import com.android.identity.util.Logger +import com.android.identity.util.UUID +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import kotlinx.datetime.Clock +import kotlin.time.Duration +import kotlin.time.Duration.Companion.milliseconds + +internal class BleTransportPeripheralMdocReader( + override val role: Role, + private val options: MdocTransportOptions, + private val centralManager: BleCentralManager, + private val uuid: UUID, + private val psm: Int? +) : MdocTransport() { + companion object { + private const val TAG = "BleTransportPeripheralMdocReader" + } + + private val mutex = Mutex() + + private val _state = MutableStateFlow(State.IDLE) + override val state: StateFlow = _state.asStateFlow() + + override val connectionMethod: ConnectionMethod + get() = ConnectionMethodBle(true, false, uuid, null) + + init { + centralManager.setUuids( + stateCharacteristicUuid = UUID.fromString("00000001-a123-48ce-896b-4c76973373e6"), + client2ServerCharacteristicUuid = UUID.fromString("00000002-a123-48ce-896b-4c76973373e6"), + server2ClientCharacteristicUuid = UUID.fromString("00000003-a123-48ce-896b-4c76973373e6"), + identCharacteristicUuid = null, + l2capUuid = if (options.bleUseL2CAP) { + UUID.fromString("0000000a-a123-48ce-896b-4c76973373e6") + } else { + null + } + ) + centralManager.setCallbacks( + onError = { error -> + runBlocking { + mutex.withLock { + failTransport(error) + } + } + }, + onClosed = { + Logger.w(TAG, "BleCentralManager close") + runBlocking { + mutex.withLock { + closeWithoutDelay() + } + } + } + ) + } + + override suspend fun advertise() { + // Nothing to do here. + } + + private var _scanningTime: Duration? = null + override val scanningTime: Duration? + get() = _scanningTime + + override suspend fun open(eSenderKey: EcPublicKey) { + mutex.withLock { + check(_state.value == State.IDLE) { "Expected state IDLE, got ${_state.value}" } + try { + _state.value = State.SCANNING + centralManager.waitForPowerOn() + val timeScanningStarted = Clock.System.now() + centralManager.waitForPeripheralWithUuid(uuid) + _scanningTime = Clock.System.now() - timeScanningStarted + _state.value = State.CONNECTING + if (psm != null) { + // If the PSM is known at engagement-time we can bypass the entire GATT server + // and just connect directly. + centralManager.connectL2cap(psm) + } else { + centralManager.connectToPeripheral() + centralManager.requestMtu() + centralManager.peripheralDiscoverServices(uuid) + centralManager.peripheralDiscoverCharacteristics() + // NOTE: ident characteristic isn't used when the mdoc is the GATT server so we don't call + // centralManager.checkReaderIdentMatches(eSenderKey) + if (centralManager.l2capPsm != null) { + centralManager.connectL2cap(centralManager.l2capPsm!!) + } else { + centralManager.subscribeToCharacteristics() + centralManager.writeToStateCharacteristic(BleTransportConstants.STATE_CHARACTERISTIC_START) + } + } + _state.value = State.CONNECTED + } catch (error: Throwable) { + failTransport(error) + throw MdocTransportException("Failed while opening transport", error) + } + } + } + + override suspend fun waitForMessage(): ByteArray { + mutex.withLock { + check(_state.value == State.CONNECTED) { "Expected state CONNECTED, got ${_state.value}" } + } + try { + return centralManager.incomingMessages.receive() + } catch (error: Throwable) { + if (_state.value == State.CLOSED) { + throw MdocTransportClosedException("Transport was closed while waiting for message") + } else { + mutex.withLock { + failTransport(error) + } + throw MdocTransportException("Failed while waiting for message", error) + } + } + } + + override suspend fun sendMessage(message: ByteArray) { + mutex.withLock { + check(_state.value == State.CONNECTED) { "Expected state CONNECTED, got ${_state.value}" } + if (message.isEmpty() && centralManager.usingL2cap) { + throw MdocTransportTerminationException("Transport-specific termination not available with L2CAP") + } + try { + if (message.isEmpty()) { + centralManager.writeToStateCharacteristic(BleTransportConstants.STATE_CHARACTERISTIC_END) + } else { + centralManager.sendMessage(message) + } + } catch (error: Throwable) { + failTransport(error) + throw MdocTransportException("Failed while sending message", error) + } + } + } + + private fun failTransport(error: Throwable) { + check(mutex.isLocked) { "failTransport called without holding lock" } + if (_state.value == State.FAILED || _state.value == State.CLOSED) { + return + } + Logger.w(TAG, "Failing transport with error", error) + centralManager.close() + _state.value = State.FAILED + } + + private fun closeWithoutDelay() { + check(mutex.isLocked) { "failTransport called without holding lock" } + centralManager.close() + _state.value = State.CLOSED + } + + override suspend fun close() { + mutex.withLock { + if (_state.value == State.FAILED || _state.value == State.CLOSED) { + return + } + centralManager.close() + _state.value = State.CLOSED + } + } +} diff --git a/identity-mdoc/src/commonMain/kotlin/com/android/identity/mdoc/transport/MdocTransport.kt b/identity-mdoc/src/commonMain/kotlin/com/android/identity/mdoc/transport/MdocTransport.kt new file mode 100644 index 000000000..59ed8fea6 --- /dev/null +++ b/identity-mdoc/src/commonMain/kotlin/com/android/identity/mdoc/transport/MdocTransport.kt @@ -0,0 +1,151 @@ +package com.android.identity.mdoc.transport + +import com.android.identity.crypto.EcPublicKey +import com.android.identity.mdoc.connectionmethod.ConnectionMethod +import kotlinx.coroutines.flow.StateFlow +import kotlin.time.Duration + +/** + * An abstraction of a ISO/IEC 18013-5:2021 device retrieval method. + * + * A [MdocTransport]'s state can be tracked in the [state] property which is [State.IDLE] + * when constructed from the factory. This is a [StateFlow] and intended to be used by + * the application to update its user interface. + * + * To open a connection to the other peer, call [open]. When [open] returns successfully + * the state is [State.CONNECTED]. At this point, the application can use [sendMessage] + * and [waitForMessage] to exchange messages with the remote peer. + * + * Each [MdocTransport] has a role, indicating whether the application is acting as + * the _mdoc_ or _mdoc reader_. For forward engagement, if acting in the role [Role.MDOC] + * the application should create one or more [ConnectionMethod] instances, call [open] on + * each of them, share the [ConnectionMethod]s (through QR or NFC in _Device Engagement_ + * according to ISO/EC 18013-5:2021). Similarly, the other peer - acting in the role + * [Role.MDOC_READER] - should obtain the _Device Engagement_, get one or more + * [ConnectionMethod] objects and call [open] on one of them. For reverse engagement, + * the process is the same but with the roles reversed. + * + * The transport can fail at any time, for example if the other peer sends invalid data + * or actively disconnects. In this case the state is changed to [State.FAILED] and + * any calls except for [close] will fail with the [MdocTransportException] exception. + * + * The connection can be closed at any time using the [close] method which will transition + * the state to [State.CLOSED] except if it's already in [State.FAILED]. + * + * [MdocTransport] instances are thread-safe and methods and properties can be called from + * any thread or coroutine. + */ +abstract class MdocTransport { + /** + * The role of the transport + */ + enum class Role { + /** The transport is being used by an _mdoc_. */ + MDOC, + + /** The transport is being used by an _mdoc reader_. */ + MDOC_READER + } + + /** + * Possible states for a transport. + */ + enum class State { + /** The transport is idle. */ + IDLE, + + /** The transport is being advertised. */ + ADVERTISING, + + /** The transport is scanning. */ + SCANNING, + + /** A remote peer has been identified and the connection is being set up. */ + CONNECTING, + + /** The transport is connected to the remote peer. */ + CONNECTED, + + /** The transport was connected at one point but one of the sides closed the connection. */ + CLOSED, + + /** The connection to the remote peer failed. */ + FAILED + } + + /** + * The current state of the transport. + */ + abstract val state: StateFlow + + /** + * The role which the transport is for. + */ + abstract val role: Role + + /** + * A [ConnectionMethod] which can be sent to the other peer to connect to. + */ + abstract val connectionMethod: ConnectionMethod + + /** + * The time spent scanning for the other peer. + * + * This is always `null` until [open] completes and it's only set for transports that actually perform active + * scanning for the other peer. This includes _BLE mdoc central client mode_ for [Role.MDOC] and + * _BLE mdoc peripheral server mode_ for [Role.MDOC_READER]. + */ + abstract val scanningTime: Duration? + + /** + * Starts advertising the connection. + * + * This is optional for transports to implement. + */ + abstract suspend fun advertise() + + /** + * Opens the connection to the other peer. + * + * @param eSenderKey This should be set to `EDeviceKey` if using forward engagement or + * `EReaderKey` if using reverse engagement. + */ + abstract suspend fun open(eSenderKey: EcPublicKey) + + /** + * Sends a message to the other peer. + * + * This should be formatted as `SessionEstablishment` or `SessionData` according to + * ISO/IEC 18013-5:2021. + * + * To signal transport-specific termination send an empty message. If the transport doesn't support + * this, [MdocTransportTerminationException] is thrown. + * + * This blocks the calling coroutine until the message is sent. + * + * @param message the message to send. + * @throws MdocTransportException if an unrecoverable error occurs. + * @throws MdocTransportTerminationException if the transport doesn't support transport-specific termination. + */ + abstract suspend fun sendMessage(message: ByteArray) + + /** + * Waits for the other peer to send a message. + * + * This received message should be formatted as `SessionEstablishment` or `SessionData` + * according to ISO/IEC 18013-5:2021. Transport-specific session termination is indicated + * by the returned message being empty. + * + * @return the message that was received or empty if transport-specific session termination was used. + * @throws MdocTransportClosedException if [close] was called from another coroutine while waiting. + * @throws MdocTransportException if an unrecoverable error occurs. + */ + abstract suspend fun waitForMessage(): ByteArray + + /** + * Closes the connection. + * + * This can be called from any thread. + */ + abstract suspend fun close() +} diff --git a/identity-mdoc/src/commonMain/kotlin/com/android/identity/mdoc/transport/MdocTransportClosedException.kt b/identity-mdoc/src/commonMain/kotlin/com/android/identity/mdoc/transport/MdocTransportClosedException.kt new file mode 100644 index 000000000..947fe4799 --- /dev/null +++ b/identity-mdoc/src/commonMain/kotlin/com/android/identity/mdoc/transport/MdocTransportClosedException.kt @@ -0,0 +1,35 @@ +package com.android.identity.mdoc.transport + +/** + * Thrown by [MdocTransport.waitForMessage] if the transport was closed by another coroutine while waiting. + */ +class MdocTransportClosedException : Exception { + /** + * Construct a new exception. + */ + constructor() + + /** + * Construct a new exception. + * + */ + constructor(message: String) : super(message) + + /** + * Construct a new exception. + * + * @param message the message. + * @param cause the cause. + */ + constructor( + message: String, + cause: Throwable + ) : super(message, cause) + + /** + * Construct a new exception. + * + * @param cause the cause. + */ + constructor(cause: Throwable) : super(cause) +} \ No newline at end of file diff --git a/identity-mdoc/src/commonMain/kotlin/com/android/identity/mdoc/transport/MdocTransportException.kt b/identity-mdoc/src/commonMain/kotlin/com/android/identity/mdoc/transport/MdocTransportException.kt new file mode 100644 index 000000000..caa7ee432 --- /dev/null +++ b/identity-mdoc/src/commonMain/kotlin/com/android/identity/mdoc/transport/MdocTransportException.kt @@ -0,0 +1,35 @@ +package com.android.identity.mdoc.transport + +/** + * Thrown if an unrecoverable error occurs. + */ +class MdocTransportException : Exception { + /** + * Construct a new exception. + */ + constructor() + + /** + * Construct a new exception. + * + */ + constructor(message: String) : super(message) + + /** + * Construct a new exception. + * + * @param message the message. + * @param cause the cause. + */ + constructor( + message: String, + cause: Throwable + ) : super(message, cause) + + /** + * Construct a new exception. + * + * @param cause the cause. + */ + constructor(cause: Throwable) : super(cause) +} \ No newline at end of file diff --git a/identity-mdoc/src/commonMain/kotlin/com/android/identity/mdoc/transport/MdocTransportFactory.kt b/identity-mdoc/src/commonMain/kotlin/com/android/identity/mdoc/transport/MdocTransportFactory.kt new file mode 100644 index 000000000..93edc7760 --- /dev/null +++ b/identity-mdoc/src/commonMain/kotlin/com/android/identity/mdoc/transport/MdocTransportFactory.kt @@ -0,0 +1,24 @@ +package com.android.identity.mdoc.transport + +import com.android.identity.mdoc.connectionmethod.ConnectionMethod + +/** + * A factory used to create [MdocTransport] instances. + */ +expect class MdocTransportFactory { + companion object { + /** + * Creates a new [MdocTransport]. + * + * @param connectionMethod the address to listen on or connect to. + * @param role the role of the [MdocTransport]. + * @param options options for the [MdocTransport]. + * @return A [MdocTransport] instance, ready to be used. + */ + fun createTransport( + connectionMethod: ConnectionMethod, + role: MdocTransport.Role, + options: MdocTransportOptions = MdocTransportOptions() + ): MdocTransport + } +} \ No newline at end of file diff --git a/identity-mdoc/src/commonMain/kotlin/com/android/identity/mdoc/transport/MdocTransportOptions.kt b/identity-mdoc/src/commonMain/kotlin/com/android/identity/mdoc/transport/MdocTransportOptions.kt new file mode 100644 index 000000000..ff82f048d --- /dev/null +++ b/identity-mdoc/src/commonMain/kotlin/com/android/identity/mdoc/transport/MdocTransportOptions.kt @@ -0,0 +1,10 @@ +package com.android.identity.mdoc.transport + +/** + * Options for using a [MdocTransport]. + * + * @property bleUseL2CAP set to `true` to use L2CAP if available, `false` otherwise + */ +data class MdocTransportOptions( + val bleUseL2CAP: Boolean = false +) \ No newline at end of file diff --git a/identity-mdoc/src/commonMain/kotlin/com/android/identity/mdoc/transport/MdocTransportTerminationException.kt b/identity-mdoc/src/commonMain/kotlin/com/android/identity/mdoc/transport/MdocTransportTerminationException.kt new file mode 100644 index 000000000..6eb975522 --- /dev/null +++ b/identity-mdoc/src/commonMain/kotlin/com/android/identity/mdoc/transport/MdocTransportTerminationException.kt @@ -0,0 +1,36 @@ +package com.android.identity.mdoc.transport + +/** + * Thrown if transport-specific termination was requested but not supported + * by the underlying tranposrt. + */ +class MdocTransportTerminationException : Exception { + /** + * Construct a new exception. + */ + constructor() + + /** + * Construct a new exception. + * + */ + constructor(message: String) : super(message) + + /** + * Construct a new exception. + * + * @param message the message. + * @param cause the cause. + */ + constructor( + message: String, + cause: Throwable + ) : super(message, cause) + + /** + * Construct a new exception. + * + * @param cause the cause. + */ + constructor(cause: Throwable) : super(cause) +} \ No newline at end of file diff --git a/identity-mdoc/src/iosMain/kotlin/com/android/identity/mdoc/transport/BleCentralManagerIos.kt b/identity-mdoc/src/iosMain/kotlin/com/android/identity/mdoc/transport/BleCentralManagerIos.kt new file mode 100644 index 000000000..01d6cfe13 --- /dev/null +++ b/identity-mdoc/src/iosMain/kotlin/com/android/identity/mdoc/transport/BleCentralManagerIos.kt @@ -0,0 +1,651 @@ +package com.android.identity.mdoc.transport + +import com.android.identity.cbor.Bstr +import com.android.identity.cbor.Cbor +import com.android.identity.cbor.Tagged +import com.android.identity.crypto.Algorithm +import com.android.identity.crypto.Crypto +import com.android.identity.crypto.EcPublicKey +import com.android.identity.util.Logger +import com.android.identity.util.UUID +import com.android.identity.util.toByteArray +import com.android.identity.util.toKotlinError +import com.android.identity.util.toHex +import com.android.identity.util.toNSData +import kotlinx.coroutines.CancellableContinuation +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.IO +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.suspendCancellableCoroutine +import kotlinx.io.Sink +import kotlinx.io.Source +import kotlinx.io.asSink +import kotlinx.io.asSource +import kotlinx.io.buffered +import kotlinx.io.bytestring.ByteStringBuilder +import kotlinx.io.readByteArray +import platform.CoreBluetooth.CBCentralManager +import platform.CoreBluetooth.CBCentralManagerDelegateProtocol +import platform.CoreBluetooth.CBCentralManagerStatePoweredOn +import platform.CoreBluetooth.CBCharacteristic +import platform.CoreBluetooth.CBCharacteristicWriteWithoutResponse +import platform.CoreBluetooth.CBL2CAPChannel +import platform.CoreBluetooth.CBPeripheral +import platform.CoreBluetooth.CBPeripheralDelegateProtocol +import platform.CoreBluetooth.CBPeripheralStateConnected +import platform.CoreBluetooth.CBService +import platform.CoreBluetooth.CBUUID +import platform.CoreFoundation.CFAbsoluteTime +import platform.Foundation.NSError +import platform.Foundation.NSNumber +import platform.darwin.NSObject +import kotlin.coroutines.resume +import kotlin.coroutines.resumeWithException +import kotlin.math.min + +internal class BleCentralManagerIos: BleCentralManager { + companion object { + private const val TAG = "BleCentralManagerIos" + private const val L2CAP_CHUNK_SIZE = 512 + } + + private lateinit var stateCharacteristicUuid: UUID + private lateinit var client2ServerCharacteristicUuid: UUID + private lateinit var server2ClientCharacteristicUuid: UUID + private var identCharacteristicUuid: UUID? = null + private var l2capCharacteristicUuid: UUID? = null + + override fun setUuids( + stateCharacteristicUuid: UUID, + client2ServerCharacteristicUuid: UUID, + server2ClientCharacteristicUuid: UUID, + identCharacteristicUuid: UUID?, + l2capCharacteristicUuid: UUID?, + ) { + this.stateCharacteristicUuid = stateCharacteristicUuid + this.client2ServerCharacteristicUuid = client2ServerCharacteristicUuid + this.server2ClientCharacteristicUuid = server2ClientCharacteristicUuid + this.identCharacteristicUuid = identCharacteristicUuid + this.l2capCharacteristicUuid = l2capCharacteristicUuid + } + + private lateinit var onError: (error: Throwable) -> Unit + private lateinit var onClosed: () -> Unit + + override fun setCallbacks( + onError: (Throwable) -> Unit, + onClosed: () -> Unit + ) { + this.onError = onError + this.onClosed = onClosed + } + + internal enum class WaitState { + POWER_ON, + PERIPHERAL_DISCOVERED, + CONNECT_TO_PERIPHERAL, + PERIPHERAL_DISCOVER_SERVICES, + PERIPHERAL_DISCOVER_CHARACTERISTICS, + PERIPHERAL_READ_VALUE_FOR_CHARACTERISTIC, + CHARACTERISTIC_READY_TO_WRITE, + OPEN_L2CAP_CHANNEL, + } + + private data class WaitFor( + val state: WaitState, + val continuation: CancellableContinuation, + val characteristic: CBCharacteristic? = null, + ) + + private var waitFor: WaitFor? = null + + private val centralManager: CBCentralManager + + private var peripheral: CBPeripheral? = null + + private var maxCharacteristicSize = 512 + + private var readCharacteristic: CBCharacteristic? = null + private var writeCharacteristic: CBCharacteristic? = null + private var stateCharacteristic: CBCharacteristic? = null + private var identCharacteristic: CBCharacteristic? = null + private var l2capCharacteristic: CBCharacteristic? = null + + private fun setWaitCondition( + state: WaitState, + continuation: CancellableContinuation, + characteristic: CBCharacteristic? = null + ) { + check(waitFor == null) + waitFor = WaitFor( + state, + continuation, + characteristic + ) + } + + private fun resumeWait() { + val continuation = waitFor!!.continuation + waitFor = null + continuation.resume(true) + } + + private fun resumeWaitWithException(exception: Throwable) { + val continuation = waitFor!!.continuation + waitFor = null + continuation.resumeWithException(exception) + } + + private val peripheralDelegate = object : NSObject(), CBPeripheralDelegateProtocol { + + override fun peripheral( + peripheral: CBPeripheral, + didDiscoverServices: NSError? + ) { + val error = didDiscoverServices + Logger.d(TAG, "didDiscoverServices peripheral=$peripheral error=${didDiscoverServices?.toKotlinError()}") + if (waitFor?.state == WaitState.PERIPHERAL_DISCOVER_SERVICES) { + if (error != null) { + resumeWaitWithException(error.toKotlinError()) + } else { + resumeWait() + } + } else { + Logger.w(TAG, "CBPeripheralDelegate didDiscoverServices callback but not waiting") + } + } + + override fun peripheral( + peripheral: CBPeripheral, + didDiscoverCharacteristicsForService: CBService, + error: NSError? + ) { + val service = didDiscoverCharacteristicsForService + Logger.d(TAG, "didDiscoverCharacteristicsForService service=$service error=${error?.toKotlinError()}") + if (waitFor?.state == WaitState.PERIPHERAL_DISCOVER_CHARACTERISTICS) { + if (error != null) { + resumeWaitWithException(error.toKotlinError()) + } else { + resumeWait() + } + } else { + Logger.w(TAG, "CBPeripheralDelegate didDiscoverCharacteristicsForService callback but not waiting") + } + } + + override fun peripheral( + peripheral: CBPeripheral, + didUpdateValueForCharacteristic: CBCharacteristic, + error: NSError? + ) { + val characteristic = didUpdateValueForCharacteristic + Logger.d(TAG, "didUpdateValueForCharacteristic characteristic=$characteristic error=${error?.toKotlinError()}") + if (didUpdateValueForCharacteristic == readCharacteristic) { + try { + handleIncomingData(characteristic.value!!.toByteArray()) + } catch (error: Throwable) { + onError(error) + } + } else { + if (waitFor?.state == WaitState.PERIPHERAL_READ_VALUE_FOR_CHARACTERISTIC && + waitFor?.characteristic == characteristic + ) { + if (error != null) { + resumeWaitWithException(error.toKotlinError()) + } else { + resumeWait() + } + } else { + if (characteristic == stateCharacteristic) { + if (characteristic.value?.toByteArray() contentEquals byteArrayOf( + BleTransportConstants.STATE_CHARACTERISTIC_END.toByte() + )) { + Logger.i(TAG, "Received transport-specific termination message") + runBlocking { + incomingMessages.send(byteArrayOf()) + } + } else { + Logger.w(TAG, "Unexpected value written to state characteristic") + } + } else { + Logger.w(TAG, "CBPeripheralDelegate didUpdateValueForCharacteristic callback but not waiting") + } + } + } + } + + override fun peripheralIsReadyToSendWriteWithoutResponse(peripheral: CBPeripheral) { + Logger.d(TAG, "peripheralIsReadyToSendWriteWithoutResponse peripheral=$peripheral") + if (waitFor?.state == WaitState.CHARACTERISTIC_READY_TO_WRITE) { + resumeWait() + } else { + Logger.w(TAG, "peripheralIsReadyToSendWriteWithoutResponse but not waiting") + } + } + + override fun peripheral(peripheral: CBPeripheral, didModifyServices: List<*>) { + val invalidatedServices = didModifyServices + Logger.d(TAG, "peripheral:didModifyServices invalidatedServices=${invalidatedServices}") + onError(Error("Remote service vanished")) + } + + override fun peripheral(peripheral: CBPeripheral, didOpenL2CAPChannel: CBL2CAPChannel?, error: NSError?) { + Logger.d(TAG, "peripheralDidOpenL2CAPChannel") + if (waitFor?.state == WaitState.OPEN_L2CAP_CHANNEL) { + if (error != null) { + resumeWaitWithException(Error("peripheralDidOpenL2CAPChannel failed", error.toKotlinError())) + } else { + this@BleCentralManagerIos.l2capChannel = didOpenL2CAPChannel + resumeWait() + } + } else { + Logger.w(TAG, "peripheralDidOpenL2CAPChannel but not waiting") + } + } + } + + private suspend fun writeToCharacteristicWithoutResponse( + characteristic: CBCharacteristic, + value: ByteArray + ) { + check(peripheral != null) + suspendCancellableCoroutine { continuation -> + setWaitCondition(WaitState.CHARACTERISTIC_READY_TO_WRITE, continuation) + + Logger.i(TAG, "writing") + peripheral!!.writeValue( + data = value.toNSData(), + forCharacteristic = characteristic, + CBCharacteristicWriteWithoutResponse + ) + if (peripheral!!.canSendWriteWithoutResponse) { + throw Error("canSendWriteWithoutResponse is true right after writing value") + } + } + } + + + private var incomingMessage = ByteStringBuilder() + + override val incomingMessages = Channel(Channel.UNLIMITED) + + private fun handleIncomingData(chunk: ByteArray) { + if (chunk.size < 1) { + throw Error("Invalid data length ${chunk.size} for Server2Client characteristic") + } + incomingMessage.append(chunk, 1, chunk.size) + when { + chunk[0].toInt() == 0x00 -> { + // Last message. + val newMessage = incomingMessage.toByteString().toByteArray() + incomingMessage = ByteStringBuilder() + runBlocking { + incomingMessages.send(newMessage) + } + } + + chunk[0].toInt() == 0x01 -> { + if (chunk.size != maxCharacteristicSize) { + // Because this is not fatal and some buggy implementations do this, we treat + // it just as a warning, not an error. + Logger.w(TAG, "Server2Client received ${chunk.size} bytes which is not the " + + "expected $maxCharacteristicSize bytes") + } + } + + else -> { + throw Error("Invalid first byte ${chunk[0]} in Server2Client data chunk, " + + "expected 0 or 1") + } + } + } + + private val centralManagerDelegate = object : NSObject(), CBCentralManagerDelegateProtocol { + override fun centralManagerDidUpdateState(cbCentralManager: CBCentralManager) { + Logger.d(TAG, "didUpdateState state=${centralManager.state}") + if (waitFor?.state == WaitState.POWER_ON) { + if (centralManager.state == CBCentralManagerStatePoweredOn) { + resumeWait() + } else { + resumeWaitWithException(Error("Excepted poweredOn, got ${centralManager.state}")) + } + } else { + Logger.w(TAG, "CBCentralManagerDelegate didUpdateState callback but not waiting") + } + } + + override fun centralManager( + central: CBCentralManager, + didDiscoverPeripheral: CBPeripheral, + advertisementData: Map, + RSSI: NSNumber + ) { + val discoveredPeripheral = didDiscoverPeripheral + Logger.d(TAG, "didDiscoverPeripheral: discoveredPeripheral=$discoveredPeripheral") + if (waitFor?.state == WaitState.PERIPHERAL_DISCOVERED) { + peripheral = discoveredPeripheral + peripheral!!.delegate = peripheralDelegate + resumeWait() + } else { + Logger.w(TAG, "CBCentralManagerDelegate didDiscoverPeripheral callback but not waiting") + } + } + + override fun centralManager( + central: CBCentralManager, + didConnectPeripheral: CBPeripheral + ) { + var connectedPeripheral = didConnectPeripheral + Logger.d(TAG, "didDiscoverPeripheral: peripheral=$connectedPeripheral") + if (waitFor?.state == WaitState.CONNECT_TO_PERIPHERAL) { + if (connectedPeripheral == peripheral) { + resumeWait() + } else { + Logger.w(TAG, "Callback for unexpected peripheral") + } + } else { + Logger.w(TAG, "CBCentralManagerDelegate didConnectPeripheral callback but not waiting") + } + } + + override fun centralManager( + central: CBCentralManager, + didDisconnectPeripheral: CBPeripheral, + timestamp: CFAbsoluteTime, + isReconnecting: Boolean, + error: NSError? + ) { + Logger.d(TAG, "didDisconnectPeripheral: peripheral=$didDisconnectPeripheral timestamp=${timestamp} " + + "isReconnecting=$isReconnecting error=${error?.toKotlinError()}") + onError(Error("Peripheral unexpectedly disconnected")) + } + } + + init { + centralManager = CBCentralManager( + delegate = centralManagerDelegate, + queue = null, + options = null, + ) + } + + override suspend fun waitForPowerOn() { + if (centralManager.state != CBCentralManagerStatePoweredOn) { + suspendCancellableCoroutine { continuation -> + setWaitCondition(WaitState.POWER_ON, continuation) + } + } + } + + override suspend fun waitForPeripheralWithUuid(uuid: UUID) { + suspendCancellableCoroutine { continuation -> + setWaitCondition(WaitState.PERIPHERAL_DISCOVERED, continuation) + centralManager.scanForPeripheralsWithServices( + serviceUUIDs = listOf(CBUUID.UUIDWithString(uuid.toString())), + options = null + ) + } + centralManager.stopScan() + } + + override suspend fun connectToPeripheral() { + check(peripheral != null) + suspendCancellableCoroutine { continuation -> + setWaitCondition(WaitState.CONNECT_TO_PERIPHERAL, continuation) + centralManager.connectPeripheral(peripheral!!, null) + } + } + + override suspend fun requestMtu() { + // The MTU isn't configurable in CoreBluetooth so this is a NO-OP. + } + + override suspend fun peripheralDiscoverServices(uuid: UUID) { + check(peripheral != null) + suspendCancellableCoroutine { continuation -> + setWaitCondition(WaitState.PERIPHERAL_DISCOVER_SERVICES, continuation) + peripheral!!.discoverServices( + serviceUUIDs = listOf(CBUUID.UUIDWithString(uuid.toString())), + ) + } + } + + private fun getCharacteristic( + availableCharacteristics: List, + uuid: UUID, + ): CBCharacteristic { + val uuidLowercase = uuid.toString().lowercase() + val characteristic = availableCharacteristics.find { + it.UUID.UUIDString.lowercase() == uuidLowercase + } + if (characteristic == null) { + throw IllegalStateException("Missing characteristic with UUID $uuidLowercase") + } + return characteristic + } + + private suspend fun processCharacteristics( + availableCharacteristics: List + ) { + readCharacteristic = getCharacteristic( + availableCharacteristics, + server2ClientCharacteristicUuid + ) + writeCharacteristic = getCharacteristic( + availableCharacteristics, + client2ServerCharacteristicUuid + ) + stateCharacteristic = getCharacteristic( + availableCharacteristics, + stateCharacteristicUuid + ) + if (identCharacteristicUuid != null) { + identCharacteristic = getCharacteristic( + availableCharacteristics, + identCharacteristicUuid!! + ) + } + if (l2capCharacteristicUuid != null) { + try { + l2capCharacteristic = getCharacteristic( + availableCharacteristics, + l2capCharacteristicUuid!! + ) + readValueForCharacteristic(l2capCharacteristic!!) + val value = l2capCharacteristic!!.value!!.toByteArray() + _l2capPsm = ((value[0].toUInt().and(0xffU) shl 24) + + (value[1].toUInt().and(0xffU) shl 16) + + (value[2].toUInt().and(0xffU) shl 8) + + (value[3].toUInt().and(0xffU) shl 0)).toInt() + Logger.i(TAG, "L2CAP PSM is $_l2capPsm") + } catch (e: Throwable) { + Logger.i(TAG, "L2CAP not available on peripheral", e) + } + } + } + + override suspend fun peripheralDiscoverCharacteristics() { + check(peripheral != null) + for (service in peripheral!!.services!!) { + service as CBService + suspendCancellableCoroutine { continuation -> + setWaitCondition(WaitState.PERIPHERAL_DISCOVER_CHARACTERISTICS, continuation) + peripheral!!.discoverCharacteristics( + characteristicUUIDs = null, + service as CBService, + ) + } + processCharacteristics(service.characteristics as List) + } + + val maximumWriteValueLengthForType = peripheral!!.maximumWriteValueLengthForType( + CBCharacteristicWriteWithoutResponse + ).toInt() + maxCharacteristicSize = min(maximumWriteValueLengthForType, 512) + Logger.i(TAG, "Using $maxCharacteristicSize as maximum data size for characteristics") + } + + private suspend fun readValueForCharacteristic(characteristic: CBCharacteristic) { + check(peripheral != null) + suspendCancellableCoroutine { continuation -> + setWaitCondition( + WaitState.PERIPHERAL_READ_VALUE_FOR_CHARACTERISTIC, + continuation, + characteristic + ) + peripheral!!.readValueForCharacteristic(characteristic) + } + } + + override suspend fun checkReaderIdentMatches(eSenderKey: EcPublicKey) { + check(peripheral != null) + check(identCharacteristic != null) + readValueForCharacteristic(identCharacteristic!!) + + val ikm = Cbor.encode(Tagged(24, Bstr(Cbor.encode(eSenderKey.toCoseKey().toDataItem())))) + val info = "BLEIdent".encodeToByteArray() + val salt = byteArrayOf() + val expectedIdentValue = Crypto.hkdf(Algorithm.HMAC_SHA256, ikm, salt, info, 16) + val identValue = identCharacteristic!!.value!!.toByteArray() + if (!(expectedIdentValue contentEquals identValue)) { + close() + throw Error( + "Ident doesn't match, expected ${expectedIdentValue.toHex()} " + + " got ${identValue.toHex()}" + ) + } + } + + override suspend fun subscribeToCharacteristics() { + check(stateCharacteristic != null) + check(readCharacteristic != null) + peripheral!!.setNotifyValue(true, stateCharacteristic!!) + peripheral!!.setNotifyValue(true, readCharacteristic!!) + } + + override suspend fun writeToStateCharacteristic(value: Int) { + check(stateCharacteristic != null) + writeToCharacteristicWithoutResponse( + stateCharacteristic!!, + byteArrayOf(value.toByte()) + ) + } + + override suspend fun sendMessage(message: ByteArray) { + if (l2capChannel != null) { + l2capSendMessage(message) + return + } + Logger.i(TAG, "sendMessage ${message.size} length") + val maxChunkSize = maxCharacteristicSize - 1 // Need room for the leading 0x00 or 0x01 + val offsets = 0 until message.size step maxChunkSize + for (offset in offsets) { + val moreDataComing = (offset != offsets.last) + val size = min(maxChunkSize, message.size - offset) + + val builder = ByteStringBuilder(size + 1) + builder.append(if (moreDataComing) 0x01 else 0x00) + builder.append(message, offset, offset + size) + val chunk = builder.toByteString().toByteArray() + + writeToCharacteristicWithoutResponse(writeCharacteristic!!, chunk) + } + Logger.i(TAG, "sendMessage completed") + } + + override fun close() { + centralManager.delegate = null + // Delayed closed because there's no way to flush L2CAP connections... + peripheral?.let { + CoroutineScope(Dispatchers.IO).launch { + delay(5000) + centralManager.cancelPeripheralConnection(it) + } + } + peripheral?.delegate = null + peripheral = null + incomingMessages.close() + l2capSink?.close() + l2capSink = null + l2capSource?.close() + l2capSource = null + l2capChannel = null + } + + private var _l2capPsm: Int? = null + + override val l2capPsm: Int? + get() = _l2capPsm + + override val usingL2cap: Boolean + get() = (l2capChannel != null) + + private var l2capChannel: CBL2CAPChannel? = null + private var l2capSink: Sink? = null + private var l2capSource: Source? = null + + override suspend fun connectL2cap(psm: Int) { + Logger.i(TAG, "connectL2cap $psm") + check(peripheral != null) + if (peripheral!!.state != CBPeripheralStateConnected) { + // This can happen on the path where the PSM is used directly from the engagement + connectToPeripheral() + } + suspendCancellableCoroutine { continuation -> + setWaitCondition(WaitState.OPEN_L2CAP_CHANNEL, continuation) + peripheral!!.openL2CAPChannel(psm.toUShort()) + } + Logger.i(TAG, "l2capChannel open") + l2capSink = l2capChannel!!.outputStream!!.asSink().buffered() + l2capSource = l2capChannel!!.inputStream!!.asSource().buffered() + + // Start reading in a coroutine + CoroutineScope(Dispatchers.IO).launch { + l2capReadChannel() + } + } + + private suspend fun l2capReadChannel() { + try { + while (true) { + val length = l2capSource!!.readInt() + val message = l2capSource!!.readByteArray(length) + incomingMessages.send(message) + } + } catch (e: Throwable) { + onError(Error("Reading from L2CAP channel failed", e)) + } + } + + private suspend fun l2capSendMessage(message: ByteArray) { + Logger.i(TAG, "l2capSendMessage ${message.size} length") + val bsb = ByteStringBuilder() + val length = message.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(message) + val messageWithHeader = bsb.toByteString().toByteArray() + + // NOTE: for some reason, iOS just fills in zeroes near the end if we send a + // really large message. Chunking it up in individual writes fixes this. We use + // the constant value L2CAP_CHUNK_SIZE for the chunk size. + // + //l2capSink?.write(payload) + //l2capSink?.emit() + + for (offset in 0 until messageWithHeader.size step L2CAP_CHUNK_SIZE) { + val size = min(L2CAP_CHUNK_SIZE, messageWithHeader.size - offset) + val chunk = messageWithHeader.slice(IntRange(offset, offset + size - 1)).toByteArray() + l2capSink?.write(chunk) + l2capSink?.flush() + } + } +} diff --git a/identity-mdoc/src/iosMain/kotlin/com/android/identity/mdoc/transport/BlePeripheralManagerIos.kt b/identity-mdoc/src/iosMain/kotlin/com/android/identity/mdoc/transport/BlePeripheralManagerIos.kt new file mode 100644 index 000000000..eb37f983e --- /dev/null +++ b/identity-mdoc/src/iosMain/kotlin/com/android/identity/mdoc/transport/BlePeripheralManagerIos.kt @@ -0,0 +1,530 @@ +package com.android.identity.mdoc.transport + +import com.android.identity.cbor.Bstr +import com.android.identity.cbor.Cbor +import com.android.identity.cbor.Tagged +import com.android.identity.crypto.Algorithm +import com.android.identity.crypto.Crypto +import com.android.identity.crypto.EcPublicKey +import com.android.identity.util.Logger +import com.android.identity.util.UUID +import com.android.identity.util.toByteArray +import com.android.identity.util.toHex +import com.android.identity.util.toKotlinError +import com.android.identity.util.toNSData +import kotlinx.coroutines.CancellableContinuation +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.IO +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.suspendCancellableCoroutine +import kotlinx.io.Sink +import kotlinx.io.Source +import kotlinx.io.asSink +import kotlinx.io.asSource +import kotlinx.io.buffered +import kotlinx.io.bytestring.ByteStringBuilder +import kotlinx.io.readByteArray +import platform.CoreBluetooth.CBATTErrorSuccess +import platform.CoreBluetooth.CBATTRequest +import platform.CoreBluetooth.CBPeripheralManager +import platform.CoreBluetooth.CBCharacteristic +import platform.CoreBluetooth.CBPeripheralManagerDelegateProtocol +import platform.CoreBluetooth.CBPeripheralManagerStatePoweredOn +import platform.CoreBluetooth.CBAdvertisementDataServiceUUIDsKey +import platform.CoreBluetooth.CBCharacteristicPropertyWriteWithoutResponse +import platform.CoreBluetooth.CBCharacteristicPropertyNotify +import platform.CoreBluetooth.CBAttributePermissionsReadable +import platform.CoreBluetooth.CBAttributePermissionsWriteable +import platform.CoreBluetooth.CBCharacteristicPropertyRead +import platform.CoreBluetooth.CBL2CAPChannel +import platform.CoreBluetooth.CBL2CAPPSM +import platform.CoreBluetooth.CBUUID +import platform.CoreBluetooth.CBMutableService +import platform.CoreBluetooth.CBMutableCharacteristic +import platform.Foundation.NSArray +import platform.Foundation.NSError +import platform.darwin.NSObject +import kotlin.coroutines.resume +import kotlin.coroutines.resumeWithException +import kotlin.math.min + +internal class BlePeripheralManagerIos: BlePeripheralManager { + companion object { + private const val TAG = "BlePeripheralManagerIos" + private const val L2CAP_CHUNK_SIZE = 512 + } + + private lateinit var stateCharacteristicUuid: UUID + private lateinit var client2ServerCharacteristicUuid: UUID + private lateinit var server2ClientCharacteristicUuid: UUID + private var identCharacteristicUuid: UUID? = null + private var l2capCharacteristicUuid: UUID? = null + + override fun setUuids( + stateCharacteristicUuid: UUID, + client2ServerCharacteristicUuid: UUID, + server2ClientCharacteristicUuid: UUID, + identCharacteristicUuid: UUID?, + l2capCharacteristicUuid: UUID? + ) { + this.stateCharacteristicUuid = stateCharacteristicUuid + this.client2ServerCharacteristicUuid = client2ServerCharacteristicUuid + this.server2ClientCharacteristicUuid = server2ClientCharacteristicUuid + this.identCharacteristicUuid = identCharacteristicUuid + this.l2capCharacteristicUuid = l2capCharacteristicUuid + } + + private lateinit var onError: (error: Throwable) -> Unit + private lateinit var onClosed: () -> Unit + + override fun setCallbacks( + onError: (Throwable) -> Unit, + onClosed: () -> Unit + ) { + this.onError = onError + this.onClosed = onClosed + } + + internal enum class WaitState { + POWER_ON, + STATE_CHARACTERISTIC_WRITTEN_OR_L2CAP_CLIENT, + CHARACTERISTIC_READY_TO_WRITE, + PUBLISH_L2CAP_CHANNEL, + } + + private data class WaitFor( + val state: WaitState, + val continuation: CancellableContinuation, + val characteristic: CBCharacteristic? = null, + ) + + private var waitFor: WaitFor? = null + + private fun setWaitCondition( + state: WaitState, + continuation: CancellableContinuation, + characteristic: CBCharacteristic? = null + ) { + check(waitFor == null) + waitFor = WaitFor( + state, + continuation, + characteristic + ) + } + + private fun resumeWait() { + val continuation = waitFor!!.continuation + waitFor = null + continuation.resume(true) + } + + private fun resumeWaitWithException(exception: Throwable) { + val continuation = waitFor!!.continuation + waitFor = null + continuation.resumeWithException(exception) + } + + private var maxCharacteristicSize = -1 + + private var incomingMessage = ByteStringBuilder() + + override val incomingMessages = Channel(Channel.UNLIMITED) + + private fun handleIncomingData(chunk: ByteArray) { + if (chunk.size < 1) { + throw Error("Invalid data length ${chunk.size} for Client2Server characteristic") + } + incomingMessage.append(chunk, 1, chunk.size) + when { + chunk[0].toInt() == 0x00 -> { + // Last message. + val newMessage = incomingMessage.toByteString().toByteArray() + incomingMessage = ByteStringBuilder() + runBlocking { + incomingMessages.send(newMessage) + } + } + + chunk[0].toInt() == 0x01 -> { + if (chunk.size != maxCharacteristicSize) { + Logger.w(TAG, "Client2Server received ${chunk.size} bytes which is not the " + + "expected $maxCharacteristicSize bytes") + } + } + + else -> { + throw Error("Invalid first byte ${chunk[0]} in Client2Server data chunk, " + + "expected 0 or 1") + } + } + } + + private val peripheralManager: CBPeripheralManager + + private val peripheralManagerDelegate = object : NSObject(), CBPeripheralManagerDelegateProtocol { + override fun peripheralManagerDidUpdateState(peripheral: CBPeripheralManager) { + if (waitFor?.state == WaitState.POWER_ON) { + if (peripheralManager.state == CBPeripheralManagerStatePoweredOn) { + resumeWait() + } else { + resumeWaitWithException(Error("Excepted poweredOn, got ${peripheralManager.state}")) + } + } + } + + override fun peripheralManager( + peripheral: CBPeripheralManager, + didReceiveWriteRequests: List<*> + ) { + for (attRequest in didReceiveWriteRequests) { + attRequest as CBATTRequest + + if (attRequest.characteristic == stateCharacteristic) { + val data = attRequest.value?.toByteArray() ?: byteArrayOf() + if (waitFor?.state == WaitState.STATE_CHARACTERISTIC_WRITTEN_OR_L2CAP_CLIENT) { + if (!(data contentEquals byteArrayOf( + BleTransportConstants.STATE_CHARACTERISTIC_START.toByte() + ))) { + resumeWaitWithException( + Error("Expected 0x01 to be written to state characteristic, got ${data.toHex()}") + ) + } else { + // Now that the central connected, figure out how big the buffer is for writes. + val maximumUpdateValueLength = attRequest.central.maximumUpdateValueLength.toInt() + maxCharacteristicSize = min(maximumUpdateValueLength, 512) + Logger.i(TAG, "Using $maxCharacteristicSize as maximum data size for characteristics") + + // Since the central found us, we can stop advertising.... + peripheralManager.stopAdvertising() + + resumeWait() + } + } else { + if (data contentEquals byteArrayOf(BleTransportConstants.STATE_CHARACTERISTIC_END.toByte())) { + Logger.i(TAG, "Received transport-specific termination message") + runBlocking { + incomingMessages.send(byteArrayOf()) + } + } else { + Logger.w(TAG, "Got write to state characteristics without waiting for it") + } + } + } else if (attRequest.characteristic == readCharacteristic) { + val data = attRequest.value?.toByteArray() ?: byteArrayOf() + try { + handleIncomingData(data) + } catch (e: Throwable) { + onError(Error("Error processing incoming data", e)) + } + } else { + Logger.w(TAG, "Unexpected write to characteristic with UUID " + + attRequest.characteristic.UUID.UUIDString) + } + } + } + + override fun peripheralManager(peripheral: CBPeripheralManager, didReceiveReadRequest: CBATTRequest) { + val attRequest = didReceiveReadRequest + if (attRequest.characteristic == identCharacteristic) { + if (identValue == null) { + onError(Error("Received request for ident before it's set..")) + } else { + attRequest.value = identValue!!.toNSData() + peripheralManager.respondToRequest(attRequest, CBATTErrorSuccess) + } + } + } + + override fun peripheralManagerIsReadyToUpdateSubscribers(peripheral: CBPeripheralManager) { + if (waitFor?.state == WaitState.CHARACTERISTIC_READY_TO_WRITE) { + resumeWait() + } + } + + override fun peripheralManager( + peripheral: CBPeripheralManager, + didPublishL2CAPChannel: CBL2CAPPSM, + error: NSError? + ) { + Logger.i(TAG, "peripheralManager didPublishL2CAPChannel") + if (waitFor?.state == WaitState.PUBLISH_L2CAP_CHANNEL) { + if (error != null) { + resumeWaitWithException(Error("peripheralManager didPublishL2CAPChannel failed", error.toKotlinError())) + } else { + _l2capPsm = didPublishL2CAPChannel.toInt() + resumeWait() + } + } else { + Logger.w(TAG, "peripheralManager didPublishL2CAPChannel but not waiting") + } + } + + override fun peripheralManager( + peripheral: CBPeripheralManager, + didOpenL2CAPChannel: CBL2CAPChannel?, + error: NSError? + ) { + Logger.i(TAG, "peripheralManager didOpenL2CAPChannel") + + if (error != null) { + Logger.w(TAG, "peripheralManager didOpenL2CAPChannel error=$error") + } else { + if (waitFor?.state == WaitState.STATE_CHARACTERISTIC_WRITTEN_OR_L2CAP_CLIENT) { + // Since the central found us, we can stop advertising.... + peripheralManager.stopAdvertising() + + l2capChannel = didOpenL2CAPChannel + l2capSink = l2capChannel!!.outputStream!!.asSink().buffered() + l2capSource = l2capChannel!!.inputStream!!.asSource().buffered() + + // Start reading in a coroutine + CoroutineScope(Dispatchers.IO).launch { + l2capReadChannel() + } + + resumeWait() + } else { + Logger.w(TAG, "Ignoring incoming L2CAP connection since we're not waiting") + } + } + } + + } + + init { + peripheralManager = CBPeripheralManager( + delegate = peripheralManagerDelegate, + queue = null, + options = null + ) + } + + override suspend fun waitForPowerOn() { + if (peripheralManager.state != CBPeripheralManagerStatePoweredOn) { + suspendCancellableCoroutine { continuation -> + setWaitCondition(WaitState.POWER_ON, continuation) + } + } + } + + private var service: CBMutableService? = null + + private var readCharacteristic: CBMutableCharacteristic? = null + private var writeCharacteristic: CBMutableCharacteristic? = null + private var stateCharacteristic: CBMutableCharacteristic? = null + private var identCharacteristic: CBMutableCharacteristic? = null + private var l2capCharacteristic: CBMutableCharacteristic? = null + private var identValue: ByteArray? = null + + override suspend fun advertiseService(uuid: UUID) { + service = CBMutableService( + type = CBUUID.UUIDWithString(uuid.toString()), + primary = true + ) + stateCharacteristic = CBMutableCharacteristic( + type = CBUUID.UUIDWithString(stateCharacteristicUuid.toString()), + properties = CBCharacteristicPropertyNotify + + CBCharacteristicPropertyWriteWithoutResponse, + value = null, + permissions = CBAttributePermissionsWriteable, + ) + readCharacteristic = CBMutableCharacteristic( + type = CBUUID.UUIDWithString(client2ServerCharacteristicUuid.toString()), + properties = CBCharacteristicPropertyWriteWithoutResponse, + value = null, + permissions = CBAttributePermissionsWriteable, + ) + writeCharacteristic = CBMutableCharacteristic( + type = CBUUID.UUIDWithString(server2ClientCharacteristicUuid.toString()), + properties = CBCharacteristicPropertyNotify, + value = null, + permissions = CBAttributePermissionsReadable + CBAttributePermissionsWriteable, + ) + if (identCharacteristicUuid != null) { + identCharacteristic = CBMutableCharacteristic( + type = CBUUID.UUIDWithString(identCharacteristicUuid.toString()), + properties = CBCharacteristicPropertyRead, + value = null, + permissions = CBAttributePermissionsReadable, + ) + } + if (l2capCharacteristicUuid != null) { + suspendCancellableCoroutine { continuation -> + setWaitCondition(WaitState.PUBLISH_L2CAP_CHANNEL, continuation) + peripheralManager.publishL2CAPChannelWithEncryption(false) + } + Logger.i(TAG, "Listening on PSM $_l2capPsm") + val bsb = ByteStringBuilder() + bsb.apply { + append((_l2capPsm!! shr 24).and(0xff).toByte()) + append((_l2capPsm!! shr 16).and(0xff).toByte()) + append((_l2capPsm!! shr 8).and(0xff).toByte()) + append((_l2capPsm!! shr 0).and(0xff).toByte()) + } + val encodedPsm = bsb.toByteString().toByteArray() + l2capCharacteristic = CBMutableCharacteristic( + type = CBUUID.UUIDWithString(l2capCharacteristicUuid.toString()), + properties = CBCharacteristicPropertyRead, + value = encodedPsm.toNSData(), + permissions = CBAttributePermissionsReadable, + ) + } + + service!!.setCharacteristics(( + service!!.characteristics ?: listOf()) + + listOf( + stateCharacteristic, + readCharacteristic, + writeCharacteristic, + ) + + (if (identCharacteristic != null) listOf(identCharacteristic) else listOf()) + + (if (l2capCharacteristic != null) listOf(l2capCharacteristic) else listOf()) + ) + peripheralManager.addService(service!!) + peripheralManager.startAdvertising( + mapOf( + CBAdvertisementDataServiceUUIDsKey to + (listOf(CBUUID.UUIDWithString(uuid.toString())) as NSArray) + ) + ) + } + + override suspend fun setESenderKey(eSenderKey: EcPublicKey) { + val ikm = Cbor.encode(Tagged(24, Bstr(Cbor.encode(eSenderKey.toCoseKey().toDataItem())))) + val info = "BLEIdent".encodeToByteArray() + val salt = byteArrayOf() + identValue = Crypto.hkdf(Algorithm.HMAC_SHA256, ikm, salt, info, 16) + } + + override suspend fun waitForStateCharacteristicWriteOrL2CAPClient() { + suspendCancellableCoroutine { continuation -> + setWaitCondition(WaitState.STATE_CHARACTERISTIC_WRITTEN_OR_L2CAP_CLIENT, continuation) + } + } + + private suspend fun writeToCharacteristic( + characteristic: CBMutableCharacteristic, + value: ByteArray, + ) { + while (true) { + val wasSent = peripheralManager.updateValue( + value = value.toNSData(), + forCharacteristic = characteristic, + onSubscribedCentrals = null + ) + if (wasSent) { + Logger.i(TAG, "Wrote to characteristic ${characteristic.UUID}") + break + } + Logger.i(TAG, "Not ready to send to characteristic ${characteristic.UUID}, waiting") + suspendCancellableCoroutine { continuation -> + setWaitCondition(WaitState.CHARACTERISTIC_READY_TO_WRITE, continuation) + } + } + } + + override suspend fun writeToStateCharacteristic(value: Int) { + writeToCharacteristic(stateCharacteristic!!, byteArrayOf(value.toByte())) + } + + override suspend fun sendMessage(message: ByteArray) { + if (l2capChannel != null) { + l2capSendMessage(message) + return + } + Logger.i(TAG, "sendMessage ${message.size} length") + val maxChunkSize = maxCharacteristicSize - 1 // Need room for the leading 0x00 or 0x01 + val offsets = 0 until message.size step maxChunkSize + for (offset in offsets) { + val moreDataComing = (offset != offsets.last) + val size = min(maxChunkSize, message.size - offset) + + val builder = ByteStringBuilder(size + 1) + builder.append(if (moreDataComing) 0x01 else 0x00) + builder.append(message, offset, offset + size) + val chunk = builder.toByteString().toByteArray() + + writeToCharacteristic(writeCharacteristic!!, chunk) + } + Logger.i(TAG, "sendMessage completed") + } + + override fun close() { + // Delayed closed because there's no way to flush L2CAP connections... + _l2capPsm?.let { + CoroutineScope(Dispatchers.IO).launch { + delay(5000) + peripheralManager.unpublishL2CAPChannel(it.toUShort()) + } + } + peripheralManager.stopAdvertising() + peripheralManager.removeAllServices() + service = null + peripheralManager.delegate = null + incomingMessages.close() + l2capSink?.close() + l2capSink = null + l2capSource?.close() + l2capSource = null + l2capChannel = null + } + + override val l2capPsm: Int? + get() = _l2capPsm + + private var _l2capPsm: Int? = null + + private var l2capChannel: CBL2CAPChannel? = null + private var l2capSink: Sink? = null + private var l2capSource: Source? = null + + override val usingL2cap: Boolean + get() = (l2capChannel != null) + + private suspend fun l2capReadChannel() { + try { + while (true) { + val length = l2capSource!!.readInt() + val message = l2capSource!!.readByteArray(length) + incomingMessages.send(message) + } + } catch (e: Throwable) { + onError(Error("Reading from L2CAP channel failed", e)) + } + } + + private suspend fun l2capSendMessage(message: ByteArray) { + Logger.i(TAG, "l2capSendMessage ${message.size} length") + val bsb = ByteStringBuilder() + val length = message.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(message) + val messageWithHeader = bsb.toByteString().toByteArray() + + // NOTE: for some reason, iOS just fills in zeroes near the end if we send a + // really large message. Chunking it up in individual writes fixes this. We use + // the constant value L2CAP_CHUNK_SIZE for the chunk size. + // + //l2capSink?.write(payload) + //l2capSink?.emit() + + for (offset in 0 until messageWithHeader.size step L2CAP_CHUNK_SIZE) { + val size = min(L2CAP_CHUNK_SIZE, messageWithHeader.size - offset) + val chunk = messageWithHeader.slice(IntRange(offset, offset + size - 1)).toByteArray() + l2capSink?.write(chunk) + l2capSink?.flush() + } + } + +} + diff --git a/identity-mdoc/src/iosMain/kotlin/com/android/identity/mdoc/transport/MdocTransportFactory.ios.kt b/identity-mdoc/src/iosMain/kotlin/com/android/identity/mdoc/transport/MdocTransportFactory.ios.kt new file mode 100644 index 000000000..1a16e5314 --- /dev/null +++ b/identity-mdoc/src/iosMain/kotlin/com/android/identity/mdoc/transport/MdocTransportFactory.ios.kt @@ -0,0 +1,68 @@ +package com.android.identity.mdoc.transport + +import com.android.identity.mdoc.connectionmethod.ConnectionMethod +import com.android.identity.mdoc.connectionmethod.ConnectionMethodBle + +actual class MdocTransportFactory { + actual companion object { + actual fun createTransport( + connectionMethod: ConnectionMethod, + role: MdocTransport.Role, + options: MdocTransportOptions + ): MdocTransport { + when (connectionMethod) { + is ConnectionMethodBle -> { + if (connectionMethod.supportsCentralClientMode && + connectionMethod.supportsPeripheralServerMode) { + throw IllegalArgumentException( + "Only Central Client or Peripheral Server mode is supported at one time, not both" + ) + } else if (connectionMethod.supportsCentralClientMode) { + return when (role) { + MdocTransport.Role.MDOC -> { + BleTransportCentralMdoc( + role, + options, + BleCentralManagerIos(), + connectionMethod.centralClientModeUuid!!, + connectionMethod.peripheralServerModePsm + ) + } + MdocTransport.Role.MDOC_READER -> { + BleTransportCentralMdocReader( + role, + options, + BlePeripheralManagerIos(), + connectionMethod.centralClientModeUuid!! + ) + } + } + } else { + return when (role) { + MdocTransport.Role.MDOC -> { + BleTransportPeripheralMdoc( + role, + options, + BlePeripheralManagerIos(), + connectionMethod.peripheralServerModeUuid!! + ) + } + MdocTransport.Role.MDOC_READER -> { + BleTransportPeripheralMdocReader( + role, + options, + BleCentralManagerIos(), + connectionMethod.peripheralServerModeUuid!!, + connectionMethod.peripheralServerModePsm, + ) + } + } + } + } + else -> { + throw IllegalArgumentException("$connectionMethod is not supported") + } + } + } + } +} \ No newline at end of file diff --git a/identity-mdoc/src/jvmMain/kotlin/com/android/identity/mdoc/transport/TransportFactory.jvm.kt b/identity-mdoc/src/jvmMain/kotlin/com/android/identity/mdoc/transport/TransportFactory.jvm.kt new file mode 100644 index 000000000..6be674d39 --- /dev/null +++ b/identity-mdoc/src/jvmMain/kotlin/com/android/identity/mdoc/transport/TransportFactory.jvm.kt @@ -0,0 +1,15 @@ +package com.android.identity.mdoc.transport + +import com.android.identity.mdoc.connectionmethod.ConnectionMethod + +actual class MdocTransportFactory { + actual companion object { + actual fun createTransport( + connectionMethod: ConnectionMethod, + role: MdocTransport.Role, + options: MdocTransportOptions, + ): MdocTransport { + throw NotImplementedError("MdocTransportFactory is not available for JVM") + } + } +} \ No newline at end of file diff --git a/identity/build.gradle.kts b/identity/build.gradle.kts index 16364b444..f6e7dbb88 100644 --- a/identity/build.gradle.kts +++ b/identity/build.gradle.kts @@ -1,7 +1,12 @@ +import org.gradle.kotlin.dsl.dependencies +import org.gradle.kotlin.dsl.get +import org.jetbrains.kotlin.gradle.ExperimentalKotlinGradlePluginApi +import org.jetbrains.kotlin.gradle.dsl.JvmTarget import org.jetbrains.kotlin.konan.target.HostManager plugins { alias(libs.plugins.kotlinMultiplatform) + alias(libs.plugins.androidLibrary) alias(libs.plugins.ksp) id("maven-publish") } @@ -14,6 +19,13 @@ kotlin { jvm() + androidTarget { + @OptIn(ExperimentalKotlinGradlePluginApi::class) + compilerOptions { + jvmTarget.set(JvmTarget.JVM_17) + } + } + listOf( iosX64(), iosArm64(), @@ -47,6 +59,8 @@ kotlin { } } + applyDefaultHierarchyTemplate() + sourceSets { val commonMain by getting { kotlin.srcDir("build/generated/ksp/metadata/commonMain/kotlin") @@ -75,6 +89,16 @@ kotlin { implementation(libs.tink) } } + + val androidMain by getting { + dependsOn(jvmMain) + dependencies { + implementation(libs.bouncy.castle.bcprov) + implementation(libs.bouncy.castle.bcpkix) + implementation(libs.tink) + } + } + } } @@ -98,6 +122,31 @@ tasks["iosSimulatorArm64SourcesJar"].dependsOn("kspCommonMainKotlinMetadata") tasks["jvmSourcesJar"].dependsOn("kspCommonMainKotlinMetadata") tasks["sourcesJar"].dependsOn("kspCommonMainKotlinMetadata") +android { + namespace = "com.android.identity" + compileSdk = libs.versions.android.compileSdk.get().toInt() + + defaultConfig { + minSdk = 26 + testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + } + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } + dependencies { + } + + packaging { + resources { + excludes += listOf("/META-INF/{AL2.0,LGPL2.1}") + excludes += listOf("/META-INF/versions/9/OSGI-INF/MANIFEST.MF") + } + } +} + + group = "com.android.identity" version = projectVersionName diff --git a/identity/src/androidMain/kotlin/com/android/identity/util/AndroidInitializer.kt b/identity/src/androidMain/kotlin/com/android/identity/util/AndroidInitializer.kt new file mode 100644 index 000000000..86f701283 --- /dev/null +++ b/identity/src/androidMain/kotlin/com/android/identity/util/AndroidInitializer.kt @@ -0,0 +1,13 @@ +package com.android.identity.util + +import android.content.Context + +class AndroidInitializer { + companion object { + lateinit var applicationContext: Context + + fun initialize(applicationContext: Context) { + AndroidInitializer.applicationContext = applicationContext + } + } +} diff --git a/identity/src/iosMain/kotlin/com/android/identity/crypto/CryptoIos.kt b/identity/src/iosMain/kotlin/com/android/identity/crypto/CryptoIos.kt index f1da2f7d1..4b33991ea 100644 --- a/identity/src/iosMain/kotlin/com/android/identity/crypto/CryptoIos.kt +++ b/identity/src/iosMain/kotlin/com/android/identity/crypto/CryptoIos.kt @@ -5,6 +5,8 @@ import com.android.identity.securearea.KeyLockedException import com.android.identity.securearea.KeyPurpose import com.android.identity.securearea.SecureEnclaveKeyUnlockData import com.android.identity.util.UUID +import com.android.identity.util.toByteArray +import com.android.identity.util.toNSData import kotlinx.cinterop.ExperimentalForeignApi import kotlinx.cinterop.addressOf import kotlinx.cinterop.allocArrayOf @@ -320,17 +322,3 @@ actual object Crypto { )?.toByteArray() ?: throw KeyLockedException("Unable to unlock key") } } - -@OptIn(ExperimentalForeignApi::class) -internal fun NSData.toByteArray(): ByteArray { - return ByteArray(length.toInt()).apply { - usePinned { - memcpy(it.addressOf(0), bytes, length) - } - } -} - -@OptIn(ExperimentalForeignApi::class) -internal fun ByteArray.toNSData(): NSData = memScoped { - NSData.create(bytes = allocArrayOf(this@toNSData), length = this@toNSData.size.toULong()) -} \ No newline at end of file diff --git a/identity/src/iosMain/kotlin/com/android/identity/crypto/X509CertIos.kt b/identity/src/iosMain/kotlin/com/android/identity/crypto/X509CertIos.kt index 1cd2eb54b..dd2d54bc0 100644 --- a/identity/src/iosMain/kotlin/com/android/identity/crypto/X509CertIos.kt +++ b/identity/src/iosMain/kotlin/com/android/identity/crypto/X509CertIos.kt @@ -3,6 +3,8 @@ package com.android.identity.crypto import com.android.identity.cbor.Bstr import com.android.identity.cbor.DataItem import com.android.identity.SwiftBridge +import com.android.identity.util.toByteArray +import com.android.identity.util.toNSData import kotlinx.cinterop.ExperimentalForeignApi import kotlin.io.encoding.Base64 import kotlin.io.encoding.ExperimentalEncodingApi diff --git a/identity/src/iosMain/kotlin/com/android/identity/util/IosUtil.kt b/identity/src/iosMain/kotlin/com/android/identity/util/IosUtil.kt new file mode 100644 index 000000000..1a3228530 --- /dev/null +++ b/identity/src/iosMain/kotlin/com/android/identity/util/IosUtil.kt @@ -0,0 +1,32 @@ +package com.android.identity.util + +import kotlinx.cinterop.ExperimentalForeignApi +import kotlinx.cinterop.addressOf +import kotlinx.cinterop.allocArrayOf +import kotlinx.cinterop.memScoped +import kotlinx.cinterop.usePinned +import platform.Foundation.NSData +import platform.Foundation.NSError +import platform.Foundation.create +import platform.posix.memcpy + +// Various iOS related utilities +// + +@OptIn(ExperimentalForeignApi::class) +fun NSData.toByteArray(): ByteArray { + return ByteArray(length.toInt()).apply { + usePinned { + memcpy(it.addressOf(0), bytes, length) + } + } +} + +@OptIn(ExperimentalForeignApi::class) +fun ByteArray.toNSData(): NSData = memScoped { + NSData.create(bytes = allocArrayOf(this@toNSData), length = this@toNSData.size.toULong()) +} + +fun NSError.toKotlinError(): Error { + return Error("NSError domain=${this.domain} code=${this.code}: ${this.localizedDescription}") +} \ No newline at end of file diff --git a/identity/src/iosMain/kotlin/com/android/identity/util/UUID.ios.kt b/identity/src/iosMain/kotlin/com/android/identity/util/UUID.ios.kt new file mode 100644 index 000000000..49315a9ed --- /dev/null +++ b/identity/src/iosMain/kotlin/com/android/identity/util/UUID.ios.kt @@ -0,0 +1,13 @@ +package com.android.identity.util + +import platform.Foundation.NSUUID + +// TODO: this could probably be faster if we use blobs instead of strings. + +fun UUID.toNSUUID(): NSUUID { + return NSUUID(this.toString()) +} + +fun UUID.Companion.fromNSUUID(nsUUID: NSUUID): UUID { + return UUID.fromString(nsUUID.UUIDString) +} diff --git a/identity/src/iosTest/kotlin/com/android/identity/util/UUIDIosTest.kt b/identity/src/iosTest/kotlin/com/android/identity/util/UUIDIosTest.kt new file mode 100644 index 000000000..c57740e54 --- /dev/null +++ b/identity/src/iosTest/kotlin/com/android/identity/util/UUIDIosTest.kt @@ -0,0 +1,15 @@ +package com.android.identity.util + +import kotlin.test.Test +import kotlin.test.assertEquals +import platform.Foundation.NSUUID + +class UUIDIosTest { + @Test + fun testToFrom() { + val uuid = UUID.randomUUID() + val nsUuid: NSUUID = uuid.toNSUUID() + assertEquals(uuid.toString().lowercase(), nsUuid.toString().lowercase()) + assertEquals(UUID.fromNSUUID(nsUuid), uuid) + } +} \ No newline at end of file diff --git a/identity/src/jvmTest/kotlin/com/android/identity/util/UUIDJvmTest.kt b/identity/src/jvmTest/kotlin/com/android/identity/util/UUIDJvmTest.kt new file mode 100644 index 000000000..d327d92f0 --- /dev/null +++ b/identity/src/jvmTest/kotlin/com/android/identity/util/UUIDJvmTest.kt @@ -0,0 +1,14 @@ +package com.android.identity.util + +import kotlin.test.Test +import kotlin.test.assertEquals + +class UUIDJvmTest { + @Test + fun testToFrom() { + val uuid = UUID.randomUUID() + val javaUuid: java.util.UUID = uuid.toJavaUuid() + assertEquals(uuid.toString().lowercase(), javaUuid.toString().lowercase()) + assertEquals(UUID.fromJavaUuid(javaUuid), uuid) + } +} \ No newline at end of file diff --git a/samples/testapp/build.gradle.kts b/samples/testapp/build.gradle.kts index 5b469f652..78c0d8d46 100644 --- a/samples/testapp/build.gradle.kts +++ b/samples/testapp/build.gradle.kts @@ -39,11 +39,16 @@ kotlin { sourceSets { + iosMain.dependencies { + implementation(libs.ktor.client.darwin) + } + androidMain.dependencies { implementation(compose.preview) implementation(libs.androidx.activity.compose) implementation(libs.bouncy.castle.bcprov) implementation(libs.androidx.biometrics) + implementation(libs.ktor.client.android) implementation(project(":identity-android")) implementation(project(":identity-android-csa")) } @@ -58,7 +63,9 @@ kotlin { implementation(compose.materialIconsExtended) implementation(libs.jetbrains.navigation.compose) implementation(libs.jetbrains.navigation.runtime) - + implementation(libs.jetbrains.lifecycle.viewmodel.compose) + implementation(libs.ktor.client.core) + implementation(libs.ktor.network) implementation(project(":identity")) implementation(project(":identity-mdoc")) implementation(project(":identity-appsupport")) diff --git a/samples/testapp/iosApp/TestApp/Info.plist b/samples/testapp/iosApp/TestApp/Info.plist index ede320772..4c49f3536 100644 --- a/samples/testapp/iosApp/TestApp/Info.plist +++ b/samples/testapp/iosApp/TestApp/Info.plist @@ -2,6 +2,8 @@ + NSBluetoothAlwaysUsageDescription + Bluetooth permission is required for proximity presentations NSCameraUsageDescription This app uses the camera to read QR codes NSFaceIDUsageDescription diff --git a/samples/testapp/src/androidMain/AndroidManifest.xml b/samples/testapp/src/androidMain/AndroidManifest.xml index 589ff97b3..84bea4c3d 100644 --- a/samples/testapp/src/androidMain/AndroidManifest.xml +++ b/samples/testapp/src/androidMain/AndroidManifest.xml @@ -8,6 +8,12 @@ + + + + + EX>4Tx04R}tkv&MmKpe$iQ$>-ggB?U1GE_mZgCB@vtwIqhgj%6h2a`*`ph-iL z;^HW{799LotU9+0Yt2!bCV&JIqBE>hzEl0u6Z503ls?%w0>9U#=pOtU&-fTr7K zDiIem*;TRY6+!eNgfUFW%rfRADGA^4b&mjF@1i`*|J*nNH0Uhl#~P8!K(hil#<9O&n1*o$`f@ z$13M7&RV(3n)l={4CVBdWvu?swklh8&O(yQY@rsKknlna{Usy6mpfo z$gzM5G{~+W{11M2YvrdVy`)ea=zMXUj}f427iiQR=lj@k8Ye*T8MxA0{z@H~{Up8C z(jrGd|2A-O-O`jj;Bp5Td@^KHcBLRqA)g1{&*+=7z`!lgv*z{I+{ftykfyGZH^9Lm zFj}DOb)R>4xA*q%nPz`K#eSad^gZEa<4bO1wgWnpw> zWFU8GbZ8()Nlj2!fese{03ZNKL_t(|+MK<6%yrv!*7qCpw$|S7CdF8{6}B#gjgJ~|b)-VIZxVpOL_T~mGkW*&cHlPtB z@Rlo*4V1D+g;GwhJ;88 zfm%1NE-yG97tD+hBC5u2o;e(DQ8PkH8KimQ=Kg)=c_x>_ z!<$>K?_FbR91nNg+#UFgr=OwZ#JVo*cRL7y?YN+=aX22xWx~`jlxZros&rL)Z(Qtl zV9L6!#GGiYAtLnNAtY`e-tfe|$4EJna;9}71?a7_*3NF4sjafrBc@7gjs3+Q?VY++ z$}}Moh-D(2_OUfutIYGvwl$_QV}NSsaJ(a@Ov;HDUwn~^-6aULzHzbNArjcOjSvC| zm>QUHcQ_EDkaOm6cfibOt&viqq)h7_%xJyy*u867>y&wBSr$Z;-i_UE&*6CF`uZ`} z1!zq3j?0THYTelC zM(-VMos=?bt6X1PV`j|Lj0hZ;16>;-WJ;MhE=PK;^xnt22wjzwGqp9Qan4FkTt9Y? zyTgGT1DGO`P&L+dA?Julpl*$;>uc_A?|9H(Zaal*|{u6DdWG$2-C}a5-npj2I)PLI{GHA7)ZS2v|1=kr*Rd z8!<&%+bAh=b$vzMR*rYK9suSU5k(|$dpI!d_iRnku84WiGBdhDnKA;XDiRXg)(A0h zTo!UlSntebqHDwGY^`D@wALuO(0c=c5CRecW(ova@1#;lBY4Ib+3$9&$0G#cu-q|C zh2DHSLKH$wEXM;mMnVvBjMUbNA<|kSi2weFHxIdY?;Zx^X`Xjg%eI)siNHL;g&1!Ebfx4{##FUszW?dF?OsIBR>(s8OD!bi|Wm!mNB8jk@ z3a>u6Pt2J-?Fbm!6&2xdw=(T!LV&~Jj*@+Ux8q7Fg{qxtnlKCtzV^rCkq{#}XHv)> z0Au9#?v{&-J*8xB@9rqmM6Zej-!IeddotD%F?v^2L4uHTq}I;m#ifI$Wuevzs-VVx zzh_xjf_TtZ1QF2Q*w#jC-N8bPTlq<5v7q8MgMP6=HrB?QbsBD7wKQP|dvsq6{yl!y~*4GEF`#U*X4)LJn* zs!ECpMA+(zh|so1t(8&=NF>HU-72O^&V}862f=8)vfItnr4oV%>fSqMmEIksnVBaK z4dfV!DYErO-8S?LG&-QDDW%M)DmiCz6yIAwM7X%Tp!UwZ+wtJl2VCs;w63T@t(6pn zl!Aj@HB38dostW!8#!fKYam9eD?x;Ht)!GNjF=)40<~6Z-5>^n2!aq0YWFoPWdb~2 zsRtX6E42c`y(gaJ{)1b7?YS3@KmLFJ#IJqg6QBICtBZSo{>OjvXMXwrR|5H#Z~ofy z%FC~O^IP8h`QQ8dzUlY8>8U5~vE1CAlYj_x>&&IFt}7)?Xzv&>4+M34d&kA)jxsr_ zk1?_=3o#}_5WrZsl@v3_!-2=IAEO&yZHOnKZC$v$+>gL6s2K=IjI_04W*&TE^vkra zgb+z7`in$iJsgQCp#sO_N(_ON5@9TWiZD%+S2Vqoa-ypT)pcD7ArK^uWaWo4L}6VW zrR12s8W{m3rZ57%&=Fd1L>y_voQNUNTPKA;@5(e!Y}Sb9sBtQ@$#V{(o&i4u=jRlW^TF+^m_kaC2M_LZd3nt|f^2P$l+8L-l@trLHbQ`27fLF00^JNj z(N>8mL6C9Tj8YP+0tABiL5wlddq+eNj3A1LkW!-7#$m1O_IuXj!hXL8GuH9mC-7j_ z2YLoG$feMFI|GZ>8rnL$-Oeko+lL<6>@0#AX(Y*IJ&3g?r^Rr-(^Mr{1PDX-EDPdg+B4~Fo)3k9s-qFk}=o}}utJoobY8|8`k8~w-ke$6|dy8r6^zw&MW-uu4i_y69nf30mttohHc z>yeZrF@=FTU@C&Kk}Fs~G1jBJ|+rfJ%t zYV_WSG0<9Pnr2T*7*m-E!4unV-qTvAck@du@toj9pp-J+TL=<8C^*ojt5I8})#kx_ z>>nZ?q*LU0cLaQUMFdmj_U?||Zg);jQ#oBoSeA`4O>EV{got8){baltfAs2ZhY zFh+&qe>0~vKtb|-8&yW|BSLL;BzFJfevg8m*PJnol;Qved7wX`wK@Qq)4-<2ZkmYE z&oFAFl6m;x0YL)mx;RodW!n}9J0Y@d8`D(K0iFc&=K&A4W(0((_a?QSID;O5V;G4W z*s9aSo`}Yp4LZ%tNU5-G4h&Tx1UZApoE_{sRsHMFfQ)A-Ib);JKLu$Abb88~!QJup-x|E1?CQE1yrZ4JR3m0(WYG)IinyyN*7UvShj6|WXX z)lkYrYmS^8k@{gZ{Gzqin5KdlNEEM1vL_hQ!{eTw81}4(?m*s zNxLb26&NTkJKY52@ja)C#}Bac!f}*$t~~@yJgOaTZ+#6?qNy>@Gp2)fGb5#RKCrcJ zq>)rnCE~~|#ps;>D2JzM;_mK_5W@NJ1@Yt|2JO1Xx1k%2V5NG#F!MBHgSIeL#B5Z5jotpjtI6p5 z7lQx2H0Zx=+s@RaqL0p?L=aE*0L!+K%S7wG|J&*)(JNlRyr%#i0qsf*k>COxWt>;l zKB%|m&mRWjj-F%~^o58Z1~~*$h@)~%=Oid1Lk9?_Sn5b~oX0u>1O}z#^M0)B#=Yxn zdh=&-x)TQnB4}@(Ak{%w5M!PS+qzM50#grI)+qxyiwN7c5>p)didWepz8+$Z)%P>CUu%S1P$H>bx;jS#(Z z4x@?_9LTO)9TanP=0Q&UgPniBgI(7@PnA5tyqm`ziPWvK+wB}=j(5p9pA)|Xr`}JX z&IV)R)VEWHr%FAHwe@}Sm;w&AM!=zzJd(N7`l?O`dPP2xR_mlw{E1EzV-O@bi0YkO z3MCg}h-|HS#V0NiY;_|kPum0jkfBp;K@|%9` zS3ddjZ=68>*dPAZT)Y0M@A~8a;U9hKiF;*T?nu!&Q4ufs{h@{49|h*F%8M_(%+s%b z+7UJWaye)0(@MlRDgn>@omK~zXX*!SP%k2$>BWqIk%AC&rq#yf#Rb~>;MId)f)G4N zj55^!RwSjwVOf~UM9PW7@pulLIcFHi$wo#${WI-m5ALSy_Ipeq1%K}}6(lC)L_d|n zJnuYc<;-C@5>s?UEw~2Jl@uakBk-hj4i>Gq5$qn7w(EL4i>$r1@jQNTRr|S$IUbMX z?85#$&u8R%N}j5HP$EW7et70-IwMT*sw@nSw6*HhNpj8Qgk;SEAx64-d9Uiob(#t> z1(xLi_6SXB?~c;UIdN6rk3NLmIP@0nii~6{gOe>~M#k5dWnr48bCq?v&{J^=PeQ-~ z+#L_jO@G?HhY`3>KkvFw_X?CquS$r~k$>Ho2RB_?qvY(ReedVQlurLXxat(+2xLxe z#pnU#6y&ENoVK<(m8trOn-0TdK?ilJDd#i-=%)uSL)F*MgK9fdQ0uZ%av5I-2+ngm zQexys)Rb+jX9}=xZW8c-iHqw4fcfwC%IK7&PSj`^Wm1`E2#;tfN6t!+aQ6ju7DH=8h0fpw@xY>8v|T9|$-wM|@AGIn!Hx1co=K z+f?1u;}vii6z_^2{&zqB+>4)j_UYGr@K-4i-nYE(JHF#DKK=A-_lKMNWD$-B&#<*^wAww0#z+X!OSnNnU0+{vJlv6T@=`n{ z6hj7LJZ-L;3)tE{YX$_mo!3toYiT+ToL>MnCFkU>oD?AhmUShkHsI@w`IuP9>Lx8)4Vw8b?I%>*(p843v zeu>wA#?zR>x>csxlds`xT*swc*VUa*K}Izy!==&)DH3ADy3u<@f^h%ALo7NylValb z_J&kkw^{2-(?-ezotp<*8GEGabSHK)MYx_O=GCieXSd(6E-N`@*479)vDM1e#f57f z!7HQ_4Ul4Rn#6QeX^o4E{W$=hl2J;5n4Inbr>|m4ppFPbNaR=s*Z)Xo*=nV=Q;&zWK(ZbB)=X{YXyF+`ymUXhJEA47N~D4V+D zaDcL{R{E$&PnDOMp}mn(KzoJmKjV~*otHknnA4$&W=D{hnoA$R&XoTU`m_3%qUX*kFIAC|>^gCOU6hx0QKzBy+bc zTwGn#x?6rjjKfjXN#h^{3265c50fDrchCOAUfZ=l;!E*hi@LTG8!OLZv4{w_PSgy45V%eFG@CP%!*9akwQTB}akEJw;zP*aw50kct+$Khr%v>B!lBzmG4 z$hUW|cI&nc6#K}zvn=jjG!?WPDZ0j?bW98hu(j>10j|q|-G25pP($=lr4qv_ake3Z zH)4Er-c&n1Bw{I!+6O3bT_Lb73z#y^j|B2I?q}~#pnDPySOcxf)x|w`s`XCSMv8gx z{9d_#I*r;#)e)mR!ea2CH%}A2)=|CsnJQ(bw-JEFL5>W&OK;ujTT?=SoFi-7n07l_ z_c$}n69o0ZoV-F6z{85`LGwHh&A`uN8vylW=BU;GJ>`fECPB?@*(s)xwDoM!4TGjT z>3OP(80RG8Y=oSvSTSg|jY>zHrD**ev-IT18g%A1?qL`LeOF%x85Z*t!?{X5t?`Mr zHO%);1SD{Gcci2=l3{R`CqzsgAl1#ClPS3=BDvB))P|9IS)a-o~eo)oqC+6X&^GE1NyMz z2EhezQ{VT2qL$@kun<)Jd2`NSIs(*mrWSJaszc2jT;ySIHAjXhXl+b6k%E`rIeV$C z!|LceQ0pm9GcHdUl_<6|_@(XPPJKu&bsJ31$Ry6GcgBXe7pRII^2& zU)vbmIdw8Kq~J){8rmw`x}Gn8KT~Vs7yGQI7}j;@7fKML)|DKi1FdZ%N?_~NMb}TG z`nEwU#gIG@i6L?dIGi;&VW8{aK;tB2+c*=>06glsP=RGzY2DqGW{QYG6cjfmbPo zfu@Qmgi*1{N!b_`X|K&K$7MSAB*_TEE>4d#GAa#!kCAxIjU^$*K^y7W$Z>ipIY-nwP5tMBcr5RAWtu0t!M%G=y#6D< z@QeTWxffo3E&{yu&2RjQcfRZ0|JfJ3_03b?Rxd4003BZbF!T$INZlSXLw^KltkU!tL!X`~3yI!?IRK%v(D*5Z&E*!`ZT|{&T%k zTjyebL2oXsdKu?V8e-zOZZwRle)+?2kgT*m_-GTk?!g*`)O?AZF-THc5 zh#?VE9*uxru5Mf9_HZPZ!o0hnHAiYX1b72SgI&EG?tM63Y`8z#KX`N^V&zhB$3N;J6+!G1pLPCzWZ`Yi^$uar<=}0p#R_F}x1~ zlvErYUS9ZFSXQ?>*U`v^d9PI0PRygX=k&WGG&Km`c)M5Of1L6t08qahT_&8x1^8FI-$+^5Ee^ z$~>`dn>(ap=KlRxdHU(sa(8#<^us_5?yT~Jv5gFzb9Oz$jCu4-Ow)vQr()1fZH@iz zf`<PAf7uWGmgXHQBq{8UP zcA>Gg>H#Nu$9$e9*ZNeQnvLP?R8uuVOo+G@-U)$*j%rVxs~jS&2SQ9nb-R#qVyz8{ ze&!xNxFv+hZk~q*;wb;rQ7*xQp;JTc+-zCyDL zjAqJ>bz5Ak3JJ5oet+SW@^T=?NXe0_t81rW{RX*aQad^NJ&!Tdns=zXt90$$-QIA0?+HHo%b)ty_rL!~ertO2HLrc!Tfg9K?|kaXd;Hpi z7X}s6X+h|6#m7JKfWPy@AK;^(+IaC+v0Nx+_D*qWnELn!Nn!ANHW*8%X+W@$ASFsp zXmfMDQXUja7}SZ|VomW;3$+tQc^5>9Q>HFQ%9OEg z)Y{SR2qT?sk3)QP8%nscueJoO%N! zM7KOovr~aXEc7?7;cKsxzaQ5xnxg2MpAcf)dFme3~Z{0XMlk!~X+eREhwhV2{ zhK*E0M9r<+VOS_n&LD^5I^M^aQY4y-e`?TzcsZ_aQ&bzi`%_id8<_0nf zn&*Z@ZAefqrb0h%bXBT$4~*`$B#izH5n-zhi*iP$G05n3aC)qbpUuhFR=bTJl>q9a za?XmZy3{Q!rhelMXpzb%oxUa-u2pn1=W+*q}3XG$n#jt7{&4nh})as8|Ez zE(`!7QHDMfP`p=w?srIAb2prXb8B(0l{8#hr(Pye?|#>2G-V*{H12U{D#`7#IU#D} zjwp6AB!mRku@JEkiP7mhr=A4FK|rh3nF^6%M|N9o2r@eA-Q;3}GIB>*peiotkFynq z&Dc)+rs{EIR44>ct%xeQ2yL(?A_G_{1CRv*fe`b#sa0%HQE~L4jGmaB+!-e7I!TC; zI8WYVl>O}Xw!*kqxfHhbK;RK|CS!_)4ceLzowlzVF}rbQ-Bz~NAS5IPatUCa5NG-Z zu{Dr}w_@_x^-F`rUP*oY#9m48w~?pSDv|6{^+;9hfrU|v~#Q*4zfZQ=5CAEqJ3Br!xE0F*zI(Is#t^j!wf6qfb8RQ#Y~zxws?Ylt5Z< z&ijpKq!TR=C7d~G?V~Z!9yvsMH=n69I!GD8Jfv`L_Hkj_hR@$cMu0w0=m}M%;gTpZ z(uW&NwIjjk2wPPyE-xM3#z84ogFZyd^L`76_OpZKq`|aSL7OM&7}-W2MI065vUr1?TRz3z zL86FykgB~A25r>G`sU&@)exh^M6yA(L~*yEcq8g|GC_<<2s+kaKb6s!Bw(k?!IPtl zy52+7^yD;(qi@C&Sil9?ZQBN)?RHJKxcWX>@2)8sXM@GEE$EoPq{HB$BS7iNc@+F? zhWN;uZKnp>0my?3e@3Tj3GH~ zv~Hv#V=uf9%WV`RAf`k&#nfGpKFMJ0rI~9Yr~8pO_hrFo9E{2J%`rOX)N?4}O^4~! zs3<1~Ap~*i$wopEiEOnY$)^ZJpba~-cE3A)jJS#7$*sg`oMCsOiFy}g7d^YkVvzIP zE5V!gYVD)j-Q#5p0U0d8*6JAH6Gk*}a1lblnt8uW2tz_}s!O6bl_Jmv(5uVJem61C zPMP1{EX>nH&S5mrIt{2p{4XiHoq9N4ZB)P~dfUc~K&A6cc!@vuzTZJu?^E?at&yA~ zPrT+?{^Eb}U;j{i!_$xbSKs#S?|Z}H<^fX(ERuNskonX9&5!Yk`Z}tBNd*bc=OTkP z@Zcwdw{r`a_`%}Q*G~Mnn7g(@@I#$a7!w~NF=d~glky0b?rKS+Z{);9c5@z3P8`4b zbEt7XRG5uR8IkD7$vIP>ZKT?UbyCkdhWZUZk$2-c3`ObcbBJ2^AmKdWDA5NZ3}+Wa zC+0{~LrS4--n>;e9|mbo(hh1xsOnyNm2v4sMv#MPo=`6|eM6JGwQBFqnGQs2y}S7C zswiD_Y`%>G&?~l(!br;FJ-WY_iqf??AFggA?dnb%iTr=IjENLoSFtcg$%M!>?OgCQBL}Cg7|n9pI}vw( z#h6B7F-Nqc5j75e+|=#DhSQFxCc{V^BD71NI(YL_e~Cmt(?}di&)8pHu(d|Wt|cl) zH%HVZPW0vh3vp^v2@n$+B28pek%gvy?j`sdhMawz5e#bB@3#?#STBR#4@ms9{*RGR zPRZJXcBJ=D8-ZhbzH3eUnZ_|SO#H6J93M6PMTl`|pTV7+&U6gm+zpF<&fMM*oDIOO z>UFC_L>HeQH|Sxrz_zvFiX4rD_}UWu{HiGHnDgaY3)B;%V?Hl$UF*3K)4H)7H{!5> z8;pj^$hI}i{C*K4b#%#}b1K`amL-pDDsgJw(*ZLJbx7(!>mTBD>$4q-UA^h^&CPY$F}#aXB0*4Pkm zYp}XI32m4)pQa5_QY4H{e;KHy zj*Hs6S5e&@EZA@gl{}nI!)+I$oDpOQ0@15iML2FpuV#it@zk6dPopt{SBzb|+cm|7 zeu7gDI=Z4=_yxfenx7AML7nCug>kthW!73TfwgXK(F}f$8K85xOYyS@@gHFF;UgeziOjQ2{Z4NI=O9RXlxQ}00DP)b#25PA^JHG z@ty+22-ztsRi|BI2p75Sg(XMlCk2Jf#J4m zZ6lZw6PP$qn@d54Yftc@fnIc<2C}$4+C#Cso1&dubPPujnEAO8vEj%HkKE;=D78s3-3^${w5j7y@?-KD2c6xV8)?p=^HmWxF7l}Dltm^NI#}Uufk=~p( zk^%1+Rbj7{KqLmpS+wVzOKUHCI03sbj5rcUbJEPUW&$>Id)ND)( z_UXl<3cY(ffOb4(cvY$BsDvE|m?_aJ#KKJ%e*Qxr<{iK73yE62i8$}*VV3yLFZ-hZ z^eeyY%h9@V@ziJVkALcy_y-?e*es6h?|l%^ohBGutVr|&(fz;YDSLyN=(&OF9P9+v ztvPay4AkyrWowMW%b3`7kc3^25IBq$;J>?DHIFuj`(WCSFoF=t3m6YPc)$cIJpX`>yM z)elwcmD)P%vUq|PIrH{mjAY2gN4BVO9!t_|L&oK3YDh?Qt8_89J*VijQm-E1Tcsp- zxk+o}QkcbWkoMP%Twfa_36>q>Z??LiWo7_1weI2Q`ypc2Sf7=7D$X!a8P7#pt~P>a!y#W5ToAuv<1I zrwJ2d3jVWUJiF>66xwP(3!|!!oL60&4;4&l&`A*bm}L|bRt0F}lzn|oh~nMN7VX@9 zTymsUp92+ataTWJAk8Qt(8qWZpWdu!cS>=;pS=zi@mWv7-Bl<0FN@zt3y`D`qMh#` zqo*m1Zt;{oafab&@+vTlSzDvgRRVF$aI0qu%;*fxfAof()-Dl=*(c_P@%2+bSsG1~ zUS+uRO>B$?Ie`X8##r=*O<^O%GP<>sJO84M-YFrV1RLjkB*YWzQ5(YsZ-^v_nUuq* z?sr7$0J{QXygxVNquAbnB=&dwh?M6%!5v8vW z*_F(8yhHj%Mrq4o^pZ4hYC_4$h`}hy`vAszx_A$iWT-g(q`i|vB*a3m2SPiN#jv)K z+D4I1&_>iov_{m5n34j6clIhGj!sQS3XM3%J&On(B}!)shO~A@STS_+l&FI;DnTg; zvOz*AC14$5Gzi;y_@W~V9YV7=9f|mehh&{>jVP7ry=Akaxn?ly`-TNfV(GUjRJVK9J`5bIff2BI#6^#)n*2o{i#F>zaT7>%sQWg&?X z%`iN<1QALwB8_%@2-^W|N5sYm^OP`&Nc8opn;R5}PCfa^3lC5q#eLkVpMVnsCb2!l5kkO0#k$@PG&d*jd*|QhPH*)Rsxl6tQ+k}SU0E} zeW^?#GfjJ{sZT0aJQ0cFG%xhJVzr+BaD6nI4Eop??-4^564*gWq(A6XvcLQ?Magt2!0qBd>*RYeN{5 zp(2b*#4?Y`fj{+~@8-2nT+@5ycv$Flb#cmslq21swaVSy4b!w|o@Z`v7e4vi^ZfJ& zf0cjr$(1xc&f1KId)OHzq|_sS@w?y4YcKZHTIp3W^|DfjAm|N`cJ@w7h`jvL%Y5V) zKF$yS^d~szj%FT!e1K!XOh^g7@{K!w*H^s_pEcLVB+|}{uPl835B@Yvm;9#3JKyxR zUyRg^ESbCY7PCO>!uS8}f5J=hI3yoMU}JQEj=OT&(6~P2JHGl&{Fcvs12P&Nb1ppp z%F6fs4?l+NF4(8QpZNB7a5X!v*6T_epQ)0@X9#rb=iZK5-TD|~qD(XMZckU^|9t;X z@S`8RkHmt}__lXE!xz8pjXvly7iwKuk9P>d{_@I!!pW#G(9JZ@eCm@g@=t&ABmA?E z9gwo;*eW^rJgXDeDlyV^Ls1?piMKzy=MR1RdwKlog0?k2_~DQ7y?^BgvCAh2QyCrT zPR;nFr+lcjs-~PtOdG}X*A(y+t_0L*0&~5P0Q*e#WOt(r^nse0a;8C)RcI(IKagWH9kQt}&~r_0Gp%xaBYZ zt$)gG7lOfSO65=f`@frD3#lZw!^1Hl+xv(hFqg?q-4F1~_bV;Vg9a7EM)hX!bzk}v-}LqG zAY0?^_SJLqXN-wb(z)5Ujt=+MRs^rM*JZ^*;p*N!+G^x^=70QO-_Hj=@)C7aW?`6$ zPHh=|v<{qlUgD_({)Ad)JqwgBEV$ldN+h znseh#XV&JzUPy7Ee;1b#7m`Qpb6RR}lR+PqL@qv$hOO@QxTmJdH@xS|DS9(Xs?lYc z()n{PPRPqA;d=Iz;2$Wpw#-L<;p6<(ANb$-$&Yn}L>ulU!-%%>uD8F1-FE98m=@?m z>u@9&&NI1U%!7&(B)*Z5-@_k$`N-e-!5`rV{@%}G;mYSWjVYYk8(;ReXL;|tzKB+D zeDT#OB*~BQxBu=3*utLB?)bW|{tEQ=MWjcF4Kz@8SG@4Ti~LU?c%JUN>jTWjNCh8% z;q=&YbDux(jql;9i_?HY@BIFi7b-CsX*YBG;5pv&6>sI<>_S?r+gb1Nu5l&XqfyhR z3cyi@JF`mJ^UBLF@qc~fr>MOny78W``~tq{w>;~g4br=J_>WE z^bGHM`&;>g?|UzQ|A&5@zxIE8kUn3q*#;I#5@-q+w(=Lg_m4Bnf#u<=?zi7g+FE!t z&AAz3iKYiOiTupZ{33t)ul*z9Y1(WKXQ#}`J?BlK#%I6oG2Z@`*K_mYuOVExRui9O zKkvDI?1CTuxlf@MoGVX^&+r(RR!Yu~^- z-u`C($RE1LKl|rD&-eYe{~MoL0*LD)T^*f1d0qIDw|pk+!{;y)`qr_FOAH_FAFp3a z5E&m+@ThBgU61F_ zU0#71U;LKe#K(T&m-(N6{MQ^5o+#`AT;`aOEIj-4<2?J?9k#8sCd4o!Av5hCP!5`-}TMB{fj;isdv2c@_pB^+D6F{lg_3KPu@%8Ghk#)mo`4<4WG+b z{q`^A{r~Wv@ZbI5PtoFI9D8%UB?|jI@yzS4`K;GpQg80_#Ki@}&^Jc#jIMT*@v#O* zwCVggF>12|9;r+h6ZE|~VKC!@{1E?SnQBaBk0+A?zgq}v)+n=`gUTfWL{=u9P*nu1?g@ zDUnpOo>ny0;Tp2>j44y{z&Q3mCg(^YlSy}U6n@~9 z*KyeFGVI*NI4;P>P@N|i!)mc$v40BN@A2~QcrO3(2cL-_53sfsh+&jbLLPb;dk#(> zr}PKJgI!!);^RPB?$Pg_X52qZS?w_FpCm1Ii2G-CFJ>A0)ml6@HT#Rw1L7F*#?pFA z9+#BVOQSd!`eDVv!5(PhD@8$9NHD~%xl6`bx~`F;o)dwZ4Gr1PNVR~i^s6PquwW5; z_7BcdVh$zW8Vp7BbjY9lH<|X}zoJL%*G_A$@3Pf^QQ5eZ(AeRB_GC2?E zN{LLNm_p2vam-ZPiW=Kh9Z5xese~Np>;_av+jO#2*1vxD%-wv;OJ6`U?2^YpBI3I9 zgi1Dt&QhhGv_;yYY6csa3D<`nJeIUNtz#vcPplcsj!77om)$$Zo+_$wsS` zC#xWaTCvp;tTG#GmV#L8rq+XwZ7exfEMCEKhEkpOL}Wf!QW1oEk$GWTdw$^izLRfw zSiqS|8AqK(C%JXlBBzL{VNr`N0TmySs*K906g{cLBrmMEcarN~@D%>tvmPQ#G7GUp zhF;h*jW?1JubF`SoN~^rhD^fKZlA{|KJjUM=)lSH)fz!$Jo57Uv1tzQZ6}L2B+#FW zd=XL+G{@7feKcv<$J-WXGmeVw=6vGDoAKQS-Wf_CWuHiK zN6i^2nwEjkHgf7oiMU#D zLUN2UT2$E&Ig(9}HLx{rAUIq%XV!I$ag^SG(h$62TNETjYePc9~R+)InLe8 zoOOz=DFiEmDPx_WZIC`$)?Sh)kWD7mh}9=w#!C{kQ~@KJDsSnq)@h#O;tT#)o8c)F zoM&rmb1i@|3OOt)j_X_g1UKTFEaLE!@{?H2d!eMuKN0 z_B1B)+E;%IadnDO^ZxzNQ&DjhmTF`UTBbQ(Nz^Fl3@fTJTw38w#ubainF?v#<$GTK z5?Jk%qqMqbO(RY0DcWo7dNDPQql}TLR6FB+AG{IcXF$SeJgy0jjn4DZr(eOQZ+~d5#22a;C@%;i3~?M;^$QFU#yy^T%~hBj)lqNB)iT?dQ;`z!eESIV z?em$>Ht1$^KvJj^&2iF7rxaQ49Ov2Ja5e7W3_i(nx|GT|N@pbK2vna+n(mXsIssx` zk@fapw5U49Q6jizdREHqs;IDR;=QefbNX4S@?n*|X_142^M1{+GXSYjqui{iX3bs8 z7!9RJXDSXO-iLrQg|6*LF%hE}zMOGjB?h|cd$u+=6)qRo{*cqV2UMfJM9sDr?G6;c z1-G8Hl{nIF%3>&L`*xyHT5mXeFtEGI7%x1a)-}|U*qqJS+^WRoS&6gdigA$oJubiB zd0ch>IX9k`2g9baYzj`WJxoYsS&Kz>!TyT4IxM}LBBNC7?QT8`?2!5dlPpb-{In;UUiZZbufA4SxnbkpSRhm_>SU2a(cbujl_gO6tAZIST z;0X7-?oiH#X_hl4uX{`+xLy{^}FAQ`_^X&QQu- z)cp_hy)St*ulTMP;Z_SwNi0v_!^^+*#k~9dzs4Qo9PccxH*C!3#N{rg1ZK02zj*iG z@u%;*NzfZ5W4x44Lu06QhH-}7)DaWwG0F5Fs}E;q8hwUHE4gVJmi()Fn=uZ8Z?L(F$VWXg=O_zSW7g82h~uadBBOd@0bDMY3oUFa9B>?Y;+2;(?46>j zgs5p-d61Zj?Y0D=oZ_iZdL%c#>n0h; z)d6GLHHS!FHTo816g0kG0S*d#v|svD>T(!opHj@p2fr>(nU`Vi zJ&nzbMyM7B!=SL!G>ocWa5&?!0jzJh_rxC6$@4Tu!8=*iG&|?1Da$h{TjHrZ+3Ov~ zX}v+C?$ZUJYT0tqTcljpBQR6s)?<_-W-C}Py}kAG9jgb-ti7)+XqZeTuJs|Te_?L& za%g_|V6~!AcFlCsuu9zQQ&QFsYZYfy02@yB53W>l%(5i(cAY_X!DDR02mj%p__;s$ zM^<&lJQ()J6~z>edB<=6)Hn0YCtpDtR+2qe+GXS5>DN4p&s={S#d%6G+FCAHVLZ#i zFJ|sIzRL?=_Zu{u7h~q0YFoB3{OEUG#dlx#9OCi}CJ$O0^pHlL@w7+r(YJjG8ysnw zX@b=caav1eQWBb_)m$~s;x~_Q(=A`-^5;E~#qKWEWV|=zq36*LyBrtKlEX#vh4XR} zlqtx-OJ8&)mXT8R8d(k7o15JD!B666M;MAwV0VY8PHFjbOuzgt%=p{)e}Z59qYsix zklUj7auNd#6*%Ti%W@QyNoi+zisZ`GDpyrF;S?>u%%0c%!rSP(ZM?6TI>NZ(1&`k1 z$A9Ei__U{sL?xk_!q(v#7j0Wkq!D}|fkYx5y#F&hJoqpEmg~Op`E2=2>d!)jz1=-& zscS?0$o8Cn_q<2pOQJWKRb0?*U&LGfch7!nrvq z4f4BEazWLALW~2|NLZjwr9pkD$0%{rTF{cMr8;Q3mbPt)#e~#zF8GRg;T3bNPcGh~<`Z+5qcl+J@C~hb}1BtyYY0x$De6 zZ}^SBpoT_7T0#0g6Vlu}CEaQ_qn3gTjYO@j5MifVhZRu2hB2gCFbpC@S`C?CA@A?< z#H$|7TD$z1n>YgsLJ$y$D`wNO)K^Ux+SRZ2`aZ`L&M#y8Tv z(C&_mwhbFwNBPa)`(tiB+fW##AE%77s8bOtWCKm($k|}Rj9eHxth*Qr=QXpxq@-wp(oH6UtrcGMjC}~`~&MTFe6Mf&WwX8w8 zK3U6FQ&~;6^zuW_lW1P;ekB*OUdxU1LR+oEnW{!G8l`4%m7!EjbClWEF=~61t)us2 zzI_RG>mvT_FF%BF?Ye9yZ&gc40}s9Ye$pB@VpvOx2Cvg8+DV~sXF1Xxx)gupQgBD` zVH;*g_XHM#OQ!1;-9k;|l8wdRT`kvK-O-6C%sBF(qS^KDkXC3kb`<`GgiV^Le|e2W!9 zN_)XEmY&ee*Ck67!yvavIC}9#Fz+~%GAlc0VYj*d^CSO!uZgm%5$Vm14YrRS z7IN~Kai*mv`CQa)lmGeGpXb|O_siUV*Kx)g!CCfJE0QtTB74f+d%w!FuDy~Pmw4ac zL*TCacKPG?e~!U88rNctgxy?{lXYx5-uUj%@YS!K#5OItB!<-iS3U0GgtEjp4)3bS zhpIz1P|_JmSwbJqHk4w?RdC>Ai9%?$vfvJ_EMdo-be1eTB5$RXm9!x1ynbGSm(ftn z1TRKHa$JW|jE)z^F-e?u@~u}x>zg$@Eha&_Td$m}lqGJRMwwwNv7qxUo#_SkkPccQc1#&>#+}LI)7UvqAaSVMg zFSt>5N8U(?W;N^(^NO)rl5NPQVZSeo)e~`;Fk>v9Vmc}_OtVe(Gqr?fs9CAb=vcN& zxTlWf2)BRjUN$zj8AqAxr&_@q;{F+a;`_fHR}SP*hBV`IE_7WGB#qV!igv&z~?)4NCgiKM5JlKtq&wr3T z52cXvfU`k7B-*ue%67A+Dw=4{%C*F1&R}K?wq-#>Znwzo7G=IoY33x;GFH!+rOeto zk5^M=Dk3PW+WxP)4xk8JvF0LUL=A9MV{lE75Ea7qlV?{^kB)HACh?dpdWsO7a$bA7 zr`<-?OgifB73P$*gnbC-STa@Ix`MIDdK;0eTGw={cx4?4l~Ytv{bgo0+aUNrilYc{ zau�F5Gn68B#`zeP-?2reuyCJCBN71)a0H^qGi97Lx=Lx3RSeO~Cj<+sb*SFC)nX zZvNaC)D6P9_*|)M9XgD$hG8t~;!suR1kur1aZ&4_)t_O;aKl{#yZgQ38H)N43d_YV z&wKVYKpELG2R!8R%NWK%KyKQ6J$~XWH{G=&Vb%c!X>J#7nmR>3SP2Cv zhz|=c7^A~2+g@zUFnWSxv*ineSI4tD86r49?Wnt(5s(98(4 zZBCxvqn*w4bE4fA-FGY&ky0()ti>{7t=zVZw^ZY~6zkSYeUirx>OBHd0JlzAOZ1@fx3Y+U~E@p-fMwf~3e=1TW+~ zYP$HG6)#u#)GhZ>Z6jpY7;(0e#-5lB6=8BY-LG_QLmL9^EHImkq0U;j=2dmwYz;wm z))PWYj0x|>O_56y&wR{~l^o%u5Z48ZDdQR?Q){Jh7VG5aRCS6bP)FHY+;Ptd?m4p* z-;2pKjof^P#gd0U=zcu*5=${z;-}iojw$KLm$f&iXbe5)N~sbS+CAvkDrBRArj*5R z<1KU=DvA?sDO;-P*pG*J^V|QPJ5D9e#EvtA<DGPmhe%X-QPRyNiRB#Bxhf z-d@sHc$Lqcuh4p`H?*@>6uAbxEf^xU%05?kYZ;?2%Bc>Z9_96+j%mu2<@RUvr5wCn zgG#Yx4eUfaHxk5|TJgfJGQkp>fEOh5R9_d2NTb3c=_ZsluDK|9ts21`PjfV{@*aa| zDoZY8Eer+k<*FIvr00VpCqaCma-eCMS4zejMWWP%H!`CfqRMLO1ob=Tzizyx!~q}X z5P}--q=aUyAdctrIUqo>Q4?N`wbXG*XGVfEtVVsf<$`yCRgCQH9Z-xAOhXV<#J_#9 z8px~@YH=K~O)EA>+wr;2-^#PDc^I)jE1~#-ixN2bMsAKpoR@<%EXdsos&eHv*%DBRTj%wp$)s$X-sWpL1*P5=Z(Qt!IR{; zz?vD!2$a%V$8uPRV?d*S(?Wh6A{NiRci+YA@Fk2Hyzdy}f>3)Nb;U!;_6sXU!`VCQ`JQ@8QD@7HmwE7&~n4Ntz3kKT3zvu;K$ z@_Q{tAb|a9#oRYKwrIp%Uy|HUf(}tkrlQUcjp9~>^o;RqIADs2T;%h=8U|sPskXQ( zT4GFvZ0ouxW{|>U7HXr69;~>pi_y+i?s3}h8I#f>jEosgHcGh_7mG(6Ek*YvlOU_s z%E^;E9Q2uNqd4hpmDHQH6x;CGFWtj=o0hhb7ohin*=&PTyE_Emu;>RGFDUnz;+jV$ zhd*)Qv>{N2UB=M~y2Le_4wTziigLS=41i@#-Y%u!j2Ei&Y@5G) z|Hpatw>_7-I8BLAa5UYF{_K6c_T?|(2Y&sXxQ!!NX9-@$eqhy|jsgV5ikLDqO-rho zGs}z*bJB3I*2KzZsA6p4qIu**+ygf!YsfU&1Pf zN>bM_Od0RzTxeBmv0SYrP@_BVlvE#4jd*h=rXhxmfIKIUx+hBvGex6KVP)kbLX({1 zdOPpEml>Wh6el~9CNz{Nc=uW>afoC^b>vzY(@5h3wWf6=wB`&}gz&;FQuV2s$}f`7 zmMnK8*ZGNYBRmtK-&GM)d(&@W5`4CJ%KCuc$zI4Yae(Zm|o2HwoXZhT9S_rbj z?Xt#UQqO$6kF+;%WY)3aJr91^VP5*8=X2#F9>8MvzBTrx)`|@sAN|OU*zORiCK6Se z+ZZ~VG5cTRzx~4Nc;F>lVLuwa2*MQ7?SSMPsr0&(Xb!j6ed$NWa9b6P0!CUnxa|SWVXugJXy!kGPy?J#3SY+`g0&YS*rTgOjd1){#a}-=$0tN6RXE7Jb5&g43qF^pOfRF8J^N z@%7wx=N;U7#|gf2`#s!ua+iBgFIm(E*Up%4Y>UA(S60J_+up=vi4E5}C~8CwBE~4% zq$sw&3j50BC9nFbdsgG_DdCZxzFpmsz;6 zaw=%O<&ujp;;VPxBTembm`a$k6pLw%CMbqA6s?p>HGVx)6hN4KsF$miLe?BrbIS$A zrXVr8H6TsXMk1a``r&m{@5$pzG`h|~6)mr+juJ;6@xWvJ&71!-P1_1my;kA$#Gcja zETQcvu~Mo?>RBigEJYUmlHxrd`{FKlA3x3c z^Eqi)Ns`1^YFzNVZ+HTqedp(}wxJS<{T^35{K2H$6O6R1Vks<##M?e_BXM>ahD5C% z=NhbYYj?POU{s1!6Bix{Tu2}^B38n#P0={3O3%>HG?x(6Gr$_~S7e-_)L^Ty#tV*Z z^3J#XcU&W7LuWj-F6mcGR=dabwo-&z8#+$y_x#o$zLyv-CY7iP(TvS29)5o2XMg1< zIlA4DmU~#&v2o-uzw_JgAcak0-_v@_s_&W2XXhZBDHC&qW`>DI1ze3X(&W}Nofxx$ z7-sA*dd@$*NworA+}fN&j$pF(XcE@KYJZQXUGsRZx#kH1l#Gc~Gef^e^&Nll*Z;(O zK6ST@Osw4ga?2Ag7)t^imBU@($V~bsHUt)n1+!U4-x!?T z!x)F(3M^L(F1z?ba#~@T6Ew}~kX=$Og*Xnh?Mz6LMLJ`~ZSiM+{yu){M_)nOJwc2a zEDSL+oIcLWzwL#5?7zJY6SgTS5@RBztTzz-J~)Z5x4~2L3hNu1Zj-NkO-hGB!zIX?J^QkWrHV%Vr z$Z5bB8Ce=<)^zEVl02~M)V(O=F>>kAO({F)o~HA})JsAyCc?N7?7GRcO+)7kgQ8)K zRi%I?M$1IAR%xH0R4iV#6;{P?PIt?a=F|>Jsq$x~uo_kbCtjT<1cEJiqYhzb<-wZ9 z0T+Y?HQ|y4%`I=LRmyz%?t8@vU0C~><2cI0-726@Uj|0!XjNHR7=trG3OPo;6vNnK zy(Lj7I9!n#`IM3fR{9{dNYN>)KGO$J#z>DKWr9W}Ym@?%H%4M;0C|))=VA}%Eh!E% zv#wGui#g*EiIH!&x$%}e_=m6T;^u*vD&9idxO32|T&T|DYL>a|^l1Q$Z!kGC*ct!) z>6>`TGajcI>L6FuVct=lQu6xPN7A3DR z#tS|@2+}!g8m-C-SOO5%(TN1`bNpoVh;K6n{{hV>Um+9rqpZ3%~wWcHL2`6Ksk%;&X|^$p7`Dui?T& zo;0q&7@B#;NSo zA`S_4EwNY)1XJ|OXx9<cx15r2Cp3(6PMgu976@KVkgDEY)JjQ7 zu;R5$=L3wJ{#seu4zZV}f0TFmtI?CwLWC#PZmAPt3hw`K#ez zxldyaWg=92PfR00_fg`Q5KCWmas!&r8?1$m`J8d=^Wzcq$cKZw3G7h*0#_snvWFvTd&8{#M*6z{z} zj1_KZj8T1J(N?3=k&6gnDrNn@O(Q#(obvjr8nAdAjq{{5iBzT$SN%70`mYx}P5F4| zoeG(XQtzdLZ=EPXJ&rasB1kfhgPlFR?`XF#;mh|f`Ke!iGu_sO^!-AVw#KY)AV$A* z)5xvpDP(Pg)3HSfmh1oSMw)ILAv;Zr$HHazzl0<8fSkdW!gpTxEE+3IQi~_&5uE2! zH~cH9s9i@Jjgss~gLPPS$2e#3G}z*4gQb>%;EkX@a>nL@t5t5MDU*u`wESd3)R&c;3YYq`a(X+Q z;+AWfm@dzakr7wdw$v<1DqF>Ovt}7pS^WEIWG_-=X>Og9C@yu)cAEknla@55i8H6B zPUkgoBdMF#4Inct?tvSVoFXN*z{Ai!&qx#d%zn{Z42CAP95sBz$3Ygh`5W z+bL1ZqzF@N7^I6*OTuB<+}ITKc`mFD_6TjzIef*MsUa>0ps5fiK)^(;kr)p!zT++b z>w3b*F^nlRuGIrUA+Pp$>5HC;%LlaG41AEEneKbn{4xw1^Vxc;kgVZ`TMzh`n{ETY zK}kI}PdW%0ahICc>?;A4N~7Lp`S(xD)2w!G=g54;554wVc+HC*MaT(LqzoOrlq)Ha zV31bK5kSYAF`Rl8HGH$5BHAD!)>-v^O#_ zDT>5dWA|H+^#;h&`{{@Eh?ZQix_)rM3x*~SSV0# z8=4^3;G9M%LZ>zQ*G@8*s>^O^f)^FYNM?R&qf0VGu3sSpjR*@qI_Go!5;@>kRXnN; zDyOwx)aaC)tiRkFjC42ToYxcF&0y-c;!+g#+9`FtHas`?`Ig> zQLD$=7Gpb_uz|O3{Q`+8YAGqNyCqXtAOOL7+P1}oO3EwwYvQ?mQyep2yX`*S{GOXw z*$twRxT!N1hhbdxco(p~p4*3nL_eXrc0Nzpr{ z6m!lZ>)9rKpP%m~#BlP*U}9^?k&9mz;x)>i4^kK;&C_j*z9 zIC|rjeX66IZ?Sds7=E@1*3)fno9Gmm~-*GK24>)JQ%G|$B)CP5h92~4Dr7)z(P%E+9 z=2!p!>$&;PleEV!2;D120t?TpdhkBos!7V(vD0^Vq3%oZyrb05~- z4rk?B)ie$LC;&8Lig1@mIz7{0tX^fkce)sqal&K`Y@8sYOO!4Cx?<0{Q=>?5{F)$X z^cQWtK3FCNWtE%I#0@a!+~TNCFR+?$))1`Bg3s(nzHsXuoPYEP55N2pYQH15n`*GZ zG4v}gz3hJc!>8X$Y>rW^6Ba)O>%v;yTQmuY2(whf#*wc^y-UKi+I6}R$I_a+jJ!Pdf^_w342wDpi7z*?v^EoDo|#5!X9 zCdcnvFyEN7y}6BzOI^rn%-a^Ox$058?3rKXZJ#(nat+Svjiw1i>!?PWtjsRtP5E+$&5mp=V@f!Cjtp=v z<~wWeddJT0E;e+S>f{iU19YBb8{YBG5Ae>v{R~)3H^q`oVLqF4>xq$( zxn}9B`fha3q2Y@8tYf)a$>%_KE|b-7>J1IUfD5vCOr}yaI^MK0S~ImU41$yKUV2Gr zEVJe!qy_bHPy0ma>}WIFI$|6NPEVL9nOQA{O^_6v)zj#*A9NQm-4tMZqr;|s#@J)5 z!_|r}g?@}G@7iL(P^>Ioof9gyNZ2H8SbfLIy^)(g|0SGn0ZW=tB&J|}V72P$W*y_0 zh~-WeWhR}43Vo>(LDw>CQ3InK3=Jh$wl=mo*jwP5hWB3oVSeb<-^{Q)iMJA1wK$gh zXL!MLp30y8*8)v z-F0=IPk!bWXr$g?OUC(5l3Kc(G+4TB&ewMDVQ;AibJYP?BcGRPLh$uk?oi~f9LNhVFT+t);DAeUDpX!*MPT%#Zty7aZK{UaE3VM=`Afp+>#+XhScz~ANo+}}*zaPU064ApjKf)8@f|PV?SK1!lQ)iv2PI~lNfh76E!7z?Gd}Xg z1s}fU$`of`PIW3yPS8n|p&$;#r^00^X4c=KEbIAr@Sjc!Tm_Rfm#u*%e zYBEi5^tp0g7&&@)o8|s-`Rv+;Miu&Ty1>evOv5<&o0zU0QK^FsD> z))}&uS$#~2cGj|cMqXl*{C9xOh&9Jyl--gtS--!2tVRe?0-8#{RCe|k6Y|fF5 zMn-_8-Q>&r9bf(HFOuK&FO+@{S4NJ`TaIo9j%?4^-kx*pye%H~kjr`OBOeO!gghUL zgeq#V#JJ${OOEiN&z`{q#}Jd?Sc;VMrk4J6kWO~Sul(NM;1690MF5=`QE^v&_!#Sn zqr6y+Zz$H0P0}!pj>C+EW~N5}^c=U^?hhJ|ZbKmELW&imZT|_VNHvTpOJBsu_&24I zcHUvzuGQt5dcUnD#-vDJMFyL5tgBiogD$CsuaqU0R#Ei0C@H69;JI{+7~Uq3SV~z( z4)&a1*m%5~kt6zNaC+Sv1Wlr|P^0bWwh81?XxdKk074h78Y6bru6ZxsH;g5)lrx(hGD9zMp#u}fgSL3ijd%UrSD2Hk86Tt_>f>&Zk^mKm6D%=Jnu z9~`4aGHRyBW9HlZ?w@^-Gy4OzN(iBvsl#JAa=793-~CJiMIIV9D4rv(W2UucjCa_! zm5jMn&1V)$Q1H_ZVdR4!|11elC5nTl_FVg@39kbV_+EglrvON>$W4=`!aomeh)Vl$@nZ^m?MM3fadS+z5=n^A1bs#*pt z$pvcY$i88uB8pK@vWc;b2ottW3Fx(s!|EP!ol;b}R4H-`PP%iORtR-HGMsiLQw-S4 zji@SYHI~Y#S7NI-glQR!MssIPs|`k36o;mf(`l_zmA6uQ>VlAbor1xpW4f76?Mj`S z9>S$$Eu%Q*ou!n4KKB$Ku`NVzC?uL751o<~n=*|mfT2&|EPC;n4$orb(_FXEkCV(k zec*ro?@#j8uN@~Fqpo6s`FZEq2$3Iu?Kjcp0k5|lr~l4P)2}jsYz6+`#UGN`f(5BA@{$K<=!sVPU!i<&OzoqAN>;527QpL zYSK=cVXFcn;ohXGjuvZiJ}{JqT&g^*Qlbnav*5_R2$TX9R|O;9ghm(WSx>a8YOP6} z+!{{E1!A$R++p7Rp7+zu4v}IJr-S0?7YC zc%VX~fyI#chfjP-o!Et#2SrOru6-CKoZ!@jZe?b>-g(Iv%u5v0nYCSSnhys>L{xPz zE=-%9dir z8qePTzP=cp(5kg*+;*O89($SGI)kHUU^I?k4Ncr*y9HkpU1JFjnqUYX#-YdADdKH( zT*CUn?l73*h2D@2$wZ8keS`yX%jXIyGjo$`K1$RtHnqcGR$VON!cA-?#` z=6p`m1X6W;^448$`O@u#rXvLHGUSTM37Z!@_vuf<3jLTU;jb=;MaeIU;UYXk4=$OqI%55XC7-{l^q}a_{~_? zNw{B)r;XMM&O3To9>PHm6mF8X>9fRXf==k(Bbm&o1x>ByDc(j$fzGd=L2~Akk!Mwc zZk>W2)1g!E0y)bwV^VM=gNpeK)>bJzn{%rIAA&x^J$)SXGNCd#9~zda>e~-(D%v&P zcWSR5+1^r+SEL^XQqDrI^q#1ZK@4(oxB3I}(~Mr4Oj((K;B3$DI|HfT0EB^evligAJaPMl)CwN2N|)+go(9EK(us{S)mO5~In zyA6KlO>d`!EsWE#NtFj>8J9fk+Q;&^V;Ns6)>dU~W#Z7Ey8&tYyp$^X+=AR@syqmt zZzTRlWt9?t^PwB)t7gw-uuF=Ohd<Ck7Hg)3VpWH^ zrIsl4W4!_w3ZtnM8>m<|OvaR+ntRIF6IUx%iv_k85j{CWjsr83uz3(ff|rp?9EbHx zRtu>T^K4uCQLgoWaQ)}F=hPCsbWy6t2W@dQb;b96*RvoVNDl}hwdPV-^eer#mvcKL zh3i>su*T6g5vd~VD@_=JrIq`kX% z)k_~kV+OjW)dsd*??eyw_gJo0f=elxjoC~RI-EfBdKKV>Dax z-COfi3wh`{yg4JqLE7)SOS0A~x@meDTJ~e+^y&LJd-7gR-g^f-C+^|w@q0OW-`5DX z()k`+<&a>B1Yc;p>=z&YpkoLyNDkK}2?1k5l^EXumZk~PfBU-lHWfvA) zH>@z@K#EI-exDc@468j-?4imn+*^ycUiQ*cI>~4%))?KXDezTqITg9Bq%p~jqE>3u z@5EFF&zTs<^_pVHnV}Y<-h6%2&agI$GhA&}#tSxG;`N4<3ukxs@r}n6Iqi7obx!Wq zi&r$p8)KA{f^PDs>bqdnb%Cyb@2s=;Yo7Q>KKLh(VP|JSG8JQFA${@D!)(l3a(`A& zfXzCogbUny`*ET0W{F#RMPcLIy8EzRc-Ml-2G@1G|K_v2{Ij=l)gvzfdZ3aF*lKXY zf**X%w{pWT{S~fr)Eb0469UdzQa#7}x7L!zi1R_PCyMdNwUUZ-V7#+@;>-J-*;{eJ zOcEHe6!1ox*13{h12&RlHWmF~+JC4;ZYxoWETOfZxj zx!;9{_~}|`NxRH>nO3Vv+Yy#G$ko)$|7EF zlilnXZ+-jq{D(KZlHv3TFzVfno;>dJyyrZXKYGVUxNDGd7DbTq&Pzxlm$Dw2PLu<| zR!G8WI>hAcc>BBlkrzJaX;}6t*I>s8}X=watbPcBq2#vTqNaGQib~9g-gEX|Yd-VG7<26_sk-V`_qpfnv-ezcjycBn z{l5SCZ}NPJxc!cT7qe$+yy-JM|K&G*1Ix>wloHESuCJfr|NOppFdgq;gVXGC$xmRD z*;;L}$&IhqF5Dk$aDBXo-a3R8beDNVPZ2aJ|E6Dg5>aKf&Mn$@gOYq+ge}O48|#qUTXHN((n2#kz^tn&*L*MpI{Ig$q53hXcRjh-5```K^{*5=hhVtk>Ycfo; z@a?RV-|^K~UlEdoN{ve{VyNs*UCRw)12$#e`1_vXTfh0OEVnnL(kR_UA>c33ys zjPkiU{d3E&^0xo!Pb_vMr?)S(elJ@r!L*0G^nKX zDqhpfmbBumWxt;|rN&SF{BMz`CnX-YJJFIOIkCM7#LYWPP<2-|i0Sa^9&h{WKg|V6QyQS%G~rlZ~OkA=IY)vGFL#b6(uLU z5w=cnfocM7j&v)ayIM0p@y|cWfArz!_^hX;vZDf4+i2~EZ~WS~@a=!^{p6M|J{~Is z;@OLI)nYF4Oug|~C%m`5SNhulT=y{2%j= z-}Nt8&kJvO-95hUtNu8D;!FQ9>nooSFs3V*aK-yS@I3GS@EH@YSzCr~aNW~=#C6fW zjec-5jjHS;6c%cYfmFAQhlbp+rLxRhTBmHT((;^l6UI5hJkwHY?2M@@|BaH~*8+j3MRKmb7wgJwTho6N-NYgY4IstgHjqCU&D95{r{%KBO!RA zivpW;mL)CN81PnLfu$}&1ojZ)j@w5!Leq!`-v0eR%6I;i|DIPq^&HWA33f9^4|olN zvyD_%tdWgTs_sgyhP}PwLm&PKU-F0EK+lh)25F5r2Ohlq9AEr}pUGSP$e+>Hq7*wN zC2FZGrJQ|7X&E_~)@MQqw{U7Fz`pJ6iSDOBP5s(&F>>;yoF{AZMhr zA?z(VWIP?)I+G6Omt|#VD|WcB2W2-Ep_!Jd%B;bDAJW_2N$bkGu2_UtD$Ybpsfa6E zuQxmEH#6l4z|Gf+rX7ej>)mZk}mo;un7TU-IHfA0@ZL zdkIFVh|1+;@ANLv-vyhs_Nn;QD9X5Ib}hoq;*q?3_7#1MzAx@;OA@p>i4~i7j7HVZjTF*e7<4VAMjs) zk3iwT~B1?RBJjw`LGTo-(txb z!tP4okm)S^Bt^S>U8^~-w33)Zpr(xNg6=>Gu(-hLC;rYme~H^>$eP_T#UO1gZ34|x zV0IIA&Geio%ZXkS%W^|03pWq%Q#O;Gm@yd(8$LanjdHCYTSRcMfUth%qgYnRMrnbU72dt0$ z#&3Oy-}vNN_LpF=L1-Ak3h}tCg|b#`xAe*eu{FKWYa*vJDX*lwa$ZlYd1YB|xp{P- z^X&spH?OjsZ|GgID9zBT3{T8fNEUL+8iJ>2f9sT#FM@+=x{$z~)44cXBCRooFkNWU zbg(IBF~P9eDij4RJx^_gOXW3Owi+pU*dI1PpLI?#?Ox=03O=-&6XNvBzve0=M^iDb zfs;^{Fb9kS<2tz))?73XT3qolI&u=!gx%fcV=p}7Tfgg_*!h~AWWXz#)`h$ZV!5nM zHu@@Qd&7v>tK34Ej{G0r`?H$l6);7z)SVM%*XO+9Gx4TiZO6Dy$_qX^YFBfOF$ClA z7=rU#OR1+ZCl|n8nrDSP!DT3Kxi1UH>zW061VZxNN%!ZuR z!xiP)G@O^~WYxiQZ9;_Y#?zYBUZ}&nDC{ibs6|bq4dDhE%VEE#cOj?NA{it-H7E_! z)Pm_H4XCYNHpp2Tg z#LQ&b?e_BUs}ZxQ+Nt+WX^^|&S zRu95N%2|5F%gUOnV%`fiohj>?URQ#H$r*ORF*$>$;d*6?9_NwSj_vebI3BK(!8PcqO6;pQ_+SZ9B%^`I zWF}@iv=h!$aSS7^aPnj7Oany+L-rOQfW6sE`qX`_DYBO zF7Nn}pJspO8M)2rEVpWI&H!0huQLN2rFJ|H(>rTR+%H#r&)felPrdGQkQkz*n0#Rb zmb}oL5CDikcfZL?Yb;f68!dxt7O(y^8;^Ya<;1h!{v!|$GRN&*)|QWgkE%_IH43G! z7$@!at{E<(uCoMZa8{B_gEuPZkOHRE!kUFm)-`f=9K2R(%FYQVt1G$T;_R1Om=9iN zy_)(sqB%z5X;R?}UCo^aY%Yo1E|}*rAO=|1RR(meX<&}oOn=50PUl7C0pOJ3FX#k! zaaQWVlxoIW=r&;efocB)_#HOvG2y^`xW|0F$8>m-FkKVoqXe^r9rN)nHXhkO`Fd9O z1V8b!@8v)F=5Ocu{v_ErYOd6>l4>LD4jfwey7LV4o!2s7JF>?v84I_L9(k2~B>f?UjWP4_d+ zT509!2eW0#TLIZcXECN?!k&+O{8NO(9ju!KJ8wPCO%im|9WLzo;lKZG5>bY`Ryyd3hG#BT4RT*?? zmF^^CGuOuZpEo@FwjbcdQ((IH8PuSe52h1>!D9&of{pBsuj7NyFZ|iR@HhCC58R4R z!HGAi)+*7dyDMxwVB;SAj0rkqHeC*CBYurm^ufWZ*($24#cob>bU+?MAcV-Q(&f@S z-HTAAV5!E@i~}FRNBr(U*d4Ib3@*@cglR{d_FUh&$F#po%1!Jzxo}!5slb{l#s%WM z$4)z@<29v2a}(WqIiO8rDJyw;lK=eAe-A(N3%|iFPh$3Wd9<#)eE$`0Zf|*Xx@B3< zJUZX7mco)()>^pxnrFE4%;)mb`I=|H^>6e4yyug+-H{NSVm~bBoU!wS+Z`y*vjXP| zOS2I6G@ z*k3&X$a60yN~=kvA|S}E^SjnTu3pd8z0ZK)uu-r+x91h(9L9CJm6ljB1v@+JG~;fQ30x1byYiz!1Y{bw>xlqyKE3SbO!SJ*FM81UU)$$nS+*Fs&eUN znDauK_*>um_qcb6;y0*;Wx2&qGo?1xHPKq<`nbdNEXJx<2{H1@gN1+nJ0IiMKJXHs zT0CZYz3^v@WM$Di`{P~ymv4DH$58OW5<_Hn*bzeDlP_ml_c9!&Db?3fjN=D?=3n!v zSN=A^80Hx9!Lpww)>3%sw#edYh4=mTi+uMx{sC!SRNgidz2}uvC%cHzOmJt#6g2rr zSyqabaHrZGfAbyx0P~-HHKt_A0{21>E;-}F#Dhm4=VRxI)ypiYBh=U3E|OBhdokx3 z8~Le!^lo1N^c}=p< zqt9_V-!kp4@Nvd_$M1dOWty{CGBgc8QqI{2UY+=hfBEekb`#eJLC7BuJ8o_tVGq~5 z?wQx{!57ZtTnM^Jw>})~QVup_KtrBJ;n5NKr+1=uTT972QyyeYr z;+ZF}nD>qlCR%f>tKp*`{U|^BV?V~vf8Zg-AF)9&6V3%1qAq!PF}(Aiya!WPeDsuN z`Q7JUr5ew=W+CJ1To6PaY`w*H%bH{sCOO?sA4(aT)>0&n7x?YpeTjeip5La{MdoPA z6|&agd?0Ji%`g-?V^~hN#29cPl5%0%U-9d|`MYxH*34oje*B$3#}miM?5t=P!p!k_ z%|~83N#rg<@ReYqKrNjVuJ|iI_{;3R=kN37Z}~TQ(-*vfDMX(3k=NXN5=$dm!|A+| zTH&P^@AFH)_AC62&Hr`e(oXA%5;Xzdz-vYA!mIUWue2OoLip?Z|iBvlAe z68~DSG&Av+zw5{NbKm^++`Mw1d781Rb}B{EFL%2=^cze#@sa0m1*_iW=AB!mo5;I< z>xTFJ58uI``r}{3XTRZTo_gXAPv5&oD>qadNb5=?l51msIPlWTH$3;;^L+TjFY>d$ z_*=Zv_MGEgoDUeQMm^mRKl1X*JAdXE*#$9G_SS@lW{ep1cJmfmmk04#$s zbp%kULNlJ=XH0`rF1Q$F{?H{upy~|C zw8k{gmpIZ|h&I!kNDo^Vowmo3auNBVoxpna7>H^EBW`AEz`4lnT5zs2M@L>8!CGk2 zLKy%w^+@7;z&lIQ!R55yk!zO_nbs*fC^LP)97PzAO2fH`_l49tsdP;1xW}p!fk+A; ztv5q-k=n{8GN?^{PN|h?5}b$RQ#g%6M&WT*QQ{XfrL&e80(s0_G|R$vgK3$bZ*b+z zXTSa~-A(-7OOJSvDlyI+c6(B3EJYCWlMf=34kAqqN)AB14Aw`|T0|hw8@V(cd)O^o z!5Hx#&^wd!8X~30`&JZGC|~PCAg3zqtZwn4v#e*4j4G9(wJJ(9B^=l(kXxpf%)H;r zt;sf)rATBzM+U+*L6w_VH-@mgBW+A$s8}e{IcVChH9#_*=sd1B91_=XKAkmIxsvii zXd*|S<^vdy2@}-{`>J7}R-6*4b4pBcf+n-9X_~0&OUWfkzPU7SLv)t&d6DzTF|ZOgcNriZ-{1+0Wv zwN_Zm%5K_=GsTLu+gi&sO{{8DkhH^2j2>&C6hmLntfvPQ1y;5uid16^`@@mmC!7m7 zZ&}s_A0vxWlw~$r@TN)HqjS=xt62uuxd<56bb9GbKCtE_Yr|_8S-*fbgZEpvNLo>< zW6|43^wN?l`izct*LKsdO{L~$W2#xyVxh3AA)J!iiup8_)}cMZeB=N4zxSX2t%Y+E zT`)=mbykuGg()w1@?G%Ut}9jLTwW{fkw*Z1b6{4DvvfvOKsGpXyMPfbI;a(UG5ImG z){y_U!M$nX6mPYOzFn)fT9hTzhu1?Sf2C!)9rewotTzs+R7AekwrgB&qJ#)eq^!NE z_T6++&X{iLxrh=%2mYmtZaZimM7Pk4WU0g$WLBf#$H)IyS|vC!^_1S#Z_spTrGu>I zE*oYY-Ce5`(t0YS3;*f2+ zSk9>sCgJdS^tPj3o>rA&5L6P|?B<2B8pP9$D6_0-kHxQ!cBss7Mmx)DEU`6)U5p~u zP!huB)`FrfE)dNTcMNSCDp!3-T55PBsGJePV9hwN45n&{(mHtg_oWKR(AkT`aX=>} zl;3MB2K-O<*h#Nc|Z;M$O(2X#YRw-Fm!O}Jo5sYn`}4lp$;4nvis zI;(7{;8k6xYrKom6zfP`+HgiUosoKIt=(cx`84UubtUV}k!ulZ0FwGx2Q)Gd z@`8kQ4TEB-l`!~UMxKqb)3Ag5 zU@e`BX{u&4nmg3Mcm+_>H^W|2mRxVjbvBNVH3lsTn~v`M7sI6NLdxQr9fWN&ivJ0ohJ z+J!9JBsvi9EbDS6L`N!Vs~(0sG#i64j-3xsGjp7Dz$-U0YvoWJSr6WOp_I~DYr%QX z9HvXIa+O0W_e{640p(i2LNpHBD&9zD!*Br)oxT(W((;k_RMGNILZ*7J)RxDEc1vDs z?ULC{)7Pl+na&7ON09ZJ7^m-n?QTQ+HyWiEx|+iz7{qDaF#1z=(<4tJX+8C3?%Wy%3zNZ}b2YIHBU^n}I3; zY*v#(#rbYjSb&IU^fiwIxX~d<>#?-DrFFL6$*o~SkoRM>OjIsy@N_Gp9E%m)_;@c` z*DR5NYgMJ^*ca54ITq9?1f_M;2v&}@-s(o}9^D0FN9-?~BzD}Sy(;LOHN+{BOT}rt zfNRi<$nqt)%gTDOLsqK<;dUWhSENWpZ?J>7j0T5UD!xndVYiNqw4wk({HyES>i4e= zHKeMK!#dfAti^gQS{3s&0;JIO*n3kY<&7KM z899aBqkiqqX{3~26cc(J4?xOn9+5SM7r@* zi5w7wOO=Yx0H(REcU_wmwTzMNV64^Jq^Rgd;z+F$2zspq7dAKXp!`lTYL>I4VQ#2# zO`RMEM6cq$H{;;Ji2tIs&FS3>I2+^;H(KPB5j9&R>`LbugJOUaqo8oc#0C|1Io z`e<{urn2&^ViFB;(gVdir}F8xg{GAueRNQ2-ab3;{YEel%#Lp2dIOQxi9ClnNv~eu zoQ_ru){9@ksqdtfjP3fq(!f?qFBtDNbyMabBVVEjeu6eqNUQX#Ee6~ua2ichER9Am zNTn{V%92G^-qpBh5DBc;jg}@}f&rPd3ior?YPPm6w8uYhC0JP9u;eBXHqMp+DznhB zO;s$8+nn`QjkO^L#lL6{yA|?-H3p|q4{aRSgW%m|R=ef4U}&YtoYxwMyz5&4Qv(9+S~Poe{KTjp4A{sdpo53#x5>v_aDxB+P+8 zXifYf#z;o7#GuMmO`=OjxXi^{f@#WgX`7~7s#$|GPAk+ANF!lRO{ILTJ?5g{(y64f z?!94E8!h_rsxuO0Ug`C_tN8{7y55S!O*cU21bcx(2Wu*L2VMt?vZYz6P`gy^Rx74j zilUpvK!{$+eTW<9%PNzowzA=4nzrAvf(bz~Q3jE-FI&KT7(#^X=2ySyKTira+wxzX};GN#0jee#we>3`iZ<#w6*6P+i zDZNvbgVVRI&xn$3Q}tBk{ecl(p`Pt`Bp0)a|zb1WVt9i~UuWXlJDwm@2 zT|+lklCg}_E0nylHD~>B2KcQG80+Q5q7^uuu@`rvX@y3vwzc*xpr#MRcvp}e9qas7 zm5Y{Lt6!xFqpFt}Cs6^#NbLfJ6jP&<+=|*ZpNl9I%%w%6huUb@3|387BaV$FXRU{*@hyd zf_FifPRK$rs<3OdtMWryPE{))Y0DY)k=U(=Zbu=YTKG0hj7?s@5fhK(!01iQn#=sk zTA|+7qS2fkMTXcdoXc2cn5idWNWracIzuU9tYN%7aoO9AWmJ0?1yz?@v(@3eH6!-6 z(Gl+vTGZ6mz-duoHJ@P=DSfomoVIN%HpqH!1AC(HiAYHqWr1wWT@C*E z^w24n)AiN67%8o{D$J<37_`CBHBq_hV6|`G2V<}y$a~0z?0Uho*lj~^T@aW?m3!mx zlorXL-S%x@ivG-Qx^l71M!lA{WK<)nX2xsoxcCqZ*#4MluhLzR=ca*i(z@;HGZ=@r zr0rSpO(@K-=zzT`^U3xxw*$Jo&q1ylIwvt27a+c<;ro!UMIoM-IBGH8ITe+NmqHF> zqjQO>Ubm61syFR!h+)T?k|+YZ+={&qv@Ym>bUjVetheqFK|3;x1o2RVV(mE+!%CFwRT=tqolII9Qrgu==2SV`-&I z7>(7(#;`p=sLr@miSjh+4wbf#01<*-qa+PWG7|=f4&(|u$3QDZvYhp)myr9Sm~Rf;YDrAqlWPVml}y#(98?J5gWppLePwM2i7#nTsK`g*BIHOxzRLGrtpaqZWEuI{69i&dVIF`7fuXRPvPv}dx<*?~#NdToP_shq49-a)Pd5l;)Ed>=%Yar|P=+8D7rgf9 z?Uuu0)$o|N=-QN%-VFTK+aBqj#wr`h8p)$6MGsJ^oCRAk)NMtJp}Mu5ox&2WBJ1gm z;6*s#y;P^h2#I(|L|aoOTW{OI*L%r^$Le8&^wot-D;wc(mMe}xZWY?b#R||<1)S2J zvqC=Jh}y!ewZ11#9g#=OLeY17zFcWrwO!+G6$!Kq=d z)FeNz39%x0m9dvZYek`!x`wUXtYj!H`5C5nb-EjB%aoEpRfoC=BsO5QpKFyHwRx=N z-U=Z)K}+dAYn#|C93s@1=SV9>ZaQVCB}d%1y(?_!K7P!diWCdb|T*ze2kcssmdBIIz+M^$l?jfCCl4nEyak7x;E)Ord%aE*@|2Z zm4fc#Bw4~L%`Ag2UxmT7rj?!Q!mYQmXiC`3FQ;7a!IN?##(+15H5c-YyunQ?F7G2b zWw}vkud`L;1ND+jh_(rWwQ4G=o5H7PUwx?s(Lc%oG=}`nnhg)XZBnffy~lJ})ayAj zDdE-VO*tYj_VzYp(I0JSK&RkO05cU)CCc()5@$7^|ho2 z4{_cT=O}VOENRWMc_QC;-pw*!@>U_4Mb#)^dxIv4X07NHlYALt93>}=87OfCeCRAbLe5Fdidph(YF=2A6iXq@w6@^2x;EOQZ#mOV+xo;^ zTa;jExll&RW}K+yOh@o2>#|^d5J5n#w9=tUVUm;Zr%bVsG9ktdR5|+UW9HJTB*3Bb zt8|ujR(^C6W|}qZ=9%+yEAK}xL}RIW#dt@=(u+pB8fhK+gmS5xc)mQGmPBx#^KwgY zUN#S<0f^JOl2rLKTF6tB>f2budM-@fb7#Nf-V@h+==VM;^kXBcp`z%cENmQ7&Qf`M zM=OceJF|17rEJ*e0q{&kKI1WT4{+R7Az6oxh7T_fj}IRwtjiqWslYf?dl0Wb3Q=y&v1L=Nnr%?wJOo`#k{JT2RcZf~N=`*@TB4S5-m$jAJWZ_U zRi(FvWlhZUEJM)VsJ&_x>bSXi#60iCm*RCp)O_v3Vb7wUBMtWZ1Gf(!k#w*Ooh&dk zN~uiqq_8@J^D?(_&a$p&f)8S>!jRT8yWO6g6Vnt~*0cp$?e{ytm!q$O>hc9)`-FvW0XkO5QE0!UUFuJ7pAqwJndwr z(v_5~xAO-NAK-nEid_fZU6m5f2XaaT<);lHgcBYq_)caS6(56iQV+Mgb=AMaX21mwQ^n-$&)a$Va5<; zjcYEv4}o<(WBo+cwxirEEfkr#-rU~OI`W)EN6v-)Vb6I@#AzniMp{>V2sAY`N@t-H zy`xpLr7FsP(FHx!ORBYzOTvYTv@Tfd*zfjGW!>bGiQe<@!9%7Pq)?t_ zk>S(iwTD3Pf`?z%mECT?wTd6zJi?4y?3%dy6?^u_GZsk4kAyBeBr>FCY zDMmt=NaqD-ExX;0oMoST_2CU+-eJD~d%vr{>5D#>^>jn4m6W9wB_^&&N-HVl4WKm! z*(oLV`@J&zy(%*#Rjt*gnZrtu)zFlU=ie&17Up?JNs0ZulSQq8?jQ;UYwh-ZrQ(Y+ zq|?oM*z1i}a^0v;MDrM?I6XEr)66;@fU+s2oaQ>Knaf^M&t!%oeB9$DFXzlO zPvo?2AM_Yx55~VSo|K9XvkFrP0&}c##g}V;Rn?mQY$UPK`@ms;pp+~htPUxAmmXzl zmli?L7M=5s)A`K)a3JMGQU6M-on<)@Cb!A_O3K9fxP|yE%feo{RB}jrm4d?U?G5uZ z%X_Zv!deT5{f=c>B&||=?xhq_VtE-xpKi~D5Vsi^$ieN5qoktJ(t6$y?9)6^l+ql1*pxrcO8$BXQ9+o(c)RE@I%_G+^DH^#Bl*vIg7eZYQD4xQ z!K9SPr4Te3&*_13Ixm_j%yeU!=P2{RCfu=<61!|Opm*T`(|CoZPZA!PUx%NrhMZrsOTBl083Hfm$J=U z2R3PhsP*1imdi%@;K2i&mry9Lc^*@oIG@i7a;@4CN$mDBy%~H6EXxAOdtmh-?86V% zW&K87FS%r_x1=N>%w<{F=~|JT`66ptl}~FCPex6dRvWwBUQ<`Ww1OSp63v>)+Fcob zs~BX4Y}CGxbdU2P@aW+~j>iMhI!^0~^`0 zH=SvT4Z?_<979Tp?YVUp0dCePAgzZZ2}@T}{~(i}cm#e38wy{-1x zl}MVFTpjl0oN2u>$D4zdeEl~TRG|!@V(piev{+tYPo60l;>w3aFQM}k`x8+Mn z@||pohr>aDcctRv#2BLuS!m9QNnw%B$pLaM8EnBtz3v%9ui#9ltT!^}@ttUeBpXy2 z(!9(!=0nu{caOAA8`B&pS$fMcM4F<*D;6Kb&5x1Lr{EnH@rLAFu-@UV5%p4tP{sA@ z)CGKfn0WZ;6@v53&SAUUU}EqbkJq$Z$ofFGy2|fgDtC5*Tga{8qvw3OWx1{Fk9+pB zr=&B{Ia-GhcjRoKRAwzK1`4N@g0}(IhM&AZ^!&`KewLK78bT}9R=l_3nXm?9r83{| zcVs1jI%}D)kFwG04Nh`6q^ft`GDkt3mnw=k8!Rq};VphK|M*|`8-de6=|$&hJcF!NEu!rbJwPoIgl;)0{B ziR*o!R+(?ay&POa-ix0Etk)XG$ZXG$FchpUs>G?-Zs;w`v*DchluFUgfI#$S9JOT9 zx=Ko3h^(vGs0r8mBWpTweZAv+K4YyX`bbKZ$y?gww1|w{+}=tHFgmK{%e2+Gsau2H`JDNQ#bhF2;OsZbEEAWDew%PDaxjwmRs)L zz2f%vgm+NWA{j*MiL1j-ozjMs6UXDs&CRW#g^Fy7XBoL&mn z1*~hNbe1(kMaHIrLYktdtJ=*9VH58iewtX;h2W#EamV?55?HMIhq|`IaxMgKm3*#% z&feKgvbI~z(#bBF7CUkBI!DV)!AkmIS++SVwQ-mynQ59%+|AOJ@m?tEDY4t{pjJuE zq+)ua=Cc}?Gn04po-qw}#I7OUrp^Ca9%pT%!?% z1d^ld{6JER9vxK?SnqKmvZO?qCN)&a-ydd|)3VYOC(?BxifTvO1OefSe%)9nGm_S^P6Ooi@AhtSA&T-!l^<4# zyfy@U2-MoCsp5>o8;`-VrlJEb{W-=`3g3;GMTW@i}QhVjRE&|f@;%Vu? zqHQ`+TM3PH{M%Db2oflk)cptevoi{pjoP$zh1((uIrwopf)6_qi(r;PvMx#Wn zQ8QO>taU+60(6xN4~b^0(ryY-5GYtkImyu9neFGg;7ColWc zTPMazdb(X&c!I5$_D0i0yRK_H=cYGOAxxws@45F*W-p}@{3I$N7qB+SVU4t9LI@lV zJ5HxFyWLSPuIh~nQ7Hu)Eatr&aP%(X8E5f+me#Cs+&sEvin{6NjB`fjv0a`EV;yVC znlvak*P+lGsjAZyU^9dn{606nieX335 z*Bc|7_Km{c-D+|&2ASx~&u_3Z7iH0G>sAk2AaWYS? z&Emf5tzY@>_sZ7rVM_I5+sojdW*>MTFLtJq_VAfRaa>h=easab6PcK6zPR41D`4_h~S)5 zU#iS-nuX9?V+xZF(a>xXWgE3d$%z;vYsomTo&)d6DKXD+Q*}9OxA&ses*7Cm9D;*h z3%xY*nx#_cx&lLjfgtf4k}WgP+6{ zw-&+ttM{Z=*(fB0i%l^k2{u*51DV}4=`GW1a8?r6au;Qf01WlkVXPPkyHOsHNX%<# zM5QF;T=fb+iPx$1O&PbYSr1GVyIQxopuHIpsTOBEH3`J7R~^ijCO^*%Wu7|eG16kF zMNF=(=s~1y)Y|msAg!vVx_g&Ug^heL)%-^nZm@nqzxw6ni%MUck(=> z^l6gsDbDiy>AY=7d$;#D$h`9A=Fuhp+Xq^e*;%R5T0Wi5c;`7Bj$-uGe0$?OBO|b> z%X}!ZN~x^ts_o8ITa9Y)+ei^=I?b}jp-jHknkuLq``tn1>lb$4q_gCl>JW=F#Rv_2 z405>EgpXbX8s15?#O9`g-C?iuJ-KzzB<`E8IZ(li;!jn9X+2|y%2c11?q}Rrf9+eJ zedgXB%6itSLN+27BvlaO#QCd(%B&}sNcWnOVIc-Ze`w_%LfA6NhjOXw12bfbqbJ@p z^{usWAeoDi%MAXL@@LkJ_u;~r>P8*Y@_(<)Ea$@3_sgX!3&vecVqNV`im%w5$GT}4 zRX&`TRr=nOw4AI)QVx8JxR$GCdWaHaSr)<+#htB&MscJo&ZS937-FPro^;SmdFL%g zrKYtMb)btkBNtJcIjgE3=cK=>RNT5{Q`e0lCSMms!KL{d-AJg>(7uZ==HhVa`uVli zSk{GH%QnAq>LwpU?=ehmUDSASo@mJMxK_ooSLvgNC}A|tE218$=7+pHmAWm`hLIc- zCob|qB$bCw{$3qeh2fJ?*S~XiqnZvhd~Grx7&Dbz9y5trF<4Hs99GnZcT)XhFX#@e znBjs&;!mqqayqve23MmPpRU7KW3}~YEg?i6K6uFSaNI(*cDudKp*jy9J|xCT+Gm<- zH_bDpHBRR$zrlqqDR0BQbU{_bAVbk*Nm!g59Lf+o-JZx* ze%7)kmPLxKSzB-trK@ub=d?g_EbAE`oVJ2=_E%&UDI%4&wQPcuHcCxNNee;JFMgF)il{>8X~H-| zDrvh_%kLtC_*R=l@M;kiLJ-=7&cT8PiFI|Q*CJmNy;mQY2#QUY*;uVIQ@Fai+FI}; z;dLsgkb=j!3ku2WLDXC2d|o!MP1IXS*9<3%;GJs8k}B%sFxM$Ruxwr&=q!tfTEx;Q z=%_gDNU5;goC!ugL+?GcWg&ZXNV($tjQiTJd+W2$+`EP>T&kkCf?5?{l4P?C+OM0Y zNd|=~$qg}VIlY%uDma}^n<{BAjGA7%22}0S?D~qTFpuSFtn0F6PA|(Ux#MbHGHTYl zxL3t*k#knErW^pGt5#gL^BNnO^(vgU18xlHN1t8Xq-^4WQX~e|nv1bUuE}x@jWH@l z0H!%&-DN&giVW6jlR&3hD^ce-V{@ua!-sOl29Z4GQbd`eZ9HegM$H7_Z`E2!rEEbl zT?GTBRigJ|+G|~}!>iic)P&iD{k{nkPNt7~_@&|LOl2^&)6K zjQ7ks94@7>+wDY+XN+VvtNqtn>D}g9HkOxM@4Fhu!Qh#Rr}t9At#%t(afF3JWrQs)d2!jUykIfcVZNLe^sQ$c{%ItR}kV= zd)R|$Ypo~cs<#mlMdYkP0K}VOfZN+MAqcnae7+?{Pi=)#WiDm(ecjFT_8PMiVZ9g6 zkkMzYltP^1Ha{Hx0y99BgFdUziPr(XP-ye^{l}hS)l@q6hoAx+ugIjTGlr~TW8y)P zF2cwla!#TPBv!$xsHReCQIygJsyf61DJ5LECq#tB&9+e^vUsyZ5DY|V2!|}T(WA2#Uopkqct9kiz>(INh}Hg?^PL7N1w88 z+@THIt-%-_aF44NKlv1g+sL~)@ zxG<_pw_G_fp;T=6%Rln^)v@;U^e3p+2@&2dw6>IfbV z_yMyT%8t>JNm;;%VTzP2l3(u~Ax4!XTdf2|@wKK!T8|pz*_D|y{#>=CGRVX0x(e9M zTJ5(g_~kLaRvS4oSasMs;W&Z({)0l>MkOOHC#hf;aw|HpcAF8b*G|;x5tRIDhJmj# zP1At*F^b$qee^OPYmF(!t!OdEs0=pCK3OY4m0??9Q=1;(R)MurRC_P`XxC>$ zx+YHJh?CA#L~eh19@OmZyyBa5F9hYbDgCz!)6i*hraYfYanOQxv{{E^J*PtN78_^W zSAWf)diHfsKPg$i0|?jp){-c>Qc7Ysq<8v56!$1{UEN6Kg-T^R&+~>r7;2dTFLR2g z9@XiPiVlfj)VZgPXjTT0zF zfA3Xk662>OFDgNZQW`!61st~RK_7=m)2pZWHo5}IjPw{W;$g6m(yDSzx$=6Anj9^q zUMnSK>DQ{PZ(uTvk}<2!FovLE&0HVS8x;h%ESw@BI1 zewkHPn@ZWN_pOVf?tB*YhSzGy8mmH!zDYf|s_(IX{MP2_n;=8XjWVddEchXbE}P`i z*~#G= zo@aW2C+=7?yR_7*)He8pku&f!otbT6roPwTA8a9&+on zrtSG38~jk)ozG`#71{MD3$ooS>2M&{S2UEdIfj(y9Kq zPL9EBDfv=Wa5<4%m6^wejn~`C@SB7X^`KCiY9GMErUP>&Z)_z zsFOU#SsDwK$57IvV(a?fBxuEL^B9m|rQx6+kg=2tMrSqBb`gAb)Sl_UgC7n@X|dF{ zIg+zJ5F-r6=?!n7MFbhIj34d!R)x0p)+LH_Lzjnb5n%)oZoM3^!MlxmYmMDp#YW9~ zIZHU$;jsTSEPp9g0h=Oqj?oi?-(q|R&U#dhV`ChmgU5Y9R<--JHBrnc^|#eZh+ZL; zT@fZ@<5ja@2qF+r+Ox4^?McO+im1UJ-ag{$AQiiDo?zhin2YtX@mtm)~35`ENyGmh#yF(1}QDlvdUS9w{jzN`gfcaIz-VHMJs~m zIGr9bPtyiTtfeqbacjTq_xlYEFP906cR^ZI?eSTJRWNARmTe%0i78IoPT#V;}n%cdn00$90$2V-T;2wrIT%%93lFk16;- zEe-D@yZxTpDltr~>;KQyd;Z$Co#%besI#rLb~*hPc2P1_9LGTrL{0((2`t0vq-FWP zx&J{v_=_PJPKCPE@T8DGusVjYkx9Nn7WN=i?`8 zv(e0dgtyRb3Q!MlDG60gv9DxwDL_lzsuzaf3ZhmbbuO6_<-S`@scDht`R$T9&m3Un zbjN#>w$tICvn~^*5acq|oGB~-B{kM%ZHI?3lsmxq%ti_=yyGYp>(35ktw<`FK?hEy z@MXc|tFvXUgi@3lu8f6* z!qw#l$&=D2L!sn=ulc=`MzjN4225Me}uC^JOKn69po zea3wA6m}d?a>oAkGhAI?lRO*(T`%zbM}LjOt52Z&37hGH#2!i`WWsO67*R64SKfy< zN^LWBeNti(V+#m@D8QE0Xajqqq@-Pd5E5NC@bc87B&uFXy(QSNO6n@b6|4_|jd1*2 zmV&+?u&fJgXMqBaMLywHq#yx+TSGhe=+IY8AE`z9lZCHL68V1y`0LnmMl$9aiTdYH?kQLztO_-G#DvLtq}i01|8N z&#H$y7OIDMtt9FgET_~Qy_J{m&)UTLVx5+7nsFt!+@e%H%3dKTkia_nxS+I4M8`fT8(RF)<-nmdt-d=IP^YsIuu4Qo&5Mqvsw7w|2jRztFKF0$W;Iw>;J z){(U_ma~2&iZoDP|J7f5?|QcZO2RzP*j-+<4~&o`iTNmu0*{FmM^)#q3C{u1Du`Nr z)R9Hqb<{Jl#mTY~)hI4%3#SIiv3fp8szslcZh*z#8trqNjQj(GgWHkR`)+NzAH_h>sLn)h$dV# z?XFU*R_d~E97nz&?U~G4zA7muM?PwXN*M*3AQtEF7P`>vCP4TR+Uqx5s5X<1Hw*= zX32=6guA=Dc6e9HVh#Q(g}S=v1C4_DY@n=>!t6Y{&5coQGIBT^TXC3j;m%KET^CqG z65E^GTl7QU4*uFXfvpoYzbJ#ofAhRx>;|CZHt8{jFg`gYCPKtEQK}bKW)pQCiBSh1 z(f1veb>-38DQL>_3ZbdntmSEvL`o4M6{z3*2fzN__2mZMEtr_I6B#93$01~dO32Kc zua#x3?BW% zNy(tNx@ZGVhgv<*>VH4qzf=^&`HN8xU9AmqhLBH7IhLqZ-Mh72<0}pQfYlLGtmE#k zeyzZlb6pl@W)Ich0|6F&`!n@0uRam)9gNm6+R!3ZoMx)|3=b|v6%9m~&N6(Fb|Kyc zXh~J2R+`h^B=3N#%rrL+PR&0cM98UotDklK-fOof*eq3l*Y&cIyrXJtvy@=0V*hk$ z3&F%V@xplhs=muPq3o?o#^ zwucakXT}L#FHuTBTxQ7Zar@#KuC5;-D~;>}Zf~CA-oq!@?~h2~2;_t%1f-Pl~ z+}$maR48cnVcE{Ga*X8lLa^|Lm6(-2-Vy?9Aq*b5%W~RXaCXz_{VOfdPjUdHKwG~S z0Wqv1NK+(QqH0K?*#X93Xch-jU(xqQ-kj62KXFzIglsbPd5uuN_RGKc-u3kal*}zE zS`d5Tkw;9tL=&b_wPV|7*OQ&!;Tdml&RxRhCE#L|oZMIfI zDJ9st*sh%eQ**(ogIUm`JH>>+7n3M_6LTe7<;QI|aMp#?j-z@G>MXKeHZ@JIUUIZ- zCKSiwJaM%O-l%FsCJCYx3fA@zkmx#lc6kt}tyXj)c+M{%`C*C?>q-zoYb|$3yyz4H zt6N)+cow9b%7u^xN=n#)&>tygen4W&)# zNtp?rH5=UwJ^KIvAOJ~3K~&nuzzL0%tSIVOK+7u>YT=3|6gX7%|#<7Ps)Lp!M{sM13evJ9%6+Zs>Lp*%)1VUQwgfkaXr=7tmewaq0 zLK2larD_z-h|%*1v}YGRl`tX1HYzkmaZY<|OJX5`!0u?O?uq70yOhM7C|d9?%iJy( z3BV{)>hwe3Wb_qEX|3i;c0^)aOC7ORuS)bM3`muj8e>W1z`H@pVn0O@r$lkBo$cUP z0tfa(e@fmHK*lK^JM)1lnADtNW_BFC%O}!CX`@x=+=<{E6Cbh)3db^v+y>&KM}k6P650_)MgboHi4~KC9a8G?H{~ zW#0|^I0T@LJd5B(GjGRccM!Z|3?%WE8tck(c`bQ=N+Z=}^>k4L8p#pJka8W71l;PNa4(g^}^4P~;?~x=|IZEd}k=nSRb-Wr}V~Z16)tRfcR-T{8R>gg}Vz zbMsj>j!8+?QZ>hsnaXiy0f#<;s@K8>y^M^+e=m*MWIM2GJS9p&DuKWDX}m>#kV13L zB2L96FSSc5E$&jVpg>4sS`h-BWW6e*jbi{1Q|hc#B|e#Hg+cY))JvU4b{)IQ3**Bx zf}1fJfy0ZBFyGzb-u(x7@$pA^=kX(a_`#2u_M32hxq}%89QUuFl|dE~#z?$+^$c1T zgpe@x7r5BnLuUuPzP-aVP0$^6E#vMUY-h25{Q}q5S2!MzINrX%#dZs+43_oUJg={^x_w<=j#S$Bonm{XBUgLI^ zR1ld$m};Ct9Whzkaj05u!i-woTWeWDOW@1RwBbClLY^7kol*vkZ}@&PIEyA!sf%;! z)M%8IVnp9tq7Z8X6vvFiIC&acg|ne&MvfR6l5xw3{#e&VF7=7-fe*XEeIac;=4V!8S#XMPaN=C|rH zKdCW5y8N>nSMeNIu)Hqv0#9qzlwNgoR+mKJ1yq&pYIgmSH1>4RozU{KE!3^ioH7_V z4NW%pv*IPGQL25NDov*B2aT0K7+cA;g~kM@#UR>3x>lt10IBho)g|AS(>&s;9TQbN zge|u$N$eSSUN7&uQ>gxzTxu#H{XUJ>2%e2u)j-9vNX41*Ic20$U^+{js}N9f#xM>i zS$9gXy2h&#{=UvpQS&5tXLc?eAbr2!j*AXQ0S@jF1xNuB0R|b%&2wxwJ^JwyJ_am) zhL8epz4JCw&RCCoEU{p;5>^+K=wYoy$pO8VSXK|)Pl&0&r+|A8AHs!%Sg5L1ro-KS zkDJ#ovDsZwwnvFb*+UtN*RP)A-M8Pu%a>1^@{PJ(N}``OI|!w4ceurahmTOQLP-vH zcQ3KqUcvSQ?H6iM!-rF1mKyjSq!FclLsqVo=1V2ByxF#Qm*NA+H$;arA=N8msjAmf zid;0ElwiX!ynzy_h#^uYa-K~(xy(t{O&wzhbdinINM0gGxgY!eo}(W~{34}fo*lcW z8@~$d?Gl_zlG*Lvai)uHo-<=~L;R-{SR!4M6KS^J^*v|9HRvx@%Dsm30k{BK6{-XbP8R+cu6DQ9bfTDbRp*Us6K z3bn>K6wcl;C_+R1%HRKm_Z~gELOBaWI&~hZ9H(cQ z9Y4NIUDuVCYOIBCC=Kp7PHx!Px|muq(4K^5htAw5g6M;%d1dt|B%X8n5P>3T5t(zV0#hK9WOd@#XjY}@1v$K<*6zuwp}`c$3(hGi%Tw?;Y^XM)hklJ z56ak3@ZOOlIuqDYFj|Bh(UG$z=wWxfxDS!AEVYZP?N`k*KbLLSmjM+Op~&kXNT-WV zW_h&9uSmS1%C5g5AY6o_+Qy9z1>*-mUP<9X|TekI+q9 zq++1-2HkXlkOaQ<7vIL0zVa2UuE2B~$Zo_mP7snza?@r9X}3fOzPo`78Jo*{5T(%9 zw~``T3*o(EBHjIQKEN1_-R1)O{S9ORrk*l1c9(CVk{4^laLMTn1rBwZ;jFXGXT{LD zAc2aObzPhK&^gC{hvwz9VAWFMV$C$Ak}+NTU02*k-}eYHu_KzEooN`cI_mxtg9qze zUErjyE26ZHBd3JTW<&diaXh{IjdPdKGlNW@%T{wHL=Qtx&-aYA;4;QEnKxyDCtg96 zHx53{zK2@1SC@I+71Ew9LMn}zGkxEsU>f@tQ60R;F!aq#x2`Mtet?p?U5qE0dL|RF z;7gEY^eKi@;m7-+%)cvSc`}%C?tI;?R0(}dfp$S0h<2`4ZD^=kWEWuQX~Y>L38APk z(-4Y&cUlo7wSru$Ouv5J`ghi7Y^CU24I#3zv4b498`eo@N-~QPjZ$c=Pzp!53W?x6<$C}OTazt7ry>-Dq?`i5ndCr*F%r2@2DoGo z2JJ>P%QQpdWmV;fR+@fp{(e@sU>G`%oRpMK$I{D__C2>nI)uP+o*8B-o1HS7|7KY!1Rd0#mwZ*h2F?PHkt;wc5i_BtMJXsT66%6K zW6xFGye=pT@FhTK3EP?G2?!~oD9OL)-15Ao6r7A^5%}kiDMM+j+7 z@4k)w;Ra7W_cpRnm^zL9-7SW3g95-E7s$xziO6t1Ary-*f9>Bzx4DO5_XuLRg0fre zF0S$OKmQ*=3JWO>ncIrM>zgA03ENHR#tRfk7(3$rlP}`QyFUS;1`HSXaSRIGu*JIU zu^9~nB>G`OH*BD-!QK7>jfIvh)H12#G;ObVVsabQ4uj0IHoToBA{bl798dy zTyW@y4k3~Gk-Tme^WqRQPy`7*5?@$CDlFb(e>@^)V09kHLl?$F%G_s3WDql>#k?%^U4hHI>z`)$v@vb~zQ;S4x6XroCvD^OISf zQLBLy!-f(7>&@!<&sW?=ZEqz@=_*Y)rf@RG@$xy8NKv+|0Z)-Gh?O<&Z~7o;EK?ob zffvq&%{-c`24hS+gb9A8&fNRujmoU=dl;5Wo-5nth1%PSCOKag)}+1=X`!dtJV}y1 z)u3q=c9~M5;3r;SSBYWflsHieWD(5LA;E-VuwCT?NnY4{{y7Cx4p8OY{PlH9_>*m3 zn{$C=(;4!LK_#sFTNK7rbVgynzk_og*Y~dR?D=zScNfr_mb%V+9DTyLyM(p_hT#%f zcF0Mz%!!;L?GILW!ewV73adCZ#akIg0cL@lI-Q^{qBvKa0kZ??&paY!ti**Fm znXc`H6GvkUep+U-@+sJHwet~FH6AIc5JMyx8@Gn% zBdq$L71aPf^v*|D^pBRf+akUmG*$OaRbp<-R;^FQ%9KvNj%s)$ zqg8B}8Yu;ePi>x^_<~FEL@EuHU0vIxv8ZA*= zzz=`$9lZ0_JGl4gA-?zB@8GLH@ns}wkuuPaBQ~2YicdJ+9dYmReJmb$@Z|rD(6vF+8*SaDtC0Lc)a%DHKe92{?y`_cw6I1D5#? z>$2j>TkqiR@De2{TwOkZTW26e+&upjMiR^DsYePoQ0=i8)|a;sx-Acqbiv+URKVHTk{A+o-Y zUDvZJsD2NgBae?K6py4`*YS94&kOmMEO%1@U2l=1M+%-Jv&o|{ub#RG+o8ma<9xvD z!!1_T?>(l?=IosVI@@8tKhhz~qr4=DXe*VXcQHjmrI*sqy2M3} z^Py#S9M>7cFygR3U>YYJ*O~KOI!5H@21JdVY*=#Q{gi5EOHqJ}iyd;w7{?JQ1LM$R z96M-P@ZjDx!aB1Nw!jDlA3en{02tU=A49@043Hp4I0TJ($7bK-|SX&o#8)Ucm%B>e*3ro>+NI&+X3u0^YAD;$~&}@WJR7*Kp-0GJT!dXE1aX5ruL$QiRf+UmY^QFJF}& z*PmHeIcy4yWY8yhv&dN73~T#SyBgvfql~HqDQCFoN=|JL1<$;zis`gmHBCt-rD`Jv z+gVODqzXhU`d!uTsV*~1Dag@t6iq=9v{cuS5QWBEJ_Q<|;Y4B1sfIwIQ9lbZVRE!; z7>9GW|FSF?`vEa{tZt^xM*!Piz*>P1e)tjY-@n4EmoHH=@c8i)+}yrKh#vd91HSad zuj1oRK7i?anCU*I?KReroA%vkjgk^jA`*{bJFbAM#8fEK?SsEJ-C_kqQ^>`_+$zqi zC|RI@l||kGc|nd5ozXZPUPG$^AuRB2hEfWHHMqLE#PgS*Vt4;tcn>6HA?3iHfz#)o zV}xh1i8dW84e9f4N*zfcKkDRS``#(ewsztt-c~gUhD33=O49Pz$F+4Q-UXyg-N-l& z*zfme-imTY!+cV+|*?HQx@B(OA|ewW(zP`1a5S2y^BKl~=X`OQDZ z;vyxO4kNSzdP_)->XNtGylF&bVMra}7NU)`F>nQ}TBUG29O%8u1=7-Zi}pKb@Xtbu zs<9tYI4@jC0j&h@D^3Acgcy=cddQ817J~!39pIhAVegPLpp`(^X*_xK5Wn~f|2f`y z@)$;D>~CL_zy~D#^&FIyGQBHS^Z!q2&h-KMj`lP2e84nKvJ(q&zV!Sconkj@~4;CX#+hd#|pa~q*F2_H-Jq!i?A)ih4)`;n2~suGdQ8 zty08u7@H8aBS`GMPz}3oy6$w*ajv%HF-wS^5g-)YqoqbHK#m!Tsj;MFFi5DRPthuC zf)*AOWOAx2%VVydKy#uXWg3~xtK)DNv~Dlw45Kw8wN+bu#zYlR-}NkOrufU+IhK;( zIkYCF6Gi!)a$J8e73E%yS1~5a9;oVz%s74_P$Z8XY7Ip#2lSmqNfFzr!!Q2Ae};eO=Y9&o9Z5Ev6G~)M3Zs#< zWWi3!L?>@We1;DzI%|2fUz%_z_>(9}C{mgc=+Qt3)etx&4(O>@k?9UMUYc$2WZZz(JslGP|l(+W2^4#^1bdXmSw>>Zd!U;{dwvGU#t1h zlvzCL;c6*ydSd;0wfo7rw0~Zmp(P3;D;Q{OwX6=gP)C$`p`IClCj^ZS`p#lmX7v5Q zJROA+Pdu`7oxPB!bi1za8^A5{zKSZw6OgT{QQF0PRymR)OCu#= zN1PuXO;TH1n3vL20n0LzG&NC1NY@S2NmrI!Sux+- zVbf{6e*O`pE>PB>?>E@Leg!wb#O~rgalgtD)AkX3-eA~VAZ3A)BcgY7iLq3>x(q50 zu7)%z3-E50NwcKf(nb?1pAUv=*7e@w>hconykN80;Pva**laeqxw(ZlB;&5*Y?6bM za)@N)Cvrd~F62~j-0xw#4pJ-lkZ9JQBCJwKDWDq$91knMGXhpBEZ)IRJz}=_pMU4~ z@TcGUb9DVgO12oF#w%Psd<*^d3T}0@$cKb57GV5{g@jz%jbyJV7R)(vN}xN0A(%aGV%Te2l+Jt|=Zo>e9tD7u9Topx+uqvYP`Z~rTL8wkfTce}mkRAhIJQ>E zGbwI8IKZb}DI9FbfVoC4_t(23D zu)1(h$!)2{vDSapZYG_fTtW4wNO&sd>>y z-0koyBo+=%!2b(o6f?$t zgi^B2s&gejoTdELF;+b=>bq03C$^hSTjWpEMCqc(xzS(i*RJ^X5M%RA@cbFp&_xnC zqsBI-9D_h}L`Hq*U;StvkS1> z9bhb^9g$%mxLfx=aO87@?I$SPGrFa+pd^Rn(Wossk9A!jyAHw_BnZZY2pD0ofBgbs zxy4`&o;`bsi$_l|UO#~DdfXjmM5!Q*g?9nVaY2GamI~5#fK*73P=*M*#2o`>A8}kA zT!^s47Q^ley5HdX;oEro^Iw7NH&}c^3?3nP{OdpZ6Kpmcy!-Au$RSWncHa>~h4bqr zD2AaXh7t*bGJfc6Y1poV7Xs?n ze(e|EyMMiHydw2E|%Cz^-fz4PsbKrRgre?q-Z_VXm+|%Q?fagk(}7b0N$;OB{T+n309;sM zEX~6S$!uC^R$``Ai=C}5f0kHK7>?;GQGIuiQ@|-pkq>z}8N2C;S|G|CS%`=J=xV*YXDnQ>4_~C~?!u9?8 zNYJq3J=lJWkZY%vPXRtDq6nbd^vG_(&H(=I2H$`GFY$-J|9|5BKl>Bhe)=(9ef%Nz z&z|A#laKJh`+teQ_~Spt5C83V@yUlDV>?Y4M+<2+3XfdHS=f&;2IDxk4qdYXZ(SX1 z-@`3OEc;h@_3Swwf9}gza)A~eukY?~as3G1MHHzZwPhl4K@6Uvcq>ZZ&vae>DuQISWc^$sn#(FwMI15BC2NKEX&dqX`Yw#mSY{rz1?9Pr?YT5+Fw@n zSFQ&cbG=fCP?8q6!?mB@hKyNLi>9KqG7$Ia#U58iCUtqJ@V6YbOi4ZR-!Hy$}5Kuy4-p?>nBdiNNPgrz6 zKq`&RI6^CpWm$1|w?~0O%n~pYwwKqi!-T_OhB6Z0{r>l`+idW~FT9I+K0uj{vd@tj z4|F0tNES;dsoA-nSv*rt!~g*R3EG@gdd67ZpQ`pORB=QlY%5+j$TRq?4*_=CPzQ2- zwM9w+vYaY%h#G3+-(X0cSH`tkZ;_cTZjz%Eb8b~J3zOJvra5ee{@$A6m2zrvu~NvE znpd-ysfuOZ*1A%}W=!p3t3PmIlNEJEnh%rID3s?_7hxG@9-E~m064$QB#({ij^(5v zi*L|n>rA?KL$>QE?TZh7!77Q8(XtB!1g!0wlQgB!W|yLMLDdmXE>Wo{flo6(SjGSV zAOJ~3K~zdI_f&OPL2gmKlwVEa0}>!G0=>K$y;_7o5hvoPR5U&ymeolqB_x>!?9RKS+S%b75@czH~W4!1HllOTnx1X?d=)7mwWg6_DG1I1 zASo{d8>WlI>KvpbvuaL>V6d5rx4s`(qV3!Gy~gR*$x*J6<9z7|sgR0*`sH8xd+%Le zZm`V9vk)hm%L~wmq8jfi29Le*Qj zF8(W>wt6V~zHk3b*U_x9WMElW_~2o?t_3>f99y?kvlEo2gDyp8fC&Mm4P~=v3Q@8Q zU5xDL4nDBtnLG_7co3(&_yhvm<%%@`gZCs$lh^FeR7rl25LMk?mMkp;CPe@AO_T~k~o-#|MRPe6A zuO3nxn0~|>5_C5rrn=uUoWRCL(o%?dWE7r$@B#j>-~Ao9`G|gdfnx}`yV>LUvllqH zh*vi=UcI=5pJxackaB@n0&DizU0tGti02>v5Ko^z!{=Rd-F^8#;w;cG}@LKX(L+fdL|^*XUsGDbG)mJF>VyQ*m^EUJ6hQ1w_N zRV#i(NQG&fSVkWk|A{qGFaW(x|x&;cNLPI>h#(AmBNWCL8AWLiW5moDOjB& z^QV$6zn;R>6xMkLB(S}>hu`{V{~cc491#FK{?bol_wY-&ooP2`ElnCkku2H>h=)C7 zBBNt!z6I5uedl(4P~ zHp7VMXf!-K5*)3I$GF|WFEasIg@I5O7rRTmeDMsuRQT4nzKx&xnV-hg8)(JBUW}2U z@$@PCq{`!kkQ}?An#r3yB_t~bYJtprFYl-j;-(<6Iy3QEeG>4T1SK&RsNejJZ@hPP zF`%S~VHhb{kd+?waE|O9sB9vdzshFx%QY8A8d%PMW5vIkney zh0(N7uX9<&-jEOjI?h+`Y}XDZGAMTKd(7-otsav)rjcA332~Cwb!V&>s=^C=^pugX zI>sTRbsOh;=~G}68LaB^o5-38hs3%dc;C7S?-ppY!S>!e@VTHHHpnSK+qyHPkz^H@s0PHN&nB_sxa%y-N(mi9 zl6N-Ka(Z5jloDDBC0r7HR$+BSEVkBSSr>GD$0Y0mtt_mu^jT<0&k%ro7J!seLT86c z18N=k(D%?>oXzvhNt2SIR9QOw2mkM%;$MFAk0G?igSWqod++`v)<}4d#+TEYeij~e zY80g4Ahe;bjuAOpTZkN>N`OSdFijB30#ZO(3)%HBU5|O*!&-%0BI64obEfFi$Bko>5$Jl91+e;K>c*hygPaBFp2ocKAxY4s6K$eX8 z)eA(oLV$?e$`aK$7K$QBJ`kOw8(_4>vaXbEUkWaFJG}VpBMiO94}S1h_*;MTXK{OT z!*Y7^bV*JkO_AS6_A6;^Ie9QbDMNcZMU;xRCaBX07PQ1fW2=ki{~u?Gmjx zh$HhS7=;C0cXkNx1+-?I`_!Q&3ua_=Q13KWLT6*N@II*58JE92uWb( zcw_P|$c6^0-Du6AsVYM<(B_u^V6@=Q-+pc(;AI_5_eJ3Qs|kn2HiB_VmIOS%g+$~4Cez9D0S0x z1GMho{R*oEv=9)=5aW!CwZ6Al=L2GNKuR!DVYAy}UKVI*9A3QwfI1Cl_?VkB_k&Z z+i4sQFK~Oa$Gz(-JbZYKQg}B@)K1EA$%%HCE!C8aoSV&-ydq3rkm8i>)2P@S^{urc ztUe2oYR5r|glM5v6>*iJI1gku0?1hAg%;DHkhqbQ{>K>H|NOpA6HqS05N6e zFiF;|7p|_-DMW^={3@{xF`UKAlFTl$p@zos0^SE^$>@e&sQZ=*Ow^LAXFilpIR>JI z;|R${LH@7HIYXhq|#V@ zW)q!6KlE5!fHoFVS??#TCLl!jCSNFIJUV%l9I7BEN4 zU^(m&V?u8_#NbhsKoT{kSGMy}YJ*fVo_+c$24nEO?|%<}`)~goj3%X9jl!*}PLx8^ z1d#k3{5-`-g|Xzb(R=DXyRKstPUiCuP}0zjO`fJX#u!*;pjG}^P6^$(h5C(eeEq%4 zZHE--;LvQcsnu$I@EY@#Sfx~Wr+ zoEM#%&!ryP1qCT`em4Wino*9cx@DQs*-9yv+&LsPL>>>O>o`twzClHN*QIn_aMcRJ z7!9Q*1wIMTC`vF%wPX%~iD&9J!J(6ce5VnLLJA(DWJt{|GGA`%y0*{92j3KEwR%xf zKuK^MFL9r83-%IFlG4@AJ0K|pL@9tEz$6OK(@R1rk;WcrH;;W!`LM8G)*SyGeGE2W@}g-`-TDEzm-{kup~ zB1?6bMh)A{i{!chuR$+l3IsbqiESge zJxwL!Fdq?fLMj;{(c)Gyc+n^t1Ed_trAor)CI^iv!Fh+A6Na9+JOz~eH7^IG9N_(m zToTrG=F2N_9tT4L*OgpXIT6}INF;z1Jv_eZ`jKhE5y#~S7d>3^(6(Y%0Ow-EM8}xm zorl(fDDslbLp8e~Ba( zDu84uW$+QfCkRNS6cBTS3l2h2x7K$9x~^|t0i^|$0)k&ju&{Vo-JuALWvXDtYaSx~Zq5Ed!YJuQSnbP*zxHZz5U-b#G*!ADq@g8ji`x7pz3i|2qYc)i}B zq=<1G@RhIqERv5<&f~4^2D`q;%h%8G^s`TJoNus%71!4fa0m$xzxXB0J|Ve`Wu5`8 z(Dfs}_02y-T0Ouxex)_-5BOc^hn@(?G9zX{3Jau!Tq0t2NFp%8J|jej)y?$mlmhPv zlxIwLhQo;9K|Wjx($x-_SJnI3Dgu9AgLAZbT{xLL?mbuaV{hR8>A|3p;Hf zbq8xl1Q)R!_HgT-iV?GcfN(ub=x4(;i+)-!8S4ao}IYt60@{~XcffPNQ zTj?FmjKmQc{h*-$h)lopEHXP(8J3CCvWKejPL=}2neVGxp{+qtfci)O@YmnF+zoVt z3(=&iG!iM)igBmVGhV&^xvIF*+Ps0vc%#Z>$)`37B7JlX znNJWX9LPddp z1Cv|{!Uq_oNrKwAI;9yE>-egE-*u*p_RJ8&2RXs7j^l4B$x#VKmj-fEleOQ2toiC> zkjrkyH~+;S;CTBQ!!Tey9x(JH76FWp@4-wH+}#29FRpO=;yGU3yhO+?g9nH{p!jei2cSR=8@&DQTP>oOP!aj0FysP?X3LrL z&k0eJIffwduBmngG^(PIFxr6>!mo1(l57lK4sv5jy)l|qS{#g}$bVEJjn!LJ#VGYs zCs<$40Zy(3w+R4!H;_n#pN$X#Im3B6&~+Wf=N9r)NGK?6;6p@A39FCz&JRAs```Tm zgfe*ixvwG$i<}6e2%#V{;XyQm{AA7utB2JVIYfG%azaUwp1n+XkC-#jdWR9y#TAt4 zDGNwSBmoSY3D;LwSeGM2Aud=En&ODZ5zAqZVVsZzVc3n)xVfP*=5%q5>qk#;dH*q# zKF81;F-;?EH}Ir_T-sVltjmmP8qp0shRp^E8d4b~9vds65Hnzh2{93a%FPR%fx!bg zq0^cSf7+5Cr9{m8XZYlkkMN)To&NxSUCyRDjNRe$%~~2a6#*z^nu1kp+w$!x*NOx+ z)ydu_L`sr(g$o0v6sc+J+0Hu`C<|TN{u*cMeSozcjIj+U7+6kvj{m5%+H<;WeWA|( zcg>5%y0p$YGkb?dV1>sJ^?O*>6~i<_p^i{Amg5Yg=;1NOAf$}#cGr9%q%QHWLr+~m zKS0O~DGjm@>;|Pa)f&>vSJR-@npI7OLvDb>amKXSV)B6(>I(BZ)8)fqPCZ|U)>w=i z8Z`;l&(AY)#mKoya>Hd=(b=xeq=mA?y;2HNDa?mEl;kiU?|@*rH_Z@=Vm#>(wmdEj zP^^%!);3@yjj|M*7!w}cpYZ(o3usLyx9Yzz=0s7={JG}>;&TPhnJki3W<6qbXNFOM zaiAnUah4swI!C&5PCla~juVfm(wwW5G^NyLw8mPJPxCkoIW@yqVpqJ@mSnuqxBTd) zz$={Wrgg){uZu%o4w#0Xs{Tw|q3QkwW(7R@{5yE@?eF1}zj`14@!$Qs`1$|z@4za7 zZ~oCA;@|)Ie-EF1cEJDfzy9C&?CEFt+FGbmc+{34&)}5x(086P?5(l1Tho>U#UvyoM-FgOBws{zxoN9dqmXK= zs_K@7{3%7yK@fZ-u9uM^q*39iiX)v-3IdPLmZfsm0&*%Chlv?>fcK7! zwo)QF4?FD;OF(cWKVJ_HyL$#}EKP@EPjP*7yS zupP0^3sf$UhQ>U$H|WPL=5>ZN2E*n8QkoVOEwn->w=fd8d-)uza~Q3{>KwM4D~Qe_ zq=={_LdsA^VBBsY*#s#xjk{b1M2vX(>{ASVkIiO-vA2kBg*6?9&cdw+SlvMhfuSE^ zyB^E_7M;?FfoT?|gUT5>EwGkI6k~63`}!s3y91`}gmIdH`2SP&X3x5&=Uv|QOz-eb zYusy3J-1qtCAB2mvSf|eR$^>$Vkn0Y3=~Br#es|BAS9Jg6)IG!a*#hDsSq-hAsBE4 zFm^($!Uke&Y+>1wTW(3#&{7Y(ckjK|UehJ_r34)-1qNy zU7o&eS|@F7S9bCkY_c~IiTMOE>=*Wcv9qkFVn zpGRtveA#Tali6^!TCv%z*)>~=qM$5XPKPO)uA7#UeU?c{^j4N}WhowLAELx)pTM>F zC~?SU*W_rd{A`=eCdXez%2gDOrfo3VF!+JsJ?r&)qVRTJzU=2`kLBlGEVzC9Hg#3- zf$w{X4}IXh+`V&`-R6qjc0KI`#?yI>DL;VYyG)BuHu$?vZiDOf70cyf;)RX-Ah}J5 ztj0fq(~qgU)@<5*GayhEj;3j3yqE%g=Lsp12oxFPkq}C(F)Bxr)wK&HN1w+N&G_OI z91Cb9zaB%N8M=v~MTT%z)tn&+cBPutpd>9c{`Zc>u$1GkL*Mh}n{UflFe(AsIm5Q^ zxiPPq9n5%e{~j(hv|Y=;{I!3{zxkanF*I99@H@ZpRW`eZk9_oH-gxa>9PjP%VE2H+ z!Sm0Z@Tg3@J@k~bCEf0dW^+X{J(uSfRI7c05{;>`iWmk=whk&I(rl%bmR(ngjrp52ptX6w+TPkWU zFD`lf&V7z=KaHK$U^EfUY`%xfDF4EgESCE;Ln0}eEUZ?0L|3qFC23j~D~1pVN;5QD zu1?>j7*X!xNg7zs&)Eb-cI))duZV#xJfN59=jzs?vgPGy|B zs3i%zyE?}Y{dGlwl9V)cJttu)>sp|zrA$}qT1H>K?{nxx6M9*iE2D9_*s7G7NERU` zg1QFQPQ;DeU+RY;%k#M2Ek98-9n}p2y-kX~lOoJ`>yVSPoyIgMS)v(ZrbTsK*K}<+ zLHkIu$U`Bt+$0U)!n#b84Vk~=@G(ujgWzQ}GO}3abCJaO6sYSt+E{+|3%}0K|NVc! z(Dxkd?J=9rD4gZw_z0yGmlqdwZNq#yXSG^R0xRL}^n{@BL6*g*j~;=oc=~-G z;?tk`497R`@cQfDWl=nDhHz{5`A^rdbF< zZa9TQTf_Oq1-Di!QVawooeiZeCNIcSOcHw?MPJ&grO%zrXr};ItNSSwonvy0zZzylAlU4rpshts*E( zpOpfIcI0HZY4Xpyd`yvLAvp3MNR>iR>PnL@kWZkoZnLVnP~$t+Y( z4;{{C+O3wI#VCYNDak|58ivl(c3rmf#Yv%<@(jJO1>b)CZT`nX7`ToA03ZNKL_t*l z`G2D{p1=Ncf03td9b!^qR#%*!K13%^RhE=azCg{;Gvu2=Syr-G4gv2I2ZzT*?>RWR z#d@=o2eLH+T`NjL7zn{r7IUKCvAH^>(DF04eV0c_vb(W1k4**v05-v6bRqG)5Yfia zYbIJ=^Arn+Fw2w>T~Pll_yL7k}o*_)q`bpXA>C2i$vb%FqAZpXVR^ zqkqbO`Pcs%&)hv`eerl2A&om0>kOIlLQGDF06}juh{^p88`ZX!icRhu% zL@j%duIs0ze`!p*y14A6-i;pBal(M58f*8oYlQ2bz z$4GceK$-ty0*!lpT_>6xQMn zPDoZ$xH-PrQ7vculmGV%TwZK>>BSegak%2*;xQlk=!&2IQ$NMcTX$*DJpa^Fy!zFz zu{b=$D@D1d*gSsByb}6r;S3M%-{;wvUZiP9{EmE&hF&Pm#)`F1%Cj8`8Fva+#1R8| z6qqnZO$Z;Q;qvO7gM+Q>;=n~2c` zoAs8(>X5<9ZbqWyN*-!KXW*sBXY@!wQ&bg7YpQZa*Yp@|sb+J0*HIM~-|wjE1$Ul( z779WB#75FrAUmn9>$rJz%+=)ur}tjtU~fsM63Ms3F43t-QMzntwCt|VvB=I(7XmZf z-`iuUOWGld+O_Xlt^~Mwe)WcE@bB#5zmo17%g=${8_S z$%{6XRI>$rvzCLLm-heu!2yrnki*{j`8m%&cMF<9q*}8=kUt?1f@c`hQ>FlI@;^@$ zu9RJ9lrmt~b`t$9X81nlXJ$Le{rY~G?6BjATts7;G7$pSWKq+2ogK-t^KL>vH*J#_t!AROYHcRzlPp4IzFSrK+Eo|4_T78@SO4|j z%txp+RN1+wH_cZN5-%I?nYuhPuyK7|EU}B`O34e7TV=8{} z7r%(EmMoV`x?X^hPQzxi!?}XpZp+Ir-Gpwjnx^ZpTCvNHVxmLX)rnHEn&Wfdj`+HJ=GG}|Wc0Nx>l$YM6KG{BQZWzw(#)+^0Xm+36|6kZHX}Jnf^3S0cuVrtKy*tb`!Qj))R#i>Y@a3tGj{ zHMpw8q$C;ktS5GbMd?J>?D* zTwxh{!RC)TQz;+HGB^T0h7n>ey40Kw9B24L{+XndX=PF9TT~3$lvpue&e>gFvbuGf z<&7hnwxiW+ih0e0$B+2QKk*a9V5v(>*Eig0@6vZY>$C6hE5GzhgkfMdubJeUjumOuFIFVYWQ5>EX<+qKho$rwZ1Ho`SCMjnE}=kHaT$jc+dE@cnRkYzn1M@}r4 zfyH7*({3nSN!Rw2rJJN|5|>VSry}!qpL;=NQHq~Fi4ib_K0CODC~ck2V+`qAwb>~n zI2J>U1AW&q3?B0MbNu~`eUlI~-d#Hh)MS!#*R?ojI6uE&wRcCV0!6{I&%FrqBRVgX z+}@9-&v7RQWieyhU1V~!xPL+zgvXW=rFC39yyE2KDQ3$hmuC-Y&raDtJfU122`erR zJoxT6a2igYd791n4&SsydyJ1gMH*n%!4D`cyIGSbC*OVT zHI8mSLyU>@hi`Lma!2C8Wx=i6cQ7O_A3wymJ4z?X)AiYXvHKZ=iX}?P=zr66I9syY zZCRv9z)~$%azHF3giw|>v$7OC(IA0=m}DieTFw~yMs^k>P{`!m(lgWaArk zZ67SEf?d;Oj+M$t4&fe+H<@vU`lJLZ#>mh~t~%x=_4#_sfAKf|7Jv0G{l|Rd`9uEM zFaI)k?mW-!+sEu5K1*z`Xs^yFYDX@aOM`n+;|GDuMHPuE&-(_S(<6$1|C8UB$6F=c z&#IF*TZv89y9D9u?(L#n8!uTg&Jq#h9f!BG}SO#$(nQio&7dyLG`R zjf%p1Z_brlC!G>wiP@>5fM|mJdxd4_+T8WAQVeVwSrF$K_XzS!xoxX~qAv67 z)zig*ww4$Ig|+m9VC1z?k`9SN3+)vYT4O?@b_H+0^A_FajL`JltZHWa3qJG7ALN@~ z`wH{<5s))j==Nz2e;PE@} z$mlO8|2i^0#vloz5HbLdCww$X;Lst1ZN8xhh zw%y5uq$t>KL_yp1Jx$x=ii#Ug?Q?dqrU(J8d#*0esH&R6YSx!m>^58O+3kmS%+$hgMu)->COq$ObD<}*%CZgc+df&3j?!+c(ofOcI`)+Ja&+xLR_FdCl> zMKzlEt8E5c*L_TZ{HeA3~A~=jMwFH zi8}6B#>KDqa)OfV(5pL0QPGHD)LI`f5ED{|Xx8HP3Mt24HnjU-{T zW=Ju2Sk$#+Bu2WvB_>VZ4!rz<_wxKRH|d)#v6p|x4?P6A3Zs)CwWFWXX5tl5q>!*$ z(YGxzDw@V~|KSDu%NngA_=Xqm-sG95p62iTyKlWqvyA9SzJX4BwlF|};9n-RX zyxEcDrI3;$`Y3ip6|Pxd`5_DH5UO$ro_=^D>g%EXAI62(sYe72Gse1=W+~jq{ga@%^5#aLpMn3DIaPqoN&M?oIQBJ3m<$B<^Fl1^ zoTmmj&R0^%eqsQ-7Q1s z(Mr^{B$b!y*F3d%`*b4KII7ux;jSDQaeicc5J0LvB*zSJs>02D{FVK0{ON|VGx{9J*)EJMO{3B-;oJw9WbD+24&baEwjapvZ}M-#uGCZ zT{#VSv{CFIpP=mmtq1xZ1{u0bzW{|Y1xgEsAo#H?C$Y=XCpH ztEM(^OrE~$Nm2f+bzNsFYRW-_Emc`bWUvror`4LngFVQn@vi9!L(9qW5$j#YcV2&o zANkD3xxBifbS2KJ$uS}zLp`Czi?YZIT04OjjS(H^h)W1@K#dkgEr}PSuj6|pS)^I5 zCM*fLycn}ypm0U*XM`urjO1mh62VUXm;3x+jf8RdCQ)jwCzGBw64Ms9gzU!Jwv&;H zH5n}XTMz7KztZ6CRO`yDi%zHl_b^XTj$Z+zn$ zm|nivBZKRi7vIOpjT`LR4&TeT#X5&dWTmgBu$JKEq+L`qAj?I%_K=38M{tKcFylUh&(%^JScI96$9u_1;O=8A#I4 zm5ySyl%3nKRg}v^LI_-(J`k1d@`$3Wuttggx`Sf1 zN4eZ%zC2*pG#nluFcKriAE;LcU=5l;VI>}|jHStt#-R^X^A#A4?{u+rBx>IG zUFM_7c#@b<={lZ2kqWZADCWMHQixr$aCChq5q_gc#-T&J7Bx-8Po)GI-F3o{$|uJ0 z*3kDoL@CvoJdak{Lore~pELlbl%{{C^BZg|mT9A@YiWJgvpK);5C1Vg`)B_n+RX)u zBo9as6|5iVwrjfGj;`rwuD0whuc)kLbAHKYeZ??zxWZn8>Ab?~0#)quOJDds{-^)r zmoU{Kwl2}eGOuP5UBygjk#XKX=IyV2+1D>ZrDVL)_nq8Qj3^+lt}bcY9X<>wr@%om zv-GLQs{vEMY*}JUMLl<56Q>!#)6>=7UR;QRR}|MZ{o;L&5O zPIPTc(=<=`T4bjYyqMKuE)@xG#!8e~C1Ub2n@5wPeu45jA~1Uri_r#tH4$C~(FI`aYkQ$C-H7 z4fLJpHAf!Vm|3^har>#KnUw``XxTLzIX$ggzWvp&QSC2r%Na%Aabs_v|K!j91YiC7 z?*Ror`T3vVkN@OP^XVV?aV{=){NZo?Hiw4?MC-Wu>@)1$IpL+}U*PubyEMCvbZ?4+ zZt(0K>AvyF{4}5rExx2YEUC|i(>Wr&HQl3Q&I*Oh>fVB7UH%?{sg(P_Y6 zJ?84sJ$Bnms=Xz}-acJaERJuoI=q1{YRdgXZan)U%j28$Xre1w?H|)!o>TS>X|tto zT86eoC8%c$LR2}*D`UpRYDP6HsOmy!HX-7DWY@Q}zN4I#td0)wZAZ7hpxa&Ml4kyW zdOvk%w8}j=EgZ6Dw|l~)B+-0hEk!9d)A@Wp6|!~SH;!q`u~e$^{%)*YOcd{Q9m#jr zQdpN6dqtValmVs0LN_w5#8(k>)Ky@JDf4xr_ta%6v`!^g;_=}zn%PFcpK~rR=9o%l z!U+m_kz?3y*6bc%^2#f(@Y6r>$M8mTBoGJ-*qZi-HP- zOxpJnFD{pqrSR~}Qd8CoKL7b+(2g!Nl*=XC<{mLg19XTHZ5{L3oU6+XRavranv4j~ z9_@sb`f_4y*Y_A}n9uj=a99P_WG5(J^+(sI*QdYQG<4tkoD8-Rerl72)qhQJC_;DYSQ6rHs0Wz(%_$-f#$gQOxT8zmW z)Inu(K%D5gV>>;>K{#Vt;gq2t2D<#29OI^87^a1w$x7o8J#}5vHw`H%?%ch@gKvKw z3si+=*lr*Oo_o&=9Nc}1Z~oD@xOH^Ium7`u&hqe2^VvW4aenb1{2H(P{+H?F!2bRo zA9?v@e)7jZ&)@xp{}WqS-u~|E%vO7x`;PUaGZqU`zV=;DNO0%wE&7l_cv^lojTV%Q zml^eFM=H=OL&%Os%7Vj+8!7(0-D@wuaJ4?4~P{#7qOYi4vS6}4p{EX%P2}MTfF;!P*5Nz3};k{IE?`|tk;l#!G3X1(L!;E1kmsOo~QYo&9cZHD^E2xh)ml0sm+ z-sV1pxHLlODV<@tSaN)FhlAro`nDm?!i;=lRN|2)nLtgf!-TwFe;E=mzvS(np3 zIzJTsG{*8_$PZoUde6W5-BI#|;x?&IdU;=-By>VW~=& zZv*KG92>cb+-A_47zu|MAwcpmGq@xLGs>+-N^gwfdl+wMa+A@bzs)WTA3Ulb>A@bW zEiM;OCsEfBsmqF?_jnSntU(V9LqDPTwTf6f-j;P zhJ(WszW;|l#uvZ%Z&AAj?>oAtVRg7q-?bQHSneO;N=LJk2WMH=>^2)opCP@5A$0i$ zA0;}U3xZ;C^ix_fK2g5aMsP3l`2vMv=z5Y)0{82CLQKqx0w3h2A=KXSb`f(-TG4ks zXejdEtR;fVtA z#rl$iqa&27Xfv44)eaTK^&OO9vm0=wqnfXH^x!^PIf}&|_5Lvdu(LU)a`au`a<{?e zuE_bL2P|iE2tBdcaPdxuaWyflXgo9_G2h$Aw_Du7At$$Q(P&R$H2roOm$+w@8s7~B1n!tq97p zYdwW=xXPh@WO;B*=(f_Go6Q+I&tM#`uF>_3)b8*x5PcSXY4NakT_<4!EBnrrB2Df| zm357Mmq1Uxo06Q9DM0iwA7bKUqa62jDJS|S2UNaQ1}_1Z!6&Q_p80Hs-&TV3wvzbp zgBMB3s6o|vhFccc>;V~<--5r8#8%t2GMdPGRBhz?I(i_=Qm%4kT@!~O3e{djL~p(I z7C-d;FY|MM`A-o-!}@AX$U>Z5n^-O!o2$p{?d?lkHzl;n38E;cr&&FhmUPMwDWkAP zn%ha|)+?IccWk$je*F-YvP`DkQWiF=JUvBGO=G;#p%6z5OXaXpPM=A6KKrR3;sd7_ z+`4s`7k~*L1E!F+{&us@t$VqNxx%rSFE~4YjI)}mnlTK*t9f*Kiq@9-Y>st~rrl7N zj{Bz<{N|UwLFYRmx2o(>$PI7jEFnc{gzJ>i*s^q0s+a^>RaMcp?Grxt>xY88>}0m; zW4K%aK1LIbCEp~vX;B=LqT97Xc#es>tfWPr@@ZHljI;80U6vU)BgV9T z2$-ZOv_@+vN=MV~M0AuHS|J365Sh(qLNXXcNl@65`J&>v@B0wn{lhQiTeG2!J@e%P zV=JEe@XHKc&+q@%mpDE+;+uc)3cvs5Mxw*pp2Phc)Qc4#`NR+N_Pw|Hm4EST{OFJT zFyH**7dd_B0sE^xp&N(9kOH6jfsb-|xklTHIC%OHa8}YVHsk-xvY5nc3GrM=oLb{D zCPXogClpRwBC@0C+K#%erq+Gi30ZbAo6&4H;+`>vzHg=nf%j70(-}KaSF=3kl1YP7 zA$uU?^CP5RKmA-?+huN>@UA4GC;gt75?w#g^~2<}5rJGmGcNy98$6_}015MMi+3qMx=`~$loKZVV z+xy8$H>{k2OJ$9adB}< z(51i!3nwERCBm!4Vou*&fp2-^wNv)@4_F=SQ)q=QDrWV9U4Cv@R}if18|M235zXPv zr)hQ#wk*Yv=n~buW_x)~N{RDb%faz6<$R7Znjv`ESB@+^CA-gUu1btup%5Z5q|Cp| z%J?X}z`pOY(m_&ez8mC#90yXq?T*B9t1U*$b3Ft zKI~mDO!;B(QUt^k0g`~q#NF)iL!O5kiz_k)7$LBZ!u$DRKF!-hj=PNrZf!N~PAr9S z*iobnZ@u}t5JqieRv0$B#B#BRY9A9fk3l7|v5f#LEeVLt#koZEda*NKH4WA|+3{rK zS=%*SU0k7-x2cMn5H#~$xxjz6E*gftLSkF%PxgiW*N_R#_z z1x+wCJ2|-xCDs@gR!W*hk_1iICAx4yt;8SEvp?2$^c zs3>L7R?!XOqewnXhYaa!sQkZaN5(sC(@qO|tu;|AdM~3WNcnt8p*@Jfk~UtC;pd~_(cyW9Zx(Nh$;*fj)D(iK6GqqQwMbHl7PbkWmw zEp=5->Vpt6aXMu+LCn7cMu=&FjWGCt)8f+@lUfr&B6mLo$QiLpqjG1(399?r`J=AA zIU*`^(@9nEGXocw7p(SI48uUX+fliKlm-cU_#R~ocIzwZlS3Jox`H7LQb^T`sD+Ov zkWok13FZ3ctvj4OK4Z0iz~#jSCpT}BVqkZq_1RViP21&bWDhCG z=wj%oa@Qt?G%d)-2J(2r7-yQ}9*R7&P)1W$CA}X|T4vN?@c2PkUNVcn_O>Xk8KUs8 zM#64c)@WtKSsl`J>l|an<6?F;n=zZsgyG{HMN#Eto-F2dZl)JSFnM59yPPI^KEnO-$dj(u&=q2Q2Ff6+DG?talBc`{Vx}S51Q}#dO!_2ky9t zTrHP!yT}jQ5o|dM+GH;@$cxYz0*Rt3(HRBOcAXsZ%ypK3ye*0>N^Tp1oRDxrijm!J zCo;3Lnm$t^6C1l-X@V#!r5J*z=~_9Y3QSp2RWlCv_h9I_d~lETJ8$4Zi?eWfdCu9x zQkk zp-m=x!h|s&-!>^FQ6lCq#Q0%PN%{>Yd#@#pA}1D+jyQ}`V&=;z^=a&tA3Edc!8s=^ zCF@vUtsx5A-f6|W+T+!)J>Y9!f1S3KSX&qdLP+^%@e}h#=BwA~0Z4>?$j*Vl;r^1n zl_)Sr*k4`GC;3sEU!Kz!Or@My8LsKIG0LbY^bCEIeduDb?z)D`S#-)e)|jvLWhqH5 zL_D*;TGKWyecQ=QJF46;ZiRC+F1~X#my8-wIT0~mECp5v7;QmWHrpnb)5u9IJw1rW zR9_3K!Tx{wsDogd5igcl3SVVM<28Pvgi)ki6SSQwrOdaUEaw^Vy~(Sio(hj zT0`HrPY`|el-^OO9N`t3!FZt2+D=8X>rq>p$j@U^P9DV9`zhJY^bCE^`Nb6<`pAcw zADyt-?O0!~*<0?364&=Ub#g+t-ty85&vSWs&g%G>O*?RKe2cqxpWz#?`~lCr_XYaC z=dG`PoyyCfW$+&BN}@Kr|Kp!zb+AV=23M34Uv(yvk=?ZHmo8N|X`lhxw$1QMdD$9m z*lo7c?J?%zdrUdF5`5;L$>*?t!qFoy6uEtMIYDT%KA>ruX(u`EF50%G@B1k+HQr3^ z6FVsxbBPqrWKLPvwbN-=DJ66Hm@!03Lb)*}v~@!Dwvr&*Y&OEm8~dBdgFU>4VqTMEWI(suaQ5In%ULZ7gD%i|jji@MK6#q92?VW~t>)CV zBXpMx+e?=9j1<7ynh$^fC-~4Oew33JKfqHjzK7EX_t>4jjUzCxOI~{EJtzeSCpUQZ z#TTd-bMC$KHYj-K{@cW%7fg=PJi31$n*#Gf5&9j@h{}7ij>Z7NQ&~aq=sXEBe%=!{;(r9I;QDAJTAhFktJ!&e5<6$ zCoRbhkI`S}q!d$@r4TlYlD_M?xVRw1kgxh$#PZJK2YGv{ zbzqCBtKn=ZyBi}pFKXyr{??ubOqkWNtP9(xIc=r zpsGp|k)p7&4A$a5?tM>Gnl`uHhoQ$POUTgu3)m(o&E zI248m(%y7h@_x44eaW`BcHidj*j zV?u?5Q-(D7=|iltx>6j~eo`2xm_+fa6iypTBV$jclkm^dHFkU|#^r^pe&S{-o|H5# z`n3{zYmBhl?WUVj2y$9gI$<(GM&;X%wPUM0QdVUi=gCdVV)4Kye)KaaTjH!>>|Ir` zsOLO<^9?rl-=Kf+4#T!V8$&%`aCLT0cX^3Fzu@b?`CF8`Er*LaUDvbQwIcn}hWX8> z_}C|Zh-j@8`9c)T{*YgWF-&`>F`X!XCixwW;fC?St&{kKJAJ*^e*k zQI#+jEMp!m7Oun(VnHOO9O+Hd@24L+io#8r>hS@htz`&-YF-nN9bPvKY`0r3E-q=i zj%IYY=Yy0ra)NEzR&G&UM;`({Mq!wRfugLb%2@_~T6Vie7;Z|Seo7gFkMynVJ7NqN ztI$fiMB{B}*Eg(g9*~?NWRctE@{+#Yad~yc$(^UUapxJ1Zr{d~HT8UkB49{dJbJ*R zH^0YszWNmwg(7IrkOCNkD=UiGjA$Lj>HyqKO0-==JzwyR?|heS-?KWt$==ZkXQz*G zLCSx3Zr|bL<}H@Xr5s3$B@gbs#iRS*C5D#Std<)`cDr<&4Z7>tU7WF~N~}>BqtG#d zAGmmQ3f@y%Y5uP-F1UPr#%{ftl6peZ!5Yf)k2T$)sycFfgOxF&=jLNNNDM%{1-m|`H>4K*l27G|BFxmEJ z^Ir^kY$E`{J`4761+z;)YXhMB%Cd9uAnYUqKaH?w>aZyw;fhl!VuW3 zud-7wPNRf=7)UyzOu#s~v4xmO9*7!O&uBxS_kn|h1NmiUy`{B6*OH3MpMt{D655OWrZ#30qV&I zR4FpLMak#FC#q8N_kwj1j0w4^?5sw`h*lP79oESW29-#8=IgA(Bm+Lt?$$J$OG4-v ze8+mX!KXw&1fn(=SAtF?4aStrmIr+Pr~dNdp7GnN+W=t*5m+qdAPW08^~Espr{pZX7JhlK9~!j+X`qyqY0bf}~tvB%t- z>AGI1x~9a(#1MTRTNuIEWDb@lk^F#727){+dmlj?%BlhaRz)lcow-O&-_j2|hM}db zYMQ3agxH3`cd{qb4y_!{Rg-R6BNDU1TCCP=ueMT#D{Bf@Ft6tVEiEg2RDwAVfv#`z z_dzqOYxyoKiG=qT+!h0}f#TS0{S6H9DBZvj)Z_ba@!+j*GqmTN z-uo_V>RkVF0VWzT{;7AhltL(y&Nrur-NL*MV_mLrZl2(#*MWMTT1mSZl$5D$_zRboX3c6A72y+Cbm(W;>^5@b2g&A zC3$1>xNOM8);PK6Q}#KGC>v`X3N46($(-o%eM@Ny2v8R#%UM0mYvU6qmk=^^Ghdez z30oFeXDDm=bIlheF?vGEmOd|v-hPk=j3=Q>OTDbwHCsa8;fyr7Q-;DNk~C&hn9_hJ zw{G$iKl3wmreeG6scbpUa3#;K4Jvq+WkF>$nn>seX@+YtSjGrlgymB&@Mr$qpP}Wc zqX19at?9dlwr!|q_2haXMe4eyD4Zk*jYz3dO4ChcgfxceCphNdJ-bHsC0(W<%d}vm zILJ83YFNzc95_k%t|JVB2a=J9d^Y;NV>X*pWEYQ&N20jZljwkbKM;o?qbm$WAtgkU z{S`3`^li(mblhCcnI%tuagOx^bUXa|irx8Rbb_bvKEv_RF^?WTVm6!eo|oQ7-$}tX zWFj@WaJp+6KKAiXu({fC_4u6kzW81~@Zpa$JHCr6OJpS11aE7N}cmZ8j%vMXZhKolJVd&`dXwW&w{&G&U6@|m-o_AIbSL3dF97m4P zTr$e$Ne|O73=Dk-NS|8hoJ4rpqy4VYDXXI)=}XJ(~kD;|)26h#d*1 zBj8Y85ORtblOE#_WJbkdekhG*v*mL6F4&@YL1ebKS#LAIt(S zTN)#wkcu)Agi+A>gp1$9YYuKSrisRV*luXpZW8D zj$i-6uk+ybZ&O?GQ=6i|qEXZ6Eim+=DNx3--fmGwbG7UE+>ifpKJcOMW7F;=QcKJa zDEShv*K69Y=U{)yysqf`o~G|8ijumlbEm*?d^F=?bCIQ6*^@J#D02Zc^g~X)v_d-W zdrYBmHqp19fMUDb3RXrbw2_^YQZNKhRn6ILw?bJqGCC@4L5iN}xpq4kjWd$O6Xbt> zxb(uJN{B$nIg3h0q;FncKH4Puc8Ae5P1~_pEtt(_>~`DhF}OB#ZIfSWN|5H6GP`M7 zTs4yiT+AI3tGRl7L9^5r(YdAbQ#yZ2<=A7UYhX)6|bMHPU zCpWpgShHJSvYahw+Z~5D?{IQ-%rNwP`47I#dVK+5;EnHDj-Gi5Q1 zV()+lZ$D&xeuf?z(HC4@u{<~;D#Lo)5StzC)rO+kad7K4RaJ4he$4jboW;>GD#8BV zJ{K347#*_qI!)tD?>+O`dNWg#!^7zz3L z?TjJ?O~&VD*s16Uq^BhNM(dmy%bwJeDiz)ZK#cKPJ3HM(@*He7n@ul*!Aru*T0_?k zV2nh_VnFMJN`bPplLun6-BMO1otM7GVm{;6ty|nUKIXF@FJ;G4RMg9TzVe6P;`=}J zKDJlqtmXxe?|%oZB_SANog&p{+btl-8J{M0wBI<5K{Zi<-7?IlH)`&NF{m?k1eEVh1srq3v+i zu(w?D+N+P)ZLX-Qnqly?+m`u!kI*%c2Kphgn$NiZ_B)(CKIQb{oT4mgwp*6-1w~o2 z*<9j^LcoTVcw77sDaxAl)fF#1|1{3Z?&nYZsh{HP?2JEpTv4%qc);B|x7puY(rmWaqNJ?rCy1zlq?r8m|36jl`D|Hs-se4Q zt-W?Q>Bhbtr^DnKV330(NVZ8)lu1fOOQJ1T*;S&#C+iRL7uzh`Qk9~u5*5p$Ktd!4 zL5PDvnB3F3`^J-YT)`jSbxsdhg({K&x_kPbv-eu>`};jlW|a7=i-O!%_!tNT`ffmF zS%NZddQpcwRO{SLb?MKVVIc zHU^t#G~HUp+u8G*F0@3zJRkzvVMb%7lQFBaIaC?BmV;$hRor>)EuNgr zne2=ioa5%^$1P}E*%_DI|-`Xymg1()hwSm=F4L|j`pEv z_v$r-jllUXT|h)o8@|@grG;8|m8gi2|^t0XVsN7loKUO=eIgsgg}bW+6nW zUgTDR+7O~;%p80o5V)jCNNHM?SZTSH!{^{!0-b71RAe@n6S-FEmy#OBWGt7T`zrA&)OSm96N8R9XzOlw7ZbP2})og}B14$?&~Pjs+3JbWMvJ6g#sW zs?swlE0)WJq_*T*wjF5+Sy*z<;uMF~YDtxkX@@>x(R13y5n_YMa#pJ)xsg+s%_Xj9 zv`Vh^F@x(->Cf$>2%Lr?V2rShE*&1wv??H~QWJXeO z%4Fy)C%9g22FVi=0he|yf??1oZDg!740K%}uL?P#mnCiA61@?2S%}~qxi#oa%3|O8 z=6_&4KgSd4da+k-T+ZHkGMmsQ&XMOb;_KRu*<`{e@BILxA*-fbzkQomUww(UzVrn? z_oXlL?DU-ZV!_4vIqiByW&mS(>xCEC+uaopjxn6iFYtN;kxXf&)c%*Ybp);1+no~n z7H5=rK!!j@APg-bM$VpGpmomOk0Re4BGqKZumAdg!%mj7T1u?EtSTn6DQ+03w8iMi zdbuDo8O^%G=1SV=gG;Obhn;nXztKtnu5zS#Un#NOE>|W-M2$bYL>i zIZ93tBr!@6H$(+d!Zg-eLKKB_naYWzLTi?f>DoS(7bAr&BteLbYumc!;{2SEE?F*X zdLOua^A=h`OjOuB%TQkPv$x;o^1&eom#;A1JLLH3Q)ZJ1wvgM>-rf$UPiy+#q4S(L z^wdL7BvP;Dtm-8@hgZ0K>ozL0qGkvXTwwp;GJQ92?dU2WKYWBzn%%u4o|TVStr{*K zK4IC`eD?F72c?Knpo&FqFnEqHPRJ)STt6@#ji~D-yGQ$+oIat*3u0)vcI`4B-TjF1 zc*46MyvNm>w;5MCi}?lhV!?cVA-y^8xO{X#mx46A0p$)WD7#H)+TFMOrsBB85ykw$dG9Qfvy>z8w+iY zQku4{ z%vW=X+V(A4XS97sH^`#d$4KuyqoN?s3U+pP2;S4Xfv5sQKcF+i-gt&{;%BhA#pWgF z^Cc!P$W_Ju;UW2CMbwe3m9boIbLR5}x31qnPcAVU-DBuGsxqf(d~$<0dM7UtYwcEL zYK)<(q;;NU8JW=p?@~iwu=6gtQTky>lvgqSbzLhck}UfrMw<6-OYC*SKv`s4q`1~9 zfhkF_HBVWHFTNO+v~3SwtawFHFbsn%*Hdal>5O$d$Y&`EZF%S&+6Powadv(|nOmY0 zyWV0krz}e$jOu`oL1ardr|-O+Oru=khXKl>pq|h9;dlO&H(q<0W#3U%7319r)BPDQ zz4j7WLZW0@#@YE9UTYff$&F>Uvx9SedeBC)Om+;;dC>%zLT&}oqpc+}(EDC0pGu+C zMl2=c-cc@%;kI7zFaO2w^E>~;-{A0IA0Hyi^@`5-9PA(Rqwl@V>651z4MP}s{q>iH zD`us)*^-9opFBU^nDN#XtSO{xSd4-~1~nkPs=#O7i?EUY(`ecB&Kr;vQ++ zR&FaBh^2ycQ)A0?z)+gD?@(GX^b!^j!m{)QHc{rXtnlZ(=mLzv+Kf?I()IG-!g-?F z_+~7=Su-vQu3fu|@tR9VR~Vwgw>|y3W?GIIO~%YGPAJDS;t&{uC-#BsFTcW*C(m#W zUVG~;F5S4r*?hscD!6|ADyx$x%DewOL9p)EvuDm#=m`=DjenwY&7H6ldmd9K=JmB6ZA5l~lyV)KY zno(I$T{&Vp8S}xrzo2)XOM6q!9t94sUZ!qZ&d*Mm9$lkeFX_)u1U}l*ORXHNj3$~~k99dV_z~1)yy73`rsBJ^FEQ=(W`P5PtO*Ja$yAFfeo`5rJsn;_9 z+;kZ-yAj&x6z~1zK0==71m_vLfs1v^|N4LbKg?GRbz4){4VU&0`1sy^{_QvaRNNHW z(zmU|K>g-qB~jDNTC$Y6)=CMEJTnYVmcYZ%vo|aFtH1dxoZi33jf0!4>qR9@bSwH9p{CeoEI3w2hEqcXr0Oen2aOK_0CC_Fw&L{{G+p zUzo4fvYYBUy1LQ zAVCIsFy#r-r?!iHwdLwTo|hC^Pun%D7jt$lT}rpufO4LpSwm3Veei&4cV7;dDpIdk zjH)pY9z5jEo39g{=bi6-n`&%P(-E(J`77ji?@?tHO}$1bIM~|c9Oho;`ZN zS(&ht;^Th z-MvJJjypGB;O>Waxj4K33Yk0r03ZNKL_t)*bsdkF4>0Lvcye+=RaHED03bo%zIsfa z=bWD}8BKPWji)S6=9Hr;tJV`u&U89L6X@y%R!M5F$RhnvGn$U6l2A=6d5)0wh`|SD zJ39#|DoB#tYJBjt?Lbu)L?3ZTKg$O>06w>iZZIaL09c=Ygk*f0m}*_`w^mTC<$#*! zB^k;{q9}A}MxNBOT9Fsh@d!~)j4CDpl(o2FpzjlkTM5EqbuP7x}L6W z#Fadnf;Pk?giqfnKnX^|8miF_&mKQyxjN_a(V3 z3ADCUxyD$-+4(tIIgSoy-2C-F!+4Z43_Z?mh?Ra@!W)JGYb|x%;(cH|83{SqWT~7c zjbd$td1JH{#D&uMp`%j;|KU%5fKH=sV=Z-8vojv2(}+S@P1`h57)m>x4F$Q#MGPcF z^hlQF+f$*m#>IuMlu80IDToQ4#{E(wb@TFYP9T1pWQV+9731*;t>pSE1u{k7cH+j+ z2Cc;Z{XB%e(c?!VfDfr+pS|Xm+c*+MK{tVYI`O`oC z7Jut+|6S^)!{#M^=(prm=Q^ww=eIEiKgffptV-tdb0(v4^4bit7m;l3c8_Ti&2{6M zkVit?k_fEL7}85fr`}7`b~Njy#7cK|B-y0|W25q%qR1)Sz$>4-L{U`i?v|(<*I6ts zSg+RXU%QM$Gqh_-h*`sHpLvVUd3Fwtm=z=b^pF04lSd!$>KmWI#mLUV0gk}YjT=mN zXYB0kp`zl$4?d#E6{XF2c<&QleEl^}k54E}#{R($L*GJ{vs$m2>>eZ!P|Z7U|B&%y zhwC?PbLEAcClBtjS{!q9c*tkI@C6>;zsr+*_n4&)(QB{2&fd{=e(}!REY~&T-CfGb zD5c(VVkYjTb?+FBOTnLb$LZ6DTsk@uAHA1ksrL|~+ycFKiSUt==f#$_R#lZ$FrpYy zvn<2;KwY<6rLthwB^{R;O`poHefq3z%CMWRiHyQ^Dm_LrA2oxEsPx0$JZxN2rEX5L zecz{XKzI)1>*}qG+Hc6QDFVDn+t|X=4;{X9+t%^s)VC?F45{g^V?=q!;`|w|U0{u& zv^k-@pk6Lnou6`ka!({lgF|I{TZjup7K>*o))raUYpSZE>3Z@^I6++_ z6T->q3DfbEfRy%h8XdU6_5r!&Vd>|ggAgJhWyMV|Mc9gZJJXH{RQ+#m1&BL+52-=sL=3#O+%* zh*1c&F##KkqNJ({{_vasp3>+vMo}DHy~fwS{$;ws5!w8+f>8lupzRxi3mBuA&33qZ z?;-!@U;Q(h#e7?`a%tSBP0r`P_%&X6n?9=y-puiRdZs;jf0kg58H6n9XF)FXqrpBvC`Z#hWN4Z$ByCM#1lVs+=|q13Jhp*84!j$*(yK5?s(3WiHf; zZt&Q$B*Z{B^h^#fV~QNNu4#RsTdh&bVylv!>5S#cDb4a3h0SRCz_9a4-fv+?J6{NOQX zi<GCD|p`-JGv(=izo41%I@l!u^Xadn#h6wvtU!dRFqwm)Ep+;+m@&nCk zfe(Q^!EQqcWGSv)*DDD-xIvolY$_6@=Yb^Wd`kPF^w!NJFL~JytEl8TrF0UDIpU@~ zn8sFZ+i&?>bzPH<$0M{3*ishaUE3z`Rx;cvVW*I;qu$HaSSiIQ%}LuJwfFIOl46On zsqeZDLckhL)G@&t6_ux09d z9~pc|%f&9SfTp z*~U!88J%AM2*)gNa6Te5RgbkB9dPE9ybzKX9PRq#3q(Fy{Jt6eCA<$=_lz3EZ zB}?nF8nIfhQ@YHeOh!QChn^fw2#T_-ST1UQ@Av*O7srpWS){az%mjS5LMa$qgHi+U zz5Sp0{-1uESKs_RU;pY`RI`$H=tZid62Vx3?|OVtWL7?Z5zTr%mo(CbMHON2fzN;L zv;5-iA7HJ45Ew$>;_QUXC@$u6skv@xIW^h`Cx^K-PJaCO zDWPpqK^`1`{D*(Q-}oE9P1FjbL}9CJ1u=@A+DE#kkr;D&DTN?4$f~N+i=-oTErS~v zyrU{cqEAge^R8=AY0R-PU}|k}ejr9oSxm@H&U#&gQgqHU8$9O^AF+4!x{OSeVmg`7 z)@yQWaYcH;MMLacx>du6_wVA{hTP^%kFN5<_h?(7i=NynhQ+}0i4thEflx^2ladHEM+BXlDj{x!*~2hK4r3!0{pXH%5%WK~sC+K!R3D#-+E zA%x73^q_=DRgSjf3dx;pa32QeeB!NaZY_#D7v_yNW{Wp*&S7&cNDyOiuG_jNq_h@g zR%%S@lBiSwl5R*fszA$Pylxso^yGPgGC9#X8DTi_R`@WWd_)D3+(s(-Aw(97<#uLh zGkNRwt>^x|``mfqa+)p5!^x!=1(DQ>&BgQ3*4tRKbB?pK6I2$-RE|=S>o=~WL!h}h zrELf4x-4sHT*sISWsb~@g-)vlDbNq{A|cA@-wlr0WJ0~}(OE{YOg!QkU|T<4uRfYW} z%Wv;N>5Ql}i&f2JQekXHW(?%Hc;=k|+44f>;33AuKQjzIqP0A52RF!nCd;;NbV&h0 z)7JP*Zn`naZmO!H>pF@&r|&&wC2i}n9Mcbh(MW!O&`c&%s?mgg=xMtSs||z!qcv?e zVDplTild0aQDGS{Pn-~+vvnL(k7wV zt)(bSit(7$`~sa>hPG!IT6AWUjg#$8Zaf;XySLA+$8+u0LvHTqkGFl8; z1ietq9NZ0`TZ8Lm5HzZ~J74tykoQ1ZaZ9zgO!8JF_WOS+ZI$ zS@r{aFT5a!xiTlJKs7Fj2&1nWRWujp#I`3>hW-6RF3wMBhk@JIuQ8qOvRc+Wdh~#+ zcWz@w6N=2xKDfup`I5_5u2R=4T-$T^7awwTDeb0}+`)#y}Tz~OJY?0BeR=oD|i%d$x`N>n-rXd8+ zOE15|Pk;17N~5TXlEwKG2CX?7-H~KukaBg`wX7B^GGKms0=^eyk8_M?JJhS1YHSkW zdXQj=Rydcs8$KNb6WeM!oysk}k6Y!>YPBNES4mTjB&B)4mxw38)wr$%oo=k-4RF*Q&*LIs5-}ER<`Ze6AMq)8W z+NP12oYE+pT*)y=Zy|b$(P+!!%JV6)ENQ!*!4K?IyDXL~E*1;=t`oqQ5ul+;5DCy2 zTjGWqm-aAi*P)CeQ--1}Sg%*qZBH}wXq9tx=`uxT=#!2$8u5jQyF}XPAvw4=JSi#e z^)i-G;(m@H(DXe+y&{VW*9R8u3ah0sC{iv%mZzn5!cPS63Ep9BCOfA*r*B%EZ{F(nHWj8p@mhKT1lUA%gP_{V;5^ z>}8p=TrIeK^$IHJG`7jMfKO|!I8Ur$-H3hf?EHeE_w**`7!Z?SI@a^w>kFS62 z%jnbv*_4O-z5}h%F=rS&Me^8mUC*ej@KNEmrcC+9ZRX~YfHHwC4xO@K|84tO9c*J_O;QFm=T**Y}_1YU>U^1O@disp`>={*A@!+G6Y1V5# z_w_%+s<}--asKc=pA zqAZ2<9E3IZX*=wOI8v77r&JHm%f~t`-HS3$7lR=COfyCcoKIS}MP5oN385@*aN1dB z8BeBoFR7Nc?J!v?Nu{M|mf18{58H;c%-J%)FrDt=oOq!(7?06tgvm74m;_N1g|2s= zwr=T$0h3!slQDT&QdZ(uwt3FrJvy`aD8EOR<=CQNGTWgTjc^gl(Rh0c*_7ip-t)4W zP!U5*cj1i$55#@z2tJ%oo{PvWq(_XY1Ra=}&)AwAueuKU1*O^_uOxq2t&(GMKl&Q?B z$&HLbGo`UwQ_tso_RY_5|G_6zrHsn@!Ex{YV|Hh|)T;&W|Kexdy!irEHQ~|YM|8tL zHI^H)DssO17rwz{x=VC{Znfkm-~A3(FCFpaKl|r+^Gjdj$_uwy)HN^My3Npgy7iiu zUw)DG;v5|v!+Oo}gNGbHe$3hV93K>2+p}6Pxpesw zh$<)AoSppxn)Q-A*OaAY=sKP}c);@flpKd8Z25uvmyVcTzQXS1%VbqaTI1n|p1NKq z617GVrKL_JFDxb#Hxfq4Em>z|wxI6@V%m#tJ};ZlRF=sh^*JKNB%`Z5vo|QD%?`XM z1n`;p)D-nT{eqcESnZGm?rKYF9EP6RY?|898|te>bvHcuEEm+p21V|DAVfu7cj+YA zCrh3@EGOd$ei)d~7v#C6Tdyh05o^xml+`yhZJ%y2EipO*8l78)5Xkae(lsv7ubZ?0 zR&*{1W!OiIHn={d1*~COj#$@@k3PQ3&7%pLPMA;$8{Kp*Hr*CX@~neIlu20jXcb+% zCa-da0ZvZOINGhSxuM84>y;!O2Jg{UFdjxLLh8~eW$1btW5kr*-U1I@8Wlw^sgw}u z>xN(jRne?hTfceRHRS2E7$XS>BT^onbiuBd(P4<5Ro%(VU6GPyk;3NNGE!BQ+hW+K z{TneR)>(}7ZA)&&O+NH3MLv=fQST`WnVS|xLDvr$Be`|g_XN#zJhzjcMrP#a%o z`2{h>oaLV<3aK{Bh(YmB{^>s_uS$xlVlePjRl>1`HPKhVkwJ*REeBDuvOR zu(%>(=Sz#uVd!y#lWv7i3@qe~pQ6++d(ODXW$V>3_ z?2N%aVfWHyZoPV&X1U_=M;{aX!1T&>qP7yAaXq8a7~ebguU+Lk-})v#3|OOZ4lZAL zf!cZY_V>xPrmt)E4i0$m@FBNfewnh&IeYp9lWAVKb%Um>sU{V9ne+6?W2UPW|Kg`V zv<{A)*SZkP{J!Mp8Qb;x##uilL z8MX5??V3!Bvm+4DnI^N(@%yByk@tbNlZ0nc77`3fz8rM}O`An83_+odtJW#|Qc z0y3-!$rEKvhEAzE2}y}=+j3Ki!dkq0HYtkaO+iH2`*YpetMJ~X-NY^(^ z#}$Jc~A3k7Qa=FB6@Xd-#hx-Jj_zS=C7y0nxkNM7b{)GDiZ$!S}z%(dB(!eDig__Kp9V)2GKg{N#PU^ySa-<`+KCotIx>Hl5I{mz+FJY{iL=!-jl2;v zOH`4aoe3d|rEoIa1!c)?NuFhNZorNz$aBc#+8=|VUH2?5R=8fuVjhjka%@py%Zk}_ zm&tg>bh?Al8S8aRURa(TKcFZgWoA+=-7+gzK;F`lcm%aYP#kgS6j@H07wBxv-~QWwC-q-6L!U;&saug3`IbW`<6kMEcKrtS8MYMZ$z;k;-hP{^ zl%!4D_FTVmna_Xj^MoM9#5 zF@NT(Uz5*KIb#3n3w-Sx{}o3^S9$*zKj-A>Q~Ivw-Cz8Iay;hJ=qlkpzSs^$L2ce(q)&$)PZUjipVjGT-4oSmH+<8g&nk@dP} zG~GexCEDhqnMh2oy`vixlLM;pjKjlA_~=;G3n>CRm|v{6sF{uEX|wa%u-G<4?@cV% zWD1)b$}&%N3wddg*m-hW@mn)xNH=^LRS751=9zeO`feNDC+V$CmXYOFPKC}7LP$k^ z1FeiW7E+n6uIsH#eX|@NjYc$0^SrE<##ma*vQJqnOLN3>w^}XnPJm~hei=1Q&1$uh zMXAzc<%ss|DJaFWCl7I1$}464+gymCHq8OGw7ZwfC0c7PmKUI4T*xw2 zX9{CAWi_K~R&qK5eBTJ`CS|N+TJ&ZnmBh5hJGtG|eJg)GnsjQ@EEaRBvZSyYT^Q=N zp~wp+JEP&7(-!1p*jo!lNSsDS)yCiZt(K8cfok001BWNklyD#ZMIcJ$K+M6 zn;Ne)!`i3hi;T05u3q7lS6|`M;ZBNKTWnr2qytxBrFkqbO-EIV=C^Cb_u^NBi9UkTlkVAN}wL{MFz3jkH|Pwm?nUp-Izxxn9XjA*CAg zqL2mc@Lbv3chYx>KH~Zyr(_=lTBszTGxP%E+#GsNPcJBEmZB;-JltcoToRPw+Krn; z7r1@<4(mY@bB{iJmwO+6#L>|atEOYQZa9ASjOaWjD~QJedECEzh{|AZe-}bvIvbN2 z&CS~{@zHzla&)*yp5-{FIJ-D!wOX^7&$)KvIzRd8PZ$PI+q8@)VGBoYx@KHeoF6~s!AI|t z=LIj^x|k^j*n} z9H`|b@%(caqrAwZkJFLoCBZvF+S6$x{g6%RJH|*i^xK1)N}nx}46?Pdg1i@OS&`W^ zR?x{BrxRAgC3o|4heSc$G>Ny>F4~GoZFq7rXMZ-v^_q3lljnx>c|%^vi=?hsRAnVQ z8P{)5J=LJe^NRIcW`llM5C%tl8(G?6V0`HYi$0R&uxxscPdl^idourR5!ZN7FoGa^(s>#ajEmmy=;aacusziRft~94P0csk(6hY}%!Qif;)> zIw{6To>1{sRS|JCO-;;mj5YMmVG2ok3`4}`0;rQ4-jvh!V*C4l_`wH!;d5V*{gK`n zD$O?ip_F1W8S~xm{TFg84;f=hRPQN^T+&8KpPL!Gy%9+K+hOxUC7izFH~v4)^)+qn>e55`18Oal&*qV{v{WP{#E8h~Fl%TCZf*9OX7- zQ%qH6&7-Z~Cb|JP42e{2QbT{>@BY2N!@BJ$%PD>D8I8t-;4rzRt!tEu$)ZC z^xf#!_#oXZn+Y6K8v4mipFZQk$L)*x{SQBqgMtR<0=IA7qOI4AtBRLid5w=heji2P z*`rU$(o1!-dy8J!ZKQ*bP0?d{si(zjO6QbiDI+r_IGn!sTY7-f8kI)kCRs^kicw-s z#?bdLcy7FKL>L0ko*q+_6N+-ogL|Ja-koBnJ6zt|;oYD7h-+7_k&ky7Xn6CDBSty= z;D_Ji;L;(@iKjF<_v&+g71f zmo(Cn1dWKfQlqwjRi)KdpS(G=m@FgfyPiJibSjiDSZ)h4BmcMGl3L{irjzIVeLD^;we-O-Uq)1xvz*s-t)n00gghSaqS2{~k`d)QK+|cSJbNMtVwGV`LFY3bzW;#n zbi$BiH_l0SBUcKwMc8|qre`|crC!fb>5XqP=`*amo=QdbuHNML|Ks<$bacqo%ZF$a z*`1cGR|^(rkJ;JT!O(E!a7&l%ZA+0Ctd?_#GBy%gt}H~eOeY~%Dd_kRaH`~! zlNPHpn&7cEr*1onB2OCOm~JD2fOlIlR!r_`>EaB7oRmZKX%96pG;Oj`BC$*qevmy6 zE)sMQqad&_XE~uJbLgk_wU_fHXQ>tjYyS@9EP5#plLdO z_`UBi%B6SVhCnfzu)n`c*U70zE4_sm4ujYw^UP8=4OvmjOI=Cv6(n_(+j84WGD(}N zl_bfcv0S}&1C7S$hzgo!x#HsV8750(L9I~6;6kKxJ?I>*az6a%E~Bc1WV#$rCQL>n zmMeL?C8s5Kvd@IKR>89O^W!eP>y$!vjqu4n4LY^i&MV)o$qpVRStfJ_6?VN_0V9l6ceo$X4BQ48fmCE1vZE-8t7W;;6s zw~5#1N#K|49ZI@0T4}sjgy_<<*ruIrq#Igd+MnrcW73S`O45?N^dZs@1BI1CfwiIs zcdi$rg!g34y2c!haIPaWA}7+y;#^0bn*_uQI42LRO-^`o${ml#Tho~WSri+c=IkPd z-LX19fRxH-I;PYGqk#AuTUrkb`L5Z>{Z;kK4xxfvO-fYdd8zm z41=7?tj!2RKt+K8`s8W1<%F&;=(`qUGJ@AQGG4v9%Ln(LaP#UB4?bQp^p39GU^+xQ zs}xLUGrV(5sxf^l^jc*y+M&e-jj2jZIpg-NtGsw^m#%Ga!@$t=sJ`dM!6nYm&$)WA z&tfrW=;oZCN1P8Vd|)yv$uwxMx7S5eh@vy1aLVE%I3U;^nz|u+86mXkCim(74%^!V zTl9XG<(t_OS{sbcwzzkjOMbj6D>9Q_oXMf$6QOwnQxvzdEN8XKz`Jcy@8y?X=8wPi zEedOCQ`ztL|MlsTyUTTF6z8PXI7GC=_c-jAm62{ zldv{MW11+ZeUdlEkql!(=&6pB>|eI^CiTxWKpm z_`8A-(iz%{WiGgmU^GAe!S{IcGjB)?)XP3(<9~3@$&xmD&=Kb%xs}k%<`z2)y(F^~ z6tZ~gf{&vZQH@snpvmEpmod&C&Xt&xz(2K=fttJRv5lVct| ze!_IN!~5^Q&&`+aP?aSY7jyQHjyQdMpL(&tInQi&k9FNplp_g`07E}ul-{C!@=TI| z%hif9&zM}hBI&rkLmPQsMg^1E4qC&r`;Y1Rhz}iCE?s82dq9?#G_|k+V~C>vO)0y! z?^x92Va)joUKsKsp_Ho)gpR z0a9c?s>H36&sW&)Gc(+$%%au`jiTv0G7-ag3Tx@psUmtMM4|K>Y@%DTiPzhCYgJXx z4Ragr&8M)?n~b?c2-WsKS5zgd)k^-toV3vwtA#|-h7KKaaY1C_E|`^5F3wg&r(g(x zBMXtCYZCZT9u{3Mos1#<1+{gD?*!K9yrc6q!FTwmXuH6AvEumnnV_fxzodxtfyBa+ zA5>;@oR65Sr0E8R-eaf0i;YxPIeBo;|)tk;YupX~jSI z2mdFpz4-;+c;gKwWkH_jy!XLJeCvBN9!<3)inO44co4>}%vl64M@} zmjqE7E!s@*)63PGa<8N)ifyx6_Ee@G?L1O^-=wj+3w5!;F(OQNZ6lHFuaHP@-CGrYqd)|3y= z+PjMVrGZAH>YVr8d#z`<@9TEP&=r}h;P{B4@9Bqb4s^>YNyzIefFUwfDJ@i9$P6Z!%D;02GKKExW$$>|A;)sitv zr|g{UWt_F#ef>=ijpg2_pYh?J{y8ts&N#Vt$`|(^^6g*yO`<}`$l7qZ+jCqm@x#Do zdr8wSutvlblb|CG^1IY!32wEE0NAACK6JZ*~*Yre1)-Q$z_x|?^4HvO~nW}JFjCRmm6H>nt49>Ly#AH zavaAPXTt2{Xq9Ens_Z&Ok;siKAq*r1?P5g?BgRfKykcq#DPoOgdwD4jedxK`?pQ2Z0+|%0O+Gq2X8Y-L z7OQKVJ$yt{H$_Dh7{-yNU5IZ)TdYkomQaFVsGUIQR&|A{GxwgJapUk7qYn~@>tHMJwR1Os4Qja09*=+c0f8%e^ zwk=K5N|jY;p{fV~axRg*suD!#2eJU}e&JxB;{_p?ef5$aTuHCrFez)i1;yKPKEM7hbU_X^6oS zm5eIKagYP(`~`=L>Sgj{i8u&JUieq5$RUp8l=!26@h|vm|K;y7_;Kz)R0d@fn#{1@ zvs$+D-%^o=6|8sP4}>H+>XHfs&NwK~>kx)wKGZDghL{pjB}v@8cAcZs6SjTNv-2xl zv%ne4fja~xL2E}RM;tUY{lEIxB+k>jzV|v z1!H536s&%fqH8)MjIkw)X`pq@D~4E!X&8o1RFGP7lBRz`2=e~M6ojf-4uYZJd?~7z z5F)$Xt}NQrOwWD!L(80Y+Hts74wyOqHj9K!vIg2YBYlcfB`2&BX=2J?m7&rVty{9) z^pGOMZj0|XI3vcEkRtmK=z=FIh$^FuU^;vlsT#`|2mH{{G*)D7N|6!#!ftydVN3fh zb!AD>)AwDO|5H-#0>R5?5OU_?>Z;trf*H|7Dyvzx4cSL3E0VXmvNZLiWYE;EW?2^e z5+bszgvL4Jk2n9Wf??5BR~)7gEzyw5{WB|Ly;( zP=FInRZ&|_4jse(3S%VK90Dl}Glg~Ed;Z$r_?x`-=56YFGCs+3SyeST3&JD>AtXA86N1^bJ~ z)Gnh+mO@?EpbWqD{r{Y|-+EJCs4Vo-rmp2B3&LPC+R*o131d8XV-Q3n2I`37omuBn{{20v*yv`Gb-nbiPC^-K)2{>N!1<@Xz;m0 zxfQBj60$7gug;%xdAT8lDCy_ZyB|f3cyxFmU3toAqh=w-FqGXJie4?Jy*L8UDvU>+VJ%GIluP(zrxqv ze-G<4Z{2;17cZW3=Z)KVJP)5f;r_$NeD>*Q9>B1_Vk3q z>o*v@00~nP@SsteF$CUt{SAKlqaX24{^395>cu%f`S2rQf)x~kGMWV9&x%t*pM%HGJ(RyCK;&iM4lACl9^owx1?2|>Zt<+&`ft&;-N4=h(pY+Zp-oSdHW@cskB zIMP%V*REX`BG?qqP^9~6KGaoFyQ-OqrAmjMQlxS<6Afqdp1zYrP)Q_o48zDcdU`*Q zkn^YOJGR^H9J3%(k7DFi%#;D^iUWHb2MR;5Z1?bUQv0ScZ{`@qf%UR4uXeTJ|wkGc6(AO%l+izqRsYGHZ(nOJJk`;#s z2Mk>=s9|Lo#z?Zo-&v%@Mq82=7q-drZ>2Stn+uZ4yz$1HY@a`-4?-aZGrPgH%de1w z6N|!(iYC)6WIR!uhCJ+1CX<4g;6n23x8poU$|;hRjNYoMrRzK)Bvh+V8mfY%_dbx6 z!dMw;2pL$Bm7}gTPtGsVS~@^qTG-&j9O~5d1I?n92d6wdGOLcVC@Yuwa=Da4$cNbv zG3{S;dHBYX4I_}gvJcS)<20_SWy#oBGKpj)-V_q6Rm1=I2Y;8dXXpH@fAP=RcUK%7 z9*|-#TrVXkZ(|typ0B+70e|@~e-~>NK1NV7CvVzC>8N4}Dbi@8Xi5^}q#B7q)Nys= z%H^BUG1GOOfB=n>nRM1=-y>tD#j>UEdVcqJf14lt@D_jZr~e;|T3Yyt3l#BC3al6* ztH|&E-tTjCbb{87gM+#_e1$ga$C1hjVZb0*kgPQ0IM6mrls5Pj&^ebJ2ai@xp?etwbH$3g;^6x)ThY2c6N*Ld;mJ z==(rQqDs_R&4mU|T+}%RD(l#GTaq#i-6&X=1V_h*3?XxLa7Z>96$6I{Yi=B_>AXkl zimvN1TH`~+12=Bp!c`U7SibtTcX{&YA#H8B`Q{x~%O#LGdv?a*(Gg0)e!s(yf~e0{ z@#NWadLQ}vH-4GVKK>Eg%@yo>+S;;>9c@$d?EXD|_Os7<`_3(1T%7aEzxJyFAk7&+ z$jrZO8hMtHD1ekauCgVLJCsf#pmipO9<3B-j~~(xJzsg}UD|^MWAgNS*&FZndm>q; z7mTczYt9~=;ky7TqO+s#I&NNDAvIDg3vxp$dy^O?Cq2fABc@RkP28ul%h7b*9$R*s zJ`7l`==(jDb2L>`p1(4osALr&9xbsrNDQ_uBTUI765)eVGQ%Bu7K z*Y&dwf3;f6Oul4IOuhnPN68Xhz+5ktSd+efl7eZiixEhcj+543aL&bUud=k&XbujJ zgb`JuS+z1n>1FA=0jw3&2y(Lr9|f|gGa-yc*&>?f5Io1nhy3i>BSs$>W5!3oSe~6d z=lZp4RJ9RTw>IP$W}srX??vrZ_D?w^!Ld78cBi6|ud151sY*<)ba+i&6?KPXMi^y? zIlR)T$}Y3iC?7v{78jd5Y$c{q?n`vPFdW@rnuiTSw?o&(CzosuA=Wdu3x*x-~apn z6PH)#JbUqiFTVJkuIsVZ&@>HSy?d9{YQ zh0EhZ~S$bmVA)lSuB_I-H0|@e)-#fi9hoz{d0>%{D^kT6PS~tRab5<}sV;Dh;deV=Ps&Tx2_jTIUg6?w5&p!N* z*-JsVd2zvZyW#NKb++3J!LC=1F!aTH^HS*0 zHjP|BF`{cdJIF^r;+&=HJ2}5(BPL-}N{pbnjCxgBm`gz0%Y;r z330$WYiGsI>@A2Q*_}pJkZGElVUW>8U(n>@wzaRAO(r?8m`-Ggn+mpF=IT?%`f|CP zRShvmDyI=qicQjgh*k8HW*{;;dcHh-@<< z>amP4r9ycr#31MR{Ik!b8z`xOC)aL*%Ec^Z3QR0W7I)54ndV4cyAnn(M#FLNq>vcK zk&}~S37zr*a+Ev`BRd0YYB8Xd*=b0WN+uPRk$lGc@?dx$NhqqOkaa_tM}{vKXJ&?5 zHcf?A4H|_i#!q7mRUrb45-a?iK~E{6q&6jgV=ZxhddG zMsCanM@otBfB$>rFrvuxW9g)n6dI|t;4xAPh(HJP%JhkpJJ`>vyD8-{-W(#)k4|HI$?JGe^Iwykt!Q8LSiabVr7=(~YG_W; z$NuVyvj_JuDYD;hx&PCjaPj;ZAAIZE>^EELy5iQI*El{o|^jfWX0unN4s2cdgC?rgXihl89)2{5BcC* z-{kIBze)&!)oR5U!Mcj*6FNz%r|b&)q8>DCHyfUwov~=v#H<-ZLTxvMagWst8e@qO zR_isVCnxgmmE_YeAD^*Yt;ohQn8d9cH+lBxA=~YvN5Z?z8$4rVtd1oRaj3ooa z&i}7?MOF;`fGdnX!R1u47xZ2Vs#nO^(-|S5eR6Ttb7qMa@i3L3(%a`SH zjW3|C$%QyMyruec^NwS>WaJ`|oMq=K+IGQyzr(qgGt=pXm{8A6(?~@(b_F#tKoP;B z001BWNkl$1i_BXCG3+{T7sBQEuRf$w{s(eZLdNPH{=6lyKV39b{huxV-l>+8Lx(9m{8> zl_HVm%Uu?9hN@{ml|1;EQ7RC_C}YN9q}z=s4UM*J_g%^C2B%|LHYY4uob|7`t3@5B z=F3BVPU&)|ZZ@UF*n7gTmn`>wq+KpZNfDFq#Q+sS>b_Lzv?pmZk^@J|5vvt`7^!QA zagM=zfpJ!gf@YEZoGLD0i8&SufMC=U7w0eNyAiD8^LzI}YYrC+R&`C~8Zwc(t-0E5 z@I_v1%2+lR{?ugRop?mXNaSSlo^BM-p&v(_6)AR19u*YEWXT*+k>#o><32+S!tipX z16wr>-uH}S$7L2The38{zMx1ZlCBU7I%1BDX%H85mT_y}_p_OC+P|p@Pm{6;8^)fb zGAVkDva=YVY8ULgj@RFKo6DyUx&Osy9G;wVygDR1LGkp%h*g?LpMAlPKm3n;?>B#) zkDfdtk3BI3ysNqQ@h8}-##WB&w{N4Y*`3sB^ zm}5+dZ~w}#@~{5cKSkF|nu8;*U%y4n3fEZPfA4*sK7Po9FYa-$KH%)hV{X6xCWj|C z2s!ff;UjjJ7aSkoKv~0PbH!q@P-!zj-$8i*mxe|h` z)`pLN`X!Z7oE%i-6R&0-~Sqao1`BUwpMPl$5q#T136S~)^g zLPm9F<}EpENLKUU=_S{XPazM&-Z7Tlc1z`Is;Xx2ejX|HW1+m3TiKOM)KxXlUYv8| zwVU+A$i?}2f&D2~%O%cAX0frBt{d480UsjGVnHg0WYaYCW55S5Z3_|nUL&m^b;)UCGfkHNzx#ZSQ-BDdxQVuAJN=6HL zI46laP$=rA7A%M<@@k_oP7`yWt{h|6V_j9opfW11>l&j(iPSWW>@2H_i;GKa<*?4+ zw54qtd5(=@v)eNISh^y4H-%*GYo{DvzYYq?1yW(WW3X zASc+Izo2OvUcd7?n-|Y`^7&6tDe&!I`Uc0Vwq$fP(m_nPppfTLqSBJF9wm`hg21d{ z^utUY5t+N2_tBGRMfRd;7SxdC98BdIR?5()jGY39Cf)Fqcbmy9mJ11xFE5~vLF8(M z!aK#ER&~vKy{7MlD`n<{D^1%@$%f3c?8%giK>qtl%=XH2gg0z zVaL*1F}eAHs+JFMv)z%E1VW9)D2Yxu;|jhWjD} zsUn#?b`rJ|LO@&bI9O-V#&Wd0j#Y}vYU*~yuIo8Ui4=r5yS+MR@QOtv84#HXsUh8+ zbJd*o__8{ed2`z~rJD>gavBDC@W``U{ndQ>X-73(DtWqS3Sk*zh+e)!5nK2DM>I(r10oRF0U>G4WUH(>vJS)Q$zz|8r=0Ae&WZ4h*J_aq>QE? z2Z;|hwxmEdvQ#Z;nmOlEM2L~}+UY6#t`pvw7h-^n;u3A!VpTZ~*^Z}I> zu8{&mOA-jW@YnWxK^0kNNC-nrmyC&&1T&sV!~*dozFO z=bzta=sYSU#>*GDMa6lyC5Ft$53e{mKH>Nmc^dbQ;IYR4xZe}bth zmdhnye*P&pZ{8p!I6ph%(Sv(@=QqE{4?p}1ZoKt2)uKfuPrp6Kt`4|<^Ew~>@Q3W4 zy&${RQ#U*duxy_4<7kvEDM;z1= zxlt9Coza@gIr`lWXDm8r@fsG4vy$v~1me z$7;Q13?6MPm2<^hl}It-s+yeTLK_QBQfs-ThcM7C*8HvC`z~XY4|08QAcV>QF2lv< zg3386W3Wyalig5e%0cwtm8188|MUO(*8~)~_-U**jANkdcXEiND2aYUH!qn{a_b)) z907^lZYv3ewnA%YnwBsQy#D%aOb$Hj0`DK+mI&Vx{4|kICso_jFk)f!y{4*f7vSM{7^H!+eX`oOd-&nS%5FE!`r`JtT@ zA5!s~d9l6Q?Pi{uxU^+?ua$xn@x^tUlccCABO1$8vQMUps_RCqRkVv5>nx#w;Ec7H zBJCY#Zk2Q7^0}9827@N)D$LSry&Bb+S*gW@cp_ zp9o1oq`U!a!ZkHRCnJWotvKK8#eMI+NNKZlTuP=}s3Jf~S+EvT=oqQSNAU^lyMfA% zrSMP$pYY>|QUYHTkCGV4B%LnMG&RnNBt93Tn$eDN7>jf`l@V!@Ag@M(%2de-Qbp3- zcOC6=&2lCC6s4u$I6XZj3>^>df5~tB`mb|*^A@{dN?256t2j8l#^3`tZeHi!!$;Ij zO;xv4t0R=woLsAU@Wsby6?pde9``@_gvIihgKO7$@AT{J_5-(W-lQLT`mkfQSh8GH zJUx5D)n<#UTVA_=_4#6>Tj_tXprq$tTaRu&!o#bj-n!N$2NW zU7Qj2m#jC()TY81OT=Okwch1EvZ z4Mu3YV;HI0M!Hx)8&!|WHt32#|LYwN>oFA@9Fj%maC<-`bzQQ#WUW1 z^9>$+`6X3dVO$MHZF`8R?qtJ zX=FCTJH43n=19$gF%bu>MrjCREXp)tu?ady7ZRy2pgZZ5$F8TcRl&MTa1}XYUJ-o6 z5X96PBHGF2JB=-4Rw7$AV#LcO-D=vmOrEFdg{zx}vCRA>>h}O2Ls6wU#!;&C5ChK0 zXA@H5`T2z?U8clJKVWOekU(3@=5j~QL2^uj#FDDgA@$`V4n9bhL+KEmb9CJvtqn~r zAw#QWLl`^A1-0JP=wb#`F`|@ayV5& z+Kro1EbKaNzkZuffBIu~`widyo!_QDIOW6t_#r2!r>NTS!E3isIr2yU@{f4$7k-iD z`k1T~%aVxNE?0d1*{8hq#%*@P9-kvet2HNwhxDgMeDvAJxayEwcW#rbCD~M5yK{?= ze)t3Ozx!jZoxaA^#W^uRqXqD}UM(f6!Wmq>#`hgh9zNg~zx69p^tlQbJx3?U)J;Ro z!V^=vL}Dj_U#6VdU2UkFn!el7wyo@{0biEym6MU}G;NxuKT~luzvC(6T#ZtRlqaTE z0%cIzinxeG%n93d9j8aNRC}+`T>GJ?ZiH64SS$+GJj^=c38pqnaf|F!YcUkX7-^dp zkQ|1^q9vtFRY|x^j8Zupqfw!R9%;+w>IMF)Cxr~AkRpdY7ndE2bWR#ZOsw%^j~^qh zs@UzXM0MpHXAhnbQ;^EH$ZjiFaefv>hwnX2-SS}bh;G{x^_pwfuk+=-FY!K5w~Hb_ z7GHrfhEP%!H=BL=oWNMe7!p}Ko<85O`Ql4{@txOr{L>FnDRTYfly0|WxD-vcj657Z zM;<=vaaDx|>bl~SkAFy8SNIUQ+FxNSht3ekz;>4jPaorK#a=BzRRyymlE_q;Kl40) zqRwjBiBwfXKgh*sO1GC)$$9BV@phQ92rXnzttguPOd?~oE!Dlkk7MCbjnuUjHC9T3 zd58kw3o+tsg)a+ssbU4b=9He*Uem5+Qi@Gx)t3b5hLi$>A0cO!?TX#5!`5;b>%zCH zaO5P~c%vc3mz9~eCHO{(JKL7HfuZklbwk$bC4stRZRDb`nq;j?F?ScCV0?73UWgog^06Sb>dd zCGvLXgrTI7YF%d3a`71@&qgc@R!NSGXe|oAs3lTZE+Ls~pB$gk?*-u3?e}ugxEh}W zOHq`ySE1wxqasjx8Ht;txvrB!3R9~>CgDed+(B6&E~~(4%SC} z{G*@ntH1FZ9G)ByGt^DX`PmuW_KNr3`zq&`4>`GhgCG3a4^ZkuoJw3=Jmc-J+@&9c z-mNRk8(+E0YqzfR^vf@}|HbDV9Ut;y@Pr&#?FV+dEzN2vHiHzenz@je(y1+1Ee z%ZnE{V@jkkMNXecNTHE~7+qHfLgbw~(d~Xm+la@^6vJ;0%scIgYdBdgLSf&)36!G*!(w3cP9jpYKe+9+7Z);Ns9G) zNf-wd2J6I(xxdPC5o4c!doPL3g|kK2!73+tG~;O zCyzNkIR@uIYr(Xn#A3A~4=0?S9#StCbfd>K3;e$0;iE^mdWk()lD(%N2Zo#(9zNxh zAAU$T^lYv+4BL)fx8?e4w>UgH;^^o)hX;v|KKvm!Z=TRJ3x4vWPchMRdi^?|eer-t z5ANYmWFNV6=QaA>hQos+9zA%*>Fu|1E};^bN)uH>eR|4`x?xc@+(<~MhyM2%M9nKj}4sS?wuyKUYQ&*15u0xSfDPbE+%mI}(V=0a*Tg$H2 zXm(vMqxU2MXS?10r2wkzJgUkT+JlfKa&`g&tt#oxhk;64hAdR3dL0! zOCRg9|L!9uZdxgkC7Y$C|hzG1lw$jfiNbGQI@7%F?wNgX*4lJ+Iq`-pjToHGm!FGC&Yh-w3sXjb6km=wevLL% z)uIG53Abw;hoaPTGpKL^JW?1@-peyTU3?R3&RQ$<dB+AsbR%Yy@ydnwD#b+F{c+PXpK#N)?LIKFj@X1zjX&??Q( z>A`wU(>SWyiDJ~t$j}+_yU9B~P+Kh#1~C?NjA9&KCW=m6xM@E(K^G@6%k*v-Q-mVL zrZ5$B(^MeFh$`RR^iKH@NJ>u#eqE|T{YqL~o-?5*qnR#MS5+3Mlb2w+$#%P4S;&~8 zwt3kiX(O)Lx^aYYw8{Y2%Z99><8X?=Y9U|pZpmyuAw-(llOC& z(&W=fDHeCM!Uusu8f|C~TE>19Mo;u|XxD}(PapB_8}E@iGm|N`v(%<$7$cj#0(SULWMWU^yom5p2 zHoT(bGP8SlG8w*%)6BB4D_)-IfC@qI?$hoj771?{N1EEsXX;(t=OnRN|?S@tif5&DABV z^}(D(DbIGHsu#KSe!Ii_z+xrZeHrrx#-X3RL#Yr2tdq|-hJvn9dL9?Z$WT)^4g1{= ztrcxk{~WAN8%6R#VsL#VBg^d&1Zkg($5@q%al6|ItTGGA!)nbimJ7}brNNILO=7#d zV7Xe5vyo_NWf_J@Q{sHZASzv(?205|aDrz_P@4SRrl`6!R&rfNp#hUKaSSv~gEf0{ zQD7b(95D7h2geeilo3Hkh@NpTMP*Z2JSE>dB>EK5zQ=en#{?-~au;j%}8-^@5@5I5;^Xcuz!e|ItGpK77Ehf9E?qzqnw1a?Hui*El>r z;oxA+)2C0%-cCAO7Y6FZg6-86H;&glIeW;M6NlGsus%8x({PgW2d!~RkyYU2;HXIa zGq>)%j;U(~A81RlFvVoZ7&l6OPF2$`mu$|@P-WNXtP|{dhLEB#$jWZcn0gk`NU@>j zk?Z8~2t{H)<%i4xQ4{e&$x?ly7^GBCDN}TIdB*(cu~X)Gfw9iF&p1k3x!G)3E|<8f zVq46F6B=8XSknlCa;pi3p{lCl2@n-Z%3hEnG0V7Vzn{?}UEgCX2!248`a*flVZ^8; z2E(?ZZA7-Vs2cW@CcYN@wJIter>$`Fi@Gew$bP$(Wl&|Pv#^ms(+^^tllV=+8fZy_LBotwTpYhcbtk5V;Rn$$cstZmd&uErhaxGHIMS-Sf zMd6f#Krzxur@1Ji4>Mnst}2A;l-O=IxPtH=$B|(esTzsnl>`4J7AD0|T*Kuhc8+26 zvh$c?p|u9-f3Gpx~{+}p=oMzf}EwG2r;r)E@X_YL=+Hm zF@9z}?>EO$6q)<&Rs;{j$bPryXP^9x_docBGK!R;qtO~uQ1eFVV*D&csz#9WAr~Y} zDDc1}AWLIewhJCUe8|bQ>x6C3mmmLxxY_WZ{qA2!tq$eS3q8()4IFGRZoYPmF^*vjTXrB^Q%jgwU~mi*2c4dThS35Sl9Z5+H?j&W+WR?djl+`kGCzW8OL`tDl zrjkLD&;_kuNf1;L*E(9GjFC!q>HsFp$h5E~i|H>0Dr;Xs-;3EQqh_v`))HK0jlx-t zE{4kZ%7Wh&spE3F5O;0}ELUslsu4rh7^&(78CBfwhOC5YAj`@$Qv&9W!Ut$gLL(zPQ+^I-9RyHV z_WgjhO<~c=h1u3MjdLuP3mNSdP~c*<0F?=T6eVPllUl7XM$Lko;62@5{8R_W2f}r# zYnH164p-}Wv7SPr+jXQUU9gXls53DRpkkrn3hCEX;*(J&X;NhC;*H5axw;|<@xeCGK46jc_q@@ zoV5r;tbiIdSuUECS=0++kpHLKbwoc(w_5yK=t*{M=6Chgk#t=7!dR0}i-EQf-PMBNmlBQjujHY%9=Mu*^PG}Yj zY$X9KDa$X`)|GT7wPU?n(03iKuAwC7#T1DlL004_w|Y#9wp|g1kvDH#=h^))0SSYN zDWTESbt@{MNzC=EbHZh%4k%MGwuUecvcJd?p94uJdOu*CW(Wf@$(Oa+?0E3t8NJtFGC1KyiGk2zt&?ycYv*ia z;bYnPQ0>c3N=8GP6!VNo_r-BP{WEt`X9w;H3iZ5frufMuOk(s-UN&CJR%uRtq?z_HtTr9@QGEzDf zxsX!`xG8Sf7zyafnVj-VC}z3*ovSh0VQfX!G$>mb5it`+&w90@sx14i6DfB#R8`A< zKM1KB$(A`kKPMCxmsGqGQ<}7(nZRhlmqaPp#~2yI%VONsLK8q6&_z;Pn0b@>ZlZ!r z>cCm_Qm|1|u`=yKy!Y()JMrBVGD1qiSZmvMR%K0prqY^_BEvY80;TM?eT=xaW;ovo zY0+A?C4g!ArysgPcyxut`LfNP9yaIfOzC_T#y2s3Qj!B_dda3A&=|vVu|O$7zV9!3 zMxP*++ z;%w$>d&%j+0lmM%kCDUmAwDEFn=6{dqKtYHJ_LNu45KHefVLVRg{f+kDk`3e-8j&+ zjU;**jj3cIIEW$3>*PvLS+=sV z9EerrW<-rK;$qfQ3tX}<3}RfYnu>k56Q9NCSu9%i`~7^V_uYW0YP78gDalA-yNvrakBGkk09EaaG8f^i&+H2CEX>80B|m9Acw@l8owEEPS)-8*$oAq1&< zO9a9MqLUp=S=g7)GmPWQ>}g}@M^DxYKX{B5#Dr2AgJI}Ll%?z%qyrPFc8L=%%DPk> z|K7{!aJ${oEL*OwHXN*1<(-RyBP#n3feUHI6bTdlW&&3Rk>Cs4YY=Kf7zxRsjYHd5 zSZmVt?t0A8?#r!jbP*0hbp47*B11uBaJek?nXwryqn=^P9lS5Ked zedO*t@6g4-MW65qUYuVP)7wZUu?;)WiCRM)760y^|1-YzYro3Do!fL7JQ;G3QDjOo z@~Uc7RDAsL56R=e@r`S&Zr-F_AK?0q-Lq#rfBb--?6;gfe!}fH-{#T1`*<|l%MG{R zyv<^1(cY7@Wf;eKcWksF3?tKOVDz$&*N}BAw)LYEp_E+ zn#EjErNS8s!P7LYEYa&`Ci%|z=$wdtD&lYAV9vUiAB=OkaH9L$v! zg6pDb7{)=`a>ZP!qQyzBt;K1>=p&0-1P8I~sFYSE2q$7qEZ7`VjFM)4878=-eq{_d zsj{YuYf966b^kIwCo=0*FXhHDkPC%c8%->E2^8OqaWZC@41zg^VzN^O=MLIwhH*q` zk;qSO`^w2OUuzh~UOI(>w#ei%FL#7A7NwoTW|4Py{ebrYXB|gJ2ZS8Ul06g*`=X5V zG&y^;wp3-z8ySUjrxg4B4udL!ge8l`f??=oN2DDw$@rw}d#n}|cvY7XO<`um5Qr(3 zw9UYxb|{q@1~1s5b|KH6_bi&4&2B55+00 z!qK%O-v9Qm#o2TI%J2Ois&ZsglSBId$$HaRP0#zh z?{_cH_AX~Xvv1^(B1KWVM9PwE3$dMeYos`7f}jDqp+HigK+)|((gH%$Hb7D2V}KwH zia1D+#zmUgaUI)ABw7|_i54YF;=asq8FFUM%$#%H^;zzvAO83AoWu5$2+eSK=6#>% zzW>X0{Vp;f(`I;nG>YZ%k{^8g`%Fg}`xnk}Sa+H37w0XEa_?7@QvWLd#@T#@I77-T;|h+I5-UPLloLvWt0=>~7T_#!)7 zo1D9No}*PQ*4hRy}nu7)VWA~{K88iVSA(jYlX;vDa%115#7N#|{cceo!{o%T5O z(}n&Fkjp3zW8i*#?SiDLik0_dA#XK8B8^c(c!X~yRhUULG2y_UNGtmeqnGS@@7Ubf zkl5L}p)5;gvzee#asyG)*{scyGE3a8d7jfWEhdxJK6H*}zWpq}^z&cfw|?i3c;|xy zftHyZT~vId%302iiRoa^l8<4Ooan3Nis^I%Wi-YLm@d<@)Y4k;#;fImx~^HQTeN}y z@UMQ2_2CC-rP87>5RJijiNCF?lEcFXOve*clEb!(nM8^P8T*x^ir;_cSqh`s+1tfh z%jU)=zUx>o79!(J7pYvJ!r_rxsmCr)|QNSn9gN zVC7=zCAOL&jczuZvAwf3INAHpMdax=#pA|dpLe1x)9E6S+zUx?YfmX$#BkXoXa+`D zCx@p=2UUy#?_#o5I?pZU^LJo(Ab zunI!S&}iz#Odzbr42PcU9Li{RFJ5GMc)*`N|3g0Yxu>}I?%UisxX)8hJ(_+hR=QB3ru$Qc>B#axOe9sJ9}p+rxQZgkrxG3RT9ET#=1#7F`LgZ zX}U8|v#k}Cr6!3(%n;tEQd(dL0e>nnwjWXV??KPP8!$+z36NZ){=!L}jV-ak`D8#l znR`2*X4o-Omenw_YTGW2pJYb@F3rrPYU?$~-uuB{(A0v-cD}`CmMoLdBW)nW-n0bA zhlgA~zt3YIeS+Wn=Ck~@zx-U2&TJ)|)Y0cqc zB@)zJ_73w^%l>rAxi5Z>aa9UhzHJFUuzhBm%$~>l^iZYL!8*D4>$)Z@3(%VVc0!vx z4+^M?F{|Ya*E)93KFYVBe~I0_JvK|rV%cI%o-i{@w2A28Q%4e#9MBF~$RZ_nxK1t^ zoye^^8OPc**Oc5&MV?#QrlZKke_^bUZ0mX@BdwJ6(xv5j*LLV!VrJ`fu^F4=J3qA0 zK148fV5wP*$UI#j&qR~HTr5G$7|Li#yHg>eViYAak}Oyxw6se6s~96q8qX;OlgX5I zvmPp~x|SELuto9>e0bSD?0$3)4()kWQHTTeT7Jz5!_Bp_Yv|HAceR?MwK^Hk^dqx+ zjaK3>s-27neERH^lBI5+r{i2mi%?J31&me{g{5f&ZR=8|wZ-O=f!_K+R^(`J@X^zS zh*g^CrPI|`Cw7rS=^&~wG+pQ*Kwe~Nna_|`=z`>+Yol1LRwCQ?UQER)FQoG=R^`co zFSr|pm^q7F#@IR{mrhoa*^Je?!$n!ZMx|+2HC1MqN5%a+chSBhy2x9vy~2Z|BhFuW zn2pUX9=d*wXTSFY>UHGT{`_C!U;Xoc#8>~?f6RFAA|`5D-*9yJCcpTrzn(J7Eu(QE zVQ_WLcrqrU@ofhxq|05=`N+op4$2yy*gwOK*Is9Kc#n^N=4oP#yzt!jxO{G#Yma_} z=l|$Cj5c=ACK6RdTTN~ynbDX|-iubG%%hJzjxjmQ#gcb^{1$m`*xTD<^TK%!4`-<0F$5;#3Y!Z$ zCiG}_kW8b_5$I{!g%n~@c5BvHxpd;mPP1oW^&{LKHx+y!DB1U^AY3k&I()4)K7vvP z^R=)3>etWgZ(&kfpXd1yDrAgdu}IF-&J9}oely=!Zh6u!`vGO326uW{7RfJA(6w&p zKsGivSuB<(1}zm(1TI~^#&7=P-{K3O{|vF7llwJIY7fUYpt}`xYeKg`$A;W^v|poQ zgT)bjL-aK&wB#yaeMe{;y5*c$FK8AEGOIW^IOgp3nAo(kBWXm2xo#Rj!~oXzYBNI& zPUiYX4_I#jL5gXxSuJVSbG&QN(Gx>Ul|?euu$~{HXqg`!phAlZEhaiL<eRZ<|0HvQZRr2;XC%E}}+yuhO( zsvmEr?IhsTE;y?>X+1;#tuoH=`jQB{yJcz|kERh1CY(n$!myI?#X(=^S8KzLH&)x%}touh4Q zs;U^~vAw=ojEV9E*Xx=*E5xr61e;!!8J~Fa2|oGhPw~I~z28JtyDXZ((R@t^a*3_h z4Ncoo*A27zn$@bKZUfFM+SGLf0wFqlXmPQ{g@(FY5w*;gJLmBMnpXa|ZUplk5(vLZ zMCVx&#Dke=@A+Lj{Ah>0$mr;%Ckp0=rnU4+J>b&?#Xg>8Sh$qq=yak76Be7+b0 zja2u_(6-&6{_B^VJ#C>!tn`;!4;EE1Qdr5|DDxabl&t79q7jlqOAHEY@+40`Nl%oY zH-`#%fBVhQb<xTOW z_jv#2TYUeIpW&@nUgW(Suk*)#*#eU;a?LbaVvr!1GtK}OnlCHblL zeYsqUVkuQ-{mglLdz-E8EkIBlb<@bp9~AYfWxbm5U;WqrIl3tM7r*y!p_~#?m%`J+Iwp*ihOva)pw=%Ff9%D!$Hz+0>Tafl4;0U3m?P@w#qYaEF6-o=+YBa7G zkH^D7G9Fcg=s^Xt%%rhUmzLHUWo{^}!bZp0y&XomW;)84jB+%dJX07IuttcaqeqakB??Tn#~G=%6Q?YFZ0*`v;TeR8=sGcVE|+UUh=b!? zSXO#CocdggvM6N?)4AbNw8l!{Q(jOM!r>yB(Y)_y8v!Ned5P-+M9q3#C)cdZ{OhJ3 zD*9d)tU{#7GBT~nPf3IOgSxN$`yGq5k~4zmRIZ5|GMPm(>Q836JqcE;QzqCj$dgs_ zzu2T!vZrRo{fp4=He?^y44qAHs5)I#^*@VF%rKQMK$8}cMNua7N;SNoCr;l8I3|-( zy3oWZW@VO3OkIaDQVgh`T##odD=+ZkcqWBdQ{$bJ9Z?XU3XuV%=VKtG?;+3g^gSg3 zf$qmFDNV3<@H_EajK?DxdAM!}d+RR_i2@cmewO7-#?t|n0i=CTB;aHpmK)HKQB^P= zkI1Y+Yk2$So5`f;xcSzb6h+SN{yDBb@&tQluduVTi&2r)vSDj)m+K#WL=L^I;$xrs zEX{hw)vJ&2$IpC+x8J(SFa7eLMJdMz@86Q^^wch@-ve5!K{>W}CS1FAku&>y933CB zxwFl(UNGI+G$z`BNx@-CDb`I5 zDsp&u$b$zn4jwG{m0$lFtJNB#4Xf3hHqo7pG2~TAF|OF!-^XEj_x;;+!Exr?J{wz8 zj%UY=#}yAfd<|nF!O3~*oh;_d#FmTUM8y`oWVNU$CP||-21Q|oSJpPoha|oqmb-@` z-r$5Irv~@=X+@(=c{;l51RCf%k>hG3o&k|Oic4A)8Ijq1M%%V577LmAW|Go&f-+g8 zhfTEQ)`&-8G#a6lWR1+{Gn%?dQoM%kjm@MevV^E;*DGSP;(z(uf0Ora-{<+4-$UhN zX0tV!&9PczG!ieX3}t>2!&$ejoIS=!9=Vcbo{E8bwMZEfYoaQduNv+j&T&B`tUd-N z8(Wxc%-UJJu9$9}BeRuMSJNHJ@r22EgwnEbuc`v?9HVN)(b0^f*_>BjdyUy_L0z}3 z>UyXUbT6d?TGwF;OSJNLF3XDXWK3pC#-nYF$!W0oY?~jx{0?9G(wF(@qYq=O>|R33 zKM5#oE@~%hjV#V%1RqnVO#UG$UWft*3_)i3MOF~J6O&v_Xm025os1kqi0Qy6Qa*Yb zjU``2zh_ZOOM;)5P#b${G2JwcXy4nG5Jd?#sQhBD4of5eAB33fx1h5_?`>ZdFhIbu?bRWPf#Sukmy`Hn9z)o)WVRc!-OdL3)&`$v&?uEiaalHK9Ch9 z+6w1MDs9VnG{%~Ys;KDN4uu@XXf;4AjWKEAX++AYZ7z_VP>Sl4T^+z!u$QWA+9 zl2h3vb7c&XBF|YbSJdm87-T29sylAoIpEI00bSb=eTv0Svg|00u{fJEP1KRmsG=HK zoL3kE#<$GsCA;S@&;>``tys1z&RsoE)R7ns#b`ta%-0QT*Rkqaitz>?+ypaJarfSR?jId9U#%$ejPYcQ%?q~n_SinN z&!dk$%8NhwF?GFWV`GE)@tk+wd>voc#LlB)NZ)_NMcCZhW;`14>WeQjJGw_TsyJRR zIXYT!=e_r6gTu8QTU%SwS$A%TaFC38F~@2v;v$rhZZIACgUH6nKF``5q8Yjnt;NgJ zwjHkPQzrEQQPaq;E2U@~$pNxTo1}S{LvFQPNz|fuL#1YuOMNmPrx??am_7{o9&OS> z3u`D0RZs6z=-QL8_8yw2l}^3|0~(657yx$XceeQN{@eeOzxlWR2R66AMjH!kvB_#B zlvi3u>!qr4DY{cR@ka=;(t!pW4LYHZjW*&PXoUrH@$5cryOIp&)tXzk-e%oIMtl4G z=*K@~XKNd+0=c!k_4Wt+?B_qu##rc->-AbbiwI380KMIDCQU= zQRx)58wgRLkG|{BI*PXim&|q7kTg9w^7PbWF~ntJb5Cx z62)oV;~-ADKGOW&rb|AfZ5xb{F;CY8tWD;lJf~|r8IPn{xJx4%=Q~i5qLAOCbzg}H z+F-eqy-eG*LUc7JnIS$Q(v{W_+yD#A^IUT4qb&H_wjs|;n(jnq8$u9SD^V&dC7-u3 zI#tlg?4;%ICfO}D+89jAArUWzNtdHfqgRIX&6xH8vV-CO*QTRSa_4)TeQqTq-g{91 z%Ec51y}b{f&UJX)pj4B6Qpz9E(!q|#Cz5wP#3AUk4w3zfbRSNZ_KAySEa*TwB!rIL zFGGpajT)44*2H0H96~_HUXCr9F1~Y#@z!$g>^^V2^MIVu5JY_xx%27!mGBG0|WXz5k?d;OfQ8z2@zVj9#6$Zs*gRR|t zmWws63ux=HRtf}MOYn|rG~)j8A&-6hNx z^OyeO*S>yscS^Hf4Q+Vawvl}HW4T*Mn(#vOA zF;E}-IeM?pS4NgKHp_;kWmnhi>~8XNU-^0d`M>yGKDc`qER3cbcx7qQzM%7-rt5?! zl&Fz{{;oy!)Vf5*70_SPIhp_)o7+?c93LN3H#PSU9xxeiVzPoW+a;g-#7AhBbGA3e z?C))}d*&Q>?%d+yxqaGtEqU3U6Sh~D@!-K>Qh?;l7V~uI8c3H!-L!(e(utnRKX^WjPcT z3Q0M%)>1cu3h%mxsvISFpB@-Ty$HZsNlzq=34CzLW!qe`FWb_4xXi$a!``y*r#Q+Qhi2ym3f;^j`4j^Z6`gmM3->n&4&psHNSaZKY#OaqD>RnIF$7^9)oJBVH7VuJ|%Y z6{panjS<+!S_1^njIDdhQm#=X5?j5FETlnb7i;SJkZ9wz- ztFJRIN30f0tO?wC_id)zd!P(QvqOI7i(lleoA0n%H|*{1icH)XP!Vkv!71vsFxCi~ zwr<(p+~WH6hnbAWxTc|AH!PPcu3WpuXf)>Sn>YC6XFkp4M;|3-7MtZ%mB`quqGC0l zvtF%4V%vFM`RPwMcV;gI#TjnB^A4Z=)F=4ibIF~K#HVdQnH4`*h>Iy3#?FRQ#uurFJ4xaps~|T zyU*@6=|S-!BrZ!}S+AH*r|H2HxmerEExEBVWwlxk<~BRD)dINdf+q$^49v0<7qwD~ z#Sdh{hJLXvs-38MoOwB1G9sAB)8bra`Taki;M{wMD?}2bNCkZ8u&VgbQo1P0#7`6c z(0smNb2?(VT*^g*>`{!B=cYG3_AIBiZPJV7M1mbWDgY`HTujQUEd4AZnrNC--G`1W zD+ct5>pFR+yDrVE8PF(MI{f<=aWo!_47Q6 zYZvnw8yjP6W+{t;OAkH7?(RPK47;kP8ixF?W_7)pk+sxMsNCJa#RdMkC zJzCeXv9m?&9KP!?dCsDh@8+S0uJeQM`~lVo{^6<5e}+8QxK>8^))<14y`Iu)@N=A| zF!ZSQ5Q5~^Cpf8?fJLDdJ?XtI(q1s9Z6&_D_y5Eof+z7_d7AYat5q@_xsE(97*EEm z>l$KAU_fy&woQu}+M6JXlRQV!o~le`UaYlXNxTn(xvOU<^{SJ)UZ2r)95Oz@q@=9by)NX8*v7&kio zH>T4KilSt(T(DZLSgn@q>}<1KuE_I@rf!&SNWAWHA-kavBct&I-#HE+JQxHCd;9x5 z{q$$~_-8-GpZ&F8;llZ|9NoV|yZTE6Wx{ia$3av=?*9{o>3m2*QYWkx)SDi*@ zHlgLskejYR2|`4QBEfuQxk+SWNdU}|^v9;IQ{IJi5y}XiRDNy=sUnIaUywfH`fii_ zQQ~@wwD?Y#8?Eul-EUK8ZWXCOXwxnxLV`iM6zbGQtH9Hm&cHDzV~bY08KufNI@PkfZzu+)6#=C6X zzQ^Uu*9Zm(Tb#$3K~h1XwugKLjGhR-rKc6MpkHDOw^ zn9s?xf`t=gdsU3NfA=0+8&l4oKSyvKCNn(t#1pJnOTPE*Z&BqL^>V?b3ukGYnh+F) z5t4ak^~pOC9QKrh{4}()r|OzaJX%^s5yd29@n96sa*<%4qW0!lh9ea=DuAH!4rc< zkyJf($L`rPtXJ!#9gjmMwKawZM@MJ{XSX*PSO!ROWQ1tTfs%t zZG$$Vmhe8{TAzNVnB2XAtjJO#oEu&kZH#b;yiYVOl@w2X@J)s5dKHmjy5TJ9-F!ni7K&inqF&t9KXX2bDM1|3o z6viO`tTmkr5ENPkF~2E8;~L6QHN-5d6q_WSMmAhNQkg5TGG6L=ULFWaI)%3DM6eMR zCNB~%C{OcckID=&_+j5ePmoUBE_@sUc(gXOjc}2QB1_Q5fD1~DUa4aXA&?i6{1%8P z1;#>diHd(>YOgg@_6Yqh!=M-Wao5)kCM$ zuYICdvXe{{VWTyLDp;>2uVqw?scV@`^hUNA6d`Cqv}?^|V}x^_RlQD}Civ19KFh!S zH{YQuOaApg`^WtC|NifA_ue719pd-x8CIX6CXv{5%1o3n>;VLclQB1n|s{7eT#BbGTGZCK#o3m%nq1QFJw%BpT(ZL7I zj*lqI5zF}@yJz-kU7)EuZocs%zx=hY^1}E3l#hM#lS13ZqjSr_od;}fZcwjl?08J@ z9YJ|EremfXBauE6$yK1uOK!gMQ{3{9Jl8yQ`8=1-?^7=hQAQK+Y0uor_u9$m*4SR9 zXb7PhRt*{{s6y8!vH&L>ENf*cFQ5f63mY?}&iWwv=x8m^m6zR;U9DEcIAy)A+1lE| zIU$YqiDf-#PAMhPm1!0nW3MHaIeO32>RaO?LDqbVfR%Z-W>m=wJ{pZ!Ef!)hbRMlO zhYyaDxu+V8PW{1ORuy?>Wr0@8+^}6Qxb@EQV16`4lJRXR^Mq9esl6L?(;yEokE%h^{ z$+VFRsOS3hS?t7=ZYP-}Afll!h^-smv0g&FTCFI`G8t@TWL!5b3QeBnG^=Hz{kmk7)5M@~A<}hTl!4I= zokZIR3$6eDdI@w<6m$v2li3WT49Y~DYfvGhZG#jvxut0(--ncOueD~mS|#&il8mQL zPAI-vG7$=}QG{^fnR@i$D{PIw%gRR%?!3oqFMN-Uvsc-@eqE5_nPIxM!~3@nxck8e zJo&Lta_{hnci(vj)io4ViOma+4hCG~oaZ)@Isw3+DqXk==n|SAG7HeAFl2--y5ASp3(lzeizJsxvcW>Y0PJWk%AA5ul91mZAh}q#W z$M+v_=iq=V7cb&{6X>=*mAFUY3%2}4NUatqDh)%!1=V@iu;yQ&65o}g6Do3<+ z!~A$A61b$0s_UA%X$DQc9LzmZSZKU;(|!oTCu*#iP~$0fGdN;!+`D&x5+1uq>yl2} zXhoSPLs^>J_GXnV0rl#-At>QoMUoUX&FHGCq8g0^9~~W&oeg59DaIqB(byuVNeJr@ z98J3h#PNNQOZ7L77FqP7;S!-D@mm>L0 zbIyLIy{^~8;@5l6bUL9-qYBCPkSaPxj|&~es1ONYDgsE6sanNk=yMp&aJlz!$T3FN zDYdX)M)xqrn5eORvS05TBF*pnwO&k zJij|7C+>ewZ-PZcpD1h6izhEsW<=vI)tOCu8p%-*>3SbYE69B3{eZ)^HXF#sJ_$ku z{oti)jEXETX*>TRGj4Cxv{v%uhwo9P9F$BZ1ywapf8QQ(+6|YKBs0e0-=$Pqv1kq7# zY|{jdih-lUL(m%6Hk>tQxn;$LLT852WW?pG z*I2ApJh*>E@ZcOQRt-P?$xo@*2@FeQvNff4YdnGNGkbWgXrkl7LsvO}CMtSxkpZSU7L)Dqv0|En2g7%?M{GcEfKX{Cv;GSgg@2w3g^1?=cO@DS)^`E@$8J6}3%8m09-NbiQIj&oi*%WgvRqC_6!-F08E1*g%Q;7t7VB*? zs(J4PYo8uE$#u}f9;9jNB+K=~pPOZwC`kt3TW@GGL)Y71QoTgHR}q;^R8_%?hry>m zPbCL-j~g+kunU4B4etM8^*LB13@ru^TJ8^!0m$O?FzWMLI z!O#8jpQqZ|Co_iiYRz;i{tB%HyVNu-^Z6X5727-8Y;J6@Zd*2{n?ePzD$2=-3m4CE z@zQz05PA9ema8Svdq(34jq{Y%2JgK6w!F{DD|6|}MaCOrHn+DJjYdqSBZ!gZe1!{< zdO7Fv)vGM)hQ+}H>ZWD?{Q2Rz+u7Yi7nb>A&L4m48OFx&!Hsta&T;}%9$Q+qs`zrCWc}BP?jZa*HSm@ zA?&_$vNSxAfikp83Ptb|(Q30(%9oTFSe6yO3wS5on%tP={#I%F5QRgp1}Ms>-v>($oz^$cvn&YiT=C_XH;!4noR%&#Xl62GQ^rg#Z#qqbgO4 zPTI_REwW^UAr(+qBVa<64ho$VH$)+}h9L8Hv}nnVma#+#F)@)yJQ~sF2cYR{lvXmT zF_MI)NMB~+R@pqm2VpWbZPTO3hC?lKazXhd!CoyFqOvLqnpRNKRavo^3zlCgHB{0O z@xJ&VwBF@%g)%mEC|)iX)9)~X>?xE^bJI9vkMu!Ry(gw=+N6r=qmTQ*96xkrz34*Z zrhZu752MjUBi70cFMxACh5X2$*`)~PJTE0DrW2%z?MEDLF7g9Cl7w0{$7J1U(-EnHANI$g5>cTi0tbS0*{K z?;O_VY33dVNp#!i%%I4ul_IYw#Pu8m&e-okUDq+HDjt9QV|@2}&k;}@9zNjR8*lRE zFaIpjWReTCUJV|r? zA~rS0_wI3gc%O?8UuEaq1+-GU`oeR(@W=laQ{gD>u0w&S+5oYqP-8z@#z6nq-AmpLwh6o_f%zAOO|KpV68voepX3hmRU*YE6Z}I zRQkEON-7kTPSw6g5oKwUjETl-+N8O+#-6+!r_?@@IpI<+bY!}*A@g}{sLB$bf{5C- zqo_(;=gDk7pfH?wINu8TnKTDntUd zGHVA#6@=l^iop#RjQ3)U>LYoLWN{LM>I2+ld8Q3zDZ35l9c5KORFtKRhC1gHIX53F zUZDw_0qxu``1{z}-Z-Yxs74z>HuM*YPT3sR2%XfWs(v(*+!veMfrSKd_zZhVYlsPt z?oZTHS(ZsGZ*q_et-uWlZ!V-@jw`3S=VB6RO$qSFFO1bE1D$O zr*SW+li1cCw{O!3R|+*nQIr^yC7ppD666GQ>C#9;+#~){Fdy{-~lbf&ngz@$U*FW+&Pd@PpCVTr-CTFWEiLRw?R%}d1yno{+XU?8w z*{rFmk>u`dOj#`#T)TRev-^AO?(b0K8OQhUqkJH@8D%wQGTmUYSV-rV=X70-3XyiT zMgi;9lIVK?oevOgX0;Gedg0lcXk<0@AKnlzlp}tu9y7mQ=i~VpZ^5)de$>{ z2W(BBfY^7J&Uu=)9nK&k)J;QX(=yzMtT70Yr~C=`(=L&>0s+K}@WW+D>Nm;v1-k>aZw^VHwk< z-;>!4AG}EZl)`3NQmvdkc>Vl6YK2Z!Zk8m1MNtg1@4hYTKfoqQPkY|I&GNyOtfc6t zEKBP3ib&*mHm4{`%Cf?BVlvd$5D7A-3MX<#=Nvv=Xjz`qdC#h@@j-sgd8tbKsvldp&MJz;;0r#*_@uv{uwz7$QDn@EC1}&MYK{vS0@yG)5eD2^Az`&5SPLDSFdwU9V9I&e@Nbh|*!(q|9?ZqyV^nE*ufznxKgS zfu?IHioVN;i2@MPm{<-(mBx+|%oTbl|CKl*nN9HYq=7q33bor8Pqojc2oS6=1q zH(uw)i!X5VLC{@F8l($8PD}KQSFiEj+wXGW(nT&`y~gr*#yhXQ%scPAg~xO4(T{N9!Uej; z5&!C+{vSMe{|5M$`TU5dKJgg8_6uL6YZl^zLMJ$=Px=Klj70m@Lp7=rD@zJ3GrUip z5T!GXQ8Sy*hcmh_=*6z!(ilsBC}_?37N4dvQZ(i0tU&#f-~9XWqt`BQe0-QXkSJ^= zW0GP(^z?1rpsg8Z>`E(KBlF&@$OiyGbc+B0AOJ~3K~&{c4`DpfAw~4CpMU zIkj|kUgVDoA&$lfb=wX~7vU~x!9b)@MT{ziD8(U0wJIw@gw<-rXfjTRU*a+aCma_7 zL4nCjw8i0xaxUMZ%PqIE7o_xx}uiz&?yhnn}o zfU-7c-Ly$RFBPbGG9uAfV(8E+pn@!Q*Xx?ebWGF+ltmfMYO!QgjhHRhoH?_{-COSo zxKYa&?2_cTb7ANPw6+QUCv$$4_5)>+C--%bRK1k3QB@Ttq2D{#rLmNZRNAhSt}j(R z#%Ma%Cb@P*+dg=#OJh&*2(44NSWM%_-W#Hnk};9@NtoaVV!;VmF-3kRvs|w-acwso z{wB@#V^aS4r1pv-;9bD>ve+mcTkpwGsc`5EC8IT6NP>doi#i?1)$d*Ui?$yLbe&7Z zkUI&nLPoVsE7+H&si_ibU1t9=k#FUI=nF3)ton4J2>7x~0YU?ILJ4|8a_)1YigckJ zH2h*NEs0*DZ1c3gQ@w0gi!5L1R5V70Id>!u#gNV9-|L;pc~KC9V_mP9Or|t-DgzuQcnL)oMZGC7xk- zZ%00}pg4SR#A3c+cYBL-=g$$fO2*5a);VIIla#)5ffvfi*0mi+M@KY^CDpXz;Y$~I z|He&@=1b3vLLfhjdM<-zoxxDX;u(3oHRGe7tBOXv1+u1UEIX$hS$(0N{<`uSj%4U2Q7 zB32lpVwC$BAToasc&m*F4LIkd~gF3n^E@2a3^z8~qyxPVP7*A{h}mpGk(W5{Ps9ZDj@T^aZp1Vq@=5u}FxKiL2duH; zZ_xsHJU!lwiGdYiz}ZX0E38}bThyJ5sOq}KC?l19+Mjj(xHhHjskBpYi6bT5h>X-a z=ZF162;#-DnZdahqe!s;P}dD*QHs2LxY!L%E8U{jl7He862^PU2~ox-zfJ1s`x$kT zXXaVS(ea$tMMjfN@_fYcY{hu8P3OSclEcF}tCa{=iXzGGlZn`c$mY(DNYZV_@q7+O z_EVkjk`O}_TS>voa$U>PxseF$u8~o!HWn8lD@U}gV>B8Ks2S%2MIpKE&2q`Pv->>$ z$iuvI^CpM0Ia&p($}+!qhnp|_h#x%r3=eL<%boXb;8rVQ*Py$Gi3+sMic%|b6Umjw zEsj~-zsLOGF6-G5v8_42`vJaLp}Lx8IcHpC>`bSOjOLA(eo8Yt#;@i)dhIG%^!R2; zS1(b{6P?I%W80A7p=~hUk-3gg*Ju|hlqLfTrHH-mDmAO9%l&z$ATC)(mK23$v071#r_>(4^WE2@>JvKKs`7=NJG`?H0 zySq>4BNp*M=-kppPmCqM{jdL!Oa=bJ*M5b=!#-O zPd&|7e)hBAS~8P!|G_<+i|As^-~0RjkUTS|mdH7E>sTxn{5Su_-@vVBZ0}6)L5Q;3 zyJz@E|MdUj{ktE?NTTk@QXFZXL77jO&*yygtACCn*PPiuJB)~A3}jf(4l%`;joown z_V4^IKl$lPj7MV>kQvR>UwE1;7tb&qmq`}v1zC<{U}TxX1xH7Q&T_u>%pdW_t8b#U zV*kupKJ&?sbLG-G+ICHDHJQ=Wjiao_1n)kCf_F*r);Y&&<=EWZ0@dM~RdR}(fyCTn z(tW~$WQ8S0jnV}^L=I=iD67d#PP=N!Z6LRr&UJJ#lJ}Kd-&JNA>$+j@{5kF%++#go zab|yqQB^UYFGU%ZC3$Tip+(lhP?~NMR7q7<;973qzQx9*q^{R!48|5xB@*zylMW<6 zIoW2_IJ}3#Dstsep%L+i%93L}P*t1!n}7E#&%gLnSsF)C+_bJ^G8zfU(-_)%#gmUe z%+G!4Y38$IQ8apoEjRhjbI-G!tvS4ZA7vDy(THFC%FmPOfOqR8O37*4mW_=m&O??L z)b$FYOCA|TG2Y_$|LwQAdvHMAHazyo!#w@zkK((Ux(j5v6bP=C(P%XqF9w1D z1>nLQbk_f>U7V|3!i31lc;=5g>& zX|2#Eqf14~x?WM{89u2mqYr3nhW}<>lo+eX%Th=Oc}_JNVQtQ8xxzWeda*zeB;C*` z2oBdYRGH!zzw-3|OV)cmU3Q&!de2&G=M%ozUuU4Zkpn?OBtbB0BvCV?WQ{DzvS(aV z(u_tVl}L&Z2`~sCK?D*68aZ_Q!bv-=;EQ+d-C$W& zybz0>&Uen)YrXI9_dI;+=N_ZoYz7q7D0Y%BY=ezVy3B~aHZdl0;$%!}Lmvs`vBMW1 z5=v|QKq^MZqN*H{Y&`sCMoUOXDbNmQHMW(@zJy0m zBL@ekA$VrfDU&K=dp70DrMu$kHx()?isgF6bUI-+DY@tJMYgwRJoxZ~Z0+uIbi9yL z;&jU8OLvnQO;gu0>WT%V;2p{o{N-1_#+z@xfyHyzT^C97jKh;--g);O(2D*29T_)y zN%pjDD;NZ4alrLkC;ai3{*fdtn)@?rh#Lux_En-$b8^u;;D0QlY z>Glq<^Qw&Jf59icr$+HyaEm>wL z3;ExtMxlepD6oA?*Q{~P8rN;0Z^=wR1&8ljrkUompZFNR`paM7^Pl-BXZB~P(975` zc7&8x^tK;MuR|O?(MAFdQCHwY+`+~`kQt^s9|{@`<0xm{*!>tjGZI%C(yNT}v4PG# z9G*-ZrHSG>kkso9w(IG8$80*sT0ds_y@$NYS#Q?DDbjZ=a9!64xX{a@bZF9g@2RSa z)oL9DQ+kxUh%rt|Rnij?SYOjxCfi#){SQw=aD47FPxATC{v0MzeEl0Mmg_Zt@@M~* z|Mh?Q*WA2uoy}$=#U|%a-f{QEi(Gx}Z9ceujW2)sEBxvgKTnp$S)qa~F9=@fy>Gnz zCO2>1BuP?!;R~PUV;{MXqs4*;@4uI$<0IaF?_Iw3_uu0G_}~9GPL7Wv_)i|1?Rvvc zUU-&A9=MzPAGnV+EvdVnyqePXf|b|NRF}kbkdBbZVL#N=D*~RpEcn;|^0!#8)|}d( zP?FRGt9D`z0Zz#%$CJCNZcg&T$&l1#GvLq2(V}RwR<&3F#@kcLXZO@ypzsbiQ zd5DmP5v1v(AHZnCPhP#sPhWc#6Fk5D8^6ZcGpDf5^ZnCgUx*?dNp6|C1a$+Vy-61sS;FD4~E-s%ESRwb+Th7cN(JfkX8 z_I7vZJ4@#s4o}l|yz|a`?C;Fz+K%3KG3gZ-^(NuvS6<_pXa12QFDS|cwBp5=e@rwn8@ zZ@kH=gMEVQsOu$JE=i`Q?NLF+4vWRPo=^VVqmUSui(?@Mn4D_1&DXy1Ep~TySe_j7 zyTAP#WG0MC+a$?w*7MD$p2E5gqZ7XH^G~u^F8ScvZQgw69sctl{~5pg-~CgzrUgED z%1G-LB8-adYR&EAHBUYB6q}}@>3Wp%{Q5utW$M)l)2e_lEP|zzqzl7pRim|ZM7ny- zw9vRx@r$4SDF5VBA7iztWw+!UP1n;lEq&WjZ#HzTjL_Dbb;J-!Nw95N0)g3V&U(EV zsSGADcq=`Rj|7?V?LRXXi*gqd2b5&?zO$UL$;2$ix51$l|WA0Y$CSF zjr9gRVj#4ZyvWCtEAg{KOunXR8l(L(AxVa1SOA@BG)nXuc^Y>vHoiyYXIAEgV4l1u z8UEnebZeyH%8bj>wU#D`^v0Mw@xoz&I9vz$kR1+Sym2XB{~iIua#P10ilNA44m_P#OlDK&^F4m_;%mHi^-VT)&6Ue{^LxMZ>tuz%%ZB;8 zjclk77vm}laYMFlNN zv9q_&2iHDeb$pAx?Jf4VCupN6^HP@eK1h12mk9N2QgU>BOz(Pv1{)HJ$u|Gx&%cZY zE}lQn=Rf-dThofBj>O!E^B|GP-_2V`IPbV{{tO5ETexOP({n|I8EQ zsiyBXWO+&7wsIq`Tb9cewe5KHV;|@6#&yafV`pCSc4A0%LcMOOazjz%)b(bZoA+%^ z-&^Wl@%>6F{IZjHnUsV~d>S4BjU>n#Q=rRs=U zGA2dqlte?*)iF`0#`}TX!eneM-+uw2u?JD@MmK#D13y`wi`v+jcmqqu$xVn+avRAJ zcpu~0!qalSqbMq@wRqo;Q-Go8r-Bq1ll5jzR%9%XkH$>B)|#fXm^6ta7Z;gSPL`H& zrs$ldnpEN!P{=={OS{`iHTCibsnOD;pDA?KEV>+8te{h2=GjSd+m>1;(opq5t zRdfB;O%|)W*xK4-dv{Kvr464&*9zX_@UfpFSJ>^XIc>ecqcO#dr@!|j=vy9o;0ljD z`Y@a20&BY%{R{+aV`yQGbDnG0-lxdY_-9y*QA}qQnOD*SGJ|lblxVNFL>fDJRr304 zZ*yvY2NePr&YhEyNGj08uC>hP@&PQ%j8w-0s_}rOXWc+J-X+dk{Q3TXSyS zxX#}0Wr7k;+h)CHI-QM?N9Vji6GJ4+23T%3G+uGx(j{KM`UYi@qj7Y7BLR|9uq4x} z!aB#vYD3*xmM2RNPR+S|_bF_1!tQK`t*tFydF3k2dV?y$SfUNSM`=ZB63TLm5DCb6 zo{Q!;2oMn!Mxj)eB-EP?v&n>W7fTuKU6cy2UTtDXM$`8KTGd)}e6o-)+;}+byv19`^7=}qI59Xe z^ft6fVt^?6IRaSk$%}%fX=r^*5~J5<>}UvlRfi$w4@Mch5yPbmL`jrH*pqnB@rVy5 z(PkW_N}OGS8Nq{&l#lr3B|DNKx#)b{X=gNz)Hw#At4R!P*AY;1dlB6CphOIjO%=IB zy&a!EL&~L#F+!~kZDe2#CvE5Loh`nz5|Im#7exee$>K;7SVl7L!E*D~O?*Tyw@pKm zE4n`5RA9C>Cj?E~cT{BtPB=WnT<^@8(==_v;o%`#YwGoyJfBca3gP$1zwdxok?ekg z3zpezj#0t``~LH<;BDZCKl~vN-E)y(TgtMaX*=*xWC;)5e-Dp5ct7*`oWr9dPL^wK z-M$?Qhq7=LluCz&5ok7cAyi;KpU2nSv0R?8y}gb1p2OQmcn5t5*kHN$?tA4bE6qsK zB!$*e#_Q?+{sDKLKZEvG(kQtiIFEH5eJ3+ZHDL5(flq6VcZ!=gZ}ZGQe2aUpT;RU@ zuCQ7y*x%cgQ>zY?X(3;_A@ZP%_&d6`XKQPlrf%4pPw4xOJTJ+!lwc8b!~ULdwzL}G zY*krOZyJgsr>Sds0~QI^^=xgOVm6y$63ue4kYDQrKi~BYMPA6yp@|79CEbuLp0c$u z?CtN;v<=GSXq7UX&SGap-hWwE&_-1IkX%8_B6M0w`K*s1Jl{*`Lu+Zy4cw=qELp5i zaL%*YtU-Aaqv+b2u5U>a&Gj2MxOicAG!YJt_O|O-+&*NpX{aJBanR|Gosu*`DZ|!$ z#_#^lzd$QZnkj5VfDjkPiAkw9Ev8DMhg_oWZPVVt)>w;mf#vZDKYHmUOk#NaBahK+ z1YjCGcFX*%Tj?x)=k5uOLz0!^FQRLS6`QXVsLodLP*JSftETPX_nKvz>BZE z#&X@@yyL-p?`F)!c0XU%BJ2>mE|4VW{jAZ zc_EbotqDeptg02neNc*&6!~$bB&lM(YDbDiR^(B~Y+{+TjO9={RhL!C@$oTLRYh~B zjB^HzPLh~W3^r5@I@^yR&&_5-l?&~_B#EGUw5Dwu=_E` z+>Dl+L%bohbt64IrQ>@hj=Zd;-qdVwZH?;aG|k5nbJt4}Zn#NFr9d7yN`*KE6)>oe zWRM|5BQa+;cA=ye;pqkQl%kaxsj1F|F~c2#CrRWlI;|$-7$wj1J7cbx$Cr|tJOu}) z(q^+kDKo}jlL$s^>MkzTLpP$};EVcuBvMFUZM!&Zgd=wWn>?)Crv4-(#^{ad2>eNi({(!+AFflSGu1jmaTr z9eI{X_&^zgw}MV_4nxAlv*%GJ<%L(T(mTf^4?o1o(GineTK?8Kx<`oxWMBdeUyX!ZF(zTY#vZ5LJE^4KEM=3HN&JOfAC-aU$Qjiv!DG8$HzxpzI>Nl z`%OZc$m=(FG6;$yFIlfPtk-L*NkMD-5slKWR?^(hG8T&k{@fXQD_D`f@0d(V(llee zS}~hW$+Aqw3VA5EQmDV(4y!^uqt?ljYC|5JM&QcU5 zMNyI(LzbnKlZv1eNjyOgdmHJ3MBKgB6lF!8XRKCBjL}Rc)ws-TyA64fMq!X9%M#|> z+x+zEn{4Vi%jJf$s0d!bj7=@69bp~GZN>t9P`v*3d(5}DQO+@+P9nbElBmet3Q)HV z$|PLAbcI*nyvldJ_g#MY(u?wb$AEJjoZ3N$o-CDUw{@aXP4b*;H*WIst5@m0XTCk> zg;!p}knqBbukdTX@@oXuH#*?E3J3Bk1$(^1LO5N!%4VZs7fF(CnRW){OhLP50vl$Po!^0$m zKwcK`A{t;7$xtMG)cz;yboRupE2+uE}IyK<LFT# zPEGou@sz#ehxWq<$T6Kw*wmY!0o)DM`Jn_RC&Xq<%?#94S;jKv-&d=ZTmh3ru0UzZ zYO}`Kp6PVL;o&h|&{#4Ss}@YoVzHu{Oz65+GVlsc77NL~XPKN{lw!GBlII!I=_E3= zB+l11?YOg%e|$+(cRc;=??5p8_1B)_kH7S1OlC8bh6nGz!g{?R$r8$mG_p@lPNdz5 zB2P1d?Ws2lSu|T&dKr_DCYh8m-JQLWWUyt-W4(O2yAY_RlPGfPF=@ipx8CJ%zWz7&zxp!J|E|-w(Yom^CnH(vAes)=`*J=nW1w%fBm&@^7MCqNLEy6Z3H42 zxlTjzOdEssaJ<~`{`KqZ?Ch|=z0J8Zr$tJYCgiz9Cx@;CK$;~~lPOtNNU<_Vc07bY zY7AvwkY^dI#ggC+lsW(aAOJ~3K~z5OeN1%7p1!w|ic$hI%<~KrC0;6)-I}JOvpsdw(naZ4 zIo;y>&puDzTFxGvCQS@kDr}~_$c3GygiThIg(Rf3d}%cGI!dGDeerW2f1J8&;`l?p z2Rs2~GJg2Nk4dsz9th4esiu_Glxk~_KmW?tc=y_EPzFzsQFOD3yHiGWYBD%;VyB_V zi_sT9)CP_hC!?x*PzMhbxDLL}L0KAZbjoKKFy?F(!#21V3s71{JL~dR#-aADR-4uWjs3X_H9WHX`4h>%{?s zG|s*6M?RbUN3_brgOf<4NK!*$jKTJ9EHe$)G;1AMF51v8HnLqr6o_fij0Dlq|4@`< zSu#2yQ1Zf#SD`G8dysbgvxW!OY&OFf!&Be>7H_@vCTW^;vN!>4ICW~DYwv$Rk!2LA z78gZ)^RHGbE}TCb9lxH#qa(^xn8VJxy13& zAxFn2oIQJn-TgfjUd(fDcX*n&9?Z%Kl`t^0Nc|!tCJJ{=Bt0rFaG?esU}rKcMD#l$TJk4 zU-{?1M4IJ1{ml2ce*HGz{qD0|x%)hmBBhL{BdrDeCMLUp(mA(|j@jDU;^7DHr>kp< zA|X-Wl;Y(0h}mpALLDVd;=E(ETH%7H?Yq&`>v0kQ0%FSE1rI~Ex=G!!l%z-y`oKs7)I?r-R%J?v%6;KM_TXYUO+*wYpmP=0W zpBhh?X_n$!AHS3&irBTSU>XK@dYa;qg6KfpwIQ%-YE%=LOegr@@qJuCj&$LUg>OfQ zqVtwXB?l-UJbi0PO^S2!P-t6=$5A(pj2FBaX~qIN^n_rdQ%B1Q-TBB?%Wx>RXIt31 zV{28(jJKi9(Op2$(@lI-z z@gY_>HB~hs%kuaglw+-+VWv|lB8Lch98y;D-6Zd&yvV6HwY*=3F-~;UnAD6*XVjgW zXy5ltCKI{U#8Fh&^%RAnscW{jw#GD<{CfnFs*Ds&wNms$$10A5?l9ftW>4e~Is$o; zVDg;Hm+u~LO2l#Bu+$$y2m>i0i8vA)=@Uwu2(^@SSBL}+r9dkoWGF2?5Mudo2(AqM zkKsY3v_>04YdaDdFS-$>Eu5m3JQGB)>mvwIxj4_)XdRWbsri`~Kx9-MlgUijSw1d- zoH(X)mC>|yOxHN9m848jmN>T*l4%G${KzBF_t>uG`r!iSJ3ja1Cpde0pRQe#C&COm zsaGOSN($DC6+Srj_RmnSkI`8m=p@E4<%LNig>r9ikL7a7Fa5%&2`VyXl;SIY_Y}|m z;6?Vf=X~K)A0rKltSCj?giu$VmHo}6nz5-nv{n+Cbsh9Bp4hW^eI>Gu)M)<-&eFFs zfA+S++K$EIn7fOdD|cVy)z{x*YiFCc-}`_E?!O1yZb(w`$al8o%H@mvTJnqJS;mcb zKVWZvk57H#ai0ChXPM5Yyz|~Q9=PW&`nHXZ5iyV1PB0AXcEg+Ryp4C3E0-^@+^pEy zJA+cf0Gm!ryl)Ai;MLb&=kB}CVT@t2wS`lVS28;3+m0?#FChIvt9vIG;y8Uq?e zDL<;9g`3s`DeqsqMm3vZt;aga`S-nyOHp2Kz$%HOEAf)txP3@gW<2u9{Umg_5ZE+< zy}i9ie-=bh2#%k;`Zklukn*+NeC?9NEEh`y367r#z~_KjdvV+c41 zkSCH%Gigfjnmj4!`-Zk}#@m99GyAS@WE5-+sgd2*;C^=zsc#}g&_(W5RTeVN%F}2a z6+J@Nbj|7A;DhW1qP22ZOs#2EK@nJWV7zgD;o=X|qdxs59sNr!PEIZ?r z-pUO##KV#RWR0W?wZ{8i(s=D!P*a&ucU^A@3S3YqcZblchh2s1!Fa6KkvEu#TgQ9) z$e{BffOVv)Mg>GC5eKRR&oD#^1X@MQt6Tu|bZZ;gW2uo!J3J%=D;|WK)OX_x6GC7% zn~mLuLD)06JC2Tyq8D06M@TY8eupvAFiRT{&$DU8rMu3vTCVuzFa8q0`m6txFMQ$i z?CW8a3{AZeE|3rG@9$%+l{u%; zqeC#VzE5o^2G!G*)M*Yk39AmN*k>6q*+Fi zWQ1Ut&$j654OmOxwwM5!GE9n+&wlzdXrp=ez4v1rPRCx3^m(MrX*sofz;~<;jB73`$0DslbsQcavRSWC+EZ2;NunrV8ag;3V!Nxdl+){A>Wma@5NG>vS{%xGMK$5Aciv^OIH9Tv4h~K+olcodCZm>M zP&DuC?DGD5@3UGhdHA6Rc=Cx)kjG{9l}mS%rkT9&o0g`vQq)9DdbaP_+MZ!u&%O8F zgE5-ftRncH*|a1v(#F4a`!@5fEeRW>$>?^m*8Yt7bhFveMoC!+M1*}pU9ai;JC2j# z*Og^S*LN%y3yPv(GM$W}gnF}yUhg<^_fD44fzhfdKAR{ii+d}rWpO>Er}F#`GGzEq z8f~2Wd%F}xF#vUJl7#E9$PN?mRANycf!L2*Ls!mgvK%SPm45 zs+yv7Dm&GvOORsWhsnQapYFH-KbuFo!?hd6_u7_f6lQ&0yhpsyU+V0GMhaZkHhS{Xx z(p_gzq2&k9{eZ6Pu>rpD^S{77S1yC^30{gnSza*To--*+rju%P4>X&NIL?zaw!Wpd zFE73jNT*drUKH%?pTb1rT~!p=uA{0-cDA?JY}PCnD-I8j@IEk^Oh+M-_m1^uNgX{X zhet<|H4|xWZn9IeR|>jEmt$clo+V#Tzq2;Omc zcst%o6q>8?2x3A zgtFGgJ(e`)(_!xszf7~lV7rFk9LLAUk+&v5LZa!C=xFT5 z4c~F7J35ASy^#cD9DxlFAn~z8^nX+WD5YrXMgmDn#mKRZ_CV>l93GvF2OR6&okNa~ z1?Je182ZwzdQOm_8Ut zqY8uVLtKEXn1ty@5Tg9ydOTeVXfb0`Z!mGumu1ecUP3UO+B;32l^89uF{6z1L-LZ$r1Fr@ixE5uuv)EY>YC%DLr{JcnGNZ#E`}0@ zIdz(*WF}?3T$5-+l^5*AxV6!DmaINRJwY;{ixivHhF5;_3Pqk$RT+D`+f+rt@zF7a zz;d-F$qU*pMyzAE%S9+>o|WWTMb|jAPB}U}mXuKl6lF0UAYDx8bPTrM>0ve;XAriww2rL%MaZEEzI)-tR^%6M9i-MEmWBTZ~PvUK6ak3Z}*2AJ$ zf==PplkrD$%Lw^1WRIM)Rx3}Z=wwvPig{2`zX)z zlvN?*T&-y$jCKf1C=JtjNx-pOEh2p6L++Re_ZG$g(`TvF}96MBCX*qcSi` zcGl~)C_(c)GE~AiU%O*XlZcs%TqA)qR(K~ag^L%@k`@a-^XX6V2Y>V@1f4LM6r8_s zPUeqsxme^f@<_9ky6L!a;}(1S`(&A+Zaea{lo3t{EY~ZXwW5@@9n;yI*REdWz4zW^ zHrwXtq~_WOH&G~F_=oS1C{LaeIXD?@TgOFkO5e#|AukF{USZ>$GRqP{F-KGaS_$IW zT2iA)k&}@RfuoZnQlrR=l1;njM=!oimZkjmZ~Y#VA|W-JZ+!c^xVELpa{7p7=xoPy zQt|3r@A2NX5BTZpZ}H?OKSAeDc=e}OdEup>&@~-9yIWY>(gY|{{UM~dd=QQoOA-T@ zFI|$@?P?=L-k_r0tb^b&I*rV;3fox-iU%LKmmj_KB8NvO94*%5)s&=2`0-C)2Jg6d z*9FRIg3*em*~ksWS~2H2$KLL?h@rIR@MOW6{xo}AyS)9*PcT}sY#JVUkXTxWipv^?(`nsTkaB$e8OV6CMd(f>C^OWOO_}2D6S&Fd@`eq(9Cat?>QcN z=zfZ7D#gV~&TKjX-=ooB14&TeJZV}mpYOA~x6jeh&A4v~(ZcCPqY?Ll&O3;5D6iy$ zy1TbW2%55}gvB(R9zz66c5-^0&S!L8M_ClistG&i&Qq0B@F^q6Ka)lp##<&nn3Gg+DWfJ}NG%zGtj?xW>dj{4SPe2Xkk_OvbDWQT zFNN%HW1Qai4h>v5cb3Cjx5jQ>n&$C=(W8`(stk*>asX=TIv(C7@fBj~Pbtwq%cxJ( z8Ubm%_s&t|MND%_Kqy2DY}3_Pzag1#%`sU?ocA9h$R?w_#<^IANu-E3 z_%8;rkeJL8ypu(7h?L$T(W8{2h$)^lO{1+a#XE(O)H(0I{Vw-jx|>bc^YM>A#@1{? zT{q-$_SQ5FI?1@bSn#cHeV3Ph{3=>!pbh`|PyZY5zkdy9TmHx2`S1DY!&k6Yk0-Xx zdd;Pa7kT52H@S7=27mYme>|!|HJ!U+1aYlu^9PRfAe*^UYy*=M~7$) zk3aqx%k_qHr}usaJwjYg-gEgbUi$GFju%Uw`@!?P{>Gc!x^;`-psGq9y6*~S&K#g| zDIOcS5j^_v{ru>qm&me|Xa4ayvLEGCWzONz@ksHV7CCt$bMqpXxaq0= zU8a+Y!{Y_Z<(h9l{XK5pzRCXHE=?_C%X8ChySW;Mpd z5t|W5_97fR_^V$M1SkR*g4EWgy0nBV%XU;q96of-9dJ^G}xOr$P4 zdZ~3JaE1_>Gfv8S1C?>`5=c+O#urXf0KK(Lrjxillq+{pQDLLgZH_0 zc*yPJWAZ%X!nrfds+?1MI|S#VS36@eoAJi$Z$|HQN>!B@t+{;ZE#4VfP?)#R2*MrStdS=IG!AOBhGoUET=3p`o2YLc<`YI`03Tx=&h%1 zYVZNqS-$wiU*N*|GvvASPl_TR4;ymD)-;<9kALiOI_p_3Rs!5ib9T1oeB$xP_{8In zMWQHFMb2iuiLZZD+A2koCcOUI>vVNZy_t#|2d8%GB!p4{YNT5nc-`iYNImDxD<>3bVV3W3?I!dXYX-r%j{^yyQ4 ztPayO$!6m7siKBC@iVzXY; zG#e75x#!9y9{=bg^j(Wm4uU7q36sf$z7v63mfh(r&8AcOSWY(4COJfwv@sNANoq9p zrjBm%=#!5lr_>BxFNuH;qjKqY49E_|PZEuxa+?TIT$hesjIQf3CXI`C6%D7FEKNUT zyY%C)4Z)A&Id7eO@y0LRVQfUyLEq`p*>Rz5?VVEMaM&KywI~$@QSwk4Mu%cM)#Kk? zDHYcOo-`G+XA-d|L+RUnNHHO9B0CbphXh*R_2bxU&{CVIz7A13RreP4pZ~+Z2@l_Q zk=xg=jol9CynwjcsA7$K4j<$sJ(*5L0m>az@Q~I~3}PW2A%-rxd}S#p-ivmehYoV3 zL~B`Uog7h3CIlymh_-EFR8d~QBu(WioafZ5jie#6g5#4Dmdk}uGTV-G7tWFu8L2U2 z+Cva6f#bv50tpNOuQeu};%p}h%5&#pPr#11mci{VT@1^5vnDkuNs^0jsP8Gu0^13_ zbI`JCB+X&_E^0<)mz3uvP16#*Ww}~|HuTQ(#v5;8l8kfb&#}8TB~Os#P6#ARqf4?*hWneCk?&k8o14QZa>dvO|A@9C}F zX1wjkq>DJwH7?SVvvNvnJ=bpD#`l)lWX_~0KzrdgO{+N8EJo_8ip$Q#7)(}>W(CXD zingvP%8Eook&9N@0a(tk?9NV=~DpVrL@D3%XuDRAo`hm$p%Kt%IOr zj55Sqy2QcLB%yZ}r7SKezV)5wdHb#RFGAYx+SSVc;#>|w*``PkGgMOERQu<<~C4$g=1R=iv;m`!Kk z8Iep{>oKD1tpgp{v@Mg#jP+{8q%42NeKQoH2l9jUf#4h}NZ-fB&!f>seqT-87sx&* zuSy{tYfWOZQG+}@NaSI5XPGUEf;OsYecXL{>UvF@W~5nx4^nz0_-NIPX)%?CA_YlQ9Gk0=>>q|L*_(Kf}ZK zTwuLe#MFm;khMwUTrwM9u(s<*BtX-(lzB0}X@|zHjmtW%pEu-PD2o0(xz+J z+uivPlETq69V&Pd70^1+dy7`W_mSqSSQoX{tk)|!1*Wp#6as^dwA&c5H1gFfU(RYg z1+;A|r!jOa%}L3tX2y(9!jmKQ9S%UFmi=z2?_CrP9yql3YEhsg{@IicNj zY&LalYumA9p5{4iT~n6D9Xnv8XUhIbQI!?T76XphLfN%G9RyEDNMd>Bz~Es3CrbL7NvMO`D!`2 zp$tanm^_m%gl#EOX&4WF46PMiY$rP}Up`t(84(Suy1@rQW0ZM8(`+d6!R{AGvxK&@ zLJMd+f_L1!eZ+jeO&j-HSz^eGB&NT_;23;BjWceCGT{0^+q7h*uI<>~+G4ZW z$o|k;HuVM~dMA%@)UInli9|_xMV1wK7e?QE7<}%=L=$3<_nJ1wkITp@lEtjRP3v0b z`QFQWt+yS)$rqex6@1)jicN6vW)p&#Jc4Fj*Gy(pv{AHOOOa=En+6{v`6{xSY#e!l z!aEsHSLKvGIJzLQS=TwLvZQMT^X`L)ozgVH^#N^C86P@nje|lfGmcQYR&KnV?U~Q# zG;J@&M{B9_9IX^CSi*2}?mGxVko7i0un(gt(8z<$Xf6D(szPfSbtZ`<;_{-9v|3(Dhbf*W>snm)Ay(gq+lf+Y zWPmBHB34F6$fAp&N6n@SnBV-(U;X{l2fN}hh~8*<@gm(Z$>@43Gi2+cTVcqvClR(N zZ-AmK$%>S=?c}tEqVJjr@REz@q^dA!MqZRSA0(3Lyj@3eqP-biu59}9ZG)b0@ zbHkz%Vy~R)3z^Sa0lFzIUzS7i!dZuP5;soLge;dgK$hgBc^L~sdKBfz;!ctcTFD!6 zWb%Z7vmHsIux&5QqWHrPv8`zm!@wlyt(6m&hoIz3eYIMW7dghHcmipW305L_in5Ad z8WQc4Wqm3qMJqSNsw$))86R-+VJISkBaLH5jnW1eWCWHZ;u$BZR-1Z*cRgsurfDQH z=z6BJX+&VD=ueOvSR4KBK6sMY7|-$?lcW)&U5sfH+xJo06UGyl?VU)zk`%PTn3OC@ z$@7vd&2X-leMjGq%~z$AaDbA8B+bN3XA&lpDbv|3Dv&j<>nZXQ*ZHX86+7Ml8cei? zps|C`y{j>)rYQ3k$jUVPbFJx~QWZ{@)g_ONAxrO>5RDiscFP@<*p`$60;db4( z*sdWn1{?A6^XXRH=gDVCMrVB_ISVW?F$r3M3qGa7&nDTw>G1q*jh)PmGYgT?sOvtMR(KG(Ssi1RKiAop&JiO zAzJ-RReyWwErBF&3FMjaw%EN{r6PKbWsOvRZo{l?`;g-^N zt$4X(`ANwJNq%i~J8w2MP2Ev9-G|sxG2=Y&y5iMTnjR)fQarP*8D%w%PHr7f3@*Nr zeM}m37^Rp@rUcBG3Q3HdeoP{i)2?mFi(<@nkNIkdz6-&?NC`^bFKycrka(nZf?prC zl0cGXgt#c(tZMEms{L=dnOKdSR+4n**wXs_T=%sG0bJHcON1?$KhvOM2_h_)Yuy^#>m6ROUI!sb1?E@9AcPF)392v z1Sf7ST~o(Yp*+l_pAbJxT8+IYlO%Z8<7`iAjJOvN7kXl{aW<^thq>?Vhxl*Nvw&w( zRisHazUH7XiIxXq+*`=_A&$x~8gcHQ zWtn*46TxlBuhAbGd-1ad&DcX3=Jo>`BhT^?2QrMW``C*Z&>C5mkH3#G3BBzpt5V{k z`u`*A&3-M-&hx%!jo+~MK2y!rLv{7YW|M4^B1MW4G|>_xQL<$vwh-sSfNui$ujoG@ zxlMrNA_0up5D+Lp6vqlAYp^6nGiNs0b9Hx(r@HE#v-kdnHRj@d*V?C?3xZR3^*MWg z-&*hRJikZqokP#pFl;e0fDrtz7$cA}l%d(ZkOIrqiZL6zv`_MPks~~XxKk4eLCAWJ z1c-^wuflhtvncb(o=C?9o>|me<=mpbPt`p}^~154fPkSMLNRF8G%Q@9E~TK4BGYVh z!7y=QMLQS8ML789jqG7*>vKRCLcx`!>dz~@y0YUGQdS%b00d9(qhfy!N#W_7J+ICz z&wMGkf}fFcf|9oK$_nbGcmbuhP`~rfe*29Jhb#DLWJ3xsf%w;1E|*mY&EGi@9?KVy zaz&)46sgkpo}(BY=7}n4DO4Q|36S(K$HWnr8sj(<)U9n0Q$pJ>X?(z>$mMd0-~)vu zg#hmYAi;YWLUqjZjILi$6m5)*W2R7_;sP!(M$(;U48vSMxX1@Sg{Y(_VtmiLHfeDg zSt6^>jmUC+DYYi$TJRphdsssNx8nMiTq!h`5Yxr{A&DittdA0l&= zAS=6Qo+m!^A_EJ1cKlKcI*x@E6u7#v40P$|5CVO_sLti+0~Uqu%rC)q7@(y> z*IPhPUPDf~`U6r?w8RJj$~7QpBo`(Oo1@QK)T%P41Y_wUYHZ6@pQG&^T8^GhT-<>{R4sMzh2a=+fddt~^tU3)S*{kK_1+ z2tsQO!5s==*%irtd4Hvq;SNUD%fmTGir8vu%zO#zCklh|omO%|rXZ^lIZ--G^2KS2 z^sZ*pof%RHNob&d`dh#L#=+hK5=2`piB{!9EAld}6lD=pNCHc5>7Z-R!S$5LYuN`Rs4a3;5ggjg@pVvhCB!-2^nst?>fZ?(w!aS)O5D&m6*8mdw?0|XUkP9z@BX{Blb zLkDE6yrY=-+2^V#axP2hCQ{vZ=K5}ci@3zdjRHU{SR~JYK%sUWD7cH30K9Vu&ZB8N zA_VgRN6T?lfrL3{NJhAjy?{{c){<#C#Y9;safdI_T4NYzY=?=@sa-BlIYbTb+IET~ zROM-Tw~aB_Y_{n84k40qN}%uq!QTlf7>zRZq9^f?s#6nUV&%H~D@JQ;E!LYgj}bxn z?V85>^#02_5tCwvEUDc9fA4ecnE1ObD*%4>=GhT0p3R%35TIgW*Uh9Psu;*~GGxKa zlzU;E9n^pEjbDD_`sE{};Hq4;=J0Y=oSekr)F^8C}E@L_?#Z9H_b^(qw?2Nwc3oS>E3H z%BBf10)k9syUR#dLyc`L+NP!IlvX125C{P&bZsF^B}2bw_!=6uGu-U@Zw4rfIINec}H|rQv2@N0@=d05rk^SHPBa^HedVv}3J}BhQi>1MuOf zjKyZVrIuc5KxpzuAakWpyCvFaNt~W-;P`?DcbNhb;Q74L3hV6_tGz?4hZ#aDXenWM zWtj!N*iswJA#ia-q}32SjG=v5h!H81TosuF13qQ+i;nOo3_Kk+14-ezYZAQZPmxBK zW#{FY4y~BeR5}AdV4fZ0+*zHa6u{wEWsE_AN8A;%Dv8bp@MTE*J1J?0W31)Hu&M@D z=Ug3o3+@c0BBHk7v6qw*P$Z^Fyo1$j!ZSv*CquzD8p_CuSqjx_MZZsZx5|D%2*Sj) zU4!Lv!G=%?B--bQT8UHmQdJ=FsLK1u7pH{d2;p)8nnuI<03r1gW1WsWa9MH(IR2I{ zbA}!A9;OHrX160XC{C#q0MUviA=qhOT*h8>9thBxK=%nT*1#yL6bqD7^A3l|;*xoG z)R8B6PZw|rjVlFlX6ZdmC`j2?##}KH?<-^h_03=Vl{cQbb{S`=_ z!!T0C9VsPmo@oY-EHE;3P8-_U2ub5Da-k*$yrEY0W%MWnY8RAZB%HOOd;my^v(1`z zKj0T$YK~|{y^N$}T)PmojODJg7^mp35xb5}N=72kuBq?a>XLQNR~>&DceQOtiHKZW z#1Ki5#oe(nwgxqM=UMzPK}qrmR#Mg9r;J!*!K1UI+|478)C1f&)vCG}@Tha9-@CB}F?!nh@R+P1 zP9%2_5I_RaCNf(a%82M}eTKd*;d}{T0f*;NwvaqBEISFo*&mErFFdQ9NF-Nw6u}2% zq?%w@I-nw{qGx*M-^cJ6JCX26YoxrBDVH3Pa%Rx)$S536KU9ixO3EHt@@}w<36<6q z+V*R|`b%%zeD*rLn`%2x3q*?yL9X-=xn0{mETvm173Mf@p|#=#`ot}gfwdaW&y)hE zMBNEwK@Y5Er?ixmC1I`ML^xTqMu-|p4OxbGHlF=7fH+P2)HYtntQ%7S(p zq$2A&s?KRXp6Kvbobjy{(PMK!%8>(gs1 zl$8N;QhT1e?}{=}X#`YM*t!=wKL#m^oANFeBXMToMdg9Tz)G+L#eONvK-BWrmUpb| z6yO6#uyotNdGQ)%w8_ml<90l7WF+IhE09OXxBmAXHK*;P}%^g?2@Q> zCb;2Lzl$Q$(Huxel5SCjCq6`uqvqR&ztb3EHR~1`ZqqbK47V*i*|KyO6?7?f_5`up z|4LqR*F7G0n~Bf662V*lf1#m%{Tp9@vEM++K(X2FpNrh*k(hTv$Ce-Fpm;IxdTuo#Q=uI=Dwk;O79ZWNTz7{I~5qmpIRjnW zA#i1S-o0B!RMR};@ZgXyhyP(H(qv=Qb{0yg-;nMVCTI5?N?y+d(Le;1jy0$LZrwkg|Yo7Kp+^+aAmPLnv*r z876xEHLz&O;eWdIP|Xq%3Y)FNH;5;mSh6qCzX&j~_XH0=^81mxtQ z8v|)uNJHEx%Z#oN0y1ABBpTUWxTuP#!k`Luv|6=;7LYcCnvbO!7$^OD_7 z`@4K@SzG4ls7RcmhMcCUiYeszae2+JpsrIZhEmEMH8$~h6D&$bDJX25sGEK#aw}txkhXhtWx4-Jf*QddFYeOn*Z#aDw>{} z01^TSMl_|EcJCAy3}sKLHU0gX6{WjBpSq&$9H28F2;~JXGry*7G0hXK-ld%ht`;0G zcylHtO$ffG(an=%g%edm`}+qqqnt(!7Fuf7Ip<1eHAYpAe_3qrDvw;5RP#K+T7&Jd zg$oIP_#giQXJ>2JMURv-P9L4%AN}$#;M%1NFv?)PUYC;x4JX}8@Xq>Yuk0un!<~wdB%3w?sVmf9&*Xj zVM=-o*lxCHtmPoDSeNw0qoR$$-rgSnt{%!Xu!W2~JLpK>i1HvAYuUUfc}Wk+(%WXWR62LIdd{ywr47y|(}|I=T53oqPw8ejR+=P0Ey3H-qy{TVU@w!?^ng9D7) zQ`~sw8f+^Og~aF`{?~u|dk7)o#g|^hYp=bEx8D3Qu3fr-%NO^tv@6`c{Sa@y`yTEc z9|JjKwOV4)_W0UYzl`KJ@Ihdh6V_wG@BN4W6T|4xbvRz)e1s4cJwhW+RTWPq zPo56r3CJ1ys}(5%hk=*4e2MXxMhRN(&eMb}1+>)l`8qp2#o>hmcuy|t=v^Igux*P7#Rtx-7f4wmhJ@3T z$Jk#jVGT)?g;aG!Br?4hG0}c72#u^7Z0Cu*LyWU8D3Ta6Umzqx5KO`KVcuoFWXaC; zRJ#%~)p}-%sX7TFp^M~qiblNoocxxpHnhz5p8gy*Tb9)YN!uw0O7eG*O|D2Ja<8*o z_?y4}jW@1bK7gCYDu1L_jvw|G^)Ah&oVj4ktN=eQy+{~UUNh0K(qCM8h3EsbThQ$|KY zzv#H4ra5WF4iQEOy!z4$I6T-VLaA=>{kPu6M<0ENr>=^A?&A-vbCjwG8WDS+`9Dv zo_YEyD5Y`d&K-Q}l~?eScR#>5CFGcK{puy`@AW7Ps#-lpdcmi8hOu_ zF{omd(8vpQ1<5czS|BS!d@;cbb)_|pU7512v_$X`A_LYGsjY|ByPu`Z){EO)Bp`Yc zUL?kkmyQLQL^LrG91;)+Ty00^(VCX?#|0Tux%yU$SWweE!i9)G`^&$Awmr7a;XnMx zZ_)BUB)t9h`}pn;-^6l%g?kSk;L62|7^VRqe*6G`@W?<>ePJjT<`aW>2Qy zamL?$=Lh)U&N1#CKSa~fBw;g*SoB@}`4#VTB_zgq#GMC^@#Z@p;UE6t+xYa$FXGYh zF)kb(!3Xlfm5lbC`B?L$qeN$$dPZbqj&=xa57-59m7*)g#2M_U``MI<7+ywKT5C+6 zNb4~b$P4X{Vuj`s<&;<;xe5 z1@J3h|3z%a4Rq7s@n*!`2aljsi;G7`NYUZ!^b{foJp1f3m?qidT*EU@UBl-;^D3@fI6!OJ zkHEaC?d0&mN4K%J(s=&HB|P=?ReW&kLmV7k!o53p@Z2*`LkU!Z12XXF^b9ErTs%BN z-;p@v@|DZ@@Wb1PAz`yV!?nv7aIjib$!*z((TKHV_UuL*1%pvW!|B{*UEax}KoXFc zoo6d2>bOe^LxM1tu+sO+a8(XYQi|O2S`#KC##DbcQNPC>G!Q(xDdQW>rp^k9sQcf#l&&yLTuM#GmO|yw1;`&`R8!|&c}HE#M#fGRDYC*i~uND0SI)RZ%Fq*9_7L6uT9D5{JY z3K==)Sh3Lb;!s8R=+R?XYth<TQ?;WgVN4m0z!R@sFfzsL_3?>(`83R(JlS~QV*1K6T)c1rdn;m%c~8>r z;>D7ZOt)><5~?Qc4vVrwI&UmX=R^s#ObtMeP?GT(fpQ~^q2i!asI-K&{M}eciAt9! zhm{XB?#zis;5jGE^H}pel+uVo)-hW;N1G_hp%5v>lHiGiEXhJZF>kJKi+IBzB#*tt z66rE6j?ab-y1v7*Z{fFFT)un>pZV-(@RJ|E2V)xuNLbsT;jk#F6uQ1co(CAM5VOGT zyLYkNKcu2sX^g`fF$5g!?=j|NXO{Fnz*q$-$p>_{S>xpNF)jX)FiaDSHI-E0y(6gO zc1xE~$tX{|rMGjAg$Z3fr%T3?_Y|2+1shA>88ed^J*AYWD##+culuW*>!nB3YJ#-* z;Mk&J>hDHao(L&9bSnWG5?U*T);1WWf>K1RluE(-nW}xM;GHMA9RFfD(n!g$J2Rw2 zk&D|6nx?^ayQvDQ41um+z`40PKF4unRzG18w4vm{v$GA1DF&rnWrk7; zluTM%&a-DtyQXd!3Wxg#h|VF4gw`4?%@VRPSo9tGM#Ie`dr)S{EuMYmIXrrJf~Ie9>()m&e((rSKlKzsaOf8+%7s5& zBlv{Havu-w--nxL?601$`y)#66F55?00H!Ei-UtB>>nIqvps`K5eh)FSYZF)0HgO1 z+MsPM&eo@t&bL}ZH3s9j#&Ul}9ax}ovu$Z98ltb4hjSCUuB&-3m0yLj;P#b$R~EZ9 zIj|5Jlw#6AA#W2HG(?ZU!~}?ploJ}uSZZ;F#O_nk<~ z6eT=QGo(@!-nCo;EI+0W*^@6~p0QkXl%a!+zVESKk2vT%c<*rk-hJG7=4rh9){pVI zpZyAk?H23J1_%3lxP0j{b&Z0~v~hB{e*GFgymOD_`D{(dDWdI`gnDPCV_{kq?p+eJ z%zpm)7m!4x3=N|&`x!ZsaEIjl6jNQ4Xl2JbJBI|owk`5BVVqc^K8|(o)9MZp9R7Ar zT~$?8l00KMkvPBy8V%-(G}3%=2Lw;wbBQw-c_+UwD%SF@595{;Y(hf{Dd1SX=DjCw zP1nF^g_a%9rTsJwwWJ|MD6KHfGyS45vx`(?oXP_$uvxFMJ~?4bwyU~jA<1DobBi=h z6V~fB8;}xfyBR~y$yphZLt(&lPl1o`KETI!Kf$B30U;{PLEtCve1Ny#{SYn`i7B~W zfBg14SS|OU1hJvorhyg`H=lb3|LB*$hOTY!Ge7-BT)9l6tE-oe@TJ#3i)XH1!|SiT z3ad4?+bzan#G>zUdU}c!0~vv)nW1J4yP0?MN2*2Wi{nL#F|*^Tht5z@YPQxoQCs{WV7VhJS)S-sh8dGSkKMB6l2Etixy zS>%{0)kB9KUQJ6+0Kqv#?~r0(xMQHPk(B)R(Lv&4#Pgr}EKbh0NHO5jr3+M@_bdFv zcfN;|0yHA_R!azJargEy%jzQ@+`osj^$Ee}09?Cz1y`?K#f6I(F%BN{I1y6EXGq&& z4if7jB4&~YD@jn$m@|$Jju66(^)TSz=m;^=STTi&+aG>}VVE$C6Xp;RGB9qoSoJ-g zzIFwNd&`>b9b>?1*<*Awjvqa)7rf%?-LadxP$C3?<#L4>GrW&Txr{x?%MgNxlzg~{ z#AI8_MDe~z-zhHyywf6yapL{fOtM}{iYn^7=|YRFM4BeY?*m=@A!f`Ifj46+BwJ6r z3@z)Z8*J265HP%NlN?UhwGDEh#6Bnm9~?q-2+>0*h3#gGX&Nw$6LJpNKRf`M4v$VY zczEvt#`P%{y#Yi(NC_^Gtb7%&`k`0yA?15wcDpcr|gq^bt#;2iHkEn@I&>I3hT6ctZ_d?YkJ&)AM5JM`HO z!IK~#Xq@ZZ%r-LtQPieVlMqJfCklcPnGgU$=Z;pDTPArGVAxVYMFMflbVva)@30GP z(=c+K#tzz8CW0n7PoYTKG#KXzLhY)H$B!PN>wC%!=hjCGagLLd&fR>Hb1ZsYe~8q835VBG%a;DQsS?_^BtUx5%0hE z9)wH~pg^OP1dbn{;OXnv@f+XxI{xPGzKb8c`4&F<_zr&h%da7%g#CjHOem#s>cdYS zKpKTcEufS}*IB&x?%NpFk1#n0?=z-p!Z?ojm;dr#;K9Ayc=?4FC~mc<*ydpz5Ti$! zN3>0Yv%I0aco&F)CMA)FrNnl##>4x^c=pC~c<|r>%T@`gKaL~8I%kr5ca;dtye~FE zlUa=3M>^P8cFhc{mPb%ha_8F;GfoIa67phd%bAULnUL`@a}y)D%XBrMibgNgb!T=5 zheQ%%DK(TbkRS(tj1fM0ct625tGb{i2|vM6lP6z@2QBrL$}BvIu*SfrL;@eB;a$dd zba-@j3T-V?-ogmr_H7DlyL;~#&p&??7Y+|_?dlOiqUWS(JDA48yNSFi!6PLgq>SUo zr&#nohIy`MRM$3`hoO3_-0Z5CDMd;Cr;reRMBlb(Z3{m+%y3vXOBiYDo{Btnln5Bk z?Ncasj`pgGL8)F7o++hFl78E=eSbH&2Xl77v&7kYi)os1bacq`UcWOeAy?fv!fLf587PNkK}on6VA=+e;dKRbzXRz7cuxz#3`t!SC#EU(QDjIAIi~?7#-X^;+bcj#{R(poDb+0 zJ%j|h#-OplXJ3C6U;DYQ;Pan<9bf&*SMcdizlvvXJO^Vn-g*ChT)KJz5FTBtvEHmH z-Zf`5M&bIki@1F00s_B;O7XZ=5SuI^V>=Ak-`~T%yLZr9gL#~wwV@QhkfAa#Y}SmN zr|veM1Ax-^%=y>n6j4uv0fl$2O5S0qMZIj_AtC8WW4@DwRFL%Qtg*c7P=F*@;O#qi z(6tQ?_YN4p)WFZK`Y5!f{GfTJ(Yn&IcIZX)addEizU^U^h99Tuz|ZH%!i5r0#04bZ zkJTLMJyWm2uvSGh>)<`@4YeW?0h7%`po^qwIvCqR@L0GM7G>#QB6`oufawC?sk@vD z3;+d-A{f?4-Hoi8=W!sfhT-;Rxm;CrNW;2vuu3I{SUD|L8(7Y9P^=hg+P2~c3VE`4 zOvYiXGtb3hK`v|OIc8GTEN*K}Rg(siba;rP3zsm@ z4lxC|=yB=d5!^gsvl%gZk1u`c3wZAO6+HXQ(^#$c@$G;3E|3$x{)@ka>({Siz1iaE z;w65l1s1!8p6>9WHfDq3l}b88VEzbw_0Jn-oi)V zHVfc%W?;(&dfr5Cb6%H)$$T0u!ox#ZZ5OkxVmU!xEzp%~G^^Ci=; zF_cHf5Ws^^j`8I${}jAqe^7`RCkG(~x<=#br9&*1E8Kth7;_BBlEeplt353H9&VbT zrNpou5PiZp2V8&ZDeUjJ$))K}_WJH{v zKE^9AzKG-FhnPdg_kQ#aPR`D7dU}G*`iwKKGYJDyz!$#o1^oG+e+!G%KE`ptYT4q^ zgS$9=bc}!ckADM;rpL#(?qb>X*xy?K8CWd$2=r)onH5C_DWpUa0)kaYLdn{Jcz!8q z{wSZoSeJ)pnx+~nX|2U#v8aLfP1Dr4+F{r*9U#FHCBnrJJWoSu z3)3`^Mq^^tRq4toPD9ZmQ78mYpH17gwHrI%t(AX%aVSTYER!63X52ZAc(f9De0m?3 zpFYBR+Ti`$_YfHk-?bfbNciA`4{_tUo49lLE-qfYfQy%}V%TQ1c7+%O9-pl7;K4Dr zBhAyjqX^h>+Q3?cVK{|zBc|~bLK3tu3xzxPA7Qhd(X~BcHn^*3`wl({>>pjl?K=-} za(afn)gGzUSmG+A#AevygAYH%OE10v+w^$v{ae^SIK*PP#Qg`yjABvfmJ29rVA&<@ zyu;q!UR{!x#dyKuTWhi1ZsEPF2=PLE?b?=qR#~y*=NU)^3lgK}izY!8b0T)A@M2yq z`QCNT&hJgRL_!RVBqDQLjJcv-s7s+JZ3%&pB1Y$sGT>)ARL5b!?8ch7nNq;@YnRct z7Dh<8nFzo)Zr;Fh(E-V0*>@EU69Re8jvqYWbB8eYj~_oKu%uErIo;ymV2^TOa>j%E z570IZK`uSLcR2}o7l~aJGiReD26$3fmNW{EkuN2v@4N%QkZz0NnB4F?S#09{L?H>6 zi3TH(5qu#0JAdy=?#?3WSS*HV;t4?k)e`e+cX8|l3?T&anz6YNOd2j-E&wQ_XevOO ze*qyfN$d=p{VLm+#uAiuAZsvt2(++WEh)ESv++y?0~TFdZ@ISVYRj1!jnZ_M<7!P+ zCuyPU!#B?X#%P*@28vxp$QWlb;22B%puPPSe)St)$9KN_1AO^QUxLyNKDmD%zw=xF z7|%R?8IG5`!#v=pKKm*ByWjoa@qgU;2$wHkfI!Cn-X8v|-~J6WCgbj%`}pHO`AZlr z@P*eugB&Kbjm9)=F^>bB8z}c8QizXHhVT*vK@cOfZTVpB84D1e0L_IE5j{mrE_!m# z=ailZM?&TG(~`3=h=kZ-9JJP$27*9ZZLswtM3PvomT>a~DVUJzdC!qEln@9m@NOl+ z&4efke&m3x9@}9=*SE+yqAMdSohve-hWQu+bS=AhBO24tr2#-0jkEQdsiPUEr)wxp zhnF-4Aq#Z<62Uom7tk(xgh=0Oj+8z~^6gxoKixD`Xt#_9z%*>2GT=f63@>OGOGG@vJ0E?3|Mp-1YyAHI^#`y@Ao+VR zR^o86L}xWl&Neta+OG>{0Ra~rOUPRcZlX*LKCII0uv+XPad?;WbfKS~uCZ7yK@tVU z-C)L|X^^v|Q5yt=Qm~Dwg$k!8)5049^Gqh#7zp-Bx%G6&8QmaDMM9XAAT5oFQVM9B z2IEAc2SksyX-F)i*$X8!8e`Bl7E>JR`6M$C`0zK1$kj4%r=AiT)8Opn1l@C&5o5%5 zj5xmc30{BoRZ5498Owf&M~{x-=8V^0dj*d_Il;wCM;JrKyYJt^&;9i0AXCDF`}c5k zv_#uzirO6`8bh$r#%dsCcsIeC2A+2f%e^Cf@cv)prI%j7xZR>P5=vY8v{=wkE|F<+ z96BmCrHO~X+T8K&!?{>g8Ai5lty`BScQP zdi4sv^5vhxUf-hc41Vg1uS3g(?RJgG3n3swX@Q^n`JchHE0^%p^(%Po)tB(n^UvaN ze}UP}xOC|<_V!k|dGk5E{NhcFADu) zO}yw-l`m8(?_#Q>C6}%a;+-lBNPK{nYGAwF@I%vbwL~U0!K#$Ky?rPt5WPqA9>F=x z)5I~O;7(%~LPX;<9=l1wuJnku>noZfmas1^nS({xl7d;^ZYN~&6QOBZjMGR`V;?!I zBEl$!WAfsiTLjwsIFF`nn5mX3D6@zOO4aN+h^iPgi+3S0eR-Z{R`&@uG-mwW-+u>J zuU>(36Rusp1Z^7Jy?Y;zK6#95moDOP{}9dvoSZy@k^+0HC5|85Lv$l99qnUpwZLMr zz<0j;eTbZ}zqgOJ>!BqO;|!H0?%uwSk3aqdfA~j#jvxHweZ2MV$9U(hw{fs&@teQ$ zi`eT(sWXi;K6!kK(~}dV6!F3P@4-(4E*$RTg%_X4l?xYec6x&4s)M#oMd@_?3b*gx z!=L`?pWx<=XRx>IF>N>KTZ7rpxO@BnO(U_tYN%Qy`*CI2Nu@AMBP3U(f(r@fJ@4&& z?ds^+AQP&a=s<26UGE*?LQ)8@nmA0sdvslo%$V~Oa~*}0LMbD%sxopFOS$FRXk@8Z%ZpO!Vj&b+?F|J)c!o|ZC z4i1j+58wViR?7tmGjd`t1?@4ku^7e)KmN&Ec=3hjaq(~uf)gacqM`F4Pi0(R_E<$F zRTkg$zGkLqyBdO5KI8LX+w<8)By`G^B3wQrI*&v(B`(-!B8!r-j@@ajUMS3^&~2I) zvkMUa^Z)qY=T~lCLkdGB$#z`_KRfzDo0beR#?bA?32H!Dnnv~|pl0Bqo{)E1|Y_`8@K zY)fMasTBK&JTyNaA$XP?8?Goz=oYD{grJIPng&ME=elS;KgN=hO{;=FU&3 zyWjgBe*Dfm2twdje(C4%`l~M?c*?*j@WWWb*LIcE18Hk(b|0~BIKVl7HD(8-tgu~wf~%MIuv{GA?t@Qo z_3}l8=y@$N zsz8RgTgCjD>aK#a->FcgQDA=JVo_X}rI>1GstU`F_qk<6OxSmZKAyX%%J|I%3GqMu z5C2{M(q~`7>B%w8EqS4}-JGH8T1c%q_nX)>!$3~b5CUx5)XKTIvfVj%W;tA?RLs$u zpD@{#+qdLbl;pbNk7(N-^E5*nsxU(!|H;|e8Jey`iUC5Nt{y>Gf8n3zw0qXNR4`s?tpZ}M^cLXC|%P)@;}!!WXNm_w2nb;RH2mY zYC0IU! z2-`Iw>vN{yByA1cG@&&VH03xdpmaSY!=zMLB{I`mRedSDIAOYooD0yFJ`>0UABZJ3 z4I}pU_TeVVHYh5e^>&MCns9n{hQq^sG{#^UX{QDxQavey7-@e(Ce?t$gB9j!!eX_; z$!0^;Wvy}c_=J$-O#_h$beS^k15Zy*(6_X-Z90pTBQj*k#v-GqmT1}r>ovvGN@T#$ zDA*dp-5l&MadLVBp(IB)C)mD+a}!R_);PLw2})_4oP2^s-yp>R%b59;c782L2_!+e zB9PF=)X*m%0u>t}@CbuGfA0wjsAPszfPlg@6H>-##hwvScYHz!W@)L494Vp_AXY_B z&ZP9viX^4Q5}*VHMYU~9|63&j6B$=XgTT`Hor)~iLpC8JOY(>mXMA9aFDV`;LL%E9 zeva5~)>!ooTB|tkLqZEO*ZJshw%%ep3{cYG`qgV(=@Tg%g3b&`iLPmI>;3m}@zNzU zZ3`s?gd!o!>?dfcVN`?n-~RxI7xpnu0}d`+#9Kdk2dhPo8_ztAi4#k^hQifyjF4K^ zNk&3O-z^YB#G^-#uv)E{MjUHFHBJ*6V;OxSssS{eL(hY*6LhEO38wPUC~AEd7c7nxQY@&))9El3EN>?O=4tj5mi>KScY62 zttDDD=75R$URqgZ&45$7I7LwsVm*PXGw&UcQ&pQ#MQ0c!$Jy&4RX2B$MV2-*1Xn4< zK9KpS%owFoFxH}Ji5^Qk9!`{#fNM@#5=?kNhR1d@P5W(u|#7imDBrxoFanv zI5@n3EF>Z_9zHxKe?o{=(oX&ya_=t|Jz@%II}534gg`-6G+UOO3a6FAaOlTz15Nm9!`%eEG5A^rM59h$+J3( z=mRBE!Ot}QGK?ak&^c9$oKg@9mfBJgLJH5wNsQLeJ5JHfggXK96H-#aVRBLkY&IJh zW1%(Se>5Y}tI>^-KbpaV6u#u(!i+`N!5W3uC>-rCkfMV?s@Qa*O4IMLTJ|_PIKZN7 z>kh8OS_@zo))gWyUc5x3H9^nOFb@?^$X|Ntj5udgU^f%O$S?bR9hb#K0oa z3m4psVlT4d7|HWe!s;Z)Dx?%)8(UHQijyBpaW19g=O$M{MIH6=83(cBZE;{M7r2=+ z*b^DYDdjK{_%tRY2ngLk{n!8ex87(JxqFQ=yqTZSSOcXrhV2$@)6uNo`zM$=F3>na zNyEb;W-)Ak$7w8O=K|&TbaKFpRVx(Uh~%wk?#FKmxnY2~E+qEjHV2ef&$v&*8y7rg6eN zQ8j0(=jdDpJr^B)-@_P-rtP4Vs6l5^87OIBEpQL>3I(QfiIxqYAH`f-@-m7ds%;yRoCnWY@|g=nlAcReNr4yNBs!wY zj6jlcN3-V4jS^=ol!EPAlEF&>Rrq!DTz7mBVA(a%Ijkn;R*2*%m;4!)J!S}GtVAy0 zN`=|Yv`@^5O}yrurzcjzM;T|9I9-aXrC5#oa=Ies7qEWIkmd+ocpnmh4 zzxu}EeovZiL6SMCC|rq_T8#DODstD#Mi@)LH^CXa#%j)P2K{FwmJ5P1q*)=L^^Vt4 z_9LYWiIFO7Z46wXR#b9KWpQ1nMBMrs(a1lSwQW@xL`FN%Xr!rb*%De*9%z)gEMpZ) z;$YwPn5Jo0efgOIkr_m9%Jn5F5?TR;#z}kd@J@I{g(RQ6PcY&DDHJ<64c`O?2R+jH5-R3rkLl zYpkU)3_t`0Prt`vu_PZ&jpv&W={chB4ti{B!4_Ycx zGI1*CG)*L3Wd>I%lALpd29bh0W14ErJT59-yiy7|f~D#7eD{lFsp(Q-yi4|A;Zl)}6J_MDN75wLe z$K=R)O9yz1rlA8ZrHr=es>+}wBere(#H^GmNQ=I2S$eD)J+RB^R%(|>)|B}=fEW^{ znJSE~>*}JPI9IV6`wDu2GT{{;z!XQjBMp-pZJMb{5InX@jGs2flGHL&wzYE}T4*Sv z$$-bzTMX2ym(1%7Fm@*L$kFg}KUR;9cjSIA9nn0~!J`y$%#>7hL}59dOe=-WcEb!P z!b2nN3{Ues*2J=|>tSuf!;Dl-fwU9TRpaiMTu^pIw3O4BrV)F4d(25n+@gn_XoH`# zz!huLFdVb(&@hfcYZ|GxZ3oM$u97od7O%SyoxmuFRL7l?vm_{eGC(TY_4Qp(?4z2k zkSd8-2#J_8l%%daGX+2w7jvQ<9K~I;W|ffAx@PBSW1$6iFEJwJi2c1ic)kP!qeDof zqN{P!SVK$YK&gTakAWx#7F56}T~qt!X~N7=%!c!LAZ3j&r92*bcWrASh2|`50m1%+ z@=TTFPGbzbbLZffs?wo_02o385+qUU{Uz-Bw}1)9M86{1N?wG+bxUoXW{)tAsT z>Uww4+#=Jcs}z}$_n%f9IxE@XT_Be+CJxl{)%S*Tj_+as6)Zdu?A=mIRzDO?14{QS z>(5nvCtOHZBJ;U3vlM95xl;yD4Gd({c1#JjX*iyN5%B*XS8vv2*>#?0K5Ol@$8%0j zHCLff6M!ld1`-4Zkd$mnwg$?SC~LB%wyjWfM>zUl@E3H1FS?^U!qMH1mejUoSrjRY zCPiuh36LTsaKJP zrseR%C-LEE1!NW%;|g&k4zABE%jFwY0o%1?Cc2iD5AfMx(~6U&+g(f@FS&AP=C{>IiwVA6q>)g258%rXE9-h7Wo`h79QG z;h^r@F2=gLF(YAk&=PG((*&hLoRg|pjYI{*V7^p>cOO(l;YjYZNrj3#>_$eHev|;F zCX?luQsV>ads)KgS$o09N2fsIA+pHNUcMT$Y_%Eo;(t90kX(l>bee-CgS(Wiz8D8HyRd49J)@%=-pv7 zdWiZ^WA#0Z>U!IYZ^bj>$t9jZvQv&8zIm37e-4C*{bBqwQzMumczj=&6QCJ(19hJExl^zj!NqFSsQJCHOjjs&xW5lm0flMO2x%KwCH1u9fOvClqCcbwW!HA>e#qIw|PdTE;le(X?xvw^U^jIa4Bo z93IlPuE{d->KJ20Z5BjMD2gjbUZf;Ok|O)olBk#)Qp$xWpB+*`M~QLM)C4CKSQD$% z#Ar+=2jlQh+lqreO*86sOI{RF=4?iloAA}-5LEGZVC~?34ni0(8tW}8Nn)$7uvYd3 z!-b{raoj5CkltC)3Dybu*n1nfTqah?Cc2?Lc_yic10_MmQKIk(UPafj;JFhK=g4{Q zL6e}O=V_E@$K@j-5S$W(y>;Y8DIHlHuc(;Gpmc(dl(+$tP8_Qd0%cW^8WYj}2}z>Z z-`^)m6zk$~w7{Vppnu00^_}cSw9-*6 z8e&&4bd8?OMBxU9Vx{5xBM3=XYf*vX11 z=Rfy_t<4E-y^hPX5Sc}N40U15$|ag*hK;_AZ)`+vN4^V6UPLD;TVYr@$2K{QQqCa^ zX&65YYKH-jJai(1^SxfL<3V!x-z30B$0R&=B=31pKiM(ia2SCMUvo&k^f9$9#3hvg z#Fi zMs_F6Bae*n?@Z#zCox81X+w}pBuYwkq6Enw)o9jQrjsdk)8Hae zT1TC}wH-x_;O)COj#V)Y4>6?a_<45LQMU~yk&*0x+GyLB)Z`*FcQNQE&Ze8T85K6h zq@&6(#H!N8v8bThlhH&rEH!Q@5M_owRtia6X2}meYO!PcH_YHXipg}wI?4~_m#$Hs!%;yjKusb)yahljm<$WM^?EI|&nPV>gi$Rc z54V^Ai&Fd)1Cwf4Dhike7AQj{@iG|~xo ztnFJVW}*=;O(JT?$md?K>nLfKxv+{0Ssjg8!zG#K8CuDKTd$jl7Rh2a?FimtZ5umW zAuNbm(8d|R>W@_%$5TT6B+F`O-){wG;N2;Gn%du zibOnYeH;M~#a5mfT=X0`8#|P!@f@DpkrbgEMyW*(ayKz)DJSPFk&v3CK7Y3P< zJ!PC)xM-pr$PL4#p^xkzZ2Szo_n0gtC?%i6AZ-WP9s7ZR>q(6W5{k&Z65mQUj=AHV zE`P7lqq1!{dws+~5fSi19JY?Tz9GAOpb`wqJZmG4#}C3N#^Qs)N)S5^p&Q#MNEsr` zn`M&SR!(>2? z3|+v{&ZBKxIXoOB=8(L)UazCyMGlmKC^{^02MqEM<2#*9DXVG}1Z2^NG0a7W2UiXU z`32hOy$HcaYN^4yFfP~!RZ0?(@mVH<0O_I}&i8`2&kNdCfM{Ern=IxFaU2gB(MO%D zaYR@*b*xrH>?-vUae+kBrsDbBbVsZguZJ=$>d_Opo~bJqA13(Q9wopXhrY(SSf}WcqCO~ zo)y%MNVTmMlCc!Ofxc~Jgc>|as>Y5j%L_bqypT4%l*FVM|}Pe390}e zdPHO`s<6K6BR5LXbwW_?t@{bZU}6$fB8Pm_wyf4QB$_-g=$cjxan_<^w;-rZ87n&L z5Ah%|^Zf9jJzRDtCgx9=7|&|5C_9ZJ2viJPQa|x*A4Z>=5#l)PABL)b$fj_SMv<9p z6s?3HK)`Vq6vtoAyU}UwW5Ae>-@kM)8XKSA!#$&nXS!bK%R~C+Vf-}X&R{qX@+>>- z`a(EF{e;1rB0t06qz_2PKZt@x^Je_BLo`2cUe63_kSh^sbNlAVyW4_d&wF zR6+=HW~!Lo7S=c<3sTS^8MT595!)gw{kOQr~y1*J~za z6=#rX+$qQhWzy(hh|6L>@Ts)0qlSbp7h6!BNXu82CAPOzWhs?WqR6wHREt_D%d@D+ z5iE7r#e=Eq$K|?73|-$I%6*NAg319|tts*nk9@dUYOuDYD64VQ;jP2@5WOyz&NQV)x;{#+i3NlpU3%YPOw7QJ8hBA>iPKpOnm?1aZKQh*QPJarW4&6Erpai^Ln-RE6BuBur1CtYZUikd@XTaE8OL*x zFfEmrK=1nAlID3_s&aTCC@te4g_z}>jC3n?zrnb@@rV=-VIq>sIg8R)OTAt(nQX=v#YJeJECf@NqLY;Qd`XdruD>kBVcXU%rpgG| z`0&bHyRIA7t2NWAl(sF=a{F3`ib3=WO<5G!C|6z{>|vv1*L!IVJ13wyZ8CoN!t-1> ze}>KJjOB7cHJK8;qU$|cNxsN(y^s#iX?d_*N0k?HzywJ+%S-WOWF`j%CypPJ!!aVP z6P-#Wsse;)rgI(_FZ$^$8t~ed>8!$g%X;0(@)vM@psWh|uB8q2c;F-^p*Myy%cz%Y zimHsJrjA)Pky&jyqaU_)_ zg1(O(S~+6t6M{Y*hqO_O?xTUNcY!RErTla<6J=K?i1oUTqfiarNq640a+FL&GeF2}|1k=%;)m?L0D1(Z@SolZ%!^hiZ#$NY@JwOwQ-ML8pw42PCQjSY|(W*eK7 zd5-IQx>h1)yS|N8j;8Aa%cke%-Cgd#bP1DWEbE5bcXrWfPMTNTxp%`;>JgY^=!G6QKZt1*ZSvPEMZPU~%w3155 zJAW8Dr4xLxc;zU{DP0Gvb&oTKRo61f3sG~Wf~D44Q8#NDD*?;Jnyz=_=u>N%!3Gqr zb1dgeOk!BA8Wb88-O?saao*GQqE2#NsuSSC001BWNklB z0^3`B5JgxBU`)ZA@4U~=+j|@=*H{;*stMQ*ydCxSL?>kH?W9_z+_sqG$^fq*A-N@aNQKCD{EQt&JqR6T1^_WQL zf**~019(v@jgHF2JkLp#9>3>td4L>rMw^&Jl=14D?{fU)NsgV|;XB{`A?Gh#ATcTL zzyAS0{nMZ3ub%!Ezx%I#i~G)9~Rj(O6bc+eJsQMGDzWvW<#Ez_>RV%Oo;S_M2j+U6lX+S zknC*}PzGbtRLoU2AGi90n|HuR=p>@1B*REXRwMNS+)$!CAz7kTZ~H@I`>uUNG; z^Yser0>AffeurtUnH2>lOKBSMQ&iJUe)#+=?Cie&&;$JF_FokceL|%Sw_8ja0aGCpp)z zzjKT4|NW0x9nA5{V=9q-KK10sc<|DB+Vwu#IVRPFrf%bsS~{V8w#~Qy{w2Qs%y$Vv zvuxLRdOrWpevz{$cF>KCEwbEDH$BtIgmvAsY68vtfb$nF3iL8dNzkJc&WbV}3pe6Ty`-S=*C-`O*4ZBB7Pd>-CgN(HGl;`nzCm8IO>oAc#A z`*RknCELe#Sk9NM)-{(dT;Lx+^(3}kO2TF$3-y7hAl*bymYQ)SBl2}ckrrd9S=aX* zKXC#Gqsep-b98-A(@J1h*E`v#*=|%C=6MN0@^;$Bjku7w0-))9h44 zri2g`jETl7=(~38lAH@@qhn`bV+x%;GUJD)=>!NEEZ!@Kc$y(bTHaM7DS=UXtCV8B z7GH}P_+&4l0?|nwrO+m&jg*HZjnZ%*8Es06PmTHs!`k8TMci4%oOUJbHl?*h0Q6n7 z3)pc7rHv*_GQ724TxxKv`IObJfJK}ILM`Nj>dJ@^2H$$9wlWnTXAt0)hy z?NBb@x{fb>@yqgnJ?$yrDM$UjIG&>X0_&#YY$;`#kT+^LD{~Z{wpSB zN!_lgsuGnXbn6wTP8=6f1F$)pGG8=&{+B<;=@X~>jL{PB+=xpOfl9HJM_)ETP_h=L2rv-sSn1UY0{4G3?l$5PFpIqZ?T%9ntN9 z)pE_#fBj7!edJM!GUwT6pJBdO(7K+J$G6$p-sGiMU*|vkKYzf#{k`A8xqxde&ISUS zXP^B$zWdw{S+3^%-oN^7$vI!NSnGNBgI&J(<^RUr-2;B@^Pgk2UXy2{UP?_$+pa0| zf)8%L&VT>L-w27e>uEbjQ#TYv#lgY8j7w%4q$Z*7Yt~IeQO)>||M^S2_x?T3o;=3c zv&YD)g10}oL+#+tzV>x~?{|KSB1r_b68$RHTJoX-J>&QPC+kE}6pXIq9{*VwfK^dO?-uFp@fpcfhaPi{(oI8I%-+cDFy#D4JY;8<=^q~ufrY#jBW+WLG z-bo_<1M58XdQDN3)J;QGOxW8$;P|m)92^`_Rs|?gzATrkab~QQfn+eNiXu&1?<&%) zWeGhj>_sw3Tr7LC%#e6Z+w|mlDJ-l`7W>V*Axkq>>lKqSUMLYeG2nf=t{2mu?cxQH zVSAB&XSsNv^1_S>cLRk%Ok{fGRW)r(o{8zND2oV8J&ZaH5lb@2RFg#ZnZsCbkcy6> zMIejeF)Xe*f|nTpp-vY6N7DJE3U*i@q_%P;ZRqmOW~IKUFvoiDlg;1%x87i?~AQ>GqtzF$tL(9 zVNh9;u)Dj@y}bpg(tP5{$Ek7+!C}26&o(H^8T*R^9(?GM6c)h?h_)!OJtVnd)mT2b zv&WBKc!`gE?8B0$gJdz81e{Oe)TPA-&F=1;ont$!R}1{kHm6RV`u|D&rK?L+8h3r* zTqkmIAATYuL`Hr=c72J!E{7db*L4wlLmZ{X<$Xv-A%U>dLG3ymgBd5M zty}UOnz|MSO*9x*RfR**^x`5HR!&A61EjPTxo4gV?gOo{ox^(1a<$~z)d%Ujn%PX` zawjjJXOko2o?;f*B$?m;dG6V1B1t_|X-~BhgMKzo8-RFKpP_n2hvV=#k zUg3cUE_3(pJ*Jta>smS$XlzeWPI&J7-{;n?+uVHr7L(b8ot<;s+r7uz@4U-H4_#zy zdz%n6ZEw-aV{MD|iuXSFfD){hz4QteWm#>z%b?xO0lCnxWH-w(H0<#iu^? zVN&fle(VJ3j6eQwUt|ByJ>GfeJsx@JEPdaQXe0DcCDQB8Iu`p2PHk=Rxu5-Mns&j~ z<`(yzJ;fjX*RSx+?|q+Z4?lqKT6RvJqg^i~H)g*Tb6z5b&w8=Iq#ExuZEMMl#&s=a zR*>grT$Ua`c7o1Xe)Qriyzt_WIX0W|==E!?+n)Kl<@LAU;ZOebtNiZ2{1uYaktQj1 z(~@O|wzn)7GCn)DwaMd;UKh0#ifdOc^Tunh^0(jpJ1*aUj-Bmo@na~&dy@!~BQ-`| zsw6?9Y1@vi*#=%~+NL4PGpq`flZnV9m63US;&5FzVl15Vm^3DQk`Qn#7E;u7Z4*tV z?I;;lIu+xVhIQKsN?-X=6k<$)4S{NE2%)DK;FHdi49;q=#3k(As9x*(o=KI(Ffr+L zvcjMd`E1CR($^O!Mq0bTyLyshq-~8NXF-gf=Z@tg`=T39}zSCImxwly1f@6DDaP#&a7HEB7 zXM2k?r%$k09zbXy)r7i_UMkIEzN9*_g;xT^eC_o&xVN{*TW`O~Z~w+GW80rzY*4sRI`3fKV&|~P}&}m9g3C3s+ z4h~RCGnwddCcIcKX_}V(y?bnKZc^7ZvuQ<@<~x7S`|rKa$3F5HJKNi0$xmc=<84cprO;bW?riXLKl4*$iI6on3uXz9c8*D!GAieF`+}OaF zjC1GCu)kbUmF38_QqeiSTCYYi!bhKcoK@=}j$ZOS!*-s&?bts!kpDlE1HyNXzF+gn zkAIvz(`-yK&Yd~M!Ty3oD;~IViJP}?@!osy@aWaEbe$!y1Vho(OJ*Be%*vEcJb8@^ z=kFufj`ecQq?j@(1$q7EdpB7uSDe_M@%D`y9N*f6y5+>?jQ{z6{9WdY0~Yf+TiY}4 z?Cz3PGrstxKgV~DhaR{>->)f(R1|K(;ykE4bk~c zMGQa-hpL{qfbaX3JTD@Vx<7QUhhaW1RkaTy?ikQ0lSy^RstU3@9Q;}Gdz31FFdh#7 zt!Wy{$%JFuTbw(0jKlss${KKoS zGMP;1qnWa)8wiSxjZIFUKErcAc%GZL?=buLMzGc=gSc$nr2*o_$sTWqze@Gb6TH~XGePA34I8(Y% zhCC;qqP5l%=z?U9TklCy8C_LXHJbE_A|F{(M>8;FAr{8uxirm2V^$ew^Z@huk~_EW zlB7BB?e25m0}pfd^27Y}FMN)R4_;+!dz+1o89sRS4;FyT_GM8i^Xk=VMUj=f`pRp# zzUSK2htP>;U9T|-6uAa0w(mr`rxQ+{I8K%-v~mRBvR*Bj&kxw$y(`#@QGsG5RZfcp zG))q=H>MmvwuLbX%jE(4^F8+G`%zNY5faad<2zujWKNhwa4vBiwzIuW+tk>;=gO5U z%%&5{qGYvN(Y7u3?(Wj8YVtJ4BXAwc*h0=6Z~>Ja+98xe;{6v7Hm5 z7$V{0AOARInUk1Qu-ipRUKF&e8be^dcZ(C@<68g4hy{zdvOCOi+(`rHyL3lzd&IMo@ zL!VqkyXa`zs+*RkbC|RsF@~bZM0V@?Xb$ty1rkQbw(#>beIyGDV%*VpE#5kbN62y+ zhb5BiC}2(>FA+C-28IJWI4^;0iG;#wolv)3RFrAyCXjB-^_C)<5ru80kK}Wx(X(Mp zB8sU*Q|3a39_YV=hvKMPT@G_Ib_&CD+QINm`w+iPzZs(so9)OXzPaH1J6JIB3gOgyZM21&RxU5{Pkbq+^OSa(HZ{0 z16TNkU-+k-K66G8C`yxOQsk9o!Hu`y;Hi&&gvTGf4k(tZI(D~~U;pKw=Tjg15Jt&F zV~CF!4s9Rc?KfZJ=l;n*X6M*3p8f9kdHR{}NoSU5y4G^)nXun(XCdd#;igIiMp_-wD6W+L$#2F{VP+8Qw}9 zL+>1u=>|!>lm~WPkqe?q_no{vgQ8@>Squ*9JkQDVd^BMleK4%;Sgz(QmvfxA*eKf^ z9-^*mX__X|Srws$PXKl~&9-T(IQ`ThU&$9(ZCf5Cfq?{cs_z@&=V z#zaC>l)^dTRPF8UVx8m7H{Rr6Zx0n5I#@O~H<-<)($Xr;WV%V3=Ok$=Nqw%P>pMyF zi_kP@EwjmljoFml-CYSvi7woFB|twFjX`~Hu^#$P0&(7Z|9u{~@&Kn!o@HaYMVXfz zJ9doLb*$G*l#(T@wVfCh>rT{dy+4d&-@y;I27he!Bfb|$O8 zc>CR(+}@q@t!Mt0EH8NU+5>c5jWUY7$Z#@4rRy4;ZMb^*Jl9%t-u=hj^m znl#BMGLc3PIT2YZ2Xk4JXbsb82|8e%AiJHlxZcVOrj>XvVkX6UU1P0dwO(VLV_DZ& zFJpkkYB?rpN=K8CX1Pd&9kfkH-wPO0d;$(|q>;%c7@3}?scD*qySw)Q8Gi}BUXm2M zwi6yw+=U#?$5!ev{nG}SVGm|q?>#|Fp`r(LO7N`fwfJJXek4Q;j&2)`fZhqhrLCI? z*b|SD$fb$?GAYQiB+lkRg~LR{B4%2M^36gJCD?Mg5{G@19LwB4iB^GZ z>~xM^8f96IorsGD-6BiLjpq8*D|B7M=Jr;kDMP9Pq3>gWS1%W+3Vich&+yu7Z%|}8 zK0r~H;wST>5}QtEBb>CZYpNn;@6Jsgy>^+VUXFy`0dq3Y#W$x@PHtCx?iYWSXP$YM zbLY-5os|)*3?nK(Z*0z6L#<1B{M12 zq#zB#hf#ow8ii9NRh0Dk{b{x3=iJe#B|lO^ebAUFTx8=_EQg%Y=5U zCEKKH1p+5R29HY(S|==*tN8f`-oL#^)AguCkC>3EsxVm^)BnV9>4Km@eCV;Q_(4LD z?(&UyZ}Hx{H{M$JMgT?M?8+C`3X_LTj?luB5=R9@5oFZF&Ks- zNx^6`rSR61B-J=(Op`LY{|$L!@IfT9iIHrP#7L-^50ctwZ70(4%)~4wO$h0D33xA~ zGVdboAc^_ZK4AzJA85@(=7WmlgXL;YQB~t|J4;iXm#)r4$}N5v0h!1+6UnTPT6`ZJ z`~)OpMiUX^g8nwq7uLsg(&7Ifcuq>e?%q9;M6YZ#~|2+_`y!wpsJ;jkkI1!%vWAIWN5MGQRKG zySLA*l0xm}mtW!B`Sa}0=M@ zU_0jXB@bP@9$9RTTet4=-@pD%?z?c2&F$mZph!}&4iIrC1Cqucp-U*OW49V}SOS7-~JcB@r7xYkZ6zdk)LmS;pRjv zh_sLvg^%G#27|yn2P@Voq5VbiE)gbYfVqq^c~+GQJF+z5N50wdWuH;dMUo z(I-e$AWIF#Xi{S^X(ss#F|cU0T9TwG-~Zu@SQmKkf&1Cos3c3g>&UW#x8Acz`6OatH8PW3 zFR6-@vMjl`sQLZ>^d~43*B`jV6OUh)&MP`pi@cDBB~8&v9;UXll;so`0_AMNAN=P( zq3s->`KeD(W(h@_ViKayMtm)4T2QZ7C?G9o{ICD#@8fLGKmX;=QZMEdSw02O%)DD6ff>1tJDOvZe%Aj?ILN(l6=U~8*#L%`fN`O@a z*xBfWA3>H8_9?}Q7ZP%eTNZ_+6ffF{0!&3;2SF0%f^&E)i;p7D;)N@F5tA6%4FSP< z$;@zrYOKKa!Wk0@uweb$NW>NImUzTs?wzDJMvj+{PI8oVPNRnkM%wQ$lH z9n%VvW+S*WF)=1K9Ku)!7^4rC(7DZ(L zIj%o^jR!7W#I-e#UcJo8oh`hz6h*=Doo)KI;X{vI=hUeaTz}{ilRP0e36EUA#_Mmq z!M*)^T)uoiC$={+T7gb*LB99tbc5w;$wxl&1Q3{21)H;qH{W@eJ9qB#>8G9|QIgWS zKi_9#W5Pz2bMn|0moHu7wO3x_Z@>L*e(4u~hG~^ylwvX!)}3^g5nB@RE`wm&g&?x| z=t4glP6fykogq#<$Rge$o+s~)P!tYP+Cz{CEhvy$0U;V~6xp?U^`}+${ z9Xrl+D$DVvZn3swbGCuTqhlHzO0jcnho))R+TIv5KHQOqd$95wozXNk3Ljre#ld{e zYPBW=$F!P6CX+}a%OW4SQd%im-(n&H{*_l=kxNN=OyZeLbFnQ2sSf9hHDy&&*Nt=v zk+CCqXF(X)&{I|w^Z7!^sMg|xW3ilb>dcv+pxcTBP{xV(?%jhBIC0`Q%hggwHHoJ0 zn^6a@w5Dkrs>tO^63xM?15@$;{?V7%-rC^PpZFM$Ja{Q8RqVES5Fr&z<7I2kxhxFCz@lU|mOWiZm65QzX_BI}kjljvr@Q z74+&*PMBxen9-q)K_xi{>yG!|d!Mb@l#|D{IDh^=4i-y(^zx5c>>toKEwhuyP>CS+ z@eUPaG}76c$F4od_T~m3`S4@x?(MN|dcOJWxADCtH5%X5WcgG^J^RQ#Y$O{Z zDNa+G)taiRSgz(|MMj!uG;53Rda68+yMl5|+q11WpcOE!sxj*!m;p#eOQA;8x<+F=DHp?`c};#3foGBJhJk2Z#hp8Fxt zQm6+Zp4~dpBk5VVWFnXtVkKAx3n2=}dddwP0qx3P)3`L%E>(&QC+6|t~tu66ZIEQmRWyG=MSx(zZ@?B~SD$nC$ zRZNATsMl*;Z&5gkVv{71k&!rF!S*dp+mM$fUEi@-30!h(Ym?(Un^?PIwOTNnZpH<4 zj>!zRx8#|j?Hu#@ex!%WGBeSJJ~*1Dp(sk`iv>Xirqcj+3Wx;jR*r z)Sz5IMYKWJ^=OmOTF2dc`*bL7?cU>yU-}9~mhy9-`6TUp4&IR!B_VjKNkt#Su@nKk zX1cM>e}DRGC=dVW(@zol7CqqlV}^q3EvYt;DmIUw;BWryF9_c8_#@Xiz9ojbQzy5% zeQ%F<-+P~R*RzcnvhLo#&H2+ONpuY8B2cfFY)mWm7YmN>Y|=VSSxrfTrOY&0j4K$% ztatC-Axllfxy!#((Up7Z)LFKVAIB@{NahCzOePytRmpO-6zOj-K*F>T$fb9VwrQyA znn|9~#~`rvdQDyx6fqXJjmypH<|gaan$>d7bW*Xux6jtb7VBEb2j}kJWU*L}??PfU z`D|PKQQqNgFUXm$AxQ)>C^PB-{wNB`wiRX=42XD1#cR8cEK4GtRnzw3v^Lrh5^*!* z0vaXA`8ettMxLX}3h11rkKtBb-=U2t0?`skm1c)X_Aw(xDY|HA8#2Q4qM)g3ilU%x z+mT5oqp4V!D#o-wqXlp8NeEph`7%0{#Jwz+E|7ve6=j+-31v~zv<+=z(JGjfZn7p8zPU+f3gxJaWVvzL>GgXs_Qp6+* z8#6BC7o0{FgpOkrgT@fN;4Dl6UE7f7qWY=VE2a|(n_4Vu=F2rY(KOA9t*s4dt7BCu zrlge2hMqJ@Sg&iUqF`M&krn16n@NLnWO>GV z-LhOQshfIS9xAQq8o?1vrxQ`Sd3n+1^EoQWh|4<1-Me?j7s%QkljbyShYE+rtERF1 z@t42IojZ5<$Ya-d^y)>54Enw%1Wi$u^ud$nIi0gqMNXkBoV6^Qmg)8m%iDMP+Lyjc zTX+1{KmR4VdcmxmkR-X}t`E*tD<-G_U^GqN@!a=+h&GyYXHN<<%*#?VF^Vk9SS}=W zGD%bJ-nqk#civ%Im7G6&iY$%G(k$nZhp%yazR#WcK4<)C+Ir2UOBd}dM0_pDVUEX>3CfBb&%HE>k z=AGBsJ(%;=ul)rVE?nfj_iu_kJ&tX=wq>RRBNF~2?_=nqNC%*nGd8h2%(nnkh0l}i`6aNkL0(}Kjv{y}8uLoPUn zDRYTrv>20Oqj)3qhlJX;?c?%3BSb~cK%O;dG@2q4*4dD~t|E_W2;?%xfR?kt#!QK# z$YY=rBldjkj5y3Rkw3#nC0Q6p=L1So+Ey-6EfAdn`dR#*fthzB&s#h@*)sqTr$Ahgy!ies|jthj&e5}+a6f08@}^*KVY)8 z&GBQ~aqjM!ZEo&h2%dV~Fsn96ljwO+o+L}q ziKt=nJQFp7RyghHx*AhW=$!{0;~PziP6Ewp5r?~CK60G}=kTh-tC+tX4C{4^#Zhg} zuqduvxli7x(?0M17ItPi;q=}-fml&m4w{pAhY;TfP6WYZb&=h6KYE_dZ zIm>m;sw>DV9Z9=6%cf>F+Z4r459jZ{pRax6JKVW_j}u!J<#YodXZBjBpbaWUqK4qe zi!Gk{{^YkCnrd32lRz;kX`31y6ir)GRU6#6 zwaeH4=4qCTeg5_D{3?Z!i`rx|D@Vr&V>Mu`W#r+l<;Dj$+1lA)qb%8|48hCDXR%sv z>f~v<&QbS{DvOsV8rO9k-`r%q+-3XRX^ffB+Md08yJX6+S~d_Ni|bY@x6O?ySuu&3 z8IGVlS_cl6HP65JB2C-!;FT-%wnryW7L_PSA`X6tmNh12I^E#YpFB_7b}UybHl`Vq z=`sH8|MWl5v@MgE@lvl>G>zb5Ok!B9=9H6?EEhe#*a#*5xb12}uu;_)DAEjPos0@% z6`3RnWm$|tN?J=`SMUxMgiM`gIX7?JqKe!$XC-2Fxe(m>6ymH$ixIJJ8=Mc6Wic-BT^Lw$8Xb~|;Fp8c zXh>2`+r%vO#L#w@G|OWKhPd)mlScTe9nmTSE!Q)2N0KD!oEjsMz4N3x8L{x*du*J1 z2XazyiYY`tse~n2J(j;K^ z_7BD=NlDxjRZnV^9NvYfZvtWbfrgN$A zC0S?4i%LLnwg>zeBf4CzNb?NGVTxeWG?ZmYRaVT30^7FI=9VRsYJ&FyCamik66t*E zRm*HP<=(*_-h=aj?d=`%yuw*W-*y-+Xl?I7Whv|RGNQ0s>eZ4f7w+e+_ipk2%{!bt zd6FX1h~K{PzQ{+dtQa>ow@6GXAj+6z=Uh*39Zp&*cDANGeDwhmI+RWc!IEe}Mc5E9 zNka4g5%y-WmSyL8*tf>L_c{08n!2l-q^WMsG)2i8teKQ3$s#2u3hW?*Y{x+!5+KNc zJmn#81o9B%B?%HpG7Lk4lfaJkph$)l1+pz$5+qY0MK&j?#il9t-0ZIEs=D`{bN1eA z%ER~n>zpzYxB-GwcU9eU_Fn7%zj0mg_QSVv@9mfI+rRsJc>lA{;$uJX18C_Q$y5~Q z@EgDWkMJGe{t^7d$NnNpT9D>N`~prgwFbQP*4y~Mf8#e12ao^wum5$7u_0%=)|+uC zEdi2>i{33jF~!wx!R6H+XbsOk^DdOU!ug2c0?yAb1corUb@L`lTdDXO1~?b+##{GM z&4=;e(KVjBdjT(f5HUn7>j80)&WN13HC94?4V17}7@Xg@g|C14Yna66bAC3WlohQ} z735{2U6v$Do!I-JA3l795ChK7&)9fb3u;|4ID?uFRzVz)uKIBx^4)5=`9al-!kn!cq{_?>xA z?~eDqHLtdcR;p}V3k({bxN{rJLKTC+e-OpJ+BLG`G5w9vp?Bc!@~A zcFA(NEIsJbdyksAZDr|kSLm{LcvX)t=eeKX;xs`!)MtE1<48#m- zyueEwjE%K0mL5H4jHCcm6cHw{i%Dw98@IY#YqvCLw@vSBKdJ7`Il+VxHU=-|8+$Ae zY7J=>W0G^2HXBALNQLB*62dUx`ud7E>@s5r0Zxc7BM&1VGLP+ci@)%lAI0~5?{{Or zJK&E#|5g0*-~9*p)nEG!JoD@a@X?Qa6h#tBhanJ;G*$?A3l{@SV2~OtB>&-Xu;L#=GFyY}t#xlSD#%qWJbA+sQc=+gwmw^lTf$#YX*j--Y4?p{PeEo%g ziRWMVCjR7wm++ha_;+yc;T5j;J6xPknCAo5b;VAcrKoiBPundXTrc?iSDr`MZ1D%5 z`#fH~cZpYCzlZBXLa7k7PLzf@;QZz-eDjr8Fr01i?0cTY+YcT9afFR$D2RaH;H!W7 z0*V1r=7SfE!{PcGty!!!;8%X_H*rV}AOHA|;?dPZWHf}$2H83M5C6+AqBa8;gmASM z^E~5vw?k=w4H4hEcTWNo2_v*pfy!#*!|ouf_KM|jK&u(8X1wpc?*WX%*S`50!sZB_zR6)&1+om0gyBJW2GojREVJHf!^PPdg7?g@$_uWp9%A0_P}T+e zt4mxxdVt;KBLv?7@Ulp2LRm8o*9XKfFml~k1RoIt;kM`_4U5BwA&wa1i1Y0Q#&Lvq z0Yea7s8vCP-1r@*V^F@7+w~(HPnTailI)X8Oyb>#QoScl+_`C3|zrR2WmO;P-@ zc3AE-gfQi`pP_mHt0%`xCY+XG0jB%!l*=W*RIsk;*qP!Y086OcFtWa(NIRi9@`^ag zs&p2x9ICXrGkQj%D35_keyb&R&!lA$32%6;e2bq~V<{G>__Nkv7&q|8VtdAysO+!7 z9?R!}?n2GdL%rW0aB=e{t`9SYA>z^XwKN!}W9?`RnD^}9zH{>&-|@lsBD9RxUw;EP zZ(QKU&6{{||32rk7KZfQ>)`1Opx`fvR-UV8b@aCLc! zXPpgT-+mi6&TjOHEv+l!G~&6he2MLKF?8iwFQ{=2%RIxnfV1;+ z%<~@e-eNxNu`YXT&)$Wz^9@{xxVU*6)>=$w7r4B>mM&x9i6@^#T36hDas=+Zh5zJl z{=4`G|M369|N8g-9zxi#WGW7L>dpoJ(kFfxx6UG5%h(PPkW3%%4CZykm%sV~{@}Bp z$07wy|MHtJ;<+z<8O~UoPXYhVU->uj%ui#)No(>ldoa05uf>kKg4c#MHsMF zR1_R)#Zyn*0&;~PM#dmi=?HKh|K~sYO#lUG1)qQJOL*!XPve!h9^m@&5ng%iHEhqO zKKWT|C=hTKrI1ukrQ)TRUPY}1cke!d!!BXkj@^yhWtq8QGj4F0cbKMtXWsoZKKHrL z;pJD~#E<^?4ZQy9%kXx>3xE0oY%_TFo$r7}L2z<7h@?CY5w!vHVZk4N{Y8BCxi7;9 zhYx(<{do5~?xN-e`|BNy_uPOMzEsmR_2Gf#K9xgK90VRLvUL|}dgJfm9Fq1YV zP$VKok993bYwP58kh+IbIh2}$UPXMW5bGO^wCL%9l~U?e(ODozdJ{5ANx1XGEtJZ} zN$VY88AN(G%z#Yd>T75?`eT?fd_WAe9pqM}BG)6!uV+ky4}6cv|5~YXrO$~|I1hOj zY?nJJA83U?0!nXFC7jPck9W4`PpR0)I$8nrGCUO?dI+jslC4x2 zCs8G>0*MvEcwO++6F0FPKLUK$N3gCH zZ{53(4}I_%9Ok{qT&c*|j2r9^SKw1eywAeGG>d5*@Zk?U3vUxluNHNjUJ~<%O5+3R zoM&5E;z~RYBi?@S0RO??{2O@U&TXvo4ly3xtVT>bB+wmRe(6P=pKam1!&6V(#((rT z{yp3{AF;oBfYuB`9C0QpjfRRTjzX>`$^PGAGY!~o4F1Ev_HWbgkSkKo>`$@0D4e&L z4>R&v61?1F94F+m!`(Z#@IBx05xjNpO{TY0;NXr2k7QwNhZWsonX0RO{zV8!1jMrX&6)(N~ z3Z8%X7Q6`oF_{mTGG)sm&ovKwjt#jeS`RLvQ+t#Tnij zO3fI?fkBIMxHenm^2l(3Tlq3CSgtN{aq|K>6%l=Ksx>fj_%ab;7)R`OY)4#|J;rg1 zW*ky2NUPxFfkkV%Fcmk+dtX|WOSQl>%NUWg9t~%p7^1eLmm{TwrOpD2vs!i>#xBj( zN!y2jd0jD$Bi6$K!CL8kC+CoBhWCUPVN8dWN@luoC**~UFSTOXQJPyy>c1EJ*9{H_ zs>ooSqydYyOoxU_SQ0d4?sklkU(!LsLL<;D zu6HbbLSr(ySLe5M+B5g7VV-Afw&!q`kZp*gw2+dYYmx)TrclxZntrT_8E0o_IP7-) z*DSekFti3m^4py8SWD@)hv2E+YM$+dx-lAo7)zn@t2V-5T6xf#KwbaaFa53d&L=jo zDBY^Q-|sPmk<*u?FfulQ(p)b_p|s@Ai+E2z0HdFs6w~nibKYUU-}g1pIfu0Jvbx?~ z!dYP4p22z&SjZLA5OA31$HL;gb2uD!oT`R^W*WR3B*I|^tm~m=TrUfjbw`*yX zEi3&c)3m`n&)9A^GIbfjxH(Eb1qepoA?T2{p zd*6rM{u1-N2T)Pvu-fnTup!WRXdVB*#(w#6n3#5%4xGG1lReLr6pzDzX_|0-b&U`L zHrp+(ujxI}DE?*H)1X(`8(m8Q3b4)#(*ni`6&0KF2}a{2Op{PL2G2cs_y|{5*SLA} zCVyr$SQ9V}BQae=$UzTZpuro9Lt0TAV_z=LFK~5ri6K}xV_}-3as`#&m1HBGYX5U7 zsIB3!u6X&i*HIHIRnIpYJbC9PrYWMCip|*u7F6Asrm{nB(U*^QI6uFMTr-SQ^-jT~ zhmUac)(r%H5BRN&8=}NG%^@9~?88HX9CN z`@=yd!z_bI5s%H-r^N<3zFTdW#?7(vwhBoJ1Mhe!jT5o*6G$BB?K4*dB4|^0c#LlL|zt5L!ggw3I#N%=B{Bp_STA zaWM|CXxQ&(Y|qZwv^pQyW?9%H4{-SKaRbzh{k($-6N0lq%~<9GnxP`F)dGu(C1sr7 zxQVhRq=gCQ#yga@pe_~hEa1VnE@20QAqJRgV1k8dhOsy%Vh9#_-q93lEe=aTv><=m ztZbeQhrJr1vh7sq+IflhqrrPfIJFdec4SU`@#33Ta0nR$O zK=@6Gq}EJdRrLo=IpJ)xW#@SuBqJY^gzB)_Y_P6NFEmRj}7|$ zL#+nh@zCLv#2lb$v%!rUH#(73TSJJEb-9L71R)HR>$TcL->Q&u#8;WpsJT5VFZSNS)`BTUY&R2fTCm+tG@LCY-j(Fc(ye{-;tY8`z&YZ_tx|wd zT9dpdPDjHyq1Mu&!w>?Nb%ziPHefH6zRjry;(B^LIci<%T^-!y{9ZYgeZnk$Fu>Sa?&$S@_U{aOw%aS z9pg;am3dkzW$}%aBNXfc=J^2U9h_%DlU@?*%mX0ANJPLeCyjJNLoNxa&?6MR??iIW zD=Jv}V*t#{ESq)0X1m4J^)=<+A&?_nR*X@$Fxi@nVI&SSp}eHB+FqEwCT<1Ba*!N{ zL6UM?k8=;6m8y(|5d*Ps3!3f5La$SauR{3TLw z?VA@E;s8Hv@vFb~>v+f0PvPuri*LR45dZYIe;40)@fCdgM?Z=$eCaFrqc8q3(wcGi z&Xf4lZ@h>v{Lz=On`b<6_bzg+`1RlTEo{$j;>P(IUU~gnc=KD|!tEPpSk{E|o44^# z{`v3Y=_l_XP80t0Yu~{Ad-pM~3pUdr+Y-|@PED01XST;W)+2c5<@2t>#W0u5yNDY% zZc6ExK@%9+XfHj81hmFPYI`D@Fw*H#N|gWrI)u*!7)%`H%=p_%?8ah5=W-u zdVh`m{@6HHa+SlDFPExNl-FoN2&o~7H?Z#pL)DuP!ywozp}IRyJb`7+$hAq$FFiG3 z7^&x$LZOt_e#m<7@aXCi6%CIrA4zUZ6PJ6$Jg;yrVi+bI<^{X`jEkGMFpe8o<1r2! z#2Ce(1uCy#c=1TANGS~|RctqB2q9vermom;y+Fj*71PtSdX}e+XvTYbP0me2q z-PMZo^DTC}>mJ)3!vND7Zk(NAkPh_o?K#GAK+X$p+`NG%yp-bC`~3lN7!YH`5CfLO zfrip0vET-bSP9Y5I8|3pQe{Oh1xZY3Std-=8m1UA4xDN?;yZC}K&|YoP$?yvwDLJG zsuLz8IBPgnJA=H=n1&!wDlblnIL5lJxVpORnEWEV#5^;g?r=E3s3#%@=4MIvf{_#C%|FZ_7ZI&_sPP2R(ER0MJ!k8zr#wL0!WR*Sj6!Fo@Eub>eyzD8+jw z8*3B=URqbgIN;g$ydOXHQ=i10J5L~|g6F>Y6@22yejK0q>=&`-LSGB` zPCdHZ;h+8P?_=0*;e23{xTpq)VZ!q-d;_=d+{Ndg`wFb{c=?sr@clpdF>Ehx0@mTt z)ehhLy?+tUKmRp^81cpD{urP7r+gwyX)~Aw<0N^6U7v zZ+iweZr(yF8Rs`{;K`@nfx}_N5X3kr@g(~5w#tio(C1Mgu96E%N-$ttjc$pTmyh_L z3;Tm8h=^#$q6mjZZoTwjRYen8I}O9c=u!*PdVsy$?XVFD=coj*aGr9&VTjo6c6jjM zVefNRDc2%K!^_J{F<9l^bQVIue!u5KD9vS*xv?sDx7(jE{LBR!N3Q1~vT74D6|HGT zZ3W|Ilyu*MHDyWJt#^@2FM{OA)($Rc2&Ntv#uigg!)-7_mr9 z$~v#GpkG1>;#wJ#4p;<7h_*E*uNuQ%?}CP0E7qJ*(fU@Uht0aKnAa7#(s)~>Yzil8 zH$7wYU#h~Yt-#54rW{JIvpuAn;?%cVB zWnM7E0oT`;Sl1b!{eM1-d0ha@?7m?NcxvU9;4A4Ff zc=5%T@%o!@BIS%%Uw;#?z40bC6Xnz;X9<}!(kdyC)yYB63UNNG&a0$1OKo%lOop)y zCDYeK$%BEhUWVd=hT2V+UEyUdYntVsVH}FX6gF^w<=^<}Pv5z@#e6s*RR#!_RIua) zaTsKU^$5G|^i`ykFocnlswju7L=8WB@Cd^& zVp(Ru(h#LfE%8VLXJePLImbiddbfkMt`q;xIr!*%G=ed7)0R}l37k7=ZkB~kZtL1xq#j8deIxFSox!5~rfYf2afCdD?;B-^SvV!Oi2 z87rLzosQ5?wM}YGWv3lHkgEEIopB zX($w9tzngqm1<(KK*YWOm+ySmJQOuy!Nd(ado|;yMA5qi68oIeDHnG;KdhT z#8Y>l#221>4sU(y4Se_mAHvtZ_H}&EcYQkyD&F_O_v6pL`3-#a`RDQGTd(0ezx~5V zYsEMJ>_yzUa|;a_hrHdJ7>0T%4c58G|o<;S2cC``(8j5Tcd$ zyPO>A3N7h#SaLzBOip*!_i`|PULqio&&q;>i+;Dpv~EYDq4&-j~VobUw1Z! zh}~O@+!&Tt#5}{T<@%%+)6zYe5{AeRE3H|g5wZ}+P5d84NZLm3LQGk3gk@}V44<={ zq_ik&7)Yju5ID&I*qr!D_WQl`WpgTW)=TNk0vJXdl+4=Qbw03vST+FbnY^oA0MvpD z>>)&b&K2A3S z*Wx|ljoMfXT48b6&Dd-vlw1+SIjrzS$_YN$ekd?)po+e|^$h@^ggJ;=%tboBO{qP= zlO6);05=x<>jTCy2$|am7mUnNGW}&s2$Ay882On)2w~lzODouHCL9h23Ev}^5@YCa zOO*q&NeZqNr!qH}+KqEewWgZtFdxtyCroe1?K!z9N+~EpKwHRwgG#bo+~m%ZoUXOP zTZ=Fb*zFJSmI44{T934F-oZ5G4f!l)X&R(z?!aa`sVbl*OTA->;h2ul;JPHV+PdjZ zc`|**n@rpa8`jp~t!06czEyKImw-l`uP*da8)ipP}LExEYU|M0) z^f1T(xByW2ou82#n1NPHCJ!=9@NvVAk7S zhgE1~jHMXEa(Fl#7U4rGHk*+KK;t@ht!MNi=H(G!_x-B_2IYhL3QHy>uM`iEKKubX64@k>7(4yYxeWa7O}(n)po?8Q<><&nJEj1V;1 zMpnc&ugzjyy)(AbLsG?s?3@=Yu6F%7)nHz@OoL&Py!zHK4bzFEv=JvXO|bmZ9qLB( z?(UQ<{dbKnww|O_^d32-9>HL6bagVqK$Hr`B3FHAMb-=;_yB7?a<08VN?{}2 z0F*%2Z2nV&XQ@mu6$}Rk?N~V}lX8@78>&`(=bxN&{JEqcNgd&8U>c_hhy5&l-fD6p z#}q=q{&46)YTkJ<)U__x)XABV6iwhxHBu?YO>U>k!IZQ7+?7pyt(Jay8)KOaU>ILf zY8CjabS*w3NY4AP!~O?C8JgAA)aT&kkf@Zx1|NAr1j2!A^Q%3T)QQAVw@{VVrjuv8 z%4N{8`=&7+rh6iEGf}aGG4!D1T(}X*MZicHJrwxsrcyW{t*Lux6dF|@lQRy^C_c`u z&e_lhk{Cs%o9UPGQiwE+gsA3a?&S69J8onXSLe9%u4h0Qn4XMltz#yeL>CPqqU04BlfuNz5iupie>^KZG=H<#n0%@6a-gTdr zwFY^nxw)dUN@Cf=7=fcjx;qRE&$RNYH^Ys>pS*LYGF~g)LJgFI7pb*1#={!VVL>44 z;5l6BGok#GK3iJaWsHS2zDK)gJuy3jEMPp`CqDK=pMLrsw~^KvtrP~>1f21EyASu?n%-DlyOC z3HLRb{wOi_kGWrUg43Ge{^B;uZA;ETt!(%6uFI}D;W{h}4{aMPvXmViAeV zB*(gN`U~=Nveb&7`F=iNyV+u$XUR!po1=A3AWT*zXX(ARww=UbH`aFc&r^H>wLNBr zi!q#ZfU6^zn6-KlRJnNSW;Sx+YLc$=L7Mo`)r7T4n}$x}axkaF4KC-5ERJuZiK(?w z&f1PYi>c`FgKp?e+VWxy+;S!VNC&3|Jy2}lBAQeIE6%QdZB%rRfN~v93d*v)f81z` zd@e^=&`FZ45jUn&8JkDIq&3>V^0iB7R3~~ z!YrY(A$ST3To8{&L8+~$=j!Fj3zV5vXd0u^*b^@u=&jQ8lhY!JA4P^2!hs5DDVM|z zlHWs<oV4a-vSPE{Vt37)E^6#`6LMJQ(|XX-$<{300zo!}oT&F6$B0&|C}Wc3 z#vF5EN+B*kZ8iuovNJ+98pZalW9|*KtHD~tVeE&^X)URmcAO(D6?~8$klg?GRg7n> z9Y(*@c!ATz#3(U`DsW?~4KYN)$XibfZLMM)89Sck^PuBthYmzSn zo$zC%`hlB@IGTGoj%V6}^YjnYdfdu<@Us55qXEtn@=IbmZ0-2+a`Q1%oOvgXZ{Yw9 zQ&wXfxYf%h1!;zJ0@oHvYp&vnS<@(EVKX zVw88dHcfj*&j#!2FhD#e0$XH0!(O(AluMW9HrXbf44a{Ez*SUKTC0n$JZtjo>j4HC zm}p)rg4atZp(j^ZhsV_C{&cnP&uvEN?-XkAQGYQZ>?Q);GRnP0%b=~5zLcUD5@ZM>5@U_0KuQxvn(5GU3<1kB!^`kt ztV2#!(2&|kqn=bSY~X(U$A0+JAAJA&ary9mZ}JL0oWxETfhuV7Y4YM`oq+m_yrQ-w z`eKWcQ->M={+j9=buM6gc82TSPU4GeKZM6&z&Hf#4?C1vFpTW-aE@KnAq;&YndgJV zN)S?_#@WkeA&22KVxA9Ds>9S?o%%|ta5f-}%p{6Kq%6@IDTZ253ynlE28_cfzb7}A zhKAq=S*IK~9*uXmV+fo8vXmasHlc6zC_#%!ZWsyV$U;cqNg(^~1C zW-=$?7QSqyW4SR#fyj!)Mo{*;t_m?#SSLXnhU+*Tv97B-N|E(8B}5?Z zG1AriPLOQYu0^A9I6hYe*%rK~YP;1a9%3WMUSnB?RVD zPA>%8V}DAih~p?qts+Vl9y%#yK!O+3vch}vMNVAOM$%+UV}FDOl{)7z9}YM_JL@Lj zF7yznYZ^zi0y$^^w2Wc!aKVzG_5lv$&r&@ad37)efjNYL72 zI6P2+MnOf&3Eo@eLa|Da!@SC7Qmb}rOCKJrWzWgFBpB%$an5vSI!@x`YZZxd%F^4TZgn+L8W96AVv5Q&W-dDs z?<~spo~-KoXeQ6MERZT&)A{Ro;Wsprs2K>yY~aL%sPgp=6{ZIF-5>q%r@!xe zz6*!LjuU`8PC7XyjKhGFm@XU+2T|D!lJ{aHd6UI4^GY~D@l+;p7TV;k6-5Bav9t4Y ze%PV}L2KAuUGuW?)F02Ya4_;Qg%CSIJC2jYsW>r|o!pj6-b=QnkhIRz}G( zJ(UbUL{ZmJP37z{UZfNr+|v}13jGPzu=vLUk`u&>#hj$MZnl&7mQsgKudlD^(iJ#H z`35gAiuaziwKT-l1_L8vit<>IPnX#XjL7H5%7F^_K(bn=M2$Dmsb6!ibOJ7yCgkrkN9sU2PK<#h8I$ntXNWb( zt-yLKo*@4HtsXm8w9Z!R7nJu<*>Gb28`fL8pw@NY+cEZ)6-%M1r|2#EX|@#rgSJfAFm#Ho7h| za$4CGS1ZywV|%vYxnav8Q8qr7KpruX#fcsZ1`n|zd)RhyKO6gqkD(8L##QR^)|j-5~o4uC40&s$kfNU_~EhC)t*7+I3Sgh zrHzhB$<|V)n%2aW2dS|gV?bUB_wYTVYKuYy=`QykrE$R0=d!knhT6jbtxm>GdM4z# zfnW`)+~W{Y1P<5ulF+Aq!85sy$R@5AW-k0^t>K2atemj*uq_QDZO^imRn{ChCIgqE zFgRt>*2(5)t-w7Za1qf$&PgQu0O0y_o|5FKdGWJJK^^K*X-2rON!b?9QR}Q+GEqEQ z=o^T>>(-G&VI=(^Q@@jhV=J6*#VR5Nqw2TTV4Z`Hp%103&Lz)Q3%?t0ZReLD1B(%v zyNYdeD>a7P$s%Zj6N`nyW4bxD2CQ&!cW-U}{HOl%&wTeR@>%b^bQar=d80OoC6PKy zB~&EokspvJ@hZeuUNaA8?=fw+XdrGn&nv7PhQkn01zYZF9njIMMJH5}R24FiY%%2x z?%%tQ{r(!$I0??q3CUT3ZX6$YEqZd!!Wy>hskteHQM@9opKYxndQQpOn5OU)zkB}I z!(mU)NQFpkPs9f~Bdz3Tn7w8QY1E21MC4X+SQZK}3OzcMfa`a{!71m|4Ta7*1Z$YN zdP=(?IwRbcu&Yx0A_j5=VDPcqEj66hkO0yO$smdvVR z#+NK|ka#(K3tS99Qz(-ME+p))l-%okc_0#+%%ybAfRS=2ZpC73EJw$=4t2d_qEK|P z87J{0QDMiwU&9)!Tu7p*1EeArT?}BIIjOjHU3iibg9{J)1sN67v_;L8b;hDjG^R06 z%ov2|ACnLhe#c`WkP1(cyyxe|3!j6N^1tmQ;7~;!RDfBT>g{9XzYYbu?URfbKUrz? zEy?A^{uhZSGRKIRS}WF+dyI^7y%3I0KYQg+PE~QbS)uV?S-c>cE=(Vnd(8aVjKM44 zx>wxye*1^&tvgTt+&i9r!W{OOaBjp-(|KjGu!d7s7Luq+*cyx#*KcH4U*RO6$`0qO zxWW0Q?RM7^iYQaFnCylq6SXrah3?yBInX3$4bzj1m|-fbSG^F?#x%I#a9HSF@KOm$ zjIc_mm=#yMMQJpng)kfwIi&;MJKJB-Aq;|{u9PavRGYJOCK!^ItqSZVQ}y9+5d1d^ z7sNgYogR&Zlj)bP2cJ1nx0O<{k^HVCB;OFn4(lm-3mEZ5&?n)o>w#)L23z2iDwniY zc|xW;?`cjurfl*s7^kTpG8Q~2o#F71O2N39$c4z7ZH2|cK*)Ol)ebaPg`D_%(3LAGnxUFX&@X?nAnI^-2&KoviQcZ_CP z^C|rI0B1Ehf}4kF zM>S>d+tJOunl5K9wTQWt(BYc7HI1XBVoPS8VQ3b-3tjHZ5fFW2RkNx>Yt9lXR6bRe znyq_L`1`9YmkF}Ao7;#6GzL<(0&AL}2HbXyaEh(bD9YTeCe5SV6peEtG-#dpUzVj` z9srp0ClFOylU(@x{9>dOzP8%^JuKB~M|GbS*r#Qnl@2SHT*d3fpWDi&W(8K_AKVBh zB|j%mT)7loha~&+B+(>jmFWbiCn`^YO(iEx6_aX6G!9~vuCOtpI*TQf!)q|G-jY*k z5TQpAX>w>^ec{Fb$-VoDi&tOy*1aG6bYd zu@SIwV$-m%pk3-dbQzf%e$+u6f)_U~336lj>+7$3ael!LV4Z#&l|$N _4#<3Ji zQ$LMdc=Iy%ElMAFHH{f@Xe&IVjb^$@)KYPEb%iQgbm(EPMmp_rkgZLd=$II7`-Y=3 z;94u@dG4iE>N8PK%jq;g43krEYX#fQ1`z!^iT5UiR_lR|(>V3G5E%U#Niyb~&_q?1 zGR;#l2L4)&hpcssuPLHH(M%`hL99R}RaWQP)UhuTQ+eUJ0TE~Rfxm|r2yK!6kSd+` zgbQlNchRS*CbgaXKE`Qa97AKDcgb2}#Pk3I4hKrW@?uVW$rT4}lbBu7h1-2vCV-)t2$Z_ZB3vxbi(2>X={;d4IR`H%R zfk9|&E4_cA$+M*1r6Td`C1t`E0y}H+fL?NgTsYb*LeWd@{+JMAm#S*2gp<51=h#w5 zD0`;US@;jH{tJP3r)iQT1D)PmkL97uof~1!>7*;YbhTzH64@bV#N`V3SNtGOn4?-_r^}&>q;e+dKr^No9428MN zsI~o1Ju$o-om49v{YV^2Jbyin%W+=L)h4B0sTY0KT6k^>r>5QK>Z@_5oEwg3rgDr% z1T$!i?KwX?7f9E1(lJZ#h!uE8m?ezkAkSG7a@t8}L+q<^ zF3cPn$5En;O_!OXRfI^wKFJ}S_0X4UPkw>6AK3bHYPYo#`HIQkx_OHP>9Pok`?#wP zzZ3uf7rIGAK~!U(a!4LXN}}u3ZpECY_$dP=3z{5IN~){;luq^tFL*IyESQ6pq)(vB zg}oJ6j*5&yTFLYQ;)@>G`GO{EdB*N~k8v0{^~(#cmtjuKlp^NZdh|z=3uF<&1trAP z&uf&0raKcBNHj9YLpH>Xv}h75ZZQ48T$WXMCexF66~4m>VI2ly@RaUK4|kTFCLz?L z6#N*;a?7c7cqI7Hi-NqsNc^)_X$M^U)HV(yO5YO5acFw#sTm9+2(TiJZ4ywb{DTe# zO`t+0(jCafHBqA}glDalBn2ZS~`4`=HCD3$5X>$)I%##c~5NN2S6 zkI)!NIc*bni1G*Ow&%OGO0%8qTm8DGj>CIn`i3tUzNTLDV?|R8{yR>qlSH$2& z*wgsTfH`m0F|jyEYC@9@NKLQ(&nd=FLfZ|?!m>P#*=Y3$C+fQ&L+l53&Kbr5OYc__ zLtzykkfdi}j0C?xqcV7L%b6@%MKl7=d z`ObCSOC+#jnsj!zR$weAEK2nX@=E=B2-NxKN?2taM>%vj323^f;{9kxg@ejXfuq@K zv)QnCsT8J?8i<3qbn_2e;*Rm~)LMH0R;>E`(6c0l7Ewl2k#Xiyg@EHrQ^jPn%qyJa zchy$<+BZ!b>gZKf(~cd^MjYiw*D@zth0C=)ay7S?Ou-w2Be*qFp3~*IJ8|Gip9DqYCd8$B10AylYjwNKGKlQ3%*9o(JC# zjMB=9&w_9-qrF?_563uQKhK1QOQu0rwor`((&6DTufmDZZJrO9Cif`TBC)I_6pr_MFtVrz}w7yF| zh{>|Gu98*6L!Xt_d7;v|EkbybSg#zJ9-ziX1Jlod(LU z>iyq#0PwB*kJ|0+=B2mpKlp1O|M>+4TE*coGj1XT9{z%%Ptz#c zXBzwHpE_#Eou?L_=dcp+R>)f?Usw<+x9Q}1D_pgGpgb_fp+|nCw00RAj7EbLIczEh ziBZsW-Q8|SXl2b9r?KOZ8gHRWo`!}h6M8Aqj3qJd_GFv#QVDDT67f#)YsS-OiTKco zFvciKA=e%1M@goAOr3F5Y}lS!tAAg~g_mF{2r)|YqGGKBe+DU(@_n^+vf6XX@Dg~Z z%9tvf-{rmUp2a*lK{)E&C{qT~Tzy zm6YRg+;nKD6!y0h4rsjpF>&>6p9KdM~&tpp?r|_1T(MSg)}hL<*Xi&U8qMfxZDX4_c>YRnxh;qe9O~ zAmu7*I`VbP!b)B8Afc-Zwa1+osqeqHb!c8_Y0MBiral-6S>*Y&E-PVg z=X>-^)d{<4JPZ01-UO|nIOuo)8^eOJTARE_tyj#dLtiUyvp~SmXI0*RIp{dt1}eaa z0vwC6R4}1%V-<<+n${kF)KiP)^U`6P!?Jzs89uEF<^ZZfgeDe)+F>!$0_dA`489Mt zy2UfRvq^jt;KwsO|H4aO|L4E=2mjTQ%2gi)ZcXlutIMl(dw%n$-t(SkOiBx?a1Lo* zFwX~Uw-?B{!dY%8J~(Met0-9t=A{orVGO-xbIRCmCTTRQk^;%YB>3YRXsyFxo~dbe ztfCwxUR}u^Rj;V>DrfptD&H;`G=3O?tGhx#4S)>}J`8eUnce~yLO?D;c3W!jM=}0c z%OqHxaLJLhzP^X(k(Nb%4ok_X5RySi?c`u(?L<}P~+YR&&s+nJCe1X!+ z&^7h%oS&b|7TCnJq?;tVBV83@LAb0emtLz!5?6;2Roy6;kuqE3RAED|iH0`o13-Tj z`L%Jt{(6VAVMH!GfT-$+>o<5JHytdLQ6I;9+76yrim< zkj`?s@bXAGiNYwN=GHf%E(;D!J1!cAD%d~7+i+MG1c5YwMh}L#z4iNb-u6pN<+NzF zzi$l{Yg!S8*u@E5@#kDu5ji2B)%eC(oraW(z?w7=v@VC|^bg&j@=*~MqVN8eQYAfE zRAM|2Og(g5Pb4qbgmD`CG&F7|*`8W=*K0DaCJXO&toFUQxDXzKJOEek+U37!wb#_P zrZQc0yq;nsR%D92Kx%8K z`54QghSHRet%HZdfrp`(WSJPS^kf0{e@jAi%9sX{_s$yxiQWf*A+poGi81WR+hmb~ zi$v3CKLevztkmCXd`I*=G`;gU92RcYjh|DKDYIk}d)1~Mt+Bk-J8c`|*fodw6p;}N~ z+iEaw(gm(EaW++Ek+3!d#|l{X3`t@_h?tk9KfhKwib&F{BRxxAmPJWf>;<+r>cJeL zw_{dW5e`?Svn0*EAS_YZDNQ4cn{uY4*|LC%bq1`r)+7NhNf67jbgoasNSi1{86o1O zx5J3n#PyAWn~4#1CEsc1PCxI~RjwMdqtLgp7FG!US}TSSd*~)B$oY(Rv6O5oY-t26 zSgBh|LGux*ZSi-0>F@tq%Ikl-o7eo<1p)xCzWH!{-@EVr+H=o+@qFB#|Imj%_+i6< z!_vu?Rx38!jp(Os?|RmdG6TL4wYDx<)5)2oHUnybbOU(I%K?L!XR=`Cnh(=eKBw!w zJ@)b%WBMczV}x-IXWKJ*fg4icgQSP9n#_!KNOJf)XAzudmR4YSJ(VI^ya=WT+I6o1 zGabbFEcKhBpjmZ7tA)y_$}*}o5q{ckH;)zLXba*IYowXYh(g6nft9+x^-x!XoDy&( z!rjRhLkXyrev2Y`{ay1Fof-Y(48c!*6s+Yr+6Qz>Y)bpRJo1CQmp4LJ0*L5A8Qs@K5(jaJBpLX@) zR_A!1I0eSClGzr7xHd41KBt~p6i_l4NO6fuyB3U+X^^KsOxaa#tk~<%%M7qkl_0Qxq z@Kv54O&8EX%XD#$KxsV=rXg3r#VsE0bNlg2Q8Ybq0*&@rb)#BUwTe!Gy6ekSWL7$9yTx zIB{Gn{(O8XoMYhJJg>(hD|z7OdFkJO4{WriN6f1{R>@mqEyg&A{+M3IlH z!7xzwZY>)SMWI!tkg5qRMrXamXULyN6OKEM?IN5BEG#p}SpHf|mudzVVW68DzsE(M zm(5nYw4=yky%6*&k33G7?84Ec146v?2eq!>2P$$&$M|)*jL080iPAQbmRjZA@G>0P z6VX6ENqR<4F5rbgXwb;#mrH?~9@aU5$2?)lQrM3o66|_1T%D$GD-6pGom|Lazw1qs z5Z;(Vp)8Ro!rjUH(oO~y(_QVZi(T}bTP0BeEVu1x%SQNQ80CW?@27lEhTng#1z~do zS4-Rf(%=1+UwZlFm;bZtW%=e~szLmJ{PmURzp=jZ##_Jh!t-DG_22sK-x;5J`duG< z_B|gMo_fbSO_kemNvnj2v4lPs&9z~jr1i|pTJwqm0~ZIztjnaNIZY|82+<3lLC#~A z9%?nVF{(i!u0;sh1=d^S!r(qH;fAGFG)s(cx8I{$ej!nkamQ%{GN+;Qi*qc8oxE@i zpc|(Ft+F^uqZ)i5@vqVEtu|OM4@u(zO(hVu_*^0w{XLBA$*!%kaHo~Wx=(6l=8|qc zO~UM2Exp2$(e|OcR<$89t*fXl3_=)?QtjkgaGtJYQT}8x2hGbt@{@?;E6#aTa~PDS zgeS|A)c14bwK7bzU1`P3nNy@nP*chnHA|^-@?|s$&`RddSXjg~L@)4_x~L)btgfr8 z(#lPS5@L29kar=>gTj}<2akdxF&=AglB}YspQeePAyK0Q*i zB#CZ>3@@S#3!Z^{rDh>(i$JjavX_}v14_~_;>3>wp*ahK-~z^R>=2O;Ji)xLODHSpE@=^_cKwfhnTAh;& zE$dJ!V3dnWnaZ=)9!qV|TIw~4Z2+bR+G+utff_5xmsrW=vZAEYz|JQBu3H#Q*ZfR# zN$d@3O`frenlgP+O;RHy_C&1($wkDAyY2ZGUVQ!+e&KKbmyaG@{?GfhKKNJf3WI;m zU+;YCrZX_#b$)U4=YIHye&DbE?9cw?_ujsB(**C3mc7I)EGXi4@WJ;8Xh{EeseCv~ z&WOQD6W-8q+_KE%6dLO-{lLR+u4-js zagH^=>d=m1I5B8(63og^$&?vI5U`(TSm$ZNlc38|=(cV-OT4?(-U>z>BRg2s^hkN- zg0t-wFyygHo&{evXvShrvzWaa20vn%_wa5I38X~1i)yD;I{SiSc3JHQPC%OVqA;ii zC;+O+c&+eERlXA|5jX}+aBf247r~c}aVP!)Lt9Dq)OXAmEnB zGo`dht{xD4#L~nER5HCGd}*Y#YUmq!u31_p86A_BrN4*dX+rNpsq(JyLRHj6lAfhS z(HaXU=mcy-k;G-&Y9HXTC=!pBfhG_u!#S5VNyeMS_3j$mX)9$;wg)HbMUivDG>*q& zHsN>2VWf1raZt+X*b=Hpk3x{O8G+kt4`U?1)w+ky2g15bTH%dD$^~&4DJ?f1ei&hW zY)i`XSHAYbTYvDGKl(eby!z@dUSBWw{{QZWf9(aLe?9Z(o-#Gp_k`&Go4hVRclYUc zoPF>^AEZ-w96Mg@obBY!qeqtrfpE=BFTaAPo_-oPZk$UWc#u%TiXb}dEFB(koM6G8 zUR|@<{6)14V*vQVCh<9F+0{5sK!a4X?xkfc(DXLXeEH`EF-{2M0B0<2UYyH;?7QR= zQtr~*33KfipAmB(IgPX49kMLBuh-5QWHIbb(?r>1C&#sgap~RUN(!O@J`!K1=s=(* z?{I#8fx}^zeiSO#Hq(R#M#Mu(YZyI0h;oD76w5q|)RyYHa)K8t^rKq|%Xd;20@TWe zz-BCpga~HoXisax`Pn(HFE0@T6-^2=T2HPi%XdJi>?liY;_}{%5hbsHdUR?=7$Z>7 zO@OU(FxVY-xVX51(kjFfg6JizvC^|tD=U)MWu@ZE0!wD9Z)*+P%~l`^$`gA$gpkKx zL<)z)9>a!tYY?2Bg+>$WOvP`)s@5u{Xa_0$f(SgUh)FUmIHgu;i*u4J>tn!@Rt(a1 z*;A0EJjm)sB0`DWtA(|{9)wtnazl(jZ(>d(glM{aoKA4`xDz%0dT+6$Rm!>yO0GTm zRqLq9ISt5V6@?|0cY|y%#u&KZ&@wOIAh6n+ljK2}o*^BtBz~V-(WO~WUQEOK95BbLJk}1G%8`RiX~lM!{@=dNT?b(phN8EWItdHn z_xS+kN-X`A4h#q}AhEDOo;8E*iitgAq9nG@xk$PWY7iJ`tE`WgO|+JuA9jUjQismFW1{?JWt2NKFl%a*Zb4>-@Wh!HT@^g Tt|4Fo00000NkvXXu0mjfxTU0i literal 0 HcmV?d00001 diff --git a/samples/testapp/src/commonMain/composeResources/values/strings.xml b/samples/testapp/src/commonMain/composeResources/values/strings.xml index 5cbb73d49..76c6f7dcf 100644 --- a/samples/testapp/src/commonMain/composeResources/values/strings.xml +++ b/samples/testapp/src/commonMain/composeResources/values/strings.xml @@ -13,5 +13,8 @@ ConsentModalBottomSheet ConsentModalBottomSheet use-cases QR code generation and scanning + ISO mdoc Proximity Sharing + ISO mdoc Proximity Reading + ISO mdoc Multi-Device Testing \ No newline at end of file diff --git a/samples/testapp/src/commonMain/kotlin/com/android/identity/testapp/App.kt b/samples/testapp/src/commonMain/kotlin/com/android/identity/testapp/App.kt index 28ceffa8c..352b5855c 100644 --- a/samples/testapp/src/commonMain/kotlin/com/android/identity/testapp/App.kt +++ b/samples/testapp/src/commonMain/kotlin/com/android/identity/testapp/App.kt @@ -31,6 +31,9 @@ import com.android.identity.testapp.ui.AboutScreen import com.android.identity.testapp.ui.AndroidKeystoreSecureAreaScreen import com.android.identity.testapp.ui.ConsentModalBottomSheetListScreen import com.android.identity.testapp.ui.ConsentModalBottomSheetScreen +import com.android.identity.testapp.ui.IsoMdocMultiDeviceTestingScreen +import com.android.identity.testapp.ui.IsoMdocProximityReadingScreen +import com.android.identity.testapp.ui.IsoMdocProximitySharingScreen import com.android.identity.testapp.ui.PassphraseEntryFieldScreen import com.android.identity.testapp.ui.QrCodesScreen import com.android.identity.testapp.ui.SecureEnclaveSecureAreaScreen @@ -95,7 +98,10 @@ class App { onClickSecureEnclaveSecureArea = { navController.navigate(SecureEnclaveSecureAreaDestination.route) }, onClickPassphraseEntryField = { navController.navigate(PassphraseEntryFieldDestination.route) }, onClickConsentSheetList = { navController.navigate(ConsentModalBottomSheetListDestination.route) }, - onClickQrCodes = { navController.navigate(QrCodesDestination.route) } + onClickQrCodes = { navController.navigate(QrCodesDestination.route) }, + onClickIsoMdocProximitySharing = { navController.navigate(IsoMdocProximitySharingDestination.route) }, + onClickIsoMdocProximityReading = { navController.navigate(IsoMdocProximityReadingDestination.route) }, + onClickMdocTransportMultiDeviceTesting = { navController.navigate(IsoMdocMultiDeviceTestingDestination.route) }, ) } composable(route = AboutDestination.route) { @@ -148,6 +154,21 @@ class App { showToast = { message -> showToast(message) } ) } + composable(route = IsoMdocProximitySharingDestination.route) { + IsoMdocProximitySharingScreen( + showToast = { message -> showToast(message) } + ) + } + composable(route = IsoMdocProximityReadingDestination.route) { + IsoMdocProximityReadingScreen( + showToast = { message -> showToast(message) } + ) + } + composable(route = IsoMdocMultiDeviceTestingDestination.route) { + IsoMdocMultiDeviceTestingScreen( + showToast = { message -> showToast(message) } + ) + } } } } diff --git a/samples/testapp/src/commonMain/kotlin/com/android/identity/testapp/Destinations.kt b/samples/testapp/src/commonMain/kotlin/com/android/identity/testapp/Destinations.kt index 9ccae185d..782c6ac35 100644 --- a/samples/testapp/src/commonMain/kotlin/com/android/identity/testapp/Destinations.kt +++ b/samples/testapp/src/commonMain/kotlin/com/android/identity/testapp/Destinations.kt @@ -8,6 +8,9 @@ import identitycredential.samples.testapp.generated.resources.android_keystore_s import identitycredential.samples.testapp.generated.resources.cloud_secure_area_screen_title import identitycredential.samples.testapp.generated.resources.consent_modal_bottom_sheet_list_screen_title import identitycredential.samples.testapp.generated.resources.consent_modal_bottom_sheet_screen_title +import identitycredential.samples.testapp.generated.resources.iso_mdoc_multi_device_testing_title +import identitycredential.samples.testapp.generated.resources.iso_mdoc_proximity_reading_title +import identitycredential.samples.testapp.generated.resources.iso_mdoc_proximity_sharing_title import identitycredential.samples.testapp.generated.resources.passphrase_entry_field_screen_title import identitycredential.samples.testapp.generated.resources.qr_codes_screen_title import identitycredential.samples.testapp.generated.resources.secure_enclave_secure_area_screen_title @@ -77,6 +80,21 @@ data object QrCodesDestination : Destination { override val title = Res.string.qr_codes_screen_title } +data object IsoMdocProximitySharingDestination : Destination { + override val route = "iso_mdoc_proximity_sharing" + override val title = Res.string.iso_mdoc_proximity_sharing_title +} + +data object IsoMdocProximityReadingDestination : Destination { + override val route = "iso_mdoc_proximity_reading" + override val title = Res.string.iso_mdoc_proximity_reading_title +} + +data object IsoMdocMultiDeviceTestingDestination : Destination { + override val route = "iso_mdoc_multi_device_testing" + override val title = Res.string.iso_mdoc_multi_device_testing_title +} + val appDestinations = listOf( StartDestination, AboutDestination, @@ -88,4 +106,7 @@ val appDestinations = listOf( ConsentModalBottomSheetListDestination, ConsentModalBottomSheetDestination, QrCodesDestination, + IsoMdocProximitySharingDestination, + IsoMdocProximityReadingDestination, + IsoMdocMultiDeviceTestingDestination, ) \ No newline at end of file diff --git a/samples/testapp/src/commonMain/kotlin/com/android/identity/testapp/Platform.kt b/samples/testapp/src/commonMain/kotlin/com/android/identity/testapp/Platform.kt index acd5d611d..d6fb008bd 100644 --- a/samples/testapp/src/commonMain/kotlin/com/android/identity/testapp/Platform.kt +++ b/samples/testapp/src/commonMain/kotlin/com/android/identity/testapp/Platform.kt @@ -6,3 +6,5 @@ enum class Platform { } expect val platform: Platform + +expect fun getLocalIpAddress(): String diff --git a/samples/testapp/src/commonMain/kotlin/com/android/identity/testapp/TestAppUtils.kt b/samples/testapp/src/commonMain/kotlin/com/android/identity/testapp/TestAppUtils.kt new file mode 100644 index 000000000..3cb553a7b --- /dev/null +++ b/samples/testapp/src/commonMain/kotlin/com/android/identity/testapp/TestAppUtils.kt @@ -0,0 +1,302 @@ +package com.android.identity.testapp + +import com.android.identity.appsupport.ui.consent.MdocConsentField +import com.android.identity.cbor.Bstr +import com.android.identity.cbor.Cbor +import com.android.identity.cbor.CborArray +import com.android.identity.cbor.DataItem +import com.android.identity.cbor.Simple +import com.android.identity.cbor.Tagged +import com.android.identity.cbor.toDataItem +import com.android.identity.cose.Cose +import com.android.identity.cose.CoseLabel +import com.android.identity.cose.CoseNumberLabel +import com.android.identity.credential.CredentialFactory +import com.android.identity.crypto.Algorithm +import com.android.identity.crypto.EcCurve +import com.android.identity.crypto.EcPrivateKey +import com.android.identity.crypto.EcPublicKey +import com.android.identity.crypto.X509Cert +import com.android.identity.crypto.X509CertChain +import com.android.identity.document.DocumentStore +import com.android.identity.document.NameSpacedData +import com.android.identity.documenttype.DocumentTypeRepository +import com.android.identity.documenttype.DocumentWellKnownRequest +import com.android.identity.documenttype.knowntypes.DrivingLicense +import com.android.identity.mdoc.credential.MdocCredential +import com.android.identity.mdoc.mso.MobileSecurityObjectGenerator +import com.android.identity.mdoc.mso.MobileSecurityObjectParser +import com.android.identity.mdoc.mso.StaticAuthDataGenerator +import com.android.identity.mdoc.mso.StaticAuthDataParser +import com.android.identity.mdoc.request.DeviceRequestGenerator +import com.android.identity.mdoc.response.DeviceResponseGenerator +import com.android.identity.mdoc.response.DocumentGenerator +import com.android.identity.mdoc.util.MdocUtil +import com.android.identity.securearea.KeyPurpose +import com.android.identity.securearea.SecureArea +import com.android.identity.securearea.SecureAreaRepository +import com.android.identity.securearea.software.SoftwareCreateKeySettings +import com.android.identity.securearea.software.SoftwareSecureArea +import com.android.identity.storage.EphemeralStorageEngine +import com.android.identity.storage.StorageEngine +import com.android.identity.util.Constants +import com.android.identity.util.Logger +import kotlinx.datetime.Clock +import kotlin.collections.component1 +import kotlin.collections.component2 +import kotlin.collections.iterator +import kotlin.random.Random +import kotlin.time.Duration.Companion.hours + +object TestAppUtils { + private const val TAG = "TestAppUtils" + + fun generateEncodedDeviceRequest( + request: DocumentWellKnownRequest, + encodedSessionTranscript: ByteArray + ): ByteArray { + val mdocRequest = request.mdocRequest!! + val itemsToRequest = mutableMapOf>() + for (ns in mdocRequest.namespacesToRequest) { + for ((de, intentToRetain) in ns.dataElementsToRequest) { + itemsToRequest.getOrPut(ns.namespace) { mutableMapOf() } + .put(de.attribute.identifier, intentToRetain) + } + } + + val deviceRequestGenerator = DeviceRequestGenerator(encodedSessionTranscript) + deviceRequestGenerator.addDocumentRequest( + docType = mdocRequest.docType, + itemsToRequest = itemsToRequest, + requestInfo = null, + readerKey = null, + signatureAlgorithm = Algorithm.UNSET, + readerKeyCertificateChain = null, + ) + return deviceRequestGenerator.generate() + } + + fun generateEncodedSessionTranscript( + encodedDeviceEngagement: ByteArray, + eReaderKey: EcPublicKey + ): ByteArray { + val handover = Simple.NULL + val encodedEReaderKey = Cbor.encode(eReaderKey.toCoseKey().toDataItem()) + return Cbor.encode( + CborArray.builder() + .add(Tagged(24, Bstr(encodedDeviceEngagement))) + .add(Tagged(24, Bstr(encodedEReaderKey))) + .add(handover) + .end() + .build() + ) + } + + fun generateEncodedDeviceResponse( + consentFields: List, + encodedSessionTranscript: ByteArray + ): ByteArray { + val nsAndDataElements = mutableMapOf>() + consentFields.forEach { + nsAndDataElements.getOrPut(it.namespaceName, { mutableListOf() }).add(it.dataElementName) + } + + val staticAuthData = StaticAuthDataParser(mdocCredential.issuerProvidedData).parse() + + val mergedIssuerNamespaces = MdocUtil.mergeIssuerNamesSpaces( + nsAndDataElements, + documentData, + staticAuthData + ) + val issuerAuthCoseSign1 = Cbor.decode(staticAuthData.issuerAuth).asCoseSign1 + val encodedMsoBytes = Cbor.decode(issuerAuthCoseSign1.payload!!) + val encodedMso = Cbor.encode(encodedMsoBytes.asTaggedEncodedCbor) + val mso = MobileSecurityObjectParser(encodedMso).parse() + + val documentGenerator = DocumentGenerator( + mso.docType, + staticAuthData.issuerAuth, + encodedSessionTranscript, + ) + documentGenerator.setIssuerNamespaces(mergedIssuerNamespaces) + + documentGenerator.setDeviceNamespacesSignature( + NameSpacedData.Builder().build(), + mdocCredential.secureArea, + mdocCredential.alias, + null, + Algorithm.ES256, + ) + + val deviceResponseGenerator = DeviceResponseGenerator(Constants.DEVICE_RESPONSE_STATUS_OK) + deviceResponseGenerator.addDocument(documentGenerator.generate()) + return deviceResponseGenerator.generate() + } + + private lateinit var documentData: NameSpacedData + lateinit var mdocCredential: MdocCredential + + private lateinit var storageEngine: StorageEngine + private lateinit var secureArea: SecureArea + private lateinit var secureAreaRepository: SecureAreaRepository + private lateinit var credentialFactory: CredentialFactory + lateinit var documentTypeRepository: DocumentTypeRepository + lateinit var dsKeyPub: EcPublicKey + + init { + storageEngine = EphemeralStorageEngine() + secureAreaRepository = SecureAreaRepository() + secureArea = SoftwareSecureArea(storageEngine) + secureAreaRepository.addImplementation(secureArea) + credentialFactory = CredentialFactory() + credentialFactory.addCredentialImplementation(MdocCredential::class) { + document, dataItem -> MdocCredential(document, dataItem) + } + provisionDocument() + documentTypeRepository = DocumentTypeRepository() + documentTypeRepository.addDocumentType(DrivingLicense.getDocumentType()) + } + + private fun provisionDocument() { + val documentStore = DocumentStore( + storageEngine, + secureAreaRepository, + credentialFactory + ) + + // Create the document... + val document = documentStore.createDocument( + "testDocument" + ) + + val nsdBuilder = NameSpacedData.Builder() + for ((nsName, ns) in DrivingLicense.getDocumentType().mdocDocumentType?.namespaces!!) { + for ((deName, de) in ns.dataElements) { + val sampleValue = de.attribute.sampleValue + if (sampleValue != null) { + nsdBuilder.putEntry(nsName, deName, Cbor.encode(sampleValue)) + } else { + Logger.w(TAG, "No sample value for data element $deName") + } + } + } + documentData = nsdBuilder.build() + + document.applicationData.setNameSpacedData("documentData", documentData) + val overrides: MutableMap> = HashMap() + val exceptions: MutableMap> = HashMap() + + // Create an authentication key... make sure the authKey used supports both + // mdoc ECDSA and MAC authentication. + val now = Clock.System.now() + val timeSigned = now - 1.hours + val timeValidityBegin = now - 1.hours + val timeValidityEnd = now + 24.hours + mdocCredential = MdocCredential( + document, + null, + "AuthKeyDomain", + secureArea, + SoftwareCreateKeySettings.Builder() + .setKeyPurposes(setOf(KeyPurpose.SIGN, KeyPurpose.AGREE_KEY)) + .build(), + "org.iso.18013.5.1.mDL" + ) + + // Generate an MSO and issuer-signed data for this authentication key. + val msoGenerator = MobileSecurityObjectGenerator( + "SHA-256", + DrivingLicense.MDL_DOCTYPE, + mdocCredential.attestation.publicKey + ) + msoGenerator.setValidityInfo(timeSigned, timeValidityBegin, timeValidityEnd, null) + val issuerNameSpaces = MdocUtil.generateIssuerNameSpaces( + documentData, + Random, + 16, + overrides + ) + for (nameSpaceName in issuerNameSpaces.keys) { + val digests = MdocUtil.calculateDigestsForNameSpace( + nameSpaceName, + issuerNameSpaces, + Algorithm.SHA256 + ) + msoGenerator.addDigestIdsForNamespace(nameSpaceName, digests) + } + val validFrom = Clock.System.now() - 1.hours + val validUntil = validFrom + 24.hours + + val documentSignerKeyPub = EcPublicKey.fromPem( +"""-----BEGIN PUBLIC KEY----- +MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEnmiWAMGIeo2E3usWRLL/EPfh1Bw5 +JHgq8RYzJvraMj5QZSh94CL/nlEi3vikGxDP34HjxZcjzGEimGg03sB6Ng== +-----END PUBLIC KEY-----""", + EcCurve.P256 + ) + dsKeyPub = documentSignerKeyPub + + val documentSignerKey = EcPrivateKey.fromPem( + """-----BEGIN PRIVATE KEY----- +MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQg/ANvinTxJAdR8nQ0 +NoUdBMcRJz+xLsb0kmhyMk+lkkGhRANCAASeaJYAwYh6jYTe6xZEsv8Q9+HUHDkk +eCrxFjMm+toyPlBlKH3gIv+eUSLe+KQbEM/fgePFlyPMYSKYaDTewHo2 +-----END PRIVATE KEY-----""", + documentSignerKeyPub + ) + + val documentSignerCert = X509Cert.fromPem( +"""-----BEGIN CERTIFICATE----- +MIIBITCBx6ADAgECAgEBMAoGCCqGSM49BAMCMBoxGDAWBgNVBAMMD1N0YXRlIE9mIFV0b3BpYTAe +Fw0yNDExMDcyMTUzMDdaFw0zNDExMDUyMTUzMDdaMBoxGDAWBgNVBAMMD1N0YXRlIE9mIFV0b3Bp +YTBZMBMGByqGSM49AgEGCCqGSM49AwEHA0IABJ5olgDBiHqNhN7rFkSy/xD34dQcOSR4KvEWMyb6 +2jI+UGUofeAi/55RIt74pBsQz9+B48WXI8xhIphoNN7AejYwCgYIKoZIzj0EAwIDSQAwRgIhALkq +UIVeaSW0xhLuMdwHyjiwTV8USD4zq68369ZW6jBvAiEAj2smZAXJB04x/s3exzjnI5BQprUOSfYE +uku1Jv7gA+A= +-----END CERTIFICATE-----"""" + ) + + val mso = msoGenerator.generate() + val taggedEncodedMso = Cbor.encode(Tagged(24, Bstr(mso))) + + // IssuerAuth is a COSE_Sign1 where payload is MobileSecurityObjectBytes + // + // MobileSecurityObjectBytes = #6.24(bstr .cbor MobileSecurityObject) + // + val protectedHeaders = mapOf( + Pair( + CoseNumberLabel(Cose.COSE_LABEL_ALG), + Algorithm.ES256.coseAlgorithmIdentifier.toDataItem() + ) + ) + val unprotectedHeaders = mapOf( + Pair( + CoseNumberLabel(Cose.COSE_LABEL_X5CHAIN), + X509CertChain(listOf(documentSignerCert)).toDataItem() + ) + ) + val encodedIssuerAuth = Cbor.encode( + Cose.coseSign1Sign( + documentSignerKey, + taggedEncodedMso, + true, + Algorithm.ES256, + protectedHeaders, + unprotectedHeaders + ).toDataItem() + ) + val issuerProvidedAuthenticationData = StaticAuthDataGenerator( + MdocUtil.stripIssuerNameSpaces(issuerNameSpaces, exceptions), + encodedIssuerAuth + ).generate() + + // Now that we have issuer-provided authentication data we certify the authentication key. + mdocCredential.certify( + issuerProvidedAuthenticationData, + timeValidityBegin, + timeValidityEnd + ) + } + + +} \ No newline at end of file diff --git a/samples/testapp/src/commonMain/kotlin/com/android/identity/testapp/multidevicetests/MultiDeviceTestsClient.kt b/samples/testapp/src/commonMain/kotlin/com/android/identity/testapp/multidevicetests/MultiDeviceTestsClient.kt new file mode 100644 index 000000000..94fe6c297 --- /dev/null +++ b/samples/testapp/src/commonMain/kotlin/com/android/identity/testapp/multidevicetests/MultiDeviceTestsClient.kt @@ -0,0 +1,186 @@ +package com.android.identity.testapp.multidevicetests + +import com.android.identity.crypto.Crypto +import com.android.identity.crypto.EcCurve +import com.android.identity.mdoc.connectionmethod.ConnectionMethodBle +import com.android.identity.mdoc.engagement.EngagementParser +import com.android.identity.mdoc.sessionencryption.SessionEncryption +import com.android.identity.mdoc.transport.MdocTransport +import com.android.identity.mdoc.transport.MdocTransportFactory +import com.android.identity.mdoc.transport.MdocTransportOptions +import com.android.identity.util.Constants +import com.android.identity.util.Logger +import com.android.identity.util.fromBase64Url +import io.ktor.network.sockets.Socket +import io.ktor.network.sockets.openReadChannel +import io.ktor.network.sockets.openWriteChannel +import io.ktor.utils.io.writeStringUtf8 +import kotlinx.coroutines.Job +import kotlinx.coroutines.TimeoutCancellationException +import kotlinx.coroutines.withTimeout +import kotlinx.datetime.Clock +import kotlin.time.Duration.Companion.seconds +import kotlin.time.DurationUnit + +class MultiDeviceTestsClient( + val job: Job, + val socket: Socket, +) { + companion object { + private const val TAG = "MultiDeviceTestsClient" + private val LINE_LIMIT = 4096 + } + + val receiveChannel = socket.openReadChannel() + val sendChannel = socket.openWriteChannel(autoFlush = true) + + suspend fun run() { + // Client listens for commands from server. + while (true) { + val cmd = receiveChannel.readUTF8Line(LINE_LIMIT) + Logger.i(TAG, "Received command '$cmd'") + if (cmd == null) { + throw Error("Receive channel was closed") + } else if (cmd == "Done") { + break + } else if (cmd.startsWith("TestPresentationPrepare ")) { + val parts = cmd.split(" ") + val iterationNumber = parts[1].toInt() + val numIterationsTotal = parts[2].toInt() + val testName = parts[3] + val usePrewarming = if (parts[4] == "true") true else false + val bleUseL2CAP = if (parts[5] == "true") true else false + val getPsmFromReader = if (parts[6] == "true") true else false + val encodedDeviceEngagement = parts[7].fromBase64Url() + val test = Test.valueOf(testName) + val options = MdocTransportOptions(bleUseL2CAP = bleUseL2CAP) + + Logger.i(TAG, "====== STARTING ITERATION ${iterationNumber} OF ${numIterationsTotal} ======") + Logger.i(TAG, "Test: $test") + Logger.iHex(TAG, "DeviceEngagement from server", encodedDeviceEngagement) + val deviceEngagement = + EngagementParser(encodedDeviceEngagement).parse() + val connectionMethod = deviceEngagement.connectionMethods[0] + val eDeviceKey = deviceEngagement.eSenderKey + val transport = MdocTransportFactory.createTransport( + connectionMethod, + MdocTransport.Role.MDOC_READER, + options + ) + Logger.i(TAG, "usePrewarming: $usePrewarming") + if (usePrewarming) { + transport.advertise() + } + if (getPsmFromReader) { + val cm = transport.connectionMethod as ConnectionMethodBle + sendChannel.writeStringUtf8("${cm.peripheralServerModePsm!!}\n") + } + val nextCmd = receiveChannel.readUTF8Line(LINE_LIMIT) + if (nextCmd != "TestPresentationStart") { + throw IllegalStateException("Expected 'TestPresentationStart' got $nextCmd") + } + Logger.i(TAG, "Starting") + try { + withTimeout(15.seconds) { + val timeStart = Clock.System.now() + transport.open(eDeviceKey) + + val eReaderKey = Crypto.createEcPrivateKey(EcCurve.P256) + val encodedSessionTranscript = byteArrayOf(0x01, 0x02) + val sessionEncryption = SessionEncryption( + role = SessionEncryption.Role.MDOC_READER, + eSelfKey = eReaderKey, + remotePublicKey = eDeviceKey, + encodedSessionTranscript = encodedSessionTranscript + ) + val deviceRequest = ByteArray(2 * 1024) + transport.sendMessage( + sessionEncryption.encryptMessage( + messagePlaintext = deviceRequest, + statusCode = null + ) + ) + val response = transport.waitForMessage() + val (deviceResponse, statusCode) = sessionEncryption.decryptMessage(response) + when (test) { + Test.MDOC_CENTRAL_CLIENT_MODE, + Test.MDOC_PERIPHERAL_SERVER_MODE, + Test.MDOC_CENTRAL_CLIENT_MODE_L2CAP, + Test.MDOC_PERIPHERAL_SERVER_MODE_L2CAP, + Test.MDOC_CENTRAL_CLIENT_MODE_L2CAP_PSM_IN_TWO_WAY_ENGAGEMENT, + Test.MDOC_PERIPHERAL_SERVER_MODE_L2CAP_PSM_IN_DEVICE_ENGAGEMENT -> { + // Expects termination in initial message + if (statusCode != Constants.SESSION_DATA_STATUS_SESSION_TERMINATION) { + throw Error("Expected status 20, got $statusCode") + } + } + Test.MDOC_CENTRAL_CLIENT_MODE_HOLDER_TERMINATION_MSG, + Test.MDOC_PERIPHERAL_SERVER_MODE_HOLDER_TERMINATION_MSG, + Test.MDOC_CENTRAL_CLIENT_MODE_L2CAP_HOLDER_TERMINATION_MSG, + Test.MDOC_PERIPHERAL_SERVER_MODE_L2CAP_HOLDER_TERMINATION_MSG -> { + if (statusCode != null) { + throw Error("Expected status to be unset got $statusCode") + } + val (deviceResponse, statusCode) = sessionEncryption.decryptMessage( + transport.waitForMessage() + ) + if (deviceResponse != null || + statusCode != Constants.SESSION_DATA_STATUS_SESSION_TERMINATION) { + throw Error("Expected empty message and status 20") + } + } + Test.MDOC_CENTRAL_CLIENT_MODE_HOLDER_TERMINATION_BLE, + Test.MDOC_PERIPHERAL_SERVER_MODE_HOLDER_TERMINATION_BLE -> { + if (statusCode != null) { + throw Error("Expected status to be unset got $statusCode") + } + // Expects a termination via BLE + val sessionData = transport.waitForMessage() + if (sessionData.isNotEmpty()) { + throw Error("Expected transport-specific termination, got non-empty message") + } + } + Test.MDOC_CENTRAL_CLIENT_MODE_READER_TERMINATION_MSG, + Test.MDOC_PERIPHERAL_SERVER_MODE_READER_TERMINATION_MSG, + Test.MDOC_CENTRAL_CLIENT_MODE_L2CAP_READER_TERMINATION_MSG, + Test.MDOC_PERIPHERAL_SERVER_MODE_L2CAP_READER_TERMINATION_MSG -> { + if (statusCode != null) { + throw Error("Expected status to be unset got $statusCode") + } + // Expects reader to terminate via message + transport.sendMessage( + SessionEncryption.encodeStatus(Constants.SESSION_DATA_STATUS_SESSION_TERMINATION) + ) + } + Test.MDOC_CENTRAL_CLIENT_MODE_READER_TERMINATION_BLE, + Test.MDOC_PERIPHERAL_SERVER_MODE_READER_TERMINATION_BLE -> { + if (statusCode != null) { + throw Error("Expected status to be unset got $statusCode") + } + // Expects reader to terminate via BLE + transport.sendMessage(byteArrayOf()) + } + } + + val timeEnd = Clock.System.now() + val timeEngagementToResponseMsec = (timeEnd - timeStart).toInt(DurationUnit.MILLISECONDS) + val scanningTimeMsec = transport.scanningTime?.toInt(DurationUnit.MILLISECONDS) ?: 0 + sendChannel.writeStringUtf8("TestPresentationSuccess $timeEngagementToResponseMsec " + + "$scanningTimeMsec\n") + } + } catch (e: TimeoutCancellationException) { + sendChannel.writeStringUtf8("TestPresentationTimeout\n") + } catch (e: Throwable) { + Logger.w(TAG, "Iteration failed", e) + e.printStackTrace() + sendChannel.writeStringUtf8("TestPresentationFailed\n") + } finally { + transport.close() + } + + } else { + throw Error("Unknown command $cmd") + } + } + } +} diff --git a/samples/testapp/src/commonMain/kotlin/com/android/identity/testapp/multidevicetests/MultiDeviceTestsServer.kt b/samples/testapp/src/commonMain/kotlin/com/android/identity/testapp/multidevicetests/MultiDeviceTestsServer.kt new file mode 100644 index 000000000..22194bbe5 --- /dev/null +++ b/samples/testapp/src/commonMain/kotlin/com/android/identity/testapp/multidevicetests/MultiDeviceTestsServer.kt @@ -0,0 +1,377 @@ +package com.android.identity.testapp.multidevicetests + +import androidx.compose.runtime.MutableState +import com.android.identity.crypto.Crypto +import com.android.identity.crypto.EcCurve +import com.android.identity.mdoc.connectionmethod.ConnectionMethodBle +import com.android.identity.mdoc.engagement.EngagementGenerator +import com.android.identity.mdoc.sessionencryption.SessionEncryption +import com.android.identity.mdoc.transport.MdocTransport +import com.android.identity.mdoc.transport.MdocTransportFactory +import com.android.identity.mdoc.transport.MdocTransportOptions +import com.android.identity.util.Constants +import com.android.identity.util.Logger +import com.android.identity.util.UUID +import com.android.identity.util.toBase64Url +import io.ktor.network.sockets.Socket +import io.ktor.network.sockets.openReadChannel +import io.ktor.network.sockets.openWriteChannel +import io.ktor.utils.io.writeStringUtf8 +import kotlinx.coroutines.Job +import kotlinx.coroutines.TimeoutCancellationException +import kotlinx.coroutines.delay +import kotlinx.coroutines.withTimeout +import kotlin.math.sqrt +import kotlin.time.Duration.Companion.seconds +import kotlin.time.DurationUnit + +class MultiDeviceTestsServer( + val job: Job, + val socket: Socket, + val plan: Plan, + val multiDeviceTestsResults: MutableState +) { + companion object { + private const val TAG = "MultiDeviceTestsServer" + private val LINE_LIMIT = 4096 + } + + val receiveChannel = socket.openReadChannel() + val sendChannel = socket.openWriteChannel(autoFlush = true) + + private var numIterationsTotal = 0 + private var numIterations = 0 + private var numSuccess = 0 + private var failedTransactions = mutableListOf() + private var numHolderTimeouts = 0 + private var numHolderErrors = 0 + private var numReaderTimeouts = 0 + private var numReaderErrors = 0 + private var recordedTimeEngagementToResponseMsec = mutableListOf() + private var recordedScanningTimeMsec = mutableListOf() + + suspend fun run() { + numIterationsTotal = plan.tests.sumOf { it.second } + multiDeviceTestsResults.value = Results( + plan = plan, + numIterationsTotal = numIterationsTotal, + ) + for ((test, numIterationsForTest) in plan.tests) { + for (n in IntRange(1, numIterationsForTest)) { + testPresentation(test) + } + } + + updateResults(null) + + sendChannel.writeStringUtf8("Done\n") + } + + private suspend fun testPresentation(test: Test) { + updateResults(test) + val connectionMethod = when (test) { + Test.MDOC_CENTRAL_CLIENT_MODE, + Test.MDOC_CENTRAL_CLIENT_MODE_HOLDER_TERMINATION_MSG, + Test.MDOC_CENTRAL_CLIENT_MODE_HOLDER_TERMINATION_BLE, + Test.MDOC_CENTRAL_CLIENT_MODE_READER_TERMINATION_MSG, + Test.MDOC_CENTRAL_CLIENT_MODE_READER_TERMINATION_BLE, + Test.MDOC_CENTRAL_CLIENT_MODE_L2CAP, + Test.MDOC_CENTRAL_CLIENT_MODE_L2CAP_HOLDER_TERMINATION_MSG, + Test.MDOC_CENTRAL_CLIENT_MODE_L2CAP_READER_TERMINATION_MSG, + Test.MDOC_CENTRAL_CLIENT_MODE_L2CAP_PSM_IN_TWO_WAY_ENGAGEMENT -> { + ConnectionMethodBle( + supportsPeripheralServerMode = false, + supportsCentralClientMode = true, + peripheralServerModeUuid = null, + centralClientModeUuid = UUID.randomUUID(), + ) + } + Test.MDOC_PERIPHERAL_SERVER_MODE, + Test.MDOC_PERIPHERAL_SERVER_MODE_HOLDER_TERMINATION_MSG, + Test.MDOC_PERIPHERAL_SERVER_MODE_HOLDER_TERMINATION_BLE, + Test.MDOC_PERIPHERAL_SERVER_MODE_READER_TERMINATION_MSG, + Test.MDOC_PERIPHERAL_SERVER_MODE_READER_TERMINATION_BLE, + Test.MDOC_PERIPHERAL_SERVER_MODE_L2CAP, + Test.MDOC_PERIPHERAL_SERVER_MODE_L2CAP_HOLDER_TERMINATION_MSG, + Test.MDOC_PERIPHERAL_SERVER_MODE_L2CAP_READER_TERMINATION_MSG, + Test.MDOC_PERIPHERAL_SERVER_MODE_L2CAP_PSM_IN_DEVICE_ENGAGEMENT -> { + ConnectionMethodBle( + supportsPeripheralServerMode = true, + supportsCentralClientMode = false, + peripheralServerModeUuid = UUID.randomUUID(), + centralClientModeUuid = null, + ) + } + } + + val bleUseL2CAP = when (test) { + Test.MDOC_CENTRAL_CLIENT_MODE, + Test.MDOC_CENTRAL_CLIENT_MODE_HOLDER_TERMINATION_MSG, + Test.MDOC_CENTRAL_CLIENT_MODE_HOLDER_TERMINATION_BLE, + Test.MDOC_CENTRAL_CLIENT_MODE_READER_TERMINATION_MSG, + Test.MDOC_CENTRAL_CLIENT_MODE_READER_TERMINATION_BLE, + Test.MDOC_PERIPHERAL_SERVER_MODE, + Test.MDOC_PERIPHERAL_SERVER_MODE_HOLDER_TERMINATION_MSG, + Test.MDOC_PERIPHERAL_SERVER_MODE_HOLDER_TERMINATION_BLE, + Test.MDOC_PERIPHERAL_SERVER_MODE_READER_TERMINATION_MSG, + Test.MDOC_PERIPHERAL_SERVER_MODE_READER_TERMINATION_BLE -> { + false + } + Test.MDOC_CENTRAL_CLIENT_MODE_L2CAP, + Test.MDOC_CENTRAL_CLIENT_MODE_L2CAP_HOLDER_TERMINATION_MSG, + Test.MDOC_CENTRAL_CLIENT_MODE_L2CAP_READER_TERMINATION_MSG, + Test.MDOC_PERIPHERAL_SERVER_MODE_L2CAP, + Test.MDOC_PERIPHERAL_SERVER_MODE_L2CAP_HOLDER_TERMINATION_MSG, + Test.MDOC_PERIPHERAL_SERVER_MODE_L2CAP_READER_TERMINATION_MSG, + Test.MDOC_CENTRAL_CLIENT_MODE_L2CAP_PSM_IN_TWO_WAY_ENGAGEMENT, + Test.MDOC_PERIPHERAL_SERVER_MODE_L2CAP_PSM_IN_DEVICE_ENGAGEMENT -> { + true + } + } + val options = MdocTransportOptions(bleUseL2CAP = bleUseL2CAP) + + var holderSuccess = false + var readerSuccess = false + var holderScanningTimeMsec = 0 + var readerScanningTimeMsec = 0 + + val iterationNumber = numIterations + 1 + Logger.i(TAG, "====== STARTING ITERATION ${iterationNumber} OF ${numIterationsTotal} ======") + + var transport = MdocTransportFactory.createTransport( + connectionMethod = connectionMethod, + role = MdocTransport.Role.MDOC, + options = options + ) + try { + if (plan.prewarm) { + transport.advertise() + } + val eDeviceKey = Crypto.createEcPrivateKey(EcCurve.P256) + val engagementGenerator = EngagementGenerator( + eSenderKey = eDeviceKey.publicKey, + version = "1.0" + ) + engagementGenerator.addConnectionMethods(listOf(transport.connectionMethod)) + val encodedDeviceEngagement = engagementGenerator.generate() + val getPsmFromReader = (test == Test.MDOC_CENTRAL_CLIENT_MODE_L2CAP_PSM_IN_TWO_WAY_ENGAGEMENT) + sendChannel.writeStringUtf8("TestPresentationPrepare ${iterationNumber} ${numIterationsTotal} " + + "${test.name} ${plan.prewarm} ${bleUseL2CAP} ${getPsmFromReader} " + + "${encodedDeviceEngagement.toBase64Url()}\n") + val prewarmDuration = 6.seconds + Logger.i(TAG, "Waiting $prewarmDuration for pre-warming connection") + if (getPsmFromReader) { + val psmFromReader = receiveChannel.readUTF8Line(LINE_LIMIT)!!.toInt() + Logger.i(TAG, "psmFromReader: $psmFromReader") + val connectionMethodWithPsm = connectionMethod + connectionMethodWithPsm.peripheralServerModePsm = psmFromReader + transport = MdocTransportFactory.createTransport( + connectionMethod = connectionMethod, + role = MdocTransport.Role.MDOC, + options = options + ) + } + delay(prewarmDuration) + Logger.i(TAG, "Done waiting") + withTimeout(15.seconds) { + sendChannel.writeStringUtf8("TestPresentationStart\n") + transport.open(eDeviceKey.publicKey) + val sessionEstablishmentMessage = transport.waitForMessage() + val eReaderKey = SessionEncryption.getEReaderKey(sessionEstablishmentMessage) + val encodedSessionTranscript = byteArrayOf(0x01, 0x02) + val sessionEncryption = SessionEncryption( + role = SessionEncryption.Role.MDOC, + eSelfKey = eDeviceKey, + remotePublicKey = eReaderKey, + encodedSessionTranscript = encodedSessionTranscript + ) + val (deviceRequest, statusCode) = sessionEncryption.decryptMessage(sessionEstablishmentMessage) + val deviceResponse = ByteArray(20 * 1024) + + when (test) { + Test.MDOC_CENTRAL_CLIENT_MODE, + Test.MDOC_PERIPHERAL_SERVER_MODE, + Test.MDOC_CENTRAL_CLIENT_MODE_L2CAP, + Test.MDOC_PERIPHERAL_SERVER_MODE_L2CAP, + Test.MDOC_CENTRAL_CLIENT_MODE_L2CAP_PSM_IN_TWO_WAY_ENGAGEMENT, + Test.MDOC_PERIPHERAL_SERVER_MODE_L2CAP_PSM_IN_DEVICE_ENGAGEMENT -> { + // Sends termination in initial message + transport.sendMessage( + sessionEncryption.encryptMessage( + messagePlaintext = deviceResponse, + statusCode = Constants.SESSION_DATA_STATUS_SESSION_TERMINATION + ) + ) + } + Test.MDOC_CENTRAL_CLIENT_MODE_HOLDER_TERMINATION_MSG, + Test.MDOC_PERIPHERAL_SERVER_MODE_HOLDER_TERMINATION_MSG, + Test.MDOC_CENTRAL_CLIENT_MODE_L2CAP_HOLDER_TERMINATION_MSG, + Test.MDOC_PERIPHERAL_SERVER_MODE_L2CAP_HOLDER_TERMINATION_MSG -> { + // Sends a separate termination message + transport.sendMessage( + sessionEncryption.encryptMessage( + messagePlaintext = deviceResponse, + statusCode = null + ) + ) + transport.sendMessage( + SessionEncryption.encodeStatus(Constants.SESSION_DATA_STATUS_SESSION_TERMINATION) + ) + } + Test.MDOC_CENTRAL_CLIENT_MODE_HOLDER_TERMINATION_BLE, + Test.MDOC_PERIPHERAL_SERVER_MODE_HOLDER_TERMINATION_BLE -> { + // Sends termination via BLE + transport.sendMessage( + sessionEncryption.encryptMessage( + messagePlaintext = deviceResponse, + statusCode = null + ) + ) + transport.sendMessage(byteArrayOf()) + } + Test.MDOC_CENTRAL_CLIENT_MODE_READER_TERMINATION_MSG, + Test.MDOC_PERIPHERAL_SERVER_MODE_READER_TERMINATION_MSG, + Test.MDOC_CENTRAL_CLIENT_MODE_L2CAP_READER_TERMINATION_MSG, + Test.MDOC_PERIPHERAL_SERVER_MODE_L2CAP_READER_TERMINATION_MSG -> { + // Expects reader to terminate via message + transport.sendMessage( + sessionEncryption.encryptMessage( + messagePlaintext = deviceResponse, + statusCode = null + ) + ) + val (deviceRequest, statusCode) = sessionEncryption.decryptMessage(transport.waitForMessage()) + if (deviceRequest != null || statusCode != Constants.SESSION_DATA_STATUS_SESSION_TERMINATION) { + throw Error("Expected empty message and status 20") + } + } + Test.MDOC_CENTRAL_CLIENT_MODE_READER_TERMINATION_BLE, + Test.MDOC_PERIPHERAL_SERVER_MODE_READER_TERMINATION_BLE -> { + // Expects reader to terminate via BLE + transport.sendMessage( + sessionEncryption.encryptMessage( + messagePlaintext = deviceResponse, + statusCode = null + ) + ) + val sessionData = transport.waitForMessage() + if (sessionData.isNotEmpty()) { + throw Error("Expected transport-specific termination, got non-empty message") + } + } + } + + holderSuccess = true + holderScanningTimeMsec = transport.scanningTime?.toInt(DurationUnit.MILLISECONDS) ?: 0 + } + } catch (e: TimeoutCancellationException) { + Logger.w(TAG, "Iteration timeout") + numHolderTimeouts += 1 + } catch (e: Throwable) { + Logger.w(TAG, "Iteration failed", e) + e.printStackTrace() + numHolderErrors += 1 + } finally { + transport.close() + } + + val response = receiveChannel.readUTF8Line(LINE_LIMIT) + Logger.i(TAG, "Response from client: $response") + if (response?.startsWith("TestPresentationSuccess ") == true) { + readerSuccess = true + val parts = response.split(" ") + val timeEngagementToResponseMsec = parts[1].toInt() + recordedTimeEngagementToResponseMsec.add(timeEngagementToResponseMsec.toDouble()) + readerScanningTimeMsec = parts[2].toInt() + } else if (response == "TestPresentationTimeout") { + numReaderTimeouts += 1 + } else if (response == "TestPresentationFailed") { + numReaderErrors += 1 + } else { + throw Error("Unexpected TestPresentation response '$response'") + } + + if (holderScanningTimeMsec > 0 && readerScanningTimeMsec > 0) { + Logger.w(TAG, "Both holder and reader scanning times are non-zero") + } + if (holderScanningTimeMsec > 0) { + recordedScanningTimeMsec.add(holderScanningTimeMsec.toDouble()) + } else if (readerScanningTimeMsec > 0) { + recordedScanningTimeMsec.add(readerScanningTimeMsec.toDouble()) + } + + numIterations += 1 + if (readerSuccess && holderSuccess) { + numSuccess += 1 + } else { + failedTransactions.add(iterationNumber) + } + } + + private fun updateResults(currentTest: Test?) { + Logger.i(TAG, "it=$numIterations success=$numSuccess " + + "hTO=$numHolderTimeouts hErr=$numHolderErrors " + + "rTO=$numReaderTimeouts rErr=$numReaderErrors " + + "| time: " + + "min=${recordedTimeEngagementToResponseMsec.minOrZero().toInt()} " + + "max=${recordedTimeEngagementToResponseMsec.maxOrZero().toInt()} " + + "avg=${recordedTimeEngagementToResponseMsec.averageOrZero().toInt()} " + + "stddev=${recordedTimeEngagementToResponseMsec.stdDevOrZero().toInt()} " + + "| scanning: " + + "min=${recordedScanningTimeMsec.minOrZero().toInt()} " + + "max=${recordedScanningTimeMsec.maxOrZero().toInt()} " + + "avg=${recordedScanningTimeMsec.averageOrZero().toInt()} " + + "stddev=${recordedScanningTimeMsec.stdDevOrZero().toInt()}") + + multiDeviceTestsResults.value = Results( + plan = plan, + currentTest = currentTest, + numIterationsTotal = numIterationsTotal, + numIterationsCompleted = numIterations, + numIterationsSuccessful = numSuccess, + failedIterations = failedTransactions.toList(), + transactionTime = Timing( + min = recordedTimeEngagementToResponseMsec.minOrZero().toInt(), + max = recordedTimeEngagementToResponseMsec.maxOrZero().toInt(), + avg = recordedTimeEngagementToResponseMsec.averageOrZero().toInt(), + stdDev = recordedTimeEngagementToResponseMsec.stdDevOrZero().toInt(), + ), + scanningTime = Timing( + min = recordedScanningTimeMsec.minOrZero().toInt(), + max = recordedScanningTimeMsec.maxOrZero().toInt(), + avg = recordedScanningTimeMsec.averageOrZero().toInt(), + stdDev = recordedScanningTimeMsec.stdDevOrZero().toInt(), + ), + ) + } +} + +private fun List.minOrZero(): Double { + if (isEmpty()) { + return 0.0 + } + return this.min() +} + +private fun List.maxOrZero(): Double { + if (isEmpty()) { + return 0.0 + } + return this.max() +} + +private fun List.averageOrZero(): Double { + if (isEmpty()) { + return 0.0 + } + return this.average() +} + +private fun List.stdDevOrZero(): Double { + if (isEmpty()) { + return 0.0 + } + val mean = average() + val sumOfSquaredDeviations = sumOf { (it - mean) * (it - mean) } + return sqrt(sumOfSquaredDeviations / size) +} \ No newline at end of file diff --git a/samples/testapp/src/commonMain/kotlin/com/android/identity/testapp/multidevicetests/Plan.kt b/samples/testapp/src/commonMain/kotlin/com/android/identity/testapp/multidevicetests/Plan.kt new file mode 100644 index 000000000..34a7cd614 --- /dev/null +++ b/samples/testapp/src/commonMain/kotlin/com/android/identity/testapp/multidevicetests/Plan.kt @@ -0,0 +1,71 @@ +package com.android.identity.testapp.multidevicetests + +enum class Plan( + val description: String, + val tests: List>, + val prewarm: Boolean, +) { + ALL_TESTS( + description = "All Tests & Corner-cases", + tests = Test.entries.toList().map { Pair(it, 1) }, + prewarm = true, + ), + HAPPY_FLOW_SHORT( + description = "Happy Flow Short", + tests = listOf( + Pair(Test.MDOC_CENTRAL_CLIENT_MODE, 10), + Pair(Test.MDOC_PERIPHERAL_SERVER_MODE, 10) + ), + prewarm = true, + ), + HAPPY_FLOW_LONG( + description = "Happy Flow Long", + tests = listOf( + Pair(Test.MDOC_CENTRAL_CLIENT_MODE, 50), + Pair(Test.MDOC_PERIPHERAL_SERVER_MODE, 50) + ), + prewarm = true, + ), + BLE_CENTRAL_CLIENT_MODE_ONLY( + description = "BLE Central Client Mode", + tests = listOf( + Pair(Test.MDOC_CENTRAL_CLIENT_MODE, 40), + ), + prewarm = true, + ), + BLE_CENTRAL_CLIENT_MODE_ONLY_NO_PREWARM( + description = "BLE Central Client Mode w/o prewarm", + tests = listOf( + Pair(Test.MDOC_CENTRAL_CLIENT_MODE, 40), + ), + prewarm = false, + ), + BLE_PERIPHERAL_SERVER_MODE( + description = "BLE Peripheral Server Mode", + tests = listOf( + Pair(Test.MDOC_PERIPHERAL_SERVER_MODE, 40), + ), + prewarm = true, + ), + BLE_PERIPHERAL_SERVER_MODE_NO_PREWARM( + description = "BLE Peripheral Server Mode w/o prewarm", + tests = listOf( + Pair(Test.MDOC_PERIPHERAL_SERVER_MODE, 40), + ), + prewarm = false, + ), + BLE_CENTRAL_CLIENT_MODE_L2CAP_PSM_IN_TWO_WAY_ENGAGEMENT( + description = "BLE Central Client L2CAP w/ PSM in Engagement", + tests = listOf( + Pair(Test.MDOC_CENTRAL_CLIENT_MODE_L2CAP_PSM_IN_TWO_WAY_ENGAGEMENT, 40), + ), + prewarm = true, + ), + BLE_PERIPHERAL_SERVER_MODE_L2CAP_PSM_IN_TWO_WAY_ENGAGEMENT( + description = "BLE Peripheral Server L2CAP w/ PSM in Engagement", + tests = listOf( + Pair(Test.MDOC_PERIPHERAL_SERVER_MODE_L2CAP_PSM_IN_DEVICE_ENGAGEMENT, 40), + ), + prewarm = true, + ), +} \ No newline at end of file diff --git a/samples/testapp/src/commonMain/kotlin/com/android/identity/testapp/multidevicetests/Results.kt b/samples/testapp/src/commonMain/kotlin/com/android/identity/testapp/multidevicetests/Results.kt new file mode 100644 index 000000000..5d530f689 --- /dev/null +++ b/samples/testapp/src/commonMain/kotlin/com/android/identity/testapp/multidevicetests/Results.kt @@ -0,0 +1,12 @@ +package com.android.identity.testapp.multidevicetests + +data class Results( + val plan: Plan = Plan.ALL_TESTS, + val currentTest: Test? = null, + val numIterationsTotal: Int = 0, + val numIterationsCompleted: Int = 0, + val numIterationsSuccessful: Int = 0, + val failedIterations: List = listOf(), + val transactionTime: Timing = Timing(), + val scanningTime: Timing = Timing(), +) \ No newline at end of file diff --git a/samples/testapp/src/commonMain/kotlin/com/android/identity/testapp/multidevicetests/Test.kt b/samples/testapp/src/commonMain/kotlin/com/android/identity/testapp/multidevicetests/Test.kt new file mode 100644 index 000000000..1b5d6ecff --- /dev/null +++ b/samples/testapp/src/commonMain/kotlin/com/android/identity/testapp/multidevicetests/Test.kt @@ -0,0 +1,67 @@ +package com.android.identity.testapp.multidevicetests + +enum class Test(val description: String) { + // holder terminates by including status 0x20 in same message as DeviceResponse + // + MDOC_CENTRAL_CLIENT_MODE("mdoc central client mode"), + MDOC_PERIPHERAL_SERVER_MODE("mdoc peripheral server mode"), + + // holder terminates with separate message with status 0x20 + // + MDOC_CENTRAL_CLIENT_MODE_HOLDER_TERMINATION_MSG("mdoc cc (holder term MSG)"), + MDOC_PERIPHERAL_SERVER_MODE_HOLDER_TERMINATION_MSG("mdoc ps (holder term MSG)"), + + // holder terminates with BLE specific termination + // + MDOC_CENTRAL_CLIENT_MODE_HOLDER_TERMINATION_BLE("mdoc cc (holder term BLE)"), + MDOC_PERIPHERAL_SERVER_MODE_HOLDER_TERMINATION_BLE("mdoc ps (holder term BLE)"), + + // reader terminates with message with status 0x20 + // + MDOC_CENTRAL_CLIENT_MODE_READER_TERMINATION_MSG("mdoc cc (reader term MSG)"), + MDOC_PERIPHERAL_SERVER_MODE_READER_TERMINATION_MSG("mdoc ps (reader term MSG)"), + + // reader terminates with BLE specific termination + // + MDOC_CENTRAL_CLIENT_MODE_READER_TERMINATION_BLE("mdoc cc (reader term BLE)"), + MDOC_PERIPHERAL_SERVER_MODE_READER_TERMINATION_BLE("mdoc ps (reader term BLE)"), + + + /////////////////////////////// + // L2CAP with PSM from GATT + /////////////////////////////// + + // holder L2CAP terminates by including status 0x20 in same message as DeviceResponse + // + MDOC_CENTRAL_CLIENT_MODE_L2CAP("mdoc cc L2CAP"), + MDOC_PERIPHERAL_SERVER_MODE_L2CAP("mdoc psm L2CAP"), + + // holder L2CAP terminates with separate message with status 0x20 + // + MDOC_CENTRAL_CLIENT_MODE_L2CAP_HOLDER_TERMINATION_MSG("mdoc cc L2CAP (holder term MSG)"), + MDOC_PERIPHERAL_SERVER_MODE_L2CAP_HOLDER_TERMINATION_MSG("mdoc ps L2CAP (holder term MSG)"), + + // holder L2CAP terminates with BLE specific termination: N/A + // + + // reader L2CAP terminates with message with status 0x20 + // + MDOC_CENTRAL_CLIENT_MODE_L2CAP_READER_TERMINATION_MSG("mdoc cc L2CAP (reader term MSG)"), + MDOC_PERIPHERAL_SERVER_MODE_L2CAP_READER_TERMINATION_MSG("mdoc ps L2CAP (reader term MSG)"), + + // reader L2CAP terminates with BLE specific termination: N/A + // + + /////////////////////////////// + // L2CAP with PSM in engagement (18013-5 Amendment 1) + /////////////////////////////// + + // PSM is conveyed from reader during two-way engagement + // + MDOC_CENTRAL_CLIENT_MODE_L2CAP_PSM_IN_TWO_WAY_ENGAGEMENT("mdoc cc L2CAP w/ PSM in two-way-eng"), + + // PSM is in Device Engagement + // + MDOC_PERIPHERAL_SERVER_MODE_L2CAP_PSM_IN_DEVICE_ENGAGEMENT("mdoc psm L2CAP w/ PSM in DE"), + +} \ No newline at end of file diff --git a/samples/testapp/src/commonMain/kotlin/com/android/identity/testapp/multidevicetests/Timing.kt b/samples/testapp/src/commonMain/kotlin/com/android/identity/testapp/multidevicetests/Timing.kt new file mode 100644 index 000000000..c0b7e6147 --- /dev/null +++ b/samples/testapp/src/commonMain/kotlin/com/android/identity/testapp/multidevicetests/Timing.kt @@ -0,0 +1,8 @@ +package com.android.identity.testapp.multidevicetests + +data class Timing( + val min: Int = 0, + val max: Int = 0, + val avg: Int = 0, + val stdDev: Int = 0, +) \ No newline at end of file diff --git a/samples/testapp/src/commonMain/kotlin/com/android/identity/testapp/ui/IsoMdocMultiDeviceTestingScreen.kt b/samples/testapp/src/commonMain/kotlin/com/android/identity/testapp/ui/IsoMdocMultiDeviceTestingScreen.kt new file mode 100644 index 000000000..5ffcb7315 --- /dev/null +++ b/samples/testapp/src/commonMain/kotlin/com/android/identity/testapp/ui/IsoMdocMultiDeviceTestingScreen.kt @@ -0,0 +1,304 @@ +package com.android.identity.testapp.ui + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.material3.Button +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.MutableState +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import com.android.identity.appsupport.ui.permissions.rememberBluetoothPermissionState +import com.android.identity.appsupport.ui.qrcode.ScanQrCodeDialog +import com.android.identity.appsupport.ui.qrcode.ShowQrCodeDialog +import com.android.identity.testapp.getLocalIpAddress +import com.android.identity.testapp.multidevicetests.MultiDeviceTestsClient +import com.android.identity.testapp.multidevicetests.MultiDeviceTestsServer +import com.android.identity.testapp.multidevicetests.Plan +import com.android.identity.testapp.multidevicetests.Results +import com.android.identity.util.Logger +import io.ktor.network.selector.SelectorManager +import io.ktor.network.sockets.InetSocketAddress +import io.ktor.network.sockets.Socket +import io.ktor.network.sockets.aSocket +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.IO +import kotlinx.coroutines.job +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import kotlin.coroutines.coroutineContext +import kotlin.time.Duration.Companion.seconds + +private const val TAG = "MdocTransportMultiDeviceTestingScreen" + +private /* const */ val PREWARMING_DURATION = 6.seconds + +@Composable +fun IsoMdocMultiDeviceTestingScreen( + showToast: (message: String) -> Unit, +) { + val blePermissionState = rememberBluetoothPermissionState() + + val coroutineScope = rememberCoroutineScope() + + val multiDeviceTestsServerShowButton = remember { mutableStateOf(null) } + val multiDeviceTestsClientShowButton = remember { mutableStateOf(false) } + + val multiDeviceTestClient = remember { mutableStateOf(null) } + val multiDeviceTestServer = remember { mutableStateOf(null) } + val multiDeviceTestResults = remember { mutableStateOf(Results()) } + + if (multiDeviceTestsServerShowButton.value != null) { + val url = multiDeviceTestsServerShowButton.value!! + Logger.i(TAG, "URL is $url") + ShowQrCodeDialog( + title = { Text(text = "Scan QR code") }, + text = { Text(text = "Scan this QR code on another device to run multi-device tests") }, + dismissButton = "Close", + data = url, + onDismiss = { + multiDeviceTestsServerShowButton.value = null + } + ) + } + + if (multiDeviceTestsClientShowButton.value) { + val uriScheme = "owf-ic-testing://" + ScanQrCodeDialog( + title = { Text ("Scan QR code") }, + text = { Text ("Scan code from another device to run multi-device tests.") }, + dismissButton = "Close", + onCodeScanned = { data -> + if (data.startsWith(uriScheme)) { + val serverAndPort = data.substring(uriScheme.length) + val colonPos = serverAndPort.indexOf(':') + val serverAddress = serverAndPort.substring(0, colonPos) + val serverPort = serverAndPort.substring(colonPos + 1, serverAndPort.length).toInt() + Logger.i(TAG, "Connect to $serverAddress port $serverPort") + + coroutineScope.launch { + withContext(Dispatchers.IO) { + try { + val selectorManager = SelectorManager(Dispatchers.IO) + val socket = aSocket(selectorManager).tcp().connect(serverAddress, serverPort) + runMultiDeviceTestsClient( + socket, + multiDeviceTestClient, + showToast + ) + } catch (error: Throwable) { + showToast("Error: ${error.message}") + } + } + } + multiDeviceTestsClientShowButton.value = false + true + } else { + false + } + }, + onDismiss = { + multiDeviceTestsClientShowButton.value = false + } + ) + } + + fun multiDeviceTestListen(plan: Plan) { + coroutineScope.launch { + withContext(Dispatchers.IO) { + try { + val localAddress = getLocalIpAddress() + val selectorManager = SelectorManager(Dispatchers.IO) + val serverSocket = aSocket(selectorManager).tcp().bind(localAddress, 0) + val localAddressPort = (serverSocket.localAddress as InetSocketAddress).port + Logger.i(TAG, "Accepting connections at $localAddress port $localAddressPort") + multiDeviceTestsServerShowButton.value = + "owf-ic-testing://${localAddress}:${localAddressPort}" + val socket = serverSocket.accept() + multiDeviceTestsServerShowButton.value = null + multiDeviceTestResults.value = Results() + runMultiDeviceTestsServer( + socket, + plan, + multiDeviceTestServer, + multiDeviceTestResults, + showToast + ) + } catch (error: Throwable) { + showToast("Error: ${error.message}") + } + } + } + } + + + if (!blePermissionState.isGranted) { + Column( + modifier = Modifier.fillMaxSize(), + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally + ) { + Button( + onClick = { blePermissionState.launchPermissionRequest() } + ) { + Text("Request BLE permissions") + } + } + } else { + if (multiDeviceTestServer.value != null) { + val r = multiDeviceTestResults.value + Column( + modifier = Modifier.fillMaxSize(), + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally + ) { + Text( + text = "Running Multi-Device-Test as Server", + style = MaterialTheme.typography.bodyLarge, + fontWeight = FontWeight.Bold, + ) + Text("Plan: ${r.plan.description}") + if (r.currentTest != null) { + Text("Currently Testing: ${r.currentTest.description}") + } + Spacer(modifier = Modifier.height(10.dp)) + + Text("Number Iterations Completed: ${r.numIterationsCompleted} of ${r.numIterationsTotal}") + Text("Number Iterations Successful: ${r.numIterationsSuccessful}") + Text("Failed Iterations: ${r.failedIterations}") + Spacer(modifier = Modifier.height(10.dp)) + + Text( + text = "Transaction Time Avg: ${r.transactionTime.avg} msec", + fontWeight = FontWeight.Bold, + ) + Text("min/max/σ: ${r.transactionTime.min}/${r.transactionTime.max}/${r.transactionTime.stdDev}") + Spacer(modifier = Modifier.height(10.dp)) + + Text( + text = "Scanning Time Avg: ${r.scanningTime.avg} msec", + fontWeight = FontWeight.Bold, + ) + Text("min/max/σ: ${r.scanningTime.min}/${r.scanningTime.max}/${r.scanningTime.stdDev}") + + Spacer(modifier = Modifier.height(20.dp)) + + Button( + onClick = { + multiDeviceTestServer.value?.job?.cancel() + multiDeviceTestServer.value = null + } + ) { + if (r.numIterationsTotal > 0 && r.numIterationsCompleted == r.numIterationsTotal) { + Text("Close") + } else { + Text("Cancel") + } + } + } + } else if (multiDeviceTestClient.value != null) { + Column( + modifier = Modifier.fillMaxSize(), + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally + ) { + Text( + text = "Running Multi-Device-Test as Client", + style = MaterialTheme.typography.bodyLarge, + fontWeight = FontWeight.Bold, + ) + Spacer(modifier = Modifier.height(10.dp)) + Button( + onClick = { + multiDeviceTestClient.value?.job?.cancel() + multiDeviceTestClient.value = null + } + ) { + Text("Cancel") + } + } + } else { + LazyColumn( + modifier = Modifier.padding(8.dp) + ) { + item { + TextButton( + onClick = { + multiDeviceTestsClientShowButton.value = true + }, + content = { Text("Run Multi-Device Tests as Client") } + ) + } + + for (plan in Plan.entries) { + val numIt = plan.tests.sumOf { it.second } + item { + TextButton( + onClick = { multiDeviceTestListen(plan) }, + content = { + Text("Plan: ${plan.description} ($numIt iterations)") + } + ) + } + } + } + } + } +} + +private suspend fun runMultiDeviceTestsServer( + socket: Socket, + plan: Plan, + multiDeviceTestsServer: MutableState, + multiDeviceTestsResults: MutableState, + showToast: (message: String) -> Unit +) { + val server = MultiDeviceTestsServer( + coroutineContext.job, + socket, + plan, + multiDeviceTestsResults + ) + multiDeviceTestsServer.value = server + try { + server.run() + } catch (error: Throwable) { + Logger.i(TAG, "Multi-Device Tests failed", error) + error.printStackTrace() + showToast("Multi-Device-Tests failed: ${error.message}") + } + // Keep multiDeviceTestServer around so user has a chance to review the results... + // multiDeviceTestsServer.value = null +} + +private suspend fun runMultiDeviceTestsClient( + socket: Socket, + multiDeviceTestsClient: MutableState, + showToast: (message: String) -> Unit +) { + val client = MultiDeviceTestsClient( + coroutineContext.job, + socket, + ) + multiDeviceTestsClient.value = client + try { + client.run() + } catch (error: Throwable) { + Logger.i(TAG, "Multi-Device Tests failed", error) + error.printStackTrace() + showToast("Multi-Device-Tests failed: ${error.message}") + } + multiDeviceTestsClient.value = null +} diff --git a/samples/testapp/src/commonMain/kotlin/com/android/identity/testapp/ui/IsoMdocProximityReadingScreen.kt b/samples/testapp/src/commonMain/kotlin/com/android/identity/testapp/ui/IsoMdocProximityReadingScreen.kt new file mode 100644 index 000000000..fb2c5ec40 --- /dev/null +++ b/samples/testapp/src/commonMain/kotlin/com/android/identity/testapp/ui/IsoMdocProximityReadingScreen.kt @@ -0,0 +1,747 @@ +package com.android.identity.testapp.ui + +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.content.MediaType.Companion.Image +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Info +import androidx.compose.material.icons.filled.Warning +import androidx.compose.material3.Button +import androidx.compose.material3.Checkbox +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.ExposedDropdownMenuBox +import androidx.compose.material3.ExposedDropdownMenuDefaults +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.TextField +import androidx.compose.runtime.Composable +import androidx.compose.runtime.MutableState +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.ImageBitmap +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import com.android.identity.appsupport.ui.decodeImage +import com.android.identity.appsupport.ui.permissions.rememberBluetoothPermissionState +import com.android.identity.appsupport.ui.qrcode.ScanQrCodeDialog +import com.android.identity.cbor.Bstr +import com.android.identity.cbor.Cbor +import com.android.identity.cbor.DiagnosticOption +import com.android.identity.crypto.Crypto +import com.android.identity.crypto.EcCurve +import com.android.identity.documenttype.DocumentAttributeType +import com.android.identity.documenttype.DocumentTypeRepository +import com.android.identity.documenttype.DocumentWellKnownRequest +import com.android.identity.documenttype.knowntypes.DrivingLicense +import com.android.identity.mdoc.engagement.EngagementParser +import com.android.identity.mdoc.response.DeviceResponseParser +import com.android.identity.mdoc.sessionencryption.SessionEncryption +import com.android.identity.mdoc.transport.MdocTransport +import com.android.identity.mdoc.transport.MdocTransportClosedException +import com.android.identity.mdoc.transport.MdocTransportFactory +import com.android.identity.mdoc.transport.MdocTransportOptions +import com.android.identity.testapp.TestAppUtils +import com.android.identity.util.Constants +import com.android.identity.util.Logger +import com.android.identity.util.fromBase64Url +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.Job +import kotlinx.coroutines.launch +import kotlinx.datetime.Clock +import kotlinx.datetime.Instant +import kotlinx.datetime.LocalDateTime +import kotlinx.datetime.TimeZone +import kotlinx.datetime.format +import kotlinx.datetime.toLocalDateTime + +private const val TAG = "IsoMdocProximityReadingScreen" + +@OptIn(ExperimentalMaterial3Api::class, ExperimentalCoroutinesApi::class) +@Composable +fun IsoMdocProximityReadingScreen( + showToast: (message: String) -> Unit, +) { + val availableRequests = DrivingLicense.getDocumentType().sampleRequests + var dropdownExpanded = remember { mutableStateOf(false) } + var selectedRequest = remember { mutableStateOf(availableRequests[0]) } + + val blePermissionState = rememberBluetoothPermissionState() + + val coroutineScope = rememberCoroutineScope() + + val readerShowQrScanner = remember { mutableStateOf(false) } + var readerAutoCloseConnection by remember { mutableStateOf(true) } + var readerBleUseL2CAP by remember { mutableStateOf(false) } + var readerJob by remember { mutableStateOf(null) } + var readerTransport = remember { mutableStateOf(null) } + var readerSessionEncryption = remember { mutableStateOf(null) } + var readerSessionTranscript = remember { mutableStateOf(null) } + var readerMostRecentDeviceResponse = remember { mutableStateOf(null) } + + if (readerShowQrScanner.value) { + ScanQrCodeDialog( + title = { Text(text = "Scan QR code") }, + text = { Text(text = "Scan this QR code on another device") }, + additionalContent = { + Column() { + Row(verticalAlignment = Alignment.CenterVertically) { + Checkbox( + checked = readerAutoCloseConnection, + onCheckedChange = { readerAutoCloseConnection = it } + ) + Text(text = "Close transport after receiving first response") + } + Row(verticalAlignment = Alignment.CenterVertically) { + Checkbox( + checked = readerBleUseL2CAP, + onCheckedChange = { readerBleUseL2CAP = it } + ) + Text(text = "Use L2CAP if available") + } + } + }, + dismissButton = "Close", + onCodeScanned = { data -> + if (data.startsWith("mdoc:")) { + readerShowQrScanner.value = false + readerJob = coroutineScope.launch() { + doReaderFlow( + encodedDeviceEngagement = data.substring(5).fromBase64Url(), + autoCloseConnection = readerAutoCloseConnection, + bleUseL2CAP = readerBleUseL2CAP, + showToast = showToast, + readerTransport = readerTransport, + readerSessionEncryption = readerSessionEncryption, + readerSessionTranscript = readerSessionTranscript, + readerMostRecentDeviceResponse = readerMostRecentDeviceResponse, + selectedRequest = selectedRequest, + ) + readerJob = null + } + true + } else { + false + } + }, + onDismiss = { readerShowQrScanner.value = false } + ) + } + + val readerTransportState = readerTransport.value?.state?.collectAsState() + + if (!blePermissionState.isGranted) { + Column( + modifier = Modifier.fillMaxSize(), + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally + ) { + Button( + onClick = { blePermissionState.launchPermissionRequest() } + ) { + Text("Request BLE permissions") + } + } + } else { + if (readerJob != null) { + Column( + modifier = Modifier.fillMaxSize(), + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally + ) { + Column( + modifier = Modifier.weight(1.0f), + verticalArrangement = Arrangement.SpaceEvenly, + ) { + ShowReaderResults(readerMostRecentDeviceResponse, readerSessionTranscript) + } + Spacer(modifier = Modifier.height(10.dp)) + Text( + text = "Connection State: ${readerTransportState?.value}", + style = MaterialTheme.typography.bodyLarge, + fontWeight = FontWeight.Bold, + ) + Spacer(modifier = Modifier.height(10.dp)) + Column( + modifier = Modifier.fillMaxWidth(), + verticalArrangement = Arrangement.SpaceEvenly, + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Button( + onClick = { + coroutineScope.launch { + try { + val encodedDeviceRequest = + TestAppUtils.generateEncodedDeviceRequest( + selectedRequest.value, + readerSessionTranscript.value!! + ) + readerMostRecentDeviceResponse.value = byteArrayOf() + readerTransport.value!!.sendMessage( + readerSessionEncryption.value!!.encryptMessage( + messagePlaintext = encodedDeviceRequest, + statusCode = null + ) + ) + } catch (error: Throwable) { + Logger.e(TAG, "Caught exception", error) + error.printStackTrace() + showToast("Error: ${error.message}") + } + } + } + ) { + Text("Send Another Request") + } + Button( + onClick = { + coroutineScope.launch { + try { + readerTransport.value!!.sendMessage( + SessionEncryption.encodeStatus(Constants.SESSION_DATA_STATUS_SESSION_TERMINATION) + ) + readerTransport.value!!.close() + } catch (error: Throwable) { + Logger.e(TAG, "Caught exception", error) + error.printStackTrace() + showToast("Error: ${error.message}") + } + } + } + ) { + Text("Close (Message)") + } + Button( + onClick = { + coroutineScope.launch { + try { + readerTransport.value!!.sendMessage(byteArrayOf()) + readerTransport.value!!.close() + } catch (error: Throwable) { + Logger.e(TAG, "Caught exception", error) + error.printStackTrace() + showToast("Error: ${error.message}") + } + } + } + ) { + Text("Close (Transport-Specific)") + } + Button( + onClick = { + coroutineScope.launch { + try { + readerTransport.value!!.close() + } catch (error: Throwable) { + Logger.e(TAG, "Caught exception", error) + error.printStackTrace() + showToast("Error: ${error.message}") + } + } + } + ) { + Text("Close (None)") + } + } + } + } else if (readerMostRecentDeviceResponse.value != null) { + Column( + modifier = Modifier.fillMaxSize(), + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally + ) { + Column( + modifier = Modifier.weight(1.0f), + verticalArrangement = Arrangement.SpaceEvenly, + ) { + ShowReaderResults(readerMostRecentDeviceResponse, readerSessionTranscript) + } + Spacer(modifier = Modifier.height(10.dp)) + Column( + modifier = Modifier.fillMaxWidth(), + verticalArrangement = Arrangement.SpaceEvenly, + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Button( + onClick = { readerMostRecentDeviceResponse.value = null } + ) { + Text("Close") + } + } + } + } else { + LazyColumn( + modifier = Modifier.padding(8.dp) + ) { + item { + RequestPicker( + availableRequests, + selectedRequest, + dropdownExpanded, + onRequestSelected = {} + ) + } + item { + TextButton( + onClick = { + readerShowQrScanner.value = true + readerMostRecentDeviceResponse.value = null + }, + content = { Text("Request mDL via QR Code") } + ) + } + } + } + } +} + +private suspend fun doReaderFlow( + encodedDeviceEngagement: ByteArray, + autoCloseConnection: Boolean, + bleUseL2CAP: Boolean, + showToast: (message: String) -> Unit, + readerTransport: MutableState, + readerSessionEncryption: MutableState, + readerSessionTranscript: MutableState, + readerMostRecentDeviceResponse: MutableState, + selectedRequest: MutableState, +) { + val deviceEngagement = EngagementParser(encodedDeviceEngagement).parse() + val connectionMethod = deviceEngagement.connectionMethods[0] + val eDeviceKey = deviceEngagement.eSenderKey + val eReaderKey = Crypto.createEcPrivateKey(EcCurve.P256) + + val transport = MdocTransportFactory.createTransport( + connectionMethod, + MdocTransport.Role.MDOC_READER, + MdocTransportOptions(bleUseL2CAP = bleUseL2CAP) + ) + readerTransport.value = transport + val encodedSessionTranscript = TestAppUtils.generateEncodedSessionTranscript( + encodedDeviceEngagement, + eReaderKey.publicKey + ) + val sessionEncryption = SessionEncryption( + SessionEncryption.Role.MDOC_READER, + eReaderKey, + eDeviceKey, + encodedSessionTranscript, + ) + readerSessionEncryption.value = sessionEncryption + readerSessionTranscript.value = encodedSessionTranscript + val encodedDeviceRequest = TestAppUtils.generateEncodedDeviceRequest( + selectedRequest.value, + encodedSessionTranscript + ) + try { + transport.open(eDeviceKey) + transport.sendMessage( + sessionEncryption.encryptMessage( + messagePlaintext = encodedDeviceRequest, + statusCode = null + ) + ) + while (true) { + val sessionData = transport.waitForMessage() + if (sessionData.isEmpty()) { + showToast("Received transport-specific session termination message from holder") + transport.close() + break + } + + val (message, status) = sessionEncryption.decryptMessage(sessionData) + Logger.i(TAG, "Holder sent ${message?.size} bytes status $status") + if (message != null) { + readerMostRecentDeviceResponse.value = message + } + if (status == Constants.SESSION_DATA_STATUS_SESSION_TERMINATION) { + showToast("Received session termination message from holder") + Logger.i(TAG, "Holder indicated they closed the connection. " + + "Closing and ending reader loop") + transport.close() + break + } + if (autoCloseConnection) { + showToast("Response received, autoclosing connection") + Logger.i(TAG, "Holder did not indicate they are closing the connection. " + + "Auto-close is enabled, so sending termination message, closing, and " + + "ending reader loop") + transport.sendMessage(SessionEncryption.encodeStatus(Constants.SESSION_DATA_STATUS_SESSION_TERMINATION)) + transport.close() + break + } + showToast("Response received, keeping connection open") + Logger.i(TAG, "Holder did not indicate they are closing the connection. " + + "Auto-close is not enabled so waiting for message from holder") + // "Send additional request" and close buttons will act further on `transport` + } + } catch (_: MdocTransportClosedException) { + // Nothing to do, this is thrown when transport.close() is called from another coroutine, that + // is, the onClick handlers for the close buttons. + Logger.i(TAG, "Ending reader flow due to MdocTransportClosedException") + } catch (error: Throwable) { + Logger.e(TAG, "Caught exception", error) + error.printStackTrace() + showToast("Error: ${error.message}") + } finally { + transport.close() + readerTransport.value = null + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun RequestPicker( + availableRequests: List, + comboBoxSelected: MutableState, + comboBoxExpanded: MutableState, + onRequestSelected: (request: DocumentWellKnownRequest) -> Unit +) { + Box( + modifier = Modifier + .fillMaxWidth() + .padding(32.dp) + ) { + Column( + modifier = Modifier.fillMaxWidth(), + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally + ) { + + Text( + modifier = Modifier.padding(end = 16.dp), + text = "mDL Data Elements to Request" + ) + + ExposedDropdownMenuBox( + expanded = comboBoxExpanded.value, + onExpandedChange = { + comboBoxExpanded.value = !comboBoxExpanded.value + } + ) { + TextField( + value = comboBoxSelected.value.displayName, + onValueChange = {}, + readOnly = true, + trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = comboBoxExpanded.value) }, + modifier = Modifier.menuAnchor() + ) + + ExposedDropdownMenu( + expanded = comboBoxExpanded.value, + onDismissRequest = { comboBoxExpanded.value = false } + ) { + availableRequests.forEach { item -> + DropdownMenuItem( + text = { Text(text = item.displayName) }, + onClick = { + comboBoxSelected.value = item + comboBoxExpanded.value = false + onRequestSelected(comboBoxSelected.value) + } + ) + } + } + } + } + } +} + +@Composable +private fun ShowReaderResults( + readerMostRecentDeviceResponse: MutableState, + readerSessionTranscript: MutableState, +) { + val deviceResponse = readerMostRecentDeviceResponse.value + if (deviceResponse == null || deviceResponse.isEmpty()) { + Text( + text = "Waiting for data", + style = MaterialTheme.typography.bodyLarge, + fontWeight = FontWeight.Bold, + ) + } else { + val parser = DeviceResponseParser(deviceResponse, readerSessionTranscript.value!!) + val deviceResponse = parser.parse() + if (deviceResponse.documents.isEmpty()) { + Text( + text = "No documents in response", + style = MaterialTheme.typography.bodyLarge, + fontWeight = FontWeight.Bold, + ) + } else { + // TODO: show multiple documents + val documentData = DocumentData.fromMdocDeviceResponseDocument( + deviceResponse.documents[0], + TestAppUtils.documentTypeRepository + ) + ShowDocumentData(documentData, 0, deviceResponse.documents.size) + } + } +} + +@Composable +private fun ShowKeyValuePair(kvPair: DocumentKeyValuePair) { + Column( + Modifier + .padding(8.dp) + .fillMaxWidth()) { + Text( + text = kvPair.key, + fontWeight = FontWeight.Bold, + style = MaterialTheme.typography.titleMedium + ) + Text( + text = kvPair.textValue, + style = MaterialTheme.typography.bodyMedium + ) + if (kvPair.bitmap != null) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.Center + ) { + Image( + bitmap = kvPair.bitmap, + modifier = Modifier.size(200.dp), + contentDescription = null + ) + } + + } + } +} + +@Composable +private fun ShowDocumentData( + documentData: DocumentData, + documentIndex: Int, + numDocuments: Int +) { + Column( + Modifier + .padding(8.dp) + .verticalScroll(rememberScrollState()) + ) { + + for (text in documentData.infoTexts) { + InfoCard(text) + } + for (text in documentData.warningTexts) { + WarningCard(text) + } + + if (numDocuments > 1) { + ShowKeyValuePair( + DocumentKeyValuePair( + "Document Number", + "${documentIndex + 1} of $numDocuments" + ) + ) + } + + for (kvPair in documentData.kvPairs) { + ShowKeyValuePair(kvPair) + } + + } +} + +@Composable +private fun WarningCard(text: String) { + Column( + modifier = Modifier + .padding(8.dp) + .fillMaxWidth() + .clip(shape = RoundedCornerShape(8.dp)) + .background(MaterialTheme.colorScheme.errorContainer), + horizontalAlignment = Alignment.Start + ) { + Row( + modifier = Modifier.padding(16.dp), + ) { + Icon( + modifier = Modifier.padding(end = 16.dp), + imageVector = Icons.Filled.Warning, + contentDescription = "An error icon", + tint = MaterialTheme.colorScheme.onErrorContainer + ) + + Text( + text = text, + color = MaterialTheme.colorScheme.onErrorContainer + ) + } + } +} + +@Composable +private fun InfoCard(text: String) { + Column( + modifier = Modifier + .padding(8.dp) + .fillMaxWidth() + .clip(shape = RoundedCornerShape(8.dp)) + .background(MaterialTheme.colorScheme.primaryContainer), + horizontalAlignment = Alignment.Start + ) { + Row( + modifier = Modifier.padding(16.dp), + ) { + Icon( + modifier = Modifier.padding(end = 16.dp), + imageVector = Icons.Filled.Info, + contentDescription = "An info icon", + tint = MaterialTheme.colorScheme.onPrimaryContainer + ) + + Text( + text = text, + color = MaterialTheme.colorScheme.onPrimaryContainer + ) + } + } +} + +// TODO: +// - add infos/warnings according to TrustManager (need to port TrustManager to KMP), that is +// add a warning if the issuer isn't well-known. +// - move to identity-appsupport +// - add fromSdJwtVcResponse() +private data class DocumentData( + val infoTexts: List, + val warningTexts: List, + val kvPairs: List +) { + companion object { + + fun fromMdocDeviceResponseDocument( + document: DeviceResponseParser.Document, + documentTypeRepository: DocumentTypeRepository, + ): DocumentData { + val infos = mutableListOf() + val warnings = mutableListOf() + val kvPairs = mutableListOf() + + if (document.issuerSignedAuthenticated) { + // TODO: Take a [TrustManager] instance and use that to determine trust relationship + if (document.issuerCertificateChain.certificates.last().ecPublicKey == TestAppUtils.dsKeyPub) { + infos.add("Issuer is in a trust list") + } else { + warnings.add("Issuer is not in trust list") + } + } + if (!document.deviceSignedAuthenticated) { + warnings.add("Device Authentication failed") + } + if (!document.issuerSignedAuthenticated) { + warnings.add("Issuer Authentication failed") + } + if (document.numIssuerEntryDigestMatchFailures > 0) { + warnings.add("One or more issuer provided data elements failed to authenticate") + } + val now = Clock.System.now() + if (now < document.validityInfoValidFrom || now > document.validityInfoValidUntil) { + warnings.add("Document information is not valid at this point in time.") + } + + kvPairs.add(DocumentKeyValuePair("Type", "ISO mdoc (ISO/IEC 18013-5:2021)")) + kvPairs.add(DocumentKeyValuePair("DocType", document.docType)) + kvPairs.add(DocumentKeyValuePair("Valid From", formatTime(document.validityInfoValidFrom))) + kvPairs.add(DocumentKeyValuePair("Valid Until", formatTime(document.validityInfoValidUntil))) + kvPairs.add(DocumentKeyValuePair("Signed At", formatTime(document.validityInfoSigned))) + kvPairs.add(DocumentKeyValuePair( + "Expected Update", + document.validityInfoExpectedUpdate?.let { formatTime(it) } ?: "Not Set" + )) + + val mdocType = documentTypeRepository.getDocumentTypeForMdoc(document.docType)?.mdocDocumentType + + // TODO: Handle DeviceSigned data + for (namespaceName in document.issuerNamespaces) { + val mdocNamespace = if (mdocType !=null) { + mdocType.namespaces.get(namespaceName) + } else { + // Some DocTypes not known by [documentTypeRepository] - could be they are + // private or was just never added - may use namespaces from existing + // DocTypes... support that as well. + // + documentTypeRepository.getDocumentTypeForMdocNamespace(namespaceName) + ?.mdocDocumentType?.namespaces?.get(namespaceName) + } + + kvPairs.add(DocumentKeyValuePair("Namespace", namespaceName)) + for (dataElementName in document.getIssuerEntryNames(namespaceName)) { + val mdocDataElement = mdocNamespace?.dataElements?.get(dataElementName) + val encodedDataElementValue = document.getIssuerEntryData(namespaceName, dataElementName) + val dataElement = Cbor.decode(encodedDataElementValue) + var bitmap: ImageBitmap? = null + val (key, value) = if (mdocDataElement != null) { + if (dataElement is Bstr && + mdocDataElement.attribute.type == DocumentAttributeType.Picture) { + try { + bitmap = decodeImage((dataElement as Bstr).value) + } catch (e: Throwable) { + Logger.w(TAG, "Error decoding image for data element $dataElement in " + + "namespace $namespaceName", e) + } + } + Pair( + mdocDataElement.attribute.displayName, + mdocDataElement.renderValue(dataElement) + ) + } else { + Pair( + dataElementName, + Cbor.toDiagnostics( + dataElement, setOf( + DiagnosticOption.PRETTY_PRINT, + DiagnosticOption.EMBEDDED_CBOR, + DiagnosticOption.BSTR_PRINT_LENGTH, + ) + ) + ) + } + kvPairs.add(DocumentKeyValuePair(key, value, bitmap = bitmap)) + } + } + return DocumentData(infos, warnings, kvPairs) + } + } +} + +private data class DocumentKeyValuePair( + val key: String, + val textValue: String, + val bitmap: ImageBitmap? = null +) + +private fun formatTime(instant: Instant): String { + val tz = TimeZone.currentSystemDefault() + val isoStr = instant.toLocalDateTime(tz).format(LocalDateTime.Formats.ISO) + // Get rid of the middle 'T' + return isoStr.substring(0, 10) + " " + isoStr.substring(11) +} + + diff --git a/samples/testapp/src/commonMain/kotlin/com/android/identity/testapp/ui/IsoMdocProximitySharingScreen.kt b/samples/testapp/src/commonMain/kotlin/com/android/identity/testapp/ui/IsoMdocProximitySharingScreen.kt new file mode 100644 index 000000000..b5cf506ba --- /dev/null +++ b/samples/testapp/src/commonMain/kotlin/com/android/identity/testapp/ui/IsoMdocProximitySharingScreen.kt @@ -0,0 +1,492 @@ +package com.android.identity.testapp.ui + +import androidx.compose.runtime.getValue +import androidx.compose.runtime.setValue +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.material3.Button +import androidx.compose.material3.Checkbox +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.rememberModalBottomSheetState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.MutableState +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import com.android.identity.appsupport.ui.consent.ConsentDocument +import com.android.identity.appsupport.ui.consent.ConsentModalBottomSheet +import com.android.identity.appsupport.ui.consent.ConsentRelyingParty +import com.android.identity.appsupport.ui.consent.MdocConsentField +import com.android.identity.appsupport.ui.permissions.rememberBluetoothPermissionState +import com.android.identity.appsupport.ui.qrcode.ShowQrCodeDialog +import com.android.identity.crypto.Crypto +import com.android.identity.crypto.EcCurve +import com.android.identity.documenttype.knowntypes.DrivingLicense +import com.android.identity.mdoc.connectionmethod.ConnectionMethod +import com.android.identity.mdoc.connectionmethod.ConnectionMethodBle +import com.android.identity.mdoc.engagement.EngagementGenerator +import com.android.identity.mdoc.request.DeviceRequestParser +import com.android.identity.mdoc.response.DeviceResponseGenerator +import com.android.identity.mdoc.sessionencryption.SessionEncryption +import com.android.identity.mdoc.transport.MdocTransport +import com.android.identity.mdoc.transport.MdocTransportClosedException +import com.android.identity.mdoc.transport.MdocTransportFactory +import com.android.identity.mdoc.transport.MdocTransportOptions +import com.android.identity.testapp.TestAppUtils +import com.android.identity.util.Constants +import com.android.identity.util.Logger +import com.android.identity.util.UUID +import com.android.identity.util.toBase64Url +import identitycredential.samples.testapp.generated.resources.Res +import identitycredential.samples.testapp.generated.resources.driving_license_card_art +import kotlinx.coroutines.CancellableContinuation +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.Job +import kotlinx.coroutines.launch +import kotlinx.coroutines.suspendCancellableCoroutine +import kotlin.coroutines.resume +import org.jetbrains.compose.resources.ExperimentalResourceApi +import org.jetbrains.compose.resources.getDrawableResourceBytes +import org.jetbrains.compose.resources.getSystemResourceEnvironment + +private const val TAG = "IsoMdocProximitySharingScreen" + +private data class ConsentSheetData( + val showConsentPrompt: Boolean, + val continuation: CancellableContinuation, + val document: ConsentDocument, + val consentFields: List, + val relyingParty: ConsentRelyingParty, +) + +@OptIn(ExperimentalMaterial3Api::class, ExperimentalCoroutinesApi::class) +@Composable +fun IsoMdocProximitySharingScreen( + showToast: (message: String) -> Unit, +) { + val blePermissionState = rememberBluetoothPermissionState() + + val coroutineScope = rememberCoroutineScope() + + var holderAutoCloseConnection = remember { mutableStateOf(true) } + var holderJob by remember { mutableStateOf(null) } + var holderTransport = remember { mutableStateOf(null) } + var holderEncodedDeviceEngagement = remember { mutableStateOf(null) } + + val holderConsentSheetData = remember { mutableStateOf(null)} + + if (holderConsentSheetData.value != null && holderConsentSheetData.value!!.showConsentPrompt) { + // TODO: use sheetGesturesEnabled=false when available - see + // https://issuetracker.google.com/issues/288211587 for details + val sheetState = rememberModalBottomSheetState( + skipPartiallyExpanded = true + ) + ConsentModalBottomSheet( + sheetState = sheetState, + consentFields = holderConsentSheetData.value!!.consentFields, + document = holderConsentSheetData.value!!.document, + relyingParty = holderConsentSheetData.value!!.relyingParty, + onConfirm = { + coroutineScope.launch { + sheetState.hide() + holderConsentSheetData.value!!.continuation.resume(true) + holderConsentSheetData.value = null + } + }, + onCancel = { + coroutineScope.launch { + sheetState.hide() + holderConsentSheetData.value!!.continuation.resume(false) + holderConsentSheetData.value = null + } + } + ) + + } + + val holderTransportState = holderTransport.value?.state?.collectAsState() + + val holderWaitingForRemotePeer = when (holderTransportState?.value) { + MdocTransport.State.IDLE, + MdocTransport.State.ADVERTISING, + MdocTransport.State.SCANNING -> { + true + } + else -> { + false + } + } + Logger.i(TAG, "holderTransportState=$holderTransportState holderWaitingForRemotePeer=$holderWaitingForRemotePeer") + if (holderTransport != null && holderWaitingForRemotePeer) { + val deviceEngagementQrCode = "mdoc:" + holderEncodedDeviceEngagement.value!!.toBase64Url() + ShowQrCodeDialog( + title = { Text(text = "Scan QR code") }, + text = { Text(text = "Scan this QR code on another device") }, + additionalContent = { + Row(verticalAlignment = Alignment.CenterVertically) { + Checkbox( + checked = holderAutoCloseConnection.value, + onCheckedChange = { holderAutoCloseConnection.value = it } + ) + Text(text = "Close transport after first response") + } + }, + dismissButton = "Close", + data = deviceEngagementQrCode, + onDismiss = { + holderJob?.cancel() + } + ) + } + + if (!blePermissionState.isGranted) { + Column( + modifier = Modifier.fillMaxSize(), + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally + ) { + Button( + onClick = { blePermissionState.launchPermissionRequest() } + ) { + Text("Request BLE permissions") + } + } + } else { + if (holderTransport.value != null) { + Column( + modifier = Modifier.fillMaxSize(), + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally + ) { + Text( + text = "Connection State: ${holderTransportState?.value}", + style = MaterialTheme.typography.bodyLarge, + fontWeight = FontWeight.Bold, + ) + Spacer(modifier = Modifier.height(10.dp)) + Column( + modifier = Modifier.fillMaxWidth(), + verticalArrangement = Arrangement.SpaceEvenly, + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Button( + onClick = { + coroutineScope.launch { + try { + holderTransport.value!!.sendMessage( + SessionEncryption.encodeStatus(Constants.SESSION_DATA_STATUS_SESSION_TERMINATION) + ) + holderTransport.value!!.close() + } catch (error: Throwable) { + Logger.e(TAG, "Caught exception", error) + error.printStackTrace() + showToast("Error: ${error.message}") + } + } + } + ) { + Text("Close (Message)") + } + Button( + onClick = { + coroutineScope.launch { + try { + holderTransport.value!!.sendMessage(byteArrayOf()) + holderTransport.value!!.close() + } catch (error: Throwable) { + Logger.e(TAG, "Caught exception", error) + error.printStackTrace() + showToast("Error: ${error.message}") + } + } + } + ) { + Text("Close (Transport-Specific)") + } + Button( + onClick = { + try { + coroutineScope.launch { + holderTransport.value!!.close() + } + } catch (error: Throwable) { + Logger.e(TAG, "Caught exception", error) + error.printStackTrace() + showToast("Error: ${error.message}") + } + } + ) { + Text("Close (None)") + } + } + } + } else { + LazyColumn( + modifier = Modifier.padding(8.dp) + ) { + item { + TextButton( + onClick = { + holderJob = coroutineScope.launch() { + doHolderFlow( + connectionMethod = ConnectionMethodBle( + supportsPeripheralServerMode = false, + supportsCentralClientMode = true, + peripheralServerModeUuid = null, + centralClientModeUuid = UUID.randomUUID(), + ), + options = MdocTransportOptions(), + autoCloseConnection = holderAutoCloseConnection, + showToast = showToast, + holderTransport = holderTransport, + encodedDeviceEngagement = holderEncodedDeviceEngagement, + holderConsentSheetData = holderConsentSheetData, + ) + } + }, + content = { Text("Share via QR (mdoc central client mode)") } + ) + } + item { + TextButton( + onClick = { + holderJob = coroutineScope.launch() { + doHolderFlow( + connectionMethod = ConnectionMethodBle( + supportsPeripheralServerMode = true, + supportsCentralClientMode = false, + peripheralServerModeUuid = UUID.randomUUID(), + centralClientModeUuid = null, + ), + options = MdocTransportOptions(), + autoCloseConnection = holderAutoCloseConnection, + showToast = showToast, + holderTransport = holderTransport, + encodedDeviceEngagement = holderEncodedDeviceEngagement, + holderConsentSheetData = holderConsentSheetData, + ) + } + }, + content = { Text("Share via QR (mdoc peripheral server mode)") } + ) + } + item { + TextButton( + onClick = { + holderJob = coroutineScope.launch() { + doHolderFlow( + connectionMethod = ConnectionMethodBle( + supportsPeripheralServerMode = false, + supportsCentralClientMode = true, + peripheralServerModeUuid = null, + centralClientModeUuid = UUID.randomUUID(), + ), + options = MdocTransportOptions(bleUseL2CAP = true), + autoCloseConnection = holderAutoCloseConnection, + showToast = showToast, + holderTransport = holderTransport, + encodedDeviceEngagement = holderEncodedDeviceEngagement, + holderConsentSheetData = holderConsentSheetData, + ) + } + }, + content = { Text("Share via QR (mdoc central client mode w/ L2CAP)") } + ) + } + item { + TextButton( + onClick = { + holderJob = coroutineScope.launch() { + doHolderFlow( + connectionMethod = ConnectionMethodBle( + supportsPeripheralServerMode = true, + supportsCentralClientMode = false, + peripheralServerModeUuid = UUID.randomUUID(), + centralClientModeUuid = null, + ), + options = MdocTransportOptions(bleUseL2CAP = true), + autoCloseConnection = holderAutoCloseConnection, + showToast = showToast, + holderTransport = holderTransport, + encodedDeviceEngagement = holderEncodedDeviceEngagement, + holderConsentSheetData = holderConsentSheetData, + ) + } + }, + content = { Text("Share via QR (mdoc peripheral server mode w/ L2CAP)") } + ) + } + } + } + } +} + +private suspend fun doHolderFlow( + connectionMethod: ConnectionMethod, + options: MdocTransportOptions, + autoCloseConnection: MutableState, + showToast: (message: String) -> Unit, + holderTransport: MutableState, + encodedDeviceEngagement: MutableState, + holderConsentSheetData: MutableState, +) { + val transport = MdocTransportFactory.createTransport( + connectionMethod, + MdocTransport.Role.MDOC, + options + ) + holderTransport.value = transport + val eDeviceKey = Crypto.createEcPrivateKey(EcCurve.P256) + val engagementGenerator = EngagementGenerator( + eSenderKey = eDeviceKey.publicKey, + version = "1.0" + ) + engagementGenerator.addConnectionMethods(listOf(transport!!.connectionMethod)) + encodedDeviceEngagement.value = engagementGenerator.generate() + try { + transport.open(eDeviceKey.publicKey) + + var sessionEncryption: SessionEncryption? = null + var encodedSessionTranscript: ByteArray? = null + while (true) { + Logger.i(TAG, "Waiting for message from reader...") + val sessionData = transport!!.waitForMessage() + if (sessionData.isEmpty()) { + showToast("Received transport-specific session termination message from reader") + transport.close() + break + } + + if (sessionEncryption == null) { + val eReaderKey = SessionEncryption.getEReaderKey(sessionData) + encodedSessionTranscript = TestAppUtils.generateEncodedSessionTranscript( + encodedDeviceEngagement.value!!, + eReaderKey + ) + sessionEncryption = SessionEncryption( + SessionEncryption.Role.MDOC, + eDeviceKey, + eReaderKey, + encodedSessionTranscript, + ) + } + val (encodedDeviceRequest, status) = sessionEncryption.decryptMessage(sessionData) + + if (status == Constants.SESSION_DATA_STATUS_SESSION_TERMINATION) { + showToast("Received session termination message from reader") + transport.close() + break + } + + val consentFields = showConsentPrompt( + encodedDeviceRequest!!, + encodedSessionTranscript!!, + holderConsentSheetData + ) + + val encodedDeviceResponse = if (consentFields == null) { + DeviceResponseGenerator(Constants.DEVICE_RESPONSE_STATUS_OK).generate() + } else { + TestAppUtils.generateEncodedDeviceResponse( + consentFields, + encodedSessionTranscript + ) + } + transport.sendMessage( + sessionEncryption.encryptMessage( + encodedDeviceResponse, + if (autoCloseConnection.value) { + Constants.SESSION_DATA_STATUS_SESSION_TERMINATION + } else { + null + } + ) + ) + if (autoCloseConnection.value) { + showToast("Response sent, autoclosing connection") + transport.close() + break + } else { + showToast("Response sent, keeping connection open") + } + } + } catch (_: MdocTransportClosedException) { + // Nothing to do, this is thrown when transport.close() is called from another coroutine, that + // is, the onClick handlers for the close buttons. + Logger.i(TAG, "Ending holderJob due to MdocTransportClosedException") + } catch (error: Throwable) { + Logger.e(TAG, "Caught exception", error) + error.printStackTrace() + showToast("Error: ${error.message}") + } finally { + transport.close() + holderTransport.value = null + encodedDeviceEngagement.value = null + } +} + +// Returns consent-fields if confirmed by the user, false otherwise. +// +// Throws if there is no mDL docrequest. +// +@OptIn(ExperimentalResourceApi::class) +private suspend fun showConsentPrompt( + encodedDeviceRequest: ByteArray, + encodedSessionTranscript: ByteArray, + holderConsentSheetData: MutableState, +): List? { + val deviceRequest = DeviceRequestParser( + encodedDeviceRequest, + encodedSessionTranscript, + ).parse() + for (docRequest in deviceRequest.docRequests) { + if (docRequest.docType == DrivingLicense.MDL_DOCTYPE) { + val cardArt = getDrawableResourceBytes( + getSystemResourceEnvironment(), + Res.drawable.driving_license_card_art + ) + val consentFields = MdocConsentField.generateConsentFields( + docRequest, + TestAppUtils.documentTypeRepository, + TestAppUtils.mdocCredential + ) + if (suspendCancellableCoroutine { continuation -> + holderConsentSheetData.value = ConsentSheetData( + showConsentPrompt = true, + continuation = continuation, + document = ConsentDocument( + name = "Erika's Driving License", + cardArt = cardArt, + description = "Driving License", + ), + consentFields = consentFields, + relyingParty = ConsentRelyingParty( + trustPoint = null, + websiteOrigin = null, + ) + ) + }) { + return consentFields + } else { + return null + } + + } + } + throw Error("DeviceRequest doesn't contain a docRequest for mDL") +} diff --git a/samples/testapp/src/commonMain/kotlin/com/android/identity/testapp/ui/QrCodesScreen.kt b/samples/testapp/src/commonMain/kotlin/com/android/identity/testapp/ui/QrCodesScreen.kt index 0d57d9c6c..fdfb92259 100644 --- a/samples/testapp/src/commonMain/kotlin/com/android/identity/testapp/ui/QrCodesScreen.kt +++ b/samples/testapp/src/commonMain/kotlin/com/android/identity/testapp/ui/QrCodesScreen.kt @@ -22,8 +22,9 @@ fun QrCodesScreen( if (showMdocQrCodeDialog.value) { ShowQrCodeDialog( - title = "Scan code on reader", - description = "Your personal information won't be shared yet. You don't need to hand your phone to anyone to share your ID.", + title = { Text(text = "Scan code on reader") }, + text = { Text(text = "Your personal information won't be shared yet. You don't need to hand your " + + "phone to anyone to share your ID.") }, dismissButton = "Close", // This is the DeviceEngagement test vector from ISO/IEC 18013-5:2021 Annex D encoded // as specified in clause 8.2.2.3. @@ -35,8 +36,8 @@ fun QrCodesScreen( if (showUrlQrCodeDialog.value) { ShowQrCodeDialog( - title = "Scan code with phone", - description = "This is a QR code for https://github.com/openwallet-foundation-labs/identity-credential", + title = { Text(text = "Scan code with phone") }, + text = { Text(text = "This is a QR code for https://github.com/openwallet-foundation-labs/identity-credential") }, dismissButton = "Close", data = "https://github.com/openwallet-foundation-labs/identity-credential", onDismiss = { showUrlQrCodeDialog.value = false } @@ -45,9 +46,9 @@ fun QrCodesScreen( if (showQrScanDialog.value) { ScanQrCodeDialog( - title = "Scan code", - description = "Ask the person you wish to request identity attributes from to present" + - " a QR code. This is usually in their identity wallet.", + title = { Text ("Scan code") }, + text = { Text ("Ask the person you wish to request identity attributes from to present" + + " a QR code. This is usually in their identity wallet.") }, dismissButton = "Close", onCodeScanned = { data -> if (data.startsWith("mdoc:")) { diff --git a/samples/testapp/src/commonMain/kotlin/com/android/identity/testapp/ui/StartScreen.kt b/samples/testapp/src/commonMain/kotlin/com/android/identity/testapp/ui/StartScreen.kt index 81c61a0b8..eef265e22 100644 --- a/samples/testapp/src/commonMain/kotlin/com/android/identity/testapp/ui/StartScreen.kt +++ b/samples/testapp/src/commonMain/kotlin/com/android/identity/testapp/ui/StartScreen.kt @@ -18,6 +18,9 @@ import identitycredential.samples.testapp.generated.resources.android_keystore_s import identitycredential.samples.testapp.generated.resources.passphrase_entry_field_screen_title import identitycredential.samples.testapp.generated.resources.cloud_secure_area_screen_title import identitycredential.samples.testapp.generated.resources.consent_modal_bottom_sheet_list_screen_title +import identitycredential.samples.testapp.generated.resources.iso_mdoc_multi_device_testing_title +import identitycredential.samples.testapp.generated.resources.iso_mdoc_proximity_reading_title +import identitycredential.samples.testapp.generated.resources.iso_mdoc_proximity_sharing_title import identitycredential.samples.testapp.generated.resources.qr_codes_screen_title import identitycredential.samples.testapp.generated.resources.secure_enclave_secure_area_screen_title import identitycredential.samples.testapp.generated.resources.software_secure_area_screen_title @@ -33,6 +36,9 @@ fun StartScreen( onClickPassphraseEntryField: () -> Unit = {}, onClickConsentSheetList: () -> Unit = {}, onClickQrCodes: () -> Unit = {}, + onClickIsoMdocProximitySharing: () -> Unit = {}, + onClickIsoMdocProximityReading: () -> Unit = {}, + onClickMdocTransportMultiDeviceTesting: () -> Unit = {}, ) { Surface( modifier = Modifier.fillMaxSize(), @@ -93,6 +99,24 @@ fun StartScreen( Text(stringResource(Res.string.qr_codes_screen_title)) } } + + item { + TextButton(onClick = onClickIsoMdocProximitySharing) { + Text(stringResource(Res.string.iso_mdoc_proximity_sharing_title)) + } + } + + item { + TextButton(onClick = onClickIsoMdocProximityReading) { + Text(stringResource(Res.string.iso_mdoc_proximity_reading_title)) + } + } + + item { + TextButton(onClick = onClickMdocTransportMultiDeviceTesting) { + Text(stringResource(Res.string.iso_mdoc_multi_device_testing_title)) + } + } } } } diff --git a/samples/testapp/src/iosMain/kotlin/com/android/identity/testapp/PlatformIos.kt b/samples/testapp/src/iosMain/kotlin/com/android/identity/testapp/PlatformIos.kt index 3e7fe058b..2b155641f 100644 --- a/samples/testapp/src/iosMain/kotlin/com/android/identity/testapp/PlatformIos.kt +++ b/samples/testapp/src/iosMain/kotlin/com/android/identity/testapp/PlatformIos.kt @@ -1,3 +1,65 @@ package com.android.identity.testapp +import kotlinx.cinterop.ByteVar +import kotlinx.cinterop.ExperimentalForeignApi +import kotlinx.cinterop.NativePlacement +import kotlinx.cinterop.allocArray +import kotlinx.cinterop.allocPointerTo +import kotlinx.cinterop.convert +import kotlinx.cinterop.memScoped +import kotlinx.cinterop.pointed +import kotlinx.cinterop.ptr +import kotlinx.cinterop.reinterpret +import kotlinx.cinterop.toKString +import kotlinx.cinterop.value +import platform.darwin.freeifaddrs +import platform.darwin.getifaddrs +import platform.darwin.ifaddrs +import platform.darwin.inet_ntop +import platform.posix.AF_INET +import platform.posix.AF_INET6 +import platform.posix.INET_ADDRSTRLEN +import platform.posix.INET6_ADDRSTRLEN +import platform.posix.sa_family_t +import platform.posix.sockaddr_in +import platform.posix.sockaddr_in6 + actual val platform = Platform.IOS + +@OptIn(ExperimentalForeignApi::class) +actual fun getLocalIpAddress(): String { + val (status, interfaces) = memScoped { + val ifap = allocPointerTo() + getifaddrs(ifap.ptr) to ifap.value + } + if (status != 0) { + freeifaddrs(interfaces) + throw IllegalStateException("getifaddrs() returned $status, expected 0") + } + val addresses = try { + generateSequence(interfaces) { it.pointed.ifa_next } + .mapNotNull { it.pointed.ifa_addr } + .mapNotNull { + val addr = when (it.pointed.sa_family) { + AF_INET.convert() -> it.reinterpret().pointed.sin_addr + AF_INET6.convert() -> it.reinterpret().pointed.sin6_addr + else -> return@mapNotNull null + } + memScoped { + val len = maxOf(INET_ADDRSTRLEN, INET6_ADDRSTRLEN) + val dst = allocArray(len) + inet_ntop(it.pointed.sa_family.convert(), addr.ptr, dst, len.convert())?.toKString() + } + } + .toList() + } finally { + freeifaddrs(interfaces) + } + + for (address in addresses) { + if (address.startsWith("192.168") || address.startsWith("10.") || address.startsWith("172.")) { + return address + } + } + throw IllegalStateException("Unable to determine local address") +} diff --git a/wallet/src/main/java/com/android/identity_credential/wallet/ui/destination/addtowallet/AddToWalletScreen.kt b/wallet/src/main/java/com/android/identity_credential/wallet/ui/destination/addtowallet/AddToWalletScreen.kt index dfdf59ee0..5834d131c 100644 --- a/wallet/src/main/java/com/android/identity_credential/wallet/ui/destination/addtowallet/AddToWalletScreen.kt +++ b/wallet/src/main/java/com/android/identity_credential/wallet/ui/destination/addtowallet/AddToWalletScreen.kt @@ -115,8 +115,10 @@ fun AddToWalletScreen( // compose ScanQrDialog when user taps on "Scan Credential Offer" if (showQrScannerDialog.value) { ScanQrCodeDialog( - title = stringResource(R.string.credential_offer_scan), - description = stringResource(id = R.string.credential_offer_details), + title = @Composable { Text(text = stringResource(R.string.credential_offer_scan)) }, + text = @Composable { Text( + text = stringResource(id = R.string.credential_offer_details) + )}, onCodeScanned = { qrCodeTextUrl -> // filter only for OID4VCI Url schemes. if (qrCodeTextUrl.startsWith(WalletApplication.OID4VCI_CREDENTIAL_OFFER_URL_SCHEME)) { diff --git a/wallet/src/main/java/com/android/identity_credential/wallet/ui/destination/reader/ReaderScreen.kt b/wallet/src/main/java/com/android/identity_credential/wallet/ui/destination/reader/ReaderScreen.kt index 4b002b2dd..f7803603d 100644 --- a/wallet/src/main/java/com/android/identity_credential/wallet/ui/destination/reader/ReaderScreen.kt +++ b/wallet/src/main/java/com/android/identity_credential/wallet/ui/destination/reader/ReaderScreen.kt @@ -174,8 +174,8 @@ private fun WaitForEngagement( if (showQrScannerDialog.value) { ScanQrCodeDialog( - title = stringResource(R.string.reader_screen_scan_qr_dialog_title), - description = stringResource(R.string.reader_screen_scan_qr_dialog_text), + title = @Composable { Text(text = stringResource(R.string.reader_screen_scan_qr_dialog_title)) }, + text = @Composable { Text(text = stringResource(R.string.reader_screen_scan_qr_dialog_text)) }, onCodeScanned = { qrCodeText -> model.setQrCode(qrCodeText) true