Stories

Detail Return Return

Rokid Glasses 移動端控制應用開發初體驗-助力業務創新 - Stories Detail

前言

在AI時代,一方面大家在提升模型這個”大腦“的能力,另一方面也在不斷地給”大腦“配備各種”外設“,錄音筆和AI眼鏡就是很好的切入點。而AI眼鏡因為與人眼、人耳處在同一個角度,可以以更自然真實的角度去採集音頻與視頻,"第一視角拍攝"和"長在眼前的AI助手"成為大家採購智能設備的首選。本文介紹AI眼鏡的佼佼者Rokid Glasses的產品、能力,以及如何從零開發一個Rokid Glasses配套的手機應用,實現銷售與客户溝通過程分析,幫助銷售人員提效。

項目介紹

在企業銷售場景中,銷售人員與客户面對面的溝通包含大量隱性信息:

  • 客户的語氣變化、提問重點、興趣方向;
  • 對價格、服務、品牌等要素的關注程度;
  • 情緒波動與潛在購買意向。
    這些關鍵信息往往無法被實時記錄,事後人工回憶也存在大量偏差。
    因此,我們希望利用 Rokid 設備的語音採集能力 和 App 的控制與數據傳輸能力,構建一套智能銷售助手系統,讓銷售過程“可聽、可見、可分析”。

Rokid 開發者工具介紹

Rokid提供了兩種類型設備Rokid AR 與 Rokid Glasses,分別對應 YodaOS-Master和YodaOS-Sprite系統,本文主要介紹基於Rokid Glasses 配套CXR-M SDK 開發手機應用。

CXR-M SDK 是面向移動端的開發工具包,主要用於構建手機端與 Rokid Glasses 的控制和協同應用。開發者可以通過 CXR-M SDK 與眼鏡建立穩定連接,實現數據通信、實時音視頻獲取以及場景自定義。它適合需要在手機端進行界面交互、遠程控制或與眼鏡端配合完成複雜功能的應用。目前 CXR-M SDK 僅提供 Android 版本,正好作為一名Androider快速上手一波。

首先用一張官方的圖片來介紹眼鏡設備與手機之間的關係:
image.png

從圖中可以看到,眼鏡是一個基於AOSP的操作系統,手機通過Rokid Glasses Protocol與眼鏡設備來交互。

通過CXR-M SDK可以與眼鏡進行如下信息交互:

  • 獲取眼鏡設備系統信息
  • 設備連接
  • AI 能力
  • 錄音服務
  • 拍照服務
  • 錄像服務
  • 飛傳功能
  • ...

系統總體架構

image.png

  • Rokid 設備:負責語音採集與錄音;
  • App 端(核心開發部分):控制錄音、下載文件、上傳雲端;
  • 雲端服務:完成語音識別與畫像分析。
    本文主要介紹App端功能開發,APP主要包含以下功能:
  • 掃描周圍rokid glasses設備列表
  • 連接某個設備
  • 斷開設備連接
  • 停止掃描
  • 開始錄音
  • 結束錄音
  • 下載glasses端錄音文件到手機
  • 刪除glasses端錄音文件

App 端與 Rokid SDK 模塊設計與開發

很多眼鏡都是配置自己應用,只針對C端用户開發,不支持企業和開發者開發應用對接,Rokid天生支持開發者定製開發,不管是對個人開發者基於硬件創新還是針對企業業務賦能,都提供了很好的支持。接下來基於Rokid開發Rokid Glasses配套的手機應用。

創建項目

在瞭解完CXR-M SDK能力後,接下來我們基於CXR-M SDK開發一個屬於我們自己的應用。
首先創建項目,在Android Studio中新建項目:
image.png

接着輸入項目名稱、包名、存儲路徑等,Minimum選擇18,CXR-M SDK最低支持Android 9.0,BuildConfigLanguage選擇Kotlin DSL,CXR-M SDK選用Kotlin語言:
image.png

點擊Finish按鈕完成項目創建,接下來在settings.gradle.kts中配置阿里雲鏡像,更快的sync工程。

repositories {  
  maven { url = uri("https://maven.aliyun.com/repository/google") }  
  maven { url = uri("https://maven.aliyun.com/repository/central") }  
  maven { url = uri("https://maven.aliyun.com/repository/gradle-plugin") }  
  google {  
    content {  
      includeGroupByRegex("com\\.android.*")  
      includeGroupByRegex("com\\.google.*")  
      includeGroupByRegex("androidx.*")  
    }  
  }  
  mavenCentral()  
  gradlePluginPortal()  
}

配置依賴

CXR-M SDK 採用Maven 在線管理SDK Package,在settings.gradle.kts添加maven倉庫配置,maven { url = uri("https://maven.rokid.com/repository/maven-public/") }

image.png

增加配置後同步工程,同步成功後添加CXR-M SDK依賴:implementation("com.rokid.cxr:client-m:1.0.1-20250812.080117-2") 除了依賴rokid client-m SDK,還需要依賴它的依賴:

implementation ("com.squareup.retrofit2:retrofit:2.9.0")
implementation ("com.squareup.retrofit2:converter-gson:2.9.0")
implementation ("com.squareup.okhttp3:okhttp:4.9.3")
implementation ("org.jetbrains.kotlin:kotlin-stdlib:2.1.0")
implementation ("com.squareup.okio:okio:2.8.0")
implementation ("com.google.code.gson:gson:2.10.1")
implementation ("com.squareup.okhttp3:logging-interceptor:4.9.1")

image.png

配置完成後同步工程,同步成功後就可以使用CXR-M SDK進行功能開發了。

掃描連接設備

Rokid Glasses 使用藍牙與手機通信,手機端做任何動作都需要與眼鏡端建立藍牙連接,首先需要通過Android系統標準藍牙接口掃描發現周邊設備。CXR-M SDK通過Android系統API掃描發現設備,掃描過程中可以使用UUID:00009100-0000-1000-8000-00805f9b34fb,來過濾Rokid 的設備。連接設備需要執行兩步操作:

  1. 掃描設備列表
  2. 選擇設備連接

在我們頁面中增加掃描設備、停止掃描按鈕,以列表形式展示掃描到的設備列表,選中某個設備點擊連接和進行手機與設備的藍牙連接。
這裏簡單介紹下UUID,BLE(Bluetooth Low Energy,藍牙低功耗)UUID(Universally Unique Identifier,通用唯一標識符)是BLE協議棧中核心標識元素,本質是一個128位的數字標籤,用於唯一標識BLE設備中的服務(Service)、特性(Characteristic)和描述符(Descriptor)。它確保了不同設備、同一設備的不同功能模塊在全球範圍內具有唯一性,是BLE設備實現互聯互通的基礎——就像每個人的身份證號碼一樣,BLE UUID讓設備能準確識別並交互所需的功能。

為簡化開發並促進跨設備兼容,藍牙特殊興趣小組(SIG)定義了大量標準服務​(如心率監測、電池電量、設備信息)的UUID,採用“16位短UUID”形式(部分場景需擴展為128位)。這些標準UUID是全球BLE設備的“通用語言”,例如:

  • 心率服務​:16位UUID為0x180D,對應全128位UUID為0000180D-0000-1000-8000-00805F9B34FB,用於標識設備的心率監測功能;
  • 電池服務​:16位UUID為0x180F,對應全128位UUID為0000180F-0000-1000-8000-00805F9B34FB,用於標識設備的電池電量信息;
  • 設備信息服務​:16位UUID為0x180A,對應全128位UUID為0000180A-0000-1000-8000-00805F9B34FB,用於標識設備的基本信息(如製造商、型號)。 標準UUID的存在,讓不同廠商的BLE設備(如智能手環、健康監測儀)能無縫交互——例如,任何支持心率服務的手機都能識別並讀取0x180D服務下的心率數據。Rokid Glasses使用的UUID為00009100-0000-1000-8000-00805f9b34fb。

頁面對應的ViewModel實現如下:

import android.Manifest
import android.annotation.SuppressLint
import android.app.Application
import android.bluetooth.BluetoothAdapter
import android.bluetooth.BluetoothDevice
import android.bluetooth.BluetoothManager
import android.bluetooth.le.ScanCallback
import android.bluetooth.le.ScanFilter
import android.bluetooth.le.ScanResult
import android.bluetooth.le.ScanSettings
import android.content.Context
import android.os.ParcelUuid
import android.util.Log
import androidx.annotation.RequiresPermission
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.viewModelScope
import com.qingkouwei.rokidclient.controller.RokidRecorderController
import com.qingkouwei.rokidclient.model.Device
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import kotlinx.coroutines.Dispatchers
import java.util.concurrent.ConcurrentHashMap
import com.rokid.cxr.client.extend.CxrApi
import com.rokid.cxr.client.extend.callbacks.BluetoothStatusCallback
import com.rokid.cxr.client.utils.ValueUtil

