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") + } +}