iOS 与 Android 在权限、后台行为、进程生命周期上差异很大。本章列出 App 的双路径处理。
| 平台 | 值 | 来源 |
|---|---|---|
| iOS | 16.0 | ios/Podfile:2 |
| Android | flutter.minSdkVersion(当前 Flutter 默认)= API 21 / Android 5.0 |
android/app/build.gradle.kts |
Live Activities 需要 iOS 16.1+,独立 Widget Extension target(见 ios/CulinaTechLiveActivity/SETUP.md)。
AppPermissions.requiredForPlatform()(permissions_service.dart:32-54)按平台返回所需 runtime permission 列表。
return const [
Permission.bluetooth,
Permission.notification,
];
不请求 Permission.location 或 Permission.backgroundRefresh,原因(见源码注释):
iOS uses a single bluetooth runtime permission; no location needed for BLE scan because CoreBluetooth doesn't gate it on location.
Background App Refresh 是系统级 settings,不是 runtime permission——App 只能在运行时读取状态,不能请求。
Local Network(仅 CM4 Wi-Fi 流程触发):Info.plist 声明 NSLocalNetworkUsageDescription;iOS 14+ 在首次访问 LAN socket(getWifiName 等)时弹出原生「本地网络」权限对话框。AppPermissions.requestCM4LocalNetwork() 本身是 no-op 占位(实际 OS 提示由后续 socket 调用触发)。ConnectionModePage 在用户选 "WiFi" 后、iOS 弹原生对话框前先弹一个解释对话框(cm4PermissionDialogTitle/Body/Continue/Cancel)以降低拒绝率。详见 连接模式。
⚠️ 注:v4.2 §六的「iOS 权限特别说明」曾写 "iOS 下 BLE 需要三个权限同时生效:Bluetooth / Location / Background App Refresh"——这与当前代码实现不符。需要在 v4.3 里澄清。详见 待解决问题。
return const [
Permission.bluetoothScan,
Permission.bluetoothConnect,
Permission.locationWhenInUse,
Permission.notification, // Android 13+ runtime
];
源码注释:
On Android 12+ (API 31+) the BLUETOOTH_SCAN entry in the manifest carries
neverForLocation, so location isn't strictly required — but we still request it for Android <12 compatibility and because some vendor OEMs behave as if location is required regardless.
安装时(AndroidManifest.xml,per Liang 2026-04-29 A-U 清单):
INTERNET / ACCESS_NETWORK_STATE / ACCESS_WIFI_STATECHANGE_WIFI_MULTICAST_STATE(J,CM4 mDNS 发现)CHANGE_NETWORK_STATE(Q,CM4 WiFi provisioning 切换网络)ACCESS_NOTIFICATION_POLICY(P,over-temp 安全报警 DND bypass 的安装时声明)REQUEST_IGNORE_BATTERY_OPTIMIZATIONS清单未声明 S(Play Install Referrer)/ T(AdServices Attribution)/ U(AdId)——App 不集成广告 SDK,过度声明可能触发 Play Store review flag;如未来加广告 SDK 需重新评估。
requestAllUpfront)入口:main.dart 在 runApp() 之前调用 AppPermissions.requestAllUpfront()——首次启动例外(详见下方 7969fc3 callout,原生系统对话框被推迟到 PermissionsIntroPage 的 Continue 按钮触发)。
行为:
requiredForPlatform() 返回的列表顺序触发原生系统对话框原因(TAPD #3):先让用户看 OS 原生对话框,再进入 Flutter UI,避免「App 说 / OS 说」两遍。
⚠️ 首次启动 priming 屏(
PermissionsIntroPage,7969fc3):在系统对话框弹出之前插入一屏权限说明(lib/features/onboarding/permissions_intro_page.dart,纯展示型StatelessWidget),按卡片列出 4 类权限(Location / Bluetooth / Local Network / Background Activity)的用途,底部 Continue 按钮才触发系统对话框。main.dart读 SharedPreferences 标perm_intro_seen_v1:(a) 命中 → 走原路径,runApp前requestAllUpfront(),Returning launches 与改前一致、保留 TAPD #3 顺序;(b) 未见 →main()DEFERSrequestAllUpfront(),_AppInitializer收showPermIntro=true,post-frame 把PermissionsIntroPagepush 到 root navigator,用户点 Continue →_requestUpfrontAndMarkIntroSeen()调requestAllUpfront()+ 写持久化 flag → pop 路由,之后才跑 every-startup 检查(missing perms / low volume / bg perms onboarding)+ first-install 引导(home-onboarding coach-marks)。Back-dismiss / Android 物理返回不会 strand 启动——_showPermissionIntro尾部 belt-and-suspenders 再调一次_requestUpfrontAndMarkIntroSeen(),flag 兜底守卫保证幂等、最多触发一次。文案 6 语本地化(ARB keypermIntroTitle/Subtitle/LocationTitle/LocationBody/...,en/zh/de/es/fr/it)。
⚠️ First-launch 引导链尾段:dashboard coach-marks(
HomeOnboardingOverlay,TAPD #1003076 / 45d9916):_AppInitializerpost-frame 链在_maybeShowBgPermsDialog之后再加一调homeOnboardingProvider.notifier.maybeShow()(lib/features/onboarding/home_onboarding_overlay.dart),SharedPreferences 标home_onboarding_seen_v1未见时把 controller state 翻 true、dashboardStack里常驻的HomeOnboardingOverlay渲染两连 coach-mark(tap-to-advance:welcome 指 Help / Support → "Add Device" 提示「连接探针需从中继盒取出」),最后一 tap 调markSeenAndHide()写持久化 flag + 翻 state 回 false。摆在链尾的原因:dashboard 必须已经是当前 route,coach-mark 才不会渲在 permission-onboarding 路由背后;DevLog 落HOME_ONBOARDING | first-launch tips shown | already seen — skipped。文案 6 语本地化(ARB keyonboardingWelcomeBody / onboardingAddDeviceTitle / onboardingAddDeviceBody / onboardingTapToContinue,en/zh/de/es/fr/it);spotlight cut-out 通过 dashboard 的 file-level_dashboardAddButtonKey: GlobalKey测量 "+" 按钮 rect(详见 仪表盘 §用户能看到的内容),post-frame_tryMeasure()重试至多 20 帧防 dashboard 未 layout 完。
入口:BleDeviceService._ensurePermissions
语义:每次 BLE 操作(扫描 / 连接)前检查权限;若在 session 中途被撤销 → 重新请求 + 记录日志。
不是持续轮询 watcher —— 只有 BLE 路径触发才检查。
iOS CoreBluetooth 在 App 启动后的前几百毫秒会报 CBManagerStateUnknown。此时任何 connect() 调用都会失败。
代码处理(ble_device_service.dart:667-670):
await FlutterBluePlus.adapterState
.where((s) => s == BluetoothAdapterState.on)
.first
.timeout(const Duration(seconds: 5));
超时 5 秒退回到基于扫描的重连循环,不直接报错。同 pattern 在 :2167-2170 直连路径也用一次。
Per Liang 2026-04-29(CM1/CM2/CM3 protocol v4 §1 permissions):A(附近的设备)、B(精确位置)、G(连接到已配对蓝牙设备)必须每次启动重新检查,任一未授权时弹提示引导用户授权。同启动还须检查手机通知音量是否 <50% 或处于静音,若是弹提示告知报警声可能听不到——per Liang 2026-04-29 amendment:原 §1 写「手机音量」未指定流,当日 audit reply 澄清相关流是 手机通知音量(报警走通知,媒体流读数会在用户静了通知但媒体没静的情况下给假 OK)。Per Liang 2026-05-06 follow-up:用户用侧键调音量也要触发提示——Android 侧键通常驱动 ring 流而非 notification(notification 是单独的、用户很少改的滑块),所以现在读两条流取 min 作为判定值。
实现(_AppInitializer.initState post-frame,main.dart:341-388):
_maybeShowMissingPermissionsDialog()——调 AppPermissions.currentStatuses() 收集所有平台所需权限当前状态,过滤未授权项列成单一对话框("Bluetooth Scan / Connect / Precise Location / Notifications"),按钮指向 openAppSettings()。无 Platform.isIOS 门控(规则主要面向 Android)。_maybeShowLowVolumeDialog()——通过 flutter_volume_controller 包同时以 AudioStream.notification 和 AudioStream.ring 读两条流,取 min 作为判定值;低于 _lowVolumeThreshold = 0.5 弹提示。Android 静音模式下两条流通常都读为 0,同一阈值同时覆盖音量低与静音两个分支;iOS 忽略 stream 参数、两次读返回相同的系统总音量(Apple 不公开 per-stream volume,min 是 no-op)。返回 null 时(平台不报)跳过提示并打 VOLUME_CHECK_NULL,而不是当成 0 误触发。music 流故意不读——暂停媒体不应触发假阳性。两个 dialog 都用静态 guard(_missingPermsDialogShown / _lowVolumeDialogShown)一次性:同进程 dismiss 后不再重弹,下次启动重新检查并重新弹("每次启动" 语义)。DevLogService 打 SETTINGS_PROMPT(权限对话框 + 列表摘要)、VOLUME_CHECK_OK / VOLUME_CHECK_LOW / VOLUME_CHECK_NULL / VOLUME_CHECK_FAIL(启动音量检查结果)。低音量 dialog 文案 7969fc3 起 6 语本地化(ARB key lowVolumeTitle / lowVolumeBody,OK 按钮复用 cookingOK),原 hardcoded 英文 TODO(i18n) 已闭合;missing-permissions dialog 文案已 6 语本地化(ARB key permissionsNeededTitle / permissionsNeededBody / permissionsNotNow / permissionsOpenSettings,列表项通过 _humanLabelFor 映射 permLabel*)。
⚠️ 固定代码(per Liang 2026-04-29 acceptance):每次启动重新检查 + 弹提示这套流程是协商定的设计,安全主张依赖「App 没拿到权限就不应工作」,不要重构成「once-per-install splash」或加 "skip" preference 跳过——动之前先回到 Liang 那里重新确认。
App-side 代码已 shipped(commit 8829902,notification_service.dart:103 / :280-281):DarwinInitializationSettings 申请 requestCriticalPermission: true,并在每条通知的 DarwinNotificationDetails 上按类型分级——ambientOverTemp / internalOverTemp 用 InterruptionLevel.critical,其余 11 类(targetReached、earlyWarning*、probeDisconnected、lowBattery 等)保留 timeSensitive。但 runtime 权限请求始终返 granted=false——Apple 必须先颁发 com.apple.developer.usernotifications.critical-alerts entitlement。
Kevin 已在 https://developer.apple.com/contact/request/notifications-critical-alerts 提交申请(justification:厨房温度计安全报警 >100°C 内温 / >275°C 外温必须 bypass Silent + DND)。Apple 审批通常 1–2 周,期间代码 dormant 但无 regression。审批通过后:
ios/Runner/Runner.entitlements 加 com.apple.developer.usernotifications.critical-alerts完整 brief:ios/CulinaTechLiveActivity/MAC_HANDOFF.md Task B;CLAUDE.md Active project state 也有同步追踪。
WidgetsBindingObserver.didChangeAppLifecycleState(detached) → Layer A_AppExit.run():await BleDeviceService.shutdown() (2s 超时) + cloudRegistryProvider.shutdownAll() (1s 超时) + CookingLiveActivityService.endAll() (500ms 超时,结束所有挂起的锁屏 Live Activity 卡)问题:main.dart 在 runApp() 之前 await requestAllUpfront()(详上方 §权限请求策略)。原生权限对话框逐个弹出期间 Flutter 还没 mount,Android 窗口由 NormalTheme 接管。Flutter scaffolding 默认让 NormalTheme.windowBackground = ?android:colorBackground,在 light-mode 设备上解析为浅灰——用户首次启动看到的是「灰底 + 系统对话框」,不是品牌界面。深色系统主题(values-night)走 Theme.Black 父,没有这个问题。
修复(Per Liang 2026-04-29):把 splash 一直撑到 Flutter 第一帧。
android/app/src/main/res/values/styles.xml 与 values-night/styles.xml:NormalTheme.windowBackground 改指 @drawable/launch_background,覆盖默认的 ?android:colorBackground。深色 / 浅色系统主题统一用同一份 splash(App 本身仅暗色)android/app/src/main/res/drawable/launch_background.xml 与 drawable-v21/launch_background.xml 改成 <layer-list>:底层 @color/splashBackground(#141416,与 CulinaColors.background 一致),上层居中 @drawable/culinatech_logoandroid/app/src/main/res/drawable/culinatech_logo.xml:CulinaTech wordmark vector drawable,从 assets/images/culinatech_logo.svg(172×28 viewBox,white fill,源自 hydrogen-beta header SVG)转换而来android/app/src/main/res/values/colors.xml:新增 splashBackground = #141416净效果:从原生 splash → 权限对话框窗口 → Flutter 第一帧整条链路上,背景颜色都是 #141416、logo 始终居中——无主题切换闪烁。
⚠️ iOS 启动页(
ios/Runner/Base.lproj/LaunchScreen.storyboard)目前仍是 Flutter scaffolding 默认。iOS 仅首次启动会在runApp()之后由PermissionsIntroPage的 Continue 触发requestAllUpfront();若perm_intro_seen_v1已存在,则仍会在runApp()之前执行AppPermissions.requestAllUpfront()。,但品牌一致性还差一份——独立任务。
NotificationService + foreground_service.dart(65 行小封装)启动一个 Android 前台 service(通过 flutter_local_notifications)防止 OS 激进回收进程,同时在通知栏显示「Monitoring N device(s)」(带本地化,per Liang 2026-05-08 i18n bug fix,详见 告警与通知 §本地化)。Android 12+ 在 AndroidManifest.xml 上声明 dataSync 类型的 FOREGROUND_SERVICE_TYPE。
⚠️ PARTIAL_WAKE_LOCK(per TAPD culinatech_log_20260519175853,17ed4db):FG 服务只防 OS 回收进程——不阻止 Doze 冻结 Dart timers 与
flutter_blue_plusscan 回调。Log 实证:booster 17:46 放回 RSSI 范围、屏幕关时 App 完全没注意到,直到 17:58 用户点亮屏幕的瞬间 Dart 才恢复(三次 reconnect 尝试间隔 27.8 s / 145.6 s / 200.6 s 而非配置的 6 s,第二台手机同时段秒扫到CM1_E4EF)。修复在BleMonitoringService.kt:onStartCommand取PowerManager.PARTIAL_WAKE_LOCK(tagculinatech:BleMonitoring)、stopForegroundCompat释放——保 CPU 不睡,但屏幕仍可关;生命周期与 FG 通知一致(任何设备被追踪期间都持,无 timeout)。电量消耗由用户预期收口——服务只在烹饪进行时跑。验证路径:屏幕关 SCAN_END 应报no match after ~6000ms(不是 27 s+),连续RECONNECT_ATTEMPT间隔 ~15 s(fast tier)。同 commit 顺手把_attemptReconnect的 SCAN_START log 硬编timeout=3s修成6s(与 bc7d627 实际 timeout 对齐——之前日志说谎让 debug 跑偏多次)。
⚠️ 后台 BLE 扫描必须带 MAC ScanFilter(per Liang 2026-04-20 + TAPD culinatech_log_20260520142704,2994f53):PARTIAL_WAKE_LOCK 保 CPU 唤醒但不解决 Android 后台结果投递抑制——log 实证 Xiaomi App 后台 2 h 17 m 中 reconnect 循环按 ~22 s 节奏跑了 370+ 次(wake lock 没问题,scan duration 也对),但 432 / 518
SCAN_END报0 total results, 0 unique advertisements(83 % 时间根本零广告),同时段另一台手机持续扫到CM1_E4EF;这是 Android 对 backgrounded 进程的**「无ScanFilter的startScan静默丢掉所有广告」行为,filter 由 OS 在投递决策之前做,所以 filtered scan 绕过抑制。修复:_attemptReconnect给FlutterBluePlus.startScan传withRemoteIds: knownMacs(saved MAC 来自savedRemoteIdToName/loadRemoteId(deviceId),每次成功连接后由_saveRemoteId写入;withRemoteIds: []等同 unfiltered,但 reconnect 路径里只有至少连过一次的设备能到这里,所以 cold-start 不进 filter);为什么不按名字 filter——per Liang 2026-04-20,legacy 中继盒有时不在广告里发 Local Name,withKeywords会静默漏掉,MAC 是 radio-level 地址不依赖 payload。User-scan (BleService.scanForDevices) 故意不**加 filter——配对发现确实需要看见所有广告。Xiaomi/MIUI 警告:MIUI 在 stock Android 之上还加了 battery-management,1.5 h+ 仍出问题就只能由用户在 Settings → Apps → CulinaTech 手动放行(Battery saver = No restrictions / Autostart = On / Background activity = Allowed / Show on lock screen = On),代码不可达。验证路径:App 后台时SCAN_END应记filteredMACs=N而非unfiltered;booster 入 RSSI 范围应在一个 ~22 s scan 周期内重连——注:屏幕关 + MIUI 场景 MAC filter 也被抑制,详见下方 39c03b4 callout。
⚠️ autoConnect=true on reconnect path(per TAPD 1003034 culinatech_log_20260521121653,39c03b4):MAC ScanFilter 仍不够——Liang Xiaomi 15 Pro(MIUI 全白名单 / 电池无限制 / Autostart 开 / Background activity 放行)log 实证:booster
-35 dBm持续广播、App MAC-filtered scan 每 22 s 跑、wake lock 持,但 11:44:20 → 12:15:17 31 分钟内 85 个SCAN_END全0 result;用户 12:15:17 点亮屏幕的瞬间下一帧 scan 1080 ms 命中。结论:OS(至少 MIUI,可能也包括其它 OEM)在屏幕关时把 app-issuedstartScan回调挂起,filter / scan mode / 电池白名单 / wake lock 都不解决——上一条 callout 推测的"filter 由 OS 在投递决策之前做所以 filtered scan 绕过抑制"在 screen-off MIUI 不成立。最小杠杆修复(实验性):reconnect 路径BleService._directConnectImpl(SCAN_MATCH命中 remoteId 之后调)改connect(autoConnect: true, mtu: null)——把 remoteId 交给 Android controller 层 background scan whitelist(运行在BluetoothLeScanner之下,部分 Android 版本 throttle 规则不同);初始 user-tap(BleService._connectViaDevice)保持autoConnect: false(用户在等、OS-managed 5–10 s latency 不合适)。connect()立即返回(fbp 在 autoConnect 时忽略timeout),所以改device.connectionState.where(connected).first.timeout(5s)bound per-attempt 延迟、~22 s reconnect cadence 不变;timeout 不调disconnect()——autoConnect 注册存活到下次_teardownConnection的显式device.disconnect()才解除(OS-managed reconnect 只活到下次 disconnect event,不跨整个 device lifecycle)。CM4 family 的requestMtu(512)后置 workaround(2026-04-20 起)保留。验证路径:Liang 重跑 1003034(连上 → 锁屏关屏 → 走出 RSSI 范围 → 等断 → 走回范围 → 屏保持关),观察success (took Xms via autoConnect) trigger=auto-reconnect;秒级重连且无LIFECYCLE foregrounded同窗口 → 修复 confirmed;仍要点屏 → 升级到 foreground service 内 PendingIntent native scan(已落地:自启动 OFF 下此实验失败,详见下方 749cac1 callout)。已撤回:727ff55(TAPD 1003057 Pixel 8a regression,logculinatech_log_20260522140059.txt)回退到autoConnect=false, timeout=5s, mtu=null——autoConnect=true文档标 5–10 s 典型 latency、5 sstateWaitbudget 太紧,CM2_0E1A 每个CONNECT_CALL都TimeoutException,循环 10+ 分钟才在 Liang 主动 foreground 后成功;同时 749cac1 callout 末尾的 follow-up(Liang 152046 PI-scan log)也证伪了 controller 层 whitelist 比 app-issued scan 更耐受 screen-off 这一假设,autoConnect=true 这一层「假定有好处」未兑现。回退后是 39c03b4 前的 ~600 ms 重连 code path(log 103854 / 143121),与下方 749cac1 PI-scan 路径正交叠加。
⚠️ PendingIntent BLE scan(per TAPD 1003034 step 2,749cac1):上一条 39c03b4
autoConnect=true实验在 Xiaomi 15 Pro 自启动 OFF 下实测失败(logculinatech_log_20260521155845.txt)——controller 层 background-scan whitelist 与 app-issuedstartScan一样过 MIUI BG-policy 过滤、screen-off 时一样挂起;并行验证「自启动 ON」(log 143121 / 162309)通过(~6 分钟无人值守 BG 重连、window 内无LIFECYCLE foregrounded),但不能要求终端用户开装时自行翻 MIUI 菜单。修复改走 Android 官方 BG-tolerant API:BluetoothLeScanner.startScan(filters, settings, pendingIntent)——OS 把每条 match 作为 wakeful broadcast intent 投递、穿透 screen-off scan-result deferral,与 wake-on-geofence / wake-on-BLE-beacon 同机制。架构:(1) 新 KotlinBleScanReceiver.kt(AndroidManifest.xml注册,android:exported="false",无 intent-filter——靠 PendingIntent 内嵌的 explicitComponentName路由;从 intent extras 取ScanResult列表,经culinatech.app/pending_intent_scanMethodChannel 把(mac, name, rssi)转给 Dart;device.name在缺BLUETOOTH_CONNECT时会抛、fallback 到scanRecord.deviceName直接读广告 payload 不要权限);(2)BleMonitoringServicecompanion 加start/stopPendingIntentScan+attach/detachDartScanChannel+notifyScanMatch/Errorstatic surface(让 receiver 与 MethodChannel handler 共用,不依赖 Service 实例);(3)MainActivity.kt把culinatech.app/pending_intent_scanchannel 绑到 companion,onDestroy时 detach 避免悬挂 binaryMessenger;(4)pending_intent_scan_service.dartsingleton 包 MethodChannel + broadcast stream(dev log 把 MAC mask 到末 5 位避 PII);(5)BleDeviceService构造时 sub PI scan stream,notifyAppBackgrounded(true)收_devices ∪ _reconnectQueue的 MAC(经loadRemoteId)启 OS-managed scan + 建 mac→deviceId snapshot(receiver 只携 mac、Dart 侧需 deviceId 给directConnect),(false)停 scan 清 snapshot;match 入_onPendingIntentMatch解析、若已连则跳过(FIRST_MATCH在 connect handshake 间短暂可能 re-fire),否则directConnect(trigger=pending-intent-match)——既存 reconnect-loop scan 并行跑、谁先找到谁赢、directConnect顶部 dedupe 处理 race。Scan 设置:SCAN_MODE_LOW_POWER+CALLBACK_TYPE_FIRST_MATCH+MATCH_MODE_AGGRESSIVE(最低电量、每设备每可见性窗口仅一次 broadcast、低置信度阈值换更快唤醒);PendingIntent 用FLAG_MUTABLE(Android 12+ 要求让 OS 填 scan-result extras)+FLAG_UPDATE_CURRENT(stopScan(pendingIntent)按 identity 匹配,同 slot 复用避免再注册留 gap)。Deferred / process-dead delivery 路径未修:MIUI 完全 kill 进程后 OS 会瞬时拉起进程跑BleScanReceiver,但 Flutter engine 在此路径未 auto-init、dartScanChannel == null、notifyScanMatchno-op;FG 服务在「backgrounded-but-alive」一般场景下保进程(Liang 测试是此类),dead-process path 需要 callback dispatcher pattern(参考flutter_local_notifications/workmanager),独立 follow-up。验证路径:Liang Xiaomi 重跑 1003034 / 自启动 OFF:观察PI_SCAN_START | macs=N (…XX:XX,…YY:YY)→PI_SCAN_MATCH | mac=…XX:XX rssi=Xdbm→PI_SCAN_RECONNECT | OS-delivered match … initiating directConnect+ 既存CONNECT_RESULT,且 window 内无LIFECYCLE | resumed (foregrounded);若仍要点屏 → MIUI 对 PI broadcast 也过滤,升级到用户侧 autostart UI 引导或文档化自启动 ON作为先决条件。Liang 2026-05-22 豁免 verdict:Liang 在 PI-scan APK 上跑 1003034 / 自启动 OFF(logculinatech_log_20260522152046.txt),实测 OS 确实在 BG / 锁屏 / 灭屏下投递 wakeful broadcasts——PI_SCAN_MATCH15:20:25 + reconnect 15:20:28(trigger=pending-intent-match),LIFECYCLE foregrounded直到 15:20:41 才出现(16 s 后;先前的"screen-on 才触发"是 post-hoc perception 误判)。瓶颈不在 OS 投递、而在 MIUI Doze maintenance window 把 delivery batch 10–40 分钟(vs 自启动 ON 时 ~6 分钟),Liang 接受这是 OS-imposed 上限并下「豁免」verdict("一般用户应该不会去开,它藏的比较深"),要求 App 在首次启动时把用户 walk through 这些 per-OEM 设置——727ff55 ships 该 onboarding flow(OEM-aware 系统电池设置 + MIUI 自启动 / EMUI 启动管理 / ColorOS 启动管理 / FuntouchOS 后台高耗电 deep-links),详见 后台权限服务。
Layer A:同 iOS,detached → _AppExit.run()。
Layer B:MethodChannel culinatech.app/lifecycle,原生 Kotlin 侧触发。
当前实现(android/app/src/main/kotlin/com/example/culinatech_app/MainActivity.kt):
override fun onDestroy() → methodChannel?.invokeMethod("onDestroy", null)onTaskRemoved(用户从最近任务列表划掉 App 时触发)关键注释(MainActivity.kt:16-27):
Only
onDestroyis hooked here becauseonTaskRemovedis an Activity callback received only from the OS and anoverride fun onTaskRemoved(...)on an Activity requires a Service subclass for earlier delivery. Aggressive-OEM phones (Xiaomi/Huawei/OPPO) may not callonDestroyin time.
影响:在小米 / 华为 / OPPO 等国产 OEM 手机上,用户划掉 App 后,BLE / MQTT 可能未正常关闭(因为 onDestroy 被激进 kill 跳过)。
修复路径:实现一个 Service 子类 + override fun onTaskRemoved(rootIntent: Intent?) → 通过 MethodChannel 通知 Dart。详见 待解决问题 TAPD #9。BleMonitoringService.kt 已经是这个 Service 子类的开始——目前承担前台服务托管职责,未来加 onTaskRemoved 钩子也放它身上。
v4.2 规定(v4.2 第一轮):行为与前台一致。
当前代码:_AppExitObserver 把 paused / hidden 事件转给 BleDeviceService.notifyAppBackgrounded(true)、resumed 转 false;inactive 状态被有意忽略(拉通知栏、来电等瞬时态不应翻 fg/bg)。前台扫描 / 重连节奏不变。⚠️ 5940b81 / TAPD #1003274 起 _AppExitObserver 同时持 ProviderContainer 把 fg/bg 转换 fan-out 给三家时间型 watchdog:私有 _setBackgrounded(bool) 串行调 BleDeviceService.notifyAppBackgrounded + container.read(deviceConnectionManagerProvider).setAppBackgrounded + container.read(connectedBoostersProvider.notifier).setAppBackgrounded。理由:Android 在 bg 期冻结 Dart 事件循环、挂起 MQTT socket、停推 BLE adapter-state stream——resume 时 overdue 的 MQTT staleness clock / cloud _checkStaleness 扫 / _adapterOn 缓存都失真,未补 fan-out 之前会触发 boosterDisconnected + probeDisconnected 假报警通知 + WiFi 模式 BT 关时翻 "Scanning for CM4 booster" / "Bluetooth must be turned on" UI 闪烁(约 3 s 后 cloud 自愈才 settle)。新 hook 在 DeviceConnectionManager.setAppBackgrounded(false) 重新 seed _adapterOn、MqttService.setAppBackgrounded(false) 重置 staleness clock、ConnectedBoostersNotifier.setAppBackgrounded(false) 把 cloud 设备的 _lastSeen forgive 到当下,详见 重连与宽限期 §_cloudLostConnectionAfter 末尾 5940b81 callout。同 commit NotificationService.cancel* / cancelAll 都包 try/catch(field log 实证 minified build 下 R8 strip 掉 flutter_local_notifications 的 Gson TypeToken 泛型 Signature 导致 Missing type parameter. FATAL,resume 报警清扫风暴下命中两次),并新建 android/app/proguard-rules.pro 持 Gson / TypeToken / com.dexterous.** keep 规则 wired-but-inert(isMinifyEnabled = false 不动、未来开启 minify 时立即生效)。⚠️ 1fe6b3e3 / TAPD #1003291 起 fan-out 同 Android 路径还喂 BackgroundKillWatchdog:Platform.isAndroid 闸内,backgrounded == true 调 BackgroundKillWatchdog.instance.onBackgrounded(monitoring: <connectedBoostersProvider 非空>) 落 SharedPreferences key bg_watchdog_monitoring_v1 + bg_watchdog_since_ms_v1;backgrounded == false 调 onForegrounded() 清 flag(unawaited fire-and-forget,丢一次 prefs 写最多漏一次检测、不会假阳)。冷启动时 _AppInitializer.initState 第一件事 latch captureColdStartVerdict()、_maybeReNudgeAfterKill 在 onboarding 链尾按 nag-worthiness + OEM autostart 存在 + 3 天 cooldown 决定是否 push 第二次 BackgroundPermissionPage,详见 后台权限服务 §首次启动 onboarding 流程 末尾 1fe6b3e3 callout。
v4.2 规定(早期"被动模式"读法):App 在后台连续 4 小时无连接 → 降级为被动模式(不主动扫描,仅监听断开事件,锁刷新 45s 一次)。
当前实现(per Liang 2026-04-29 follow-up to E verification,修订自 2026-04-28 的"被动模式"读法):4 小时后只切换重连节奏——后台 <4h 与前台一致(始终 15 秒不衰减),后台 ≥4h 走 1–5 / 6–30 / 31+ 分级;重连循环本身不停。BleDeviceService._bgTieredCadenceArmTimer(4 小时,ble_device_service.dart:484)+ _inBgTieredCadenceMode 标志(:491)实现,纯生命周期驱动(bg-entry 起计时,fg-resume 取消并清标志),与连接状态解耦。DevLogService 打 APP_BACKGROUNDED / APP_FOREGROUNDED / BG_CADENCE_TIERED_ENTER 三个事件(旧的 POWER_SAVER_* 系列已撤)。详见 重连与宽限期 §重连退避节奏。
⚠️ 6dbaaa7:
MainActivity.getRenderMode()覆盖为RenderMode.texture。Android 前台服务(BleMonitoringService)刻意把进程 +FlutterEngine保活跨长背景;但在 Samsung / Android 13 上夜间长背景中,OS 会把默认FlutterSurfaceView的底层 surface 销毁、进程却存活。resume 时 DartAppLifecycleState正常 fireresumed,provider 也继续从 BLE telemetry 更新(log#1003140实证),但 SurfaceView 留下一个黑洞(无 surface 的 SurfaceView 把 window 戳穿、连 launch-logo windowBackground 都不显),renderer 不再 reattach 到新 surface →「黑屏 / 无响应 / 返回退出 / 重开恢复」。修复:MainActivity.kt加override fun getRenderMode(): RenderMode = RenderMode.texture,让 Flutter 走FlutterTextureView——一个普通 view(无 surface 洞),resume 时重建底层SurfaceTexture,renderer 自动 reattach、resume 首帧正常 paint。App 不嵌 PlatformViews,TextureView 的 compositing 代价可忽略。
Platform.isIOS / Platform.isAndroid / kIsWeb)| 位置 | 用途 |
|---|---|
main.dart |
启动时音量 / 权限对话框(同代码两平台) |
permissions_service.dart |
按平台返回不同 permission 列表(iOS 2 项 / Android 4 项) |
ble_service.dart |
iOS/Android 扫描策略差异 |
notification_service.dart |
Android 前台服务启动;iOS Critical Alerts 申请;Live Activity sync 仅 iOS |
cooking_live_activity_service.dart |
iOS 16.1+ 走 live_activities plugin;Android / 老 iOS no-op |
kIsWeb 分支 |
Web 禁用 BLE/通知(Web 非发布目标) |
iOS 有时会静默撤销 Bluetooth 权限,App 安装几天后 BLE 突然不工作,重装才恢复。