Rokid Glasses 移动端控制应用开发初体验 - 助力业务创新
- 2025-10-13 北京
本文字数:32684 字
阅读完需:约 107 分钟

前言
在 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 快速上手一波。
首先用一张官方的图片来介绍眼镜设备与手机之间的关系:

从图中可以看到,眼镜是一个基于 AOSP 的操作系统,手机通过 Rokid Glasses Protocol 与眼镜设备来交互。
通过 CXR-M SDK 可以与眼镜进行如下信息交互:
获取眼镜设备系统信息
设备连接
AI 能力
录音服务
拍照服务
录像服务
飞传功能
...
系统总体架构

Rokid 设备:负责语音采集与录音;
App 端(核心开发部分):控制录音、下载文件、上传云端;
云端服务:完成语音识别与画像分析。本文主要介绍 App 端功能开发,APP 主要包含以下功能:
扫描周围 rokid glasses 设备列表
连接某个设备
断开设备连接
停止扫描
开始录音
结束录音
下载 glasses 端录音文件到手机
删除 glasses 端录音文件
App 端与 Rokid SDK 模块设计与开发
很多眼镜都是配置自己应用,只针对 C 端用户开发,不支持企业和开发者开发应用对接,Rokid 天生支持开发者定制开发,不管是对个人开发者基于硬件创新还是针对企业业务赋能,都提供了很好的支持。接下来基于 Rokid 开发 Rokid Glasses 配套的手机应用。
创建项目
在了解完 CXR-M SDK 能力后,接下来我们基于 CXR-M SDK 开发一个属于我们自己的应用。首先创建项目,在 Android Studio 中新建项目:

接着输入项目名称、包名、存储路径等,Minimum 选择 18,CXR-M SDK 最低支持 Android 9.0,BuildConfigLanguage 选择 Kotlin DSL,CXR-M SDK 选用 Kotlin 语言:

点击 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/") }

增加配置后同步工程,同步成功后添加 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")

配置完成后同步工程,同步成功后就可以使用 CXR-M SDK 进行功能开发了。
扫描连接设备
Rokid Glasses 使用蓝牙与手机通信,手机端做任何动作都需要与眼镜端建立蓝牙连接,首先需要通过 Android 系统标准蓝牙接口扫描发现周边设备。CXR-M SDK 通过 Android 系统 API 扫描发现设备,扫描过程中可以使用 UUID:00009100-0000-1000-8000-00805f9b34fb,来过滤 Rokid 的设备。连接设备需要执行两步操作:
扫描设备列表
选择设备连接
在我们页面中增加扫描设备、停止扫描按钮,以列表形式展示扫描到的设备列表,选中某个设备点击连接和进行手机与设备的蓝牙连接。这里简单介绍下 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 效果图如下:

页面中点击搜索设备跳转“扫描连接设备”小节中的扫描页面,连接成功后开始录音置为可以点击,点击后开始录音。控制页面对应 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
,配置密钥库路径、密码、别名等信息;然后在buildTypes
的release
类型中引用该签名配置。接着点击顶部菜单栏 Build → Generate Signed Bundle/APK,选择“APK”,点击“Next”;接着选择 Release 构建类型,指定 APK 输出路径,点击“Finish”;构建完成后,Android Studio 会弹出提示框,显示 APK 文件的保存路径(默认路径为app/build/outputs/apk/release/app-release.apk
)。

完成构建后将 apk 安装到手机就可以跟 Rokid Glasses 设备通信了,点击连接后可以开启录音,点击结束录音后可以将设备中录制的语音导出到手机,结合自身业务需求将音频传输到云端做 ASR 与用户画像分析等。
总结
Rokid Glasses 通过 CXR-M SDK 为开发者提供了完整的移动端控制与协同开发框架,涵盖蓝牙发现、连接、录音、拍照、录像、文件同步及 Wi-Fi 高速传输等核心能力。借助官方 SDK,企业或个人可在数小时内完成「手机-眼镜」端到端原型,将第一视角音视频、AI 助手、实时提词、会议纪要等场景快速落地。整套方案兼顾低功耗蓝牙的便捷与高带宽 Wi-Fi 的效率,本文结合自身业务将 Rokid Glasses 能力无缝接入自有业务后台与大模型 pipeline。随着 Rokid 生态持续迭代,开发者只需聚焦上层业务创新,即可让「长在眼前的 AI 助手」成为销售、培训、巡检、售后等场景的提效利器。</br>
![[Config/Templates/公众号介绍|公众号介绍]]
![[公众号卡片]]![[公众号欢迎词]]
版权声明: 本文为 InfoQ 作者【轻口味】的原创文章。
原文链接:【http://xie.infoq.cn/article/c964343d6aba86699e3e5f226】。文章转载请联系作者。


轻口味
🏆2021年InfoQ写作平台-签约作者 🏆 2017-10-17 加入
Android、音视频、AI相关领域从业者。 欢迎加我微信wodekouwei拉您进InfoQ音视频沟通群 邮箱:qingkouwei@gmail.com
评论