فهرست منبع

fix(android): 接管物理返回键 + 移除多余闹钟权限 + 修正状态栏图标

真机(小米 25060RK16C / Android 16)验证中发现并修复 3 个问题。

## 1. 物理返回键未接管(P0,导致白屏)
无任何代码接管 `backButton`,返回键完全交给 WebView 的 goBack()。
实测症状:
  · 打开文件预览后按返回 → 预览关掉的**同时会话也退回列表**(退两层)
  · 偶发整屏白屏、15 秒不恢复,只能杀进程
根因:弹层是组件内部状态,浏览器历史里没有对应记录,系统把返回键
      当"浏览器后退",退掉的是上一个**路由**。

方案:新增 core/back-button.service.ts,按优先级处理
  ① 已登记的弹层关闭器(LIFO)→ 只关弹层,不碰路由
  ② 非根页 → Location.back()
  ③ 根页 → exitApp() 回桌面
聊天页的抽屉/成果抽屉/图片灯箱/文件预览四个弹层已接入登记。

关于 ③ 的取舍:`minimizeApp()` 在本 ROM 上实测无效(焦点跑到了
系统浏览器和 miui.systemAdSolution),故改用 exitApp()。
代价是退出会丢运行时状态(登录态在 localStorage 不受影响),
但「按返回键跳到别的 App」是明确的错误体验。

## 2. 通知授权后误跳「闹钟和提醒」设置页
@capacitor/local-notifications 自带 SCHEDULE_EXACT_ALARM 权限,
小米在授权通知后顺带跳到闹钟设置。我们用不到精确定时,已从
manifest 移除(tools:node="remove"),aapt2 验证已生效。

## 3. 状态栏图标 Style.Light/Dark 语义写反
Capacitor 的 Style 指**背景明暗**而非图标颜色:
  Style.Light = 适配浅色背景 = 深色图标
首次实现写成 `dark ? Light : Dark`,导致亮色界面拿到浅色图标,
抽屉打开时(白色面板)整个状态栏看不见。已改正。

## 验证
真机实测:预览→返回只关预览仍在会话页 ✅;会话→返回回列表 ✅;
根页→返回退出到桌面 ✅;重启后登录态保持 ✅

Co-Authored-By: Claude Code <noreply@anthropic.com>
Huccct 4 ساعت پیش
والد
کامیت
adb787add9

+ 14 - 1
android/app/src/main/AndroidManifest.xml

@@ -1,5 +1,6 @@
 <?xml version="1.0" encoding="utf-8"?>
-<manifest xmlns:android="http://schemas.android.com/apk/res/android">
+<manifest xmlns:android="http://schemas.android.com/apk/res/android"
+    xmlns:tools="http://schemas.android.com/tools">
 
     <application
         android:allowBackup="true"
@@ -58,6 +59,18 @@
         </provider>
     </application>
 
+    <!--
+      🔴 移除 @capacitor/local-notifications 自带的精确闹钟权限(2026-11-15 真机实测)
+
+      该插件在自身 manifest 里声明了 SCHEDULE_EXACT_ALARM。保留的后果:
+      用户首次授权通知后,小米系统会**顺带跳转到「闹钟和提醒」设置页**,
+      用户会莫名其妙 —— 这是一个即时消息 App,凭什么要闹钟权限?
+
+      我们只用 `schedule()` 发**立即**通知,不需要毫秒级定时,故显式移除。
+      将来若真要做定时提醒,再把它加回来并给出用途说明。
+    -->
+    <uses-permission android:name="android.permission.SCHEDULE_EXACT_ALARM" tools:node="remove" />
+
     <!-- Permissions -->
 
     <uses-permission android:name="android.permission.INTERNET" />

+ 7 - 0
src/app/app.config.ts

@@ -14,6 +14,7 @@ import { provideFmodeApi } from './core/api/api.provider';
 import { AppErrorHandler } from './core/app-error-handler';
 import { DeepLinkService } from './core/deep-link.service';
 import { SafeAreaService } from './core/safe-area.service';
