112 changed files with 3599 additions and 5484 deletions
@ -1,10 +0,0 @@ |
|||
{ |
|||
"mcpServers": { |
|||
"web-search": { |
|||
"url": "http://mcp.ideapsound.com:8000/sse" |
|||
}, |
|||
"amap-amap-sse": { |
|||
"url": "https://mcp.amap.com/sse?key=df7b0b6747c632fc38ddad22e7176646" |
|||
} |
|||
} |
|||
} |
|||
@ -1,11 +0,0 @@ |
|||
import 'package:get/get.dart'; |
|||
import '../controllers/jieli_test_controller.dart'; |
|||
|
|||
class JieliTestBinding extends Bindings { |
|||
@override |
|||
void dependencies() { |
|||
Get.lazyPut<JieliTestController>( |
|||
() => JieliTestController(), |
|||
); |
|||
} |
|||
} |
|||
@ -1,323 +0,0 @@ |
|||
import 'dart:async'; |
|||
import 'dart:typed_data'; |
|||
import 'package:flutter/material.dart' hide ConnectionState; |
|||
import 'package:get/get.dart'; |
|||
import 'package:jieli/jieli.dart'; |
|||
|
|||
/// 杰理测试控制器 |
|||
class JieliTestController extends GetxController { |
|||
// Jieli插件实例 |
|||
late Jieli jieli; |
|||
|
|||
// 状态变量 |
|||
var isScanning = false.obs; |
|||
var deviceInfo = '未获取'.obs; |
|||
var connectStatus = '未连接'.obs; |
|||
var currentDevice = Rx<BleDevice?>(null); |
|||
var scannedDevices = RxList<BleDevice>([]); |
|||
var wakeupReceived = false.obs; |
|||
var audioDataSize = 0.obs; |
|||
var receivedFrames = 0.obs; |
|||
var audioDataReceiving = false.obs; |
|||
var volumeCommandResult = ''.obs; |
|||
|
|||
// 事件订阅 |
|||
StreamSubscription? _scanSubscription; |
|||
StreamSubscription? _connectionSubscription; |
|||
StreamSubscription? _wakeupSubscription; |
|||
StreamSubscription? _audioSubscription; |
|||
StreamSubscription? _commandResultSubscription; |
|||
|
|||
@override |
|||
void onInit() { |
|||
super.onInit(); |
|||
_initializeJieli(); |
|||
} |
|||
|
|||
@override |
|||
void onClose() { |
|||
_cancelAllSubscriptions(); |
|||
_disposeJieli(); |
|||
super.onClose(); |
|||
} |
|||
|
|||
/// 取消所有订阅 |
|||
void _cancelAllSubscriptions() { |
|||
_scanSubscription?.cancel(); |
|||
_connectionSubscription?.cancel(); |
|||
_wakeupSubscription?.cancel(); |
|||
_audioSubscription?.cancel(); |
|||
_commandResultSubscription?.cancel(); |
|||
} |
|||
|
|||
/// 初始化杰理SDK |
|||
void _initializeJieli() { |
|||
try { |
|||
jieli = Jieli(); |
|||
_setupEventListeners(); |
|||
} catch (e) { |
|||
// 初始化错误处理 |
|||
} |
|||
} |
|||
|
|||
/// 设置事件监听器 |
|||
void _setupEventListeners() { |
|||
// 监听扫描结果 |
|||
_scanSubscription = jieli.scanResults.listen((devices) { |
|||
scannedDevices.assignAll(devices); |
|||
}); |
|||
|
|||
// 监听连接状态 |
|||
_connectionSubscription = jieli.connectionState.listen((state) { |
|||
switch (state) { |
|||
case ConnectionState.connected: |
|||
connectStatus.value = '已连接'; |
|||
break; |
|||
case ConnectionState.disconnected: |
|||
connectStatus.value = '已断开'; |
|||
currentDevice.value = null; |
|||
break; |
|||
case ConnectionState.connecting: |
|||
connectStatus.value = '连接中...'; |
|||
break; |
|||
case ConnectionState.disconnecting: |
|||
connectStatus.value = '断开中...'; |
|||
break; |
|||
case ConnectionState.error: |
|||
connectStatus.value = '连接错误'; |
|||
break; |
|||
} |
|||
}); |
|||
|
|||
// 监听唤醒信号 |
|||
_wakeupSubscription = jieli.wakeupStream.listen((wakeup) { |
|||
wakeupReceived.value = true; |
|||
Get.snackbar('收到唤醒信号', '耳机发送了唤醒信号'); |
|||
}); |
|||
|
|||
// 监听音频数据 |
|||
_audioSubscription = jieli.audioDataStream.listen((data) { |
|||
// 更新音频数据统计 |
|||
audioDataSize.value += data.length; |
|||
receivedFrames.value++; |
|||
audioDataReceiving.value = true; |
|||
}); |
|||
|
|||
// 监听命令结果 |
|||
_commandResultSubscription = jieli.commandResultStream.listen((result) { |
|||
// 处理音量调整命令结果 |
|||
if (result.commandType == Jieli.commandTypeVolume) { |
|||
volumeCommandResult.value = result.success ? '音量调整成功' : '音量调整失败'; |
|||
Get.snackbar( |
|||
result.success ? '音量调整成功' : '音量调整失败', |
|||
result.success ? '设备已更新音量' : '设备未能更新音量', |
|||
snackPosition: SnackPosition.BOTTOM, |
|||
backgroundColor: result.success ? Colors.green.withOpacity(0.7) : Colors.red.withOpacity(0.7), |
|||
colorText: Colors.white, |
|||
duration: Duration(seconds: 2), |
|||
); |
|||
} |
|||
}); |
|||
} |
|||
|
|||
/// 检查蓝牙状态 |
|||
Future<bool> checkBluetoothStatus() async { |
|||
try { |
|||
final isEnabled = await jieli.isBluetoothEnabled(); |
|||
if (!isEnabled) { |
|||
return await jieli.requestBluetoothEnable(); |
|||
} |
|||
return true; |
|||
} catch (e) { |
|||
return false; |
|||
} |
|||
} |
|||
|
|||
/// 开始扫描设备 |
|||
Future<void> startScan() async { |
|||
if (isScanning.value) return; |
|||
|
|||
final bluetoothReady = await checkBluetoothStatus(); |
|||
if (!bluetoothReady) { |
|||
Get.snackbar('蓝牙未启用', '请启用蓝牙后再试'); |
|||
return; |
|||
} |
|||
|
|||
try { |
|||
isScanning.value = true; |
|||
|
|||
// 清空之前的设备列表 |
|||
scannedDevices.clear(); |
|||
|
|||
// 开始扫描,10秒后自动停止 |
|||
final success = await jieli.startScan(scanTimeout: 10000); |
|||
|
|||
if (!success) { |
|||
isScanning.value = false; |
|||
Get.snackbar('扫描失败', '启动扫描设备失败,请检查蓝牙权限'); |
|||
} |
|||
|
|||
// 10秒后自动更新UI状态 |
|||
Future.delayed(Duration(milliseconds: 10000), () { |
|||
isScanning.value = false; |
|||
}); |
|||
|
|||
} catch (e) { |
|||
isScanning.value = false; |
|||
Get.snackbar('扫描错误', e.toString()); |
|||
} |
|||
} |
|||
|
|||
/// 停止扫描 |
|||
Future<void> stopScan() async { |
|||
if (!isScanning.value) return; |
|||
|
|||
try { |
|||
await jieli.stopScan(); |
|||
isScanning.value = false; |
|||
} catch (e) { |
|||
// 停止扫描错误处理 |
|||
} |
|||
} |
|||
|
|||
/// 连接到指定设备 |
|||
Future<void> connectToDevice(BleDevice device) async { |
|||
try { |
|||
// 先断开当前连接 |
|||
if (currentDevice.value != null) { |
|||
await disconnectDevice(); |
|||
} |
|||
|
|||
// 更新UI状态 |
|||
connectStatus.value = '连接中...'; |
|||
|
|||
// 重置音频和唤醒相关的状态 |
|||
wakeupReceived.value = false; |
|||
audioDataSize.value = 0; |
|||
receivedFrames.value = 0; |
|||
audioDataReceiving.value = false; |
|||
|
|||
// 连接设备 |
|||
final success = await jieli.connect(device.id); |
|||
|
|||
if (success) { |
|||
// 连接成功,更新当前设备 |
|||
currentDevice.value = device; |
|||
deviceInfo.value = '设备名称: ${device.name}\n设备ID: ${device.id}\nRSSI: ${device.rssi} dBm'; |
|||
} else { |
|||
deviceInfo.value = '连接失败'; |
|||
} |
|||
} catch (e) { |
|||
deviceInfo.value = '连接错误: ${e.toString()}'; |
|||
} |
|||
} |
|||
|
|||
/// 断开设备连接 |
|||
Future<void> disconnectDevice() async { |
|||
if (currentDevice.value == null) return; |
|||
|
|||
try { |
|||
connectStatus.value = '断开中...'; |
|||
|
|||
final success = await jieli.disconnect(); |
|||
|
|||
if (success) { |
|||
deviceInfo.value = '未连接设备'; |
|||
} |
|||
|
|||
} catch (e) { |
|||
// 断开连接错误处理 |
|||
} |
|||
} |
|||
|
|||
/// 发送开始录音命令 |
|||
Future<void> sendStartRecordingCommand() async { |
|||
if (currentDevice.value == null) { |
|||
Get.snackbar('操作失败', '未连接设备'); |
|||
return; |
|||
} |
|||
|
|||
try { |
|||
final success = await jieli.sendStartRecordingCommand(); |
|||
|
|||
if (success) { |
|||
Get.snackbar('命令已发送', '已发送开始录音命令'); |
|||
} else { |
|||
Get.snackbar('命令发送失败', '发送开始录音命令失败'); |
|||
} |
|||
} catch (e) { |
|||
Get.snackbar('命令错误', e.toString()); |
|||
} |
|||
} |
|||
|
|||
/// 发送停止录音命令 |
|||
Future<void> sendStopRecordingCommand() async { |
|||
if (currentDevice.value == null) { |
|||
Get.snackbar('操作失败', '未连接设备'); |
|||
return; |
|||
} |
|||
|
|||
try { |
|||
final success = await jieli.sendStopRecordingCommand(); |
|||
|
|||
if (success) { |
|||
Get.snackbar('命令已发送', '已发送停止录音命令'); |
|||
} else { |
|||
Get.snackbar('命令发送失败', '发送停止录音命令失败'); |
|||
} |
|||
} catch (e) { |
|||
Get.snackbar('命令错误', e.toString()); |
|||
} |
|||
} |
|||
|
|||
/// 增加音量 |
|||
Future<void> increaseVolume() async { |
|||
if (currentDevice.value == null) { |
|||
Get.snackbar('操作失败', '未连接设备'); |
|||
return; |
|||
} |
|||
|
|||
try { |
|||
volumeCommandResult.value = '发送中...'; |
|||
final success = await jieli.increaseVolume(); |
|||
|
|||
if (!success) { |
|||
Get.snackbar('命令发送失败', '发送增加音量命令失败'); |
|||
volumeCommandResult.value = '发送失败'; |
|||
} |
|||
} catch (e) { |
|||
Get.snackbar('命令错误', e.toString()); |
|||
volumeCommandResult.value = '发送出错'; |
|||
} |
|||
} |
|||
|
|||
/// 减小音量 |
|||
Future<void> decreaseVolume() async { |
|||
if (currentDevice.value == null) { |
|||
Get.snackbar('操作失败', '未连接设备'); |
|||
return; |
|||
} |
|||
|
|||
try { |
|||
volumeCommandResult.value = '发送中...'; |
|||
final success = await jieli.decreaseVolume(); |
|||
|
|||
if (!success) { |
|||
Get.snackbar('命令发送失败', '发送减小音量命令失败'); |
|||
volumeCommandResult.value = '发送失败'; |
|||
} |
|||
} catch (e) { |
|||
Get.snackbar('命令错误', e.toString()); |
|||
volumeCommandResult.value = '发送出错'; |
|||
} |
|||
} |
|||
|
|||
/// 清理资源 |
|||
void _disposeJieli() { |
|||
try { |
|||
jieli.dispose(); |
|||
} catch (e) { |
|||
// 释放资源错误处理 |
|||
} |
|||
} |
|||
} |
|||
@ -1,568 +0,0 @@ |
|||
import 'package:flutter/material.dart'; |
|||
import 'package:get/get.dart'; |
|||
import 'package:flutter_screenutil/flutter_screenutil.dart'; |
|||
import '../controllers/jieli_test_controller.dart'; |
|||
import 'package:jieli/jieli.dart'; |
|||
|
|||
class JieliTestView extends GetView<JieliTestController> { |
|||
const JieliTestView({Key? key}) : super(key: key); |
|||
|
|||
@override |
|||
Widget build(BuildContext context) { |
|||
final isDarkMode = Theme.of(context).brightness == Brightness.dark; |
|||
|
|||
return Scaffold( |
|||
backgroundColor: isDarkMode ? Colors.black : Colors.grey[50], |
|||
appBar: AppBar( |
|||
backgroundColor: isDarkMode ? Colors.black : Colors.white, |
|||
elevation: 0.5, |
|||
title: Text( |
|||
'杰理蓝牙测试', |
|||
style: TextStyle( |
|||
fontSize: 17.sp, |
|||
fontWeight: FontWeight.w600, |
|||
color: isDarkMode ? Colors.white : Colors.black, |
|||
), |
|||
), |
|||
centerTitle: true, |
|||
leading: IconButton( |
|||
icon: Icon( |
|||
Icons.arrow_back_ios, |
|||
color: isDarkMode ? Colors.white : Colors.black, |
|||
size: 20.sp, |
|||
), |
|||
onPressed: () => Get.back(), |
|||
), |
|||
), |
|||
body: Padding( |
|||
padding: EdgeInsets.all(16.w), |
|||
child: Column( |
|||
crossAxisAlignment: CrossAxisAlignment.stretch, |
|||
children: [ |
|||
_buildStatusCard(isDarkMode), |
|||
SizedBox(height: 16.h), |
|||
_buildActionButtons(isDarkMode), |
|||
SizedBox(height: 16.h), |
|||
_buildDeviceInfo(isDarkMode), |
|||
SizedBox(height: 16.h), |
|||
_buildDevicesList(isDarkMode), |
|||
], |
|||
), |
|||
), |
|||
); |
|||
} |
|||
|
|||
Widget _buildStatusCard(bool isDarkMode) { |
|||
return Container( |
|||
padding: EdgeInsets.all(16.w), |
|||
decoration: BoxDecoration( |
|||
color: isDarkMode ? Colors.grey[900] : Colors.white, |
|||
borderRadius: BorderRadius.circular(12.r), |
|||
boxShadow: [ |
|||
BoxShadow( |
|||
color: Colors.black.withOpacity(0.05), |
|||
blurRadius: 10, |
|||
offset: const Offset(0, 2), |
|||
), |
|||
], |
|||
), |
|||
child: Column( |
|||
crossAxisAlignment: CrossAxisAlignment.start, |
|||
children: [ |
|||
Row( |
|||
children: [ |
|||
Text( |
|||
'连接状态: ', |
|||
style: TextStyle( |
|||
fontSize: 14.sp, |
|||
color: isDarkMode ? Colors.white : Colors.black87, |
|||
), |
|||
), |
|||
Obx(() => Text( |
|||
controller.connectStatus.value, |
|||
style: TextStyle( |
|||
fontSize: 14.sp, |
|||
fontWeight: FontWeight.bold, |
|||
color: _getStatusColor(controller.connectStatus.value), |
|||
), |
|||
)), |
|||
], |
|||
), |
|||
SizedBox(height: 8.h), |
|||
Row( |
|||
mainAxisAlignment: MainAxisAlignment.spaceBetween, |
|||
children: [ |
|||
Obx(() => _buildStatusChip( |
|||
label: '唤醒信号', |
|||
isActive: controller.wakeupReceived.value, |
|||
activeColor: Colors.orange, |
|||
isDarkMode: isDarkMode, |
|||
)), |
|||
Obx(() => _buildStatusChip( |
|||
label: '音频数据', |
|||
isActive: controller.audioDataReceiving.value, |
|||
activeColor: Colors.green, |
|||
isDarkMode: isDarkMode, |
|||
)), |
|||
], |
|||
), |
|||
SizedBox(height: 8.h), |
|||
Obx(() => controller.audioDataReceiving.value |
|||
? Column( |
|||
crossAxisAlignment: CrossAxisAlignment.start, |
|||
children: [ |
|||
Text( |
|||
'音频数据统计:', |
|||
style: TextStyle( |
|||
fontSize: 12.sp, |
|||
color: isDarkMode ? Colors.white70 : Colors.black54, |
|||
), |
|||
), |
|||
SizedBox(height: 4.h), |
|||
Text( |
|||
'已接收 ${(controller.audioDataSize.value / 1024).toStringAsFixed(2)} KB', |
|||
style: TextStyle( |
|||
fontSize: 12.sp, |
|||
color: isDarkMode ? Colors.white70 : Colors.black54, |
|||
), |
|||
), |
|||
Text( |
|||
'共 ${controller.receivedFrames.value} 帧', |
|||
style: TextStyle( |
|||
fontSize: 12.sp, |
|||
color: isDarkMode ? Colors.white70 : Colors.black54, |
|||
), |
|||
), |
|||
], |
|||
) |
|||
: SizedBox.shrink() |
|||
), |
|||
], |
|||
), |
|||
); |
|||
} |
|||
|
|||
Widget _buildStatusChip({ |
|||
required String label, |
|||
required bool isActive, |
|||
required Color activeColor, |
|||
required bool isDarkMode, |
|||
}) { |
|||
return Container( |
|||
padding: EdgeInsets.symmetric(horizontal: 12.w, vertical: 6.h), |
|||
decoration: BoxDecoration( |
|||
color: isActive |
|||
? activeColor |
|||
: (isDarkMode ? Colors.grey[800] : Colors.grey[200]), |
|||
borderRadius: BorderRadius.circular(16.r), |
|||
), |
|||
child: Row( |
|||
mainAxisSize: MainAxisSize.min, |
|||
children: [ |
|||
Container( |
|||
width: 8.w, |
|||
height: 8.w, |
|||
decoration: BoxDecoration( |
|||
color: isActive ? Colors.white : Colors.grey, |
|||
shape: BoxShape.circle, |
|||
), |
|||
), |
|||
SizedBox(width: 6.w), |
|||
Text( |
|||
label, |
|||
style: TextStyle( |
|||
fontSize: 12.sp, |
|||
color: isActive |
|||
? Colors.white |
|||
: (isDarkMode ? Colors.white70 : Colors.black54), |
|||
fontWeight: isActive ? FontWeight.bold : FontWeight.normal, |
|||
), |
|||
), |
|||
], |
|||
), |
|||
); |
|||
} |
|||
|
|||
Widget _buildActionButtons(bool isDarkMode) { |
|||
return Column( |
|||
children: [ |
|||
Row( |
|||
children: [ |
|||
Expanded( |
|||
child: Obx(() => ElevatedButton.icon( |
|||
onPressed: controller.isScanning.value |
|||
? controller.stopScan |
|||
: controller.startScan, |
|||
icon: Icon( |
|||
controller.isScanning.value ? Icons.stop : Icons.bluetooth_searching, |
|||
size: 18.sp, |
|||
), |
|||
label: Text( |
|||
controller.isScanning.value ? '停止扫描' : '扫描设备', |
|||
style: TextStyle(fontSize: 14.sp), |
|||
), |
|||
style: ElevatedButton.styleFrom( |
|||
backgroundColor: controller.isScanning.value |
|||
? Colors.red[600] |
|||
: Colors.blue[600], |
|||
padding: EdgeInsets.symmetric(vertical: 12.h), |
|||
shape: RoundedRectangleBorder( |
|||
borderRadius: BorderRadius.circular(8.r), |
|||
), |
|||
), |
|||
)), |
|||
), |
|||
], |
|||
), |
|||
SizedBox(height: 8.h), |
|||
Row( |
|||
children: [ |
|||
Expanded( |
|||
child: ElevatedButton.icon( |
|||
onPressed: controller.sendStartRecordingCommand, |
|||
icon: Icon( |
|||
Icons.mic, |
|||
size: 18.sp, |
|||
), |
|||
label: Text( |
|||
'开始录音', |
|||
style: TextStyle(fontSize: 14.sp), |
|||
), |
|||
style: ElevatedButton.styleFrom( |
|||
backgroundColor: Colors.green[600], |
|||
padding: EdgeInsets.symmetric(vertical: 12.h), |
|||
shape: RoundedRectangleBorder( |
|||
borderRadius: BorderRadius.circular(8.r), |
|||
), |
|||
), |
|||
), |
|||
), |
|||
SizedBox(width: 8.w), |
|||
Expanded( |
|||
child: ElevatedButton.icon( |
|||
onPressed: controller.sendStopRecordingCommand, |
|||
icon: Icon( |
|||
Icons.stop_circle, |
|||
size: 18.sp, |
|||
), |
|||
label: Text( |
|||
'停止录音', |
|||
style: TextStyle(fontSize: 14.sp), |
|||
), |
|||
style: ElevatedButton.styleFrom( |
|||
backgroundColor: Colors.red[600], |
|||
padding: EdgeInsets.symmetric(vertical: 12.h), |
|||
shape: RoundedRectangleBorder( |
|||
borderRadius: BorderRadius.circular(8.r), |
|||
), |
|||
), |
|||
), |
|||
), |
|||
], |
|||
), |
|||
SizedBox(height: 8.h), |
|||
Row( |
|||
children: [ |
|||
Expanded( |
|||
child: ElevatedButton.icon( |
|||
onPressed: controller.increaseVolume, |
|||
icon: Icon( |
|||
Icons.volume_up, |
|||
size: 18.sp, |
|||
), |
|||
label: Text( |
|||
'增加音量', |
|||
style: TextStyle(fontSize: 14.sp), |
|||
), |
|||
style: ElevatedButton.styleFrom( |
|||
backgroundColor: Colors.blue[600], |
|||
padding: EdgeInsets.symmetric(vertical: 12.h), |
|||
shape: RoundedRectangleBorder( |
|||
borderRadius: BorderRadius.circular(8.r), |
|||
), |
|||
), |
|||
), |
|||
), |
|||
SizedBox(width: 8.w), |
|||
Expanded( |
|||
child: ElevatedButton.icon( |
|||
onPressed: controller.decreaseVolume, |
|||
icon: Icon( |
|||
Icons.volume_down, |
|||
size: 18.sp, |
|||
), |
|||
label: Text( |
|||
'减小音量', |
|||
style: TextStyle(fontSize: 14.sp), |
|||
), |
|||
style: ElevatedButton.styleFrom( |
|||
backgroundColor: Colors.orange[600], |
|||
padding: EdgeInsets.symmetric(vertical: 12.h), |
|||
shape: RoundedRectangleBorder( |
|||
borderRadius: BorderRadius.circular(8.r), |
|||
), |
|||
), |
|||
), |
|||
), |
|||
], |
|||
), |
|||
SizedBox(height: 8.h), |
|||
Obx(() => controller.volumeCommandResult.value.isNotEmpty |
|||
? Container( |
|||
width: double.infinity, |
|||
padding: EdgeInsets.symmetric(vertical: 8.h, horizontal: 16.w), |
|||
decoration: BoxDecoration( |
|||
color: controller.volumeCommandResult.value.contains('成功') |
|||
? Colors.green[100] |
|||
: (controller.volumeCommandResult.value == '发送中...' |
|||
? Colors.blue[100] |
|||
: Colors.red[100]), |
|||
borderRadius: BorderRadius.circular(8.r), |
|||
), |
|||
child: Text( |
|||
'音量控制: ${controller.volumeCommandResult.value}', |
|||
style: TextStyle( |
|||
fontSize: 13.sp, |
|||
color: controller.volumeCommandResult.value.contains('成功') |
|||
? Colors.green[900] |
|||
: (controller.volumeCommandResult.value == '发送中...' |
|||
? Colors.blue[900] |
|||
: Colors.red[900]), |
|||
), |
|||
textAlign: TextAlign.center, |
|||
), |
|||
) |
|||
: SizedBox.shrink() |
|||
), |
|||
], |
|||
); |
|||
} |
|||
|
|||
Widget _buildDeviceInfo(bool isDarkMode) { |
|||
return Container( |
|||
padding: EdgeInsets.all(16.w), |
|||
decoration: BoxDecoration( |
|||
color: isDarkMode ? Colors.grey[900] : Colors.white, |
|||
borderRadius: BorderRadius.circular(12.r), |
|||
boxShadow: [ |
|||
BoxShadow( |
|||
color: Colors.black.withOpacity(0.05), |
|||
blurRadius: 10, |
|||
offset: const Offset(0, 2), |
|||
), |
|||
], |
|||
), |
|||
child: Column( |
|||
crossAxisAlignment: CrossAxisAlignment.start, |
|||
children: [ |
|||
Text( |
|||
'设备信息:', |
|||
style: TextStyle( |
|||
fontSize: 14.sp, |
|||
color: isDarkMode ? Colors.white : Colors.black87, |
|||
), |
|||
), |
|||
SizedBox(height: 8.h), |
|||
Container( |
|||
padding: EdgeInsets.all(8.w), |
|||
decoration: BoxDecoration( |
|||
color: isDarkMode ? Colors.black : Colors.grey[100], |
|||
borderRadius: BorderRadius.circular(8.r), |
|||
), |
|||
child: Obx(() => Text( |
|||
controller.deviceInfo.value, |
|||
style: TextStyle( |
|||
fontSize: 13.sp, |
|||
color: isDarkMode ? Colors.white70 : Colors.black54, |
|||
height: 1.4, |
|||
), |
|||
)), |
|||
), |
|||
SizedBox(height: 16.h), |
|||
Obx(() => controller.currentDevice.value != null |
|||
? ElevatedButton.icon( |
|||
onPressed: controller.disconnectDevice, |
|||
icon: Icon( |
|||
Icons.bluetooth_disabled, |
|||
size: 16.sp, |
|||
), |
|||
label: Text( |
|||
'断开连接', |
|||
style: TextStyle(fontSize: 13.sp), |
|||
), |
|||
style: ElevatedButton.styleFrom( |
|||
backgroundColor: Colors.red[600], |
|||
padding: EdgeInsets.symmetric(horizontal: 16.w, vertical: 10.h), |
|||
shape: RoundedRectangleBorder( |
|||
borderRadius: BorderRadius.circular(8.r), |
|||
), |
|||
), |
|||
) |
|||
: SizedBox.shrink() |
|||
), |
|||
], |
|||
), |
|||
); |
|||
} |
|||
|
|||
Widget _buildDevicesList(bool isDarkMode) { |
|||
return Expanded( |
|||
child: Container( |
|||
decoration: BoxDecoration( |
|||
color: isDarkMode ? Colors.grey[900] : Colors.white, |
|||
borderRadius: BorderRadius.circular(12.r), |
|||
boxShadow: [ |
|||
BoxShadow( |
|||
color: Colors.black.withOpacity(0.05), |
|||
blurRadius: 10, |
|||
offset: const Offset(0, 2), |
|||
), |
|||
], |
|||
), |
|||
child: Column( |
|||
crossAxisAlignment: CrossAxisAlignment.start, |
|||
children: [ |
|||
Padding( |
|||
padding: EdgeInsets.all(16.w), |
|||
child: Row( |
|||
mainAxisAlignment: MainAxisAlignment.spaceBetween, |
|||
children: [ |
|||
Text( |
|||
'扫描到的设备', |
|||
style: TextStyle( |
|||
fontSize: 16.sp, |
|||
fontWeight: FontWeight.w600, |
|||
color: isDarkMode ? Colors.white : Colors.black87, |
|||
), |
|||
), |
|||
Obx(() => Text( |
|||
'${controller.scannedDevices.length}个设备', |
|||
style: TextStyle( |
|||
fontSize: 12.sp, |
|||
color: isDarkMode ? Colors.white70 : Colors.black54, |
|||
), |
|||
)), |
|||
], |
|||
), |
|||
), |
|||
Divider(height: 1, thickness: 1, color: isDarkMode ? Colors.grey[800] : Colors.grey[200]), |
|||
Expanded( |
|||
child: Obx(() => controller.scannedDevices.isEmpty |
|||
? Center( |
|||
child: controller.isScanning.value |
|||
? Column( |
|||
mainAxisAlignment: MainAxisAlignment.center, |
|||
children: [ |
|||
CircularProgressIndicator(), |
|||
SizedBox(height: 16.h), |
|||
Text( |
|||
'正在扫描...', |
|||
style: TextStyle( |
|||
fontSize: 14.sp, |
|||
color: isDarkMode ? Colors.white70 : Colors.black54, |
|||
), |
|||
), |
|||
], |
|||
) |
|||
: Text( |
|||
'未扫描到设备', |
|||
style: TextStyle( |
|||
fontSize: 14.sp, |
|||
color: isDarkMode ? Colors.white70 : Colors.black54, |
|||
), |
|||
), |
|||
) |
|||
: ListView.separated( |
|||
padding: EdgeInsets.symmetric(vertical: 8.h), |
|||
itemCount: controller.scannedDevices.length, |
|||
separatorBuilder: (context, index) => Divider( |
|||
height: 1, |
|||
thickness: 1, |
|||
color: isDarkMode ? Colors.grey[800] : Colors.grey[200], |
|||
), |
|||
itemBuilder: (context, index) { |
|||
final device = controller.scannedDevices[index]; |
|||
return ListTile( |
|||
dense: true, |
|||
title: Text( |
|||
device.name.isEmpty ? '未知设备' : device.name, |
|||
style: TextStyle( |
|||
fontSize: 14.sp, |
|||
fontWeight: FontWeight.w500, |
|||
color: isDarkMode ? Colors.white : Colors.black87, |
|||
), |
|||
), |
|||
subtitle: Column( |
|||
crossAxisAlignment: CrossAxisAlignment.start, |
|||
children: [ |
|||
Text( |
|||
'设备ID: ${device.id}', |
|||
style: TextStyle( |
|||
fontSize: 11.sp, |
|||
color: isDarkMode ? Colors.white70 : Colors.black54, |
|||
), |
|||
), |
|||
Text( |
|||
'RSSI: ${device.rssi} dBm', |
|||
style: TextStyle( |
|||
fontSize: 11.sp, |
|||
color: isDarkMode ? Colors.white70 : Colors.black54, |
|||
), |
|||
), |
|||
], |
|||
), |
|||
trailing: Obx(() => controller.currentDevice.value?.id == device.id |
|||
? Chip( |
|||
label: Text( |
|||
'已连接', |
|||
style: TextStyle( |
|||
fontSize: 12.sp, |
|||
color: Colors.white, |
|||
), |
|||
), |
|||
backgroundColor: Colors.green, |
|||
padding: EdgeInsets.symmetric(horizontal: 8.w), |
|||
) |
|||
: ElevatedButton( |
|||
onPressed: () => controller.connectToDevice(device), |
|||
child: Text( |
|||
'连接', |
|||
style: TextStyle(fontSize: 12.sp), |
|||
), |
|||
style: ElevatedButton.styleFrom( |
|||
backgroundColor: Colors.green[600], |
|||
padding: EdgeInsets.symmetric(horizontal: 12.w, vertical: 6.h), |
|||
shape: RoundedRectangleBorder( |
|||
borderRadius: BorderRadius.circular(6.r), |
|||
), |
|||
), |
|||
), |
|||
), |
|||
contentPadding: EdgeInsets.symmetric(horizontal: 16.w, vertical: 8.h), |
|||
isThreeLine: true, |
|||
); |
|||
}, |
|||
), |
|||
), |
|||
), |
|||
], |
|||
), |
|||
), |
|||
); |
|||
} |
|||
|
|||
Color _getStatusColor(String status) { |
|||
switch (status) { |
|||
case '连接中...': |
|||
return Colors.orange; |
|||
case '已连接': |
|||
return Colors.green; |
|||
case '连接失败': |
|||
return Colors.red; |
|||
case '已断开': |
|||
return Colors.grey; |
|||
default: |
|||
return Colors.grey; |
|||
} |
|||
} |
|||
} |
|||
@ -1,16 +1,2 @@ |
|||
rootProject.name = "agent_service" |
|||
|
|||
// 包含项目依赖 |
|||
include(":azure_speech") |
|||
include(":open_ai_service") |
|||
include(":deepvoice_utils") |
|||
include(":chat_storage") |
|||
include(":music_service") |
|||
include(":amap_service") |
|||
// 设置项目路径 |
|||
project(":azure_speech").projectDir = file("../../azure_speech/android") |
|||
project(":open_ai_service").projectDir = file("../../open_ai_service/android") |
|||
project(":deepvoice_utils").projectDir = file("../../deepvoice_utils/android") |
|||
project(":chat_storage").projectDir = file("../../chat_storage/android") |
|||
project(":music_service").projectDir = file("../../music_service/android") |
|||
project(":amap_service").projectDir = file("../../amap_service/android") |
|||
Binary file not shown.
Binary file not shown.
@ -0,0 +1,116 @@ |
|||
package com.yunqiinnovation.ble_service |
|||
import io.flutter.embedding.engine.plugins.FlutterPlugin |
|||
import io.flutter.plugin.common.EventChannel |
|||
import io.flutter.plugin.common.MethodCall |
|||
import io.flutter.plugin.common.MethodChannel |
|||
import io.flutter.plugin.common.MethodChannel.MethodCallHandler |
|||
import io.flutter.plugin.common.MethodChannel.Result |
|||
import android.bluetooth.BluetoothDevice |
|||
import android.bluetooth.BluetoothGatt |
|||
import android.bluetooth.BluetoothProfile |
|||
import android.content.Context |
|||
import com.jieli.jl_bt_ota.constant.ErrorCode; |
|||
import com.jieli.jl_bt_ota.constant.StateCode; |
|||
import com.jieli.jl_bt_ota.impl.BluetoothOTAManager; |
|||
import com.jieli.jl_bt_ota.interfaces.BtEventCallback; |
|||
import com.jieli.jl_bt_ota.interfaces.IActionCallback; |
|||
import com.jieli.jl_bt_ota.interfaces.IUpgradeCallback; |
|||
import com.jieli.jl_bt_ota.model.BluetoothOTAConfigure; |
|||
import com.jieli.jl_bt_ota.model.base.BaseError; |
|||
import com.jieli.jl_bt_ota.model.response.TargetInfoResponse; |
|||
import android.util.Log |
|||
import java.util.* |
|||
class CustomUpdateCallback( |
|||
val context: Context, |
|||
val device: BluetoothDevice? |
|||
) : IUpgradeCallback { |
|||
companion object { |
|||
private const val TAG = "JlOtaPlugin" |
|||
private const val CHANNEL_NAME = "com.yunqiinnovation.jl_ota" |
|||
private const val EVENT_CHANNEL_NAME = "com.yunqiinnovation.jl_ota/events" |
|||
|
|||
} |
|||
|
|||
override fun onStartOTA() { |
|||
Log.d(TAG, "onStartOTA开始升级") |
|||
// viewModel.otaStateMLD.value = OTAStart(device) |
|||
} |
|||
|
|||
override fun onNeedReconnect(addr: String?, isNewReconnectWay: Boolean) { |
|||
Log.d(TAG, "onNeedReconnect需要重新链接") |
|||
|
|||
} |
|||
|
|||
override fun onProgress(type: Int, progress: Float) { |
|||
Log.d(TAG, "onProgress正在进展") |
|||
//viewModel.otaStateMLD.value = OTAWorking(device, type, progress) |
|||
} |
|||
|
|||
override fun onStopOTA() { |
|||
Log.d(TAG, "onStartOTA停止升级") |
|||
|
|||
} |
|||
|
|||
override fun onCancelOTA() { |
|||
Log.d(TAG, "onCancelOTA取消升级") |
|||
|
|||
} |
|||
|
|||
override fun onError(error: BaseError?) { |
|||
val errorMessage = error?.message ?: "Unknown error" |
|||
Log.e(TAG, "升级失败: ${error?.message ?: "未知错误"}") |
|||
|
|||
// 如果需要打印完整错误对象信息(假设BaseError有重写toString()) |
|||
Log.d(TAG, "完整错误对象: ${error ?: "null"}") |
|||
} |
|||
} |
|||
|
|||
class OTAManager(context: Context) : BluetoothOTAManager(context) { |
|||
|
|||
|
|||
// 定义回调接口 |
|||
interface DataCallback { |
|||
fun onDataReceived(device: BluetoothDevice?, data: ByteArray?) |
|||
} |
|||
// 保存回调的实例 |
|||
private var dataCallback: DataCallback? = null |
|||
|
|||
// 设置回调的方法 |
|||
fun setDataCallback(callback: DataCallback) { |
|||
this.dataCallback = callback |
|||
} |
|||
override fun getConnectedDevice(): BluetoothDevice? { |
|||
// 这里可以添加空实现的注释说明 |
|||
Log.i(TAG, "ota数据发送getConnectedDevice") |
|||
return null |
|||
} |
|||
|
|||
override fun getConnectedBluetoothGatt(): BluetoothGatt? { |
|||
// 这里可以添加空实现的注释说明 |
|||
Log.i(TAG, "ota数据发送getConnectedBluetoothGatt") |
|||
return null |
|||
} |
|||
|
|||
override fun connectBluetoothDevice(bluetoothDevice: BluetoothDevice?) { |
|||
// 这里可以添加空实现的注释说明 |
|||
Log.i(TAG, "ota数据发送connectBluetoothDevice") |
|||
} |
|||
|
|||
override fun disconnectBluetoothDevice(bluetoothDevice: BluetoothDevice?) { |
|||
// 这里可以添加空实现的注释说明 |
|||
Log.i(TAG, "ota数据发送disconnectBluetoothDevice") |
|||
} |
|||
|
|||
/** 数据发送方法(协议无关) */ |
|||
override fun sendDataToDevice( |
|||
bluetoothDevice: BluetoothDevice?, |
|||
bytes: ByteArray? |
|||
): Boolean { |
|||
Log.i(TAG, "ota数据发送${bluetoothDevice},${bytes?.size}") |
|||
// 触发回调,将数据回传给调用者 |
|||
dataCallback?.onDataReceived(bluetoothDevice, bytes) |
|||
// context.onReceiveDeviceData(device, data); |
|||
return true |
|||
} |
|||
} |
|||
|
|||
@ -0,0 +1,329 @@ |
|||
package com.deep_voice.bytedance_speech |
|||
|
|||
import android.media.AudioAttributes |
|||
import android.media.AudioFormat |
|||
import android.media.AudioManager |
|||
import android.media.AudioTrack |
|||
import android.os.Build |
|||
import android.util.Log |
|||
import com.deep_voice.speech.tts.AudioDataListener |
|||
import kotlinx.coroutines.CoroutineScope |
|||
import kotlinx.coroutines.Dispatchers |
|||
import kotlinx.coroutines.Job |
|||
import kotlinx.coroutines.SupervisorJob |
|||
import kotlinx.coroutines.cancelChildren |
|||
import kotlinx.coroutines.delay |
|||
import kotlinx.coroutines.isActive |
|||
import kotlinx.coroutines.launch |
|||
import kotlinx.coroutines.withContext |
|||
import java.util.concurrent.ConcurrentLinkedQueue |
|||
import java.util.concurrent.atomic.AtomicBoolean |
|||
import java.util.concurrent.CancellationException |
|||
/** |
|||
* 字节跳动语音合成音频播放器 |
|||
* |
|||
* 使用AudioTrack播放PCM格式音频流,基于Kotlin协程实现异步处理 |
|||
*/ |
|||
class BytedanceAudioPlayer : AudioDataListener, CoroutineScope { |
|||
companion object { |
|||
private const val TAG = "BytedanceAudioPlayer" |
|||
private const val SAMPLE_RATE = 24000 // 样本率 |
|||
private const val CHANNEL_CONFIG = AudioFormat.CHANNEL_OUT_MONO // 单声道 |
|||
private const val AUDIO_FORMAT = AudioFormat.ENCODING_PCM_16BIT // 16位PCM |
|||
private const val IDLE_TIMEOUT_MS = 800L // 无数据超时时间 |
|||
} |
|||
|
|||
// 协程相关 |
|||
private val job = SupervisorJob() |
|||
override val coroutineContext = Dispatchers.IO + job |
|||
|
|||
// 音频处理相关 |
|||
private var audioTrack: AudioTrack? = null |
|||
private val isPlaying = AtomicBoolean(false) |
|||
private val isPaused = AtomicBoolean(false) |
|||
|
|||
// 播放状态 |
|||
private val playStarted = AtomicBoolean(false) |
|||
private val completionReported = AtomicBoolean(false) |
|||
|
|||
// 使用简单的并发队列,保证线程安全 |
|||
private val audioDataQueue = ConcurrentLinkedQueue<ByteArray>() |
|||
|
|||
// 播放状态监听 |
|||
private var onPlayStarted: (() -> Unit)? = null |
|||
private var onPlayCompleted: (() -> Unit)? = null |
|||
private var onError: ((String) -> Unit)? = null |
|||
|
|||
// 处理协程 |
|||
private var processingJob: Job? = null |
|||
private var lastDataTime = 0L |
|||
|
|||
init { |
|||
Log.d(TAG, "BytedanceAudioPlayer初始化") |
|||
} |
|||
|
|||
/** |
|||
* 开始播放 |
|||
*/ |
|||
fun start() { |
|||
if (isPlaying.getAndSet(true)) return |
|||
|
|||
isPaused.set(false) |
|||
playStarted.set(false) |
|||
completionReported.set(false) |
|||
lastDataTime = System.currentTimeMillis() |
|||
|
|||
processingJob = launch { |
|||
try { |
|||
initAudioTrack() |
|||
|
|||
// 处理队列中的音频数据 |
|||
while (isActive && isPlaying.get()) { |
|||
processQueuedAudio() |
|||
|
|||
// 检查是否播放完成 |
|||
checkPlaybackCompletion() |
|||
|
|||
// 队列为空时短暂延迟 |
|||
if (audioDataQueue.isEmpty()) { |
|||
delay(10) |
|||
} |
|||
} |
|||
} catch (e: CancellationException) { |
|||
// 协程被取消,正常行为 |
|||
} catch (e: Exception) { |
|||
Log.e(TAG, "播放错误: ${e.message}") |
|||
onError?.invoke("播放错误: ${e.message}") |
|||
stopInternal(false) |
|||
} |
|||
} |
|||
} |
|||
|
|||
/** |
|||
* 暂停播放 |
|||
*/ |
|||
fun pause() { |
|||
if (!isPlaying.get() || isPaused.getAndSet(true)) return |
|||
audioTrack?.pause() |
|||
} |
|||
|
|||
/** |
|||
* 恢复播放 |
|||
*/ |
|||
fun resume() { |
|||
if (!isPlaying.get() || !isPaused.getAndSet(false)) return |
|||
audioTrack?.play() |
|||
} |
|||
|
|||
/** |
|||
* 停止播放 |
|||
*/ |
|||
fun stop() { |
|||
Log.d(TAG, "停止播放") |
|||
stopInternal(true) |
|||
} |
|||
|
|||
/** |
|||
* 内部停止处理 |
|||
*/ |
|||
private fun stopInternal(reportCompletion: Boolean) { |
|||
if (!isPlaying.getAndSet(false)) return |
|||
|
|||
isPaused.set(false) |
|||
processingJob?.cancel() |
|||
|
|||
audioTrack?.stop() |
|||
// 不在stopInternal中释放资源,只停止播放 |
|||
|
|||
// 清空队列 |
|||
audioDataQueue.clear() |
|||
|
|||
// 播放完成通知 |
|||
if (reportCompletion && playStarted.get() && !completionReported.getAndSet(true)) { |
|||
launch(Dispatchers.Main) { |
|||
onPlayCompleted?.invoke() |
|||
} |
|||
} |
|||
} |
|||
|
|||
/** |
|||
* 释放资源 |
|||
*/ |
|||
fun release() { |
|||
stopInternal(false) |
|||
|
|||
// 在release方法中释放AudioTrack资源 |
|||
audioTrack?.release() |
|||
audioTrack = null |
|||
|
|||
// 释放协程资源 |
|||
job.cancel() |
|||
} |
|||
|
|||
/** |
|||
* 设置播放开始回调 |
|||
*/ |
|||
fun setOnPlayStarted(listener: () -> Unit) { |
|||
onPlayStarted = listener |
|||
} |
|||
|
|||
/** |
|||
* 设置播放完成回调 |
|||
*/ |
|||
fun setOnPlayCompleted(listener: () -> Unit) { |
|||
onPlayCompleted = listener |
|||
} |
|||
|
|||
/** |
|||
* 设置错误回调 |
|||
*/ |
|||
fun setOnError(listener: (String) -> Unit) { |
|||
onError = listener |
|||
} |
|||
|
|||
/** |
|||
* 接收音频数据(实现AudioDataListener接口) |
|||
*/ |
|||
override fun onAudioData(data: ByteArray) { |
|||
if (data.isEmpty()) return |
|||
|
|||
lastDataTime = System.currentTimeMillis() |
|||
|
|||
if (!isPlaying.get()) { |
|||
start() |
|||
} |
|||
|
|||
// 添加到队列 - 简单有效,保证FIFO顺序 |
|||
audioDataQueue.add(data.copyOf()) |
|||
} |
|||
|
|||
/** |
|||
* 检查是否正在播放 |
|||
*/ |
|||
fun isPlaying(): Boolean = isPlaying.get() && !isPaused.get() |
|||
|
|||
/** |
|||
* 处理队列中的音频数据 |
|||
*/ |
|||
private suspend fun processQueuedAudio() { |
|||
if (isPaused.get() || !isPlaying.get() || !isActive) return |
|||
|
|||
// 获取并处理队列中的数据 |
|||
val data = audioDataQueue.poll() ?: return |
|||
|
|||
// 写入音频数据 |
|||
val result = audioTrack?.write(data, 0, data.size) ?: 0 |
|||
|
|||
if (result > 0) { |
|||
// 标记播放开始 |
|||
if (!playStarted.getAndSet(true)) { |
|||
withContext(Dispatchers.Main) { |
|||
onPlayStarted?.invoke() |
|||
} |
|||
} |
|||
} else if (result < 0) { |
|||
// 处理错误 |
|||
handleAudioTrackError(result) |
|||
} |
|||
} |
|||
|
|||
/** |
|||
* 检查播放是否完成(超时无数据) |
|||
*/ |
|||
private suspend fun checkPlaybackCompletion() { |
|||
val currentTime = System.currentTimeMillis() |
|||
|
|||
// 超时判断 - 队列为空且超过超时时间 |
|||
if (playStarted.get() && audioDataQueue.isEmpty() && |
|||
currentTime - lastDataTime > IDLE_TIMEOUT_MS && isPlaying.get()) { |
|||
|
|||
// 播放完成 |
|||
if (!completionReported.getAndSet(true)) { |
|||
withContext(Dispatchers.Main) { |
|||
onPlayCompleted?.invoke() |
|||
} |
|||
stopInternal(false) // 已报告,不需要再次报告 |
|||
} |
|||
} |
|||
} |
|||
|
|||
/** |
|||
* 处理AudioTrack错误 |
|||
*/ |
|||
private fun handleAudioTrackError(result: Int) { |
|||
val errorMsg = when(result) { |
|||
AudioTrack.ERROR_INVALID_OPERATION -> "无效操作" |
|||
AudioTrack.ERROR_BAD_VALUE -> "参数错误" |
|||
AudioTrack.ERROR_DEAD_OBJECT -> "对象已销毁" |
|||
else -> "未知错误" |
|||
} |
|||
|
|||
// 对象已销毁时尝试重建 |
|||
if (result == AudioTrack.ERROR_DEAD_OBJECT) { |
|||
try { |
|||
audioTrack?.release() |
|||
initAudioTrack() |
|||
} catch (e: Exception) { |
|||
Log.e(TAG, "重建AudioTrack失败: ${e.message}") |
|||
throw e |
|||
} |
|||
} else { |
|||
Log.e(TAG, "AudioTrack错误: $errorMsg") |
|||
} |
|||
} |
|||
|
|||
/** |
|||
* 初始化AudioTrack |
|||
*/ |
|||
private fun initAudioTrack() { |
|||
val minBufferSize = AudioTrack.getMinBufferSize( |
|||
SAMPLE_RATE, CHANNEL_CONFIG, AUDIO_FORMAT |
|||
) |
|||
|
|||
if (minBufferSize == AudioTrack.ERROR || minBufferSize == AudioTrack.ERROR_BAD_VALUE) { |
|||
throw IllegalStateException("无法获取有效的音频缓冲区大小") |
|||
} |
|||
|
|||
// 使用较大的缓冲区提高稳定性 |
|||
val bufferSize = minBufferSize * 4 |
|||
|
|||
// 创建AudioTrack |
|||
audioTrack = if (Build.VERSION.SDK_INT >= 23) { // Android M (6.0) |
|||
AudioTrack.Builder() |
|||
.setAudioAttributes( |
|||
AudioAttributes.Builder() |
|||
.setUsage(AudioAttributes.USAGE_MEDIA) |
|||
.setContentType(AudioAttributes.CONTENT_TYPE_SPEECH) |
|||
.build() |
|||
) |
|||
.setAudioFormat( |
|||
AudioFormat.Builder() |
|||
.setEncoding(AUDIO_FORMAT) |
|||
.setSampleRate(SAMPLE_RATE) |
|||
.setChannelMask(CHANNEL_CONFIG) |
|||
.build() |
|||
) |
|||
.setBufferSizeInBytes(bufferSize) |
|||
.setTransferMode(AudioTrack.MODE_STREAM) |
|||
.build() |
|||
} else { |
|||
@Suppress("DEPRECATION") |
|||
AudioTrack( |
|||
AudioManager.STREAM_MUSIC, |
|||
SAMPLE_RATE, |
|||
CHANNEL_CONFIG, |
|||
AUDIO_FORMAT, |
|||
bufferSize, |
|||
AudioTrack.MODE_STREAM |
|||
) |
|||
} |
|||
|
|||
// 检查初始化状态 |
|||
if (audioTrack?.state != AudioTrack.STATE_INITIALIZED) { |
|||
throw IllegalStateException("AudioTrack初始化失败") |
|||
} |
|||
|
|||
// 开始播放 |
|||
audioTrack?.play() |
|||
} |
|||
} |
|||
File diff suppressed because it is too large
@ -1,46 +0,0 @@ |
|||
plugins { |
|||
// Android Library 插件 |
|||
id("com.android.library") |
|||
// Kotlin Android 插件 |
|||
id("org.jetbrains.kotlin.android") |
|||
id("com.github.johnrengelman.shadow") version "8.1.1" |
|||
} |
|||
|
|||
android { |
|||
// 命名空间,对应你插件的包名(需与代码内包名保持一致) |
|||
namespace = "com.yunqiinnovation.deepvoice_utils" |
|||
|
|||
// 目标 SDK 版本 |
|||
compileSdk = 35 |
|||
|
|||
defaultConfig { |
|||
// 最低 SDK 版本 |
|||
minSdk = 21 |
|||
targetSdk = 34 |
|||
} |
|||
|
|||
// Java 语言级别兼容配置 |
|||
compileOptions { |
|||
sourceCompatibility = JavaVersion.VERSION_11 |
|||
targetCompatibility = JavaVersion.VERSION_11 |
|||
} |
|||
|
|||
// Kotlin 语言级别 |
|||
kotlinOptions { |
|||
jvmTarget = "11" |
|||
} |
|||
|
|||
// 添加packaging配置,排除冲突文件 |
|||
packaging { |
|||
resources { |
|||
excludes.add("META-INF/DEPENDENCIES") |
|||
excludes.add("META-INF/LICENSE") |
|||
excludes.add("META-INF/LICENSE.txt") |
|||
excludes.add("META-INF/license.txt") |
|||
excludes.add("META-INF/NOTICE") |
|||
excludes.add("META-INF/NOTICE.txt") |
|||
excludes.add("META-INF/notice.txt") |
|||
excludes.add("META-INF/*.kotlin_module") |
|||
} |
|||
} |
|||
} |
|||
@ -1 +0,0 @@ |
|||
rootProject.name = "deepvoice_utils" |
|||
@ -1,7 +0,0 @@ |
|||
<?xml version="1.0" encoding="utf-8"?> |
|||
<manifest xmlns:android="http://schemas.android.com/apk/res/android" |
|||
package="com.yunqiinnovation.deepvoice_utils"> |
|||
|
|||
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" /> |
|||
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" /> |
|||
</manifest> |
|||
@ -1,33 +0,0 @@ |
|||
package com.yunqiinnovation.deepvoice_utils |
|||
|
|||
import androidx.annotation.NonNull |
|||
import io.flutter.embedding.engine.plugins.FlutterPlugin |
|||
import io.flutter.plugin.common.MethodCall |
|||
import io.flutter.plugin.common.MethodChannel |
|||
import io.flutter.plugin.common.MethodChannel.MethodCallHandler |
|||
import io.flutter.plugin.common.MethodChannel.Result |
|||
|
|||
/** DeepvoiceUtilsPlugin */ |
|||
class DeepvoiceUtilsPlugin: FlutterPlugin, MethodCallHandler { |
|||
private lateinit var channel : MethodChannel |
|||
|
|||
override fun onAttachedToEngine(@NonNull flutterPluginBinding: FlutterPlugin.FlutterPluginBinding) { |
|||
channel = MethodChannel(flutterPluginBinding.binaryMessenger, "deepvoice_utils") |
|||
channel.setMethodCallHandler(this) |
|||
} |
|||
|
|||
override fun onMethodCall(@NonNull call: MethodCall, @NonNull result: Result) { |
|||
when (call.method) { |
|||
"getPlatformVersion" -> { |
|||
result.success("Android ${android.os.Build.VERSION.RELEASE}") |
|||
} |
|||
else -> { |
|||
result.notImplemented() |
|||
} |
|||
} |
|||
} |
|||
|
|||
override fun onDetachedFromEngine(@NonNull binding: FlutterPlugin.FlutterPluginBinding) { |
|||
channel.setMethodCallHandler(null) |
|||
} |
|||
} |
|||
@ -1,237 +0,0 @@ |
|||
package com.yunqiinnovation.deepvoice_utils |
|||
|
|||
import android.content.Context |
|||
import android.util.Log |
|||
import java.io.File |
|||
import java.io.FileWriter |
|||
import java.io.IOException |
|||
import java.text.SimpleDateFormat |
|||
import java.util.* |
|||
import java.util.concurrent.ExecutorService |
|||
import java.util.concurrent.Executors |
|||
|
|||
/** |
|||
* 文件日志工具 |
|||
* 将日志同时输出到Logcat和文件系统 |
|||
*/ |
|||
class FileLogger private constructor(context: Context) { |
|||
private val logDir: File |
|||
private var currentLogFile: File? = null |
|||
private val dateFormat = SimpleDateFormat("yyyy-MM-dd", Locale.getDefault()) |
|||
private val timeFormat = SimpleDateFormat("HH:mm:ss.SSS", Locale.getDefault()) |
|||
private val logExecutor: ExecutorService = Executors.newSingleThreadExecutor() |
|||
|
|||
init { |
|||
// 初始化日志目录 |
|||
logDir = File(context.getExternalFilesDir(null), "logs") |
|||
if (!logDir.exists()) { |
|||
logDir.mkdirs() |
|||
} |
|||
|
|||
// 创建当天的日志文件 |
|||
updateLogFile() |
|||
} |
|||
|
|||
/** |
|||
* 更新日志文件为当天日期 |
|||
*/ |
|||
private fun updateLogFile() { |
|||
val today = dateFormat.format(Date()) |
|||
val logFile = File(logDir, "log_$today.txt") |
|||
|
|||
if (currentLogFile == null || currentLogFile?.absolutePath != logFile.absolutePath) { |
|||
currentLogFile = logFile |
|||
|
|||
// 如果是新文件,添加文件头 |
|||
if (!logFile.exists()) { |
|||
try { |
|||
logFile.createNewFile() |
|||
FileWriter(logFile, true).use { writer -> |
|||
writer.append("===== 日志开始: ${SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.getDefault()).format(Date())} =====\n") |
|||
} |
|||
} catch (e: IOException) { |
|||
Log.e(TAG, "创建日志文件失败: ${e.message}") |
|||
} |
|||
} |
|||
} |
|||
} |
|||
|
|||
/** |
|||
* 调试级别日志 |
|||
*/ |
|||
fun d(tag: String, message: String) { |
|||
// 输出到Logcat |
|||
Log.d(tag, message) |
|||
|
|||
// 异步写入文件 |
|||
logToFile("D", tag, message) |
|||
} |
|||
|
|||
/** |
|||
* 信息级别日志 |
|||
*/ |
|||
fun i(tag: String, message: String) { |
|||
// 输出到Logcat |
|||
Log.i(tag, message) |
|||
|
|||
// 异步写入文件 |
|||
logToFile("I", tag, message) |
|||
} |
|||
|
|||
/** |
|||
* 警告级别日志 |
|||
*/ |
|||
fun w(tag: String, message: String) { |
|||
// 输出到Logcat |
|||
Log.w(tag, message) |
|||
|
|||
// 异步写入文件 |
|||
logToFile("W", tag, message) |
|||
} |
|||
|
|||
/** |
|||
* 错误级别日志 |
|||
*/ |
|||
fun e(tag: String, message: String, throwable: Throwable? = null) { |
|||
// 输出到Logcat |
|||
if (throwable != null) { |
|||
Log.e(tag, message, throwable) |
|||
} else { |
|||
Log.e(tag, message) |
|||
} |
|||
|
|||
// 异步写入文件 |
|||
val errorMsg = if (throwable != null) { |
|||
"$message\n${throwable.message}\n${throwable.stackTraceToString()}" |
|||
} else { |
|||
message |
|||
} |
|||
|
|||
logToFile("E", tag, errorMsg) |
|||
} |
|||
|
|||
/** |
|||
* 将日志写入文件 |
|||
*/ |
|||
private fun logToFile(level: String, tag: String, message: String) { |
|||
|
|||
logExecutor.execute { |
|||
try { |
|||
// 检查是否需要更新日志文件 |
|||
updateLogFile() |
|||
|
|||
// 格式化日志内容 |
|||
val timestamp = timeFormat.format(Date()) |
|||
val logLine = "$timestamp $level/$tag: $message\n" |
|||
|
|||
// 写入文件 |
|||
if (currentLogFile != null && currentLogFile!!.exists()) { |
|||
FileWriter(currentLogFile!!, true).use { writer -> |
|||
writer.append(logLine) |
|||
} |
|||
} |
|||
} catch (e: IOException) { |
|||
Log.e(TAG, "写入日志文件失败: ${e.message}") |
|||
} |
|||
} |
|||
} |
|||
|
|||
/** |
|||
* 清理旧日志文件 |
|||
* @param daysToKeep 保留的天数 |
|||
*/ |
|||
fun cleanOldLogs(daysToKeep: Int = 7) { |
|||
logExecutor.execute { |
|||
try { |
|||
val currentTime = System.currentTimeMillis() |
|||
val maxAge = daysToKeep * 24 * 60 * 60 * 1000L |
|||
|
|||
logDir.listFiles()?.forEach { file -> |
|||
if (file.isFile && file.name.startsWith("log_") && file.name.endsWith(".txt")) { |
|||
val fileAge = currentTime - file.lastModified() |
|||
if (fileAge > maxAge) { |
|||
val deleted = file.delete() |
|||
if (deleted) { |
|||
Log.d(TAG, "已删除旧日志文件: ${file.name}") |
|||
} |
|||
} |
|||
} |
|||
} |
|||
} catch (e: Exception) { |
|||
Log.e(TAG, "清理旧日志失败: ${e.message}") |
|||
} |
|||
} |
|||
} |
|||
|
|||
/** |
|||
* 关闭日志系统 |
|||
*/ |
|||
fun shutdown() { |
|||
logExecutor.shutdown() |
|||
} |
|||
|
|||
companion object { |
|||
private const val TAG = "FileLogger" |
|||
private var INSTANCE: FileLogger? = null |
|||
|
|||
/** |
|||
* 初始化日志系统 |
|||
*/ |
|||
@Synchronized |
|||
fun init(context: Context) { |
|||
if (INSTANCE == null) { |
|||
INSTANCE = FileLogger(context.applicationContext) |
|||
d(TAG, "文件日志系统已初始化") |
|||
} |
|||
} |
|||
|
|||
/** |
|||
* 调试日志 |
|||
*/ |
|||
fun d(tag: String, message: String) { |
|||
INSTANCE?.d(tag, message) ?: Log.d(tag, message) |
|||
} |
|||
|
|||
/** |
|||
* 信息日志 |
|||
*/ |
|||
fun i(tag: String, message: String) { |
|||
INSTANCE?.i(tag, message) ?: Log.i(tag, message) |
|||
} |
|||
|
|||
/** |
|||
* 警告日志 |
|||
*/ |
|||
fun w(tag: String, message: String) { |
|||
INSTANCE?.w(tag, message) ?: Log.w(tag, message) |
|||
} |
|||
|
|||
/** |
|||
* 错误日志 |
|||
*/ |
|||
fun e(tag: String, message: String, throwable: Throwable? = null) { |
|||
INSTANCE?.e(tag, message, throwable) ?: run { |
|||
if (throwable != null) { |
|||
Log.e(tag, message, throwable) |
|||
} else { |
|||
Log.e(tag, message) |
|||
} |
|||
} |
|||
} |
|||
|
|||
/** |
|||
* 清理旧日志 |
|||
*/ |
|||
fun cleanOldLogs(daysToKeep: Int = 7) { |
|||
INSTANCE?.cleanOldLogs(daysToKeep) |
|||
} |
|||
|
|||
/** |
|||
* 关闭日志系统 |
|||
*/ |
|||
fun shutdown() { |
|||
INSTANCE?.shutdown() |
|||
INSTANCE = null |
|||
} |
|||
} |
|||
} |
|||
@ -1,4 +0,0 @@ |
|||
#import <Flutter/Flutter.h> |
|||
|
|||
@interface DeepvoiceUtilsPlugin : NSObject<FlutterPlugin> |
|||
@end |
|||
@ -1,12 +0,0 @@ |
|||
#import "DeepvoiceUtilsPlugin.h" |
|||
#if __has_include(<deepvoice_utils/deepvoice_utils-Swift.h>) |
|||
#import <deepvoice_utils/deepvoice_utils-Swift.h> |
|||
#else |
|||
#import "deepvoice_utils-Swift.h" |
|||
#endif |
|||
|
|||
@implementation DeepvoiceUtilsPlugin |
|||
+ (void)registerWithRegistrar:(NSObject<FlutterPluginRegistrar>*)registrar { |
|||
[SwiftDeepvoiceUtilsPlugin registerWithRegistrar:registrar]; |
|||
} |
|||
@end |
|||
@ -1,30 +0,0 @@ |
|||
import Flutter |
|||
import UIKit |
|||
|
|||
public class DeepvoiceUtilsPlugin: NSObject, FlutterPlugin { |
|||
private static let TAG = "DeepvoiceUtilsPlugin" |
|||
|
|||
public static func register(with registrar: FlutterPluginRegistrar) { |
|||
let channel = FlutterMethodChannel(name: "deepvoice_utils", binaryMessenger: registrar.messenger()) |
|||
let instance = DeepvoiceUtilsPlugin() |
|||
registrar.addMethodCallDelegate(instance, channel: channel) |
|||
|
|||
// 初始化文件日志 |
|||
FileLogger.initialize() |
|||
FileLogger.d(TAG, "DeepvoiceUtilsPlugin 已初始化") |
|||
} |
|||
|
|||
public func handle(_ call: FlutterMethodCall, result: @escaping FlutterResult) { |
|||
switch call.method { |
|||
case "getPlatformVersion": |
|||
result("iOS \(UIDevice.current.systemVersion)") |
|||
default: |
|||
result(FlutterMethodNotImplemented) |
|||
} |
|||
} |
|||
|
|||
public func detachFromEngine(for registrar: FlutterPluginRegistrar) { |
|||
// 关闭日志系统 |
|||
FileLogger.shutdown() |
|||
} |
|||
} |
|||
@ -1,286 +0,0 @@ |
|||
import Foundation |
|||
import os.log |
|||
|
|||
/** |
|||
* 文件日志工具 |
|||
* 将日志同时输出到控制台和文件系统 |
|||
*/ |
|||
@objc public class FileLogger: NSObject { |
|||
|
|||
private static let TAG = "FileLogger" |
|||
private static var instance: FileLogger? |
|||
|
|||
private let logDir: URL |
|||
private var currentLogFile: URL? |
|||
private let logQueue = DispatchQueue(label: "com.yunqiinnovation.deepvoice_utils.logger", qos: .utility) |
|||
|
|||
private let dateFormatter: DateFormatter = { |
|||
let formatter = DateFormatter() |
|||
formatter.dateFormat = "yyyy-MM-dd" |
|||
return formatter |
|||
}() |
|||
|
|||
private let timeFormatter: DateFormatter = { |
|||
let formatter = DateFormatter() |
|||
formatter.dateFormat = "HH:mm:ss.SSS" |
|||
return formatter |
|||
}() |
|||
|
|||
/** |
|||
* 初始化日志系统 |
|||
*/ |
|||
private init(directory: URL) { |
|||
self.logDir = directory.appendingPathComponent("logs", isDirectory: true) |
|||
super.init() |
|||
|
|||
// 创建日志目录 |
|||
try? FileManager.default.createDirectory(at: logDir, withIntermediateDirectories: true) |
|||
|
|||
// 创建当天日志文件 |
|||
updateLogFile() |
|||
|
|||
// 记录初始化完成 |
|||
self.d(FileLogger.TAG, "文件日志系统已初始化") |
|||
} |
|||
|
|||
/** |
|||
* 更新日志文件为当天日期 |
|||
*/ |
|||
private func updateLogFile() { |
|||
let today = dateFormatter.string(from: Date()) |
|||
let logFile = logDir.appendingPathComponent("log_\(today).txt") |
|||
|
|||
if currentLogFile == nil || currentLogFile?.path != logFile.path { |
|||
currentLogFile = logFile |
|||
|
|||
// 如果是新文件,添加文件头 |
|||
if !FileManager.default.fileExists(atPath: logFile.path) { |
|||
let headerDate = DateFormatter() |
|||
headerDate.dateFormat = "yyyy-MM-dd HH:mm:ss" |
|||
let headerLine = "===== 日志开始: \(headerDate.string(from: Date())) =====\n" |
|||
|
|||
try? headerLine.write(to: logFile, atomically: true, encoding: .utf8) |
|||
} |
|||
} |
|||
} |
|||
|
|||
/** |
|||
* 将日志写入文件 |
|||
*/ |
|||
private func logToFile(level: String, tag: String, message: String) { |
|||
logQueue.async { [weak self] in |
|||
guard let self = self else { return } |
|||
|
|||
do { |
|||
// 检查是否需要更新日志文件 |
|||
self.updateLogFile() |
|||
|
|||
guard let logFile = self.currentLogFile, |
|||
FileManager.default.fileExists(atPath: logFile.path) else { |
|||
return |
|||
} |
|||
|
|||
// 格式化日志内容 |
|||
let timestamp = self.timeFormatter.string(from: Date()) |
|||
let logLine = "\(timestamp) \(level)/\(tag): \(message)\n" |
|||
|
|||
// 获取文件句柄并追加内容 |
|||
if let fileHandle = try? FileHandle(forWritingTo: logFile) { |
|||
fileHandle.seekToEndOfFile() |
|||
if let data = logLine.data(using: .utf8) { |
|||
fileHandle.write(data) |
|||
} |
|||
fileHandle.closeFile() |
|||
} else { |
|||
// 如果无法获取文件句柄,尝试直接写入 |
|||
let existingContent = try? String(contentsOf: logFile, encoding: .utf8) |
|||
let newContent = (existingContent ?? "") + logLine |
|||
try newContent.write(to: logFile, atomically: true, encoding: .utf8) |
|||
} |
|||
} catch { |
|||
print("写入日志文件失败: \(error.localizedDescription)") |
|||
} |
|||
} |
|||
} |
|||
|
|||
/** |
|||
* 调试级别日志 |
|||
*/ |
|||
private func d(_ tag: String, _ message: String) { |
|||
// 输出到控制台 |
|||
print("D/\(tag): \(message)") |
|||
|
|||
// 写入文件 |
|||
logToFile(level: "D", tag: tag, message: message) |
|||
} |
|||
|
|||
/** |
|||
* 信息级别日志 |
|||
*/ |
|||
private func i(_ tag: String, _ message: String) { |
|||
// 输出到控制台 |
|||
print("I/\(tag): \(message)") |
|||
|
|||
// 写入文件 |
|||
logToFile(level: "I", tag: tag, message: message) |
|||
} |
|||
|
|||
/** |
|||
* 警告级别日志 |
|||
*/ |
|||
private func w(_ tag: String, _ message: String) { |
|||
// 输出到控制台 |
|||
print("W/\(tag): \(message)") |
|||
|
|||
// 写入文件 |
|||
logToFile(level: "W", tag: tag, message: message) |
|||
} |
|||
|
|||
/** |
|||
* 错误级别日志 |
|||
*/ |
|||
private func e(_ tag: String, _ message: String, _ error: Error? = nil) { |
|||
var fullMessage = message |
|||
|
|||
// 如果有错误,添加错误信息 |
|||
if let error = error { |
|||
fullMessage += "\n\(error.localizedDescription)" |
|||
} |
|||
|
|||
// 输出到控制台 |
|||
print("E/\(tag): \(fullMessage)") |
|||
|
|||
// 写入文件 |
|||
logToFile(level: "E", tag: tag, message: fullMessage) |
|||
} |
|||
|
|||
/** |
|||
* 清理旧日志文件 |
|||
*/ |
|||
private func cleanOldLogs(daysToKeep: Int = 7) { |
|||
logQueue.async { |
|||
do { |
|||
let currentDate = Date() |
|||
let fileManager = FileManager.default |
|||
|
|||
let logFiles = try fileManager.contentsOfDirectory(at: self.logDir, includingPropertiesForKeys: [.creationDateKey], options: []) |
|||
|
|||
for fileURL in logFiles where fileURL.pathExtension == "txt" { |
|||
if let fileAttributes = try? fileManager.attributesOfItem(atPath: fileURL.path), |
|||
let creationDate = fileAttributes[.creationDate] as? Date { |
|||
|
|||
let age = Calendar.current.dateComponents([.day], from: creationDate, to: currentDate) |
|||
|
|||
if let days = age.day, days > daysToKeep { |
|||
try? fileManager.removeItem(at: fileURL) |
|||
print("D/\(FileLogger.TAG): 已删除旧日志文件: \(fileURL.lastPathComponent)") |
|||
} |
|||
} |
|||
} |
|||
} catch { |
|||
print("E/\(FileLogger.TAG): 清理旧日志失败: \(error.localizedDescription)") |
|||
} |
|||
} |
|||
} |
|||
|
|||
// MARK: - 公共静态方法 |
|||
|
|||
/** |
|||
* 初始化日志系统 |
|||
*/ |
|||
@objc public static func initialize() { |
|||
if instance == nil { |
|||
let documentsDirectory = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0] |
|||
instance = FileLogger(directory: documentsDirectory) |
|||
} |
|||
} |
|||
|
|||
/** |
|||
* 初始化日志系统(使用自定义目录) |
|||
*/ |
|||
@objc public static func initialize(withDirectory directory: URL) { |
|||
if instance == nil { |
|||
instance = FileLogger(directory: directory) |
|||
} |
|||
} |
|||
|
|||
/** |
|||
* 调试日志 |
|||
*/ |
|||
@objc public static func d(_ tag: String, _ message: String) { |
|||
// 如果实例不存在,先使用默认目录初始化 |
|||
if instance == nil { |
|||
initialize() |
|||
} |
|||
|
|||
instance?.d(tag, message) |
|||
|
|||
// 使用系统日志 |
|||
if #available(iOS 10.0, *) { |
|||
os_log("%{public}@", log: OSLog(subsystem: "com.yunqiinnovation.deepvoice_utils", category: tag), type: .debug, message) |
|||
} |
|||
} |
|||
|
|||
/** |
|||
* 信息日志 |
|||
*/ |
|||
@objc public static func i(_ tag: String, _ message: String) { |
|||
if instance == nil { |
|||
initialize() |
|||
} |
|||
|
|||
instance?.i(tag, message) |
|||
|
|||
// 使用系统日志 |
|||
if #available(iOS 10.0, *) { |
|||
os_log("%{public}@", log: OSLog(subsystem: "com.yunqiinnovation.deepvoice_utils", category: tag), type: .info, message) |
|||
} |
|||
} |
|||
|
|||
/** |
|||
* 警告日志 |
|||
*/ |
|||
@objc public static func w(_ tag: String, _ message: String) { |
|||
if instance == nil { |
|||
initialize() |
|||
} |
|||
|
|||
instance?.w(tag, message) |
|||
|
|||
// 使用系统日志 |
|||
if #available(iOS 10.0, *) { |
|||
os_log("%{public}@", log: OSLog(subsystem: "com.yunqiinnovation.deepvoice_utils", category: tag), type: .default, message) |
|||
} |
|||
} |
|||
|
|||
/** |
|||
* 错误日志 |
|||
*/ |
|||
@objc public static func e(_ tag: String, _ message: String, _ error: Error? = nil) { |
|||
if instance == nil { |
|||
initialize() |
|||
} |
|||
|
|||
instance?.e(tag, message, error) |
|||
|
|||
// 使用系统日志 |
|||
if #available(iOS 10.0, *) { |
|||
let errorMessage = error != nil ? "\(message): \(error!.localizedDescription)" : message |
|||
os_log("%{public}@", log: OSLog(subsystem: "com.yunqiinnovation.deepvoice_utils", category: tag), type: .error, errorMessage) |
|||
} |
|||
} |
|||
|
|||
/** |
|||
* 清理旧日志 |
|||
*/ |
|||
@objc public static func cleanOldLogs(daysToKeep: Int = 7) { |
|||
instance?.cleanOldLogs(daysToKeep: daysToKeep) |
|||
} |
|||
|
|||
/** |
|||
* 关闭日志系统 |
|||
*/ |
|||
@objc public static func shutdown() { |
|||
instance = nil |
|||
} |
|||
} |
|||
@ -1,11 +0,0 @@ |
|||
import Flutter |
|||
import UIKit |
|||
|
|||
// 这个类是为了满足Flutter插件的命名约定 |
|||
// 实际实现在DeepvoiceUtilsPlugin.swift中 |
|||
public class SwiftDeepvoiceUtilsPlugin: NSObject, FlutterPlugin { |
|||
public static func register(with registrar: FlutterPluginRegistrar) { |
|||
// 调用真正的实现 |
|||
DeepvoiceUtilsPlugin.register(with: registrar) |
|||
} |
|||
} |
|||
@ -1,54 +0,0 @@ |
|||
# DeepVoice Utils iOS 实现 |
|||
|
|||
## 简介 |
|||
|
|||
本模块是 `deepvoice_utils` 插件的 iOS 实现部分,主要提供以下功能: |
|||
|
|||
- 文件日志记录(FileLogger):将日志记录到文件,方便调试和问题排查 |
|||
|
|||
## 使用说明 |
|||
|
|||
### 日志系统 |
|||
|
|||
该插件自动初始化日志系统,开发者无需手动调用初始化方法。日志文件默认保存在应用的 Documents 目录下的 logs 文件夹中,按日期分文件存储。 |
|||
|
|||
## 配置要求 |
|||
|
|||
- iOS 11.0 或更高版本 |
|||
- Swift 5.0 |
|||
- 确保应用拥有文件写入权限 |
|||
|
|||
## 开发说明 |
|||
|
|||
### 文件结构 |
|||
|
|||
- `DeepvoiceUtilsPlugin.swift`:插件主类,负责与 Flutter 交互 |
|||
- `FileLogger.swift`:日志系统实现,提供日志记录功能 |
|||
- `SwiftDeepvoiceUtilsPlugin.swift`:命名约定桥接类 |
|||
- `DeepvoiceUtilsPlugin.h/.m`:Objective-C 桥接文件 |
|||
|
|||
### 自定义日志目录 |
|||
|
|||
如需使用自定义日志目录,可以在应用启动时手动调用初始化方法: |
|||
|
|||
```swift |
|||
import deepvoice_utils |
|||
|
|||
// 在 AppDelegate 中 |
|||
func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool { |
|||
// 自定义日志目录 |
|||
let customDirectory = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0].appendingPathComponent("custom_logs") |
|||
FileLogger.initialize(withDirectory: customDirectory) |
|||
|
|||
return true |
|||
} |
|||
``` |
|||
|
|||
### 清理旧日志 |
|||
|
|||
默认情况下,日志系统不会自动清理旧日志。如果需要清理,可以手动调用: |
|||
|
|||
```swift |
|||
// 清理7天前的日志 |
|||
FileLogger.cleanOldLogs(daysToKeep: 7) |
|||
``` |
|||
@ -1,19 +0,0 @@ |
|||
# |
|||
# To learn more about a Podspec see http://guides.cocoapods.org/syntax/podspec.html. |
|||
# |
|||
Pod::Spec.new do |s| |
|||
s.name = 'deepvoice_utils' |
|||
s.version = '0.0.1' |
|||
s.summary = '深语工具库' |
|||
s.description = <<-DESC |
|||
提供文件日志记录等通用功能的Flutter插件。 |
|||
DESC |
|||
s.homepage = 'http://yunqiinnovation.com' |
|||
s.license = { :file => '../LICENSE' } |
|||
s.author = { 'YunQi Innovation' => 'support@yunqiinnovation.com' } |
|||
s.source = { :path => '.' } |
|||
s.source_files = 'Classes/**/*' |
|||
|
|||
s.ios.deployment_target = '11.0' |
|||
s.swift_version = '5.0' |
|||
end |
|||
@ -1,3 +0,0 @@ |
|||
library deepvoice_utils; |
|||
|
|||
export 'src/file_logger.dart'; |
|||
@ -1,31 +0,0 @@ |
|||
import 'dart:io'; |
|||
import 'package:flutter/foundation.dart'; |
|||
|
|||
/// 文件日志记录器 |
|||
/// |
|||
/// 提供文件日志记录功能,支持简单的日志记录 |
|||
class FileLogger { |
|||
/// 记录调试日志 |
|||
static void d(String tag, String message) { |
|||
debugPrint('[$tag] DEBUG: $message'); |
|||
} |
|||
|
|||
/// 记录信息日志 |
|||
static void i(String tag, String message) { |
|||
debugPrint('[$tag] INFO: $message'); |
|||
} |
|||
|
|||
/// 记录警告日志 |
|||
static void w(String tag, String message) { |
|||
debugPrint('[$tag] WARNING: $message'); |
|||
} |
|||
|
|||
/// 记录错误日志 |
|||
static void e(String tag, String message, [dynamic error, StackTrace? stackTrace]) { |
|||
final errorMsg = error != null ? '$message: $error' : message; |
|||
debugPrint('[$tag] ERROR: $errorMsg'); |
|||
if (stackTrace != null) { |
|||
debugPrint(stackTrace.toString()); |
|||
} |
|||
} |
|||
} |
|||
@ -1,27 +0,0 @@ |
|||
name: deepvoice_utils |
|||
description: 通用工具库,包含日志、文件处理等功能 |
|||
version: 0.0.1 |
|||
|
|||
environment: |
|||
sdk: ">=2.17.0 <4.0.0" |
|||
flutter: ">=2.5.0" |
|||
|
|||
dependencies: |
|||
flutter: |
|||
sdk: flutter |
|||
logger: ^2.0.0 |
|||
path_provider: ^2.1.0 |
|||
|
|||
dev_dependencies: |
|||
flutter_test: |
|||
sdk: flutter |
|||
flutter_lints: ^2.0.0 |
|||
|
|||
flutter: |
|||
plugin: |
|||
platforms: |
|||
android: |
|||
package: com.yunqiinnovation.deepvoice_utils |
|||
pluginClass: DeepvoiceUtilsPlugin |
|||
ios: |
|||
pluginClass: DeepvoiceUtilsPlugin |
|||
@ -1,144 +0,0 @@ |
|||
# Jieli 插件 |
|||
|
|||
一个用于连接杰理蓝牙耳机的Flutter插件,支持Android 12+的BLE通信。 |
|||
|
|||
## 功能 |
|||
|
|||
- 扫描支持特定BLE服务的耳机设备 |
|||
- 连接设备并发现服务 |
|||
- 接收耳机的唤醒通知 |
|||
- 向耳机发送控制命令启动录音 |
|||
- 接收音频数据流 |
|||
|
|||
## 技术规范 |
|||
|
|||
此插件实现了以下BLE特性: |
|||
|
|||
- **服务UUID**: 0000A100-0000-1000-8000-00805F9B34FB |
|||
- **音频数据特征UUID**: 0000A101-0000-1000-8000-00805F9B34FB |
|||
- **控制特征UUID**: 0000A102-0000-1000-8000-00805F9B34FB |
|||
|
|||
## 使用方法 |
|||
|
|||
### 初始化插件 |
|||
|
|||
```dart |
|||
final jieli = Jieli(); |
|||
``` |
|||
|
|||
### 检查蓝牙状态 |
|||
|
|||
```dart |
|||
bool isEnabled = await jieli.isBluetoothEnabled(); |
|||
if (!isEnabled) { |
|||
await jieli.requestBluetoothEnable(); |
|||
} |
|||
``` |
|||
|
|||
### 扫描设备 |
|||
|
|||
```dart |
|||
// 监听扫描结果 |
|||
jieli.scanResults.listen((devices) { |
|||
// 处理扫描到的设备列表 |
|||
for (var device in devices) { |
|||
print('发现设备: ${device.name} (${device.id}), RSSI: ${device.rssi}'); |
|||
} |
|||
}); |
|||
|
|||
// 开始扫描,10秒后自动停止 |
|||
await jieli.startScan(scanTimeout: 10000); |
|||
|
|||
// 手动停止扫描 |
|||
await jieli.stopScan(); |
|||
``` |
|||
|
|||
### 连接设备 |
|||
|
|||
```dart |
|||
// 监听连接状态 |
|||
jieli.connectionState.listen((state) { |
|||
switch (state) { |
|||
case ConnectionState.connected: |
|||
print('设备已连接'); |
|||
break; |
|||
case ConnectionState.disconnected: |
|||
print('设备已断开'); |
|||
break; |
|||
case ConnectionState.connecting: |
|||
print('正在连接...'); |
|||
break; |
|||
case ConnectionState.disconnecting: |
|||
print('正在断开...'); |
|||
break; |
|||
case ConnectionState.error: |
|||
print('连接错误'); |
|||
break; |
|||
} |
|||
}); |
|||
|
|||
// 连接到设备 |
|||
await jieli.connect(deviceId); |
|||
|
|||
// 断开连接 |
|||
await jieli.disconnect(); |
|||
``` |
|||
|
|||
### 接收唤醒信号 |
|||
|
|||
```dart |
|||
// 监听唤醒信号 |
|||
jieli.wakeupStream.listen((wakeup) { |
|||
print('收到唤醒信号'); |
|||
// 通常在收到唤醒后自动发送开始录音命令 |
|||
}); |
|||
``` |
|||
|
|||
### 发送录音命令 |
|||
|
|||
```dart |
|||
// 发送开始录音命令 |
|||
await jieli.sendStartRecordingCommand(); |
|||
|
|||
// 发送停止录音命令 |
|||
await jieli.sendStopRecordingCommand(); |
|||
``` |
|||
|
|||
### 接收音频数据 |
|||
|
|||
```dart |
|||
// 监听音频数据流 |
|||
jieli.audioDataStream.listen((data) { |
|||
print('收到音频数据: ${data.length} 字节'); |
|||
// 处理音频数据 |
|||
}); |
|||
``` |
|||
|
|||
### 资源清理 |
|||
|
|||
```dart |
|||
// 在不再需要插件时释放资源 |
|||
await jieli.dispose(); |
|||
``` |
|||
|
|||
## 权限 |
|||
|
|||
使用此插件需要以下Android权限: |
|||
|
|||
- Android 12+ (API 31+): `BLUETOOTH_SCAN`, `BLUETOOTH_CONNECT` |
|||
- Android 11及以下: `BLUETOOTH`, `BLUETOOTH_ADMIN` |
|||
|
|||
插件会在需要时自动请求权限,但建议在应用清单文件中声明这些权限: |
|||
|
|||
```xml |
|||
<!-- 针对 Android 11 及以下的传统蓝牙权限 --> |
|||
<uses-permission android:name="android.permission.BLUETOOTH" |
|||
android:maxSdkVersion="30" /> |
|||
<uses-permission android:name="android.permission.BLUETOOTH_ADMIN" |
|||
android:maxSdkVersion="30" /> |
|||
|
|||
<!-- Android 12+ 新的 BLE 扫描和连接权限 --> |
|||
<uses-permission android:name="android.permission.BLUETOOTH_SCAN" |
|||
android:usesPermissionFlags="neverForLocation" /> |
|||
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT" /> |
|||
``` |
|||
@ -1 +0,0 @@ |
|||
rootProject.name = "jieli" |
|||
@ -1,22 +0,0 @@ |
|||
<?xml version="1.0" encoding="utf-8"?> |
|||
<manifest xmlns:android="http://schemas.android.com/apk/res/android" |
|||
package="com.yunqiinnovation.jieli"> |
|||
|
|||
<!-- 声明需要蓝牙功能 --> |
|||
<uses-feature android:name="android.hardware.bluetooth_le" android:required="true" /> |
|||
|
|||
<!-- 针对 Android 11 及以下的传统蓝牙权限 --> |
|||
<uses-permission android:name="android.permission.BLUETOOTH" |
|||
android:maxSdkVersion="30" /> |
|||
<uses-permission android:name="android.permission.BLUETOOTH_ADMIN" |
|||
android:maxSdkVersion="30" /> |
|||
|
|||
<!-- Android 12+ 新的 BLE 扫描和连接权限 --> |
|||
<uses-permission android:name="android.permission.BLUETOOTH_SCAN" |
|||
android:usesPermissionFlags="neverForLocation" /> |
|||
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT" /> |
|||
|
|||
<!-- 前台服务权限 --> |
|||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" /> |
|||
|
|||
</manifest> |
|||
@ -1,665 +0,0 @@ |
|||
package com.yunqiinnovation.jieli |
|||
|
|||
import android.bluetooth.* |
|||
import android.bluetooth.le.* |
|||
import android.content.Context |
|||
import android.os.Handler |
|||
import android.os.Looper |
|||
import android.os.ParcelUuid |
|||
import android.util.Log |
|||
import androidx.lifecycle.LifecycleService |
|||
import androidx.lifecycle.MutableLiveData |
|||
import kotlinx.coroutines.* |
|||
import java.io.File |
|||
import java.io.FileOutputStream |
|||
import java.text.SimpleDateFormat |
|||
import java.util.* |
|||
import com.yunqiinnovation.azure_speech.AzureAsrHelper |
|||
import com.yunqiinnovation.azure_speech.AzureTtsHelper |
|||
import com.yunqiinnovation.jl_opus.JlOpusPlugin |
|||
|
|||
/** |
|||
* BLE服务类,提供核心蓝牙功能 |
|||
*/ |
|||
class BleService(private val context: Context) { |
|||
companion object { |
|||
private const val TAG = "JieliBleService" |
|||
|
|||
// 服务和特征UUID - 根据协议规范重新定义 |
|||
// private const val AUDIO_SERVICE_UUID = "0000faa0-0000-1000-8000-00805f9b34fb" // 主服务UUID |
|||
private const val AUDIO_SERVICE_UUID = "0000abc0-0000-1111-2222-123456789abc" // 主服务UUID |
|||
private const val NOTIFY_CHAR_UUID = |
|||
"0000abc2-0000-1111-2222-123456789abc" // 用于接收数据的特征(notify) |
|||
private const val WRITE_CHAR_UUID = |
|||
"0000abc1-0000-1111-2222-123456789abc" // 用于发送命令的特征(write without response) |
|||
private const val CLIENT_CHAR_CONFIG_UUID = |
|||
"00002902-0000-1000-8000-00805f9b34fb" // 标准的客户端特征配置描述符 |
|||
|
|||
// 连接状态常量 |
|||
const val STATE_DISCONNECTED = 0 |
|||
const val STATE_CONNECTING = 1 |
|||
const val STATE_CONNECTED = 2 |
|||
const val STATE_DISCONNECTING = 3 |
|||
const val STATE_ERROR = 4 |
|||
|
|||
// 扫描结果更新间隔(毫秒) |
|||
private const val SCAN_RESULT_UPDATE_INTERVAL = 500L |
|||
|
|||
// 命令类型常量 |
|||
const val COMMAND_VOLUME = 1 |
|||
} |
|||
|
|||
/** |
|||
* 回调接口 |
|||
*/ |
|||
interface Callback { |
|||
fun onScanResult(devices: List<Map<String, Any>>) |
|||
fun onConnectionStateChanged(state: Int) |
|||
fun onAudioDataReceived(data: ByteArray) |
|||
fun onWakeupSignalReceived() |
|||
fun onCommandResult(commandType: Int, success: Boolean) |
|||
} |
|||
|
|||
// region 文件相关 |
|||
private var outputFile: File? = null |
|||
private var fos: FileOutputStream? = null |
|||
|
|||
// Azure服务 |
|||
private var azureAsrHelper: AzureAsrHelper? = null |
|||
private var azureTtsHelper: AzureTtsHelper? = null |
|||
|
|||
// 获取插件实例 |
|||
private val opusPlugin = JlOpusPlugin() |
|||
|
|||
// 蓝牙管理器和适配器 |
|||
private val bluetoothManager = |
|||
context.getSystemService(Context.BLUETOOTH_SERVICE) as BluetoothManager |
|||
private val bluetoothAdapter: BluetoothAdapter? = bluetoothManager.adapter |
|||
private val bluetoothLeScanner: BluetoothLeScanner? = bluetoothAdapter?.bluetoothLeScanner |
|||
|
|||
// GATT连接和特征 |
|||
private var bluetoothGatt: BluetoothGatt? = null |
|||
private var notifyChar: BluetoothGattCharacteristic? = null |
|||
private var writeChar: BluetoothGattCharacteristic? = null |
|||
|
|||
// 扫描相关 |
|||
private val scanHandler = Handler(Looper.getMainLooper()) |
|||
private val scanResults = ArrayList<ScanResult>() |
|||
private var isScanning = false |
|||
private var isPendingScanResultUpdate = false |
|||
private val scanResultUpdateRunnable = Runnable { |
|||
notifyScanResults() |
|||
isPendingScanResultUpdate = false |
|||
} |
|||
|
|||
// 连接状态LiveData |
|||
val connectionState = MutableLiveData<Int>(STATE_DISCONNECTED) |
|||
|
|||
// 统一回调接口 |
|||
var callback: Callback? = null |
|||
|
|||
/** |
|||
* 开始扫描BLE设备 |
|||
*/ |
|||
fun startScan(scanTimeout: Long = 5000): Boolean { |
|||
if (bluetoothLeScanner == null || isScanning) { |
|||
return false |
|||
} |
|||
|
|||
Log.i(TAG, "开始扫描BLE设备...") |
|||
scanResults.clear() |
|||
|
|||
val serviceUuid = UUID.fromString(AUDIO_SERVICE_UUID) |
|||
val scanFilter = ScanFilter.Builder() |
|||
// .setServiceUuid(ParcelUuid(serviceUuid)) |
|||
.build() |
|||
val scanFilters = listOf(scanFilter) |
|||
|
|||
val scanSettings = ScanSettings.Builder() |
|||
.setScanMode(ScanSettings.SCAN_MODE_LOW_LATENCY) |
|||
.build() |
|||
|
|||
bluetoothLeScanner.startScan(scanFilters, scanSettings, scanCallback) |
|||
isScanning = true |
|||
|
|||
// 设置超时停止扫描 |
|||
scanHandler.postDelayed({ |
|||
Log.d( |
|||
TAG, |
|||
"定时器触发,准备停止扫描 [主线程:${Looper.myLooper() == Looper.getMainLooper()}]" |
|||
) |
|||
stopScan() |
|||
}, scanTimeout) |
|||
|
|||
return true |
|||
} |
|||
|
|||
/** |
|||
* 停止扫描 |
|||
*/ |
|||
fun stopScan(): Boolean { |
|||
Log.i(TAG, "停止扫描BLE设备") |
|||
|
|||
if (bluetoothLeScanner == null || !isScanning) { |
|||
return false |
|||
} |
|||
|
|||
bluetoothLeScanner.stopScan(scanCallback) |
|||
scanHandler.removeCallbacksAndMessages(null) |
|||
isScanning = false |
|||
|
|||
// 最后一次发送扫描结果 |
|||
notifyScanResults() |
|||
|
|||
return true |
|||
} |
|||
|
|||
/** |
|||
* 扫描回调 |
|||
*/ |
|||
private val scanCallback = object : ScanCallback() { |
|||
override fun onScanResult(callbackType: Int, result: ScanResult) { |
|||
val device = result.device |
|||
val deviceName = device.name ?: "未知设备" |
|||
val deviceAddress = device.address |
|||
val rssi = result.rssi |
|||
val scanRecord = result.scanRecord |
|||
if (!deviceName.contains("zxd")) { |
|||
return |
|||
} |
|||
Log.d(TAG, "============发现设备: $deviceName [$deviceAddress], RSSI: $rssi") |
|||
|
|||
// 打印所有发现的UUID以便调试 |
|||
val serviceUuids = scanRecord?.serviceUuids |
|||
if (serviceUuids != null) { |
|||
for (uuid in serviceUuids) { |
|||
Log.d(TAG, "设备UUID: ${uuid.uuid}") |
|||
} |
|||
} |
|||
|
|||
// 检查制造商数据 |
|||
for (i in 0 until 65535) { |
|||
val manufacturerData = scanRecord?.getManufacturerSpecificData(i) |
|||
if (manufacturerData != null) { |
|||
// Log.d(TAG, "发现厂商ID: $i, 数据长度: ${manufacturerData.size}") |
|||
// 这里可以根据已知的厂商ID和格式解析数据 |
|||
} |
|||
} |
|||
|
|||
// 避免重复添加 |
|||
|
|||
val existingIndex = scanResults.indexOfFirst { it.device.address == deviceAddress } |
|||
|
|||
if (existingIndex >= 0) { |
|||
|
|||
scanResults[existingIndex] = result |
|||
} else { |
|||
Log.d(TAG, "扫描回调A, scanResults[existingIndex]ADD") |
|||
scanResults.add(result) |
|||
} |
|||
|
|||
// 如果没有待处理的更新,安排一个 |
|||
if (!isPendingScanResultUpdate) { |
|||
isPendingScanResultUpdate = true |
|||
scanHandler.postDelayed(scanResultUpdateRunnable, SCAN_RESULT_UPDATE_INTERVAL) |
|||
} |
|||
} |
|||
|
|||
override fun onScanFailed(errorCode: Int) { |
|||
Log.e(TAG, "扫描失败,错误码: $errorCode") |
|||
isScanning = false |
|||
} |
|||
} |
|||
|
|||
/** |
|||
* 将扫描结果通知回调 |
|||
*/ |
|||
private fun notifyScanResults() { |
|||
val devicesJson = scanResults.map { result -> |
|||
mapOf( |
|||
"id" to result.device.address, |
|||
"name" to (result.device.name ?: "未知设备"), |
|||
"rssi" to result.rssi |
|||
) |
|||
} |
|||
|
|||
callback?.onScanResult(devicesJson) |
|||
} |
|||
|
|||
/** |
|||
* 连接到指定设备 |
|||
*/ |
|||
fun connect(deviceAddress: String) { |
|||
if (bluetoothAdapter == null) { |
|||
Log.e(TAG, "蓝牙适配器不可用") |
|||
updateConnectionState(STATE_ERROR) |
|||
return |
|||
} |
|||
|
|||
// 先断开现有连接 |
|||
bluetoothGatt?.close() |
|||
bluetoothGatt = null |
|||
|
|||
try { |
|||
val device = bluetoothAdapter.getRemoteDevice(deviceAddress) |
|||
updateConnectionState(STATE_CONNECTING) |
|||
|
|||
// 连接GATT服务器 |
|||
Log.i(TAG, "正在连接设备: ${device.name ?: "未知设备"} [$deviceAddress]") |
|||
bluetoothGatt = |
|||
device.connectGatt(context, false, gattCallback, BluetoothDevice.TRANSPORT_LE) |
|||
} catch (e: Exception) { |
|||
Log.e(TAG, "连接设备时发生错误: ${e.message}") |
|||
updateConnectionState(STATE_ERROR) |
|||
} |
|||
} |
|||
|
|||
/** |
|||
* 断开连接 |
|||
*/ |
|||
fun disconnect() { |
|||
bluetoothGatt?.disconnect() |
|||
updateConnectionState(STATE_DISCONNECTING) |
|||
} |
|||
|
|||
/** |
|||
* 更新连接状态并通知回调 |
|||
*/ |
|||
private fun updateConnectionState(state: Int) { |
|||
connectionState.postValue(state) |
|||
callback?.onConnectionStateChanged(state) |
|||
} |
|||
|
|||
/** |
|||
* GATT回调 |
|||
*/ |
|||
private val gattCallback = object : BluetoothGattCallback() { |
|||
|
|||
|
|||
override fun onConnectionStateChange(gatt: BluetoothGatt, status: Int, newState: Int) { |
|||
if (newState == BluetoothProfile.STATE_CONNECTED && status == BluetoothGatt.GATT_SUCCESS) { |
|||
Log.i(TAG, "设备已连接,开始发现服务...") |
|||
updateConnectionState(STATE_CONNECTED) |
|||
|
|||
// 开始搜索服务 |
|||
gatt.discoverServices() |
|||
} else if (newState == BluetoothProfile.STATE_DISCONNECTED) { |
|||
Log.i(TAG, "设备已断开连接") |
|||
updateConnectionState(STATE_DISCONNECTED) |
|||
|
|||
// 清理资源 |
|||
gatt.close() |
|||
bluetoothGatt = null |
|||
notifyChar = null |
|||
writeChar = null |
|||
} else if (status != BluetoothGatt.GATT_SUCCESS) { |
|||
Log.e(TAG, "连接失败,status=$status") |
|||
updateConnectionState(STATE_ERROR) |
|||
|
|||
gatt.close() |
|||
bluetoothGatt = null |
|||
} |
|||
} |
|||
|
|||
override fun onServicesDiscovered(gatt: BluetoothGatt, status: Int) { |
|||
if (status == BluetoothGatt.GATT_SUCCESS) { |
|||
Log.i(TAG, "服务发现成功") |
|||
|
|||
// 查找目标服务 |
|||
val audioService = gatt.getService(UUID.fromString(AUDIO_SERVICE_UUID)) |
|||
if (audioService != null) { |
|||
Log.i(TAG, "找到音频服务,开始遍历特征...") |
|||
audioService.characteristics.forEach { char -> |
|||
Log.i(TAG, "特征UUID: ${char.uuid}") |
|||
} |
|||
Log.i(TAG, "找到音频服务$") |
|||
|
|||
// 获取特征 |
|||
notifyChar = audioService.getCharacteristic(UUID.fromString(NOTIFY_CHAR_UUID)) |
|||
writeChar = audioService.getCharacteristic(UUID.fromString(WRITE_CHAR_UUID)) |
|||
if (notifyChar != null && writeChar != null) { |
|||
Log.i(TAG, "找到所需特征") |
|||
|
|||
// 设置特征写入类型为无响应模式 |
|||
writeChar?.writeType = BluetoothGattCharacteristic.WRITE_TYPE_NO_RESPONSE |
|||
|
|||
// 注册特征通知 |
|||
setupNotifications(gatt) |
|||
|
|||
opusPlugin?.startOpusDecodeStream(false, 1, 16000, 40, object : |
|||
JlOpusPlugin.CallbackOpus { |
|||
override fun onAudioDataReceivedOpus(pcmData: ByteArray) { |
|||
// Log.i(TAG, "收到解码控制通知: ${pcmData.contentToString()}") |
|||
callback?.onAudioDataReceived(pcmData) |
|||
// 处理解码后的 PCM 数据(如播放或写入文件) |
|||
} |
|||
}) |
|||
} else { |
|||
Log.e(TAG, "未找到所需特征") |
|||
updateConnectionState(STATE_ERROR) |
|||
} |
|||
} else { |
|||
Log.e(TAG, "未找到音频服务") |
|||
updateConnectionState(STATE_ERROR) |
|||
} |
|||
} else { |
|||
Log.e(TAG, "服务发现失败,status=$status") |
|||
updateConnectionState(STATE_ERROR) |
|||
} |
|||
} |
|||
|
|||
// 添加扩展函数(在文件顶部声明) |
|||
fun List<Byte>.toByteArray(): ByteArray { |
|||
return ByteArray(this.size) { this[it] } |
|||
} |
|||
|
|||
override fun onCharacteristicChanged( |
|||
gatt: BluetoothGatt, |
|||
characteristic: BluetoothGattCharacteristic |
|||
) { |
|||
val characteristicUuid = characteristic.uuid.toString() |
|||
// Log.d(TAG, "收到特征变化通知: $characteristicUuid") |
|||
|
|||
when (characteristicUuid) { |
|||
// NOTIFY_CHAR_UUID -> { |
|||
// // 收到音频数据 |
|||
// val audioData = characteristic.value |
|||
// Log.d(TAG, "收到音频数据帧,大小: ${audioData.size} 字节") |
|||
|
|||
// callback?.onAudioDataReceived(audioData) |
|||
// } |
|||
NOTIFY_CHAR_UUID -> { |
|||
// 处理控制消息 |
|||
val value = characteristic.value |
|||
// Log.i(TAG, "收到控制通知: ${value.size}") |
|||
|
|||
// 判断是否收到音频数据 |
|||
if (value.isNotEmpty() && (value[0].toInt() and 0xFF == 0x4b || value[0].toInt() and 0xFF == 0x48)) { |
|||
// 处理设备返回的协议数据 |
|||
fos?.write(value) |
|||
|
|||
opusPlugin?.handleDecodeRequest(value) |
|||
|
|||
|
|||
} |
|||
// 判断是否是协议格式响应 |
|||
if (value.isNotEmpty() && value[0].toInt() and 0xFF == 0xA5) { |
|||
// 处理设备返回的协议数据 |
|||
processResponseData(value) |
|||
} |
|||
// 假设0x01表示唤醒信号 |
|||
else if (value.isNotEmpty() && value[0].toInt() == 0x01) { |
|||
Log.i(TAG, "收到唤醒信号") |
|||
|
|||
callback?.onWakeupSignalReceived() |
|||
|
|||
// 收到唤醒后自动发送开始录音命令 |
|||
sendStartRecordingCommand() |
|||
|
|||
|
|||
} |
|||
} |
|||
} |
|||
} |
|||
|
|||
override fun onCharacteristicWrite( |
|||
gatt: BluetoothGatt, |
|||
characteristic: BluetoothGattCharacteristic, |
|||
status: Int |
|||
) { |
|||
if (characteristic.uuid.toString() == WRITE_CHAR_UUID) { |
|||
if (status == BluetoothGatt.GATT_SUCCESS) { |
|||
Log.i(TAG, "命令发送成功: ${characteristic.value.contentToString()}") |
|||
} else { |
|||
Log.e(TAG, "命令发送失败,status=$status") |
|||
} |
|||
} |
|||
} |
|||
|
|||
override fun onDescriptorWrite( |
|||
gatt: BluetoothGatt, |
|||
descriptor: BluetoothGattDescriptor, |
|||
status: Int |
|||
) { |
|||
if (status == BluetoothGatt.GATT_SUCCESS) { |
|||
Log.i(TAG, "描述符写入成功: ${descriptor.uuid}") |
|||
Log.i(TAG, "特征通知设置成功,设备就绪") |
|||
} else { |
|||
Log.e(TAG, "描述符写入失败,status=$status") |
|||
updateConnectionState(STATE_ERROR) |
|||
} |
|||
} |
|||
} |
|||
|
|||
/** |
|||
* 设置特征通知 |
|||
*/ |
|||
private fun setupNotifications(gatt: BluetoothGatt) { |
|||
// 启用 notify 特征通知 |
|||
if (notifyChar?.properties?.and(BluetoothGattCharacteristic.PROPERTY_NOTIFY) != 0) { |
|||
gatt.setCharacteristicNotification(notifyChar, true) |
|||
|
|||
val descriptor = notifyChar?.getDescriptor(UUID.fromString(CLIENT_CHAR_CONFIG_UUID)) |
|||
descriptor?.value = BluetoothGattDescriptor.ENABLE_NOTIFICATION_VALUE |
|||
|
|||
if (descriptor != null) { |
|||
gatt.writeDescriptor(descriptor) |
|||
Log.i(TAG, "设置数据通知特征通知") |
|||
} |
|||
} else { |
|||
Log.e(TAG, "数据通知特征不支持通知") |
|||
} |
|||
} |
|||
|
|||
|
|||
/** |
|||
* 处理设备返回的数据 |
|||
* 协议格式: |
|||
* 协议头(0xA5) + 字段类型(1byte) + 数据长度(1byte) + 数据域(N byte) + 校验和(1byte) |
|||
*/ |
|||
private fun processResponseData(data: ByteArray) { |
|||
if (data.size < 4) { |
|||
Log.e(TAG, "返回数据格式错误,长度不足") |
|||
return |
|||
} |
|||
|
|||
// 判断协议头 |
|||
if (data[0].toInt() and 0xFF != 0xA5) { |
|||
Log.e(TAG, "返回数据协议头错误: ${data[0].toInt() and 0xFF}") |
|||
return |
|||
} |
|||
|
|||
val fieldType = data[1].toInt() and 0xFF |
|||
val dataLen = data[2].toInt() and 0xFF |
|||
|
|||
// 验证长度 |
|||
if (data.size != dataLen + 4) { // 协议头+字段类型+数据长度+数据域+校验和 |
|||
Log.e(TAG, "返回数据长度不匹配: 应为${dataLen + 4},实际为${data.size}") |
|||
return |
|||
} |
|||
|
|||
// 计算校验和 |
|||
var checksum = 0 |
|||
for (i in 0 until data.size - 1) { |
|||
checksum += data[i].toInt() and 0xFF |
|||
} |
|||
checksum = checksum and 0xFF |
|||
|
|||
val receivedChecksum = data[data.size - 1].toInt() and 0xFF |
|||
|
|||
if (checksum != receivedChecksum) { |
|||
Log.e(TAG, "校验和错误: 计算值=$checksum, 接收值=$receivedChecksum") |
|||
return |
|||
} |
|||
|
|||
// 解析字段类型 |
|||
when (fieldType) { |
|||
0x01 -> { |
|||
// 音量调整命令的响应 |
|||
val result = data[3].toInt() and 0xFF |
|||
val success = result == 0 |
|||
Log.i(TAG, "音量调整命令响应: ${if (success) "成功" else "失败"}") |
|||
callback?.onCommandResult(COMMAND_VOLUME, success) |
|||
} |
|||
// 其他命令类型可在此处添加 |
|||
else -> { |
|||
Log.d(TAG, "未知命令类型: ${String.format("0x%02X", fieldType)}") |
|||
} |
|||
} |
|||
} |
|||
|
|||
/** |
|||
* 发送开始录音命令 |
|||
*/ |
|||
fun sendStartRecordingCommand(): Boolean { |
|||
if (bluetoothGatt == null || writeChar == null || connectionState.value != STATE_CONNECTED) { |
|||
Log.e(TAG, "无法发送开始录音命令,设备未连接") |
|||
return false |
|||
} |
|||
|
|||
try { |
|||
val commandData = byteArrayOf(0x01) |
|||
writeChar?.value = commandData |
|||
// 获取外部存储目录路径 |
|||
// 生成带时间戳的文件名(格式:yyyyMMdd_HHmmss_audio.opus) |
|||
val timeStamp = |
|||
SimpleDateFormat("yyyyMMdd_HHmmss", Locale.getDefault()).format(Date()) |
|||
val fileName = "${timeStamp}_audio.opus" |
|||
val externalDir = context.getExternalFilesDir(null) |
|||
outputFile = File(externalDir, fileName) |
|||
fos = FileOutputStream(outputFile).also { |
|||
Log.i(TAG, "音频文件创建成功:${outputFile?.absolutePath}") |
|||
} |
|||
|
|||
// val subscriptionKey = call.argument<String>("subscriptionKey") ?: "" |
|||
// val region = call.argument<String>("region") ?: "" |
|||
// val supportedLanguages = call.argument<List<String>>("supportedLanguages") ?: listOf("zh-CN") |
|||
|
|||
// val success = azureAsrHelper.initialize(subscriptionKey, region, supportedLanguages.toTypedArray()) |
|||
// result.success(success) |
|||
|
|||
|
|||
val success = bluetoothGatt?.writeCharacteristic(writeChar) ?: false |
|||
|
|||
if (success) { |
|||
Log.i(TAG, "开始录音命令已发送") |
|||
} else { |
|||
Log.e(TAG, "开始录音命令发送失败") |
|||
} |
|||
|
|||
return success |
|||
} catch (e: Exception) { |
|||
Log.e(TAG, "发送开始录音命令时发生异常: ${e.message}") |
|||
return false |
|||
} |
|||
} |
|||
|
|||
/** |
|||
* 发送停止录音命令 |
|||
*/ |
|||
fun sendStopRecordingCommand(): Boolean { |
|||
if (bluetoothGatt == null || writeChar == null || connectionState.value != STATE_CONNECTED) { |
|||
Log.e(TAG, "无法发送停止录音命令,设备未连接") |
|||
return false |
|||
} |
|||
|
|||
try { |
|||
val commandData = byteArrayOf(0x02) |
|||
writeChar?.value = commandData |
|||
|
|||
val success = bluetoothGatt?.writeCharacteristic(writeChar) ?: false |
|||
fos?.close() |
|||
if (success) { |
|||
Log.i(TAG, "停止录音命令已发送") |
|||
} else { |
|||
Log.e(TAG, "停止录音命令发送失败") |
|||
} |
|||
|
|||
return success |
|||
} catch (e: Exception) { |
|||
Log.e(TAG, "发送停止录音命令时发生异常: ${e.message}") |
|||
return false |
|||
} |
|||
} |
|||
|
|||
/** |
|||
* 发送音量调整命令 |
|||
* 协议格式: |
|||
* 协议头(0xAA) + 字段类型(0x01) + 数据长度(0x01) + 音量值(0~16) + 校验和 |
|||
*/ |
|||
fun adjustVolume(volume: Int): Boolean { |
|||
Log.e( |
|||
TAG, |
|||
"adjustVolume,bluetoothGatt=${bluetoothGatt},writeChar=${writeChar},connectionState=${connectionState.value}" |
|||
) |
|||
if (bluetoothGatt == null || writeChar == null || connectionState.value != STATE_CONNECTED) { |
|||
Log.e(TAG, "无法发送音量调整命令,设备未连接") |
|||
return false |
|||
} |
|||
|
|||
// 限制音量范围为0-16 |
|||
val safeVolume = volume.coerceIn(0, 16) |
|||
|
|||
try { |
|||
// 构建命令数据 |
|||
val header: Byte = 0xAA.toByte() |
|||
val fieldType: Byte = 0x01 |
|||
val dataLen: Byte = 0x01 |
|||
val volumeData: Byte = safeVolume.toByte() |
|||
|
|||
// 计算校验和 |
|||
val checksum = ((header.toInt() and 0xFF) + |
|||
(fieldType.toInt() and 0xFF) + |
|||
(dataLen.toInt() and 0xFF) + |
|||
(volumeData.toInt() and 0xFF)) and 0xFF |
|||
|
|||
// 创建完整命令 |
|||
val commandData = |
|||
byteArrayOf(header, fieldType, dataLen, volumeData, checksum.toByte()) |
|||
|
|||
// 写入特征 |
|||
writeChar?.value = commandData |
|||
|
|||
val success = bluetoothGatt?.writeCharacteristic(writeChar) ?: false |
|||
|
|||
if (success) { |
|||
Log.i(TAG, "音量调整命令已发送: $safeVolume") |
|||
} else { |
|||
Log.e(TAG, "音量调整命令发送失败") |
|||
} |
|||
|
|||
return success |
|||
} catch (e: Exception) { |
|||
Log.e(TAG, "发送音量调整命令时发生异常: ${e.message}") |
|||
return false |
|||
} |
|||
} |
|||
|
|||
/** |
|||
* 增加音量 |
|||
*/ |
|||
fun increaseVolume(): Boolean { |
|||
// 假设当前音量保存在设备中,我们只需发送增加命令 |
|||
return adjustVolume(16) // 发送最大音量命令 |
|||
} |
|||
|
|||
/** |
|||
* 减小音量 |
|||
*/ |
|||
fun decreaseVolume(): Boolean { |
|||
// 假设当前音量保存在设备中,我们只需发送减小命令 |
|||
return adjustVolume(0) // 发送最小音量命令 |
|||
} |
|||
|
|||
/** |
|||
* 清理资源 |
|||
*/ |
|||
fun cleanup() { |
|||
stopScan() |
|||
scanHandler.removeCallbacks(scanResultUpdateRunnable) |
|||
bluetoothGatt?.close() |
|||
bluetoothGatt = null |
|||
notifyChar = null |
|||
writeChar = null |
|||
} |
|||
} |
|||
@ -1,294 +0,0 @@ |
|||
package com.yunqiinnovation.jieli |
|||
|
|||
import android.app.Activity |
|||
import android.bluetooth.BluetoothAdapter |
|||
import android.bluetooth.BluetoothManager |
|||
import android.content.Context |
|||
import android.content.Intent |
|||
import android.content.pm.PackageManager |
|||
import android.util.Log |
|||
import androidx.annotation.NonNull |
|||
import androidx.core.app.ActivityCompat |
|||
import androidx.core.content.ContextCompat |
|||
import io.flutter.embedding.engine.plugins.FlutterPlugin |
|||
import io.flutter.embedding.engine.plugins.activity.ActivityAware |
|||
import io.flutter.embedding.engine.plugins.activity.ActivityPluginBinding |
|||
import io.flutter.plugin.common.EventChannel |
|||
import io.flutter.plugin.common.MethodCall |
|||
import io.flutter.plugin.common.MethodChannel |
|||
import io.flutter.plugin.common.MethodChannel.MethodCallHandler |
|||
import io.flutter.plugin.common.MethodChannel.Result |
|||
import kotlinx.coroutines.* |
|||
|
|||
/** JieliPlugin */ |
|||
class JieliPlugin: FlutterPlugin, MethodCallHandler, ActivityAware, EventChannel.StreamHandler { |
|||
/// 方法通道 |
|||
private lateinit var methodChannel: MethodChannel |
|||
/// 统一事件通道 |
|||
private lateinit var eventChannel: EventChannel |
|||
|
|||
private lateinit var context: Context |
|||
private var activity: Activity? = null |
|||
|
|||
/// BLE服务实例 |
|||
private lateinit var bleService: BleService |
|||
|
|||
// 协程作用域 |
|||
private val pluginScope = CoroutineScope(Dispatchers.IO + SupervisorJob()) |
|||
|
|||
// 事件接收器 |
|||
private var eventSink: EventChannel.EventSink? = null |
|||
|
|||
// 事件类型常量 |
|||
companion object { |
|||
private const val EVENT_TYPE_AUDIO_DATA = "audio_data" |
|||
private const val EVENT_TYPE_CONNECTION_STATE = "connection_state" |
|||
private const val EVENT_TYPE_SCAN_RESULT = "scan_result" |
|||
private const val EVENT_TYPE_WAKEUP_SIGNAL = "wakeup_signal" |
|||
private const val EVENT_TYPE_COMMAND_RESULT = "command_result" |
|||
} |
|||
|
|||
// 蓝牙相关请求码 |
|||
private val REQUEST_ENABLE_BT = 1 |
|||
private val REQUEST_PERMISSION_BLE = 2 |
|||
|
|||
override fun onAttachedToEngine(@NonNull flutterPluginBinding: FlutterPlugin.FlutterPluginBinding) { |
|||
val binaryMessenger = flutterPluginBinding.binaryMessenger |
|||
|
|||
// 设置方法通道 |
|||
methodChannel = MethodChannel(binaryMessenger, "com.yunqiinnovation.jieli") |
|||
methodChannel.setMethodCallHandler(this) |
|||
|
|||
// 设置统一事件通道 |
|||
eventChannel = EventChannel(binaryMessenger, "com.yunqiinnovation.jieli/events") |
|||
eventChannel.setStreamHandler(this) |
|||
|
|||
context = flutterPluginBinding.applicationContext |
|||
|
|||
// 初始化BLE服务 |
|||
bleService = BleService(context) |
|||
|
|||
// 设置回调 |
|||
setupBleCallbacks() |
|||
} |
|||
|
|||
/** |
|||
* 设置BLE服务回调 |
|||
*/ |
|||
private fun setupBleCallbacks() { |
|||
// 设置统一回调接口 |
|||
bleService.callback = object : BleService.Callback { |
|||
override fun onScanResult(devices: List<Map<String, Any>>) { |
|||
pluginScope.launch(Dispatchers.Main) { |
|||
sendEvent(EVENT_TYPE_SCAN_RESULT, devices) |
|||
} |
|||
} |
|||
|
|||
override fun onConnectionStateChanged(state: Int) { |
|||
pluginScope.launch(Dispatchers.Main) { |
|||
sendEvent(EVENT_TYPE_CONNECTION_STATE, state) |
|||
} |
|||
} |
|||
|
|||
override fun onAudioDataReceived(data: ByteArray) { |
|||
pluginScope.launch(Dispatchers.Main) { |
|||
// Log.d("","数据流onAudioDataReceived${data}") |
|||
sendEvent(EVENT_TYPE_AUDIO_DATA, data) |
|||
} |
|||
} |
|||
|
|||
override fun onWakeupSignalReceived() { |
|||
pluginScope.launch(Dispatchers.Main) { |
|||
sendEvent(EVENT_TYPE_WAKEUP_SIGNAL, true) |
|||
} |
|||
} |
|||
|
|||
override fun onCommandResult(commandType: Int, success: Boolean) { |
|||
pluginScope.launch(Dispatchers.Main) { |
|||
sendEvent(EVENT_TYPE_COMMAND_RESULT, mapOf( |
|||
"commandType" to commandType, |
|||
"success" to success |
|||
)) |
|||
} |
|||
} |
|||
} |
|||
} |
|||
|
|||
/** |
|||
* 发送事件到Flutter端 |
|||
*/ |
|||
private fun sendEvent(eventType: String, data: Any?) { |
|||
eventSink?.success(mapOf( |
|||
"type" to eventType, |
|||
"data" to data |
|||
)) |
|||
} |
|||
|
|||
override fun onMethodCall(@NonNull call: MethodCall, @NonNull result: Result) { |
|||
when (call.method) { |
|||
"startScan" -> { |
|||
val scanTimeout = call.argument<Int>("scanTimeout") ?: 10000 |
|||
|
|||
if (!hasRequiredPermissions()) { |
|||
requestBlePermissions() |
|||
result.error("PERMISSION_DENIED", "需要蓝牙权限才能扫描设备", null) |
|||
return |
|||
} |
|||
|
|||
val success = bleService.startScan(scanTimeout.toLong()) |
|||
result.success(success) |
|||
} |
|||
"stopScan" -> { |
|||
val success = bleService.stopScan() |
|||
result.success(success) |
|||
} |
|||
"connect" -> { |
|||
val deviceId = call.argument<String>("deviceId") |
|||
if (deviceId == null) { |
|||
result.error("INVALID_ARGUMENT", "设备ID不能为空", null) |
|||
return |
|||
} |
|||
|
|||
if (!hasRequiredPermissions()) { |
|||
requestBlePermissions() |
|||
result.error("PERMISSION_DENIED", "需要蓝牙权限才能连接设备", null) |
|||
return |
|||
} |
|||
|
|||
bleService.connect(deviceId) |
|||
result.success(true) |
|||
} |
|||
"disconnect" -> { |
|||
bleService.disconnect() |
|||
result.success(true) |
|||
} |
|||
"sendStartRecordingCommand" -> { |
|||
val success = bleService.sendStartRecordingCommand() |
|||
result.success(success) |
|||
} |
|||
"sendStopRecordingCommand" -> { |
|||
val success = bleService.sendStopRecordingCommand() |
|||
result.success(success) |
|||
} |
|||
"isBluetoothEnabled" -> { |
|||
val bluetoothManager = context.getSystemService(Context.BLUETOOTH_SERVICE) as BluetoothManager |
|||
val bluetoothAdapter = bluetoothManager.adapter |
|||
result.success(bluetoothAdapter?.isEnabled == true) |
|||
} |
|||
"requestBluetoothEnable" -> { |
|||
if (activity == null) { |
|||
result.error("ACTIVITY_NOT_AVAILABLE", "Activity不可用,无法请求打开蓝牙", null) |
|||
return |
|||
} |
|||
|
|||
val bluetoothManager = context.getSystemService(Context.BLUETOOTH_SERVICE) as BluetoothManager |
|||
val bluetoothAdapter = bluetoothManager.adapter |
|||
|
|||
if (bluetoothAdapter == null) { |
|||
result.error("BLUETOOTH_UNAVAILABLE", "设备不支持蓝牙", null) |
|||
return |
|||
} |
|||
|
|||
if (bluetoothAdapter.isEnabled) { |
|||
result.success(true) |
|||
return |
|||
} |
|||
|
|||
// 请求打开蓝牙 |
|||
val enableBtIntent = Intent(BluetoothAdapter.ACTION_REQUEST_ENABLE) |
|||
activity?.startActivityForResult(enableBtIntent, REQUEST_ENABLE_BT) |
|||
result.success(true) |
|||
} |
|||
"adjustVolume" -> { |
|||
val volume = call.argument<Int>("volume") ?: 0 |
|||
val success = bleService.adjustVolume(volume) |
|||
result.success(success) |
|||
} |
|||
"increaseVolume" -> { |
|||
val success = bleService.increaseVolume() |
|||
result.success(success) |
|||
} |
|||
"decreaseVolume" -> { |
|||
val success = bleService.decreaseVolume() |
|||
result.success(success) |
|||
} |
|||
else -> { |
|||
result.notImplemented() |
|||
} |
|||
} |
|||
} |
|||
|
|||
override fun onDetachedFromEngine(@NonNull binding: FlutterPlugin.FlutterPluginBinding) { |
|||
methodChannel.setMethodCallHandler(null) |
|||
eventChannel.setStreamHandler(null) |
|||
|
|||
bleService.cleanup() |
|||
pluginScope.cancel() |
|||
} |
|||
|
|||
override fun onAttachedToActivity(binding: ActivityPluginBinding) { |
|||
activity = binding.activity |
|||
} |
|||
|
|||
override fun onDetachedFromActivityForConfigChanges() { |
|||
activity = null |
|||
} |
|||
|
|||
override fun onReattachedToActivityForConfigChanges(binding: ActivityPluginBinding) { |
|||
activity = binding.activity |
|||
} |
|||
|
|||
override fun onDetachedFromActivity() { |
|||
activity = null |
|||
} |
|||
|
|||
/// EventChannel.StreamHandler接口实现 |
|||
override fun onListen(arguments: Any?, events: EventChannel.EventSink?) { |
|||
eventSink = events |
|||
} |
|||
|
|||
override fun onCancel(arguments: Any?) { |
|||
eventSink = null |
|||
} |
|||
|
|||
/// 检查是否拥有所需权限 |
|||
private fun hasRequiredPermissions(): Boolean { |
|||
return if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.S) { |
|||
hasPermission(android.Manifest.permission.BLUETOOTH_SCAN) && |
|||
hasPermission(android.Manifest.permission.BLUETOOTH_CONNECT) |
|||
} else { |
|||
hasPermission(android.Manifest.permission.BLUETOOTH) && |
|||
hasPermission(android.Manifest.permission.BLUETOOTH_ADMIN) |
|||
} |
|||
} |
|||
|
|||
/// 检查单个权限 |
|||
private fun hasPermission(permission: String): Boolean { |
|||
return ContextCompat.checkSelfPermission(context, permission) == PackageManager.PERMISSION_GRANTED |
|||
} |
|||
|
|||
/// 请求BLE权限 |
|||
private fun requestBlePermissions() { |
|||
activity?.let { |
|||
if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.S) { |
|||
ActivityCompat.requestPermissions( |
|||
it, |
|||
arrayOf( |
|||
android.Manifest.permission.BLUETOOTH_SCAN, |
|||
android.Manifest.permission.BLUETOOTH_CONNECT |
|||
), |
|||
REQUEST_PERMISSION_BLE |
|||
) |
|||
} else { |
|||
ActivityCompat.requestPermissions( |
|||
it, |
|||
arrayOf( |
|||
android.Manifest.permission.BLUETOOTH, |
|||
android.Manifest.permission.BLUETOOTH_ADMIN |
|||
), |
|||
REQUEST_PERMISSION_BLE |
|||
) |
|||
} |
|||
} |
|||
} |
|||
} |
|||
@ -1,283 +0,0 @@ |
|||
import 'dart:async'; |
|||
import 'dart:typed_data'; |
|||
|
|||
import 'package:flutter/services.dart'; |
|||
|
|||
/// 设备类,代表一个BLE设备 |
|||
class BleDevice { |
|||
final String id; |
|||
final String name; |
|||
final int rssi; |
|||
|
|||
BleDevice({required this.id, required this.name, required this.rssi}); |
|||
|
|||
factory BleDevice.fromJson(Map<String, dynamic> json) { |
|||
return BleDevice( |
|||
id: json['id'] as String, |
|||
name: json['name'] as String? ?? 'Unknown Device', |
|||
rssi: json['rssi'] as int? ?? 0, |
|||
); |
|||
} |
|||
|
|||
Map<String, dynamic> toJson() { |
|||
return { |
|||
'id': id, |
|||
'name': name, |
|||
'rssi': rssi, |
|||
}; |
|||
} |
|||
|
|||
@override |
|||
String toString() => 'BleDevice(id: $id, name: $name, rssi: $rssi)'; |
|||
} |
|||
|
|||
/// 连接状态 |
|||
enum ConnectionState { |
|||
disconnected, |
|||
connecting, |
|||
connected, |
|||
disconnecting, |
|||
error |
|||
} |
|||
|
|||
/// 事件类型 |
|||
class EventType { |
|||
static const String audioData = 'audio_data'; |
|||
static const String connectionState = 'connection_state'; |
|||
static const String scanResult = 'scan_result'; |
|||
static const String wakeupSignal = 'wakeup_signal'; |
|||
static const String commandResult = 'command_result'; |
|||
} |
|||
|
|||
/// 杰理BLE插件主类 |
|||
class Jieli { |
|||
static const MethodChannel _methodChannel = |
|||
MethodChannel('com.yunqiinnovation.jieli'); |
|||
static const EventChannel _eventChannel = |
|||
EventChannel('com.yunqiinnovation.jieli/events'); |
|||
|
|||
/// 扫描结果流 |
|||
static final StreamController<List<BleDevice>> _scanController = |
|||
StreamController<List<BleDevice>>.broadcast(); |
|||
Stream<List<BleDevice>> get scanResults => _scanController.stream; |
|||
|
|||
/// 连接状态变化流 |
|||
static final StreamController<ConnectionState> _connectionController = |
|||
StreamController<ConnectionState>.broadcast(); |
|||
Stream<ConnectionState> get connectionState => _connectionController.stream; |
|||
|
|||
/// 音频数据流 |
|||
static final StreamController<Uint8List> _audioController = |
|||
StreamController<Uint8List>.broadcast(); |
|||
Stream<Uint8List> get audioDataStream => _audioController.stream; |
|||
|
|||
/// 唤醒信号流 |
|||
static final StreamController<bool> _wakeupController = |
|||
StreamController<bool>.broadcast(); |
|||
Stream<bool> get wakeupStream => _wakeupController.stream; |
|||
|
|||
/// 命令结果流 |
|||
static final StreamController<CommandResult> _commandResultController = |
|||
StreamController<CommandResult>.broadcast(); |
|||
Stream<CommandResult> get commandResultStream => |
|||
_commandResultController.stream; |
|||
|
|||
/// 原始事件流 |
|||
Stream<Map<String, dynamic>>? _eventStream; |
|||
|
|||
/// 事件流订阅 |
|||
StreamSubscription? _eventSubscription; |
|||
|
|||
/// 命令类型常量 |
|||
static const int commandTypeVolume = 1; |
|||
|
|||
/// 构造函数,初始化平台通道和事件监听 |
|||
Jieli() { |
|||
_setupEventListener(); |
|||
} |
|||
|
|||
/// 设置事件监听器 |
|||
void _setupEventListener() { |
|||
_eventSubscription = |
|||
_eventChannel.receiveBroadcastStream().listen(_handleEvent); |
|||
} |
|||
|
|||
/// 处理来自原生平台的事件 |
|||
void _handleEvent(dynamic event) { |
|||
if (event is! Map<dynamic, dynamic>) return; |
|||
|
|||
final eventData = Map<String, dynamic>.from(event); |
|||
final String eventType = eventData['type'] as String; |
|||
final dynamic data = eventData['data']; |
|||
|
|||
switch (eventType) { |
|||
case EventType.scanResult: |
|||
if (data is List<dynamic>) { |
|||
final deviceList = data |
|||
.map( |
|||
(item) => BleDevice.fromJson(Map<String, dynamic>.from(item))) |
|||
.toList(); |
|||
_scanController.add(deviceList); |
|||
} |
|||
break; |
|||
case EventType.connectionState: |
|||
if (data is int && data >= 0 && data < ConnectionState.values.length) { |
|||
_connectionController.add(ConnectionState.values[data]); |
|||
} |
|||
break; |
|||
case EventType.audioData: |
|||
if (data is Uint8List) { |
|||
//print('收到ble通道消息: $data'); |
|||
_audioController.add(data); |
|||
} |
|||
break; |
|||
case EventType.wakeupSignal: |
|||
if (data is bool) { |
|||
_wakeupController.add(data); |
|||
} |
|||
break; |
|||
case EventType.commandResult: |
|||
if (data is Map<dynamic, dynamic>) { |
|||
final commandType = data['commandType'] as int; |
|||
final success = data['success'] as bool; |
|||
_commandResultController.add(CommandResult( |
|||
commandType: commandType, |
|||
success: success, |
|||
)); |
|||
} |
|||
break; |
|||
} |
|||
} |
|||
|
|||
/// 开始扫描BLE设备 |
|||
Future<bool> startScan({int scanTimeout = 10000}) async { |
|||
final result = await _methodChannel.invokeMethod<bool>( |
|||
'startScan', |
|||
{'scanTimeout': scanTimeout}, |
|||
); |
|||
return result ?? false; |
|||
} |
|||
|
|||
/// 停止扫描BLE设备 |
|||
Future<bool> stopScan() async { |
|||
final result = await _methodChannel.invokeMethod<bool>('stopScan'); |
|||
return result ?? false; |
|||
} |
|||
|
|||
/// 连接到指定设备 |
|||
Future<bool> connect(String deviceId) async { |
|||
final result = await _methodChannel.invokeMethod<bool>( |
|||
'connect', |
|||
{'deviceId': deviceId}, |
|||
); |
|||
return result ?? false; |
|||
} |
|||
|
|||
/// 断开连接 |
|||
Future<bool> disconnect() async { |
|||
final result = await _methodChannel.invokeMethod<bool>('disconnect'); |
|||
return result ?? false; |
|||
} |
|||
|
|||
/// 发送开始录音命令 |
|||
Future<bool> sendStartRecordingCommand() async { |
|||
final result = |
|||
await _methodChannel.invokeMethod<bool>('sendStartRecordingCommand'); |
|||
return result ?? false; |
|||
} |
|||
|
|||
/// 发送停止录音命令 |
|||
Future<bool> sendStopRecordingCommand() async { |
|||
final result = |
|||
await _methodChannel.invokeMethod<bool>('sendStopRecordingCommand'); |
|||
return result ?? false; |
|||
} |
|||
|
|||
/// 关闭插件,释放资源 |
|||
Future<void> dispose() async { |
|||
await stopScan(); |
|||
// await disconnect(); |
|||
// _eventSubscription?.cancel(); |
|||
// _scanController.close(); |
|||
// _connectionController.close(); |
|||
// _audioController.close(); |
|||
// _wakeupController.close(); |
|||
// _commandResultController.close(); |
|||
} |
|||
|
|||
/// 检查蓝牙是否已启用 |
|||
Future<bool> isBluetoothEnabled() async { |
|||
final result = |
|||
await _methodChannel.invokeMethod<bool>('isBluetoothEnabled'); |
|||
return result ?? false; |
|||
} |
|||
|
|||
/// 请求打开蓝牙 |
|||
Future<bool> requestBluetoothEnable() async { |
|||
final result = |
|||
await _methodChannel.invokeMethod<bool>('requestBluetoothEnable'); |
|||
return result ?? false; |
|||
} |
|||
|
|||
/// 调整音量 |
|||
/// [volume] 音量值,范围0-16 |
|||
Future<bool> adjustVolume(int volume) async { |
|||
try { |
|||
final result = await _methodChannel.invokeMethod<bool>( |
|||
'adjustVolume', |
|||
{'volume': volume}, |
|||
); |
|||
return result ?? false; |
|||
} catch (e) { |
|||
print('调整音量失败: $e'); |
|||
return false; |
|||
} |
|||
} |
|||
|
|||
/// 增加音量 |
|||
Future<bool> increaseVolume() async { |
|||
try { |
|||
final result = await _methodChannel.invokeMethod<bool>('increaseVolume'); |
|||
return result ?? false; |
|||
} catch (e) { |
|||
print('增加音量失败: $e'); |
|||
return false; |
|||
} |
|||
} |
|||
|
|||
/// 减小音量 |
|||
Future<bool> decreaseVolume() async { |
|||
try { |
|||
final result = await _methodChannel.invokeMethod<bool>('decreaseVolume'); |
|||
return result ?? false; |
|||
} catch (e) { |
|||
print('减小音量失败: $e'); |
|||
return false; |
|||
} |
|||
} |
|||
|
|||
/// 命令结果回调 |
|||
Stream<CommandResult> get onCommandResult => commandResultStream |
|||
.where((event) => event.commandType == commandTypeVolume) |
|||
.map((event) => event); |
|||
} |
|||
|
|||
/// 蓝牙设备模型 |
|||
class BluetoothDevice { |
|||
// ... existing code ... |
|||
} |
|||
|
|||
/// 命令结果 |
|||
class CommandResult { |
|||
final int commandType; |
|||
final bool success; |
|||
|
|||
CommandResult({ |
|||
required this.commandType, |
|||
required this.success, |
|||
}); |
|||
|
|||
@override |
|||
String toString() => |
|||
'CommandResult{commandType: $commandType, success: $success}'; |
|||
} |
|||
@ -1,23 +0,0 @@ |
|||
name: jieli |
|||
description: A Flutter plugin for connecting with Jieli BLE earphones |
|||
version: 0.0.1 |
|||
homepage: |
|||
|
|||
environment: |
|||
sdk: ">=2.12.0 <3.0.0" |
|||
flutter: ">=2.0.0" |
|||
|
|||
dependencies: |
|||
flutter: |
|||
sdk: flutter |
|||
|
|||
dev_dependencies: |
|||
flutter_test: |
|||
sdk: flutter |
|||
|
|||
flutter: |
|||
plugin: |
|||
platforms: |
|||
android: |
|||
package: com.yunqiinnovation.jieli |
|||
pluginClass: JieliPlugin |
|||
Binary file not shown.
@ -1 +1 @@ |
|||
rootProject.name = 'jl_ota' |
|||
rootProject.name = 'jl_ota' |
|||
Binary file not shown.
Binary file not shown.
@ -1,2 +1,2 @@ |
|||
#Sun May 11 13:39:53 IST 2025 |
|||
#Fri May 16 09:26:08 IST 2025 |
|||
gradle.version=8.10 |
|||
|
|||
Some files were not shown because too many files changed in this diff
Loading…
Reference in new issue