Browse Source

client:同步 iOS 权限延后、去掉 CallKit,并在首次调用第三方模型前征得同意。

默认昵称不再使用会被词库拦下的 User_ 前缀,资料保存时名字未改不再整单拒绝。

Co-authored-by: Cursor <cursoragent@cursor.com>
along
Rodger-Wang 10 hours ago
parent
commit
f38b91a22f
  1. 9
      apps/client/ios/Podfile
  2. 2
      apps/client/ios/Podfile.lock
  3. 17
      apps/client/ios/Runner/AppDelegate.swift
  4. 21
      apps/client/lib/data/models/user_Info.dart
  5. 97
      apps/client/lib/data/services/ai_data_consent.dart
  6. 2
      apps/client/lib/data/services/alibaba_image_translation_service.dart
  7. 5
      apps/client/lib/data/services/bailian_multimodal_service.dart
  8. 5
      apps/client/lib/data/services/bailian_native_dialog.dart
  9. 7
      apps/client/lib/data/services/deapsound_ai_service.dart
  10. 5
      apps/client/lib/data/services/meeting/meeting_task_service.dart
  11. 2
      apps/client/lib/data/services/server_translation_service.dart
  12. 8
      apps/client/lib/data/services/speech_impl/azure_asr_service.dart
  13. 10
      apps/client/lib/data/services/speech_impl/azure_ast_service.dart
  14. 36
      apps/client/lib/devices/bes/bes_bluetooth_service.dart
  15. 13
      apps/client/lib/modules/meeting/views/bottomSheet/ai_bottom_sheet.dart
  16. 7
      apps/client/lib/modules/meeting/views/template/template_edit_view.dart
  17. 28
      apps/client/local_plugins/amap_service/ios/Classes/AmapServicePlugin.swift
  18. 18
      apps/client/local_plugins/azure_speech/ios/azure_speech/Sources/tools/SimpleAudioReceiver.swift
  19. 25
      apps/client/local_plugins/ble_service/ios/ble_service/Sources/ble_service/BleService.swift
  20. 31
      apps/client/local_plugins/classic_bluetooth/ios/classic_bluetooth/Sources/classic_bluetooth/ClassicBluetoothHelper.swift
  21. 37
      apps/client/local_plugins/device_jieli/ios/device_jieli/Sources/device_jieli/JieliHomeServer.swift
  22. 5
      apps/client/local_plugins/music_service/ios/music_service/Sources/music_service/MusicService.swift
  23. 25
      apps/client/local_plugins/recorder_holder/ios/recorder_holder/Sources/recorder_holder/BleService.swift
  24. 6
      apps/client/local_plugins/recorder_holder/ios/recorder_holder/Sources/recorder_holder/ClassicBluetoothHelper.swift
  25. 16
      apps/services/modules/user/api_setting.go
  26. 11
      apps/services/modules/user/api_sgin.go

9
apps/client/ios/Podfile

@ -123,10 +123,17 @@ post_install do |installer|
## dart: PermissionGroup.bluetooth ## dart: PermissionGroup.bluetooth
'PERMISSION_BLUETOOTH=1', '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'] << 'FBSDK_DISABLE_IDFA=1'
config.build_settings['GCC_PREPROCESSOR_DEFINITIONS'] << 'PERMISSION_APP_TRACKING_TRANSPARENCY=0'
# 这里原先有一段 config.build_settings.delete('NSUserTrackingUsageDescription'), # 这里原先有一段 config.build_settings.delete('NSUserTrackingUsageDescription'),
# 意图是"不要用户追踪",但**完全无效**:那个 key 写在 Runner/Info.plist 里, # 意图是"不要用户追踪",但**完全无效**:那个 key 写在 Runner/Info.plist 里,

2
apps/client/ios/Podfile.lock