/**
 * 設備掃描 ViewModel
 * 管理設備掃描和連接邏輯
 */
class DeviceScanViewModel(application: Application) : AndroidViewModel(application) {
    
    companion object {
        private const val TAG = "DeviceScanViewModel"
        private const val ROKID_SERVICE_UUID = "00009100-0000-1000-8000-00805f9b34fb" // Rokid Glasses Service
    }
    
    private val controller = RokidRecorderController(application)
    private val context = application.applicationContext
    
    // 藍牙相關
    private var bluetoothAdapter: BluetoothAdapter? = null
    private var bluetoothLeScanner: android.bluetooth.le.BluetoothLeScanner? = null
    
    // 掃描結果存儲
    private val scanResultMap: ConcurrentHashMap<String, BluetoothDevice> = ConcurrentHashMap()
    private val bondedDeviceMap: ConcurrentHashMap<String, BluetoothDevice> = ConcurrentHashMap()
    
    // 掃描狀態
    private val _isScanning = MutableLiveData<Boolean>()
    val isScanning: LiveData<Boolean> = _isScanning
    
    // 設備列表
    private val _devices = MutableLiveData<List<Device>>()
    val devices: LiveData<List<Device>> = _devices
    
    // 連接狀態
    private val _isConnecting = MutableLiveData<Boolean>()
    val isConnecting: LiveData<Boolean> = _isConnecting
    
    // 連接結果
    private val _connectionResult = MutableLiveData<Boolean?>()
    val connectionResult: LiveData<Boolean?> = _connectionResult
    
    // 狀態消息
    private val _statusMessage = MutableLiveData<String>()
    val statusMessage: LiveData<String> = _statusMessage
    
    // 連接信息
    private val _connectionInfo = MutableLiveData<ConnectionInfo?>()
    val connectionInfo: LiveData<ConnectionInfo?> = _connectionInfo
    
    // 當前連接的設備
    private val _currentConnectedDevice = MutableLiveData<Device?>()
    val currentConnectedDevice: LiveData<Device?> = _currentConnectedDevice
    
    
    // 連接信息數據類
    data class ConnectionInfo(
        val socketUuid: String,
        val macAddress: String,
        val rokidAccount: String?,
        val glassesType: Int
    )
    
    
    // Rokid CXR SDK 藍牙狀態回調
    private val bluetoothStatusCallback = object : BluetoothStatusCallback {
        override fun onConnectionInfo(
            socketUuid: String?,
            macAddress: String?,
            rokidAccount: String?,
            glassesType: Int
        ) {
            Log.d(TAG, "收到連接信息: socketUuid=$socketUuid, macAddress=$macAddress, rokidAccount=$rokidAccount, glassesType=$glassesType")
            
            socketUuid?.let { uuid ->
                macAddress?.let { address ->
                    val connectionInfo = ConnectionInfo(
                        socketUuid = uuid,
                        macAddress = address,
                        rokidAccount = rokidAccount,
                        glassesType = glassesType
                    )
                    _connectionInfo.value = connectionInfo
                    
                    // 自動進行連接
                    connectBluetooth(uuid, address)
                } ?: run {
                    Log.e(TAG, "macAddress is null")
                    _statusMessage.value = "設備 MAC 地址為空"
                }
            } ?: run {
                Log.e(TAG, "socketUuid is null")
                _statusMessage.value = "設備 UUID 為空"
            }
        }

        override fun onConnected() {
            Log.d(TAG, "藍牙連接成功")
            _statusMessage.value = "藍牙連接成功"
            _connectionResult.value = true
            _isConnecting.value = false
        }

        override fun onDisconnected() {
            Log.d(TAG, "藍牙連接斷開")
            _statusMessage.value = "藍牙連接斷開"
            _connectionResult.value = false
            _isConnecting.value = false
            _currentConnectedDevice.value = null
        }

        override fun onFailed(errorCode: ValueUtil.CxrBluetoothErrorCode?) {
            Log.e(TAG, "藍牙連接失敗: $errorCode")
            val errorMessage = when (errorCode) {
                ValueUtil.CxrBluetoothErrorCode.PARAM_INVALID -> "參數無效"
                ValueUtil.CxrBluetoothErrorCode.BLE_CONNECT_FAILED -> "BLE 連接失敗"
                ValueUtil.CxrBluetoothErrorCode.SOCKET_CONNECT_FAILED -> "Socket 連接失敗"
                ValueUtil.CxrBluetoothErrorCode.UNKNOWN -> "未知錯誤"
                else -> "連接失敗"
            }
            _statusMessage.value = "藍牙連接失敗: $errorMessage"
            _connectionResult.value = false
            _isConnecting.value = false
        }
    }
    
    // 藍牙掃描回調
    private val scanCallback = object : ScanCallback() {
        @SuppressLint("MissingPermission")
        override fun onScanResult(callbackType: Int, result: ScanResult?) {
            super.onScanResult(callbackType, result)
            result?.let { scanResult ->
                scanResult.device.name?.let { deviceName ->
                    // 檢查是否是 Rokid 設備
                    if (deviceName.contains("Rokid", ignoreCase = true) || 
                        deviceName.contains("Glasses", ignoreCase = true)) {
                        scanResultMap[deviceName] = scanResult.device
                        val rssi = scanResult.rssi
                        updateDeviceList()
                        Log.d(TAG, "發現 Rokid 設備: $deviceName")
                    }
                }
            }
        }
        
        override fun onScanFailed(errorCode: Int) {
            super.onScanFailed(errorCode)
            Log.e(TAG, "掃描失敗,錯誤代碼: $errorCode")
            _statusMessage.value = "掃描失敗,錯誤代碼: $errorCode"
            _isScanning.value = false
        }
    }
    
    init {
        _devices.value = emptyList()
        _isScanning.value = false
        _isConnecting.value = false
        _statusMessage.value = "點擊開始掃描設備"
        
        // 初始化藍牙適配器
        initializeBluetooth()
    }
    
    /**
     * 初始化藍牙適配器
     */
    private fun initializeBluetooth() {
        try {
            val bluetoothManager = context.getSystemService(Context.BLUETOOTH_SERVICE) as BluetoothManager
            bluetoothAdapter = bluetoothManager.adapter
            
            if (bluetoothAdapter == null) {
                Log.e(TAG, "設備不支持藍牙")
                _statusMessage.value = "設備不支持藍牙"
                return
            }
            
            if (!bluetoothAdapter!!.isEnabled) {
                Log.w(TAG, "藍牙未開啓")
                _statusMessage.value = "請先開啓藍牙"
                return
            }
            
            bluetoothLeScanner = bluetoothAdapter!!.bluetoothLeScanner
            Log.d(TAG, "藍牙初始化成功")
            _statusMessage.value = "藍牙已就緒,可以開始掃描"
            
        } catch (e: Exception) {
            Log.e(TAG, "藍牙初始化失敗", e)
            _statusMessage.value = "藍牙初始化失敗: ${e.message}"
        }
    }
    
    /**
     * 開始掃描設備
     */
    fun startScanning() {
        if (_isScanning.value == true) return
        
        if (bluetoothLeScanner == null) {
            _statusMessage.value = "藍牙未初始化,請檢查藍牙狀態"
            return
        }
        
        _isScanning.value = true
        _statusMessage.value = "正在掃描附近的Rokid設備..."
        
        viewModelScope.launch {
            try {
                // 清空之前的掃描結果
                scanResultMap.clear()
                
                // 先檢查已配對的設備
                checkBondedDevices()
                
                // 開始藍牙 LE 掃描
                startBluetoothLeScan()
                
            } catch (e: Exception) {
                Log.e(TAG, "開始掃描失敗", e)
                _statusMessage.value = "掃描失敗: ${e.message}"
                _isScanning.value = false
            }
        }
    }
    
