Browse Source

上传jieli sdk 的版本优化

weicu
liwei1dao 5 months ago
parent
commit
dc9bafdd94
  1. 8
      android/app/build.gradle.kts
  2. 4
      lib/core/bindings/initial_binding.dart
  3. 17
      lib/data/services/ast_service.dart
  4. 1521
      lib/data/services/ble_manager.dart
  5. 37
      lib/data/services/bluetooth_manager.dart
  6. 75
      lib/data/services/novice_guide_service.dart
  7. 42
      lib/data/services/speech_impl/azure_ast_service.dart
  8. 84
      lib/modules/agent/controllers/agent_controller.dart
  9. 255
      lib/modules/devices/controllers/devices_controller.dart
  10. 915
      lib/modules/devices/views/devices_view.dart
  11. 2
      lib/modules/home/controllers/home_controller.dart
  12. 8
      lib/modules/login/controllers/login_controller.dart
  13. 2
      lib/modules/ota_upgrade/views/ota_upgrade_popup_view.dart
  14. 9
      lib/modules/pairing/bindings/pairing_binding.dart
  15. 1486
      lib/modules/pairing/controllers/pairing_controller.dart
  16. 879
      lib/modules/pairing/views/pairing_view.dart
  17. 304
      lib/modules/pairing/views/pairing_view_new.dart
  18. 100
      lib/modules/settings/controllers/settings_controller.dart
  19. 42
      lib/modules/settings/views/settings_view.dart
  20. 10
      lib/modules/splash/controllers/splash_controller.dart
  21. 47
      lib/modules/translation/controllers/translation_controller.dart
  22. 10
      lib/modules/wakeupvoice/bindings/wakeup_voice_binding.dart
  23. 543
      lib/modules/wakeupvoice/controllers/wakeup_voice_controller.dart
  24. 457
      lib/modules/wakeupvoice/views/wakeup_voice_view.dart
  25. 18
      lib/routes/app_pages.dart
  26. 2
      lib/routes/app_routes.dart
  27. 95
      local_plugins/azure_speech/android/src/main/kotlin/com/yunqiinnovation/azure_speech/AzureSpeechPlugin.kt
  28. 2
      local_plugins/azure_speech/pubspec.yaml
  29. 33
      local_plugins/device_jieli/.gitignore
  30. 30
      local_plugins/device_jieli/.metadata
  31. 3
      local_plugins/device_jieli/CHANGELOG.md
  32. 1
      local_plugins/device_jieli/LICENSE
  33. 15
      local_plugins/device_jieli/README.md
  34. 4
      local_plugins/device_jieli/analysis_options.yaml
  35. 9
      local_plugins/device_jieli/android/.gitignore
  36. 80
      local_plugins/device_jieli/android/build.gradle
  37. BIN
      local_plugins/device_jieli/android/libs/jl_audio_decode_V2.1.0_20012-release.aar
  38. BIN
      local_plugins/device_jieli/android/libs/jl_audio_v2_V1.0.0_9-release.aar
  39. BIN
      local_plugins/device_jieli/android/libs/jl_bluetooth_rcsp_V4.2.0_beta2_40214_20251224.aar
  40. BIN
      local_plugins/device_jieli/android/libs/jldecryption_v0.4-release.aar
  41. 1
      local_plugins/device_jieli/android/settings.gradle
  42. 12
      local_plugins/device_jieli/android/src/main/AndroidManifest.xml
  43. 41
      local_plugins/device_jieli/android/src/main/kotlin/com/jielihome/jielihome/JielihomePlugin.kt
  44. 153
      local_plugins/device_jieli/android/src/main/kotlin/com/jielihome/jielihome/api/JieliEventListener.kt
  45. 63
      local_plugins/device_jieli/android/src/main/kotlin/com/jielihome/jielihome/audio/LocalPlayer.kt
  46. 104
      local_plugins/device_jieli/android/src/main/kotlin/com/jielihome/jielihome/audio/OpusStreamCodec.kt
  47. 25
      local_plugins/device_jieli/android/src/main/kotlin/com/jielihome/jielihome/audio/PcmKit.kt
  48. 102
      local_plugins/device_jieli/android/src/main/kotlin/com/jielihome/jielihome/audio/PhoneMicCapture.kt
  49. 50
      local_plugins/device_jieli/android/src/main/kotlin/com/jielihome/jielihome/bridge/EventDispatcher.kt
  50. 227
      local_plugins/device_jieli/android/src/main/kotlin/com/jielihome/jielihome/bridge/MethodRouter.kt
  51. 195
      local_plugins/device_jieli/android/src/main/kotlin/com/jielihome/jielihome/core/JieliHomeServer.kt
  52. 114
      local_plugins/device_jieli/android/src/main/kotlin/com/jielihome/jielihome/event/BluetoothEventForwarder.kt
  53. 40
      local_plugins/device_jieli/android/src/main/kotlin/com/jielihome/jielihome/event/CustomEventForwarder.kt
  54. 73
      local_plugins/device_jieli/android/src/main/kotlin/com/jielihome/jielihome/event/DeviceInfoEventForwarder.kt
  55. 79
      local_plugins/device_jieli/android/src/main/kotlin/com/jielihome/jielihome/event/MediaEventForwarder.kt
  56. 56
      local_plugins/device_jieli/android/src/main/kotlin/com/jielihome/jielihome/feature/ConnectFeature.kt
  57. 42
      local_plugins/device_jieli/android/src/main/kotlin/com/jielihome/jielihome/feature/CustomCmdFeature.kt
  58. 73
      local_plugins/device_jieli/android/src/main/kotlin/com/jielihome/jielihome/feature/DeviceInfoFeature.kt
  59. 19
      local_plugins/device_jieli/android/src/main/kotlin/com/jielihome/jielihome/feature/ScanFeature.kt
  60. 264
      local_plugins/device_jieli/android/src/main/kotlin/com/jielihome/jielihome/feature/ota/OtaFeature.kt
  61. 105
      local_plugins/device_jieli/android/src/main/kotlin/com/jielihome/jielihome/feature/translation/EventChannelAudioBridge.kt
  62. 210
      local_plugins/device_jieli/android/src/main/kotlin/com/jielihome/jielihome/feature/translation/JieliCallTranslationPort.kt
  63. 52
      local_plugins/device_jieli/android/src/main/kotlin/com/jielihome/jielihome/feature/translation/TranslationAudioBridge.kt
  64. 151
      local_plugins/device_jieli/android/src/main/kotlin/com/jielihome/jielihome/feature/translation/TranslationFeature.kt
  65. 67
      local_plugins/device_jieli/android/src/main/kotlin/com/jielihome/jielihome/feature/translation/TranslationModeHandler.kt
  66. 27
      local_plugins/device_jieli/android/src/main/kotlin/com/jielihome/jielihome/feature/translation/TranslationStreams.kt
  67. 61
      local_plugins/device_jieli/android/src/main/kotlin/com/jielihome/jielihome/feature/translation/mode/AudioTranslationModeHandler.kt
  68. 138
      local_plugins/device_jieli/android/src/main/kotlin/com/jielihome/jielihome/feature/translation/mode/CallTranslationModeHandler.kt
  69. 63
      local_plugins/device_jieli/android/src/main/kotlin/com/jielihome/jielihome/feature/translation/mode/FaceToFaceTranslationModeHandler.kt
  70. 64
      local_plugins/device_jieli/android/src/main/kotlin/com/jielihome/jielihome/feature/translation/mode/RecordModeHandler.kt
  71. 59
      local_plugins/device_jieli/android/src/main/kotlin/com/jielihome/jielihome/feature/translation/mode/RecordingTranslationModeHandler.kt
  72. 128
      local_plugins/device_jieli/android/src/main/kotlin/com/jielihome/jielihome/feature/translation/mode/StereoCallTranslationModeHandler.kt
  73. 17
      local_plugins/device_jieli/android/src/main/kotlin/com/jielihome/jielihome/feature/translation/runtime/NoOpAITranslationApi.kt
  74. 470
      local_plugins/device_jieli/android/src/main/kotlin/com/jielihome/jielihome/feature/translation/runtime/RcspTranslationRuntime.kt
  75. 196
      local_plugins/device_jieli/android/src/main/kotlin/com/jielihome/jielihome/feature/voice/SpeechFeature.kt
  76. 314
      local_plugins/device_jieli/android/src/main/kotlin/com/jielihome/jielihome/integration/JieliNativeDevicePlugin.kt
  77. 266
      local_plugins/device_jieli/android/src/main/kotlin/com/jielihome/jielihome/integration/JieliNativeDeviceSession.kt
  78. 27
      local_plugins/device_jieli/android/src/test/kotlin/com/jielihome/jielihome/JielihomePluginTest.kt
  79. 45
      local_plugins/device_jieli/example/.gitignore
  80. 16
      local_plugins/device_jieli/example/README.md
  81. 28
      local_plugins/device_jieli/example/analysis_options.yaml
  82. 14
      local_plugins/device_jieli/example/android/.gitignore
  83. 45
      local_plugins/device_jieli/example/android/app/build.gradle.kts
  84. BIN
      local_plugins/device_jieli/example/android/app/libs/jl_audio_decode_V2.1.0_20012-release.aar
  85. BIN
      local_plugins/device_jieli/example/android/app/libs/jl_audio_v2_V1.0.0_9-release.aar
  86. BIN
      local_plugins/device_jieli/example/android/app/libs/jl_bluetooth_rcsp_V4.2.0_beta2_40214_20251224.aar
  87. BIN
      local_plugins/device_jieli/example/android/app/libs/jldecryption_v0.4-release.aar
  88. 7
      local_plugins/device_jieli/example/android/app/src/debug/AndroidManifest.xml
  89. 55
      local_plugins/device_jieli/example/android/app/src/main/AndroidManifest.xml
  90. 5
      local_plugins/device_jieli/example/android/app/src/main/kotlin/com/jielihome/jielihome_example/MainActivity.kt
  91. 12
      local_plugins/device_jieli/example/android/app/src/main/res/drawable-v21/launch_background.xml
  92. 12
      local_plugins/device_jieli/example/android/app/src/main/res/drawable/launch_background.xml
  93. BIN
      local_plugins/device_jieli/example/android/app/src/main/res/mipmap-hdpi/ic_launcher.png
  94. BIN
      local_plugins/device_jieli/example/android/app/src/main/res/mipmap-mdpi/ic_launcher.png
  95. BIN
      local_plugins/device_jieli/example/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png
  96. BIN
      local_plugins/device_jieli/example/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png
  97. BIN
      local_plugins/device_jieli/example/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png
  98. 18
      local_plugins/device_jieli/example/android/app/src/main/res/values-night/styles.xml
  99. 18
      local_plugins/device_jieli/example/android/app/src/main/res/values/styles.xml
  100. 7
      local_plugins/device_jieli/example/android/app/src/profile/AndroidManifest.xml

8
android/app/build.gradle.kts

@ -6,6 +6,7 @@ repositories {
flatDir {
dirs(project(":spotify_sdk").projectDir.resolve("libs"))
dirs(project(":qq_music").projectDir.resolve("libs")) // 添加这行
dirs(project(":device_jieli").projectDir.resolve("libs")) // 杰理 SDK 运行时 .aar
}
}
@ -152,6 +153,13 @@ dependencies {
// 添加 CardView 依赖
implementation("androidx.cardview:cardview:1.0.0")
// device_jieli 杰理 SDK 运行时依赖(.aar 在 device_jieli 模块只能 compileOnly,
// host app 必须自己引入,否则 RCSPController 等类在运行时找不到 → scanFeature 不会被初始化)
implementation(":jl_bluetooth_rcsp_V4.2.0_beta2_40214_20251224@aar")
implementation(":jl_audio_v2_V1.0.0_9-release@aar")
implementation(":jldecryption_v0.4-release@aar")
// jl_audio_decode 已经被 ble_service 通过 com.jieli.audio:jl_audio_decode:2.1.0 引入,无需重复
}
flutter {

4
lib/core/bindings/initial_binding.dart

@ -52,10 +52,10 @@ class InitialBinding extends Bindings {
() => MicrosoftTranslationService(),
fenix: true);
// BleManager —— 提前 put(不用 lazyPut),让原生杰理 SDK 在 onInit 里立即开始 initialize
Get.put<BleManager>(BleManager(), permanent: true);
// 蓝牙服务
Get.lazyPut<BluetoothManager>(() => BluetoothManager(), fenix: true);
// BleManager
Get.lazyPut<BleManager>(() => BleManager(), fenix: true);
// MusicManager
Get.lazyPut<MusicManager>(() => MusicManager(), fenix: true);

17
lib/data/services/ast_service.dart

@ -36,10 +36,27 @@ abstract class AstService {
/// 未实现的原生端应 no-op 并返回 true
Future<void> setBroadcastPeerTranslate(bool enabled);
/// 把外部捕获的 PCM(杰理 runtime 解码后的 SCO 上下行)推给 AST 流水线。
/// [leg] 'A'/'uplink' = 本机麦克风;'B'/'downlink' = 对端通话音
/// [pcm] 16k mono 16bit PCM little-endian
Future<void> pushExternalAudio({required String leg, required Uint8List pcm});
/// AST 流水线 / E2E 服务合成的 TTS PCM 帧流。每帧包含 leg('A'|'B') + pcm。
/// 调用方负责把它转发给设备(如 [Jielihome.feedTranslatedAudio])。
Stream<AstTtsFrame> get astTtsFrames;
/// 释放资源
Future<void> dispose();
}
/// AST 服务输出的 TTS PCM 帧(用于通话翻译双向回灌)。
class AstTtsFrame {
/// 'A' = 己方→对端 译文(让对端听);'B' = 对端→己方 译文(让本机听)
final String leg;
final Uint8List pcm;
const AstTtsFrame({required this.leg, required this.pcm});
}
// 识别事件类型
enum ASTEventType {
/// 最终识别结果

1521
lib/data/services/ble_manager.dart

File diff suppressed because it is too large

37
lib/data/services/bluetooth_manager.dart

@ -1,10 +1,7 @@
import 'dart:async';
import 'package:get/get.dart';
import 'package:classic_bluetooth/classic_bluetooth.dart';
import 'package:get_storage/get_storage.dart';
import '../../core/utils/logger.dart';
import '../../routes/app_routes.dart';
import 'ble_manager.dart';
// 重新导出蓝牙设备类型,让外部可以通过BluetoothManager访问
export 'package:classic_bluetooth/classic_bluetooth.dart' show BluetoothDevice;
@ -21,8 +18,6 @@ class BluetoothManager extends GetxService {
// 当前经典蓝牙设备
final Rx<BluetoothDevice?> currentDeviceRx = Rx<BluetoothDevice?>(null);
BluetoothDevice? get currentDevice => currentDeviceRx.value;
final BleManager _bleManager = Get.find<BleManager>();
final GetStorage _storage = GetStorage();
// 订阅
StreamSubscription<BluetoothEvent>? _bluetoothSubscription;
@ -77,16 +72,7 @@ class BluetoothManager extends GetxService {
currentDeviceRx.value = newDevice;
// 检查当前是否在OTA升级界面,如果是则不跳转
if (_isLoggedIn() &&
!_isInOtaUpgradePage() &&
!_bleManager.isConnected &&
Get.currentRoute != Routes.pairing) {
Get.toNamed(Routes.pairing, arguments: newDevice);
} else {
Logger.info('当前在OTA升级界面,跳过配对页面跳转');
}
// 正常模式:更新当前设备并触发监听器
// 经典蓝牙连接事件:仅记录日志,不再跳转配对页(绑定流程已下线)
Logger.info('经典蓝牙设备已连接: ${newDevice.name} (${newDevice.address})');
} else if (event.type == 'deviceDisconnected' &&
event.device != null) {
@ -153,25 +139,4 @@ class BluetoothManager extends GetxService {
}
}
/// 检查当前是否在OTA升级界面
/// 返回true表示当前在OTA界面,不应该跳转到配对页面
bool _isInOtaUpgradePage() {
try {
final currentRoute = Get.currentRoute;
return currentRoute == Routes.OTA_UPGRADE;
} catch (e) {
Logger.error('检查当前路由失败: ${e.toString()}');
return false;
}
}
/// 是否已登录
bool _isLoggedIn() {
try {
final token = _storage.read('logintoken');
return token != null && token is String && token.isNotEmpty;
} catch (e) {
return false;
}
}
}

75
lib/data/services/novice_guide_service.dart

@ -1,7 +1,5 @@
import 'dart:async';
import 'dart:io';
import 'package:ble_service/ble_service.dart';
import 'package:flutter_intro/flutter_intro.dart';
import 'package:get/get.dart';
import 'package:get_storage/get_storage.dart';
@ -11,12 +9,10 @@ import '../../core/utils/permission_util.dart';
class NoviceGuideService extends GetxService {
final GetStorage _storage = GetStorage();
final BleService _bleService = BleService();
StreamSubscription<dynamic>? _statusSubscription;
bool isHomeGuide = false; //false代表未完成新手引导,true代表已经完成
bool isAIGuide = false;
/// AI 唤醒已下线,保留字段以兼容 home_view 引导覆盖层;始终为 false。
RxBool isWakeup = false.obs;
int aiStep = 0;
@ -28,13 +24,11 @@ class NoviceGuideService extends GetxService {
'sendKey': 'guideWeatherSend',
'endKey': 'guideWeatherEnd',
'method': () async {
// 天气查询需要位置权限
await PermissionUtil.instance.requestPermission(
permissionType: Permission.location,
permissionName: 'locationInfo'.tr, // 位置信息
explanationText: 'locationPermissionDesc'.tr, // 用于获取当前位置信息
permanentDenialText:
'locationPermissionDetailDesc'.tr, // 获取位置信息需要位置权限
permissionName: 'locationInfo'.tr,
explanationText: 'locationPermissionDesc'.tr,
permanentDenialText: 'locationPermissionDetailDesc'.tr,
);
},
},
@ -51,13 +45,11 @@ class NoviceGuideService extends GetxService {
'sendKey': 'guideNavSend',
'endKey': 'guideNavEnd',
'method': () async {
// 导航功能需要位置权限
await PermissionUtil.instance.requestPermission(
permissionType: Permission.location,
permissionName: 'locationInfo'.tr, // 位置信息
explanationText: 'locationPermissionDesc'.tr, // 用于获取当前位置信息
permanentDenialText:
'locationPermissionDetailDesc'.tr, // 获取位置信息需要位置权限
permissionName: 'locationInfo'.tr,
explanationText: 'locationPermissionDesc'.tr,
permanentDenialText: 'locationPermissionDetailDesc'.tr,
);
},
},
@ -67,13 +59,11 @@ class NoviceGuideService extends GetxService {
'sendKey': 'guideMeetingSend',
'endKey': 'guideMeetingEnd',
'method': () async {
//创建会议提醒需要日历权限
await PermissionUtil.instance.requestPermission(
permissionType: Permission.calendarFullAccess,
permissionName: 'calendarFullAccess'.tr, // 日历写入
explanationText: 'calendarFullAccessPermissionDesc'.tr, // 用于创建日历事件和提醒
permanentDenialText:
'calendarFullAccessPermissionDetailDesc'.tr, // 创建日历事件需要日历写入权限
permissionName: 'calendarFullAccess'.tr,
explanationText: 'calendarFullAccessPermissionDesc'.tr,
permanentDenialText: 'calendarFullAccessPermissionDetailDesc'.tr,
);
},
},
@ -84,20 +74,17 @@ class NoviceGuideService extends GetxService {
'endKey': 'guidePhoneEnd',
'method': () async {
if (Platform.isAndroid) {
// 拨打电话需要电话权限
await PermissionUtil.instance.requestPermission(
permissionType: Permission.phone,
permissionName: 'phone'.tr, // 电话
explanationText: 'phonePermissionDesc'.tr, // 用于拨打电话功能
permanentDenialText: 'phonePermissionDetailDesc'.tr, // 拨打电话功能需要电话权限
permissionName: 'phone'.tr,
explanationText: 'phonePermissionDesc'.tr,
permanentDenialText: 'phonePermissionDetailDesc'.tr,
);
//拨打电话还需要联系人权限
await PermissionUtil.instance.requestPermission(
permissionType: Permission.contacts,
permissionName: 'contacts'.tr, // 联系人
explanationText: 'contactsPermissionDesc'.tr, // 用于访问联系人信息
permanentDenialText:
'contactsPermissionDetailDesc'.tr, // 拨打电话功能需要联系人权限
permissionName: 'contacts'.tr,
explanationText: 'contactsPermissionDesc'.tr,
permanentDenialText: 'contactsPermissionDetailDesc'.tr,
);
}
},
@ -109,13 +96,11 @@ class NoviceGuideService extends GetxService {
'endKey': 'guideSmsEnd',
'method': () async {
if (Platform.isAndroid) {
//发送短信还需要联系人权限
await PermissionUtil.instance.requestPermission(
permissionType: Permission.contacts,
permissionName: 'contacts'.tr, // 联系人
explanationText: 'contactsPermissionDesc'.tr, // 用于访问联系人信息
permanentDenialText:
'contactsPermissionDetailDesc'.tr, // 发送短信功能需要联系人权限
permissionName: 'contacts'.tr,
explanationText: 'contactsPermissionDesc'.tr,
permanentDenialText: 'contactsPermissionDetailDesc'.tr,
);
}
},
@ -129,12 +114,6 @@ class NoviceGuideService extends GetxService {
isAIGuide = _storage.read('ai_guide') ?? false;
}
@override
void onClose() {
closeWakeupListen();
super.onClose();
}
void startHomeGuide() async {
if (!isHomeGuide) {
await Future.delayed(const Duration(milliseconds: 200));
@ -153,17 +132,7 @@ class NoviceGuideService extends GetxService {
}
}
void startWakeupListen() {
_bleService.setWakeup(false);
_statusSubscription = _bleService.onStatusUpdated.listen((event) {
if (event is Map && event['type'] == 'wakeup') {
isWakeup.value = true;
}
});
}
void closeWakeupListen() {
_bleService.setWakeup(true);
_statusSubscription?.cancel();
}
/// 唤醒功能已移除,下面两个方法保留为空实现以兼容旧调用点。
void startWakeupListen() {}
void closeWakeupListen() {}
}

42
lib/data/services/speech_impl/azure_ast_service.dart

@ -13,6 +13,10 @@ class AzureAstService extends GetxService implements AstService {
static const MethodChannel _channel = MethodChannel('azure_speech/ast');
static const EventChannel _eventChannel =
EventChannel('azure_speech/ast_events');
// 通话翻译模式下,A/B 路 TTS PCM 走这条 EventChannel 抛回 Dart,
// 由调用方(translation_controller)转发给 Jielihome.feedTranslatedAudio。
static const EventChannel _astTtsEventChannel =
EventChannel('azure_speech/ast_tts_events');
bool _isInitialized = false;
late final String _subscriptionKey;
@ -424,6 +428,44 @@ class AzureAstService extends GetxService implements AstService {
}
}
@override
Future<void> pushExternalAudio({
required String leg,
required Uint8List pcm,
}) async {
try {
await _channel.invokeMethod('pushExternalAudio', {
'leg': leg,
'pcm': pcm,
});
} catch (e) {
Logger.error('pushExternalAudio leg=$leg failed: $e');
}
}
// AST TTS 输出帧流(lazy + broadcast,多方订阅安全)
StreamController<AstTtsFrame>? _astTtsController;
StreamSubscription? _astTtsRawSub;
@override
Stream<AstTtsFrame> get astTtsFrames {
final c = _astTtsController ??= StreamController<AstTtsFrame>.broadcast(
onCancel: () {
// 没有订阅者时不必停 native event channel:原生侧自身有 sink null 检查。
},
);
_astTtsRawSub ??= _astTtsEventChannel.receiveBroadcastStream().listen(
(raw) {
if (raw is! Map) return;
final leg = raw['leg'] as String?;
final pcm = raw['pcm'];
if (leg == null || pcm is! Uint8List) return;
if (!c.isClosed) c.add(AstTtsFrame(leg: leg, pcm: pcm));
},
onError: (e) => Logger.error('astTtsFrames stream error: $e'),
);
return c.stream;
}
@override
void setLowVolumeThreshold(int threshold) {
try {

84
lib/modules/agent/controllers/agent_controller.dart

@ -18,7 +18,6 @@ import '/core/utils/upload_oss.dart';
import '/data/models/user_Info.dart';
import '/data/services/music_manager.dart';
import '/data/services/navigation_manager.dart';
import 'package:ble_service/ble_service.dart';
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import 'package:agent_service/agent_service.dart';
@ -118,9 +117,6 @@ class AgentController extends GetxController with WidgetsBindingObserver {
// 按住说话模式状态 - 用于区分是否处于按住说话模式(即使当前没有按住)
//final isPushToTalkMode = false.obs; // 是否处于按住说话模式
// 蓝牙唤醒状态
final isBleWakeupActive = false.obs; // 是否正在进行蓝牙唤醒对话
// 键盘状态
final isKeyboardVisible = false.obs; // 键盘是否可见
@ -130,9 +126,6 @@ class AgentController extends GetxController with WidgetsBindingObserver {
// 导航流事件订阅
StreamSubscription<AmapServiceEvent>? _amapeventSubscription;
// BLE事件订阅
StreamSubscription<dynamic>? _bleEventSubscription;
// 进度更新定时器
Timer? _progressTimer;
// 用于跟踪AI回复状态
@ -197,7 +190,6 @@ class AgentController extends GetxController with WidgetsBindingObserver {
_setupScrollListener(); //设置滚动监听器
_loadChatHistory(); //加载聊天历史记录
_subscribeToEvents(); //订阅代理服务事件
// _subscribeToBleEvents(); //订阅BLE事件
_initMusicStatus(); //初始化音乐状态
_setupMediaSessionCallbacks(); //设置媒体会话回调
_setupTextInputListener(); //设置文本输入监听器
@ -250,7 +242,6 @@ class AgentController extends GetxController with WidgetsBindingObserver {
scrollController.dispose();
_eventSubscription?.cancel();
_amapeventSubscription?.cancel();
_bleEventSubscription?.cancel();
Intro.of(Get.context!).dispose();
// 取消应用生命周期监听
@ -633,21 +624,6 @@ class AgentController extends GetxController with WidgetsBindingObserver {
case AgentServiceEventType.recognitionStopped:
isListening.value = false;
// 处理蓝牙唤醒结束
if (isBleWakeupActive.value) {
isBleWakeupActive.value = false;
// 蓝牙唤醒结束后,恢复之前的输入模式
_restorePreviousInputMode();
Logger.i(TAG, '蓝牙唤醒对话结束,恢复之前的输入模式');
} else {
// 只有在非按住说话模式且非打电话模式下才自动切换到文字模式
// 按住说话模式下应该保持在语音模式,等待下次按住
// if (!isPhoneCallMode.value && !isPushToTalkActive.value) {
// Logger.i(TAG, '切换文本输入模式');
// isTextInputMode.value = false;
// }
}
Logger.i(TAG, '识别停止');
break;
@ -1154,66 +1130,6 @@ class AgentController extends GetxController with WidgetsBindingObserver {
});
}
// 订阅BLE事件
void _subscribeToBleEvents() {
try {
_bleEventSubscription = BleService().onStatusUpdated.listen((event) {
if (event is Map && event['type'] == 'wakeup') {
_handleBleWakeup();
}
});
Logger.i(TAG, 'BLE事件监听已设置');
} catch (e) {
Logger.e(TAG, 'BLE事件监听设置失败: $e');
}
}
// 处理BLE唤醒信号
void _handleBleWakeup() {
Logger.i(TAG, '🎧 收到BLE唤醒信号');
// 检查是否可以启动蓝牙唤醒
if (!_canStartBleWakeup()) {
Logger.w(TAG, '当前状态不允许BLE唤醒');
return;
}
// 保存当前输入模式
_saveCurrentInputMode();
// 设置蓝牙唤醒状态
isBleWakeupActive.value = true;
// 注意:这里不需要调用startVoiceInput(),因为原生层已经启动了语音识别
// 我们只需要同步Flutter层的状态
//isTextInputMode.value = false;
Logger.i(TAG, 'BLE唤醒状态已设置,等待原生层语音识别结果');
}
// 检查是否可以启动BLE唤醒
bool _canStartBleWakeup() {
// 如果已经在BLE唤醒状态,不允许重复启动
if (isBleWakeupActive.value) {
Logger.w(TAG, '已在BLE唤醒状态,忽略重复唤醒');
return false;
}
// 如果正在打电话模式,不允许BLE唤醒
if (isPhoneCallMode.value) {
Logger.w(TAG, '正在打电话模式,忽略BLE唤醒');
return false;
}
// 如果正在处理TTS,暂时不允许启动
if (isSpeaking.value) {
Logger.w(TAG, '正在播放TTS,延迟BLE唤醒');
return false;
}
return true;
}
// 发送消息(包括所有支持的消息类型)
//场景1:用户在文本输入框中输入文本,点击发送按钮,调用sendMessage()方法。
//场景2:当用户选择图片后在文本框输入内容并发送时,调用sendMessage()转发给sendImageMessage()。

255
lib/modules/devices/controllers/devices_controller.dart

@ -1,110 +1,211 @@
// import 'dart:ffi'; // Not supported on web; not needed here
import 'dart:async';
import '/data/models/appconfig_model.dart';
import 'package:device_jieli/device_jieli.dart';
import 'package:flutter/material.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
import 'package:get/get.dart';
import 'package:permission_handler/permission_handler.dart';
import '/core/utils/logger.dart';
import '/data/models/user_Info.dart';
import '/data/services/ble_manager.dart';
import '/data/services/network/api.dart';
import '/routes/app_routes.dart';
import 'package:get/get.dart';
/*
设备管理
*/
/// 设备页控制器:扫描杰理设备、点击连接、断开。
/// 旧的绑定 / 解绑流程已下线。
class DevicesController extends GetxController {
late final BleManager _bleManager;
// 修改为响应式变量
final RxMap<int, List<UserDevice>> devices = <int, List<UserDevice>>{}.obs;
// 扫描结果(直接绑定到 BleManager 内部 Rx 列表)
RxList<JieliDevice> get scannedDevices => _bleManager.scannedDevices;
RxBool get isScanning => _bleManager.isScanning;
// 当前连接地址(响应式)
RxInt get connectionStateRx => _bleManager.connectionStateRx;
// 或者使用这种方式
// final Rx<Map<int, List<UserDevice>>> devices = Rx<Map<int, List<UserDevice>>>({});
String? get currentDeviceAddress => _bleManager.currentDeviceAddress;
// 正在连接的目标地址(用于 tile UI 反馈)
final RxnString connectingAddress = RxnString();
@override
void onInit() {
super.onInit();
print("初始化设备管理1");
_bleManager = Get.find<BleManager>();
print("初始化设备管理2");
// 在初始化时加载设备数据
_loadDevices();
Future.microtask(startScan);
}
// 加载设备数据
void _loadDevices() async {
Logger.info("加载设备");
final response = await Api.getdevices();
if (response != null) {
if (response['devices'] != null) {
var devices = (response['devices'] as List<dynamic>)
.map((e) => UserDevice.fromJson(e as Map<String, dynamic>))
.toList();
User.instance.devices = devices;
} else {
User.instance.devices = [];
}
Logger.info("加载设备 ${User.instance.devices.length}");
@override
void onClose() {
_bleManager.stopScan();
super.onClose();
}
Future<void> startScan() async {
print('[BLE] [SCAN] DevicesController.startScan 被调用');
final granted = await _ensureBluetoothPermission();
print('[BLE] [SCAN] 权限申请结果: granted=$granted');
if (!granted) {
Get.snackbar('tip'.tr, 'pleaseGrantBluetoothPermission'.tr);
return;
}
devices.clear();
for (var device in User.instance.devices) {
if (!devices.containsKey(device.devicetype)) {
devices[device.devicetype] = [];
}
devices[device.devicetype]!.add(device);
final ok = await _bleManager.startScan();
if (!ok) {
final err = _bleManager.lastScanError ?? 'unknown';
print('[BLE] [SCAN] 扫描启动失败: $err');
Get.snackbar('error'.tr, '${'startScanFailed'.tr}: $err');
}
devices.refresh();
}
@override
void onClose() {
super.onClose();
// onClose 中不应该修改数据,这里应该是清理资源
Future<void> stopScan() async {
await _bleManager.stopScan();
}
void unbindearphone(UserDevice device) async {
final response = await Api.unbinddevice({
"id": device.id,
});
Logger.info("解绑设备 $response");
if (response != null) {
// 响应式更新
User.instance.devices.remove(device);
devices.clear();
for (var device in User.instance.devices) {
if (!devices.containsKey(device.devicetype)) {
devices[device.devicetype] = [];
Future<void> connect(JieliDevice device) async {
if (connectingAddress.value != null) {
Logger.info('连接进行中,忽略重复点击: ${device.address}');
return;
}
// 已连上同一台设备 → 直接进首页,避免卡在「连接中」对话框
if (_bleManager.isConnected &&
_bleManager.currentDeviceAddress == device.address) {
Logger.info('设备已处于连接状态,直接进入首页: ${device.address}');
Get.offAllNamed(Routes.home);
return;
}
Logger.info('连接设备: ${device.name} ${device.address}');
connectingAddress.value = device.address;
await _bleManager.stopScan();
// 必须 BLE 连接 OK + RCSP init 成功 才算「真正连上」。
// 任一环节失败立即判失败,避免 dialog 关掉之后又被断开。
final completer = Completer<bool>();
bool gotConnectionOk = false;
final evtSub = Jielihome.instance.events.listen((evt) {
if (evt is ConnectionStateEvent) {
if (evt.address != device.address) return;
if (evt.state == ConnectionStateEvent.connectionOk) {
gotConnectionOk = true;
} else if (evt.state == ConnectionStateEvent.connectionDisconnect) {
if (!completer.isCompleted) completer.complete(false);
}
} else if (evt is RcspInitEvent) {
if (evt.address != device.address) return;
if (evt.success && gotConnectionOk) {
if (!completer.isCompleted) completer.complete(true);
} else if (!evt.success) {
if (!completer.isCompleted) completer.complete(false);
}
devices[device.devicetype]!.add(device);
}
devices.refresh();
Logger.info(
"解绑设备 ${device.devicemac} 当前使用设备:${_bleManager.currentDeviceAddress}");
if (device.devicemac == _bleManager.currentDeviceAddress) {
Logger.info("解绑设备 $device 重新匹配!");
User.instance.removeUserDevice(device.id);
_bleManager.clearPairedDevices();
_bleManager.disconnect();
await Future.delayed(const Duration(seconds: 1));
Get.toNamed(Routes.pairing);
});
_showConnectingDialog(device);
try {
await _bleManager.connectDevice(device);
final ok = await completer.future
.timeout(const Duration(seconds: 20), onTimeout: () => false);
if (Get.isDialogOpen ?? false) Get.back();
if (ok) {
Get.offAllNamed(Routes.home);
} else {
Get.snackbar('error'.tr, 'connectionFailure'.tr);
}
return;
} else {
return;
} catch (e) {
Logger.error('连接异常: $e');
if (Get.isDialogOpen ?? false) Get.back();
Get.snackbar('error'.tr, 'connectionFailure'.tr);
} finally {
await evtSub.cancel();
connectingAddress.value = null;
}
}
// 添加设备
void addDevice(UserDevice device) {
if (!devices.containsKey(device.devicetype)) {
devices[device.devicetype] = [];
}
devices[device.devicetype]!.add(device);
devices.refresh(); // 触发UI更新
void _showConnectingDialog(JieliDevice device) {
Get.dialog(
PopScope(
canPop: false,
child: Dialog(
backgroundColor: Colors.transparent,
elevation: 0,
insetPadding: EdgeInsets.symmetric(horizontal: 40.w),
child: Container(
padding: EdgeInsets.symmetric(horizontal: 24.w, vertical: 28.h),
decoration: BoxDecoration(
color: Get.isDarkMode
? const Color(0xFF1C1C1E)
: Colors.white,
borderRadius: BorderRadius.circular(16.r),
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
SizedBox(
width: 44.w,
height: 44.w,
child: const CircularProgressIndicator(
strokeWidth: 3,
valueColor:
AlwaysStoppedAnimation<Color>(Color(0xFF0066CC)),
),
),
SizedBox(height: 18.h),
Text(
'connectingDevice'.tr,
style: TextStyle(
fontSize: 15.sp,
fontWeight: FontWeight.w600,
color: Get.isDarkMode ? Colors.white : Colors.black87,
),
),
SizedBox(height: 6.h),
Text(
device.name.isEmpty ? device.address : device.name,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontSize: 12.sp,
color: Get.isDarkMode ? Colors.white54 : Colors.black54,
),
),
],
),
),
),
),
barrierDismissible: false,
);
}
// 获取指定类型的设备列表
List<UserDevice> getDevicesByType(int deviceType) {
return devices[deviceType] ?? [];
Future<void> disconnect() async {
await _bleManager.disconnect();
}
bool isConnected(JieliDevice device) {
return _bleManager.isConnected &&
_bleManager.currentDeviceAddress == device.address;
}
Future<bool> _ensureBluetoothPermission() async {
try {
// Android 12+:BLUETOOTH_SCAN 已声明 neverForLocation,所以不强制要求定位。
// 但实际不少 ROM 仍要求定位才能扫到 BLE 设备 —— 同时申请,谁拒绝就报错。
final results = await [
Permission.bluetoothScan,
Permission.bluetoothConnect,
Permission.locationWhenInUse,
].request();
print('[BLE] [SCAN] 权限申请详情: $results');
// 至少 scan + connect 必须授权;location 缺失警告但不一定阻塞
final scanOk = results[Permission.bluetoothScan]?.isGranted ?? false;
final connectOk = results[Permission.bluetoothConnect]?.isGranted ?? false;
return scanOk && connectOk;
} catch (e) {
Logger.error('请求蓝牙权限失败: $e');
return false;
}
}
}

915
lib/modules/devices/views/devices_view.dart

File diff suppressed because it is too large

2
lib/modules/home/controllers/home_controller.dart

@ -203,7 +203,7 @@ class HomeController extends GetxController {
}
void rescanDevices() {
Get.toNamed(Routes.pairing);
Get.toNamed(Routes.devices);
}
void openTranslation() {

8
lib/modules/login/controllers/login_controller.dart

@ -17,6 +17,7 @@ import 'appleLogin.dart';
import '../../../routes/app_routes.dart';
import '../../../data/models/appconfig.dart';
import '../../../data/models/appconfig_model.dart';
import '../../../data/services/ble_manager.dart';
import '../../../data/services/network/api.dart';
import '../../../../data/models/user_Info.dart';
import '../../../data/models/channel_app_model.dart';
@ -666,7 +667,12 @@ class LoginController extends GetxController {
// 获取app的配置信息
await getAppConfig();
await _initMeetingTemplate();
Get.offAllNamed(Routes.pairing);
// 登录成功 → 检查 BLE 设备是否已连接,未连接则进入扫描页
bool connected = false;
try {
connected = await BleManager.to.hasActiveConnection();
} catch (_) {}
Get.offAllNamed(connected ? Routes.home : Routes.devices);
} else {
throw Exception('接口返回错误: ${response['msg']}');
}

2
lib/modules/ota_upgrade/views/ota_upgrade_popup_view.dart

@ -80,7 +80,7 @@ class OtaUpgradePopUp {
ElevatedButton(
onPressed: () {
Get.back();
Get.offAllNamed(Routes.pairing);
Get.offAllNamed(Routes.devices);
},
style: ElevatedButton.styleFrom(
backgroundColor: Colors.green[600],

9
lib/modules/pairing/bindings/pairing_binding.dart

@ -1,9 +0,0 @@
import 'package:get/get.dart';
import '../controllers/pairing_controller.dart';
class PairingBinding extends Bindings {
@override
void dependencies() {
Get.put<PairingController>(PairingController());
}
}

1486
lib/modules/pairing/controllers/pairing_controller.dart

File diff suppressed because it is too large

879
lib/modules/pairing/views/pairing_view.dart

@ -1,879 +0,0 @@
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
import '../controllers/pairing_controller.dart';
class PairingView extends StatefulWidget {
const PairingView({Key? key}) : super(key: key);
@override
State<PairingView> createState() => _PairingViewState();
}
class _PairingViewState extends State<PairingView>
with TickerProviderStateMixin {
late AnimationController _pulseController;
late AnimationController _rotationController;
late Animation<double> _pulseAnimation;
late Animation<double> _rotationAnimation;
PairingController get controller => Get.find<PairingController>();
@override
void initState() {
super.initState();
// 脉搏动画控制器
_pulseController = AnimationController(
duration: const Duration(milliseconds: 1500),
vsync: this,
);
// 旋转动画控制器
_rotationController = AnimationController(
duration: const Duration(milliseconds: 2000),
vsync: this,
);
// 脉搏动画
_pulseAnimation = Tween<double>(
begin: 0.8,
end: 1.2,
).animate(CurvedAnimation(
parent: _pulseController,
curve: Curves.easeInOut,
));
// 旋转动画
_rotationAnimation = Tween<double>(
begin: 0.0,
end: 1.0,
).animate(CurvedAnimation(
parent: _rotationController,
curve: Curves.linear,
));
// 开始动画
_pulseController.repeat(reverse: true);
_rotationController.repeat();
}
@override
void dispose() {
_pulseController.dispose();
_rotationController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final bool isDarkMode = Theme.of(context).brightness == Brightness.dark;
return Scaffold(
backgroundColor: isDarkMode ? Colors.black : Colors.white,
body: SafeArea(
child: Column(
children: [
// 固定顶部区域 - 不受下方内容影响
_buildFixedTopSection(isDarkMode),
// 可滚动的动态内容区域
Expanded(
child: _buildDynamicContentSection(isDarkMode),
),
],
),
),
);
}
// 固定顶部区域
Widget _buildFixedTopSection(bool isDarkMode) {
return Padding(
padding: EdgeInsets.only(top: 60.h, left: 24.w, right: 24.w),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
// 标题部分
_buildHeader(isDarkMode),
SizedBox(height: 20.h),
_buildDeviceAnimation(isDarkMode),
],
),
);
}
// 动态内容区域
Widget _buildDynamicContentSection(bool isDarkMode) {
return SingleChildScrollView(
physics: const BouncingScrollPhysics(),
child: Padding(
padding: EdgeInsets.symmetric(horizontal: 24.w),
child: Column(
mainAxisAlignment: MainAxisAlignment.start,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
SizedBox(height: 30.h),
// 状态信息
_buildStatusInfo(isDarkMode),
SizedBox(height: 40.h),
// 底部按钮
_buildActionButtons(isDarkMode),
SizedBox(height: 20.h),
],
),
),
);
}
// 头部标题
Widget _buildHeader(bool isDarkMode) {
return Column(
children: [
Text(
'Voitrans',
style: TextStyle(
fontSize: 30.sp,
fontWeight: FontWeight.bold,
color: isDarkMode ? Colors.white : Colors.black,
),
),
SizedBox(height: 8.h),
Text(
'homeSlogan'.tr, // 连接世界,超越声音
style: TextStyle(
fontSize: 16.sp,
color: isDarkMode ? Colors.white60 : Colors.black54,
),
),
],
);
}
// 设备动画 - 根据状态显示不同的动画效果
Widget _buildDeviceAnimation(bool isDarkMode) {
return Obx(() {
final state = controller.currentState;
final isLoading = controller.isLoading.value;
// 根据状态决定动画颜色
Color primaryColor = const Color(0xFF0066CC);
Color secondaryColor = const Color(0xFF5E5CE6);
// 错误状态使用红色
if (state == PairingState.error) {
primaryColor = Colors.red;
secondaryColor = Colors.redAccent;
}
// 成功状态使用绿色
else if (state == PairingState.completed) {
primaryColor = Colors.green;
secondaryColor = Colors.greenAccent;
}
// 待机状态的特殊处理
if (state == PairingState.idle || state == PairingState.waitingClassic) {
return SizedBox(
width: 240.w,
height: 240.w,
child: Stack(
alignment: Alignment.center,
children: [
// 静态背景圆圈
Container(
width: 200.w,
height: 200.w,
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [
primaryColor.withValues(alpha: 0.1),
secondaryColor.withValues(alpha: 0.1),
],
),
shape: BoxShape.circle,
),
),
// 设备图标
Container(
width: 120.w,
height: 120.w,
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [primaryColor, secondaryColor],
),
shape: BoxShape.circle,
),
child: Icon(
Icons.headphones,
color: Colors.white,
size: 60.w,
),
),
],
),
);
}
return SizedBox(
width: 240.w,
height: 240.w,
child: Stack(
alignment: Alignment.center,
children: [
// 连续脉搏动画 - 只在加载时显示
if (isLoading) _buildPulseAnimation(primaryColor, secondaryColor),
// 特殊的安全连接动画 - 只在建立安全连接时显示
if (state == PairingState.waitingBle)
_buildSecureConnectionAnimation(primaryColor, secondaryColor),
// 设备关联动画 - 只在关联设备时显示
if (state == PairingState.associating)
_buildAssociatingAnimation(primaryColor, secondaryColor),
// 旋转边框 - 只在加载时显示
if (isLoading) _buildRotatingBorder(primaryColor),
// 设备图标
Container(
width: 120.w,
height: 120.w,
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [primaryColor, secondaryColor],
),
shape: BoxShape.circle,
),
child: Icon(
_getIconForState(state),
color: Colors.white,
size: 60.w,
),
),
],
),
);
});
}
// 构建连续脉搏动画
Widget _buildPulseAnimation(Color primaryColor, Color secondaryColor) {
return AnimatedBuilder(
animation: _pulseAnimation,
builder: (context, child) {
final value = _pulseAnimation.value;
final opacity =
0.4 - (_pulseController.value * 0.3); // 0.4 到 0.1 的透明度变化
return Container(
width: 200.w * value,
height: 200.w * value,
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [
primaryColor.withValues(alpha: opacity),
secondaryColor.withValues(alpha: opacity),
],
),
shape: BoxShape.circle,
),
);
},
);
}
// 构建旋转边框动画
Widget _buildRotatingBorder(Color primaryColor) {
return AnimatedBuilder(
animation: _rotationAnimation,
builder: (context, child) {
return Transform.rotate(
angle: _rotationAnimation.value * 2 * 3.14159, // 完整旋转
child: SizedBox(
width: 140.w,
height: 140.w,
child: CircularProgressIndicator(
strokeWidth: 3.w,
valueColor: AlwaysStoppedAnimation<Color>(primaryColor),
backgroundColor: Colors.grey.withValues(alpha: 0.2),
value: 0.7, // 显示70%的圆弧
),
),
);
},
);
}
// 构建安全连接特殊动画 - 多层波纹效果
Widget _buildSecureConnectionAnimation(
Color primaryColor, Color secondaryColor) {
return Stack(
alignment: Alignment.center,
children: [
// 第一层波纹
AnimatedBuilder(
animation: _pulseAnimation,
builder: (context, child) {
final value = _pulseAnimation.value;
final opacity = 0.3 - (_pulseController.value * 0.2);
return Container(
width: 180.w * value,
height: 180.w * value,
decoration: BoxDecoration(
border: Border.all(
color: primaryColor.withValues(alpha: opacity),
width: 2.w,
),
shape: BoxShape.circle,
),
);
},
),
// 第二层波纹 - 延迟动画
AnimatedBuilder(
animation: _pulseAnimation,
builder: (context, child) {
final delayedValue = (_pulseController.value + 0.3) % 1.0;
final value = 0.8 + (delayedValue * 0.4);
final opacity = 0.25 - (delayedValue * 0.15);
return Container(
width: 160.w * value,
height: 160.w * value,
decoration: BoxDecoration(
border: Border.all(
color: secondaryColor.withValues(alpha: opacity),
width: 1.5.w,
),
shape: BoxShape.circle,
),
);
},
),
// 第三层波纹 - 更大延迟
AnimatedBuilder(
animation: _pulseAnimation,
builder: (context, child) {
final delayedValue = (_pulseController.value + 0.6) % 1.0;
final value = 0.8 + (delayedValue * 0.4);
final opacity = 0.2 - (delayedValue * 0.1);
return Container(
width: 140.w * value,
height: 140.w * value,
decoration: BoxDecoration(
border: Border.all(
color: primaryColor.withValues(alpha: opacity),
width: 1.w,
),
shape: BoxShape.circle,
),
);
},
),
],
);
}
// 根据状态获取图标
IconData _getIconForState(PairingState state) {
switch (state) {
case PairingState.completed:
return Icons.check_circle;
case PairingState.associating:
return Icons.link;
case PairingState.error:
final errorType = controller.errorType;
switch (errorType) {
case ErrorType.bluetoothOff:
case ErrorType.noPermission:
return Icons.bluetooth_disabled;
case ErrorType.noClassicDevice:
case ErrorType.deviceNotMatch:
return Icons.headphones_outlined;
case ErrorType.associationFailed:
return Icons.link_off;
default:
return Icons.error_outline;
}
default:
return Icons.headphones;
}
}
// 状态信息
Widget _buildStatusInfo(bool isDarkMode) {
return Obx(() {
final status = controller.statusText.value;
final state = controller.currentState;
final device = controller.currentDevice;
return Column(
children: [
// 状态文本 - 更大更醒目
Text(
status,
style: TextStyle(
fontSize: 20.sp,
fontWeight: FontWeight.w600,
color: isDarkMode ? Colors.white : Colors.black,
),
textAlign: TextAlign.center,
),
SizedBox(height: 10.h),
// 子状态提示文本 - 根据状态显示不同提示
Text(
_getSubtitleForState(state),
style: TextStyle(
fontSize: 14.sp,
color: isDarkMode ? Colors.white60 : Colors.grey[600],
),
textAlign: TextAlign.center,
),
// 当前连接设备(有设备时才显示)
if (device != null &&
(state == PairingState.verifying ||
state == PairingState.binding ||
state == PairingState.waitingBle ||
state == PairingState.completed)) ...[
SizedBox(height: 24.h),
_buildConnectedDevice(device, isDarkMode),
],
],
);
});
}
// 根据状态获取副标题
String _getSubtitleForState(PairingState state) {
switch (state) {
case PairingState.idle:
return 'readyToPair'.tr; // 准备开始配对
case PairingState.checking:
return 'preparingPairingEnvironment'.tr; // 正在准备配对环境...
case PairingState.waitingClassic:
return 'connectDeepSoundViaSystemBluetooth'.tr; // 请先通过系统蓝牙连接Voitrans设备
case PairingState.verifying:
return 'verifyingDevice'.tr; // 正在验证设备...
case PairingState.binding:
return 'deviceBindingConfirm'.tr; // 设备绑定确认...
case PairingState.associating:
return 'establishingDeviceAssociation'.tr; // 正在建立设备关联...
case PairingState.waitingBle:
return 'waitingCompletePairing'.tr; // 请稍候,正在完成配对...
case PairingState.completed:
return 'pairingSuccessRedirecting'.tr; // 配对成功!即将跳转到主页...
case PairingState.error:
final errorType = controller.errorType;
switch (errorType) {
case ErrorType.bluetoothOff:
return 'connectDeepSoundViaSystemBluetooth'.tr; // 蓝牙已关闭,请打开蓝牙
case ErrorType.noPermission:
return 'lackPermissions'.tr; // 缺少蓝牙权限和位置权限
case ErrorType.noClassicDevice:
return 'connectDeepSoundViaSystemBluetooth'
.tr; // 请先通过系统蓝牙连接Voitrans设备
case ErrorType.deviceNotMatch:
return 'ensureConnectedDeepSoundDevice'.tr.trArgs(
[controller.currentDevice?.name ?? '']); // 请确保连接的是Voitrans设备
case ErrorType.deviceBound:
return 'deviceAlreadyBoundToOtherAccount'.tr; // 该设备已被其他账号绑定
case ErrorType.associationFailed:
return 'deviceAssociationFailed'.tr; // 设备关联失败,请重试
case ErrorType.bleTimeout:
return 'scanTimeoutEnsureDeviceNearby'.tr; // 扫描超时,请确保设备在附近
case ErrorType.networkError:
return 'networkError'.tr; // 网络错误
case ErrorType.authnFailed:
return 'authorizationFailedCheckCode'.tr; // 授权失败,请检查授权码是否正确
default:
return 'pairingFailedRetry'.tr; // 配对失败,请重试
}
default:
return '';
}
}
// 已连接设备信息
Widget _buildConnectedDevice(device, bool isDarkMode) {
return SizedBox(
width: double.infinity,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'currentlyConnected'.tr, // 当前已连接:
style: TextStyle(
fontSize: 16.sp,
fontWeight: FontWeight.w600,
color: isDarkMode ? Colors.white70 : Colors.black87,
),
),
SizedBox(height: 8.h),
_buildDeviceItem(device, isDarkMode),
],
),
);
}
// 设备项
Widget _buildDeviceItem(device, bool isDarkMode) {
return Container(
margin: EdgeInsets.only(bottom: 8.h),
padding: EdgeInsets.symmetric(horizontal: 16.w, vertical: 12.h),
decoration: BoxDecoration(
color: isDarkMode ? Colors.grey[900] : Colors.grey[100],
borderRadius: BorderRadius.circular(8.r),
),
child: Row(
children: [
Icon(
Icons.headphones,
color: isDarkMode ? Colors.white70 : Colors.black54,
size: 24.w,
),
SizedBox(width: 12.w),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
device.name ?? 'unknown'.tr,
style: TextStyle(
fontSize: 16.sp,
fontWeight: FontWeight.w500,
color: isDarkMode ? Colors.white : Colors.black,
),
),
SizedBox(height: 4.h),
Text(
device.address ?? '',
style: TextStyle(
fontSize: 12.sp,
color: isDarkMode ? Colors.white54 : Colors.black45,
),
),
],
),
),
Icon(
Icons.bluetooth_connected,
color: Colors.green,
size: 20.w,
),
],
),
);
}
// 获取连接按钮图标
IconData _getConnectButtonIcon() {
final buttonText = controller.connectButtonText.value;
if (buttonText.contains('bluetooth'.tr)) {
return Icons.bluetooth;
} else if (buttonText.contains('settings'.tr)) {
return Icons.settings;
}
return Icons.bluetooth_searching;
}
// 操作按钮
Widget _buildActionButtons(bool isDarkMode) {
return Obx(() {
final showConnectButton = controller.showConnectButton.value;
final showRetryButton = controller.showRetryButton.value;
final showBindButton = controller.showBindButton.value;
final showRetryChectButton = controller.showRetryChectButton.value;
final showSkipButton = controller.showSkipButton.value;
final showAutoCodeInput = controller.showAutoCodeInput.value;
final showSkipBindingButton = controller.showSkipBindingButton.value;
return SizedBox(
width: double.infinity,
child: Column(
children: [
// 显示连接按钮
if (showConnectButton)
ElevatedButton(
onPressed: () => controller.onConnectTap(),
style: ElevatedButton.styleFrom(
backgroundColor: const Color(0xFF0066CC),
foregroundColor: Colors.white,
padding: EdgeInsets.symmetric(vertical: 16.h),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16.r),
),
elevation: 0,
minimumSize: Size(double.infinity, 56.h),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(_getConnectButtonIcon(), size: 20.w),
SizedBox(width: 8.w),
Text(
controller.connectButtonText.value,
style: TextStyle(
fontSize: 16.sp,
fontWeight: FontWeight.w600,
),
),
],
),
),
if (showBindButton)
ElevatedButton(
onPressed: () => controller.confirmBindDevice(),
style: ElevatedButton.styleFrom(
backgroundColor: Colors.blue,
foregroundColor: Colors.white,
padding: EdgeInsets.symmetric(vertical: 16.h),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16.r),
),
elevation: 0,
minimumSize: Size(double.infinity, 56.h),
),
child: Text(
"bind".tr, // 绑定
style: TextStyle(
fontSize: 16.sp,
fontWeight: FontWeight.w600,
),
),
),
SizedBox(height: 12.h),
// 显示重试按钮
if (showRetryButton)
ElevatedButton(
onPressed: () => controller.onRetryTap(),
style: ElevatedButton.styleFrom(
backgroundColor: Colors.red,
foregroundColor: Colors.white,
padding: EdgeInsets.symmetric(vertical: 16.h),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16.r),
),
elevation: 0,
minimumSize: Size(double.infinity, 56.h),
),
child: Text(
controller.retryButtonText.value,
style: TextStyle(
fontSize: 16.sp,
fontWeight: FontWeight.w600,
),
),
),
if (showAutoCodeInput)
Column(
children: [
// 输入框
SizedBox(
width: double.infinity,
height: 48.h,
child: TextField(
controller: controller
.codeInputController, // 你可以在 Controller 中声明这个
decoration: InputDecoration(
hintText: 'enterCode'.tr, // 请输入代码
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12.r),
),
contentPadding: EdgeInsets.symmetric(horizontal: 16.w),
),
),
),
SizedBox(height: 12.h),
SizedBox(
width: double.infinity,
height: 48.h,
child: ElevatedButton(
onPressed: controller.onAutoCodeInputTap,
style: ElevatedButton.styleFrom(
backgroundColor: Theme.of(Get.context!).primaryColor,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12.r),
),
elevation: 0,
),
child: Text(
'authorizeBind'.tr, // 授权绑定
style: TextStyle(
fontSize: 16.sp,
fontWeight: FontWeight.w600,
color: Colors.white,
),
),
),
),
],
),
// 自动输入代码按钮
// 跳过按钮
if (showSkipButton) ...[
SizedBox(height: 12.h),
TextButton(
onPressed: () => controller.onSkipTap(),
style: TextButton.styleFrom(
foregroundColor: const Color(0xFF0066CC),
padding: EdgeInsets.symmetric(vertical: 12.h),
minimumSize: Size(double.infinity, 48.h),
),
child: Text(
'talkLater'.tr, // 稍后再说
style: TextStyle(
fontSize: 16.sp,
fontWeight: FontWeight.w500,
),
),
),
],
// 跳过绑定按钮
if (showSkipBindingButton) ...[
SizedBox(height: 12.h),
TextButton(
onPressed: () => controller.onSkipBindingTap(),
style: TextButton.styleFrom(
foregroundColor: const Color(0xFF0066CC),
padding: EdgeInsets.symmetric(vertical: 12.h),
minimumSize: Size(double.infinity, 48.h),
),
child: Text(
'skipBinding'.tr, // 跳过绑定
style: TextStyle(
fontSize: 16.sp,
fontWeight: FontWeight.w500,
),
),
),
],
//重新检测
if (showRetryChectButton) ...[
SizedBox(height: 12.h),
TextButton(
onPressed: () => controller.onRetryTap(),
style: TextButton.styleFrom(
foregroundColor: const Color(0xFF0066CC),
padding: EdgeInsets.symmetric(vertical: 12.h),
minimumSize: Size(double.infinity, 48.h),
),
child: Text(
'rescan'.tr, // 重新扫描
style: TextStyle(
fontSize: 16.sp,
fontWeight: FontWeight.w500,
),
),
),
],
],
),
);
});
}
// 构建设备关联动画 - 连接线条效果
Widget _buildAssociatingAnimation(Color primaryColor, Color secondaryColor) {
return AnimatedBuilder(
animation: _rotationAnimation,
builder: (context, child) {
return Stack(
alignment: Alignment.center,
children: [
// 旋转的连接线条
Transform.rotate(
angle: _rotationAnimation.value * 2 * 3.14159,
child: Container(
width: 160.w,
height: 160.w,
decoration: BoxDecoration(
shape: BoxShape.circle,
border: Border.all(
color: primaryColor.withValues(alpha: 0.6),
width: 2.w,
),
),
child: Stack(
children: [
// 连接点1
Positioned(
top: 10.w,
left: 80.w - 4.w,
child: Container(
width: 8.w,
height: 8.w,
decoration: BoxDecoration(
color: primaryColor,
shape: BoxShape.circle,
),
),
),
// 连接点2
Positioned(
bottom: 10.w,
left: 80.w - 4.w,
child: Container(
width: 8.w,
height: 8.w,
decoration: BoxDecoration(
color: secondaryColor,
shape: BoxShape.circle,
),
),
),
// 连接点3
Positioned(
top: 80.w - 4.w,
left: 10.w,
child: Container(
width: 8.w,
height: 8.w,
decoration: BoxDecoration(
color: primaryColor,
shape: BoxShape.circle,
),
),
),
// 连接点4
Positioned(
top: 80.w - 4.w,
right: 10.w,
child: Container(
width: 8.w,
height: 8.w,
decoration: BoxDecoration(
color: secondaryColor,
shape: BoxShape.circle,
),
),
),
],
),
),
),
],
);
},
);
}
}