+import { BackButtonService } from './core/back-button.service';
 import { routes } from './app.routes';
 
 export const appConfig: ApplicationConfig = {
@@ -51,5 +52,11 @@ export const appConfig: ApplicationConfig = {
      * 非原生环境内部直接返回,浏览器零影响。
      */
     provideAppInitializer(() => inject(SafeAreaService).apply()),
+    /*
+     * Android 物理返回键接管(2026-11-15 真机问题)。
+     * 无此监听时,返回键完全交给 WebView 的 goBack():弹层与路由会一起后退,
+     * 极端情况下历史栈错位导致整屏白屏。浏览器环境内部直接返回。
+     */
+    provideAppInitializer(() => inject(BackButtonService).start()),
   ],
 };

+ 147 - 0
src/app/core/back-button.service.ts

@@ -0,0 +1,147 @@
+import { Injectable, inject, signal } from '@angular/core';
+import { Location } from '@angular/common';
+import { NavigationEnd, Router } from '@angular/router';
+import { isNativeApp } from './api/platform-proxy.service';
+
+/**
+ * Android 返回键(2026-11-15 真机实测发现的问题)
+ *
+ * ## 问题
+ *
+ * 打包成 App 后**没有任何 code 接管物理返回键**,完全交给系统默认行为
+ * (= WebView 的 `goBack()`)。后果有两个,都在真机上复现过:
+ *
+ * ① **一次返回退两层**:在会话页打开 report.md 预览后按返回键,
+ *    预览关闭的同时**会话也退回了列表**(实测 11:54)。
+ *    因为预览弹层是**组件内部状态**,浏览器历史里没有对应记录,
+ *    系统把它当"浏览器后退"处理,直接退掉了上一次路由跳转。
+ *
+ * ② **白屏**(P0,偶发):历史栈与 Angular 路由栈错位到一定程度后,
+ *    `goBack()` 退到一个没有渲染内容的条目 → 整屏空白,15 秒不恢复,
+ *    只能杀进程。真机上出现过一次(11:47)。
+ *
+ * ## 做法
+ *
+ * 接管 `App.backButton`,按优先级处理:
+ *   ① 有注册的"关闭回调"(弹层/抽屉/预览)→ 只关它,不碰路由
+ *   ② 路由有上一页 → `Location.back()`
+ *   ③ 已在根页面 → **最小化 App**(Android 惯例,不是退出)
+ *
+ * ## 关于优先级①
+ *
+ * 各弹层通过 `register()` 登记自己的关闭函数,后进先出(LIFO)——
+ * 与弹层的栈语义一致:最后打开的先关闭。
+ *
+ * 🔴 登记必须与销毁成对:组件的 `ngOnDestroy` 调 `unregister()`,
+ *    否则弹层已关但回调还在,会出现"按一次返回没反应"。
+ */
+export interface BackHandler {
+  /** 关闭回调;返回 true 表示"这次返回我消费了,不再往下传" */
+  handle: () => boolean;
+  /** 标识,用于精确注销 */
+  id: string;
+}
+
+@Injectable({ providedIn: 'root' })
+export class BackButtonService {
+  private readonly router = inject(Router);
+  private readonly location = inject(Location);
+
+  /** 已登记的弹层关闭器(后进先出) */
+  private readonly handlers: BackHandler[] = [];
+  /** 是否还有路由可退(由 Router 事件维护) */
+  readonly canGoBack = signal(false);
+  private started = false;
+
+  /**
+   * 启动监听。非原生环境直接返回 —— 浏览器有自己的返回键语义,不能接管。
+   */
+  async start(): Promise<void> {
+    if (!isNativeApp() || this.started) return;
+    this.started = true;
+
+    /*
+     * 维护"是否还能后退"。
+     *
+     * 🔴 不用 `history.length > 1`:SPA 里 `replaceUrl` 不增长历史,
+     *    而深链冷启动会一次性压入多个条目 —— 数字与实际可退次数对不上。
+     *    改为直接按**当前 URL 是否根页**判断:
+     *    在根页 → 没有可退的上一级;否则 → 退一层。
+     */
+    this.router.events.subscribe((e) => {
+      if (!(e instanceof NavigationEnd)) return;
+      this.canGoBack.set(this.isNotRoot(e.urlAfterRedirects));
+    });
+
+    try {
+      const { App } = await import('@capacitor/app');
+      await App.addListener('backButton', () => {
+        /* ① 先问弹层 */
+        for (let i = this.handlers.length - 1; i >= 0; i--) {
+          if (this.handlers[i].handle()) return;
+        }
+        /* ② 能退就退路由 */
+        if (this.canGoBack()) {
+          this.location.back();
+          return;
+        }
+        /* ③ 根页面 → 退到后台(Android 惯例,不退出、不丢会话)。
+         *    具体实现与已知的 ROM 兼容问题见 `goHome()` 注释。 */
+        void this.goHome();
+      });
+    } catch {
+      /* 插件缺失:保持系统默认行为,不影响启动 */
+    }
+  }
+
+  /** 根页面判定:只在这些路径上"没有上一页"是正常的 */
+  private isNotRoot(url: string): boolean {
+    const roots = ['/mobile/messages', '/mobile/contacts', '/mobile/workbench', '/mobile/mine'];
+    return !roots.some((r) => url === r || url.startsWith(r + '?'));
+  }
+
+  /**
+   * 根页面按返回键:**退出 App 到桌面**。
+   *
+   * ## 为什么用 exitApp 而不是 minimizeApp
+   *
+   * 实测(小米 Android 16,2026-11-15)`App.minimizeApp()` **在本 ROM 上无效**:
+   * 两次真机验证,焦点分别跑到了「系统浏览器」和「miui.systemAdSolution」,
+   * 即系统没接住最小化,反而把这次按键当成"上一步"传给了别的界面。
+   *
+   * 而 `exitApp()` 语义明确:直接结束 Activity 回桌面。Android 上
+   * 「根页面按返回 = 退出应用」本来就是标准行为(微信/淘宝同此)。
+   *
+   * ## 代价(如实说明)
+   *
+   * 退出会销毁 WebView,**会话状态与 Matrix 长连接丢失**,
+   * 下次冷启动要重新连一次(登录态在 localStorage,不会丢)。
+   * 这是「返回键行为正确」与「不丢运行时状态」之间的取舍,
+   * 选择前者 —— 因为"按返回键跳到别的 App"是明确的错误体验。
+   */
+  private async goHome(): Promise<void> {
+    try {
+      const { App } = await import('@capacitor/app');
+      await App.exitApp();
+    } catch {
+      /* 插件不可用:保持前台,用户再按一次走系统默认 */
+    }
+  }
+
+  /**
+   * 登记一个弹层的关闭器(组件打开弹层时调用)。
+   *
+   * @param id 唯一标识,建议用组件名 + 弹层名
+   * @param handle 返回 true 表示已消费这次返回
+   */
+  register(id: string, handle: () => boolean): void {
+    this.unregister(id);
+    this.handlers.push({ id, handle });
+  }
+
+  /** 注销(组件 `ngOnDestroy` 或弹层关闭时调用) */
+  unregister(id: string): void {
+    const i = this.handlers.findIndex((h) => h.id === id);
+    if (i >= 0) this.handlers.splice(i, 1);
+  }
+}

