Browse Source

闹钟改成只在 App 内提示;通话翻译/通话录音挂前台服务;复刻档位日志提到 warn

1) 拾忆提醒不再发系统通知
   改成进程内 Timer + App 内弹窗。代价必须说清楚:**App 不在前台就不会提示**,
   它是「打开 App 时告诉你有什么要做」,不是系统闹钟。
   - 一个 Timer 只指向队首那条,响完武装下一条;定时器最长压 30 分钟,
     超过就到期重算(长定时器在移动端本来就不可靠)。
   - 错过的不补弹,否则早上打开 App 会糊一串昨天的提醒。
   - 仍保留 flutter_local_notifications,只用来撤老版本排进系统的那些排期
     (装过老包的机器上还留着,不撤的话改口径之后仍会收到、且没法关)。
     只撤自己的 id 区间,绝不 cancelAll(会干掉录音的前台通知)。
   - 顺带删掉上一版加的「通知权限未开启」提示条,不再需要。

2) 通话翻译 / 通话录音 切后台继续工作
   两条链路原先都没有前台服务:
   - 通话翻译走 AliyunBailianE2EHelper,与 AzureAsrHelper 不是同一条路,
     后者的前台服务管不到它;
   - 通话录音的音频来自耳机 SPP 流,压根不经过识别插件。
   没有前台服务时切后台会发生两件事且都不报错:国内 ROM(荣耀/华为尤其)
   冻结或回收进程,WebSocket 断、PCM 流停摆;Android 9+ 后台拿不到麦克风。
   → AST 的 start/stopContinuousTranslation 里起停 AudioRecordingForegroundService;
     另开两个 method channel 口子(startAudioForegroundService/stop…)给
     通话录音用。三条链路互斥使用,暂不做引用计数,注释里写明并存时要改。
   ⚠️ 前台服务自带常驻通知,用户关掉通知权限后它不显示但服务照常跑——
     所以第 1 条「不要系统通知」和这一条不冲突。

3) 声音复刻的档位日志提到 warn 级
   [TRANS-INIT] 命中哪一档、[STS] initialize 的 voiceClone 开关,原先是 info,
   release 包里看不到。而「有没有复刻」完全取决于命中的是 alibaba35 还是旧版档
   (旧版模型静默忽略 enable_voice_clone),不看这两行只能靠猜。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
main
Rodger-Wang 1 month ago
parent
commit
46ebf28d64
  1. 52
      apps/client/lib/core/utils/audio_foreground_service.dart
  2. 5
      apps/client/lib/data/services/bes_call_recording_service.dart
  3. 4
      apps/client/lib/data/services/language_manager.dart
  4. 315
      apps/client/lib/data/services/memory_reminder_scheduler.dart
  5. 5
      apps/client/lib/data/services/speech_impl/azure_ast_service.dart
  6. 53
      apps/client/lib/modules/home/views/home_view.dart
  7. 32
      apps/client/local_plugins/azure_speech/android/src/main/kotlin/com/yunqiinnovation/azure_speech/AzureSpeechPlugin.kt

52
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<void> start() async {
if (!Platform.isAndroid) return;
try {
await _channel.invokeMethod('startAudioForegroundService');
} catch (e) {
// 起不来不该阻断录音本身:前台是为了「切后台还能录」,
// 停在前台的那部分照样是好的。
Logger.w(_tag, '启动前台服务失败(前台使用不受影响): $e');
}
}
static Future<void> stop() async {
if (!Platform.isAndroid) return;
try {
await _channel.invokeMethod('stopAudioForegroundService');
} catch (e) {
Logger.w(_tag, '停止前台服务失败: $e');
}
}
}

5
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;

4
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);

315
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<MemoryReminderScheduler>();
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<MemoryRemindSlot> _pending = [];
/// 上一次真正排出去的 id,取消时按它逐条撤
final List<int> _scheduledIds = [];
/// 指向队首那条的定时器
Timer? _timer;
/// 权限被拒时置 true,页面据此显示一条静态提示(不是弹窗)指向权限管理页
final RxBool permissionDenied = false.obs;
/// 正在展示提醒弹窗——同一时刻挤进来两条时不叠第二个 dialog
bool _showing = false;
Future<void> _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<void> _ensurePlugin() async {
if (_pluginReady) return;
@ -116,24 +103,7 @@ class MemoryReminderScheduler extends GetxService {
}
}
/// 通知权限是否已授予。
///
/// ⚠️ **只查不申请**。申请必须走 PermissionUtil 的 requestPermission(它收敛了
/// 48 小时冷却),而且只允许在「设置 → 权限管理」里由用户主动发起。
/// 这里主动申请会直接违反那条合规规则。
Future<bool> _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<void> reschedule() async {
if (!await _hasPermission()) {
Logger.i(_tag, '无通知权限,降级为「打开 App 才提醒」');
await _cancelAllOurs();
return;
}
await _ensureTz();
await _ensurePlugin();
await _cancelLegacyNotifications();
final now = DateTime.now();
final slots = <MemoryRemindSlot>[
...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<void> _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<void> _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<void> clear() async {
await _ensurePlugin();
await _cancelAllOurs();
_timer?.cancel();
_timer = null;
_pending.clear();
await _cancelLegacyNotifications();
}
@override
void onClose() {
_timer?.cancel();
super.onClose();
}
}

5
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');
// 底层会初始化前释放

53
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<HomeController> {
@ -79,7 +78,6 @@ class HomeView extends GetView<HomeController> {
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<HomeController> {
);
}
/// 通知权限没开时的静态提示条。
///
/// ⚠️ 这是**唯一**能让用户知道「闹钟不会响」的地方。
/// MemoryReminderScheduler.permissionDenied 这个标志一直存在,但在此之前
/// 没有任何 UI 在读它 —— 于是没通知权限时的表现是:助手说闹钟定好了、
/// 拾忆页也确实列出来了,到点却一声不响,而且没有任何解释。
///
/// ⚠️ 刻意做成常驻的一条,**不是弹窗**:申请通知权限必须由用户在
/// 「设置 → 权限管理」里主动发起(PermissionUtil 有 48 小时合规冷却,
/// 拒过一次之后两天内 App 不被允许再弹窗解释)。这里只能引导,不能代劳。
Widget _buildNotifyDisabledBar(bool isDarkMode) {
if (!Get.isRegistered<MemoryReminderScheduler>()) {
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<CalendarController>();

32
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()

Loading…
Cancel
Save