commit 97aaa6d454bbdb9e50e7b9d72d9adeb245ebe5cb Author: km2023 Date: Fri Apr 10 16:33:05 2026 +0800 Add Android BLE SDK design spec 1:1 feature parity with iOS DuooomiBleSDK, pure Kotlin, zero third-party deps. Co-Authored-By: Claude Opus 4.6 diff --git a/docs/superpowers/specs/2026-04-10-android-ble-sdk-design.md b/docs/superpowers/specs/2026-04-10-android-ble-sdk-design.md new file mode 100644 index 0000000..13d82c4 --- /dev/null +++ b/docs/superpowers/specs/2026-04-10-android-ble-sdk-design.md @@ -0,0 +1,248 @@ +# DuooomiBleSDK Android — Design Spec + +## Overview + +Pure Kotlin Android BLE SDK, zero third-party dependencies. 1:1 feature parity with the iOS `DuooomiBleSDK`. + +## Project Structure + +``` +duooomi-android-sdk/ +├── build.gradle.kts # Root build +├── settings.gradle.kts +├── gradle.properties +├── gradle/ +│ └── wrapper/ +├── sdk/ +│ ├── build.gradle.kts # Android Library (AAR) +│ └── src/main/ +│ ├── AndroidManifest.xml +│ └── kotlin/com/duooomi/ble/ +│ ├── DuooomiBleSDK.kt +│ ├── DuooomiBleConfig.kt +│ ├── core/ +│ │ ├── BleClient.kt +│ │ └── BleTypes.kt +│ ├── protocol/ +│ │ ├── ProtocolConstants.kt +│ │ ├── ProtocolFrame.kt +│ │ └── ProtocolManager.kt +│ ├── services/ +│ │ ├── BleProtocolService.kt +│ │ ├── DeviceInfoService.kt +│ │ ├── FileTransferService.kt +│ │ ├── AniConverter.kt +│ │ └── FirmwareService.kt +│ ├── models/ +│ │ ├── BindingResponse.kt +│ │ ├── DeleteFileResponse.kt +│ │ ├── DeviceInfo.kt +│ │ ├── PrepareTransferResponse.kt +│ │ ├── UnbindResponse.kt +│ │ ├── VersionInfo.kt +│ │ └── FirmwareInfo.kt +│ └── utils/ +│ └── BleLogger.kt +└── demo/ # Demo app (future) + ├── build.gradle.kts + └── src/main/... +``` + +## Technology Mapping (iOS → Android) + +| iOS | Android/Kotlin | +|-----|----------------| +| `CoreBluetooth` / `CBCentralManager` | `BluetoothLeScanner` + `BluetoothGatt` | +| `CBPeripheral` | `BluetoothDevice` / `BluetoothGatt` | +| `AsyncStream` | `Flow` / `callbackFlow` | +| `@Published` | `MutableStateFlow` (internal) / `StateFlow` (public) | +| `CheckedContinuation` | `suspendCancellableCoroutine` | +| `ObservableObject` | Direct `StateFlow` properties | +| `@MainActor` | `Dispatchers.Main` | +| `Data` | `ByteArray` | +| `URLSession` | `HttpURLConnection` (zero deps) | +| `Task.sleep(nanoseconds:)` | `delay()` | +| `DispatchQueue(serial)` | `newSingleThreadContext` / `Mutex` | +| `DispatchWorkItem` + `asyncAfter` | `Job` + `delay` | + +## Layer Design + +### 1. DuooomiBleConfig + +```kotlin +data class DuooomiBleConfig( + val apiHost: String = "https://api.mixvideo.bowong.cc", + val apiKey: String, + val cdnHost: String = "https://cdn.bowong.cc/", + val firmwareIdentifier: String = "duomi", + val firmwareStatus: String = "DRAFT" +) +``` + +### 2. BleTypes (core/BleTypes.kt) + +```kotlin +enum class ConnectionState { IDLE, SCANNING, CONNECTING, CONNECTED, DISCONNECTING, DISCONNECTED } + +data class DiscoveredDevice(val id: String, val name: String?, val rssi: Int) + +sealed class DuooomiBleError(message: String) : Exception(message) { + class BluetoothNotPoweredOn(state: String) : DuooomiBleError("Bluetooth not powered on: $state") + object NotConnected : DuooomiBleError("No device connected") + class Timeout(command: CommandType) : DuooomiBleError("Timeout: $command") + class ConnectionFailed(reason: String) : DuooomiBleError("Connection failed: $reason") + object ServiceNotFound : DuooomiBleError("BLE service not found") + object CharacteristicNotFound : DuooomiBleError("Characteristic not found") + class BindingFailed(reason: String) : DuooomiBleError("Binding failed: $reason") + object UnbindFailed : DuooomiBleError("Unbind failed") + class DeleteFileFailed(status: Int) : DuooomiBleError("Delete failed: $status") + class PrepareTransferFailed(status: String) : DuooomiBleError("Prepare failed: $status") + class TransferFailed(reason: String) : DuooomiBleError("Transfer failed: $reason") + object InvalidFrame : DuooomiBleError("Invalid protocol frame") + object PermissionDenied : DuooomiBleError("Bluetooth permission denied") +} +``` + +### 3. ProtocolConstants (protocol/ProtocolConstants.kt) + +- BLE UUIDs: same values as iOS (000002c4/c5/c6/c1) +- FrameHead: APP_TO_DEVICE = 0xC7, DEVICE_TO_APP = 0xB0 +- FrameConstants: maxDataSize = 496, headerSize = 8, footerSize = 1, frameIntervalMs = 35 +- CommandType enum: same values as iOS (0x02..0x14) + +### 4. ProtocolFrame + ProtocolManager + +- Frame format identical to iOS: [head:1][type:1][subpageTotal:2][curPage:2][dataLen:2][data:N][checksum:1] +- Checksum: `(~sum + 1) & 0xFF` +- `createFrames()` / `parseFrame()` — same logic, ByteArray operations + +### 5. BleClient (core/BleClient.kt) + +Android BLE wrapper using coroutines: + +- `scan()` → `callbackFlow` wrapping `ScanCallback` +- `connect(deviceId)` → `suspend fun` using `suspendCancellableCoroutine` + `BluetoothGattCallback` +- `disconnect()` → `suspend fun` +- `writeWithoutResponse(data)` → direct `BluetoothGatt.writeCharacteristic` +- `notifications()` → `callbackFlow` from `onCharacteristicChanged` +- `retrieveConnectedDevices()` → `BluetoothManager.getConnectedDevices` + +Key difference from iOS: Android needs `Context` for `BluetoothManager`. Pass via `DuooomiBleSDK` constructor. + +### 6. BleProtocolService + +Same as iOS: +- `startListening()` / `stopListening()` +- `send(type, data, onProgress)` — frame split + write with 35ms delay +- `sendJSON(type, payload)` — JSON encode + send +- Fragment reassembly with `FragmentSession` map +- `incomingMessages` as `SharedFlow>` + +### 7. DeviceInfoService + +Same command payloads as iOS: +- `getDeviceInfo()`, `getDeviceVersion()`, `bindDevice(userId)`, `unbindDevice(userId)`, `deleteFile(key)`, `prepareTransfer(key, size)` + +### 8. FileTransferService + +- `transferFile(fileUri, commandType, onProgress)` +- `loadFileData(uri)` — supports `file://` (local) and `https://` (HttpURLConnection) + +### 9. AniConverter + +- POST to `{apiHost}/api/auth/loomart/file/convert-to-ani` +- x-api-key header +- Body: `{ videoUrl, width: 360, height: 360, fps: "24" }` +- Retry on connection lost (once) +- Uses `HttpURLConnection` + +### 10. FirmwareService + +- GET `{apiHost}/api/auth/loomart/firmware/latest-published?identifier=X&status=Y` +- x-api-key header +- Response: `{ success: bool, data: FirmwareInfo? }` + +### 11. Models + +All using `org.json.JSONObject` for parsing (Android built-in, zero deps): + +| Model | Fields | +|-------|--------| +| `BindingResponse` | type: Int, sn: String, success: Int, contents: List | +| `DeleteFileResponse` | type: Int, success: Int | +| `DeviceInfo` | allspace: Long, freespace: Long, name: String, size: String, brand: String, powerlevel: Int | +| `PrepareTransferResponse` | type: Int, key: String, status: String | +| `UnbindResponse` | success: Int | +| `VersionInfo` | version: String, type: String | +| `FirmwareInfo` | version: String, fileUrl: String, description: String?, fileSize: String?, fileMd5: String?, identifier: String?, status: String? | + +### 12. DuooomiBleSDK — Public API + +```kotlin +class DuooomiBleSDK(context: Context, config: DuooomiBleConfig) { + + // Observable state (StateFlow) + val btState: StateFlow + val discoveredDevices: StateFlow> + val connectedDevice: StateFlow + val deviceInfo: StateFlow + val version: StateFlow + val isActivated: StateFlow + val transferProgress: StateFlow + val error: StateFlow + + // Scanning + fun scan() + fun stopScan() + + // Connection + suspend fun connect(deviceId: String): DiscoveredDevice + suspend fun disconnect() + fun getConnectedDevices(): List + + // Device commands + suspend fun getDeviceInfo(): DeviceInfo + suspend fun getVersion(): VersionInfo + suspend fun bind(userId: String): BindingResponse + suspend fun unbind(userId: String): UnbindResponse + suspend fun deleteFile(key: String): DeleteFileResponse + suspend fun prepareTransfer(key: String, size: Int): PrepareTransferResponse + + // File transfer + suspend fun transferFile(fileUri: String, commandType: CommandType = CommandType.TRANSFER_ANI_VIDEO) + suspend fun transferMedia(fileUrl: String) + + // Firmware + suspend fun fetchLatestFirmware(identifier: String? = null, status: String? = null): FirmwareInfo? + suspend fun upgradeFirmware(fileUrl: String) +} +``` + +## Android-specific Considerations + +1. **Context**: `DuooomiBleSDK` constructor requires `Context` (use `applicationContext` internally) +2. **Permissions**: Manifest declares `BLUETOOTH_SCAN`, `BLUETOOTH_CONNECT`, `ACCESS_FINE_LOCATION`. Runtime permission check is caller's responsibility. +3. **Threading**: All BLE operations on a dedicated coroutine dispatcher. Public API methods are `suspend` functions (no `@MainActor` equivalent needed — caller controls dispatcher). +4. **JSON**: Use `org.json.JSONObject` (Android built-in) instead of third-party JSON libraries. +5. **MTU**: Request MTU 512 after connection to maximize throughput (iOS handles this automatically). +6. **Write queue**: Android BLE requires waiting for `onCharacteristicWrite` callback before next write. Use a serial channel/mutex. + +## Build Configuration + +- `minSdk`: 26 (Android 8.0+) +- `targetSdk`: 34 +- `compileSdk`: 34 +- Kotlin: 1.9+ +- Gradle: 8.x with KTS +- Zero external dependencies (only `kotlinx-coroutines-android` from Kotlin stdlib) + +Note: `kotlinx-coroutines-android` is considered part of the Kotlin ecosystem, not a "third-party" dependency. It provides `Dispatchers.Main` which is essential for Android. + +## Permissions in AndroidManifest.xml + +```xml + + + + +```