    /**
     * 檢查已配對的設備
     */
    @SuppressLint("MissingPermission")
    private fun checkBondedDevices() {
        bluetoothAdapter?.bondedDevices?.forEach { device ->
            device.name?.let { deviceName ->
                if (deviceName.contains("Rokid", ignoreCase = true) || 
                    deviceName.contains("Glasses", ignoreCase = true)) {
                    bondedDeviceMap[deviceName] = device
                    Log.d(TAG, "發現已配對的 Rokid 設備: $deviceName")
                }
            }
        }
        updateDeviceList()
    }
    
    /**
     * 開始藍牙 LE 掃描
     */
    @SuppressLint("MissingPermission")
    @RequiresPermission(Manifest.permission.BLUETOOTH_SCAN)
    private fun startBluetoothLeScan() {
        try {
            val scanFilter = ScanFilter.Builder()
                .setServiceUuid(ParcelUuid.fromString(ROKID_SERVICE_UUID))
                .build()
            
            val scanSettings = ScanSettings.Builder()
                .setScanMode(ScanSettings.SCAN_MODE_LOW_LATENCY)
                .build()
            
            bluetoothLeScanner?.startScan(
                listOf(scanFilter),
                scanSettings,
                scanCallback
            )
            
            Log.d(TAG, "開始藍牙 LE 掃描")
            
        } catch (e: Exception) {
            Log.e(TAG, "啓動藍牙 LE 掃描失敗", e)
            _statusMessage.value = "啓動掃描失敗: ${e.message}"
            _isScanning.value = false
        }
    }
    
    /**
     * 停止掃描
     */
    @RequiresPermission(Manifest.permission.BLUETOOTH_SCAN)
    fun stopScanning() {
        _isScanning.value = false
        _statusMessage.value = "掃描已停止"
        
        try {
            bluetoothLeScanner?.stopScan(scanCallback)
            Log.d(TAG, "停止藍牙 LE 掃描")
        } catch (e: Exception) {
            Log.e(TAG, "停止掃描失敗", e)
            _statusMessage.value = "停止掃描失敗: ${e.message}"
        }
    }
    
    /**
     * 更新設備列表
     */
    private fun updateDeviceList() {
        val deviceList = mutableListOf<Device>()
        
        // 添加掃描到的設備
        scanResultMap.forEach { (deviceName, bluetoothDevice) ->
            val device = Device(
                deviceId = bluetoothDevice.address,
                deviceName = deviceName,
                ipAddress = bluetoothDevice.address,
                isConnected = false,
                batteryLevel = 0, // 藍牙設備無法直接獲取電量
                signalStrength = 0 // 藍牙設備無法直接獲取信號強度
            )
            deviceList.add(device)
        }
        
        // 添加已配對的設備
        bondedDeviceMap.forEach { (deviceName, bluetoothDevice) ->
            val device = Device(
                deviceId = bluetoothDevice.address,
                deviceName = deviceName,
                ipAddress = bluetoothDevice.address,
                isConnected = false,
                batteryLevel = 0,
                signalStrength = 0
            )
            // 避免重複添加
            if (deviceList.none { it.deviceId == device.deviceId }) {
                deviceList.add(device)
            }
        }
        
        _devices.value = deviceList
        
        if (deviceList.isEmpty()) {
            _statusMessage.value = "未發現設備,請確保設備已開啓"
        } else {
            _statusMessage.value = "發現 ${deviceList.size} 個設備"
        }
    }
    
    /**
     * 連接到指定設備
     */
    fun connectToDevice(device: Device) {
        if (_isConnecting.value == true) return
        
        _isConnecting.value = true
        _statusMessage.value = "正在連接到 ${device.deviceName}..."
        
        viewModelScope.launch {
            try {
                // 使用藍牙連接設備
                val success = connectBluetoothDevice(device)
                _connectionResult.value = success
                
                if (success) {
                    _statusMessage.value = "連接成功"
                } else {
                    _statusMessage.value = "連接失敗,請重試"
                }
            } catch (e: Exception) {
                _statusMessage.value = "連接失敗: ${e.message}"
                _connectionResult.value = false
            } finally {
                _isConnecting.value = false
            }
        }
    }
    
    /**
     * 使用藍牙連接設備 - 使用 Rokid CXR SDK
     */
    @SuppressLint("MissingPermission")
    @RequiresPermission(Manifest.permission.BLUETOOTH_CONNECT)
    private suspend fun connectBluetoothDevice(device: Device): Boolean = withContext(Dispatchers.IO) {
        try {
            // 查找對應的藍牙設備
            val bluetoothDevice = scanResultMap.values.find { it.address == device.deviceId }
                ?: bondedDeviceMap.values.find { it.address == device.deviceId }
            
            if (bluetoothDevice == null) {
                Log.e(TAG, "未找到對應的藍牙設備: ${device.deviceName}")
                return@withContext false
            }
            
            Log.d(TAG, "開始使用 Rokid CXR SDK 初始化藍牙設備: ${device.deviceName}")
            
            // 使用 Rokid CXR SDK 初始化藍牙
            initBluetooth(bluetoothDevice)
            
            // 設置當前連接的設備
            _currentConnectedDevice.value = device.copy(isConnected = true)
            
            true
        } catch (e: Exception) {
            Log.e(TAG, "連接藍牙設備失敗", e)
            false
        }
    }
    
    /**
     * 初始化藍牙 - 使用 Rokid CXR SDK
     */
    private fun initBluetooth(device: BluetoothDevice) {
        try {
            Log.d(TAG, "調用 CxrApi.initBluetooth")
            CxrApi.getInstance().initBluetooth(context, device, bluetoothStatusCallback)
        } catch (e: Exception) {
            Log.e(TAG, "初始化藍牙失敗", e)
            _statusMessage.value = "初始化藍牙失敗: ${e.message}"
            _connectionResult.value = false
            _isConnecting.value = false
        }
    }
    
    /**
     * 連接藍牙 - 使用 Rokid CXR SDK
     */
    private fun connectBluetooth(socketUuid: String, macAddress: String) {
        try {
            Log.d(TAG, "調用 CxrApi.connectBluetooth: socketUuid=$socketUuid, macAddress=$macAddress")
            CxrApi.getInstance().connectBluetooth(context, socketUuid, macAddress, bluetoothStatusCallback)
        } catch (e: Exception) {
            Log.e(TAG, "連接藍牙失敗", e)
            _statusMessage.value = "連接藍牙失敗: ${e.message}"
            _connectionResult.value = false
            _isConnecting.value = false
        }
    }
    
    
    
    
    
    
    /**
     * 釋放資源,Rokid CXR SDK 反初始化
     */
    @RequiresPermission(Manifest.permission.BLUETOOTH_SCAN)
    fun release() {
        try {
            // 停止掃描
            if (_isScanning.value == true) {
                stopScanning()
            }
            
            // 反初始化 Rokid CXR SDK 藍牙
            try {
                CxrApi.getInstance().deinitBluetooth()
                Log.d(TAG, "Rokid CXR SDK 藍牙已反初始化")
            } catch (e: Exception) {
                Log.e(TAG, "反初始化 Rokid CXR SDK 藍牙失敗", e)
            }
            
            // 清空掃描結果
            scanResultMap.clear()
            bondedDeviceMap.clear()
            
            // 重置狀態
            _devices.value = emptyList()
            _isScanning.value = false
            _isConnecting.value = false
            _connectionInfo.value = null
            _currentConnectedDevice.value = null
            _statusMessage.value = "資源已釋放"
            
            Log.d(TAG, "DeviceScanViewModel 資源已釋放")
            
        } catch (e: Exception) {
            Log.e(TAG, "釋放資源失敗", e)
        }
    }
    
    /**
     * 獲取藍牙連接狀態
     */
    fun isBluetoothConnected(): Boolean {
        return try {
            CxrApi.getInstance().isBluetoothConnected
        } catch (e: Exception) {
            Log.e(TAG, "獲取藍牙連接狀態失敗", e)
            false
        }
    }
    
    /**
     * 斷開藍牙連接
     */
    fun disconnectBluetooth() {
        try {
            CxrApi.getInstance().deinitBluetooth()
            _currentConnectedDevice.value = null
            _connectionInfo.value = null
            _statusMessage.value = "藍牙連接已斷開"
            Log.d(TAG, "藍牙連接已斷開")
        } catch (e: Exception) {
            Log.e(TAG, "斷開藍牙連接失敗", e)
        }
    }
    
}

點擊頁面中開始掃描,調用上面代碼中startScan方法開始掃描,方法中首選獲取已連接設備列表, BluetoothAdapter的bondedDevices標識所有已配對設備信息,通過過濾返回的已配對設備的isConnected字段來判斷已配對設備中哪些是已連接設備。接着根據設備名稱中是否包含Glasses來篩出眼鏡設備,更新到應用緩存。

