diff --git a/apps/client/lib/core/utils/audio_foreground_service.dart b/apps/client/lib/core/utils/audio_foreground_service.dart new file mode 100644 index 00000000..fb4981fd --- /dev/null +++ b/apps/client/lib/core/utils/audio_foreground_service.dart @@ -0,0 +1,52 @@ +import 'dart:io'; + +import 'package:flutter/services.dart'; + +import 'logger.dart'; + +/// Android 前台服务的开关(iOS 上是空操作)。 +/// +/// ## 为什么需要它 +/// 通话录音的音频来自耳机的 SPP 流,**不经过任何识别插件**,所以 +/// azure_speech 那条链路自带的前台服务管不到它。而没有前台服务时, +/// 切后台后会发生两件事,且都不报错: +/// 1. 国内 ROM(荣耀/华为尤其激进)冻结甚至回收后台进程 —— Dart isolate 停摆, +/// PCM 流不再被消费,录音悄悄断在半路,回到前台才发现文件截断了; +/// 2. Android 9+ 后台应用拿不到麦克风(这条只影响用手机麦的那些路径)。 +/// +/// ## ⚠️ 与 azure_speech 内部共用同一个服务实例 +/// [AzureAsrHelper] 和通话翻译(AST)也会开同一个前台服务。 +/// 三者互斥使用(通话录音 / 通话翻译 / 面对面同传不会同时开), +/// 所以这里不做引用计数。**将来若允许并存,谁先 stop 就会把别人的也停掉**, +/// 到时候必须改成计数式。 +/// +/// ## ⚠️ 通知被关掉不影响它工作 +/// 前台服务自带一条常驻通知。用户关掉 App 的通知权限后这条通知不显示, +/// 但服务照常运行、进程照常被钉住 —— 所以「不想要系统通知」和 +/// 「后台要能继续录」这两件事不冲突。 +class AudioForegroundService { + AudioForegroundService._(); + + static const MethodChannel _channel = MethodChannel('azure_speech/ast'); + static const String _tag = 'AudioForegroundService'; + + static Future start() async { + if (!Platform.isAndroid) return; + try { + await _channel.invokeMethod('startAudioForegroundService'); + } catch (e) { + // 起不来不该阻断录音本身:前台是为了「切后台还能录」, + // 停在前台的那部分照样是好的。 + Logger.w(_tag, '启动前台服务失败(前台使用不受影响): $e'); + } + } + + static Future stop() async { + if (!Platform.isAndroid) return; + try { + await _channel.invokeMethod('stopAudioForegroundService'); + } catch (e) { + Logger.w(_tag, '停止前台服务失败: $e'); + } + } +} diff --git a/apps/client/lib/data/services/bes_call_recording_service.dart b/apps/client/lib/data/services/bes_call_recording_service.dart index 4a59e820..3ac752b7 100644 --- a/apps/client/lib/data/services/bes_call_recording_service.dart +++ b/apps/client/lib/data/services/bes_call_recording_service.dart @@ -7,6 +7,7 @@ import 'package:get/get.dart'; import 'package:intl/intl.dart'; import 'package:path_provider/path_provider.dart'; +import '../../core/utils/audio_foreground_service.dart'; import '../../core/utils/logger.dart'; import 'bes_bluetooth_service.dart'; import 'recording_archive.dart'; @@ -175,6 +176,9 @@ class BesCallRecordingService extends GetxService { } }); + // 把进程钉住,否则切后台后录音会被国内 ROM 悄悄掐断(回前台才发现截断) + unawaited(AudioForegroundService.start()); + isRecording.value = true; elapsedMs.value = 0; level.value = 0; @@ -235,6 +239,7 @@ class BesCallRecordingService extends GetxService { _deviceRecordingActive = false; await _closeFile(save: true); + unawaited(AudioForegroundService.stop()); isRecording.value = false; elapsedMs.value = 0; level.value = 0; diff --git a/apps/client/lib/data/services/language_manager.dart b/apps/client/lib/data/services/language_manager.dart index eca25c7e..89733536 100644 --- a/apps/client/lib/data/services/language_manager.dart +++ b/apps/client/lib/data/services/language_manager.dart @@ -756,7 +756,9 @@ class LanguageManager extends GetxService { final providerString = _providerStringFromType(provider); final isEndToEnd = _isAlibaba(provider); - Logger.info( + // warn 级:这一行决定了「有没有声音复刻」——只有 alibaba35 那一档才有。 + // 客户反馈「没被复刻」时,第一件事就是看这里命中的是哪一档。 + Logger.warning( '[TRANS-INIT] 命中 ${providerDisplayName(providerString)}[$providerString] for $sourceShortCode -> $targetShortCode (端到端=$isEndToEnd), transSrc=${sourceTransSpec["translationCode"]}, transTgt=${targetTransSpec["translationCode"]}, asrSrc=${sourceTransSpec["asrCode"]}, asrTgt=${targetTransSpec["asrCode"]}, ttsSrc=${sourceTransSpec["ttsCode"]}, ttsTgt=${targetTransSpec["ttsCode"]}'); return BestProviderResult( providerString, sourceTransSpec, targetTransSpec); diff --git a/apps/client/lib/data/services/memory_reminder_scheduler.dart b/apps/client/lib/data/services/memory_reminder_scheduler.dart index cf393e77..dad13ac7 100644 --- a/apps/client/lib/data/services/memory_reminder_scheduler.dart +++ b/apps/client/lib/data/services/memory_reminder_scheduler.dart @@ -1,11 +1,9 @@ +import 'dart:async'; import 'dart:convert'; +import 'package:flutter/material.dart'; import 'package:flutter_local_notifications/flutter_local_notifications.dart'; -import 'package:flutter_timezone/flutter_timezone.dart'; import 'package:get/get.dart'; -import 'package:permission_handler/permission_handler.dart'; -import 'package:timezone/data/latest_all.dart' as tzdata; -import 'package:timezone/timezone.dart' as tz; import '../../core/utils/logger.dart'; import '../models/assistant_directive.dart'; @@ -13,28 +11,33 @@ import '../models/memory_item.dart'; import 'assistant_directive_service.dart'; import 'memory_service.dart'; -/// 拾忆的提前提醒:把服务端展开好的提醒时刻排成系统本地通知。 +/// 拾忆的提前提醒:**只在 App 内提示,不发系统通知**。 /// -/// ## 为什么在客户端做 -/// App 未打开、甚至无网时也要响。服务端推送做不到——没有 APNs/FCM 通道, -/// 国内厂商推送还要逐家接。 +/// ## ⚠️ 这是刻意的产品选择,别顺手改回系统通知 +/// 2026-09-05 定的口径:提醒内容只停留在 App 里。代价必须说清楚—— +/// **App 没在前台跑的时候不会有任何提示**,到点也不响。它是「打开 App 时 +/// 告诉你有什么要做」,不是系统闹钟。要真正做到关着 App 也能响,只有 +/// 系统通知/AlarmManager 一条路,那就得回到通知权限那套(还有 48 小时 +/// 合规冷却、Android 14 的精确闹钟权限、国内 ROM 省电策略三道坎)。 /// -/// ## ⚠️ 送达率做不到 100%,这是已知的 -/// 三条叠加,都不是代码能绕过去的: -/// 1. 通知权限有 48 小时合规冷却(见 PermissionUtil):用户在别处拒过一次, -/// 两天内提醒完全静默,而且 App **不被允许**弹窗解释。 -/// 2. Android 14 起 SCHEDULE_EXACT_ALARM 对普通应用默认不授予。真正该用的是 -/// USE_EXACT_ALARM,但 Google Play 要求「应用核心功能就是闹钟/日历」并单独 -/// 提交说明——那是产品决策。拿不到精确权限时这里退化成 inexact 排期, -/// 「提前 5 分钟」会变成「提前 5 到 20 分钟之间」。 -/// 3. 国内 ROM 的省电策略会清掉未加白名单应用的 AlarmManager 排期。 +/// ## 实现 +/// 进程内一个 Timer 指向**最近的那一条**,响完再武装下一条。 +/// 不给每条各排一个 Timer:几十个 Timer 既没必要,改期时还容易漏撤。 /// -/// ## ⚠️ 通知 id 必须分命名空间 -/// 仓库里已经有两个各自 new 出来的 FlutterLocalNotificationsPlugin 实例 -/// (background_session_notifier / meeting_record_controller),各管各的 id。 -/// 提醒占 [_idBase] 起的一段固定区间,取消时**逐条 cancel**, -/// **绝不调 cancelAll()** —— 那会顺手干掉正在录音的前台通知, -/// 表现是录音悄悄失去前台身份、被系统回收。 +/// 定时器最长只压 [_maxTimerSpan],超过就到期重算一次。理由是长定时器在 +/// 移动端本来就不可靠(进程随时被回收),而且中途数据可能变了。 +/// +/// ## ⚠️ 错过的不补弹 +/// App 起来时如果发现某条提醒时刻已经过去了,直接丢掉,不追着弹。 +/// 追弹的话,早上打开 App 会一次性糊上来一串昨天的提醒。 +/// +/// ## ⚠️ 仍然保留 flutter_local_notifications,只用来「撤」 +/// 老版本(2026-09-05 之前)真的往系统里排过通知,装过老包的机器上 +/// 那些排期还在。[_cancelLegacyNotifications] 启动时撤一次, +/// 否则用户会在改口径之后仍然收到系统提醒,且再也没有地方能关掉它。 +/// +/// ⚠️ **绝不调 cancelAll()** —— 那会顺手干掉正在录音的前台通知, +/// 表现是录音悄悄失去前台身份、被系统回收。只按我们自己的 id 区间撤。 class MemoryReminderScheduler extends GetxService { static MemoryReminderScheduler get to => Get.find(); static const String _tag = 'MemoryReminder'; @@ -42,47 +45,31 @@ class MemoryReminderScheduler extends GetxService { /// 提醒通知的 id 区间:[_idBase, _idBase + _maxScheduled) static const int _idBase = 920000; - /// 排期上限。iOS 最多 64 条待处理通知,留一些余量给别的通知用。 + /// 老版本排过的通知 id 区间上限,只用于启动时撤干净 static const int _maxScheduled = 48; - /// 只排未来这么多天 + /// 只看未来这么多天 static const int _days = 7; - final FlutterLocalNotificationsPlugin _plugin = - FlutterLocalNotificationsPlugin(); + /// 单个定时器最长压多久,超过就到期重算 + static const Duration _maxTimerSpan = Duration(minutes: 30); - bool _pluginReady = false; - bool _tzReady = false; + /// 待提醒队列,按 remindAt 升序,队首是最近的一条 + final List _pending = []; - /// 上一次真正排出去的 id,取消时按它逐条撤 - final List _scheduledIds = []; + /// 指向队首那条的定时器 + Timer? _timer; - /// 权限被拒时置 true,页面据此显示一条静态提示(不是弹窗)指向权限管理页 - final RxBool permissionDenied = false.obs; + /// 正在展示提醒弹窗——同一时刻挤进来两条时不叠第二个 dialog + bool _showing = false; - Future _ensureTz() async { - if (_tzReady) return; - tzdata.initializeTimeZones(); - try { - final name = await FlutterTimezone.getLocalTimezone(); - tz.setLocalLocation(tz.getLocation(name.toString())); - } catch (e) { - // 拿不到系统时区就用 tz 的默认 UTC 之外的兜底:按本机偏移找一个等价区。 - // 不做这一步会让所有提醒按 UTC 排,国内用户全部偏 8 小时。 - Logger.w(_tag, '读系统时区失败,按本机偏移兜底: $e'); - try { - final offset = DateTime.now().timeZoneOffset; - final match = tz.timeZoneDatabase.locations.values.firstWhere( - (l) => l.currentTimeZone.offset == offset.inMilliseconds, - orElse: () => tz.getLocation('Asia/Shanghai'), - ); - tz.setLocalLocation(match); - } catch (_) { - tz.setLocalLocation(tz.getLocation('Asia/Shanghai')); - } - } - _tzReady = true; - } + /// 老版本遗留的系统通知只需要撤一次 + bool _legacyCleared = false; + + final FlutterLocalNotificationsPlugin _plugin = + FlutterLocalNotificationsPlugin(); + + bool _pluginReady = false; Future _ensurePlugin() async { if (_pluginReady) return; @@ -116,24 +103,7 @@ class MemoryReminderScheduler extends GetxService { } } - /// 通知权限是否已授予。 - /// - /// ⚠️ **只查不申请**。申请必须走 PermissionUtil 的 requestPermission(它收敛了 - /// 48 小时冷却),而且只允许在「设置 → 权限管理」里由用户主动发起。 - /// 这里主动申请会直接违反那条合规规则。 - Future _hasPermission() async { - try { - final status = await Permission.notification.status; - final ok = status.isGranted; - permissionDenied.value = !ok; - return ok; - } catch (e) { - Logger.w(_tag, '查询通知权限失败: $e'); - return false; - } - } - - /// 迁移期的本地兜底:把**还没同步到服务端**的助手闹钟也排成通知。 + /// 迁移期的本地兜底:把**还没同步到服务端**的助手闹钟也算进待提醒队列。 /// /// ⚠️ 不做这个,「对助手说定个闹钟」在真机上就是**完全不响**。 /// 排期的数据源原先只有 `MemoryService.loadUpcoming`,那是纯服务端接口; @@ -206,80 +176,88 @@ class MemoryReminderScheduler extends GetxService { return out; } - /// 重新排期:先撤旧的,再按服务端给的时刻排新的。 + /// 重新算一遍待提醒队列,并武装最近的那一条。 /// - /// 每次都全撤全排,不做增量 diff —— 改期后旧通知还在响比多排一次贵得多。 + /// 每次都全量重算,不做增量 diff —— 改期后旧的还在响比多算一次贵得多。 Future reschedule() async { - if (!await _hasPermission()) { - Logger.i(_tag, '无通知权限,降级为「打开 App 才提醒」'); - await _cancelAllOurs(); - return; - } - await _ensureTz(); - await _ensurePlugin(); + await _cancelLegacyNotifications(); + final now = DateTime.now(); final slots = [ ...await MemoryService.to.loadUpcoming(days: _days), ..._localSlots(_days), - ]; + ] + // 已经过去的直接丢:错过的不补弹(见类注释) + .where((e) => e.remindDateTime.isAfter(now)) + .toList(); // 合流之后**必须自己排一次**:服务端那份是升序的,本地补进来的不是, - // 直接拼起来会让本地闹钟按插入位置抢占前 N 个名额,把服务端更早的挤掉。 + // 直接拼起来队首就不是最近的那条,武装出来的定时器指向的是随便一条。 slots.sort((a, b) => a.remindAt.compareTo(b.remindAt)); - await _cancelAllOurs(); - if (slots.isEmpty) { - Logger.w(_tag, '没有待排的提醒'); - return; - } - Logger.w(_tag, '待排提醒 ${slots.length} 条'); + + _pending + ..clear() + ..addAll(slots); + Logger.w(_tag, '待提醒 ${_pending.length} 条(App 内提示,不发系统通知)'); + _armNext(); + } + + /// 武装队首那条的定时器。 + void _armNext() { + _timer?.cancel(); + _timer = null; final now = DateTime.now(); - var idx = 0; - for (final slot in slots) { - if (idx >= _maxScheduled) break; - final when = slot.remindDateTime; - if (!when.isAfter(now)) continue; + // 队首可能在这次武装之前就已经过期了(进程被挂起过一段时间),逐个丢掉 + while (_pending.isNotEmpty && !_pending.first.remindDateTime.isAfter(now)) { + final missed = _pending.removeAt(0); + Logger.w(_tag, '错过的提醒不补弹: ${missed.title}'); + } + if (_pending.isEmpty) return; - final id = _idBase + idx; - try { - await _plugin.zonedSchedule( - id, - slot.title.isEmpty ? 'memReminderTitle'.tr : slot.title, - _bodyOf(slot), - tz.TZDateTime.from(when, tz.local), - _details(), - androidScheduleMode: AndroidScheduleMode.exactAllowWhileIdle, - // 按「墙上时钟时间」解释:用户设的是 8:00,跨时区飞行后也该是当地 8:00 语义 - // 由服务端按项自己的 tz 算好绝对时刻,这里只需保证不被二次偏移。 - uiLocalNotificationDateInterpretation: - UILocalNotificationDateInterpretation.absoluteTime, - payload: jsonEncode({'item_id': slot.itemId}), - ); - _scheduledIds.add(id); - idx++; - } catch (e) { - // 拿不到精确闹钟权限(Android 14 常见)会在这里抛。退化成 inexact - // 再试一次:不精确的提醒也比没有强。 - try { - await _plugin.zonedSchedule( - id, - slot.title.isEmpty ? 'memReminderTitle'.tr : slot.title, - _bodyOf(slot), - tz.TZDateTime.from(when, tz.local), - _details(), - androidScheduleMode: AndroidScheduleMode.inexactAllowWhileIdle, - uiLocalNotificationDateInterpretation: - UILocalNotificationDateInterpretation.absoluteTime, - payload: jsonEncode({'item_id': slot.itemId}), - ); - _scheduledIds.add(id); - idx++; - Logger.w(_tag, '精确排期失败已退化为 inexact(提醒时间可能延后): $e'); - } catch (e2) { - Logger.e(_tag, '排期失败 item:${slot.itemId}: $e2'); - } + final due = _pending.first.remindDateTime; + var wait = due.difference(now); + // 封顶:长定时器在移动端不可靠,到点重算一次即可 + if (wait > _maxTimerSpan) wait = _maxTimerSpan; + _timer = Timer(wait, () { + // 封顶重算的情况下这里还没到点,_armNext 会重新压一段 + if (_pending.isNotEmpty && + !_pending.first.remindDateTime.isAfter(DateTime.now())) { + _fire(_pending.removeAt(0)); } + _armNext(); + }); + } + + /// 到点了:在 App 内弹一条。 + /// + /// ⚠️ 用 dialog 不用 snackbar:闹钟是需要用户确认的,snackbar 两秒就滑走了, + /// 用户低头一眼没看见就等于没提醒过。 + void _fire(MemoryRemindSlot slot) { + if (_showing) { + // 同一分钟撞车的第二条:不叠 dialog,退化成一条提示, + // 叠两个 dialog 会把返回键和路由栈弄乱。 + Get.snackbar( + slot.title.isEmpty ? 'memReminderTitle'.tr : slot.title, + _bodyOf(slot), + snackPosition: SnackPosition.TOP, + duration: const Duration(seconds: 4), + ); + return; } - Logger.i(_tag, '已排定 $idx 条提醒(共 ${slots.length} 个待选时刻)'); + _showing = true; + Logger.w(_tag, '触发提醒: ${slot.title} item:${slot.itemId}'); + Get.dialog( + AlertDialog( + title: Text(slot.title.isEmpty ? 'memReminderTitle'.tr : slot.title), + content: Text(_bodyOf(slot)), + // 只放一个按钮:没有现成的「查看详情」文案,为一个按钮补 41 个语言 + // 的 key 不值当;提醒内容本身已经在标题和正文里了。 + actions: [ + TextButton(onPressed: Get.back, child: Text('gotIt'.tr)), + ], + ), + barrierDismissible: true, + ).whenComplete(() => _showing = false); } String _bodyOf(MemoryRemindSlot slot) { @@ -289,46 +267,41 @@ class MemoryReminderScheduler extends GetxService { '${t.minute.toString().padLeft(2, '0')}'; } - NotificationDetails _details() { - // 单独的通道:录音提示是常驻低优先级通道,共用会让提醒被降级成静默。 - const android = AndroidNotificationDetails( - 'memory_reminder', - '拾忆提醒', - channelDescription: '待办与闹钟的提前提醒', - importance: Importance.high, - priority: Priority.high, - ); - const ios = DarwinNotificationDetails( - presentAlert: true, - presentBadge: true, - presentSound: true, - ); - return const NotificationDetails(android: android, iOS: ios); - } - - /// 只撤我们自己排的那些 id。 + /// 撤掉**老版本**排进系统的那些通知。 /// - /// ⚠️ **绝不能用 cancelAll()**:它会把录音的前台通知一起干掉。 - Future _cancelAllOurs() async { - if (!_pluginReady) return; - for (final id in _scheduledIds) { - try { - await _plugin.cancel(id); - } catch (_) {} - } - // 进程重启后 _scheduledIds 是空的,但系统里可能还留着上次的排期, - // 所以把整个区间都撤一遍(cancel 不存在的 id 是安全的空操作)。 - for (var i = 0; i < _maxScheduled; i++) { - try { - await _plugin.cancel(_idBase + i); - } catch (_) {} + /// 2026-09-05 之前的包真的往系统里排过提醒,装过老包的机器上那些排期还在, + /// 不撤的话用户在改口径之后仍会收到系统提醒,而且没有任何地方能关掉。 + /// 一个进程内只需要跑一次。 + /// + /// ⚠️ **绝不能用 cancelAll()**:它会把录音的前台通知一起干掉, + /// 表现是录音悄悄失去前台身份、被系统回收。只撤我们自己的 id 区间 + /// (cancel 一个不存在的 id 是安全的空操作)。 + Future _cancelLegacyNotifications() async { + if (_legacyCleared) return; + _legacyCleared = true; + try { + await _ensurePlugin(); + for (var i = 0; i < _maxScheduled; i++) { + try { + await _plugin.cancel(_idBase + i); + } catch (_) {} + } + } catch (e) { + Logger.w(_tag, '撤老版本系统通知失败(不影响 App 内提醒): $e'); } - _scheduledIds.clear(); } - /// 登出时清掉所有提醒——换账号不该继续响上一个账号的日程 + /// 登出时清掉所有提醒——换账号不该继续弹上一个账号的日程 Future clear() async { - await _ensurePlugin(); - await _cancelAllOurs(); + _timer?.cancel(); + _timer = null; + _pending.clear(); + await _cancelLegacyNotifications(); + } + + @override + void onClose() { + _timer?.cancel(); + super.onClose(); } } diff --git a/apps/client/lib/data/services/speech_impl/azure_ast_service.dart b/apps/client/lib/data/services/speech_impl/azure_ast_service.dart index 1c0ad32e..7a29ac44 100644 --- a/apps/client/lib/data/services/speech_impl/azure_ast_service.dart +++ b/apps/client/lib/data/services/speech_impl/azure_ast_service.dart @@ -438,7 +438,10 @@ class AzureAstService extends GetxService implements AstService { // 而现象只是「声音复刻悄悄没了」,不报任何错。 final String alibabaModel = provider == 'alibaba35' ? kAlibabaModel35 : _alibabaAppId; - Logger.info( + // ⚠️ warn 级:release 的日志门槛是 warning。「声音有没有被复刻」这个问题 + // 只能靠这一行对齐——它说明本次会话用的是哪一档模型、复刻开关是不是真的 + // 打开了。写 info 的话正式包里查不到,只能凭猜。 + Logger.warning( '[STS] initialize: provider=$provider (native=$nativeProvider), ' 'alibabaModel=$alibabaModel, voiceClone=$enableVoiceClone, isInitialized=$_isInitialized'); // 底层会初始化前释放 diff --git a/apps/client/lib/modules/home/views/home_view.dart b/apps/client/lib/modules/home/views/home_view.dart index 4be226cd..7b16b5d9 100644 --- a/apps/client/lib/modules/home/views/home_view.dart +++ b/apps/client/lib/modules/home/views/home_view.dart @@ -6,7 +6,6 @@ import '../../../routes/app_routes.dart'; import 'widgets/calendar/calendar_card.dart'; import 'widgets/calendar/calendar_task_list.dart'; import '../controllers/calendar_controller.dart'; -import '../../../data/services/memory_reminder_scheduler.dart'; // 首页视图 class HomeView extends GetView { @@ -79,7 +78,6 @@ class HomeView extends GetView { padding: EdgeInsets.symmetric(horizontal: 16.w), child: _buildAppBar(context, isDarkMode), ), - _buildNotifyDisabledBar(isDarkMode), SizedBox(height: 16.h), // 日历 + 任务列表 + 翻译卡片/AI(随日历折叠/展开切换) Expanded(child: _buildCalendarArea(isDarkMode)), @@ -92,57 +90,6 @@ class HomeView extends GetView { ); } - /// 通知权限没开时的静态提示条。 - /// - /// ⚠️ 这是**唯一**能让用户知道「闹钟不会响」的地方。 - /// MemoryReminderScheduler.permissionDenied 这个标志一直存在,但在此之前 - /// 没有任何 UI 在读它 —— 于是没通知权限时的表现是:助手说闹钟定好了、 - /// 拾忆页也确实列出来了,到点却一声不响,而且没有任何解释。 - /// - /// ⚠️ 刻意做成常驻的一条,**不是弹窗**:申请通知权限必须由用户在 - /// 「设置 → 权限管理」里主动发起(PermissionUtil 有 48 小时合规冷却, - /// 拒过一次之后两天内 App 不被允许再弹窗解释)。这里只能引导,不能代劳。 - Widget _buildNotifyDisabledBar(bool isDarkMode) { - if (!Get.isRegistered()) { - return const SizedBox.shrink(); - } - return Obx(() { - if (!MemoryReminderScheduler.to.permissionDenied.value) { - return const SizedBox.shrink(); - } - return Padding( - padding: EdgeInsets.only(top: 8.h, left: 16.w, right: 16.w), - child: InkWell( - onTap: () => Get.toNamed(Routes.permissions), - borderRadius: BorderRadius.circular(8.r), - child: Container( - padding: EdgeInsets.symmetric(horizontal: 12.w, vertical: 8.h), - decoration: BoxDecoration( - color: const Color(0xFFFFF4E5), - borderRadius: BorderRadius.circular(8.r), - ), - child: Row( - children: [ - Icon(Icons.notifications_off_outlined, - size: 16.sp, color: const Color(0xFFB26A00)), - SizedBox(width: 8.w), - Expanded( - child: Text( - 'memNotifyDisabled'.tr, - style: TextStyle( - fontSize: 12.sp, color: const Color(0xFFB26A00)), - ), - ), - Icon(Icons.chevron_right, - size: 16.sp, color: const Color(0xFFB26A00)), - ], - ), - ), - ), - ); - }); - } - // 日历区域:日历卡片 + 任务列表(Expanded) + 翻译卡片/AI(随展开收起) Widget _buildCalendarArea(bool isDarkMode) { final cal = Get.find(); diff --git a/apps/client/local_plugins/azure_speech/android/src/main/kotlin/com/yunqiinnovation/azure_speech/AzureSpeechPlugin.kt b/apps/client/local_plugins/azure_speech/android/src/main/kotlin/com/yunqiinnovation/azure_speech/AzureSpeechPlugin.kt index 29b480db..a05fca3a 100644 --- a/apps/client/local_plugins/azure_speech/android/src/main/kotlin/com/yunqiinnovation/azure_speech/AzureSpeechPlugin.kt +++ b/apps/client/local_plugins/azure_speech/android/src/main/kotlin/com/yunqiinnovation/azure_speech/AzureSpeechPlugin.kt @@ -7,6 +7,7 @@ import android.app.Activity import android.content.Intent import android.util.Log import com.yunqiinnovation.azure_speech.tools.ScreenCaptureManager +import com.yunqiinnovation.azure_speech.tools.AudioRecordingForegroundService import com.yunqiinnovation.azure_speech.tools.ScreenCaptureForegroundService import com.yunqiinnovation.azure_speech.tools.isCapturing import io.flutter.embedding.engine.plugins.activity.ActivityAware @@ -1181,9 +1182,39 @@ class AzureSpeechPlugin : BleService.Callback, FlutterPlugin, ActivityAware, override fun onMethodCall(@NonNull call: MethodCall, @NonNull result: Result) { when (call.method) { + // 供 Dart 主动控制前台服务:通话录音的音频来自耳机 SPP 流、 + // 不经过本插件的任何识别链路,但同样需要前台服务把进程钉住, + // 否则切后台后录音会在国内 ROM 上被悄悄掐断。 + "startAudioForegroundService" -> { + try { + AudioRecordingForegroundService.startService(context) + result.success(true) + } catch (e: Exception) { + result.error("START_FGS_ERROR", e.message, null) + } + } + + "stopAudioForegroundService" -> { + try { + AudioRecordingForegroundService.stopService(context) + result.success(true) + } catch (e: Exception) { + result.error("STOP_FGS_ERROR", e.message, null) + } + } + "startContinuousTranslation" -> { try { FileLogger.d(tag, "开启双向翻译") + // ⚠️ 通话翻译必须挂前台服务,否则切后台就废: + // - 国内 ROM(荣耀/华为尤其)会冻结甚至回收没有前台服务的后台进程, + // WebSocket 断掉、PCM 回灌停摆,而这一切不报任何错; + // - Android 9+ 后台应用拿不到麦克风,A 路(己方语音)直接静音。 + // 复用 ASR 那条链路已有的 microphone 型前台服务(manifest 里已声明)。 + // ⚠️ 它与 AzureAsrHelper 共用同一个服务实例:通话翻译(AST)和 + // 面对面/同传(ASR)是互斥模式,不会同时开,所以谁停都不会误伤对方。 + // 将来若两者能并存,这里要改成引用计数。 + AudioRecordingForegroundService.startService(context) if (currentAstProvider == "iflytek" ) { iflytekAstHelperA?.startContinuousTranslation() iflytekAstHelperB?.startContinuousTranslation() @@ -1207,6 +1238,7 @@ class AzureSpeechPlugin : BleService.Callback, FlutterPlugin, ActivityAware, "stopContinuousTranslation" -> { try { FileLogger.d(tag, "停止双向翻译") + AudioRecordingForegroundService.stopService(context) if (currentAstProvider == "iflytek" ) { iflytekAstHelperA?.stopContinuousTranslation() iflytekAstHelperB?.stopContinuousTranslation()