diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 00000000..e0f15db2 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,3 @@ +{ + "java.configuration.updateBuildConfiguration": "automatic" +} \ No newline at end of file diff --git a/apps/client/.env.example b/apps/client/.env.example index 7e31dab1..f75c040a 100644 --- a/apps/client/.env.example +++ b/apps/client/.env.example @@ -72,3 +72,5 @@ VOLCANO_TRANSLATION_REGION= APP_IOS_IAP_PREFIX= APP_IOS_IAP_SUFFIX= APP_IOS_IAP_ID_MAP= +# 请求签名密钥(可选,不配用内置默认值;须与服务端一致) +API_SIGN_KEY= diff --git a/apps/client/.gitignore b/apps/client/.gitignore index 078e08fe..ced95f15 100644 --- a/apps/client/.gitignore +++ b/apps/client/.gitignore @@ -69,3 +69,6 @@ app.*.map.json # ---- 本机私密配置 ---- # .env 含火山/OpenAI/COS 等第三方密钥,只提交 .env.example .env + +# 多渠道打包产物(build_channels.sh 的默认输出目录) +build_outputs/ diff --git a/apps/client/GOOGLE_PLAY_RELEASE_CHECKLIST.md b/apps/client/GOOGLE_PLAY_RELEASE_CHECKLIST.md index 9f7cfd86..b56acc61 100644 --- a/apps/client/GOOGLE_PLAY_RELEASE_CHECKLIST.md +++ b/apps/client/GOOGLE_PLAY_RELEASE_CHECKLIST.md @@ -5,6 +5,52 @@ --- +## 进度(2026-09-12 更新) + +基线产物换成 `build_outputs/EAIMAR_0912_1.0.1_google.aab`(1.0.1+7,Channel=GLOBAL_GOOGLE), +下面每条都是在**这个 aab 上复验**过的,不是改完就算数。 + +| 项 | 状态 | 复验方式 | +|---|---|---| +| 1.1 targetSdk 36 | ✅ 已改 | 合并 manifest `targetSdkVersion="36"` | +| 1.2 Play Billing 8 | ✅ 已改 | aab 的 `dependencies.pb` 里 `com.android.billingclient:billing 8.0.0` | +| 1.3 16KB 页对齐 | ✅ 已改 | `python3 check_16kb.py ` → 64 位 .so 44/44 合规 | +| 2. 支付回退 | ✅ 已改 | Google 渠道硬闸门,见下 | +| 3. 权限瘦身 | 🟡 部分 | `GET_TASKS`、`MEDIA_CONTENT_CONTROL` 已移除(44→40 条),其余待定 | +| 4. 构建脚本 | ✅ 已改 | `./build_channels.sh google` 实测跑通 | +| 5. Play Console 事项 | ⬜ 未做 | 外部系统 | + +⚠️ **Billing 8 的迁移成本远低于本文当初的估计**:0.5.0 唯一的破坏性变更是移除 +`queryPurchaseHistory`,本项目零引用。实际改动就是 `in_app_purchase: ^3.3.0` + +`in_app_purchase_android: ^0.5.0` 两行,没碰一行业务代码。 + +⚠️ **支付回退的修法与本文第 2 节的建议不同,刻意的**:本文建议「安卓端兜底只留 google」, +但那会连带改掉国内五家渠道的兜底行为(后端没下发 paychannels 时国内用户就付不了款)。 +实际实现是**只对 Google 渠道加硬闸门**:`_isGooglePlayBuild` 为真时直接返回 +`[google]`,既不信后端下发的渠道列表、也不走任何回退分支;其它渠道逻辑原封不动。 +渠道判定读的是**编译期烧进包里的 `.env` 的 `Channel`**,⚠️ 不要换成 +`VersionUpdateService.cachedChannel`——那个值在版本检查跑完前会回退成 `GLOBAL_GOOGLE`, +用它会在国内包启动初期把微信/支付宝误杀。 + +⚠️ **`check_16kb.py` 当初并没有入库**(本文 1.3 节说「仓库里已有」是错的), +2026-09-12 才补写并提交。 + +### 仍然待办 + +- **`ACCESS_BACKGROUND_LOCATION` + `WRITE_SETTINGS` 主要由 `amap_service` 插件拖进来** + (`ACCESS_BACKGROUND_LOCATION` 另有 `location_service` 和主工程两个来源)。 + 而 `NavigationManager` 全项目只在 `initial_binding.dart` 用 `Get.lazyPut` 注册过, + **没有任何一处 `Get.find`**——高德导航在当前 App 里是走不到的死代码。 + 所以「Google 渠道不引入 `amap_service`」能一次干掉这两条最难过审的权限, + 且不会有功能损失(也顺带证明了排除高德 `.so` 不会崩:没有代码路径去加载它)。 +- `QUERY_ALL_PACKAGES`(主工程)、`SYSTEM_ALERT_WINDOW`(主工程 + `floating_ui_plugin`)、 + `SCHEDULE_EXACT_ALARM`(主工程)仍需声明表或删除。 +- **targetSdk 36 的真机回归没做**(edge-to-edge / 前台服务类型校验),见 1.1 节的警告。 +- **iOS 侧有同类的支付政策风险**:本次的闸门只加在 Google 渠道,App Store 包在 + `paychannels` 缺失时仍会回退成展示微信/支付宝/PayPal。 + +--- + ## 0. 先决定:要不要申请延期 有两条硬期限是 **2026-08-31**,也就是排查次日。两条都可以在 Play Console 申请延期到 **2026-11-01**。 diff --git a/apps/client/analysis_options.yaml b/apps/client/analysis_options.yaml index f9b30346..af3263ba 100644 --- a/apps/client/analysis_options.yaml +++ b/apps/client/analysis_options.yaml @@ -1 +1,23 @@ include: package:flutter_lints/flutter.yaml + +analyzer: + exclude: + - "**/*.g.dart" + - "local_plugins/**" + - "build/**" + errors: + # 这几类几乎总是「写了没接上」或「删了没删干净」,提级成 warning 让 CI 拦住 + unused_import: warning + unused_element: warning + unused_field: warning + unused_local_variable: warning + dead_code: warning + dead_null_aware_expression: warning + avoid_print: warning + # 老代码里的大写常量不在本轮整改范围,先降噪 + constant_identifier_names: ignore + +linter: + rules: + avoid_print: true + prefer_const_constructors: false diff --git a/apps/client/android/app/build.gradle.kts b/apps/client/android/app/build.gradle.kts index ee0eb9d6..49ba147f 100644 --- a/apps/client/android/app/build.gradle.kts +++ b/apps/client/android/app/build.gradle.kts @@ -5,7 +5,6 @@ repositories { mavenCentral() flatDir { dirs(project(":qq_music").projectDir.resolve("libs")) // 添加这行 - dirs(project(":device_jieli").projectDir.resolve("libs")) // 杰理 SDK 运行时 .aar } } @@ -81,7 +80,7 @@ android { // You can update the following values to match your application needs. // For more information, see: https://flutter.dev/to/review-gradle-config. minSdk = 26 // 更新为26以满足ble_service库的要求 - targetSdk = 35 // 设置为 Android 15 (API 35),以满足应用市场要求 + targetSdk = 36 // Android 16 (API 36),Google Play 2026-08-31 起的最低要求 versionCode = flutter.versionCode // 对应构建号,自动同步flutter构建号 versionName = flutter.versionName // 对应版本号,自动同步flutter版本号 } @@ -151,12 +150,6 @@ dependencies { // 添加 CardView 依赖 implementation("androidx.cardview:cardview:1.0.0") - // device_jieli 杰理 SDK 运行时依赖(.aar 在 device_jieli 模块只能 compileOnly, - // host app 必须自己引入,否则 RCSPController 等类在运行时找不到 → scanFeature 不会被初始化) - implementation(":jl_bluetooth_rcsp_V4.2.0_beta1_40255_20260324@aar") - implementation(":jl_audio_v2_V1.0.0_9-release@aar") - implementation(":jldecryption_v0.4-release@aar") - // jl_audio_decode 已经被 ble_service 通过 com.jieli.audio:jl_audio_decode:2.1.0 引入,无需重复 } flutter { diff --git a/apps/client/android/app/src/main/AndroidManifest.xml b/apps/client/android/app/src/main/AndroidManifest.xml index 377d797f..816b5935 100644 --- a/apps/client/android/app/src/main/AndroidManifest.xml +++ b/apps/client/android/app/src/main/AndroidManifest.xml @@ -2,7 +2,13 @@ xmlns:tools="http://schemas.android.com/tools"> - + + + + diff --git a/apps/client/android/settings.gradle.kts b/apps/client/android/settings.gradle.kts index e28f2041..7984d0cc 100644 --- a/apps/client/android/settings.gradle.kts +++ b/apps/client/android/settings.gradle.kts @@ -34,7 +34,6 @@ plugins { include(":app") include(":azure_speech") include(":chat_storage") -include(":jieli") include(":ble_service") include(":classic_bluetooth") include(":agent_service") @@ -42,13 +41,11 @@ include(":speech") include(":chat_api") include(":bytedance_speech") include(":qq_music") // 添加这行 -include(":jl_opus") include(":bluetooth_manager") // 恒玄(BES)经典蓝牙连接 + G722/mSBC 音频编解码 // 设置项目路径 project(":azure_speech").projectDir = file("../local_plugins/azure_speech/android") project(":chat_storage").projectDir = file("../local_plugins/chat_storage/android") -project(":jieli").projectDir = file("../local_plugins/jieli/android") project(":ble_service").projectDir = file("../local_plugins/ble_service/android") project(":classic_bluetooth").projectDir = file("../local_plugins/classic_bluetooth/android") project(":agent_service").projectDir = file("../local_plugins/agent_service/android") @@ -56,5 +53,4 @@ project(":speech").projectDir = file("../local_plugins/speech/android") project(":chat_api").projectDir = file("../local_plugins/chat_api/android") project(":bytedance_speech").projectDir = file("../local_plugins/bytedance_speech/android") project(":qq_music").projectDir = file("../local_plugins/qq_music/android") // 添加这行 -project(":jl_opus").projectDir = file("../local_plugins/jl_opus/android") project(":bluetooth_manager").projectDir = file("../local_plugins/bluetooth_manager/android") diff --git a/apps/client/assets/images/devices/Echomeet.png b/apps/client/assets/images/devices/Echomeet.png new file mode 100644 index 00000000..14a3203b Binary files /dev/null and b/apps/client/assets/images/devices/Echomeet.png differ diff --git a/apps/client/assets/images/devices/Echomeet_icon.png b/apps/client/assets/images/devices/Echomeet_icon.png new file mode 100644 index 00000000..c0620f7c Binary files /dev/null and b/apps/client/assets/images/devices/Echomeet_icon.png differ diff --git a/apps/client/build_channels.sh b/apps/client/build_channels.sh index 558d7f35..eb02b5c4 100755 --- a/apps/client/build_channels.sh +++ b/apps/client/build_channels.sh @@ -14,7 +14,9 @@ set -euo pipefail SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" ENV_FILE="${SCRIPT_DIR}/.env" PUBSPEC_FILE="${SCRIPT_DIR}/pubspec.yaml" -OUTPUT_DIR="/Users/liuxiamin/work/build_outputs" +# 产物输出目录。默认落在仓库内的 build_outputs/(已在 .gitignore 里), +# 需要放别处时用环境变量覆盖:BUILD_OUTPUT_DIR=/path/to/out ./build_channels.sh google +OUTPUT_DIR="${BUILD_OUTPUT_DIR:-${SCRIPT_DIR}/build_outputs}" APP_NAME="EAIMAR" BUILD_DATE=$(date +"%m%d") @@ -314,7 +316,7 @@ build_channel() { 2>&1 | tee "${OUTPUT_DIR}/build_${channel_name}.log" || build_result=$? source_file="${SCRIPT_DIR}/build/app/outputs/flutter-apk/app-release.apk" else - flutter build aab \ + flutter build appbundle \ --obfuscate \ --split-debug-info=./build/app/outputs/symbols \ 2>&1 | tee "${OUTPUT_DIR}/build_${channel_name}.log" || build_result=$? diff --git a/apps/client/check_16kb.py b/apps/client/check_16kb.py new file mode 100755 index 00000000..8b307b7e --- /dev/null +++ b/apps/client/check_16kb.py @@ -0,0 +1,106 @@ +#!/usr/bin/env python3 +"""校验 APK/AAB 里 64 位 .so 是否满足 Google Play 的 16KB 页对齐要求。 + +Google Play 自 2025-11-01 起要求 64 位原生库(arm64-v8a / x86_64)的每个 +PT_LOAD 段 p_align >= 0x4000(16KB),不合规直接拒收,且**无延期**。 +32 位(armeabi-v7a / x86)不受约束,脚本会列出但不判失败。 + +用法: + python3 check_16kb.py build/app/outputs/bundle/release/app-release.aab + python3 check_16kb.py build/app/outputs/flutter-apk/app-release.apk + +退出码: 0=全部合规, 1=有不合规的 64 位库, 2=用法/文件错误 +""" +import struct +import sys +import zipfile + +REQUIRED_ALIGN = 0x4000 +ABI64 = ("arm64-v8a", "x86_64") + +PT_LOAD = 1 + + +def max_load_align(data: bytes): + """返回 ELF 所有 PT_LOAD 段里最小的 p_align;不是 64 位 ELF 返回 None。""" + if len(data) < 64 or data[:4] != b"\x7fELF": + return None + if data[4] != 2: # EI_CLASS: 2 = ELFCLASS64,32 位不参与判定 + return None + little = data[5] == 1 + end = "<" if little else ">" + + # Elf64_Ehdr: e_phoff@0x20, e_phentsize@0x36, e_phnum@0x38 + (e_phoff,) = struct.unpack_from(end + "Q", data, 0x20) + (e_phentsize,) = struct.unpack_from(end + "H", data, 0x36) + (e_phnum,) = struct.unpack_from(end + "H", data, 0x38) + + aligns = [] + for i in range(e_phnum): + off = e_phoff + i * e_phentsize + if off + 56 > len(data): + break + (p_type,) = struct.unpack_from(end + "I", data, off) + if p_type != PT_LOAD: + continue + # Elf64_Phdr: p_align 是最后一个字段,偏移 0x30 + (p_align,) = struct.unpack_from(end + "Q", data, off + 0x30) + aligns.append(p_align) + return min(aligns) if aligns else None + + +def abi_of(name: str): + for abi in ("arm64-v8a", "armeabi-v7a", "x86_64", "x86"): + if "/%s/" % abi in name: + return abi + return "?" + + +def main(argv): + if len(argv) != 2: + print(__doc__) + return 2 + path = argv[1] + try: + zf = zipfile.ZipFile(path) + except Exception as exc: # noqa: BLE001 + print("无法打开 %s: %s" % (path, exc)) + return 2 + + bad, ok, skipped = [], [], [] + with zf: + for info in zf.infolist(): + if not info.filename.endswith(".so"): + continue + data = zf.read(info.filename) + align = max_load_align(data) + abi = abi_of(info.filename) + if align is None: + # 32 位或非 ELF:不参与 16KB 判定 + skipped.append((info.filename, abi)) + continue + if align < REQUIRED_ALIGN: + bad.append((info.filename, abi, align)) + else: + ok.append((info.filename, abi, align)) + + print("包: %s" % path) + print("64 位 .so 合规 %d 个,不合规 %d 个;32 位/非 ELF 跳过 %d 个\n" + % (len(ok), len(bad), len(skipped))) + + if bad: + print("❌ 不合规(p_align < 0x4000,Play 会拒收):") + for name, abi, align in sorted(bad): + print(" %-60s %-12s p_align=0x%x" % (name, abi, align)) + print("") + print("修法:给产出该库的模块声明 NDK r28+(`ndkVersion`),或在 CMake 里加") + print(" target_link_options( PRIVATE \"-Wl,-z,max-page-size=16384\")") + print(" 改完记得清掉该模块的 android/.cxx 缓存,否则会复用旧产物。") + return 1 + + print("✅ 全部 64 位 .so 均满足 16KB 页对齐") + return 0 + + +if __name__ == "__main__": + sys.exit(main(sys.argv)) diff --git a/apps/client/ios/Podfile.lock b/apps/client/ios/Podfile.lock index 6dd9dfc3..99ae5440 100644 --- a/apps/client/ios/Podfile.lock +++ b/apps/client/ios/Podfile.lock @@ -11,8 +11,6 @@ PODS: - Flutter - bluetooth_manager (0.0.1): - Flutter - - device_plugin_interface (0.1.0): - - Flutter - floating_ui_plugin (0.0.1): - Flutter - Flutter (1.0.0) @@ -60,7 +58,6 @@ DEPENDENCIES: - audio_waveforms (from `.symlinks/plugins/audio_waveforms/ios`) - bes_ota_manager (from `.symlinks/plugins/bes_ota_manager/ios`) - bluetooth_manager (from `.symlinks/plugins/bluetooth_manager/ios`) - - device_plugin_interface (from `.symlinks/plugins/device_plugin_interface/ios`) - floating_ui_plugin (from `.symlinks/plugins/floating_ui_plugin/ios`) - Flutter (from `Flutter`) - flutter_local_notifications (from `.symlinks/plugins/flutter_local_notifications/ios`) @@ -92,8 +89,6 @@ EXTERNAL SOURCES: :path: ".symlinks/plugins/bes_ota_manager/ios" bluetooth_manager: :path: ".symlinks/plugins/bluetooth_manager/ios" - device_plugin_interface: - :path: ".symlinks/plugins/device_plugin_interface/ios" floating_ui_plugin: :path: ".symlinks/plugins/floating_ui_plugin/ios" Flutter: @@ -126,7 +121,6 @@ SPEC CHECKSUMS: audio_waveforms: cd736909ebc6b6a164eb74701d8c705ce3241e1c bes_ota_manager: 4fcdbe6962548a1ac69421f0c3212837e76c0af4 bluetooth_manager: f09915bb90e61cc30cbf93f1f5b127b86d957e43 - device_plugin_interface: ee316d62288f942bd08a05acdf417ec4671639a7 floating_ui_plugin: d218b25dcd922d3a66a536dd2190ecd47451e5a8 Flutter: cabc95a1d2626b1b06e7179b784ebcf0c0cde467 flutter_local_notifications: df98d66e515e1ca797af436137b4459b160ad8c9 diff --git a/apps/client/ios/Runner.xcodeproj/project.pbxproj b/apps/client/ios/Runner.xcodeproj/project.pbxproj index 46cca1d3..9a329526 100644 --- a/apps/client/ios/Runner.xcodeproj/project.pbxproj +++ b/apps/client/ios/Runner.xcodeproj/project.pbxproj @@ -144,7 +144,6 @@ 75F9C6FC394EA5D10CEE9FE1 /* BroadcastExtension.entitlements */, 6C2968D7A4F3A0D478C6EF09 /* Extension.xcconfig */, ); - name = BroadcastExtension; path = BroadcastExtension; sourceTree = ""; }; @@ -642,7 +641,7 @@ CODE_SIGN_ENTITLEMENTS = Runner/RunnerProfile.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 5; + CURRENT_PROJECT_VERSION = 7; DEVELOPMENT_TEAM = B8AJQ7ACU7; ENABLE_BITCODE = NO; INFOPLIST_FILE = Runner/Info.plist; @@ -836,7 +835,7 @@ CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 5; + CURRENT_PROJECT_VERSION = 7; DEVELOPMENT_TEAM = B8AJQ7ACU7; ENABLE_BITCODE = NO; INFOPLIST_FILE = Runner/Info.plist; @@ -870,7 +869,7 @@ CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 5; + CURRENT_PROJECT_VERSION = 7; DEVELOPMENT_TEAM = B8AJQ7ACU7; ENABLE_BITCODE = NO; INFOPLIST_FILE = Runner/Info.plist; diff --git a/apps/client/ios/sync_spm_platform.sh b/apps/client/ios/sync_spm_platform.sh new file mode 100755 index 00000000..0996be7f --- /dev/null +++ b/apps/client/ios/sync_spm_platform.sh @@ -0,0 +1,146 @@ +#!/usr/bin/env bash +# +# 把 Flutter 生成的 SPM 中间包的 iOS 平台版本,对齐到本工程的 deployment target。 +# +# ── 解决什么问题 ──────────────────────────────────────────────────────────── +# Flutter 3.29+ 的 Swift Package Manager 集成会生成 +# ios/Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage/Package.swift +# 所有插件都挂在这个包下面。**它生成时的平台版本写死是 Flutter 自己的最低版本 +# (iOS 13.0)**,而本项目有十几个本地插件声明了 `.iOS("16.0")`。SPM 不允许依赖的 +# 平台版本高于上层包,于是 Xcode 报: +# +# The package product 'xxx' requires minimum platform version 16.0 +# for the iOS platform, but this target supports 13.0 +# +# Flutter 自己有补救(SwiftPackageManager.updateMinimumDeployment:读 Runner 的 +# IPHONEOS_DEPLOYMENT_TARGET 把生成包改写上去),但**只在 `flutter build/run` 里跑**。 +# 而 Xcode 直接构建时,SPM 解析发生在所有 Flutter 步骤之前,看到的还是 13.0 —— 所以 +# `flutter build ios` 能过、Xcode 里点构建就炸。 +# +# ⚠️ 任何重新生成插件清单的操作都会把它打回 13.0:flutter pub get / clean / install、 +# 改完 pubspec 的自动 pub get。也就是说这不是一次性问题,会反复出现。 +# +# ── 怎么用 ────────────────────────────────────────────────────────────────── +# ./ios/sync_spm_platform.sh # 在 apps/client 下随时手动跑,幂等 +# ./ios/sync_spm_platform.sh --check # 只检查不改写,不一致时退出码 1(给 CI 用) +# +# 挂进 Xcode(推荐,省得每次记着手动跑): +# Product → Scheme → Edit Scheme → Build → Pre-actions → + New Run Script Action +# Provide build settings from: Runner +# 脚本内容: "$SRCROOT/sync_spm_platform.sh" +# +# ⚠️ 不依赖 flutter 命令:Xcode 的 pre-action 环境 PATH 很干净,调 flutter 经常找不到。 +# 这里只做纯文本改写,几毫秒完成。代价是生成包必须已经存在(跑过一次 +# flutter pub get 即可),不存在时脚本会明确告诉你先跑什么。 +# +# ⚠️ 别改 Package.swift 本身来"修好"这个问题——它头上写着 Generated file. Do not edit., +# 下次 pub get 就被覆盖,属于假修好。本脚本做的是同一件事,但可重复执行。 + +# ⚠️ macOS 自带 bash 3.2 不认多字节字符边界:`$VAR,` 会被当成变量名 `VAR,`, +# 在 set -u 下报 unbound variable。所以本文件里凡是变量后面紧跟中文标点的, +# 一律写成 ${VAR}。 +set -euo pipefail + +IOS_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +CLIENT_DIR="$(cd "$IOS_DIR/.." && pwd)" +PBXPROJ="$IOS_DIR/Runner.xcodeproj/project.pbxproj" +MANIFEST="$IOS_DIR/Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage/Package.swift" + +CHECK_ONLY=0 +[[ "${1:-}" == "--check" ]] && CHECK_ONLY=1 + +red() { printf "\033[31m%s\033[0m\n" "$*"; } +green() { printf "\033[32m%s\033[0m\n" "$*"; } +blue() { printf "\033[34m%s\033[0m\n" "$*"; } + +# 把 .iOS(.v16) / .iOS("16.0") / 16 统一成可比较的 x.y 形式 +normalize() { + local v="$1" + v="${v#.v}" # .v16 → 16 + v="${v//\"/}" # 去引号 + [[ "$v" == *.* ]] || v="$v.0" # 16 → 16.0 + printf '%s' "$v" +} + +# 版本比较:$1 <= $2 ? +le() { [[ "$(printf '%s\n%s\n' "$1" "$2" | sort -V | head -1)" == "$1" ]]; } + +[[ -f "$PBXPROJ" ]] || { red "✗ 找不到 $PBXPROJ"; exit 1; } + +# ── 1) 工程能接受的上限 ───────────────────────────────────────────────────── +# 生成包的平台版本必须 ≤ 每个链接它的 target,所以取全工程最低的那个 deployment target。 +CEILING="$(grep -o 'IPHONEOS_DEPLOYMENT_TARGET = [0-9.]*' "$PBXPROJ" \ + | awk '{print $3}' | sed 's/;$//' | sort -V | head -1)" +[[ -n "$CEILING" ]] || { red "✗ 没能从 project.pbxproj 读出 IPHONEOS_DEPLOYMENT_TARGET"; exit 1; } +CEILING="$(normalize "$CEILING")" + +# ── 2) 插件要求的下限 ─────────────────────────────────────────────────────── +# 扫本地插件的 Package.swift,取声明得最高的那个。 +FLOOR="0.0"; FLOOR_OWNER="" +while IFS= read -r f; do + raw="$(grep -o '\.iOS([^)]*)' "$f" | head -1 | sed 's/^\.iOS(//; s/)$//')" || true + [[ -n "$raw" ]] || continue + v="$(normalize "$raw")" + if ! le "$v" "$FLOOR"; then FLOOR="$v"; FLOOR_OWNER="${f#"$CLIENT_DIR"/}"; fi +done < <(find "$CLIENT_DIR/local_plugins" -name Package.swift -not -path '*/.*' 2>/dev/null | sort) + +blue "工程 deployment target(取最低) = ${CEILING} 本地插件最高要求 = ${FLOOR}${FLOOR_OWNER:+ (${FLOOR_OWNER})}" + +# ── 3) 插件要求超过工程上限 = 真问题,脚本救不了,直接点名 ─────────────────── +if ! le "$FLOOR" "$CEILING"; then + red "✗ 插件要求 iOS ${FLOOR},但工程最低 target 只有 ${CEILING} —— 这个脚本改不了。" + red " 要么把工程的 IPHONEOS_DEPLOYMENT_TARGET 抬到 ${FLOOR}(Runner + Podfile 都要)," + red " 要么把 ${FLOOR_OWNER} 的平台声明降下来。" + exit 1 +fi + +# ── 4) 对齐生成包 ─────────────────────────────────────────────────────────── +if [[ ! -f "$MANIFEST" ]]; then + red "✗ 生成包还不存在:${MANIFEST#"$CLIENT_DIR"/}" + red " 先在 apps/client 下跑一次 \`flutter pub get\`(或 \`flutter build ios --config-only\`)再执行本脚本。" + exit 1 +fi + +# ── 本地插件之间的相对路径依赖 ──────────────────────────────────────────── +# 若干本地插件的 Package.swift 用 `../../..//ios/` 引用兄弟插件 +# (agent_service→test、azure_speech→classic_bluetooth、recorder_holder→device_jieli、 +# chat_api→swift_sdk_local)。这些路径是按 `local_plugins//ios/` 的真实 +# 目录写的,但 Flutter 把插件以符号链接挂在 `ephemeral/Packages/.packages/` 下, +# SPM 不解析符号链接,`../../..` 就落到了 `ephemeral/`——于是报 +# "the package at 'ephemeral//ios/' cannot be accessed"。 +# 这里在 ephemeral/ 下给每个本地插件目录补一个同名链接,让那些相对路径能落地。 +# 幂等;`flutter pub get` 不会清掉它们。 +ensure_local_plugin_links() { + local eph="$IOS_DIR/Flutter/ephemeral" + local plugins_dir="$CLIENT_DIR/local_plugins" + [ -d "$eph" ] || return 0 + for d in "$plugins_dir"/*/; do + local name + name="$(basename "$d")" + [ -e "$eph/$name" ] && continue + ln -s "../../../local_plugins/$name" "$eph/$name" + done +} +ensure_local_plugin_links + +CURRENT_RAW="$(grep -o '\.iOS([^)]*)' "$MANIFEST" | head -1 | sed 's/^\.iOS(//; s/)$//')" +CURRENT="$(normalize "$CURRENT_RAW")" + +if [[ "$CURRENT" == "$CEILING" ]]; then + green "✓ 生成包已是 iOS ${CURRENT},无需改动" + exit 0 +fi + +if [[ $CHECK_ONLY -eq 1 ]]; then + red "✗ 生成包是 iOS ${CURRENT},应为 ${CEILING}(跑 ./ios/sync_spm_platform.sh 修)" + exit 1 +fi + +# 只替换 platforms 段里的第一个 .iOS(...),不碰文件其余内容 +/usr/bin/sed -i '' "s/\.iOS($(printf '%s' "$CURRENT_RAW" | sed 's/[.[\*^$\/]/\\&/g'))/.iOS(\"$CEILING\")/" "$MANIFEST" + +NEW="$(normalize "$(grep -o '\.iOS([^)]*)' "$MANIFEST" | head -1 | sed 's/^\.iOS(//; s/)$//')")" +if [[ "$NEW" != "$CEILING" ]]; then + red "✗ 改写失败,现在是 ${NEW}"; exit 1 +fi +green "✓ 生成包平台版本 ${CURRENT} → ${CEILING},可以回 Xcode 构建了" diff --git a/apps/client/lib/core/bindings/initial_binding.dart b/apps/client/lib/core/bindings/initial_binding.dart index 6f965f9b..8b32d93f 100644 --- a/apps/client/lib/core/bindings/initial_binding.dart +++ b/apps/client/lib/core/bindings/initial_binding.dart @@ -16,21 +16,20 @@ import '../../data/services/speech_factory.dart'; import '../../data/services/alibaba_image_translation_service.dart'; import '../../data/services/assistant_directive_service.dart'; import '../../data/services/bailian_multimodal_service.dart'; -import '../../data/services/bes_ai_session_service.dart'; import '../../data/services/emai_chat_store.dart'; import '../../data/services/id_verify_service.dart'; import '../../data/services/microsoft_translation_service.dart'; import '../../data/services/server_translation_service.dart'; import '../../data/services/language_manager.dart'; import '../../core/utils/logger.dart'; -import '../../data/services/bluetooth_manager.dart'; -import '../../data/services/ble_manager.dart'; -import '../../data/services/bes_bluetooth_service.dart'; -import '../../data/services/holder_device_service.dart'; -import '../../data/services/holder_recording_service.dart'; -import '/data/services/bes_recording_service.dart'; -import '/data/services/bes_call_recording_service.dart'; -import '/data/services/face_to_face_ble_service.dart'; +import '../../data/services/agent_module_defaults.dart'; +import '../../data/services/device_ai_session_service.dart'; +import '../../data/services/device_call_recording_service.dart'; +import '../../data/services/device_file_import_service.dart'; +import '../../data/services/device_recording_service.dart'; +import '../../devices/device_bootstrap.dart'; +import '../../devices/device_hub.dart'; +import '../../modules/module_registry.dart'; import '../services/ai_orb_controller.dart'; import '../../data/services/account_service.dart'; import '../../data/services/memory_reminder_scheduler.dart'; @@ -83,31 +82,30 @@ class InitialBinding extends Bindings { // 会话都往它送指令,代办页又要读它,任何一方被回收都会丢记录。 Get.put(AssistantDirectiveService(), permanent: true); + // Agent 能力模块注册表:路由/跳转/指令/文案/常驻服务都由模块自带 + ModuleRegistry.install(); + ModuleRegistry.registerServices(); + AssistantDirectiveService.to + .registerHandlers(ModuleRegistry.directiveHandlers); // 阿里通义千问 VL:拍照翻译(识别 + 翻译一次完成) Get.lazyPut( () => AlibabaImageTranslationService(), fenix: true); - // BleManager —— 提前 put(不用 lazyPut),让原生杰理 SDK 在 onInit 里立即开始 initialize - Get.put(BleManager(), permanent: true); - // 蓝牙服务(经典蓝牙系统状态观测,仅用于通话音频路由判断) - Get.lazyPut(() => BluetoothManager(), fenix: true); - // 恒玄(BES)蓝牙服务:经典蓝牙 SPP 主动连接 + 音频编解码,独立于杰理 BLE 链路 - // 常驻:状态监听与冷启动自动回连都要在任意页面生效 - Get.put(BesBluetoothService(), permanent: true); - // 蓝讯方案设备(AI手机支架 / AI Pencil):BLE 连接 + Opus 录音流, - // 协议实现来自 echomeet 的 recorder_holder 插件 - Get.put(HolderDeviceService(), permanent: true); - // 支架录音的全局采集/归档:设备端按键在任意页面(含页面未打开)都生效, - // 所以必须常驻,不能挂在录音页控制器上 - Get.put(HolderRecordingService(), permanent: true); - // 恒玄耳机录音的全局采集/归档,与支架那条完全独立 - Get.put(BesRecordingService(), permanent: true); - // 恒玄耳机通话录音(双声道:左本端 / 右对端),跟随通话状态自动起停 - Get.put(BesCallRecordingService(), + // 设备中枢:注册所有厂商插件(恒玄耳机 / EaiRec 支架 / …),业务层只认它。 + // 常驻:状态监听与冷启动自动回连都要在任意页面生效。 + installDeviceHub(); + // 设备品类首次连上时把默认功能铺到 AI 页 + AgentDefaults.bind(DeviceHub.to); + // 设备主麦录音(现场录音)的全局采集/归档:设备端按键在任意页面都生效 + Get.put(DeviceRecordingService(), permanent: true); + // 通话录音(双声道:左本端 / 右对端),跟随通话状态自动起停 + Get.put(DeviceCallRecordingService(), + permanent: true); + // 把「设备自己录在 TF 卡 / flash 里的音频」同步进语音纪要。 + // 常驻:支架连上(含后台自动回连)就该开始同步,不能等用户进某个页面。 + Get.put(DeviceFileImportService(), permanent: true); - // 面对面翻译的左右耳独立 BLE(底层待固件命令,先占好位) - Get.put(FaceToFaceBleService(), permanent: true); // EMAI 对话记录:手机页面与耳机会话共用一份,必须先于 BesAiSessionService // 注册(后者一起来就可能收到耳机唤醒并往里写) Get.put(EmaiChatStore(), permanent: true); @@ -115,9 +113,9 @@ class InitialBinding extends Bindings { // 实名认证状态:AI 页闸门与「我的」页都读它,全局常驻。 // onInit 里会自动拉一次服务端状态(游客跳过)。 Get.put(IdVerifyService(), permanent: true); - // 恒玄耳机按键唤醒的 AI 实时对话(接百炼多模态)。必须常驻: - // 耳机按键在任意页面、甚至 App 在后台时都要能起会话 - Get.put(BesAiSessionService(), permanent: true); + // 设备按键唤醒的 AI 实时对话(接百炼多模态)。必须常驻: + // 按键在任意页面、甚至 App 在后台时都要能起会话 + Get.put(DeviceAiSessionService(), permanent: true); // MusicManager Get.lazyPut(() => MusicManager(), fenix: true); diff --git a/apps/client/lib/core/services/ai_orb_controller.dart b/apps/client/lib/core/services/ai_orb_controller.dart index feb506a7..8724a5e6 100644 --- a/apps/client/lib/core/services/ai_orb_controller.dart +++ b/apps/client/lib/core/services/ai_orb_controller.dart @@ -21,7 +21,6 @@ class AiOrbController extends GetxController { static const Set _hiddenRoutes = { Routes.splash, Routes.login, - Routes.agent, Routes.emai, Routes.mobileElf, }; diff --git a/apps/client/lib/core/translations/app_translations.dart b/apps/client/lib/core/translations/app_translations.dart index 2395cfbf..90a8e8b1 100644 --- a/apps/client/lib/core/translations/app_translations.dart +++ b/apps/client/lib/core/translations/app_translations.dart @@ -3,6 +3,8 @@ import 'dart:ui'; import 'package:get/get.dart'; import 'package:get_storage/get_storage.dart'; +import '../../modules/module_registry.dart'; + import 'language/en_us.dart'; // 英语 import 'language/zh_cn.dart'; // 简体中文 import 'language/es_es.dart'; // 西班牙语 @@ -302,8 +304,12 @@ class AppTranslations extends Translations { return fallbackLocale; } + /// 全局语言表 + 各能力模块自带的文案(见 ModuleRegistry.mergeTranslations) @override - Map> get keys => { + Map> get keys => + ModuleRegistry.mergeTranslations(_base); + + static final Map> _base = { 'en_US': enUS, 'zh_CN': zhCN, 'zh_HK': zhHK, // 添加繁体中文映射 diff --git a/apps/client/lib/core/translations/language/ar_sa.dart b/apps/client/lib/core/translations/language/ar_sa.dart index 7f67e3d0..0b0ac548 100644 --- a/apps/client/lib/core/translations/language/ar_sa.dart +++ b/apps/client/lib/core/translations/language/ar_sa.dart @@ -1868,4 +1868,208 @@ const Map arSA = { 'unmarkFavorite': 'إزالة من المفضلة', 'unnamedDevice': 'جهاز بلا اسم', 'userDataParseFailed': 'تعذّر تحليل بيانات المستخدم', + 'catEarphone': 'سماعات ذكية', + 'catEarphoneDesc': 'سماعات بلوتوث كلاسيكي / BLE', + 'catPhoneHolder': 'حامل هاتف ذكي', + 'catPhoneHolderDesc': 'أجهزة الحوامل', + 'catCarAroma': 'معطر سيارة ذكي', + 'catCarAromaDesc': 'موزع عطر للسيارة', + 'agreeAndContinue': 'موافقة ومتابعة', + 'memberServiceAgreement': 'اتفاقية خدمة العضوية', + 'memberServiceAgreementTitle': 'اتفاقية خدمة العضوية', + 'memberServiceAgreementIntro': 'لتقديم خدمات عضوية أفضل لك، يرجى القراءة بعناية قبل الاشتراك: ', + 'memberServiceAgreementOutro': '. بالنقر على «موافقة ومتابعة» فإنك تؤكد أنك قرأت الاتفاقية أعلاه بالكامل ووافقت عليها.', + 'memberAgreementRejectedTip': 'يجب الموافقة على اتفاقية خدمة العضوية للمتابعة.', + 'translationCallExitTitle': 'الخروج من ترجمة المكالمة', + 'translationCallExitMessage': 'اختر ما تريد فعله قبل المغادرة', + 'translationCallEnd': 'إنهاء ترجمة المكالمة', + 'translationContinueInBackground': 'متابعة الترجمة في الخلفية', + 'backgroundRecordingIndicatorBody': 'التسجيل مستمر في الخلفية. اضغط للعودة.', + 'backgroundTranslationIndicatorBody': 'ترجمة المكالمة مستمرة في الخلفية. اضغط للعودة.', + 'meetingRecordScreenCapturePermissionDenied': 'إذن تسجيل الشاشة مطلوب لالتقاط صوت النظام. يرجى منحه ثم إعادة المحاولة.', + 'pleaseGrantBluetoothPermission': 'يرجى منح إذن البلوتوث للمسح', + 'startScanFailed': 'فشل بدء المسح', + 'pleaseLoginFirst': 'يرجى تسجيل الدخول أولاً', + 'deviceUnauthorized': 'الجهاز غير مصرح به، يرجى إعادة المحاولة', + 'boundDevices': 'الأجهزة المرتبطة', + 'currentConnection': 'الاتصال الحالي', + 'binding': 'جارٍ الربط...', + 'permissionDisabled': 'معطّل', + 'confirmDisablePermissionTitle': 'إيقاف الإذن', + 'confirmDisablePermissionContent': 'هل تريد إيقاف إذن {permission}؟ ستحتاج إلى الذهاب إلى إعدادات النظام لإعادة تفعيله.', + 'confirmPermanentlyDisallowTitle': 'التوقف عن السؤال؟', + 'confirmPermanentlyDisallowContent': 'لقد رفضت إذن {permission}. هل تريد التوقف عن السؤال من الآن فصاعداً؟ إذا اخترت «عدم السماح»، يمكنك تفعيله لاحقاً من إعدادات النظام.', + 'notNow': 'ليس الآن', + 'permanentlyDisallow': 'عدم السماح', + 'otaBesNotSupported': 'هذه السماعة لا تدعم ترقية البرنامج الثابت داخل التطبيق بعد. يرجى التواصل مع الدعم.', + 'tabDevice': 'الأجهزة', + 'tabPlaza': 'الساحة', + 'sloganHome': 'تقنية لحياة أذكى', + 'sloganAi': 'ذكاء بلا حدود', + 'sloganDevice': 'أجهزة ذكية جاهزة للمستقبل', + 'sloganPlaza': 'عالم ذكي يفهمك', + 'aiEditHint': 'اضغط مطولاً على بطاقة لسحبها وإعادة ترتيب الوحدات', + 'aiEmptyTitle': 'لم تتم إضافة أي وحدات بعد', + 'aiEmptyDesc': 'اختر الوكلاء الذين تحتاجهم من الساحة', + 'goToPlaza': 'الذهاب إلى الساحة', + 'plazaOnline': 'متاح', + 'plazaPlanned': 'قريباً', + 'plazaSearchHint': 'ابحث عن الوكلاء، مثل «ترجمة» أو «تسجيل»', + 'moduleRemove': 'إزالة', + 'moduleAdd': 'إضافة', + 'moduleRemoved': 'تمت الإزالة', + 'moduleAdded': 'تمت الإضافة', + 'goSee': 'عرض', + 'comingSoonTag': 'قريباً', + 'needDeviceTag': 'يتطلب جهازاً', + 'featureInDev': 'هذه الميزة قيد التطوير', + 'featureNotWired': 'غير متاح بعد', + 'todayLabel': 'اليوم', + 'deviceConnected': 'متصل', + 'licenseUnknown': 'الترخيص غير معروف', + 'selectDevice': 'اختر جهازاً', + 'catNoteCard': 'بطاقة تسجيل AI', + 'catNoteCardDesc': 'مسجّل على شكل بطاقة', + 'catParkerPen': 'قلم AI', + 'catParkerPenDesc': 'أقلام تسجيل صوتي', + 'catStylusPen': 'قلم لمس AI', + 'catStylusPenDesc': 'أجهزة الكتابة اليدوية / أقلام اللمس', + 'catStudyMachine': 'لوح تعلّم AI', + 'catStudyMachineDesc': 'أجهزة التعلّم', + 'besAddTitle': 'إضافة سماعات AI', + 'besConnecting': 'جارٍ الاتصال بالسماعات…', + 'besNotFound': 'لم يتم العثور على السماعات', + 'besNotFoundIosDesc': 'قم بتوصيل السماعات أولاً من الإعدادات ← البلوتوث، ثم عد إلى هنا', + 'besNotFoundAndroidDesc': 'قم بإقران السماعات أولاً من الإعدادات ← البلوتوث، ثم اتصل من هنا', + 'besReadFailed': 'فشلت القراءة', + 'besConnectFailIos': 'تأكد من ظهور السماعات بحالة «متصل» في بلوتوث النظام، ثم أعد المحاولة', + 'besConnectFailAndroid': 'تأكد من أن السماعات قيد التشغيل وقريبة وغير متصلة بجهاز آخر. ستعيد الاتصال تلقائياً بمجرد الارتباط.', + 'holderAddTitle': 'إضافة حامل هاتف AI', + 'holderConnecting': 'جارٍ الاتصال بالجهاز…', + 'holderSearching': 'جارٍ البحث عن الأجهزة…', + 'holderNotFound': 'لم يتم العثور على أجهزة', + 'holderPairHint': 'تأكد من أن الحامل قيد التشغيل وفي وضع الإقران', + 'holderRetryHint': 'تأكد من أن الجهاز قيد التشغيل وقريب منك، ثم أعد المحاولة', + 'autoConnectTitle': 'الاتصال التلقائي بالأجهزة', + 'autoConnectDesc': 'إيقاظ التطبيق لإعادة الاتصال واستقبال التسجيلات عند تشغيل جهازك أو اقترابه', + 'autoConnectConsentTitle': 'تفعيل الاتصال التلقائي', + 'autoConnectConsentIntro': 'للسماح لجهازك (سماعات AI / حامل هاتف AI) بالاتصال تلقائياً وحفظ التسجيلات عند الضغط على زر التسجيل فيه، نحتاج إلى موافقتك:', + 'autoConnectConsentB1': 'تسجيل مسح بلوتوث في الخلفية بحيث يتم إيقاظ التطبيق (بدء تلقائي) عند اكتشاف جهازك المرتبط، لإعادة الاتصال واستقبال التسجيلات', + 'autoConnectConsentB2': 'بعد الإيقاظ، يُستخدم فقط لاتصال الجهاز والتسجيل والرفع — لا شيء آخر، ولا يتم تشغيل أي تطبيقات أخرى', + 'autoConnectConsentB3': 'يمكنك الاستمرار في استخدام التطبيق بدونه — فقط اتصل بالجهاز يدوياً في كل مرة', + 'autoConnectConsentB4': 'يمكنك إيقافه في أي وقت من الإعدادات ← الاتصال التلقائي بالأجهزة', + 'autoConnectAgree': 'موافقة وتفعيل', + 'autoConnectLater': 'ليس الآن', + 'autoConnectRequired': 'الاتصال التلقائي متوقف', + 'autoConnectRequiredDesc': 'فعّل الاتصال التلقائي لربط هذا الجهاز', + 'recordTapToStart': 'اضغط على الزر أدناه لبدء التسجيل', + 'recordPaused': 'متوقف مؤقتاً', + 'recordMark': 'علامة', + 'recordDeleteTitle': 'حذف التسجيل', + 'accountAndProfile': 'الحساب والملف الشخصي', + 'nicknameUnset': 'لم يتم تعيين لقب', + 'nicknameInput': 'أدخل لقباً', + 'loginMethod': 'طريقة تسجيل الدخول', + 'manageMyLogin': 'إدارة تسجيل الدخول', + 'logoutAction': 'تسجيل الخروج', + 'warningLabel': 'تحذير:', + 'deleteAccountWarn': 'سيتم حذف حسابك وجميع ملفاتك نهائياً.', + 'icpFiling': 'تسجيل ICP', + 'logExport': 'تصدير السجلات', + 'logExportDesc': 'عرض سجلات التطبيق وتصديرها', + 'permLoadFailed': 'فشل تحميل مجموعات الأذونات', + 'permInsufficient': 'تم رفض الإذن', + 'voiceCloneCreating': 'جارٍ إنشاء صوتك…', + 'voiceCloneCreate': 'إنشاء صوتي', + 'voiceCloneAgree': 'الموافقة وقراءة البيان بصوت عالٍ', + 'voiceCloneReadPrompt': 'يرجى قراءة:', + 'voiceCloneFirstMonthFree': 'الشهر الأول مجاناً', + 'voiceCloneEnable': 'تفعيل استنساخ الصوت', + 'noMusicPlaying': 'لا يوجد شيء قيد التشغيل', + 'qqMusicNoSeek': 'التقديم غير مدعوم في QQ Music', + 'bleScanTitle': 'مسح BLE', + 'bluetoothOn': 'البلوتوث مفعّل', + 'bleScanning': 'جارٍ المسح…', + 'bleConnectedDevices': 'الأجهزة المتصلة', + 'bleUnknownDevice': 'جهاز غير معروف', + 'bleTapToScan': 'اضغط على مسح للبحث عن أجهزة BLE', + 'weekMon': 'الاثنين', + 'weekTue': 'الثلاثاء', + 'weekWed': 'الأربعاء', + 'weekThu': 'الخميس', + 'weekFri': 'الجمعة', + 'weekSat': 'السبت', + 'weekSun': 'الأحد', + 'agentMeeting': 'محاضر صوتية', + 'agentMeetingDesc': 'تلخيص التسجيلات ومركز الذاكرة', + 'agentEmai': 'مساعد EMAI', + 'agentEmaiDesc': 'تحدث واسأل أي شيء بصوتك', + 'agentSimultaneous': 'الترجمة الفورية', + 'agentSimultaneousDesc': 'ترجمة أثناء حديثك', + 'agentFaceToFace': 'وجهاً لوجه', + 'agentFaceToFaceDesc': 'ترجمة محادثة ثنائية الاتجاه', + 'agentCallRecording': 'تسجيل المكالمات', + 'agentCallRecordingDesc': 'حفظ المكالمات تلقائياً', + 'agentFieldRecording': 'التسجيل الميداني', + 'agentFieldRecordingDesc': 'ميكروفون الجهاز أو الهاتف', + 'agentCall': 'ترجمة المكالمات', + 'agentCallDesc': 'ترجمة فورية أثناء المكالمات', + 'agentAudioVideo': 'ترجمة الوسائط', + 'agentAudioVideoDesc': 'تأطير الكاميرا / ترجمة الصوت والفيديو', + 'agentEnglishChat': 'محادثة بالإنجليزية', + 'agentEnglishChatDesc': 'تدريب غامر على التحدث', + 'agentEnglishStudy': 'دراسة الإنجليزية', + 'agentEnglishStudyDesc': 'دروس المفردات والقواعد', + 'agentKidsStory': 'قصص الأطفال', + 'agentKidsStoryDesc': 'قصص ما قبل النوم وأسئلة وأجوبة', + 'moduleRemovedDesc': 'تمت إزالته من صفحة AI', + 'moduleAddedDesc': 'تمت إضافته إلى أسفل صفحة AI', + 'calNoRecords': 'لا توجد إدخالات', + 'calNoRecordsDay': 'لا يوجد شيء مجدول', + 'calTodos': 'المهام', + 'calExpenses': 'المصروفات', + 'calTodoCount': '@count مهام', + 'calExpenseCount': '@count مصروفات', + 'calEntryCount': '@count إدخالات', + 'calItemsUnit': 'عناصر', + 'calCountUnit': 'إدخالات', + 'calTotalAmount': 'الإجمالي', + 'recordPause': 'إيقاف مؤقت', + 'recordResume': 'استئناف', + 'recordDeleteConfirm': 'حذف هذا التسجيل؟', + 'scopeDay': 'يوم', + 'scopeWeek': 'أسبوع', + 'scopeMonth': 'شهر', + 'allDay': 'طوال اليوم', + 'guestModeTitle': 'وضع الضيف', + 'guestModeDesc': 'يمكن للضيوف التصفح فقط. سجّل الدخول لاستخدام الملاحظات الصوتية والترجمة والتسجيل — وسيتم حفظ بياناتك في حسابك.', + 'guestKeepBrowsing': 'متابعة التصفح', + 'guestGoLogin': 'تسجيل الدخول', + 'guestViewOnlyTag': 'للعرض فقط', + 'autoConnectReenableHint': 'الاتصال التلقائي متوقف. أعد تفعيله من الإعدادات ← الاتصال التلقائي بالأجهزة.', + 'plazaEntryDesc': 'تصفح وأضف المزيد من الوكلاء', + 'agentTextTranslation': 'ترجمة النصوص', + 'agentTextTranslationDesc': 'ترجمة النص المكتوب أو الملصق', + 'agentPictureTranslation': 'ترجمة الصور', + 'agentPictureTranslationDesc': 'التقط صورة أو اختر واحدة لترجمتها', + 'callRequiredTitle': 'يتطلب مكالمة', + 'callRequiredDesc': 'تعمل هذه الميزة أثناء المكالمة فقط', + 'inCallBusyTitle': 'غير متاح أثناء المكالمة', + 'inCallBusyDesc': 'الميكروفون ومسار الصوت مشغولان بالمكالمة. حاول مرة أخرى بعد إنهاء المكالمة.', + 'vipSectionTitle': 'VIP', + 'freeSectionTitle': 'مجاني', + 'smartcarConnected': 'Connected', + 'smartcarConnectFailed': 'Connection failed', + 'smartcarConnecting': 'Connecting…', + 'smartcarSearching': 'Searching for nearby car diffusers…', + 'smartcarNotFound': 'No device found', + 'smartcarScanHint': 'Make sure the device is powered on and close to your phone', + 'vipRequiredTitle': 'Membership required', + 'vipRequiredDesc': 'Translation and meeting notes require a membership. Renew, or pair a device that includes one.', + 'vipRequiredGo': 'Get membership', + 'vipFromDevice': 'from device', + 'computeSummary': 'Compute: device @device · account @user', + 'computePack': 'Compute packs', + 'vipExpiringSoon': 'Membership expires in @days day(s). Renew to keep using translation and meeting notes.', + 'computeLow': 'Running low on compute. You won\'t be able to continue once it runs out.', }; diff --git a/apps/client/lib/core/translations/language/bg_bg.dart b/apps/client/lib/core/translations/language/bg_bg.dart index ee28a3c8..9b789b04 100644 --- a/apps/client/lib/core/translations/language/bg_bg.dart +++ b/apps/client/lib/core/translations/language/bg_bg.dart @@ -1931,4 +1931,208 @@ const Map bgBG = { 'unmarkFavorite': 'Премахни от любими', 'unnamedDevice': 'Устройство без име', 'userDataParseFailed': 'Данните на потребителя не бяха разчетени', + 'catEarphone': 'AI слушалки', + 'catEarphoneDesc': 'Класически Bluetooth / BLE слушалки', + 'catPhoneHolder': 'AI стойка за телефон', + 'catPhoneHolderDesc': 'Устройства тип стойка', + 'catCarAroma': 'AI ароматизатор за кола', + 'catCarAromaDesc': 'Ароматизатор за автомобил', + 'agreeAndContinue': 'Приемам и продължавам', + 'memberServiceAgreement': 'Споразумение за членски услуги', + 'memberServiceAgreementTitle': 'Споразумение за членски услуги', + 'memberServiceAgreementIntro': 'За да ви предоставим по-добри членски услуги, моля, прочетете внимателно, преди да се абонирате: ', + 'memberServiceAgreementOutro': '. С натискане на „Приемам и продължавам“ потвърждавате, че сте прочели изцяло и приемате горното споразумение.', + 'memberAgreementRejectedTip': 'Трябва да приемете Споразумението за членски услуги, за да продължите.', + 'translationCallExitTitle': 'Изход от превода на разговор', + 'translationCallExitMessage': 'Изберете какво да направите, преди да излезете', + 'translationCallEnd': 'Край на превода на разговор', + 'translationContinueInBackground': 'Продължи превода във фонов режим', + 'backgroundRecordingIndicatorBody': 'Записът продължава във фонов режим. Натиснете, за да се върнете.', + 'backgroundTranslationIndicatorBody': 'Преводът на разговора продължава във фонов режим. Натиснете, за да се върнете.', + 'meetingRecordScreenCapturePermissionDenied': 'Необходимо е разрешение за запис на екрана, за да се записва системното аудио. Моля, разрешете го и опитайте отново.', + 'pleaseGrantBluetoothPermission': 'Моля, разрешете достъп до Bluetooth за сканиране', + 'startScanFailed': 'Неуспешно стартиране на сканирането', + 'pleaseLoginFirst': 'Моля, първо влезте', + 'deviceUnauthorized': 'Устройството не е оторизирано, опитайте отново', + 'boundDevices': 'Обвързани устройства', + 'currentConnection': 'Текуща връзка', + 'binding': 'Обвързване...', + 'permissionDisabled': 'Изключено', + 'confirmDisablePermissionTitle': 'Изключване на разрешение', + 'confirmDisablePermissionContent': 'Да се изключи ли разрешението {permission}? Ще трябва да го включите отново от системните настройки.', + 'confirmPermanentlyDisallowTitle': 'Спиране на запитванията?', + 'confirmPermanentlyDisallowContent': 'Отказахте разрешението {permission}. Да спрем ли да питаме занапред? Ако изберете „Не разрешавай“, пак можете да го включите по-късно от системните настройки.', + 'notNow': 'Не сега', + 'permanentlyDisallow': 'Не разрешавай', + 'otaBesNotSupported': 'Тези слушалки все още не поддържат обновяване на фърмуера от приложението. Моля, свържете се с поддръжката.', + 'tabDevice': 'Устройства', + 'tabPlaza': 'Площад', + 'sloganHome': 'Технология за по-умен живот', + 'sloganAi': 'Безграничен интелект', + 'sloganDevice': 'Умен хардуер, готов за бъдещето', + 'sloganPlaza': 'Умен свят, който те разбира', + 'aiEditHint': 'Задръжте карта, за да плъзнете и пренаредите модулите', + 'aiEmptyTitle': 'Все още няма добавени модули', + 'aiEmptyDesc': 'Изберете нужните агенти в Площад', + 'goToPlaza': 'Към Площад', + 'plazaOnline': 'Наличен', + 'plazaPlanned': 'Очаквайте скоро', + 'plazaSearchHint': 'Търсене на агенти, напр. „превод“, „запис“', + 'moduleRemove': 'Премахни', + 'moduleAdd': 'Добави', + 'moduleRemoved': 'Премахнато', + 'moduleAdded': 'Добавено', + 'goSee': 'Виж', + 'comingSoonTag': 'Очаквайте скоро', + 'needDeviceTag': 'Изисква устройство', + 'featureInDev': 'Тази функция е в разработка', + 'featureNotWired': 'Все още не е налична', + 'todayLabel': 'Днес', + 'deviceConnected': 'Свързано', + 'licenseUnknown': 'Неизвестен лиценз', + 'selectDevice': 'Изберете устройство', + 'catNoteCard': 'AI карта за запис', + 'catNoteCardDesc': 'Рекордер с форма на карта', + 'catParkerPen': 'AI писалка', + 'catParkerPenDesc': 'Писалки с диктофон', + 'catStylusPen': 'AI стилус', + 'catStylusPenDesc': 'Устройства за ръкопис / стилус', + 'catStudyMachine': 'AI таблет за учене', + 'catStudyMachineDesc': 'Устройства за обучение', + 'besAddTitle': 'Добавяне на AI слушалки', + 'besConnecting': 'Свързване със слушалките…', + 'besNotFound': 'Слушалките не са намерени', + 'besNotFoundIosDesc': 'Първо свържете слушалките в Настройки → Bluetooth, след което се върнете тук', + 'besNotFoundAndroidDesc': 'Първо сдвоете слушалките в Настройки → Bluetooth, след което ги свържете тук', + 'besReadFailed': 'Неуспешно четене', + 'besConnectFailIos': 'Уверете се, че слушалките са показани като „Свързани“ в системния Bluetooth, след което опитайте отново', + 'besConnectFailAndroid': 'Уверете се, че слушалките са включени, наблизо и не са свързани с друго устройство. След свързване ще се свързват отново автоматично.', + 'holderAddTitle': 'Добавяне на AI стойка за телефон', + 'holderConnecting': 'Свързване с устройството…', + 'holderSearching': 'Търсене на устройства…', + 'holderNotFound': 'Не са намерени устройства', + 'holderPairHint': 'Уверете се, че стойката е включена и в режим на сдвояване', + 'holderRetryHint': 'Уверете се, че устройството е включено и наблизо, след което опитайте отново', + 'autoConnectTitle': 'Автоматично свързване на устройства', + 'autoConnectDesc': 'Събужда приложението, за да се свърже отново и да получи записите, когато устройството ви се включи или е наблизо', + 'autoConnectConsentTitle': 'Включване на автоматичното свързване', + 'autoConnectConsentIntro': 'За да може устройството ви (AI слушалки / AI стойка за телефон) да се свързва автоматично и да запазва записи при натискане на бутона му за запис, ни е нужно вашето съгласие:', + 'autoConnectConsentB1': 'Регистриране на фоново Bluetooth сканиране, за да се събуди (стартира автоматично) приложението при откриване на обвързаното ви устройство, да се свърже отново и да получи записите', + 'autoConnectConsentB2': 'След събуждане се използва само за свързване с устройството, запис и качване — нищо друго, и не се стартират други приложения', + 'autoConnectConsentB3': 'Можете да използвате приложението и без това — просто свързвайте устройството ръчно всеки път', + 'autoConnectConsentB4': 'Можете да го изключите по всяко време в Настройки → Автоматично свързване на устройства', + 'autoConnectAgree': 'Приемам и включвам', + 'autoConnectLater': 'Не сега', + 'autoConnectRequired': 'Автоматичното свързване е изключено', + 'autoConnectRequiredDesc': 'Включете автоматичното свързване, за да обвържете това устройство', + 'recordTapToStart': 'Натиснете бутона по-долу, за да започнете запис', + 'recordPaused': 'На пауза', + 'recordMark': 'Маркер', + 'recordDeleteTitle': 'Изтриване на запис', + 'accountAndProfile': 'Акаунт и профил', + 'nicknameUnset': 'Няма зададен прякор', + 'nicknameInput': 'Въведете прякор', + 'loginMethod': 'Начин на вход', + 'manageMyLogin': 'Управление на входа', + 'logoutAction': 'Излизане', + 'warningLabel': 'Предупреждение:', + 'deleteAccountWarn': 'Вашият акаунт и всички файлове ще бъдат изтрити завинаги.', + 'icpFiling': 'ICP регистрация', + 'logExport': 'Експорт на логове', + 'logExportDesc': 'Преглед и експорт на логовете на приложението', + 'permLoadFailed': 'Неуспешно зареждане на групите разрешения', + 'permInsufficient': 'Отказано разрешение', + 'voiceCloneCreating': 'Създаване на вашия глас…', + 'voiceCloneCreate': 'Създай моя глас', + 'voiceCloneAgree': 'Приемам и прочитам декларацията на глас', + 'voiceCloneReadPrompt': 'Моля, прочетете:', + 'voiceCloneFirstMonthFree': 'Първият месец безплатно', + 'voiceCloneEnable': 'Включване на клониране на глас', + 'noMusicPlaying': 'Нищо не се възпроизвежда', + 'qqMusicNoSeek': 'Превъртането не се поддържа за QQ Music', + 'bleScanTitle': 'BLE сканиране', + 'bluetoothOn': 'Bluetooth е включен', + 'bleScanning': 'Сканиране…', + 'bleConnectedDevices': 'Свързани устройства', + 'bleUnknownDevice': 'Неизвестно устройство', + 'bleTapToScan': 'Натиснете „Сканирай“, за да търсите BLE устройства', + 'weekMon': 'Пн', + 'weekTue': 'Вт', + 'weekWed': 'Ср', + 'weekThu': 'Чт', + 'weekFri': 'Пт', + 'weekSat': 'Сб', + 'weekSun': 'Нд', + 'agentMeeting': 'Гласови бележки', + 'agentMeetingDesc': 'Резюме на записи, център за памет', + 'agentEmai': 'EMAI асистент', + 'agentEmaiDesc': 'Разговаряйте и питайте всичко с глас', + 'agentSimultaneous': 'Превод на живо', + 'agentSimultaneousDesc': 'Превод, докато говорите', + 'agentFaceToFace': 'Лице в лице', + 'agentFaceToFaceDesc': 'Двупосочен превод на разговор', + 'agentCallRecording': 'Запис на разговор', + 'agentCallRecordingDesc': 'Разговорите се записват автоматично', + 'agentFieldRecording': 'Запис на място', + 'agentFieldRecordingDesc': 'Микрофон на устройството или телефона', + 'agentCall': 'Превод на разговори', + 'agentCallDesc': 'Превод в реално време по време на разговор', + 'agentAudioVideo': 'Превод на медия', + 'agentAudioVideoDesc': 'Кадриране с камера / превод на аудио и видео', + 'agentEnglishChat': 'Разговор на английски', + 'agentEnglishChatDesc': 'Потапяща практика на говорене', + 'agentEnglishStudy': 'Учене на английски', + 'agentEnglishStudyDesc': 'Уроци по лексика и граматика', + 'agentKidsStory': 'Детски приказки', + 'agentKidsStoryDesc': 'Приказки за лека нощ и въпроси и отговори', + 'moduleRemovedDesc': 'премахнато от страницата AI', + 'moduleAddedDesc': 'добавено в края на страницата AI', + 'calNoRecords': 'Няма записи', + 'calNoRecordsDay': 'Нищо планирано', + 'calTodos': 'Задачи', + 'calExpenses': 'Разходи', + 'calTodoCount': '@count задачи', + 'calExpenseCount': '@count разходи', + 'calEntryCount': '@count записи', + 'calItemsUnit': 'елемента', + 'calCountUnit': 'записа', + 'calTotalAmount': 'Общо', + 'recordPause': 'Пауза', + 'recordResume': 'Продължи', + 'recordDeleteConfirm': 'Да се изтрие ли този запис?', + 'scopeDay': 'Ден', + 'scopeWeek': 'Седмица', + 'scopeMonth': 'Месец', + 'allDay': 'Цял ден', + 'guestModeTitle': 'Режим гост', + 'guestModeDesc': 'Гостите могат само да разглеждат. Влезте, за да използвате гласови бележки, превод и запис — данните ви ще се запазват в акаунта ви.', + 'guestKeepBrowsing': 'Продължи разглеждането', + 'guestGoLogin': 'Вход', + 'guestViewOnlyTag': 'Само преглед', + 'autoConnectReenableHint': 'Автоматичното свързване е изключено. Включете го отново в Настройки → Автоматично свързване на устройства.', + 'plazaEntryDesc': 'Разгледайте и добавете още агенти', + 'agentTextTranslation': 'Текстов превод', + 'agentTextTranslationDesc': 'Превод на въведен или поставен текст', + 'agentPictureTranslation': 'Превод на снимки', + 'agentPictureTranslationDesc': 'Снимайте или изберете снимка за превод', + 'callRequiredTitle': 'Необходим е разговор', + 'callRequiredDesc': 'Тази функция работи само по време на разговор', + 'inCallBusyTitle': 'Недостъпно по време на разговор', + 'inCallBusyDesc': 'Микрофонът и аудио каналът се използват от разговора. Опитайте отново след затваряне на разговора.', + 'vipSectionTitle': 'VIP', + 'freeSectionTitle': 'Безплатно', + 'smartcarConnected': 'Connected', + 'smartcarConnectFailed': 'Connection failed', + 'smartcarConnecting': 'Connecting…', + 'smartcarSearching': 'Searching for nearby car diffusers…', + 'smartcarNotFound': 'No device found', + 'smartcarScanHint': 'Make sure the device is powered on and close to your phone', + 'vipRequiredTitle': 'Membership required', + 'vipRequiredDesc': 'Translation and meeting notes require a membership. Renew, or pair a device that includes one.', + 'vipRequiredGo': 'Get membership', + 'vipFromDevice': 'from device', + 'computeSummary': 'Compute: device @device · account @user', + 'computePack': 'Compute packs', + 'vipExpiringSoon': 'Membership expires in @days day(s). Renew to keep using translation and meeting notes.', + 'computeLow': 'Running low on compute. You won\'t be able to continue once it runs out.', }; diff --git a/apps/client/lib/core/translations/language/cs_cz.dart b/apps/client/lib/core/translations/language/cs_cz.dart index 254aff2d..166713f9 100644 --- a/apps/client/lib/core/translations/language/cs_cz.dart +++ b/apps/client/lib/core/translations/language/cs_cz.dart @@ -1899,4 +1899,208 @@ const Map csCZ = { 'unmarkFavorite': 'Odebrat z oblíbených', 'unnamedDevice': 'Nepojmenované zařízení', 'userDataParseFailed': 'Data uživatele se nepodařilo načíst', + 'catEarphone': 'AI sluchátka', + 'catEarphoneDesc': 'Klasická Bluetooth / BLE sluchátka', + 'catPhoneHolder': 'AI držák telefonu', + 'catPhoneHolderDesc': 'Zařízení typu držák', + 'catCarAroma': 'AI vůně do auta', + 'catCarAromaDesc': 'Difuzér vůně do auta', + 'agreeAndContinue': 'Souhlasím a pokračovat', + 'memberServiceAgreement': 'Smlouva o členských službách', + 'memberServiceAgreementTitle': 'Smlouva o členských službách', + 'memberServiceAgreementIntro': 'Abychom vám mohli poskytovat lepší členské služby, před předplacením si prosím pečlivě přečtěte: ', + 'memberServiceAgreementOutro': '. Klepnutím na „Souhlasím a pokračovat“ potvrzujete, že jste si výše uvedenou smlouvu celou přečetli a přijímáte ji.', + 'memberAgreementRejectedTip': 'Pro pokračování musíte souhlasit se Smlouvou o členských službách.', + 'translationCallExitTitle': 'Opustit překlad hovoru', + 'translationCallExitMessage': 'Vyberte, co udělat před odchodem', + 'translationCallEnd': 'Ukončit překlad hovoru', + 'translationContinueInBackground': 'Pokračovat v překladu na pozadí', + 'backgroundRecordingIndicatorBody': 'Nahrávání pokračuje na pozadí. Klepnutím se vrátíte.', + 'backgroundTranslationIndicatorBody': 'Překlad hovoru pokračuje na pozadí. Klepnutím se vrátíte.', + 'meetingRecordScreenCapturePermissionDenied': 'K nahrávání systémového zvuku je potřeba oprávnění k nahrávání obrazovky. Udělte ho prosím a zkuste to znovu.', + 'pleaseGrantBluetoothPermission': 'Pro skenování udělte prosím oprávnění Bluetooth', + 'startScanFailed': 'Spuštění skenování selhalo', + 'pleaseLoginFirst': 'Nejprve se prosím přihlaste', + 'deviceUnauthorized': 'Zařízení není autorizováno, zkuste to znovu', + 'boundDevices': 'Propojená zařízení', + 'currentConnection': 'Aktuální připojení', + 'binding': 'Propojování…', + 'permissionDisabled': 'Vypnuto', + 'confirmDisablePermissionTitle': 'Vypnout oprávnění', + 'confirmDisablePermissionContent': 'Vypnout oprávnění {permission}? Pro opětovné zapnutí bude nutné přejít do nastavení systému.', + 'confirmPermanentlyDisallowTitle': 'Přestat se ptát?', + 'confirmPermanentlyDisallowContent': 'Odepřeli jste oprávnění {permission}. Přestat se od nynějška ptát? Pokud zvolíte „Nepovolit“, můžete ho později zapnout v nastavení systému.', + 'notNow': 'Teď ne', + 'permanentlyDisallow': 'Nepovolit', + 'otaBesNotSupported': 'Tato sluchátka zatím nepodporují aktualizaci firmwaru v aplikaci. Kontaktujte prosím podporu.', + 'tabDevice': 'Zařízení', + 'tabPlaza': 'Plaza', + 'sloganHome': 'Technologie pro chytřejší život', + 'sloganAi': 'Inteligence bez hranic', + 'sloganDevice': 'Chytrý hardware připravený na budoucnost', + 'sloganPlaza': 'Chytrý svět, který vám rozumí', + 'aiEditHint': 'Dlouhým stisknutím karty přetáhnete a přeuspořádáte moduly', + 'aiEmptyTitle': 'Zatím žádné moduly', + 'aiEmptyDesc': 'Vyberte si potřebné agenty v Plaza', + 'goToPlaza': 'Přejít do Plaza', + 'plazaOnline': 'K dispozici', + 'plazaPlanned': 'Již brzy', + 'plazaSearchHint': 'Hledat agenty, např. „překlad“, „nahrávání“', + 'moduleRemove': 'Odebrat', + 'moduleAdd': 'Přidat', + 'moduleRemoved': 'Odebráno', + 'moduleAdded': 'Přidáno', + 'goSee': 'Zobrazit', + 'comingSoonTag': 'Již brzy', + 'needDeviceTag': 'Vyžaduje zařízení', + 'featureInDev': 'Tato funkce se připravuje', + 'featureNotWired': 'Zatím není k dispozici', + 'todayLabel': 'Dnes', + 'deviceConnected': 'Připojeno', + 'licenseUnknown': 'Licence neznámá', + 'selectDevice': 'Vyberte zařízení', + 'catNoteCard': 'AI záznamová karta', + 'catNoteCardDesc': 'Záznamník ve tvaru karty', + 'catParkerPen': 'AI pero', + 'catParkerPenDesc': 'Pera s hlasovým záznamníkem', + 'catStylusPen': 'AI stylus', + 'catStylusPenDesc': 'Zařízení pro ruční psaní / stylusy', + 'catStudyMachine': 'AI výukový tablet', + 'catStudyMachineDesc': 'Výuková zařízení', + 'besAddTitle': 'Přidat AI sluchátka', + 'besConnecting': 'Připojování ke sluchátkům…', + 'besNotFound': 'Sluchátka nenalezena', + 'besNotFoundIosDesc': 'Nejprve připojte sluchátka v Nastavení → Bluetooth a poté se vraťte', + 'besNotFoundAndroidDesc': 'Nejprve spárujte sluchátka v Nastavení → Bluetooth a poté je zde připojte', + 'besReadFailed': 'Čtení selhalo', + 'besConnectFailIos': 'Ujistěte se, že jsou sluchátka v systémovém Bluetooth zobrazena jako Připojeno, a zkuste to znovu', + 'besConnectFailAndroid': 'Ujistěte se, že jsou sluchátka zapnutá, poblíž a nejsou připojena k jinému zařízení. Po propojení se budou připojovat automaticky.', + 'holderAddTitle': 'Přidat AI stojánek na telefon', + 'holderConnecting': 'Připojování k zařízení…', + 'holderSearching': 'Hledání zařízení…', + 'holderNotFound': 'Žádná zařízení nenalezena', + 'holderPairHint': 'Ujistěte se, že je stojánek zapnutý a v režimu párování', + 'holderRetryHint': 'Ujistěte se, že je zařízení zapnuté a poblíž, a zkuste to znovu', + 'autoConnectTitle': 'Automatické připojení zařízení', + 'autoConnectDesc': 'Probudí aplikaci, aby se znovu připojila a přijala nahrávky, když se vaše zařízení zapne nebo přiblíží', + 'autoConnectConsentTitle': 'Zapnout automatické připojení', + 'autoConnectConsentIntro': 'Aby se vaše zařízení (AI sluchátka / AI stojánek na telefon) mohlo připojovat automaticky a ukládat nahrávky po stisknutí tlačítka nahrávání, potřebujeme váš souhlas:', + 'autoConnectConsentB1': 'Zaregistrovat skenování Bluetooth na pozadí, aby se aplikace při zjištění propojeného zařízení probudila (automaticky spustila), znovu připojila a přijala nahrávky', + 'autoConnectConsentB2': 'Po probuzení se používá pouze pro připojení zařízení, nahrávání a odesílání — nic jiného, žádné další aplikace se nespouštějí', + 'autoConnectConsentB3': 'Aplikaci můžete používat i bez toho — stačí zařízení pokaždé připojit ručně', + 'autoConnectConsentB4': 'Kdykoli to můžete vypnout v Nastavení → Automatické připojení zařízení', + 'autoConnectAgree': 'Souhlasím a zapnout', + 'autoConnectLater': 'Teď ne', + 'autoConnectRequired': 'Automatické připojení je vypnuté', + 'autoConnectRequiredDesc': 'Pro propojení tohoto zařízení zapněte automatické připojení', + 'recordTapToStart': 'Klepnutím na tlačítko níže spustíte nahrávání', + 'recordPaused': 'Pozastaveno', + 'recordMark': 'Značka', + 'recordDeleteTitle': 'Smazat nahrávku', + 'accountAndProfile': 'Účet a profil', + 'nicknameUnset': 'Přezdívka nenastavena', + 'nicknameInput': 'Zadejte přezdívku', + 'loginMethod': 'Způsob přihlášení', + 'manageMyLogin': 'Spravovat přihlášení', + 'logoutAction': 'Odhlásit se', + 'warningLabel': 'Upozornění:', + 'deleteAccountWarn': 'Váš účet a všechny soubory budou trvale smazány.', + 'icpFiling': 'Registrace ICP', + 'logExport': 'Export protokolů', + 'logExportDesc': 'Zobrazit a exportovat protokoly aplikace', + 'permLoadFailed': 'Načtení skupin oprávnění selhalo', + 'permInsufficient': 'Oprávnění odepřeno', + 'voiceCloneCreating': 'Vytváření vašeho hlasu…', + 'voiceCloneCreate': 'Vytvořit můj hlas', + 'voiceCloneAgree': 'Souhlasím a přečtu prohlášení nahlas', + 'voiceCloneReadPrompt': 'Přečtěte prosím:', + 'voiceCloneFirstMonthFree': 'První měsíc zdarma', + 'voiceCloneEnable': 'Zapnout klonování hlasu', + 'noMusicPlaying': 'Nic se nepřehrává', + 'qqMusicNoSeek': 'QQ Music nepodporuje přetáčení', + 'bleScanTitle': 'Skenování BLE', + 'bluetoothOn': 'Bluetooth zapnutý', + 'bleScanning': 'Skenování…', + 'bleConnectedDevices': 'Připojená zařízení', + 'bleUnknownDevice': 'Neznámé zařízení', + 'bleTapToScan': 'Klepnutím na Skenovat vyhledáte BLE zařízení', + 'weekMon': 'Po', + 'weekTue': 'Út', + 'weekWed': 'St', + 'weekThu': 'Čt', + 'weekFri': 'Pá', + 'weekSat': 'So', + 'weekSun': 'Ne', + 'agentMeeting': 'Hlasové zápisy', + 'agentMeetingDesc': 'Shrnutí nahrávek, centrum paměti', + 'agentEmai': 'Asistent EMAI', + 'agentEmaiDesc': 'Povídejte si a ptejte se hlasem na cokoli', + 'agentSimultaneous': 'Živý překlad', + 'agentSimultaneousDesc': 'Překlad během řeči', + 'agentFaceToFace': 'Tváří v tvář', + 'agentFaceToFaceDesc': 'Obousměrný překlad konverzace', + 'agentCallRecording': 'Nahrávání hovorů', + 'agentCallRecordingDesc': 'Hovory se ukládají automaticky', + 'agentFieldRecording': 'Terénní nahrávání', + 'agentFieldRecordingDesc': 'Mikrofon zařízení nebo telefonu', + 'agentCall': 'Překlad hovoru', + 'agentCallDesc': 'Překlad v reálném čase během hovorů', + 'agentAudioVideo': 'Překlad médií', + 'agentAudioVideoDesc': 'Záběr kamery / překlad audia a videa', + 'agentEnglishChat': 'Konverzace v angličtině', + 'agentEnglishChatDesc': 'Imerzivní procvičování mluvení', + 'agentEnglishStudy': 'Studium angličtiny', + 'agentEnglishStudyDesc': 'Lekce slovní zásoby a gramatiky', + 'agentKidsStory': 'Dětské pohádky', + 'agentKidsStoryDesc': 'Pohádky na dobrou noc a otázky s odpověďmi', + 'moduleRemovedDesc': 'odebráno ze stránky AI', + 'moduleAddedDesc': 'přidáno na konec stránky AI', + 'calNoRecords': 'Žádné záznamy', + 'calNoRecordsDay': 'Nic naplánováno', + 'calTodos': 'Úkoly', + 'calExpenses': 'Výdaje', + 'calTodoCount': 'Úkoly: @count', + 'calExpenseCount': 'Výdaje: @count', + 'calEntryCount': 'Záznamy: @count', + 'calItemsUnit': 'položek', + 'calCountUnit': 'záznamů', + 'calTotalAmount': 'Celkem', + 'recordPause': 'Pozastavit', + 'recordResume': 'Pokračovat', + 'recordDeleteConfirm': 'Smazat tuto nahrávku?', + 'scopeDay': 'Den', + 'scopeWeek': 'Týden', + 'scopeMonth': 'Měsíc', + 'allDay': 'Celý den', + 'guestModeTitle': 'Režim hosta', + 'guestModeDesc': 'Hosté mohou pouze prohlížet. Přihlaste se a používejte hlasové poznámky, překlad a nahrávání — vaše data se uloží k vašemu účtu.', + 'guestKeepBrowsing': 'Pokračovat v prohlížení', + 'guestGoLogin': 'Přihlásit se', + 'guestViewOnlyTag': 'Pouze prohlížení', + 'autoConnectReenableHint': 'Automatické připojení je vypnuté. Znovu ho zapněte v Nastavení → Automatické připojení zařízení.', + 'plazaEntryDesc': 'Procházejte a přidávejte další agenty', + 'agentTextTranslation': 'Překlad textu', + 'agentTextTranslationDesc': 'Překlad napsaného nebo vloženého textu', + 'agentPictureTranslation': 'Překlad fotek', + 'agentPictureTranslationDesc': 'Vyfoťte nebo vyberte fotku k překladu', + 'callRequiredTitle': 'Vyžadován hovor', + 'callRequiredDesc': 'Tato funkce funguje pouze během hovoru', + 'inCallBusyTitle': 'Během hovoru nedostupné', + 'inCallBusyDesc': 'Mikrofon a zvukovou cestu používá hovor. Zkuste to znovu po zavěšení.', + 'vipSectionTitle': 'VIP', + 'freeSectionTitle': 'Zdarma', + 'smartcarConnected': 'Connected', + 'smartcarConnectFailed': 'Connection failed', + 'smartcarConnecting': 'Connecting…', + 'smartcarSearching': 'Searching for nearby car diffusers…', + 'smartcarNotFound': 'No device found', + 'smartcarScanHint': 'Make sure the device is powered on and close to your phone', + 'vipRequiredTitle': 'Membership required', + 'vipRequiredDesc': 'Translation and meeting notes require a membership. Renew, or pair a device that includes one.', + 'vipRequiredGo': 'Get membership', + 'vipFromDevice': 'from device', + 'computeSummary': 'Compute: device @device · account @user', + 'computePack': 'Compute packs', + 'vipExpiringSoon': 'Membership expires in @days day(s). Renew to keep using translation and meeting notes.', + 'computeLow': 'Running low on compute. You won\'t be able to continue once it runs out.', }; diff --git a/apps/client/lib/core/translations/language/da_dk.dart b/apps/client/lib/core/translations/language/da_dk.dart index 3ebb0210..15d8da9b 100644 --- a/apps/client/lib/core/translations/language/da_dk.dart +++ b/apps/client/lib/core/translations/language/da_dk.dart @@ -1901,4 +1901,208 @@ const Map daDK = { 'unmarkFavorite': 'Fjern fra favoritter', 'unnamedDevice': 'Enhed uden navn', 'userDataParseFailed': 'Brugerdata kunne ikke læses', + 'catEarphone': 'AI-ørepropper', + 'catEarphoneDesc': 'Klassiske Bluetooth- / BLE-ørepropper', + 'catPhoneHolder': 'AI-mobilholder', + 'catPhoneHolderDesc': 'Holderenheder', + 'catCarAroma': 'AI-bilduft', + 'catCarAromaDesc': 'Aromadiffuser til bil', + 'agreeAndContinue': 'Accepter og fortsæt', + 'memberServiceAgreement': 'Medlemsserviceaftale', + 'memberServiceAgreementTitle': 'Medlemsserviceaftale', + 'memberServiceAgreementIntro': 'For at give dig bedre medlemsservice bedes du læse følgende omhyggeligt, før du abonnerer: ', + 'memberServiceAgreementOutro': '. Ved at trykke på "Accepter og fortsæt" bekræfter du, at du har læst og accepterer ovenstående aftale.', + 'memberAgreementRejectedTip': 'Du skal acceptere medlemsserviceaftalen for at fortsætte.', + 'translationCallExitTitle': 'Forlad opkaldsoversættelse', + 'translationCallExitMessage': 'Vælg, hvad der skal ske, før du forlader', + 'translationCallEnd': 'Afslut opkaldsoversættelse', + 'translationContinueInBackground': 'Fortsæt oversættelse i baggrunden', + 'backgroundRecordingIndicatorBody': 'Optagelsen fortsætter i baggrunden. Tryk for at vende tilbage.', + 'backgroundTranslationIndicatorBody': 'Opkaldsoversættelsen fortsætter i baggrunden. Tryk for at vende tilbage.', + 'meetingRecordScreenCapturePermissionDenied': 'Skærmoptagelsestilladelse kræves for at optage systemlyd. Giv tilladelse, og prøv igen.', + 'pleaseGrantBluetoothPermission': 'Giv Bluetooth-tilladelse for at scanne', + 'startScanFailed': 'Kunne ikke starte scanning', + 'pleaseLoginFirst': 'Log venligst ind først', + 'deviceUnauthorized': 'Enheden er ikke autoriseret, prøv igen', + 'boundDevices': 'Tilknyttede enheder', + 'currentConnection': 'Aktuel forbindelse', + 'binding': 'Tilknytter...', + 'permissionDisabled': 'Deaktiveret', + 'confirmDisablePermissionTitle': 'Slå tilladelse fra', + 'confirmDisablePermissionContent': 'Slå {permission}-tilladelse fra? Du skal gå til systemindstillinger for at aktivere den igen.', + 'confirmPermanentlyDisallowTitle': 'Stop med at spørge?', + 'confirmPermanentlyDisallowContent': 'Du afviste {permission}-tilladelse. Stop med at spørge fremover? Hvis du vælger "Tillad ikke", kan du stadig aktivere den senere i systemindstillinger.', + 'notNow': 'Ikke nu', + 'permanentlyDisallow': 'Tillad ikke', + 'otaBesNotSupported': 'Dette headset understøtter endnu ikke firmwareopgradering i appen. Kontakt venligst support.', + 'tabDevice': 'Enheder', + 'tabPlaza': 'Plaza', + 'sloganHome': 'Teknologi til et smartere liv', + 'sloganAi': 'Grænseløs intelligens', + 'sloganDevice': 'Smart hardware, klar til fremtiden', + 'sloganPlaza': 'En smart verden, der forstår dig', + 'aiEditHint': 'Hold et kort nede for at trække og omarrangere moduler', + 'aiEmptyTitle': 'Ingen moduler tilføjet endnu', + 'aiEmptyDesc': 'Vælg de agenter, du har brug for, i Plaza', + 'goToPlaza': 'Gå til Plaza', + 'plazaOnline': 'Tilgængelig', + 'plazaPlanned': 'Kommer snart', + 'plazaSearchHint': 'Søg efter agenter, f.eks. “oversæt”, “optag”', + 'moduleRemove': 'Fjern', + 'moduleAdd': 'Tilføj', + 'moduleRemoved': 'Fjernet', + 'moduleAdded': 'Tilføjet', + 'goSee': 'Se', + 'comingSoonTag': 'Kommer snart', + 'needDeviceTag': 'Kræver enhed', + 'featureInDev': 'Denne funktion er under udvikling', + 'featureNotWired': 'Ikke tilgængelig endnu', + 'todayLabel': 'I dag', + 'deviceConnected': 'Tilsluttet', + 'licenseUnknown': 'Licens ukendt', + 'selectDevice': 'Vælg en enhed', + 'catNoteCard': 'AI-optagerkort', + 'catNoteCardDesc': 'Kortformet optager', + 'catParkerPen': 'AI-pen', + 'catParkerPenDesc': 'Stemmeoptagerpenne', + 'catStylusPen': 'AI-stylus', + 'catStylusPenDesc': 'Håndskrifts-/stylusenheder', + 'catStudyMachine': 'AI-læringstablet', + 'catStudyMachineDesc': 'Læringsenheder', + 'besAddTitle': 'Tilføj AI-øretelefoner', + 'besConnecting': 'Tilslutter øretelefoner…', + 'besNotFound': 'Øretelefoner ikke fundet', + 'besNotFoundIosDesc': 'Tilslut først øretelefonerne under Indstillinger → Bluetooth, og kom så tilbage', + 'besNotFoundAndroidDesc': 'Par først øretelefonerne under Indstillinger → Bluetooth, og tilslut dem så her', + 'besReadFailed': 'Læsning mislykkedes', + 'besConnectFailIos': 'Sørg for, at øretelefonerne vises som Tilsluttet i systemets Bluetooth, og prøv igen', + 'besConnectFailAndroid': 'Sørg for, at øretelefonerne er tændt, i nærheden og ikke tilsluttet en anden enhed. De genopretter forbindelsen automatisk, når de er parret.', + 'holderAddTitle': 'Tilføj AI-telefonholder', + 'holderConnecting': 'Tilslutter enhed…', + 'holderSearching': 'Søger efter enheder…', + 'holderNotFound': 'Ingen enheder fundet', + 'holderPairHint': 'Sørg for, at holderen er tændt og i parringstilstand', + 'holderRetryHint': 'Sørg for, at enheden er tændt og i nærheden, og prøv igen', + 'autoConnectTitle': 'Tilslut enheder automatisk', + 'autoConnectDesc': 'Væk appen for at genoprette forbindelsen og modtage optagelser, når din enhed tændes eller kommer i nærheden', + 'autoConnectConsentTitle': 'Aktivér automatisk tilslutning', + 'autoConnectConsentIntro': 'For at din enhed (AI-øretelefoner / AI-telefonholder) kan tilslutte automatisk og gemme optagelser, når du trykker på dens optageknap, har vi brug for dit samtykke:', + 'autoConnectConsentB1': 'Registrere en Bluetooth-scanning i baggrunden, så appen vækkes (startes automatisk), når din tilknyttede enhed registreres, for at genoprette forbindelsen og modtage optagelser', + 'autoConnectConsentB2': 'Når appen er vækket, bruges den kun til enhedstilslutning, optagelse og upload — intet andet, og ingen andre apps startes', + 'autoConnectConsentB3': 'Du kan stadig bruge appen uden — tilslut blot enheden manuelt hver gang', + 'autoConnectConsentB4': 'Du kan slå det fra når som helst under Indstillinger → Tilslut enheder automatisk', + 'autoConnectAgree': 'Accepter og aktivér', + 'autoConnectLater': 'Ikke nu', + 'autoConnectRequired': 'Automatisk tilslutning er slået fra', + 'autoConnectRequiredDesc': 'Aktivér automatisk tilslutning for at tilknytte denne enhed', + 'recordTapToStart': 'Tryk på knappen nedenfor for at starte optagelsen', + 'recordPaused': 'Sat på pause', + 'recordMark': 'Markér', + 'recordDeleteTitle': 'Slet optagelse', + 'accountAndProfile': 'Konto og profil', + 'nicknameUnset': 'Intet kaldenavn angivet', + 'nicknameInput': 'Indtast et kaldenavn', + 'loginMethod': 'Loginmetode', + 'manageMyLogin': 'Administrer login', + 'logoutAction': 'Log ud', + 'warningLabel': 'Advarsel:', + 'deleteAccountWarn': 'Din konto og alle filer slettes permanent.', + 'icpFiling': 'ICP-registrering', + 'logExport': 'Eksportér logfiler', + 'logExportDesc': 'Se og eksportér app-logfiler', + 'permLoadFailed': 'Kunne ikke indlæse tilladelsesgrupper', + 'permInsufficient': 'Tilladelse nægtet', + 'voiceCloneCreating': 'Opretter din stemme…', + 'voiceCloneCreate': 'Opret min stemme', + 'voiceCloneAgree': 'Accepter og læs erklæringen højt', + 'voiceCloneReadPrompt': 'Læs venligst:', + 'voiceCloneFirstMonthFree': 'Første måned gratis', + 'voiceCloneEnable': 'Aktivér stemmekloning', + 'noMusicPlaying': 'Intet afspilles', + 'qqMusicNoSeek': 'Spoling understøttes ikke for QQ Music', + 'bleScanTitle': 'BLE-scanning', + 'bluetoothOn': 'Bluetooth er tændt', + 'bleScanning': 'Scanner…', + 'bleConnectedDevices': 'Tilsluttede enheder', + 'bleUnknownDevice': 'Ukendt enhed', + 'bleTapToScan': 'Tryk på scan for at søge efter BLE-enheder', + 'weekMon': 'Man', + 'weekTue': 'Tir', + 'weekWed': 'Ons', + 'weekThu': 'Tor', + 'weekFri': 'Fre', + 'weekSat': 'Lør', + 'weekSun': 'Søn', + 'agentMeeting': 'Talereferat', + 'agentMeetingDesc': 'Opsummer optagelser, hukommelsescenter', + 'agentEmai': 'EMAI-assistent', + 'agentEmaiDesc': 'Chat og spørg om alt med stemmen', + 'agentSimultaneous': 'Realtidsoversættelse', + 'agentSimultaneousDesc': 'Oversæt, mens du taler', + 'agentFaceToFace': 'Ansigt-til-ansigt', + 'agentFaceToFaceDesc': 'Tovejs samtaleoversættelse', + 'agentCallRecording': 'Opkaldsoptagelse', + 'agentCallRecordingDesc': 'Opkald gemmes automatisk', + 'agentFieldRecording': 'Feltoptagelse', + 'agentFieldRecordingDesc': 'Enhedens eller telefonens mikrofon', + 'agentCall': 'Opkaldsoversættelse', + 'agentCallDesc': 'Realtidsoversættelse under opkald', + 'agentAudioVideo': 'Medieoversættelse', + 'agentAudioVideoDesc': 'Kameraindramning / lyd- og videooversættelse', + 'agentEnglishChat': 'Engelsk samtale', + 'agentEnglishChatDesc': 'Fordybende taletræning', + 'agentEnglishStudy': 'Engelskstudie', + 'agentEnglishStudyDesc': 'Ordforråds- og grammatiklektioner', + 'agentKidsStory': 'Børnehistorier', + 'agentKidsStoryDesc': 'Godnathistorier og spørgsmål og svar', + 'moduleRemovedDesc': 'fjernet fra AI-siden', + 'moduleAddedDesc': 'tilføjet nederst på AI-siden', + 'calNoRecords': 'Ingen poster', + 'calNoRecordsDay': 'Intet planlagt', + 'calTodos': 'To-do', + 'calExpenses': 'Udgifter', + 'calTodoCount': '@count to-dos', + 'calExpenseCount': '@count udgifter', + 'calEntryCount': '@count poster', + 'calItemsUnit': 'stk.', + 'calCountUnit': 'poster', + 'calTotalAmount': 'I alt', + 'recordPause': 'Pause', + 'recordResume': 'Fortsæt', + 'recordDeleteConfirm': 'Slet denne optagelse?', + 'scopeDay': 'Dag', + 'scopeWeek': 'Uge', + 'scopeMonth': 'Måned', + 'allDay': 'Hele dagen', + 'guestModeTitle': 'Gæstetilstand', + 'guestModeDesc': 'Gæster kan kun kigge. Log ind for at bruge talereferater, oversættelse og optagelse — dine data gemmes på din konto.', + 'guestKeepBrowsing': 'Fortsæt med at kigge', + 'guestGoLogin': 'Log ind', + 'guestViewOnlyTag': 'Kun visning', + 'autoConnectReenableHint': 'Automatisk tilslutning er slået fra. Slå det til igen under Indstillinger → Tilslut enheder automatisk.', + 'plazaEntryDesc': 'Gennemse og tilføj flere agenter', + 'agentTextTranslation': 'Tekstoversættelse', + 'agentTextTranslationDesc': 'Oversæt indtastet eller indsat tekst', + 'agentPictureTranslation': 'Billedoversættelse', + 'agentPictureTranslationDesc': 'Tag eller vælg et billede for at oversætte', + 'callRequiredTitle': 'Opkald påkrævet', + 'callRequiredDesc': 'Denne funktion virker kun under et opkald', + 'inCallBusyTitle': 'Ikke tilgængelig under et opkald', + 'inCallBusyDesc': 'Mikrofonen og lydvejen bruges af opkaldet. Prøv igen, når du har lagt på.', + 'vipSectionTitle': 'VIP', + 'freeSectionTitle': 'Gratis', + 'smartcarConnected': 'Connected', + 'smartcarConnectFailed': 'Connection failed', + 'smartcarConnecting': 'Connecting…', + 'smartcarSearching': 'Searching for nearby car diffusers…', + 'smartcarNotFound': 'No device found', + 'smartcarScanHint': 'Make sure the device is powered on and close to your phone', + 'vipRequiredTitle': 'Membership required', + 'vipRequiredDesc': 'Translation and meeting notes require a membership. Renew, or pair a device that includes one.', + 'vipRequiredGo': 'Get membership', + 'vipFromDevice': 'from device', + 'computeSummary': 'Compute: device @device · account @user', + 'computePack': 'Compute packs', + 'vipExpiringSoon': 'Membership expires in @days day(s). Renew to keep using translation and meeting notes.', + 'computeLow': 'Running low on compute. You won\'t be able to continue once it runs out.', }; diff --git a/apps/client/lib/core/translations/language/de_de.dart b/apps/client/lib/core/translations/language/de_de.dart index a3d854c2..9b4bca76 100644 --- a/apps/client/lib/core/translations/language/de_de.dart +++ b/apps/client/lib/core/translations/language/de_de.dart @@ -1887,6 +1887,8 @@ const Map deDE = { 'catNoteCardDesc': 'Rekorder im Kartenformat', 'catPhoneHolder': 'KI-Handyhalterung', 'catPhoneHolderDesc': 'Halterungsgeräte', + 'catCarAroma': 'KI-Autoduft', + 'catCarAromaDesc': 'Auto-Aromadiffusor', 'catParkerPen': 'KI-Stift', 'catParkerPenDesc': 'Aufnahmestifte', 'catStylusPen': 'KI-Stylus', @@ -2142,4 +2144,32 @@ const Map deDE = { 'unmarkFavorite': 'Aus Favoriten entfernen', 'unnamedDevice': 'Unbenanntes Gerät', 'userDataParseFailed': 'Benutzerdaten konnten nicht gelesen werden', + 'translationCallExitTitle': 'Anruf-Übersetzung verlassen', + 'translationCallExitMessage': 'Wählen Sie, was beim Verlassen geschehen soll', + 'translationCallEnd': 'Anruf-Übersetzung beenden', + 'translationContinueInBackground': 'Im Hintergrund weiter übersetzen', + 'backgroundRecordingIndicatorBody': 'Die Aufnahme läuft im Hintergrund weiter. Zum Zurückkehren tippen.', + 'backgroundTranslationIndicatorBody': 'Die Anruf-Übersetzung läuft im Hintergrund weiter. Zum Zurückkehren tippen.', + 'permissionDisabled': 'Deaktiviert', + 'confirmDisablePermissionTitle': 'Berechtigung deaktivieren', + 'confirmDisablePermissionContent': 'Berechtigung {permission} deaktivieren? Sie müssen sie danach in den Systemeinstellungen erneut aktivieren.', + 'confirmPermanentlyDisallowTitle': 'Nicht mehr fragen?', + 'confirmPermanentlyDisallowContent': 'Sie haben die Berechtigung {permission} abgelehnt. Ab jetzt nicht mehr fragen? Wenn Sie „Nicht erlauben“ wählen, können Sie sie später in den Systemeinstellungen aktivieren.', + 'notNow': 'Nicht jetzt', + 'permanentlyDisallow': 'Nicht erlauben', + 'otaBesNotSupported': 'Dieses Headset unterstützt das Firmware-Upgrade in der App noch nicht. Bitte wenden Sie sich an den Support.', + 'smartcarConnected': 'Verbunden', + 'smartcarConnectFailed': 'Verbindung fehlgeschlagen', + 'smartcarConnecting': 'Verbinde…', + 'smartcarSearching': 'Suche nach Auto-Diffusoren in der Nähe…', + 'smartcarNotFound': 'Kein Gerät gefunden', + 'smartcarScanHint': 'Stellen Sie sicher, dass das Gerät eingeschaltet und in der Nähe ist', + 'vipRequiredTitle': 'Membership required', + 'vipRequiredDesc': 'Translation and meeting notes require a membership. Renew, or pair a device that includes one.', + 'vipRequiredGo': 'Get membership', + 'vipFromDevice': 'from device', + 'computeSummary': 'Compute: device @device · account @user', + 'computePack': 'Compute packs', + 'vipExpiringSoon': 'Membership expires in @days day(s). Renew to keep using translation and meeting notes.', + 'computeLow': 'Running low on compute. You won\'t be able to continue once it runs out.', }; diff --git a/apps/client/lib/core/translations/language/el_gr.dart b/apps/client/lib/core/translations/language/el_gr.dart index 638927b5..216bc0f7 100644 --- a/apps/client/lib/core/translations/language/el_gr.dart +++ b/apps/client/lib/core/translations/language/el_gr.dart @@ -1935,4 +1935,208 @@ const Map elGR = { 'unmarkFavorite': 'Αφαίρεση από τα αγαπημένα', 'unnamedDevice': 'Συσκευή χωρίς όνομα', 'userDataParseFailed': 'Αποτυχία ανάγνωσης δεδομένων χρήστη', + 'catEarphone': 'Ακουστικά AI', + 'catEarphoneDesc': 'Ακουστικά κλασικού Bluetooth / BLE', + 'catPhoneHolder': 'Βάση κινητού AI', + 'catPhoneHolderDesc': 'Συσκευές βάσης', + 'catCarAroma': 'Αρωματικό αυτοκινήτου AI', + 'catCarAromaDesc': 'Διαχύτης αρώματος αυτοκινήτου', + 'agreeAndContinue': 'Συμφωνώ και συνέχεια', + 'memberServiceAgreement': 'Συμφωνία υπηρεσιών μέλους', + 'memberServiceAgreementTitle': 'Συμφωνία υπηρεσιών μέλους', + 'memberServiceAgreementIntro': 'Για να σας παρέχουμε καλύτερες υπηρεσίες μέλους, διαβάστε προσεκτικά πριν από τη συνδρομή: ', + 'memberServiceAgreementOutro': '. Πατώντας «Συμφωνώ και συνέχεια», επιβεβαιώνετε ότι έχετε διαβάσει πλήρως και αποδέχεστε την παραπάνω συμφωνία.', + 'memberAgreementRejectedTip': 'Πρέπει να αποδεχτείτε τη Συμφωνία υπηρεσιών μέλους για να συνεχίσετε.', + 'translationCallExitTitle': 'Έξοδος από τη μετάφραση κλήσης', + 'translationCallExitMessage': 'Επιλέξτε τι θα κάνετε πριν φύγετε', + 'translationCallEnd': 'Τερματισμός μετάφρασης κλήσης', + 'translationContinueInBackground': 'Συνέχιση μετάφρασης στο παρασκήνιο', + 'backgroundRecordingIndicatorBody': 'Η εγγραφή συνεχίζεται στο παρασκήνιο. Πατήστε για επιστροφή.', + 'backgroundTranslationIndicatorBody': 'Η μετάφραση κλήσης συνεχίζεται στο παρασκήνιο. Πατήστε για επιστροφή.', + 'meetingRecordScreenCapturePermissionDenied': 'Απαιτείται δικαίωμα εγγραφής οθόνης για την καταγραφή ήχου συστήματος. Παραχωρήστε το και δοκιμάστε ξανά.', + 'pleaseGrantBluetoothPermission': 'Παραχωρήστε δικαίωμα Bluetooth για σάρωση', + 'startScanFailed': 'Αποτυχία έναρξης σάρωσης', + 'pleaseLoginFirst': 'Συνδεθείτε πρώτα', + 'deviceUnauthorized': 'Η συσκευή δεν είναι εξουσιοδοτημένη, δοκιμάστε ξανά', + 'boundDevices': 'Συνδεδεμένες συσκευές', + 'currentConnection': 'Τρέχουσα σύνδεση', + 'binding': 'Σύνδεση...', + 'permissionDisabled': 'Απενεργοποιημένο', + 'confirmDisablePermissionTitle': 'Απενεργοποίηση δικαιώματος', + 'confirmDisablePermissionContent': 'Απενεργοποίηση δικαιώματος {permission}; Θα χρειαστεί να μεταβείτε στις ρυθμίσεις συστήματος για να το ενεργοποιήσετε ξανά.', + 'confirmPermanentlyDisallowTitle': 'Να μην ξαναρωτηθείτε;', + 'confirmPermanentlyDisallowContent': 'Απορρίψατε το δικαίωμα {permission}. Να μην ξαναρωτηθείτε από εδώ και πέρα; Αν επιλέξετε «Να μην επιτραπεί», μπορείτε να το ενεργοποιήσετε αργότερα από τις ρυθμίσεις συστήματος.', + 'notNow': 'Όχι τώρα', + 'permanentlyDisallow': 'Να μην επιτραπεί', + 'otaBesNotSupported': 'Αυτά τα ακουστικά δεν υποστηρίζουν ακόμα αναβάθμιση υλικολογισμικού μέσα από την εφαρμογή. Επικοινωνήστε με την υποστήριξη.', + 'tabDevice': 'Συσκευές', + 'tabPlaza': 'Plaza', + 'sloganHome': 'Τεχνολογία για μια πιο έξυπνη ζωή', + 'sloganAi': 'Απεριόριστη νοημοσύνη', + 'sloganDevice': 'Έξυπνο hardware, έτοιμο για το μέλλον', + 'sloganPlaza': 'Ένας έξυπνος κόσμος που σας καταλαβαίνει', + 'aiEditHint': 'Πατήστε παρατεταμένα μια κάρτα για να σύρετε και να αναδιατάξετε τις ενότητες', + 'aiEmptyTitle': 'Δεν έχουν προστεθεί ενότητες ακόμα', + 'aiEmptyDesc': 'Επιλέξτε τους πράκτορες που χρειάζεστε στο Plaza', + 'goToPlaza': 'Μετάβαση στο Plaza', + 'plazaOnline': 'Διαθέσιμο', + 'plazaPlanned': 'Σύντομα', + 'plazaSearchHint': 'Αναζήτηση πρακτόρων, π.χ. «μετάφραση», «εγγραφή»', + 'moduleRemove': 'Αφαίρεση', + 'moduleAdd': 'Προσθήκη', + 'moduleRemoved': 'Αφαιρέθηκε', + 'moduleAdded': 'Προστέθηκε', + 'goSee': 'Προβολή', + 'comingSoonTag': 'Σύντομα', + 'needDeviceTag': 'Απαιτείται συσκευή', + 'featureInDev': 'Αυτή η λειτουργία βρίσκεται υπό ανάπτυξη', + 'featureNotWired': 'Δεν είναι διαθέσιμο ακόμα', + 'todayLabel': 'Σήμερα', + 'deviceConnected': 'Συνδεδεμένο', + 'licenseUnknown': 'Άγνωστη άδεια', + 'selectDevice': 'Επιλέξτε συσκευή', + 'catNoteCard': 'Κάρτα εγγραφής AI', + 'catNoteCardDesc': 'Συσκευή εγγραφής σε σχήμα κάρτας', + 'catParkerPen': 'Στυλό AI', + 'catParkerPenDesc': 'Στυλό εγγραφής φωνής', + 'catStylusPen': 'Γραφίδα AI', + 'catStylusPenDesc': 'Συσκευές γραφής / γραφίδας', + 'catStudyMachine': 'Tablet μάθησης AI', + 'catStudyMachineDesc': 'Συσκευές μάθησης', + 'besAddTitle': 'Προσθήκη ακουστικών AI', + 'besConnecting': 'Σύνδεση με τα ακουστικά…', + 'besNotFound': 'Δεν βρέθηκαν ακουστικά', + 'besNotFoundIosDesc': 'Συνδέστε πρώτα τα ακουστικά από Ρυθμίσεις → Bluetooth και μετά επιστρέψτε εδώ', + 'besNotFoundAndroidDesc': 'Κάντε πρώτα σύζευξη των ακουστικών από Ρυθμίσεις → Bluetooth και μετά συνδεθείτε εδώ', + 'besReadFailed': 'Αποτυχία ανάγνωσης', + 'besConnectFailIos': 'Βεβαιωθείτε ότι τα ακουστικά εμφανίζονται ως Συνδεδεμένα στο Bluetooth του συστήματος και δοκιμάστε ξανά', + 'besConnectFailAndroid': 'Βεβαιωθείτε ότι τα ακουστικά είναι ενεργοποιημένα, κοντά σας και δεν είναι συνδεδεμένα σε άλλη συσκευή. Μόλις συνδεθούν, θα επανασυνδέονται αυτόματα.', + 'holderAddTitle': 'Προσθήκη βάσης κινητού AI', + 'holderConnecting': 'Σύνδεση με τη συσκευή…', + 'holderSearching': 'Αναζήτηση συσκευών…', + 'holderNotFound': 'Δεν βρέθηκαν συσκευές', + 'holderPairHint': 'Βεβαιωθείτε ότι η βάση είναι ενεργοποιημένη και σε λειτουργία σύζευξης', + 'holderRetryHint': 'Βεβαιωθείτε ότι η συσκευή είναι ενεργοποιημένη και κοντά σας και δοκιμάστε ξανά', + 'autoConnectTitle': 'Αυτόματη σύνδεση συσκευών', + 'autoConnectDesc': 'Αφύπνιση της εφαρμογής για επανασύνδεση και λήψη εγγραφών όταν η συσκευή σας ενεργοποιείται ή βρίσκεται κοντά', + 'autoConnectConsentTitle': 'Ενεργοποίηση αυτόματης σύνδεσης', + 'autoConnectConsentIntro': 'Για να συνδέεται αυτόματα η συσκευή σας (ακουστικά AI / βάση κινητού AI) και να αποθηκεύονται οι εγγραφές όταν πατάτε το κουμπί εγγραφής της, χρειαζόμαστε τη συγκατάθεσή σας:', + 'autoConnectConsentB1': 'Καταχώριση σάρωσης Bluetooth στο παρασκήνιο, ώστε η εφαρμογή να αφυπνίζεται (να ξεκινά αυτόματα) όταν εντοπίζεται η συνδεδεμένη συσκευή σας, για επανασύνδεση και λήψη εγγραφών', + 'autoConnectConsentB2': 'Μετά την αφύπνιση, χρησιμοποιείται μόνο για σύνδεση συσκευής, εγγραφή και μεταφόρτωση — τίποτα άλλο, και δεν εκκινούνται άλλες εφαρμογές', + 'autoConnectConsentB3': 'Μπορείτε να χρησιμοποιείτε την εφαρμογή και χωρίς αυτό — απλώς συνδέετε τη συσκευή χειροκίνητα κάθε φορά', + 'autoConnectConsentB4': 'Μπορείτε να το απενεργοποιήσετε ανά πάσα στιγμή από Ρυθμίσεις → Αυτόματη σύνδεση συσκευών', + 'autoConnectAgree': 'Συμφωνώ και ενεργοποίηση', + 'autoConnectLater': 'Όχι τώρα', + 'autoConnectRequired': 'Η αυτόματη σύνδεση είναι απενεργοποιημένη', + 'autoConnectRequiredDesc': 'Ενεργοποιήστε την αυτόματη σύνδεση για να συνδέσετε αυτή τη συσκευή', + 'recordTapToStart': 'Πατήστε το παρακάτω κουμπί για να ξεκινήσει η εγγραφή', + 'recordPaused': 'Σε παύση', + 'recordMark': 'Σήμανση', + 'recordDeleteTitle': 'Διαγραφή εγγραφής', + 'accountAndProfile': 'Λογαριασμός & προφίλ', + 'nicknameUnset': 'Δεν έχει οριστεί ψευδώνυμο', + 'nicknameInput': 'Εισαγάγετε ψευδώνυμο', + 'loginMethod': 'Τρόπος σύνδεσης', + 'manageMyLogin': 'Διαχείριση σύνδεσης', + 'logoutAction': 'Αποσύνδεση', + 'warningLabel': 'Προειδοποίηση:', + 'deleteAccountWarn': 'Ο λογαριασμός σας και όλα τα αρχεία θα διαγραφούν οριστικά.', + 'icpFiling': 'Καταχώριση ICP', + 'logExport': 'Εξαγωγή αρχείων καταγραφής', + 'logExportDesc': 'Προβολή και εξαγωγή αρχείων καταγραφής της εφαρμογής', + 'permLoadFailed': 'Αποτυχία φόρτωσης ομάδων δικαιωμάτων', + 'permInsufficient': 'Δεν παραχωρήθηκε δικαίωμα', + 'voiceCloneCreating': 'Δημιουργία της φωνής σας…', + 'voiceCloneCreate': 'Δημιουργία της φωνής μου', + 'voiceCloneAgree': 'Συμφωνώ και διαβάζω τη δήλωση δυνατά', + 'voiceCloneReadPrompt': 'Διαβάστε:', + 'voiceCloneFirstMonthFree': 'Ο πρώτος μήνας δωρεάν', + 'voiceCloneEnable': 'Ενεργοποίηση κλωνοποίησης φωνής', + 'noMusicPlaying': 'Δεν αναπαράγεται τίποτα', + 'qqMusicNoSeek': 'Η αναζήτηση θέσης δεν υποστηρίζεται για το QQ Music', + 'bleScanTitle': 'Σάρωση BLE', + 'bluetoothOn': 'Bluetooth ενεργοποιημένο', + 'bleScanning': 'Σάρωση…', + 'bleConnectedDevices': 'Συνδεδεμένες συσκευές', + 'bleUnknownDevice': 'Άγνωστη συσκευή', + 'bleTapToScan': 'Πατήστε σάρωση για αναζήτηση συσκευών BLE', + 'weekMon': 'Δευ', + 'weekTue': 'Τρί', + 'weekWed': 'Τετ', + 'weekThu': 'Πέμ', + 'weekFri': 'Παρ', + 'weekSat': 'Σάβ', + 'weekSun': 'Κυρ', + 'agentMeeting': 'Φωνητικά πρακτικά', + 'agentMeetingDesc': 'Σύνοψη εγγραφών, κέντρο μνήμης', + 'agentEmai': 'Βοηθός EMAI', + 'agentEmaiDesc': 'Συνομιλήστε και ρωτήστε οτιδήποτε με τη φωνή σας', + 'agentSimultaneous': 'Ζωντανή μετάφραση', + 'agentSimultaneousDesc': 'Μετάφραση καθώς μιλάτε', + 'agentFaceToFace': 'Πρόσωπο με πρόσωπο', + 'agentFaceToFaceDesc': 'Αμφίδρομη μετάφραση συνομιλίας', + 'agentCallRecording': 'Εγγραφή κλήσεων', + 'agentCallRecordingDesc': 'Οι κλήσεις αποθηκεύονται αυτόματα', + 'agentFieldRecording': 'Εγγραφή πεδίου', + 'agentFieldRecordingDesc': 'Μικρόφωνο συσκευής ή τηλεφώνου', + 'agentCall': 'Μετάφραση κλήσης', + 'agentCallDesc': 'Μετάφραση σε πραγματικό χρόνο κατά τις κλήσεις', + 'agentAudioVideo': 'Μετάφραση πολυμέσων', + 'agentAudioVideoDesc': 'Καδράρισμα κάμερας / μετάφραση ήχου & βίντεο', + 'agentEnglishChat': 'Συνομιλία στα Αγγλικά', + 'agentEnglishChatDesc': 'Εξάσκηση ομιλίας με πλήρη εμβύθιση', + 'agentEnglishStudy': 'Μελέτη Αγγλικών', + 'agentEnglishStudyDesc': 'Μαθήματα λεξιλογίου και γραμματικής', + 'agentKidsStory': 'Παιδικές ιστορίες', + 'agentKidsStoryDesc': 'Ιστορίες για ύπνο και ερωτήσεις-απαντήσεις', + 'moduleRemovedDesc': 'αφαιρέθηκε από τη σελίδα AI', + 'moduleAddedDesc': 'προστέθηκε στο τέλος της σελίδας AI', + 'calNoRecords': 'Δεν υπάρχουν καταχωρίσεις', + 'calNoRecordsDay': 'Τίποτα προγραμματισμένο', + 'calTodos': 'Εργασίες', + 'calExpenses': 'Έξοδα', + 'calTodoCount': '@count εργασίες', + 'calExpenseCount': '@count έξοδα', + 'calEntryCount': '@count καταχωρίσεις', + 'calItemsUnit': 'στοιχεία', + 'calCountUnit': 'καταχωρίσεις', + 'calTotalAmount': 'Σύνολο', + 'recordPause': 'Παύση', + 'recordResume': 'Συνέχιση', + 'recordDeleteConfirm': 'Διαγραφή αυτής της εγγραφής;', + 'scopeDay': 'Ημέρα', + 'scopeWeek': 'Εβδομάδα', + 'scopeMonth': 'Μήνας', + 'allDay': 'Ολοήμερο', + 'guestModeTitle': 'Λειτουργία επισκέπτη', + 'guestModeDesc': 'Οι επισκέπτες μπορούν μόνο να περιηγηθούν. Συνδεθείτε για να χρησιμοποιήσετε φωνητικές σημειώσεις, μετάφραση και εγγραφή — τα δεδομένα σας θα αποθηκεύονται στον λογαριασμό σας.', + 'guestKeepBrowsing': 'Συνέχιση περιήγησης', + 'guestGoLogin': 'Σύνδεση', + 'guestViewOnlyTag': 'Μόνο προβολή', + 'autoConnectReenableHint': 'Η αυτόματη σύνδεση είναι απενεργοποιημένη. Ενεργοποιήστε την ξανά από Ρυθμίσεις → Αυτόματη σύνδεση συσκευών.', + 'plazaEntryDesc': 'Περιηγηθείτε και προσθέστε περισσότερους πράκτορες', + 'agentTextTranslation': 'Μετάφραση κειμένου', + 'agentTextTranslationDesc': 'Μετάφραση πληκτρολογημένου ή επικολλημένου κειμένου', + 'agentPictureTranslation': 'Μετάφραση φωτογραφίας', + 'agentPictureTranslationDesc': 'Τραβήξτε ή επιλέξτε μια φωτογραφία για μετάφραση', + 'callRequiredTitle': 'Απαιτείται κλήση', + 'callRequiredDesc': 'Αυτή η λειτουργία λειτουργεί μόνο κατά τη διάρκεια κλήσης', + 'inCallBusyTitle': 'Μη διαθέσιμο κατά τη διάρκεια κλήσης', + 'inCallBusyDesc': 'Το μικρόφωνο και η διαδρομή ήχου χρησιμοποιούνται από την κλήση. Δοκιμάστε ξανά αφού τερματίσετε την κλήση.', + 'vipSectionTitle': 'VIP', + 'freeSectionTitle': 'Δωρεάν', + 'smartcarConnected': 'Connected', + 'smartcarConnectFailed': 'Connection failed', + 'smartcarConnecting': 'Connecting…', + 'smartcarSearching': 'Searching for nearby car diffusers…', + 'smartcarNotFound': 'No device found', + 'smartcarScanHint': 'Make sure the device is powered on and close to your phone', + 'vipRequiredTitle': 'Membership required', + 'vipRequiredDesc': 'Translation and meeting notes require a membership. Renew, or pair a device that includes one.', + 'vipRequiredGo': 'Get membership', + 'vipFromDevice': 'from device', + 'computeSummary': 'Compute: device @device · account @user', + 'computePack': 'Compute packs', + 'vipExpiringSoon': 'Membership expires in @days day(s). Renew to keep using translation and meeting notes.', + 'computeLow': 'Running low on compute. You won\'t be able to continue once it runs out.', }; diff --git a/apps/client/lib/core/translations/language/en_us.dart b/apps/client/lib/core/translations/language/en_us.dart index 22711eed..47d5c750 100644 --- a/apps/client/lib/core/translations/language/en_us.dart +++ b/apps/client/lib/core/translations/language/en_us.dart @@ -1857,6 +1857,8 @@ const Map enUS = { 'catNoteCardDesc': 'Card-shaped recorder', 'catPhoneHolder': 'AI Phone Stand', 'catPhoneHolderDesc': 'Stand devices', + 'catCarAroma': 'AI Car Diffuser', + 'catCarAromaDesc': 'In-car aroma diffuser', 'catParkerPen': 'AI Pen', 'catParkerPenDesc': 'Voice recorder pens', 'catStylusPen': 'AI Stylus', @@ -2112,4 +2114,18 @@ const Map enUS = { 'unmarkFavorite': 'Remove from Favorites', 'unnamedDevice': 'Unnamed Device', 'userDataParseFailed': 'Failed to parse user data', + 'smartcarConnected': 'Connected', + 'smartcarConnectFailed': 'Connection failed', + 'smartcarConnecting': 'Connecting…', + 'smartcarSearching': 'Searching for nearby car diffusers…', + 'smartcarNotFound': 'No device found', + 'smartcarScanHint': 'Make sure the device is powered on and close to your phone', + 'vipRequiredTitle': 'Membership required', + 'vipRequiredDesc': 'Translation and meeting notes require a membership. Renew, or pair a device that includes one.', + 'vipRequiredGo': 'Get membership', + 'vipFromDevice': 'from device', + 'computeSummary': 'Compute: device @device · account @user', + 'computePack': 'Compute packs', + 'vipExpiringSoon': 'Membership expires in @days day(s). Renew to keep using translation and meeting notes.', + 'computeLow': 'Running low on compute. You won\'t be able to continue once it runs out.', }; diff --git a/apps/client/lib/core/translations/language/es_es.dart b/apps/client/lib/core/translations/language/es_es.dart index 2fc83bf2..4962439a 100644 --- a/apps/client/lib/core/translations/language/es_es.dart +++ b/apps/client/lib/core/translations/language/es_es.dart @@ -1859,6 +1859,8 @@ const Map esES = { 'catNoteCardDesc': 'Grabadora tipo tarjeta', 'catPhoneHolder': 'Soporte de móvil IA', 'catPhoneHolderDesc': 'Dispositivos tipo soporte', + 'catCarAroma': 'Ambientador de coche IA', + 'catCarAromaDesc': 'Difusor de aroma para coche', 'catParkerPen': 'Bolígrafo IA', 'catParkerPenDesc': 'Bolígrafos grabadores', 'catStylusPen': 'Lápiz óptico IA', @@ -2136,4 +2138,32 @@ const Map esES = { 'unmarkFavorite': 'Quitar de favoritos', 'unnamedDevice': 'Dispositivo sin nombre', 'userDataParseFailed': 'Error al procesar los datos del usuario', + 'translationCallExitTitle': 'Salir de la traducción de llamadas', + 'translationCallExitMessage': 'Elige qué hacer antes de salir', + 'translationCallEnd': 'Finalizar traducción de llamada', + 'translationContinueInBackground': 'Seguir traduciendo en segundo plano', + 'backgroundRecordingIndicatorBody': 'La grabación continúa en segundo plano. Toca para volver.', + 'backgroundTranslationIndicatorBody': 'La traducción de llamada continúa en segundo plano. Toca para volver.', + 'permissionDisabled': 'Desactivado', + 'confirmDisablePermissionTitle': 'Desactivar permiso', + 'confirmDisablePermissionContent': '¿Desactivar el permiso de {permission}? Tendrás que ir a los ajustes del sistema para volver a activarlo.', + 'confirmPermanentlyDisallowTitle': '¿Dejar de preguntar?', + 'confirmPermanentlyDisallowContent': 'Has denegado el permiso de {permission}. ¿No volver a preguntar? Si eliges «No permitir», podrás activarlo más tarde en los ajustes del sistema.', + 'notNow': 'Ahora no', + 'permanentlyDisallow': 'No permitir', + 'otaBesNotSupported': 'Estos auriculares aún no admiten la actualización de firmware desde la app. Ponte en contacto con el soporte.', + 'smartcarConnected': 'Conectado', + 'smartcarConnectFailed': 'Error de conexión', + 'smartcarConnecting': 'Conectando…', + 'smartcarSearching': 'Buscando difusores de coche cercanos…', + 'smartcarNotFound': 'No se encontró ningún dispositivo', + 'smartcarScanHint': 'Asegúrate de que el dispositivo esté encendido y cerca del teléfono', + 'vipRequiredTitle': 'Membership required', + 'vipRequiredDesc': 'Translation and meeting notes require a membership. Renew, or pair a device that includes one.', + 'vipRequiredGo': 'Get membership', + 'vipFromDevice': 'from device', + 'computeSummary': 'Compute: device @device · account @user', + 'computePack': 'Compute packs', + 'vipExpiringSoon': 'Membership expires in @days day(s). Renew to keep using translation and meeting notes.', + 'computeLow': 'Running low on compute. You won\'t be able to continue once it runs out.', }; diff --git a/apps/client/lib/core/translations/language/et_ee.dart b/apps/client/lib/core/translations/language/et_ee.dart index 1638b870..cf6bf38e 100644 --- a/apps/client/lib/core/translations/language/et_ee.dart +++ b/apps/client/lib/core/translations/language/et_ee.dart @@ -1895,4 +1895,208 @@ const Map etEE = { 'unmarkFavorite': 'Eemalda lemmikutest', 'unnamedDevice': 'Nimeta seade', 'userDataParseFailed': 'Kasutajaandmete lugemine ebaõnnestus', + 'catEarphone': 'AI kõrvaklapid', + 'catEarphoneDesc': 'Klassikalised Bluetooth / BLE kõrvaklapid', + 'catPhoneHolder': 'AI telefonihoidik', + 'catPhoneHolderDesc': 'Hoidikuseadmed', + 'catCarAroma': 'AI auto lõhnastaja', + 'catCarAromaDesc': 'Auto aroomihajuti', + 'agreeAndContinue': 'Nõustu ja jätka', + 'memberServiceAgreement': 'Liikmeteenuse leping', + 'memberServiceAgreementTitle': 'Liikmeteenuse leping', + 'memberServiceAgreementIntro': 'Parema liikmeteenuse pakkumiseks loe enne tellimist hoolikalt läbi: ', + 'memberServiceAgreementOutro': '. Klõpsates „Nõustu ja jätka“ kinnitad, et oled ülaltoodud lepingu täielikult läbi lugenud ja sellega nõustud.', + 'memberAgreementRejectedTip': 'Jätkamiseks pead nõustuma liikmeteenuse lepinguga.', + 'translationCallExitTitle': 'Välju kõnetõlkest', + 'translationCallExitMessage': 'Vali, mida enne lahkumist teha', + 'translationCallEnd': 'Lõpeta kõnetõlge', + 'translationContinueInBackground': 'Jätka tõlkimist taustal', + 'backgroundRecordingIndicatorBody': 'Salvestamine jätkub taustal. Naasmiseks puuduta.', + 'backgroundTranslationIndicatorBody': 'Kõnetõlge jätkub taustal. Naasmiseks puuduta.', + 'meetingRecordScreenCapturePermissionDenied': 'Süsteemiheli salvestamiseks on vajalik ekraanisalvestuse luba. Anna luba ja proovi uuesti.', + 'pleaseGrantBluetoothPermission': 'Skannimiseks anna Bluetoothi luba', + 'startScanFailed': 'Skannimise käivitamine ebaõnnestus', + 'pleaseLoginFirst': 'Palun logi esmalt sisse', + 'deviceUnauthorized': 'Seade pole autoriseeritud, proovi uuesti', + 'boundDevices': 'Seotud seadmed', + 'currentConnection': 'Praegune ühendus', + 'binding': 'Sidumine...', + 'permissionDisabled': 'Keelatud', + 'confirmDisablePermissionTitle': 'Lülita luba välja', + 'confirmDisablePermissionContent': 'Kas lülitada {permission} luba välja? Uuesti lubamiseks pead minema süsteemi seadetesse.', + 'confirmPermanentlyDisallowTitle': 'Ei küsi enam?', + 'confirmPermanentlyDisallowContent': 'Keeldusid {permission} loast. Kas edaspidi enam mitte küsida? Kui valid „Ära luba“, saad selle hiljem süsteemi seadetes siiski sisse lülitada.', + 'notNow': 'Mitte praegu', + 'permanentlyDisallow': 'Ära luba', + 'otaBesNotSupported': 'See peakomplekt ei toeta veel rakendusesisest püsivara uuendamist. Palun võta ühendust klienditoega.', + 'tabDevice': 'Seadmed', + 'tabPlaza': 'Plaza', + 'sloganHome': 'Tehnoloogia nutikama elu heaks', + 'sloganAi': 'Piiritu intelligentsus', + 'sloganDevice': 'Nutikas riistvara, tulevikuks valmis', + 'sloganPlaza': 'Nutikas maailm, mis sind mõistab', + 'aiEditHint': 'Moodulite ümberpaigutamiseks hoia kaarti all ja lohista', + 'aiEmptyTitle': 'Mooduleid pole veel lisatud', + 'aiEmptyDesc': 'Vali Plazast vajalikud agendid', + 'goToPlaza': 'Mine Plazasse', + 'plazaOnline': 'Saadaval', + 'plazaPlanned': 'Peagi tulekul', + 'plazaSearchHint': 'Otsi agente, nt „tõlgi“, „salvesta“', + 'moduleRemove': 'Eemalda', + 'moduleAdd': 'Lisa', + 'moduleRemoved': 'Eemaldatud', + 'moduleAdded': 'Lisatud', + 'goSee': 'Vaata', + 'comingSoonTag': 'Peagi tulekul', + 'needDeviceTag': 'Vajab seadet', + 'featureInDev': 'See funktsioon on arendamisel', + 'featureNotWired': 'Pole veel saadaval', + 'todayLabel': 'Täna', + 'deviceConnected': 'Ühendatud', + 'licenseUnknown': 'Litsents teadmata', + 'selectDevice': 'Vali seade', + 'catNoteCard': 'AI salvestuskaart', + 'catNoteCardDesc': 'Kaardikujuline salvesti', + 'catParkerPen': 'AI pastakas', + 'catParkerPenDesc': 'Salvestavad pastakad', + 'catStylusPen': 'AI pliiats', + 'catStylusPenDesc': 'Käsitsi kirjutamise / pliiatsiga seadmed', + 'catStudyMachine': 'AI õppetahvel', + 'catStudyMachineDesc': 'Õppeseadmed', + 'besAddTitle': 'Lisa AI kõrvaklapid', + 'besConnecting': 'Ühendan kõrvaklappidega…', + 'besNotFound': 'Kõrvaklappe ei leitud', + 'besNotFoundIosDesc': 'Ühenda kõrvaklapid esmalt menüüs Seaded → Bluetooth ja tule siis tagasi', + 'besNotFoundAndroidDesc': 'Seo kõrvaklapid esmalt menüüs Seaded → Bluetooth ja ühenda siis siin', + 'besReadFailed': 'Lugemine ebaõnnestus', + 'besConnectFailIos': 'Veendu, et kõrvaklapid on süsteemi Bluetoothis olekus „Ühendatud“, seejärel proovi uuesti', + 'besConnectFailAndroid': 'Veendu, et kõrvaklapid on sisse lülitatud, lähedal ega ole ühendatud teise seadmega. Pärast sidumist ühenduvad need automaatselt uuesti.', + 'holderAddTitle': 'Lisa AI telefonialus', + 'holderConnecting': 'Ühendan seadmega…', + 'holderSearching': 'Otsin seadmeid…', + 'holderNotFound': 'Seadmeid ei leitud', + 'holderPairHint': 'Veendu, et alus on sisse lülitatud ja sidumisrežiimis', + 'holderRetryHint': 'Veendu, et seade on sisse lülitatud ja lähedal, seejärel proovi uuesti', + 'autoConnectTitle': 'Seadmete automaatne ühendamine', + 'autoConnectDesc': 'Äratab rakenduse, et taasühenduda ja salvestusi vastu võtta, kui seade sisse lülitub või lähedale tuleb', + 'autoConnectConsentTitle': 'Luba automaatne ühendamine', + 'autoConnectConsentIntro': 'Et sinu seade (AI kõrvaklapid / AI telefonialus) saaks automaatselt ühenduda ja salvestusnupu vajutamisel salvestusi salvestada, vajame sinu nõusolekut:', + 'autoConnectConsentB1': 'Registreerime Bluetoothi taustaskannimise, et rakendus äratataks (käivitataks automaatselt) seotud seadme tuvastamisel, et taasühenduda ja salvestusi vastu võtta', + 'autoConnectConsentB2': 'Pärast äratamist kasutatakse seda ainult seadme ühendamiseks, salvestamiseks ja üleslaadimiseks — mitte millekski muuks, ja teisi rakendusi ei käivitata', + 'autoConnectConsentB3': 'Saad rakendust kasutada ka ilma selleta — ühenda seade lihtsalt iga kord käsitsi', + 'autoConnectConsentB4': 'Saad selle igal ajal välja lülitada menüüs Seaded → Seadmete automaatne ühendamine', + 'autoConnectAgree': 'Nõustu ja luba', + 'autoConnectLater': 'Mitte praegu', + 'autoConnectRequired': 'Automaatne ühendamine on välja lülitatud', + 'autoConnectRequiredDesc': 'Selle seadme sidumiseks lülita sisse automaatne ühendamine', + 'recordTapToStart': 'Salvestamise alustamiseks puuduta allolevat nuppu', + 'recordPaused': 'Peatatud', + 'recordMark': 'Märgi', + 'recordDeleteTitle': 'Kustuta salvestus', + 'accountAndProfile': 'Konto ja profiil', + 'nicknameUnset': 'Hüüdnime pole määratud', + 'nicknameInput': 'Sisesta hüüdnimi', + 'loginMethod': 'Sisselogimisviis', + 'manageMyLogin': 'Halda sisselogimist', + 'logoutAction': 'Logi välja', + 'warningLabel': 'Hoiatus:', + 'deleteAccountWarn': 'Sinu konto ja kõik failid kustutatakse jäädavalt.', + 'icpFiling': 'ICP registreering', + 'logExport': 'Ekspordi logid', + 'logExportDesc': 'Vaata ja ekspordi rakenduse logisid', + 'permLoadFailed': 'Loagruppide laadimine ebaõnnestus', + 'permInsufficient': 'Luba puudub', + 'voiceCloneCreating': 'Loon sinu häält…', + 'voiceCloneCreate': 'Loo minu hääl', + 'voiceCloneAgree': 'Nõustu ja loe avaldus valjusti ette', + 'voiceCloneReadPrompt': 'Palun loe:', + 'voiceCloneFirstMonthFree': 'Esimene kuu tasuta', + 'voiceCloneEnable': 'Luba hääle kloneerimine', + 'noMusicPlaying': 'Midagi ei mängi', + 'qqMusicNoSeek': 'QQ Music ei toeta kerimist', + 'bleScanTitle': 'BLE skannimine', + 'bluetoothOn': 'Bluetooth sees', + 'bleScanning': 'Skannin…', + 'bleConnectedDevices': 'Ühendatud seadmed', + 'bleUnknownDevice': 'Tundmatu seade', + 'bleTapToScan': 'BLE-seadmete otsimiseks puuduta „Skanni“', + 'weekMon': 'E', + 'weekTue': 'T', + 'weekWed': 'K', + 'weekThu': 'N', + 'weekFri': 'R', + 'weekSat': 'L', + 'weekSun': 'P', + 'agentMeeting': 'Häälmemod', + 'agentMeetingDesc': 'Salvestuste kokkuvõtted, mälukeskus', + 'agentEmai': 'EMAI assistent', + 'agentEmaiDesc': 'Vestle ja küsi häälega mida iganes', + 'agentSimultaneous': 'Sünkroontõlge', + 'agentSimultaneousDesc': 'Tõlgib rääkimise ajal', + 'agentFaceToFace': 'Näost näkku', + 'agentFaceToFaceDesc': 'Kahesuunaline vestluse tõlge', + 'agentCallRecording': 'Kõne salvestus', + 'agentCallRecordingDesc': 'Kõned salvestatakse automaatselt', + 'agentFieldRecording': 'Kohapealne salvestus', + 'agentFieldRecordingDesc': 'Seadme või telefoni mikrofon', + 'agentCall': 'Kõnetõlge', + 'agentCallDesc': 'Reaalajas tõlge kõne ajal', + 'agentAudioVideo': 'Meediatõlge', + 'agentAudioVideoDesc': 'Kaamera kadreering / heli ja video tõlge', + 'agentEnglishChat': 'Inglise vestlus', + 'agentEnglishChatDesc': 'Süvenev kõnepraktika', + 'agentEnglishStudy': 'Inglise keele õpe', + 'agentEnglishStudyDesc': 'Sõnavara ja grammatika tunnid', + 'agentKidsStory': 'Lastejutud', + 'agentKidsStoryDesc': 'Unejutud ja küsimused-vastused', + 'moduleRemovedDesc': 'eemaldatud AI lehelt', + 'moduleAddedDesc': 'lisatud AI lehe lõppu', + 'calNoRecords': 'Kirjeid pole', + 'calNoRecordsDay': 'Midagi pole plaanis', + 'calTodos': 'Ülesanded', + 'calExpenses': 'Kulud', + 'calTodoCount': '@count ülesannet', + 'calExpenseCount': '@count kulu', + 'calEntryCount': '@count kirjet', + 'calItemsUnit': 'tk', + 'calCountUnit': 'kirjet', + 'calTotalAmount': 'Kokku', + 'recordPause': 'Peata', + 'recordResume': 'Jätka', + 'recordDeleteConfirm': 'Kas kustutada see salvestus?', + 'scopeDay': 'Päev', + 'scopeWeek': 'Nädal', + 'scopeMonth': 'Kuu', + 'allDay': 'Terve päev', + 'guestModeTitle': 'Külalisrežiim', + 'guestModeDesc': 'Külalised saavad ainult sirvida. Logi sisse, et kasutada häälmärkmeid, tõlget ja salvestamist — sinu andmed salvestatakse sinu kontole.', + 'guestKeepBrowsing': 'Jätka sirvimist', + 'guestGoLogin': 'Logi sisse', + 'guestViewOnlyTag': 'Ainult vaatamiseks', + 'autoConnectReenableHint': 'Automaatne ühendamine on välja lülitatud. Lülita see uuesti sisse menüüs Seaded → Seadmete automaatne ühendamine.', + 'plazaEntryDesc': 'Sirvi ja lisa rohkem agente', + 'agentTextTranslation': 'Tekstitõlge', + 'agentTextTranslationDesc': 'Tõlgi trükitud või kleebitud teksti', + 'agentPictureTranslation': 'Fototõlge', + 'agentPictureTranslationDesc': 'Pildista või vali foto tõlkimiseks', + 'callRequiredTitle': 'Vajalik on kõne', + 'callRequiredDesc': 'See funktsioon töötab ainult kõne ajal', + 'inCallBusyTitle': 'Kõne ajal pole saadaval', + 'inCallBusyDesc': 'Mikrofon ja heliliin on kõne kasutuses. Proovi uuesti pärast kõne lõpetamist.', + 'vipSectionTitle': 'VIP', + 'freeSectionTitle': 'Tasuta', + 'smartcarConnected': 'Connected', + 'smartcarConnectFailed': 'Connection failed', + 'smartcarConnecting': 'Connecting…', + 'smartcarSearching': 'Searching for nearby car diffusers…', + 'smartcarNotFound': 'No device found', + 'smartcarScanHint': 'Make sure the device is powered on and close to your phone', + 'vipRequiredTitle': 'Membership required', + 'vipRequiredDesc': 'Translation and meeting notes require a membership. Renew, or pair a device that includes one.', + 'vipRequiredGo': 'Get membership', + 'vipFromDevice': 'from device', + 'computeSummary': 'Compute: device @device · account @user', + 'computePack': 'Compute packs', + 'vipExpiringSoon': 'Membership expires in @days day(s). Renew to keep using translation and meeting notes.', + 'computeLow': 'Running low on compute. You won\'t be able to continue once it runs out.', }; diff --git a/apps/client/lib/core/translations/language/fa_ir.dart b/apps/client/lib/core/translations/language/fa_ir.dart index a63b3d2b..aed4c419 100644 --- a/apps/client/lib/core/translations/language/fa_ir.dart +++ b/apps/client/lib/core/translations/language/fa_ir.dart @@ -1886,4 +1886,208 @@ const Map faIR = { 'unmarkFavorite': 'حذف از علاقه‌مندی‌ها', 'unnamedDevice': 'دستگاه بدون نام', 'userDataParseFailed': 'تحلیل داده‌های کاربر ناموفق بود', + 'catEarphone': 'هدفون هوشمند', + 'catEarphoneDesc': 'هدفون بلوتوث کلاسیک / BLE', + 'catPhoneHolder': 'پایه گوشی هوشمند', + 'catPhoneHolderDesc': 'دستگاه‌های پایه', + 'catCarAroma': 'خوشبوکننده خودرو هوشمند', + 'catCarAromaDesc': 'دستگاه پخش رایحه خودرو', + 'agreeAndContinue': 'موافقت و ادامه', + 'memberServiceAgreement': 'توافق‌نامه خدمات عضویت', + 'memberServiceAgreementTitle': 'توافق‌نامه خدمات عضویت', + 'memberServiceAgreementIntro': 'برای ارائه خدمات عضویت بهتر، لطفاً پیش از اشتراک با دقت مطالعه کنید: ', + 'memberServiceAgreementOutro': '. با کلیک بر روی «موافقت و ادامه»، تأیید می‌کنید که توافق‌نامه فوق را به طور کامل مطالعه کرده و می‌پذیرید.', + 'memberAgreementRejectedTip': 'برای ادامه باید با توافق‌نامه خدمات عضویت موافقت کنید.', + 'translationCallExitTitle': 'خروج از ترجمه تماس', + 'translationCallExitMessage': 'پیش از خروج، انتخاب کنید چه کاری انجام شود', + 'translationCallEnd': 'پایان ترجمه تماس', + 'translationContinueInBackground': 'ادامهٔ ترجمه در پس‌زمینه', + 'backgroundRecordingIndicatorBody': 'ضبط در پس‌زمینه ادامه دارد. برای بازگشت لمس کنید.', + 'backgroundTranslationIndicatorBody': 'ترجمه تماس در پس‌زمینه ادامه دارد. برای بازگشت لمس کنید.', + 'meetingRecordScreenCapturePermissionDenied': 'برای ضبط صدای سیستم، مجوز ضبط صفحه لازم است. لطفاً آن را بدهید و دوباره تلاش کنید.', + 'pleaseGrantBluetoothPermission': 'برای اسکن، لطفاً مجوز بلوتوث را بدهید', + 'startScanFailed': 'شروع اسکن ناموفق بود', + 'pleaseLoginFirst': 'لطفاً ابتدا وارد شوید', + 'deviceUnauthorized': 'دستگاه مجاز نیست، لطفاً دوباره تلاش کنید', + 'boundDevices': 'دستگاه‌های پیوندشده', + 'currentConnection': 'اتصال فعلی', + 'binding': 'در حال پیوند...', + 'permissionDisabled': 'غیرفعال', + 'confirmDisablePermissionTitle': 'خاموش کردن مجوز', + 'confirmDisablePermissionContent': 'مجوز {permission} خاموش شود؟ برای فعال‌سازی مجدد باید به تنظیمات سیستم بروید.', + 'confirmPermanentlyDisallowTitle': 'دیگر پرسیده نشود؟', + 'confirmPermanentlyDisallowContent': 'شما مجوز {permission} را رد کردید. از این پس دیگر پرسیده نشود؟ اگر «اجازه نده» را انتخاب کنید، بعداً می‌توانید آن را از تنظیمات سیستم فعال کنید.', + 'notNow': 'حالا نه', + 'permanentlyDisallow': 'اجازه نده', + 'otaBesNotSupported': 'این هدست هنوز از ارتقای سفت‌افزار داخل برنامه پشتیبانی نمی‌کند. لطفاً با پشتیبانی تماس بگیرید.', + 'tabDevice': 'دستگاه‌ها', + 'tabPlaza': 'پلازا', + 'sloganHome': 'فناوری برای زندگی هوشمندتر', + 'sloganAi': 'هوشی بی‌پایان', + 'sloganDevice': 'سخت‌افزار هوشمند، آمادهٔ آینده', + 'sloganPlaza': 'دنیایی هوشمند که شما را می‌فهمد', + 'aiEditHint': 'برای جابه‌جایی و مرتب‌سازی ماژول‌ها، کارت را لمس طولانی کنید', + 'aiEmptyTitle': 'هنوز ماژولی اضافه نشده است', + 'aiEmptyDesc': 'عامل‌های مورد نیاز خود را از پلازا انتخاب کنید', + 'goToPlaza': 'رفتن به پلازا', + 'plazaOnline': 'در دسترس', + 'plazaPlanned': 'به زودی', + 'plazaSearchHint': 'جستجوی عامل‌ها، مثلاً «ترجمه»، «ضبط»', + 'moduleRemove': 'حذف', + 'moduleAdd': 'افزودن', + 'moduleRemoved': 'حذف شد', + 'moduleAdded': 'اضافه شد', + 'goSee': 'مشاهده', + 'comingSoonTag': 'به زودی', + 'needDeviceTag': 'نیاز به دستگاه', + 'featureInDev': 'این قابلیت در حال توسعه است', + 'featureNotWired': 'هنوز در دسترس نیست', + 'todayLabel': 'امروز', + 'deviceConnected': 'متصل', + 'licenseUnknown': 'مجوز نامشخص', + 'selectDevice': 'یک دستگاه انتخاب کنید', + 'catNoteCard': 'کارت ضبط AI', + 'catNoteCardDesc': 'ضبط‌کنندهٔ کارتی', + 'catParkerPen': 'قلم AI', + 'catParkerPenDesc': 'قلم‌های ضبط صدا', + 'catStylusPen': 'قلم لمسی AI', + 'catStylusPenDesc': 'دستگاه‌های دست‌نویس / قلم لمسی', + 'catStudyMachine': 'تبلت آموزشی AI', + 'catStudyMachineDesc': 'دستگاه‌های آموزشی', + 'besAddTitle': 'افزودن هدست AI', + 'besConnecting': 'در حال اتصال به هدست…', + 'besNotFound': 'هدست پیدا نشد', + 'besNotFoundIosDesc': 'ابتدا هدست را از تنظیمات ← بلوتوث متصل کنید، سپس به اینجا برگردید', + 'besNotFoundAndroidDesc': 'ابتدا هدست را از تنظیمات ← بلوتوث جفت کنید، سپس از اینجا متصل شوید', + 'besReadFailed': 'خواندن ناموفق بود', + 'besConnectFailIos': 'مطمئن شوید هدست در بلوتوث سیستم به‌صورت «متصل» نمایش داده می‌شود، سپس دوباره تلاش کنید', + 'besConnectFailAndroid': 'مطمئن شوید هدست روشن و نزدیک است و به دستگاه دیگری متصل نیست. پس از پیوند، به‌صورت خودکار دوباره متصل می‌شود.', + 'holderAddTitle': 'افزودن پایه گوشی AI', + 'holderConnecting': 'در حال اتصال به دستگاه…', + 'holderSearching': 'در حال جستجوی دستگاه‌ها…', + 'holderNotFound': 'دستگاهی پیدا نشد', + 'holderPairHint': 'مطمئن شوید پایه روشن و در حالت جفت‌سازی است', + 'holderRetryHint': 'مطمئن شوید دستگاه روشن و نزدیک است، سپس دوباره تلاش کنید', + 'autoConnectTitle': 'اتصال خودکار دستگاه‌ها', + 'autoConnectDesc': 'بیدار کردن برنامه برای اتصال مجدد و دریافت ضبط‌ها هنگام روشن شدن یا نزدیک شدن دستگاه', + 'autoConnectConsentTitle': 'فعال‌سازی اتصال خودکار', + 'autoConnectConsentIntro': 'برای اینکه دستگاه شما (هدست AI / پایه گوشی AI) به‌صورت خودکار متصل شود و با فشردن دکمه ضبط آن، ضبط‌ها ذخیره شوند، به رضایت شما نیاز داریم:', + 'autoConnectConsentB1': 'ثبت اسکن بلوتوث در پس‌زمینه تا هنگام شناسایی دستگاه پیوندشده، برنامه بیدار (خودکار اجرا) شود و اتصال مجدد و دریافت ضبط‌ها انجام گیرد', + 'autoConnectConsentB2': 'پس از بیدار شدن، فقط برای اتصال دستگاه، ضبط و بارگذاری استفاده می‌شود — نه چیز دیگری، و هیچ برنامه دیگری اجرا نمی‌شود', + 'autoConnectConsentB3': 'بدون آن هم می‌توانید از برنامه استفاده کنید — فقط هر بار دستگاه را به‌صورت دستی متصل کنید', + 'autoConnectConsentB4': 'هر زمان می‌توانید آن را از تنظیمات ← اتصال خودکار دستگاه‌ها خاموش کنید', + 'autoConnectAgree': 'موافقت و فعال‌سازی', + 'autoConnectLater': 'حالا نه', + 'autoConnectRequired': 'اتصال خودکار خاموش است', + 'autoConnectRequiredDesc': 'برای پیوند این دستگاه، اتصال خودکار را فعال کنید', + 'recordTapToStart': 'برای شروع ضبط، دکمهٔ زیر را لمس کنید', + 'recordPaused': 'متوقف شده', + 'recordMark': 'نشانه', + 'recordDeleteTitle': 'حذف ضبط', + 'accountAndProfile': 'حساب و پروفایل', + 'nicknameUnset': 'نام مستعار تنظیم نشده', + 'nicknameInput': 'نام مستعار را وارد کنید', + 'loginMethod': 'روش ورود', + 'manageMyLogin': 'مدیریت ورود', + 'logoutAction': 'خروج از حساب', + 'warningLabel': 'هشدار:', + 'deleteAccountWarn': 'حساب شما و همه فایل‌ها برای همیشه حذف خواهند شد.', + 'icpFiling': 'ثبت ICP', + 'logExport': 'خروجی گزارش‌ها', + 'logExportDesc': 'مشاهده و خروجی گرفتن از گزارش‌های برنامه', + 'permLoadFailed': 'بارگذاری گروه‌های مجوز ناموفق بود', + 'permInsufficient': 'مجوز رد شد', + 'voiceCloneCreating': 'در حال ساخت صدای شما…', + 'voiceCloneCreate': 'ساخت صدای من', + 'voiceCloneAgree': 'موافقت و خواندن بیانیه با صدای بلند', + 'voiceCloneReadPrompt': 'لطفاً بخوانید:', + 'voiceCloneFirstMonthFree': 'ماه اول رایگان', + 'voiceCloneEnable': 'فعال‌سازی شبیه‌سازی صدا', + 'noMusicPlaying': 'چیزی در حال پخش نیست', + 'qqMusicNoSeek': 'جابه‌جایی در QQ Music پشتیبانی نمی‌شود', + 'bleScanTitle': 'اسکن BLE', + 'bluetoothOn': 'بلوتوث روشن', + 'bleScanning': 'در حال اسکن…', + 'bleConnectedDevices': 'دستگاه‌های متصل', + 'bleUnknownDevice': 'دستگاه ناشناس', + 'bleTapToScan': 'برای جستجوی دستگاه‌های BLE، اسکن را لمس کنید', + 'weekMon': 'دوشنبه', + 'weekTue': 'سه‌شنبه', + 'weekWed': 'چهارشنبه', + 'weekThu': 'پنجشنبه', + 'weekFri': 'جمعه', + 'weekSat': 'شنبه', + 'weekSun': 'یکشنبه', + 'agentMeeting': 'صورت‌جلسه صوتی', + 'agentMeetingDesc': 'خلاصه‌سازی ضبط‌ها، مرکز حافظه', + 'agentEmai': 'دستیار EMAI', + 'agentEmaiDesc': 'با صدا گفتگو کنید و هر چیزی بپرسید', + 'agentSimultaneous': 'ترجمه همزمان', + 'agentSimultaneousDesc': 'ترجمه همزمان با صحبت شما', + 'agentFaceToFace': 'رو در رو', + 'agentFaceToFaceDesc': 'ترجمه گفتگوی دوطرفه', + 'agentCallRecording': 'ضبط تماس', + 'agentCallRecordingDesc': 'تماس‌ها به‌صورت خودکار ذخیره می‌شوند', + 'agentFieldRecording': 'ضبط میدانی', + 'agentFieldRecordingDesc': 'میکروفون دستگاه یا گوشی', + 'agentCall': 'ترجمه تماس', + 'agentCallDesc': 'ترجمه بلادرنگ در حین تماس', + 'agentAudioVideo': 'ترجمه رسانه', + 'agentAudioVideoDesc': 'قاب‌بندی دوربین / ترجمه صوت و ویدیو', + 'agentEnglishChat': 'گفتگوی انگلیسی', + 'agentEnglishChatDesc': 'تمرین مکالمه به‌صورت غوطه‌ور', + 'agentEnglishStudy': 'یادگیری انگلیسی', + 'agentEnglishStudyDesc': 'درس‌های واژگان و دستور زبان', + 'agentKidsStory': 'قصه‌های کودکان', + 'agentKidsStoryDesc': 'قصه‌های شب و پرسش و پاسخ', + 'moduleRemovedDesc': 'از صفحه AI حذف شد', + 'moduleAddedDesc': 'به انتهای صفحه AI اضافه شد', + 'calNoRecords': 'موردی وجود ندارد', + 'calNoRecordsDay': 'برنامه‌ای وجود ندارد', + 'calTodos': 'کارها', + 'calExpenses': 'هزینه‌ها', + 'calTodoCount': '@count کار', + 'calExpenseCount': '@count هزینه', + 'calEntryCount': '@count مورد', + 'calItemsUnit': 'مورد', + 'calCountUnit': 'مورد', + 'calTotalAmount': 'مجموع', + 'recordPause': 'توقف', + 'recordResume': 'ادامه', + 'recordDeleteConfirm': 'این ضبط حذف شود؟', + 'scopeDay': 'روز', + 'scopeWeek': 'هفته', + 'scopeMonth': 'ماه', + 'allDay': 'تمام روز', + 'guestModeTitle': 'حالت مهمان', + 'guestModeDesc': 'مهمان‌ها فقط می‌توانند مرور کنند. برای استفاده از یادداشت صوتی، ترجمه و ضبط وارد شوید — داده‌های شما در حسابتان ذخیره می‌شود.', + 'guestKeepBrowsing': 'ادامه مرور', + 'guestGoLogin': 'ورود', + 'guestViewOnlyTag': 'فقط مشاهده', + 'autoConnectReenableHint': 'اتصال خودکار خاموش است. آن را از تنظیمات ← اتصال خودکار دستگاه‌ها دوباره روشن کنید.', + 'plazaEntryDesc': 'عامل‌های بیشتری را مرور و اضافه کنید', + 'agentTextTranslation': 'ترجمه متن', + 'agentTextTranslationDesc': 'ترجمه متن تایپ‌شده یا چسبانده‌شده', + 'agentPictureTranslation': 'ترجمه عکس', + 'agentPictureTranslationDesc': 'عکس بگیرید یا انتخاب کنید تا ترجمه شود', + 'callRequiredTitle': 'نیاز به تماس', + 'callRequiredDesc': 'این قابلیت فقط در حین تماس کار می‌کند', + 'inCallBusyTitle': 'در حین تماس در دسترس نیست', + 'inCallBusyDesc': 'میکروفون و مسیر صدا در اختیار تماس است. پس از پایان تماس دوباره تلاش کنید.', + 'vipSectionTitle': 'VIP', + 'freeSectionTitle': 'رایگان', + 'smartcarConnected': 'Connected', + 'smartcarConnectFailed': 'Connection failed', + 'smartcarConnecting': 'Connecting…', + 'smartcarSearching': 'Searching for nearby car diffusers…', + 'smartcarNotFound': 'No device found', + 'smartcarScanHint': 'Make sure the device is powered on and close to your phone', + 'vipRequiredTitle': 'Membership required', + 'vipRequiredDesc': 'Translation and meeting notes require a membership. Renew, or pair a device that includes one.', + 'vipRequiredGo': 'Get membership', + 'vipFromDevice': 'from device', + 'computeSummary': 'Compute: device @device · account @user', + 'computePack': 'Compute packs', + 'vipExpiringSoon': 'Membership expires in @days day(s). Renew to keep using translation and meeting notes.', + 'computeLow': 'Running low on compute. You won\'t be able to continue once it runs out.', }; diff --git a/apps/client/lib/core/translations/language/fi_fi.dart b/apps/client/lib/core/translations/language/fi_fi.dart index 7b032e3c..52f99be9 100644 --- a/apps/client/lib/core/translations/language/fi_fi.dart +++ b/apps/client/lib/core/translations/language/fi_fi.dart @@ -1905,4 +1905,208 @@ const Map fiFI = { 'unmarkFavorite': 'Poista suosikeista', 'unnamedDevice': 'Nimetön laite', 'userDataParseFailed': 'Käyttäjätietojen luku epäonnistui', + 'catEarphone': 'AI-nappikuulokkeet', + 'catEarphoneDesc': 'Klassiset Bluetooth- / BLE-kuulokkeet', + 'catPhoneHolder': 'AI-puhelinteline', + 'catPhoneHolderDesc': 'Telinelaitteet', + 'catCarAroma': 'AI-autotuoksu', + 'catCarAromaDesc': 'Auton tuoksudiffuusori', + 'agreeAndContinue': 'Hyväksy ja jatka', + 'memberServiceAgreement': 'Jäsenpalvelusopimus', + 'memberServiceAgreementTitle': 'Jäsenpalvelusopimus', + 'memberServiceAgreementIntro': 'Tarjotaksemme sinulle parempia jäsenpalveluita, lue huolellisesti ennen tilaamista: ', + 'memberServiceAgreementOutro': '. Napsauttamalla ”Hyväksy ja jatka” vahvistat lukeneesi yllä olevan sopimuksen kokonaan ja hyväksyväsi sen.', + 'memberAgreementRejectedTip': 'Sinun on hyväksyttävä jäsenpalvelusopimus jatkaaksesi.', + 'translationCallExitTitle': 'Poistu puhelukäännöksestä', + 'translationCallExitMessage': 'Valitse, mitä tehdään ennen poistumista', + 'translationCallEnd': 'Lopeta puhelukäännös', + 'translationContinueInBackground': 'Jatka kääntämistä taustalla', + 'backgroundRecordingIndicatorBody': 'Tallennus jatkuu taustalla. Palaa napauttamalla.', + 'backgroundTranslationIndicatorBody': 'Puhelukäännös jatkuu taustalla. Palaa napauttamalla.', + 'meetingRecordScreenCapturePermissionDenied': 'Järjestelmän äänen tallentamiseen tarvitaan näytön tallennuslupa. Myönnä lupa ja yritä uudelleen.', + 'pleaseGrantBluetoothPermission': 'Myönnä Bluetooth-lupa hakua varten', + 'startScanFailed': 'Haun käynnistys epäonnistui', + 'pleaseLoginFirst': 'Kirjaudu ensin sisään', + 'deviceUnauthorized': 'Laitetta ei ole valtuutettu, yritä uudelleen', + 'boundDevices': 'Sidotut laitteet', + 'currentConnection': 'Nykyinen yhteys', + 'binding': 'Sidotaan...', + 'permissionDisabled': 'Pois käytöstä', + 'confirmDisablePermissionTitle': 'Poista lupa käytöstä', + 'confirmDisablePermissionContent': 'Poistetaanko {permission}-lupa käytöstä? Sinun on otettava se uudelleen käyttöön järjestelmän asetuksista.', + 'confirmPermanentlyDisallowTitle': 'Lopetetaanko kysyminen?', + 'confirmPermanentlyDisallowContent': 'Epäsit {permission}-luvan. Lopetetaanko kysyminen tästä lähtien? Jos valitset ”Älä salli”, voit silti ottaa sen käyttöön myöhemmin järjestelmän asetuksista.', + 'notNow': 'Ei nyt', + 'permanentlyDisallow': 'Älä salli', + 'otaBesNotSupported': 'Nämä kuulokkeet eivät vielä tue laiteohjelmiston päivitystä sovelluksesta. Ota yhteyttä tukeen.', + 'tabDevice': 'Laitteet', + 'tabPlaza': 'Plaza', + 'sloganHome': 'Teknologiaa älykkäämpään elämään', + 'sloganAi': 'Rajaton älykkyys', + 'sloganDevice': 'Älykäs laitteisto, valmis tulevaisuuteen', + 'sloganPlaza': 'Älykäs maailma, joka ymmärtää sinua', + 'aiEditHint': 'Paina korttia pitkään ja vedä järjestääksesi moduulit', + 'aiEmptyTitle': 'Moduuleja ei ole vielä lisätty', + 'aiEmptyDesc': 'Valitse tarvitsemasi agentit Plazasta', + 'goToPlaza': 'Siirry Plazaan', + 'plazaOnline': 'Saatavilla', + 'plazaPlanned': 'Tulossa pian', + 'plazaSearchHint': 'Hae agentteja, esim. ”käännä”, ”tallenna”', + 'moduleRemove': 'Poista', + 'moduleAdd': 'Lisää', + 'moduleRemoved': 'Poistettu', + 'moduleAdded': 'Lisätty', + 'goSee': 'Katso', + 'comingSoonTag': 'Tulossa pian', + 'needDeviceTag': 'Vaatii laitteen', + 'featureInDev': 'Tämä ominaisuus on kehitteillä', + 'featureNotWired': 'Ei vielä saatavilla', + 'todayLabel': 'Tänään', + 'deviceConnected': 'Yhdistetty', + 'licenseUnknown': 'Lisenssi tuntematon', + 'selectDevice': 'Valitse laite', + 'catNoteCard': 'AI-tallenninkortti', + 'catNoteCardDesc': 'Kortin muotoinen tallennin', + 'catParkerPen': 'AI-kynä', + 'catParkerPenDesc': 'Tallentavat kynät', + 'catStylusPen': 'AI-stylus', + 'catStylusPenDesc': 'Käsinkirjoitus-/stylus-laitteet', + 'catStudyMachine': 'AI-oppimistabletti', + 'catStudyMachineDesc': 'Oppimislaitteet', + 'besAddTitle': 'Lisää AI-kuulokkeet', + 'besConnecting': 'Yhdistetään kuulokkeisiin…', + 'besNotFound': 'Kuulokkeita ei löytynyt', + 'besNotFoundIosDesc': 'Yhdistä kuulokkeet ensin kohdassa Asetukset → Bluetooth ja palaa sitten tänne', + 'besNotFoundAndroidDesc': 'Parita kuulokkeet ensin kohdassa Asetukset → Bluetooth ja yhdistä sitten täällä', + 'besReadFailed': 'Lukeminen epäonnistui', + 'besConnectFailIos': 'Varmista, että kuulokkeet näkyvät yhdistettyinä järjestelmän Bluetooth-asetuksissa, ja yritä sitten uudelleen', + 'besConnectFailAndroid': 'Varmista, että kuulokkeet ovat päällä, lähellä eivätkä yhdistettynä toiseen laitteeseen. Ne yhdistyvät automaattisesti uudelleen parituksen jälkeen.', + 'holderAddTitle': 'Lisää AI-puhelinteline', + 'holderConnecting': 'Yhdistetään laitteeseen…', + 'holderSearching': 'Etsitään laitteita…', + 'holderNotFound': 'Laitteita ei löytynyt', + 'holderPairHint': 'Varmista, että teline on päällä ja paritustilassa', + 'holderRetryHint': 'Varmista, että laite on päällä ja lähellä, ja yritä sitten uudelleen', + 'autoConnectTitle': 'Laitteiden automaattinen yhdistäminen', + 'autoConnectDesc': 'Herättää sovelluksen yhdistämään uudelleen ja vastaanottamaan tallenteita, kun laite käynnistyy tai tulee lähelle', + 'autoConnectConsentTitle': 'Ota automaattinen yhdistäminen käyttöön', + 'autoConnectConsentIntro': 'Jotta laitteesi (AI-kuulokkeet / AI-puhelinteline) voi yhdistää automaattisesti ja tallentaa tallenteet, kun painat sen tallennuspainiketta, tarvitsemme suostumuksesi:', + 'autoConnectConsentB1': 'Rekisteröidään Bluetooth-taustahaku, jotta sovellus herätetään (käynnistetään automaattisesti), kun sidottu laite havaitaan, yhteyden palauttamiseksi ja tallenteiden vastaanottamiseksi', + 'autoConnectConsentB2': 'Herättämisen jälkeen sitä käytetään vain laitteen yhdistämiseen, tallentamiseen ja lataamiseen — ei mihinkään muuhun, eikä muita sovelluksia käynnistetä', + 'autoConnectConsentB3': 'Voit käyttää sovellusta myös ilman sitä — yhdistä laite vain manuaalisesti joka kerta', + 'autoConnectConsentB4': 'Voit poistaa sen käytöstä milloin tahansa kohdassa Asetukset → Laitteiden automaattinen yhdistäminen', + 'autoConnectAgree': 'Hyväksy ja ota käyttöön', + 'autoConnectLater': 'Ei nyt', + 'autoConnectRequired': 'Automaattinen yhdistäminen on pois päältä', + 'autoConnectRequiredDesc': 'Ota automaattinen yhdistäminen käyttöön sitoaksesi tämän laitteen', + 'recordTapToStart': 'Aloita tallennus napauttamalla alla olevaa painiketta', + 'recordPaused': 'Keskeytetty', + 'recordMark': 'Merkitse', + 'recordDeleteTitle': 'Poista tallenne', + 'accountAndProfile': 'Tili ja profiili', + 'nicknameUnset': 'Nimimerkkiä ei ole asetettu', + 'nicknameInput': 'Syötä nimimerkki', + 'loginMethod': 'Kirjautumistapa', + 'manageMyLogin': 'Hallitse kirjautumista', + 'logoutAction': 'Kirjaudu ulos', + 'warningLabel': 'Varoitus:', + 'deleteAccountWarn': 'Tilisi ja kaikki tiedostot poistetaan pysyvästi.', + 'icpFiling': 'ICP-rekisteröinti', + 'logExport': 'Vie lokit', + 'logExportDesc': 'Tarkastele ja vie sovelluksen lokeja', + 'permLoadFailed': 'Luparyhmien lataus epäonnistui', + 'permInsufficient': 'Lupa evätty', + 'voiceCloneCreating': 'Luodaan ääntäsi…', + 'voiceCloneCreate': 'Luo oma ääneni', + 'voiceCloneAgree': 'Hyväksy ja lue lausunto ääneen', + 'voiceCloneReadPrompt': 'Lue ääneen:', + 'voiceCloneFirstMonthFree': 'Ensimmäinen kuukausi ilmaiseksi', + 'voiceCloneEnable': 'Ota äänen kloonaus käyttöön', + 'noMusicPlaying': 'Mitään ei toisteta', + 'qqMusicNoSeek': 'QQ Music ei tue kelausta', + 'bleScanTitle': 'BLE-haku', + 'bluetoothOn': 'Bluetooth päällä', + 'bleScanning': 'Haetaan…', + 'bleConnectedDevices': 'Yhdistetyt laitteet', + 'bleUnknownDevice': 'Tuntematon laite', + 'bleTapToScan': 'Napauta Hae etsiäksesi BLE-laitteita', + 'weekMon': 'Ma', + 'weekTue': 'Ti', + 'weekWed': 'Ke', + 'weekThu': 'To', + 'weekFri': 'Pe', + 'weekSat': 'La', + 'weekSun': 'Su', + 'agentMeeting': 'Äänimuistiot', + 'agentMeetingDesc': 'Tiivistä tallenteet, muistikeskus', + 'agentEmai': 'EMAI-avustaja', + 'agentEmaiDesc': 'Keskustele ja kysy mitä tahansa puhumalla', + 'agentSimultaneous': 'Simultaanitulkkaus', + 'agentSimultaneousDesc': 'Kääntää puhuessasi', + 'agentFaceToFace': 'Kasvokkain', + 'agentFaceToFaceDesc': 'Kaksisuuntainen keskustelun käännös', + 'agentCallRecording': 'Puhelun tallennus', + 'agentCallRecordingDesc': 'Puhelut tallennetaan automaattisesti', + 'agentFieldRecording': 'Kenttätallennus', + 'agentFieldRecordingDesc': 'Laitteen tai puhelimen mikrofoni', + 'agentCall': 'Puhelukäännös', + 'agentCallDesc': 'Reaaliaikainen käännös puhelun aikana', + 'agentAudioVideo': 'Mediakäännös', + 'agentAudioVideoDesc': 'Kameran rajaus / ääni- ja videokäännös', + 'agentEnglishChat': 'Englannin keskustelu', + 'agentEnglishChatDesc': 'Immersiivistä puheharjoittelua', + 'agentEnglishStudy': 'Englannin opiskelu', + 'agentEnglishStudyDesc': 'Sanasto- ja kielioppitunnit', + 'agentKidsStory': 'Lastentarinat', + 'agentKidsStoryDesc': 'Iltasatuja ja kysymyksiä', + 'moduleRemovedDesc': 'poistettu AI-sivulta', + 'moduleAddedDesc': 'lisätty AI-sivun alaosaan', + 'calNoRecords': 'Ei merkintöjä', + 'calNoRecordsDay': 'Ei mitään suunniteltua', + 'calTodos': 'Tehtävät', + 'calExpenses': 'Kulut', + 'calTodoCount': '@count tehtävää', + 'calExpenseCount': '@count kulua', + 'calEntryCount': '@count merkintää', + 'calItemsUnit': 'kpl', + 'calCountUnit': 'merkintää', + 'calTotalAmount': 'Yhteensä', + 'recordPause': 'Keskeytä', + 'recordResume': 'Jatka', + 'recordDeleteConfirm': 'Poistetaanko tämä tallenne?', + 'scopeDay': 'Päivä', + 'scopeWeek': 'Viikko', + 'scopeMonth': 'Kuukausi', + 'allDay': 'Koko päivä', + 'guestModeTitle': 'Vierastila', + 'guestModeDesc': 'Vieraat voivat vain selata. Kirjaudu sisään käyttääksesi äänimuistiinpanoja, käännöstä ja tallennusta — tietosi tallennetaan tilillesi.', + 'guestKeepBrowsing': 'Jatka selaamista', + 'guestGoLogin': 'Kirjaudu sisään', + 'guestViewOnlyTag': 'Vain katselu', + 'autoConnectReenableHint': 'Automaattinen yhdistäminen on pois päältä. Ota se uudelleen käyttöön kohdassa Asetukset → Laitteiden automaattinen yhdistäminen.', + 'plazaEntryDesc': 'Selaa ja lisää agentteja', + 'agentTextTranslation': 'Tekstin käännös', + 'agentTextTranslationDesc': 'Käännä kirjoitettua tai liitettyä tekstiä', + 'agentPictureTranslation': 'Kuvan käännös', + 'agentPictureTranslationDesc': 'Ota tai valitse kuva käännettäväksi', + 'callRequiredTitle': 'Puhelu vaaditaan', + 'callRequiredDesc': 'Tämä ominaisuus toimii vain puhelun aikana', + 'inCallBusyTitle': 'Ei käytettävissä puhelun aikana', + 'inCallBusyDesc': 'Mikrofoni ja äänireitti ovat puhelun käytössä. Yritä uudelleen puhelun päätyttyä.', + 'vipSectionTitle': 'VIP', + 'freeSectionTitle': 'Ilmainen', + 'smartcarConnected': 'Connected', + 'smartcarConnectFailed': 'Connection failed', + 'smartcarConnecting': 'Connecting…', + 'smartcarSearching': 'Searching for nearby car diffusers…', + 'smartcarNotFound': 'No device found', + 'smartcarScanHint': 'Make sure the device is powered on and close to your phone', + 'vipRequiredTitle': 'Membership required', + 'vipRequiredDesc': 'Translation and meeting notes require a membership. Renew, or pair a device that includes one.', + 'vipRequiredGo': 'Get membership', + 'vipFromDevice': 'from device', + 'computeSummary': 'Compute: device @device · account @user', + 'computePack': 'Compute packs', + 'vipExpiringSoon': 'Membership expires in @days day(s). Renew to keep using translation and meeting notes.', + 'computeLow': 'Running low on compute. You won\'t be able to continue once it runs out.', }; diff --git a/apps/client/lib/core/translations/language/fr_fr.dart b/apps/client/lib/core/translations/language/fr_fr.dart index 6237ea65..d96af653 100644 --- a/apps/client/lib/core/translations/language/fr_fr.dart +++ b/apps/client/lib/core/translations/language/fr_fr.dart @@ -1898,6 +1898,8 @@ const Map frFR = { 'catNoteCardDesc': 'Enregistreur format carte', 'catPhoneHolder': 'Support téléphone IA', 'catPhoneHolderDesc': 'Appareils de type support', + 'catCarAroma': 'Diffuseur voiture IA', + 'catCarAromaDesc': 'Diffuseur d’arôme pour voiture', 'catParkerPen': 'Stylo IA', 'catParkerPenDesc': 'Stylos enregistreurs', 'catStylusPen': 'Stylet IA', @@ -2155,4 +2157,32 @@ const Map frFR = { 'unmarkFavorite': 'Retirer des favoris', 'unnamedDevice': 'Appareil sans nom', 'userDataParseFailed': "Échec de l'analyse des données utilisateur", + 'translationCallExitTitle': 'Quitter la traduction d\'appel', + 'translationCallExitMessage': 'Choisissez ce que vous souhaitez faire avant de quitter', + 'translationCallEnd': 'Terminer la traduction d\'appel', + 'translationContinueInBackground': 'Continuer la traduction en arrière-plan', + 'backgroundRecordingIndicatorBody': 'L\'enregistrement continue en arrière-plan. Appuyez pour revenir.', + 'backgroundTranslationIndicatorBody': 'La traduction d\'appel continue en arrière-plan. Appuyez pour revenir.', + 'permissionDisabled': 'Désactivé', + 'confirmDisablePermissionTitle': 'Désactiver l\'autorisation', + 'confirmDisablePermissionContent': 'Désactiver l\'autorisation {permission} ? Vous devrez aller dans les paramètres système pour la réactiver.', + 'confirmPermanentlyDisallowTitle': 'Ne plus demander ?', + 'confirmPermanentlyDisallowContent': 'Vous avez refusé l\'autorisation {permission}. Ne plus demander désormais ? Si vous choisissez « Ne pas autoriser », vous pourrez l\'activer plus tard dans les paramètres système.', + 'notNow': 'Pas maintenant', + 'permanentlyDisallow': 'Ne pas autoriser', + 'otaBesNotSupported': 'Ce casque ne prend pas encore en charge la mise à jour du firmware depuis l\'application. Veuillez contacter le support.', + 'smartcarConnected': 'Connecté', + 'smartcarConnectFailed': 'Échec de la connexion', + 'smartcarConnecting': 'Connexion…', + 'smartcarSearching': 'Recherche de diffuseurs auto à proximité…', + 'smartcarNotFound': 'Aucun appareil trouvé', + 'smartcarScanHint': 'Vérifiez que l’appareil est allumé et proche du téléphone', + 'vipRequiredTitle': 'Membership required', + 'vipRequiredDesc': 'Translation and meeting notes require a membership. Renew, or pair a device that includes one.', + 'vipRequiredGo': 'Get membership', + 'vipFromDevice': 'from device', + 'computeSummary': 'Compute: device @device · account @user', + 'computePack': 'Compute packs', + 'vipExpiringSoon': 'Membership expires in @days day(s). Renew to keep using translation and meeting notes.', + 'computeLow': 'Running low on compute. You won\'t be able to continue once it runs out.', }; diff --git a/apps/client/lib/core/translations/language/hi_in.dart b/apps/client/lib/core/translations/language/hi_in.dart index b19dc573..30ee74b8 100644 --- a/apps/client/lib/core/translations/language/hi_in.dart +++ b/apps/client/lib/core/translations/language/hi_in.dart @@ -1904,4 +1904,208 @@ const Map hiIN = { 'unmarkFavorite': 'पसंदीदा से हटाएँ', 'unnamedDevice': 'बिना नाम का डिवाइस', 'userDataParseFailed': 'उपयोगकर्ता डेटा पढ़ने में विफल', + 'catEarphone': 'AI ईयरबड्स', + 'catEarphoneDesc': 'क्लासिक ब्लूटूथ / BLE ईयरबड्स', + 'catPhoneHolder': 'AI फ़ोन स्टैंड', + 'catPhoneHolderDesc': 'स्टैंड डिवाइस', + 'catCarAroma': 'AI कार डिफ्यूज़र', + 'catCarAromaDesc': 'कार सुगंध डिफ्यूज़र', + 'agreeAndContinue': 'सहमत हों और जारी रखें', + 'memberServiceAgreement': 'सदस्यता सेवा अनुबंध', + 'memberServiceAgreementTitle': 'सदस्यता सेवा अनुबंध', + 'memberServiceAgreementIntro': 'आपको बेहतर सदस्यता सेवाएँ देने के लिए, कृपया सदस्यता लेने से पहले ध्यान से पढ़ें: ', + 'memberServiceAgreementOutro': '। "सहमत हों और जारी रखें" पर टैप करके आप पुष्टि करते हैं कि आपने उपरोक्त अनुबंध पूरी तरह पढ़ लिया है और स्वीकार करते हैं।', + 'memberAgreementRejectedTip': 'जारी रखने के लिए आपको सदस्यता सेवा अनुबंध से सहमत होना होगा।', + 'translationCallExitTitle': 'कॉल अनुवाद से बाहर निकलें', + 'translationCallExitMessage': 'बाहर निकलने से पहले चुनें कि क्या करना है', + 'translationCallEnd': 'कॉल अनुवाद समाप्त करें', + 'translationContinueInBackground': 'बैकग्राउंड में अनुवाद जारी रखें', + 'backgroundRecordingIndicatorBody': 'रिकॉर्डिंग बैकग्राउंड में जारी है। वापस जाने के लिए टैप करें।', + 'backgroundTranslationIndicatorBody': 'कॉल अनुवाद बैकग्राउंड में जारी है। वापस जाने के लिए टैप करें।', + 'meetingRecordScreenCapturePermissionDenied': 'सिस्टम ऑडियो कैप्चर करने के लिए स्क्रीन रिकॉर्डिंग अनुमति आवश्यक है। कृपया अनुमति दें और पुनः प्रयास करें।', + 'pleaseGrantBluetoothPermission': 'स्कैन करने के लिए कृपया ब्लूटूथ अनुमति दें', + 'startScanFailed': 'स्कैन शुरू करने में विफल', + 'pleaseLoginFirst': 'कृपया पहले लॉग इन करें', + 'deviceUnauthorized': 'डिवाइस अधिकृत नहीं है, कृपया पुनः प्रयास करें', + 'boundDevices': 'बाउंड डिवाइस', + 'currentConnection': 'वर्तमान कनेक्शन', + 'binding': 'बाइंड हो रहा है...', + 'permissionDisabled': 'बंद', + 'confirmDisablePermissionTitle': 'अनुमति बंद करें', + 'confirmDisablePermissionContent': '{permission} अनुमति बंद करें? इसे फिर से चालू करने के लिए आपको सिस्टम सेटिंग्स में जाना होगा।', + 'confirmPermanentlyDisallowTitle': 'अब न पूछें?', + 'confirmPermanentlyDisallowContent': 'आपने {permission} अनुमति अस्वीकार कर दी। क्या अब से न पूछा जाए? यदि आप "अनुमति न दें" चुनते हैं, तो बाद में सिस्टम सेटिंग्स में इसे चालू कर सकते हैं।', + 'notNow': 'अभी नहीं', + 'permanentlyDisallow': 'अनुमति न दें', + 'otaBesNotSupported': 'यह हेडसेट अभी ऐप में फ़र्मवेयर अपग्रेड का समर्थन नहीं करता। कृपया सहायता से संपर्क करें।', + 'tabDevice': 'डिवाइस', + 'tabPlaza': 'प्लाज़ा', + 'sloganHome': 'स्मार्ट जीवन के लिए तकनीक', + 'sloganAi': 'असीमित बुद्धिमत्ता', + 'sloganDevice': 'स्मार्ट हार्डवेयर, भविष्य के लिए तैयार', + 'sloganPlaza': 'एक स्मार्ट दुनिया जो आपको समझती है', + 'aiEditHint': 'मॉड्यूल खींचकर पुनर्व्यवस्थित करने के लिए कार्ड को देर तक दबाएँ', + 'aiEmptyTitle': 'अभी तक कोई मॉड्यूल नहीं जोड़ा गया', + 'aiEmptyDesc': 'प्लाज़ा में अपने ज़रूरी एजेंट चुनें', + 'goToPlaza': 'प्लाज़ा पर जाएँ', + 'plazaOnline': 'उपलब्ध', + 'plazaPlanned': 'जल्द आ रहा है', + 'plazaSearchHint': 'एजेंट खोजें, जैसे “अनुवाद”, “रिकॉर्ड”', + 'moduleRemove': 'हटाएँ', + 'moduleAdd': 'जोड़ें', + 'moduleRemoved': 'हटाया गया', + 'moduleAdded': 'जोड़ा गया', + 'goSee': 'देखें', + 'comingSoonTag': 'जल्द आ रहा है', + 'needDeviceTag': 'डिवाइस आवश्यक', + 'featureInDev': 'यह सुविधा विकासाधीन है', + 'featureNotWired': 'अभी उपलब्ध नहीं', + 'todayLabel': 'आज', + 'deviceConnected': 'कनेक्टेड', + 'licenseUnknown': 'लाइसेंस अज्ञात', + 'selectDevice': 'एक डिवाइस चुनें', + 'catNoteCard': 'AI रिकॉर्डर कार्ड', + 'catNoteCardDesc': 'कार्ड के आकार का रिकॉर्डर', + 'catParkerPen': 'AI पेन', + 'catParkerPenDesc': 'वॉयस रिकॉर्डर पेन', + 'catStylusPen': 'AI स्टाइलस', + 'catStylusPenDesc': 'हस्तलेखन / स्टाइलस डिवाइस', + 'catStudyMachine': 'AI लर्निंग पैड', + 'catStudyMachineDesc': 'लर्निंग डिवाइस', + 'besAddTitle': 'AI ईयरबड्स जोड़ें', + 'besConnecting': 'ईयरबड्स से कनेक्ट हो रहा है…', + 'besNotFound': 'ईयरबड्स नहीं मिले', + 'besNotFoundIosDesc': 'पहले सेटिंग्स → ब्लूटूथ में ईयरबड्स कनेक्ट करें, फिर यहाँ वापस आएँ', + 'besNotFoundAndroidDesc': 'पहले सेटिंग्स → ब्लूटूथ में ईयरबड्स पेयर करें, फिर यहाँ कनेक्ट करें', + 'besReadFailed': 'पढ़ने में विफल', + 'besConnectFailIos': 'सुनिश्चित करें कि सिस्टम ब्लूटूथ में ईयरबड्स "कनेक्टेड" दिख रहे हैं, फिर पुनः प्रयास करें', + 'besConnectFailAndroid': 'सुनिश्चित करें कि ईयरबड्स चालू हैं, पास में हैं और किसी अन्य डिवाइस से कनेक्ट नहीं हैं। लिंक होते ही वे अपने आप फिर से कनेक्ट हो जाएँगे।', + 'holderAddTitle': 'AI फ़ोन स्टैंड जोड़ें', + 'holderConnecting': 'डिवाइस से कनेक्ट हो रहा है…', + 'holderSearching': 'डिवाइस खोजे जा रहे हैं…', + 'holderNotFound': 'कोई डिवाइस नहीं मिला', + 'holderPairHint': 'सुनिश्चित करें कि स्टैंड चालू है और पेयरिंग मोड में है', + 'holderRetryHint': 'सुनिश्चित करें कि डिवाइस चालू और पास में है, फिर पुनः प्रयास करें', + 'autoConnectTitle': 'डिवाइस ऑटो-कनेक्ट', + 'autoConnectDesc': 'डिवाइस चालू होने या पास आने पर फिर से कनेक्ट होने और रिकॉर्डिंग प्राप्त करने के लिए ऐप को जगाएँ', + 'autoConnectConsentTitle': 'ऑटो-कनेक्ट सक्षम करें', + 'autoConnectConsentIntro': 'आपके डिवाइस (AI ईयरबड्स / AI फ़ोन स्टैंड) को अपने आप कनेक्ट होने और उसका रिकॉर्ड बटन दबाने पर रिकॉर्डिंग सहेजने के लिए, हमें आपकी सहमति चाहिए:', + 'autoConnectConsentB1': 'बैकग्राउंड ब्लूटूथ स्कैन पंजीकृत करें ताकि आपका बाउंड डिवाइस मिलने पर ऐप जागे (ऑटो-स्टार्ट), और फिर से कनेक्ट होकर रिकॉर्डिंग प्राप्त करे', + 'autoConnectConsentB2': 'जागने के बाद इसका उपयोग केवल डिवाइस कनेक्शन, रिकॉर्डिंग और अपलोड के लिए होता है — और कुछ नहीं, न ही कोई अन्य ऐप खोला जाता है', + 'autoConnectConsentB3': 'इसके बिना भी आप ऐप का उपयोग कर सकते हैं — बस हर बार डिवाइस को मैन्युअल रूप से कनेक्ट करें', + 'autoConnectConsentB4': 'आप इसे कभी भी सेटिंग्स → डिवाइस ऑटो-कनेक्ट में बंद कर सकते हैं', + 'autoConnectAgree': 'सहमत हों और सक्षम करें', + 'autoConnectLater': 'अभी नहीं', + 'autoConnectRequired': 'ऑटो-कनेक्ट बंद है', + 'autoConnectRequiredDesc': 'इस डिवाइस को बाइंड करने के लिए ऑटो-कनेक्ट सक्षम करें', + 'recordTapToStart': 'रिकॉर्डिंग शुरू करने के लिए नीचे दिए बटन पर टैप करें', + 'recordPaused': 'रुका हुआ', + 'recordMark': 'मार्क', + 'recordDeleteTitle': 'रिकॉर्डिंग हटाएँ', + 'accountAndProfile': 'खाता और प्रोफ़ाइल', + 'nicknameUnset': 'कोई उपनाम सेट नहीं', + 'nicknameInput': 'उपनाम दर्ज करें', + 'loginMethod': 'लॉगिन विधि', + 'manageMyLogin': 'लॉगिन प्रबंधित करें', + 'logoutAction': 'लॉग आउट', + 'warningLabel': 'चेतावनी:', + 'deleteAccountWarn': 'आपका खाता और सभी फ़ाइलें स्थायी रूप से हटा दी जाएँगी।', + 'icpFiling': 'ICP पंजीकरण', + 'logExport': 'लॉग निर्यात करें', + 'logExportDesc': 'ऐप लॉग देखें और निर्यात करें', + 'permLoadFailed': 'अनुमति समूह लोड करने में विफल', + 'permInsufficient': 'अनुमति अस्वीकृत', + 'voiceCloneCreating': 'आपकी आवाज़ बनाई जा रही है…', + 'voiceCloneCreate': 'मेरी आवाज़ बनाएँ', + 'voiceCloneAgree': 'सहमत हों और कथन को ज़ोर से पढ़ें', + 'voiceCloneReadPrompt': 'कृपया पढ़ें:', + 'voiceCloneFirstMonthFree': 'पहला महीना मुफ़्त', + 'voiceCloneEnable': 'वॉयस क्लोन सक्षम करें', + 'noMusicPlaying': 'कुछ नहीं चल रहा', + 'qqMusicNoSeek': 'QQ Music में सीक करना समर्थित नहीं है', + 'bleScanTitle': 'BLE स्कैन', + 'bluetoothOn': 'ब्लूटूथ चालू', + 'bleScanning': 'स्कैन हो रहा है…', + 'bleConnectedDevices': 'कनेक्टेड डिवाइस', + 'bleUnknownDevice': 'अज्ञात डिवाइस', + 'bleTapToScan': 'BLE डिवाइस खोजने के लिए स्कैन पर टैप करें', + 'weekMon': 'सोम', + 'weekTue': 'मंगल', + 'weekWed': 'बुध', + 'weekThu': 'गुरु', + 'weekFri': 'शुक्र', + 'weekSat': 'शनि', + 'weekSun': 'रवि', + 'agentMeeting': 'वॉयस सारांश', + 'agentMeetingDesc': 'रिकॉर्डिंग का सारांश, मेमोरी हब', + 'agentEmai': 'EMAI असिस्टेंट', + 'agentEmaiDesc': 'आवाज़ से बात करें और कुछ भी पूछें', + 'agentSimultaneous': 'लाइव अनुवाद', + 'agentSimultaneousDesc': 'बोलते समय अनुवाद', + 'agentFaceToFace': 'आमने-सामने', + 'agentFaceToFaceDesc': 'दो-तरफ़ा बातचीत अनुवाद', + 'agentCallRecording': 'कॉल रिकॉर्डिंग', + 'agentCallRecordingDesc': 'कॉल अपने आप सहेजे जाते हैं', + 'agentFieldRecording': 'फ़ील्ड रिकॉर्डिंग', + 'agentFieldRecordingDesc': 'डिवाइस या फ़ोन का माइक्रोफ़ोन', + 'agentCall': 'कॉल अनुवाद', + 'agentCallDesc': 'कॉल के दौरान रीयल-टाइम अनुवाद', + 'agentAudioVideo': 'मीडिया अनुवाद', + 'agentAudioVideoDesc': 'कैमरा फ़्रेमिंग / ऑडियो और वीडियो अनुवाद', + 'agentEnglishChat': 'इंग्लिश चैट', + 'agentEnglishChatDesc': 'इमर्सिव बोलने का अभ्यास', + 'agentEnglishStudy': 'इंग्लिश स्टडी', + 'agentEnglishStudyDesc': 'शब्दावली और व्याकरण पाठ', + 'agentKidsStory': 'बच्चों की कहानियाँ', + 'agentKidsStoryDesc': 'सोने के समय की कहानियाँ और प्रश्नोत्तर', + 'moduleRemovedDesc': 'AI पेज से हटाया गया', + 'moduleAddedDesc': 'AI पेज के नीचे जोड़ा गया', + 'calNoRecords': 'कोई प्रविष्टि नहीं', + 'calNoRecordsDay': 'कुछ भी निर्धारित नहीं', + 'calTodos': 'टू-डू', + 'calExpenses': 'खर्च', + 'calTodoCount': '@count टू-डू', + 'calExpenseCount': '@count खर्च', + 'calEntryCount': '@count प्रविष्टियाँ', + 'calItemsUnit': 'आइटम', + 'calCountUnit': 'प्रविष्टियाँ', + 'calTotalAmount': 'कुल', + 'recordPause': 'रोकें', + 'recordResume': 'जारी रखें', + 'recordDeleteConfirm': 'यह रिकॉर्डिंग हटाएँ?', + 'scopeDay': 'दिन', + 'scopeWeek': 'सप्ताह', + 'scopeMonth': 'माह', + 'allDay': 'पूरे दिन', + 'guestModeTitle': 'अतिथि मोड', + 'guestModeDesc': 'अतिथि केवल ब्राउज़ कर सकते हैं। वॉयस नोट्स, अनुवाद और रिकॉर्डिंग का उपयोग करने के लिए लॉग इन करें — आपका डेटा आपके खाते में सहेजा जाएगा।', + 'guestKeepBrowsing': 'ब्राउज़ करते रहें', + 'guestGoLogin': 'लॉग इन करें', + 'guestViewOnlyTag': 'केवल देखें', + 'autoConnectReenableHint': 'ऑटो-कनेक्ट बंद है। इसे सेटिंग्स → डिवाइस ऑटो-कनेक्ट में फिर से चालू करें।', + 'plazaEntryDesc': 'और एजेंट ब्राउज़ करें और जोड़ें', + 'agentTextTranslation': 'पाठ अनुवाद', + 'agentTextTranslationDesc': 'टाइप या पेस्ट किए गए पाठ का अनुवाद', + 'agentPictureTranslation': 'फ़ोटो अनुवाद', + 'agentPictureTranslationDesc': 'अनुवाद के लिए फ़ोटो लें या चुनें', + 'callRequiredTitle': 'कॉल आवश्यक', + 'callRequiredDesc': 'यह सुविधा केवल कॉल के दौरान काम करती है', + 'inCallBusyTitle': 'कॉल के दौरान अनुपलब्ध', + 'inCallBusyDesc': 'माइक और ऑडियो पाथ कॉल द्वारा उपयोग में हैं। कॉल समाप्त करने के बाद पुनः प्रयास करें।', + 'vipSectionTitle': 'VIP', + 'freeSectionTitle': 'मुफ़्त', + 'smartcarConnected': 'Connected', + 'smartcarConnectFailed': 'Connection failed', + 'smartcarConnecting': 'Connecting…', + 'smartcarSearching': 'Searching for nearby car diffusers…', + 'smartcarNotFound': 'No device found', + 'smartcarScanHint': 'Make sure the device is powered on and close to your phone', + 'vipRequiredTitle': 'Membership required', + 'vipRequiredDesc': 'Translation and meeting notes require a membership. Renew, or pair a device that includes one.', + 'vipRequiredGo': 'Get membership', + 'vipFromDevice': 'from device', + 'computeSummary': 'Compute: device @device · account @user', + 'computePack': 'Compute packs', + 'vipExpiringSoon': 'Membership expires in @days day(s). Renew to keep using translation and meeting notes.', + 'computeLow': 'Running low on compute. You won\'t be able to continue once it runs out.', }; diff --git a/apps/client/lib/core/translations/language/hr_hr.dart b/apps/client/lib/core/translations/language/hr_hr.dart index 65e53b26..26262c95 100644 --- a/apps/client/lib/core/translations/language/hr_hr.dart +++ b/apps/client/lib/core/translations/language/hr_hr.dart @@ -1920,4 +1920,208 @@ const Map hrHR = { 'unmarkFavorite': 'Ukloni iz favorita', 'unnamedDevice': 'Uređaj bez naziva', 'userDataParseFailed': 'Podatke korisnika nije moguće učitati', + 'catEarphone': 'AI slušalice', + 'catEarphoneDesc': 'Klasične Bluetooth / BLE slušalice', + 'catPhoneHolder': 'AI držač telefona', + 'catPhoneHolderDesc': 'Uređaji tipa držač', + 'catCarAroma': 'AI auto difuzor', + 'catCarAromaDesc': 'Difuzor mirisa za auto', + 'agreeAndContinue': 'Slažem se i nastavi', + 'memberServiceAgreement': 'Ugovor o članskim uslugama', + 'memberServiceAgreementTitle': 'Ugovor o članskim uslugama', + 'memberServiceAgreementIntro': 'Kako bismo vam pružili bolje članske usluge, pažljivo pročitajte prije pretplate: ', + 'memberServiceAgreementOutro': '. Klikom na „Slažem se i nastavi” potvrđujete da ste u cijelosti pročitali i prihvatili gore navedeni ugovor.', + 'memberAgreementRejectedTip': 'Za nastavak morate prihvatiti Ugovor o članskim uslugama.', + 'translationCallExitTitle': 'Izađi iz prijevoda poziva', + 'translationCallExitMessage': 'Odaberite što učiniti prije izlaska', + 'translationCallEnd': 'Završi prijevod poziva', + 'translationContinueInBackground': 'Nastavi prevoditi u pozadini', + 'backgroundRecordingIndicatorBody': 'Snimanje se nastavlja u pozadini. Dodirnite za povratak.', + 'backgroundTranslationIndicatorBody': 'Prijevod poziva nastavlja se u pozadini. Dodirnite za povratak.', + 'meetingRecordScreenCapturePermissionDenied': 'Za snimanje zvuka sustava potrebno je dopuštenje za snimanje zaslona. Odobrite ga i pokušajte ponovno.', + 'pleaseGrantBluetoothPermission': 'Za skeniranje odobrite Bluetooth dopuštenje', + 'startScanFailed': 'Pokretanje skeniranja nije uspjelo', + 'pleaseLoginFirst': 'Prvo se prijavite', + 'deviceUnauthorized': 'Uređaj nije autoriziran, pokušajte ponovno', + 'boundDevices': 'Povezani uređaji', + 'currentConnection': 'Trenutna veza', + 'binding': 'Povezivanje...', + 'permissionDisabled': 'Isključeno', + 'confirmDisablePermissionTitle': 'Isključi dopuštenje', + 'confirmDisablePermissionContent': 'Isključiti dopuštenje {permission}? Za ponovno uključivanje morat ćete otići u postavke sustava.', + 'confirmPermanentlyDisallowTitle': 'Prestati pitati?', + 'confirmPermanentlyDisallowContent': 'Odbili ste dopuštenje {permission}. Prestati pitati od sada? Ako odaberete „Ne dopusti”, i dalje ga možete kasnije omogućiti u postavkama sustava.', + 'notNow': 'Ne sada', + 'permanentlyDisallow': 'Ne dopusti', + 'otaBesNotSupported': 'Ove slušalice još ne podržavaju nadogradnju firmvera iz aplikacije. Obratite se podršci.', + 'tabDevice': 'Uređaji', + 'tabPlaza': 'Trg', + 'sloganHome': 'Tehnologija za pametniji život', + 'sloganAi': 'Inteligencija bez granica', + 'sloganDevice': 'Pametni hardver, spreman za budućnost', + 'sloganPlaza': 'Pametan svijet koji vas razumije', + 'aiEditHint': 'Dugo pritisnite karticu za povlačenje i razmještaj modula', + 'aiEmptyTitle': 'Još nema dodanih modula', + 'aiEmptyDesc': 'Odaberite agente koje trebate na Trgu', + 'goToPlaza': 'Idi na Trg', + 'plazaOnline': 'Dostupno', + 'plazaPlanned': 'Uskoro', + 'plazaSearchHint': 'Pretražite agente, npr. „prijevod”, „snimanje”', + 'moduleRemove': 'Ukloni', + 'moduleAdd': 'Dodaj', + 'moduleRemoved': 'Uklonjeno', + 'moduleAdded': 'Dodano', + 'goSee': 'Pogledaj', + 'comingSoonTag': 'Uskoro', + 'needDeviceTag': 'Potreban uređaj', + 'featureInDev': 'Ova je značajka u razvoju', + 'featureNotWired': 'Još nije dostupno', + 'todayLabel': 'Danas', + 'deviceConnected': 'Povezano', + 'licenseUnknown': 'Licenca nepoznata', + 'selectDevice': 'Odaberite uređaj', + 'catNoteCard': 'AI kartica za snimanje', + 'catNoteCardDesc': 'Snimač u obliku kartice', + 'catParkerPen': 'AI olovka', + 'catParkerPenDesc': 'Olovke za snimanje glasa', + 'catStylusPen': 'AI pisaljka', + 'catStylusPenDesc': 'Uređaji za rukopis / pisaljke', + 'catStudyMachine': 'AI tablet za učenje', + 'catStudyMachineDesc': 'Uređaji za učenje', + 'besAddTitle': 'Dodaj AI slušalice', + 'besConnecting': 'Povezivanje sa slušalicama…', + 'besNotFound': 'Slušalice nisu pronađene', + 'besNotFoundIosDesc': 'Najprije povežite slušalice u Postavke → Bluetooth, zatim se vratite ovdje', + 'besNotFoundAndroidDesc': 'Najprije uparite slušalice u Postavke → Bluetooth, zatim ih ovdje povežite', + 'besReadFailed': 'Čitanje nije uspjelo', + 'besConnectFailIos': 'Provjerite prikazuju li se slušalice kao Povezano u Bluetooth postavkama sustava, zatim pokušajte ponovno', + 'besConnectFailAndroid': 'Provjerite jesu li slušalice uključene, u blizini i da nisu povezane s drugim uređajem. Nakon povezivanja automatski će se ponovno spojiti.', + 'holderAddTitle': 'Dodaj AI stalak za telefon', + 'holderConnecting': 'Povezivanje s uređajem…', + 'holderSearching': 'Pretraživanje uređaja…', + 'holderNotFound': 'Nije pronađen nijedan uređaj', + 'holderPairHint': 'Provjerite je li stalak uključen i u načinu uparivanja', + 'holderRetryHint': 'Provjerite je li uređaj uključen i u blizini, zatim pokušajte ponovno', + 'autoConnectTitle': 'Automatsko povezivanje uređaja', + 'autoConnectDesc': 'Probudi aplikaciju radi ponovnog povezivanja i primanja snimki kad se uređaj uključi ili približi', + 'autoConnectConsentTitle': 'Omogući automatsko povezivanje', + 'autoConnectConsentIntro': 'Kako bi se vaš uređaj (AI slušalice / AI stalak za telefon) automatski povezao i spremao snimke kad pritisnete njegov gumb za snimanje, trebamo vaš pristanak:', + 'autoConnectConsentB1': 'Registrira se pozadinsko Bluetooth skeniranje kako bi se aplikacija probudila (automatski pokrenula) kad se otkrije vaš povezani uređaj, radi ponovnog povezivanja i primanja snimki', + 'autoConnectConsentB2': 'Nakon buđenja koristi se samo za povezivanje uređaja, snimanje i prijenos — ništa drugo, a druge aplikacije se ne pokreću', + 'autoConnectConsentB3': 'Aplikaciju možete koristiti i bez toga — samo svaki put ručno povežite uređaj', + 'autoConnectConsentB4': 'Možete ga isključiti bilo kada u Postavke → Automatsko povezivanje uređaja', + 'autoConnectAgree': 'Slažem se i omogući', + 'autoConnectLater': 'Ne sada', + 'autoConnectRequired': 'Automatsko povezivanje je isključeno', + 'autoConnectRequiredDesc': 'Omogućite automatsko povezivanje kako biste povezali ovaj uređaj', + 'recordTapToStart': 'Dodirnite gumb ispod za početak snimanja', + 'recordPaused': 'Pauzirano', + 'recordMark': 'Označi', + 'recordDeleteTitle': 'Izbriši snimku', + 'accountAndProfile': 'Račun i profil', + 'nicknameUnset': 'Nadimak nije postavljen', + 'nicknameInput': 'Unesite nadimak', + 'loginMethod': 'Način prijave', + 'manageMyLogin': 'Upravljaj prijavom', + 'logoutAction': 'Odjavi se', + 'warningLabel': 'Upozorenje:', + 'deleteAccountWarn': 'Vaš račun i sve datoteke bit će trajno izbrisani.', + 'icpFiling': 'ICP registracija', + 'logExport': 'Izvoz zapisnika', + 'logExportDesc': 'Pregledajte i izvezite zapisnike aplikacije', + 'permLoadFailed': 'Učitavanje grupa dopuštenja nije uspjelo', + 'permInsufficient': 'Dopuštenje odbijeno', + 'voiceCloneCreating': 'Stvaranje vašeg glasa…', + 'voiceCloneCreate': 'Stvori moj glas', + 'voiceCloneAgree': 'Slažem se i pročitat ću izjavu naglas', + 'voiceCloneReadPrompt': 'Pročitajte:', + 'voiceCloneFirstMonthFree': 'Prvi mjesec besplatno', + 'voiceCloneEnable': 'Omogući kloniranje glasa', + 'noMusicPlaying': 'Ništa se ne reproducira', + 'qqMusicNoSeek': 'Premotavanje nije podržano za QQ Music', + 'bleScanTitle': 'BLE skeniranje', + 'bluetoothOn': 'Bluetooth uključen', + 'bleScanning': 'Skeniranje…', + 'bleConnectedDevices': 'Povezani uređaji', + 'bleUnknownDevice': 'Nepoznat uređaj', + 'bleTapToScan': 'Dodirnite Skeniraj za traženje BLE uređaja', + 'weekMon': 'Pon', + 'weekTue': 'Uto', + 'weekWed': 'Sri', + 'weekThu': 'Čet', + 'weekFri': 'Pet', + 'weekSat': 'Sub', + 'weekSun': 'Ned', + 'agentMeeting': 'Glasovni zapisnik', + 'agentMeetingDesc': 'Sažimanje snimki, središte sjećanja', + 'agentEmai': 'EMAI asistent', + 'agentEmaiDesc': 'Razgovarajte i pitajte bilo što glasom', + 'agentSimultaneous': 'Simultani prijevod', + 'agentSimultaneousDesc': 'Prijevod dok govorite', + 'agentFaceToFace': 'Licem u lice', + 'agentFaceToFaceDesc': 'Dvosmjerni prijevod razgovora', + 'agentCallRecording': 'Snimanje poziva', + 'agentCallRecordingDesc': 'Pozivi se automatski spremaju', + 'agentFieldRecording': 'Snimanje na terenu', + 'agentFieldRecordingDesc': 'Mikrofon uređaja ili telefona', + 'agentCall': 'Prijevod poziva', + 'agentCallDesc': 'Prijevod u stvarnom vremenu tijekom poziva', + 'agentAudioVideo': 'Prijevod medija', + 'agentAudioVideoDesc': 'Kadriranje kamerom / prijevod zvuka i videa', + 'agentEnglishChat': 'Razgovor na engleskom', + 'agentEnglishChatDesc': 'Imerzivno vježbanje govora', + 'agentEnglishStudy': 'Učenje engleskog', + 'agentEnglishStudyDesc': 'Lekcije vokabulara i gramatike', + 'agentKidsStory': 'Dječje priče', + 'agentKidsStoryDesc': 'Priče za laku noć te pitanja i odgovori', + 'moduleRemovedDesc': 'uklonjeno s AI stranice', + 'moduleAddedDesc': 'dodano na dno AI stranice', + 'calNoRecords': 'Nema unosa', + 'calNoRecordsDay': 'Ništa nije zakazano', + 'calTodos': 'Zadaci', + 'calExpenses': 'Troškovi', + 'calTodoCount': '@count zadataka', + 'calExpenseCount': '@count troškova', + 'calEntryCount': '@count unosa', + 'calItemsUnit': 'stavki', + 'calCountUnit': 'unosa', + 'calTotalAmount': 'Ukupno', + 'recordPause': 'Pauziraj', + 'recordResume': 'Nastavi', + 'recordDeleteConfirm': 'Izbrisati ovu snimku?', + 'scopeDay': 'Dan', + 'scopeWeek': 'Tjedan', + 'scopeMonth': 'Mjesec', + 'allDay': 'Cijeli dan', + 'guestModeTitle': 'Način gosta', + 'guestModeDesc': 'Gosti mogu samo pregledavati. Prijavite se za korištenje glasovnih bilješki, prijevoda i snimanja — vaši će podaci biti spremljeni na vaš račun.', + 'guestKeepBrowsing': 'Nastavi pregledavati', + 'guestGoLogin': 'Prijavi se', + 'guestViewOnlyTag': 'Samo pregled', + 'autoConnectReenableHint': 'Automatsko povezivanje je isključeno. Ponovno ga uključite u Postavke → Automatsko povezivanje uređaja.', + 'plazaEntryDesc': 'Pregledajte i dodajte više agenata', + 'agentTextTranslation': 'Prijevod teksta', + 'agentTextTranslationDesc': 'Prevedite upisani ili zalijepljeni tekst', + 'agentPictureTranslation': 'Prijevod fotografija', + 'agentPictureTranslationDesc': 'Snimite ili odaberite fotografiju za prijevod', + 'callRequiredTitle': 'Potreban poziv', + 'callRequiredDesc': 'Ova značajka radi samo tijekom poziva', + 'inCallBusyTitle': 'Nedostupno tijekom poziva', + 'inCallBusyDesc': 'Mikrofon i audio put koristi poziv. Pokušajte ponovno nakon prekida poziva.', + 'vipSectionTitle': 'VIP', + 'freeSectionTitle': 'Besplatno', + 'smartcarConnected': 'Connected', + 'smartcarConnectFailed': 'Connection failed', + 'smartcarConnecting': 'Connecting…', + 'smartcarSearching': 'Searching for nearby car diffusers…', + 'smartcarNotFound': 'No device found', + 'smartcarScanHint': 'Make sure the device is powered on and close to your phone', + 'vipRequiredTitle': 'Membership required', + 'vipRequiredDesc': 'Translation and meeting notes require a membership. Renew, or pair a device that includes one.', + 'vipRequiredGo': 'Get membership', + 'vipFromDevice': 'from device', + 'computeSummary': 'Compute: device @device · account @user', + 'computePack': 'Compute packs', + 'vipExpiringSoon': 'Membership expires in @days day(s). Renew to keep using translation and meeting notes.', + 'computeLow': 'Running low on compute. You won\'t be able to continue once it runs out.', }; diff --git a/apps/client/lib/core/translations/language/hu_hu.dart b/apps/client/lib/core/translations/language/hu_hu.dart index 4a0a07cc..e7233371 100644 --- a/apps/client/lib/core/translations/language/hu_hu.dart +++ b/apps/client/lib/core/translations/language/hu_hu.dart @@ -1923,4 +1923,208 @@ const Map huHU = { 'unmarkFavorite': 'Eltávolítás a kedvencekből', 'unnamedDevice': 'Névtelen eszköz', 'userDataParseFailed': 'A felhasználói adatok feldolgozása sikertelen', + 'catEarphone': 'AI fülhallgató', + 'catEarphoneDesc': 'Klasszikus Bluetooth / BLE fülhallgató', + 'catPhoneHolder': 'AI telefontartó', + 'catPhoneHolderDesc': 'Tartó eszközök', + 'catCarAroma': 'AI autóillatosító', + 'catCarAromaDesc': 'Autós illatpárologtató', + 'agreeAndContinue': 'Elfogadom és folytatom', + 'memberServiceAgreement': 'Tagsági szolgáltatási szerződés', + 'memberServiceAgreementTitle': 'Tagsági szolgáltatási szerződés', + 'memberServiceAgreementIntro': 'A jobb tagsági szolgáltatás érdekében kérjük, előfizetés előtt figyelmesen olvassa el: ', + 'memberServiceAgreementOutro': '. Az „Elfogadom és folytatom” gombra koppintva megerősíti, hogy a fenti szerződést teljes egészében elolvasta és elfogadja.', + 'memberAgreementRejectedTip': 'A folytatáshoz el kell fogadnia a Tagsági szolgáltatási szerződést.', + 'translationCallExitTitle': 'Kilépés a hívásfordításból', + 'translationCallExitMessage': 'Válassza ki, mi történjen kilépés előtt', + 'translationCallEnd': 'Hívásfordítás befejezése', + 'translationContinueInBackground': 'Fordítás folytatása a háttérben', + 'backgroundRecordingIndicatorBody': 'A felvétel a háttérben folytatódik. Koppintson a visszatéréshez.', + 'backgroundTranslationIndicatorBody': 'A hívásfordítás a háttérben folytatódik. Koppintson a visszatéréshez.', + 'meetingRecordScreenCapturePermissionDenied': 'A rendszerhang rögzítéséhez képernyőfelvételi engedély szükséges. Kérjük, engedélyezze, majd próbálja újra.', + 'pleaseGrantBluetoothPermission': 'A kereséshez adjon Bluetooth engedélyt', + 'startScanFailed': 'A keresés indítása sikertelen', + 'pleaseLoginFirst': 'Kérjük, először jelentkezzen be', + 'deviceUnauthorized': 'Az eszköz nincs engedélyezve, próbálja újra', + 'boundDevices': 'Párosított eszközök', + 'currentConnection': 'Jelenlegi kapcsolat', + 'binding': 'Párosítás...', + 'permissionDisabled': 'Kikapcsolva', + 'confirmDisablePermissionTitle': 'Engedély kikapcsolása', + 'confirmDisablePermissionContent': 'Kikapcsolja a(z) {permission} engedélyt? Az újbóli bekapcsoláshoz a rendszerbeállításokba kell lépnie.', + 'confirmPermanentlyDisallowTitle': 'Ne kérdezzük többé?', + 'confirmPermanentlyDisallowContent': 'Megtagadta a(z) {permission} engedélyt. Ne kérdezzük többé? Ha a „Nem engedélyezem” lehetőséget választja, később a rendszerbeállításokban továbbra is bekapcsolhatja.', + 'notNow': 'Most nem', + 'permanentlyDisallow': 'Nem engedélyezem', + 'otaBesNotSupported': 'Ez a fülhallgató még nem támogatja az alkalmazáson belüli firmware-frissítést. Kérjük, forduljon az ügyfélszolgálathoz.', + 'tabDevice': 'Eszközök', + 'tabPlaza': 'Plaza', + 'sloganHome': 'Technológia az okosabb életért', + 'sloganAi': 'Határtalan intelligencia', + 'sloganDevice': 'Okos hardver, a jövőre készen', + 'sloganPlaza': 'Egy okos világ, amely megért', + 'aiEditHint': 'Tartson lenyomva egy kártyát a modulok húzásához és átrendezéséhez', + 'aiEmptyTitle': 'Még nincs hozzáadott modul', + 'aiEmptyDesc': 'Válassza ki a szükséges ügynököket a Plazában', + 'goToPlaza': 'Ugrás a Plazába', + 'plazaOnline': 'Elérhető', + 'plazaPlanned': 'Hamarosan', + 'plazaSearchHint': 'Ügynökök keresése, pl. „fordítás”, „felvétel”', + 'moduleRemove': 'Eltávolítás', + 'moduleAdd': 'Hozzáadás', + 'moduleRemoved': 'Eltávolítva', + 'moduleAdded': 'Hozzáadva', + 'goSee': 'Megtekintés', + 'comingSoonTag': 'Hamarosan', + 'needDeviceTag': 'Eszköz szükséges', + 'featureInDev': 'Ez a funkció fejlesztés alatt áll', + 'featureNotWired': 'Még nem érhető el', + 'todayLabel': 'Ma', + 'deviceConnected': 'Csatlakoztatva', + 'licenseUnknown': 'Ismeretlen licenc', + 'selectDevice': 'Válasszon eszközt', + 'catNoteCard': 'AI diktafonkártya', + 'catNoteCardDesc': 'Kártya alakú diktafon', + 'catParkerPen': 'AI toll', + 'catParkerPenDesc': 'Diktafon tollak', + 'catStylusPen': 'AI érintőtoll', + 'catStylusPenDesc': 'Kézírás / érintőtoll eszközök', + 'catStudyMachine': 'AI tanulótablet', + 'catStudyMachineDesc': 'Tanulóeszközök', + 'besAddTitle': 'AI fülhallgató hozzáadása', + 'besConnecting': 'Csatlakozás a fülhallgatóhoz…', + 'besNotFound': 'Fülhallgató nem található', + 'besNotFoundIosDesc': 'Először csatlakoztassa a fülhallgatót a Beállítások → Bluetooth menüben, majd térjen vissza ide', + 'besNotFoundAndroidDesc': 'Először párosítsa a fülhallgatót a Beállítások → Bluetooth menüben, majd csatlakozzon itt', + 'besReadFailed': 'Olvasás sikertelen', + 'besConnectFailIos': 'Győződjön meg róla, hogy a fülhallgató Csatlakoztatva állapotban látszik a rendszer Bluetooth-beállításaiban, majd próbálja újra', + 'besConnectFailAndroid': 'Győződjön meg róla, hogy a fülhallgató be van kapcsolva, a közelben van, és nincs másik eszközhöz csatlakoztatva. A kapcsolat létrejötte után automatikusan újracsatlakozik.', + 'holderAddTitle': 'AI telefontartó hozzáadása', + 'holderConnecting': 'Csatlakozás az eszközhöz…', + 'holderSearching': 'Eszközök keresése…', + 'holderNotFound': 'Nem található eszköz', + 'holderPairHint': 'Győződjön meg róla, hogy a tartó be van kapcsolva és párosítási módban van', + 'holderRetryHint': 'Győződjön meg róla, hogy az eszköz be van kapcsolva és a közelben van, majd próbálja újra', + 'autoConnectTitle': 'Eszközök automatikus csatlakoztatása', + 'autoConnectDesc': 'Felébreszti az alkalmazást az újracsatlakozáshoz és a felvételek fogadásához, amikor az eszköz bekapcsol vagy a közelbe kerül', + 'autoConnectConsentTitle': 'Automatikus csatlakozás bekapcsolása', + 'autoConnectConsentIntro': 'Ahhoz, hogy eszköze (AI fülhallgató / AI telefontartó) automatikusan csatlakozzon, és a felvételgomb megnyomásakor mentse a felvételeket, az Ön hozzájárulására van szükségünk:', + 'autoConnectConsentB1': 'Háttérben futó Bluetooth-keresés regisztrálása, hogy az alkalmazás felébredjen (automatikusan elinduljon) a párosított eszköz észlelésekor az újracsatlakozáshoz és a felvételek fogadásához', + 'autoConnectConsentB2': 'Felébresztés után csak az eszköz csatlakoztatására, felvételre és feltöltésre használjuk — semmi másra, és más alkalmazás nem indul el', + 'autoConnectConsentB3': 'Enélkül is használhatja az alkalmazást — csak minden alkalommal kézzel kell csatlakoztatnia az eszközt', + 'autoConnectConsentB4': 'Bármikor kikapcsolhatja a Beállítások → Eszközök automatikus csatlakoztatása menüben', + 'autoConnectAgree': 'Elfogadom és bekapcsolom', + 'autoConnectLater': 'Most nem', + 'autoConnectRequired': 'Az automatikus csatlakozás ki van kapcsolva', + 'autoConnectRequiredDesc': 'Az eszköz párosításához kapcsolja be az automatikus csatlakozást', + 'recordTapToStart': 'A felvétel indításához koppintson az alábbi gombra', + 'recordPaused': 'Szüneteltetve', + 'recordMark': 'Megjelölés', + 'recordDeleteTitle': 'Felvétel törlése', + 'accountAndProfile': 'Fiók és profil', + 'nicknameUnset': 'Nincs beállított becenév', + 'nicknameInput': 'Adjon meg egy becenevet', + 'loginMethod': 'Bejelentkezési mód', + 'manageMyLogin': 'Bejelentkezés kezelése', + 'logoutAction': 'Kijelentkezés', + 'warningLabel': 'Figyelmeztetés:', + 'deleteAccountWarn': 'Fiókja és minden fájlja véglegesen törlődik.', + 'icpFiling': 'ICP-nyilvántartás', + 'logExport': 'Naplók exportálása', + 'logExportDesc': 'Az alkalmazás naplóinak megtekintése és exportálása', + 'permLoadFailed': 'Az engedélycsoportok betöltése sikertelen', + 'permInsufficient': 'Engedély megtagadva', + 'voiceCloneCreating': 'Hangjának létrehozása…', + 'voiceCloneCreate': 'Saját hang létrehozása', + 'voiceCloneAgree': 'Elfogadom és felolvasom a nyilatkozatot', + 'voiceCloneReadPrompt': 'Kérjük, olvassa fel:', + 'voiceCloneFirstMonthFree': 'Az első hónap ingyenes', + 'voiceCloneEnable': 'Hangklónozás bekapcsolása', + 'noMusicPlaying': 'Nincs lejátszás', + 'qqMusicNoSeek': 'A QQ Music nem támogatja a tekerést', + 'bleScanTitle': 'BLE keresés', + 'bluetoothOn': 'Bluetooth bekapcsolva', + 'bleScanning': 'Keresés…', + 'bleConnectedDevices': 'Csatlakoztatott eszközök', + 'bleUnknownDevice': 'Ismeretlen eszköz', + 'bleTapToScan': 'Koppintson a keresésre a BLE eszközök megtalálásához', + 'weekMon': 'H', + 'weekTue': 'K', + 'weekWed': 'Sze', + 'weekThu': 'Cs', + 'weekFri': 'P', + 'weekSat': 'Szo', + 'weekSun': 'V', + 'agentMeeting': 'Hangjegyzőkönyv', + 'agentMeetingDesc': 'Felvételek összefoglalása, emlékközpont', + 'agentEmai': 'EMAI asszisztens', + 'agentEmaiDesc': 'Beszélgessen és kérdezzen bármit hanggal', + 'agentSimultaneous': 'Élő fordítás', + 'agentSimultaneousDesc': 'Fordítás beszéd közben', + 'agentFaceToFace': 'Szemtől szemben', + 'agentFaceToFaceDesc': 'Kétirányú beszélgetésfordítás', + 'agentCallRecording': 'Hívásrögzítés', + 'agentCallRecordingDesc': 'A hívások automatikusan mentődnek', + 'agentFieldRecording': 'Helyszíni felvétel', + 'agentFieldRecordingDesc': 'Eszköz- vagy telefonmikrofon', + 'agentCall': 'Hívásfordítás', + 'agentCallDesc': 'Valós idejű fordítás hívás közben', + 'agentAudioVideo': 'Médiafordítás', + 'agentAudioVideoDesc': 'Kamerakép / hang- és videófordítás', + 'agentEnglishChat': 'Angol csevegés', + 'agentEnglishChatDesc': 'Magával ragadó beszédgyakorlás', + 'agentEnglishStudy': 'Angoltanulás', + 'agentEnglishStudyDesc': 'Szókincs- és nyelvtanleckék', + 'agentKidsStory': 'Gyerekmesék', + 'agentKidsStoryDesc': 'Esti mesék és kérdés-válasz', + 'moduleRemovedDesc': 'eltávolítva az AI oldalról', + 'moduleAddedDesc': 'hozzáadva az AI oldal aljához', + 'calNoRecords': 'Nincs bejegyzés', + 'calNoRecordsDay': 'Nincs betervezve semmi', + 'calTodos': 'Teendők', + 'calExpenses': 'Kiadások', + 'calTodoCount': '@count teendő', + 'calExpenseCount': '@count kiadás', + 'calEntryCount': '@count bejegyzés', + 'calItemsUnit': 'elem', + 'calCountUnit': 'bejegyzés', + 'calTotalAmount': 'Összesen', + 'recordPause': 'Szünet', + 'recordResume': 'Folytatás', + 'recordDeleteConfirm': 'Törli ezt a felvételt?', + 'scopeDay': 'Nap', + 'scopeWeek': 'Hét', + 'scopeMonth': 'Hónap', + 'allDay': 'Egész nap', + 'guestModeTitle': 'Vendég mód', + 'guestModeDesc': 'A vendégek csak böngészhetnek. Jelentkezzen be a hangjegyzetek, a fordítás és a felvétel használatához — adatai a fiókjába kerülnek mentésre.', + 'guestKeepBrowsing': 'Böngészés folytatása', + 'guestGoLogin': 'Bejelentkezés', + 'guestViewOnlyTag': 'Csak megtekintés', + 'autoConnectReenableHint': 'Az automatikus csatlakozás ki van kapcsolva. Kapcsolja vissza a Beállítások → Eszközök automatikus csatlakoztatása menüben.', + 'plazaEntryDesc': 'Böngésszen és adjon hozzá további ügynököket', + 'agentTextTranslation': 'Szövegfordítás', + 'agentTextTranslationDesc': 'Begépelt vagy beillesztett szöveg fordítása', + 'agentPictureTranslation': 'Fotófordítás', + 'agentPictureTranslationDesc': 'Készítsen vagy válasszon fotót a fordításhoz', + 'callRequiredTitle': 'Hívás szükséges', + 'callRequiredDesc': 'Ez a funkció csak hívás közben működik', + 'inCallBusyTitle': 'Hívás közben nem érhető el', + 'inCallBusyDesc': 'A mikrofont és a hangútvonalat a hívás használja. Próbálja újra a hívás befejezése után.', + 'vipSectionTitle': 'VIP', + 'freeSectionTitle': 'Ingyenes', + 'smartcarConnected': 'Connected', + 'smartcarConnectFailed': 'Connection failed', + 'smartcarConnecting': 'Connecting…', + 'smartcarSearching': 'Searching for nearby car diffusers…', + 'smartcarNotFound': 'No device found', + 'smartcarScanHint': 'Make sure the device is powered on and close to your phone', + 'vipRequiredTitle': 'Membership required', + 'vipRequiredDesc': 'Translation and meeting notes require a membership. Renew, or pair a device that includes one.', + 'vipRequiredGo': 'Get membership', + 'vipFromDevice': 'from device', + 'computeSummary': 'Compute: device @device · account @user', + 'computePack': 'Compute packs', + 'vipExpiringSoon': 'Membership expires in @days day(s). Renew to keep using translation and meeting notes.', + 'computeLow': 'Running low on compute. You won\'t be able to continue once it runs out.', }; diff --git a/apps/client/lib/core/translations/language/id_id.dart b/apps/client/lib/core/translations/language/id_id.dart index f542c541..7d0a0c85 100644 --- a/apps/client/lib/core/translations/language/id_id.dart +++ b/apps/client/lib/core/translations/language/id_id.dart @@ -1904,4 +1904,208 @@ const Map idID = { 'unmarkFavorite': 'Hapus dari favorit', 'unnamedDevice': 'Perangkat tanpa nama', 'userDataParseFailed': 'Gagal membaca data pengguna', + 'catEarphone': 'Earbud AI', + 'catEarphoneDesc': 'Earbud Bluetooth klasik / BLE', + 'catPhoneHolder': 'Dudukan Ponsel AI', + 'catPhoneHolderDesc': 'Perangkat dudukan', + 'catCarAroma': 'Pengharum Mobil AI', + 'catCarAromaDesc': 'Diffuser aroma mobil', + 'agreeAndContinue': 'Setuju dan Lanjutkan', + 'memberServiceAgreement': 'Perjanjian Layanan Keanggotaan', + 'memberServiceAgreementTitle': 'Perjanjian Layanan Keanggotaan', + 'memberServiceAgreementIntro': 'Untuk memberikan layanan keanggotaan yang lebih baik, harap baca dengan saksama sebelum berlangganan: ', + 'memberServiceAgreementOutro': '. Dengan mengetuk "Setuju dan Lanjutkan", Anda menyatakan telah membaca sepenuhnya dan menerima perjanjian di atas.', + 'memberAgreementRejectedTip': 'Anda harus menyetujui Perjanjian Layanan Keanggotaan untuk melanjutkan.', + 'translationCallExitTitle': 'Keluar dari Terjemahan Panggilan', + 'translationCallExitMessage': 'Pilih tindakan sebelum keluar', + 'translationCallEnd': 'Akhiri terjemahan panggilan', + 'translationContinueInBackground': 'Lanjutkan terjemahan di latar belakang', + 'backgroundRecordingIndicatorBody': 'Perekaman berlanjut di latar belakang. Ketuk untuk kembali.', + 'backgroundTranslationIndicatorBody': 'Terjemahan panggilan berlanjut di latar belakang. Ketuk untuk kembali.', + 'meetingRecordScreenCapturePermissionDenied': 'Izin perekaman layar diperlukan untuk merekam audio sistem. Berikan izin lalu coba lagi.', + 'pleaseGrantBluetoothPermission': 'Berikan izin Bluetooth untuk memindai', + 'startScanFailed': 'Gagal memulai pemindaian', + 'pleaseLoginFirst': 'Silakan masuk terlebih dahulu', + 'deviceUnauthorized': 'Perangkat tidak berwenang, silakan coba lagi', + 'boundDevices': 'Perangkat Terikat', + 'currentConnection': 'Koneksi Saat Ini', + 'binding': 'Mengikat...', + 'permissionDisabled': 'Nonaktif', + 'confirmDisablePermissionTitle': 'Matikan izin', + 'confirmDisablePermissionContent': 'Matikan izin {permission}? Anda perlu membuka pengaturan sistem untuk mengaktifkannya kembali.', + 'confirmPermanentlyDisallowTitle': 'Berhenti bertanya?', + 'confirmPermanentlyDisallowContent': 'Anda menolak izin {permission}. Berhenti bertanya mulai sekarang? Jika memilih "Jangan izinkan", Anda masih bisa mengaktifkannya nanti di pengaturan sistem.', + 'notNow': 'Nanti saja', + 'permanentlyDisallow': 'Jangan izinkan', + 'otaBesNotSupported': 'Headset ini belum mendukung pembaruan firmware dalam aplikasi. Silakan hubungi dukungan.', + 'tabDevice': 'Perangkat', + 'tabPlaza': 'Plaza', + 'sloganHome': 'Teknologi untuk hidup lebih cerdas', + 'sloganAi': 'Kecerdasan tanpa batas', + 'sloganDevice': 'Perangkat keras pintar, siap masa depan', + 'sloganPlaza': 'Dunia cerdas yang memahami Anda', + 'aiEditHint': 'Tekan lama kartu untuk menyeret dan menyusun ulang modul', + 'aiEmptyTitle': 'Belum ada modul yang ditambahkan', + 'aiEmptyDesc': 'Pilih agen yang Anda butuhkan di Plaza', + 'goToPlaza': 'Buka Plaza', + 'plazaOnline': 'Tersedia', + 'plazaPlanned': 'Segera hadir', + 'plazaSearchHint': 'Cari agen, mis. “terjemah”, “rekam”', + 'moduleRemove': 'Hapus', + 'moduleAdd': 'Tambah', + 'moduleRemoved': 'Dihapus', + 'moduleAdded': 'Ditambahkan', + 'goSee': 'Lihat', + 'comingSoonTag': 'Segera hadir', + 'needDeviceTag': 'Perlu perangkat', + 'featureInDev': 'Fitur ini sedang dalam pengembangan', + 'featureNotWired': 'Belum tersedia', + 'todayLabel': 'Hari ini', + 'deviceConnected': 'Terhubung', + 'licenseUnknown': 'Lisensi tidak diketahui', + 'selectDevice': 'Pilih perangkat', + 'catNoteCard': 'Kartu Perekam AI', + 'catNoteCardDesc': 'Perekam berbentuk kartu', + 'catParkerPen': 'Pena AI', + 'catParkerPenDesc': 'Pena perekam suara', + 'catStylusPen': 'Stylus AI', + 'catStylusPenDesc': 'Perangkat tulisan tangan / stylus', + 'catStudyMachine': 'Tablet Belajar AI', + 'catStudyMachineDesc': 'Perangkat belajar', + 'besAddTitle': 'Tambah Earbud AI', + 'besConnecting': 'Menghubungkan ke earbud…', + 'besNotFound': 'Earbud tidak ditemukan', + 'besNotFoundIosDesc': 'Hubungkan earbud di Pengaturan → Bluetooth terlebih dahulu, lalu kembali ke sini', + 'besNotFoundAndroidDesc': 'Sandingkan earbud di Pengaturan → Bluetooth terlebih dahulu, lalu hubungkan di sini', + 'besReadFailed': 'Gagal membaca', + 'besConnectFailIos': 'Pastikan earbud berstatus Terhubung di Bluetooth sistem, lalu coba lagi', + 'besConnectFailAndroid': 'Pastikan earbud menyala, berada di dekat Anda, dan tidak terhubung ke perangkat lain. Earbud akan tersambung kembali secara otomatis setelah terhubung.', + 'holderAddTitle': 'Tambah Penyangga Ponsel AI', + 'holderConnecting': 'Menghubungkan ke perangkat…', + 'holderSearching': 'Mencari perangkat…', + 'holderNotFound': 'Tidak ada perangkat ditemukan', + 'holderPairHint': 'Pastikan penyangga menyala dan dalam mode penyandingan', + 'holderRetryHint': 'Pastikan perangkat menyala dan berada di dekat Anda, lalu coba lagi', + 'autoConnectTitle': 'Sambung otomatis perangkat', + 'autoConnectDesc': 'Bangunkan aplikasi untuk menyambung kembali dan menerima rekaman saat perangkat Anda menyala atau berada di dekat', + 'autoConnectConsentTitle': 'Aktifkan sambung otomatis', + 'autoConnectConsentIntro': 'Agar perangkat Anda (Earbud AI / Penyangga Ponsel AI) dapat tersambung otomatis dan menyimpan rekaman saat Anda menekan tombol rekamnya, kami memerlukan persetujuan Anda:', + 'autoConnectConsentB1': 'Mendaftarkan pemindaian Bluetooth latar belakang agar aplikasi dibangunkan (dijalankan otomatis) saat perangkat terikat Anda terdeteksi, untuk menyambung kembali dan menerima rekaman', + 'autoConnectConsentB2': 'Setelah dibangunkan, hanya digunakan untuk koneksi perangkat, perekaman, dan pengunggahan — tidak untuk hal lain, dan tidak ada aplikasi lain yang dijalankan', + 'autoConnectConsentB3': 'Anda tetap dapat menggunakan aplikasi tanpanya — cukup hubungkan perangkat secara manual setiap kali', + 'autoConnectConsentB4': 'Anda dapat menonaktifkannya kapan saja di Pengaturan → Sambung otomatis perangkat', + 'autoConnectAgree': 'Setuju dan aktifkan', + 'autoConnectLater': 'Nanti saja', + 'autoConnectRequired': 'Sambung otomatis nonaktif', + 'autoConnectRequiredDesc': 'Aktifkan sambung otomatis untuk mengikat perangkat ini', + 'recordTapToStart': 'Ketuk tombol di bawah untuk mulai merekam', + 'recordPaused': 'Dijeda', + 'recordMark': 'Tandai', + 'recordDeleteTitle': 'Hapus rekaman', + 'accountAndProfile': 'Akun & profil', + 'nicknameUnset': 'Nama panggilan belum diatur', + 'nicknameInput': 'Masukkan nama panggilan', + 'loginMethod': 'Metode masuk', + 'manageMyLogin': 'Kelola metode masuk', + 'logoutAction': 'Keluar', + 'warningLabel': 'Peringatan:', + 'deleteAccountWarn': 'Akun Anda dan semua file akan dihapus secara permanen.', + 'icpFiling': 'Pendaftaran ICP', + 'logExport': 'Ekspor log', + 'logExportDesc': 'Lihat dan ekspor log aplikasi', + 'permLoadFailed': 'Gagal memuat grup izin', + 'permInsufficient': 'Izin ditolak', + 'voiceCloneCreating': 'Membuat suara Anda…', + 'voiceCloneCreate': 'Buat suara saya', + 'voiceCloneAgree': 'Setuju dan bacakan pernyataan dengan lantang', + 'voiceCloneReadPrompt': 'Silakan baca:', + 'voiceCloneFirstMonthFree': 'Gratis bulan pertama', + 'voiceCloneEnable': 'Aktifkan kloning suara', + 'noMusicPlaying': 'Tidak ada yang diputar', + 'qqMusicNoSeek': 'Geser posisi tidak didukung untuk QQ Music', + 'bleScanTitle': 'Pemindaian BLE', + 'bluetoothOn': 'Bluetooth aktif', + 'bleScanning': 'Memindai…', + 'bleConnectedDevices': 'Perangkat terhubung', + 'bleUnknownDevice': 'Perangkat tidak dikenal', + 'bleTapToScan': 'Ketuk pindai untuk mencari perangkat BLE', + 'weekMon': 'Sen', + 'weekTue': 'Sel', + 'weekWed': 'Rab', + 'weekThu': 'Kam', + 'weekFri': 'Jum', + 'weekSat': 'Sab', + 'weekSun': 'Min', + 'agentMeeting': 'Notulen Suara', + 'agentMeetingDesc': 'Ringkas rekaman, pusat memori', + 'agentEmai': 'Asisten EMAI', + 'agentEmaiDesc': 'Mengobrol dan bertanya apa saja lewat suara', + 'agentSimultaneous': 'Terjemahan Langsung', + 'agentSimultaneousDesc': 'Terjemahkan sambil bicara', + 'agentFaceToFace': 'Tatap Muka', + 'agentFaceToFaceDesc': 'Terjemahan percakapan dua arah', + 'agentCallRecording': 'Rekaman Panggilan', + 'agentCallRecordingDesc': 'Panggilan tersimpan otomatis', + 'agentFieldRecording': 'Rekaman Lapangan', + 'agentFieldRecordingDesc': 'Mikrofon perangkat atau ponsel', + 'agentCall': 'Terjemahan Panggilan', + 'agentCallDesc': 'Terjemahan waktu nyata selama panggilan', + 'agentAudioVideo': 'Terjemahan Media', + 'agentAudioVideoDesc': 'Bidikan kamera / terjemahan audio & video', + 'agentEnglishChat': 'Obrolan Bahasa Inggris', + 'agentEnglishChatDesc': 'Latihan bicara imersif', + 'agentEnglishStudy': 'Belajar Bahasa Inggris', + 'agentEnglishStudyDesc': 'Pelajaran kosakata dan tata bahasa', + 'agentKidsStory': 'Cerita Anak', + 'agentKidsStoryDesc': 'Dongeng pengantar tidur dan tanya jawab', + 'moduleRemovedDesc': 'dihapus dari halaman AI', + 'moduleAddedDesc': 'ditambahkan ke bagian bawah halaman AI', + 'calNoRecords': 'Tidak ada entri', + 'calNoRecordsDay': 'Tidak ada jadwal', + 'calTodos': 'Tugas', + 'calExpenses': 'Pengeluaran', + 'calTodoCount': '@count tugas', + 'calExpenseCount': '@count pengeluaran', + 'calEntryCount': '@count entri', + 'calItemsUnit': 'item', + 'calCountUnit': 'entri', + 'calTotalAmount': 'Total', + 'recordPause': 'Jeda', + 'recordResume': 'Lanjutkan', + 'recordDeleteConfirm': 'Hapus rekaman ini?', + 'scopeDay': 'Hari', + 'scopeWeek': 'Minggu', + 'scopeMonth': 'Bulan', + 'allDay': 'Sepanjang hari', + 'guestModeTitle': 'Mode tamu', + 'guestModeDesc': 'Tamu hanya dapat menjelajah. Masuk untuk menggunakan catatan suara, terjemahan, dan perekaman — data Anda akan disimpan ke akun Anda.', + 'guestKeepBrowsing': 'Lanjut menjelajah', + 'guestGoLogin': 'Masuk', + 'guestViewOnlyTag': 'Hanya lihat', + 'autoConnectReenableHint': 'Sambung otomatis nonaktif. Aktifkan kembali di Pengaturan → Sambung otomatis perangkat.', + 'plazaEntryDesc': 'Jelajahi dan tambahkan lebih banyak agen', + 'agentTextTranslation': 'Terjemahan Teks', + 'agentTextTranslationDesc': 'Terjemahkan teks yang diketik atau ditempel', + 'agentPictureTranslation': 'Terjemahan Foto', + 'agentPictureTranslationDesc': 'Ambil atau pilih foto untuk diterjemahkan', + 'callRequiredTitle': 'Perlu panggilan', + 'callRequiredDesc': 'Fitur ini hanya berfungsi selama panggilan', + 'inCallBusyTitle': 'Tidak tersedia selama panggilan', + 'inCallBusyDesc': 'Mikrofon dan jalur audio sedang digunakan oleh panggilan. Coba lagi setelah menutup panggilan.', + 'vipSectionTitle': 'VIP', + 'freeSectionTitle': 'Gratis', + 'smartcarConnected': 'Connected', + 'smartcarConnectFailed': 'Connection failed', + 'smartcarConnecting': 'Connecting…', + 'smartcarSearching': 'Searching for nearby car diffusers…', + 'smartcarNotFound': 'No device found', + 'smartcarScanHint': 'Make sure the device is powered on and close to your phone', + 'vipRequiredTitle': 'Membership required', + 'vipRequiredDesc': 'Translation and meeting notes require a membership. Renew, or pair a device that includes one.', + 'vipRequiredGo': 'Get membership', + 'vipFromDevice': 'from device', + 'computeSummary': 'Compute: device @device · account @user', + 'computePack': 'Compute packs', + 'vipExpiringSoon': 'Membership expires in @days day(s). Renew to keep using translation and meeting notes.', + 'computeLow': 'Running low on compute. You won\'t be able to continue once it runs out.', }; diff --git a/apps/client/lib/core/translations/language/is_is.dart b/apps/client/lib/core/translations/language/is_is.dart index 4d32ea7d..5676fdeb 100644 --- a/apps/client/lib/core/translations/language/is_is.dart +++ b/apps/client/lib/core/translations/language/is_is.dart @@ -1901,4 +1901,208 @@ const Map isIS = { 'unmarkFavorite': 'Fjarlægja úr eftirlæti', 'unnamedDevice': 'Ónefnt tæki', 'userDataParseFailed': 'Ekki tókst að lesa notandagögn', + 'catEarphone': 'AI eyrnatól', + 'catEarphoneDesc': 'Klassísk Bluetooth / BLE eyrnatól', + 'catPhoneHolder': 'AI símastandur', + 'catPhoneHolderDesc': 'Standtæki', + 'catCarAroma': 'AI bílailmur', + 'catCarAromaDesc': 'Ilmdreifir fyrir bíl', + 'agreeAndContinue': 'Samþykkja og halda áfram', + 'memberServiceAgreement': 'Þjónustusamningur meðlima', + 'memberServiceAgreementTitle': 'Þjónustusamningur meðlima', + 'memberServiceAgreementIntro': 'Til að veita þér betri meðlimaþjónustu, vinsamlegast lestu vandlega áður en þú gerist áskrifandi: ', + 'memberServiceAgreementOutro': '. Með því að smella á „Samþykkja og halda áfram“ staðfestir þú að þú hafir lesið samninginn hér að ofan að fullu og samþykkir hann.', + 'memberAgreementRejectedTip': 'Þú verður að samþykkja þjónustusamning meðlima til að halda áfram.', + 'translationCallExitTitle': 'Hætta símtalsþýðingu', + 'translationCallExitMessage': 'Veldu hvað á að gera áður en þú ferð', + 'translationCallEnd': 'Ljúka símtalsþýðingu', + 'translationContinueInBackground': 'Halda áfram að þýða í bakgrunni', + 'backgroundRecordingIndicatorBody': 'Upptaka heldur áfram í bakgrunni. Ýttu til að fara til baka.', + 'backgroundTranslationIndicatorBody': 'Símtalsþýðing heldur áfram í bakgrunni. Ýttu til að fara til baka.', + 'meetingRecordScreenCapturePermissionDenied': 'Heimild til skjáupptöku er nauðsynleg til að taka upp kerfishljóð. Veittu hana og reyndu aftur.', + 'pleaseGrantBluetoothPermission': 'Veittu Bluetooth-heimild til að leita', + 'startScanFailed': 'Ekki tókst að hefja leit', + 'pleaseLoginFirst': 'Skráðu þig fyrst inn', + 'deviceUnauthorized': 'Tæki ekki heimilað, reyndu aftur', + 'boundDevices': 'Tengd tæki', + 'currentConnection': 'Núverandi tenging', + 'binding': 'Tengi...', + 'permissionDisabled': 'Óvirkt', + 'confirmDisablePermissionTitle': 'Slökkva á heimild', + 'confirmDisablePermissionContent': 'Slökkva á {permission} heimild? Þú þarft að fara í kerfisstillingar til að virkja hana aftur.', + 'confirmPermanentlyDisallowTitle': 'Hætta að spyrja?', + 'confirmPermanentlyDisallowContent': 'Þú hafnaðir {permission} heimild. Hætta að spyrja héðan í frá? Ef þú velur „Ekki leyfa“ geturðu samt virkjað hana síðar í kerfisstillingum.', + 'notNow': 'Ekki núna', + 'permanentlyDisallow': 'Ekki leyfa', + 'otaBesNotSupported': 'Þessi heyrnartól styðja ekki enn fastbúnaðaruppfærslu í forritinu. Hafðu samband við þjónustuver.', + 'tabDevice': 'Tæki', + 'tabPlaza': 'Plaza', + 'sloganHome': 'Tækni fyrir snjallara líf', + 'sloganAi': 'Takmarkalaus greind', + 'sloganDevice': 'Snjallur vélbúnaður, tilbúinn fyrir framtíðina', + 'sloganPlaza': 'Snjall heimur sem skilur þig', + 'aiEditHint': 'Haltu spjaldi inni og dragðu til að endurraða einingum', + 'aiEmptyTitle': 'Engum einingum bætt við enn', + 'aiEmptyDesc': 'Veldu þá aðstoðarmenn sem þú þarft í Plaza', + 'goToPlaza': 'Fara í Plaza', + 'plazaOnline': 'Í boði', + 'plazaPlanned': 'Væntanlegt', + 'plazaSearchHint': 'Leita að aðstoðarmönnum, t.d. „þýða“, „taka upp“', + 'moduleRemove': 'Fjarlægja', + 'moduleAdd': 'Bæta við', + 'moduleRemoved': 'Fjarlægt', + 'moduleAdded': 'Bætt við', + 'goSee': 'Skoða', + 'comingSoonTag': 'Væntanlegt', + 'needDeviceTag': 'Tæki nauðsynlegt', + 'featureInDev': 'Þessi eiginleiki er í þróun', + 'featureNotWired': 'Ekki í boði enn', + 'todayLabel': 'Í dag', + 'deviceConnected': 'Tengt', + 'licenseUnknown': 'Leyfi óþekkt', + 'selectDevice': 'Veldu tæki', + 'catNoteCard': 'AI upptökukort', + 'catNoteCardDesc': 'Upptökutæki í kortaformi', + 'catParkerPen': 'AI penni', + 'catParkerPenDesc': 'Upptökupennar', + 'catStylusPen': 'AI snertipenni', + 'catStylusPenDesc': 'Rithandar-/snertipennatæki', + 'catStudyMachine': 'AI námsspjaldtölva', + 'catStudyMachineDesc': 'Námstæki', + 'besAddTitle': 'Bæta við AI heyrnartólum', + 'besConnecting': 'Tengist heyrnartólum…', + 'besNotFound': 'Heyrnartól fundust ekki', + 'besNotFoundIosDesc': 'Tengdu heyrnartólin fyrst í Stillingar → Bluetooth og komdu svo til baka', + 'besNotFoundAndroidDesc': 'Paraðu heyrnartólin fyrst í Stillingar → Bluetooth og tengdu þau svo hér', + 'besReadFailed': 'Lestur mistókst', + 'besConnectFailIos': 'Gakktu úr skugga um að heyrnartólin séu sýnd sem „Tengd“ í Bluetooth-stillingum kerfisins, reyndu svo aftur', + 'besConnectFailAndroid': 'Gakktu úr skugga um að kveikt sé á heyrnartólunum, þau séu nálægt og ekki tengd öðru tæki. Þau tengjast sjálfkrafa aftur eftir pörun.', + 'holderAddTitle': 'Bæta við AI símastandi', + 'holderConnecting': 'Tengist tæki…', + 'holderSearching': 'Leita að tækjum…', + 'holderNotFound': 'Engin tæki fundust', + 'holderPairHint': 'Gakktu úr skugga um að standurinn sé kveiktur og í pörunarham', + 'holderRetryHint': 'Gakktu úr skugga um að tækið sé kveikt og nálægt, reyndu svo aftur', + 'autoConnectTitle': 'Sjálfvirk tenging tækja', + 'autoConnectDesc': 'Vekur forritið til að endurtengjast og taka við upptökum þegar kveikt er á tækinu eða það kemur nálægt', + 'autoConnectConsentTitle': 'Virkja sjálfvirka tengingu', + 'autoConnectConsentIntro': 'Til að tækið þitt (AI heyrnartól / AI símastandur) geti tengst sjálfkrafa og vistað upptökur þegar þú ýtir á upptökuhnapp þess þurfum við samþykki þitt:', + 'autoConnectConsentB1': 'Skrá Bluetooth-leit í bakgrunni svo forritið sé vakið (ræst sjálfkrafa) þegar tengt tæki finnst, til að endurtengjast og taka við upptökum', + 'autoConnectConsentB2': 'Eftir vakningu er þetta aðeins notað til að tengja tækið, taka upp og hlaða upp — ekkert annað, og engin önnur forrit eru ræst', + 'autoConnectConsentB3': 'Þú getur samt notað forritið án þess — tengdu bara tækið handvirkt í hvert sinn', + 'autoConnectConsentB4': 'Þú getur slökkt á þessu hvenær sem er í Stillingar → Sjálfvirk tenging tækja', + 'autoConnectAgree': 'Samþykkja og virkja', + 'autoConnectLater': 'Ekki núna', + 'autoConnectRequired': 'Slökkt er á sjálfvirkri tengingu', + 'autoConnectRequiredDesc': 'Virkjaðu sjálfvirka tengingu til að tengja þetta tæki', + 'recordTapToStart': 'Ýttu á hnappinn hér að neðan til að hefja upptöku', + 'recordPaused': 'Í bið', + 'recordMark': 'Merkja', + 'recordDeleteTitle': 'Eyða upptöku', + 'accountAndProfile': 'Reikningur og prófíll', + 'nicknameUnset': 'Ekkert gælunafn stillt', + 'nicknameInput': 'Sláðu inn gælunafn', + 'loginMethod': 'Innskráningarleið', + 'manageMyLogin': 'Stjórna innskráningu', + 'logoutAction': 'Skrá út', + 'warningLabel': 'Viðvörun:', + 'deleteAccountWarn': 'Reikningnum þínum og öllum skrám verður eytt varanlega.', + 'icpFiling': 'ICP-skráning', + 'logExport': 'Flytja út skrár', + 'logExportDesc': 'Skoða og flytja út atburðaskrár forritsins', + 'permLoadFailed': 'Ekki tókst að hlaða heimildaflokkum', + 'permInsufficient': 'Heimild hafnað', + 'voiceCloneCreating': 'Bý til röddina þína…', + 'voiceCloneCreate': 'Búa til mína rödd', + 'voiceCloneAgree': 'Samþykkja og lesa yfirlýsinguna upphátt', + 'voiceCloneReadPrompt': 'Lestu upphátt:', + 'voiceCloneFirstMonthFree': 'Fyrsti mánuðurinn ókeypis', + 'voiceCloneEnable': 'Virkja raddklónun', + 'noMusicPlaying': 'Ekkert í spilun', + 'qqMusicNoSeek': 'QQ Music styður ekki að spóla', + 'bleScanTitle': 'BLE-leit', + 'bluetoothOn': 'Kveikt á Bluetooth', + 'bleScanning': 'Leita…', + 'bleConnectedDevices': 'Tengd tæki', + 'bleUnknownDevice': 'Óþekkt tæki', + 'bleTapToScan': 'Ýttu á Leita til að finna BLE-tæki', + 'weekMon': 'Mán', + 'weekTue': 'Þri', + 'weekWed': 'Mið', + 'weekThu': 'Fim', + 'weekFri': 'Fös', + 'weekSat': 'Lau', + 'weekSun': 'Sun', + 'agentMeeting': 'Raddfundargerðir', + 'agentMeetingDesc': 'Draga saman upptökur, minnismiðstöð', + 'agentEmai': 'EMAI aðstoðarmaður', + 'agentEmaiDesc': 'Spjallaðu og spurðu um hvað sem er með röddinni', + 'agentSimultaneous': 'Samtímaþýðing', + 'agentSimultaneousDesc': 'Þýðir á meðan þú talar', + 'agentFaceToFace': 'Augliti til auglitis', + 'agentFaceToFaceDesc': 'Tvíátta samtalsþýðing', + 'agentCallRecording': 'Símtalsupptaka', + 'agentCallRecordingDesc': 'Símtöl vistuð sjálfkrafa', + 'agentFieldRecording': 'Vettvangsupptaka', + 'agentFieldRecordingDesc': 'Hljóðnemi tækis eða síma', + 'agentCall': 'Símtalsþýðing', + 'agentCallDesc': 'Rauntímaþýðing í símtölum', + 'agentAudioVideo': 'Miðlaþýðing', + 'agentAudioVideoDesc': 'Myndavélarrammi / hljóð- og myndþýðing', + 'agentEnglishChat': 'Enskuspjall', + 'agentEnglishChatDesc': 'Yfirgripsmikil talæfing', + 'agentEnglishStudy': 'Enskunám', + 'agentEnglishStudyDesc': 'Orðaforða- og málfræðikennsla', + 'agentKidsStory': 'Barnasögur', + 'agentKidsStoryDesc': 'Kvöldsögur og spurningar og svör', + 'moduleRemovedDesc': 'fjarlægt af AI-síðunni', + 'moduleAddedDesc': 'bætt neðst á AI-síðuna', + 'calNoRecords': 'Engar færslur', + 'calNoRecordsDay': 'Ekkert á dagskrá', + 'calTodos': 'Verkefni', + 'calExpenses': 'Útgjöld', + 'calTodoCount': '@count verkefni', + 'calExpenseCount': '@count útgjöld', + 'calEntryCount': '@count færslur', + 'calItemsUnit': 'atriði', + 'calCountUnit': 'færslur', + 'calTotalAmount': 'Samtals', + 'recordPause': 'Hlé', + 'recordResume': 'Halda áfram', + 'recordDeleteConfirm': 'Eyða þessari upptöku?', + 'scopeDay': 'Dagur', + 'scopeWeek': 'Vika', + 'scopeMonth': 'Mánuður', + 'allDay': 'Allan daginn', + 'guestModeTitle': 'Gestahamur', + 'guestModeDesc': 'Gestir geta aðeins skoðað. Skráðu þig inn til að nota raddminnispunkta, þýðingu og upptöku — gögnin þín verða vistuð á reikninginn þinn.', + 'guestKeepBrowsing': 'Halda áfram að skoða', + 'guestGoLogin': 'Skrá inn', + 'guestViewOnlyTag': 'Aðeins skoðun', + 'autoConnectReenableHint': 'Slökkt er á sjálfvirkri tengingu. Kveiktu aftur á henni í Stillingar → Sjálfvirk tenging tækja.', + 'plazaEntryDesc': 'Skoðaðu og bættu við fleiri aðstoðarmönnum', + 'agentTextTranslation': 'Textaþýðing', + 'agentTextTranslationDesc': 'Þýddu innsleginn eða límdan texta', + 'agentPictureTranslation': 'Myndaþýðing', + 'agentPictureTranslationDesc': 'Taktu eða veldu mynd til að þýða', + 'callRequiredTitle': 'Símtal nauðsynlegt', + 'callRequiredDesc': 'Þessi eiginleiki virkar aðeins í símtali', + 'inCallBusyTitle': 'Ekki í boði í símtali', + 'inCallBusyDesc': 'Hljóðneminn og hljóðleiðin eru í notkun í símtalinu. Reyndu aftur eftir að þú leggur á.', + 'vipSectionTitle': 'VIP', + 'freeSectionTitle': 'Ókeypis', + 'smartcarConnected': 'Connected', + 'smartcarConnectFailed': 'Connection failed', + 'smartcarConnecting': 'Connecting…', + 'smartcarSearching': 'Searching for nearby car diffusers…', + 'smartcarNotFound': 'No device found', + 'smartcarScanHint': 'Make sure the device is powered on and close to your phone', + 'vipRequiredTitle': 'Membership required', + 'vipRequiredDesc': 'Translation and meeting notes require a membership. Renew, or pair a device that includes one.', + 'vipRequiredGo': 'Get membership', + 'vipFromDevice': 'from device', + 'computeSummary': 'Compute: device @device · account @user', + 'computePack': 'Compute packs', + 'vipExpiringSoon': 'Membership expires in @days day(s). Renew to keep using translation and meeting notes.', + 'computeLow': 'Running low on compute. You won\'t be able to continue once it runs out.', }; diff --git a/apps/client/lib/core/translations/language/it_it.dart b/apps/client/lib/core/translations/language/it_it.dart index fd21e174..b9a6b4a7 100644 --- a/apps/client/lib/core/translations/language/it_it.dart +++ b/apps/client/lib/core/translations/language/it_it.dart @@ -1942,4 +1942,208 @@ const Map itIT = { 'unmarkFavorite': 'Rimuovi dai preferiti', 'unnamedDevice': 'Dispositivo senza nome', 'userDataParseFailed': "Impossibile leggere i dati dell'utente", + 'catEarphone': 'Auricolari IA', + 'catEarphoneDesc': 'Auricolari Bluetooth classico / BLE', + 'catPhoneHolder': 'Supporto telefono IA', + 'catPhoneHolderDesc': 'Dispositivi di supporto', + 'catCarAroma': 'Diffusore auto IA', + 'catCarAromaDesc': 'Diffusore di aromi per auto', + 'agreeAndContinue': 'Accetta e continua', + 'memberServiceAgreement': 'Contratto di servizio per gli abbonati', + 'memberServiceAgreementTitle': 'Contratto di servizio per gli abbonati', + 'memberServiceAgreementIntro': 'Per offrirti un servizio migliore, leggi attentamente prima di abbonarti: ', + 'memberServiceAgreementOutro': '. Facendo clic su "Accetta e continua" confermi di aver letto integralmente e di accettare il contratto sopra indicato.', + 'memberAgreementRejectedTip': 'Devi accettare il Contratto di servizio per gli abbonati per continuare.', + 'translationCallExitTitle': 'Esci dalla traduzione chiamate', + 'translationCallExitMessage': 'Scegli cosa fare prima di uscire', + 'translationCallEnd': 'Termina traduzione chiamata', + 'translationContinueInBackground': 'Continua a tradurre in background', + 'backgroundRecordingIndicatorBody': 'La registrazione continua in background. Tocca per tornare.', + 'backgroundTranslationIndicatorBody': 'La traduzione della chiamata continua in background. Tocca per tornare.', + 'meetingRecordScreenCapturePermissionDenied': 'Per registrare l\'audio di sistema è necessario il permesso di registrazione dello schermo. Concedilo e riprova.', + 'pleaseGrantBluetoothPermission': 'Concedi il permesso Bluetooth per la scansione', + 'startScanFailed': 'Impossibile avviare la scansione', + 'pleaseLoginFirst': 'Accedi prima di continuare', + 'deviceUnauthorized': 'Dispositivo non autorizzato, riprova', + 'boundDevices': 'Dispositivi associati', + 'currentConnection': 'Connessione attuale', + 'binding': 'Associazione...', + 'permissionDisabled': 'Disattivato', + 'confirmDisablePermissionTitle': 'Disattivare il permesso', + 'confirmDisablePermissionContent': 'Disattivare il permesso {permission}? Dovrai riattivarlo dalle impostazioni di sistema.', + 'confirmPermanentlyDisallowTitle': 'Non chiedere più?', + 'confirmPermanentlyDisallowContent': 'Hai negato il permesso {permission}. Non chiedere più? Se scegli "Non consentire", potrai comunque attivarlo in seguito dalle impostazioni di sistema.', + 'notNow': 'Non ora', + 'permanentlyDisallow': 'Non consentire', + 'otaBesNotSupported': 'Queste cuffie non supportano ancora l\'aggiornamento del firmware dall\'app. Contatta l\'assistenza.', + 'tabDevice': 'Dispositivi', + 'tabPlaza': 'Plaza', + 'sloganHome': 'Tecnologia per una vita più smart', + 'sloganAi': 'Intelligenza senza limiti', + 'sloganDevice': 'Hardware intelligente, pronto per il futuro', + 'sloganPlaza': 'Un mondo smart che ti capisce', + 'aiEditHint': 'Tieni premuta una scheda per trascinare e riordinare i moduli', + 'aiEmptyTitle': 'Nessun modulo aggiunto', + 'aiEmptyDesc': 'Scegli gli agenti che ti servono in Plaza', + 'goToPlaza': 'Vai a Plaza', + 'plazaOnline': 'Disponibile', + 'plazaPlanned': 'Prossimamente', + 'plazaSearchHint': 'Cerca agenti, es. “traduci”, “registra”', + 'moduleRemove': 'Rimuovi', + 'moduleAdd': 'Aggiungi', + 'moduleRemoved': 'Rimosso', + 'moduleAdded': 'Aggiunto', + 'goSee': 'Vedi', + 'comingSoonTag': 'Prossimamente', + 'needDeviceTag': 'Dispositivo richiesto', + 'featureInDev': 'Questa funzione è in fase di sviluppo', + 'featureNotWired': 'Non ancora disponibile', + 'todayLabel': 'Oggi', + 'deviceConnected': 'Connesso', + 'licenseUnknown': 'Licenza sconosciuta', + 'selectDevice': 'Seleziona un dispositivo', + 'catNoteCard': 'Scheda registratore AI', + 'catNoteCardDesc': 'Registratore a forma di scheda', + 'catParkerPen': 'Penna AI', + 'catParkerPenDesc': 'Penne con registratore vocale', + 'catStylusPen': 'Stilo AI', + 'catStylusPenDesc': 'Dispositivi per scrittura a mano / stilo', + 'catStudyMachine': 'Tablet didattico AI', + 'catStudyMachineDesc': 'Dispositivi per l\'apprendimento', + 'besAddTitle': 'Aggiungi auricolari AI', + 'besConnecting': 'Connessione agli auricolari…', + 'besNotFound': 'Auricolari non trovati', + 'besNotFoundIosDesc': 'Connetti prima gli auricolari in Impostazioni → Bluetooth, poi torna qui', + 'besNotFoundAndroidDesc': 'Associa prima gli auricolari in Impostazioni → Bluetooth, poi connettili qui', + 'besReadFailed': 'Lettura non riuscita', + 'besConnectFailIos': 'Assicurati che gli auricolari risultino Connessi nel Bluetooth di sistema, poi riprova', + 'besConnectFailAndroid': 'Assicurati che gli auricolari siano accesi, nelle vicinanze e non connessi a un altro dispositivo. Una volta associati si riconnetteranno automaticamente.', + 'holderAddTitle': 'Aggiungi supporto AI per telefono', + 'holderConnecting': 'Connessione al dispositivo…', + 'holderSearching': 'Ricerca dispositivi…', + 'holderNotFound': 'Nessun dispositivo trovato', + 'holderPairHint': 'Assicurati che il supporto sia acceso e in modalità di associazione', + 'holderRetryHint': 'Assicurati che il dispositivo sia acceso e nelle vicinanze, poi riprova', + 'autoConnectTitle': 'Connessione automatica dispositivi', + 'autoConnectDesc': 'Riattiva l\'app per riconnettersi e ricevere le registrazioni quando il dispositivo si accende o si avvicina', + 'autoConnectConsentTitle': 'Attiva la connessione automatica', + 'autoConnectConsentIntro': 'Per consentire al tuo dispositivo (auricolari AI / supporto AI per telefono) di connettersi automaticamente e salvare le registrazioni quando premi il suo tasto di registrazione, abbiamo bisogno del tuo consenso:', + 'autoConnectConsentB1': 'Registrare una scansione Bluetooth in background così che l\'app venga riattivata (avviata automaticamente) quando viene rilevato il dispositivo associato, per riconnettersi e ricevere le registrazioni', + 'autoConnectConsentB2': 'Una volta riattivata, viene usata solo per la connessione del dispositivo, la registrazione e il caricamento — nient\'altro, e non viene avviata nessun\'altra app', + 'autoConnectConsentB3': 'Puoi comunque usare l\'app senza questa funzione — basta connettere il dispositivo manualmente ogni volta', + 'autoConnectConsentB4': 'Puoi disattivarla in qualsiasi momento in Impostazioni → Connessione automatica dispositivi', + 'autoConnectAgree': 'Accetta e attiva', + 'autoConnectLater': 'Non ora', + 'autoConnectRequired': 'Connessione automatica disattivata', + 'autoConnectRequiredDesc': 'Attiva la connessione automatica per associare questo dispositivo', + 'recordTapToStart': 'Tocca il pulsante in basso per iniziare a registrare', + 'recordPaused': 'In pausa', + 'recordMark': 'Segna', + 'recordDeleteTitle': 'Elimina registrazione', + 'accountAndProfile': 'Account e profilo', + 'nicknameUnset': 'Nessun soprannome impostato', + 'nicknameInput': 'Inserisci un soprannome', + 'loginMethod': 'Metodo di accesso', + 'manageMyLogin': 'Gestisci accesso', + 'logoutAction': 'Esci', + 'warningLabel': 'Attenzione:', + 'deleteAccountWarn': 'Il tuo account e tutti i file verranno eliminati definitivamente.', + 'icpFiling': 'Registrazione ICP', + 'logExport': 'Esporta log', + 'logExportDesc': 'Visualizza ed esporta i log dell\'app', + 'permLoadFailed': 'Impossibile caricare i gruppi di permessi', + 'permInsufficient': 'Permesso negato', + 'voiceCloneCreating': 'Creazione della tua voce…', + 'voiceCloneCreate': 'Crea la mia voce', + 'voiceCloneAgree': 'Accetta e leggi la dichiarazione ad alta voce', + 'voiceCloneReadPrompt': 'Leggi:', + 'voiceCloneFirstMonthFree': 'Primo mese gratis', + 'voiceCloneEnable': 'Attiva la clonazione vocale', + 'noMusicPlaying': 'Nessuna riproduzione in corso', + 'qqMusicNoSeek': 'Lo scorrimento non è supportato per QQ Music', + 'bleScanTitle': 'Scansione BLE', + 'bluetoothOn': 'Bluetooth attivo', + 'bleScanning': 'Scansione…', + 'bleConnectedDevices': 'Dispositivi connessi', + 'bleUnknownDevice': 'Dispositivo sconosciuto', + 'bleTapToScan': 'Tocca Scansiona per cercare dispositivi BLE', + 'weekMon': 'Lun', + 'weekTue': 'Mar', + 'weekWed': 'Mer', + 'weekThu': 'Gio', + 'weekFri': 'Ven', + 'weekSat': 'Sab', + 'weekSun': 'Dom', + 'agentMeeting': 'Verbali vocali', + 'agentMeetingDesc': 'Riassume le registrazioni, hub della memoria', + 'agentEmai': 'Assistente EMAI', + 'agentEmaiDesc': 'Chatta e chiedi qualsiasi cosa con la voce', + 'agentSimultaneous': 'Traduzione simultanea', + 'agentSimultaneousDesc': 'Traduce mentre parli', + 'agentFaceToFace': 'Faccia a faccia', + 'agentFaceToFaceDesc': 'Traduzione bidirezionale delle conversazioni', + 'agentCallRecording': 'Registrazione chiamate', + 'agentCallRecordingDesc': 'Chiamate salvate automaticamente', + 'agentFieldRecording': 'Registrazione live', + 'agentFieldRecordingDesc': 'Microfono del dispositivo o del telefono', + 'agentCall': 'Traduzione chiamate', + 'agentCallDesc': 'Traduzione in tempo reale durante le chiamate', + 'agentAudioVideo': 'Traduzione multimediale', + 'agentAudioVideoDesc': 'Inquadratura fotocamera / traduzione audio e video', + 'agentEnglishChat': 'Conversazione in inglese', + 'agentEnglishChatDesc': 'Pratica orale immersiva', + 'agentEnglishStudy': 'Studio dell\'inglese', + 'agentEnglishStudyDesc': 'Lezioni di vocabolario e grammatica', + 'agentKidsStory': 'Storie per bambini', + 'agentKidsStoryDesc': 'Storie della buonanotte e domande e risposte', + 'moduleRemovedDesc': 'rimosso dalla pagina AI', + 'moduleAddedDesc': 'aggiunto in fondo alla pagina AI', + 'calNoRecords': 'Nessuna voce', + 'calNoRecordsDay': 'Niente in programma', + 'calTodos': 'Da fare', + 'calExpenses': 'Spese', + 'calTodoCount': '@count attività', + 'calExpenseCount': '@count spese', + 'calEntryCount': '@count voci', + 'calItemsUnit': 'elementi', + 'calCountUnit': 'voci', + 'calTotalAmount': 'Totale', + 'recordPause': 'Pausa', + 'recordResume': 'Riprendi', + 'recordDeleteConfirm': 'Eliminare questa registrazione?', + 'scopeDay': 'Giorno', + 'scopeWeek': 'Settimana', + 'scopeMonth': 'Mese', + 'allDay': 'Tutto il giorno', + 'guestModeTitle': 'Modalità ospite', + 'guestModeDesc': 'Gli ospiti possono solo navigare. Accedi per usare note vocali, traduzione e registrazione — i tuoi dati verranno salvati nel tuo account.', + 'guestKeepBrowsing': 'Continua a navigare', + 'guestGoLogin': 'Accedi', + 'guestViewOnlyTag': 'Solo visualizzazione', + 'autoConnectReenableHint': 'La connessione automatica è disattivata. Riattivala in Impostazioni → Connessione automatica dispositivi.', + 'plazaEntryDesc': 'Sfoglia e aggiungi altri agenti', + 'agentTextTranslation': 'Traduzione testo', + 'agentTextTranslationDesc': 'Traduci testo digitato o incollato', + 'agentPictureTranslation': 'Traduzione foto', + 'agentPictureTranslationDesc': 'Scatta o scegli una foto da tradurre', + 'callRequiredTitle': 'Chiamata richiesta', + 'callRequiredDesc': 'Questa funzione è disponibile solo durante una chiamata', + 'inCallBusyTitle': 'Non disponibile durante una chiamata', + 'inCallBusyDesc': 'Il microfono e il percorso audio sono in uso dalla chiamata. Riprova dopo aver riagganciato.', + 'vipSectionTitle': 'VIP', + 'freeSectionTitle': 'Gratis', + 'smartcarConnected': 'Connected', + 'smartcarConnectFailed': 'Connection failed', + 'smartcarConnecting': 'Connecting…', + 'smartcarSearching': 'Searching for nearby car diffusers…', + 'smartcarNotFound': 'No device found', + 'smartcarScanHint': 'Make sure the device is powered on and close to your phone', + 'vipRequiredTitle': 'Membership required', + 'vipRequiredDesc': 'Translation and meeting notes require a membership. Renew, or pair a device that includes one.', + 'vipRequiredGo': 'Get membership', + 'vipFromDevice': 'from device', + 'computeSummary': 'Compute: device @device · account @user', + 'computePack': 'Compute packs', + 'vipExpiringSoon': 'Membership expires in @days day(s). Renew to keep using translation and meeting notes.', + 'computeLow': 'Running low on compute. You won\'t be able to continue once it runs out.', }; diff --git a/apps/client/lib/core/translations/language/ja_jp.dart b/apps/client/lib/core/translations/language/ja_jp.dart index 68a8540f..a85b8d2a 100644 --- a/apps/client/lib/core/translations/language/ja_jp.dart +++ b/apps/client/lib/core/translations/language/ja_jp.dart @@ -1683,6 +1683,8 @@ const Map jaJP = { 'catNoteCardDesc': 'カード型レコーダー', 'catPhoneHolder': 'AI スマホスタンド', 'catPhoneHolderDesc': 'スタンド型デバイス', + 'catCarAroma': 'AI カーアロマ', + 'catCarAromaDesc': '車載アロマディフューザー', 'catParkerPen': 'AI ペン', 'catParkerPenDesc': 'ボイスレコーダーペン', 'catStylusPen': 'AI スタイラス', @@ -1937,4 +1939,32 @@ const Map jaJP = { 'unmarkFavorite': 'お気に入りから削除', 'unnamedDevice': '名称未設定のデバイス', 'userDataParseFailed': 'ユーザーデータの解析に失敗しました', + 'translationCallExitTitle': '通話翻訳を終了', + 'translationCallExitMessage': '離れる前の操作を選択してください', + 'translationCallEnd': '通話翻訳を終了する', + 'translationContinueInBackground': 'バックグラウンドで翻訳を続ける', + 'backgroundRecordingIndicatorBody': 'バックグラウンドで録音を続けています。タップして戻る', + 'backgroundTranslationIndicatorBody': 'バックグラウンドで通話翻訳を続けています。タップして戻る', + 'permissionDisabled': '無効', + 'confirmDisablePermissionTitle': '権限をオフにする', + 'confirmDisablePermissionContent': '{permission}の権限をオフにしますか?再度有効にするにはシステム設定から操作する必要があります。', + 'confirmPermanentlyDisallowTitle': '今後確認しない?', + 'confirmPermanentlyDisallowContent': '{permission}の権限が拒否されました。今後は確認しないようにしますか?「許可しない」を選んでも、後からシステム設定で有効にできます。', + 'notNow': '今はしない', + 'permanentlyDisallow': '許可しない', + 'otaBesNotSupported': 'このイヤホンはアプリ内でのファームウェアアップグレードにまだ対応していません。サポートにお問い合わせください。', + 'smartcarConnected': '接続しました', + 'smartcarConnectFailed': '接続に失敗しました', + 'smartcarConnecting': '接続中…', + 'smartcarSearching': '近くの車載アロマを検索中…', + 'smartcarNotFound': 'デバイスが見つかりません', + 'smartcarScanHint': '電源が入っていること、スマホの近くにあることを確認してください', + 'vipRequiredTitle': 'Membership required', + 'vipRequiredDesc': 'Translation and meeting notes require a membership. Renew, or pair a device that includes one.', + 'vipRequiredGo': 'Get membership', + 'vipFromDevice': 'from device', + 'computeSummary': 'Compute: device @device · account @user', + 'computePack': 'Compute packs', + 'vipExpiringSoon': 'Membership expires in @days day(s). Renew to keep using translation and meeting notes.', + 'computeLow': 'Running low on compute. You won\'t be able to continue once it runs out.', }; diff --git a/apps/client/lib/core/translations/language/ko_kr.dart b/apps/client/lib/core/translations/language/ko_kr.dart index 659dbc0d..5483accd 100644 --- a/apps/client/lib/core/translations/language/ko_kr.dart +++ b/apps/client/lib/core/translations/language/ko_kr.dart @@ -1688,6 +1688,8 @@ const Map koKR = { 'catNoteCardDesc': '카드형 녹음 기기', 'catPhoneHolder': 'AI 휴대폰 거치대', 'catPhoneHolderDesc': '거치대형 기기', + 'catCarAroma': 'AI 차량용 방향제', + 'catCarAromaDesc': '차량용 방향제 기기', 'catParkerPen': 'AI 펜', 'catParkerPenDesc': '녹음 펜 기기', 'catStylusPen': 'AI 스타일러스', @@ -1930,4 +1932,32 @@ const Map koKR = { 'unmarkFavorite': '즐겨찾기 해제', 'unnamedDevice': '이름 없는 기기', 'userDataParseFailed': '사용자 데이터 분석 실패', + 'translationCallExitTitle': '통화 번역 나가기', + 'translationCallExitMessage': '나가기 전에 할 작업을 선택하세요', + 'translationCallEnd': '통화 번역 종료', + 'translationContinueInBackground': '백그라운드에서 번역 계속하기', + 'backgroundRecordingIndicatorBody': '백그라운드에서 녹음이 계속되고 있습니다. 탭하여 돌아가기', + 'backgroundTranslationIndicatorBody': '백그라운드에서 통화 번역이 계속되고 있습니다. 탭하여 돌아가기', + 'permissionDisabled': '비활성화됨', + 'confirmDisablePermissionTitle': '권한 끄기', + 'confirmDisablePermissionContent': '{permission} 권한을 끄시겠습니까? 다시 켜려면 시스템 설정으로 이동해야 합니다.', + 'confirmPermanentlyDisallowTitle': '더 이상 묻지 않을까요?', + 'confirmPermanentlyDisallowContent': '{permission} 권한을 거부하셨습니다. 앞으로 다시 묻지 않을까요? \'허용 안 함\'을 선택해도 나중에 시스템 설정에서 켤 수 있습니다.', + 'notNow': '나중에', + 'permanentlyDisallow': '허용 안 함', + 'otaBesNotSupported': '이 이어폰은 아직 앱 내 펌웨어 업그레이드를 지원하지 않습니다. 고객 지원에 문의해 주세요.', + 'smartcarConnected': '연결됨', + 'smartcarConnectFailed': '연결 실패', + 'smartcarConnecting': '연결 중…', + 'smartcarSearching': '주변 차량용 방향제 검색 중…', + 'smartcarNotFound': '기기를 찾을 수 없습니다', + 'smartcarScanHint': '기기 전원이 켜져 있고 휴대폰 근처에 있는지 확인하세요', + 'vipRequiredTitle': 'Membership required', + 'vipRequiredDesc': 'Translation and meeting notes require a membership. Renew, or pair a device that includes one.', + 'vipRequiredGo': 'Get membership', + 'vipFromDevice': 'from device', + 'computeSummary': 'Compute: device @device · account @user', + 'computePack': 'Compute packs', + 'vipExpiringSoon': 'Membership expires in @days day(s). Renew to keep using translation and meeting notes.', + 'computeLow': 'Running low on compute. You won\'t be able to continue once it runs out.', }; diff --git a/apps/client/lib/core/translations/language/lt_lt.dart b/apps/client/lib/core/translations/language/lt_lt.dart index 5a57283f..903c5f43 100644 --- a/apps/client/lib/core/translations/language/lt_lt.dart +++ b/apps/client/lib/core/translations/language/lt_lt.dart @@ -1919,4 +1919,208 @@ const Map ltLT = { 'unmarkFavorite': 'Pašalinti iš mėgstamų', 'unnamedDevice': 'Įrenginys be pavadinimo', 'userDataParseFailed': 'Nepavyko nuskaityti naudotojo duomenų', + 'catEarphone': 'AI ausinės', + 'catEarphoneDesc': 'Klasikinės Bluetooth / BLE ausinės', + 'catPhoneHolder': 'AI telefono laikiklis', + 'catPhoneHolderDesc': 'Laikiklio įrenginiai', + 'catCarAroma': 'AI automobilio kvapas', + 'catCarAromaDesc': 'Automobilio aromato difuzorius', + 'agreeAndContinue': 'Sutikti ir tęsti', + 'memberServiceAgreement': 'Narystės paslaugų sutartis', + 'memberServiceAgreementTitle': 'Narystės paslaugų sutartis', + 'memberServiceAgreementIntro': 'Kad galėtume teikti geresnes narystės paslaugas, prieš prenumeruodami atidžiai perskaitykite: ', + 'memberServiceAgreementOutro': '. Spustelėdami „Sutikti ir tęsti“ patvirtinate, kad visiškai perskaitėte ir sutinkate su aukščiau pateikta sutartimi.', + 'memberAgreementRejectedTip': 'Norėdami tęsti, turite sutikti su Narystės paslaugų sutartimi.', + 'translationCallExitTitle': 'Išeiti iš skambučių vertimo', + 'translationCallExitMessage': 'Pasirinkite, ką daryti prieš išeinant', + 'translationCallEnd': 'Baigti skambučio vertimą', + 'translationContinueInBackground': 'Tęsti vertimą fone', + 'backgroundRecordingIndicatorBody': 'Įrašymas tęsiamas fone. Palieskite, kad grįžtumėte.', + 'backgroundTranslationIndicatorBody': 'Skambučio vertimas tęsiamas fone. Palieskite, kad grįžtumėte.', + 'meetingRecordScreenCapturePermissionDenied': 'Sistemos garsui įrašyti reikalingas ekrano įrašymo leidimas. Suteikite jį ir bandykite dar kartą.', + 'pleaseGrantBluetoothPermission': 'Suteikite Bluetooth leidimą, kad būtų galima skenuoti', + 'startScanFailed': 'Nepavyko pradėti skenavimo', + 'pleaseLoginFirst': 'Pirmiausia prisijunkite', + 'deviceUnauthorized': 'Įrenginys neautorizuotas, bandykite dar kartą', + 'boundDevices': 'Susieti įrenginiai', + 'currentConnection': 'Dabartinis ryšys', + 'binding': 'Siejama...', + 'permissionDisabled': 'Išjungta', + 'confirmDisablePermissionTitle': 'Išjungti leidimą', + 'confirmDisablePermissionContent': 'Išjungti {permission} leidimą? Norėdami jį vėl įjungti, turėsite eiti į sistemos nustatymus.', + 'confirmPermanentlyDisallowTitle': 'Daugiau neklausti?', + 'confirmPermanentlyDisallowContent': 'Atmetėte {permission} leidimą. Daugiau neklausti? Jei pasirinksite „Neleisti“, vėliau vis tiek galėsite jį įjungti sistemos nustatymuose.', + 'notNow': 'Ne dabar', + 'permanentlyDisallow': 'Neleisti', + 'otaBesNotSupported': 'Šios ausinės dar nepalaiko programinės aparatinės įrangos atnaujinimo per programą. Susisiekite su palaikymo tarnyba.', + 'tabDevice': 'Įrenginiai', + 'tabPlaza': 'Plaza', + 'sloganHome': 'Technologijos išmanesniam gyvenimui', + 'sloganAi': 'Beribis intelektas', + 'sloganDevice': 'Išmanioji įranga, pasirengusi ateičiai', + 'sloganPlaza': 'Išmanus pasaulis, kuris jus supranta', + 'aiEditHint': 'Ilgai palaikykite kortelę, kad vilkdami pertvarkytumėte modulius', + 'aiEmptyTitle': 'Modulių dar nepridėta', + 'aiEmptyDesc': 'Pasirinkite reikalingus agentus „Plaza“ skiltyje', + 'goToPlaza': 'Eiti į „Plaza“', + 'plazaOnline': 'Prieinama', + 'plazaPlanned': 'Netrukus', + 'plazaSearchHint': 'Ieškoti agentų, pvz., „versti“, „įrašyti“', + 'moduleRemove': 'Pašalinti', + 'moduleAdd': 'Pridėti', + 'moduleRemoved': 'Pašalinta', + 'moduleAdded': 'Pridėta', + 'goSee': 'Peržiūrėti', + 'comingSoonTag': 'Netrukus', + 'needDeviceTag': 'Reikia įrenginio', + 'featureInDev': 'Ši funkcija dar kuriama', + 'featureNotWired': 'Dar neprieinama', + 'todayLabel': 'Šiandien', + 'deviceConnected': 'Prisijungta', + 'licenseUnknown': 'Licencija nežinoma', + 'selectDevice': 'Pasirinkite įrenginį', + 'catNoteCard': 'AI įrašymo kortelė', + 'catNoteCardDesc': 'Kortelės formos įrašytuvas', + 'catParkerPen': 'AI rašiklis', + 'catParkerPenDesc': 'Rašikliai su balso įrašymu', + 'catStylusPen': 'AI jutiklinis rašiklis', + 'catStylusPenDesc': 'Rašymo ranka / jutiklinio rašiklio įrenginiai', + 'catStudyMachine': 'AI mokymosi planšetė', + 'catStudyMachineDesc': 'Mokymosi įrenginiai', + 'besAddTitle': 'Pridėti AI ausines', + 'besConnecting': 'Jungiamasi prie ausinių…', + 'besNotFound': 'Ausinės nerastos', + 'besNotFoundIosDesc': 'Pirmiausia prijunkite ausines per Nustatymai → Bluetooth, tada grįžkite čia', + 'besNotFoundAndroidDesc': 'Pirmiausia susiekite ausines per Nustatymai → Bluetooth, tada prijunkite čia', + 'besReadFailed': 'Nepavyko nuskaityti', + 'besConnectFailIos': 'Įsitikinkite, kad sistemos Bluetooth nustatymuose ausinės rodomos kaip prijungtos, tada bandykite dar kartą', + 'besConnectFailAndroid': 'Įsitikinkite, kad ausinės įjungtos, yra netoliese ir neprijungtos prie kito įrenginio. Susiejus jos prisijungs automatiškai.', + 'holderAddTitle': 'Pridėti AI telefono laikiklį', + 'holderConnecting': 'Jungiamasi prie įrenginio…', + 'holderSearching': 'Ieškoma įrenginių…', + 'holderNotFound': 'Įrenginių nerasta', + 'holderPairHint': 'Įsitikinkite, kad laikiklis įjungtas ir veikia susiejimo režimu', + 'holderRetryHint': 'Įsitikinkite, kad įrenginys įjungtas ir yra netoliese, tada bandykite dar kartą', + 'autoConnectTitle': 'Automatinis įrenginių prijungimas', + 'autoConnectDesc': 'Pažadina programą, kad ji vėl prisijungtų ir gautų įrašus, kai įrenginys įsijungia arba atsiduria netoliese', + 'autoConnectConsentTitle': 'Įjungti automatinį prijungimą', + 'autoConnectConsentIntro': 'Kad jūsų įrenginys (AI ausinės / AI telefono laikiklis) galėtų prisijungti automatiškai ir išsaugoti įrašus paspaudus jo įrašymo mygtuką, mums reikia jūsų sutikimo:', + 'autoConnectConsentB1': 'Užregistruoti Bluetooth skenavimą fone, kad aptikus susietą įrenginį programa būtų pažadinta (paleista automatiškai) ir galėtų vėl prisijungti bei gauti įrašus', + 'autoConnectConsentB2': 'Pažadinta programa naudojama tik įrenginio prijungimui, įrašymui ir įkėlimui — niekam kitam, ir jokios kitos programos nepaleidžiamos', + 'autoConnectConsentB3': 'Programą galite naudoti ir be to — tiesiog kiekvieną kartą prijunkite įrenginį rankiniu būdu', + 'autoConnectConsentB4': 'Bet kada galite tai išjungti: Nustatymai → Automatinis įrenginių prijungimas', + 'autoConnectAgree': 'Sutikti ir įjungti', + 'autoConnectLater': 'Ne dabar', + 'autoConnectRequired': 'Automatinis prijungimas išjungtas', + 'autoConnectRequiredDesc': 'Įjunkite automatinį prijungimą, kad susietumėte šį įrenginį', + 'recordTapToStart': 'Palieskite mygtuką apačioje, kad pradėtumėte įrašyti', + 'recordPaused': 'Pristabdyta', + 'recordMark': 'Žymėti', + 'recordDeleteTitle': 'Ištrinti įrašą', + 'accountAndProfile': 'Paskyra ir profilis', + 'nicknameUnset': 'Slapyvardis nenustatytas', + 'nicknameInput': 'Įveskite slapyvardį', + 'loginMethod': 'Prisijungimo būdas', + 'manageMyLogin': 'Tvarkyti prisijungimą', + 'logoutAction': 'Atsijungti', + 'warningLabel': 'Įspėjimas:', + 'deleteAccountWarn': 'Jūsų paskyra ir visi failai bus visam laikui ištrinti.', + 'icpFiling': 'ICP registracija', + 'logExport': 'Eksportuoti žurnalus', + 'logExportDesc': 'Peržiūrėti ir eksportuoti programos žurnalus', + 'permLoadFailed': 'Nepavyko įkelti leidimų grupių', + 'permInsufficient': 'Leidimas nesuteiktas', + 'voiceCloneCreating': 'Kuriamas jūsų balsas…', + 'voiceCloneCreate': 'Sukurti mano balsą', + 'voiceCloneAgree': 'Sutikti ir garsiai perskaityti pareiškimą', + 'voiceCloneReadPrompt': 'Perskaitykite:', + 'voiceCloneFirstMonthFree': 'Pirmas mėnuo nemokamai', + 'voiceCloneEnable': 'Įjungti balso klonavimą', + 'noMusicPlaying': 'Niekas negrojama', + 'qqMusicNoSeek': '„QQ Music“ nepalaiko persukimo', + 'bleScanTitle': 'BLE skenavimas', + 'bluetoothOn': 'Bluetooth įjungtas', + 'bleScanning': 'Skenuojama…', + 'bleConnectedDevices': 'Prijungti įrenginiai', + 'bleUnknownDevice': 'Nežinomas įrenginys', + 'bleTapToScan': 'Palieskite „Skenuoti“, kad ieškotumėte BLE įrenginių', + 'weekMon': 'Pr', + 'weekTue': 'An', + 'weekWed': 'Tr', + 'weekThu': 'Kt', + 'weekFri': 'Pn', + 'weekSat': 'Št', + 'weekSun': 'Sk', + 'agentMeeting': 'Balso protokolai', + 'agentMeetingDesc': 'Įrašų santraukos, atminties centras', + 'agentEmai': 'EMAI asistentas', + 'agentEmaiDesc': 'Kalbėkitės ir klauskite bet ko balsu', + 'agentSimultaneous': 'Sinchroninis vertimas', + 'agentSimultaneousDesc': 'Verčia jums kalbant', + 'agentFaceToFace': 'Akis į akį', + 'agentFaceToFaceDesc': 'Dvikryptis pokalbių vertimas', + 'agentCallRecording': 'Skambučių įrašymas', + 'agentCallRecordingDesc': 'Skambučiai išsaugomi automatiškai', + 'agentFieldRecording': 'Tiesioginis įrašymas', + 'agentFieldRecordingDesc': 'Įrenginio arba telefono mikrofonas', + 'agentCall': 'Skambučių vertimas', + 'agentCallDesc': 'Vertimas realiuoju laiku skambučių metu', + 'agentAudioVideo': 'Medijos vertimas', + 'agentAudioVideoDesc': 'Kameros kadravimas / garso ir vaizdo vertimas', + 'agentEnglishChat': 'Pokalbiai anglų kalba', + 'agentEnglishChatDesc': 'Įtraukianti kalbėjimo praktika', + 'agentEnglishStudy': 'Anglų kalbos mokymasis', + 'agentEnglishStudyDesc': 'Žodyno ir gramatikos pamokos', + 'agentKidsStory': 'Pasakos vaikams', + 'agentKidsStoryDesc': 'Pasakos prieš miegą ir klausimai-atsakymai', + 'moduleRemovedDesc': 'pašalinta iš AI puslapio', + 'moduleAddedDesc': 'pridėta AI puslapio apačioje', + 'calNoRecords': 'Įrašų nėra', + 'calNoRecordsDay': 'Nieko nesuplanuota', + 'calTodos': 'Darbai', + 'calExpenses': 'Išlaidos', + 'calTodoCount': 'Darbų: @count', + 'calExpenseCount': 'Išlaidų: @count', + 'calEntryCount': 'Įrašų: @count', + 'calItemsUnit': 'vnt.', + 'calCountUnit': 'įrašai', + 'calTotalAmount': 'Iš viso', + 'recordPause': 'Pristabdyti', + 'recordResume': 'Tęsti', + 'recordDeleteConfirm': 'Ištrinti šį įrašą?', + 'scopeDay': 'Diena', + 'scopeWeek': 'Savaitė', + 'scopeMonth': 'Mėnuo', + 'allDay': 'Visą dieną', + 'guestModeTitle': 'Svečio režimas', + 'guestModeDesc': 'Svečiai gali tik naršyti. Prisijunkite, kad naudotumėte balso užrašus, vertimą ir įrašymą — jūsų duomenys bus išsaugoti jūsų paskyroje.', + 'guestKeepBrowsing': 'Toliau naršyti', + 'guestGoLogin': 'Prisijungti', + 'guestViewOnlyTag': 'Tik peržiūra', + 'autoConnectReenableHint': 'Automatinis prijungimas išjungtas. Vėl įjunkite: Nustatymai → Automatinis įrenginių prijungimas.', + 'plazaEntryDesc': 'Naršykite ir pridėkite daugiau agentų', + 'agentTextTranslation': 'Teksto vertimas', + 'agentTextTranslationDesc': 'Verskite įvestą ar įklijuotą tekstą', + 'agentPictureTranslation': 'Nuotraukų vertimas', + 'agentPictureTranslationDesc': 'Nufotografuokite arba pasirinkite nuotrauką vertimui', + 'callRequiredTitle': 'Reikalingas skambutis', + 'callRequiredDesc': 'Ši funkcija veikia tik skambučio metu', + 'inCallBusyTitle': 'Neprieinama skambučio metu', + 'inCallBusyDesc': 'Mikrofoną ir garso kelią naudoja skambutis. Bandykite dar kartą padėję ragelį.', + 'vipSectionTitle': 'VIP', + 'freeSectionTitle': 'Nemokama', + 'smartcarConnected': 'Connected', + 'smartcarConnectFailed': 'Connection failed', + 'smartcarConnecting': 'Connecting…', + 'smartcarSearching': 'Searching for nearby car diffusers…', + 'smartcarNotFound': 'No device found', + 'smartcarScanHint': 'Make sure the device is powered on and close to your phone', + 'vipRequiredTitle': 'Membership required', + 'vipRequiredDesc': 'Translation and meeting notes require a membership. Renew, or pair a device that includes one.', + 'vipRequiredGo': 'Get membership', + 'vipFromDevice': 'from device', + 'computeSummary': 'Compute: device @device · account @user', + 'computePack': 'Compute packs', + 'vipExpiringSoon': 'Membership expires in @days day(s). Renew to keep using translation and meeting notes.', + 'computeLow': 'Running low on compute. You won\'t be able to continue once it runs out.', }; diff --git a/apps/client/lib/core/translations/language/lv_lv.dart b/apps/client/lib/core/translations/language/lv_lv.dart index 80815f00..d379f54e 100644 --- a/apps/client/lib/core/translations/language/lv_lv.dart +++ b/apps/client/lib/core/translations/language/lv_lv.dart @@ -1924,4 +1924,208 @@ const Map lvLV = { 'unmarkFavorite': 'Noņemt no izlases', 'unnamedDevice': 'Ierīce bez nosaukuma', 'userDataParseFailed': 'Neizdevās nolasīt lietotāja datus', + 'catEarphone': 'AI austiņas', + 'catEarphoneDesc': 'Klasiskās Bluetooth / BLE austiņas', + 'catPhoneHolder': 'AI tālruņa turētājs', + 'catPhoneHolderDesc': 'Turētāja ierīces', + 'catCarAroma': 'AI auto aromatizētājs', + 'catCarAromaDesc': 'Automašīnas aromāta difuzors', + 'agreeAndContinue': 'Piekrist un turpināt', + 'memberServiceAgreement': 'Dalības pakalpojumu līgums', + 'memberServiceAgreementTitle': 'Dalības pakalpojumu līgums', + 'memberServiceAgreementIntro': 'Lai sniegtu jums labākus dalības pakalpojumus, pirms abonēšanas, lūdzu, rūpīgi izlasiet: ', + 'memberServiceAgreementOutro': '. Noklikšķinot uz “Piekrist un turpināt”, jūs apstiprināt, ka esat pilnībā izlasījis un piekrītat iepriekš minētajam līgumam.', + 'memberAgreementRejectedTip': 'Lai turpinātu, jums jāpiekrīt Dalības pakalpojumu līgumam.', + 'translationCallExitTitle': 'Iziet no zvanu tulkošanas', + 'translationCallExitMessage': 'Izvēlieties, ko darīt pirms iziešanas', + 'translationCallEnd': 'Beigt zvana tulkošanu', + 'translationContinueInBackground': 'Turpināt tulkošanu fonā', + 'backgroundRecordingIndicatorBody': 'Ierakstīšana turpinās fonā. Pieskarieties, lai atgrieztos.', + 'backgroundTranslationIndicatorBody': 'Zvana tulkošana turpinās fonā. Pieskarieties, lai atgrieztos.', + 'meetingRecordScreenCapturePermissionDenied': 'Lai ierakstītu sistēmas audio, nepieciešama ekrāna ierakstīšanas atļauja. Piešķiriet to un mēģiniet vēlreiz.', + 'pleaseGrantBluetoothPermission': 'Piešķiriet Bluetooth atļauju, lai skenētu', + 'startScanFailed': 'Neizdevās sākt skenēšanu', + 'pleaseLoginFirst': 'Vispirms piesakieties', + 'deviceUnauthorized': 'Ierīce nav autorizēta, mēģiniet vēlreiz', + 'boundDevices': 'Saistītās ierīces', + 'currentConnection': 'Pašreizējais savienojums', + 'binding': 'Saista...', + 'permissionDisabled': 'Izslēgta', + 'confirmDisablePermissionTitle': 'Izslēgt atļauju', + 'confirmDisablePermissionContent': 'Izslēgt {permission} atļauju? Lai to atkal ieslēgtu, būs jādodas uz sistēmas iestatījumiem.', + 'confirmPermanentlyDisallowTitle': 'Vairs nejautāt?', + 'confirmPermanentlyDisallowContent': 'Jūs noraidījāt {permission} atļauju. Vairs nejautāt? Ja izvēlēsieties “Neatļaut”, vēlāk to joprojām varēsiet ieslēgt sistēmas iestatījumos.', + 'notNow': 'Ne tagad', + 'permanentlyDisallow': 'Neatļaut', + 'otaBesNotSupported': 'Šīs austiņas vēl neatbalsta programmaparatūras atjaunināšanu lietotnē. Lūdzu, sazinieties ar atbalsta dienestu.', + 'tabDevice': 'Ierīces', + 'tabPlaza': 'Plaza', + 'sloganHome': 'Tehnoloģijas viedākai dzīvei', + 'sloganAi': 'Neierobežots intelekts', + 'sloganDevice': 'Viedā aparatūra, gatava nākotnei', + 'sloganPlaza': 'Vieda pasaule, kas jūs saprot', + 'aiEditHint': 'Turiet karti nospiestu, lai vilktu un pārkārtotu moduļus', + 'aiEmptyTitle': 'Moduļi vēl nav pievienoti', + 'aiEmptyDesc': 'Izvēlieties nepieciešamos aģentus sadaļā Plaza', + 'goToPlaza': 'Doties uz Plaza', + 'plazaOnline': 'Pieejams', + 'plazaPlanned': 'Drīzumā', + 'plazaSearchHint': 'Meklēt aģentus, piem., “tulkot”, “ierakstīt”', + 'moduleRemove': 'Noņemt', + 'moduleAdd': 'Pievienot', + 'moduleRemoved': 'Noņemts', + 'moduleAdded': 'Pievienots', + 'goSee': 'Skatīt', + 'comingSoonTag': 'Drīzumā', + 'needDeviceTag': 'Nepieciešama ierīce', + 'featureInDev': 'Šī funkcija vēl tiek izstrādāta', + 'featureNotWired': 'Vēl nav pieejams', + 'todayLabel': 'Šodien', + 'deviceConnected': 'Savienots', + 'licenseUnknown': 'Licence nav zināma', + 'selectDevice': 'Izvēlieties ierīci', + 'catNoteCard': 'AI ierakstītāja karte', + 'catNoteCardDesc': 'Kartes formas ierakstītājs', + 'catParkerPen': 'AI pildspalva', + 'catParkerPenDesc': 'Pildspalvas ar balss ierakstītāju', + 'catStylusPen': 'AI irbulis', + 'catStylusPenDesc': 'Rokraksta / irbuļa ierīces', + 'catStudyMachine': 'AI mācību planšete', + 'catStudyMachineDesc': 'Mācību ierīces', + 'besAddTitle': 'Pievienot AI austiņas', + 'besConnecting': 'Savienojas ar austiņām…', + 'besNotFound': 'Austiņas nav atrastas', + 'besNotFoundIosDesc': 'Vispirms savienojiet austiņas sadaļā Iestatījumi → Bluetooth, pēc tam atgriezieties šeit', + 'besNotFoundAndroidDesc': 'Vispirms savienojiet austiņas pārī sadaļā Iestatījumi → Bluetooth, pēc tam savienojiet šeit', + 'besReadFailed': 'Nolasīšana neizdevās', + 'besConnectFailIos': 'Pārliecinieties, ka sistēmas Bluetooth iestatījumos austiņas ir redzamas kā savienotas, pēc tam mēģiniet vēlreiz', + 'besConnectFailAndroid': 'Pārliecinieties, ka austiņas ir ieslēgtas, tuvumā un nav savienotas ar citu ierīci. Pēc savienošanas pārī tās atkal savienosies automātiski.', + 'holderAddTitle': 'Pievienot AI tālruņa turētāju', + 'holderConnecting': 'Savienojas ar ierīci…', + 'holderSearching': 'Meklē ierīces…', + 'holderNotFound': 'Ierīces nav atrastas', + 'holderPairHint': 'Pārliecinieties, ka turētājs ir ieslēgts un savienošanas pārī režīmā', + 'holderRetryHint': 'Pārliecinieties, ka ierīce ir ieslēgta un tuvumā, pēc tam mēģiniet vēlreiz', + 'autoConnectTitle': 'Automātiska ierīču savienošana', + 'autoConnectDesc': 'Pamodina lietotni, lai atkal savienotos un saņemtu ierakstus, kad ierīce ieslēdzas vai atrodas tuvumā', + 'autoConnectConsentTitle': 'Ieslēgt automātisko savienošanu', + 'autoConnectConsentIntro': 'Lai jūsu ierīce (AI austiņas / AI tālruņa turētājs) varētu savienoties automātiski un saglabāt ierakstus, kad nospiežat tās ierakstīšanas pogu, mums nepieciešama jūsu piekrišana:', + 'autoConnectConsentB1': 'Reģistrēt Bluetooth skenēšanu fonā, lai lietotne tiktu pamodināta (automātiski palaista), kad tiek atrasta saistītā ierīce, un varētu atkal savienoties un saņemt ierakstus', + 'autoConnectConsentB2': 'Pēc pamodināšanas tā tiek izmantota tikai ierīces savienošanai, ierakstīšanai un augšupielādei — nekam citam, un citas lietotnes netiek palaistas', + 'autoConnectConsentB3': 'Lietotni varat izmantot arī bez tā — vienkārši katru reizi savienojiet ierīci manuāli', + 'autoConnectConsentB4': 'To jebkurā laikā varat izslēgt sadaļā Iestatījumi → Automātiska ierīču savienošana', + 'autoConnectAgree': 'Piekrist un ieslēgt', + 'autoConnectLater': 'Ne tagad', + 'autoConnectRequired': 'Automātiskā savienošana ir izslēgta', + 'autoConnectRequiredDesc': 'Ieslēdziet automātisko savienošanu, lai saistītu šo ierīci', + 'recordTapToStart': 'Pieskarieties pogai zemāk, lai sāktu ierakstīt', + 'recordPaused': 'Pauzēts', + 'recordMark': 'Atzīmēt', + 'recordDeleteTitle': 'Dzēst ierakstu', + 'accountAndProfile': 'Konts un profils', + 'nicknameUnset': 'Segvārds nav iestatīts', + 'nicknameInput': 'Ievadiet segvārdu', + 'loginMethod': 'Pieteikšanās veids', + 'manageMyLogin': 'Pārvaldīt pieteikšanos', + 'logoutAction': 'Izrakstīties', + 'warningLabel': 'Brīdinājums:', + 'deleteAccountWarn': 'Jūsu konts un visi faili tiks neatgriezeniski dzēsti.', + 'icpFiling': 'ICP reģistrācija', + 'logExport': 'Eksportēt žurnālus', + 'logExportDesc': 'Skatīt un eksportēt lietotnes žurnālus', + 'permLoadFailed': 'Neizdevās ielādēt atļauju grupas', + 'permInsufficient': 'Atļauja liegta', + 'voiceCloneCreating': 'Veido jūsu balsi…', + 'voiceCloneCreate': 'Izveidot manu balsi', + 'voiceCloneAgree': 'Piekrist un skaļi nolasīt paziņojumu', + 'voiceCloneReadPrompt': 'Lūdzu, nolasiet:', + 'voiceCloneFirstMonthFree': 'Pirmais mēnesis bez maksas', + 'voiceCloneEnable': 'Ieslēgt balss klonēšanu', + 'noMusicPlaying': 'Nekas netiek atskaņots', + 'qqMusicNoSeek': 'QQ Music neatbalsta pārtīšanu', + 'bleScanTitle': 'BLE skenēšana', + 'bluetoothOn': 'Bluetooth ieslēgts', + 'bleScanning': 'Skenē…', + 'bleConnectedDevices': 'Savienotās ierīces', + 'bleUnknownDevice': 'Nezināma ierīce', + 'bleTapToScan': 'Pieskarieties Skenēt, lai meklētu BLE ierīces', + 'weekMon': 'Pr', + 'weekTue': 'Ot', + 'weekWed': 'Tr', + 'weekThu': 'Ce', + 'weekFri': 'Pk', + 'weekSat': 'Se', + 'weekSun': 'Sv', + 'agentMeeting': 'Balss protokoli', + 'agentMeetingDesc': 'Ierakstu kopsavilkumi, atmiņas centrs', + 'agentEmai': 'EMAI asistents', + 'agentEmaiDesc': 'Sarunājieties un jautājiet jebko ar balsi', + 'agentSimultaneous': 'Sinhronā tulkošana', + 'agentSimultaneousDesc': 'Tulko, kamēr runājat', + 'agentFaceToFace': 'Aci pret aci', + 'agentFaceToFaceDesc': 'Divvirzienu sarunu tulkošana', + 'agentCallRecording': 'Zvanu ierakstīšana', + 'agentCallRecordingDesc': 'Zvani tiek saglabāti automātiski', + 'agentFieldRecording': 'Ierakstīšana uz vietas', + 'agentFieldRecordingDesc': 'Ierīces vai tālruņa mikrofons', + 'agentCall': 'Zvanu tulkošana', + 'agentCallDesc': 'Tulkošana reāllaikā zvanu laikā', + 'agentAudioVideo': 'Mediju tulkošana', + 'agentAudioVideoDesc': 'Kameras kadrēšana / audio un video tulkošana', + 'agentEnglishChat': 'Sarunas angļu valodā', + 'agentEnglishChatDesc': 'Iedziļinoša runāšanas prakse', + 'agentEnglishStudy': 'Angļu valodas mācības', + 'agentEnglishStudyDesc': 'Vārdu krājuma un gramatikas nodarbības', + 'agentKidsStory': 'Bērnu stāsti', + 'agentKidsStoryDesc': 'Vakara pasakas un jautājumi-atbildes', + 'moduleRemovedDesc': 'noņemts no AI lapas', + 'moduleAddedDesc': 'pievienots AI lapas apakšā', + 'calNoRecords': 'Nav ierakstu', + 'calNoRecordsDay': 'Nekas nav ieplānots', + 'calTodos': 'Darāmie darbi', + 'calExpenses': 'Izdevumi', + 'calTodoCount': 'Darbi: @count', + 'calExpenseCount': 'Izdevumi: @count', + 'calEntryCount': 'Ieraksti: @count', + 'calItemsUnit': 'vienības', + 'calCountUnit': 'ieraksti', + 'calTotalAmount': 'Kopā', + 'recordPause': 'Pauze', + 'recordResume': 'Turpināt', + 'recordDeleteConfirm': 'Dzēst šo ierakstu?', + 'scopeDay': 'Diena', + 'scopeWeek': 'Nedēļa', + 'scopeMonth': 'Mēnesis', + 'allDay': 'Visu dienu', + 'guestModeTitle': 'Viesa režīms', + 'guestModeDesc': 'Viesi var tikai pārlūkot. Piesakieties, lai izmantotu balss piezīmes, tulkošanu un ierakstīšanu — jūsu dati tiks saglabāti jūsu kontā.', + 'guestKeepBrowsing': 'Turpināt pārlūkot', + 'guestGoLogin': 'Pieteikties', + 'guestViewOnlyTag': 'Tikai skatīšanās', + 'autoConnectReenableHint': 'Automātiskā savienošana ir izslēgta. Ieslēdziet to atkal sadaļā Iestatījumi → Automātiska ierīču savienošana.', + 'plazaEntryDesc': 'Pārlūkojiet un pievienojiet vairāk aģentu', + 'agentTextTranslation': 'Teksta tulkošana', + 'agentTextTranslationDesc': 'Tulkojiet ievadītu vai ielīmētu tekstu', + 'agentPictureTranslation': 'Foto tulkošana', + 'agentPictureTranslationDesc': 'Uzņemiet vai izvēlieties fotoattēlu tulkošanai', + 'callRequiredTitle': 'Nepieciešams zvans', + 'callRequiredDesc': 'Šī funkcija darbojas tikai zvana laikā', + 'inCallBusyTitle': 'Nav pieejams zvana laikā', + 'inCallBusyDesc': 'Mikrofonu un audio ceļu izmanto zvans. Mēģiniet vēlreiz pēc zvana beigšanas.', + 'vipSectionTitle': 'VIP', + 'freeSectionTitle': 'Bezmaksas', + 'smartcarConnected': 'Connected', + 'smartcarConnectFailed': 'Connection failed', + 'smartcarConnecting': 'Connecting…', + 'smartcarSearching': 'Searching for nearby car diffusers…', + 'smartcarNotFound': 'No device found', + 'smartcarScanHint': 'Make sure the device is powered on and close to your phone', + 'vipRequiredTitle': 'Membership required', + 'vipRequiredDesc': 'Translation and meeting notes require a membership. Renew, or pair a device that includes one.', + 'vipRequiredGo': 'Get membership', + 'vipFromDevice': 'from device', + 'computeSummary': 'Compute: device @device · account @user', + 'computePack': 'Compute packs', + 'vipExpiringSoon': 'Membership expires in @days day(s). Renew to keep using translation and meeting notes.', + 'computeLow': 'Running low on compute. You won\'t be able to continue once it runs out.', }; diff --git a/apps/client/lib/core/translations/language/ms_my.dart b/apps/client/lib/core/translations/language/ms_my.dart index a2a4e188..49f0272b 100644 --- a/apps/client/lib/core/translations/language/ms_my.dart +++ b/apps/client/lib/core/translations/language/ms_my.dart @@ -1908,4 +1908,208 @@ const Map msMY = { 'unmarkFavorite': 'Buang dari kegemaran', 'unnamedDevice': 'Peranti tanpa nama', 'userDataParseFailed': 'Gagal membaca data pengguna', + 'catEarphone': 'Fon Telinga AI', + 'catEarphoneDesc': 'Fon telinga Bluetooth klasik / BLE', + 'catPhoneHolder': 'Pemegang Telefon AI', + 'catPhoneHolderDesc': 'Peranti pemegang', + 'catCarAroma': 'Pewangi Kereta AI', + 'catCarAromaDesc': 'Penyebar aroma kereta', + 'agreeAndContinue': 'Setuju dan Teruskan', + 'memberServiceAgreement': 'Perjanjian Perkhidmatan Keahlian', + 'memberServiceAgreementTitle': 'Perjanjian Perkhidmatan Keahlian', + 'memberServiceAgreementIntro': 'Untuk memberikan perkhidmatan keahlian yang lebih baik, sila baca dengan teliti sebelum melanggan: ', + 'memberServiceAgreementOutro': '. Dengan mengetik "Setuju dan Teruskan", anda mengesahkan bahawa anda telah membaca sepenuhnya dan menerima perjanjian di atas.', + 'memberAgreementRejectedTip': 'Anda mesti bersetuju dengan Perjanjian Perkhidmatan Keahlian untuk meneruskan.', + 'translationCallExitTitle': 'Keluar Penterjemahan Panggilan', + 'translationCallExitMessage': 'Pilih tindakan sebelum keluar', + 'translationCallEnd': 'Tamatkan penterjemahan panggilan', + 'translationContinueInBackground': 'Teruskan penterjemahan di latar belakang', + 'backgroundRecordingIndicatorBody': 'Rakaman diteruskan di latar belakang. Ketik untuk kembali.', + 'backgroundTranslationIndicatorBody': 'Penterjemahan panggilan diteruskan di latar belakang. Ketik untuk kembali.', + 'meetingRecordScreenCapturePermissionDenied': 'Kebenaran rakaman skrin diperlukan untuk merakam audio sistem. Sila berikan kebenaran dan cuba lagi.', + 'pleaseGrantBluetoothPermission': 'Sila berikan kebenaran Bluetooth untuk mengimbas', + 'startScanFailed': 'Gagal memulakan imbasan', + 'pleaseLoginFirst': 'Sila log masuk dahulu', + 'deviceUnauthorized': 'Peranti tidak dibenarkan, sila cuba lagi', + 'boundDevices': 'Peranti Terikat', + 'currentConnection': 'Sambungan Semasa', + 'binding': 'Mengikat...', + 'permissionDisabled': 'Dimatikan', + 'confirmDisablePermissionTitle': 'Matikan kebenaran', + 'confirmDisablePermissionContent': 'Matikan kebenaran {permission}? Anda perlu ke tetapan sistem untuk mengaktifkannya semula.', + 'confirmPermanentlyDisallowTitle': 'Berhenti bertanya?', + 'confirmPermanentlyDisallowContent': 'Anda menolak kebenaran {permission}. Berhenti bertanya mulai sekarang? Jika anda memilih "Jangan benarkan", anda masih boleh mengaktifkannya kemudian dalam tetapan sistem.', + 'notNow': 'Bukan sekarang', + 'permanentlyDisallow': 'Jangan benarkan', + 'otaBesNotSupported': 'Set kepala ini belum menyokong naik taraf firmware dalam aplikasi. Sila hubungi sokongan.', + 'tabDevice': 'Peranti', + 'tabPlaza': 'Plaza', + 'sloganHome': 'Teknologi untuk kehidupan lebih bijak', + 'sloganAi': 'Kecerdasan tanpa had', + 'sloganDevice': 'Perkakasan pintar, sedia untuk masa depan', + 'sloganPlaza': 'Dunia pintar yang memahami anda', + 'aiEditHint': 'Tekan lama kad untuk seret dan susun semula modul', + 'aiEmptyTitle': 'Tiada modul ditambah lagi', + 'aiEmptyDesc': 'Pilih ejen yang anda perlukan di Plaza', + 'goToPlaza': 'Pergi ke Plaza', + 'plazaOnline': 'Tersedia', + 'plazaPlanned': 'Akan datang', + 'plazaSearchHint': 'Cari ejen, cth. “terjemah”, “rakam”', + 'moduleRemove': 'Buang', + 'moduleAdd': 'Tambah', + 'moduleRemoved': 'Dibuang', + 'moduleAdded': 'Ditambah', + 'goSee': 'Lihat', + 'comingSoonTag': 'Akan datang', + 'needDeviceTag': 'Perlukan peranti', + 'featureInDev': 'Ciri ini sedang dibangunkan', + 'featureNotWired': 'Belum tersedia', + 'todayLabel': 'Hari ini', + 'deviceConnected': 'Disambungkan', + 'licenseUnknown': 'Lesen tidak diketahui', + 'selectDevice': 'Pilih peranti', + 'catNoteCard': 'Kad Perakam AI', + 'catNoteCardDesc': 'Perakam berbentuk kad', + 'catParkerPen': 'Pen AI', + 'catParkerPenDesc': 'Pen perakam suara', + 'catStylusPen': 'Stilus AI', + 'catStylusPenDesc': 'Peranti tulisan tangan / stilus', + 'catStudyMachine': 'Pad Pembelajaran AI', + 'catStudyMachineDesc': 'Peranti pembelajaran', + 'besAddTitle': 'Tambah Earbud AI', + 'besConnecting': 'Menyambung ke earbud…', + 'besNotFound': 'Earbud tidak ditemui', + 'besNotFoundIosDesc': 'Sambungkan earbud di Tetapan → Bluetooth dahulu, kemudian kembali ke sini', + 'besNotFoundAndroidDesc': 'Pasangkan earbud di Tetapan → Bluetooth dahulu, kemudian sambung di sini', + 'besReadFailed': 'Gagal membaca', + 'besConnectFailIos': 'Pastikan earbud dipaparkan sebagai Disambungkan dalam Bluetooth sistem, kemudian cuba lagi', + 'besConnectFailAndroid': 'Pastikan earbud dihidupkan, berdekatan dan tidak disambungkan ke peranti lain. Earbud akan menyambung semula secara automatik selepas dipautkan.', + 'holderAddTitle': 'Tambah Pemegang Telefon AI', + 'holderConnecting': 'Menyambung ke peranti…', + 'holderSearching': 'Mencari peranti…', + 'holderNotFound': 'Tiada peranti ditemui', + 'holderPairHint': 'Pastikan pemegang dihidupkan dan dalam mod berpasangan', + 'holderRetryHint': 'Pastikan peranti dihidupkan dan berdekatan, kemudian cuba lagi', + 'autoConnectTitle': 'Sambung automatik peranti', + 'autoConnectDesc': 'Bangunkan aplikasi untuk menyambung semula dan menerima rakaman apabila peranti anda dihidupkan atau berdekatan', + 'autoConnectConsentTitle': 'Aktifkan sambung automatik', + 'autoConnectConsentIntro': 'Untuk membolehkan peranti anda (Earbud AI / Pemegang Telefon AI) menyambung secara automatik dan menyimpan rakaman apabila anda menekan butang rakamnya, kami memerlukan persetujuan anda:', + 'autoConnectConsentB1': 'Mendaftarkan imbasan Bluetooth latar belakang supaya aplikasi dibangunkan (dimulakan automatik) apabila peranti terikat anda dikesan, untuk menyambung semula dan menerima rakaman', + 'autoConnectConsentB2': 'Setelah dibangunkan, ia hanya digunakan untuk sambungan peranti, rakaman dan muat naik — tiada yang lain, dan tiada aplikasi lain dilancarkan', + 'autoConnectConsentB3': 'Anda masih boleh menggunakan aplikasi tanpanya — cuma sambungkan peranti secara manual setiap kali', + 'autoConnectConsentB4': 'Anda boleh mematikannya pada bila-bila masa di Tetapan → Sambung automatik peranti', + 'autoConnectAgree': 'Setuju dan aktifkan', + 'autoConnectLater': 'Bukan sekarang', + 'autoConnectRequired': 'Sambung automatik dimatikan', + 'autoConnectRequiredDesc': 'Aktifkan sambung automatik untuk mengikat peranti ini', + 'recordTapToStart': 'Ketik butang di bawah untuk mula merakam', + 'recordPaused': 'Dijeda', + 'recordMark': 'Tanda', + 'recordDeleteTitle': 'Padam rakaman', + 'accountAndProfile': 'Akaun & profil', + 'nicknameUnset': 'Nama panggilan belum ditetapkan', + 'nicknameInput': 'Masukkan nama panggilan', + 'loginMethod': 'Kaedah log masuk', + 'manageMyLogin': 'Urus log masuk', + 'logoutAction': 'Log keluar', + 'warningLabel': 'Amaran:', + 'deleteAccountWarn': 'Akaun anda dan semua fail akan dipadam secara kekal.', + 'icpFiling': 'Pendaftaran ICP', + 'logExport': 'Eksport log', + 'logExportDesc': 'Lihat dan eksport log aplikasi', + 'permLoadFailed': 'Gagal memuatkan kumpulan kebenaran', + 'permInsufficient': 'Kebenaran ditolak', + 'voiceCloneCreating': 'Mencipta suara anda…', + 'voiceCloneCreate': 'Cipta suara saya', + 'voiceCloneAgree': 'Setuju dan baca kenyataan dengan kuat', + 'voiceCloneReadPrompt': 'Sila baca:', + 'voiceCloneFirstMonthFree': 'Bulan pertama percuma', + 'voiceCloneEnable': 'Aktifkan klon suara', + 'noMusicPlaying': 'Tiada yang dimainkan', + 'qqMusicNoSeek': 'Melangkau kedudukan tidak disokong untuk QQ Music', + 'bleScanTitle': 'Imbasan BLE', + 'bluetoothOn': 'Bluetooth dihidupkan', + 'bleScanning': 'Mengimbas…', + 'bleConnectedDevices': 'Peranti disambungkan', + 'bleUnknownDevice': 'Peranti tidak dikenali', + 'bleTapToScan': 'Ketik imbas untuk mencari peranti BLE', + 'weekMon': 'Isn', + 'weekTue': 'Sel', + 'weekWed': 'Rab', + 'weekThu': 'Kha', + 'weekFri': 'Jum', + 'weekSat': 'Sab', + 'weekSun': 'Ahd', + 'agentMeeting': 'Minit Suara', + 'agentMeetingDesc': 'Ringkaskan rakaman, hab memori', + 'agentEmai': 'Pembantu EMAI', + 'agentEmaiDesc': 'Berbual dan tanya apa sahaja melalui suara', + 'agentSimultaneous': 'Penterjemahan Langsung', + 'agentSimultaneousDesc': 'Terjemah semasa anda bercakap', + 'agentFaceToFace': 'Bersemuka', + 'agentFaceToFaceDesc': 'Penterjemahan perbualan dua hala', + 'agentCallRecording': 'Rakaman Panggilan', + 'agentCallRecordingDesc': 'Panggilan disimpan secara automatik', + 'agentFieldRecording': 'Rakaman Lapangan', + 'agentFieldRecordingDesc': 'Mikrofon peranti atau telefon', + 'agentCall': 'Penterjemahan Panggilan', + 'agentCallDesc': 'Terjemahan masa nyata semasa panggilan', + 'agentAudioVideo': 'Penterjemahan Media', + 'agentAudioVideoDesc': 'Bingkai kamera / terjemahan audio & video', + 'agentEnglishChat': 'Sembang Bahasa Inggeris', + 'agentEnglishChatDesc': 'Latihan bertutur secara imersif', + 'agentEnglishStudy': 'Belajar Bahasa Inggeris', + 'agentEnglishStudyDesc': 'Pelajaran kosa kata dan tatabahasa', + 'agentKidsStory': 'Cerita Kanak-kanak', + 'agentKidsStoryDesc': 'Cerita sebelum tidur dan soal jawab', + 'moduleRemovedDesc': 'dibuang daripada halaman AI', + 'moduleAddedDesc': 'ditambah ke bahagian bawah halaman AI', + 'calNoRecords': 'Tiada entri', + 'calNoRecordsDay': 'Tiada jadual', + 'calTodos': 'Tugasan', + 'calExpenses': 'Perbelanjaan', + 'calTodoCount': '@count tugasan', + 'calExpenseCount': '@count perbelanjaan', + 'calEntryCount': '@count entri', + 'calItemsUnit': 'item', + 'calCountUnit': 'entri', + 'calTotalAmount': 'Jumlah', + 'recordPause': 'Jeda', + 'recordResume': 'Sambung semula', + 'recordDeleteConfirm': 'Padam rakaman ini?', + 'scopeDay': 'Hari', + 'scopeWeek': 'Minggu', + 'scopeMonth': 'Bulan', + 'allDay': 'Sepanjang hari', + 'guestModeTitle': 'Mod tetamu', + 'guestModeDesc': 'Tetamu hanya boleh melayari. Log masuk untuk menggunakan nota suara, penterjemahan dan rakaman — data anda akan disimpan ke akaun anda.', + 'guestKeepBrowsing': 'Terus melayari', + 'guestGoLogin': 'Log masuk', + 'guestViewOnlyTag': 'Lihat sahaja', + 'autoConnectReenableHint': 'Sambung automatik dimatikan. Hidupkan semula di Tetapan → Sambung automatik peranti.', + 'plazaEntryDesc': 'Layari dan tambah lebih banyak ejen', + 'agentTextTranslation': 'Penterjemahan Teks', + 'agentTextTranslationDesc': 'Terjemah teks yang ditaip atau ditampal', + 'agentPictureTranslation': 'Penterjemahan Foto', + 'agentPictureTranslationDesc': 'Ambil atau pilih foto untuk diterjemah', + 'callRequiredTitle': 'Panggilan diperlukan', + 'callRequiredDesc': 'Ciri ini hanya berfungsi semasa panggilan', + 'inCallBusyTitle': 'Tidak tersedia semasa panggilan', + 'inCallBusyDesc': 'Mikrofon dan laluan audio sedang digunakan oleh panggilan. Cuba lagi selepas menamatkan panggilan.', + 'vipSectionTitle': 'VIP', + 'freeSectionTitle': 'Percuma', + 'smartcarConnected': 'Connected', + 'smartcarConnectFailed': 'Connection failed', + 'smartcarConnecting': 'Connecting…', + 'smartcarSearching': 'Searching for nearby car diffusers…', + 'smartcarNotFound': 'No device found', + 'smartcarScanHint': 'Make sure the device is powered on and close to your phone', + 'vipRequiredTitle': 'Membership required', + 'vipRequiredDesc': 'Translation and meeting notes require a membership. Renew, or pair a device that includes one.', + 'vipRequiredGo': 'Get membership', + 'vipFromDevice': 'from device', + 'computeSummary': 'Compute: device @device · account @user', + 'computePack': 'Compute packs', + 'vipExpiringSoon': 'Membership expires in @days day(s). Renew to keep using translation and meeting notes.', + 'computeLow': 'Running low on compute. You won\'t be able to continue once it runs out.', }; diff --git a/apps/client/lib/core/translations/language/nb_no.dart b/apps/client/lib/core/translations/language/nb_no.dart index bcf6a136..4a4e8821 100644 --- a/apps/client/lib/core/translations/language/nb_no.dart +++ b/apps/client/lib/core/translations/language/nb_no.dart @@ -1902,4 +1902,208 @@ const Map nbNO = { 'unmarkFavorite': 'Fjern fra favoritter', 'unnamedDevice': 'Enhet uten navn', 'userDataParseFailed': 'Kunne ikke lese brukerdata', + 'catEarphone': 'AI-øreplugger', + 'catEarphoneDesc': 'Klassiske Bluetooth- / BLE-øreplugger', + 'catPhoneHolder': 'AI-mobilholder', + 'catPhoneHolderDesc': 'Holderenheter', + 'catCarAroma': 'AI-bilduft', + 'catCarAromaDesc': 'Aromaspreder for bil', + 'agreeAndContinue': 'Godta og fortsett', + 'memberServiceAgreement': 'Medlemstjenesteavtale', + 'memberServiceAgreementTitle': 'Medlemstjenesteavtale', + 'memberServiceAgreementIntro': 'For å gi deg bedre medlemstjenester ber vi deg lese nøye før du abonnerer: ', + 'memberServiceAgreementOutro': '. Ved å trykke på «Godta og fortsett» bekrefter du at du har lest og godtar avtalen ovenfor.', + 'memberAgreementRejectedTip': 'Du må godta medlemstjenesteavtalen for å fortsette.', + 'translationCallExitTitle': 'Forlat samtaleoversettelse', + 'translationCallExitMessage': 'Velg hva som skal skje før du forlater', + 'translationCallEnd': 'Avslutt samtaleoversettelse', + 'translationContinueInBackground': 'Fortsett oversettelsen i bakgrunnen', + 'backgroundRecordingIndicatorBody': 'Opptaket fortsetter i bakgrunnen. Trykk for å gå tilbake.', + 'backgroundTranslationIndicatorBody': 'Samtaleoversettelsen fortsetter i bakgrunnen. Trykk for å gå tilbake.', + 'meetingRecordScreenCapturePermissionDenied': 'Skjermopptakstillatelse kreves for å ta opp systemlyd. Gi tillatelse og prøv igjen.', + 'pleaseGrantBluetoothPermission': 'Gi Bluetooth-tillatelse for å skanne', + 'startScanFailed': 'Kunne ikke starte skanning', + 'pleaseLoginFirst': 'Logg inn først', + 'deviceUnauthorized': 'Enheten er ikke autorisert, prøv igjen', + 'boundDevices': 'Tilknyttede enheter', + 'currentConnection': 'Gjeldende tilkobling', + 'binding': 'Knytter til...', + 'permissionDisabled': 'Deaktivert', + 'confirmDisablePermissionTitle': 'Slå av tillatelse', + 'confirmDisablePermissionContent': 'Slå av {permission}-tillatelse? Du må gå til systeminnstillingene for å aktivere den igjen.', + 'confirmPermanentlyDisallowTitle': 'Slutte å spørre?', + 'confirmPermanentlyDisallowContent': 'Du avslo {permission}-tillatelse. Slutte å spørre fra nå av? Hvis du velger «Ikke tillat», kan du fortsatt aktivere den senere i systeminnstillingene.', + 'notNow': 'Ikke nå', + 'permanentlyDisallow': 'Ikke tillat', + 'otaBesNotSupported': 'Dette headsetet støtter ennå ikke firmware-oppgradering i appen. Kontakt kundestøtte.', + 'tabDevice': 'Enheter', + 'tabPlaza': 'Plaza', + 'sloganHome': 'Teknologi for et smartere liv', + 'sloganAi': 'Grenseløs intelligens', + 'sloganDevice': 'Smart maskinvare, klar for fremtiden', + 'sloganPlaza': 'En smart verden som forstår deg', + 'aiEditHint': 'Hold inne et kort for å dra og omorganisere moduler', + 'aiEmptyTitle': 'Ingen moduler lagt til ennå', + 'aiEmptyDesc': 'Velg agentene du trenger i Plaza', + 'goToPlaza': 'Gå til Plaza', + 'plazaOnline': 'Tilgjengelig', + 'plazaPlanned': 'Kommer snart', + 'plazaSearchHint': 'Søk etter agenter, f.eks. «oversett», «ta opp»', + 'moduleRemove': 'Fjern', + 'moduleAdd': 'Legg til', + 'moduleRemoved': 'Fjernet', + 'moduleAdded': 'Lagt til', + 'goSee': 'Se', + 'comingSoonTag': 'Kommer snart', + 'needDeviceTag': 'Krever enhet', + 'featureInDev': 'Denne funksjonen er under utvikling', + 'featureNotWired': 'Ikke tilgjengelig ennå', + 'todayLabel': 'I dag', + 'deviceConnected': 'Tilkoblet', + 'licenseUnknown': 'Lisens ukjent', + 'selectDevice': 'Velg en enhet', + 'catNoteCard': 'AI-opptakerkort', + 'catNoteCardDesc': 'Kortformet opptaker', + 'catParkerPen': 'AI-penn', + 'catParkerPenDesc': 'Stemmeopptakerpenner', + 'catStylusPen': 'AI-pekepenn', + 'catStylusPenDesc': 'Håndskrift-/pekepennenheter', + 'catStudyMachine': 'AI-læringsnettbrett', + 'catStudyMachineDesc': 'Læringsenheter', + 'besAddTitle': 'Legg til AI-ørepropper', + 'besConnecting': 'Kobler til øreproppene…', + 'besNotFound': 'Fant ikke øreproppene', + 'besNotFoundIosDesc': 'Koble til øreproppene i Innstillinger → Bluetooth først, og kom så tilbake', + 'besNotFoundAndroidDesc': 'Par øreproppene i Innstillinger → Bluetooth først, og koble til her etterpå', + 'besReadFailed': 'Lesing mislyktes', + 'besConnectFailIos': 'Sørg for at øreproppene vises som Tilkoblet i systemets Bluetooth, og prøv igjen', + 'besConnectFailAndroid': 'Sørg for at øreproppene er på, i nærheten og ikke koblet til en annen enhet. De kobler til på nytt automatisk når de er paret.', + 'holderAddTitle': 'Legg til AI-telefonholder', + 'holderConnecting': 'Kobler til enheten…', + 'holderSearching': 'Søker etter enheter…', + 'holderNotFound': 'Ingen enheter funnet', + 'holderPairHint': 'Sørg for at holderen er på og i paringsmodus', + 'holderRetryHint': 'Sørg for at enheten er på og i nærheten, og prøv igjen', + 'autoConnectTitle': 'Koble til enheter automatisk', + 'autoConnectDesc': 'Vekk appen for å koble til på nytt og motta opptak når enheten din slås på eller kommer i nærheten', + 'autoConnectConsentTitle': 'Aktiver automatisk tilkobling', + 'autoConnectConsentIntro': 'For at enheten din (AI-ørepropper / AI-telefonholder) skal kunne koble til automatisk og lagre opptak når du trykker på opptaksknappen, trenger vi ditt samtykke:', + 'autoConnectConsentB1': 'Registrere en Bluetooth-skanning i bakgrunnen slik at appen vekkes (startes automatisk) når den tilknyttede enheten oppdages, for å koble til på nytt og motta opptak', + 'autoConnectConsentB2': 'Når appen er vekket, brukes den kun til enhetstilkobling, opptak og opplasting — ingenting annet, og ingen andre apper startes', + 'autoConnectConsentB3': 'Du kan fortsatt bruke appen uten dette — bare koble til enheten manuelt hver gang', + 'autoConnectConsentB4': 'Du kan slå det av når som helst i Innstillinger → Koble til enheter automatisk', + 'autoConnectAgree': 'Godta og aktiver', + 'autoConnectLater': 'Ikke nå', + 'autoConnectRequired': 'Automatisk tilkobling er av', + 'autoConnectRequiredDesc': 'Aktiver automatisk tilkobling for å knytte til denne enheten', + 'recordTapToStart': 'Trykk på knappen nedenfor for å starte opptaket', + 'recordPaused': 'Satt på pause', + 'recordMark': 'Marker', + 'recordDeleteTitle': 'Slett opptak', + 'accountAndProfile': 'Konto og profil', + 'nicknameUnset': 'Ingen kallenavn angitt', + 'nicknameInput': 'Skriv inn et kallenavn', + 'loginMethod': 'Innloggingsmetode', + 'manageMyLogin': 'Administrer innlogging', + 'logoutAction': 'Logg ut', + 'warningLabel': 'Advarsel:', + 'deleteAccountWarn': 'Kontoen din og alle filer slettes permanent.', + 'icpFiling': 'ICP-registrering', + 'logExport': 'Eksporter logger', + 'logExportDesc': 'Se og eksporter app-logger', + 'permLoadFailed': 'Kunne ikke laste tillatelsesgrupper', + 'permInsufficient': 'Tillatelse avslått', + 'voiceCloneCreating': 'Oppretter stemmen din…', + 'voiceCloneCreate': 'Opprett min stemme', + 'voiceCloneAgree': 'Godta og les erklæringen høyt', + 'voiceCloneReadPrompt': 'Vennligst les:', + 'voiceCloneFirstMonthFree': 'Første måned gratis', + 'voiceCloneEnable': 'Aktiver stemmekloning', + 'noMusicPlaying': 'Ingenting spilles av', + 'qqMusicNoSeek': 'Spoling støttes ikke for QQ Music', + 'bleScanTitle': 'BLE-skanning', + 'bluetoothOn': 'Bluetooth er på', + 'bleScanning': 'Skanner…', + 'bleConnectedDevices': 'Tilkoblede enheter', + 'bleUnknownDevice': 'Ukjent enhet', + 'bleTapToScan': 'Trykk på skann for å søke etter BLE-enheter', + 'weekMon': 'Man', + 'weekTue': 'Tir', + 'weekWed': 'Ons', + 'weekThu': 'Tor', + 'weekFri': 'Fre', + 'weekSat': 'Lør', + 'weekSun': 'Søn', + 'agentMeeting': 'Talereferat', + 'agentMeetingDesc': 'Oppsummer opptak, minnesenter', + 'agentEmai': 'EMAI-assistent', + 'agentEmaiDesc': 'Chat og spør om alt med stemmen', + 'agentSimultaneous': 'Sanntidsoversettelse', + 'agentSimultaneousDesc': 'Oversett mens du snakker', + 'agentFaceToFace': 'Ansikt-til-ansikt', + 'agentFaceToFaceDesc': 'Toveis samtaleoversettelse', + 'agentCallRecording': 'Samtaleopptak', + 'agentCallRecordingDesc': 'Samtaler lagres automatisk', + 'agentFieldRecording': 'Feltopptak', + 'agentFieldRecordingDesc': 'Enhetens eller telefonens mikrofon', + 'agentCall': 'Samtaleoversettelse', + 'agentCallDesc': 'Sanntidsoversettelse under samtaler', + 'agentAudioVideo': 'Medieoversettelse', + 'agentAudioVideoDesc': 'Kamerainnramming / lyd- og videooversettelse', + 'agentEnglishChat': 'Engelsk samtale', + 'agentEnglishChatDesc': 'Oppslukende taletrening', + 'agentEnglishStudy': 'Engelskstudier', + 'agentEnglishStudyDesc': 'Ordforråds- og grammatikkleksjoner', + 'agentKidsStory': 'Barnefortellinger', + 'agentKidsStoryDesc': 'Godnatthistorier og spørsmål og svar', + 'moduleRemovedDesc': 'fjernet fra AI-siden', + 'moduleAddedDesc': 'lagt til nederst på AI-siden', + 'calNoRecords': 'Ingen oppføringer', + 'calNoRecordsDay': 'Ingenting planlagt', + 'calTodos': 'To-do', + 'calExpenses': 'Utgifter', + 'calTodoCount': '@count to-dos', + 'calExpenseCount': '@count utgifter', + 'calEntryCount': '@count oppføringer', + 'calItemsUnit': 'stk.', + 'calCountUnit': 'oppføringer', + 'calTotalAmount': 'Totalt', + 'recordPause': 'Pause', + 'recordResume': 'Fortsett', + 'recordDeleteConfirm': 'Slette dette opptaket?', + 'scopeDay': 'Dag', + 'scopeWeek': 'Uke', + 'scopeMonth': 'Måned', + 'allDay': 'Hele dagen', + 'guestModeTitle': 'Gjestemodus', + 'guestModeDesc': 'Gjester kan bare se seg rundt. Logg inn for å bruke talereferat, oversettelse og opptak — dataene dine lagres på kontoen din.', + 'guestKeepBrowsing': 'Fortsett å se deg rundt', + 'guestGoLogin': 'Logg inn', + 'guestViewOnlyTag': 'Kun visning', + 'autoConnectReenableHint': 'Automatisk tilkobling er av. Slå det på igjen i Innstillinger → Koble til enheter automatisk.', + 'plazaEntryDesc': 'Bla gjennom og legg til flere agenter', + 'agentTextTranslation': 'Tekstoversettelse', + 'agentTextTranslationDesc': 'Oversett skrevet eller limt inn tekst', + 'agentPictureTranslation': 'Bildeoversettelse', + 'agentPictureTranslationDesc': 'Ta eller velg et bilde for å oversette', + 'callRequiredTitle': 'Samtale kreves', + 'callRequiredDesc': 'Denne funksjonen fungerer bare under en samtale', + 'inCallBusyTitle': 'Ikke tilgjengelig under en samtale', + 'inCallBusyDesc': 'Mikrofonen og lydbanen brukes av samtalen. Prøv igjen etter at du har lagt på.', + 'vipSectionTitle': 'VIP', + 'freeSectionTitle': 'Gratis', + 'smartcarConnected': 'Connected', + 'smartcarConnectFailed': 'Connection failed', + 'smartcarConnecting': 'Connecting…', + 'smartcarSearching': 'Searching for nearby car diffusers…', + 'smartcarNotFound': 'No device found', + 'smartcarScanHint': 'Make sure the device is powered on and close to your phone', + 'vipRequiredTitle': 'Membership required', + 'vipRequiredDesc': 'Translation and meeting notes require a membership. Renew, or pair a device that includes one.', + 'vipRequiredGo': 'Get membership', + 'vipFromDevice': 'from device', + 'computeSummary': 'Compute: device @device · account @user', + 'computePack': 'Compute packs', + 'vipExpiringSoon': 'Membership expires in @days day(s). Renew to keep using translation and meeting notes.', + 'computeLow': 'Running low on compute. You won\'t be able to continue once it runs out.', }; diff --git a/apps/client/lib/core/translations/language/nl_nl.dart b/apps/client/lib/core/translations/language/nl_nl.dart index 59fa9949..efacfdf4 100644 --- a/apps/client/lib/core/translations/language/nl_nl.dart +++ b/apps/client/lib/core/translations/language/nl_nl.dart @@ -1801,4 +1801,363 @@ const Map nlNL = { 'unmarkFavorite': 'Uit favorieten verwijderen', 'unnamedDevice': 'Naamloos apparaat', 'userDataParseFailed': 'Kan gebruikersgegevens niet verwerken', + 'catEarphone': 'AI-oordopjes', + 'catEarphoneDesc': 'Klassieke Bluetooth- / BLE-oordopjes', + 'catPhoneHolder': 'AI-telefoonhouder', + 'catPhoneHolderDesc': 'Houderapparaten', + 'catCarAroma': 'AI-autoverfrisser', + 'catCarAromaDesc': 'Auto-aromaverspreider', + 'agreeAndContinue': 'Akkoord en doorgaan', + 'memberServiceAgreement': 'Lidmaatschapsserviceovereenkomst', + 'memberServiceAgreementTitle': 'Lidmaatschapsserviceovereenkomst', + 'memberServiceAgreementIntro': 'Om u betere lidmaatschapsservices te bieden, verzoeken wij u dit aandachtig te lezen voordat u zich abonneert: ', + 'memberServiceAgreementOutro': '. Door op "Akkoord en doorgaan" te klikken, bevestigt u dat u de bovenstaande overeenkomst volledig hebt gelezen en accepteert.', + 'memberAgreementRejectedTip': 'U moet akkoord gaan met de Lidmaatschapsserviceovereenkomst om door te gaan.', + 'exploreAi': 'Ontdek de AI-functies van Eaimar', + 'aiFunction': 'AI-functies', + 'allRoundAssistant': 'Alround assistent', + 'allRoundAssistantInfo': 'Helpt bij allerlei vragen en problemen', + 'simultaneousTranslation': 'Realtime vertaling', + 'faceToFaceTranslation': 'Face-to-face vertaling', + 'audioVideoTranslation': 'Multimediavertaling', + 'callTranslation': 'Gespreksvertaling', + 'simultaneousTranslationInfo': 'Directe vertaling in meer dan 80 talen', + 'aiGirlfriend': 'Ouder-kindband', + 'aiGirlfriendInfo': 'Slim gezelschap, altijd om mee te praten', + 'healthConsultant': 'Multifunctionele AI-chat', + 'healthConsultantInfo': 'Meer AI-chatfuncties', + 'conferenceAssistant': 'Zakelijke vergaderassistent', + 'conferenceAssistantInfo': 'Realtime notulen en meertalige vertaling', + 'usageStats': 'Gebruiksstatistieken', + 'usageStatsInfo': 'Bekijk uw gebruiksstatistieken', + 'scene': 'Scenario\'s', + 'aiChat': 'AI-chat', + 'translationStats': 'Vertaalstatistieken', + 'meetingStats': 'Vergaderstatistieken', + 'today': 'Vandaag', + 'thisWeek': 'Deze week', + 'thisMonth': 'Deze maand', + 'history': 'Geschiedenis', + 'currentSessionStats': 'Statistieken huidige sessie', + 'translationCount': 'Aantal vertalingen', + 'sessionDuration': 'Sessieduur', + 'characters': 'Tekens', + 'recordingDuration': 'Opnameduur', + 'totalApiCalls': 'Totaal API-aanroepen', + 'sessionCount': 'Aantal sessies', + 'totalCharacters': 'Totaal aantal tekens', + 'apiCallStats': 'API-aanroepstatistieken', + 'translationApi': 'Vertaal-API', + 'asr': 'ASR', + 'tts': 'TTS', + 'dataVolumeStats': 'Datavolumestatistieken', + 'sourceTextCharacters': 'Tekens brontekst', + 'ttsTextCharacters': 'Tekens TTS-tekst', + 'effectiveAudioDuration': 'Effectieve audioduur', + 'totalSessionDuration': 'Totale sessieduur', + 'costStats': 'Kostenstatistieken', + 'estimatedCost': 'Geschatte kosten', + 'dailyCostThreshold': 'Dagelijkse kostenlimiet', + 'monthlyCostThreshold': 'Maandelijkse kostenlimiet', + 'usageBehaviorStats': 'Gebruiksgedragstatistieken', + 'mostUsedLanguagePair': 'Meest gebruikte talenpaar', + 'mostUsedMode': 'Meest gebruikte modus', + 'asrApiCalls': 'ASR-aanroepen', + 'totalAudioDuration': 'Totale audioduur', + 'audioFileSize': 'Grootte audiobestand', + 'transcriptBytes': 'Transcriptbytes', + 'loadFailed': 'Laden mislukt', + 'statsLoadFailedTryLater': 'Laden van statistieken mislukt. Probeer het later opnieuw.', + 'availablePackages': 'Beschikbare pakketten', + 'remainingQuota': 'Resterend quota', + 'totalQuota': 'Totaal quota', + 'remaining': 'Resterend', + 'featureDescription': 'Functiebeschrijving', + 'paymentSuccess': 'Betaling geslaagd', + 'gotIt': 'Begrepen', + 'paymentFailedTryAgain': 'Betaling mislukt. Probeer het opnieuw.', + 'alipayPay': 'Alipay', + 'paypalPay': 'PayPal', + 'appleIap': 'Apple IAP', + 'googleIap': 'Google IAP', + 'wechatOrderCreateFailed': 'Aanmaken van WeChat-bestelling mislukt', + 'alipayOrderCreateFailed': 'Aanmaken van Alipay-bestelling mislukt', + 'appleOrderCreateFailed': 'Aanmaken van Apple-bestelling mislukt', + 'googleOrderCreateFailed': 'Aanmaken van Google-bestelling mislukt', + 'paypalOrderCreateFailed': 'Aanmaken van PayPal-bestelling mislukt', + 'paymentException': 'Betalingsfout', + 'paymentLaunchFailed': 'Starten van betaling mislukt', + 'paymentExceptionWithError': 'Betalingsfout: @error', + 'paymentProcessingCheckLater': 'Uw bestelling wordt verwerkt. Controleer het later opnieuw.', + 'paymentResultQueryException': 'Opvragen van betalingsresultaat mislukt: @error', + 'orderConfirmedQuotaRefreshed': 'Bestelling bevestigd. Uw quota is bijgewerkt.', + 'paymentCompletedConfirming': 'Betaling voltooid. Bevestigen…', + 'paymentCancelled': 'Betaling geannuleerd', + 'paypalOpenedCompleteInBrowser': 'PayPal is geopend. Voltooi de betaling in uw browser.', + 'paypalOpenFailed': 'Kan de PayPal-betaalpagina niet openen', + 'paymentTestTitle': 'Betalingstest', + 'paymentTestOrderInfo': 'Testbestelgegevens', + 'paymentTestProductId': 'Product-ID', + 'paymentTestProductDescription': 'Productbeschrijving', + 'paymentTestWechatDescription': 'WeChat Pay-betalingstest', + 'paymentTestAlipayDescription': 'Alipay-betalingstest', + 'paymentTestAmount': 'Bedrag', + 'paymentTestSelectAmount': 'Selecteer testbedrag', + 'paying': 'Bezig met betalen...', + 'wechatPayWithAmount': 'WeChat Pay ¥@amount', + 'alipayPayWithAmount': 'Alipay ¥@amount', + 'paymentStatusTitle': 'Betalingsstatus', + 'errorInfoWithMessage': 'Fout: @message', + 'retest': 'Opnieuw testen', + 'paymentTestInstructionsTitle': 'Instructies', + 'paymentTestInstructionsContent': '1. Selecteer een testbedrag (aanbevolen: ¥0,01)\n2. Tik op de knop "WeChat Pay"\n3. De app roept de WeChat Pay SDK aan\n4. Voltooi de betaling in WeChat\n5. Keer terug naar de app om het resultaat te bekijken\n\nLet op: dit is een testomgeving. Er wordt niets in rekening gebracht.', + 'startingWechatPayment': 'WeChat Pay wordt gestart...', + 'startingAlipayPayment': 'Alipay wordt gestart...', + 'wechatPaymentSuccess': 'WeChat Pay-betaling geslaagd!', + 'wechatPaymentFailed': 'WeChat Pay-betaling mislukt', + 'wechatPaymentLaunchFailed': 'Starten van WeChat Pay mislukt', + 'cannotLaunchWechatPayment': 'Kan WeChat Pay niet starten', + 'wechatPaymentException': 'WeChat Pay-fout', + 'wechatPaymentTestCompleted': 'WeChat Pay-test voltooid', + 'alipayPaymentSuccess': 'Alipay-betaling geslaagd!', + 'alipayPaymentFailed': 'Alipay-betaling mislukt', + 'alipayPaymentLaunchFailed': 'Starten van Alipay mislukt', + 'cannotLaunchAlipayPayment': 'Kan Alipay niet starten', + 'alipayPaymentException': 'Alipay-fout', + 'insufficientIntegral': 'Onvoldoende tegoed', + 'insufficientBalanceDesc': 'Onvoldoende saldo. Ga naar de bijbehorende opwaardeerpagina om op te waarderen.', + 'recharge': 'Naar opwaarderen', + 'aiMemberExpiredDesc': 'Uw AI-lidmaatschap is verlopen. Ga naar de lidmaatschapspagina om te verlengen.', + 'musicSearchPlaybackControl': 'Muziekbibliotheek doorzoeken en afspelen bedienen', + 'smartPlaylistAndSimilarRecommendations': 'Slimme afspeellijsten en aanbevelingen van vergelijkbare nummers', + 'routePlanningAndCongestionAvoidance': 'Routeplanning en files vermijden', + 'voiceBroadcastAndWaypoints': 'Gesproken aanwijzingen en tussenpunten instellen', + 'realTimeWeatherForecast': 'Actueel weer en weersverwachting', + 'clothingAdviceAndTravelReminders': 'Kledingadvies en reisherinneringen', + 'poiSearchFoodGasParking': 'POI-zoeken: eten / tanken / parkeren', + 'ratingsHoursAndDirectRoutes': 'Beoordelingen, openingstijden en directe routes', + 'hotNewsAndTopicOverview': 'Actueel nieuws en onderwerpoverzicht', + 'longArticleSummariesAndKeyPoints': 'Samenvattingen en kernpunten van lange artikelen', + 'helpCallAndSMS': 'Hulp bij bellen en sms\'en', + 'templatedMessagesAndScheduleReminders': 'Sjabloonberichten en agendaherinneringen', + 'aiDisclaimer': 'Functies zijn afhankelijk van apparaatmachtigingen, beschikbaarheid van de service en netwerkomstandigheden.', + 'realtimeTranscription': 'Realtime transcriptie', + 'sceneLexicon': 'Scenariowoordenlijst', + 'subtitleDisplay': 'Ondertitels', + 'transHeaderDesc1': 'Realtime simultaanvertaling en tekstvertaling met spraak met lage latentie.', + 'transHeaderDesc2': 'Geschikt voor reizen, zakelijk gebruik, toespraken en onderwijs.', + 'translateZhEn': 'Chinees-Engelse vertaling', + 'lowLatencyVoiceBroadcast': 'Spraakweergave met lage latentie', + 'multiLangTextTranslation': 'Meertalige tekstvertaling', + 'termOptimizationContextPreserve': 'Terminologie-optimalisatie en contextbehoud', + 'lexiconTourBusinessLife': 'Woordenlijsten voor reizen/zakelijk/dagelijks leven', + 'personalVocabularyMemory': 'Persoonlijk woordenschatgeheugen', + 'conversationSubtitleLargeText': 'Gespreksondertitels / grote-tekstmodus', + 'colorHighContrast': 'Hoog kleurcontrast', + 'transDisclaimer': 'De vertaalkwaliteit is afhankelijk van de microfoon, netwerklatentie en omgevingsgeluid.', + 'meetingRecording': 'Vergaderopname', + 'meetingHeaderDesc1': 'Audio-opname, transcriptie, zoeken op trefwoord en automatische samenvatting voor efficiëntere vergaderingen.', + 'meetingAutoRecording': 'Automatisch opnemen en segmenteren', + 'meetingDisclaimer': 'Gebruik in een rustige omgeving; de prestaties zijn afhankelijk van microfoon en netwerk.', + 'crossDeviceSync': 'Synchronisatie tussen apparaten', + 'trans_001_name': 'Vertaling Basis 100', + 'trans_002_name': 'Vertaling Basis 500', + 'trans_003_name': 'Vertaling Basis 1000', + 'trans_001_desc': 'Bevat 100 vertalingen (spraak/tekst).', + 'trans_002_desc': 'Bevat 500 vertalingen (spraak/tekst).', + 'trans_003_desc': 'Bevat 1000 vertalingen (spraak/tekst).', + 'translationCallExitTitle': 'Gespreksvertaling verlaten', + 'translationCallExitMessage': 'Kies wat u wilt doen voordat u vertrekt', + 'translationCallEnd': 'Gespreksvertaling beëindigen', + 'translationContinueInBackground': 'Vertaling op de achtergrond voortzetten', + 'backgroundRecordingIndicatorBody': 'De opname gaat door op de achtergrond. Tik om terug te keren.', + 'backgroundTranslationIndicatorBody': 'De gespreksvertaling gaat door op de achtergrond. Tik om terug te keren.', + 'meetingRecordScreenCapturePermissionDenied': 'Schermopnamemachtiging is vereist om systeemaudio op te nemen. Verleen deze en probeer het opnieuw.', + 'pleaseGrantBluetoothPermission': 'Verleen Bluetooth-machtiging om te scannen', + 'startScanFailed': 'Starten van scan mislukt', + 'pleaseLoginFirst': 'Log eerst in', + 'deviceUnauthorized': 'Apparaat niet geautoriseerd, probeer het opnieuw', + 'boundDevices': 'Gekoppelde apparaten', + 'currentConnection': 'Huidige verbinding', + 'binding': 'Koppelen...', + 'permissionDisabled': 'Uitgeschakeld', + 'confirmDisablePermissionTitle': 'Machtiging uitschakelen', + 'confirmDisablePermissionContent': 'Machtiging {permission} uitschakelen? U moet naar de systeeminstellingen gaan om deze opnieuw in te schakelen.', + 'confirmPermanentlyDisallowTitle': 'Niet meer vragen?', + 'confirmPermanentlyDisallowContent': 'U hebt de machtiging {permission} geweigerd. Voortaan niet meer vragen? Als u "Niet toestaan" kiest, kunt u deze later nog steeds inschakelen in de systeeminstellingen.', + 'notNow': 'Nu niet', + 'permanentlyDisallow': 'Niet toestaan', + 'otaBesNotSupported': 'Deze headset ondersteunt nog geen firmware-upgrade via de app. Neem contact op met de klantenservice.', + '乌拉圭': 'Uruguay', + '黎巴嫩': 'Libanon', + 'tabDevice': 'Apparaten', + 'tabPlaza': 'Plaza', + 'sloganHome': 'Technologie voor een slimmer leven', + 'sloganAi': 'Grenzeloze intelligentie', + 'sloganDevice': 'Slimme hardware, klaar voor de toekomst', + 'sloganPlaza': 'Een slimme wereld die u begrijpt', + 'aiEditHint': 'Houd een kaart ingedrukt om modules te verslepen en te herschikken', + 'aiEmptyTitle': 'Nog geen modules toegevoegd', + 'aiEmptyDesc': 'Kies de agents die u nodig hebt in Plaza', + 'goToPlaza': 'Naar Plaza', + 'plazaOnline': 'Beschikbaar', + 'plazaPlanned': 'Binnenkort beschikbaar', + 'plazaSearchHint': 'Zoek agents, bijv. “vertalen”, “opnemen”', + 'moduleRemove': 'Verwijderen', + 'moduleAdd': 'Toevoegen', + 'moduleRemoved': 'Verwijderd', + 'moduleAdded': 'Toegevoegd', + 'goSee': 'Bekijken', + 'comingSoonTag': 'Binnenkort', + 'needDeviceTag': 'Apparaat vereist', + 'featureInDev': 'Deze functie is in ontwikkeling', + 'featureNotWired': 'Nog niet beschikbaar', + 'todayLabel': 'Vandaag', + 'deviceConnected': 'Verbonden', + 'licenseUnknown': 'Licentie onbekend', + 'selectDevice': 'Selecteer een apparaat', + 'catNoteCard': 'AI-opnamekaart', + 'catNoteCardDesc': 'Recorder in kaartvorm', + 'catParkerPen': 'AI-pen', + 'catParkerPenDesc': 'Pennen met spraakrecorder', + 'catStylusPen': 'AI-stylus', + 'catStylusPenDesc': 'Handschrift-/stylusapparaten', + 'catStudyMachine': 'AI-leertablet', + 'catStudyMachineDesc': 'Leerapparaten', + 'besAddTitle': 'AI-oortjes toevoegen', + 'besConnecting': 'Verbinden met oortjes…', + 'besNotFound': 'Oortjes niet gevonden', + 'besNotFoundIosDesc': 'Verbind de oortjes eerst via Instellingen → Bluetooth en kom daarna terug', + 'besNotFoundAndroidDesc': 'Koppel de oortjes eerst via Instellingen → Bluetooth en verbind ze daarna hier', + 'besReadFailed': 'Lezen mislukt', + 'besConnectFailIos': 'Controleer of de oortjes als Verbonden worden weergegeven in de Bluetooth-instellingen en probeer het opnieuw', + 'besConnectFailAndroid': 'Zorg dat de oortjes aan staan, in de buurt zijn en niet met een ander apparaat verbonden zijn. Zodra ze gekoppeld zijn, verbinden ze automatisch opnieuw.', + 'holderAddTitle': 'AI-telefoonhouder toevoegen', + 'holderConnecting': 'Verbinden met apparaat…', + 'holderSearching': 'Apparaten zoeken…', + 'holderNotFound': 'Geen apparaten gevonden', + 'holderPairHint': 'Zorg dat de houder aan staat en in koppelmodus is', + 'holderRetryHint': 'Zorg dat het apparaat aan staat en in de buurt is, en probeer het opnieuw', + 'autoConnectTitle': 'Apparaten automatisch verbinden', + 'autoConnectDesc': 'De app wekken om opnieuw te verbinden en opnames te ontvangen wanneer uw apparaat wordt ingeschakeld of in de buurt komt', + 'autoConnectConsentTitle': 'Automatisch verbinden inschakelen', + 'autoConnectConsentIntro': 'Om uw apparaat (AI-oortjes / AI-telefoonhouder) automatisch te laten verbinden en opnames op te slaan wanneer u op de opnameknop drukt, hebben we uw toestemming nodig:', + 'autoConnectConsentB1': 'Een Bluetooth-scan op de achtergrond registreren zodat de app wordt gewekt (automatisch gestart) wanneer uw gekoppelde apparaat wordt gedetecteerd, om opnieuw te verbinden en opnames te ontvangen', + 'autoConnectConsentB2': 'Na het wekken wordt dit alleen gebruikt voor apparaatverbinding, opname en uploaden — niets anders, en er worden geen andere apps gestart', + 'autoConnectConsentB3': 'U kunt de app ook zonder gebruiken — verbind het apparaat dan telkens handmatig', + 'autoConnectConsentB4': 'U kunt dit altijd uitschakelen via Instellingen → Apparaten automatisch verbinden', + 'autoConnectAgree': 'Akkoord en inschakelen', + 'autoConnectLater': 'Nu niet', + 'autoConnectRequired': 'Automatisch verbinden staat uit', + 'autoConnectRequiredDesc': 'Schakel automatisch verbinden in om dit apparaat te koppelen', + 'recordTapToStart': 'Tik op de onderstaande knop om de opname te starten', + 'recordPaused': 'Gepauzeerd', + 'recordMark': 'Markeren', + 'recordDeleteTitle': 'Opname verwijderen', + 'accountAndProfile': 'Account en profiel', + 'nicknameUnset': 'Geen bijnaam ingesteld', + 'nicknameInput': 'Voer een bijnaam in', + 'loginMethod': 'Inlogmethode', + 'manageMyLogin': 'Inloggen beheren', + 'logoutAction': 'Uitloggen', + 'warningLabel': 'Waarschuwing:', + 'deleteAccountWarn': 'Uw account en alle bestanden worden permanent verwijderd.', + 'icpFiling': 'ICP-registratie', + 'logExport': 'Logboeken exporteren', + 'logExportDesc': 'App-logboeken bekijken en exporteren', + 'permLoadFailed': 'Laden van machtigingsgroepen mislukt', + 'permInsufficient': 'Machtiging geweigerd', + 'voiceCloneCreating': 'Uw stem wordt aangemaakt…', + 'voiceCloneCreate': 'Mijn stem aanmaken', + 'voiceCloneAgree': 'Akkoord en lees de verklaring hardop voor', + 'voiceCloneReadPrompt': 'Lees voor:', + 'voiceCloneFirstMonthFree': 'Eerste maand gratis', + 'voiceCloneEnable': 'Stemklonen inschakelen', + 'noMusicPlaying': 'Er wordt niets afgespeeld', + 'qqMusicNoSeek': 'Doorspoelen wordt niet ondersteund voor QQ Music', + 'bleScanTitle': 'BLE-scan', + 'bluetoothOn': 'Bluetooth aan', + 'bleScanning': 'Scannen…', + 'bleConnectedDevices': 'Verbonden apparaten', + 'bleUnknownDevice': 'Onbekend apparaat', + 'bleTapToScan': 'Tik op scannen om BLE-apparaten te zoeken', + 'weekMon': 'Ma', + 'weekTue': 'Di', + 'weekWed': 'Wo', + 'weekThu': 'Do', + 'weekFri': 'Vr', + 'weekSat': 'Za', + 'weekSun': 'Zo', + 'agentMeeting': 'Spraaknotulen', + 'agentMeetingDesc': 'Opnames samenvatten, geheugencentrum', + 'agentEmai': 'EMAI-assistent', + 'agentEmaiDesc': 'Chat en vraag alles met uw stem', + 'agentSimultaneous': 'Live vertaling', + 'agentSimultaneousDesc': 'Vertalen terwijl u spreekt', + 'agentFaceToFace': 'Face-to-face', + 'agentFaceToFaceDesc': 'Tweerichtingsgespreksvertaling', + 'agentCallRecording': 'Gespreksopname', + 'agentCallRecordingDesc': 'Gesprekken worden automatisch opgeslagen', + 'agentFieldRecording': 'Opname ter plaatse', + 'agentFieldRecordingDesc': 'Microfoon van apparaat of telefoon', + 'agentCall': 'Gespreksvertaling', + 'agentCallDesc': 'Realtime vertaling tijdens gesprekken', + 'agentAudioVideo': 'Mediavertaling', + 'agentAudioVideoDesc': 'Camerabeeld / audio- en videovertaling', + 'agentEnglishChat': 'Engelse conversatie', + 'agentEnglishChatDesc': 'Meeslepende spreekoefening', + 'agentEnglishStudy': 'Engels leren', + 'agentEnglishStudyDesc': 'Woordenschat- en grammaticalessen', + 'agentKidsStory': 'Kinderverhalen', + 'agentKidsStoryDesc': 'Verhaaltjes voor het slapengaan en vragen', + 'moduleRemovedDesc': 'verwijderd van de AI-pagina', + 'moduleAddedDesc': 'onderaan de AI-pagina toegevoegd', + 'calNoRecords': 'Geen items', + 'calNoRecordsDay': 'Niets gepland', + 'calTodos': 'Taken', + 'calExpenses': 'Uitgaven', + 'calTodoCount': '@count taken', + 'calExpenseCount': '@count uitgaven', + 'calEntryCount': '@count items', + 'calItemsUnit': 'items', + 'calCountUnit': 'items', + 'calTotalAmount': 'Totaal', + 'recordPause': 'Pauzeren', + 'recordResume': 'Hervatten', + 'recordDeleteConfirm': 'Deze opname verwijderen?', + 'scopeDay': 'Dag', + 'scopeWeek': 'Week', + 'scopeMonth': 'Maand', + 'allDay': 'Hele dag', + 'guestModeTitle': 'Gastmodus', + 'guestModeDesc': 'Gasten kunnen alleen bekijken. Log in om spraaknotities, vertaling en opname te gebruiken — uw gegevens worden dan in uw account opgeslagen.', + 'guestKeepBrowsing': 'Verder kijken', + 'guestGoLogin': 'Inloggen', + 'guestViewOnlyTag': 'Alleen bekijken', + 'autoConnectReenableHint': 'Automatisch verbinden staat uit. Schakel het weer in via Instellingen → Apparaten automatisch verbinden.', + 'plazaEntryDesc': 'Blader door en voeg meer agents toe', + 'agentTextTranslation': 'Tekstvertaling', + 'agentTextTranslationDesc': 'Getypte of geplakte tekst vertalen', + 'agentPictureTranslation': 'Fotovertaling', + 'agentPictureTranslationDesc': 'Maak of kies een foto om te vertalen', + 'callRequiredTitle': 'Gesprek vereist', + 'callRequiredDesc': 'Deze functie werkt alleen tijdens een gesprek', + 'inCallBusyTitle': 'Niet beschikbaar tijdens een gesprek', + 'inCallBusyDesc': 'De microfoon en het audiopad worden gebruikt door het gesprek. Probeer het opnieuw na het ophangen.', + 'vipSectionTitle': 'VIP', + 'freeSectionTitle': 'Gratis', + 'smartcarConnected': 'Connected', + 'smartcarConnectFailed': 'Connection failed', + 'smartcarConnecting': 'Connecting…', + 'smartcarSearching': 'Searching for nearby car diffusers…', + 'smartcarNotFound': 'No device found', + 'smartcarScanHint': 'Make sure the device is powered on and close to your phone', + 'vipRequiredTitle': 'Membership required', + 'vipRequiredDesc': 'Translation and meeting notes require a membership. Renew, or pair a device that includes one.', + 'vipRequiredGo': 'Get membership', + 'vipFromDevice': 'from device', + 'computeSummary': 'Compute: device @device · account @user', + 'computePack': 'Compute packs', + 'vipExpiringSoon': 'Membership expires in @days day(s). Renew to keep using translation and meeting notes.', + 'computeLow': 'Running low on compute. You won\'t be able to continue once it runs out.', }; diff --git a/apps/client/lib/core/translations/language/pl_pl.dart b/apps/client/lib/core/translations/language/pl_pl.dart index 8fd44905..db72b09b 100644 --- a/apps/client/lib/core/translations/language/pl_pl.dart +++ b/apps/client/lib/core/translations/language/pl_pl.dart @@ -1930,4 +1930,208 @@ const Map plPL = { 'unmarkFavorite': 'Usuń z ulubionych', 'unnamedDevice': 'Urządzenie bez nazwy', 'userDataParseFailed': 'Nie udało się odczytać danych użytkownika', + 'catEarphone': 'Słuchawki AI', + 'catEarphoneDesc': 'Słuchawki Bluetooth Classic / BLE', + 'catPhoneHolder': 'Uchwyt na telefon AI', + 'catPhoneHolderDesc': 'Urządzenia typu uchwyt', + 'catCarAroma': 'Zapach samochodowy AI', + 'catCarAromaDesc': 'Dyfuzor zapachu do auta', + 'agreeAndContinue': 'Zgadzam się i kontynuuję', + 'memberServiceAgreement': 'Umowa o usługi członkowskie', + 'memberServiceAgreementTitle': 'Umowa o usługi członkowskie', + 'memberServiceAgreementIntro': 'Aby zapewnić Ci lepsze usługi członkowskie, przed subskrypcją uważnie przeczytaj: ', + 'memberServiceAgreementOutro': '. Klikając „Zgadzam się i kontynuuję”, potwierdzasz, że w pełni zapoznałeś(-aś) się z powyższą umową i ją akceptujesz.', + 'memberAgreementRejectedTip': 'Aby kontynuować, musisz zaakceptować Umowę o usługi członkowskie.', + 'translationCallExitTitle': 'Wyjdź z tłumaczenia rozmowy', + 'translationCallExitMessage': 'Wybierz, co zrobić przed wyjściem', + 'translationCallEnd': 'Zakończ tłumaczenie rozmowy', + 'translationContinueInBackground': 'Kontynuuj tłumaczenie w tle', + 'backgroundRecordingIndicatorBody': 'Nagrywanie trwa w tle. Dotknij, aby wrócić.', + 'backgroundTranslationIndicatorBody': 'Tłumaczenie rozmowy trwa w tle. Dotknij, aby wrócić.', + 'meetingRecordScreenCapturePermissionDenied': 'Do przechwytywania dźwięku systemowego wymagane jest uprawnienie do nagrywania ekranu. Przyznaj je i spróbuj ponownie.', + 'pleaseGrantBluetoothPermission': 'Aby skanować, przyznaj uprawnienie Bluetooth', + 'startScanFailed': 'Nie udało się rozpocząć skanowania', + 'pleaseLoginFirst': 'Najpierw się zaloguj', + 'deviceUnauthorized': 'Urządzenie nieautoryzowane, spróbuj ponownie', + 'boundDevices': 'Powiązane urządzenia', + 'currentConnection': 'Bieżące połączenie', + 'binding': 'Wiązanie…', + 'permissionDisabled': 'Wyłączone', + 'confirmDisablePermissionTitle': 'Wyłącz uprawnienie', + 'confirmDisablePermissionContent': 'Wyłączyć uprawnienie {permission}? Aby je ponownie włączyć, trzeba będzie przejść do ustawień systemu.', + 'confirmPermanentlyDisallowTitle': 'Przestać pytać?', + 'confirmPermanentlyDisallowContent': 'Odmówiono uprawnienia {permission}. Przestać pytać od teraz? Jeśli wybierzesz „Nie zezwalaj”, nadal możesz je później włączyć w ustawieniach systemu.', + 'notNow': 'Nie teraz', + 'permanentlyDisallow': 'Nie zezwalaj', + 'otaBesNotSupported': 'Te słuchawki nie obsługują jeszcze aktualizacji oprogramowania z poziomu aplikacji. Skontaktuj się z pomocą techniczną.', + 'tabDevice': 'Urządzenia', + 'tabPlaza': 'Plaza', + 'sloganHome': 'Technologia dla mądrzejszego życia', + 'sloganAi': 'Inteligencja bez granic', + 'sloganDevice': 'Inteligentny sprzęt gotowy na przyszłość', + 'sloganPlaza': 'Inteligentny świat, który Cię rozumie', + 'aiEditHint': 'Przytrzymaj kartę, aby przeciągnąć i zmienić układ modułów', + 'aiEmptyTitle': 'Nie dodano jeszcze modułów', + 'aiEmptyDesc': 'Wybierz potrzebnych agentów w Plaza', + 'goToPlaza': 'Przejdź do Plaza', + 'plazaOnline': 'Dostępne', + 'plazaPlanned': 'Wkrótce', + 'plazaSearchHint': 'Szukaj agentów, np. „tłumacz”, „nagraj”', + 'moduleRemove': 'Usuń', + 'moduleAdd': 'Dodaj', + 'moduleRemoved': 'Usunięto', + 'moduleAdded': 'Dodano', + 'goSee': 'Zobacz', + 'comingSoonTag': 'Wkrótce', + 'needDeviceTag': 'Wymaga urządzenia', + 'featureInDev': 'Ta funkcja jest w przygotowaniu', + 'featureNotWired': 'Jeszcze niedostępne', + 'todayLabel': 'Dziś', + 'deviceConnected': 'Połączono', + 'licenseUnknown': 'Licencja nieznana', + 'selectDevice': 'Wybierz urządzenie', + 'catNoteCard': 'Karta dyktafonu AI', + 'catNoteCardDesc': 'Dyktafon w kształcie karty', + 'catParkerPen': 'Długopis AI', + 'catParkerPenDesc': 'Długopisy z dyktafonem', + 'catStylusPen': 'Rysik AI', + 'catStylusPenDesc': 'Urządzenia do pisma odręcznego / rysiki', + 'catStudyMachine': 'Tablet edukacyjny AI', + 'catStudyMachineDesc': 'Urządzenia edukacyjne', + 'besAddTitle': 'Dodaj słuchawki AI', + 'besConnecting': 'Łączenie ze słuchawkami…', + 'besNotFound': 'Nie znaleziono słuchawek', + 'besNotFoundIosDesc': 'Najpierw połącz słuchawki w Ustawienia → Bluetooth, a potem wróć tutaj', + 'besNotFoundAndroidDesc': 'Najpierw sparuj słuchawki w Ustawienia → Bluetooth, a potem połącz je tutaj', + 'besReadFailed': 'Odczyt nie powiódł się', + 'besConnectFailIos': 'Upewnij się, że słuchawki są widoczne jako Połączone w systemowym Bluetooth, a następnie spróbuj ponownie', + 'besConnectFailAndroid': 'Upewnij się, że słuchawki są włączone, w pobliżu i niepołączone z innym urządzeniem. Po powiązaniu będą łączyć się automatycznie.', + 'holderAddTitle': 'Dodaj podstawkę AI na telefon', + 'holderConnecting': 'Łączenie z urządzeniem…', + 'holderSearching': 'Wyszukiwanie urządzeń…', + 'holderNotFound': 'Nie znaleziono urządzeń', + 'holderPairHint': 'Upewnij się, że podstawka jest włączona i w trybie parowania', + 'holderRetryHint': 'Upewnij się, że urządzenie jest włączone i w pobliżu, a następnie spróbuj ponownie', + 'autoConnectTitle': 'Automatyczne łączenie urządzeń', + 'autoConnectDesc': 'Budzi aplikację, aby ponownie połączyć się i odebrać nagrania, gdy urządzenie zostanie włączone lub znajdzie się w pobliżu', + 'autoConnectConsentTitle': 'Włącz automatyczne łączenie', + 'autoConnectConsentIntro': 'Aby Twoje urządzenie (słuchawki AI / podstawka AI na telefon) mogło łączyć się automatycznie i zapisywać nagrania po naciśnięciu przycisku nagrywania, potrzebujemy Twojej zgody:', + 'autoConnectConsentB1': 'Zarejestrowanie skanowania Bluetooth w tle, aby aplikacja była budzona (uruchamiana automatycznie) po wykryciu powiązanego urządzenia w celu ponownego połączenia i odbioru nagrań', + 'autoConnectConsentB2': 'Po wybudzeniu jest używana wyłącznie do łączenia z urządzeniem, nagrywania i przesyłania — nic więcej, żadne inne aplikacje nie są uruchamiane', + 'autoConnectConsentB3': 'Nadal możesz korzystać z aplikacji bez tego — wystarczy za każdym razem połączyć urządzenie ręcznie', + 'autoConnectConsentB4': 'Możesz to wyłączyć w dowolnym momencie w Ustawienia → Automatyczne łączenie urządzeń', + 'autoConnectAgree': 'Zgadzam się i włączam', + 'autoConnectLater': 'Nie teraz', + 'autoConnectRequired': 'Automatyczne łączenie jest wyłączone', + 'autoConnectRequiredDesc': 'Włącz automatyczne łączenie, aby powiązać to urządzenie', + 'recordTapToStart': 'Dotknij przycisku poniżej, aby rozpocząć nagrywanie', + 'recordPaused': 'Wstrzymano', + 'recordMark': 'Znacznik', + 'recordDeleteTitle': 'Usuń nagranie', + 'accountAndProfile': 'Konto i profil', + 'nicknameUnset': 'Nie ustawiono pseudonimu', + 'nicknameInput': 'Wpisz pseudonim', + 'loginMethod': 'Metoda logowania', + 'manageMyLogin': 'Zarządzaj logowaniem', + 'logoutAction': 'Wyloguj się', + 'warningLabel': 'Ostrzeżenie:', + 'deleteAccountWarn': 'Twoje konto i wszystkie pliki zostaną trwale usunięte.', + 'icpFiling': 'Rejestracja ICP', + 'logExport': 'Eksport dzienników', + 'logExportDesc': 'Przeglądaj i eksportuj dzienniki aplikacji', + 'permLoadFailed': 'Nie udało się wczytać grup uprawnień', + 'permInsufficient': 'Brak uprawnień', + 'voiceCloneCreating': 'Tworzenie Twojego głosu…', + 'voiceCloneCreate': 'Utwórz mój głos', + 'voiceCloneAgree': 'Zgadzam się i odczytam oświadczenie na głos', + 'voiceCloneReadPrompt': 'Przeczytaj:', + 'voiceCloneFirstMonthFree': 'Pierwszy miesiąc za darmo', + 'voiceCloneEnable': 'Włącz klonowanie głosu', + 'noMusicPlaying': 'Nic nie jest odtwarzane', + 'qqMusicNoSeek': 'QQ Music nie obsługuje przewijania', + 'bleScanTitle': 'Skanowanie BLE', + 'bluetoothOn': 'Bluetooth włączony', + 'bleScanning': 'Skanowanie…', + 'bleConnectedDevices': 'Połączone urządzenia', + 'bleUnknownDevice': 'Nieznane urządzenie', + 'bleTapToScan': 'Dotknij Skanuj, aby wyszukać urządzenia BLE', + 'weekMon': 'Pn', + 'weekTue': 'Wt', + 'weekWed': 'Śr', + 'weekThu': 'Cz', + 'weekFri': 'Pt', + 'weekSat': 'So', + 'weekSun': 'Nd', + 'agentMeeting': 'Notatki głosowe', + 'agentMeetingDesc': 'Podsumowania nagrań, centrum pamięci', + 'agentEmai': 'Asystent EMAI', + 'agentEmaiDesc': 'Rozmawiaj i pytaj o wszystko głosem', + 'agentSimultaneous': 'Tłumaczenie na żywo', + 'agentSimultaneousDesc': 'Tłumaczenie w trakcie mówienia', + 'agentFaceToFace': 'Twarzą w twarz', + 'agentFaceToFaceDesc': 'Dwukierunkowe tłumaczenie rozmowy', + 'agentCallRecording': 'Nagrywanie rozmów', + 'agentCallRecordingDesc': 'Rozmowy zapisywane automatycznie', + 'agentFieldRecording': 'Nagrywanie w terenie', + 'agentFieldRecordingDesc': 'Mikrofon urządzenia lub telefonu', + 'agentCall': 'Tłumaczenie rozmów', + 'agentCallDesc': 'Tłumaczenie w czasie rzeczywistym podczas rozmów', + 'agentAudioVideo': 'Tłumaczenie mediów', + 'agentAudioVideoDesc': 'Kadr aparatu / tłumaczenie audio i wideo', + 'agentEnglishChat': 'Rozmowa po angielsku', + 'agentEnglishChatDesc': 'Immersyjna nauka mówienia', + 'agentEnglishStudy': 'Nauka angielskiego', + 'agentEnglishStudyDesc': 'Lekcje słownictwa i gramatyki', + 'agentKidsStory': 'Bajki dla dzieci', + 'agentKidsStoryDesc': 'Bajki na dobranoc i pytania z odpowiedziami', + 'moduleRemovedDesc': 'usunięto ze strony AI', + 'moduleAddedDesc': 'dodano na dole strony AI', + 'calNoRecords': 'Brak wpisów', + 'calNoRecordsDay': 'Brak zaplanowanych zdarzeń', + 'calTodos': 'Zadania', + 'calExpenses': 'Wydatki', + 'calTodoCount': 'Zadania: @count', + 'calExpenseCount': 'Wydatki: @count', + 'calEntryCount': 'Wpisy: @count', + 'calItemsUnit': 'pozycji', + 'calCountUnit': 'wpisów', + 'calTotalAmount': 'Razem', + 'recordPause': 'Wstrzymaj', + 'recordResume': 'Wznów', + 'recordDeleteConfirm': 'Usunąć to nagranie?', + 'scopeDay': 'Dzień', + 'scopeWeek': 'Tydzień', + 'scopeMonth': 'Miesiąc', + 'allDay': 'Cały dzień', + 'guestModeTitle': 'Tryb gościa', + 'guestModeDesc': 'Goście mogą tylko przeglądać. Zaloguj się, aby korzystać z notatek głosowych, tłumaczenia i nagrywania — Twoje dane zostaną zapisane na Twoim koncie.', + 'guestKeepBrowsing': 'Przeglądaj dalej', + 'guestGoLogin': 'Zaloguj się', + 'guestViewOnlyTag': 'Tylko podgląd', + 'autoConnectReenableHint': 'Automatyczne łączenie jest wyłączone. Włącz je ponownie w Ustawienia → Automatyczne łączenie urządzeń.', + 'plazaEntryDesc': 'Przeglądaj i dodawaj kolejnych agentów', + 'agentTextTranslation': 'Tłumaczenie tekstu', + 'agentTextTranslationDesc': 'Tłumacz wpisany lub wklejony tekst', + 'agentPictureTranslation': 'Tłumaczenie zdjęć', + 'agentPictureTranslationDesc': 'Zrób lub wybierz zdjęcie do tłumaczenia', + 'callRequiredTitle': 'Wymagana rozmowa', + 'callRequiredDesc': 'Ta funkcja działa tylko podczas rozmowy', + 'inCallBusyTitle': 'Niedostępne podczas rozmowy', + 'inCallBusyDesc': 'Mikrofon i tor audio są używane przez rozmowę. Spróbuj ponownie po rozłączeniu.', + 'vipSectionTitle': 'VIP', + 'freeSectionTitle': 'Bezpłatne', + 'smartcarConnected': 'Connected', + 'smartcarConnectFailed': 'Connection failed', + 'smartcarConnecting': 'Connecting…', + 'smartcarSearching': 'Searching for nearby car diffusers…', + 'smartcarNotFound': 'No device found', + 'smartcarScanHint': 'Make sure the device is powered on and close to your phone', + 'vipRequiredTitle': 'Membership required', + 'vipRequiredDesc': 'Translation and meeting notes require a membership. Renew, or pair a device that includes one.', + 'vipRequiredGo': 'Get membership', + 'vipFromDevice': 'from device', + 'computeSummary': 'Compute: device @device · account @user', + 'computePack': 'Compute packs', + 'vipExpiringSoon': 'Membership expires in @days day(s). Renew to keep using translation and meeting notes.', + 'computeLow': 'Running low on compute. You won\'t be able to continue once it runs out.', }; diff --git a/apps/client/lib/core/translations/language/pt_pt.dart b/apps/client/lib/core/translations/language/pt_pt.dart index 70e92cf0..4d835731 100644 --- a/apps/client/lib/core/translations/language/pt_pt.dart +++ b/apps/client/lib/core/translations/language/pt_pt.dart @@ -1885,6 +1885,8 @@ const Map ptPT = { 'catNoteCardDesc': 'Gravador em formato de cartão', 'catPhoneHolder': 'Suporte de telemóvel IA', 'catPhoneHolderDesc': 'Dispositivos de suporte', + 'catCarAroma': 'Difusor de carro IA', + 'catCarAromaDesc': 'Difusor de aroma para carro', 'catParkerPen': 'Caneta IA', 'catParkerPenDesc': 'Canetas gravadoras', 'catStylusPen': 'Stylus IA', @@ -2142,4 +2144,32 @@ const Map ptPT = { 'unmarkFavorite': 'Remover dos favoritos', 'unnamedDevice': 'Dispositivo sem nome', 'userDataParseFailed': 'Falha ao processar os dados do utilizador', + 'translationCallExitTitle': 'Sair da Tradução de Chamadas', + 'translationCallExitMessage': 'Escolha o que fazer antes de sair', + 'translationCallEnd': 'Terminar tradução de chamada', + 'translationContinueInBackground': 'Continuar a traduzir em segundo plano', + 'backgroundRecordingIndicatorBody': 'A gravação continua em segundo plano. Toque para voltar.', + 'backgroundTranslationIndicatorBody': 'A tradução de chamada continua em segundo plano. Toque para voltar.', + 'permissionDisabled': 'Desativado', + 'confirmDisablePermissionTitle': 'Desativar permissão', + 'confirmDisablePermissionContent': 'Desativar a permissão de {permission}? Terá de ir às definições do sistema para a reativar.', + 'confirmPermanentlyDisallowTitle': 'Parar de perguntar?', + 'confirmPermanentlyDisallowContent': 'Recusou a permissão de {permission}. Deixar de perguntar a partir de agora? Se escolher «Não permitir», poderá ativá-la mais tarde nas definições do sistema.', + 'notNow': 'Agora não', + 'permanentlyDisallow': 'Não permitir', + 'otaBesNotSupported': 'Estes auscultadores ainda não suportam a atualização de firmware na app. Contacte o suporte.', + 'smartcarConnected': 'Ligado', + 'smartcarConnectFailed': 'Falha na ligação', + 'smartcarConnecting': 'A ligar…', + 'smartcarSearching': 'A procurar difusores de carro próximos…', + 'smartcarNotFound': 'Nenhum dispositivo encontrado', + 'smartcarScanHint': 'Certifique-se de que o dispositivo está ligado e perto do telemóvel', + 'vipRequiredTitle': 'Membership required', + 'vipRequiredDesc': 'Translation and meeting notes require a membership. Renew, or pair a device that includes one.', + 'vipRequiredGo': 'Get membership', + 'vipFromDevice': 'from device', + 'computeSummary': 'Compute: device @device · account @user', + 'computePack': 'Compute packs', + 'vipExpiringSoon': 'Membership expires in @days day(s). Renew to keep using translation and meeting notes.', + 'computeLow': 'Running low on compute. You won\'t be able to continue once it runs out.', }; diff --git a/apps/client/lib/core/translations/language/ro_ro.dart b/apps/client/lib/core/translations/language/ro_ro.dart index 16ba2826..a7b8222f 100644 --- a/apps/client/lib/core/translations/language/ro_ro.dart +++ b/apps/client/lib/core/translations/language/ro_ro.dart @@ -1932,4 +1932,208 @@ const Map roRO = { 'unmarkFavorite': 'Elimină de la favorite', 'unnamedDevice': 'Dispozitiv fără nume', 'userDataParseFailed': 'Datele utilizatorului nu au putut fi citite', + 'catEarphone': 'Căști AI', + 'catEarphoneDesc': 'Căști Bluetooth clasic / BLE', + 'catPhoneHolder': 'Suport telefon AI', + 'catPhoneHolderDesc': 'Dispozitive de tip suport', + 'catCarAroma': 'Difuzor auto AI', + 'catCarAromaDesc': 'Difuzor de arome auto', + 'agreeAndContinue': 'Sunt de acord și continuă', + 'memberServiceAgreement': 'Acordul privind serviciile de membru', + 'memberServiceAgreementTitle': 'Acordul privind serviciile de membru', + 'memberServiceAgreementIntro': 'Pentru a vă oferi servicii de membru mai bune, vă rugăm să citiți cu atenție înainte de a vă abona: ', + 'memberServiceAgreementOutro': '. Făcând clic pe „Sunt de acord și continuă”, confirmați că ați citit integral și acceptați acordul de mai sus.', + 'memberAgreementRejectedTip': 'Trebuie să acceptați Acordul privind serviciile de membru pentru a continua.', + 'translationCallExitTitle': 'Ieșire din traducerea apelului', + 'translationCallExitMessage': 'Alege ce să faci înainte de a ieși', + 'translationCallEnd': 'Încheie traducerea apelului', + 'translationContinueInBackground': 'Continuă traducerea în fundal', + 'backgroundRecordingIndicatorBody': 'Înregistrarea continuă în fundal. Atinge pentru a reveni.', + 'backgroundTranslationIndicatorBody': 'Traducerea apelului continuă în fundal. Atinge pentru a reveni.', + 'meetingRecordScreenCapturePermissionDenied': 'Este necesară permisiunea de înregistrare a ecranului pentru a capta sunetul sistemului. Acordați-o și reîncercați.', + 'pleaseGrantBluetoothPermission': 'Acordați permisiunea Bluetooth pentru a scana', + 'startScanFailed': 'Pornirea scanării a eșuat', + 'pleaseLoginFirst': 'Autentificați-vă mai întâi', + 'deviceUnauthorized': 'Dispozitiv neautorizat, reîncercați', + 'boundDevices': 'Dispozitive asociate', + 'currentConnection': 'Conexiune curentă', + 'binding': 'Se asociază...', + 'permissionDisabled': 'Dezactivat', + 'confirmDisablePermissionTitle': 'Dezactivează permisiunea', + 'confirmDisablePermissionContent': 'Dezactivezi permisiunea {permission}? Va trebui să mergi în setările sistemului pentru a o reactiva.', + 'confirmPermanentlyDisallowTitle': 'Nu mai întreba?', + 'confirmPermanentlyDisallowContent': 'Ai refuzat permisiunea {permission}. Nu mai întrebăm de acum înainte? Dacă alegi „Nu permite”, o poți activa ulterior din setările sistemului.', + 'notNow': 'Nu acum', + 'permanentlyDisallow': 'Nu permite', + 'otaBesNotSupported': 'Aceste căști nu acceptă încă actualizarea firmware-ului din aplicație. Contactați asistența.', + 'tabDevice': 'Dispozitive', + 'tabPlaza': 'Piața', + 'sloganHome': 'Tehnologie pentru o viață mai inteligentă', + 'sloganAi': 'Inteligență fără limite', + 'sloganDevice': 'Hardware inteligent, pregătit pentru viitor', + 'sloganPlaza': 'O lume inteligentă care te înțelege', + 'aiEditHint': 'Apasă lung pe un card pentru a trage și rearanja modulele', + 'aiEmptyTitle': 'Niciun modul adăugat încă', + 'aiEmptyDesc': 'Alege agenții de care ai nevoie din Piață', + 'goToPlaza': 'Mergi la Piață', + 'plazaOnline': 'Disponibil', + 'plazaPlanned': 'În curând', + 'plazaSearchHint': 'Caută agenți, de ex. „traducere”, „înregistrare”', + 'moduleRemove': 'Elimină', + 'moduleAdd': 'Adaugă', + 'moduleRemoved': 'Eliminat', + 'moduleAdded': 'Adăugat', + 'goSee': 'Vezi', + 'comingSoonTag': 'În curând', + 'needDeviceTag': 'Necesită dispozitiv', + 'featureInDev': 'Această funcție este în dezvoltare', + 'featureNotWired': 'Momentan indisponibil', + 'todayLabel': 'Astăzi', + 'deviceConnected': 'Conectat', + 'licenseUnknown': 'Licență necunoscută', + 'selectDevice': 'Selectează un dispozitiv', + 'catNoteCard': 'Card de înregistrare AI', + 'catNoteCardDesc': 'Reportofon în formă de card', + 'catParkerPen': 'Stilou AI', + 'catParkerPenDesc': 'Stilouri cu reportofon', + 'catStylusPen': 'Stylus AI', + 'catStylusPenDesc': 'Dispozitive de scris de mână / stylus', + 'catStudyMachine': 'Tabletă de învățare AI', + 'catStudyMachineDesc': 'Dispozitive de învățare', + 'besAddTitle': 'Adaugă căști AI', + 'besConnecting': 'Se conectează la căști…', + 'besNotFound': 'Căștile nu au fost găsite', + 'besNotFoundIosDesc': 'Conectează mai întâi căștile din Setări → Bluetooth, apoi revino aici', + 'besNotFoundAndroidDesc': 'Asociază mai întâi căștile din Setări → Bluetooth, apoi conectează-le aici', + 'besReadFailed': 'Citirea a eșuat', + 'besConnectFailIos': 'Asigură-te că în Bluetooth-ul sistemului căștile apar ca Conectate, apoi reîncearcă', + 'besConnectFailAndroid': 'Asigură-te că căștile sunt pornite, în apropiere și neconectate la alt dispozitiv. Odată asociate, se vor reconecta automat.', + 'holderAddTitle': 'Adaugă suport de telefon AI', + 'holderConnecting': 'Se conectează la dispozitiv…', + 'holderSearching': 'Se caută dispozitive…', + 'holderNotFound': 'Niciun dispozitiv găsit', + 'holderPairHint': 'Asigură-te că suportul este pornit și în modul de asociere', + 'holderRetryHint': 'Asigură-te că dispozitivul este pornit și în apropiere, apoi reîncearcă', + 'autoConnectTitle': 'Conectare automată a dispozitivelor', + 'autoConnectDesc': 'Trezește aplicația pentru a se reconecta și a primi înregistrări când dispozitivul pornește sau se apropie', + 'autoConnectConsentTitle': 'Activează conectarea automată', + 'autoConnectConsentIntro': 'Pentru ca dispozitivul tău (căști AI / suport de telefon AI) să se conecteze automat și să salveze înregistrările când apeși butonul său de înregistrare, avem nevoie de consimțământul tău:', + 'autoConnectConsentB1': 'Înregistrarea unei scanări Bluetooth în fundal, astfel încât aplicația să fie trezită (pornită automat) când dispozitivul asociat este detectat, pentru a se reconecta și a primi înregistrări', + 'autoConnectConsentB2': 'Odată trezită, este folosită doar pentru conectarea dispozitivului, înregistrare și încărcare — nimic altceva, și nu sunt lansate alte aplicații', + 'autoConnectConsentB3': 'Poți folosi aplicația și fără aceasta — doar conectează dispozitivul manual de fiecare dată', + 'autoConnectConsentB4': 'O poți dezactiva oricând din Setări → Conectare automată a dispozitivelor', + 'autoConnectAgree': 'Sunt de acord și activează', + 'autoConnectLater': 'Nu acum', + 'autoConnectRequired': 'Conectarea automată este dezactivată', + 'autoConnectRequiredDesc': 'Activează conectarea automată pentru a asocia acest dispozitiv', + 'recordTapToStart': 'Atinge butonul de mai jos pentru a începe înregistrarea', + 'recordPaused': 'În pauză', + 'recordMark': 'Marchează', + 'recordDeleteTitle': 'Șterge înregistrarea', + 'accountAndProfile': 'Cont și profil', + 'nicknameUnset': 'Niciun pseudonim setat', + 'nicknameInput': 'Introdu un pseudonim', + 'loginMethod': 'Metodă de autentificare', + 'manageMyLogin': 'Gestionează autentificarea', + 'logoutAction': 'Deconectare', + 'warningLabel': 'Avertisment:', + 'deleteAccountWarn': 'Contul tău și toate fișierele vor fi șterse definitiv.', + 'icpFiling': 'Înregistrare ICP', + 'logExport': 'Export jurnale', + 'logExportDesc': 'Vezi și exportă jurnalele aplicației', + 'permLoadFailed': 'Încărcarea grupurilor de permisiuni a eșuat', + 'permInsufficient': 'Permisiune refuzată', + 'voiceCloneCreating': 'Se creează vocea ta…', + 'voiceCloneCreate': 'Creează vocea mea', + 'voiceCloneAgree': 'Sunt de acord și voi citi declarația cu voce tare', + 'voiceCloneReadPrompt': 'Te rugăm să citești:', + 'voiceCloneFirstMonthFree': 'Prima lună gratuită', + 'voiceCloneEnable': 'Activează clonarea vocii', + 'noMusicPlaying': 'Nu se redă nimic', + 'qqMusicNoSeek': 'Derularea nu este acceptată pentru QQ Music', + 'bleScanTitle': 'Scanare BLE', + 'bluetoothOn': 'Bluetooth pornit', + 'bleScanning': 'Se scanează…', + 'bleConnectedDevices': 'Dispozitive conectate', + 'bleUnknownDevice': 'Dispozitiv necunoscut', + 'bleTapToScan': 'Atinge Scanează pentru a căuta dispozitive BLE', + 'weekMon': 'Lun', + 'weekTue': 'Mar', + 'weekWed': 'Mie', + 'weekThu': 'Joi', + 'weekFri': 'Vin', + 'weekSat': 'Sâm', + 'weekSun': 'Dum', + 'agentMeeting': 'Note vocale de ședință', + 'agentMeetingDesc': 'Rezumă înregistrările, centru de amintiri', + 'agentEmai': 'Asistent EMAI', + 'agentEmaiDesc': 'Conversează și întreabă orice prin voce', + 'agentSimultaneous': 'Traducere simultană', + 'agentSimultaneousDesc': 'Traduce pe măsură ce vorbești', + 'agentFaceToFace': 'Față în față', + 'agentFaceToFaceDesc': 'Traducere bidirecțională a conversației', + 'agentCallRecording': 'Înregistrare apel', + 'agentCallRecordingDesc': 'Apelurile sunt salvate automat', + 'agentFieldRecording': 'Înregistrare pe teren', + 'agentFieldRecordingDesc': 'Microfonul dispozitivului sau al telefonului', + 'agentCall': 'Traducere apeluri', + 'agentCallDesc': 'Traducere în timp real în timpul apelurilor', + 'agentAudioVideo': 'Traducere media', + 'agentAudioVideoDesc': 'Încadrare cu camera / traducere audio și video', + 'agentEnglishChat': 'Conversație în engleză', + 'agentEnglishChatDesc': 'Exersare imersivă a vorbirii', + 'agentEnglishStudy': 'Studiul englezei', + 'agentEnglishStudyDesc': 'Lecții de vocabular și gramatică', + 'agentKidsStory': 'Povești pentru copii', + 'agentKidsStoryDesc': 'Povești de seară și întrebări și răspunsuri', + 'moduleRemovedDesc': 'eliminat din pagina AI', + 'moduleAddedDesc': 'adăugat la finalul paginii AI', + 'calNoRecords': 'Nicio intrare', + 'calNoRecordsDay': 'Nimic programat', + 'calTodos': 'De făcut', + 'calExpenses': 'Cheltuieli', + 'calTodoCount': '@count sarcini', + 'calExpenseCount': '@count cheltuieli', + 'calEntryCount': '@count intrări', + 'calItemsUnit': 'elemente', + 'calCountUnit': 'intrări', + 'calTotalAmount': 'Total', + 'recordPause': 'Pauză', + 'recordResume': 'Reia', + 'recordDeleteConfirm': 'Ștergi această înregistrare?', + 'scopeDay': 'Zi', + 'scopeWeek': 'Săptămână', + 'scopeMonth': 'Lună', + 'allDay': 'Toată ziua', + 'guestModeTitle': 'Mod vizitator', + 'guestModeDesc': 'Vizitatorii pot doar naviga. Autentifică-te pentru a folosi notele vocale, traducerea și înregistrarea — datele tale vor fi salvate în contul tău.', + 'guestKeepBrowsing': 'Continuă navigarea', + 'guestGoLogin': 'Autentificare', + 'guestViewOnlyTag': 'Doar vizualizare', + 'autoConnectReenableHint': 'Conectarea automată este dezactivată. Reactiveaz-o din Setări → Conectare automată a dispozitivelor.', + 'plazaEntryDesc': 'Răsfoiește și adaugă mai mulți agenți', + 'agentTextTranslation': 'Traducere text', + 'agentTextTranslationDesc': 'Traduce text tastat sau lipit', + 'agentPictureTranslation': 'Traducere foto', + 'agentPictureTranslationDesc': 'Fotografiază sau alege o poză pentru traducere', + 'callRequiredTitle': 'Necesită un apel', + 'callRequiredDesc': 'Această funcție funcționează doar în timpul unui apel', + 'inCallBusyTitle': 'Indisponibil în timpul apelului', + 'inCallBusyDesc': 'Microfonul și calea audio sunt folosite de apel. Reîncearcă după ce închizi.', + 'vipSectionTitle': 'VIP', + 'freeSectionTitle': 'Gratuit', + 'smartcarConnected': 'Connected', + 'smartcarConnectFailed': 'Connection failed', + 'smartcarConnecting': 'Connecting…', + 'smartcarSearching': 'Searching for nearby car diffusers…', + 'smartcarNotFound': 'No device found', + 'smartcarScanHint': 'Make sure the device is powered on and close to your phone', + 'vipRequiredTitle': 'Membership required', + 'vipRequiredDesc': 'Translation and meeting notes require a membership. Renew, or pair a device that includes one.', + 'vipRequiredGo': 'Get membership', + 'vipFromDevice': 'from device', + 'computeSummary': 'Compute: device @device · account @user', + 'computePack': 'Compute packs', + 'vipExpiringSoon': 'Membership expires in @days day(s). Renew to keep using translation and meeting notes.', + 'computeLow': 'Running low on compute. You won\'t be able to continue once it runs out.', }; diff --git a/apps/client/lib/core/translations/language/ru_ru.dart b/apps/client/lib/core/translations/language/ru_ru.dart index 02c1d994..b0994fdb 100644 --- a/apps/client/lib/core/translations/language/ru_ru.dart +++ b/apps/client/lib/core/translations/language/ru_ru.dart @@ -1863,6 +1863,8 @@ const Map ruRU = { 'catNoteCardDesc': 'Диктофон в форме карты', 'catPhoneHolder': 'ИИ-держатель телефона', 'catPhoneHolderDesc': 'Устройства-держатели', + 'catCarAroma': 'ИИ-ароматизатор для авто', + 'catCarAromaDesc': 'Автомобильный ароматизатор', 'catParkerPen': 'ИИ-ручка', 'catParkerPenDesc': 'Ручки-диктофоны', 'catStylusPen': 'ИИ-стилус', @@ -2117,4 +2119,32 @@ const Map ruRU = { 'unmarkFavorite': 'Убрать из избранного', 'unnamedDevice': 'Устройство без имени', 'userDataParseFailed': 'Не удалось обработать данные пользователя', + 'translationCallExitTitle': 'Выйти из перевода звонка', + 'translationCallExitMessage': 'Выберите, что сделать перед выходом', + 'translationCallEnd': 'Завершить перевод звонка', + 'translationContinueInBackground': 'Продолжить перевод в фоне', + 'backgroundRecordingIndicatorBody': 'Запись продолжается в фоновом режиме. Нажмите, чтобы вернуться.', + 'backgroundTranslationIndicatorBody': 'Перевод звонка продолжается в фоновом режиме. Нажмите, чтобы вернуться.', + 'permissionDisabled': 'Отключено', + 'confirmDisablePermissionTitle': 'Отключить разрешение', + 'confirmDisablePermissionContent': 'Отключить разрешение «{permission}»? Чтобы включить его снова, нужно будет перейти в настройки системы.', + 'confirmPermanentlyDisallowTitle': 'Больше не спрашивать?', + 'confirmPermanentlyDisallowContent': 'Вы отклонили разрешение «{permission}». Больше не спрашивать? Если выбрать «Не разрешать», его можно будет включить позже в настройках системы.', + 'notNow': 'Не сейчас', + 'permanentlyDisallow': 'Не разрешать', + 'otaBesNotSupported': 'Эта гарнитура пока не поддерживает обновление прошивки из приложения. Обратитесь в службу поддержки.', + 'smartcarConnected': 'Подключено', + 'smartcarConnectFailed': 'Ошибка подключения', + 'smartcarConnecting': 'Подключение…', + 'smartcarSearching': 'Поиск автомобильных ароматизаторов поблизости…', + 'smartcarNotFound': 'Устройство не найдено', + 'smartcarScanHint': 'Убедитесь, что устройство включено и находится рядом с телефоном', + 'vipRequiredTitle': 'Membership required', + 'vipRequiredDesc': 'Translation and meeting notes require a membership. Renew, or pair a device that includes one.', + 'vipRequiredGo': 'Get membership', + 'vipFromDevice': 'from device', + 'computeSummary': 'Compute: device @device · account @user', + 'computePack': 'Compute packs', + 'vipExpiringSoon': 'Membership expires in @days day(s). Renew to keep using translation and meeting notes.', + 'computeLow': 'Running low on compute. You won\'t be able to continue once it runs out.', }; diff --git a/apps/client/lib/core/translations/language/sk_sk.dart b/apps/client/lib/core/translations/language/sk_sk.dart index 9e783cb1..6efa4718 100644 --- a/apps/client/lib/core/translations/language/sk_sk.dart +++ b/apps/client/lib/core/translations/language/sk_sk.dart @@ -1913,4 +1913,208 @@ const Map skSK = { 'unmarkFavorite': 'Odstrániť z obľúbených', 'unnamedDevice': 'Nepomenované zariadenie', 'userDataParseFailed': 'Údaje používateľa sa nepodarilo načítať', + 'catEarphone': 'AI slúchadlá', + 'catEarphoneDesc': 'Klasické Bluetooth / BLE slúchadlá', + 'catPhoneHolder': 'AI držiak telefónu', + 'catPhoneHolderDesc': 'Zariadenia typu držiak', + 'catCarAroma': 'AI vôňa do auta', + 'catCarAromaDesc': 'Difuzér vône do auta', + 'agreeAndContinue': 'Súhlasím a pokračovať', + 'memberServiceAgreement': 'Zmluva o členských službách', + 'memberServiceAgreementTitle': 'Zmluva o členských službách', + 'memberServiceAgreementIntro': 'Aby sme vám mohli poskytovať lepšie členské služby, pred predplatením si pozorne prečítajte: ', + 'memberServiceAgreementOutro': '. Ťuknutím na „Súhlasím a pokračovať“ potvrdzujete, že ste si vyššie uvedenú zmluvu celú prečítali a prijímate ju.', + 'memberAgreementRejectedTip': 'Na pokračovanie musíte súhlasiť so Zmluvou o členských službách.', + 'translationCallExitTitle': 'Opustiť preklad hovoru', + 'translationCallExitMessage': 'Vyberte, čo urobiť pred odchodom', + 'translationCallEnd': 'Ukončiť preklad hovoru', + 'translationContinueInBackground': 'Pokračovať v preklade na pozadí', + 'backgroundRecordingIndicatorBody': 'Nahrávanie pokračuje na pozadí. Ťuknutím sa vrátite.', + 'backgroundTranslationIndicatorBody': 'Preklad hovoru pokračuje na pozadí. Ťuknutím sa vrátite.', + 'meetingRecordScreenCapturePermissionDenied': 'Na nahrávanie systémového zvuku je potrebné povolenie na nahrávanie obrazovky. Udeľte ho a skúste to znova.', + 'pleaseGrantBluetoothPermission': 'Na skenovanie udeľte povolenie Bluetooth', + 'startScanFailed': 'Spustenie skenovania zlyhalo', + 'pleaseLoginFirst': 'Najprv sa prihláste', + 'deviceUnauthorized': 'Zariadenie nie je autorizované, skúste to znova', + 'boundDevices': 'Prepojené zariadenia', + 'currentConnection': 'Aktuálne pripojenie', + 'binding': 'Prepája sa…', + 'permissionDisabled': 'Vypnuté', + 'confirmDisablePermissionTitle': 'Vypnúť povolenie', + 'confirmDisablePermissionContent': 'Vypnúť povolenie {permission}? Na opätovné zapnutie bude potrebné prejsť do nastavení systému.', + 'confirmPermanentlyDisallowTitle': 'Prestať sa pýtať?', + 'confirmPermanentlyDisallowContent': 'Zamietli ste povolenie {permission}. Prestať sa odteraz pýtať? Ak zvolíte „Nepovoliť“, môžete ho neskôr zapnúť v nastaveniach systému.', + 'notNow': 'Teraz nie', + 'permanentlyDisallow': 'Nepovoliť', + 'otaBesNotSupported': 'Tieto slúchadlá zatiaľ nepodporujú aktualizáciu firmvéru v aplikácii. Kontaktujte podporu.', + 'tabDevice': 'Zariadenia', + 'tabPlaza': 'Plaza', + 'sloganHome': 'Technológie pre inteligentnejší život', + 'sloganAi': 'Inteligencia bez hraníc', + 'sloganDevice': 'Inteligentný hardvér pripravený na budúcnosť', + 'sloganPlaza': 'Inteligentný svet, ktorý vám rozumie', + 'aiEditHint': 'Dlhým stlačením karty presuniete a preusporiadate moduly', + 'aiEmptyTitle': 'Zatiaľ žiadne moduly', + 'aiEmptyDesc': 'Vyberte si potrebných agentov v Plaza', + 'goToPlaza': 'Prejsť do Plaza', + 'plazaOnline': 'K dispozícii', + 'plazaPlanned': 'Už čoskoro', + 'plazaSearchHint': 'Hľadať agentov, napr. „preklad“, „nahrávanie“', + 'moduleRemove': 'Odstrániť', + 'moduleAdd': 'Pridať', + 'moduleRemoved': 'Odstránené', + 'moduleAdded': 'Pridané', + 'goSee': 'Zobraziť', + 'comingSoonTag': 'Už čoskoro', + 'needDeviceTag': 'Vyžaduje zariadenie', + 'featureInDev': 'Táto funkcia sa pripravuje', + 'featureNotWired': 'Zatiaľ nie je k dispozícii', + 'todayLabel': 'Dnes', + 'deviceConnected': 'Pripojené', + 'licenseUnknown': 'Licencia neznáma', + 'selectDevice': 'Vyberte zariadenie', + 'catNoteCard': 'AI záznamová karta', + 'catNoteCardDesc': 'Záznamník v tvare karty', + 'catParkerPen': 'AI pero', + 'catParkerPenDesc': 'Perá s hlasovým záznamníkom', + 'catStylusPen': 'AI stylus', + 'catStylusPenDesc': 'Zariadenia na ručné písanie / stylusy', + 'catStudyMachine': 'AI výukový tablet', + 'catStudyMachineDesc': 'Výukové zariadenia', + 'besAddTitle': 'Pridať AI slúchadlá', + 'besConnecting': 'Pripája sa k slúchadlám…', + 'besNotFound': 'Slúchadlá sa nenašli', + 'besNotFoundIosDesc': 'Najprv pripojte slúchadlá v Nastavenia → Bluetooth a potom sa vráťte', + 'besNotFoundAndroidDesc': 'Najprv spárujte slúchadlá v Nastavenia → Bluetooth a potom ich tu pripojte', + 'besReadFailed': 'Čítanie zlyhalo', + 'besConnectFailIos': 'Uistite sa, že sa slúchadlá v systémovom Bluetooth zobrazujú ako Pripojené, a skúste to znova', + 'besConnectFailAndroid': 'Uistite sa, že sú slúchadlá zapnuté, nablízku a nie sú pripojené k inému zariadeniu. Po prepojení sa budú pripájať automaticky.', + 'holderAddTitle': 'Pridať AI stojan na telefón', + 'holderConnecting': 'Pripája sa k zariadeniu…', + 'holderSearching': 'Hľadajú sa zariadenia…', + 'holderNotFound': 'Nenašli sa žiadne zariadenia', + 'holderPairHint': 'Uistite sa, že je stojan zapnutý a v režime párovania', + 'holderRetryHint': 'Uistite sa, že je zariadenie zapnuté a nablízku, a skúste to znova', + 'autoConnectTitle': 'Automatické pripojenie zariadení', + 'autoConnectDesc': 'Prebudí aplikáciu, aby sa znova pripojila a prijala nahrávky, keď sa vaše zariadenie zapne alebo priblíži', + 'autoConnectConsentTitle': 'Zapnúť automatické pripojenie', + 'autoConnectConsentIntro': 'Aby sa vaše zariadenie (AI slúchadlá / AI stojan na telefón) mohlo pripájať automaticky a ukladať nahrávky po stlačení tlačidla nahrávania, potrebujeme váš súhlas:', + 'autoConnectConsentB1': 'Zaregistrovať skenovanie Bluetooth na pozadí, aby sa aplikácia pri zistení prepojeného zariadenia prebudila (automaticky spustila), znova pripojila a prijala nahrávky', + 'autoConnectConsentB2': 'Po prebudení sa používa iba na pripojenie zariadenia, nahrávanie a odosielanie — nič iné, žiadne ďalšie aplikácie sa nespúšťajú', + 'autoConnectConsentB3': 'Aplikáciu môžete používať aj bez toho — stačí zariadenie zakaždým pripojiť ručne', + 'autoConnectConsentB4': 'Kedykoľvek to môžete vypnúť v Nastavenia → Automatické pripojenie zariadení', + 'autoConnectAgree': 'Súhlasím a zapnúť', + 'autoConnectLater': 'Teraz nie', + 'autoConnectRequired': 'Automatické pripojenie je vypnuté', + 'autoConnectRequiredDesc': 'Na prepojenie tohto zariadenia zapnite automatické pripojenie', + 'recordTapToStart': 'Ťuknutím na tlačidlo nižšie spustíte nahrávanie', + 'recordPaused': 'Pozastavené', + 'recordMark': 'Značka', + 'recordDeleteTitle': 'Odstrániť nahrávku', + 'accountAndProfile': 'Účet a profil', + 'nicknameUnset': 'Prezývka nie je nastavená', + 'nicknameInput': 'Zadajte prezývku', + 'loginMethod': 'Spôsob prihlásenia', + 'manageMyLogin': 'Spravovať prihlásenie', + 'logoutAction': 'Odhlásiť sa', + 'warningLabel': 'Upozornenie:', + 'deleteAccountWarn': 'Váš účet a všetky súbory budú natrvalo odstránené.', + 'icpFiling': 'Registrácia ICP', + 'logExport': 'Export protokolov', + 'logExportDesc': 'Zobraziť a exportovať protokoly aplikácie', + 'permLoadFailed': 'Načítanie skupín povolení zlyhalo', + 'permInsufficient': 'Povolenie zamietnuté', + 'voiceCloneCreating': 'Vytvára sa váš hlas…', + 'voiceCloneCreate': 'Vytvoriť môj hlas', + 'voiceCloneAgree': 'Súhlasím a prečítam vyhlásenie nahlas', + 'voiceCloneReadPrompt': 'Prečítajte:', + 'voiceCloneFirstMonthFree': 'Prvý mesiac zadarmo', + 'voiceCloneEnable': 'Zapnúť klonovanie hlasu', + 'noMusicPlaying': 'Nič sa neprehráva', + 'qqMusicNoSeek': 'QQ Music nepodporuje pretáčanie', + 'bleScanTitle': 'Skenovanie BLE', + 'bluetoothOn': 'Bluetooth zapnutý', + 'bleScanning': 'Skenuje sa…', + 'bleConnectedDevices': 'Pripojené zariadenia', + 'bleUnknownDevice': 'Neznáme zariadenie', + 'bleTapToScan': 'Ťuknutím na Skenovať vyhľadáte BLE zariadenia', + 'weekMon': 'Po', + 'weekTue': 'Ut', + 'weekWed': 'St', + 'weekThu': 'Št', + 'weekFri': 'Pi', + 'weekSat': 'So', + 'weekSun': 'Ne', + 'agentMeeting': 'Hlasové zápisnice', + 'agentMeetingDesc': 'Zhrnutie nahrávok, centrum pamäte', + 'agentEmai': 'Asistent EMAI', + 'agentEmaiDesc': 'Rozprávajte sa a pýtajte sa hlasom na čokoľvek', + 'agentSimultaneous': 'Živý preklad', + 'agentSimultaneousDesc': 'Preklad počas reči', + 'agentFaceToFace': 'Tvárou v tvár', + 'agentFaceToFaceDesc': 'Obojsmerný preklad konverzácie', + 'agentCallRecording': 'Nahrávanie hovorov', + 'agentCallRecordingDesc': 'Hovory sa ukladajú automaticky', + 'agentFieldRecording': 'Terénne nahrávanie', + 'agentFieldRecordingDesc': 'Mikrofón zariadenia alebo telefónu', + 'agentCall': 'Preklad hovoru', + 'agentCallDesc': 'Preklad v reálnom čase počas hovorov', + 'agentAudioVideo': 'Preklad médií', + 'agentAudioVideoDesc': 'Záber kamery / preklad audia a videa', + 'agentEnglishChat': 'Konverzácia v angličtine', + 'agentEnglishChatDesc': 'Imerzívne precvičovanie rozprávania', + 'agentEnglishStudy': 'Štúdium angličtiny', + 'agentEnglishStudyDesc': 'Lekcie slovnej zásoby a gramatiky', + 'agentKidsStory': 'Detské rozprávky', + 'agentKidsStoryDesc': 'Rozprávky na dobrú noc a otázky s odpoveďami', + 'moduleRemovedDesc': 'odstránené zo stránky AI', + 'moduleAddedDesc': 'pridané na koniec stránky AI', + 'calNoRecords': 'Žiadne záznamy', + 'calNoRecordsDay': 'Nič naplánované', + 'calTodos': 'Úlohy', + 'calExpenses': 'Výdavky', + 'calTodoCount': 'Úlohy: @count', + 'calExpenseCount': 'Výdavky: @count', + 'calEntryCount': 'Záznamy: @count', + 'calItemsUnit': 'položiek', + 'calCountUnit': 'záznamov', + 'calTotalAmount': 'Spolu', + 'recordPause': 'Pozastaviť', + 'recordResume': 'Pokračovať', + 'recordDeleteConfirm': 'Odstrániť túto nahrávku?', + 'scopeDay': 'Deň', + 'scopeWeek': 'Týždeň', + 'scopeMonth': 'Mesiac', + 'allDay': 'Celý deň', + 'guestModeTitle': 'Režim hosťa', + 'guestModeDesc': 'Hostia môžu iba prezerať. Prihláste sa a používajte hlasové poznámky, preklad a nahrávanie — vaše údaje sa uložia k vášmu účtu.', + 'guestKeepBrowsing': 'Pokračovať v prezeraní', + 'guestGoLogin': 'Prihlásiť sa', + 'guestViewOnlyTag': 'Iba prezeranie', + 'autoConnectReenableHint': 'Automatické pripojenie je vypnuté. Znova ho zapnite v Nastavenia → Automatické pripojenie zariadení.', + 'plazaEntryDesc': 'Prezerajte a pridávajte ďalších agentov', + 'agentTextTranslation': 'Preklad textu', + 'agentTextTranslationDesc': 'Preklad napísaného alebo vloženého textu', + 'agentPictureTranslation': 'Preklad fotiek', + 'agentPictureTranslationDesc': 'Odfoťte alebo vyberte fotku na preklad', + 'callRequiredTitle': 'Vyžaduje sa hovor', + 'callRequiredDesc': 'Táto funkcia funguje iba počas hovoru', + 'inCallBusyTitle': 'Počas hovoru nedostupné', + 'inCallBusyDesc': 'Mikrofón a zvukovú cestu používa hovor. Skúste to znova po zavesení.', + 'vipSectionTitle': 'VIP', + 'freeSectionTitle': 'Zadarmo', + 'smartcarConnected': 'Connected', + 'smartcarConnectFailed': 'Connection failed', + 'smartcarConnecting': 'Connecting…', + 'smartcarSearching': 'Searching for nearby car diffusers…', + 'smartcarNotFound': 'No device found', + 'smartcarScanHint': 'Make sure the device is powered on and close to your phone', + 'vipRequiredTitle': 'Membership required', + 'vipRequiredDesc': 'Translation and meeting notes require a membership. Renew, or pair a device that includes one.', + 'vipRequiredGo': 'Get membership', + 'vipFromDevice': 'from device', + 'computeSummary': 'Compute: device @device · account @user', + 'computePack': 'Compute packs', + 'vipExpiringSoon': 'Membership expires in @days day(s). Renew to keep using translation and meeting notes.', + 'computeLow': 'Running low on compute. You won\'t be able to continue once it runs out.', }; diff --git a/apps/client/lib/core/translations/language/sl_si.dart b/apps/client/lib/core/translations/language/sl_si.dart index 61f616d3..f411afd9 100644 --- a/apps/client/lib/core/translations/language/sl_si.dart +++ b/apps/client/lib/core/translations/language/sl_si.dart @@ -1916,4 +1916,208 @@ const Map slSI = { 'unmarkFavorite': 'Odstrani iz priljubljenih', 'unnamedDevice': 'Neimenovana naprava', 'userDataParseFailed': 'Uporabniških podatkov ni bilo mogoče prebrati', + 'catEarphone': 'AI slušalke', + 'catEarphoneDesc': 'Klasične Bluetooth / BLE slušalke', + 'catPhoneHolder': 'AI držalo za telefon', + 'catPhoneHolderDesc': 'Naprave tipa držalo', + 'catCarAroma': 'AI avtomobilski difuzor', + 'catCarAromaDesc': 'Difuzor vonja za avto', + 'agreeAndContinue': 'Strinjam se in nadaljuj', + 'memberServiceAgreement': 'Pogodba o članskih storitvah', + 'memberServiceAgreementTitle': 'Pogodba o članskih storitvah', + 'memberServiceAgreementIntro': 'Da bi vam zagotovili boljše članske storitve, pred naročnino pozorno preberite: ', + 'memberServiceAgreementOutro': '. S klikom na „Strinjam se in nadaljuj“ potrjujete, da ste v celoti prebrali in sprejeli zgornjo pogodbo.', + 'memberAgreementRejectedTip': 'Za nadaljevanje morate sprejeti Pogodbo o članskih storitvah.', + 'translationCallExitTitle': 'Izhod iz prevajanja klica', + 'translationCallExitMessage': 'Izberite dejanje pred izhodom', + 'translationCallEnd': 'Končaj prevajanje klica', + 'translationContinueInBackground': 'Nadaljuj prevajanje v ozadju', + 'backgroundRecordingIndicatorBody': 'Snemanje se nadaljuje v ozadju. Tapnite za vrnitev.', + 'backgroundTranslationIndicatorBody': 'Prevajanje klica se nadaljuje v ozadju. Tapnite za vrnitev.', + 'meetingRecordScreenCapturePermissionDenied': 'Za snemanje sistemskega zvoka je potrebno dovoljenje za snemanje zaslona. Odobrite ga in poskusite znova.', + 'pleaseGrantBluetoothPermission': 'Za iskanje dovolite dostop do Bluetootha', + 'startScanFailed': 'Zagon iskanja ni uspel', + 'pleaseLoginFirst': 'Najprej se prijavite', + 'deviceUnauthorized': 'Naprava ni pooblaščena, poskusite znova', + 'boundDevices': 'Povezane naprave', + 'currentConnection': 'Trenutna povezava', + 'binding': 'Povezovanje...', + 'permissionDisabled': 'Izklopljeno', + 'confirmDisablePermissionTitle': 'Izklopi dovoljenje', + 'confirmDisablePermissionContent': 'Želite izklopiti dovoljenje {permission}? Za ponovno omogočanje boste morali v sistemske nastavitve.', + 'confirmPermanentlyDisallowTitle': 'Ne sprašuj več?', + 'confirmPermanentlyDisallowContent': 'Zavrnili ste dovoljenje {permission}. Naj vas ne sprašujemo več? Če izberete „Ne dovoli“, ga lahko pozneje še vedno omogočite v sistemskih nastavitvah.', + 'notNow': 'Ne zdaj', + 'permanentlyDisallow': 'Ne dovoli', + 'otaBesNotSupported': 'Te slušalke še ne podpirajo nadgradnje vdelane programske opreme v aplikaciji. Obrnite se na podporo.', + 'tabDevice': 'Naprave', + 'tabPlaza': 'Trg', + 'sloganHome': 'Tehnologija za pametnejše življenje', + 'sloganAi': 'Inteligenca brez meja', + 'sloganDevice': 'Pametna strojna oprema, pripravljena na prihodnost', + 'sloganPlaza': 'Pameten svet, ki vas razume', + 'aiEditHint': 'Dolgo pritisnite kartico, da povlečete in prerazporedite module', + 'aiEmptyTitle': 'Ni še dodanih modulov', + 'aiEmptyDesc': 'Na Trgu izberite agente, ki jih potrebujete', + 'goToPlaza': 'Pojdi na Trg', + 'plazaOnline': 'Na voljo', + 'plazaPlanned': 'Kmalu', + 'plazaSearchHint': 'Iščite agente, npr. „prevod“, „snemanje“', + 'moduleRemove': 'Odstrani', + 'moduleAdd': 'Dodaj', + 'moduleRemoved': 'Odstranjeno', + 'moduleAdded': 'Dodano', + 'goSee': 'Poglej', + 'comingSoonTag': 'Kmalu', + 'needDeviceTag': 'Potrebna naprava', + 'featureInDev': 'Ta funkcija je v razvoju', + 'featureNotWired': 'Še ni na voljo', + 'todayLabel': 'Danes', + 'deviceConnected': 'Povezano', + 'licenseUnknown': 'Licenca neznana', + 'selectDevice': 'Izberite napravo', + 'catNoteCard': 'AI snemalna kartica', + 'catNoteCardDesc': 'Snemalnik v obliki kartice', + 'catParkerPen': 'AI pisalo', + 'catParkerPenDesc': 'Pisala za snemanje glasu', + 'catStylusPen': 'AI stilus', + 'catStylusPenDesc': 'Naprave za rokopis / stilusi', + 'catStudyMachine': 'AI učna tablica', + 'catStudyMachineDesc': 'Naprave za učenje', + 'besAddTitle': 'Dodaj AI slušalke', + 'besConnecting': 'Povezovanje s slušalkami…', + 'besNotFound': 'Slušalke niso najdene', + 'besNotFoundIosDesc': 'Najprej povežite slušalke v Nastavitve → Bluetooth, nato se vrnite sem', + 'besNotFoundAndroidDesc': 'Najprej seznanite slušalke v Nastavitve → Bluetooth, nato jih povežite tukaj', + 'besReadFailed': 'Branje ni uspelo', + 'besConnectFailIos': 'Prepričajte se, da so slušalke v sistemskem Bluetoothu prikazane kot Povezane, nato poskusite znova', + 'besConnectFailAndroid': 'Prepričajte se, da so slušalke vklopljene, v bližini in niso povezane z drugo napravo. Ko bodo povezane, se bodo samodejno znova povezale.', + 'holderAddTitle': 'Dodaj AI stojalo za telefon', + 'holderConnecting': 'Povezovanje z napravo…', + 'holderSearching': 'Iskanje naprav…', + 'holderNotFound': 'Ni najdenih naprav', + 'holderPairHint': 'Prepričajte se, da je stojalo vklopljeno in v načinu seznanjanja', + 'holderRetryHint': 'Prepričajte se, da je naprava vklopljena in v bližini, nato poskusite znova', + 'autoConnectTitle': 'Samodejno povezovanje naprav', + 'autoConnectDesc': 'Prebudi aplikacijo za ponovno povezavo in prejem posnetkov, ko se naprava vklopi ali približa', + 'autoConnectConsentTitle': 'Omogoči samodejno povezovanje', + 'autoConnectConsentIntro': 'Da se vaša naprava (AI slušalke / AI stojalo za telefon) samodejno poveže in shrani posnetke, ko pritisnete njen gumb za snemanje, potrebujemo vaše soglasje:', + 'autoConnectConsentB1': 'Registracija Bluetooth iskanja v ozadju, da se aplikacija prebudi (samodejno zažene), ko je zaznana vaša povezana naprava, za ponovno povezavo in prejem posnetkov', + 'autoConnectConsentB2': 'Po prebuditvi se uporablja samo za povezavo naprave, snemanje in nalaganje — nič drugega, druge aplikacije se ne zaženejo', + 'autoConnectConsentB3': 'Aplikacijo lahko uporabljate tudi brez tega — napravo le vsakič povežite ročno', + 'autoConnectConsentB4': 'Kadarkoli ga lahko izklopite v Nastavitve → Samodejno povezovanje naprav', + 'autoConnectAgree': 'Strinjam se in omogoči', + 'autoConnectLater': 'Ne zdaj', + 'autoConnectRequired': 'Samodejno povezovanje je izklopljeno', + 'autoConnectRequiredDesc': 'Za povezavo te naprave omogočite samodejno povezovanje', + 'recordTapToStart': 'Tapnite spodnji gumb za začetek snemanja', + 'recordPaused': 'Začasno ustavljeno', + 'recordMark': 'Označi', + 'recordDeleteTitle': 'Izbriši posnetek', + 'accountAndProfile': 'Račun in profil', + 'nicknameUnset': 'Vzdevek ni nastavljen', + 'nicknameInput': 'Vnesite vzdevek', + 'loginMethod': 'Način prijave', + 'manageMyLogin': 'Upravljaj prijavo', + 'logoutAction': 'Odjava', + 'warningLabel': 'Opozorilo:', + 'deleteAccountWarn': 'Vaš račun in vse datoteke bodo trajno izbrisani.', + 'icpFiling': 'ICP registracija', + 'logExport': 'Izvoz dnevnikov', + 'logExportDesc': 'Oglejte si in izvozite dnevnike aplikacije', + 'permLoadFailed': 'Nalaganje skupin dovoljenj ni uspelo', + 'permInsufficient': 'Dovoljenje zavrnjeno', + 'voiceCloneCreating': 'Ustvarjanje vašega glasu…', + 'voiceCloneCreate': 'Ustvari moj glas', + 'voiceCloneAgree': 'Strinjam se in bom izjavo prebral naglas', + 'voiceCloneReadPrompt': 'Preberite:', + 'voiceCloneFirstMonthFree': 'Prvi mesec brezplačno', + 'voiceCloneEnable': 'Omogoči kloniranje glasu', + 'noMusicPlaying': 'Nič se ne predvaja', + 'qqMusicNoSeek': 'Previjanje ni podprto za QQ Music', + 'bleScanTitle': 'BLE iskanje', + 'bluetoothOn': 'Bluetooth vklopljen', + 'bleScanning': 'Iskanje…', + 'bleConnectedDevices': 'Povezane naprave', + 'bleUnknownDevice': 'Neznana naprava', + 'bleTapToScan': 'Tapnite Išči za iskanje naprav BLE', + 'weekMon': 'Pon', + 'weekTue': 'Tor', + 'weekWed': 'Sre', + 'weekThu': 'Čet', + 'weekFri': 'Pet', + 'weekSat': 'Sob', + 'weekSun': 'Ned', + 'agentMeeting': 'Glasovni zapisnik', + 'agentMeetingDesc': 'Povzemanje posnetkov, središče spominov', + 'agentEmai': 'Pomočnik EMAI', + 'agentEmaiDesc': 'Klepetajte in vprašajte karkoli z glasom', + 'agentSimultaneous': 'Simultano prevajanje', + 'agentSimultaneousDesc': 'Prevajanje med govorjenjem', + 'agentFaceToFace': 'Iz oči v oči', + 'agentFaceToFaceDesc': 'Dvosmerno prevajanje pogovora', + 'agentCallRecording': 'Snemanje klicev', + 'agentCallRecordingDesc': 'Klici se samodejno shranijo', + 'agentFieldRecording': 'Terensko snemanje', + 'agentFieldRecordingDesc': 'Mikrofon naprave ali telefona', + 'agentCall': 'Prevajanje klicev', + 'agentCallDesc': 'Prevajanje v realnem času med klici', + 'agentAudioVideo': 'Prevajanje medijev', + 'agentAudioVideoDesc': 'Kadriranje s kamero / prevajanje avdia in videa', + 'agentEnglishChat': 'Klepet v angleščini', + 'agentEnglishChatDesc': 'Poglobljena vadba govora', + 'agentEnglishStudy': 'Učenje angleščine', + 'agentEnglishStudyDesc': 'Lekcije besedišča in slovnice', + 'agentKidsStory': 'Otroške zgodbe', + 'agentKidsStoryDesc': 'Zgodbe za lahko noč ter vprašanja in odgovori', + 'moduleRemovedDesc': 'odstranjeno s strani AI', + 'moduleAddedDesc': 'dodano na dno strani AI', + 'calNoRecords': 'Ni vnosov', + 'calNoRecordsDay': 'Nič načrtovanega', + 'calTodos': 'Opravila', + 'calExpenses': 'Stroški', + 'calTodoCount': '@count opravil', + 'calExpenseCount': '@count stroškov', + 'calEntryCount': '@count vnosov', + 'calItemsUnit': 'elementov', + 'calCountUnit': 'vnosov', + 'calTotalAmount': 'Skupaj', + 'recordPause': 'Premor', + 'recordResume': 'Nadaljuj', + 'recordDeleteConfirm': 'Želite izbrisati ta posnetek?', + 'scopeDay': 'Dan', + 'scopeWeek': 'Teden', + 'scopeMonth': 'Mesec', + 'allDay': 'Ves dan', + 'guestModeTitle': 'Način gosta', + 'guestModeDesc': 'Gostje lahko samo brskajo. Prijavite se za uporabo glasovnih zapiskov, prevajanja in snemanja — vaši podatki bodo shranjeni v vašem računu.', + 'guestKeepBrowsing': 'Nadaljuj brskanje', + 'guestGoLogin': 'Prijava', + 'guestViewOnlyTag': 'Samo ogled', + 'autoConnectReenableHint': 'Samodejno povezovanje je izklopljeno. Znova ga vklopite v Nastavitve → Samodejno povezovanje naprav.', + 'plazaEntryDesc': 'Prebrskajte in dodajte več agentov', + 'agentTextTranslation': 'Prevajanje besedila', + 'agentTextTranslationDesc': 'Prevedite vneseno ali prilepljeno besedilo', + 'agentPictureTranslation': 'Prevajanje fotografij', + 'agentPictureTranslationDesc': 'Posnemite ali izberite fotografijo za prevod', + 'callRequiredTitle': 'Potreben je klic', + 'callRequiredDesc': 'Ta funkcija deluje samo med klicem', + 'inCallBusyTitle': 'Ni na voljo med klicem', + 'inCallBusyDesc': 'Mikrofon in zvočno pot uporablja klic. Poskusite znova, ko odložite.', + 'vipSectionTitle': 'VIP', + 'freeSectionTitle': 'Brezplačno', + 'smartcarConnected': 'Connected', + 'smartcarConnectFailed': 'Connection failed', + 'smartcarConnecting': 'Connecting…', + 'smartcarSearching': 'Searching for nearby car diffusers…', + 'smartcarNotFound': 'No device found', + 'smartcarScanHint': 'Make sure the device is powered on and close to your phone', + 'vipRequiredTitle': 'Membership required', + 'vipRequiredDesc': 'Translation and meeting notes require a membership. Renew, or pair a device that includes one.', + 'vipRequiredGo': 'Get membership', + 'vipFromDevice': 'from device', + 'computeSummary': 'Compute: device @device · account @user', + 'computePack': 'Compute packs', + 'vipExpiringSoon': 'Membership expires in @days day(s). Renew to keep using translation and meeting notes.', + 'computeLow': 'Running low on compute. You won\'t be able to continue once it runs out.', }; diff --git a/apps/client/lib/core/translations/language/sr_rs.dart b/apps/client/lib/core/translations/language/sr_rs.dart index cbb78188..a8afcee5 100644 --- a/apps/client/lib/core/translations/language/sr_rs.dart +++ b/apps/client/lib/core/translations/language/sr_rs.dart @@ -1912,4 +1912,208 @@ const Map srRS = { 'unmarkFavorite': 'Уклони из омиљених', 'unnamedDevice': 'Уређај без назива', 'userDataParseFailed': 'Подаци корисника нису могли да се учитају', + 'catEarphone': 'AI slušalice', + 'catEarphoneDesc': 'Klasične Bluetooth / BLE slušalice', + 'catPhoneHolder': 'AI držač telefona', + 'catPhoneHolderDesc': 'Uređaji tipa držač', + 'catCarAroma': 'AI auto difuzor', + 'catCarAromaDesc': 'Difuzor mirisa za auto', + 'agreeAndContinue': 'Prihvati i nastavi', + 'memberServiceAgreement': 'Ugovor o članskim uslugama', + 'memberServiceAgreementTitle': 'Ugovor o članskim uslugama', + 'memberServiceAgreementIntro': 'Da bismo vam pružili bolje članske usluge, pažljivo pročitajte pre pretplate: ', + 'memberServiceAgreementOutro': '. Klikom na „Prihvati i nastavi“ potvrđujete da ste u potpunosti pročitali i prihvatili gore navedeni ugovor.', + 'memberAgreementRejectedTip': 'Morate prihvatiti Ugovor o članskim uslugama da biste nastavili.', + 'translationCallExitTitle': 'Izlaz iz prevoda poziva', + 'translationCallExitMessage': 'Izaberite šta želite da uradite pre izlaska', + 'translationCallEnd': 'Završi prevod poziva', + 'translationContinueInBackground': 'Nastavi prevod u pozadini', + 'backgroundRecordingIndicatorBody': 'Snimanje se nastavlja u pozadini. Dodirnite da se vratite.', + 'backgroundTranslationIndicatorBody': 'Prevod poziva se nastavlja u pozadini. Dodirnite da se vratite.', + 'meetingRecordScreenCapturePermissionDenied': 'Za snimanje sistemskog zvuka potrebna je dozvola za snimanje ekrana. Odobrite je i pokušajte ponovo.', + 'pleaseGrantBluetoothPermission': 'Dozvolite pristup Bluetooth-u za pretraživanje', + 'startScanFailed': 'Pokretanje pretrage nije uspelo', + 'pleaseLoginFirst': 'Prvo se prijavite', + 'deviceUnauthorized': 'Uređaj nije autorizovan, pokušajte ponovo', + 'boundDevices': 'Vezani uređaji', + 'currentConnection': 'Trenutna veza', + 'binding': 'Vezivanje...', + 'permissionDisabled': 'Isključeno', + 'confirmDisablePermissionTitle': 'Isključi dozvolu', + 'confirmDisablePermissionContent': 'Isključiti dozvolu {permission}? Moraćete da je ponovo uključite u sistemskim podešavanjima.', + 'confirmPermanentlyDisallowTitle': 'Ne pitati više?', + 'confirmPermanentlyDisallowContent': 'Odbili ste dozvolu {permission}. Da više ne pitamo? Ako izaberete „Ne dozvoli“, i dalje je možete kasnije uključiti u sistemskim podešavanjima.', + 'notNow': 'Ne sada', + 'permanentlyDisallow': 'Ne dozvoli', + 'otaBesNotSupported': 'Ove slušalice još ne podržavaju ažuriranje firmvera iz aplikacije. Obratite se podršci.', + 'tabDevice': 'Uređaji', + 'tabPlaza': 'Trg', + 'sloganHome': 'Tehnologija za pametniji život', + 'sloganAi': 'Inteligencija bez granica', + 'sloganDevice': 'Pametan hardver, spreman za budućnost', + 'sloganPlaza': 'Pametan svet koji te razume', + 'aiEditHint': 'Dugo pritisnite karticu da prevučete i preuredite module', + 'aiEmptyTitle': 'Još nema dodatih modula', + 'aiEmptyDesc': 'Izaberite potrebne agente na Trgu', + 'goToPlaza': 'Idi na Trg', + 'plazaOnline': 'Dostupno', + 'plazaPlanned': 'Uskoro', + 'plazaSearchHint': 'Pretraži agente, npr. „prevod“, „snimanje“', + 'moduleRemove': 'Ukloni', + 'moduleAdd': 'Dodaj', + 'moduleRemoved': 'Uklonjeno', + 'moduleAdded': 'Dodato', + 'goSee': 'Pogledaj', + 'comingSoonTag': 'Uskoro', + 'needDeviceTag': 'Potreban uređaj', + 'featureInDev': 'Ova funkcija je u razvoju', + 'featureNotWired': 'Još nije dostupno', + 'todayLabel': 'Danas', + 'deviceConnected': 'Povezano', + 'licenseUnknown': 'Licenca nepoznata', + 'selectDevice': 'Izaberite uređaj', + 'catNoteCard': 'AI kartica za snimanje', + 'catNoteCardDesc': 'Diktafon u obliku kartice', + 'catParkerPen': 'AI olovka', + 'catParkerPenDesc': 'Olovke sa diktafonom', + 'catStylusPen': 'AI stilus', + 'catStylusPenDesc': 'Uređaji za rukopis / stilus', + 'catStudyMachine': 'AI tablet za učenje', + 'catStudyMachineDesc': 'Uređaji za učenje', + 'besAddTitle': 'Dodaj AI slušalice', + 'besConnecting': 'Povezivanje sa slušalicama…', + 'besNotFound': 'Slušalice nisu pronađene', + 'besNotFoundIosDesc': 'Prvo povežite slušalice u Podešavanja → Bluetooth, pa se vratite ovde', + 'besNotFoundAndroidDesc': 'Prvo uparite slušalice u Podešavanja → Bluetooth, pa ih povežite ovde', + 'besReadFailed': 'Čitanje nije uspelo', + 'besConnectFailIos': 'Proverite da su slušalice prikazane kao „Povezano“ u sistemskom Bluetooth-u, pa pokušajte ponovo', + 'besConnectFailAndroid': 'Proverite da su slušalice uključene, u blizini i da nisu povezane sa drugim uređajem. Nakon povezivanja povezivaće se automatski.', + 'holderAddTitle': 'Dodaj AI držač telefona', + 'holderConnecting': 'Povezivanje sa uređajem…', + 'holderSearching': 'Traženje uređaja…', + 'holderNotFound': 'Nije pronađen nijedan uređaj', + 'holderPairHint': 'Proverite da je držač uključen i u režimu uparivanja', + 'holderRetryHint': 'Proverite da je uređaj uključen i u blizini, pa pokušajte ponovo', + 'autoConnectTitle': 'Automatsko povezivanje uređaja', + 'autoConnectDesc': 'Budi aplikaciju radi ponovnog povezivanja i prijema snimaka kada se uređaj uključi ili nađe u blizini', + 'autoConnectConsentTitle': 'Uključi automatsko povezivanje', + 'autoConnectConsentIntro': 'Da bi se vaš uređaj (AI slušalice / AI držač telefona) automatski povezivao i čuvao snimke kada pritisnete dugme za snimanje, potrebna nam je vaša saglasnost:', + 'autoConnectConsentB1': 'Registrovanje pozadinske Bluetooth pretrage kako bi se aplikacija probudila (automatski pokrenula) kada se otkrije vaš vezani uređaj, radi ponovnog povezivanja i prijema snimaka', + 'autoConnectConsentB2': 'Nakon buđenja koristi se samo za povezivanje uređaja, snimanje i otpremanje — ništa drugo, i ne pokreću se druge aplikacije', + 'autoConnectConsentB3': 'Aplikaciju možete koristiti i bez toga — samo svaki put ručno povežite uređaj', + 'autoConnectConsentB4': 'Možete je isključiti bilo kada u Podešavanja → Automatsko povezivanje uređaja', + 'autoConnectAgree': 'Prihvati i uključi', + 'autoConnectLater': 'Ne sada', + 'autoConnectRequired': 'Automatsko povezivanje je isključeno', + 'autoConnectRequiredDesc': 'Uključite automatsko povezivanje da biste vezali ovaj uređaj', + 'recordTapToStart': 'Dodirnite dugme ispod da započnete snimanje', + 'recordPaused': 'Pauzirano', + 'recordMark': 'Oznaka', + 'recordDeleteTitle': 'Obriši snimak', + 'accountAndProfile': 'Nalog i profil', + 'nicknameUnset': 'Nadimak nije postavljen', + 'nicknameInput': 'Unesite nadimak', + 'loginMethod': 'Način prijave', + 'manageMyLogin': 'Upravljaj prijavom', + 'logoutAction': 'Odjavi se', + 'warningLabel': 'Upozorenje:', + 'deleteAccountWarn': 'Vaš nalog i sve datoteke biće trajno obrisani.', + 'icpFiling': 'ICP registracija', + 'logExport': 'Izvoz dnevnika', + 'logExportDesc': 'Pregled i izvoz dnevnika aplikacije', + 'permLoadFailed': 'Učitavanje grupa dozvola nije uspelo', + 'permInsufficient': 'Dozvola odbijena', + 'voiceCloneCreating': 'Kreiranje vašeg glasa…', + 'voiceCloneCreate': 'Kreiraj moj glas', + 'voiceCloneAgree': 'Prihvati i pročitaj izjavu naglas', + 'voiceCloneReadPrompt': 'Pročitajte:', + 'voiceCloneFirstMonthFree': 'Prvi mesec besplatno', + 'voiceCloneEnable': 'Uključi kloniranje glasa', + 'noMusicPlaying': 'Ništa se ne reprodukuje', + 'qqMusicNoSeek': 'Premotavanje nije podržano za QQ Music', + 'bleScanTitle': 'BLE pretraga', + 'bluetoothOn': 'Bluetooth je uključen', + 'bleScanning': 'Pretraživanje…', + 'bleConnectedDevices': 'Povezani uređaji', + 'bleUnknownDevice': 'Nepoznat uređaj', + 'bleTapToScan': 'Dodirnite „Pretraži“ da potražite BLE uređaje', + 'weekMon': 'Pon', + 'weekTue': 'Uto', + 'weekWed': 'Sre', + 'weekThu': 'Čet', + 'weekFri': 'Pet', + 'weekSat': 'Sub', + 'weekSun': 'Ned', + 'agentMeeting': 'Glasovne beleške', + 'agentMeetingDesc': 'Rezimei snimaka, centar memorije', + 'agentEmai': 'EMAI asistent', + 'agentEmaiDesc': 'Razgovarajte i pitajte bilo šta glasom', + 'agentSimultaneous': 'Prevod uživo', + 'agentSimultaneousDesc': 'Prevod dok govorite', + 'agentFaceToFace': 'Licem u lice', + 'agentFaceToFaceDesc': 'Dvosmerni prevod razgovora', + 'agentCallRecording': 'Snimanje poziva', + 'agentCallRecordingDesc': 'Pozivi se čuvaju automatski', + 'agentFieldRecording': 'Snimanje na terenu', + 'agentFieldRecordingDesc': 'Mikrofon uređaja ili telefona', + 'agentCall': 'Prevod poziva', + 'agentCallDesc': 'Prevod u realnom vremenu tokom poziva', + 'agentAudioVideo': 'Prevod medija', + 'agentAudioVideoDesc': 'Kadriranje kamerom / prevod audio i video sadržaja', + 'agentEnglishChat': 'Razgovor na engleskom', + 'agentEnglishChatDesc': 'Imerzivno vežbanje govora', + 'agentEnglishStudy': 'Učenje engleskog', + 'agentEnglishStudyDesc': 'Lekcije iz vokabulara i gramatike', + 'agentKidsStory': 'Priče za decu', + 'agentKidsStoryDesc': 'Priče za laku noć i pitanja i odgovori', + 'moduleRemovedDesc': 'uklonjeno sa AI stranice', + 'moduleAddedDesc': 'dodato na dno AI stranice', + 'calNoRecords': 'Nema unosa', + 'calNoRecordsDay': 'Ništa nije zakazano', + 'calTodos': 'Zadaci', + 'calExpenses': 'Troškovi', + 'calTodoCount': '@count zadataka', + 'calExpenseCount': '@count troškova', + 'calEntryCount': '@count unosa', + 'calItemsUnit': 'stavki', + 'calCountUnit': 'unosa', + 'calTotalAmount': 'Ukupno', + 'recordPause': 'Pauziraj', + 'recordResume': 'Nastavi', + 'recordDeleteConfirm': 'Obrisati ovaj snimak?', + 'scopeDay': 'Dan', + 'scopeWeek': 'Nedelja', + 'scopeMonth': 'Mesec', + 'allDay': 'Ceo dan', + 'guestModeTitle': 'Režim gosta', + 'guestModeDesc': 'Gosti mogu samo da pregledaju. Prijavite se da biste koristili glasovne beleške, prevod i snimanje — vaši podaci će se čuvati na vašem nalogu.', + 'guestKeepBrowsing': 'Nastavi pregled', + 'guestGoLogin': 'Prijavi se', + 'guestViewOnlyTag': 'Samo pregled', + 'autoConnectReenableHint': 'Automatsko povezivanje je isključeno. Ponovo ga uključite u Podešavanja → Automatsko povezivanje uređaja.', + 'plazaEntryDesc': 'Pregledajte i dodajte još agenata', + 'agentTextTranslation': 'Prevod teksta', + 'agentTextTranslationDesc': 'Prevod ukucanog ili nalepljenog teksta', + 'agentPictureTranslation': 'Prevod fotografija', + 'agentPictureTranslationDesc': 'Slikajte ili izaberite fotografiju za prevod', + 'callRequiredTitle': 'Potreban je poziv', + 'callRequiredDesc': 'Ova funkcija radi samo tokom poziva', + 'inCallBusyTitle': 'Nedostupno tokom poziva', + 'inCallBusyDesc': 'Mikrofon i audio putanja su zauzeti pozivom. Pokušajte ponovo nakon završetka poziva.', + 'vipSectionTitle': 'VIP', + 'freeSectionTitle': 'Besplatno', + 'smartcarConnected': 'Connected', + 'smartcarConnectFailed': 'Connection failed', + 'smartcarConnecting': 'Connecting…', + 'smartcarSearching': 'Searching for nearby car diffusers…', + 'smartcarNotFound': 'No device found', + 'smartcarScanHint': 'Make sure the device is powered on and close to your phone', + 'vipRequiredTitle': 'Membership required', + 'vipRequiredDesc': 'Translation and meeting notes require a membership. Renew, or pair a device that includes one.', + 'vipRequiredGo': 'Get membership', + 'vipFromDevice': 'from device', + 'computeSummary': 'Compute: device @device · account @user', + 'computePack': 'Compute packs', + 'vipExpiringSoon': 'Membership expires in @days day(s). Renew to keep using translation and meeting notes.', + 'computeLow': 'Running low on compute. You won\'t be able to continue once it runs out.', }; diff --git a/apps/client/lib/core/translations/language/sv_se.dart b/apps/client/lib/core/translations/language/sv_se.dart index 238904bd..f888067e 100644 --- a/apps/client/lib/core/translations/language/sv_se.dart +++ b/apps/client/lib/core/translations/language/sv_se.dart @@ -1908,4 +1908,208 @@ const Map svSE = { 'unmarkFavorite': 'Ta bort från favoriter', 'unnamedDevice': 'Namnlös enhet', 'userDataParseFailed': 'Det gick inte att läsa användardata', + 'catEarphone': 'AI-öronsnäckor', + 'catEarphoneDesc': 'Klassiska Bluetooth- / BLE-öronsnäckor', + 'catPhoneHolder': 'AI-mobilhållare', + 'catPhoneHolderDesc': 'Hållarenheter', + 'catCarAroma': 'AI-bildoft', + 'catCarAromaDesc': 'Aromdiffusor för bil', + 'agreeAndContinue': 'Godkänn och fortsätt', + 'memberServiceAgreement': 'Medlemstjänstavtal', + 'memberServiceAgreementTitle': 'Medlemstjänstavtal', + 'memberServiceAgreementIntro': 'För att ge dig bättre medlemstjänster, läs noga innan du prenumererar: ', + 'memberServiceAgreementOutro': '. Genom att trycka på ”Godkänn och fortsätt” bekräftar du att du har läst och godkänner avtalet ovan.', + 'memberAgreementRejectedTip': 'Du måste godkänna medlemstjänstavtalet för att fortsätta.', + 'translationCallExitTitle': 'Lämna samtalsöversättning', + 'translationCallExitMessage': 'Välj vad som ska hända innan du lämnar', + 'translationCallEnd': 'Avsluta samtalsöversättning', + 'translationContinueInBackground': 'Fortsätt översätta i bakgrunden', + 'backgroundRecordingIndicatorBody': 'Inspelningen fortsätter i bakgrunden. Tryck för att gå tillbaka.', + 'backgroundTranslationIndicatorBody': 'Samtalsöversättningen fortsätter i bakgrunden. Tryck för att gå tillbaka.', + 'meetingRecordScreenCapturePermissionDenied': 'Behörighet för skärminspelning krävs för att spela in systemljud. Ge behörighet och försök igen.', + 'pleaseGrantBluetoothPermission': 'Ge Bluetooth-behörighet för att skanna', + 'startScanFailed': 'Det gick inte att starta skanningen', + 'pleaseLoginFirst': 'Logga in först', + 'deviceUnauthorized': 'Enheten är inte auktoriserad, försök igen', + 'boundDevices': 'Kopplade enheter', + 'currentConnection': 'Aktuell anslutning', + 'binding': 'Kopplar...', + 'permissionDisabled': 'Inaktiverad', + 'confirmDisablePermissionTitle': 'Stäng av behörighet', + 'confirmDisablePermissionContent': 'Stänga av behörigheten {permission}? Du måste gå till systeminställningarna för att aktivera den igen.', + 'confirmPermanentlyDisallowTitle': 'Sluta fråga?', + 'confirmPermanentlyDisallowContent': 'Du nekade behörigheten {permission}. Sluta fråga från och med nu? Om du väljer ”Tillåt inte” kan du fortfarande aktivera den senare i systeminställningarna.', + 'notNow': 'Inte nu', + 'permanentlyDisallow': 'Tillåt inte', + 'otaBesNotSupported': 'Det här headsetet stöder ännu inte firmware-uppgradering i appen. Kontakta supporten.', + 'tabDevice': 'Enheter', + 'tabPlaza': 'Plaza', + 'sloganHome': 'Teknik för ett smartare liv', + 'sloganAi': 'Gränslös intelligens', + 'sloganDevice': 'Smart hårdvara, redo för framtiden', + 'sloganPlaza': 'En smart värld som förstår dig', + 'aiEditHint': 'Håll in ett kort för att dra och ordna om moduler', + 'aiEmptyTitle': 'Inga moduler tillagda ännu', + 'aiEmptyDesc': 'Välj de agenter du behöver i Plaza', + 'goToPlaza': 'Gå till Plaza', + 'plazaOnline': 'Tillgänglig', + 'plazaPlanned': 'Kommer snart', + 'plazaSearchHint': 'Sök efter agenter, t.ex. ”översätt”, ”spela in”', + 'moduleRemove': 'Ta bort', + 'moduleAdd': 'Lägg till', + 'moduleRemoved': 'Borttagen', + 'moduleAdded': 'Tillagd', + 'goSee': 'Visa', + 'comingSoonTag': 'Kommer snart', + 'needDeviceTag': 'Kräver enhet', + 'featureInDev': 'Den här funktionen är under utveckling', + 'featureNotWired': 'Inte tillgänglig ännu', + 'todayLabel': 'Idag', + 'deviceConnected': 'Ansluten', + 'licenseUnknown': 'Licens okänd', + 'selectDevice': 'Välj en enhet', + 'catNoteCard': 'AI-inspelarkort', + 'catNoteCardDesc': 'Kortformad inspelare', + 'catParkerPen': 'AI-penna', + 'catParkerPenDesc': 'Röstinspelningspennor', + 'catStylusPen': 'AI-stylus', + 'catStylusPenDesc': 'Handskrifts-/stylusenheter', + 'catStudyMachine': 'AI-lärplatta', + 'catStudyMachineDesc': 'Lärenheter', + 'besAddTitle': 'Lägg till AI-hörlurar', + 'besConnecting': 'Ansluter till hörlurarna…', + 'besNotFound': 'Hörlurarna hittades inte', + 'besNotFoundIosDesc': 'Anslut hörlurarna i Inställningar → Bluetooth först, och kom sedan tillbaka', + 'besNotFoundAndroidDesc': 'Parkoppla hörlurarna i Inställningar → Bluetooth först, och anslut sedan här', + 'besReadFailed': 'Läsning misslyckades', + 'besConnectFailIos': 'Se till att hörlurarna visas som Ansluten i systemets Bluetooth, och försök igen', + 'besConnectFailAndroid': 'Se till att hörlurarna är på, i närheten och inte anslutna till en annan enhet. De återansluter automatiskt när de är parkopplade.', + 'holderAddTitle': 'Lägg till AI-telefonhållare', + 'holderConnecting': 'Ansluter till enheten…', + 'holderSearching': 'Söker efter enheter…', + 'holderNotFound': 'Inga enheter hittades', + 'holderPairHint': 'Se till att hållaren är på och i parkopplingsläge', + 'holderRetryHint': 'Se till att enheten är på och i närheten, och försök igen', + 'autoConnectTitle': 'Anslut enheter automatiskt', + 'autoConnectDesc': 'Väck appen för att återansluta och ta emot inspelningar när din enhet slås på eller kommer i närheten', + 'autoConnectConsentTitle': 'Aktivera automatisk anslutning', + 'autoConnectConsentIntro': 'För att din enhet (AI-hörlurar / AI-telefonhållare) ska kunna ansluta automatiskt och spara inspelningar när du trycker på dess inspelningsknapp behöver vi ditt samtycke:', + 'autoConnectConsentB1': 'Registrera en Bluetooth-skanning i bakgrunden så att appen väcks (startas automatiskt) när din kopplade enhet upptäcks, för att återansluta och ta emot inspelningar', + 'autoConnectConsentB2': 'När appen väckts används den bara för enhetsanslutning, inspelning och uppladdning — inget annat, och inga andra appar startas', + 'autoConnectConsentB3': 'Du kan fortfarande använda appen utan det — anslut bara enheten manuellt varje gång', + 'autoConnectConsentB4': 'Du kan stänga av det när som helst i Inställningar → Anslut enheter automatiskt', + 'autoConnectAgree': 'Godkänn och aktivera', + 'autoConnectLater': 'Inte nu', + 'autoConnectRequired': 'Automatisk anslutning är av', + 'autoConnectRequiredDesc': 'Aktivera automatisk anslutning för att koppla den här enheten', + 'recordTapToStart': 'Tryck på knappen nedan för att starta inspelningen', + 'recordPaused': 'Pausad', + 'recordMark': 'Markera', + 'recordDeleteTitle': 'Ta bort inspelning', + 'accountAndProfile': 'Konto och profil', + 'nicknameUnset': 'Inget smeknamn angivet', + 'nicknameInput': 'Ange ett smeknamn', + 'loginMethod': 'Inloggningsmetod', + 'manageMyLogin': 'Hantera inloggning', + 'logoutAction': 'Logga ut', + 'warningLabel': 'Varning:', + 'deleteAccountWarn': 'Ditt konto och alla filer raderas permanent.', + 'icpFiling': 'ICP-registrering', + 'logExport': 'Exportera loggar', + 'logExportDesc': 'Visa och exportera appens loggar', + 'permLoadFailed': 'Det gick inte att läsa in behörighetsgrupper', + 'permInsufficient': 'Behörighet nekad', + 'voiceCloneCreating': 'Skapar din röst…', + 'voiceCloneCreate': 'Skapa min röst', + 'voiceCloneAgree': 'Godkänn och läs upp uttalandet högt', + 'voiceCloneReadPrompt': 'Läs upp:', + 'voiceCloneFirstMonthFree': 'Första månaden gratis', + 'voiceCloneEnable': 'Aktivera röstkloning', + 'noMusicPlaying': 'Inget spelas', + 'qqMusicNoSeek': 'Spolning stöds inte för QQ Music', + 'bleScanTitle': 'BLE-skanning', + 'bluetoothOn': 'Bluetooth är på', + 'bleScanning': 'Skannar…', + 'bleConnectedDevices': 'Anslutna enheter', + 'bleUnknownDevice': 'Okänd enhet', + 'bleTapToScan': 'Tryck på skanna för att söka efter BLE-enheter', + 'weekMon': 'Mån', + 'weekTue': 'Tis', + 'weekWed': 'Ons', + 'weekThu': 'Tors', + 'weekFri': 'Fre', + 'weekSat': 'Lör', + 'weekSun': 'Sön', + 'agentMeeting': 'Röstprotokoll', + 'agentMeetingDesc': 'Sammanfatta inspelningar, minnescenter', + 'agentEmai': 'EMAI-assistent', + 'agentEmaiDesc': 'Chatta och fråga vad som helst med rösten', + 'agentSimultaneous': 'Realtidsöversättning', + 'agentSimultaneousDesc': 'Översätt medan du talar', + 'agentFaceToFace': 'Ansikte mot ansikte', + 'agentFaceToFaceDesc': 'Tvåvägs samtalsöversättning', + 'agentCallRecording': 'Samtalsinspelning', + 'agentCallRecordingDesc': 'Samtal sparas automatiskt', + 'agentFieldRecording': 'Fältinspelning', + 'agentFieldRecordingDesc': 'Enhetens eller telefonens mikrofon', + 'agentCall': 'Samtalsöversättning', + 'agentCallDesc': 'Realtidsöversättning under samtal', + 'agentAudioVideo': 'Medieöversättning', + 'agentAudioVideoDesc': 'Kamerainramning / ljud- och videoöversättning', + 'agentEnglishChat': 'Engelsk konversation', + 'agentEnglishChatDesc': 'Uppslukande talträning', + 'agentEnglishStudy': 'Engelskstudier', + 'agentEnglishStudyDesc': 'Ordförråds- och grammatiklektioner', + 'agentKidsStory': 'Barnsagor', + 'agentKidsStoryDesc': 'Godnattsagor och frågor och svar', + 'moduleRemovedDesc': 'borttagen från AI-sidan', + 'moduleAddedDesc': 'tillagd längst ner på AI-sidan', + 'calNoRecords': 'Inga poster', + 'calNoRecordsDay': 'Inget planerat', + 'calTodos': 'Att göra', + 'calExpenses': 'Utgifter', + 'calTodoCount': '@count att göra', + 'calExpenseCount': '@count utgifter', + 'calEntryCount': '@count poster', + 'calItemsUnit': 'st', + 'calCountUnit': 'poster', + 'calTotalAmount': 'Totalt', + 'recordPause': 'Pausa', + 'recordResume': 'Fortsätt', + 'recordDeleteConfirm': 'Ta bort den här inspelningen?', + 'scopeDay': 'Dag', + 'scopeWeek': 'Vecka', + 'scopeMonth': 'Månad', + 'allDay': 'Hela dagen', + 'guestModeTitle': 'Gästläge', + 'guestModeDesc': 'Gäster kan bara titta. Logga in för att använda röstprotokoll, översättning och inspelning — dina data sparas på ditt konto.', + 'guestKeepBrowsing': 'Fortsätt titta', + 'guestGoLogin': 'Logga in', + 'guestViewOnlyTag': 'Endast visning', + 'autoConnectReenableHint': 'Automatisk anslutning är av. Slå på den igen i Inställningar → Anslut enheter automatiskt.', + 'plazaEntryDesc': 'Bläddra och lägg till fler agenter', + 'agentTextTranslation': 'Textöversättning', + 'agentTextTranslationDesc': 'Översätt skriven eller inklistrad text', + 'agentPictureTranslation': 'Bildöversättning', + 'agentPictureTranslationDesc': 'Ta eller välj ett foto att översätta', + 'callRequiredTitle': 'Samtal krävs', + 'callRequiredDesc': 'Den här funktionen fungerar bara under ett samtal', + 'inCallBusyTitle': 'Inte tillgänglig under samtal', + 'inCallBusyDesc': 'Mikrofonen och ljudvägen används av samtalet. Försök igen när du har lagt på.', + 'vipSectionTitle': 'VIP', + 'freeSectionTitle': 'Gratis', + 'smartcarConnected': 'Connected', + 'smartcarConnectFailed': 'Connection failed', + 'smartcarConnecting': 'Connecting…', + 'smartcarSearching': 'Searching for nearby car diffusers…', + 'smartcarNotFound': 'No device found', + 'smartcarScanHint': 'Make sure the device is powered on and close to your phone', + 'vipRequiredTitle': 'Membership required', + 'vipRequiredDesc': 'Translation and meeting notes require a membership. Renew, or pair a device that includes one.', + 'vipRequiredGo': 'Get membership', + 'vipFromDevice': 'from device', + 'computeSummary': 'Compute: device @device · account @user', + 'computePack': 'Compute packs', + 'vipExpiringSoon': 'Membership expires in @days day(s). Renew to keep using translation and meeting notes.', + 'computeLow': 'Running low on compute. You won\'t be able to continue once it runs out.', }; diff --git a/apps/client/lib/core/translations/language/th_th.dart b/apps/client/lib/core/translations/language/th_th.dart index d764e72f..9bdd6000 100644 --- a/apps/client/lib/core/translations/language/th_th.dart +++ b/apps/client/lib/core/translations/language/th_th.dart @@ -1861,4 +1861,208 @@ const Map thTH = { 'unmarkFavorite': 'นำออกจากรายการโปรด', 'unnamedDevice': 'อุปกรณ์ที่ไม่มีชื่อ', 'userDataParseFailed': 'อ่านข้อมูลผู้ใช้ไม่สำเร็จ', + 'catEarphone': 'หูฟัง AI', + 'catEarphoneDesc': 'หูฟัง Bluetooth คลาสสิก / BLE', + 'catPhoneHolder': 'ขาตั้งโทรศัพท์ AI', + 'catPhoneHolderDesc': 'อุปกรณ์ประเภทขาตั้ง', + 'catCarAroma': 'เครื่องหอมในรถ AI', + 'catCarAromaDesc': 'เครื่องกระจายกลิ่นในรถ', + 'agreeAndContinue': 'ยอมรับและดำเนินการต่อ', + 'memberServiceAgreement': 'ข้อตกลงการให้บริการสมาชิก', + 'memberServiceAgreementTitle': 'ข้อตกลงการให้บริการสมาชิก', + 'memberServiceAgreementIntro': 'เพื่อให้บริการสมาชิกที่ดียิ่งขึ้น กรุณาอ่านอย่างละเอียดก่อนสมัคร: ', + 'memberServiceAgreementOutro': ' เมื่อแตะ "ยอมรับและดำเนินการต่อ" ถือว่าคุณได้อ่านและยอมรับข้อตกลงข้างต้นครบถ้วนแล้ว', + 'memberAgreementRejectedTip': 'คุณต้องยอมรับข้อตกลงการให้บริการสมาชิกจึงจะดำเนินการต่อได้', + 'translationCallExitTitle': 'ออกจากการแปลการโทร', + 'translationCallExitMessage': 'เลือกสิ่งที่ต้องการทำก่อนออก', + 'translationCallEnd': 'สิ้นสุดการแปลการโทร', + 'translationContinueInBackground': 'แปลต่อในเบื้องหลัง', + 'backgroundRecordingIndicatorBody': 'กำลังบันทึกต่อในเบื้องหลัง แตะเพื่อกลับ', + 'backgroundTranslationIndicatorBody': 'กำลังแปลการโทรต่อในเบื้องหลัง แตะเพื่อกลับ', + 'meetingRecordScreenCapturePermissionDenied': 'ต้องได้รับสิทธิ์บันทึกหน้าจอเพื่อบันทึกเสียงของระบบ กรุณาอนุญาตแล้วลองใหม่', + 'pleaseGrantBluetoothPermission': 'กรุณาอนุญาตสิทธิ์บลูทูธเพื่อสแกน', + 'startScanFailed': 'เริ่มสแกนไม่สำเร็จ', + 'pleaseLoginFirst': 'กรุณาเข้าสู่ระบบก่อน', + 'deviceUnauthorized': 'อุปกรณ์ไม่ได้รับอนุญาต กรุณาลองใหม่', + 'boundDevices': 'อุปกรณ์ที่ผูกไว้', + 'currentConnection': 'การเชื่อมต่อปัจจุบัน', + 'binding': 'กำลังผูกอุปกรณ์...', + 'permissionDisabled': 'ปิดอยู่', + 'confirmDisablePermissionTitle': 'ปิดสิทธิ์', + 'confirmDisablePermissionContent': 'ปิดสิทธิ์ {permission} หรือไม่? คุณจะต้องไปเปิดใหม่ในการตั้งค่าระบบ', + 'confirmPermanentlyDisallowTitle': 'ไม่ถามอีก?', + 'confirmPermanentlyDisallowContent': 'คุณปฏิเสธสิทธิ์ {permission} ต้องการให้ไม่ถามอีกหรือไม่? หากเลือก "ไม่อนุญาต" คุณยังเปิดได้ภายหลังในการตั้งค่าระบบ', + 'notNow': 'ไว้ทีหลัง', + 'permanentlyDisallow': 'ไม่อนุญาต', + 'otaBesNotSupported': 'หูฟังนี้ยังไม่รองรับการอัปเกรดเฟิร์มแวร์ในแอป กรุณาติดต่อฝ่ายสนับสนุน', + 'tabDevice': 'อุปกรณ์', + 'tabPlaza': 'พลาซ่า', + 'sloganHome': 'เทคโนโลยีเพื่อชีวิตที่ฉลาดขึ้น', + 'sloganAi': 'ความอัจฉริยะไร้ขีดจำกัด', + 'sloganDevice': 'ฮาร์ดแวร์อัจฉริยะ พร้อมสู่อนาคต', + 'sloganPlaza': 'โลกอัจฉริยะที่เข้าใจคุณ', + 'aiEditHint': 'กดค้างที่การ์ดเพื่อลากจัดเรียงโมดูล', + 'aiEmptyTitle': 'ยังไม่ได้เพิ่มโมดูล', + 'aiEmptyDesc': 'เลือกเอเจนต์ที่คุณต้องการได้ในพลาซ่า', + 'goToPlaza': 'ไปที่พลาซ่า', + 'plazaOnline': 'ใช้งานได้', + 'plazaPlanned': 'เร็วๆ นี้', + 'plazaSearchHint': 'ค้นหาเอเจนต์ เช่น “แปล”, “บันทึก”', + 'moduleRemove': 'นำออก', + 'moduleAdd': 'เพิ่ม', + 'moduleRemoved': 'นำออกแล้ว', + 'moduleAdded': 'เพิ่มแล้ว', + 'goSee': 'ดู', + 'comingSoonTag': 'เร็วๆ นี้', + 'needDeviceTag': 'ต้องใช้อุปกรณ์', + 'featureInDev': 'ฟีเจอร์นี้อยู่ระหว่างการพัฒนา', + 'featureNotWired': 'ยังไม่พร้อมใช้งาน', + 'todayLabel': 'วันนี้', + 'deviceConnected': 'เชื่อมต่อแล้ว', + 'licenseUnknown': 'ไม่ทราบสถานะสิทธิ์การใช้งาน', + 'selectDevice': 'เลือกอุปกรณ์', + 'catNoteCard': 'การ์ดบันทึกเสียง AI', + 'catNoteCardDesc': 'เครื่องบันทึกเสียงทรงการ์ด', + 'catParkerPen': 'ปากกา AI', + 'catParkerPenDesc': 'ปากกาบันทึกเสียง', + 'catStylusPen': 'สไตลัส AI', + 'catStylusPenDesc': 'อุปกรณ์เขียนด้วยลายมือ / สไตลัส', + 'catStudyMachine': 'แท็บเล็ตการเรียนรู้ AI', + 'catStudyMachineDesc': 'อุปกรณ์เพื่อการเรียนรู้', + 'besAddTitle': 'เพิ่มหูฟัง AI', + 'besConnecting': 'กำลังเชื่อมต่อหูฟัง…', + 'besNotFound': 'ไม่พบหูฟัง', + 'besNotFoundIosDesc': 'กรุณาเชื่อมต่อหูฟังใน การตั้งค่า → บลูทูธ ก่อน แล้วกลับมาที่นี่', + 'besNotFoundAndroidDesc': 'กรุณาจับคู่หูฟังใน การตั้งค่า → บลูทูธ ก่อน แล้วมาเชื่อมต่อที่นี่', + 'besReadFailed': 'อ่านข้อมูลไม่สำเร็จ', + 'besConnectFailIos': 'ตรวจสอบว่าหูฟังแสดงสถานะ "เชื่อมต่อแล้ว" ในบลูทูธของระบบ แล้วลองใหม่', + 'besConnectFailAndroid': 'ตรวจสอบว่าหูฟังเปิดอยู่ อยู่ใกล้ๆ และไม่ได้เชื่อมต่อกับอุปกรณ์อื่น เมื่อเชื่อมต่อแล้วจะเชื่อมต่อใหม่โดยอัตโนมัติ', + 'holderAddTitle': 'เพิ่มขาตั้งโทรศัพท์ AI', + 'holderConnecting': 'กำลังเชื่อมต่ออุปกรณ์…', + 'holderSearching': 'กำลังค้นหาอุปกรณ์…', + 'holderNotFound': 'ไม่พบอุปกรณ์', + 'holderPairHint': 'ตรวจสอบว่าขาตั้งเปิดอยู่และอยู่ในโหมดจับคู่', + 'holderRetryHint': 'ตรวจสอบว่าอุปกรณ์เปิดอยู่และอยู่ใกล้ๆ แล้วลองใหม่', + 'autoConnectTitle': 'เชื่อมต่ออุปกรณ์อัตโนมัติ', + 'autoConnectDesc': 'ปลุกแอปเพื่อเชื่อมต่อใหม่และรับการบันทึกเมื่ออุปกรณ์เปิดเครื่องหรืออยู่ใกล้', + 'autoConnectConsentTitle': 'เปิดการเชื่อมต่ออัตโนมัติ', + 'autoConnectConsentIntro': 'เพื่อให้อุปกรณ์ของคุณ (หูฟัง AI / ขาตั้งโทรศัพท์ AI) เชื่อมต่อโดยอัตโนมัติและบันทึกเสียงเมื่อคุณกดปุ่มบันทึกบนอุปกรณ์ เราต้องขอความยินยอมจากคุณ:', + 'autoConnectConsentB1': 'ลงทะเบียนการสแกนบลูทูธในเบื้องหลัง เพื่อให้แอปถูกปลุก (เริ่มทำงานอัตโนมัติ) เมื่อตรวจพบอุปกรณ์ที่ผูกไว้ สำหรับเชื่อมต่อใหม่และรับการบันทึก', + 'autoConnectConsentB2': 'เมื่อถูกปลุกแล้ว จะใช้เพื่อเชื่อมต่ออุปกรณ์ บันทึกเสียง และอัปโหลดเท่านั้น ไม่ทำอย่างอื่น และไม่เปิดแอปอื่น', + 'autoConnectConsentB3': 'คุณยังใช้แอปได้โดยไม่เปิดฟีเจอร์นี้ เพียงเชื่อมต่ออุปกรณ์ด้วยตนเองทุกครั้ง', + 'autoConnectConsentB4': 'คุณปิดได้ทุกเมื่อใน การตั้งค่า → เชื่อมต่ออุปกรณ์อัตโนมัติ', + 'autoConnectAgree': 'ยอมรับและเปิดใช้', + 'autoConnectLater': 'ไว้ทีหลัง', + 'autoConnectRequired': 'การเชื่อมต่ออัตโนมัติปิดอยู่', + 'autoConnectRequiredDesc': 'เปิดการเชื่อมต่ออัตโนมัติเพื่อผูกอุปกรณ์นี้', + 'recordTapToStart': 'แตะปุ่มด้านล่างเพื่อเริ่มบันทึก', + 'recordPaused': 'หยุดชั่วคราว', + 'recordMark': 'ทำเครื่องหมาย', + 'recordDeleteTitle': 'ลบการบันทึก', + 'accountAndProfile': 'บัญชีและโปรไฟล์', + 'nicknameUnset': 'ยังไม่ได้ตั้งชื่อเล่น', + 'nicknameInput': 'กรอกชื่อเล่น', + 'loginMethod': 'วิธีเข้าสู่ระบบ', + 'manageMyLogin': 'จัดการการเข้าสู่ระบบ', + 'logoutAction': 'ออกจากระบบ', + 'warningLabel': 'คำเตือน:', + 'deleteAccountWarn': 'บัญชีและไฟล์ทั้งหมดของคุณจะถูกลบอย่างถาวร', + 'icpFiling': 'การจดทะเบียน ICP', + 'logExport': 'ส่งออกบันทึกระบบ', + 'logExportDesc': 'ดูและส่งออกบันทึกระบบของแอป', + 'permLoadFailed': 'โหลดกลุ่มสิทธิ์ไม่สำเร็จ', + 'permInsufficient': 'ไม่ได้รับสิทธิ์', + 'voiceCloneCreating': 'กำลังสร้างเสียงของคุณ…', + 'voiceCloneCreate': 'สร้างเสียงของฉัน', + 'voiceCloneAgree': 'ยอมรับและอ่านข้อความออกเสียง', + 'voiceCloneReadPrompt': 'กรุณาอ่าน:', + 'voiceCloneFirstMonthFree': 'ฟรีเดือนแรก', + 'voiceCloneEnable': 'เปิดใช้การโคลนเสียง', + 'noMusicPlaying': 'ไม่มีเพลงที่กำลังเล่น', + 'qqMusicNoSeek': 'QQ Music ไม่รองรับการเลื่อนตำแหน่งเล่น', + 'bleScanTitle': 'สแกน BLE', + 'bluetoothOn': 'บลูทูธเปิดอยู่', + 'bleScanning': 'กำลังสแกน…', + 'bleConnectedDevices': 'อุปกรณ์ที่เชื่อมต่อแล้ว', + 'bleUnknownDevice': 'อุปกรณ์ที่ไม่รู้จัก', + 'bleTapToScan': 'แตะสแกนเพื่อค้นหาอุปกรณ์ BLE', + 'weekMon': 'จ.', + 'weekTue': 'อ.', + 'weekWed': 'พ.', + 'weekThu': 'พฤ.', + 'weekFri': 'ศ.', + 'weekSat': 'ส.', + 'weekSun': 'อา.', + 'agentMeeting': 'บันทึกเสียงสรุป', + 'agentMeetingDesc': 'สรุปการบันทึกเสียง ศูนย์รวมความจำ', + 'agentEmai': 'ผู้ช่วย EMAI', + 'agentEmaiDesc': 'พูดคุยและถามได้ทุกเรื่องด้วยเสียง', + 'agentSimultaneous': 'แปลสด', + 'agentSimultaneousDesc': 'แปลทันทีขณะที่คุณพูด', + 'agentFaceToFace': 'แปลแบบเห็นหน้า', + 'agentFaceToFaceDesc': 'แปลบทสนทนาสองทาง', + 'agentCallRecording': 'บันทึกการโทร', + 'agentCallRecordingDesc': 'บันทึกสายโทรอัตโนมัติ', + 'agentFieldRecording': 'บันทึกเสียงภาคสนาม', + 'agentFieldRecordingDesc': 'ผ่านอุปกรณ์หรือไมโครโฟนโทรศัพท์', + 'agentCall': 'แปลการโทร', + 'agentCallDesc': 'แปลแบบเรียลไทม์ระหว่างโทร', + 'agentAudioVideo': 'แปลสื่อ', + 'agentAudioVideoDesc': 'จัดเฟรมกล้อง / แปลเสียงและวิดีโอ', + 'agentEnglishChat': 'สนทนาภาษาอังกฤษ', + 'agentEnglishChatDesc': 'ฝึกพูดแบบเสมือนจริง', + 'agentEnglishStudy': 'เรียนภาษาอังกฤษ', + 'agentEnglishStudyDesc': 'บทเรียนคำศัพท์และไวยากรณ์', + 'agentKidsStory': 'นิทานเด็ก', + 'agentKidsStoryDesc': 'นิทานก่อนนอนและถาม-ตอบ', + 'moduleRemovedDesc': 'ถูกนำออกจากหน้า AI แล้ว', + 'moduleAddedDesc': 'ถูกเพิ่มไว้ท้ายหน้า AI แล้ว', + 'calNoRecords': 'ไม่มีรายการ', + 'calNoRecordsDay': 'ไม่มีกำหนดการ', + 'calTodos': 'สิ่งที่ต้องทำ', + 'calExpenses': 'ค่าใช้จ่าย', + 'calTodoCount': 'สิ่งที่ต้องทำ @count รายการ', + 'calExpenseCount': 'ค่าใช้จ่าย @count รายการ', + 'calEntryCount': '@count รายการ', + 'calItemsUnit': 'รายการ', + 'calCountUnit': 'รายการ', + 'calTotalAmount': 'รวม', + 'recordPause': 'หยุดชั่วคราว', + 'recordResume': 'บันทึกต่อ', + 'recordDeleteConfirm': 'ลบการบันทึกนี้หรือไม่?', + 'scopeDay': 'วัน', + 'scopeWeek': 'สัปดาห์', + 'scopeMonth': 'เดือน', + 'allDay': 'ทั้งวัน', + 'guestModeTitle': 'โหมดผู้เยี่ยมชม', + 'guestModeDesc': 'ผู้เยี่ยมชมดูได้อย่างเดียว เข้าสู่ระบบเพื่อใช้บันทึกเสียงสรุป การแปล และการบันทึก ข้อมูลของคุณจะถูกบันทึกไว้ในบัญชี', + 'guestKeepBrowsing': 'ดูต่อ', + 'guestGoLogin': 'เข้าสู่ระบบ', + 'guestViewOnlyTag': 'ดูได้อย่างเดียว', + 'autoConnectReenableHint': 'การเชื่อมต่ออัตโนมัติปิดอยู่ เปิดใหม่ได้ใน การตั้งค่า → เชื่อมต่ออุปกรณ์อัตโนมัติ', + 'plazaEntryDesc': 'สำรวจและเพิ่มเอเจนต์เพิ่มเติม', + 'agentTextTranslation': 'แปลข้อความ', + 'agentTextTranslationDesc': 'แปลข้อความที่พิมพ์หรือวาง', + 'agentPictureTranslation': 'แปลรูปภาพ', + 'agentPictureTranslationDesc': 'ถ่ายหรือเลือกรูปเพื่อแปล', + 'callRequiredTitle': 'ต้องอยู่ระหว่างการโทร', + 'callRequiredDesc': 'ฟีเจอร์นี้ใช้ได้เฉพาะระหว่างการโทรเท่านั้น', + 'inCallBusyTitle': 'ใช้งานไม่ได้ระหว่างการโทร', + 'inCallBusyDesc': 'ไมโครโฟนและเส้นทางเสียงกำลังถูกใช้โดยการโทร กรุณาลองใหม่หลังวางสาย', + 'vipSectionTitle': 'VIP', + 'freeSectionTitle': 'ฟรี', + 'smartcarConnected': 'Connected', + 'smartcarConnectFailed': 'Connection failed', + 'smartcarConnecting': 'Connecting…', + 'smartcarSearching': 'Searching for nearby car diffusers…', + 'smartcarNotFound': 'No device found', + 'smartcarScanHint': 'Make sure the device is powered on and close to your phone', + 'vipRequiredTitle': 'Membership required', + 'vipRequiredDesc': 'Translation and meeting notes require a membership. Renew, or pair a device that includes one.', + 'vipRequiredGo': 'Get membership', + 'vipFromDevice': 'from device', + 'computeSummary': 'Compute: device @device · account @user', + 'computePack': 'Compute packs', + 'vipExpiringSoon': 'Membership expires in @days day(s). Renew to keep using translation and meeting notes.', + 'computeLow': 'Running low on compute. You won\'t be able to continue once it runs out.', }; diff --git a/apps/client/lib/core/translations/language/tl_ph.dart b/apps/client/lib/core/translations/language/tl_ph.dart index 97759c0b..4f1d504b 100644 --- a/apps/client/lib/core/translations/language/tl_ph.dart +++ b/apps/client/lib/core/translations/language/tl_ph.dart @@ -1946,4 +1946,208 @@ const Map tlPH = { 'unmarkFavorite': 'Alisin sa mga paborito', 'unnamedDevice': 'Device na walang pangalan', 'userDataParseFailed': 'Nabigong basahin ang data ng user', + 'catEarphone': 'AI Earbuds', + 'catEarphoneDesc': 'Classic Bluetooth / BLE earbuds', + 'catPhoneHolder': 'AI Phone Stand', + 'catPhoneHolderDesc': 'Mga stand device', + 'catCarAroma': 'AI Car Diffuser', + 'catCarAromaDesc': 'Pang-kotseng aroma diffuser', + 'agreeAndContinue': 'Sumang-ayon at Magpatuloy', + 'memberServiceAgreement': 'Kasunduan sa Serbisyo ng Membership', + 'memberServiceAgreementTitle': 'Kasunduan sa Serbisyo ng Membership', + 'memberServiceAgreementIntro': 'Para mabigyan ka ng mas mahusay na serbisyo ng membership, pakibasa nang mabuti bago mag-subscribe: ', + 'memberServiceAgreementOutro': '. Sa pag-tap sa "Sumang-ayon at Magpatuloy", kinukumpirma mong nabasa mo nang buo at tinatanggap ang kasunduan sa itaas.', + 'memberAgreementRejectedTip': 'Kailangan mong sumang-ayon sa Kasunduan sa Serbisyo ng Membership para magpatuloy.', + 'translationCallExitTitle': 'Umalis sa Pagsasalin ng Tawag', + 'translationCallExitMessage': 'Piliin ang gagawin bago umalis', + 'translationCallEnd': 'Tapusin ang pagsasalin ng tawag', + 'translationContinueInBackground': 'Ipagpatuloy ang pagsasalin sa background', + 'backgroundRecordingIndicatorBody': 'Nagpapatuloy ang pag-record sa background. I-tap para bumalik.', + 'backgroundTranslationIndicatorBody': 'Nagpapatuloy ang pagsasalin ng tawag sa background. I-tap para bumalik.', + 'meetingRecordScreenCapturePermissionDenied': 'Kailangan ng pahintulot sa screen recording para ma-record ang audio ng system. Pakibigay ito at subukang muli.', + 'pleaseGrantBluetoothPermission': 'Pakibigay ang pahintulot sa Bluetooth para mag-scan', + 'startScanFailed': 'Nabigong simulan ang pag-scan', + 'pleaseLoginFirst': 'Mag-login muna', + 'deviceUnauthorized': 'Hindi awtorisado ang device, pakisubukang muli', + 'boundDevices': 'Mga Naka-bind na Device', + 'currentConnection': 'Kasalukuyang Koneksyon', + 'binding': 'Nagbi-bind...', + 'permissionDisabled': 'Naka-off', + 'confirmDisablePermissionTitle': 'I-off ang pahintulot', + 'confirmDisablePermissionContent': 'I-off ang pahintulot sa {permission}? Kailangan mong pumunta sa mga setting ng system para i-enable itong muli.', + 'confirmPermanentlyDisallowTitle': 'Huwag nang magtanong?', + 'confirmPermanentlyDisallowContent': 'Tinanggihan mo ang pahintulot sa {permission}. Huwag nang magtanong mula ngayon? Kung pipiliin mo ang "Huwag payagan", maaari mo pa rin itong i-enable sa mga setting ng system.', + 'notNow': 'Hindi ngayon', + 'permanentlyDisallow': 'Huwag payagan', + 'otaBesNotSupported': 'Hindi pa sinusuportahan ng headset na ito ang in-app na pag-upgrade ng firmware. Makipag-ugnayan sa support.', + 'tabDevice': 'Mga Device', + 'tabPlaza': 'Plaza', + 'sloganHome': 'Teknolohiya para sa mas matalinong buhay', + 'sloganAi': 'Walang hangganang talino', + 'sloganDevice': 'Matalinong hardware, handa sa hinaharap', + 'sloganPlaza': 'Matalinong mundong nakakaintindi sa iyo', + 'aiEditHint': 'Pindutin nang matagal ang card para i-drag at ayusin ang mga module', + 'aiEmptyTitle': 'Wala pang naidadagdag na module', + 'aiEmptyDesc': 'Piliin ang mga agent na kailangan mo sa Plaza', + 'goToPlaza': 'Pumunta sa Plaza', + 'plazaOnline': 'Available', + 'plazaPlanned': 'Malapit na', + 'plazaSearchHint': 'Maghanap ng agent, hal. “isalin”, “i-record”', + 'moduleRemove': 'Alisin', + 'moduleAdd': 'Idagdag', + 'moduleRemoved': 'Inalis', + 'moduleAdded': 'Naidagdag', + 'goSee': 'Tingnan', + 'comingSoonTag': 'Malapit na', + 'needDeviceTag': 'Kailangan ng device', + 'featureInDev': 'Ginagawa pa ang feature na ito', + 'featureNotWired': 'Hindi pa available', + 'todayLabel': 'Ngayon', + 'deviceConnected': 'Konektado', + 'licenseUnknown': 'Hindi alam ang lisensya', + 'selectDevice': 'Pumili ng device', + 'catNoteCard': 'AI Recorder Card', + 'catNoteCardDesc': 'Recorder na hugis card', + 'catParkerPen': 'AI Pen', + 'catParkerPenDesc': 'Mga voice recorder pen', + 'catStylusPen': 'AI Stylus', + 'catStylusPenDesc': 'Mga device para sa sulat-kamay / stylus', + 'catStudyMachine': 'AI Learning Pad', + 'catStudyMachineDesc': 'Mga device para sa pag-aaral', + 'besAddTitle': 'Magdagdag ng AI Earbuds', + 'besConnecting': 'Kumokonekta sa earbuds…', + 'besNotFound': 'Hindi natagpuan ang earbuds', + 'besNotFoundIosDesc': 'Ikonekta muna ang earbuds sa Settings → Bluetooth, pagkatapos ay bumalik dito', + 'besNotFoundAndroidDesc': 'I-pair muna ang earbuds sa Settings → Bluetooth, pagkatapos ay kumonekta dito', + 'besReadFailed': 'Nabigo ang pagbasa', + 'besConnectFailIos': 'Siguraduhing nakalagay na Connected ang earbuds sa Bluetooth ng system, pagkatapos ay subukang muli', + 'besConnectFailAndroid': 'Siguraduhing naka-on ang earbuds, malapit, at hindi konektado sa ibang device. Awtomatiko itong muling kokonekta kapag na-link na.', + 'holderAddTitle': 'Magdagdag ng AI Phone Stand', + 'holderConnecting': 'Kumokonekta sa device…', + 'holderSearching': 'Naghahanap ng mga device…', + 'holderNotFound': 'Walang natagpuang device', + 'holderPairHint': 'Siguraduhing naka-on ang stand at nasa pairing mode', + 'holderRetryHint': 'Siguraduhing naka-on at malapit ang device, pagkatapos ay subukang muli', + 'autoConnectTitle': 'Auto-connect ng mga device', + 'autoConnectDesc': 'Gisingin ang app para muling kumonekta at tumanggap ng mga recording kapag naka-on o malapit ang iyong device', + 'autoConnectConsentTitle': 'I-enable ang auto-connect', + 'autoConnectConsentIntro': 'Para awtomatikong kumonekta ang iyong device (AI Earbuds / AI Phone Stand) at ma-save ang mga recording kapag pinindot mo ang record button nito, kailangan namin ang iyong pahintulot:', + 'autoConnectConsentB1': 'Magrehistro ng background Bluetooth scan para magising (awtomatikong masimulan) ang app kapag na-detect ang iyong naka-bind na device, para muling kumonekta at tumanggap ng mga recording', + 'autoConnectConsentB2': 'Kapag nagising, gagamitin lang ito para sa koneksyon ng device, pag-record at pag-upload — wala nang iba, at walang ibang app na bubuksan', + 'autoConnectConsentB3': 'Magagamit mo pa rin ang app kahit wala ito — ikonekta na lang nang manu-mano ang device sa bawat pagkakataon', + 'autoConnectConsentB4': 'Maaari mo itong i-off anumang oras sa Settings → Auto-connect ng mga device', + 'autoConnectAgree': 'Sumang-ayon at i-enable', + 'autoConnectLater': 'Hindi ngayon', + 'autoConnectRequired': 'Naka-off ang auto-connect', + 'autoConnectRequiredDesc': 'I-enable ang auto-connect para i-bind ang device na ito', + 'recordTapToStart': 'I-tap ang button sa ibaba para simulan ang pag-record', + 'recordPaused': 'Naka-pause', + 'recordMark': 'Markahan', + 'recordDeleteTitle': 'Burahin ang recording', + 'accountAndProfile': 'Account at profile', + 'nicknameUnset': 'Walang nakatakdang nickname', + 'nicknameInput': 'Maglagay ng nickname', + 'loginMethod': 'Paraan ng pag-login', + 'manageMyLogin': 'Pamahalaan ang pag-login', + 'logoutAction': 'Mag-logout', + 'warningLabel': 'Babala:', + 'deleteAccountWarn': 'Permanenteng mabubura ang iyong account at lahat ng file.', + 'icpFiling': 'ICP filing', + 'logExport': 'I-export ang mga log', + 'logExportDesc': 'Tingnan at i-export ang mga log ng app', + 'permLoadFailed': 'Nabigong i-load ang mga grupo ng pahintulot', + 'permInsufficient': 'Tinanggihan ang pahintulot', + 'voiceCloneCreating': 'Ginagawa ang iyong boses…', + 'voiceCloneCreate': 'Gawin ang aking boses', + 'voiceCloneAgree': 'Sumang-ayon at basahin nang malakas ang pahayag', + 'voiceCloneReadPrompt': 'Pakibasa:', + 'voiceCloneFirstMonthFree': 'Libre ang unang buwan', + 'voiceCloneEnable': 'I-enable ang pag-clone ng boses', + 'noMusicPlaying': 'Walang tumutugtog', + 'qqMusicNoSeek': 'Hindi suportado ang pag-seek para sa QQ Music', + 'bleScanTitle': 'BLE scan', + 'bluetoothOn': 'Naka-on ang Bluetooth', + 'bleScanning': 'Nag-i-scan…', + 'bleConnectedDevices': 'Mga konektadong device', + 'bleUnknownDevice': 'Hindi kilalang device', + 'bleTapToScan': 'I-tap ang scan para maghanap ng mga BLE device', + 'weekMon': 'Lun', + 'weekTue': 'Mar', + 'weekWed': 'Miy', + 'weekThu': 'Huw', + 'weekFri': 'Biy', + 'weekSat': 'Sab', + 'weekSun': 'Lin', + 'agentMeeting': 'Voice Minutes', + 'agentMeetingDesc': 'Buod ng mga recording, memory hub', + 'agentEmai': 'EMAI Assistant', + 'agentEmaiDesc': 'Makipag-chat at magtanong ng kahit ano gamit ang boses', + 'agentSimultaneous': 'Live na Pagsasalin', + 'agentSimultaneousDesc': 'Isinasalin habang nagsasalita ka', + 'agentFaceToFace': 'Harapan', + 'agentFaceToFaceDesc': 'Two-way na pagsasalin ng usapan', + 'agentCallRecording': 'Pag-record ng Tawag', + 'agentCallRecordingDesc': 'Awtomatikong nase-save ang mga tawag', + 'agentFieldRecording': 'Field Recording', + 'agentFieldRecordingDesc': 'Mikropono ng device o telepono', + 'agentCall': 'Pagsasalin ng Tawag', + 'agentCallDesc': 'Real-time na pagsasalin habang tumatawag', + 'agentAudioVideo': 'Pagsasalin ng Media', + 'agentAudioVideoDesc': 'Camera framing / pagsasalin ng audio at video', + 'agentEnglishChat': 'English Chat', + 'agentEnglishChatDesc': 'Immersive na pagsasanay sa pagsasalita', + 'agentEnglishStudy': 'Pag-aaral ng English', + 'agentEnglishStudyDesc': 'Mga aralin sa bokabularyo at grammar', + 'agentKidsStory': 'Mga Kuwentong Pambata', + 'agentKidsStoryDesc': 'Mga kuwento bago matulog at Q&A', + 'moduleRemovedDesc': 'inalis sa AI page', + 'moduleAddedDesc': 'idinagdag sa ibaba ng AI page', + 'calNoRecords': 'Walang entry', + 'calNoRecordsDay': 'Walang nakaiskedyul', + 'calTodos': 'Mga gagawin', + 'calExpenses': 'Gastos', + 'calTodoCount': '@count gagawin', + 'calExpenseCount': '@count gastos', + 'calEntryCount': '@count entry', + 'calItemsUnit': 'item', + 'calCountUnit': 'entry', + 'calTotalAmount': 'Kabuuan', + 'recordPause': 'I-pause', + 'recordResume': 'Ipagpatuloy', + 'recordDeleteConfirm': 'Burahin ang recording na ito?', + 'scopeDay': 'Araw', + 'scopeWeek': 'Linggo', + 'scopeMonth': 'Buwan', + 'allDay': 'Buong araw', + 'guestModeTitle': 'Guest mode', + 'guestModeDesc': 'Ang mga guest ay maaari lamang mag-browse. Mag-login para gamitin ang voice notes, pagsasalin at pag-record — mase-save ang iyong data sa iyong account.', + 'guestKeepBrowsing': 'Magpatuloy sa pag-browse', + 'guestGoLogin': 'Mag-login', + 'guestViewOnlyTag': 'Tingnan lamang', + 'autoConnectReenableHint': 'Naka-off ang auto-connect. I-on itong muli sa Settings → Auto-connect ng mga device.', + 'plazaEntryDesc': 'Mag-browse at magdagdag ng higit pang agent', + 'agentTextTranslation': 'Pagsasalin ng Teksto', + 'agentTextTranslationDesc': 'Isalin ang na-type o na-paste na teksto', + 'agentPictureTranslation': 'Pagsasalin ng Larawan', + 'agentPictureTranslationDesc': 'Kumuha o pumili ng larawan para isalin', + 'callRequiredTitle': 'Kailangan ng tawag', + 'callRequiredDesc': 'Gumagana lang ang feature na ito habang may tawag', + 'inCallBusyTitle': 'Hindi available habang may tawag', + 'inCallBusyDesc': 'Ginagamit ng tawag ang mikropono at audio path. Subukang muli pagkatapos ibaba ang tawag.', + 'vipSectionTitle': 'VIP', + 'freeSectionTitle': 'Libre', + 'smartcarConnected': 'Connected', + 'smartcarConnectFailed': 'Connection failed', + 'smartcarConnecting': 'Connecting…', + 'smartcarSearching': 'Searching for nearby car diffusers…', + 'smartcarNotFound': 'No device found', + 'smartcarScanHint': 'Make sure the device is powered on and close to your phone', + 'vipRequiredTitle': 'Membership required', + 'vipRequiredDesc': 'Translation and meeting notes require a membership. Renew, or pair a device that includes one.', + 'vipRequiredGo': 'Get membership', + 'vipFromDevice': 'from device', + 'computeSummary': 'Compute: device @device · account @user', + 'computePack': 'Compute packs', + 'vipExpiringSoon': 'Membership expires in @days day(s). Renew to keep using translation and meeting notes.', + 'computeLow': 'Running low on compute. You won\'t be able to continue once it runs out.', }; diff --git a/apps/client/lib/core/translations/language/tr_tr.dart b/apps/client/lib/core/translations/language/tr_tr.dart index 294884b4..abea3bfd 100644 --- a/apps/client/lib/core/translations/language/tr_tr.dart +++ b/apps/client/lib/core/translations/language/tr_tr.dart @@ -1900,4 +1900,210 @@ const Map trTR = { 'unmarkFavorite': 'Favorilerden çıkar', 'unnamedDevice': 'Adsız cihaz', 'userDataParseFailed': 'Kullanıcı verileri işlenemedi', + 'catEarphone': 'AI Kulaklık', + 'catEarphoneDesc': 'Klasik Bluetooth / BLE kulaklık', + 'catPhoneHolder': 'AI Telefon Tutucu', + 'catPhoneHolderDesc': 'Tutucu cihazlar', + 'catCarAroma': 'AI Araç Kokusu', + 'catCarAromaDesc': 'Araç içi koku difüzörü', + 'agreeAndContinue': 'Kabul et ve devam et', + 'memberServiceAgreement': 'Üyelik Hizmet Sözleşmesi', + 'memberServiceAgreementTitle': 'Üyelik Hizmet Sözleşmesi', + 'memberServiceAgreementIntro': 'Size daha iyi üyelik hizmetleri sunabilmemiz için lütfen abone olmadan önce dikkatlice okuyun: ', + 'memberServiceAgreementOutro': '. "Kabul et ve devam et"e dokunarak yukarıdaki sözleşmeyi tamamen okuduğunuzu ve kabul ettiğinizi onaylarsınız.', + 'memberAgreementRejectedTip': 'Devam etmek için Üyelik Hizmet Sözleşmesi\'ni kabul etmelisiniz.', + 'translationCallExitTitle': 'Arama Çevirisinden Çık', + 'translationCallExitMessage': 'Ayrılmadan önce ne yapılacağını seçin', + 'translationCallEnd': 'Arama çevirisini bitir', + 'translationContinueInBackground': 'Arka planda çeviriye devam et', + 'backgroundRecordingIndicatorBody': 'Kayıt arka planda devam ediyor. Geri dönmek için dokunun.', + 'backgroundTranslationIndicatorBody': 'Arama çevirisi arka planda devam ediyor. Geri dönmek için dokunun.', + 'meetingRecordScreenCapturePermissionDenied': 'Sistem sesini kaydetmek için ekran kaydı izni gerekir. Lütfen izin verip tekrar deneyin.', + 'pleaseGrantBluetoothPermission': 'Tarama için lütfen Bluetooth izni verin', + 'startScanFailed': 'Tarama başlatılamadı', + 'pleaseLoginFirst': 'Lütfen önce giriş yapın', + 'deviceUnauthorized': 'Cihaz yetkili değil, lütfen tekrar deneyin', + 'boundDevices': 'Bağlı Cihazlar', + 'currentConnection': 'Mevcut Bağlantı', + 'binding': 'Bağlanıyor...', + 'permissionDisabled': 'Kapalı', + 'confirmDisablePermissionTitle': 'İzni kapat', + 'confirmDisablePermissionContent': '{permission} izni kapatılsın mı? Yeniden etkinleştirmek için sistem ayarlarına gitmeniz gerekecek.', + 'confirmPermanentlyDisallowTitle': 'Bir daha sorulmasın mı?', + 'confirmPermanentlyDisallowContent': '{permission} iznini reddettiniz. Bundan sonra sorulmasın mı? "İzin verme"yi seçerseniz daha sonra sistem ayarlarından yine etkinleştirebilirsiniz.', + 'notNow': 'Şimdi değil', + 'permanentlyDisallow': 'İzin verme', + 'otaUpgradeStarted': 'OTA güncellemesi başladı...', + 'otaUpgradeProgressWithPercent': 'Güncelleme ilerlemesi: @percent%', + 'otaBesNotSupported': 'Bu kulaklık henüz uygulama içi donanım yazılımı güncellemesini desteklemiyor. Lütfen destek ile iletişime geçin.', + 'tabDevice': 'Cihazlar', + 'tabPlaza': 'Plaza', + 'sloganHome': 'Daha akıllı bir yaşam için teknoloji', + 'sloganAi': 'Sınırsız zekâ', + 'sloganDevice': 'Akıllı donanım, geleceğe hazır', + 'sloganPlaza': 'Sizi anlayan akıllı bir dünya', + 'aiEditHint': 'Modülleri sürükleyip yeniden sıralamak için bir karta uzun basın', + 'aiEmptyTitle': 'Henüz modül eklenmedi', + 'aiEmptyDesc': 'İhtiyacınız olan ajanları Plaza\'dan seçin', + 'goToPlaza': 'Plaza\'ya git', + 'plazaOnline': 'Kullanılabilir', + 'plazaPlanned': 'Çok yakında', + 'plazaSearchHint': 'Ajan ara, örn. “çeviri”, “kayıt”', + 'moduleRemove': 'Kaldır', + 'moduleAdd': 'Ekle', + 'moduleRemoved': 'Kaldırıldı', + 'moduleAdded': 'Eklendi', + 'goSee': 'Görüntüle', + 'comingSoonTag': 'Çok yakında', + 'needDeviceTag': 'Cihaz gerekli', + 'featureInDev': 'Bu özellik geliştirme aşamasında', + 'featureNotWired': 'Henüz kullanılamıyor', + 'todayLabel': 'Bugün', + 'deviceConnected': 'Bağlandı', + 'licenseUnknown': 'Lisans bilinmiyor', + 'selectDevice': 'Bir cihaz seçin', + 'catNoteCard': 'AI Kayıt Kartı', + 'catNoteCardDesc': 'Kart şeklinde kayıt cihazı', + 'catParkerPen': 'AI Kalem', + 'catParkerPenDesc': 'Ses kayıt kalemleri', + 'catStylusPen': 'AI Stylus', + 'catStylusPenDesc': 'El yazısı / stylus cihazları', + 'catStudyMachine': 'AI Öğrenme Tableti', + 'catStudyMachineDesc': 'Öğrenme cihazları', + 'besAddTitle': 'AI Kulaklık Ekle', + 'besConnecting': 'Kulaklığa bağlanılıyor…', + 'besNotFound': 'Kulaklık bulunamadı', + 'besNotFoundIosDesc': 'Önce Ayarlar → Bluetooth\'tan kulaklığı bağlayın, sonra buraya dönün', + 'besNotFoundAndroidDesc': 'Önce Ayarlar → Bluetooth\'tan kulaklığı eşleştirin, sonra buradan bağlanın', + 'besReadFailed': 'Okuma başarısız', + 'besConnectFailIos': 'Kulaklığın sistem Bluetooth\'unda Bağlı olarak göründüğünden emin olup tekrar deneyin', + 'besConnectFailAndroid': 'Kulaklığın açık, yakında ve başka bir cihaza bağlı olmadığından emin olun. Bağlandıktan sonra otomatik olarak yeniden bağlanacaktır.', + 'holderAddTitle': 'AI Telefon Tutucu Ekle', + 'holderConnecting': 'Cihaza bağlanılıyor…', + 'holderSearching': 'Cihazlar aranıyor…', + 'holderNotFound': 'Cihaz bulunamadı', + 'holderPairHint': 'Tutucunun açık ve eşleştirme modunda olduğundan emin olun', + 'holderRetryHint': 'Cihazın açık ve yakında olduğundan emin olup tekrar deneyin', + 'autoConnectTitle': 'Cihazlara otomatik bağlan', + 'autoConnectDesc': 'Cihazınız açıldığında veya yaklaştığında yeniden bağlanmak ve kayıtları almak için uygulamayı uyandırır', + 'autoConnectConsentTitle': 'Otomatik bağlanmayı etkinleştir', + 'autoConnectConsentIntro': 'Cihazınızın (AI Kulaklık / AI Telefon Tutucu) otomatik bağlanabilmesi ve kayıt düğmesine bastığınızda kayıtların kaydedilebilmesi için onayınıza ihtiyacımız var:', + 'autoConnectConsentB1': 'Bağlı cihazınız algılandığında uygulamanın uyandırılması (otomatik başlatılması), yeniden bağlanması ve kayıtları alması için arka planda Bluetooth taraması kaydedilir', + 'autoConnectConsentB2': 'Uyandırıldıktan sonra yalnızca cihaz bağlantısı, kayıt ve yükleme için kullanılır — başka hiçbir şey için değil, başka uygulama da başlatılmaz', + 'autoConnectConsentB3': 'Bunu kullanmadan da uygulamayı kullanabilirsiniz — her seferinde cihazı elle bağlamanız yeterlidir', + 'autoConnectConsentB4': 'Ayarlar → Cihazlara otomatik bağlan bölümünden istediğiniz zaman kapatabilirsiniz', + 'autoConnectAgree': 'Kabul et ve etkinleştir', + 'autoConnectLater': 'Şimdi değil', + 'autoConnectRequired': 'Otomatik bağlanma kapalı', + 'autoConnectRequiredDesc': 'Bu cihazı bağlamak için otomatik bağlanmayı etkinleştirin', + 'recordTapToStart': 'Kaydı başlatmak için aşağıdaki düğmeye dokunun', + 'recordPaused': 'Duraklatıldı', + 'recordMark': 'İşaretle', + 'recordDeleteTitle': 'Kaydı sil', + 'accountAndProfile': 'Hesap ve profil', + 'nicknameUnset': 'Takma ad ayarlanmadı', + 'nicknameInput': 'Takma ad girin', + 'loginMethod': 'Giriş yöntemi', + 'manageMyLogin': 'Girişi yönet', + 'logoutAction': 'Çıkış yap', + 'warningLabel': 'Uyarı:', + 'deleteAccountWarn': 'Hesabınız ve tüm dosyalarınız kalıcı olarak silinecek.', + 'icpFiling': 'ICP kaydı', + 'logExport': 'Günlükleri dışa aktar', + 'logExportDesc': 'Uygulama günlüklerini görüntüleyin ve dışa aktarın', + 'permLoadFailed': 'İzin grupları yüklenemedi', + 'permInsufficient': 'İzin reddedildi', + 'voiceCloneCreating': 'Sesiniz oluşturuluyor…', + 'voiceCloneCreate': 'Sesimi oluştur', + 'voiceCloneAgree': 'Kabul et ve metni sesli oku', + 'voiceCloneReadPrompt': 'Lütfen okuyun:', + 'voiceCloneFirstMonthFree': 'İlk ay ücretsiz', + 'voiceCloneEnable': 'Ses klonlamayı etkinleştir', + 'noMusicPlaying': 'Hiçbir şey çalmıyor', + 'qqMusicNoSeek': 'QQ Music için ileri/geri sarma desteklenmiyor', + 'bleScanTitle': 'BLE taraması', + 'bluetoothOn': 'Bluetooth açık', + 'bleScanning': 'Taranıyor…', + 'bleConnectedDevices': 'Bağlı cihazlar', + 'bleUnknownDevice': 'Bilinmeyen cihaz', + 'bleTapToScan': 'BLE cihazlarını aramak için tara\'ya dokunun', + 'weekMon': 'Pzt', + 'weekTue': 'Sal', + 'weekWed': 'Çar', + 'weekThu': 'Per', + 'weekFri': 'Cum', + 'weekSat': 'Cmt', + 'weekSun': 'Paz', + 'agentMeeting': 'Sesli Tutanak', + 'agentMeetingDesc': 'Kayıtları özetleyin, hafıza merkezi', + 'agentEmai': 'EMAI Asistan', + 'agentEmaiDesc': 'Sesle sohbet edin ve her şeyi sorun', + 'agentSimultaneous': 'Canlı Çeviri', + 'agentSimultaneousDesc': 'Konuşurken çevirin', + 'agentFaceToFace': 'Yüz Yüze', + 'agentFaceToFaceDesc': 'İki yönlü konuşma çevirisi', + 'agentCallRecording': 'Arama Kaydı', + 'agentCallRecordingDesc': 'Aramalar otomatik kaydedilir', + 'agentFieldRecording': 'Saha Kaydı', + 'agentFieldRecordingDesc': 'Cihaz veya telefon mikrofonu', + 'agentCall': 'Arama Çevirisi', + 'agentCallDesc': 'Aramalar sırasında gerçek zamanlı çeviri', + 'agentAudioVideo': 'Medya Çevirisi', + 'agentAudioVideoDesc': 'Kamera çerçeveleme / ses ve video çevirisi', + 'agentEnglishChat': 'İngilizce Sohbet', + 'agentEnglishChatDesc': 'Sürükleyici konuşma pratiği', + 'agentEnglishStudy': 'İngilizce Çalışma', + 'agentEnglishStudyDesc': 'Kelime ve dil bilgisi dersleri', + 'agentKidsStory': 'Çocuk Hikâyeleri', + 'agentKidsStoryDesc': 'Uyku hikâyeleri ve soru-cevap', + 'moduleRemovedDesc': 'AI sayfasından kaldırıldı', + 'moduleAddedDesc': 'AI sayfasının altına eklendi', + 'calNoRecords': 'Kayıt yok', + 'calNoRecordsDay': 'Planlanmış bir şey yok', + 'calTodos': 'Yapılacaklar', + 'calExpenses': 'Harcamalar', + 'calTodoCount': '@count yapılacak', + 'calExpenseCount': '@count harcama', + 'calEntryCount': '@count kayıt', + 'calItemsUnit': 'öğe', + 'calCountUnit': 'kayıt', + 'calTotalAmount': 'Toplam', + 'recordPause': 'Duraklat', + 'recordResume': 'Devam et', + 'recordDeleteConfirm': 'Bu kayıt silinsin mi?', + 'scopeDay': 'Gün', + 'scopeWeek': 'Hafta', + 'scopeMonth': 'Ay', + 'allDay': 'Tüm gün', + 'guestModeTitle': 'Misafir modu', + 'guestModeDesc': 'Misafirler yalnızca göz atabilir. Sesli notlar, çeviri ve kayıt için giriş yapın — verileriniz hesabınıza kaydedilecek.', + 'guestKeepBrowsing': 'Gezinmeye devam et', + 'guestGoLogin': 'Giriş yap', + 'guestViewOnlyTag': 'Yalnızca görüntüleme', + 'autoConnectReenableHint': 'Otomatik bağlanma kapalı. Ayarlar → Cihazlara otomatik bağlan bölümünden yeniden açın.', + 'plazaEntryDesc': 'Daha fazla ajana göz atın ve ekleyin', + 'agentTextTranslation': 'Metin Çevirisi', + 'agentTextTranslationDesc': 'Yazılan veya yapıştırılan metni çevirin', + 'agentPictureTranslation': 'Fotoğraf Çevirisi', + 'agentPictureTranslationDesc': 'Çevirmek için fotoğraf çekin veya seçin', + 'callRequiredTitle': 'Arama gerekli', + 'callRequiredDesc': 'Bu özellik yalnızca arama sırasında çalışır', + 'inCallBusyTitle': 'Arama sırasında kullanılamaz', + 'inCallBusyDesc': 'Mikrofon ve ses yolu arama tarafından kullanılıyor. Aramayı kapattıktan sonra tekrar deneyin.', + 'vipSectionTitle': 'VIP', + 'freeSectionTitle': 'Ücretsiz', + 'smartcarConnected': 'Connected', + 'smartcarConnectFailed': 'Connection failed', + 'smartcarConnecting': 'Connecting…', + 'smartcarSearching': 'Searching for nearby car diffusers…', + 'smartcarNotFound': 'No device found', + 'smartcarScanHint': 'Make sure the device is powered on and close to your phone', + 'vipRequiredTitle': 'Membership required', + 'vipRequiredDesc': 'Translation and meeting notes require a membership. Renew, or pair a device that includes one.', + 'vipRequiredGo': 'Get membership', + 'vipFromDevice': 'from device', + 'computeSummary': 'Compute: device @device · account @user', + 'computePack': 'Compute packs', + 'vipExpiringSoon': 'Membership expires in @days day(s). Renew to keep using translation and meeting notes.', + 'computeLow': 'Running low on compute. You won\'t be able to continue once it runs out.', }; diff --git a/apps/client/lib/core/translations/language/uk_ua.dart b/apps/client/lib/core/translations/language/uk_ua.dart index 214759d2..43c7db08 100644 --- a/apps/client/lib/core/translations/language/uk_ua.dart +++ b/apps/client/lib/core/translations/language/uk_ua.dart @@ -1920,4 +1920,208 @@ const Map ukUA = { 'unmarkFavorite': 'Прибрати з обраного', 'unnamedDevice': 'Пристрій без назви', 'userDataParseFailed': 'Не вдалося обробити дані користувача', + 'catEarphone': 'ШІ-навушники', + 'catEarphoneDesc': 'Навушники Bluetooth Classic / BLE', + 'catPhoneHolder': 'ШІ-тримач телефона', + 'catPhoneHolderDesc': 'Пристрої-тримачі', + 'catCarAroma': 'ШІ-ароматизатор для авто', + 'catCarAromaDesc': 'Автомобільний ароматизатор', + 'agreeAndContinue': 'Погодитися й продовжити', + 'memberServiceAgreement': 'Угода про членські послуги', + 'memberServiceAgreementTitle': 'Угода про членські послуги', + 'memberServiceAgreementIntro': 'Щоб надавати вам кращі членські послуги, уважно прочитайте перед оформленням підписки: ', + 'memberServiceAgreementOutro': '. Натискаючи «Погодитися й продовжити», ви підтверджуєте, що повністю прочитали й приймаєте наведену вище угоду.', + 'memberAgreementRejectedTip': 'Щоб продовжити, потрібно погодитися з Угодою про членські послуги.', + 'translationCallExitTitle': 'Вийти з перекладу дзвінка', + 'translationCallExitMessage': 'Виберіть, що зробити перед виходом', + 'translationCallEnd': 'Завершити переклад дзвінка', + 'translationContinueInBackground': 'Продовжити переклад у фоні', + 'backgroundRecordingIndicatorBody': 'Запис триває у фоновому режимі. Торкніться, щоб повернутися.', + 'backgroundTranslationIndicatorBody': 'Переклад дзвінка триває у фоновому режимі. Торкніться, щоб повернутися.', + 'meetingRecordScreenCapturePermissionDenied': 'Для запису системного звуку потрібен дозвіл на запис екрана. Надайте його та повторіть спробу.', + 'pleaseGrantBluetoothPermission': 'Надайте дозвіл на Bluetooth для сканування', + 'startScanFailed': 'Не вдалося почати сканування', + 'pleaseLoginFirst': 'Спочатку увійдіть', + 'deviceUnauthorized': 'Пристрій не авторизовано, повторіть спробу', + 'boundDevices': 'Прив’язані пристрої', + 'currentConnection': 'Поточне підключення', + 'binding': 'Прив’язування...', + 'permissionDisabled': 'Вимкнено', + 'confirmDisablePermissionTitle': 'Вимкнути дозвіл', + 'confirmDisablePermissionContent': 'Вимкнути дозвіл {permission}? Щоб увімкнути його знову, потрібно буде перейти в системні налаштування.', + 'confirmPermanentlyDisallowTitle': 'Більше не запитувати?', + 'confirmPermanentlyDisallowContent': 'Ви відхилили дозвіл {permission}. Більше не запитувати? Якщо вибрати «Не дозволяти», його все одно можна буде ввімкнути пізніше в системних налаштуваннях.', + 'notNow': 'Не зараз', + 'permanentlyDisallow': 'Не дозволяти', + 'otaBesNotSupported': 'Ці навушники поки не підтримують оновлення прошивки із застосунку. Зверніться до служби підтримки.', + 'tabDevice': 'Пристрої', + 'tabPlaza': 'Площа', + 'sloganHome': 'Технології для розумнішого життя', + 'sloganAi': 'Безмежний інтелект', + 'sloganDevice': 'Розумне обладнання, готове до майбутнього', + 'sloganPlaza': 'Розумний світ, який тебе розуміє', + 'aiEditHint': 'Утримуйте картку, щоб перетягнути й упорядкувати модулі', + 'aiEmptyTitle': 'Модулів ще не додано', + 'aiEmptyDesc': 'Оберіть потрібних агентів на Площі', + 'goToPlaza': 'Перейти на Площу', + 'plazaOnline': 'Доступно', + 'plazaPlanned': 'Незабаром', + 'plazaSearchHint': 'Пошук агентів, напр. «переклад», «запис»', + 'moduleRemove': 'Видалити', + 'moduleAdd': 'Додати', + 'moduleRemoved': 'Видалено', + 'moduleAdded': 'Додано', + 'goSee': 'Переглянути', + 'comingSoonTag': 'Незабаром', + 'needDeviceTag': 'Потрібен пристрій', + 'featureInDev': 'Ця функція в розробці', + 'featureNotWired': 'Поки що недоступно', + 'todayLabel': 'Сьогодні', + 'deviceConnected': 'Підключено', + 'licenseUnknown': 'Ліцензія невідома', + 'selectDevice': 'Виберіть пристрій', + 'catNoteCard': 'ШІ-картка-диктофон', + 'catNoteCardDesc': 'Диктофон у формі картки', + 'catParkerPen': 'ШІ-ручка', + 'catParkerPenDesc': 'Ручки з диктофоном', + 'catStylusPen': 'ШІ-стилус', + 'catStylusPenDesc': 'Пристрої для рукопису / стилуси', + 'catStudyMachine': 'ШІ-планшет для навчання', + 'catStudyMachineDesc': 'Навчальні пристрої', + 'besAddTitle': 'Додати ШІ-навушники', + 'besConnecting': 'Підключення до навушників…', + 'besNotFound': 'Навушники не знайдено', + 'besNotFoundIosDesc': 'Спочатку підключіть навушники в Налаштування → Bluetooth, а потім поверніться сюди', + 'besNotFoundAndroidDesc': 'Спочатку створіть пару з навушниками в Налаштування → Bluetooth, а потім підключіть їх тут', + 'besReadFailed': 'Помилка читання', + 'besConnectFailIos': 'Переконайтеся, що навушники позначено як «Підключено» в системному Bluetooth, і повторіть спробу', + 'besConnectFailAndroid': 'Переконайтеся, що навушники ввімкнено, вони поруч і не підключені до іншого пристрою. Після з’єднання вони підключатимуться автоматично.', + 'holderAddTitle': 'Додати ШІ-тримач телефона', + 'holderConnecting': 'Підключення до пристрою…', + 'holderSearching': 'Пошук пристроїв…', + 'holderNotFound': 'Пристроїв не знайдено', + 'holderPairHint': 'Переконайтеся, що тримач увімкнено й він у режимі створення пари', + 'holderRetryHint': 'Переконайтеся, що пристрій увімкнено й він поруч, і повторіть спробу', + 'autoConnectTitle': 'Автопідключення пристроїв', + 'autoConnectDesc': 'Пробуджує застосунок для повторного підключення та отримання записів, коли пристрій вмикається або опиняється поруч', + 'autoConnectConsentTitle': 'Увімкнути автопідключення', + 'autoConnectConsentIntro': 'Щоб ваш пристрій (ШІ-навушники / ШІ-тримач телефона) підключався автоматично та зберігав записи після натискання кнопки запису, нам потрібна ваша згода:', + 'autoConnectConsentB1': 'Реєстрація фонового сканування Bluetooth, щоб застосунок пробуджувався (запускався автоматично) при виявленні прив’язаного пристрою для повторного підключення та отримання записів', + 'autoConnectConsentB2': 'Після пробудження використовується лише для підключення пристрою, запису та завантаження — нічого іншого, інші застосунки не запускаються', + 'autoConnectConsentB3': 'Застосунком можна користуватися й без цього — просто підключайте пристрій вручну щоразу', + 'autoConnectConsentB4': 'Це можна будь-коли вимкнути в Налаштування → Автопідключення пристроїв', + 'autoConnectAgree': 'Погодитися й увімкнути', + 'autoConnectLater': 'Не зараз', + 'autoConnectRequired': 'Автопідключення вимкнено', + 'autoConnectRequiredDesc': 'Увімкніть автопідключення, щоб прив’язати цей пристрій', + 'recordTapToStart': 'Натисніть кнопку нижче, щоб почати запис', + 'recordPaused': 'Призупинено', + 'recordMark': 'Позначка', + 'recordDeleteTitle': 'Видалити запис', + 'accountAndProfile': 'Обліковий запис і профіль', + 'nicknameUnset': 'Псевдонім не задано', + 'nicknameInput': 'Введіть псевдонім', + 'loginMethod': 'Спосіб входу', + 'manageMyLogin': 'Керувати входом', + 'logoutAction': 'Вийти', + 'warningLabel': 'Увага:', + 'deleteAccountWarn': 'Ваш обліковий запис і всі файли буде видалено назавжди.', + 'icpFiling': 'Реєстрація ICP', + 'logExport': 'Експорт журналів', + 'logExportDesc': 'Перегляд і експорт журналів застосунку', + 'permLoadFailed': 'Не вдалося завантажити групи дозволів', + 'permInsufficient': 'Немає дозволу', + 'voiceCloneCreating': 'Створення вашого голосу…', + 'voiceCloneCreate': 'Створити мій голос', + 'voiceCloneAgree': 'Погодитися й прочитати заяву вголос', + 'voiceCloneReadPrompt': 'Прочитайте:', + 'voiceCloneFirstMonthFree': 'Перший місяць безкоштовно', + 'voiceCloneEnable': 'Увімкнути клонування голосу', + 'noMusicPlaying': 'Нічого не відтворюється', + 'qqMusicNoSeek': 'Перемотування не підтримується для QQ Music', + 'bleScanTitle': 'Сканування BLE', + 'bluetoothOn': 'Bluetooth увімкнено', + 'bleScanning': 'Сканування…', + 'bleConnectedDevices': 'Підключені пристрої', + 'bleUnknownDevice': 'Невідомий пристрій', + 'bleTapToScan': 'Натисніть «Сканувати», щоб знайти BLE-пристрої', + 'weekMon': 'Пн', + 'weekTue': 'Вт', + 'weekWed': 'Ср', + 'weekThu': 'Чт', + 'weekFri': 'Пт', + 'weekSat': 'Сб', + 'weekSun': 'Нд', + 'agentMeeting': 'Голосові нотатки', + 'agentMeetingDesc': 'Підсумки записів, центр пам’яті', + 'agentEmai': 'Асистент EMAI', + 'agentEmaiDesc': 'Спілкуйтеся й запитуйте будь-що голосом', + 'agentSimultaneous': 'Синхронний переклад', + 'agentSimultaneousDesc': 'Переклад під час мовлення', + 'agentFaceToFace': 'Віч-на-віч', + 'agentFaceToFaceDesc': 'Двосторонній переклад розмови', + 'agentCallRecording': 'Запис дзвінка', + 'agentCallRecordingDesc': 'Дзвінки зберігаються автоматично', + 'agentFieldRecording': 'Запис на місці', + 'agentFieldRecordingDesc': 'Мікрофон пристрою або телефона', + 'agentCall': 'Переклад дзвінків', + 'agentCallDesc': 'Переклад у реальному часі під час дзвінка', + 'agentAudioVideo': 'Переклад медіа', + 'agentAudioVideoDesc': 'Кадрування камерою / переклад аудіо та відео', + 'agentEnglishChat': 'Розмова англійською', + 'agentEnglishChatDesc': 'Занурення в розмовну практику', + 'agentEnglishStudy': 'Вивчення англійської', + 'agentEnglishStudyDesc': 'Уроки лексики та граматики', + 'agentKidsStory': 'Дитячі казки', + 'agentKidsStoryDesc': 'Казки на ніч і відповіді на запитання', + 'moduleRemovedDesc': 'видалено зі сторінки ШІ', + 'moduleAddedDesc': 'додано в кінець сторінки ШІ', + 'calNoRecords': 'Немає записів', + 'calNoRecordsDay': 'Нічого не заплановано', + 'calTodos': 'Завдання', + 'calExpenses': 'Витрати', + 'calTodoCount': 'Завдань: @count', + 'calExpenseCount': 'Витрат: @count', + 'calEntryCount': 'Записів: @count', + 'calItemsUnit': 'елементів', + 'calCountUnit': 'записів', + 'calTotalAmount': 'Разом', + 'recordPause': 'Пауза', + 'recordResume': 'Продовжити', + 'recordDeleteConfirm': 'Видалити цей запис?', + 'scopeDay': 'День', + 'scopeWeek': 'Тиждень', + 'scopeMonth': 'Місяць', + 'allDay': 'Увесь день', + 'guestModeTitle': 'Гостьовий режим', + 'guestModeDesc': 'Гості можуть лише переглядати. Увійдіть, щоб користуватися голосовими нотатками, перекладом і записом — ваші дані зберігатимуться в обліковому записі.', + 'guestKeepBrowsing': 'Продовжити перегляд', + 'guestGoLogin': 'Увійти', + 'guestViewOnlyTag': 'Лише перегляд', + 'autoConnectReenableHint': 'Автопідключення вимкнено. Увімкніть його знову в Налаштування → Автопідключення пристроїв.', + 'plazaEntryDesc': 'Переглядайте й додавайте більше агентів', + 'agentTextTranslation': 'Текстовий переклад', + 'agentTextTranslationDesc': 'Переклад введеного або вставленого тексту', + 'agentPictureTranslation': 'Переклад фото', + 'agentPictureTranslationDesc': 'Зробіть або виберіть фото для перекладу', + 'callRequiredTitle': 'Потрібен дзвінок', + 'callRequiredDesc': 'Ця функція працює лише під час дзвінка', + 'inCallBusyTitle': 'Недоступно під час дзвінка', + 'inCallBusyDesc': 'Мікрофон і аудіотракт зайняті дзвінком. Спробуйте ще раз після завершення дзвінка.', + 'vipSectionTitle': 'VIP', + 'freeSectionTitle': 'Безкоштовно', + 'smartcarConnected': 'Connected', + 'smartcarConnectFailed': 'Connection failed', + 'smartcarConnecting': 'Connecting…', + 'smartcarSearching': 'Searching for nearby car diffusers…', + 'smartcarNotFound': 'No device found', + 'smartcarScanHint': 'Make sure the device is powered on and close to your phone', + 'vipRequiredTitle': 'Membership required', + 'vipRequiredDesc': 'Translation and meeting notes require a membership. Renew, or pair a device that includes one.', + 'vipRequiredGo': 'Get membership', + 'vipFromDevice': 'from device', + 'computeSummary': 'Compute: device @device · account @user', + 'computePack': 'Compute packs', + 'vipExpiringSoon': 'Membership expires in @days day(s). Renew to keep using translation and meeting notes.', + 'computeLow': 'Running low on compute. You won\'t be able to continue once it runs out.', }; diff --git a/apps/client/lib/core/translations/language/vi_vn.dart b/apps/client/lib/core/translations/language/vi_vn.dart index b4963ad8..529ea6f0 100644 --- a/apps/client/lib/core/translations/language/vi_vn.dart +++ b/apps/client/lib/core/translations/language/vi_vn.dart @@ -1880,4 +1880,208 @@ const Map viVN = { 'unmarkFavorite': 'Bỏ khỏi yêu thích', 'unnamedDevice': 'Thiết bị chưa đặt tên', 'userDataParseFailed': 'Không đọc được dữ liệu người dùng', + 'catEarphone': 'Tai nghe AI', + 'catEarphoneDesc': 'Tai nghe Bluetooth cổ điển / BLE', + 'catPhoneHolder': 'Giá đỡ điện thoại AI', + 'catPhoneHolderDesc': 'Thiết bị giá đỡ', + 'catCarAroma': 'Máy khuếch tán hương ô tô AI', + 'catCarAromaDesc': 'Máy khuếch tán hương trên xe', + 'agreeAndContinue': 'Đồng ý và tiếp tục', + 'memberServiceAgreement': 'Thỏa thuận dịch vụ hội viên', + 'memberServiceAgreementTitle': 'Thỏa thuận dịch vụ hội viên', + 'memberServiceAgreementIntro': 'Để cung cấp dịch vụ hội viên tốt hơn, vui lòng đọc kỹ trước khi đăng ký: ', + 'memberServiceAgreementOutro': '. Khi nhấn "Đồng ý và tiếp tục", bạn xác nhận đã đọc đầy đủ và chấp nhận thỏa thuận trên.', + 'memberAgreementRejectedTip': 'Bạn phải đồng ý Thỏa thuận dịch vụ hội viên để tiếp tục.', + 'translationCallExitTitle': 'Thoát dịch cuộc gọi', + 'translationCallExitMessage': 'Chọn thao tác trước khi rời đi', + 'translationCallEnd': 'Kết thúc dịch cuộc gọi', + 'translationContinueInBackground': 'Tiếp tục dịch trong nền', + 'backgroundRecordingIndicatorBody': 'Đang tiếp tục ghi âm trong nền. Nhấn để quay lại.', + 'backgroundTranslationIndicatorBody': 'Đang tiếp tục dịch cuộc gọi trong nền. Nhấn để quay lại.', + 'meetingRecordScreenCapturePermissionDenied': 'Cần quyền ghi màn hình để ghi âm thanh hệ thống. Vui lòng cấp quyền rồi thử lại.', + 'pleaseGrantBluetoothPermission': 'Vui lòng cấp quyền Bluetooth để quét', + 'startScanFailed': 'Không thể bắt đầu quét', + 'pleaseLoginFirst': 'Vui lòng đăng nhập trước', + 'deviceUnauthorized': 'Thiết bị chưa được cấp phép, vui lòng thử lại', + 'boundDevices': 'Thiết bị đã liên kết', + 'currentConnection': 'Kết nối hiện tại', + 'binding': 'Đang liên kết...', + 'permissionDisabled': 'Đã tắt', + 'confirmDisablePermissionTitle': 'Tắt quyền', + 'confirmDisablePermissionContent': 'Tắt quyền {permission}? Bạn sẽ cần vào cài đặt hệ thống để bật lại.', + 'confirmPermanentlyDisallowTitle': 'Không hỏi lại?', + 'confirmPermanentlyDisallowContent': 'Bạn đã từ chối quyền {permission}. Không hỏi lại từ giờ? Nếu chọn "Không cho phép", bạn vẫn có thể bật lại sau trong cài đặt hệ thống.', + 'notNow': 'Để sau', + 'permanentlyDisallow': 'Không cho phép', + 'otaBesNotSupported': 'Tai nghe này chưa hỗ trợ nâng cấp firmware trong ứng dụng. Vui lòng liên hệ bộ phận hỗ trợ.', + 'tabDevice': 'Thiết bị', + 'tabPlaza': 'Plaza', + 'sloganHome': 'Công nghệ cho cuộc sống thông minh hơn', + 'sloganAi': 'Trí tuệ không giới hạn', + 'sloganDevice': 'Phần cứng thông minh, sẵn sàng cho tương lai', + 'sloganPlaza': 'Thế giới thông minh thấu hiểu bạn', + 'aiEditHint': 'Nhấn giữ thẻ để kéo và sắp xếp lại mô-đun', + 'aiEmptyTitle': 'Chưa thêm mô-đun nào', + 'aiEmptyDesc': 'Chọn tác nhân bạn cần trong Plaza', + 'goToPlaza': 'Đến Plaza', + 'plazaOnline': 'Sẵn sàng', + 'plazaPlanned': 'Sắp ra mắt', + 'plazaSearchHint': 'Tìm tác nhân, ví dụ “dịch”, “ghi âm”', + 'moduleRemove': 'Gỡ', + 'moduleAdd': 'Thêm', + 'moduleRemoved': 'Đã gỡ', + 'moduleAdded': 'Đã thêm', + 'goSee': 'Xem', + 'comingSoonTag': 'Sắp ra mắt', + 'needDeviceTag': 'Cần thiết bị', + 'featureInDev': 'Tính năng này đang được phát triển', + 'featureNotWired': 'Chưa khả dụng', + 'todayLabel': 'Hôm nay', + 'deviceConnected': 'Đã kết nối', + 'licenseUnknown': 'Không rõ trạng thái cấp phép', + 'selectDevice': 'Chọn thiết bị', + 'catNoteCard': 'Thẻ ghi âm AI', + 'catNoteCardDesc': 'Máy ghi âm dạng thẻ', + 'catParkerPen': 'Bút AI', + 'catParkerPenDesc': 'Bút ghi âm', + 'catStylusPen': 'Bút cảm ứng AI', + 'catStylusPenDesc': 'Thiết bị viết tay / bút cảm ứng', + 'catStudyMachine': 'Máy học tập AI', + 'catStudyMachineDesc': 'Thiết bị học tập', + 'besAddTitle': 'Thêm tai nghe AI', + 'besConnecting': 'Đang kết nối tai nghe…', + 'besNotFound': 'Không tìm thấy tai nghe', + 'besNotFoundIosDesc': 'Hãy kết nối tai nghe trong Cài đặt → Bluetooth trước, rồi quay lại đây', + 'besNotFoundAndroidDesc': 'Hãy ghép nối tai nghe trong Cài đặt → Bluetooth trước, rồi kết nối tại đây', + 'besReadFailed': 'Đọc dữ liệu thất bại', + 'besConnectFailIos': 'Hãy chắc chắn tai nghe hiển thị "Đã kết nối" trong Bluetooth hệ thống, rồi thử lại', + 'besConnectFailAndroid': 'Hãy chắc chắn tai nghe đã bật, ở gần và không kết nối với thiết bị khác. Sau khi liên kết, tai nghe sẽ tự động kết nối lại.', + 'holderAddTitle': 'Thêm giá đỡ điện thoại AI', + 'holderConnecting': 'Đang kết nối thiết bị…', + 'holderSearching': 'Đang tìm thiết bị…', + 'holderNotFound': 'Không tìm thấy thiết bị', + 'holderPairHint': 'Hãy chắc chắn giá đỡ đã bật và đang ở chế độ ghép nối', + 'holderRetryHint': 'Hãy chắc chắn thiết bị đã bật và ở gần, rồi thử lại', + 'autoConnectTitle': 'Tự động kết nối thiết bị', + 'autoConnectDesc': 'Đánh thức ứng dụng để kết nối lại và nhận bản ghi âm khi thiết bị bật nguồn hoặc ở gần', + 'autoConnectConsentTitle': 'Bật tự động kết nối', + 'autoConnectConsentIntro': 'Để thiết bị của bạn (tai nghe AI / giá đỡ điện thoại AI) tự động kết nối và lưu bản ghi âm khi bạn nhấn nút ghi âm trên thiết bị, chúng tôi cần sự đồng ý của bạn:', + 'autoConnectConsentB1': 'Đăng ký quét Bluetooth trong nền để ứng dụng được đánh thức (tự khởi động) khi phát hiện thiết bị đã liên kết, nhằm kết nối lại và nhận bản ghi âm', + 'autoConnectConsentB2': 'Sau khi được đánh thức, chỉ dùng để kết nối thiết bị, ghi âm và tải lên — không làm gì khác và không mở ứng dụng nào khác', + 'autoConnectConsentB3': 'Bạn vẫn có thể dùng ứng dụng mà không bật tính năng này — chỉ cần kết nối thiết bị thủ công mỗi lần', + 'autoConnectConsentB4': 'Bạn có thể tắt bất cứ lúc nào trong Cài đặt → Tự động kết nối thiết bị', + 'autoConnectAgree': 'Đồng ý và bật', + 'autoConnectLater': 'Để sau', + 'autoConnectRequired': 'Tự động kết nối đang tắt', + 'autoConnectRequiredDesc': 'Bật tự động kết nối để liên kết thiết bị này', + 'recordTapToStart': 'Nhấn nút bên dưới để bắt đầu ghi âm', + 'recordPaused': 'Đã tạm dừng', + 'recordMark': 'Đánh dấu', + 'recordDeleteTitle': 'Xóa bản ghi', + 'accountAndProfile': 'Tài khoản và hồ sơ', + 'nicknameUnset': 'Chưa đặt biệt danh', + 'nicknameInput': 'Nhập biệt danh', + 'loginMethod': 'Phương thức đăng nhập', + 'manageMyLogin': 'Quản lý đăng nhập', + 'logoutAction': 'Đăng xuất', + 'warningLabel': 'Cảnh báo:', + 'deleteAccountWarn': 'Tài khoản và toàn bộ tệp của bạn sẽ bị xóa vĩnh viễn.', + 'icpFiling': 'Đăng ký ICP', + 'logExport': 'Xuất nhật ký', + 'logExportDesc': 'Xem và xuất nhật ký ứng dụng', + 'permLoadFailed': 'Không tải được nhóm quyền', + 'permInsufficient': 'Không đủ quyền', + 'voiceCloneCreating': 'Đang tạo giọng nói của bạn…', + 'voiceCloneCreate': 'Tạo giọng nói của tôi', + 'voiceCloneAgree': 'Đồng ý và đọc to đoạn tuyên bố', + 'voiceCloneReadPrompt': 'Vui lòng đọc:', + 'voiceCloneFirstMonthFree': 'Miễn phí tháng đầu', + 'voiceCloneEnable': 'Bật sao chép giọng nói', + 'noMusicPlaying': 'Không có nhạc đang phát', + 'qqMusicNoSeek': 'QQ Music không hỗ trợ tua nhạc', + 'bleScanTitle': 'Quét BLE', + 'bluetoothOn': 'Bluetooth đã bật', + 'bleScanning': 'Đang quét…', + 'bleConnectedDevices': 'Thiết bị đã kết nối', + 'bleUnknownDevice': 'Thiết bị không xác định', + 'bleTapToScan': 'Nhấn quét để tìm thiết bị BLE', + 'weekMon': 'T2', + 'weekTue': 'T3', + 'weekWed': 'T4', + 'weekThu': 'T5', + 'weekFri': 'T6', + 'weekSat': 'T7', + 'weekSun': 'CN', + 'agentMeeting': 'Biên bản giọng nói', + 'agentMeetingDesc': 'Tóm tắt bản ghi âm, trung tâm ký ức', + 'agentEmai': 'Trợ lý EMAI', + 'agentEmaiDesc': 'Trò chuyện và hỏi mọi thứ bằng giọng nói', + 'agentSimultaneous': 'Dịch trực tiếp', + 'agentSimultaneousDesc': 'Dịch ngay khi bạn nói', + 'agentFaceToFace': 'Mặt đối mặt', + 'agentFaceToFaceDesc': 'Dịch hội thoại hai chiều', + 'agentCallRecording': 'Ghi âm cuộc gọi', + 'agentCallRecordingDesc': 'Tự động lưu cuộc gọi', + 'agentFieldRecording': 'Ghi âm hiện trường', + 'agentFieldRecordingDesc': 'Qua thiết bị hoặc micro điện thoại', + 'agentCall': 'Dịch cuộc gọi', + 'agentCallDesc': 'Dịch thời gian thực trong cuộc gọi', + 'agentAudioVideo': 'Dịch đa phương tiện', + 'agentAudioVideoDesc': 'Khung hình camera / dịch âm thanh và video', + 'agentEnglishChat': 'Trò chuyện tiếng Anh', + 'agentEnglishChatDesc': 'Luyện nói theo phong cách nhập vai', + 'agentEnglishStudy': 'Học tiếng Anh', + 'agentEnglishStudyDesc': 'Bài học từ vựng và ngữ pháp', + 'agentKidsStory': 'Truyện thiếu nhi', + 'agentKidsStoryDesc': 'Truyện trước khi ngủ và hỏi đáp', + 'moduleRemovedDesc': 'đã gỡ khỏi trang AI', + 'moduleAddedDesc': 'đã thêm vào cuối trang AI', + 'calNoRecords': 'Không có mục nào', + 'calNoRecordsDay': 'Không có lịch trình', + 'calTodos': 'Việc cần làm', + 'calExpenses': 'Chi tiêu', + 'calTodoCount': '@count việc cần làm', + 'calExpenseCount': '@count khoản chi', + 'calEntryCount': '@count mục', + 'calItemsUnit': 'mục', + 'calCountUnit': 'mục', + 'calTotalAmount': 'Tổng', + 'recordPause': 'Tạm dừng', + 'recordResume': 'Tiếp tục', + 'recordDeleteConfirm': 'Xóa bản ghi này?', + 'scopeDay': 'Ngày', + 'scopeWeek': 'Tuần', + 'scopeMonth': 'Tháng', + 'allDay': 'Cả ngày', + 'guestModeTitle': 'Chế độ khách', + 'guestModeDesc': 'Khách chỉ có thể xem. Đăng nhập để dùng biên bản giọng nói, dịch và ghi âm — dữ liệu của bạn sẽ được lưu vào tài khoản.', + 'guestKeepBrowsing': 'Tiếp tục xem', + 'guestGoLogin': 'Đăng nhập', + 'guestViewOnlyTag': 'Chỉ xem', + 'autoConnectReenableHint': 'Tự động kết nối đang tắt. Bật lại trong Cài đặt → Tự động kết nối thiết bị.', + 'plazaEntryDesc': 'Khám phá và thêm tác nhân khác', + 'agentTextTranslation': 'Dịch văn bản', + 'agentTextTranslationDesc': 'Dịch văn bản đã nhập hoặc dán', + 'agentPictureTranslation': 'Dịch hình ảnh', + 'agentPictureTranslationDesc': 'Chụp hoặc chọn ảnh để dịch', + 'callRequiredTitle': 'Cần có cuộc gọi', + 'callRequiredDesc': 'Tính năng này chỉ hoạt động trong cuộc gọi', + 'inCallBusyTitle': 'Không khả dụng trong cuộc gọi', + 'inCallBusyDesc': 'Micro và đường âm thanh đang được cuộc gọi sử dụng. Hãy thử lại sau khi cúp máy.', + 'vipSectionTitle': 'VIP', + 'freeSectionTitle': 'Miễn phí', + 'smartcarConnected': 'Connected', + 'smartcarConnectFailed': 'Connection failed', + 'smartcarConnecting': 'Connecting…', + 'smartcarSearching': 'Searching for nearby car diffusers…', + 'smartcarNotFound': 'No device found', + 'smartcarScanHint': 'Make sure the device is powered on and close to your phone', + 'vipRequiredTitle': 'Membership required', + 'vipRequiredDesc': 'Translation and meeting notes require a membership. Renew, or pair a device that includes one.', + 'vipRequiredGo': 'Get membership', + 'vipFromDevice': 'from device', + 'computeSummary': 'Compute: device @device · account @user', + 'computePack': 'Compute packs', + 'vipExpiringSoon': 'Membership expires in @days day(s). Renew to keep using translation and meeting notes.', + 'computeLow': 'Running low on compute. You won\'t be able to continue once it runs out.', }; diff --git a/apps/client/lib/core/translations/language/zh_cn.dart b/apps/client/lib/core/translations/language/zh_cn.dart index 876b437a..605078bf 100644 --- a/apps/client/lib/core/translations/language/zh_cn.dart +++ b/apps/client/lib/core/translations/language/zh_cn.dart @@ -1685,6 +1685,8 @@ const Map zhCN = { 'catNoteCardDesc': '卡片式录音设备', 'catPhoneHolder': 'AI手机支架', 'catPhoneHolderDesc': '支架类设备', + 'catCarAroma': 'AI车载香薰', + 'catCarAromaDesc': '车载香薰设备', 'catParkerPen': 'AI派克笔', 'catParkerPenDesc': '录音笔类设备', 'catStylusPen': 'AI电容笔', @@ -1924,4 +1926,18 @@ const Map zhCN = { 'unmarkFavorite': '取消收藏', 'unnamedDevice': '未命名设备', 'userDataParseFailed': '用户数据解析失败', + 'smartcarConnected': '已连接', + 'smartcarConnectFailed': '连接失败', + 'smartcarConnecting': '正在连接…', + 'smartcarSearching': '正在搜索附近的车载香薰…', + 'smartcarNotFound': '没有找到设备', + 'smartcarScanHint': '请确认设备已通电,并靠近手机', + 'vipRequiredTitle': '需要会员', + 'vipRequiredDesc': '翻译与会议纪要需要会员。续费会员,或绑定带会员的设备即可使用。', + 'vipRequiredGo': '去开通', + 'vipFromDevice': '来自设备', + 'computeSummary': '算力:设备 @device · 账户 @user', + 'computePack': '算力包', + 'vipExpiringSoon': '会员还有 @days 天到期,续费后可继续使用翻译与会议纪要', + 'computeLow': '算力不多了,用完后将无法继续使用', }; diff --git a/apps/client/lib/core/translations/language/zh_hk.dart b/apps/client/lib/core/translations/language/zh_hk.dart index 96dceb85..24337960 100644 --- a/apps/client/lib/core/translations/language/zh_hk.dart +++ b/apps/client/lib/core/translations/language/zh_hk.dart @@ -1660,6 +1660,8 @@ const Map zhHK = { 'catNoteCardDesc': '卡片式錄音裝置', 'catPhoneHolder': 'AI 手機支架', 'catPhoneHolderDesc': '支架類裝置', + 'catCarAroma': 'AI 車載香薰', + 'catCarAromaDesc': '車載香薰裝置', 'catParkerPen': 'AI 派克筆', 'catParkerPenDesc': '錄音筆類裝置', 'catStylusPen': 'AI 觸控筆', @@ -1907,4 +1909,32 @@ const Map zhHK = { 'unmarkFavorite': '取消收藏', 'unnamedDevice': '未命名裝置', 'userDataParseFailed': '使用者資料解析失敗', + 'translationCallExitTitle': '退出通話翻譯', + 'translationCallExitMessage': '請選擇離開後的操作', + 'translationCallEnd': '結束通話翻譯', + 'translationContinueInBackground': '背景繼續翻譯', + 'backgroundRecordingIndicatorBody': '正在背景繼續錄音,點擊返回', + 'backgroundTranslationIndicatorBody': '正在背景繼續通話翻譯,點擊返回', + 'permissionDisabled': '已停用', + 'confirmDisablePermissionTitle': '關閉權限', + 'confirmDisablePermissionContent': '確定要永久關閉{permission}權限嗎?關閉後需要前往系統設定重新開啟。', + 'confirmPermanentlyDisallowTitle': '是否永久不允許', + 'confirmPermanentlyDisallowContent': '你拒絕了{permission}權限,是否以後都不再詢問?選擇「永久不允許」後,如需開啟請前往系統設定。', + 'notNow': '暫不', + 'permanentlyDisallow': '永久不允許', + 'otaBesNotSupported': '此耳機暫不支援在 App 內升級韌體,請聯絡客服獲取升級方式', + 'smartcarConnected': '已連接', + 'smartcarConnectFailed': '連接失敗', + 'smartcarConnecting': '正在連接…', + 'smartcarSearching': '正在搜尋附近的車載香薰…', + 'smartcarNotFound': '沒有找到裝置', + 'smartcarScanHint': '請確認裝置已通電,並靠近手機', + 'vipRequiredTitle': 'Membership required', + 'vipRequiredDesc': 'Translation and meeting notes require a membership. Renew, or pair a device that includes one.', + 'vipRequiredGo': 'Get membership', + 'vipFromDevice': 'from device', + 'computeSummary': 'Compute: device @device · account @user', + 'computePack': 'Compute packs', + 'vipExpiringSoon': 'Membership expires in @days day(s). Renew to keep using translation and meeting notes.', + 'computeLow': 'Running low on compute. You won\'t be able to continue once it runs out.', }; diff --git a/apps/client/lib/core/translations/language/zh_tw.dart b/apps/client/lib/core/translations/language/zh_tw.dart index 29cf04ec..29b4747b 100644 --- a/apps/client/lib/core/translations/language/zh_tw.dart +++ b/apps/client/lib/core/translations/language/zh_tw.dart @@ -1660,6 +1660,8 @@ const Map zhTW = { 'catNoteCardDesc': '卡片式錄音裝置', 'catPhoneHolder': 'AI 手機支架', 'catPhoneHolderDesc': '支架類裝置', + 'catCarAroma': 'AI 車載香氛', + 'catCarAromaDesc': '車載香氛裝置', 'catParkerPen': 'AI 派克筆', 'catParkerPenDesc': '錄音筆類裝置', 'catStylusPen': 'AI 觸控筆', @@ -1907,4 +1909,32 @@ const Map zhTW = { 'unmarkFavorite': '取消收藏', 'unnamedDevice': '未命名裝置', 'userDataParseFailed': '使用者資料解析失敗', + 'translationCallExitTitle': '退出通話翻譯', + 'translationCallExitMessage': '請選擇離開後的操作', + 'translationCallEnd': '結束通話翻譯', + 'translationContinueInBackground': '背景繼續翻譯', + 'backgroundRecordingIndicatorBody': '正在背景繼續錄音,點選返回', + 'backgroundTranslationIndicatorBody': '正在背景繼續通話翻譯,點選返回', + 'permissionDisabled': '已停用', + 'confirmDisablePermissionTitle': '關閉權限', + 'confirmDisablePermissionContent': '確定要永久關閉{permission}權限嗎?關閉後需要前往系統設定重新開啟。', + 'confirmPermanentlyDisallowTitle': '是否永久不允許', + 'confirmPermanentlyDisallowContent': '您拒絕了{permission}權限,是否以後都不再詢問?選擇「永久不允許」後,如需開啟請前往系統設定。', + 'notNow': '暫不', + 'permanentlyDisallow': '永久不允許', + 'otaBesNotSupported': '此耳機暫不支援在 App 內升級韌體,請聯絡客服取得升級方式', + 'smartcarConnected': '已連接', + 'smartcarConnectFailed': '連接失敗', + 'smartcarConnecting': '正在連接…', + 'smartcarSearching': '正在搜尋附近的車載香氛…', + 'smartcarNotFound': '沒有找到裝置', + 'smartcarScanHint': '請確認裝置已通電,並靠近手機', + 'vipRequiredTitle': 'Membership required', + 'vipRequiredDesc': 'Translation and meeting notes require a membership. Renew, or pair a device that includes one.', + 'vipRequiredGo': 'Get membership', + 'vipFromDevice': 'from device', + 'computeSummary': 'Compute: device @device · account @user', + 'computePack': 'Compute packs', + 'vipExpiringSoon': 'Membership expires in @days day(s). Renew to keep using translation and meeting notes.', + 'computeLow': 'Running low on compute. You won\'t be able to continue once it runs out.', }; diff --git a/apps/client/lib/core/utils/device_connection.dart b/apps/client/lib/core/utils/device_connection.dart index d0e1614f..fac511c9 100644 --- a/apps/client/lib/core/utils/device_connection.dart +++ b/apps/client/lib/core/utils/device_connection.dart @@ -1,54 +1,33 @@ -import 'package:echomeet_device_sdk/echomeet_device_sdk.dart'; -import 'package:get/get.dart'; +import 'package:device_plugin_interface/device_plugin_interface.dart'; import '../../data/models/device_images.dart'; import '../../data/models/user_Info.dart'; -import '../../data/services/bes_bluetooth_service.dart'; -import '../../data/services/holder_device_service.dart'; -import '../../modules/home/controllers/home_controller.dart'; +import '../../devices/device_hub.dart'; -/// 「当前有没有连着设备」的统一判断。 +/// 「当前有没有连着设备」的统一判断——[DeviceHub] 之上的薄门面。 /// -/// 项目里有三条互相独立的蓝牙链路,之前各页面各判各的:设备页三条都看, -/// AI 页却只看了杰理那条,于是恒玄耳机连上后「通话录音 / 通话翻译」还是灰的。 -/// 统一收到这里,谁都别再自己拼。 +/// 早先三条蓝牙链路各页面各判各的(设备页三条都看,AI 页只看杰理那条), +/// 现在只有一个真相源。**这里不出现任何厂商名**,要按能力区分就问 +/// [DeviceHub.has]。 class DeviceConnection { DeviceConnection._(); - /// 杰理 BLE 耳机 - static bool get jieliConnected => - Get.isRegistered() && - HomeController.to.isFullyConnectedRx.value; + static DeviceHub get _hub => DeviceHub.to; - /// 蓝讯手机支架 - static bool get holderConnected => - Get.isRegistered() && - HolderDeviceService.to.connection.value == - EchoMeetConnectionState.connected; + /// 任意一台设备在线即算「已连接设备」 + static bool get any => _hub.anyConnected.value; - /// 恒玄耳机(经典蓝牙 / iOS 走 GATT 桥接) - static bool get besConnected => - Get.isRegistered() && - BesBluetoothService.to.deviceStatus.value == 2; - - /// 任意一条链路连上即算「已连接设备」 - static bool get any => jieliConnected || holderConnected || besConnected; - - /// 依赖设备的功能是否可用(预留:将来若某功能只认某条链路,在这里区分) + /// 依赖设备的功能是否可用 static bool get canUseDeviceFeatures => any && User.isLoggedIn(); - /// 是否正处于通话中。 - /// - /// 以耳机上报为准:0xD1/0x02 接通 → true,0xD2/0x03 挂断 → false。 + /// 是否正处于通话中(由声明了 callState 能力的设备上报)。 /// 通话录音和通话翻译都依赖双路通话音频,不在通话里拿不到数据。 - static bool get inCall => - besConnected && BesBluetoothService.to.isCallOngoing.value; + static bool get inCall => _hub.inCall.value; + + /// 有没有一台设备具备该能力 + static bool has(DeviceCapability c) => _hub.capabilities.contains(c); - /// 通话中被禁用的功能。 - /// - /// 通话时耳机的麦克风和音频通路被通话占用(`AA 51` 之后是双路通话流), - /// 这些功能要么抢不到麦、要么会把通话打断,所以直接禁掉: - /// 现场录音、同声翻译、面对面翻译、多媒体翻译。 + /// 通话中被禁用的功能:通话时设备的麦克风和音频通路被通话占用。 /// 通话录音和通话翻译反过来——只有通话中才可用。 static const Set blockedDuringCall = { 'fieldRecording', @@ -57,45 +36,22 @@ class DeviceConnection { 'audioVideo', }; - /// 该功能此刻是否因为「正在通话」而不可用 static bool isBlockedByCall(String moduleId) => inCall && blockedDuringCall.contains(moduleId); - /// 当前连着的设备名(恒玄耳机 / 蓝讯支架);都没连返回空 - static String get connectedName { - if (besConnected) return BesBluetoothService.to.connectedDeviceName.value; - if (holderConnected) return HolderDeviceService.to.connectedName; - return ''; - } + /// 当前连着的设备名;都没连返回空 + static String get connectedName => _hub.primaryName.value; /// 当前(或最近一次)连接设备的**产品主图**。 - /// - /// 取图规则统一在 [DeviceImages]:按设备名找 `<设备名>.png`,找不到退默认图。 - /// 设备页、固件升级页、设备管理页都走这一份,避免出现「设备页显示 A、 - /// 固件升级页显示 B」这种对不上的情况。 static String currentAssetImage() => DeviceImages.imageFor(_nameForImage()); - /// 同一台设备的**小图标**(列表、卡片里用,别拿主图去塞 44px 的位置) + /// 同一台设备的**小图标** static String currentIconImage() => DeviceImages.iconFor(_nameForImage()); /// 取图用的设备名:优先当前连着的,其次最近连过的。 - /// - /// 断连后仍然认「最近连过的那台」是刻意的——用户拔掉耳机不代表他换了设备, - /// 这时候把图换成通用图会让人以为设备丢了。从没连过任何设备才返回空。 - static String _nameForImage() { - if (besConnected) return BesBluetoothService.to.connectedDeviceName.value; - if (holderConnected) return HolderDeviceService.to.connectedName; - if (Get.isRegistered()) { - final n = HolderDeviceService.to.lastDeviceName; - if (n.isNotEmpty) return n; - } - if (Get.isRegistered() && - BesBluetoothService.to.hasPairedDevice) { - return BesBluetoothService.to.lastDeviceName; - } - return ''; - } + /// 断连后仍然认「最近连过的那台」是刻意的——用户拔掉耳机不代表他换了设备。 + static String _nameForImage() => _hub.lastKnownName; /// 是否曾经连过设备(用来决定断连时要不要把图压暗) - static bool get hasKnownDevice => _nameForImage().isNotEmpty; + static bool get hasKnownDevice => _hub.hasKnownDevice; } diff --git a/apps/client/lib/core/utils/version_compare.dart b/apps/client/lib/core/utils/version_compare.dart new file mode 100644 index 00000000..8c1d74f2 --- /dev/null +++ b/apps/client/lib/core/utils/version_compare.dart @@ -0,0 +1,34 @@ +/// 版本号比较,与服务端 `comm.CompareVersion` 同口径:按 `.` 切段、每段只取 +/// 前导数字(`3+4` → 3,`0-beta` → 0),逐段按整数比。 +/// +/// 用 `int.parse` 的写法遇到 `1.0.0-beta` / `1.2.3+4` 会直接抛,被外层 catch +/// 吞成「无更新」——表现是「明明有新版本却说已是最新」。 +class VersionCompare { + VersionCompare._(); + + static List split(String v) { + final s = v.trim(); + if (s.isEmpty) return const []; + return s.split('.').map((seg) { + final m = RegExp(r'^\d+').firstMatch(seg.trim()); + return m == null ? 0 : int.parse(m.group(0)!); + }).toList(); + } + + /// 负数:a < b;0:相等;正数:a > b + static int compare(String a, String b) { + final pa = split(a); + final pb = split(b); + if (pa.isEmpty || pb.isEmpty) return a.trim().compareTo(b.trim()); + final len = pa.length > pb.length ? pa.length : pb.length; + for (var i = 0; i < len; i++) { + final x = i < pa.length ? pa[i] : 0; + final y = i < pb.length ? pb[i] : 0; + if (x != y) return x > y ? 1 : -1; + } + return 0; + } + + /// [a] 是否比 [b] 新 + static bool isNewer(String a, String b) => compare(a, b) > 0; +} diff --git a/apps/client/lib/data/models/agent_module.dart b/apps/client/lib/data/models/agent_module.dart index 81afc0d4..314c49a8 100644 --- a/apps/client/lib/data/models/agent_module.dart +++ b/apps/client/lib/data/models/agent_module.dart @@ -5,8 +5,7 @@ import 'device_category.dart'; /// 功能模块(agent)定义。 /// /// AI 页与广场页共用同一份注册表:广场展示全部,AI 页只展示用户启用的那些, -/// 顺序由 [AgentModuleService] 持久化。新增 agent 只需往 [AgentModules.all] -/// 里加一条,两个页面自动生效。 +/// 顺序由 [AgentModuleService] 持久化。新增 agent 见 `lib/modules/module_registry.dart`。 class AgentModule { const AgentModule({ required this.id, @@ -16,6 +15,7 @@ class AgentModule { this.subtitle, this.requiresDevice = false, this.comingSoon = false, + this.bailianAppId, }); /// 唯一标识,持久化时存的就是它,改名会导致用户已有配置失效 @@ -36,6 +36,25 @@ class AgentModule { /// 尚未实现的占位模块:可以加到 AI 页排版,但点击只提示"敬请期待" final bool comingSoon; + + /// 这个模块用哪个百炼应用(app_id)。null = 用全局默认(EMAI 那个)。 + /// + /// **设备按键唤醒时用它决定接哪个助手**:`DeviceAiSessionService` 按 + /// 厂商 → 品类 → 默认编排第一个模块 → 这个字段,找到该说话的那个 agent。 + /// 车载香薰唤醒的是「Smartcar 车载助手」而不是 EMAI,就是靠这一条。 + /// + /// ⚠️ 放在 data 层的基类而不是 `AgentModuleDescriptor`(modules 层),是为了让 + /// `DeviceAiSessionService`(data 层)读得到 —— **data 层不反向依赖 modules 层**。 + final String? bailianAppId; +} + +/// 按模块 id 取它要接的百炼 app_id。查不到返回 null(用全局默认)。 +String? bailianAppIdForModule(String? moduleId) { + if (moduleId == null) return null; + for (final m in AgentModules.all) { + if (m.id == moduleId) return m.bailianAppId; + } + return null; } /// 全部可用模块的注册表 @@ -53,6 +72,9 @@ class AgentModules { /// EMAI 助手:原来 AI 页底部那颗悬浮球,改成广场里的一个正式 agent static const String emaiAssistant = 'emaiAssistant'; + + /// Smartcar 车载助手(车载香薰的对话 agent,另一个百炼应用) + static const String smartcarChat = 'smartcarChat'; static const String textTranslation = 'textTranslation'; static const String pictureTranslation = 'pictureTranslation'; @@ -61,106 +83,10 @@ class AgentModules { static const String englishStudy = 'englishStudy'; static const String kidsStory = 'kidsStory'; - static const List all = [ - AgentModule( - id: meeting, - title: 'agentMeeting', - subtitle: 'agentMeetingDesc', - icon: Icons.groups_2, - gradient: [Color(0xFFAB47BC), Color(0xFFEC407A)], - ), - AgentModule( - id: emaiAssistant, - title: 'agentEmai', - subtitle: 'agentEmaiDesc', - icon: Icons.auto_awesome, - gradient: [Color(0xFF5B8DEF), Color(0xFF7E57C2)], - ), - AgentModule( - id: simultaneous, - title: 'agentSimultaneous', - subtitle: 'agentSimultaneousDesc', - icon: Icons.record_voice_over, - gradient: [Color(0xFF4CAF50), Color(0xFF009688)], - ), - AgentModule( - id: faceToFace, - title: 'agentFaceToFace', - subtitle: 'agentFaceToFaceDesc', - icon: Icons.forum, - gradient: [Color(0xFF42A5F5), Color(0xFF1E88E5)], - ), - AgentModule( - id: callRecording, - title: 'agentCallRecording', - subtitle: 'agentCallRecordingDesc', - icon: Icons.fiber_manual_record, - gradient: [Color(0xFFEF5350), Color(0xFFE53935)], - requiresDevice: true, - ), - AgentModule( - id: fieldRecording, - title: 'agentFieldRecording', - // 有设备走设备录音通路,没设备退回手机麦克风,两种情况都能用 - subtitle: 'agentFieldRecordingDesc', - icon: Icons.mic, - gradient: [Color(0xFF26C6DA), Color(0xFF00838F)], - ), - AgentModule( - id: call, - title: 'agentCall', - subtitle: 'agentCallDesc', - icon: Icons.phone_in_talk, - gradient: [Color(0xFFFFA726), Color(0xFFFB8C00)], - requiresDevice: true, - ), - AgentModule( - id: audioVideo, - title: 'agentAudioVideo', - subtitle: 'agentAudioVideoDesc', - // 放映机图标:这个功能是音视频内容翻译,用投影/放映的意象更贴切 - icon: Icons.slideshow, - gradient: [Color(0xFF5C6BC0), Color(0xFF3949AB)], - ), - AgentModule( - id: textTranslation, - title: 'agentTextTranslation', - subtitle: 'agentTextTranslationDesc', - icon: Icons.translate, - gradient: [Color(0xFF26A69A), Color(0xFF00796B)], - ), - AgentModule( - id: pictureTranslation, - title: 'agentPictureTranslation', - subtitle: 'agentPictureTranslationDesc', - icon: Icons.image_search, - gradient: [Color(0xFFEC407A), Color(0xFFAD1457)], - ), - AgentModule( - id: englishChat, - title: 'agentEnglishChat', - subtitle: 'agentEnglishChatDesc', - icon: Icons.chat_bubble_outline, - gradient: [Color(0xFF7E57C2), Color(0xFF5E35B1)], - comingSoon: true, - ), - AgentModule( - id: englishStudy, - title: 'agentEnglishStudy', - subtitle: 'agentEnglishStudyDesc', - icon: Icons.menu_book, - gradient: [Color(0xFF29B6F6), Color(0xFF0288D1)], - comingSoon: true, - ), - AgentModule( - id: kidsStory, - title: 'agentKidsStory', - subtitle: 'agentKidsStoryDesc', - icon: Icons.auto_stories, - gradient: [Color(0xFFFF7043), Color(0xFFF4511E)], - comingSoon: true, - ), - ]; + /// 全部模块。由 `ModuleRegistry.install()` 在启动时注入——注册表本体在 + /// `lib/modules/module_registry.dart`(描述符要引用各模块的页面,只能放在 + /// modules 层;data 层不反向依赖它)。 + static List all = const []; /// AI 页首次进入时的默认模块。 /// @@ -187,11 +113,22 @@ class AgentModules { /// key 用的是 [DeviceCategories] 的品类 id。只在该品类首次连接时生效一次 /// (由 AgentModuleService 记录已应用过的品类),之后用户怎么增删排序都不再覆盖。 static const Map> defaultsByCategory = { - // AI手机支架 / AI录音卡片:只有录音相关能力,AI 页就只留这三个 - DeviceCategories.phoneHolder: [meeting, fieldRecording, callRecording], + // AI手机支架(EaiRec):连上后 AI 页默认就这三个 —— 语音纪要 / 现场录音 / + // EMAI助手。**不含通话录音**:支架没有接管通话音频的能力,摆在这儿点进去 + // 也用不了。 + DeviceCategories.phoneHolder: [meeting, fieldRecording, emaiAssistant], + // AI录音卡片是另一条产品线,保持原编排不动。 DeviceCategories.noteCard: [meeting, fieldRecording, callRecording], // AI耳机(恒玄):通话 + 翻译整套都能跑,铺开全量默认编排 DeviceCategories.earphone: defaultEnabled, + // AI车载香薰(Smartcar):随时开聊 + 现场录音 + 语音纪要。 + // 这三个都不依赖设备能力(只有通话录音/翻译才声明 requiredCapabilities), + // 所以连上香薰就能直接用,不会灰。 + // ⚠️ 这是**默认编排不是权限限制**——用户仍可去广场把别的功能加回来, + // 这是产品有意接受的(落地文档 6.5)。 + // ⚠️ 2026-09-11 从「只有 smartcarChat」改成这三个;已经铺过旧编排的装机 + // 由 AgentModuleService 的一次性补铺处理,见那边的 _migrateCarAromaDefaults。 + DeviceCategories.carAroma: [smartcarChat, fieldRecording, meeting], }; static List defaultsForCategory(String? categoryId) { diff --git a/apps/client/lib/data/models/appconfig.dart b/apps/client/lib/data/models/appconfig.dart index 660862b0..bc78f95b 100644 --- a/apps/client/lib/data/models/appconfig.dart +++ b/apps/client/lib/data/models/appconfig.dart @@ -49,11 +49,6 @@ class AppConfig { return names; } - static List allproductUuids() { - List uuids = config.products.map((user) => user.scanuuid).toList(); - return uuids; - } - /// 根据产品名称获取产品配置,查不到返回 null。 /// /// ⚠️ **不要改回 `firstWhere`**:它没有 orElse,查不到时抛 StateError 而不是 diff --git a/apps/client/lib/data/models/appconfig_model.dart b/apps/client/lib/data/models/appconfig_model.dart index 788289e6..fff97e95 100644 --- a/apps/client/lib/data/models/appconfig_model.dart +++ b/apps/client/lib/data/models/appconfig_model.dart @@ -3,11 +3,19 @@ import 'package:get/get.dart'; part 'appconfig_model.g.dart'; // 生成的文件名 +/// ⚠️ 每个非空字段都带 `defaultValue`:服务端少下发一个键时用默认值, +/// **而不是**整个 fromJson 抛 TypeError 让产品表/设备列表整体空白 +/// (见 CLAUDE.md「客户端模型是手写镜像」那一段踩过的坑)。 +/// 契约测试在 test/appconfig_model_test.dart。 @JsonSerializable() class UserGetAppConfigResp { + @JsonKey(defaultValue: {}) final Map env; + @JsonKey(defaultValue: []) final List agents; + @JsonKey(defaultValue: {}) final Map mcps; + @JsonKey(defaultValue: []) final List products; UserGetAppConfigResp( @@ -23,12 +31,19 @@ class UserGetAppConfigResp { @JsonSerializable() class DBAgent { + @JsonKey(defaultValue: '') final String id; + @JsonKey(defaultValue: '') final String name; + @JsonKey(defaultValue: '') final String avatarUrl; + @JsonKey(defaultValue: '') final String description; + @JsonKey(defaultValue: '') final String tag; + @JsonKey(defaultValue: false) final bool isOnline; + @JsonKey(defaultValue: '') final String voice; final String? welcomeMessage; final String? voiceWelcomeMessage; @@ -56,9 +71,13 @@ class DBAgent { @JsonSerializable() class DBMCPServer { + @JsonKey(defaultValue: '') final String servername; + @JsonKey(defaultValue: '') final String url; + @JsonKey(defaultValue: 0) final int type; + @JsonKey(defaultValue: '') final String tools; DBMCPServer({ required this.servername, @@ -74,12 +93,19 @@ class DBMCPServer { @JsonSerializable() class DBProduct { + @JsonKey(defaultValue: 0) final int id; + @JsonKey(defaultValue: 0) final int factoryid; + /// 服务端已废弃(恒为 0),只为老包兼容保留 + @JsonKey(defaultValue: 0) final int devicetype; + @JsonKey(defaultValue: '') final String devicename; final String? productimage; - final String scanuuid; + // ⚠️ scanuuid 已于 2026-09-08 删除:设备校验早就改成按 MAC 走,绑定奖励也是按 MAC 判 + // 「这台设备有没有被绑过」,跟这个 UUID 没关系。服务端仍会下发这个键(恒为空串, + // 只为老 App 包不炸),新端直接不解析。 final String? version; bool? isforceupdate; @@ -103,7 +129,6 @@ class DBProduct { required this.devicetype, required this.devicename, required this.productimage, - required this.scanuuid, required this.version, required this.isforceupdate, //该产品固件版本是否强制更新 this.updateonmismatch, //可选:老服务端不下发这个字段,缺省即 null→按 false diff --git a/apps/client/lib/data/models/appconfig_model.g.dart b/apps/client/lib/data/models/appconfig_model.g.dart index e776602a..879b442f 100644 --- a/apps/client/lib/data/models/appconfig_model.g.dart +++ b/apps/client/lib/data/models/appconfig_model.g.dart @@ -9,16 +9,23 @@ part of 'appconfig_model.dart'; UserGetAppConfigResp _$UserGetAppConfigRespFromJson( Map json) => UserGetAppConfigResp( - env: Map.from(json['env'] as Map), - agents: (json['agents'] as List) - .map((e) => DBAgent.fromJson(e as Map)) - .toList(), - mcps: (json['mcps'] as Map).map( - (k, e) => MapEntry(k, DBMCPServer.fromJson(e as Map)), - ), - products: (json['products'] as List) - .map((e) => DBProduct.fromJson(e as Map)) - .toList(), + env: (json['env'] as Map?)?.map( + (k, e) => MapEntry(k, e as String), + ) ?? + {}, + agents: (json['agents'] as List?) + ?.map((e) => DBAgent.fromJson(e as Map)) + .toList() ?? + [], + mcps: (json['mcps'] as Map?)?.map( + (k, e) => + MapEntry(k, DBMCPServer.fromJson(e as Map)), + ) ?? + {}, + products: (json['products'] as List?) + ?.map((e) => DBProduct.fromJson(e as Map)) + .toList() ?? + [], ); Map _$UserGetAppConfigRespToJson( @@ -31,13 +38,13 @@ Map _$UserGetAppConfigRespToJson( }; DBAgent _$DBAgentFromJson(Map json) => DBAgent( - id: json['id'] as String, - name: json['name'] as String, - avatarUrl: json['avatarUrl'] as String, - description: json['description'] as String, - tag: json['tag'] as String, - isOnline: json['isOnline'] as bool, - voice: json['voice'] as String, + id: json['id'] as String? ?? '', + name: json['name'] as String? ?? '', + avatarUrl: json['avatarUrl'] as String? ?? '', + description: json['description'] as String? ?? '', + tag: json['tag'] as String? ?? '', + isOnline: json['isOnline'] as bool? ?? false, + voice: json['voice'] as String? ?? '', welcomeMessage: json['welcomeMessage'] as String?, voiceWelcomeMessage: json['voiceWelcomeMessage'] as String?, systemPrompt: json['systemPrompt'] as String?, @@ -59,10 +66,10 @@ Map _$DBAgentToJson(DBAgent instance) => { }; DBMCPServer _$DBMCPServerFromJson(Map json) => DBMCPServer( - servername: json['servername'] as String, - url: json['url'] as String, - type: (json['type'] as num).toInt(), - tools: json['tools'] as String, + servername: json['servername'] as String? ?? '', + url: json['url'] as String? ?? '', + type: (json['type'] as num?)?.toInt() ?? 0, + tools: json['tools'] as String? ?? '', ); Map _$DBMCPServerToJson(DBMCPServer instance) => @@ -74,12 +81,11 @@ Map _$DBMCPServerToJson(DBMCPServer instance) => }; DBProduct _$DBProductFromJson(Map json) => DBProduct( - id: (json['id'] as num).toInt(), - factoryid: (json['factoryid'] as num).toInt(), - devicetype: (json['devicetype'] as num).toInt(), - devicename: json['devicename'] as String, + id: (json['id'] as num?)?.toInt() ?? 0, + factoryid: (json['factoryid'] as num?)?.toInt() ?? 0, + devicetype: (json['devicetype'] as num?)?.toInt() ?? 0, + devicename: json['devicename'] as String? ?? '', productimage: json['productimage'] as String?, - scanuuid: json['scanuuid'] as String, version: json['version'] as String?, isforceupdate: json['isforceupdate'] as bool?, updateonmismatch: json['updateonmismatch'] as bool?, @@ -96,7 +102,6 @@ Map _$DBProductToJson(DBProduct instance) => { 'devicetype': instance.devicetype, 'devicename': instance.devicename, 'productimage': instance.productimage, - 'scanuuid': instance.scanuuid, 'version': instance.version, 'isforceupdate': instance.isforceupdate, 'updateonmismatch': instance.updateonmismatch, diff --git a/apps/client/lib/data/models/assistant_directive.dart b/apps/client/lib/data/models/assistant_directive.dart index 7bcb2358..ca815e54 100644 --- a/apps/client/lib/data/models/assistant_directive.dart +++ b/apps/client/lib/data/models/assistant_directive.dart @@ -4,6 +4,10 @@ import 'package:get/get.dart'; import 'memory_item.dart'; +/// 端侧指令处理器:由各能力模块注册,处理完自己把 [AssistantDirective.status] +/// / `note` 填好(见 `AssistantDirectiveService.registerHandlers`)。 +typedef DirectiveHandler = void Function(AssistantDirective directive); + /// 助手指令:百炼多模态在 `RespondingContent.extra_info.tool_calls` 里下发的 /// 端侧函数调用。 /// @@ -90,7 +94,10 @@ class AssistantDirective { final extra = output['extra_info']; if (extra is! Map) return const []; final calls = extra['tool_calls']; - if (calls is! List || calls.isEmpty) return const []; + final commands = extra['commands']; + final hasCalls = calls is List && calls.isNotEmpty; + final hasCommands = commands is List && commands.isNotEmpty; + if (!hasCalls && !hasCommands) return const []; final dialogId = (output['dialog_id'] ?? '').toString(); final roundId = (output['round_id'] ?? '').toString(); @@ -98,6 +105,16 @@ class AssistantDirective { final now = DateTime.now(); final result = []; + if (!hasCalls) { + _appendCommands(commands, result, + dialogId: dialogId, + roundId: roundId, + llmRequestId: llmRequestId, + source: source, + now: now, + replyText: (output['text'] ?? '').toString()); + return result; + } for (var i = 0; i < calls.length; i++) { final call = calls[i]; if (call is! Map) continue; @@ -130,9 +147,77 @@ class AssistantDirective { replyText: (output['text'] ?? '').toString(), )); } + if (hasCommands) { + _appendCommands(commands, result, + dialogId: dialogId, + roundId: roundId, + llmRequestId: llmRequestId, + source: source, + now: now, + replyText: (output['text'] ?? '').toString()); + } return result; } + /// 解析 `extra_info.commands` —— **另一种指令形状**。 + /// + /// ⚠️ 2026-09-11 真机发现:Smartcar 车载助手下发的指令不在 `tool_calls` 里, + /// 而是 `extra_info.commands`,形如 + /// ```json + /// "commands":[{"intent_info":{"domain":"general_command","intent":"quit"}, + /// "name":"quit","params":[]}] + /// ``` + /// 只解析 `tool_calls` 的话这里恒为 0 条 —— 表现就是"AI 说好的已经关闭了, + /// 但实际什么都没发生"。两种形状都要认,且**不能互斥**:同一帧可能都带。 + static void _appendCommands( + List commands, + List out, { + required String dialogId, + required String roundId, + required String llmRequestId, + required String source, + required DateTime now, + required String replyText, + }) { + for (var i = 0; i < commands.length; i++) { + final c = commands[i]; + if (c is! Map) continue; + final name = (c['name'] ?? '').toString(); + if (name.isEmpty) continue; + + // params 形状没有文档,实测是空数组。能转成 Map 就转,转不成就原样留着, + // 别为了凑格式把信息丢了。 + final params = c['params']; + final args = {}; + if (params is Map) { + args.addAll(params.cast()); + } else if (params is List) { + for (final p in params) { + if (p is Map && p['name'] != null) { + args[p['name'].toString()] = p['value']; + } + } + } + final intent = c['intent_info']; + if (intent is Map && intent['intent'] != null) { + args.putIfAbsent('intent', () => intent['intent']); + } + + out.add(AssistantDirective( + id: '${roundId.isEmpty ? now.microsecondsSinceEpoch : roundId}#cmd$i', + name: name, + args: args, + rawArguments: json.encode(c), + dialogId: dialogId, + roundId: roundId, + llmRequestId: llmRequestId, + source: source, + createdAt: now, + replyText: replyText, + )); + } + } + Map toJson() => { 'id': id, 'name': name, diff --git a/apps/client/lib/data/models/device_category.dart b/apps/client/lib/data/models/device_category.dart index 6bb8b5a8..b6e0f6e6 100644 --- a/apps/client/lib/data/models/device_category.dart +++ b/apps/client/lib/data/models/device_category.dart @@ -2,6 +2,7 @@ import 'package:flutter/material.dart'; import 'appconfig.dart'; import 'appconfig_model.dart'; +import 'device_images.dart'; /// 「添加设备」第一步要用户选的产品品类。 /// @@ -58,17 +59,21 @@ class DeviceCategories { static const String studyMachine = 'studyMachine'; static const String noteCard = 'noteCard'; + /// AI车载香薰(Smartcar)。2026-09-08 新增。 + static const String carAroma = 'carAroma'; + // ⚠️ 这里原来有一组写死的图片常量(earphoneImage / earphoneIconImage / // noteCardImage / phoneHolderImage)和一个按关键字猜图的 imageForDeviceName()。 // 已全部删除:取图改成「按设备名找 <设备名>.png / <设备名>_icon.png」, // 统一在 [DeviceImages]。加新产品只要按这个命名把图丢进 // assets/images/devices/ 即可,不用再回这里加一条映射。 - // ⚠️ 下面的 deviceTypes / nameKeywords 是按服务端现有产品数据推测的, - // 需要产品侧确认后再定稿。未配置的品类会退回扫描全部 UUID。 + // ⚠️ **deviceTypes 这个筛选条件已经失效**:服务端 DBProduct.devicetype 于 + // 2026-09-08 废弃,下发的值恒为 0,再也匹配不到 1/2/3。留着字段只为不动 + // 老数据结构,**新品类一律用 nameKeywords 圈定**。圈不到时 productsOf 返回空。 // - // 「选择设备」页当前只上架 AI耳机(恒玄)这一条品类,手机支架暂时下架到 - // [hidden](2026-08-26),要恢复直接把条目挪回 all 即可。 + // 「选择设备」页当前上架三条:AI耳机(恒玄)、AI手机支架(EaiRec)、AI车载香薰(Smartcar)。 + // 下架某条 = 把它挪回 [hidden]。 static const List all = [ DeviceCategory( id: earphone, @@ -76,11 +81,32 @@ class DeviceCategories { subtitle: 'catEarphoneDesc', icon: Icons.headphones, gradient: [Color(0xFFAB47BC), Color(0xFF7B1FA2)], - deviceTypes: [ - 1, // classicBluetoothHeadset - 2, // bleBluetoothDoubleHeadset - 3, // bleBluetoothSingleHeadset - ], + imageAsset: '${DeviceImages.dir}Echo-one.png', + // deviceTypes 已失效(见上),耳机改由名字圈定 + nameKeywords: ['echo-one', 'deepvoice', 'deepsound', 'buds', 'ai80', 'a80'], + ), + // AI手机支架 = EaiRec(EAIMAR 圆盘)。2026-09-08 上架,取代原先挂在这个 + // 品类下、已停售的EaiRec Echomeet 支架 —— 所以沿用 phoneHolder 这个 id: + // AI 页的默认功能编排(会议/现场录音/通话录音)本来就是按它配的,换 id 要一起改。 + // ⚠️ 蓝牙链路还没接,点击不进扫描页(见 device_category_view._openCategory)。 + DeviceCategory( + id: phoneHolder, + title: 'catPhoneHolder', + subtitle: 'catPhoneHolderDesc', + icon: Icons.stay_current_portrait, + gradient: [Color(0xFF42A5F5), Color(0xFF1E88E5)], + imageAsset: '${DeviceImages.dir}EaiRec.png', + nameKeywords: ['echomeet', 'eairec', 'eaimar', '支架', 'holder', 'stand'], + ), + // AI车载香薰 = Smartcar。2026-09-08 新增,蓝牙链路同样未接。 + DeviceCategory( + id: carAroma, + title: 'catCarAroma', + subtitle: 'catCarAromaDesc', + icon: Icons.air, + gradient: [Color(0xFFFFB74D), Color(0xFFE65100)], + imageAsset: '${DeviceImages.dir}Smartcar.png', + nameKeywords: ['smartcar', '车载', '香薰', 'aroma', 'diffuser'], ), ]; @@ -89,15 +115,8 @@ class DeviceCategories { /// 只是不展示,定义都留着:AI 页的默认功能编排([AgentModules.defaultsByCategory]) /// 和主图映射还按 id 引用它们,老用户存下来的数据也要能查得到, /// 所以 [byId] 仍然会在这里查。上架时把条目挪回 [all] 就行。 + // 注:phoneHolder 已于 2026-09-08 挪到 [all] 上架(产品换成 EaiRec)。 static const List hidden = [ - DeviceCategory( - id: phoneHolder, - title: 'catPhoneHolder', - subtitle: 'catPhoneHolderDesc', - icon: Icons.stay_current_portrait, - gradient: [Color(0xFF42A5F5), Color(0xFF1E88E5)], - nameKeywords: ['支架', 'holder', 'stand', 'echomeet'], - ), DeviceCategory( id: noteCard, title: 'catNoteCard', @@ -157,21 +176,7 @@ class DeviceCategories { }).toList(growable: false); } - /// 扫描该品类时要下发给 BLE SDK 的 UUID 列表。 - /// - /// 圈不出产品(品类没配筛选条件,或服务端产品表里暂时没有这类产品)时 - /// 返回全部 UUID —— 宁可多扫,也不能让用户扫不到自己的设备。 - static List scanUuidsFor(String? categoryId) { - if (!AppConfig.isInitialized()) return const []; - final category = byId(categoryId); - if (category == null) return AppConfig.allproductUuids(); - - final uuids = productsOf(category) - .map((p) => p.scanuuid) - .where((u) => u.isNotEmpty) - .toSet() - .toList(growable: false); - - return uuids.isEmpty ? AppConfig.allproductUuids() : uuids; - } + // ⚠️ 这里原来有个 scanUuidsFor(categoryId),按品类把产品的 scanuuid 拼成 BLE 扫描 + // 白名单。scanuuid 已于 2026-09-08 整体废弃(设备校验与绑定奖励都改成按 MAC 判定), + // 扫描不再做 UUID 过滤,改由扫到之后按名字/品类筛。 } diff --git a/apps/client/lib/data/models/goods.dart b/apps/client/lib/data/models/goods.dart index 0738a7a6..f0cb6812 100644 --- a/apps/client/lib/data/models/goods.dart +++ b/apps/client/lib/data/models/goods.dart @@ -13,9 +13,10 @@ enum UsageType { Meet, // 2 /// 智能体 AI, // 3 - /// 智能体 - Vip; // 4 - /// Vip + /// VIP 时长 + Vip, // 4 + /// 算力包(2026-09 起;商品的 compute 字段即本次发放量,归账号、不过期) + Compute; // 5 // 解析支持 int 或 string(大小写不敏感) static UsageType from(dynamic value) { @@ -31,6 +32,8 @@ enum UsageType { return UsageType.AI; case 4: return UsageType.Vip; + case 5: + return UsageType.Compute; default: return UsageType.UsageNull; } @@ -52,6 +55,8 @@ enum UsageType { return UsageType.AI; case 'vip': return UsageType.Vip; + case 'compute': + return UsageType.Compute; default: return UsageType.UsageNull; } @@ -71,6 +76,8 @@ enum UsageType { return 3; case UsageType.Vip: return 4; + case UsageType.Compute: + return 5; } } @@ -86,6 +93,8 @@ enum UsageType { return 'AI'; case UsageType.Vip: return 'Vip'; + case UsageType.Compute: + return 'Compute'; } } } @@ -107,6 +116,13 @@ class DBGoods { final UsageType usagetype; final int usagenum; + /// 本商品发放的算力(服务端 DBGoods.compute,2026-09 起)。 + /// 老商品没配就是 0,服务端发货时会把旧资源点按换算系数折成算力,这里只用于展示。 + final int compute; + + /// 本商品发放的 VIP 天数(服务端 DBGoods.viptime)。 + final int viptime; + const DBGoods({ required this.id, required this.enable, @@ -115,6 +131,8 @@ class DBGoods { required this.price, required this.usagetype, required this.usagenum, + this.compute = 0, + this.viptime = 0, }); /// 解析后端 JSON @@ -150,6 +168,8 @@ class DBGoods { price: _asInt(json['price']), usagetype: UsageType.from(json['usagetype']), usagenum: _asInt(json['usagenum']), + compute: _asInt(json['compute']), + viptime: _asInt(json['viptime']), ); } @@ -163,6 +183,8 @@ class DBGoods { 'price': price, 'usagetype': usagetype.toInt, 'usagenum': usagenum, + 'compute': compute, + 'viptime': viptime, }; } diff --git a/apps/client/lib/data/models/user_Info.dart b/apps/client/lib/data/models/user_Info.dart index b6075d48..8ebdc864 100644 --- a/apps/client/lib/data/models/user_Info.dart +++ b/apps/client/lib/data/models/user_Info.dart @@ -288,4 +288,22 @@ class UserDevice { 'license': license, 'cmei': cmei, }; + + /// 这台设备有没有「值得展示」的授权码。 + /// + /// ⚠️ 光判非空不够:服务端绑定时写回的是**从设备表查到的** code + /// (`api_binddevice.go` 的 `License: model.Code`,不是客户端传的空串), + /// 而后台「导入 MAC」模式建的记录,`code` 就是 MAC 串本身 + /// (`device_mac` 合表迁移里 `code := devicemac`)。于是界面上会出现 + /// **MAC 一行、授权码一行、两行值一模一样**,看着像显示了两个 MAC。 + /// 这种占位值不展示;真授权码(如 `71E5E5C3A797C043B5A7`)照常展示。 + /// + /// ⚠️ 规则只放这一份:设备页和「我的设备」页都用它,各写各的必然走岔。 + bool get hasRealLicense { + final lic = license.trim(); + if (lic.isEmpty) return false; + String norm(String v) => + v.replaceAll(':', '').replaceAll('-', '').toUpperCase(); + return norm(lic) != norm(devicemac); + } } diff --git a/apps/client/lib/data/services/account_service.dart b/apps/client/lib/data/services/account_service.dart index 73f1a444..92418fd4 100644 --- a/apps/client/lib/data/services/account_service.dart +++ b/apps/client/lib/data/services/account_service.dart @@ -10,7 +10,7 @@ import 'db/database_helper.dart'; import '../../data/models/user_Info.dart'; import 'meeting/meeting_upload_service.dart'; import '../../core/utils/toast_throttle.dart'; -import 'bes_device_auth.dart'; +import '../../devices/device_auth.dart'; import 'mcp_token_service.dart'; import 'memory_reminder_scheduler.dart'; import 'memory_service.dart'; @@ -43,7 +43,7 @@ class AccountService extends GetxService { User.clearUserSession(); // 换账号后同一台耳机要重新登记到新账号名下, // 不清这个 set 的话「本进程已登记过」会把新账号的绑定吞掉。 - BesDeviceAuth.reset(); + DeviceAuth.reset(); // MCP 令牌是按 uid 签的:不清的话新账号会拿着上一个人的令牌去调 MCP, // 读到的是别人的会议纪要和待办。 McpTokenService.reset(); @@ -70,7 +70,7 @@ class AccountService extends GetxService { try { await _storage.erase(); User.clearUserSession(); - BesDeviceAuth.reset(); + DeviceAuth.reset(); McpTokenService.reset(); if (Get.isRegistered()) { MemoryService.to.clear(); diff --git a/apps/client/lib/data/services/agent_module_defaults.dart b/apps/client/lib/data/services/agent_module_defaults.dart index d663fc0f..563afba4 100644 --- a/apps/client/lib/data/services/agent_module_defaults.dart +++ b/apps/client/lib/data/services/agent_module_defaults.dart @@ -1,35 +1,58 @@ +import 'package:device_plugin_interface/device_plugin_interface.dart'; import 'package:get/get.dart'; +import '../../devices/device_hub.dart'; +import '../../devices/device_vendors.dart'; +import '../models/agent_module.dart'; import '../models/device_category.dart'; import 'agent_module_service.dart'; -/// 设备品类首次连接时,把该品类的默认功能铺到 AI 页。 +/// 设备品类**首次连接成功**时,把该品类的默认功能铺到 AI 页。 /// -/// AgentModuleService 是 MainTabBinding 里 put 的,蓝牙服务可能比它先跑, -/// 没注册就先建出来(permanent,和 MainTabBinding 里的守卫互不冲突)。 -void applyAgentDefaultsFor(String categoryId) { - try { - final service = Get.isRegistered() - ? AgentModuleService.to - : Get.put(AgentModuleService(), permanent: true); - service.applyDeviceDefaults(categoryId); - } catch (_) { - // 铺默认功能失败不能影响连接本身 - } -} - -/// 便捷别名,避免各处再去 import device_category +/// 挂在 [DeviceHub.sessions] 上,谁上线就按厂商 → 品类映射铺一次; +/// 「只铺一次」由 [AgentModuleService.applyDeviceDefaults] 记录。 class AgentDefaults { AgentDefaults._(); - static void onHolderConnected() => - applyAgentDefaultsFor(DeviceCategories.phoneHolder); + static const Map _vendorCategory = { + DeviceVendors.bes: DeviceCategories.earphone, + DeviceVendors.holder: DeviceCategories.phoneHolder, + DeviceVendors.smartcar: DeviceCategories.carAroma, + }; + + /// 厂商 → 品类。设备按键唤醒时用它找该接哪个 agent(见 + /// `DeviceAiSessionService._resolveAgentAppId`)。 + static String? categoryForVendor(String vendor) => _vendorCategory[vendor]; - static void onEarphoneConnected() => - applyAgentDefaultsFor(DeviceCategories.earphone); + /// 这个品类默认该由哪个 agent 接管对话 —— 取默认编排里的第一个模块。 + /// 找不到返回 null,调用方回退到全局默认(EMAI)。 + static String? primaryModuleForVendor(String vendor) { + final cat = _vendorCategory[vendor]; + if (cat == null) return null; + final list = AgentModules.defaultsForCategory(cat); + return list.isEmpty ? null : list.first; + } + + static Worker? _worker; + + static void bind(DeviceHub hub) { + _worker?.dispose(); + _worker = ever>(hub.sessions, (list) { + for (final s in list) { + final cat = _vendorCategory[s.vendor]; + if (cat != null) applyAgentDefaultsFor(cat); + } + }); + } - static void onJieliConnected(String? categoryId) { - if (categoryId == null || categoryId.isEmpty) return; - applyAgentDefaultsFor(categoryId); + static void applyAgentDefaultsFor(String categoryId) { + try { + final service = Get.isRegistered() + ? AgentModuleService.to + : Get.put(AgentModuleService(), permanent: true); + service.applyDeviceDefaults(categoryId); + } catch (_) { + // 铺默认功能失败不能影响连接本身 + } } } diff --git a/apps/client/lib/data/services/agent_module_service.dart b/apps/client/lib/data/services/agent_module_service.dart index 76c0f985..22279da8 100644 --- a/apps/client/lib/data/services/agent_module_service.dart +++ b/apps/client/lib/data/services/agent_module_service.dart @@ -16,6 +16,10 @@ class AgentModuleService extends GetxService { /// 已经应用过默认编排的设备品类 id,保证「第一次连接」只铺一次 static const String _appliedCategoriesKey = 'ai_tab_device_defaults_applied'; + /// 上一次**自动铺**下去的编排清单。用来判断用户有没有自己动过, + /// 见 [applyDeviceDefaults]。 + static const String _lastAppliedKey = 'ai_tab_device_defaults_last'; + final GetStorage _storage = GetStorage(); /// AI 页当前展示的模块 id,顺序即展示顺序 @@ -140,35 +144,53 @@ class AgentModuleService extends GetxService { return i == query.length; } - /// 某个设备品类**第一次连接成功**时铺开它的默认功能。 + /// 连上某品类的设备时,把 AI 页铺成这个品类的默认编排。 + /// + /// 规则只有一条:**只要用户没自己动过编排,AI 页就跟着当前设备走**。 + /// 于是「连上车载香薰 → 只剩 Smartcar / 现场录音 / 语音纪要」「换回耳机 → 恢复全量」 + /// 都成立,而用户一旦增删或排序过,就再也不会被自动改动。 + /// + /// 判据是 [_lastAppliedKey]:上一次**自动铺**下去的那份清单。 + /// 当前编排和它逐项相同 = 用户没动过;不同 = 动过,直接放手。 /// - /// - 只生效一次:品类 id 记在 [_appliedCategoriesKey] 里,之后再连同类设备 - /// 不会覆盖用户自己的增删和排序; - /// - 采用「补齐」而不是「替换」:用户已有的模块和顺序原样保留, - /// 缺的按品类默认顺序追加到后面。 + /// ⚠️ 2026-09-11 之前的规则是「每个品类只铺一次」,切设备时编排不跟着换—— + /// 表现是先连耳机(铺 11 个)再连香薰,香薰的三个编排永远铺不上。 + /// 别改回去。 /// - /// 三条蓝牙链路(蓝讯支架 / 恒玄耳机 / 杰理)连上后各自调用它, - /// 传自己的 [DeviceCategories] 品类 id。 + /// ⚠️ 老装机没有 [_lastAppliedKey] 这条记录,无法判断有没有动过, + /// 按「允许铺一次」处理:升级后第一次连设备会按品类重铺一次 AI 页。 + /// + /// 采用**整套替换**而不是追加:支架/香薰只有录音与开聊,AI 页就该只剩那几个; + /// 耳机能跑通话和翻译,就铺全量。追加式做不到「只显示这几个」。 bool applyDeviceDefaults(String? categoryId) { final defaults = AgentModules.defaultsForCategory(categoryId); - if (defaults.isEmpty) return false; + if (defaults.isEmpty || categoryId == null) return false; - final applied = (_storage.read(_appliedCategoriesKey) ?? const []) - .map((e) => e.toString()) - .toList(); - if (applied.contains(categoryId)) return false; - - // 按该设备的能力**整套铺开**,而不是往用户现有编排上追加: - // 支架/录音卡片只有录音能力,AI 页就该只剩三个;耳机能跑通话和翻译, - // 就铺全量。追加式做不到「只显示」这个要求。 final next = defaults .where((id) => AgentModules.byId(id) != null) .toList(growable: false); + if (next.isEmpty) return false; + + final hasRecord = _storage.hasData(_lastAppliedKey); + if (hasRecord) { + final last = (_storage.read(_lastAppliedKey) ?? const []) + .map((e) => e.toString()) + .toList(); + // 用户动过编排 → 不再自动改动 + if (!_sameOrder(enabledIds, last)) return false; + } + final changed = !_sameOrder(enabledIds, next); if (changed) enabledIds.assignAll(next); + _storage.write(_lastAppliedKey, next); - applied.add(categoryId!); - _storage.write(_appliedCategoriesKey, applied); + final applied = (_storage.read(_appliedCategoriesKey) ?? const []) + .map((e) => e.toString()) + .toList(); + if (!applied.contains(categoryId)) { + applied.add(categoryId); + _storage.write(_appliedCategoriesKey, applied); + } if (changed) _persist(); return changed; } diff --git a/apps/client/lib/data/services/assistant_directive_service.dart b/apps/client/lib/data/services/assistant_directive_service.dart index 578e4918..b6738a45 100644 --- a/apps/client/lib/data/services/assistant_directive_service.dart +++ b/apps/client/lib/data/services/assistant_directive_service.dart @@ -43,6 +43,14 @@ class AssistantDirectiveService extends GetxService { final GetStorage _storage = GetStorage(); + /// 各能力模块注册的指令处理器(`ModuleRegistry.directiveHandlers`)。 + /// 查不到的指令走 [_dispatch] 里的内置处理(SET_clock)或记 unsupported。 + final Map _handlers = {}; + + void registerHandlers(Map handlers) { + _handlers.addAll(handlers); + } + /// 指令流水,新的在前 final RxList directives = [].obs; @@ -98,6 +106,17 @@ class AssistantDirectiveService extends GetxService { return; } + final h = _handlers[d.name]; + if (h != null) { + try { + h(d); + } catch (e) { + d.status = DirectiveStatus.failed; + d.note = '模块处理失败: $e'; + Logger.e(_tag, '指令 ${d.name} 处理失败: $e'); + } + return; + } switch (d.name) { case 'SET_clock': _handleSetClock(d); diff --git a/apps/client/lib/data/services/bailian_multimodal_service.dart b/apps/client/lib/data/services/bailian_multimodal_service.dart index 4c30e7f5..8b63d86a 100644 --- a/apps/client/lib/data/services/bailian_multimodal_service.dart +++ b/apps/client/lib/data/services/bailian_multimodal_service.dart @@ -87,7 +87,26 @@ class BailianEvent { /// - `ALIBABA_OPENSPEECH_APP_KEY`:DashScope API Key(与语音翻译、拍照翻译共用),**没有默认值** /// - `ALIBABA_BAILIAN_WORKSPACE_ID`:业务空间 ID,默认 [_defaultWorkspaceId] /// - `ALIBABA_BAILIAN_APP_ID`:多模态应用 ID,默认 [_defaultAppId] +/// +/// ## 一个应用一个实例 +/// +/// [appId] / [workspaceId] 传了就用传的,**不传(默认)完全走上面那套全局配置**—— +/// 也就是说 EMAI 与设备唤醒会话的行为一个字节都没变。 +/// +/// 之所以做成实例级而不是改全局:不同 agent 用的是百炼里不同的应用 +/// (EMAI 一个、Smartcar 车载助手另一个),改全局值会把所有 agent 一起换掉。 +/// +/// ⚠️ 每个应用**必须各自 new 一个实例**,不能共用:`start()` 第一件事就是 `stop()`, +/// 共用会让两边互相把对方的会话掐掉,下行音频还会串到对方的播放器上。 class BailianMultimodalService extends GetxService { + BailianMultimodalService({String? appId, String? workspaceId}) + : _appIdOverride = appId, + _workspaceIdOverride = workspaceId; + + /// 实例级覆盖;null/空 = 用全局配置 + final String? _appIdOverride; + final String? _workspaceIdOverride; + static const String _tag = 'BailianMultimodal'; static BailianMultimodalService get to => Get.find(); @@ -132,11 +151,15 @@ class BailianMultimodalService extends GetxService { String get _apiKey => AppConfig.env('ALIBABA_OPENSPEECH_APP_KEY') ?? ''; String get _workspaceId { + final o = _workspaceIdOverride?.trim() ?? ''; + if (o.isNotEmpty) return o; final v = (AppConfig.env('ALIBABA_BAILIAN_WORKSPACE_ID') ?? '').trim(); return v.isEmpty ? _defaultWorkspaceId : v; } String get _appId { + final o = _appIdOverride?.trim() ?? ''; + if (o.isNotEmpty) return o; final v = (AppConfig.env('ALIBABA_BAILIAN_APP_ID') ?? '').trim(); return v.isEmpty ? _defaultAppId : v; } diff --git a/apps/client/lib/data/services/device_ai_session_service.dart b/apps/client/lib/data/services/device_ai_session_service.dart new file mode 100644 index 00000000..3d2bad31 --- /dev/null +++ b/apps/client/lib/data/services/device_ai_session_service.dart @@ -0,0 +1,666 @@ +import 'dart:async'; +import 'dart:typed_data'; + +import 'package:device_plugin_interface/device_plugin_interface.dart'; +import 'package:get/get.dart'; + +import '../../core/utils/logger.dart'; +import '../../devices/device_hub.dart'; +import '../models/agent_module.dart'; +import '../models/emai_message.dart'; +import '../models/user_Info.dart' as user_info; +import '../utils/cue_tone.dart'; +import 'agent_module_defaults.dart'; +import 'assistant_directive_service.dart'; +import 'bailian_multimodal_service.dart'; +import 'emai_chat_store.dart'; +import 'mcp_token_service.dart'; + +/// 设备按键唤醒的 AI 实时对话会话——**厂商无关**,要求设备具备 +/// [DeviceCapability.aiWake]。 +/// +/// 把「设备按键唤醒」接到阿里百炼多模态上:设备麦的音频由插件解码成 16k PCM +/// (`openMic(ai)`)推给百炼,百炼回的 16k PCM 交给 `openSpeaker(ai)` 由插件/原生 +/// 编码后按节奏发回设备播放。整条链路上这里只搬运 PCM。 +/// +/// **握手是设备主导的**,App 只能应答(恒玄时序,其它厂商在插件里对齐到同一语义): +/// ``` +/// 设备按键 ──► wakeEvents(ptt) 恒玄: BB 64 +/// App ──► ai.accept 恒玄: AA 6A,必须 3 秒内,否则设备停止送音频 +/// …会话进行中… +/// App ──► ai.exit 恒玄: AA 65 +/// 设备 ──► wakeEvents(hangup) 恒玄: BB E5;2 秒不回就本地强制结束 +/// ``` +/// +/// 常驻服务:按键在任意页面(含 App 在后台、EMAI 页没打开)都要能唤醒。 +class DeviceAiSessionService extends GetxService { + static const String _tag = 'DeviceAiSession'; + static DeviceAiSessionService get to => Get.find(); + + /// 设备侧的会话与手机 EMAI 页各用一条独立的 WebSocket。 + /// 共用一个单例的话两边会互相 stop 掉对方、下行音频也会串。 + /// + /// ⚠️ **接哪个百炼应用要看是哪台设备唤醒的**:耳机/支架接 EMAI,车载香薰接 + /// 「Smartcar 车载助手」(各自的 app_id 不同)。所以这里不能是 final 单例, + /// 由 [_ensureService] 按厂商换实例;app_id 没变就复用,不白开 WebSocket。 + BailianMultimodalService _svc = BailianMultimodalService(); + String? _svcAppId; + + /// 厂商 → 该接哪个 agent 的 app_id。走的是「厂商 → 品类 → 默认编排第一个模块 + /// → 模块声明的 bailianAppId」这条链,**设备层不认识 agent、agent 层不认识厂商**。 + /// 查不到返回 null = 用全局默认(EMAI)。 + String? _resolveAgentAppId(String vendor) { + try { + return bailianAppIdForModule(AgentDefaults.primaryModuleForVendor(vendor)); + } catch (e) { + Logger.w(_tag, '解析 agent app_id 失败(回退默认): $e'); + return null; + } + } + + /// 按需换一个指向目标 app_id 的服务实例。 + void _ensureService(String? appId) { + final want = (appId ?? '').trim(); + final cur = (_svcAppId ?? '').trim(); + if (want == cur) return; + Logger.i(_tag, + '切换设备侧百炼应用: ${cur.isEmpty ? '(默认)' : cur} → ${want.isEmpty ? '(默认)' : want}'); + // 旧实例可能还挂着 WebSocket,先停掉再丢 + unawaited(_svc.stop()); + _svc = BailianMultimodalService(appId: want.isEmpty ? null : want); + _svcAppId = want; + } + + /// 设备上行 PCM 就是 16k/16bit/mono,与百炼上行要求一致 + static const int _deviceRate = 16000; + + /// 上行攒够这么多字节再发一包:3200B = 100ms @16k。设备每 20ms 上来一包, + /// 逐包转发就是 50 条 WS 消息/秒,攒成 100ms 一条降到 1/5。 + static const int _micBatchBytes = 3200; + final BytesBuilder _micAccum = BytesBuilder(copy: false); + + /// 收到唤醒后必须在这个时间内应答。我们是立即回,这个常量只是把固件约束写在代码里。 + static const Duration _ackDeadline = Duration(seconds: 3); + + /// 发出 ai.exit 后等设备回 hangup 的时间,超时就本地强制结束 + static const Duration _stopAckTimeout = Duration(seconds: 2); + + StreamSubscription? _wakeSub; + StreamSubscription? _hubSub; + StreamSubscription? _micSub; + StreamSubscription? _eventSub; + StreamSubscription? _audioSub; + DeviceSession? _session; + DeviceAudioSource? _mic; + DeviceAudioSink? _spk; + Timer? _stopAckTimer; + Timer? _playoutTimer; + + /// 会话是否在跑(已应答,还没收到/发出结束) + final RxBool isActive = false.obs; + + /// 手机 EMAI 页(打开时由它自己 attach 上来)。 + /// 不用 `Get.put(controller)`:GetX 的 put 会对实例调 + /// onInit,而页面正是在自己的 onInit 里注册的,会递归到 Stack Overflow。 + EmaiSessionOwner? _phonePage; + + int _respondingBytes = 0; + DateTime? _respondingStartedAt; + + /// 上行是否被门控住(AI 正在设备里播报期间)。 + /// 设备没有把下发的音频接进自己的 AEC,播出去的 AI 声音会被麦克风原样收回, + /// duplex 模式下服务端 VAD 把它当成用户插话。没有 AEC 就只能做半双工。 + bool _uplinkGated = false; + Timer? _gateTimer; + bool _tearingDown = false; + static const Duration _gateTail = Duration(milliseconds: 500); + static const Duration _idleTimeout = Duration(seconds: 5); + Timer? _idleTimer; + DateTime? _firstAudioAt; + int _micPackets = 0; + int _downPackets = 0; + + EmaiChatStore get _store => EmaiChatStore.to; + EmaiMessage? _streamingUser; + EmaiMessage? _streamingAssistant; + + @override + void onInit() { + super.onInit(); + final hub = DeviceHub.to; + _wakeSub = hub.wakeEvents.listen(_onWake, onError: (e) { + Logger.e(_tag, '唤醒事件流错误: $e'); + }); + _hubSub = hub.sessionEvents.listen((e) { + if (e.type == DeviceSessionEventType.connectionStateChanged && + e.connectionState == DeviceConnectionState.disconnected && + isActive.value && + _session?.deviceId == e.deviceId) { + Logger.w(_tag, '设备断连,结束 AI 会话'); + _teardown(notifyDevice: false, playEndTone: false); + } + }); + Logger.i(_tag, '设备 AI 会话服务已就绪,等待按键唤醒'); + } + + @override + void onClose() { + _wakeSub?.cancel(); + _hubSub?.cancel(); + _teardown(notifyDevice: true, playEndTone: false); + super.onClose(); + } + + // ---------------- 设备事件 ---------------- + + void _onWake(DeviceWakeEvent e) { + switch (e.reason) { + case WakeReason.ptt: + case WakeReason.voiceWake: + _handleStart(e.deviceId); + break; + case WakeReason.hangup: + Logger.i(_tag, '设备通知结束 AI'); + _teardown(notifyDevice: false); + break; + default: + break; + } + } + + void _onMicPcm(Uint8List pcm) { + if (!isActive.value || pcm.isEmpty) return; + _micPackets++; + if (_micPackets == 1 || _micPackets % 50 == 0) { + Logger.i(_tag, + '设备上行 #$_micPackets (${pcm.length}B/包)${_uplinkGated ? ' [门控中]' : ''}'); + } + // 门控期间推等长静音而不是断流:上行保持连续,服务端 VAD 收到的是 + // "真的没人说话",而不是一段数据缺口 + _micAccum.add(_uplinkGated ? Uint8List(pcm.length) : pcm); + if (_micAccum.length >= _micBatchBytes) { + _svc.pushAudio(_micAccum.takeBytes()); + } + } + + Future _handleStart(String deviceId) async { + if (isActive.value) { + Logger.w(_tag, '已有会话在跑,忽略重复唤醒'); + return; + } + final hub = DeviceHub.to; + final session = + hub.sessions.firstWhereOrNull((s) => s.deviceId == deviceId) ?? + hub.sessionWith(DeviceCapability.aiWake); + if (session == null || session.state != DeviceConnectionState.ready) { + Logger.e(_tag, '设备未处于已连接状态,拒绝本次唤醒'); + return; + } + // ★ 先按厂商选中该说话的那个 agent(车载香薰 → Smartcar 车载助手), + // 再去看 isConfigured —— 反过来的话检查的是上一台设备的应用 + _ensureService(_resolveAgentAppId(session.vendor)); + Logger.i(_tag, + '设备按键唤醒 AI (${session.vendor},应用=${_svcAppId?.isNotEmpty == true ? _svcAppId : '默认'})'); + + // 起不来就别应答,直接让设备退出 AI 模式——应答之后设备会一直送音频, + // 而我们这边没有会话接,它会白等到超时 + if (!_svc.isConfigured) { + Logger.e(_tag, '百炼未配置(${_svc.configHint}),拒绝本次唤醒'); + await _sendExit(session); + return; + } + + // 固件要求 3 秒内确认,先应答再做后面的初始化。 + // 反过来的话,百炼握手(一次 WebSocket 往返)可能就吃掉大半个预算。 + final ackAt = DateTime.now(); + _session = session; + try { + await session.invokeFeature(DeviceFeatures.aiAccept); + } catch (e) { + Logger.e(_tag, '应答唤醒失败: $e'); + _session = null; + return; + } + isActive.value = true; + // 换了设备就重新试一次 ai.state(上一台不支持不代表这台也不支持) + _aiStateUnsupported = false; + _quitAfterPlayout = false; + _micPackets = 0; + _downPackets = 0; + _uplinkGated = false; + _micAccum.clear(); + + await _yieldPhonePage(); + + try { + _mic = await session.openMic(route: AudioRoute.ai); + _micSub = _mic!.pcm.listen(_onMicPcm); + _eventSub = _svc.events.listen(_onBailianEvent); + // 下行 16k:G.722 编码器就是 16k,让服务端直接出 16k 省掉一次重采样 + _audioSub = _svc.audioOut.listen(_onDownstreamPcm); + _spk = await session.openSpeaker(route: AudioRoute.ai); + } catch (e) { + Logger.e(_tag, '打开设备音频通路失败: $e'); + await _teardown(notifyDevice: true, playEndTone: false); + return; + } + // 先给一声「叮」,让用户立刻知道已经在听了——百炼握手还要一次往返 + await _playCue(CueTone.ding(sampleRate: _deviceRate)); + _touchActivity(); + + final ok = await _svc.start( + mode: 'duplex', + downstreamRate: _deviceRate, + userId: _currentUserId(), + source: 'earphone', + mcpToken: await McpTokenService.get(), + ); + final elapsed = DateTime.now().difference(ackAt); + if (elapsed > _ackDeadline) { + Logger.w( + _tag, + '会话初始化耗时 ${elapsed.inMilliseconds}ms,已超过固件 ' + '${_ackDeadline.inSeconds}s 预算(应答已提前回,通常无碍)'); + } + if (!ok) { + Logger.e(_tag, '百炼会话启动失败,结束'); + await _teardown(notifyDevice: true); + } + } + + // ---------------- 百炼事件 ---------------- + + void _onBailianEvent(BailianEvent e) { + Logger.i(_tag, '百炼事件 $e'); + switch (e.type) { + case BailianEventType.speechStarted: + case BailianEventType.speechContent: + case BailianEventType.speechEnded: + case BailianEventType.respondingStarted: + case BailianEventType.respondingContent: + case BailianEventType.respondingEnded: + _touchActivity(); + break; + default: + break; + } + switch (e.type) { + case BailianEventType.speechStarted: + // 只有真人插话才丢队列。门控期间的 speechStarted 是 AI 自己的回声触发的 + if (!_uplinkGated) { + _spk?.discardPending(); + _cancelPlayoutTimer(); + } + _streamingUser = null; + break; + + case BailianEventType.speechContent: + _streamingUser ??= _store + .append(EmaiMessage(isUser: true, text: '', fromEarphone: true)); + _store.applyText(_streamingUser!, e.text); + if (e.isFinal) { + _store.finalize(_streamingUser); + _streamingUser = null; + } + break; + + case BailianEventType.speechEnded: + if (e.text.isNotEmpty && _streamingUser != null) { + _store.applyText(_streamingUser!, e.text); + } + _store.finalize(_streamingUser); + _streamingUser = null; + break; + + case BailianEventType.respondingStarted: + _respondingBytes = 0; + _firstAudioAt = null; + _respondingStartedAt = DateTime.now(); + _cancelPlayoutTimer(); + _streamingAssistant = null; + break; + + case BailianEventType.respondingContent: + if (e.directives.isNotEmpty && + Get.isRegistered()) { + AssistantDirectiveService.to.handle(e.directives); + } + // 「关闭对话」:模型会下发 quit 指令并同时回一句告别语。 + // 不能立刻结束——那样告别语还没播完就被掐了;标记下来,等播完再收尾。 + if (e.directives.any((d) => d.name == _quitDirective)) { + Logger.i(_tag, '收到 quit 指令,本轮播完后结束会话'); + _quitAfterPlayout = true; + } + // 纯指令帧没有文本,别建空气泡 + if (e.text.isEmpty && e.finishReason == 'command_calls') break; + _streamingAssistant ??= _store + .append(EmaiMessage(isUser: false, text: '', fromEarphone: true)); + _store.applyText(_streamingAssistant!, e.text); + break; + + case BailianEventType.respondingEnded: + if (e.text.isNotEmpty && _streamingAssistant != null) { + _store.applyText(_streamingAssistant!, e.text); + } + _store.finalize(_streamingAssistant); + _streamingAssistant = null; + _logResponseAudioStats(); + _schedulePlayoutDone(); + break; + + case BailianEventType.error: + Logger.e(_tag, '会话错误: ${e.code} ${e.text} fatal=${e.fatal}'); + _store.dropIfEmpty(_streamingUser); + _streamingUser = null; + _store.dropIfEmpty(_streamingAssistant); + _streamingAssistant = null; + if (e.text.isNotEmpty) { + _store.append(EmaiMessage( + isUser: false, + text: e.text, + isFinal: true, + isError: true, + fromEarphone: true)); + _store.persist(); + } + if (e.fatal) _teardown(notifyDevice: true); + break; + + case BailianEventType.stateChanged: + // 把会话状态同步给设备,驱动它的指示灯/表情动画。 + // ⚠️ 只有声明了这个 feature 的厂商会真发字节,其余抛 not_supported 被吞掉。 + switch (e.state) { + case BailianDialogState.listening: + _pushAiState(DeviceAiState.listening); + break; + case BailianDialogState.thinking: + _pushAiState(DeviceAiState.thinking); + break; + case BailianDialogState.responding: + _pushAiState(DeviceAiState.speaking); + break; + default: + break; + } + break; + + case BailianEventType.closed: + if (isActive.value) { + Logger.w(_tag, '百炼会话关闭,结束设备 AI'); + _teardown(notifyDevice: true); + } + break; + + default: + break; + } + } + + void _onDownstreamPcm(Uint8List pcm) { + if (!isActive.value || pcm.isEmpty) return; + if (_firstAudioAt == null) { + _firstAudioAt = DateTime.now(); + _openGate(); + } + _respondingBytes += pcm.length; + _downPackets++; + if (_downPackets == 1 || _downPackets % 50 == 0) { + Logger.i(_tag, '下行给设备 #$_downPackets (${pcm.length}B)'); + } + _spk?.write(pcm); + } + + /// 有动静就把静默计时重新拨回 5 秒。播报期间不计时。 + /// 设备是否支持 [DeviceFeatures.aiState]。不支持就别每次都试、别每次都打日志。 + bool _aiStateUnsupported = false; + + /// 模型下发的「结束对话」指令名(`extra_info.commands` 里的 `name`) + static const String _quitDirective = 'quit'; + + /// 收到 quit 指令,等这一轮告别语播完就收尾 + bool _quitAfterPlayout = false; + + /// 把 AI 会话状态推给设备(驱动指示灯/表情)。 + /// + /// **fire-and-forget**:设备不支持、或这条命令失败,都不该影响对话本身, + /// 所以既不 await 也不往上抛。 + void _pushAiState(DeviceAiState state) { + if (_aiStateUnsupported) return; + final s = _session; + if (s == null || s.state != DeviceConnectionState.ready) return; + s.invokeFeature(DeviceFeatures.aiState, {'state': state}).catchError( + (Object e) { + if (e is DeviceException && e.code == DeviceErrorCode.notSupported) { + // 这台设备没有表情/指示灯,记一次就够了 + _aiStateUnsupported = true; + Logger.i(_tag, '设备不支持 ai.state,后续不再推送会话状态'); + } else { + Logger.w(_tag, '推送 ai.state($state) 失败: $e'); + } + return {}; + }, + ); + } + + void _touchActivity() { + _idleTimer?.cancel(); + _idleTimer = null; + if (!isActive.value || _uplinkGated) return; + _idleTimer = Timer(_idleTimeout, () { + if (!isActive.value) return; + Logger.i(_tag, '双方 ${_idleTimeout.inSeconds}s 无声,自动结束 AI 会话'); + requestStop(); + }); + } + + /// 在设备里放一段提示音(走 AI 下行通道)。播放期间开门控。 + Future _playCue(Uint8List pcm, {bool waitForPlayout = false}) async { + final spk = _spk; + if (spk == null || _session?.state != DeviceConnectionState.ready) return; + _openGate(); + await spk.write(pcm); + final ms = pcm.length * 1000 ~/ (_deviceRate * 2); + if (waitForPlayout) { + // 结束音要等它真的发完+播完,否则紧接着关通道会把它清掉 + await Future.delayed(Duration(milliseconds: ms + 400)); + } else { + _gateTimer?.cancel(); + _gateTimer = Timer(Duration(milliseconds: ms + 300), _closeGate); + } + } + + void _openGate() { + _gateTimer?.cancel(); + _gateTimer = null; + if (_uplinkGated) return; + _uplinkGated = true; + _idleTimer?.cancel(); + _idleTimer = null; + Logger.i(_tag, '上行门控开启(AI 播报中)'); + } + + void _closeGate() { + _gateTimer?.cancel(); + _gateTimer = null; + if (!_uplinkGated) return; + _uplinkGated = false; + Logger.i(_tag, '上行门控解除'); + _touchActivity(); + } + + void _logResponseAudioStats() { + final start = _firstAudioAt; + if (start == null || _respondingBytes == 0) return; + final ms = DateTime.now().difference(start).inMilliseconds; + final audioMs = _respondingBytes * 1000 ~/ (_deviceRate * 2); + Logger.i(_tag, + '本轮下行音频 ${_respondingBytes}B ≈ ${audioMs}ms(按16k算),实际用时 ${ms}ms'); + } + + /// 估算设备什么时候把这一轮播完,然后回 `LocalRespondingEnded`。 + void _schedulePlayoutDone() { + _cancelPlayoutTimer(); + const bytesPerSecond = _deviceRate * 2; + final totalMs = _respondingBytes * 1000 ~/ bytesPerSecond; + final elapsedMs = DateTime.now() + .difference(_respondingStartedAt ?? DateTime.now()) + .inMilliseconds; + // 上限 10 分钟,跟原生下行队列的容量对齐 + final remainMs = (totalMs - elapsedMs).clamp(0, 600000); + Logger.i(_tag, '预计还需 ${remainMs}ms 播完(已推 ${totalMs}ms 音频)'); + _playoutTimer = Timer(Duration(milliseconds: remainMs), () { + if (!isActive.value) return; + _svc.notifyLocalRespondingEnded(); + _gateTimer = Timer(_gateTail, _closeGate); + if (_quitAfterPlayout) { + _quitAfterPlayout = false; + Logger.i(_tag, '告别语已播完,按 quit 指令结束会话'); + _teardown(notifyDevice: true, playEndTone: false); + } + }); + } + + void _cancelPlayoutTimer() { + _playoutTimer?.cancel(); + _playoutTimer = null; + } + + // ---------------- 结束 ---------------- + + /// 主动结束:发 ai.exit,等设备回 hangup;超时就本地强制收尾。 + Future requestStop() async { + if (!isActive.value) return; + Logger.i(_tag, '请求设备退出 AI 模式'); + final s = _session; + if (s != null) await _sendExit(s); + _stopAckTimer?.cancel(); + _stopAckTimer = Timer(_stopAckTimeout, () { + if (!isActive.value) return; + Logger.w(_tag, '${_stopAckTimeout.inSeconds}s 未收到设备的结束应答,本地强制结束'); + _teardown(notifyDevice: false); + }); + } + + Future _sendExit(DeviceSession s) async { + if (s.state != DeviceConnectionState.ready) return; + try { + await s.invokeFeature(DeviceFeatures.aiExit); + } catch (e) { + Logger.w(_tag, '发送 ai.exit 失败: $e'); + } + } + + /// [notifyDevice] 为 true 时补发 ai.exit:不是设备主导的结束,必须告诉设备退出。 + /// [playEndTone] 为 false 用于设备已经断连的场景。 + Future _teardown({ + required bool notifyDevice, + bool playEndTone = true, + }) async { + if (_tearingDown) return; + _tearingDown = true; + try { + await _doTeardown(notifyDevice: notifyDevice, playEndTone: playEndTone); + } finally { + _tearingDown = false; + } + } + + Future _doTeardown({ + required bool notifyDevice, + bool playEndTone = true, + }) async { + _stopAckTimer?.cancel(); + _stopAckTimer = null; + _idleTimer?.cancel(); + _idleTimer = null; + _cancelPlayoutTimer(); + _micAccum.clear(); + + if (isActive.value && playEndTone) { + // 先把还没播完的 AI 语音丢掉,不然「咚」要排在几十秒的队尾 + await _spk?.discardPending(); + await _playCue(CueTone.dong(sampleRate: _deviceRate), waitForPlayout: true); + } + _closeGate(); + + final wasActive = isActive.value; + isActive.value = false; + + _store.dropIfEmpty(_streamingUser); + _streamingUser = null; + _store.dropIfEmpty(_streamingAssistant); + _streamingAssistant = null; + if (wasActive) _store.persist(); + + await _eventSub?.cancel(); + _eventSub = null; + await _audioSub?.cancel(); + _audioSub = null; + await _micSub?.cancel(); + _micSub = null; + + await _spk?.close(); + _spk = null; + await _mic?.close(); + _mic = null; + await _svc.stop(); + + final s = _session; + if (wasActive && notifyDevice && s != null) await _sendExit(s); + _session = null; + if (wasActive) { + Logger.i(_tag, '设备 AI 会话已结束 (上行 $_micPackets 包 / 下行 $_downPackets 包)'); + await _resumePhonePage(); + } + } + + // ---------------- 与手机 EMAI 页互斥 ---------------- + + void attachPhonePage(EmaiSessionOwner page) => _phonePage = page; + + void detachPhonePage(EmaiSessionOwner page) { + if (identical(_phonePage, page)) _phonePage = null; + } + + Future _yieldPhonePage() async { + final ctrl = _phonePage; + if (ctrl == null) return; + Logger.i(_tag, '手机 EMAI 页正开着,让它让出会话'); + try { + await ctrl.yieldToEarphone(); + } catch (e) { + Logger.w(_tag, '通知 EMAI 页让出失败: $e'); + } + } + + Future _resumePhonePage() async { + final ctrl = _phonePage; + if (ctrl == null) return; + try { + await ctrl.resumeFromEarphone(); + } catch (e) { + Logger.w(_tag, '通知 EMAI 页恢复失败: $e'); + } + } + + String _currentUserId() { + try { + return user_info.User.isLoggedIn() + ? user_info.User.instance.uid + : 'eaimar-guest'; + } catch (_) { + return 'eaimar-guest'; + } + } +} + +/// 手机 EMAI 页实现这个接口,让设备会话能在不反向依赖 modules 层的前提下 +/// 通知它让出/恢复。EmaiController 在 onInit/onClose 里 +/// 调 [DeviceAiSessionService.attachPhonePage] / [DeviceAiSessionService.detachPhonePage]。 +abstract class EmaiSessionOwner { + /// 设备接管:停掉本页的播放与会话,并且不要自动重连 + Future yieldToEarphone(); + + /// 设备结束:把本页的会话接回来 + Future resumeFromEarphone(); +} diff --git a/apps/client/lib/data/services/device_call_recording_service.dart b/apps/client/lib/data/services/device_call_recording_service.dart new file mode 100644 index 00000000..da25dd18 --- /dev/null +++ b/apps/client/lib/data/services/device_call_recording_service.dart @@ -0,0 +1,296 @@ +import 'dart:async'; +import 'dart:io'; +import 'dart:typed_data'; + +import 'package:device_plugin_interface/device_plugin_interface.dart'; +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 '../../devices/device_hub.dart'; +import 'recording_archive.dart'; +import 'wav_writer.dart'; + +/// **通话录音**的全局服务——厂商无关,要求设备具备 [DeviceCapability.callAudioTap]。 +/// +/// 两路音频 `openMic(callLocal)`(本端麦)+ `openMic(callPeer)`(对端声音), +/// 落成**双声道 WAV:左=本端,右=对端**。恒玄是 G.722 mode=0x03 交织上行, +/// 原生按帧标好 mic/spk 后分两路给出,这里按到达顺序配对。 +/// +/// 只在通话中可用([DeviceHub.inCall]),通话结束自动收尾。 +class DeviceCallRecordingService extends GetxService { + static const String _tag = 'DeviceCallRecording'; + static DeviceCallRecordingService get to => Get.find(); + + final RxBool isRecording = false.obs; + final RxInt elapsedMs = 0.obs; + final RxDouble level = 0.0.obs; + final RxList waveform = [].obs; + static const int sampleIntervalMs = 100; + static const int _maxSamples = 600; + + StreamSubscription? _hubSub; + Worker? _callWorker; + DeviceSession? _session; + DeviceAudioSource? _local; + DeviceAudioSource? _peer; + StreamSubscription? _localSub; + StreamSubscription? _peerSub; + + /// 暂存的对端帧,等本端帧到齐后一起交织写盘 + Uint8List? _pendingPeer; + int _frameCount = 0; + Timer? _silenceCheck; + WavWriter? _wav; + String? _path; + Timer? _tick; + Timer? _sampleTimer; + final Stopwatch _stopwatch = Stopwatch(); + bool _busy = false; + + /// 本次录音是不是设备按键发起的。设备侧处于录音态时,停止要额外补一条 + /// `record.stop` 让它自己也退出来,只发 `call.stop` 不足以清掉设备端的录音状态。 + bool _deviceRecordingActive = false; + + @override + void onInit() { + super.onInit(); + _hubSub = DeviceHub.to.sessionEvents.listen(_onSessionEvent); + // 通话结束时如果还在录,收尾归档(不能让文件一直挂着) + _callWorker = ever(DeviceHub.to.inCall, (calling) async { + if (!calling && isRecording.value && !_busy) { + Logger.i(_tag, '通话结束,停止并归档'); + await stop(sendCommand: true); + } + }); + } + + @override + void onClose() { + _hubSub?.cancel(); + _callWorker?.dispose(); + _localSub?.cancel(); + _peerSub?.cancel(); + _tick?.cancel(); + _sampleTimer?.cancel(); + super.onClose(); + } + + void _onSessionEvent(DeviceSessionEvent e) async { + switch (e.type) { + case DeviceSessionEventType.feature: + if (_busy) return; + final key = e.feature?.key; + // 通话开始**不再自动录音**。入口只有两个:录音页点开始、设备按录音键。 + if (key == DeviceFeatureKeys.recordStarted && !isRecording.value) { + if (!DeviceHub.to.inCall.value) return; // 不在通话里的按键归现场录音管 + Logger.i(_tag, '设备端按下录音键,开始通话录音'); + _deviceRecordingActive = true; + await start(sendCommand: true, deviceId: e.deviceId); + } else if (key == DeviceFeatureKeys.recordStopped && isRecording.value) { + Logger.i(_tag, '设备端结束录音,落盘并归档'); + _deviceRecordingActive = false; + await stop(sendCommand: true); + } + break; + case DeviceSessionEventType.connectionStateChanged: + if (e.connectionState == DeviceConnectionState.disconnected && + isRecording.value && + _session?.deviceId == e.deviceId) { + Logger.w(_tag, '设备断开,结束当前通话录音'); + stop(); + } + break; + default: + break; + } + } + + Future start({bool sendCommand = false, String? deviceId}) async { + if (isRecording.value || _busy) return isRecording.value; + _busy = true; + try { + return await _start(sendCommand, deviceId); + } finally { + _busy = false; + } + } + + Future _start(bool sendCommand, String? deviceId) async { + final hub = DeviceHub.to; + final session = (deviceId == null + ? null + : hub.sessions.firstWhereOrNull((s) => s.deviceId == deviceId)) ?? + hub.sessionWith(DeviceCapability.callAudioTap); + if (session == null) return false; + _session = session; + + try { + final docs = await getApplicationDocumentsDirectory(); + final dir = Directory('${docs.path}/call_recordings'); + if (!await dir.exists()) await dir.create(recursive: true); + final ts = DateFormat('yyyyMMdd_HHmmss').format(DateTime.now()); + _path = '${dir.path}/call_$ts.wav'; + _wav = await WavWriter.open(_path!, channels: 2); + + _pendingPeer = null; + _frameCount = 0; + _peer = await session.openMic(route: AudioRoute.callPeer); + _local = await session.openMic(route: AudioRoute.callLocal); + _peerSub = _peer!.pcm.listen(_onPeer); + _localSub = _local!.pcm.listen(_onLocal); + + // 进通话模式,设备才会双路上行。不管是不是 App 主动发起的都补发一次—— + // 只靠设备自己进通话模式时,它未必会把音频回传给 App。 + await session.invokeFeature(DeviceFeatures.callStart); + + // 2 秒还没收到任何音频帧就再补一次指令,并把情况打进日志 + _silenceCheck?.cancel(); + _silenceCheck = Timer(const Duration(seconds: 2), () async { + if (!isRecording.value) return; + if (_frameCount == 0) { + Logger.w(_tag, '进通话模式 2s 内没收到任何音频帧,重发 call.start'); + try { + await session.invokeFeature(DeviceFeatures.callStart); + } catch (_) {} + } else { + Logger.i(_tag, '通话音频已上行,2s 内收到 $_frameCount 帧'); + } + }); + + // 把进程钉住,否则切后台后录音会被国内 ROM 悄悄掐断 + unawaited(AudioForegroundService.start()); + + isRecording.value = true; + elapsedMs.value = 0; + level.value = 0; + waveform.clear(); + _stopwatch + ..reset() + ..start(); + _tick = Timer.periodic(const Duration(milliseconds: 200), (_) { + elapsedMs.value = _stopwatch.elapsedMilliseconds; + }); + _sampleTimer = Timer.periodic( + const Duration(milliseconds: sampleIntervalMs), + (_) => _sample(), + ); + Logger.i(_tag, '开始录制: $_path'); + return true; + } catch (e) { + Logger.e(_tag, '启动本地采集失败: $e'); + await _closeFile(save: false); + return false; + } + } + + Future stop({bool sendCommand = false}) async { + if (!isRecording.value || _busy) return; + _busy = true; + try { + await _stop(sendCommand); + } finally { + _busy = false; + } + } + + Future _stop(bool sendCommand) async { + _silenceCheck?.cancel(); + Logger.i(_tag, '停止通话录音: 收到 $_frameCount 帧,写入 ${_wav?.dataBytes} 字节'); + _tick?.cancel(); + _sampleTimer?.cancel(); + _stopwatch.stop(); + + final int seconds = elapsedMs.value ~/ 1000; + final String? path = _path; + final session = _session; + + if (sendCommand && + session != null && + session.state == DeviceConnectionState.ready) { + try { + // 设备侧还在录音态时先让它停录,再退出通话模式。顺序不能反: + // 先退通话模式的话,录音停止指令未必还能被正确处理。 + if (_deviceRecordingActive) { + await session.invokeFeature(DeviceFeatures.recordStop); + Logger.i(_tag, '已下发 record.stop,通知设备退出录音态'); + } + await session.invokeFeature(DeviceFeatures.callStop); + } catch (e) { + Logger.e(_tag, '下发停止指令失败: $e'); + } + } + _deviceRecordingActive = false; + + final dataBytes = _wav?.dataBytes ?? 0; + await _closeFile(save: true); + unawaited(AudioForegroundService.stop()); + isRecording.value = false; + elapsedMs.value = 0; + level.value = 0; + waveform.clear(); + _session = null; + + if (path != null && dataBytes > 0) { + await RecordingArchive.archive(path: path, seconds: seconds, tag: _tag); + } else { + Logger.w(_tag, '本次没有收到音频数据,删除空文件'); + if (path != null) { + try { + final f = File(path); + if (await f.exists()) await f.delete(); + } catch (e) { + Logger.e(_tag, '删除空录音文件失败: $e'); + } + } + } + _path = null; + } + + void _onPeer(Uint8List pcm) { + if (_wav == null || pcm.isEmpty) return; + _frameCount++; + _pendingPeer = pcm; + } + + void _onLocal(Uint8List pcm) { + if (_wav == null || pcm.isEmpty) return; + _frameCount++; + if (_frameCount <= 3 || _frameCount % 200 == 0) { + Logger.d(_tag, '收到通话音频帧 #$_frameCount len=${pcm.length}'); + } + final peer = _pendingPeer; + _pendingPeer = null; + if (peer == null) return; + // 左声道=本端麦克风,右声道=对端声音 + _wav!.write(WavWriter.interleave(pcm, peer), onWritten: () { + level.value = WavWriter.rmsLevel(pcm); + }); + } + + void _sample() { + final list = waveform.toList(); + list.add(level.value); + if (list.length > _maxSamples) { + list.removeRange(0, list.length - _maxSamples); + } + waveform.assignAll(list); + } + + Future _closeFile({required bool save}) async { + await _localSub?.cancel(); + await _peerSub?.cancel(); + _localSub = null; + _peerSub = null; + await _local?.close(); + await _peer?.close(); + _local = null; + _peer = null; + final w = _wav; + _wav = null; + if (w == null) return; + await w.close(save: save); + } +} diff --git a/apps/client/lib/data/services/device_file_import_service.dart b/apps/client/lib/data/services/device_file_import_service.dart new file mode 100644 index 00000000..f1fe1a50 --- /dev/null +++ b/apps/client/lib/data/services/device_file_import_service.dart @@ -0,0 +1,489 @@ +import 'dart:async'; +import 'dart:io'; + +import 'package:device_plugin_interface/device_plugin_interface.dart'; +import 'package:get/get.dart'; +import 'package:get_storage/get_storage.dart'; +import 'package:path/path.dart' as p; +import 'package:path_provider/path_provider.dart'; + +import '../../core/utils/logger.dart'; +import '../../devices/device_hub.dart'; +import '../utils/device_file_match.dart'; +import '../utils/ogg_opus_duration.dart'; +import 'db/sqflite_api.dart'; +import 'recording_archive.dart'; + +/// 把「设备自己录在本机存储里的音频」同步进语音纪要 —— **厂商无关**。 +/// +/// 适用于任何声明了 [DeviceCapability.fileStore] 的设备。当前只有 EaiRec 支架 +/// (TF 卡,通道 B),但录音笔、眼镜都是同一个形态,接进来零改动。 +/// +/// 触发时机三个,缺一不可: +/// 1. 设备上线(连上后延时几秒,等厂商插件把文件通道握完手); +/// 2. 设备**主动推送**文件列表——多数硬件在「录音停止」后推一次, +/// 这是「刚录完立刻出现在列表里」的唯一及时来源; +/// 3. 用户在语音纪要页手动下拉/点同步([importNow])。 +/// +/// **导入成功并确认已上传服务端之后,会把设备上的原件删掉**([autoDeleteAfterImport])。 +/// +/// ⚠️ 这一步是必须的,不是可选的优化:协议里**没有任何自动回收/循环覆盖机制** +/// (EaiRec_protocol.md §5.3 有 `NO_SPACE = 0x08`,§5.10 的 `FILE_DELETE` 是 +/// 唯一的单文件清理手段且**只能 App 主动发**)。不删,卡就一直涨到满, +/// 然后设备直接录不进去。 +/// +/// ⚠️ **删除的门槛是「已经传到服务端」,不是「已经落到手机上」**: +/// 判据是本地库里这条记录的 `audiourl` 非空([MeetingUploadService] 上传成功后写的)。 +/// 所以删除通常发生在**后面某一轮**同步,不是导入的那一刻。 +/// 断网、上传失败、上传队列还没轮到 —— 这些情况下设备原件一律不动, +/// 这是刻意选的失败方向:宁可卡满要人工清,也不能把用户唯一那份录音销毁掉。 +class DeviceFileImportService extends GetxService { + static const String _tag = 'DeviceFileImport'; + + static DeviceFileImportService get to => Get.find(); + + /// 已导入的文件指纹(`设备id|文件名|字节数`)→ 语音纪要里的记录 id。 + /// + /// ⚠️ 指纹带上字节数是有意的:8.3 短文件名会**回绕复用**(`MIC0001.OGG` 删掉后 + /// 下次录音可能又叫这个名),只按文件名记会把新录音当成已导入而永远跳过。 + /// + /// 记 id 而不只是记「导过了」,是因为删除设备原件前要回查这条记录传上去没有。 + static const String _doneKey = 'device_files_imported'; + + /// 导入并确认上传后,删掉设备上的原件。默认开,理由见类文档。 + static const String _autoDeleteKey = 'device_files_auto_delete'; + + bool get autoDeleteAfterImport => + _storage.read(_autoDeleteKey) ?? true; + + set autoDeleteAfterImport(bool v) => _storage.write(_autoDeleteKey, v); + + /// 本轮清理掉的设备文件数,UI 可用来提示「已释放空间」 + final RxInt deletedCount = 0.obs; + + final GetStorage _storage = GetStorage(); + + /// 正在导入 + final RxBool isImporting = false.obs; + + /// 当前正在拉的文件名(没有则空串) + final RxString currentFile = ''.obs; + + /// 当前文件的进度 0~1;设备没给总长度时为 -1(不确定态) + final RxDouble progress = 0.0.obs; + + /// 本轮还剩几个没拉 + final RxInt pending = 0.obs; + + /// 最近一次失败原因,成功时清空。UI 可以拿它提示,但**不要弹窗**—— + /// 这是后台同步,失败下次连上还会再来。 + final RxString lastError = ''.obs; + + /// 本进程内累计导入成功的条数,UI 用它做「同步完成」提示 + final RxInt importedCount = 0.obs; + + Worker? _sessionsWorker; + StreamSubscription>? _pushSub; + Timer? _debounce; + DeviceFileCancelToken? _cancel; + bool _running = false; + + /// 已经挂上推送订阅的那个 session 的 deviceId,避免重复订阅 + String _boundDeviceId = ''; + + @override + void onInit() { + super.onInit(); + _sessionsWorker = ever>( + DeviceHub.to.sessions, + (_) => _onSessionsChanged(), + ); + _onSessionsChanged(); + } + + @override + void onClose() { + _sessionsWorker?.dispose(); + _pushSub?.cancel(); + _debounce?.cancel(); + _cancel?.cancel(); + super.onClose(); + } + + // ---------------- 触发 ---------------- + + void _onSessionsChanged() { + final s = DeviceHub.to.sessionWith(DeviceCapability.fileStore); + final store = s?.fileStore(); + if (s == null || store == null) { + _pushSub?.cancel(); + _pushSub = null; + _boundDeviceId = ''; + // 设备走了就把在途传输掐掉,不然会在超时里卡好几十秒 + _cancel?.cancel(); + return; + } + if (_boundDeviceId == s.deviceId) return; + _boundDeviceId = s.deviceId; + _pushSub?.cancel(); + _pushSub = store.listPushes.listen( + (_) { + Logger.i(_tag, '设备推送了文件列表,安排一次同步'); + _schedule(const Duration(seconds: 1)); + }, + onError: (Object e) => Logger.w(_tag, '文件列表推送流错误: $e'), + ); + // ⚠️ 连上后不能立刻拉:厂商插件那边通道还在握手(支架要对时 + 查状态), + // 这几秒省不掉,抢在前面只会拿到一个「通道不可用」。 + _schedule(const Duration(seconds: 5)); + } + + void _schedule(Duration delay) { + _debounce?.cancel(); + _debounce = Timer(delay, () => unawaited(importNow())); + } + + /// 「导完了但还没删掉」时的回扫次数上限。 + /// + /// ⚠️ 刚导完那一轮基本删不掉任何东西:上传还在 [MeetingUploadService] 的队列里, + /// 而删除的门槛是「已上传」。所以要过几分钟再回来收一次。 + /// **必须有上限**:上传一直失败(没网/额度用尽)的话,无上限就是每 3 分钟 + /// 白拉一次 FILE_LIST,永远停不下来。删掉东西或导入新文件会把计数清零。 + static const int _maxCleanupRetries = 5; + static const Duration _cleanupRetryDelay = Duration(minutes: 3); + int _cleanupRetries = 0; + + /// 本轮导入成功的条数([importedCount] 是进程累计值,两者别混) + int _importedThisSweep = 0; + + /// 设备上还有「导过、但还没删」的文件时,安排一次回扫。 + void _scheduleCleanupRetry({required bool madeProgress}) { + if (!autoDeleteAfterImport) return; + if (madeProgress) _cleanupRetries = 0; + if (_doneMap().isEmpty) return; // 设备上没有我们导过的文件了 + if (_cleanupRetries >= _maxCleanupRetries) { + Logger.i(_tag, '回扫 $_cleanupRetries 次仍无可删文件(多半是上传没完成),暂停'); + return; + } + _cleanupRetries++; + Logger.i(_tag, '安排第 $_cleanupRetries 次清理回扫(${_cleanupRetryDelay.inMinutes} 分钟后)'); + _schedule(_cleanupRetryDelay); + } + + /// 手动触发一次同步。已在跑就直接返回,不排队。 + Future importNow() async { + if (_running) return; + final session = DeviceHub.to.sessionWith(DeviceCapability.fileStore); + final store = session?.fileStore(); + if (session == null || store == null) return; + + _running = true; + isImporting.value = true; + lastError.value = ''; + deletedCount.value = 0; + _importedThisSweep = 0; + final cancel = DeviceFileCancelToken(); + _cancel = cancel; + try { + await _sweep(session, store, cancel); + } catch (e) { + lastError.value = '$e'; + Logger.w(_tag, '同步失败: $e'); + } finally { + _running = false; + isImporting.value = false; + currentFile.value = ''; + progress.value = 0; + pending.value = 0; + _cancel = null; + // ⚠️ 这里要用**本轮**的计数。importedCount 是进程累计值, + // 拿它判「有进展」会让重试计数永远清零,退化成无限回扫。 + _scheduleCleanupRetry( + madeProgress: deletedCount.value > 0 || _importedThisSweep > 0); + } + } + + /// 取消正在进行的同步(用户离开页面 / 主动断开设备) + void cancel() => _cancel?.cancel(); + + // ---------------- 主流程 ---------------- + + Future _sweep( + DeviceSession session, + DeviceFileStore store, + DeviceFileCancelToken cancel, + ) async { + // 设备正在录音时整轮都别做:通道 B 是单事务的,插一条文件传输进去 + // 会和录音抢链路(协议 §7.1)。等它录完自然会推列表,那时再来。 + if (store.isDeviceRecording) { + Logger.i(_tag, '设备正在录音,本轮跳过'); + return; + } + + var files = await store.list(); + + // 先清理再导入:先腾空间再往卡里写,卡快满时只有这个顺序救得回来。 + // 清完重新拉一次列表,免得拿着已删的文件去下载。 + final before = files.length; + await _cleanup(session, store, files, cancel); + if (cancel.isCancelled) return; + if (deletedCount.value > 0) { + files = await store.list(); + Logger.i(_tag, '清理后设备上剩 ${files.length} 个文件(清理前 $before)'); + } + + final done = _doneMap(); + final todo = []; + for (final f in files) { + // 0 字节的多半是刚建还没写的占位,拉下来是个空文件 + if (f.size <= 0) continue; + if (done.containsKey(_fingerprint(session.deviceId, f))) continue; + todo.add(f); + } + Logger.i(_tag, '设备上 ${files.length} 个文件,需要导入 ${todo.length} 个'); + if (todo.isEmpty) return; + + // 旧的先导,用户的时间线才是顺的 + todo.sort((a, b) { + final ta = a.modifiedAt; + final tb = b.modifiedAt; + if (ta == null || tb == null) return a.name.compareTo(b.name); + return ta.compareTo(tb); + }); + + pending.value = todo.length; + final tmpDir = Directory( + '${(await getApplicationDocumentsDirectory()).path}/DeviceImport'); + if (!await tmpDir.exists()) await tmpDir.create(recursive: true); + + for (final f in todo) { + if (cancel.isCancelled) { + Logger.i(_tag, '同步被取消,剩 ${pending.value} 个未导入'); + return; + } + // 每个文件都重新问一次:用户可能中途按了设备上的录音键 + if (store.isDeviceRecording) { + Logger.i(_tag, '设备开始录音,本轮中止'); + return; + } + await _importOne(session, store, f, tmpDir, cancel); + pending.value = pending.value - 1; + } + } + + Future _importOne( + DeviceSession session, + DeviceFileStore store, + DeviceFileEntry f, + Directory tmpDir, + DeviceFileCancelToken cancel, + ) async { + currentFile.value = f.name; + progress.value = 0; + final localName = _localName(session, f); + final destPath = '${tmpDir.path}/$localName'; + + try { + // ⚠️ 同名残留会被 SDK 当成断点续传的半成品接着写。**这正是要的行为**: + // 上一次没拉完的下次接着拉,不用从头来(BLE 上一个 10 分钟的录音要好几分钟)。 + await store.download( + f.name, + destPath: destPath, + cancelToken: cancel, + onProgress: (received, total) { + progress.value = total > 0 ? received / total : -1; + }, + ); + } on DeviceException catch (e) { + if (e.code == DeviceErrorCode.cancelled) return; // 取消不是错误 + lastError.value = '${f.name}: ${e.message ?? e.code}'; + Logger.w(_tag, '拉取失败 ${f.name}: $e'); + return; + } + + if (cancel.isCancelled) return; + + final seconds = await _probeSeconds(destPath, f.size); + final id = await RecordingArchive.archive( + path: destPath, + seconds: seconds, + tag: _tag, + // 后台批量同步,一条弹一次会把屏幕刷满 + notify: false, + // 列表要按「什么时候录的」排,不是「什么时候同步的」 + createdAt: f.modifiedAt, + ); + if (id == null) { + // 归档失败:文件还在暂存目录,**不记已导入**,下次连上重来。 + Logger.w(_tag, '归档失败,保留 $destPath 待下次重试'); + lastError.value = '${f.name}: 归档失败'; + return; + } + // 记下记录 id:删设备原件前要靠它回查这条传上去没有 + _markDone(session.deviceId, f, id); + importedCount.value = importedCount.value + 1; + _importedThisSweep++; + Logger.i(_tag, '已导入 ${f.name} → 语音纪要 id=$id (${seconds}s)'); + } + + // ---------------- 辅助 ---------------- + + /// 导入后在语音纪要里显示的名字。 + /// + /// 设备上是 8.3 短名(`MIC0016.OGG`),直接拿来当标题既看不出是什么时候录的, + /// 又会因为名字回绕而重复。所以拼成 `holder_20260910_203015_MIC0016.ogg`: + /// 前缀沿用 [RecordingArchive] 的约定,中间是设备记的录制时间。 + String _localName(DeviceSession session, DeviceFileEntry f) { + final t = f.modifiedAt ?? DateTime.now(); + String two(int v) => v.toString().padLeft(2, '0'); + final stamp = '${t.year}${two(t.month)}${two(t.day)}_' + '${two(t.hour)}${two(t.minute)}${two(t.second)}'; + final base = p.basenameWithoutExtension(f.name); + final ext = p.extension(f.name).toLowerCase(); + return '${session.vendor}_${stamp}_$base${ext.isEmpty ? '.ogg' : ext}'; + } + + /// 读音频时长(秒)。 + /// + /// 走 Ogg 容器的 granule position,见 [oggOpusSeconds] 里的说明 + /// (不解码、不依赖播放器,因为播放器解不了这种裸 Ogg Opus)。 + Future _probeSeconds(String path, int sizeHint) async { + try { + final secs = await oggOpusSeconds(File(path)); + if (secs != null && secs > 0) return secs.round(); + } catch (e) { + Logger.w(_tag, '解析 Ogg 时长失败,退回按码率估: $e'); + } + // 兜底:非 Ogg 或文件损坏。16kbps = 2000 B/s,是支架当前固件的规格。 + // ⚠️ 这个估值会偏出一倍(真机实测),只是不得已时不至于显示 0。 + final est = sizeHint ~/ 2000; + return est > 0 ? est : 1; + } + + String _fingerprint(String deviceId, DeviceFileEntry f) => + '$deviceId|${f.name}|${f.size}'; + + /// 指纹 → 语音纪要记录 id。 + /// + /// ⚠️ 兼容旧格式:这个键早先存的是 `List`(只有指纹、没有 id)。 + /// 那些条目读成 id=0,含义是「导过了,但不知道是哪条记录」—— + /// **id=0 的永远不会被自动删除**,宁可留在卡上让人工清。 + Map _doneMap() { + final raw = _storage.read(_doneKey); + if (raw is Map) { + return raw.map((k, v) => MapEntry('$k', v is int ? v : 0)); + } + if (raw is List) { + return {for (final e in raw.whereType()) e: 0}; + } + return {}; + } + + void _writeDone(Map m) { + // 无上限地长会让每次启动都读一坨;设备卡上撑死几百个文件,留 2000 条足够。 + // 超了从最早的开始丢(丢了最坏情况是重导一次,不会丢数据)。 + var out = m; + if (out.length > 2000) { + final keys = out.keys.toList().sublist(out.length - 2000); + out = {for (final k in keys) k: out[k]!}; + } + _storage.write(_doneKey, out); + } + + void _markDone(String deviceId, DeviceFileEntry f, int meetingId) { + _writeDone(_doneMap()..[_fingerprint(deviceId, f)] = meetingId); + } + + /// 已经确认传到服务端的录音:标题(大写)→ 本机音频路径。 + /// + /// 上传成功时 [MeetingUploadService] 把 audiourl 写回本地库 + /// (`SqfliteApi.editMeetingTitle(id, {'audiourl': ...})`),所以读本地库就够了。 + /// + /// ⚠️ **按标题反查而不是只认那张指纹表**,是为了自愈:指纹表可能是旧格式、 + /// 也可能因为超长被截断过,而标题里带着设备原文件名。 + /// + /// 读不到就返回空表:这一轮谁都不删,失败方向是「不删」。 + Future> _uploadedRecords() async { + try { + final list = await SqfliteApi.getMeetingList(); + final out = {}; + for (final row in (list as List?) ?? const []) { + if (row is! Map) continue; + if ((row['audiourl'] ?? '').toString().isEmpty) continue; + final title = (row['title'] ?? '').toString(); + final path = (row['filepath'] ?? '').toString(); + if (title.isEmpty || path.isEmpty) continue; + out[title.toUpperCase()] = path; + } + return out; + } catch (e) { + Logger.w(_tag, '读本地记录失败,本轮不清理设备文件: $e'); + return const {}; + } + } + + /// 这个设备文件是不是「已上传,且本机那份与它逐字节等长」。 + Future _safeToDelete( + Map uploaded, DeviceFileEntry f) async { + final path = uploadedLocalPathFor(uploaded, f.name); + if (path == null) return false; + try { + final local = File(path); + if (!await local.exists()) return false; + final len = await local.length(); + if (len != f.size) { + Logger.w(_tag, + '${f.name} 本机 $len B 与设备 ${f.size} B 不符,不删(可能是同名的新录音)'); + return false; + } + return true; + } catch (e) { + Logger.w(_tag, '校验本机文件失败 ${f.name},不删: $e'); + return false; + } + } + + /// 把「已导入且已上传」的设备原件删掉。 + /// + /// 在导入之前先跑:先腾空间再往里写,卡快满时这个顺序才救得回来。 + Future _cleanup( + DeviceSession session, + DeviceFileStore store, + List files, + DeviceFileCancelToken cancel, + ) async { + if (!autoDeleteAfterImport) return; + if (files.isEmpty) return; + final uploaded = await _uploadedRecords(); + if (uploaded.isEmpty) return; + + final done = _doneMap(); + var changed = false; + for (final f in files) { + if (cancel.isCancelled) break; + // 设备一开始录音就停手:协议 §5.10 规定录音/传输中删除会回 BUSY, + // 而且正在写的那个文件绝对不能碰。 + if (store.isDeviceRecording) { + Logger.i(_tag, '设备开始录音,停止清理'); + break; + } + if (!await _safeToDelete(uploaded, f)) continue; + try { + await store.delete(f.name); + // 文件没了,指纹也不用留(名字回绕后同名新文件才不会被当成已导入) + if (done.remove(_fingerprint(session.deviceId, f)) != null) { + changed = true; + } + deletedCount.value = deletedCount.value + 1; + Logger.i(_tag, '设备原件已删除 ${f.name}(${f.size} B,已确认上传)'); + } catch (e) { + // 删不掉不是灾难:下一轮还会再试。指纹保留着,别丢。 + Logger.w(_tag, '删除设备文件失败 ${f.name}: $e'); + } + } + if (changed) _writeDone(done); + if (deletedCount.value > 0) { + Logger.i(_tag, '本轮清理了 ${deletedCount.value} 个设备文件'); + } + } +} diff --git a/apps/client/lib/data/services/device_recording_service.dart b/apps/client/lib/data/services/device_recording_service.dart new file mode 100644 index 00000000..11534350 --- /dev/null +++ b/apps/client/lib/data/services/device_recording_service.dart @@ -0,0 +1,284 @@ +import 'dart:async'; +import 'dart:io'; +import 'dart:typed_data'; + +import 'package:device_plugin_interface/device_plugin_interface.dart'; +import 'package:get/get.dart'; +import 'package:intl/intl.dart'; +import 'package:path_provider/path_provider.dart'; + +import '../../core/utils/logger.dart'; +import '../../devices/device_hub.dart'; +import 'recording_archive.dart'; +import 'wav_writer.dart'; + +/// 设备主麦录音(现场录音)的全局采集与归档服务——**厂商无关**。 +/// +/// 替代原先各写一份的 BesRecordingService / HolderRecordingService: +/// 谁声明了 [DeviceCapability.micRecording] 就能录, +/// 设备按键起停走 `record.started` / `record.stopped` feature 事件, +/// 音频走 `openMic(mic)`(插件负责让设备开始上行、解码成 16k PCM、挑帧)。 +/// +/// 常驻:设备端按键在任意页面(含页面未打开)都要生效。 +class DeviceRecordingService extends GetxService { + static const String _tag = 'DeviceRecording'; + static DeviceRecordingService get to => Get.find(); + + /// 当前是否正在录(由设备端状态驱动) + final RxBool isRecording = false.obs; + + /// 已录毫秒数 + final RxInt elapsedMs = 0.obs; + + /// 当前电平 0~1,供波形使用 + final RxDouble level = 0.0.obs; + + /// 波形采样序列(固定 [sampleIntervalMs] 一格) + final RxList waveform = [].obs; + static const int sampleIntervalMs = 100; + static const int _maxSamples = 600; + + StreamSubscription? _hubSub; + DeviceSession? _session; + DeviceAudioSource? _src; + StreamSubscription? _audioSub; + WavWriter? _wav; + String? _path; + Timer? _tick; + Timer? _sampleTimer; + final Stopwatch _stopwatch = Stopwatch(); + bool _firstPcmLogged = false; + DateTime? _cmdSentAt; + bool _busy = false; + + @override + void onInit() { + super.onInit(); + _hubSub = DeviceHub.to.sessionEvents.listen(_onSessionEvent); + } + + @override + void onClose() { + _hubSub?.cancel(); + _audioSub?.cancel(); + _tick?.cancel(); + _sampleTimer?.cancel(); + super.onClose(); + } + + void _onSessionEvent(DeviceSessionEvent e) async { + switch (e.type) { + case DeviceSessionEventType.feature: + final key = e.feature?.key; + if (_busy) return; + // 通话中的录音键归通话录音管(DeviceCallRecordingService) + if (key == DeviceFeatureKeys.recordStarted && + !isRecording.value && + !DeviceHub.to.inCall.value) { + Logger.i(_tag, '设备端开始录音,启动本地采集'); + await start(deviceId: e.deviceId); + } else if (key == DeviceFeatureKeys.recordStopped && isRecording.value) { + Logger.i(_tag, '设备端结束录音,落盘并归档'); + await stop(); + } + break; + case DeviceSessionEventType.connectionStateChanged: + if (e.connectionState == DeviceConnectionState.disconnected && + isRecording.value && + _session?.deviceId == e.deviceId) { + Logger.w(_tag, '设备断开,结束当前录音'); + stop(); + } + break; + default: + break; + } + } + + /// 开始本地采集。[sendCommand] = true 时同时下发设备开始指令(App 主动发起)。 + Future start({bool sendCommand = false, String? deviceId}) async { + if (isRecording.value || _busy) return isRecording.value; + _busy = true; + try { + return await _start(sendCommand, deviceId); + } finally { + _busy = false; + } + } + + Future _start(bool sendCommand, String? deviceId) async { + final hub = DeviceHub.to; + final session = (deviceId == null + ? null + : hub.sessions.firstWhereOrNull((s) => s.deviceId == deviceId)) ?? + hub.sessionWith(DeviceCapability.micRecording); + if (session == null) return false; + _session = session; + + try { + final docs = await getApplicationDocumentsDirectory(); + final dir = Directory('${docs.path}/field_recordings'); + if (!await dir.exists()) await dir.create(recursive: true); + final ts = DateFormat('yyyyMMdd_HHmmss').format(DateTime.now()); + _path = '${dir.path}/${session.vendor}_$ts.wav'; + _wav = await WavWriter.open(_path!, channels: 1); + + _firstPcmLogged = false; + _cmdSentAt = null; + + if (sendCommand) { + // 顺序:先让设备进录音态,再开上行(支架的编码器必须在切状态之后开, + // 否则会被 toggle 顺手关掉;恒玄的 AA 59 与订阅先后无关)。 + final t0 = DateTime.now(); + final r = await session.invokeFeature(DeviceFeatures.recordStart); + _cmdSentAt = DateTime.now(); + if (r['ok'] == false) { + await _closeFile(save: false); + return false; + } + Logger.i(_tag, + '已下发 record.start,写入耗时 ${_cmdSentAt!.difference(t0).inMilliseconds}ms'); + } + + _src = await session.openMic(route: AudioRoute.mic); + _audioSub = _src!.pcm.listen(_onPcm); + + isRecording.value = true; + elapsedMs.value = 0; + level.value = 0; + waveform.clear(); + _stopwatch.reset(); + // 时长按**已写入的数据量**算,不用墙钟:显示值与文件实际播放长度永远一致 + // (恒玄收到 AA 59 后要 1.8~6.2s 才开始上行,还会把等待期缓存的音频补推)。 + final bytesPerMs = _src!.format.bytesPerMs; + _tick = Timer.periodic(const Duration(milliseconds: 200), (_) { + elapsedMs.value = _wav == null ? 0 : _wav!.dataBytes ~/ bytesPerMs; + }); + _sampleTimer = Timer.periodic( + const Duration(milliseconds: sampleIntervalMs), + (_) => _sample(), + ); + Logger.i(_tag, '开始录制: $_path (${session.vendor})'); + return true; + } catch (e) { + Logger.e(_tag, '启动本地采集失败: $e'); + await _closeFile(save: false); + return false; + } + } + + /// 停止。[sendCommand] = true 时同时下发设备停止指令。 + Future stop({bool sendCommand = false}) async { + if (!isRecording.value || _busy) return; + _busy = true; + try { + await _stop(sendCommand); + } finally { + _busy = false; + } + } + + Future _stop(bool sendCommand) async { + _tick?.cancel(); + _sampleTimer?.cancel(); + _stopwatch.stop(); + + await _audioSub?.cancel(); + _audioSub = null; + await _src?.close(); + _src = null; + // 等队列写完再关文件,否则最后几帧会丢、WAV 头长度也对不上 + await _wav?.drain(); + + final int dataBytes = _wav?.dataBytes ?? 0; + final int seconds = elapsedMs.value ~/ 1000; + final String? path = _path; + final wallMs = _stopwatch.elapsedMilliseconds; + Logger.i( + _tag, + '录音统计: 音频${(dataBytes / 32000).toStringAsFixed(2)}s 数据${dataBytes}B ' + '墙钟${(wallMs / 1000).toStringAsFixed(2)}s ' + '写入${_wav?.writtenFrames}帧 丢${_wav?.droppedFrames}帧'); + + final session = _session; + if (sendCommand && + session != null && + session.state == DeviceConnectionState.ready) { + try { + await session.invokeFeature(DeviceFeatures.recordStop, { + if (path != null) + 'fileName': path.split('/').last.replaceAll('.wav', ''), + }); + } catch (e) { + Logger.e(_tag, '下发停止指令失败: $e'); + } + } + + await _closeFile(save: true); + isRecording.value = false; + elapsedMs.value = 0; + level.value = 0; + waveform.clear(); + _session = null; + + if (path != null && dataBytes > 0) { + await RecordingArchive.archive(path: path, seconds: seconds, tag: _tag); + } else { + Logger.w(_tag, '本次没有收到音频数据,删除空文件'); + if (path != null) { + try { + final f = File(path); + if (await f.exists()) await f.delete(); + } catch (e) { + Logger.e(_tag, '删除空录音文件失败: $e'); + } + } + } + _path = null; + } + + void _onPcm(Uint8List pcm) { + final w = _wav; + if (w == null || pcm.isEmpty) return; + if (!_firstPcmLogged) { + _firstPcmLogged = true; + if (!_stopwatch.isRunning) _stopwatch.start(); + final sent = _cmdSentAt; + Logger.i( + _tag, + '首帧音频到达' + '${sent != null ? ",距 record.start ${DateTime.now().difference(sent).inMilliseconds}ms" : ""}' + ',计时从此刻开始'); + } + // 串行写,绝不在回调里直接 await 文件(见 WavWriter);电平只是显示,不能挡写入 + w.write(pcm, onWritten: () { + try { + _updateLevel(pcm); + } catch (_) {} + }); + } + + void _sample() { + final list = waveform.toList(); + list.add(level.value); + if (list.length > _maxSamples) { + list.removeRange(0, list.length - _maxSamples); + } + waveform.assignAll(list); + } + + void _updateLevel(Uint8List pcm) { + level.value = WavWriter.rmsLevel(pcm); + } + + Future _closeFile({required bool save}) async { + await _audioSub?.cancel(); + _audioSub = null; + await _src?.close(); + _src = null; + final w = _wav; + _wav = null; + if (w == null) return; + await w.close(save: save); + } +} diff --git a/apps/client/lib/data/services/device_stereo_recorder.dart b/apps/client/lib/data/services/device_stereo_recorder.dart new file mode 100644 index 00000000..eaf8eb81 --- /dev/null +++ b/apps/client/lib/data/services/device_stereo_recorder.dart @@ -0,0 +1,103 @@ +import 'dart:async'; +import 'dart:typed_data'; + +import 'package:device_plugin_interface/device_plugin_interface.dart'; + +import '../../core/utils/logger.dart'; +import '../../devices/device_hub.dart'; +import 'wav_writer.dart'; + +/// 把设备的通话双路(本端 / 对端)录成一个**双声道 WAV** 的小工具, +/// 给「语音纪要 → 通话录音」这类要自己指定文件路径的页面用。 +/// +/// 与常驻的 DeviceCallRecordingService 的区别:这个是页面持有的一次性对象, +/// 文件放哪、什么时候停都由调用方决定,不归档。 +/// 左声道=本端麦克风,右声道=对端声音。 +class DeviceStereoRecorder { + DeviceStereoRecorder({this.onWavePcm}); + + static const String _tag = 'DeviceStereoRecorder'; + + /// 每写一对帧回调一次本端 PCM(驱动波形) + final void Function(Uint8List pcm)? onWavePcm; + + DeviceSession? _session; + DeviceAudioSource? _local; + DeviceAudioSource? _peer; + StreamSubscription? _localSub; + StreamSubscription? _peerSub; + WavWriter? _wav; + Uint8List? _pendingPeer; + bool _paused = false; + + bool get isRunning => _wav != null; + + /// 开始。没有具备通话双路抓音能力的设备返回 false。 + Future start(String filePath) async { + if (isRunning) return true; + final session = DeviceHub.to.sessionWith(DeviceCapability.callAudioTap); + if (session == null) { + Logger.w(_tag, '没有具备通话双路抓音能力的设备'); + return false; + } + try { + _session = session; + _wav = await WavWriter.open(filePath, channels: 2); + _pendingPeer = null; + _paused = false; + _peer = await session.openMic(route: AudioRoute.callPeer); + _local = await session.openMic(route: AudioRoute.callLocal); + _peerSub = _peer!.pcm.listen((pcm) { + if (_paused || pcm.isEmpty) return; + _pendingPeer = pcm; + }); + _localSub = _local!.pcm.listen((pcm) { + if (_paused || pcm.isEmpty) return; + final peer = _pendingPeer; + _pendingPeer = null; + if (peer == null) return; + _wav?.write(WavWriter.interleave(pcm, peer), + onWritten: () => onWavePcm?.call(pcm)); + }); + await session.invokeFeature(DeviceFeatures.callStart); + return true; + } catch (e) { + Logger.e(_tag, '启动失败: $e'); + await _shutdown(saveFile: false); + return false; + } + } + + void pause() => _paused = true; + + void resume() => _paused = false; + + Future stop({required bool save}) async { + if (!isRunning) return true; + await _shutdown(saveFile: save); + return true; + } + + Future _shutdown({required bool saveFile}) async { + await _localSub?.cancel(); + await _peerSub?.cancel(); + _localSub = null; + _peerSub = null; + await _local?.close(); + await _peer?.close(); + _local = null; + _peer = null; + final s = _session; + _session = null; + if (s != null && s.state == DeviceConnectionState.ready) { + try { + await s.invokeFeature(DeviceFeatures.callStop); + } catch (e) { + Logger.w(_tag, 'call.stop 失败: $e'); + } + } + final w = _wav; + _wav = null; + await w?.close(save: saveFile); + } +} diff --git a/apps/client/lib/data/services/network/api.dart b/apps/client/lib/data/services/network/api.dart index c4a43644..0d1bde60 100644 --- a/apps/client/lib/data/services/network/api.dart +++ b/apps/client/lib/data/services/network/api.dart @@ -345,15 +345,6 @@ class Api { ); } - //绑定设备 - static bindauthcode([params]) { - return DioManager().request( - NWMethod.post, - '/api/home/user_bindauthcode', - params: params, - ); - } - //创建支付订单 static paycreateorder([params]) { return DioManager().request( @@ -473,6 +464,19 @@ class Api { ); } + /// 会员与算力状态(`/api/home/user_getcompute`)。 + /// + /// 一次拿齐:`vip{level, exptime, selfexptime, devices[]}`(自购与设备赋予合并后的有效值)、 + /// `compute{deviceleft, userbalance, totalused, monthused}`、`rates{translate, meeting, aichat, gate}`。 + /// 客户端**不做任何换算**,展示服务端给的数;`rates` 只用于本地预估文案。 + static getCompute([params]) { + return DioManager().request( + NWMethod.post, + '/api/home/user_getcompute', + params: params ?? {}, + ); + } + static getwakeupvoices([params]) { return DioManager().request( NWMethod.post, diff --git a/apps/client/lib/data/services/network/auth_interceptor.dart b/apps/client/lib/data/services/network/auth_interceptor.dart index 47ed97b9..16be3fe9 100644 --- a/apps/client/lib/data/services/network/auth_interceptor.dart +++ b/apps/client/lib/data/services/network/auth_interceptor.dart @@ -1,4 +1,5 @@ import 'package:dio/dio.dart'; +import 'package:flutter_dotenv/flutter_dotenv.dart'; import 'package:flutter/material.dart'; import 'package:get/get.dart' hide Response; import 'dart:convert'; @@ -7,10 +8,16 @@ import 'package:crypto/crypto.dart'; import 'package:get_storage/get_storage.dart'; import '../../../core/utils/logger.dart'; import 'entity/base_entity.dart'; +import '../../../routes/app_routes.dart'; class AuthInterceptor extends Interceptor { final GetStorage _storage = GetStorage(); - final String _signKey = '@214%67g15q4*67m17#4l67!'; // 替换为实际的密钥 + /// 请求签名密钥。优先读 .env 的 API_SIGN_KEY,没配时用内置默认值 + /// (与 Go 端保持一致,改这里要同步服务端)。 + static String get _signKey => + (dotenv.env['API_SIGN_KEY'] ?? '').trim().isNotEmpty + ? dotenv.env['API_SIGN_KEY']!.trim() + : '@214%67g15q4*67m17#4l67!'; bool _shouldSuppressErrorSnackbar(DioException err) { final path = err.requestOptions.path; @@ -84,8 +91,8 @@ class AuthInterceptor extends Interceptor { String _generateSign(String timestamp) { // 按照“MD5(私钥 + 时间戳)”生成签名,与 Go 端保持一致 + // ⚠️ 别把 signStr 打进日志:里面是明文密钥 final signStr = 'ts=${timestamp}key=${_signKey}'; - print("signStr: $signStr"); return md5.convert(utf8.encode(signStr)).toString(); } @@ -107,6 +114,13 @@ class AuthInterceptor extends Interceptor { if (_isNotLoggedInMessage(rawMessage)) { return; } + // 5302 VipRequired:翻译 / 会议纪要在 VIP 过期后受限。这不是「错误」,是引导—— + // 弹开通窗而不是红色错误条,并且要同时给「续费」与「绑定带会员的设备」两条路, + // 设备也能带来 VIP,只说充值会漏掉一半用户。 + if (entity.code == _codeVipRequired) { + _showVipRequired(rawMessage); + return; + } final lowerMessage = rawMessage.toLowerCase(); final displayMessage = lowerMessage.contains('record not found') ? 'requestedResourceNotFound'.tr @@ -123,6 +137,36 @@ class AuthInterceptor extends Interceptor { } } + /// 服务端 pb.ErrorCode_VipRequired。 + static const int _codeVipRequired = 5302; + static bool _vipDialogShowing = false; + + void _showVipRequired(String message) { + // 同一屏可能连着几个请求都被拒(同传里 ASR 每句话一次翻译),只弹一次 + if (_vipDialogShowing) return; + _vipDialogShowing = true; + Get.dialog( + AlertDialog( + title: Text('vipRequiredTitle'.tr), + content: Text(message.isNotEmpty ? message : 'vipRequiredDesc'.tr), + actions: [ + TextButton( + onPressed: () => Get.back(), + child: Text('cancel'.tr), + ), + TextButton( + onPressed: () { + Get.back(); + Get.toNamed(Routes.goodsvip); + }, + child: Text('vipRequiredGo'.tr), + ), + ], + ), + barrierDismissible: true, + ).whenComplete(() => _vipDialogShowing = false); + } + @override void onError(DioException err, ErrorInterceptorHandler handler) { switch (err.type) { diff --git a/apps/client/lib/data/services/ota_check_service.dart b/apps/client/lib/data/services/ota_check_service.dart index 13a5f09d..d1cfd3dd 100644 --- a/apps/client/lib/data/services/ota_check_service.dart +++ b/apps/client/lib/data/services/ota_check_service.dart @@ -1,149 +1,100 @@ +import 'package:device_plugin_interface/device_plugin_interface.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_screenutil/flutter_screenutil.dart'; import 'package:get/get.dart'; + import '../../core/utils/logger.dart'; +import '../../core/utils/version_compare.dart'; +import '../../devices/device_hub.dart'; import '../../routes/app_routes.dart'; -import '../../modules/ota_upgrade/controllers/ota_core_contropller.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter_screenutil/flutter_screenutil.dart'; +import '../models/appconfig.dart'; +import '../models/appconfig_model.dart'; -/// OTA检查服务 -/// 负责管理全局OTA检查状态,确保在应用生命周期内只检查一次 +/// 连上设备后的固件升级检查(一次会话只提醒一次)——厂商无关。 +/// +/// 当前版本来自在线会话的 [DeviceInfo.firmwareVersion],最新版本来自 +/// `user_getappconfig` 下发的产品配置(按设备名匹配)。 class OtaCheckService extends GetxService { static const String _tag = 'OtaCheckService'; - // 全局状态管理 static bool _hasCheckedInCurrentSession = false; static bool _isCheckingInProgress = false; - final _otaCoreService = OtaCoreService(); - - /// 获取服务实例 static OtaCheckService get to => Get.find(); - @override - void onInit() { - super.onInit(); - Logger.d(_tag, 'OTA检查服务已初始化'); - } - /// 重置检查状态(仅在应用重启时调用) static void resetCheckStatus() { _hasCheckedInCurrentSession = false; _isCheckingInProgress = false; - Logger.d(_tag, 'OTA检查状态已重置'); } - /// 检查是否应该进行OTA检查 - bool shouldCheckOta() { - return !_hasCheckedInCurrentSession && !_isCheckingInProgress; - } + bool shouldCheckOta() => !_hasCheckedInCurrentSession && !_isCheckingInProgress; - /// 标记为已检查 void _markAsChecked() { _hasCheckedInCurrentSession = true; _isCheckingInProgress = false; - Logger.d(_tag, 'OTA检查状态已标记为完成'); } - /// 检查当前是否在Home页面 bool _isCurrentlyOnHomePage() { final currentRoute = Get.currentRoute; - final isOnHome = - currentRoute == Routes.home || currentRoute == Routes.initial; - Logger.d(_tag, '当前路由: $currentRoute, 是否在Home页面: $isOnHome'); - return isOnHome; + return currentRoute == Routes.home || currentRoute == Routes.initial; } - /// 执行OTA升级检查 - /// [isFullyConnected] 设备是否完全连接 - /// 返回是否成功执行了检查 + DBProduct? _findProduct(String name) { + if (!AppConfig.isInitialized()) return null; + final n = name.trim().toUpperCase(); + if (n.isEmpty) return null; + for (final p in AppConfig.config.products) { + final pn = p.devicename.trim().toUpperCase(); + if (pn.isEmpty) continue; + if (pn.contains(n) || n.contains(pn)) return p; + } + return null; + } + + /// 执行一次检查。[isFullyConnected] 为 false 直接跳过。返回是否真的检查了。 Future performOtaCheck(bool isFullyConnected) async { bool isForceUpdate = false; try { - // 检查是否应该进行OTA检查 - if (!shouldCheckOta()) { - Logger.d(_tag, '跳过OTA检查 - 已检查过或正在检查中'); - return false; - } - - // 检查设备连接状态 - if (!isFullyConnected) { - Logger.d(_tag, '设备未完全连接,跳过OTA检查'); - return false; - } - - // 检查当前页面 - if (!_isCurrentlyOnHomePage()) { - Logger.d(_tag, '当前不在Home页面,跳过OTA检查'); - return false; - } + if (!shouldCheckOta()) return false; + if (!isFullyConnected) return false; + if (!_isCurrentlyOnHomePage()) return false; - // 标记为检查中 + final session = DeviceHub.to.sessionWith(DeviceCapability.ota); + if (session == null) return false; _isCheckingInProgress = true; - Logger.d(_tag, '开始执行OTA固件升级检查...'); - - // 获取当前版本信息 - final currentInfo = await _otaCoreService.getCurrentVersionInfo(); - final currentVersion = currentInfo['currentVersion'] as String; - - // 获取最新版本信息 - final latestInfo = await _otaCoreService.getLatestVersionInfo(); - final latestVersion = latestInfo['latestVersion'] as String; + final currentVersion = session.info.firmwareVersion ?? ''; + final product = _findProduct(session.info.name); + final latestVersion = product?.version?.split('+').first.trim() ?? ''; Logger.d(_tag, '当前版本: $currentVersion, 最新版本: $latestVersion'); - // 检查是否有更新 - final hasUpdate = - _otaCoreService.isNewVersionAvailable(currentVersion, latestVersion); - + final hasUpdate = latestVersion.isNotEmpty && + (currentVersion.isEmpty || + VersionCompare.isNewer(latestVersion, currentVersion)); if (hasUpdate) { - isForceUpdate = latestInfo['isForceUpdate'] as bool? ?? false; - if (isForceUpdate) { - Logger.d(_tag, '发现强制更新版本,准备显示升级提醒'); - } else { - Logger.d(_tag, '发现新版本,准备显示升级提醒'); - } - - // 再次确认当前在Home页面(防止异步执行期间页面切换) + isForceUpdate = product?.isforceupdate ?? false; if (_isCurrentlyOnHomePage()) { - _showOtaUpgradeDialog( - latestVersion, latestInfo['updateDescription'] as String?); - } else { - Logger.d(_tag, '检查完成时已不在Home页面,取消显示弹窗'); + _showOtaUpgradeDialog(latestVersion); } } else { Logger.d(_tag, '当前已是最新版本'); } - // 标记为已检查(无论是否有更新都标记为已检查) - if (!isForceUpdate) { - _markAsChecked(); - } + if (!isForceUpdate) _markAsChecked(); _isCheckingInProgress = false; return true; } catch (e) { Logger.e(_tag, '检查OTA升级失败: $e'); - // 即使检查失败,也标记为已检查,避免重复尝试 - if (!isForceUpdate) { - _markAsChecked(); - } + if (!isForceUpdate) _markAsChecked(); _isCheckingInProgress = false; return false; } } - /// 显示OTA升级提醒对话框 - /// [latestVersion] 最新版本号 - /// [updateDescription] 更新描述 - void _showOtaUpgradeDialog(String latestVersion, String? updateDescription) { - // 最后一次确认当前页面状态 - if (!_isCurrentlyOnHomePage()) { - Logger.d(_tag, '显示弹窗时发现不在Home页面,取消显示'); - return; - } + void _showOtaUpgradeDialog(String latestVersion) { + if (!_isCurrentlyOnHomePage()) return; final isDarkMode = Get.isDarkMode; - - Logger.d(_tag, '显示OTA升级提醒对话框'); - Get.dialog( AlertDialog( title: Row( @@ -154,18 +105,16 @@ class OtaCheckService extends GetxService { size: 24.sp, ), SizedBox(width: 8.w), - // 使用Flexible确保标题文本能够自适应不同语言长度,避免溢出 Flexible( child: Text( - 'ota_newVersionFound'.tr, // 发现新版本 + 'ota_newVersionFound'.tr, style: TextStyle( color: isDarkMode ? Colors.white : Colors.blue[700], fontWeight: FontWeight.w600, - fontSize: 20.sp, // 设置合适的字体大小 + fontSize: 20.sp, ), - softWrap: true, // 启用软换行 - overflow: TextOverflow.visible, // 确保文本完全显示 - maxLines: 2, // 最多显示两行 + softWrap: true, + maxLines: 2, ), ), ], @@ -188,12 +137,8 @@ class OtaCheckService extends GetxService { Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ - // 稍后再说 (靠左) TextButton( - onPressed: () { - Logger.d(_tag, '用户取消OTA升级'); - Get.back(); - }, + onPressed: () => Get.back(), child: Text( 'talkLater'.tr, style: TextStyle( @@ -201,12 +146,10 @@ class OtaCheckService extends GetxService { ), ), ), - // 立即升级 (靠右) ElevatedButton( onPressed: () { - Logger.d(_tag, '用户确认OTA升级,跳转到升级页面'); Get.back(); - Get.toNamed(Routes.OTA_UPGRADE); + Get.toNamed(Routes.otaUpgrade); }, style: ElevatedButton.styleFrom( backgroundColor: @@ -219,30 +162,22 @@ class OtaCheckService extends GetxService { ? BorderSide(color: Colors.grey[800]!, width: 1) : BorderSide.none, ), - child: Text( - 'ota_updateNow'.tr, - style: TextStyle(fontSize: 14.sp), - ), + child: Text('ota_updateNow'.tr, style: TextStyle(fontSize: 14.sp)), ), ], ), ], - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(12.sp), - ), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12.sp)), backgroundColor: isDarkMode ? const Color(0xFF1A1A1A) : Colors.white, ), - barrierDismissible: true, // 允许点击外部关闭 + barrierDismissible: true, ); } - /// 获取当前检查状态(用于调试) - Map getCheckStatus() { - return { - 'hasCheckedInCurrentSession': _hasCheckedInCurrentSession, - 'isCheckingInProgress': _isCheckingInProgress, - 'currentRoute': Get.currentRoute, - 'isOnHomePage': _isCurrentlyOnHomePage(), - }; - } + Map getCheckStatus() => { + 'hasCheckedInCurrentSession': _hasCheckedInCurrentSession, + 'isCheckingInProgress': _isCheckingInProgress, + 'currentRoute': Get.currentRoute, + 'isOnHomePage': _isCurrentlyOnHomePage(), + }; } diff --git a/apps/client/lib/data/services/recording_archive.dart b/apps/client/lib/data/services/recording_archive.dart index 123a59ec..d47a5f37 100644 --- a/apps/client/lib/data/services/recording_archive.dart +++ b/apps/client/lib/data/services/recording_archive.dart @@ -5,7 +5,9 @@ import 'package:path/path.dart' as p; import 'package:path_provider/path_provider.dart'; import '../../core/utils/logger.dart'; +import '../utils/recording_time.dart'; import 'db/sqflite_api.dart'; +import 'meeting/meeting_task_service.dart'; import 'meeting/meeting_upload_service.dart'; import 'network/api.dart'; @@ -29,11 +31,15 @@ class RecordingArchive { /// /// 返回服务端记录 id;失败返回 null(文件会原地保留,不会丢)。 /// [notify] 为 true 时弹一条「已存入语音记录助手」的提示。 + /// [createdAt] 这段录音**实际录制**的时刻。边录边存的四条链路不用传(就是现在), + /// 但从设备存储同步进来的历史录音必须传 —— 否则列表上一条 8 月 15 日录的东西 + /// 会显示成「刚刚」,用户按时间根本找不到它。 static Future archive({ required String path, required int seconds, String tag = 'RecordingArchive', bool notify = true, + DateTime? createdAt, }) async { try { final file = File(path); @@ -70,7 +76,7 @@ class RecordingArchive { 'filepath': newPath, 'audiourl': '', 'tasktype': 0, - 'creationtime': DateTime.now().millisecondsSinceEpoch, + 'creationtime': resolveRecordingCreationTime(createdAt), }; final resp = await Api.addRecordEchomeet({ 'rtype': 'LOCAL', @@ -87,6 +93,12 @@ class RecordingArchive { Get.find() .addUpload(meetingId, newPath, 'LocalAudio'); } + // 让语音纪要首页立刻重画。**不是可选的**:MeetingHomeController 只在 + // `updateListState` 变化时才重查本地库,不捅它就得退出页面再进来才看得到, + // 表现成「录完了/同步完了但列表里没有」。四条归档链路统一在这里捅一次。 + if (Get.isRegistered()) { + Get.find().updateListState.value++; + } Logger.i(tag, '已归档到语音记录助手 id=$meetingId title=$fileName'); if (notify) { Get.snackbar('recordingSavedTitle'.tr, 'recordingSavedDesc'.tr, diff --git a/apps/client/lib/data/services/wav_writer.dart b/apps/client/lib/data/services/wav_writer.dart new file mode 100644 index 00000000..09fbf0e8 --- /dev/null +++ b/apps/client/lib/data/services/wav_writer.dart @@ -0,0 +1,136 @@ +import 'dart:async'; +import 'dart:io'; +import 'dart:math' as math; +import 'dart:typed_data'; + +import '../../core/utils/logger.dart'; + +/// 串行写入的 WAV 文件(16k / 16bit)。 +/// +/// ⚠️ **必须串行写**:Stream.listen 的回调即使标了 async 也不会被等待, +/// 音频每秒上百帧,多次 writeFrom 必然重叠;Dart 的 RandomAccessFile 同一时刻 +/// 只允许一个异步操作,重叠调用会抛 "An async operation is currently pending", +/// 那一帧就丢了(实测等效采样率只剩 1587~5331Hz,播放又快又糊)。 +/// 用 Future 链把写入排成队列:既不丢帧,也保证顺序。 +class WavWriter { + WavWriter._(this._raf, this.path, this.channels, this.sampleRate); + + static const String _tag = 'WavWriter'; + + final RandomAccessFile _raf; + final String path; + final int channels; + final int sampleRate; + + int dataBytes = 0; + int writtenFrames = 0; + int droppedFrames = 0; + Future _chain = Future.value(); + bool _closed = false; + + static Future open(String path, + {int channels = 1, int sampleRate = 16000}) async { + final raf = await File(path).open(mode: FileMode.write); + final w = WavWriter._(raf, path, channels, sampleRate); + await raf.writeFrom(w._header(0)); + return w; + } + + /// 入队写一块 PCM。[onWritten] 在这一块真正落盘后回调(电平等只读用途)。 + void write(Uint8List pcm, {void Function()? onWritten}) { + if (_closed) return; + _chain = _chain.then((_) async { + if (_closed) return; + try { + await _raf.writeFrom(pcm); + dataBytes += pcm.length; + writtenFrames++; + onWritten?.call(); + } catch (e) { + droppedFrames++; + Logger.e(_tag, '写入 PCM 失败(已丢帧 $droppedFrames): $e'); + } + }); + } + + /// 等队列里的帧全部落盘。 + Future drain() async { + try { + await _chain; + } catch (_) {} + } + + /// 关闭;[save] = true 时回填 WAV 头的长度。 + Future close({required bool save}) async { + if (_closed) return; + await drain(); + _closed = true; + try { + if (save) { + await _raf.setPosition(0); + await _raf.writeFrom(_header(dataBytes)); + } + await _raf.close(); + } catch (e) { + Logger.e(_tag, '关闭录音文件失败: $e'); + } + } + + Uint8List _header(int dataLength) { + const bits = 16; + final byteRate = sampleRate * channels * (bits ~/ 8); + final h = ByteData(44); + void ascii(int off, String s) { + for (var i = 0; i < s.length; i++) { + h.setUint8(off + i, s.codeUnitAt(i)); + } + } + + ascii(0, 'RIFF'); + h.setUint32(4, 36 + dataLength, Endian.little); + ascii(8, 'WAVE'); + ascii(12, 'fmt '); + h.setUint32(16, 16, Endian.little); + h.setUint16(20, 1, Endian.little); + h.setUint16(22, channels, Endian.little); + h.setUint32(24, sampleRate, Endian.little); + h.setUint32(28, byteRate, Endian.little); + h.setUint16(32, channels * (bits ~/ 8), Endian.little); + h.setUint16(34, bits, Endian.little); + ascii(36, 'data'); + h.setUint32(40, dataLength, Endian.little); + return h.buffer.asUint8List(); + } + + /// PCM16 小端的 RMS 电平(0~1,开过根让波形更有跳动感)。 + /// + /// ⚠️ 不能用 `buffer.asInt16List(offsetInBytes, …)`:原生过来的 Uint8List 是 + /// 带 offset 的视图,偏移是奇数时直接抛 RangeError。按字节手工拼。 + static double rmsLevel(Uint8List pcm) { + final count = pcm.lengthInBytes ~/ 2; + if (count == 0) return 0; + var sum = 0.0; + for (var i = 0; i < count; i++) { + var v = (pcm[i * 2 + 1] << 8) | pcm[i * 2]; + if (v >= 0x8000) v -= 0x10000; + final n = v / 32768.0; + sum += n * n; + } + final rms = math.sqrt(sum / count); + return math.sqrt(rms).clamp(0.0, 1.0); + } + + /// 把两路单声道按字节交织成双声道(左=[left],右=[right])。 + static Uint8List interleave(Uint8List left, Uint8List right) { + final samples = (left.length < right.length ? left.length : right.length) ~/ 2; + final out = Uint8List(samples * 4); + for (var i = 0; i < samples; i++) { + final si = i * 2, di = i * 4; + out[di] = left[si]; + out[di + 1] = left[si + 1]; + out[di + 2] = right[si]; + out[di + 3] = right[si + 1]; + } + return out; + } +} diff --git a/apps/client/lib/data/utils/device_file_match.dart b/apps/client/lib/data/utils/device_file_match.dart new file mode 100644 index 00000000..a045da17 --- /dev/null +++ b/apps/client/lib/data/utils/device_file_match.dart @@ -0,0 +1,27 @@ +/// 「设备上这个文件,是不是已经安全落袋了」—— +/// **这是删除设备原件的唯一判据**,改它等于改一个不可逆操作的闸门。 +library; + +/// 归档标题形如 `_<时间戳>_<设备文件名>` +/// (`holder_20260907_181224_MIC0049.ogg` ← 设备上的 `MIC0049.OGG`)。 +/// +/// 在「已上传」的记录里按这个后缀找对应的那条,返回它在本机的音频路径; +/// 找不到返回 null。[uploadedByTitle] 的 key 必须已大写。 +/// +/// ⚠️ **只匹配到名字还不够,调用方必须再比一次字节数**:设备用的是 8.3 短 +/// 文件名,删掉后会**回绕复用**(`MIC0001.OGG` 下次录音可能又叫这个名)。 +/// 只认名字的话,一条同名的新录音会被当成「已上传」直接删掉 —— 永久丢数据。 +/// 字节数的来源刻意选了「本机那份音频的实际长度」而不是数据库里的字段: +/// `meeting` 表**根本没有 size 列**(schema 见 database_helper.dart), +/// 而且比文件长度顺带证明了本机这份没被截断、确实是同一个文件。 +String? uploadedLocalPathFor( + Map uploadedByTitle, + String deviceFileName, +) { + if (deviceFileName.isEmpty) return null; + final suffix = '_${deviceFileName.toUpperCase()}'; + for (final e in uploadedByTitle.entries) { + if (e.key.endsWith(suffix) && e.value.isNotEmpty) return e.value; + } + return null; +} diff --git a/apps/client/lib/data/utils/ogg_opus_duration.dart b/apps/client/lib/data/utils/ogg_opus_duration.dart new file mode 100644 index 00000000..912090ff --- /dev/null +++ b/apps/client/lib/data/utils/ogg_opus_duration.dart @@ -0,0 +1,93 @@ +/// Ogg Opus 时长解析 —— **不解码、不依赖播放器**。 +/// +/// Opus 的 granule position 恒以 **48kHz** 计样本(RFC 7845 §4,与音频本身的 +/// 采样率无关),所以「最后一个 Ogg page 的 granulepos 减去 OpusHead 里的 +/// pre-skip,再除以 48000」就是精确时长。 +/// +/// 为什么不用现成的播放器: +/// - ⚠️ `just_audio` 的 `setFilePath` 对支架录的这种裸 Ogg Opus **两端都报 +/// `Source error`**(Android/iOS 真机实测),拿不到时长; +/// - ⚠️ 按码率估会明显失真:真机那条 38400 字节的录音实际 9.16s, +/// 按 16kbps 估是 19.2s,差一倍,列表上一眼就看得出来。 +library; + +import 'dart:io'; + +const List _oggS = [0x4F, 0x67, 0x67, 0x53]; // 'OggS' +const List _opusHead = [ + 0x4F, 0x70, 0x75, 0x73, 0x48, 0x65, 0x61, 0x64 // 'OpusHead' +]; + +/// Opus 的 granulepos 单位,**恒为 48000**,不是音频采样率。 +/// 写成 16000 会把时长算成 3 倍。 +const int _opusGranuleRate = 48000; + +/// 从「文件头若干字节」和「文件尾若干字节」解出时长(秒)。 +/// +/// [head] 至少要覆盖第一个 page(含 OpusHead);[tail] 至少要覆盖最后一个 +/// page 的头部。不是 Ogg、或没有可用的 granulepos 时返回 null。 +double? oggOpusSecondsFrom(List head, List tail) { + if (head.length < 4 || _indexOf(head, _oggS) != 0) return null; // 不是 'OggS' + + final hi = _indexOf(head, _opusHead); + // OpusHead 布局:magic(8) version(1) channels(1) pre-skip(2, LE) + final preSkip = + hi >= 0 && hi + 12 <= head.length ? head[hi + 10] | (head[hi + 11] << 8) : 0; + + final ti = _lastIndexOf(tail, _oggS); + if (ti < 0 || ti + 14 > tail.length) return null; + + // page 头偏移 6 起的 8 字节小端就是 granulepos + var granule = 0; + for (var b = 7; b >= 0; b--) { + granule = (granule << 8) | tail[ti + 6 + b]; + } + if (granule <= preSkip) return null; + return (granule - preSkip) / _opusGranuleRate; +} + +/// 读文件解时长。不是 Ogg Opus 返回 null。 +/// +/// 只读头 4KB 和尾 128KB —— Ogg page 最大 65307 字节,128KB 必然覆盖到 +/// 最后一个 page,不用把整个文件读进内存(录音可能几十兆)。 +Future oggOpusSeconds(File file) async { + final raf = await file.open(); + try { + final len = await raf.length(); + if (len < 47) return null; // 连一个 OpusHead page 都放不下 + + await raf.setPosition(0); + final head = await raf.read(len < 4096 ? len : 4096); + + const tailLen = 128 * 1024; + final from = len > tailLen ? len - tailLen : 0; + await raf.setPosition(from); + final tail = await raf.read(len - from); + + return oggOpusSecondsFrom(head, tail); + } finally { + await raf.close(); + } +} + +int _indexOf(List hay, List needle) { + outer: + for (var i = 0; i + needle.length <= hay.length; i++) { + for (var j = 0; j < needle.length; j++) { + if (hay[i + j] != needle[j]) continue outer; + } + return i; + } + return -1; +} + +int _lastIndexOf(List hay, List needle) { + outer: + for (var i = hay.length - needle.length; i >= 0; i--) { + for (var j = 0; j < needle.length; j++) { + if (hay[i + j] != needle[j]) continue outer; + } + return i; + } + return -1; +} diff --git a/apps/client/lib/data/utils/recording_time.dart b/apps/client/lib/data/utils/recording_time.dart new file mode 100644 index 00000000..ad422fdd --- /dev/null +++ b/apps/client/lib/data/utils/recording_time.dart @@ -0,0 +1,28 @@ +/// 一条录音在列表里该显示的「录制时间」。 +/// +/// [deviceTime] 是设备自己记的时间(如 EaiRec 支架 TF 卡文件的 FAT 时间戳)。 +/// +/// ⚠️ **设备给的时间不能无条件相信**:支架在 RTC 对时之前写的文件,时间戳是 +/// 1980 / 2018 这类垃圾值(2026-09-10 真机那张卡上就有一批 `2018-08-10`)。 +/// 直接采信的话,这些记录会沉到列表最底下,用户按时间根本找不到。 +/// +/// 所以只接受「2020 年之后、且不超过明天」的值,其余退回当前时刻: +/// 显示成「刚导入」是次优但可用,显示成 2018 年是直接丢失。 +/// +/// ⚠️ **当前这个值在列表上还看不到效果,别以为这段是死代码把它删了。** +/// 服务端 `echomeet_addrecord` 把 `Creationtime` 写死成 `time.Now().Unix()` +/// (`EchomeetAddRecordReq` 里压根没有这个字段),而 `Synchrodata._synchrodata()` +/// 会拿服务端的值**整行 update** 覆盖本地库 —— 于是列表上显示的永远是「导入时刻」。 +/// 录制时间目前只活在标题里(`holder_20260907_181224_MIC0049.ogg`)。 +/// 要让它真正生效,服务端要做两件事:`EchomeetAddRecordReq` 加 `creationtime` +/// 字段,`AddRecord` 在传了合法值时用它而不是 `time.Now()`。 +/// 客户端这一侧已经按「服务端接受」的形状送上去了,接口一改就自动生效。 +int resolveRecordingCreationTime(DateTime? deviceTime, {DateTime? now}) { + final n = now ?? DateTime.now(); + if (deviceTime == null) return n.millisecondsSinceEpoch; + if (deviceTime.year < 2020) return n.millisecondsSinceEpoch; + if (deviceTime.isAfter(n.add(const Duration(days: 1)))) { + return n.millisecondsSinceEpoch; + } + return deviceTime.millisecondsSinceEpoch; +} diff --git a/apps/client/lib/devices/bes/bes_bluetooth_service.dart b/apps/client/lib/devices/bes/bes_bluetooth_service.dart index 0ebe305d..c3d9f9c3 100644 --- a/apps/client/lib/devices/bes/bes_bluetooth_service.dart +++ b/apps/client/lib/devices/bes/bes_bluetooth_service.dart @@ -8,9 +8,9 @@ import 'package:get/get.dart'; import 'package:get_storage/get_storage.dart'; import '../../core/utils/logger.dart'; -import 'agent_module_defaults.dart'; -import 'background_launch_consent.dart'; +import '../../data/services/background_launch_consent.dart'; import 'bes_device_auth.dart'; +import 'bes_protocol.dart'; /// 恒玄(BES/Bestechnic)芯片经典蓝牙连接服务。 /// @@ -28,6 +28,12 @@ class BesBluetoothService extends GetxService { final _manager = bes.BluetoothManager(); final _storage = GetStorage(); + /// 原生插件句柄。只给同目录的 [BesDevicePlugin] 用(AI 通道、下行编码)。 + bes.BluetoothManager get manager => _manager; + + /// 确权闸门(按 MAC 向服务端登记),由插件注入;null = 不确权。 + BesDeviceAuth? auth; + StreamSubscription? _statusSubscription; StreamSubscription? _cmdEventSubscription; @@ -216,7 +222,8 @@ class BesBluetoothService extends GetxService { // // 只有 denied 会中止:unknown(断网/未登录/产品表没下发)一律放行, // 理由见 BesDeviceAuth 的类注释。 - final auth = await BesDeviceAuth.verifyConnected(); + final auth = await (this.auth?.verifyConnected() ?? + Future.value(BesAuthResult.unknown)); if (auth == BesAuthResult.denied) { // 断开与提示已由 BesDeviceAuth 就地做完(此时 connectedDeviceId // 已被清空,别再拿它打日志),这里只是不再往下走 @@ -225,13 +232,10 @@ class BesBluetoothService extends GetxService { } if (deviceStatus.value != 2) return; // 确权期间掉线了 - // 首次连上耳机 → AI 页铺开耳机的默认功能编排 - AgentDefaults.onEarphoneConnected(); - // 数据通道就绪后再发 0x06 获取固件版本 await Future.delayed(const Duration(milliseconds: 500)); if (deviceStatus.value == 2) { - await sendData([0xAA, 0x06]); + await sendData(BesCmd.frame(BesCmd.firmwareVersion)); await queryBattery(); _scheduleBatteryRetry(); // App 可能是在耳机已经通话中时才连上/重启回连的——0xD1/0xD2/0x02/0x03 @@ -368,11 +372,11 @@ class BesBluetoothService extends GetxService { Future refreshDeviceState() async { if (!isConnected) return; try { - await sendData([0xAA, 0x06]); + await sendData(BesCmd.frame(BesCmd.firmwareVersion)); await Future.delayed(const Duration(milliseconds: 120)); - await sendData([0xAA, 0x0C]); + await sendData(BesCmd.frame(BesCmd.keyConfig)); await Future.delayed(const Duration(milliseconds: 120)); - await sendData([0xAA, 0x10]); + await sendData(BesCmd.frame(BesCmd.eqConfig)); await Future.delayed(const Duration(milliseconds: 120)); // 电量会随时间掉,进功能页时重查一次,别只依赖连接那一刻的值 await queryBattery(); @@ -452,7 +456,7 @@ class BesBluetoothService extends GetxService { Future queryBattery() async { try { - await sendData([0xAA, 0x09]); + await sendData(BesCmd.frame(BesCmd.status)); } catch (e) { Logger.e(_tag, '查询电量失败: $e'); } @@ -499,7 +503,7 @@ class BesBluetoothService extends GetxService { /// 这条查询命令是唯一能补的办法,见连接握手和 [refreshDeviceState]。 Future queryCallState() async { try { - await sendData([0xAA, 0x0E]); + await sendData(BesCmd.frame(BesCmd.callState)); } catch (e) { Logger.e(_tag, '查询通话状态失败: $e'); } @@ -568,67 +572,38 @@ class BesBluetoothService extends GetxService { /// 3. **电量字节最高位是充电指示位**,必须 `& 0x7F` 取百分比。 /// 直接用原字节,充电时会得到 128~228 的假电量。 void _parseBattery(List data) { - var p = 3; // 跳过 SOF + CMD + 1 字节长度 - var hit = false; - while (p + 2 < data.length) { - final blockLen = data[p]; - if (blockLen < 2 || p + blockLen >= data.length) break; - final attr = data[p + 1]; - final raw = data[p + 2]; - final level = raw & 0x7F; // 低 7 位是百分比 - final charging = (raw & 0x80) != 0; // 最高位是充电指示 - if (level <= 100) { - switch (attr) { - case 0x01: - batteryLeft.value = level; - chargingLeft.value = charging; - hit = true; - break; - case 0x02: - batteryRight.value = level; - chargingRight.value = charging; - hit = true; - break; - case 0x03: - batteryCase.value = level; - chargingCase.value = charging; - hit = true; - break; - } - } - // 属性 7 = 耳机蓝牙地址(6 字节)。这是**耳机自己报上来的**真 MAC, - // 不经过系统蓝牙 API,所以 iOS 上也拿得到——iOS 的 connectedDeviceId - // 是 CoreBluetooth 的 peripheral UUID(每台手机看同一只耳机都不一样), - // 设备页显示 MAC 和设备确权都指望这个值。 - // 实测(Echo-one 0.0.3)解出 11:11:22:33:33:A4,与 Android 上 - // connectedDeviceId 逐字节一致,也正是后台 license 表登记的那个。 - // ⚠️ 别改用属性 6:那 12 字节里虽然也嵌着一个 MAC - //(布局 `00 01 01 | <6字节> | 00 00 00`,实测 70:B3:B1:4D:33:DC), - // 但它既不是耳机主地址也不是手机地址,含义至今未知,拿去确权必然对不上。 - if (attr == 0x07 && blockLen >= 7 && p + 7 < data.length) { - final mac = data - .sublist(p + 2, p + 8) - .map((e) => e.toRadixString(16).padLeft(2, '0').toUpperCase()) - .join(':'); - if (mac != deviceMac.value) { - deviceMac.value = mac; - Logger.i(_tag, '耳机上报蓝牙地址: $mac'); - } - // 按本地设备标识记下来,下次连同一台时同步命中,不用再等一帧 BB 0A - _rememberDeviceMac(mac); - } - p += blockLen + 1; + final st = BesProtocol.parseStatus(data); + if (st.batteryLeft != null) { + batteryLeft.value = st.batteryLeft!; + chargingLeft.value = st.chargingLeft; } - if (!hit) return; - final levels = - [batteryLeft.value, batteryRight.value].where((v) => v >= 0).toList(); - if (levels.isNotEmpty) { - // 左右耳取较低的一侧——用户关心的是「还能用多久」 - batteryLevel.value = levels.reduce((a, b) => a < b ? a : b); - } else if (batteryCase.value >= 0) { - // 只有盒子上报时(两只耳机都在盒内)退而显示盒电量 - batteryLevel.value = batteryCase.value; + if (st.batteryRight != null) { + batteryRight.value = st.batteryRight!; + chargingRight.value = st.chargingRight; } + if (st.batteryCase != null) { + batteryCase.value = st.batteryCase!; + chargingCase.value = st.chargingCase; + } + // 属性 7 = 耳机蓝牙地址(6 字节)。这是**耳机自己报上来的**真 MAC, + // 不经过系统蓝牙 API,所以 iOS 上也拿得到——iOS 的 connectedDeviceId + // 是 CoreBluetooth 的 peripheral UUID(每台手机看同一只耳机都不一样), + // 设备页显示 MAC 和设备确权都指望这个值。 + // 实测(Echo-one 0.0.3)解出 11:11:22:33:33:A4,与 Android 上 + // connectedDeviceId 逐字节一致,也正是后台 license 表登记的那个。 + // ⚠️ 别改用属性 6:那 12 字节里虽然也嵌着一个 MAC,含义至今未知。 + final mac = st.mac; + if (mac != null) { + if (mac != deviceMac.value) { + deviceMac.value = mac; + Logger.i(_tag, '耳机上报蓝牙地址: $mac'); + } + // 按本地设备标识记下来,下次连同一台时同步命中,不用再等一帧 BB 0A + _rememberDeviceMac(mac); + } + if (!st.hasBattery) return; + final summary = st.summaryBattery; + if (summary != null) batteryLevel.value = summary; Logger.w( _tag, '恒玄耳机电量: ${batteryLevel.value}%' @@ -671,30 +646,7 @@ class BesBluetoothService extends GetxService { /// 固件版本回包:BB CMD len(2) + 若干版本块 [len, 属性, 8字节版本], /// 属性 0x01左 / 0x02右 / 0x04盒;版本取版本块后 3 字节,优先左耳。 void _parseFirmwareVersion(List data) { - String? left, right, box; - var p = 4; // 跳过 SOF + CMD + 2 字节长度 - while (p + 1 < data.length) { - final blockLen = data[p]; - if (blockLen < 2 || p + blockLen >= data.length) break; - final attr = data[p + 1]; - final version = data.sublist(p + 2, p + 1 + blockLen); - if (version.length >= 8) { - final v = '${version[5]}.${version[6]}.${version[7]}'; - switch (attr) { - case 0x01: - left = v; - break; - case 0x02: - right = v; - break; - case 0x04: - box = v; - break; - } - } - p += blockLen + 1; - } - final parsed = left ?? right ?? box; + final parsed = BesProtocol.parseFirmware(data).preferred; if (parsed != null) { deviceVersion.value = parsed; // 固件版本决定这台机器该用哪套命令号(0.0.2 与 0.0.3 的电量命令不同), @@ -756,30 +708,30 @@ class BesBluetoothService extends GetxService { /// 进入通话模式(`AA 51`):耳机开始双路上行(对端 + 本端), /// mode=0x03 交织,原生解码后走 [audioStream]。通话录音/通话翻译都用它。 - Future startCallMode() => sendData([0xAA, 0x51]); + Future startCallMode() => sendData(BesCmd.frame(BesCmd.callModeStart)); /// 退出通话模式(`AA 52`) - Future stopCallMode() => sendData([0xAA, 0x52]); + Future stopCallMode() => sendData(BesCmd.frame(BesCmd.callModeStop)); /// 通知耳机「ASR 已开启」(`AA 57`),收到 0xD1 之后发 - Future notifyAsrStarted() => sendData([0xAA, 0x57]); + Future notifyAsrStarted() => sendData(BesCmd.frame(BesCmd.asrStarted)); /// 通知耳机「ASR 已关闭」(`AA 58`) - Future notifyAsrStopped() => sendData([0xAA, 0x58]); + Future notifyAsrStopped() => sendData(BesCmd.frame(BesCmd.asrStopped)); /// 开始面对面翻译(`AA 66`)。耳机就绪后回 `0xE6`; /// 耳机按键触发时也会主动上报 `0xE6`。 - Future startFaceToFace() => sendData([0xAA, 0x66]); + Future startFaceToFace() => sendData(BesCmd.frame(BesCmd.faceToFaceStart)); /// 结束面对面翻译(`AA 67`),设备回 `0xE7` - Future stopFaceToFace() => sendData([0xAA, 0x67]); + Future stopFaceToFace() => sendData(BesCmd.frame(BesCmd.faceToFaceStop)); /// 开始设备端主麦录音(`AA 59`)。设备就绪后会回 `0xD9`, /// 音频以 mSBC 帧上行,由原生解码后走 [audioStream]。 - Future startMicRecording() => sendData([0xAA, 0x59]); + Future startMicRecording() => sendData(BesCmd.frame(BesCmd.micRecordStart)); /// 停止设备端主麦录音(`AA 5A`),设备回 `0xDA` - Future stopMicRecording() => sendData([0xAA, 0x5A]); + Future stopMicRecording() => sendData(BesCmd.frame(BesCmd.micRecordStop)); /// 系统已配对的经典蓝牙设备列表(不做 BLE 扫描)。 Future>> getBondedDevices() { diff --git a/apps/client/lib/devices/bes/bes_device_auth.dart b/apps/client/lib/devices/bes/bes_device_auth.dart index c8262d1e..332b1520 100644 --- a/apps/client/lib/devices/bes/bes_device_auth.dart +++ b/apps/client/lib/devices/bes/bes_device_auth.dart @@ -4,9 +4,9 @@ import 'package:get/get.dart'; import 'package:get_storage/get_storage.dart'; import '../../core/utils/logger.dart'; -import '../models/user_Info.dart'; +import '../../data/models/user_Info.dart'; +import '../../data/services/network/api.dart'; import 'bes_bluetooth_service.dart'; -import 'network/api.dart'; /// 恒玄耳机的确权结论。 enum BesAuthResult { @@ -62,7 +62,9 @@ enum BesAuthResult { /// iOS 用耳机在 `BB 0A` 状态帧属性 7 里**自报**的蓝牙地址(不经过系统 API,两端一致; /// 实测与 Android 拿到的逐字节吻合)。取不到时照旧返回 [BesAuthResult.unknown] 放行。 class BesDeviceAuth { - BesDeviceAuth._(); + BesDeviceAuth(this._bes); + + final BesBluetoothService _bes; static const String _tag = 'BesDeviceAuth'; @@ -127,9 +129,8 @@ class BesDeviceAuth { /// /// 判定为 [BesAuthResult.denied] 时会**就地断开连接并提示用户**,调用方只需 /// 据返回值决定要不要继续后续握手。 - static Future verifyConnected() async { - if (!Get.isRegistered()) return BesAuthResult.unknown; - final bes = BesBluetoothService.to; + Future verifyConnected() async { + final bes = _bes; if (!bes.isConnected) return BesAuthResult.unknown; // iOS 上 connectedDeviceId 是 peripheral UUID,不能拿去校验; @@ -159,7 +160,7 @@ class BesDeviceAuth { } } - static Future _verifyRemote({ + Future _verifyRemote({ required String mac, required String name, }) async { @@ -235,12 +236,12 @@ class BesDeviceAuth { /// /// ⚠️ **必须移出 pairedDevices**,否则冷启动的 `tryAutoConnect` 和原生 ACL /// 广播会立刻把它连回来,变成「连上→弹窗→断开→连上」的死循环。 - static Future _reject(String address) async { + Future _reject(String address) async { try { // removePairedDevice 内部在地址匹配时会顺带 disconnectDevice - await BesBluetoothService.to.removePairedDevice(address); - if (BesBluetoothService.to.isConnected) { - await BesBluetoothService.to.disconnectDevice(); + await _bes.removePairedDevice(address); + if (_bes.isConnected) { + await _bes.disconnectDevice(); } } catch (e) { Logger.e(_tag, '断开未确权设备失败: $e'); diff --git a/apps/client/lib/devices/bes/bes_device_plugin.dart b/apps/client/lib/devices/bes/bes_device_plugin.dart new file mode 100644 index 00000000..2cf27a67 --- /dev/null +++ b/apps/client/lib/devices/bes/bes_device_plugin.dart @@ -0,0 +1,637 @@ +import 'dart:async'; +import 'dart:io'; +import 'dart:typed_data'; + +import 'package:device_plugin_interface/device_plugin_interface.dart'; +import 'package:get/get.dart'; + +import '../../core/utils/logger.dart'; +import '../device_vendors.dart'; +import 'bes_bluetooth_service.dart'; +import 'bes_ota_port.dart'; +import 'bes_protocol.dart'; + +/// 恒玄(BES) 耳机的厂商插件——把 [BesBluetoothService](协议实现,冻结) +/// 适配成 [DevicePlugin] / [DeviceSession]。 +/// +/// **这一层只做映射,不碰字节。** feature key → 命令字节的对应表在 +/// [BesDeviceSession.invokeFeature],由 `test/devices/bes_session_test.dart` +/// 逐条断言发出的字节。 +class BesDevicePlugin implements DevicePlugin { + static const String _tag = 'BesDevicePlugin'; + + /// 协议实现。仍注册进 GetX 只是为了走 GetxService 的生命周期; + /// 业务层不允许 import 它(见 device_hub_boundary_test)。 + final BesBluetoothService link; + + BesDevicePlugin({BesBluetoothService? link}) + : link = link ?? BesBluetoothService(); + + final StreamController _ctrl = + StreamController.broadcast(); + BesDeviceSession? _session; + Worker? _statusWorker; + bool _initialized = false; + bool _disposed = false; + + @override + String get vendorKey => DeviceVendors.bes; + + @override + String get displayName => 'BES (恒玄)'; + + @override + Set get capabilities => BesDeviceSession.caps; + + @override + DevicePluginConfigSchema get configSchema => DevicePluginConfigSchema.empty; + + @override + DeviceSession? get activeSession => _session; + + @override + Stream get eventStream => _ctrl.stream; + + @override + Future initialize(DevicePluginConfig config) async { + _checkAlive(); + if (_initialized) return; + _initialized = true; + if (!Get.isRegistered()) { + Get.put(link, permanent: true); + } + _statusWorker = ever(link.deviceStatus, _onStatus); + if (link.deviceStatus.value == 2) _onStatus(2); + _emit(const DevicePluginEvent(type: DevicePluginEventType.pluginReady)); + } + + void _onStatus(int v) { + switch (v) { + case 2: + if (_session != null) return; + final s = BesDeviceSession._(link); + _session = s; + _emit(DevicePluginEvent( + type: DevicePluginEventType.connectionStateChanged, + deviceId: s.deviceId, + connectionState: DeviceConnectionState.ready, + )); + break; + case 1: + _emit(DevicePluginEvent( + type: DevicePluginEventType.connectionStateChanged, + deviceId: link.connectedDeviceId.value, + connectionState: DeviceConnectionState.connecting, + )); + break; + default: + final s = _session; + _session = null; + s?._markDisconnected(); + _emit(DevicePluginEvent( + type: DevicePluginEventType.connectionStateChanged, + deviceId: s?.deviceId, + connectionState: DeviceConnectionState.disconnected, + )); + } + } + + void _emit(DevicePluginEvent e) { + if (!_ctrl.isClosed) _ctrl.add(e); + } + + // ---------- 扫描(恒玄没有扫描:只从系统配对列表里挑) ---------- + + @override + Future startScan({DeviceScanFilter? filter, Duration? timeout}) async { + _emit(const DevicePluginEvent(type: DevicePluginEventType.scanStarted)); + for (final d in await bondedDevices()) { + _emit(DevicePluginEvent( + type: DevicePluginEventType.deviceDiscovered, + deviceId: d.id, + discovered: d)); + } + _emit(const DevicePluginEvent(type: DevicePluginEventType.scanStopped)); + } + + @override + Future stopScan() async {} + + @override + Future isScanning() async => false; + + @override + Future> bondedDevices() async { + final list = await link.getBondedDevices(); + return list + .map((d) => DiscoveredDevice( + id: (d['address'] ?? '') as String, + name: (d['name'] ?? '') as String, + vendor: vendorKey, + )) + .where((d) => d.id.isNotEmpty) + .toList(growable: false); + } + + @override + Future ensureReady() async { + try { + return await link.initialize(); + } catch (e) { + Logger.e(_tag, '恒玄蓝牙初始化失败: $e'); + return false; + } + } + + // ---------- 连接 ---------- + + /// 连接并等到「已连接」。 + /// + /// 进过「连接中」又掉回 0 就提前判失败(原生 connectBlocking 失败会推 0); + /// 但仍要保留超时兜底:原生若把这次 connect 丢弃了(isConnecting 门禁), + /// 状态会停在 connectToDevice 自己写的 1,等不到 0。 + @override + Future connect(String deviceId, + {DeviceConnectOptions? options}) async { + _checkAlive(); + final name = (options?.extra['name'] as String?) ?? ''; + final timeout = options?.timeout ?? const Duration(seconds: 8); + await link.connectToDevice(deviceId, name); + final end = DateTime.now().add(timeout); + var sawConnecting = false; + while (DateTime.now().isBefore(end)) { + final s = link.deviceStatus.value; + if (s == 2) break; + if (s == 1) sawConnecting = true; + if (sawConnecting && s == 0) { + throw DeviceException(DeviceErrorCode.connectFailed, deviceId); + } + await Future.delayed(const Duration(milliseconds: 300)); + } + if (link.deviceStatus.value != 2) { + throw DeviceException(DeviceErrorCode.connectTimeout, deviceId); + } + // 状态流回调与这里是同一个事件循环,session 已经建好 + _onStatus(2); + return _session!; + } + + @override + List get rememberedDevices => link + .getPairedDevices() + .map((d) => DiscoveredDevice( + id: (d['address'] ?? '') as String, + name: (d['name'] ?? '') as String, + vendor: vendorKey, + )) + .where((d) => d.id.isNotEmpty) + .toList(growable: false); + + @override + DiscoveredDevice? get lastKnownDevice { + final n = link.lastDeviceName; + final a = link.lastDeviceAddress; + if (n.isEmpty && a.isEmpty) return null; + return DiscoveredDevice(id: a, name: n, vendor: vendorKey); + } + + @override + Future rememberDevice(DiscoveredDevice device) => + link.savePairedDevice({'address': device.id, 'name': device.name}); + + @override + Future tryAutoConnect() => link.tryAutoConnect(); + + @override + Future forgetDevice(String deviceId) => + link.removePairedDevice(deviceId); + + @override + Future dispose() async { + _disposed = true; + _statusWorker?.dispose(); + _session?._markDisconnected(); + _session = null; + await _ctrl.close(); + } + + void _checkAlive() { + if (_disposed) throw StateError('BesDevicePlugin disposed'); + } +} + +/// 恒玄耳机会话。 +class BesDeviceSession extends DeviceSession { + BesDeviceSession._(this._link) { + _bind(); + } + + static const String _tag = 'BesSession'; + + static const Set caps = { + DeviceCapability.connect, + DeviceCapability.bond, + DeviceCapability.battery, + DeviceCapability.macAddress, + DeviceCapability.micUplink, + DeviceCapability.speakerDownlink, + DeviceCapability.micRecording, + DeviceCapability.callAudioTap, + DeviceCapability.callState, + DeviceCapability.aiWake, + DeviceCapability.translationDownlink, + DeviceCapability.faceToFace, + DeviceCapability.ota, + DeviceCapability.customCommand, + }; + + final BesBluetoothService _link; + final StreamController _events = + StreamController.broadcast(); + final StreamController _wake = + StreamController.broadcast(); + final List _workers = []; + StreamSubscription? _aiSub; + DeviceConnectionState _state = DeviceConnectionState.ready; + + /// 通话翻译下行的引用计数:A/B 两路都关了才 stopCallDownlink + int _callSinks = 0; + BesOtaPort? _ota; + + void _bind() { + _workers.add(ever(_link.lastCmdType, (type) { + switch (type) { + case 'startMicRecording': + _feature(DeviceFeatureKeys.recordStarted); + break; + case 'stopMicRecording': + _feature(DeviceFeatureKeys.recordStopped); + break; + } + })); + _workers.add(ever(_link.isCallOngoing, (on) { + _feature(on ? DeviceFeatureKeys.callStarted : DeviceFeatureKeys.callEnded); + })); + void infoChanged(dynamic _) => _infoUpdated(); + _workers.add(ever(_link.batteryLevel, (_) { + _feature(DeviceFeatureKeys.batteryUpdated); + _infoUpdated(); + })); + _workers.add(ever(_link.batteryCase, infoChanged)); + _workers.add(ever(_link.chargingLeft, infoChanged)); + _workers.add(ever(_link.chargingRight, infoChanged)); + _workers.add(ever(_link.chargingCase, infoChanged)); + _workers.add(ever(_link.deviceVersion, (_) { + _feature(DeviceFeatureKeys.firmwareUpdated); + _infoUpdated(); + })); + _workers.add(ever(_link.deviceMac, (_) { + _feature(DeviceFeatureKeys.macUpdated); + _infoUpdated(); + })); + _workers.add(ever(_link.connectedDeviceName, infoChanged)); + + // AI 通道:BB 64 唤醒 / BB E5 结束。应答 AA 6A 由业务层通过 ai.accept 发, + // 与原先 BesAiSessionService 的判断顺序一致(未配置时不应答、发 ai.exit)。 + _aiSub = _link.manager.onDeviceAiEvent().listen((event) { + switch (event['type']) { + case 'startAI': + _wake.add(DeviceWakeEvent(deviceId: deviceId, reason: WakeReason.ptt)); + break; + case 'stopAI': + _wake.add( + DeviceWakeEvent(deviceId: deviceId, reason: WakeReason.hangup)); + break; + } + }, onError: (e) => Logger.e(_tag, 'AI 事件流错误: $e')); + } + + void _feature(String key, [Map data = const {}]) { + if (_events.isClosed) return; + _events.add(DeviceSessionEvent( + type: DeviceSessionEventType.feature, + deviceId: deviceId, + feature: DeviceFeatureEvent(key: key, data: data), + )); + } + + void _infoUpdated() { + if (_events.isClosed) return; + _events.add(DeviceSessionEvent( + type: DeviceSessionEventType.deviceInfoUpdated, + deviceId: deviceId, + deviceInfo: info, + )); + } + + void _markDisconnected() { + if (_state == DeviceConnectionState.disconnected) return; + _state = DeviceConnectionState.disconnected; + for (final w in _workers) { + w.dispose(); + } + _workers.clear(); + _aiSub?.cancel(); + _aiSub = null; + _ota?.onSessionGone(); + if (!_events.isClosed) { + _events.add(DeviceSessionEvent( + type: DeviceSessionEventType.connectionStateChanged, + deviceId: deviceId, + connectionState: DeviceConnectionState.disconnected, + )); + _events.close(); + } + if (!_wake.isClosed) _wake.close(); + } + + void _requireReady() { + if (_state != DeviceConnectionState.ready) { + throw DeviceException(DeviceErrorCode.noActiveSession, 'bes'); + } + } + + // ---------- DeviceSession ---------- + + @override + String get deviceId => _link.connectedDeviceId.value; + + @override + String get vendor => DeviceVendors.bes; + + @override + DeviceConnectionState get state => _state; + + @override + Set get capabilities => caps; + + @override + Stream get eventStream => _events.stream; + + @override + Stream get wakeEvents => _wake.stream; + + static int? _nz(int v) => v >= 0 ? v : null; + + /// 真实 MAC:Android 系统给的就是;iOS 只能靠耳机自报(`BB 0A` 属性 7)。 + String? get macAddress { + if (Platform.isAndroid) { + final id = _link.connectedDeviceId.value; + return id.isEmpty ? null : id.toUpperCase(); + } + final m = _link.deviceMac.value; + return m.isEmpty ? null : m; + } + + @override + DeviceInfo get info { + final battery = DeviceBattery( + left: _nz(_link.batteryLeft.value), + right: _nz(_link.batteryRight.value), + caseBox: _nz(_link.batteryCase.value), + single: _nz(_link.batteryLevel.value), + chargingLeft: _link.chargingLeft.value, + chargingRight: _link.chargingRight.value, + chargingCase: _link.chargingCase.value, + ); + final fw = _link.deviceVersion.value; + return DeviceInfo( + id: deviceId, + name: _link.connectedDeviceName.value, + vendor: vendor, + firmwareVersion: fw.isEmpty ? null : fw, + batteryPercent: battery.summary, + battery: battery, + macAddress: macAddress, + metadata: { + 'inCall': _link.isCallOngoing.value, + 'localId': _link.connectedDeviceId.value, + }, + ); + } + + @override + Future readRssi() => + throw DeviceException(DeviceErrorCode.notSupported, 'rssi'); + + @override + Future readBattery() async { + _requireReady(); + await _link.queryBattery(); + return info.battery.summary; + } + + @override + Future refreshInfo() async { + _requireReady(); + await _link.refreshDeviceState(); + return info; + } + + /// 取真实 MAC,iOS 上会主动发一次查询并等最多 [timeout]。 + Future resolveMac({Duration timeout = const Duration(seconds: 3)}) => + _link.resolveDeviceMac(timeout: timeout); + + // ---------- 音频 ---------- + + @override + Future openMic({AudioRoute route = AudioRoute.mic}) async { + _requireReady(); + switch (route) { + case AudioRoute.mic: + // 现场录音:主麦与前馈麦交替上行,隔帧取主麦 + return _BesSource(route, _alternate(_link.audioStream)); + case AudioRoute.callLocal: + return _BesSource(route, _link.micPcmStream); + case AudioRoute.callPeer: + return _BesSource(route, _link.spkPcmStream); + case AudioRoute.ai: + return _BesSource( + route, + _link.manager + .onDeviceAiEvent() + .where((e) => e['type'] == 'micData' && e['data'] is Uint8List) + .map((e) => e['data'] as Uint8List) + .where((d) => d.isNotEmpty)); + default: + throw DeviceException(DeviceErrorCode.notSupported, 'openMic($route)'); + } + } + + static Stream _alternate(Stream raw) { + var take = true; + return raw.where((_) { + final t = take; + take = !take; + return t; + }); + } + + @override + Future openSpeaker( + {AudioRoute route = AudioRoute.ai}) async { + _requireReady(); + switch (route) { + case AudioRoute.ai: + await _link.manager.startAiDownlink(); + return _BesAiSink(_link); + case AudioRoute.callLegA: + case AudioRoute.callLegB: + if (_callSinks == 0) await _link.startTranslationDownlink(); + _callSinks++; + return _BesCallSink(_link, route, () async { + _callSinks--; + if (_callSinks <= 0) { + _callSinks = 0; + await _link.stopTranslationDownlink(); + } + }); + default: + throw DeviceException( + DeviceErrorCode.notSupported, 'openSpeaker($route)'); + } + } + + // ---------- feature ---------- + + /// feature → 字节的对应表。**冻结**,改动看金测试。 + static const Map featureCommands = { + DeviceFeatures.recordStart: BesCmd.micRecordStart, + DeviceFeatures.recordStop: BesCmd.micRecordStop, + DeviceFeatures.callStart: BesCmd.callModeStart, + DeviceFeatures.callStop: BesCmd.callModeStop, + DeviceFeatures.callQuery: BesCmd.callState, + DeviceFeatures.asrStarted: BesCmd.asrStarted, + DeviceFeatures.asrStopped: BesCmd.asrStopped, + DeviceFeatures.aiAccept: BesCmd.aiAccept, + DeviceFeatures.aiExit: BesCmd.aiExit, + DeviceFeatures.f2fStart: BesCmd.faceToFaceStart, + DeviceFeatures.f2fStop: BesCmd.faceToFaceStop, + DeviceFeatures.batteryQuery: BesCmd.status, + 'bes.firmware.query': BesCmd.firmwareVersion, + }; + + @override + Future> invokeFeature(String featureKey, + [Map args = const {}]) async { + _requireReady(); + final cmd = featureCommands[featureKey]; + if (cmd != null) { + await _link.sendData(BesCmd.frame(cmd)); + return {'sent': BesCmd.frame(cmd)}; + } + switch (featureKey) { + case DeviceFeatures.stateRefresh: + await _link.refreshDeviceState(); + return const {}; + case 'bes.probe': + await _link.probeCommands( + from: (args['from'] as int?) ?? 0x21, + to: (args['to'] as int?) ?? 0x7F, + ); + return const {}; + case 'bes.send': + final bytes = (args['bytes'] as List?)?.cast() ?? const []; + if (bytes.isEmpty) { + throw DeviceException(DeviceErrorCode.invalidArgument, 'bytes'); + } + await _link.sendData(bytes); + return {'sent': bytes}; + default: + throw DeviceException(DeviceErrorCode.notSupported, featureKey); + } + } + + @override + DeviceOtaPort? otaPort() => _ota ??= BesOtaPort(this, _link); + + @override + Future disconnect() => _link.disconnectDevice(); +} + +class _BesSource implements DeviceAudioSource { + _BesSource(this.route, Stream src) { + _sub = src.listen(_ctrl.add, onError: _ctrl.addError); + } + + @override + final AudioRoute route; + final StreamController _ctrl = StreamController.broadcast(); + StreamSubscription? _sub; + + @override + AudioFormat get format => AudioFormat.pcm16kMono; + + @override + Stream get pcm => _ctrl.stream; + + @override + Future close() async { + await _sub?.cancel(); + _sub = null; + await _ctrl.close(); + } +} + +/// AI 下行:G.722 编码与 60ms 组包、0xD6/0xE9 调速全在原生 `AiAudioDownlink`。 +class _BesAiSink implements DeviceAudioSink { + _BesAiSink(this._link); + final BesBluetoothService _link; + bool _closed = false; + + @override + AudioRoute get route => AudioRoute.ai; + + @override + AudioFormat get format => AudioFormat.pcm16kMono; + + @override + Future write(Uint8List pcm) async { + if (_closed) return; + await _link.manager.pushAiPcm(pcm); + } + + @override + Future discardPending() async { + if (_closed) return; + await _link.manager.flushAiDownlink(); + } + + @override + Future close() async { + if (_closed) return; + _closed = true; + await _link.manager.stopAiDownlink(); + } +} + +/// 通话翻译下行:`AA 56` 84 字节包,G.722 + 20ms 节流在原生 `CallTranslationDownlink`。 +class _BesCallSink implements DeviceAudioSink { + _BesCallSink(this._link, this.route, this._onClose); + final BesBluetoothService _link; + @override + final AudioRoute route; + final Future Function() _onClose; + bool _closed = false; + + String get _leg => route == AudioRoute.callLegA ? 'A' : 'B'; + + @override + AudioFormat get format => AudioFormat.pcm16kMono; + + @override + Future write(Uint8List pcm) async { + if (_closed) return; + await _link.pushTranslationTtsPcm(_leg, pcm); + } + + @override + Future discardPending() async {} + + @override + Future close() async { + if (_closed) return; + _closed = true; + await _onClose(); + } +} diff --git a/apps/client/lib/devices/bes/bes_ota_port.dart b/apps/client/lib/devices/bes/bes_ota_port.dart new file mode 100644 index 00000000..7fdf6202 --- /dev/null +++ b/apps/client/lib/devices/bes/bes_ota_port.dart @@ -0,0 +1,203 @@ +import 'dart:async'; + +import 'package:bes_ota_manager/bes_ota_manager.dart'; +import 'package:device_plugin_interface/device_plugin_interface.dart'; + +import '../../core/utils/logger.dart'; +import 'bes_bluetooth_service.dart'; +import 'bes_device_plugin.dart'; + +/// 恒玄 SPP OTA 2.0 端口。 +/// +/// 恒玄的固件推送由 `bes_ota_manager` 插件**自管**一条 SPP 链路,多数固件同时 +/// 只接受一条 SPP,所以: +/// 1. [start] 先断开 app 层连接、留 2s 让耳机拆完旧链路,再让插件连; +/// 2. 升级中 app 层的「已断开」是我们自己断的,**不能**当成 OTA 掉线; +/// 3. [close](退出升级页)先 stopOta 释放插件链路,再延时把 app 层连回来 +/// (成功 5s:设备在重启;其余 2s:等耳机拆链路)。 +/// +/// 看门狗、下载、屏幕常亮这些与厂商无关的事在通用 `OtaUpgradeController` 里。 +class BesOtaPort implements DeviceOtaPort { + BesOtaPort(this._session, this._link); + + static const String _tag = 'BesOtaPort'; + + final BesDeviceSession _session; + final BesBluetoothService _link; + final BesOtaManager _ota = BesOtaManager(); + final StreamController _progress = + StreamController.broadcast(); + + StreamSubscription? _sub; + bool _running = false; + bool _terminal = false; + bool _succeeded = false; + + /// 我们自己为了 OTA 断开了 app 层连接 + bool _handover = false; + String _address = ''; + String _name = ''; + int _lastPercent = -1; + + @override + bool get isRunning => _running; + + @override + Stream get progressStream => _progress.stream; + + @override + Future start(DeviceOtaRequest request) async { + if (_running) throw DeviceException(DeviceErrorCode.otaBusy); + if (request is! DeviceOtaFileRequest) { + throw DeviceException( + DeviceErrorCode.notSupported, '${request.runtimeType}'); + } + // 断开前先抓住设备标识(disconnect 会清空服务字段) + final id = _link.connectedDeviceId.value; + _address = id.isNotEmpty ? id : _address; + final n = _link.connectedDeviceName.value; + _name = n.isNotEmpty ? n : _name; + if (_address.isEmpty) { + throw DeviceException(DeviceErrorCode.noActiveSession, 'no address'); + } + + _running = true; + _terminal = false; + _succeeded = false; + _lastPercent = -1; + _emit(DeviceOtaState.entering, 0); + + // 1) 释放 app 层 SPP,让插件自管 OTA 连接。 + _handover = true; + await _link.disconnectDevice(); + // disconnect 返回只代表本端 socket 已关,耳机侧链路拆除还要在空中传播, + // 立刻连 OTA 会被拒;留 2s 缓冲。 + await Future.delayed(const Duration(seconds: 2)); + if (!_running) return; // 期间被 cancel 了 + + // 2) 初始化原生 OTA 环境并订阅事件。 + await _ota.init(); + _sub ??= _ota.events.listen(_onEvent, onError: (Object e) { + _fail(DeviceErrorCode.otaTransferFailed, e.toString()); + }); + + // 3) 启动 SPP OTA 2.0。 + _emit(DeviceOtaState.transferring, 0); + await _ota.startOta( + address: _address, + name: _name, + filePath: request.filePath, + protocol: 'SPP', + userFlag: 1, + upgradeType: 1, + user: 1, + ); + } + + void _onEvent(BesOtaEvent e) { + // 进入终态即锁定:迟到的进度/Canceled/Error 一律忽略, + // 否则成功会被迟到的 Canceled 覆盖成失败。 + if (_terminal) return; + switch (e.event) { + case 'onProgress': + _emitPercent(((e.progress ?? 0) * 100).round()); + break; + case 'onStatus': + // OTA_SEND_DATA_PROGRESS 状态消息带原始浮点百分比,其余状态文本只记日志 + final numeric = double.tryParse(e.message ?? ''); + if (numeric != null && numeric > 0) { + _emitPercent(numeric.clamp(0, 100).round()); + } else { + Logger.d(_tag, 'OTA 状态: ${e.message}'); + } + break; + case 'onSuccess': + _terminal = true; + _succeeded = true; + _running = false; + _emit(DeviceOtaState.done, 100); + _ota.stopOta(); + break; + case 'onError': + _fail(DeviceErrorCode.otaTransferFailed, + e.message?.isNotEmpty == true ? e.message! : 'code=${e.code}'); + break; + } + } + + /// 进度只在单调递增时上报,避免两个来源(整数 onProgress / 浮点 onStatus) + /// 高频交替导致数字来回跳。 + void _emitPercent(int p) { + if (p <= _lastPercent) return; + _lastPercent = p; + _emit(DeviceOtaState.transferring, p); + } + + void _fail(String code, String message) { + if (_terminal) return; + _terminal = true; + _running = false; + _emit(DeviceOtaState.failed, -1, errorCode: code, errorMessage: message); + // 失败即释放原生会话与 SPP 链路,否则它还占着唯一的 SPP + _ota.stopOta(); + } + + void _emit(DeviceOtaState st, int percent, + {String? errorCode, String? errorMessage}) { + if (_progress.isClosed) return; + _progress.add(DeviceOtaProgress( + state: st, + sentBytes: 0, + totalBytes: 0, + percent: percent, + tsMs: DateTime.now().millisecondsSinceEpoch, + errorCode: errorCode, + errorMessage: errorMessage, + )); + } + + @override + Future cancel() async { + if (!_running) return; + _running = false; + _terminal = true; + await _ota.stopOta(); + _emit(DeviceOtaState.cancelled, -1); + } + + /// 会话没了(app 层断开)。升级中那次断开是我们自己发起的,忽略。 + void onSessionGone() { + if (_handover) return; + if (_running) _fail(DeviceErrorCode.disconnectedRemote, '设备断开'); + } + + @override + Future close() async { + _running = false; + await _sub?.cancel(); + _sub = null; + // 先停 OTA 会话释放插件占用的 SPP,再回连 app 层,避免两条连接打架。 + try { + await _ota.stopOta(); + } catch (_) {} + _ota.dispose(); + if (!_progress.isClosed) await _progress.close(); + + if (!_handover || _address.isEmpty) return; + _handover = false; + final delay = _succeeded + ? const Duration(seconds: 5) // 设备正在重启 + : const Duration(seconds: 2); // 等耳机拆完 OTA 链路再回连 + final addr = _address; + final name = _name; + Future.delayed(delay, () { + // 期间可能已(自动/手动)连上,避免重复发起 + if (_link.deviceStatus.value != 2) { + _link.connectToDevice(addr, name); + } + }); + } + + /// 供插件在会话对象被替换时判断归属。 + bool belongsTo(BesDeviceSession s) => identical(s, _session); +} diff --git a/apps/client/lib/devices/bes/bes_protocol.dart b/apps/client/lib/devices/bes/bes_protocol.dart new file mode 100644 index 00000000..f0cd1080 --- /dev/null +++ b/apps/client/lib/devices/bes/bes_protocol.dart @@ -0,0 +1,278 @@ +/// 恒玄(BES) 私有协议的**纯函数**编解码。 +/// +/// ⚠️ **协议冻结**:这里的每一个字节都对应已量产固件(Echo-one 0.0.2 / 0.0.3) +/// 的真实行为,由 `test/devices/bes_protocol_test.dart` 用真机抓的帧做金测试。 +/// 改任何一个常量、任何一处偏移,先看测试为什么红。 +/// +/// 这个文件不依赖 Flutter / GetX / 原生插件,只做 `List` ↔ 结构体。 +library; + +/// App → 耳机 的命令(`AA xx`)。 +/// +/// 来源:厂商协议 + 真机实测(见各条注释)。请求码与响应码的关系**没有** +/// 统一规律:多数是 `响应 = 请求 | 0x80`(06→86、0E→8E),但电量是 09→0A。 +class BesCmd { + BesCmd._(); + + static const int sof = 0xAA; + + /// 固件版本查询,回 `BB 86`(或主动上报 `BB 07`) + static const int firmwareVersion = 0x06; + + /// 耳机状态(电量/位置/连接/MAC),回 `BB 0A` + ack `BB 89`。 + /// 实测扫出来的请求码,文档里那个「响应命令 20」两种进制都无回包。 + /// ⚠️ 固件 0.0.3 上此命令已不回 `BB 0A`。 + static const int status = 0x09; + + /// 按键配置查询,回 `BB 8C` + static const int keyConfig = 0x0C; + + /// 通话状态查询,回 `BB 8E`(data[6] 0x01=来电 / 0x02=通话中 / 0x03=结束) + static const int callState = 0x0E; + + /// EQ 配置查询,回 `BB 90` + static const int eqConfig = 0x10; + + /// 进入通话模式:耳机开始 G.722 mode=0x03 双路交织上行 + static const int callModeStart = 0x51; + + /// 退出通话模式 + static const int callModeStop = 0x52; + + /// 通话翻译下行 TTS 包(84 字节,由原生 `CallTranslationDownlink` 组包) + static const int translationTts = 0x56; + + /// 通知耳机 ASR 已开启 + static const int asrStarted = 0x57; + + /// 通知耳机 ASR 已关闭 + static const int asrStopped = 0x58; + + /// 开始设备端主麦录音(mSBC 上行),设备回 `BB D9` + static const int micRecordStart = 0x59; + + /// 停止设备端主麦录音,设备回 `BB DA` + static const int micRecordStop = 0x5A; + + /// 请求退出 AI 模式,耳机回 `BB E5` + static const int aiExit = 0x65; + + /// 开始面对面翻译,耳机回 `BB E6` + static const int faceToFaceStart = 0x66; + + /// 结束面对面翻译,耳机回 `BB E7` + static const int faceToFaceStop = 0x67; + + /// 应答 AI 唤醒(收到 `BB 64` 后 **3 秒内**必须发) + static const int aiAccept = 0x6A; + + /// 这一段会切换耳机工作模式(进通话/录音/AI),探测时一律不碰 + static bool switchesMode(int cmd) => cmd >= 0x51 && cmd <= 0x6A; + + static List frame(int cmd) => [sof, cmd & 0xFF]; +} + +/// 耳机 → App 的响应/上报(`BB xx`)。 +class BesResp { + BesResp._(); + + static const int sof = 0xBB; + + /// 主动上报固件版本 + static const int firmwarePush = 0x07; + + /// 响应 `AA 06` + static const int firmwareVersion = 0x86; + + /// 耳机状态帧(电量/位置/连接/MAC) + static const int status = 0x0A; + + /// `AA 09` 的 ack(`BB 89 04 00`),无数据 + static const int statusAck = 0x89; + + /// 周期上报的另一种状态帧,`[len,attr,value]×2`,值 0/1,含义未定 + static const int unknown18 = 0x18; +} + +/// `BB 0A` 状态帧里的属性号。 +class BesStatusAttr { + BesStatusAttr._(); + + static const int batteryLeft = 0x01; + static const int batteryRight = 0x02; + static const int batteryCase = 0x03; + + /// 耳机位置(是否在盒内),值常 >100,**不是电量** + static const int position = 0x04; + + /// 连接状态,值常 >100 + static const int connection = 0x05; + + /// 预留 12 字节,里面嵌着一个含义未知的 MAC,**不要拿去确权** + static const int reserved = 0x06; + + /// 耳机蓝牙地址 6 字节——耳机自报的真 MAC,iOS 上唯一可靠来源 + static const int macAddress = 0x07; +} + +/// `BB 0A` 解析结果。没出现的字段为 null。 +class BesStatus { + const BesStatus({ + this.batteryLeft, + this.batteryRight, + this.batteryCase, + this.chargingLeft = false, + this.chargingRight = false, + this.chargingCase = false, + this.mac, + }); + + final int? batteryLeft; + final int? batteryRight; + final int? batteryCase; + final bool chargingLeft; + final bool chargingRight; + final bool chargingCase; + + /// 大写、冒号分隔,如 `11:11:22:33:33:A4` + final String? mac; + + bool get hasBattery => + batteryLeft != null || batteryRight != null || batteryCase != null; + + /// 「只显示一个数」时用:左右取较低一侧;两只都在盒里只有盒电量时退回盒。 + int? get summaryBattery { + final lr = [batteryLeft, batteryRight].whereType().toList(); + if (lr.isNotEmpty) return lr.reduce((a, b) => a < b ? a : b); + return batteryCase; + } + + @override + String toString() => + 'BesStatus(L=$batteryLeft${chargingLeft ? '⚡' : ''} ' + 'R=$batteryRight${chargingRight ? '⚡' : ''} ' + 'C=$batteryCase${chargingCase ? '⚡' : ''} mac=$mac)'; +} + +/// 固件版本帧解析结果。 +class BesFirmware { + const BesFirmware({this.left, this.right, this.box}); + + final String? left; + final String? right; + final String? box; + + /// 优先左耳。 + String? get preferred => left ?? right ?? box; +} + +class BesProtocol { + BesProtocol._(); + + static String hex(Iterable b) => b + .map((e) => (e & 0xFF).toRadixString(16).padLeft(2, '0').toUpperCase()) + .join(' '); + + static String macOf(Iterable b) => b + .map((e) => (e & 0xFF).toRadixString(16).padLeft(2, '0').toUpperCase()) + .join(':'); + + /// 是不是一个 `BB` 开头、至少带命令号的帧。 + static bool isResponse(List data) => + data.length >= 2 && (data[0] & 0xFF) == BesResp.sof; + + /// 解析 `BB 0A` 状态帧。 + /// + /// 帧格式 `BB | 0A | CMD Length(1) | 块… | checksum`, + /// 块是 `[len, 属性, 数据…]`,**len 计的是「属性 + 数据」的字节数**。 + /// + /// 三个踩过的坑(都会让电量永远显示不出来、且不报错): + /// 1. 起点是 **3** 不是 4:长度字段只有 1 字节,照抄固件版本帧的 `p = 4` + /// 会整体错位一格,第一个块长度读成 0x01 < 2 直接 break; + /// 2. 充电盒是属性 **0x03** 不是 0x04(0x04 是耳机位置,值常 >100); + /// 3. 电量字节最高位是充电指示位,必须 `& 0x7F`。 + /// + /// 属性 3(盒电量)**可选**:耳机不在盒内时这帧没有它。 + static BesStatus parseStatus(List data) { + int? l, r, c; + var cl = false, cr = false, cc = false; + String? mac; + var p = 3; + while (p + 2 < data.length) { + final blockLen = data[p] & 0xFF; + if (blockLen < 2 || p + blockLen >= data.length) break; + final attr = data[p + 1] & 0xFF; + final raw = data[p + 2] & 0xFF; + final level = raw & 0x7F; + final charging = (raw & 0x80) != 0; + if (level <= 100) { + switch (attr) { + case BesStatusAttr.batteryLeft: + l = level; + cl = charging; + break; + case BesStatusAttr.batteryRight: + r = level; + cr = charging; + break; + case BesStatusAttr.batteryCase: + c = level; + cc = charging; + break; + } + } + if (attr == BesStatusAttr.macAddress && + blockLen >= 7 && + p + 7 < data.length) { + mac = macOf(data.sublist(p + 2, p + 8)); + } + p += blockLen + 1; + } + return BesStatus( + batteryLeft: l, + batteryRight: r, + batteryCase: c, + chargingLeft: cl, + chargingRight: cr, + chargingCase: cc, + mac: mac, + ); + } + + /// 解析固件版本帧(`BB 86` / `BB 07`): + /// `BB CMD len(2) + 若干版本块 [len, 属性, 8字节版本]`, + /// 属性 0x01左 / 0x02右 / 0x04盒;版本取版本块后 3 字节。 + static BesFirmware parseFirmware(List data) { + String? left, right, box; + var p = 4; + while (p + 1 < data.length) { + final blockLen = data[p] & 0xFF; + if (blockLen < 2 || p + blockLen >= data.length) break; + final attr = data[p + 1] & 0xFF; + final version = data.sublist(p + 2, p + 1 + blockLen); + if (version.length >= 8) { + final v = '${version[5]}.${version[6]}.${version[7]}'; + switch (attr) { + case 0x01: + left = v; + break; + case 0x02: + right = v; + break; + case 0x04: + box = v; + break; + } + } + p += blockLen + 1; + } + return BesFirmware(left: left, right: right, box: box); + } + + /// 通话状态查询 `BB 8E` 的 data[6]:0x01=来电 0x02=通话中 0x03=结束。 + /// 原生已经把 0x02/0x03 翻成 startMakeCall/stopMakeCall 事件,这里只作备查。 + static int? parseCallState(List data) { + if (data.length < 7 || (data[1] & 0xFF) != 0x8E) return null; + return data[6] & 0xFF; + } +} diff --git a/apps/client/lib/devices/device_auth.dart b/apps/client/lib/devices/device_auth.dart new file mode 100644 index 00000000..3b30707a --- /dev/null +++ b/apps/client/lib/devices/device_auth.dart @@ -0,0 +1,71 @@ +import 'package:device_plugin_interface/device_plugin_interface.dart'; + +import 'bes/bes_device_auth.dart'; +import 'bes/bes_device_plugin.dart'; +import 'device_hub.dart'; +import 'smartcar/smartcar_device_auth.dart'; +import 'smartcar/smartcar_device_plugin.dart'; + +/// 确权结论(厂商无关)。 +/// +/// ⚠️ [unknown] 的语义是**放行**,不是"失败"。判不出来时放行是刻意选的失败方向: +/// 判严的代价是合法用户一断网就用不了自己的设备;判宽只是未登记设备离线时 +/// 能用一会儿,联网第一次确权就会被拦下。 +enum DeviceAuthResult { + /// 白名单命中 / 服务端确认 / 已在 User.devices 里 + authorized, + + /// 服务端明确拒绝(这台 MAC 没登记) + denied, + + /// 未登录 / 断网 / 拿不到 MAC —— **放行** + unknown, +} + +/// 设备确权的业务层入口(登出重置、解绑清白名单、补校当前设备)。 +/// +/// 确权本身是各厂商插件在连接时就地做的(恒玄见 [BesDeviceAuth]),这里只把 +/// 需要从业务层触发的几个动作聚在一起,业务层不用知道是哪家的实现。 +class DeviceAuth { + DeviceAuth._(); + + /// 登出:换账号后同一台设备要重新登记到新账号名下。**白名单不清**。 + static void reset() { + BesDeviceAuth.reset(); + SmartcarDeviceAuth.reset(); + } + + /// 解绑:移出白名单,下次连上重新走服务端确权。 + /// + /// 两家都调:调用方只有一个 MAC,不知道它属于哪个厂商, + /// 而各家白名单是按 MAC 存的、互不相干,多清一次无副作用。 + static Future forget(String mac) async { + await BesDeviceAuth.forget(mac); + await SmartcarDeviceAuth.forget(mac); + } + + /// 【调试/售后】清空白名单 + static Future clearWhitelist() async { + await BesDeviceAuth.clearWhitelist(); + await SmartcarDeviceAuth.clearWhitelist(); + } + + /// 补校当前连着的、需要确权的设备(顺带完成绑定登记)。 + /// 「先连耳机后登录」时连接事件里的登记会因未登录跳过,设备管理页进来补一次。 + static Future verifyConnected() async { + final hub = DeviceHub.to; + for (final s in hub.sessions.toList()) { + if (!s.supports(DeviceCapability.macAddress)) continue; + final p = hub.plugin(s.vendor); + if (p is BesDevicePlugin) { + await p.link.auth?.verifyConnected(); + } else if (p is SmartcarDevicePlugin) { + // Smartcar 的 MAC 来自广播,两端都拿得到(不像恒玄 iOS 只有 UUID) + await SmartcarDeviceAuth.verify( + mac: s.info.macAddress, + name: s.info.name, + ); + } + } + } +} diff --git a/apps/client/lib/devices/device_bootstrap.dart b/apps/client/lib/devices/device_bootstrap.dart new file mode 100644 index 00000000..68ba6d20 --- /dev/null +++ b/apps/client/lib/devices/device_bootstrap.dart @@ -0,0 +1,41 @@ +import 'package:device_plugin_interface/device_plugin_interface.dart'; +import 'package:get/get.dart'; + +import '../core/utils/logger.dart'; +import 'bes/bes_device_auth.dart'; +import 'bes/bes_device_plugin.dart'; +import 'device_hub.dart'; +import 'holder/holder_device_plugin.dart'; +import 'smartcar/smartcar_device_plugin.dart'; + +/// 装配设备中枢:注册所有厂商插件并初始化。由 InitialBinding 调用一次。 +/// +/// 新接一种芯片 = 在这里多 `registerPlugin` 一行 + `lib/devices//` 一个目录, +/// 业务层零改动。 +/// +/// 两个插件的 `initialize` 都是轻量的:恒玄在 iOS 上必须无条件建 CBCentralManager +/// 才能被动接管系统层连接,Android 只在绑过设备时申请权限;支架只在记住过设备时 +/// 注册后台扫连。所以这里不做「按绑定记录挑插件」,行为与改造前一致。 +void installDeviceHub() { + if (Get.isRegistered()) return; + final hub = Get.put(DeviceHub(), permanent: true); + + final bes = BesDevicePlugin(); + bes.link.auth = BesDeviceAuth(bes.link); + _register(hub, bes); + + _register(hub, HolderDevicePlugin()); + + // Smartcar 车载香薰。BLE 走 flutter_blue_plus(纯 Dart,无原生插件)。 + // initialize 里不申请权限也不起扫描,冷启动零副作用。 + _register(hub, SmartcarDevicePlugin()); +} + +void _register(DeviceHub hub, DevicePlugin p) { + try { + hub.registerPlugin(p); + p.initialize(const DevicePluginConfig()); + } catch (e) { + Logger.e('DeviceBootstrap', '初始化厂商插件 ${p.vendorKey} 失败: $e'); + } +} diff --git a/apps/client/lib/devices/device_hub.dart b/apps/client/lib/devices/device_hub.dart new file mode 100644 index 00000000..da8d1d90 --- /dev/null +++ b/apps/client/lib/devices/device_hub.dart @@ -0,0 +1,265 @@ +import 'dart:async'; + +import 'package:device_plugin_interface/device_plugin_interface.dart'; +import 'package:get/get.dart'; + +import '../core/utils/logger.dart'; + +/// 设备中枢——**业务层唯一的设备入口**。 +/// +/// 业务层(`lib/modules`、`lib/core`)只能 import 这个文件和 +/// `device_plugin_interface`,不能 import 任何 `lib/devices//` 下的东西, +/// `test/devices/device_hub_boundary_test.dart` 守着这条线。 +/// +/// 职责: +/// - 持有各厂商插件(恒玄 / EaiRec 支架 / 以后接的每一种芯片各一个); +/// - 汇总所有 `ready` 会话成一份可观测状态(连没连、能力集、电量、通话中…); +/// - 合流唤醒事件与会话事件,供常驻服务订阅; +/// - 「最近连过的设备」这类展示用信息也从这里拿。 +/// +/// 多设备模型:耳机和支架可以同时在线,所以是 `sessions` 列表而不是单个 +/// active。要「一台能录音的设备」就问 [sessionWith]。 +class DeviceHub extends GetxService { + static const String _tag = 'DeviceHub'; + static DeviceHub get to => Get.find(); + + final Map _plugins = {}; + final Map _pluginSubs = {}; + final Map _sessionSubs = {}; + final Map _wakeSubs = {}; + + /// 当前 `ready` 的会话,按连上的先后排。 + final RxList sessions = [].obs; + + /// 任一设备在线。 + final RxBool anyConnected = false.obs; + + /// 所有在线设备能力的并集。UI 显隐一律问它,不问厂商。 + final RxSet capabilities = {}.obs; + + /// 正在通话中(由声明了 [DeviceCapability.callState] 的设备上报)。 + final RxBool inCall = false.obs; + + /// 主设备(第一台在线的)的信息快照;没有设备时为 null。 + final Rxn primaryInfo = Rxn(); + + /// 主设备名;没有设备时为空串。 + final RxString primaryName = ''.obs; + + /// 每当任何会话的 info / 状态变了就 +1,给只想「重画一下」的 UI 用。 + final RxInt revision = 0.obs; + + final StreamController _wake = + StreamController.broadcast(); + final StreamController _events = + StreamController.broadcast(); + + /// 所有在线设备的唤醒事件合流。 + Stream get wakeEvents => _wake.stream; + + /// 所有在线设备的会话事件合流。 + Stream get sessionEvents => _events.stream; + + // ---------------- 插件管理 ---------------- + + Iterable get plugins => _plugins.values; + + DevicePlugin? plugin(String vendorKey) => _plugins[vendorKey]; + + /// 注册一个厂商插件。重复 key 直接覆盖并报警(正常不该发生)。 + void registerPlugin(DevicePlugin p) { + if (_plugins.containsKey(p.vendorKey)) { + Logger.w(_tag, '重复注册厂商插件 ${p.vendorKey},覆盖旧实例'); + _pluginSubs.remove(p.vendorKey)?.cancel(); + } + _plugins[p.vendorKey] = p; + _pluginSubs[p.vendorKey] = p.eventStream.listen( + (e) => _onPluginEvent(p, e), + onError: (e) => Logger.e(_tag, '${p.vendorKey} 事件流错误: $e'), + ); + // 插件可能在注册前就已经连上了(比如原生被动回连),补一次快照 + final s = p.activeSession; + if (s != null && s.state == DeviceConnectionState.ready) _attach(s); + Logger.i(_tag, '已注册厂商插件 ${p.vendorKey}(${p.displayName})'); + } + + // ---------------- 查询 ---------------- + + bool has(DeviceCapability c) => capabilities.contains(c); + + /// 第一台具备该能力的在线设备;没有为 null。 + DeviceSession? sessionWith(DeviceCapability c) { + for (final s in sessions) { + if (s.supports(c)) return s; + } + return null; + } + + /// 主音频设备:第一台能上行麦克风的。 + DeviceSession? get primaryAudio => sessionWith(DeviceCapability.micUplink); + + /// 主设备 = **最近上线**的那台([_attach] 把新会话插在最前)。 + DeviceSession? get primary => sessions.isEmpty ? null : sessions.first; + + DeviceSession? sessionOf(String vendorKey) { + for (final s in sessions) { + if (s.vendor == vendorKey) return s; + } + return null; + } + + /// 最近连过(或正连着)的设备名,用于断连后仍显示「我的那台设备」。 + /// 从没连过任何设备返回空串。 + String get lastKnownName { + if (primaryName.value.isNotEmpty) return primaryName.value; + for (final p in _plugins.values) { + final d = p.lastKnownDevice; + if (d != null && d.name.isNotEmpty) return d.name; + } + return ''; + } + + bool get hasKnownDevice => lastKnownName.isNotEmpty; + + // ---------------- 动作 ---------------- + + /// 冷启动/前台恢复时让每个插件各自回连记住的设备。 + Future tryAutoConnectAll() async { + for (final p in _plugins.values) { + try { + await p.tryAutoConnect(); + } catch (e) { + Logger.e(_tag, '${p.vendorKey} 自动回连失败: $e'); + } + } + } + + Future disconnectAll() async { + for (final s in sessions.toList()) { + try { + await s.disconnect(); + } catch (e) { + Logger.e(_tag, '${s.vendor} 断开失败: $e'); + } + } + } + + /// 进功能页时刷一遍所有在线设备的状态(电量会掉、配置会变)。 + Future refreshAll() async { + for (final s in sessions.toList()) { + try { + await s.refreshInfo(); + } catch (e) { + Logger.w(_tag, '${s.vendor} 刷新状态失败: $e'); + } + } + } + + // ---------------- 内部 ---------------- + + void _onPluginEvent(DevicePlugin p, DevicePluginEvent e) { + switch (e.type) { + case DevicePluginEventType.connectionStateChanged: + final s = p.activeSession; + if (e.connectionState == DeviceConnectionState.ready && s != null) { + _attach(s); + } else if (e.connectionState == DeviceConnectionState.disconnected) { + _detachVendor(p.vendorKey); + } + break; + case DevicePluginEventType.error: + Logger.w(_tag, '${p.vendorKey} 错误: ${e.errorCode} ${e.errorMessage}'); + break; + default: + break; + } + } + + void _attach(DeviceSession s) { + if (sessions.any((x) => identical(x, s))) return; + // 同一厂商只保留最新的会话 + _detachVendor(s.vendor, silent: true); + // ★ 单活动设备:用户从车载香薰切到耳机(反之亦然),旧设备的链路要断掉, + // 不能两台同时在线——设备页只有一张图、一个电量,两台并存时展示和音频路由都会打架。 + // 新上线的设备**排在最前**成为主设备,设备页的图/名字/电量跟着它走。 + // (2026-09-10 用户反馈:切到耳机后香薰 BLE 没断、设备页图片也没换。) + // ⚠️ 被动回连(系统 ACL 把耳机连回来)同样会走到这里并顶掉当前设备,这是刻意的: + // "最近连上的那台就是用户此刻想用的那台"。 + for (final other in sessions.where((x) => x.vendor != s.vendor).toList()) { + Logger.i(_tag, '切换设备:${s.vendor} 上线,断开 ${other.vendor} ${other.info.name}'); + _detachVendor(other.vendor, silent: true); + other.disconnect().catchError((Object e) { + Logger.w(_tag, '${other.vendor} 断开失败: $e'); + }); + } + sessions.insert(0, s); + _sessionSubs[s.vendor] = s.eventStream.listen((e) { + _events.add(e); + _onSessionEvent(s, e); + }); + _wakeSubs[s.vendor] = s.wakeEvents.listen(_wake.add); + Logger.i(_tag, '设备上线: ${s.vendor} ${s.info.name} caps=${s.capabilities.map((c) => c.name).join(',')}'); + _recompute(); + } + + void _detachVendor(String vendor, {bool silent = false}) { + _sessionSubs.remove(vendor)?.cancel(); + _wakeSubs.remove(vendor)?.cancel(); + final before = sessions.length; + sessions.removeWhere((x) => x.vendor == vendor); + if (sessions.length != before && !silent) { + Logger.i(_tag, '设备下线: $vendor'); + } + _recompute(); + } + + void _onSessionEvent(DeviceSession s, DeviceSessionEvent e) { + switch (e.type) { + case DeviceSessionEventType.connectionStateChanged: + if (e.connectionState == DeviceConnectionState.disconnected) { + _detachVendor(s.vendor); + } + break; + case DeviceSessionEventType.deviceInfoUpdated: + _recompute(); + break; + case DeviceSessionEventType.feature: + final key = e.feature?.key; + if (key == DeviceFeatureKeys.callStarted) inCall.value = true; + if (key == DeviceFeatureKeys.callEnded) inCall.value = false; + break; + default: + break; + } + } + + void _recompute() { + anyConnected.value = sessions.isNotEmpty; + final caps = {}; + for (final s in sessions) { + caps.addAll(s.capabilities); + } + capabilities.assignAll(caps); + final p = primary; + primaryInfo.value = p?.info; + primaryName.value = p?.info.name ?? ''; + if (p == null) inCall.value = false; + revision.value++; + } + + @override + void onClose() { + for (final s in _pluginSubs.values) { + s.cancel(); + } + for (final s in _sessionSubs.values) { + s.cancel(); + } + for (final s in _wakeSubs.values) { + s.cancel(); + } + _wake.close(); + _events.close(); + super.onClose(); + } +} diff --git a/apps/client/lib/devices/device_vendors.dart b/apps/client/lib/devices/device_vendors.dart new file mode 100644 index 00000000..4b5ffd09 --- /dev/null +++ b/apps/client/lib/devices/device_vendors.dart @@ -0,0 +1,14 @@ +/// 厂商插件的 key。业务层只在「选品类 → 进哪个连接页」这类地方用到, +/// 其余判断一律走 [DeviceCapability]。 +class DeviceVendors { + DeviceVendors._(); + + /// 恒玄 BES 耳机(Echo-one),经典蓝牙 SPP / iOS GATT 桥 + static const String bes = 'bes'; + + /// EaiRec 录音支架,BLE + Opus + static const String holder = 'holder'; + + /// Smartcar 车载香薰(协议待接) + static const String smartcar = 'smartcar'; +} diff --git a/apps/client/lib/devices/holder/holder_device_plugin.dart b/apps/client/lib/devices/holder/holder_device_plugin.dart new file mode 100644 index 00000000..97f3ff36 --- /dev/null +++ b/apps/client/lib/devices/holder/holder_device_plugin.dart @@ -0,0 +1,437 @@ +import 'dart:async'; +import 'dart:io'; +import 'dart:typed_data'; + +import 'package:device_plugin_interface/device_plugin_interface.dart'; +import 'package:echomeet_device_sdk/echomeet_device_sdk.dart'; +import 'package:get/get.dart'; + +import '../../core/utils/logger.dart'; +import '../device_vendors.dart'; +import 'holder_device_service.dart'; +import 'holder_file_store.dart'; +import 'holder_ota_port.dart'; + +/// EaiRec 录音支架的厂商插件——把 [HolderDeviceService](协议在 `recorder_holder` +/// 插件里,冻结)适配成 [DevicePlugin] / [DeviceSession]。 +/// +/// 与恒玄的差异全在这一层吸收:BLE 扫描而不是系统配对列表、Opus 而不是 G.722、 +/// 录音是「切状态 + 开编码器」两步而不是一条 `AA 59`。业务层看到的是同一套 +/// `openMic(mic)` / `record.start`。 +class HolderDevicePlugin implements DevicePlugin { + final HolderDeviceService link; + + HolderDevicePlugin({HolderDeviceService? link}) + : link = link ?? HolderDeviceService(); + + final StreamController _ctrl = + StreamController.broadcast(); + HolderDeviceSession? _session; + final List _workers = []; + bool _initialized = false; + bool _disposed = false; + + @override + String get vendorKey => DeviceVendors.holder; + + @override + String get displayName => 'EaiRec (录音支架)'; + + @override + Set get capabilities => HolderDeviceSession.caps; + + @override + DevicePluginConfigSchema get configSchema => DevicePluginConfigSchema.empty; + + @override + DeviceSession? get activeSession => _session; + + @override + Stream get eventStream => _ctrl.stream; + + @override + Future initialize(DevicePluginConfig config) async { + _checkAlive(); + if (_initialized) return; + _initialized = true; + if (!Get.isRegistered()) { + Get.put(link, permanent: true); + } + _workers.add(ever(link.connection, _onConn)); + _workers.add(ever>(link.scanned, (list) { + for (final d in list) { + _emit(DevicePluginEvent( + type: DevicePluginEventType.deviceDiscovered, + deviceId: d.address, + discovered: _discovered(d), + )); + } + })); + _workers.add(ever(link.isScanning, (on) { + _emit(DevicePluginEvent( + type: on + ? DevicePluginEventType.scanStarted + : DevicePluginEventType.scanStopped)); + })); + if (link.isConnected) _onConn(EchoMeetConnectionState.connected); + _emit(const DevicePluginEvent(type: DevicePluginEventType.pluginReady)); + } + + DiscoveredDevice _discovered(EchoMeetScannedDevice d) => DiscoveredDevice( + id: d.address.isNotEmpty ? d.address : d.id, + name: d.name, + vendor: vendorKey, + metadata: {'advertisementHex': d.advertisementHex, 'sdkId': d.id}, + ); + + void _onConn(EchoMeetConnectionState s) { + switch (s) { + case EchoMeetConnectionState.connected: + if (_session != null) return; + final sess = HolderDeviceSession._(link); + _session = sess; + _emit(DevicePluginEvent( + type: DevicePluginEventType.connectionStateChanged, + deviceId: sess.deviceId, + connectionState: DeviceConnectionState.ready, + )); + break; + case EchoMeetConnectionState.connecting: + _emit(DevicePluginEvent( + type: DevicePluginEventType.connectionStateChanged, + deviceId: link.connectedAddress, + connectionState: DeviceConnectionState.connecting, + )); + break; + default: + final sess = _session; + _session = null; + sess?._markDisconnected(); + _emit(DevicePluginEvent( + type: DevicePluginEventType.connectionStateChanged, + deviceId: sess?.deviceId, + connectionState: DeviceConnectionState.disconnected, + )); + } + } + + void _emit(DevicePluginEvent e) { + if (!_ctrl.isClosed) _ctrl.add(e); + } + + // ---------- 扫描 / 连接 ---------- + + @override + Future ensureReady() async { + if (link.isPermissionCoolingDown) return false; + // 用户主动进连接页:允许再申请一次系统蓝牙权限 + link.resetPermissionDenied(); + return link.requestPermission(); + } + + /// 用户拒绝过蓝牙权限且仍在冷却期——UI 据此置灰入口 + bool get isPermissionCoolingDown => link.isPermissionCoolingDown; + + @override + Future startScan({DeviceScanFilter? filter, Duration? timeout}) => + link.startScan(); + + @override + Future stopScan() => link.stopScan(); + + @override + Future isScanning() async => link.isScanning.value; + + @override + Future> bondedDevices() async => + link.scanned.map(_discovered).toList(growable: false); + + @override + Future connect(String deviceId, + {DeviceConnectOptions? options}) async { + _checkAlive(); + final d = link.scanned.firstWhereOrNull( + (x) => x.address.toUpperCase() == deviceId.toUpperCase() || x.id == deviceId); + if (d == null) { + throw DeviceException(DeviceErrorCode.connectFailed, '未扫描到 $deviceId'); + } + final registered = await link.connect(d); + if (!registered) { + throw DeviceException(DeviceErrorCode.connectFailed, deviceId); + } + final ok = await link.waitConnected( + timeout: options?.timeout ?? const Duration(seconds: 25)); + if (!ok) throw DeviceException(DeviceErrorCode.connectTimeout, deviceId); + _onConn(EchoMeetConnectionState.connected); + return _session!; + } + + @override + List get rememberedDevices => link.pairedDevices + .map((e) => DiscoveredDevice( + id: (e['address'] ?? '').toString(), + name: (e['name'] ?? '').toString(), + vendor: vendorKey, + )) + .where((d) => d.id.isNotEmpty) + .toList(growable: false); + + @override + DiscoveredDevice? get lastKnownDevice { + final n = link.lastDeviceName; + if (n.isEmpty) return null; + return DiscoveredDevice(id: link.connectedAddress, name: n, vendor: vendorKey); + } + + @override + Future rememberDevice(DiscoveredDevice device) async { + // 支架在 connect 成功时自己会记;这里保持幂等 + } + + @override + Future tryAutoConnect() => link.tryAutoConnect(); + + @override + Future forgetDevice(String deviceId) async { + link.removePaired(deviceId); + if (link.isConnected && + link.connectedAddress.toUpperCase() == deviceId.toUpperCase()) { + await link.disconnect(); + } + } + + @override + Future dispose() async { + _disposed = true; + for (final w in _workers) { + w.dispose(); + } + _session?._markDisconnected(); + _session = null; + await _ctrl.close(); + } + + void _checkAlive() { + if (_disposed) throw StateError('HolderDevicePlugin disposed'); + } +} + +class HolderDeviceSession extends DeviceSession { + HolderDeviceSession._(this._link) { + _bind(); + } + + static const String _tag = 'HolderSession'; + + static const Set caps = { + DeviceCapability.scan, + DeviceCapability.connect, + DeviceCapability.battery, + DeviceCapability.macAddress, + DeviceCapability.micUplink, + DeviceCapability.micRecording, + // AB FOT 固件升级(协议实现在 recorder_holder/lib/src/ota/),见 [HolderOtaPort] + DeviceCapability.ota, + // 通道 B:TF 卡录音文件,见 [HolderFileStore]。 + // ⚠️ 这台硬件**只有**这条录音路径可用(没有 ae00 实时音频服务)。 + DeviceCapability.fileStore, + DeviceCapability.customCommand, + }; + + final HolderDeviceService _link; + final StreamController _events = + StreamController.broadcast(); + final List _workers = []; + DeviceConnectionState _state = DeviceConnectionState.ready; + bool _lastRecording = false; + + void _bind() { + _lastRecording = _link.recording.value.isRecording; + _workers.add(ever(_link.recording, (st) { + if (st.isRecording == _lastRecording) return; + _lastRecording = st.isRecording; + _feature(st.isRecording + ? DeviceFeatureKeys.recordStarted + : DeviceFeatureKeys.recordStopped); + })); + _workers.add(ever(_link.deviceInfo, (_) { + _feature(DeviceFeatureKeys.batteryUpdated); + _infoUpdated(); + })); + _workers.add(ever(_link.connectedNameRx, (_) => _infoUpdated())); + } + + void _feature(String key, [Map data = const {}]) { + if (_events.isClosed) return; + _events.add(DeviceSessionEvent( + type: DeviceSessionEventType.feature, + deviceId: deviceId, + feature: DeviceFeatureEvent(key: key, data: data), + )); + } + + void _infoUpdated() { + if (_events.isClosed) return; + _events.add(DeviceSessionEvent( + type: DeviceSessionEventType.deviceInfoUpdated, + deviceId: deviceId, + deviceInfo: info, + )); + } + + void _markDisconnected() { + if (_state == DeviceConnectionState.disconnected) return; + _state = DeviceConnectionState.disconnected; + for (final w in _workers) { + w.dispose(); + } + _workers.clear(); + _ota?.onSessionGone(); + if (!_events.isClosed) { + _events.add(DeviceSessionEvent( + type: DeviceSessionEventType.connectionStateChanged, + deviceId: deviceId, + connectionState: DeviceConnectionState.disconnected, + )); + _events.close(); + } + } + + void _requireReady() { + if (_state != DeviceConnectionState.ready) { + throw DeviceException(DeviceErrorCode.noActiveSession, 'holder'); + } + } + + @override + String get deviceId => _link.connectedAddress; + + @override + String get vendor => DeviceVendors.holder; + + @override + DeviceConnectionState get state => _state; + + @override + Set get capabilities => caps; + + @override + Stream get eventStream => _events.stream; + + static final RegExp _macRe = RegExp(r'^([0-9A-F]{2}:){5}[0-9A-F]{2}$'); + + @override + DeviceInfo get info { + final i = _link.deviceInfo.value; + final lvl = i?.batteryLevel; + final addr = _link.connectedAddress.toUpperCase(); + return DeviceInfo( + id: deviceId, + name: _link.connectedName, + vendor: vendor, + firmwareVersion: i?.firmwareVersion, + batteryPercent: lvl, + battery: DeviceBattery(single: lvl, chargingSingle: i?.isCharging ?? false), + // iOS 上拿到的是 peripheral UUID,只有长得像 MAC 才当 MAC + macAddress: Platform.isAndroid || _macRe.hasMatch(addr) ? addr : null, + metadata: {'productId': i?.productId, 'deviceIsRecording': _link.deviceIsRecording}, + ); + } + + @override + Future readRssi() => + throw DeviceException(DeviceErrorCode.notSupported, 'rssi'); + + @override + Future readBattery() async { + _requireReady(); + await _link.refreshBattery(); + return info.battery.single; + } + + @override + Future refreshInfo() async { + _requireReady(); + await _link.refreshBattery(); + return info; + } + + /// 主麦上行。**必须**打开设备编码器,否则设备不往手机推 Opus 流 + /// (设备按键发起的录音只切本地录音态、不开编码器,所以无条件补一条)。 + @override + Future openMic({AudioRoute route = AudioRoute.mic}) async { + _requireReady(); + if (route != AudioRoute.mic) { + throw DeviceException(DeviceErrorCode.notSupported, 'openMic($route)'); + } + final ok = await _link.openEncoder(); + if (!ok) Logger.w(_tag, 'openEncoder 返回 false,可能收不到音频流'); + return _HolderSource(_link.audioFrames.map((f) => f.pcm)); + } + + @override + Future> invokeFeature(String featureKey, + [Map args = const {}]) async { + _requireReady(); + switch (featureKey) { + case DeviceFeatures.recordStart: + // toggle 语义:设备已在录时再切一次会把它关掉,先看设备状态 + if (_link.deviceIsRecording) return const {'ok': true, 'already': true}; + final ok = await _link.startRecording(); + return {'ok': ok}; + case DeviceFeatures.recordStop: + if (!_link.deviceIsRecording) return const {'ok': true, 'already': true}; + final ok = await _link.stopRecording( + fileName: args['fileName'] as String?); + return {'ok': ok}; + case 'holder.toggle': + case 'record.toggle': + return {'ok': await _link.toggleRecording()}; + case DeviceFeatures.batteryQuery: + return {'ok': await _link.refreshBattery()}; + case DeviceFeatures.stateRefresh: + await _link.refreshBattery(); + return const {}; + default: + throw DeviceException(DeviceErrorCode.notSupported, featureKey); + } + } + + /// 主动断开 = 「不要它了」:支架 SDK 的 disconnect 会一并清掉自动回连记忆。 + HolderOtaPort? _ota; + HolderFileStore? _files; + + @override + DeviceFileStore? fileStore() => _files ??= HolderFileStore(_link); + + @override + DeviceOtaPort? otaPort() => _ota ??= HolderOtaPort(_link); + + @override + Future disconnect() => _link.disconnect(); +} + +class _HolderSource implements DeviceAudioSource { + _HolderSource(Stream src) { + _sub = src.listen(_ctrl.add, onError: _ctrl.addError); + } + + final StreamController _ctrl = StreamController.broadcast(); + StreamSubscription? _sub; + + @override + AudioRoute get route => AudioRoute.mic; + + @override + AudioFormat get format => AudioFormat.pcm16kMono; + + @override + Stream get pcm => _ctrl.stream; + + @override + Future close() async { + await _sub?.cancel(); + _sub = null; + await _ctrl.close(); + } +} diff --git a/apps/client/lib/devices/holder/holder_device_service.dart b/apps/client/lib/devices/holder/holder_device_service.dart index ff097c93..9096bb87 100644 --- a/apps/client/lib/devices/holder/holder_device_service.dart +++ b/apps/client/lib/devices/holder/holder_device_service.dart @@ -4,23 +4,26 @@ import 'dart:io'; import 'package:echomeet_device_sdk/echomeet_device_sdk.dart'; import 'package:get/get.dart'; import 'package:get_storage/get_storage.dart'; +import 'package:path_provider/path_provider.dart'; import 'package:permission_handler/permission_handler.dart'; import '../../core/utils/logger.dart'; import '../../core/utils/permission_util.dart'; -import 'background_launch_consent.dart'; -import 'agent_module_defaults.dart'; -import '../models/user_Info.dart'; -import 'network/api.dart'; +import '../../data/models/user_Info.dart'; +import '../../data/services/background_launch_consent.dart'; +import '../../data/services/network/api.dart'; -/// 蓝讯方案设备(AI手机支架 / AI Pencil)的接入层。 +/// EaiRec 录音支架(AI手机支架)的接入层。 /// -/// 底层协议实现全部来自 echomeet 项目的 `recorder_holder` 插件,本类只是把 -/// [EchoMeetDeviceSdk] 的流封装成 GetX 可观测状态,不重写任何蓝牙/Opus 逻辑。 +/// 底层协议在 `recorder_holder` 插件里,本类只是把 [EchoMeetDeviceSdk] 的流 +/// 封装成 GetX 可观测状态,不重写任何蓝牙/Opus 逻辑。设备的两条通道: +/// - 通道 A:控制命令 + 实时 Opus 音频流(录音走这条) +/// - 通道 B:TF 卡文件(列表/下载/删除),见本类下半部分 +/// 协议规范见 local_plugins/recorder_holder/EaiRec_protocol.md。 /// /// 与项目原有的 [BleManager](杰理耳机链路)是两套独立的设备通路,互不影响: /// - 杰理耳机 → BleManager -/// - 蓝讯支架 → 本服务 +/// - EaiRec 支架 → 本服务 class HolderDeviceService extends GetxService { static const String _tag = 'HolderDeviceService'; @@ -198,6 +201,138 @@ class HolderDeviceService extends GetxService { /// 录音的 PCM 帧流(16kHz / 16bit / 单声道,Opus 已在原生侧解码) Stream get audioFrames => _sdk.audioFrames; + // ==================== 通道 B:TF 卡文件 ==================== + // + // 协议见 local_plugins/recorder_holder/EaiRec_protocol.md §5。 + // 这里只做「连上之后对时 + 拉一次状态」和几个供上层调用的入口; + // 帧格式与状态机都在插件的 RecClient 里,本类不碰字节。 + + /// 设备 TF 卡与录音状态。设备主动上报(录音起停)也会刷新它。 + final Rxn recInfo = Rxn(); + + /// 通道 B 是否可用。设备没有 TF 卡能力时为 false,此时通道 A 照常工作。 + final RxBool recChannelReady = false.obs; + + StreamSubscription? _recInfoSub; + StreamSubscription>? _recListSub; + + /// 设备端录音停止后会主动推一次完整文件列表,落在这里。 + final RxList recFiles = [].obs; + + /// 同一份推送的事件流版本。RxList 只表达「当前是什么」, + /// 而导入服务要的是「又推了一次」这个**事件**(内容可能一模一样)。 + final StreamController> _recFilesPushes = + StreamController>.broadcast(); + Stream> get recFilesPushes => _recFilesPushes.stream; + + /// 连上之后的通道 B 握手。 + /// + /// 顺序按协议文档 §8.1:先对时再查状态 —— 设备是拿 RTC 生成录音文件的 FAT + /// 时间的,对时晚了这一次录的文件时间就是错的。 + /// 通道 B 握手最多试几次。原生的 `connected` 回调在**服务发现之前**就抛出来了 + /// (真机实测差约 700ms),第一次探必然探到「没有 0xFDA5」。 + /// ⚠️ 这里失败就直接 return 的老写法后果比看上去严重:不只是这次没握上手, + /// 而是**对时没做(录音文件时间全错)、文件列表推送没订阅(录完不会自动同步)、 + /// 设备状态没拉**,而 `ensureRecChannel()` 本身是每次重新探的, + /// 于是「列表能拉、但录完不会自动出现」——一个非常难查的半死状态。 + static const int _maxRecChannelTries = 6; + + Future _initRecChannel() async { + for (var i = 1; i <= _maxRecChannelTries; i++) { + if (!isConnected) return; // 中途断了就别再试 + try { + if (await _sdk.ensureRecChannel()) { + // 订阅要在对时之前挂上:设备可能在握手过程中就推东西上来。 + // `??=` 保证重试时不会重复订阅。 + _recInfoSub ??= _sdk.recorder.onRecInfo.listen((info) { + recInfo.value = info; + Logger.i(_tag, '设备状态: $info'); + }); + _recListSub ??= _sdk.recorder.onFileListPushed.listen((list) { + Logger.i(_tag, '设备主动推送文件列表,共 ${list.length} 个'); + recFiles.assignAll(list); + if (!_recFilesPushes.isClosed) _recFilesPushes.add(list); + }); + + await _sdk.recorder.syncRtc(); + recInfo.value = await _sdk.recorder.queryInfo(); + recChannelReady.value = true; + Logger.i(_tag, '通道 B 就绪(第 $i 次): ${recInfo.value}'); + return; + } + } catch (e) { + // 通道 B 失败不能影响连接本身,只记不抛 + Logger.i(_tag, '通道 B 握手第 $i 次失败,重试: $e'); + } + await Future.delayed(const Duration(milliseconds: 800)); + } + recChannelReady.value = false; + Logger.w(_tag, '通道 B 握手 $_maxRecChannelTries 次都没成,TF 卡录音无法同步'); + } + + /// 拉取 TF 卡上的完整录音列表。 + Future> listRecordings() async { + if (!await _sdk.ensureRecChannel()) { + throw RecException('通道 B 不可用'); + } + final list = await _sdk.recorder.listFiles(); + recFiles.assignAll(list); + return list; + } + + /// 把 TF 卡上的一个录音下载到本地。 + /// + /// 落在 `/EaiRec/<文件名>`,**同名文件会被当作断点续传的半成品接着写** + /// —— 这正是要的行为(下载中断后重进接着下)。要重下得先删掉本地文件。 + Future downloadRecording( + String name, { + void Function(int received, int total)? onProgress, + RecCancelToken? cancelToken, + }) async { + if (!await _sdk.ensureRecChannel()) { + throw RecException('通道 B 不可用'); + } + final docs = await getApplicationDocumentsDirectory(); + final sink = FileRecSink(File('${docs.path}/EaiRec/$name')); + await sink.open(); + return _sdk.recorder.download( + name: name, + sink: sink, + onProgress: onProgress, + cancelToken: cancelToken, + ); + } + + /// 把 TF 卡上的一个录音拉到调用方给的 sink 里。 + /// + /// 与 [downloadRecording] 的区别是**落点由调用方决定**——通用的 + /// [DeviceFileStore] 端口要把文件放进自己的暂存目录,不能被这里写死。 + Future downloadTo({ + required String name, + required RecFileSink sink, + void Function(int received, int total)? onProgress, + RecCancelToken? cancelToken, + }) async { + if (!await _sdk.ensureRecChannel()) { + throw RecException('通道 B 不可用'); + } + return _sdk.recorder.download( + name: name, + sink: sink, + onProgress: onProgress, + cancelToken: cancelToken, + ); + } + + /// 删除设备上的一个录音。**调用前 UI 必须已二次确认。** + Future deleteRecording(String name) async { + if (!await _sdk.ensureRecChannel()) { + throw RecException('通道 B 不可用'); + } + await _sdk.recorder.deleteFile(name); + recFiles.removeWhere((e) => e.name == name); + } + @override void onInit() { super.onInit(); @@ -217,12 +352,29 @@ class HolderDeviceService extends GetxService { if (s == EchoMeetConnectionState.connected) { _autoConnectTimer?.cancel(); _autoAttempt = 0; - _rememberDevice('Echomeet', connectedAddressRx.value); - // 首次连上支架 → AI 页铺开支架的默认功能编排 - AgentDefaults.onHolderConnected(); + // 名字用本次实际连上的那台。原来这里写死 'Echomeet',换成 EaiRec 之后 + // 记下来的名字会是错的,设备页据 lastDeviceName 选主图就会选错。 + _rememberDevice( + // 兜底名要跟当前固件的广播名一致,否则设备页按名字取产品图会落空。 + // 切换设备名见 EchoMeetDeviceSdk.defaultNameFilters。 + connectedNameRx.value.isNotEmpty ? connectedNameRx.value : 'Echomeet', + connectedAddressRx.value, + ); + // 协议 §8.1 第 7 步:连上后主动查一次电量。 + // ⚠️ 不能只靠 waitConnected() 里那次——**自动回连不走 waitConnected** + // (`tryAutoConnect` 直接调 SDK.connect),于是设备页电量一直空着。 + // 设备侧电量是「请求/响应 + 主动上报」双模式,不问就可能永远等不到。 + _kickBatteryQuery(); + // ⚠️ 绑定登记同样不能只挂在 connect():**自动回连不走 connect()**。 + // 设备一旦记住了,之后每次都是原生回连上线,绑定接口一次都不会打, + // 设备管理页就永远空着(换账号/后台补录 MAC 之后尤其明显)。 + unawaited(bindConnectedToServer()); + // 通道 B(TF 卡文件)握手,失败不影响录音等通道 A 的功能 + unawaited(_initRecChannel()); } else if (s == EchoMeetConnectionState.disconnected && hasPairedDevice) { // 设备关机/走远导致的断连:重新挂上后台扫连,等它再开机 + _batteryRetry?.cancel(); Logger.i(_tag, '连接断开,重新注册后台扫连等待设备上线'); Future.delayed(const Duration(seconds: 3), () => tryAutoConnect()); } @@ -239,7 +391,11 @@ class HolderDeviceService extends GetxService { _connSub?.cancel(); _recSub?.cancel(); _infoSub?.cancel(); + _recInfoSub?.cancel(); + _recListSub?.cancel(); + _recFilesPushes.close(); _autoConnectTimer?.cancel(); + _batteryRetry?.cancel(); super.onClose(); } @@ -289,7 +445,18 @@ class HolderDeviceService extends GetxService { _byAddress.clear(); scanned.clear(); isScanning.value = true; - await _sdk.startScan(); + // ⚠️ 原生 `startScan` 会返回 false(蓝牙未就绪 / 权限 / 已在扫描)。 + // 以前这里不看返回值,UI 就一直转「正在搜索」而其实根本没在扫。 + // 现在失败了等蓝牙栈就绪再补一次;两次都不行才如实把 isScanning 放下来。 + var ok = await _sdk.startScan(); + if (!ok) { + await Future.delayed(const Duration(milliseconds: 800)); + ok = await _sdk.startScan(); + } + if (!ok) { + Logger.w(_tag, '原生扫描未能启动(蓝牙未就绪/权限/已在扫描),UI 退回未扫描态'); + isScanning.value = false; + } } Future stopScan() async { @@ -310,28 +477,54 @@ class HolderDeviceService extends GetxService { /// 把支架登记到服务端的已绑定设备列表。 /// - /// 走的是和杰理耳机同一个 user_binddevice 接口。蓝讯广播里没有杰理那套 - /// pid/code 授权字段,所以 pid 用广播里的 hex(服务端可据此识别), - /// code 留空——接口失败不阻断连接,只记日志。 - Future bindToServer(EchoMeetScannedDevice device) async { + /// 走 `user_binddevice`,与恒玄耳机同一个接口、同一套规则:**只按 MAC 校验** + /// (协议 §2.3;服务端 `api_binddevice.go` 拿 devicemac 在全局 `device_mac` + /// 表里查,产品是从命中行的 productid 列读出来的)。 + /// + /// ⚠️ **不要传 pid**:服务端 `UserBindDeviceReq.pid` 是 `int32`,而这里唯一能填的 + /// `advertisementHex` 是十六进制**字符串**(常常还是空串)。传上去必然 + /// `readUint32: unexpected character` → `code:22`,整个请求被拒, + /// 表现为「连上了但设备管理页永远空着」。恒玄那条链路就是不传 pid 的。 + /// code 也留空:支架没有杰理那套广播授权码。 + /// + /// 接口失败不阻断连接和录音,只是设备管理页没有记录。 + Future bindToServer(EchoMeetScannedDevice device) => + _bindMac(device.name, device.address); + + /// 用**当前连着的**这台去登记。自动回连路径专用:那条路不经过 [connect], + /// 所以拿不到 `EchoMeetScannedDevice`,只有连接时记下的名字和 MAC。 + Future bindConnectedToServer() => + _bindMac(connectedNameRx.value, connectedAddressRx.value); + + /// 本进程内已登记成功的 MAC,避免每次回连都打一次接口。 + /// **只记成功的**:失败(如后台还没补录这台 MAC)下次连上要能重试。 + final Set _bound = {}; + + Future _bindMac(String name, String mac) async { + if (mac.isEmpty) return; if (!User.isLoggedIn()) { Logger.w(_tag, '未登录,跳过设备绑定登记'); return; } + final key = mac.toUpperCase(); + if (_bound.contains(key)) return; try { final resp = await Api.binddevice({ - 'pid': device.advertisementHex, 'code': '', - 'devicename': device.name, - 'devicemac': device.address, + 'devicename': name, + 'devicemac': mac, }); Logger.i(_tag, '绑定接口返回: $resp'); final deviceJson = resp?['device']; if (deviceJson is Map) { + _bound.add(key); User.instance.addUserDevice(UserDevice.fromJson(deviceJson)); Logger.i(_tag, '支架已登记到已绑定设备列表'); } else { - Logger.w(_tag, '绑定接口未返回 device 字段,设备管理页可能看不到这台'); + // resp == null 是服务端明确拒绝(AuthInterceptor 在业务码 != 0 时 + // 把 data 置 null 后 resolve,不抛)——最常见的是这台 MAC 还没导入 + // 后台的 device_mac 表,返回 `{"code":2005,"msg":"设备未登记"}`。 + Logger.w(_tag, '绑定接口未返回 device 字段(resp=$resp),设备管理页看不到这台'); } } catch (e) { // 绑定失败不影响本次连接和录音,只是设备管理页没有记录 @@ -360,8 +553,8 @@ class HolderDeviceService extends GetxService { worker = ever(connection, (s) { if (s == EchoMeetConnectionState.connected) { - // 连上后主动拉一次电量,设备页右下角才有数可显示 - refreshBattery(); + // 电量查询不在这里发:onInit 的 connected 分支已经用 _kickBatteryQuery() + // 带重试地问了。在这儿再补一发只会在服务发现完成前多失败一次。 finish(true); } if (s == EchoMeetConnectionState.error) finish(false); @@ -402,4 +595,60 @@ class HolderDeviceService extends GetxService { Future toggleRecording() => _sdk.toggleRecording(); Future refreshBattery() => _sdk.refreshBattery(); + + // ---------------- 电量:连上后必须重试着问 ---------------- + + Timer? _batteryRetry; + int _batteryTries = 0; + + /// 连上后把电量拿到手。**这里必须重试,问一次是不够的。** + /// + /// ⚠️ 原生的 `connected` 回调在**服务发现之前**就抛出来了(真机实测差约 + /// 700ms),那一刻写特征还是 null,`sendCommand` 直接打 + /// `发送命令失败: 设备未连接` 并返回 false —— 命令整条丢在地上,且没有任何 + /// 上层能看见的错。设备侧电量虽然也有主动上报,但不保证会来, + /// 于是表现就是**设备页右下角永远没有电量**(2026-09-10 真机复现)。 + /// + /// 所以:发不出去就隔 800ms 再发,发出去了给 2s 等回包,直到 deviceInfo + /// 里真的有电量值、或者试满 [_maxBatteryTries] 次为止。 + /// 4 次约 3 秒,够覆盖服务发现那 700ms 的窗口了。**不要调大**: + /// 通道 A 缺失时这些重试一次都不会成功,只是白刷日志(见 [_attemptBattery] 的说明)。 + static const int _maxBatteryTries = 4; + + void _kickBatteryQuery() { + _batteryRetry?.cancel(); + _batteryTries = 0; + unawaited(_attemptBattery()); + } + + Future _attemptBattery() async { + if (!isConnected) return; // 断了就别再问 + if (_batteryTries >= _maxBatteryTries) { + // ⚠️ 走到这里最常见的**不是**时序问题,而是设备根本没有通道 A。 + // 2026-09-10 真机(Echomeet 00:0D:01:00:00:41)服务发现只有一个服务: + // `0xFDA5`(FDA6/FDA7) —— 通道 B(TF 卡)。协议 §4.3 的电量在通道 A + // (`abc0`/`abc1`/`abc2`) 上,那个服务压根不存在,于是原生 checkConn() + // 因 writeChar==null 恒 false,每一发都打「发送命令失败: 设备未连接」。 + // **那条错误信息是误导**:链路好好的,缺的是服务。要电量得固件补通道 A。 + Logger.w( + _tag, + '连上后问了 $_batteryTries 次都没拿到电量。' + '若原生一直报「发送命令失败: 设备未连接」,看服务发现清单:' + '这台固件很可能只暴露了 0xFDA5(通道B),没有 abc0(通道A),电量物理上问不到'); + return; + } + _batteryTries++; + final sent = await refreshBattery(); + _batteryRetry = Timer( + Duration(milliseconds: sent ? 2000 : 800), + () { + if (deviceInfo.value?.batteryLevel != null) { + Logger.i(_tag, '电量已拿到: ${deviceInfo.value?.batteryLevel}%' + '(第 $_batteryTries 次尝试)'); + return; + } + unawaited(_attemptBattery()); + }, + ); + } } diff --git a/apps/client/lib/devices/holder/holder_file_store.dart b/apps/client/lib/devices/holder/holder_file_store.dart new file mode 100644 index 00000000..41b41176 --- /dev/null +++ b/apps/client/lib/devices/holder/holder_file_store.dart @@ -0,0 +1,120 @@ +import 'dart:async'; +import 'dart:io'; + +import 'package:device_plugin_interface/device_plugin_interface.dart'; +import 'package:echomeet_device_sdk/echomeet_device_sdk.dart'; + +import '../../core/utils/logger.dart'; +import 'holder_device_service.dart'; + +/// EaiRec 支架的设备端文件仓库 —— 通道 B(TF 卡,`0xFDA5`)。 +/// +/// **这台硬件唯一可用的录音路径就是这条**:它没有通道 A' 实时音频上行 +/// (真机 GATT 里根本没有 `ae00`,见 CLAUDE.md 支架那节),录音是 +/// 「录到 TF 卡、事后拉」。所以本类不是锦上添花,是支架录音能不能用的关键。 +/// +/// 协议与状态机都在 `recorder_holder` 的 `RecClient` 里,本类只做两件事: +/// 把它接到通用的 [DeviceFileStore] 上,以及把 SDK 的异常翻译成 [DeviceException]。 +class HolderFileStore implements DeviceFileStore { + HolderFileStore(this._link); + + static const String _tag = 'HolderFileStore'; + + final HolderDeviceService _link; + + @override + bool get isDeviceRecording => + _link.deviceIsRecording || (_link.recInfo.value?.recording ?? false); + + @override + Stream> get listPushes => + _link.recFilesPushes.map((list) => list.map(_toEntry).toList()); + + @override + Future> list() async { + try { + final files = await _link.listRecordings(); + return files.map(_toEntry).toList(growable: false); + } on RecException catch (e) { + throw DeviceException(DeviceErrorCode.fileTransferFailed, e.toString()); + } + } + + @override + Future download( + String name, { + required String destPath, + void Function(int received, int total)? onProgress, + DeviceFileCancelToken? cancelToken, + }) async { + // ⚠️ 正在录的那个文件不能拉:设备还在往里写,拉下来是个残片, + // 而且 size 一直在变,断点续传的长度判断也会错。 + if (isDeviceRecording) { + throw DeviceException(DeviceErrorCode.fileBusy, '设备正在录音'); + } + + final dest = File(destPath); + await dest.parent.create(recursive: true); + + // SDK 的取消令牌与通用令牌是两套类型,这里做个桥接 + final tok = RecCancelToken(); + if (cancelToken?.isCancelled ?? false) { + tok.cancel(); + } else { + unawaited(cancelToken?.whenCancelled.then((_) => tok.cancel())); + } + + final sink = FileRecSink(dest); + await sink.open(); + try { + final result = await _link.downloadTo( + name: name, + sink: sink, + onProgress: onProgress, + cancelToken: tok, + ); + if (!result.complete) { + // 没传完只有两种可能:被取消,或链路断了。前者不是故障。 + if (cancelToken?.isCancelled ?? false) { + throw DeviceException(DeviceErrorCode.cancelled, name); + } + throw DeviceException( + DeviceErrorCode.fileTransferFailed, '$name 未传输完整'); + } + // ⚠️ 落盘字节数必须与设备报的文件长度逐字节对上。 + // 这条是**删除设备原件的前置条件**:传输层虽然逐包校验了 checksum + // (RecClient.verifyChecksum 默认开)且 complete 判的是 bytes==fileSize, + // 但那看的是 sink 的计数,不是磁盘上真实的长度。写盘失败/被截断时 + // 两者会不一致,而我们马上要拿这个结果去做不可逆的删除。 + final onDisk = await dest.length(); + if (onDisk != result.fileSize) { + throw DeviceException(DeviceErrorCode.fileTransferFailed, + '$name 落盘 $onDisk B 与设备报的 ${result.fileSize} B 不符'); + } + Logger.i(_tag, + '已拉取 $name → $destPath ($onDisk B,' + '断点续传 ${result.resumes} 次)'); + return destPath; + } on RecException catch (e) { + throw DeviceException(DeviceErrorCode.fileTransferFailed, e.toString()); + } + } + + @override + Future delete(String name) async { + try { + await _link.deleteRecording(name); + } on RecException catch (e) { + throw DeviceException(DeviceErrorCode.featureFailed, e.toString()); + } + } + + DeviceFileEntry _toEntry(RecFileItem f) => DeviceFileEntry( + name: f.name, + size: f.size, + // RecFileItem.time 解不出合法 FAT 日期时就是 null,原样透传: + // 上层会退回「按导入时刻命名」,好过显示 1980 年 + modifiedAt: f.time, + metadata: {'ftime': f.ftime}, + ); +} diff --git a/apps/client/lib/devices/holder/holder_ota_port.dart b/apps/client/lib/devices/holder/holder_ota_port.dart new file mode 100644 index 00000000..c7e3b871 --- /dev/null +++ b/apps/client/lib/devices/holder/holder_ota_port.dart @@ -0,0 +1,222 @@ +import 'dart:async'; +import 'dart:io'; + +import 'package:device_plugin_interface/device_plugin_interface.dart'; +import 'package:recorder_holder/recorder_holder.dart'; +import 'package:recorder_holder/recorder_holder_ota.dart'; + +import '../../core/utils/logger.dart'; +import 'holder_device_service.dart'; + +/// EaiRec 支架(中科蓝讯方案)的固件 OTA 端口 —— AB FOT 协议。 +/// +/// 协议实现全在 `recorder_holder/lib/src/ota/`(帧格式、CRC、TLV、状态机), +/// 本类只做两件事:把它接到通用的 [DeviceOtaPort] 上,以及处理**连接交接**。 +/// +/// ⚠️ **OTA 期间必须让出常规 BLE 连接**:一部手机对同一个外设只能有一条链路, +/// 而 `FotBleTransport` 自己用 flutter_blue_plus 连 OTA 服务(`0xFF12`, +/// 与常规的 `abc0`/`ae00`/`FDA5` 是不同的 service,设备重启进 OTA 模式后才暴露)。 +/// 所以 [start] 先 `setEnterOta(true)` 让原生放手,[close] 再 `setEnterOta(false)` +/// 把设备交还给常规会话。这与恒玄那条 SPP OTA 的交接思路一致。 +/// +/// ⚠️ 设备进 OTA 模式后**可能换一个蓝牙地址广播**,所以 `knownRemoteId` 只是 +/// Android 上的快路径,失败会自动退回按 service UUID 扫描(两端共用那条路径)。 +class HolderOtaPort implements DeviceOtaPort { + HolderOtaPort(this._link); + + static const String _tag = 'HolderOtaPort'; + + final HolderDeviceService _link; + + /// 原生插件单例。`setEnterOta` 只在插件层暴露(SDK 没转发),直接用它, + /// 少一层转发就少一处会忘记同步的地方。 + final RecorderHolder _plugin = RecorderHolder(); + + final StreamController _progress = + StreamController.broadcast(); + + FotBleTransport? _transport; + FotOtaSession? _session; + StreamSubscription? _sub; + + bool _running = false; + bool _terminal = false; + bool _succeeded = false; + + /// 我们为了 OTA 把常规连接让出去了,[close] 时要还回来 + bool _handover = false; + int _lastPercent = -1; + + @override + bool get isRunning => _running; + + @override + Stream get progressStream => _progress.stream; + + @override + Future start(DeviceOtaRequest request) async { + if (_running) throw DeviceException(DeviceErrorCode.otaBusy); + if (request is! DeviceOtaFileRequest) { + throw DeviceException( + DeviceErrorCode.notSupported, '${request.runtimeType}'); + } + + final file = File(request.filePath); + if (!await file.exists()) { + throw DeviceException(DeviceErrorCode.otaFileInvalid, request.filePath); + } + final bytes = await file.readAsBytes(); + if (bytes.isEmpty) { + throw DeviceException(DeviceErrorCode.otaFileInvalid, '固件为空'); + } + + _running = true; + _terminal = false; + _succeeded = false; + _lastPercent = -1; + _emit(DeviceOtaState.entering, 0); + + // 1) 让出常规连接。原生 setEnterOta(true) 会 disconnect 且不自动重连。 + final mac = _link.connectedAddress; + _handover = true; + await _plugin.setEnterOta(true); + // 设备要重启进 OTA 模式并重新广播,给它一点时间;连太早只会白等超时。 + await Future.delayed(const Duration(seconds: 2)); + if (!_running) return; // 期间被 cancel 了 + + // 2) 建 OTA 链路(自管连接,与原生那条互斥) + final config = FotOtaConfig( + knownRemoteId: Platform.isAndroid && mac.isNotEmpty ? mac : null, + ); + final transport = FotBleTransport(config: config); + _transport = transport; + + final firmware = FotFirmware.fromBytes(bytes); + final session = FotOtaSession( + transport: transport, + firmware: firmware, + config: config, + ); + _session = session; + _sub = session.progress.listen(_onProgress, onError: (Object e) { + _fail(DeviceErrorCode.otaTransferFailed, e.toString()); + }); + + try { + await transport.open(); + if (!_running) return; + await session.start(); + } catch (e) { + _fail(DeviceErrorCode.otaTransferFailed, e.toString()); + } + } + + void _onProgress(FotProgress p) { + // 终态锁定:迟到的事件一律忽略,否则成功会被收尾事件覆盖成失败 + if (_terminal) return; + switch (p.stage) { + case FotStage.connecting: + _emit(DeviceOtaState.entering, 0); + break; + case FotStage.handshaking: + case FotStage.querying: + _emit(DeviceOtaState.inquiring, 0); + break; + case FotStage.transferring: + _emitPercent(p.percent); + break; + case FotStage.finishing: + case FotStage.verifying: + // 压缩包在这一步解压+烧写,可能要几十秒,UI 别当成卡死 + _emit(DeviceOtaState.verifying, 100); + break; + case FotStage.completed: + _terminal = true; + _succeeded = true; + _running = false; + _emit(DeviceOtaState.done, 100); + break; + case FotStage.failed: + _fail(DeviceErrorCode.otaTransferFailed, + p.message ?? p.deviceStatus?.toString() ?? 'failed'); + break; + case FotStage.idle: + break; + } + } + + /// 进度只在单调递增时上报,避免数字来回跳 + void _emitPercent(int p) { + if (p <= _lastPercent) return; + _lastPercent = p; + _emit(DeviceOtaState.transferring, p); + } + + void _fail(String code, String message) { + if (_terminal) return; + _terminal = true; + _running = false; + Logger.w(_tag, 'OTA 失败: $message'); + _emit(DeviceOtaState.failed, -1, errorCode: code, errorMessage: message); + } + + void _emit(DeviceOtaState st, int percent, + {String? errorCode, String? errorMessage}) { + if (_progress.isClosed) return; + _progress.add(DeviceOtaProgress( + state: st, + sentBytes: 0, + totalBytes: 0, + percent: percent, + tsMs: DateTime.now().millisecondsSinceEpoch, + errorCode: errorCode, + errorMessage: errorMessage, + )); + } + + @override + Future cancel() async { + if (!_running) return; + _running = false; + _terminal = true; + await _teardownLink(); + _emit(DeviceOtaState.cancelled, -1); + } + + /// 会话没了(常规连接断开)。升级中那次断开是我们自己让出的,忽略。 + void onSessionGone() { + if (_handover) return; + if (_running) _fail(DeviceErrorCode.disconnectedRemote, '设备断开'); + } + + Future _teardownLink() async { + await _sub?.cancel(); + _sub = null; + try { + _session?.dispose(); + } catch (_) {} + _session = null; + try { + await _transport?.close(); + } catch (_) {} + _transport = null; + } + + @override + Future close() async { + _running = false; + await _teardownLink(); + if (!_progress.isClosed) await _progress.close(); + + if (!_handover) return; + _handover = false; + // 把设备交还给常规会话。升级成功时设备正在重启,多等几秒。 + await _plugin.setEnterOta(false); + final delay = _succeeded + ? const Duration(seconds: 5) + : const Duration(seconds: 2); + Future.delayed(delay, () { + if (!_link.isConnected) _link.tryAutoConnect(); + }); + } +} diff --git a/apps/client/lib/devices/smartcar/smartcar_device_auth.dart b/apps/client/lib/devices/smartcar/smartcar_device_auth.dart new file mode 100644 index 00000000..9a7b3dbf --- /dev/null +++ b/apps/client/lib/devices/smartcar/smartcar_device_auth.dart @@ -0,0 +1,170 @@ +import 'package:get_storage/get_storage.dart'; + +import '../../core/utils/logger.dart'; +import '../../data/models/user_Info.dart'; +import '../../data/services/network/api.dart'; +import '../device_auth.dart'; + +/// 「账号 ↔ Smartcar」的确权 + 绑定登记,走服务端 `user_binddevice`。 +/// +/// 与恒玄那套([BesDeviceAuth])同一个套路,判定口径逐条对齐, +/// 但有**一个关键差别**—— +/// +/// ## MAC 从广播里来,所以 iOS 也能真正确权 +/// +/// 恒玄链路上 iOS 拿不到真 MAC(CoreBluetooth 只给 peripheral UUID, +/// 每台手机看同一只耳机都不一样),所以那边 iOS 一律放行、闸门等于没有。 +/// +/// Smartcar 的**经典蓝牙 MAC 就写在 BLE 广播里**(厂商数据 CID 之后的 6 字节,原样、不异或), +/// 两端解出来完全一致。所以这条链路的确权在 iOS 上是真生效的。 +/// ⚠️ 别照抄恒玄那句「非 Android 直接返回 null」。 +/// +/// ## 服务端不需要改 +/// +/// 设备表 2026-09-04 已合成全局一张 `device_mac`,按 MAC 全局反查、 +/// 产品从命中行的 `productid` 列读出。所以**只报 MAC,不传 pid**—— +/// 2026-09-04 那次「设备连不上」的事故根因就是客户端猜 pid 猜错了。 +/// +/// ## 第一次连才走服务端,之后读本地白名单 +/// +/// 白名单**不按账号分**:确权说的是「这台硬件是不是正品」,是设备属性不是账号属性, +/// 换账号不该让同一台设备重新变可疑。[reset](登出)只清进程内的上报去重集合。 +/// +/// ## 判不出来一律放行 +/// +/// [DeviceAuthResult.unknown] 放行是刻意选的失败方向:判严的代价是 +/// **合法用户一断网就用不了自己的设备**;判宽只是未登记设备离线时能用一会儿, +/// 联网第一次确权就会被拦下。前者严重得多。 +class SmartcarDeviceAuth { + SmartcarDeviceAuth._(); + + static const String _tag = 'SmartcarDeviceAuth'; + + /// 确权通过的 MAC 白名单(持久化,存大写) + static const String _authorizedKey = 'smartcar_authorized_macs'; + + static final GetStorage _storage = GetStorage(); + + /// 本进程已成功登记的 MAC,避免每次回连都打接口。只记成功的。 + static final Set _reported = {}; + + /// 同一台设备的确权正在进行,避免状态抖动时并发打接口 + static final Set _inflight = {}; + + /// 登出时调:换账号后同一台设备要重新登记到新账号名下。 + /// **白名单不清**(理由见类注释)。 + static void reset() => _reported.clear(); + + static List get _whitelist => + (_storage.read(_authorizedKey) ?? const []) + .map((e) => e.toString()) + .toList(); + + static bool isAuthorizedLocally(String mac) => + mac.isNotEmpty && _whitelist.contains(mac.toUpperCase()); + + static Future _remember(String mac) async { + final list = _whitelist; + final m = mac.toUpperCase(); + if (list.contains(m)) return; + list.add(m); + await _storage.write(_authorizedKey, list); + } + + /// 把一台设备移出白名单,下次连上重新走服务端确权。 + /// + /// ⚠️ 用户在设备管理页主动解绑时**必须**调这个:白名单命中会跳过接口, + /// 不清的话解绑之后再连上不会重新登记,设备管理页就永远空着了。 + static Future forget(String mac) async { + if (mac.isEmpty) return; + final m = mac.toUpperCase(); + _reported.remove(m); + final list = _whitelist..remove(m); + await _storage.write(_authorizedKey, list); + } + + /// 【调试/售后】清空白名单 + static Future clearWhitelist() async { + await _storage.remove(_authorizedKey); + _reported.clear(); + } + + /// 校验一台刚连上的 Smartcar。 + /// + /// [mac] 来自广播(CID 之后 6 字节原样)。拿不到就 [DeviceAuthResult.unknown]。 + static Future verify({ + required String? mac, + required String name, + }) async { + if (mac == null || mac.isEmpty) { + Logger.i(_tag, '广播里没解出 MAC,跳过校验(放行)'); + return DeviceAuthResult.unknown; + } + final m = mac.toUpperCase(); + + // ① 本地白名单——第一次之后走这条,不打接口、离线也能连 + if (isAuthorizedLocally(m)) return DeviceAuthResult.authorized; + + if (_inflight.contains(m)) return DeviceAuthResult.unknown; + _inflight.add(m); + try { + return await _verifyRemote(mac: m, name: name); + } finally { + _inflight.remove(m); + } + } + + static Future _verifyRemote({ + required String mac, + required String name, + }) async { + if (!User.isLoggedIn()) { + // 先连设备后登录是常见顺序,不算失败:下次连接或拉设备列表时会再校一次 + Logger.i(_tag, '未登录,暂不校验,放行'); + return DeviceAuthResult.unknown; + } + + // 服务端已经绑过这台了,等于确权通过,省一次往返 + if (User.instance.devices + .any((d) => d.devicemac.toUpperCase() == mac)) { + await _remember(mac); + _reported.add(mac); + return DeviceAuthResult.authorized; + } + + try { + // ⚠️ **不传 pid**:服务端按 MAC 全局反查(device_mac 合表后), + // 产品是从命中行读出来的。客户端猜 pid 出过事故,别加回来。 + // code 传空:C2 的 License 在广播里,但服务端这条链路认的是 MAC。 + // 10s 上限:确权排在握手后、业务可用前,dio 那边 connect/receive 各 30s, + // 弱网不限制会让首连干等一分钟。超时 → catch → unknown → 放行。 + final resp = await Api.binddevice({ + 'code': '', + 'devicename': name, + 'devicemac': mac, + }).timeout(const Duration(seconds: 10)); + + // ⚠️ null 与抛异常含义**完全相反**,绝不能合并处理: + // AuthInterceptor 在业务码 != 0 时把 data 置 null 后 **resolve(不抛)**, + // 网络层出问题才抛 DioException。 + // 所以 null = 服务端明确拒绝 → denied;异常 = 没连上服务端 → unknown。 + // 写成 try{ if(resp==null) ... }catch{ 同样处理 } 等于断网即锁死设备。 + if (resp == null) { + Logger.w( + _tag, + '服务端拒绝: name=$name mac=$mac —— 这个 MAC 不在 device_mac 表里,' + '去后台「设备管理 → 生成MAC」确认是否已生产/导入'); + return DeviceAuthResult.denied; + } + + await _remember(mac); + _reported.add(mac); + Logger.i(_tag, '确权通过并已登记: $mac'); + return DeviceAuthResult.authorized; + } catch (e) { + // 网络层问题:放行。判严会让合法用户断网时用不了自己的设备。 + Logger.w(_tag, '确权请求异常(放行): $e'); + return DeviceAuthResult.unknown; + } + } +} diff --git a/apps/client/lib/devices/smartcar/smartcar_device_plugin.dart b/apps/client/lib/devices/smartcar/smartcar_device_plugin.dart new file mode 100644 index 00000000..0a9e9f85 --- /dev/null +++ b/apps/client/lib/devices/smartcar/smartcar_device_plugin.dart @@ -0,0 +1,959 @@ +import 'dart:async'; +import 'dart:io'; + +import 'package:classic_bluetooth/classic_bluetooth.dart'; +import 'package:device_plugin_interface/device_plugin_interface.dart'; +import 'package:flutter/services.dart' show Uint8List; +import 'package:get/get.dart'; +import 'package:get_storage/get_storage.dart'; +import 'package:permission_handler/permission_handler.dart'; + +import '../../core/utils/logger.dart'; +import '../../data/utils/pcm_stream_player.dart'; +import '../../core/utils/permission_util.dart'; +import '../device_auth.dart'; +import '../device_vendors.dart'; +import 'smartcar_device_auth.dart'; +import 'smartcar_link.dart'; +import 'smartcar_ota_port.dart'; +import 'smartcar_protocol.dart'; + +/// Smartcar(车载香薰)厂商插件。 +/// +/// 把 [SmartcarLink] 的 C2 私有协议适配成 [DevicePlugin] / [DeviceSession] 契约, +/// 业务层看到的只是「一台能读电量、能报 MAC、能发私有命令的设备」。 +/// +/// ## 本期(阶段 1~3)范围 +/// +/// 只做**连接 + 控制 + 绑定确权**。声明的能力集也只有这些—— +/// [DeviceCapability] 是「只增不删」的,先声明再实现会让业务层把不存在的功能画出来。 +/// +/// 还没做、要等后续阶段的: +/// - `micUplink` / `aiWake`:Opus 上行(阶段 6),要在同一条 GATT 上订阅 +/// [SmartcarProtocol.audioNotifyCharUuid],**不能另起连接** +/// - `ota`:阶段 7,搬杰理 OTA SDK +/// - 经典蓝牙 A2DP 音乐(阶段 5),要先给 `classic_bluetooth` 补 `connectDevice` +class SmartcarDevicePlugin implements DevicePlugin { + static const String _tag = 'SmartcarDevicePlugin'; + + /// 记住的设备(自动回连用)。存 `{id, name, mac}`。 + static const String _rememberedKey = 'smartcar_remembered_devices'; + + final SmartcarLink link = SmartcarLink(); + final GetStorage _storage = GetStorage(); + final _events = StreamController.broadcast(); + + /// 扫描诊断事件的 key(业务层按它取 [DevicePluginEvent.customPayload])。 + /// + /// 业务层**不能** import 厂商目录(`device_hub_boundary_test` 守着), + /// 所以诊断信息只能顺着插件事件流出去。 + static const String kScanDiagnosticKey = 'smartcar.scan_diagnostic'; + + SmartcarSession? _session; + StreamSubscription? _connSub; + StreamSubscription>? _diagSub; + bool _disposed = false; + bool _scanning = false; + + @override + String get vendorKey => DeviceVendors.smartcar; + + @override + String get displayName => 'Smartcar (车载香薰)'; + + @override + Set get capabilities => SmartcarSession.caps; + + @override + DevicePluginConfigSchema get configSchema => DevicePluginConfigSchema.empty; + + @override + DeviceSession? get activeSession => _session; + + @override + Stream get eventStream => _events.stream; + + @override + Future initialize(DevicePluginConfig config) async { + _checkAlive(); + // 这里**不申请权限、不起扫描**:initialize 在冷启动就跑, + // 弹权限框会在启动页糊用户一脸(与 bes / holder 同口径)。 + _diagSub?.cancel(); + _diagSub = link.diagnostics.listen((d) { + _emit(DevicePluginEvent( + type: DevicePluginEventType.customEvent, + customKey: kScanDiagnosticKey, + customPayload: d, + )); + }); + + _connSub?.cancel(); + _connSub = link.connected.listen((up) { + if (!up) { + _session?._markDisconnected(); + _session = null; + } + _emit(DevicePluginEvent( + type: DevicePluginEventType.connectionStateChanged, + deviceId: link.deviceId, + connectionState: up + ? DeviceConnectionState.ready + : DeviceConnectionState.disconnected, + )); + }); + _emit(const DevicePluginEvent(type: DevicePluginEventType.pluginReady)); + } + + /// 自持 GATT 要的运行时权限(Android 12+)。iOS 由系统在首次用 CoreBluetooth 时弹框。 + static const List _btPermissions = [ + Permission.bluetoothScan, + Permission.bluetoothConnect, + ]; + + /// 用户拒绝过且仍在冷却期——UI 据此置灰入口(与支架同一套闸) + bool get isPermissionCoolingDown => + Platform.isAndroid && + PermissionUtil.instance.isAnyDenialCoolingDown(_btPermissions); + + @override + Future ensureReady() async { + if (!Platform.isAndroid) return true; + // ⚠️ Android 12+ 起 BLUETOOTH_SCAN / BLUETOOTH_CONNECT 是运行时权限, + // 没授权时原生 startScan 直接抛 SecurityException。我们不走 flutter_blue_plus + // (它自己会申请),所以这一步必须自己做,否则安卓上永远「扫不到设备」且不报错。 + if (isPermissionCoolingDown) { + Logger.i(_tag, '蓝牙权限已被拒绝且在冷却期,本次不再申请'); + return false; + } + final granted = await PermissionUtil.instance.requestPermissions( + permissions: _btPermissions, + permissionName: 'bluetoothPermissions'.tr, + explanationText: 'bluetoothPermissionDetailDesc'.tr, + userInitiated: true, + ); + if (!granted) await PermissionUtil.instance.markDenied(_btPermissions); + return granted; + } + + // ───────────────────────────── 扫描 ───────────────────────────── + + StreamSubscription? _scanSub; + + @override + Future startScan({DeviceScanFilter? filter, Duration? timeout}) async { + _checkAlive(); + await stopScan(); + _scanning = true; + _emit(const DevicePluginEvent(type: DevicePluginEventType.scanStarted)); + + _scanSub = link + .scan(timeout: timeout ?? const Duration(seconds: 15)) + .listen((r) { + _emit(DevicePluginEvent( + type: DevicePluginEventType.deviceDiscovered, + deviceId: r.id, + discovered: DiscoveredDevice( + id: r.id, + name: r.name.isEmpty ? 'Smartcar' : r.name, + rssi: r.rssi, + vendor: vendorKey, + // ⚠️ MAC 与 License 只在广播里有,连上之后就拿不到了。 + // 绑定确权要用,所以在这里就带出去,别等到 connect 之后再找。 + metadata: { + 'mac': r.advertisement?.macAddress, + 'license': r.advertisement?.license, + 'pid': r.advertisement?.pid, + 'classicBtConnected': r.advertisement?.classicBtConnected, + }, + ), + )); + }, onError: (Object e) { + _emit(DevicePluginEvent( + type: DevicePluginEventType.error, + errorCode: DeviceErrorCode.scanFailed, + errorMessage: '$e', + )); + }, onDone: () { + _scanning = false; + _emit(const DevicePluginEvent(type: DevicePluginEventType.scanStopped)); + }); + } + + @override + Future stopScan() async { + await _scanSub?.cancel(); + _scanSub = null; + await link.stopScan(); + if (_scanning) { + _scanning = false; + _emit(const DevicePluginEvent(type: DevicePluginEventType.scanStopped)); + } + } + + @override + Future isScanning() async => _scanning; + + @override + Future> bondedDevices() async => rememberedDevices; + + // ───────────────────────────── 连接 ───────────────────────────── + + @override + Future connect(String deviceId, + {DeviceConnectOptions? options}) async { + _checkAlive(); + await stopScan(); + + final adv = options?.extra['advertisement']; + final macHint = options?.extra['mac'] as String?; + + try { + // 45s 不是随手写的:GATT 最多等 35s(设备被别的手机占着时不接受新连接), + // 之后杰理认证再给 15s。给 15s 会在设备刚被抢走时误报"超时"。 + await link.connect( + deviceId, + timeout: options?.timeout ?? const Duration(seconds: 45), + adv: adv is SmartcarAdvertisement ? adv : null, + ); + } on TimeoutException catch (e) { + throw DeviceException(DeviceErrorCode.connectTimeout, '$e'); + } catch (e) { + throw DeviceException(DeviceErrorCode.connectFailed, '$e'); + } + + final s = SmartcarSession( + deviceId: deviceId, + link: link, + plugin: this, + macAddress: link.advertisement?.macAddress ?? macHint, + ); + // 契约要求:握手完成后才 resolve。这里的「握手」= 取到固件版本, + // 取不到说明服务/特征对不上,宁可判失败也别把半条链路交给业务层。 + try { + await s._handshake(); + } catch (e) { + await link.disconnect(); + // 握手失败 = BLE 连上了但设备不认我们的命令(命令号错、CRC 错、 + // 或这一版固件根本没有 0x15/0x01)。**必须把原因带出去**, + // 只说「连接失败」分不清是链路问题还是协议问题。 + throw DeviceException( + DeviceErrorCode.handshakeFailed, 'BLE 已连上,但握手失败:$e'); + } + // 确权闸门。放在握手之后:要拿设备名去登记,而设备名是握手时才确定的。 + // + // ⚠️ 只有 denied 才断开。unknown(未登录/断网/没解出 MAC)一律放行—— + // 判严的代价是合法用户断网就用不了自己的设备,见 [SmartcarDeviceAuth] 类注释。 + final auth = await SmartcarDeviceAuth.verify( + mac: s.info.macAddress, + name: s.info.name, + ); + if (auth == DeviceAuthResult.denied) { + await link.disconnect(); + throw DeviceException( + DeviceErrorCode.connectFailed, + '设备未登记:这台 MAC 不在后台设备表里', + ); + } + + _session = s; + // 顺手把经典蓝牙(A2DP)接起来:AI 回复与音乐都靠它出声,BLE 只走控制与上行。 + // Android 上是 createBond(配上了系统自己接 A2DP);iOS 上做不到,只会返回 + // unsupported_ios,由扫描页提示用户去系统设置配对。失败一律不影响 BLE 会话。 + unawaited(s.connectClassicAudio().then((r) { + Logger.i(_tag, '经典蓝牙: $r'); + }).catchError((Object e) { + Logger.w(_tag, '经典蓝牙自动连接失败(不影响 BLE): $e'); + })); + _emit(DevicePluginEvent( + type: DevicePluginEventType.connectionStateChanged, + deviceId: deviceId, + connectionState: DeviceConnectionState.ready, + )); + return s; + } + + // ────────────────────── 记住 / 回连 / 解绑 ────────────────────── + + List> get _remembered => + (_storage.read(_rememberedKey) ?? const []) + .whereType() + .map((e) => e.cast()) + .toList(); + + @override + List get rememberedDevices => _remembered + .map((m) => DiscoveredDevice( + id: '${m['id']}', + name: '${m['name'] ?? 'Smartcar'}', + vendor: vendorKey, + metadata: {'mac': m['mac']}, + )) + .toList(); + + @override + DiscoveredDevice? get lastKnownDevice { + final list = rememberedDevices; + return list.isEmpty ? null : list.first; + } + + @override + Future rememberDevice(DiscoveredDevice device) async { + final list = _remembered..removeWhere((m) => m['id'] == device.id); + // 最近的排前面,[lastKnownDevice] 靠这个顺序 + list.insert(0, { + 'id': device.id, + 'name': device.name, + 'mac': device.metadata['mac'], + }); + await _storage.write(_rememberedKey, list); + } + + @override + Future forgetDevice(String deviceId) async { + final list = _remembered..removeWhere((m) => m['id'] == deviceId); + await _storage.write(_rememberedKey, list); + if (link.deviceId == deviceId) await link.disconnect(); + } + + @override + Future tryAutoConnect() async { + if (_disposed || _session != null || link.isConnected) return; + final first = rememberedDevices.isEmpty ? null : rememberedDevices.first; + if (first == null) return; + try { + await connect(first.id); + } catch (e) { + // 回连失败很正常(设备不在身边 / 蓝牙没开),不该打扰用户 + Logger.i(_tag, '自动回连未成功: $e'); + } + } + + @override + Future dispose() async { + if (_disposed) return; + _disposed = true; + await _scanSub?.cancel(); + await _connSub?.cancel(); + await _diagSub?.cancel(); + await link.dispose(); + _emit(const DevicePluginEvent(type: DevicePluginEventType.pluginDisposed)); + await _events.close(); + } + + void _emit(DevicePluginEvent e) { + if (!_events.isClosed) _events.add(e); + } + + void _checkAlive() { + if (_disposed) throw StateError('SmartcarDevicePlugin 已 dispose'); + } +} + +/// Smartcar 的会话句柄 +class SmartcarSession implements DeviceSession { + SmartcarSession({ + required this.deviceId, + required SmartcarLink link, + required SmartcarDevicePlugin plugin, + String? macAddress, + }) : _link = link, + _plugin = plugin, + _mac = macAddress { + _frameSub = _link.frames.listen(_onFrame); + } + + /// 本期实现到的能力。 + /// + /// ⚠️ **只增不删**,而且**别提前声明没做的**:业务层按能力画 UI, + /// 声明了 `micUplink` 却没实现,AI 页就会出现一个点了没反应的入口。 + static const Set caps = { + DeviceCapability.scan, + DeviceCapability.connect, + DeviceCapability.battery, + // Smartcar 的 MAC 在广播里,**iOS 也拿得到**——这点比恒玄强, + // 恒玄在 iOS 上只有 peripheral UUID,确权等于没有。 + DeviceCapability.macAddress, + DeviceCapability.customCommand, + // 杰理 OTA:原生 cmdOTAData + stage2 扫描回连(从 lumi 搬),见 [SmartcarOtaPort] + DeviceCapability.ota, + // 设备麦克风上行:opus → 16k PCM(原生 SmartcarAudio 解码) + DeviceCapability.micUplink, + // 设备按键/语音唤醒 AI:设备主动上报 0xCC 0x11。声明它就能白嫖 + // DeviceAiSessionService 那整套会话编排 + DeviceCapability.aiWake, + // ⚠️ 刻意**不声明** speakerDownlink:AI 回复不走 BLE,走经典蓝牙 A2DP + // (手机正常播放,设备当音箱)。openSpeaker(ai) 返回的是"手机扬声器"这个汇, + // 见 [_SmartcarPhoneSpeaker]。 + }; + + @override + final String deviceId; + + final SmartcarLink _link; + /// OTA 结束后回连要用([SmartcarOtaPort.close]) + final SmartcarDevicePlugin _plugin; + final String? _mac; + + StreamSubscription? _frameSub; + final _events = StreamController.broadcast(); + final _wake = StreamController.broadcast(); + _SmartcarMic? _mic; + _SmartcarPhoneSpeaker? _spk; + + DeviceConnectionState _state = DeviceConnectionState.linkConnected; + DeviceInfo _info = const DeviceInfo(id: '', name: '', vendor: ''); + + @override + String get vendor => DeviceVendors.smartcar; + + @override + DeviceConnectionState get state => _state; + + @override + DeviceInfo get info => _info; + + @override + Set get capabilities => caps; + + @override + bool supports(DeviceCapability c) => caps.contains(c); + + @override + Stream get eventStream => _events.stream; + + /// 握手:先宣告「APP 已连接」(0x10),再取一次固件版本 + 参数汇总。 + /// + /// ⚠️ 0x10 不能省:杰理认证通过、0x15 也能应答的情况下,设备屏幕照样显示 + /// BLE 未连接,直到收到这条(真机 2026-09-10 验证,见 [SmartcarProtocol.cmdAppConnected])。 + /// 它失败就判握手失败——"App 以为连上了、设备不认"正是这次排查的起点。 + /// + /// 0x15 一次就把电量、版本、各种开关全带回来了,比逐条问省好几个往返。 + /// 拿不到 0x15 时退回 0x01(只要版本号),两条都失败才判握手失败。 + Future _handshake() async { + _info = DeviceInfo( + id: deviceId, + name: 'Smartcar', + vendor: vendor, + macAddress: _mac, + ); + await _announceAppConnected(); + await _jlPostAuth(); + await _syncTime(); + Object? e15; + try { + final f = await _link.send(SmartcarProtocol.cmdSyncSummary); + if (f != null && f.success) { + _applySummary(f.payload); + _state = DeviceConnectionState.ready; + return; + } + e15 = '设备回了 0x15 但 len=0(命令执行失败)'; + } catch (e) { + e15 = e; + Logger.w('SmartcarSession', '0x15 参数汇总失败,退回取版本号: $e'); + } + Object? e01; + SmartcarFrame? v; + try { + v = await _link.send(SmartcarProtocol.cmdGetVersion); + } catch (e) { + e01 = e; + } + if (v == null || !v.success) { + // 两条查询命令都没回应答。最可能的原因按概率排: + // ① 命令号不对(协议文档与 lumi 代码本来就冲突,见 SmartcarProtocol 的 + // TODO(smartcar-cmd));② CRC 算法或帧格式对不上;③ 这版固件不支持。 + throw StateError('0x15 与 0x01 都没有有效应答 ' + '(0x15: $e15; 0x01: ${e01 ?? '无应答'})。' + '多半是命令号或帧格式不对——见 SmartcarProtocol 里的 TODO(smartcar-cmd)'); + } + _applyVersion(v.payload); + _state = DeviceConnectionState.ready; + } + + /// 0x10「蓝牙连接成功」:`[设备类型, 车企编号]`,设备回 `BB 10 01 64` 才算数。 + Future _announceAppConnected() async { + final appType = Platform.isIOS + ? SmartcarProtocol.appTypeIos + : SmartcarProtocol.appTypeAndroid; + SmartcarFrame? f; + try { + f = await _link.send( + SmartcarProtocol.cmdAppConnected, + payload: [appType, SmartcarProtocol.carVendorDefault], + ); + } catch (e) { + throw StateError('0x10「蓝牙连接成功」无应答:$e。' + '认证已过但设备不会把手机当成已连接,屏幕会一直显示 BLE 未连接'); + } + if (f == null || !f.success) { + throw StateError('设备拒绝了 0x10「蓝牙连接成功」(len=0)'); + } + Logger.i('SmartcarSession', '已向设备宣告 APP 连接 (type=$appType),设备应答 ${f.payload}'); + } + + /// 杰理 RCSP 认证后的收尾:设时间 → GET_TARGET_INFO → GET_SYS_INFO。 + /// + /// lumi 两端的 JL SDK 都会在认证后自动发这一串(iOS `initDialSystem`、Android `WatchOpImpl`), + /// 我们自己持有 GATT 就得自己发。2026-09-10 真机:0x10 + 0x15 都成功时屏幕仍显示未连接, + /// 把这串和 0x17 补上之后才亮。TODO(smartcar-handshake):还没二分出到底是哪一步点亮的, + /// 全部保留;失败只记日志不断链——这几条是"锦上添花",链路本身已经能用。 + Future _jlPostAuth() async { + try { + final r = await _link.jlPostAuth(); + Logger.i('SmartcarSession', 'RCSP 认证后收尾: $r'); + } catch (e) { + Logger.w('SmartcarSession', 'RCSP 认证后收尾失败(继续): $e'); + } + } + + /// 0x17 同步手机时间:`[年%100, 月, 日, 时, 分, 秒, 0x01]`(lumi 连上 800ms 后发,设备回 `BB 17 01 64`)。 + /// 协议文档里没有这条,以 lumi 代码与真机应答为准。 + Future _syncTime() async { + final n = DateTime.now(); + try { + await _link.send( + SmartcarProtocol.cmdSyncTime, + payload: [n.year % 100, n.month, n.day, n.hour, n.minute, n.second, 0x01], + timeout: const Duration(seconds: 2), + ); + } catch (e) { + Logger.w('SmartcarSession', '0x17 时间同步无应答(继续): $e'); + } + } + + void _applySummary(List p) { + final s = SmartcarAllSettings.parse(p); + if (s == null) return; + _info = _info.copyWith( + firmwareVersion: s.firmwareVersion, + batteryPercent: s.battery, + battery: DeviceBattery(single: s.battery), + ); + _emitInfo(); + _emitFeature('common.battery', {'percent': s.battery}); + } + + void _applyVersion(List p) { + if (p.length < 3) return; + _info = _info.copyWith( + firmwareVersion: '${p[0] & 0xFF}.${p[1] & 0xFF}.${p[2] & 0xFF}'); + _emitInfo(); + } + + void _onFrame(SmartcarFrame f) { + if (f.isNotify) { + // 设备主动上报。0xCC 0x11 = AI 唤醒:设备已经自己开麦了, + // DeviceAiSessionService 收到 wakeEvents 后会 aiAccept + openMic。 + if (f.cmd == SmartcarProtocol.notifyAiWake) { + Logger.i('SmartcarSession', '设备唤醒 AI 对话 (0xCC 0x11)'); + _emitFeature('smartcar.ai_wake', const {}); + if (!_wake.isClosed) { + _wake.add(DeviceWakeEvent( + deviceId: deviceId, + // 设备侧是按键或语音触发的,协议里区分不出来,统一按 ptt 上报 + reason: WakeReason.ptt, + )); + } + return; + } + if (f.cmd == SmartcarProtocol.cmdGetBattery && f.payload.isNotEmpty) { + final b = f.payload[0] & 0xFF; + _info = _info.copyWith( + batteryPercent: b, battery: DeviceBattery(single: b)); + _emitInfo(); + _emitFeature('common.battery', {'percent': b}); + } + return; + } + if (f.isResponse && f.success) { + if (f.cmd == SmartcarProtocol.cmdSyncSummary) _applySummary(f.payload); + if (f.cmd == SmartcarProtocol.cmdGetVersion) _applyVersion(f.payload); + if (f.cmd == SmartcarProtocol.cmdGetBattery && f.payload.isNotEmpty) { + final b = f.payload[0] & 0xFF; + _info = _info.copyWith( + batteryPercent: b, battery: DeviceBattery(single: b)); + _emitInfo(); + } + } + } + + void _emitInfo() { + if (_events.isClosed) return; + _events.add(DeviceSessionEvent( + type: DeviceSessionEventType.deviceInfoUpdated, + deviceId: deviceId, + deviceInfo: _info, + )); + } + + void _emitFeature(String key, Map data) { + if (_events.isClosed) return; + _events.add(DeviceSessionEvent( + type: DeviceSessionEventType.feature, + deviceId: deviceId, + feature: DeviceFeatureEvent(key: key, data: data), + )); + } + + void _closeAudio() { + _mic?.close(); + _mic = null; + _spk?.close(); + _spk = null; + } + + void _markDisconnected() { + if (_state == DeviceConnectionState.disconnected) return; + _state = DeviceConnectionState.disconnected; + if (!_events.isClosed) { + _events.add(DeviceSessionEvent( + type: DeviceSessionEventType.connectionStateChanged, + deviceId: deviceId, + connectionState: DeviceConnectionState.disconnected, + )); + _events.close(); + } + _frameSub?.cancel(); + _frameSub = null; + // 断链时把音频通路一起收掉:麦克风流没人再喂,扬声器要停掉正在播的 AI 回复 + _closeAudio(); + if (!_wake.isClosed) _wake.close(); + } + + void _requireReady() { + if (_state == DeviceConnectionState.disconnected) { + throw StateError('会话已断开'); + } + if (_state != DeviceConnectionState.ready) { + throw DeviceException( + DeviceErrorCode.noActiveSession, 'Smartcar 会话未就绪'); + } + } + + @override + Future readRssi() async { + _requireReady(); + // flutter_blue_plus 的 readRssi 挂在 BluetoothDevice 上,本期不接; + // 契约允许抛 not_supported,业务层已按能力判断(没声明 rssi 能力)。 + throw DeviceException(DeviceErrorCode.notSupported, 'readRssi'); + } + + @override + Future readBattery() async { + _requireReady(); + final f = await _link.send(SmartcarProtocol.cmdGetBattery); + if (f == null || !f.success || f.payload.isEmpty) return null; + return f.payload[0] & 0xFF; + } + + @override + Future refreshInfo() async { + _requireReady(); + final f = await _link.send(SmartcarProtocol.cmdSyncSummary); + if (f != null && f.success) _applySummary(f.payload); + return _info; + } + + @override + Stream get wakeEvents => _wake.stream; + + @override + Future openMic({AudioRoute route = AudioRoute.mic}) async { + _requireReady(); + // ⚠️ **先关旧的、再开新的**,顺序反了会踩这个坑(2026-09-11 真机): + // 旧 mic 的 close() 会 audioStop(),写在 audioStart() 后面就等于把刚打开的 + // 解码器又关掉。第一次唤醒 _mic 是 null 看不出来,**第二次起每次都上行 0 包**, + // 表现成"第一句正常、后面全卡住"。[_closeMic] 的身份校验是第二道保险。 + final old = _mic; + _mic = null; + await old?.close(); + + // 设备在唤醒后自己开麦,这里只是打开原生的 opus 解码转发 + final ok = await _link.audioStart(); + if (!ok) { + throw DeviceException( + DeviceErrorCode.notSupported, 'opus 解码器起不来,拿不到设备麦克风'); + } + final src = _SmartcarMic(route, _link, _closeMic); + _mic = src; + return src; + } + + /// 关掉某个麦克风源。**只有它还是当前那个才真去停解码器**—— + /// 已经被新会话换掉的旧 mic 再 close 是空操作,否则会误伤新会话。 + Future _closeMic(_SmartcarMic mic) async { + if (!identical(_mic, mic)) return; + _mic = null; + await _link.audioStop(); + } + + @override + Future openSpeaker({AudioRoute route = AudioRoute.ai}) async { + _requireReady(); + if (route != AudioRoute.ai) { + throw DeviceException( + DeviceErrorCode.notSupported, 'openSpeaker($route)'); + } + // ⚠️ 与恒玄不同:Smartcar 的下行**不经 BLE**。AI 回复由手机正常播放, + // 经典蓝牙 A2DP 把它送到设备喇叭。所以这个"汇"就是手机扬声器。 + // 没配对 A2DP 时声音会从手机外放出来——功能仍然成立,只是不在车里响, + // 所以这里只提示、不拦截。 + if (!await isClassicAudioConnected()) { + Logger.w('SmartcarSession', + 'A2DP 未连接,AI 回复会从手机外放。设备 MAC=$_mac'); + } + // 同 openMic:先关旧的再建新的。这里每个 sink 自带播放器、互不共享, + // 顺序反了不会互相误伤,但保持一致的写法,免得下次照着抄出问题。 + final old = _spk; + _spk = null; + await old?.close(); + final sink = _SmartcarPhoneSpeaker(); + _spk = sink; + return sink; + } + + /// 经典蓝牙音频通路(A2DP)通没通——决定 AI 回复"在不在车里响"。 + /// + /// 判据有两个,取其一即可: + /// 1. 系统层:`ClassicBluetooth.isA2dpConnected(MAC)`(Android 查 A2DP profile, + /// iOS 查 AVAudioSession 当前输出) + /// 2. 设备层:广播里的经典蓝牙连接位(`0x0C`)——设备自己说的,最权威, + /// 但只有扫描时才能拿到,连上之后就不再更新 + Future isClassicAudioConnected() async { + final mac = classicMacCandidate; + if (mac == null || mac.isEmpty) return false; + return ClassicBluetooth.isA2dpConnected(mac); + } + + SmartcarOtaPort? _otaPort; + + /// 杰理 OTA 端口。同一会话复用一个实例(端口内部有互斥,重复 start 抛 ota_busy)。 + @override + DeviceOtaPort? otaPort() => _otaPort ??= SmartcarOtaPort(_link, _plugin); + + /// 车载香薰**没有**设备端文件仓库(没有 TF 卡,录音不落设备),所以恒为 null, + /// 能力集里也没有 `DeviceCapability.fileStore`。 + /// 支架那条「录到 TF 卡、事后拉」的链路见 `HolderFileStore`。 + @override + DeviceFileStore? fileStore() => null; + + /// 通用 feature key → C2 命令。 + /// + /// ⚠️ 这张表就是**冻结的协议**:同一个 key 永远发同样的字节。 + /// 改它之前先看 [SmartcarProtocol] 里那些 TODO(smartcar-cmd) 标记—— + /// 有几条命令号本身还没经真机验证。 + @override + Future> invokeFeature( + String key, [ + Map args = const {}, + ]) async { + _requireReady(); + switch (key) { + case 'battery.query': + final b = await readBattery(); + return {'percent': b}; + case 'state.refresh': + await refreshInfo(); + return {'ok': true}; + case 'smartcar.volume': + final v = (args['value'] as num?)?.toInt() ?? 0; + await _link.send(SmartcarProtocol.cmdSetVolume, payload: [v & 0xFF]); + return {'ok': true}; + case 'smartcar.brightness': + final v = (args['value'] as num?)?.toInt() ?? 0; + await _link + .send(SmartcarProtocol.cmdControlBrightness, payload: [v & 0xFF]); + return {'ok': true}; + case 'smartcar.app_connected': + // 重发一次「APP 已连接」宣告(握手里已经发过;这里给排查/固件回落时用) + await _announceAppConnected(); + return {'ok': true}; + + // ---------- AI 会话(DeviceAiSessionService 驱动) ---------- + case DeviceFeatures.aiAccept: + // 设备发完 0xCC 0x11 就自己开麦了,App 这边只要把状态回报过去, + // 设备据此切"聆听中"的表情。**不需要**再下开麦命令。 + await _setAiState(SmartcarProtocol.aiStateListening); + return {'ok': true}; + case DeviceFeatures.aiExit: + await _setAiState(SmartcarProtocol.aiStateClose); + await _link.audioStop(); + return {'ok': true}; + case DeviceFeatures.aiState: + // 通用语义 → C2 的 0x07。**设备的表情动画全靠这条**:不发的话 + // 整轮对话里设备只在开头收到一次"聆听中",之后一直是那个表情 + // (2026-09-11 真机反馈"表情没反应"就是这个原因)。 + final st = args['state']; + final v = switch (st) { + DeviceAiState.listening => SmartcarProtocol.aiStateListening, + DeviceAiState.thinking => SmartcarProtocol.aiStateThinking, + DeviceAiState.speaking => SmartcarProtocol.aiStateSpeakStart, + DeviceAiState.idle => SmartcarProtocol.aiStateSpeakEnd, + _ => null, + }; + if (v == null) { + throw DeviceException( + DeviceErrorCode.invalidArgument, 'ai.state 参数非法: $st'); + } + await _setAiState(v); + return {'ok': true}; + case 'smartcar.ai_state': + final v = (args['value'] as num?)?.toInt(); + if (v == null) { + throw DeviceException(DeviceErrorCode.invalidArgument, '缺少 value'); + } + await _setAiState(v); + return {'ok': true}; + + // ---------- 经典蓝牙 / 音乐 ---------- + case 'smartcar.music_playing': + // 0x05:告诉设备手机在不在放音乐,设备据此显示"听音乐"表情 + final on = args['value'] == true; + await _link.send(SmartcarProtocol.cmdMusicPlayState, + payload: [on ? 0x01 : 0x00]); + return {'ok': true}; + case 'smartcar.a2dp_status': + return { + 'mac': _mac, + 'connected': await isClassicAudioConnected(), + 'devices': (await ClassicBluetooth.getConnectedDevices()) + .map((d) => {'name': d.name, 'address': d.address}) + .toList(), + }; + case 'smartcar.a2dp_connect': + return await connectClassicAudio(); + + case 'smartcar.emotion': + final v = (args['value'] as num?)?.toInt() ?? 0; + await _link + .send(SmartcarProtocol.cmdControlEmotion, payload: [v & 0xFF]); + return {'ok': true}; + default: + throw DeviceException(DeviceErrorCode.notSupported, 'feature $key'); + } + } + + /// 0x07 AI 状态。设备靠它切表情动画,**不回应答**,发完就完事。 + Future _setAiState(int state) async { + try { + await _link + .send(SmartcarProtocol.cmdAiState, payload: [state & 0xFF]); + } catch (e) { + // 状态同步失败不该中断会话——它只影响设备表情,不影响对话本身 + Logger.w('SmartcarSession', '0x07 AI 状态($state) 发送失败: $e'); + } + } + + /// 发起经典蓝牙配对,让 A2DP 通起来(AI 回复与音乐都靠它出声)。 + /// + /// - Android:`createBond()`,配对成功后系统自己接 A2DP + /// - iOS:**做不到**——Apple 没有发起经典蓝牙配对的公开 API,只能引导用户 + /// 去「设置 → 蓝牙」手动配一次。返回 `{'ok': false, 'status': 'unsupported_ios'}` + Future> connectClassicAudio() async { + final mac = classicMacCandidate; + if (mac == null || mac.isEmpty) { + return {'ok': false, 'status': 'no_mac', 'hint': '广播里没解出 MAC'}; + } + if (await ClassicBluetooth.isA2dpConnected(mac)) { + return {'ok': true, 'status': 'already_connected', 'mac': mac}; + } + try { + final status = await ClassicBluetooth.connectDevice(mac); + Logger.i('SmartcarSession', + '经典蓝牙 connectDevice($mac) → $status(广播MAC=$_mac, BLE id=$deviceId)'); + return { + 'ok': status != null && status != 'unsupported_ios', + 'status': status, + 'mac': mac, + }; + } catch (e) { + Logger.w('SmartcarSession', '经典蓝牙配对失败: $e'); + return {'ok': false, 'status': 'error', 'error': '$e', 'mac': mac}; + } + } + + /// 拿去配经典蓝牙的 MAC。 + /// + /// 协议文档说广播里那 6 字节就是**经典蓝牙 MAC**,iOS 真机上服务端也按它确权通过了, + /// 所以默认原样用。但 lumi 的 Android 实现是另一套:`经典 MAC = BLE MAC 首字节 − 1` + /// (`deriveClassicMacFromBleMac`,固件约定),前提是广播里给的就是 BLE MAC。 + /// + /// 两种说法只能在安卓上分辨——安卓的 `deviceId` 就是 BLE MAC,iOS 只有 UUID: + /// - 广播 MAC ≠ BLE MAC → 广播给的是另一个地址,按文档当经典 MAC 用 + /// - 广播 MAC == BLE MAC → 广播给的其实是 BLE 地址,按 lumi 的规则减一 + /// + /// ⚠️ **未经安卓真机验证**(TODO(smartcar-a2dp))。首次真机看 + /// `connectDevice(...)` 那行日志里三个地址的关系就能定论。 + String? get classicMacCandidate { + final mac = _mac; + if (mac == null || mac.isEmpty) return null; + if (!Platform.isAndroid) return mac; + if (mac.toUpperCase() != deviceId.toUpperCase()) return mac; + final parts = mac.split(':'); + if (parts.length != 6) return mac; + final first = int.tryParse(parts[0], radix: 16); + if (first == null) return mac; + parts[0] = ((first - 1) & 0xFF).toRadixString(16).padLeft(2, '0').toUpperCase(); + return parts.join(':'); + } + + @override + Future disconnect() async { + _state = DeviceConnectionState.disconnecting; + await _link.audioStop(); + await _link.disconnect(); + _markDisconnected(); + } +} + +/// 设备麦克风 → 业务层。原生已经把 opus 解成 16k/16bit/mono PCM。 +class _SmartcarMic implements DeviceAudioSource { + _SmartcarMic(this.route, this._link, this._onClose); + + @override + final AudioRoute route; + final SmartcarLink _link; + + /// 交给会话去决定要不要真停:这个源可能早就被新会话换掉了, + /// 直接 `audioStop()` 会把新会话的解码器一起关了。 + final Future Function(_SmartcarMic) _onClose; + + @override + AudioFormat get format => AudioFormat.pcm16kMono; + + @override + Stream get pcm => _link.audioPcm; + + @override + Future close() => _onClose(this); +} + +/// AI 回复的去处:**手机扬声器**(经典蓝牙 A2DP 连着时就从设备喇叭出来)。 +/// +/// 与恒玄那种"PCM 编码后经 BLE 灌给设备"完全不同——Smartcar 的下行不经 BLE, +/// 所以这里不写任何字节到链路上,只是把 PCM 交给手机播放。 +class _SmartcarPhoneSpeaker implements DeviceAudioSink { + _SmartcarPhoneSpeaker(); + + final PcmStreamPlayer _player = + PcmStreamPlayer(sampleRate: 16000, channels: 1); + + @override + AudioRoute get route => AudioRoute.ai; + + @override + AudioFormat get format => AudioFormat.pcm16kMono; + + @override + Future write(Uint8List pcm) async { + // feed 内部会自己 begin,不用外面掐时序 + _player.feed(pcm); + } + + @override + Future discardPending() => _player.stopNow(); + + @override + Future close() => _player.dispose(); +} diff --git a/apps/client/lib/devices/smartcar/smartcar_link.dart b/apps/client/lib/devices/smartcar/smartcar_link.dart new file mode 100644 index 00000000..2227a575 --- /dev/null +++ b/apps/client/lib/devices/smartcar/smartcar_link.dart @@ -0,0 +1,410 @@ +import 'dart:async'; + +import 'package:device_smartcar/device_smartcar.dart'; +import 'package:flutter/services.dart'; + +import '../../core/utils/logger.dart'; +import 'smartcar_protocol.dart'; + +/// Smartcar 的 BLE 链路:扫描 / 连接 / 收发 C2 帧。 +/// +/// ## 为什么是原生插件、不是 flutter_blue_plus(2026-09-10 真机踩出来的) +/// +/// Smartcar 是杰理芯片,**固件要求先过杰理 RCSP 认证**(在服务 `AE00` 上,由闭源 +/// `JL_BLEKit` 的 `JL_Assist` 跑挑战应答)才把手机当成已连接:认证前设备屏幕显示 +/// BLE 未连接、`ABC0` 私有通道的命令也不理。lumi 两端代码里都写明了这一点 +/// (Android:「全新的 BLE 连接,没有走 JLWatchManager 认证,设备拒绝响应任何 RCSP 命令」)。 +/// +/// 第一版用 flutter_blue_plus 纯 Dart 实现,只订阅了 `ABC0`,从没和 `AE00` 说过话—— +/// 设备看来就是"没人认证过"。认证算法在闭源 SDK 里,Dart 复刻不了;而要把 +/// CBPeripheralDelegate 的回调转交给 SDK,GATT 必须由自己持有,flutter_blue_plus +/// 做不到。于是链路收进原生插件 `device_smartcar`,这里只剩通道封装。 +/// +/// 组帧 / CRC / 广播解析仍在 Dart([SmartcarProtocol]),单测继续守着。 +/// +/// ⚠️ 一部手机对同一个 peripheral **只能有一条 GATT 连接**。阶段 6 的 Opus 上行 +/// 要在原生插件里复用这条连接订阅音频特征,**不能另起一条**。 +class SmartcarLink { + static const String _tag = 'SmartcarLink'; + + final DeviceSmartcar _native = DeviceSmartcar.instance; + StreamSubscription>? _eventSub; + + final _frames = StreamController.broadcast(); + final _connState = StreamController.broadcast(); + final _diag = StreamController>.broadcast(); + final _ota = StreamController>.broadcast(); + final _audio = StreamController.broadcast(); + + /// 设备麦克风上行 PCM(**16k / 16bit / mono**,原生已把 opus 解好)。 + /// 帧格式与解码见原生 `SmartcarAudio`。 + Stream get audioPcm => _audio.stream; + + /// 原生 OTA 事件:`{state: preparing|transferring|stage2|done|failed|cancelled, percent, code?, message?}` + Stream> get otaEvents => _ota.stream; + + /// 解好的帧(CRC 已校验,坏帧不会出现在这里) + Stream get frames => _frames.stream; + + /// 链路通断。**true = 杰理认证已通过**,不是 GATT 连上。 + Stream get connected => _connState.stream; + + /// 扫描诊断:每一台扫到的 BLE 设备(含没命中判定的),扫描页拿去摊开给人看。 + /// 「扫不到」「连不上」两次排查都栽在失败不可见上,所以原生报上来的每个状态/原因 + /// 也会以一行的形式塞进这里。 + Stream> get diagnostics => _diag.stream; + + bool _isConnected = false; + bool get isConnected => _isConnected; + + String? _deviceId; + String? get deviceId => _deviceId; + + /// 连上时记下的广播信息(MAC / License 从这里来) + SmartcarAdvertisement? advertisement; + + /// 最近一次原生上报的链路状态与原因,连接失败时拼进错误信息 + String _lastState = 'disconnected'; + String? _lastReason; + + SmartcarLink() { + _eventSub = _native.events.listen(_onNativeEvent, onError: (Object e) { + Logger.w(_tag, '原生事件流错误: $e'); + }); + } + + // ───────────────────────────── 事件路由 ───────────────────────────── + + StreamController? _scanCtrl; + + void _onNativeEvent(Map e) { + switch (e['type']) { + case 'adapter': + _diagLine('(蓝牙适配器)', 'adapter=${e['state']}'); + break; + case 'scan': + _onScanHit(e); + break; + case 'scanStopped': + final c = _scanCtrl; + _scanCtrl = null; + if (c != null && !c.isClosed) c.close(); + break; + case 'state': + _onStateEvent(e); + break; + case 'custom': + final d = e['data']; + if (d is Uint8List) _onValue(d); + break; + case 'ota': + if (!_ota.isClosed) _ota.add(Map.from(e)); + break; + case 'audio': + final d = e['data']; + if (d is Uint8List && !_audio.isClosed) _audio.add(d); + break; + } + } + + /// 开始接收设备麦克风上行(设备自己开麦,这里只是打开原生的解码转发) + Future audioStart() async { + try { + return await _native.audioStart(); + } on PlatformException catch (e) { + Logger.w(_tag, '音频上行起不来: ${e.message ?? e.code}'); + return false; + } on MissingPluginException { + return false; + } + } + + Future audioStop() async { + try { + await _native.audioStop(); + } catch (_) {} + } + + Future> audioStats() async { + try { + return await _native.audioStats(); + } catch (_) { + return const {}; + } + } + + /// 启动杰理 OTA(原生 `SmartcarOta`:cmdOTAData + stage2 扫描回连)。失败抛 [StateError]。 + Future otaStart(String filePath) async { + try { + await _native.otaStart(filePath); + } on PlatformException catch (e) { + throw StateError(e.message ?? e.code); + } + } + + Future otaCancel() async { + try { + await _native.otaCancel(); + } catch (_) {} + } + + void _onStateEvent(Map e) { + final s = '${e['state']}'; + _lastState = s; + _lastReason = e['reason'] as String?; + Logger.i(_tag, '链路状态 → $s${_lastReason == null ? '' : '($_lastReason)'}'); + _diagLine('(链路 ${e['id'] ?? ''})', '$s${_lastReason == null ? '' : ':$_lastReason'}'); + final up = s == 'ready'; + if (up == _isConnected) return; + _isConnected = up; + if (up) { + _deviceId = e['id'] as String?; + } else { + _failAllPending('链路断开: $s ${_lastReason ?? ''}'); + } + if (!_connState.isClosed) _connState.add(up); + } + + void _diagLine(String name, String msg) { + if (_diag.isClosed) return; + _diag.add({ + 'name': name, 'id': '', 'rssi': 0, 'matched': false, + 'mfg': msg, 'services': '', + }); + } + + // ───────────────────────────── 扫描 ───────────────────────────── + + /// 扫本品类设备。原生侧全量扫(不加 service 过滤——CoreBluetooth 的过滤只认 + /// 「Service UUIDs」AD 字段,C2 的身份在厂商数据里,加了一台都扫不到),这里按两条判定收: + /// 1. 厂商数据能解出合法 C2 广播且 CID == 0x8505(主判据,MAC/License 一并解出) + /// 2. 广播名含 `smartcar`(兜底) + Stream scan({ + Duration timeout = const Duration(seconds: 15), + }) { + final ctrl = StreamController(); + _scanCtrl?.close(); + _scanCtrl = ctrl; + ctrl.onCancel = () async { + if (identical(_scanCtrl, ctrl)) _scanCtrl = null; + await stopScan(); + }; + + () async { + try { + await _native.startScan(timeoutSec: timeout.inSeconds); + } on PlatformException catch (e) { + final msg = e.message ?? e.code; + Logger.e(_tag, '启动扫描失败: $msg'); + _diagLine('(扫描启动失败)', msg); + if (!ctrl.isClosed) ctrl.addError(StateError(msg)); + } on MissingPluginException { + const msg = '本平台没有 Smartcar 原生链路(只支持 Android / iOS)'; + if (!ctrl.isClosed) ctrl.addError(StateError(msg)); + } + }(); + + return ctrl.stream; + } + + Future stopScan() async { + try { + await _native.stopScan(); + } catch (_) {} + } + + void _onScanHit(Map e) { + final id = '${e['id'] ?? ''}'; + final name = '${e['name'] ?? ''}'; + final rssi = (e['rssi'] as num?)?.toInt() ?? 0; + final mfg = e['mfg']; + final services = (e['services'] as List?)?.map((x) => '$x').join(',') ?? ''; + + // CBAdvertisementDataManufacturerDataKey 原样过来:前 2 字节是小端公司 ID + int? companyId; + List payload = const []; + if (mfg is Uint8List && mfg.length >= 2) { + companyId = mfg[0] | (mfg[1] << 8); + payload = mfg.sublist(2); + } + + SmartcarAdvertisement? adv; + // 云衍厂商 ID 0x8505;0x0585 是字节序反过来的写法,两个都收(lumi 的常量表并列了两者) + final cidMatched = companyId == 0x8505 || companyId == 0x0585; + if (cidMatched) { + // 补回 [长度, 0xFF, CID低, CID高]:解析器按协议文档吃完整 AD 结构 + adv = SmartcarProtocol.parseManufacturerData( + [payload.length + 3, 0xFF, 0x05, 0x85, ...payload]); + } + final byName = name.toLowerCase().contains('smartcar'); + + if (!_diag.isClosed) { + _diag.add({ + 'name': name, + 'id': id, + 'rssi': rssi, + 'matched': adv != null || byName, + 'mfg': companyId == null + ? '(无厂商数据)' + : '0x${companyId.toRadixString(16).toUpperCase()}[${payload.length}]=' + '${payload.map((b) => b.toRadixString(16).padLeft(2, '0')).join()}', + 'services': services, + 'parsedMac': adv?.macAddress ?? '(解不出)', + }); + } + + if (adv == null && !byName) return; + if (adv == null) { + Logger.w(_tag, '按名字命中 "$name" 但厂商广播段解析失败,MAC 取不到;companyId=$companyId len=${payload.length}'); + } + final c = _scanCtrl; + if (c != null && !c.isClosed) { + c.add(SmartcarScanResult(id: id, name: name, rssi: rssi, advertisement: adv)); + } + } + + // ───────────────────────────── 连接 ───────────────────────────── + + /// 连上并**等杰理认证通过**才返回。失败抛 [StateError],message 带原生报上来的原因 + /// (没有 AE00 / 认证失败 / 认证超时 / GATT 超时 各不相同,别再合并成"连接失败")。 + /// + /// [timeout] 是总超时:GATT 连接本身最多 35s(设备被别的手机占着时不接受新连接, + /// 只能等它超时),认证再给 15s,所以默认给到 45s。 + Future connect( + String deviceId, { + Duration timeout = const Duration(seconds: 45), + SmartcarAdvertisement? adv, + }) async { + advertisement = adv; + _lastReason = null; + try { + await _native.connect(deviceId, timeoutSec: timeout.inSeconds); + } on PlatformException catch (e) { + throw StateError(e.message ?? e.code); + } on MissingPluginException { + throw StateError('本平台没有 Smartcar 原生链路(只支持 Android / iOS)'); + } + // 原生只在 ready 时 resolve;state 事件与 result 谁先到不确定,这里补齐 + if (!_isConnected) { + _isConnected = true; + _deviceId = deviceId; + if (!_connState.isClosed) _connState.add(true); + } + // 认证刚过,给固件一拍再发业务命令(lumi 连上后也留了延迟才 syncCurrentTime) + await Future.delayed(const Duration(milliseconds: 300)); + Logger.i(_tag, '已连接并通过认证 $deviceId'); + } + + /// 杰理 RCSP 认证后的收尾(原生 SDK 发:设时间 / GET_TARGET_INFO / GET_SYS_INFO)。 + /// 返回各步状态,0 = 成功;失败抛 [StateError]。 + Future> jlPostAuth() async { + try { + return await _native.jlPostAuth(); + } on PlatformException catch (e) { + throw StateError(e.message ?? e.code); + } + } + + Future disconnect() async { + _failAllPending('主动断开'); + try { + await _native.disconnect(); + } catch (_) {} + if (_isConnected) { + _isConnected = false; + if (!_connState.isClosed) _connState.add(false); + } + _deviceId = null; + } + + Future dispose() async { + await disconnect(); + await _eventSub?.cancel(); + await _frames.close(); + await _connState.close(); + await _diag.close(); + await _ota.close(); + await _audio.close(); + } + + // ───────────────────────── 收发 ───────────────────────── + + /// 等应答的命令:cmd → 等待者。同一条命令不允许并发两次(应答里没有序号)。 + final Map> _pending = {}; + + void _onValue(List value) { + final f = SmartcarProtocol.parseFrame(value); + if (f == null) { + Logger.w(_tag, '丢弃非法帧: ${_hex(value)}'); + return; + } + if (f.isResponse) { + final c = _pending.remove(f.cmd); + if (c != null && !c.isCompleted) c.complete(f); + } + if (!_frames.isClosed) _frames.add(f); + } + + /// 发一条命令。[expectResponse] 不传就按 [SmartcarProtocol.expectsResponse] 判断—— + /// 那四条设备不回包的命令若强行等,只会白等到超时并把成功记成失败。 + Future send( + int cmd, { + List payload = const [], + bool? expectResponse, + Duration timeout = const Duration(seconds: 3), + }) async { + if (!_isConnected) { + throw StateError('链路未就绪($_lastState),不能发 0x${cmd.toRadixString(16)}'); + } + final wantResp = expectResponse ?? SmartcarProtocol.expectsResponse(cmd); + + Completer? waiter; + if (wantResp) { + final exist = _pending[cmd]; + if (exist != null) return exist.future; + waiter = Completer(); + _pending[cmd] = waiter; + } + + final packet = SmartcarProtocol.buildPacket(cmd, payload); + final ok = await _native.writeCustom(packet); + if (!ok) { + _pending.remove(cmd); + throw StateError('写 0x${cmd.toRadixString(16)} 失败:原生侧没有可用的写特征'); + } + if (waiter == null) return null; + + return waiter.future.timeout(timeout, onTimeout: () { + _pending.remove(cmd); + throw TimeoutException('0x${cmd.toRadixString(16)} 等应答超时'); + }); + } + + void _failAllPending(String why) { + for (final c in _pending.values) { + if (!c.isCompleted) c.completeError(StateError(why)); + } + _pending.clear(); + } + + static String _hex(List b) => + b.map((x) => (x & 0xFF).toRadixString(16).padLeft(2, '0')).join(' '); +} + +/// 一条扫描结果 +class SmartcarScanResult { + const SmartcarScanResult({ + required this.id, + required this.name, + required this.rssi, + this.advertisement, + }); + + final String id; + final String name; + final int rssi; + + /// 厂商广播段。**可能为 null**:仅按名字命中、厂商段解不出来时就是空的, + /// 此时拿不到 MAC,绑定确权会走 unknown(放行但不登记)。 + final SmartcarAdvertisement? advertisement; +} diff --git a/apps/client/lib/devices/smartcar/smartcar_ota_port.dart b/apps/client/lib/devices/smartcar/smartcar_ota_port.dart new file mode 100644 index 00000000..19d64ccf --- /dev/null +++ b/apps/client/lib/devices/smartcar/smartcar_ota_port.dart @@ -0,0 +1,159 @@ +import 'dart:async'; + +import 'package:device_plugin_interface/device_plugin_interface.dart'; + +import '../../core/utils/logger.dart'; +import 'smartcar_device_plugin.dart'; +import 'smartcar_link.dart'; + +/// Smartcar(杰理方案)的 OTA 端口。 +/// +/// 推送本身在原生 `SmartcarOta`(从 lumi `PbMangerImp` 搬:`cmdOTAData` + 0xfc 后 +/// 扫描回连 + 认证 + 续传),这里只做三件事: +/// 1. 把原生事件翻译成 [DeviceOtaProgress]; +/// 2. 终态锁定——成功后迟到的 failed/cancelled 不能把它覆盖回去(恒玄踩过); +/// 3. [close]:升级成功设备会重启,退出页面时延时回连记住的设备。 +/// +/// 与恒玄不同,**这条链路不用断 app 层连接**:OTA 数据走同一条 GATT 的 AE01, +/// 私有通道 ABC1 由原生在升级期间挡掉。stage2 阶段设备会主动断开重启, +/// 那次「断开」是升级流程的一部分,不是失败——由原生判定,这里不看链路状态。 +/// +/// ⚠️ 上层 `DeviceOtaController` 的看门狗 90s 没进度就判失败,而 stage2 等待可达 3 分钟, +/// 原生在这段窗口每 15s 发一次 `stage2` 心跳事件喂它。 +class SmartcarOtaPort implements DeviceOtaPort { + SmartcarOtaPort(this._link, this._plugin); + + static const String _tag = 'SmartcarOtaPort'; + + final SmartcarLink _link; + final SmartcarDevicePlugin _plugin; + final StreamController _progress = + StreamController.broadcast(); + + StreamSubscription>? _sub; + bool _running = false; + bool _terminal = false; + bool _succeeded = false; + int _lastPercent = -1; + + @override + bool get isRunning => _running; + + @override + Stream get progressStream => _progress.stream; + + @override + Future start(DeviceOtaRequest request) async { + if (_running) throw DeviceException(DeviceErrorCode.otaBusy); + if (request is! DeviceOtaFileRequest) { + throw DeviceException( + DeviceErrorCode.notSupported, '${request.runtimeType}'); + } + if (!_link.isConnected) { + throw DeviceException(DeviceErrorCode.noActiveSession, '链路未就绪'); + } + _running = true; + _terminal = false; + _succeeded = false; + _lastPercent = -1; + _sub ??= _link.otaEvents.listen(_onEvent, onError: (Object e) { + _fail(DeviceErrorCode.otaTransferFailed, '$e'); + }); + _emit(DeviceOtaState.inquiring, 0); + try { + await _link.otaStart(request.filePath); + } catch (e) { + _fail(DeviceErrorCode.otaTransferFailed, '$e'); + rethrow; + } + } + + void _onEvent(Map e) { + if (_terminal) return; + final state = '${e['state']}'; + final percent = (e['percent'] as num?)?.toInt() ?? -1; + switch (state) { + case 'preparing': + _emit(DeviceOtaState.entering, 0); + break; + case 'transferring': + // 只在单调递增时上报,SDK 的 preparing/prepared 会把百分比打回 0 + if (percent > _lastPercent) { + _lastPercent = percent; + _emit(DeviceOtaState.transferring, percent); + } + break; + case 'stage2': + // 设备断开重启进 stage2,原生在扫描回连;进度条停在 15% 但要持续有事件喂看门狗 + Logger.i(_tag, 'stage2 等待回连: ${e['message'] ?? ''}'); + _emit(DeviceOtaState.rebooting, _lastPercent < 15 ? 15 : _lastPercent); + break; + case 'done': + _terminal = true; + _succeeded = true; + _running = false; + _emit(DeviceOtaState.done, 100); + break; + case 'cancelled': + _terminal = true; + _running = false; + _emit(DeviceOtaState.cancelled, -1); + break; + case 'failed': + _fail(DeviceErrorCode.otaTransferFailed, + '${e['message'] ?? ''} (code=${e['code']})'); + break; + } + } + + void _fail(String code, String message) { + if (_terminal) return; + _terminal = true; + _running = false; + _emit(DeviceOtaState.failed, -1, errorCode: code, errorMessage: message); + } + + @override + Future cancel() async { + if (!_running) return; + await _link.otaCancel(); + // 原生会回 cancelled;5s 内没回就自己收尾(契约要求) + Future.delayed(const Duration(seconds: 5), () { + if (!_terminal) { + _terminal = true; + _running = false; + _emit(DeviceOtaState.cancelled, -1); + } + }); + } + + @override + Future close() async { + if (_running) await cancel(); + await _sub?.cancel(); + _sub = null; + // 升级成功设备会重启,广播回来要几秒;失败/取消时原生已把链路断开,一样回连 + final delay = _succeeded ? const Duration(seconds: 8) : const Duration(seconds: 2); + Future.delayed(delay, () async { + try { + await _plugin.tryAutoConnect(); + } catch (e) { + Logger.w(_tag, 'OTA 后回连失败: $e'); + } + }); + } + + void _emit(DeviceOtaState s, int percent, + {String? errorCode, String? errorMessage}) { + if (_progress.isClosed) return; + _progress.add(DeviceOtaProgress( + state: s, + sentBytes: 0, + totalBytes: 0, + percent: percent, + tsMs: DateTime.now().millisecondsSinceEpoch, + errorCode: errorCode, + errorMessage: errorMessage, + )); + } +} diff --git a/apps/client/lib/devices/smartcar/smartcar_protocol.dart b/apps/client/lib/devices/smartcar/smartcar_protocol.dart new file mode 100644 index 00000000..617e058c --- /dev/null +++ b/apps/client/lib/devices/smartcar/smartcar_protocol.dart @@ -0,0 +1,402 @@ +import 'dart:typed_data'; + +/// Smartcar(车载香薰,方案代号 **C2**)的 BLE 私有协议。 +/// +/// 纯函数 + 纯数据,不碰蓝牙栈,方便单测把每个字节钉死 +/// (`test/devices/smartcar_protocol_test.dart`)。 +/// +/// 帧格式: +/// ``` +/// APP → 设备 0xAA | cmd(1) | len(1) | data(len) | crc(1) +/// 设备 → APP 0xBB | cmd(1) | len(1) | data(len) | crc(1) 应答 +/// 设备 → APP 0xCC | cmd(1) | len(1) | data(len) | crc(1) 主动上报 +/// ``` +/// CRC 覆盖**除自己以外的全部前导字节**,算法见 [crc8Maxim]。 +/// +/// ⚠️ 多字节字段一律**小端**。 +class SmartcarProtocol { + SmartcarProtocol._(); + + // ─────────────────────────── GATT ─────────────────────────── + + /// 控制服务 + static const String serviceUuid = '0000abc0-0000-1111-2222-123456789abc'; + + /// 写特征(WriteWithoutResponse) + static const String writeCharUuid = '0000abc1-0000-1111-2222-123456789abc'; + + /// 通知特征 + static const String notifyCharUuid = '0000abc2-0000-1111-2222-123456789abc'; + + /// 广播里用于扫描过滤的服务 UUID + static const String broadcastServiceUuid = + '0000bb01-0000-1000-8000-00805f9b34fb'; + + /// 音频上行服务 / 特征(**本期不用**,阶段 6 接 Opus 时才需要)。 + /// 注意它与控制服务是两个不同的 service,别混。 + static const String audioServiceUuid = + '0000ae00-0000-1000-8000-00805f9b34fb'; + static const String audioNotifyCharUuid = + '0000ae02-0000-1000-8000-00805f9b34fb'; + + // ─────────────────────────── 帧头 ─────────────────────────── + + static const int headerRequest = 0xAA; + static const int headerResponse = 0xBB; + static const int headerNotify = 0xCC; + + static bool isFrameHeader(int b) => + b == headerRequest || b == headerResponse || b == headerNotify; + + // ───────────────────────── 命令号 ───────────────────────── + // + // ⚠️⚠️ 下面标了 TODO(smartcar-cmd) 的几条,**协议文档与 lumi 代码是冲突的**。 + // 按 2026-09-10 的决定:一律以 lumi 的 BleConst.kt 为准(那是真机上跑着的), + // 协议文档只作参考。样机到手后逐条复核,改这里并同步更新 + // docs/Smartcar车载香薰-接入设计与落地文档.md 的 2.4 节。 + // + // ⚠️ 复核时**只扫查询类命令**。0x0C 恢复出厂 / 0x0D 启停开关 / 0x0E 遥控电机 + // 会真的改设备状态,别拿来试探。 + + /// 获取设备版本号。 + /// + /// ✅ **2026-09-11 真机实测**(样机固件):应答是 **3 字节** `02 0c 0c` → `2.12.12`, + /// **不是**协议文档写的 6 字节(两组 major/minor/patch)。同一个值在 0x15 的 + /// `[5..7]` 又出现了一次,两处对得上,所以 3 字节是可信的。 + static const int cmdGetVersion = 0x01; + + /// 获取设备 ID。✅ 真机实测应答 1 字节:`01` + static const int cmdGetProductId = 0x02; + + /// ⚠️ **含义仍未定**。协议文档里没有这条;lumi `BleConst.kt` 叫 CMD_GET_COLOR_ID, + /// 但 2026-09-11 真机实测应答是 **14 字节** + /// `03 00 00 00 3d 00 00 00 00 00 00 00 00 00`,一个"颜色 ID"不该这么长。 + /// 里面的 `3d`(61) 与当时电量 60 只差 1,也可能是另一份状态汇总。 + /// **本项目没有任何地方在用它**,先留着名字不动,等厂商给准信。 + static const int cmdGetColorId = 0x03; + + /// 获取电量。应答 data[0] = 0~100。 + /// ✅ 真机实测 `3c` = 60%,与同一时刻 0x15 的 `[4]` 完全一致。 + static const int cmdGetBattery = 0x04; + + /// 手机音乐播放状态:`0x00` 没在放 / `0x01` 在放。设备据此显示"听音乐"表情。 + /// + /// ✅ **2026-09-11 定论(改过一次)**:早先按 lumi `BleConst.kt` 的名字 + /// `CMD_CLOSE_AI_DIALOG` 理解成"关闭 AI 对话",是错的。判据是 lumi 的**运行时代码**—— + /// `BleService.swift` 的 `checkMusicPlaybackState()` 在检测到手机在放音乐时发的正是 + /// `0x05 [0x01]`、停止时发 `0x05 [0x00]`,与协议文档「0x05 手机音乐播放状态」一致。 + /// `BleConst.kt` 里那个名字是没跟上的旧命名,别再照它改回去。 + static const int cmdMusicPlayState = 0x05; + + /// 控制设备情绪表达 + static const int cmdControlEmotion = 0x06; + + /// AI 状态(APP → 设备),载荷 1 字节,取值见 [aiStateSpeakEnd] 等。 + /// + /// ✅ **2026-09-11 定论(改过一次)**:早先按 `BleConst.kt` 的 `CMD_CONTROL_MOTION` + /// 理解成"控制动作",是错的——协议文档里"遥控电机"另有其号([cmdRemoteMotor] `0x0E`), + /// 0x07 就是 AI 状态。设备靠它切换"思考中/说话中/聆听中"的表情动画。 + static const int cmdAiState = 0x07; + + /// [cmdAiState] 的取值 + static const int aiStateSpeakEnd = 0x00; + static const int aiStateThinking = 0x01; + static const int aiStateSpeakStart = 0x02; + static const int aiStateClose = 0x03; + static const int aiStateListening = 0x04; + + /// TODO(smartcar-cmd): 未经真机验证 —— 协议文档里没有这条, + /// BleConst.kt 说 CMD_CONTROL_MUSIC_BEAT,取后者。 + static const int cmdControlMusicBeat = 0x08; + + static const int cmdControlBrightness = 0x0A; + static const int cmdSetVolume = 0x0B; + + /// ⚠️ 恢复出厂设置。复核命令表时**不要**发这条。 + static const int cmdFactoryReset = 0x0C; + + /// ⚠️ 启停开关。同上,会改设备状态。 + static const int cmdAutoPowerSwitch = 0x0D; + + /// ⚠️ 遥控电机。同上。 + static const int cmdRemoteMotor = 0x0E; + + static const int cmdSetMusicExpression = 0x0F; + + /// 「蓝牙连接成功」——**APP 告诉设备"我连上了"**,不是经典蓝牙。 + /// + /// ⚠️ 2026-09-10 真机定论:GATT 连上 + 杰理认证通过 + 0x15 都能应答之后, + /// 设备屏幕**仍显示 BLE 未连接**;发了这条(`AA 10 02 03 00`)设备回 `BB 10 01 64`, + /// 屏幕才认。也就是说固件把"手机已连接"这个状态交给 APP 来宣告。 + /// 所以它是握手的一部分([SmartcarSession._handshake]),不是可选功能。 + /// + /// 载荷 2 字节:`[设备类型, 车企编号]`,设备类型 0 车机 / 1 小程序 / 2 安卓 / 3 iOS / 4 鸿蒙, + /// 车企编号 0 默认 / 1 长城。lumi 没发过这条(它不是车载产品)。 + static const int cmdAppConnected = 0x10; + + /// [cmdAppConnected] 的「设备类型」取值 + static const int appTypeAndroid = 0x02; + static const int appTypeIos = 0x03; + static const int appTypeHarmony = 0x04; + + /// [cmdAppConnected] 的「车企编号」:0 默认 + static const int carVendorDefault = 0x00; + + /// 陀螺仪开关(APP → 设备,`0xAA 0x11`)。 + /// + /// ✅ **2026-09-11 定论**:与 [notifyAiWake] 号相同但**方向不同**,协议文档里两条都在 + /// (「0x11 开启AI智能对话」是 `0xCC` 设备上报,「0x11 陀螺仪开关」是 `0xAA` APP 下发), + /// 靠帧头区分,不冲突。 + static const int cmdGyroscopeSwitch = 0x11; + + static const int cmdMusicHeadStyle = 0x12; + static const int cmdRandomExpressionInterval = 0x13; + static const int cmdMotorSwitch = 0x14; + + /// 同步参数汇总。应答 data = 14 字节,见 [SmartcarAllSettings] + static const int cmdSyncSummary = 0x15; + + /// 同步手机时间(协议文档没写,lumi 代码有,真机回 `BB 17 01 64`): + /// 载荷 `[年%100, 月, 日, 时, 分, 秒, 0x01]` + static const int cmdSyncTime = 0x17; + + /// 设备主动上报:AI 对话唤醒(`0xCC 0x11 0x01 0xAA`)。 + /// + /// ⚠️ 协议文档原话:「**不需要 app 回复,设备端直接开麦,录音发 opus 数据给到 app**」。 + /// 也就是说 App 收到它之后不用下"开麦"命令,只要准备好接 opus(见 `SmartcarAudio`), + /// 并用 [cmdAiState] 把会话状态回报给设备去驱动表情。 + static const int notifyAiWake = 0x11; + + /// 这几条设备**不回应答**,发完就完事。 + /// + /// ⚠️ 别给它们等回包——会白等到超时,把一次正常操作记成失败。 + static const Set _noResponse = { + cmdMusicPlayState, + cmdControlEmotion, + cmdAiState, + cmdControlMusicBeat, + }; + + static bool expectsResponse(int cmd) => !_noResponse.contains(cmd); + + // ─────────────────────────── CRC ─────────────────────────── + + /// CRC-8/MAXIM(Dallas/Maxim)。 + /// + /// poly=0x31,反射后 0x8C;init=0x00,xorOut=0x00,refIn/refOut=true。 + /// 与 lumi `BleProtocol.calcCrc` 逐位一致——**改这里等于改协议**, + /// 单测里有真实帧的金值守着。 + static int crc8Maxim(List data) { + var crc = 0x00; + for (final b in data) { + crc ^= b & 0xFF; + for (var i = 0; i < 8; i++) { + crc = (crc & 0x01) != 0 ? (crc >> 1) ^ 0x8C : crc >> 1; + } + } + return crc & 0xFF; + } + + // ────────────────────────── 组帧 ────────────────────────── + + /// 组一帧 APP → 设备的请求。 + static Uint8List buildPacket(int cmd, [List payload = const []]) { + final out = Uint8List(payload.length + 4); + out[0] = headerRequest; + out[1] = cmd & 0xFF; + out[2] = payload.length & 0xFF; + for (var i = 0; i < payload.length; i++) { + out[3 + i] = payload[i] & 0xFF; + } + out[out.length - 1] = crc8Maxim(out.sublist(0, out.length - 1)); + return out; + } + + /// 解一帧设备发来的数据。 + /// + /// 返回 null 表示这段不是合法帧(长度不够 / 帧头不认识 / CRC 不符)。 + /// **CRC 不符一律丢弃**,不要「先用着再说」——错帧解出来的电量、版本号 + /// 看着都像合法值,查起来极费劲(恒玄那次就是被错位解析出的垃圾值带偏的)。 + static SmartcarFrame? parseFrame(List raw) { + if (raw.length < 4) return null; + final header = raw[0] & 0xFF; + if (!isFrameHeader(header)) return null; + final cmd = raw[1] & 0xFF; + final len = raw[2] & 0xFF; + if (raw.length < len + 4) return null; + final expected = raw[len + 3] & 0xFF; + final calc = crc8Maxim(raw.sublist(0, len + 3)); + if (expected != calc) return null; + return SmartcarFrame( + header: header, + cmd: cmd, + payload: Uint8List.fromList(raw.sublist(3, 3 + len)), + ); + } + + // ──────────────────────── 广播解析 ──────────────────────── + + /// 解厂商自定义广播段(传完整 AD 结构:`[长度, 0xFF, CID低, CID高, ...]`)。 + /// + /// **真机实测布局(2026-09-10,Smartcar 样机,与 lumi iOS `parseMacFromManufacturer` + /// 的 0x0585 分支一致)**:CID 之后只有 7 字节 + /// ``` + /// [0] 长度 [1] Type=0xFF + /// [2-3] CID 0x05 0x85 → 小端 0x8505 + /// [4-9] MAC 原样、不异或、不倒序:a9 1a b3 21 e4 dd = A9:1A:B3:21:E4:DD + /// [10] 1 字节状态 TODO(smartcar-adv):未连经典蓝牙时实测为 0x00, + /// 按协议文档「0x0C = 经典蓝牙已连」处理,连上 A2DP 后要复核 + /// ``` + /// 实测原始厂商数据:`05 85 a9 1a b3 21 e4 dd 00`,解出的 MAC 与设备已知地址逐字节相同, + /// 服务端 `user_binddevice` 也按这个 MAC 确权通过。 + /// + /// ⚠️ 协议文档里那套「26 字节:BID/PID/MAC XOR 0x5A/License/设备类型」**这台固件没有发**, + /// 第一版照文档写的解析器要求 26 字节,于是永远解不出、MAC 恒为空。 + /// BID/PID/License 现在从广播里拿不到,字段保留为 0 / 空串。 + static SmartcarAdvertisement? parseManufacturerData(List? raw) { + if (raw == null || raw.length < 10) return null; + if ((raw[1] & 0xFF) != 0xFF) return null; + int le16(int off) => (raw[off] & 0xFF) | ((raw[off + 1] & 0xFF) << 8); + final mac = raw + .sublist(4, 10) + .map((b) => (b & 0xFF).toRadixString(16).padLeft(2, '0').toUpperCase()) + .join(':'); + final state = raw.length > 10 ? raw[10] & 0xFF : 0; + return SmartcarAdvertisement( + cid: le16(2), + bid: 0, + pid: 0, + classicBtConnected: state == 0x0C, + macAddress: mac, + license: '', + deviceType: 0, + ); + } +} + + +/// 一帧解好的数据 +class SmartcarFrame { + const SmartcarFrame({ + required this.header, + required this.cmd, + required this.payload, + }); + + final int header; + final int cmd; + final Uint8List payload; + + bool get isResponse => header == SmartcarProtocol.headerResponse; + bool get isNotify => header == SmartcarProtocol.headerNotify; + + /// 设备回的应答里,`len == 0` 表示这条命令**执行失败** + /// (协议就是这么设计的:成功才带数据)。 + bool get success => payload.isNotEmpty; + + @override + String toString() => + 'SmartcarFrame(0x${header.toRadixString(16)}, cmd=0x${cmd.toRadixString(16)}, ' + 'len=${payload.length})'; +} + +/// 广播里带的设备身份 +class SmartcarAdvertisement { + const SmartcarAdvertisement({ + required this.cid, + required this.bid, + required this.pid, + required this.classicBtConnected, + required this.macAddress, + required this.license, + required this.deviceType, + }); + + /// 生产商 ID(云衍 = 0x8505) + final int cid; + + /// 服务商 ID + final int bid; + + /// 产品 ID + final int pid; + + /// 经典蓝牙当前连没连上(决定要不要发起 A2DP 配对) + final bool classicBtConnected; + + /// **经典蓝牙** MAC,已还原 XOR,大写冒号分隔。 + /// 服务端确权用的就是它(`user_binddevice`)。 + final String macAddress; + + /// 授权码,20 个十六进制字符 + final String license; + + final int deviceType; + + @override + String toString() => 'SmartcarAdv(pid=0x${pid.toRadixString(16)}, ' + 'mac=$macAddress, btConnected=$classicBtConnected)'; +} + +/// `0x15 同步参数汇总` 的 14 字节应答 +class SmartcarAllSettings { + const SmartcarAllSettings({ + required this.otaAbnormal, + required this.volume, + required this.randomExprFreq, + required this.brightness, + required this.battery, + required this.firmwareVersion, + required this.powerSwitch, + required this.musicHeadStyle, + required this.musicExpression, + required this.gyroscope, + required this.motor, + }); + + final bool otaAbnormal; + final int volume; + final int randomExprFreq; + final int brightness; + + /// 0~100 + final int battery; + + /// `major.minor.patch` + final String firmwareVersion; + + final bool powerSwitch; + final int musicHeadStyle; + final bool musicExpression; + final bool gyroscope; + final bool motor; + + /// 布局见 lumi `BleProtocol.AllSettings`: + /// `[0]OTA异常 [1]音量 [2]随机表情频次 [3]亮度 [4]电量 [5..7]版本 + /// [8]启停 [9]摇头风格 [10]音乐表情 [11]陀螺仪 [12]保留 [13]电机` + /// + /// ✅ **2026-09-11 真机实测帧**:`01 00 14 0a 3c 02 0c 0c 00 02 01 01 04 01` + /// —— 偏移由两处**独立交叉验证**钉死:`[4]=0x3c`(60) 与单发 0x04 的电量一字不差、 + /// `[5..7]=02 0c 0c` 与单发 0x01 的版本一字不差。解出来: + /// 亮度 10(0-10 满格)、随机表情频次 20 秒、电量 60%、版本 2.12.12。 + /// + /// ⚠️ **`[1]` 音量实测是 0**。如果这个偏移没错,说明样机当前是静音的—— + /// A2DP 接上后 AI 回复也可能听不见。真机验声音前先确认这一项。 + static SmartcarAllSettings? parse(List d) { + if (d.length < 14) return null; + int u(int i) => d[i] & 0xFF; + return SmartcarAllSettings( + otaAbnormal: u(0) != 0, + volume: u(1), + randomExprFreq: u(2), + brightness: u(3), + battery: u(4), + firmwareVersion: '${u(5)}.${u(6)}.${u(7)}', + powerSwitch: u(8) != 0, + musicHeadStyle: u(9), + musicExpression: u(10) != 0, + gyroscope: u(11) != 0, + motor: u(13) != 0, + ); + } +} diff --git a/apps/client/lib/modules/bes_test/controllers/bes_test_controller.dart b/apps/client/lib/modules/bes_test/controllers/bes_test_controller.dart index f30a3602..a10b60d7 100644 --- a/apps/client/lib/modules/bes_test/controllers/bes_test_controller.dart +++ b/apps/client/lib/modules/bes_test/controllers/bes_test_controller.dart @@ -1,20 +1,24 @@ +import 'package:device_plugin_interface/device_plugin_interface.dart'; import 'package:get/get.dart'; import '../../../core/utils/logger.dart'; -import '../../../data/services/bes_bluetooth_service.dart'; +import '../../../devices/device_hub.dart'; +import '../../../devices/device_vendors.dart'; -/// 恒玄(BES)蓝牙连接模块验证页控制器。 -/// 用于确认原生插件(Android RFCOMM / iOS GATT 桥接)已正确编译、 -/// 注册进 GeneratedPluginRegistrant,方法通道调用不崩溃。 -/// 是否能真正配对/收发数据依赖一台恒玄芯片的实体耳机。 +/// 恒玄(BES)协议探测页控制器(设置 → 开发者)。 +/// +/// 走 [DeviceHub] 的耳机插件;发原始帧用 `bes.send`、扫命令用 `bes.probe`。 +/// 换固件要重扫命令时用它(见 CLAUDE.md「私有协议命令号会随固件版本变」)。 class BesTestController extends GetxController { static const String _tag = 'BesTestController'; - final BesBluetoothService _service = BesBluetoothService.to; + DevicePlugin get _plugin => DeviceHub.to.plugin(DeviceVendors.bes)!; + DeviceSession? get _session => DeviceHub.to.sessionOf(DeviceVendors.bes); - RxInt get deviceStatus => _service.deviceStatus; - RxString get connectedDeviceName => _service.connectedDeviceName; - RxString get connectedDeviceId => _service.connectedDeviceId; + /// 0=未连接 1=连接中 2=已连接(沿用页面原来的约定) + final RxInt deviceStatus = 0.obs; + final RxString connectedDeviceName = ''.obs; + final RxString connectedDeviceId = ''.obs; final RxBool isInitialized = false.obs; final RxBool isBusy = false.obs; @@ -22,9 +26,32 @@ class BesTestController extends GetxController { >[].obs; final RxList logLines = [].obs; + Worker? _worker; + + @override + void onInit() { + super.onInit(); + _worker = ever(DeviceHub.to.revision, (_) => _sync()); + _sync(); + } + + @override + void onClose() { + _worker?.dispose(); + super.onClose(); + } + + void _sync() { + final s = _session; + deviceStatus.value = s == null ? 0 : 2; + connectedDeviceName.value = s?.info.name ?? ''; + connectedDeviceId.value = s?.deviceId ?? ''; + } + void _log(String line) { Logger.i(_tag, line); - logLines.insert(0, '${DateTime.now().toIso8601String().substring(11, 19)} $line'); + logLines.insert( + 0, '${DateTime.now().toIso8601String().substring(11, 19)} $line'); if (logLines.length > 100) logLines.removeRange(100, logLines.length); } @@ -32,12 +59,12 @@ class BesTestController extends GetxController { if (isBusy.value) return; isBusy.value = true; try { - _log('调用 initialize() ...'); - final ok = await _service.initialize(); + _log('调用 ensureReady() ...'); + final ok = await _plugin.ensureReady(); isInitialized.value = ok; - _log('initialize() 返回: $ok'); + _log('ensureReady() 返回: $ok'); } catch (e) { - _log('initialize() 异常: $e'); + _log('ensureReady() 异常: $e'); } finally { isBusy.value = false; } @@ -47,12 +74,14 @@ class BesTestController extends GetxController { if (isBusy.value) return; isBusy.value = true; try { - _log('调用 getBondedDevices() ...'); - final devices = await _service.getBondedDevices(); - bondedDevices.value = devices; - _log('getBondedDevices() 返回 ${devices.length} 个设备'); + _log('调用 bondedDevices() ...'); + final devices = await _plugin.bondedDevices(); + bondedDevices.value = devices + .map((d) => {'address': d.id, 'name': d.name}) + .toList(); + _log('bondedDevices() 返回 ${devices.length} 个设备'); } catch (e) { - _log('getBondedDevices() 异常: $e'); + _log('bondedDevices() 异常: $e'); } finally { isBusy.value = false; } @@ -66,22 +95,43 @@ class BesTestController extends GetxController { return; } _log('连接 $name ($address) ...'); - await _service.savePairedDevice(device); - await _service.connectToDevice(address, name); + deviceStatus.value = 1; + try { + await _plugin.rememberDevice(DiscoveredDevice(id: address, name: name)); + await _plugin.connect(address, + options: DeviceConnectOptions(extra: {'name': name})); + _log('已连接'); + } catch (e) { + _log('连接失败: $e'); + } + _sync(); } Future disconnect() async { _log('调用 disconnect() ...'); - await _service.disconnectDevice(); + await _session?.disconnect(); + _sync(); } + /// 发一帧固件版本查询(AA 06),看回包 Future sendPing() async { try { _log('发送测试帧 [0xAA, 0x06] ...'); - await _service.sendData([0xAA, 0x06]); - _log('sendData() 已调用'); + await _session?.invokeFeature('bes.send', {'bytes': [0xAA, 0x06]}); + _log('已发送'); + } catch (e) { + _log('发送异常: $e'); + } + } + + /// 重扫查询类命令(0x01~0x20),0x5x/0x6x 会切工作模式,插件里已跳过 + Future probe({int from = 0x01, int to = 0x20}) async { + try { + _log('开始探测 0x${from.toRadixString(16)}~0x${to.toRadixString(16)}'); + await _session?.invokeFeature('bes.probe', {'from': from, 'to': to}); + _log('探测结束,看日志里的「恒玄设备命令事件」'); } catch (e) { - _log('sendData() 异常: $e'); + _log('探测异常: $e'); } } } diff --git a/apps/client/lib/modules/call_recording/controllers/call_recording_controller.dart b/apps/client/lib/modules/call_recording/controllers/call_recording_controller.dart index bee9d6bb..4111466c 100644 --- a/apps/client/lib/modules/call_recording/controllers/call_recording_controller.dart +++ b/apps/client/lib/modules/call_recording/controllers/call_recording_controller.dart @@ -1,6 +1,5 @@ import 'dart:async'; import 'dart:io'; -import 'dart:math' as math; import 'dart:typed_data'; import 'package:get/get.dart'; @@ -9,20 +8,17 @@ import 'package:just_audio/just_audio.dart'; import 'package:path/path.dart' as p; import 'package:path_provider/path_provider.dart'; import 'package:record/record.dart'; -import 'package:echomeet_device_sdk/echomeet_device_sdk.dart'; +import 'package:device_plugin_interface/device_plugin_interface.dart'; import '../../../core/utils/logger.dart'; import '../../../core/utils/background_session_notifier.dart'; -import '../../../data/services/ble_manager.dart'; -import '../../../data/services/holder_device_service.dart'; -import '../../../data/services/holder_recording_service.dart'; -import '/data/services/bes_bluetooth_service.dart'; -import '/data/services/bes_recording_service.dart'; -import '/data/services/bes_call_recording_service.dart'; +import '../../../core/utils/device_connection.dart'; +import '../../../data/services/device_call_recording_service.dart'; +import '../../../data/services/device_recording_service.dart'; import '../../../data/services/db/sqflite_api.dart'; import '../../../data/services/meeting/meeting_upload_service.dart'; import '../../../data/services/network/api.dart'; -import '../../meeting/utils/jieli_call_recorder.dart'; +import '../../../devices/device_hub.dart'; /// 一条已保存的通话录音文件 class CallRecordingItem { @@ -53,11 +49,12 @@ class CallRecordingItem { } /// 通话录音 / 现场录音页控制器:开始/停止录制、列出/播放/删除已保存的录音。 -/// 复用会议模块里的 [JieliCallRecorder](设备录音通路 PCM→WAV 写入器), -/// 但不接入"会议"概念(无纪要/任务/上传),是独立于会议模块的轻量录音功能。 /// -/// 通话录音与现场录音底层录制机制相同(均走设备录音通路写 WAV),仅保存 -/// 目录/标题不同,靠路由 arguments 区分: +/// 设备通路的采集/落盘/归档全部由全局服务负责(现场录音 [DeviceRecordingService]、 +/// 通话录音 [DeviceCallRecordingService]),本页只做展示与转发指令; +/// 没连设备时现场录音退回手机麦克风。**不认厂商**。 +/// +/// 通话录音与现场录音靠路由 arguments 区分: /// `Get.toNamed(Routes.recording, arguments: {'title': '通话录音', 'folder': 'call_recordings'})` /// `Get.toNamed(Routes.fieldRecording, arguments: {'title': '现场录音', 'folder': 'field_recordings'})` class CallRecordingController extends GetxController { @@ -75,10 +72,6 @@ class CallRecordingController extends GetxController { static int notificationIdFor(String tag) => tag == 'field_recordings' ? 91011 : 91012; - /// 设备录音通路(杰理 PCM → WAV)。onWavePcm 回调用来驱动波形。 - late final JieliCallRecorder _recorder = - JieliCallRecorder(onWavePcm: _onDevicePcm); - /// 手机麦克风录音通路(没连设备时用) final _micRecorder = AudioRecorder(); @@ -87,19 +80,13 @@ class CallRecordingController extends GetxController { /// 本次录制是否走的手机麦克风,停止时要按同一条通路收尾 bool _recordingWithMic = false; - /// 本次录制是否走蓝讯支架(echomeet SDK)通路 - bool _recordingWithHolder = false; - bool _recordingWithBes = false; + /// 本次录制是否走设备通路(由全局服务采集) + bool _recordingWithDevice = false; - /// 支架 PCM 帧订阅 + WAV 写入句柄 - - /// 监听设备端录音状态:支架上按开始/结束时,本页要跟着起停 - Worker? _holderStateWorker; - Worker? _holderElapsedWorker; - Worker? _holderWaveWorker; - Worker? _besStateWorker; - Worker? _besElapsedWorker; - Worker? _besWaveWorker; + /// 监听设备端录音状态:设备上按开始/结束时,本页要跟着起停 + Worker? _devStateWorker; + Worker? _devElapsedWorker; + Worker? _devWaveWorker; /// 本次录制的目标文件路径(停止后要用它归档) String? _currentPath; @@ -166,93 +153,49 @@ class CallRecordingController extends GetxController { } }); _loadRecordings(); - _bindHolderRecordingState(); - _bindBesRecordingState(); + _bindDeviceRecordingState(); } - /// 设备端按键与本页联动。 + /// 设备端按键与本页联动:现场录音页盯 [DeviceRecordingService], + /// 通话录音页盯 [DeviceCallRecordingService]。 /// - /// 支架上直接开始录音 → 本页自动进入录音态并接收 PCM; - /// 支架上结束录音 → 本页停止、落盘并归档进语音记录助手(走既有上传逻辑)。 - void _bindHolderRecordingState() { - if (!_archiveToMeeting) return; // 只有现场录音页做联动 - if (!Get.isRegistered()) return; - if (!Get.isRegistered()) return; - final rec = HolderRecordingService.to; - - // 页面进来时若设备已经在录,立刻同步为录音态(比如先在支架上按了录音再进页面) - if (rec.isRecording.value) _syncFromHolder(); - - _holderStateWorker = ever(rec.isRecording, (recording) { - if (recording) { - Logger.info('$_tag 设备端开始录音,界面同步进入录音态'); - _recordingWithHolder = true; - isRecording.value = true; - isPaused.value = false; - } else if (_recordingWithHolder) { - Logger.info('$_tag 设备端结束录音,界面同步停止(归档由全局服务完成)'); - _recordingWithHolder = false; - isRecording.value = false; - elapsedMs.value = 0; - elapsedSeconds.value = 0; - waveform.clear(); - level.value = 0; - _loadRecordings(); - } - }); - - // 时长与波形直接镜像全局服务,保证界面数值和实际落盘一致 - _holderElapsedWorker = ever(rec.elapsedMs, (ms) { - if (!_recordingWithHolder) return; - elapsedMs.value = ms; - elapsedSeconds.value = ms ~/ 1000; - }); - _holderWaveWorker = ever>(rec.waveform, (w) { - if (!_recordingWithHolder) return; - waveform.assignAll(w); - level.value = w.isEmpty ? 0 : w.last; - }); - } - - /// 耳机端按键与本页联动(与支架那套同构,只是数据源换成耳机侧服务)。 - /// - /// 现场录音页盯 [BesRecordingService](主麦 AA 59/5A), - /// 通话录音页盯 [BesCallRecordingService](通话双路 AA 51/52)。 - /// 之前这里 `if (!_archiveToMeeting) return` 直接把通话录音页排除在外, - /// 于是耳机按键停止、或通话挂断触发的兜底停止,页面都不会跟着回到未录音态。 - void _bindBesRecordingState() { + /// 设备上直接开始录音 → 本页自动进入录音态;结束 → 本页停止(归档由全局服务完成)。 + void _bindDeviceRecordingState() { + final RxBool isRec; + final RxInt elapsed; + final RxList wave; if (_archiveToMeeting) { - if (!Get.isRegistered()) return; - _bindBesSource(BesRecordingService.to.isRecording, - BesRecordingService.to.elapsedMs, BesRecordingService.to.waveform); + if (!Get.isRegistered()) return; + final r = DeviceRecordingService.to; + isRec = r.isRecording; + elapsed = r.elapsedMs; + wave = r.waveform; } else { - if (!Get.isRegistered()) return; - _bindBesSource( - BesCallRecordingService.to.isRecording, - BesCallRecordingService.to.elapsedMs, - BesCallRecordingService.to.waveform); + if (!Get.isRegistered()) return; + final r = DeviceCallRecordingService.to; + isRec = r.isRecording; + elapsed = r.elapsedMs; + wave = r.waveform; } - } - void _bindBesSource( - RxBool isRec, RxInt elapsed, RxList wave) { + // 页面进来时若设备已经在录,立刻同步为录音态 if (isRec.value) { - _recordingWithBes = true; + _recordingWithDevice = true; isRecording.value = true; elapsedMs.value = elapsed.value; elapsedSeconds.value = elapsed.value ~/ 1000; waveform.assignAll(wave); } - _besStateWorker = ever(isRec, (recording) { + _devStateWorker = ever(isRec, (recording) { if (recording) { - Logger.info('$_tag 耳机端开始录音,界面同步进入录音态'); - _recordingWithBes = true; + Logger.info('$_tag 设备端开始录音,界面同步进入录音态'); + _recordingWithDevice = true; isRecording.value = true; isPaused.value = false; - } else if (_recordingWithBes) { - Logger.info('$_tag 耳机端结束录音,界面同步停止(归档由全局服务完成)'); - _recordingWithBes = false; + } else if (_recordingWithDevice) { + Logger.info('$_tag 设备端结束录音,界面同步停止(归档由全局服务完成)'); + _recordingWithDevice = false; isRecording.value = false; elapsedMs.value = 0; elapsedSeconds.value = 0; @@ -261,51 +204,33 @@ class CallRecordingController extends GetxController { _loadRecordings(); } }); - _besElapsedWorker = ever(elapsed, (ms) { - if (!_recordingWithBes) return; + // 时长与波形直接镜像全局服务,保证界面数值和实际落盘一致 + _devElapsedWorker = ever(elapsed, (ms) { + if (!_recordingWithDevice) return; elapsedMs.value = ms; elapsedSeconds.value = ms ~/ 1000; }); - _besWaveWorker = ever>(wave, (w) { - if (!_recordingWithBes) return; + _devWaveWorker = ever>(wave, (w) { + if (!_recordingWithDevice) return; waveform.assignAll(w); level.value = w.isEmpty ? 0 : w.last; }); } - - void _syncFromHolder() { - final rec = HolderRecordingService.to; - _recordingWithHolder = true; - isRecording.value = true; - elapsedMs.value = rec.elapsedMs.value; - elapsedSeconds.value = rec.elapsedMs.value ~/ 1000; - waveform.assignAll(rec.waveform); - } - @override void onClose() { _elapsedTimer?.cancel(); _micAmpTimer?.cancel(); _sampleTimer?.cancel(); - _holderStateWorker?.dispose(); - _holderElapsedWorker?.dispose(); - _holderWaveWorker?.dispose(); - _besStateWorker?.dispose(); - _besElapsedWorker?.dispose(); - _besWaveWorker?.dispose(); + _devStateWorker?.dispose(); + _devElapsedWorker?.dispose(); + _devWaveWorker?.dispose(); _playerStateSub?.cancel(); _player.dispose(); - // 注意:支架/恒玄通路的录音分别由全局 HolderRecordingService / - // BesRecordingService(BesCallRecordingService) 负责,页面关闭不能停它们, - // 否则用户退出页面就把设备端的录音中断了。这里只收尾本页自己接管的通路 - // (手机麦克风 / 杰理设备录音,这两条没有独立于本页的全局服务)。 - if (isRecording.value && !_recordingWithHolder && !_recordingWithBes) { - if (_recordingWithMic) { - _micRecorder.stop(); - } else { - _recorder.stop(save: true); - } + // 注意:设备通路的录音由全局服务负责,页面关闭不能停它们, + // 否则用户退出页面就把设备端的录音中断了。这里只收尾手机麦克风。 + if (isRecording.value && !_recordingWithDevice && _recordingWithMic) { + _micRecorder.stop(); } _micRecorder.dispose(); super.onClose(); @@ -391,43 +316,13 @@ class CallRecordingController extends GetxController { } } - /// 蓝讯支架是否已连接(优先级最高的录音通路) - bool get _holderAvailable { - if (!Get.isRegistered()) return false; - return Get.find().isConnected; - } - - /// 恒玄耳机是否已连接(走 SPP 主麦录音,与支架那条互不影响) - bool get _besAvailable { - if (!Get.isRegistered()) return false; - return BesBluetoothService.to.isConnected; - } - - /// 杰理耳机是否已连接 - bool get _deviceAvailable { - if (!Get.isRegistered()) return false; - return Get.find().isConnected; - } + /// 有没有一台能走本页这条通路的设备:现场录音看主麦录音能力,通话录音看双路抓音 + bool get _deviceAvailable => _archiveToMeeting + ? DeviceConnection.has(DeviceCapability.micRecording) + : DeviceConnection.has(DeviceCapability.callAudioTap); // ==================== 波形 / 电平 ==================== - /// 设备通路:从写入的 stereo PCM 算 RMS 电平 - void _onDevicePcm(Uint8List pcm) { - if (pcm.lengthInBytes < 2) return; - final samples = pcm.buffer.asInt16List( - pcm.offsetInBytes, - pcm.lengthInBytes ~/ 2, - ); - var sumSq = 0.0; - for (final s in samples) { - final v = s / 32768.0; - sumSq += v * v; - } - final rms = math.sqrt(sumSq / samples.length); - // RMS 本身偏小,开根放大一下让波形更有跳动感 - _pushLevel(math.sqrt(rms).clamp(0.0, 1.0)); - } - /// 手机麦通路:轮询 record 包的振幅(dBFS,约 -60~0) Future _pollMicAmplitude() async { try { @@ -489,33 +384,23 @@ class CallRecordingController extends GetxController { final ts = DateFormat('yyyyMMdd_HHmmss').format(DateTime.now()); final path = '${dir.path}/call_$ts.wav'; - // 通路优先级:蓝讯支架 > 恒玄耳机 > 杰理耳机 > 手机麦克风 - final bool useHolder = _holderAvailable; - final bool useBes = !useHolder && _besAvailable; - final bool useMic = !useHolder && !useBes && !_deviceAvailable; + // 通路:有能用的设备就走设备(由全局服务采集,页面只展示),否则手机麦克风 + final bool useDevice = _deviceAvailable; + final bool useMic = !useDevice; final bool ok; - if (useHolder) { - // 支架通路统一由全局服务采集,页面只做展示 - ok = deviceInitiated - ? true - : await HolderRecordingService.to.start(sendCommand: true); - } else if (useBes) { - // 耳机通路同理,由全局服务采集,页面只展示。 - // 通话录音走双声道通话通路(AA 51/52),现场录音走主麦(AA 59/5A)。 + if (useDevice) { + // 通话录音走双路通话通路,现场录音走主麦 ok = deviceInitiated ? true : _archiveToMeeting - ? await BesRecordingService.to.start(sendCommand: true) - : await BesCallRecordingService.to.start(sendCommand: true); - } else if (useMic) { - ok = await _startMicRecording(path); + ? await DeviceRecordingService.to.start(sendCommand: true) + : await DeviceCallRecordingService.to.start(sendCommand: true); } else { - ok = await _recorder.start(path); + ok = await _startMicRecording(path); } if (!ok) { - // 手机麦/支架/恒玄分支自己弹过提示了,这里只补杰理分支的 - if (!useMic && !useHolder && !useBes) { + if (useDevice) { Get.snackbar('error'.tr, '录音启动失败,请检查设备连接', snackPosition: SnackPosition.TOP); } @@ -523,8 +408,7 @@ class CallRecordingController extends GetxController { } _recordingWithMic = useMic; - _recordingWithHolder = useHolder; - _recordingWithBes = useBes; + _recordingWithDevice = useDevice; _currentPath = path; isRecording.value = true; isPaused.value = false; @@ -549,7 +433,7 @@ class CallRecordingController extends GetxController { (_) => _sampleTick(), ); - // 手机麦没有 PCM 回调,只能轮询振幅;设备通路靠 onWavePcm 推 + // 手机麦没有 PCM 回调,只能轮询振幅;设备通路的波形镜像自全局服务 _micAmpTimer?.cancel(); if (useMic) { _micAmpTimer = Timer.periodic( @@ -563,22 +447,18 @@ class CallRecordingController extends GetxController { Future togglePause() async { if (!isRecording.value) return; if (isPaused.value) { - if (_recordingWithHolder) { - await Get.find().toggleRecording(); + if (_recordingWithDevice) { + await _toggleDevicePause(); } else if (_recordingWithMic) { await _micRecorder.resume(); - } else { - _recorder.resume(); } _stopwatch.start(); isPaused.value = false; } else { - if (_recordingWithHolder) { - await Get.find().toggleRecording(); + if (_recordingWithDevice) { + await _toggleDevicePause(); } else if (_recordingWithMic) { await _micRecorder.pause(); - } else { - _recorder.pause(); } _stopwatch.stop(); isPaused.value = true; @@ -586,6 +466,19 @@ class CallRecordingController extends GetxController { } } + /// 设备端暂停/继续(支架支持,耳机不支持——不支持就当没按) + Future _toggleDevicePause() async { + final s = DeviceHub.to.sessionWith(DeviceCapability.micRecording); + if (s == null) return; + try { + await s.invokeFeature('record.toggle'); + } on DeviceException catch (e) { + Logger.info('$_tag 设备不支持暂停: ${e.code}'); + } catch (e) { + Logger.error('$_tag 设备暂停失败: $e'); + } + } + /// 手机麦克风录制。采样参数与设备通路对齐(16kHz/16bit),只是单声道, /// 时长换算靠读 WAV 头的字节率,不受声道数影响。 Future _startMicRecording(String path) async { @@ -627,40 +520,22 @@ class CallRecordingController extends GetxController { bool ok; // 按开始时选定的通路收尾,中途设备断连也不会走错分支。 - // - // 恒玄这支以前**嵌在支架分支里面**,而开始录音时 - // `_recordingWithHolder=useHolder` 对恒玄通路恒为 false, - // 于是点停止时根本进不来,最后掉到杰理 `_recorder.stop()` 分支—— - // 结果就是:设备没收到 AA 52 还在上行,文件也没落盘归档, - // 只能等挂断电话时靠 BesCallRecordingService 的兜底才保存。 - // 这里提成与支架平级的独立分支。 - if (_recordingWithBes) { + if (_recordingWithDevice) { + // 设备通路的落盘与归档全部由全局服务负责,页面只负责把停止指令转过去; + // 设备端自己停的(deviceInitiated)不再回发指令,避免和设备状态打架。 try { - // 设备端自己停的(0xDA)不再回发指令,避免和设备状态打架 final bool sendCmd = !deviceInitiated; if (_archiveToMeeting) { - await BesRecordingService.to.stop(sendCommand: sendCmd); + await DeviceRecordingService.to.stop(sendCommand: sendCmd); } else { - await BesCallRecordingService.to.stop(sendCommand: sendCmd); + await DeviceCallRecordingService.to.stop(sendCommand: sendCmd); } ok = true; } catch (e) { - Logger.error('$_tag 耳机停止失败: $e'); + Logger.error('$_tag 设备停止失败: $e'); ok = false; } - } else if (_recordingWithHolder) { - // 支架通路的落盘与归档全部由全局 HolderRecordingService 负责, - // 页面只负责在用户点停止时把指令转过去;设备端自己停的则什么都不用做。 - try { - if (!deviceInitiated) { - await HolderRecordingService.to.stop(sendCommand: true); - } - ok = true; - } catch (e) { - Logger.error('$_tag 支架停止失败: $e'); - ok = false; - } - } else if (_recordingWithMic) { + } else { try { final p = await _micRecorder.stop(); ok = p != null; @@ -668,12 +543,9 @@ class CallRecordingController extends GetxController { Logger.error('$_tag 手机麦克风停止失败: $e'); ok = false; } - } else { - ok = await _recorder.stop(save: true); } _recordingWithMic = false; - _recordingWithHolder = false; - _recordingWithBes = false; + _recordingWithDevice = false; _currentPath = null; isRecording.value = false; isPaused.value = false; diff --git a/apps/client/lib/modules/call_recording/module.dart b/apps/client/lib/modules/call_recording/module.dart new file mode 100644 index 00000000..ac6e630b --- /dev/null +++ b/apps/client/lib/modules/call_recording/module.dart @@ -0,0 +1,49 @@ +import 'package:device_plugin_interface/device_plugin_interface.dart'; +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; + +import '../../routes/app_routes.dart'; +import '../home/controllers/home_controller.dart'; +import '../module_descriptor.dart'; +import 'bindings/call_recording_binding.dart'; +import 'views/call_recording_view.dart'; + +final List _pages = [ + GetPage( + name: Routes.recording, + page: () => const CallRecordingView(), + binding: CallRecordingBinding(), + transition: Transition.rightToLeft, + transitionDuration: const Duration(milliseconds: 250), + ), + GetPage( + name: Routes.fieldRecording, + page: () => const CallRecordingView(), + binding: CallRecordingBinding(), + transition: Transition.rightToLeft, + transitionDuration: const Duration(milliseconds: 250), + ), +]; + +/// 通话录音(需设备抓通话双路)与现场录音(有设备走设备,没设备用手机麦) +final List callRecordingModules = [ + AgentModuleDescriptor( + id: 'callRecording', + title: 'agentCallRecording', + subtitle: 'agentCallRecordingDesc', + icon: Icons.fiber_manual_record, + gradient: const [Color(0xFFEF5350), Color(0xFFE53935)], + requiredCapabilities: const {DeviceCapability.callAudioTap}, + onLaunch: () => HomeController.to.openCallRecording(), + pages: _pages, + ), + AgentModuleDescriptor( + id: 'fieldRecording', + title: 'agentFieldRecording', + subtitle: 'agentFieldRecordingDesc', + icon: Icons.mic, + gradient: const [Color(0xFF26C6DA), Color(0xFF00838F)], + onLaunch: () => HomeController.to.openFieldRecording(), + pages: _pages, + ), +]; diff --git a/apps/client/lib/modules/devices/views/bes_scan_view.dart b/apps/client/lib/modules/devices/views/bes_scan_view.dart index 821ce61e..23e7d3ac 100644 --- a/apps/client/lib/modules/devices/views/bes_scan_view.dart +++ b/apps/client/lib/modules/devices/views/bes_scan_view.dart @@ -1,29 +1,25 @@ import 'dart:io'; +import 'package:device_plugin_interface/device_plugin_interface.dart'; import 'package:flutter/material.dart'; import 'package:flutter_screenutil/flutter_screenutil.dart'; import 'package:get/get.dart'; import '../../../core/theme/app_palette.dart'; import '../../../data/services/background_launch_consent.dart'; -import '../../../data/services/bes_bluetooth_service.dart'; +import '../../../devices/device_hub.dart'; +import '../../../devices/device_vendors.dart'; import '../../../routes/app_routes.dart'; import '../../main_tab/controllers/main_tab_controller.dart'; -/// 恒玄(BES)方案耳机的连接页。 +/// AI 耳机(恒玄 Echo-one)的连接页——走 [DeviceHub] 里的耳机插件。 /// -/// 与蓝讯支架([HolderScanView],BLE 广播扫描)是**完全独立的一条通路**, -/// 而且两端的底层实现也不一样(native 插件已按平台各写一套): -/// - Android:经典蓝牙 RFCOMM/SPP,`getBondedDevices` 给的是系统**已配对**列表; -/// - iOS:CoreBluetooth 走厂商私有 service 的 GATT 桥接(BR/EDR 承载), -/// `getBondedDevices` 底层是 `retrieveConnectedPeripherals`, -/// 给的是系统里**当前已连接**且带该 service 的耳机,address 是 CBPeripheral 的 -/// UUID 而不是 MAC。 +/// 两端的用户前提不同(插件按平台各写一套): +/// - Android:经典蓝牙 RFCOMM/SPP,`bondedDevices` 给的是系统**已配对**列表; +/// - iOS:CoreBluetooth GATT 桥接,给的是系统里**当前已连接**且带该 service 的耳机, +/// id 是 CBPeripheral 的 UUID 而不是 MAC。 /// -/// 所以两端的用户前提不同:Android 只要配过对就能连;iOS 必须先在系统蓝牙里 -/// 把耳机连上(A2DP/HFP),App 才看得到它。页面文案按平台分开。 -/// -/// 三条链路各自持有自己的 service、各自的连接态和持久化,互不影响。 +/// 所以 Android 只要配过对就能连;iOS 必须先在系统蓝牙里把耳机连上。页面文案按平台分开。 class BesScanView extends StatefulWidget { const BesScanView({super.key}); @@ -32,64 +28,51 @@ class BesScanView extends StatefulWidget { } class _BesScanViewState extends State { - /// 展示用的设备名(提示文案、连接成功 snackbar 的兜底)。当前固件的广播名。 - /// - /// ⚠️ 大小写按设备端实际广播的写;**匹配**不要直接拿它比,走 - /// [_acceptedNames]([_findTargets] 里统一转大写)——上一版常量恰好是全大写的 - /// `DEEPVOICE`,跟大写后的设备名比才碰巧成立,改成混合大小写就会全部匹配不到。 + /// 展示用的设备名。当前固件的广播名。 static const String _targetName = 'Echo-one'; - /// 认哪些广播名。恒玄这条链路只对接自家耳机,其它已配对的经典蓝牙设备 - /// (车机、音箱、别家耳机)都不该出现在这个页面里,直接按名字写死过滤。 - /// - /// 之所以是列表而不是单个值:设备端 2026-08-31 把广播名从 `DEEPVOICE` 改成 - /// `Echo-one`,但 Android 的 `getBondedDevices` 给的是**系统缓存的配对名**, - /// 改名前配过对的手机在重新配对之前仍然报旧名,只认新名会让这些机器扫不到。 - /// 旧名可在存量设备全部换代后删除。 + /// 认哪些广播名:设备端 2026-08-31 把广播名从 `DEEPVOICE` 改成 `Echo-one`, + /// 但 Android 的配对列表给的是系统缓存的配对名,改名前配过对的手机在重新配对 + /// 之前仍然报旧名。旧名可在存量设备全部换代后删除。 static const List _acceptedNames = ['Echo-one', 'DEEPVOICE']; - late final BesBluetoothService _service; - final RxList> _devices = >[].obs; + DevicePlugin get _plugin => DeviceHub.to.plugin(DeviceVendors.bes)!; + final RxList _devices = [].obs; final RxBool _loading = true.obs; bool _connecting = false; + Worker? _sessionsWorker; + bool _finished = false; @override void initState() { super.initState(); - _service = Get.isRegistered() - ? Get.find() - : Get.put(BesBluetoothService()); // 主动连接失败后耳机才连上手机的场景:原生靠 ACL 广播自动补 RFCOMM, - // 状态一旦变成「已连接」,这页也要把绑定存下来并跳回设备页。 - // - // ⚠️ `_connecting` 期间不能开火:_connect 正在按候选列表逐个试, - // 第一个候选一连上就 _finish() 会把用户点的那台挤掉(存盘存成先连上的 - // 那一台并直接跳走)。候选循环自己会收尾—— - // · 回连发生在循环期间 → _waitConnected 看到 2 就 break,由 _connect 走 _finish; - // · 回连发生在循环之后 → finally 已把 _connecting 置回 false,这里照常开火。 - // 两条路都有人收尾,不存在空档。 - _statusWorker = ever(_service.deviceStatus, (v) { - if (v == 2 && mounted && !_finished && !_connecting) _finish(); + // 一旦上线,这页也要把绑定存下来并跳回设备页。 + // `_connecting` 期间不开火:候选循环自己会收尾。 + _sessionsWorker = ever>(DeviceHub.to.sessions, (_) { + if (_liveSession != null && mounted && !_finished && !_connecting) { + _finish(); + } }); WidgetsBinding.instance.addPostFrameCallback((_) => _load()); } - Worker? _statusWorker; - bool _finished = false; + DeviceSession? get _liveSession => DeviceHub.to.sessionOf(DeviceVendors.bes); @override void dispose() { - _statusWorker?.dispose(); + _sessionsWorker?.dispose(); super.dispose(); } Future _finish() async { _finished = true; - final id = _service.connectedDeviceId.value; - final name = _service.connectedDeviceName.value; - if (id.isNotEmpty) { - await _service.savePairedDevice({'address': id, 'name': name}); + final s = _liveSession; + if (s != null && s.deviceId.isNotEmpty) { + await _plugin.rememberDevice( + DiscoveredDevice(id: s.deviceId, name: s.info.name)); } + final name = s?.info.name ?? ''; Get.snackbar('连接成功', '${name.isEmpty ? _targetName : name} 已连接', snackPosition: SnackPosition.TOP, duration: const Duration(seconds: 2)); @@ -104,17 +87,14 @@ class _BesScanViewState extends State { Future _load() async { _loading.value = true; try { - await _service.initialize(); - // ⚠️ 进页面时已经连上(冷启动自动回连 / 上个页面连过)**不再直接跳回主页**。 - // 这页要展示已配对的耳机并把「当前连着的那台」高亮出来,直接跳走的话 - // 用户根本看不到这个区分。想回主页点那一行即可(走 _finish)。 + await _plugin.ensureReady(); + // 进页面时已经连上**不再直接跳回主页**:这页要展示已配对的耳机并把 + // 「当前连着的那台」高亮出来。想回主页点那一行即可(走 _finish)。 final target = await _findTargets(); _devices.assignAll(target); - // 只有一台目标耳机时不用再让用户点一次,直接连。 - // 但用户拒绝过后台自动连接就不再自动发起——否则每次进页面都要被拦一次, - // 变成「重复启动 + 重复弹窗」。这时列表照常展示,等用户自己点。 + // 只有一台目标耳机时直接连;但用户拒绝过后台自动连接就不再自动发起。 if (target.length == 1 && - _service.deviceStatus.value != 2 && + _liveSession == null && !BackgroundLaunchConsent.declined) { await _connect(target.first, userInitiated: false); } @@ -126,18 +106,14 @@ class _BesScanViewState extends State { } } - /// 取目标耳机列表。 - /// - /// iOS 上 `initialize()` 只是把 CBCentralManager 建出来,state 变成 .poweredOn - /// 要等系统回调;在那之前 `getBondedDevices` 底层的 - /// `retrieveConnectedPeripherals` 会直接返回空。所以 iOS 要轮询几次再判定 - /// 「没找到」,否则刚进页面必定误报。Android 是同步查系统配对列表,一次就够。 - Future>> _findTargets() async { + /// 取目标耳机列表。iOS 上 CBCentralManager 变成 .poweredOn 要等系统回调, + /// 在那之前列表是空的,所以要轮询几次再判定「没找到」。 + Future> _findTargets() async { final int attempts = Platform.isIOS ? 10 : 1; for (var i = 0; i < attempts; i++) { - final list = await _service.getBondedDevices(); + final list = await _plugin.bondedDevices(); final target = list.where((d) { - final n = (d['name'] as String? ?? '').toUpperCase(); + final n = d.name.toUpperCase(); return _acceptedNames.any((t) => n.contains(t.toUpperCase())); }).toList(); if (target.isNotEmpty || i == attempts - 1) return target; @@ -147,15 +123,10 @@ class _BesScanViewState extends State { return const []; } - Future _connect(Map d, - {bool userInitiated = true}) async { + Future _connect(DiscoveredDevice d, {bool userInitiated = true}) async { if (_connecting) return; - final id = (d['address'] ?? '') as String; - final name = (d['name'] ?? '') as String; - if (id.isEmpty) return; - // 绑定后耳机开机会自动回连(Android 靠 ACL 广播、iOS 靠 .peerConnected), - // 属于后台自动连接,先取得用户明示同意。 - // 只有用户亲手点连接才允许弹窗;自动路径拿不到授权就安静地不连。 + if (d.id.isEmpty) return; + // 绑定后耳机开机会自动回连,属于后台自动连接,先取得用户明示同意。 if (!await BackgroundLaunchConsent.ensure(userInitiated: userInitiated)) { if (userInitiated) { Get.snackbar( @@ -170,34 +141,30 @@ class _BesScanViewState extends State { setState(() => _connecting = true); try { // 候选 = 用户点的那台(永远第一)+ 列表里的同名设备。 - // - // 身份由连上之后的 MAC 校验来定(见 BesDeviceAuth 与 _listen 的回填), - // 不由拨哪个地址来定。所以连接阶段只要「拨通一条链路」即可, - // 不需要在地址层面追求精确——同名的另一行既可能是同一只耳机的另一个 - // BR/EDR 地址(Android TWS 双地址:实测配对列表是 …C1:C4,SPP 走 …BE:F8), + // 身份由连上之后的 MAC 校验来定,连接阶段只要「拨通一条链路」即可—— + // 同名的另一行既可能是同一只耳机的另一个 BR/EDR 地址(Android TWS 双地址), // 也可能是另一台同款耳机,两种都值得试。 - // - // ⚠️ **不要把 currentConnectedDevice() / lastDeviceAddress 加回来。** - // 插件里没有任何扫描/发现路径,地址的唯一来源是 adapter.bondedDevices; - // 而这两个值都只是把 App 自己拨过的地址回吐(lastDeviceId 只在 connect() - // 里赋值,lastDeviceAddress 来自 savePairedDevice 存的 connectedDeviceId)。 - // 它们永远产生不了一个「新地址」,唯一的实际效果是把**上一台耳机**塞进 - // 候选并排在用户的选择前面——换耳机时就会连上旧的那台,或者在旧耳机 - // 不在身边时白等一轮超时。 - final tappedName = name.trim().toUpperCase(); - final candidates = [id]; + final tappedName = d.name.trim().toUpperCase(); + final candidates = [d.id]; for (final row in _devices) { - final a = (row['address'] as String? ?? ''); - final n = (row['name'] as String? ?? '').trim().toUpperCase(); - if (a.isEmpty || n != tappedName) continue; - if (candidates.any((c) => c.toUpperCase() == a.toUpperCase())) continue; - candidates.add(a); + final n = row.name.trim().toUpperCase(); + if (row.id.isEmpty || n != tappedName) continue; + if (candidates.any((c) => c.toUpperCase() == row.id.toUpperCase())) { + continue; + } + candidates.add(row.id); } bool ok = false; for (final addr in candidates) { - await _service.connectToDevice(addr, name); - ok = await _waitConnected(); + try { + await _plugin.connect(addr, + options: DeviceConnectOptions(extra: {'name': d.name})); + ok = true; + } on DeviceException catch (e) { + debugPrint('bes connect $addr failed: ${e.code}'); + ok = false; + } if (ok || !mounted) break; } if (!mounted) return; @@ -219,28 +186,6 @@ class _BesScanViewState extends State { } } - /// 等状态流推到「已连接」,超时返回当前状态。 - /// - /// 进过「连接中」又掉回 0 就提前判失败:原生 `connectBlocking` 失败时会 - /// `publishBluetoothStatus(0)`,不必每个候选都干等满超时——遍历同名设备的 - /// 总耗时会从「N × timeout」降到接近真实失败时间。 - /// - /// ⚠️ 仍要保留超时兜底:原生若把这次 connect 丢弃了(见 BluetoothManager.kt - /// 的 isConnecting 门禁),状态会停在 connectToDevice 自己写的 1,等不到 0。 - Future _waitConnected( - {Duration timeout = const Duration(seconds: 8)}) async { - final end = DateTime.now().add(timeout); - bool sawConnecting = false; - while (DateTime.now().isBefore(end)) { - final s = _service.deviceStatus.value; - if (s == 2) return true; - if (s == 1) sawConnecting = true; - if (sawConnecting && s == 0) return false; - await Future.delayed(const Duration(milliseconds: 300)); - } - return _service.deviceStatus.value == 2; - } - @override Widget build(BuildContext context) { final bool isDark = Theme.of(context).brightness == Brightness.dark; @@ -259,8 +204,8 @@ class _BesScanViewState extends State { child: Column( mainAxisSize: MainAxisSize.min, children: [ - CircularProgressIndicator(), - SizedBox(height: 16), + const CircularProgressIndicator(), + const SizedBox(height: 16), Text('besConnecting'.tr), ], ), @@ -270,12 +215,10 @@ class _BesScanViewState extends State { return const Center(child: CircularProgressIndicator()); } if (_devices.isEmpty) return _empty(isDark); - // ⚠️ 连接态必须在**这里**读出来再传下去:ListView 的 itemBuilder 是 - // 延迟调用的,在 _tile 里读 Rx 不会被这个 Obx 收集到, - // 耳机连上/断开时列表不会重绘。 - final live = _service.deviceStatus.value == 2; - final connId = _service.connectedDeviceId.value; - final connName = _service.connectedDeviceName.value; + // 连接态必须在**这里**读出来再传下去:ListView 的 itemBuilder 是 + // 延迟调用的,在 _tile 里读 Rx 不会被这个 Obx 收集到。 + DeviceHub.to.sessions.length; + final live = _liveSession; return ListView.separated( padding: EdgeInsets.fromLTRB(16.w, 16.h, 16.w, 24.h), itemCount: _devices.length, @@ -283,8 +226,7 @@ class _BesScanViewState extends State { itemBuilder: (context, i) => _tile( _devices[i], isDark, - connected: - live && _isLiveDevice(_devices[i], connId, connName), + connected: live != null && _isLiveDevice(_devices[i], live), ), ); }), @@ -307,7 +249,6 @@ class _BesScanViewState extends State { SizedBox(height: 6.h), Text( Platform.isIOS - // iOS 只能看到「系统里已经连上的」耳机,配过对但没连上是看不到的 ? '请先在系统「设置 → 蓝牙」里连接 $_targetName(显示"已连接"),再回到这里' : '$_targetName 走经典蓝牙,请先在系统「设置 → 蓝牙」里完成配对,再回到这里连接', textAlign: TextAlign.center, @@ -320,35 +261,24 @@ class _BesScanViewState extends State { ); } - /// 这一行是不是当前正连着的那台。 - /// - /// ⚠️ 不能只比地址:Android 上耳机是 TWS 双地址,系统配对列表给的地址不一定 - /// 是真正跑 SPP 的那个(实测配对列表是 …C1:C4,SPP 走 …BE:F8)。只比地址会让 - /// 明明连着的那台显示成「未连接」。地址对不上时退回比设备名——本项目一次只 - /// 连一台耳机,重名的风险可以忽略。 - bool _isLiveDevice( - Map d, String connId, String connName) { - final addr = (d['address'] as String? ?? '').toUpperCase(); - if (addr.isNotEmpty && addr == connId.toUpperCase()) return true; - final n = (d['name'] as String? ?? '').trim().toUpperCase(); - final cn = connName.trim().toUpperCase(); + /// 这一行是不是当前正连着的那台。不能只比地址:Android 上耳机是 TWS 双地址, + /// 系统配对列表给的地址不一定是真正跑 SPP 的那个。地址对不上时退回比设备名。 + bool _isLiveDevice(DiscoveredDevice d, DeviceSession live) { + final addr = d.id.toUpperCase(); + final localId = (live.info.metadata['localId'] as String?) ?? live.deviceId; + if (addr.isNotEmpty && addr == localId.toUpperCase()) return true; + final n = d.name.trim().toUpperCase(); + final cn = live.info.name.trim().toUpperCase(); return n.isNotEmpty && n == cn; } - /// 一行设备。[connected] 决定这一行是高亮态还是置灰态。 - /// - /// 置灰的是「配过对但此刻没连上」的存量设备——它们仍然可点(点了就去连), - /// 只是视觉权重压下去,让用户一眼看出哪台是活的。 - Widget _tile(Map d, bool isDark, {bool connected = false}) { - final name = (d['name'] ?? 'bleUnknownDevice'.tr) as String; - final addr = (d['address'] ?? '') as String; - + Widget _tile(DiscoveredDevice d, bool isDark, {bool connected = false}) { + final name = d.name.isEmpty ? 'bleUnknownDevice'.tr : d.name; const Color accent = AppPalette.accent; final Color idle = AppPalette.textHint(isDark); return GestureDetector( behavior: HitTestBehavior.opaque, - // 已经连着的那台点了就是「用这台」——存盘并回主页,跟连接成功后同一条路径 onTap: () => connected ? _finish() : _connect(d), child: Container( padding: EdgeInsets.all(14.w), @@ -380,8 +310,7 @@ class _BesScanViewState extends State { ? AppPalette.textPrimary(isDark) : AppPalette.textSecondary(isDark))), SizedBox(height: 2.h), - Text(addr, - style: TextStyle(fontSize: 11.sp, color: idle)), + Text(d.id, style: TextStyle(fontSize: 11.sp, color: idle)), ], ), ), diff --git a/apps/client/lib/modules/devices/views/device_category_view.dart b/apps/client/lib/modules/devices/views/device_category_view.dart index eab25e28..3f92b299 100644 --- a/apps/client/lib/modules/devices/views/device_category_view.dart +++ b/apps/client/lib/modules/devices/views/device_category_view.dart @@ -2,14 +2,15 @@ import 'package:flutter/material.dart'; import 'package:flutter_screenutil/flutter_screenutil.dart'; import 'package:get/get.dart'; +import '../../../core/theme/app_palette.dart'; +import '../../../core/utils/toast_throttle.dart'; import '../../../data/models/device_category.dart'; import '../../../routes/app_routes.dart'; -import '../../../core/theme/app_palette.dart'; /// 「添加设备」第一步:选择要绑定的产品品类。 /// /// 以产品图为主的两列网格——每格一张实拍图 + 一行品类名,不放描述文字。 -/// 选完带着 categoryId 进扫描页,只扫该品类产品的 scanuuid。 +/// 选完带着 categoryId 进扫描页,用来筛扫描结果的展示。 class DeviceCategoryView extends StatelessWidget { const DeviceCategoryView({Key? key}) : super(key: key); @@ -53,16 +54,42 @@ class DeviceCategoryView extends StatelessWidget { ); } + /// 蓝牙链路尚未接入的品类。点它们只提示,不进任何扫描页。 + /// + /// 目前是空的——耳机、支架、车载香薰三条链路都已接上。 + /// 以后加新品类时,链路没做完就先放进来,别让用户点进一个扫不出东西的页面。 + static const Set _scanNotReady = {}; + void _openCategory(DeviceCategory category) { + if (_scanNotReady.contains(category.id)) { + ToastThrottle.show( + 'device.scan_not_ready.${category.id}', + title: category.title.tr, + message: 'featureInDev'.tr, + window: ToastThrottle.gateWindow, + ); + return; + } switch (category.id) { - case DeviceCategories.phoneHolder: - Get.toNamed(Routes.holderScan); - break; case DeviceCategories.earphone: Get.toNamed(Routes.besScan); break; + case DeviceCategories.carAroma: + // Smartcar 车载香薰,走 lib/devices/smartcar 那条 BLE 链路(C2 私有协议)。 + Get.toNamed(Routes.smartcarScan); + break; + case DeviceCategories.phoneHolder: + // EaiRec 录音支架,走 recorder_holder 那条 BLE 链路。 + Get.toNamed(Routes.holderScan); + break; default: - Get.toNamed(Routes.devices, arguments: {'categoryId': category.id}); + // 还没接蓝牙链路的品类,与 _scanNotReady 同一处理 + ToastThrottle.show( + 'device.scan_not_ready.${category.id}', + title: category.title.tr, + message: 'featureInDev'.tr, + window: ToastThrottle.gateWindow, + ); } } @@ -73,8 +100,8 @@ class DeviceCategoryView extends StatelessWidget { return GestureDetector( behavior: HitTestBehavior.opaque, - // 每个品类走各自独立的蓝牙链路: - // 手机支架 = 蓝讯 BLE(echomeet SDK)、耳机 = 恒玄经典蓝牙、其余 = 杰理 BLE + // 每个品类走各自的连接页(背后是 DeviceHub 里对应的厂商插件)。 + // Smartcar 的链路还没接,见 [_scanNotReady]。 onTap: () => _openCategory(category), child: Container( decoration: BoxDecoration( diff --git a/apps/client/lib/modules/devices/views/holder_scan_view.dart b/apps/client/lib/modules/devices/views/holder_scan_view.dart index e01d291d..b9ece800 100644 --- a/apps/client/lib/modules/devices/views/holder_scan_view.dart +++ b/apps/client/lib/modules/devices/views/holder_scan_view.dart @@ -1,18 +1,18 @@ -import 'package:echomeet_device_sdk/echomeet_device_sdk.dart'; +import 'dart:async'; + +import 'package:device_plugin_interface/device_plugin_interface.dart'; import 'package:flutter/material.dart'; import 'package:flutter_screenutil/flutter_screenutil.dart'; import 'package:get/get.dart'; import '../../../core/theme/app_palette.dart'; import '../../../data/services/background_launch_consent.dart'; -import '../../../data/services/holder_device_service.dart'; +import '../../../devices/device_hub.dart'; +import '../../../devices/device_vendors.dart'; import '../../../routes/app_routes.dart'; import '../../main_tab/controllers/main_tab_controller.dart'; -/// 蓝讯方案设备(AI手机支架)的扫描 / 连接页。 -/// -/// 走的是从 echomeet 拉过来的 [EchoMeetDeviceSdk],与杰理耳机的扫描页 -/// (DevicesView)是两条独立链路,互不干扰。 +/// EaiRec 录音支架(AI手机支架)的扫描 / 连接页——走 [DeviceHub] 里的支架插件。 class HolderScanView extends StatefulWidget { const HolderScanView({super.key}); @@ -21,27 +21,60 @@ class HolderScanView extends StatefulWidget { } class _HolderScanViewState extends State { - late final HolderDeviceService _service; + DevicePlugin get _plugin => DeviceHub.to.plugin(DeviceVendors.holder)!; + final RxList _scanned = [].obs; + final RxBool _isScanning = false.obs; + StreamSubscription? _sub; + bool _connecting = false; @override void initState() { super.initState(); - _service = Get.isRegistered() - ? Get.find() - : Get.put(HolderDeviceService()); + _sub = _plugin.eventStream.listen((e) { + switch (e.type) { + case DevicePluginEventType.scanStarted: + _isScanning.value = true; + break; + case DevicePluginEventType.scanStopped: + _isScanning.value = false; + break; + case DevicePluginEventType.deviceDiscovered: + final d = e.discovered; + if (d == null || d.id.isEmpty) break; + // 按地址去重累积:原生是「每扫到一个就单独上报一次」 + final i = _scanned.indexWhere((x) => x.id == d.id); + if (i >= 0) { + _scanned[i] = d; + } else { + _scanned.add(d); + } + break; + default: + break; + } + }); // 进页面自动开扫 - WidgetsBinding.instance.addPostFrameCallback((_) => _service.startScan()); + WidgetsBinding.instance.addPostFrameCallback((_) => _startScan()); + } + + Future _startScan() async { + _scanned.clear(); + if (!await _plugin.ensureReady()) { + Get.snackbar('tip'.tr, 'holderPairHint'.tr, + snackPosition: SnackPosition.TOP); + return; + } + await _plugin.startScan(); } @override void dispose() { - _service.stopScan(); + _sub?.cancel(); + _plugin.stopScan(); super.dispose(); } - bool _connecting = false; - - Future _connect(EchoMeetScannedDevice d) async { + Future _connect(DiscoveredDevice d) async { if (_connecting) return; // Android 上 SDK 的 connect() 本身就是向系统注册后台扫连(设备靠近就唤醒 // 本应用),属于自启动行为,必须先向用户明示并取得同意。 @@ -55,11 +88,15 @@ class _HolderScanViewState extends State { return; } setState(() => _connecting = true); - // 用户主动点了连接:允许再申请一次系统蓝牙权限 - _service.resetPermissionDenied(); - // connect() 只是注册后台扫连,真正连上要等 connectionState - final registered = await _service.connect(d); - final ok = registered && await _service.waitConnected(); + bool ok = false; + try { + await _plugin.connect(d.id); + ok = true; + } on DeviceException catch (e) { + debugPrint('holder connect failed: ${e.code} ${e.message}'); + } catch (e) { + debugPrint('holder connect failed: $e'); + } if (!mounted) return; setState(() => _connecting = false); if (ok) { @@ -67,7 +104,6 @@ class _HolderScanViewState extends State { snackPosition: SnackPosition.TOP, duration: const Duration(seconds: 2)); Get.offAllNamed(Routes.home); - // 回到主框架后切到「设备」页,展示刚连上的设备 WidgetsBinding.instance.addPostFrameCallback((_) { if (Get.isRegistered()) { MainTabController.to.changeTab(MainTabController.tabDevice); @@ -89,7 +125,7 @@ class _HolderScanViewState extends State { elevation: 0, title: Text('holderAddTitle'.tr), actions: [ - Obx(() => _service.isScanning.value + Obx(() => _isScanning.value ? Padding( padding: EdgeInsets.only(right: 16.w), child: Center( @@ -102,7 +138,7 @@ class _HolderScanViewState extends State { ) : IconButton( icon: const Icon(Icons.refresh), - onPressed: _service.startScan, + onPressed: _startScan, )), ], ), @@ -111,93 +147,96 @@ class _HolderScanViewState extends State { child: Column( mainAxisSize: MainAxisSize.min, children: [ - CircularProgressIndicator(), - SizedBox(height: 16), + const CircularProgressIndicator(), + const SizedBox(height: 16), Text('holderConnecting'.tr), ], ), ) : Obx(() { - final list = _service.scanned; - if (list.isEmpty) { - return Center( - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - Icon(Icons.bluetooth_searching, - size: 56.sp, color: AppPalette.brand300), - SizedBox(height: 12.h), - Text( - _service.isScanning.value ? 'holderSearching'.tr : 'holderNotFound'.tr, - style: TextStyle( - fontSize: 15.sp, color: AppPalette.textSecondary(isDark)), - ), - SizedBox(height: 6.h), - Text( - 'holderPairHint'.tr, - style: TextStyle( - fontSize: 12.sp, color: AppPalette.textHint(isDark)), - ), - ], - ), - ); - } - return ListView.separated( - padding: EdgeInsets.symmetric(horizontal: 16.w, vertical: 16.h), - itemCount: list.length, - separatorBuilder: (_, __) => SizedBox(height: 12.h), - itemBuilder: (context, i) { - final d = list[i]; - return GestureDetector( - behavior: HitTestBehavior.opaque, - onTap: () => _connect(d), - child: Container( - padding: EdgeInsets.all(14.w), - decoration: BoxDecoration( - color: AppPalette.surface(isDark), - borderRadius: BorderRadius.circular(14.r), - border: Border.all(color: AppPalette.border(isDark)), - ), - child: Row( - children: [ - Icon(Icons.stay_current_portrait, - size: 26.sp, color: AppPalette.accent), - SizedBox(width: 12.w), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, + final list = _scanned; + if (list.isEmpty) { + return Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.bluetooth_searching, + size: 56.sp, color: AppPalette.brand300), + SizedBox(height: 12.h), + Text( + _isScanning.value + ? 'holderSearching'.tr + : 'holderNotFound'.tr, + style: TextStyle( + fontSize: 15.sp, + color: AppPalette.textSecondary(isDark)), + ), + SizedBox(height: 6.h), + Text( + 'holderPairHint'.tr, + style: TextStyle( + fontSize: 12.sp, color: AppPalette.textHint(isDark)), + ), + ], + ), + ); + } + return ListView.separated( + padding: EdgeInsets.symmetric(horizontal: 16.w, vertical: 16.h), + itemCount: list.length, + separatorBuilder: (_, __) => SizedBox(height: 12.h), + itemBuilder: (context, i) { + final d = list[i]; + return GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: () => _connect(d), + child: Container( + padding: EdgeInsets.all(14.w), + decoration: BoxDecoration( + color: AppPalette.surface(isDark), + borderRadius: BorderRadius.circular(14.r), + border: Border.all(color: AppPalette.border(isDark)), + ), + child: Row( children: [ - Text( - d.displayName, - style: TextStyle( - fontSize: 15.sp, - fontWeight: FontWeight.w600, - color: AppPalette.textPrimary(isDark), - ), - ), - SizedBox(height: 2.h), - Text( - d.address, - style: TextStyle( - fontSize: 11.sp, - color: AppPalette.textHint(isDark), + Icon(Icons.stay_current_portrait, + size: 26.sp, color: AppPalette.accent), + SizedBox(width: 12.w), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + d.name.isEmpty ? 'bleUnknownDevice'.tr : d.name, + style: TextStyle( + fontSize: 15.sp, + fontWeight: FontWeight.w600, + color: AppPalette.textPrimary(isDark), + ), + ), + SizedBox(height: 2.h), + Text( + d.id, + style: TextStyle( + fontSize: 11.sp, + color: AppPalette.textHint(isDark), + ), + ), + ], ), ), + Text('连接', + style: TextStyle( + fontSize: 13.sp, + fontWeight: FontWeight.w600, + color: AppPalette.accent)), ], ), ), - Text('连接', - style: TextStyle( - fontSize: 13.sp, - fontWeight: FontWeight.w600, - color: AppPalette.accent)), - ], - ), - ), - ); - }, - ); - }), + ); + }, + ); + }), ); } } diff --git a/apps/client/lib/modules/devices/views/smartcar_scan_view.dart b/apps/client/lib/modules/devices/views/smartcar_scan_view.dart new file mode 100644 index 00000000..67d152cd --- /dev/null +++ b/apps/client/lib/modules/devices/views/smartcar_scan_view.dart @@ -0,0 +1,398 @@ +import 'dart:async'; + +import 'package:device_plugin_interface/device_plugin_interface.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_screenutil/flutter_screenutil.dart'; +import 'package:get/get.dart'; + +import '../../../core/theme/app_palette.dart'; +import '../../../devices/device_hub.dart'; +import '../../../devices/device_vendors.dart'; +import '../../../routes/app_routes.dart'; +import '../../main_tab/controllers/main_tab_controller.dart'; + +/// Smartcar(AI车载香薰)的扫描 / 连接页——走 [DeviceHub] 里的 Smartcar 插件。 +/// +/// 与支架那页结构一致,两点不同: +/// 1. **不需要后台自启动授权**:Smartcar 的连接是纯 GATT,没有向系统注册 +/// 「设备靠近就唤醒本应用」那种后台扫连,不构成自启动行为。 +/// 2. 卡片上多显示一行 MAC —— 它来自 BLE 广播,是服务端确权的依据, +/// 连不上时看一眼就知道是不是扫到了别的设备。 +class SmartcarScanView extends StatefulWidget { + const SmartcarScanView({super.key}); + + @override + State createState() => _SmartcarScanViewState(); +} + +class _SmartcarScanViewState extends State { + DevicePlugin get _plugin => DeviceHub.to.plugin(DeviceVendors.smartcar)!; + + final RxList _scanned = [].obs; + final RxBool _isScanning = false.obs; + + /// 附近**所有** BLE 设备(含没命中判定的),按 id 去重。 + /// 只在一台都没扫到时给人看——「扫不到」这个故障, + /// 不摊开设备到底播了什么就只能靠反复出包猜。 + final RxMap> _nearby = + >{}.obs; + final RxBool _showDiag = false.obs; + + /// 扫描起不来时的原因。**必须显示出来**:第一版失败是静默的, + /// 界面只会一直转圈,分不清「没扫到设备」和「压根没扫起来」。 + final RxnString _scanError = RxnString(); + StreamSubscription? _sub; + bool _connecting = false; + + @override + void initState() { + super.initState(); + _sub = _plugin.eventStream.listen((e) { + switch (e.type) { + case DevicePluginEventType.scanStarted: + _isScanning.value = true; + break; + case DevicePluginEventType.scanStopped: + _isScanning.value = false; + break; + case DevicePluginEventType.deviceDiscovered: + final d = e.discovered; + if (d == null || d.id.isEmpty) break; + // 按 id 去重累积:BLE 扫描会对同一台设备反复上报 + final i = _scanned.indexWhere((x) => x.id == d.id); + if (i >= 0) { + _scanned[i] = d; + } else { + _scanned.add(d); + } + break; + case DevicePluginEventType.customEvent: + // 厂商插件转发的扫描诊断(业务层不能直接 import 厂商目录) + if (e.customKey == 'smartcar.scan_diagnostic') { + final d = e.customPayload; + if (d is Map) { + final id = '${d['id']}'; + if (id.isNotEmpty) _nearby[id] = d; + } + } + break; + case DevicePluginEventType.error: + _scanError.value = e.errorMessage ?? e.errorCode; + _isScanning.value = false; + break; + default: + break; + } + }); + WidgetsBinding.instance.addPostFrameCallback((_) => _startScan()); + } + + Future _startScan() async { + _scanned.clear(); + _nearby.clear(); + _scanError.value = null; + if (!await _plugin.ensureReady()) return; + await _plugin.startScan(); + } + + @override + void dispose() { + _sub?.cancel(); + _plugin.stopScan(); + super.dispose(); + } + + Future _connect(DiscoveredDevice d) async { + if (_connecting) return; + setState(() => _connecting = true); + + String? failReason; + try { + await _plugin.connect( + d.id, + // MAC 只在广播里有,连上就拿不到了。这里带过去给确权用, + // 免得连上之后再回头找。 + options: DeviceConnectOptions(extra: {'mac': d.metadata['mac']}), + ); + await _plugin.rememberDevice(d); + } on DeviceException catch (e) { + failReason = e.message ?? e.code; + } catch (e) { + failReason = '$e'; + } + if (!mounted) return; + setState(() => _connecting = false); + + if (failReason == null) { + Get.snackbar('smartcarConnected'.tr, d.name, + snackPosition: SnackPosition.TOP, + duration: const Duration(seconds: 2)); + // BLE 通了不等于听得见:AI 回复和音乐走的是**经典蓝牙 A2DP**(见落地文档 7.0)。 + // 没接上时提示一次,别让用户以为"连上了却没声音"是坏了。 + unawaited(_hintClassicAudioIfNeeded(d.name)); + Get.offAllNamed(Routes.home); + WidgetsBinding.instance.addPostFrameCallback((_) { + if (Get.isRegistered()) { + MainTabController.to.changeTab(MainTabController.tabDevice); + } + }); + } else { + // ⚠️ 失败原因**留在页面上**,不能只弹一下就没了: + // 这些信息(设备实际暴露了哪些服务、握手卡在哪条命令)是排查的全部依据, + // snackbar 几秒就消失,用户根本来不及看清更别说转述。 + _scanError.value = failReason; + Get.snackbar('smartcarConnectFailed'.tr, failReason, + snackPosition: SnackPosition.TOP, + duration: const Duration(seconds: 5)); + } + } + + /// 经典蓝牙没接上就提示一次。 + /// + /// - Android:插件已经在连接后自动 `createBond` 试过一次,这里只兜底提示 + /// - iOS:**没有**发起经典蓝牙配对的公开 API,只能引导用户去系统设置 + Future _hintClassicAudioIfNeeded(String name) async { + try { + final r = await _plugin.activeSession + ?.invokeFeature('smartcar.a2dp_status'); + if (r == null || r['connected'] == true) return; + Get.snackbar( + 'smartcarA2dpTitle'.tr, + 'smartcarA2dpHint'.tr.replaceAll('@name', name), + snackPosition: SnackPosition.TOP, + duration: const Duration(seconds: 6), + ); + } catch (_) { + // 查不到就不提示——这只是体验提示,不该把连接流程搅黄 + } + } + + @override + Widget build(BuildContext context) { + final bool isDark = Theme.of(context).brightness == Brightness.dark; + return Scaffold( + backgroundColor: AppPalette.background(isDark), + appBar: AppBar( + backgroundColor: AppPalette.background(isDark), + elevation: 0, + title: Text('catCarAroma'.tr), + actions: [ + Obx(() => _isScanning.value + ? Padding( + padding: EdgeInsets.only(right: 16.w), + child: Center( + child: SizedBox( + width: 18.w, + height: 18.w, + child: const CircularProgressIndicator(strokeWidth: 2), + ), + ), + ) + : IconButton( + icon: const Icon(Icons.refresh), + onPressed: _startScan, + )), + ], + ), + body: _connecting + ? Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const CircularProgressIndicator(), + SizedBox(height: 16.h), + Text('smartcarConnecting'.tr), + ], + ), + ) + : Obx(() { + final list = _scanned; + if (list.isEmpty) return _empty(isDark); + return ListView.separated( + padding: EdgeInsets.symmetric(horizontal: 16.w, vertical: 16.h), + itemCount: list.length, + separatorBuilder: (_, __) => SizedBox(height: 12.h), + itemBuilder: (context, i) => _card(list[i], isDark), + ); + }), + ); + } + + Widget _empty(bool isDark) => Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.bluetooth_searching, + size: 56.sp, color: AppPalette.brand300), + SizedBox(height: 12.h), + Obx(() => Text( + _isScanning.value + ? 'smartcarSearching'.tr + : 'smartcarNotFound'.tr, + style: TextStyle( + fontSize: 15.sp, + color: AppPalette.textSecondary(isDark)), + )), + SizedBox(height: 6.h), + Text( + 'smartcarScanHint'.tr, + textAlign: TextAlign.center, + style: TextStyle( + fontSize: 12.sp, color: AppPalette.textHint(isDark)), + ), + SizedBox(height: 20.h), + _diagnostics(isDark), + ], + ), + ); + + /// 诊断面板:把附近扫到的 BLE 设备摊开。 + /// + /// 判据(厂商 ID 0x8505 / 名字含 smartcar)对不对,看这里最快: + /// - 列表里根本没有目标设备 → 设备没在播,或不在范围内 + /// - 有、但 mfg 那栏的厂商 ID 不是 0x8505 → 判据要按实际值改 + /// - 有、厂商 ID 对但长度不足 22 → 广播段布局和文档对不上 + Widget _diagnostics(bool isDark) => Obx(() { + final list = _nearby.values.toList(); + final err = _scanError.value; + // ⚠️ 0 台也要显示。「附近 0 台」和「面板不出现」是两回事: + // 前者说明扫描跑了但什么都没收到,后者说明扫描压根没起来。 + return Padding( + padding: EdgeInsets.symmetric(horizontal: 24.w), + child: Column( + children: [ + if (err != null) + Container( + width: double.infinity, + margin: EdgeInsets.only(bottom: 8.h), + padding: EdgeInsets.all(10.w), + decoration: BoxDecoration( + color: Colors.red.withValues(alpha: 0.08), + borderRadius: BorderRadius.circular(8.r), + ), + child: SelectableText('失败详情:$err', + style: TextStyle(fontSize: 11.sp, color: Colors.red)), + ), + TextButton.icon( + onPressed: () => _showDiag.value = !_showDiag.value, + icon: Icon( + _showDiag.value ? Icons.expand_less : Icons.expand_more, + size: 18.sp), + label: Text('附近 ${list.length} 台蓝牙设备', + style: TextStyle(fontSize: 12.sp)), + ), + if (_showDiag.value && list.isEmpty) + Text('一台都没收到 —— 蓝牙可能没开、权限被拒,或周围确实没有设备', + textAlign: TextAlign.center, + style: TextStyle( + fontSize: 11.sp, color: AppPalette.textHint(isDark))), + if (_showDiag.value && list.isNotEmpty) + Container( + constraints: BoxConstraints(maxHeight: 260.h), + padding: EdgeInsets.all(10.w), + decoration: BoxDecoration( + color: AppPalette.surface(isDark), + borderRadius: BorderRadius.circular(10.r), + border: Border.all(color: AppPalette.border(isDark)), + ), + child: ListView.builder( + shrinkWrap: true, + itemCount: list.length, + itemBuilder: (_, i) { + final d = list[i]; + final matched = d['matched'] == true; + final name = '${d['name']}'; + return Padding( + padding: EdgeInsets.symmetric(vertical: 4.h), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + '${matched ? '✓ ' : ''}' + '${name.isEmpty ? '(无名)' : name} ${d['rssi']}dBm', + style: TextStyle( + fontSize: 11.sp, + fontWeight: matched + ? FontWeight.w700 + : FontWeight.w400, + color: AppPalette.textPrimary(isDark), + ), + ), + // 可长按复制:MAC 解错时要把这串原始字节发出来分析 + SelectableText( + 'mfg=${d['mfg']}\n' + '解出MAC=${d['parsedMac']}\n' + 'svc=${d['services']}', + style: TextStyle( + fontSize: 10.sp, + color: AppPalette.textHint(isDark)), + ), + ], + ), + ); + }, + ), + ), + ], + ), + ); + }); + + Widget _card(DiscoveredDevice d, bool isDark) { + final mac = '${d.metadata['mac'] ?? ''}'; + return GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: () => _connect(d), + child: Container( + padding: EdgeInsets.all(14.w), + decoration: BoxDecoration( + color: AppPalette.surface(isDark), + borderRadius: BorderRadius.circular(14.r), + border: Border.all(color: AppPalette.border(isDark)), + ), + child: Row( + children: [ + Icon(Icons.air, size: 26.sp, color: AppPalette.brand500), + SizedBox(width: 12.w), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + d.name, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + fontSize: 15.sp, + fontWeight: FontWeight.w600, + color: AppPalette.textPrimary(isDark), + ), + ), + if (mac.isNotEmpty) ...[ + SizedBox(height: 3.h), + Text( + mac, + style: TextStyle( + fontSize: 11.sp, + color: AppPalette.textHint(isDark), + ), + ), + ], + ], + ), + ), + if (d.rssi != null) + Text( + '${d.rssi} dBm', + style: TextStyle( + fontSize: 11.sp, color: AppPalette.textHint(isDark)), + ), + SizedBox(width: 6.w), + Icon(Icons.chevron_right, + size: 20.sp, color: AppPalette.textHint(isDark)), + ], + ), + ), + ); + } +} diff --git a/apps/client/lib/modules/emai/controllers/emai_controller.dart b/apps/client/lib/modules/emai/controllers/emai_controller.dart index 1d1528cb..c5575264 100644 --- a/apps/client/lib/modules/emai/controllers/emai_controller.dart +++ b/apps/client/lib/modules/emai/controllers/emai_controller.dart @@ -4,13 +4,14 @@ import 'package:flutter/material.dart'; import 'package:get/get.dart'; import 'package:haptic_feedback/haptic_feedback.dart'; +import '../../../data/services/network/api.dart'; import '../../../core/utils/logger.dart'; import '../../../data/models/appconfig.dart'; import '../../../data/models/user_Info.dart' as user_info; import '../../../data/services/assistant_directive_service.dart'; import '../../../data/services/bailian_multimodal_service.dart'; import '../../../data/models/emai_message.dart'; -import '../../../data/services/bes_ai_session_service.dart'; +import '../../../data/services/device_ai_session_service.dart'; import '../../../data/services/emai_chat_store.dart'; import '../../../data/utils/mic_pcm_streamer.dart'; import '../../../data/services/mcp_token_service.dart'; @@ -39,10 +40,17 @@ enum EmaiLinkState { class EmaiController extends GetxController with WidgetsBindingObserver implements EmaiSessionOwner { + /// [service] 不传就用全局那个(EMAI 的百炼应用),行为与改造前一致。 + /// + /// 传了才用传的——给「同一套对话 UI、不同百炼应用」的 agent 复用(如 Smartcar + /// 车载助手)。两个 agent 各自 new 一个 service 实例,不能共用,理由见 + /// [BailianMultimodalService] 的类注释。 + EmaiController({BailianMultimodalService? service}) + : _svc = service ?? Get.find(); + static const String _tag = 'EmaiController'; - static EmaiController get to => Get.find(); - final BailianMultimodalService _svc = Get.find(); + final BailianMultimodalService _svc; final MicPcmStreamer _mic = MicPcmStreamer(sampleRate: BailianMultimodalService.upstreamSampleRate); final PcmStreamPlayer _player = PcmStreamPlayer( @@ -78,7 +86,7 @@ class EmaiController extends GetxController /// ⚠️ duplex 在手机上能成立的前提是 AEC —— 外放的播报会被自己的麦收回去, /// 服务端 VAD 会当成用户插话,每次 AI 一开口就被自己打断。 /// [MicPcmStreamer] 已经开了 echoCancel/noiseSuppress,所以不需要像耳机那条 - /// 链路那样做「播报期间门控上行」的半双工妥协(见 BesAiSessionService 的注释: + /// 链路那样做「播报期间门控上行」的半双工妥协(见 DeviceAiSessionService 的注释: /// 耳机没把下行接进 AEC 参考路径,只能门控)。 /// /// **默认关**:进页面只建立会话,不开麦。连续对话必须由用户点一下中间按钮 @@ -87,7 +95,7 @@ class EmaiController extends GetxController /// ⚠️ 别再改回默认开。进页面即开麦意味着用户还没表达意图,麦克风就已经在 /// 录音并往服务端推流了——既是隐私问题,也会让「不小心点进来」变成一次 /// 计费的对话。耳机唤醒那条链路不走这里,它有自己的会话生命周期 - /// (见 BesAiSessionService)。 + /// (见 DeviceAiSessionService)。 final isContinuous = false.obs; /// 文字输入模式 / 语音输入模式 @@ -160,12 +168,12 @@ class EmaiController extends GetxController // 那条路径不经过本控制器的事件处理,得单独盯着 store 才能跟到底 _storeWorker = ever(_store.messages, (_) => _scrollToBottom()); - // 让 BesAiSessionService 能在耳机唤醒时找到本页并要求让出会话, + // 让 DeviceAiSessionService 能在耳机唤醒时找到本页并要求让出会话, // 同时避免 data 层反向 import modules 层。 // 用普通引用而不是 Get.put(this):后者会让 GetX // 再跑一次 onStart→onInit,而我们就在 onInit 里,直接递归爆栈。 - if (Get.isRegistered()) { - BesAiSessionService.to.attachPhonePage(this); + if (Get.isRegistered()) { + DeviceAiSessionService.to.attachPhonePage(this); } _subs.add(_svc.events.listen(_onEvent)); @@ -192,8 +200,8 @@ class EmaiController extends GetxController @override void onClose() { WidgetsBinding.instance.removeObserver(this); - if (Get.isRegistered()) { - BesAiSessionService.to.detachPhonePage(this); + if (Get.isRegistered()) { + DeviceAiSessionService.to.detachPhonePage(this); } _handshakeTimer?.cancel(); _cancelIdle(); @@ -245,8 +253,8 @@ class EmaiController extends GetxController if (linkState.value == EmaiLinkState.connecting) return; // 耳机会话正在跑:本页不去抢,等它结束后 resumeFromEarphone 会接回来 if (linkState.value == EmaiLinkState.onEarphone) return; - if (Get.isRegistered() && - BesAiSessionService.to.isActive.value) { + if (Get.isRegistered() && + DeviceAiSessionService.to.isActive.value) { linkState.value = EmaiLinkState.onEarphone; return; } @@ -304,18 +312,18 @@ class EmaiController extends GetxController /// 手机页面主动把控制权要回来:让耳机退出 AI 模式。 /// - /// 收尾是异步的——[BesAiSessionService.requestStop] 发完 `AA 65` 要等耳机回 + /// 收尾是异步的——[DeviceAiSessionService.requestStop] 发完 `AA 65` 要等耳机回 /// `0xE5`(或 2s 超时),它的 teardown 会回调 [resumeFromEarphone] 把本页接回来, /// 所以这里不用自己 connect(),否则会和耳机会话抢。 Future takeBackFromEarphone() async { - if (!Get.isRegistered()) { + if (!Get.isRegistered()) { // 服务没注册(理论上不会),至少别让页面卡在 onEarphone linkState.value = EmaiLinkState.idle; await connect(); return; } linkState.value = EmaiLinkState.connecting; - await BesAiSessionService.to.requestStop(); + await DeviceAiSessionService.to.requestStop(); } @override @@ -604,6 +612,32 @@ class EmaiController extends GetxController _svc.sendText(text); } + /// 一轮对话结束后上报**原始字数**(输入 = 这轮之前最近一条用户消息,输出 = 助手这条回复), + /// 服务端按后台换算系数折成算力记账。客户端**不算算力**——系数可在后台改,客户端算的话 + /// 改一次系数就要发一次版。字符数按 Unicode 码点(runes)计,与服务端口径一致。 + /// 上报失败只记日志:这是记账不是闸门,不该影响对话。 + void _reportAiUsage(EmaiMessage assistant) { + int up = 0; + for (final msg in messages.reversed) { + if (identical(msg, assistant)) continue; + if (msg.isUser) { + up = msg.text.runes.length; + break; + } + } + final down = assistant.text.runes.length; + if (up == 0 && down == 0) return; + unawaited(Api.usagesResPoints({ + 'usagetype': 3, // UsageType.AI + 'usages': {}, + 'upchars': up, + 'downchars': down, + }).catchError((e) { + Logger.w(_tag, 'AI 用量上报失败: $e'); + return null; + })); + } + /// 打断当前回复 Future interrupt() async { if (!isResponding) return; @@ -706,6 +740,7 @@ class EmaiController extends GetxController if (e.text.isNotEmpty) _mergeInto(m, e.text); _store.finalize(m); _streamingAssistant = null; + _reportAiUsage(m); } // 文本收完不代表音频收完,但下行音频到此为止,让播放器把缓冲播完 _player.end(); diff --git a/apps/client/lib/modules/emai/module.dart b/apps/client/lib/modules/emai/module.dart new file mode 100644 index 00000000..2243a314 --- /dev/null +++ b/apps/client/lib/modules/emai/module.dart @@ -0,0 +1,28 @@ +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; + +import '../../core/utils/ai_navigation.dart'; +import '../../routes/app_routes.dart'; +import '../module_descriptor.dart'; +import 'bindings/emai_binding.dart'; +import 'views/emai_view.dart'; + +/// EMAI 助手(阿里百炼多模态对话) +final AgentModuleDescriptor emaiModule = AgentModuleDescriptor( + id: 'emaiAssistant', + title: 'agentEmai', + subtitle: 'agentEmaiDesc', + icon: Icons.auto_awesome, + gradient: const [Color(0xFF5B8DEF), Color(0xFF7E57C2)], + // 按 agentType 走移动精灵或 EMAI 页 + onLaunch: navigateToAIAssistant, + pages: [ + GetPage( + name: Routes.emai, + page: () => const EmaiView(), + binding: EmaiBinding(), + transition: Transition.rightToLeft, + transitionDuration: const Duration(milliseconds: 250), + ), + ], +); diff --git a/apps/client/lib/modules/emai/views/emai_view.dart b/apps/client/lib/modules/emai/views/emai_view.dart index 9eb74920..33c01254 100644 --- a/apps/client/lib/modules/emai/views/emai_view.dart +++ b/apps/client/lib/modules/emai/views/emai_view.dart @@ -13,7 +13,17 @@ import 'widgets/emai_level_bar.dart'; /// 结构自上而下:状态条 → 消息列表 → 输入区(按住说话 / 文字输入二选一)。 /// 所有 AI 能力都在阿里百炼多模态应用里,页面本身只管采音、播音和展示。 class EmaiView extends GetView { - const EmaiView({super.key}); + /// [controllerTag]:不传 = 取全局那个 [EmaiController](EMAI 自己),行为不变。 + /// 传了则取同名 tag 的实例——给复用这套 UI 的其它 agent 用(如 Smartcar)。 + /// [titleKey]:标题栏的 i18n key,默认 EMAI 自己的。 + const EmaiView({super.key, this.controllerTag, this.titleKey = 'agentEmai'}); + + final String? controllerTag; + final String titleKey; + + /// GetView 用它决定 `controller` 取哪个实例 + @override + String? get tag => controllerTag; @override Widget build(BuildContext context) { @@ -52,7 +62,7 @@ class EmaiView extends GetView { onPressed: () => Get.back(), ), title: Text( - 'agentEmai'.tr, + titleKey.tr, style: TextStyle( fontSize: 17.sp, fontWeight: FontWeight.w600, diff --git a/apps/client/lib/modules/goods/controllers/goods_base_controller.dart b/apps/client/lib/modules/goods/controllers/goods_base_controller.dart index 78a2eac8..944bf2d1 100644 --- a/apps/client/lib/modules/goods/controllers/goods_base_controller.dart +++ b/apps/client/lib/modules/goods/controllers/goods_base_controller.dart @@ -141,8 +141,25 @@ abstract class GoodsBaseController extends GetxController { } } + /// 本包是否为 Google Play 渠道。 + /// + /// ⚠️ 读的是编译期烧进包里的 `.env` 的 `Channel`,**不要**换成 + /// `VersionUpdateService.cachedChannel`——那个值在版本检查跑完前会回退成 + /// `GLOBAL_GOOGLE`(见 version_update_service.dart 的 getter),用它会在国内包 + /// 启动初期把微信/支付宝误杀掉。 + static bool get _isGooglePlayBuild => + (dotenv.env['Channel'] ?? '').trim().toUpperCase() == 'GLOBAL_GOOGLE'; + /// 解析后端 paychannels 并结合平台过滤可用支付方式 + /// + /// ⚠️ Google Play 卖数字商品必须走 Play 结算,摆出微信/支付宝/PayPal 是最典型的 + /// 下架理由。所以 Google 渠道**一律只留 google**:既不信后端下发的渠道列表, + /// 配置缺失时也不回退成「展示所有」。其它渠道行为保持不变。 List<_PayMethod> _getEnabledPayMethods() { + if (_isGooglePlayBuild) { + final bool isAndroid = GetPlatform.isAndroid && !kIsWeb; + return isAndroid ? <_PayMethod>[_PayMethod.google] : <_PayMethod>[]; + } try { //1. 获取后端配置的支付渠道 final paychannels = diff --git a/apps/client/lib/modules/goods/controllers/goodsvip_controller.dart b/apps/client/lib/modules/goods/controllers/goodsvip_controller.dart index 98b18c03..55c9d737 100644 --- a/apps/client/lib/modules/goods/controllers/goodsvip_controller.dart +++ b/apps/client/lib/modules/goods/controllers/goodsvip_controller.dart @@ -1,15 +1,92 @@ +import 'package:get/get.dart'; import '../../../data/models/goods.dart'; import '../../../data/services/network/api.dart'; import 'goods_base_controller.dart'; class GoodsvipController extends GoodsBaseController { + /// 会员与算力状态(`user_getcompute`),页面顶部 VIP 卡片下展示。 + /// 服务端已把自购与设备赋予的 VIP 合并好、算力也拆好了来源,这里**不做任何换算**。 + final compute = Rxn>(); + + @override + void onInit() { + super.onInit(); + refreshCompute(); + } + + Future refreshCompute() async { + try { + final resp = await Api.getCompute(); + if (resp is Map) compute.value = Map.from(resp); + } catch (_) { + // 展示项,拿不到就不显示 + } + } + + /// 「设备 N + 账户 M」形式的算力摘要;没数据返回 null。 + String? get computeSummary { + final c = compute.value?['compute']; + if (c is! Map) return null; + final dev = (c['deviceleft'] as num?)?.toInt() ?? 0; + final usr = (c['userbalance'] as num?)?.toInt() ?? 0; + return 'computeSummary'.trParams({'device': '$dev', 'user': '$usr'}); + } + + /// 需要提示「VIP 快到期了」吗:剩余天数 ≤ 后台配的阈值(阈值为 0 = 后台关掉了这个提示)。 + /// 判定用服务端下发的 vipwarndays,客户端不自己拍数字——运营改阈值不用发版。 + bool get vipExpiringSoon { + final v = compute.value?['vip']; + final r = compute.value?['rates']; + if (v is! Map || r is! Map) return false; + final days = (r['vipwarndays'] as num?)?.toInt() ?? 0; + if (days <= 0) return false; + final exp = (v['exptime'] as num?)?.toInt() ?? 0; + if (exp <= 0) return false; + final left = exp - DateTime.now().millisecondsSinceEpoch ~/ 1000; + return left > 0 && left <= days * 86400; + } + + /// VIP 剩余天数(向上取整,至少 1);无 VIP 或已过期返回 0。 + int get vipDaysLeft { + final v = compute.value?['vip']; + if (v is! Map) return 0; + final exp = (v['exptime'] as num?)?.toInt() ?? 0; + final left = exp - DateTime.now().millisecondsSinceEpoch ~/ 1000; + if (left <= 0) return 0; + return (left / 86400).ceil(); + } + + /// 需要提示「算力不多了」吗:剩余总量 ≤ 后台配的阈值(阈值 0 = 不提示)。 + bool get computeLow { + final c = compute.value?['compute']; + final r = compute.value?['rates']; + if (c is! Map || r is! Map) return false; + final warn = (r['computewarn'] as num?)?.toInt() ?? 0; + if (warn <= 0) return false; + final left = ((c['deviceleft'] as num?)?.toInt() ?? 0) + ((c['userbalance'] as num?)?.toInt() ?? 0); + return left <= warn; + } + + /// 当前 VIP 是否来自设备(用于副标题标注来源)。 + bool get vipFromDevice { + final v = compute.value?['vip']; + if (v is! Map) return false; + final self = (v['selfexptime'] as num?)?.toInt() ?? 0; + final exp = (v['exptime'] as num?)?.toInt() ?? 0; + final level = (v['level'] as num?)?.toInt() ?? 0; + return level > 0 && exp > self; + } + @override Future> fetchProducts() async { + // 2026-09 起在售的只有 VIP 时长与算力包;AI/Meet/Translation 是旧资源点包, + // 仍然拉取是为了让还挂在架上的存量商品能买(发货时会折成算力),但不再新建。 final types = [ + UsageType.Vip, + UsageType.Compute, UsageType.AI, UsageType.Meet, UsageType.Translation, - UsageType.Vip, ]; final Map all = {}; for (final t in types) { diff --git a/apps/client/lib/modules/goods/views/goodsvip_view.dart b/apps/client/lib/modules/goods/views/goodsvip_view.dart index 3edacde0..ffb2bbde 100644 --- a/apps/client/lib/modules/goods/views/goodsvip_view.dart +++ b/apps/client/lib/modules/goods/views/goodsvip_view.dart @@ -52,8 +52,21 @@ class GoodsvipView extends GetView { for (final g in controller.items) { grouped.putIfAbsent(g.usagetype, () => []).add(g); } - final order = [UsageType.Vip]; + // VIP 时长在前、算力包在后;两者都没有商品时页面只剩权益说明 + final order = [UsageType.Vip, UsageType.Compute]; final children = []; + // 到期 / 算力不足提示条:阈值由后台配(0 = 关掉该提示),判定在 controller 里 + if (controller.vipExpiringSoon) { + children.add(_buildWarnBanner( + isDarkMode, + 'vipExpiringSoon'.trParams({'days': '${controller.vipDaysLeft}'}), + )); + children.add(SizedBox(height: 12.h)); + } + if (controller.computeLow) { + children.add(_buildWarnBanner(isDarkMode, 'computeLow'.tr)); + children.add(SizedBox(height: 12.h)); + } children.add(_buildFeaturesSection(isDarkMode)); children.add(SizedBox(height: 16.h)); // children.add(_buildScenesSection(isDarkMode)); @@ -100,6 +113,39 @@ class GoodsvipView extends GetView { ); } + /// 到期 / 算力不足的提示条。刻意做成温和的黄色而不是红色错误样式—— + /// 这是「该续费了」的提醒,不是故障;下面就是购买入口,不需要再额外引导。 + Widget _buildWarnBanner(bool isDarkMode, String text) { + return Container( + width: double.infinity, + padding: EdgeInsets.symmetric(horizontal: 16.w, vertical: 12.h), + decoration: BoxDecoration( + color: isDarkMode ? const Color(0xFF3A2F16) : const Color(0xFFFFF7E6), + borderRadius: BorderRadius.circular(12.r), + border: Border.all( + color: isDarkMode ? const Color(0xFF6B551F) : const Color(0xFFFFE0A3), + ), + ), + child: Row( + children: [ + Icon(Icons.info_outline, + size: 18.sp, + color: isDarkMode ? const Color(0xFFE0B860) : const Color(0xFFB7791F)), + SizedBox(width: 10.w), + Expanded( + child: Text( + text, + style: TextStyle( + fontSize: 13.sp, + color: isDarkMode ? const Color(0xFFE8D5A8) : const Color(0xFF8A6116), + ), + ), + ), + ], + ), + ); + } + Widget _buildFeaturesSection(bool isDarkMode) { final textColor = isDarkMode ? Colors.white : Colors.black; final subColor = isDarkMode ? Colors.white70 : Colors.grey[700]; @@ -233,11 +279,16 @@ class GoodsvipView extends GetView { UsageType.Meet: 'conferenceAssistant'.tr, UsageType.Translation: 'simultaneousTranslation'.tr, UsageType.Vip: 'memberService'.tr, + UsageType.Compute: 'computePack'.tr, }; final iconAndColor = _iconColorForType(type, isDarkMode); final subColor = isDarkMode ? Colors.white70 : Colors.grey[700]; String titleStr = titles[type] ?? type.name; String? vipSubtitle; + if (type == UsageType.Compute) { + // 算力包分区:副标题显示当前余额(服务端已把设备赠送与账户余额拆好) + vipSubtitle = controller.computeSummary; + } if (type == UsageType.Vip) { int viplv = 0; int vipexptime = 0; @@ -254,7 +305,7 @@ class GoodsvipView extends GetView { } // titleStr = 'Vip $viplv'; vipSubtitle = expiry != null - ? '${'expiryLabel'.tr}${_fmtDate(expiry)}' + ? '${'expiryLabel'.tr}${_fmtDate(expiry)}${controller.vipFromDevice ? ' · ${'vipFromDevice'.tr}' : ''}' : 'notActivated'.tr; } return Container( @@ -298,6 +349,14 @@ class GoodsvipView extends GetView { ), ), SizedBox(height: 8.h), + // 会员与算力(user_getcompute):服务端已合并自购与设备赋予,这里只展示 + if (type == UsageType.Vip && controller.computeSummary != null) ...[ + Text( + controller.computeSummary!, + style: TextStyle(fontSize: 12.sp, color: subColor), + ), + SizedBox(height: 4.h), + ], if (type == UsageType.Vip && vipSubtitle != null) Text( vipSubtitle, @@ -817,6 +876,14 @@ class GoodsvipView extends GetView { : Colors.indigo[100]!, foreground: isDarkMode ? Colors.indigo[300]! : Colors.indigo[600]!, ); + case UsageType.Compute: + return _IconColors( + icon: Icons.bolt, + background: isDarkMode + ? Colors.amber[900]!.withOpacity(0.3) + : Colors.amber[100]!, + foreground: isDarkMode ? Colors.amber[300]! : Colors.amber[700]!, + ); case UsageType.Vip: case UsageType.UsageNull: return _IconColors( diff --git a/apps/client/lib/modules/home/controllers/home_controller.dart b/apps/client/lib/modules/home/controllers/home_controller.dart index d3bfc0c6..c3e705e1 100644 --- a/apps/client/lib/modules/home/controllers/home_controller.dart +++ b/apps/client/lib/modules/home/controllers/home_controller.dart @@ -1,98 +1,48 @@ import 'dart:async'; -import '/core/utils/id_verify_guard.dart'; -import '/core/utils/device_connection.dart'; -import '/core/utils/toast_throttle.dart'; -import 'package:device_jieli/device_jieli.dart'; +import 'package:agent_service/agent_service.dart'; +import 'package:flutter/material.dart'; import 'package:get/get.dart'; import 'package:get_storage/get_storage.dart'; -import 'package:agent_service/agent_service.dart'; -import '../../../data/services/novice_guide_service.dart'; -import '../../../routes/app_routes.dart'; + +import '/core/utils/device_connection.dart'; +import '/core/utils/id_verify_guard.dart'; +import '/core/utils/toast_throttle.dart'; import '../../../core/utils/logger.dart'; -import 'package:flutter/material.dart'; -import '../../../data/services/bluetooth_manager.dart'; -import '../../../data/services/ble_manager.dart'; -import '../../../data/services/ota_check_service.dart'; -import '../../../data/services/id_verify_service.dart'; import '../../../data/models/appconfig.dart'; import '../../../data/models/appconfig_model.dart'; +import '../../../data/services/id_verify_service.dart'; +import '../../../data/services/novice_guide_service.dart'; +import '../../../data/services/ota_check_service.dart'; +import '../../../devices/device_hub.dart'; +import '../../../routes/app_routes.dart'; -/// 连接状态枚举 -enum ConnectionStatus { - disconnected, // 未连接 - connecting, // 连接中 - connected, // 已连接 - failed // 连接失败 -} - -/// 主页控制器,管理设备连接状态和信息展示 +/// 主框架控制器:各功能入口的跳转与闸门、连上设备后的固件检查、播报打断开关。 +/// +/// 设备状态一律来自 [DeviceHub],这里不再自己维护任何连接/电量真相。 class HomeController extends GetxController { static const String _tag = 'HomeCtrl'; - static const String _lastDeviceNameKey = 'last_connected_device_name'; // 存储键 - static const String _lastDeviceImageKey = - 'last_connected_device_image'; // 存储上一次设备图片的键 static HomeController get to => Get.find(); final GetStorage _storage = GetStorage(); - String? _lastConnectedDeviceImage; // 新增:存储上一次连接设备的图片链接 - - final _isFullyConnected = false.obs; - String? _lastConnectedDeviceName; - // 播报打断开关状态(与 AgentController 对齐) - final isTtsInterruptEnabled = false.obs; // 是否启用播报打断 + // 播报打断开关状态 + final isTtsInterruptEnabled = false.obs; - // 移除了电池信息定时刷新相关变量 - - // OTA检查服务 late final OtaCheckService _otaCheckService; - - bool get isFullyConnected => _isFullyConnected.value; - RxBool get isFullyConnectedRx => _isFullyConnected; - BluetoothDevice? get currentDevice => _bluetoothManager.currentDevice; - Rx get deviceInfo => _bleManager.deviceInfo; - - static final RxInt leftEarBattery = 0.obs; - static final RxInt rightEarBattery = 0.obs; - final caseBattery = 0.obs; - final leftEarCharging = false.obs; - final rightEarCharging = false.obs; - final caseCharging = false.obs; - final firmwareVersion = ''.obs; - - void updateDeviceInfo(DeviceInfo? newDeviceInfo) { - leftEarBattery.value = newDeviceInfo?.leftEarBattery ?? 0; - rightEarBattery.value = newDeviceInfo?.rightEarBattery ?? 0; - caseBattery.value = newDeviceInfo?.caseBattery ?? 0; - leftEarCharging.value = newDeviceInfo?.leftEarCharging ?? false; - rightEarCharging.value = newDeviceInfo?.rightEarCharging ?? false; - caseCharging.value = newDeviceInfo?.caseCharging ?? false; - firmwareVersion.value = newDeviceInfo?.firmwareVersion ?? ''; - } - - Worker? _deviceInfoWorker; - Worker? _classicBluetoothWorker; - Worker? _bleConnectionWorker; - - /// 全局 5s 刷新定时器。声明为 static 是为了让它跨 HomeController 实例存活: - /// `Get.offAllNamed(Routes.home)` 把旧 controller 干掉、新 controller 起来时, - /// 这个定时器始终是同一个,永远在跑,直接调当前注册的 HomeController 的 - /// `refreshDeviceConnection`(=点刷新按钮的逻辑)。 - static Timer? _staticPollTimer; - static const Duration _pollInterval = Duration(seconds: 5); - - final _bluetoothManager = Get.find(); - final _bleManager = Get.find(); - final noviceGuideService = Get.find(); + Worker? _connectedWorker; + + /// 任一设备在线。保留旧名给还在用它的页面。 + RxBool get isFullyConnectedRx => DeviceHub.to.anyConnected; + bool get isFullyConnected => isFullyConnectedRx.value; - /// 按设备名安全地查产品(AppConfig.getproduct 会抛,见调用处注释)。 + /// 按设备名安全地查产品(AppConfig.getproduct 单向包含,这里双向)。 DBProduct? _findProductByName(String deviceName) { if (!AppConfig.isInitialized()) return null; final n = deviceName.trim(); if (n.isEmpty) return null; for (final p in AppConfig.config.products) { - if (p.devicename.contains(n)) return p; + if (p.devicename.contains(n) || n.contains(p.devicename)) return p; } return null; } @@ -106,87 +56,34 @@ class HomeController extends GetxController { if (Get.isRegistered()) { IdVerifyService.to.ensureLoaded(); } - _loadLastConnectedDeviceName(); // 加载持久化的设备名称 - _initWithCachedInfo(); - _loadTtsInterrupt(); // 加载播报打断开关初始状态 - // 监听存储中的“agent_tts_interrupe”键变化,实现与 Agent 页同步 - // 中文注释:当 Agent 页或 Home 页任意一处切换时,另一处通过此监听自动刷新开关状态 + _loadTtsInterrupt(); _storage.listenKey('agent_tts_interrupt', (value) { try { final bool newValue = value is bool ? value : (value == 'true'); if (isTtsInterruptEnabled.value != newValue) { isTtsInterruptEnabled.value = newValue; } - } catch (e) { - // 中文注释:忽略异常,保持现有状态,避免因类型转换失败导致崩溃 + } catch (_) {} + }); + // 连接成功后检查 OTA 升级 + _connectedWorker = ever(DeviceHub.to.anyConnected, (on) { + if (on && noviceGuideService.isHomeGuide) { + Future.delayed(const Duration(seconds: 1), _checkOtaUpgrade); } }); - - // 移除了电池信息定时刷新的启动 } @override void onClose() { - _deviceInfoWorker?.dispose(); - _classicBluetoothWorker?.dispose(); - _bleConnectionWorker?.dispose(); + _connectedWorker?.dispose(); super.onClose(); } - void _initWithCachedInfo() { - _setupStateListeners(); - _checkConnectionStatus(); - _ensureStaticPollTimer(); - } - - /// 启动静态 5s 刷新定时器。已经在跑就什么都不做。 - /// 定时器一旦起来就永远不取消(HomeController 只是 lazyPut,控制器实例可能反复 - /// 销毁/重建,但定时器跨实例存活),每次 fire 都去当前注册的 HomeController - /// 调 refreshDeviceConnection——和用户点刷新按钮完全等价。 - static void _ensureStaticPollTimer() { - if (_staticPollTimer != null && _staticPollTimer!.isActive) return; - _staticPollTimer = Timer.periodic(_pollInterval, (_) { - try { - if (Get.isRegistered()) { - Get.find().refreshDeviceConnection(); - } - } catch (_) { - // 忽略:home 还没注册或者刚销毁中间窗口期 - } - }); - } - - void _setupStateListeners() { - _deviceInfoWorker = ever(_bleManager.deviceInfo, (DeviceInfo? info) { - if (info != null) updateDeviceInfo(info); - }); - - _classicBluetoothWorker = - ever(_bluetoothManager.currentDeviceRx, (BluetoothDevice? device) { - if (device != null) { - _saveLastConnectedDeviceName(device.name); // 使用新的保存方法 - } - _checkConnectionStatus(); - }); - - _bleConnectionWorker = ever(_bleManager.connectionStateRx, (int state) { - _checkConnectionStatus(); - }); - } - - /// 加载“播报打断”开关状态 - /// 逻辑与 AgentController 保持一致: - /// 1)若本地不存在存储,则根据设备产品的 iscontinuouschat 取反作为默认值; - /// 2)若存在存储则直接读取; - /// 3)同步到原生服务 AgentService.isInterrupt。 + /// 加载"播报打断"开关状态:本地没存过时按设备产品的 iscontinuouschat 取反。 Future _loadTtsInterrupt() async { try { if (!_storage.hasData('agent_tts_interrupt')) { final deviceName = getDeviceName(); - // 这里用本地的 _findProductByName 而不是 AppConfig.getproduct(): - // 后者只做「产品名包含设备名」单向匹配,本地这份是双向包含 + 兜底, - // 设备名与产品名不完全一致时才匹配得上。 - // (getproduct 本身已经不会抛了,2026-09-03 起查不到返回 null。) final DBProduct? product = _findProductByName(deviceName); if (deviceName.isNotEmpty && product != null) { isTtsInterruptEnabled.value = !(product.iscontinuouschat ?? true); @@ -204,8 +101,6 @@ class HomeController extends GetxController { } } - /// 切换“播报打断”开关 - /// 同步保存到本地,并通知原生服务。 Future toggleTtsInterrupt() async { try { isTtsInterruptEnabled.toggle(); @@ -216,44 +111,11 @@ class HomeController extends GetxController { } } - /// 从本地存储加载最后连接的设备名称 - void _loadLastConnectedDeviceName() { - _lastConnectedDeviceName = _storage.read(_lastDeviceNameKey); - Logger.d(_tag, '加载最后连接的设备名称: $_lastConnectedDeviceName'); - } - - /// 保存最后连接的设备名称到本地存储 - void _saveLastConnectedDeviceName(String deviceName) { - _lastConnectedDeviceName = deviceName; - _storage.write(_lastDeviceNameKey, deviceName); - Logger.d(_tag, '保存最后连接的设备名称: $deviceName'); - } - - /// 保存上一次连接设备的图片链接到本地存储 - void _saveLastConnectedDeviceImage(String? imageUrl) { - if (imageUrl != null && imageUrl.isNotEmpty) { - _lastConnectedDeviceImage = imageUrl; - _storage.write(_lastDeviceImageKey, imageUrl); - Logger.d(_tag, '保存上一次连接设备的图片: $imageUrl'); - } - } - - Future refreshDeviceConnection() async { - await _checkConnectionStatus(); - - // 如果设备已连接,主动刷新设备信息 - if (_isFullyConnected.value) { - try { - Logger.d(_tag, '刷新设备信息'); - await _bleManager.fetchDeviceInfo(); - } catch (e) { - Logger.e(_tag, '刷新设备信息失败: $e'); - } - } - } + /// 刷新设备状态(点刷新按钮 / 定时) + Future refreshDeviceConnection() => DeviceHub.to.refreshAll(); void rescanDevices() { - Get.toNamed(Routes.devices); + Get.toNamed(Routes.deviceCategory); } void openTranslation() { @@ -262,32 +124,23 @@ class HomeController extends GetxController { } /// 翻译跳转:按 translationId 进入对应翻译模式 - /// 逻辑参考 TranslationMiddlewareController.navigateToTranslationFeature /// translationId 取值:simultaneous / faceToFace / call / audioVideo - /// 其中 call、audioVideo 依赖设备连接,未连接则提示并返回 + /// 其中 call 依赖设备连接且要正处于通话中 void openTranslationFeature(String translationId) { if (IdVerifyGuard.blocked()) return; - // 只有通话翻译必须依赖耳机链路;多媒体翻译走手机摄像头/麦克风,无需连接设备 - final bool needBle = translationId == 'call'; - // 三条链路任意一条连上都算已连接(杰理 / 蓝讯支架 / 恒玄耳机)。 - // 之前只看 _isFullyConnected(杰理专用),恒玄耳机连上了照样被拦。 - // 通话翻译还要求正处于通话中——耳机得先进通话模式(0xD1)才有双路音频 - // ⚠️ 下面三条一律走 ToastThrottle 而不是裸 Get.snackbar: - // 它们拦的都是「这个功能现在就是用不了」,而卡片只是灰着、点得动, - // 用户会连点几下试——每点一下补一条一模一样的提示就把屏幕糊满了, - // 而且 Get 的 snackbar 会排队,要等它们逐条超时才散得干净。 - // 信息量只在第一条,后面全是噪音。 - if (needBle && DeviceConnection.any && !DeviceConnection.inCall) { + final bool needDevice = translationId == 'call'; + // 下面三条一律走 ToastThrottle:卡片只是灰着、点得动,用户会连点几下试 + if (needDevice && DeviceConnection.any && !DeviceConnection.inCall) { ToastThrottle.show(ToastThrottle.keyCallRequired, title: 'callRequiredTitle'.tr, message: 'callRequiredDesc'.tr, window: ToastThrottle.gateWindow); return; } - if (needBle && !DeviceConnection.any) { + if (needDevice && !DeviceConnection.any) { ToastThrottle.show(ToastThrottle.keyConnectDevice, - title: 'connectDevice'.tr, // 连接设备 - message: 'pleaseConnectDeepSoundDevice'.tr, // 请连接 Eaimar 设备 + title: 'connectDevice'.tr, + message: 'pleaseConnectDeepSoundDevice'.tr, window: ToastThrottle.gateWindow); return; } @@ -303,7 +156,7 @@ class HomeController extends GetxController { arguments: {'translationId': translationId}); } - /// 打开通话录音:与首页卡片置灰条件统一,未连接设备则提示并返回 + /// 打开通话录音:与卡片置灰条件统一,未连接设备/不在通话中则提示并返回 void openCallRecording() { if (IdVerifyGuard.blocked()) return; if (DeviceConnection.any && !DeviceConnection.inCall) { @@ -315,8 +168,8 @@ class HomeController extends GetxController { } if (!DeviceConnection.any) { ToastThrottle.show(ToastThrottle.keyConnectDevice, - title: 'connectDevice'.tr, // 连接设备 - message: 'pleaseConnectDeepSoundDevice'.tr, // 请连接 Eaimar 设备 + title: 'connectDevice'.tr, + message: 'pleaseConnectDeepSoundDevice'.tr, window: ToastThrottle.gateWindow); return; } @@ -324,10 +177,7 @@ class HomeController extends GetxController { arguments: {'title': 'agentCallRecording', 'folder': 'call_recordings'}); } - /// 打开现场录音。 - /// - /// 不再要求先连设备:连上了走设备录音通路,没连就用手机麦克风 - /// (具体分支见 CallRecordingController._startRecording)。 + /// 打开现场录音。不要求先连设备:连上了走设备录音通路,没连就用手机麦克风。 void openFieldRecording() { if (DeviceConnection.isBlockedByCall('fieldRecording')) { ToastThrottle.show(ToastThrottle.keyInCallBusy, @@ -340,322 +190,42 @@ class HomeController extends GetxController { Get.toNamed(Routes.fieldRecording, arguments: { 'title': 'agentFieldRecording', 'folder': 'field_recordings', - // 录完归档进语音记录助手,由它统一管理并上云 'archiveToMeeting': true, }); } /// 打开会议模块 - /// 参数:无 - /// 返回值:`Future` 无返回数据 - /// 异常:不显式抛出;内部仅记录日志 - /// 说明:进入会议页前关闭唤醒;返回首页后根据录音状态决定是否开启唤醒(存在录音任务则不启用) void openMeet() { if (IdVerifyGuard.blocked()) return; AgentService.interruptCurrentResponse(); Get.toNamed(Routes.meeting); } - Future _checkConnectionStatus() async { - try { - // 直接问杰理 SDK 当前已连接设备 - JieliDevice? sdkDevice; - try { - sdkDevice = await Jielihome.instance.connectedDevice(); - } catch (e) { - Logger.w(_tag, '查询杰理已连接设备失败: $e'); - sdkDevice = null; - } - final hasBleConnection = - sdkDevice != null && sdkDevice.address.isNotEmpty; - bool wasConnected = _isFullyConnected.value; - bool isNowConnected = hasBleConnection; - - if (_isFullyConnected.value != isNowConnected) { - _isFullyConnected.value = isNowConnected; - _isFullyConnected.refresh(); - //update(); - - if (!wasConnected && isNowConnected) { - // 连接成功后等待1秒再获取设备信息 - Future.delayed(const Duration(seconds: 1), () async { - await _bleManager.fetchDeviceInfo(); - }); - - // 移除了连接成功后的电池信息刷新 - - // 连接成功后检查OTA升级 - if (noviceGuideService.isHomeGuide) { - Future.delayed(const Duration(seconds: 1), () { - _checkOtaUpgrade(); - }); - } - } - // 移除了根据连接状态控制定时器的逻辑 - } - - //update(); - } catch (e) { - Logger.e(_tag, '${'checkConnectionStatusFailed'.tr}: $e'); - if (_isFullyConnected.value) { - _isFullyConnected.value = false; - _isFullyConnected.refresh(); - // 移除了发生错误时停止定时器的逻辑 - // 注意:不再重置OTA检查状态,因为现在由全局服务管理 - //update(); - } - } - } - - Color getConnectionStatusColor() { - return _isFullyConnected.value ? Colors.green : Colors.grey; - } + Color getConnectionStatusColor() => + isFullyConnected ? Colors.green : Colors.grey; - String getConnectionStatusText() { - return _isFullyConnected.value ? 'connected'.tr : 'unconnected'.tr; - } - - bool get shouldShowDeviceInfo => currentDevice != null; + String getConnectionStatusText() => + isFullyConnected ? 'connected'.tr : 'unconnected'.tr; + /// 当前(或最近一次)连接设备的名字;从没连过返回「未知设备」 String getDeviceName() { - if (_isFullyConnected.value && currentDevice == null) { - _checkConnectionStatus(); - } - - final deviceName = currentDevice?.name; - //final deviceName = '102'; - if (deviceName != null && deviceName.isNotEmpty) { - return deviceName; - } - - final bleDeviceName = deviceInfo.value?.name; - if (bleDeviceName != null && bleDeviceName.isNotEmpty) { - return bleDeviceName; - } - - if (!_isFullyConnected.value && - _lastConnectedDeviceName != null && - _lastConnectedDeviceName!.isNotEmpty) { - return _lastConnectedDeviceName!; - } - - return 'unknownEquipment'.tr; - } - - String getSignalStrength() { - if (!_isFullyConnected.value) { - return 'zeroSignal'.tr; - } - - final random = DateTime.now().millisecondsSinceEpoch % 3; - switch (random) { - case 0: - return 'good'.tr; - case 1: - return 'fine'.tr; - default: - return 'excellent'.tr; - } - } - - String getDeviceId() { - return deviceInfo.value?.address ?? currentDevice?.address ?? 'unknown'.tr; - } - - String getBatteryText() { - return deviceInfo.value?.batteryLevel != null && - deviceInfo.value!.batteryLevel > 0 - ? '${deviceInfo.value?.batteryLevel}%' - : 'unknown'.tr; - } - - String getEarbudsText() { - if (deviceInfo.value == null || - (deviceInfo.value!.leftEarBattery <= 0 && - deviceInfo.value!.rightEarBattery <= 0 || - deviceInfo.value!.caseBattery > 100)) { - return 'unknown'.tr; - } - - String leftStatus = deviceInfo.value!.leftEarBattery > 0 - ? '${deviceInfo.value!.leftEarBattery}%' - : 'N/A'; - String rightStatus = deviceInfo.value!.rightEarBattery > 0 - ? '${deviceInfo.value!.rightEarBattery}%' - : 'N/A'; - - leftStatus = - deviceInfo.value!.leftEarCharging ? '$leftStatus ⚡' : leftStatus; - rightStatus = - deviceInfo.value!.rightEarCharging ? '$rightStatus ⚡' : rightStatus; - - return '${'leftEar'.tr}: $leftStatus / ${'rightEar'.tr}: $rightStatus'; // 左耳: xxx / 右耳: xxx - } - - String getCaseText() { - if (deviceInfo.value == null || - deviceInfo.value!.caseBattery <= 0 || - deviceInfo.value!.caseBattery > 100) { - return 'unknown'.tr; - } - - String caseStatus = '${deviceInfo.value!.caseBattery}%'; - if (deviceInfo.value!.caseCharging) { - caseStatus = '$caseStatus ⚡'; - } - - return '${'chargingCase'.tr}: $caseStatus'; // 充电仓: xxx - } - - String getChargingText() { - if (deviceInfo.value == null) { - return 'unknown'.tr; - } - - bool isCharging = deviceInfo.value!.leftEarCharging || - deviceInfo.value!.rightEarCharging || - deviceInfo.value!.caseCharging; - - return isCharging ? 'charging'.tr : 'notCharging'.tr; + final n = DeviceHub.to.lastKnownName; + return n.isNotEmpty ? n : 'unknownEquipment'.tr; } - /// 获取当前设备类型 - /// 返回设备类型,如果无法获取则返回未知类型 - DeviceType getCurrentDeviceType() { - try { - final deviceName = getDeviceName(); - if (deviceName == 'unknownEquipment'.tr) { - return DeviceType.unknown; - } - - // 通过AppConfig获取产品信息 - final product = AppConfig.getproduct(deviceName); - if (product != null) { - // 根据产品的devicetype字段返回对应的DeviceType - switch (product.devicetype) { - case 1: - return DeviceType.classicBluetoothHeadset; - case 2: - return DeviceType.bleBluetoothDoubleHeadset; - case 3: - return DeviceType.bleBluetoothSingleHeadset; - case 4: - return DeviceType.smartVoiceRecorder; - case 5: - return DeviceType.smartEye; - default: - return DeviceType.unknown; - } - } - - return DeviceType.unknown; - } catch (e) { - Logger.e(_tag, '获取设备类型失败: $e'); - return DeviceType.unknown; - } - } - - /// 判断当前设备是否为单端耳机 - bool isSingleHeadset() { - return getCurrentDeviceType() == DeviceType.bleBluetoothSingleHeadset || - getCurrentDeviceType() == DeviceType.smartVoiceRecorder || - getCurrentDeviceType() == DeviceType.smartEye; - } + String getDeviceId() => DeviceHub.to.primaryInfo.value?.macAddress ?? + DeviceHub.to.primaryInfo.value?.id ?? + 'unknown'.tr; String getVersionText() { - return deviceInfo.value?.firmwareVersion.isNotEmpty == true - ? deviceInfo.value!.firmwareVersion - : 'unknown'.tr; + final v = DeviceHub.to.primaryInfo.value?.firmwareVersion ?? ''; + return v.isNotEmpty ? v : 'unknown'.tr; } - // 移除了电池信息定时刷新相关方法 - - /// 检查OTA固件升级 - /// 在BLE连接成功后调用,检查是否需要升级 Future _checkOtaUpgrade() async { - Logger.d(_tag, '触发OTA升级检查...'); - - // 打印当前OTA检查状态(用于调试) - final status = _otaCheckService.getCheckStatus(); - Logger.d(_tag, 'OTA检查状态: $status'); - - // 使用全局OTA检查服务执行检查 - final success = - await _otaCheckService.performOtaCheck(_isFullyConnected.value); - - if (success) { - Logger.d(_tag, 'OTA检查已成功执行'); - } else { - Logger.d(_tag, 'OTA检查被跳过或执行失败'); - } - } - - /// 调试方法:获取OTA检查状态 - /// 可以在开发阶段用于调试 - Map getOtaCheckStatus() { - return _otaCheckService.getCheckStatus(); - } - - /// 获取当前设备的产品图片路径 - /// 如果没有配置产品图片或获取失败,返回null使用默认图片 - String? getCurrentDeviceImage() { - try { - final deviceName = getDeviceName(); - if (deviceName == 'unknownEquipment'.tr) { - Logger.d(_tag, '设备未知,使用默认图片'); - return null; - } - - // 通过AppConfig获取产品信息 - if (!AppConfig.isInitialized()) { - Logger.w(_tag, 'AppConfig未初始化,使用默认图片'); - return null; - } - - final product = AppConfig.getproduct(deviceName); - if (product != null && - product.productimage != null && - product.productimage!.isNotEmpty) { - Logger.d(_tag, '获取到设备产品图片: ${product.productimage}'); - _saveLastConnectedDeviceImage(product.productimage); - return product.productimage; - } else { - Logger.d(_tag, '设备 "$deviceName" 未配置产品图片,使用默认图片'); - return null; - } - } catch (e) { - Logger.e(_tag, '获取设备产品图片失败: $e'); - return null; - } + final success = await _otaCheckService.performOtaCheck(isFullyConnected); + Logger.d(_tag, success ? 'OTA检查已成功执行' : 'OTA检查被跳过或执行失败'); } - /// 获取设备图片的完整资源路径 - /// 处理网络图片URL和本地资源路径 - String getDeviceImagePath(bool isDarkMode) { - final productImage = getCurrentDeviceImage(); - - if (productImage != null) { - // 如果是网络图片URL - if (productImage.startsWith('http://') || - productImage.startsWith('https://')) { - return productImage; - } - // 如果是本地资源路径,确保以assets/开头 - else if (productImage.startsWith('assets/')) { - return productImage; - } - // 如果只是文件名,添加assets/images/前缀 - else { - return 'assets/images/$productImage'; - } - } - - // 使用默认耳机图片 - return 'assets/images/defaultPicture.png'; - } - - /// 判断是否为网络图片 - bool isNetworkImage(String imagePath) { - return imagePath.startsWith('http://') || imagePath.startsWith('https://'); - } + Map getOtaCheckStatus() => _otaCheckService.getCheckStatus(); } diff --git a/apps/client/lib/modules/login/controllers/login_controller.dart b/apps/client/lib/modules/login/controllers/login_controller.dart index 6dcd8eb2..89814752 100644 --- a/apps/client/lib/modules/login/controllers/login_controller.dart +++ b/apps/client/lib/modules/login/controllers/login_controller.dart @@ -789,7 +789,6 @@ class LoginController extends GetxController { 'getAppConfig: 产品[$i] ID=${product.id}, 设备名称="${product.devicename}"'); Logger().d( 'getAppConfig: 产品[$i] 厂商ID=${product.factoryid}, 设备类型=${product.devicetype}'); - Logger().d('getAppConfig: 产品[$i] 扫描UUID="${product.scanuuid}"'); Logger().d( 'getAppConfig: 产品[$i] 产品图片="${product.productimage ?? "未设置"}"'); Logger().d('getAppConfig: 产品[$i] 版本="${product.version ?? "未设置"}"'); @@ -819,9 +818,7 @@ class LoginController extends GetxController { // 验证AppConfig中的产品数据 final productNames = AppConfig.allproductNames(); - final productUuids = AppConfig.allproductUuids(); Logger().d('getAppConfig: 可用产品名称列表 = $productNames'); - Logger().d('getAppConfig: 可用产品UUID列表 = $productUuids'); Logger().d('getAppConfig: 应用配置获取和初始化成功完成'); diff --git a/apps/client/lib/modules/main_tab/views/tabs/ai_tab.dart b/apps/client/lib/modules/main_tab/views/tabs/ai_tab.dart index 3fbccb0a..3b87bd74 100644 --- a/apps/client/lib/modules/main_tab/views/tabs/ai_tab.dart +++ b/apps/client/lib/modules/main_tab/views/tabs/ai_tab.dart @@ -82,10 +82,8 @@ class AiTab extends StatelessWidget { final modules = service.enabledModules; final bool editing = tabCtrl.isEditingAi.value; // 三条链路任意一条连上都算已连接:之前只看了杰理那条, - // 恒玄耳机/蓝讯支架连上后依赖设备的卡片还是灰的。 - final bool connected = home.isFullyConnectedRx.value || - DeviceConnection.holderConnected || - DeviceConnection.besConnected; + // 恒玄耳机/EaiRec 支架连上后依赖设备的卡片还是灰的。 + final bool connected = DeviceConnection.any; if (modules.isEmpty) return _buildEmpty(isDarkMode, tabCtrl); diff --git a/apps/client/lib/modules/main_tab/views/tabs/device_tab.dart b/apps/client/lib/modules/main_tab/views/tabs/device_tab.dart index 2ec9333a..68b054b7 100644 --- a/apps/client/lib/modules/main_tab/views/tabs/device_tab.dart +++ b/apps/client/lib/modules/main_tab/views/tabs/device_tab.dart @@ -1,39 +1,36 @@ +import 'package:device_plugin_interface/device_plugin_interface.dart'; import 'package:flutter/material.dart'; -import '../../../../core/utils/device_connection.dart'; import 'package:flutter_screenutil/flutter_screenutil.dart'; -import 'package:echomeet_device_sdk/echomeet_device_sdk.dart'; import 'package:get/get.dart'; +import '../../../../core/theme/app_palette.dart'; +import '../../../../core/utils/device_connection.dart'; import '../../../../data/models/user_Info.dart'; +import '../../../../devices/device_hub.dart'; import '../../../../routes/app_routes.dart'; -import '../../../home/controllers/home_controller.dart'; -import '../../../../data/services/holder_device_service.dart'; -import '../../../../data/services/bes_bluetooth_service.dart'; import '../widgets/battery_indicator.dart'; import '../widgets/tab_scaffold.dart'; -import '../../../../core/theme/app_palette.dart'; /// 设备页 /// /// 顶部标题栏左侧是「添加设备」的圆形加号;下面是设备图片展示区,图片四角 -/// 叠信息:右上=连接状态,左下=MAC + 授权码,右下=电量(单/双电池自适应)。 +/// 叠信息:右上=连接状态,左下=授权码,右下=MAC + 电量(单/双电池自适应)。 +/// +/// **不认厂商**:所有信息来自 [DeviceHub] 的主设备快照([DeviceInfo]), +/// 电量按 [DeviceBattery] 里有什么显示什么。 class DeviceTab extends StatelessWidget { const DeviceTab({Key? key}) : super(key: key); /// 设备图片区高度:不论连没连上都占住,避免连接前后布局跳动 static const double _imageAreaHeight = 300; - /// 底部信息(左下 MAC / 右下电量)的专属高度。 - /// - /// 这两块原先都贴在 `bottom: 0`,与居中的耳机主图压在一起、挡住图片下沿。 - /// 这里把它们下移到图片区之外:图片仍在上方 [_imageAreaHeight] 内居中 - /// (位置和大小都不变),整块区域高度加上这一段。 + /// 底部信息(左下授权码 / 右下电量)的专属高度,免得压在耳机主图上 static const double _bottomInfoInset = 28; @override Widget build(BuildContext context) { final bool isDarkMode = Theme.of(context).brightness == Brightness.dark; - final HomeController controller = Get.find(); + final hub = DeviceHub.to; return TabScaffold( slogan: 'sloganDevice'.tr, @@ -41,22 +38,14 @@ class DeviceTab extends StatelessWidget { leading: _buildAddButton(isDarkMode), titleOnRight: true, child: Obx(() { - // 三条设备通路都算「已连接」,彼此独立: - // 杰理耳机(BleManager)+ 蓝讯支架(echomeet SDK)+ 恒玄耳机(经典蓝牙) - final bool holderConnected = - Get.isRegistered() && - HolderDeviceService.to.connection.value == - EchoMeetConnectionState.connected; - final bool besConnected = Get.isRegistered() && - BesBluetoothService.to.deviceStatus.value == 2; - final bool connected = controller.isFullyConnectedRx.value || - holderConnected || - besConnected; + // revision 随任何会话的 info 变化 +1,读一下让 Obx 收集到它 + hub.revision.value; + final bool connected = hub.anyConnected.value; + final DeviceInfo? info = hub.primaryInfo.value; return ListView( padding: EdgeInsets.symmetric(horizontal: 16.w, vertical: 8.h), children: [ - _buildImageArea( - isDarkMode, connected, holderConnected, besConnected, controller), + _buildImageArea(isDarkMode, connected, info), SizedBox(height: 20.h), _buildEntry( isDarkMode: isDarkMode, @@ -67,13 +56,18 @@ class DeviceTab extends StatelessWidget { onTap: () => Get.toNamed(Routes.myDevices), ), SizedBox(height: 12.h), + // 固件升级入口**始终显示**:进去后由 DeviceOtaController 按 + // 「有没有在线设备 / 它有没有 otaPort」给出具体提示。 + // ⚠️ 别再按 `hub.has(DeviceCapability.ota)` 来显隐——那样写有个洞: + // 设备连着、但它不支持 OTA 时,「有能力」和「没连设备」两个分支 + // 都不满足,入口会**凭空消失**(2026-09-10 支架就这么丢过入口)。 _buildEntry( isDarkMode: isDarkMode, icon: Icons.system_update_alt, iconColor: AppPalette.warning, title: 'firmwareUpgrade'.tr, // 固件升级 - subtitle: 'bleHeadsetFirmwareUpgrade'.tr, // BLE耳机固件版本升级 - onTap: () => Get.toNamed(Routes.OTA_UPGRADE), + subtitle: 'bleHeadsetFirmwareUpgrade'.tr, + onTap: () => Get.toNamed(Routes.otaUpgrade), ), ], ); @@ -93,13 +87,10 @@ class DeviceTab extends StatelessWidget { width: 30.w, height: 30.w, decoration: BoxDecoration( - // 淡蓝底 + 深蓝加号:白色加号在 accentLight 上对比度只有 1.2:1, - // 换成 accent 才看得清(3.8:1,过了图形元素 3:1 的线)。 color: AppPalette.accentLight, shape: BoxShape.circle, boxShadow: [ BoxShadow( - // 底色淡了,投影跟着收一档,否则比按钮本身还重 color: AppPalette.accent.withOpacity(0.18), blurRadius: 8, offset: const Offset(0, 2), @@ -123,110 +114,56 @@ class DeviceTab extends StatelessWidget { } /// 设备图片展示区 + 四角信息浮层 - Widget _buildImageArea( - bool isDarkMode, - bool connected, - bool holderOn, - bool besOn, - HomeController controller, - ) { + Widget _buildImageArea(bool isDarkMode, bool connected, DeviceInfo? info) { return SizedBox( height: (_imageAreaHeight + _bottomInfoInset).h, child: Stack( children: [ - // 图片只占上方 _imageAreaHeight,底部那段留给 MAC / 电量, - // 免得信息压在耳机主图上 Positioned( left: 0, right: 0, top: 0, bottom: _bottomInfoInset.h, - child: Center( - child: _buildImage( - isDarkMode, connected, holderOn, besOn, controller)), + child: Center(child: _buildImage(connected)), ), - // 右上:连接状态 Positioned( right: 0, top: 0, child: _buildStatusChip(isDarkMode, connected), ), - // 左下:MAC + 授权码(支架模式下整块隐藏,信息在右下角) Positioned( left: 0, bottom: 0, - child: _buildIdentityInfo( - isDarkMode, connected, holderOn, besOn, controller), + child: _buildIdentityInfo(isDarkMode, connected, info), ), - // 右下:电量 Positioned( right: 0, bottom: 0, - child: _buildBatteryInfo( - isDarkMode, connected, holderOn, besOn, controller), + child: _buildBatteryInfo(isDarkMode, connected, info), ), ], ), ); } - Widget _buildImage(bool isDarkMode, bool connected, bool holderOn, - bool besOn, HomeController controller) { - // 蓝讯支架不在 AppConfig 的杰理产品表里,getDeviceImagePath 查不到会退回 - // defaultPicture(那张蓝色机器人图),这里直接用产品实拍图 - if (holderOn) { - // 按当前连接设备取主图——连哪台就显示哪台。 - // 取图逻辑统一在 DeviceConnection,固件升级页用的是同一份。 - return Image.asset( - DeviceConnection.currentAssetImage(), - fit: BoxFit.contain, - errorBuilder: (_, __, ___) => const SizedBox.shrink(), - ); - } - if (besOn) { - return Image.asset( + Widget _buildImage(bool connected) { + // 按当前(或最近连过的)设备取主图,取图逻辑统一在 DeviceConnection。 + // 断连后仍显示最近连接过的那台设备的图,只是整体压暗一点表示未连接; + // 从没连过任何设备时展示的是通用图,再压暗会让人以为是坏图。 + final bool dim = !connected && DeviceConnection.hasKnownDevice; + return Opacity( + opacity: dim ? 0.55 : 1.0, + child: Image.asset( DeviceConnection.currentAssetImage(), fit: BoxFit.contain, errorBuilder: (_, __, ___) => const SizedBox.shrink(), - ); - } - if (!connected) { - // 断连后仍显示最近连接过的那台设备的图,只是整体压暗一点表示未连接; - // 从没连过任何设备时才退回默认产品图。 - // 压暗只对「连过、现在断了」生效;从没连过设备时展示的是通用图, - // 再压暗会让人以为是坏图。 - final bool known = DeviceConnection.hasKnownDevice; - return Opacity( - opacity: known ? 0.55 : 1.0, - child: Image.asset( - DeviceConnection.currentAssetImage(), - fit: BoxFit.contain, - errorBuilder: (_, __, ___) => const SizedBox.shrink(), - ), - ); - } - final String path = controller.getDeviceImagePath(isDarkMode); - final bool isNetwork = controller.isNetworkImage(path); - if (!isNetwork) return Image.asset(path, fit: BoxFit.contain); - return Image.network( - path, - fit: BoxFit.contain, - // 网络图加载失败/加载中都退回本地参考图,避免出现破图图标; - // 手机支架已下架,通用兜底图统一用耳机图 - errorBuilder: (_, __, ___) => Image.asset( - DeviceConnection.currentAssetImage(), - fit: BoxFit.contain, ), - loadingBuilder: (context, child, progress) => progress == null - ? child - : const Center(child: CircularProgressIndicator(strokeWidth: 2)), ); } /// 右上角:设备已连接 / 未连接 Widget _buildStatusChip(bool isDarkMode, bool connected) { - final Color c = - connected ? AppPalette.success : AppPalette.brand400; + final Color c = connected ? AppPalette.success : AppPalette.brand400; return Container( padding: EdgeInsets.symmetric(horizontal: 10.w, vertical: 5.h), decoration: BoxDecoration( @@ -256,33 +193,14 @@ class DeviceTab extends StatelessWidget { ); } - /// 左下角:MAC 地址 + 授权码(license) - Widget _buildIdentityInfo(bool isDarkMode, bool connected, bool holderOn, - bool besOn, HomeController controller) { - if (!connected) return const SizedBox.shrink(); - // 支架 / 恒玄耳机模式下 MAC 已在右下角显示,左下角不再重复 - if (holderOn || besOn) return const SizedBox.shrink(); - final Color textColor = - isDarkMode ? Colors.white70 : AppPalette.brand500; - final String mac = controller.getDeviceId(); - final String license = _licenseForMac(mac); - - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisSize: MainAxisSize.min, - children: [ - _infoLine(Icons.memory, mac, textColor), - // ⚠️ 没有授权码就整行不显示,**不要**退回显示「授权码未知」。 - // 经典蓝牙耳机(恒玄)这条链路压根没有授权码:绑定时 code 传空, - // 服务端是按 MAC 去 license 表反查的(api_binddevice.go), - // 所以 DBUserDivice.License 恒为空串。显示「未知」会让人以为 - // 「本该有、只是没读到」,跑去查一个根本不存在的东西。 - if (license.isNotEmpty) ...[ - SizedBox(height: 3.h), - _infoLine(Icons.vpn_key_outlined, license, textColor), - ], - ], - ); + /// 左下角:授权码(license)。没有就整行不显示——经典蓝牙耳机压根没有授权码, + /// 显示「未知」会让人以为「本该有、只是没读到」。 + Widget _buildIdentityInfo(bool isDarkMode, bool connected, DeviceInfo? info) { + if (!connected || info == null) return const SizedBox.shrink(); + final Color textColor = isDarkMode ? Colors.white70 : AppPalette.brand500; + final String license = _licenseForMac(info.macAddress ?? info.id); + if (license.isEmpty) return const SizedBox.shrink(); + return _infoLine(Icons.vpn_key_outlined, license, textColor); } Widget _infoLine(IconData icon, String text, Color color) { @@ -303,152 +221,83 @@ class DeviceTab extends StatelessWidget { ); } - /// 已绑定设备列表里按 MAC 找授权码(忽略冒号和大小写) + /// 已绑定设备列表里按 MAC 找授权码(忽略冒号和大小写)。 + /// + /// ⚠️ 授权码**等于 MAC** 时返回空串——那是后台「导入 MAC」模式留下的占位值, + /// 照显示会在设备页上出现两行一模一样的串(一行钥匙图标、一行 MAC), + /// 看着像同一个 MAC 显示了两遍。判据见 [UserDevice.hasRealLicense], + /// 与「我的设备」页共用同一份规则。 String _licenseForMac(String mac) { if (!User.isLoggedIn()) return ''; final key = mac.replaceAll(':', '').toUpperCase(); if (key.isEmpty) return ''; for (final d in User.instance.devices) { if (d.devicemac.replaceAll(':', '').toUpperCase() == key) { - return d.license; + return d.hasRealLicense ? d.license : ''; } } return ''; } - /// 右下角:电量。双电池(左右耳都有数据)显示两个电池,否则显示一个。 - Widget _buildBatteryInfo(bool isDarkMode, bool connected, bool holderOn, - bool besOn, HomeController controller) { - if (!connected) return const SizedBox.shrink(); - return Obx(() { - // 恒玄耳机:电量随 `BB 0A` 状态帧上报(左/右/仓分开,带充电位) - if (besOn) { - final Color c = - isDarkMode ? Colors.white70 : const Color(0xFF444444); - final bes = BesBluetoothService.to; - // ⚠️ 别直接用 connectedDeviceId:那在 iOS 上是 CoreBluetooth 的 - // peripheral UUID(每台 iPhone 都不一样),显示出来根本不是 MAC。 - // 耳机在 `BB 0A` 属性 7 里自报的地址两端一致,优先用它; - // 它要等一帧状态回包才有值,那之前先拿 connectedDeviceId 顶着 - // (Android 上本来就等于最终值,iOS 上会在收到帧后自动刷成真 MAC)。 - final reported = bes.deviceMac.value; - final mac = reported.isNotEmpty ? reported : bes.connectedDeviceId.value; - final battery = bes.batteryLevel.value; - final left = bes.batteryLeft.value; - final right = bes.batteryRight.value; - final box = bes.batteryCase.value; - if (mac.isEmpty && battery < 0) { - return const SizedBox.shrink(); - } - // 固件版本不在这里显示——固件升级页(Routes.OTA_UPGRADE)已经有了。 - // 也不套半透明底:直接压在耳机主图上。 - return Column( - crossAxisAlignment: CrossAxisAlignment.end, - mainAxisSize: MainAxisSize.min, - children: [ - if (mac.isNotEmpty) _infoLine(Icons.memory, mac, c), - // 电量一行排开,顺序固定 C → L → R。 - // 每个都可能缺:盒电量只在耳机入盒时上报(属性 3 是可选的), - // 左右耳只有单侧数据时退化成一个不带标的电池。 - if (box >= 0 || left >= 0 || right >= 0 || battery >= 0) ...[ - SizedBox(height: 4.h), - Row( - mainAxisSize: MainAxisSize.min, - // 用 spacing 而不是手动插 SizedBox:只有 C 一个电池时, - // 尾随的间距会把它整体推离右边缘。 - spacing: 8.w, - children: [ - if (box >= 0) - BatteryIndicator( - level: box, - label: 'C', - isDarkMode: isDarkMode, - charging: bes.chargingCase.value, - ), - if (left >= 0 && right >= 0) ...[ - BatteryIndicator( - level: left, - label: 'L', - isDarkMode: isDarkMode, - charging: bes.chargingLeft.value, - ), - BatteryIndicator( - level: right, - label: 'R', - isDarkMode: isDarkMode, - charging: bes.chargingRight.value, - ), - ] else if (battery >= 0) - BatteryIndicator( - level: battery, - isDarkMode: isDarkMode, - charging: - bes.chargingLeft.value || bes.chargingRight.value, - ), - ], - ), - ], - ], - ); - } - // 蓝讯支架是单电池设备,直接用 SDK 上报的电量 - if (holderOn) { - final info = HolderDeviceService.to.deviceInfo.value; - final mac = HolderDeviceService.to.connectedAddress; - return Column( - crossAxisAlignment: CrossAxisAlignment.end, - mainAxisSize: MainAxisSize.min, - children: [ - if (mac.isNotEmpty) ...[ - _infoLine(Icons.memory, mac, - isDarkMode ? Colors.white70 : const Color(0xFF444444)), - SizedBox(height: 4.h), - ], - BatteryIndicator( - level: info?.batteryLevel ?? 0, - isDarkMode: isDarkMode, - charging: info?.isCharging ?? false, - ), - ], - ); - } - - final int left = HomeController.leftEarBattery.value; - final int right = HomeController.rightEarBattery.value; - final bool dual = left > 0 || right > 0; - - if (dual) { - return Column( - crossAxisAlignment: CrossAxisAlignment.end, - mainAxisSize: MainAxisSize.min, - children: [ - BatteryIndicator( - level: left, - label: 'L', - isDarkMode: isDarkMode, - charging: controller.leftEarCharging.value, - ), - SizedBox(height: 4.h), - BatteryIndicator( - level: right, - label: 'R', - isDarkMode: isDarkMode, - charging: controller.rightEarCharging.value, - ), - ], - ); - } + /// 右下角:MAC + 电量。电量按快照里有什么显示什么: + /// C → L → R 一行排开;只有单侧或单体设备退化成一个不带标的电池。 + Widget _buildBatteryInfo(bool isDarkMode, bool connected, DeviceInfo? info) { + if (!connected || info == null) return const SizedBox.shrink(); + final Color c = isDarkMode ? Colors.white70 : const Color(0xFF444444); + // ⚠️ 别直接用 id:那在 iOS 上是 CoreBluetooth 的 peripheral UUID。 + // macAddress 是设备自报/系统给的真 MAC,拿不到时先拿 id 顶着。 + final mac = info.macAddress ?? info.id; + final b = info.battery; + final int? box = b.caseBox; + final int? left = b.left; + final int? right = b.right; + final int? single = b.summary; + if (mac.isEmpty && single == null) return const SizedBox.shrink(); - // 单电池:优先整机电量,没有就用充电仓电量 - final info = controller.deviceInfo.value; - final int single = - (info?.batteryLevel ?? 0) > 0 ? info!.batteryLevel : controller.caseBattery.value; - return BatteryIndicator( - level: single, - isDarkMode: isDarkMode, - charging: controller.caseCharging.value, - ); - }); + return Column( + crossAxisAlignment: CrossAxisAlignment.end, + mainAxisSize: MainAxisSize.min, + children: [ + if (mac.isNotEmpty) _infoLine(Icons.memory, mac, c), + if (single != null) ...[ + SizedBox(height: 4.h), + Row( + mainAxisSize: MainAxisSize.min, + spacing: 8.w, + children: [ + if (box != null) + BatteryIndicator( + level: box, + label: 'C', + isDarkMode: isDarkMode, + charging: b.chargingCase, + ), + if (left != null && right != null) ...[ + BatteryIndicator( + level: left, + label: 'L', + isDarkMode: isDarkMode, + charging: b.chargingLeft, + ), + BatteryIndicator( + level: right, + label: 'R', + isDarkMode: isDarkMode, + charging: b.chargingRight, + ), + ] else + BatteryIndicator( + level: single, + isDarkMode: isDarkMode, + charging: b.chargingLeft || + b.chargingRight || + b.chargingSingle, + ), + ], + ), + ], + ], + ); } Widget _buildEntry({ @@ -460,10 +309,8 @@ class DeviceTab extends StatelessWidget { Color iconColor = AppPalette.accent, }) { final Color cardBg = isDarkMode ? AppPalette.brand800 : Colors.white; - final Color titleColor = - isDarkMode ? Colors.white : AppPalette.brand900; - final Color subColor = - isDarkMode ? Colors.white60 : AppPalette.brand400; + final Color titleColor = isDarkMode ? Colors.white : AppPalette.brand900; + final Color subColor = isDarkMode ? Colors.white60 : AppPalette.brand400; return GestureDetector( behavior: HitTestBehavior.opaque, onTap: onTap, diff --git a/apps/client/lib/modules/main_tab/views/widgets/module_card.dart b/apps/client/lib/modules/main_tab/views/widgets/module_card.dart index 9be35771..119c31f6 100644 --- a/apps/client/lib/modules/main_tab/views/widgets/module_card.dart +++ b/apps/client/lib/modules/main_tab/views/widgets/module_card.dart @@ -1,16 +1,14 @@ import 'dart:async'; import 'package:flutter/material.dart'; -import '../../../../routes/app_routes.dart'; import 'package:flutter_screenutil/flutter_screenutil.dart'; import 'package:get/get.dart'; -import '../../../../core/utils/ai_navigation.dart'; import '../../../../core/utils/id_verify_guard.dart'; import '../../../../core/utils/device_connection.dart'; import '../../../../core/utils/toast_throttle.dart'; -import '../../../../data/services/bes_bluetooth_service.dart'; +import '../../../../devices/device_hub.dart'; import '../../../../data/models/agent_module.dart'; -import '../../../home/controllers/home_controller.dart'; +import '../../../module_registry.dart'; import '../../../../core/theme/app_palette.dart'; /// 模块卡片的统一渲染 + 点击分发。 @@ -301,7 +299,7 @@ class ModuleSquareCard extends StatelessWidget { } } -/// 模块点击分发:复用 HomeController 里已有的跳转逻辑(含未连接设备的提示) +/// 模块点击分发:闸门在这里,跳转交给模块描述符 void launchModule(AgentModule module) { // 所有功能一律要求先实名(**游客同样走这条**,不再按游客身份拦死): // 未实名时拦下并直接弹认证窗,认证通过后用户再点一次即可 @@ -328,42 +326,18 @@ void launchModule(AgentModule module) { } // 每次进功能都刷一次耳机状态(版本/按键/EQ)——耳机可能在后台被摘下、 // 重连过或改过配置,只在连接时查一次会用到陈旧数据。 - if (DeviceConnection.besConnected) { - unawaited(BesBluetoothService.to.refreshDeviceState()); + if (DeviceConnection.any) { + unawaited(DeviceHub.to.refreshAll()); } - final home = Get.find(); - switch (module.id) { - case AgentModules.meeting: - home.openMeet(); - break; - case AgentModules.emaiAssistant: - // 与原来那颗 AI 球同一套跳转逻辑(按 agentType 走移动精灵或通用 agent 页) - navigateToAIAssistant(); - break; - case AgentModules.callRecording: - home.openCallRecording(); - break; - case AgentModules.fieldRecording: - home.openFieldRecording(); - break; - case AgentModules.textTranslation: - Get.toNamed(Routes.textTranslation); - break; - case AgentModules.pictureTranslation: - Get.toNamed(Routes.pictureTranslation); - break; - case AgentModules.simultaneous: - case AgentModules.faceToFace: - case AgentModules.call: - case AgentModules.audioVideo: - home.openTranslationFeature(module.id); - break; - default: - Get.snackbar( - module.title.tr, - 'featureNotWired'.tr, - snackPosition: SnackPosition.TOP, - duration: const Duration(seconds: 2), - ); + // 跳转由模块描述符自带(onLaunch / entryRoute),不再在这里 switch。 + // 找不到描述符或描述符没配跳转 = 模块忘了接线,直接提示而不是静默。 + final desc = ModuleRegistry.byId(module.id); + if (desc == null || !desc.launch()) { + Get.snackbar( + module.title.tr, + 'featureNotWired'.tr, + snackPosition: SnackPosition.TOP, + duration: const Duration(seconds: 2), + ); } } diff --git a/apps/client/lib/modules/meeting/controllers/meeting_home_controller.dart b/apps/client/lib/modules/meeting/controllers/meeting_home_controller.dart index 31f5f63a..55edea9a 100644 --- a/apps/client/lib/modules/meeting/controllers/meeting_home_controller.dart +++ b/apps/client/lib/modules/meeting/controllers/meeting_home_controller.dart @@ -12,7 +12,6 @@ import 'package:permission_handler/permission_handler.dart'; import '../../../core/utils/logger.dart'; import '../../../core/utils/synchrodata.dart'; -import '../../../data/services/ble_manager.dart'; import '../../../data/services/db/sqflite_api.dart'; import '../../../core/utils/permission_util.dart'; @@ -105,8 +104,6 @@ class MeetingHomeController extends GetxController { exitSelectionMode(); } - // 蓝牙服务 - final bleManager = Get.find(); late final Worker _updateListStateListener; late final Worker _localImportListener; diff --git a/apps/client/lib/modules/meeting/controllers/meeting_record_controller.dart b/apps/client/lib/modules/meeting/controllers/meeting_record_controller.dart index 5888dac9..b563f692 100644 --- a/apps/client/lib/modules/meeting/controllers/meeting_record_controller.dart +++ b/apps/client/lib/modules/meeting/controllers/meeting_record_controller.dart @@ -18,9 +18,8 @@ import 'package:permission_handler/permission_handler.dart'; import 'package:flutter_local_notifications/flutter_local_notifications.dart'; import '../../../routes/app_routes.dart'; import '../../../data/services/asr_service.dart'; -import '../../../data/services/ble_manager.dart'; import '../../../data/services/meeting/meeting_task_service.dart'; -import '../utils/jieli_call_recorder.dart'; +import '../../../data/services/device_stereo_recorder.dart'; /// 会议记录状态枚举 enum TimerStatus { @@ -335,7 +334,6 @@ class MeetingRecordController extends GetxController // 服务 final AsrService _asrService = Get.find(); - final BleManager _bleManager = Get.find(); // 流订阅 StreamSubscription? _recognitionSubscription; @@ -373,7 +371,7 @@ class MeetingRecordController extends GetxController // 移除了未使用的变量和TabController StreamSubscription? _audioDataSubscription; - JieliCallRecorder? _jieliCallRecorder; + DeviceStereoRecorder? _jieliCallRecorder; double _smoothedRms = 0.0; double _latestRms = 0.0; Timer? _waveformTimer; @@ -481,8 +479,6 @@ class MeetingRecordController extends GetxController if (Platform.isAndroid) { // Android 关掉 MediaProjection 前台服务;onClose 不能 await,所以 fire-and-forget。 unawaited(_asrService.stopScreenCapture()); - } else { - _bleManager.closeCodec(); } } final pendingJieli = _jieliCallRecorder; @@ -617,10 +613,9 @@ class MeetingRecordController extends GetxController } if (audioType.value == 2) { - // 通话录音:走 device_jieli 设备录音通路(deviceRecordStart → - // MODE_CALL_TRANSLATION_WITH_STEREO + ch=2 + STRATEGY_DEVICE_ALWAYS_RECORDING), - // 双声道交织 PCM(L=本端 / R=对端)直写 stereo WAV,不依赖手机真实通话状态。 - _jieliCallRecorder = JieliCallRecorder( + // 通话录音:走设备的通话双路通路(需要 callAudioTap 能力), + // 双声道交织 PCM(L=本端 / R=对端)直写 stereo WAV。 + _jieliCallRecorder = DeviceStereoRecorder( onWavePcm: _processAudioData, ); final ok = await _jieliCallRecorder!.start(filePath); @@ -687,15 +682,12 @@ class MeetingRecordController extends GetxController case 1: _asrService.setAudioConfig(sampleRate: 16000, channels: 1); // Android 上音视频录音改走 MediaProjection(在 _startRecording 已发起 - // 系统授权 + 启动前台服务),不再依赖蓝牙耳机的 A2DP 解码通路。 - if (!Platform.isAndroid) { - _bleManager.openDecoder(); - } + // 系统授权 + 启动前台服务);iOS 走录屏广播扩展。 break; case 2: // 通话录音走 device_jieli 设备录音通路(deviceRecordStart, // MODE_CALL_TRANSLATION_WITH_STEREO + ch=2 + ALWAYS_RECORDING), - // 由 JieliCallRecorder 在 Dart 层接管 stereo PCM 写入与 WAV 文件生成, + // 由 DeviceStereoRecorder 在 Dart 层接管 stereo PCM 写入与 WAV 文件生成, // 不再依赖 BleManager.openCallRecordDecoder(当前为空壳实现)。 break; } @@ -828,8 +820,6 @@ class MeetingRecordController extends GetxController if (audioType.value == 1) { if (Platform.isAndroid) { await _asrService.stopScreenCapture(); - } else { - _bleManager.closeCodec(); } } @@ -896,8 +886,6 @@ class MeetingRecordController extends GetxController if (audioType.value == 1) { if (Platform.isAndroid) { await _asrService.stopScreenCapture(); - } else { - _bleManager.closeCodec(); } } diff --git a/apps/client/lib/modules/meeting/controllers/mind_map_controller.dart b/apps/client/lib/modules/meeting/controllers/mind_map_controller.dart index 8b26f20d..229e704f 100644 --- a/apps/client/lib/modules/meeting/controllers/mind_map_controller.dart +++ b/apps/client/lib/modules/meeting/controllers/mind_map_controller.dart @@ -27,6 +27,8 @@ class MindMapController extends GetxController { @override void onInit() { super.onInit(); + final String summaryJs = + _jsString(_meetingDetailsController.meetingDetails.value.summary); String html = ''' @@ -120,26 +122,38 @@ class MindMapController extends GetxController { @@ -208,10 +232,20 @@ class MindMapController extends GetxController { _summaryNumberListener = ever(_meetingDetailsController.summaryNumberModifications, (value) { webViewController.runJavaScript( - 'updateMarkmap(`${_meetingDetailsController.meetingDetails.value.summary}`)'); + 'updateMarkmap(${_jsString(_meetingDetailsController.meetingDetails.value.summary)})'); }); } + /// 把任意文本编码成一个合法的 JS 字符串字面量。 + /// + /// ⚠️ 总结正文是大模型产出的 Markdown,里头出现反引号(行内代码、甚至整段 + /// ```markdown 包裹)是常事。原先直接拼进 JS 模板串 `${summary}`,一个反引号 + /// 就让整个 ` 会提前截断脚本块。 + static String _jsString(String value) => + jsonEncode(value).replaceAll(' HomeController.to.openMeet(), + pages: [ + GetPage( + name: Routes.meeting, + page: () => const MeetingView(), + binding: MeetingBinding(), + transition: Transition.rightToLeft, + transitionDuration: const Duration(milliseconds: 250), + ), + GetPage( + name: Routes.meetingDetails, + page: () => const MeetingDetailsView(), + binding: MeetingDetailsBinding(), + transition: Transition.rightToLeft, + transitionDuration: const Duration(milliseconds: 250), + ), + GetPage( + name: Routes.meetingConnect, + page: () => const MeetingConnectView(), + binding: MeetingConnectBinding(), + transition: Transition.rightToLeft, + transitionDuration: const Duration(milliseconds: 250), + ), + GetPage( + name: Routes.meetingRecord, + page: () => const MeetingRecordView(), + binding: MeetingRecordBinding(), + popGesture: false, + transition: Transition.rightToLeft, + transitionDuration: const Duration(milliseconds: 250), + ), + ], +); diff --git a/apps/client/lib/modules/meeting/views/meeting_home_view.dart b/apps/client/lib/modules/meeting/views/meeting_home_view.dart index 650a0857..8266526b 100644 --- a/apps/client/lib/modules/meeting/views/meeting_home_view.dart +++ b/apps/client/lib/modules/meeting/views/meeting_home_view.dart @@ -5,6 +5,7 @@ import 'package:get/get.dart'; import 'package:haptic_feedback/haptic_feedback.dart'; import '../../../core/theme/app_colors.dart'; +import '../../../data/services/device_file_import_service.dart'; import '../../../routes/app_routes.dart'; import '../controllers/meeting_home_controller.dart'; import '../model/meeting_model.dart'; @@ -59,6 +60,7 @@ class _MeetingHomeViewState extends State ], ), ), + _buildDeviceSyncBanner(isDarkMode), Expanded( child: _buildBody(context, isDarkMode), ), @@ -237,6 +239,61 @@ class _MeetingHomeViewState extends State ); } + /// 「正在从设备同步录音」条。空闲时整条不占位。 + /// + /// ⚠️ 这条**不是装饰**:支架的录音在 TF 卡上,靠 BLE 拉,一个十几分钟的录音 + /// 要好几分钟。没有进度提示的话,用户看到的就是「连上了、列表里什么都没有」, + /// 和坏了没有区别。 + Widget _buildDeviceSyncBanner(bool isDarkMode) { + if (!Get.isRegistered()) { + return const SizedBox.shrink(); + } + final svc = DeviceFileImportService.to; + return Obx(() { + if (!svc.isImporting.value) return const SizedBox.shrink(); + final pct = svc.progress.value; + final name = svc.currentFile.value; + final left = svc.pending.value; + return Container( + margin: EdgeInsets.symmetric(horizontal: 12.w), + padding: EdgeInsets.symmetric(horizontal: 12.w, vertical: 8.h), + decoration: BoxDecoration( + color: isDarkMode + ? MeetingUIUtils.getTranslucentWhite(0.05) + : MeetingUIUtils.getCardColor(isDarkMode), + borderRadius: BorderRadius.circular(12.r), + ), + child: Row( + children: [ + SizedBox( + width: 14.w, + height: 14.w, + // 设备没给总长度时 progress 是 -1,交给不确定态转圈,别画一个假的 0% + child: CircularProgressIndicator( + strokeWidth: 2, + value: pct >= 0 ? pct.clamp(0.0, 1.0) : null, + ), + ), + SizedBox(width: 10.w), + Expanded( + child: Text( + '${'importing'.tr} $name' + '${left > 1 ? ' ($left)' : ''}' + '${pct >= 0 ? ' ${(pct * 100).toStringAsFixed(0)}%' : ''}', + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + fontSize: 12.sp, + color: isDarkMode ? Colors.white70 : Colors.black54, + ), + ), + ), + ], + ), + ); + }); + } + Widget _buildConnect(bool isDarkMode) { return GestureDetector( onTap: () { diff --git a/apps/client/lib/modules/module_descriptor.dart b/apps/client/lib/modules/module_descriptor.dart new file mode 100644 index 00000000..d7afba20 --- /dev/null +++ b/apps/client/lib/modules/module_descriptor.dart @@ -0,0 +1,75 @@ +import 'package:device_plugin_interface/device_plugin_interface.dart'; +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; + +import '../data/models/agent_module.dart'; +export '../data/models/assistant_directive.dart' show DirectiveHandler; +import '../data/models/assistant_directive.dart'; + +/// Agent 能力模块的描述符——**模块自带行为**,不再靠中心 switch。 +/// +/// 一个模块 = `lib/modules//module.dart` 里导出的一个描述符, +/// 在 [ModuleRegistry] 里登记一行即可: +/// - 卡片元数据(沿用 [AgentModule]:标题/副标题/图标/渐变) +/// - [pages]:模块自带的路由,`AppPages` 会把它们拼进总表 +/// - [entryRoute] / [onLaunch]:卡片点击去哪(二选一,优先 onLaunch) +/// - [requiredCapabilities]:需要在线设备具备的能力,空 = 不依赖设备 +/// - [directives]:本模块能落地的端侧指令 +/// - [translations]:模块自带的文案(按 locale,如 `zh_CN` / `en_US`), +/// 与全局语言文件合并;新模块不必再去改 41 个语言文件 +/// - [registerServices]:需要常驻服务时在这里 `Get.put` +/// +/// `test/module_registry_test.dart` 守着:id 唯一、entryRoute 已注册、 +/// 标题文案有落点。 +class AgentModuleDescriptor extends AgentModule { + AgentModuleDescriptor({ + required super.id, + required super.title, + super.subtitle, + required super.icon, + required super.gradient, + this.requiredCapabilities = const {}, + super.comingSoon = false, + this.pages = const [], + this.entryRoute, + this.onLaunch, + this.directives = const {}, + this.translations = const {}, + this.registerServices, + super.bailianAppId, + }) : super(requiresDevice: requiredCapabilities.isNotEmpty); + + /// 需要在线设备具备的能力;空集合 = 手机自己就能用 + final Set requiredCapabilities; + + /// 模块自带的页面 + final List pages; + + /// 卡片点击跳转的路由(无 [onLaunch] 时用) + final String? entryRoute; + + /// 卡片点击的自定义动作(带闸门/提示逻辑的模块用它) + final VoidCallback? onLaunch; + + /// 端侧指令 → 处理器 + final Map directives; + + /// 模块自带文案:`{'zh_CN': {...}, 'en_US': {...}}` + final Map> translations; + + /// 常驻服务注册钩子(InitialBinding 之后调用一次) + final void Function()? registerServices; + + /// 卡片点击:优先 [onLaunch],其次 [entryRoute];都没有返回 false 让调用方兜底 + bool launch() { + if (onLaunch != null) { + onLaunch!(); + return true; + } + if (entryRoute != null) { + Get.toNamed(entryRoute!); + return true; + } + return false; + } +} diff --git a/apps/client/lib/modules/module_registry.dart b/apps/client/lib/modules/module_registry.dart new file mode 100644 index 00000000..9352832d --- /dev/null +++ b/apps/client/lib/modules/module_registry.dart @@ -0,0 +1,128 @@ +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; + +import '../core/utils/logger.dart'; +import '../data/models/agent_module.dart'; +import 'call_recording/module.dart'; +import 'emai/module.dart'; +import 'meeting/module.dart'; +import 'module_descriptor.dart'; +import 'picture_translation/module.dart'; +import 'smartcar/module.dart'; +import 'text_translation/module.dart'; +import 'translation/module.dart'; + +/// **Agent 模块的唯一注册点。** +/// +/// 新增一个能力模块: +/// 1. 建 `lib/modules//`,在 `module.dart` 里导出一个 [AgentModuleDescriptor]; +/// 2. 在下面 [all] 里加一行。 +/// +/// 路由、卡片跳转、端侧指令、文案、常驻服务都由描述符自带, +/// 不用再去改 `app_pages.dart` / `module_card.dart` / `assistant_directive_service.dart` +/// / 41 个语言文件。 +class ModuleRegistry { + ModuleRegistry._(); + + static final List all = [ + meetingModule, + emaiModule, + ...translationModules, + ...callRecordingModules, + textTranslationModule, + pictureTranslationModule, + smartcarModule, + // ---------- 规划中(占位,方便测试增删) ---------- + AgentModuleDescriptor( + id: 'englishChat', + title: 'agentEnglishChat', + subtitle: 'agentEnglishChatDesc', + icon: Icons.chat_bubble_outline, + gradient: const [Color(0xFF7E57C2), Color(0xFF5E35B1)], + comingSoon: true, + ), + AgentModuleDescriptor( + id: 'englishStudy', + title: 'agentEnglishStudy', + subtitle: 'agentEnglishStudyDesc', + icon: Icons.menu_book, + gradient: const [Color(0xFF29B6F6), Color(0xFF0288D1)], + comingSoon: true, + ), + AgentModuleDescriptor( + id: 'kidsStory', + title: 'agentKidsStory', + subtitle: 'agentKidsStoryDesc', + icon: Icons.auto_stories, + gradient: const [Color(0xFFFF7043), Color(0xFFF4511E)], + comingSoon: true, + ), + ]; + + /// 启动时调用一次:把注册表交给 data 层的 [AgentModules], + /// AgentModuleService / 广场页 / 设备品类默认编排都从那里读。 + static void install() { + AgentModules.all = all; + } + + static AgentModuleDescriptor? byId(String id) { + for (final m in all) { + if (m.id == id) return m; + } + return null; + } + + /// 全部模块自带的页面(按 name 去重:同一模块的多个描述符共用一组页面) + static List get pages { + final seen = {}; + final out = []; + for (final m in all) { + for (final p in m.pages) { + if (seen.add(p.name)) out.add(p); + } + } + return out; + } + + /// 端侧指令 → 处理器。同名指令后注册的覆盖先注册的并报警。 + static Map get directiveHandlers { + final out = {}; + for (final m in all) { + for (final e in m.directives.entries) { + if (out.containsKey(e.key)) { + Logger.w('ModuleRegistry', '指令 ${e.key} 被 ${m.id} 重复注册,以后者为准'); + } + out[e.key] = e.value; + } + } + return out; + } + + /// 把各模块自带文案合并进全局语言表(模块的覆盖全局的同名 key)。 + static Map> mergeTranslations( + Map> base) { + final out = >{ + for (final e in base.entries) e.key: Map.from(e.value), + }; + for (final m in all) { + for (final loc in m.translations.entries) { + out.putIfAbsent(loc.key, () => {}).addAll(loc.value); + } + } + return out; + } + + /// 各模块的常驻服务注册。只跑一次。 + static bool _servicesRegistered = false; + static void registerServices() { + if (_servicesRegistered) return; + _servicesRegistered = true; + for (final m in all) { + try { + m.registerServices?.call(); + } catch (e) { + Logger.e('ModuleRegistry', '模块 ${m.id} 注册服务失败: $e'); + } + } + } +} diff --git a/apps/client/lib/modules/my_devices/controllers/my_devices_controller.dart b/apps/client/lib/modules/my_devices/controllers/my_devices_controller.dart index d7afca18..a9eed596 100644 --- a/apps/client/lib/modules/my_devices/controllers/my_devices_controller.dart +++ b/apps/client/lib/modules/my_devices/controllers/my_devices_controller.dart @@ -1,22 +1,22 @@ import 'dart:async'; +import 'package:device_plugin_interface/device_plugin_interface.dart'; import 'package:get/get.dart'; import '/core/utils/logger.dart'; import '/data/models/user_Info.dart'; -import '/data/services/ble_manager.dart'; -import '/data/services/bes_bluetooth_service.dart'; -import '/data/services/bes_device_auth.dart'; import '/data/services/network/api.dart'; +import '/devices/device_auth.dart'; +import '/devices/device_hub.dart'; import '/routes/app_routes.dart'; /// 用户已绑定设备管理页:拉取用户绑定列表、解绑。 +/// +/// 连接状态**只看本地蓝牙**([DeviceHub]),不问服务端——连接是此刻的链路状态, +/// 服务端存的是绑定关系,两回事。 class MyDevicesController extends GetxController { static const String _tag = 'MyDevicesController'; - late final BleManager _bleManager; - - /// 按设备类型分组的已绑定设备 /// 已绑定设备的平铺列表(一行一个产品,页面直接用这个) final RxList allDevices = [].obs; @@ -26,44 +26,31 @@ class MyDevicesController extends GetxController { final RxBool isLoading = false.obs; - /// 连接状态监听句柄,[onClose] 里要断掉 - final List _workers = []; + Worker? _revisionWorker; @override void onInit() { super.onInit(); - _bleManager = Get.find(); _refreshFromUser(); Future.microtask(loadDevices); - // 排序把「在线的排最前」,而在线与否是随时会变的链路状态——耳机可能在 - // 用户停在本页时才连上、或者被摘下断开。列表本身在 Obx 里,但 Obx 只 - // 收集到 allDevices 这一个可观察对象,**排序结果不会自己重算**, - // 必须在连接状态变化时重新跑一遍 _refreshFromUser。 - if (Get.isRegistered()) { - final bes = BesBluetoothService.to; - _workers.add(ever(bes.deviceStatus, (_) => _refreshFromUser())); - // deviceStatus 只说「连上了」,连的是哪台要看这两个:Android 上是 - // connectedDeviceId,iOS 上只能等耳机自报的 deviceMac 到达。 - _workers.add(ever(bes.connectedDeviceId, (_) => _refreshFromUser())); - _workers.add(ever(bes.deviceMac, (_) => _refreshFromUser())); - } + // 排序把「在线的排最前」,而在线与否是随时会变的链路状态——设备可能在 + // 用户停在本页时才连上、或者被摘下断开;iOS 上耳机自报的 MAC 也是晚一帧 + // 才到。列表本身在 Obx 里只收集到 allDevices,**排序结果不会自己重算**, + // 必须在设备状态变化时重新跑一遍 _refreshFromUser。 + _revisionWorker = ever(DeviceHub.to.revision, (_) => _refreshFromUser()); } @override void onClose() { - for (final w in _workers) { - w.dispose(); - } - _workers.clear(); + _revisionWorker?.dispose(); super.onClose(); } /// 从服务端拉取最新的设备列表,并刷新 [User.instance.devices]。 Future loadDevices() async { if (!User.isLoggedIn()) return; - // 本地已有数据就不要遮罩:onInit 里 _refreshFromUser() 已经拿本地列表渲染过 - // 一次了,再盖一层 loading 只会让用户对着转圈干等网络往返。 + // 本地已有数据就不要遮罩 isLoading.value = User.instance.devices.isEmpty; try { final response = await Api.getdevices(); @@ -85,19 +72,13 @@ class MyDevicesController extends GetxController { isLoading.value = false; } - // 补校当前连着的恒玄耳机(顺带完成绑定登记)。 - // 「先连耳机、后登录」是很常见的顺序,那时候连接事件里的登记会因为未登录 - // 而跳过;不在这里补一次,这台设备就要等下次重连才会出现在列表里。 - // - // ⚠️ 但**不能串在 getdevices 前面**:首次连接时白名单没命中,它会真的去打 - // user_binddevice(带 10s 超时),整个页面就得干等这一趟——表现就是 - // 「点设备页半天没响应」。放到列表渲染之后跑,页面立刻可见。 - // 它登记成功时会直接把设备塞进 User.instance.devices,所以回来刷一次就够, - // 不必再拉一遍 getdevices。 - unawaited(BesDeviceAuth.verifyConnected().then((_) => _refreshFromUser())); + // 补校当前连着的设备(顺带完成绑定登记)。「先连耳机、后登录」时连接事件里的 + // 登记会因为未登录而跳过;不在这里补一次,这台设备就要等下次重连才会出现。 + // ⚠️ 不能串在 getdevices 前面:首次连接会真的去打 user_binddevice(10s 超时)。 + unawaited(DeviceAuth.verifyConnected().then((_) => _refreshFromUser())); } - /// 解绑设备:调接口成功后从本地列表移除;如果是当前连接的设备,断开并跳到扫描页。 + /// 解绑设备:调接口成功后从本地列表移除;如果是当前连接的设备,断开并回主框架。 Future unbindDevice(UserDevice device) async { try { final response = await Api.unbinddevice({'id': device.id}); @@ -106,41 +87,22 @@ class MyDevicesController extends GetxController { User.instance.removeUserDevice(device.id); // 解绑要把这台在本地留下的痕迹一次清干净,两处缺一不可: - // 1. 确权白名单——不清的话下次连上会命中缓存、跳过 binddevice, - // 这台设备再也回不到列表里; - // 2. 已配对列表 + lastDevice——不清的话 tryAutoConnect 和原生 ACL 广播 - // 还会把它自动连回来,用户「解绑了但它还连着」。 - // removePairedDevice 内部会顺带断开当前连接。 - await BesDeviceAuth.forget(device.devicemac); - - // 解绑的是不是当前连着的那台恒玄耳机。 - // ⚠️ 判据不能只看 connectedDeviceId:iOS 上那是 CoreBluetooth 的 - // peripheral UUID,跟服务端记录里的真 MAC 永远对不上。耳机自报的 - // deviceMac 才是能把两边对应起来的值。必须在 removePairedDevice - // **之前**取,那之后连接已经被断开、这两个值都清空了。 - bool besWasCurrent = false; - if (device.devicemac.isNotEmpty && - Get.isRegistered()) { - final bes = BesBluetoothService.to; - final String t = device.devicemac.toUpperCase(); - besWasCurrent = bes.isConnected && - (bes.connectedDeviceId.value.toUpperCase() == t || - bes.deviceMac.value.toUpperCase() == t); - await bes.removePairedDevice(device.devicemac); + // 1. 确权白名单——不清的话下次连上会命中缓存、跳过 binddevice; + // 2. 各厂商的已配对列表——不清的话自动回连还会把它连回来。 + await DeviceAuth.forget(device.devicemac); + + // 解绑的是不是当前连着的那台:必须在 forgetDevice **之前**判,那之后连接已断 + final wasCurrent = isConnected(device); + final hub = DeviceHub.to; + for (final p in hub.plugins) { + // 每家插件按自己的标识删(恒玄同时认本地 id 与耳机自报 MAC) + await p.forgetDevice(device.devicemac); } _refreshFromUser(); - final bool jieliWasCurrent = device.devicemac.isNotEmpty && - _bleManager.currentDeviceAddress == device.devicemac; - if (jieliWasCurrent) { - await _bleManager.disconnect(); - } - // 恒玄那条的断开已由 removePairedDevice 做完,这里只负责离开本页 - if (jieliWasCurrent || besWasCurrent) { + if (wasCurrent) { Logger.i(_tag, '解绑的设备就是当前连接,已断开,回到主框架'); await Future.delayed(const Duration(milliseconds: 300)); - // 本页已去掉「添加设备」入口,解绑当前设备后回主框架, - // 让用户从设备页左上角重新走「选择设备 → 扫描」流程 Get.offAllNamed(Routes.home); } } catch (e) { @@ -160,40 +122,27 @@ class MyDevicesController extends GetxController { } devicesByType.value = grouped; devicesByType.refresh(); - // 在线的排最前,其余保持服务端返回的顺序。 - // - // ⚠️ 刻意用「两个桶拼起来」而不是 List.sort:Dart 的 sort **不保证稳定** - // (长度 <32 走插入排序碰巧稳定,再长就切到快排),拿它按 0/1 排会让 - // 离线设备的相对顺序每次刷新都可能变,看着像列表在乱跳。 + // 在线的排最前,其余保持服务端顺序。刻意用「两个桶拼起来」而不是 List.sort: + // Dart 的 sort 不保证稳定,拿它按 0/1 排会让离线设备的相对顺序每次刷新都变。 allDevices.assignAll([...online, ...offline]); } - /// 当前是不是正连着这一台。**只看本地蓝牙状态,不问服务端**—— - /// 连接是此刻的链路状态,服务端存的是绑定关系,两回事。 - /// - /// ⚠️ 原来只查了 [BleManager](杰理链路),而本项目实际用的是恒玄耳机, - /// 于是这里对所有设备恒为 false,列表上永远显示"未连接"。 + /// 当前是不是正连着这一台:比服务端记的 MAC 与在线会话的真实 MAC / 本地 id。 bool isConnected(UserDevice device) { if (device.devicemac.isEmpty) return false; final mac = device.devicemac.toUpperCase(); - if (Get.isRegistered()) { - final bes = BesBluetoothService.to; - // ⚠️ 两个值都要比,判据与 unbindDevice 里那段保持一致: - // connectedDeviceId 在 Android 上就是 MAC,在 iOS 上却是 CoreBluetooth - // 的 peripheral UUID,跟服务端记的真 MAC 永远对不上;耳机在 `BB 0A` - // 属性 7 里自报的 deviceMac 才是两端都能对上的那个。 - // 只比前者的话,iOS 上这里恒为 false —— 列表全显示未连接、排序也白排。 - if (bes.isConnected && - (bes.connectedDeviceId.value.toUpperCase() == mac || - bes.deviceMac.value.toUpperCase() == mac)) { - return true; - } + for (final s in DeviceHub.to.sessions) { + final info = s.info; + if ((info.macAddress ?? '').toUpperCase() == mac) return true; + if (info.id.toUpperCase() == mac) return true; + final localId = (info.metadata['localId'] as String?) ?? ''; + if (localId.isNotEmpty && localId.toUpperCase() == mac) return true; } - return _bleManager.isConnected && - _bleManager.currentDeviceAddress?.toUpperCase() == mac; + return false; } + /// 本页已去掉「添加设备」入口,跳品类选择页 void goToScan() { - Get.toNamed(Routes.devices); + Get.toNamed(Routes.deviceCategory); } } diff --git a/apps/client/lib/modules/my_devices/views/my_devices_view.dart b/apps/client/lib/modules/my_devices/views/my_devices_view.dart index 06520a06..7716b409 100644 --- a/apps/client/lib/modules/my_devices/views/my_devices_view.dart +++ b/apps/client/lib/modules/my_devices/views/my_devices_view.dart @@ -484,7 +484,7 @@ class MyDevicesView extends GetView { ), ], ), - if (_hasRealLicense(device) || device.cmei.isNotEmpty) ...[ + if (device.hasRealLicense || device.cmei.isNotEmpty) ...[ SizedBox(height: 14.h), Container( padding: EdgeInsets.all(12.w), @@ -496,12 +496,12 @@ class MyDevicesView extends GetView { ), child: Column( children: [ - if (_hasRealLicense(device)) + if (device.hasRealLicense) _buildFieldRow( label: 'licenseCode'.tr, value: device.license, isDark: isDark), - if (_hasRealLicense(device) && device.cmei.isNotEmpty) + if (device.hasRealLicense && device.cmei.isNotEmpty) SizedBox(height: 8.h), if (device.cmei.isNotEmpty) _buildFieldRow( @@ -515,20 +515,6 @@ class MyDevicesView extends GetView { ); } - /// 这台设备有没有「值得展示」的授权码。 - /// - /// ⚠️ 光判非空不够:服务端绑定时写的是**从 license 表查到的** code - /// (api_binddevice.go 的 `License: model.Code`,不是客户端传的空串), - /// 而后台「导入 MAC」模式建的授权码,code 就是 MAC 串本身。 - /// 于是界面上会出现 MAC 一行、授权码一行、两行值一模一样——看着像 bug。 - /// 这种占位值不展示;真授权码(如 71E5E5C3A797C043B5A7)照常展示。 - bool _hasRealLicense(UserDevice device) { - final lic = device.license.trim(); - if (lic.isEmpty) return false; - String norm(String v) => v.replaceAll(':', '').replaceAll('-', '').toUpperCase(); - return norm(lic) != norm(device.devicemac); - } - Widget _buildFieldRow({ required String label, required String value, diff --git a/apps/client/lib/modules/ota_upgrade/bindings/ota_upgrade_binding.dart b/apps/client/lib/modules/ota_upgrade/bindings/ota_upgrade_binding.dart index eabd8a5f..254023e7 100644 --- a/apps/client/lib/modules/ota_upgrade/bindings/ota_upgrade_binding.dart +++ b/apps/client/lib/modules/ota_upgrade/bindings/ota_upgrade_binding.dart @@ -1,11 +1,11 @@ import 'package:get/get.dart'; -import '../controllers/bes_ota_upgrade_controller.dart'; +import '../controllers/device_ota_controller.dart'; class OtaUpgradeBinding extends Bindings { @override void dependencies() { // 本项目只有恒玄耳机,固件升级走 BES 链路。杰理那版 OtaUpgradeController // 依赖 BleManager/Jielihome,已不再由路由装配。 - Get.lazyPut(() => BesOtaUpgradeController()); + Get.lazyPut(() => DeviceOtaController()); } } diff --git a/apps/client/lib/modules/ota_upgrade/controllers/device_ota_controller.dart b/apps/client/lib/modules/ota_upgrade/controllers/device_ota_controller.dart new file mode 100644 index 00000000..0ef9913e --- /dev/null +++ b/apps/client/lib/modules/ota_upgrade/controllers/device_ota_controller.dart @@ -0,0 +1,421 @@ +import 'dart:async'; + +import 'package:device_plugin_interface/device_plugin_interface.dart'; +import 'package:dio/dio.dart'; +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; +import 'package:wakelock_plus/wakelock_plus.dart'; + +import '../../../core/utils/logger.dart'; +import '../../../core/utils/version_compare.dart'; +import '../../../data/models/appconfig.dart'; +import '../../../data/models/appconfig_model.dart'; +import '../../../data/models/user_Info.dart'; +import '../../../data/services/network/api.dart'; +import '../../../devices/device_hub.dart'; +import 'ota_download_service.dart'; + +/// OTA 升级阶段(驱动页面 UI)。 +class OtaPhase { + static const idle = 0; // 待升级 + static const downloading = 1; // 下载固件中 + static const upgrading = 2; // 蓝牙推送中 + static const success = 3; // 升级成功 + static const failed = 4; // 升级失败 +} + +/// 固件 OTA 升级控制器——**厂商无关**。 +/// +/// 厂商差异全在 [DeviceOtaPort](恒玄要断 app 层 SPP 再由插件自管连接、完了再回连), +/// 这里只做:读产品配置 → 下载固件 → 交给端口推送 → 看门狗/常亮/终态锁定。 +/// +/// 流程:读产品配置 → 下载固件 → `port.start(file)` → 设备重启。 +class DeviceOtaController extends GetxController { + static const _tag = 'DeviceOtaController'; + + /// 看门狗超时:升级阶段超过该时长无任何进度或状态推进,即判定卡死失败。 + /// 兜底原生侧「既不推进也不上报 onError」导致 UI 永久停在升级中的情况。 + static const Duration _watchdogTimeout = Duration(seconds: 90); + + String _downloadUrl = ''; + bool _disposed = false; + CancelToken? _downloadCancelToken; + Timer? _watchdog; + + final OtaDownloadService _downloadService = OtaDownloadService(); + DeviceSession? _session; + DeviceOtaPort? _port; + StreamSubscription? _portSub; + StreamSubscription? _hubSub; + + // ---- 响应式状态 ---- + final RxString currentVersion = ''.obs; + final RxString latestVersion = ''.obs; + final RxString updateDescription = ''.obs; + final RxBool isUpdateAvailable = false.obs; + final RxBool isConnected = true.obs; + + /// 设备名快照:升级中 app 层连接会被端口主动断开,页面用自己的快照 + final RxString deviceName = ''.obs; + + final RxInt phase = OtaPhase.idle.obs; + final RxDouble downloadProgress = 0.0.obs; // 0~1 + final RxDouble otaProgress = 0.0.obs; // 0~1 + final RxString detailedStatus = ''.obs; + + Worker? _sessionsWorker; + + bool get isBusy => + phase.value == OtaPhase.downloading || phase.value == OtaPhase.upgrading; + + @override + void onInit() { + super.onInit(); + final hub = DeviceHub.to; + _bindSession(hub.sessionWith(DeviceCapability.ota)); + // 跟踪 app 层连接状态(仅用于展示与按钮可用性)。 + // 注意:OTA 连接由端口自管、掉线会经 progressStream 上报;开始升级时 app 层 + // 连接是端口主动断开的,不能拿它的断开去中止 OTA。 + _sessionsWorker = ever>(hub.sessions, (_) { + final s = hub.sessionWith(DeviceCapability.ota); + isConnected.value = s != null; + if (s != null && !identical(s, _session)) _bindSession(s); + }); + _hubSub = hub.sessionEvents.listen((e) { + if (e.type != DeviceSessionEventType.deviceInfoUpdated) return; + final s = _session; + if (s == null || e.deviceId != s.deviceId) return; + final fw = e.deviceInfo?.firmwareVersion ?? ''; + // 开始升级时主动断开会把版本清空,空值忽略,保留页面上已知的版本 + if (fw.isNotEmpty && fw != currentVersion.value) { + currentVersion.value = fw; + _refreshUpdateAvailable(); + } + final n = e.deviceInfo?.name ?? ''; + if (n.isNotEmpty && n != deviceName.value) { + deviceName.value = n; + _loadProductConfig(); + } + }); + unawaited(_ensureBoundDevicesLoaded()); + } + + /// 产品匹配要用绑定关系里的 productid(见 [_findProduct]),而 + /// `User.instance.devices` 只有在连接时登记成功、或用户进过「设备管理」页 + /// 之后才有值。**直接从设备页进固件升级时它可能是空的**,那样就退回名字匹配、 + /// 又匹配不上,页面照旧报「未找到产品配置」。所以这里补拉一次。 + Future _ensureBoundDevicesLoaded() async { + if (!User.isLoggedIn()) return; + if (User.instance.devices.isNotEmpty) return; + try { + final resp = await Api.getdevices(); + final list = resp?['devices'] as List?; + if (list == null || list.isEmpty) return; + User.instance.devices = list + .map((e) => UserDevice.fromJson(e as Map)) + .toList(); + if (_disposed) return; + Logger.i(_tag, '补拉已绑定设备 ${User.instance.devices.length} 条,重算产品配置'); + _loadProductConfig(); + } catch (e) { + // 拉不到就退回名字匹配,不影响页面其它部分 + Logger.w(_tag, '补拉已绑定设备失败: $e'); + } + } + + void _bindSession(DeviceSession? s) { + _session = s; + isConnected.value = s != null; + if (s == null) return; + final fw = s.info.firmwareVersion ?? ''; + if (fw.isNotEmpty) currentVersion.value = fw; + if (s.info.name.isNotEmpty) deviceName.value = s.info.name; + _loadProductConfig(); + } + + @override + void onClose() { + _disposed = true; + _downloadCancelToken?.cancel('page closed'); + _cancelWatchdog(); + _setKeepScreenOn(false); + _portSub?.cancel(); + _sessionsWorker?.dispose(); + _hubSub?.cancel(); + // 端口负责释放链路并把设备交还给正常会话(恒玄:stopOta 后延时回连) + _port?.close(); + _port = null; + super.onClose(); + } + + /// 从后端产品配置取最新固件版本与下载地址(按设备名匹配产品)。 + void _loadProductConfig() { + final product = _findProduct(deviceName.value); + if (product == null) { + Logger.w(_tag, '未找到设备对应的产品配置: ${deviceName.value}'); + latestVersion.value = ''; + updateDescription.value = ''; + _downloadUrl = ''; + } else { + latestVersion.value = product.version?.split('+').first.trim() ?? ''; + updateDescription.value = product.updatedescription ?? ''; + _downloadUrl = product.updatepackageaddress ?? ''; + Logger.i( + _tag, + '产品配置: name=${product.devicename} pid=${product.id} ' + '服务端最新=${product.version} → 解析后=${latestVersion.value} ' + '设备当前=${currentVersion.value} ' + '产品表共${AppConfig.config.products.length}个'); + } + _refreshUpdateAvailable(); + } + + bool get _allowDowngrade => + _findProduct(deviceName.value)?.updateonmismatch ?? false; + + /// 找这台设备对应的产品配置。**先按绑定关系里的 productid,再退回名字匹配。** + /// + /// ⚠️ 名字匹配靠不住:支架广播名是 `Echomeet`,产品表里叫 `EaiRec`, + /// 两者互不包含 —— 于是固件升级页恒报「未找到设备对应的产品配置」, + /// 一次服务端读取都不会发生(2026-09-10 真机复现)。 + /// `user_binddevice` / `user_getdevices` 返回的 `productid` 是服务端 + /// 从全局 `device_mac` 表命中行读出来的**权威值**,按 MAC 对得上就用它。 + /// + /// ⚠️ 不要用 [AppConfig.getproduct]——它内部 `firstWhere` 没有 orElse, + /// 查不到直接抛 StateError,而调用点写的都是 `if (product == null)`。 + DBProduct? _findProduct(String name) { + if (!AppConfig.isInitialized()) return null; + final products = AppConfig.config.products; + if (products.isEmpty) return null; + + // ① 绑定关系里的 productid(权威) + final pid = _boundProductId(); + if (pid != null) { + for (final p in products) { + if (p.id == pid) return p; + } + Logger.w(_tag, '绑定关系给的 pid=$pid 不在本应用产品表里,退回按名字匹配'); + } + + // ② 设备名与产品名互相包含(老口径,保留给还没绑上的设备) + final n = name.trim().toUpperCase(); + if (n.isNotEmpty && n != 'UNKNOWNEQUIPMENT') { + for (final p in products) { + final pn = p.devicename.trim().toUpperCase(); + if (pn.isEmpty) continue; + if (pn.contains(n) || n.contains(pn)) return p; + } + } + return null; + } + + /// 当前设备在「我的设备」里登记的 productid。没登录/没绑定/拿不到 MAC 返回 null。 + /// + /// MAC 比对忽略冒号与大小写:服务端存的和设备上报的写法不一定一致。 + int? _boundProductId() { + if (!User.isLoggedIn()) return null; + final info = _session?.info; + final mac = (info?.macAddress ?? info?.id ?? '') + .replaceAll(':', '') + .toUpperCase(); + if (mac.isEmpty) return null; + for (final d in User.instance.devices) { + if (d.devicemac.replaceAll(':', '').toUpperCase() == mac) { + return d.productid; + } + } + return null; + } + + void _refreshUpdateAvailable() { + final cur = currentVersion.value.trim(); + final latest = latestVersion.value.trim(); + if (latest.isEmpty) { + isUpdateAvailable.value = false; + return; + } + // 读不到当前版本时允许升级(未知 → 允许) + if (cur.isEmpty) { + isUpdateAvailable.value = true; + return; + } + // 判定口径由后台产品配置决定:updateonmismatch=true → 不一致就允许(可刷回旧固件); + // false/null → 只有服务端版本更高才允许(正式,默认) + isUpdateAvailable.value = + _allowDowngrade ? latest != cur : _isNewer(latest, cur); + } + + /// 入口:开始升级(下载 → 交给端口推送)。 + Future startUpdate() async { + if (isBusy) return; + final session = _session ?? DeviceHub.to.sessionWith(DeviceCapability.ota); + if (session == null || session.state != DeviceConnectionState.ready) { + _snack('otaError'.tr, 'otaNeedConnect'.tr); + return; + } + final port = session.otaPort(); + if (port == null) { + _snack('otaError'.tr, 'otaNeedConnect'.tr); + return; + } + _session = session; + + if (_downloadUrl.isEmpty) { + _loadProductConfig(); + if (_downloadUrl.isEmpty) { + _snack('otaError'.tr, 'otaVersionInfoFailed'.tr); + return; + } + } + + final cancelToken = CancelToken(); + _downloadCancelToken = cancelToken; + + try { + _setKeepScreenOn(true); + phase.value = OtaPhase.downloading; + downloadProgress.value = 0; + otaProgress.value = 0; + detailedStatus.value = 'otaPreparing'.tr; + + // 1) 下载固件(端口 start 时才断链路,下载期间设备照常连着) + final filePath = await _downloadService.downloadFirmware( + downloadUrl: _downloadUrl, + cancelToken: cancelToken, + onProgress: (p) => downloadProgress.value = p, + onStatusUpdate: (s) => detailedStatus.value = s, + ); + if (_disposed || cancelToken.isCancelled) return; + + // 2) 交给端口推送 + phase.value = OtaPhase.upgrading; + detailedStatus.value = 'otaConnectingDevice'.tr; + _port = port; + _portSub?.cancel(); + _portSub = port.progressStream.listen(_onProgress, onError: (Object e) { + _onUpgradeError(e.toString()); + }); + _armWatchdog(); + await port.start(DeviceOtaFileRequest(filePath: filePath)); + } catch (e, st) { + if (_disposed || (e is DioException && CancelToken.isCancel(e))) return; + Logger.e(_tag, '升级流程异常', e, st); + _onUpgradeError(e.toString()); + } + } + + void _onProgress(DeviceOtaProgress p) { + // 进入终态即锁定,迟到的事件一律忽略 + if (phase.value == OtaPhase.success || phase.value == OtaPhase.failed) { + return; + } + _armWatchdog(); + switch (p.state) { + case DeviceOtaState.transferring: + phase.value = OtaPhase.upgrading; + if (p.percent >= 0) _updateProgress(p.percent / 100); + break; + case DeviceOtaState.done: + _onUpgradeSuccess(); + break; + case DeviceOtaState.failed: + _onUpgradeError(p.errorMessage ?? p.errorCode ?? 'failed'); + break; + case DeviceOtaState.cancelled: + _cancelWatchdog(); + _setKeepScreenOn(false); + phase.value = OtaPhase.idle; + detailedStatus.value = ''; + break; + default: + break; + } + } + + /// 取消升级。 + void cancel() { + _downloadCancelToken?.cancel('user cancelled'); + _cancelWatchdog(); + _port?.cancel(); + _setKeepScreenOn(false); + phase.value = OtaPhase.idle; + detailedStatus.value = ''; + downloadProgress.value = 0; + otaProgress.value = 0; + } + + void _armWatchdog() { + if (_disposed) return; + _watchdog?.cancel(); + _watchdog = Timer(_watchdogTimeout, _onWatchdogTimeout); + } + + void _cancelWatchdog() { + _watchdog?.cancel(); + _watchdog = null; + } + + void _onWatchdogTimeout() { + if (_disposed) return; + if (phase.value == OtaPhase.upgrading) { + Logger.w(_tag, 'OTA 看门狗超时:${_watchdogTimeout.inSeconds}s 无进度推进'); + _onUpgradeError('otaTimeout'.tr); + } + } + + void _setKeepScreenOn(bool on) { + try { + on ? WakelockPlus.enable() : WakelockPlus.disable(); + } catch (e) { + Logger.w(_tag, '设置屏幕常亮失败: $e'); + } + } + + /// 进度的单一写入口:只在单调递增时刷新 + void _updateProgress(double p) { + if (p <= otaProgress.value) return; + otaProgress.value = p; + detailedStatus.value = 'otaUpgrading'.trParams({ + 'percent': (p * 100).toStringAsFixed(2), + }); + } + + void _onUpgradeSuccess() { + _cancelWatchdog(); + _setKeepScreenOn(false); + phase.value = OtaPhase.success; + otaProgress.value = 1.0; + detailedStatus.value = 'otaSuccessReboot'.tr; + currentVersion.value = latestVersion.value; + isUpdateAvailable.value = false; + } + + void _onUpgradeError(String? message) { + if (phase.value == OtaPhase.failed) return; + _cancelWatchdog(); + _setKeepScreenOn(false); + phase.value = OtaPhase.failed; + _port?.cancel(); + detailedStatus.value = 'otaFailed'.tr; + _snack( + 'otaFailed'.tr, + message?.isNotEmpty == true + ? message! + : 'otaFailedWithCode'.trParams({'code': '-1'}), + ); + } + + void _snack(String title, String msg) { + Get.snackbar( + title, + msg, + snackPosition: SnackPosition.TOP, + backgroundColor: Colors.red[400], + colorText: Colors.white, + margin: const EdgeInsets.all(12), + duration: const Duration(seconds: 3), + ); + } + + bool _isNewer(String a, String b) => VersionCompare.isNewer(a, b); +} diff --git a/apps/client/lib/modules/ota_upgrade/controllers/ota_download_service.dart b/apps/client/lib/modules/ota_upgrade/controllers/ota_download_service.dart index 48ff5ead..d63a47e4 100644 --- a/apps/client/lib/modules/ota_upgrade/controllers/ota_download_service.dart +++ b/apps/client/lib/modules/ota_upgrade/controllers/ota_download_service.dart @@ -8,10 +8,10 @@ import 'package:path_provider/path_provider.dart'; import '../../../core/utils/logger.dart'; -/// 恒玄(BES) OTA 固件下载服务 +/// OTA 固件下载服务(厂商无关) /// /// 用 Dio 把固件包下载到应用支持目录,提供进度/状态回调,并做"同名复用 + 清旧文件"。 -class BesOtaDownloadService { +class OtaDownloadService { static const _tag = 'BesOtaDownloadService'; static const _dirName = 'ota_upgrades'; @@ -27,7 +27,7 @@ class BesOtaDownloadService { late final Dio _dio; - BesOtaDownloadService() { + OtaDownloadService() { _dio = Dio( BaseOptions( connectTimeout: const Duration(seconds: 30), diff --git a/apps/client/lib/modules/ota_upgrade/views/device_ota_view.dart b/apps/client/lib/modules/ota_upgrade/views/device_ota_view.dart index b4045473..63977810 100644 --- a/apps/client/lib/modules/ota_upgrade/views/device_ota_view.dart +++ b/apps/client/lib/modules/ota_upgrade/views/device_ota_view.dart @@ -3,10 +3,10 @@ import '../../../core/utils/device_connection.dart'; import 'package:flutter_screenutil/flutter_screenutil.dart'; import 'package:get/get.dart'; -import '../controllers/bes_ota_upgrade_controller.dart'; +import '../controllers/device_ota_controller.dart'; -class BesOtaUpgradeView extends GetView { - const BesOtaUpgradeView({super.key}); +class DeviceOtaView extends GetView { + const DeviceOtaView({super.key}); static const _accent = Color(0xFF009688); static const _accentBg = Color(0xFFE0F2F1); diff --git a/apps/client/lib/modules/ota_upgrade/views/ota_upgrade_popup_view.dart b/apps/client/lib/modules/ota_upgrade/views/ota_upgrade_popup_view.dart index 350b1cc6..a82f5ccb 100644 --- a/apps/client/lib/modules/ota_upgrade/views/ota_upgrade_popup_view.dart +++ b/apps/client/lib/modules/ota_upgrade/views/ota_upgrade_popup_view.dart @@ -80,7 +80,7 @@ class OtaUpgradePopUp { ElevatedButton( onPressed: () { Get.back(); - Get.offAllNamed(Routes.devices); + Get.offAllNamed(Routes.deviceCategory); }, style: ElevatedButton.styleFrom( backgroundColor: Colors.green[600], diff --git a/apps/client/lib/modules/picture_translation/module.dart b/apps/client/lib/modules/picture_translation/module.dart new file mode 100644 index 00000000..2ed396d0 --- /dev/null +++ b/apps/client/lib/modules/picture_translation/module.dart @@ -0,0 +1,26 @@ +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; + +import '../../routes/app_routes.dart'; +import '../module_descriptor.dart'; +import 'bindings/picture_translation_binding.dart'; +import 'views/picture_translation_view.dart'; + +/// 拍照翻译 +final AgentModuleDescriptor pictureTranslationModule = AgentModuleDescriptor( + id: 'pictureTranslation', + title: 'agentPictureTranslation', + subtitle: 'agentPictureTranslationDesc', + icon: Icons.image_search, + gradient: const [Color(0xFFEC407A), Color(0xFFAD1457)], + entryRoute: Routes.pictureTranslation, + pages: [ + GetPage( + name: Routes.pictureTranslation, + page: () => const PictureTranslationView(), + binding: PictureTranslationBinding(), + transition: Transition.rightToLeft, + transitionDuration: const Duration(milliseconds: 250), + ), + ], +); diff --git a/apps/client/lib/modules/settings/controllers/settings_controller.dart b/apps/client/lib/modules/settings/controllers/settings_controller.dart index b172f861..f18d8fe2 100644 --- a/apps/client/lib/modules/settings/controllers/settings_controller.dart +++ b/apps/client/lib/modules/settings/controllers/settings_controller.dart @@ -1,7 +1,6 @@ import 'dart:async'; import '/data/services/background_launch_consent.dart'; -import '/data/services/bes_bluetooth_service.dart'; -import '/data/services/holder_device_service.dart'; +import '/devices/device_hub.dart'; import 'package:agent_service/agent_service.dart'; import 'package:get/get.dart'; @@ -66,12 +65,7 @@ class SettingsController extends GetxController { autoConnectEnabled.value = false; // 注销系统级后台扫描:不注销的话设备一靠近还是会把 App 唤醒 try { - if (Get.isRegistered()) { - await HolderDeviceService.to.disconnect(); - } - if (Get.isRegistered()) { - await BesBluetoothService.to.disconnectDevice(); - } + await DeviceHub.to.disconnectAll(); } catch (e) { debugPrint('关闭自动连接时断开设备失败: $e'); } diff --git a/apps/client/lib/modules/settings/views/settings_view.dart b/apps/client/lib/modules/settings/views/settings_view.dart index 47451c31..dc8a4277 100644 --- a/apps/client/lib/modules/settings/views/settings_view.dart +++ b/apps/client/lib/modules/settings/views/settings_view.dart @@ -537,26 +537,6 @@ class SettingsView extends GetView { // ? Colors.white.withOpacity(0.1) // : Colors.grey[200]), - Obx(() => controller.showDebugInfo.value - ? _buildSimpleNavigationSetting( - title: 'opus解码测试', - subtitle: '测试opus解码', - icon: Icons.bluetooth_searching, - iconBgColor: isDarkMode - ? Colors.green[900]!.withValues(alpha: 0.3) - : Colors.green[100]!, - iconColor: isDarkMode - ? Colors.green[300]! - : Colors.green[600]!, - titleColor: isDarkMode ? Colors.white : null, - subtitleColor: isDarkMode ? Colors.white70 : null, - onTap: () { - Get.toNamed(Routes.opusTest); - }, - isDarkMode: isDarkMode, - ) - : const SizedBox.shrink()), - Obx(() => controller.showDebugInfo.value ? _buildSimpleNavigationSetting( title: '恒玄蓝牙测试', diff --git a/apps/client/lib/modules/smartcar/bindings/smartcar_chat_binding.dart b/apps/client/lib/modules/smartcar/bindings/smartcar_chat_binding.dart new file mode 100644 index 00000000..db732f24 --- /dev/null +++ b/apps/client/lib/modules/smartcar/bindings/smartcar_chat_binding.dart @@ -0,0 +1,48 @@ +import 'package:get/get.dart'; + +import '../../../data/services/bailian_multimodal_service.dart'; +import '../../emai/controllers/emai_controller.dart'; + +/// Smartcar 车载助手的依赖装配。 +/// +/// 复用 EMAI 那套对话 UI 与控制器,只把「连哪个百炼应用」换掉: +/// 这里 put 一个带 Smartcar 专属 app_id 的 [BailianMultimodalService], +/// 再用它构造一个同 tag 的 [EmaiController]。 +/// +/// ⚠️ **两者都必须带 tag**:不带 tag 会覆盖掉全局那个 EMAI 用的实例, +/// 表现为退出车载助手后 EMAI 也连到了车载的应用上。 +/// `EmaiView(controllerTag: kSmartcarTag)` 靠同一个 tag 取到这里的控制器。 +/// +/// ⚠️ **service 必须是独立实例**,不能 `Get.find` 全局那个: +/// `BailianMultimodalService.start()` 第一件事就是 `stop()`,共用会让两个 agent +/// 互相把对方的会话掐掉,下行音频还会串到对方的播放器上。 +class SmartcarChatBinding extends Bindings { + @override + void dependencies() { + if (!Get.isRegistered(tag: kSmartcarTag)) { + Get.put( + BailianMultimodalService(appId: kSmartcarBailianAppId), + tag: kSmartcarTag, + permanent: true, + ); + } + Get.lazyPut( + () => EmaiController( + service: Get.find(tag: kSmartcarTag), + ), + tag: kSmartcarTag, + ); + } +} + +/// GetX 实例 tag:service 与 controller 共用这一个 +const String kSmartcarTag = 'smartcar'; + +/// Smartcar 车载助手在百炼里的多模态应用 ID(workspace 与 EMAI 同一个, +/// 所以不覆盖 workspaceId,让它走全局配置)。 +/// +/// ⚠️ 写死在客户端是**有意选的**(落地文档 6.3 方案 A):换 app_id 要发版。 +/// 等运营提出「要后台随时改」时,再改成从 `DBAgent.serviceData` 下发—— +/// 那时要给客户端手写镜像补 `source` / `serviceData` 两个字段,且**必须 nullable**, +/// 否则老后端不下发就整个 `fromJson` 抛 TypeError、应用配置全空白。 +const String kSmartcarBailianAppId = 'mm_dd1eb7cb06484aafb94a0b6b2dff'; diff --git a/apps/client/lib/modules/smartcar/module.dart b/apps/client/lib/modules/smartcar/module.dart new file mode 100644 index 00000000..9e044cbc --- /dev/null +++ b/apps/client/lib/modules/smartcar/module.dart @@ -0,0 +1,130 @@ +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; + +import '../../routes/app_routes.dart'; +import '../emai/views/emai_view.dart'; +import '../module_descriptor.dart'; +import 'bindings/smartcar_chat_binding.dart'; + +/// Smartcar 车载助手(Smartcar 车载香薰的随身对话 agent) +/// +/// 与 EMAI 共用同一套对话 UI 和控制器,差别只有一个:连的是百炼里**另一个应用** +/// (见 [kSmartcarBailianAppId])。所以这里直接复用 [EmaiView],靠 `controllerTag` +/// 取到 [SmartcarChatBinding] 装配的那份带专属 app_id 的实例。 +/// +/// ⚠️ **没有声明 `requiredCapabilities`**:这个 agent 手机自己就能聊,不必连着香薰 +/// (产品定位是"随时开聊")。设备连上时它会被自动铺到 AI 页首位,那是 +/// `AgentModules.defaultsByCategory[carAroma]` 的事,与本字段无关。 +/// +/// ⚠️ **对话记录与 EMAI 共用 `EmaiChatStore`**:设备按键唤醒的轮次由 +/// `DeviceAiSessionService` 写进那个 store,页面不开也能收;这里若另起一个 store, +/// 设备发起的对话就不会出现在本页上。要拆分历史得连 `DeviceAiSessionService` +/// 一起改,本期不做。 +final AgentModuleDescriptor smartcarModule = AgentModuleDescriptor( + id: 'smartcarChat', + title: 'agentSmartcar', + subtitle: 'agentSmartcarDesc', + // 卡片图标只支持 IconData(module_card 里是 Icon(module.icon) 套渐变块), + // 用不了产品图 Smartcar_icon.png。要用图得给 AgentModule 加字段并改 module_card, + // 那是耳机/支架共用的卡片组件,本期不动。 + icon: Icons.directions_car, + // 与「AI车载香薰」品类卡同色系(device_category.dart 的 carAroma) + gradient: const [Color(0xFFFFB74D), Color(0xFFE65100)], + entryRoute: Routes.smartcarChat, + // 设备按键唤醒时接的就是这个应用(不是 EMAI)——见 AgentModuleDescriptor.bailianAppId + bailianAppId: kSmartcarBailianAppId, + pages: [ + GetPage( + name: Routes.smartcarChat, + page: () => const EmaiView( + controllerTag: kSmartcarTag, + titleKey: 'agentSmartcar', + ), + binding: SmartcarChatBinding(), + transition: Transition.rightToLeft, + transitionDuration: const Duration(milliseconds: 250), + ), + ], + // 模块自带文案:不用再去改 41 个语言文件。 + // 缺某个语种时 GetX 回退到全局表,全局也没有就显示 key 本身, + // 所以至少要保证 zh_CN / en_US 有(module_registry_test 守着这两个)。 + translations: const { + 'zh_CN': { + 'agentSmartcar': 'Smartcar', + 'agentSmartcarDesc': '您的车载助手', + 'smartcarWelcome': '欢迎回来', + 'smartcarA2dpTitle': '需要连接蓝牙音频', + 'smartcarA2dpHint': 'AI 回复和音乐要经典蓝牙才能从设备里放出来。请到「设置 → 蓝牙」里配对 @name。', + }, + 'zh_TW': { + 'agentSmartcar': 'Smartcar', + 'agentSmartcarDesc': '您的車載助手', + 'smartcarWelcome': '歡迎回來', + 'smartcarA2dpTitle': '需要連接藍牙音訊', + 'smartcarA2dpHint': 'AI 回覆和音樂要經典藍牙才能從裝置裡播出來。請到「設定 → 藍牙」配對 @name。', + }, + 'zh_HK': { + 'agentSmartcar': 'Smartcar', + 'agentSmartcarDesc': '您的車載助手', + 'smartcarWelcome': '歡迎回來', + 'smartcarA2dpTitle': '需要連接藍牙音訊', + 'smartcarA2dpHint': 'AI 回覆和音樂要經典藍牙先可以喺裝置度播出嚟。請去「設定 → 藍牙」配對 @name。', + }, + 'en_US': { + 'agentSmartcar': 'Smartcar', + 'agentSmartcarDesc': 'Your in-car assistant', + 'smartcarWelcome': 'Welcome back', + 'smartcarA2dpTitle': 'Bluetooth audio not connected', + 'smartcarA2dpHint': 'Pair @name under Settings → Bluetooth so replies and music play from the device.', + }, + 'ja_JP': { + 'agentSmartcar': 'Smartcar', + 'agentSmartcarDesc': '車載アシスタント', + 'smartcarWelcome': 'おかえりなさい', + 'smartcarA2dpTitle': 'Bluetoothオーディオ未接続', + 'smartcarA2dpHint': '「設定 → Bluetooth」で @name をペアリングすると、応答と音楽が本体から再生されます。', + }, + 'ko_KR': { + 'agentSmartcar': 'Smartcar', + 'agentSmartcarDesc': '차량용 어시스턴트', + 'smartcarWelcome': '다시 오신 것을 환영합니다', + 'smartcarA2dpTitle': '블루투스 오디오 미연결', + 'smartcarA2dpHint': '설정 → 블루투스에서 @name 을(를) 페어링하면 응답과 음악이 기기에서 재생됩니다。', + }, + 'de_DE': { + 'agentSmartcar': 'Smartcar', + 'agentSmartcarDesc': 'Ihr Auto-Assistent', + 'smartcarWelcome': 'Willkommen zurück', + 'smartcarA2dpTitle': 'Bluetooth-Audio nicht verbunden', + 'smartcarA2dpHint': 'Koppeln Sie @name unter Einstellungen → Bluetooth, damit Antworten und Musik über das Gerät laufen.', + }, + 'fr_FR': { + 'agentSmartcar': 'Smartcar', + 'agentSmartcarDesc': 'Votre assistant automobile', + 'smartcarWelcome': 'Bon retour', + 'smartcarA2dpTitle': 'Audio Bluetooth non connecté', + 'smartcarA2dpHint': 'Associez @name dans Réglages → Bluetooth pour que les réponses et la musique sortent de l\'appareil.', + }, + 'es_ES': { + 'agentSmartcar': 'Smartcar', + 'agentSmartcarDesc': 'Tu asistente para el coche', + 'smartcarWelcome': 'Bienvenido de nuevo', + 'smartcarA2dpTitle': 'Audio Bluetooth no conectado', + 'smartcarA2dpHint': 'Empareja @name en Ajustes → Bluetooth para que las respuestas y la música suenen en el dispositivo.', + }, + 'pt_PT': { + 'agentSmartcar': 'Smartcar', + 'agentSmartcarDesc': 'O seu assistente para o carro', + 'smartcarWelcome': 'Bem-vindo de volta', + 'smartcarA2dpTitle': 'Áudio Bluetooth não ligado', + 'smartcarA2dpHint': 'Emparelhe @name em Definições → Bluetooth para que as respostas e a música saiam do dispositivo.', + }, + 'ru_RU': { + 'agentSmartcar': 'Smartcar', + 'agentSmartcarDesc': 'Ваш автомобильный помощник', + 'smartcarWelcome': 'С возвращением', + 'smartcarA2dpTitle': 'Bluetooth-аудио не подключено', + 'smartcarA2dpHint': 'Подключите @name в «Настройки → Bluetooth», чтобы ответы и музыка звучали из устройства.', + }, + }, +); diff --git a/apps/client/lib/modules/text_translation/module.dart b/apps/client/lib/modules/text_translation/module.dart new file mode 100644 index 00000000..e9af02bf --- /dev/null +++ b/apps/client/lib/modules/text_translation/module.dart @@ -0,0 +1,26 @@ +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; + +import '../../routes/app_routes.dart'; +import '../module_descriptor.dart'; +import 'bindings/text_translation_binding.dart'; +import 'views/text_translation_view.dart'; + +/// 文本翻译 +final AgentModuleDescriptor textTranslationModule = AgentModuleDescriptor( + id: 'textTranslation', + title: 'agentTextTranslation', + subtitle: 'agentTextTranslationDesc', + icon: Icons.translate, + gradient: const [Color(0xFF26A69A), Color(0xFF00796B)], + entryRoute: Routes.textTranslation, + pages: [ + GetPage( + name: Routes.textTranslation, + page: () => const TextTranslationView(), + binding: TextTranslationBinding(), + transition: Transition.rightToLeft, + transitionDuration: const Duration(milliseconds: 250), + ), + ], +); diff --git a/apps/client/lib/modules/translation/controllers/translation_controller.dart b/apps/client/lib/modules/translation/controllers/translation_controller.dart index 75ab3ec6..c0daccf3 100644 --- a/apps/client/lib/modules/translation/controllers/translation_controller.dart +++ b/apps/client/lib/modules/translation/controllers/translation_controller.dart @@ -14,7 +14,6 @@ import 'package:device_info_plus/device_info_plus.dart'; import 'package:eaimar/data/services/qq_music.dart'; import '../../../data/services/ast_service.dart'; import '../../../data/services/server_translation_service.dart'; -import '../../../data/services/bluetooth_manager.dart'; import '../../../data/services/music_manager.dart'; import '../../../data/services/translation_usage_service.dart'; import '../../../data/services/tts_service.dart'; @@ -25,8 +24,8 @@ import '../../../core/services/floating_window_manager.dart'; import '../../../data/services/asr_service.dart'; import '../../../data/services/usage_stats_service.dart'; -import '../../../data/services/ble_manager.dart'; -import '../../../data/services/bes_bluetooth_service.dart'; +import 'package:device_plugin_interface/device_plugin_interface.dart'; +import '../../../devices/device_hub.dart'; import '../../../data/services/recording_archive.dart'; import '../../../core/utils/device_connection.dart'; import '../../../core/utils/background_session_notifier.dart'; @@ -90,8 +89,6 @@ class TranslationController extends GetxController with WidgetsBindingObserver { /// 否则下次点开始会以为不需要再调,实际原生那边已经停了。 bool _astAutoStartPending = false; final GetStorage _storage = GetStorage(); - final BluetoothManager bluetoothManager = Get.find(); - final BleManager bleManager = Get.find(); final _musiceManager = Get.find(); final _qqMusicManager = Get.find(); late final UsageStatsService _usageService; @@ -185,22 +182,21 @@ class TranslationController extends GetxController with WidgetsBindingObserver { StreamSubscription? _astEventSubscription; StreamSubscription? _voiceInteractionSubscription; StreamSubscription? _deviceCallTranslationSubscription; - // 通话翻译 ↔ 杰理桥接: - // inUplink/inDownlink → AstService.pushExternalAudio - // AstService.astTtsFrames → BleManager.feedAstTtsPcm - StreamSubscription? _jieliCallInUplinkSub; - StreamSubscription? _jieliCallInDownlinkSub; - StreamSubscription? _jieliCallAstTtsSub; - // 通话翻译 ↔ 恒玄桥接(与上面杰理那套二选一,见 _configureCallMode): - // micPcmStream(本端) / spkPcmStream(对端) → AstService.pushExternalAudio - // AstService.astTtsFrames → BesBluetoothService.pushTranslationTtsPcm + // 通话翻译 ↔ 设备桥接(厂商无关,走 DeviceSession 的音频通路): + // openMic(callLocal)(本端) / openMic(callPeer)(对端) → AstService.pushExternalAudio + // AstService.astTtsFrames → openSpeaker(callLegA / callLegB) + DeviceSession? _callSession; + DeviceAudioSource? _callLocalSrc; + DeviceAudioSource? _callPeerSrc; + DeviceAudioSink? _callSinkA; + DeviceAudioSink? _callSinkB; StreamSubscription? _besCallMicSub; StreamSubscription? _besCallSpkSub; StreamSubscription? _besCallAstTtsSub; - // 本次通话翻译会话用的是恒玄链路还是杰理链路——停止/出错收尾时按这个分流, - // 不要临停时再去查 DeviceConnection.besConnected(这时耳机可能已经断开了) + // 本次通话翻译会话是否走了设备双路通路——停止/出错收尾时按这个分流, + // 不要临停时再去查连接状态(这时耳机可能已经断开了) bool _callModeUsingBes = false; - // 一次通话模式会话的设备侧收尾(AA52/AA58 或 bleManager.closeCodec)只应该 + // 一次通话模式会话的设备侧收尾(call.stop/asr.stopped)只应该 // 真正执行一次——stopAll() 和 AST 流异步报错触发的 _terminateCallOnError() // 都可能收尾同一个会话,不加锁耳机会收到两遍关闭指令。见 _teardownCallModeAudio。 bool _callAudioTornDown = true; @@ -674,33 +670,11 @@ class TranslationController extends GetxController with WidgetsBindingObserver { WidgetsBinding.instance.addObserver(this); } - /// 监听 BleManager 下发的设备通话翻译开关信号 (0x16 / 0x17) - /// - 收到 true: 若当前非 call 模式则切换到 call 模式,并自动启动识别; - /// - 收到 false: 停止当前识别(stopAll)。 - /// 首次进入时若 autoStart=true,也立即触发一次开启。 + /// 首次进入时若 autoStart=true,立即触发一次开启。 + /// (设备端「通话翻译开关」按键的信号目前没有厂商上报,所以这里只剩 autoStart。) void _setupDeviceCallTranslationListener(String initMode) { _deviceCallTranslationSubscription?.cancel(); - _deviceCallTranslationSubscription = - bleManager.onCallTranslationRequest.listen((enable) async { - Logger.info( - '[CALL_TRANS_CMD] TranslationController 收到信号: ${enable ? "开启" : "关闭"}'); - try { - if (enable) { - if (currentMode.value != 'call') { - await changeTranslationMode('call'); - } - if (!isRecognizing.value) { - await startRecognition(); - } - } else { - await stopAll(); - } - } catch (e, s) { - Logger.e('TRANSL', '[CALL_TRANS_CMD] 处理设备通话翻译信号失败: $e', e, s); - } - }); - - // 支持从 BleManager 导航进入时携带 autoStart 参数自动开启 + // 支持导航进入时携带 autoStart 参数自动开启 final autoStart = Get.arguments?['autoStart'] == true; if (autoStart && initMode == 'call') { Logger.info('[CALL_TRANS_CMD] 检测到 autoStart=true, 进入通话翻译并自启动'); @@ -718,14 +692,14 @@ class TranslationController extends GetxController with WidgetsBindingObserver { } } - /// 监听恒玄耳机的通话状态(0xD1/0xD2/0x02/0x03 → BesBluetoothService.isCallOngoing)。 + /// 监听设备上报的通话状态(DeviceHub.inCall)。 /// 挂断(isCallOngoing 变 false)时,如果正在通话翻译,自动停止识别—— /// 不然识别会一直空跑,AST 也拿不到新音频,只能等用户自己发现再手动停。 void _setupBesCallHangupListener() { _besCallOngoingWorker?.dispose(); - if (!Get.isRegistered()) return; + if (!Get.isRegistered()) return; _besCallOngoingWorker = ever( - BesBluetoothService.to.isCallOngoing, + DeviceHub.to.inCall, (calling) async { if (calling) return; if (currentMode.value != 'call') return; @@ -1080,7 +1054,6 @@ class TranslationController extends GetxController with WidgetsBindingObserver { _besCallOngoingWorker = null; _floatingWindowClosedSubscription?.cancel(); _floatingWindowClosedSubscription = null; - _detachJieliCallTranslationBridge(); _detachBesCallTranslationBridge(); _translationDebounceTimer?.cancel(); restoreOtherServices(); @@ -1343,8 +1316,7 @@ class TranslationController extends GetxController with WidgetsBindingObserver { currentMode.value == 'call') && !isAndroidAudioVideo && !isIosAudioVideo && - !(currentMode.value == 'call' && _callModeUsingBes) && - !bleManager.isCodecActive) { + !(currentMode.value == 'call' && _callModeUsingBes)) { return; } if (currentMode.value == 'simultaneous') { @@ -1521,45 +1493,44 @@ class TranslationController extends GetxController with WidgetsBindingObserver { /// 配置通话模式(只做设备音频链路配置,AST 已在 /// _initializeCallModeTranslationService 中初始化)。 - /// 恒玄和杰理耳机的通话音频通路完全不同,分两套桥接,互不影响; - /// 用哪套由当前连着的是哪条链路决定,记进 [_callModeUsingBes] 供停止时分流。 + /// 需要一台具备 [DeviceCapability.callAudioTap] 的设备;没有就直接报错。 Future _configureCallMode() async { - _callModeUsingBes = DeviceConnection.besConnected; + _callSession = DeviceHub.to.sessionWith(DeviceCapability.callAudioTap); + _callModeUsingBes = _callSession != null; _callAudioTornDown = false; if (_callModeUsingBes) { await _configureCallModeBes(); } else { - await _configureCallModeJieli(); + Get.snackbar('tip'.tr, 'bluetoothCallStateError'.tr); + throw Exception('没有具备通话双路抓音能力的设备'); } } - /// 配置通话模式(恒玄):进通话模式拿双路上行(AA 51)+ 开译文下行通道(AA 56)+ - /// 通知耳机 ASR 已开启(AA 57),再把 mic/spk PCM 接上 AST。 + /// 配置通话模式(设备双路通路):进通话模式拿双路上行 + 开译文下行通道 + + /// 通知设备 ASR 已开启,再把本端/对端 PCM 接上 AST。 /// - /// 恒玄这套私有协议是纯事件推送,没有类似杰理 isCodecActive 那种「解码器 - /// 已就绪」的同步信号,所以这里不像杰理那样轮询等待/失败即抛异常—— - /// 直接乐观下发指令,真正有没有收到音频帧由 [_attachBesCallTranslationBridge] - /// 里的流订阅决定。 + /// 恒玄这套私有协议是纯事件推送,没有「解码器已就绪」的同步信号, + /// 所以这里直接乐观下发指令,真正有没有收到音频帧由桥接里的流订阅决定。 Future _configureCallModeBes() async { isPreparing.value = true; - final bes = BesBluetoothService.to; + final session = _callSession!; try { - Logger.info('[CALL-CFG-BES] 进通话模式(AA 51) + 开译文下行通道(AA 56) + 通知ASR已开启(AA 57)'); - await bes.startCallMode(); - await bes.startTranslationDownlink(); - await bes.notifyAsrStarted(); + Logger.info('[CALL-CFG] call.start + 开译文下行通道 + asr.started'); + await session.invokeFeature(DeviceFeatures.callStart); + _callSinkA = await session.openSpeaker(route: AudioRoute.callLegA); + _callSinkB = await session.openSpeaker(route: AudioRoute.callLegB); + await session.invokeFeature(DeviceFeatures.asrStarted); } catch (e) { - Logger.error('[CALL-CFG-BES] 恒玄通话模式配置失败: $e'); + Logger.error('[CALL-CFG] 设备通话模式配置失败: $e'); } isPreparing.value = false; _audioSourceType = true; isTtsEnabled.value = false; - // 根据设备产品配置决定是否由原生层本地播报对方(B)翻译音频, - // 逻辑与杰理那份一致,只是设备名的来源换成恒玄 + // 根据设备产品配置决定是否由原生层本地播报对方(B)翻译音频 bool broadcastPeer = false; try { - final deviceName = bes.connectedDeviceName.value; + final deviceName = session.info.name; if (deviceName.isNotEmpty) { final DBProduct? product = AppConfig.getproduct(deviceName); broadcastPeer = product?.broadcastpeertranslate == true; @@ -1568,31 +1539,30 @@ class TranslationController extends GetxController with WidgetsBindingObserver { Logger.error('读取 broadcastpeertranslate 失败,回退默认 false: $e'); } await _astService.setBroadcastPeerTranslate(broadcastPeer); - Logger.info('[CALL-BES] broadcastPeerTranslate=$broadcastPeer'); - - _attachBesCallTranslationBridge(); - Logger.info('发送恒玄通话双路(mic本端/spk对端)声音'); - } + Logger.info('[CALL] broadcastPeerTranslate=$broadcastPeer'); - /// 通话翻译 ↔ 恒玄桥接: - /// micPcmStream(本端) → AST.A(己方→对端语种) - /// spkPcmStream(对端) → AST.B(对端→己方语种) - /// AST.A TTS → pushTranslationTtsPcm('A', pcm)(对端听) - /// AST.B TTS → pushTranslationTtsPcm('B', pcm)(己方听) - void _attachBesCallTranslationBridge() { - _detachBesCallTranslationBridge(); - final bes = BesBluetoothService.to; - _besCallMicSub = bes.micPcmStream.listen( - (pcm) { - _astService.pushExternalAudio(leg: 'A', pcm: pcm); - }, - onError: (e) => Logger.error('[CALL-BRIDGE-BES] mic stream error: $e'), + await _attachBesCallTranslationBridge(); + Logger.info('发送设备通话双路(本端/对端)声音'); + } + + /// 通话翻译 ↔ 设备桥接: + /// callLocal(本端) → AST.A(己方→对端语种) + /// callPeer(对端) → AST.B(对端→己方语种) + /// AST.A TTS → callLegA(对端听) + /// AST.B TTS → callLegB(己方听) + Future _attachBesCallTranslationBridge() async { + await _detachBesCallTranslationBridge(closeSinks: false); + final session = _callSession; + if (session == null) return; + _callLocalSrc = await session.openMic(route: AudioRoute.callLocal); + _callPeerSrc = await session.openMic(route: AudioRoute.callPeer); + _besCallMicSub = _callLocalSrc!.pcm.listen( + (pcm) => _astService.pushExternalAudio(leg: 'A', pcm: pcm), + onError: (e) => Logger.error('[CALL-BRIDGE] local stream error: $e'), ); - _besCallSpkSub = bes.spkPcmStream.listen( - (pcm) { - _astService.pushExternalAudio(leg: 'B', pcm: pcm); - }, - onError: (e) => Logger.error('[CALL-BRIDGE-BES] spk stream error: $e'), + _besCallSpkSub = _callPeerSrc!.pcm.listen( + (pcm) => _astService.pushExternalAudio(leg: 'B', pcm: pcm), + onError: (e) => Logger.error('[CALL-BRIDGE] peer stream error: $e'), ); // 诊断:计数 + isFinal 必打,用于定位 "TTS 到底有没有被转发到 native"。 int forwardCnt = 0; @@ -1601,121 +1571,34 @@ class TranslationController extends GetxController with WidgetsBindingObserver { forwardCnt++; if (frame.isFinal || forwardCnt == 1 || forwardCnt % 50 == 0) { Logger.info( - '[CALL-BRIDGE-BES] forward TTS leg=${frame.leg} pcm=${frame.pcm.length}B isFinal=${frame.isFinal} (#$forwardCnt)', + '[CALL-BRIDGE] forward TTS leg=${frame.leg} pcm=${frame.pcm.length}B isFinal=${frame.isFinal} (#$forwardCnt)', ); } - bes.pushTranslationTtsPcm(frame.leg, frame.pcm); + final sink = frame.leg == 'A' ? _callSinkA : _callSinkB; + sink?.write(frame.pcm); }, - onError: (e) => Logger.error('[CALL-BRIDGE-BES] ast tts stream error: $e'), + onError: (e) => Logger.error('[CALL-BRIDGE] ast tts stream error: $e'), ); - Logger.info('[CALL-BRIDGE-BES] 已连通 恒玄双路 ↔ AST'); + Logger.info('[CALL-BRIDGE] 已连通 设备双路 ↔ AST'); } - void _detachBesCallTranslationBridge() { + Future _detachBesCallTranslationBridge({bool closeSinks = true}) async { _besCallMicSub?.cancel(); _besCallMicSub = null; _besCallSpkSub?.cancel(); _besCallSpkSub = null; _besCallAstTtsSub?.cancel(); _besCallAstTtsSub = null; - } - - /// 配置通话模式(杰理,原有实现,只是改了名字) - Future _configureCallModeJieli() async { - isPreparing.value = true; - // 最多重试 3 轮 A2DP 解码器打开,每轮等待 2 秒(约 13 次 150ms 轮询) - // 部分耳机首次收到 CMD_CONTROL_CODEC 时需要重放命令才能真正切到 A2DP 流式模式 - const int maxRounds = 3; - const int attemptsPerRound = 13; - for (int round = 0; round < maxRounds; round++) { - Logger.info('[CALL-CFG] 打开 A2DP 解码器 (round=${round + 1}/$maxRounds)'); - await bleManager.openA2DPDecoder(); - int attempts = 0; - while (!bleManager.isCodecActive && attempts < attemptsPerRound) { - await Future.delayed(const Duration(milliseconds: 150)); - attempts++; - } - if (bleManager.isCodecActive) { - Logger.info('[CALL-CFG] A2DP 解码器就绪 (round=${round + 1}, attempts=$attempts)'); - break; - } - Logger.warning('[CALL-CFG] A2DP 解码器未激活,重试 (round=${round + 1})'); + await _callLocalSrc?.close(); + _callLocalSrc = null; + await _callPeerSrc?.close(); + _callPeerSrc = null; + if (closeSinks) { + await _callSinkA?.close(); + _callSinkA = null; + await _callSinkB?.close(); + _callSinkB = null; } - isPreparing.value = false; - if (!bleManager.isCodecActive) { - Get.snackbar('tip'.tr, 'bluetoothCallStateError'.tr); - throw Exception('lxm--BLE编解码打开失败'); - } - _audioSourceType = true; - isTtsEnabled.value = false; - - // 根据设备产品配置决定是否由原生层本地播报对方(B)翻译音频 - // 只有 product.broadcastpeertranslate == true 才播报;null / false / 读取异常 → 不播报 - bool broadcastPeer = false; - try { - final deviceName = bleManager.getDeviceName(); - if (deviceName.isNotEmpty) { - final DBProduct? product = AppConfig.getproduct(deviceName); - broadcastPeer = product?.broadcastpeertranslate == true; - } - } catch (e) { - Logger.error('读取 broadcastpeertranslate 失败,回退默认 false: $e'); - } - await _astService.setBroadcastPeerTranslate(broadcastPeer); - Logger.info('[CALL] broadcastPeerTranslate=$broadcastPeer'); - - // 把杰理 SCO 双向 PCM ↔ AST 流水线接上: - // in.uplink → AST.A(己方→对端语种) - // in.downlink → AST.B(对端→己方语种) - // AST.A TTS → out.uplink(对端听) - // AST.B TTS → out.downlink(己方听) - _attachJieliCallTranslationBridge(); - - Logger.info('发送ble系统mic和dac(音乐或者通话远端)声音'); - } - - void _attachJieliCallTranslationBridge() { - _detachJieliCallTranslationBridge(); - _jieliCallInUplinkSub = bleManager.inUplinkPcmStream.listen( - (pcm) { - _astService.pushExternalAudio(leg: 'A', pcm: pcm); - }, - onError: (e) => Logger.error('[CALL-BRIDGE] in.uplink stream error: $e'), - ); - _jieliCallInDownlinkSub = bleManager.inDownlinkPcmStream.listen( - (pcm) { - _astService.pushExternalAudio(leg: 'B', pcm: pcm); - }, - onError: (e) => Logger.error('[CALL-BRIDGE] in.downlink stream error: $e'), - ); - // 诊断:计数 + isFinal 必打,用于定位 “TTS 到底有没有被转发到 native”。 - int forwardCnt = 0; - _jieliCallAstTtsSub = _astService.astTtsFrames.listen( - (frame) { - forwardCnt++; - if (frame.isFinal || forwardCnt == 1 || forwardCnt % 50 == 0) { - Logger.info( - '[CALL-BRIDGE] forward TTS leg=${frame.leg} pcm=${frame.pcm.length}B isFinal=${frame.isFinal} (#$forwardCnt)', - ); - } - bleManager.feedAstTtsPcm( - leg: frame.leg, - pcm: frame.pcm, - isFinal: frame.isFinal, - ); - }, - onError: (e) => Logger.error('[CALL-BRIDGE] ast tts stream error: $e'), - ); - Logger.info('[CALL-BRIDGE] 已连通 杰理 SCO ↔ AST'); - } - - void _detachJieliCallTranslationBridge() { - _jieliCallInUplinkSub?.cancel(); - _jieliCallInUplinkSub = null; - _jieliCallInDownlinkSub?.cancel(); - _jieliCallInDownlinkSub = null; - _jieliCallAstTtsSub?.cancel(); - _jieliCallAstTtsSub = null; } /// 配置默认模式 @@ -2577,18 +2460,12 @@ class TranslationController extends GetxController with WidgetsBindingObserver { _callAudioTornDown = true; if (currentMode.value == "call" && _callModeUsingBes) { await _teardownBesCallMode(); - } else { - _detachJieliCallTranslationBridge(); - if (currentMode.value == "audioVideo" && - (Platform.isAndroid || Platform.isIOS)) { - // Android:关掉 MediaProjection 的前台服务。 - // iOS:只关我们这一端的 socket 接收;广播本身得用户从系统面板/灵动岛 - // 自己停,App 无权强停(扩展会收到 broadcastFinished 自行退出)。 - await _asrService.stopScreenCapture(); - } else { - bleManager.closeCodec(); - } - Logger.info('关闭ble编解码器'); + } else if (currentMode.value == "audioVideo" && + (Platform.isAndroid || Platform.isIOS)) { + // Android:关掉 MediaProjection 的前台服务。 + // iOS:只关我们这一端的 socket 接收;广播本身得用户从系统面板/灵动岛 + // 自己停,App 无权强停(扩展会收到 broadcastFinished 自行退出)。 + await _asrService.stopScreenCapture(); } if (currentMode.value == "call") { // 通话翻译真的结束了(不管是用户点结束、还是挂断自动停止), @@ -2597,20 +2474,22 @@ class TranslationController extends GetxController with WidgetsBindingObserver { } } - /// 恒玄通话翻译收尾:断开桥接 + 关译文下行(AA 58/56的反向) + 退通话模式(AA 52)。 + /// 设备通话翻译收尾:断开桥接 + 通知 ASR 关闭 + 关译文下行 + 退通话模式。 /// 耳机可能已经断开了,下发指令前先判断连接状态,别让收尾本身再抛异常。 Future _teardownBesCallMode() async { - _detachBesCallTranslationBridge(); - if (!Get.isRegistered()) return; - final bes = BesBluetoothService.to; - if (!bes.isConnected) return; + final session = _callSession; + _callSession = null; + final alive = session != null && session.state == DeviceConnectionState.ready; + try { + if (alive) await session.invokeFeature(DeviceFeatures.asrStopped); + } catch (_) {} + await _detachBesCallTranslationBridge(); // 关掉两路 sink = 停译文下行 + if (!alive) return; try { - await bes.notifyAsrStopped(); // AA 58 - await bes.stopTranslationDownlink(); - await bes.stopCallMode(); // AA 52 - Logger.info('[CALL-BRIDGE-BES] 已退出恒玄通话模式'); + await session.invokeFeature(DeviceFeatures.callStop); + Logger.info('[CALL-BRIDGE] 已退出设备通话模式'); } catch (e) { - Logger.error('[CALL-BRIDGE-BES] 退出恒玄通话模式失败: $e'); + Logger.error('[CALL-BRIDGE] 退出设备通话模式失败: $e'); } } diff --git a/apps/client/lib/modules/translation/middleware/controllers/translation_middleware_controller.dart b/apps/client/lib/modules/translation/middleware/controllers/translation_middleware_controller.dart index e9989d03..b1e0695e 100644 --- a/apps/client/lib/modules/translation/middleware/controllers/translation_middleware_controller.dart +++ b/apps/client/lib/modules/translation/middleware/controllers/translation_middleware_controller.dart @@ -3,12 +3,11 @@ import 'package:get/get.dart'; import 'package:flutter/material.dart'; import '../../../../core/utils/toast_throttle.dart'; import '../../../../routes/app_routes.dart'; -import '../../../../data/services/ble_manager.dart'; +import '../../../../core/utils/device_connection.dart'; /// 翻译中间层控制器 /// 负责管理翻译功能入口的导航和状态 class TranslationMiddlewareController extends GetxController { - final BleManager bleManager = Get.find(); //获取BLE管理器实例 /// 翻译功能选项列表 final List translationOptions = [ @@ -121,7 +120,7 @@ class TranslationMiddlewareController extends GetxController { bool isOptionEnabled(TranslationOption option) { // 多媒体翻译改为走手机摄像头/麦克风,不再要求 BLE 已连接 if (option.translationId == 'call') { - return bleManager.isConnected; + return DeviceConnection.any; } return true; } diff --git a/apps/client/lib/modules/translation/module.dart b/apps/client/lib/modules/translation/module.dart new file mode 100644 index 00000000..0a38c7b7 --- /dev/null +++ b/apps/client/lib/modules/translation/module.dart @@ -0,0 +1,87 @@ +import 'package:device_plugin_interface/device_plugin_interface.dart'; +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; + +import '../../routes/app_routes.dart'; +import '../home/controllers/home_controller.dart'; +import '../module_descriptor.dart'; +import 'bindings/translation_binding.dart'; +import 'bindings/translation_history_binding.dart'; +import 'bindings/translation_list_binding.dart'; +import 'middleware/bindings/translation_middleware_binding.dart'; +import 'middleware/views/translation_middleware_view.dart'; +import 'views/translation_history_view.dart'; +import 'views/translation_list_view.dart'; +import 'views/translation_view.dart'; + +GetPage _p(String name, GetPageBuilder page, Bindings binding) => GetPage( + name: name, + page: page, + binding: binding, + transition: Transition.rightToLeft, + transitionDuration: const Duration(milliseconds: 250), + ); + +/// 翻译模块的页面,四个入口共用 +final List _translationPages = [ + _p(Routes.translationMiddleware, () => const TranslationMiddlewareView(), + TranslationMiddlewareBinding()), + _p(Routes.translation, () => const TranslationView(), TranslationBinding()), + _p(Routes.translationHistory, () => const TranslationHistoryView(), + TranslationHistoryBinding()), + _p(Routes.translationList, () => const TranslationListView(), + TranslationListBinding()), +]; + +AgentModuleDescriptor _mode({ + required String id, + required String title, + required String subtitle, + required IconData icon, + required List gradient, + Set caps = const {}, +}) => + AgentModuleDescriptor( + id: id, + title: title, + subtitle: subtitle, + icon: icon, + gradient: gradient, + requiredCapabilities: caps, + onLaunch: () => HomeController.to.openTranslationFeature(id), + pages: _translationPages, + ); + +/// 同声翻译 / 面对面翻译 / 通话翻译 / 多媒体翻译——同一个翻译页的四种模式 +final List translationModules = [ + _mode( + id: 'simultaneous', + title: 'agentSimultaneous', + subtitle: 'agentSimultaneousDesc', + icon: Icons.record_voice_over, + gradient: const [Color(0xFF4CAF50), Color(0xFF009688)], + ), + _mode( + id: 'faceToFace', + title: 'agentFaceToFace', + subtitle: 'agentFaceToFaceDesc', + icon: Icons.forum, + gradient: const [Color(0xFF42A5F5), Color(0xFF1E88E5)], + ), + _mode( + id: 'call', + title: 'agentCall', + subtitle: 'agentCallDesc', + icon: Icons.phone_in_talk, + gradient: const [Color(0xFFFFA726), Color(0xFFFB8C00)], + // 通话翻译要抓通话双路音频 + caps: const {DeviceCapability.callAudioTap}, + ), + _mode( + id: 'audioVideo', + title: 'agentAudioVideo', + subtitle: 'agentAudioVideoDesc', + icon: Icons.slideshow, + gradient: const [Color(0xFF5C6BC0), Color(0xFF3949AB)], + ), +]; diff --git a/apps/client/lib/modules/translation/views/dialogs/translation_mode_selector.dart b/apps/client/lib/modules/translation/views/dialogs/translation_mode_selector.dart index 76109102..b3d80887 100644 --- a/apps/client/lib/modules/translation/views/dialogs/translation_mode_selector.dart +++ b/apps/client/lib/modules/translation/views/dialogs/translation_mode_selector.dart @@ -2,6 +2,8 @@ import 'dart:ui'; import 'dart:io'; import 'package:flutter/material.dart'; import 'package:get/get.dart'; + +import '../../../../core/utils/device_connection.dart'; import 'package:flutter_screenutil/flutter_screenutil.dart'; import '../../controllers/translation_controller.dart'; @@ -122,7 +124,7 @@ class TranslationModeSelector { Icons.videocam, Colors.orange, controller, - onTap: controller.bleManager.isConnected + onTap: DeviceConnection.any ? () async { Get.back(); await controller.stopAll(); @@ -137,7 +139,7 @@ class TranslationModeSelector { Icons.phone, Colors.purple, controller, - onTap: controller.bleManager.isConnected + onTap: DeviceConnection.any ? () async { Get.back(); await controller.stopAll(); diff --git a/apps/client/lib/routes/app_pages.dart b/apps/client/lib/routes/app_pages.dart index a8a92789..7fd1a67b 100644 --- a/apps/client/lib/routes/app_pages.dart +++ b/apps/client/lib/routes/app_pages.dart @@ -1,114 +1,69 @@ import 'package:get/get.dart'; -import '../modules/call_recording/bindings/call_recording_binding.dart'; -import '../modules/call_recording/views/call_recording_view.dart'; -import '../modules/qqmusic/bindings/qqmusic_playlist_binding.dart'; -import '../modules/qqmusic/views/qqmusic_playlist_view.dart'; -import '../modules/voice_replication/bindings/voice_replication_binding.dart'; -import '../modules/voice_replication/views/voice_replication_view.dart'; -import '/modules/ble/bindings/ble_binding.dart'; -import '../modules/ble/views/ble_view.dart'; - -import '../../modules/devices/bindings/devices_binding.dart'; -import '../../modules/devices/views/devices_view.dart'; -import '../../modules/devices/views/device_category_view.dart'; -import '../../modules/devices/views/holder_scan_view.dart'; -import '../../modules/devices/views/bes_scan_view.dart'; -import '../modules/main_tab/views/tabs/plaza_tab.dart'; -import '../modules/my_devices/bindings/my_devices_binding.dart'; -import '../modules/my_devices/views/my_devices_view.dart'; +import '../modules/devices/views/bes_scan_view.dart'; +import '../modules/devices/views/device_category_view.dart'; +import '../modules/devices/views/holder_scan_view.dart'; +import '../modules/devices/views/smartcar_scan_view.dart'; import '../modules/feedback/bindings/feedback_bindings.dart'; import '../modules/feedback/views/feedback_view.dart'; -import '../modules/home/views/home_view.dart'; -import '../modules/home/bindings/home_binding.dart'; -import '../modules/main_tab/views/main_tab_view.dart'; -import '../modules/main_tab/bindings/main_tab_binding.dart'; -import '../modules/meeting/bindings/meeting_binding.dart'; -import '../modules/meeting/bindings/meeting_connect_binding.dart'; -import '../modules/meeting/bindings/meeting_details_binding.dart'; -import '../modules/meeting/views/meeting_connect_view.dart'; -import '../modules/meeting/views/meeting_details_view.dart'; -import '../modules/meeting/bindings/meeting_record_binding.dart'; -import '../modules/meeting/views/meeting_record_view.dart'; -import '../modules/meeting/views/meeting_view.dart'; -import '../modules/opus_test/bindings/opus_test_binding.dart'; -import '../modules/opus_test/views/opus_test_view.dart'; -import '../modules/bes_test/bindings/bes_test_binding.dart'; -import '../modules/bes_test/views/bes_test_view.dart'; +import '../modules/goods/bindings/goodsmeet_binding.dart'; +import '../modules/goods/bindings/goodstrans_binding.dart'; +import '../modules/goods/bindings/goodsvip_binding.dart'; +import '../modules/goods/views/goodsmeet_view.dart'; +import '../modules/goods/views/goodstrans_view.dart'; +import '../modules/goods/views/goodsvip_view.dart'; import '../modules/log_export/bindings/log_export_binding.dart'; import '../modules/log_export/views/log_export_view.dart'; -import '../modules/profile/views/profile_view.dart'; -import '../modules/profile/views/profile_meeting_view.dart'; -import '../modules/profile/bindings/profile_binding.dart'; -// import '../modules/chat/bindings/chat_binding.dart'; -//import '../modules/chat/views/chat_view.dart'; - -import '../modules/timbre/bindings/timbre_binding.dart'; -import '../modules/timbre/views/timbre_view.dart'; -import '../modules/translation/views/translation_view.dart'; -import '../modules/translation/bindings/translation_binding.dart'; -import '../modules/translation/views/translation_history_view.dart'; -import '../modules/usage/views/usage_view.dart'; -import '../modules/usage/bindings/usage_binding.dart'; -import '../modules/translation/bindings/translation_list_binding.dart'; -import '../modules/translation/views/translation_list_view.dart'; -import '../modules/translation/bindings/translation_history_binding.dart'; -import '../modules/login/views/login_view.dart'; import '../modules/login/bindings/login_binding.dart'; -import '../modules/splash/views/splash_view.dart'; -import '../modules/splash/bindings/splash_binding.dart'; -import './app_routes.dart'; -import '../modules/settings/views/settings_view.dart'; -import '../modules/settings/bindings/settings_binding.dart'; -import '../modules/permissions/views/permissions_view.dart'; -import '../modules/permissions/bindings/permissions_binding.dart'; -import '../modules/speech_test/views/speech_test_view.dart'; -import '../modules/speech_test/bindings/speech_test_binding.dart'; -import '../modules/agent/views/agent_view.dart'; -import '../modules/emai/views/emai_view.dart'; -import '../modules/emai/bindings/emai_binding.dart'; -import '../modules/agent/bindings/agent_binding.dart'; -import '../modules/ota_upgrade/views/bes_ota_upgrade_view.dart'; +import '../modules/login/views/login_view.dart'; +import '../modules/main_tab/bindings/main_tab_binding.dart'; +import '../modules/main_tab/views/main_tab_view.dart'; +import '../modules/main_tab/views/tabs/plaza_tab.dart'; +import '../modules/mobile_elf/bindings/mobile_elf_binding.dart'; +import '../modules/mobile_elf/views/mobile_elf_view.dart'; +import '../modules/my_devices/bindings/my_devices_binding.dart'; +import '../modules/my_devices/views/my_devices_view.dart'; import '../modules/ota_upgrade/bindings/ota_upgrade_binding.dart'; -import '../modules/realtime/bindings/realtime_binding.dart'; -import '../modules/realtime/views/realtime_view.dart'; -import '../modules/music/views/music_playlist_view.dart'; -import '../modules/music/bindings/music_playlist_binding.dart'; +import '../modules/ota_upgrade/views/device_ota_view.dart'; import '../modules/payment/bindings/payment_binding.dart'; import '../modules/payment/views/payment_view.dart'; -import '../modules/translation/middleware/views/translation_middleware_view.dart'; -import '../modules/translation/middleware/bindings/translation_middleware_binding.dart'; -import '../modules/text_translation/bindings/text_translation_binding.dart'; -import '../modules/text_translation/views/text_translation_view.dart'; -import '../modules/picture_translation/views/picture_translation_view.dart'; -import '../modules/picture_translation/bindings/picture_translation_binding.dart'; -import '../modules/goods/bindings/goodsvip_binding.dart'; -import '../modules/goods/views/goodsvip_view.dart'; -import '../modules/goods/bindings/goodstrans_binding.dart'; -import '../modules/goods/views/goodstrans_view.dart'; -import '../modules/goods/bindings/goodsmeet_binding.dart'; -import '../modules/goods/views/goodsmeet_view.dart'; -import '../modules/goods/bindings/goodsai_binding.dart'; -import '../modules/goods/views/goodsai_view.dart'; -import '../modules/mobile_elf/bindings/mobile_elf_binding.dart'; -import '../modules/mobile_elf/views/mobile_elf_view.dart'; +import '../modules/permissions/bindings/permissions_binding.dart'; +import '../modules/permissions/views/permissions_view.dart'; +import '../modules/profile/bindings/profile_binding.dart'; +import '../modules/profile/views/profile_meeting_view.dart'; +import '../modules/profile/views/profile_view.dart'; +import '../modules/splash/bindings/splash_binding.dart'; +import '../modules/splash/views/splash_view.dart'; +import '../modules/voice_replication/bindings/voice_replication_binding.dart'; +import '../modules/voice_replication/views/voice_replication_view.dart'; +import '../modules/bes_test/bindings/bes_test_binding.dart'; +import '../modules/module_registry.dart'; +import '../modules/bes_test/views/bes_test_view.dart'; +import 'app_routes.dart'; +/// 路由表。 +/// +/// 只放**核心页面**(启动/登录/主框架/设备/商品/设置…)。 +/// Agent 能力模块的页面由各模块的描述符自带(见 `modules/module_registry.dart`), +/// 在末尾 `...ModuleRegistry.pages` 拼进来,新增模块不用回这里改。 abstract class AppPages { - static final pages = [ - GetPage( - name: Routes.splash, - page: () => const SplashView(), - binding: SplashBinding(), - transition: Transition.rightToLeft, - transitionDuration: Duration(milliseconds: 250), - ), - GetPage( - name: Routes.login, - page: () => const LoginView(), - binding: LoginBinding(), - transition: Transition.rightToLeft, - transitionDuration: Duration(milliseconds: 250), - ), + static const _fade = Transition.rightToLeft; + static const _dur = Duration(milliseconds: 250); + + static GetPage _page(String name, GetPageBuilder page, + {Bindings? binding, bool popGesture = true}) => + GetPage( + name: name, + page: page, + binding: binding, + popGesture: popGesture, + transition: _fade, + transitionDuration: _dur, + ); + + static final pages = [ + _page(Routes.splash, () => const SplashView(), binding: SplashBinding()), + _page(Routes.login, () => const LoginView(), binding: LoginBinding()), GetPage( name: Routes.initial, page: () => const MainTabView(), @@ -121,322 +76,42 @@ abstract class AppPages { binding: MainTabBinding(), transition: Transition.noTransition, ), - // 改版前的单页首页,保留作为回退入口,方便对照与回滚 - GetPage( - name: Routes.legacyHome, - page: () => const HomeView(), - binding: HomeBinding(), - transition: Transition.noTransition, - ), - // GetPage( - // name: Routes.chat, - // page: () => const ChatView(), - // binding: ChatBinding(), - // transition: Transition.rightToLeft, - // transitionDuration: Duration(milliseconds: 250), - // ), - GetPage( - name: Routes.recording, - page: () => const CallRecordingView(), - binding: CallRecordingBinding(), - transition: Transition.rightToLeft, - transitionDuration: Duration(milliseconds: 250), - ), - GetPage( - name: Routes.fieldRecording, - page: () => const CallRecordingView(), - binding: CallRecordingBinding(), - transition: Transition.rightToLeft, - transitionDuration: Duration(milliseconds: 250), - ), - GetPage( - name: Routes.OTA_UPGRADE, - // 本项目只有恒玄耳机:固件页走 BES 链路(bes_ota_manager 插件自管 SPP)。 - // 杰理那版 OtaUpgradeView/OtaUpgradeController 留着但不再挂路由。 - page: () => const BesOtaUpgradeView(), - binding: OtaUpgradeBinding(), - transition: Transition.rightToLeft, - transitionDuration: Duration(milliseconds: 250), - ), - // GetPage( - // name: Routes.profile, - // page: () => const ProfileView(), - // binding: ProfileBinding(), - // transition: Transition.rightToLeft, - // transitionDuration: Duration(milliseconds: 250), - // ), - GetPage( - name: Routes.editProfile, - page: () => const EditProfileView(), - binding: ProfileBinding(), - transition: Transition.rightToLeft, - transitionDuration: Duration(milliseconds: 250), - ), - GetPage( - name: Routes.profileMeeting, - page: () => ProfileMeetingView(), - binding: ProfileBinding(), - transition: Transition.rightToLeft, - transitionDuration: Duration(milliseconds: 250), - ), - GetPage( - name: Routes.translationMiddleware, - page: () => const TranslationMiddlewareView(), - binding: TranslationMiddlewareBinding(), - transition: Transition.rightToLeft, - transitionDuration: Duration(milliseconds: 250), - ), - GetPage( - name: Routes.translation, - page: () => const TranslationView(), - binding: TranslationBinding(), - transition: Transition.rightToLeft, - transitionDuration: Duration(milliseconds: 250), - ), - GetPage( - name: Routes.pictureTranslation, - page: () => const PictureTranslationView(), - binding: PictureTranslationBinding(), - transition: Transition.rightToLeft, - transitionDuration: Duration(milliseconds: 250), - ), - GetPage( - name: Routes.textTranslation, - page: () => const TextTranslationView(), - binding: TextTranslationBinding(), - transition: Transition.rightToLeft, - transitionDuration: Duration(milliseconds: 250), - ), - GetPage( - name: Routes.translationHistory, - page: () => const TranslationHistoryView(), - binding: TranslationHistoryBinding(), - transition: Transition.rightToLeft, - transitionDuration: Duration(milliseconds: 250), - ), - GetPage( - name: Routes.usageStats, - page: () => const UsageView(), - binding: UsageBinding(), - transition: Transition.rightToLeft, - transitionDuration: Duration(milliseconds: 250), - ), - GetPage( - name: Routes.translationList, - page: () => const TranslationListView(), - binding: TranslationListBinding(), - transition: Transition.rightToLeft, - transitionDuration: Duration(milliseconds: 250), - ), - GetPage( - name: Routes.meeting, - page: () => const MeetingView(), - binding: MeetingBinding(), - transition: Transition.rightToLeft, - transitionDuration: Duration(milliseconds: 250), - ), - GetPage( - name: Routes.meetingDetails, - page: () => const MeetingDetailsView(), - binding: MeetingDetailsBinding(), - transition: Transition.rightToLeft, - transitionDuration: Duration(milliseconds: 250), - ), - GetPage( - name: Routes.meetingConnect, - page: () => const MeetingConnectView(), - binding: MeetingConnectBinding(), - transition: Transition.rightToLeft, - transitionDuration: Duration(milliseconds: 250), - ), - GetPage( - name: Routes.meetingRecord, - page: () => const MeetingRecordView(), - binding: MeetingRecordBinding(), - popGesture: false, - transition: Transition.rightToLeft, - transitionDuration: Duration(milliseconds: 250), - ), - GetPage( - name: Routes.timbre, - page: () => const TimbreView(), - binding: TimbreBinding(), - transition: Transition.rightToLeft, - transitionDuration: Duration(milliseconds: 250), - ), - GetPage( - name: Routes.voiceReplication, - page: () => const VoiceReplicationView(), - binding: VoiceReplicationBinding(), - transition: Transition.rightToLeft, - transitionDuration: Duration(milliseconds: 250), - ), - GetPage( - name: Routes.settings, - page: () => const SettingsView(), - binding: SettingsBinding(), - transition: Transition.rightToLeft, - transitionDuration: Duration(milliseconds: 250), - ), - GetPage( - name: Routes.feedback, - page: () => const FeedbackView(), - binding: FeedbackBinding(), - transition: Transition.rightToLeft, - transitionDuration: Duration(milliseconds: 250), - ), - - GetPage( - name: Routes.speechTest, - page: () => const SpeechTestView(), - binding: SpeechTestBinding(), - transition: Transition.rightToLeft, - transitionDuration: Duration(milliseconds: 250), - ), - GetPage( - name: Routes.opusTest, - page: () => const OpusTestView(), - binding: OpusTestBinding(), - transition: Transition.rightToLeft, - transitionDuration: Duration(milliseconds: 250), - ), - GetPage( - name: Routes.besTest, - page: () => const BesTestView(), - binding: BesTestBinding(), - transition: Transition.rightToLeft, - transitionDuration: Duration(milliseconds: 250), - ), - GetPage( - name: Routes.logExport, - page: () => const LogExportView(), - binding: LogExportBinding(), - transition: Transition.rightToLeft, - transitionDuration: Duration(milliseconds: 250), - ), - GetPage( - name: Routes.agent, - page: () => const AgentView(), - binding: AgentBinding(), - transition: Transition.rightToLeft, - transitionDuration: Duration(milliseconds: 250), - ), - GetPage( - name: Routes.emai, - page: () => const EmaiView(), - binding: EmaiBinding(), - transition: Transition.rightToLeft, - transitionDuration: Duration(milliseconds: 250), - ), - GetPage( - name: Routes.permissions, - page: () => const PermissionsView(), - binding: PermissionsBinding(), - transition: Transition.rightToLeft, - transitionDuration: Duration(milliseconds: 250), - ), - GetPage( - name: Routes.realtime, - page: () => const RealtimeView(), - binding: RealtimeBinding(), - ), - GetPage( - name: Routes.musicPlaylist, - page: () => const MusicPlaylistView(), - binding: MusicPlaylistBinding(), - transition: Transition.rightToLeft, - transitionDuration: Duration(milliseconds: 250), - ), + _page(Routes.otaUpgrade, () => const DeviceOtaView(), + binding: OtaUpgradeBinding()), + _page(Routes.editProfile, () => const EditProfileView(), + binding: ProfileBinding()), + _page(Routes.profileMeeting, () => ProfileMeetingView(), + binding: ProfileBinding()), + _page(Routes.voiceReplication, () => const VoiceReplicationView(), + binding: VoiceReplicationBinding()), + _page(Routes.feedback, () => const FeedbackView(), + binding: FeedbackBinding()), + _page(Routes.logExport, () => const LogExportView(), + binding: LogExportBinding()), + _page(Routes.permissions, () => const PermissionsView(), + binding: PermissionsBinding()), // 添加设备第一步:选品类(纯展示,无需 binding) - GetPage( - name: Routes.deviceCategory, - page: () => const DeviceCategoryView(), - transition: Transition.rightToLeft, - transitionDuration: Duration(milliseconds: 250), - ), - GetPage( - name: Routes.holderScan, - page: () => const HolderScanView(), - transition: Transition.rightToLeft, - transitionDuration: Duration(milliseconds: 250), - ), - GetPage( - name: Routes.plaza, - page: () => const PlazaTab(), - transition: Transition.rightToLeft, - transitionDuration: Duration(milliseconds: 250), - ), - GetPage( - name: Routes.besScan, - page: () => const BesScanView(), - transition: Transition.rightToLeft, - transitionDuration: Duration(milliseconds: 250), - ), - GetPage( - name: Routes.devices, - page: () => const DevicesView(), - binding: DevicesBinding(), - transition: Transition.rightToLeft, - transitionDuration: Duration(milliseconds: 250), - ), - GetPage( - name: Routes.myDevices, - page: () => const MyDevicesView(), - binding: MyDevicesBinding(), - transition: Transition.rightToLeft, - transitionDuration: Duration(milliseconds: 250), - ), - GetPage( - name: Routes.ble, - page: () => const BleView(), - binding: BleBinding(), - ), - GetPage( - name: Routes.qqmusic, - page: () => const QqmusicPlaylistView(), - binding: QQMusicPlaylistBinding(), - ), - GetPage( - name: Routes.payment, - page: () => const PaymentView(), - binding: PaymentBinding(), - transition: Transition.rightToLeft, - transitionDuration: Duration(milliseconds: 250), - ), - GetPage( - name: Routes.goodsvip, - page: () => const GoodsvipView(), - binding: GoodsvipBinding(), - transition: Transition.rightToLeft, - transitionDuration: Duration(milliseconds: 250), - ), - GetPage( - name: Routes.goodstrans, - page: () => const GoodstransView(), - binding: GoodstransBinding(), - transition: Transition.rightToLeft, - transitionDuration: Duration(milliseconds: 250), - ), - GetPage( - name: Routes.goodsmeet, - page: () => const GoodsmeetView(), - binding: GoodsmeetBinding(), - transition: Transition.rightToLeft, - transitionDuration: Duration(milliseconds: 250), - ), - GetPage( - name: Routes.goodsai, - page: () => const GoodsaiView(), - binding: GoodsaiBinding(), - transition: Transition.rightToLeft, - transitionDuration: Duration(milliseconds: 250), - ), - GetPage( - name: Routes.mobileElf, - page: () => const MobileElfView(), - binding: MobileElfBinding(), - transition: Transition.rightToLeft, - transitionDuration: Duration(milliseconds: 250), - ), + _page(Routes.deviceCategory, () => const DeviceCategoryView()), + _page(Routes.holderScan, () => const HolderScanView()), + _page(Routes.smartcarScan, () => const SmartcarScanView()), + _page(Routes.besScan, () => const BesScanView()), + _page(Routes.plaza, () => const PlazaTab()), + _page(Routes.myDevices, () => const MyDevicesView(), + binding: MyDevicesBinding()), + _page(Routes.payment, () => const PaymentView(), + binding: PaymentBinding()), + _page(Routes.goodsvip, () => const GoodsvipView(), + binding: GoodsvipBinding()), + _page(Routes.goodstrans, () => const GoodstransView(), + binding: GoodstransBinding()), + _page(Routes.goodsmeet, () => const GoodsmeetView(), + binding: GoodsmeetBinding()), + _page(Routes.mobileElf, () => const MobileElfView(), + binding: MobileElfBinding()), + // 设备协议探测页(设置 → 开发者),只在需要重扫固件命令时用 + _page(Routes.besTest, () => const BesTestView(), + binding: BesTestBinding()), + // ---------- Agent 能力模块自带的页面 ---------- + ...ModuleRegistry.pages, ]; } diff --git a/apps/client/lib/routes/app_routes.dart b/apps/client/lib/routes/app_routes.dart index 6cb07e0a..a85d4ed8 100644 --- a/apps/client/lib/routes/app_routes.dart +++ b/apps/client/lib/routes/app_routes.dart @@ -1,90 +1,62 @@ +/// 路由常量。 +/// +/// 只放核心页面;Agent 能力模块的路由由各模块描述符自带。 +/// 这里每一条都必须在 `app_pages.dart` 注册且有跳转方,`test/routes_test.dart` 守着。 abstract class Routes { // 认证相关 static const splash = '/splash'; static const login = '/login'; - // 主功能模块 + // 主框架(底部五 tab:设备/代办/AI/广场/我的) static const initial = '/home'; - static const home = '/home'; // 底部五 tab 主框架(设备/代办/AI/广场/我的) - static const legacyHome = '/legacy_home'; // 改版前的单页首页,保留备查 - static const chat = '/chat'; - static const profile = '/profile'; + static const home = '/home'; static const editProfile = '/edit_profile'; static const profileMeeting = '/profile_meeting'; // 个人信息页面(从会议模块跳转) - // 设备管理 - static const OTA_UPGRADE = '/ota_upgrade'; // OTA升级 - static const timbre = '/timbre'; // 音色设置 + // 设备 + static const otaUpgrade = '/ota_upgrade'; // 固件升级 + static const deviceCategory = '/device_category'; // 添加设备第一步:选择产品品类 + static const holderScan = '/holder_scan'; // EaiRec 录音支架扫描/连接 + static const smartcarScan = '/smartcar_scan'; // Smartcar 车载香薰扫描/连接 + static const besScan = '/bes_scan'; // 恒玄耳机(经典蓝牙)连接 + static const myDevices = '/my_devices'; // 用户已绑定设备列表 + static const besTest = '/bes_test'; // 恒玄协议探测页(开发者) + + // 录音 + static const recording = '/recording'; // 通话录音 + static const fieldRecording = '/field_recording'; // 现场录音 + + // 设置 static const voiceReplication = '/voice_replication'; // 声音复刻 - static const settings = '/settings'; // 系统设置 static const permissions = '/permissions'; // 权限管理 - static const recording = '/recording'; // 录音功能 - static const eqSettings = '/eq_settings'; // 设备设置 + static const logExport = '/log_export'; // 日志导出 + static const feedback = '/feedback'; // 用户反馈 // 会议相关 static const meeting = '/meeting'; static const meetingDetails = '/meeting/details'; static const meetingConnect = '/meeting/connect'; static const meetingRecord = '/meeting/record'; - // 语言功能 + + // 翻译 static const translationMiddleware = '/translationMiddleware'; // 翻译中间层(统一入口) - static const translation = '/translation'; // 翻译 - static const pictureTranslation = '/picture_translation'; // 图片翻译 - static const textTranslation = '/text_translation'; // 文本翻译 - static const translationHistory = '/translation/history'; // 翻译历史 - static const usageStats = '/usage/stats'; // 用量统计 - static const speechDemo = '/speech_demo'; // 语音演示 - static const translationList = '/translation/List'; // 翻译历史 - // 测试路由(生产环境建议移除) - static const ttsTest = '/tts_test'; // 文字转语音测试 - static const asrTest = '/asr_test'; // 语音识别测试 - static const azureAsrTest = '/azure_asr_test'; // Azure语音识别 - static const azureTtsTest = '/azure_tts_test'; // Azure文字转语音 - static const volcanoAsrTest = '/volcano_asr_test'; // 火山引擎语音识别 - static const flutterTtsTest = '/flutter_tts_test'; // Flutter TTS测试 - static const flutterAsrTest = '/flutter_asr_test'; // Flutter ASR测试 - static const jieliTest = '/jieli_test'; // 杰理芯片测试 - static const opusTest = '/opus_test'; // Opus编码测试 - static const logExport = '/log_export'; // 日志导出 - static const bleTest = '/ble_test'; // 蓝牙测试 - static const besTest = '/bes_test'; // 恒玄(BES)蓝牙连接测试 - static const speechTest = '/speech_test'; // 综合语音测试 + static const translation = '/translation'; + static const pictureTranslation = '/picture_translation'; + static const textTranslation = '/text_translation'; + static const translationHistory = '/translation/history'; + static const translationList = '/translation/List'; - // 支持服务 - static const feedback = '/feedback'; // 用户反馈 - static const agent = '/agent'; // 在线客服 + // AI 助手 static const emai = '/emai'; // EMAI 助手(阿里百炼多模态对话) - static const navigation = '/navigation'; // 实时导航 - - // 音乐相关 - static const musicPlaylist = '/music/playlist'; // 音乐播放列表 - - // 实时语音对话 - static const realtime = '/realtime'; - - // 移动精灵 - static const mobileElf = '/mobile_elf'; - - // 设备管理 - static const deviceCategory = '/device_category'; // 添加设备第一步:选择产品品类 - static const holderScan = '/holder_scan'; // 蓝讯支架扫描/连接 - static const besScan = '/bes_scan'; // 恒玄耳机(经典蓝牙)连接 - static const plaza = '/plaza'; // 广场(原底部 tab,改成「我的」里的入口) - static const devices = '/devices'; // 扫描页:发现/绑定/连接新设备 - static const fieldRecording = '/field_recording'; // 现场录音 - static const myDevices = '/my_devices'; // 用户已绑定设备列表 - - // ble设备管理 - static const ble = '/ble'; - // ble设备管理 - static const qqmusic = '/qqmusic'; + static const smartcarChat = '/smartcar_chat'; // Smartcar 车载助手(同一套对话 UI,另一个百炼应用) + static const mobileElf = '/mobile_elf'; // 移动精灵 - // 支付相关 - static const payment = '/payment'; // 支付测试页面 + // 广场 + static const plaza = '/plaza'; - //商品购买页面 - static const goodsvip = '/goodsvip'; //vip商品页面 - static const goodstrans = '/goodstrans'; //翻译商品页面 - static const goodsmeet = '/goodsmeet'; //会议商品页面 - static const goodsai = '/goodsai'; //ai商品页面 + // 支付 / 商品 + static const payment = '/payment'; + static const goodsvip = '/goodsvip'; + static const goodstrans = '/goodstrans'; + static const goodsmeet = '/goodsmeet'; } 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 b3a66b38..1a87a654 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 @@ -140,8 +140,13 @@ public class BleService: NSObject { } os_log("🔧 开始初始化CBCentralManager", log: logger, type: .info) + // ⚠️ **不要在这里申请状态恢复(restore identifier)。** + // 这个插件 Dart 侧已零引用,只因 agent_service 的原生依赖还链在工程里, + // 但它在 GeneratedPluginRegistrant 里排在 recorder_holder 前面、注册时就建 central。 + // iOS 上同一进程里第二个带 restore identifier 的 central 拿不到真实电源状态 + // (恒为 4=poweredOff),于是真正在用的支架插件 startScan 永远「蓝牙未就绪」。 + // 2026-09-11 iPhone 12 / iOS 26 真机复现;把这里的 restore id 去掉后支架恢复正常。 let options: [String: Any] = [ - CBCentralManagerOptionRestoreIdentifierKey: "BLEAssistantCentral", CBCentralManagerOptionShowPowerAlertKey: true ] diff --git a/apps/client/local_plugins/bluetooth_manager/android/build.gradle b/apps/client/local_plugins/bluetooth_manager/android/build.gradle index ca0a10e7..930dbf12 100644 --- a/apps/client/local_plugins/bluetooth_manager/android/build.gradle +++ b/apps/client/local_plugins/bluetooth_manager/android/build.gradle @@ -29,6 +29,10 @@ android { compileSdk = 36 + // 与主工程一致。NDK r28 起链接器默认 16KB 页对齐;不声明就会退回工具链默认的 + // 老 NDK,产出 p_align=0x1000 的 .so,Google Play 的 16KB 检查直接拒收。 + ndkVersion = "28.2.13676358" + compileOptions { sourceCompatibility = JavaVersion.VERSION_11 targetCompatibility = JavaVersion.VERSION_11 @@ -50,6 +54,8 @@ android { externalNativeBuild { cmake { cppFlags "" + // NDK r27+ 支持,显式要求可变页大小(16KB) + arguments "-DANDROID_SUPPORT_FLEXIBLE_PAGE_SIZES=ON" } } } diff --git a/apps/client/local_plugins/bluetooth_manager/android/src/main/cpp/CMakeLists.txt b/apps/client/local_plugins/bluetooth_manager/android/src/main/cpp/CMakeLists.txt index 72a8282f..be8d39f3 100644 --- a/apps/client/local_plugins/bluetooth_manager/android/src/main/cpp/CMakeLists.txt +++ b/apps/client/local_plugins/bluetooth_manager/android/src/main/cpp/CMakeLists.txt @@ -58,3 +58,9 @@ target_link_libraries(msbc_jni sbc ${log-lib} ) + +# 16KB 页对齐兜底:任何 NDK 版本都生效,不依赖上面 gradle 里的 ndkVersion 声明。 +# Google Play 自 2025-11-01 起要求 64 位 .so 的 LOAD 段 p_align >= 0x4000, +# 不合规直接拒收(无延期)。 +target_link_options(g722_jni PRIVATE "-Wl,-z,max-page-size=16384") +target_link_options(msbc_jni PRIVATE "-Wl,-z,max-page-size=16384") diff --git a/apps/client/local_plugins/classic_bluetooth/android/src/main/kotlin/com/yunqiinnovation/classic_bluetooth/ClassicBluetoothHelper.kt b/apps/client/local_plugins/classic_bluetooth/android/src/main/kotlin/com/yunqiinnovation/classic_bluetooth/ClassicBluetoothHelper.kt index 3f5ca6e5..198c50db 100644 --- a/apps/client/local_plugins/classic_bluetooth/android/src/main/kotlin/com/yunqiinnovation/classic_bluetooth/ClassicBluetoothHelper.kt +++ b/apps/client/local_plugins/classic_bluetooth/android/src/main/kotlin/com/yunqiinnovation/classic_bluetooth/ClassicBluetoothHelper.kt @@ -66,6 +66,86 @@ class ClassicBluetoothHelper(private val context: Context) { } } + /** + * 发起经典蓝牙配对(A2DP 音频通路的前置动作)。 + * + * ⚠️ **这是 2026-09-11 为车载香薰新增的方法,纯增量**:原有的 + * `getConnectedDevices` / `isBluetoothEnabled` / 状态监听一个字节都没动, + * 依赖本插件的 `azure_speech`(iOS 的 MicrophoneCapture)不受影响。 + * + * Android 上「连 A2DP」的正确做法就是 `createBond()`:配对成功后系统会自己 + * 把 A2DP/HFP 接起来,**没有公开 API 直接连某个 profile**。所以这里只负责配对, + * 连接与否交给系统。返回值语义(对齐 lumi): + * - `already_bonded` 已配对,系统会自动连 + * - `bonding_in_progress` 正在配对 + * - `bonding_started` 已发起配对,系统会弹配对框 + */ + fun connectDevice(macAddress: String, callback: ConnectCallback) { + try { + if (!BluetoothAdapter.checkBluetoothAddress(macAddress)) { + callback.onError("INVALID_MAC", "非法的 MAC 地址: $macAddress") + return + } + if (!hasBluetoothConnectPermission()) { + callback.onError("PERMISSION_DENIED", "缺少 BLUETOOTH_CONNECT 权限") + return + } + val adapter = BluetoothAdapter.getDefaultAdapter() + if (adapter == null) { + callback.onError("NO_ADAPTER", "没有找到蓝牙适配器") + return + } + if (!adapter.isEnabled) { + callback.onError("BT_DISABLED", "蓝牙未开启") + return + } + val device = adapter.getRemoteDevice(macAddress) + val bondState = try { + device.bondState + } catch (e: SecurityException) { + callback.onError("PERMISSION_DENIED", "读取 bondState 失败: ${e.message}") + return + } + when (bondState) { + BluetoothDevice.BOND_BONDED -> callback.onSuccess("already_bonded") + BluetoothDevice.BOND_BONDING -> callback.onSuccess("bonding_in_progress") + else -> { + val started = try { + device.createBond() + } catch (e: SecurityException) { + callback.onError("PERMISSION_DENIED", "createBond 失败: ${e.message}") + return + } + if (started) { + callback.onSuccess("bonding_started") + } else { + callback.onError("BOND_FAILED", "createBond 返回 false") + } + } + } + } catch (e: Exception) { + callback.onError("BLUETOOTH_ERROR", e.message ?: "未知错误") + } + } + + /** + * 这台 MAC 当前是不是已经接上了 A2DP(真正决定"能不能出声"的判据)。 + * 拿不到就回 false,绝不阻塞调用方。 + */ + fun isA2dpConnected(macAddress: String, callback: (Boolean) -> Unit) { + if (!hasBluetoothConnectPermission()) { + callback(false) + return + } + getConnectedBluetoothDevices(BluetoothProfile.A2DP, object : BluetoothDevicesCallback { + override fun onSuccess(devices: List>) { + callback(devices.any { it["address"].equals(macAddress, ignoreCase = true) }) + } + + override fun onError(code: String, message: String) = callback(false) + }) + } + /** * 检查蓝牙是否启用 * @return 蓝牙是否启用 @@ -394,6 +474,12 @@ class ClassicBluetoothHelper(private val context: Context) { fun onSuccess(devices: List>) fun onError(code: String, message: String) } + + /** [connectDevice] 的回调(2026-09-11 新增) */ + interface ConnectCallback { + fun onSuccess(status: String) + fun onError(code: String, message: String) + } /** * 蓝牙状态回调接口 diff --git a/apps/client/local_plugins/classic_bluetooth/android/src/main/kotlin/com/yunqiinnovation/classic_bluetooth/ClassicBluetoothPlugin.kt b/apps/client/local_plugins/classic_bluetooth/android/src/main/kotlin/com/yunqiinnovation/classic_bluetooth/ClassicBluetoothPlugin.kt index d2174a77..109e9cc6 100644 --- a/apps/client/local_plugins/classic_bluetooth/android/src/main/kotlin/com/yunqiinnovation/classic_bluetooth/ClassicBluetoothPlugin.kt +++ b/apps/client/local_plugins/classic_bluetooth/android/src/main/kotlin/com/yunqiinnovation/classic_bluetooth/ClassicBluetoothPlugin.kt @@ -59,6 +59,26 @@ class ClassicBluetoothPlugin: FlutterPlugin, MethodCallHandler { "isBluetoothEnabled" -> { result.success(bluetoothHelper.isBluetoothEnabled()) } + // ↓ 2026-09-11 为车载香薰新增,原有分支未改动 + "connectDevice" -> { + val mac = call.argument("macAddress") + if (mac.isNullOrEmpty()) { + result.error("INVALID_MAC", "缺少 macAddress", null) + } else { + bluetoothHelper.connectDevice(mac, object : ClassicBluetoothHelper.ConnectCallback { + override fun onSuccess(status: String) = result.success(status) + override fun onError(code: String, message: String) = result.error(code, message, null) + }) + } + } + "isA2dpConnected" -> { + val mac = call.argument("macAddress") + if (mac.isNullOrEmpty()) { + result.success(false) + } else { + bluetoothHelper.isA2dpConnected(mac) { result.success(it) } + } + } else -> { result.notImplemented() } 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 6db8ac02..dde0287f 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 @@ -246,6 +246,23 @@ public class ClassicBluetoothHelper: NSObject, CBCentralManagerDelegate { return portType == .bluetoothA2DP || portType == .bluetoothHFP || portType == .bluetoothLE } + /// 这台 MAC(或设备名)当前是不是正接在音频输出上(= A2DP 通了)。 + /// + /// 2026-09-11 为车载香薰新增。iOS 上 `AVAudioSession` 的 `output.uid` 形如 + /// `A8:1A:B3:21:E4:DD-tacl`,去掉后缀就是**经典蓝牙 MAC**——这也是 iOS 上唯一 + /// 能拿到设备经典 MAC 的地方(BLE 那侧只有 peripheral UUID)。 + /// 传空串时只要有任意蓝牙音频输出就算连上。 + public func isAudioDeviceConnected(address: String) -> Bool { + let devices = getConnectedAudioDevices() + if address.isEmpty { return !devices.isEmpty } + let target = address.uppercased() + return devices.contains { d in + let addr = (d["address"] ?? "").uppercased() + let name = (d["name"] ?? "").uppercased() + return addr == target || name == target + } + } + // 清理设备地址 private func cleanDeviceAddress(_ address: String) -> String { // 移除类似 "-tacl" 的后缀 diff --git a/apps/client/local_plugins/classic_bluetooth/ios/classic_bluetooth/Sources/classic_bluetooth/SwiftClassicBluetoothPlugin.swift b/apps/client/local_plugins/classic_bluetooth/ios/classic_bluetooth/Sources/classic_bluetooth/SwiftClassicBluetoothPlugin.swift index 6f445ea1..c5c296b5 100644 --- a/apps/client/local_plugins/classic_bluetooth/ios/classic_bluetooth/Sources/classic_bluetooth/SwiftClassicBluetoothPlugin.swift +++ b/apps/client/local_plugins/classic_bluetooth/ios/classic_bluetooth/Sources/classic_bluetooth/SwiftClassicBluetoothPlugin.swift @@ -34,6 +34,15 @@ public class SwiftClassicBluetoothPlugin: NSObject, FlutterPlugin { getConnectedDevices(result: result) case "isBluetoothEnabled": result(bluetoothHelper.isBluetoothEnabled()) + // ↓ 2026-09-11 为车载香薰新增,原有分支未改动 + case "connectDevice": + // iOS **没有**发起经典蓝牙配对的公开 API(Apple 限制), + // 只能引导用户去「设置 → 蓝牙」自己配一次。返回固定串让上层区分平台。 + result("unsupported_ios") + case "isA2dpConnected": + let args = call.arguments as? [String: Any] + let mac = (args?["macAddress"] as? String) ?? "" + result(bluetoothHelper.isAudioDeviceConnected(address: mac)) default: result(FlutterMethodNotImplemented) } diff --git a/apps/client/local_plugins/classic_bluetooth/lib/classic_bluetooth.dart b/apps/client/local_plugins/classic_bluetooth/lib/classic_bluetooth.dart index 2deef982..1a5495e6 100644 --- a/apps/client/local_plugins/classic_bluetooth/lib/classic_bluetooth.dart +++ b/apps/client/local_plugins/classic_bluetooth/lib/classic_bluetooth.dart @@ -23,6 +23,40 @@ class ClassicBluetooth { return await _methodChannel.invokeMethod('isBluetoothEnabled'); } + /// 发起经典蓝牙配对(A2DP 出声的前置动作)。**2026-09-11 为车载香薰新增。** + /// + /// 返回原生层的状态串: + /// - Android:`already_bonded` / `bonding_in_progress` / `bonding_started` + /// (Android 没有"直接连某个 profile"的公开 API,配对成功后系统自己接 A2DP) + /// - iOS:恒为 `unsupported_ios` —— Apple 不提供发起经典蓝牙配对的接口, + /// 只能引导用户去「设置 → 蓝牙」手动配一次 + /// + /// 失败抛 [PlatformException](`INVALID_MAC` / `PERMISSION_DENIED` / + /// `BT_DISABLED` / `NO_ADAPTER` / `BOND_FAILED`)。 + static Future connectDevice(String macAddress) async { + return await _methodChannel.invokeMethod( + 'connectDevice', + {'macAddress': macAddress}, + ); + } + + /// 这台 MAC 当前是不是已经接上音频通路(A2DP)。**2026-09-11 新增。** + /// + /// Android 查 `BluetoothProfile.A2DP` 的已连接列表; + /// iOS 查 `AVAudioSession` 的当前输出(`uid` 去掉后缀就是经典蓝牙 MAC)。 + /// 拿不到一律 false,不抛。 + static Future isA2dpConnected(String macAddress) async { + try { + return await _methodChannel.invokeMethod( + 'isA2dpConnected', + {'macAddress': macAddress}, + ) ?? + false; + } catch (_) { + return false; + } + } + /// 监听蓝牙事件(状态变化、设备连接/断开) static Stream get events { _eventStream ??= _eventChannel diff --git a/apps/client/local_plugins/device_jieli/ios/device_jieli/Package.swift b/apps/client/local_plugins/device_jieli/ios/device_jieli/Package.swift index ef3ec31c..e476e898 100644 --- a/apps/client/local_plugins/device_jieli/ios/device_jieli/Package.swift +++ b/apps/client/local_plugins/device_jieli/ios/device_jieli/Package.swift @@ -20,6 +20,14 @@ let package = Package( // recorder_holder(蓝讯支架)也要用同一份,跨包同名 target 会冲突, // 所以这里以产品形式暴露,供其复用。 .library(name: "jl-audio-unit-kit", targets: ["JLAudioUnitKit"]), + // JL_BLEKit.xcframework(杰理 BLE + RCSP 认证,**动态库**)同理暴露给 + // device_smartcar(车载香薰)复用:Smartcar 固件要求先过 JL_Assist 的 RCSP + // 认证才把手机当成已连接。再 vendor 一份动态库会在运行时报 + // 「Class JL_xxx is implemented in both …」且行为不可预期,所以只能共享这一份。 + .library(name: "jl-blekit", targets: ["JL_BLEKit"]), + // JL_OTALib.xcframework(杰理 OTA)同理暴露给 device_smartcar:Smartcar 固件升级走 + // cmdOTAData + stage2 扫描回连(lumi 的做法)。 + .library(name: "jl-otalib", targets: ["JL_OTALib"]), ], dependencies: [], targets: [ diff --git a/apps/client/local_plugins/device_jieli/pubspec.yaml b/apps/client/local_plugins/device_jieli/pubspec.yaml index b82ad95f..d1466156 100644 --- a/apps/client/local_plugins/device_jieli/pubspec.yaml +++ b/apps/client/local_plugins/device_jieli/pubspec.yaml @@ -37,9 +37,9 @@ flutter: # adding or updating assets for this project. plugin: platforms: - android: - package: com.jielihome.jielihome - pluginClass: JielihomePlugin + # ⚠️ Android 已不再装配:Dart 侧不再使用杰理,且它的 Android 模块依赖 + # device_plugin_interface 的原生工程(已改成纯 Dart 契约)。 + # 只保留 iOS:recorder_holder 的 Package.swift 引用本插件 vendor 的 JLAudioUnitKit。 ios: pluginClass: JielihomePlugin sharedDarwinSource: false diff --git a/apps/client/local_plugins/device_plugin_interface/android/build.gradle b/apps/client/local_plugins/device_plugin_interface/android/build.gradle deleted file mode 100644 index 821b1198..00000000 --- a/apps/client/local_plugins/device_plugin_interface/android/build.gradle +++ /dev/null @@ -1,37 +0,0 @@ -buildscript { - repositories { - google() - mavenCentral() - } - dependencies { - classpath "com.android.tools.build:gradle:8.7.3" - classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:2.1.0" - } -} - -apply plugin: "com.android.library" -apply plugin: "kotlin-android" - -android { - namespace "com.aiagent.device_plugin_interface" - compileSdk 35 - - defaultConfig { - minSdk 24 - } - - compileOptions { - sourceCompatibility JavaVersion.VERSION_17 - targetCompatibility JavaVersion.VERSION_17 - } - - kotlinOptions { - jvmTarget = "17" - } -} - -dependencies { - implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:1.9.0" - // 注意:本包不依赖任何 native 厂商实现;它只定义接口与数据类, - // 厂商插件(device_jieli 等)依赖它来 implement,不能反向依赖。 -} diff --git a/apps/client/local_plugins/device_plugin_interface/android/src/main/AndroidManifest.xml b/apps/client/local_plugins/device_plugin_interface/android/src/main/AndroidManifest.xml deleted file mode 100644 index 31aa4282..00000000 --- a/apps/client/local_plugins/device_plugin_interface/android/src/main/AndroidManifest.xml +++ /dev/null @@ -1,3 +0,0 @@ - - diff --git a/apps/client/local_plugins/device_plugin_interface/android/src/main/kotlin/com/aiagent/device_plugin_interface/Assistant.kt b/apps/client/local_plugins/device_plugin_interface/android/src/main/kotlin/com/aiagent/device_plugin_interface/Assistant.kt deleted file mode 100644 index 81c54fb5..00000000 --- a/apps/client/local_plugins/device_plugin_interface/android/src/main/kotlin/com/aiagent/device_plugin_interface/Assistant.kt +++ /dev/null @@ -1,149 +0,0 @@ -package com.aiagent.device_plugin_interface - -import kotlinx.coroutines.flow.Flow - -/** - * AI 助理场景的音频载荷编码。 - * - * 与 [CallAudioCodec] 并列但语义独立 —— AI 助理是单向对话(用户麦 → AI → 耳机扬声器), - * 没有通话翻译的 UPLINK/DOWNLINK 概念。 - */ -enum class AssistantAudioCodec { OPUS, PCM_S16LE } - -/** AI 助理场景的音频格式描述。 */ -data class AssistantAudioFormat( - val codec: AssistantAudioCodec, - val sampleRate: Int, - val channels: Int, - val frameMs: Int, -) { - companion object { - /** 16 kHz / mono / 20 ms 一帧 OPUS(耳机原生格式) */ - val OPUS_16K_MONO_20MS = AssistantAudioFormat(AssistantAudioCodec.OPUS, 16000, 1, 20) - - /** 16 kHz / mono / 20 ms 一帧 PCM_S16LE = 640 字节 */ - val PCM_S16LE_16K_MONO_20MS = AssistantAudioFormat(AssistantAudioCodec.PCM_S16LE, 16000, 1, 20) - } -} - -/** - * device → 编排器:一帧用户麦克风音频(单路上行)。 - * - * 与 [CallAudioFrame] 的区别:**无 leg 字段**。AI 助理场景只有"用户说话"一路输入, - * 不存在"远端通话音"的概念。 - */ -data class AssistantAudioFrame( - val codec: AssistantAudioCodec, - val sampleRate: Int, - val channels: Int, - val bytes: ByteArray, - /** 单调递增帧序号;丢帧后**不得**回填。 */ - val sequence: Long, - /** 单调递增的捕获时间戳(微秒)。 */ - val timestampUs: Long, -) { - override fun equals(other: Any?): Boolean { - if (this === other) return true - if (other !is AssistantAudioFrame) return false - return sequence == other.sequence && bytes.contentEquals(other.bytes) - } - override fun hashCode(): Int { - var r = sequence.hashCode() - r = 31 * r + bytes.contentHashCode() - return r - } -} - -/** - * 编排器 → device:把 AI 的 TTS 音频回灌给耳机扬声器播放。 - * - * 与 [TranslatedAudioFrame] 的区别:**无 leg 字段**。AI 助理只有一个回放方向 - * (AI 回复 → 戴耳机的用户),不存在"回灌给对端"的概念。 - * - * @param isFinal 本帧是否为当前 utterance 的最后一帧。device 端可能据此决定是否 - * 触发整段编码 / 一次性下发(例如 jieli RCSP 场景下按"一段 = 一个 AudioData"下发)。 - */ -data class AssistantPlaybackFrame( - val codec: AssistantAudioCodec, - val sampleRate: Int, - val channels: Int, - val bytes: ByteArray, - val isFinal: Boolean = false, -) { - override fun equals(other: Any?): Boolean { - if (this === other) return true - if (other !is AssistantPlaybackFrame) return false - return codec == other.codec && bytes.contentEquals(other.bytes) - } - override fun hashCode(): Int { - var r = codec.hashCode() - r = 31 * r + bytes.contentHashCode() - return r - } -} - -/** AI 助理场景下由 device 抛出的错误(不致命:编排器决定是否 stop)。 */ -data class AssistantError( - val code: String, - val message: String, -) - -/** - * AI 助理设备能力端口(device 侧)。 - * - * 由设备厂商插件按需实现(例如杰理基于 RCSP `MODE_CALL_TRANSLATION` 的 `UP_LINK` 上行 + - * `OUT_UPLINK` 回灌落地一份 `JieliAssistantPort`)。`assistant_server` 编排器统一通过 - * 本接口拿用户麦克风 PCM、回灌 AI TTS,不依赖具体 vendor。 - * - * 与 [DeviceCallTranslationPort] 并列但**语义完全独立**: - * - 不存在 leg 概念(通话翻译特有的上下行) - * - 上行流只有一路(用户麦) - * - 下行回灌只有一路(耳机扬声器) - * - * 生命周期: - * ``` - * idle → enter(format) → active → exit() → idle - * ``` - * - * 铁律: - * - 同一设备同一时刻**至多一个** active 端口;重复 `enter` 抛 - * `IllegalStateException("device.assistant.busy")`; - * - 协商格式([enter] 入参)必须出自 [supportedSourceFormats] 列出的范围; - * - [reportPlayback] 的 codec/sampleRate/channels 必须落在 [supportedSinkFormats] 内; - * - [exit] 后 [audioFrames]/[errors] 仍可订阅,但不再产新事件。 - */ -interface DeviceAssistantPort { - /** 该端口能向上派发的用户麦克风音频格式集合。 */ - fun supportedSourceFormats(): Set - - /** 该端口能接受的 TTS 回灌格式集合。 */ - fun supportedSinkFormats(): Set - - /** - * 进入 AI 助理模式。 - * - * @param sourceFormat 协商好的源音频格式(必须 ∈ [supportedSourceFormats])。 - * device 侧据此决定是否做 OPUS↔PCM 转换。 - */ - fun enter(sourceFormat: AssistantAudioFormat) - - /** - * 用户麦克风上行音频帧流。`enter` 之后开始派发,`exit` 后停止。 - * 必须是 broadcast / replayable 不变的实现,编排器多次订阅安全。 - */ - val audioFrames: Flow - - /** - * 把 AI 的 TTS 音频回灌给耳机扬声器。 - * - * @throws IllegalArgumentException codec 不在 [supportedSinkFormats] 内 - * @throws IllegalStateException 当前未在 active 状态 - */ - fun reportPlayback(frame: AssistantPlaybackFrame) - - /** 退出 AI 助理模式,归还设备 RCSP / 音频通道。 */ - fun exit() - - /** 错误流。**不致命**——编排器自行决定是否 [exit]。 */ - val errors: Flow -} diff --git a/apps/client/local_plugins/device_plugin_interface/android/src/main/kotlin/com/aiagent/device_plugin_interface/CallRecording.kt b/apps/client/local_plugins/device_plugin_interface/android/src/main/kotlin/com/aiagent/device_plugin_interface/CallRecording.kt deleted file mode 100644 index 50c11d91..00000000 --- a/apps/client/local_plugins/device_plugin_interface/android/src/main/kotlin/com/aiagent/device_plugin_interface/CallRecording.kt +++ /dev/null @@ -1,126 +0,0 @@ -package com.aiagent.device_plugin_interface - -import kotlinx.coroutines.flow.Flow - -/** - * 通话录音两条腿(leg)的方向: - * - [UPLINK]: 本端说出去(戴耳机的人 / 会议本地参会者) → 录音 - * - [DOWNLINK]: 对端说过来(远端通话音 / 会议参会对方) → 录音 - * - * 与 [CallTranslationLeg] 同义;区分独立枚举仅为防止跨场景误用。 - */ -enum class CallRecordingLeg { UPLINK, DOWNLINK } - -/** 通话录音音频载荷的编码。 */ -enum class CallRecordingAudioCodec { OPUS, PCM_S16LE } - -/** 通话录音协商时使用的音频格式描述。 */ -data class CallRecordingAudioFormat( - val codec: CallRecordingAudioCodec, - val sampleRate: Int, - val channels: Int, - val frameMs: Int, -) { - companion object { - /** 16 kHz / mono / 20 ms 一帧 OPUS(耳机原生格式) */ - val OPUS_16K_MONO_20MS = CallRecordingAudioFormat(CallRecordingAudioCodec.OPUS, 16000, 1, 20) - - /** 16 kHz / mono / 20 ms 一帧 PCM_S16LE = 640 字节 */ - val PCM_S16LE_16K_MONO_20MS = CallRecordingAudioFormat(CallRecordingAudioCodec.PCM_S16LE, 16000, 1, 20) - } -} - -/** - * device → 编排器:一帧通话录音音频。 - * - * 与 [CallAudioFrame] 同形但语义独立 —— 通话录音不需要 TTS 回灌,因此对应的端口里 - * 没有 [DeviceCallTranslationPort.reportTranslated] 这种回写接口。 - */ -data class CallRecordingFrame( - val leg: CallRecordingLeg, - val codec: CallRecordingAudioCodec, - val sampleRate: Int, - val channels: Int, - val bytes: ByteArray, - /** 单调递增帧序号;丢帧后**不得**回填。 */ - val sequence: Long, - /** 单调递增的捕获时间戳(微秒)。 */ - val timestampUs: Long, -) { - override fun equals(other: Any?): Boolean { - if (this === other) return true - if (other !is CallRecordingFrame) return false - return leg == other.leg && sequence == other.sequence && bytes.contentEquals(other.bytes) - } - override fun hashCode(): Int { - var r = leg.hashCode() - r = 31 * r + sequence.hashCode() - r = 31 * r + bytes.contentHashCode() - return r - } -} - -/** 通话录音过程中由 device 抛出的错误(不致命:编排器决定是否 stop)。 */ -data class CallRecordingError( - val code: String, - val message: String, -) - -/** - * 通话录音能力端口(device 侧)。 - * - * 应用场景:会议记录 / 通话存证 / 通话转写。耳机持续把**本端 + 对端**两路音频 - * 同步上推给 APP,APP 落盘或交给 ASR/纪要服务做后续处理。 - * - * 由设备厂商插件按需实现,例如杰理基于 RCSP `MODE_CALL_TRANSLATION_WITH_STEREO`(=6) - * + `STRATEGY_DEVICE_ALWAYS_RECORDING` + 双声道 OPUS(SOURCE_E_SCO_MIX)落地一份 - * `JieliCallRecordingPort`(左=UPLINK、右=DOWNLINK 反交织成 mono 双流)。 - * - * # 与 [DeviceCallTranslationPort] 的区别 - * 两者都按 leg 派发音频,但通话录音是**单向上报**: - * - 没有 TTS 回灌(无 `reportTranslated` / `reportPlayback`),编排器只读不写 - * - 一般也不依赖真实通话(`MODE_RECORDING_TRANSLATION` 不需要 eSCO 通话存在), - * 适合「会议中的环境录音 + 远端通话录音混合」场景 - * - * # 与 [DeviceAssistantPort] 的区别 - * AI 助理是单路上行(用户麦),通话录音是**双路上行**(按 leg)。 - * - * 生命周期: - * ``` - * idle → enter(format) → active → exit() → idle - * ``` - * - * 铁律: - * - 同一设备同一时刻**至多一个** active 端口(与 callTranslation / assistant 共用底层 - * RCSP 状态机互斥);重复 `enter` 抛 `IllegalStateException("device.call_recording.busy")` - * - 协商格式([enter] 入参)必须出自 [supportedSourceFormats] 列出的范围; - * - [exit] 后 [audioFrames]/[errors] 仍可订阅,但不再产新事件。 - */ -interface DeviceCallRecordingPort { - /** 该端口能向上派发的音频源格式集合(per-leg mono)。 */ - fun supportedSourceFormats(): Set - - /** - * 进入通话录音模式。 - * - * @param sourceFormat 协商好的源音频格式(必须 ∈ [supportedSourceFormats])。 - * device 侧据此决定是否做 OPUS↔PCM 转换:例如耳机原生发 stereo OPUS, - * APP 只吃 mono PCM 时 device 内部解码 + 反交织后再按 leg 派发到 [audioFrames]。 - */ - fun enter(sourceFormat: CallRecordingAudioFormat) - - /** - * 上行/下行通话录音帧。`enter` 之后开始派发,`exit` 后停止。 - * 必须是 broadcast / replayable 不变的实现,编排器多次订阅安全。 - * - * 帧到达顺序(UPLINK vs DOWNLINK)由 device 实现保证「时序基本对齐」即可, - * 不要求严格交替。消费方应以各 leg 自己的 `timestampUs` 做时间轴。 - */ - val audioFrames: Flow - - /** 退出通话录音模式,归还设备 RCSP / 音频通道。幂等。 */ - fun exit() - - /** 错误流。**不致命**——编排器自行决定是否 [exit]。 */ - val errors: Flow -} diff --git a/apps/client/local_plugins/device_plugin_interface/android/src/main/kotlin/com/aiagent/device_plugin_interface/CallTranslation.kt b/apps/client/local_plugins/device_plugin_interface/android/src/main/kotlin/com/aiagent/device_plugin_interface/CallTranslation.kt deleted file mode 100644 index 1b64ef32..00000000 --- a/apps/client/local_plugins/device_plugin_interface/android/src/main/kotlin/com/aiagent/device_plugin_interface/CallTranslation.kt +++ /dev/null @@ -1,139 +0,0 @@ -package com.aiagent.device_plugin_interface - -import kotlinx.coroutines.flow.Flow - -/** - * 通话翻译两条腿(leg)的方向: - * - [UPLINK]: 用户说出去(手机麦/耳机麦) → 翻译给对方 - * - [DOWNLINK]: 对方说过来(远端通话音) → 翻译给用户 - */ -enum class CallTranslationLeg { UPLINK, DOWNLINK } - -/** 通话翻译音频载荷的编码。 */ -enum class CallAudioCodec { OPUS, PCM_S16LE } - -/** 通话翻译协商时使用的音频格式描述。 */ -data class CallAudioFormat( - val codec: CallAudioCodec, - val sampleRate: Int, - val channels: Int, - val frameMs: Int, -) { - companion object { - val OPUS_16K_MONO_20MS = CallAudioFormat(CallAudioCodec.OPUS, 16000, 1, 20) - val PCM_S16LE_16K_MONO_20MS = CallAudioFormat(CallAudioCodec.PCM_S16LE, 16000, 1, 20) - } -} - -/** device → 编排器:一帧通话翻译音频。 */ -data class CallAudioFrame( - val leg: CallTranslationLeg, - val codec: CallAudioCodec, - val sampleRate: Int, - val channels: Int, - val bytes: ByteArray, - /** 单调递增帧序号;丢帧后**不得**回填。 */ - val sequence: Long, - /** 单调递增的捕获时间戳(微秒)。 */ - val timestampUs: Long, -) { - override fun equals(other: Any?): Boolean { - if (this === other) return true - if (other !is CallAudioFrame) return false - return leg == other.leg && sequence == other.sequence && bytes.contentEquals(other.bytes) - } - override fun hashCode(): Int { - var r = leg.hashCode() - r = 31 * r + sequence.hashCode() - r = 31 * r + bytes.contentHashCode() - return r - } -} - -/** 编排器 → device:把已翻译的 TTS 音频回灌到指定 leg。 - * - * @param isFinal 本帧是否为当前 utterance 的最后一帧。device 端可能据此决定是否触发整段编码 / 一次性下发 - * (例如 jieli 通话翻译走 RCSP 时,需要按"一段 = 一个 AudioData"下发)。 - */ -data class TranslatedAudioFrame( - val leg: CallTranslationLeg, - val codec: CallAudioCodec, - val sampleRate: Int, - val channels: Int, - val bytes: ByteArray, - val isFinal: Boolean = false, -) { - override fun equals(other: Any?): Boolean { - if (this === other) return true - if (other !is TranslatedAudioFrame) return false - return leg == other.leg && codec == other.codec && bytes.contentEquals(other.bytes) - } - override fun hashCode(): Int { - var r = leg.hashCode() - r = 31 * r + codec.hashCode() - r = 31 * r + bytes.contentHashCode() - return r - } -} - -/** 通话翻译过程中由 device 抛出的错误(不致命:编排器决定是否 stop)。 */ -data class CallTranslationError( - val code: String, - val message: String, -) - -/** - * 通话翻译能力端口(device 侧)。 - * - * 由设备厂商插件按需实现,例如杰理基于 RCSP `MODE_CALL_TRANSLATION` 落地一份 - * `JieliCallTranslationPort`。`translate_server` 编排器统一通过本接口拿音频帧、 - * 回灌 TTS,不依赖具体 vendor。 - * - * 生命周期: - * ``` - * idle → enter(format) → active → exit() → idle - * ``` - * - * 铁律: - * - 同一设备同一时刻**至多一个** active 端口;重复 `enter` 抛 - * `IllegalStateException("device.call_translation.busy")`; - * - 协商格式([enter] 入参)必须出自 [supportedSourceFormats] 列出的范围; - * - [reportTranslated] 的 codec/sampleRate/channels 必须落在 [supportedSinkFormats] 内; - * - [exit] 后 [audioFrames]/[errors] 仍可订阅,但不再产新事件。 - */ -interface DeviceCallTranslationPort { - /** 该端口能向上派发的音频源格式集合。 */ - fun supportedSourceFormats(): Set - - /** 该端口能接受的 TTS 回灌格式集合。 */ - fun supportedSinkFormats(): Set - - /** - * 进入通话翻译模式。 - * - * @param sourceFormat 协商好的源音频格式(必须 ∈ [supportedSourceFormats])。 - * device 侧据此决定是否做 OPUS↔PCM 转换:例如耳机原生发 OPUS,agent 只吃 - * PCM 时编排器协商出 PCM,device 内部解码后再派发到 [audioFrames]。 - */ - fun enter(sourceFormat: CallAudioFormat) - - /** - * 上行/下行音频帧。`enter` 之后开始派发,`exit` 后停止。 - * 必须是 broadcast / replayable 不变的实现,编排器多次订阅安全。 - */ - val audioFrames: Flow - - /** - * 把已翻译的 TTS 音频回灌到指定 leg。 - * - * @throws IllegalArgumentException codec 不在 [supportedSinkFormats] 内 - * @throws IllegalStateException 当前未在 active 状态 - */ - fun reportTranslated(frame: TranslatedAudioFrame) - - /** 退出通话翻译模式,归还设备 RCSP / 音频通道。 */ - fun exit() - - /** 错误流。**不致命**——编排器自行决定是否 [exit]。 */ - val errors: Flow -} diff --git a/apps/client/local_plugins/device_plugin_interface/android/src/main/kotlin/com/aiagent/device_plugin_interface/DevicePluginInterfacePlugin.kt b/apps/client/local_plugins/device_plugin_interface/android/src/main/kotlin/com/aiagent/device_plugin_interface/DevicePluginInterfacePlugin.kt deleted file mode 100644 index 66775506..00000000 --- a/apps/client/local_plugins/device_plugin_interface/android/src/main/kotlin/com/aiagent/device_plugin_interface/DevicePluginInterfacePlugin.kt +++ /dev/null @@ -1,9 +0,0 @@ -package com.aiagent.device_plugin_interface - -import io.flutter.embedding.engine.plugins.FlutterPlugin - -/** No-op plugin class — device_plugin_interface only provides native interfaces and data classes. */ -class DevicePluginInterfacePlugin : FlutterPlugin { - override fun onAttachedToEngine(binding: FlutterPlugin.FlutterPluginBinding) {} - override fun onDetachedFromEngine(binding: FlutterPlugin.FlutterPluginBinding) {} -} diff --git a/apps/client/local_plugins/device_plugin_interface/android/src/main/kotlin/com/aiagent/device_plugin_interface/NativeDeviceManager.kt b/apps/client/local_plugins/device_plugin_interface/android/src/main/kotlin/com/aiagent/device_plugin_interface/NativeDeviceManager.kt deleted file mode 100644 index a37ceb4f..00000000 --- a/apps/client/local_plugins/device_plugin_interface/android/src/main/kotlin/com/aiagent/device_plugin_interface/NativeDeviceManager.kt +++ /dev/null @@ -1,93 +0,0 @@ -package com.aiagent.device_plugin_interface - -import kotlinx.coroutines.flow.Flow - -/** 容器 → app 的聚合事件类型(与 Dart [DeviceManagerEventType] 对齐)。 */ -enum class DeviceManagerEventType { - MANAGER_READY, - VENDOR_CHANGED, - BLUETOOTH_STATE_CHANGED, - SCAN_STARTED, - SCAN_STOPPED, - DEVICE_DISCOVERED, - ACTIVE_SESSION_CHANGED, - SESSION_EVENT, - /** - * 设备完整快照变化。**任何**改动(连接态、电量、固件、活跃 session 切换) - * 都会产生一份新的快照供 Dart 直接 mirror,Dart 端不再做字段 diff。 - */ - SNAPSHOT_UPDATED, - ERROR, -} - -data class DeviceManagerEvent( - val type: DeviceManagerEventType, - val vendorKey: String? = null, - val bluetoothEnabled: Boolean? = null, - val discovered: DiscoveredDevice? = null, - val activeDeviceId: String? = null, - val sessionEvent: DeviceSessionEvent? = null, - /** - * SNAPSHOT_UPDATED / ACTIVE_SESSION_CHANGED / SESSION_EVENT 一律附带;null - * 表示当前没有 active session(vendor 未选 / 已断开)。 - * 由 [com.aiagent.device_manager.DefaultNativeDeviceManager] 在每次状态变化 - * 时统一序列化,供 Dart MethodChannel facade 整体替换缓存。 - */ - val sessionSnapshot: Map? = null, - val errorCode: String? = null, - val errorMessage: String? = null, -) - -/** 设备触发 agent 的事件(PTT / 翻译键 / 挂断键)。 */ -data class DeviceAgentTrigger( - val deviceId: String, - val kind: Kind, - val payload: Map = emptyMap(), -) { - enum class Kind { CHAT, TRANSLATE, STOP } -} - -/** - * 设备路由器 native 抽象(与 Dart [DeviceManager] 等价)。 - * - * 进程内单例(典型实现:[com.aiagent.device_manager.DefaultNativeDeviceManager])。 - * - * 单设备 / 单 vendor 模型: - * - [activeSession] 至多一个;新 [connect] 自动断开旧 session - * - [useVendor] 是原子操作:stopScan → disconnect → 旧 plugin.dispose → - * 新 plugin.initialize → emit [DeviceManagerEventType.VENDOR_CHANGED] - * - 切换中所有 scan/connect 抛 [DeviceException]([DeviceErrorCode.VENDOR_SWITCHING]) - */ -interface NativeDeviceManager { - /** 当前 active vendor key;未选为 null。 */ - val activeVendor: String? - - val activeCapabilities: Set - - val activeSession: NativeDeviceSession? - - /** 列出全部已注册的 vendor(来自 [NativeDevicePluginRegistry])。 */ - fun listVendors(): List - - /** 切换到指定 vendor;vendor=null 等价于 [clearVendor]。 */ - fun useVendor(vendorKey: String, config: DevicePluginConfig) - - /** 清掉当前 vendor:disconnect → dispose 当前 plugin。 */ - fun clearVendor() - - fun startScan(filter: DeviceScanFilter? = null, timeoutMs: Long? = null) - fun stopScan() - fun bondedDevices(): List - - fun connect(deviceId: String, options: DeviceConnectOptions? = null): NativeDeviceSession - fun disconnect() - - /** 容器级聚合事件流(多订阅安全)。 */ - val eventStream: Flow - - /** 设备主动触发 agent 的事件流。 */ - val agentTriggers: Flow - - fun initialize() - fun dispose() -} diff --git a/apps/client/local_plugins/device_plugin_interface/android/src/main/kotlin/com/aiagent/device_plugin_interface/NativeDevicePlugin.kt b/apps/client/local_plugins/device_plugin_interface/android/src/main/kotlin/com/aiagent/device_plugin_interface/NativeDevicePlugin.kt deleted file mode 100644 index 548cde6f..00000000 --- a/apps/client/local_plugins/device_plugin_interface/android/src/main/kotlin/com/aiagent/device_plugin_interface/NativeDevicePlugin.kt +++ /dev/null @@ -1,149 +0,0 @@ -package com.aiagent.device_plugin_interface - -import kotlinx.coroutines.flow.Flow - -/** - * 厂商插件 → 容器的事件类型。 - */ -enum class DevicePluginEventType { - PLUGIN_READY, - PLUGIN_DISPOSED, - BLUETOOTH_STATE_CHANGED, - SCAN_STARTED, - SCAN_STOPPED, - DEVICE_DISCOVERED, - BOND_STATE_CHANGED, - CONNECTION_STATE_CHANGED, - DEVICE_INFO_UPDATED, - WAKE_TRIGGERED, - CUSTOM_EVENT, - ERROR, -} - -data class DevicePluginEvent( - val type: DevicePluginEventType, - val deviceId: String? = null, - val discovered: DiscoveredDevice? = null, - val connectionState: DeviceConnectionState? = null, - val deviceInfo: DeviceInfo? = null, - val bluetoothEnabled: Boolean? = null, - val bondState: String? = null, // 'none' | 'bonding' | 'bonded' - val wake: DeviceWakeEvent? = null, - val customKey: String? = null, - val customPayload: Map? = null, - val errorCode: String? = null, - val errorMessage: String? = null, -) - -/** - * 厂商插件 native 抽象(与 Dart [DevicePlugin] 等价)。 - * - * 每家芯片/SDK 一份实现: - * - `JieliNativeDevicePlugin` (设备厂商:杰理) - * - 后续:恒玄 / 高通 / 中科蓝讯 等 - * - * 实现方在自己 FlutterPlugin.onAttachedToEngine 时把工厂注册到 - * [NativeDevicePluginRegistry],例如: - * ```kotlin - * NativeDevicePluginRegistry.register("jieli") { JieliNativeDevicePlugin(context) } - * ``` - * - * 容器 [NativeDeviceManager] 通过 vendorKey 查工厂,按需创建实例并管理生命周期。 - * - * 铁律(与 Dart 接口完全对齐): - * 1. `initialize` 幂等;重复调用先释放旧资源。 - * 2. `dispose` 必须释放**所有** native 资源 + close 事件流。 - * 3. `dispose` 后任何方法抛 [IllegalStateException]。 - * 4. 通过 [eventStream]([Flow])派发事件,多订阅安全(用 `MutableSharedFlow.replay=0`)。 - */ -interface NativeDevicePlugin { - val vendorKey: String - val displayName: String - val capabilities: Set - - /** 初始化 SDK;失败抛 [DeviceException]。 */ - fun initialize(config: DevicePluginConfig) - - fun startScan(filter: DeviceScanFilter? = null, timeoutMs: Long? = null) - fun stopScan() - fun isScanning(): Boolean - - /** 已配对设备快照(含历史/系统配对)。 */ - fun bondedDevices(): List - - /** - * 建立会话;必须在握手完成(state=READY)之后才 return; - * 失败抛 [DeviceException]。 - */ - fun connect(deviceId: String, options: DeviceConnectOptions? = null): NativeDeviceSession - - /** 当前 active session(最多一个)。 */ - val activeSession: NativeDeviceSession? - - /** 全局事件流:扫描/连接/蓝牙开关/错误等。 */ - val eventStream: Flow - - /** 释放 SDK + 断开所有 session + close stream。 */ - fun dispose() -} - -/** 厂商插件工厂。 */ -typealias NativeDevicePluginFactory = () -> NativeDevicePlugin - -/** - * 厂商插件全局注册表(参照 NativeAgentRegistry 模式)。 - * - * 每个 device_ 包在自己的 FlutterPlugin.onAttachedToEngine 时注册一次工厂, - * 容器 [NativeDeviceManager] 通过 vendorKey 创建实例。 - */ -object NativeDevicePluginRegistry { - - private val factories = mutableMapOf() - private val descriptors = mutableMapOf() - - /** 注册厂商(重复 register 用新工厂覆盖旧的)。 */ - fun register( - vendorKey: String, - displayName: String, - capabilities: Set, - factory: NativeDevicePluginFactory, - ) { - factories[vendorKey] = factory - descriptors[vendorKey] = VendorDescriptor( - vendorKey = vendorKey, - displayName = displayName, - capabilities = capabilities, - ) - } - - fun unregister(vendorKey: String) { - factories.remove(vendorKey) - descriptors.remove(vendorKey) - } - - fun create(vendorKey: String): NativeDevicePlugin { - val f = factories[vendorKey] - ?: throw DeviceException( - DeviceErrorCode.NOT_SUPPORTED, - "vendor \"$vendorKey\" not registered. Available: ${factories.keys}", - ) - return f() - } - - fun listVendors(): List = descriptors.values.toList() - - fun isRegistered(vendorKey: String): Boolean = factories.containsKey(vendorKey) -} - -/** 用于 listVendors() 给上层 UI 渲染下拉选项。 */ -data class VendorDescriptor( - val vendorKey: String, - val displayName: String, - val capabilities: Set, -) { - fun toMap(): Map = mapOf( - "vendorKey" to vendorKey, - "displayName" to displayName, - "capabilities" to capabilities.map { it.name }, - ) -} diff --git a/apps/client/local_plugins/device_plugin_interface/android/src/main/kotlin/com/aiagent/device_plugin_interface/NativeDeviceSession.kt b/apps/client/local_plugins/device_plugin_interface/android/src/main/kotlin/com/aiagent/device_plugin_interface/NativeDeviceSession.kt deleted file mode 100644 index 04297b62..00000000 --- a/apps/client/local_plugins/device_plugin_interface/android/src/main/kotlin/com/aiagent/device_plugin_interface/NativeDeviceSession.kt +++ /dev/null @@ -1,88 +0,0 @@ -package com.aiagent.device_plugin_interface - -import kotlinx.coroutines.flow.Flow - -/** 单设备会话事件类型(与 Dart [DeviceSessionEventType] 对齐)。 */ -enum class DeviceSessionEventType { - CONNECTION_STATE_CHANGED, - DEVICE_INFO_UPDATED, - FEATURE, - RSSI_UPDATED, - RAW, - ERROR, -} - -data class DeviceSessionEvent( - val type: DeviceSessionEventType, - val deviceId: String, - val connectionState: DeviceConnectionState? = null, - val deviceInfo: DeviceInfo? = null, - val feature: DeviceFeatureEvent? = null, - val rssi: Int? = null, - val raw: Map? = null, - val errorCode: String? = null, - val errorMessage: String? = null, -) - -/** - * 已连接设备的 native 会话句柄(与 Dart [DeviceSession] 等价)。 - * - * 生命周期:connecting → linkConnected → ready → (业务调用) → disconnecting → disconnected - * - * 铁律: - * 1. `disconnected` 之后任何方法抛 [IllegalStateException] - * 2. [eventStream] 是 broadcast / multi-subscriber-safe - * 3. [invokeFeature] / [readBattery] / [refreshInfo] 在状态非 READY 时抛 - * [DeviceException]([DeviceErrorCode.NO_ACTIVE_SESSION]),禁止静默排队 - */ -interface NativeDeviceSession { - val deviceId: String - val vendor: String - val state: DeviceConnectionState - val info: DeviceInfo - val capabilities: Set - - val eventStream: Flow - - fun readRssi(): Int - - /** 读电量;不支持时返回 null(不抛)。 */ - fun readBattery(): Int? - - /** 强制刷新 [DeviceInfo](电量/固件版本/序列号)。 */ - fun refreshInfo(): DeviceInfo - - /** - * 调用厂商私有 feature。`featureKey` 形如: - * - `common.battery.subscribe` - * - `jieli.translation.start` - * - `bes.eq.set` - */ - fun invokeFeature(featureKey: String, args: Map = emptyMap()): Map - - /** - * 该 session 暴露的通话翻译端口;vendor 无该能力时返回 null。 - * - * 给 native 编排器(translate_server)vendor-agnostic 拿端口的入口—— - * 编排器只调本接口,不需要 import 任何具体厂商类型。 - */ - fun callTranslationPort(): DeviceCallTranslationPort? = null - - /** - * 该 session 暴露的 AI 助理端口;vendor 无该能力时返回 null。 - * - * 给 native 编排器(assistant_server)vendor-agnostic 拿端口的入口—— - * 与 [callTranslationPort] 语义独立(AI 助理为单向对话,无 UPLINK/DOWNLINK 两条腿)。 - */ - fun assistantPort(): DeviceAssistantPort? = null - - /** - * 该 session 暴露的 OTA 端口;vendor 无 [DeviceCapability.OTA] 时返回 null。 - * - * 编排器 / UI 通过本接口拿到 vendor-agnostic 的 OTA 控制;不依赖具体厂商。 - * 端口生命周期与 session 绑定 —— session disconnect 时端口必须自动收尾。 - */ - fun otaPort(): DeviceOtaPort? = null - - fun disconnect() -} diff --git a/apps/client/local_plugins/device_plugin_interface/android/src/main/kotlin/com/aiagent/device_plugin_interface/NativeDeviceTypes.kt b/apps/client/local_plugins/device_plugin_interface/android/src/main/kotlin/com/aiagent/device_plugin_interface/NativeDeviceTypes.kt deleted file mode 100644 index d4e4f667..00000000 --- a/apps/client/local_plugins/device_plugin_interface/android/src/main/kotlin/com/aiagent/device_plugin_interface/NativeDeviceTypes.kt +++ /dev/null @@ -1,208 +0,0 @@ -package com.aiagent.device_plugin_interface - -/** 设备能力(与 Dart [DeviceCapability] 一一对应)。 */ -enum class DeviceCapability { - SCAN, - CONNECT, - BOND, - BATTERY, - RSSI, - OTA, - EQ, - ANC, - KEY_MAPPING, - WEAR_DETECTION, - MIC_UPLINK, - SPEAKER_DOWNLINK, - WAKE_WORD, - ON_DEVICE_CALL_TRANSLATION, - ON_DEVICE_FACE_TO_FACE_TRANSLATION, - ON_DEVICE_RECORDING_TRANSLATION, - CUSTOM_COMMAND, -} - -/** 连接状态机(与 Dart [DeviceConnectionState] 一一对应)。 */ -enum class DeviceConnectionState { - DISCONNECTED, - CONNECTING, - LINK_CONNECTED, - READY, - DISCONNECTING, -} - -/** 厂商插件初始化配置。 */ -data class DevicePluginConfig( - val appKey: String? = null, - val appSecret: String? = null, - val extra: Map = emptyMap(), -) { - companion object { - fun fromMap(map: Map<*, *>?): DevicePluginConfig { - if (map == null) return DevicePluginConfig() - return DevicePluginConfig( - appKey = map["appKey"] as? String, - appSecret = map["appSecret"] as? String, - extra = (map["extra"] as? Map<*, *>) - ?.entries - ?.associate { it.key.toString() to it.value } - ?: emptyMap(), - ) - } - } -} - -/** 扫描过滤条件。 */ -data class DeviceScanFilter( - val namePrefix: String? = null, - val nameList: List? = null, - val serviceUuids: List? = null, - val vendor: String? = null, - val minRssi: Int? = null, - val skipUnnamed: Boolean? = null, -) { - companion object { - fun fromMap(map: Map<*, *>?): DeviceScanFilter? { - if (map == null) return null - @Suppress("UNCHECKED_CAST") - return DeviceScanFilter( - namePrefix = map["namePrefix"] as? String, - nameList = (map["nameList"] as? List<*>)?.map { it.toString() }, - serviceUuids = (map["serviceUuids"] as? List<*>)?.map { it.toString() }, - vendor = map["vendor"] as? String, - minRssi = (map["minRssi"] as? Number)?.toInt(), - skipUnnamed = map["skipUnnamed"] as? Boolean, - ) - } - } -} - -/** 连接选项。 */ -data class DeviceConnectOptions( - val timeoutMs: Long = 15_000, - val extra: Map = emptyMap(), -) { - companion object { - fun fromMap(map: Map<*, *>?): DeviceConnectOptions { - if (map == null) return DeviceConnectOptions() - return DeviceConnectOptions( - timeoutMs = (map["timeoutMs"] as? Number)?.toLong() ?: 15_000, - extra = (map["extra"] as? Map<*, *>) - ?.entries - ?.associate { it.key.toString() to it.value } - ?: emptyMap(), - ) - } - } -} - -/** 扫描发现的设备。 */ -data class DiscoveredDevice( - val id: String, - val name: String, - val rssi: Int? = null, - val vendor: String? = null, - val metadata: Map = emptyMap(), -) { - fun toMap(): Map = mapOf( - "id" to id, - "name" to name, - "rssi" to rssi, - "vendor" to vendor, - "metadata" to metadata, - ) -} - -/** 已连接设备的信息快照。 */ -data class DeviceInfo( - val id: String, - val name: String, - val vendor: String, - val firmwareVersion: String? = null, - val hardwareVersion: String? = null, - val serialNumber: String? = null, - val manufacturer: String? = null, - val model: String? = null, - val batteryPercent: Int? = null, - val metadata: Map = emptyMap(), -) { - fun toMap(): Map = mapOf( - "id" to id, - "name" to name, - "vendor" to vendor, - "firmwareVersion" to firmwareVersion, - "hardwareVersion" to hardwareVersion, - "serialNumber" to serialNumber, - "manufacturer" to manufacturer, - "model" to model, - "batteryPercent" to batteryPercent, - "metadata" to metadata, - ) - - fun copyWith( - name: String? = null, - firmwareVersion: String? = null, - hardwareVersion: String? = null, - serialNumber: String? = null, - manufacturer: String? = null, - model: String? = null, - batteryPercent: Int? = null, - metadata: Map? = null, - ): DeviceInfo = DeviceInfo( - id = id, - vendor = vendor, - name = name ?: this.name, - firmwareVersion = firmwareVersion ?: this.firmwareVersion, - hardwareVersion = hardwareVersion ?: this.hardwareVersion, - serialNumber = serialNumber ?: this.serialNumber, - manufacturer = manufacturer ?: this.manufacturer, - model = model ?: this.model, - batteryPercent = batteryPercent ?: this.batteryPercent, - metadata = metadata ?: this.metadata, - ) -} - -/** 唤醒原因(设备 → app)。 */ -enum class WakeReason { PTT, VOICE_WAKE, TRANSLATE_KEY, HANGUP } - -data class DeviceWakeEvent( - val deviceId: String, - val reason: WakeReason, - val payload: Map = emptyMap(), -) - -/** 通用语义事件。`key` 命名空间:`common.` / `.`。 */ -data class DeviceFeatureEvent( - val key: String, - val data: Map = emptyMap(), -) - -/** 设备异常(plugin 抛出,路由器据此 emit error / 中断)。 */ -class DeviceException( - val code: String, - message: String? = null, - cause: Throwable? = null, -) : RuntimeException(message ?: code, cause) - -/** 错误码命名空间。 */ -object DeviceErrorCode { - const val PERMISSION_DENIED = "device.permission_denied" - const val BLUETOOTH_OFF = "device.bluetooth_off" - const val CONNECT_TIMEOUT = "device.connect_timeout" - const val CONNECT_FAILED = "device.connect_failed" - const val HANDSHAKE_FAILED = "device.handshake_failed" - const val DISCONNECTED_REMOTE = "device.disconnected_remote" - const val AUDIO_BUSY = "device.audio_busy" - const val NOT_SUPPORTED = "device.not_supported" - const val FORMAT_UNSUPPORTED = "device.format_unsupported" - const val VENDOR_SWITCHING = "device.vendor_switching" - const val NO_ACTIVE_SESSION = "device.no_active_session" - const val PLUGIN_NOT_INITIALIZED = "device.plugin_not_initialized" - const val INVALID_ARGUMENT = "device.invalid_argument" - - // OTA - const val OTA_BUSY = "device.ota_busy" - const val OTA_INQUIRE_REFUSED = "device.ota_inquire_refused" - const val OTA_VERIFY_FAILED = "device.ota_verify_failed" - const val OTA_FILE_INVALID = "device.ota_file_invalid" - const val OTA_TRANSFER_FAILED = "device.ota_transfer_failed" -} diff --git a/apps/client/local_plugins/device_plugin_interface/android/src/main/kotlin/com/aiagent/device_plugin_interface/Ota.kt b/apps/client/local_plugins/device_plugin_interface/android/src/main/kotlin/com/aiagent/device_plugin_interface/Ota.kt deleted file mode 100644 index dd9eb910..00000000 --- a/apps/client/local_plugins/device_plugin_interface/android/src/main/kotlin/com/aiagent/device_plugin_interface/Ota.kt +++ /dev/null @@ -1,170 +0,0 @@ -package com.aiagent.device_plugin_interface - -import kotlinx.coroutines.flow.Flow - -/** - * OTA 升级请求 —— 多态请求层级(与 Dart [DeviceOtaRequest] 对齐)。 - * - * 业务层只构造其中一种子类;插件实现按 `when` 分派。 - * 厂商不支持的子类一律抛 [DeviceException]([DeviceErrorCode.NOT_SUPPORTED]), - * **禁止**静默退化(业务侧才有"是否回退到本地下载"的语义)。 - */ -sealed class DeviceOtaRequest { - /** 单块大小。null = 走厂商默认。 */ - abstract val blockSize: Int? - - /** 整段 OTA 的超时;null = 不限。 */ - abstract val timeoutMs: Long? - - /** 本地文件:app 自己下载好后放在沙盒里。 */ - data class File( - val filePath: String, - override val blockSize: Int? = null, - override val timeoutMs: Long? = null, - ) : DeviceOtaRequest() - - /** 内存字节流:固件已经在内存里。 */ - data class Bytes( - val bytes: ByteArray, - override val blockSize: Int? = null, - override val timeoutMs: Long? = null, - ) : DeviceOtaRequest() { - override fun equals(other: Any?): Boolean = - this === other || (other is Bytes && bytes.contentEquals(other.bytes)) - override fun hashCode(): Int = bytes.contentHashCode() - } - - /** 远程 URL:让 native 自己下载;不支持时抛 NOT_SUPPORTED 由调用方退回。 */ - data class Url( - val url: String, - val headers: Map = emptyMap(), - override val blockSize: Int? = null, - override val timeoutMs: Long? = null, - ) : DeviceOtaRequest() - - /** 厂商扩展:差分包 / 双备份 / fileFlag 等私有参数。 */ - data class Vendor( - val vendorKey: String, - val payload: Map, - override val blockSize: Int? = null, - override val timeoutMs: Long? = null, - ) : DeviceOtaRequest() - - companion object { - /** - * 从 method channel map 反序列化(kind 字段做 tag)。 - * 不识别的 kind 抛 [DeviceException]([DeviceErrorCode.INVALID_ARGUMENT])。 - */ - @Suppress("UNCHECKED_CAST") - fun fromMap(map: Map<*, *>): DeviceOtaRequest { - val kind = map["kind"] as? String - ?: throw DeviceException(DeviceErrorCode.INVALID_ARGUMENT, "ota: kind required") - val blockSize = (map["blockSize"] as? Number)?.toInt() - val timeoutMs = (map["timeoutMs"] as? Number)?.toLong() - return when (kind) { - "file" -> File( - filePath = map["filePath"] as? String - ?: throw DeviceException(DeviceErrorCode.INVALID_ARGUMENT, "ota.file: filePath required"), - blockSize = blockSize, - timeoutMs = timeoutMs, - ) - "bytes" -> Bytes( - bytes = (map["bytes"] as? ByteArray) - ?: throw DeviceException(DeviceErrorCode.INVALID_ARGUMENT, "ota.bytes: bytes required"), - blockSize = blockSize, - timeoutMs = timeoutMs, - ) - "url" -> Url( - url = map["url"] as? String - ?: throw DeviceException(DeviceErrorCode.INVALID_ARGUMENT, "ota.url: url required"), - headers = (map["headers"] as? Map) ?: emptyMap(), - blockSize = blockSize, - timeoutMs = timeoutMs, - ) - "vendor" -> Vendor( - vendorKey = map["vendorKey"] as? String - ?: throw DeviceException(DeviceErrorCode.INVALID_ARGUMENT, "ota.vendor: vendorKey required"), - payload = (map["payload"] as? Map) ?: emptyMap(), - blockSize = blockSize, - timeoutMs = timeoutMs, - ) - else -> throw DeviceException( - DeviceErrorCode.INVALID_ARGUMENT, - "ota: unknown kind \"$kind\"", - ) - } - } - } -} - -/** OTA 全局状态机(与 Dart [DeviceOtaState] 一一对应)。 */ -enum class DeviceOtaState { - IDLE, - /** 容器层下载远程固件中(Url 请求专属,厂商插件看不到)。 */ - DOWNLOADING, - INQUIRING, - NOTIFYING_SIZE, - ENTERING, - TRANSFERRING, - VERIFYING, - REBOOTING, - DONE, - FAILED, - CANCELLED, -} - -/** OTA 进度事件。 */ -data class DeviceOtaProgress( - val state: DeviceOtaState, - val sentBytes: Long, - val totalBytes: Long, - /** 0..100;未知或非 transferring 阶段可为 -1。 */ - val percent: Int, - val tsMs: Long, - /** 终态时携带错误码(state ∈ {FAILED});其它阶段为 null。 */ - val errorCode: String? = null, - val errorMessage: String? = null, -) { - fun toMap(): Map = mapOf( - "state" to state.name, - "sentBytes" to sentBytes, - "totalBytes" to totalBytes, - "percent" to percent, - "tsMs" to tsMs, - "errorCode" to errorCode, - "errorMessage" to errorMessage, - ) - - val isTerminal: Boolean - get() = state == DeviceOtaState.DONE || - state == DeviceOtaState.FAILED || - state == DeviceOtaState.CANCELLED -} - -/** - * OTA 端口(同 [DeviceCallTranslationPort] 的设计风格)。 - * - * 单设备 OTA 互斥:同时只能跑一个 [start],重复 start 抛 - * [DeviceException]([DeviceErrorCode.OTA_BUSY])。 - * - * 生命周期与铁律: - * 1. `start` 之后 [progressStream] 必有终态(DONE/FAILED/CANCELLED),不得悬挂; - * 2. 设备掉线时端口须主动派 FAILED 收尾; - * 3. `cancel` 后短时间内(≤ 5s)须收到 CANCELLED;超时强制收尾; - * 4. **不**要求支持断点续传,但若实现了须在 SDK 内部完成,对外只露统一进度。 - */ -interface DeviceOtaPort { - fun start(request: DeviceOtaRequest) - fun cancel() - val isRunning: Boolean - val progressStream: Flow -} - -/** OTA 错误码补充(与 Dart 镜像)。 */ -object OtaErrorCode { - const val BUSY = DeviceErrorCode.OTA_BUSY - const val INQUIRE_REFUSED = DeviceErrorCode.OTA_INQUIRE_REFUSED - const val VERIFY_FAILED = DeviceErrorCode.OTA_VERIFY_FAILED - const val FILE_INVALID = DeviceErrorCode.OTA_FILE_INVALID - const val TRANSFER_FAILED = DeviceErrorCode.OTA_TRANSFER_FAILED -} diff --git a/apps/client/local_plugins/device_plugin_interface/ios/Classes/DevicePluginInterfacePlugin.swift b/apps/client/local_plugins/device_plugin_interface/ios/Classes/DevicePluginInterfacePlugin.swift deleted file mode 100644 index 485c56d4..00000000 --- a/apps/client/local_plugins/device_plugin_interface/ios/Classes/DevicePluginInterfacePlugin.swift +++ /dev/null @@ -1,14 +0,0 @@ -import Flutter -import UIKit - -/// No-op plugin class — `device_plugin_interface` only provides Dart-side contracts -/// and shared data classes. Vendor plugins (`device_jieli`, ...) implement them -/// in their own native code and depend on this package solely at the Flutter level. -/// -/// Mirrors the Android stub at -/// `android/src/main/kotlin/com/aiagent/device_plugin_interface/DevicePluginInterfacePlugin.kt`. -public class DevicePluginInterfacePlugin: NSObject, FlutterPlugin { - public static func register(with registrar: FlutterPluginRegistrar) { - // Intentionally empty: no MethodChannel / EventChannel surface. - } -} diff --git a/apps/client/local_plugins/device_plugin_interface/ios/device_plugin_interface.podspec b/apps/client/local_plugins/device_plugin_interface/ios/device_plugin_interface.podspec deleted file mode 100644 index 562b742c..00000000 --- a/apps/client/local_plugins/device_plugin_interface/ios/device_plugin_interface.podspec +++ /dev/null @@ -1,19 +0,0 @@ -Pod::Spec.new do |s| - s.name = 'device_plugin_interface' - s.version = '0.1.0' - s.summary = 'Abstract contracts for hardware device plugins (BLE / RCSP / vendor SDK).' - s.description = <<-DESC -This pod is a no-op companion for the device_plugin_interface Flutter plugin. -The Dart layer holds the actual abstract contracts; vendor plugins (e.g. device_jieli) -implement them in their own native code and depend on this package only at the Flutter level. - DESC - s.homepage = 'https://example.com' - s.license = { :type => 'MIT' } - s.author = { 'AI Agent' => 'liwei1dao@gmail.com' } - s.source = { :path => '.' } - s.source_files = 'Classes/**/*.{h,m,swift}' - s.dependency 'Flutter' - s.platform = :ios, '12.0' - s.swift_version = '5.0' - s.pod_target_xcconfig = { 'DEFINES_MODULE' => 'YES' } -end diff --git a/apps/client/local_plugins/device_plugin_interface/lib/device_plugin_interface.dart b/apps/client/local_plugins/device_plugin_interface/lib/device_plugin_interface.dart index adf0f298..6813ad35 100644 --- a/apps/client/local_plugins/device_plugin_interface/lib/device_plugin_interface.dart +++ b/apps/client/local_plugins/device_plugin_interface/lib/device_plugin_interface.dart @@ -2,8 +2,8 @@ export 'src/audio_format.dart'; export 'src/device_capability.dart'; export 'src/device_event.dart'; export 'src/device_exception.dart'; +export 'src/device_files.dart'; export 'src/device_info.dart'; -export 'src/device_manager.dart'; export 'src/device_ota.dart'; export 'src/device_plugin.dart'; export 'src/device_session.dart'; diff --git a/apps/client/local_plugins/device_plugin_interface/lib/src/audio_format.dart b/apps/client/local_plugins/device_plugin_interface/lib/src/audio_format.dart index 6707a8cb..8b50476f 100644 --- a/apps/client/local_plugins/device_plugin_interface/lib/src/audio_format.dart +++ b/apps/client/local_plugins/device_plugin_interface/lib/src/audio_format.dart @@ -1,3 +1,5 @@ +import 'dart:async'; + import 'package:flutter/foundation.dart'; /// 音频编码。 @@ -9,15 +11,12 @@ enum AudioCodec { pcm16le, } -/// 设备插件与 agent 容器之间统一的音频格式约束。 -/// -/// 接口层规定: -/// - 所有 [DeviceAudioSource] 派发的 [AudioFrame] 必须满足 [AudioFormat.standard]; -/// - 所有 [DeviceAudioSink] 接受的 [AudioFrame] 必须满足 [AudioFormat.standard]。 +/// 设备插件与业务层之间统一的音频格式约束。 /// -/// 厂商插件内部如果拿到的格式不一致(如 8 kHz Opus、PCM、非 20ms 帧长), -/// **必须**在 vendor SDK 出口做重采样 / 转码 / 重新切帧后再送入 Stream。 -/// 转换不可行时,在 [DevicePlugin.initialize] 阶段抛出 +/// 铁律:**业务层拿到的永远是 [AudioFormat.pcm16kMono]**。 +/// 厂商插件内部无论拿到的是 G.722 / mSBC / Opus / 别的采样率, +/// 都必须在插件出口(通常是原生解码器)转成这个格式再交出来。 +/// 转换不可行时,在 `DevicePlugin.initialize` 阶段抛出 /// `DeviceException('device.format_unsupported')`,让上层禁用该厂商。 @immutable class AudioFormat { @@ -28,22 +27,22 @@ class AudioFormat { required this.frameMs, }); - /// 接口层规定的统一格式:Opus / 16 kHz / mono / 20 ms 一帧。 + /// 业务层统一格式:PCM 16-bit LE / 16 kHz / mono / 20 ms 一帧 = 640 字节。 /// - /// 适用于"app 直接转发设备端 OPUS 帧"的零转码路径(旧版设计)。 - static const AudioFormat standard = AudioFormat( - codec: AudioCodec.opus, + /// 恒玄(G.722/mSBC 原生解码)、EaiRec 支架(Opus 原生解码)出口都是它。 + static const AudioFormat pcm16kMono = AudioFormat( + codec: AudioCodec.pcm16le, sampleRate: 16000, channels: 1, frameMs: 20, ); - /// PCM 16-bit LE / 16 kHz / mono / 20 ms 一帧 = 640 字节。 - /// - /// 当前 jieli 通话翻译路径已经在 native 端把 OPUS 解码成 PCM 再上推, - /// translate_server / agent 直接吃这个格式即可(无需自己解码)。 - static const AudioFormat pcm16kMono20ms = AudioFormat( - codec: AudioCodec.pcm16le, + /// 旧名保留:等同 [pcm16kMono]。 + static const AudioFormat pcm16kMono20ms = pcm16kMono; + + /// 「app 直接转发设备端 OPUS 帧」的零转码路径(旧版设计,目前无实现)。 + static const AudioFormat standard = AudioFormat( + codec: AudioCodec.opus, sampleRate: 16000, channels: 1, frameMs: 20, @@ -56,6 +55,10 @@ class AudioFormat { /// 单帧时长(毫秒)。 final int frameMs; + /// PCM 时每毫秒的字节数(16k/16bit/mono = 32)。Opus 无意义,返回 0。 + int get bytesPerMs => + codec == AudioCodec.pcm16le ? sampleRate * channels * 2 ~/ 1000 : 0; + bool isCompatibleWith(AudioFormat other) => codec == other.codec && sampleRate == other.sampleRate && @@ -80,8 +83,7 @@ class AudioFrame { /// 帧负载。Opus 时为压缩字节;PCM 时为 16-bit 小端样本。 final Uint8List bytes; - /// 实际帧格式。理论上等于 [AudioFormat.standard], - /// 实现方仍需逐帧附带,便于 agent 容器层防御性校验。 + /// 实际帧格式。 final AudioFormat format; /// 严格单调递增的帧序号;丢帧后**不得**回填。 @@ -90,3 +92,47 @@ class AudioFrame { /// 单调递增的捕获时间戳(微秒)。 final int timestampUs; } + +/// 音频通路。业务层按「要什么音频」选,插件负责映射到具体链路。 +/// +/// 上行(设备 → app,[DeviceSession.openMic]): +/// - [mic]:设备主麦(现场录音)。 +/// - [callLocal]:通话中本端麦克风。 +/// - [callPeer]:通话中对端声音。 +/// - [ai]:设备 AI 会话(按键唤醒)时的麦克风。 +/// +/// 下行(app → 设备,[DeviceSession.openSpeaker]): +/// - [ai]:AI 会话的回复语音。 +/// - [callLegA]:己方译文,给对端听。 +/// - [callLegB]:对端译文,给己方听。 +enum AudioRoute { mic, callLocal, callPeer, ai, callLegA, callLegB } + +/// 设备 → app 的音频源。`pcm` 里的每一块都满足 [format]。 +/// +/// 由 [DeviceSession.openMic] 创建;用完必须 [close],否则插件不知道 +/// 什么时候可以让设备停止上行。 +abstract class DeviceAudioSource { + AudioRoute get route; + AudioFormat get format; + + /// PCM 数据块流。块大小由插件决定(恒玄 640B/20ms,支架按 Opus 帧)。 + Stream get pcm; + + Future close(); +} + +/// app → 设备的音频汇。写入的 PCM 必须满足 [format]。 +/// +/// 编码、节流、组包(比如恒玄 G.722 + 20ms 定时)一律在插件/原生里做, +/// 业务层只管往里丢 PCM,**不要**自己掐时钟。 +abstract class DeviceAudioSink { + AudioRoute get route; + AudioFormat get format; + + Future write(Uint8List pcm); + + /// 丢掉还没发出去的排队音频(用户插话打断时用)。 + Future discardPending(); + + Future close(); +} diff --git a/apps/client/local_plugins/device_plugin_interface/lib/src/device_capability.dart b/apps/client/local_plugins/device_plugin_interface/lib/src/device_capability.dart index 192b6c35..55abb3c8 100644 --- a/apps/client/local_plugins/device_plugin_interface/lib/src/device_capability.dart +++ b/apps/client/local_plugins/device_plugin_interface/lib/src/device_capability.dart @@ -2,6 +2,8 @@ /// /// 业务层据此显示/隐藏 UI(OTA 按钮、翻译模式选择等), /// **禁止**写厂商分支判断。 +/// +/// 只增不删:已发布的插件声明过的值不会被移除。 enum DeviceCapability { /// 扫描发现设备(必备) scan, @@ -33,10 +35,10 @@ enum DeviceCapability { /// 佩戴检测事件 wearDetection, - /// 设备 → app 麦克风上行(音频通道在 native 内部维护,不直接暴露给 Flutter) + /// 设备 → app 麦克风上行([DeviceSession.openMic],`AudioRoute.mic`) micUplink, - /// app → 设备扬声器下行(音频通道在 native 内部维护) + /// app → 设备扬声器下行 speakerDownlink, /// 设备本地唤醒(PTT 键 / 语音唤醒),派发 [DeviceWakeEvent] @@ -53,4 +55,36 @@ enum DeviceCapability { /// 自定义 RCSP / 私有协议命令([DeviceSession.invokeFeature] 可用) customCommand, + + // ---------- 2026-09 新增 ---------- + + /// 能拿到设备真实蓝牙 MAC([DeviceInfo.macAddress]),用于服务端确权 + macAddress, + + /// 设备端主麦录音:`record.start` / `record.stop`,且设备按键起停会派 + /// `record.started` / `record.stopped` feature 事件 + micRecording, + + /// 通话中能抓到本端与对端两路音频(`AudioRoute.callLocal` / `callPeer`) + callAudioTap, + + /// 设备会上报通话起止(`call.started` / `call.ended` feature 事件) + callState, + + /// 按键唤醒 AI 会话:派 [DeviceWakeEvent],并支持 `ai.accept` / `ai.exit` + /// 与 `AudioRoute.ai` 的上下行 + aiWake, + + /// 通话翻译下行:`AudioRoute.callLegA` / `callLegB` + translationDownlink, + + /// 设备端面对面翻译开关:`f2f.start` / `f2f.stop` + faceToFace, + + /// 设备自己把录音存在本机存储里(TF 卡 / 内置 flash),事后由 app 同步走。 + /// 通过 [DeviceSession.fileStore] 拿 [DeviceFileStore]。 + /// + /// ⚠️ 与 [micUplink](边录边传)**互不蕴含**:EaiRec 支架只有前者, + /// 恒玄耳机只有后者,以后的录音笔可能两个都有。 + fileStore, } diff --git a/apps/client/local_plugins/device_plugin_interface/lib/src/device_exception.dart b/apps/client/local_plugins/device_plugin_interface/lib/src/device_exception.dart index 9d13a5ea..7d754249 100644 --- a/apps/client/local_plugins/device_plugin_interface/lib/src/device_exception.dart +++ b/apps/client/local_plugins/device_plugin_interface/lib/src/device_exception.dart @@ -33,6 +33,14 @@ class DeviceErrorCode { static const String otaVerifyFailed = 'device.ota_verify_failed'; static const String otaFileInvalid = 'device.ota_file_invalid'; static const String otaTransferFailed = 'device.ota_transfer_failed'; + + // 设备端文件仓库(DeviceFileStore) + static const String fileNotFound = 'device.file_not_found'; + static const String fileTransferFailed = 'device.file_transfer_failed'; + static const String fileBusy = 'device.file_busy'; + + /// 调用方主动取消。**不是故障**,调用方不该按错误弹提示。 + static const String cancelled = 'device.cancelled'; } /// 设备域统一异常。 diff --git a/apps/client/local_plugins/device_plugin_interface/lib/src/device_files.dart b/apps/client/local_plugins/device_plugin_interface/lib/src/device_files.dart new file mode 100644 index 00000000..a46c54f7 --- /dev/null +++ b/apps/client/local_plugins/device_plugin_interface/lib/src/device_files.dart @@ -0,0 +1,87 @@ +import 'dart:async'; + +import 'device_exception.dart'; + +/// 设备端存储里的一个文件。 +/// +/// 「设备自己录、事后同步到手机」这类硬件的通用抽象:支架/录音笔的 TF 卡、 +/// 眼镜的内置 flash 都是这一类。与 [DeviceAudioSource] 那条「边录边传」的 +/// 实时链路是**两回事**,很多硬件只有其中一条。 +class DeviceFileEntry { + const DeviceFileEntry({ + required this.name, + required this.size, + this.modifiedAt, + this.metadata = const {}, + }); + + /// 设备上的文件名。**这是这个文件在设备上的唯一标识**,下载/删除都用它。 + final String name; + + /// 字节数。设备正在往这个文件里录时它会变,别拿它做「下完了没」的判据。 + final int size; + + /// 设备侧记录的修改时间。解不出合法时间的给 null—— + /// 宁可没有时间,也不要把 1980 年这种垃圾值当真。 + final DateTime? modifiedAt; + + /// 厂商私有的附加信息(原始时间戳、目录、格式…),业务层不解释它。 + final Map metadata; + + @override + String toString() => 'DeviceFileEntry($name, $size B, $modifiedAt)'; +} + +/// 文件传输的取消令牌。 +/// +/// 设备端文件走 BLE 拉,速度按 KB/s 计,一个十几分钟的录音要拉好几分钟—— +/// **必须可取消**,否则用户退出页面/断开设备时会留一条拉不完也停不下的传输。 +class DeviceFileCancelToken { + bool _cancelled = false; + final _completer = Completer(); + + bool get isCancelled => _cancelled; + + /// 已取消时完成的 Future,实现方可以用它去 race 自己的等待。 + Future get whenCancelled => _completer.future; + + void cancel() { + if (_cancelled) return; + _cancelled = true; + if (!_completer.isCompleted) _completer.complete(); + } +} + +/// 设备端文件仓库端口。 +/// +/// 声明 [DeviceCapability.fileStore] 的会话通过 [DeviceSession.fileStore] +/// 暴露它。业务层只认这个接口,不认哪家的芯片、哪条私有通道。 +abstract class DeviceFileStore { + /// 列出设备上的文件。设备没有文件时返回空列表(**不是**抛异常)。 + Future> list(); + + /// 把 [name] 拉到本地 [destPath]。 + /// + /// 实现方负责建父目录。返回实际写入的路径(通常就是 [destPath])。 + /// [onProgress] 的 `total` 在设备没给总长度时为 0。 + /// 被 [cancelToken] 取消时抛 [DeviceException],错误码 + /// [DeviceErrorCode.cancelled],**已写的半成品要保留**(供断点续传)。 + Future download( + String name, { + required String destPath, + void Function(int received, int total)? onProgress, + DeviceFileCancelToken? cancelToken, + }); + + /// 删除设备上的文件。**调用前必须已经过用户二次确认**——这是不可逆的, + /// 而且删的是用户录在硬件里、可能还没同步走的东西。 + Future delete(String name); + + /// 设备主动推来的文件列表(多数硬件会在「录音停止」后推一次)。 + /// 没有这个行为的实现给空流即可,业务层照样能靠 [list] 轮询。 + Stream> get listPushes => const Stream.empty(); + + /// 设备此刻是否正在录音。正在录的那个文件**不能拉**(还在写,拉下来是残的)。 + /// 判断不出来的实现返回 false。 + bool get isDeviceRecording => false; +} diff --git a/apps/client/local_plugins/device_plugin_interface/lib/src/device_info.dart b/apps/client/local_plugins/device_plugin_interface/lib/src/device_info.dart index 04daa7b6..4ac392a6 100644 --- a/apps/client/local_plugins/device_plugin_interface/lib/src/device_info.dart +++ b/apps/client/local_plugins/device_plugin_interface/lib/src/device_info.dart @@ -12,7 +12,7 @@ class DiscoveredDevice { }); /// 厂商插件不透明 ID。 - /// - 杰里:BLE MAC(与 [metadata]['edrAddr'] 一同标识真实设备) + /// - 经典蓝牙:Android 是 MAC,iOS 是 CoreBluetooth peripheral UUID /// - BLE 通用:MAC(Android)/ CBPeripheral.identifier(iOS) /// /// **业务层只把它当字符串用**,不要解析或假设格式。 @@ -27,14 +27,63 @@ class DiscoveredDevice { /// 来源厂商插件 [DevicePlugin.vendorKey]。 final String? vendor; - /// 厂商相关的扩展字段(edrAddr / deviceType / advertisementData / serviceUuids 等)。 - /// key 由具体厂商插件文档约定。 + /// 厂商相关的扩展字段。key 由具体厂商插件文档约定。 final Map metadata; @override String toString() => 'DiscoveredDevice($name, $id, rssi=$rssi)'; } +/// 电量快照。每一项 `-1` 或 null 表示未知/未上报。 +/// +/// TWS 耳机三个值都可能有;支架、香薰这类单体设备只填 [single]。 +@immutable +class DeviceBattery { + const DeviceBattery({ + this.left, + this.right, + this.caseBox, + this.single, + this.chargingLeft = false, + this.chargingRight = false, + this.chargingCase = false, + this.chargingSingle = false, + }); + + static const DeviceBattery unknown = DeviceBattery(); + + final int? left; + final int? right; + final int? caseBox; + + /// 单体设备的电量;TWS 耳机这里给「左右较低的一侧」。 + final int? single; + + final bool chargingLeft; + final bool chargingRight; + final bool chargingCase; + final bool chargingSingle; + + bool get hasAny => + (left ?? -1) >= 0 || + (right ?? -1) >= 0 || + (caseBox ?? -1) >= 0 || + (single ?? -1) >= 0; + + /// 给「只显示一个数」的 UI 用:优先 single,其次左右较低值,再次盒。 + int? get summary { + if ((single ?? -1) >= 0) return single; + final lr = [left, right].where((v) => (v ?? -1) >= 0).cast(); + if (lr.isNotEmpty) return lr.reduce((a, b) => a < b ? a : b); + if ((caseBox ?? -1) >= 0) return caseBox; + return null; + } + + @override + String toString() => + 'Battery(L=$left R=$right C=$caseBox S=$single)'; +} + /// 已连接设备的信息快照。 @immutable class DeviceInfo { @@ -48,6 +97,8 @@ class DeviceInfo { this.manufacturer, this.model, this.batteryPercent, + this.battery = DeviceBattery.unknown, + this.macAddress, this.metadata = const {}, }); @@ -61,9 +112,18 @@ class DeviceInfo { final String? manufacturer; final String? model; - /// 0..100;未知为 null。 + /// 0..100;未知为 null。等价于 [battery].summary,保留兼容旧调用。 final int? batteryPercent; + final DeviceBattery battery; + + /// 设备**真实**蓝牙 MAC(大写、冒号分隔)。 + /// + /// 与 [id] 的区别:iOS 上 [id] 是 peripheral UUID,每台手机都不一样, + /// 不能拿去服务端确权;这个值要么来自系统(Android),要么来自设备自报。 + /// 拿不到为 null。 + final String? macAddress; + final Map metadata; DeviceInfo copyWith({ @@ -74,6 +134,8 @@ class DeviceInfo { String? manufacturer, String? model, int? batteryPercent, + DeviceBattery? battery, + String? macAddress, Map? metadata, }) { return DeviceInfo( @@ -86,19 +148,21 @@ class DeviceInfo { manufacturer: manufacturer ?? this.manufacturer, model: model ?? this.model, batteryPercent: batteryPercent ?? this.batteryPercent, + battery: battery ?? this.battery, + macAddress: macAddress ?? this.macAddress, metadata: metadata ?? this.metadata, ); } } -/// 连接状态机(细化以容纳"链路通了但 RCSP 协议尚未握手"的中间态)。 +/// 连接状态机(细化以容纳"链路通了但私有协议尚未握手"的中间态)。 enum DeviceConnectionState { disconnected, /// 已发起连接,链路(ACL/GATT/SPP)尚未建立 connecting, - /// 蓝牙链路已建立,等待厂商私有协议握手(如杰里 RCSP init、GATT 服务发现等) + /// 蓝牙链路已建立,等待厂商私有协议握手 linkConnected, /// 协议握手完成,业务可调用 read/write/feature @@ -109,12 +173,6 @@ enum DeviceConnectionState { } /// 扫描过滤条件。所有字段均可选;`null` 表示不过滤。 -/// -/// - [namePrefix]:设备名前缀(单串,跨厂商);与 [nameList] 同时存在时各厂商 -/// 实现可自行选择优先级,杰里实现优先 [nameList]。 -/// - [nameList]:设备名命中列表(精确匹配);空 = 不按名过滤。 -/// - [serviceUuids]:UUID 命中列表;杰理实现按 `BleScanMessage.flagContent` -/// 做忽略大小写的 contains 匹配。 @immutable class DeviceScanFilter { const DeviceScanFilter({ @@ -132,8 +190,7 @@ class DeviceScanFilter { final String? vendor; final int? minRssi; - /// 是否跳过没有 `name` 的广播(环境 BLE 噪声过滤);`null` = 由实现选默认。 - /// 杰理实现默认 `true`。 + /// 是否跳过没有 `name` 的广播;`null` = 由实现选默认。 final bool? skipUnnamed; } @@ -147,6 +204,6 @@ class DeviceConnectOptions { final Duration timeout; - /// 厂商私有连接参数(如杰里的 connectWay/deviceType)。 + /// 厂商私有连接参数。 final Map extra; } diff --git a/apps/client/local_plugins/device_plugin_interface/lib/src/device_manager.dart b/apps/client/local_plugins/device_plugin_interface/lib/src/device_manager.dart deleted file mode 100644 index c7f14e0e..00000000 --- a/apps/client/local_plugins/device_plugin_interface/lib/src/device_manager.dart +++ /dev/null @@ -1,107 +0,0 @@ -import 'dart:async'; - -import 'device_capability.dart'; -import 'device_event.dart'; -import 'device_info.dart'; -import 'device_plugin.dart'; -import 'device_session.dart'; - -/// 容器层入口(业务层只见此一个)。 -/// -/// 单设备模型: -/// - `activeSession` 至多一个;切换厂商或连接新设备会先断开旧 session; -/// - 唤醒/翻译键事件统一从 [agentTriggers] 派发; -/// - 音频通道(耳机麦/扬声器)一律由 native 内部管理,Flutter 不直接持有。 -/// -/// 所有方法在 `dispose` 后必须抛 `StateError`。 -abstract class DeviceManager { - // ---------- 厂商管理 ---------- - - /// 已注册的厂商(在 app 启动时由各 plugin 主动 register)。 - Map get registeredVendors; - - /// 注册厂商工厂。`replace=false` 时同 key 重复注册抛 [DeviceException]。 - void registerVendor( - DevicePluginDescriptor descriptor, { - bool replace = false, - }); - - /// 当前激活的厂商 key;未选择为 null。 - String? get activeVendor; - - /// 当前激活厂商的能力集合(便于 UI 一行判断);无 active 时空集合。 - Set get activeCapabilities; - - /// 切换厂商。流程(原子操作): - /// - /// 1. `stopScan()` - /// 2. 断开 [activeSession](若有) - /// 3. dispose 旧 plugin - /// 4. 创建并 initialize 新 plugin - /// 5. 派发 `vendorChanged` - /// - /// 切换中所有 scan/connect 调用抛 `DeviceException('device.vendor_switching')`。 - Future useVendor(String vendorKey, DevicePluginConfig config); - - /// 清空当前 vendor —— 断开 active session、dispose 旧 plugin, - /// 派发 `vendorChanged(vendorKey=null)`。无 active vendor 时 no-op。 - Future clearVendor(); - - // ---------- 扫描 / 连接 ---------- - - Future startScan({DeviceScanFilter? filter, Duration? timeout}); - Future stopScan(); - - /// 当前蓝牙开关状态。仅由 native 实现真实查询,pure-Dart 默认返回 true。 - Future isBluetoothEnabled(); - - /// 已配对设备(来自 active plugin)。 - Future> bondedDevices(); - - /// 连接设备。如果当前已有 active session,先断开旧的再连新的, - /// 并派发 `activeSessionChanged`。 - Future connect( - String deviceId, { - DeviceConnectOptions? options, - }); - - /// 断开当前 session(无 session 时 no-op)。 - Future disconnect(); - - /// 当前 active session;未连接为 null。 - DeviceSession? get activeSession; - - // ---------- agent 协调 ---------- - - /// 设备主动触发 agent 的事件(PTT/语音唤醒/翻译键)。 - /// 由 `agents_server` 中的 `DeviceAgentRouter` 监听。 - Stream get agentTriggers; - - /// 容器全局事件流:扫描结果、连接状态、active session 变更等。 - Stream get eventStream; - - // ---------- 生命周期 ---------- - - Future initialize(); - Future dispose(); -} - -/// 厂商描述符(用于注册表 / 设置 UI 列表)。 -class DevicePluginDescriptor { - DevicePluginDescriptor({ - required this.vendorKey, - required this.displayName, - required this.factory, - this.configSchema = DevicePluginConfigSchema.empty, - this.declaredCapabilities = const {}, - }); - - final String vendorKey; - final String displayName; - final DevicePluginFactory factory; - final DevicePluginConfigSchema configSchema; - - /// 能力声明的"广告版"——容器在 plugin 未 initialize 前用它做 UI 引导。 - /// 真实可用能力以 [DevicePlugin.capabilities] 为准。 - final Set declaredCapabilities; -} diff --git a/apps/client/local_plugins/device_plugin_interface/lib/src/device_ota.dart b/apps/client/local_plugins/device_plugin_interface/lib/src/device_ota.dart index feb73a13..9e4e08c2 100644 --- a/apps/client/local_plugins/device_plugin_interface/lib/src/device_ota.dart +++ b/apps/client/local_plugins/device_plugin_interface/lib/src/device_ota.dart @@ -169,4 +169,11 @@ abstract class DeviceOtaPort { /// 进度事件流(broadcast / multi-subscribe)。 Stream get progressStream; + + /// 释放端口占用的链路资源,并把设备**交还给正常会话**。 + /// + /// 有些方案(恒玄 SPP OTA)升级期间要独占蓝牙链路、必须先断开 app 层连接, + /// 升级完/取消后由端口负责把 app 层连接恢复回来。UI 退出升级页时调用。 + /// 没在跑时 no-op。 + Future close() async {} } diff --git a/apps/client/local_plugins/device_plugin_interface/lib/src/device_plugin.dart b/apps/client/local_plugins/device_plugin_interface/lib/src/device_plugin.dart index bfd486e1..a04accdb 100644 --- a/apps/client/local_plugins/device_plugin_interface/lib/src/device_plugin.dart +++ b/apps/client/local_plugins/device_plugin_interface/lib/src/device_plugin.dart @@ -79,6 +79,28 @@ abstract class DevicePlugin { /// 已配对设备快照(含历史/系统配对,但**不一定**当前可连)。 Future> bondedDevices(); + /// 本插件「记住」的设备(App 自己配过、会自动回连的那些),最近的在前。 + /// 同步读本地存储,不打原生。没有则空列表。 + List get rememberedDevices => const []; + + /// 最近一次连过的设备。断连后设备页仍按它显示主图。 + DiscoveredDevice? get lastKnownDevice => + rememberedDevices.isEmpty ? null : rememberedDevices.first; + + /// 冷启动/断连后尝试回连记住的设备。没记住任何设备时 no-op。 + Future tryAutoConnect() async {} + + /// 把一台设备记为「我的设备」(连接成功后调用),之后会参与自动回连。 + Future rememberDevice(DiscoveredDevice device) async {} + + /// 用户**主动**进入连接页时调:申请权限、起蓝牙栈。返回 false 表示 + /// 当前不能扫/连(权限被拒且在冷却期等),UI 应置灰入口。 + /// 与 [initialize] 的区别:后者在冷启动就跑、不允许弹任何权限框。 + Future ensureReady() async => true; + + /// 把一台设备从「记住」列表移除(解绑),正连着就顺带断开。 + Future forgetDevice(String deviceId) async {} + /// 建立会话。 /// /// 实现要求: @@ -92,6 +114,8 @@ abstract class DevicePlugin { }); /// 当前 active session(最多一个);未连接为 null。 + /// + /// 只有 `state == ready` 的会话才算业务可用,容器层据此维护已连接列表。 DeviceSession? get activeSession; /// 全局事件流:扫描/连接/蓝牙开关/错误等。 diff --git a/apps/client/local_plugins/device_plugin_interface/lib/src/device_session.dart b/apps/client/local_plugins/device_plugin_interface/lib/src/device_session.dart index 9a37e128..ea21f486 100644 --- a/apps/client/local_plugins/device_plugin_interface/lib/src/device_session.dart +++ b/apps/client/local_plugins/device_plugin_interface/lib/src/device_session.dart @@ -1,8 +1,11 @@ import 'dart:async'; +import 'audio_format.dart'; import 'device_capability.dart'; import 'device_event.dart'; +import 'device_exception.dart'; import 'device_info.dart'; +import 'device_files.dart'; import 'device_ota.dart'; /// 已连接设备的会话句柄。生命周期: @@ -14,10 +17,10 @@ import 'device_ota.dart'; /// 铁律: /// 1. `disconnected` 之后任何方法必须抛 `StateError`; /// 2. `eventStream` 必须用 `StreamController.broadcast()`,`disconnect` 时关闭; -/// 3. 同一时刻最多一个 active [DeviceAudioSource] + 一个 active [DeviceAudioSink], -/// 重复 open 抛 `DeviceException('device.audio_busy')`; -/// 4. `invokeFeature` / `read*` / `write*` 在状态非 `ready` 时抛 -/// `DeviceException('device.no_active_session')`,禁止静默排队。 +/// 3. `invokeFeature` / `read*` 在状态非 `ready` 时抛 +/// `DeviceException('device.no_active_session')`,禁止静默排队; +/// 4. **协议是冻结的**:同一个 featureKey 在同一厂商上永远发同样的字节, +/// 这条由各插件的字节级金测试守着。 abstract class DeviceSession { String get deviceId; String get vendor; @@ -26,9 +29,16 @@ abstract class DeviceSession { DeviceInfo get info; Set get capabilities; + bool supports(DeviceCapability c) => capabilities.contains(c); + /// 会话事件流(连接态、电量、按键、佩戴...)。 Stream get eventStream; + /// 设备主动唤醒(PTT / 语音 / 翻译键 / 挂断)。 + /// + /// 无 [DeviceCapability.aiWake] 的设备给空流即可。 + Stream get wakeEvents => const Stream.empty(); + // -------- 通用查询 -------- Future readRssi(); @@ -38,14 +48,36 @@ abstract class DeviceSession { /// 强制刷新 [DeviceInfo](电量/固件版本/序列号)。 Future refreshInfo(); + // -------- 音频 -------- + + /// 打开一路上行音频。多次打开同一 route 各自独立、互不影响。 + /// + /// 插件负责:让设备开始上行(若需要)、解码到 [AudioFormat.pcm16kMono]、 + /// 挑出这条 route 的帧。不支持的 route 抛 `device.not_supported`。 + Future openMic({AudioRoute route = AudioRoute.mic}) => + throw DeviceException(DeviceErrorCode.notSupported, 'openMic($route)'); + + /// 打开一路下行音频。编码/节流/组包在插件里做。 + Future openSpeaker({AudioRoute route = AudioRoute.ai}) => + throw DeviceException( + DeviceErrorCode.notSupported, 'openSpeaker($route)'); + // -------- 厂商私有特性 -------- - /// 调用厂商私有 feature。`featureKey` 形如: - /// - `common.battery.subscribe` - /// - `jieli.translation.start` - /// - `bes.eq.set` + /// 调用 feature。跨厂商通用的 key(插件按能力声明实现): + /// + /// | key | 能力 | 含义 | + /// |---|---|---| + /// | `record.start` / `record.stop` | micRecording | 设备主麦录音起停(stop 可带 `fileName`) | + /// | `call.start` / `call.stop` | callAudioTap | 进入/退出通话模式(双路上行) | + /// | `call.query` | callState | 主动查一次通话状态 | + /// | `asr.started` / `asr.stopped` | callAudioTap | 通知设备 ASR 起停 | + /// | `ai.accept` / `ai.exit` | aiWake | 应答唤醒 / 请求退出 AI 模式 | + /// | `f2f.start` / `f2f.stop` | faceToFace | 面对面翻译起停 | + /// | `battery.query` | battery | 主动查一次电量 | + /// | `state.refresh` | — | 把所有已知查询各发一遍 | /// - /// 返回 `Map` 或 throw [DeviceException]。 + /// 厂商私有的用 `.`。返回 `Map` 或 throw [DeviceException]。 Future> invokeFeature( String featureKey, [ Map args = const {}, @@ -54,13 +86,70 @@ abstract class DeviceSession { // -------- OTA 端口 -------- /// 该 session 暴露的 OTA 端口;vendor 无 [DeviceCapability.ota] 时返回 null。 - /// - /// 编排器 / UI 通过本接口拿到 vendor-agnostic 的 OTA 控制;不依赖具体厂商。 - /// 端口生命周期与 session 绑定 —— session disconnect 时端口必须自动收尾 - /// (`failed` 事件 + `isRunning=false`)。 DeviceOtaPort? otaPort() => null; + /// 该 session 暴露的设备端文件仓库;无 [DeviceCapability.fileStore] 时返回 null。 + /// + /// 「设备自己录到 TF 卡 / flash,事后 app 同步」这条链路的入口。 + /// 同一个 session 多次调用应返回同一个实例。 + DeviceFileStore? fileStore() => null; + // -------- 关闭 -------- Future disconnect(); } + +/// 通用 feature 事件 key(设备 → app)。插件把私有帧翻译成这些名字派发。 +class DeviceFeatureKeys { + DeviceFeatureKeys._(); + + static const String recordStarted = 'record.started'; + static const String recordStopped = 'record.stopped'; + static const String callStarted = 'call.started'; + static const String callEnded = 'call.ended'; + static const String batteryUpdated = 'battery.updated'; + static const String firmwareUpdated = 'firmware.updated'; + static const String macUpdated = 'mac.updated'; +} + +/// 通用 feature 调用 key(app → 设备)。 +class DeviceFeatures { + DeviceFeatures._(); + + static const String recordStart = 'record.start'; + static const String recordStop = 'record.stop'; + static const String callStart = 'call.start'; + static const String callStop = 'call.stop'; + static const String callQuery = 'call.query'; + static const String asrStarted = 'asr.started'; + static const String asrStopped = 'asr.stopped'; + static const String aiAccept = 'ai.accept'; + static const String aiExit = 'ai.exit'; + static const String f2fStart = 'f2f.start'; + static const String f2fStop = 'f2f.stop'; + static const String batteryQuery = 'battery.query'; + static const String stateRefresh = 'state.refresh'; + + /// AI 会话状态(app → 设备),驱动设备端的指示灯 / 表情动画。 + /// 参数:`{'state': DeviceAiState}`。设备不支持时抛 `device.not_supported`, + /// **调用方要吞掉**——这只是锦上添花,不该影响对话本身。 + static const String aiState = 'ai.state'; +} + +/// [DeviceFeatures.aiState] 的取值。 +/// +/// 刻意用语义名而不是厂商字节:车载香薰映射到 C2 的 `0x07` +/// (0说话结束/1思考中/2说话开始/4聆听中),别的芯片各映射各的。 +enum DeviceAiState { + /// 在听用户说话 + listening, + + /// 已收到用户的话,正在想 + thinking, + + /// AI 正在说 + speaking, + + /// 一轮说完,回到待命 + idle, +} diff --git a/apps/client/local_plugins/device_plugin_interface/pubspec.yaml b/apps/client/local_plugins/device_plugin_interface/pubspec.yaml index 7712630e..7afff3b5 100644 --- a/apps/client/local_plugins/device_plugin_interface/pubspec.yaml +++ b/apps/client/local_plugins/device_plugin_interface/pubspec.yaml @@ -1,10 +1,10 @@ name: device_plugin_interface -description: Abstract contracts for hardware device plugins (BLE / RCSP / vendor SDK). +description: 硬件设备插件的纯 Dart 契约(厂商插件 / 会话 / 能力 / 音频 / OTA)。不含任何原生代码。 publish_to: none -version: 0.1.0 +version: 0.2.0 environment: - sdk: ^3.8.1 + sdk: ">=3.3.0 <4.0.0" flutter: ">=3.3.0" dependencies: @@ -14,12 +14,3 @@ dependencies: dev_dependencies: flutter_test: sdk: flutter - -flutter: - plugin: - platforms: - android: - package: com.aiagent.device_plugin_interface - pluginClass: DevicePluginInterfacePlugin - ios: - pluginClass: DevicePluginInterfacePlugin diff --git a/apps/client/local_plugins/device_smartcar/android/build.gradle b/apps/client/local_plugins/device_smartcar/android/build.gradle new file mode 100644 index 00000000..63bd1502 --- /dev/null +++ b/apps/client/local_plugins/device_smartcar/android/build.gradle @@ -0,0 +1,83 @@ +group = "com.yimai.device_smartcar" +version = "1.0-SNAPSHOT" + +buildscript { + ext.kotlin_version = "2.1.0" + repositories { + google() + mavenCentral() + } + + dependencies { + classpath("com.android.tools.build:gradle:8.7.3") + classpath("org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version") + } +} + +allprojects { + repositories { + google() + mavenCentral() + } +} + +apply plugin: "com.android.library" +apply plugin: "kotlin-android" + +// 杰理 SDK 以「解包后的 jar + jniLibs」形态随插件分发(原始 aar 留在 third_party/aar/): +// Android Gradle Plugin 不允许 library 模块直接依赖本地 .aar +// ("Direct local .aar file dependencies are not supported when building an Android library"), +// 走 flatDir 又要求每个宿主 App 自己配仓库。解包成 jar 就完全自包含。 +// 升级 SDK:换 third_party/aar/ 下的文件,重跑 tool/unpack_aars.sh,提交产物。 +// +// 只带一个:jl_rcsp —— RcspAuth(设备认证,libjl_auth.so / libjl_crc.so),**BLE 能不能连上全靠它**。 +// 它在 maven 上没有坐标,只能 vendor;包名 com.jieli.jl_rcsp.* / jl_filebrowse.*, +// 与工程里已有的 com.jieli.jl_bt_ota.* / com.jieli.audio.* 不重叠(已用 javap 核过)。 +// +// ⚠️ **jl_bt_ota 必须走 maven,不能 vendor**:`ble_service` 与 `recorder_holder` 已经 +// 依赖 com.jieli.jl_bt_ota:jl_bt_ota(解析到 1.11.0),再 vendor 一份 1.10.0 会直接 +// Duplicate class 编不过(2026-09-10 踩过)。这里显式声明同一个坐标即可, +// 它自带 libjl_ota_auth.so,所以 jniLibs 里也不要放那个 so。 +// +// ⚠️ 刻意**没有**带 jl_audio_decode / JL_Watch / jl_bluetooth_connect: +// - 音频解码(opus)阶段 6 才用,且 ble_service 已经带了 com.jieli.audio:jl_audio_decode; +// - JL_Watch 的 WatchOpImpl 是手表专用,我们直接继承 jl_rcsp 的 RcspOpImpl; +// - jl_bluetooth_connect 只被 lumi 的状态码转换用到,RcspAuth 本身不依赖它。 +android { + namespace = "com.yimai.device_smartcar" + compileSdk = 36 + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } + + kotlinOptions { + jvmTarget = JavaVersion.VERSION_17 + } + + sourceSets { + main.java.srcDirs += "src/main/kotlin" + main.jniLibs.srcDirs = ["src/main/jniLibs"] + } + + defaultConfig { + minSdk = 21 + } + + // 杰理的 so 没有 16KB 对齐等新要求,按原样打包 + packagingOptions { + jniLibs { + useLegacyPackaging = false + } + } + + dependencies { + implementation "androidx.core:core-ktx:1.13.1" + implementation fileTree(dir: "libs", include: ["*.jar"]) + // 与 ble_service / recorder_holder 同一个坐标,版本由 Gradle 统一解析 + implementation "com.jieli.jl_bt_ota:jl_bt_ota:1.11.0" + // 麦克风上行的 opus 解码(同上,坐标与 ble_service/recorder_holder 一致) + implementation "com.jieli.audio:jl_audio_decode:2.1.0" + } +} diff --git a/apps/client/local_plugins/device_smartcar/android/libs/jl_rcsp_V0.8.0_705-release.jar b/apps/client/local_plugins/device_smartcar/android/libs/jl_rcsp_V0.8.0_705-release.jar new file mode 100644 index 00000000..bf4110a4 Binary files /dev/null and b/apps/client/local_plugins/device_smartcar/android/libs/jl_rcsp_V0.8.0_705-release.jar differ diff --git a/apps/client/local_plugins/device_smartcar/android/src/main/AndroidManifest.xml b/apps/client/local_plugins/device_smartcar/android/src/main/AndroidManifest.xml new file mode 100644 index 00000000..93acb3b5 --- /dev/null +++ b/apps/client/local_plugins/device_smartcar/android/src/main/AndroidManifest.xml @@ -0,0 +1,10 @@ + + + + + + diff --git a/apps/client/local_plugins/device_smartcar/android/src/main/kotlin/com/yimai/device_smartcar/DeviceSmartcarPlugin.kt b/apps/client/local_plugins/device_smartcar/android/src/main/kotlin/com/yimai/device_smartcar/DeviceSmartcarPlugin.kt new file mode 100644 index 00000000..1a145ec0 --- /dev/null +++ b/apps/client/local_plugins/device_smartcar/android/src/main/kotlin/com/yimai/device_smartcar/DeviceSmartcarPlugin.kt @@ -0,0 +1,280 @@ +package com.yimai.device_smartcar + +import android.bluetooth.BluetoothDevice +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import android.content.IntentFilter +import android.bluetooth.BluetoothAdapter +import android.os.Handler +import android.os.Looper +import io.flutter.embedding.engine.plugins.FlutterPlugin +import io.flutter.plugin.common.EventChannel +import io.flutter.plugin.common.MethodCall +import io.flutter.plugin.common.MethodChannel + +/** + * Smartcar 车载香薰的 Flutter 插件(Android)。 + * + * 方法/事件通道与 iOS **逐字对齐**(`DeviceSmartcarPlugin.swift`),Dart 侧一份代码两端通用: + * + * 方法 `com.yimai.device_smartcar`: + * adapterState() -> String on / off / unauthorized / unsupported + * startScan({timeoutSec}) -> Bool 起不来抛 SCAN_FAILED(带原因) + * stopScan() + * connect({id, timeoutSec}) -> Bool **认证通过才 resolve**;失败抛 CONNECT_FAILED + * disconnect() / isConnected() -> Bool + * writeCustom({data: Uint8List}) -> Bool + * jlPostAuth() -> Map 认证后的 RCSP 收尾状态 + * otaStart({filePath}) / otaCancel() / otaIsRunning() -> Bool + * + * 事件 `com.yimai.device_smartcar/events`: + * {type:"adapter", state} / {type:"scan", id,name,rssi,mfg,services} / {type:"scanStopped"} + * {type:"state", id, state, reason?} / {type:"custom", id, data} + * {type:"ota", state, percent, code?, message?} + * + * ⚠️ `id` 在 Android 上是**蓝牙 MAC**、在 iOS 上是 peripheral UUID。Dart 只把它当不透明串 + * 传回来连接,不要在业务层解释它(真正的设备身份是广播里的 MAC,见 `SmartcarProtocol`)。 + */ +class DeviceSmartcarPlugin : + FlutterPlugin, + MethodChannel.MethodCallHandler, + EventChannel.StreamHandler, + SmartcarGattListener { + + private lateinit var methodChannel: MethodChannel + private lateinit var eventChannel: EventChannel + private var sink: EventChannel.EventSink? = null + private var context: Context? = null + private val handler = Handler(Looper.getMainLooper()) + + private var pendingConnect: MethodChannel.Result? = null + private var connectTimeoutTask: Runnable? = null + + private val central get() = SmartcarBleCentral.shared + + override fun onAttachedToEngine(binding: FlutterPlugin.FlutterPluginBinding) { + context = binding.applicationContext + central.attach(binding.applicationContext) + SmartcarOta.shared.attach(binding.applicationContext) + central.connectListener = this + central.onScanHit = { hit -> + val m = HashMap() + m["type"] = "scan" + m["id"] = hit.id + m["name"] = hit.name + m["rssi"] = hit.rssi + m["services"] = hit.serviceUuids + if (hit.manufacturerData != null) m["mfg"] = hit.manufacturerData + emit(m) + } + central.onScanStopped = { emit(mapOf("type" to "scanStopped")) } + SmartcarOta.shared.onEvent = { emit(it) } + SmartcarAudio.onPcm = { pcm -> emit(mapOf("type" to "audio", "data" to pcm)) } + + methodChannel = MethodChannel(binding.binaryMessenger, "com.yimai.device_smartcar") + methodChannel.setMethodCallHandler(this) + eventChannel = EventChannel(binding.binaryMessenger, "com.yimai.device_smartcar/events") + eventChannel.setStreamHandler(this) + + binding.applicationContext.registerReceiver( + adapterReceiver, IntentFilter(BluetoothAdapter.ACTION_STATE_CHANGED) + ) + } + + override fun onDetachedFromEngine(binding: FlutterPlugin.FlutterPluginBinding) { + try { + binding.applicationContext.unregisterReceiver(adapterReceiver) + } catch (e: Throwable) { + // 没注册上就算了 + } + methodChannel.setMethodCallHandler(null) + eventChannel.setStreamHandler(null) + central.onScanHit = null + central.onScanStopped = null + if (central.connectListener === this) central.connectListener = null + SmartcarOta.shared.onEvent = null + SmartcarAudio.onPcm = null + SmartcarAudio.stop() + sink = null + context = null + } + + private val adapterReceiver = object : BroadcastReceiver() { + override fun onReceive(ctx: Context?, intent: Intent?) { + if (intent?.action != BluetoothAdapter.ACTION_STATE_CHANGED) return + emit(mapOf("type" to "adapter", "state" to central.stateString)) + } + } + + // ───────────── 事件 ───────────── + + override fun onListen(arguments: Any?, events: EventChannel.EventSink?) { + sink = events + } + + override fun onCancel(arguments: Any?) { + sink = null + } + + private fun emit(m: Map) { + handler.post { sink?.success(m) } + } + + // ───────────── 方法 ───────────── + + override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) { + when (call.method) { + "adapterState" -> result.success(central.stateString) + + "startScan" -> { + val timeout = call.argument("timeoutSec") ?: 15 + val err = central.startScan(timeout) + if (err == null) result.success(true) + else result.error("SCAN_FAILED", err, null) + } + + "stopScan" -> { + central.stopScan() + result.success(null) + } + + "connect" -> { + val id = call.argument("id") + if (id.isNullOrEmpty()) { + result.error("BAD_ARGS", "缺少 id", null) + return + } + if (pendingConnect != null) { + result.error("BUSY", "上一次连接还在进行中", null) + return + } + val session = central.connect(id, this) + if (session == null) { + result.error("NOT_FOUND", "这个 id 不是合法的蓝牙地址,或蓝牙不可用", null) + return + } + if (session.isReady) { + result.success(true) + return + } + pendingConnect = result + val timeoutSec = call.argument("timeoutSec") ?: 45 + val task = Runnable { + if (pendingConnect == null) return@Runnable + central.disconnect() + finishConnect("连接总超时(${timeoutSec}s),停在 ${central.active?.state?.raw ?: "?"}") + } + connectTimeoutTask = task + handler.postDelayed(task, timeoutSec * 1000L) + } + + "disconnect" -> { + central.disconnect() + result.success(null) + } + + "isConnected" -> result.success(central.active?.isReady == true) + + "writeCustom" -> { + val data = call.argument("data") + if (data == null) { + result.error("BAD_ARGS", "data 必须是 Uint8List", null) + return + } + val s = central.active + result.success(s != null && s.isGattConnected && s.writeCustom(data)) + } + + "jlPostAuth" -> { + if (!SmartcarRcsp.shared.isAuthPass) { + result.error("NOT_READY", "链路未就绪(认证未过)", null) + return + } + var done = false + // SDK 不回调就永远不回,给 8s 兜底(与 iOS 同) + handler.postDelayed({ + if (!done) { done = true; result.success(mapOf("timeout" to true)) } + }, 8_000) + SmartcarRcsp.shared.runPostAuth { report -> + handler.post { + if (!done) { done = true; result.success(report) } + } + } + } + + // 设备在唤醒后自己开麦,这里只决定要不要往 Dart 抛 + "audioStart" -> result.success(SmartcarAudio.start()) + "audioStop" -> { + SmartcarAudio.stop() + result.success(null) + } + "audioStats" -> result.success(SmartcarAudio.stats()) + + "otaStart" -> { + val path = call.argument("filePath") + if (path.isNullOrEmpty()) { + result.error("BAD_ARGS", "缺少 filePath", null) + return + } + val err = SmartcarOta.shared.start(path) + if (err == null) result.success(true) else result.error("OTA_FAILED", err, null) + } + + "otaCancel" -> { + SmartcarOta.shared.cancel() + result.success(null) + } + + "otaIsRunning" -> result.success(SmartcarOta.shared.isRunning) + + else -> result.notImplemented() + } + } + + private fun finishConnect(error: String?) { + connectTimeoutTask?.let { handler.removeCallbacks(it) } + connectTimeoutTask = null + val r = pendingConnect ?: return + pendingConnect = null + if (error != null) r.error("CONNECT_FAILED", error, null) else r.success(true) + } + + // ───────────── SmartcarGattListener ───────────── + + override fun onLinkState(session: SmartcarGatt, state: SmartcarLinkState, reason: String?) { + val m = HashMap() + m["type"] = "state" + m["id"] = session.id + m["state"] = state.raw + if (reason != null) m["reason"] = reason + emit(m) + + SmartcarOta.shared.onLinkState(session, state) + + when (state) { + SmartcarLinkState.READY -> finishConnect(null) + SmartcarLinkState.FAILED -> finishConnect(reason ?: "连接失败") + SmartcarLinkState.DISCONNECTED -> + if (pendingConnect != null) { + finishConnect("连接过程中链路断开" + (reason?.let { ":$it" } ?: "")) + } + else -> {} + } + } + + override fun onCustomData(session: SmartcarGatt, data: ByteArray) { + emit(mapOf("type" to "custom", "id" to session.id, "data" to data)) + } + + override fun onJlData(session: SmartcarGatt, data: ByteArray) { + // 音频帧与 RCSP 命令共用 AE02,先让音频挑走(只认那个 7 字节头) + SmartcarAudio.feedJl(data) + // 认证前/后都先过 RCSP;OTA 在跑且认证已过时,数据归 OTA SDK(对齐 lumi 的分流) + if (SmartcarOta.shared.isRunning && SmartcarRcsp.shared.isAuthPass) { + SmartcarOta.shared.onDeviceData(session.device, data) + } else { + SmartcarRcsp.shared.onJlData(data) + } + } +} diff --git a/apps/client/local_plugins/device_smartcar/android/src/main/kotlin/com/yimai/device_smartcar/OggOpusExtractor.kt b/apps/client/local_plugins/device_smartcar/android/src/main/kotlin/com/yimai/device_smartcar/OggOpusExtractor.kt new file mode 100644 index 00000000..8ccf50ef --- /dev/null +++ b/apps/client/local_plugins/device_smartcar/android/src/main/kotlin/com/yimai/device_smartcar/OggOpusExtractor.kt @@ -0,0 +1,137 @@ +package com.yimai.device_smartcar + +/** + * 从 **Ogg 封装的 Opus 流**里拆出一个个 opus 包。与 iOS `OggOpusExtractor.swift` 一字对应。 + * + * ## 为什么需要它(2026-09-11 iOS 真机踩出来的) + * 设备经 AE02 送上来的不是裸 opus 帧,而是 **Ogg 流**:`OggS` 页头 + 段表 + 页体。 + * 直接把整段 payload 喂给解码器,解出来是噪声——真机上表现为"上行有包、百炼一句不回, + * 5 秒无声自动结束会话"。 + * + * 逻辑照 lumi `MusicDetector.swift` 内嵌的同名类搬: + * - BLE 通知会把一页切成好几包,所以要**跨包累积**再找 `OggS`; + * - 段长 255 表示"还没完,接着拼",< 255 才是一个完整 opus 包的结尾; + * - 每页校验 CRC32(Ogg 用 `0x04C11DB7` 无反射变体),校验不过就从下一字节重找同步字, + * 不能整段丢——BLE 丢包时后面还有好页; + * - `OpusHead` / `OpusTags` 两个头包要跳过,喂给解码器会出错。 + */ +class OggOpusExtractor { + + companion object { + private const val MAX_BUFFER = 64 * 1024 + + private val CRC_TABLE = IntArray(256).also { t -> + for (i in 0 until 256) { + var r = i shl 24 + repeat(8) { + r = if (r and 0x8000_0000.toInt() != 0) (r shl 1) xor 0x04C1_1DB7 else (r shl 1) + } + t[i] = r + } + } + } + + private var buffer = ByteArray(0) + + fun reset() { + buffer = ByteArray(0) + } + + /** 喂一段 payload,返回这次能凑出的完整 opus 包(可能 0 个,也可能好几个) */ + fun feed(payload: ByteArray): List { + if (payload.isEmpty()) return emptyList() + buffer += payload + val out = ArrayList() + var searchFrom = 0 + while (true) { + val sync = indexOfOggS(buffer, searchFrom) + if (sync < 0) { + // 没找到同步字:只留最后 3 字节,`OggS` 可能被切在两包之间 + val keepFrom = maxOf(buffer.size - 3, searchFrom) + if (keepFrom in 1 until buffer.size) { + buffer = buffer.copyOfRange(keepFrom, buffer.size) + } + break + } + if (sync + 27 > buffer.size) break + val pageSegments = buffer[sync + 26].toInt() and 0xFF + val headerLen = 27 + pageSegments + if (sync + headerLen > buffer.size) break + var bodyLen = 0 + for (i in 0 until pageSegments) bodyLen += buffer[sync + 27 + i].toInt() and 0xFF + val totalPageLen = headerLen + bodyLen + if (sync + totalPageLen > buffer.size) break + if (!verifyCrc(buffer, sync, totalPageLen)) { + searchFrom = sync + 1 + continue + } + if (sync > 0) buffer = buffer.copyOfRange(sync, buffer.size) + extractPackets(headerLen, pageSegments, out) + buffer = buffer.copyOfRange(totalPageLen, buffer.size) + searchFrom = 0 + } + // 防炸:只可能是一直没对上同步字,保留末尾一段继续找 + if (buffer.size > MAX_BUFFER) { + val cutFrom = buffer.size - MAX_BUFFER + val cut = indexOfOggS(buffer, cutFrom) + buffer = buffer.copyOfRange(if (cut >= 0) cut else cutFrom, buffer.size) + } + return out + } + + /** 调用时 buffer 已经以 `OggS` 开头(sync == 0) */ + private fun extractPackets(headerLen: Int, pageSegments: Int, out: MutableList) { + var current = ByteArray(0) + var bodyOffset = headerLen + for (i in 0 until pageSegments) { + val segLen = buffer[27 + i].toInt() and 0xFF + if (bodyOffset + segLen > buffer.size) break + if (segLen > 0) { + current += buffer.copyOfRange(bodyOffset, bodyOffset + segLen) + bodyOffset += segLen + } + // 段长 255 = 还有后续段属于同一个包;< 255 才收尾 + if (segLen < 255) { + if (current.isNotEmpty() && !isOpusHeader(current)) out.add(current) + current = ByteArray(0) + } + } + } + + private fun indexOfOggS(data: ByteArray, start: Int): Int { + var i = maxOf(start, 0) + val end = data.size - 4 + while (i <= end) { + if (data[i] == 0x4F.toByte() && data[i + 1] == 0x67.toByte() && + data[i + 2] == 0x67.toByte() && data[i + 3] == 0x53.toByte() + ) return i + i++ + } + return -1 + } + + /** `OpusHead` / `OpusTags` 不是音频包,喂给解码器会出错 */ + private fun isOpusHeader(p: ByteArray): Boolean { + if (p.size < 8) return false + return p[0] == 0x4F.toByte() && p[1] == 0x70.toByte() && p[2] == 0x75.toByte() && + p[3] == 0x73.toByte() && (p[4] == 0x48.toByte() || p[4] == 0x54.toByte()) + } + + /** Ogg 页 CRC:算的时候把 CRC 字段本身(22..25)当 0 */ + private fun verifyCrc(data: ByteArray, offset: Int, pageLen: Int): Boolean { + if (offset + pageLen > data.size) return false + val stored = (data[offset + 22].toInt() and 0xFF) or + ((data[offset + 23].toInt() and 0xFF) shl 8) or + ((data[offset + 24].toInt() and 0xFF) shl 16) or + ((data[offset + 25].toInt() and 0xFF) shl 24) + var crc = 0 + val crcStart = offset + 22 + val crcEnd = offset + 25 + for (i in offset until offset + pageLen) { + val b = if (i in crcStart..crcEnd) 0 else data[i].toInt() and 0xFF + val idx = ((crc ushr 24) and 0xFF) xor b + crc = (crc shl 8) xor CRC_TABLE[idx] + } + return crc == stored + } +} diff --git a/apps/client/local_plugins/device_smartcar/android/src/main/kotlin/com/yimai/device_smartcar/SmartcarAudio.kt b/apps/client/local_plugins/device_smartcar/android/src/main/kotlin/com/yimai/device_smartcar/SmartcarAudio.kt new file mode 100644 index 00000000..44f42bee --- /dev/null +++ b/apps/client/local_plugins/device_smartcar/android/src/main/kotlin/com/yimai/device_smartcar/SmartcarAudio.kt @@ -0,0 +1,148 @@ +package com.yimai.device_smartcar + +import android.util.Log +import com.jieli.jl_audio_decode.callback.OnDecodeStreamCallback +import com.jieli.jl_audio_decode.opus.OpusManager +import com.jieli.jl_audio_decode.opus.model.OpusOption + +/** + * 设备麦克风上行(Android):从杰理通道(AE02)里挑出 opus 音频帧,解成 16k/16bit/mono PCM。 + * + * 帧格式与 iOS `SmartcarAudio.swift` 完全一致(照 lumi 搬): + * ``` + * FE DC BA 80 01 00 8E | seq(1) | 04 | opus payload … | 尾字节(1,丢弃) + * ``` + * 音频与 RCSP 命令**共用 AE02**,靠这个 7 字节头区分;`seq` 0~255 循环,用来去重。 + * + * ⚠️ 解码器用杰理的 `com.jieli.audio:jl_audio_decode`(`ble_service` / `recorder_holder` + * 已经在依赖同一个坐标,不要再 vendor 第二份),不是 iOS 那边的 libopus—— + * 两端解码库不同但输出口径一致:16k / 单声道 / 16bit 小端 PCM。 + */ +object SmartcarAudio { + private const val TAG = "SmartcarAudio" + + /** 解好的 PCM。由插件转成 `{type:"audio", data}` 事件 */ + var onPcm: ((ByteArray) -> Unit)? = null + + private var opus: OpusManager? = null + private var lastSeq = -1 + + /** ⚠️ 设备送的是 **Ogg 封装的 opus**,不是裸帧,必须先拆页取包。见 [OggOpusExtractor] */ + private val ogg = OggOpusExtractor() + + @Volatile + var isRunning = false + private set + + // 排查用计数:真机上"没声音"时先看这三个数 + private var framesIn = 0 + private var framesDup = 0 + private var pcmBytesOut = 0 + + /** 从 Ogg 里真正拆出来的 opus 包数。一直是 0 = 根本没解析出 Ogg 页 */ + private var opusPackets = 0 + + @Synchronized + fun start(): Boolean { + framesIn = 0; framesDup = 0; pcmBytesOut = 0; opusPackets = 0 + lastSeq = -1 + ogg.reset() + if (opus != null) { + isRunning = true + return true + } + return try { + val m = OpusManager() + // hasHead=false:设备送的是裸 opus 帧,不是 ogg 封装 + val opt = OpusOption() + .setHasHead(false) + .setChannel(1) + .setSampleRate(16_000) + m.startDecodeStream(opt, object : OnDecodeStreamCallback { + override fun onDecodeStream(data: ByteArray?) { + if (data == null || data.isEmpty()) return + pcmBytesOut += data.size + onPcm?.invoke(data) + } + + // ⚠️ 别写成表达式体:Log.i 返回 Int,接口要的是 Unit,编不过。 + override fun onStart() { + Log.i(TAG, "opus 解码流已启动") + } + + override fun onComplete(path: String?) { + Log.i(TAG, "opus 解码流结束") + } + override fun onError(code: Int, msg: String?) { + Log.e(TAG, "opus 解码错误 code=$code msg=$msg") + } + }) + opus = m + isRunning = true + true + } catch (e: Throwable) { + Log.e(TAG, "OpusManager 初始化失败: ${e.message}", e) + false + } + } + + @Synchronized + fun stop() { + isRunning = false + try { + opus?.stopDecodeStream() + opus?.release() + } catch (e: Throwable) { + Log.w(TAG, "opus 释放失败: ${e.message}") + } + opus = null + lastSeq = -1 + } + + fun stats(): Map = mapOf( + "running" to isRunning, + "framesIn" to framesIn, + "opusPackets" to opusPackets, + "framesDup" to framesDup, + "pcmBytesOut" to pcmBytesOut, + ) + + /** 喂一包 AE02 原始数据。不是音频帧就忽略(RCSP 命令走 SmartcarRcsp 那条路) */ + @Synchronized + fun feedJl(packet: ByteArray) { + if (!isRunning || !isAudioPacket(packet)) return + val seq = packet[7].toInt() and 0xFF + if (lastSeq >= 0) { + // seq 1 字节循环;> 200 跳到 < 50 视为回绕,不当重复 + val wrapped = lastSeq > 200 && seq < 50 + if (!wrapped && seq <= lastSeq) { + framesDup++ + return + } + } + lastSeq = seq + framesIn++ + if (packet.size <= 10) return + // 先从 Ogg 流里拆出完整的 opus 包(跨 BLE 包累积),再逐包喂解码器。 + // 直接喂整段 payload 解出来是噪声——iOS 那边真机踩过。 + val payload = packet.copyOfRange(9, packet.size - 1) + val packets = ogg.feed(payload) + if (packets.isEmpty()) return + opusPackets += packets.size + for (pkt in packets) { + try { + opus?.writeAudioStream(pkt) + } catch (e: Throwable) { + Log.w(TAG, "writeAudioStream 失败: ${e.message}") + } + } + } + + /** 头 7 字节 + `[8] == 0x04` 一起判,单看头会把 RCSP 帧误收 */ + fun isAudioPacket(p: ByteArray): Boolean { + if (p.size < 9) return false + return p[0] == 0xFE.toByte() && p[1] == 0xDC.toByte() && p[2] == 0xBA.toByte() && + p[3] == 0x80.toByte() && p[4] == 0x01.toByte() && p[5] == 0x00.toByte() && + p[6] == 0x8E.toByte() && p[8] == 0x04.toByte() + } +} diff --git a/apps/client/local_plugins/device_smartcar/android/src/main/kotlin/com/yimai/device_smartcar/SmartcarBleCentral.kt b/apps/client/local_plugins/device_smartcar/android/src/main/kotlin/com/yimai/device_smartcar/SmartcarBleCentral.kt new file mode 100644 index 00000000..1040feef --- /dev/null +++ b/apps/client/local_plugins/device_smartcar/android/src/main/kotlin/com/yimai/device_smartcar/SmartcarBleCentral.kt @@ -0,0 +1,185 @@ +package com.yimai.device_smartcar + +import android.Manifest +import android.annotation.SuppressLint +import android.bluetooth.BluetoothAdapter +import android.bluetooth.BluetoothManager +import android.bluetooth.le.ScanCallback +import android.bluetooth.le.ScanResult +import android.bluetooth.le.ScanSettings +import android.content.Context +import android.content.pm.PackageManager +import android.os.Build +import android.os.Handler +import android.os.Looper +import android.util.Log +import androidx.core.content.ContextCompat + +/** 一条扫描结果。厂商数据原样上抛,字段解释在 Dart(`SmartcarProtocol`) */ +data class SmartcarScanHit( + val id: String, + val name: String, + val rssi: Int, + /** `[公司ID低, 公司ID高] + payload` —— 拼成和 iOS `CBAdvertisementDataManufacturerDataKey` 一样的形状 */ + val manufacturerData: ByteArray?, + val serviceUuids: List, +) + +/** + * Smartcar 的 BLE 扫描 / 连接持有者(进程内单例),对齐 iOS `SmartcarBleCentral`。 + * + * 扫描**不加 ScanFilter**:C2 的身份在厂商自定义数据里,用 service 过滤一台都扫不到 + * (iOS 上踩过同样的坑)。判定在 Dart 侧按厂商 ID + 名字做。 + */ +@SuppressLint("MissingPermission") +class SmartcarBleCentral private constructor() { + + companion object { + private const val TAG = "SmartcarBleCentral" + val shared = SmartcarBleCentral() + } + + private val handler = Handler(Looper.getMainLooper()) + private var context: Context? = null + private var adapter: BluetoothAdapter? = null + + var onScanHit: ((SmartcarScanHit) -> Unit)? = null + var onScanStopped: (() -> Unit)? = null + + /** OTA stage2 回连用:先于 [onScanHit] 看每条结果,返回 true 表示已处理 */ + var scanInterceptor: ((SmartcarScanHit) -> Boolean)? = null + + /** 原生侧自己发起连接(OTA 回连)时用的监听者 */ + var connectListener: SmartcarGattListener? = null + + var active: SmartcarGatt? = null + private set + + var isScanning = false + private set + + fun attach(ctx: Context) { + if (context != null) return + context = ctx.applicationContext + adapter = (ctx.getSystemService(Context.BLUETOOTH_SERVICE) as? BluetoothManager)?.adapter + } + + // ───────────── 状态与权限 ───────────── + + val stateString: String + get() { + val a = adapter ?: return "unsupported" + if (!hasPermission(scan = true)) return "unauthorized" + return if (a.isEnabled) "on" else "off" + } + + /** + * Android 12+ 把蓝牙权限拆成了运行时的 BLUETOOTH_SCAN / BLUETOOTH_CONNECT。 + * 没授权时直接抛 SecurityException,所以先查一遍给出说得清楚的错误。 + */ + private fun hasPermission(scan: Boolean): Boolean { + val ctx = context ?: return false + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.S) return true + val perm = if (scan) Manifest.permission.BLUETOOTH_SCAN else Manifest.permission.BLUETOOTH_CONNECT + return ContextCompat.checkSelfPermission(ctx, perm) == PackageManager.PERMISSION_GRANTED + } + + // ───────────── 扫描 ───────────── + + private val scanTimeout = Runnable { stopScan() } + + /** 返回 null = 已启动,否则是失败原因 */ + fun startScan(timeoutSec: Int): String? { + val a = adapter ?: return "这台设备不支持蓝牙" + if (!a.isEnabled) return "蓝牙没打开" + if (!hasPermission(scan = true)) return "缺少「附近的设备」权限(BLUETOOTH_SCAN),去系统设置里打开" + if (isScanning) return null + val scanner = a.bluetoothLeScanner ?: return "拿不到 BluetoothLeScanner(蓝牙可能刚被关掉)" + val settings = ScanSettings.Builder() + .setScanMode(ScanSettings.SCAN_MODE_LOW_LATENCY) + .build() + return try { + scanner.startScan(null, settings, scanCallback) + isScanning = true + handler.removeCallbacks(scanTimeout) + handler.postDelayed(scanTimeout, timeoutSec.coerceAtLeast(1) * 1000L) + null + } catch (e: Exception) { + "启动扫描失败: ${e.message}" + } + } + + fun stopScan() { + handler.removeCallbacks(scanTimeout) + if (!isScanning) return + isScanning = false + try { + adapter?.bluetoothLeScanner?.stopScan(scanCallback) + } catch (e: Exception) { + Log.w(TAG, "stopScan 失败: ${e.message}") + } + onScanStopped?.invoke() + } + + private val scanCallback = object : ScanCallback() { + override fun onScanResult(callbackType: Int, result: ScanResult) { + val record = result.scanRecord + var mfg: ByteArray? = null + val msd = record?.manufacturerSpecificData + if (msd != null && msd.size() > 0) { + val cid = msd.keyAt(0) + val payload = msd.valueAt(0) ?: ByteArray(0) + // Android 把公司 ID 提到了 key 上,这里补回去(小端), + // 让 Dart 侧拿到和 iOS 完全一样的字节,一份解析代码两端通用 + mfg = byteArrayOf((cid and 0xFF).toByte(), ((cid shr 8) and 0xFF).toByte()) + payload + } + val hit = SmartcarScanHit( + id = result.device.address, + name = record?.deviceName ?: (try { result.device.name } catch (e: SecurityException) { null }) ?: "", + rssi = result.rssi, + manufacturerData = mfg, + serviceUuids = record?.serviceUuids?.map { it.uuid.toString() } ?: emptyList(), + ) + handler.post { + if (scanInterceptor?.invoke(hit) == true) return@post + onScanHit?.invoke(hit) + } + } + + override fun onScanFailed(errorCode: Int) { + Log.e(TAG, "扫描失败 errorCode=$errorCode") + isScanning = false + handler.post { onScanStopped?.invoke() } + } + } + + // ───────────── 连接 ───────────── + + /** 返回 null = 拿不到这个 MAC 对应的设备 */ + fun connect(id: String, listener: SmartcarGattListener): SmartcarGatt? { + val ctx = context ?: return null + val a = adapter ?: return null + if (!BluetoothAdapter.checkBluetoothAddress(id)) return null + stopScan() + val cur = active + if (cur != null && cur.id.equals(id, ignoreCase = true) && + cur.state != SmartcarLinkState.DISCONNECTED && cur.state != SmartcarLinkState.FAILED + ) { + return cur // 正在连/已连的同一台,复用 + } + if (cur != null && !cur.id.equals(id, ignoreCase = true)) cur.disconnect() + val device = try { + a.getRemoteDevice(id) + } catch (e: IllegalArgumentException) { + return null + } + val session = SmartcarGatt(ctx, device, listener) + active = session + session.connect() + return session + } + + fun disconnect() { + active?.disconnect() + } +} diff --git a/apps/client/local_plugins/device_smartcar/android/src/main/kotlin/com/yimai/device_smartcar/SmartcarGatt.kt b/apps/client/local_plugins/device_smartcar/android/src/main/kotlin/com/yimai/device_smartcar/SmartcarGatt.kt new file mode 100644 index 00000000..4e27c3ee --- /dev/null +++ b/apps/client/local_plugins/device_smartcar/android/src/main/kotlin/com/yimai/device_smartcar/SmartcarGatt.kt @@ -0,0 +1,463 @@ +package com.yimai.device_smartcar + +import android.annotation.SuppressLint +import android.bluetooth.BluetoothDevice +import android.bluetooth.BluetoothGatt +import android.bluetooth.BluetoothGattCallback +import android.bluetooth.BluetoothGattCharacteristic +import android.bluetooth.BluetoothGattDescriptor +import android.bluetooth.BluetoothProfile +import android.content.Context +import android.os.Build +import android.os.Handler +import android.os.Looper +import android.util.Log +import java.util.ArrayDeque +import java.util.UUID + +/** 链路状态。与 iOS `SmartcarLinkState` 一字不差,Dart 侧按字符串判定 */ +enum class SmartcarLinkState(val raw: String) { + DISCONNECTED("disconnected"), + CONNECTING("connecting"), + LINK_CONNECTED("linkConnected"), + AUTHENTICATING("authenticating"), + READY("ready"), + FAILED("failed"), +} + +interface SmartcarGattListener { + fun onLinkState(session: SmartcarGatt, state: SmartcarLinkState, reason: String?) + fun onCustomData(session: SmartcarGatt, data: ByteArray) + /** OTA 期间 SDK 要拿 AE02 的原始数据 */ + fun onJlData(session: SmartcarGatt, data: ByteArray) +} + +/** + * 一台 Smartcar 的 GATT 会话(Android)。 + * + * 从 lumi `jl/core/BlePeripheral.kt`(1656 行)精简出与固件行为相关的部分: + * - 自持 `BluetoothGatt`,在**同一条连接**上复用杰理服务 AE00 与 C2 私有服务 ABC0 + * (SDK 自己持连接就没法再订阅 ABC0,这正是不能直接用官方 SDK 连接的原因); + * - 服务发现 → 开 AE02 / ABC2 通知 → 协商 MTU → **跑杰理认证**,认证过才算 [SmartcarLinkState.READY]; + * - AE02 数据交给 [SmartcarRcsp],ABC2 数据原样上抛(组帧/CRC 在 Dart)。 + * + * ⚠️ **Android 的 GATT 一次只能有一个在途操作**(写/读/写描述符/协商 MTU 互斥), + * 所以所有操作都要过 [enqueue],在对应回调里 [completedCommand]。没有队列的写法 + * 在真机上表现为"偶尔丢一条命令",而且和手机型号强相关。 + */ +@SuppressLint("MissingPermission") +class SmartcarGatt( + private val context: Context, + val device: BluetoothDevice, + private val listener: SmartcarGattListener, +) { + companion object { + private const val TAG = "SmartcarGatt" + + /** 杰理 RCSP(认证 + OTA) */ + val JL_SERVICE: UUID = UUID.fromString("0000ae00-0000-1000-8000-00805f9b34fb") + val JL_TX: UUID = UUID.fromString("0000ae01-0000-1000-8000-00805f9b34fb") + val JL_RX: UUID = UUID.fromString("0000ae02-0000-1000-8000-00805f9b34fb") + + /** C2 私有通道 */ + val CUSTOM_SERVICE: UUID = UUID.fromString("0000abc0-0000-1111-2222-123456789abc") + val CUSTOM_TX: UUID = UUID.fromString("0000abc1-0000-1111-2222-123456789abc") + val CUSTOM_RX: UUID = UUID.fromString("0000abc2-0000-1111-2222-123456789abc") + + val CCC_DESCRIPTOR: UUID = UUID.fromString("00002902-0000-1000-8000-00805f9b34fb") + + private const val CONNECT_TIMEOUT_MS = 35_000L + private const val AUTH_TIMEOUT_MS = 15_000L + /** 单条 GATT 操作的兜底超时:回调不来时不能让队列永久卡住 */ + private const val OP_TIMEOUT_MS = 6_000L + private const val MAX_MTU = 247 + } + + private val handler = Handler(Looper.getMainLooper()) + private var gatt: BluetoothGatt? = null + + var state: SmartcarLinkState = SmartcarLinkState.DISCONNECTED + private set + + val id: String get() = device.address + val name: String get() = try { device.name ?: "" } catch (e: SecurityException) { "" } + val isGattConnected: Boolean get() = gatt != null && state != SmartcarLinkState.DISCONNECTED + val isReady: Boolean get() = state == SmartcarLinkState.READY + + /** OTA 进行中:私有通道写入一律拒绝——业务包会和 SDK 的 OTA 数据抢 AE01 */ + @Volatile + var otaInProgress = false + + var currentMtu = 23 + private set + + private var txJl: BluetoothGattCharacteristic? = null + private var rxJl: BluetoothGattCharacteristic? = null + private var txCustom: BluetoothGattCharacteristic? = null + private var rxCustom: BluetoothGattCharacteristic? = null + + private var authStarted = false + + // ───────────── 操作队列 ───────────── + + private val queue = ArrayDeque<() -> Unit>() + private var queueBusy = false + private val opTimeout = Runnable { + Log.w(TAG, "GATT 操作超时,强制推进队列") + completedCommand() + } + + private fun enqueue(op: () -> Unit): Boolean { + if (gatt == null) return false + queue.add(op) + handler.post { nextCommand() } + return true + } + + private fun nextCommand() { + if (queueBusy) return + val op = queue.poll() ?: return + if (gatt == null) { + queue.clear() + return + } + queueBusy = true + handler.postDelayed(opTimeout, OP_TIMEOUT_MS) + try { + op() + } catch (e: Throwable) { + Log.e(TAG, "GATT 操作抛异常: ${e.message}") + completedCommand() + } + } + + private fun completedCommand() { + handler.removeCallbacks(opTimeout) + queueBusy = false + handler.post { nextCommand() } + } + + // ───────────── 连接 ───────────── + + fun connect() { + if (state != SmartcarLinkState.DISCONNECTED && state != SmartcarLinkState.FAILED) return + reset() + set(SmartcarLinkState.CONNECTING) + gatt = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { + device.connectGatt(context, false, callback, BluetoothDevice.TRANSPORT_LE) + } else { + device.connectGatt(context, false, callback) + } + if (gatt == null) { + fail("connectGatt 返回 null(蓝牙没开或权限被拒)") + return + } + handler.postDelayed(connectTimeout, CONNECT_TIMEOUT_MS) + } + + private val connectTimeout = Runnable { + if (state == SmartcarLinkState.CONNECTING || state == SmartcarLinkState.LINK_CONNECTED) { + fail("GATT 连接超时(${CONNECT_TIMEOUT_MS / 1000}s),设备可能已被别的手机连着") + closeGatt() + } + } + + private val authTimeout = Runnable { + if (state == SmartcarLinkState.AUTHENTICATING) { + fail("杰理 RCSP 认证超时(${AUTH_TIMEOUT_MS / 1000}s):SDK 没有回调结果。" + + "多半是固件不接受这套认证密钥,或 AE02 通知没真正打开") + closeGatt() + } + } + + fun disconnect() { + handler.removeCallbacks(connectTimeout) + handler.removeCallbacks(authTimeout) + val g = gatt + if (g == null) { + if (state != SmartcarLinkState.DISCONNECTED) set(SmartcarLinkState.DISCONNECTED) + return + } + try { + g.disconnect() + } catch (e: SecurityException) { + Log.w(TAG, "disconnect 权限异常: ${e.message}") + } + // 部分机型 disconnect 后不回调,2s 兜底收状态 + handler.postDelayed({ + if (state != SmartcarLinkState.DISCONNECTED) { + closeGatt() + set(SmartcarLinkState.DISCONNECTED) + } + }, 2_000L) + } + + private fun closeGatt() { + try { + gatt?.close() + } catch (e: Throwable) { + Log.w(TAG, "gatt.close 失败: ${e.message}") + } + gatt = null + queue.clear() + queueBusy = false + handler.removeCallbacks(opTimeout) + } + + private fun reset() { + txJl = null; rxJl = null; txCustom = null; rxCustom = null + authStarted = false + currentMtu = 23 + queue.clear() + queueBusy = false + } + + private fun set(s: SmartcarLinkState, reason: String? = null) { + state = s + listener.onLinkState(this, s, reason) + } + + private fun fail(reason: String) { + handler.removeCallbacks(connectTimeout) + handler.removeCallbacks(authTimeout) + set(SmartcarLinkState.FAILED, reason) + } + + // ───────────── 收发 ───────────── + + /** 往 C2 私有写特征发一帧(组帧/CRC 由 Dart 完成)。没有 ABC1 时退回 AE01,与 lumi 一致 */ + fun writeCustom(data: ByteArray): Boolean { + if (otaInProgress) return false + val ch = txCustom ?: txJl ?: return false + return write(ch, data) + } + + /** 杰理通道(认证帧 / OTA 数据)写 AE01 */ + fun writeJl(data: ByteArray): Boolean { + val ch = txJl ?: return false + return write(ch, data) + } + + private fun write(ch: BluetoothGattCharacteristic, value: ByteArray): Boolean { + if (value.isEmpty() || gatt == null) return false + val type = if (ch.properties and BluetoothGattCharacteristic.PROPERTY_WRITE != 0) { + BluetoothGattCharacteristic.WRITE_TYPE_DEFAULT + } else { + BluetoothGattCharacteristic.WRITE_TYPE_NO_RESPONSE + } + return enqueue { + val g = gatt + val ok = if (g == null) { + false + } else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + g.writeCharacteristic(ch, value, type) == BluetoothGatt.GATT_SUCCESS + } else { + @Suppress("DEPRECATION") + run { + ch.writeType = type + ch.value = value + g.writeCharacteristic(ch) + } + } + if (!ok) { + Log.e(TAG, "writeCharacteristic 失败 ${ch.uuid}") + completedCommand() + } + } + } + + private fun setNotify(ch: BluetoothGattCharacteristic): Boolean { + val descriptor = ch.getDescriptor(CCC_DESCRIPTOR) ?: run { + Log.e(TAG, "${ch.uuid} 没有 CCCD 描述符") + return false + } + val value = if (ch.properties and BluetoothGattCharacteristic.PROPERTY_NOTIFY != 0) { + BluetoothGattDescriptor.ENABLE_NOTIFICATION_VALUE + } else { + BluetoothGattDescriptor.ENABLE_INDICATION_VALUE + } + return enqueue { + val g = gatt + if (g == null || !g.setCharacteristicNotification(ch, true)) { + Log.e(TAG, "setCharacteristicNotification 失败 ${ch.uuid}") + completedCommand() + return@enqueue + } + val ok = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + g.writeDescriptor(descriptor, value) == BluetoothGatt.GATT_SUCCESS + } else { + @Suppress("DEPRECATION") + run { + descriptor.value = value + g.writeDescriptor(descriptor) + } + } + if (!ok) { + Log.e(TAG, "writeDescriptor 失败 ${ch.uuid}") + completedCommand() + } + } + } + + /** + * 认证必须在 AE02 通知打开之后才开始,否则设备的应答收不到。 + * 对齐 lumi:MTU 协商完(或超时)再起认证,让 SDK 拿到真实包长。 + */ + private fun startAuthOnce() { + if (authStarted) return + val rx = rxJl + if (rx == null) { + fail("BLE 已连上,但设备没有杰理服务 AE00 的通知特征,认证走不了") + closeGatt() + return + } + authStarted = true + handler.removeCallbacks(connectTimeout) + set(SmartcarLinkState.AUTHENTICATING) + handler.postDelayed(authTimeout, AUTH_TIMEOUT_MS) + SmartcarRcsp.shared.attach(this) + SmartcarRcsp.shared.onAuthResult = { ok, reason -> + handler.post { + handler.removeCallbacks(authTimeout) + if (ok) { + if (state != SmartcarLinkState.READY) set(SmartcarLinkState.READY) + } else { + fail(reason ?: "杰理认证失败") + closeGatt() + } + } + } + SmartcarRcsp.shared.startAuth() + } + + // ───────────── GATT 回调 ───────────── + + private val callback = object : BluetoothGattCallback() { + + override fun onConnectionStateChange(g: BluetoothGatt, status: Int, newState: Int) { + if (newState == BluetoothProfile.STATE_CONNECTED) { + handler.removeCallbacks(connectTimeout) + handler.postDelayed(connectTimeout, CONNECT_TIMEOUT_MS) + set(SmartcarLinkState.LINK_CONNECTED) + // 连上后等 200ms 再发现服务:太早会碰到 DLE 协商未完成(lumi 同款延时) + handler.postDelayed({ + try { + if (g.discoverServices() != true) fail("discoverServices 起不来") + } catch (e: SecurityException) { + fail("没有蓝牙连接权限: ${e.message}") + } + }, 200L) + } else if (newState == BluetoothProfile.STATE_DISCONNECTED) { + handler.removeCallbacks(connectTimeout) + handler.removeCallbacks(authTimeout) + SmartcarRcsp.shared.detach(this@SmartcarGatt) + val wasConnecting = state == SmartcarLinkState.CONNECTING || + state == SmartcarLinkState.LINK_CONNECTED || + state == SmartcarLinkState.AUTHENTICATING + closeGatt() + reset() + if (wasConnecting && status != BluetoothGatt.GATT_SUCCESS) { + set(SmartcarLinkState.FAILED, "连接过程中断开 (status=$status)") + } else { + set(SmartcarLinkState.DISCONNECTED, if (status == 0) null else "status=$status") + } + } + } + + override fun onServicesDiscovered(g: BluetoothGatt, status: Int) { + if (status != BluetoothGatt.GATT_SUCCESS) { + fail("服务发现失败 status=$status") + closeGatt() + return + } + val list = g.services.joinToString(", ") { it.uuid.toString() } + val jl = g.services.find { it.uuid == JL_SERVICE } + if (jl == null) { + // ⚠️ 没有 AE00 就没法认证,固件不会认我们。把实际服务列出来,别只说"没找到" + fail("BLE 已连上,但设备没有杰理服务 AE00,认证走不了。实际服务: [$list]") + closeGatt() + return + } + txJl = jl.getCharacteristic(JL_TX) + rxJl = jl.getCharacteristic(JL_RX) + if (txJl == null || rxJl == null) { + fail("杰理服务 AE00 里缺少 AE01/AE02 特征") + closeGatt() + return + } + g.services.find { it.uuid == CUSTOM_SERVICE }?.let { svc -> + txCustom = svc.getCharacteristic(CUSTOM_TX) + rxCustom = svc.getCharacteristic(CUSTOM_RX) + } + + setNotify(rxJl!!) + rxCustom?.let { setNotify(it) } + // MTU 协商放在通知之后;无论成败都会走到 startAuthOnce + enqueue { + val ok = try { + gatt?.requestMtu(MAX_MTU) == true + } catch (e: SecurityException) { + false + } + if (!ok) { + completedCommand() + handler.post { startAuthOnce() } + } + } + // 兜底:个别机型 onMtuChanged 不回调,认证就永远起不来, + // 只会在 35s 后报「连接超时」——那个提示指向的方向是错的。 + // startAuthOnce 是幂等的,早到的那次生效即可。 + handler.postDelayed({ startAuthOnce() }, 6_000L) + } + + override fun onDescriptorWrite(g: BluetoothGatt, descriptor: BluetoothGattDescriptor, status: Int) { + if (status != BluetoothGatt.GATT_SUCCESS) { + Log.w(TAG, "CCCD 写失败 ${descriptor.characteristic?.uuid} status=$status") + } + completedCommand() + } + + override fun onMtuChanged(g: BluetoothGatt, mtu: Int, status: Int) { + if (status == BluetoothGatt.GATT_SUCCESS) currentMtu = mtu + Log.i(TAG, "MTU = $currentMtu (status=$status)") + completedCommand() + handler.post { startAuthOnce() } + } + + override fun onCharacteristicWrite(g: BluetoothGatt, ch: BluetoothGattCharacteristic, status: Int) { + if (status != BluetoothGatt.GATT_SUCCESS) { + Log.w(TAG, "写失败 ${ch.uuid} status=$status") + } + completedCommand() + } + + // API 33+ + override fun onCharacteristicChanged( + g: BluetoothGatt, + ch: BluetoothGattCharacteristic, + value: ByteArray, + ) { + dispatch(ch, value) + } + + @Deprecated("Deprecated in Java") + @Suppress("DEPRECATION") + override fun onCharacteristicChanged(g: BluetoothGatt, ch: BluetoothGattCharacteristic) { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) { + dispatch(ch, ch.value ?: ByteArray(0)) + } + } + + private fun dispatch(ch: BluetoothGattCharacteristic, value: ByteArray) { + if (value.isEmpty()) return + when (ch.uuid) { + CUSTOM_RX -> handler.post { listener.onCustomData(this@SmartcarGatt, value) } + JL_RX -> handler.post { listener.onJlData(this@SmartcarGatt, value) } + else -> Log.d(TAG, "未知特征通知 ${ch.uuid}") + } + } + } + + /** 供 OTA SDK 拿原始 gatt(`BluetoothOTAManager.getConnectedBluetoothGatt`) */ + val rawGatt: BluetoothGatt? get() = gatt +} diff --git a/apps/client/local_plugins/device_smartcar/android/src/main/kotlin/com/yimai/device_smartcar/SmartcarOta.kt b/apps/client/local_plugins/device_smartcar/android/src/main/kotlin/com/yimai/device_smartcar/SmartcarOta.kt new file mode 100644 index 00000000..7390e378 --- /dev/null +++ b/apps/client/local_plugins/device_smartcar/android/src/main/kotlin/com/yimai/device_smartcar/SmartcarOta.kt @@ -0,0 +1,325 @@ +package com.yimai.device_smartcar + +import android.annotation.SuppressLint +import android.bluetooth.BluetoothDevice +import android.bluetooth.BluetoothGatt +import android.content.Context +import android.os.Handler +import android.os.Looper +import android.util.Log +import com.jieli.jl_bt_ota.constant.BluetoothConstant +import com.jieli.jl_bt_ota.impl.BluetoothOTAManager +import com.jieli.jl_bt_ota.interfaces.IUpgradeCallback +import com.jieli.jl_bt_ota.model.BluetoothOTAConfigure +import com.jieli.jl_bt_ota.model.base.BaseError +// ⚠️ 用 jl_bt_ota 自己的 StateCode(值与 jl_rcsp 的相同,但这里调的是 OTA SDK 的方法) +import com.jieli.jl_bt_ota.constant.StateCode + +/** + * Smartcar 固件升级(Android)——从 lumi `jl/ota/OtaManager.kt` 搬。 + * + * ⚠️ **lumi 那份是死代码**:`PbManager` 里 `otaManager = OtaManager(context)` 整段被注释掉 + * (原注释「暂时禁用 OTA 以避免初次扫描冲突(当前不使用 OTA 功能)」),也就是说这条 Android + * 路径在 lumi 上**从未真机跑过**。所以这里的实现只能算"照着他们的写法接上去", + * 首次真机升级务必盯日志。iOS 那侧同样未经样机验证。 + * + * 与 iOS 的差别:Android SDK 用 [IUpgradeCallback],stage2 是显式的 + * [IUpgradeCallback.onNeedReconnect](iOS 是 `cmdOTAData` 回调里的 0xfc),语义一致: + * 设备烧完 stage1 会断开重启、换一个地址广播,App 要重新扫到它、连上、过认证, + * 再 [changeToPreparedState] 让 SDK 接着烧。 + * + * 事件形状与 iOS 完全一致:`{type:"ota", state, percent, code?, message?}`。 + */ +@SuppressLint("MissingPermission") +class SmartcarOta private constructor() { + + companion object { + private const val TAG = "SmartcarOta" + val shared = SmartcarOta() + + /** stage2 扫不到设备的总放弃时间。3 轮 60s + 轮间等待,与 iOS 对齐 */ + private const val STAGE2_TIMEOUT_MS = 190_000L + private const val STAGE2_ROUND_SEC = 60 + } + + private val handler = Handler(Looper.getMainLooper()) + + /** 事件出口(插件接到 EventChannel) */ + var onEvent: ((Map) -> Unit)? = null + + private var manager: Manager? = null + private var context: Context? = null + + @Volatile + var isRunning = false + private set + + private var terminal = false + private var lastPercent = -1 + private var stage2Target = "" + private var originalName = "" + private var stage2Deadline = 0L + + fun attach(ctx: Context) { + if (context == null) context = ctx.applicationContext + } + + // ───────────── 对外 ───────────── + + /** 返回 null = 已启动,否则是拒绝原因 */ + fun start(filePath: String): String? { + if (isRunning) return "OTA 已在进行中" + val ctx = context ?: return "插件未初始化" + val session = SmartcarBleCentral.shared.active + if (session == null || !session.isReady) return "链路未就绪(认证未过)" + val f = java.io.File(filePath) + if (!f.isFile || f.length() <= 0) return "固件文件读不到或为空: $filePath" + + originalName = session.name + terminal = false + lastPercent = -1 + isRunning = true + session.otaInProgress = true + + val m = manager ?: Manager(ctx).also { manager = it } + m.session = session + m.isOtaProcess = true + + // ⚠️ 这些开关只有 setter 没有 getter,Kotlin 属性赋值写法编不过,必须调 setXxx() + val option = BluetoothOTAConfigure() + .setPriority(BluetoothOTAConfigure.PREFER_BLE) + // 走 SDK 自己的重连逻辑(lumi 试过自定义重连,会让状态机卡住) + .setUseReconnect(false) + // 设备走的是标准 RCSP 握手(已经在 SmartcarRcsp 里做完),不是 SDK 内置那套; + // 打开会让 SDK 发设备不认的挑战包,超时失败 + .setUseAuthDevice(false) + .setMtu(BluetoothConstant.BLE_MTU_MIN) + .setNeedChangeMtu(false) + .setUseJLServer(false) + .setPriorityCallbackOtaFinish(true) + // 默认 500ms/包是 stage1 最大瓶颈;MTU 已协商到 ~247,贴着写回调发下一包 + .setBleIntervalMs(0) + .setFirmwareFilePath(filePath) + m.configure(option) + m.onBtDeviceConnection(session.device, StateCode.CONNECTION_OK) + session.rawGatt?.let { m.onMtuChanged(it, session.currentMtu, BluetoothGatt.GATT_SUCCESS) } + + emit("preparing", 0) + Log.i(TAG, "start: ${f.length()} bytes, device=$originalName") + m.startOTA(upgradeCallback) + return null + } + + fun cancel() { + if (!isRunning) return + Log.i(TAG, "cancel") + try { + manager?.cancelOTA() + } catch (e: Throwable) { + Log.w(TAG, "cancelOTA 失败: ${e.message}") + } + emit("cancelled", -1) + finish() + } + + /** AE02 数据:认证通过且 OTA 在跑时归 SDK */ + fun onDeviceData(device: BluetoothDevice, data: ByteArray) { + manager?.onReceiveDeviceData(device, data) + } + + /** 链路状态变化(插件转发) */ + fun onLinkState(session: SmartcarGatt, state: SmartcarLinkState) { + if (!isRunning) return + when (state) { + SmartcarLinkState.DISCONNECTED, SmartcarLinkState.FAILED -> { + // stage2 期间设备本来就会断开重启,这不是失败;交给 SDK 的状态机 + manager?.notifyConnectionState(session.device, StateCode.CONNECTION_DISCONNECT) + } + SmartcarLinkState.READY -> { + if (stage2Target.isEmpty()) return + Log.i(TAG, "stage2 已连上并认证,通知 SDK 续跑") + manager?.session = session + session.otaInProgress = true + stage2Target = "" + SmartcarBleCentral.shared.scanInterceptor = null + manager?.changeToPreparedState() + } + else -> {} + } + } + + // ───────────── SDK 回调 ───────────── + + private val upgradeCallback = object : IUpgradeCallback { + override fun onStartOTA() { + Log.i(TAG, "onStartOTA") + emit("transferring", 0) + } + + override fun onNeedReconnect(addr: String?, isBle: Boolean) { + // stage1 完成:设备断开重启,换地址广播,要我们重新连上去 + Log.i(TAG, "onNeedReconnect addr=$addr isBle=$isBle") + stage2Target = (addr ?: "").uppercase() + emit("stage2", if (lastPercent < 15) 15 else lastPercent, message = "$stage2Target|$originalName") + startStage2Scan() + } + + override fun onProgress(type: Int, progress: Float) { + val p = progress.toInt().coerceIn(0, 100) + if (p <= lastPercent) return + lastPercent = p + emit("transferring", p) + } + + override fun onStopOTA() { + Log.i(TAG, "onStopOTA(成功)") + emit("done", 100) + finish() + } + + override fun onCancelOTA() { + Log.i(TAG, "onCancelOTA") + emit("cancelled", -1) + finish() + } + + override fun onError(error: BaseError?) { + val msg = error?.message ?: "unknown" + val code = error?.subCode ?: -1 + Log.e(TAG, "onError code=$code msg=$msg") + emit("failed", -1, code = code, message = msg) + finish() + } + } + + // ───────────── stage2 扫描回连 ───────────── + + private fun startStage2Scan() { + val central = SmartcarBleCentral.shared + stage2Deadline = System.currentTimeMillis() + STAGE2_TIMEOUT_MS + central.scanInterceptor = { hit -> + if (!matchesStage2(hit)) { + false + } else { + Log.i(TAG, "stage2 命中 ${hit.name} (${hit.id})") + central.stopScan() + central.scanInterceptor = null + val l = central.connectListener + if (l == null || central.connect(hit.id, l) == null) { + emit("failed", -1, message = "stage2 设备无法发起连接") + finish() + } + true + } + } + scanRound() + } + + private fun scanRound() { + if (!isRunning || stage2Target.isEmpty()) return + if (System.currentTimeMillis() > stage2Deadline) { + emit("failed", -1, message = "3 轮扫描(约 3 分钟)都没等到设备重启后的广播") + finish() + return + } + val err = SmartcarBleCentral.shared.startScan(STAGE2_ROUND_SEC) + if (err != null) { + emit("failed", -1, message = "stage2 扫描起不来: $err") + finish() + return + } + // 一轮到点没命中就再起一轮;同时喂上层 90s 看门狗(stage2 窗口没有进度事件) + handler.postDelayed({ + if (isRunning && stage2Target.isNotEmpty()) { + emit("stage2", if (lastPercent < 15) 15 else lastPercent) + scanRound() + } + }, (STAGE2_ROUND_SEC + 3) * 1000L) + handler.postDelayed(stage2Heartbeat, 15_000L) + } + + private val stage2Heartbeat = object : Runnable { + override fun run() { + if (!isRunning || stage2Target.isEmpty()) return + emit("stage2", if (lastPercent < 15) 15 else lastPercent) + handler.postDelayed(this, 15_000L) + } + } + + private fun matchesStage2(hit: SmartcarScanHit): Boolean { + if (stage2Target.isNotEmpty() && hit.id.equals(stage2Target, ignoreCase = true)) return true + // ⚠️ SDK 给的回连地址经常和实际 ADV 对不上(lumi 明确记过这个坑),名字兜底不能删。 + // TODO(smartcar-ota): stage2 的广播名待样机确认(lumi 是固定的 Lumi-Music) + val n = hit.name.trim().lowercase() + if (n.isEmpty()) return false + if (n.contains("smartcar")) return true + val base = originalName.lowercase().substringBefore('-') + return base.length >= 4 && n.contains(base) + } + + // ───────────── 收尾 ───────────── + + private fun finish() { + if (terminal) return + terminal = true + isRunning = false + stage2Target = "" + lastPercent = -1 + handler.removeCallbacks(stage2Heartbeat) + SmartcarBleCentral.shared.scanInterceptor = null + manager?.isOtaProcess = false + manager?.session?.otaInProgress = false + manager?.session = null + } + + private fun emit(state: String, percent: Int, code: Int? = null, message: String? = null) { + val m = HashMap() + m["type"] = "ota" + m["state"] = state + m["percent"] = percent + if (code != null) m["code"] = code + if (message != null) m["message"] = message + handler.post { onEvent?.invoke(m) } + } + + /** + * 杰理 OTA SDK 的适配层:把 SDK 需要的「连接/收发」接到我们自持的 GATT 上。 + */ + private class Manager(ctx: Context) : BluetoothOTAManager(ctx) { + var session: SmartcarGatt? = null + var isOtaProcess = false + + fun changeToPreparedState() { + session?.let { onBtDeviceConnection(it.device, StateCode.CONNECTION_OK) } + } + + fun notifyConnectionState(device: BluetoothDevice, state: Int) { + onBtDeviceConnection(device, state) + } + + override fun getConnectedDevice(): BluetoothDevice? = session?.device + + override fun getConnectedBluetoothGatt(): BluetoothGatt? = session?.rawGatt + + override fun connectBluetoothDevice(device: BluetoothDevice?) { + // 回连由我们自己的 stage2 扫描做(SDK 那套扫描会和我们抢 Android 的扫描配额) + val ok = session?.let { it.connect(); true } ?: false + if (!ok) onBtDeviceConnection(device, StateCode.CONNECTION_FAILED) + } + + override fun disconnectBluetoothDevice(device: BluetoothDevice?) { + session?.disconnect() + } + + override fun sendDataToDevice(device: BluetoothDevice?, data: ByteArray?): Boolean { + if (data == null) return false + return session?.writeJl(data) == true + } + + // 注:lumi 那份还 override 了 `startBLEScan` 来拦 SDK 内部的重连扫描 + // (Android 30s 内只允许 5 次扫描,SDK 会拿一个可能错的 MAC 反复扫、白占配额)。 + // 我们用的 jl_bt_ota **1.11.0 已经不自带扫描**(`BluetoothOTAManager` 不再继承 + // BluetoothDiscovery,接口里没有 startBLEScan),回连一律由上面的 stage2 扫描做, + // 所以这个 override 没有了也不需要。 + } +} diff --git a/apps/client/local_plugins/device_smartcar/android/src/main/kotlin/com/yimai/device_smartcar/SmartcarRcsp.kt b/apps/client/local_plugins/device_smartcar/android/src/main/kotlin/com/yimai/device_smartcar/SmartcarRcsp.kt new file mode 100644 index 00000000..dc62f933 --- /dev/null +++ b/apps/client/local_plugins/device_smartcar/android/src/main/kotlin/com/yimai/device_smartcar/SmartcarRcsp.kt @@ -0,0 +1,182 @@ +package com.yimai.device_smartcar + +import android.bluetooth.BluetoothDevice +import android.util.Log +import com.jieli.jl_rcsp.constant.StateCode +import com.jieli.jl_rcsp.impl.RcspAuth +import com.jieli.jl_rcsp.impl.RcspOpImpl +import com.jieli.jl_rcsp.interfaces.rcsp.RcspCommandCallback +import com.jieli.jl_rcsp.model.base.BaseError +import com.jieli.jl_rcsp.model.base.CommandBase +import com.jieli.jl_rcsp.util.CommandBuilder +import java.util.Calendar + +/** + * 杰理 RCSP 认证 + 命令层(Android)。 + * + * 从 lumi `jl/jieLi/JLWatchManager.kt` 搬,**去掉 WatchOpImpl**:那是手表专用基类 + * (表盘/通知推送),Smartcar 用不上,直接继承 `RcspOpImpl` 就够,也省掉 JL_Watch 这个 jar。 + * + * 固件行为:设备连上后要在杰理服务 AE00 上跑挑战应答,**认证通过前不把手机当成已连接** + * (屏幕显示 BLE 未连接、私有通道 ABC0 的命令也不应答)。算法在 `libjl_auth.so` 里, + * 只能调 SDK。见 docs/Smartcar车载香薰-接入设计与落地文档.md 5.5。 + * + * 数据分流(对齐 lumi `PbManager.onReceiveJLResponse`): + * - 认证通过前:AE02 收到的一律喂 [RcspAuth.handleAuthData] + * - 认证通过后:喂 [notifyReceiveDeviceData],让 RCSP 命令层自己处理 + */ +class SmartcarRcsp : RcspOpImpl() { + + companion object { + private const val TAG = "SmartcarRcsp" + val shared = SmartcarRcsp() + } + + /** 当前 GATT 会话,由 [attach] 设置 */ + private var gatt: SmartcarGatt? = null + + @Volatile + var isAuthPass = false + private set + + /** 认证结果回调,由 [SmartcarGatt] 设置 */ + var onAuthResult: ((Boolean, String?) -> Unit)? = null + + private val auth = RcspAuth( + RcspAuth.IRcspAuthOp { device, data -> sendDataToDevice(device, data) }, + object : RcspAuth.OnRcspAuthListener { + override fun onInitResult(ok: Boolean) { + Log.i(TAG, "RcspAuth 初始化: $ok") + isAuthPass = false + if (!ok) onAuthResult?.invoke(false, "杰理认证库初始化失败(libjl_auth.so 没加载起来)") + } + + override fun onAuthSuccess(device: BluetoothDevice?) { + Log.i(TAG, "杰理认证通过 ${device?.address}") + isAuthPass = true + // 告诉 RCSP 命令层设备已就绪:SDK 会自己去取设备信息/对齐状态, + // 相当于 iOS 那边 JL_BLEKit 认证后自动跑的那串(见 iOS JLAssistBridge.runPostAuth) + try { + notifyBtDeviceConnection(device, StateCode.CONNECTION_OK) + } catch (e: Throwable) { + Log.w(TAG, "notifyBtDeviceConnection 失败(不致命): ${e.message}") + } + onAuthResult?.invoke(true, null) + } + + override fun onAuthFailed(device: BluetoothDevice?, code: Int, msg: String?) { + Log.w(TAG, "杰理认证失败 code=$code msg=$msg") + isAuthPass = false + onAuthResult?.invoke(false, "杰理 RCSP 认证失败: code=$code ${msg ?: ""}") + } + } + ) + + fun attach(session: SmartcarGatt) { + gatt = session + isAuthPass = false + } + + fun detach(session: SmartcarGatt) { + if (gatt !== session) return + val device = session.device + isAuthPass = false + try { + auth.stopAuth(device, false) + notifyBtDeviceConnection(device, StateCode.CONNECTION_DISCONNECT) + } catch (e: Throwable) { + Log.w(TAG, "detach 清理失败: ${e.message}") + } + gatt = null + } + + /** 开始认证。必须在 AE02 通知已打开之后调,否则设备的应答收不到 */ + fun startAuth() { + val d = gatt?.device ?: return + isAuthPass = false + auth.stopAuth(d, false) + auth.startAuth(d) + } + + /** AE02 的通知数据 */ + fun onJlData(data: ByteArray) { + val d = gatt?.device ?: return + if (!isAuthPass) { + auth.handleAuthData(d, data) + } else { + notifyReceiveDeviceData(d, data) + } + } + + /** + * 认证通过后的 RCSP 收尾:同步时间 → 取设备信息 → 取系统信息(通用 / BT)。 + * + * ⚠️ **与 iOS 逐条对齐**(`JLAssistBridge.runPostAuth`)。iOS 真机上验过: + * GATT 连上、认证通过、`0x10` 和 `0x15` 都成功,设备屏幕**仍显示 BLE 未连接**, + * 把这一串 RCSP 加上(外加 `0x17`)之后才亮。到底是哪一步点亮的没有二分出来, + * 所以两端都整串发,别只发一半。 + * + * `notifyBtDeviceConnection(CONNECTION_OK)` 在认证成功时已经调过,SDK 自己 + * 可能也会取一遍设备信息;这里显式再发一遍是为了和 iOS 完全同构——重复查询是幂等的。 + * + * 每条失败都只记进 report 不中断:这几条是锦上添花,链路本身已经能用。 + */ + fun runPostAuth(done: (Map) -> Unit) { + val device = gatt?.device + if (device == null) { + done(mapOf("error" to "no device")) + return + } + val report = HashMap() + send(device, CommandBuilder.buildSyncTimeCmd(Calendar.getInstance()), "setTime", report) { + send(device, CommandBuilder.buildGetDeviceInfoCmdForAll(), "targetFeature", report) { + send(device, CommandBuilder.buildGetPublicSysInfoCmd(), "sysInfoCommon", report) { + send(device, CommandBuilder.buildGetBtSysInfoCmd(), "sysInfoBT", report) { + done(report) + } + } + } + } + } + + /** 发一条 RCSP 命令,成败都记进 [report] 后走 [next],不中断整串 */ + private fun send( + device: BluetoothDevice, + cmd: CommandBase<*, *>, + key: String, + report: HashMap, + next: () -> Unit, + ) { + var fired = false + fun once(v: Any) { + if (fired) return + fired = true + report[key] = v + next() + } + try { + sendRcspCommand(device, cmd, object : RcspCommandCallback> { + override fun onCommandResponse(d: BluetoothDevice?, c: CommandBase<*, *>?) = once(0) + override fun onErrCode(d: BluetoothDevice?, e: BaseError?) { + Log.w(TAG, "$key 失败: ${e?.message}") + once(e?.code ?: -1) + } + }) + } catch (e: Throwable) { + Log.w(TAG, "$key 抛异常: ${e.message}") + once(-1) + } + } + + // ---- IBluetoothProxy 三个抽象方法 ---- + + override fun getConnectedDevice(): BluetoothDevice? = gatt?.device + + override fun isDeviceConnected(device: BluetoothDevice?): Boolean = + device != null && gatt?.device?.address == device.address && gatt?.isGattConnected == true + + override fun sendDataToDevice(device: BluetoothDevice?, data: ByteArray?): Boolean { + if (data == null || data.isEmpty()) return false + return gatt?.writeJl(data) == true + } +} diff --git a/apps/client/local_plugins/device_smartcar/android/third_party/aar/jl_rcsp_V0.8.0_705-release.aar b/apps/client/local_plugins/device_smartcar/android/third_party/aar/jl_rcsp_V0.8.0_705-release.aar new file mode 100644 index 00000000..1bb9afe5 Binary files /dev/null and b/apps/client/local_plugins/device_smartcar/android/third_party/aar/jl_rcsp_V0.8.0_705-release.aar differ diff --git a/apps/client/local_plugins/device_smartcar/android/tool/unpack_aars.sh b/apps/client/local_plugins/device_smartcar/android/tool/unpack_aars.sh new file mode 100755 index 00000000..1b269f1a --- /dev/null +++ b/apps/client/local_plugins/device_smartcar/android/tool/unpack_aars.sh @@ -0,0 +1,24 @@ +#!/usr/bin/env bash +# 把 third_party/aar/ 下的杰理 AAR 解包成 Gradle library 模块能直接消费的形态: +# classes.jar -> libs/.jar +# jni//* -> src/main/jniLibs// +# 理由见 build.gradle 顶部注释。升级 SDK 时替换 aar 后重跑本脚本并提交产物。 +set -euo pipefail +cd "$(dirname "$0")/.." +rm -rf libs src/main/jniLibs +mkdir -p libs src/main/jniLibs +for aar in third_party/aar/*.aar; do + name="$(basename "$aar" .aar)" + tmp="$(mktemp -d)" + unzip -q "$aar" -d "$tmp" + [ -f "$tmp/classes.jar" ] && cp "$tmp/classes.jar" "libs/$name.jar" && echo " jar <- $name" + if [ -d "$tmp/jni" ]; then + for abi_dir in "$tmp"/jni/*/; do + abi="$(basename "$abi_dir")" + mkdir -p "src/main/jniLibs/$abi" + find "$abi_dir" -maxdepth 1 -name '*.so' -exec cp {} "src/main/jniLibs/$abi/" \; + done + echo " so <- $name" + fi + rm -rf "$tmp" +done diff --git a/apps/client/local_plugins/device_smartcar/ios/device_smartcar/Package.swift b/apps/client/local_plugins/device_smartcar/ios/device_smartcar/Package.swift new file mode 100644 index 00000000..ab7dfa07 --- /dev/null +++ b/apps/client/local_plugins/device_smartcar/ios/device_smartcar/Package.swift @@ -0,0 +1,38 @@ +// swift-tools-version: 5.9 +import PackageDescription + +let package = Package( + name: "device_smartcar", + // 与 Podfile 的 platform :ios, '16.0' 及其它本地插件对齐 + // (ios/sync_spm_platform.sh 会核对这个值不高于工程 target) + platforms: [.iOS("16.0")], + products: [ + .library(name: "device-smartcar", targets: ["device_smartcar"]) + ], + dependencies: [ + // JL_BLEKit.xcframework 由 device_jieli vendor 并以产品形式暴露(见那边的 Package.swift)。 + // 它是**动态库**,这里绝不能再 vendor 一份——同一进程装两份会在启动时报 + // 「Class JL_xxx is implemented in both …」,认证行为不可预期。 + // 先例:recorder_holder 也是这样复用 device_jieli 的 JLAudioUnitKit。 + .package(path: "../../../device_jieli/ios/device_jieli"), + // libopus:设备麦克风上行是 opus(见 SmartcarAudio.swift)。 + // opus.xcframework 由 ble_service vendor 并以 `opus-binary` 暴露, + // SwiftPM 不允许跨包同名 target,自己再 vendor 一份会报 + // 「Multiple commands produce opus.framework」。先例:recorder_holder。 + .package(path: "../../../ble_service/ios/ble_service") + ], + targets: [ + .target( + name: "device_smartcar", + dependencies: [ + .product(name: "jl-blekit", package: "device_jieli"), + .product(name: "jl-otalib", package: "device_jieli"), + .product(name: "opus-binary", package: "ble_service"), + ], + path: "Sources/device_smartcar", + linkerSettings: [ + .linkedFramework("CoreBluetooth") + ] + ), + ] +) diff --git a/apps/client/local_plugins/device_smartcar/ios/device_smartcar/Sources/device_smartcar/DeviceSmartcarPlugin.swift b/apps/client/local_plugins/device_smartcar/ios/device_smartcar/Sources/device_smartcar/DeviceSmartcarPlugin.swift new file mode 100644 index 00000000..8b865f28 --- /dev/null +++ b/apps/client/local_plugins/device_smartcar/ios/device_smartcar/Sources/device_smartcar/DeviceSmartcarPlugin.swift @@ -0,0 +1,259 @@ +import Flutter +import UIKit +import CoreBluetooth + +/// Smartcar 车载香薰的 Flutter 插件(iOS)。 +/// +/// 方法通道 `com.yimai.device_smartcar`: +/// adapterState() -> String +/// startScan({timeoutSec}) -> Bool 扫描起不来抛 SCAN_FAILED(带原因) +/// stopScan() +/// connect({id, timeoutSec}) -> Bool **认证通过才 resolve**;失败抛 CONNECT_FAILED(带原因) +/// disconnect() +/// isConnected() -> Bool = 认证通过且 GATT 在线 +/// jlPostAuth() -> Map 认证后的 RCSP 收尾(设时间/GET_TARGET_INFO/GET_SYS_INFO),回各步状态 +/// otaStart({filePath}) -> Bool 杰理 OTA(含 stage2 扫描回连),进度走事件 {type:"ota"} +/// otaCancel() / otaIsRunning() -> Bool +/// audioStart() -> Bool / audioStop() / audioStats() -> Map 麦克风上行(opus→16k PCM) +/// writeCustom({data: Uint8List}) -> Bool 往 ABC1 写一帧裸字节 +/// +/// 事件通道 `com.yimai.device_smartcar/events`,每条是 Map: +/// {type: "adapter", state} +/// {type: "scan", id, name, rssi, mfg: Uint8List?, services: [String]} +/// {type: "scanStopped"} +/// {type: "state", id, state, reason?} state 见 SmartcarLinkState +/// {type: "custom", data: Uint8List} ABC2 通知的裸字节 +/// {type: "ota", state, percent, code?, message?} state ∈ preparing/transferring/stage2/done/failed/cancelled +/// {type: "audio", data: Uint8List} 设备麦克风 PCM(16k/16bit/mono) +public class DeviceSmartcarPlugin: NSObject, FlutterPlugin, FlutterStreamHandler { + private var sink: FlutterEventSink? + private var pendingConnect: FlutterResult? + private var connectTimer: Timer? + + public static func register(with registrar: FlutterPluginRegistrar) { + let channel = FlutterMethodChannel(name: "com.yimai.device_smartcar", + binaryMessenger: registrar.messenger()) + let instance = DeviceSmartcarPlugin() + registrar.addMethodCallDelegate(instance, channel: channel) + + let events = FlutterEventChannel(name: "com.yimai.device_smartcar/events", + binaryMessenger: registrar.messenger()) + events.setStreamHandler(instance) + } + + override init() { + super.init() + let c = SmartcarBleCentral.shared + c.onAdapterState = { [weak self] s in + self?.emit(["type": "adapter", "state": s]) + } + c.onScanHit = { [weak self] hit in + var m: [String: Any] = [ + "type": "scan", + "id": hit.id, + "name": hit.name, + "rssi": hit.rssi, + "services": hit.serviceUuids, + ] + if let d = hit.manufacturerData { + m["mfg"] = FlutterStandardTypedData(bytes: d) + } + self?.emit(m) + } + c.onScanStopped = { [weak self] in + self?.emit(["type": "scanStopped"]) + } + c.connectDelegate = self + SmartcarOta.shared.onEvent = { [weak self] m in self?.emit(m) } + SmartcarAudio.shared.onPcm = { [weak self] pcm in + self?.emit(["type": "audio", "data": FlutterStandardTypedData(bytes: pcm)]) + } + } + + // MARK: - FlutterStreamHandler + + public func onListen(withArguments arguments: Any?, eventSink events: @escaping FlutterEventSink) -> FlutterError? { + sink = events + return nil + } + + public func onCancel(withArguments arguments: Any?) -> FlutterError? { + sink = nil + return nil + } + + private func emit(_ m: [String: Any]) { + if Thread.isMainThread { + sink?(m) + } else { + DispatchQueue.main.async { [weak self] in self?.sink?(m) } + } + } + + // MARK: - 方法 + + public func handle(_ call: FlutterMethodCall, result: @escaping FlutterResult) { + let args = call.arguments as? [String: Any] ?? [:] + let central = SmartcarBleCentral.shared + + switch call.method { + case "adapterState": + central.ensureCentral() + result(central.stateString) + + case "startScan": + let timeout = args["timeoutSec"] as? Int ?? 15 + central.startScan(timeoutSec: timeout) { ok, reason in + if ok { + result(true) + } else { + result(FlutterError(code: "SCAN_FAILED", message: reason, details: nil)) + } + } + + case "stopScan": + central.stopScan() + result(nil) + + case "connect": + guard let id = args["id"] as? String, !id.isEmpty else { + result(FlutterError(code: "BAD_ARGS", message: "缺少 id", details: nil)) + return + } + if pendingConnect != nil { + result(FlutterError(code: "BUSY", message: "上一次连接还在进行中", details: nil)) + return + } + guard let sp = central.connect(id: id, delegate: self) else { + result(FlutterError(code: "NOT_FOUND", + message: "这个 id 不在扫描结果里、系统也没缓存,请重新扫描后再连", + details: nil)) + return + } + if sp.isReady { + result(true) + return + } + pendingConnect = result + let timeout = TimeInterval(args["timeoutSec"] as? Int ?? 45) + connectTimer?.invalidate() + connectTimer = Timer.scheduledTimer(withTimeInterval: timeout, repeats: false) { [weak self] _ in + guard let self = self, self.pendingConnect != nil else { return } + SmartcarBleCentral.shared.disconnect() + self.finishConnect(error: "连接总超时(\(Int(timeout))s),停在 \(SmartcarBleCentral.shared.active?.state.rawValue ?? "?")") + } + + case "disconnect": + central.disconnect() + result(nil) + + case "isConnected": + result(central.active?.isReady ?? false) + + case "audioStart": + // 设备在唤醒后自己开麦,这里只决定要不要往 Dart 抛 + result(SmartcarAudio.shared.start()) + + case "audioStop": + SmartcarAudio.shared.stop() + result(nil) + + case "audioStats": + result(SmartcarAudio.shared.stats) + + case "otaStart": + guard let path = args["filePath"] as? String, !path.isEmpty else { + result(FlutterError(code: "BAD_ARGS", message: "缺少 filePath", details: nil)) + return + } + guard let sp = central.active, sp.isReady else { + result(FlutterError(code: "NOT_READY", message: "链路未就绪(认证未过)", details: nil)) + return + } + if let err = SmartcarOta.shared.start(filePath: path, peripheral: sp) { + result(FlutterError(code: "OTA_FAILED", message: err, details: nil)) + } else { + result(true) + } + + case "otaCancel": + SmartcarOta.shared.cancel() + result(nil) + + case "otaIsRunning": + result(SmartcarOta.shared.isRunning) + + case "jlPostAuth": + guard let sp = central.active, sp.isReady else { + result(FlutterError(code: "NOT_READY", message: "链路未就绪(认证未过)", details: nil)) + return + } + var done = false + // SDK 没回调就永远不回,给 8s 兜底 + DispatchQueue.main.asyncAfter(deadline: .now() + 8) { + if !done { done = true; result(["timeout": true]) } + } + JLAssistBridge.shared.runPostAuth { report in + if !done { done = true; result(report) } + } + + case "writeCustom": + guard let td = args["data"] as? FlutterStandardTypedData else { + result(FlutterError(code: "BAD_ARGS", message: "data 必须是 Uint8List", details: nil)) + return + } + guard let sp = central.active, sp.cb.state == .connected else { + result(false) + return + } + result(sp.writeCustom(td.data)) + + default: + result(FlutterMethodNotImplemented) + } + } + + private func finishConnect(error: String?) { + connectTimer?.invalidate() + connectTimer = nil + guard let r = pendingConnect else { return } + pendingConnect = nil + if let e = error { + r(FlutterError(code: "CONNECT_FAILED", message: e, details: nil)) + } else { + r(true) + } + } +} + +// MARK: - SmartcarPeripheralDelegate + +extension DeviceSmartcarPlugin: SmartcarPeripheralDelegate { + func smartcarPeripheral(_ p: SmartcarPeripheral, didChange state: SmartcarLinkState, reason: String?) { + var m: [String: Any] = ["type": "state", "id": p.id, "state": state.rawValue] + if let r = reason { m["reason"] = r } + emit(m) + SmartcarOta.shared.onLinkState(state, peripheral: p) + + switch state { + case .ready: + finishConnect(error: nil) + case .failed: + finishConnect(error: reason ?? "连接失败") + case .disconnected: + if pendingConnect != nil { + finishConnect(error: "连接过程中链路断开" + (reason.map { ":\($0)" } ?? "")) + } + default: + break + } + } + + func smartcarPeripheral(_ p: SmartcarPeripheral, didReceiveCustom data: Data) { + emit(["type": "custom", "id": p.id, "data": FlutterStandardTypedData(bytes: data)]) + } + + func smartcarPeripheral(_ p: SmartcarPeripheral, didReceiveJl data: Data) { + SmartcarAudio.shared.feedJl(data) + } +} diff --git a/apps/client/local_plugins/device_smartcar/ios/device_smartcar/Sources/device_smartcar/JLAssistBridge.swift b/apps/client/local_plugins/device_smartcar/ios/device_smartcar/Sources/device_smartcar/JLAssistBridge.swift new file mode 100644 index 00000000..8348fa1c --- /dev/null +++ b/apps/client/local_plugins/device_smartcar/ios/device_smartcar/Sources/device_smartcar/JLAssistBridge.swift @@ -0,0 +1,94 @@ +import Foundation +import CoreBluetooth +import JL_BLEKit + +/// 杰理 `JL_Assist` 的薄封装——**只做 RCSP 认证**,不走 JL_BLEMultiple 那套连接管理。 +/// +/// 为什么不用 JL_BLEMultiple 直接连:它会自己持有 CBPeripheral 与连接, +/// 我们就没法在同一条 GATT 连接上再订阅 C2 的私有服务 ABC0。所以照 lumi 的做法, +/// GATT 由 [SmartcarPeripheral] 自己持有,把 `CBPeripheralDelegate` 的回调**转交**给 +/// JL_Assist,让 SDK 只负责认证握手与 RCSP 帧的收发。 +/// +/// `JL_Assist` 必须是进程内唯一实例:lumi 那边的注释写着「每次访问都创建新的 +/// JL_Assist 实例会导致 XPC 连接冲突」。 +final class JLAssistBridge { + static let shared = JLAssistBridge() + + let assist: JL_Assist + + /// SDK 的 `mCmdManager.mEntity` 是 **weak**,我们不自己持有的话认证一过实体就释放了。 + private var boundEntity: JL_EntityM? + + private init() { + assist = JL_Assist() + // 1.13 把 mNeedPaired 改名成了 mAuthEnable;语义就是「连上后要跑设备认证」。 + // Smartcar 固件没过这一步不把手机当成已连接,所以必须开。 + assist.mAuthEnable = true + #if DEBUG + // 排查期打印 AE01/AE02 裸数据,能看见认证握手到底有没有真的来回 + assist.mLogData = true + #else + assist.mLogData = false + #endif + } + + /// 认证通过后、lumi 两端 SDK 都会自动跑的 RCSP 收尾序列 + /// (iOS `JLWatchManager.initDialSystem`,Android 由 `WatchOpImpl` 在 `onAuthSuccess` 后自动发): + /// 设系统时间 → `GET_TARGET_INFO`(cmdTargetFeature) → `GET_SYS_INFO`(COMMON / BT)。 + /// + /// 杰理固件通常在收到 `GET_TARGET_INFO` 之后才把"手机 App 已连接"置位—— + /// 我们只做了认证、一条 RCSP 命令都没发,屏幕就一直显示 BLE 未连接(2026-09-10 真机)。 + /// 表盘文件系统那段(openDialFileSystem)是手表专用,这里不搬。 + func runPostAuth(completion: @escaping ([String: Any]) -> Void) { + let m = assist.mCmdManager + var report: [String: Any] = [:] + + let c = Calendar.current.dateComponents([.year, .month, .day, .hour, .minute, .second], from: Date()) + m.mSystemTime.cmdSetSystemYear(UInt16(c.year ?? 0), month: UInt8(c.month ?? 0), day: UInt8(c.day ?? 0), + hour: UInt8(c.hour ?? 0), minute: UInt8(c.minute ?? 0), second: UInt8(c.second ?? 0)) + report["setTime"] = "sent" + + m.cmdTargetFeatureResult { status, sn, data in + report["targetFeature"] = Int(status.rawValue) + report["targetFeatureLen"] = data?.count ?? 0 + NSLog("[Smartcar] cmdTargetFeatureResult status=%d len=%d", Int(status.rawValue), data?.count ?? 0) + m.cmdGetSystemInfo(.COMMON) { status, sn, data in + report["sysInfoCommon"] = Int(status.rawValue) + NSLog("[Smartcar] cmdGetSystemInfo(COMMON) status=%d", Int(status.rawValue)) + m.cmdGetSystemInfo(.BT) { status, sn, data in + report["sysInfoBT"] = Int(status.rawValue) + NSLog("[Smartcar] cmdGetSystemInfo(BT) status=%d", Int(status.rawValue)) + DispatchQueue.main.async { completion(report) } + } + } + } + } + + /// 转交 `centralManagerDidUpdateState` + func updateCentralState(_ state: CBManagerState) { + assist.assistUpdate(state) + } + + /// 认证通过后把 SDK 的命令实体绑到当前外设。本期不发 RCSP 命令, + /// 但阶段 7 的 OTA 要用;照官方示例在认证成功后才绑。 + func bindEntity(peripheral: CBPeripheral, name: String) { + // 头文件在 NS_ASSUME_NONNULL 里,mCmdManager 导入成非可选;mEntity 是 weak, + // SDK 没绑过时读出来是 nil(运行时行为),所以用 Optional 兜一下。 + let manager = assist.mCmdManager + let existing: JL_EntityM? = manager.mEntity + let entity: JL_EntityM = existing ?? boundEntity ?? JL_EntityM() + entity.setBlePeripheral(peripheral) + entity.setBleItem(name) + entity.mUUID = peripheral.identifier.uuidString + let addr: String? = entity.mBleAddr + if (addr ?? "").isEmpty { + entity.mBleAddr = peripheral.identifier.uuidString + } + manager.mEntity = entity + boundEntity = entity + } + + func unbindEntity() { + boundEntity = nil + } +} diff --git a/apps/client/local_plugins/device_smartcar/ios/device_smartcar/Sources/device_smartcar/OggOpusExtractor.swift b/apps/client/local_plugins/device_smartcar/ios/device_smartcar/Sources/device_smartcar/OggOpusExtractor.swift new file mode 100644 index 00000000..a92c732b --- /dev/null +++ b/apps/client/local_plugins/device_smartcar/ios/device_smartcar/Sources/device_smartcar/OggOpusExtractor.swift @@ -0,0 +1,138 @@ +import Foundation + +/// 从 **Ogg 封装的 Opus 流**里拆出一个个 opus 包。 +/// +/// ## 为什么需要它(2026-09-11 真机踩出来的) +/// 设备经 AE02 送上来的不是裸 opus 帧,而是 **Ogg 流**:`OggS` 页头 + 段表 + 页体。 +/// 第一版直接把整段 payload 喂给 `opus_decode`,结果是:85 帧只解出 21280 字节 PCM +/// (每帧 ~250 字节,而 opus 帧应该是 640/1920 这种整数),百炼收到的是噪声, +/// 自然一句都不回。日志表现是「上行 42 包、下行 0 包,双方 5s 无声自动结束」。 +/// +/// 从 lumi `MusicDetector.swift` 内嵌的同名类搬过来,逻辑一字未改: +/// - BLE 通知会把一页切成好几包,所以要**跨包累积**再找 `OggS`; +/// - 段长 255 表示"还没完,接着拼",< 255 才是一个完整 opus 包的结尾; +/// - 每页校验 CRC32(Ogg 用的是 `0x04C11DB7` 无反射变体),校验不过就从下一字节重找同步字, +/// 不能整段丢——BLE 丢包时后面还有好页; +/// - `OpusHead` / `OpusTags` 这两个头包要跳过,喂给解码器会出错。 +final class OggOpusExtractor { + private var buffer: [UInt8] = [] + private static let maxBuffer = 64 * 1024 + + private static let crcTable: [UInt32] = { + var t = [UInt32](repeating: 0, count: 256) + for i in 0..<256 { + var r = UInt32(i) << 24 + for _ in 0..<8 { + r = (r & 0x8000_0000) != 0 ? (r << 1) ^ 0x04C1_1DB7 : (r << 1) + } + t[i] = r + } + return t + }() + + func reset() { buffer.removeAll(keepingCapacity: true) } + + /// 喂一段 payload,返回这次能凑出的完整 opus 包(可能 0 个,也可能好几个) + func feed(_ payload: Data) -> [Data] { + if payload.isEmpty { return [] } + buffer.append(contentsOf: payload) + var out: [Data] = [] + var searchFrom = 0 + while true { + guard let sync = indexOfOggS(buffer, from: searchFrom) else { + // 没找到同步字:只留最后 3 字节,`OggS` 可能被切在两包之间 + let keepFrom = max(buffer.count - 3, searchFrom) + if keepFrom > 0, keepFrom < buffer.count { + buffer = Array(buffer[keepFrom.. buffer.count { break } + let pageSegments = Int(buffer[sync + 26]) + let headerLen = 27 + pageSegments + if sync + headerLen > buffer.count { break } + var bodyLen = 0 + for i in 0.. buffer.count { break } + if !verifyCrc(buffer, offset: sync, pageLen: totalPageLen) { + searchFrom = sync + 1 + continue + } + if sync > 0 { buffer = Array(buffer[sync.. Self.maxBuffer { + let cutFrom = buffer.count - Self.maxBuffer + if let cut = indexOfOggS(buffer, from: cutFrom) { + buffer = Array(buffer[cut.. buffer.count { break } + if segLen > 0 { + current.append(contentsOf: buffer[bodyOffset.. Int? { + let s = max(start, 0) + let end = data.count - 4 + if s > end { return nil } + for i in s...end { + if data[i] == 0x4F, data[i + 1] == 0x67, data[i + 2] == 0x67, data[i + 3] == 0x53 { + return i + } + } + return nil + } + + /// `OpusHead` / `OpusTags` 不是音频包,喂给解码器会出错 + private func isOpusHeader(_ packet: [UInt8]) -> Bool { + if packet.count < 8 { return false } + return packet[0] == 0x4F && packet[1] == 0x70 && packet[2] == 0x75 && packet[3] == 0x73 && + (packet[4] == 0x48 || packet[4] == 0x54) + } + + /// Ogg 页 CRC:算的时候把 CRC 字段本身(22..25)当 0 + private func verifyCrc(_ data: [UInt8], offset: Int, pageLen: Int) -> Bool { + if offset + pageLen > data.count { return false } + let stored: UInt32 = + UInt32(data[offset + 22]) | + (UInt32(data[offset + 23]) << 8) | + (UInt32(data[offset + 24]) << 16) | + (UInt32(data[offset + 25]) << 24) + var crc: UInt32 = 0 + let crcStart = offset + 22 + let crcEnd = offset + 25 + let end = offset + pageLen + for i in offset..= crcStart && i <= crcEnd) ? 0 : UInt32(data[i]) + let idx = Int((crc >> 24) & 0xFF) ^ Int(b) + crc = (crc << 8) ^ Self.crcTable[idx] + } + return crc == stored + } +} diff --git a/apps/client/local_plugins/device_smartcar/ios/device_smartcar/Sources/device_smartcar/SmartcarAudio.swift b/apps/client/local_plugins/device_smartcar/ios/device_smartcar/Sources/device_smartcar/SmartcarAudio.swift new file mode 100644 index 00000000..16468477 --- /dev/null +++ b/apps/client/local_plugins/device_smartcar/ios/device_smartcar/Sources/device_smartcar/SmartcarAudio.swift @@ -0,0 +1,152 @@ +import Foundation +import opus + +/// 设备麦克风上行:从杰理通道(AE02)里挑出 opus 音频帧,解成 16k/16bit/mono PCM。 +/// +/// ## 帧格式(照 lumi `LumiDeviceManager.isLikelyAudioPacket` / `extractAudioPayload` 搬) +/// ``` +/// FE DC BA 80 01 00 8E | seq(1) | 04 | opus payload … | 尾字节(1,丢弃) +/// 0 1 2 3 4 5 6 7 8 9 ..< count-1 +/// ``` +/// 音频和 RCSP 命令**共用 AE02**,靠这个 7 字节头区分;`seq` 按 0~255 循环,用来去重 +/// (BLE 通知偶尔会重复投递,重复帧解出来是杂音)。 +/// +/// ⚠️ 设备是在收到 `0xCC 0x11`(唤醒)之后**自己开麦**开始送数据的,App 不需要下命令开麦 +/// (协议文档原话:「不需要app回复,设备端直接开麦,录音发opus数据给到app」)。 +/// 所以这里只做"要不要往上抛"的开关,不控制设备。 +/// +/// ⚠️ libopus 来自 `ble_service` 暴露的 `opus-binary` 产品——SwiftPM 不允许跨包同名 target, +/// 自己再 vendor 一份 Xcode 会报「Multiple commands produce opus.framework」。 +/// 先例:`recorder_holder` 也是这么复用的。 +final class SmartcarAudio { + static let shared = SmartcarAudio() + + /// 解好的 PCM(16k/16bit/mono,小端) + var onPcm: ((Data) -> Void)? + + private let lock = NSLock() + private var decoder: OpaquePointer? + private var lastSeq: Int = -1 + + /// ⚠️ 设备送的是 **Ogg 封装的 opus**,不是裸帧。必须先拆页取包再解码, + /// 直接把 payload 喂 `opus_decode` 出来的是噪声(2026-09-11 真机踩过, + /// 表现是"上行有包、百炼一句不回")。见 [OggOpusExtractor]。 + private let ogg = OggOpusExtractor() + + /// 16k mono 最大 60ms/包 → 960 samples + private let maxFrameSize: Int32 = 960 + + /// 排查用:从 Ogg 里真正拆出来的 opus 包数。 + /// 它一直是 0 就说明**根本没解析出 Ogg 页**(同步字没对上/CRC 全错), + /// 与"拆出来了但解不出 PCM"是两种完全不同的故障。 + private(set) var opusPackets = 0 + + private(set) var isRunning = false + + // 排查用计数:真机上"没声音"时先看这三个数分别是多少 + private(set) var framesIn = 0 + private(set) var framesDup = 0 + private(set) var pcmBytesOut = 0 + + private init() {} + + @discardableResult + func start() -> Bool { + lock.lock(); defer { lock.unlock() } + framesIn = 0; framesDup = 0; pcmBytesOut = 0; opusPackets = 0 + lastSeq = -1 + ogg.reset() + if decoder != nil { + isRunning = true + return true + } + var err: Int32 = 0 + guard let d = opus_decoder_create(16_000, 1, &err), err == OPUS_OK else { + NSLog("[SmartcarAudio] opus_decoder_create 失败 err=%d", err) + return false + } + decoder = d + isRunning = true + return true + } + + func stop() { + lock.lock(); defer { lock.unlock() } + isRunning = false + if let d = decoder { + opus_decoder_destroy(d) + decoder = nil + } + lastSeq = -1 + } + + var stats: [String: Any] { + lock.lock(); defer { lock.unlock() } + return [ + "running": isRunning, + "framesIn": framesIn, + "opusPackets": opusPackets, + "framesDup": framesDup, + "pcmBytesOut": pcmBytesOut, + ] + } + + /// 喂一包 AE02 的原始数据。不是音频帧就原样忽略(RCSP 命令走 SDK 那条路)。 + func feedJl(_ packet: Data) { + guard isRunning, Self.isAudioPacket(packet) else { return } + let seq = Int(packet[7]) & 0xFF + + lock.lock() + if lastSeq >= 0 { + // seq 是 1 字节循环计数;> 200 跳到 < 50 视为回绕,不当重复 + let wrapped = lastSeq > 200 && seq < 50 + if !wrapped && seq <= lastSeq { + framesDup += 1 + lock.unlock() + return + } + } + lastSeq = seq + framesIn += 1 + guard let d = decoder, packet.count > 10 else { + lock.unlock() + return + } + // ① 先从 Ogg 流里拆出完整的 opus 包(跨 BLE 包累积,可能 0 个也可能好几个) + let payload = packet.subdata(in: 9..<(packet.count - 1)) + let packets = ogg.feed(payload) + guard !packets.isEmpty else { + lock.unlock() + return + } + opusPackets += packets.count + + // ② 逐包解码,攒成一块再抛,省掉几次跨通道往返 + var out = Data() + var pcm = [opus_int16](repeating: 0, count: Int(maxFrameSize)) + for pkt in packets { + guard !pkt.isEmpty, pkt.count <= 1275 else { continue } + let samples: opus_int32 = pkt.withUnsafeBytes { raw -> opus_int32 in + guard let base = raw.baseAddress?.assumingMemoryBound(to: UInt8.self) else { return -1 } + return opus_decode(d, base, opus_int32(pkt.count), &pcm, maxFrameSize, 0) + } + if samples <= 0 { continue } + out.append(pcm.prefix(Int(samples)).withUnsafeBufferPointer { Data(buffer: $0) }) + } + guard !out.isEmpty else { + lock.unlock() + return + } + pcmBytesOut += out.count + lock.unlock() + + onPcm?(out) + } + + /// 是不是音频帧。头 7 字节 + `[8] == 0x04` 一起判,单看头会把 RCSP 帧误收 + static func isAudioPacket(_ p: Data) -> Bool { + guard p.count >= 9 else { return false } + return p[0] == 0xFE && p[1] == 0xDC && p[2] == 0xBA && p[3] == 0x80 && + p[4] == 0x01 && p[5] == 0x00 && p[6] == 0x8E && p[8] == 0x04 + } +} diff --git a/apps/client/local_plugins/device_smartcar/ios/device_smartcar/Sources/device_smartcar/SmartcarBleCentral.swift b/apps/client/local_plugins/device_smartcar/ios/device_smartcar/Sources/device_smartcar/SmartcarBleCentral.swift new file mode 100644 index 00000000..f9625ca8 --- /dev/null +++ b/apps/client/local_plugins/device_smartcar/ios/device_smartcar/Sources/device_smartcar/SmartcarBleCentral.swift @@ -0,0 +1,230 @@ +import Foundation +import CoreBluetooth + +/// 一条扫描结果(原样把广播段交给 Dart 解析,这里不做任何字段解释) +struct SmartcarScanHit { + let id: String + let name: String + let rssi: Int + /// `CBAdvertisementDataManufacturerDataKey` 的原始内容:前 2 字节是小端公司 ID + let manufacturerData: Data? + let serviceUuids: [String] +} + +/// Smartcar 的 CBCentralManager 持有者(进程内单例)。 +/// +/// 从 lumi 的 `BleScanner.swift` 精简:保留「懒建 central、等 poweredOn 再扫、 +/// 扫描超时、连接/断开回调路由」,去掉状态恢复、自动重连队列、通知中心广播。 +/// +/// 为什么不用 flutter_blue_plus:它的 CBPeripheralDelegate 在它自己手里, +/// 我们拿不到回调去转交给 JL_Assist,认证就没法跑。 +final class SmartcarBleCentral: NSObject { + static let shared = SmartcarBleCentral() + + private var central: CBCentralManager? + private var discovered: [UUID: CBPeripheral] = [:] + private(set) var active: SmartcarPeripheral? + + var onAdapterState: ((String) -> Void)? + var onScanHit: ((SmartcarScanHit) -> Void)? + var onScanStopped: (() -> Void)? + + /// OTA stage2 回连用:先于 [onScanHit] 看每条扫描结果,返回 true 表示已处理(不再上报 Dart) + var scanInterceptor: ((SmartcarScanHit) -> Bool)? + + /// 插件实例:原生侧自己发起的连接(OTA 回连)也要走它,状态事件才会到 Dart + weak var connectDelegate: SmartcarPeripheralDelegate? + + private var readyWaiters: [(Bool) -> Void] = [] + private var readyTimer: Timer? + private static let readyTimeout: TimeInterval = 8 + + private var scanTimer: Timer? + private(set) var isScanning = false + + private override init() { + super.init() + } + + // MARK: - 状态 + + /// 懒建:冷启动不能弹蓝牙权限框,首次真要扫/连时才创建 + func ensureCentral() { + guard central == nil else { return } + central = CBCentralManager(delegate: self, queue: .main, options: [ + CBCentralManagerOptionShowPowerAlertKey: true, + ]) + } + + var stateString: String { + switch central?.state ?? .unknown { + case .poweredOn: return "on" + case .poweredOff: return "off" + case .unauthorized: return "unauthorized" + case .unsupported: return "unsupported" + case .resetting: return "resetting" + default: return "unknown" + } + } + + /// 等 central 变成 poweredOn。unauthorized/unsupported/poweredOff 是终态,直接失败。 + private func waitReady(_ completion: @escaping (Bool) -> Void) { + ensureCentral() + switch central?.state ?? .unknown { + case .poweredOn: + completion(true) + return + case .unauthorized, .unsupported, .poweredOff: + completion(false) + return + default: + break + } + readyWaiters.append(completion) + if readyTimer == nil { + readyTimer = Timer.scheduledTimer(withTimeInterval: Self.readyTimeout, repeats: false) { [weak self] _ in + self?.drainReady(false) + } + } + } + + private func drainReady(_ ok: Bool) { + let list = readyWaiters + readyWaiters.removeAll() + readyTimer?.invalidate() + readyTimer = nil + list.forEach { $0(ok) } + } + + // MARK: - 扫描 + + /// 全量扫(不传 withServices)。C2 的身份信息在厂商自定义数据里, + /// CoreBluetooth 的 service 过滤只匹配「Service UUIDs」字段,加了就一台都扫不到。 + func startScan(timeoutSec: Int, completion: @escaping (Bool, String?) -> Void) { + waitReady { [weak self] ok in + guard let self = self else { return } + guard ok else { + completion(false, "蓝牙不可用: \(self.stateString)" + + (self.stateString == "unauthorized" ? "(权限被拒,去系统设置里打开)" : "")) + return + } + if self.isScanning { + completion(true, nil) + return + } + self.isScanning = true + self.discovered.removeAll() + self.central?.scanForPeripherals(withServices: nil, options: [ + CBCentralManagerScanOptionAllowDuplicatesKey: false, + ]) + self.scanTimer?.invalidate() + self.scanTimer = Timer.scheduledTimer(withTimeInterval: TimeInterval(max(timeoutSec, 1)), repeats: false) { [weak self] _ in + self?.stopScan() + } + completion(true, nil) + } + } + + func stopScan() { + scanTimer?.invalidate() + scanTimer = nil + guard isScanning else { return } + isScanning = false + central?.stopScan() + onScanStopped?() + } + + // MARK: - 连接 + + private func peripheral(for id: String) -> CBPeripheral? { + guard let uuid = UUID(uuidString: id) else { return nil } + if let p = discovered[uuid] { return p } + // 记住的设备回连:不在本次扫描列表里,从系统缓存捞 + if let p = central?.retrievePeripherals(withIdentifiers: [uuid]).first { + discovered[uuid] = p + return p + } + return nil + } + + /// 返回 nil 表示这个 id 既不在扫描结果里、系统也没缓存 + func connect(id: String, delegate: SmartcarPeripheralDelegate) -> SmartcarPeripheral? { + ensureCentral() + guard let central = central, let p = peripheral(for: id) else { return nil } + stopScan() + if let old = active, old.id != id { + old.disconnect() + } + if let cur = active, cur.id == id, cur.state != .disconnected, cur.state != .failed { + // 正在连/已连的同一台:复用,不重复发起 + cur.delegate = delegate + return cur + } + let sp = SmartcarPeripheral(peripheral: p, central: central, delegate: delegate) + active = sp + sp.connect() + return sp + } + + func disconnect() { + active?.disconnect() + } +} + +// MARK: - CBCentralManagerDelegate + +extension SmartcarBleCentral: CBCentralManagerDelegate { + + func centralManagerDidUpdateState(_ central: CBCentralManager) { + // SDK 要知道适配器状态,否则认证流程不启动(lumi 标注为"关键修复") + JLAssistBridge.shared.updateCentralState(central.state) + onAdapterState?(stateString) + switch central.state { + case .poweredOn: + drainReady(true) + case .poweredOff, .unauthorized, .unsupported: + drainReady(false) + if central.state == .poweredOff { + stopScan() + active?.handleDidDisconnect(nil) + } + default: + break + } + } + + func centralManager(_ central: CBCentralManager, didDiscover peripheral: CBPeripheral, + advertisementData: [String: Any], rssi RSSI: NSNumber) { + discovered[peripheral.identifier] = peripheral + let name = peripheral.name + ?? (advertisementData[CBAdvertisementDataLocalNameKey] as? String) + ?? "" + let mfg = advertisementData[CBAdvertisementDataManufacturerDataKey] as? Data + let svcs = (advertisementData[CBAdvertisementDataServiceUUIDsKey] as? [CBUUID])? + .map { $0.uuidString } ?? [] + let hit = SmartcarScanHit( + id: peripheral.identifier.uuidString, + name: name, + rssi: RSSI.intValue, + manufacturerData: mfg, + serviceUuids: svcs + ) + if let i = scanInterceptor, i(hit) { return } + onScanHit?(hit) + } + + func centralManager(_ central: CBCentralManager, didConnect peripheral: CBPeripheral) { + guard let a = active, a.cb.identifier == peripheral.identifier else { return } + a.handleDidConnect() + } + + func centralManager(_ central: CBCentralManager, didFailToConnect peripheral: CBPeripheral, error: Error?) { + guard let a = active, a.cb.identifier == peripheral.identifier else { return } + a.handleDidFailToConnect(error) + } + + func centralManager(_ central: CBCentralManager, didDisconnectPeripheral peripheral: CBPeripheral, error: Error?) { + guard let a = active, a.cb.identifier == peripheral.identifier else { return } + a.handleDidDisconnect(error) + } +} diff --git a/apps/client/local_plugins/device_smartcar/ios/device_smartcar/Sources/device_smartcar/SmartcarOta.swift b/apps/client/local_plugins/device_smartcar/ios/device_smartcar/Sources/device_smartcar/SmartcarOta.swift new file mode 100644 index 00000000..0a879f19 --- /dev/null +++ b/apps/client/local_plugins/device_smartcar/ios/device_smartcar/Sources/device_smartcar/SmartcarOta.swift @@ -0,0 +1,286 @@ +import Foundation +import CoreBluetooth +import JL_BLEKit +import JL_OTALib + +/// Smartcar(杰理方案)固件升级——从 lumi `PbMangerImp.startOta / handleOtaResult / +/// scanAndConnectStage2` 搬过来,接口对齐 lumi 的 `cmdOTAData` 流程(不是 SDK 新加的 +/// `cmdUpgrade:Option:` 内置回连——那条路 SDK 要自己持有连接,和我们自持 GATT 的做法冲突)。 +/// +/// 流程(单备份固件两段式): +/// ``` +/// cmdOTAData(固件) → 0x08~0x0f 进度 +/// → 0xfc「需按 MAC 回连」= stage1 完成,设备断开重启进 stage2 广播 +/// ├─ 记下 SDK 给的 MAC(outputDeviceModel().bleAddr) +/// ├─ 扫描 3 轮 × 60s:广播 MAC 相同 或 名字命中 → 连接 → 杰理认证 +/// └─ 认证过:noteEntityConnected → cmdTargetFeature → 再 cmdOTAData(同一份固件) +/// → 0x00 成功 / 0x01~0x07、0xf1~0xfb、0xfd 失败 / 0xe1 取消 +/// ``` +/// +/// ⚠️ lumi 的 stage2 广播名固定叫 `Lumi-Music`;Smartcar 的 stage2 名字**还没在样机上看过** +/// (TODO(smartcar-ota)),所以匹配顺序是:广播 MAC == SDK 给的 MAC → 名字含原设备名前缀。 +/// lumi 自己也踩过「SDK 给的 reconnect MAC 经常错」,所以名字兜底不能删。 +/// +/// OTA 期间私有通道(ABC1)的写入被 [SmartcarPeripheral.otaInProgress] 挡掉: +/// 业务包会和 SDK 的 `cmdOTAData` 抢 AE01,lumi 那边卡死过。 +final class SmartcarOta { + static let shared = SmartcarOta() + + enum Phase { case idle, running, stage2Scanning, stage2Connecting } + + /// 事件出口(由插件接到 EventChannel): + /// `{type:"ota", state, percent, code?, message?}`,state ∈ preparing / transferring / + /// stage2 / done / failed / cancelled + var onEvent: (([String: Any]) -> Void)? + + private(set) var phase: Phase = .idle + var isRunning: Bool { phase != .idle } + + private var firmware: Data? + private var targetMac = "" + private var originalName = "" + private var stage2Round = 0 + private var stage2Timer: Timer? + private var heartbeat: Timer? + private var lastLoggedRaw: UInt16? + private weak var peripheral: SmartcarPeripheral? + + private static let stage2MaxRounds = 3 + private static let stage2RoundSec = 60 + private static let stage2InterRoundDelay: TimeInterval = 3 + + private init() {} + + private var otaManager: JL_OTAManager { + JLAssistBridge.shared.assist.mCmdManager.mOTAManager + } + + // MARK: - 对外 + + /// 返回 nil = 已启动;否则是拒绝原因 + func start(filePath: String, peripheral: SmartcarPeripheral) -> String? { + guard phase == .idle else { return "OTA 已在进行中" } + guard peripheral.isReady else { return "链路未就绪(认证未过)" } + guard let data = try? Data(contentsOf: URL(fileURLWithPath: filePath)), !data.isEmpty else { + return "固件文件读不到或为空: \(filePath)" + } + firmware = data + originalName = peripheral.name + self.peripheral = peripheral + peripheral.otaInProgress = true + lastLoggedRaw = nil + phase = .running + emit(state: "preparing", percent: 0) + NSLog("[SmartcarOta] start: %d bytes, device=%@", data.count, originalName) + invoke(data) + return nil + } + + func cancel() { + guard phase != .idle else { return } + NSLog("[SmartcarOta] cancel (phase=%d)", phaseCode) + if phase == .running { + otaManager.cmdOTACancelResult(nil) + } + SmartcarBleCentral.shared.stopScan() + emit(state: "cancelled", percent: -1) + finish() + // 对齐 lumi cancelOta:主动断开,让设备退出升级态 + SmartcarBleCentral.shared.disconnect() + } + + /// 由插件在每次链路状态变化时调用 + func onLinkState(_ state: SmartcarLinkState, peripheral p: SmartcarPeripheral) { + guard phase != .idle else { return } + switch state { + case .disconnected: + // OTA 期间告诉 SDK 链路断了,否则它还以为连着继续往旧 peripheral 发数据 + // —— lumi 注明这是 stage1→stage2 续接能成功的关键 + otaManager.noteEntityDisconnected() + if phase == .stage2Connecting { + fail(code: 0xfd, message: "stage2 设备连上又断开") + } + case .ready: + guard phase == .stage2Connecting else { return } + NSLog("[SmartcarOta] stage2 已连上并认证,续跑 OTA") + self.peripheral = p + p.otaInProgress = true + phase = .running + otaManager.noteEntityConnected() + // 缺 cmdTargetFeature 时 SDK 会按默认 JL_OtaStatusNormal + BootLoaderYES 走 + // loader 下载路径,卡死在 "Need to download loader."(lumi 原话)。 + // 500ms 让认证收尾命令排空,再 1s 等设备 0x03 应答 + DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { [weak self] in + guard let self = self, self.phase == .running else { return } + self.otaManager.cmdTargetFeature() + DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) { [weak self] in + guard let self = self, self.phase == .running, let d = self.firmware else { return } + self.invoke(d) + } + } + case .failed: + if phase == .stage2Connecting { + fail(code: 0xfd, message: "stage2 连接失败") + } + default: + break + } + } + + // MARK: - SDK 回调 + + private func invoke(_ data: Data) { + otaManager.cmdOTAData(data) { [weak self] result, prog in + self?.handle(result: result, prog: prog) + } + } + + /// `rawCode` 直接对应 `JL_OTAResult`,**不折叠**——0x08 升级中 / 0x0b 准备中会被误判成失败 + private func handle(result: JL_OTAResult, prog: Float) { + guard phase != .idle else { return } + let raw = result.rawValue + if raw != lastLoggedRaw { + NSLog("[SmartcarOta] cmdOTAData ===> 0x%02x", Int(raw)) + lastLoggedRaw = raw + } + switch raw { + case 0x00: + emit(state: "done", percent: 100) + finish() + case 0x08...0x0f: + emit(state: "transferring", percent: Int((prog * 100).rounded())) + case 0xfc, 0x09, 0xfe: + // stage1 完成,设备即将断开重启进 stage2。0x09/0xfe 是按 UUID/更新资源回连, + // lumi 只处理 0xfc;这里一并按"扫描回连"处理,比忽略掉安全 + guard phase == .running else { return } + let model = JLAssistBridge.shared.assist.mCmdManager.outputDeviceModel() + targetMac = (model.bleAddr ?? "").uppercased() + NSLog("[SmartcarOta] stage1 完成(0x%02x),等设备重启后回连 mac=%@ name=%@", Int(raw), targetMac, originalName) + emit(state: "stage2", percent: 15, message: "\(targetMac)|\(originalName)") + stage2Round = 0 + startStage2Scan() + case 0xe1: + emit(state: "cancelled", percent: -1) + finish() + case 0x01...0x07, 0xf1...0xfb, 0xfd, 0xe0, 0x10, 0x11: + fail(code: Int(raw), message: "OTA 失败 0x\(String(raw, radix: 16))") + default: + NSLog("[SmartcarOta] 未识别状态 0x%02x,忽略", Int(raw)) + } + } + + // MARK: - stage2 扫描回连 + + private func startStage2Scan() { + phase = .stage2Scanning + let central = SmartcarBleCentral.shared + central.scanInterceptor = { [weak self] hit in + guard let self = self, self.phase == .stage2Scanning else { return false } + guard self.matchesStage2(hit) else { return false } + NSLog("[SmartcarOta] stage2 命中 %@ (%@)", hit.name, hit.id) + self.phase = .stage2Connecting + self.stage2Timer?.invalidate() + central.stopScan() + central.scanInterceptor = nil + if let d = central.connectDelegate, central.connect(id: hit.id, delegate: d) == nil { + self.fail(code: 0xfd, message: "stage2 设备无法发起连接") + } + return true + } + central.startScan(timeoutSec: Self.stage2RoundSec) { [weak self] ok, reason in + guard let self = self else { return } + if !ok { self.fail(code: 0xfd, message: "stage2 扫描起不来: \(reason ?? "")") } + } + // 一轮到点还没命中:等 3s 让 BLE 栈收尾再起下一轮,最多 3 轮 ≈ 186s + stage2Timer?.invalidate() + stage2Timer = Timer.scheduledTimer(withTimeInterval: TimeInterval(Self.stage2RoundSec) + Self.stage2InterRoundDelay, + repeats: false) { [weak self] _ in + guard let self = self, self.phase == .stage2Scanning else { return } + self.stage2Round += 1 + if self.stage2Round < Self.stage2MaxRounds { + NSLog("[SmartcarOta] stage2 第 %d 轮未找到,起下一轮", self.stage2Round) + self.startStage2Scan() + } else { + self.fail(code: 0xfd, message: "3 轮扫描(约 3 分钟)都没等到设备重启后的广播") + } + } + // 上层看门狗 90s 没进度会判失败,stage2 窗口没有进度,用心跳喂它 + heartbeat?.invalidate() + heartbeat = Timer.scheduledTimer(withTimeInterval: 15, repeats: true) { [weak self] _ in + guard let self = self, self.phase == .stage2Scanning || self.phase == .stage2Connecting else { return } + self.emit(state: "stage2", percent: 15) + } + } + + private func matchesStage2(_ hit: SmartcarScanHit) -> Bool { + if let mfg = hit.manufacturerData, let mac = Self.parseAdvMac(mfg), !targetMac.isEmpty { + if mac == targetMac || Self.reverseMac(mac) == targetMac { return true } + } + // TODO(smartcar-ota): stage2 广播名待样机确认;先按"含原设备名"兜底(lumi 是固定名 Lumi-Music) + let name = hit.name.trimmingCharacters(in: .whitespaces).lowercased() + if !name.isEmpty { + if name.contains("smartcar") { return true } + let base = originalName.lowercased().split(separator: "-").first.map(String.init) ?? "" + if base.count >= 4 && name.contains(base) { return true } + } + return false + } + + /// 厂商数据:`05 85 | MAC(6) | 状态` —— 与 Dart 侧 `parseManufacturerData` 同口径 + private static func parseAdvMac(_ data: Data) -> String? { + guard data.count >= 8 else { return nil } + let b = [UInt8](data) + let cid = UInt16(b[0]) | (UInt16(b[1]) << 8) + guard cid == 0x8505 || cid == 0x0585 else { return nil } + return b[2..<8].map { String(format: "%02X", $0) }.joined(separator: ":") + } + + private static func reverseMac(_ mac: String) -> String { + let parts = mac.split(separator: ":") + guard parts.count == 6 else { return mac } + return parts.reversed().joined(separator: ":") + } + + // MARK: - 收尾 + + private func fail(code: Int, message: String) { + guard phase != .idle else { return } + NSLog("[SmartcarOta] failed: %@", message) + emit(state: "failed", percent: -1, code: code, message: message) + finish() + } + + private func finish() { + phase = .idle + firmware = nil + targetMac = "" + stage2Round = 0 + stage2Timer?.invalidate() + stage2Timer = nil + heartbeat?.invalidate() + heartbeat = nil + SmartcarBleCentral.shared.scanInterceptor = nil + peripheral?.otaInProgress = false + peripheral = nil + } + + private var phaseCode: Int { + switch phase { + case .idle: return 0 + case .running: return 1 + case .stage2Scanning: return 2 + case .stage2Connecting: return 3 + } + } + + private func emit(state: String, percent: Int, code: Int? = nil, message: String? = nil) { + var m: [String: Any] = ["type": "ota", "state": state, "percent": percent] + if let c = code { m["code"] = c } + if let s = message { m["message"] = s } + if Thread.isMainThread { + onEvent?(m) + } else { + DispatchQueue.main.async { [weak self] in self?.onEvent?(m) } + } + } +} diff --git a/apps/client/local_plugins/device_smartcar/ios/device_smartcar/Sources/device_smartcar/SmartcarPeripheral.swift b/apps/client/local_plugins/device_smartcar/ios/device_smartcar/Sources/device_smartcar/SmartcarPeripheral.swift new file mode 100644 index 00000000..d401efc1 --- /dev/null +++ b/apps/client/local_plugins/device_smartcar/ios/device_smartcar/Sources/device_smartcar/SmartcarPeripheral.swift @@ -0,0 +1,338 @@ +import Foundation +import CoreBluetooth +import JL_BLEKit + +/// 链路状态。 +/// +/// ⚠️ `ready` 的含义是**杰理 RCSP 认证已通过**,不是 GATT 连上。 +/// Smartcar 固件(杰理芯片)在认证通过前不把手机当成已连接:屏幕显示 BLE 未连接、 +/// 也不响应 RCSP 命令。这是 lumi 两端代码里都写明的固件行为,不是我们能绕过的。 +enum SmartcarLinkState: String { + case disconnected + case connecting + /// GATT 已连上,正在发现服务 + case linkConnected + /// 已找到 AE00,杰理 SDK 正在跑认证握手 + case authenticating + /// 认证通过,业务可用 + case ready + case failed +} + +protocol SmartcarPeripheralDelegate: AnyObject { + func smartcarPeripheral(_ p: SmartcarPeripheral, didChange state: SmartcarLinkState, reason: String?) + func smartcarPeripheral(_ p: SmartcarPeripheral, didReceiveCustom data: Data) + /// AE02 的原始字节。**音频和 RCSP 命令共用这条通知**,所以两边都要看一眼: + /// SDK 那份照旧交给 JL_Assist,这份给 [SmartcarAudio] 挑 opus 帧。 + func smartcarPeripheral(_ p: SmartcarPeripheral, didReceiveJl data: Data) +} + +/// 一台 Smartcar 的 GATT 会话:自己持有连接,在同一条连接上复用两个服务。 +/// +/// 从 lumi 的 `BlePeripheral.swift`(1058 行)精简而来,保留的是与固件行为相关的部分: +/// - 发现 `AE00`(杰理)与 `ABC0`(C2 私有)两个服务; +/// - `AE00` 交给 `JL_Assist`:由它开 `AE02` 通知、跑认证;`AE02` 的数据全部转交 SDK; +/// - `ABC2` 的通知数据原样交给上层(组帧/CRC 在 Dart 侧); +/// - 连接 35s 超时、失败指数退避重试两次、断开时通知 SDK。 +/// +/// 去掉的是 OTA 大包分片/背压、周期 RSSI、表盘那些——控制帧只有几个字节,用不上。 +final class SmartcarPeripheral: NSObject { + static let jlService = CBUUID(string: "AE00") + static let jlTx = CBUUID(string: "AE01") + static let jlRx = CBUUID(string: "AE02") + static let customService = CBUUID(string: "0000ABC0-0000-1111-2222-123456789ABC") + static let customTx = CBUUID(string: "0000ABC1-0000-1111-2222-123456789ABC") + static let customRx = CBUUID(string: "0000ABC2-0000-1111-2222-123456789ABC") + + private static let connectTimeout: TimeInterval = 35 + /// lumi 对认证失败的处理是「等 SDK 自己重试或超时」;这里给一个明确上限, + /// 否则 UI 会停在「连接中」不动。 + private static let authTimeout: TimeInterval = 15 + private static let retryDelays: [TimeInterval] = [0.5, 2.0] + + let cb: CBPeripheral + private weak var central: CBCentralManager? + weak var delegate: SmartcarPeripheralDelegate? + + private(set) var state: SmartcarLinkState = .disconnected + + /// OTA 进行中:私有通道(ABC1)的写入一律拒绝——业务包会和 SDK 的 cmdOTAData 抢 AE01, + /// lumi 那边卡死过 + var otaInProgress = false + + private var txJL: CBCharacteristic? + private var rxJL: CBCharacteristic? + private var txCustom: CBCharacteristic? + private var rxCustom: CBCharacteristic? + + private var connectTimer: Timer? + private var authTimer: Timer? + private var retries = 0 + private var servicesPending = 0 + private var jlFound = false + + var id: String { cb.identifier.uuidString } + var name: String { cb.name ?? "" } + var isReady: Bool { state == .ready && cb.state == .connected } + + init(peripheral: CBPeripheral, central: CBCentralManager, delegate: SmartcarPeripheralDelegate?) { + self.cb = peripheral + self.central = central + self.delegate = delegate + super.init() + cb.delegate = self + } + + // MARK: - 连接管理 + + func connect() { + guard state == .disconnected || state == .failed else { return } + retries = 0 + startConnect() + } + + private func startConnect() { + guard let central = central else { + fail("CBCentralManager 已释放") + return + } + resetChars() + // CBPeripheral.delegate 是 weak,重连时必须重新绑回来 + cb.delegate = self + set(.connecting) + central.connect(cb, options: [ + CBConnectPeripheralOptionNotifyOnConnectionKey: true, + CBConnectPeripheralOptionNotifyOnDisconnectionKey: true, + ]) + connectTimer?.invalidate() + connectTimer = Timer.scheduledTimer(withTimeInterval: Self.connectTimeout, repeats: false) { [weak self] _ in + guard let self = self, self.state == .connecting else { return } + self.central?.cancelPeripheralConnection(self.cb) + self.fail("GATT 连接超时(\(Int(Self.connectTimeout))s),设备可能已被别的手机连着——BLE 被占用时不再广播也不接受新连接") + } + } + + func disconnect() { + connectTimer?.invalidate() + authTimer?.invalidate() + guard state != .disconnected else { return } + let wasConnecting = state == .connecting + central?.cancelPeripheralConnection(cb) + // 还在 connecting 阶段取消时 iOS 不一定回调 didDisconnect,兜底收状态 + if wasConnecting { + resetChars() + set(.disconnected) + } + } + + /// 往 C2 私有写特征发一帧(组帧/CRC 由 Dart 侧完成)。 + /// + /// 没有 ABC1 时退回 AE01——与 lumi 一致,兼容旧固件把私有协议塞在 RCSP 通道里的情况。 + @discardableResult + func writeCustom(_ data: Data) -> Bool { + guard cb.state == .connected, !data.isEmpty else { return false } + if otaInProgress { return false } + guard let ch = txCustom ?? txJL else { return false } + let type: CBCharacteristicWriteType = + ch.properties.contains(.writeWithoutResponse) ? .withoutResponse : .withResponse + cb.writeValue(data, for: ch, type: type) + return true + } + + // MARK: - 由 CentralManager 代理转发 + + func handleDidConnect() { + connectTimer?.invalidate() + retries = 0 + set(.linkConnected) + // 与 lumi 相同:连上后等 0.2s 再发现服务,太早会碰到 DLE 协商未完成 + DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) { [weak self] in + guard let self = self, self.cb.state == .connected else { return } + self.servicesPending = 0 + self.jlFound = false + self.cb.discoverServices([Self.jlService, Self.customService]) + } + } + + func handleDidFailToConnect(_ error: Error?) { + connectTimer?.invalidate() + if retryIfPossible() { return } + fail("GATT 连接失败: \(error?.localizedDescription ?? "unknown")") + } + + func handleDidDisconnect(_ error: Error?) { + connectTimer?.invalidate() + authTimer?.invalidate() + // 让 SDK 清掉它自己那份状态,否则下次认证会用到陈旧的特征引用 + JLAssistBridge.shared.assist.assistDisconnectPeripheral(cb) + JLAssistBridge.shared.unbindEntity() + if state == .connecting, retryIfPossible() { return } + resetChars() + set(.disconnected, reason: error?.localizedDescription) + } + + // MARK: - 内部 + + private func retryIfPossible() -> Bool { + guard retries < Self.retryDelays.count else { return false } + let delay = Self.retryDelays[retries] + retries += 1 + state = .connecting + DispatchQueue.main.asyncAfter(deadline: .now() + delay) { [weak self] in + guard let self = self, self.state == .connecting else { return } + self.startConnect() + } + return true + } + + private func fail(_ reason: String) { + connectTimer?.invalidate() + authTimer?.invalidate() + resetChars() + set(.failed, reason: reason) + } + + private func set(_ s: SmartcarLinkState, reason: String? = nil) { + state = s + delegate?.smartcarPeripheral(self, didChange: s, reason: reason) + } + + private func resetChars() { + txJL = nil + rxJL = nil + txCustom = nil + rxCustom = nil + servicesPending = 0 + jlFound = false + } + + private func startAuthTimer() { + authTimer?.invalidate() + authTimer = Timer.scheduledTimer(withTimeInterval: Self.authTimeout, repeats: false) { [weak self] _ in + guard let self = self, self.state == .authenticating else { return } + self.fail("杰理 RCSP 认证超时(\(Int(Self.authTimeout))s):SDK 没有回调结果。" + + "多半是固件不接受这套认证密钥,或 AE02 通知没真正打开") + self.central?.cancelPeripheralConnection(self.cb) + } + } +} + +// MARK: - CBPeripheralDelegate + +extension SmartcarPeripheral: CBPeripheralDelegate { + + func peripheral(_ peripheral: CBPeripheral, didDiscoverServices error: Error?) { + if let error = error { + fail("服务发现失败: \(error.localizedDescription)") + central?.cancelPeripheralConnection(cb) + return + } + let services = peripheral.services ?? [] + let list = services.map { $0.uuid.uuidString }.joined(separator: ", ") + + // ⚠️ 没有 AE00 就没法认证,固件不会认我们。把实际服务列出来,别只说"没找到"。 + guard services.contains(where: { $0.uuid == Self.jlService }) else { + fail("BLE 已连上,但设备没有杰理服务 AE00,认证走不了。实际服务: [\(list)]") + central?.cancelPeripheralConnection(cb) + return + } + + servicesPending = services.count + for service in services { + let whitelist: [CBUUID]? + if service.uuid == Self.jlService { + whitelist = [Self.jlTx, Self.jlRx] + } else if service.uuid == Self.customService { + whitelist = [Self.customTx, Self.customRx] + } else { + whitelist = nil + } + peripheral.discoverCharacteristics(whitelist, for: service) + } + } + + func peripheral(_ peripheral: CBPeripheral, didDiscoverCharacteristicsFor service: CBService, error: Error?) { + servicesPending -= 1 + let chars = service.characteristics ?? [] + + if service.uuid == Self.jlService { + txJL = chars.first { $0.uuid == Self.jlTx } + rxJL = chars.first { $0.uuid == Self.jlRx } + jlFound = txJL != nil && rxJL != nil + if jlFound { + let assist = JLAssistBridge.shared.assist + // 告诉 SDK RCSP 走哪条服务/特征,认证数据才会发对通道 + assist.mService = Self.jlService.uuidString + assist.mRcsp_W = Self.jlTx.uuidString + assist.mRcsp_R = Self.jlRx.uuidString + assist.mAuthEnable = true + set(.authenticating) + startAuthTimer() + // ★ 只调这个,让 SDK 自己去开 AE02 的通知;**不要**手动 setNotifyValue, + // 也不要在这里绑实体——lumi 照官方示例踩过,绑早了认证不过。 + assist.assistDiscoverCharacteristics(for: service, peripheral: peripheral) + } + } else if service.uuid == Self.customService { + txCustom = chars.first { $0.uuid == Self.customTx } + rxCustom = chars.first { $0.uuid == Self.customRx } + if let rx = rxCustom { + peripheral.setNotifyValue(true, for: rx) + } + } + + if servicesPending <= 0 && !jlFound { + let names = chars.map { $0.uuid.uuidString }.joined(separator: ", ") + fail("杰理服务 AE00 里缺少 AE01/AE02 特征。实际特征: [\(names)]") + central?.cancelPeripheralConnection(cb) + } + } + + func peripheral(_ peripheral: CBPeripheral, didUpdateNotificationStateFor characteristic: CBCharacteristic, error: Error?) { + guard characteristic.uuid == Self.jlRx else { return } + if let error = error { + fail("AE02 通知打不开: \(error.localizedDescription)") + central?.cancelPeripheralConnection(cb) + return + } + // AE02 通知一开,SDK 就开始认证握手;结果从这个回调回来 + JLAssistBridge.shared.assist.assistUpdate(characteristic, peripheral: peripheral) { [weak self] ok in + guard let self = self else { return } + self.authTimer?.invalidate() + if ok { + JLAssistBridge.shared.bindEntity(peripheral: peripheral, name: self.name) + if self.state != .ready && self.cb.state == .connected { + self.set(.ready) + } + } else { + self.fail("杰理 RCSP 认证失败:固件拒绝了这次握手") + self.central?.cancelPeripheralConnection(self.cb) + } + } + } + + func peripheral(_ peripheral: CBPeripheral, didUpdateValueFor characteristic: CBCharacteristic, error: Error?) { + guard error == nil, let data = characteristic.value else { return } + switch characteristic.uuid { + case Self.customRx: + delegate?.smartcarPeripheral(self, didReceiveCustom: data) + case Self.jlRx: + // 认证帧 / RCSP 帧全部交给 SDK,我们不解析 + JLAssistBridge.shared.assist.assistUpdateValue(for: characteristic) + // 音频帧也走这条通知,交给上层挑(SmartcarAudio 只认那个 7 字节头) + delegate?.smartcarPeripheral(self, didReceiveJl: data) + default: + break + } + } + + func peripheral(_ peripheral: CBPeripheral, didWriteValueFor characteristic: CBCharacteristic, error: Error?) { + // 1.13 新增:SDK 内部的写队列靠这个推进(旧版 lumi 用的 1.12 没有它) + if characteristic.uuid == Self.jlTx { + JLAssistBridge.shared.assist.assistDidReady() + } + } + + func peripheralIsReady(toSendWriteWithoutResponse peripheral: CBPeripheral) { + JLAssistBridge.shared.assist.assistDidReady() + } +} diff --git a/apps/client/local_plugins/device_smartcar/lib/device_smartcar.dart b/apps/client/local_plugins/device_smartcar/lib/device_smartcar.dart new file mode 100644 index 00000000..237b7c0c --- /dev/null +++ b/apps/client/local_plugins/device_smartcar/lib/device_smartcar.dart @@ -0,0 +1,82 @@ +import 'dart:async'; + +import 'package:flutter/services.dart'; + +/// Smartcar(车载香薰)原生链路的 Dart 通道封装。 +/// +/// 只做三件事:扫描、连接(**含杰理 RCSP 认证**)、收发 C2 私有通道的裸字节。 +/// 组帧 / CRC / 广播解析都在 App 侧的 `smartcar_protocol.dart`,这里不解释任何字节。 +/// +/// `connect()` 只在**认证通过**后才返回——固件在那之前不把手机当成已连接。 +class DeviceSmartcar { + DeviceSmartcar._(); + static final DeviceSmartcar instance = DeviceSmartcar._(); + + static const MethodChannel _method = MethodChannel('com.yimai.device_smartcar'); + static const EventChannel _events = EventChannel('com.yimai.device_smartcar/events'); + + Stream>? _stream; + + /// 原生事件(广播流)。每条形如 `{type: scan|scanStopped|state|custom|adapter, ...}`。 + Stream> get events { + return _stream ??= _events + .receiveBroadcastStream() + .map((e) => Map.from(e as Map)) + .asBroadcastStream(); + } + + Future adapterState() async => + (await _method.invokeMethod('adapterState')) ?? 'unknown'; + + /// 起不来抛 [PlatformException](code `SCAN_FAILED`,message 是原因)。 + Future startScan({int timeoutSec = 15}) => + _method.invokeMethod('startScan', {'timeoutSec': timeoutSec}); + + Future stopScan() => _method.invokeMethod('stopScan'); + + /// 认证通过才 resolve;失败抛 [PlatformException](code `CONNECT_FAILED` / `NOT_FOUND` / `BUSY`)。 + Future connect(String id, {int timeoutSec = 45}) => + _method.invokeMethod('connect', {'id': id, 'timeoutSec': timeoutSec}); + + Future disconnect() => _method.invokeMethod('disconnect'); + + Future isConnected() async => + (await _method.invokeMethod('isConnected')) ?? false; + + /// 认证通过后的杰理 RCSP 收尾序列(设时间 / GET_TARGET_INFO / GET_SYS_INFO), + /// 返回各步的 JL_CMDStatus(0 = 成功)。未认证抛 `NOT_READY`。 + Future> jlPostAuth() async { + final r = await _method.invokeMethod('jlPostAuth'); + return Map.from(r ?? const {}); + } + + /// 开始把设备麦克风的 opus 上行解码成 16k PCM 往上抛(`{type:'audio'}` 事件)。 + /// + /// ⚠️ **不控制设备开麦**:设备是收到 `0xCC 0x11` 唤醒后自己开麦的, + /// 这里只决定原生要不要解码转发。返回 false = opus 解码器起不来。 + Future audioStart() async => + (await _method.invokeMethod('audioStart')) ?? false; + + Future audioStop() => _method.invokeMethod('audioStop'); + + /// 排查用:`{running, framesIn, framesDup, pcmBytesOut}`。 + /// 真机"没声音"时先看这几个数,能立刻区分是没收到帧、全被当重复丢了、还是解码没出字节。 + Future> audioStats() async { + final r = await _method.invokeMethod('audioStats'); + return Map.from(r ?? const {}); + } + + /// 启动杰理 OTA(原生含 stage2 扫描回连)。进度从 [events] 的 `{type:'ota'}` 来。 + /// 起不来抛 [PlatformException](`OTA_FAILED` / `NOT_READY`)。 + Future otaStart(String filePath) => + _method.invokeMethod('otaStart', {'filePath': filePath}); + + Future otaCancel() => _method.invokeMethod('otaCancel'); + + Future otaIsRunning() async => + (await _method.invokeMethod('otaIsRunning')) ?? false; + + /// 往 ABC1 写一帧(已组好、含 CRC 的裸字节) + Future writeCustom(Uint8List data) async => + (await _method.invokeMethod('writeCustom', {'data': data})) ?? false; +} diff --git a/apps/client/local_plugins/device_smartcar/pubspec.yaml b/apps/client/local_plugins/device_smartcar/pubspec.yaml new file mode 100644 index 00000000..b986c345 --- /dev/null +++ b/apps/client/local_plugins/device_smartcar/pubspec.yaml @@ -0,0 +1,31 @@ +name: device_smartcar +description: >- + Smartcar(车载香薰,杰理方案,协议代号 C2)的 BLE 链路:自己持有 GATT 连接, + 同一条连接上跑杰理 RCSP 认证(AE00)与 C2 私有通道(ABC0)。 + 组帧/CRC/广播解析在 Dart 侧(lib/devices/smartcar/smartcar_protocol.dart),本插件只收发裸字节。 +version: 0.0.1 +publish_to: none + +environment: + sdk: ">=3.3.0 <4.0.0" + flutter: ">=3.3.0" + +dependencies: + flutter: + sdk: flutter + +dev_dependencies: + flutter_test: + sdk: flutter + flutter_lints: ^2.0.0 + +flutter: + plugin: + platforms: + android: + package: com.yimai.device_smartcar + pluginClass: DeviceSmartcarPlugin + ios: + pluginClass: DeviceSmartcarPlugin + swiftPackage: + path: ios/device_smartcar diff --git a/apps/client/local_plugins/recorder_holder/eai_recorde_protocol.md b/apps/client/local_plugins/recorder_holder/EaiRec_protocol.md similarity index 85% rename from apps/client/local_plugins/recorder_holder/eai_recorde_protocol.md rename to apps/client/local_plugins/recorder_holder/EaiRec_protocol.md index 76ecdf50..ee5a5bef 100644 --- a/apps/client/local_plugins/recorder_holder/eai_recorde_protocol.md +++ b/apps/client/local_plugins/recorder_holder/EaiRec_protocol.md @@ -1,7 +1,7 @@ # EAI 录音支架 · BLE 协议规范 -版本:**v0.1(草案,待固件确认)** -日期:2026-09-08 +版本:**v0.2(协议待固件确认;App 侧已按本文落地)** +日期:2026-09-09(v0.1 于 2026-09-08) 适用设备:**带 TF 卡存储的 EAI 录音支架(唯一形态)** --- @@ -62,7 +62,7 @@ | 写特征写类型 | `WRITE_TYPE_NO_RESPONSE`(通道 A 控制) | | MTU 请求 | 连接后 `requestMtu(512)`,取双方 min,结果由 `onMtuChanged` 回传 | | **MTU 请求时机** | **必须在 CCCD 写完后延时 1s 再发**。vivo 等魔改 ROM 在 CCCD `writeDescriptor` 还在队列里时紧跟着发 `requestMtu` 会被协议栈**静默吞掉**(`accepted=true` 但 `ATT_EXCHANGE_MTU_REQ` 根本没上空中),空中抓不到协商 → 数据模式起不来 → 录音失败。同时 MCU 在 connection complete 后也需要几百 ms 进入待命态。 | -| 通知使能顺序 | **先使能音频特征通知,再使能控制特征通知**,顺序颠倒会收不到音频 | +| 通知使能顺序 | **先音频 `ae02` → 再控制 `abc2` → 再文件 `FDA6`**,顺序颠倒会收不到音频。**且必须串行**:Android GATT 同一时刻只允许一个未完成操作,连着发多个 `writeDescriptor` 除第一个外会被静默丢弃(返回 true 但 `onDescriptorWrite` 永不回调)。v0.2 已加 CCCD 队列,逐条发、等回调再发下一条 | | 自动重连 | 最多 10 次,退避 `2s/3s/5s/5s/10s`;主动断开不重连 | | 后台唤醒 | Android 走 CompanionDeviceManager + 前台服务;iOS 走 CoreBluetooth 后台中央模式 | @@ -248,11 +248,11 @@ App 侧当前把 `[2]` 当作整机电量展示。 **下行(App → 设备)**:`0000abc1-0001-…` 的 Write(带响应),**120 字节/块,60 ms 间隔**, 失败重试 1 次、间隔 30 ms。 -> ⚠️ **现状提醒:Android 侧下行音频是死的。** `BleService.kt` 的 `callWriteChar` 声明了但 -> **从未赋值**——`onServicesDiscovered` 只查找了 `abc0` 和 `ae00` 两个 service,没有查找 -> `abc0-0001`。所以 `sendAudioChunk()` 永远走 `callWriteChar == null` 分支直接 return, -> Android 上从来没有真正下行播放过音频;只有 iOS 实现完整。 -> 如果新支架要用下行音频,**Android 侧这条链路需要重新接通**,不是「沿用」而是「新做」。 +> ✅ **已修(v0.2)**:`BleService.kt` 的 `callWriteChar` 原先声明了却**从未赋值**—— +> `onServicesDiscovered` 只查找了 `abc0` 和 `ae00`,没查 `abc0-0001`,于是 +> `sendAudioChunk()` 永远走 `callWriteChar == null` 分支直接 return, +> **Android 上从来没有真正下行播放过音频**(只有 iOS 完整)。现已补上服务查找。 +> 真机上这条链路等于第一次通电,验收时要单独测。 ### 4.7 MTU @@ -634,20 +634,52 @@ u8 second; // 0~59 ## 10. App 侧落地影响清单 -给客户端开发看的「哪些代码要动」: +### 已落地(v0.2,2026-09-09) -| 模块 | 改动 | +**架构决定:通道 B 的协议逻辑全部在 Dart,原生只做搬运。** +原生侧只负责「发现 `0xFDA5`、使能 `0xFDA6` 通知、把原始字节转上来 / 把字节写进 `0xFDA7`」, +帧格式、seq、超时、状态机一律在 `lib/src/rec/`。这样状态机只有一份,Android / iOS +不会各自跑偏,也能脱离真机做单测。 + +> ⚠️ **通道 B 不能像 OTA 那样用 flutter_blue_plus 自己连一条。** 一部手机对同一个 +> peripheral 只能有一条 GATT 连接,通道 A 已经被原生占着了;OTA 能那么做,是因为它 +> 先 `setEnterOta(true)` 把原生连接让了出来,而通道 B 必须与通道 A 同时在线。 + +| 模块 | 状态 | +|---|---| +| `lib/src/rec/rec_protocol.dart` | ✅ 命令号 / 状态码 / 确认魔数 / 文件名校验 | +| `lib/src/rec/rec_frame.dart` | ✅ 包头编解码 + DATA 校验和(覆盖整包、sum 字段按 0) | +| `lib/src/rec/rec_models.dart` | ✅ `REC_INFO` / `FILE_LIST` / `FILE_INFO` / `SETUP_RSP` / `DATA` 解析 | +| `lib/src/rec/rec_client.dart` | ✅ 事务层:seq、3s 超时、FILE_LIST 多包累积、FILE_TRANSFER 会话(offset 跟踪 / 缺口重传 / 5s 空闲超时 / 断点续传 / 取消)、**通道内互斥闸** | +| `lib/src/rec/rec_transport.dart` | ✅ transport 抽象 + `FileRecSink`(落盘、天然续传)+ `MemoryRecFileSink`(单测) | +| `test/rec/` | ✅ 45 个用例:帧编解码、越界包、校验和、去重/重叠/缺口/续传/互斥/魔数 | +| `BleConst.kt` / `BleConst.swift` | ✅ `0xFDA5/6/7`;Android 补 `abc0-0001` | +| `BleService.kt` | ✅ 发现 `FDA5`、CCCD 串行队列、`FDA6` 分派、`writeRecCommand`、修 `callWriteChar` | +| `BleService.swift` | ✅ 同上(CoreBluetooth 自带串行,无需队列) | +| `RecorderHolderPlugin.{kt,swift}` | ✅ `writeRecCommand` / `isRecChannelReady` + `recData` 事件 | +| `EchoMeetDeviceSdk` | ✅ `recorder`(`RecClient`)、`ensureRecChannel()`、`recChannelReady` | +| `HolderDeviceService` | ✅ 连上即 `RTC_SYNC` → `REC_INFO`;`listRecordings` / `downloadRecording` / `deleteRecording`;`recInfo` / `recFiles` 可观测 | +| `device_category_view` | ✅ `phoneHolder` 不再是「链路未接」,路由到 `Routes.holderScan` | +| `AgentModules.defaultsByCategory` | ✅ 支架默认三件套改为 **语音纪要 / 现场录音 / EMAI助手** | +| `HolderDeviceService.bindToServer` | ✅ **未改**:仍是 MAC 上报、`code` 留空,服务端校验逻辑一行未动 | + +### 尚未做 + +| 项 | 说明 | |---|---| -| `BleConst.kt` / `BleConst.swift` | 新增 `0xFDA5/6/7` 三个 UUID | -| `BleService.onServicesDiscovered` | 新增查找 `FDA5` 服务并使能 `FDA6` 通知;**顺带补上一直缺失的 `abc0-0001` 查找**(Android 下行音频修复,见 §4.6) | -| `BleService.onCharacteristicChanged` | 已是 `when(c.uuid)` 分派,加一个 `FDA6` 分支即可;**新写一个通道 B 解析器,不要复用 `processDeviceResponse`**(len 位宽与校验方式都不同) | -| 新增 | 通道 B 的事务层:seq 管理、3s 超时、FILE_LIST 多包累积状态机、FILE_TRANSFER 会话状态机(offset 跟踪 / 5s 无数据超时 / 续传 / 取消) | -| 新增 | 通道 A / 通道 B 全局互斥闸(§7.1) | -| `HolderRecordingService` | 录音状态机从「App 主导」改为「设备主导 + App 观察」,见 §7.3 | -| 新增 UI | TF 卡文件列表页(大小/时间/下载进度/删除)、容量展示、格式化入口(二次确认) | -| `RecordingArchive` | 接入「从 TF 下载的 `.OGG`」这条来源;时长字段待 Q11 | -| `HolderDeviceService.bindToServer` | **不变**(MAC 上报,code 留空) | -| iOS | 通道 B 全部逻辑要在 `BleService.swift` 独立再实现一遍;确认后台 `bluetooth-central` 模式下长时间下载不被挂起 | +| TF 卡文件 UI | 列表页 / 下载进度 / 删除 / 格式化入口全部没有。协议层与 service 入口已就绪,接 UI 时不用再碰蓝牙 | +| `RecordingArchive` 接入 | 下载下来的 `.OGG` 还没走归档 / 上传。**卡在 Q11(没有时长字段)**——`archive()` 要求传 `seconds` | +| 录音状态机归属 | §7.3 说的「App 主导 → 设备主导」还没改,`HolderRecordingService` 仍是原样。**卡在 Q15**(`CC 21/22/23` 与 `REC_INFO` 是不是同一件事没定) | +| 跨通道互斥 | 目前互斥只做在通道 B 内部(传输期间拒绝其它 B 命令)。传输期间**不阻止**通道 A 下发命令,也没给电量上报降频 —— 见 §7.1,等 Q4 给出上报频率再定策略 | +| OTA | 见 §9,待 Q16 | + +### ⚠️ 真机第一件要确认的事 + +**扫描页一台设备都不出现 = 广播名对不上(Q1)**,不是蓝牙权限问题,且不报任何错。 +过滤名字在 `sdk/lib/src/client.dart` 的 `defaultNameFilters`,当前是 +`['EaiRec', 'EAIMAR', 'Pad Note']`,**按前缀、大小写不敏感**匹配 +(改成前缀是因为固件常在名字后挂序列号,如 `EaiRec-1A2B`,全等匹配会全滤掉)。 +拿到真机第一步就是把实际广播名填进去。 **落地顺序建议**(与草案一致): `RTC_SYNC` + `REC_INFO` + `FILE_LIST` + `FILE_TRANSFER` 先跑通只读链路 → 再接 `FILE_INFO` → @@ -655,6 +687,7 @@ u8 second; // 0~59 --- + ## 11. 待固件确认清单 请逐条回复,有异议的改动记进 §12。 @@ -685,3 +718,4 @@ u8 second; // 0~59 | 日期 | 版本 | 变更 | |---|---|---| | 2026-09-08 | v0.1 | 首版。合并现有支架代码实现(通道 A / A' / A'')与 TF 存储协议草案(通道 B),补充两通道互斥、录音状态机归属、App 落地清单与 16 条待确认项。 | +| 2026-09-09 | v0.2 | 落地通道 B:Dart 协议层 + 双端原生原始通道 + SDK/业务层接入(见 §10)。顺带修两处既有缺陷:Android 下行音频特征从未查找(§4.6)、CCCD 通知使能未串行(§2.2)。**协议本身一字未改,16 条待确认项仍全部有效。** | diff --git a/apps/client/local_plugins/recorder_holder/android/src/main/kotlin/com/recorder/holder/BleConst.kt b/apps/client/local_plugins/recorder_holder/android/src/main/kotlin/com/recorder/holder/BleConst.kt index d1356f47..308e43e9 100644 --- a/apps/client/local_plugins/recorder_holder/android/src/main/kotlin/com/recorder/holder/BleConst.kt +++ b/apps/client/local_plugins/recorder_holder/android/src/main/kotlin/com/recorder/holder/BleConst.kt @@ -25,6 +25,30 @@ object BleConst { /** 客户端特征配置描述符UUID */ val CLIENT_CHAR_CONFIG_UUID: UUID = UUID.fromString("00002902-0000-1000-8000-00805f9b34fb") + // ===== 通话/下行音频服务(EaiRec_protocol.md §4.6 通道 A'')===== + // ⚠️ 这两个 UUID 以前只在 iOS 的 BleConst.swift 里有,Android 从来没查找过 + // 对应的 service —— 于是 callWriteChar 恒为 null,sendAudioChunk() 每次都走 + // 「特征值为空」分支直接 return,**Android 上的下行音频从来没通过**。 + /** 下行音频服务 */ + val CALL_AUDIO_SERVICE_UUID: UUID = UUID.fromString("0000abc0-0001-1111-2222-123456789abc") + + /** 下行音频写入特征 */ + val CALL_WRITE_AUDIO_CHAR_UUID: UUID = + UUID.fromString("0000abc1-0001-1111-2222-123456789abc") + + // ===== 通道 B:TF 卡文件(EaiRec_protocol.md §5.1)===== + // 16-bit UUID,展开成完整形式。与通道 A 的 abc0/abc1/abc2 是两套独立的 + // service,帧格式也完全不同(这边是 0xA5 magic + u16 长度,没有帧尾 CRC), + // **解析入口必须按 characteristic 分派,不能与通道 A 共用解析函数**。 + /** 文件服务 0xFDA5 */ + val REC_SERVICE_UUID: UUID = UUID.fromString("0000fda5-0000-1000-8000-00805f9b34fb") + + /** CMD_TX:设备 → APP,Notify */ + val REC_NOTIFY_CHAR_UUID: UUID = UUID.fromString("0000fda6-0000-1000-8000-00805f9b34fb") + + /** CMD_RX:APP → 设备,Write */ + val REC_WRITE_CHAR_UUID: UUID = UUID.fromString("0000fda7-0000-1000-8000-00805f9b34fb") + // ===== 标准 BLE Device Information Service(0x180A)===== // 新固件(2.x+)才提供此服务;用来做新/旧固件代差识别: // 存在此服务 → 新固件(支持 32kbps Opus / 80B 每帧) diff --git a/apps/client/local_plugins/recorder_holder/android/src/main/kotlin/com/recorder/holder/BleRecord.kt b/apps/client/local_plugins/recorder_holder/android/src/main/kotlin/com/recorder/holder/BleRecord.kt index f2a24ede..e575efd8 100644 --- a/apps/client/local_plugins/recorder_holder/android/src/main/kotlin/com/recorder/holder/BleRecord.kt +++ b/apps/client/local_plugins/recorder_holder/android/src/main/kotlin/com/recorder/holder/BleRecord.kt @@ -577,6 +577,10 @@ class BleRecord private constructor(private val context: Context) : BleService.C override fun onAudioDataReceived1(data: ByteArray) { } + /// 通道 B 的数据与录音流程无关(那是 TF 卡文件管理),这里不处理。 + override fun onRecDataReceived(data: ByteArray) { + } + override fun onWakeupSignalReceived() { } diff --git a/apps/client/local_plugins/recorder_holder/android/src/main/kotlin/com/recorder/holder/BleService.kt b/apps/client/local_plugins/recorder_holder/android/src/main/kotlin/com/recorder/holder/BleService.kt index 39ae5bde..4b3a8036 100644 --- a/apps/client/local_plugins/recorder_holder/android/src/main/kotlin/com/recorder/holder/BleService.kt +++ b/apps/client/local_plugins/recorder_holder/android/src/main/kotlin/com/recorder/holder/BleService.kt @@ -93,6 +93,11 @@ object BleService { // 唤醒信号相关回调 fun onWakeupSignalReceived() + /// 通道 B(TF 卡文件)的原始 BLE value。 + /// 原生**不解析**,原样上抛给 Dart —— 协议状态机只在 Dart 有一份, + /// 见 lib/src/rec/。这样 Android / iOS 不会各自跑偏,也能脱机单测。 + fun onRecDataReceived(data: ByteArray) + // 设备信息相关回调 - 统一回调接口 fun onDeviceInfoReceived(infoType: Int, infoData: Map) } @@ -116,6 +121,19 @@ object BleService { private var writeChar: BluetoothGattCharacteristic? = null private var audioChar: BluetoothGattCharacteristic? = null private var callWriteChar: BluetoothGattCharacteristic? = null + + // ===== 通道 B:TF 卡文件 ===== + private var recNotifyChar: BluetoothGattCharacteristic? = null + private var recWriteChar: BluetoothGattCharacteristic? = null + + /// CCCD 写入队列。 + /// + /// ⚠️ Android GATT **同一时刻只允许一个未完成的操作**:连着发多个 + /// writeDescriptor,除第一个外都会被静默丢弃(writeDescriptor 返回 true, + /// 但 onDescriptorWrite 永远不来)。原先只有音频 + 控制两条通知,第二条 + /// 时灵时不灵;现在加上通道 B 是三条,必须串起来一条一条发。 + private val cccdQueue: Queue = LinkedList() + private var cccdInFlight = false var recordfile: RecordingFile? = null var recordfile1: RecordingFile? = null @@ -758,6 +776,10 @@ object BleService { writeChar = null audioChar = null callWriteChar = null + recNotifyChar = null + recWriteChar = null + cccdQueue.clear() + cccdInFlight = false } } @@ -1002,6 +1024,15 @@ object BleService { return } + // 真机排障用的服务清单。**别删**:`发送命令失败: 设备未连接` 的根因 + // 十有八九是 abc0 私有服务压根没暴露(writeChar==null → checkConn() 恒 false), + // 而那条错误信息指向的是"连接",会把人带到完全错误的方向上去。 + Log.i(TAG, "🔍 服务发现完成,共 ${g.services.size} 个服务") + g.services.forEach { svc -> + Log.i(TAG, " └─ service ${svc.uuid} : " + + svc.characteristics.joinToString { it.uuid.toString() }) + } + // 识别固件代差:看 0x180A (Device Information Service) 是否存在 // - 存在 → 新固件,启用 V2 规格(32kbps/80B),并异步读 0x2A26 拿版本号记录 // - 不存在 → 旧固件,保持 LEGACY 规格(16kbps/40B) @@ -1032,19 +1063,53 @@ object BleService { val audioSvc = g.getService(BleConst.AUDIO_SERVICE_UUID) audioChar = audioSvc?.getCharacteristic(BleConst.RECEIVE_AUDIO_CHAR_UUID) + // 下行音频(通道 A'')。以前这里根本没查找,callWriteChar 恒为 null, + // Android 上的下行播放从来没通过 —— 见 BleConst 里的说明。 + val callSvc = g.getService(BleConst.CALL_AUDIO_SERVICE_UUID) + callWriteChar = callSvc?.getCharacteristic(BleConst.CALL_WRITE_AUDIO_CHAR_UUID) + if (callWriteChar != null) { + Log.i(TAG, "下行音频特征已找到") + } else { + Log.w(TAG, "未找到下行音频特征(本设备可能不支持下行播放)") + } + + // 通道 B:TF 卡文件服务。设备没有 TF 卡功能时找不到是正常的, + // 不影响通道 A,所以只记日志不改连接状态。 + val recSvc = g.getService(BleConst.REC_SERVICE_UUID) + recNotifyChar = recSvc?.getCharacteristic(BleConst.REC_NOTIFY_CHAR_UUID) + recWriteChar = recSvc?.getCharacteristic(BleConst.REC_WRITE_CHAR_UUID) + if (recSvc != null) { + Log.i(TAG, "📁 发现通道 B(TF 卡文件)服务 0xFDA5") + } else { + Log.i(TAG, "未发现 0xFDA5 服务 —— 本设备没有 TF 卡文件能力") + } + startBytesStatistics() writeChar?.writeType = BluetoothGattCharacteristic.WRITE_TYPE_NO_RESPONSE - //要先设置音频服务的通知,否则接收不到 - // 设置音频服务的通知(如果存在) + // 写类型:协议要 Write 或 Write No Response 都行;设备支持无响应就用它, + // 吞吐更高。只支持有响应写时保持默认,别硬设——设成不支持的类型会写失败。 + recWriteChar?.let { c -> + if (c.properties and BluetoothGattCharacteristic.PROPERTY_WRITE_NO_RESPONSE != 0) { + c.writeType = BluetoothGattCharacteristic.WRITE_TYPE_NO_RESPONSE + } + } + + // 通知使能一律排队串行发(见 cccdQueue 的说明)。 + // 顺序仍是「先音频、再控制、最后文件」—— 音频那条历史上就必须排第一, + // 顺序颠倒会收不到音频流。 + cccdQueue.clear() + cccdInFlight = false if (audioChar != null) { - setupNotifications(g, audioChar) - Log.i(TAG, "音频服务特征找到并设置通知") + enqueueNotification(audioChar) + Log.i(TAG, "音频服务特征找到并排队设置通知") } else { Log.w(TAG, "音频服务特征未找到") } - - // 设置主服务的通知 - setupNotifications(g, notifyChar) + enqueueNotification(notifyChar) + if (recNotifyChar != null) { + enqueueNotification(recNotifyChar) + } + drainCccdQueue(g) // 主动请求 MTU 扩展。BLE 默认 23(payload 20B),对音频流几乎没用; // 协商到 247+ 能让 notify 吞吐提升一个数量级。512 是与固件约定的目标值, @@ -1129,6 +1194,12 @@ object BleService { Log.d(TAG, "收到音频数据: ${data.size} 字节") processAudioData(data) } + // 通道 B:TF 卡文件。原样上抛,**不要**走通道 A 的解析函数: + // 两边帧格式不同(这边 magic 0xA5 + u16 长度、无帧尾 CRC), + // 且命令号 0x01~0x07 数值重叠而语义完全不同。 + BleConst.REC_NOTIFY_CHAR_UUID -> { + notifyRecDataReceived(data) + } // 通知特征数据(命令和控制) BleConst.NOTIFY_CHAR_UUID -> { // Log.i(TAG, "收到通知特征数据(命令和控制)") @@ -1177,26 +1248,91 @@ object BleService { override fun onDescriptorWrite( g: BluetoothGatt, d: BluetoothGattDescriptor, status: Int ) { - if (status == BluetoothGatt.GATT_SUCCESS) + if (status == BluetoothGatt.GATT_SUCCESS) { Log.i(TAG, "通知描述符写入成功: ${d.characteristic.uuid}") + } else { + Log.w(TAG, "通知描述符写入失败(status=$status): ${d.characteristic.uuid}") + } + // 无论成功与否都要接着发下一条,否则一次失败会把后面的通知全卡死 + cccdInFlight = false + drainCccdQueue(g) } } /** * 设置特征的通知功能 */ - private fun setupNotifications(g: BluetoothGatt, characteristic: BluetoothGattCharacteristic?) { - if (characteristic == null || characteristic.properties.and(BluetoothGattCharacteristic.PROPERTY_NOTIFY) == 0) { + private fun enqueueNotification(characteristic: BluetoothGattCharacteristic?) { + if (characteristic == null || + characteristic.properties.and(BluetoothGattCharacteristic.PROPERTY_NOTIFY) == 0 + ) { Log.w(TAG, "特征不支持通知功能: ${characteristic?.uuid}") return } + cccdQueue.offer(characteristic) + } + + /** + * 逐条发出排队中的 CCCD 写入。 + * + * 一次只发一条,等 onDescriptorWrite 回来再发下一条 —— Android GATT 不 + * 允许并发操作,连着发只有第一条真正上空中。 + */ + @SuppressLint("MissingPermission") + private fun drainCccdQueue(g: BluetoothGatt) { + if (cccdInFlight) return + val c = cccdQueue.poll() ?: return + val desc = c.getDescriptor(BleConst.CLIENT_CHAR_CONFIG_UUID) + if (desc == null) { + Log.w(TAG, "特征缺少 CCCD 描述符,跳过: ${c.uuid}") + drainCccdQueue(g) + return + } + cccdInFlight = true + g.setCharacteristicNotification(c, true) + desc.value = BluetoothGattDescriptor.ENABLE_NOTIFICATION_VALUE + val ok = g.writeDescriptor(desc) + Log.d(TAG, "写 CCCD: ${c.uuid} accepted=$ok,队列剩 ${cccdQueue.size}") + if (!ok) { + // 立刻失败时不会有回调,得自己往下走,否则整条队列停摆 + cccdInFlight = false + drainCccdQueue(g) + } + } - g.setCharacteristicNotification(characteristic, true) - val desc = characteristic.getDescriptor(BleConst.CLIENT_CHAR_CONFIG_UUID) - desc?.value = BluetoothGattDescriptor.ENABLE_NOTIFICATION_VALUE - g.writeDescriptor(desc) + /** + * 通道 B:把一帧原始字节写到 0xFDA7。 + * + * 原生不认识帧格式,也不该认识 —— 组帧、seq、超时、状态机全在 Dart。 + */ + @SuppressLint("MissingPermission") + fun writeRecCommand(data: ByteArray): Boolean { + val c = recWriteChar + val g = bluetoothGatt + // ⚠️ **不能用 checkConn()**:它要求 `writeChar != null`,而那是**通道 A** + // (`abc1`)的写特征。EaiRec 支架的固件只暴露 0xFDA5 一个服务,根本没有 + // 通道 A,于是 checkConn() 恒为 false —— 通道 B 明明齐全却被锁死, + // 表现是「0xFDA5 发现了、握手却写不出去」,TF 卡录音一个都同步不下来。 + // 通道 B 的可用性只取决于它自己那两个特征,用 isRecChannelReady()。 + if (c == null || g == null || !isRecChannelReady()) { + Log.e(TAG, "通道 B 写入失败:未连接或未发现 0xFDA7 特征") + return false + } + return try { + c.value = data + val ok = g.writeCharacteristic(c) + if (!ok) Log.e(TAG, "通道 B writeCharacteristic 返回 false(${data.size} 字节)") + ok + } catch (e: Exception) { + Log.e(TAG, "通道 B 写入异常: ${e.message}", e) + false + } } + /** 通道 B 是否可用(已连接且发现了 0xFDA5 服务) */ + fun isRecChannelReady(): Boolean = + isConnected() && recWriteChar != null && recNotifyChar != null + /** * 处理音频数据 * @param data 音频数据 (Opus) @@ -2532,6 +2668,16 @@ object BleService { /** * 向所有回调监听器分发唤醒信号 */ + private fun notifyRecDataReceived(data: ByteArray) { + for (callback in callbacks) { + try { + callback.onRecDataReceived(data) + } catch (e: Exception) { + Log.e(TAG, "分发通道 B 数据异常: ${e.message}", e) + } + } + } + private fun notifyWakeupSignalReceived() { for (callback in callbacks) { try { diff --git a/apps/client/local_plugins/recorder_holder/android/src/main/kotlin/com/recorder/holder/RecorderHolderPlugin.kt b/apps/client/local_plugins/recorder_holder/android/src/main/kotlin/com/recorder/holder/RecorderHolderPlugin.kt index 70119530..7bd5f3bc 100644 --- a/apps/client/local_plugins/recorder_holder/android/src/main/kotlin/com/recorder/holder/RecorderHolderPlugin.kt +++ b/apps/client/local_plugins/recorder_holder/android/src/main/kotlin/com/recorder/holder/RecorderHolderPlugin.kt @@ -392,6 +392,19 @@ class RecorderHolderPlugin : FlutterPlugin, MethodCallHandler, ActivityAware, val success = BleService.stopScan() result.success(success) } + // ===== 通道 B(TF 卡文件)===== + // 原生只做搬运:写字节 / 报字节。组帧与状态机都在 Dart。 + "writeRecCommand" -> { + val data = call.argument("data") + if (data == null || data.isEmpty()) { + result.error("INVALID_ARGUMENT", "data 不能为空", null) + } else { + result.success(BleService.writeRecCommand(data)) + } + } + "isRecChannelReady" -> { + result.success(BleService.isRecChannelReady()) + } "getDeviceInfo" -> { val address = call.argument("address")?:"" val info = BleService.getDeviceInfo(address); @@ -676,6 +689,11 @@ class RecorderHolderPlugin : FlutterPlugin, MethodCallHandler, ActivityAware, // sendEvent(dataEventSink, mapOf("type" to "audioData", "data" to data), "发送音频数据异常") } + override fun onRecDataReceived(data: ByteArray) { + // 原样转给 Dart,协议解析在 lib/src/rec/ 里做 + sendEvent(dataEventSink, mapOf("type" to "recData", "data" to data), "发送通道B数据异常") + } + override fun onWakeupSignalReceived() { // 将唤醒事件发送到Flutter sendEvent(statusEventSink, mapOf("type" to "wakeup"), "发送唤醒信号异常") diff --git a/apps/client/local_plugins/recorder_holder/ios/recorder_holder/Sources/recorder_holder/BleConst.swift b/apps/client/local_plugins/recorder_holder/ios/recorder_holder/Sources/recorder_holder/BleConst.swift index bd238a60..d6149eb0 100644 --- a/apps/client/local_plugins/recorder_holder/ios/recorder_holder/Sources/recorder_holder/BleConst.swift +++ b/apps/client/local_plugins/recorder_holder/ios/recorder_holder/Sources/recorder_holder/BleConst.swift @@ -29,6 +29,19 @@ class BleConst { /** 客户端特征配置描述符UUID */ static let CLIENT_CHAR_CONFIG_UUID = CBUUID(string: "00002902-0000-1000-8000-00805f9b34fb") + // ===== 通道 B:TF 卡文件(EaiRec_protocol.md §5.1)===== + // 与通道 A(abc0/abc1/abc2)是两套独立 service,帧格式完全不同: + // 这边是 magic 0xA5 + u16 长度、无帧尾 CRC,且命令号 0x01~0x07 与通道 A + // 数值重叠而语义不同 —— **解析必须按 characteristic 分派**。 + /** 文件服务 0xFDA5 */ + static let REC_SERVICE_UUID = CBUUID(string: "FDA5") + + /** CMD_TX:设备 → APP,Notify */ + static let REC_NOTIFY_CHAR_UUID = CBUUID(string: "FDA6") + + /** CMD_RX:APP → 设备,Write */ + static let REC_WRITE_CHAR_UUID = CBUUID(string: "FDA7") + // ===== 标准 BLE Device Information Service(0x180A)===== // 新固件(2.x+)才提供;存在 → V2 (32kbps/80B),不存在 → LEGACY (16kbps/40B)。 static let DEVICE_INFO_SERVICE_UUID = CBUUID(string: "180A") 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 ec6f7d2d..e8655670 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 @@ -40,6 +40,10 @@ public class BleService: NSObject { // 唤醒信号回调 func onWakeupSignalReceived() + + /// 通道 B(TF 卡文件)的原始 BLE value。 + /// 原生**不解析**,原样上抛给 Dart —— 协议状态机只在 Dart 有一份(lib/src/rec/)。 + func onRecDataReceived(data: Data) // 设备信息相关回调 func onDeviceInfoReceived(infoType: Int, infoData: [String: Any]) @@ -53,6 +57,10 @@ public class BleService: NSObject { private var notifyCharacteristic: CBCharacteristic? private var audioCharacteristic: CBCharacteristic? + + // ===== 通道 B:TF 卡文件 ===== + private var recNotifyCharacteristic: CBCharacteristic? + private var recWriteCharacteristic: CBCharacteristic? // 添加录制文件管理器 private var recordingFile: RecordingFile? @@ -157,8 +165,13 @@ private var cmdReplyType: UInt8 = 0 } os_log("🔧 开始初始化CBCentralManager", log: logger, type: .info) + // ⚠️ restore identifier **必须进程内唯一**。这里原来也叫 "BLEAssistantCentral", + // 和 local_plugins/ble_service(它被 agent_service 原生依赖着,始终会被链进来 + // 并先于本插件初始化)的一模一样 —— CoreBluetooth 会让后注册同名 id 的那个 + // central 永远停在 .unsupported,表现就是 startScan 恒报「蓝牙未就绪」而 + // 同进程另一个 central 明明是 poweredOn。2026-09-11 iPhone 12 真机复现。 let options: [String: Any] = [ - CBCentralManagerOptionRestoreIdentifierKey: "BLEAssistantCentral", + CBCentralManagerOptionRestoreIdentifierKey: "com.recorder.holder.central", CBCentralManagerOptionShowPowerAlertKey: true ] @@ -291,7 +304,10 @@ private var cmdReplyType: UInt8 = 0 } guard let manager = centralManager, manager.state == .poweredOn else { - os_log("❌ 蓝牙未就绪,无法开始扫描", log: logger, type: .error) + // 把 state 值一起打出来(.error 级别才进 syslog): + // 2=unsupported 多半是 restore identifier 撞了别的插件,见 initializeCentralManager。 + os_log("❌ 蓝牙未就绪,无法开始扫描 (central.state=%d)", + log: logger, type: .error, centralManager?.state.rawValue ?? -1) return false } @@ -459,9 +475,11 @@ private var cmdReplyType: UInt8 = 0 callAudioCharacteristic = nil notifyCharacteristic = nil audioCharacteristic = nil + recNotifyCharacteristic = nil + recWriteCharacteristic = nil os_log("🔍 恢复会话时重新发现服务和特征", log: logger, type: .info) - peripheral.discoverServices([BleConst.PRIMARY_SERVICE_UUID, BleConst.AUDIO_SERVICE_UUID, BleConst.CALL_AUDIO_SERVICE_UUID, BleConst.DEVICE_INFO_SERVICE_UUID]) + peripheral.discoverServices([BleConst.PRIMARY_SERVICE_UUID, BleConst.AUDIO_SERVICE_UUID, BleConst.CALL_AUDIO_SERVICE_UUID, BleConst.REC_SERVICE_UUID, BleConst.DEVICE_INFO_SERVICE_UUID]) os_log("✅ 已恢复设备会话: %@", log: logger, type: .info, peripheral.identifier.uuidString) } @@ -647,6 +665,36 @@ private var cmdReplyType: UInt8 = 0 callback.onAudioDataReceived(data: data) } } + + /// 通知所有回调接收到通道 B(TF 卡文件)数据 + private func notifyRecDataReceived(_ data: Data) { + for callback in callbacks { + callback.onRecDataReceived(data: data) + } + } + + /// 通道 B:把一帧原始字节写到 0xFDA7。 + /// + /// 原生不认识帧格式,也不该认识 —— 组帧、seq、超时、状态机全在 Dart。 + /// 设备支持无响应写就用它(吞吐更高),否则退回有响应写。 + public func writeRecCommand(_ data: Data) -> Bool { + guard let peripheral = peripheralDevice, + let characteristic = recWriteCharacteristic else { + os_log("❌ 通道 B 写入失败:未连接或未发现 0xFDA7 特征", log: logger, type: .error) + return false + } + let type: CBCharacteristicWriteType = + characteristic.properties.contains(.writeWithoutResponse) ? .withoutResponse : .withResponse + peripheral.writeValue(data, for: characteristic, type: type) + return true + } + + /// 通道 B 是否可用(已连接且发现了 0xFDA5 的两个特征) + public func isRecChannelReady() -> Bool { + return peripheralDevice != nil + && recWriteCharacteristic != nil + && recNotifyCharacteristic != nil + } /// 通知所有回调接收到音频数据 private func notifyAudioDataReceived1(data: Data) { for callback in callbacks { @@ -1614,7 +1662,10 @@ extension BleService: SwiftOpusAudioProcessor.AudioDataCallback { @available(iOS 13.0, *) extension BleService: CBCentralManagerDelegate { public func centralManagerDidUpdateState(_ central: CBCentralManager) { - os_log("🔧 CBCentralManager状态更新: %d", log: logger, type: .info, central.state.rawValue) + // .default 而不是 .info:只有 .default/.error 会进设备 syslog, + // 状态机跳变是排 BLE 问题的第一手证据,必须能从 idevicesyslog 里看到。 + os_log("🔧 CBCentralManager状态更新: %d (0=unknown 1=resetting 2=unsupported 3=unauthorized 4=poweredOff 5=poweredOn)", + log: logger, type: .default, central.state.rawValue) switch central.state { case .poweredOn: @@ -1959,7 +2010,7 @@ extension BleService: CBCentralManagerDelegate { os_log("🔍 开始发现服务...", log: logger, type: .debug) // 查找服务(含 DEVICE_INFO_SERVICE_UUID 用于新旧固件识别) - peripheral.discoverServices([BleConst.PRIMARY_SERVICE_UUID, BleConst.AUDIO_SERVICE_UUID, BleConst.CALL_AUDIO_SERVICE_UUID, BleConst.DEVICE_INFO_SERVICE_UUID]) + peripheral.discoverServices([BleConst.PRIMARY_SERVICE_UUID, BleConst.AUDIO_SERVICE_UUID, BleConst.CALL_AUDIO_SERVICE_UUID, BleConst.REC_SERVICE_UUID, BleConst.DEVICE_INFO_SERVICE_UUID]) // 确保Opus解码器处于活跃状态 // if opusProcessor?.isDecoding() != true { @@ -2082,6 +2133,10 @@ extension BleService: CBPeripheralDelegate { os_log("🔍 发现通话音频服务,开始发现通话音频特征...", log: logger, type: .debug) // 通话音频服务,发现通话音频特征 peripheral.discoverCharacteristics([BleConst.CALL_WRITE_AUDIO_CHAR_UUID], for: service) + } else if service.uuid == BleConst.REC_SERVICE_UUID { + os_log("📁 发现通道 B(TF 卡文件)服务 0xFDA5", log: logger, type: .info) + peripheral.discoverCharacteristics( + [BleConst.REC_NOTIFY_CHAR_UUID, BleConst.REC_WRITE_CHAR_UUID], for: service) } else if service.uuid == BleConst.DEVICE_INFO_SERVICE_UUID { os_log("🔍 发现设备信息服务 (0x180A),开始发现固件版本特征...", log: logger, type: .debug) peripheral.discoverCharacteristics([BleConst.FIRMWARE_REVISION_CHAR_UUID], for: service) @@ -2120,6 +2175,13 @@ extension BleService: CBPeripheralDelegate { callAudioCharacteristic = characteristic os_log("🎵 通话音频特征已设置,开始发现描述符...", log: logger, type: .info) // 先发现描述符,再配置通知 + } else if characteristic.uuid == BleConst.REC_NOTIFY_CHAR_UUID { + recNotifyCharacteristic = characteristic + os_log("📁 通道 B 通知特征已设置,开始发现描述符...", log: logger, type: .info) + peripheral.discoverDescriptors(for: characteristic) + } else if characteristic.uuid == BleConst.REC_WRITE_CHAR_UUID { + recWriteCharacteristic = characteristic + os_log("📁 通道 B 写入特征已设置", log: logger, type: .info) } else if characteristic.uuid == BleConst.FIRMWARE_REVISION_CHAR_UUID { // 0x2A26 只读,异步读取一次,结果在 didUpdateValueFor 处理 os_log("📖 读取固件版本特征 0x2A26...", log: logger, type: .debug) @@ -2153,6 +2215,10 @@ extension BleService: CBPeripheralDelegate { } else if characteristic.uuid == BleConst.RECEIVE_AUDIO_CHAR_UUID { // 处理音频数据 processAudioData(data) + } else if characteristic.uuid == BleConst.REC_NOTIFY_CHAR_UUID { + // 通道 B:原样上抛,**不要**走 handleResponseData(那是通道 A 的 + // AA/BB/CC + CRC8 帧格式,与这边完全不同) + notifyRecDataReceived(data) } else if characteristic.uuid == BleConst.FIRMWARE_REVISION_CHAR_UUID { // 0x2A26 读到版本号字符串。码率判定不依赖内容,仅记录供诊断。 let version = String(data: data, encoding: .utf8)? @@ -2193,7 +2259,9 @@ extension BleService: CBPeripheralDelegate { characteristic.uuid.uuidString, characteristic.descriptors?.count ?? 0) // 对通知特征和音频特征配置通知 - if characteristic.uuid == BleConst.NOTIFY_CHAR_UUID || characteristic.uuid == BleConst.RECEIVE_AUDIO_CHAR_UUID { + if characteristic.uuid == BleConst.NOTIFY_CHAR_UUID + || characteristic.uuid == BleConst.RECEIVE_AUDIO_CHAR_UUID + || characteristic.uuid == BleConst.REC_NOTIFY_CHAR_UUID { setupNotification(for: peripheral, characteristic: characteristic) } } diff --git a/apps/client/local_plugins/recorder_holder/ios/recorder_holder/Sources/recorder_holder/RecorderHolderPlugin.swift b/apps/client/local_plugins/recorder_holder/ios/recorder_holder/Sources/recorder_holder/RecorderHolderPlugin.swift index 50a2f76d..cad15006 100644 --- a/apps/client/local_plugins/recorder_holder/ios/recorder_holder/Sources/recorder_holder/RecorderHolderPlugin.swift +++ b/apps/client/local_plugins/recorder_holder/ios/recorder_holder/Sources/recorder_holder/RecorderHolderPlugin.swift @@ -133,6 +133,20 @@ public class RecorderHolderPlugin: NSObject, FlutterPlugin { BleService.shared.stopScan() result(true) + // ===== 通道 B(TF 卡文件)===== + // 原生只做搬运:写字节 / 报字节。组帧与状态机都在 Dart。 + case "writeRecCommand": + let arguments = call.arguments as? [String: Any] + guard let typed = arguments?["data"] as? FlutterStandardTypedData, + !typed.data.isEmpty else { + result(FlutterError(code: "INVALID_ARGUMENT", message: "data 不能为空", details: nil)) + return + } + result(BleService.shared.writeRecCommand(typed.data)) + + case "isRecChannelReady": + result(BleService.shared.isRecChannelReady()) + case "getDeviceList": let arguments = call.arguments as? [String: Any] let deviceName = (arguments?["deviceName"] as? String) ?? "" @@ -357,6 +371,18 @@ extension RecorderHolderPlugin: BleService.Callback { // Reserved } + public func onRecDataReceived(data: Data) { + // 原样转给 Dart,协议解析在 lib/src/rec/ 里做 + if let eventSink = dataEventSink { + DispatchQueue.main.async { + eventSink([ + "type": "recData", + "data": FlutterStandardTypedData(bytes: data) + ]) + } + } + } + public func onWakeupSignalReceived() { if let eventSink = statusEventSink { DispatchQueue.main.async { diff --git a/apps/client/local_plugins/recorder_holder/lib/recorder_holder_rec.dart b/apps/client/local_plugins/recorder_holder/lib/recorder_holder_rec.dart new file mode 100644 index 00000000..0b9df106 --- /dev/null +++ b/apps/client/local_plugins/recorder_holder/lib/recorder_holder_rec.dart @@ -0,0 +1,13 @@ +/// EaiRec 支架 · 通道 B(TF 卡文件)协议层。 +/// +/// 规范见 `EaiRec_protocol.md` §5。协议逻辑全部在 Dart,原生侧只负责把 +/// `0xFDA6` 的通知原样转上来、把字节写到 `0xFDA7`——所以状态机只有一份, +/// Android / iOS 不会各自跑偏,也能脱离真机做单测。 +library recorder_holder_rec; + +export 'src/rec/rec_client.dart'; +export 'src/rec/rec_frame.dart'; +export 'src/rec/rec_models.dart'; +export 'src/rec/rec_native_transport.dart'; +export 'src/rec/rec_protocol.dart'; +export 'src/rec/rec_transport.dart'; diff --git a/apps/client/local_plugins/recorder_holder/lib/src/rec/rec_client.dart b/apps/client/local_plugins/recorder_holder/lib/src/rec/rec_client.dart new file mode 100644 index 00000000..4ed07d2d --- /dev/null +++ b/apps/client/local_plugins/recorder_holder/lib/src/rec/rec_client.dart @@ -0,0 +1,665 @@ +import 'dart:async'; +import 'dart:typed_data'; + +import 'rec_frame.dart'; +import 'rec_models.dart'; +import 'rec_protocol.dart'; +import 'rec_transport.dart'; + +/// 协议层错误。[status] 有值表示设备明确回了一个非 OK 的状态码。 +class RecException implements Exception { + final String message; + final RecStatus? status; + + RecException(this.message, {this.status}); + + @override + String toString() => + 'RecException($message${status == null ? '' : ', status: $status'})'; +} + +/// 下载结果。 +class RecDownloadResult { + final String name; + final int bytes; + final int fileSize; + final int ftime; + + /// 期间发生过几次「offset 缺口 / 空闲超时」导致的重新 SETUP。 + /// 真机调优时这个数字比吞吐更能说明问题。 + final int resumes; + + const RecDownloadResult({ + required this.name, + required this.bytes, + required this.fileSize, + required this.ftime, + required this.resumes, + }); + + bool get complete => bytes == fileSize; +} + +/// 取消令牌。下载是长任务,UI 必须能中断它。 +class RecCancelToken { + bool _cancelled = false; + + bool get isCancelled => _cancelled; + + void cancel() => _cancelled = true; +} + +/// 通道 B(TF 卡文件)的事务层与状态机。 +/// +/// 对应 `EaiRec_protocol.md` §5。职责: +/// - 发送方 seq 递增与帧组装 +/// - 单事务串行 + 3s 超时([RecTiming.commandTimeout]) +/// - `FILE_LIST` 的多包累积,直到 `eof = 1` +/// - `FILE_TRANSFER` 会话:offset 跟踪、缺口重传、5s 空闲超时、断点续传 +/// - 设备主动上报(`REC_INFO` / 录音停止后的 `FILE_LIST`)的分发 +/// +/// **不负责**:GATT 连接、与通道 A 的互斥。后者由上层 +/// (`HolderDeviceService`)统一调度——协议层看不到通道 A。 +class RecClient { + RecClient({ + required RecTransport transport, + void Function(String msg)? log, + this.verifyChecksum = true, + this.commandTimeout = RecTiming.commandTimeout, + this.idleTimeout = RecTiming.transferIdleTimeout, + }) : _transport = transport, + _log = log ?? _noop; + + static void _noop(String _) {} + + final RecTransport _transport; + final void Function(String) _log; + + /// DATA 包是否校验 `sum`。 + /// + /// 默认开。协议文档 Q12 还留着「`sum = 0` 表示不启用」这个未定后门—— + /// 若真机上发现固件根本没实现校验和(表现为每包都判失败、传输走不动), + /// 把这个关掉可以先跑通链路,但**上线前必须和固件把 Q12 定死**。 + final bool verifyChecksum; + + /// 普通命令超时。默认取协议值,单测里调小以免每个用例真等 3 秒。 + final Duration commandTimeout; + + /// 传输期间「多久没收到新 DATA 就重来」。同上,可调只为可测。 + final Duration idleTimeout; + + StreamSubscription? _sub; + + int _seq = 0; + + // ---- 单事务:同一时刻只允许一个未完成的请求 ---- + Completer? _lock; + _Pending? _pending; + Timer? _pendingTimer; + + // ---- FILE_LIST 多包累积 ---- + final List _listAccum = []; + + // ---- FILE_TRANSFER 当前会话 ---- + _TransferSession? _transfer; + + final _infoCtrl = StreamController.broadcast(); + final _listCtrl = StreamController>.broadcast(); + + /// 设备状态。查询结果与主动上报都会推到这里。 + Stream get onRecInfo => _infoCtrl.stream; + + /// 设备**主动**推来的完整文件列表(录音停止后固件会自己推一次)。 + Stream> get onFileListPushed => _listCtrl.stream; + + bool get isTransferring => _transfer != null; + + /// 开始收包。 + void attach() { + _sub ??= _transport.incoming.listen(_onRaw, onError: (Object e) { + _log('通道 B 接收流异常: $e'); + }); + } + + Future detach() async { + await _sub?.cancel(); + _sub = null; + _failPending(RecException('链路已断开')); + _transfer?.fail(RecException('链路已断开')); + _transfer = null; + _listAccum.clear(); + } + + Future dispose() async { + await detach(); + await _infoCtrl.close(); + await _listCtrl.close(); + } + + // ========================================================================== + // 对外命令 + // ========================================================================== + + /// `0x07 RTC_SYNC`。协议建议每次连上就同步一次,成本低。 + /// + /// 时间按**设备本地时间**理解,不做时区协商(协议文档 §5.12), + /// 所以这里直接送手机的本地时间,不转 UTC。 + Future syncRtc([DateTime? now]) async { + final t = now ?? DateTime.now(); + final payload = [ + ...RecBytes.u16le(t.year), + t.month, + t.day, + t.hour, + t.minute, + t.second, + ]; + final rsp = await _request(RecCmd.rtcSync, payload); + _expectOk(rsp, 'RTC_SYNC'); + } + + /// `0x01 REC_INFO` + Future queryInfo() async { + final rsp = await _request(RecCmd.recInfo, const []); + final info = RecInfo.parse(rsp.payload); + if (info == null) throw RecException('REC_INFO 响应长度非法'); + return info; + } + + /// `0x02 FILE_LIST`。设备会连续推多包,这里一直收到 `eof = 1` 才返回。 + Future> listFiles() async { + _listAccum.clear(); + final rsp = await _request(RecCmd.fileList, const [], collectList: true); + // _request 在 eof 到达时才完成,累积结果挂在 pending 上 + final items = rsp.listResult; + if (items == null) throw RecException('FILE_LIST 未返回完整列表'); + return items; + } + + /// `0x03 FILE_INFO`(可选命令,固件可能未实现,会回 UNSUPPORTED) + Future fileInfo(String name) async { + _requireValidName(name); + final encoded = RecFileName.encode(name); + final rsp = await _request(RecCmd.fileInfo, [encoded.length, ...encoded]); + final info = RecFileInfo.parse(rsp.payload); + if (info == null) throw RecException('FILE_INFO 响应长度非法'); + if (!info.status.isOk) { + throw RecException('FILE_INFO 失败', status: info.status); + } + return info; + } + + /// `0x05 FILE_DELETE`。**调用前 UI 必须已二次确认**(协议文档 §5.10)。 + Future deleteFile(String name) async { + _requireValidName(name); + final encoded = RecFileName.encode(name); + final rsp = await _request(RecCmd.fileDelete, [ + ...RecBytes.u32le(RecConfirm.delete), + encoded.length, + ...encoded, + ]); + _expectOk(rsp, 'FILE_DELETE'); + } + + /// `0x06 REC_FORMAT`。**调用前 UI 必须已二次确认**,这是不可逆的数据销毁。 + Future format({int fsType = 0}) async { + final rsp = await _request(RecCmd.format, [ + ...RecBytes.u32le(RecConfirm.format), + fsType, + ]); + _expectOk(rsp, 'REC_FORMAT'); + } + + /// `0x04 FILE_TRANSFER`:下载一个文件到 [sink]。 + /// + /// 断点续传是天然的:起始 offset 取 `sink.length`,所以传入一个已经写了 + /// 一半的 sink 就是续传。 + /// + /// [maxResumes] 是「缺口 / 空闲超时」触发重新 SETUP 的次数上限,超了就报错, + /// 避免弱链路下无限重试把电量耗光。 + Future download({ + required String name, + required RecFileSink sink, + void Function(int received, int total)? onProgress, + RecCancelToken? cancelToken, + int maxResumes = 8, + }) async { + _requireValidName(name); + if (_transfer != null) { + throw RecException('已有文件传输在进行中', status: RecStatus.busy); + } + + final session = _TransferSession( + name: name, + sink: sink, + onProgress: onProgress, + log: _log, + verifyChecksum: verifyChecksum, + idleTimeout: idleTimeout, + ); + _transfer = session; + + try { + var resumes = 0; + while (true) { + if (cancelToken?.isCancelled ?? false) { + await _sendCancel(RecCancelReason.userCancel); + throw RecException('用户取消', status: RecStatus.cancelled); + } + + // ⚠️ 必须**先**开好接收窗口再发 SETUP:设备收到 SETUP_RSP 之后是立刻连续 + // 推 DATA 的,等 SETUP 的 await 返回再准备接收,中间那几包会被丢掉, + // 于是第一包就判 offset 缺口 → CANCEL → 重来,陷入死循环。 + session.beginAttempt(cancelToken); + + final setupRsp = await _setupTransfer(name, sink.length); + if (!setupRsp.status.isOk) { + throw RecException('SETUP 失败', status: setupRsp.status); + } + session.onSetup(setupRsp); + + // 设备实际给的起点可能与请求的不同;它比本地已有的还靠后就是空洞, + // 补不上,只能判失败——继续写下去文件中间会缺一段而大小却是对的。 + if (setupRsp.startOfs > sink.length) { + throw RecException( + 'SETUP 返回的 start_ofs(${setupRsp.startOfs}) 超过本地已有(${sink.length}),无法续传', + ); + } + + final outcome = await session.awaitOutcome(); + if (outcome == _TransferOutcome.done) { + return RecDownloadResult( + name: name, + bytes: sink.length, + fileSize: setupRsp.fileSize, + ftime: setupRsp.ftime, + resumes: resumes, + ); + } + + resumes++; + if (resumes > maxResumes) { + await _sendCancel(RecCancelReason.timeout); + throw RecException('重试 $maxResumes 次仍未完成,放弃'); + } + // 协议要求:发现缺口 / 超时先 CANCEL,再从期望 offset 重新 SETUP + await _sendCancel(RecCancelReason.offsetMismatch); + _log('通道 B 传输重来第 $resumes 次,从 offset ${sink.length} 续'); + } + } finally { + session.abort(); + _transfer = null; + await sink.close(); + } + } + + /// 主动中断当前传输。 + Future cancelTransfer() async { + if (_transfer == null) return; + await _sendCancel(RecCancelReason.userCancel); + _transfer?.fail(RecException('已取消', status: RecStatus.cancelled)); + } + + // ========================================================================== + // 内部:请求 / 响应 + // ========================================================================== + + Future<_Pending> _request( + int cmd, + List payload, { + bool collectList = false, + }) async { + // 协议文档 §7.1:文件传输期间禁止下发其它命令。设备侧「同一时间只允许一个 + // 传输」,中途插进来的命令会把它打断,所以闸门放在协议层而不是指望上层记得。 + if (_transfer != null && cmd != RecCmd.fileTransfer) { + throw RecException('文件传输进行中,拒绝命令 0x${cmd.toRadixString(16)}', + status: RecStatus.busy); + } + await _acquire(); + try { + final pending = _Pending(cmd: cmd, collectList: collectList); + _pending = pending; + _listAccum.clear(); + + final frame = RecFrame.build(cmd: cmd, seq: _nextSeq(), payload: payload); + await _transport.send(frame); + + _pendingTimer = Timer(commandTimeout, () { + _failPending(RecException( + '命令 0x${cmd.toRadixString(16)} 超时(${commandTimeout.inMilliseconds}ms)')); + }); + + return await pending.completer.future; + } finally { + _pendingTimer?.cancel(); + _pendingTimer = null; + _pending = null; + _release(); + } + } + + /// SETUP 走的是同一条单事务通道,但**只等 SETUP_RSP**; + /// 之后的 DATA 是设备连续推的,不占事务槽。 + Future _setupTransfer(String name, int startOfs) async { + final encoded = RecFileName.encode(name); + final rsp = await _request(RecCmd.fileTransfer, [ + RecTransferAction.setup, + ...RecBytes.u32le(startOfs), + encoded.length, + ...encoded, + ]); + final parsed = rsp.setupRsp; + if (parsed == null) throw RecException('SETUP_RSP 响应非法'); + return parsed; + } + + Future _sendCancel(int reason) async { + try { + final frame = RecFrame.build( + cmd: RecCmd.fileTransfer, + seq: _nextSeq(), + payload: [RecTransferAction.cancel, reason], + ); + await _transport.send(frame); + } catch (e) { + // 取消发不出去不该覆盖掉真正的失败原因 + _log('发送 CANCEL 失败(忽略): $e'); + } + } + + int _nextSeq() { + final s = _seq; + _seq = (_seq + 1) & 0xFF; + return s; + } + + Future _acquire() async { + while (_lock != null) { + await _lock!.future; + } + _lock = Completer(); + } + + void _release() { + final l = _lock; + _lock = null; + if (l != null && !l.isCompleted) l.complete(); + } + + void _failPending(RecException e) { + final p = _pending; + _pending = null; + _pendingTimer?.cancel(); + _pendingTimer = null; + if (p != null && !p.completer.isCompleted) { + p.completer.completeError(e); + } + } + + void _expectOk(_Pending rsp, String what) { + if (rsp.payload.isEmpty) { + throw RecException('$what 响应为空'); + } + final st = RecStatus.fromCode(rsp.payload[0] & 0xFF); + if (!st.isOk) throw RecException('$what 失败', status: st); + } + + void _requireValidName(String name) { + if (!RecFileName.isValid(name)) { + throw RecException('文件名非法: $name', status: RecStatus.paramErr); + } + } + + // ========================================================================== + // 内部:收包分发 + // ========================================================================== + + void _onRaw(Uint8List bytes) { + final frame = RecFrame.tryParse(bytes); + if (frame == null) { + // magic / len 不符一律丢弃,不回错误帧(协议文档 §5.2) + return; + } + switch (frame.cmd) { + case RecCmd.fileTransfer: + _onTransferFrame(frame); + break; + case RecCmd.fileList: + _onFileListFrame(frame); + break; + case RecCmd.recInfo: + _onRecInfoFrame(frame); + break; + default: + _completePending(frame); + } + } + + void _onRecInfoFrame(RecFrame frame) { + final info = RecInfo.parse(frame.payload); + if (info != null && !_infoCtrl.isClosed) { + // 查询结果与主动上报都推给订阅者:录音状态变化是设备驱动的, + // 上层要靠这条流才能知道「设备自己开始录了」。 + _infoCtrl.add(info); + } + _completePending(frame); + } + + void _onFileListFrame(RecFrame frame) { + final page = RecFileListPage.parse(frame.payload); + if (page == null) { + _log('FILE_LIST 包结构非法,已丢弃(len=${frame.payload.length})'); + return; + } + _listAccum.addAll(page.items); + if (!page.eof) return; + + final all = List.unmodifiable(_listAccum); + _listAccum.clear(); + + final p = _pending; + if (p != null && p.cmd == RecCmd.fileList) { + p.listResult = all; + _completePending(frame); + } else { + // 无人在等 → 这是录音停止后设备主动推的那一次 + if (!_listCtrl.isClosed) _listCtrl.add(all); + } + } + + void _onTransferFrame(RecFrame frame) { + if (frame.payload.isEmpty) return; + final action = frame.payload[0] & 0xFF; + final body = frame.payload.sublist(1); + + switch (action) { + case RecTransferAction.setupRsp: + final parsed = RecTransferSetupRsp.parse(body); + final p = _pending; + if (p != null && p.cmd == RecCmd.fileTransfer) { + p.setupRsp = parsed; + _completePending(frame); + } + break; + + case RecTransferAction.data: + final data = RecTransferData.parse(body, frame.raw); + if (data == null) { + _log('DATA 包结构非法,已丢弃'); + return; + } + _transfer?.onData(data); + break; + + case RecTransferAction.cancel: + final reason = body.isNotEmpty ? body[0] & 0xFF : -1; + _log('设备取消了传输,reason=$reason'); + _transfer?.fail( + RecException('设备中断了传输 (reason=$reason)', status: RecStatus.cancelled)); + break; + + default: + _log('未知的 FILE_TRANSFER action: 0x${action.toRadixString(16)}'); + } + } + + void _completePending(RecFrame frame) { + final p = _pending; + if (p == null || p.cmd != frame.cmd) return; + // FILE_LIST 要等 eof 才算完成 + if (p.collectList && p.listResult == null) return; + p.payload = frame.payload; + _pending = null; + _pendingTimer?.cancel(); + _pendingTimer = null; + if (!p.completer.isCompleted) p.completer.complete(p); + } +} + +/// 一次未完成的请求。 +class _Pending { + _Pending({required this.cmd, this.collectList = false}); + + final int cmd; + final bool collectList; + final Completer<_Pending> completer = Completer<_Pending>(); + + Uint8List payload = Uint8List(0); + List? listResult; + RecTransferSetupRsp? setupRsp; +} + +enum _TransferOutcome { done, needResume } + +/// 一次文件传输的接收侧状态机。 +class _TransferSession { + _TransferSession({ + required this.name, + required this.sink, + required this.log, + required this.verifyChecksum, + required this.idleTimeout, + this.onProgress, + }); + + final String name; + final RecFileSink sink; + final void Function(String) log; + final bool verifyChecksum; + final Duration idleTimeout; + final void Function(int received, int total)? onProgress; + + int _fileSize = 0; + Completer<_TransferOutcome>? _done; + Timer? _idle; + RecCancelToken? _cancelToken; + bool _aborted = false; + + /// 落盘是异步的,而 DATA 是连续推来的——必须串起来,否则并发 append + /// 会把字节顺序写乱,而且 [sink.length] 在两次 await 之间是不可信的。 + Future _writeChain = Future.value(); + + void onSetup(RecTransferSetupRsp rsp) => _fileSize = rsp.fileSize; + + /// 开启一次尝试的接收窗口。**在发 SETUP 之前调用。** + void beginAttempt(RecCancelToken? cancelToken) { + _cancelToken = cancelToken; + _done = Completer<_TransferOutcome>(); + _restartIdle(); + } + + Future<_TransferOutcome> awaitOutcome() { + final d = _done; + if (d == null) { + throw StateError('awaitOutcome 必须在 beginAttempt 之后调用'); + } + return d.future; + } + + /// 整个下载结束(成功或失败)时清场,避免定时器泄漏。 + void abort() { + _aborted = true; + _idle?.cancel(); + _idle = null; + } + + void onData(RecTransferData d) { + if (_aborted || _done == null || _done!.isCompleted) return; + + if (verifyChecksum && !d.checksumOk) { + // 校验不过就当没收到:不推进 offset,靠空闲超时重来。 + log('DATA 校验和不符 @${d.fileOfs},丢弃'); + return; + } + + if (_cancelToken?.isCancelled ?? false) { + _finish(RecException('用户取消', status: RecStatus.cancelled)); + return; + } + + _restartIdle(); + _writeChain = _writeChain.then((_) => _apply(d)).catchError((Object e) { + _finish(e is RecException ? e : RecException('落盘失败: $e')); + }); + } + + Future _apply(RecTransferData d) async { + if (_done == null || _done!.isCompleted) return; + + final expected = sink.length; + + if (d.fileOfs > expected) { + // 缺口:协议要求 CANCEL 后从期望 offset 重新 SETUP + log('offset 缺口:收到 ${d.fileOfs},期望 $expected'); + _finishOutcome(_TransferOutcome.needResume); + return; + } + + if (d.fileOfs + d.data.length <= expected) { + // 整包都是已经写过的,直接丢 + return; + } + + // 部分重叠:只接尾巴那一段 + final skip = expected - d.fileOfs; + final slice = skip > 0 ? d.data.sublist(skip) : d.data; + if (slice.isNotEmpty) { + await sink.append(slice); + onProgress?.call(sink.length, _fileSize); + } + + if (d.isEnd) { + if (_fileSize > 0 && sink.length != _fileSize) { + // END 到了但大小对不上:中间丢过东西,让外层重新 SETUP 补齐 + log('收到 END 但大小不符:本地 ${sink.length} / 应为 $_fileSize'); + _finishOutcome(_TransferOutcome.needResume); + return; + } + _finishOutcome(_TransferOutcome.done); + } + } + + void _restartIdle() { + _idle?.cancel(); + _idle = Timer(idleTimeout, () { + log('传输空闲超过 ${idleTimeout.inMilliseconds}ms,准备续传'); + _finishOutcome(_TransferOutcome.needResume); + }); + } + + void fail(RecException e) => _finish(e); + + void _finish(Object error) { + _idle?.cancel(); + _idle = null; + final d = _done; + if (d != null && !d.isCompleted) d.completeError(error); + } + + void _finishOutcome(_TransferOutcome o) { + _idle?.cancel(); + _idle = null; + final d = _done; + if (d != null && !d.isCompleted) d.complete(o); + } +} diff --git a/apps/client/local_plugins/recorder_holder/lib/src/rec/rec_frame.dart b/apps/client/local_plugins/recorder_holder/lib/src/rec/rec_frame.dart new file mode 100644 index 00000000..4c100e45 --- /dev/null +++ b/apps/client/local_plugins/recorder_holder/lib/src/rec/rec_frame.dart @@ -0,0 +1,112 @@ +import 'dart:typed_data'; + + +/// 小端读写helper。通道 B 的所有多字节整数一律 little-endian(协议文档 §5.2)。 +class RecBytes { + RecBytes._(); + + static List u16le(int v) => [v & 0xFF, (v >> 8) & 0xFF]; + + static List u32le(int v) => [ + v & 0xFF, + (v >> 8) & 0xFF, + (v >> 16) & 0xFF, + (v >> 24) & 0xFF, + ]; + + static int readU16le(List b, int off) => + (b[off] & 0xFF) | ((b[off + 1] & 0xFF) << 8); + + static int readU32le(List b, int off) => + (b[off] & 0xFF) | + ((b[off + 1] & 0xFF) << 8) | + ((b[off + 2] & 0xFF) << 16) | + ((b[off + 3] & 0xFF) << 24); +} + +/// 通道 B 的包头:`magic(1) | cmd(1) | seq(1) | len(2 LE)`,共 5 字节。 +class RecHeader { + RecHeader._(); + + static const int magic = 0xA5; + static const int size = 5; +} + +/// 一个解析好的通道 B 帧。 +class RecFrame { + final int cmd; + final int seq; + final Uint8List payload; + + /// 原始 BLE value(含包头)。`FILE_TRANSFER/DATA` 的校验和要按整包算, + /// 所以必须把原始字节留着,不能只保留 payload。 + final Uint8List raw; + + RecFrame({ + required this.cmd, + required this.seq, + required this.payload, + required this.raw, + }); + + /// 解析一个 BLE value。 + /// + /// 返回 `null` 表示这个包**应当被丢弃**(协议文档 §5.2:magic 不符或 len 不符 + /// 直接丢弃,不回错误帧)。这里刻意不抛异常——通道上混进别的数据是正常现象, + /// 抛异常会把接收循环打断。 + static RecFrame? tryParse(List bytes) { + if (bytes.length < RecHeader.size) return null; + if ((bytes[0] & 0xFF) != RecHeader.magic) return null; + final len = RecBytes.readU16le(bytes, 3); + if (bytes.length != RecHeader.size + len) return null; + return RecFrame( + cmd: bytes[1] & 0xFF, + seq: bytes[2] & 0xFF, + payload: Uint8List.fromList(bytes.sublist(RecHeader.size)), + raw: Uint8List.fromList(bytes), + ); + } + + /// 组一个 App → 设备的帧。 + static Uint8List build({ + required int cmd, + required int seq, + List payload = const [], + }) { + final out = BytesBuilder(); + out.addByte(RecHeader.magic); + out.addByte(cmd & 0xFF); + out.addByte(seq & 0xFF); + out.add(RecBytes.u16le(payload.length)); + out.add(payload); + return out.toBytes(); + } + + @override + String toString() => + 'RecFrame(cmd: 0x${cmd.toRadixString(16)}, seq: $seq, len: ${payload.length})'; +} + +/// `FILE_TRANSFER/DATA` 的校验和(协议文档 §5.9)。 +/// +/// 「整个 BLE value 逐字节累加的 u16,计算时 sum 字段自身两字节按 0 处理」。 +/// +/// ⚠️ **计算范围包含包头**。最容易写错成只算 `data[]`——那样算出来永远对不上, +/// 表现是每一包都被判校验失败、传输永远走不动。 +class RecChecksum { + RecChecksum._(); + + /// DATA 包里 `sum` 字段相对于**整包起始**的偏移: + /// hdr(5) + action_cmd(1) + file_ofs(4) + control(1) = 11 + static const int sumOffsetInPacket = 11; + + static int compute(List packet, {int sumOffset = sumOffsetInPacket}) { + var sum = 0; + for (var i = 0; i < packet.length; i++) { + // sum 字段自身两字节按 0 参与计算 + if (i == sumOffset || i == sumOffset + 1) continue; + sum = (sum + (packet[i] & 0xFF)) & 0xFFFF; + } + return sum; + } +} diff --git a/apps/client/local_plugins/recorder_holder/lib/src/rec/rec_models.dart b/apps/client/local_plugins/recorder_holder/lib/src/rec/rec_models.dart new file mode 100644 index 00000000..202c5b71 --- /dev/null +++ b/apps/client/local_plugins/recorder_holder/lib/src/rec/rec_models.dart @@ -0,0 +1,250 @@ +import 'dart:typed_data'; + +import 'rec_frame.dart'; +import 'rec_protocol.dart'; + +/// `REC_INFO` 响应(协议文档 §5.6)。 +/// +/// payload: `status(1) fs_type(1) total_kb(4) used_kb(4) rec_state(1)` = 11 字节 +class RecInfo { + final RecStatus status; + final int fsType; + final int totalKb; + final int usedKb; + + /// 是否正在录音。协议明确:**本字段始终有效**, + /// 即使 `status != OK` 也要采信它,其余字段按 0 处理。 + final bool recording; + + const RecInfo({ + required this.status, + required this.fsType, + required this.totalKb, + required this.usedKb, + required this.recording, + }); + + static const int payloadSize = 11; + + int get freeKb => (totalKb - usedKb).clamp(0, totalKb); + + /// 有没有卡。`NO_CARD` 是明确信号;容量为 0 只能算旁证,不作判据。 + bool get hasCard => status != RecStatus.noCard; + + static RecInfo? parse(List p) { + if (p.length < payloadSize) return null; + final status = RecStatus.fromCode(p[0] & 0xFF); + final ok = status.isOk; + return RecInfo( + status: status, + // status != OK 时其余字段按 0 处理(协议文档 §5.6) + fsType: ok ? p[1] & 0xFF : 0, + totalKb: ok ? RecBytes.readU32le(p, 2) : 0, + usedKb: ok ? RecBytes.readU32le(p, 6) : 0, + recording: (p[10] & 0xFF) != 0, + ); + } + + @override + String toString() => 'RecInfo(status: $status, fs: ${RecFsType.describe(fsType)}, ' + '$usedKb/$totalKb KB, recording: $recording)'; +} + +/// 文件列表里的一条(协议文档 §5.7)。 +/// +/// 线格式: `size(4) ftime(4) name_len(1) name[name_len]` +class RecFileItem { + final String name; + final int size; + + /// 原始 FAT 时间戳。解不出合法日期时 [time] 为 null,但这里始终保留原值。 + final int ftime; + + const RecFileItem({ + required this.name, + required this.size, + required this.ftime, + }); + + /// 按标准 FAT 布局解码:高 16 位是日期,低 16 位是时间。 + /// + /// ⚠️ 协议只写了「格式同 `fs_get_ftime()`」,没给字段布局。这里按 FAT 通行布局 + /// 实现(date = year-1980 << 9 | month << 5 | day;time = hour << 11 | + /// min << 5 | sec/2)。**真机第一次拿到列表时要核对这个解码是否对得上**, + /// 对不上会表现为「文件时间全是 1980 年」或日期乱跳。见协议文档 Q11 附近。 + DateTime? get time { + final date = (ftime >> 16) & 0xFFFF; + final t = ftime & 0xFFFF; + final year = 1980 + ((date >> 9) & 0x7F); + final month = (date >> 5) & 0x0F; + final day = date & 0x1F; + final hour = (t >> 11) & 0x1F; + final minute = (t >> 5) & 0x3F; + final second = (t & 0x1F) * 2; + if (month < 1 || month > 12 || day < 1 || day > 31) return null; + if (hour > 23 || minute > 59 || second > 59) return null; + try { + return DateTime(year, month, day, hour, minute, second); + } catch (_) { + return null; + } + } + + @override + String toString() => 'RecFileItem($name, $size B, ${time ?? ftime})'; +} + +/// `FILE_LIST` 的单个响应包(一次查询会有多包,直到 [eof])。 +class RecFileListPage { + final RecStatus status; + final bool eof; + final List items; + + const RecFileListPage({ + required this.status, + required this.eof, + required this.items, + }); + + /// payload: `status(1) item_count(1) eof(1) items[]` + /// + /// 返回 null 表示这一包结构非法(长度对不上、item 越界、文件名不合法)。 + /// 协议要求 App「同时校验 hdr.len 与 item_count,避免异常包越界解析」。 + static RecFileListPage? parse(List p) { + if (p.length < 3) return null; + final status = RecStatus.fromCode(p[0] & 0xFF); + final count = p[1] & 0xFF; + final eof = (p[2] & 0xFF) != 0; + + final items = []; + var off = 3; + for (var i = 0; i < count; i++) { + // size(4) + ftime(4) + name_len(1) + if (off + 9 > p.length) return null; + final size = RecBytes.readU32le(p, off); + final ftime = RecBytes.readU32le(p, off + 4); + final nameLen = p[off + 8] & 0xFF; + final nameStart = off + 9; + if (nameLen < RecFileName.minNameLen) return null; + if (nameStart + nameLen > p.length) return null; + final raw = p.sublist(nameStart, nameStart + nameLen); + if (!RecFileName.isValidRaw(raw)) return null; + items.add(RecFileItem( + name: RecFileName.decode(raw), + size: size, + ftime: ftime, + )); + off = nameStart + nameLen; + } + // item_count 说有 N 条就必须正好是 N 条,多出来的尾巴说明这一包结构已经坏了 + if (off != p.length) return null; + return RecFileListPage(status: status, eof: eof, items: items); + } +} + +/// `FILE_INFO` 响应(协议文档 §5.8)。 +class RecFileInfo { + final RecStatus status; + final int size; + final int ftime; + + const RecFileInfo({ + required this.status, + required this.size, + required this.ftime, + }); + + static const int payloadSize = 9; + + static RecFileInfo? parse(List p) { + if (p.length < payloadSize) return null; + final status = RecStatus.fromCode(p[0] & 0xFF); + final ok = status.isOk; + return RecFileInfo( + status: status, + size: ok ? RecBytes.readU32le(p, 1) : 0, + ftime: ok ? RecBytes.readU32le(p, 5) : 0, + ); + } +} + +/// `FILE_TRANSFER / SETUP_RSP`(协议文档 §5.9)。 +/// +/// payload(不含 action_cmd): `status(1) file_size(4) start_ofs(4) ftime(4) data_len(2)` +class RecTransferSetupRsp { + final RecStatus status; + final int fileSize; + final int startOfs; + final int ftime; + + /// 设备侧采用的单包 data 长度。 + /// + /// ⚠️ **只用于日志和限速判断,不能拿它当每包的固定长度去切数据**—— + /// 最后一包通常更短。实际长度一律用 `hdr.len - 8`。 + final int dataLen; + + const RecTransferSetupRsp({ + required this.status, + required this.fileSize, + required this.startOfs, + required this.ftime, + required this.dataLen, + }); + + static const int bodySize = 15; + + static RecTransferSetupRsp? parse(List body) { + if (body.length < bodySize) return null; + final status = RecStatus.fromCode(body[0] & 0xFF); + final ok = status.isOk; + return RecTransferSetupRsp( + status: status, + fileSize: ok ? RecBytes.readU32le(body, 1) : 0, + startOfs: ok ? RecBytes.readU32le(body, 5) : 0, + ftime: ok ? RecBytes.readU32le(body, 9) : 0, + dataLen: ok ? RecBytes.readU16le(body, 13) : 0, + ); + } +} + +/// `FILE_TRANSFER / DATA`(协议文档 §5.9)。 +class RecTransferData { + final int fileOfs; + final int control; + final int sum; + final Uint8List data; + + const RecTransferData({ + required this.fileOfs, + required this.control, + required this.sum, + required this.data, + required this.checksumOk, + }); + + /// 收到的 sum 与本地按整包算出来的是否一致。 + /// + /// 刻意做成字段而不是「校验失败就返回 null」:调用方需要区分 + /// 「包坏了」和「校验和对不上」,前者只能丢,后者要打日志—— + /// 协议文档 Q12 还留着「sum = 0 表示不启用」这个未定的后门, + /// 真机上第一次对不上时,日志是唯一能判断固件到底实现了哪种语义的依据。 + final bool checksumOk; + + bool get isEnd => control == RecDataControl.end; + + /// [body] 是去掉 action_cmd 之后的部分;[whole] 是整包(校验和要按整包算)。 + /// + /// 返回 null 只表示**包结构非法**;校验和结果看 [checksumOk]。 + static RecTransferData? parse(List body, List whole) { + // file_ofs(4) + control(1) + sum(2) + if (body.length < 7) return null; + final sum = RecBytes.readU16le(body, 5); + return RecTransferData( + fileOfs: RecBytes.readU32le(body, 0), + control: body[4] & 0xFF, + sum: sum, + data: Uint8List.fromList(body.sublist(7)), + checksumOk: RecChecksum.compute(whole) == sum, + ); + } +} diff --git a/apps/client/local_plugins/recorder_holder/lib/src/rec/rec_native_transport.dart b/apps/client/local_plugins/recorder_holder/lib/src/rec/rec_native_transport.dart new file mode 100644 index 00000000..154e541f --- /dev/null +++ b/apps/client/local_plugins/recorder_holder/lib/src/rec/rec_native_transport.dart @@ -0,0 +1,43 @@ +import 'dart:typed_data'; + +import '../recorder_holder.dart'; +import 'rec_transport.dart'; + +/// 走原生 GATT 的通道 B 传输实现。 +/// +/// 原生侧只做两件事:把 `0xFDA6` 的通知原样转上来、把字节写到 `0xFDA7`。 +/// 因此这里薄得只剩转发——所有协议逻辑都在 [RecClient]。 +/// +/// ⚠️ **不要改成用 flutter_blue_plus 自己连一条**:一部手机对同一个 peripheral +/// 只能有一条 GATT 连接,而通道 A(控制 / 实时音频)已经被原生占着了。 +/// OTA 那边能用 flutter_blue_plus,是因为它先 `setEnterOta(true)` 把原生连接让了出来。 +class RecNativeTransport implements RecTransport { + RecNativeTransport({RecorderHolder? plugin}) + : _plugin = plugin ?? RecorderHolder(); + + final RecorderHolder _plugin; + + bool _ready = false; + + @override + Stream get incoming => _plugin.onRecData; + + @override + bool get isReady => _ready; + + /// 向原生确认通道是否可用,并把结果缓存到 [isReady]。 + Future refreshReady() async { + _ready = await _plugin.isRecChannelReady(); + return _ready; + } + + @override + Future send(List data) async { + final ok = await _plugin.writeRecCommand(data); + if (!ok) { + // 写失败必须抛,不能静默返回:上层 RecClient 正等着响应, + // 吞掉的话它只会一路等到 3 秒超时,日志里看不出是写没出去。 + throw StateError('通道 B 写入失败(${data.length} 字节)'); + } + } +} diff --git a/apps/client/local_plugins/recorder_holder/lib/src/rec/rec_protocol.dart b/apps/client/local_plugins/recorder_holder/lib/src/rec/rec_protocol.dart new file mode 100644 index 00000000..a360bfd4 --- /dev/null +++ b/apps/client/local_plugins/recorder_holder/lib/src/rec/rec_protocol.dart @@ -0,0 +1,191 @@ +/// EaiRec 支架 · 通道 B(TF 卡文件)协议常量。 +/// +/// 规范见 `EaiRec_protocol.md` §5。本文件只放"线上格式"的常量, +/// 不含任何状态机逻辑,方便固件对照。 +library recorder_holder_rec_protocol; + +/// 通道 B 的 GATT UUID(16-bit,与协议文档 §5.1 一致)。 +class RecUuid { + RecUuid._(); + + /// Service `0xFDA5` + static const int service16 = 0xFDA5; + + /// CMD_TX:设备 → App,Notify + static const int notify16 = 0xFDA6; + + /// CMD_RX:App → 设备,Write / Write No Response + static const int cmdRx16 = 0xFDA7; + + static const String service = '0000fda5-0000-1000-8000-00805f9b34fb'; + static const String notify = '0000fda6-0000-1000-8000-00805f9b34fb'; + static const String cmdRx = '0000fda7-0000-1000-8000-00805f9b34fb'; +} + +/// 命令号(协议文档 §5.4)。 +/// +/// ⚠️ 与通道 A 的 `0x01~0x07` **数值重叠但语义完全不同**(A 的 0x04 是电量, +/// 这里的 0x04 是文件传输)。两者在不同 characteristic 上,解析入口必须按 +/// characteristic 分派,不能共用。 +class RecCmd { + RecCmd._(); + + static const int recInfo = 0x01; + static const int fileList = 0x02; + static const int fileInfo = 0x03; + static const int fileTransfer = 0x04; + static const int fileDelete = 0x05; + static const int format = 0x06; + static const int rtcSync = 0x07; +} + +/// `FILE_TRANSFER` 的子操作。 +class RecTransferAction { + RecTransferAction._(); + + /// App → 设备:设置传输配置 + static const int setup = 0x01; + + /// 设备 → App:配置响应 + static const int setupRsp = 0x02; + + /// 设备 → App:文件数据 + static const int data = 0x03; + + /// 双向:取消 / 中断 + static const int cancel = 0x04; +} + +/// `DATA.control` +class RecDataControl { + RecDataControl._(); + + static const int cont = 0x00; + static const int end = 0x01; +} + +/// `CANCEL.reason` +class RecCancelReason { + RecCancelReason._(); + + static const int userCancel = 0; + static const int offsetMismatch = 1; + static const int timeout = 2; + static const int deviceError = 3; +} + +/// 状态码(协议文档 §5.3)。 +enum RecStatus { + ok(0x00), + paramErr(0x01), + busy(0x02), + noCard(0x03), + fsErr(0x04), + fileNotFound(0x05), + accessDenied(0x06), + unsupported(0x07), + noSpace(0x08), + cancelled(0x09); + + const RecStatus(this.code); + + final int code; + + bool get isOk => this == RecStatus.ok; + + static RecStatus fromCode(int code) { + for (final s in RecStatus.values) { + if (s.code == code) return s; + } + return RecStatus.fsErr; + } +} + +/// 文件系统类型(`REC_INFO.fs_type`)。 +class RecFsType { + RecFsType._(); + + static const int fat12 = 1; + static const int fat16 = 2; + static const int fat32 = 3; + static const int exfat = 4; + + static String describe(int v) { + switch (v) { + case fat12: + return 'FAT12'; + case fat16: + return 'FAT16'; + case fat32: + return 'FAT32'; + case exfat: + return 'exFAT'; + default: + return 'unknown($v)'; + } + } +} + +/// 危险操作的确认魔数(协议文档 §5.10 / §5.11)。 +class RecConfirm { + RecConfirm._(); + + /// "DEL!" + static const int delete = 0x44454C21; + + /// "FMT!" + static const int format = 0x464D5421; +} + +/// 协议里写死的时序常量(协议文档 §5.2 / §5.9)。 +class RecTiming { + RecTiming._(); + + /// 普通命令超时。超时后重发当前命令。 + static const Duration commandTimeout = Duration(seconds: 3); + + /// 文件传输期间「多久没收到新 DATA 就判超时」。 + /// + /// 注意这条**不能**照搬 [commandTimeout]:SETUP 之后设备是连续推数据的, + /// 中途一次卡顿就重来会让大文件永远传不完。 + static const Duration transferIdleTimeout = Duration(seconds: 5); +} + +/// 协议要求的文件名校验(协议文档 §5.5)。 +/// +/// 设备返回的名字也要过这一关——固件返回脏数据时,不校验就会把文件写到 +/// `\REC\` 以外的地方去。 +class RecFileName { + RecFileName._(); + + /// `name[]` 允许的最小总长度(含结尾 `\0`):1 个字符 + 1 个 `\0`。 + static const int minNameLen = 2; + + /// 校验一个**已含结尾 `\0`** 的原始 name 字节序列。 + static bool isValidRaw(List raw) { + if (raw.length < minNameLen) return false; + if (raw.last != 0x00) return false; + for (var i = 0; i < raw.length - 1; i++) { + final c = raw[i]; + // 中间不允许再出现 \0,也不允许路径分隔符 + if (c == 0x00 || c == 0x2F /* / */ || c == 0x5C /* \ */) return false; + } + return true; + } + + /// 校验一个 App 侧要发出去的文件名(不含 `\0`)。 + static bool isValid(String name) { + if (name.isEmpty) return false; + for (final c in name.codeUnits) { + if (c == 0x00 || c == 0x2F || c == 0x5C || c > 0x7F) return false; + } + return true; + } + + /// 编码成协议要求的 `name[]`:ASCII + 结尾 `\0`。 + static List encode(String name) => [...name.codeUnits, 0x00]; + + /// 从原始 `name[]` 解出字符串(去掉结尾 `\0`)。 + static String decode(List raw) => + String.fromCharCodes(raw.sublist(0, raw.length - 1)); +} diff --git a/apps/client/local_plugins/recorder_holder/lib/src/rec/rec_transport.dart b/apps/client/local_plugins/recorder_holder/lib/src/rec/rec_transport.dart new file mode 100644 index 00000000..14f485ab --- /dev/null +++ b/apps/client/local_plugins/recorder_holder/lib/src/rec/rec_transport.dart @@ -0,0 +1,97 @@ +import 'dart:io'; +import 'dart:typed_data'; + +/// 通道 B 的传输抽象。 +/// +/// 与 OTA 那边的 `FotTransport` 同一个思路:把状态机与具体 BLE 栈解耦, +/// 于是协议逻辑可以用假 transport 做单测,而真机上走原生 GATT。 +/// +/// ⚠️ **通道 B 不能用 flutter_blue_plus 自己再连一条**。一部手机对同一个 +/// peripheral 只能有一条 GATT 连接,而通道 A(控制 / 实时音频)已经被原生侧 +/// 占着了。OTA 之所以能用 flutter_blue_plus,是因为它会先 `setEnterOta(true)` +/// 把原生那条连接让出来——通道 B 要与通道 A 同时在线,没有这个条件。 +abstract class RecTransport { + /// 设备 → App 的原始 BLE value 流(`0xFDA6` notify),一次通知一个事件。 + Stream get incoming; + + /// App → 设备写一帧(`0xFDA7`)。 + Future send(List data); + + /// 通道是否就绪(GATT 已连接且 FDA5 服务已发现)。 + bool get isReady; +} + +/// 下载时的落盘出口。 +/// +/// 抽象出去有两个理由:单测不碰文件系统;以及 App 侧可能想边下边算时长/校验, +/// 不必都塞进协议层。 +/// +/// **只需要追加写**:DATA 按 `file_ofs` 顺序推,乱序包由 [RecClient] 负责丢弃或 +/// 重新 SETUP,所以 sink 永远只会被要求从 [length] 处往后接。这也让断点续传 +/// 天然成立——重连后 [length] 就是新的 `start_ofs`。 +abstract class RecFileSink { + /// 已写入的字节数,等于「期望的下一个 file_ofs」。 + int get length; + + Future append(List data); + + Future close(); +} + +/// 内存 sink,单测与小文件用。 +class MemoryRecFileSink implements RecFileSink { + final BytesBuilder _buf = BytesBuilder(); + + @override + int get length => _buf.length; + + @override + Future append(List data) async => _buf.add(data); + + @override + Future close() async {} + + Uint8List get bytes => _buf.toBytes(); +} + +/// 落到本地文件的 sink,带断点续传。 +/// +/// [length] 直接取文件当前大小,所以「上次没下完的那个文件」传进来就是续传, +/// 不需要额外记进度。 +class FileRecSink implements RecFileSink { + FileRecSink(this.file); + + final File file; + + RandomAccessFile? _raf; + int _length = 0; + + /// 打开文件并定位到末尾。文件不存在就新建。 + Future open() async { + if (!await file.parent.exists()) { + await file.parent.create(recursive: true); + } + _raf = await file.open(mode: FileMode.append); + _length = await _raf!.length(); + await _raf!.setPosition(_length); + } + + @override + int get length => _length; + + @override + Future append(List data) async { + final raf = _raf; + if (raf == null) throw StateError('FileRecSink 未 open()'); + await raf.writeFrom(data); + _length += data.length; + } + + @override + Future close() async { + // flush 之后再关:BLE 下载动辄几分钟,中途被杀进程时已落盘的部分要能续上 + await _raf?.flush(); + await _raf?.close(); + _raf = null; + } +} diff --git a/apps/client/local_plugins/recorder_holder/lib/src/recorder_holder.dart b/apps/client/local_plugins/recorder_holder/lib/src/recorder_holder.dart index 8b90a671..422ab02e 100644 --- a/apps/client/local_plugins/recorder_holder/lib/src/recorder_holder.dart +++ b/apps/client/local_plugins/recorder_holder/lib/src/recorder_holder.dart @@ -54,6 +54,7 @@ class RecorderHolder { // ==================== Typed streams (lazy) ==================== Stream? _audioDataStream; + Stream? _recDataStream; Stream? _connectionStateStream; Stream>? _scanResultStream; Stream? _deviceInfoStream; @@ -90,6 +91,23 @@ class RecorderHolder { return _audioDataStream!; } + /// 通道 B(TF 卡文件)的原始 BLE value(`0xFDA6` 通知)。 + /// + /// 原生不解析,一次通知一个事件。协议解析在 `lib/src/rec/` 的 [RecClient] 里, + /// 只有一份实现,Android / iOS 共用。 + Stream get onRecData { + _recDataStream ??= _dataStream + .where((event) => + event is Map && event['type'] == 'recData' && event['data'] != null) + .map((event) { + final data = event['data']; + if (data is Uint8List) return data; + if (data is List) return Uint8List.fromList(data); + return Uint8List(0); + }); + return _recDataStream!; + } + /// All status events (connection, scan, device info, recording) Stream get onStatusEvent { _statusEventStream ??= _statusStream @@ -190,6 +208,34 @@ class RecorderHolder { } } + /// 通道 B:写一帧到 `0xFDA7`。 + /// + /// [data] 是**已经组好帧**的完整 BLE value(含 0xA5 包头)——组帧由 + /// [RecFrame.build] 负责,原生只搬运。 + Future writeRecCommand(List data) async { + try { + return await _methodChannel.invokeMethod( + 'writeRecCommand', + {'data': Uint8List.fromList(data)}, + ) ?? + false; + } catch (e) { + return false; + } + } + + /// 通道 B 是否可用(已连接且发现了 `0xFDA5` 服务)。 + /// + /// 设备没有 TF 卡能力时为 false,此时通道 A 仍然正常工作。 + Future isRecChannelReady() async { + try { + return await _methodChannel.invokeMethod('isRecChannelReady') ?? + false; + } catch (e) { + return false; + } + } + /// Stop scanning for BLE devices Future stopScan() async { try { diff --git a/apps/client/local_plugins/recorder_holder/recorder_protocol(2)(1).md b/apps/client/local_plugins/recorder_holder/recorder_protocol(2)(1).md deleted file mode 100644 index cd713f49..00000000 --- a/apps/client/local_plugins/recorder_holder/recorder_protocol(2)(1).md +++ /dev/null @@ -1,489 +0,0 @@ -# BLE Recorder File Protocol Draft - -版本:V0.1 - -本文档定义 APP 通过 BLE 同步设备 TF 卡内容的私有协议草案,覆盖 TF 信息查询、文件列表、文件信息、文件传输、删除文件、格式化和录音状态上报。 - -## BLE GATT - -建议使用一个自定义 Service,两个 Characteristic。 - -| Characteristic | 方向 | 属性 | 用途 | -| --- | --- | --- | --- | -| CMD_RX | APP -> Device | Write 或 Write Without Response | APP 下发命令、传输配置、取消 | -| CMD_TX | Device -> APP | Notify | 设备返回响应、连续文件数据 | - -当前工程实现使用 16-bit UUID: - -| 项目 | UUID | -| --- | --- | -| Service | `0xFDA5` | -| CMD_TX | `0xFDA6` | -| CMD_RX | `0xFDA7` | - -文件传输使用 Notify,不使用 Indicate。Indicate 每包都需要 ATT 确认,吞吐会明显下降。 - -## 基础约定 - -- 所有多字节整数均为 little-endian。 -- 所有结构体均为 packed。 -- 每个 BLE value 均以 `ble_rec_pkt_hdr_t` 开头。 -- 请求和响应不再用 `type` 区分,由 BLE 方向和 `cmd` 决定。 -- 文件查询、传输、删除固定在 TF 卡 `\REC\` 目录下。 -- APP 只传文件名,不传路径,例如 `MIC0016.OGG`。 -- 第一版文件名固定使用 8.3 短文件名,建议大写 ASCII,例如 `MIC0016.OGG`。 -- 所有变长 `name[]` 均必须带字符串结束符 `\0`。 -- `name_len` 表示 `name[]` 总长度,包含结尾 `\0`。例如 `MIC0016.OGG` 的 `name_len = 12`。 -- 接收方应同时校验 `name_len` 和结尾 `\0`,两者任一异常都按参数错误处理。 -- `name_len` 最小值为 2,最后一个字节必须是 `0x00`,前面的文件名内容不能包含 `\` 或 `/`。 -- 协议版本不在线协商,APP 和固件按项目版本配套发布。后续新增字段尽量只在结构体末尾扩展。 - -结构体说明: - -- 文档中的 packed 结构体表示 BLE 线格式,不要求 APP 或固件直接对收包 buffer 做结构体指针强转。 -- 解析 `u16/u32` 时应按 little-endian 从字节流读取,避免非对齐访问问题。 -- `hdr.len` 表示 payload 长度,即 `BLE value 总长度 - sizeof(ble_rec_pkt_hdr_t)`。 -- 变长数组所在结构体的实际长度需要按字段长度计算,不能使用 `sizeof()` 直接得到完整包长。 - -示例宏: - -```c -#ifndef PACKED -#define PACKED __attribute__((packed)) -#endif -``` - -## 包头 - -包头保留 `magic/cmd/seq/len`,用于快速识别协议包、匹配请求响应、检查 payload 长度。 - -```c -#define BLE_REC_PKT_MAGIC 0xA5 - -typedef struct PACKED { - u8 magic; // BLE_REC_PKT_MAGIC - u8 cmd; // BLE_REC_CMD_xxx - u8 seq; // 发送方独立递增序号 - u16 len; // payload 长度,不包含本包头 -} ble_rec_pkt_hdr_t; -``` - -约定: - -- APP -> Device 和 Device -> APP 两个方向的 `seq` 独立递增。 -- `seq` 为 u8,递增到 0xFF 后回绕到 0x00。 -- 设备响应不回填 APP 请求的 `seq`,而是使用设备侧自己的响应 `seq`。 -- 第一版协议按单事务处理:APP 发出请求后,等待对应 `cmd` 的设备响应,再发下一个普通请求。 -- 录音状态变化时的 `REC_INFO` 和录音停止后紧随的 `FILE_LIST` 是设备主动 Notify,不对应 APP 请求。 -- 文件传输 DATA 使用设备侧连续递增 `seq`,数据顺序和缺口主要由 `file_ofs` 判断。 -- `len` 必须等于 BLE value 长度减 `sizeof(ble_rec_pkt_hdr_t)`。 -- 收到 `magic` 不匹配或 `len` 不匹配的包,直接丢弃。 -- 普通命令建议 APP 使用 3 秒超时;超时后可重新发送当前命令。 - -## MTU 和数据长度 - -BLE ATT Notify/Write 的应用 value 最大长度通常为: - -```text -ble_value_max = att_mtu - 3 -``` - -协议本身不再协商 `max_payload`。设备端从 BLE 栈获取当前 MTU 后自行计算单包最大 data 长度。 - -文件 DATA 包最大数据长度: - -```text -data_max = att_mtu - 3 - sizeof(ble_rec_pkt_hdr_t) - sizeof(action_cmd) - - sizeof(file_ofs) - sizeof(control) - sizeof(sum) - -data_max = att_mtu - 16 -``` - -例:`att_mtu = 247` 时,`data_max = 231`。 - -设备实现时可再取 `min(data_max, 512)`,避免文件系统读缓存过大。 - -## 命令总览 - -| Cmd | 名称 | 说明 | 第一版建议 | -| ---: | --- | --- | --- | -| 0x01 | REC_INFO | 查询 TF 信息和录音状态;录音状态变化时设备主动上报 | 必做 | -| 0x02 | FILE_LIST | 获取完整文件列表 | 必做 | -| 0x03 | FILE_INFO | 查询指定文件信息 | 可选 | -| 0x04 | FILE_TRANSFER | 文件传输,设备连续 Notify | 必做 | -| 0x05 | FILE_DELETE | 删除文件 | 第二步 | -| 0x06 | REC_FORMAT | 格式化 TF 卡 | 第二步 | -| 0x07 | RTC_SYNC | APP 同步设备 RTC 时间 | 必做 | - -说明: - -- `FILE_INFO` 不是首版必需,因为 `FILE_LIST` 已返回文件大小和时间;保留它是为了 APP 删除/下载前单独刷新文件状态。 -- 删除和格式化属于危险操作,建议等列表和传输稳定后再接。 - -## 状态码 - -响应结构体中的 `status` 使用以下取值。 - -```c -enum { - BLE_REC_ST_OK = 0x00, - BLE_REC_ST_PARAM_ERR = 0x01, - BLE_REC_ST_BUSY = 0x02, - BLE_REC_ST_NO_CARD = 0x03, - BLE_REC_ST_FS_ERR = 0x04, - BLE_REC_ST_FILE_NOT_FOUND = 0x05, - BLE_REC_ST_ACCESS_DENIED = 0x06, - BLE_REC_ST_UNSUPPORTED = 0x07, - BLE_REC_ST_NO_SPACE = 0x08, - BLE_REC_ST_CANCELLED = 0x09, -}; -``` - -## 0x01 REC_INFO - -查询 TF 卡信息和录音状态。容量单位为 KB;录音开始、停止时,设备也使用此响应结构主动 Notify。 - -```c -#define BLE_REC_CMD_INFO 0x01 - -typedef struct PACKED { - ble_rec_pkt_hdr_t hdr; // cmd = BLE_REC_CMD_INFO, len = 0 -} ble_rec_info_req_t; - -typedef struct PACKED { - ble_rec_pkt_hdr_t hdr; // cmd = BLE_REC_CMD_INFO - u8 status; // BLE_REC_ST_xxx - u8 fs_type; // 1:FAT12 / 2:FAT16 / 3:FAT32 / 4:exFAT - u32 total_kb; // TF 总容量,单位 KB - u32 used_kb; // 已使用容量,单位 KB - u8 rec_state; // 0:已停止 / 1:录音中 -} ble_rec_info_rsp_t; -``` - -`u32` KB 单位最大可表示约 4TB,足够覆盖当前 128MB TF 卡和后续常见容量。 - -`used_kb` 建议按 `total_kb - free_kb` 计算。如果当前文件系统接口暂时无法快速获取剩余空间,可以后续补实现,但协议字段保持不变。 - -`rec_state` 始终有效。如果 `status != BLE_REC_ST_OK`,APP 仍可更新录音状态,其余信息字段按 0 处理。 - -录音状态变化时,设备主动发送 `REC_INFO`,`status = BLE_REC_ST_OK`。其中容量信息取最近一次成功查询的结果,APP 应以 `rec_state` 为主要关注字段。录音停止状态发送成功后,设备主动发送完整 `FILE_LIST`,用于刷新新生成的录音文件。 - -## 0x02 FILE_LIST - -获取 `\REC\` 目录下的完整文件列表。APP 只发一次请求,设备固定返回所有文件条目;如果一包放不下,设备连续 Notify 多个 FILE_LIST 响应包,直到 `eof = 1`。 - -文件顺序由 FAT 目录项扫描顺序决定,不保证按文件名或时间排序。APP 如需排序,建议本地排序。 - -```c -#define BLE_REC_CMD_FILE_LIST 0x02 - -typedef struct PACKED { - ble_rec_pkt_hdr_t hdr; // cmd = BLE_REC_CMD_FILE_LIST, len = 0 -} ble_rec_file_list_req_t; - -typedef struct PACKED { - u32 size; // 文件大小 - u32 ftime; // FAT 时间,格式同 fs_get_ftime() - u8 name_len; // name[] 总长度,包含结尾 '\0' - u8 name[]; // 文件名,变长,以 '\0' 结尾 -} ble_rec_file_item_t; - -typedef struct PACKED { - ble_rec_pkt_hdr_t hdr; // cmd = BLE_REC_CMD_FILE_LIST - u8 status; // BLE_REC_ST_xxx - u8 item_count; // 本包 item 数量 - u8 eof; // 1: 所有文件列表返回结束 - u8 items[]; // ble_rec_file_item_t 数组,逐项变长解析 -} ble_rec_file_list_rsp_t; -``` - -建议: - -- 默认固定扫描 `\REC\` 目录,过滤隐藏/系统文件。 -- 如果 `\REC\` 目录不存在,返回 `status = BLE_REC_ST_OK`、`item_count = 0`、`eof = 1`。 -- 如果 TF 卡不存在,返回 `status = BLE_REC_ST_NO_CARD`、`item_count = 0`、`eof = 1`。 -- 设备每个响应包尽量填满当前 MTU 可容纳的 item。 -- 单个 `ble_rec_file_item_t` 不跨 BLE 包;如果当前剩余空间放不下完整 item,则放到下一个 FILE_LIST 响应包。 -- 每个 item 的实际长度为 `sizeof(size) + sizeof(ftime) + sizeof(name_len) + name_len`,其中 `name_len` 包含结尾 `\0`。 -- APP 解析时应同时校验 `hdr.len` 和 `item_count`,避免异常包导致越界解析。 -- 如果文件列表需要多包返回,除最后一包外 `eof = 0`,最后一包 `eof = 1`。 -- 删除或新增文件后,APP 重新发送一次 FILE_LIST 获取完整列表。 - -## 0x03 FILE_INFO - -查询指定文件信息。该命令可选,首版可以先不实现。 - -```c -#define BLE_REC_CMD_FILE_INFO 0x03 - -typedef struct PACKED { - ble_rec_pkt_hdr_t hdr; // cmd = BLE_REC_CMD_FILE_INFO - u8 name_len; // name[] 总长度,包含结尾 '\0' - u8 name[]; // \REC\ 目录下的文件名,以 '\0' 结尾 -} ble_rec_file_info_req_t; - -typedef struct PACKED { - ble_rec_pkt_hdr_t hdr; // cmd = BLE_REC_CMD_FILE_INFO - u8 status; // BLE_REC_ST_xxx - u32 size; // 文件大小 - u32 ftime; // FAT 时间 -} ble_rec_file_info_rsp_t; -``` - -如果 `status != BLE_REC_ST_OK`,APP 只处理 `status` 字段,其余字段按 0 处理。 - -## 0x04 FILE_TRANSFER - -文件传输使用一个命令,通过 `action_cmd` 区分配置、数据、取消。 - -核心策略: - -- APP 发一次 SETUP。 -- 设备返回 SETUP_RSP 后连续 Notify DATA。 -- APP 不逐包请求数据。 -- 设备同一时间只允许一个文件传输,DATA/CANCEL 默认作用于当前传输。 -- 如果 APP 发现 offset 缺口,发送 CANCEL,然后重新 SETUP,从期望 offset 继续。 - -### 结构体 - -```c -#define BLE_REC_CMD_FILE_TRANSFER 0x04 - -enum { - BLE_REC_TRANS_SETUP = 0x01, // APP -> Device,设置传输配置 - BLE_REC_TRANS_SETUP_RSP = 0x02, // Device -> APP,配置响应 - BLE_REC_TRANS_DATA = 0x03, // Device -> APP,文件数据 - BLE_REC_TRANS_CANCEL = 0x04, // 双向,取消/中断传输 -}; - -enum { - BLE_REC_DATA_CONTINUE = 0x00, // 继续 - BLE_REC_DATA_END = 0x01, // 本包后传输结束 -}; - -typedef struct PACKED { - ble_rec_pkt_hdr_t hdr; // cmd = BLE_REC_CMD_FILE_TRANSFER - u8 action_cmd; // BLE_REC_TRANS_xxx - union { - /* BLE_REC_TRANS_SETUP: APP -> Device */ - struct PACKED { - u32 start_ofs; // 起始偏移,断点续传时使用 - u8 name_len; // name[] 总长度,包含结尾 '\0' - u8 name[]; // \REC\ 目录下的文件名,以 '\0' 结尾 - } setup; - - /* BLE_REC_TRANS_SETUP_RSP: Device -> APP */ - struct PACKED { - u8 status; // BLE_REC_ST_xxx - u32 file_size; // 文件总大小 - u32 start_ofs; // 实际起始偏移 - u32 ftime; // FAT 时间 - u16 data_len; // 设备实际采用的单包 data 长度 - } setup_rsp; - - /* BLE_REC_TRANS_DATA: Device -> APP */ - struct PACKED { - u32 file_ofs; // 本包 data 在文件中的偏移 - u8 control; // BLE_REC_DATA_CONTINUE / BLE_REC_DATA_END - u16 sum; // 本包校验和,计算时 sum 字段按 0 处理 - u8 data[]; // 文件数据 - } data; - - /* BLE_REC_TRANS_CANCEL: 双向 */ - struct PACKED { - u8 reason; // 0:用户取消 / 1:offset异常 / 2:超时 / 3:设备错误 - } cancel; - }; -} ble_rec_file_transfer_t; -``` - -SETUP_RSP 说明: - -- 如果 `status != BLE_REC_ST_OK`,APP 只处理 `status` 字段,其余字段按 0 处理。 -- 如果 `status == BLE_REC_ST_OK`,APP 保存 `file_size/start_ofs/data_len`,后续 DATA/CANCEL 都默认属于当前传输。 -- 当前传输结束、取消、超时或 BLE 断开后,设备清空当前传输状态;断点续传需要重新 SETUP。 - -DATA 实际数据长度: - -```text -data_actual_len = hdr.len - sizeof(action_cmd) - - sizeof(file_ofs) - - sizeof(control) - - sizeof(sum) - -data_actual_len = hdr.len - 8 -``` - -APP 必须使用 `data_actual_len` 解析 `data[]`,不要使用 SETUP_RSP 里的 `data_len` 作为每包固定长度。最后一包通常会小于 `data_len`。 - -### DATA 校验和 - -`sum` 是一个轻量 sanity check,不用于替代 BLE 链路层 CRC。 - -建议算法: - -```c -u16 sum = 0; -for (i = 0; i < packet_len; i++) { - sum += packet[i]; -} -``` - -校验范围为整个 BLE value,即从 `hdr.magic` 到 `data[]` 最后一个字节。计算时 `sum` 字段两个字节按 0 处理。APP 收到 DATA 后按同样方式计算,结果一致才写入本地文件。 - -如果实测 CPU 开销影响吞吐,可以后续约定 `sum = 0` 表示不启用 DATA 校验和;第一版建议先保留,方便调试。 - -### 设备发送策略 - -- 如果 `\REC\` 目录不存在或目标文件不存在,SETUP_RSP 返回 `BLE_REC_ST_FILE_NOT_FOUND`。 -- SETUP 成功后,设备尽快连续发送 DATA。 -- 能发就发,直到 BLE TX 队列满、文件结束、收到 CANCEL 或文件系统错误。 -- 如果传输过程中发生文件系统错误,设备发送 `BLE_REC_TRANS_CANCEL`,`reason = 3`。 -- BLE TX 队列满时不要忙等,等 notify complete/tx credit 回调后继续发送。 -- 每次从 TF 读取的长度不超过 `data_len`,复用一个静态缓冲。 -- 长文件传输循环中定期喂狗。 -- 最后一包 DATA 的 `control = BLE_REC_DATA_END`。如果文件大小刚好按 data_len 对齐,可以发送一个 0 字节 END 包。 - -### APP 接收策略 - -- 按 `file_ofs` 写入本地文件。 -- 如果收到重复 DATA,可按 offset 去重。 -- 如果 `file_ofs` 不等于期望 offset,发送 CANCEL,然后重新 SETUP,从期望 offset 继续。 -- 如果 APP 超过 5 秒没有收到新的 DATA 或 CANCEL,可认为传输超时,重新 SETUP 从已保存 offset 续传。 - -## 0x05 FILE_DELETE - -删除指定文件。 - -```c -#define BLE_REC_CMD_FILE_DELETE 0x05 -#define BLE_REC_DELETE_CONFIRM 0x44454C21 // "DEL!" - -typedef struct PACKED { - ble_rec_pkt_hdr_t hdr; // cmd = BLE_REC_CMD_FILE_DELETE - u32 confirm; // BLE_REC_DELETE_CONFIRM - u8 name_len; // name[] 总长度,包含结尾 '\0' - u8 name[]; // \REC\ 目录下的文件名,以 '\0' 结尾 -} ble_rec_file_delete_req_t; - -typedef struct PACKED { - ble_rec_pkt_hdr_t hdr; // cmd = BLE_REC_CMD_FILE_DELETE - u8 status; // BLE_REC_ST_xxx -} ble_rec_file_delete_rsp_t; -``` - -安全要求: - -- 录音中返回 `BLE_REC_ST_BUSY`。 -- 文件传输中返回 `BLE_REC_ST_BUSY`,或要求 APP 先 CANCEL。 -- 如果 `\REC\` 目录不存在或目标文件不存在,返回 `BLE_REC_ST_FILE_NOT_FOUND`。 -- APP UI 侧必须二次确认。 - -## 0x06 REC_FORMAT - -格式化 TF 卡。 - -```c -#define BLE_REC_CMD_FORMAT 0x06 -#define BLE_REC_FORMAT_CONFIRM 0x464D5421 // "FMT!" - -typedef struct PACKED { - ble_rec_pkt_hdr_t hdr; // cmd = BLE_REC_CMD_FORMAT - u32 confirm; // BLE_REC_FORMAT_CONFIRM - u8 fs_type; // 0: 自动 -} ble_rec_format_req_t; - -typedef struct PACKED { - ble_rec_pkt_hdr_t hdr; // cmd = BLE_REC_CMD_FORMAT - u8 status; // BLE_REC_ST_xxx -} ble_rec_format_rsp_t; -``` - -安全要求: - -- 录音中返回 `BLE_REC_ST_BUSY`。 -- 文件传输中返回 `BLE_REC_ST_BUSY`。 -- 低电量返回 `BLE_REC_ST_ACCESS_DENIED`。 -- TF 卡不存在返回 `BLE_REC_ST_NO_CARD`。 -- APP UI 侧必须二次确认。 - -## 0x07 RTC_SYNC - -APP 将本地时间同步到设备 RTC。设备后续创建录音文件时,使用 RTC 时间生成 FAT 创建时间。 - -```c -#define BLE_REC_CMD_RTC_SYNC 0x07 - -typedef struct PACKED { - ble_rec_pkt_hdr_t hdr; // cmd = BLE_REC_CMD_RTC_SYNC - u16 year; // 1980~2106 - u8 month; // 1~12 - u8 day; // 1~31,设备按年月校验 - u8 hour; // 0~23 - u8 minute; // 0~59 - u8 second; // 0~59 -} ble_rec_rtc_sync_req_t; - -typedef struct PACKED { - ble_rec_pkt_hdr_t hdr; // cmd = BLE_REC_CMD_RTC_SYNC - u8 status; // BLE_REC_ST_xxx -} ble_rec_rtc_sync_rsp_t; -``` - -说明: -- 时间按设备本地时间理解,不做时区字段协商。 -- 建议 APP 在 BLE 连接成功后主动同步一次 RTC,再查询/下载文件。 -- 如果设备 RTC 未同步或时间无效,录音文件创建时间会回退到旧的递增时间策略,避免写入非法 FAT 时间。 - -## 典型流程 - -### 枚举文件 - -1. APP 使能 CMD_TX Notify。 -2. APP -> `RTC_SYNC`。 -3. APP -> `REC_INFO`。 -4. APP -> `FILE_LIST`。 -5. 设备连续返回 FILE_LIST 响应包,APP 收集 item,直到 `eof = 1`。 - -### 下载文件,最高速度模式 - -1. APP -> `FILE_TRANSFER / SETUP`,`start_ofs = 0`。 -2. 设备 -> `FILE_TRANSFER / SETUP_RSP`。 -3. 设备连续 Notify `FILE_TRANSFER / DATA`。 -4. APP 按 offset 写文件。 -5. APP 收到 `control = BLE_REC_DATA_END` 后,确认本地文件大小等于 `file_size`。 - -### 断点续传 - -1. APP 本地已有 `saved_offset`。 -2. APP -> `FILE_TRANSFER / SETUP`,`start_ofs = saved_offset`。 -3. 设备从该 offset 开始连续发送 DATA。 - -### 删除文件 - -1. APP UI 二次确认。 -2. APP -> `FILE_DELETE(confirm = "DEL!")`。 -3. 设备返回 OK 后,APP 重新 `FILE_LIST`。 - -### 格式化 - -1. APP UI 二次确认。 -2. APP -> `REC_FORMAT(confirm = "FMT!")`。 -3. 成功后 APP 重新 `REC_INFO` 和 `FILE_LIST`。 - -## 第一版实现建议 - -第一版建议只实现: - -- `REC_INFO` -- `RTC_SYNC` -- `FILE_LIST` -- `FILE_TRANSFER` - -之后再补: - -- `FILE_INFO` -- `FILE_DELETE` -- `REC_FORMAT` - -这样可以先把 APP 浏览和高速下载主链路跑通,再加危险操作。 diff --git a/apps/client/docs/recorder_protocol.md b/apps/client/local_plugins/recorder_holder/recorder_protocol.md similarity index 100% rename from apps/client/docs/recorder_protocol.md rename to apps/client/local_plugins/recorder_holder/recorder_protocol.md diff --git a/apps/client/local_plugins/recorder_holder/sdk/lib/echomeet_device_sdk.dart b/apps/client/local_plugins/recorder_holder/sdk/lib/echomeet_device_sdk.dart index 728cf7af..ec5207d5 100644 --- a/apps/client/local_plugins/recorder_holder/sdk/lib/echomeet_device_sdk.dart +++ b/apps/client/local_plugins/recorder_holder/sdk/lib/echomeet_device_sdk.dart @@ -11,3 +11,20 @@ library echomeet_device_sdk; export 'src/models.dart'; export 'src/client.dart'; + +// 通道 B(TF 卡文件)的协议类型:文件列表项、状态码、下载结果等。 +// 业务层拿 EchoMeetDeviceSdk.instance.recorder 用它们。 +export 'package:recorder_holder/recorder_holder_rec.dart' + show + RecClient, + RecCancelToken, + RecDownloadResult, + RecException, + RecFileInfo, + RecFileItem, + RecFileSink, + FileRecSink, + RecFsType, + RecInfo, + RecStatus, + MemoryRecFileSink; diff --git a/apps/client/local_plugins/recorder_holder/sdk/lib/src/client.dart b/apps/client/local_plugins/recorder_holder/sdk/lib/src/client.dart index 18ade3cf..7ef3c0ce 100644 --- a/apps/client/local_plugins/recorder_holder/sdk/lib/src/client.dart +++ b/apps/client/local_plugins/recorder_holder/sdk/lib/src/client.dart @@ -3,6 +3,7 @@ import 'dart:io' show Platform; import 'package:flutter/foundation.dart'; import 'package:recorder_holder/recorder_holder.dart'; +import 'package:recorder_holder/recorder_holder_rec.dart'; import 'models.dart'; @@ -11,8 +12,8 @@ import 'models.dart'; /// 封装底层 [RecorderHolder],对外只暴露:搜索、连接、录音、状态监听。 /// **不包含** OSS 上传、账号、会议等业务。 /// -/// 支持设备(蓝牙名): -/// - `Echomeet`(录音支架) +/// 支持设备(蓝牙名前缀): +/// - `EaiRec`(AI 手机支架,带 TF 卡存储) /// - `Pad Note`(UI 名 AI Pencil) class EchoMeetDeviceSdk { EchoMeetDeviceSdk._(); @@ -21,8 +22,45 @@ class EchoMeetDeviceSdk { final RecorderHolder _plugin = RecorderHolder(); - /// 默认只保留 EchoMeet 产品线广播名。 - static const List defaultNameFilters = ['Echomeet', 'Pad Note']; + /// 通道 B(TF 卡文件)客户端。 + /// + /// 协议状态机在 recorder_holder 的 `lib/src/rec/` 里,这里只负责在连接建立/ + /// 断开时挂上和摘掉。设备没有 TF 卡能力时 [recChannelReady] 为 false, + /// 调用会以超时告终——所以用之前先看那个标志。 + late final RecNativeTransport _recTransport = RecNativeTransport(plugin: _plugin); + late final RecClient recorder = RecClient( + transport: _recTransport, + log: (m) => debugPrint('[EaiRec/通道B] $m'), + ); + + bool _recReady = false; + + /// 通道 B 是否可用(已连接且设备提供了 0xFDA5 服务)。 + bool get recChannelReady => _recReady; + + /// 默认保留的广播名**前缀**(大小写不敏感)。 + /// + /// ⚠️ **`EaiRec` 是按产品名推的,尚未在真机上确认**(见 EaiRec_protocol.md + /// 的待确认项 Q1)。名字对不上的表现是**扫描页一台设备都不出现**,而不会 + /// 报任何错——真机第一次扫不到时先查这里,别去怀疑蓝牙权限。 + /// + /// 改成前缀匹配而不是原来的全等:固件常在名字后面挂序列号后缀 + /// (`EaiRec-1A2B`),全等匹配会把这类设备全滤掉。 + /// 扫描保留的广播名**前缀**(大小写不敏感,`startsWith` 匹配)。 + /// + /// ★ **这是切换设备名的唯一入口**(协议 §2.1:App 不做 Service UUID 过滤, + /// 只按设备名前缀筛)。名字对不上的表现是**扫描页一台设备都不出现,且不报任何错**。 + /// + /// 2026-09-10 现状:**在用的支架固件广播名是 `Echomeet`**(用户实测)。 + /// `EaiRec` 是产品改名后的目标名,固件还没刷,先都留着: + /// - 设备刷成 `EaiRec` 之后,这里不用改也能扫到(两个前缀都在); + /// - 真要只认新名,把 `Echomeet` 从列表里删掉即可。 + static const List defaultNameFilters = [ + 'Echomeet', // ← 当前在用的固件广播名 + 'EaiRec', // ← 改名后的目标名 + 'EAIMAR', + 'Pad Note', + ]; List _nameFilters = List.from(defaultNameFilters); @@ -98,8 +136,15 @@ class EchoMeetDeviceSdk { if (!_connectionController.isClosed) { _connectionController.add(_connectionState); } + if (_connectionState == EchoMeetConnectionState.connected) { + // 服务发现是异步的,连接状态先到、0xFDA5 特征后到, + // 所以这里只是探一次,真正的判定交给 ensureRecChannel()。 + unawaited(ensureRecChannel()); + } if (_connectionState == EchoMeetConnectionState.disconnected || _connectionState == EchoMeetConnectionState.error) { + _recReady = false; + unawaited(recorder.detach()); _emitRecording(const EchoMeetRecordingState()); } }); @@ -169,6 +214,8 @@ class EchoMeetDeviceSdk { _recSub = null; _infoSub = null; _audioSub = null; + _recReady = false; + await recorder.detach(); _bound = false; } @@ -198,10 +245,19 @@ class EchoMeetDeviceSdk { .toList(); } - /// 连接设备。 + /// 连接设备。协议 §8.1 第 3 步:**扫描之后要真的发起 GATT 连接**。 /// - /// - iOS:`startDeviceAssociation` - /// - Android:`registerBackgroundScan` + /// - iOS:`startDeviceAssociation` → 原生 `connectToDevice(uuid:)`,本身就是直连。 + /// - Android:`registerBackgroundScan` **只是注册系统后台扫描的 PendingIntent** + /// (`savePairedMacAddress` + `ScanFilter(mac)` + `SCAN_MODE_LOW_POWER`), + /// 它一次 `connectGatt` 都不发;真正连接要等系统扫到广播 → `BleWakeReceiver` + /// (还带 10s 去抖)→ 拉起前台服务才发生。 + /// + /// ⚠️ 以前这里到此为止就 `return true`,于是「点连接」立刻成功、 + /// `waitConnected()` 干等 25 秒超时 —— 表现就是**扫得到但永远连不上**。 + /// 现在注册完后台回连后**立刻再直连一次**(原生早就暴露了 `connectToDevice` + /// → `BleService.connect(mac)` → `device.connectGatt`,只是 Dart 侧没调)。 + /// 两者不冲突:后台注册管「设备下次靠近自动连」,直连管「用户现在就要连」。 Future connect({ required String name, required String address, @@ -223,7 +279,13 @@ class EchoMeetDeviceSdk { macAddress: address, ); } else { - ok = await _plugin.registerBackgroundScan(macAddress: address); + // 后台回连注册失败不阻断本次连接:它只影响「下次设备靠近自动连」 + final registered = await _plugin.registerBackgroundScan(macAddress: address); + if (!registered) { + // ignore: avoid_print + print('[EaiRec] 后台扫连注册失败,仅影响自动回连,继续直连'); + } + ok = await _plugin.connectToDevice(address); } if (!ok) { @@ -235,6 +297,17 @@ class EchoMeetDeviceSdk { return ok; } + /// 确认通道 B 是否就绪,就绪就挂上收包。 + /// + /// 可以重复调用:连接状态回调、以及业务层第一次要用文件功能时都会调一次。 + /// 服务发现比连接状态晚到,只在连接回调里探一次会漏。 + Future ensureRecChannel() async { + final ready = await _recTransport.refreshReady(); + _recReady = ready; + if (ready) recorder.attach(); + return ready; + } + Future connectDevice(EchoMeetScannedDevice device) { return connect(name: device.name, address: device.address); } @@ -320,7 +393,9 @@ class EchoMeetDeviceSdk { bool _matchesFilter(String name) { if (_nameFilters.isEmpty) return true; - return _nameFilters.any((f) => f == name); + final n = name.trim().toLowerCase(); + if (n.isEmpty) return false; + return _nameFilters.any((f) => n.startsWith(f.toLowerCase())); } void _emitRecording(EchoMeetRecordingState state) { diff --git a/apps/client/local_plugins/recorder_holder/test/rec/rec_client_test.dart b/apps/client/local_plugins/recorder_holder/test/rec/rec_client_test.dart new file mode 100644 index 00000000..9bc65f4c --- /dev/null +++ b/apps/client/local_plugins/recorder_holder/test/rec/rec_client_test.dart @@ -0,0 +1,458 @@ +import 'dart:async'; +import 'dart:typed_data'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:recorder_holder/recorder_holder_rec.dart'; + +import 'rec_frame_test.dart' show buildDataPacket; + +/// 可编程的假设备:把 App 发来的帧记下来,并按剧本回包。 +class FakeDevice implements RecTransport { + final _ctrl = StreamController.broadcast(); + final List sent = []; + + /// 收到一帧时的回调,用来编排设备行为。 + void Function(RecFrame frame)? onFrame; + + @override + Stream get incoming => _ctrl.stream; + + @override + bool get isReady => true; + + @override + Future send(List data) async { + final bytes = Uint8List.fromList(data); + sent.add(bytes); + final f = RecFrame.tryParse(bytes); + if (f != null) onFrame?.call(f); + } + + void push(List bytes) => _ctrl.add(Uint8List.fromList(bytes)); + + /// 设备侧回一个「只有 status 一个字节」的响应。 + void reply(int cmd, int status) => + push(RecFrame.build(cmd: cmd, seq: 0, payload: [status])); + + void pushSetupRsp({ + required int fileSize, + required int startOfs, + int status = 0, + int dataLen = 231, + }) { + push(RecFrame.build( + cmd: RecCmd.fileTransfer, + seq: 0, + payload: [ + RecTransferAction.setupRsp, + status, + ...RecBytes.u32le(fileSize), + ...RecBytes.u32le(startOfs), + ...RecBytes.u32le(0), + ...RecBytes.u16le(dataLen), + ], + )); + } + + void pushData({ + required int fileOfs, + required List data, + bool end = false, + int? forceSum, + }) { + push(buildDataPacket( + seq: 0, + fileOfs: fileOfs, + control: end ? RecDataControl.end : RecDataControl.cont, + data: data, + forceSum: forceSum, + )); + } + + Future dispose() => _ctrl.close(); +} + +/// 取出最后一次发出去的帧 +RecFrame lastFrame(FakeDevice d) => RecFrame.tryParse(d.sent.last)!; + +void main() { + late FakeDevice dev; + late RecClient client; + + setUp(() { + dev = FakeDevice(); + client = RecClient( + transport: dev, + // 协议值是 3s / 5s,单测里调小,否则每个超时用例都要真等 + commandTimeout: const Duration(milliseconds: 200), + idleTimeout: const Duration(milliseconds: 300), + ); + client.attach(); + }); + + tearDown(() async { + await client.dispose(); + await dev.dispose(); + }); + + group('RTC_SYNC', () { + test('按设备本地时间下发,不转 UTC', () async { + dev.onFrame = (f) => dev.reply(RecCmd.rtcSync, RecStatus.ok.code); + await client.syncRtc(DateTime(2026, 9, 8, 13, 45, 30)); + + final f = RecFrame.tryParse(dev.sent.single)!; + expect(f.cmd, RecCmd.rtcSync); + expect(RecBytes.readU16le(f.payload, 0), 2026); + expect(f.payload[2], 9); + expect(f.payload[3], 8); + expect(f.payload[4], 13); + expect(f.payload[5], 45); + expect(f.payload[6], 30); + }); + + test('设备回非 OK 时抛出并带上状态码', () async { + dev.onFrame = (f) => dev.reply(RecCmd.rtcSync, RecStatus.paramErr.code); + expect( + () => client.syncRtc(DateTime(2026, 1, 1)), + throwsA(isA() + .having((e) => e.status, 'status', RecStatus.paramErr)), + ); + }); + }); + + group('单事务与超时', () { + test('无响应时超时报错', () async { + await expectLater(client.queryInfo(), throwsA(isA())); + }); + + test('超时后事务槽释放,下一条命令还能发', () async { + await expectLater(client.queryInfo(), throwsA(isA())); + dev.onFrame = (f) => dev.reply(RecCmd.rtcSync, RecStatus.ok.code); + await client.syncRtc(DateTime(2026, 1, 1)); + }); + + test('协议规定的超时值没有被改动', () { + expect(RecTiming.commandTimeout, const Duration(seconds: 3)); + expect(RecTiming.transferIdleTimeout, const Duration(seconds: 5)); + }); + }); + + group('FILE_LIST 多包累积', () { + test('收齐 eof=1 才返回,且顺序保持', () async { + List item(String name) { + final n = RecFileName.encode(name); + return [...RecBytes.u32le(1), ...RecBytes.u32le(0), n.length, ...n]; + } + + dev.onFrame = (f) { + if (f.cmd != RecCmd.fileList) return; + dev.push(RecFrame.build( + cmd: RecCmd.fileList, + seq: 0, + payload: [RecStatus.ok.code, 1, 0, ...item('A.OGG')], + )); + dev.push(RecFrame.build( + cmd: RecCmd.fileList, + seq: 1, + payload: [RecStatus.ok.code, 1, 0, ...item('B.OGG')], + )); + dev.push(RecFrame.build( + cmd: RecCmd.fileList, + seq: 2, + payload: [RecStatus.ok.code, 1, 1, ...item('C.OGG')], + )); + }; + + final list = await client.listFiles(); + expect(list.map((e) => e.name), ['A.OGG', 'B.OGG', 'C.OGG']); + }); + + test('无人等待时的 FILE_LIST 走「设备主动推」流', () async { + final n = RecFileName.encode('Z.OGG'); + final pushed = client.onFileListPushed.first; + dev.push(RecFrame.build( + cmd: RecCmd.fileList, + seq: 0, + payload: [ + RecStatus.ok.code, + 1, + 1, + ...RecBytes.u32le(5), + ...RecBytes.u32le(0), + n.length, + ...n, + ], + )); + expect((await pushed).single.name, 'Z.OGG'); + }); + }); + + group('REC_INFO 主动上报', () { + test('录音状态变化推到 onRecInfo', () async { + final first = client.onRecInfo.first; + dev.push(RecFrame.build( + cmd: RecCmd.recInfo, + seq: 0, + payload: [ + RecStatus.ok.code, + RecFsType.fat32, + ...RecBytes.u32le(100), + ...RecBytes.u32le(10), + 1, + ], + )); + expect((await first).recording, isTrue); + }); + }); + + group('下载', () { + test('顺序收包直到 END', () async { + dev.onFrame = (f) { + if (f.cmd != RecCmd.fileTransfer) return; + if (f.payload[0] != RecTransferAction.setup) return; + dev.pushSetupRsp(fileSize: 6, startOfs: 0); + dev.pushData(fileOfs: 0, data: [1, 2, 3]); + dev.pushData(fileOfs: 3, data: [4, 5, 6], end: true); + }; + + final sink = MemoryRecFileSink(); + final r = await client.download(name: 'MIC0016.OGG', sink: sink); + expect(r.complete, isTrue); + expect(r.resumes, 0); + expect(sink.bytes, [1, 2, 3, 4, 5, 6]); + }); + + test('重复包按 offset 去重,不会写两遍', () async { + dev.onFrame = (f) { + if (f.cmd != RecCmd.fileTransfer) return; + if (f.payload[0] != RecTransferAction.setup) return; + dev.pushSetupRsp(fileSize: 4, startOfs: 0); + dev.pushData(fileOfs: 0, data: [1, 2]); + dev.pushData(fileOfs: 0, data: [1, 2]); // 重复 + dev.pushData(fileOfs: 2, data: [3, 4], end: true); + }; + + final sink = MemoryRecFileSink(); + await client.download(name: 'A.OGG', sink: sink); + expect(sink.bytes, [1, 2, 3, 4]); + }); + + test('部分重叠的包只接尾巴', () async { + dev.onFrame = (f) { + if (f.cmd != RecCmd.fileTransfer) return; + if (f.payload[0] != RecTransferAction.setup) return; + dev.pushSetupRsp(fileSize: 4, startOfs: 0); + dev.pushData(fileOfs: 0, data: [1, 2]); + dev.pushData(fileOfs: 1, data: [2, 3, 4], end: true); // 前 1 字节是旧的 + }; + + final sink = MemoryRecFileSink(); + await client.download(name: 'A.OGG', sink: sink); + expect(sink.bytes, [1, 2, 3, 4]); + }); + + test('offset 缺口触发 CANCEL + 从期望 offset 重新 SETUP', () async { + var setups = 0; + dev.onFrame = (f) { + if (f.cmd != RecCmd.fileTransfer) return; + if (f.payload[0] != RecTransferAction.setup) return; + setups++; + final startOfs = RecBytes.readU32le(f.payload, 1); + if (setups == 1) { + dev.pushSetupRsp(fileSize: 4, startOfs: 0); + dev.pushData(fileOfs: 0, data: [1, 2]); + dev.pushData(fileOfs: 99, data: [9]); // 缺口 + } else { + expect(startOfs, 2, reason: '必须从本地已有的长度续,不能从 0 重来'); + dev.pushSetupRsp(fileSize: 4, startOfs: startOfs); + dev.pushData(fileOfs: 2, data: [3, 4], end: true); + } + }; + + final sink = MemoryRecFileSink(); + final r = await client.download(name: 'A.OGG', sink: sink); + expect(setups, 2); + expect(r.resumes, 1); + expect(sink.bytes, [1, 2, 3, 4]); + + final cancels = dev.sent + .map(RecFrame.tryParse) + .where((f) => + f != null && + f.cmd == RecCmd.fileTransfer && + f.payload.isNotEmpty && + f.payload[0] == RecTransferAction.cancel) + .length; + expect(cancels, 1, reason: '重新 SETUP 之前必须先 CANCEL'); + }); + + test('传入已有内容的 sink 即断点续传', () async { + dev.onFrame = (f) { + if (f.cmd != RecCmd.fileTransfer) return; + if (f.payload[0] != RecTransferAction.setup) return; + expect(RecBytes.readU32le(f.payload, 1), 2); + dev.pushSetupRsp(fileSize: 4, startOfs: 2); + dev.pushData(fileOfs: 2, data: [3, 4], end: true); + }; + + final sink = MemoryRecFileSink(); + await sink.append([1, 2]); + final r = await client.download(name: 'A.OGG', sink: sink); + expect(sink.bytes, [1, 2, 3, 4]); + expect(r.complete, isTrue); + }); + + test('校验和不符的包被丢弃,不推进 offset', () async { + var setups = 0; + dev.onFrame = (f) { + if (f.cmd != RecCmd.fileTransfer) return; + if (f.payload[0] != RecTransferAction.setup) return; + setups++; + if (setups == 1) { + dev.pushSetupRsp(fileSize: 2, startOfs: 0); + dev.pushData(fileOfs: 0, data: [1, 2], end: true, forceSum: 0xDEAD); + // 坏包被丢后靠重传补上 + dev.pushData(fileOfs: 0, data: [1, 2], end: true); + } + }; + + final sink = MemoryRecFileSink(); + await client.download(name: 'A.OGG', sink: sink); + expect(sink.bytes, [1, 2]); + }); + + test('SETUP 返回 FILE_NOT_FOUND 时报错并带状态码', () async { + dev.onFrame = (f) { + if (f.cmd != RecCmd.fileTransfer) return; + if (f.payload[0] != RecTransferAction.setup) return; + dev.pushSetupRsp( + fileSize: 0, startOfs: 0, status: RecStatus.fileNotFound.code); + }; + expect( + () => client.download(name: 'NOPE.OGG', sink: MemoryRecFileSink()), + throwsA(isA() + .having((e) => e.status, 'status', RecStatus.fileNotFound)), + ); + }); + + test('END 到了但大小不符 → 续传而不是当成功', () async { + var setups = 0; + dev.onFrame = (f) { + if (f.cmd != RecCmd.fileTransfer) return; + if (f.payload[0] != RecTransferAction.setup) return; + setups++; + if (setups == 1) { + dev.pushSetupRsp(fileSize: 4, startOfs: 0); + dev.pushData(fileOfs: 0, data: [1, 2], end: true); // 谎报结束 + } else { + dev.pushSetupRsp(fileSize: 4, startOfs: 2); + dev.pushData(fileOfs: 2, data: [3, 4], end: true); + } + }; + final sink = MemoryRecFileSink(); + final r = await client.download(name: 'A.OGG', sink: sink); + expect(setups, 2); + expect(sink.bytes, [1, 2, 3, 4]); + expect(r.complete, isTrue); + }); + + test('设备发 CANCEL 时下载失败', () async { + dev.onFrame = (f) { + if (f.cmd != RecCmd.fileTransfer) return; + if (f.payload[0] != RecTransferAction.setup) return; + dev.pushSetupRsp(fileSize: 4, startOfs: 0); + dev.push(RecFrame.build( + cmd: RecCmd.fileTransfer, + seq: 0, + payload: [RecTransferAction.cancel, RecCancelReason.deviceError], + )); + }; + expect( + () => client.download(name: 'A.OGG', sink: MemoryRecFileSink()), + throwsA(isA()), + ); + }); + }); + + group('互斥闸', () { + test('传输期间拒绝其它命令', () async { + final gate = Completer(); + dev.onFrame = (f) { + if (f.cmd != RecCmd.fileTransfer) return; + if (f.payload[0] != RecTransferAction.setup) return; + dev.pushSetupRsp(fileSize: 4, startOfs: 0); + dev.pushData(fileOfs: 0, data: [1, 2]); + gate.complete(); // 传输已开始、尚未结束 + }; + + final dl = client.download(name: 'A.OGG', sink: MemoryRecFileSink()); + await gate.future; + + await expectLater( + client.queryInfo(), + throwsA(isA() + .having((e) => e.status, 'status', RecStatus.busy)), + ); + + dev.pushData(fileOfs: 2, data: [3, 4], end: true); + await dl; + }); + + test('已有传输时再发起下载直接拒绝', () async { + final gate = Completer(); + dev.onFrame = (f) { + if (f.cmd != RecCmd.fileTransfer) return; + if (f.payload[0] != RecTransferAction.setup) return; + dev.pushSetupRsp(fileSize: 4, startOfs: 0); + dev.pushData(fileOfs: 0, data: [1, 2]); + if (!gate.isCompleted) gate.complete(); + }; + final dl = client.download(name: 'A.OGG', sink: MemoryRecFileSink()); + await gate.future; + + await expectLater( + client.download(name: 'B.OGG', sink: MemoryRecFileSink()), + throwsA(isA() + .having((e) => e.status, 'status', RecStatus.busy)), + ); + + dev.pushData(fileOfs: 2, data: [3, 4], end: true); + await dl; + }); + }); + + group('危险操作', () { + test('删除带 "DEL!" 魔数', () async { + dev.onFrame = (f) => dev.reply(RecCmd.fileDelete, RecStatus.ok.code); + await client.deleteFile('MIC0016.OGG'); + final f = RecFrame.tryParse(dev.sent.single)!; + expect(RecBytes.readU32le(f.payload, 0), RecConfirm.delete); + expect(RecConfirm.delete, 0x44454C21); + }); + + test('格式化带 "FMT!" 魔数', () async { + dev.onFrame = (f) => dev.reply(RecCmd.format, RecStatus.ok.code); + await client.format(); + final f = RecFrame.tryParse(dev.sent.single)!; + expect(RecBytes.readU32le(f.payload, 0), RecConfirm.format); + expect(RecConfirm.format, 0x464D5421); + }); + + test('录音中删除返回 BUSY', () async { + dev.onFrame = (f) => dev.reply(RecCmd.fileDelete, RecStatus.busy.code); + expect( + () => client.deleteFile('A.OGG'), + throwsA(isA() + .having((e) => e.status, 'status', RecStatus.busy)), + ); + }); + + test('非法文件名在本地就被挡下,不发包', () async { + expect( + () => client.deleteFile('../secret'), + throwsA(isA()), + ); + expect(dev.sent, isEmpty); + }); + }); +} diff --git a/apps/client/local_plugins/recorder_holder/test/rec/rec_frame_test.dart b/apps/client/local_plugins/recorder_holder/test/rec/rec_frame_test.dart new file mode 100644 index 00000000..124a193f --- /dev/null +++ b/apps/client/local_plugins/recorder_holder/test/rec/rec_frame_test.dart @@ -0,0 +1,248 @@ +import 'dart:typed_data'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:recorder_holder/recorder_holder_rec.dart'; + +/// 按协议文档 §5.9 组一个 DATA 包(含正确校验和)。 +Uint8List buildDataPacket({ + required int seq, + required int fileOfs, + required int control, + required List data, + int? forceSum, +}) { + final body = [ + RecTransferAction.data, + ...RecBytes.u32le(fileOfs), + control, + 0, 0, // sum 占位 + ...data, + ]; + final packet = RecFrame.build(cmd: RecCmd.fileTransfer, seq: seq, payload: body); + final sum = forceSum ?? RecChecksum.compute(packet); + packet[RecChecksum.sumOffsetInPacket] = sum & 0xFF; + packet[RecChecksum.sumOffsetInPacket + 1] = (sum >> 8) & 0xFF; + return packet; +} + +void main() { + group('包头', () { + test('组包与解析对称', () { + final f = RecFrame.build(cmd: RecCmd.recInfo, seq: 7, payload: [1, 2, 3]); + expect(f[0], 0xA5); + expect(f[1], RecCmd.recInfo); + expect(f[2], 7); + // len 是 u16 小端 + expect(f[3], 3); + expect(f[4], 0); + + final p = RecFrame.tryParse(f)!; + expect(p.cmd, RecCmd.recInfo); + expect(p.seq, 7); + expect(p.payload, [1, 2, 3]); + }); + + test('magic 不符直接丢弃', () { + final f = RecFrame.build(cmd: 1, seq: 0, payload: const []); + f[0] = 0xA6; + expect(RecFrame.tryParse(f), isNull); + }); + + test('len 与实际长度不符直接丢弃', () { + final f = RecFrame.build(cmd: 1, seq: 0, payload: [1, 2, 3]); + // 声称 payload 有 4 字节,实际只有 3 + f[3] = 4; + expect(RecFrame.tryParse(f), isNull); + }); + + test('短于包头的包直接丢弃', () { + expect(RecFrame.tryParse([0xA5, 0x01]), isNull); + }); + + test('seq 到 0xFF 回绕', () { + final f = RecFrame.build(cmd: 1, seq: 0xFF, payload: const []); + expect(RecFrame.tryParse(f)!.seq, 0xFF); + }); + }); + + group('DATA 校验和', () { + test('覆盖整包且 sum 字段按 0 参与计算', () { + final pkt = buildDataPacket(seq: 1, fileOfs: 0, control: 0, data: [9, 9, 9]); + final parsed = RecFrame.tryParse(pkt)!; + final d = RecTransferData.parse(parsed.payload.sublist(1), parsed.raw)!; + expect(d.checksumOk, isTrue); + expect(d.data, [9, 9, 9]); + expect(d.fileOfs, 0); + }); + + test('只算 data 段的实现会算错(守住最容易写反的那一处)', () { + final pkt = buildDataPacket(seq: 1, fileOfs: 0, control: 0, data: [9, 9, 9]); + const dataOnlySum = 9 + 9 + 9; + expect(RecChecksum.compute(pkt), isNot(dataOnlySum)); + }); + + test('校验和不符时 checksumOk=false 而不是解析失败', () { + final pkt = + buildDataPacket(seq: 1, fileOfs: 0, control: 0, data: [1], forceSum: 0x1234); + final parsed = RecFrame.tryParse(pkt)!; + final d = RecTransferData.parse(parsed.payload.sublist(1), parsed.raw); + expect(d, isNotNull); + expect(d!.checksumOk, isFalse); + }); + + test('data 实际长度按 hdr.len-8 得出,不用 SETUP_RSP.data_len', () { + final pkt = buildDataPacket( + seq: 1, fileOfs: 100, control: RecDataControl.end, data: [1, 2]); + final parsed = RecFrame.tryParse(pkt)!; + // payload = action(1)+ofs(4)+control(1)+sum(2)+data(2) = 10 + expect(parsed.payload.length, 10); + final d = RecTransferData.parse(parsed.payload.sublist(1), parsed.raw)!; + expect(d.data.length, 2); + expect(d.isEnd, isTrue); + }); + }); + + group('文件名校验', () { + test('必须以 \\0 结尾', () { + expect(RecFileName.isValidRaw('MIC0016.OGG'.codeUnits), isFalse); + expect(RecFileName.isValidRaw([...'MIC0016.OGG'.codeUnits, 0]), isTrue); + }); + + test('name_len 含结尾 \\0', () { + expect(RecFileName.encode('MIC0016.OGG').length, 12); + }); + + test('拒绝路径分隔符(防止写到 \\REC\\ 之外)', () { + expect(RecFileName.isValidRaw([...'../x'.codeUnits, 0]), isFalse); + expect(RecFileName.isValidRaw([...r'a\b'.codeUnits, 0]), isFalse); + expect(RecFileName.isValid('a/b'), isFalse); + expect(RecFileName.isValid(r'a\b'), isFalse); + }); + + test('最小长度为 2', () { + expect(RecFileName.isValidRaw([0]), isFalse); + expect(RecFileName.isValidRaw([65, 0]), isTrue); + }); + }); + + group('REC_INFO', () { + test('正常解析', () { + final p = [ + RecStatus.ok.code, + RecFsType.fat32, + ...RecBytes.u32le(124928), + ...RecBytes.u32le(1024), + 1, + ]; + final info = RecInfo.parse(p)!; + expect(info.status, RecStatus.ok); + expect(info.fsType, RecFsType.fat32); + expect(info.totalKb, 124928); + expect(info.usedKb, 1024); + expect(info.recording, isTrue); + expect(info.freeKb, 124928 - 1024); + }); + + test('status 非 OK 时其余字段归零,但 rec_state 仍然采信', () { + final p = [ + RecStatus.noCard.code, + RecFsType.fat32, + ...RecBytes.u32le(999), + ...RecBytes.u32le(999), + 1, + ]; + final info = RecInfo.parse(p)!; + expect(info.status, RecStatus.noCard); + expect(info.totalKb, 0); + expect(info.recording, isTrue, reason: 'rec_state 协议明确始终有效'); + expect(info.hasCard, isFalse); + }); + }); + + group('FILE_LIST', () { + List item(String name, int size, int ftime) { + final n = RecFileName.encode(name); + return [...RecBytes.u32le(size), ...RecBytes.u32le(ftime), n.length, ...n]; + } + + test('多条变长 item 逐项解析', () { + final p = [ + RecStatus.ok.code, + 2, + 1, + ...item('MIC0016.OGG', 1234, 0), + ...item('MIC0017.OGG', 5678, 0), + ]; + final page = RecFileListPage.parse(p)!; + expect(page.eof, isTrue); + expect(page.items.length, 2); + expect(page.items[0].name, 'MIC0016.OGG'); + expect(page.items[0].size, 1234); + expect(page.items[1].name, 'MIC0017.OGG'); + }); + + test('item_count 与实际内容不符时整包判非法', () { + final p = [RecStatus.ok.code, 3, 1, ...item('A.OGG', 1, 0)]; + expect(RecFileListPage.parse(p), isNull); + }); + + test('name_len 越界不会读到包外', () { + final n = RecFileName.encode('A.OGG'); + final p = [ + RecStatus.ok.code, + 1, + 1, + ...RecBytes.u32le(1), + ...RecBytes.u32le(0), + 99, // 谎报的 name_len + ...n, + ]; + expect(RecFileListPage.parse(p), isNull); + }); + + test('空目录:count=0 且 eof=1', () { + final page = RecFileListPage.parse([RecStatus.ok.code, 0, 1])!; + expect(page.items, isEmpty); + expect(page.eof, isTrue); + }); + + test('FAT 时间按标准布局解码', () { + // 2026-09-08 12:30:04 → date=(46<<9)|(9<<5)|8, time=(12<<11)|(30<<5)|2 + const date = ((2026 - 1980) << 9) | (9 << 5) | 8; + const time = (12 << 11) | (30 << 5) | 2; + const ftime = (date << 16) | time; + final page = + RecFileListPage.parse([RecStatus.ok.code, 1, 1, ...item('A.OGG', 1, ftime)])!; + expect(page.items.single.time, DateTime(2026, 9, 8, 12, 30, 4)); + }); + }); + + group('SETUP_RSP', () { + test('正常解析', () { + final body = [ + RecStatus.ok.code, + ...RecBytes.u32le(10000), + ...RecBytes.u32le(256), + ...RecBytes.u32le(0), + ...RecBytes.u16le(231), + ]; + final r = RecTransferSetupRsp.parse(body)!; + expect(r.fileSize, 10000); + expect(r.startOfs, 256); + expect(r.dataLen, 231); + }); + + test('status 非 OK 时其余字段归零', () { + final body = [ + RecStatus.fileNotFound.code, + ...RecBytes.u32le(10000), + ...RecBytes.u32le(256), + ...RecBytes.u32le(0), + ...RecBytes.u16le(231), + ]; + final r = RecTransferSetupRsp.parse(body)!; + expect(r.status, RecStatus.fileNotFound); + expect(r.fileSize, 0); + }); + }); +} diff --git a/apps/client/pubspec.lock b/apps/client/pubspec.lock index cbc2c8a1..61e12eaf 100644 --- a/apps/client/pubspec.lock +++ b/apps/client/pubspec.lock @@ -236,7 +236,7 @@ packages: source: hosted version: "1.4.1" chat_api: - dependency: "direct main" + dependency: transitive description: path: "local_plugins/chat_api" relative: true @@ -400,12 +400,19 @@ packages: source: path version: "0.0.1" device_plugin_interface: - dependency: transitive + dependency: "direct main" description: path: "local_plugins/device_plugin_interface" relative: true source: path - version: "0.1.0" + version: "0.2.0" + device_smartcar: + dependency: "direct main" + description: + path: "local_plugins/device_smartcar" + relative: true + source: path + version: "0.0.1" diff_match_patch: dependency: transitive description: @@ -1060,18 +1067,18 @@ packages: dependency: "direct main" description: name: in_app_purchase - sha256: ad59b45d998868b8ace0f606e09b348ffd888952a3cf8ff9f7956aaf4844e6e2 + sha256: "0e9510b80b0074e89ab0a8e0fc901439b779dc9ae575ab8d419253c6e1627716" url: "https://pub.dev" source: hosted - version: "3.2.4" + version: "3.3.0" in_app_purchase_android: dependency: "direct main" description: name: in_app_purchase_android - sha256: "518b48a385dfc1aa278080472969c6cee0c3d41ef8b9511b0071dd015473ecf1" + sha256: b8af1b6f0e57076b5da5ede71e3af2d727017c0416ad2a41017c6ed2b102381d url: "https://pub.dev" source: hosted - version: "0.4.0+11" + version: "0.5.0" in_app_purchase_platform_interface: dependency: transitive description: @@ -1104,13 +1111,6 @@ packages: url: "https://pub.dev" source: hosted version: "1.0.5" - jl_opus: - dependency: "direct main" - description: - path: "local_plugins/jl_opus" - relative: true - source: path - version: "0.0.1" jni: dependency: transitive description: @@ -1309,13 +1309,6 @@ packages: url: "https://pub.dev" source: hosted version: "9.5.0" - open_ai_service: - dependency: "direct main" - description: - path: "local_plugins/open_ai_service" - relative: true - source: path - version: "0.0.1" package_config: dependency: transitive description: @@ -1579,13 +1572,6 @@ packages: url: "https://pub.dev" source: hosted version: "2.2.3" - realtime: - dependency: "direct main" - description: - path: "local_plugins/realtime" - relative: true - source: path - version: "0.0.1" record: dependency: "direct main" description: @@ -1659,7 +1645,7 @@ packages: source: hosted version: "1.0.7" recorder_holder: - dependency: transitive + dependency: "direct main" description: path: "local_plugins/recorder_holder" relative: true diff --git a/apps/client/pubspec.yaml b/apps/client/pubspec.yaml index 71e23b7f..d03c8d17 100644 --- a/apps/client/pubspec.yaml +++ b/apps/client/pubspec.yaml @@ -1,7 +1,7 @@ name: eaimar description: "Eaimar - AI Voice Assistant." publish_to: "none" -version: 1.0.1+6 +version: 1.0.1+7 environment: sdk: ">=3.3.0 <4.0.0" @@ -20,7 +20,7 @@ dependencies: flutter_shaders: ^0.0.6 get: ^4.7.2 - in_app_purchase: ^3.2.3 + in_app_purchase: ^3.3.0 json_annotation: ^4.9.0 get_storage: ^2.1.1 http: ^1.3.0 @@ -80,9 +80,14 @@ dependencies: # 源自 echomeet 项目 local_plugins/recorder_holder,原样拉取未改协议实现。 echomeet_device_sdk: path: local_plugins/recorder_holder/sdk + # 插件本体。⚠️ 必须显式声明:`lib/devices/holder/holder_ota_port.dart` 直接 + # import 了它的 OTA 实现(`recorder_holder_ota.dart`),只靠 sdk 的传递依赖 + # 拿到是不作数的 —— 上游哪天不再依赖它,这边就编不过了。 + recorder_holder: + path: local_plugins/recorder_holder tobias: ^5.2.0 # 支付宝SDK url_launcher: ^6.3.1 - in_app_purchase_android: ^0.4.0+5 + in_app_purchase_android: ^0.5.0 # Play Billing 8.0(Google Play 2026-08-31 起的最低要求) app_links: firebase_core: ^4.0.0 firebase_auth: ^6.0.1 @@ -102,18 +107,29 @@ dependencies: path: local_plugins/speech azure_speech: path: local_plugins/azure_speech - chat_api: - path: local_plugins/chat_api - open_ai_service: - path: local_plugins/open_ai_service chat_storage: path: local_plugins/chat_storage + # ⚠️ 下面三个 Dart 侧已无引用,但**原生侧仍被别的插件依赖**,不能删: + # - device_jieli:recorder_holder 的 iOS Package.swift 引用它 vendor 的 JLAudioUnitKit(支架 Opus 解码) + # - classic_bluetooth:azure_speech 的 iOS Package.swift 引用它 + # - test:agent_service 的 iOS Package.swift 引用它 + # 要真正摘掉得先把那几个 xcframework 搬进使用方,属于原生改动,本轮不做。 device_jieli: path: local_plugins/device_jieli + # Smartcar 车载香薰(杰理方案)的 BLE 链路:自持 GATT,在同一条连接上跑杰理 RCSP 认证 + # (AE00)与 C2 私有通道(ABC0)。iOS 通过 device_jieli 暴露的 jl-blekit 产品复用同一份 + # JL_BLEKit 动态库。Android 目前是桩。 + device_smartcar: + path: local_plugins/device_smartcar classic_bluetooth: path: local_plugins/classic_bluetooth + test: + path: local_plugins/test bluetooth_manager: path: local_plugins/bluetooth_manager + # 设备插件契约(纯 Dart):厂商插件 / 会话 / 能力 / 音频 / OTA + device_plugin_interface: + path: local_plugins/device_plugin_interface # 恒玄(BES)固件 OTA 升级:自包含 BES OTA SDK(Android Java / iOS ObjC)与原生桥接。 # ⚠️ 它自管到耳机的 SPP 连接,不复用 bluetooth_manager 的连接——多数固件同时 # 只接受一条 SPP 链路,升级前必须先 disconnectDevice() 让路,见 ota_upgrade_controller。 @@ -134,18 +150,12 @@ dependencies: path: local_plugins/amap_service location_service: path: local_plugins/location_service - realtime: - path: local_plugins/realtime - jl_opus: - path: local_plugins/jl_opus user_portrait: path: local_plugins/user_portrait qq_music: path: local_plugins/qq_music floating_ui_plugin: path: local_plugins/floating_ui_plugin - test: - path: local_plugins/test dev_dependencies: flutter_test: diff --git a/apps/client/test/appconfig_model_test.dart b/apps/client/test/appconfig_model_test.dart new file mode 100644 index 00000000..be445fbb --- /dev/null +++ b/apps/client/test/appconfig_model_test.dart @@ -0,0 +1,73 @@ +import 'package:eaimar/data/models/appconfig_model.dart'; +import 'package:flutter_test/flutter_test.dart'; + +/// `user_getappconfig` 响应的契约测试:服务端少下发字段、下发 null, +/// 客户端都要能解出来,而不是整个 fromJson 抛 TypeError。 +void main() { + test('完整响应正常解析', () { + final resp = UserGetAppConfigResp.fromJson({ + 'env': {'AGENT_TYPE': 'default'}, + 'agents': [ + { + 'id': 'a1', + 'name': 'Emma', + 'avatarUrl': '', + 'description': '', + 'tag': '', + 'isOnline': true, + 'voice': 'v', + 'welcomeMessage': null, + 'voiceWelcomeMessage': null, + 'systemPrompt': 'hi', + 'tools': null, + } + ], + 'mcps': { + 'm': {'servername': 'm', 'url': 'u', 'type': 1, 'tools': ''} + }, + 'products': [ + { + 'id': 45091, + 'factoryid': 1, + 'devicetype': 0, + 'devicename': 'Echo-one', + 'productimage': null, + 'version': '0.0.3', + 'isforceupdate': false, + 'iscontinuouschat': null, + 'wakeupenable': null, + 'broadcastpeertranslate': null, + 'updatedescription': null, + 'updatepackageaddress': null, + } + ], + }); + expect(resp.products.single.devicename, 'Echo-one'); + expect(resp.agents.single.systemPrompt, 'hi'); + expect(resp.mcps['m']!.type, 1); + }); + + test('顶层键缺失时给空集合,不抛', () { + final resp = UserGetAppConfigResp.fromJson({}); + expect(resp.env, isEmpty); + expect(resp.agents, isEmpty); + expect(resp.mcps, isEmpty); + expect(resp.products, isEmpty); + }); + + test('产品缺 devicetype / factoryid / devicename 时用默认值', () { + final p = DBProduct.fromJson({'id': 1, 'version': '1.0.0'}); + expect(p.devicetype, 0); + expect(p.factoryid, 0); + expect(p.devicename, ''); + expect(p.version, '1.0.0'); + expect(p.updateonmismatch, isNull); + }); + + test('agent 缺非空字段时用默认值', () { + final a = DBAgent.fromJson({'id': 'x'}); + expect(a.name, ''); + expect(a.isOnline, isFalse); + expect(a.voice, ''); + }); +} diff --git a/apps/client/test/assistant_directive_test.dart b/apps/client/test/assistant_directive_test.dart index 424e7858..103dfb98 100644 --- a/apps/client/test/assistant_directive_test.dart +++ b/apps/client/test/assistant_directive_test.dart @@ -211,4 +211,58 @@ void main() { expect(back.source, 'earphone'); expect(back.synced, isFalse); }); + + group('commands 形状(2026-09-11 车载助手真机帧)', () { + // Smartcar 车载助手的指令不在 tool_calls 里,而是 extra_info.commands。 + // 只解析 tool_calls 的话这里恒为 0 条 —— 表现就是"AI 说已经关闭了、实际没反应"。 + Map frame() => { + 'dialog_id': 'd1', + 'round_id': 'r3', + 'text': '好的,那我先不打扰你啦,有需要随时叫我哦~', + 'finish_reason': 'stop', + 'extra_info': { + 'tool_infos': [], + 'agent_info': { + 'intent_infos': [ + {'domain': 'general_command', 'intent': 'quit'} + ], + 'round': 3, + }, + 'commands': [ + { + 'intent_info': {'domain': 'general_command', 'intent': 'quit'}, + 'name': 'quit', + 'params': [], + } + ], + 'query': '关闭对话。', + }, + }; + + test('解得出 quit 指令', () { + final list = + AssistantDirective.parseFromOutput(frame(), source: 'earphone'); + expect(list.length, 1); + expect(list.first.name, 'quit'); + expect(list.first.args['intent'], 'quit'); + expect(list.first.replyText, contains('不打扰')); + }); + + test('两种形状同时出现时都要解出来', () { + final f = frame(); + (f['extra_info'] as Map)['tool_calls'] = [ + { + 'function': {'name': 'SET_clock', 'arguments': '{"time":"08:00"}'} + } + ]; + final list = AssistantDirective.parseFromOutput(f, source: 'earphone'); + expect(list.map((d) => d.name).toList(), ['SET_clock', 'quit']); + }); + + test('commands 为空时不产生指令', () { + final f = frame(); + (f['extra_info'] as Map)['commands'] = []; + expect(AssistantDirective.parseFromOutput(f, source: 'earphone'), isEmpty); + }); + }); } diff --git a/apps/client/test/device_file_match_test.dart b/apps/client/test/device_file_match_test.dart new file mode 100644 index 00000000..3a7adbe0 --- /dev/null +++ b/apps/client/test/device_file_match_test.dart @@ -0,0 +1,43 @@ +import 'package:eaimar/data/utils/device_file_match.dart'; +import 'package:flutter_test/flutter_test.dart'; + +/// 删除设备原件的闸门(第一道:按标题找到那条已上传的记录)。 +/// 用例里的标题都是 2026-09-10 真机那次同步的实际值。 +/// +/// ⚠️ 第二道闸(本机文件长度 == 设备文件长度)在 DeviceFileImportService +/// 里做,因为要读磁盘。**两道都过才允许删**,放宽任何一道都是永久丢用户数据。 +void main() { + const uploaded = { + 'HOLDER_20260907_181224_MIC0049.OGG': '/data/.../LocalAudio/a.ogg', + 'HOLDER_20260907_184120_MIC0056.OGG': '/data/.../LocalAudio/b.ogg', + 'EARBUDS_20260902_223314.WAV': '/data/.../LocalAudio/c.wav', + }; + + test('设备上是大写短名,标题里是小写,要能对上', () { + expect(uploadedLocalPathFor(uploaded, 'MIC0049.OGG'), + '/data/.../LocalAudio/a.ogg'); + expect(uploadedLocalPathFor(uploaded, 'mic0056.ogg'), + '/data/.../LocalAudio/b.ogg'); + }); + + test('设备上有、但没有对应的已上传记录 → 拿不到路径,删不了', () { + expect(uploadedLocalPathFor(uploaded, 'MIC0099.OGG'), isNull); + }); + + test('⚠️ 后缀匹配不能被短名蒙混:IC0049 不是 MIC0049', () { + expect(uploadedLocalPathFor(uploaded, 'IC0049.OGG'), isNull); + }); + + test('没有已上传记录时一律删不了', () { + expect(uploadedLocalPathFor(const {}, 'MIC0049.OGG'), isNull); + }); + + test('空文件名一律拒绝', () { + expect(uploadedLocalPathFor(uploaded, ''), isNull); + }); + + test('记录里没有本机路径(服务端同步下来的行)→ 不作数', () { + const noPath = {'HOLDER_20260907_181224_MIC0049.OGG': ''}; + expect(uploadedLocalPathFor(noPath, 'MIC0049.OGG'), isNull); + }); +} diff --git a/apps/client/test/devices/bes_protocol_test.dart b/apps/client/test/devices/bes_protocol_test.dart new file mode 100644 index 00000000..553a7f7d --- /dev/null +++ b/apps/client/test/devices/bes_protocol_test.dart @@ -0,0 +1,110 @@ +import 'package:eaimar/devices/bes/bes_protocol.dart'; +import 'package:flutter_test/flutter_test.dart'; + +/// 恒玄协议金测试:帧全部来自真机抓包或厂商文档样例,**一个字节都不能改**。 +/// 这些测试红了,先怀疑改动、别怀疑帧。 +void main() { + List h(String s) => s + .trim() + .split(RegExp(r'\s+')) + .where((e) => e.isNotEmpty) + .map((e) => int.parse(e, radix: 16)) + .toList(); + + group('BB 0A 状态帧', () { + test('Echo-one 0.0.2 真机帧:不在盒内,无属性 3,帧尾 MAC 逐字节吻合', () { + // 2026-09-01 真机抓帧(属性 6 那 12 字节按实测样本填) + final frame = h('BB 0A 25 ' + '02 01 45 ' + '02 02 56 ' + '02 04 49 ' + '02 05 6C ' + '0D 06 00 01 01 70 B3 B1 4D 33 DC 00 00 00 ' + '07 07 11 11 22 33 33 A4'); + final st = BesProtocol.parseStatus(frame); + expect(st.batteryLeft, 69); + expect(st.batteryRight, 86); + expect(st.batteryCase, isNull, reason: '不在盒内时没有属性 3'); + expect(st.chargingLeft, isFalse); + expect(st.chargingRight, isFalse); + expect(st.mac, '11:11:22:33:33:A4'); + expect(st.summaryBattery, 69, reason: '左右取较低一侧'); + }); + + test('厂商文档样例:三个电量都在', () { + final frame = h('BB 0A 28 02 01 2B 02 02 18 02 03 64 02 04 85 02 05 6E'); + final st = BesProtocol.parseStatus(frame); + expect(st.batteryLeft, 43); + expect(st.batteryRight, 24); + expect(st.batteryCase, 100); + expect(st.summaryBattery, 24); + }); + + test('最高位是充电指示位,必须 & 0x7F', () { + final frame = h('BB 0A 09 02 01 C5 02 02 56'); + final st = BesProtocol.parseStatus(frame); + expect(st.batteryLeft, 69); + expect(st.chargingLeft, isTrue); + expect(st.batteryRight, 86); + expect(st.chargingRight, isFalse); + }); + + test('属性 4/5 的值超过 100 不会被当成电量', () { + final frame = h('BB 0A 06 02 04 89 02 05 6E'); + final st = BesProtocol.parseStatus(frame); + expect(st.hasBattery, isFalse); + }); + + test('从偏移 4 开始解(固件版本帧的写法)会一个值都解不出——守住起点是 3', () { + final frame = h('BB 0A 25 02 01 45 02 02 56 02 04 49 02 05 6C'); + // 人为错位一格模拟旧 bug:第一个「块长度」读到的是属性号 0x01 < 2 + final shifted = [frame[0], frame[1], frame[2], 0x00, ...frame.sublist(3)]; + expect(BesProtocol.parseStatus(shifted).hasBattery, isFalse); + expect(BesProtocol.parseStatus(frame).hasBattery, isTrue); + }); + }); + + group('固件版本帧', () { + test('BB 86 优先左耳,版本取块后 3 字节', () { + final frame = h('BB 86 14 00 ' + '09 01 00 00 00 00 00 00 00 02 ' + '09 02 00 00 00 00 00 00 00 03'); + final fw = BesProtocol.parseFirmware(frame); + expect(fw.left, '0.0.2'); + expect(fw.right, '0.0.3'); + expect(fw.preferred, '0.0.2'); + }); + }); + + group('命令字节', () { + test('每个 feature 对应的字节序列固定', () { + expect(BesCmd.frame(BesCmd.firmwareVersion), [0xAA, 0x06]); + expect(BesCmd.frame(BesCmd.status), [0xAA, 0x09]); + expect(BesCmd.frame(BesCmd.callState), [0xAA, 0x0E]); + expect(BesCmd.frame(BesCmd.callModeStart), [0xAA, 0x51]); + expect(BesCmd.frame(BesCmd.callModeStop), [0xAA, 0x52]); + expect(BesCmd.frame(BesCmd.asrStarted), [0xAA, 0x57]); + expect(BesCmd.frame(BesCmd.asrStopped), [0xAA, 0x58]); + expect(BesCmd.frame(BesCmd.micRecordStart), [0xAA, 0x59]); + expect(BesCmd.frame(BesCmd.micRecordStop), [0xAA, 0x5A]); + expect(BesCmd.frame(BesCmd.aiExit), [0xAA, 0x65]); + expect(BesCmd.frame(BesCmd.faceToFaceStart), [0xAA, 0x66]); + expect(BesCmd.frame(BesCmd.faceToFaceStop), [0xAA, 0x67]); + expect(BesCmd.frame(BesCmd.aiAccept), [0xAA, 0x6A]); + }); + + test('电量请求是 09 不是 0A,也不是 |0x80 推出来的任何值', () { + expect(BesCmd.status, 0x09); + expect(BesResp.status, 0x0A); + expect(BesCmd.status | 0x80, isNot(BesResp.status)); + }); + + test('0x51~0x6A 会切工作模式,探测时必须跳过', () { + for (var c = 0x51; c <= 0x6A; c++) { + expect(BesCmd.switchesMode(c), isTrue, reason: '0x${c.toRadixString(16)}'); + } + expect(BesCmd.switchesMode(0x09), isFalse); + expect(BesCmd.switchesMode(0x06), isFalse); + }); + }); +} diff --git a/apps/client/test/devices/bes_session_commands_test.dart b/apps/client/test/devices/bes_session_commands_test.dart new file mode 100644 index 00000000..1b73a2a3 --- /dev/null +++ b/apps/client/test/devices/bes_session_commands_test.dart @@ -0,0 +1,52 @@ +import 'package:device_plugin_interface/device_plugin_interface.dart'; +import 'package:eaimar/devices/bes/bes_device_plugin.dart'; +import 'package:eaimar/devices/bes/bes_protocol.dart'; +import 'package:flutter_test/flutter_test.dart'; + +/// feature key → 发给恒玄耳机的字节,**冻结**。 +/// +/// 业务层只认 feature key,这张表是它与线上固件之间唯一的翻译;改一个字节 +/// 等于改协议。表里的每一条都对应已量产固件的真实行为。 +void main() { + test('通用 feature 到字节的映射逐条固定', () { + const expected = >{ + DeviceFeatures.recordStart: [0xAA, 0x59], + DeviceFeatures.recordStop: [0xAA, 0x5A], + DeviceFeatures.callStart: [0xAA, 0x51], + DeviceFeatures.callStop: [0xAA, 0x52], + DeviceFeatures.callQuery: [0xAA, 0x0E], + DeviceFeatures.asrStarted: [0xAA, 0x57], + DeviceFeatures.asrStopped: [0xAA, 0x58], + DeviceFeatures.aiAccept: [0xAA, 0x6A], + DeviceFeatures.aiExit: [0xAA, 0x65], + DeviceFeatures.f2fStart: [0xAA, 0x66], + DeviceFeatures.f2fStop: [0xAA, 0x67], + DeviceFeatures.batteryQuery: [0xAA, 0x09], + 'bes.firmware.query': [0xAA, 0x06], + }; + for (final e in expected.entries) { + final cmd = BesDeviceSession.featureCommands[e.key]; + expect(cmd, isNotNull, reason: '${e.key} 没有映射'); + expect(BesCmd.frame(cmd!), e.value, reason: e.key); + } + expect(BesDeviceSession.featureCommands.length, expected.length, + reason: '多出来的映射也要在这里登记'); + }); + + test('恒玄会话声明的能力集:只增不删', () { + const must = { + DeviceCapability.battery, + DeviceCapability.macAddress, + DeviceCapability.micUplink, + DeviceCapability.speakerDownlink, + DeviceCapability.micRecording, + DeviceCapability.callAudioTap, + DeviceCapability.callState, + DeviceCapability.aiWake, + DeviceCapability.translationDownlink, + DeviceCapability.faceToFace, + DeviceCapability.ota, + }; + expect(BesDeviceSession.caps.containsAll(must), isTrue); + }); +} diff --git a/apps/client/test/devices/device_hub_boundary_test.dart b/apps/client/test/devices/device_hub_boundary_test.dart new file mode 100644 index 00000000..6fc2ad8d --- /dev/null +++ b/apps/client/test/devices/device_hub_boundary_test.dart @@ -0,0 +1,62 @@ +import 'dart:io'; + +import 'package:flutter_test/flutter_test.dart'; + +/// 分层边界:业务层不认厂商。 +/// +/// `lib/modules`、`lib/core`、`lib/data` 只能 import `lib/devices/*.dart`(中枢) +/// 与 `device_plugin_interface`,**不能** import `lib/devices//…`, +/// 也不能出现厂商类名。新接一种芯片只动 `lib/devices//` 与 bootstrap。 +void main() { + final vendorImport = RegExp(r"devices/(bes|holder|smartcar)/"); + final vendorTypes = RegExp( + r'\b(BesBluetoothService|BesDevicePlugin|BesDeviceSession|BesDeviceAuth|' + r'HolderDeviceService|HolderDevicePlugin|HolderDeviceSession|' + r'EchoMeetDeviceSdk|BluetoothManager\(\))'); + final vendorPackages = RegExp( + r"package:(bluetooth_manager|echomeet_device_sdk|bes_ota_manager)/"); + + List dartFiles(String dir) => Directory(dir) + .listSync(recursive: true) + .whereType() + .where((f) => f.path.endsWith('.dart')) + .toList(); + + /// 只看代码,注释里提到厂商名不算 + String codeOnly(File f) => f + .readAsLinesSync() + .where((l) => !l.trimLeft().startsWith('//')) + .map((l) => l.replaceFirst(RegExp(r'\s//.*$'), '')) + .join('\n'); + + test('lib/modules、lib/core、lib/data 不引用任何厂商实现', () { + final offenders = []; + for (final dir in ['lib/modules', 'lib/core', 'lib/data']) { + for (final f in dartFiles(dir)) { + final src = codeOnly(f); + if (vendorImport.hasMatch(src) || + vendorTypes.hasMatch(src) || + vendorPackages.hasMatch(src)) { + offenders.add(f.path); + } + } + } + expect(offenders, isEmpty, + reason: '这些文件绕过了 DeviceHub 直接碰厂商实现:\n${offenders.join('\n')}'); + }); + + test('厂商目录之间互不引用', () { + final offenders = []; + for (final f in dartFiles('lib/devices')) { + final parts = f.path.split('/'); + final idx = parts.indexOf('devices'); + if (idx < 0 || idx + 2 >= parts.length) continue; // 顶层文件不限制 + final myVendor = parts[idx + 1]; + final src = codeOnly(f); + for (final m in vendorImport.allMatches(src)) { + if (m.group(1) != myVendor) offenders.add('${f.path} -> ${m.group(0)}'); + } + } + expect(offenders, isEmpty, reason: offenders.join('\n')); + }); +} diff --git a/apps/client/test/devices/smartcar_protocol_test.dart b/apps/client/test/devices/smartcar_protocol_test.dart new file mode 100644 index 00000000..8a77eb53 --- /dev/null +++ b/apps/client/test/devices/smartcar_protocol_test.dart @@ -0,0 +1,258 @@ +import 'package:eaimar/devices/smartcar/smartcar_protocol.dart'; +import 'package:flutter_test/flutter_test.dart'; + +/// Smartcar C2 协议的字节级金测试。 +/// +/// 协议是**冻结的**:同一个命令永远发同样的字节。这些用例就是那份契约—— +/// 改动这里等于改协议,必须先在真机上验过。 +void main() { + group('CRC-8/MAXIM', () { + test('空数据为 0', () { + expect(SmartcarProtocol.crc8Maxim(const []), 0x00); + }); + + // 与 lumi BleProtocol.calcCrc 同算法(poly 0x31 反射 0x8C / init 0 / xorOut 0)。 + // 这几个值是标准 CRC-8/MAXIM 的公认结果,可离线核对。 + test('标准向量', () { + expect(SmartcarProtocol.crc8Maxim([0x00]), 0x00); + // "123456789" 的 CRC-8/MAXIM 标准 check 值是 0xA1 + expect( + SmartcarProtocol.crc8Maxim( + '123456789'.codeUnits), + 0xA1, + ); + }); + + test('逐位实现与查表口径一致(对 0..255 单字节全覆盖)', () { + for (var b = 0; b < 256; b++) { + var crc = 0; + crc ^= b; + for (var i = 0; i < 8; i++) { + crc = (crc & 1) != 0 ? (crc >> 1) ^ 0x8C : crc >> 1; + } + expect(SmartcarProtocol.crc8Maxim([b]), crc & 0xFF); + } + }); + }); + + group('组帧', () { + test('无参命令:获取版本号', () { + final p = SmartcarProtocol.buildPacket(SmartcarProtocol.cmdGetVersion); + expect(p[0], 0xAA); + expect(p[1], 0x01); + expect(p[2], 0x00); // len + expect(p.length, 4); + expect(p[3], SmartcarProtocol.crc8Maxim([0xAA, 0x01, 0x00])); + }); + + test('带参命令:设音量', () { + final p = SmartcarProtocol.buildPacket( + SmartcarProtocol.cmdSetVolume, const [0x05]); + expect(p.sublist(0, 4), [0xAA, 0x0B, 0x01, 0x05]); + expect(p.length, 5); + expect(p[4], SmartcarProtocol.crc8Maxim([0xAA, 0x0B, 0x01, 0x05])); + }); + + test('组出来的帧能被自己解回去', () { + final p = SmartcarProtocol.buildPacket(0x42, const [1, 2, 3]); + // buildPacket 发的是 0xAA,parseFrame 也认这个头 + final f = SmartcarProtocol.parseFrame(p); + expect(f, isNotNull); + expect(f!.cmd, 0x42); + expect(f.payload, [1, 2, 3]); + }); + }); + + group('解帧', () { + /// 造一帧设备应答 + List resp(int cmd, List payload) { + final head = [0xBB, cmd, payload.length, ...payload]; + return [...head, SmartcarProtocol.crc8Maxim(head)]; + } + + test('正常应答', () { + final f = SmartcarProtocol.parseFrame(resp(0x04, const [0x64])); + expect(f, isNotNull); + expect(f!.isResponse, isTrue); + expect(f.cmd, SmartcarProtocol.cmdGetBattery); + expect(f.payload, [100]); + expect(f.success, isTrue); + }); + + test('len=0 表示命令执行失败', () { + final f = SmartcarProtocol.parseFrame(resp(0x01, const [])); + expect(f, isNotNull); + expect(f!.success, isFalse); + }); + + test('主动上报帧头 0xCC', () { + final head = [0xCC, SmartcarProtocol.notifyAiWake, 0x00]; + final f = SmartcarProtocol + .parseFrame([...head, SmartcarProtocol.crc8Maxim(head)]); + expect(f!.isNotify, isTrue); + expect(f.cmd, 0x11); + }); + + test('CRC 不符必须丢弃', () { + final bad = resp(0x04, const [0x64]); + bad[bad.length - 1] = (bad.last + 1) & 0xFF; + expect(SmartcarProtocol.parseFrame(bad), isNull); + }); + + test('帧头不认识 / 长度不够 一律 null', () { + expect(SmartcarProtocol.parseFrame(const [0x12, 0x01, 0x00, 0x00]), isNull); + expect(SmartcarProtocol.parseFrame(const [0xBB, 0x01]), isNull); + // 声明 len=5 但实际只有 1 字节 payload + expect( + SmartcarProtocol.parseFrame(const [0xBB, 0x01, 0x05, 0x00, 0x00]), + isNull); + }); + }); + + group('广播解析', () { + /// 造一段 26 字节广播:CID 0x8505 / BID 0xA001 / PID 0x0001, + /// 经典蓝牙已连,MAC 11:22:33:44:55:66(存的是 XOR 0x5A 之后的值) + /// 真机实测(2026-09-10):CoreBluetooth 给的厂商数据 `05 85 a9 1a b3 21 e4 dd 00`, + /// 链路层补回 `[长度, 0xFF]` 后交给解析器 + List adv({int btState = 0x00}) => [ + 10, 0xFF, + 0x05, 0x85, // CID 小端 → 0x8505 + 0xA9, 0x1A, 0xB3, 0x21, 0xE4, 0xDD, // MAC 原样 + btState, + ]; + + test('真机广播:MAC 原样取 CID 之后 6 字节', () { + final a = SmartcarProtocol.parseManufacturerData(adv())!; + expect(a.cid, 0x8505); + expect(a.macAddress, 'A9:1A:B3:21:E4:DD'); + expect(a.classicBtConnected, isFalse); + }); + + test('状态字节 0x0C 视为经典蓝牙已连', () { + final a = SmartcarProtocol.parseManufacturerData(adv(btState: 0x0C))!; + expect(a.classicBtConnected, isTrue); + }); + + test('没有状态字节也能解出 MAC', () { + final a = SmartcarProtocol.parseManufacturerData(adv().sublist(0, 10))!; + expect(a.macAddress, 'A9:1A:B3:21:E4:DD'); + }); + + test('长度不足返回 null,不能抛', () { + expect(SmartcarProtocol.parseManufacturerData(null), isNull); + expect(SmartcarProtocol.parseManufacturerData(List.filled(9, 0)), isNull); + // Type 不是 0xFF 的段不是厂商数据 + expect(SmartcarProtocol.parseManufacturerData([10, 0x09, ...List.filled(8, 0)]), isNull); + }); + }); + + group('0x15 参数汇总', () { + test('14 字节逐项解对', () { + final s = SmartcarAllSettings.parse(const [ + 0x00, // OTA 正常 + 0x06, // 音量 6 + 0x1E, // 随机表情频次 30 + 0x08, // 亮度 8 + 0x50, // 电量 80 + 1, 2, 3, // 版本 1.2.3 + 0x01, // 启停开 + 0x03, // 摇头风格 3 + 0x01, // 音乐表情开 + 0x00, // 陀螺仪关 + 0x00, // 保留 + 0x01, // 电机开 + ])!; + expect(s.otaAbnormal, isFalse); + expect(s.volume, 6); + expect(s.randomExprFreq, 30); + expect(s.brightness, 8); + expect(s.battery, 80); + expect(s.firmwareVersion, '1.2.3'); + expect(s.powerSwitch, isTrue); + expect(s.musicHeadStyle, 3); + expect(s.musicExpression, isTrue); + expect(s.gyroscope, isFalse); + expect(s.motor, isTrue); + }); + + test('短包返回 null', () { + expect(SmartcarAllSettings.parse(List.filled(13, 0)), isNull); + }); + }); + + group('命令号(2026-09-11 按 lumi 运行时代码 + 协议文档定稿)', () { + test('0x05 是音乐播放状态、0x07 是 AI 状态,别按 BleConst.kt 的旧名字改回去', () { + // 判据:lumi `BleService.swift` 的 checkMusicPlaybackState() 实际发的是 + // 0x05 [0x01]/[0x00](手机在放/停止放音乐),与协议文档一致; + // "遥控电机"另有其号 0x0E,所以 0x07 不是控制动作。 + expect(SmartcarProtocol.cmdMusicPlayState, 0x05); + expect(SmartcarProtocol.cmdAiState, 0x07); + expect(SmartcarProtocol.cmdRemoteMotor, 0x0E); + }); + + test('0x11 双向共号:0xAA 是陀螺仪开关,0xCC 是 AI 唤醒', () { + expect(SmartcarProtocol.cmdGyroscopeSwitch, 0x11); + expect(SmartcarProtocol.notifyAiWake, 0x11); + final wake = SmartcarProtocol.parseFrame( + SmartcarProtocol.buildPacket(SmartcarProtocol.notifyAiWake, const [0xAA]) + .toList() + ..[0] = SmartcarProtocol.headerNotify); + // 帧头被改过,CRC 对不上,解析必须失败——这条顺带守住"CRC 覆盖帧头" + expect(wake, isNull); + }); + + test('AI 状态取值与协议文档一致', () { + expect(SmartcarProtocol.aiStateSpeakEnd, 0x00); + expect(SmartcarProtocol.aiStateThinking, 0x01); + expect(SmartcarProtocol.aiStateSpeakStart, 0x02); + expect(SmartcarProtocol.aiStateClose, 0x03); + expect(SmartcarProtocol.aiStateListening, 0x04); + }); + }); + + group('0x15 真机帧(2026-09-11 样机实测,金测试)', () { + // 同一次连接里单发 0x04 得到 3c(60)、单发 0x01 得到 02 0c 0c(2.12.12), + // 与这一帧的 [4] / [5..7] 完全一致 —— 偏移就是靠这两处交叉验证钉死的。 + const real = [ + 0x01, 0x00, 0x14, 0x0a, 0x3c, 0x02, 0x0c, 0x0c, + 0x00, 0x02, 0x01, 0x01, 0x04, 0x01, + ]; + + test('电量与版本与单发命令对得上', () { + final s = SmartcarAllSettings.parse(real)!; + expect(s.battery, 60, reason: '与 0x04 的应答 3c 一致'); + expect(s.firmwareVersion, '2.12.12', reason: '与 0x01 的应答 02 0c 0c 一致'); + }); + + test('其余字段按已钉死的偏移解出', () { + final s = SmartcarAllSettings.parse(real)!; + expect(s.brightness, 10); + expect(s.randomExprFreq, 20); + expect(s.volume, 0, reason: '实测就是 0(样机静音),改这条前先在真机上复核'); + expect(s.otaAbnormal, isTrue); + }); + }); + + group('应答期望', () { + test('这四条命令设备不回包,别等超时', () { + for (final c in [ + SmartcarProtocol.cmdMusicPlayState, + SmartcarProtocol.cmdControlEmotion, + SmartcarProtocol.cmdAiState, + SmartcarProtocol.cmdControlMusicBeat, + ]) { + expect(SmartcarProtocol.expectsResponse(c), isFalse, + reason: '0x${c.toRadixString(16)} 不该等应答'); + } + }); + + test('查询类命令要等应答', () { + expect(SmartcarProtocol.expectsResponse(SmartcarProtocol.cmdGetVersion), + isTrue); + expect(SmartcarProtocol.expectsResponse(SmartcarProtocol.cmdGetBattery), + isTrue); + expect(SmartcarProtocol.expectsResponse(SmartcarProtocol.cmdSyncSummary), + isTrue); + }); + }); +} diff --git a/apps/client/test/module_registry_test.dart b/apps/client/test/module_registry_test.dart new file mode 100644 index 00000000..a2190db0 --- /dev/null +++ b/apps/client/test/module_registry_test.dart @@ -0,0 +1,94 @@ +import 'package:eaimar/core/translations/language/en_us.dart'; +import 'package:eaimar/core/translations/language/zh_cn.dart'; +import 'package:eaimar/data/models/agent_module.dart'; +import 'package:eaimar/data/models/device_category.dart'; +import 'package:eaimar/modules/module_registry.dart'; +import 'package:eaimar/routes/app_pages.dart'; +import 'package:flutter_test/flutter_test.dart'; + +/// Agent 模块注册表卫生:新增模块只动 `lib/modules//` + 注册表一行, +/// 这里守住每条描述符都接完了线。 +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + test('模块 id 唯一', () { + final ids = ModuleRegistry.all.map((m) => m.id).toList(); + expect(ids.toSet().length, ids.length, reason: '重复 id: $ids'); + }); + + test('已实现的模块都有跳转方式;占位模块可以没有', () { + final broken = ModuleRegistry.all + .where((m) => !m.comingSoon && m.entryRoute == null && m.onLaunch == null) + .map((m) => m.id) + .toList(); + expect(broken, isEmpty, reason: '这些模块点了没反应: $broken'); + }); + + test('entryRoute 指向的页面已注册', () { + final registered = AppPages.pages.map((p) => p.name).toSet(); + final missing = ModuleRegistry.all + .where((m) => m.entryRoute != null && !registered.contains(m.entryRoute)) + .map((m) => '${m.id} -> ${m.entryRoute}') + .toList(); + expect(missing, isEmpty, reason: missing.join('\n')); + }); + + test('模块页面都进了总路由表,且 name 不与核心页面撞车', () { + final all = AppPages.pages.map((p) => p.name).toList(); + for (final p in ModuleRegistry.pages) { + expect(all.where((n) => n == p.name).length, 1, + reason: '${p.name} 注册了 ${all.where((n) => n == p.name).length} 次'); + } + }); + + test('标题/副标题的 i18n key 在 en_US 与 zh_CN 里有落点(或模块自带)', () { + final merged = ModuleRegistry.mergeTranslations({'en_US': enUS, 'zh_CN': zhCN}); + final missing = []; + for (final m in ModuleRegistry.all) { + for (final loc in ['en_US', 'zh_CN']) { + final table = merged[loc]!; + if (!table.containsKey(m.title)) missing.add('$loc:${m.title}'); + final sub = m.subtitle; + if (sub != null && !table.containsKey(sub)) missing.add('$loc:$sub'); + } + } + expect(missing, isEmpty, reason: '缺文案: $missing'); + }); + + test('注册表注入后 AgentModules.all 与 ModuleRegistry.all 同一份', () { + ModuleRegistry.install(); + expect(identical(AgentModules.all, ModuleRegistry.all), isTrue); + expect(AgentModules.byId('meeting'), isNotNull); + }); + + test('指令处理器同名不重复注册', () { + final names = []; + for (final m in ModuleRegistry.all) { + names.addAll(m.directives.keys); + } + expect(names.toSet().length, names.length, reason: '重复指令: $names'); + }); + + group('设备品类默认编排', () { + test('车载香薰连上后铺 Smartcar / 现场录音 / 语音纪要(按这个顺序)', () { + expect( + AgentModules.defaultsForCategory(DeviceCategories.carAroma), + [ + AgentModules.smartcarChat, + AgentModules.fieldRecording, + AgentModules.meeting, + ], + ); + }); + + test('这三个都不依赖设备能力,所以连上香薰不会灰', () { + ModuleRegistry.install(); + for (final id in AgentModules.defaultsForCategory(DeviceCategories.carAroma)) { + final m = ModuleRegistry.byId(id); + expect(m, isNotNull, reason: '$id 不在注册表里'); + expect(m!.requiredCapabilities, isEmpty, + reason: '$id 声明了 requiredCapabilities,车载香薰没有这些能力时会被置灰'); + } + }); + }); +} diff --git a/apps/client/test/ogg_opus_duration_test.dart b/apps/client/test/ogg_opus_duration_test.dart new file mode 100644 index 00000000..45c9b152 --- /dev/null +++ b/apps/client/test/ogg_opus_duration_test.dart @@ -0,0 +1,65 @@ +import 'package:eaimar/data/utils/ogg_opus_duration.dart'; +import 'package:flutter_test/flutter_test.dart'; + +/// Ogg Opus 时长解析的金测试。 +/// +/// 用例是 **EaiRec 支架真机 TF 卡上那条录音的原始字节**(2026-09-10 拉下来的 +/// `MIC0005.OGG`,38400 字节),不是构造出来的样本。改这里的解析逻辑前先看 +/// 这个测试为什么红。 +void main() { + // 文件头 48 字节:第一个 page(含 OpusHead) + const head = [ + 0x4F, 0x67, 0x67, 0x53, 0x00, 0x02, 0x00, 0x00, // + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01, 0x17, + 0x30, 0x89, 0x00, 0x00, 0x00, 0x00, 0x21, 0x8B, + 0x55, 0x8A, 0x01, 0x13, 0x4F, 0x70, 0x75, 0x73, + 0x48, 0x65, 0x61, 0x64, 0x01, 0x01, 0x00, 0x00, + 0x80, 0x3E, 0x00, 0x00, 0x00, 0x00, 0x00, 0x4F, + ]; + + // 最后一个 page 的头 27 字节:granulepos = 0x0006B580 = 439680 + const tail = [ + 0x4F, 0x67, 0x67, 0x53, 0x00, 0x04, 0x80, 0xB5, // + 0x06, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01, 0x17, + 0x30, 0x89, 0x1E, 0x00, 0x00, 0x00, 0xF3, 0xEE, + 0x70, 0xEC, 0x0A, + ]; + + test('真机 MIC0005.OGG:granulepos 439680 → 9.16 秒', () { + final secs = oggOpusSecondsFrom(head, tail); + expect(secs, isNotNull); + // 439680 / 48000 = 9.16 + expect(secs!, closeTo(9.16, 0.01)); + }); + + test('⚠️ 除数必须是 48000 而不是音频的 16000', () { + // OpusHead 里写的输入采样率是 16000(head 偏移 40 起的 0x00003E80), + // 但 granulepos 恒以 48kHz 计。用 16000 去除会得到 27.48s —— 3 倍。 + final secs = oggOpusSecondsFrom(head, tail)!; + expect(secs, lessThan(10)); + }); + + test('pre-skip 要从 granulepos 里扣掉', () { + // 把 OpusHead 的 pre-skip 从 0 改成 48000(1 秒) + final h = List.from(head); + h[38] = 0x80; // pre-skip 低字节 + h[39] = 0xBB; // 0xBB80 = 48000 + final secs = oggOpusSecondsFrom(h, tail)!; + expect(secs, closeTo(8.16, 0.01)); + }); + + test('不是 Ogg 就返回 null,不猜', () { + // RIFF/WAV 头 + const wav = [0x52, 0x49, 0x46, 0x46, 0x00, 0x00, 0x00, 0x00]; + expect(oggOpusSecondsFrom(wav, tail), isNull); + }); + + test('granulepos 不大于 pre-skip 时返回 null(空文件/只有头)', () { + // 最后一个 page 的 granulepos 置 0 + final t = List.from(tail); + for (var i = 6; i < 14; i++) { + t[i] = 0; + } + expect(oggOpusSecondsFrom(head, t), isNull); + }); +} diff --git a/apps/client/test/recording_time_test.dart b/apps/client/test/recording_time_test.dart new file mode 100644 index 00000000..62f769c0 --- /dev/null +++ b/apps/client/test/recording_time_test.dart @@ -0,0 +1,38 @@ +import 'package:eaimar/data/utils/recording_time.dart'; +import 'package:flutter_test/flutter_test.dart'; + +/// 「从设备同步进来的录音显示什么时间」的口径。 +/// +/// 用例里的 2018-08-10 不是编的:那是 2026-09-10 真机那张 TF 卡上一批文件的 +/// 真实 FAT 时间戳(设备 RTC 还没对时就录的)。 +void main() { + final now = DateTime(2026, 9, 10, 23, 43); + + test('设备时间合理时就用它', () { + final t = DateTime(2026, 8, 15, 14, 33, 4); + expect(resolveRecordingCreationTime(t, now: now), t.millisecondsSinceEpoch); + }); + + test('设备没给时间就用当前时刻', () { + expect(resolveRecordingCreationTime(null, now: now), + now.millisecondsSinceEpoch); + }); + + test('⚠️ RTC 没对时的垃圾时间戳(2018)要丢掉,否则记录沉到列表最底下', () { + expect(resolveRecordingCreationTime(DateTime(2018, 8, 10), now: now), + now.millisecondsSinceEpoch); + }); + + test('FAT 的 1980 纪元同样丢掉', () { + expect(resolveRecordingCreationTime(DateTime(1980, 1, 1), now: now), + now.millisecondsSinceEpoch); + }); + + test('未来时间也丢掉(设备时钟跑飞),但留一天容差给时区', () { + expect(resolveRecordingCreationTime(DateTime(2030, 1, 1), now: now), + now.millisecondsSinceEpoch); + final justAhead = now.add(const Duration(hours: 8)); + expect(resolveRecordingCreationTime(justAhead, now: now), + justAhead.millisecondsSinceEpoch); + }); +} diff --git a/apps/client/test/routes_test.dart b/apps/client/test/routes_test.dart new file mode 100644 index 00000000..57e83382 --- /dev/null +++ b/apps/client/test/routes_test.dart @@ -0,0 +1,50 @@ +import 'dart:io'; + +import 'package:eaimar/routes/app_pages.dart'; +import 'package:flutter_test/flutter_test.dart'; + +/// 路由表卫生:每条 Routes 常量都要在 AppPages 里注册,且至少有一处跳转; +/// 每个 GetPage 的 name 唯一。死路由曾经堆到 20 个,这里守住不再长回来。 +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + final routesSrc = File('lib/routes/app_routes.dart').readAsStringSync(); + final constRe = RegExp(r"static const (\w+) = '([^']+)';"); + final declared = { + for (final m in constRe.allMatches(routesSrc)) m.group(1)!: m.group(2)! + }; + + test('每条路由常量都在 AppPages 注册(initial 与 home 共用同一路径)', () { + final registered = AppPages.pages.map((p) => p.name).toSet(); + final missing = declared.entries + .where((e) => !registered.contains(e.value)) + .map((e) => e.key) + .toList(); + expect(missing, isEmpty, reason: '声明了但没注册: $missing'); + }); + + test('GetPage name 唯一(home/initial 例外,两个常量同一路径)', () { + final names = AppPages.pages.map((p) => p.name).toList(); + final dup = {}; + final seen = {}; + for (final n in names) { + if (!seen.add(n)) dup.add(n); + } + dup.remove('/home'); + expect(dup, isEmpty, reason: '重复注册: $dup'); + }); + + test('每条路由常量至少有一处跳转方(routes/ 目录之外)', () { + final files = Directory('lib') + .listSync(recursive: true) + .whereType() + .where((f) => f.path.endsWith('.dart') && !f.path.contains('lib/routes/')) + .map((f) => f.readAsStringSync()) + .join('\n'); + final unused = declared.keys + .where((k) => !RegExp('Routes\\.$k\\b').hasMatch(files)) + .where((k) => k != 'initial') // GetMaterialApp 的 initialRoute 在 main.dart 直接用 + .toList(); + expect(unused, isEmpty, reason: '没人跳转的死路由: $unused'); + }); +} diff --git a/apps/client/test/user_device_license_test.dart b/apps/client/test/user_device_license_test.dart new file mode 100644 index 00000000..4368fdcd --- /dev/null +++ b/apps/client/test/user_device_license_test.dart @@ -0,0 +1,52 @@ +import 'package:eaimar/data/models/user_Info.dart'; +import 'package:flutter_test/flutter_test.dart'; + +/// 「设备页显示了两个 MAC」这个 bug 的回归测试(2026-09-11)。 +/// +/// 真机上车载香薰绑定后,服务端回的是 +/// `"devicemac":"A9:1A:B3:21:E4:DD","license":"A9:1A:B3:21:E4:DD"` —— +/// 授权码就是 MAC 本身(后台「导入 MAC」模式建的记录,`code := devicemac`)。 +/// 界面照显示就会出现两行一模一样的串:一行钥匙图标(授权码)、一行 MAC, +/// 看着像同一个 MAC 显示了两遍。 +void main() { + UserDevice make({required String mac, required String license}) => UserDevice( + id: 1, + uid: '1', + productid: 45345, + devicetype: 0, + devicename: 'Smartcar', + devicemac: mac, + license: license, + cmei: '', + ); + + group('UserDevice.hasRealLicense', () { + test('授权码就是 MAC 本身 → 不展示(真机原始数据)', () { + expect( + make(mac: 'A9:1A:B3:21:E4:DD', license: 'A9:1A:B3:21:E4:DD') + .hasRealLicense, + isFalse, + ); + }); + + test('大小写与分隔符不同也算同一个值', () { + expect(make(mac: 'A9:1A:B3:21:E4:DD', license: 'a91ab321e4dd').hasRealLicense, + isFalse); + expect(make(mac: 'A9:1A:B3:21:E4:DD', license: 'A9-1A-B3-21-E4-DD') + .hasRealLicense, isFalse); + }); + + test('真授权码照常展示', () { + expect( + make(mac: 'A9:1A:B3:21:E4:DD', license: '71E5E5C3A797C043B5A7') + .hasRealLicense, + isTrue, + ); + }); + + test('空授权码不展示', () { + expect(make(mac: 'A9:1A:B3:21:E4:DD', license: ' ').hasRealLicense, + isFalse); + }); + }); +} diff --git a/apps/client/tools/i18n_fill_missing.py b/apps/client/tools/i18n_fill_missing.py new file mode 100644 index 00000000..708e9e6b --- /dev/null +++ b/apps/client/tools/i18n_fill_missing.py @@ -0,0 +1,220 @@ +#!/usr/bin/env python3 +"""补齐语言文件里相对 en_us.dart 缺失的 key。 + +语言文件是 `const Map`,key 同时存在 `'key'` 与 `"key"` 两种引号写法, +所以查重必须两种引号都认——const map 重复键要到 build 阶段才炸,analyze 不报。 + +用法(都在 apps/client 下执行): + + python3 tools/i18n_fill_missing.py --report # 每个语言缺哪些 / 多哪些 key + python3 tools/i18n_fill_missing.py --dump-missing DIR # 把缺失 key 按语言写成 DIR/.json(key → 英文原文) + python3 tools/i18n_fill_missing.py --apply DIR # 读 DIR/.json(key → 译文)追加到各语言文件末尾 + python3 tools/i18n_fill_missing.py --apply DIR --fallback-en # 译文文件里没有的 key 用英文原文补上 + +--apply 只追加**真正缺失**的 key(两种引号都查过),已有的一律不动;多余 key(enUS 没有的)只打印清单,不删。 +译文里的占位符(@name / %s / {0} / \\n)会与英文原文比对,不一致的 key 会拒绝写入并列出来。 +""" + +from __future__ import annotations + +import argparse +import glob +import json +import os +import re +import sys + +LANG_DIR = os.path.join("lib", "core", "translations", "language") +BASELINE = "en_us.dart" + +# 一条 entry:key 字面量 + 冒号 + 一个或多个相邻字符串字面量(Dart 相邻字符串会拼接) +_STR = r"""(?:'(?:\\.|[^'\\])*'|"(?:\\.|[^"\\])*")""" +_ENTRY_RE = re.compile( + r"^[ \t]*(?P" + _STR + r")\s*:\s*(?P(?:" + _STR + r"\s*)+),", + re.M, +) +_KEY_RE = re.compile(r"^[ \t]*(['\"])((?:\\.|(?!\1).)*)\1\s*:", re.M) +_PLACEHOLDER_RE = re.compile(r"@\w+|%[sd]|\{\w+\}|\n") + + +def _unquote(lit: str) -> str: + """把一个 Dart 字符串字面量还原成值(处理 \\' \\" \\\\ \\n \\t \\$ \\uXXXX)。""" + body = lit[1:-1] + out: list[str] = [] + i = 0 + while i < len(body): + ch = body[i] + if ch == "\\" and i + 1 < len(body): + nxt = body[i + 1] + if nxt == "u": + # \uXXXX 或 \u{X...} + if i + 2 < len(body) and body[i + 2] == "{": + j = body.index("}", i + 3) + out.append(chr(int(body[i + 3 : j], 16))) + i = j + 1 + else: + out.append(chr(int(body[i + 2 : i + 6], 16))) + i += 6 + continue + out.append({"n": "\n", "t": "\t"}.get(nxt, nxt)) + i += 2 + continue + out.append(ch) + i += 1 + return "".join(out) + + +def _quote(value: str) -> str: + """把值写成单引号 Dart 字面量;`$` 要转义,否则被当成插值。""" + s = ( + value.replace("\\", "\\\\") + .replace("'", "\\'") + .replace("$", "\\$") + .replace("\n", "\\n") + .replace("\t", "\\t") + ) + return f"'{s}'" + + +def parse_file(path: str) -> tuple[dict[str, str], list[str]]: + """返回 (key → 值, 重复出现的 key 列表)。两种引号的 key 一起算。""" + with open(path, encoding="utf-8") as f: + text = f.read() + values: dict[str, str] = {} + seen: dict[str, int] = {} + for m in _ENTRY_RE.finditer(text): + key = _unquote(m.group("key")) + parts = re.findall(_STR, m.group("val")) + values[key] = "".join(_unquote(p) for p in parts) + seen[key] = seen.get(key, 0) + 1 + # _ENTRY_RE 只认值是字符串字面量的行;再用纯 key 正则兜一遍,防止漏掉写法怪异的行 + for m in _KEY_RE.finditer(text): + key = _unquote(m.group(1) + m.group(2) + m.group(1)) + if key not in seen: + seen[key] = 1 + values.setdefault(key, "") + dups = sorted(k for k, n in seen.items() if n > 1) + return values, dups + + +def placeholders(s: str) -> list[str]: + return sorted(_PLACEHOLDER_RE.findall(s)) + + +def lang_files() -> list[str]: + return sorted( + p for p in glob.glob(os.path.join(LANG_DIR, "*.dart")) if os.path.basename(p) != BASELINE + ) + + +def lang_name(path: str) -> str: + return os.path.splitext(os.path.basename(path))[0] + + +def append_entries(path: str, entries: list[tuple[str, str]]) -> None: + with open(path, encoding="utf-8") as f: + text = f.read() + end = text.rfind("};") + if end == -1: + raise SystemExit(f"{path}: 找不到 map 结尾 '}};'") + block = "".join(f" {_quote(k)}: {_quote(v)},\n" for k, v in entries) + before = text[:end] + if not before.endswith("\n"): + before += "\n" + with open(path, "w", encoding="utf-8") as f: + f.write(before + block + text[end:]) + + +def main() -> int: + ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) + ap.add_argument("--report", action="store_true", help="只输出每个语言缺失/多余 key 的统计") + ap.add_argument("--dump-missing", metavar="DIR", help="把缺失 key(key → 英文原文)按语言写到 DIR/.json") + ap.add_argument("--apply", metavar="DIR", help="读 DIR/.json(key → 译文)追加到语言文件") + ap.add_argument("--fallback-en", action="store_true", help="--apply 时译文缺的 key 用英文原文补上") + ap.add_argument("--only", nargs="*", help="只处理这些语言(如 es_es fr_fr)") + args = ap.parse_args() + + base_path = os.path.join(LANG_DIR, BASELINE) + base, base_dups = parse_file(base_path) + if base_dups: + print(f"[警告] {BASELINE} 自身有重复 key: {base_dups}") + print(f"{BASELINE}: {len(base)} keys") + + files = lang_files() + if args.only: + files = [p for p in files if lang_name(p) in set(args.only)] + + total_added = 0 + english_kept: dict[str, list[str]] = {} + rejected: dict[str, list[str]] = {} + extras_all: dict[str, list[str]] = {} + + for path in files: + lang = lang_name(path) + cur, dups = parse_file(path) + if dups: + print(f"[警告] {lang}: 文件内已有重复 key: {dups}") + missing = [k for k in base if k not in cur] + extra = [k for k in cur if k not in base] + if extra: + extras_all[lang] = extra + + if args.report or (not args.dump_missing and not args.apply): + print(f"{lang}: {len(cur)} keys, 缺 {len(missing)}, 多 {len(extra)}") + continue + + if args.dump_missing: + os.makedirs(args.dump_missing, exist_ok=True) + with open(os.path.join(args.dump_missing, f"{lang}.json"), "w", encoding="utf-8") as f: + json.dump({k: base[k] for k in missing}, f, ensure_ascii=False, indent=2) + print(f"{lang}: 写出 {len(missing)} 个缺失 key") + continue + + if args.apply: + tpath = os.path.join(args.apply, f"{lang}.json") + trans: dict[str, str] = {} + if os.path.exists(tpath): + with open(tpath, encoding="utf-8") as f: + trans = json.load(f) + entries: list[tuple[str, str]] = [] + for k in missing: + v = trans.get(k) + if v is None or not str(v).strip(): + if not args.fallback_en: + rejected.setdefault(lang, []).append(f"{k} (无译文)") + continue + v = base[k] + v = str(v) + if placeholders(v) != placeholders(base[k]): + rejected.setdefault(lang, []).append( + f"{k} (占位符不一致: {placeholders(v)} vs {placeholders(base[k])})" + ) + continue + if v == base[k] and re.search(r"[A-Za-z]{3,}", v): + english_kept.setdefault(lang, []).append(k) + entries.append((k, v)) + if entries: + append_entries(path, entries) + total_added += len(entries) + print(f"{lang}: 追加 {len(entries)} 个 key" + (f",跳过 {len(rejected.get(lang, []))}" if lang in rejected else "")) + + if extras_all: + print("\n== enUS 没有、语言文件多出来的 key(未删除,仅列出)==") + for lang, ks in extras_all.items(): + print(f"{lang} ({len(ks)}): {', '.join(ks)}") + if rejected: + print("\n== 未写入的 key ==") + for lang, ks in rejected.items(): + for k in ks: + print(f"{lang}: {k}") + if english_kept: + print("\n== 与英文原文相同(保留英文)的 key ==") + for lang, ks in english_kept.items(): + print(f"{lang} ({len(ks)}): {', '.join(ks)}") + if args.apply: + print(f"\n共追加 {total_added} 条") + return 1 if rejected else 0 + + +if __name__ == "__main__": + sys.exit(main())