|
|
@@ -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);
|
|
|
+ }
|
|
|
+}
|