304
lib/modules/pairing/views/pairing_view_new.dart

@ -1,304 +0,0 @@
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
import '../controllers/pairing_controller.dart';
/// 配对视图 - 简化版本
class PairingView extends GetView<PairingController> {
const PairingView({Key? key}) : super(key: key);
@override
Widget build(BuildContext context) {
final bool isDarkMode = Theme.of(context).brightness == Brightness.dark;
return Scaffold(
backgroundColor: isDarkMode ? Colors.black : Colors.white,
body: SafeArea(
child: Padding(
padding: EdgeInsets.all(24.w),
child: Column(
children: [
// 顶部标题
_buildHeader(isDarkMode),
// 中间内容
Expanded(
child: Center(
child: Obx(() => _buildContent(isDarkMode)),
),
),
// 底部按钮
Obx(() => _buildButtons(isDarkMode)),
SizedBox(height: 20.h),
],
),
),
),
);
}
/// 构建标题
Widget _buildHeader(bool isDarkMode) {
return Column(
children: [
Text(
'devicePairing'.tr, // 设备配对
style: TextStyle(
fontSize: 24.sp,
fontWeight: FontWeight.bold,
color: isDarkMode ? Colors.white : Colors.black,
),
),
SizedBox(height: 8.h),
Text(
'connectYourDeepSoundHeadset'.tr, // 连接您的 Voitrans 耳机
style: TextStyle(
fontSize: 14.sp,
color: isDarkMode ? Colors.grey[400] : Colors.grey[600],
),
),
SizedBox(height: 40.h),
],
);
}
/// 构建主要内容
Widget _buildContent(bool isDarkMode) {
final state = controller.currentState;
final isLoading = controller.isLoading.value;
return Column(
mainAxisSize: MainAxisSize.min,
children: [
// 状态图标
_buildStateIcon(state, isLoading, isDarkMode),
SizedBox(height: 40.h),
// 状态文本
Text(
controller.statusText.value,
style: TextStyle(
fontSize: 16.sp,
color: isDarkMode ? Colors.white : Colors.black,
),
textAlign: TextAlign.center,
),
// 错误详情
if (state == PairingState.error) ...[
SizedBox(height: 16.h),
Container(
padding: EdgeInsets.all(16.w),
decoration: BoxDecoration(
color: Colors.red.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(12.r),
border: Border.all(color: Colors.red.withValues(alpha: 0.3)),
),
child: Row(
children: [
Icon(
Icons.error_outline,
color: Colors.red,
size: 20.sp,
),
SizedBox(width: 12.w),
Expanded(
child: Text(
controller.errorMessage,
style: TextStyle(
fontSize: 14.sp,
color: Colors.red,
),
),
),
],
),
),
],
],
);
}
/// 构建状态图标
Widget _buildStateIcon(PairingState state, bool isLoading, bool isDarkMode) {
if (isLoading) {
return CircularProgressIndicator(
valueColor: AlwaysStoppedAnimation<Color>(
Theme.of(Get.context!).primaryColor,
),
);
}
IconData iconData;
Color iconColor;
switch (state) {
case PairingState.idle:
case PairingState.checking:
iconData = Icons.bluetooth_searching;
iconColor = Colors.blue;
break;
case PairingState.waitingClassic:
iconData = Icons.bluetooth;
iconColor = Colors.grey;
break;
case PairingState.verifying:
iconData = Icons.verified_user;
iconColor = Colors.orange;
break;
case PairingState.binding:
iconData = Icons.blind;
iconColor = Colors.blue;
break;
case PairingState.associating:
iconData = Icons.link;
iconColor = Colors.blue;
break;
case PairingState.waitingBle:
iconData = Icons.sync;
iconColor = Colors.blue;
break;
case PairingState.manualbinding:
iconData = Icons.blind;
iconColor = Colors.blue;
break;
case PairingState.completed:
iconData = Icons.check_circle;
iconColor = Colors.green;
break;
case PairingState.error:
iconData = Icons.error;
iconColor = Colors.red;
break;
}
return Container(
width: 100.w,
height: 100.w,
decoration: BoxDecoration(
color: iconColor.withValues(alpha: 0.1),
shape: BoxShape.circle,
),
child: Icon(
iconData,
size: 60.sp,
color: iconColor,
),
);
}
/// 构建按钮
Widget _buildButtons(bool isDarkMode) {
final showConnectButton = controller.showConnectButton.value;
final showBindButton = controller.showBindButton.value;
final showRetryButton = controller.showRetryButton.value;
final showAutoCodeInput = controller.showAutoCodeInput.value;
final showSkipButton = controller.showSkipButton.value;
return Column(
children: [
// 主要操作按钮
if (showConnectButton)
_buildPrimaryButton(
'connectDevice'.tr, // 连接设备
controller.onConnectTap,
isDarkMode,
),
// 绑定按钮
if (showBindButton)
TextButton(
onPressed: controller.confirmBindDevice,
child: Text(
'bind'.tr, // 绑定
style: TextStyle(
fontSize: 14.sp,
color: isDarkMode ? Colors.grey[400] : Colors.grey[600],
),
),
),
if (showRetryButton)
_buildPrimaryButton(
'retry'.tr, // 重试
controller.onRetryTap,
isDarkMode,
),
SizedBox(height: 12.h),
if (showAutoCodeInput)
// 自动输入代码按钮
SizedBox(
width: double.infinity,
height: 48.h,
child: ElevatedButton(
onPressed: controller.onAutoCodeInputTap,
style: ElevatedButton.styleFrom(
backgroundColor: Theme.of(Get.context!).primaryColor,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12.r),
),
elevation: 0,
),
child: Text(
'autoInputCode'.tr, // 自动输入代码
style: TextStyle(
fontSize: 16.sp,
fontWeight: FontWeight.w600,
color: Colors.white,
),
),
),
),
// 跳过按钮
if (showSkipButton)
TextButton(
onPressed: controller.onSkipTap,
child: Text(
'skip'.tr, // 跳过
style: TextStyle(
fontSize: 14.sp,
color: isDarkMode ? Colors.grey[400] : Colors.grey[600],
),
),
),
],
);
}
/// 构建主要按钮
Widget _buildPrimaryButton(
String text, VoidCallback onPressed, bool isDarkMode) {
return SizedBox(
width: double.infinity,
height: 48.h,
child: ElevatedButton(
onPressed: onPressed,
style: ElevatedButton.styleFrom(
backgroundColor: Theme.of(Get.context!).primaryColor,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12.r),
),
elevation: 0,
),
child: Text(
text,
style: TextStyle(
fontSize: 16.sp,
fontWeight: FontWeight.w600,
color: Colors.white,
),
),
),
);
}
}

100
lib/modules/settings/controllers/settings_controller.dart

