From f38b91a22fd856170b22229e541478d09abe1ea3 Mon Sep 17 00:00:00 2001 From: Rodger-Wang <1367893453@qq.com> Date: Sat, 24 Oct 2026 02:00:01 +0800 Subject: [PATCH] =?UTF-8?q?client=EF=BC=9A=E5=90=8C=E6=AD=A5=20iOS=20?= =?UTF-8?q?=E6=9D=83=E9=99=90=E5=BB=B6=E5=90=8E=E3=80=81=E5=8E=BB=E6=8E=89?= =?UTF-8?q?=20CallKit=EF=BC=8C=E5=B9=B6=E5=9C=A8=E9=A6=96=E6=AC=A1?= =?UTF-8?q?=E8=B0=83=E7=94=A8=E7=AC=AC=E4=B8=89=E6=96=B9=E6=A8=A1=E5=9E=8B?= =?UTF-8?q?=E5=89=8D=E5=BE=81=E5=BE=97=E5=90=8C=E6=84=8F=E3=80=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 默认昵称不再使用会被词库拦下的 User_ 前缀,资料保存时名字未改不再整单拒绝。 Co-authored-by: Cursor --- apps/client/ios/Podfile | 9 +- apps/client/ios/Podfile.lock | 2 +- apps/client/ios/Runner/AppDelegate.swift | 17 ++++ apps/client/lib/data/models/user_Info.dart | 21 ++++ .../lib/data/services/ai_data_consent.dart | 97 +++++++++++++++++++ .../alibaba_image_translation_service.dart | 2 + .../services/bailian_multimodal_service.dart | 5 + .../data/services/bailian_native_dialog.dart | 5 + .../data/services/deapsound_ai_service.dart | 7 ++ .../meeting/meeting_task_service.dart | 5 + .../services/server_translation_service.dart | 2 + .../speech_impl/azure_asr_service.dart | 8 ++ .../speech_impl/azure_ast_service.dart | 10 ++ .../devices/bes/bes_bluetooth_service.dart | 36 +++++-- .../views/bottomSheet/ai_bottom_sheet.dart | 13 +++ .../views/template/template_edit_view.dart | 7 ++ .../ios/Classes/AmapServicePlugin.swift | 28 +++++- .../Sources/tools/SimpleAudioReceiver.swift | 18 +--- .../Sources/ble_service/BleService.swift | 25 ++++- .../ClassicBluetoothHelper.swift | 31 +++++- .../device_jieli/JieliHomeServer.swift | 37 ++++++- .../Sources/music_service/MusicService.swift | 5 +- .../Sources/recorder_holder/BleService.swift | 25 ++++- .../ClassicBluetoothHelper.swift | 6 +- apps/services/modules/user/api_setting.go | 16 ++- apps/services/modules/user/api_sgin.go | 11 ++- 26 files changed, 403 insertions(+), 45 deletions(-) create mode 100644 apps/client/lib/data/services/ai_data_consent.dart diff --git a/apps/client/ios/Podfile b/apps/client/ios/Podfile index af1e8479..e5f084f1 100644 --- a/apps/client/ios/Podfile +++ b/apps/client/ios/Podfile @@ -123,10 +123,17 @@ post_install do |installer| ## dart: PermissionGroup.bluetooth 'PERMISSION_BLUETOOTH=1', + + ## 不追踪用户。设成 1 会把 ATTrackingManager 编进包, + ## App Store Connect 就会要求隐私答复把数据标成「用于追踪」。 + 'PERMISSION_APP_TRACKING_TRANSPARENCY=0', ] - # 禁用用户跟踪相关功能 + # 禁用用户跟踪相关功能。后面这一条必须再写一次:上面的数组用的是 ||=, + # 构建配置里已经有 GCC_PREPROCESSOR_DEFINITIONS 时不会执行,缓存里若还留着 =1, + # 新包仍会带上追踪接口。 config.build_settings['GCC_PREPROCESSOR_DEFINITIONS'] << 'FBSDK_DISABLE_IDFA=1' + config.build_settings['GCC_PREPROCESSOR_DEFINITIONS'] << 'PERMISSION_APP_TRACKING_TRANSPARENCY=0' # 这里原先有一段 config.build_settings.delete('NSUserTrackingUsageDescription'), # 意图是"不要用户追踪",但**完全无效**:那个 key 写在 Runner/Info.plist 里, diff --git a/apps/client/ios/Podfile.lock b/apps/client/ios/Podfile.lock index c7cddc18..81df206d 100644 --- a/apps/client/ios/Podfile.lock +++ b/apps/client/ios/Podfile.lock @@ -110,6 +110,6 @@ SPEC CHECKSUMS: WechatOpenSDK-XCFramework: 5df9b250e9839dcc306ad8b00f46822eead1ed47 wifi_iot: b5aafd6f9b52f8a357383a1deabab45f31cd602d -PODFILE CHECKSUM: 615a00a0854e6c6a2c6db75ecb9a29206ab3e341 +PODFILE CHECKSUM: 989d66a7b31571adcd303adc2d5f8bea9f7b32ce COCOAPODS: 1.16.2 diff --git a/apps/client/ios/Runner/AppDelegate.swift b/apps/client/ios/Runner/AppDelegate.swift index 580165e6..f11dd218 100644 --- a/apps/client/ios/Runner/AppDelegate.swift +++ b/apps/client/ios/Runner/AppDelegate.swift @@ -19,6 +19,23 @@ import UIKit /// 插件必须挂到场景创建出来的引擎上。留在 didFinishLaunching 里注册会挂到还没建好的引擎。 func didInitializeImplicitFlutterEngine(_ engineBridge: FlutterImplicitEngineBridge) { GeneratedPluginRegistrant.register(with: engineBridge.pluginRegistry) + // 本地网络(高德)和蓝牙(CBCentralManager)不要在插件注册时创建。 + // 首次安装会在启动页就弹出系统权限框。等登录成功后 Dart 调 arm, + // 各插件收到通知再初始化。已经登录过的下次冷启动,UserDefaults 为真, + // 插件注册时直接初始化,系统不会再弹框。 + let channel = FlutterMethodChannel( + name: "com.yimai.eaimar/hardware_prompt", + binaryMessenger: engineBridge.applicationRegistrar.messenger() + ) + channel.setMethodCallHandler { call, result in + guard call.method == "arm" else { + result(FlutterMethodNotImplemented) + return + } + UserDefaults.standard.set(true, forKey: "sp_hardware_ready") + NotificationCenter.default.post(name: Notification.Name("sp.hardware.arm"), object: nil) + result(nil) + } } override func applicationDidBecomeActive(_ application: UIApplication) { diff --git a/apps/client/lib/data/models/user_Info.dart b/apps/client/lib/data/models/user_Info.dart index badc6970..a0caf025 100644 --- a/apps/client/lib/data/models/user_Info.dart +++ b/apps/client/lib/data/models/user_Info.dart @@ -1,3 +1,6 @@ +import 'dart:io'; + +import 'package:flutter/services.dart'; import 'package:get_storage/get_storage.dart'; import '../services/id_verify_service.dart'; import 'package:get/get.dart'; @@ -7,6 +10,22 @@ class User { // 单例实例 static User? _instance; + /// 本地网络(高德)和蓝牙(CBCentralManager)的系统框。 + /// + /// 插件注册时不创建这些对象,否则首次安装一打开就弹。登录成功后调一次, + /// 原生收到再初始化。已经点过允许/不允许的,系统不会再弹。 + static bool _hardwareArmed = false; + static const _hardwareChannel = MethodChannel('com.yimai.eaimar/hardware_prompt'); + + static void armHardwarePrompts() { + if (!Platform.isIOS || _hardwareArmed) return; + _hardwareArmed = true; + _hardwareChannel.invokeMethod('arm').catchError((Object e) { + _hardwareArmed = false; + Logger.w('User', '延后本地网络/蓝牙初始化失败: $e'); + }); + } + /// 全局刷新信号(当用户信息更新时 +1,用于驱动UI重建) static final RxInt refreshTick = 0.obs; @@ -147,6 +166,7 @@ class User { // (见 DeviceAuth.bindLoginRetry)。原来只有 updateUserInfo 会 +1, // 于是「先连设备后登录」这条路上没有任何人知道登录已经完成。 refreshTick.value++; + armHardwarePrompts(); Logger.i("User", "初始化用户: uid=${u.uid}, name=${u.name}, gender=${u.gender}, viplv=${u.viplv}, vipexptime=${u.vipexptime}, ai聊天=${u.aichatintegral}/${u.aichattotalintegral}, 翻译=${u.tradeintegral}/${u.tradetotalintegral}, 会议=${u.meetintegral}/${u.meettotalintegral}"); return u; @@ -185,6 +205,7 @@ class User { ); _instance = u; refreshTick.value++; // 同上:token 登录(冷启动恢复会话)也要发信号 + armHardwarePrompts(); Logger.i("User", "初始化用户(token): uid=${u.uid}, name=${u.name}, gender=${u.gender}, viplv=${u.viplv}, vipexptime=${u.vipexptime}, ai聊天=${u.aichatintegral}/${u.aichattotalintegral}, 翻译=${u.tradeintegral}/${u.tradetotalintegral}, 会议=${u.meetintegral}/${u.meettotalintegral}"); return u; diff --git a/apps/client/lib/data/services/ai_data_consent.dart b/apps/client/lib/data/services/ai_data_consent.dart new file mode 100644 index 00000000..360fa232 --- /dev/null +++ b/apps/client/lib/data/services/ai_data_consent.dart @@ -0,0 +1,97 @@ +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; +import 'package:get_storage/get_storage.dart'; + +/// 用户拒绝把数据发给这家服务商。调用方据此停掉本次请求,不要再弹错误提示。 +class AiDataConsentDenied implements Exception { + const AiDataConsentDenied(); + @override + String toString() => 'AiDataConsentDenied'; +} + +/// 第一次把个人数据发给某一家第三方 AI 之前,先说明发什么、发给谁,并取得同意。 +/// +/// 同意记在本机,之后同一家不再问。不同意不落盘,下次再使用还会问; +/// 短时间内的自动重试不会把确认框连弹两次。 +enum AiDataVendor { alibaba, microsoft, iflytek, meeting } + +class AiDataConsent { + static final Map> _inflight = {}; + static final Map _declinedAt = {}; + + static String _key(AiDataVendor vendor) => 'ai_data_consent_${vendor.name}'; + + static Future allow(AiDataVendor vendor) async { + if (GetStorage().read(_key(vendor)) == true) return true; + final declined = _declinedAt[vendor]; + if (declined != null && + DateTime.now().difference(declined) < const Duration(seconds: 2)) { + return false; + } + final pending = _inflight[vendor]; + if (pending != null) return pending; + final fut = _ask(vendor); + _inflight[vendor] = fut; + try { + final ok = await fut; + if (ok) { + await GetStorage().write(_key(vendor), true); + } else { + _declinedAt[vendor] = DateTime.now(); + } + return ok; + } finally { + _inflight.remove(vendor); + } + } + + static Future _ask(AiDataVendor vendor) async { + final zh = (Get.locale ?? Get.deviceLocale) + ?.languageCode + .toLowerCase() + .startsWith('zh') ?? + false; + final result = await Get.dialog( + PopScope( + canPop: false, + child: AlertDialog( + title: Text(zh ? '发送数据确认' : 'Confirm data sharing'), + content: Text(_body(vendor, zh)), + actions: [ + TextButton( + onPressed: () => Get.back(result: false), + child: Text(zh ? '不同意' : "Don't Allow"), + ), + TextButton( + onPressed: () => Get.back(result: true), + child: Text(zh ? '同意' : 'Allow'), + ), + ], + ), + ), + barrierDismissible: false, + ); + return result == true; + } + + static String _body(AiDataVendor vendor, bool zh) { + switch (vendor) { + case AiDataVendor.alibaba: + return zh + ? '继续将把你的语音、对话内容、待翻译文字或照片发送给阿里云(通义千问、百炼),用于翻译、对话和图片识别。不同意则不会发送。' + : 'To continue, your voice, conversation, text, or photos will be sent to Alibaba Cloud (Tongyi Qianwen / Bailian) for translation, chat, and image recognition. If you do not agree, nothing will be sent.'; + case AiDataVendor.microsoft: + return zh + ? '继续将把你的语音发送给微软(Azure 语音服务),用于语音识别和翻译。不同意则不会发送。' + : 'To continue, your voice will be sent to Microsoft (Azure Speech) for speech recognition and translation. If you do not agree, nothing will be sent.'; + case AiDataVendor.iflytek: + return zh + ? '继续将把你的语音发送给科大讯飞,用于语音识别和翻译。不同意则不会发送。' + : 'To continue, your voice will be sent to iFLYTEK for speech recognition and translation. If you do not agree, nothing will be sent.'; + case AiDataVendor.meeting: + return zh + ? '继续将把这段录音上传到 EAIMAR 服务器,并交给第三方做转写和总结。接收方由当前服务配置决定,可能是阿里云(通义)、微软(Azure 语音)或字节跳动(火山引擎)。不同意则不会提交。' + : 'To continue, this recording will be uploaded to EAIMAR servers and sent to a third party for transcription and summary. Depending on the current service configuration, the recipient may be Alibaba Cloud (Tongyi), Microsoft (Azure Speech), or ByteDance (Volcano Engine). If you do not agree, it will not be submitted.'; + } + } +} diff --git a/apps/client/lib/data/services/alibaba_image_translation_service.dart b/apps/client/lib/data/services/alibaba_image_translation_service.dart index c5d8198d..41fe629d 100644 --- a/apps/client/lib/data/services/alibaba_image_translation_service.dart +++ b/apps/client/lib/data/services/alibaba_image_translation_service.dart @@ -7,6 +7,7 @@ import 'package:http/http.dart' as http; import '../../core/utils/logger.dart'; import '../../data/models/appconfig.dart'; import '../../modules/picture_translation/models/picture_translation_models.dart'; +import 'ai_data_consent.dart'; import 'language_manager.dart'; /// 阿里云百炼(通义千问 VL)图片翻译服务。 @@ -73,6 +74,7 @@ class AlibabaImageTranslationService extends GetxService { required String targetLanguageCode, }) async { if (!_isInitialized && !await initialize()) return null; + if (!await AiDataConsent.allow(AiDataVendor.alibaba)) return null; try { final targetName = diff --git a/apps/client/lib/data/services/bailian_multimodal_service.dart b/apps/client/lib/data/services/bailian_multimodal_service.dart index fa6e7fc2..7fb47e2c 100644 --- a/apps/client/lib/data/services/bailian_multimodal_service.dart +++ b/apps/client/lib/data/services/bailian_multimodal_service.dart @@ -9,6 +9,7 @@ import 'package:web_socket_channel/io.dart'; import '../../core/utils/logger.dart'; import '../models/appconfig.dart'; import '../models/assistant_directive.dart'; +import 'ai_data_consent.dart'; import 'device_location_service.dart'; /// 对话状态(对应协议的 DialogStateChanged) @@ -246,6 +247,10 @@ class BailianMultimodalService extends GetxService { text: configHint, code: 'NOT_CONFIGURED', fatal: true)); return false; } + if (!await AiDataConsent.allow(AiDataVendor.alibaba)) { + Logger.w(_tag, '用户未同意向阿里云发送对话数据'); + return false; + } await stop(); _downstreamRate = downstreamRate; diff --git a/apps/client/lib/data/services/bailian_native_dialog.dart b/apps/client/lib/data/services/bailian_native_dialog.dart index 28191e07..2fc09a8a 100644 --- a/apps/client/lib/data/services/bailian_native_dialog.dart +++ b/apps/client/lib/data/services/bailian_native_dialog.dart @@ -7,6 +7,7 @@ import 'package:flutter/services.dart'; import '../../core/utils/logger.dart'; import '../models/assistant_directive.dart'; +import 'ai_data_consent.dart'; import 'bailian_multimodal_service.dart'; /// 耳机唤醒那条 EMAI 链路的**原生**百炼客户端句柄。 @@ -108,6 +109,10 @@ class BailianNativeDialog { text: configHint, code: 'NOT_CONFIGURED', fatal: true)); return false; } + if (!await AiDataConsent.allow(AiDataVendor.alibaba)) { + Logger.w(_tag, '用户未同意向阿里云发送对话数据'); + return false; + } await stop(); directiveSource = source; _sub = _events.receiveBroadcastStream().listen( diff --git a/apps/client/lib/data/services/deapsound_ai_service.dart b/apps/client/lib/data/services/deapsound_ai_service.dart index c31fcf67..9a8cddec 100644 --- a/apps/client/lib/data/services/deapsound_ai_service.dart +++ b/apps/client/lib/data/services/deapsound_ai_service.dart @@ -4,6 +4,7 @@ import 'package:flutter/foundation.dart' show visibleForTesting; import 'package:http/http.dart' as http; import '../models/appconfig.dart'; +import 'ai_data_consent.dart'; import 'ai_service.dart'; /// 语音纪要「Ask AI」用的文本大模型(OpenAI 兼容接口,客户端直连)。 @@ -49,6 +50,9 @@ class DeapsoundAIService implements AiService { String? systemPrompt, String? userProperties, }) async { + if (!await AiDataConsent.allow(AiDataVendor.alibaba)) { + throw const AiDataConsentDenied(); + } final url = chatUrl; final headers = { @@ -85,6 +89,9 @@ class DeapsoundAIService implements AiService { String? systemPrompt, String? userProperties, }) async* { + if (!await AiDataConsent.allow(AiDataVendor.alibaba)) { + throw const AiDataConsentDenied(); + } final client = http.Client(); final url = chatUrl; diff --git a/apps/client/lib/data/services/meeting/meeting_task_service.dart b/apps/client/lib/data/services/meeting/meeting_task_service.dart index ec80cdfc..ef02749f 100644 --- a/apps/client/lib/data/services/meeting/meeting_task_service.dart +++ b/apps/client/lib/data/services/meeting/meeting_task_service.dart @@ -4,6 +4,7 @@ import 'package:get/get.dart'; import 'package:get_storage/get_storage.dart'; import '../../../core/utils/logger.dart'; +import '../ai_data_consent.dart'; import '../db/sqflite_api.dart'; import '../network/api.dart'; import '../../models/user_Info.dart'; @@ -152,6 +153,10 @@ class MeetingTaskService extends GetxService { String tid, { String summarySvcId = '', }) async { + if (!await AiDataConsent.allow(AiDataVendor.meeting)) { + Logger.w('MeetingTask', '用户未同意把录音交给第三方转写'); + return false; + } final resp = await Api.startTaskEchomeet({ 'id': id, 'formlanguage': formlanguage, diff --git a/apps/client/lib/data/services/server_translation_service.dart b/apps/client/lib/data/services/server_translation_service.dart index 938d9b3c..5eb9d2dd 100644 --- a/apps/client/lib/data/services/server_translation_service.dart +++ b/apps/client/lib/data/services/server_translation_service.dart @@ -2,6 +2,7 @@ import 'package:dio/dio.dart'; import 'package:get/get.dart'; import '../../core/utils/logger.dart'; +import 'ai_data_consent.dart'; import 'network/api.dart'; /// 文本机器翻译:由**服务端**代调后台「第三方服务配置」里启用的 MT 服务。 @@ -60,6 +61,7 @@ class ServerTranslationService extends GetxService { }) async { final src = text.trim(); if (src.isEmpty) return null; + if (!await AiDataConsent.allow(AiDataVendor.alibaba)) return null; try { final data = await Api.userTranslate({ 'text': src, diff --git a/apps/client/lib/data/services/speech_impl/azure_asr_service.dart b/apps/client/lib/data/services/speech_impl/azure_asr_service.dart index b3f4bf03..d70cee91 100644 --- a/apps/client/lib/data/services/speech_impl/azure_asr_service.dart +++ b/apps/client/lib/data/services/speech_impl/azure_asr_service.dart @@ -5,6 +5,7 @@ import '../../../data/models/appconfig.dart'; import 'package:flutter/services.dart'; import '../../../core/utils/logger.dart'; import 'package:get/get.dart'; +import '../ai_data_consent.dart'; import '../asr_service.dart'; /// Azure 语音识别服务 @@ -218,6 +219,13 @@ class AzureAsrService extends GetxService implements AsrService { await stopContinuousRecognition(); } + // 通话模式的识别走 AST,不在这里把语音交给微软。 + if (!skipAsrForCallMode && + !await AiDataConsent.allow(AiDataVendor.microsoft)) { + Logger.warning('ASR: 用户未同意向微软发送语音'); + return false; + } + try { // 开始连续识别 final bool result = 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 6317a2cc..cce0f59c 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 @@ -3,6 +3,7 @@ import '../../../data/models/appconfig.dart'; import 'package:flutter/services.dart'; import '../../../core/utils/logger.dart'; import 'package:get/get.dart'; +import '../ai_data_consent.dart'; import '../ast_service.dart'; /// 阿里通义实时翻译的两个模型名。 @@ -491,6 +492,15 @@ class AzureAstService extends GetxService implements AstService { }) async { _lastProvider = provider; if (supportedLanguages != null) _lastLanguages = List.from(supportedLanguages); + final AiDataVendor vendor = switch (provider) { + 'alibaba' || 'alibaba35' => AiDataVendor.alibaba, + 'iflytek' => AiDataVendor.iflytek, + _ => AiDataVendor.microsoft, + }; + if (!await AiDataConsent.allow(vendor)) { + Logger.warning('[STS] initialize: 用户未同意向 $provider 发送数据'); + return false; + } try { final List languages = supportedLanguages ?? _defaultSupportedLanguages; diff --git a/apps/client/lib/devices/bes/bes_bluetooth_service.dart b/apps/client/lib/devices/bes/bes_bluetooth_service.dart index bdf6ba27..a9630b4d 100644 --- a/apps/client/lib/devices/bes/bes_bluetooth_service.dart +++ b/apps/client/lib/devices/bes/bes_bluetooth_service.dart @@ -9,6 +9,7 @@ import 'package:get/get.dart'; import 'package:get_storage/get_storage.dart'; import '../../core/utils/logger.dart'; +import '../../data/models/user_Info.dart'; import '../../data/services/background_launch_consent.dart'; import 'bes_device_auth.dart'; import 'bes_protocol.dart'; @@ -116,13 +117,24 @@ class BesBluetoothService extends GetxService { bool get hasPairedDevice => getPairedDevices().isNotEmpty; + Worker? _iosLoginBootstrap; + @override void onInit() { super.onInit(); _listen(); + if (Platform.isIOS) { + // 首次安装时用户还没登录,不要在启动页创建 CBCentralManager。 + // 登录成功 User.refreshTick 会加一,到那时再初始化。 + _iosLoginBootstrap = ever(User.refreshTick, (_) { + if (User.isLoggedIn()) unawaited(_bootstrapIosOnce()); + }); + } Future.delayed(const Duration(seconds: 2), _bootstrap); } + bool _iosBootstrapped = false; + /// 冷启动引导。两端的前提完全不同: /// /// - **Android**:原生 `BluetoothManager.setup()` 在引擎 attach 时就跑了,ACL 广播 @@ -130,14 +142,25 @@ class BesBluetoothService extends GetxService { /// 免得没绑设备的用户一进 App 就被弹权限框。 /// - **iOS**:CBCentralManager 是在 `initialize()` 里才创建的,不调就什么都没有—— /// 既收不到状态流,`registerForConnectionEvents` 也没注册, - /// 耳机连上时的 `.peerConnected` 被动接管自然不会发生。所以 iOS 必须无条件初始化。 + /// 耳机连上时的 `.peerConnected` 被动接管自然不会发生。 + /// 但首次安装不能在启动页就建:那会先于登录弹出「查找蓝牙设备」。 + /// 已登录(含冷启动恢复会话)才初始化。 Future _bootstrap() async { if (Platform.isIOS) { - try { - await initialize(); - } catch (e) { - Logger.e(_tag, '恒玄蓝牙初始化失败: $e'); - } + if (!User.isLoggedIn()) return; + await _bootstrapIosOnce(); + return; + } + await tryAutoConnect(); + } + + Future _bootstrapIosOnce() async { + if (_iosBootstrapped) return; + _iosBootstrapped = true; + try { + await initialize(); + } catch (e) { + Logger.e(_tag, '恒玄蓝牙初始化失败: $e'); } await tryAutoConnect(); } @@ -175,6 +198,7 @@ class BesBluetoothService extends GetxService { @override void onClose() { + _iosLoginBootstrap?.dispose(); _statusSubscription?.cancel(); _cmdEventSubscription?.cancel(); _batteryRetryTimer?.cancel(); diff --git a/apps/client/lib/modules/meeting/views/bottomSheet/ai_bottom_sheet.dart b/apps/client/lib/modules/meeting/views/bottomSheet/ai_bottom_sheet.dart index 0e4401ba..71f114e7 100644 --- a/apps/client/lib/modules/meeting/views/bottomSheet/ai_bottom_sheet.dart +++ b/apps/client/lib/modules/meeting/views/bottomSheet/ai_bottom_sheet.dart @@ -6,6 +6,7 @@ import 'package:flutter_screenutil/flutter_screenutil.dart'; import 'package:get/get.dart'; import '../../../../data/services/db/sqflite_api.dart'; +import '../../../../data/services/ai_data_consent.dart'; import '../../../../data/services/deapsound_ai_service.dart'; import '../../controllers/meeting_details_controller.dart'; @@ -494,6 +495,18 @@ class _AIBottomSheetState extends State { // 异步异常,onDone 不会触发,_isLoading 永远 true —— 表现就是「Ask AI 点了没反应, // 再点也没反应」,且不报任何错。现在把错误写进气泡并恢复可用。 onError: (Object e) { + if (e is AiDataConsentDenied) { + if (mounted) { + setState(() { + if (_dataList.isNotEmpty && + (_dataList.first['assistant'] as String? ?? '').isEmpty) { + _dataList.removeAt(0); + } + _isLoading = false; + }); + } + return; + } final msg = e.toString().replaceFirst('Exception: ', ''); if (mounted) { setState(() { diff --git a/apps/client/lib/modules/meeting/views/template/template_edit_view.dart b/apps/client/lib/modules/meeting/views/template/template_edit_view.dart index 0c31f360..bb3ba778 100644 --- a/apps/client/lib/modules/meeting/views/template/template_edit_view.dart +++ b/apps/client/lib/modules/meeting/views/template/template_edit_view.dart @@ -368,6 +368,13 @@ class _TemplateEditViewState extends State { _isLoading = false; }); }, + onError: (Object e) { + if (mounted) { + setState(() { + _isLoading = false; + }); + } + }, ); } } diff --git a/apps/client/local_plugins/amap_service/ios/Classes/AmapServicePlugin.swift b/apps/client/local_plugins/amap_service/ios/Classes/AmapServicePlugin.swift index d23e467c..18ff18dd 100644 --- a/apps/client/local_plugins/amap_service/ios/Classes/AmapServicePlugin.swift +++ b/apps/client/local_plugins/amap_service/ios/Classes/AmapServicePlugin.swift @@ -2,6 +2,26 @@ import Flutter import UIKit import AMapNaviKit +/// 登录之后才允许创建会弹出系统权限框的对象。键和通知名与 AppDelegate 一致。 +enum PostLoginHardware { + static let readyKey = "sp_hardware_ready" + static let armNote = Notification.Name("sp.hardware.arm") + + static func whenReady(_ body: @escaping () -> Void) { + if UserDefaults.standard.bool(forKey: readyKey) { + DispatchQueue.main.async(execute: body) + return + } + var token: NSObjectProtocol? + token = NotificationCenter.default.addObserver( + forName: armNote, object: nil, queue: .main + ) { _ in + if let token { NotificationCenter.default.removeObserver(token) } + body() + } + } +} + public class AmapServicePlugin: NSObject, FlutterPlugin, FlutterStreamHandler { private static var channel: FlutterMethodChannel? private var eventSink: FlutterEventSink? @@ -19,12 +39,12 @@ public class AmapServicePlugin: NSObject, FlutterPlugin, FlutterStreamHandler { let instance = AmapServicePlugin() eventChannel.setStreamHandler(instance) - - // 高德SDK初始化 - DispatchQueue.main.async { + + // 高德一初始化就会触发「查找本地网络中的设备」。延到登录之后。 + PostLoginHardware.whenReady { AMapServices.shared()?.apiKey = "6144a636c24dc9f2e1e57f7597849cf7" AMapServices.shared()?.enableHTTPS = true - + if #available(iOS 13.0, *) { AMapNaviManagerConfig.shared().updatePrivacyShow(.didShow, privacyInfo: .didContain) AMapNaviManagerConfig.shared().updatePrivacyAgree(.didAgree) diff --git a/apps/client/local_plugins/azure_speech/ios/azure_speech/Sources/tools/SimpleAudioReceiver.swift b/apps/client/local_plugins/azure_speech/ios/azure_speech/Sources/tools/SimpleAudioReceiver.swift index f4e7a1c2..48fdd6ce 100644 --- a/apps/client/local_plugins/azure_speech/ios/azure_speech/Sources/tools/SimpleAudioReceiver.swift +++ b/apps/client/local_plugins/azure_speech/ios/azure_speech/Sources/tools/SimpleAudioReceiver.swift @@ -2,7 +2,6 @@ import Foundation import AVFoundation import MicrosoftCognitiveServicesSpeech import os.log -import CallKit /** * 简单音频接收器类,用于处理音频录制和流传输 @@ -16,9 +15,6 @@ public class SimpleAudioReceiver: NSObject { private let tag = "SimpleAudioReceiver" private let log = OSLog(subsystem: "com.azure.speech", category: "SimpleAudioReceiver") - // 用于检测系统电话通话状态(iOS10+) - private let callObserver = CXCallObserver() - /** * 麦克风捕获实例 * 使用 MicrophoneCapture 的单例模式 @@ -431,18 +427,10 @@ public class SimpleAudioReceiver: NSObject { } } - /** - * 检测是否存在正在进行的系统电话通话 - * - iOS10+:使用 CallKit 的 CXCallObserver - * - 旧系统:使用 secondaryAudioShouldBeSilencedHint 近似判断 - */ + /// 系统电话占用时,音频会话需要让路。 + /// 不用 CallKit:中国区上架不能链接该框架。这里用系统给出的「应静音」提示判断。 private func isTelephonyActive() -> Bool { - if #available(iOS 10.0, *) { - return callObserver.calls.contains { !$0.hasEnded } - } else { - // 非精确,但可作为兜底判断:若需要静音则可能有电话或系统占用 - return AVAudioSession.sharedInstance().secondaryAudioShouldBeSilencedHint - } + AVAudioSession.sharedInstance().secondaryAudioShouldBeSilencedHint } /** diff --git a/apps/client/local_plugins/ble_service/ios/ble_service/Sources/ble_service/BleService.swift b/apps/client/local_plugins/ble_service/ios/ble_service/Sources/ble_service/BleService.swift index 1a87a654..48a63ca2 100644 --- a/apps/client/local_plugins/ble_service/ios/ble_service/Sources/ble_service/BleService.swift +++ b/apps/client/local_plugins/ble_service/ios/ble_service/Sources/ble_service/BleService.swift @@ -3,6 +3,26 @@ import CoreBluetooth import os.log import AVFoundation +/// 登录之后才创建 CBCentralManager。键和通知名与 AppDelegate 一致。 +enum PostLoginHardware { + static let readyKey = "sp_hardware_ready" + static let armNote = Notification.Name("sp.hardware.arm") + + static func whenReady(_ body: @escaping () -> Void) { + if UserDefaults.standard.bool(forKey: readyKey) { + DispatchQueue.main.async(execute: body) + return + } + var token: NSObjectProtocol? + token = NotificationCenter.default.addObserver( + forName: armNote, object: nil, queue: .main + ) { _ in + if let token { NotificationCenter.default.removeObserver(token) } + body() + } + } +} + /// BLE服务类 - 传统BLE扫描和连接实现 /// /// 核心特性: @@ -128,8 +148,11 @@ public class BleService: NSObject { // MARK: - 初始化 private override init() { super.init() - initializeCentralManager() initializeOpusProcessor() + // 创建 CBCentralManager 会立刻弹「查找蓝牙设备」。首次安装延到登录之后。 + PostLoginHardware.whenReady { [weak self] in + self?.initializeCentralManager() + } } // 初始化中央管理器 diff --git a/apps/client/local_plugins/classic_bluetooth/ios/classic_bluetooth/Sources/classic_bluetooth/ClassicBluetoothHelper.swift b/apps/client/local_plugins/classic_bluetooth/ios/classic_bluetooth/Sources/classic_bluetooth/ClassicBluetoothHelper.swift index dde0287f..4385f95b 100644 --- a/apps/client/local_plugins/classic_bluetooth/ios/classic_bluetooth/Sources/classic_bluetooth/ClassicBluetoothHelper.swift +++ b/apps/client/local_plugins/classic_bluetooth/ios/classic_bluetooth/Sources/classic_bluetooth/ClassicBluetoothHelper.swift @@ -2,6 +2,26 @@ import Foundation import AVFoundation import CoreBluetooth import ble_service + +/// 登录之后才创建 CBCentralManager。键和通知名与 AppDelegate 一致。 +enum PostLoginHardware { + static let readyKey = "sp_hardware_ready" + static let armNote = Notification.Name("sp.hardware.arm") + + static func whenReady(_ body: @escaping () -> Void) { + if UserDefaults.standard.bool(forKey: readyKey) { + DispatchQueue.main.async(execute: body) + return + } + var token: NSObjectProtocol? + token = NotificationCenter.default.addObserver( + forName: armNote, object: nil, queue: .main + ) { _ in + if let token { NotificationCenter.default.removeObserver(token) } + body() + } + } +} /// 蓝牙事件类型 public enum BluetoothEventType { case stateChanged @@ -39,9 +59,14 @@ public class ClassicBluetoothHelper: NSObject, CBCentralManagerDelegate { /// 私有初始化方法,防止外部创建实例 private override init() { super.init() - centralManager = CBCentralManager(delegate: self, queue: nil) - // 应用启动时自动开始监听 - enableAutoStart() + // 创建 CBCentralManager 会立刻弹「查找蓝牙设备」。首次安装延到登录之后。 + PostLoginHardware.whenReady { [weak self] in + guard let self else { return } + if self.centralManager == nil { + self.centralManager = CBCentralManager(delegate: self, queue: nil) + } + self.enableAutoStart() + } } /** diff --git a/apps/client/local_plugins/device_jieli/ios/device_jieli/Sources/device_jieli/JieliHomeServer.swift b/apps/client/local_plugins/device_jieli/ios/device_jieli/Sources/device_jieli/JieliHomeServer.swift index 0e84e882..4f368967 100644 --- a/apps/client/local_plugins/device_jieli/ios/device_jieli/Sources/device_jieli/JieliHomeServer.swift +++ b/apps/client/local_plugins/device_jieli/ios/device_jieli/Sources/device_jieli/JieliHomeServer.swift @@ -4,6 +4,26 @@ import JL_BLEKit import JL_OTALib import JLLogHelper +/// 登录之后才创建会弹出系统权限框的对象。键和通知名与 AppDelegate 一致。 +enum PostLoginHardware { + static let readyKey = "sp_hardware_ready" + static let armNote = Notification.Name("sp.hardware.arm") + + static func whenReady(_ body: @escaping () -> Void) { + if UserDefaults.standard.bool(forKey: readyKey) { + DispatchQueue.main.async(execute: body) + return + } + var token: NSObjectProtocol? + token = NotificationCenter.default.addObserver( + forName: armNote, object: nil, queue: .main + ) { _ in + if let token { NotificationCenter.default.removeObserver(token) } + body() + } + } +} + /// 全局单例。与 Android `JieliHomeServer` 对应: /// - 持有 `JL_BLEMultiple`(搜索 + 连接 + 已连 entity 列表) /// - 持有 `JL_OTAManager` 引用(OTA 由 SDK 暴露的单例) @@ -18,7 +38,15 @@ public final class JieliHomeServer: NSObject { public private(set) var initialized: Bool = false - public let bleMultiple: JL_BLEMultiple = JL_BLEMultiple() + /// `JL_BLEMultiple()` 的 init 会立刻 `CBCentralManager initWithDelegate:queue:`。 + /// 插件注册时不能创建,否则首次安装一打开就弹「查找蓝牙设备」。 + private var bleMultipleStorage: JL_BLEMultiple? + public var bleMultiple: JL_BLEMultiple { + if let bleMultipleStorage { return bleMultipleStorage } + let created = JL_BLEMultiple() + bleMultipleStorage = created + return created + } public var otaManager: JL_OTAManager { JL_OTAManager.getOTAManager() } // MARK: - Per-device translation sessions @@ -65,7 +93,12 @@ public final class JieliHomeServer: NSObject { private var customForwarder: CustomEventForwarder? private var deviceInfoForwarder: DeviceInfoEventForwarder? - private override init() { super.init() } + private override init() { + super.init() + PostLoginHardware.whenReady { [weak self] in + _ = self?.bleMultiple + } + } public func initialize(multiDevice: Bool, skipNoNameDev: Bool, enableLog: Bool) { if initialized { return } diff --git a/apps/client/local_plugins/music_service/ios/music_service/Sources/music_service/MusicService.swift b/apps/client/local_plugins/music_service/ios/music_service/Sources/music_service/MusicService.swift index 892d3b66..a1f4b405 100644 --- a/apps/client/local_plugins/music_service/ios/music_service/Sources/music_service/MusicService.swift +++ b/apps/client/local_plugins/music_service/ios/music_service/Sources/music_service/MusicService.swift @@ -9,7 +9,6 @@ import Foundation import AVFoundation import MediaPlayer import UIKit -//import CallKit public class MusicService: NSObject { public static let shared: MusicService = MusicService() @@ -35,9 +34,7 @@ public class MusicService: NSObject { // 音频中断处理相关 private var interruptedDuringPlayback: Bool = false - -// private let callObserver = CXCallObserver() - + private override init() { super.init() setupRemoteCommandCenter() diff --git a/apps/client/local_plugins/recorder_holder/ios/recorder_holder/Sources/recorder_holder/BleService.swift b/apps/client/local_plugins/recorder_holder/ios/recorder_holder/Sources/recorder_holder/BleService.swift index e8655670..ea538278 100644 --- a/apps/client/local_plugins/recorder_holder/ios/recorder_holder/Sources/recorder_holder/BleService.swift +++ b/apps/client/local_plugins/recorder_holder/ios/recorder_holder/Sources/recorder_holder/BleService.swift @@ -3,6 +3,26 @@ import CoreBluetooth import os.log import AVFoundation +/// 登录之后才创建 CBCentralManager。键和通知名与 AppDelegate 一致。 +enum PostLoginHardware { + static let readyKey = "sp_hardware_ready" + static let armNote = Notification.Name("sp.hardware.arm") + + static func whenReady(_ body: @escaping () -> Void) { + if UserDefaults.standard.bool(forKey: readyKey) { + DispatchQueue.main.async(execute: body) + return + } + var token: NSObjectProtocol? + token = NotificationCenter.default.addObserver( + forName: armNote, object: nil, queue: .main + ) { _ in + if let token { NotificationCenter.default.removeObserver(token) } + body() + } + } +} + /// BLE服务类 - 传统BLE扫描和连接实现 /// /// 核心特性: @@ -153,8 +173,11 @@ private var cmdReplyType: UInt8 = 0 // MARK: - 初始化 private override init() { super.init() - initializeCentralManager() initializeOpusProcessor() + // 创建 CBCentralManager 会立刻弹「查找蓝牙设备」。首次安装延到登录之后。 + PostLoginHardware.whenReady { [weak self] in + self?.initializeCentralManager() + } } // 初始化中央管理器 diff --git a/apps/client/local_plugins/recorder_holder/ios/recorder_holder/Sources/recorder_holder/ClassicBluetoothHelper.swift b/apps/client/local_plugins/recorder_holder/ios/recorder_holder/Sources/recorder_holder/ClassicBluetoothHelper.swift index 53348214..676a1bef 100644 --- a/apps/client/local_plugins/recorder_holder/ios/recorder_holder/Sources/recorder_holder/ClassicBluetoothHelper.swift +++ b/apps/client/local_plugins/recorder_holder/ios/recorder_holder/Sources/recorder_holder/ClassicBluetoothHelper.swift @@ -32,7 +32,11 @@ class ClassicBluetoothHelper: NSObject, CBCentralManagerDelegate { override init() { super.init() - centralManager = CBCentralManager(delegate: self, queue: nil) + // 创建 CBCentralManager 会立刻弹「查找蓝牙设备」。首次安装延到登录之后。 + PostLoginHardware.whenReady { [weak self] in + guard let self, self.centralManager == nil else { return } + self.centralManager = CBCentralManager(delegate: self, queue: nil) + } } deinit { diff --git a/apps/services/modules/user/api_setting.go b/apps/services/modules/user/api_setting.go index e62da281..1fb21530 100644 --- a/apps/services/modules/user/api_setting.go +++ b/apps/services/modules/user/api_setting.go @@ -38,7 +38,16 @@ func (this *apiComp) Setting(session comm.IUserSession, req *pb.UserSettingReq) ) resp = &pb.UserSettingResp{} - if req.Name != "" { + if user, err = this.module.model.findforid(session.GetUserId()); err != nil && err != mysql.ErrNoDocuments { + errdata = &pb.ErrorData{ + Code: pb.ErrorCode_DBError, + Message: err.Error(), + } + return + } + // 名字没变就不再审。登录时生成的 User_xxxx 一定含词库里的 "se", + // 再审一次会把同请求里的头像、性别一起拒绝。 + if req.Name != "" && (user == nil || req.Name != user.Name) { if ok, _ = wordfilter.Validate(req.Name); !ok { errdata = &pb.ErrorData{ Code: pb.ErrorCode_NameInscriptionWords, @@ -47,11 +56,10 @@ func (this *apiComp) Setting(session comm.IUserSession, req *pb.UserSettingReq) return } } - - if user, err = this.module.model.findforid(session.GetUserId()); err != nil && err != mysql.ErrNoDocuments { + if user == nil { errdata = &pb.ErrorData{ Code: pb.ErrorCode_DBError, - Message: err.Error(), + Message: "用户不存在", } return } diff --git a/apps/services/modules/user/api_sgin.go b/apps/services/modules/user/api_sgin.go index 9e8300b6..47244298 100644 --- a/apps/services/modules/user/api_sgin.go +++ b/apps/services/modules/user/api_sgin.go @@ -345,12 +345,19 @@ func (this *apiComp) Sgin(session comm.IUserSession, req *pb.UserSginReq) (resp } // cleanDisplayName 去掉空名和敏感词。登录不能因为微信昵称不干净就拒绝建号, -// 换成 User_xxxx,之后可以在资料页再改一个干净的。 +// 换成能过词库的默认名。前缀不能用 User_:词库里有 "se",User 必中, +// 之后改头像、性别都会被同一条校验整单拒绝。 func cleanDisplayName(name string) string { if name != "" { if ok, _ := wordfilter.Validate(name); ok { return name } } - return fmt.Sprintf("User_%d", rand.Intn(10000)) + for i := 0; i < 8; i++ { + candidate := fmt.Sprintf("ID%d", rand.Intn(10000)) + if ok, _ := wordfilter.Validate(candidate); ok { + return candidate + } + } + return fmt.Sprintf("ID%d", time.Now().Unix()%100000) }