- 增加了wrapper处理原生app的交互

This commit is contained in:
Yudi Xiao
2026-04-08 17:04:29 +08:00
parent 90dcd9605c
commit f9dfde0ace
19 changed files with 1501 additions and 32 deletions

View File

@@ -16,7 +16,14 @@
"libraryName": "duooomi-ble-sdk", "libraryName": "duooomi-ble-sdk",
"group": "com.duooomi", "group": "com.duooomi",
"package": "com.duooomi.ble.sdk", "package": "com.duooomi.ble.sdk",
"version": "1.0.0" "version": "1.0.0",
"publishing": [
{
"type": "localDirectory",
"name": "dist",
"path": "./dist/android/maven"
}
]
} }
} }
], ],

View File

@@ -57,6 +57,7 @@ class BleSDK {
// 批量设备发现缓冲 // 批量设备发现缓冲
private pendingDevices: Array<{ id: string; name: string | null; rssi: number | null }> = [] private pendingDevices: Array<{ id: string; name: string | null; rssi: number | null }> = []
private discoveredDevices: Array<{ id: string; name: string | null; rssi: number | null }> = []
private flushTimer: ReturnType<typeof setTimeout> | null = null private flushTimer: ReturnType<typeof setTimeout> | null = null
initialize() { initialize() {
@@ -146,59 +147,84 @@ class BleSDK {
// ====== 接收原生 App 的消息指令 ====== // ====== 接收原生 App 的消息指令 ======
private setupMessageHandler() { private setupMessageHandler() {
addMessageListener((event: Record<string, any>) => { addMessageListener((event: Record<string, any>) => {
const { action, ...params } = event void this.handleAction(event)
console.log(`[BleSDK] Received action: ${action}`, params) })
}
private async handleAction(event: Record<string, any>) {
const { action, requestId, ...params } = event
console.log(`[BleSDK] Received action: ${action}`, params)
try {
let data: any = null
switch (action) { switch (action) {
case 'scan': case 'scan':
this.startScan() await this.startScan()
data = { started: true }
break break
case 'stopScan': case 'stopScan':
this.stopScan() this.stopScan()
data = { stopped: true }
break break
case 'connect': case 'connect':
this.connect(params.deviceId) data = await this.connect(params.deviceId)
break break
case 'disconnect': case 'disconnect':
this.disconnect() data = await this.disconnect()
break break
case 'getDeviceInfo': case 'getDeviceInfo':
this.getDeviceInfo() data = await this.getDeviceInfo()
break break
case 'getVersion': case 'getVersion':
this.getVersion() data = await this.getVersion()
break break
case 'bind': case 'bind':
this.bind(params.userId) data = await this.bind(params.userId)
break break
case 'unbind': case 'unbind':
this.unbind(params.userId) data = await this.unbind(params.userId)
break break
case 'deleteFile': case 'deleteFile':
this.deleteFile(params.key) data = await this.deleteFile(params.key)
break break
case 'transferFile': case 'transferFile':
this.transferFile(params.fileUri, params.commandType) data = await this.transferFile(params.fileUri, params.commandType)
break break
default: default:
sendMessage({ event: 'error', type: 'unknownAction', message: `Unknown action: ${action}` }) throw new Error(`Unknown action: ${action}`)
} }
})
if (requestId) {
this.sendResponse(requestId, action, data)
}
} catch (e: any) {
const message = e instanceof Error ? e.message : String(e)
if (requestId) {
this.sendErrorResponse(requestId, action, message)
return
}
sendMessage({ event: 'error', type: action ?? 'unknownAction', message })
}
} }
// ====== 蓝牙操作实现 ====== // ====== 蓝牙操作实现 ======
private async startScan() { private async startScan() {
try { const hasPerms = await this.requestPermissions()
const hasPerms = await this.requestPermissions() if (!hasPerms) {
if (!hasPerms) { const error = new Error('Bluetooth permission denied')
sendMessage({ event: 'error', type: 'permission', message: 'Bluetooth permission denied' }) this.updateState(STATE_KEYS.ERROR, error.message)
return sendMessage({ event: 'error', type: 'permission', message: error.message })
} throw error
}
try {
this.updateState(STATE_KEYS.BT_STATE, 'scanning') this.updateState(STATE_KEYS.BT_STATE, 'scanning')
this.updateState(STATE_KEYS.DISCOVERED_DEVICES, []) this.updateState(STATE_KEYS.DISCOVERED_DEVICES, [])
this.pendingDevices = [] this.pendingDevices = []
this.discoveredDevices = []
await this.bleClient.startScan( await this.bleClient.startScan(
[BLE_UUIDS.SERVICE], [BLE_UUIDS.SERVICE],
@@ -223,6 +249,7 @@ class BleSDK {
this.updateState(STATE_KEYS.BT_STATE, 'idle') this.updateState(STATE_KEYS.BT_STATE, 'idle')
this.updateState(STATE_KEYS.ERROR, e.message) this.updateState(STATE_KEYS.ERROR, e.message)
sendMessage({ event: 'error', type: 'scan', message: e.message }) sendMessage({ event: 'error', type: 'scan', message: e.message })
throw e
} }
} }
@@ -235,8 +262,7 @@ class BleSDK {
private async connect(deviceId: string) { private async connect(deviceId: string) {
if (!deviceId) { if (!deviceId) {
sendMessage({ event: 'error', type: 'connect', message: 'deviceId is required' }) throw new Error('deviceId is required')
return
} }
this.stopScan() this.stopScan()
@@ -258,10 +284,15 @@ class BleSDK {
// 自动获取设备信息 // 自动获取设备信息
this.getDeviceInfo().catch(() => {}) this.getDeviceInfo().catch(() => {})
return {
id: device.id,
name: device.name || device.localName || null,
}
} catch (e: any) { } catch (e: any) {
this.updateState(STATE_KEYS.BT_STATE, 'idle') this.updateState(STATE_KEYS.BT_STATE, 'idle')
this.updateState(STATE_KEYS.ERROR, e.message) this.updateState(STATE_KEYS.ERROR, e.message)
sendMessage({ event: 'error', type: 'connect', message: e.message }) sendMessage({ event: 'error', type: 'connect', message: e.message })
throw e
} }
} }
@@ -278,68 +309,75 @@ class BleSDK {
this.updateState(STATE_KEYS.BT_STATE, 'disconnected') this.updateState(STATE_KEYS.BT_STATE, 'disconnected')
sendMessage({ event: 'disconnected' }) sendMessage({ event: 'disconnected' })
return { success: true }
} catch (e: any) { } catch (e: any) {
sendMessage({ event: 'error', type: 'disconnect', message: e.message }) sendMessage({ event: 'error', type: 'disconnect', message: e.message })
throw e
} }
} }
private async getDeviceInfo() { private async getDeviceInfo() {
if (!this.connectedDeviceId) return if (!this.connectedDeviceId) throw new Error('No device connected')
try { try {
const promise = this.createWaitable<DeviceInfo>('deviceInfo') const promise = this.createWaitable<DeviceInfo>('deviceInfo')
await this.deviceInfoService.getDeviceInfo(this.connectedDeviceId) await this.deviceInfoService.getDeviceInfo(this.connectedDeviceId)
return await promise return await promise
} catch (e: any) { } catch (e: any) {
sendMessage({ event: 'error', type: 'deviceInfo', message: e.message }) sendMessage({ event: 'error', type: 'deviceInfo', message: e.message })
throw e
} }
} }
private async getVersion() { private async getVersion() {
if (!this.connectedDeviceId) return if (!this.connectedDeviceId) throw new Error('No device connected')
try { try {
const promise = this.createWaitable<VersionInfo>('versionInfo') const promise = this.createWaitable<VersionInfo>('versionInfo')
await this.deviceInfoService.getDeviceVersion(this.connectedDeviceId) await this.deviceInfoService.getDeviceVersion(this.connectedDeviceId)
return await promise return await promise
} catch (e: any) { } catch (e: any) {
sendMessage({ event: 'error', type: 'version', message: e.message }) sendMessage({ event: 'error', type: 'version', message: e.message })
throw e
} }
} }
private async bind(userId: string) { private async bind(userId: string) {
if (!this.connectedDeviceId) return if (!this.connectedDeviceId) throw new Error('No device connected')
try { try {
const promise = this.createWaitable<BindingResponse>('bindDevice') const promise = this.createWaitable<BindingResponse>('bindDevice')
await this.deviceInfoService.bindDevice(this.connectedDeviceId, userId) await this.deviceInfoService.bindDevice(this.connectedDeviceId, userId)
return await promise return await promise
} catch (e: any) { } catch (e: any) {
sendMessage({ event: 'error', type: 'bind', message: e.message }) sendMessage({ event: 'error', type: 'bind', message: e.message })
throw e
} }
} }
private async unbind(userId: string) { private async unbind(userId: string) {
if (!this.connectedDeviceId) return if (!this.connectedDeviceId) throw new Error('No device connected')
try { try {
const promise = this.createWaitable<UnBindResponse>('unbindDevice') const promise = this.createWaitable<UnBindResponse>('unbindDevice')
await this.deviceInfoService.unbindDevice(this.connectedDeviceId, userId) await this.deviceInfoService.unbindDevice(this.connectedDeviceId, userId)
return await promise return await promise
} catch (e: any) { } catch (e: any) {
sendMessage({ event: 'error', type: 'unbind', message: e.message }) sendMessage({ event: 'error', type: 'unbind', message: e.message })
throw e
} }
} }
private async deleteFile(key: string) { private async deleteFile(key: string) {
if (!this.connectedDeviceId) return if (!this.connectedDeviceId) throw new Error('No device connected')
try { try {
const promise = this.createWaitable<DeleteFileResponse>('deleteFile') const promise = this.createWaitable<DeleteFileResponse>('deleteFile')
await this.deviceInfoService.deleteFile(this.connectedDeviceId, key) await this.deviceInfoService.deleteFile(this.connectedDeviceId, key)
return await promise return await promise
} catch (e: any) { } catch (e: any) {
sendMessage({ event: 'error', type: 'deleteFile', message: e.message }) sendMessage({ event: 'error', type: 'deleteFile', message: e.message })
throw e
} }
} }
private async transferFile(fileUri: string, commandType?: number) { private async transferFile(fileUri: string, commandType?: number) {
if (!this.connectedDeviceId) return if (!this.connectedDeviceId) throw new Error('No device connected')
try { try {
this.updateState(STATE_KEYS.TRANSFER_PROGRESS, 0) this.updateState(STATE_KEYS.TRANSFER_PROGRESS, 0)
const type = commandType ?? COMMAND_TYPES.TRANSFER_ANI_VIDEO const type = commandType ?? COMMAND_TYPES.TRANSFER_ANI_VIDEO
@@ -356,9 +394,11 @@ class BleSDK {
) )
sendMessage({ event: 'transferComplete' }) sendMessage({ event: 'transferComplete' })
return { success: true }
} catch (e: any) { } catch (e: any) {
this.updateState(STATE_KEYS.ERROR, e.message) this.updateState(STATE_KEYS.ERROR, e.message)
sendMessage({ event: 'error', type: 'transfer', message: e.message }) sendMessage({ event: 'error', type: 'transfer', message: e.message })
throw e
} }
} }
@@ -373,7 +413,9 @@ class BleSDK {
} }
private queueDevice(device: { id: string; name: string | null; rssi: number | null }) { private queueDevice(device: { id: string; name: string | null; rssi: number | null }) {
if (this.pendingDevices.some((d) => d.id === device.id)) return if (this.discoveredDevices.some((d) => d.id === device.id)) return
this.discoveredDevices.push(device)
this.pendingDevices.push(device) this.pendingDevices.push(device)
if (this.flushTimer) clearTimeout(this.flushTimer) if (this.flushTimer) clearTimeout(this.flushTimer)
@@ -382,12 +424,38 @@ class BleSDK {
private flushDevices() { private flushDevices() {
if (this.pendingDevices.length === 0) return if (this.pendingDevices.length === 0) return
const devices = [...this.pendingDevices] const devices = [...this.discoveredDevices]
this.pendingDevices = [] this.pendingDevices = []
this.updateState(STATE_KEYS.DISCOVERED_DEVICES, devices) this.updateState(STATE_KEYS.DISCOVERED_DEVICES, devices)
sendMessage({ event: 'devicesFound', devices }) sendMessage({ event: 'devicesFound', devices })
} }
private sendResponse(requestId: string, action: string, data: any) {
sendMessage({ event: 'response', requestId, action, success: true, data: this.serializeForNative(data) })
}
private sendErrorResponse(requestId: string, action: string | undefined, message: string) {
sendMessage({ event: 'response', requestId, action, success: false, error: message })
}
private serializeForNative(value: any): any {
if (typeof value === 'bigint') {
return value.toString()
}
if (Array.isArray(value)) {
return value.map((item) => this.serializeForNative(item))
}
if (value && typeof value === 'object') {
return Object.fromEntries(
Object.entries(value).map(([key, nestedValue]) => [key, this.serializeForNative(nestedValue)])
)
}
return value
}
private createWaitable<T>(opId: string, timeout = 10000): Promise<T> { private createWaitable<T>(opId: string, timeout = 10000): Promise<T> {
return new Promise<T>((resolve, reject) => { return new Promise<T>((resolve, reject) => {
const timeoutId = setTimeout(() => { const timeoutId = setTimeout(() => {

View File

@@ -6,8 +6,12 @@
"start": "expo start", "start": "expo start",
"android": "expo run:android", "android": "expo run:android",
"ios": "expo run:ios", "ios": "expo run:ios",
"build:ios": "npx expo-brownfield build:ios", "build:brownfield:ios": "pnpx expo-brownfield build:ios",
"build:android": "npx expo-brownfield build:android", "build:brownfield:android": "pnpx expo-brownfield build:android",
"build:ios": "node ./scripts/package-ios.mjs",
"build:android": "node ./scripts/package-android.mjs",
"package:ios": "node ./scripts/package-ios.mjs",
"package:android": "node ./scripts/package-android.mjs",
"lint": "expo lint" "lint": "expo lint"
}, },
"dependencies": { "dependencies": {

View File

@@ -0,0 +1,38 @@
import { cpSync, existsSync, mkdirSync, rmSync } from 'node:fs'
import path from 'node:path'
import { spawnSync } from 'node:child_process'
import { fileURLToPath } from 'node:url'
const __filename = fileURLToPath(import.meta.url)
const __dirname = path.dirname(__filename)
const rootDir = path.resolve(__dirname, '..')
const distDir = path.join(rootDir, 'dist', 'android')
const mavenDir = path.join(distDir, 'maven')
const wrapperDir = path.join(distDir, 'wrapper')
const wrapperTemplateDir = path.join(rootDir, 'wrappers', 'android')
function run(command, args) {
const executable = process.platform === 'win32' ? `${command}.cmd` : command
const result = spawnSync(executable, args, {
cwd: rootDir,
stdio: 'inherit',
})
if (result.status !== 0) {
process.exit(result.status ?? 1)
}
}
rmSync(distDir, { recursive: true, force: true })
mkdirSync(distDir, { recursive: true })
run('pnpx', ['expo-brownfield', 'build:android', '--release'])
if (!existsSync(mavenDir)) {
throw new Error(`Missing Android Maven output: ${mavenDir}`)
}
cpSync(wrapperTemplateDir, wrapperDir, { recursive: true })
console.log(`Android wrapper package ready: ${wrapperDir}`)
console.log(`Android Maven repo ready: ${mavenDir}`)

64
scripts/package-ios.mjs Normal file
View File

@@ -0,0 +1,64 @@
import { cpSync, existsSync, mkdirSync, readdirSync, rmSync } from 'node:fs'
import path from 'node:path'
import { spawnSync } from 'node:child_process'
import { fileURLToPath } from 'node:url'
const __filename = fileURLToPath(import.meta.url)
const __dirname = path.dirname(__filename)
const rootDir = path.resolve(__dirname, '..')
const distDir = path.join(rootDir, 'dist', 'ios')
const tempArtifactsDir = path.join(distDir, '.artifacts')
const packageDir = path.join(distDir, 'DuooomiBleWrapper')
const frameworksDir = path.join(packageDir, 'Frameworks')
const wrapperTemplateDir = path.join(rootDir, 'wrappers', 'ios')
function run(command, args) {
const executable = process.platform === 'win32' ? `${command}.cmd` : command
const result = spawnSync(executable, args, {
cwd: rootDir,
stdio: 'inherit',
})
if (result.status !== 0) {
process.exit(result.status ?? 1)
}
}
function findDirectoryByName(dir, expectedName) {
for (const entry of readdirSync(dir, { withFileTypes: true })) {
const fullPath = path.join(dir, entry.name)
if (entry.isDirectory() && entry.name === expectedName) {
return fullPath
}
}
return null
}
rmSync(distDir, { recursive: true, force: true })
mkdirSync(frameworksDir, { recursive: true })
run('pnpx', ['expo-brownfield', 'build:ios', '--release', '--artifacts', tempArtifactsDir])
const sdkFramework = findDirectoryByName(tempArtifactsDir, 'DuooomiBleSDK.xcframework')
const hermesFramework = findDirectoryByName(tempArtifactsDir, 'hermesvm.xcframework')
if (!sdkFramework) {
throw new Error(`Missing iOS artifact: ${path.join(tempArtifactsDir, 'DuooomiBleSDK.xcframework')}`)
}
if (!hermesFramework) {
throw new Error(`Missing iOS artifact: ${path.join(tempArtifactsDir, 'hermesvm.xcframework')}`)
}
cpSync(wrapperTemplateDir, packageDir, { recursive: true })
cpSync(sdkFramework, path.join(frameworksDir, 'DuooomiBleSDK.xcframework'), { recursive: true })
cpSync(hermesFramework, path.join(frameworksDir, 'hermesvm.xcframework'), { recursive: true })
rmSync(tempArtifactsDir, { recursive: true, force: true })
if (!existsSync(path.join(packageDir, 'Package.swift'))) {
throw new Error('Swift wrapper package is incomplete: missing Package.swift')
}
console.log(`iOS wrapper package ready: ${packageDir}`)

View File

@@ -0,0 +1,63 @@
# Android Wrapper 输出说明
该目录会在执行 `pnpm build:android` 后被复制到 `dist/android/wrapper`,用于交付给 Android 原生宿主工程。
## 目录内容
- `../maven`brownfield AAR 及其依赖对应的 Maven 仓库
- `wrapper/`:对外暴露 `DuooomiBleManager``DuooomiBleRuntime` 的 Android Library 模块
## 宿主 App 接入步骤
1.`dist/android/maven` 配置为宿主工程可访问的 Maven 仓库。
2.`dist/android/wrapper` 作为 composite build 引入,或者直接把 `wrapper` 模块复制到宿主工程。
3. 在宿主 App 的一个长期存在的容器里挂载 Brownfield 对应的 React Native View。
4. 运行时启动后,通过 `runtime.manager` 调用蓝牙能力。
## 最小使用示例
```kotlin
val runtime = DuooomiBleRuntime()
runtime.start(container = rootView) { context ->
createReactNativeView(context)
}
lifecycleScope.launch {
val result = runtime.manager.connect(deviceId = "device-id")
Log.d("DuooomiBle", result.toString())
}
```
如果是扫描,仍然沿用监听方式:
```kotlin
runtime.manager.onDiscoveredDevicesChange = { devices ->
Log.d("DuooomiBle", devices.toString())
}
runtime.manager.scan()
```
## 如果宿主需要先初始化 Brownfield Host
```kotlin
val runtime = DuooomiBleRuntime()
runtime.start(
container = rootView,
initializeHost = {
initializeBrownfieldHost()
}
) { context ->
createReactNativeView(context)
}
```
## 说明
- `DuooomiBleRuntime` 负责挂载隐藏的 RN 容器并启动 JS 运行时
- `DuooomiBleManager` 的大部分命令方法为 `suspend`,例如 `connect``bind``transferFile`
- `scan` / `stopScan` 仍然保持事件监听方式
- `DuooomiBleManager` 负责发送 `expo-brownfield` 消息和订阅 `BrownfieldState`
- `createReactNativeView(context)` 需要由宿主工程提供,因为不同宿主的 Brownfield 接入方式可能不同
如果没有先调用 `DuooomiBleRuntime.start(...)`,那么 JS 入口不会真正启动,`scan``connect` 等调用也不会生效。

View File

@@ -0,0 +1,4 @@
plugins {
id("com.android.library") version "8.5.2" apply false
id("org.jetbrains.kotlin.android") version "2.0.21" apply false
}

View File

@@ -0,0 +1,19 @@
pluginManagement {
repositories {
google()
mavenCentral()
gradlePluginPortal()
}
}
dependencyResolutionManagement {
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
repositories {
google()
mavenCentral()
maven(url = uri("../maven"))
}
}
rootProject.name = "duooomi-ble-wrapper"
include(":wrapper")

View File

@@ -0,0 +1,28 @@
plugins {
id("com.android.library")
id("org.jetbrains.kotlin.android")
}
android {
namespace = "com.duooomi.ble.wrapper"
compileSdk = 34
defaultConfig {
minSdk = 24
consumerProguardFiles("consumer-rules.pro")
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
kotlinOptions {
jvmTarget = "17"
}
}
dependencies {
implementation("com.duooomi:duooomi-ble-sdk:1.0.0")
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.8.1")
}

View File

@@ -0,0 +1,2 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest package="com.duooomi.ble.wrapper" />

View File

@@ -0,0 +1,341 @@
package com.duooomi.ble.wrapper
import expo.modules.brownfield.BrownfieldMessage
import expo.modules.brownfield.BrownfieldMessaging
import expo.modules.brownfield.BrownfieldState
import expo.modules.brownfield.Removable
import java.util.UUID
import java.util.concurrent.ConcurrentHashMap
import kotlinx.coroutines.CancellableContinuation
import kotlinx.coroutines.suspendCancellableCoroutine
import kotlin.coroutines.resume
import kotlin.coroutines.resumeWithException
/** 表示 `DuooomiBleManager` 异步命令调用失败。 */
class DuooomiBleManagerException(message: String) : Exception(message)
/**
* 原生宿主调用的 BLE 门面。
*
* 该类型负责把宿主方法调用转换为 `expo-brownfield` 消息,
* 并将 JS 侧共享状态变化转发给宿主回调。
*/
class DuooomiBleManager {
companion object {
private const val BT_STATE = "btState"
private const val CONNECTED_DEVICE = "connectedDevice"
private const val DEVICE_INFO = "deviceInfo"
private const val VERSION = "version"
private const val IS_ACTIVATED = "isActivated"
private const val TRANSFER_PROGRESS = "transferProgress"
private const val ERROR = "error"
private const val DISCOVERED_DEVICES = "discoveredDevices"
}
private val sharedStateSubscriptions = mutableListOf<Removable>()
private var messageListenerId: String? = null
private var started = false
private val pendingRequests = ConcurrentHashMap<String, CancellableContinuation<Map<String, Any?>>>()
/** 接收 JS 侧原始消息事件。 */
var onMessage: ((BrownfieldMessage) -> Unit)? = null
/** 接收蓝牙状态变化,例如 `idle`、`scanning`、`connected`。 */
var onBluetoothStateChange: ((String) -> Unit)? = null
/** 接收当前已连接设备信息。 */
var onConnectedDeviceChange: ((DuooomiConnectedDevice) -> Unit)? = null
/** 接收设备详情信息。 */
var onDeviceInfoChange: ((DuooomiDeviceInfo) -> Unit)? = null
/** 接收固件版本信息。 */
var onVersionChange: ((String) -> Unit)? = null
/** 接收设备激活状态变化。 */
var onActivationChange: ((Boolean) -> Unit)? = null
/** 接收文件传输进度,范围为 `0..100`。 */
var onTransferProgressChange: ((Int) -> Unit)? = null
/** 接收 SDK 上报的错误字符串。 */
var onError: ((String) -> Unit)? = null
/** 接收扫描发现的设备列表。 */
var onDiscoveredDevicesChange: ((List<DuooomiDiscoveredDevice>) -> Unit)? = null
/**
* 开始订阅 Brownfield 消息与共享状态。
*
* 调用前应先通过 [DuooomiBleRuntime] 启动 RN/Brownfield 运行时。
*/
fun start() {
if (started) return
started = true
sharedStateSubscriptions += BrownfieldState.subscribe(BT_STATE) { value ->
(value as? String)?.let { onBluetoothStateChange?.invoke(it) }
}
sharedStateSubscriptions += BrownfieldState.subscribe(CONNECTED_DEVICE) { value ->
@Suppress("UNCHECKED_CAST")
(value as? Map<String, Any?>)?.let {
onConnectedDeviceChange?.invoke(
DuooomiConnectedDevice(
id = DuooomiBleValueDecoder.requiredString(it, "id"),
name = DuooomiBleValueDecoder.optionalString(it, "name"),
)
)
}
}
sharedStateSubscriptions += BrownfieldState.subscribe(DEVICE_INFO) { value ->
@Suppress("UNCHECKED_CAST")
(value as? Map<String, Any?>)?.let {
onDeviceInfoChange?.invoke(
DuooomiDeviceInfo(
allspace = DuooomiBleValueDecoder.requiredString(it, "allspace"),
freespace = DuooomiBleValueDecoder.requiredString(it, "freespace"),
name = DuooomiBleValueDecoder.requiredString(it, "name"),
size = DuooomiBleValueDecoder.requiredString(it, "size"),
brand = DuooomiBleValueDecoder.requiredString(it, "brand"),
powerlevel = DuooomiBleValueDecoder.requiredInt(it, "powerlevel"),
)
)
}
}
sharedStateSubscriptions += BrownfieldState.subscribe(VERSION) { value ->
(value as? String)?.let { onVersionChange?.invoke(it) }
}
sharedStateSubscriptions += BrownfieldState.subscribe(IS_ACTIVATED) { value ->
(value as? Boolean)?.let { onActivationChange?.invoke(it) }
}
sharedStateSubscriptions += BrownfieldState.subscribe(TRANSFER_PROGRESS) { value ->
(value as? Int)?.let { onTransferProgressChange?.invoke(it) }
}
sharedStateSubscriptions += BrownfieldState.subscribe(ERROR) { value ->
(value as? String)?.let { onError?.invoke(it) }
}
sharedStateSubscriptions += BrownfieldState.subscribe(DISCOVERED_DEVICES) { value ->
@Suppress("UNCHECKED_CAST")
(value as? List<Map<String, Any?>>)?.let { devices ->
onDiscoveredDevicesChange?.invoke(
devices.mapNotNull { device ->
runCatching {
DuooomiDiscoveredDevice(
id = DuooomiBleValueDecoder.requiredString(device, "id"),
name = DuooomiBleValueDecoder.optionalString(device, "name"),
rssi = DuooomiBleValueDecoder.optionalInt(device, "rssi"),
)
}.getOrNull()
}
)
}
}
messageListenerId = BrownfieldMessaging.addListener { event ->
if (handleResponse(event)) {
return@addListener
}
onMessage?.invoke(event)
}
}
/** 停止订阅 Brownfield 消息与共享状态。 */
fun stop() {
started = false
sharedStateSubscriptions.forEach { it.remove() }
sharedStateSubscriptions.clear()
pendingRequests.forEach { (_, continuation) ->
continuation.resumeWithException(DuooomiBleManagerException("Manager stopped before response was received"))
}
pendingRequests.clear()
messageListenerId?.let(BrownfieldMessaging::removeListener)
messageListenerId = null
}
/** 开始扫描蓝牙设备。 */
fun scan() {
send(mapOf("action" to "scan"))
}
/** 停止扫描蓝牙设备。 */
fun stopScan() {
send(mapOf("action" to "stopScan"))
}
/**
* 连接指定设备。
*
* @param deviceId 设备唯一标识。
* @return 连接成功后的设备摘要信息。
*/
suspend fun connect(deviceId: String): DuooomiConnectedDevice {
val result = sendAsync(
action = "connect",
params = mapOf("deviceId" to deviceId),
)
return DuooomiConnectedDevice(
id = DuooomiBleValueDecoder.requiredString(result, "id"),
name = DuooomiBleValueDecoder.optionalString(result, "name"),
)
}
/**
* 断开当前设备连接。
*
* @return 断开连接结果。
*/
suspend fun disconnect(): DuooomiCommandResult {
val result = sendAsync(action = "disconnect")
return DuooomiCommandResult(
success = DuooomiBleValueDecoder.requiredBoolean(result, "success"),
)
}
/**
* 获取当前已连接设备的详细信息。
*
* @return 设备详情数据。
*/
suspend fun getDeviceInfo(): DuooomiDeviceInfo {
val result = sendAsync(action = "getDeviceInfo")
return DuooomiDeviceInfo(
allspace = DuooomiBleValueDecoder.requiredString(result, "allspace"),
freespace = DuooomiBleValueDecoder.requiredString(result, "freespace"),
name = DuooomiBleValueDecoder.requiredString(result, "name"),
size = DuooomiBleValueDecoder.requiredString(result, "size"),
brand = DuooomiBleValueDecoder.requiredString(result, "brand"),
powerlevel = DuooomiBleValueDecoder.requiredInt(result, "powerlevel"),
)
}
/**
* 获取当前已连接设备的版本信息。
*
* @return 版本信息数据。
*/
suspend fun getVersion(): DuooomiVersionInfo {
val result = sendAsync(action = "getVersion")
return DuooomiVersionInfo(
version = DuooomiBleValueDecoder.requiredString(result, "version"),
type = DuooomiBleValueDecoder.requiredString(result, "type"),
)
}
/**
* 绑定当前设备到指定用户。
*
* @param userId 业务侧用户标识。
* @return 绑定结果数据。
*/
suspend fun bind(userId: String): DuooomiBindingResult {
val result = sendAsync(
action = "bind",
params = mapOf("userId" to userId),
)
return DuooomiBindingResult(
type = DuooomiBleValueDecoder.requiredInt(result, "type"),
sn = DuooomiBleValueDecoder.requiredString(result, "sn"),
success = DuooomiBleValueDecoder.requiredInt(result, "success"),
contents = DuooomiBleValueDecoder.stringList(result, "contents"),
)
}
/**
* 解绑当前设备与指定用户的关系。
*
* @param userId 业务侧用户标识。
* @return 解绑结果数据。
*/
suspend fun unbind(userId: String): DuooomiUnbindResult {
val result = sendAsync(
action = "unbind",
params = mapOf("userId" to userId),
)
return DuooomiUnbindResult(
success = DuooomiBleValueDecoder.requiredInt(result, "success"),
)
}
/**
* 删除设备中的指定文件。
*
* @param key 设备侧文件 key。
* @return 删除结果数据。
*/
suspend fun deleteFile(key: String): DuooomiDeleteFileResult {
val result = sendAsync(
action = "deleteFile",
params = mapOf("key" to key),
)
return DuooomiDeleteFileResult(
type = DuooomiBleValueDecoder.requiredInt(result, "type"),
success = DuooomiBleValueDecoder.requiredInt(result, "success"),
)
}
/**
* 向设备传输文件。
*
* @param fileUri 待传输文件的本地 URI。
* @param commandType 传输命令类型;不传时由 JS 侧使用默认值。
* @return 传输完成结果。
*/
suspend fun transferFile(fileUri: String, commandType: Int? = null): DuooomiCommandResult {
val result = sendAsync(
action = "transferFile",
params = buildMap {
put("fileUri", fileUri)
commandType?.let { put("commandType", it) }
},
)
return DuooomiCommandResult(
success = DuooomiBleValueDecoder.requiredBoolean(result, "success"),
)
}
private fun send(message: BrownfieldMessage) {
BrownfieldMessaging.sendMessage(message)
}
private suspend fun sendAsync(
action: String,
params: Map<String, Any?> = emptyMap(),
): Map<String, Any?> = suspendCancellableCoroutine { continuation ->
val requestId = UUID.randomUUID().toString()
pendingRequests[requestId] = continuation
continuation.invokeOnCancellation {
pendingRequests.remove(requestId)
}
send(
buildMap {
putAll(params)
put("action", action)
put("requestId", requestId)
}
)
}
private fun handleResponse(message: BrownfieldMessage): Boolean {
if (message["event"] != "response") {
return false
}
val requestId = message["requestId"] as? String ?: return false
val continuation = pendingRequests.remove(requestId) ?: return true
val success = message["success"] as? Boolean ?: false
if (success) {
@Suppress("UNCHECKED_CAST")
val data = message["data"] as? Map<String, Any?> ?: emptyMap()
continuation.resume(data)
} else {
val error = message["error"] as? String ?: "Unknown error"
continuation.resumeWithException(DuooomiBleManagerException(error))
}
return true
}
}

View File

@@ -0,0 +1,99 @@
package com.duooomi.ble.wrapper
/** 扫描阶段发现的设备摘要信息。 */
data class DuooomiDiscoveredDevice(
val id: String,
val name: String?,
val rssi: Int?,
)
/** 设备连接结果摘要。 */
data class DuooomiConnectedDevice(
val id: String,
val name: String?,
)
/** 设备详细信息。 */
data class DuooomiDeviceInfo(
val allspace: String,
val freespace: String,
val name: String,
val size: String,
val brand: String,
val powerlevel: Int,
)
/** 设备版本信息。 */
data class DuooomiVersionInfo(
val version: String,
val type: String,
)
/** 绑定结果信息。 */
data class DuooomiBindingResult(
val type: Int,
val sn: String,
val success: Int,
val contents: List<String>,
)
/** 解绑结果信息。 */
data class DuooomiUnbindResult(
val success: Int,
)
/** 删除文件结果信息。 */
data class DuooomiDeleteFileResult(
val type: Int,
val success: Int,
)
/** 通用布尔完成结果。 */
data class DuooomiCommandResult(
val success: Boolean,
)
internal object DuooomiBleValueDecoder {
fun requiredString(map: Map<String, Any?>, key: String): String {
return optionalString(map, key)
?: throw DuooomiBleManagerException("Missing string value for key: $key")
}
fun optionalString(map: Map<String, Any?>, key: String): String? {
val value = map[key] ?: return null
return when (value) {
is String -> value
is Number -> value.toString()
else -> null
}
}
fun requiredInt(map: Map<String, Any?>, key: String): Int {
return optionalInt(map, key)
?: throw DuooomiBleManagerException("Missing integer value for key: $key")
}
fun optionalInt(map: Map<String, Any?>, key: String): Int? {
val value = map[key] ?: return null
return when (value) {
is Int -> value
is Number -> value.toInt()
is String -> value.toIntOrNull()
else -> null
}
}
fun requiredBoolean(map: Map<String, Any?>, key: String): Boolean {
val value = map[key]
return when (value) {
is Boolean -> value
is Number -> value.toInt() != 0
else -> throw DuooomiBleManagerException("Missing boolean value for key: $key")
}
}
fun stringList(map: Map<String, Any?>, key: String): List<String> {
@Suppress("UNCHECKED_CAST")
return map[key] as? List<String> ?: emptyList()
}
}

View File

@@ -0,0 +1,56 @@
package com.duooomi.ble.wrapper
import android.content.Context
import android.view.View
import android.view.ViewGroup
/**
* 负责在宿主视图层中挂载隐藏的 RN 容器并启动 JS 运行时。
*
* @property manager 运行时启动后对外复用的 BLE 门面实例。
*/
class DuooomiBleRuntime(
val manager: DuooomiBleManager = DuooomiBleManager(),
) {
private var mountedView: View? = null
/**
* 在指定容器中启动 Brownfield 运行时。
*
* @param container 用于挂载隐藏 React Native 视图的宿主容器。
* @param initializeHost 可选的宿主初始化逻辑。
* @param createReactNativeView 由宿主提供的 RN View 构造逻辑。
*/
fun start(
container: ViewGroup,
initializeHost: (() -> Unit)? = null,
createReactNativeView: ((Context) -> View)?
) {
if (mountedView != null) {
return
}
initializeHost?.invoke()
val reactNativeView = createReactNativeView(container.context)
reactNativeView.alpha = 0f
reactNativeView.isClickable = false
reactNativeView.isFocusable = false
reactNativeView.importantForAccessibility = View.IMPORTANT_FOR_ACCESSIBILITY_NO_HIDE_DESCENDANTS
val layoutParams = ViewGroup.LayoutParams(1, 1)
container.addView(reactNativeView, layoutParams)
mountedView = reactNativeView
manager.start()
}
/** 停止运行时并移除已挂载的隐藏 RN 视图。 */
fun stop() {
manager.stop()
mountedView?.let { view ->
(view.parent as? ViewGroup)?.removeView(view)
}
mountedView = null
}
}

View File

@@ -0,0 +1,30 @@
// swift-tools-version: 5.9
import PackageDescription
let package = Package(
name: "DuooomiBleWrapper",
platforms: [
.iOS(.v15),
],
products: [
.library(
name: "DuooomiBleWrapper",
targets: ["DuooomiBleWrapper"]
),
],
targets: [
.binaryTarget(
name: "DuooomiBleSDK",
path: "Frameworks/DuooomiBleSDK.xcframework"
),
.binaryTarget(
name: "hermesvm",
path: "Frameworks/hermesvm.xcframework"
),
.target(
name: "DuooomiBleWrapper",
dependencies: ["DuooomiBleSDK", "hermesvm"],
path: "Sources/DuooomiBleWrapper"
),
]
)

62
wrappers/ios/README.md Normal file
View File

@@ -0,0 +1,62 @@
# DuooomiBleWrapper
该目录由 `pnpm build:ios` 生成,是可以直接交付给 iOS 原生宿主工程使用的 Swift Package。
## 目录内容
- `Frameworks/DuooomiBleSDK.xcframework`
- `Frameworks/hermesvm.xcframework`
- `Sources/DuooomiBleWrapper/DuooomiBleManager.swift`
- `Sources/DuooomiBleWrapper/DuooomiBleRuntime.swift`
## 宿主 App 接入步骤
1.`dist/ios/DuooomiBleWrapper` 作为本地 Swift Package 引入宿主工程。
2. 在宿主 App 的一个长期存在的容器视图里启动 Brownfield 运行时。
3. 运行时启动完成后,通过 `DuooomiBleManager.shared` 调用蓝牙能力。
## 最小使用示例
```swift
try DuooomiBleRuntime.shared.start(in: view)
Task {
let result = try await DuooomiBleManager.shared.connect(deviceId: "device-id")
print(result)
}
```
如果是扫描,仍然沿用监听方式:
```swift
DuooomiBleManager.shared.onDiscoveredDevicesChange = { devices in
print(devices)
}
DuooomiBleManager.shared.scan()
```
## 如果宿主需要显式初始化 Brownfield
如果你的宿主工程不能被 `DuooomiBleRuntime` 自动识别出 `ReactNativeHostManager``ReactNativeView`,可以显式传入初始化逻辑:
```swift
try DuooomiBleRuntime.shared.start(
in: view,
initializeHost: {
ReactNativeHostManager.shared.initialize()
},
createReactNativeView: {
ReactNativeView()
}
)
```
## 说明
- `DuooomiBleRuntime` 负责启动 Expo Brownfield 对应的 RN/JS 运行时
- `DuooomiBleManager` 的大部分命令方法为 `async throws`,例如 `connect``bind``transferFile`
- `scan` / `stopScan` 仍然保持事件监听方式
- `DuooomiBleManager` 也会订阅 `ble/sdk/BleSDK.ts` 中定义的 shared state 并转发给宿主
如果只调用 `DuooomiBleManager` 而没有先启动 `DuooomiBleRuntime`JS 侧的 `bleSDK.initialize()` 不会执行,消息也不会被处理。

View File

@@ -0,0 +1,289 @@
import Combine
import Foundation
import DuooomiBleSDK
/// `DuooomiBleManager`
public struct DuooomiBleManagerError: Error, LocalizedError {
///
public let message: String
public var errorDescription: String? {
message
}
/// manager
/// - Parameter message:
public init(message: String) {
self.message = message
}
}
/// 宿 BLE
///
/// 宿 `expo-brownfield`
/// JS
public final class DuooomiBleManager {
/// 宿
public static let shared = DuooomiBleManager()
private enum StateKey {
static let btState = "btState"
static let connectedDevice = "connectedDevice"
static let deviceInfo = "deviceInfo"
static let version = "version"
static let isActivated = "isActivated"
static let transferProgress = "transferProgress"
static let error = "error"
static let discoveredDevices = "discoveredDevices"
}
private var sharedStateCancellables: [AnyCancellable] = []
private var messageListenerId: String?
private var started = false
private let pendingRequestsLock = NSLock()
private var pendingRequests: [String: (Result<[String: Any?], Error>) -> Void] = [:]
/// JS
public var onMessage: (([String: Any?]) -> Void)?
/// `idle``scanning``connected`
public var onBluetoothStateChange: ((String) -> Void)?
///
public var onConnectedDeviceChange: ((DuooomiConnectedDevice) -> Void)?
///
public var onDeviceInfoChange: ((DuooomiDeviceInfo) -> Void)?
///
public var onVersionChange: ((String) -> Void)?
///
public var onActivationChange: ((Bool) -> Void)?
/// `0...100`
public var onTransferProgressChange: ((Int) -> Void)?
/// SDK
public var onError: ((String) -> Void)?
///
public var onDiscoveredDevicesChange: (([DuooomiDiscoveredDevice]) -> Void)?
private init() {}
/// Brownfield
///
/// `DuooomiBleRuntime` RN/Brownfield
public func start() {
guard !started else { return }
started = true
sharedStateCancellables = [
BrownfieldStateInternal.shared.subscribe(StateKey.btState) { [weak self] value in
if let state = value as? String {
self?.onBluetoothStateChange?(state)
}
},
BrownfieldStateInternal.shared.subscribe(StateKey.connectedDevice) { [weak self] value in
if let device = value as? [String: Any?], let decoded = try? DuooomiConnectedDevice(dictionary: device) {
self?.onConnectedDeviceChange?(decoded)
}
},
BrownfieldStateInternal.shared.subscribe(StateKey.deviceInfo) { [weak self] value in
if let info = value as? [String: Any?], let decoded = try? DuooomiDeviceInfo(dictionary: info) {
self?.onDeviceInfoChange?(decoded)
}
},
BrownfieldStateInternal.shared.subscribe(StateKey.version) { [weak self] value in
if let version = value as? String {
self?.onVersionChange?(version)
}
},
BrownfieldStateInternal.shared.subscribe(StateKey.isActivated) { [weak self] value in
if let isActivated = value as? Bool {
self?.onActivationChange?(isActivated)
}
},
BrownfieldStateInternal.shared.subscribe(StateKey.transferProgress) { [weak self] value in
if let progress = value as? Int {
self?.onTransferProgressChange?(progress)
}
},
BrownfieldStateInternal.shared.subscribe(StateKey.error) { [weak self] value in
if let error = value as? String {
self?.onError?(error)
}
},
BrownfieldStateInternal.shared.subscribe(StateKey.discoveredDevices) { [weak self] value in
if let devices = value as? [[String: Any]] {
let decoded = devices.compactMap { try? DuooomiDiscoveredDevice(dictionary: $0) }
self?.onDiscoveredDevicesChange?(decoded)
}
},
]
messageListenerId = BrownfieldMessagingInternal.shared.addListener { [weak self] message in
guard let self else { return }
if self.handleResponse(message) {
return
}
self.onMessage?(message)
}
}
/// Brownfield
public func stop() {
started = false
sharedStateCancellables.removeAll()
cancelPendingRequests()
if let messageListenerId {
BrownfieldMessagingInternal.shared.removeListener(id: messageListenerId)
self.messageListenerId = nil
}
}
///
public func scan() {
send(["action": "scan"])
}
///
public func stopScan() {
send(["action": "stopScan"])
}
///
/// - Parameter deviceId:
/// - Returns:
public func connect(deviceId: String) async throws -> DuooomiConnectedDevice {
let result = try await sendAsync(action: "connect", params: [
"deviceId": deviceId,
])
return try DuooomiConnectedDevice(dictionary: result)
}
///
/// - Returns:
public func disconnect() async throws -> DuooomiCommandResult {
let result = try await sendAsync(action: "disconnect")
return try DuooomiCommandResult(dictionary: result)
}
///
/// - Returns:
public func getDeviceInfo() async throws -> DuooomiDeviceInfo {
let result = try await sendAsync(action: "getDeviceInfo")
return try DuooomiDeviceInfo(dictionary: result)
}
///
/// - Returns:
public func getVersion() async throws -> DuooomiVersionInfo {
let result = try await sendAsync(action: "getVersion")
return try DuooomiVersionInfo(dictionary: result)
}
///
/// - Parameter userId:
/// - Returns:
public func bind(userId: String) async throws -> DuooomiBindingResult {
let result = try await sendAsync(action: "bind", params: [
"userId": userId,
])
return try DuooomiBindingResult(dictionary: result)
}
///
/// - Parameter userId:
/// - Returns:
public func unbind(userId: String) async throws -> DuooomiUnbindResult {
let result = try await sendAsync(action: "unbind", params: [
"userId": userId,
])
return try DuooomiUnbindResult(dictionary: result)
}
///
/// - Parameter key: key
/// - Returns:
public func deleteFile(key: String) async throws -> DuooomiDeleteFileResult {
let result = try await sendAsync(action: "deleteFile", params: [
"key": key,
])
return try DuooomiDeleteFileResult(dictionary: result)
}
///
/// - Parameters:
/// - fileUri: URI
/// - commandType: JS 使
/// - Returns:
public func transferFile(fileUri: String, commandType: Int? = nil) async throws -> DuooomiCommandResult {
var params: [String: Any?] = [
"fileUri": fileUri,
]
if let commandType {
params["commandType"] = commandType
}
let result = try await sendAsync(action: "transferFile", params: params)
return try DuooomiCommandResult(dictionary: result)
}
private func send(_ message: [String: Any?]) {
BrownfieldMessagingInternal.shared.sendMessage(message)
}
private func sendAsync(action: String, params: [String: Any?] = [:]) async throws -> [String: Any?] {
let requestId = UUID().uuidString
return try await withCheckedThrowingContinuation { continuation in
storePendingRequest(requestId) { result in
continuation.resume(with: result)
}
var message = params
message["action"] = action
message["requestId"] = requestId
send(message)
}
}
private func storePendingRequest(_ requestId: String, completion: @escaping (Result<[String: Any?], Error>) -> Void) {
pendingRequestsLock.lock()
pendingRequests[requestId] = completion
pendingRequestsLock.unlock()
}
private func completePendingRequest(_ requestId: String, result: Result<[String: Any?], Error>) {
pendingRequestsLock.lock()
let completion = pendingRequests.removeValue(forKey: requestId)
pendingRequestsLock.unlock()
completion?(result)
}
private func cancelPendingRequests() {
pendingRequestsLock.lock()
let requests = pendingRequests
pendingRequests.removeAll()
pendingRequestsLock.unlock()
for completion in requests.values {
completion(.failure(DuooomiBleManagerError(message: "Manager stopped before response was received")))
}
}
private func handleResponse(_ message: [String: Any?]) -> Bool {
guard (message["event"] as? String) == "response",
let requestId = message["requestId"] as? String else {
return false
}
if let success = message["success"] as? Bool, success {
let data = message["data"] as? [String: Any?] ?? [:]
completePendingRequest(requestId, result: .success(data))
return true
}
let errorMessage = message["error"] as? String ?? "Unknown error"
completePendingRequest(requestId, result: .failure(DuooomiBleManagerError(message: errorMessage)))
return true
}
}

View File

@@ -0,0 +1,180 @@
import Foundation
///
public struct DuooomiDiscoveredDevice {
///
public let id: String
///
public let name: String?
///
public let rssi: Int?
init(dictionary: [String: Any]) throws {
self.id = try DuooomiBleValueDecoder.requiredString(dictionary, key: "id")
self.name = DuooomiBleValueDecoder.optionalString(dictionary, key: "name")
self.rssi = DuooomiBleValueDecoder.optionalInt(dictionary, key: "rssi")
}
}
///
public struct DuooomiConnectedDevice {
///
public let id: String
///
public let name: String?
init(dictionary: [String: Any?]) throws {
self.id = try DuooomiBleValueDecoder.requiredString(dictionary, key: "id")
self.name = DuooomiBleValueDecoder.optionalString(dictionary, key: "name")
}
}
///
public struct DuooomiDeviceInfo {
///
public let allspace: String
///
public let freespace: String
///
public let name: String
/// `360x360`
public let size: String
///
public let brand: String
/// `0...100`
public let powerlevel: Int
init(dictionary: [String: Any?]) throws {
self.allspace = try DuooomiBleValueDecoder.requiredString(dictionary, key: "allspace")
self.freespace = try DuooomiBleValueDecoder.requiredString(dictionary, key: "freespace")
self.name = try DuooomiBleValueDecoder.requiredString(dictionary, key: "name")
self.size = try DuooomiBleValueDecoder.requiredString(dictionary, key: "size")
self.brand = try DuooomiBleValueDecoder.requiredString(dictionary, key: "brand")
self.powerlevel = try DuooomiBleValueDecoder.requiredInt(dictionary, key: "powerlevel")
}
}
///
public struct DuooomiVersionInfo {
///
public let version: String
///
public let type: String
init(dictionary: [String: Any?]) throws {
self.version = try DuooomiBleValueDecoder.requiredString(dictionary, key: "version")
self.type = try DuooomiBleValueDecoder.requiredString(dictionary, key: "type")
}
}
///
public struct DuooomiBindingResult {
///
public let type: Int
/// SN
public let sn: String
/// `1`
public let success: Int
///
public let contents: [String]
init(dictionary: [String: Any?]) throws {
self.type = try DuooomiBleValueDecoder.requiredInt(dictionary, key: "type")
self.sn = try DuooomiBleValueDecoder.requiredString(dictionary, key: "sn")
self.success = try DuooomiBleValueDecoder.requiredInt(dictionary, key: "success")
self.contents = DuooomiBleValueDecoder.stringArray(dictionary, key: "contents")
}
}
///
public struct DuooomiUnbindResult {
/// `1`
public let success: Int
init(dictionary: [String: Any?]) throws {
self.success = try DuooomiBleValueDecoder.requiredInt(dictionary, key: "success")
}
}
///
public struct DuooomiDeleteFileResult {
///
public let type: Int
/// `0` `1` `2`
public let success: Int
init(dictionary: [String: Any?]) throws {
self.type = try DuooomiBleValueDecoder.requiredInt(dictionary, key: "type")
self.success = try DuooomiBleValueDecoder.requiredInt(dictionary, key: "success")
}
}
///
public struct DuooomiCommandResult {
///
public let success: Bool
init(dictionary: [String: Any?]) throws {
self.success = try DuooomiBleValueDecoder.requiredBool(dictionary, key: "success")
}
}
enum DuooomiBleValueDecoder {
static func requiredString(_ dictionary: [String: Any?], key: String) throws -> String {
if let value = optionalString(dictionary, key: key) {
return value
}
throw DuooomiBleManagerError(message: "Missing string value for key: \(key)")
}
static func optionalString(_ dictionary: [String: Any?], key: String) -> String? {
if let value = dictionary[key] as? String {
return value
}
if let value = dictionary[key] as? NSNumber {
return value.stringValue
}
return nil
}
static func requiredInt(_ dictionary: [String: Any?], key: String) throws -> Int {
if let value = optionalInt(dictionary, key: key) {
return value
}
throw DuooomiBleManagerError(message: "Missing integer value for key: \(key)")
}
static func optionalInt(_ dictionary: [String: Any?], key: String) -> Int? {
if let value = dictionary[key] as? Int {
return value
}
if let value = dictionary[key] as? NSNumber {
return value.intValue
}
if let value = dictionary[key] as? String {
return Int(value)
}
return nil
}
static func requiredBool(_ dictionary: [String: Any?], key: String) throws -> Bool {
if let value = dictionary[key] as? Bool {
return value
}
if let value = dictionary[key] as? NSNumber {
return value.boolValue
}
throw DuooomiBleManagerError(message: "Missing boolean value for key: \(key)")
}
static func stringArray(_ dictionary: [String: Any?], key: String) -> [String] {
(dictionary[key] as? [String]) ?? []
}
}

View File

@@ -0,0 +1,115 @@
import Foundation
import UIKit
/// Brownfield
public enum DuooomiBleRuntimeError: Error, LocalizedError {
case hostManagerUnavailable
case reactNativeViewUnavailable
public var errorDescription: String? {
switch self {
case .hostManagerUnavailable:
return "Unable to locate ReactNativeHostManager. Make sure the brownfield framework is linked correctly."
case .reactNativeViewUnavailable:
return "Unable to create ReactNativeView automatically. Pass createReactNativeView when starting the runtime."
}
}
}
/// 宿 RN JS
public final class DuooomiBleRuntime {
/// 宿
public static let shared = DuooomiBleRuntime()
private weak var mountedView: UIView?
private weak var containerView: UIView?
private init() {}
/// Brownfield
///
/// - Parameters:
/// - containerView: React Native 宿
/// - initializeHost: 宿
/// - createReactNativeView: RN View
/// - Throws: `DuooomiBleRuntimeError`
public func start(
in containerView: UIView,
initializeHost: (() -> Void)? = nil,
createReactNativeView: (() -> UIView)? = nil
) throws {
if mountedView != nil {
return
}
if let initializeHost {
initializeHost()
} else {
try autoInitializeHostManager()
}
let reactNativeView = createReactNativeView?() ?? try autoCreateReactNativeView()
reactNativeView.isHidden = true
reactNativeView.isUserInteractionEnabled = false
reactNativeView.frame = CGRect(x: 0, y: 0, width: 1, height: 1)
reactNativeView.accessibilityElementsHidden = true
containerView.addSubview(reactNativeView)
self.containerView = containerView
self.mountedView = reactNativeView
DuooomiBleManager.shared.start()
}
/// RN
public func stop() {
DuooomiBleManager.shared.stop()
mountedView?.removeFromSuperview()
mountedView = nil
containerView = nil
}
private func autoInitializeHostManager() throws {
let possibleClassNames = [
"ReactNativeHostManager",
"DuooomiBleSDK.ReactNativeHostManager",
]
for className in possibleClassNames {
guard let managerClass = NSClassFromString(className) as? NSObject.Type else {
continue
}
let sharedSelector = NSSelectorFromString("shared")
let initializeSelector = NSSelectorFromString("initialize")
let managerObject = (managerClass as AnyObject).perform(sharedSelector)?.takeUnretainedValue() as? NSObject
if let managerObject, managerObject.responds(to: initializeSelector) {
managerObject.perform(initializeSelector)
return
}
}
throw DuooomiBleRuntimeError.hostManagerUnavailable
}
private func autoCreateReactNativeView() throws -> UIView {
let possibleClassNames = [
"ReactNativeView",
"DuooomiBleSDK.ReactNativeView",
]
for className in possibleClassNames {
guard let viewClass = NSClassFromString(className) as? NSObject.Type else {
continue
}
if let view = viewClass.init() as? UIView {
return view
}
}
throw DuooomiBleRuntimeError.reactNativeViewUnavailable
}
}