@ -17,7 +17,6 @@ import '../models/app_supported_languages.dart';
import '../../../data/services/language_manager.dart';
import '../../../data/models/appconfig.dart';
import '../../../data/services/version_update_service.dart';
import '../../../data/services/ble_manager.dart';
import '../../common/widgets/version_update_dialog.dart';
class SettingsController extends GetxController {
@ -46,13 +45,6 @@ class SettingsController extends GetxController {
DateTime? _lastClickTime; // 上次点击时间
final RxBool showDebugInfo = false.obs; // 是否显示调试信息
// 语音唤醒相关
late final BleManager _bleManager;
final RxBool isVoiceWakeupEnabled = true.obs;
final RxBool isBleConnected = false.obs;
final RxBool isVoiceWakeupSupported = false.obs;
final RxBool isWakeupEnableByProduct = false.obs; // 产品配置是否支持唤醒开关
@override
void onInit() {
super.onInit();
@ -68,98 +60,6 @@ class SettingsController extends GetxController {
// 获取应用版本信息
getAppVersion();
// 语音唤醒相关初始化
_initVoiceWakeup();
}
/// 初始化语音唤醒功能
void _initVoiceWakeup() {
_bleManager = Get.find<BleManager>();
// 同步初始连接状态
isBleConnected.value = _bleManager.isConnected;
isVoiceWakeupSupported.value = _bleManager.voiceWakeupSupported;
isVoiceWakeupEnabled.value = _bleManager.voiceWakeupEnabled;
// 如果当前已连接,检查产品配置
if (_bleManager.isConnected) {
_checkProductWakeupEnable();
}
// 监听BLE连接状态变化
// 注意:ever 回调同步触发,此时 _isConnected 可能尚未更新,
// 因此直接用 state 参数判断连接状态
ever<int>(_bleManager.connectionStateRx, (state) {
final connected = state == BleConnectionState.connected;
isBleConnected.value = connected;
if (connected) {
// 连接后检查产品配置
_checkProductWakeupEnable();
// 连接后延迟查询唤醒状态
Future.delayed(const Duration(milliseconds: 500), () {
if (_bleManager.isConnected) {
_queryVoiceWakeupStatus();
}
});
} else {
isWakeupEnableByProduct.value = false;
}
});
// 监听语音唤醒状态变化
ever<bool>(_bleManager.voiceWakeupEnabledRx, (enabled) {
isVoiceWakeupEnabled.value = enabled;
});
// 监听语音唤醒支持状态变化
ever<bool>(_bleManager.voiceWakeupSupportedRx, (supported) {
isVoiceWakeupSupported.value = supported;
});
// 如果当前已连接,立即查询
if (_bleManager.isConnected) {
_queryVoiceWakeupStatus();
}
}
/// 查询语音唤醒状态
Future<void> _queryVoiceWakeupStatus() async {
try {
await _bleManager.queryVoiceWakeupStatus();
} catch (e) {
Logger().e('查询语音唤醒状态失败: $e');
}
}
/// 检查当前连接设备的产品配置是否支持唤醒开关
void _checkProductWakeupEnable() {
try {
final deviceName = _bleManager.getDeviceName();
if (deviceName.isEmpty || !AppConfig.isInitialized()) {
isWakeupEnableByProduct.value = false;
return;
}
final product = AppConfig.getproduct(deviceName);
isWakeupEnableByProduct.value = product?.wakeupenable ?? false;
} catch (e) {
Logger().e('检查产品唤醒配置失败: $e');
isWakeupEnableByProduct.value = false;
}
}
/// 切换语音唤醒状态
Future<void> toggleVoiceWakeup(bool enabled) async {
try {
final success = await _bleManager.setVoiceWakeupStatus(enabled);
if (!success) {
// 命令发送失败,BleManager 已将 supported 设为 false
Logger().w('语音唤醒设置命令发送失败');
}
} catch (e) {
Logger().e('设置语音唤醒状态失败: $e');
_bleManager.voiceWakeupSupportedRx.value = false;
}
}
// 获取应用版本信息的方法

42
lib/modules/settings/views/settings_view.dart

@ -437,48 +437,6 @@ class SettingsView extends GetView<SettingsController> {
? const Color(0xFF2C2C2C)
: Colors.grey[200]),
// 语音唤醒开关 - 仅在连接设备且产品配置支持时显示
Obx(
() {
final connected = controller.isBleConnected.value;
final wakeupEnable =
controller.isWakeupEnableByProduct.value;
// 未连接设备或产品不支持唤醒开关时隐藏
if (!connected || !wakeupEnable) {
return const SizedBox.shrink();
}
final supported = controller.isVoiceWakeupSupported.value;
return Column(
children: [
_buildToggleSetting(
title: 'voiceWakeup'.tr,
subtitle: !supported
? 'deviceNotSupportFeature'.tr
: 'voiceWakeupDesc'.tr,
icon: Icons.mic,
iconBgColor: isDarkMode
? Colors.teal[900]!.withValues(alpha: 0.3)
: Colors.teal[100]!,
iconColor: isDarkMode
? Colors.teal[300]!
: Colors.teal[600]!,
value: controller.isVoiceWakeupEnabled.value,
onChanged: supported
? (enabled) =>
controller.toggleVoiceWakeup(enabled)
: null,
isDarkMode: isDarkMode,
),
Divider(
height: 1,
color: isDarkMode
? const Color(0xFF2C2C2C)
: Colors.grey[200]),
],
);
},
),
// 用户反馈
_buildSimpleNavigationSetting(
title: 'feedback'.tr, // 用户反馈

10
lib/modules/splash/controllers/splash_controller.dart

@ -12,6 +12,7 @@ import '../../../data/models/appconfig_model.dart';
import '../../../data/models/user_Info.dart';
import '../../../routes/app_routes.dart';
import '../../../data/services/network/api.dart';
import '../../../data/services/ble_manager.dart';
import '../../../data/services/version_update_service.dart';
import '../../../modules/common/widgets/version_update_dialog.dart';
import '../../../modules/common/widgets/privacy_policy_dialog.dart';
@ -262,13 +263,18 @@ class SplashController extends GetxController {
}
/// 根据最终状态跳转到目标页面
void _navigateToTargetPage() {
Future<void> _navigateToTargetPage() async {
// 导航时直接检查最终的数据状态,而不是依赖中间变量
final token = _storage.read('logintoken');
final isAppConfigInitialized = AppConfig.isInitialized();
// 检查登录状态和配置初始化状态
if (token != null && token.isNotEmpty && isAppConfigInitialized) {
Get.offAllNamed(Routes.pairing);
// 登录成功 → 检查 BLE 设备是否已连接,未连接则进入扫描页
bool connected = false;
try {
connected = await BleManager.to.hasActiveConnection();
} catch (_) {}
Get.offAllNamed(connected ? Routes.home : Routes.devices);
} else {
_storage.remove('meeting_upload_list');
_storage.remove('operation_records');

47
lib/modules/translation/controllers/translation_controller.dart

@ -155,6 +155,12 @@ class TranslationController extends GetxController with WidgetsBindingObserver {
StreamSubscription? _astEventSubscription;
StreamSubscription? _voiceInteractionSubscription;
StreamSubscription<bool>? _deviceCallTranslationSubscription;
// 通话翻译 ↔ 杰理桥接:
// inUplink/inDownlink → AstService.pushExternalAudio
// AstService.astTtsFrames → BleManager.feedAstTtsPcm
StreamSubscription? _jieliCallInUplinkSub;
StreamSubscription? _jieliCallInDownlinkSub;
StreamSubscription? _jieliCallAstTtsSub;
Timer? _translationDebounceTimer;
// ==================== 重新初始化守卫 ====================
@ -963,6 +969,7 @@ class TranslationController extends GetxController with WidgetsBindingObserver {
_voiceInteractionSubscription?.cancel();
_deviceCallTranslationSubscription?.cancel();
_deviceCallTranslationSubscription = null;
_detachJieliCallTranslationBridge();
_translationDebounceTimer?.cancel();
restoreOtherServices();
// 移除对服务的 dispose,避免频繁销毁/重建造成卡顿
@ -1226,9 +1233,48 @@ class TranslationController extends GetxController with WidgetsBindingObserver {
await _astService.setBroadcastPeerTranslate(broadcastPeer);
Logger.info('[CALL] broadcastPeerTranslate=$broadcastPeer');
// 把杰理 SCO 双向 PCM ↔ AST 流水线接上:
// in.uplink → AST.A(己方→对端语种)
// in.downlink → AST.B(对端→己方语种)
// AST.A TTS → out.uplink(对端听)
// AST.B TTS → out.downlink(己方听)
_attachJieliCallTranslationBridge();
Logger.info('发送ble系统mic和dac(音乐或者通话远端)声音');
}
void _attachJieliCallTranslationBridge() {
_detachJieliCallTranslationBridge();
_jieliCallInUplinkSub = bleManager.inUplinkPcmStream.listen(
(pcm) {
_astService.pushExternalAudio(leg: 'A', pcm: pcm);
},
onError: (e) => Logger.error('[CALL-BRIDGE] in.uplink stream error: $e'),
);
_jieliCallInDownlinkSub = bleManager.inDownlinkPcmStream.listen(
(pcm) {
_astService.pushExternalAudio(leg: 'B', pcm: pcm);
},
onError: (e) => Logger.error('[CALL-BRIDGE] in.downlink stream error: $e'),
);
_jieliCallAstTtsSub = _astService.astTtsFrames.listen(
(frame) {
bleManager.feedAstTtsPcm(leg: frame.leg, pcm: frame.pcm);
},
onError: (e) => Logger.error('[CALL-BRIDGE] ast tts stream error: $e'),
);
Logger.info('[CALL-BRIDGE] 已连通 杰理 SCO ↔ AST');
}
void _detachJieliCallTranslationBridge() {
_jieliCallInUplinkSub?.cancel();
_jieliCallInUplinkSub = null;
_jieliCallInDownlinkSub?.cancel();
_jieliCallInDownlinkSub = null;
_jieliCallAstTtsSub?.cancel();
_jieliCallAstTtsSub = null;
}
/// 配置默认模式
Future<void> _configureDefaultMode() async {
_audioSourceType = false;
@ -1951,6 +1997,7 @@ class TranslationController extends GetxController with WidgetsBindingObserver {
try {
isPreparing.value = false;
if (currentMode.value == "audioVideo" || currentMode.value == "call") {
_detachJieliCallTranslationBridge();
bleManager.closeCodec();
Logger.info('关闭ble编解码器');
}

10
lib/modules/wakeupvoice/bindings/wakeup_voice_binding.dart

@ -1,10 +0,0 @@
import 'package:get/get.dart';
import '../controllers/wakeup_voice_controller.dart';
class WakeupVoiceBinding extends Bindings {
@override
void dependencies() {
Get.lazyPut<WakeupVoiceController>(() => WakeupVoiceController());
}
}

543
lib/modules/wakeupvoice/controllers/wakeup_voice_controller.dart

@ -1,543 +0,0 @@
import 'package:get/get.dart';
import 'package:dio/dio.dart';
import '../../../core/utils/logger.dart';
import '../../../data/services/ble_manager.dart';
import '../../../data/services/network/api.dart';
class WakeupVoiceController extends GetxController {
// 是否启用唤醒词(原有本地词功能,可保留)
final RxBool enabled = true.obs;
final BleManager bleManager = Get.find<BleManager>();
// 本地自定义唤醒词(原有)
final RxList<String> wakeWords = <String>['你好,深声', 'Hey Voitrans'].obs;
final RxString inputText = ''.obs;
// 服务端唤醒语音包列表
final RxList<WakeupVoiceItem> voices = <WakeupVoiceItem>[].obs;
// 当前设备的唤醒语音 id 与版本(由外部设备信息设置)
final RxnInt currentDeviceVoiceId = RxnInt(53251);
final RxnString currentDeviceVoiceVersion = RxnString('1.0.0');
// 当前页面选择的语音包 id
final RxnInt selectedVoiceId = RxnInt();
// 加载状态
final RxBool isLoading = false.obs;
final RxString loadError = ''.obs;
// 设备状态(用于页面头部展示)
final RxString deviceName = ''.obs;
final RxBool isConnected = false.obs;
final RxBool wakeupVersionMismatch = false.obs;
final RxnInt deviceWakeIdCode = RxnInt();
final RxnInt deviceWakeVerCode = RxnInt();
// 预览/测试模式:跳过 onReady 自动拉取
bool skipAutoFetch = false;
@override
void onReady() {
super.onReady();
_bindBleObservers();
// 允许通过路由参数传入当前设备唤醒语音信息
final args = Get.arguments;
if (args is Map) {
final id = args['wakeupVoiceId'];
final ver = args['wakeupVoiceVersion'];
if (id is int && ver is String) {
setCurrentDeviceWakeVoice(id: id, version: ver);
}
final name = args['deviceName'];
final connected = args['connected'];
if (name is String) deviceName.value = name;
if (connected is bool) isConnected.value = connected;
}
if (!skipAutoFetch) {
fetchVoices();
}
}
/// 绑定 BLE 相关的观察者
/// 参数:无
/// 返回:无
/// 异常:发生监听绑定异常时捕获并记录日志,不抛出
void _bindBleObservers() {
// 同步连接状态与设备名
try {
isConnected.value = bleManager.isConnected;
final info = bleManager.deviceInfo.value;
if (info != null && info.name.isNotEmpty) {
deviceName.value = info.name;
_syncDeviceWakeVersions(info);
}
// 监听连接状态变化
ever<int>(bleManager.connectionStateRx, (state) {
isConnected.value = bleManager.isConnected;
final info = bleManager.deviceInfo.value;
if (info != null && info.name.isNotEmpty) {
deviceName.value = info.name;
_syncDeviceWakeVersions(info);
}
});
// 监听设备信息更新
ever<DeviceInfo?>(bleManager.deviceInfo, (info) {
if (info != null && info.name.isNotEmpty) {
deviceName.value = info.name;
_syncDeviceWakeVersions(info);
}
});
// 监听升级进度
ever<int>(bleManager.wakeupVoiceUpgradeProgress, (p) {
upgradeProgress.value = p;
if (p >= 100 && upgradingVoiceId.value != null) {
final item = _findById(upgradingVoiceId.value);
if (item != null) {
currentDeviceVoiceId.value = item.id;
currentDeviceVoiceVersion.value = item.version;
selectedVoiceId.value = item.id;
}
upgradingVoiceId.value = null;
}
});
// 监听升级状态:仅当状态为“成功(2)”或“失败(3)”时弹出提示弹窗;升级中(1)或其他状态不提示
ever<int>(bleManager.wakeupVoiceUpgradeStatus, (s) {
// 升级状态字段未定义,暂存到日志
Logger.info('BLE 升级状态码: $s');
if (s == 2) {
// 升级成功
Get.snackbar('成功', '唤醒语音升级成功');
} else if (s == 3) {
// 升级失败
Get.snackbar('错误', '唤醒语音升级失败');
}
});
} catch (e) {
Logger.warning('绑定BLE观察者失败: $e');
}
}
// 供外部设置当前设备语音包信息
/// 设置当前设备的唤醒语音包信息
/// 参数:`id` 唤醒词ID;`version` 版本字符串(如 `1.2.3.4`)
/// 返回:无
/// 异常:无
void setCurrentDeviceWakeVoice({required int id, required String version}) {
currentDeviceVoiceId.value = id;
currentDeviceVoiceVersion.value = version;
// 若已加载列表,默认选中当前设备项
if (voices.isNotEmpty) {
selectedVoiceId.value = id;
}
}
/// 拉取服务端唤醒语音包列表
/// 参数:无
/// 返回:`Future<void>`,完成后 `voices`、`selectedVoiceId` 等被更新
/// 异常:网络或数据解析异常会写入 `loadError` 并弹出提示,不抛出
Future<void> fetchVoices() async {
try {
isLoading.value = true;
loadError.value = '';
final res = await Api.getwakeupvoices();
Logger.info('获取唤醒语音列表成功 res: $res');
// 仅按约定结构解析:{ voices: [...] }
if (res is! Map || res['voices'] is! List) {
loadError.value = '接口返回异常:缺少 voices 列表';
voices.clear();
return;
}
final list = List<Map<String, dynamic>>.from(res['voices'] as List);
voices.assignAll(list
.map((e) => WakeupVoiceItem.fromJson(e as Map<String, dynamic>))
.toList());
if (voices.isNotEmpty) {
selectedVoiceId.value = voices.first.id;
}
final devIdCode = deviceWakeIdCode.value;
if (devIdCode != null) {
final sameIdItems = voices.where((v) {
final p = _parseVersion(v.version);
return p != null &&
p.length >= 2 &&
((p[0] << 8) | p[1]) == devIdCode;
}).toList();
if (sameIdItems.isNotEmpty) {
selectedVoiceId.value = sameIdItems.first.id;
currentDeviceVoiceId.value = devIdCode;
}
}
if (wakeupVersionMismatch.value) {
loadError.value = '设备异常:左右唤醒词版本不一致';
}
} catch (e) {
loadError.value = '加载唤醒语音失败: $e';
Get.snackbar('错误', loadError.value);
} finally {
isLoading.value = false;
}
}
/// 解析版本字符串为四段整型数组
/// 参数:`v` 版本字符串,期望形如 `A.B.C.D`(可包含非数字分隔符)
/// 返回:`List<int>?` 长度为4的数组,若解析失败返回 `null`
/// 异常:无
List<int>? _parseVersion(String v) {
final parts =
v.split(RegExp(r'[^0-9]+')).where((e) => e.isNotEmpty).toList();
if (parts.length < 4) return null;
final nums = parts.take(4).map((e) => int.tryParse(e) ?? 0).toList();
return nums;
}
/// 同步设备左右耳唤醒词版本信息,并计算设备唤醒词ID与版本编码
/// 参数:`info` 设备信息对象,需包含 `wakeupLeftVersion` 与 `wakeupRightVersion`
/// 返回:无
/// 异常:无;若版本缺失或解析失败则静默返回
void _syncDeviceWakeVersions(DeviceInfo info) {
final left = info.wakeupLeftVersion;
final right = info.wakeupRightVersion;
wakeupVersionMismatch.value =
left.isNotEmpty && right.isNotEmpty && left != right;
final base = left.isNotEmpty ? left : right;
if (base.isEmpty) return;
final p = _parseVersion(base);
if (p == null || p.length < 4) return;
deviceWakeIdCode.value = (p[0] << 8) | p[1];
deviceWakeVerCode.value = (p[2] << 8) | p[3];
currentDeviceVoiceId.value = deviceWakeIdCode.value;
currentDeviceVoiceVersion.value = '${p[2]}.${p[3]}';
}
/// 判断候选项与设备的唤醒词ID是否一致
/// 参数:`item` 唤醒词项,若为空返回 `false`
/// 返回:`bool`,一致返回 `true`
/// 异常:无
bool _isSameWakeId(WakeupVoiceItem? item) {
if (item == null) return false;
final p = _parseVersion(item.version);
if (p == null || p.length < 2) return false;
final idCode = (p[0] << 8) | p[1];
return deviceWakeIdCode.value != null && deviceWakeIdCode.value == idCode;
}
/// 判断候选项与设备的唤醒词版本是否一致
/// 参数:`item` 唤醒词项,若为空返回 `false`
/// 返回:`bool`,一致返回 `true`
/// 异常:无
bool _isSameWakeVersion(WakeupVoiceItem? item) {
if (item == null) return false;
final p = _parseVersion(item.version);
if (p == null || p.length < 4) return false;
final verCode = (p[2] << 8) | p[3];
return deviceWakeVerCode.value != null &&
deviceWakeVerCode.value == verCode;
}
// 选择语音包
/// 选择语音包
/// 参数:`id` 待选择的语音包ID
/// 返回:无
/// 异常:无
void selectVoice(int id) {
selectedVoiceId.value = id;
}
// 动作按钮文案与状态
/// 获取当前选择项对应的操作文案(基于设备与候选项版本关系)
/// 参数:无(使用 `selectedVoiceId`)
/// 返回:`String`,可能为 `设备异常`、`升级`、`切换`、`已是最新`、`处理`
/// 异常:无
String get actionText {
final sel = selectedVoiceId.value;
final curId = currentDeviceVoiceId.value;
final curVer = currentDeviceVoiceVersion.value;
final selected = _findById(sel);
if (sel == null || selected == null) return '处理';
if (wakeupVersionMismatch.value) return '设备异常';
if (_isSameWakeId(selected)) {
return _isSameWakeVersion(selected) ? '已是最新' : '升级';
}
return '切换';
}
/// 获取当前选择项是否允许触发操作
/// 参数:无(使用 `selectedVoiceId`)
/// 返回:`bool`,当 `设备异常` 或 `已是最新` 时为 `false`
/// 异常:无
bool get actionEnabled {
final sel = selectedVoiceId.value;
final curId = currentDeviceVoiceId.value;
final curVer = currentDeviceVoiceVersion.value;
final selected = _findById(sel);
if (sel == null || selected == null) return false;
if (!isConnected.value) return false;
if (wakeupVersionMismatch.value) return false;
if (_isSameWakeId(selected)) {
return !_isSameWakeVersion(selected);
}
return true;
}
/// 计算指定语音项的操作文案(不影响当前选择项)
/// 参数:`item` 候选语音项
/// 返回:`String`,可能为 `设备异常`、`升级`、`切换`、`已是最新`
/// 异常:无
String actionTextForItem(WakeupVoiceItem item) {
if (wakeupVersionMismatch.value) return '设备异常';
if (_isSameWakeId(item)) {
return _isSameWakeVersion(item) ? '已是最新' : '升级';
}
return '切换';
}
/// 计算指定语音项是否允许操作(不影响当前选择项)
/// 参数:`item` 候选语音项
/// 返回:`bool`,当 `设备异常` 或 `已是最新` 时为 `false`
/// 异常:无
bool actionEnabledForItem(WakeupVoiceItem item) {
if (!isConnected.value) return false;
if (wakeupVersionMismatch.value) return false;
if (_isSameWakeId(item)) {
return !_isSameWakeVersion(item);
}
return true;
}
/// 根据当前选择项触发操作
/// 参数:无
/// 返回:`Future<void>`
/// 异常:无;当不允许操作时直接返回
Future<void> onActionPressed() async {
final label = actionText;
if (!actionEnabled) return;
switch (label) {
case '升级':
await upgradeWakeVoice();
break;
case '切换':
await switchWakeVoice();
break;
default:
break;
}
}
/// 将当前设备升级到已选择项
/// 参数:无(使用 `selectedVoiceId`)
/// 返回:`Future<void>`
/// 异常:下载或升级失败会通过提示与日志反馈,不抛出
Future<void> upgradeWakeVoice() async {
final sel = _findById(selectedVoiceId.value);
if (sel == null) return;
await _startUpgradeByItem(sel);
}
/// 将当前设备切换到已选择项
/// 参数:无(使用 `selectedVoiceId`)
/// 返回:`Future<void>`
/// 异常:下载或升级失败会通过提示与日志反馈,不抛出
Future<void> switchWakeVoice() async {
final sel = _findById(selectedVoiceId.value);
if (sel == null) return;
await _startUpgradeByItem(sel);
}
// 直接切换到指定语音包(页面卡片按钮使用)
/// 直接切换到指定语音项
/// 参数:`item` 目标语音项
/// 返回:`Future<void>`
/// 异常:下载或升级失败会通过提示与日志反馈,不抛出
Future<void> switchWakeVoiceTo(WakeupVoiceItem item) async {
selectedVoiceId.value = item.id;
await _startUpgradeByItem(item);
}
// 直接升级到指定语音包(页面卡片按钮使用)
/// 直接升级到指定语音项
/// 参数:`item` 目标语音项
/// 返回:`Future<void>`
/// 异常:下载或升级失败会通过提示与日志反馈,不抛出
Future<void> upgradeWakeVoiceTo(WakeupVoiceItem item) async {
selectedVoiceId.value = item.id;
await _startUpgradeByItem(item);
}
// 升级相关状态
final RxnInt upgradingVoiceId = RxnInt();
final RxInt upgradeProgress = 0.obs;
/// 按指定语音项发起升级流程(下载并写入设备)
/// 参数:`item` 目标语音项
/// 返回:`Future<void>`
/// 异常:下载或BLE交互失败会通过提示与日志反馈,不抛出
Future<void> _startUpgradeByItem(WakeupVoiceItem item) async {
try {
if (item.packagaddress.isEmpty) {
Get.snackbar('升级失败', '语音包地址为空');
return;
}
upgradingVoiceId.value = item.id;
upgradeProgress.value = 0;
final url = item.packagaddress;
Logger.info('开始下载唤醒语音包: $url');
final bytes = await _downloadVoiceBytes(url);
if (bytes.isEmpty) {
Get.snackbar('升级失败', '语音包下载为空');
upgradingVoiceId.value = null;
return;
}
Logger.info('下载完成,字节数: ${bytes.length},开始升级');
// 注意:MethodChannel 在 Android 端对 Uint8List 会编码为 byte[],
// 而当前原生插件期望的是 List。为避免 ClassCastException,传递 List<int>。
final ok = await bleManager.startWakeupVoicesUpgrade(bytes.toList());
if (!ok) {
Get.snackbar('升级失败', 'BLE升级启动失败');
upgradingVoiceId.value = null;
return;
}
Get.snackbar('升级已启动', '正在写入设备,请稍候');
} catch (e) {
upgradingVoiceId.value = null;
Logger.info('升级失败: $e');
Get.snackbar('升级失败', e.toString());
}
}
/// 下载语音包二进制数据
/// 参数:`url` 语音包地址
/// 返回:`Future<List<int>>` 字节列表;失败返回空列表
/// 异常:网络异常会记录日志并返回空列表
Future<List<int>> _downloadVoiceBytes(String url) async {
try {
final dio = Dio();
final response = await dio.get<List<int>>(
url,
options: Options(
responseType: ResponseType.bytes,
receiveTimeout: const Duration(seconds: 60),
),
);
return response.data ?? [];
} catch (e) {
Logger.error('下载语音包失败: $url', e);
return [];
}
}
/// 根据ID查找语音项
/// 参数:`id` 语音包ID
/// 返回:`WakeupVoiceItem?` 找到返回对象,否则 `null`
/// 异常:无
WakeupVoiceItem? _findById(int? id) {
if (id == null) return null;
for (final v in voices) {
if (v.id == id) return v;
}
return null;
}
// 以下保留原有本地词功能(开关、增删)
void toggleEnabled(bool value) {
enabled.value = value;
}
void setInput(String value) {
inputText.value = value;
}
String? validateWakeWord(String text) {
final t = text.trim();
if (t.isEmpty) return '唤醒词不能为空';
if (t.length > 20) return '唤醒词过长(≤20个字符)';
if (wakeWords.contains(t)) return '重复的唤醒词';
return null;
}
bool addWakeWord(String text) {
final err = validateWakeWord(text);
if (err != null) {
Get.snackbar('提示', err);
return false;
}
if (wakeWords.length >= 5) {
Get.snackbar('提示', '最多添加5个唤醒词');
return false;
}
wakeWords.add(text.trim());
inputText.value = '';
return true;
}
void removeWakeWord(String text) {
wakeWords.remove(text);
}
Future<void> saveChanges() async {
Get.snackbar('保存成功', '唤醒词设置已更新');
}
}
class WakeupVoiceItem {
final int id;
final bool enable;
final String name;
final String desc;
final String version;
final String packagaddress;
WakeupVoiceItem({
required this.id,
required this.enable,
required this.name,
required this.desc,
required this.version,
required this.packagaddress,
});
factory WakeupVoiceItem.fromJson(Map<String, dynamic> json) {
return WakeupVoiceItem(
id: (json['id'] ?? json['Id'] ?? 0) is String
? int.tryParse(json['id'] ?? json['Id']) ?? 0
: (json['id'] ?? json['Id'] ?? 0) as int,
enable: (json['enable'] ?? json['Enable'] ?? false) == true ||
(json['enable'] ?? 'false').toString() == 'true',
name: (json['name'] ?? '').toString(),
desc: (json['desc'] ?? '').toString(),
version: (json['version'] ?? '').toString(),
packagaddress: (json['packagaddress'] ?? '').toString(),
);
}
// 从描述字段提取识别率与更新说明(供UI展示)
}
extension WakeupVoiceItemExt on WakeupVoiceController {
String? extractAccuracy(WakeupVoiceItem item) {
final d = item.desc;
final reg = RegExp(r'(识别率|准确率)\s*([0-9]{1,3}(\.[0-9])?)%');
final m = reg.firstMatch(d);
if (m != null) {
return '识别率 ${m.group(2)}%';
}
return null;
}
List<String> extractUpdateNotes(WakeupVoiceItem item) {
final d = item.desc.trim();
if (d.isEmpty) {
return const ['提升识别准确率至99.2%', '优化唤醒提示音的响应', '修复复杂性延迟问题'];
}
// 按常见分隔符切分
final parts = d
.split(RegExp(r'[;;\n\r]'))
.map((e) => e.trim())
.where((e) => e.isNotEmpty)
.toList();
return parts;
}
}

457
lib/modules/wakeupvoice/views/wakeup_voice_view.dart

@ -1,457 +0,0 @@
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import '../controllers/wakeup_voice_controller.dart';
class WakeupVoiceView extends GetView<WakeupVoiceController> {
const WakeupVoiceView({Key? key}) : super(key: key);
@override
Widget build(BuildContext context) {
final isDark = Theme.of(context).brightness == Brightness.dark;
return Scaffold(
backgroundColor: isDark ? Colors.black : Colors.grey[50],
body: Obx(() {
// 顶部渐变头部 + 内容
return CustomScrollView(
slivers: [
SliverAppBar(
pinned: true,
elevation: 0,
centerTitle: true,
backgroundColor: Colors.transparent,
flexibleSpace: _buildHeader(isDark),
expandedHeight: 180,
leading: IconButton(
icon: const Icon(Icons.arrow_back_ios, color: Colors.white),
onPressed: () => Get.back(),
),
title: const Text('唤醒词管理',
style: TextStyle(color: Colors.white, fontSize: 18)),
actions: const [
Padding(
padding: EdgeInsets.only(right: 12),
child: Icon(Icons.more_vert, color: Colors.white),
)
],
),
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_buildCurrentWakeWordCard(isDark),
const SizedBox(height: 16),
_buildAvailableWakeWords(isDark),
],
),
),
),
],
);
}),
);
}
// 顶部渐变头部
/// 构建顶部设备状态头部
/// 参数:`isDark` 是否深色模式
/// 返回:`Widget` 构建好的头部组件
/// 异常:无
Widget _buildHeader(bool isDark) {
final connected = controller.isConnected.value;
final deviceName = controller.deviceName.value.isEmpty
? '未连接设备'
: controller.deviceName.value;
final mismatch = controller.wakeupVersionMismatch.value;
return Container(
decoration: const BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [Color(0xFF6A5AE0), Color(0xFF8A65E6)],
),
),
child: SafeArea(
bottom: false,
child: Padding(
padding: const EdgeInsets.fromLTRB(16, 56, 16, 16),
child: Row(
children: [
Container(
width: 56,
height: 56,
decoration: BoxDecoration(
color: Colors.white.withOpacity(0.15),
borderRadius: BorderRadius.circular(12),
),
child: const Icon(Icons.headset, color: Colors.white, size: 28),
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Text(deviceName,
style: const TextStyle(
color: Colors.white,
fontSize: 18,
fontWeight: FontWeight.w600)),
const SizedBox(height: 6),
Row(
children: [
Container(
width: 8,
height: 8,
decoration: BoxDecoration(
color: connected ? Colors.greenAccent : Colors.red,
shape: BoxShape.circle,
),
),
const SizedBox(width: 6),
Text(connected ? '已连接' : '未连接',
style: const TextStyle(
color: Colors.white70, fontSize: 12)),
if (mismatch) ...[
const SizedBox(width: 12),
Container(
padding: const EdgeInsets.symmetric(
horizontal: 8, vertical: 2),
decoration: BoxDecoration(
color: Colors.red.withOpacity(0.2),
borderRadius: BorderRadius.circular(6),
border: Border.all(color: Colors.redAccent),
),
child: const Text('设备异常',
style: TextStyle(
color: Colors.white,
fontSize: 11,
fontWeight: FontWeight.w500)),
),
]
],
)
],
),
),
],
),
),
),
);
}
// 当前唤醒词卡片
/// 构建当前设备唤醒词卡片
/// 参数:`isDark` 是否深色模式
/// 返回:`Widget` 构建好的卡片
/// 异常:无
Widget _buildCurrentWakeWordCard(bool isDark) {
final curId = controller.currentDeviceVoiceId.value;
final curVer = controller.currentDeviceVoiceVersion.value;
WakeupVoiceItem? curItem;
for (final v in controller.voices) {
if (v.id == curId) {
curItem = v;
break;
}
}
final title = '当前唤醒词';
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(title,
style:
const TextStyle(fontSize: 16, fontWeight: FontWeight.w600)),
Text(curVer != null ? 'v$curVer' : '',
style: const TextStyle(color: Colors.grey, fontSize: 12)),
],
),
const SizedBox(height: 8),
if (curItem != null)
_buildVoiceCard(
isDark: isDark,
item: curItem,
isCurrent: true,
hasUpdate: curItem != null &&
controller.actionTextForItem(curItem) == '升级',
),
// 版本一致的唤醒词放在“当前唤醒词”下面
...controller.voices
.where((v) => controller.actionTextForItem(v) == '已是最新')
.where((v) => !identical(v, curItem))
.map((v) => _buildVoiceCard(
isDark: isDark,
item: v,
isCurrent: true,
hasUpdate: false,
)),
],
);
}
// 可用唤醒词列表
/// 构建可用唤醒词列表
/// 参数:`isDark` 是否深色模式
/// 返回:`Widget` 构建好的列表
/// 异常:无
Widget _buildAvailableWakeWords(bool isDark) {
final curId = controller.currentDeviceVoiceId.value;
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text('可用唤醒词',
style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600)),
const SizedBox(height: 12),
if (controller.loadError.value.isNotEmpty)
Text(controller.loadError.value,
style: const TextStyle(color: Colors.red)),
// 仅展示非“已是最新”的可用唤醒词(排除当前使用版本)
...controller.voices
.where((v) => controller.actionTextForItem(v) != '已是最新')
.map((v) => _buildVoiceCard(
isDark: isDark,
item: v,
isCurrent: false,
hasUpdate: controller.actionTextForItem(v) == '升级',
)),
],
);
}
/// 比较版本是否更高(三段比较)
/// 参数:`a` 候选版本;`b` 当前版本(可空)
/// 返回:`bool` 若 `a` 高于 `b` 返回 `true`
/// 异常:无
bool _versionHigherThan(String a, String? b) {
if (b == null) return true;
// 简易版本比较:按点位分割逐段比较
List<int> pa = a.split('.').map((e) => int.tryParse(e) ?? 0).toList();
List<int> pb = b.split('.').map((e) => int.tryParse(e) ?? 0).toList();
for (int i = 0; i < 3; i++) {
final va = i < pa.length ? pa[i] : 0;
final vb = i < pb.length ? pb[i] : 0;
if (va != vb) return va > vb;
}
return false;
}
/// 判断当前卡片是否存在可升级版本
/// 参数:`curItem` 当前项(可空)
/// 返回:`bool` 存在升级返回 `true`
/// 异常:无
bool _hasUpdateForCurrent(WakeupVoiceItem? curItem) {
if (curItem == null) return false;
final curVer = controller.currentDeviceVoiceVersion.value;
return _versionHigherThan(curItem.version, curVer);
}
/// 构建通用语音卡片
/// 参数:`isDark` 是否深色模式;`item` 语音项;`isCurrent` 是否当前项;`hasUpdate` 是否升级卡片
/// 返回:`Widget` 卡片组件
/// 异常:无
Widget _buildVoiceCard({
required bool isDark,
required WakeupVoiceItem? item,
required bool isCurrent,
bool hasUpdate = false,
}) {
if (item == null) {
if (isCurrent) {
return Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: isDark ? Colors.grey[900] : Colors.white,
borderRadius: BorderRadius.circular(12),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.05),
blurRadius: 10,
offset: const Offset(0, 2),
),
],
),
child: const Text('未设置'),
);
} else {
return const SizedBox.shrink();
}
}
final accuracyText = controller.extractAccuracy(item) ?? '识别率 -';
final showCheck = isCurrent && !hasUpdate;
final actionColor =
hasUpdate ? const Color(0xFFFF7A00) : const Color(0xFF6A5AE0);
final actionText = controller.actionTextForItem(item);
final actionEnabled = controller.actionEnabledForItem(item);
final isUpgrading =
item != null && controller.upgradingVoiceId.value == item.id;
final progress = controller.upgradeProgress.value;
return Container(
margin: const EdgeInsets.only(bottom: 12),
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: isDark ? Colors.grey[900] : Colors.white,
borderRadius: BorderRadius.circular(12),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.05),
blurRadius: 10,
offset: const Offset(0, 2),
),
],
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Container(
width: 40,
height: 40,
decoration: BoxDecoration(
color: (hasUpdate
? const Color(0xFFFFE8D5)
: const Color(0xFFE8E5FF)),
borderRadius: BorderRadius.circular(10),
),
child: Icon(
hasUpdate ? Icons.mic : Icons.auto_awesome,
color: hasUpdate
? const Color(0xFFFF7A00)
: const Color(0xFF6A5AE0),
),
),
const SizedBox(width: 10),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Expanded(
child: Text(item.name,
style: const TextStyle(
fontSize: 15, fontWeight: FontWeight.w600)),
),
Text('v${item.version}',
style: const TextStyle(
fontSize: 12, color: Colors.grey)),
],
),
const SizedBox(height: 4),
Text(accuracyText,
style:
const TextStyle(fontSize: 12, color: Colors.grey)),
],
),
),
if (showCheck) ...[
const SizedBox(width: 8),
const Icon(Icons.check_circle, color: Color(0xFF6A5AE0)),
]
],
),
if (hasUpdate) ...[
const SizedBox(height: 12),
Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: isDark ? Colors.grey[850] : const Color(0xFFFFF4E8),
borderRadius: BorderRadius.circular(8),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: const [
Icon(Icons.upgrade, color: Color(0xFFFF7A00), size: 18),
SizedBox(width: 6),
Text('更新内容',
style: TextStyle(
fontSize: 13,
fontWeight: FontWeight.w600,
color: Color(0xFFFF7A00))),
],
),
const SizedBox(height: 6),
...controller.extractUpdateNotes(item).map((note) => Padding(
padding: const EdgeInsets.symmetric(vertical: 2),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text('• ',
style: TextStyle(color: Colors.black54)),
Expanded(
child: Text(note,
style: const TextStyle(
fontSize: 12, color: Colors.black87)),
)
],
),
)),
],
),
),
],
const SizedBox(height: 12),
SizedBox(
width: double.infinity,
child: ElevatedButton(
style: ElevatedButton.styleFrom(
backgroundColor: actionColor,
padding: const EdgeInsets.symmetric(vertical: 12),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(10)),
),
onPressed: isUpgrading || !actionEnabled
? null
: () {
if (actionText == '升级') {
controller.upgradeWakeVoiceTo(item);
} else if (actionText == '切换') {
controller.switchWakeVoiceTo(item);
}
},
child: Text(actionText),
),
),
if (isUpgrading) ...[
const SizedBox(height: 12),
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
const Icon(Icons.sync, size: 16, color: Color(0xFF6A5AE0)),
const SizedBox(width: 6),
Text('升级中... $progress%',
style: const TextStyle(
fontSize: 12, color: Colors.black54)),
],
),
const SizedBox(height: 6),
ClipRRect(
borderRadius: BorderRadius.circular(6),
child: LinearProgressIndicator(
value: (progress.clamp(0, 100)) / 100.0,
minHeight: 8,
backgroundColor:
isDark ? Colors.grey[800] : Colors.grey[300],
color: const Color(0xFF6A5AE0),
),
),
],
),
],
],
),
);
}
// 底部按钮已移除,改为每个卡片独立的操作按钮以贴合设计
}

18
lib/routes/app_pages.dart