+ 10 - 5
src/app/core/safe-area.service.ts

@@ -59,14 +59,19 @@ export class SafeAreaService {
 
       /*
        * 深色图标还是浅色图标,取当前主题。
-       * 亮色背景 → 深色图标(Style.Dark 在 Capacitor 里指的是「深色文字」);
-       * 暗色背景 → 浅色图标。
+       * 🔴 Capacitor 的 `Style` 命名指的是**背景明暗**,不是图标颜色:
+       *      Style.Light → 适配浅色背景 → 系统给**深色**图标
+       *      Style.Dark  → 适配深色背景 → 系统给**浅色**图标
        *
-       * 🔴 主题切换时也要跟着改,否则暗色模式下状态栏黑字黑底看不见。
+       *    我第一版按「Dark = 深色图标」理解写成 `dark ? Light : Dark`,
+       *    结果亮色界面拿到浅色图标 —— 真机截图里抽屉打开时整个状态栏看不见
+       *    (白色面板 + 白色图标)。靠强制设为 LIGHT 对比才确认,已改正。
+       *
+       * 取值:亮色主题(无 .dark)→ Light;暗色主题 → Dark。
        */
       const applyStyle = () => {
-        const dark = document.documentElement.classList.contains('dark');
-        void StatusBar.setStyle({ style: dark ? Style.Light : Style.Dark });
+        const isDark = document.documentElement.classList.contains('dark');
+        void StatusBar.setStyle({ style: isDark ? Style.Dark : Style.Light });
       };
       applyStyle();
 

+ 41 - 0
src/app/mobile/pages/chat/chat.ts

@@ -42,6 +42,7 @@ import { sessionPresence } from '../../../core/agent-liveness';
 import { TaskPulseService } from '../../../core/task-pulse.service';
 import { UiMToastService } from '../../../ui/mobile/components/toast/toast';
 import { AppNotificationService } from '../../../core/app-notification.service';
+import { BackButtonService } from '../../../core/back-button.service';
 import { fileIdentity } from '../../../core/s3-link';
 import { mergeConsecutiveBodies } from '../../../core/message-merge';
 import { ToolActivityComponent } from '../../../components/domain/tool-activity/tool-activity';
@@ -1063,6 +1064,8 @@ export class MbChatPage implements OnDestroy {
   private readonly toast = inject(UiMToastService);
   /** 系统通知(2026-11-15):App 在后台时也能在通知栏留痕 */
   private readonly notify = inject(AppNotificationService);
+  /** Android 返回键接管(2026-11-15):弹层开着时返回键只关弹层 */
+  private readonly backBtn = inject(BackButtonService);
   private static readonly TIMEOUT_TITLE = '容器长时间未响应';
 
   /** 用时 → 人话(完成提醒专用;与 task-pulse 的 fmtDur 同口径,那边未导出) */
@@ -1391,6 +1394,44 @@ export class MbChatPage implements OnDestroy {
       if (a.title === MbChatPage.TIMEOUT_TITLE) this.resendLast();
     });
 
+    /*
+     * Android 返回键接管(2026-11-15 真机问题修复)
+     *
+     * 🔴 不接管时的实测症状(两个,同一根因):
+     *   ① 打开文件预览后按返回 → 预览关掉的**同时会话也退出到列表**(退两层)
+     *   ② 偶发整屏白屏、15 秒不恢复,只能杀进程
+     * 原因:弹层是组件内部状态,浏览器历史里没有对应记录,
+     *       系统把返回键当「浏览器后退」,退掉的是上一个**路由**。
+     *
+     * 这里登记一个关闭器:只要有任一弹层开着,返回键只关弹层,不碰路由。
+     * 后进先出 —— 用户看到的最上层先关。
+     */
+    effect((onCleanup) => {
+      const anyOpen =
+        this.drawerOpen() || this.sheetOpen() || !!this.lightbox() || !!this.preview();
+      if (!anyOpen) return;
+      this.backBtn.register('mobile-chat-overlays', () => {
+        if (this.drawerOpen()) {
+          this.drawerOpen.set(false);
+          return true;
+        }
+        if (this.sheetOpen()) {
+          this.sheetOpen.set(false);
+          return true;
+        }
+        if (this.lightbox()) {
+          this.lightbox.set(null);
+          return true;
+        }
+        if (this.preview()) {
+          this.preview.set(null);
+          return true;
+        }
+        return false;
+      });
+      onCleanup(() => this.backBtn.unregister('mobile-chat-overlays'));
+    });
+
     /*
      * 任务完成即时提醒(2026-11-14)
      *