接下來調用BluetoothLeScanner的startScan方法掃描周圍所有藍牙設備,並曬出UUID 為00009100-0000-1000-8000-00805f9b34fb的所有設備,上面提到過這是Rokid Glasses Service的標識。

獲取到掃描結果後可以從ScanResult中獲取設備的信號強度以及掃描到設備的設備名稱等信息。

點擊掃描到某個設備的連接按鈕後調用connectToDevice方法連接設備,最終調用rokid sdk中的CxrApi.getInstance().connectBluetooth方法與設備進行連接,此時我們的手機應用與某一台Glasses設備就建立了連接,可以進行其他操作了。

避坑指南:在開發手機應用與設備連接是基於藍牙的,需要申請對應權限:

<!-- 定位權限 -->  
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />  
<!-- 允許應用獲取精確的位置信息,精度較高,通常依賴GPS -->  
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />  
<!-- 藍牙權限 -->  
<uses-permission android:name="android.permission.BLUETOOTH" />  
<!-- 允許應用管理藍牙連接,如發現和配對新設備 -->  
<uses-permission android:name="android.permission.BLUETOOTH_ADMIN" />  
<!-- Android 12及以上新增,允許應用主動連接到藍牙設備 -->  
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT" />  
<!-- 藍牙掃描權限 -->  
<uses-permission android:name="android.permission.BLUETOOTH_SCAN" />  
<!-- 允許應用改變網絡連接狀態(如啓用/禁用數據連接) -->  
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />  
<!-- 允許應用獲取WiFi網絡狀態(如WiFi是否開啓、連接的網絡名稱) -->  
<uses-permission android:name="android.permission.CHANGE_NETWORK_STATE" />  
<!-- 允許應用改變WiFi狀態(如開啓/關閉WiFi、連接指定WiFi) -->  
<uses-permission android:name="android.permission.ACCESS_WIFI_STATE" />  
<uses-permission android:name="android.permission.CHANGE_WIFI_STATE" />  
<!-- 網絡權限 -->  
<uses-permission android:name="android.permission.INTERNET" />

其中ACCESS_FINE_LOCATION、Manifest.permission.BLUETOOTH、Manifest.permission.BLUETOOTH_ADMIN是敏感權限,需要動態申請。在使用SDK之前彈出授權對話框進行權限申請。

啓動錄音

手機應用與設備建立連接後,接下來我們通過調用SDK接口來啓動Glasses設備開始錄音。
可以通過CXR-M SDK 的fun openAudioRecord(codecType: Int, streamType: String?): ValueUtil.CxrStatus?接口,開啓錄音,通過fun closeAudioRecord(streamType: String): ValueUtil.CxrStatus?關閉錄音,並通過fun setVideoStreamListener(callback: AudioStreamListener)設置回調監聽錄音結果。

在控制頁面增加開始錄音、結束錄音、斷開連接、文件管理等按鈕,UI效果圖如下:

image.png

頁面中點擊搜索設備跳轉“掃描連接設備”小節中的掃描頁面,連接成功後開始錄音置為可以點擊,點擊後開始錄音。控制頁面對應ViewModel實現代碼如下:

package com.qingkouwei.rokidclient.viewmodel

import android.app.Application
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.viewModelScope
import com.qingkouwei.rokidclient.controller.RokidRecorderController
import com.qingkouwei.rokidclient.model.AnalysisResult
import com.qingkouwei.rokidclient.model.Device
import com.qingkouwei.rokidclient.model.Recording
import com.qingkouwei.rokidclient.network.MockAnalysisService
import com.rokid.cxr.client.extend.CxrApi
import com.rokid.cxr.client.extend.callbacks.BluetoothStatusCallback
import com.rokid.cxr.client.utils.ValueUtil
import com.rokid.cxr.client.extend.listeners.AudioStreamListener
import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.launch
import okhttp3.MediaType.Companion.toMediaTypeOrNull
import okhttp3.MultipartBody
import okhttp3.RequestBody.Companion.asRequestBody
import java.io.File
import java.io.FileOutputStream
import java.io.IOException
import java.text.SimpleDateFormat
import java.util.*

/**
 * 主界面 ViewModel
 * 管理設備連接、錄音控制和文件上傳分析
 */
class MainViewModel(application: Application) : AndroidViewModel(application) {
    
    private val recorderController = RokidRecorderController(application)
    private val analysisService = MockAnalysisService()
    
    // Rokid CXR SDK 實例
    private val cxrApi = CxrApi.getInstance()
    
    // 連接信息
    private val _connectionInfo = MutableLiveData<ConnectionInfo?>()
    val connectionInfo: LiveData<ConnectionInfo?> = _connectionInfo
    
    // 連接信息數據類
    data class ConnectionInfo(
        val socketUuid: String,
        val macAddress: String,
        val rokidAccount: String?,
        val glassesType: Int
    )
    
    // 設備相關狀態
    private val _devices = MutableLiveData<List<Device>>()
    val devices: LiveData<List<Device>> = _devices
    
    private val _isConnected = MutableLiveData<Boolean>()
    val isConnected: LiveData<Boolean> = _isConnected
    
    private val _currentDevice = MutableLiveData<Device?>()
    val currentDevice: LiveData<Device?> = _currentDevice
    
    // 錄音相關狀態
    private val _isRecording = MutableLiveData<Boolean>()
    val isRecording: LiveData<Boolean> = _isRecording
    
    private val _recordingDuration = MutableLiveData<Long>()
    val recordingDuration: LiveData<Long> = _recordingDuration
    
    private val _downloadProgress = MutableLiveData<Int>()
    val downloadProgress: LiveData<Int> = _downloadProgress
    
    // 錄音流類型
    private val _currentStreamType = MutableLiveData<String?>()
    val currentStreamType: LiveData<String?> = _currentStreamType
    
    // 錄音數據
    private val _audioData = MutableLiveData<ByteArray?>()
    val audioData: LiveData<ByteArray?> = _audioData
    
    // 錄音文件相關
    private var currentRecordingFile: File? = null
    private var fileOutputStream: FileOutputStream? = null
    private val recordingDirectory = File(application.getExternalFilesDir(null), "recordings")
    
    // 錄音文件路徑
    private val _currentRecordingPath = MutableLiveData<String?>()
    val currentRecordingPath: LiveData<String?> = _currentRecordingPath
    
    // 分析結果
    private val _analysisResult = MutableLiveData<AnalysisResult?>()
    val analysisResult: LiveData<AnalysisResult?> = _analysisResult
    
    // 狀態消息
    private val _statusMessage = MutableLiveData<String>()
    val statusMessage: LiveData<String> = _statusMessage
    
    // 加載狀態
    private val _isLoading = MutableLiveData<Boolean>()
    val isLoading: LiveData<Boolean> = _isLoading
    
    // 音頻流監聽器
    private val audioStreamListener = object : AudioStreamListener {
        override fun onStartAudioStream(codecType: Int, streamType: String?) {
            _currentStreamType.value = streamType
            _statusMessage.value = "錄音已開始: $streamType"
            
            // 創建錄音文件
            createRecordingFile(streamType)
        }

        override fun onAudioStream(data: ByteArray?, offset: Int, length: Int) {
            data?.let { audioData ->
                val validData = audioData.sliceArray(offset until offset + length)
                _audioData.value = validData
                
                // 實時寫入文件
                writeAudioDataToFile(validData)
            }
        }
    }
    
    init {
        observeControllerStates()
        initializeRecordingDirectory()
    }
    
    /**
     * 觀察控制器狀態變化
     */
    private fun observeControllerStates() {
        viewModelScope.launch {
            recorderController.isConnected.collect { connected ->
                _isConnected.value = connected
            }
        }
        
        viewModelScope.launch {
            recorderController.isRecording.collect { recording ->
                _isRecording.value = recording
            }
        }
        
        viewModelScope.launch {
            recorderController.currentDevice.collect { device ->
                _currentDevice.value = device
            }
        }
        
        viewModelScope.launch {
            recorderController.recordingDuration.collect { duration ->
                _recordingDuration.value = duration
            }
        }
        
        viewModelScope.launch {
            recorderController.downloadProgress.collect { progress ->
                _downloadProgress.value = progress
            }
        }
    }
    