@ -47,13 +47,9 @@ import '../modules/login/views/login_view.dart';
import '../modules/login/bindings/login_binding.dart';
import '../modules/splash/views/splash_view.dart';
import '../modules/splash/bindings/splash_binding.dart';
import '../modules/pairing/views/pairing_view.dart';
import '../modules/pairing/bindings/pairing_binding.dart';
import './app_routes.dart';
import '../modules/settings/views/settings_view.dart';
import '../modules/settings/bindings/settings_binding.dart';
import '../modules/wakeupvoice/views/wakeup_voice_view.dart';
import '../modules/wakeupvoice/bindings/wakeup_voice_binding.dart';
import '../modules/permissions/views/permissions_view.dart';
import '../modules/permissions/bindings/permissions_binding.dart';
import '../modules/speech_test/views/speech_test_view.dart';
@ -101,13 +97,6 @@ abstract class AppPages {
transition: Transition.rightToLeft,
transitionDuration: Duration(milliseconds: 250),
),
GetPage(
name: Routes.pairing,
page: () => const PairingView(),
binding: PairingBinding(),
transition: Transition.rightToLeft,
transitionDuration: Duration(milliseconds: 250),
),
GetPage(
name: Routes.initial,
page: () => const HomeView(),
@ -373,13 +362,6 @@ abstract class AppPages {
transition: Transition.rightToLeft,
transitionDuration: Duration(milliseconds: 250),
),
GetPage(
name: Routes.wakeupvoice,
page: () => const WakeupVoiceView(),
binding: WakeupVoiceBinding(),
transition: Transition.rightToLeft,
transitionDuration: Duration(milliseconds: 250),
),
GetPage(
name: Routes.mobileElf,
page: () => const MobileElfView(),

2
lib/routes/app_routes.dart

@ -2,7 +2,6 @@ abstract class Routes {
// 认证相关
static const splash = '/splash';
static const login = '/login';
static const pairing = '/pairing';
// 主功能模块
static const initial = '/home';
@ -80,5 +79,4 @@ abstract class Routes {
static const goodstrans = '/goodstrans'; //翻译商品页面
static const goodsmeet = '/goodsmeet'; //会议商品页面
static const goodsai = '/goodsai'; //ai商品页面
static const wakeupvoice = '/wakeupvoice'; //唤醒词管理页面
}

95
local_plugins/azure_speech/android/src/main/kotlin/com/yunqiinnovation/azure_speech/AzureSpeechPlugin.kt

@ -71,11 +71,35 @@ class AzureSpeechPlugin : BleService.Callback, FlutterPlugin, ActivityAware,
private lateinit var astEventChannel: EventChannel
private var astEventSink: EventChannel.EventSink? = null
// AST TTS 输出事件通道(A/B 两路 TTS PCM → Dart → Jielihome.feedTranslatedAudio)
private lateinit var astTtsEventChannel: EventChannel
private var astTtsEventSink: EventChannel.EventSink? = null
// 通话翻译:对方(B)路 E2E 翻译音频(16kHz PCM16 mono)本地播放器
private val callBPcmPlayer = CallBPcmPlayer()
// 设备产品配置 broadcastpeertranslate:true=播报并启用 gate;false/未下发=不播报且 gate 永不触发。默认 false。
// 设备产品配置 broadcastpeertranslate:true=B 路同时也走外放(耳机一路总会走,外放是叠加)。
@Volatile private var broadcastPeerTranslate: Boolean = false
private fun emitAstTtsFrame(leg: String, data: ByteArray) {
val sink = astTtsEventSink ?: return
// EventChannel 在主线程 send,确保有效;TTS 段一段一段过来,量级在 KB,主线程开销可忽略
mainHandler.post {
try {
sink.success(mapOf("leg" to leg, "pcm" to data))
} catch (e: Exception) {
FileLogger.e(tag, "发送 AST TTS 事件失败 leg=$leg: ${e.message}")
}
}
}
/** A 路 TTS(己方→对端语种)→ 让对端听 = SOURCE_E_SCO_UP_LINK */
private val aPcmWriter: AudioWriter = AudioWriter { data ->
emitAstTtsFrame("A", data)
}
/** B 路 TTS(对端→己方语种)→ 让本机听 = SOURCE_E_SCO_DOWN_LINK;
* 若 broadcastPeerTranslate=true 还额外通过手机外放叠加播放。 */
private val bPcmWriter: AudioWriter = AudioWriter { data ->
emitAstTtsFrame("B", data)
if (broadcastPeerTranslate) callBPcmPlayer.feed(data)
}
@ -351,6 +375,20 @@ class AzureSpeechPlugin : BleService.Callback, FlutterPlugin, ActivityAware,
astEventSink = null
}
})
// 初始化 AST TTS 输出事件通道(杰理通话翻译用:把 A/B 路 TTS PCM 抛回 Dart,
// 由 Dart 转发给 Jielihome.feedTranslatedAudio 灌回耳机)
astTtsEventChannel =
EventChannel(flutterPluginBinding.binaryMessenger, "azure_speech/ast_tts_events")
astTtsEventChannel.setStreamHandler(object : EventChannel.StreamHandler {
override fun onListen(arguments: Any?, events: EventChannel.EventSink?) {
astTtsEventSink = events
}
override fun onCancel(arguments: Any?) {
astTtsEventSink = null
}
})
// 初始化翻译事件通道
translationEventChannel =
EventChannel(flutterPluginBinding.binaryMessenger, "azure_speech/translation_events")
@ -1216,6 +1254,28 @@ class AzureSpeechPlugin : BleService.Callback, FlutterPlugin, ActivityAware,
result.success(true)
}
/** Dart 把杰理 runtime 解码后的 SCO PCM 推进来:
* leg = 'A' / 'uplink' = 本机麦克风 / 己方
* leg = 'B' / 'downlink' = 对端通话音
* pcm 16k mono PCM16 little-endian
*/
"pushExternalAudio" -> {
val leg = call.argument<String>("leg")?.uppercase() ?: ""
val pcm = call.argument<ByteArray>("pcm")
if (pcm == null || pcm.isEmpty()) {
result.success(false); return
}
when (leg) {
"A", "UPLINK" -> pushAstAudioToA(pcm)
"B", "DOWNLINK" -> pushAstAudioToB(pcm)
else -> {
result.error("BAD_LEG", "leg must be A/uplink or B/downlink, got=$leg", null)
return
}
}
result.success(true)
}
"initialize" -> {
val initGeneration = nextAstInitGeneration()
val provider = call.argument<String>("provider") ?: ""
@ -1297,13 +1357,7 @@ class AzureSpeechPlugin : BleService.Callback, FlutterPlugin, ActivityAware,
val callbackA = IflytekAstCallback(
"A", "$lang0->$lang1",
{ sendAstEvent(it) },
{ data ->
bleWriteScope.launch {
bleRightMutex.withLock {
BleService.writeExternalAudioData(data)
}
}
}
aPcmWriter
)
val callbackB = IflytekAstCallback(
"B", "$lang1->$lang0",
@ -1366,13 +1420,7 @@ class AzureSpeechPlugin : BleService.Callback, FlutterPlugin, ActivityAware,
val callbackA = AzureAstCallback(
"A", "$lang0->$lang1",
{ sendAstEvent(it) },
{ data ->
bleWriteScope.launch {
bleRightMutex.withLock {
BleService.writeExternalAudioData(data)
}
}
}
aPcmWriter
)
val callbackB = AzureAstCallback(
"B", "$lang1->$lang0",
@ -1417,13 +1465,7 @@ class AzureSpeechPlugin : BleService.Callback, FlutterPlugin, ActivityAware,
val callbackA = DoubaoAstCallback(
"A", "$translationLang0->$translationLang1", translationLang1,
{ sendAstEvent(it) },
{ data ->
bleWriteScope.launch {
bleRightMutex.withLock {
BleService.writeExternalAudioData(data)
}
}
}
aPcmWriter
)
val callbackB = DoubaoAstCallback(
"B", "$translationLang1->$translationLang0", translationLang0,
@ -1476,13 +1518,7 @@ class AzureSpeechPlugin : BleService.Callback, FlutterPlugin, ActivityAware,
val callbackA = AliyunAstCallback(
"A", "$translationLang0->$translationLang1", translationLang1,
{ sendAstEvent(it) },
{ data ->
bleWriteScope.launch {
bleRightMutex.withLock {
BleService.writeExternalAudioData(data)
}
}
}
aPcmWriter
)
val callbackB = AliyunAstCallback(
"B", "$translationLang1->$translationLang0", translationLang0,
@ -1792,6 +1828,7 @@ class AzureSpeechPlugin : BleService.Callback, FlutterPlugin, ActivityAware,
asrEventChannel.setStreamHandler(null)
ttsEventChannel.setStreamHandler(null)
astEventChannel.setStreamHandler(null)
if (::astTtsEventChannel.isInitialized) astTtsEventChannel.setStreamHandler(null)
translationEventChannel.setStreamHandler(null)
try {

2
local_plugins/azure_speech/pubspec.yaml

@ -12,7 +12,7 @@ dependencies:
sdk: flutter
ble_service:
path: ../ble_service
dev_dependencies:
flutter_test:
sdk: flutter

33
local_plugins/device_jieli/.gitignore

@ -0,0 +1,33 @@
# Miscellaneous
*.class
*.log
*.pyc
*.swp
.DS_Store
.atom/
.build/
.buildlog/
.history
.svn/
.swiftpm/
migrate_working_dir/
# IntelliJ related
*.iml
*.ipr
*.iws
.idea/
# The .vscode folder contains launch configuration and tasks you configure in
# VS Code which you may wish to be included in version control, so this line
# is commented out by default.
#.vscode/
# Flutter/Dart/Pub related
# Libraries should not include pubspec.lock, per https://dart.dev/guides/libraries/private-files#pubspeclock.
/pubspec.lock
**/doc/api/
.dart_tool/
.flutter-plugins
.flutter-plugins-dependencies
build/

30
local_plugins/device_jieli/.metadata

@ -0,0 +1,30 @@
# This file tracks properties of this Flutter project.
# Used by Flutter tool to assess capabilities and perform upgrades etc.
#
# This file should be version controlled and should not be manually edited.
version:
revision: "30ade5e937e6afbd2d2c38ec53175b89b556447e"
channel: "stable"
project_type: plugin
# Tracks metadata for the flutter migrate command
migration:
platforms:
- platform: root
create_revision: 30ade5e937e6afbd2d2c38ec53175b89b556447e
base_revision: 30ade5e937e6afbd2d2c38ec53175b89b556447e
- platform: android
create_revision: 30ade5e937e6afbd2d2c38ec53175b89b556447e
base_revision: 30ade5e937e6afbd2d2c38ec53175b89b556447e
# User provided section
# List of Local paths (relative to this file) that should be
# ignored by the migrate tool.
#
# Files that are not part of the templates will be ignored by default.
unmanaged_files:
- 'lib/main.dart'
- 'ios/Runner.xcodeproj/project.pbxproj'

3
local_plugins/device_jieli/CHANGELOG.md

@ -0,0 +1,3 @@
## 0.0.1
* TODO: Describe initial release.

1
local_plugins/device_jieli/LICENSE

@ -0,0 +1 @@
TODO: Add your license here.

15
local_plugins/device_jieli/README.md

@ -0,0 +1,15 @@
# jielihome
JieLi Home SDK Flutter plugin
## Getting Started
This project is a starting point for a Flutter
[plug-in package](https://flutter.dev/to/develop-plugins),
a specialized package that includes platform-specific implementation code for
Android and/or iOS.
For help getting started with Flutter development, view the
[online documentation](https://docs.flutter.dev), which offers tutorials,
samples, guidance on mobile development, and a full API reference.

4
local_plugins/device_jieli/analysis_options.yaml

@ -0,0 +1,4 @@
include: package:flutter_lints/flutter.yaml
# Additional information about this file can be found at
# https://dart.dev/guides/language/analysis-options

9
local_plugins/device_jieli/android/.gitignore

@ -0,0 +1,9 @@
*.iml
.gradle
/local.properties
/.idea/workspace.xml
/.idea/libraries
.DS_Store
/build
/captures
.cxx

80
local_plugins/device_jieli/android/build.gradle

@ -0,0 +1,80 @@
group = "com.jielihome.jielihome"
version = "1.0-SNAPSHOT"
buildscript {
ext.kotlin_version = "2.1.0"
repositories {
google()
mavenCentral()
}
dependencies {
classpath("com.android.tools.build:gradle:8.7.3")
classpath("org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version")
}
}
allprojects {
repositories {
google()
mavenCentral()
flatDir {
dirs "libs"
}
}
}
apply plugin: "com.android.library"
apply plugin: "kotlin-android"
android {
namespace = "com.jielihome.jielihome"
compileSdk = 36
compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
kotlinOptions {
jvmTarget = JavaVersion.VERSION_17
}
sourceSets {
main.java.srcDirs += "src/main/kotlin"
test.java.srcDirs += "src/test/kotlin"
}
defaultConfig {
minSdk = 21
}
dependencies {
// 杰理原生 SDK:library 模块只能 compileOnly(AGP 限制:library 不能直接 bundle 本地 aar)。
// host app 需在 android/app/build.gradle.kts 里把同名 aar 用 implementation 引入作运行时依赖。
compileOnly fileTree(dir: 'libs', include: ['*.aar', '*.jar'])
// device_plugin_interface 提供 DeviceCallTranslationPort 等通用接口,
// 让 JieliCallTranslationPort 可以无 vendor 耦合地被 translate_server 消费。
implementation project(':device_plugin_interface')
implementation "com.squareup.okhttp3:okhttp:4.12.0"
implementation "com.google.code.gson:gson:2.10.1"
implementation "androidx.core:core-ktx:1.13.1"
implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:1.8.1"
testImplementation("org.jetbrains.kotlin:kotlin-test")
testImplementation("org.mockito:mockito-core:5.0.0")
}
testOptions {
unitTests.all {
useJUnitPlatform()
testLogging {
events "passed", "skipped", "failed", "standardOut", "standardError"
outputs.upToDateWhen {false}
showStandardStreams = true
}
}
}
}

BIN
local_plugins/device_jieli/android/libs/jl_audio_decode_V2.1.0_20012-release.aar

Binary file not shown.

BIN
local_plugins/device_jieli/android/libs/jl_audio_v2_V1.0.0_9-release.aar

Binary file not shown.

BIN
local_plugins/device_jieli/android/libs/jl_bluetooth_rcsp_V4.2.0_beta2_40214_20251224.aar

Binary file not shown.

BIN
local_plugins/device_jieli/android/libs/jldecryption_v0.4-release.aar

Binary file not shown.

1
local_plugins/device_jieli/android/settings.gradle

@ -0,0 +1 @@
rootProject.name = 'jielihome'

12
local_plugins/device_jieli/android/src/main/AndroidManifest.xml

@ -0,0 +1,12 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.BLUETOOTH"
android:maxSdkVersion="30" />
<uses-permission android:name="android.permission.BLUETOOTH_ADMIN"
android:maxSdkVersion="30" />
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT" />
<uses-permission android:name="android.permission.BLUETOOTH_SCAN" />
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
<uses-permission android:name="android.permission.RECORD_AUDIO" />
</manifest>

41
local_plugins/device_jieli/android/src/main/kotlin/com/jielihome/jielihome/JielihomePlugin.kt

@ -0,0 +1,41 @@
package com.jielihome.jielihome
import com.jielihome.jielihome.bridge.MethodRouter
import com.jielihome.jielihome.core.JieliHomeServer
import com.jielihome.jielihome.integration.JieliNativeDevicePlugin
import io.flutter.embedding.engine.plugins.FlutterPlugin
import io.flutter.plugin.common.EventChannel
import io.flutter.plugin.common.MethodChannel
/**
* Flutter 入口,仅负责注册 Channel 并把请求转交给 MethodRouter。
* 全部业务逻辑在 [JieliHomeServer] 与 feature/event 子模块中。
*/
class JielihomePlugin : FlutterPlugin {
private lateinit var methodChannel: MethodChannel
private lateinit var eventChannel: EventChannel
private lateinit var router: MethodRouter
override fun onAttachedToEngine(binding: FlutterPlugin.FlutterPluginBinding) {
val server = JieliHomeServer.get()
router = MethodRouter(binding.applicationContext, server)
methodChannel = MethodChannel(binding.binaryMessenger, "device_jieli/method")
methodChannel.setMethodCallHandler(router)
eventChannel = EventChannel(binding.binaryMessenger, "device_jieli/event")
eventChannel.setStreamHandler(server.dispatcher)
// 把杰理 vendor 注册到 device_manager 的 NativeDevicePluginRegistry:
// 这样 app 走 device_manager 路径时能 listVendors / useVendor("jieli")。
// SDK 直连路径(device_jieli/method 上面那条)保留不变;两条路径并存。
JieliNativeDevicePlugin.register(binding.applicationContext)
}
override fun onDetachedFromEngine(binding: FlutterPlugin.FlutterPluginBinding) {
methodChannel.setMethodCallHandler(null)
eventChannel.setStreamHandler(null)
// 进程级资源不在这里 shutdown,保持后续 attach 仍可继续使用
}
}

153
local_plugins/device_jieli/android/src/main/kotlin/com/jielihome/jielihome/api/JieliEventListener.kt

@ -0,0 +1,153 @@
package com.jielihome.jielihome.api
/**
* 原生层事件监听器。宿主 App 在 Activity/Service 里实现并注册到
* [com.jielihome.jielihome.core.JieliHomeServer.addEventListener],
* 即可订阅插件所有事件而不依赖 Flutter EventChannel。
*
* 所有回调里的 payload 都是 [Map],字段与 Dart 侧 EventChannel 推上去的完全一致,
* 因此原生订阅与 Dart 订阅可以并存(fan-out)。
*
* 默认提供「按 type 路由」的便捷扩展,子类只覆盖关心的方法即可。
*/
interface JieliEventListener {
/** 任意事件,原始 payload。可以自己按 payload["type"] 路由。 */
fun onEvent(payload: Map<String, Any?>) {}
}
/**
* 按事件类型分发的便捷适配器。子类只覆盖关心的回调,未覆盖的事件会落到 [onUnknown]。
*/
abstract class JieliEventAdapter : JieliEventListener {
override fun onEvent(payload: Map<String, Any?>) {
when (payload["type"] as? String) {
"adapterStatus" -> onAdapterStatus(
enabled = payload["enabled"] as? Boolean ?: false,
hasBle = payload["hasBle"] as? Boolean ?: false,
)
"scanStatus" -> onScanStatus(
ble = payload["ble"] as? Boolean ?: false,
started = payload["started"] as? Boolean ?: false,
)
"deviceFound" -> onDeviceFound(payload)
"bondStatus" -> onBondStatus(
address = payload["address"] as? String ?: "",
status = payload["status"] as? Int ?: 0,
)
"rcspInit" -> onRcspInit(
address = payload["address"] as? String ?: "",
code = payload["code"] as? Int ?: -1,
)
"connectionState" -> onConnectionState(
address = payload["address"] as? String ?: "",
state = payload["state"] as? Int ?: -1,
)
"battery" -> onBattery(
address = payload["address"] as? String,
level = payload["level"] as? Int,
)
"phoneCallStatus" -> onPhoneCallStatus(
address = payload["address"] as? String,
status = payload["status"] as? Int ?: 0,
)
"voiceMode" -> onVoiceMode(
address = payload["address"] as? String,
modeId = payload["modeId"] as? Int,
)
"volume" -> onVolume(payload)
"musicName" -> onMusicName(
address = payload["address"] as? String,
name = payload["name"] as? String,
)
"musicStatus" -> onMusicStatus(payload)
"playMode" -> onPlayMode(
address = payload["address"] as? String,
mode = payload["mode"] as? Int,
)
"expandFunction" -> onExpandFunction(payload)
"translationAudio" -> {
onTranslationAudio(payload)
onTranslationEvent(payload)
}
"translationResult" -> {
onTranslationResult(payload)
onTranslationEvent(payload)
}
"translationError" -> {
onTranslationError(payload)
onTranslationEvent(payload)
}
"translationLog" -> {
onTranslationLog(payload)
onTranslationEvent(payload)
}
"speechStart" -> onSpeechStart(payload)
"speechAudio" -> onSpeechAudio(payload)
"speechEnd" -> onSpeechEnd(payload)
"speechError" -> onSpeechError(payload)
"otaState" -> onOtaState(payload)
"otaError" -> onOtaError(payload)
else -> onUnknown(payload)
}
}
open fun onAdapterStatus(enabled: Boolean, hasBle: Boolean) {}
open fun onScanStatus(ble: Boolean, started: Boolean) {}
open fun onDeviceFound(payload: Map<String, Any?>) {}
open fun onBondStatus(address: String, status: Int) {}
open fun onRcspInit(address: String, code: Int) {}
open fun onConnectionState(address: String, state: Int) {}
open fun onBattery(address: String?, level: Int?) {}
open fun onPhoneCallStatus(address: String?, status: Int) {}
open fun onVoiceMode(address: String?, modeId: Int?) {}
open fun onVolume(payload: Map<String, Any?>) {}
open fun onMusicName(address: String?, name: String?) {}
open fun onMusicStatus(payload: Map<String, Any?>) {}
open fun onPlayMode(address: String?, mode: Int?) {}
open fun onExpandFunction(payload: Map<String, Any?>) {}
/** 翻译事件统一入口(兜底,默认细分回调之后还会触发一次) */
open fun onTranslationEvent(payload: Map<String, Any?>) {}
/** payload["pcm"] 为 ByteArray;附带 streamId/sampleRate/seq/tsMs/final 字段 */
open fun onTranslationAudio(payload: Map<String, Any?>) {}
/** payload 含 srcLang/srcText/destLang/destText/requestId */
open fun onTranslationResult(payload: Map<String, Any?>) {}
/** payload 含 code/message */
open fun onTranslationError(payload: Map<String, Any?>) {}
open fun onTranslationLog(payload: Map<String, Any?>) {}
/** 耳机检测到唤醒/语音助手触发;payload: voiceType, sampleRate, vadWay, address, tsMs */
open fun onSpeechStart(payload: Map<String, Any?>) {}
/** 语音助手音频帧;payload: pcm(ByteArray), encoding, sampleRate, channels, address */
open fun onSpeechAudio(payload: Map<String, Any?>) {}
/** 语音助手会话结束;payload: reason, message, address */
open fun onSpeechEnd(payload: Map<String, Any?>) {}
open fun onSpeechError(payload: Map<String, Any?>) {}
/** OTA 进度/状态:state(String), sent(Long), total(Long), percent(Int) */
open fun onOtaState(payload: Map<String, Any?>) {}
open fun onOtaError(payload: Map<String, Any?>) {}
open fun onUnknown(payload: Map<String, Any?>) {}
}

63
local_plugins/device_jieli/android/src/main/kotlin/com/jielihome/jielihome/audio/LocalPlayer.kt

@ -0,0 +1,63 @@
package com.jielihome.jielihome.audio
import android.media.AudioAttributes
import android.media.AudioFormat
import android.media.AudioManager
import android.media.AudioTrack
import java.util.concurrent.atomic.AtomicBoolean
/**
* 本地 PCM 播放(A2DP 路径)。用于音视频翻译模式:TTS 不回耳机,直接走 A2DP。
*/
class LocalPlayer(
private val sampleRate: Int = 16000,
private val channels: Int = 1,
) {
private var track: AudioTrack? = null
private val playing = AtomicBoolean(false)
fun start() {
if (playing.get()) return
val cfg = if (channels == 2) AudioFormat.CHANNEL_OUT_STEREO else AudioFormat.CHANNEL_OUT_MONO
val minBuf = AudioTrack.getMinBufferSize(sampleRate, cfg, AudioFormat.ENCODING_PCM_16BIT)
track = AudioTrack.Builder()
.setAudioAttributes(
AudioAttributes.Builder()
.setUsage(AudioAttributes.USAGE_MEDIA)
.setContentType(AudioAttributes.CONTENT_TYPE_SPEECH)
.build()
)
.setAudioFormat(
AudioFormat.Builder()
.setSampleRate(sampleRate)
.setChannelMask(cfg)
.setEncoding(AudioFormat.ENCODING_PCM_16BIT)
.build()
)
.setBufferSizeInBytes(maxOf(minBuf, sampleRate * 2 * channels)) // 1s
.setTransferMode(AudioTrack.MODE_STREAM)
.build()
track?.play()
playing.set(true)
}
fun feed(pcm: ByteArray) {
if (!playing.get()) return
runCatching { track?.write(pcm, 0, pcm.size) }
}
fun stop() {
if (!playing.compareAndSet(true, false)) return
runCatching { track?.stop() }
runCatching { track?.release() }
track = null
}
@Suppress("unused")
fun setVolumeFollowingSystemMedia() {
// hint: 用 AudioManager.STREAM_MUSIC 已由 USAGE_MEDIA 隐式选择
@Suppress("DEPRECATION")
AudioManager.STREAM_MUSIC // no-op
}
}

104
local_plugins/device_jieli/android/src/main/kotlin/com/jielihome/jielihome/audio/OpusStreamCodec.kt

@ -0,0 +1,104 @@
package com.jielihome.jielihome.audio
import com.jieli.jl_audio_decode.callback.OnDecodeStreamCallback
import com.jieli.jl_audio_decode.callback.OnEncodeStreamCallback
import com.jieli.jl_audio_decode.opus.OpusManager
import com.jieli.jl_audio_decode.opus.model.OpusOption
/**
* OPUS 流式解码器。
* 输入:从耳机收到的 OPUS 编码帧
* 输出:16kHz / mono PCM 流
*
* 立体声场景把 [channel] 设 2 + [packetSize] 设 80。
*/
class OpusStreamDecoder(
private val channel: Int = 1,
private val packetSize: Int = 200,
private val sampleRate: Int = 16000,
private val hasHead: Boolean = false,
private val onPcm: (pcm: ByteArray) -> Unit,
private val onError: (code: Int, msg: String?) -> Unit = { _, _ -> },
) {
private val manager = OpusManager()
@Volatile
private var started = false
fun start() {
if (started) return
val option = OpusOption()
.setHasHead(hasHead)
.setChannel(channel)
.setPacketSize(packetSize)
.setSampleRate(sampleRate)
manager.startDecodeStream(option, object : OnDecodeStreamCallback {
override fun onDecodeStream(pcm: ByteArray) { runCatching { onPcm(pcm) } }
override fun onStart() {}
override fun onComplete(p0: String?) {}
override fun onError(code: Int, msg: String?) {
runCatching { onError(code, msg) }
}
})
started = true
}
fun feedEncoded(opus: ByteArray) {
if (!started) return
runCatching { manager.writeAudioStream(opus) }
}
fun stop() {
if (!started) return
runCatching { manager.stopDecodeStream() }
runCatching { manager.release() }
started = false
}
}
/**
* OPUS 流式编码器。把 PCM 编码成 OPUS 帧后回调 [onOpus],由调用方包成 AudioData 回送给设备。
*/
class OpusStreamEncoder(
private val channel: Int = 1,
private val packetSize: Int = 200,
private val sampleRate: Int = 16000,
private val hasHead: Boolean = false,
private val onOpus: (opus: ByteArray) -> Unit,
private val onError: (code: Int, msg: String?) -> Unit = { _, _ -> },
) {
private val manager = OpusManager()
@Volatile
private var started = false
fun start() {
if (started) return
val option = OpusOption()
.setHasHead(hasHead)
.setChannel(channel)
.setPacketSize(packetSize)
.setSampleRate(sampleRate)
manager.startEncodeStream(option, object : OnEncodeStreamCallback {
override fun onEncodeStream(opus: ByteArray) { runCatching { onOpus(opus) } }
override fun onStart() {}
override fun onComplete(p0: String?) {}
override fun onError(code: Int, msg: String?) {
runCatching { onError(code, msg) }
}
})
started = true
}
fun feedPcm(pcm: ByteArray) {
if (!started) return
runCatching { manager.writeEncodeStream(pcm) }
}
fun stop() {
if (!started) return
runCatching { manager.stopEncodeStream() }
runCatching { manager.release() }
started = false
}
}

25
local_plugins/device_jieli/android/src/main/kotlin/com/jielihome/jielihome/audio/PcmKit.kt

@ -0,0 +1,25 @@
package com.jielihome.jielihome.audio
object PcmKit {
/**
* 把 16bit 立体声 PCM 拆为左/右两路单声道。
* 入参字节流:L0_lo L0_hi R0_lo R0_hi L1_lo L1_hi R1_lo R1_hi ...
* 返回:Pair(leftMono, rightMono)
*/
fun splitStereo16(pcm: ByteArray): Pair<ByteArray, ByteArray> {
val frames = pcm.size / 4
val left = ByteArray(frames * 2)
val right = ByteArray(frames * 2)
var li = 0
var ri = 0
var i = 0
while (i + 3 < pcm.size) {
left[li++] = pcm[i]
left[li++] = pcm[i + 1]
right[ri++] = pcm[i + 2]
right[ri++] = pcm[i + 3]
i += 4
}
return left to right
}
}

102
local_plugins/device_jieli/android/src/main/kotlin/com/jielihome/jielihome/audio/PhoneMicCapture.kt

@ -0,0 +1,102 @@
package com.jielihome.jielihome.audio
import android.Manifest
import android.annotation.SuppressLint
import android.content.Context
import android.content.pm.PackageManager
import android.media.AudioFormat
import android.media.AudioRecord
import android.media.MediaRecorder
import android.util.Log
import androidx.core.content.ContextCompat
import java.util.concurrent.atomic.AtomicBoolean
import kotlin.concurrent.thread
/**
* 手机麦克风采集,输出 16kHz / 16bit / mono PCM。
* 每 20ms 切一帧(320 samples = 640 bytes)。
*
* 用法:
* val mic = PhoneMicCapture(context) { pcm -> /* push to bridge */ }
* mic.start()
* ...
* mic.stop()
*/
class PhoneMicCapture(
private val context: Context,
private val sampleRate: Int = 16000,
private val frameDurationMs: Int = 20,
private val onFrame: (pcm: ByteArray) -> Unit,
private val onError: (code: Int, msg: String) -> Unit = { _, _ -> },
) {
private val running = AtomicBoolean(false)
private var recorder: AudioRecord? = null
private var worker: Thread? = null
val frameSizeBytes: Int = sampleRate * frameDurationMs / 1000 * 2 /* 16bit */
@SuppressLint("MissingPermission")
fun start(): Boolean {
if (running.get()) return true
if (ContextCompat.checkSelfPermission(context, Manifest.permission.RECORD_AUDIO)
!= PackageManager.PERMISSION_GRANTED
) {
Log.w(TAG, "RECORD_AUDIO permission missing")
onError(-101, "RECORD_AUDIO permission missing")
return false
}
val minBuf = AudioRecord.getMinBufferSize(
sampleRate,
AudioFormat.CHANNEL_IN_MONO,
AudioFormat.ENCODING_PCM_16BIT
)
val bufSize = maxOf(minBuf, frameSizeBytes * 4)
val ar = AudioRecord(
MediaRecorder.AudioSource.VOICE_COMMUNICATION,
sampleRate,
AudioFormat.CHANNEL_IN_MONO,
AudioFormat.ENCODING_PCM_16BIT,
bufSize
)
if (ar.state != AudioRecord.STATE_INITIALIZED) {
ar.release()
Log.w(TAG, "AudioRecord init failed")
onError(-102, "AudioRecord init failed (state=${ar.state})")
return false
}
recorder = ar
running.set(true)
ar.startRecording()
worker = thread(name = "device-jieli-mic", isDaemon = true) {
val frame = ByteArray(frameSizeBytes)
while (running.get()) {
val n = try { ar.read(frame, 0, frameSizeBytes) } catch (t: Throwable) {
onError(-103, "AudioRecord.read crashed: ${t.message}"); -1
}
when {
n < 0 -> { onError(-104, "AudioRecord.read err code=$n"); break }
n == 0 -> continue
n == frameSizeBytes -> onFrame(frame.copyOf())
else -> onFrame(frame.copyOf(n))
}
}
}
return true
}
fun stop() {
if (!running.compareAndSet(true, false)) return
runCatching { recorder?.stop() }
runCatching { recorder?.release() }
recorder = null
worker?.join(200)
worker = null
}
companion object {
private const val TAG = "PhoneMicCapture"
}
}

50
local_plugins/device_jieli/android/src/main/kotlin/com/jielihome/jielihome/bridge/EventDispatcher.kt

@ -0,0 +1,50 @@
package com.jielihome.jielihome.bridge
import android.os.Handler
import android.os.Looper
import com.jielihome.jielihome.api.JieliEventListener
import io.flutter.plugin.common.EventChannel
import java.util.concurrent.CopyOnWriteArrayList
/**
* 事件总线。所有 forwarder / feature 调用 [send],由本类做 fan-out:
* - Flutter 端:通过 EventChannel.EventSink 推到 Dart
* - 原生端:通过 [JieliEventListener] 推给宿主直接订阅
*
* Flutter / 原生 / 多个原生监听器 可以同时存在;payload 形态完全一致。
*/
class EventDispatcher : EventChannel.StreamHandler {
private val mainHandler = Handler(Looper.getMainLooper())
@Volatile
private var sink: EventChannel.EventSink? = null
private val nativeListeners = CopyOnWriteArrayList<JieliEventListener>()
override fun onListen(arguments: Any?, events: EventChannel.EventSink?) {
sink = events
}
override fun onCancel(arguments: Any?) {
sink = null
}
fun addNativeListener(listener: JieliEventListener) {
if (!nativeListeners.contains(listener)) nativeListeners.add(listener)
}
fun removeNativeListener(listener: JieliEventListener) {
nativeListeners.remove(listener)
}
fun send(payload: Map<String, Any?>) {
// 原生监听同步派发(在事件原始线程,不经主线程切换,方便高频音频帧)
for (l in nativeListeners) {
try { l.onEvent(payload) } catch (_: Throwable) { /* swallow listener errors */ }
}
// Flutter 端必须在主线程
val s = sink ?: return
mainHandler.post { runCatching { s.success(payload) } }
}
}

227
local_plugins/device_jieli/android/src/main/kotlin/com/jielihome/jielihome/bridge/MethodRouter.kt

@ -0,0 +1,227 @@
package com.jielihome.jielihome.bridge
import android.content.Context
import com.jielihome.jielihome.core.JieliHomeServer
import io.flutter.plugin.common.MethodCall
import io.flutter.plugin.common.MethodChannel
/**
* MethodChannel 路由层:把 Dart 调用映射到 server 内的 feature 模块。
* Plugin 类不直接处理任何业务逻辑,只装配 router。
*/
class MethodRouter(
private val context: Context,
private val server: JieliHomeServer,
) : MethodChannel.MethodCallHandler {
override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) {
try {
when (call.method) {
"getPlatformVersion" ->
result.success("Android ${android.os.Build.VERSION.RELEASE}")
"initialize" -> {
server.initialize(
context = context,
multiDevice = call.argument<Boolean>("multiDevice") ?: true,
skipNoNameDev = call.argument<Boolean>("skipNoNameDev") ?: false,
enableLog = call.argument<Boolean>("enableLog") ?: false,
)
result.success(true)
}
"startScan" -> {
val timeout = call.argument<Int>("timeoutMs") ?: 30000
server.scanFeature.startScan(timeout)
.fold({ result.success(true) },
{ result.error("SCAN_FAILED", it.message, null) })
}
"stopScan" -> { server.scanFeature.stopScan(); result.success(true) }
"isScanning" -> result.success(server.scanFeature.isScanning())
"connect" -> {
val mac = call.argument<String>("address")
?: return result.error("BAD_ARG", "address required", null)
server.connectFeature.connect(
bleAddress = mac,
edrAddress = call.argument<String>("edrAddr"),
deviceType = call.argument<Int>("deviceType") ?: -1,
connectWay = call.argument<Int>("connectWay") ?: 0,
).fold({ result.success(true) },
{ result.error("CONNECT_FAILED", it.message, null) })
}
"disconnect" -> {
val mac = call.argument<String>("address")
?: return result.error("BAD_ARG", "address required", null)
server.connectFeature.disconnect(mac)
.fold({ result.success(true) },
{ result.error("DISCONNECT_FAILED", it.message, null) })
}
"isConnected" -> {
val mac = call.argument<String>("address")
?: return result.error("BAD_ARG", "address required", null)
result.success(server.connectFeature.isConnected(mac))
}
"connectedDevice" -> {
val d = server.connectFeature.connectedDevice()
if (d == null) result.success(null)
else result.success(
mapOf(
"address" to d.address,
"name" to runCatching { d.name }.getOrNull()
)
)
}
"deviceSnapshot" -> {
val mac = call.argument<String>("address")
?: return result.error("BAD_ARG", "address required", null)
result.success(server.deviceInfoFeature.snapshot(mac))
}
"queryTargetInfo" -> {
val mac = call.argument<String>("address")
?: return result.error("BAD_ARG", "address required", null)
val mask = call.argument<Int>("mask") ?: 0x0F
server.deviceInfoFeature.queryTargetInfo(
mac, mask,
onSuccess = { result.success(it) },
onError = { c, m -> result.error("TARGET_INFO_ERR", m, c) }
)
}
"sendCustomCmd" -> {
val mac = call.argument<String>("address")
?: return result.error("BAD_ARG", "address required", null)
val opCode = call.argument<Int>("opCode")
?: return result.error("BAD_ARG", "opCode required", null)
val payload = call.argument<ByteArray>("payload") ?: ByteArray(0)
server.customCmdFeature.send(
mac, opCode, payload,
onSuccess = { result.success(it) },
onError = { c, m -> result.error("CUSTOM_CMD_ERR", m, c) }
)
}
"startTranslation" -> {
val modeId = call.argument<Int>("modeId")
?: return result.error("BAD_ARG", "modeId required", null)
@Suppress("UNCHECKED_CAST")
val args = (call.argument<Map<String, Any?>>("args") ?: emptyMap())
server.translationFeature.start(modeId, args)
.fold({ result.success(true) },
{ result.error("TRANSLATION_ERR", it.message, null) })
}
"stopTranslation" -> {
server.translationFeature.stop(); result.success(true)
}
"translationStatus" -> result.success(
mapOf(
"working" to server.translationFeature.isWorking(),
"modeId" to server.translationFeature.currentModeId(),
"inputStreams" to server.translationFeature.currentInputStreams(),
"outputStreams" to server.translationFeature.currentOutputStreams()
)
)
"feedTranslatedAudio" -> {
val streamId = call.argument<String>("streamId")
?: return result.error("BAD_ARG", "streamId required", null)
val pcm = call.argument<ByteArray>("pcm")
?: return result.error("BAD_ARG", "pcm required", null)
val sampleRate = call.argument<Int>("sampleRate") ?: 16000
val channels = call.argument<Int>("channels") ?: 1
val bits = call.argument<Int>("bitsPerSample") ?: 16
val isFinal = call.argument<Boolean>("final") ?: false
val ok = server.translationFeature.feedTranslatedAudio(
streamId, pcm,
com.jielihome.jielihome.feature.translation.AudioFormat(sampleRate, channels, bits),
isFinal
)
result.success(ok)
}
"feedTranslationResult" -> {
server.translationFeature.feedTranslationResult(
srcLang = call.argument<String>("srcLang"),
srcText = call.argument<String>("srcText"),
destLang = call.argument<String>("destLang"),
destText = call.argument<String>("destText"),
requestId = call.argument<String>("requestId"),
)
result.success(true)
}
"isSupportCallTranslationWithStereo" -> {
val mac = call.argument<String>("address")
result.success(server.translationFeature.isSupportCallTranslationWithStereo(mac))
}
"feedAudioFilePcm" -> {
val pcm = call.argument<ByteArray>("pcm")
?: return result.error("BAD_ARG", "pcm required", null)
val sampleRate = call.argument<Int>("sampleRate") ?: 16000
result.success(server.translationFeature.feedAudioFilePcm(pcm, sampleRate))
}
"speechIsRecording" ->
result.success(server.speechFeature.isRecording(call.argument<String>("address")))
"speechStart" -> {
server.speechFeature.start(
address = call.argument<String>("address"),
voiceType = call.argument<Int>("voiceType")
?: com.jieli.bluetooth.bean.record.RecordParam.VOICE_TYPE_OPUS,
sampleRate = call.argument<Int>("sampleRate")
?: com.jieli.bluetooth.bean.record.RecordParam.SAMPLE_RATE_16K,
vadWay = call.argument<Int>("vadWay")
?: com.jieli.bluetooth.bean.record.RecordParam.VAD_WAY_DEVICE,
onResult = { ok, msg ->
if (ok) result.success(true)
else result.error("SPEECH_START_ERR", msg, null)
}
)
}
"speechStop" -> {
server.speechFeature.stop(
address = call.argument<String>("address"),
reason = call.argument<Int>("reason")
?: com.jieli.bluetooth.bean.record.RecordState.REASON_NORMAL,
onResult = { ok, msg ->
if (ok) result.success(true)
else result.error("SPEECH_STOP_ERR", msg, null)
}
)
}
"otaStart" -> {
val path = call.argument<String>("firmwareFilePath")
?: return result.error("BAD_ARG", "firmwareFilePath required", null)
server.otaFeature.start(
address = call.argument<String>("address"),
firmwareFilePath = path,
blockSize = call.argument<Int>("blockSize") ?: 512,
fileFlagBytes = call.argument<ByteArray>("fileFlag") ?: ByteArray(0),
)
result.success(true)
}
"otaCancel" -> { server.otaFeature.cancel(); result.success(true) }
"otaIsRunning" -> result.success(server.otaFeature.isRunning())
else -> result.notImplemented()
}
} catch (t: Throwable) {
result.error("PLUGIN_ERR", t.message, t.stackTraceToString())
}
}
}

195
local_plugins/device_jieli/android/src/main/kotlin/com/jielihome/jielihome/core/JieliHomeServer.kt

@ -0,0 +1,195 @@
package com.jielihome.jielihome.core
import android.content.Context
import com.jieli.bluetooth.bean.BluetoothOption
import com.jieli.bluetooth.impl.JL_BluetoothManager
import com.jieli.bluetooth.impl.rcsp.RCSPController
import com.jieli.bluetooth.utils.JL_Log
import com.jielihome.jielihome.api.JieliEventListener
import com.jielihome.jielihome.bridge.EventDispatcher
import com.jielihome.jielihome.event.BluetoothEventForwarder
import com.jielihome.jielihome.event.CustomEventForwarder
import com.jielihome.jielihome.event.DeviceInfoEventForwarder
import com.jielihome.jielihome.event.MediaEventForwarder
import com.jielihome.jielihome.feature.ConnectFeature
import com.jielihome.jielihome.feature.CustomCmdFeature
import com.jielihome.jielihome.feature.DeviceInfoFeature
import com.jielihome.jielihome.feature.ScanFeature
import com.jielihome.jielihome.feature.ota.OtaFeature
import com.jielihome.jielihome.feature.translation.EventChannelAudioBridge
import com.jielihome.jielihome.feature.translation.TranslationAudioBridge
import com.jielihome.jielihome.feature.translation.TranslationFeature
import com.jielihome.jielihome.feature.voice.SpeechFeature
/**
* 全局单例。原生层和 Flutter 层共用同一个 server。
*
* 原生用法:
* ```
* val server = JieliHomeServer.get()
* server.initialize(context, multiDevice = true, skipNoNameDev = false, enableLog = true)
* server.addEventListener(myListener) // 订阅事件
* server.scanFeature.startScan(30_000)
* server.translationFeature.start(MODE_CALL_TRANSLATION, mapOf("address" to mac))
* server.translationFeature.feedTranslatedAudio("uplink", pcm, AudioFormat(), false)
* ```
*
* Flutter 侧由 [com.jielihome.jielihome.JielihomePlugin] 自动绑定 EventChannel + MethodChannel。
* 即使没有 Flutter,原生也能完整使用 server。
*/
class JieliHomeServer private constructor() {
companion object {
@Volatile
private var instance: JieliHomeServer? = null
@JvmStatic
fun get(): JieliHomeServer = instance ?: synchronized(this) {
instance ?: JieliHomeServer().also { instance = it }
}
}
@Volatile
var initialized: Boolean = false
private set
/** 事件总线,原生层通过 [addEventListener] 订阅;Flutter 自动注入 EventChannel sink */
val dispatcher: EventDispatcher = EventDispatcher()
private lateinit var btManager: JL_BluetoothManager
private lateinit var bluetoothForwarder: BluetoothEventForwarder
private lateinit var deviceInfoForwarder: DeviceInfoEventForwarder
private lateinit var mediaForwarder: MediaEventForwarder
private lateinit var customForwarder: CustomEventForwarder
lateinit var scanFeature: ScanFeature
private set
lateinit var connectFeature: ConnectFeature
private set
lateinit var deviceInfoFeature: DeviceInfoFeature
private set
lateinit var customCmdFeature: CustomCmdFeature
private set
lateinit var translationFeature: TranslationFeature
private set
lateinit var speechFeature: SpeechFeature
private set
lateinit var otaFeature: OtaFeature
private set
/** 默认翻译音频桥(EventChannel 透传到 Dart);宿主 native 想完全自管时可调
* [TranslationFeature.setBridge] 替换。
*
* 暴露 getter 供 native 编排器(如 [JieliCallTranslationPort])在 enter/exit
* 时拿到原始引用做"装上自己的 capture bridge / 退出后还原"切换。
*/
lateinit var defaultBridge: EventChannelAudioBridge
private set
fun initialize(
context: Context,
multiDevice: Boolean = true,
skipNoNameDev: Boolean = false,
enableLog: Boolean = false,
) {
if (initialized) return
JL_Log.configureLog(context, enableLog, enableLog)
val option = BluetoothOption.createDefaultOption().also {
it.isUseMultiDevice = multiDevice
it.isSkipNoNameDev = skipNoNameDev
// 清空杰理协议广播过滤头:默认值会让 SDK 只回调匹配杰理私有广播头的设备,
// 导致 onDiscovery 在普通 BLE 设备 / 测试场景下完全不触发。
it.scanFilterData = ""
it.otaScanFilterData = ""
}
RCSPController.init(context, option)
btManager = RCSPController.getInstance().bluetoothManager
?: error("RCSPController.bluetoothManager is null after init")
// 事件转发层
bluetoothForwarder = BluetoothEventForwarder(btManager, dispatcher).also { it.attach() }
deviceInfoForwarder = DeviceInfoEventForwarder(btManager, dispatcher).also { it.attach() }
mediaForwarder = MediaEventForwarder(btManager, dispatcher).also { it.attach() }
customForwarder = CustomEventForwarder(btManager, dispatcher).also { it.attach() }
// 业务功能层
scanFeature = ScanFeature(btManager)
connectFeature = ConnectFeature(btManager)
deviceInfoFeature = DeviceInfoFeature(btManager)
customCmdFeature = CustomCmdFeature(btManager)
translationFeature = TranslationFeature(context.applicationContext, btManager, connectFeature)
speechFeature = SpeechFeature(btManager, connectFeature, dispatcher).also { it.attach() }
otaFeature = OtaFeature(btManager, connectFeature, dispatcher)
// 默认音频桥:EventChannel;injector 把 Dart 推过来的 PCM 路由回当前 ModeHandler
defaultBridge = EventChannelAudioBridge(dispatcher) { _, streamId, pcm, fmt, isFinal ->
translationFeature.feedTranslatedAudio(streamId, pcm, fmt, isFinal)
}
translationFeature.setBridge(defaultBridge)
// 内部级联收尾:连接断开 / 来电状态变化时,自动停止当前翻译,避免脏状态
dispatcher.addNativeListener(internalCleanupListener)
initialized = true
}
/** 监听若干生命周期事件并触发自动收尾 */
private val internalCleanupListener = object : com.jielihome.jielihome.api.JieliEventListener {
override fun onEvent(payload: Map<String, Any?>) {
when (payload["type"] as? String) {
"connectionState" -> {
val state = payload["state"] as? Int ?: return
// StateCode.CONNECTION_DISCONNECT == 0
if (state == 0 && translationFeature.isWorking()) {
translationFeature.stop()
}
}
"phoneCallStatus" -> {
// 来电/通话进行中 (status != 0) 且当前不是通话翻译,自动 exit
val status = payload["status"] as? Int ?: return
if (status == 0) return
val mid = translationFeature.currentModeId() ?: return
val isCallMode = mid == com.jieli.bluetooth.bean.translation.TranslationMode.MODE_CALL_TRANSLATION ||
mid == com.jieli.bluetooth.bean.translation.TranslationMode.MODE_CALL_TRANSLATION_WITH_STEREO
if (!isCallMode && translationFeature.isWorking()) {
translationFeature.stop()
}
}
}
}
}
/** 原生事件订阅 */
fun addEventListener(listener: JieliEventListener) = dispatcher.addNativeListener(listener)
fun removeEventListener(listener: JieliEventListener) = dispatcher.removeNativeListener(listener)
/** 替换翻译音频桥(完全 native 直连翻译服务时使用) */
fun setTranslationBridge(bridge: TranslationAudioBridge) {
translationFeature.setBridge(bridge)
}
/**
* 通话翻译能力端口([com.aiagent.device_plugin_interface.DeviceCallTranslationPort]
* 的杰理实现)。延迟创建;翻译编排器(translate_server native)通过
* [DeviceSession.invokeFeature("jieli.callTranslationPort")] 或类似入口拿到引用。
*/
val callTranslationPort: com.jielihome.jielihome.feature.translation.JieliCallTranslationPort by lazy {
com.jielihome.jielihome.feature.translation.JieliCallTranslationPort(this)
}
fun shutdown() {
if (!initialized) return
runCatching { otaFeature.cancel() }
runCatching { translationFeature.stop() }
runCatching { speechFeature.detach() }
runCatching { bluetoothForwarder.detach() }
runCatching { deviceInfoForwarder.detach() }
runCatching { mediaForwarder.detach() }
runCatching { customForwarder.detach() }
initialized = false
}
}

114
local_plugins/device_jieli/android/src/main/kotlin/com/jielihome/jielihome/event/BluetoothEventForwarder.kt

@ -0,0 +1,114 @@
package com.jielihome.jielihome.event
import android.annotation.SuppressLint
import android.bluetooth.BluetoothDevice
import android.util.Log
import com.jieli.bluetooth.bean.BleScanMessage
import com.jieli.bluetooth.constant.BluetoothConstant
import com.jieli.bluetooth.impl.JL_BluetoothManager
import com.jieli.bluetooth.interfaces.bluetooth.BluetoothCallbackImpl
import com.jieli.bluetooth.interfaces.rcsp.callback.OnRcspCallback
import com.jielihome.jielihome.bridge.EventDispatcher
private const val TAG = "JLBtEvtFwd"
/**
* 蓝牙基础事件:适配器开关、扫描状态、设备发现、配对、RCSP init、连接状态。
* 与设备「业务数据」(电量、音乐等)解耦。
*/
class BluetoothEventForwarder(
private val btManager: JL_BluetoothManager,
private val dispatcher: EventDispatcher,
) {
private val btCallback = object : BluetoothCallbackImpl() {
override fun onAdapterStatus(bEnabled: Boolean, bHasBle: Boolean) {
Log.i(TAG, "onAdapterStatus enabled=$bEnabled hasBle=$bHasBle")
dispatcher.send(
mapOf("type" to "adapterStatus", "enabled" to bEnabled, "hasBle" to bHasBle)
)
}
override fun onDiscoveryStatus(bBle: Boolean, bStart: Boolean) {
Log.i(TAG, "onDiscoveryStatus ble=$bBle started=$bStart")
dispatcher.send(
mapOf("type" to "scanStatus", "ble" to bBle, "started" to bStart)
)
}
@SuppressLint("MissingPermission")
override fun onDiscovery(device: BluetoothDevice?, msg: BleScanMessage?) {
Log.i(TAG, "onDiscovery raw: device=$device msg=$msg")
if (device == null) {
Log.w(TAG, "onDiscovery 跳过 device==null")
return
}
// 只放行杰理协议设备(vid == JL_VID=1494)。
// 其它厂商广播(Apple vid=76、Microsoft vid=6 等)跳过,避免列表里出来一堆不能连的设备。
if (msg == null || msg.vid != BluetoothConstant.JL_VID) {
Log.d(TAG, "onDiscovery 跳过非杰理: addr=${device.address} vid=${msg?.vid}")
return
}
val name = try { device.name } catch (_: SecurityException) { null } ?: ""
Log.i(
TAG,
"onDiscovery deliver name='$name' addr=${device.address} " +
"edr=${msg?.edrAddr} type=${msg?.deviceType} way=${msg?.connectWay} rssi=${msg?.rssi}"
)
dispatcher.send(
mapOf(
"type" to "deviceFound",
"name" to name,
"address" to device.address,
"edrAddr" to (msg?.edrAddr ?: ""),
"deviceType" to (msg?.deviceType ?: 0),
"connectWay" to (msg?.connectWay ?: 0),
"rssi" to (msg?.rssi ?: 0),
// BleScanMessage 已经包含双耳/盒子电量及充电状态——直接透传,
// 上层就不需要等 RCSP 拉快照了。0 视为「该位置无数据」(如单耳设备的 rightDeviceQuantity)
"leftBattery" to (msg?.leftDeviceQuantity ?: 0),
"rightBattery" to (msg?.rightDeviceQuantity ?: 0),
"caseBattery" to (msg?.chargingBinQuantity ?: 0),
"leftCharging" to (msg?.isLeftCharging ?: false),
"rightCharging" to (msg?.isRightCharging ?: false),
"caseCharging" to (msg?.isDeviceCharging ?: false)
)
)
}
override fun onBondStatus(device: BluetoothDevice?, status: Int) {
if (device == null) return
dispatcher.send(
mapOf("type" to "bondStatus", "address" to device.address, "status" to status)
)
}
}
private val rcspCallback = object : OnRcspCallback() {
override fun onRcspInit(device: BluetoothDevice?, code: Int) {
if (device == null) return
dispatcher.send(
mapOf("type" to "rcspInit", "address" to device.address, "code" to code)
)
if (code != 0) btManager.disconnect(device)
}
override fun onConnectStateChange(device: BluetoothDevice?, status: Int) {
if (device == null) return
dispatcher.send(
mapOf("type" to "connectionState", "address" to device.address, "state" to status)
)
}
}
fun attach() {
Log.i(TAG, "attach: 注册 btCallback / rcspCallback")
btManager.addEventListener(btCallback)
btManager.registerOnRcspCallback(rcspCallback)
}
fun detach() {
Log.i(TAG, "detach: 注销 btCallback / rcspCallback")
btManager.removeEventListener(btCallback)
btManager.unregisterOnRcspCallback(rcspCallback)
}
}

40
local_plugins/device_jieli/android/src/main/kotlin/com/jielihome/jielihome/event/CustomEventForwarder.kt

@ -0,0 +1,40 @@
package com.jielihome.jielihome.event
import android.bluetooth.BluetoothDevice
import android.util.Base64
import com.jieli.bluetooth.impl.JL_BluetoothManager
import com.jieli.bluetooth.interfaces.rcsp.callback.OnRcspEventListener
import com.jielihome.jielihome.bridge.EventDispatcher
/**
* 厂商私有协议入口。
* SDK 将所有未在内置协议表中匹配的「设备主动推」事件统一从 onExpandFunction 抛出,
* 这里把它原样透传到 Dart(payload 走 base64),由业务层自行解析。
*
* 适用:自定义指令的设备->APP 反向通知,例如「检测到唤醒词」「自定义按键事件」。
*/
class CustomEventForwarder(
private val btManager: JL_BluetoothManager,
private val dispatcher: EventDispatcher,
) {
private val listener = object : OnRcspEventListener() {
override fun onExpandFunction(device: BluetoothDevice?, opCode: Int, payload: ByteArray?) {
dispatcher.send(
mapOf(
"type" to "expandFunction",
"address" to device?.address,
"opCode" to opCode,
"payloadBase64" to payload?.let { Base64.encodeToString(it, Base64.NO_WRAP) }
)
)
}
}
fun attach() {
btManager.registerOnRcspEventListener(listener)
}
fun detach() {
btManager.unregisterOnRcspEventListener(listener)
}
}

73
local_plugins/device_jieli/android/src/main/kotlin/com/jielihome/jielihome/event/DeviceInfoEventForwarder.kt

@ -0,0 +1,73 @@
package com.jielihome.jielihome.event
import android.bluetooth.BluetoothDevice
import com.jieli.bluetooth.bean.base.VoiceMode
import com.jieli.bluetooth.bean.device.status.BatteryInfo
import com.jieli.bluetooth.bean.device.voice.VoiceFunc
import com.jieli.bluetooth.impl.JL_BluetoothManager
import com.jieli.bluetooth.interfaces.rcsp.callback.OnRcspEventListener
import com.jielihome.jielihome.bridge.EventDispatcher
/**
* 设备信息类事件(设备主动推送):电量、来电状态、语音模式/功能、双连状态。
*
* 实现思路:注册一个 OnRcspEventListener 实例,按主题分配到不同 forwarder。
* 这里只负责「设备级状态」,多媒体放 MediaEventForwarder,私有协议放 CustomEventForwarder。
*
* 多个 forwarder 各注册一个独立 listener,SDK 会全部派发,互不干扰。
*/
class DeviceInfoEventForwarder(
private val btManager: JL_BluetoothManager,
private val dispatcher: EventDispatcher,
) {
private val listener = object : OnRcspEventListener() {
override fun onBatteryChange(device: BluetoothDevice?, info: BatteryInfo?) {
dispatcher.send(
mapOf(
"type" to "battery",
"address" to device?.address,
"level" to info?.battery
)
)
}
override fun onPhoneCallStatusChange(device: BluetoothDevice?, status: Int) {
dispatcher.send(
mapOf(
"type" to "phoneCallStatus",
"address" to device?.address,
"status" to status
)
)
}
override fun onCurrentVoiceMode(device: BluetoothDevice?, mode: VoiceMode?) {
dispatcher.send(
mapOf(
"type" to "voiceMode",
"address" to device?.address,
"modeId" to mode?.mode
)
)
}
override fun onVoiceFunctionChange(device: BluetoothDevice?, func: VoiceFunc?) {
dispatcher.send(
mapOf(
"type" to "voiceFunction",
"address" to device?.address,
"function" to func?.toString()
)
)
}
}
fun attach() {
btManager.registerOnRcspEventListener(listener)
}
fun detach() {
btManager.unregisterOnRcspEventListener(listener)
}
}

79
local_plugins/device_jieli/android/src/main/kotlin/com/jielihome/jielihome/event/MediaEventForwarder.kt

@ -0,0 +1,79 @@
package com.jielihome.jielihome.event
import android.bluetooth.BluetoothDevice
import com.jieli.bluetooth.bean.device.eq.EqInfo
import com.jieli.bluetooth.bean.device.music.MusicNameInfo
import com.jieli.bluetooth.bean.device.music.MusicStatusInfo
import com.jieli.bluetooth.bean.device.music.PlayModeInfo
import com.jieli.bluetooth.bean.device.voice.VolumeInfo
import com.jieli.bluetooth.impl.JL_BluetoothManager
import com.jieli.bluetooth.interfaces.rcsp.callback.OnRcspEventListener
import com.jielihome.jielihome.bridge.EventDispatcher
/**
* 多媒体事件:音乐名/状态/播放模式、EQ、音量。
*/
class MediaEventForwarder(
private val btManager: JL_BluetoothManager,
private val dispatcher: EventDispatcher,
) {
private val listener = object : OnRcspEventListener() {
override fun onVolumeChange(device: BluetoothDevice?, info: VolumeInfo?) {
dispatcher.send(
mapOf(
"type" to "volume",
"address" to device?.address,
"current" to info?.volume,
"max" to info?.maxVol
)
)
}
override fun onEqChange(device: BluetoothDevice?, info: EqInfo?) {
dispatcher.send(
mapOf("type" to "eq", "address" to device?.address, "info" to info?.toString())
)
}
override fun onMusicNameChange(device: BluetoothDevice?, info: MusicNameInfo?) {
dispatcher.send(
mapOf(
"type" to "musicName",
"address" to device?.address,
"name" to info?.name
)
)
}
override fun onMusicStatusChange(device: BluetoothDevice?, info: MusicStatusInfo?) {
dispatcher.send(
mapOf(
"type" to "musicStatus",
"address" to device?.address,
"playing" to info?.isPlay,
"currentTime" to info?.currentTime,
"totalTime" to info?.totalTime
)
)
}
override fun onPlayModeChange(device: BluetoothDevice?, info: PlayModeInfo?) {
dispatcher.send(
mapOf(
"type" to "playMode",
"address" to device?.address,
"mode" to info?.playMode
)
)
}
}
fun attach() {
btManager.registerOnRcspEventListener(listener)
}
fun detach() {
btManager.unregisterOnRcspEventListener(listener)
}
}

56
local_plugins/device_jieli/android/src/main/kotlin/com/jielihome/jielihome/feature/ConnectFeature.kt

@ -0,0 +1,56 @@
package com.jielihome.jielihome.feature
import android.bluetooth.BluetoothDevice
import com.jieli.bluetooth.constant.BluetoothConstant
import com.jieli.bluetooth.constant.JL_DeviceType
import com.jieli.bluetooth.impl.JL_BluetoothManager
import com.jieli.bluetooth.utils.BluetoothUtil
class ConnectFeature(private val btManager: JL_BluetoothManager) {
fun connect(
bleAddress: String,
edrAddress: String?,
deviceType: Int,
connectWay: Int,
): Result<Unit> {
val target = resolveTarget(bleAddress, edrAddress, deviceType, connectWay)
?: return Result.failure(IllegalStateException("remote device not found"))
btManager.connect(target.first, target.second)
return Result.success(Unit)
}
fun disconnect(address: String): Result<Unit> {
val device = BluetoothUtil.getRemoteDevice(address)
?: return Result.failure(IllegalStateException("remote device not found"))
btManager.disconnect(device)
return Result.success(Unit)
}
fun isConnected(address: String): Boolean {
val device = BluetoothUtil.getRemoteDevice(address) ?: return false
return btManager.isConnectedBtDevice(device)
}
fun connectedDevice(): BluetoothDevice? = btManager.connectedDevice
fun deviceByAddress(address: String): BluetoothDevice? =
BluetoothUtil.getRemoteDevice(address)
private fun resolveTarget(
bleAddress: String,
edrAddress: String?,
deviceType: Int,
connectWay: Int,
): Pair<BluetoothDevice, Int>? {
val ble = BluetoothUtil.getRemoteDevice(bleAddress) ?: return null
if (deviceType != JL_DeviceType.JL_DEVICE_TYPE_WATCH &&
!edrAddress.isNullOrEmpty() &&
(connectWay == BluetoothConstant.PROTOCOL_TYPE_SPP ||
connectWay == BluetoothConstant.PROTOCOL_TYPE_GATT_OVER_BR_EDR)
) {
BluetoothUtil.getRemoteDevice(edrAddress)?.let { return it to connectWay }
}
return ble to BluetoothConstant.PROTOCOL_TYPE_BLE
}
}

42
local_plugins/device_jieli/android/src/main/kotlin/com/jielihome/jielihome/feature/CustomCmdFeature.kt

@ -0,0 +1,42 @@
package com.jielihome.jielihome.feature
import android.bluetooth.BluetoothDevice
import com.jieli.bluetooth.bean.base.BaseError
import com.jieli.bluetooth.bean.base.CommandBase
import com.jieli.bluetooth.bean.command.custom.CustomCmd
import com.jieli.bluetooth.bean.parameter.CustomParam
import com.jieli.bluetooth.bean.response.CustomResponse
import com.jieli.bluetooth.impl.JL_BluetoothManager
import com.jieli.bluetooth.interfaces.bluetooth.RcspCommandCallback
import com.jieli.bluetooth.utils.BluetoothUtil
/**
* 厂商扩展指令通道。
* 用法:业务方约定一个 opCode + payload 字节流,从这里下发;设备应答的 payload 透传回 Dart。
* 设备「主动推」的扩展事件由 CustomEventForwarder.onExpandFunction 接收。
*/
class CustomCmdFeature(private val btManager: JL_BluetoothManager) {
fun send(
address: String,
opCode: Int,
payload: ByteArray,
onSuccess: (ByteArray?) -> Unit,
onError: (Int, String?) -> Unit,
) {
val device: BluetoothDevice = BluetoothUtil.getRemoteDevice(address)
?: return onError(-1, "remote device not found")
val cmd = CustomCmd(opCode, CustomParam(payload))
btManager.sendRcspCommand(device, cmd, object : RcspCommandCallback {
override fun onCommandResponse(dev: BluetoothDevice?, resp: CommandBase<*, *>?) {
val r = resp?.response as? CustomResponse
onSuccess(r?.data)
}
override fun onErrCode(dev: BluetoothDevice?, err: BaseError?) {
onError(err?.code ?: -2, err?.message)
}
})
}
}

73
local_plugins/device_jieli/android/src/main/kotlin/com/jielihome/jielihome/feature/DeviceInfoFeature.kt

@ -0,0 +1,73 @@
package com.jielihome.jielihome.feature
import android.bluetooth.BluetoothDevice
import com.jieli.bluetooth.bean.base.BaseError
import com.jieli.bluetooth.bean.base.CommandBase
import com.jieli.bluetooth.bean.command.GetTargetInfoCmd
import com.jieli.bluetooth.bean.parameter.GetTargetInfoParam
import com.jieli.bluetooth.bean.response.TargetInfoResponse
import com.jieli.bluetooth.impl.JL_BluetoothManager
import com.jieli.bluetooth.interfaces.bluetooth.RcspCommandCallback
import com.jieli.bluetooth.utils.BluetoothUtil
/**
* 查询电量、版本号等设备静态信息(拉取式)。
* 设备主动推的电量变化由 DeviceInfoEventForwarder 处理。
*/
class DeviceInfoFeature(private val btManager: JL_BluetoothManager) {
/** 直接读 SDK 缓存(连接成功后已自动拉取过一次) */
fun snapshot(address: String): Map<String, Any?>? {
val device = BluetoothUtil.getRemoteDevice(address) ?: return null
val info = btManager.getDeviceInfo(device) ?: return null
return mapOf(
"address" to device.address,
"battery" to info.quantity,
"volume" to info.volume,
"maxVolume" to info.maxVol,
"versionCode" to info.versionCode,
"versionName" to info.versionName,
"name" to info.name,
"vid" to info.vid,
"pid" to info.pid,
"uid" to info.uid,
"edrAddr" to info.edrAddr
)
}
/**
* 主动向设备发指令拉取最新 TargetInfo。
* @param mask 设备字段位掩码,常见 0x0F 拉常用字段;具体 mask 见杰理协议文档
*/
fun queryTargetInfo(
address: String,
mask: Int,
onSuccess: (Map<String, Any?>) -> Unit,
onError: (Int, String?) -> Unit,
) {
val device: BluetoothDevice = BluetoothUtil.getRemoteDevice(address)
?: return onError(-1, "remote device not found")
val cmd = GetTargetInfoCmd(GetTargetInfoParam(mask))
btManager.sendRcspCommand(device, cmd, object : RcspCommandCallback {
override fun onCommandResponse(dev: BluetoothDevice?, resp: CommandBase<*, *>?) {
val r = resp?.response as? TargetInfoResponse
?: return onError(-2, "no response")
onSuccess(
mapOf(
"address" to (dev?.address ?: address),
"battery" to r.quantity,
"volume" to r.volume,
"versionCode" to r.versionCode,
"versionName" to r.versionName,
"name" to r.name
)
)
}
override fun onErrCode(dev: BluetoothDevice?, err: BaseError?) {
onError(err?.code ?: -3, err?.message)
}
})
}
}

19
local_plugins/device_jieli/android/src/main/kotlin/com/jielihome/jielihome/feature/ScanFeature.kt

@ -0,0 +1,19 @@
package com.jielihome.jielihome.feature
import com.jieli.bluetooth.constant.ErrorCode
import com.jieli.bluetooth.impl.JL_BluetoothManager
class ScanFeature(private val btManager: JL_BluetoothManager) {
fun startScan(timeoutMs: Int): Result<Unit> {
val code = btManager.scan(timeoutMs)
return if (code == ErrorCode.ERR_NONE) Result.success(Unit)
else Result.failure(IllegalStateException("scan returned $code"))
}
fun stopScan() {
btManager.stopScan()
}
fun isScanning(): Boolean = btManager.bluetoothOperation.isScanning
}

264
local_plugins/device_jieli/android/src/main/kotlin/com/jielihome/jielihome/feature/ota/OtaFeature.kt

@ -0,0 +1,264 @@
package com.jielihome.jielihome.feature.ota
import android.bluetooth.BluetoothDevice
import com.jieli.bluetooth.bean.base.BaseError
import com.jieli.bluetooth.bean.base.CommandBase
import com.jieli.bluetooth.bean.command.ota.EnterUpdateModeCmd
import com.jieli.bluetooth.bean.command.ota.ExitUpdateModeCmd
import com.jieli.bluetooth.bean.command.ota.FirmwareUpdateBlockCmd
import com.jieli.bluetooth.bean.command.ota.FirmwareUpdateStatusCmd
import com.jieli.bluetooth.bean.command.ota.GetUpdateFileOffsetCmd
import com.jieli.bluetooth.bean.command.ota.InquireUpdateCmd
import com.jieli.bluetooth.bean.command.ota.NotifyUpdateContentSizeCmd
import com.jieli.bluetooth.bean.command.ota.RebootDeviceCmd
import com.jieli.bluetooth.bean.parameter.FirmwareUpdateBlockParam
import com.jieli.bluetooth.bean.parameter.InquireUpdateParam
import com.jieli.bluetooth.bean.parameter.NotifyUpdateContentSizeParam
import com.jieli.bluetooth.bean.parameter.RebootDeviceParam
import com.jieli.bluetooth.bean.response.EnterUpdateModeResponse
import com.jieli.bluetooth.bean.response.FirmwareUpdateBlockResponse
import com.jieli.bluetooth.bean.response.FirmwareUpdateStatusResponse
import com.jieli.bluetooth.bean.response.InquireUpdateResponse
import com.jieli.bluetooth.bean.response.UpdateFileOffsetResponse
import com.jieli.bluetooth.impl.JL_BluetoothManager
import com.jieli.bluetooth.interfaces.bluetooth.RcspCommandCallback
import com.jielihome.jielihome.bridge.EventDispatcher
import com.jielihome.jielihome.feature.ConnectFeature
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.suspendCancellableCoroutine
import java.io.File
import java.io.RandomAccessFile
import java.util.concurrent.atomic.AtomicBoolean
import kotlin.coroutines.resume
import kotlin.coroutines.resumeWithException
/**
* OTA 升级。
*
* # 重要说明
* **当前 SDK 包(jl_bluetooth_rcsp)只提供 OTA 协议的原始 Cmd,没有高层 OTA 管理器。**
* 完整 OTA 流(含 CRC 校验、断点续传、双备份切换)通常在杰理另发的 `jl_otalib_*.aar`
* 里实现。本实现是基于公开 cmd 的「最小可工作版」,**生产前需在真机验证**。
*
* # 状态机
* IDLE → INQUIRING → NOTIFYING_SIZE → ENTERING → TRANSFERRING(progress) → VERIFYING
* → REBOOTING → DONE / FAILED / CANCELLED
*
* # 数据流(推测的协议,需真机验证)
* 1. InquireUpdate(file flag) → device 决定能否升级
* 2. NotifyUpdateContentSize(totalSize) → 告知固件包大小
* 3. EnterUpdateMode → device 进入升级态
* 4. Loop:
* GetUpdateFileOffset → device 告知它需要从哪个 offset 开始
* FirmwareUpdateBlock(offset, len) → APP 发块(数据塞在响应里)
* until offset >= totalSize
* 5. FirmwareUpdateStatus → device 验证结果
* 6. ExitUpdateMode + RebootDevice
*
* # 调用方式
* server.otaFeature.start(address, "/sdcard/firmware.ufw")
* server.otaFeature.cancel()
*/
class OtaFeature(
private val btManager: JL_BluetoothManager,
private val connectFeature: ConnectFeature,
private val dispatcher: EventDispatcher,
) {
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
private val running = AtomicBoolean(false)
private var job: Job? = null
fun isRunning(): Boolean = running.get()
fun start(
address: String? = null,
firmwareFilePath: String,
/** 块大小(设备可能在响应里给出建议值;这里只是默认) */
blockSize: Int = 512,
/** 升级文件标记(杰理协议要求;常见空数组或自定义校验) */
fileFlagBytes: ByteArray = ByteArray(0),
) {
if (!running.compareAndSet(false, true)) {
emitError(-1, "ota already running"); return
}
val device = address?.let { connectFeature.deviceByAddress(it) }
?: connectFeature.connectedDevice()
if (device == null) {
emitError(-2, "no connected device")
running.set(false); return
}
val file = File(firmwareFilePath)
if (!file.isFile || file.length() <= 0) {
emitError(-3, "firmware file invalid: $firmwareFilePath")
running.set(false); return
}
emitState(State.INQUIRING, 0, file.length())
job = scope.launch { runFlow(device, file, blockSize, fileFlagBytes) }
}
fun cancel() {
job?.cancel()
job = null
running.set(false)
emitState(State.CANCELLED, -1, 0)
}
// ───── 内部主流程 ─────
private suspend fun runFlow(
device: BluetoothDevice,
file: File,
blockSize: Int,
fileFlagBytes: ByteArray,
) {
try {
val totalSize = file.length()
// 1. Inquire
val inquire: InquireUpdateResponse = sendCmd(
device, InquireUpdateCmd(InquireUpdateParam(fileFlagBytes))
)
if (inquire.canUpdateFlag != 0) {
throw IllegalStateException("device refused update, flag=${inquire.canUpdateFlag}")
}
// 2. NotifyContentSize
emitState(State.NOTIFYING_SIZE, 0, totalSize)
sendCmd<com.jieli.bluetooth.bean.base.CommonResponse>(
device,
NotifyUpdateContentSizeCmd(NotifyUpdateContentSizeParam(totalSize.toInt()))
)
// 3. Enter update mode
emitState(State.ENTERING, 0, totalSize)
val enter: EnterUpdateModeResponse = sendCmd(device, EnterUpdateModeCmd())
if (enter.canUpdateFlag != 0) {
throw IllegalStateException("enter update mode failed, flag=${enter.canUpdateFlag}")
}
// 4. Block transfer loop
emitState(State.TRANSFERRING, 0, totalSize)
RandomAccessFile(file, "r").use { raf ->
var safety = 0
while (running.get()) {
val ofs: UpdateFileOffsetResponse = sendCmd(device, GetUpdateFileOffsetCmd())
val nextOffset = ofs.updateFileFlagOffset.toLong()
val askLen = ofs.updateFileFlagLen.takeIf { it > 0 } ?: blockSize
if (nextOffset >= totalSize) break
val len = minOf(askLen.toLong(), totalSize - nextOffset).toInt()
val data = ByteArray(len)
raf.seek(nextOffset)
raf.readFully(data)
val blockCmd = FirmwareUpdateBlockCmd(
FirmwareUpdateBlockParam(nextOffset.toInt(), len)
)
// 在响应里塞数据:杰理协议要求 APP 把块数据放在 response 里,
// 而不是 param 里(FirmwareUpdateBlockParam 没有 data 字段)。
// 这里我们走 sendRcspResponse 模式:先把 cmd 当作"已收到的 device 请求"
// 处理,然后回响应。需要 SDK 内部支持;如果直接 sendRcspCommand 不行,
// 真机调试时切到 device-initiated 流(见类注释)。
val resp = FirmwareUpdateBlockResponse().setFirmwareUpdateBlockData(data)
blockCmd.response = resp
sendBlock(device, blockCmd)
val written = nextOffset + len
emitState(State.TRANSFERRING, written, totalSize)
safety++
if (safety > 200_000) throw IllegalStateException("too many block iterations")
delay(0L) // yield
}
if (!running.get()) throw CancellationException()
}
// 5. Verify
emitState(State.VERIFYING, totalSize, totalSize)
val status: FirmwareUpdateStatusResponse = sendCmd(device, FirmwareUpdateStatusCmd())
if (status.result != 0) throw IllegalStateException("verify failed result=${status.result}")
// 6. Exit + Reboot
emitState(State.REBOOTING, totalSize, totalSize)
runCatching { sendCmd<com.jieli.bluetooth.bean.response.ExitUpdateModeResponse>(device, ExitUpdateModeCmd()) }
runCatching { sendCmd<com.jieli.bluetooth.bean.response.RebootDeviceResponse>(device, RebootDeviceCmd(RebootDeviceParam(0))) }
emitState(State.DONE, totalSize, totalSize)
} catch (ce: CancellationException) {
emitState(State.CANCELLED, -1, file.length())
} catch (t: Throwable) {
emitError(-100, "ota failed: ${t.message}")
emitState(State.FAILED, -1, file.length())
} finally {
running.set(false)
}
}
/** 普通 OTA 控制命令:APP 发,等设备响应 */
private suspend inline fun <reified R> sendCmd(
device: BluetoothDevice,
cmd: CommandBase<*, *>,
): R = suspendCancellableCoroutine { cont ->
btManager.sendRcspCommand(device, cmd, object : RcspCommandCallback {
override fun onCommandResponse(d: BluetoothDevice?, resp: CommandBase<*, *>?) {
@Suppress("UNCHECKED_CAST")
val r = resp?.response as? R
if (r != null) cont.resume(r)
else cont.resumeWithException(IllegalStateException("unexpected response"))
}
override fun onErrCode(d: BluetoothDevice?, err: BaseError?) {
cont.resumeWithException(IllegalStateException("err ${err?.code}: ${err?.message}"))
}
})
}
/** 块响应:把已经填好 response 的 cmd 通过 sendRcspResponse 回设备 */
private suspend fun sendBlock(
device: BluetoothDevice,
cmd: FirmwareUpdateBlockCmd,
) = suspendCancellableCoroutine<Unit> { cont ->
try {
// 先尝试 sendRcspResponse(device-initiated 模式)
btManager.sendRcspResponse(device, cmd)
cont.resume(Unit)
} catch (t: Throwable) {
// 兜底:sendRcspCommand
btManager.sendRcspCommand(device, cmd, object : RcspCommandCallback {
override fun onCommandResponse(d: BluetoothDevice?, resp: CommandBase<*, *>?) { cont.resume(Unit) }
override fun onErrCode(d: BluetoothDevice?, err: BaseError?) {
cont.resumeWithException(IllegalStateException("block err ${err?.code}: ${err?.message}"))
}
})
}
}
// ───── 事件 ─────
enum class State { IDLE, INQUIRING, NOTIFYING_SIZE, ENTERING, TRANSFERRING, VERIFYING, REBOOTING, DONE, FAILED, CANCELLED }
private fun emitState(state: State, sent: Long, total: Long) {
val percent = if (total > 0 && sent in 0..total) (sent * 100.0 / total).toInt() else -1
dispatcher.send(
mapOf(
"type" to "otaState",
"state" to state.name,
"sent" to sent,
"total" to total,
"percent" to percent,
"tsMs" to System.currentTimeMillis(),
)
)
}
private fun emitError(code: Int, msg: String?) {
dispatcher.send(
mapOf("type" to "otaError", "code" to code, "message" to msg)
)
}
}

105
local_plugins/device_jieli/android/src/main/kotlin/com/jielihome/jielihome/feature/translation/EventChannelAudioBridge.kt

@ -0,0 +1,105 @@
package com.jielihome.jielihome.feature.translation
import com.jielihome.jielihome.bridge.EventDispatcher
/**
* 默认桥实现:采集帧通过 EventChannel 推到 Dart(base64 PCM),
* Dart 把 PCM 喂给外部翻译服务,再通过 MethodChannel 回调 [TranslationFeature.feedTranslatedAudio]。
*
* 16kHz / 16bit / 单声道 PCM 流量约 32KB/s,base64 后 ~43KB/s,跨通道开销可控。
* 若宿主想完全 native 直连翻译服务,可实现自己的 [TranslationAudioBridge] 并通过
* [TranslationFeature.setBridge] 替换。
*/
class EventChannelAudioBridge(
private val dispatcher: EventDispatcher,
/** 由 TranslationFeature 注入:把 PCM 真正塞回当前 ModeHandler */
var injector: TranslationAudioInjector? = null,
) : TranslationAudioBridge {
override fun emitAudioFrame(
modeId: Int,
streamId: String,
pcm: ByteArray,
format: AudioFormat,
seq: Long,
tsMs: Long,
isFinal: Boolean,
) {
// EventChannel 原生支持 byte[] → Uint8List,直接传字节流,避免 base64 33% 体积膨胀。
dispatcher.send(
mapOf(
"type" to "translationAudio",
"modeId" to modeId,
"streamId" to streamId,
"sampleRate" to format.sampleRate,
"channels" to format.channels,
"bitsPerSample" to format.bitsPerSample,
"seq" to seq,
"tsMs" to tsMs,
"final" to isFinal,
"pcm" to pcm,
)
)
}
override fun emitTranslationResult(
modeId: Int,
srcLang: String?,
srcText: String?,
destLang: String?,
destText: String?,
requestId: String?,
) {
dispatcher.send(
mapOf(
"type" to "translationResult",
"modeId" to modeId,
"srcLang" to srcLang,
"srcText" to srcText,
"destLang" to destLang,
"destText" to destText,
"requestId" to requestId,
)
)
}
override fun emitLog(modeId: Int, content: String) {
dispatcher.send(
mapOf("type" to "translationLog", "modeId" to modeId, "content" to content)
)
}
override fun emitError(modeId: Int, code: Int, message: String?) {
dispatcher.send(
mapOf(
"type" to "translationError",
"modeId" to modeId,
"code" to code,
"message" to message
)
)
}
override fun feedTranslatedAudio(
modeId: Int,
outputStreamId: String,
pcm: ByteArray,
format: AudioFormat,
isFinal: Boolean,
): Boolean {
return injector?.inject(modeId, outputStreamId, pcm, format, isFinal) ?: false
}
}
/**
* 内部接口:TranslationFeature 决定当前 ModeHandler 是谁,由它消费 PCM。
*/
fun interface TranslationAudioInjector {
fun inject(
modeId: Int,
outputStreamId: String,
pcm: ByteArray,
format: AudioFormat,
isFinal: Boolean,
): Boolean
}

210
local_plugins/device_jieli/android/src/main/kotlin/com/jielihome/jielihome/feature/translation/JieliCallTranslationPort.kt

@ -0,0 +1,210 @@
package com.jielihome.jielihome.feature.translation
import android.util.Log
import com.aiagent.device_plugin_interface.CallAudioCodec
import com.aiagent.device_plugin_interface.CallAudioFormat
import com.aiagent.device_plugin_interface.CallAudioFrame
import com.aiagent.device_plugin_interface.CallTranslationError
import com.aiagent.device_plugin_interface.CallTranslationLeg
import com.aiagent.device_plugin_interface.DeviceCallTranslationPort
import com.aiagent.device_plugin_interface.TranslatedAudioFrame
import com.jielihome.jielihome.core.JieliHomeServer
import kotlinx.coroutines.channels.BufferOverflow
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.asSharedFlow
/**
* `DeviceCallTranslationPort` 的杰理实现。
*
* 当前阶段策略:**只支持 PCM_S16LE / 16kHz / mono / 20ms**。
* 新版 SDK 已经在 native 内部把耳机端 OPUS 解码成 PCM 上推(见 [TranslationFeature]
* 的 `mode` handlers),上层零编解码即可贯通。OPUS 直通模式后续按需补。
*
* # enter / exit 与 SDK bridge 的关系
* - [enter] 时把 [JieliHomeServer.defaultBridge](EventChannel → Dart 的桥)替换为
* 本实现内的 capture bridge:拦截 `IN_UPLINK` / `IN_DOWNLINK` 上推的 PCM 帧,
* 翻译成 [CallAudioFrame] 写入 [audioFrames] 流;同时把 `emitError` 接到
* [errors] 流。
* - 期间 Dart 的 `device_jieli/event` 不会再收到翻译音频事件——这正是"完全 native
* 直连翻译服务"的预期路径,与 `translate_server` native 编排器配套。
* - [exit] 时还原默认桥,Dart 端的 EventChannel 恢复正常使用。
*
* # TTS 回灌
* 编排器把翻译完成的 PCM 通过 [reportTranslated] 回写:
* - `CallTranslationLeg.UPLINK` → SDK 的 `OUT_UPLINK`(对方耳机听)
* - `CallTranslationLeg.DOWNLINK` → SDK 的 `OUT_DOWNLINK`(本机用户耳机听)
*
* # 线程安全
* `enter`/`exit`/`reportTranslated` 通过 `synchronized(this)` 互斥;
* SharedFlow `tryEmit` 自身线程安全,capture bridge 可被任意线程回调。
*/
class JieliCallTranslationPort(
private val server: JieliHomeServer,
) : DeviceCallTranslationPort {
companion object {
private const val TAG = "JieliCallTransPort"
private val PCM_16K_MONO_20MS = CallAudioFormat.PCM_S16LE_16K_MONO_20MS
}
private val _audioFrames = MutableSharedFlow<CallAudioFrame>(
replay = 0,
extraBufferCapacity = 128,
onBufferOverflow = BufferOverflow.DROP_OLDEST,
)
private val _errors = MutableSharedFlow<CallTranslationError>(
replay = 0,
extraBufferCapacity = 16,
onBufferOverflow = BufferOverflow.DROP_OLDEST,
)
@Volatile private var entered = false
@Volatile private var savedBridge: TranslationAudioBridge? = null
override fun supportedSourceFormats(): Set<CallAudioFormat> = setOf(PCM_16K_MONO_20MS)
override fun supportedSinkFormats(): Set<CallAudioFormat> = setOf(PCM_16K_MONO_20MS)
override val audioFrames: Flow<CallAudioFrame> = _audioFrames.asSharedFlow()
override val errors: Flow<CallTranslationError> = _errors.asSharedFlow()
@Synchronized
override fun enter(sourceFormat: CallAudioFormat) {
require(sourceFormat == PCM_16K_MONO_20MS) {
"JieliCallTranslationPort only supports PCM_S16LE/16k/mono/20ms (got $sourceFormat)"
}
check(!entered) { "device.call_translation.busy" }
val translationFeature = server.translationFeature
// 调试:统计 SDK 给我们推了多少帧、按 leg 分桶;每秒打一次。
val countByStream = java.util.concurrent.ConcurrentHashMap<String, Long>()
var lastReportMs = System.currentTimeMillis()
val captureBridge = object : TranslationAudioBridge {
override fun emitAudioFrame(
modeId: Int,
streamId: String,
pcm: ByteArray,
format: AudioFormat,
seq: Long,
tsMs: Long,
isFinal: Boolean,
) {
// 调试用:周期性打印 SDK 上推帧统计,确认设备端有没有真出 PCM。
countByStream.merge(streamId, 1L) { old, _ -> old + 1L }
val now = System.currentTimeMillis()
if (now - lastReportMs >= 1000L) {
Log.d(TAG, "SDK frame stats (last 1s): $countByStream fmt=${format.sampleRate}Hz/${format.channels}ch")
countByStream.clear()
lastReportMs = now
}
val leg = when (streamId) {
TranslationStreams.IN_UPLINK -> CallTranslationLeg.UPLINK
TranslationStreams.IN_DOWNLINK -> CallTranslationLeg.DOWNLINK
else -> return // ignore non-call streams in this mode
}
val frame = CallAudioFrame(
leg = leg,
codec = CallAudioCodec.PCM_S16LE,
sampleRate = format.sampleRate,
channels = format.channels,
bytes = pcm,
sequence = seq,
timestampUs = tsMs * 1000L,
)
if (!_audioFrames.tryEmit(frame)) {
Log.w(TAG, "audioFrames buffer overflow; dropped seq=$seq leg=$leg")
}
}
override fun emitTranslationResult(
modeId: Int,
srcLang: String?, srcText: String?,
destLang: String?, destText: String?,
requestId: String?,
) {
// 字幕在 native 编排器里由 agent 直接出,不依赖这个回调;忽略。
}
override fun emitLog(modeId: Int, content: String) {
Log.d(TAG, "translation log: modeId=$modeId $content")
}
override fun emitError(modeId: Int, code: Int, message: String?) {
_errors.tryEmit(
CallTranslationError(
code = "device.feature_failed",
message = "code=$code msg=${message ?: ""}",
)
)
}
override fun feedTranslatedAudio(
modeId: Int,
outputStreamId: String,
pcm: ByteArray,
format: AudioFormat,
isFinal: Boolean,
): Boolean {
// capture bridge 不负责回灌——编排器走 [reportTranslated] 直接调
// server.translationFeature.feedTranslatedAudio。
return false
}
}
savedBridge = server.defaultBridge
server.setTranslationBridge(captureBridge)
val result = translationFeature.start(
TranslationModeIds.MODE_CALL_TRANSLATION,
emptyMap(),
)
if (result.isFailure) {
// 启动失败:还原桥并抛出
savedBridge?.let { runCatching { server.setTranslationBridge(it) } }
savedBridge = null
throw IllegalStateException(
"failed to enter MODE_CALL_TRANSLATION: ${result.exceptionOrNull()?.message}",
result.exceptionOrNull(),
)
}
entered = true
Log.d(TAG, "entered MODE_CALL_TRANSLATION")
}
@Synchronized
override fun reportTranslated(frame: TranslatedAudioFrame) {
check(entered) { "device.call_translation.not_active" }
require(frame.codec == CallAudioCodec.PCM_S16LE) {
"JieliCallTranslationPort only accepts PCM_S16LE for sink (got ${frame.codec})"
}
val streamId = when (frame.leg) {
CallTranslationLeg.UPLINK -> TranslationStreams.OUT_UPLINK
CallTranslationLeg.DOWNLINK -> TranslationStreams.OUT_DOWNLINK
}
val ok = server.translationFeature.feedTranslatedAudio(
outputStreamId = streamId,
pcm = frame.bytes,
format = AudioFormat(
sampleRate = frame.sampleRate,
channels = frame.channels,
bitsPerSample = 16,
),
isFinal = frame.isFinal,
)
if (!ok) {
Log.w(TAG, "feedTranslatedAudio rejected: streamId=$streamId leg=${frame.leg}")
}
}
@Synchronized
override fun exit() {
if (!entered) return
entered = false
runCatching { server.translationFeature.stop() }
savedBridge?.let { runCatching { server.setTranslationBridge(it) } }
savedBridge = null
Log.d(TAG, "exited call translation")
}
}

52
local_plugins/device_jieli/android/src/main/kotlin/com/jielihome/jielihome/feature/translation/TranslationAudioBridge.kt

@ -0,0 +1,52 @@
package com.jielihome.jielihome.feature.translation
/**
* 翻译音频桥:插件 ↔ 外部翻译服务 的双向接口。
*
* - 「采集面」:ModeHandler 调用 [emitAudioFrame] 把解码后的 PCM 帧推出去。
* 默认实现走 EventChannel 推到 Dart,由 Dart 转发到外部翻译服务。
* 也可以由宿主原生代码替换成自定义实现(比如直接走 gRPC)。
*
* - 「回放面」:[feedTranslatedAudio] 是入口,外部翻译服务调用,把 TTS PCM 注入进来。
* 交给当前 ModeHandler 编码并回送到耳机/A2DP。
*
* - 文本结果(字幕)走 [emitTranslationResult],纯展示用,不影响音频路径。
*/
interface TranslationAudioBridge {
/** 插件→外部 :推一帧采集到的 PCM 音频 */
fun emitAudioFrame(
modeId: Int,
streamId: String,
pcm: ByteArray,
format: AudioFormat,
seq: Long,
tsMs: Long,
isFinal: Boolean = false,
)
/** 插件→外部 :推翻译文本(可选)。requestId 由调用方贯穿同一段翻译,便于字幕拼接 */
fun emitTranslationResult(
modeId: Int,
srcLang: String?,
srcText: String?,
destLang: String?,
destText: String?,
requestId: String? = null,
)
/** 插件→外部 :日志 */
fun emitLog(modeId: Int, content: String)
/** 插件→外部 :错误 */
fun emitError(modeId: Int, code: Int, message: String?)
/** 外部→插件 :注入翻译完成的 TTS PCM;插件负责回送到 outputStreamId 对应的物理通道 */
fun feedTranslatedAudio(
modeId: Int,
outputStreamId: String,
pcm: ByteArray,
format: AudioFormat,
isFinal: Boolean,
): Boolean
}

151
local_plugins/device_jieli/android/src/main/kotlin/com/jielihome/jielihome/feature/translation/TranslationFeature.kt

@ -0,0 +1,151 @@
package com.jielihome.jielihome.feature.translation
import android.content.Context
import com.jieli.bluetooth.impl.JL_BluetoothManager
import com.jielihome.jielihome.feature.ConnectFeature
import com.jielihome.jielihome.feature.translation.mode.AudioTranslationModeHandler
import com.jielihome.jielihome.feature.translation.mode.CallTranslationModeHandler
import com.jielihome.jielihome.feature.translation.mode.FaceToFaceTranslationModeHandler
import com.jielihome.jielihome.feature.translation.mode.RecordModeHandler
import com.jielihome.jielihome.feature.translation.mode.RecordingTranslationModeHandler
import com.jielihome.jielihome.feature.translation.mode.StereoCallTranslationModeHandler
/**
* 翻译特性入口。
*
* # TTS PCM 回灌的两条路径
* 宿主把外部翻译服务回送的 PCM 注入回插件时,**两条路径同时存在、宿主只需选一条**:
*
* 1) Dart / 远端进程 → MethodChannel `feedTranslatedAudio` → MethodRouter →
* `TranslationFeature.feedTranslatedAudio` → 当前 [TranslationModeHandler.onTranslatedAudio]
* 场景:Flutter UI 调用,或宿主原生代码使用通用入口(不区分桥实现时)。
*
* 2) 自定义 native bridge → `TranslationAudioBridge.feedTranslatedAudio` →
* (桥内部决定如何下发;默认实现 [EventChannelAudioBridge] 通过 injector 转回 1)。
* 场景:宿主用 [setBridge] 替换桥,希望把音频生命周期完全留在 native 层管理。
*
* 推荐:除非有性能/隔离强需求,**统一走路径 1**(更简单、字幕和音频走同一条命令通道)。
*
* # 立体声通话翻译
* `MODE_CALL_TRANSLATION` 永远是双流单声道;`MODE_CALL_TRANSLATION_WITH_STEREO`
* 永远走立体声软件分声道方案。是否切到后者由调用方自行判断,可先调
* [isSupportCallTranslationWithStereo] 探测设备能力。
*/
class TranslationFeature(
private val context: Context,
private val btManager: JL_BluetoothManager,
private val connectFeature: ConnectFeature,
) {
@Volatile
private var bridge: TranslationAudioBridge? = null
private var handlers: Map<Int, TranslationModeHandler> = emptyMap()
@Volatile
private var current: TranslationModeHandler? = null
/** 由 JieliHomeServer 在 init 时调一次;后续宿主可调 [setBridge] 替换 */
fun setBridge(bridge: TranslationAudioBridge) {
this.bridge = bridge
// 重建 handlers,把新桥 + 上下文注入下去
handlers = mapOf(
TranslationModeIds.MODE_RECORD to RecordModeHandler(context, btManager, bridge),
TranslationModeIds.MODE_RECORDING_TRANSLATION to RecordingTranslationModeHandler(context, btManager, bridge),
TranslationModeIds.MODE_CALL_TRANSLATION to CallTranslationModeHandler(context, btManager, connectFeature, bridge),
TranslationModeIds.MODE_CALL_TRANSLATION_WITH_STEREO to StereoCallTranslationModeHandler(context, btManager, connectFeature, bridge),
TranslationModeIds.MODE_AUDIO_TRANSLATION to AudioTranslationModeHandler(btManager, bridge),
TranslationModeIds.MODE_FACE_TO_FACE_TRANSLATION to FaceToFaceTranslationModeHandler(context, btManager, bridge),
)
}
fun start(modeId: Int, args: Map<String, Any?>): Result<Unit> {
// 与官方 demo (TranslationViewModel.enterMode) 对齐:MODE_CALL_TRANSLATION
// + OPUS + 设备支持立体声方案时,自动升级到 MODE_CALL_TRANSLATION_WITH_STEREO
// (channel=2)。很多固件版本下 mono CALL_TRANSLATION 通路实际上不出帧,
// 真正出数据的是立体声方案——上下行 mix 成一路立体声 PCM 上推。
val effectiveModeId = if (
modeId == TranslationModeIds.MODE_CALL_TRANSLATION &&
(args["audioType"] == null ||
args["audioType"] == com.jieli.bluetooth.constant.Constants.AUDIO_TYPE_OPUS ||
(args["audioType"] as? String)?.lowercase() == "opus") &&
isSupportCallTranslationWithStereo(args["address"] as? String)
) {
android.util.Log.d(
"TranslationFeature",
"MODE_CALL_TRANSLATION + OPUS + stereo-supported → upgrade to MODE_CALL_TRANSLATION_WITH_STEREO",
)
TranslationModeIds.MODE_CALL_TRANSLATION_WITH_STEREO
} else modeId
val handler = handlers[effectiveModeId]
?: return Result.failure(IllegalArgumentException("unknown modeId=$effectiveModeId"))
current?.takeIf { it.isWorking }?.stop()
handler.start(args)
current = handler
return Result.success(Unit)
}
fun stop() {
current?.takeIf { it.isWorking }?.stop()
current = null
}
fun isWorking(): Boolean = current?.isWorking == true
fun currentModeId(): Int? = current?.modeId
fun currentInputStreams(): List<String> = current?.inputStreams ?: emptyList()
fun currentOutputStreams(): List<String> = current?.outputStreams ?: emptyList()
/**
* 翻译服务回送 TTS PCM 的统一入口。
* 路由到当前活动 ModeHandler 的 onTranslatedAudio。
*/
fun feedTranslatedAudio(
outputStreamId: String,
pcm: ByteArray,
format: AudioFormat,
isFinal: Boolean,
): Boolean {
val h = current ?: return false
if (!h.isWorking) return false
if (outputStreamId !in h.outputStreams) return false
return h.onTranslatedAudio(outputStreamId, pcm, format, isFinal)
}
/** 透传文本结果(字幕);requestId 由调用方贯穿同一段翻译,便于聚合 */
fun feedTranslationResult(
srcLang: String?, srcText: String?,
destLang: String?, destText: String?,
requestId: String? = null,
) {
val mid = current?.modeId ?: return
bridge?.emitTranslationResult(mid, srcLang, srcText, destLang, destText, requestId)
}
/**
* 探测当前已连耳机是否支持立体声通话翻译方案。
* 内部根据 SDK [com.jieli.bluetooth.impl.rcsp.translation.TranslationImpl.isSupportCallTranslationWithStereo]
* 判断;若设备未连或未握手 RCSP 则返回 false。
*/
fun isSupportCallTranslationWithStereo(connectedDeviceAddress: String?): Boolean {
val device = connectedDeviceAddress?.let {
com.jieli.bluetooth.utils.BluetoothUtil.getRemoteDevice(it)
} ?: btManager.connectedDevice ?: return false
val tmp = com.jieli.bluetooth.impl.rcsp.translation.TranslationImpl(
btManager,
com.jielihome.jielihome.feature.translation.runtime.NoOpAITranslationApi(),
device,
)
return runCatching { tmp.isSupportCallTranslationWithStereo }.getOrDefault(false)
.also { runCatching { tmp.destroy() } }
}
/**
* 把外部音频文件解码后的 PCM 灌入「音视频翻译」模式(仅在该模式生效)。
*/
fun feedAudioFilePcm(pcm: ByteArray, sampleRate: Int = 16000): Boolean {
val h = current as? com.jielihome.jielihome.feature.translation.mode.AudioTranslationModeHandler
?: return false
h.feedFilePcm(pcm, sampleRate)
return true
}
}

67
local_plugins/device_jieli/android/src/main/kotlin/com/jielihome/jielihome/feature/translation/TranslationModeHandler.kt

@ -0,0 +1,67 @@
package com.jielihome.jielihome.feature.translation
import com.jieli.bluetooth.impl.JL_BluetoothManager
/**
* 翻译模式处理器统一接口。每种模式独立实现:
* - 声明自己消费哪些「输入流」(采集面)和产出哪些「输出流」(回放面)
* - 实现 start:启动 SDK 端音频流(进入翻译模式 / 起手机麦),把解码后的 PCM 通过 bridge 推出
* - 实现 stop:关闭音频流,释放编解码器
* - 实现 onTranslatedAudio:接到外部翻译服务回送的 PCM,按 outputStreamId 路由
*/
interface TranslationModeHandler {
val modeId: Int
val inputStreams: List<String>
val outputStreams: List<String>
fun start(args: Map<String, Any?>)
fun stop()
val isWorking: Boolean
/** 外部翻译服务回送的 PCM 注入点,由 TranslationFeature 路由到当前活动 handler */
fun onTranslatedAudio(
outputStreamId: String,
pcm: ByteArray,
format: AudioFormat,
isFinal: Boolean,
): Boolean
}
abstract class BaseTranslationModeHandler(
protected val btManager: JL_BluetoothManager,
protected val bridge: TranslationAudioBridge,
) : TranslationModeHandler {
@Volatile
protected var working: Boolean = false
override val isWorking: Boolean get() = working
/** 把采集到的一帧 PCM 推给外部翻译服务 */
protected fun pushFrame(
streamId: String,
pcm: ByteArray,
format: AudioFormat = AudioFormat(),
seq: Long = System.nanoTime(),
isFinal: Boolean = false,
) {
bridge.emitAudioFrame(modeId, streamId, pcm, format, seq, System.currentTimeMillis(), isFinal)
}
protected fun emitLog(content: String) = bridge.emitLog(modeId, content)
protected fun emitError(code: Int, msg: String?) = bridge.emitError(modeId, code, msg)
}
/**
* 与 SDK [com.jieli.bluetooth.bean.translation.TranslationMode] 完全一致的取值,
* 方便直接拿插件 modeId 去构造 SDK 的 TranslationMode。
*/
object TranslationModeIds {
const val MODE_IDLE = 0
const val MODE_RECORD = 1
const val MODE_RECORDING_TRANSLATION = 2
const val MODE_CALL_TRANSLATION = 3
const val MODE_AUDIO_TRANSLATION = 4
const val MODE_FACE_TO_FACE_TRANSLATION = 5
const val MODE_CALL_TRANSLATION_WITH_STEREO = 6
}

27
local_plugins/device_jieli/android/src/main/kotlin/com/jielihome/jielihome/feature/translation/TranslationStreams.kt

@ -0,0 +1,27 @@
package com.jielihome.jielihome.feature.translation
/**
* 翻译流逻辑 ID 常量。
* 「输入流」= 插件解码后推给翻译服务(设备麦克风、eSCO 上行/下行、本地音频文件等)。
* 「输出流」= 翻译服务返回 TTS PCM 后,插件回送的目标(耳机扬声器、A2DP 本地、对端通话上行等)。
*/
object TranslationStreams {
// 输入流:插件解码后推给翻译服务的音频
const val IN_MIC = "in.mic"
const val IN_UPLINK = "in.uplink" // 通话上行:本机用户
const val IN_DOWNLINK = "in.downlink" // 通话下行:对端
const val IN_AUDIO_FILE = "in.audioFile" // 本地音频文件
// 输出流:翻译服务回送 TTS 后插件应送达的目的地
const val OUT_SPEAKER = "out.speaker"
const val OUT_UPLINK = "out.uplink" // 译文给对端听
const val OUT_DOWNLINK = "out.downlink" // 译文给本机用户听
const val OUT_LOCAL_PLAYBACK = "out.localPlayback"
}
/** 一帧 PCM 音频规格 */
data class AudioFormat(
val sampleRate: Int = 16000,
val channels: Int = 1,
val bitsPerSample: Int = 16,
)

61
local_plugins/device_jieli/android/src/main/kotlin/com/jielihome/jielihome/feature/translation/mode/AudioTranslationModeHandler.kt

@ -0,0 +1,61 @@
package com.jielihome.jielihome.feature.translation.mode
import com.jieli.bluetooth.impl.JL_BluetoothManager
import com.jielihome.jielihome.audio.LocalPlayer
import com.jielihome.jielihome.feature.translation.AudioFormat
import com.jielihome.jielihome.feature.translation.BaseTranslationModeHandler
import com.jielihome.jielihome.feature.translation.TranslationAudioBridge
import com.jielihome.jielihome.feature.translation.TranslationModeIds
import com.jielihome.jielihome.feature.translation.TranslationStreams
/**
* MODE_AUDIO_TRANSLATION —— 音视频翻译。
* 输入:本地音频文件(由调用方自行解码并通过 [feedFilePcm] 灌进来,避免插件依赖各种封装格式解析)
* 输出:本机扬声器(A2DP),不回耳机
*/
class AudioTranslationModeHandler(
btManager: JL_BluetoothManager,
bridge: TranslationAudioBridge,
) : BaseTranslationModeHandler(btManager, bridge) {
override val modeId = TranslationModeIds.MODE_AUDIO_TRANSLATION
override val inputStreams = listOf(TranslationStreams.IN_AUDIO_FILE)
override val outputStreams = listOf(TranslationStreams.OUT_LOCAL_PLAYBACK)
private var player: LocalPlayer? = null
override fun start(args: Map<String, Any?>) {
if (working) return
val sampleRate = (args["sampleRate"] as? Int) ?: 16000
val channels = (args["channels"] as? Int) ?: 1
player = LocalPlayer(sampleRate, channels).also { it.start() }
working = true
emitLog("AudioTranslation start sampleRate=$sampleRate channels=$channels")
}
/** 调用方把解码出的 PCM 灌进来推给翻译服务 */
@Suppress("unused")
fun feedFilePcm(pcm: ByteArray, sampleRate: Int = 16000) {
if (!working) return
pushFrame(TranslationStreams.IN_AUDIO_FILE, pcm, AudioFormat(sampleRate, 1, 16))
}
override fun stop() {
if (!working) return
player?.stop()
player = null
working = false
emitLog("AudioTranslation stop")
}
override fun onTranslatedAudio(
outputStreamId: String,
pcm: ByteArray,
format: AudioFormat,
isFinal: Boolean,
): Boolean {
if (outputStreamId != TranslationStreams.OUT_LOCAL_PLAYBACK) return false
player?.feed(pcm)
return true
}
}

138
local_plugins/device_jieli/android/src/main/kotlin/com/jielihome/jielihome/feature/translation/mode/CallTranslationModeHandler.kt

@ -0,0 +1,138 @@
package com.jielihome.jielihome.feature.translation.mode
import android.content.Context
import com.jieli.bluetooth.bean.translation.AudioData
import com.jieli.bluetooth.bean.translation.TranslationMode
import com.jieli.bluetooth.constant.Constants
import com.jieli.bluetooth.impl.JL_BluetoothManager
import com.jielihome.jielihome.feature.ConnectFeature
import com.jielihome.jielihome.feature.translation.AudioFormat
import com.jielihome.jielihome.feature.translation.BaseTranslationModeHandler
import com.jielihome.jielihome.feature.translation.TranslationAudioBridge
import com.jielihome.jielihome.feature.translation.TranslationModeIds
import com.jielihome.jielihome.feature.translation.TranslationStreams
import com.jielihome.jielihome.feature.translation.runtime.RcspTranslationRuntime
import java.io.File
/**
* MODE_CALL_TRANSLATION —— 通话翻译,eSCO 上下行各一路单声道。
* 输入:uplink(本机用户)+ downlink(对端)
* 输出:uplink(TTS 给对端听)+ downlink(TTS 给本机用户听)
*
* 实现:通过 [RcspTranslationRuntime] 让耳机进入 SDK 翻译模式,
* OPUS 流式解码后按 source 分流推到 bridge;
* 回送 TTS PCM 时编码 OPUS 后通过同一 runtime 写回耳机。
*/
class CallTranslationModeHandler(
private val context: Context,
btManager: JL_BluetoothManager,
private val connectFeature: ConnectFeature,
bridge: TranslationAudioBridge,
) : BaseTranslationModeHandler(btManager, bridge) {
override val modeId = TranslationModeIds.MODE_CALL_TRANSLATION
override val inputStreams = listOf(TranslationStreams.IN_UPLINK, TranslationStreams.IN_DOWNLINK)
override val outputStreams = listOf(TranslationStreams.OUT_UPLINK, TranslationStreams.OUT_DOWNLINK)
private var runtime: RcspTranslationRuntime? = null
override fun start(args: Map<String, Any?>) {
if (working) return
val address = args["address"] as? String
?: connectFeature.connectedDevice()?.address
val device = address?.let { connectFeature.deviceByAddress(it) }
?: connectFeature.connectedDevice()
if (device == null) {
emitError(-200, "no connected device; pass args.address or connect first")
return
}
val sampleRate = (args["sampleRate"] as? Int) ?: 16000
val audioType = parseAudioType(args["audioType"]) ?: Constants.AUDIO_TYPE_OPUS
// Recording strategy 选择:
// STRATEGY_DEVICE_AUTO_RECORDING(2) —— 耳机自动检测真实 SCO 通话才开始录,
// 手机端没在打电话时耳机不会上推 PCM,本场景(双向 AI 通话翻译)行不通。
// STRATEGY_DEVICE_ALWAYS_RECORDING(1) —— 一进入翻译模式就持续上推双声道
// PCM,正是 AI 通话翻译需要的"始终开麦"语义。
// 允许通过 args["strategy"] 覆盖(数值或字符串 "auto"/"always"/"custom")。
val strategy = parseStrategy(args["strategy"])
?: TranslationMode.STRATEGY_DEVICE_ALWAYS_RECORDING
val sdkMode = TranslationMode(
TranslationMode.MODE_CALL_TRANSLATION,
audioType,
1, // 单声道
sampleRate,
).setRecordingStrategy(strategy)
val rt = RcspTranslationRuntime(
btManager = btManager,
device = device,
mode = sdkMode,
tempDir = File(context.cacheDir, "jieli_translation_tts"),
onPcm = { source, pcm ->
val streamId = when (source) {
AudioData.SOURCE_E_SCO_UP_LINK -> TranslationStreams.IN_UPLINK
AudioData.SOURCE_E_SCO_DOWN_LINK -> TranslationStreams.IN_DOWNLINK
else -> return@RcspTranslationRuntime
}
pushFrame(streamId, pcm, AudioFormat(sampleRate, 1, 16))
},
onError = { code, msg -> emitError(code, msg) }
)
rt.start().fold(
{
runtime = rt
working = true
emitLog("CallTranslation start device=${device.address} audioType=$audioType sampleRate=$sampleRate")
},
{ emitError(-201, it.message); rt.stop() },
)
}
private fun parseStrategy(raw: Any?): Int? = when (raw) {
null -> null
is Int -> raw
is Number -> raw.toInt()
is String -> when (raw.lowercase()) {
"custom" -> TranslationMode.STRATEGY_CUSTOM_RECORDING
"always" -> TranslationMode.STRATEGY_DEVICE_ALWAYS_RECORDING
"auto" -> TranslationMode.STRATEGY_DEVICE_AUTO_RECORDING
else -> raw.toIntOrNull()
}
else -> null
}
private fun parseAudioType(raw: Any?): Int? = when (raw) {
null -> null
is Int -> raw
is Number -> raw.toInt()
is String -> when (raw.lowercase()) {
"opus" -> Constants.AUDIO_TYPE_OPUS
"pcm" -> Constants.AUDIO_TYPE_PCM
"speex" -> Constants.AUDIO_TYPE_SPEEX
"msbc", "m_sbc" -> Constants.AUDIO_TYPE_M_SBC
"jla_v2", "jlav2" -> Constants.AUDIO_TYPE_JLA_V2
else -> raw.toIntOrNull()
}
else -> null
}
override fun stop() {
if (!working) return
runtime?.stop()
runtime = null
working = false
emitLog("CallTranslation stop")
}
override fun onTranslatedAudio(
outputStreamId: String,
pcm: ByteArray,
format: AudioFormat,
isFinal: Boolean,
): Boolean {
if (outputStreamId != TranslationStreams.OUT_UPLINK &&
outputStreamId != TranslationStreams.OUT_DOWNLINK) return false
return runtime?.feedTtsPcm(outputStreamId, pcm, isFinal) ?: false
}
}

63
local_plugins/device_jieli/android/src/main/kotlin/com/jielihome/jielihome/feature/translation/mode/FaceToFaceTranslationModeHandler.kt

@ -0,0 +1,63 @@
package com.jielihome.jielihome.feature.translation.mode
import android.content.Context
import com.jieli.bluetooth.impl.JL_BluetoothManager
import com.jielihome.jielihome.audio.LocalPlayer
import com.jielihome.jielihome.audio.PhoneMicCapture
import com.jielihome.jielihome.feature.translation.AudioFormat
import com.jielihome.jielihome.feature.translation.BaseTranslationModeHandler
import com.jielihome.jielihome.feature.translation.TranslationAudioBridge
import com.jielihome.jielihome.feature.translation.TranslationModeIds
import com.jielihome.jielihome.feature.translation.TranslationStreams
/**
* MODE_FACE_TO_FACE_TRANSLATION —— 面对面翻译。
* 输入:手机麦
* 输出:本机扬声器(自带的 LocalPlayer,A2DP/外放都行)
*/
class FaceToFaceTranslationModeHandler(
private val context: Context,
btManager: JL_BluetoothManager,
bridge: TranslationAudioBridge,
) : BaseTranslationModeHandler(btManager, bridge) {
override val modeId = TranslationModeIds.MODE_FACE_TO_FACE_TRANSLATION
override val inputStreams = listOf(TranslationStreams.IN_MIC)
override val outputStreams = listOf(TranslationStreams.OUT_SPEAKER)
private var mic: PhoneMicCapture? = null
private var player: LocalPlayer? = null
override fun start(args: Map<String, Any?>) {
if (working) return
val sampleRate = (args["sampleRate"] as? Int) ?: 16000
mic = PhoneMicCapture(
context, sampleRate,
onFrame = { pcm -> pushFrame(TranslationStreams.IN_MIC, pcm, AudioFormat(sampleRate, 1, 16)) },
onError = { code, msg -> emitError(code, msg) },
)
if (mic?.start() != true) return
player = LocalPlayer(sampleRate, 1).also { it.start() }
working = true
emitLog("FaceToFaceTranslation start")
}
override fun stop() {
if (!working) return
mic?.stop(); mic = null
player?.stop(); player = null
working = false
emitLog("FaceToFaceTranslation stop")
}
override fun onTranslatedAudio(
outputStreamId: String,
pcm: ByteArray,
format: AudioFormat,
isFinal: Boolean,
): Boolean {
if (outputStreamId != TranslationStreams.OUT_SPEAKER) return false
player?.feed(pcm)
return true
}
}

64
local_plugins/device_jieli/android/src/main/kotlin/com/jielihome/jielihome/feature/translation/mode/RecordModeHandler.kt

@ -0,0 +1,64 @@
package com.jielihome.jielihome.feature.translation.mode
import android.content.Context
import com.jieli.bluetooth.impl.JL_BluetoothManager
import com.jielihome.jielihome.audio.PhoneMicCapture
import com.jielihome.jielihome.feature.translation.AudioFormat
import com.jielihome.jielihome.feature.translation.BaseTranslationModeHandler
import com.jielihome.jielihome.feature.translation.TranslationAudioBridge
import com.jielihome.jielihome.feature.translation.TranslationModeIds
import com.jielihome.jielihome.feature.translation.TranslationStreams
/**
* MODE_RECORD —— 单向录音翻译。
* 输入:手机麦 → 推到外部
* 输出:speaker ← 外部回送 TTS PCM;当前不依赖耳机回送,直接交给宿主自行播放(可选)。
*
* 注:本模式完全不动 RCSP,只用手机麦。耳机端不需要进入翻译模式。
*/
class RecordModeHandler(
private val context: Context,
btManager: JL_BluetoothManager,
bridge: TranslationAudioBridge,
) : BaseTranslationModeHandler(btManager, bridge) {
override val modeId = TranslationModeIds.MODE_RECORD
override val inputStreams = listOf(TranslationStreams.IN_MIC)
override val outputStreams = listOf(TranslationStreams.OUT_SPEAKER)
private var mic: PhoneMicCapture? = null
override fun start(args: Map<String, Any?>) {
if (working) return
val sampleRate = (args["sampleRate"] as? Int) ?: 16000
val frameMs = (args["frameDurationMs"] as? Int) ?: 20
mic = PhoneMicCapture(
context, sampleRate, frameMs,
onFrame = { pcm -> pushFrame(TranslationStreams.IN_MIC, pcm, AudioFormat(sampleRate, 1, 16)) },
onError = { code, msg -> emitError(code, msg) },
)
if (mic?.start() != true) return
working = true
emitLog("RecordMode start sampleRate=$sampleRate")
}
override fun stop() {
if (!working) return
mic?.stop()
mic = null
working = false
emitLog("RecordMode stop")
}
override fun onTranslatedAudio(
outputStreamId: String,
pcm: ByteArray,
format: AudioFormat,
isFinal: Boolean,
): Boolean {
if (outputStreamId != TranslationStreams.OUT_SPEAKER) return false
// RECORD 模式下耳机不参与回送;如果宿主想播给用户听,自己用 AudioTrack 播即可。
emitLog("recv tts pcm=${pcm.size}B final=$isFinal (host-side playback)")
return true
}
}

59
local_plugins/device_jieli/android/src/main/kotlin/com/jielihome/jielihome/feature/translation/mode/RecordingTranslationModeHandler.kt

@ -0,0 +1,59 @@
package com.jielihome.jielihome.feature.translation.mode
import android.content.Context
import com.jieli.bluetooth.impl.JL_BluetoothManager
import com.jielihome.jielihome.audio.PhoneMicCapture
import com.jielihome.jielihome.feature.translation.AudioFormat
import com.jielihome.jielihome.feature.translation.BaseTranslationModeHandler
import com.jielihome.jielihome.feature.translation.TranslationAudioBridge
import com.jielihome.jielihome.feature.translation.TranslationModeIds
import com.jielihome.jielihome.feature.translation.TranslationStreams
/**
* MODE_RECORDING_TRANSLATION —— 流式录音翻译。等同于 RecordMode 的实时版,差别只在语义:
* 调用方实现实时 STT/翻译流水线、不缓存整段录音再发。
*/
class RecordingTranslationModeHandler(
private val context: Context,
btManager: JL_BluetoothManager,
bridge: TranslationAudioBridge,
) : BaseTranslationModeHandler(btManager, bridge) {
override val modeId = TranslationModeIds.MODE_RECORDING_TRANSLATION
override val inputStreams = listOf(TranslationStreams.IN_MIC)
override val outputStreams = listOf(TranslationStreams.OUT_SPEAKER)
private var mic: PhoneMicCapture? = null
override fun start(args: Map<String, Any?>) {
if (working) return
val sampleRate = (args["sampleRate"] as? Int) ?: 16000
mic = PhoneMicCapture(
context, sampleRate,
onFrame = { pcm -> pushFrame(TranslationStreams.IN_MIC, pcm, AudioFormat(sampleRate, 1, 16)) },
onError = { code, msg -> emitError(code, msg) },
)
if (mic?.start() != true) return
working = true
emitLog("RecordingTranslation start sampleRate=$sampleRate")
}
override fun stop() {
if (!working) return
mic?.stop()
mic = null
working = false
emitLog("RecordingTranslation stop")
}
override fun onTranslatedAudio(
outputStreamId: String,
pcm: ByteArray,
format: AudioFormat,
isFinal: Boolean,
): Boolean {
if (outputStreamId != TranslationStreams.OUT_SPEAKER) return false
emitLog("recv tts ${pcm.size}B final=$isFinal")
return true
}
}

128
local_plugins/device_jieli/android/src/main/kotlin/com/jielihome/jielihome/feature/translation/mode/StereoCallTranslationModeHandler.kt

@ -0,0 +1,128 @@
package com.jielihome.jielihome.feature.translation.mode
import android.content.Context
import com.jieli.bluetooth.bean.translation.AudioData
import com.jieli.bluetooth.bean.translation.TranslationMode
import com.jieli.bluetooth.constant.Constants
import com.jieli.bluetooth.impl.JL_BluetoothManager
import com.jielihome.jielihome.audio.PcmKit
import com.jielihome.jielihome.feature.ConnectFeature
import com.jielihome.jielihome.feature.translation.AudioFormat
import com.jielihome.jielihome.feature.translation.BaseTranslationModeHandler
import com.jielihome.jielihome.feature.translation.TranslationAudioBridge
import com.jielihome.jielihome.feature.translation.TranslationModeIds
import com.jielihome.jielihome.feature.translation.TranslationStreams
import com.jielihome.jielihome.feature.translation.runtime.RcspTranslationRuntime
import java.io.File
/**
* MODE_CALL_TRANSLATION_WITH_STEREO —— 立体声通话翻译。
* SDK 上来一路立体声 PCM(左=上行,右=下行),软件分离后等价于普通通话翻译两路。
*/
class StereoCallTranslationModeHandler(
private val context: Context,
btManager: JL_BluetoothManager,
private val connectFeature: ConnectFeature,
bridge: TranslationAudioBridge,
) : BaseTranslationModeHandler(btManager, bridge) {
override val modeId = TranslationModeIds.MODE_CALL_TRANSLATION_WITH_STEREO
override val inputStreams = listOf(TranslationStreams.IN_UPLINK, TranslationStreams.IN_DOWNLINK)
override val outputStreams = listOf(TranslationStreams.OUT_UPLINK, TranslationStreams.OUT_DOWNLINK)
private var runtime: RcspTranslationRuntime? = null
override fun start(args: Map<String, Any?>) {
if (working) return
val address = args["address"] as? String
?: connectFeature.connectedDevice()?.address
val device = address?.let { connectFeature.deviceByAddress(it) }
?: connectFeature.connectedDevice()
if (device == null) {
emitError(-200, "no connected device")
return
}
val sampleRate = (args["sampleRate"] as? Int) ?: 16000
val audioType = parseAudioType(args["audioType"]) ?: Constants.AUDIO_TYPE_OPUS
// 与 demo 对齐:默认 ALWAYS_RECORDING,进 mode 即持续上推 PCM;
// AUTO 会在没有真实通话事件时静默不出帧。
val strategy = parseStrategy(args["strategy"])
?: TranslationMode.STRATEGY_DEVICE_ALWAYS_RECORDING
val sdkMode = TranslationMode(
TranslationMode.MODE_CALL_TRANSLATION_WITH_STEREO,
audioType,
2, // 立体声
sampleRate,
).setRecordingStrategy(strategy)
val rt = RcspTranslationRuntime(
btManager = btManager,
device = device,
mode = sdkMode,
tempDir = File(context.cacheDir, "jieli_translation_tts"),
onPcm = { source, stereoPcm ->
if (source != AudioData.SOURCE_E_SCO_MIX) return@RcspTranslationRuntime
val (left, right) = PcmKit.splitStereo16(stereoPcm)
val fmt = AudioFormat(sampleRate, 1, 16)
pushFrame(TranslationStreams.IN_UPLINK, left, fmt)
pushFrame(TranslationStreams.IN_DOWNLINK, right, fmt)
},
onError = { code, msg -> emitError(code, msg) }
)
rt.start().fold(
{
runtime = rt
working = true
emitLog("StereoCallTranslation start device=${device.address} audioType=$audioType sampleRate=$sampleRate")
},
{ emitError(-201, it.message); rt.stop() },
)
}
private fun parseStrategy(raw: Any?): Int? = when (raw) {
null -> null
is Int -> raw
is Number -> raw.toInt()
is String -> when (raw.lowercase()) {
"custom" -> TranslationMode.STRATEGY_CUSTOM_RECORDING
"always" -> TranslationMode.STRATEGY_DEVICE_ALWAYS_RECORDING
"auto" -> TranslationMode.STRATEGY_DEVICE_AUTO_RECORDING
else -> raw.toIntOrNull()
}
else -> null
}
private fun parseAudioType(raw: Any?): Int? = when (raw) {
null -> null
is Int -> raw
is Number -> raw.toInt()
is String -> when (raw.lowercase()) {
"opus" -> Constants.AUDIO_TYPE_OPUS
"pcm" -> Constants.AUDIO_TYPE_PCM
"speex" -> Constants.AUDIO_TYPE_SPEEX
"msbc", "m_sbc" -> Constants.AUDIO_TYPE_M_SBC
"jla_v2", "jlav2" -> Constants.AUDIO_TYPE_JLA_V2
else -> raw.toIntOrNull()
}
else -> null
}
override fun stop() {
if (!working) return
runtime?.stop()
runtime = null
working = false
emitLog("StereoCallTranslation stop")
}
override fun onTranslatedAudio(
outputStreamId: String,
pcm: ByteArray,
format: AudioFormat,
isFinal: Boolean,
): Boolean {
if (outputStreamId != TranslationStreams.OUT_UPLINK &&
outputStreamId != TranslationStreams.OUT_DOWNLINK) return false
return runtime?.feedTtsPcm(outputStreamId, pcm, isFinal) ?: false
}
}

17
local_plugins/device_jieli/android/src/main/kotlin/com/jielihome/jielihome/feature/translation/runtime/NoOpAITranslationApi.kt

@ -0,0 +1,17 @@
package com.jielihome.jielihome.feature.translation.runtime
import com.jieli.bluetooth.bean.translation.AudioData
import com.jieli.bluetooth.bean.translation.TranslationMode
import com.jieli.bluetooth.interfaces.rcsp.translation.AITranslationCallback
import com.jieli.bluetooth.interfaces.rcsp.translation.IAITranslationApi
/**
* SDK 的 [TranslationImpl] 构造函数要求一个 [IAITranslationApi]。
* 我们走的是「外部翻译服务」路线,SDK 自带的 AI 流程不启用,所以这里给个空实现。
*/
internal class NoOpAITranslationApi : IAITranslationApi {
override fun isWorking(): Boolean = false
override fun startTranslating(mode: TranslationMode, cb: AITranslationCallback) { /* no-op */ }
override fun stopTranslating() { /* no-op */ }
override fun writeAudio(data: AudioData) { /* no-op */ }
}

470
local_plugins/device_jieli/android/src/main/kotlin/com/jielihome/jielihome/feature/translation/runtime/RcspTranslationRuntime.kt

@ -0,0 +1,470 @@
package com.jielihome.jielihome.feature.translation.runtime
import android.bluetooth.BluetoothDevice
import com.jieli.bluetooth.bean.translation.AudioData
import com.jieli.bluetooth.bean.translation.TranslationMode
import com.jieli.bluetooth.constant.Constants
import com.jieli.bluetooth.impl.JL_BluetoothManager
import com.jieli.bluetooth.impl.rcsp.translation.TranslationImpl
import com.jieli.bluetooth.interfaces.rcsp.callback.OnRcspActionCallback
import com.jieli.bluetooth.interfaces.rcsp.translation.TranslationCallback
import android.util.Log
import com.jielihome.jielihome.audio.OpusStreamDecoder
import com.jielihome.jielihome.feature.translation.TranslationStreams
import com.jieli.jl_audio_decode.callback.OnStateCallback
import com.jieli.jl_audio_decode.opus.OpusManager
import java.io.ByteArrayOutputStream
import java.io.File
import java.util.ArrayDeque
import java.util.concurrent.Executors
import java.util.concurrent.ScheduledExecutorService
import java.util.concurrent.TimeUnit
import java.util.concurrent.atomic.AtomicBoolean
/**
* RCSP 翻译模式运行时。
*
* audioType:
* - [Constants.AUDIO_TYPE_OPUS](默认):上行 OPUS → 解码 PCM;下行 PCM → **整段** 编码 OPUS 写回
* - [Constants.AUDIO_TYPE_PCM]:上下行直接走 PCM,不经编解码
*
* # TTS 回灌策略(与官方 demo `MachineTranslation.tryToTTS` / `OpusHelper.encodeFile` 对齐)
*
* 之前是流式 [com.jielihome.jielihome.audio.OpusStreamEncoder]:每出一帧 OPUS 立即包成
* AudioData 调 [TranslationImpl.writeAudioData],导致耳机端 RCSP 把每帧当成"独立的一段
* TTS 起点",解码器频繁 reset → 杂音 / 断续。
*
* 现在按 demo:[feedTtsPcm] 把 PCM 累积到 per-leg 缓冲;上层在每段 utterance 末尾把
* `isFinal=true` 透传过来;runtime 这一刻:
* 1. 把累积 PCM 写到临时 .pcm 文件;
* 2. `OpusManager.encodeFile(pcmFile, opusFile, callback)` 离线整段编码(带 head 的默认 OpusOption);
* 3. 整段读出 → **一个** [AudioData] → [WriteScheduler] 单次入队下发。
*
* # 文件命名
* 临时文件位于 [tempDir],按 `tts_<source>_<seq>.<pcm|opus>` 命名;编码完成异步删除。
*
* # source 字段写回方向
* - 通话翻译给「对端听」 → AudioData.source = SOURCE_E_SCO_UP_LINK
* - 通话翻译给「本机听」 → AudioData.source = SOURCE_E_SCO_DOWN_LINK
* - 录音/音视频/面对面 → AudioData.source = SOURCE_PHONE_MIC(SDK 按 mode 自分发)
*/
class RcspTranslationRuntime(
private val btManager: JL_BluetoothManager,
private val device: BluetoothDevice,
private val mode: TranslationMode,
private val tempDir: File,
/** 解码(或直传 PCM)后的音频上行;source 为 SDK 原值 */
private val onPcm: (source: Int, pcm: ByteArray) -> Unit,
private val onError: (code: Int, msg: String?) -> Unit,
private val opusPacketSize: Int = if (mode.channel == 2) 80 else 200,
) {
private val translationImpl = TranslationImpl(btManager, NoOpAITranslationApi(), device)
private val isPcmMode = mode.audioType == Constants.AUDIO_TYPE_PCM
private val sampleRateHz = mode.sampleRate.takeIf { it > 0 } ?: 16000
/** 入栈解码器:仅 OPUS 模式下创建 */
private val upDecoder: OpusStreamDecoder? = if (isPcmMode) null else OpusStreamDecoder(
channel = 1,
packetSize = opusPacketSize,
sampleRate = sampleRateHz,
onPcm = { pcm -> onPcm(AudioData.SOURCE_E_SCO_UP_LINK, pcm) },
onError = { c, m -> onError(c, "upDecoder: $m") },
)
private val downDecoder: OpusStreamDecoder? =
if (!isPcmMode && mode.mode == TranslationMode.MODE_CALL_TRANSLATION) OpusStreamDecoder(
channel = 1,
packetSize = opusPacketSize,
sampleRate = sampleRateHz,
onPcm = { pcm -> onPcm(AudioData.SOURCE_E_SCO_DOWN_LINK, pcm) },
onError = { c, m -> onError(c, "downDecoder: $m") },
) else null
private val stereoDecoder: OpusStreamDecoder? =
if (!isPcmMode && mode.mode == TranslationMode.MODE_CALL_TRANSLATION_WITH_STEREO) OpusStreamDecoder(
channel = 2, packetSize = 80,
sampleRate = sampleRateHz,
onPcm = { pcm -> onPcm(AudioData.SOURCE_E_SCO_MIX, pcm) },
onError = { c, m -> onError(c, "stereoDecoder: $m") },
) else null
/** 调试统计:SDK onReceiveAudioData 被回调多少次、按 (source,type) 分桶 */
@Volatile private var rxCount = 0L
@Volatile private var rxTypeMismatch = 0L
@Volatile private var rxNullPayload = 0L
@Volatile private var rxLastReportMs = 0L
private val translationCallback = object : TranslationCallback {
override fun onModeChange(d: BluetoothDevice, m: TranslationMode) {
Log.d(TAG, "TranslationCallback.onModeChange: mode=${m.mode} type=${m.audioType} sr=${m.sampleRate} ch=${m.channel}")
}
override fun onReceiveAudioData(d: BluetoothDevice, data: AudioData) {
rxCount++
val now = System.currentTimeMillis()
if (now - rxLastReportMs >= 1000L) {
Log.d(TAG, "RX stats (last 1s): total=$rxCount typeMismatch=$rxTypeMismatch nullPayload=$rxNullPayload expect.type=${mode.audioType} last.source=${data.source} last.type=${data.type} payloadSize=${data.audioData?.size ?: 0}")
rxCount = 0; rxTypeMismatch = 0; rxNullPayload = 0; rxLastReportMs = now
}
if (data.type != mode.audioType) {
rxTypeMismatch++
return
}
val payload = data.audioData
if (payload == null) {
rxNullPayload++
return
}
if (isPcmMode) {
onPcm(data.source, payload)
return
}
when (data.source) {
AudioData.SOURCE_E_SCO_UP_LINK -> upDecoder?.feedEncoded(payload)
AudioData.SOURCE_E_SCO_DOWN_LINK -> downDecoder?.feedEncoded(payload)
AudioData.SOURCE_E_SCO_MIX -> stereoDecoder?.feedEncoded(payload)
}
}
override fun onError(d: BluetoothDevice, code: Int, msg: String) {
Log.e(TAG, "TranslationCallback.onError code=$code msg=$msg")
this@RcspTranslationRuntime.onError(code, msg)
}
}
companion object {
private const val TAG = "RcspTranslationRuntime"
/** 每腿 writeAudioData 缓冲队列上限。整段下发后队列里几乎不会堆积,留 8 个槽足够。 */
private const val WRITE_QUEUE_LIMIT = 8
/** 单段 PCM 上限:≈ 60s 16k mono 16bit = 1.92MB,正常 utterance 远低于此值。 */
private const val PCM_BUFFER_HARD_LIMIT_BYTES = 2 * 1024 * 1024
/**
* 周期性 flush 间隔:每隔此毫秒数巡检一次 buffer,**只要非空就 flush**。
*
* 实测:低于 1s(试过 200ms)耳机端 RCSP 解码器会被频繁 reset 直至死机,
* 因此周期上限只能保持 1s。降低段间延迟改走"首段优先 flush"路线(B)。
*/
private const val PERIODIC_FLUSH_MS = 1_000L
/**
* 首段优先 flush 阈值(路线 B):每个 utterance 的**首段**达到此毫秒数对应字节量
* 就立即 flush,不等周期。让用户开口到对方听到的延迟尽可能小(< 200ms)。
*
* 触发后转入周期模式直到 isFinal=true 重置。火山不发 isFinal 的会话里,
* 只有第一句享受首段优先;后续句统一走 [PERIODIC_FLUSH_MS] 周期。这是为了
* 避免连续讲话被反复识别成"新 utterance"导致每 100ms 切一段、reset 频率失控。
*/
private const val FIRST_SEGMENT_FLUSH_MS = 100L
}
/**
* 串行化的 writeAudioData 调度器(每个 source 一个)。
*
* RCSP/SPP 写入要求:上一帧 [writeAudioData] 的回调返回(成功或失败)之前不要再发,
* 否则 SDK 会拒收新请求并报 "Operation in progress"。这里维护一个 in-flight ≤ 1
* 的 FIFO 队列,溢出时丢最老的帧(保实时性,不堆积延迟)。
*/
private class WriteScheduler(
private val tag: String,
private val send: (AudioData, OnRcspActionCallback<Boolean>) -> Unit,
) {
private val queue = ArrayDeque<AudioData>(WRITE_QUEUE_LIMIT)
private val inFlight = AtomicBoolean(false)
private val lock = Any()
@Volatile private var droppedSinceReport = 0L
@Volatile private var lastReportMs = 0L
fun enqueue(data: AudioData) {
val toSend: AudioData? = synchronized(lock) {
if (inFlight.compareAndSet(false, true)) {
data
} else {
if (queue.size >= WRITE_QUEUE_LIMIT) {
queue.pollFirst() // 丢最老
droppedSinceReport++
val now = System.currentTimeMillis()
if (now - lastReportMs >= 1000L) {
Log.w(TAG, "writeAudioData[$tag] queue full, dropped=$droppedSinceReport (last 1s)")
droppedSinceReport = 0
lastReportMs = now
}
}
queue.offerLast(data)
null
}
}
if (toSend != null) dispatch(toSend)
}
private fun dispatch(data: AudioData) {
send(data, object : OnRcspActionCallback<Boolean> {
override fun onSuccess(d: BluetoothDevice?, ok: Boolean?) = onComplete()
override fun onError(d: BluetoothDevice?, err: com.jieli.bluetooth.bean.base.BaseError?) {
val msg = err?.message.orEmpty()
if (!msg.contains("Operation in progress", ignoreCase = true)) {
Log.w(TAG, "writeAudioData[$tag] error: code=${err?.code} msg=$msg")
}
onComplete()
}
})
}
private fun onComplete() {
val next: AudioData? = synchronized(lock) {
val n = queue.pollFirst()
if (n == null) inFlight.set(false)
n
}
if (next != null) dispatch(next)
}
fun clear() {
synchronized(lock) {
queue.clear()
inFlight.set(false)
}
}
}
private val uplinkWriter = WriteScheduler("uplink") { data, cb ->
translationImpl.writeAudioData(data, cb)
}
private val downlinkWriter = WriteScheduler("downlink") { data, cb ->
translationImpl.writeAudioData(data, cb)
}
private val phoneMicWriter = WriteScheduler("phoneMic") { data, cb ->
translationImpl.writeAudioData(data, cb)
}
/** 兜底:PCM 直传 / 未识别 source 走这条;和 OPUS 编码器互不抢 in-flight 槽位。 */
private val rawWriter = WriteScheduler("raw") { data, cb ->
translationImpl.writeAudioData(data, cb)
}
/**
* Per-leg PCM 缓冲。`feedTtsPcm` 累积写入,`isFinal=true` 时整段编码下发后清空。
*
* key = outputStreamId(OUT_UPLINK / OUT_DOWNLINK / OUT_SPEAKER 等)
*/
private val pcmBuffers = mutableMapOf<String, ByteArrayOutputStream>()
/** 每条 leg 是否仍处于"等待首段触发"状态(路线 B 首段优先 flush 用)。 */
private val firstSegmentPending = mutableMapOf<String, Boolean>()
private val bufferLock = Any()
private var encodeSeq = 0L
/** 周期性 flush 巡检线程;start() 启动,stop() 关闭。 */
private var flushWatcher: ScheduledExecutorService? = null
/** 启动前置校验 + 进入翻译模式 */
fun start(): Result<Unit> {
if (!translationImpl.isInit) {
return Result.failure(IllegalStateException("RCSP not init for ${device.address}"))
}
if (!translationImpl.isSupportTranslation) {
return Result.failure(IllegalStateException("device does not support translation"))
}
if (mode.mode == TranslationMode.MODE_CALL_TRANSLATION_WITH_STEREO &&
!translationImpl.isSupportCallTranslationWithStereo
) {
return Result.failure(IllegalStateException("device does not support stereo call translation"))
}
if (!tempDir.exists()) tempDir.mkdirs()
upDecoder?.start()
downDecoder?.start()
stereoDecoder?.start()
translationImpl.addTranslationCallback(translationCallback)
Log.d(TAG, "enterMode: mode=${mode.mode} type=${mode.audioType} sr=${mode.sampleRate} ch=${mode.channel} strategy=${mode.recordingStrategy} (waiting for onModeChange/onReceiveAudioData)")
translationImpl.enterMode(mode, translationCallback)
// OPUS 模式启动周期性 flush 巡检:每 [PERIODIC_FLUSH_MS] 切一次缓冲。
if (!isPcmMode) startPeriodicFlushWatcher()
return Result.success(Unit)
}
private fun startPeriodicFlushWatcher() {
flushWatcher?.shutdownNow()
val exec = Executors.newSingleThreadScheduledExecutor { r ->
Thread(r, "rcsp-tts-flush").apply { isDaemon = true }
}
flushWatcher = exec
exec.scheduleAtFixedRate(
{ runCatching { periodicFlush() } },
PERIODIC_FLUSH_MS, PERIODIC_FLUSH_MS, TimeUnit.MILLISECONDS,
)
}
/** 每秒巡检:每条 leg 的 buffer 只要非空就立即切走 + 编码 + 下发(不依赖任何外部信号)。 */
private fun periodicFlush() {
val toEncode = mutableListOf<Triple<Int, String, ByteArray>>()
synchronized(bufferLock) {
for ((leg, buf) in pcmBuffers) {
if (buf.size() == 0) continue
val source = when (leg) {
TranslationStreams.OUT_UPLINK -> AudioData.SOURCE_E_SCO_UP_LINK
TranslationStreams.OUT_DOWNLINK -> AudioData.SOURCE_E_SCO_DOWN_LINK
else -> AudioData.SOURCE_PHONE_MIC
}
val out = buf.toByteArray()
buf.reset()
Log.d(TAG, "periodicFlush: leg=$leg bytes=${out.size}")
toEncode.add(Triple(source, leg, out))
}
}
// encode 走 async,必须释放锁后做。
for ((source, leg, pcm) in toEncode) {
encodeAndDispatchAsync(source, leg, pcm)
}
}
/**
* 把外部翻译服务回送的 PCM 注入回耳机。
*
* 策略:
* - PCM 模式:每次调用直接当作一段 AudioData 下发(SDK 内部按 blockMtu 分片)。
* - OPUS 模式:纯时间驱动 flush —— 火山等端到端服务的段尾事件不可靠,
* 不能依赖 isFinal 或字节量阈值。改为:
* a) feedTtsPcm 仅追加 buffer,不主动 flush(除非 isFinal=true);
* b) [PERIODIC_FLUSH_MS] (1s) 巡检线程每秒切走 buffer 并整段编码下发;
* c) `isFinal=true` 仍短路立即 flush(兼容主动信号,不再依赖)。
* 最大听感延迟 ≤ 1s,且短句 / 没有段尾事件的服务都不会卡死。
*
* @param outputStreamId 决定 source:
* [TranslationStreams.OUT_UPLINK] / [TranslationStreams.OUT_DOWNLINK] 用于通话翻译;
* 其它(speaker/localPlayback)由 ModeHandler 自己处理,不会落到这里。
* @param isFinal 本帧是否为当前 utterance 的最后一帧;触发 buffer 残余立即 flush。
*/
fun feedTtsPcm(outputStreamId: String, pcm: ByteArray, isFinal: Boolean): Boolean {
val source = when (outputStreamId) {
TranslationStreams.OUT_UPLINK -> AudioData.SOURCE_E_SCO_UP_LINK
TranslationStreams.OUT_DOWNLINK -> AudioData.SOURCE_E_SCO_DOWN_LINK
else -> AudioData.SOURCE_PHONE_MIC
}
if (isPcmMode) {
// PCM 直传:每次调用一段 AudioData。`isFinal` 在该模式下不影响下发节奏。
writeBack(AudioData(source, Constants.AUDIO_TYPE_PCM, pcm))
return true
}
// OPUS 模式:累积到 buffer,三条 flush 触发链(按优先级):
// 1. isFinal=true → 立即 flush,并把首段优先重置回 true(下一句新算)
// 2. 首段优先(路线 B) → 当前 leg 处于 firstSegmentPending=true 且 buffer
// 累积到 FIRST_SEGMENT_FLUSH_MS 字节量时立即 flush,
// 让"开口到对方听见"的延迟最小化(< 200ms)
// 3. 周期 flush(路线 A) → 由 [PERIODIC_FLUSH_MS] 巡检线程接管
val pendingFlush: ByteArray? = synchronized(bufferLock) {
val buf = pcmBuffers.getOrPut(outputStreamId) { ByteArrayOutputStream() }
if (pcm.isNotEmpty()) {
if (buf.size() + pcm.size > PCM_BUFFER_HARD_LIMIT_BYTES) {
Log.w(TAG, "feedTtsPcm: leg=$outputStreamId pcm buffer hit hard limit ${PCM_BUFFER_HARD_LIMIT_BYTES}, dropping segment")
buf.reset()
return false
}
buf.write(pcm)
}
// 1) 段尾信号:立即下发尾段并把首段标志重置回 true。
if (isFinal) {
firstSegmentPending[outputStreamId] = true
val out = buf.toByteArray()
buf.reset()
return@synchronized if (out.isEmpty()) null else out
}
// 2) 首段优先:仅当 leg 处于 pending 状态(首次 utterance 起始或 isFinal 重置后)。
val pending = firstSegmentPending.getOrDefault(outputStreamId, true)
if (pending) {
val firstFlushBytes = (sampleRateHz * 2 * FIRST_SEGMENT_FLUSH_MS / 1000L).toInt()
if (buf.size() >= firstFlushBytes) {
firstSegmentPending[outputStreamId] = false
val out = buf.toByteArray()
buf.reset()
Log.d(TAG, "firstSegmentFlush: leg=$outputStreamId bytes=${out.size}")
return@synchronized if (out.isEmpty()) null else out
}
}
null
}
if (pendingFlush != null) {
encodeAndDispatchAsync(source, outputStreamId, pendingFlush)
}
return true
}
private fun encodeAndDispatchAsync(source: Int, leg: String, pcmBytes: ByteArray) {
val seq = synchronized(bufferLock) { ++encodeSeq }
val pcmFile = File(tempDir, "tts_${source}_$seq.pcm")
val opusFile = File(tempDir, "tts_${source}_$seq.opus")
try {
pcmFile.writeBytes(pcmBytes)
} catch (e: Throwable) {
Log.w(TAG, "encodeAndDispatch leg=$leg seq=$seq write pcm file failed: ${e.message}")
return
}
// OpusManager.encodeFile(in, out, callback) 默认 OpusOption(带 head),与 demo 一致。
val encoder = OpusManager()
encoder.encodeFile(pcmFile.absolutePath, opusFile.absolutePath, object : OnStateCallback {
override fun onStart() {}
override fun onComplete(path: String?) {
try {
val opusBytes = if (opusFile.exists()) opusFile.readBytes() else null
if (opusBytes == null || opusBytes.isEmpty()) {
Log.w(TAG, "encodeAndDispatch leg=$leg seq=$seq: empty opus output")
} else {
Log.d(TAG, "encodeAndDispatch leg=$leg seq=$seq pcm=${pcmBytes.size}B opus=${opusBytes.size}B")
writeBack(AudioData(source, Constants.AUDIO_TYPE_OPUS, opusBytes))
}
} finally {
runCatching { encoder.release() }
runCatching { pcmFile.delete() }
runCatching { opusFile.delete() }
}
}
override fun onError(code: Int, message: String?) {
Log.w(TAG, "encodeAndDispatch leg=$leg seq=$seq encodeFile error code=$code msg=$message")
runCatching { encoder.release() }
runCatching { pcmFile.delete() }
runCatching { opusFile.delete() }
onError(code, "encodeFile $leg: $message")
}
})
}
fun stop() {
runCatching { flushWatcher?.shutdownNow() }
flushWatcher = null
runCatching {
translationImpl.exitMode(object : OnRcspActionCallback<Int> {
override fun onSuccess(d: BluetoothDevice?, t: Int?) {}
override fun onError(d: BluetoothDevice?, err: com.jieli.bluetooth.bean.base.BaseError?) {}
})
}
runCatching { translationImpl.removeTranslationCallback(translationCallback) }
runCatching { translationImpl.destroy() }
runCatching { upDecoder?.stop() }
runCatching { downDecoder?.stop() }
runCatching { stereoDecoder?.stop() }
runCatching {
uplinkWriter.clear()
downlinkWriter.clear()
phoneMicWriter.clear()
rawWriter.clear()
}
synchronized(bufferLock) {
pcmBuffers.values.forEach { it.reset() }
pcmBuffers.clear()
firstSegmentPending.clear()
}
}
/**
* 把 OPUS / PCM 帧入队到对应 source 的 [WriteScheduler],由调度器串行发起 RCSP
* writeAudioData,避免并发写入触发 "Operation in progress"。
*/
private fun writeBack(data: AudioData) {
val scheduler = when (data.source) {
AudioData.SOURCE_E_SCO_UP_LINK -> uplinkWriter
AudioData.SOURCE_E_SCO_DOWN_LINK -> downlinkWriter
AudioData.SOURCE_PHONE_MIC -> phoneMicWriter
else -> rawWriter
}
scheduler.enqueue(data)
}
}

196
local_plugins/device_jieli/android/src/main/kotlin/com/jielihome/jielihome/feature/voice/SpeechFeature.kt

@ -0,0 +1,196 @@
package com.jielihome.jielihome.feature.voice
import android.bluetooth.BluetoothDevice
import com.jieli.bluetooth.bean.base.BaseError
import com.jieli.bluetooth.bean.record.RecordParam
import com.jieli.bluetooth.bean.record.RecordState
import com.jieli.bluetooth.impl.JL_BluetoothManager
import com.jieli.bluetooth.impl.rcsp.record.RecordOpImpl
import com.jieli.bluetooth.interfaces.rcsp.callback.OnRcspActionCallback
import com.jieli.bluetooth.interfaces.rcsp.record.OnRecordStateCallback
import com.jielihome.jielihome.audio.OpusStreamDecoder
import com.jielihome.jielihome.bridge.EventDispatcher
import com.jielihome.jielihome.feature.ConnectFeature
/**
* 语音助手 / 唤醒功能。
*
* # 工作模型(重要)
* 唤醒是「**耳机端主动检测 + 主动推**」模式。零等待:
*
* 1. 插件初始化时立即注册一个常驻 [OnRecordStateCallback]([attach])
* 2. 耳机本地检测到唤醒词 / 用户按下语音键
* → SDK 直接回调 onStateChange(state=START),**不需要 APP 先发任何指令**
* → 紧接着持续回调 state=WORKING + voiceData(PCM 或 OPUS 帧)
* → 通话结束/取消时回调 state=IDLE
* 3. 插件解码后通过 EventDispatcher 推三类事件给宿主:
* `speechStart` / `speechAudio` / `speechEnd`
*
* # 主动控制(可选)
* - [start]:APP 主动触发语音助手(用户点 UI 的"按住说话"按钮场景)
* - [stop]:APP 主动结束(用户松手 / 超时取消)
* - 这两个调用内部封装了 SDK 的 StartSpeechCmd/StopSpeechCmd。
* 大部分场景下你不需要调它们——耳机会自动管理。
*/
class SpeechFeature(
private val btManager: JL_BluetoothManager,
private val connectFeature: ConnectFeature,
private val dispatcher: EventDispatcher,
) {
private val recordOp: RecordOpImpl = RecordOpImpl.getInstance(btManager)
@Volatile
private var decoder: OpusStreamDecoder? = null
@Volatile
private var currentParam: RecordParam? = null
private val recordCallback = OnRecordStateCallback { device, state ->
if (device == null || state == null) return@OnRecordStateCallback
when (state.state) {
RecordState.RECORD_STATE_START -> onStart(device, state)
RecordState.RECORD_STATE_WORKING -> onWorking(device, state)
RecordState.RECORD_STATE_IDLE -> onEnd(device, state)
}
}
fun attach() {
recordOp.addOnRecordStateCallback(recordCallback)
}
fun detach() {
recordOp.removeOnRecordStateCallback(recordCallback)
releaseDecoder()
}
fun isRecording(address: String?): Boolean {
val d = address?.let { connectFeature.deviceByAddress(it) }
?: connectFeature.connectedDevice() ?: return false
return recordOp.isRecording(d)
}
/**
* APP 主动启动语音助手;通常无需调用(耳机会自动推)。
* @param voiceType RecordParam.VOICE_TYPE_PCM / SPEEX / OPUS
* @param sampleRate RecordParam.SAMPLE_RATE_8K / 16K(注意取值是 8/16,不是 8000/16000)
* @param vadWay RecordParam.VAD_WAY_DEVICE / SDK
*/
fun start(
address: String?,
voiceType: Int = RecordParam.VOICE_TYPE_OPUS,
sampleRate: Int = RecordParam.SAMPLE_RATE_16K,
vadWay: Int = RecordParam.VAD_WAY_DEVICE,
onResult: (ok: Boolean, msg: String?) -> Unit = { _, _ -> },
) {
val device = address?.let { connectFeature.deviceByAddress(it) }
?: connectFeature.connectedDevice()
?: return onResult(false, "no connected device")
val param = RecordParam(voiceType, sampleRate, vadWay)
recordOp.startRecord(device, param, object : OnRcspActionCallback<Boolean> {
override fun onSuccess(d: BluetoothDevice?, r: Boolean?) { onResult(r == true, null) }
override fun onError(d: BluetoothDevice?, e: BaseError?) { onResult(false, e?.message) }
})
}
fun stop(
address: String?,
reason: Int = RecordState.REASON_NORMAL,
onResult: (ok: Boolean, msg: String?) -> Unit = { _, _ -> },
) {
val device = address?.let { connectFeature.deviceByAddress(it) }
?: connectFeature.connectedDevice()
?: return onResult(false, "no connected device")
recordOp.stopRecord(device, reason, object : OnRcspActionCallback<Boolean> {
override fun onSuccess(d: BluetoothDevice?, r: Boolean?) { onResult(r == true, null) }
override fun onError(d: BluetoothDevice?, e: BaseError?) { onResult(false, e?.message) }
})
}
// ───── 内部回调处理 ─────
private fun onStart(device: BluetoothDevice, state: RecordState) {
currentParam = state.recordParam
ensureDecoder(state.recordParam)
dispatcher.send(
mapOf(
"type" to "speechStart",
"address" to device.address,
"voiceType" to state.recordParam?.voiceType,
"sampleRate" to (state.recordParam?.sampleRate ?: 16) * 1000, // 8/16 → 8000/16000
"vadWay" to state.recordParam?.vadWay,
"tsMs" to System.currentTimeMillis(),
)
)
}
private fun onWorking(device: BluetoothDevice, state: RecordState) {
val payload = state.voiceDataBlock ?: state.voiceData ?: return
val type = (state.recordParam ?: currentParam)?.voiceType ?: RecordParam.VOICE_TYPE_OPUS
when (type) {
RecordParam.VOICE_TYPE_OPUS -> decoder?.feedEncoded(payload) ?: emitFrame(device, payload)
RecordParam.VOICE_TYPE_PCM -> emitFrame(device, payload)
RecordParam.VOICE_TYPE_SPEEX -> {
// 当前未挂 speex 解码器;原样透出,由宿主自己解(极少使用)
emitFrame(device, payload, encoding = "speex")
}
}
}
private fun onEnd(device: BluetoothDevice, state: RecordState) {
releaseDecoder()
dispatcher.send(
mapOf(
"type" to "speechEnd",
"address" to device.address,
"reason" to state.reason,
"message" to state.message,
"tsMs" to System.currentTimeMillis(),
)
)
currentParam = null
}
private fun ensureDecoder(param: RecordParam?) {
if (decoder != null) return
if (param?.voiceType != RecordParam.VOICE_TYPE_OPUS) return
val srKhz = param.sampleRate.takeIf { it > 0 } ?: RecordParam.SAMPLE_RATE_16K
val sampleRateHz = srKhz * 1000
val packetSize = if (srKhz == RecordParam.SAMPLE_RATE_8K) 50 else 200
decoder = OpusStreamDecoder(
channel = 1,
packetSize = packetSize,
sampleRate = sampleRateHz,
onPcm = { pcm -> emitFrame(connectFeature.connectedDevice(), pcm) },
onError = { c, m ->
dispatcher.send(
mapOf("type" to "speechError", "code" to c, "message" to "decoder: $m")
)
}
).also { it.start() }
}
private fun releaseDecoder() {
runCatching { decoder?.stop() }
decoder = null
}
private fun emitFrame(
device: BluetoothDevice?,
pcm: ByteArray,
encoding: String = "pcm16",
) {
val sr = (currentParam?.sampleRate ?: RecordParam.SAMPLE_RATE_16K) * 1000
dispatcher.send(
mapOf(
"type" to "speechAudio",
"address" to device?.address,
"encoding" to encoding,
"sampleRate" to sr,
"channels" to 1,
"bitsPerSample" to 16,
"tsMs" to System.currentTimeMillis(),
"pcm" to pcm,
)
)
}
}

314
local_plugins/device_jieli/android/src/main/kotlin/com/jielihome/jielihome/integration/JieliNativeDevicePlugin.kt

@ -0,0 +1,314 @@
package com.jielihome.jielihome.integration
import android.content.Context
import android.util.Log
import com.aiagent.device_plugin_interface.DeviceCapability
import com.aiagent.device_plugin_interface.DeviceConnectOptions
import com.aiagent.device_plugin_interface.DeviceConnectionState
import com.aiagent.device_plugin_interface.DeviceErrorCode
import com.aiagent.device_plugin_interface.DeviceException
import com.aiagent.device_plugin_interface.DevicePluginConfig
import com.aiagent.device_plugin_interface.DevicePluginEvent
import com.aiagent.device_plugin_interface.DevicePluginEventType
import com.aiagent.device_plugin_interface.DeviceScanFilter
import com.aiagent.device_plugin_interface.DiscoveredDevice
import com.aiagent.device_plugin_interface.NativeDevicePlugin
import com.aiagent.device_plugin_interface.NativeDevicePluginRegistry
import com.aiagent.device_plugin_interface.NativeDeviceSession
import com.jielihome.jielihome.api.JieliEventAdapter
import com.jielihome.jielihome.api.JieliEventListener
import com.jielihome.jielihome.core.JieliHomeServer
import kotlinx.coroutines.channels.BufferOverflow
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.asSharedFlow
import java.util.concurrent.CompletableFuture
import java.util.concurrent.TimeUnit
/**
* 杰理 [NativeDevicePlugin] 实现 —— 把 [JieliHomeServer] 包成 vendor-agnostic 接口,
* 由 device_manager native ([com.aiagent.device_manager.DefaultNativeDeviceManager])
* 通过 [NativeDevicePluginRegistry] 创建并管理。
*
* 注册入口:[register],由 `JielihomePlugin.onAttachedToEngine` 调用一次。
*
* 注意:本类**不**关闭 [JieliHomeServer] —— server 是进程级单例,可能被 Dart 直连
* 路径 / translate_server / OTA 等并发使用,本类的 [dispose] 只解订阅事件 + 切断
* 当前 session,server 自身保留。
*/
class JieliNativeDevicePlugin(
private val context: Context,
) : NativeDevicePlugin {
companion object {
private const val TAG = "JieliNativeDevicePlugin"
const val VENDOR_KEY = "jieli"
const val DISPLAY_NAME = "JieLi (杰理)"
val CAPABILITIES: Set<DeviceCapability> = setOf(
DeviceCapability.SCAN,
DeviceCapability.CONNECT,
DeviceCapability.BOND,
DeviceCapability.BATTERY,
DeviceCapability.CUSTOM_COMMAND,
DeviceCapability.WAKE_WORD,
DeviceCapability.OTA,
DeviceCapability.ON_DEVICE_CALL_TRANSLATION,
DeviceCapability.ON_DEVICE_FACE_TO_FACE_TRANSLATION,
DeviceCapability.ON_DEVICE_RECORDING_TRANSLATION,
)
/**
* 把杰理工厂注册到 [NativeDevicePluginRegistry],由 [JielihomePlugin]
* 在 onAttachedToEngine 调一次(FlutterPlugin 生命周期保证它早于 device_manager
* 的 listVendors / useVendor 调用)。
*/
fun register(appContext: Context) {
NativeDevicePluginRegistry.register(
vendorKey = VENDOR_KEY,
displayName = DISPLAY_NAME,
capabilities = CAPABILITIES,
) { JieliNativeDevicePlugin(appContext) }
}
}
override val vendorKey: String = VENDOR_KEY
override val displayName: String = DISPLAY_NAME
override val capabilities: Set<DeviceCapability> = CAPABILITIES
private val server: JieliHomeServer = JieliHomeServer.get()
@Volatile private var _initialized = false
@Volatile private var _disposed = false
@Volatile private var _activeSession: JieliNativeDeviceSession? = null
@Volatile private var _pendingConnect: CompletableFuture<NativeDeviceSession>? = null
@Volatile private var _pendingConnectAddress: String? = null
private val _events = MutableSharedFlow<DevicePluginEvent>(
replay = 0,
extraBufferCapacity = 64,
onBufferOverflow = BufferOverflow.DROP_OLDEST,
)
override val eventStream: Flow<DevicePluginEvent> = _events.asSharedFlow()
private val listener: JieliEventListener = object : JieliEventAdapter() {
override fun onAdapterStatus(enabled: Boolean, hasBle: Boolean) {
emit(DevicePluginEvent(
type = DevicePluginEventType.BLUETOOTH_STATE_CHANGED,
bluetoothEnabled = enabled,
))
}
override fun onScanStatus(ble: Boolean, started: Boolean) {
emit(DevicePluginEvent(
type = if (started) DevicePluginEventType.SCAN_STARTED
else DevicePluginEventType.SCAN_STOPPED,
))
}
override fun onDeviceFound(payload: Map<String, Any?>) {
emit(DevicePluginEvent(
type = DevicePluginEventType.DEVICE_DISCOVERED,
discovered = DiscoveredDevice(
id = (payload["address"] as? String) ?: "",
name = (payload["name"] as? String) ?: "",
rssi = (payload["rssi"] as? Number)?.toInt(),
vendor = VENDOR_KEY,
metadata = mapOf(
"edrAddr" to payload["edrAddr"],
"deviceType" to payload["deviceType"],
"connectWay" to payload["connectWay"],
),
),
))
}
override fun onConnectionState(address: String, state: Int) {
val session = _activeSession ?: return
if (session.deviceId != address) return
when (state) {
2 -> session.setState(DeviceConnectionState.CONNECTING)
1 -> session.setState(DeviceConnectionState.LINK_CONNECTED)
0 -> {
session.setState(DeviceConnectionState.DISCONNECTED)
_activeSession = null
val pending = _pendingConnect
if (pending != null && !pending.isDone) {
pending.completeExceptionally(
DeviceException(
DeviceErrorCode.DISCONNECTED_REMOTE,
"disconnected before ready",
)
)
_pendingConnect = null
_pendingConnectAddress = null
}
}
}
}
override fun onRcspInit(address: String, code: Int) {
val session = _activeSession ?: return
if (session.deviceId != address) return
if (code == 0) {
session.setState(DeviceConnectionState.READY)
val pending = _pendingConnect
if (pending != null && _pendingConnectAddress == address && !pending.isDone) {
pending.complete(session)
_pendingConnect = null
_pendingConnectAddress = null
}
} else {
val pending = _pendingConnect
if (pending != null && _pendingConnectAddress == address && !pending.isDone) {
pending.completeExceptionally(
DeviceException(
DeviceErrorCode.HANDSHAKE_FAILED,
"RCSP init failed code=$code",
)
)
_pendingConnect = null
_pendingConnectAddress = null
}
}
}
override fun onBattery(address: String?, level: Int?) {
val session = _activeSession ?: return
if (address != null && session.deviceId != address) return
// 由 session 自己 refresh info 拉一次最新快照
runCatching { session.refreshInfo() }
}
override fun onTranslationAudio(payload: Map<String, Any?>) {
_activeSession?.emitFeature("jieli.translation.audio", payload)
}
override fun onTranslationResult(payload: Map<String, Any?>) {
_activeSession?.emitFeature("jieli.translation.result", payload)
}
override fun onTranslationLog(payload: Map<String, Any?>) {
_activeSession?.emitFeature("jieli.translation.log", payload)
}
override fun onTranslationError(payload: Map<String, Any?>) {
_activeSession?.emitError(
"device.feature_failed",
"translation: ${payload["code"]} ${payload["message"] ?: ""}",
)
}
}
// ─── NativeDevicePlugin 接口 ──────────────────────────────────────────
override fun initialize(config: DevicePluginConfig) {
checkAlive()
if (_initialized) return
if (!server.initialized) {
server.initialize(
context = context,
multiDevice = (config.extra["multiDevice"] as? Boolean) ?: false,
skipNoNameDev = (config.extra["skipNoNameDev"] as? Boolean) ?: false,
enableLog = (config.extra["enableLog"] as? Boolean) ?: false,
)
}
server.addEventListener(listener)
_initialized = true
emit(DevicePluginEvent(type = DevicePluginEventType.PLUGIN_READY))
}
override fun startScan(filter: DeviceScanFilter?, timeoutMs: Long?) {
requireInit()
val ms = (timeoutMs ?: 30_000L).toInt()
server.scanFeature.startScan(ms).onFailure {
throw DeviceException("device.scan_failed", it.message, it)
}
}
override fun stopScan() {
if (!_initialized) return
server.scanFeature.stopScan()
}
override fun isScanning(): Boolean =
_initialized && server.scanFeature.isScanning()
override fun bondedDevices(): List<DiscoveredDevice> {
// 杰理 SDK 没暴露公开的"已配对"接口;不混入系统经典蓝牙列表。
return emptyList()
}
override fun connect(deviceId: String, options: DeviceConnectOptions?): NativeDeviceSession {
requireInit()
// 单设备:有旧 session 时先断
_activeSession?.let { if (it.deviceId != deviceId) runCatching { it.disconnect() } }
val extra = options?.extra ?: emptyMap()
val session = JieliNativeDeviceSession(
server = server,
deviceId = deviceId,
initialName = (extra["name"] as? String) ?: "",
capabilities = capabilities,
)
_activeSession = session
val future = CompletableFuture<NativeDeviceSession>()
_pendingConnect = future
_pendingConnectAddress = deviceId
server.connectFeature.connect(
bleAddress = deviceId,
edrAddress = extra["edrAddr"] as? String,
deviceType = (extra["deviceType"] as? Number)?.toInt() ?: 0,
connectWay = (extra["connectWay"] as? Number)?.toInt() ?: 0,
).onFailure {
future.completeExceptionally(
DeviceException(DeviceErrorCode.CONNECT_FAILED, it.message, it)
)
_pendingConnect = null
_pendingConnectAddress = null
}
val timeout = (options?.timeoutMs ?: 15_000L)
return try {
future.get(timeout, TimeUnit.MILLISECONDS)
} catch (e: java.util.concurrent.TimeoutException) {
_pendingConnect = null
_pendingConnectAddress = null
throw DeviceException(DeviceErrorCode.CONNECT_TIMEOUT,
"connect timeout after ${timeout}ms")
} catch (e: java.util.concurrent.ExecutionException) {
throw e.cause ?: e
}
}
override val activeSession: NativeDeviceSession?
get() = _activeSession?.takeIf { it.state != DeviceConnectionState.DISCONNECTED }
override fun dispose() {
if (_disposed) return
_disposed = true
runCatching { _activeSession?.disconnect() }
runCatching { server.removeEventListener(listener) }
_activeSession = null
emit(DevicePluginEvent(type = DevicePluginEventType.PLUGIN_DISPOSED))
}
// ─── 内部 ──────────────────────────────────────────────────────────────
private fun checkAlive() {
if (_disposed) throw IllegalStateException("JieliNativeDevicePlugin disposed")
}
private fun requireInit() {
checkAlive()
if (!_initialized) throw DeviceException(DeviceErrorCode.PLUGIN_NOT_INITIALIZED)
}
private fun emit(e: DevicePluginEvent) {
if (!_events.tryEmit(e)) Log.w(TAG, "event buffer full; dropped ${e.type}")
}
}

266
local_plugins/device_jieli/android/src/main/kotlin/com/jielihome/jielihome/integration/JieliNativeDeviceSession.kt

@ -0,0 +1,266 @@
package com.jielihome.jielihome.integration
import android.util.Log
import com.aiagent.device_plugin_interface.DeviceCallTranslationPort
import com.aiagent.device_plugin_interface.DeviceCapability
import com.aiagent.device_plugin_interface.DeviceConnectionState
import com.aiagent.device_plugin_interface.DeviceErrorCode
import com.aiagent.device_plugin_interface.DeviceException
import com.aiagent.device_plugin_interface.DeviceFeatureEvent
import com.aiagent.device_plugin_interface.DeviceInfo
import com.aiagent.device_plugin_interface.DeviceSessionEvent
import com.aiagent.device_plugin_interface.DeviceSessionEventType
import com.aiagent.device_plugin_interface.NativeDeviceSession
import com.jielihome.jielihome.core.JieliHomeServer
import com.jielihome.jielihome.feature.translation.TranslationStreams
import kotlinx.coroutines.channels.BufferOverflow
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.asSharedFlow
/**
* 杰理 [NativeDeviceSession] 实现 —— 包装 [JieliHomeServer] 的连接快照与事件。
*
* 状态机由 [JieliNativeDevicePlugin] 在 RCSP 事件回调里推动:
* - ConnectionStateEvent.state == CONNECTING(2) → connecting
* - state == OK(1) + RcspInit 未到 → linkConnected
* - RcspInit(success=true) → ready
* - state == DISCONNECT(0) → disconnected
*/
class JieliNativeDeviceSession internal constructor(
private val server: JieliHomeServer,
override val deviceId: String,
initialName: String,
override val capabilities: Set<DeviceCapability>,
) : NativeDeviceSession {
companion object {
private const val TAG = "JieliNativeSession"
}
override val vendor: String = "jieli"
@Volatile private var _state = DeviceConnectionState.CONNECTING
@Volatile private var _info = DeviceInfo(
id = deviceId, name = initialName, vendor = vendor,
)
@Volatile private var _disposed = false
override val state: DeviceConnectionState get() = _state
override val info: DeviceInfo get() = _info
private val _events = MutableSharedFlow<DeviceSessionEvent>(
replay = 0,
extraBufferCapacity = 32,
onBufferOverflow = BufferOverflow.DROP_OLDEST,
)
override val eventStream: Flow<DeviceSessionEvent> = _events.asSharedFlow()
// ─── 由 plugin 在事件回调中调用 ─────────────────────────────────────────
internal fun setState(s: DeviceConnectionState) {
if (_state == s) return
_state = s
emit(DeviceSessionEvent(
type = DeviceSessionEventType.CONNECTION_STATE_CHANGED,
deviceId = deviceId,
connectionState = s,
))
if (s == DeviceConnectionState.DISCONNECTED) {
_disposed = true
}
}
internal fun emitFeature(key: String, data: Map<String, Any?>) {
emit(DeviceSessionEvent(
type = DeviceSessionEventType.FEATURE,
deviceId = deviceId,
feature = DeviceFeatureEvent(key = key, data = data),
))
}
internal fun emitError(code: String, message: String?) {
emit(DeviceSessionEvent(
type = DeviceSessionEventType.ERROR,
deviceId = deviceId,
errorCode = code,
errorMessage = message,
))
}
private fun emit(e: DeviceSessionEvent) {
if (!_events.tryEmit(e)) {
Log.w(TAG, "session event buffer full; dropped ${e.type}")
}
}
private fun applySnapshot(snapshot: Map<String, Any?>) {
_info = _info.copyWith(
name = (snapshot["name"] as? String).takeIf { !it.isNullOrEmpty() }
?: _info.name,
firmwareVersion = (snapshot["versionName"] as? String)
?: _info.firmwareVersion,
batteryPercent = (snapshot["battery"] as? Number)?.toInt(),
metadata = snapshot,
)
emit(DeviceSessionEvent(
type = DeviceSessionEventType.DEVICE_INFO_UPDATED,
deviceId = deviceId,
deviceInfo = _info,
))
}
// ─── NativeDeviceSession 接口 ──────────────────────────────────────────
override fun readRssi(): Int {
requireReady()
throw DeviceException(
DeviceErrorCode.NOT_SUPPORTED,
"jieli native bridge does not expose RSSI yet",
)
}
override fun readBattery(): Int? {
requireReady()
return server.deviceInfoFeature.snapshot(deviceId)
?.get("battery") as? Int
}
override fun refreshInfo(): DeviceInfo {
requireReady()
val snap = server.deviceInfoFeature.snapshot(deviceId) ?: return _info
applySnapshot(snap)
return _info
}
override fun invokeFeature(
featureKey: String,
args: Map<String, Any?>,
): Map<String, Any?> {
requireReady()
return when (featureKey) {
"jieli.translation.support" -> mapOf(
"supportCallStereo" to server.translationFeature
.isSupportCallTranslationWithStereo(deviceId),
)
"jieli.translation.start" -> {
val modeId = (args["modeId"] as? Number)?.toInt()
?: throw DeviceException(
DeviceErrorCode.INVALID_ARGUMENT,
"modeId(int) required",
)
@Suppress("UNCHECKED_CAST")
val extra = (args["args"] as? Map<String, Any?>) ?: emptyMap()
server.translationFeature.start(modeId, extra).getOrElse {
throw DeviceException(
"device.feature_failed",
"translation.start failed: ${it.message}",
it,
)
}
mapOf("ok" to true)
}
"jieli.translation.stop" -> {
server.translationFeature.stop()
mapOf("ok" to true)
}
"jieli.translation.feedTranslatedAudio" -> {
val pcm = args["pcm"] as? ByteArray
?: (args["pcm"] as? List<*>)
?.let { it.map { v -> (v as Number).toByte() }.toByteArray() }
?: throw DeviceException(
DeviceErrorCode.INVALID_ARGUMENT,
"pcm(ByteArray|List<int>) required",
)
val streamId = args["streamId"] as? String
?: throw DeviceException(
DeviceErrorCode.INVALID_ARGUMENT,
"streamId required (e.g. ${TranslationStreams.OUT_UPLINK})",
)
val ok = server.translationFeature.feedTranslatedAudio(
outputStreamId = streamId,
pcm = pcm,
format = com.jielihome.jielihome.feature.translation.AudioFormat(
sampleRate = (args["sampleRate"] as? Number)?.toInt() ?: 16000,
channels = (args["channels"] as? Number)?.toInt() ?: 1,
bitsPerSample = (args["bitsPerSample"] as? Number)?.toInt() ?: 16,
),
isFinal = (args["final"] as? Boolean) == true,
)
mapOf("ok" to ok)
}
"jieli.translation.feedTranslationResult" -> {
server.translationFeature.feedTranslationResult(
srcLang = args["srcLang"] as? String,
srcText = args["srcText"] as? String,
destLang = args["destLang"] as? String,
destText = args["destText"] as? String,
requestId = args["requestId"] as? String,
)
mapOf("ok" to true)
}
"jieli.cmd.send" -> {
val opCode = (args["opCode"] as? Number)?.toInt()
?: throw DeviceException(
DeviceErrorCode.INVALID_ARGUMENT,
"opCode required",
)
@Suppress("UNCHECKED_CAST")
val payload = (args["payload"] as? List<Number>)
?.map { it.toByte() }?.toByteArray()
?: byteArrayOf()
// 同步等待(5s 超时)—— RCSP 自定义命令通常 < 500ms 返回。
val deferred = java.util.concurrent.CompletableFuture<ByteArray?>()
server.customCmdFeature.send(
address = deviceId,
opCode = opCode,
payload = payload,
onSuccess = { data -> deferred.complete(data) },
onError = { code, msg ->
deferred.completeExceptionally(
DeviceException("device.feature_failed",
"cmd $opCode failed: code=$code msg=${msg ?: ""}")
)
},
)
val resp = try {
deferred.get(5, java.util.concurrent.TimeUnit.SECONDS)
} catch (e: java.util.concurrent.TimeoutException) {
throw DeviceException(
"device.feature_failed",
"cmd $opCode timeout",
)
} catch (e: java.util.concurrent.ExecutionException) {
throw e.cause ?: e
}
mapOf("response" to resp)
}
else -> throw DeviceException(
DeviceErrorCode.NOT_SUPPORTED,
"unknown feature \"$featureKey\"",
)
}
}
override fun callTranslationPort(): DeviceCallTranslationPort = server.callTranslationPort
override fun disconnect() {
if (_disposed) return
setState(DeviceConnectionState.DISCONNECTING)
runCatching { server.connectFeature.disconnect(deviceId) }
// 真正的 disconnected 状态由 ConnectionStateEvent 推过来
}
private fun requireReady() {
if (_disposed || _state != DeviceConnectionState.READY) {
throw DeviceException(DeviceErrorCode.NO_ACTIVE_SESSION)
}
}
}

27
local_plugins/device_jieli/android/src/test/kotlin/com/jielihome/jielihome/JielihomePluginTest.kt

@ -0,0 +1,27 @@
package com.jielihome.jielihome
import io.flutter.plugin.common.MethodCall
import io.flutter.plugin.common.MethodChannel
import kotlin.test.Test
import org.mockito.Mockito
/*
* This demonstrates a simple unit test of the Kotlin portion of this plugin's implementation.
*
* Once you have built the plugin's example app, you can run these tests from the command
* line by running `./gradlew testDebugUnitTest` in the `example/android/` directory, or
* you can run them directly from IDEs that support JUnit such as Android Studio.
*/
internal class JielihomePluginTest {
@Test
fun onMethodCall_getPlatformVersion_returnsExpectedValue() {
val plugin = JielihomePlugin()
val call = MethodCall("getPlatformVersion", null)
val mockResult: MethodChannel.Result = Mockito.mock(MethodChannel.Result::class.java)
plugin.onMethodCall(call, mockResult)
Mockito.verify(mockResult).success("Android " + android.os.Build.VERSION.RELEASE)
}
}

45
local_plugins/device_jieli/example/.gitignore

@ -0,0 +1,45 @@
# Miscellaneous
*.class
*.log
*.pyc
*.swp
.DS_Store
.atom/
.build/
.buildlog/
.history
.svn/
.swiftpm/
migrate_working_dir/
# IntelliJ related
*.iml
*.ipr
*.iws
.idea/
# The .vscode folder contains launch configuration and tasks you configure in
# VS Code which you may wish to be included in version control, so this line
# is commented out by default.
#.vscode/
# Flutter/Dart/Pub related
**/doc/api/
**/ios/Flutter/.last_build_id
.dart_tool/
.flutter-plugins
.flutter-plugins-dependencies
.pub-cache/
.pub/
/build/
# Symbolication related
app.*.symbols
# Obfuscation related
app.*.map.json
# Android Studio will place build artifacts here
/android/app/debug
/android/app/profile
/android/app/release

16
local_plugins/device_jieli/example/README.md

@ -0,0 +1,16 @@
# jielihome_example
Demonstrates how to use the jielihome plugin.
## Getting Started
This project is a starting point for a Flutter application.
A few resources to get you started if this is your first Flutter project:
- [Lab: Write your first Flutter app](https://docs.flutter.dev/get-started/codelab)
- [Cookbook: Useful Flutter samples](https://docs.flutter.dev/cookbook)
For help getting started with Flutter development, view the
[online documentation](https://docs.flutter.dev/), which offers tutorials,
samples, guidance on mobile development, and a full API reference.

28
local_plugins/device_jieli/example/analysis_options.yaml

@ -0,0 +1,28 @@
# This file configures the analyzer, which statically analyzes Dart code to
# check for errors, warnings, and lints.
#
# The issues identified by the analyzer are surfaced in the UI of Dart-enabled
# IDEs (https://dart.dev/tools#ides-and-editors). The analyzer can also be
# invoked from the command line by running `flutter analyze`.
# The following line activates a set of recommended lints for Flutter apps,
# packages, and plugins designed to encourage good coding practices.
include: package:flutter_lints/flutter.yaml
linter:
# The lint rules applied to this project can be customized in the
# section below to disable rules from the `package:flutter_lints/flutter.yaml`
# included above or to enable additional rules. A list of all available lints
# and their documentation is published at https://dart.dev/lints.
#
# Instead of disabling a lint rule for the entire project in the
# section below, it can also be suppressed for a single line of code
# or a specific dart file by using the `// ignore: name_of_lint` and
# `// ignore_for_file: name_of_lint` syntax on the line or in the file
# producing the lint.
rules:
# avoid_print: false # Uncomment to disable the `avoid_print` rule
# prefer_single_quotes: true # Uncomment to enable the `prefer_single_quotes` rule
# Additional information about this file can be found at
# https://dart.dev/guides/language/analysis-options

14
local_plugins/device_jieli/example/android/.gitignore

@ -0,0 +1,14 @@
gradle-wrapper.jar
/.gradle
/captures/
/gradlew
/gradlew.bat
/local.properties
GeneratedPluginRegistrant.java
.cxx/
# Remember to never publicly share your keystore.
# See https://flutter.dev/to/reference-keystore
key.properties
**/*.keystore
**/*.jks

45
local_plugins/device_jieli/example/android/app/build.gradle.kts

@ -0,0 +1,45 @@
plugins {
id("com.android.application")
id("kotlin-android")
// The Flutter Gradle Plugin must be applied after the Android and Kotlin Gradle plugins.
id("dev.flutter.flutter-gradle-plugin")
}
android {
namespace = "com.jielihome.jielihome_example"
compileSdk = 36
ndkVersion = "27.0.12077973"
compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
kotlinOptions {
jvmTarget = JavaVersion.VERSION_17.toString()
}
defaultConfig {
applicationId = "com.jielihome.jielihome_example"
minSdk = 21
targetSdk = flutter.targetSdkVersion
versionCode = flutter.versionCode
versionName = flutter.versionName
}
buildTypes {
release {
// TODO: Add your own signing config for the release build.
// Signing with the debug keys for now, so `flutter run --release` works.
signingConfig = signingConfigs.getByName("debug")
}
}
}
flutter {
source = "../.."
}
dependencies {
implementation(fileTree(mapOf("dir" to "libs", "include" to listOf("*.aar"))))
}

BIN
local_plugins/device_jieli/example/android/app/libs/jl_audio_decode_V2.1.0_20012-release.aar

Binary file not shown.

BIN
local_plugins/device_jieli/example/android/app/libs/jl_audio_v2_V1.0.0_9-release.aar

Binary file not shown.

BIN
local_plugins/device_jieli/example/android/app/libs/jl_bluetooth_rcsp_V4.2.0_beta2_40214_20251224.aar

Binary file not shown.

BIN
local_plugins/device_jieli/example/android/app/libs/jldecryption_v0.4-release.aar

Binary file not shown.

7
local_plugins/device_jieli/example/android/app/src/debug/AndroidManifest.xml

@ -0,0 +1,7 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<!-- The INTERNET permission is required for development. Specifically,
the Flutter tool needs it to communicate with the running application
to allow setting breakpoints, to provide hot reload, etc.
-->
<uses-permission android:name="android.permission.INTERNET"/>
</manifest>

55
local_plugins/device_jieli/example/android/app/src/main/AndroidManifest.xml

@ -0,0 +1,55 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.BLUETOOTH"
android:maxSdkVersion="30" />
<uses-permission android:name="android.permission.BLUETOOTH_ADMIN"
android:maxSdkVersion="30" />
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT" />
<uses-permission android:name="android.permission.BLUETOOTH_SCAN" />
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
<uses-permission android:name="android.permission.RECORD_AUDIO" />
<application
android:label="jielihome_example"
android:name="${applicationName}"
android:icon="@mipmap/ic_launcher">
<activity
android:name=".MainActivity"
android:exported="true"
android:launchMode="singleTop"
android:taskAffinity=""
android:theme="@style/LaunchTheme"
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode"
android:hardwareAccelerated="true"
android:windowSoftInputMode="adjustResize">
<!-- Specifies an Android theme to apply to this Activity as soon as
the Android process has started. This theme is visible to the user
while the Flutter UI initializes. After that, this theme continues
to determine the Window background behind the Flutter UI. -->
<meta-data
android:name="io.flutter.embedding.android.NormalTheme"
android:resource="@style/NormalTheme"
/>
<intent-filter>
<action android:name="android.intent.action.MAIN"/>
<category android:name="android.intent.category.LAUNCHER"/>
</intent-filter>
</activity>
<!-- Don't delete the meta-data below.
This is used by the Flutter tool to generate GeneratedPluginRegistrant.java -->
<meta-data
android:name="flutterEmbedding"
android:value="2" />
</application>
<!-- Required to query activities that can process text, see:
https://developer.android.com/training/package-visibility and
https://developer.android.com/reference/android/content/Intent#ACTION_PROCESS_TEXT.
In particular, this is used by the Flutter engine in io.flutter.plugin.text.ProcessTextPlugin. -->
<queries>
<intent>
<action android:name="android.intent.action.PROCESS_TEXT"/>
<data android:mimeType="text/plain"/>
</intent>
</queries>
</manifest>

5
local_plugins/device_jieli/example/android/app/src/main/kotlin/com/jielihome/jielihome_example/MainActivity.kt

@ -0,0 +1,5 @@
package com.jielihome.jielihome_example
import io.flutter.embedding.android.FlutterActivity
class MainActivity : FlutterActivity()

12
local_plugins/device_jieli/example/android/app/src/main/res/drawable-v21/launch_background.xml

@ -0,0 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Modify this file to customize your launch splash screen -->
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
<item android:drawable="?android:colorBackground" />
<!-- You can insert your own image assets here -->
<!-- <item>
<bitmap
android:gravity="center"
android:src="@mipmap/launch_image" />
</item> -->
</layer-list>

12
local_plugins/device_jieli/example/android/app/src/main/res/drawable/launch_background.xml

@ -0,0 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Modify this file to customize your launch splash screen -->
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
<item android:drawable="@android:color/white" />
<!-- You can insert your own image assets here -->
<!-- <item>
<bitmap
android:gravity="center"
android:src="@mipmap/launch_image" />
</item> -->
</layer-list>

BIN
local_plugins/device_jieli/example/android/app/src/main/res/mipmap-hdpi/ic_launcher.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 544 B

BIN
local_plugins/device_jieli/example/android/app/src/main/res/mipmap-mdpi/ic_launcher.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 442 B

BIN
local_plugins/device_jieli/example/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 721 B

BIN
local_plugins/device_jieli/example/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 KiB

BIN
local_plugins/device_jieli/example/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

18
local_plugins/device_jieli/example/android/app/src/main/res/values-night/styles.xml

@ -0,0 +1,18 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!-- Theme applied to the Android Window while the process is starting when the OS's Dark Mode setting is on -->
<style name="LaunchTheme" parent="@android:style/Theme.Black.NoTitleBar">
<!-- Show a splash screen on the activity. Automatically removed when
the Flutter engine draws its first frame -->
<item name="android:windowBackground">@drawable/launch_background</item>
</style>
<!-- Theme applied to the Android Window as soon as the process has started.
This theme determines the color of the Android Window while your
Flutter UI initializes, as well as behind your Flutter UI while its
running.
This Theme is only used starting with V2 of Flutter's Android embedding. -->
<style name="NormalTheme" parent="@android:style/Theme.Black.NoTitleBar">
<item name="android:windowBackground">?android:colorBackground</item>
</style>
</resources>

18
local_plugins/device_jieli/example/android/app/src/main/res/values/styles.xml

@ -0,0 +1,18 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!-- Theme applied to the Android Window while the process is starting when the OS's Dark Mode setting is off -->
<style name="LaunchTheme" parent="@android:style/Theme.Light.NoTitleBar">
<!-- Show a splash screen on the activity. Automatically removed when
the Flutter engine draws its first frame -->
<item name="android:windowBackground">@drawable/launch_background</item>
</style>
<!-- Theme applied to the Android Window as soon as the process has started.
This theme determines the color of the Android Window while your
Flutter UI initializes, as well as behind your Flutter UI while its
running.
This Theme is only used starting with V2 of Flutter's Android embedding. -->
<style name="NormalTheme" parent="@android:style/Theme.Light.NoTitleBar">
<item name="android:windowBackground">?android:colorBackground</item>
</style>
</resources>

7
local_plugins/device_jieli/example/android/app/src/profile/AndroidManifest.xml

@ -0,0 +1,7 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<!-- The INTERNET permission is required for development. Specifically,
the Flutter tool needs it to communicate with the running application
to allow setting breakpoints, to provide hot reload, etc.
-->
<uses-permission android:name="android.permission.INTERNET"/>
</manifest>

Some files were not shown because too many files changed in this diff

Loading…
Cancel
Save