diff --git a/adapters-android/build.gradle.kts b/adapters-android/build.gradle.kts
index 948aed2..a59758c 100644
--- a/adapters-android/build.gradle.kts
+++ b/adapters-android/build.gradle.kts
@@ -1,6 +1,7 @@
plugins {
- id("com.android.library") version "8.2.2"
- kotlin("android") version "1.9.24"
+ id("com.android.library")
+ kotlin("android")
+ kotlin("plugin.serialization") version "1.9.24"
}
android {
@@ -37,11 +38,24 @@ android {
}
dependencies {
+ implementation(project(":core-domain"))
+ implementation(project(":core-runtime"))
implementation(project(":ports"))
- implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.3")
- implementation("androidx.core:core-ktx:1.12.0")
- implementation(libs.koin.android)
+ // Android framework + security
+ implementation("androidx.core:core-ktx:1.13.1")
+ implementation("androidx.security:security-crypto:1.1.0-alpha06")
- testImplementation(kotlin("test"))
+ // Coroutines
+ implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.8.1")
+
+ // Serialization
+ implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.6.3")
+
+ // Koin
+ implementation("io.insert-koin:koin-android:3.5.6")
+
+ // Unit test (JVM)
+ testImplementation("junit:junit:4.13.2")
+ testImplementation("org.jetbrains.kotlin:kotlin-test:1.9.24")
}
diff --git a/adapters-android/src/main/AndroidManifest.xml b/adapters-android/src/main/AndroidManifest.xml
index 19ef311..70c3430 100644
--- a/adapters-android/src/main/AndroidManifest.xml
+++ b/adapters-android/src/main/AndroidManifest.xml
@@ -1 +1,8 @@
-
\ No newline at end of file
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/adapters-android/src/main/java/com/hailie/adapters/android/ble/AndroidBleAdapter.kt b/adapters-android/src/main/java/com/hailie/adapters/android/ble/AndroidBleAdapter.kt
new file mode 100644
index 0000000..964d525
--- /dev/null
+++ b/adapters-android/src/main/java/com/hailie/adapters/android/ble/AndroidBleAdapter.kt
@@ -0,0 +1,282 @@
+package com.hailie.adapters.android.ble
+
+import android.Manifest
+import android.content.Context
+import androidx.annotation.RequiresPermission
+import com.hailie.domain.DeviceId
+import com.hailie.domain.DomainError
+import com.hailie.domain.EventCount
+import com.hailie.domain.EventOffset
+import com.hailie.domain.EventRange
+import com.hailie.domain.events.Event
+import com.hailie.domain.events.EventCountLoaded
+import com.hailie.domain.events.EventsAcked
+import com.hailie.domain.events.EventsRead
+import com.hailie.runtime.ports.BlePort
+import com.hailie.runtime.ports.ClockPort
+import com.hailie.runtime.ports.TelemetryEvent
+import com.hailie.runtime.ports.TelemetryPort
+import kotlinx.coroutines.runBlocking
+import kotlinx.coroutines.sync.Mutex
+import kotlinx.coroutines.sync.withLock
+import kotlinx.coroutines.withTimeout
+import java.nio.ByteBuffer
+import java.nio.ByteOrder
+import java.util.concurrent.ConcurrentHashMap
+import kotlin.coroutines.resume
+
+class AndroidBleAdapter(
+ private val context: Context,
+ private val clock: ClockPort,
+ private val telemetry: TelemetryPort,
+ private val resolver: DeviceResolver,
+ private val config: BleConfig,
+) : BlePort {
+ private val gate = Mutex()
+ private val sessions = ConcurrentHashMap()
+
+ @RequiresPermission(Manifest.permission.BLUETOOTH_CONNECT)
+ override fun connect(deviceId: DeviceId): Event =
+ runBlockingSerial("connect", deviceId) {
+ return@runBlockingSerial runCatching {
+ check(BluetoothPermission.hasConnectPermission(context)) { "Missing BLUETOOTH_CONNECT" }
+
+ val dev = resolver.resolve(deviceId)
+ val start = clock.now()
+ val session = sessions.computeIfAbsent(deviceId.raw) { ConnectionSession(context, dev) }
+ withTimeout(config.connectTimeoutMs) { session.connect() }
+ telemetry.emit(TelemetryEvent("gatt_connected", start, deviceId.raw))
+ com.hailie.domain.events.DeviceConnected(deviceId, start)
+ }.getOrElse {
+ syncFailed(deviceId, "Missing BLUETOOTH_CONNECT", security = true)
+ }
+ }
+
+ @RequiresPermission(Manifest.permission.BLUETOOTH_CONNECT)
+ override fun disconnect(deviceId: DeviceId): Event =
+ runBlockingSerial("disconnect", deviceId) {
+ if (!BluetoothPermission.hasConnectPermission(context)) {
+ telemetry.emit(
+ TelemetryEvent(
+ "sync_failed",
+ clock.now(),
+ deviceId.raw,
+ mapOf("error_category" to "security", "error_message" to "Missing BLUETOOTH_CONNECT"),
+ ),
+ )
+ return@runBlockingSerial com.hailie.domain.events.SyncFailed(
+ deviceId,
+ clock.now(),
+ DomainError.Unexpected("Missing BLUETOOTH_CONNECT"),
+ )
+ }
+ sessions.remove(deviceId.raw)?.close()
+ telemetry
+ .emit(TelemetryEvent("gatt_disconnected", clock.now(), deviceId.raw))
+ com.hailie.domain.events.Disconnected(
+ deviceId = deviceId,
+ at = clock.now(),
+ reason = com.hailie.domain.DisconnectReason.Custom("Manual"),
+ gattCode = null,
+ )
+ }
+
+ @RequiresPermission(Manifest.permission.BLUETOOTH_CONNECT)
+ override fun bond(deviceId: DeviceId): Event =
+ runBlockingSerial("bond", deviceId) {
+ if (!BluetoothPermission.hasConnectPermission(context)) {
+ return@runBlockingSerial syncFailed(
+ deviceId,
+ "Missing BLUETOOTH_CONNECT",
+ security = true,
+ )
+ }
+ val dev = resolver.resolve(deviceId)
+ val start = clock.now()
+ val ok = createBondBlocking(dev)
+ if (ok) {
+ telemetry
+ .emit(TelemetryEvent("bonded", clock.now(), deviceId.raw))
+ com.hailie.domain.events.DeviceBonded(deviceId, start)
+ } else {
+ telemetry.emit(
+ TelemetryEvent(
+ "sync_failed",
+ clock.now(),
+ deviceId.raw,
+ mapOf(
+ "error_category" to "security",
+ "error_message" to "bond_failed",
+ ),
+ ),
+ )
+ com.hailie.domain.events
+ .SyncFailed(deviceId, clock.now(), DomainError.Unexpected("bond_failed"))
+ }
+ }
+
+ @RequiresPermission(Manifest.permission.BLUETOOTH_CONNECT)
+ override fun readCount(deviceId: DeviceId): Event =
+ runBlockingSerial("readCount", deviceId) {
+ if (!BluetoothPermission.hasConnectPermission(context)) {
+ return@runBlockingSerial syncFailed(
+ deviceId,
+ "Missing BLUETOOTH_CONNECT",
+ security = true,
+ )
+ }
+ val dev = resolver.resolve(deviceId)
+ val session =
+ sessions
+ .computeIfAbsent(deviceId.raw) { ConnectionSession(context, dev) }
+ withTimeout(config.connectTimeoutMs) { session.connect() }
+
+ val start = clock.now()
+ val payload = withTimeout(config.readCountTimeoutMs) { session.read(config.countCharacteristicUuid) }
+
+ if (payload.size < 4) return@runBlockingSerial syncFailed(deviceId, "count_payload_short")
+ val total =
+ ByteBuffer
+ .wrap(payload, 0, 4).order(ByteOrder.LITTLE_ENDIAN).int
+
+ telemetry
+ .emit(TelemetryEvent("count_read", clock.now(), deviceId.raw, mapOf("total_count" to total.toString())))
+ EventCountLoaded(deviceId, start, total = EventCount(total.toLong()))
+ }
+
+ @RequiresPermission(Manifest.permission.BLUETOOTH_CONNECT)
+ override fun readPage(
+ deviceId: DeviceId,
+ offset: EventOffset,
+ count: Int,
+ ): Event =
+ runBlockingSerial("readPage", deviceId) {
+ if (!BluetoothPermission.hasConnectPermission(context)) {
+ return@runBlockingSerial syncFailed(
+ deviceId,
+ "Missing BLUETOOTH_CONNECT",
+ security = true,
+ )
+ }
+ val dev = resolver.resolve(deviceId)
+ val session = sessions.computeIfAbsent(deviceId.raw) { ConnectionSession(context, dev) }
+ withTimeout(config.connectTimeoutMs) { session.connect() }
+
+ val req =
+ ByteBuffer.allocate(8).order(ByteOrder.LITTLE_ENDIAN)
+ .putInt(offset.value.toInt()).putInt(count).array()
+
+ val start = clock.now()
+ withTimeout(config.readPageTimeoutMs) { session.write(config.pageCharacteristicUuid, req) }
+ val payload = withTimeout(config.readPageTimeoutMs) { session.read(config.pageCharacteristicUuid) }
+
+ // If your device returns a precise end offset in 'payload', parse here.
+ val range =
+ EventRange(
+ offset,
+ EventOffset(offset.value + count.toLong()),
+ )
+
+ telemetry.emit(
+ TelemetryEvent(
+ "page_read",
+ clock.now(),
+ deviceId.raw,
+ mapOf(
+ "offset" to offset.value.toString(),
+ "page_size" to count.toString(),
+ "payload_len" to payload.size.toString(),
+ ),
+ ),
+ )
+ EventsRead(deviceId, start, range)
+ }
+
+ @RequiresPermission(Manifest.permission.BLUETOOTH_CONNECT)
+ override fun ack(
+ deviceId: DeviceId,
+ upTo: EventOffset,
+ ): Event =
+ runBlockingSerial("ack", deviceId) {
+ if (!BluetoothPermission.hasConnectPermission(context)) {
+ return@runBlockingSerial syncFailed(
+ deviceId,
+ "Missing BLUETOOTH_CONNECT",
+ security = true,
+ )
+ }
+ val dev = resolver.resolve(deviceId)
+ val session =
+ sessions.computeIfAbsent(
+ deviceId.raw,
+ ) { ConnectionSession(context, dev) }
+ withTimeout(config.connectTimeoutMs) { session.connect() }
+
+ val req =
+ ByteBuffer
+ .allocate(4).order(ByteOrder.LITTLE_ENDIAN).putInt(upTo.value.toInt()).array()
+
+ val start = clock.now()
+ withTimeout(config.ackTimeoutMs) { session.write(config.ackCharacteristicUuid, req) }
+
+ telemetry
+ .emit(TelemetryEvent("ack_sent", clock.now(), deviceId.raw, mapOf("offset" to upTo.value.toString())))
+ EventsAcked(deviceId, start, upTo)
+ }
+
+ @RequiresPermission(Manifest.permission.BLUETOOTH_CONNECT)
+ private fun createBondBlocking(device: android.bluetooth.BluetoothDevice): Boolean {
+ if (!BluetoothPermission.hasConnectPermission(context)) return false
+ return runBlocking {
+ kotlinx.coroutines.suspendCancellableCoroutine { cont ->
+ val r =
+ BondStateReceiver(
+ device.address,
+ ) { ok, ex -> cont.resume(ex == null && ok) }
+ r.register(context)
+ try {
+ val started = device.createBond()
+ if (!started) {
+ r.unregister(context)
+ cont.resume(false)
+ }
+ } catch (_: Throwable) {
+ r.unregister(context)
+ cont.resume(false)
+ }
+ cont.invokeOnCancellation { r.unregister(context) }
+ }
+ }
+ }
+
+ private fun syncFailed(
+ deviceId: DeviceId,
+ reason: String,
+ security: Boolean = false,
+ ): Event {
+ telemetry.emit(
+ TelemetryEvent(
+ "sync_failed",
+ clock.now(),
+ deviceId.raw,
+ mapOf(
+ "error_category" to if (security) "security" else "transport",
+ "error_message" to reason,
+ ),
+ ),
+ )
+ val err = if (security) DomainError.Unexpected(reason) else DomainError.Transport(reason)
+ return com.hailie.domain.events.SyncFailed(deviceId, clock.now(), err)
+ }
+
+ private fun runBlockingSerial(
+ op: String,
+ deviceId: DeviceId,
+ block: suspend () -> T,
+ ): T =
+ runBlocking {
+ print(op)
+ print(deviceId)
+ gate.withLock { block() }
+ }
+}
diff --git a/adapters-android/src/main/java/com/hailie/adapters/android/ble/BleConfig.kt b/adapters-android/src/main/java/com/hailie/adapters/android/ble/BleConfig.kt
new file mode 100644
index 0000000..9244690
--- /dev/null
+++ b/adapters-android/src/main/java/com/hailie/adapters/android/ble/BleConfig.kt
@@ -0,0 +1,15 @@
+package com.hailie.adapters.android.ble
+
+import java.util.UUID
+
+data class BleConfig(
+ val serviceUuid: UUID,
+ val countCharacteristicUuid: UUID,
+ val pageCharacteristicUuid: UUID,
+ val ackCharacteristicUuid: UUID,
+ val connectTimeoutMs: Long = 10_000,
+ val bondTimeoutMs: Long = 20_000,
+ val readCountTimeoutMs: Long = 5_000,
+ val readPageTimeoutMs: Long = 8_000,
+ val ackTimeoutMs: Long = 5_000,
+)
diff --git a/adapters-android/src/main/java/com/hailie/adapters/android/ble/BluetoothPermission.kt b/adapters-android/src/main/java/com/hailie/adapters/android/ble/BluetoothPermission.kt
new file mode 100644
index 0000000..70a60bd
--- /dev/null
+++ b/adapters-android/src/main/java/com/hailie/adapters/android/ble/BluetoothPermission.kt
@@ -0,0 +1,30 @@
+package com.hailie.adapters.android.ble
+
+import android.Manifest
+import android.content.Context
+import android.content.pm.PackageManager
+import android.os.Build
+import androidx.core.content.ContextCompat
+
+internal object BluetoothPermission {
+ fun hasConnectPermission(ctx: Context): Boolean {
+ return if (Build.VERSION.SDK_INT >= 31) {
+ ContextCompat
+ .checkSelfPermission(
+ ctx,
+ Manifest.permission.BLUETOOTH_CONNECT,
+ ) == PackageManager.PERMISSION_GRANTED
+ } else {
+ // On older Android, BLUETOOTH/ADMIN are normal permissions (granted at install)
+ true
+ }
+ }
+
+ fun requireConnect(ctx: Context) {
+ if (!hasConnectPermission(ctx)) {
+ throw SecurityException(
+ "Missing BLUETOOTH_CONNECT permission (Android 12+). Request it at runtime before using BLE.",
+ )
+ }
+ }
+}
diff --git a/adapters-android/src/main/java/com/hailie/adapters/android/ble/BondStateReceiver.kt b/adapters-android/src/main/java/com/hailie/adapters/android/ble/BondStateReceiver.kt
new file mode 100644
index 0000000..ce3efc3
--- /dev/null
+++ b/adapters-android/src/main/java/com/hailie/adapters/android/ble/BondStateReceiver.kt
@@ -0,0 +1,35 @@
+package com.hailie.adapters.android.ble
+
+import android.bluetooth.BluetoothDevice
+import android.content.BroadcastReceiver
+import android.content.Context
+import android.content.Intent
+import android.content.IntentFilter
+
+internal class BondStateReceiver(
+ private val targetAddress: String,
+ private val callback: (ok: Boolean, ex: Exception?) -> Unit,
+) : BroadcastReceiver() {
+ fun register(context: Context) {
+ context.registerReceiver(this, IntentFilter(BluetoothDevice.ACTION_BOND_STATE_CHANGED))
+ }
+
+ fun unregister(context: Context) {
+ try {
+ context.unregisterReceiver(this)
+ } catch (_: Throwable) {
+ }
+ }
+
+ override fun onReceive(
+ context: Context,
+ intent: Intent,
+ ) {
+ val dev: BluetoothDevice? = intent.getParcelableExtra(BluetoothDevice.EXTRA_DEVICE)
+ if (dev?.address?.equals(targetAddress, ignoreCase = true) != true) return
+ when (intent.getIntExtra(BluetoothDevice.EXTRA_BOND_STATE, BluetoothDevice.BOND_NONE)) {
+ BluetoothDevice.BOND_BONDED -> callback(true, null)
+ BluetoothDevice.BOND_NONE -> callback(false, null)
+ }
+ }
+}
diff --git a/adapters-android/src/main/java/com/hailie/adapters/android/ble/ConnectionSession.kt b/adapters-android/src/main/java/com/hailie/adapters/android/ble/ConnectionSession.kt
new file mode 100644
index 0000000..9289e5a
--- /dev/null
+++ b/adapters-android/src/main/java/com/hailie/adapters/android/ble/ConnectionSession.kt
@@ -0,0 +1,210 @@
+package com.hailie.adapters.android.ble
+
+import android.Manifest
+import android.bluetooth.BluetoothDevice
+import android.bluetooth.BluetoothGatt
+import android.bluetooth.BluetoothGattCallback
+import android.bluetooth.BluetoothGattCharacteristic
+import android.bluetooth.BluetoothProfile
+import android.content.Context
+import android.os.Build
+import androidx.annotation.RequiresPermission
+import com.hailie.domain.DomainError
+import kotlinx.coroutines.CompletableDeferred
+import kotlinx.coroutines.sync.Mutex
+import kotlinx.coroutines.sync.withLock
+import java.util.UUID
+
+internal class ConnectionSession(
+ private val context: Context,
+ private val device: BluetoothDevice,
+) {
+ private val stateLock = Mutex()
+ private var gatt: BluetoothGatt? = null
+ private val callback = RoutingCallback()
+
+ private class Pending(val uuid: UUID, val cont: CompletableDeferred)
+
+ private val pendingServiceDiscovery = CompletableDeferred()
+ private var pendingRead: Pending? = null
+ private var pendingWrite: Pending? = null
+
+ @RequiresPermission(Manifest.permission.BLUETOOTH_CONNECT)
+ @Suppress("UseCheckOrError")
+ suspend fun connect(): BluetoothGatt {
+ return stateLock.withLock {
+ if (gatt == null) {
+ if (!BluetoothPermission.hasConnectPermission(context)) {
+ pendingServiceDiscovery
+ .completeExceptionally(IllegalStateException("missing_bluetooth_connect_permission"))
+ throw IllegalStateException("missing_bluetooth_connect_permission")
+ }
+ val created =
+ if (Build.VERSION.SDK_INT >= 23) {
+ device.connectGatt(context, false, callback, BluetoothDevice.TRANSPORT_LE)
+ } else {
+ @Suppress("DEPRECATION")
+ device.connectGatt(context, false, callback)
+ }
+ gatt = created
+ pendingServiceDiscovery.await()
+ }
+ requireNotNull(gatt) { "GATT not available after connect" }
+ }
+ }
+
+ @RequiresPermission(Manifest.permission.BLUETOOTH_CONNECT)
+ @Suppress("UseCheckOrError")
+ suspend fun read(chrUuid: UUID): ByteArray {
+ if (!BluetoothPermission.hasConnectPermission(context)) {
+ throw IllegalStateException("missing_bluetooth_connect_permission")
+ }
+ val g = requireNotNull(gatt) { "Not connected" }
+ val chr = findCharacteristicOrError(g, chrUuid)
+ val p = Pending(chrUuid, CompletableDeferred())
+ stateLock.withLock {
+ pendingRead = p
+ check(g.readCharacteristic(chr)) { "readCharacteristic=false" }
+ }
+ return p.cont.await()
+ }
+
+ @RequiresPermission(Manifest.permission.BLUETOOTH_CONNECT)
+ @Suppress("UseCheckOrError")
+ suspend fun write(
+ chrUuid: UUID,
+ value: ByteArray,
+ ) {
+ if (!BluetoothPermission.hasConnectPermission(context)) {
+ throw IllegalStateException("missing_bluetooth_connect_permission")
+ }
+ val g = requireNotNull(gatt) { "Not connected" }
+ val chr = findCharacteristicOrError(g, chrUuid)
+ chr.value = value
+ val p = Pending(chrUuid, CompletableDeferred())
+ stateLock.withLock {
+ pendingWrite = p
+ check(g.writeCharacteristic(chr)) { "writeCharacteristic=false" }
+ }
+ p.cont.await()
+ }
+
+ @RequiresPermission(Manifest.permission.BLUETOOTH_CONNECT)
+ fun close() {
+ try {
+ gatt?.close()
+ } catch (_: Throwable) {
+ }
+ gatt = null
+ }
+
+ private fun findCharacteristicOrError(
+ gatt: BluetoothGatt,
+ uuid: UUID,
+ ): BluetoothGattCharacteristic {
+ val chr =
+ gatt
+ .services.asSequence().flatMap { it.characteristics.asSequence() }.firstOrNull { it.uuid == uuid }
+ return chr ?: error("Characteristic not found: $uuid")
+ }
+
+ private inner class RoutingCallback : BluetoothGattCallback() {
+ @RequiresPermission(Manifest.permission.BLUETOOTH_CONNECT)
+ override fun onConnectionStateChange(
+ g: BluetoothGatt,
+ status: Int,
+ newState: Int,
+ ) {
+ if (status == BluetoothGatt.GATT_SUCCESS && newState == BluetoothProfile.STATE_CONNECTED) {
+ val started = g.discoverServices()
+ if (!started) {
+ pendingServiceDiscovery
+ .completeExceptionally(IllegalStateException("discoverServices=false"))
+ }
+ return
+ }
+ if (newState == BluetoothProfile.STATE_DISCONNECTED) {
+ val ex = IllegalStateException("disconnected (gatt=$status)")
+ if (!pendingServiceDiscovery.isCompleted) {
+ pendingServiceDiscovery
+ .completeExceptionally(ex)
+ }
+ pendingRead?.cont?.completeExceptionally(ex)
+ pendingWrite?.cont?.completeExceptionally(ex)
+ close()
+ return
+ }
+ val ex = IllegalStateException(domainMessage(mapGatt(status, "connect")))
+ if (!pendingServiceDiscovery.isCompleted) {
+ pendingServiceDiscovery
+ .completeExceptionally(ex)
+ }
+ pendingRead?.cont?.completeExceptionally(ex)
+ pendingWrite?.cont?.completeExceptionally(ex)
+ close()
+ }
+
+ override fun onServicesDiscovered(
+ gatt: BluetoothGatt,
+ status: Int,
+ ) {
+ if (status == BluetoothGatt.GATT_SUCCESS) {
+ pendingServiceDiscovery.complete(Unit)
+ } else {
+ pendingServiceDiscovery.completeExceptionally(
+ IllegalStateException(domainMessage(mapGatt(status, "services"))),
+ )
+ }
+ }
+
+ override fun onCharacteristicRead(
+ gatt: BluetoothGatt,
+ characteristic: BluetoothGattCharacteristic,
+ status: Int,
+ ) {
+ val p = pendingRead ?: return
+ if (p.uuid != characteristic.uuid) return
+ if (status == BluetoothGatt.GATT_SUCCESS) {
+ pendingRead = null
+ p.cont.complete(characteristic.value ?: ByteArray(0))
+ } else {
+ pendingRead = null
+ p.cont.completeExceptionally(IllegalStateException(domainMessage(mapGatt(status, "char_read"))))
+ }
+ }
+
+ override fun onCharacteristicWrite(
+ gatt: BluetoothGatt,
+ characteristic: BluetoothGattCharacteristic,
+ status: Int,
+ ) {
+ val p = pendingWrite ?: return
+ if (p.uuid != characteristic.uuid) return
+ if (status == BluetoothGatt.GATT_SUCCESS) {
+ pendingWrite = null
+ p.cont.complete(Unit)
+ } else {
+ pendingWrite = null
+ p.cont.completeExceptionally(IllegalStateException(domainMessage(mapGatt(status, "char_write"))))
+ }
+ }
+ }
+
+ private fun mapGatt(
+ code: Int,
+ op: String,
+ ): DomainError =
+ when (code) {
+ 8, 19 -> DomainError.Transport("$op:gatt=$code")
+ 133 -> DomainError.Unexpected("$op:gatt=$code")
+ else -> DomainError.Protocol("$op:gatt=$code")
+ }
+
+ private fun domainMessage(err: DomainError): String =
+ when (err) {
+ is DomainError.Transport -> "transport ${err.message}"
+ is DomainError.Unexpected -> "unavailable ${err.message}"
+ is DomainError.Protocol -> "protocol ${err.message}"
+ else -> err.toString()
+ }
+}
diff --git a/adapters-android/src/main/java/com/hailie/adapters/android/ble/DeviceResolver.kt b/adapters-android/src/main/java/com/hailie/adapters/android/ble/DeviceResolver.kt
new file mode 100644
index 0000000..148405a
--- /dev/null
+++ b/adapters-android/src/main/java/com/hailie/adapters/android/ble/DeviceResolver.kt
@@ -0,0 +1,8 @@
+package com.hailie.adapters.android.ble
+
+import android.bluetooth.BluetoothDevice
+import com.hailie.domain.DeviceId
+
+fun interface DeviceResolver {
+ fun resolve(deviceId: DeviceId): BluetoothDevice
+}
diff --git a/adapters-android/src/main/java/com/hailie/adapters/android/di/AndroidAdaptersModule.kt b/adapters-android/src/main/java/com/hailie/adapters/android/di/AndroidAdaptersModule.kt
new file mode 100644
index 0000000..1acc238
--- /dev/null
+++ b/adapters-android/src/main/java/com/hailie/adapters/android/di/AndroidAdaptersModule.kt
@@ -0,0 +1,55 @@
+package com.hailie.adapters.android.di
+
+import android.bluetooth.BluetoothManager
+import android.content.Context
+import com.hailie.adapters.android.ble.AndroidBleAdapter
+import com.hailie.adapters.android.ble.BleConfig
+import com.hailie.adapters.android.ble.DeviceResolver
+import com.hailie.adapters.android.store.AndroidStateStore
+import com.hailie.adapters.android.telemetry.AndroidStructuredLogger
+import com.hailie.adapters.android.telemetry.AndroidTelemetryPort
+import com.hailie.adapters.android.telemetry.StructuredLogger
+import com.hailie.domain.DeviceId
+import com.hailie.runtime.ports.BlePort
+import com.hailie.runtime.ports.StateStorePort
+import com.hailie.runtime.ports.TelemetryPort
+import kotlinx.serialization.json.Json
+import org.koin.android.ext.koin.androidContext
+import org.koin.dsl.module
+import java.util.UUID
+
+val androidAdaptersModule =
+ module {
+
+ // Logger + Telemetry
+ single {
+ AndroidStructuredLogger(
+ context = androidContext(),
+ sessionId = java.util.UUID.randomUUID().toString(),
+ )
+ }
+ single { AndroidTelemetryPort(get()) }
+
+ // State Store
+ single { AndroidStateStore(androidContext(), Json) }
+
+ // BLE Config — inject your real UUIDs here
+ single {
+ BleConfig(
+ serviceUuid = UUID.fromString("0000xxxx-0000-1000-8000-00805f9b34fb"),
+ countCharacteristicUuid = UUID.fromString("0000yyyy-0000-1000-8000-00805f9b34fb"),
+ pageCharacteristicUuid = UUID.fromString("0000zzzz-0000-1000-8000-00805f9b34fb"),
+ ackCharacteristicUuid = UUID.fromString("0000aaaa-0000-1000-8000-00805f9b34fb"),
+ )
+ }
+
+ // Device resolver (MAC-based example)
+ single {
+ val ctx: Context = androidContext()
+ val adapter = (ctx.getSystemService(Context.BLUETOOTH_SERVICE) as BluetoothManager).adapter
+ DeviceResolver { deviceId: DeviceId -> adapter.getRemoteDevice(deviceId.raw) }
+ }
+
+ // BLE Adapter
+ single { AndroidBleAdapter(androidContext(), get(), get(), get(), get()) }
+ }
diff --git a/adapters-android/src/main/java/com/hailie/adapters/android/store/AndroidStateStore.kt b/adapters-android/src/main/java/com/hailie/adapters/android/store/AndroidStateStore.kt
new file mode 100644
index 0000000..49bc7a6
--- /dev/null
+++ b/adapters-android/src/main/java/com/hailie/adapters/android/store/AndroidStateStore.kt
@@ -0,0 +1,48 @@
+package com.hailie.adapters.android.store
+
+import android.content.Context
+import androidx.security.crypto.EncryptedSharedPreferences
+import androidx.security.crypto.MasterKeys
+import com.hailie.domain.DeviceId
+import com.hailie.runtime.ports.StateStorePort
+import com.hailie.runtime.ports.SyncSnapshot
+import kotlinx.serialization.decodeFromString
+import kotlinx.serialization.encodeToString
+import kotlinx.serialization.json.Json
+
+class AndroidStateStore(
+ context: Context,
+ private val json: Json =
+ Json {
+ encodeDefaults = true
+ ignoreUnknownKeys = true
+ },
+) : StateStorePort {
+ private val masterKey = MasterKeys.getOrCreate(MasterKeys.AES256_GCM_SPEC)
+ private val prefs =
+ EncryptedSharedPreferences.create(
+ "ble_sync_store",
+ masterKey,
+ context,
+ EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
+ EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM,
+ )
+
+ private fun key(deviceId: DeviceId) = "snap:${deviceId.raw}"
+
+ override fun write(snap: SyncSnapshot) {
+ try {
+ prefs.edit().putString(key(snap.deviceId), json.encodeToString(snap)).commit()
+ } catch (_: Throwable) {
+ // Corruption fallback
+ prefs.edit().remove(key(snap.deviceId)).commit()
+ }
+ }
+
+ override fun read(deviceId: DeviceId): SyncSnapshot? =
+ try {
+ prefs.getString(key(deviceId), null)?.let { json.decodeFromString(it) }
+ } catch (_: Throwable) {
+ null
+ }
+}
diff --git a/adapters-android/src/main/java/com/hailie/adapters/android/telemetry/AndroidStructuredLogger.kt b/adapters-android/src/main/java/com/hailie/adapters/android/telemetry/AndroidStructuredLogger.kt
new file mode 100644
index 0000000..e39c0d0
--- /dev/null
+++ b/adapters-android/src/main/java/com/hailie/adapters/android/telemetry/AndroidStructuredLogger.kt
@@ -0,0 +1,60 @@
+package com.hailie.adapters.android.telemetry
+
+import android.content.Context
+import android.util.Log
+import kotlinx.serialization.encodeToString
+import kotlinx.serialization.json.Json
+import java.io.File
+
+class AndroidStructuredLogger(
+ private val context: Context,
+ private val sessionId: String,
+ private val json: Json = Json { encodeDefaults = true },
+) : StructuredLogger {
+ private val tag = "BleSync"
+ private val file = File(context.filesDir, "telemetry.log")
+ private val maxBytes = 1_000_000L
+
+ override fun info(fields: Map) = write("INFO", fields)
+
+ override fun warn(fields: Map) = write("WARN", fields)
+
+ override fun error(
+ fields: Map,
+ throwable: Throwable?,
+ ) = write("ERROR", fields + ("error_stack" to (throwable?.stackTraceToString())))
+
+ override fun flush() { /* appendText writes directly; no-op */ }
+
+ private fun write(
+ level: String,
+ fields: Map,
+ ) {
+ val payload =
+ mapOf(
+ "level" to level,
+ "ts" to System.currentTimeMillis(),
+ "session_id" to sessionId,
+ ) + fields
+ val line = json.encodeToString(payload)
+
+ val prio =
+ when (level) {
+ "INFO" -> Log.INFO
+ "WARN" -> Log.WARN
+ else -> Log.ERROR
+ }
+ Log.println(prio, tag, line)
+
+ rollIfNeeded()
+ file.appendText(line + "\n")
+ }
+
+ private fun rollIfNeeded() {
+ if (file.length() > maxBytes) {
+ val bak = File(file.parentFile, "telemetry.${System.currentTimeMillis()}.log")
+ file.copyTo(bak, overwrite = true)
+ file.writeText("")
+ }
+ }
+}
diff --git a/adapters-android/src/main/java/com/hailie/adapters/android/telemetry/AndroidTelemetryPort.kt b/adapters-android/src/main/java/com/hailie/adapters/android/telemetry/AndroidTelemetryPort.kt
new file mode 100644
index 0000000..e0ba164
--- /dev/null
+++ b/adapters-android/src/main/java/com/hailie/adapters/android/telemetry/AndroidTelemetryPort.kt
@@ -0,0 +1,28 @@
+package com.hailie.adapters.android.telemetry
+
+import com.hailie.runtime.ports.TelemetryEvent
+import com.hailie.runtime.ports.TelemetryPort
+
+class AndroidTelemetryPort(
+ private val logger: StructuredLogger,
+) : TelemetryPort {
+ override fun emit(ev: TelemetryEvent) {
+ val fields =
+ buildMap {
+ put("device_id", ev.deviceId)
+ put("event", ev.name)
+ ev.data?.let { putAll(it) }
+ }
+ when (ev.name) {
+ "snapshot_saved", "snapshot_restored", "retry_scheduled" -> {
+ logger.info(fields)
+ logger.flush()
+ }
+ "sync_failed" -> {
+ logger.warn(fields)
+ logger.flush()
+ }
+ else -> logger.info(fields)
+ }
+ }
+}
diff --git a/adapters-android/src/main/java/com/hailie/adapters/android/telemetry/StructuredLogger.kt b/adapters-android/src/main/java/com/hailie/adapters/android/telemetry/StructuredLogger.kt
new file mode 100644
index 0000000..7ccd034
--- /dev/null
+++ b/adapters-android/src/main/java/com/hailie/adapters/android/telemetry/StructuredLogger.kt
@@ -0,0 +1,14 @@
+package com.hailie.adapters.android.telemetry
+
+interface StructuredLogger {
+ fun info(fields: Map)
+
+ fun warn(fields: Map)
+
+ fun error(
+ fields: Map,
+ throwable: Throwable? = null,
+ )
+
+ fun flush()
+}
diff --git a/adapters-android/src/test/java/com/hailie/adapters/android/ble/GattErrorMappingTest.kt b/adapters-android/src/test/java/com/hailie/adapters/android/ble/GattErrorMappingTest.kt
new file mode 100644
index 0000000..b85389f
--- /dev/null
+++ b/adapters-android/src/test/java/com/hailie/adapters/android/ble/GattErrorMappingTest.kt
@@ -0,0 +1,14 @@
+package com.hailie.adapters.android.ble
+
+import org.junit.Test
+import kotlin.test.assertTrue
+
+class GattErrorMappingTest {
+ @Test
+ fun gatt_messages_contain_code() {
+ val ex133 = IllegalStateException("unavailable connect:gatt=133")
+ val ex8 = IllegalStateException("transport connect:gatt=8")
+ assertTrue(ex133.message!!.contains("gatt=133"))
+ assertTrue(ex8.message!!.contains("gatt=8"))
+ }
+}
diff --git a/adapters-android/src/test/java/com/hailie/adapters/android/telemetry/TelemetryLoggingTest.kt b/adapters-android/src/test/java/com/hailie/adapters/android/telemetry/TelemetryLoggingTest.kt
new file mode 100644
index 0000000..fa4480b
--- /dev/null
+++ b/adapters-android/src/test/java/com/hailie/adapters/android/telemetry/TelemetryLoggingTest.kt
@@ -0,0 +1,80 @@
+package com.hailie.adapters.android.telemetry
+
+import com.hailie.domain.TimestampMs
+import com.hailie.runtime.ports.TelemetryEvent
+import org.junit.Test
+import kotlin.test.assertEquals
+import kotlin.test.assertTrue
+
+private class FakeLogger : StructuredLogger {
+ val lines = mutableListOf>>()
+
+ override fun info(fields: Map) {
+ lines += "INFO" to fields
+ }
+
+ override fun warn(fields: Map) {
+ lines += "WARN" to fields
+ }
+
+ override fun error(
+ fields: Map,
+ throwable: Throwable?,
+ ) {
+ lines += "ERROR" to (fields + ("ex" to throwable?.message))
+ }
+
+ override fun flush() {
+ // FLUSH
+ }
+}
+
+class TelemetryLoggingTest {
+ @Test
+ fun snapshot_events_are_info_and_flushed() {
+ val fake = FakeLogger()
+ val t = AndroidTelemetryPort(fake)
+
+ t.emit(
+ TelemetryEvent(
+ name = "snapshot_saved",
+ at = TimestampMs(1L),
+ deviceId = "dev1",
+ data = mapOf("acked" to "50"),
+ ),
+ )
+ t.emit(
+ TelemetryEvent(
+ name = "snapshot_restored",
+ at = TimestampMs(2L),
+ deviceId = "dev1",
+ data = emptyMap(),
+ ),
+ )
+
+ assertEquals(2, fake.lines.size)
+ assertEquals("INFO", fake.lines[0].first)
+ assertTrue(fake.lines[0].second["device_id"] == "dev1")
+ assertEquals("INFO", fake.lines[1].first)
+ assertTrue(fake.lines[1].second["event"] == "snapshot_restored")
+ }
+
+ @Test
+ fun sync_failed_is_warn() {
+ val fake = FakeLogger()
+ val t = AndroidTelemetryPort(fake)
+
+ t.emit(
+ TelemetryEvent(
+ name = "sync_failed",
+ at = TimestampMs(3L),
+ deviceId = "dev2",
+ data = mapOf("error" to "x"),
+ ),
+ )
+
+ assertEquals(1, fake.lines.size)
+ assertEquals("WARN", fake.lines[0].first)
+ assertTrue(fake.lines[0].second["event"] == "sync_failed")
+ }
+}