    /**
     * 連接到指定設備
     */
    fun connectToDevice(device: Device) {
        viewModelScope.launch {
            try {
                _isLoading.value = true
                _statusMessage.value = "正在連接到 ${device.deviceName}..."
                
                val success = recorderController.connectToDevice(device)
                
                if (success) {
                    _statusMessage.value = "設備連接成功"
                } else {
                    _statusMessage.value = "設備連接失敗"
                }
                
            } catch (e: Exception) {
                _statusMessage.value = "連接異常: ${e.message}"
            } finally {
                _isLoading.value = false
            }
        }
    }
    
    /**
     * 開始錄音
     */
    fun startRecording(codecType: Int = 1, streamType: String = "AI_assistant") {
        viewModelScope.launch {
            try {
                _statusMessage.value = "開始錄音..."
                
                // 檢查藍牙連接狀態
                if (!cxrApi.isBluetoothConnected) {
                    _statusMessage.value = "藍牙未連接,無法開始錄音"
                    return@launch
                }
                
                // 設置音頻流監聽器
                cxrApi.setAudioStreamListener(audioStreamListener)
                
                // 開啓錄音
                val status = cxrApi.openAudioRecord(codecType, streamType)
                
                when (status) {
                    ValueUtil.CxrStatus.REQUEST_SUCCEED -> {
                        _isRecording.value = true
                        _currentStreamType.value = streamType
                        _statusMessage.value = "錄音已開始"
                    }
                    ValueUtil.CxrStatus.REQUEST_WAITING -> {
                        _statusMessage.value = "錄音請求等待中"
                    }
                    ValueUtil.CxrStatus.REQUEST_FAILED -> {
                        _statusMessage.value = "錄音開始失敗"
                    }
                    else -> {
                        _statusMessage.value = "錄音狀態未知: $status"
                    }
                }
                
            } catch (e: Exception) {
                _statusMessage.value = "錄音異常: ${e.message}"
            }
        }
    }
    
    /**
     * 停止錄音
     */
    fun stopRecording(streamType: String = "AI_assistant") {
        viewModelScope.launch {
            try {
                _statusMessage.value = "停止錄音..."
                
                // 檢查錄音狀態
                if (_isRecording.value != true) {
                    _statusMessage.value = "當前沒有進行錄音"
                    return@launch
                }
                
                // 關閉錄音
                val status = cxrApi.closeAudioRecord(streamType)
                
                when (status) {
                    ValueUtil.CxrStatus.REQUEST_SUCCEED -> {
                        _isRecording.value = false
                        _currentStreamType.value = null
                        _statusMessage.value = "錄音已停止"
                        
                        // 關閉錄音文件
                        closeRecordingFile()
                    }
                    ValueUtil.CxrStatus.REQUEST_WAITING -> {
                        _statusMessage.value = "錄音停止請求等待中"
                    }
                    ValueUtil.CxrStatus.REQUEST_FAILED -> {
                        _statusMessage.value = "錄音停止失敗"
                    }
                    else -> {
                        _statusMessage.value = "錄音停止狀態未知: $status"
                    }
                }
                
                // 移除音頻流監聽器
                cxrApi.setAudioStreamListener(null)
                
            } catch (e: Exception) {
                _statusMessage.value = "停止錄音異常: ${e.message}"
            }
        }
    }
    
    /**
     * 停止錄音並下載文件
     */
    fun stopRecordingAndDownload() {
        viewModelScope.launch {
            try {
                _isLoading.value = true
                _statusMessage.value = "停止錄音並下載文件..."
                
                val recording = recorderController.stopRecordingAndDownload()
                
                if (recording != null) {
                    _statusMessage.value = "錄音文件下載完成"
                    // 自動上傳並分析
                    uploadAndAnalyzeRecording(recording)
                } else {
                    _statusMessage.value = "錄音停止失敗"
                }
                
            } catch (e: Exception) {
                _statusMessage.value = "停止錄音異常: ${e.message}"
            } finally {
                _isLoading.value = false
            }
        }
    }
    
    /**
     * 上傳錄音文件並進行分析
     */
    private fun uploadAndAnalyzeRecording(recording: Recording) {
        viewModelScope.launch {
            try {
                _statusMessage.value = "正在上傳文件並分析..."
                
                val file = File(recording.localPath)
                if (!file.exists()) {
                    _statusMessage.value = "錄音文件不存在"
                    return@launch
                }
                
                // 創建 MultipartBody.Part
                val requestFile = file.asRequestBody("audio/wav".toMediaTypeOrNull())
                val audioPart = MultipartBody.Part.createFormData("audio", file.name, requestFile)
                
                // 調用分析服務
                val response = analysisService.uploadAudio(audioPart)
                
                if (response.isSuccessful) {
                    val result = response.body()
                    if (result != null) {
                        _analysisResult.value = result
                        _statusMessage.value = "分析完成"
                    } else {
                        _statusMessage.value = "分析結果為空"
                    }
                } else {
                    _statusMessage.value = "上傳失敗: ${response.code()}"
                }
                
            } catch (e: Exception) {
                _statusMessage.value = "上傳分析異常: ${e.message}"
            }
        }
    }
    
    /**
     * 斷開設備連接
     */
    fun disconnectDevice() {
        viewModelScope.launch {
            try {
                // 先停止錄音
                if (_isRecording.value == true) {
                    stopRecording()
                }
                
                // 斷開藍牙連接
                cxrApi.deinitBluetooth()
                
                // 重置狀態
                _isConnected.value = false
                _currentDevice.value = null
                _connectionInfo.value = null
                _currentRecordingPath.value = null
                _statusMessage.value = "設備已斷開連接"
                
            } catch (e: Exception) {
                _statusMessage.value = "斷開連接異常: ${e.message}"
            }
        }
    }
    
    /**
     * 格式化錄音時長顯示
     */
    fun formatDuration(durationMs: Long): String {
        val seconds = durationMs / 1000
        val minutes = seconds / 60
        val remainingSeconds = seconds % 60
        return String.format("%02d:%02d", minutes, remainingSeconds)
    }
    
    override fun onCleared() {
        super.onCleared()
        recorderController.cleanup()
        
        // 關閉錄音文件
        closeRecordingFile()
        
        // 釋放 Rokid CXR SDK 資源
        try {
            cxrApi.deinitBluetooth()
        } catch (e: Exception) {
            // 忽略異常
        }
    }
    
    /**
     * 初始化錄音目錄
     */
    private fun initializeRecordingDirectory() {
        try {
            if (!recordingDirectory.exists()) {
                recordingDirectory.mkdirs()
            }
        } catch (e: Exception) {
            android.util.Log.e("MainViewModel", "創建錄音目錄失敗", e)
        }
    }
    
    /**
     * 創建錄音文件
     */
    private fun createRecordingFile(streamType: String?) {
        try {
            val timestamp = SimpleDateFormat("yyyyMMdd_HHmmss", Locale.getDefault()).format(Date())
            val fileName = "recording_${streamType ?: "unknown"}_$timestamp.pcm"
            currentRecordingFile = File(recordingDirectory, fileName)
            
            fileOutputStream = FileOutputStream(currentRecordingFile)
            _currentRecordingPath.value = currentRecordingFile?.absolutePath
            
            android.util.Log.d("MainViewModel", "創建錄音文件: ${currentRecordingFile?.absolutePath}")
            
        } catch (e: Exception) {
            android.util.Log.e("MainViewModel", "創建錄音文件失敗", e)
            _statusMessage.value = "創建錄音文件失敗: ${e.message}"
        }
    }
    
    /**
     * 實時寫入音頻數據到文件
     */
    private fun writeAudioDataToFile(audioData: ByteArray) {
        try {
            fileOutputStream?.write(audioData)
            fileOutputStream?.flush()
        } catch (e: IOException) {
            android.util.Log.e("MainViewModel", "寫入音頻數據失敗", e)
            _statusMessage.value = "寫入音頻數據失敗: ${e.message}"
        }
    }
    
    /**
     * 關閉錄音文件
     */
    private fun closeRecordingFile() {
        try {
            fileOutputStream?.close()
            fileOutputStream = null
            
            val filePath = currentRecordingFile?.absolutePath
            android.util.Log.d("MainViewModel", "錄音文件已保存: $filePath")
            
            if (filePath != null) {
                _statusMessage.value = "錄音文件已保存: ${File(filePath).name}"
            }
            
        } catch (e: Exception) {
            android.util.Log.e("MainViewModel", "關閉錄音文件失敗", e)
            _statusMessage.value = "關閉錄音文件失敗: ${e.message}"
        } finally {
            currentRecordingFile = null
        }
    }
    
