From c33e7d3fa1c3b22eeafdd2a09aee3c1b74a5e2e0 Mon Sep 17 00:00:00 2001 From: km2023 Date: Wed, 8 Apr 2026 15:41:46 +0800 Subject: [PATCH] Initial commit: DuooomiBleSDK test app with XcodeGen Co-Authored-By: Claude Opus 4.6 --- .gitignore | 38 ++++ DuooomiTest/Info.plist | 28 +++ DuooomiTest/Sources/ContentView.swift | 205 ++++++++++++++++++++ DuooomiTest/Sources/DuooomiTestApp.swift | 24 +++ DuooomiTest/Sources/SDKManager.swift | 229 +++++++++++++++++++++++ README.md | 52 +++++ project.yml | 38 ++++ 7 files changed, 614 insertions(+) create mode 100644 .gitignore create mode 100644 DuooomiTest/Info.plist create mode 100644 DuooomiTest/Sources/ContentView.swift create mode 100644 DuooomiTest/Sources/DuooomiTestApp.swift create mode 100644 DuooomiTest/Sources/SDKManager.swift create mode 100644 README.md create mode 100644 project.yml diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..2897dcc --- /dev/null +++ b/.gitignore @@ -0,0 +1,38 @@ +# Xcode +*.xcodeproj/ +*.xcworkspace/ +!*.xcworkspace/contents.xcworkspacedata + +# XcodeGen generates xcodeproj from project.yml, no need to track it +# To regenerate: xcodegen generate + +# User-specific Xcode data +xcuserdata/ +*.xcuserstate + +# Build +build/ +DerivedData/ +*.ipa +*.dSYM.zip +*.dSYM + +# CocoaPods (if used) +Pods/ + +# SPM +.build/ +Package.resolved + +# macOS +.DS_Store +**/.DS_Store + +# Fastlane +fastlane/report.xml +fastlane/Preview.html +fastlane/screenshots/**/*.png +fastlane/test_output + +# Code Injection +iOSInjectionProject/ diff --git a/DuooomiTest/Info.plist b/DuooomiTest/Info.plist new file mode 100644 index 0000000..cc0dd52 --- /dev/null +++ b/DuooomiTest/Info.plist @@ -0,0 +1,28 @@ + + + + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleDisplayName + Duooomi Test + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + APPL + CFBundleShortVersionString + 1.0.0 + CFBundleVersion + 1 + NSBluetoothAlwaysUsageDescription + This app needs Bluetooth to communicate with your device + UILaunchScreen + + + diff --git a/DuooomiTest/Sources/ContentView.swift b/DuooomiTest/Sources/ContentView.swift new file mode 100644 index 0000000..258422d --- /dev/null +++ b/DuooomiTest/Sources/ContentView.swift @@ -0,0 +1,205 @@ +import SwiftUI + +struct ContentView: View { + @EnvironmentObject var sdk: SDKManager + @State private var userId = "test-user-001" + @State private var fileKey = "" + + var body: some View { + NavigationView { + List { + statusSection + scanSection + deviceSection + fileSection + logSection + } + .navigationTitle("BLE SDK Test") + } + } + + // MARK: - Status + + private var statusSection: some View { + Section("Status") { + HStack { + Text("Connection") + Spacer() + Text(sdk.btState) + .foregroundStyle(sdk.btState == "connected" ? .green : .secondary) + .fontWeight(.medium) + } + + if let error = sdk.error { + HStack { + Image(systemName: "exclamationmark.triangle.fill") + .foregroundStyle(.red) + Text(error) + .foregroundStyle(.red) + .font(.caption) + } + } + + HStack { + Text("SDK Ready") + Spacer() + Image(systemName: sdk.isSDKReady ? "checkmark.circle.fill" : "xmark.circle") + .foregroundStyle(sdk.isSDKReady ? .green : .red) + } + } + } + + // MARK: - Scan + + private var scanSection: some View { + Section("Scan") { + HStack { + Button("Scan") { sdk.scan() } + .buttonStyle(.borderedProminent) + .disabled(sdk.btState == "scanning") + + Button("Stop") { sdk.stopScan() } + .buttonStyle(.bordered) + .disabled(sdk.btState != "scanning") + } + + if sdk.discoveredDevices.isEmpty && sdk.btState == "scanning" { + HStack { + ProgressView() + Text("Searching...") + .foregroundStyle(.secondary) + } + } + + ForEach(sdk.discoveredDevices) { device in + Button { + sdk.stopScan() + sdk.connect(deviceId: device.id) + } label: { + HStack { + VStack(alignment: .leading) { + Text(device.name) + .font(.body) + Text(device.id) + .font(.caption2) + .foregroundStyle(.secondary) + } + Spacer() + Text("\(device.rssi) dBm") + .font(.caption) + .foregroundStyle(.secondary) + } + } + } + } + } + + // MARK: - Device + + private var deviceSection: some View { + Section("Device") { + if sdk.btState == "connected" { + if let device = sdk.connectedDevice { + LabeledContent("Name", value: device["name"] as? String ?? "—") + LabeledContent("ID", value: device["id"] as? String ?? "—") + } + + LabeledContent("Version", value: sdk.version.isEmpty ? "—" : sdk.version) + + if let info = sdk.deviceInfo { + LabeledContent("Brand", value: info.brand) + LabeledContent("Screen", value: info.screenSize) + LabeledContent("Battery", value: "\(info.batteryLevel)%") + LabeledContent("Storage", value: formatStorage(free: info.freeSpace, total: info.totalSpace)) + } + + HStack(spacing: 12) { + Button("Get Info") { sdk.getDeviceInfo() } + .buttonStyle(.bordered) + Button("Get Version") { sdk.getVersion() } + .buttonStyle(.bordered) + } + + HStack { + TextField("User ID", text: $userId) + .textFieldStyle(.roundedBorder) + Button("Bind") { sdk.bind(userId: userId) } + .buttonStyle(.bordered) + } + + HStack(spacing: 12) { + Button("Unbind") { sdk.unbind(userId: userId) } + .buttonStyle(.bordered) + Button("Disconnect") { sdk.disconnect() } + .buttonStyle(.bordered) + .tint(.red) + } + } else if sdk.btState == "connecting" { + HStack { + ProgressView() + Text("Connecting...") + } + } else { + Text("No device connected") + .foregroundStyle(.secondary) + } + } + } + + // MARK: - File + + private var fileSection: some View { + Section("File Transfer") { + if sdk.btState == "connected" { + if sdk.transferProgress > 0 { + VStack(alignment: .leading) { + Text("Transfer: \(sdk.transferProgress)%") + .font(.caption) + ProgressView(value: Double(sdk.transferProgress), total: 100) + } + } + + HStack { + TextField("File key to delete", text: $fileKey) + .textFieldStyle(.roundedBorder) + Button("Delete") { sdk.deleteFile(key: fileKey) } + .buttonStyle(.bordered) + .tint(.red) + .disabled(fileKey.isEmpty) + } + } else { + Text("Connect a device first") + .foregroundStyle(.secondary) + } + } + } + + // MARK: - Log + + private var logSection: some View { + Section("Event Log (\(sdk.logs.count))") { + ForEach(sdk.logs.prefix(50)) { entry in + VStack(alignment: .leading, spacing: 2) { + Text(entry.message) + .font(.caption) + Text(entry.time, style: .time) + .font(.caption2) + .foregroundStyle(.tertiary) + } + } + + if sdk.logs.isEmpty { + Text("No events yet") + .foregroundStyle(.secondary) + } + } + } + + // MARK: - Helpers + + private func formatStorage(free: Int, total: Int) -> String { + let freeMB = free / 1_048_576 + let totalMB = total / 1_048_576 + return "\(freeMB)MB / \(totalMB)MB" + } +} diff --git a/DuooomiTest/Sources/DuooomiTestApp.swift b/DuooomiTest/Sources/DuooomiTestApp.swift new file mode 100644 index 0000000..fc242c9 --- /dev/null +++ b/DuooomiTest/Sources/DuooomiTestApp.swift @@ -0,0 +1,24 @@ +import SwiftUI +import DuooomiBleSDK + +class AppDelegate: ExpoBrownfieldAppDelegate { + override func application( + _ application: UIApplication, + didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil + ) -> Bool { + SDKManager.shared.initialize() + return super.application(application, didFinishLaunchingWithOptions: launchOptions) + } +} + +@main +struct DuooomiTestApp: App { + @UIApplicationDelegateAdaptor(AppDelegate.self) var appDelegate + + var body: some Scene { + WindowGroup { + ContentView() + .environmentObject(SDKManager.shared) + } + } +} diff --git a/DuooomiTest/Sources/SDKManager.swift b/DuooomiTest/Sources/SDKManager.swift new file mode 100644 index 0000000..ee23790 --- /dev/null +++ b/DuooomiTest/Sources/SDKManager.swift @@ -0,0 +1,229 @@ +import UIKit +import Combine +import DuooomiBleSDK + +struct DiscoveredDevice: Identifiable { + let id: String + let name: String + let rssi: Int +} + +struct DeviceInfo { + let brand: String + let screenSize: String + let freeSpace: Int + let totalSpace: Int + let batteryLevel: Int +} + +struct LogEntry: Identifiable { + let id = UUID() + let time: Date + let message: String +} + +@MainActor +class SDKManager: ObservableObject { + static let shared = SDKManager() + + // State + @Published var btState = "idle" + @Published var connectedDevice: [String: Any]? = nil + @Published var deviceInfo: DeviceInfo? = nil + @Published var version = "" + @Published var isActivated = false + @Published var transferProgress: Int = 0 + @Published var error: String? = nil + @Published var discoveredDevices: [DiscoveredDevice] = [] + @Published var logs: [LogEntry] = [] + @Published var isSDKReady = false + + private var cancellables = Set() + private var listenerIds: [String] = [] + + private init() {} + + func initialize() { + ReactNativeHostManager.shared.initialize() + + // 加载 RN 视图启动 JS 运行时 + do { + let view = try ReactNativeHostManager.shared.loadView( + moduleName: "main", initialProps: nil, launchOptions: nil) + // 保持引用防止释放 + _rnView = view + log("SDK initialized, RN runtime started") + isSDKReady = true + } catch { + log("SDK init failed: \(error.localizedDescription)") + } + + setupSubscriptions() + setupMessageListener() + } + + private var _rnView: UIView? + + // MARK: - State Subscriptions + + private func setupSubscriptions() { + BrownfieldState.subscribe("btState") { [weak self] value in + DispatchQueue.main.async { + let state = value as? String ?? "idle" + self?.btState = state + self?.log("btState → \(state)") + } + }.store(in: &cancellables) + + BrownfieldState.subscribe("connectedDevice") { [weak self] value in + DispatchQueue.main.async { + self?.connectedDevice = value as? [String: Any] + if let device = value as? [String: Any] { + self?.log("connectedDevice → \(device["name"] ?? "unknown")") + } else { + self?.log("connectedDevice → nil") + } + } + }.store(in: &cancellables) + + BrownfieldState.subscribe("deviceInfo") { [weak self] value in + DispatchQueue.main.async { + if let dict = value as? [String: Any] { + self?.deviceInfo = DeviceInfo( + brand: dict["brand"] as? String ?? "", + screenSize: dict["screenSize"] as? String ?? "", + freeSpace: dict["freeSpace"] as? Int ?? 0, + totalSpace: dict["totalSpace"] as? Int ?? 0, + batteryLevel: dict["batteryLevel"] as? Int ?? 0 + ) + self?.log("deviceInfo updated") + } else { + self?.deviceInfo = nil + } + } + }.store(in: &cancellables) + + BrownfieldState.subscribe("version") { [weak self] value in + DispatchQueue.main.async { + self?.version = value as? String ?? "" + if let v = value as? String { + self?.log("version → \(v)") + } + } + }.store(in: &cancellables) + + BrownfieldState.subscribe("isActivated") { [weak self] value in + DispatchQueue.main.async { + self?.isActivated = value as? Bool ?? false + } + }.store(in: &cancellables) + + BrownfieldState.subscribe("transferProgress") { [weak self] value in + DispatchQueue.main.async { + self?.transferProgress = value as? Int ?? 0 + } + }.store(in: &cancellables) + + BrownfieldState.subscribe("error") { [weak self] value in + DispatchQueue.main.async { + self?.error = value as? String + if let e = value as? String { + self?.log("ERROR: \(e)") + } + } + }.store(in: &cancellables) + + BrownfieldState.subscribe("discoveredDevices") { [weak self] value in + DispatchQueue.main.async { + if let arr = value as? [[String: Any]] { + self?.discoveredDevices = arr.compactMap { dict in + guard let id = dict["id"] as? String else { return nil } + return DiscoveredDevice( + id: id, + name: dict["name"] as? String ?? "Unknown", + rssi: dict["rssi"] as? Int ?? 0 + ) + } + } else { + self?.discoveredDevices = [] + } + } + }.store(in: &cancellables) + } + + // MARK: - Message Listener + + private func setupMessageListener() { + let id = BrownfieldMessaging.addListener { [weak self] message in + DispatchQueue.main.async { + let event = message["event"] as? String ?? "unknown" + self?.log("Event: \(event) — \(message)") + } + } + listenerIds.append(id) + } + + // MARK: - Actions + + func scan() { + log("→ scan") + BrownfieldMessaging.sendMessage(["action": "scan"]) + } + + func stopScan() { + log("→ stopScan") + BrownfieldMessaging.sendMessage(["action": "stopScan"]) + } + + func connect(deviceId: String) { + log("→ connect \(deviceId)") + BrownfieldMessaging.sendMessage(["action": "connect", "deviceId": deviceId]) + } + + func disconnect() { + log("→ disconnect") + BrownfieldMessaging.sendMessage(["action": "disconnect"]) + } + + func bind(userId: String) { + log("→ bind \(userId)") + BrownfieldMessaging.sendMessage(["action": "bind", "userId": userId]) + } + + func unbind(userId: String) { + log("→ unbind \(userId)") + BrownfieldMessaging.sendMessage(["action": "unbind", "userId": userId]) + } + + func getDeviceInfo() { + log("→ getDeviceInfo") + BrownfieldMessaging.sendMessage(["action": "getDeviceInfo"]) + } + + func getVersion() { + log("→ getVersion") + BrownfieldMessaging.sendMessage(["action": "getVersion"]) + } + + func deleteFile(key: String) { + log("→ deleteFile \(key)") + BrownfieldMessaging.sendMessage(["action": "deleteFile", "key": key]) + } + + func transferFile(fileUri: String, commandType: Int) { + log("→ transferFile \(fileUri)") + BrownfieldMessaging.sendMessage([ + "action": "transferFile", + "fileUri": fileUri, + "commandType": commandType + ]) + } + + // MARK: - Logging + + private func log(_ message: String) { + let entry = LogEntry(time: Date(), message: message) + logs.insert(entry, at: 0) + if logs.count > 200 { logs.removeLast() } + } +} diff --git a/README.md b/README.md new file mode 100644 index 0000000..eebb2ab --- /dev/null +++ b/README.md @@ -0,0 +1,52 @@ +# DuooomiTest + +DuooomiBleSDK 的 iOS 测试应用,用于验证蓝牙设备的扫描、连接、绑定、文件传输等功能。 + +## 环境要求 + +- Xcode 26+ +- iOS 16.0+ +- Swift 6.0 +- [XcodeGen](https://github.com/yonaskolb/XcodeGen) + +## 项目依赖 + +- `DuooomiBleSDK.xcframework` — 蓝牙通信 SDK +- `hermesvm.xcframework` — JS 运行时 + +依赖文件位于 `../duooomi-ble-sdk/artifacts/`,请确保该路径下存在对应的 xcframework。 + +## 快速开始 + +```bash +# 1. 安装 XcodeGen(如果还没有) +brew install xcodegen + +# 2. 生成 Xcode 项目 +xcodegen generate + +# 3. 打开项目 +open DuooomiTest.xcodeproj +``` + +在 Xcode 中设置你的 `DEVELOPMENT_TEAM`,然后运行到真机即可(蓝牙功能需要真机调试)。 + +## 项目结构 + +``` +├── project.yml # XcodeGen 配置 +├── DuooomiTest/ +│ ├── Info.plist +│ └── Sources/ +│ ├── DuooomiTestApp.swift # App 入口 +│ ├── ContentView.swift # 测试界面 +│ └── SDKManager.swift # SDK 封装层 +``` + +## 功能 + +- 蓝牙设备扫描与连接 +- 设备信息查询(品牌、屏幕、电量、存储) +- 用户绑定/解绑 +- 文件传输与删除 +- 实时事件日志 diff --git a/project.yml b/project.yml new file mode 100644 index 0000000..b0fe577 --- /dev/null +++ b/project.yml @@ -0,0 +1,38 @@ +name: DuooomiTest +options: + bundleIdPrefix: com.duooomi + deploymentTarget: + iOS: "16.0" + xcodeVersion: "26.0" + generateEmptyDirectories: true + +settings: + base: + SWIFT_VERSION: "6.0" + CODE_SIGN_STYLE: Automatic + DEVELOPMENT_TEAM: "" + +targets: + DuooomiTest: + type: application + platform: iOS + sources: + - path: DuooomiTest/Sources + info: + path: DuooomiTest/Info.plist + properties: + CFBundleDisplayName: Duooomi Test + CFBundleShortVersionString: "1.0.0" + CFBundleVersion: "1" + NSBluetoothAlwaysUsageDescription: "This app needs Bluetooth to communicate with your device" + UILaunchScreen: {} + settings: + base: + PRODUCT_BUNDLE_IDENTIFIER: com.duooomi.test + PRODUCT_NAME: DuooomiTest + LD_RUNPATH_SEARCH_PATHS: "$(inherited) @executable_path/Frameworks" + dependencies: + - framework: ../duooomi-ble-sdk/artifacts/DuooomiBleSDK.xcframework + embed: true + - framework: ../duooomi-ble-sdk/artifacts/hermesvm.xcframework + embed: true