@ -110,6 +110,6 @@ SPEC CHECKSUMS:
WechatOpenSDK-XCFramework: 5df9b250e9839dcc306ad8b00f46822eead1ed47 WechatOpenSDK-XCFramework: 5df9b250e9839dcc306ad8b00f46822eead1ed47
wifi_iot: b5aafd6f9b52f8a357383a1deabab45f31cd602d wifi_iot: b5aafd6f9b52f8a357383a1deabab45f31cd602d
PODFILE CHECKSUM: 615a00a0854e6c6a2c6db75ecb9a29206ab3e341 PODFILE CHECKSUM: 989d66a7b31571adcd303adc2d5f8bea9f7b32ce
COCOAPODS: 1.16.2 COCOAPODS: 1.16.2

17
apps/client/ios/Runner/AppDelegate.swift

@ -19,6 +19,23 @@ import UIKit
/// 插件必须挂到场景创建出来的引擎上。留在 didFinishLaunching 里注册会挂到还没建好的引擎。 /// 插件必须挂到场景创建出来的引擎上。留在 didFinishLaunching 里注册会挂到还没建好的引擎。
func didInitializeImplicitFlutterEngine(_ engineBridge: FlutterImplicitEngineBridge) { func didInitializeImplicitFlutterEngine(_ engineBridge: FlutterImplicitEngineBridge) {
GeneratedPluginRegistrant.register(with: engineBridge.pluginRegistry) 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) { override func applicationDidBecomeActive(_ application: UIApplication) {

21
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 'package:get_storage/get_storage.dart';
import '../services/id_verify_service.dart'; import '../services/id_verify_service.dart';
import 'package:get/get.dart'; import 'package:get/get.dart';
@ -7,6 +10,22 @@ class User {
// 单例实例 // 单例实例
static User? _instance; 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<void>('arm').catchError((Object e) {
_hardwareArmed = false;
Logger.w('User', '延后本地网络/蓝牙初始化失败: $e');
});
}
/// 全局刷新信号(当用户信息更新时 +1,用于驱动UI重建) /// 全局刷新信号(当用户信息更新时 +1,用于驱动UI重建)
static final RxInt refreshTick = 0.obs; static final RxInt refreshTick = 0.obs;
@ -147,6 +166,7 @@ class User {
// (见 DeviceAuth.bindLoginRetry)。原来只有 updateUserInfo 会 +1, // (见 DeviceAuth.bindLoginRetry)。原来只有 updateUserInfo 会 +1,
// 于是「先连设备后登录」这条路上没有任何人知道登录已经完成。 // 于是「先连设备后登录」这条路上没有任何人知道登录已经完成。
refreshTick.value++; refreshTick.value++;
armHardwarePrompts();
Logger.i("User", 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}"); "初始化用户: 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; return u;
@ -185,6 +205,7 @@ class User {
); );
_instance = u; _instance = u;
refreshTick.value++; // 同上:token 登录(冷启动恢复会话)也要发信号 refreshTick.value++; // 同上:token 登录(冷启动恢复会话)也要发信号
armHardwarePrompts();
Logger.i("User", 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}"); "初始化用户(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; return u;

97
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<AiDataVendor, Future<bool>> _inflight = {};
static final Map<AiDataVendor, DateTime> _declinedAt = {};
static String _key(AiDataVendor vendor) => 'ai_data_consent_${vendor.name}';
static Future<bool> 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<bool> _ask(AiDataVendor vendor) async {
final zh = (Get.locale ?? Get.deviceLocale)
?.languageCode
.toLowerCase()
.startsWith('zh') ??
false;
final result = await Get.dialog<bool>(
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.';
}
}
}

2
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 '../../core/utils/logger.dart';
import '../../data/models/appconfig.dart'; import '../../data/models/appconfig.dart';
import '../../modules/picture_translation/models/picture_translation_models.dart'; import '../../modules/picture_translation/models/picture_translation_models.dart';
import 'ai_data_consent.dart';
import 'language_manager.dart'; import 'language_manager.dart';
/// 阿里云百炼(通义千问 VL)图片翻译服务。 /// 阿里云百炼(通义千问 VL)图片翻译服务。
@ -73,6 +74,7 @@ class AlibabaImageTranslationService extends GetxService {
required String targetLanguageCode, required String targetLanguageCode,
}) async { }) async {
if (!_isInitialized && !await initialize()) return null; if (!_isInitialized && !await initialize()) return null;
if (!await AiDataConsent.allow(AiDataVendor.alibaba)) return null;
try { try {
final targetName = final targetName =

5
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 '../../core/utils/logger.dart';
import '../models/appconfig.dart'; import '../models/appconfig.dart';
import '../models/assistant_directive.dart'; import '../models/assistant_directive.dart';
import 'ai_data_consent.dart';
import 'device_location_service.dart'; import 'device_location_service.dart';
/// 对话状态(对应协议的 DialogStateChanged) /// 对话状态(对应协议的 DialogStateChanged)
@ -246,6 +247,10 @@ class BailianMultimodalService extends GetxService {
text: configHint, code: 'NOT_CONFIGURED', fatal: true)); text: configHint, code: 'NOT_CONFIGURED', fatal: true));
return false; return false;
} }
if (!await AiDataConsent.allow(AiDataVendor.alibaba)) {
Logger.w(_tag, '用户未同意向阿里云发送对话数据');
return false;
}
await stop(); await stop();
_downstreamRate = downstreamRate; _downstreamRate = downstreamRate;