    /**
     * 獲取錄音文件大小
     */
    fun getRecordingFileSize(): Long {
        return currentRecordingFile?.length() ?: 0L
    }
    
    /**
     * 格式化文件大小顯示
     */
    fun formatFileSize(bytes: Long): String {
        return when {
            bytes < 1024 -> "$bytes B"
            bytes < 1024 * 1024 -> "${bytes / 1024} KB"
            bytes < 1024 * 1024 * 1024 -> "${bytes / (1024 * 1024)} MB"
            else -> "${bytes / (1024 * 1024 * 1024)} GB"
        }
    }
}
  • 點擊開始錄音按鈕調用stopRecording方法,Rokid Glasses錄製的音頻格式支持opus和pcm,都支持流式存儲,所以通過audioStreamListener可以在錄音過程中實時的將音頻信息下載到手機,邊錄音邊傳輸到手機。
  • 點擊結束錄音按鈕調用stopRecording方法,停止設備錄音。
  • 點擊斷開連接調用disconnectDevice斷開設備連接。

同步文件

上面提到可以邊錄邊將音頻傳輸到手機,但是如果錄音過程中手機與設備斷連,此時沒辦法實時下載音頻,可以在錄音結設備連接後主動從設備拉取音頻文件。另一方面錄音時一直連接設備會增加設備耗電,可以從硬件直接開啓錄音,錄音結束後統一下載文件。CXR-M SDK提供了獲取設備文件和同步文件的方法。點擊操作頁管理設備文件可以查看設備端文件。

設備文件頁面對應ViewModel代碼實現如下:

package com.qingkouwei.rokidclient.viewmodel

import android.app.Application
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.viewModelScope
import com.qingkouwei.rokidclient.controller.RokidRecorderController
import com.qingkouwei.rokidclient.model.DeviceFile
import com.qingkouwei.rokidclient.model.FileType
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import java.io.File
import java.text.SimpleDateFormat
import java.util.*
import android.util.Log
// 導入 Rokid CXR SDK
import com.rokid.cxr.client.extend.CxrApi
import com.rokid.cxr.client.extend.callbacks.UnsyncNumResultCallback
import com.rokid.cxr.client.extend.listeners.MediaFilesUpdateListener
import com.rokid.cxr.client.extend.callbacks.SyncStatusCallback
import com.rokid.cxr.client.extend.callbacks.WifiP2PStatusCallback
import com.rokid.cxr.client.utils.ValueUtil

/**
 * 設備文件管理 ViewModel
 * 管理設備文件列表、下載、上傳和刪除操作
 */
class DeviceFilesViewModel(application: Application) : AndroidViewModel(application) {
    
    companion object {
        private const val TAG = "DeviceFilesViewModel"
    }
    
    private val controller = RokidRecorderController(application)
    private val context = application.applicationContext
    
    // Rokid CXR SDK 實例
    private val cxrApi = CxrApi.getInstance()
    
    // 本地存儲目錄
    private val localStorageDir = File(context.getExternalFilesDir(null), "device_files")
    
    // 設備文件列表
    private val _deviceFiles = MutableLiveData<List<DeviceFile>>()
    val deviceFiles: LiveData<List<DeviceFile>> = _deviceFiles
    
    // 設備信息
    private val _deviceName = MutableLiveData<String>()
    val deviceName: LiveData<String> = _deviceName
    
    private val _fileCount = MutableLiveData<Int>()
    val fileCount: LiveData<Int> = _fileCount
    
    private val _storageInfo = MutableLiveData<String>()
    val storageInfo: LiveData<String> = _storageInfo
    
    // 未同步文件數量
    private val _unsyncAudioCount = MutableLiveData<Int>()
    val unsyncAudioCount: LiveData<Int> = _unsyncAudioCount
    
    private val _unsyncPictureCount = MutableLiveData<Int>()
    val unsyncPictureCount: LiveData<Int> = _unsyncPictureCount
    
    private val _unsyncVideoCount = MutableLiveData<Int>()
    val unsyncVideoCount: LiveData<Int> = _unsyncVideoCount
    
    // 操作狀態
    private val _isLoading = MutableLiveData<Boolean>()
    val isLoading: LiveData<Boolean> = _isLoading
    
    private val _isDownloading = MutableLiveData<Boolean>()
    val isDownloading: LiveData<Boolean> = _isDownloading
    
    private val _isUploading = MutableLiveData<Boolean>()
    val isUploading: LiveData<Boolean> = _isUploading
    
    // 同步狀態
    private val _isSyncing = MutableLiveData<Boolean>()
    val isSyncing: LiveData<Boolean> = _isSyncing
    
    private val _syncProgress = MutableLiveData<String>()
    val syncProgress: LiveData<String> = _syncProgress
    
    // Wi-Fi 連接狀態
    private val _isWifiConnected = MutableLiveData<Boolean>()
    val isWifiConnected: LiveData<Boolean> = _isWifiConnected
    
    private val _isWifiConnecting = MutableLiveData<Boolean>()
    val isWifiConnecting: LiveData<Boolean> = _isWifiConnecting
    
    // 狀態消息
    private val _statusMessage = MutableLiveData<String>()
    val statusMessage: LiveData<String> = _statusMessage
    
    // 下載進度
    private val _downloadProgress = MutableLiveData<Map<String, Int>>()
    val downloadProgress: LiveData<Map<String, Int>> = _downloadProgress
    
    // 上傳進度
    private val _uploadProgress = MutableLiveData<Map<String, Int>>()
    val uploadProgress: LiveData<Map<String, Int>> = _uploadProgress
    
    init {
        _deviceName.value = "Rokid Air Pro"
        _fileCount.value = 0
        _storageInfo.value = "存儲: 2.1GB/8GB"
        _deviceFiles.value = emptyList()
        _statusMessage.value = "點擊刷新獲取設備文件"
        
        // 初始化未同步文件數量
        _unsyncAudioCount.value = 0
        _unsyncPictureCount.value = 0
        _unsyncVideoCount.value = 0
        
        // 初始化同步狀態
        _isSyncing.value = false
        _syncProgress.value = ""
        
        // 初始化 Wi-Fi 狀態
        _isWifiConnected.value = false
        _isWifiConnecting.value = false
        
        // 初始化本地存儲目錄
        initializeLocalStorage()
        
        // 設置媒體文件更新監聽器
        setupMediaFilesUpdateListener()
    }
    
    /**
     * 刷新設備文件列表
     */
    fun refreshDeviceFiles() {
        viewModelScope.launch {
            try {
                _isLoading.value = true
                _statusMessage.value = "正在獲取設備文件..."
                
                // 檢查藍牙連接狀態
                if (!cxrApi.isBluetoothConnected) {
                    _statusMessage.value = "設備未連接,請先連接設備"
                    return@launch
                }
                
                // 獲取未同步文件數量
                getUnsyncFileCount()
                
                // 設置媒體文件更新監聽器
                setupMediaFilesUpdateListener()
                
                _statusMessage.value = "設備文件列表已更新"
                
            } catch (e: Exception) {
                Log.e(TAG, "獲取設備文件失敗", e)
                _statusMessage.value = "獲取文件失敗: ${e.message}"
            } finally {
                _isLoading.value = false
            }
        }
    }
    
    /**
     * 下載文件到本地
     */
    fun downloadFile(deviceFile: DeviceFile) {
        viewModelScope.launch {
            try {
                _isDownloading.value = true
                _statusMessage.value = "正在下載 ${deviceFile.fileName}..."
                
                // 檢查藍牙連接狀態
                if (!cxrApi.isBluetoothConnected) {
                    _statusMessage.value = "設備未連接,無法下載文件"
                    return@launch
                }
                
                // 檢查 Wi-Fi 連接狀態,如果未連接則先初始化 Wi-Fi
                if (!cxrApi.isWifiP2PConnected) {
                    _statusMessage.value = "正在初始化 Wi-Fi 連接..."
                    val wifiInitSuccess = initWifiConnection()
                    if (!wifiInitSuccess) {
                        _statusMessage.value = "Wi-Fi 連接失敗,無法下載文件"
                        return@launch
                    }
                }
                
                // 使用 Rokid SDK 同步單個文件
                val mediaType = when (deviceFile.fileType) {
                    FileType.AUDIO -> ValueUtil.CxrMediaType.AUDIO
                    FileType.VIDEO -> ValueUtil.CxrMediaType.VIDEO
                    else -> ValueUtil.CxrMediaType.AUDIO
                }
                
                val success = syncSingleFile(deviceFile.fileName, mediaType)
                
                if (success) {
                    _statusMessage.value = "下載成功: ${deviceFile.fileName}"
                    // 刷新文件列表
                    refreshDeviceFiles()
                } else {
                    _statusMessage.value = "下載失敗: ${deviceFile.fileName}"
                }
                
            } catch (e: Exception) {
                Log.e(TAG, "下載文件失敗", e)
                _statusMessage.value = "下載失敗: ${e.message}"
            } finally {
                _isDownloading.value = false
            }
        }
    }
    
    /**
     * 上傳文件到雲端
     */
    fun uploadFile(deviceFile: DeviceFile) {
        viewModelScope.launch {
            try {
                _isUploading.value = true
                _statusMessage.value = "正在上傳 ${deviceFile.fileName}..."
                
                // 檢查藍牙連接狀態
                if (!cxrApi.isBluetoothConnected) {
                    _statusMessage.value = "設備未連接,無法上傳文件"
                    return@launch
                }
                
                // 這裏可以實現上傳到雲端的邏輯
                // 暫時模擬上傳成功
                delay(2000) // 模擬上傳時間
                
                _statusMessage.value = "上傳成功: ${deviceFile.fileName}"
                
            } catch (e: Exception) {
                Log.e(TAG, "上傳文件失敗", e)
                _statusMessage.value = "上傳失敗: ${e.message}"
            } finally {
                _isUploading.value = false
            }
        }
    }
    
    /**
     * 批量下載所有文件
     */
    fun downloadAllFiles() {
        viewModelScope.launch {
            try {
                _isSyncing.value = true
                _statusMessage.value = "正在同步所有文件..."
                
                // 檢查藍牙連接狀態
                if (!cxrApi.isBluetoothConnected) {
                    _statusMessage.value = "設備未連接,無法同步文件"
                    return@launch
                }
                
                // 檢查 Wi-Fi 連接狀態,如果未連接則先初始化 Wi-Fi
                if (!cxrApi.isWifiP2PConnected) {
                    _statusMessage.value = "正在初始化 Wi-Fi 連接..."
                    val wifiInitSuccess = initWifiConnection()
                    if (!wifiInitSuccess) {
                        _statusMessage.value = "Wi-Fi 連接失敗,無法同步文件"
                        return@launch
                    }
                }
                
                // 同步所有類型的文件
                val types = arrayOf(
                    ValueUtil.CxrMediaType.AUDIO,
                    ValueUtil.CxrMediaType.PICTURE,
                    ValueUtil.CxrMediaType.VIDEO
                )
                
                val success = startSyncAllFiles(types)
                
                if (success) {
                    _statusMessage.value = "開始同步所有文件"
                } else {
                    _statusMessage.value = "同步失敗"
                    _isSyncing.value = false
                }
                
            } catch (e: Exception) {
                Log.e(TAG, "同步所有文件失敗", e)
                _statusMessage.value = "同步失敗: ${e.message}"
                _isSyncing.value = false
            }
        }
    }
    
    /**
     * 停止同步
     */
    fun stopSync() {
        try {
            cxrApi.stopSync()
            _isSyncing.value = false
            _statusMessage.value = "同步已停止"
            Log.d(TAG, "同步已停止")
        } catch (e: Exception) {
            Log.e(TAG, "停止同步失敗", e)
            _statusMessage.value = "停止同步失敗: ${e.message}"
        }
    }
    
    /**
     * 批量上傳所有文件
     */
    fun uploadAllFiles() {
        val files = _deviceFiles.value ?: return
        files.forEach { file ->
            uploadFile(file)
        }
    }
    
    /**
     * 初始化本地存儲目錄
     */
    private fun initializeLocalStorage() {
        try {
            if (!localStorageDir.exists()) {
                localStorageDir.mkdirs()
            }
            Log.d(TAG, "本地存儲目錄已初始化: ${localStorageDir.absolutePath}")
        } catch (e: Exception) {
            Log.e(TAG, "初始化本地存儲目錄失敗", e)
        }
    }
    
    /**
     * 設置媒體文件更新監聽器
     */
    private fun setupMediaFilesUpdateListener() {
        try {
            val mediaFileUpdateListener = object : MediaFilesUpdateListener {
                override fun onMediaFilesUpdated() {
                    Log.d(TAG, "媒體文件已更新")
                    // 刷新文件列表
                    refreshDeviceFiles()
                }
            }
            
            cxrApi.setMediaFilesUpdateListener(mediaFileUpdateListener)
            Log.d(TAG, "媒體文件更新監聽器已設置")
        } catch (e: Exception) {
            Log.e(TAG, "設置媒體文件更新監聽器失敗", e)
        }
    }
    
    /**
     * 獲取未同步文件數量
     */
    private fun getUnsyncFileCount() {
        try {
            val unSyncCallback = object : UnsyncNumResultCallback {
                override fun onUnsyncNumResult(
                    status: ValueUtil.CxrStatus?,
                    audioNum: Int,
                    pictureNum: Int,
                    videoNum: Int
                ) {
                    when (status) {
                        ValueUtil.CxrStatus.RESPONSE_SUCCEED -> {
                            _unsyncAudioCount.value = audioNum
                            _unsyncPictureCount.value = pictureNum
                            _unsyncVideoCount.value = videoNum
                            
                            val totalFiles = audioNum + pictureNum + videoNum
                            _fileCount.value = totalFiles
                            
                            Log.d(TAG, "未同步文件數量 - 音頻: $audioNum, 圖片: $pictureNum, 視頻: $videoNum")
                            _statusMessage.value = "發現 $totalFiles 個未同步文件"
                        }
                        ValueUtil.CxrStatus.RESPONSE_INVALID -> {
                            Log.e(TAG, "獲取未同步文件數量失敗: 響應無效")
                            _statusMessage.value = "獲取文件數量失敗: 響應無效"
                        }
                        ValueUtil.CxrStatus.RESPONSE_TIMEOUT -> {
                            Log.e(TAG, "獲取未同步文件數量失敗: 響應超時")
                            _statusMessage.value = "獲取文件數量失敗: 響應超時"
                        }
                        else -> {
                            Log.e(TAG, "獲取未同步文件數量失敗: 未知狀態 $status")
                            _statusMessage.value = "獲取文件數量失敗: 未知狀態"
                        }
                    }
                }
            }
            
            val status = cxrApi.getUnsyncNum(unSyncCallback)
            when (status) {
                ValueUtil.CxrStatus.REQUEST_SUCCEED -> {
                    Log.d(TAG, "獲取未同步文件數量請求成功")
                }
                ValueUtil.CxrStatus.REQUEST_WAITING -> {
                    Log.d(TAG, "獲取未同步文件數量請求等待中")
                    _statusMessage.value = "正在獲取文件數量..."
                }
                ValueUtil.CxrStatus.REQUEST_FAILED -> {
                    Log.e(TAG, "獲取未同步文件數量請求失敗")
                    _statusMessage.value = "獲取文件數量請求失敗"
                }
                else -> {
                    Log.e(TAG, "獲取未同步文件數量請求狀態未知: $status")
                    _statusMessage.value = "獲取文件數量請求狀態未知"
                }
            }
        } catch (e: Exception) {
            Log.e(TAG, "獲取未同步文件數量異常", e)
            _statusMessage.value = "獲取文件數量異常: ${e.message}"
        }
    }
    
    /**
     * 同步單個文件
     */
    private fun syncSingleFile(fileName: String, mediaType: ValueUtil.CxrMediaType): Boolean {
        return try {
            val syncCallback = object : SyncStatusCallback {
                override fun onSyncStart() {
                    Log.d(TAG, "開始同步文件: $fileName")
                    _syncProgress.value = "開始同步: $fileName"
                }
                
                override fun onSingleFileSynced(fileName: String?) {
                    Log.d(TAG, "文件同步成功: $fileName")
                    _syncProgress.value = "同步成功: $fileName"
                }
                
                override fun onSyncFailed() {
                    Log.e(TAG, "文件同步失敗: $fileName")
                    _syncProgress.value = "同步失敗: $fileName"
                }
                
                override fun onSyncFinished() {
                    Log.d(TAG, "文件同步完成: $fileName")
                    _syncProgress.value = "同步完成: $fileName"
                }
            }
            
            val success = cxrApi.syncSingleFile(localStorageDir.absolutePath, mediaType, fileName, syncCallback)
            Log.d(TAG, "同步單個文件結果: $success")
            success
        } catch (e: Exception) {
            Log.e(TAG, "同步單個文件異常", e)
            false
        }
    }
    