5
apps/client/lib/data/services/bailian_native_dialog.dart

@ -7,6 +7,7 @@ import 'package:flutter/services.dart';
import '../../core/utils/logger.dart'; import '../../core/utils/logger.dart';
import '../models/assistant_directive.dart'; import '../models/assistant_directive.dart';
import 'ai_data_consent.dart';
import 'bailian_multimodal_service.dart'; import 'bailian_multimodal_service.dart';
/// 耳机唤醒那条 EMAI 链路的**原生**百炼客户端句柄。 /// 耳机唤醒那条 EMAI 链路的**原生**百炼客户端句柄。
@ -108,6 +109,10 @@ class BailianNativeDialog {
text: configHint, code: 'NOT_CONFIGURED', fatal: true)); text: configHint, code: 'NOT_CONFIGURED', fatal: true));
return false; return false;
} }
if (!await AiDataConsent.allow(AiDataVendor.alibaba)) {
Logger.w(_tag, '用户未同意向阿里云发送对话数据');
return false;
}
await stop(); await stop();
directiveSource = source; directiveSource = source;
_sub = _events.receiveBroadcastStream().listen( _sub = _events.receiveBroadcastStream().listen(

7
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 'package:http/http.dart' as http;
import '../models/appconfig.dart'; import '../models/appconfig.dart';
import 'ai_data_consent.dart';
import 'ai_service.dart'; import 'ai_service.dart';
/// 语音纪要「Ask AI」用的文本大模型(OpenAI 兼容接口,客户端直连)。 /// 语音纪要「Ask AI」用的文本大模型(OpenAI 兼容接口,客户端直连)。
@ -49,6 +50,9 @@ class DeapsoundAIService implements AiService {
String? systemPrompt, String? systemPrompt,
String? userProperties, String? userProperties,
}) async { }) async {
if (!await AiDataConsent.allow(AiDataVendor.alibaba)) {
throw const AiDataConsentDenied();
}
final url = chatUrl; final url = chatUrl;
final headers = { final headers = {
@ -85,6 +89,9 @@ class DeapsoundAIService implements AiService {
String? systemPrompt, String? systemPrompt,
String? userProperties, String? userProperties,
}) async* { }) async* {
if (!await AiDataConsent.allow(AiDataVendor.alibaba)) {
throw const AiDataConsentDenied();
}
final client = http.Client(); final client = http.Client();
final url = chatUrl; final url = chatUrl;

5
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 'package:get_storage/get_storage.dart';
import '../../../core/utils/logger.dart'; import '../../../core/utils/logger.dart';
import '../ai_data_consent.dart';
import '../db/sqflite_api.dart'; import '../db/sqflite_api.dart';
import '../network/api.dart'; import '../network/api.dart';
import '../../models/user_Info.dart'; import '../../models/user_Info.dart';
@ -152,6 +153,10 @@ class MeetingTaskService extends GetxService {
String tid, { String tid, {
String summarySvcId = '', String summarySvcId = '',
}) async { }) async {
if (!await AiDataConsent.allow(AiDataVendor.meeting)) {
Logger.w('MeetingTask', '用户未同意把录音交给第三方转写');
return false;
}
final resp = await Api.startTaskEchomeet({ final resp = await Api.startTaskEchomeet({
'id': id, 'id': id,
'formlanguage': formlanguage, 'formlanguage': formlanguage,

2
apps/client/lib/data/services/server_translation_service.dart

@ -2,6 +2,7 @@ import 'package:dio/dio.dart';
import 'package:get/get.dart'; import 'package:get/get.dart';
import '../../core/utils/logger.dart'; import '../../core/utils/logger.dart';
import 'ai_data_consent.dart';
import 'network/api.dart'; import 'network/api.dart';
/// 文本机器翻译:由**服务端**代调后台「第三方服务配置」里启用的 MT 服务。 /// 文本机器翻译:由**服务端**代调后台「第三方服务配置」里启用的 MT 服务。
@ -60,6 +61,7 @@ class ServerTranslationService extends GetxService {
}) async { }) async {
final src = text.trim(); final src = text.trim();
if (src.isEmpty) return null; if (src.isEmpty) return null;
if (!await AiDataConsent.allow(AiDataVendor.alibaba)) return null;
try { try {
final data = await Api.userTranslate({ final data = await Api.userTranslate({
'text': src, 'text': src,

8
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 'package:flutter/services.dart';
import '../../../core/utils/logger.dart'; import '../../../core/utils/logger.dart';
import 'package:get/get.dart'; import 'package:get/get.dart';
import '../ai_data_consent.dart';
import '../asr_service.dart'; import '../asr_service.dart';
/// Azure 语音识别服务 /// Azure 语音识别服务
@ -218,6 +219,13 @@ class AzureAsrService extends GetxService implements AsrService {
await stopContinuousRecognition(); await stopContinuousRecognition();
} }
// 通话模式的识别走 AST,不在这里把语音交给微软。
if (!skipAsrForCallMode &&
!await AiDataConsent.allow(AiDataVendor.microsoft)) {
Logger.warning('ASR: 用户未同意向微软发送语音');
return false;
}
try { try {
// 开始连续识别 // 开始连续识别
final bool result = final bool result =

10
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 'package:flutter/services.dart';
import '../../../core/utils/logger.dart'; import '../../../core/utils/logger.dart';
import 'package:get/get.dart'; import 'package:get/get.dart';
import '../ai_data_consent.dart';
import '../ast_service.dart'; import '../ast_service.dart';
/// 阿里通义实时翻译的两个模型名。 /// 阿里通义实时翻译的两个模型名。
@ -491,6 +492,15 @@ class AzureAstService extends GetxService implements AstService {
}) async { }) async {
_lastProvider = provider; _lastProvider = provider;
if (supportedLanguages != null) _lastLanguages = List<String>.from(supportedLanguages); if (supportedLanguages != null) _lastLanguages = List<String>.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 { try {
final List<String> languages = final List<String> languages =
supportedLanguages ?? _defaultSupportedLanguages; supportedLanguages ?? _defaultSupportedLanguages;

36
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 'package:get_storage/get_storage.dart';
import '../../core/utils/logger.dart'; import '../../core/utils/logger.dart';
import '../../data/models/user_Info.dart';
import '../../data/services/background_launch_consent.dart'; import '../../data/services/background_launch_consent.dart';
import 'bes_device_auth.dart'; import 'bes_device_auth.dart';
import 'bes_protocol.dart'; import 'bes_protocol.dart';
@ -116,13 +117,24 @@ class BesBluetoothService extends GetxService {
bool get hasPairedDevice => getPairedDevices().isNotEmpty; bool get hasPairedDevice => getPairedDevices().isNotEmpty;
Worker? _iosLoginBootstrap;
@override @override
void onInit() { void onInit() {
super.onInit(); super.onInit();
_listen(); _listen();
if (Platform.isIOS) {
// 首次安装时用户还没登录,不要在启动页创建 CBCentralManager。
// 登录成功 User.refreshTick 会加一,到那时再初始化。
_iosLoginBootstrap = ever<int>(User.refreshTick, (_) {
if (User.isLoggedIn()) unawaited(_bootstrapIosOnce());
});
}
Future.delayed(const Duration(seconds: 2), _bootstrap); Future.delayed(const Duration(seconds: 2), _bootstrap);
} }
bool _iosBootstrapped = false;
/// 冷启动引导。两端的前提完全不同: /// 冷启动引导。两端的前提完全不同:
/// ///
/// - **Android**:原生 `BluetoothManager.setup()` 在引擎 attach 时就跑了,ACL 广播 /// - **Android**:原生 `BluetoothManager.setup()` 在引擎 attach 时就跑了,ACL 广播
@ -130,14 +142,25 @@ class BesBluetoothService extends GetxService {
/// 免得没绑设备的用户一进 App 就被弹权限框。 /// 免得没绑设备的用户一进 App 就被弹权限框。
/// - **iOS**:CBCentralManager 是在 `initialize()` 里才创建的,不调就什么都没有—— /// - **iOS**:CBCentralManager 是在 `initialize()` 里才创建的,不调就什么都没有——
/// 既收不到状态流,`registerForConnectionEvents` 也没注册, /// 既收不到状态流,`registerForConnectionEvents` 也没注册,
/// 耳机连上时的 `.peerConnected` 被动接管自然不会发生。所以 iOS 必须无条件初始化。 /// 耳机连上时的 `.peerConnected` 被动接管自然不会发生。
/// 但首次安装不能在启动页就建:那会先于登录弹出「查找蓝牙设备」。
/// 已登录(含冷启动恢复会话)才初始化。
Future<void> _bootstrap() async { Future<void> _bootstrap() async {
if (Platform.isIOS) { if (Platform.isIOS) {
try { if (!User.isLoggedIn()) return;
await initialize(); await _bootstrapIosOnce();
} catch (e) { return;
Logger.e(_tag, '恒玄蓝牙初始化失败: $e'); }
} await tryAutoConnect();
}
Future<void> _bootstrapIosOnce() async {
if (_iosBootstrapped) return;
_iosBootstrapped = true;
try {
await initialize();
} catch (e) {
Logger.e(_tag, '恒玄蓝牙初始化失败: $e');
} }
await tryAutoConnect(); await tryAutoConnect();
} }
@ -175,6 +198,7 @@ class BesBluetoothService extends GetxService {
@override @override
void onClose() { void onClose() {
_iosLoginBootstrap?.dispose();
_statusSubscription?.cancel(); _statusSubscription?.cancel();
_cmdEventSubscription?.cancel(); _cmdEventSubscription?.cancel();
_batteryRetryTimer?.cancel(); _batteryRetryTimer?.cancel();

13
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 'package:get/get.dart';
import '../../../../data/services/db/sqflite_api.dart'; import '../../../../data/services/db/sqflite_api.dart';
import '../../../../data/services/ai_data_consent.dart';
import '../../../../data/services/deapsound_ai_service.dart'; import '../../../../data/services/deapsound_ai_service.dart';
import '../../controllers/meeting_details_controller.dart'; import '../../controllers/meeting_details_controller.dart';
@ -494,6 +495,18 @@ class _AIBottomSheetState extends State<AIBottomSheet> {
// 异步异常,onDone 不会触发,_isLoading 永远 true —— 表现就是「Ask AI 点了没反应, // 异步异常,onDone 不会触发,_isLoading 永远 true —— 表现就是「Ask AI 点了没反应,
// 再点也没反应」,且不报任何错。现在把错误写进气泡并恢复可用。 // 再点也没反应」,且不报任何错。现在把错误写进气泡并恢复可用。
onError: (Object e) { 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: ', ''); final msg = e.toString().replaceFirst('Exception: ', '');
if (mounted) { if (mounted) {
setState(() { setState(() {

7
apps/client/lib/modules/meeting/views/template/template_edit_view.dart

@ -368,6 +368,13 @@ class _TemplateEditViewState extends State<TemplateEditView> {
_isLoading = false; _isLoading = false;
}); });
}, },
onError: (Object e) {
if (mounted) {
setState(() {
_isLoading = false;
});
}
},
); );
} }
} }

28
apps/client/local_plugins/amap_service/ios/Classes/AmapServicePlugin.swift

@ -2,6 +2,26 @@ import Flutter
import UIKit import UIKit
import AMapNaviKit 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 { public class AmapServicePlugin: NSObject, FlutterPlugin, FlutterStreamHandler {
private static var channel: FlutterMethodChannel? private static var channel: FlutterMethodChannel?
private var eventSink: FlutterEventSink? private var eventSink: FlutterEventSink?
@ -19,12 +39,12 @@ public class AmapServicePlugin: NSObject, FlutterPlugin, FlutterStreamHandler {
let instance = AmapServicePlugin() let instance = AmapServicePlugin()
eventChannel.setStreamHandler(instance) eventChannel.setStreamHandler(instance)
// 高德SDK初始化 // 高德一初始化就会触发「查找本地网络中的设备」。延到登录之后。
DispatchQueue.main.async { PostLoginHardware.whenReady {
AMapServices.shared()?.apiKey = "6144a636c24dc9f2e1e57f7597849cf7" AMapServices.shared()?.apiKey = "6144a636c24dc9f2e1e57f7597849cf7"
AMapServices.shared()?.enableHTTPS = true AMapServices.shared()?.enableHTTPS = true
if #available(iOS 13.0, *) { if #available(iOS 13.0, *) {
AMapNaviManagerConfig.shared().updatePrivacyShow(.didShow, privacyInfo: .didContain) AMapNaviManagerConfig.shared().updatePrivacyShow(.didShow, privacyInfo: .didContain)
AMapNaviManagerConfig.shared().updatePrivacyAgree(.didAgree) AMapNaviManagerConfig.shared().updatePrivacyAgree(.didAgree)

18
apps/client/local_plugins/azure_speech/ios/azure_speech/Sources/tools/SimpleAudioReceiver.swift

@ -2,7 +2,6 @@ import Foundation
import AVFoundation import AVFoundation
import MicrosoftCognitiveServicesSpeech import MicrosoftCognitiveServicesSpeech
import os.log import os.log
import CallKit
/** /**
* 简单音频接收器类,用于处理音频录制和流传输 * 简单音频接收器类,用于处理音频录制和流传输
@ -16,9 +15,6 @@ public class SimpleAudioReceiver: NSObject {
private let tag = "SimpleAudioReceiver" private let tag = "SimpleAudioReceiver"
private let log = OSLog(subsystem: "com.azure.speech", category: "SimpleAudioReceiver") private let log = OSLog(subsystem: "com.azure.speech", category: "SimpleAudioReceiver")
// 用于检测系统电话通话状态(iOS10+)
private let callObserver = CXCallObserver()
/** /**
* 麦克风捕获实例 * 麦克风捕获实例
* 使用 MicrophoneCapture 的单例模式 * 使用 MicrophoneCapture 的单例模式
@ -431,18 +427,10 @@ public class SimpleAudioReceiver: NSObject {
} }
} }
/** /// 系统电话占用时,音频会话需要让路。
* 检测是否存在正在进行的系统电话通话 /// 不用 CallKit:中国区上架不能链接该框架。这里用系统给出的「应静音」提示判断。
* - iOS10+:使用 CallKit 的 CXCallObserver
* - 旧系统:使用 secondaryAudioShouldBeSilencedHint 近似判断
*/
private func isTelephonyActive() -> Bool { private func isTelephonyActive() -> Bool {
if #available(iOS 10.0, *) { AVAudioSession.sharedInstance().secondaryAudioShouldBeSilencedHint
return callObserver.calls.contains { !$0.hasEnded }
} else {
// 非精确,但可作为兜底判断:若需要静音则可能有电话或系统占用
return AVAudioSession.sharedInstance().secondaryAudioShouldBeSilencedHint
}
} }
/** /**

25
apps/client/local_plugins/ble_service/ios/ble_service/Sources/ble_service/BleService.swift

@ -3,6 +3,26 @@ import CoreBluetooth
import os.log import os.log
import AVFoundation 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扫描和连接实现 /// BLE服务类 - 传统BLE扫描和连接实现
/// ///
/// 核心特性: /// 核心特性:
@ -128,8 +148,11 @@ public class BleService: NSObject {
// MARK: - 初始化 // MARK: - 初始化
private override init() { private override init() {
super.init() super.init()
initializeCentralManager()
initializeOpusProcessor() initializeOpusProcessor()
// 创建 CBCentralManager 会立刻弹「查找蓝牙设备」。首次安装延到登录之后。
PostLoginHardware.whenReady { [weak self] in
self?.initializeCentralManager()
}
} }
// 初始化中央管理器 // 初始化中央管理器

31
apps/client/local_plugins/classic_bluetooth/ios/classic_bluetooth/Sources/classic_bluetooth/ClassicBluetoothHelper.swift

@ -2,6 +2,26 @@ import Foundation
import AVFoundation import AVFoundation
import CoreBluetooth import CoreBluetooth
import ble_service 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 { public enum BluetoothEventType {
case stateChanged case stateChanged
@ -39,9 +59,14 @@ public class ClassicBluetoothHelper: NSObject, CBCentralManagerDelegate {
/// 私有初始化方法,防止外部创建实例 /// 私有初始化方法,防止外部创建实例
private override init() { private override init() {
super.init() super.init()
centralManager = CBCentralManager(delegate: self, queue: nil) // 创建 CBCentralManager 会立刻弹「查找蓝牙设备」。首次安装延到登录之后。
// 应用启动时自动开始监听 PostLoginHardware.whenReady { [weak self] in
enableAutoStart() guard let self else { return }
if self.centralManager == nil {
self.centralManager = CBCentralManager(delegate: self, queue: nil)
}
self.enableAutoStart()
}
} }
/** /**

37
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 JL_OTALib
import JLLogHelper 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` 对应: /// 全局单例。与 Android `JieliHomeServer` 对应:
/// - 持有 `JL_BLEMultiple`(搜索 + 连接 + 已连 entity 列表) /// - 持有 `JL_BLEMultiple`(搜索 + 连接 + 已连 entity 列表)
/// - 持有 `JL_OTAManager` 引用(OTA 由 SDK 暴露的单例) /// - 持有 `JL_OTAManager` 引用(OTA 由 SDK 暴露的单例)
@ -18,7 +38,15 @@ public final class JieliHomeServer: NSObject {
public private(set) var initialized: Bool = false 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() } public var otaManager: JL_OTAManager { JL_OTAManager.getOTAManager() }
// MARK: - Per-device translation sessions // MARK: - Per-device translation sessions
@ -65,7 +93,12 @@ public final class JieliHomeServer: NSObject {
private var customForwarder: CustomEventForwarder? private var customForwarder: CustomEventForwarder?
private var deviceInfoForwarder: DeviceInfoEventForwarder? 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) { public func initialize(multiDevice: Bool, skipNoNameDev: Bool, enableLog: Bool) {
if initialized { return } if initialized { return }

5
apps/client/local_plugins/music_service/ios/music_service/Sources/music_service/MusicService.swift

@ -9,7 +9,6 @@ import Foundation
import AVFoundation import AVFoundation
import MediaPlayer import MediaPlayer
import UIKit import UIKit
//import CallKit
public class MusicService: NSObject { public class MusicService: NSObject {
public static let shared: MusicService = MusicService() public static let shared: MusicService = MusicService()
@ -35,9 +34,7 @@ public class MusicService: NSObject {
// 音频中断处理相关 // 音频中断处理相关
private var interruptedDuringPlayback: Bool = false private var interruptedDuringPlayback: Bool = false
// private let callObserver = CXCallObserver()
private override init() { private override init() {
super.init() super.init()
setupRemoteCommandCenter() setupRemoteCommandCenter()

25
apps/client/local_plugins/recorder_holder/ios/recorder_holder/Sources/recorder_holder/BleService.swift

@ -3,6 +3,26 @@ import CoreBluetooth
import os.log import os.log
import AVFoundation 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扫描和连接实现 /// BLE服务类 - 传统BLE扫描和连接实现
/// ///
/// 核心特性: /// 核心特性:
@ -153,8 +173,11 @@ private var cmdReplyType: UInt8 = 0
// MARK: - 初始化 // MARK: - 初始化
private override init() { private override init() {
super.init() super.init()
initializeCentralManager()
initializeOpusProcessor() initializeOpusProcessor()
// 创建 CBCentralManager 会立刻弹「查找蓝牙设备」。首次安装延到登录之后。
PostLoginHardware.whenReady { [weak self] in
self?.initializeCentralManager()
}
} }
// 初始化中央管理器 // 初始化中央管理器

6
apps/client/local_plugins/recorder_holder/ios/recorder_holder/Sources/recorder_holder/ClassicBluetoothHelper.swift

@ -32,7 +32,11 @@ class ClassicBluetoothHelper: NSObject, CBCentralManagerDelegate {
override init() { override init() {
super.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 { deinit {

16
apps/services/modules/user/api_setting.go

@ -38,7 +38,16 @@ func (this *apiComp) Setting(session comm.IUserSession, req *pb.UserSettingReq)
) )
resp = &pb.UserSettingResp{} 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 { if ok, _ = wordfilter.Validate(req.Name); !ok {
errdata = &pb.ErrorData{ errdata = &pb.ErrorData{
Code: pb.ErrorCode_NameInscriptionWords, Code: pb.ErrorCode_NameInscriptionWords,
@ -47,11 +56,10 @@ func (this *apiComp) Setting(session comm.IUserSession, req *pb.UserSettingReq)
return return
} }
} }
if user == nil {
if user, err = this.module.model.findforid(session.GetUserId()); err != nil && err != mysql.ErrNoDocuments {
errdata = &pb.ErrorData{ errdata = &pb.ErrorData{
Code: pb.ErrorCode_DBError, Code: pb.ErrorCode_DBError,
Message: err.Error(), Message: "用户不存在",
} }
return return
} }

11
apps/services/modules/user/api_sgin.go

@ -345,12 +345,19 @@ func (this *apiComp) Sgin(session comm.IUserSession, req *pb.UserSginReq) (resp
} }
// cleanDisplayName 去掉空名和敏感词。登录不能因为微信昵称不干净就拒绝建号, // cleanDisplayName 去掉空名和敏感词。登录不能因为微信昵称不干净就拒绝建号,
// 换成 User_xxxx,之后可以在资料页再改一个干净的。 // 换成能过词库的默认名。前缀不能用 User_:词库里有 "se",User 必中,
// 之后改头像、性别都会被同一条校验整单拒绝。
func cleanDisplayName(name string) string { func cleanDisplayName(name string) string {
if name != "" { if name != "" {
if ok, _ := wordfilter.Validate(name); ok { if ok, _ := wordfilter.Validate(name); ok {
return name 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)
} }

Loading…
Cancel
Save