    /**
     * 開始同步所有文件
     */
    private fun startSyncAllFiles(types: Array<ValueUtil.CxrMediaType>): Boolean {
        return try {
            val syncCallback = object : SyncStatusCallback {
                override fun onSyncStart() {
                    Log.d(TAG, "開始同步所有文件")
                    _syncProgress.value = "開始同步所有文件..."
                }
                
                override fun onSingleFileSynced(fileName: String?) {
                    Log.d(TAG, "文件同步成功: $fileName")
                    _syncProgress.value = "同步成功: $fileName"
                }
                
                override fun onSyncFailed() {
                    Log.e(TAG, "同步失敗")
                    _syncProgress.value = "同步失敗"
                    _isSyncing.value = false
                }
                
                override fun onSyncFinished() {
                    Log.d(TAG, "所有文件同步完成")
                    _syncProgress.value = "所有文件同步完成"
                    _isSyncing.value = false
                    _statusMessage.value = "所有文件同步完成"
                }
            }
            
            val success = cxrApi.startSync(localStorageDir.absolutePath, types, syncCallback)
            Log.d(TAG, "開始同步所有文件結果: $success")
            success
        } catch (e: Exception) {
            Log.e(TAG, "開始同步所有文件異常", e)
            false
        }
    }
    
    /**
     * 初始化 Wi-Fi 連接
     */
    private suspend fun initWifiConnection(): Boolean {
        return try {
            _isWifiConnecting.value = true
            
            val wifiCallback = object : WifiP2PStatusCallback {
                override fun onConnected() {
                    Log.d(TAG, "Wi-Fi P2P 連接成功")
                    _isWifiConnected.value = true
                    _isWifiConnecting.value = false
                    _statusMessage.value = "Wi-Fi 連接成功"
                }
                
                override fun onDisconnected() {
                    Log.d(TAG, "Wi-Fi P2P 連接斷開")
                    _isWifiConnected.value = false
                    _isWifiConnecting.value = false
                    _statusMessage.value = "Wi-Fi 連接斷開"
                }
                
                override fun onFailed(errorCode: ValueUtil.CxrWifiErrorCode?) {
                    Log.e(TAG, "Wi-Fi P2P 連接失敗: $errorCode")
                    _isWifiConnected.value = false
                    _isWifiConnecting.value = false
                    
                    val errorMessage = when (errorCode) {
                        ValueUtil.CxrWifiErrorCode.WIFI_DISABLED -> "手機 Wi-Fi 未打開"
                        ValueUtil.CxrWifiErrorCode.WIFI_CONNECT_FAILED -> "P2P 連接失敗"
                        ValueUtil.CxrWifiErrorCode.UNKNOWN -> "未知錯誤"
                        else -> "連接失敗"
                    }
                    _statusMessage.value = "Wi-Fi 連接失敗: $errorMessage"
                }
            }
            
            val status = cxrApi.initWifiP2P(wifiCallback)
            when (status) {
                ValueUtil.CxrStatus.REQUEST_SUCCEED -> {
                    Log.d(TAG, "Wi-Fi 初始化請求成功")
                    true
                }
                ValueUtil.CxrStatus.REQUEST_WAITING -> {
                    Log.d(TAG, "Wi-Fi 初始化請求等待中")
                    _statusMessage.value = "Wi-Fi 初始化中..."
                    true
                }
                ValueUtil.CxrStatus.REQUEST_FAILED -> {
                    Log.e(TAG, "Wi-Fi 初始化請求失敗")
                    _statusMessage.value = "Wi-Fi 初始化失敗"
                    false
                }
                else -> {
                    Log.e(TAG, "Wi-Fi 初始化請求狀態未知: $status")
                    _statusMessage.value = "Wi-Fi 初始化狀態未知"
                    false
                }
            }
        } catch (e: Exception) {
            Log.e(TAG, "初始化 Wi-Fi 連接異常", e)
            _statusMessage.value = "Wi-Fi 初始化異常: ${e.message}"
            _isWifiConnecting.value = false
            false
        }
    }
    
    /**
     * 獲取 Wi-Fi 連接狀態
     */
    fun getWifiConnectionStatus(): Boolean {
        return try {
            val isConnected = cxrApi.isWifiP2PConnected
            _isWifiConnected.value = isConnected
            isConnected
        } catch (e: Exception) {
            Log.e(TAG, "獲取 Wi-Fi 連接狀態失敗", e)
            false
        }
    }
    
    /**
     * 反初始化 Wi-Fi 連接
     */
    fun deinitWifi() {
        try {
            cxrApi.deinitWifiP2P()
            _isWifiConnected.value = false
            _isWifiConnecting.value = false
            _statusMessage.value = "Wi-Fi 連接已斷開"
            Log.d(TAG, "Wi-Fi 連接已反初始化")
        } catch (e: Exception) {
            Log.e(TAG, "反初始化 Wi-Fi 連接失敗", e)
            _statusMessage.value = "斷開 Wi-Fi 連接失敗: ${e.message}"
        }
    }
    
    /**
     * 清理資源
     */
    override fun onCleared() {
        super.onCleared()
        try {
            // 移除媒體文件更新監聽器
            cxrApi.setMediaFilesUpdateListener(null)
            
            // 停止同步
            cxrApi.stopSync()
            
            // 反初始化 Wi-Fi 連接
            cxrApi.deinitWifiP2P()
            
            Log.d(TAG, "DeviceFilesViewModel 資源已清理")
        } catch (e: Exception) {
            Log.e(TAG, "清理資源失敗", e)
        }
    }
}

進入頁面調用refreshDeviceFiles獲取設備文件,點擊下載按鈕調用downloadFile方法將文件從設備傳輸到手機,設備傳輸依賴wifi模塊,所以在downloadFile方法中需要先連接wifi然後再下載文件。

避坑指南:wifi是高耗電模塊,使用完成及時關閉,避免設備耗電太快。

構建打包

開發完成後將代碼打包構建,編譯最終apk後即可安裝到手機。首先在gradle中配置秘鑰,在android節點下添加signingConfigs,配置密鑰庫路徑、密碼、別名等信息;然後在buildTypesrelease類型中引用該簽名配置。接着點擊頂部菜單欄Build → Generate Signed Bundle/APK,選擇“APK”,點擊“Next”;接着選擇Release構建類型,指定APK輸出路徑,點擊“Finish”;構建完成後,Android Studio會彈出提示框,顯示APK文件的保存路徑(默認路徑為app/build/outputs/apk/release/app-release.apk)。

image.png

完成構建後將apk安裝到手機就可以跟Rokid Glasses設備通信了,點擊連接後可以開啓錄音,點擊結束錄音後可以將設備中錄製的語音導出到手機,結合自身業務需求將音頻傳輸到雲端做ASR與用户畫像分析等。

總結

Rokid Glasses 通過 CXR-M SDK 為開發者提供了完整的移動端控制與協同開發框架,涵蓋藍牙發現、連接、錄音、拍照、錄像、文件同步及 Wi-Fi 高速傳輸等核心能力。藉助官方 SDK,企業或個人可在數小時內完成「手機-眼鏡」端到端原型,將第一視角音視頻、AI 助手、實時提詞、會議紀要等場景快速落地。整套方案兼顧低功耗藍牙的便捷與高帶寬 Wi-Fi 的效率,本文結合自身業務將Rokid Glasses能力無縫接入自有業務後台與大模型 pipeline。隨着 Rokid 生態持續迭代,開發者只需聚焦上層業務創新,即可讓「長在眼前的 AI 助手」成為銷售、培訓、巡檢、售後等場景的提效利器。

user avatar hankin_liu Avatar u_16231477 Avatar donnytab Avatar muzijun_68c14af5563a2 Avatar vivotech Avatar guoduandemuer Avatar pipiimmortal Avatar labilezhu Avatar airenaodexianrenqiu Avatar jianshendemifan Avatar manxisuo Avatar zhengzhouaiwenkeji Avatar
Favorites 20 users favorite the story!
Favorites

Add a new Comments

Some HTML is okay.