Quellcode durchsuchen

feat(android): Capacitor 打包 + fmode:// 深链 + 本地通知地基(阶段 B)

## 接入 Capacitor 8.6.2
- @capacitor/core|android|cli|app|local-notifications|share
- capacitor.config.ts:appId=cn.fmode.app、webDir、origin 固定 https://localhost
- android/ 工程(深链与 Manifest 入库,构建产物已 gitignore)

## 深链 fmode://
- core/deep-link.service.ts:URL → 移动端路由,复用既有 mobile.routes.ts
- AndroidManifest:VIEW + DEFAULT + BROWSABLE + scheme=fmode
- 补冷启动 getLaunchUrl() 入口,避免首次分享/通知拉起时丢跳转

## 本地通知(替代极光 JPush)
- core/app-notification.service.ts:POST_NOTIFICATIONS 权限、同 key 覆盖、
  点击按 extra.deepLink 跳页
- 已接 TaskPulseService 完成脉冲
- 调研:极光官方 jpush-phonegap-plugin 923★/v6.3.0 仍在维护,是 Cordova 插件,
  Capacitor 8 仍支持;但无服务端推送链路时其离线推送价值用不上,故本期不接

## CORS 应对
- 实测 /api/apig/message 随机返回 3 条 ACAO(登录可能失败)
- parse-api.ts 新增 retryLoginRequest:网络类失败重试 3 次(400/900ms)
- PlatformProxyService 在原生壳跳过探测(/api-platform/** 在 App 内不存在)
- 产出 docs/03-interface/平台CORS多值修复需求-20261115.md(治本)

## 其他
- 更正 attachment-fetch / parse-api 里「s3 没配 CORS」的过时注释(已实测证伪)
- docs:外部分享接收设计、Android 打包实施记录

## 验证
- tsc ✅ · build ✅ 594.53 kB · core 测试 1362 SUCCESS ·
  components(排除 PDF)103 SUCCESS · Web 端回归无报错
- gradlew assembleDebug BUILD SUCCESSFUL → app-debug.apk 6.8 MB
- aapt2 确认 cn.fmode.app / targetSdk 36 / scheme=fmode / POST_NOTIFICATIONS
- 已知非本次引入:PDF 预览测试全量跑时 worker 崩溃(stash 验证改动前同样失败)

Co-Authored-By: Claude Code <noreply@anthropic.com>
Huccct vor 7 Stunden
Ursprung
Commit
8abe9af074
75 geänderte Dateien mit 2831 neuen und 13 gelöschten Zeilen
  1. 40 0
      android/.gitignore
  2. 2 0
      android/app/.gitignore
  3. 54 0
      android/app/build.gradle
  4. 20 0
      android/app/capacitor.build.gradle
  5. 21 0
      android/app/proguard-rules.pro
  6. 26 0
      android/app/src/androidTest/java/com/getcapacitor/myapp/ExampleInstrumentedTest.java
  7. 64 0
      android/app/src/main/AndroidManifest.xml
  8. 13 0
      android/app/src/main/assets/capacitor.config.json
  9. 10 0
      android/app/src/main/assets/capacitor.plugins.json
  10. 5 0
      android/app/src/main/java/cn/fmode/app/MainActivity.java
  11. BIN
      android/app/src/main/res/drawable-land-hdpi/splash.png
  12. BIN
      android/app/src/main/res/drawable-land-mdpi/splash.png
  13. BIN
      android/app/src/main/res/drawable-land-xhdpi/splash.png
  14. BIN
      android/app/src/main/res/drawable-land-xxhdpi/splash.png
  15. BIN
      android/app/src/main/res/drawable-land-xxxhdpi/splash.png
  16. BIN
      android/app/src/main/res/drawable-port-hdpi/splash.png
  17. BIN
      android/app/src/main/res/drawable-port-mdpi/splash.png
  18. BIN
      android/app/src/main/res/drawable-port-xhdpi/splash.png
  19. BIN
      android/app/src/main/res/drawable-port-xxhdpi/splash.png
  20. BIN
      android/app/src/main/res/drawable-port-xxxhdpi/splash.png
  21. 34 0
      android/app/src/main/res/drawable-v24/ic_launcher_foreground.xml
  22. 170 0
      android/app/src/main/res/drawable/ic_launcher_background.xml
  23. BIN
      android/app/src/main/res/drawable/splash.png
  24. 12 0
      android/app/src/main/res/layout/activity_main.xml
  25. 5 0
      android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml
  26. 5 0
      android/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml
  27. BIN
      android/app/src/main/res/mipmap-hdpi/ic_launcher.png
  28. BIN
      android/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png
  29. BIN
      android/app/src/main/res/mipmap-hdpi/ic_launcher_round.png
  30. BIN
      android/app/src/main/res/mipmap-mdpi/ic_launcher.png
  31. BIN
      android/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.png
  32. BIN
      android/app/src/main/res/mipmap-mdpi/ic_launcher_round.png
  33. BIN
      android/app/src/main/res/mipmap-xhdpi/ic_launcher.png
  34. BIN
      android/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png
  35. BIN
      android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png
  36. BIN
      android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png
  37. BIN
      android/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png
  38. BIN
      android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png
  39. BIN
      android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png
  40. BIN
      android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png
  41. BIN
      android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png
  42. 4 0
      android/app/src/main/res/values/ic_launcher_background.xml
  43. 7 0
      android/app/src/main/res/values/strings.xml
  44. 22 0
      android/app/src/main/res/values/styles.xml
  45. 6 0
      android/app/src/main/res/xml/config.xml
  46. 5 0
      android/app/src/main/res/xml/file_paths.xml
  47. 18 0
      android/app/src/test/java/com/getcapacitor/myapp/ExampleUnitTest.java
  48. 29 0
      android/build.gradle
  49. 59 0
      android/capacitor-cordova-android-plugins/build.gradle
  50. 7 0
      android/capacitor-cordova-android-plugins/cordova.variables.gradle
  51. 8 0
      android/capacitor-cordova-android-plugins/src/main/AndroidManifest.xml
  52. 0 0
      android/capacitor-cordova-android-plugins/src/main/java/.gitkeep
  53. 1 0
      android/capacitor-cordova-android-plugins/src/main/res/.gitkeep
  54. 9 0
      android/capacitor.settings.gradle
  55. 22 0
      android/gradle.properties
  56. BIN
      android/gradle/wrapper/gradle-wrapper.jar
  57. 7 0
      android/gradle/wrapper/gradle-wrapper.properties
  58. 251 0
      android/gradlew
  59. 94 0
      android/gradlew.bat
  60. 5 0
      android/settings.gradle
  61. 16 0
      android/variables.gradle
  62. 68 0
      capacitor.config.ts
  63. 16 0
      docs/00-tracking/操作追踪.md
  64. 154 0
      docs/02-design/plans/2026-11-15-Android打包实施记录.md
  65. 152 0
      docs/02-design/plans/2026-11-15-外部分享接收设计.md
  66. 114 0
      docs/03-interface/平台CORS多值修复需求-20261115.md
  67. 843 1
      package-lock.json
  68. 10 1
      package.json
  69. 11 0
      src/app/app.config.ts
  70. 48 6
      src/app/core/api/parse-api.ts
  71. 48 0
      src/app/core/api/platform-proxy.service.ts
  72. 156 0
      src/app/core/app-notification.service.ts
  73. 17 4
      src/app/core/attachment-fetch.ts
  74. 124 0
      src/app/core/deep-link.service.ts
  75. 19 1
      src/app/mobile/pages/chat/chat.ts

+ 40 - 0
android/.gitignore

@@ -0,0 +1,40 @@
+# Android 构建产物与本地缓存(2026-11-15 建立)
+#
+# 🔴 只忽略「可再生成」的东西。以下**必须入库**:
+#   · android/app/src/main/AndroidManifest.xml(深链 intent-filter 在这里)
+#   · android/app/src/main/assets/(Capacitor sync 出来的 Web 产物)
+#   · android/app/build.gradle、variables.gradle、settings.gradle
+#   · gradle/wrapper/gradle-wrapper.properties(gradle 版本锁定)
+
+# Gradle 构建输出
+/app/build/
+/build/
+.gradle/
+
+# Cordova 兼容层的构建产物(Capacitor 自动生成,随 sync 重建)
+/capacitor-cordova-android-plugins/build/
+
+# 🔴 Capacitor sync 出来的 Web 产物(2026-11-15)
+#
+# 这是 `dist/fmode-app-v2/browser/` 的拷贝,每次 `npm run cap:sync` 都会重新生成。
+# 入库的话:① 仓库体积每次构建都涨 ② 和 dist 内容重复 ③ 容易漏同步产生误导。
+# 需要构建时先 `npm run cap:sync` 即可。
+/app/src/main/assets/public/
+
+# 本地环境配置(Android Studio 自动生成,含本机 SDK 路径)
+local.properties
+
+# IDE
+.idea/
+*.iml
+
+# 构建缓存与临时文件
+.cxx/
+*.hprof
+captures/
+.externalNativeBuild/
+
+# 签名文件 —— 绝不入库(即使黄金规则允许明文存放,签名库泄露等价于别人能冒充发版)
+*.jks
+*.keystore
+!debug.keystore

+ 2 - 0
android/app/.gitignore

@@ -0,0 +1,2 @@
+/build/*
+!/build/.npmkeep

+ 54 - 0
android/app/build.gradle

@@ -0,0 +1,54 @@
+apply plugin: 'com.android.application'
+
+android {
+    namespace = "cn.fmode.app"
+    compileSdk = rootProject.ext.compileSdkVersion
+    defaultConfig {
+        applicationId "cn.fmode.app"
+        minSdkVersion rootProject.ext.minSdkVersion
+        targetSdkVersion rootProject.ext.targetSdkVersion
+        versionCode 1
+        versionName "1.0"
+        testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
+        aaptOptions {
+             // Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps.
+             // Default: https://android.googlesource.com/platform/frameworks/base/+/282e181b58cf72b6ca770dc7ca5f91f135444502/tools/aapt/AaptAssets.cpp#61
+            ignoreAssetsPattern = '!.svn:!.git:!.ds_store:!*.scc:.*:!CVS:!thumbs.db:!picasa.ini:!*~'
+        }
+    }
+    buildTypes {
+        release {
+            minifyEnabled false
+            proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro'
+        }
+    }
+}
+
+repositories {
+    flatDir{
+        dirs '../capacitor-cordova-android-plugins/src/main/libs', 'libs'
+    }
+}
+
+dependencies {
+    implementation fileTree(include: ['*.jar'], dir: 'libs')
+    implementation "androidx.appcompat:appcompat:$androidxAppCompatVersion"
+    implementation "androidx.coordinatorlayout:coordinatorlayout:$androidxCoordinatorLayoutVersion"
+    implementation "androidx.core:core-splashscreen:$coreSplashScreenVersion"
+    implementation project(':capacitor-android')
+    testImplementation "junit:junit:$junitVersion"
+    androidTestImplementation "androidx.test.ext:junit:$androidxJunitVersion"
+    androidTestImplementation "androidx.test.espresso:espresso-core:$androidxEspressoCoreVersion"
+    implementation project(':capacitor-cordova-android-plugins')
+}
+
+apply from: 'capacitor.build.gradle'
+
+try {
+    def servicesJSON = file('google-services.json')
+    if (servicesJSON.text) {
+        apply plugin: 'com.google.gms.google-services'
+    }
+} catch(Exception e) {
+    logger.info("google-services.json not found, google-services plugin not applied. Push Notifications won't work")
+}

+ 20 - 0
android/app/capacitor.build.gradle

@@ -0,0 +1,20 @@
+// DO NOT EDIT THIS FILE! IT IS GENERATED EACH TIME "capacitor update" IS RUN
+
+android {
+  compileOptions {
+      sourceCompatibility JavaVersion.VERSION_21
+      targetCompatibility JavaVersion.VERSION_21
+  }
+}
+
+apply from: "../capacitor-cordova-android-plugins/cordova.variables.gradle"
+dependencies {
+    implementation project(':capacitor-local-notifications')
+    implementation project(':capacitor-share')
+
+}
+
+
+if (hasProperty('postBuildExtras')) {
+  postBuildExtras()
+}

+ 21 - 0
android/app/proguard-rules.pro

@@ -0,0 +1,21 @@
+# Add project specific ProGuard rules here.
+# You can control the set of applied configuration files using the
+# proguardFiles setting in build.gradle.
+#
+# For more details, see
+#   http://developer.android.com/guide/developing/tools/proguard.html
+
+# If your project uses WebView with JS, uncomment the following
+# and specify the fully qualified class name to the JavaScript interface
+# class:
+#-keepclassmembers class fqcn.of.javascript.interface.for.webview {
+#   public *;
+#}
+
+# Uncomment this to preserve the line number information for
+# debugging stack traces.
+#-keepattributes SourceFile,LineNumberTable
+
+# If you keep the line number information, uncomment this to
+# hide the original source file name.
+#-renamesourcefileattribute SourceFile

+ 26 - 0
android/app/src/androidTest/java/com/getcapacitor/myapp/ExampleInstrumentedTest.java

@@ -0,0 +1,26 @@
+package com.getcapacitor.myapp;
+
+import static org.junit.Assert.*;
+
+import android.content.Context;
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import androidx.test.platform.app.InstrumentationRegistry;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+
+/**
+ * Instrumented test, which will execute on an Android device.
+ *
+ * @see <a href="http://d.android.com/tools/testing">Testing documentation</a>
+ */
+@RunWith(AndroidJUnit4.class)
+public class ExampleInstrumentedTest {
+
+    @Test
+    public void useAppContext() throws Exception {
+        // Context of the app under test.
+        Context appContext = InstrumentationRegistry.getInstrumentation().getTargetContext();
+
+        assertEquals("com.getcapacitor.app", appContext.getPackageName());
+    }
+}

+ 64 - 0
android/app/src/main/AndroidManifest.xml

@@ -0,0 +1,64 @@
+<?xml version="1.0" encoding="utf-8"?>
+<manifest xmlns:android="http://schemas.android.com/apk/res/android">
+
+    <application
+        android:allowBackup="true"
+        android:icon="@mipmap/ic_launcher"
+        android:label="@string/app_name"
+        android:roundIcon="@mipmap/ic_launcher_round"
+        android:supportsRtl="true"
+        android:theme="@style/AppTheme">
+
+        <activity
+            android:configChanges="orientation|keyboardHidden|keyboard|screenSize|locale|smallestScreenSize|screenLayout|uiMode|navigation|density"
+            android:name=".MainActivity"
+            android:label="@string/title_activity_main"
+            android:theme="@style/AppTheme.NoActionBarLaunch"
+            android:launchMode="singleTask"
+            android:exported="true">
+
+            <intent-filter>
+                <action android:name="android.intent.action.MAIN" />
+                <category android:name="android.intent.category.LAUNCHER" />
+            </intent-filter>
+
+            <!--
+              自定义 scheme 深链(2026-11-15)
+
+              fmode://chat/<agentId>/<sessionId>
+              fmode://messages  /  contacts  /  workbench  /  mine
+
+              用途:系统通知点击、微信/企微内点链接、其他 App 跳转,
+              都能把用户直接带到对应会话。解析逻辑见 `core/deep-link.service.ts`。
+
+              🔴 上面的 `launchMode="singleTask"` 是这套方案能工作的前提:
+                 已在运行时,新链接会通过 `onNewIntent` 交给同一个实例(`appUrlOpen` 事件),
+                 而不是再起一个 App 实例。验证时不要顺手把它改掉。
+
+              🔴 `android:autoVerify` **不加**:那是给 https 的 App Links 用的,
+                 自定义 scheme 没有域名归属校验,加了反而会产生误导。
+            -->
+            <intent-filter>
+                <action android:name="android.intent.action.VIEW" />
+                <category android:name="android.intent.category.DEFAULT" />
+                <category android:name="android.intent.category.BROWSABLE" />
+                <data android:scheme="fmode" />
+            </intent-filter>
+
+        </activity>
+
+        <provider
+            android:name="androidx.core.content.FileProvider"
+            android:authorities="${applicationId}.fileprovider"
+            android:exported="false"
+            android:grantUriPermissions="true">
+            <meta-data
+                android:name="android.support.FILE_PROVIDER_PATHS"
+                android:resource="@xml/file_paths"></meta-data>
+        </provider>
+    </application>
+
+    <!-- Permissions -->
+
+    <uses-permission android:name="android.permission.INTERNET" />
+</manifest>

+ 13 - 0
android/app/src/main/assets/capacitor.config.json

@@ -0,0 +1,13 @@
+{
+	"appId": "cn.fmode.app",
+	"appName": "Fmode",
+	"webDir": "dist/fmode-app-v2/browser",
+	"appendUserAgent": "FmodeApp/2.0",
+	"server": {
+		"androidScheme": "https",
+		"hostname": "localhost"
+	},
+	"android": {
+		"allowMixedContent": false
+	}
+}

+ 10 - 0
android/app/src/main/assets/capacitor.plugins.json

@@ -0,0 +1,10 @@
+[
+	{
+		"pkg": "@capacitor/local-notifications",
+		"classpath": "com.capacitorjs.plugins.localnotifications.LocalNotificationsPlugin"
+	},
+	{
+		"pkg": "@capacitor/share",
+		"classpath": "com.capacitorjs.plugins.share.SharePlugin"
+	}
+]

+ 5 - 0
android/app/src/main/java/cn/fmode/app/MainActivity.java

@@ -0,0 +1,5 @@
+package cn.fmode.app;
+
+import com.getcapacitor.BridgeActivity;
+
+public class MainActivity extends BridgeActivity {}

BIN
android/app/src/main/res/drawable-land-hdpi/splash.png


BIN
android/app/src/main/res/drawable-land-mdpi/splash.png


BIN
android/app/src/main/res/drawable-land-xhdpi/splash.png


BIN
android/app/src/main/res/drawable-land-xxhdpi/splash.png


BIN
android/app/src/main/res/drawable-land-xxxhdpi/splash.png


BIN
android/app/src/main/res/drawable-port-hdpi/splash.png


BIN
android/app/src/main/res/drawable-port-mdpi/splash.png


BIN
android/app/src/main/res/drawable-port-xhdpi/splash.png


BIN
android/app/src/main/res/drawable-port-xxhdpi/splash.png


BIN
android/app/src/main/res/drawable-port-xxxhdpi/splash.png


+ 34 - 0
android/app/src/main/res/drawable-v24/ic_launcher_foreground.xml

@@ -0,0 +1,34 @@
+<vector xmlns:android="http://schemas.android.com/apk/res/android"
+    xmlns:aapt="http://schemas.android.com/aapt"
+    android:width="108dp"
+    android:height="108dp"
+    android:viewportHeight="108"
+    android:viewportWidth="108">
+    <path
+        android:fillType="evenOdd"
+        android:pathData="M32,64C32,64 38.39,52.99 44.13,50.95C51.37,48.37 70.14,49.57 70.14,49.57L108.26,87.69L108,109.01L75.97,107.97L32,64Z"
+        android:strokeColor="#00000000"
+        android:strokeWidth="1">
+        <aapt:attr name="android:fillColor">
+            <gradient
+                android:endX="78.5885"
+                android:endY="90.9159"
+                android:startX="48.7653"
+                android:startY="61.0927"
+                android:type="linear">
+                <item
+                    android:color="#44000000"
+                    android:offset="0.0" />
+                <item
+                    android:color="#00000000"
+                    android:offset="1.0" />
+            </gradient>
+        </aapt:attr>
+    </path>
+    <path
+        android:fillColor="#FFFFFF"
+        android:fillType="nonZero"
+        android:pathData="M66.94,46.02L66.94,46.02C72.44,50.07 76,56.61 76,64L32,64C32,56.61 35.56,50.11 40.98,46.06L36.18,41.19C35.45,40.45 35.45,39.3 36.18,38.56C36.91,37.81 38.05,37.81 38.78,38.56L44.25,44.05C47.18,42.57 50.48,41.71 54,41.71C57.48,41.71 60.78,42.57 63.68,44.05L69.11,38.56C69.84,37.81 70.98,37.81 71.71,38.56C72.44,39.3 72.44,40.45 71.71,41.19L66.94,46.02ZM62.94,56.92C64.08,56.92 65,56.01 65,54.88C65,53.76 64.08,52.85 62.94,52.85C61.8,52.85 60.88,53.76 60.88,54.88C60.88,56.01 61.8,56.92 62.94,56.92ZM45.06,56.92C46.2,56.92 47.13,56.01 47.13,54.88C47.13,53.76 46.2,52.85 45.06,52.85C43.92,52.85 43,53.76 43,54.88C43,56.01 43.92,56.92 45.06,56.92Z"
+        android:strokeColor="#00000000"
+        android:strokeWidth="1" />
+</vector>

+ 170 - 0
android/app/src/main/res/drawable/ic_launcher_background.xml

@@ -0,0 +1,170 @@
+<?xml version="1.0" encoding="utf-8"?>
+<vector xmlns:android="http://schemas.android.com/apk/res/android"
+    android:width="108dp"
+    android:height="108dp"
+    android:viewportHeight="108"
+    android:viewportWidth="108">
+    <path
+        android:fillColor="#26A69A"
+        android:pathData="M0,0h108v108h-108z" />
+    <path
+        android:fillColor="#00000000"
+        android:pathData="M9,0L9,108"
+        android:strokeColor="#33FFFFFF"
+        android:strokeWidth="0.8" />
+    <path
+        android:fillColor="#00000000"
+        android:pathData="M19,0L19,108"
+        android:strokeColor="#33FFFFFF"
+        android:strokeWidth="0.8" />
+    <path
+        android:fillColor="#00000000"
+        android:pathData="M29,0L29,108"
+        android:strokeColor="#33FFFFFF"
+        android:strokeWidth="0.8" />
+    <path
+        android:fillColor="#00000000"
+        android:pathData="M39,0L39,108"
+        android:strokeColor="#33FFFFFF"
+        android:strokeWidth="0.8" />
+    <path
+        android:fillColor="#00000000"
+        android:pathData="M49,0L49,108"
+        android:strokeColor="#33FFFFFF"
+        android:strokeWidth="0.8" />
+    <path
+        android:fillColor="#00000000"
+        android:pathData="M59,0L59,108"
+        android:strokeColor="#33FFFFFF"
+        android:strokeWidth="0.8" />
+    <path
+        android:fillColor="#00000000"
+        android:pathData="M69,0L69,108"
+        android:strokeColor="#33FFFFFF"
+        android:strokeWidth="0.8" />
+    <path
+        android:fillColor="#00000000"
+        android:pathData="M79,0L79,108"
+        android:strokeColor="#33FFFFFF"
+        android:strokeWidth="0.8" />
+    <path
+        android:fillColor="#00000000"
+        android:pathData="M89,0L89,108"
+        android:strokeColor="#33FFFFFF"
+        android:strokeWidth="0.8" />
+    <path
+        android:fillColor="#00000000"
+        android:pathData="M99,0L99,108"
+        android:strokeColor="#33FFFFFF"
+        android:strokeWidth="0.8" />
+    <path
+        android:fillColor="#00000000"
+        android:pathData="M0,9L108,9"
+        android:strokeColor="#33FFFFFF"
+        android:strokeWidth="0.8" />
+    <path
+        android:fillColor="#00000000"
+        android:pathData="M0,19L108,19"
+        android:strokeColor="#33FFFFFF"
+        android:strokeWidth="0.8" />
+    <path
+        android:fillColor="#00000000"
+        android:pathData="M0,29L108,29"
+        android:strokeColor="#33FFFFFF"
+        android:strokeWidth="0.8" />
+    <path
+        android:fillColor="#00000000"
+        android:pathData="M0,39L108,39"
+        android:strokeColor="#33FFFFFF"
+        android:strokeWidth="0.8" />
+    <path
+        android:fillColor="#00000000"
+        android:pathData="M0,49L108,49"
+        android:strokeColor="#33FFFFFF"
+        android:strokeWidth="0.8" />
+    <path
+        android:fillColor="#00000000"
+        android:pathData="M0,59L108,59"
+        android:strokeColor="#33FFFFFF"
+        android:strokeWidth="0.8" />
+    <path
+        android:fillColor="#00000000"
+        android:pathData="M0,69L108,69"
+        android:strokeColor="#33FFFFFF"
+        android:strokeWidth="0.8" />
+    <path
+        android:fillColor="#00000000"
+        android:pathData="M0,79L108,79"
+        android:strokeColor="#33FFFFFF"
+        android:strokeWidth="0.8" />
+    <path
+        android:fillColor="#00000000"
+        android:pathData="M0,89L108,89"
+        android:strokeColor="#33FFFFFF"
+        android:strokeWidth="0.8" />
+    <path
+        android:fillColor="#00000000"
+        android:pathData="M0,99L108,99"
+        android:strokeColor="#33FFFFFF"
+        android:strokeWidth="0.8" />
+    <path
+        android:fillColor="#00000000"
+        android:pathData="M19,29L89,29"
+        android:strokeColor="#33FFFFFF"
+        android:strokeWidth="0.8" />
+    <path
+        android:fillColor="#00000000"
+        android:pathData="M19,39L89,39"
+        android:strokeColor="#33FFFFFF"
+        android:strokeWidth="0.8" />
+    <path
+        android:fillColor="#00000000"
+        android:pathData="M19,49L89,49"
+        android:strokeColor="#33FFFFFF"
+        android:strokeWidth="0.8" />
+    <path
+        android:fillColor="#00000000"
+        android:pathData="M19,59L89,59"
+        android:strokeColor="#33FFFFFF"
+        android:strokeWidth="0.8" />
+    <path
+        android:fillColor="#00000000"
+        android:pathData="M19,69L89,69"
+        android:strokeColor="#33FFFFFF"
+        android:strokeWidth="0.8" />
+    <path
+        android:fillColor="#00000000"
+        android:pathData="M19,79L89,79"
+        android:strokeColor="#33FFFFFF"
+        android:strokeWidth="0.8" />
+    <path
+        android:fillColor="#00000000"
+        android:pathData="M29,19L29,89"
+        android:strokeColor="#33FFFFFF"
+        android:strokeWidth="0.8" />
+    <path
+        android:fillColor="#00000000"
+        android:pathData="M39,19L39,89"
+        android:strokeColor="#33FFFFFF"
+        android:strokeWidth="0.8" />
+    <path
+        android:fillColor="#00000000"
+        android:pathData="M49,19L49,89"
+        android:strokeColor="#33FFFFFF"
+        android:strokeWidth="0.8" />
+    <path
+        android:fillColor="#00000000"
+        android:pathData="M59,19L59,89"
+        android:strokeColor="#33FFFFFF"
+        android:strokeWidth="0.8" />
+    <path
+        android:fillColor="#00000000"
+        android:pathData="M69,19L69,89"
+        android:strokeColor="#33FFFFFF"
+        android:strokeWidth="0.8" />
+    <path
+        android:fillColor="#00000000"
+        android:pathData="M79,19L79,89"
+        android:strokeColor="#33FFFFFF"
+        android:strokeWidth="0.8" />
+</vector>

BIN
android/app/src/main/res/drawable/splash.png


+ 12 - 0
android/app/src/main/res/layout/activity_main.xml

@@ -0,0 +1,12 @@
+<?xml version="1.0" encoding="utf-8"?>
+<androidx.coordinatorlayout.widget.CoordinatorLayout xmlns:android="http://schemas.android.com/apk/res/android"
+    xmlns:app="http://schemas.android.com/apk/res-auto"
+    xmlns:tools="http://schemas.android.com/tools"
+    android:layout_width="match_parent"
+    android:layout_height="match_parent"
+    tools:context=".MainActivity">
+
+    <WebView
+        android:layout_width="match_parent"
+        android:layout_height="match_parent" />
+</androidx.coordinatorlayout.widget.CoordinatorLayout>

+ 5 - 0
android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml

@@ -0,0 +1,5 @@
+<?xml version="1.0" encoding="utf-8"?>
+<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
+    <background android:drawable="@color/ic_launcher_background"/>
+    <foreground android:drawable="@mipmap/ic_launcher_foreground"/>
+</adaptive-icon>

+ 5 - 0
android/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml

@@ -0,0 +1,5 @@
+<?xml version="1.0" encoding="utf-8"?>
+<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
+    <background android:drawable="@color/ic_launcher_background"/>
+    <foreground android:drawable="@mipmap/ic_launcher_foreground"/>
+</adaptive-icon>

BIN
android/app/src/main/res/mipmap-hdpi/ic_launcher.png


BIN
android/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png


BIN
android/app/src/main/res/mipmap-hdpi/ic_launcher_round.png


BIN
android/app/src/main/res/mipmap-mdpi/ic_launcher.png


BIN
android/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.png


BIN
android/app/src/main/res/mipmap-mdpi/ic_launcher_round.png


BIN
android/app/src/main/res/mipmap-xhdpi/ic_launcher.png


BIN
android/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png


BIN
android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png


BIN
android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png


BIN
android/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png


BIN
android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png


BIN
android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png


BIN
android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png


BIN
android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png


+ 4 - 0
android/app/src/main/res/values/ic_launcher_background.xml

@@ -0,0 +1,4 @@
+<?xml version="1.0" encoding="utf-8"?>
+<resources>
+    <color name="ic_launcher_background">#FFFFFF</color>
+</resources>

+ 7 - 0
android/app/src/main/res/values/strings.xml

@@ -0,0 +1,7 @@
+<?xml version='1.0' encoding='utf-8'?>
+<resources>
+    <string name="app_name">Fmode</string>
+    <string name="title_activity_main">Fmode</string>
+    <string name="package_name">cn.fmode.app</string>
+    <string name="custom_url_scheme">cn.fmode.app</string>
+</resources>

+ 22 - 0
android/app/src/main/res/values/styles.xml

@@ -0,0 +1,22 @@
+<?xml version="1.0" encoding="utf-8"?>
+<resources>
+
+    <!-- Base application theme. -->
+    <style name="AppTheme" parent="Theme.AppCompat.Light.DarkActionBar">
+        <!-- Customize your theme here. -->
+        <item name="colorPrimary">@color/colorPrimary</item>
+        <item name="colorPrimaryDark">@color/colorPrimaryDark</item>
+        <item name="colorAccent">@color/colorAccent</item>
+    </style>
+
+    <style name="AppTheme.NoActionBar" parent="Theme.AppCompat.DayNight.NoActionBar">
+        <item name="windowActionBar">false</item>
+        <item name="windowNoTitle">true</item>
+        <item name="android:background">@null</item>
+    </style>
+
+
+    <style name="AppTheme.NoActionBarLaunch" parent="Theme.SplashScreen">
+        <item name="android:background">@drawable/splash</item>
+    </style>
+</resources>

+ 6 - 0
android/app/src/main/res/xml/config.xml

@@ -0,0 +1,6 @@
+<?xml version='1.0' encoding='utf-8'?>
+<widget version="1.0.0" xmlns="http://www.w3.org/ns/widgets" xmlns:cdv="http://cordova.apache.org/ns/1.0">
+  <access origin="*" />
+  
+  
+</widget>

+ 5 - 0
android/app/src/main/res/xml/file_paths.xml

@@ -0,0 +1,5 @@
+<?xml version="1.0" encoding="utf-8"?>
+<paths xmlns:android="http://schemas.android.com/apk/res/android">
+    <external-path name="my_images" path="." />
+    <cache-path name="my_cache_images" path="." />
+</paths>

+ 18 - 0
android/app/src/test/java/com/getcapacitor/myapp/ExampleUnitTest.java

@@ -0,0 +1,18 @@
+package com.getcapacitor.myapp;
+
+import static org.junit.Assert.*;
+
+import org.junit.Test;
+
+/**
+ * Example local unit test, which will execute on the development machine (host).
+ *
+ * @see <a href="http://d.android.com/tools/testing">Testing documentation</a>
+ */
+public class ExampleUnitTest {
+
+    @Test
+    public void addition_isCorrect() throws Exception {
+        assertEquals(4, 2 + 2);
+    }
+}

+ 29 - 0
android/build.gradle

@@ -0,0 +1,29 @@
+// Top-level build file where you can add configuration options common to all sub-projects/modules.
+
+buildscript {
+    
+    repositories {
+        google()
+        mavenCentral()
+    }
+    dependencies {
+        classpath 'com.android.tools.build:gradle:8.13.0'
+        classpath 'com.google.gms:google-services:4.4.4'
+
+        // NOTE: Do not place your application dependencies here; they belong
+        // in the individual module build.gradle files
+    }
+}
+
+apply from: "variables.gradle"
+
+allprojects {
+    repositories {
+        google()
+        mavenCentral()
+    }
+}
+
+task clean(type: Delete) {
+    delete rootProject.buildDir
+}

+ 59 - 0
android/capacitor-cordova-android-plugins/build.gradle

@@ -0,0 +1,59 @@
+ext {
+    androidxAppCompatVersion = project.hasProperty('androidxAppCompatVersion') ? rootProject.ext.androidxAppCompatVersion : '1.7.1'
+    cordovaAndroidVersion = project.hasProperty('cordovaAndroidVersion') ? rootProject.ext.cordovaAndroidVersion : '14.0.1'
+}
+
+buildscript {
+    repositories {
+        google()
+        mavenCentral()
+    }
+    dependencies {
+        classpath 'com.android.tools.build:gradle:8.13.0'
+    }
+}
+
+apply plugin: 'com.android.library'
+
+android {
+    namespace = "capacitor.cordova.android.plugins"
+    compileSdk = project.hasProperty('compileSdkVersion') ? rootProject.ext.compileSdkVersion : 36
+    defaultConfig {
+        minSdkVersion project.hasProperty('minSdkVersion') ? rootProject.ext.minSdkVersion : 24
+        targetSdkVersion project.hasProperty('targetSdkVersion') ? rootProject.ext.targetSdkVersion : 36
+        versionCode 1
+        versionName "1.0"
+    }
+    lintOptions {
+        abortOnError = false
+    }
+    compileOptions {
+        sourceCompatibility JavaVersion.VERSION_21
+        targetCompatibility JavaVersion.VERSION_21
+    }
+}
+
+repositories {
+    google()
+    mavenCentral()
+    flatDir{
+        dirs 'src/main/libs', 'libs'
+    }
+}
+
+dependencies {
+    implementation fileTree(dir: 'src/main/libs', include: ['*.jar'])
+    implementation "androidx.appcompat:appcompat:$androidxAppCompatVersion"
+    implementation "org.apache.cordova:framework:$cordovaAndroidVersion"
+    // SUB-PROJECT DEPENDENCIES START
+
+    // SUB-PROJECT DEPENDENCIES END
+}
+
+// PLUGIN GRADLE EXTENSIONS START
+apply from: "cordova.variables.gradle"
+// PLUGIN GRADLE EXTENSIONS END
+
+for (def func : cdvPluginPostBuildExtras) {
+    func()
+}

+ 7 - 0
android/capacitor-cordova-android-plugins/cordova.variables.gradle

@@ -0,0 +1,7 @@
+// DO NOT EDIT THIS FILE! IT IS GENERATED EACH TIME "capacitor update" IS RUN
+ext {
+  cdvMinSdkVersion = project.hasProperty('minSdkVersion') ? rootProject.ext.minSdkVersion : 24
+  // Plugin gradle extensions can append to this to have code run at the end.
+  cdvPluginPostBuildExtras = []
+  cordovaConfig = [:]
+}

+ 8 - 0
android/capacitor-cordova-android-plugins/src/main/AndroidManifest.xml

@@ -0,0 +1,8 @@
+<?xml version='1.0' encoding='utf-8'?>
+<manifest xmlns:android="http://schemas.android.com/apk/res/android"
+xmlns:amazon="http://schemas.amazon.com/apk/res/android">
+<application  >
+
+</application>
+
+</manifest>

+ 0 - 0
android/capacitor-cordova-android-plugins/src/main/java/.gitkeep


+ 1 - 0
android/capacitor-cordova-android-plugins/src/main/res/.gitkeep

@@ -0,0 +1 @@
+

+ 9 - 0
android/capacitor.settings.gradle

@@ -0,0 +1,9 @@
+// DO NOT EDIT THIS FILE! IT IS GENERATED EACH TIME "capacitor update" IS RUN
+include ':capacitor-android'
+project(':capacitor-android').projectDir = new File('../node_modules/@capacitor/android/capacitor')
+
+include ':capacitor-local-notifications'
+project(':capacitor-local-notifications').projectDir = new File('../node_modules/@capacitor/local-notifications/android')
+
+include ':capacitor-share'
+project(':capacitor-share').projectDir = new File('../node_modules/@capacitor/share/android')

+ 22 - 0
android/gradle.properties

@@ -0,0 +1,22 @@
+# Project-wide Gradle settings.
+
+# IDE (e.g. Android Studio) users:
+# Gradle settings configured through the IDE *will override*
+# any settings specified in this file.
+
+# For more details on how to configure your build environment visit
+# http://www.gradle.org/docs/current/userguide/build_environment.html
+
+# Specifies the JVM arguments used for the daemon process.
+# The setting is particularly useful for tweaking memory settings.
+org.gradle.jvmargs=-Xmx1536m
+
+# When configured, Gradle will run in incubating parallel mode.
+# This option should only be used with decoupled projects. More details, visit
+# http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects
+# org.gradle.parallel=true
+
+# AndroidX package structure to make it clearer which packages are bundled with the
+# Android operating system, and which are packaged with your app's APK
+# https://developer.android.com/topic/libraries/support-library/androidx-rn
+android.useAndroidX=true

BIN
android/gradle/wrapper/gradle-wrapper.jar


+ 7 - 0
android/gradle/wrapper/gradle-wrapper.properties

@@ -0,0 +1,7 @@
+distributionBase=GRADLE_USER_HOME
+distributionPath=wrapper/dists
+distributionUrl=https\://services.gradle.org/distributions/gradle-8.14.3-bin.zip
+networkTimeout=10000
+validateDistributionUrl=true
+zipStoreBase=GRADLE_USER_HOME
+zipStorePath=wrapper/dists

+ 251 - 0
android/gradlew

@@ -0,0 +1,251 @@
+#!/bin/sh
+
+#
+# Copyright © 2015-2021 the original authors.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+#      https://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+#
+# SPDX-License-Identifier: Apache-2.0
+#
+
+##############################################################################
+#
+#   Gradle start up script for POSIX generated by Gradle.
+#
+#   Important for running:
+#
+#   (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
+#       noncompliant, but you have some other compliant shell such as ksh or
+#       bash, then to run this script, type that shell name before the whole
+#       command line, like:
+#
+#           ksh Gradle
+#
+#       Busybox and similar reduced shells will NOT work, because this script
+#       requires all of these POSIX shell features:
+#         * functions;
+#         * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
+#           «${var#prefix}», «${var%suffix}», and «$( cmd )»;
+#         * compound commands having a testable exit status, especially «case»;
+#         * various built-in commands including «command», «set», and «ulimit».
+#
+#   Important for patching:
+#
+#   (2) This script targets any POSIX shell, so it avoids extensions provided
+#       by Bash, Ksh, etc; in particular arrays are avoided.
+#
+#       The "traditional" practice of packing multiple parameters into a
+#       space-separated string is a well documented source of bugs and security
+#       problems, so this is (mostly) avoided, by progressively accumulating
+#       options in "$@", and eventually passing that to Java.
+#
+#       Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
+#       and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
+#       see the in-line comments for details.
+#
+#       There are tweaks for specific operating systems such as AIX, CygWin,
+#       Darwin, MinGW, and NonStop.
+#
+#   (3) This script is generated from the Groovy template
+#       https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
+#       within the Gradle project.
+#
+#       You can find Gradle at https://github.com/gradle/gradle/.
+#
+##############################################################################
+
+# Attempt to set APP_HOME
+
+# Resolve links: $0 may be a link
+app_path=$0
+
+# Need this for daisy-chained symlinks.
+while
+    APP_HOME=${app_path%"${app_path##*/}"}  # leaves a trailing /; empty if no leading path
+    [ -h "$app_path" ]
+do
+    ls=$( ls -ld "$app_path" )
+    link=${ls#*' -> '}
+    case $link in             #(
+      /*)   app_path=$link ;; #(
+      *)    app_path=$APP_HOME$link ;;
+    esac
+done
+
+# This is normally unused
+# shellcheck disable=SC2034
+APP_BASE_NAME=${0##*/}
+# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
+APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit
+
+# Use the maximum available, or set MAX_FD != -1 to use that value.
+MAX_FD=maximum
+
+warn () {
+    echo "$*"
+} >&2
+
+die () {
+    echo
+    echo "$*"
+    echo
+    exit 1
+} >&2
+
+# OS specific support (must be 'true' or 'false').
+cygwin=false
+msys=false
+darwin=false
+nonstop=false
+case "$( uname )" in                #(
+  CYGWIN* )         cygwin=true  ;; #(
+  Darwin* )         darwin=true  ;; #(
+  MSYS* | MINGW* )  msys=true    ;; #(
+  NONSTOP* )        nonstop=true ;;
+esac
+
+CLASSPATH="\\\"\\\""
+
+
+# Determine the Java command to use to start the JVM.
+if [ -n "$JAVA_HOME" ] ; then
+    if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
+        # IBM's JDK on AIX uses strange locations for the executables
+        JAVACMD=$JAVA_HOME/jre/sh/java
+    else
+        JAVACMD=$JAVA_HOME/bin/java
+    fi
+    if [ ! -x "$JAVACMD" ] ; then
+        die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
+
+Please set the JAVA_HOME variable in your environment to match the
+location of your Java installation."
+    fi
+else
+    JAVACMD=java
+    if ! command -v java >/dev/null 2>&1
+    then
+        die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
+
+Please set the JAVA_HOME variable in your environment to match the
+location of your Java installation."
+    fi
+fi
+
+# Increase the maximum file descriptors if we can.
+if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
+    case $MAX_FD in #(
+      max*)
+        # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
+        # shellcheck disable=SC2039,SC3045
+        MAX_FD=$( ulimit -H -n ) ||
+            warn "Could not query maximum file descriptor limit"
+    esac
+    case $MAX_FD in  #(
+      '' | soft) :;; #(
+      *)
+        # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
+        # shellcheck disable=SC2039,SC3045
+        ulimit -n "$MAX_FD" ||
+            warn "Could not set maximum file descriptor limit to $MAX_FD"
+    esac
+fi
+
+# Collect all arguments for the java command, stacking in reverse order:
+#   * args from the command line
+#   * the main class name
+#   * -classpath
+#   * -D...appname settings
+#   * --module-path (only if needed)
+#   * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
+
+# For Cygwin or MSYS, switch paths to Windows format before running java
+if "$cygwin" || "$msys" ; then
+    APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
+    CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" )
+
+    JAVACMD=$( cygpath --unix "$JAVACMD" )
+
+    # Now convert the arguments - kludge to limit ourselves to /bin/sh
+    for arg do
+        if
+            case $arg in                                #(
+              -*)   false ;;                            # don't mess with options #(
+              /?*)  t=${arg#/} t=/${t%%/*}              # looks like a POSIX filepath
+                    [ -e "$t" ] ;;                      #(
+              *)    false ;;
+            esac
+        then
+            arg=$( cygpath --path --ignore --mixed "$arg" )
+        fi
+        # Roll the args list around exactly as many times as the number of
+        # args, so each arg winds up back in the position where it started, but
+        # possibly modified.
+        #
+        # NB: a `for` loop captures its iteration list before it begins, so
+        # changing the positional parameters here affects neither the number of
+        # iterations, nor the values presented in `arg`.
+        shift                   # remove old arg
+        set -- "$@" "$arg"      # push replacement arg
+    done
+fi
+
+
+# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
+DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
+
+# Collect all arguments for the java command:
+#   * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
+#     and any embedded shellness will be escaped.
+#   * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be
+#     treated as '${Hostname}' itself on the command line.
+
+set -- \
+        "-Dorg.gradle.appname=$APP_BASE_NAME" \
+        -classpath "$CLASSPATH" \
+        -jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \
+        "$@"
+
+# Stop when "xargs" is not available.
+if ! command -v xargs >/dev/null 2>&1
+then
+    die "xargs is not available"
+fi
+
+# Use "xargs" to parse quoted args.
+#
+# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
+#
+# In Bash we could simply go:
+#
+#   readarray ARGS < <( xargs -n1 <<<"$var" ) &&
+#   set -- "${ARGS[@]}" "$@"
+#
+# but POSIX shell has neither arrays nor command substitution, so instead we
+# post-process each arg (as a line of input to sed) to backslash-escape any
+# character that might be a shell metacharacter, then use eval to reverse
+# that process (while maintaining the separation between arguments), and wrap
+# the whole thing up as a single "set" statement.
+#
+# This will of course break if any of these variables contains a newline or
+# an unmatched quote.
+#
+
+eval "set -- $(
+        printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
+        xargs -n1 |
+        sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
+        tr '\n' ' '
+    )" '"$@"'
+
+exec "$JAVACMD" "$@"

+ 94 - 0
android/gradlew.bat

@@ -0,0 +1,94 @@
+@rem
+@rem Copyright 2015 the original author or authors.
+@rem
+@rem Licensed under the Apache License, Version 2.0 (the "License");
+@rem you may not use this file except in compliance with the License.
+@rem You may obtain a copy of the License at
+@rem
+@rem      https://www.apache.org/licenses/LICENSE-2.0
+@rem
+@rem Unless required by applicable law or agreed to in writing, software
+@rem distributed under the License is distributed on an "AS IS" BASIS,
+@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+@rem See the License for the specific language governing permissions and
+@rem limitations under the License.
+@rem
+@rem SPDX-License-Identifier: Apache-2.0
+@rem
+
+@if "%DEBUG%"=="" @echo off
+@rem ##########################################################################
+@rem
+@rem  Gradle startup script for Windows
+@rem
+@rem ##########################################################################
+
+@rem Set local scope for the variables with windows NT shell
+if "%OS%"=="Windows_NT" setlocal
+
+set DIRNAME=%~dp0
+if "%DIRNAME%"=="" set DIRNAME=.
+@rem This is normally unused
+set APP_BASE_NAME=%~n0
+set APP_HOME=%DIRNAME%
+
+@rem Resolve any "." and ".." in APP_HOME to make it shorter.
+for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
+
+@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
+set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
+
+@rem Find java.exe
+if defined JAVA_HOME goto findJavaFromJavaHome
+
+set JAVA_EXE=java.exe
+%JAVA_EXE% -version >NUL 2>&1
+if %ERRORLEVEL% equ 0 goto execute
+
+echo. 1>&2
+echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2
+echo. 1>&2
+echo Please set the JAVA_HOME variable in your environment to match the 1>&2
+echo location of your Java installation. 1>&2
+
+goto fail
+
+:findJavaFromJavaHome
+set JAVA_HOME=%JAVA_HOME:"=%
+set JAVA_EXE=%JAVA_HOME%/bin/java.exe
+
+if exist "%JAVA_EXE%" goto execute
+
+echo. 1>&2
+echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2
+echo. 1>&2
+echo Please set the JAVA_HOME variable in your environment to match the 1>&2
+echo location of your Java installation. 1>&2
+
+goto fail
+
+:execute
+@rem Setup the command line
+
+set CLASSPATH=
+
+
+@rem Execute Gradle
+"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %*
+
+:end
+@rem End local scope for the variables with windows NT shell
+if %ERRORLEVEL% equ 0 goto mainEnd
+
+:fail
+rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
+rem the _cmd.exe /c_ return code!
+set EXIT_CODE=%ERRORLEVEL%
+if %EXIT_CODE% equ 0 set EXIT_CODE=1
+if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
+exit /b %EXIT_CODE%
+
+:mainEnd
+if "%OS%"=="Windows_NT" endlocal
+
+:omega

+ 5 - 0
android/settings.gradle

@@ -0,0 +1,5 @@
+include ':app'
+include ':capacitor-cordova-android-plugins'
+project(':capacitor-cordova-android-plugins').projectDir = new File('./capacitor-cordova-android-plugins/')
+
+apply from: 'capacitor.settings.gradle'

+ 16 - 0
android/variables.gradle

@@ -0,0 +1,16 @@
+ext {
+    minSdkVersion = 24
+    compileSdkVersion = 36
+    targetSdkVersion = 36
+    androidxActivityVersion = '1.11.0'
+    androidxAppCompatVersion = '1.7.1'
+    androidxCoordinatorLayoutVersion = '1.3.0'
+    androidxCoreVersion = '1.17.0'
+    androidxFragmentVersion = '1.8.9'
+    coreSplashScreenVersion = '1.2.0'
+    androidxWebkitVersion = '1.14.0'
+    junitVersion = '4.13.2'
+    androidxJunitVersion = '1.3.0'
+    androidxEspressoCoreVersion = '3.7.0'
+    cordovaAndroidVersion = '14.0.1'
+}

+ 68 - 0
capacitor.config.ts

@@ -0,0 +1,68 @@
+import type { CapacitorConfig } from '@capacitor/cli';
+
+/**
+ * Capacitor 配置 —— Android 打包的**唯一真源**(2026-11-15)
+ *
+ * ## 为什么 webDir 指到 dist/fmode-app-v2/browser
+ *
+ * 本项目是 Angular `@angular/build:application`,产物落在
+ * `dist/<project>/browser/`。Capacitor 只认这个静态目录,
+ * 所以必须先 `npm run build` 再用 `npx cap sync` 把它拷进 `android/app/src/main/assets/public/`。
+ *
+ * ## 为什么必须锁 server.hostname / androidScheme
+ *
+ * 🔴 这是打包后**最容易踩的坑**:
+ *   · 默认 `hostname = localhost`、`androidScheme = https` → origin 变成 `https://localhost`
+ *   · Capacitor 的 `_capacitor_file_` 协议下,WebView 对跨域请求的限制与浏览器一致
+ *   · 我们实测过 `s3.fmode.cn` / `server.fmode.cn` / `matrix.fmode.cn` 三个域名
+ *     都能对 `https://localhost` 正常回 CORS 头,所以**直连可行**
+ *
+ * 锁死这两项是为了让 origin 稳定 —— 以后若要给平台报白名单,
+ * 报的就是 `https://localhost` 这一个值,不会随版本漂移。
+ *
+ * ## 关于推送
+ *
+ * 本期**不接极光 SDK**(用户 2026-11-15 裁决)。原因见
+ * `docs/02-design/plans/2026-11-14-手机端UX优化与Android打包方案.md`:
+ * 没有服务端推送触发链路时,极光的核心价值(离线推送)用不上,
+ * 本地通知用官方 `@capacitor/local-notifications` 即可。
+ */
+const config: CapacitorConfig = {
+  /** 🔴 用户 2026-11-15 拍板:一旦分发后改名 = 换签名 + 重装 */
+  appId: 'cn.fmode.app',
+  appName: 'Fmode',
+
+  /** Angular 生产构建输出目录 */
+  webDir: 'dist/fmode-app-v2/browser',
+
+  /**
+   * 追加 UA 标记。
+   *
+   * 服务端 / 埋点可以据此区分「Web 访问」与「App 内访问」,
+   * 也方便线上排查时确认用户到底跑的是哪个壳。
+   */
+  appendUserAgent: 'FmodeApp/2.0',
+
+  server: {
+    /**
+     * 固定为 https —— Android WebView 在 http 下会禁用部分能力
+     * (Service Worker、部分安全上下文 API)。
+     */
+    androidScheme: 'https',
+    /**
+     * 固定 hostname:origin 稳定为 `https://localhost`。
+     * 不去改它,避免 origin 随 Capacitor 版本漂移。
+     */
+    hostname: 'localhost',
+  },
+
+  android: {
+    /**
+     * 允许明文流量**关闭**(默认值,显式写出来是为了防止有人后来误开)。
+     * 我们的后端全部是 https,没有例外。
+     */
+    allowMixedContent: false,
+  },
+};
+
+export default config;

+ 16 - 0
docs/00-tracking/操作追踪.md

@@ -8340,3 +8340,19 @@ URL 不会重复显示:`chat.service.ts:2349` 的 `stripLinks` 会把已提取
 | **总方案文档** | 新建 `docs/02-design/plans/2026-11-14-手机端UX优化与Android打包方案.md`——含用户 7 项裁决、阶段 A 已做/已删/不做清单、阶段 A 遗留高优项、阶段 B 环境盘点与 CORS 实测结论、阶段 C 推送与分享方案、**可勾选的任务清单**、相关文档索引 |
 | **遗留文件** | `docs/03-interface/回复-kangshifu-坚持s3直链-20261114.md` 提交;顺手修正行首多出的一个空格(格式瑕疵,无内容变化) |
 | **后续入口** | 下次接手先读总方案文档第六章「任务清单」 |
+
+## 2026-11-15(续一)· 阶段 B:Capacitor 打包 Android
+
+| 项 | 内容 |
+|---|---|
+| **用户要求** | 「技术总监说的方案优先用极光 Capacitor 的 jpush 插件 + 安卓分享插件……看看他是否理解过时了,我们当前这个技术栈使用什么方案会比较好」+ 要求完整技术对比 |
+| **调研结论(实测)** | ① 总监给的 `ahhxyz/jpush-capacitor-plugin` **1★**、2024-25 仅改 package.json,不可用<br>② 极光官方 `jpush/jpush-phonegap-plugin` **923★、v6.3.0(2026-10-29 仍更新)**,是 **Cordova** 插件<br>③ **Capacitor 8 官方文档仍明确支持 Cordova 插件** → 「极光没有 Capacitor 支持」的说法不准确<br>④ 但**真正阻塞不在插件**:无服务端推送链路,极光核心能力(离线推送)用不上<br>⑤ `@capacitor/share` **只能往外发**,「微信/企微 → App」**必须自建 `ACTION_SEND`** |
+| **用户裁决** | 推送不接极光用官方本地通知 / CORS 先提需求给平台 / 外部分享本期只设计 / appId = `cn.fmode.app` |
+| **CORS 实测** | `/parse/**`、`/api/functions`、`/api/fmode/mobile` 均 **1 条** ✅;**`/api/apig/message`(登录发验证码)实测出现 3 条** ❌ 随机 |
+| **已完成** | ① Capacitor 8.6.2 接入(core/android/cli/app/local-notifications/share)② `capacitor.config.ts` ③ `android/` 工程 ④ 深链 `fmode://`(service + Manifest intent-filter + 冷启动 `getLaunchUrl`)⑤ `AppNotificationService`(权限/覆盖/点击跳转)⑥ 登录重试 `retryLoginRequest()` ⑦ 原生壳跳过代理探测 ⑧ 更正 s3 CORS 过时注释 |
+| **构建踩坑** | Gradle wrapper 配的是 `-all`(下载超时),本地只有 `-bin` 完整缓存(`.ok`)→ 改用本地 gradle 可执行文件直出;建议改 `-bin` 省 ~100MB |
+| **产物** | `android/app/build/outputs/apk/debug/app-debug.apk` **6.8 MB**;`aapt2` 确认 `cn.fmode.app` / targetSdk 36 / minSdk 24 / `scheme="fmode"` / `POST_NOTIFICATIONS` |
+| **验证** | `tsc` ✅ · `build` ✅ 594.53 kB · core 测试 **1362 SUCCESS** · components(排除 PDF)**103 SUCCESS** · Web 端回归 87 会话正常无报错 · **BUILD SUCCESSFUL** |
+| **已知问题(非本次引入)** | PDF 预览测试在全量跑时 worker 崩溃——`git stash` 验证**改动前同样失败**,是既有稳定性问题 |
+| **文档** | `docs/03-interface/平台CORS多值修复需求-20261115.md`、`docs/02-design/plans/2026-11-15-外部分享接收设计.md`、`docs/02-design/plans/2026-11-15-Android打包实施记录.md` |
+| **待办** | 用户插真机执行 V1–V10 验证清单;键盘遮挡则修 visualViewport;向平台提 CORS 修复 |

+ 154 - 0
docs/02-design/plans/2026-11-15-Android打包实施记录.md

@@ -0,0 +1,154 @@
+# Android 打包实施记录(阶段 B)
+
+- **日期**:2026-11-15
+- **状态**:✅ **已产出 debug APK**;真机验证待用户插设备
+- **APK 位置**:`android/app/build/outputs/apk/debug/app-debug.apk`(6.8 MB)
+
+---
+
+## 一、做了什么
+
+### 1. 接入 Capacitor 8.6.2
+
+| 包 | 版本 | 用途 |
+|---|---|---|
+| `@capacitor/core` | 8.6.2 | 运行时 |
+| `@capacitor/android` | 8.6.2 | Android 平台 |
+| `@capacitor/cli`(dev) | 8.6.2 | 命令行 |
+| `@capacitor/app` | 8.1.2 | `appUrlOpen` 深链监听 |
+| `@capacitor/local-notifications` | 8.3.1 | 本地通知(**替代极光**) |
+| `@capacitor/share` | 8.0.3 | 向系统分享(本期装入未启用) |
+
+**没有引入 Ionic 全家桶**,只用了 Capacitor 核心。
+
+`capacitor.config.ts` 关键项:
+- `appId: cn.fmode.app`(用户 2026-11-15 拍板)
+- `webDir: dist/fmode-app-v2/browser`
+- `server.androidScheme: https` + `hostname: localhost` → **origin 固定为 `https://localhost`**
+- `appendUserAgent: FmodeApp/2.0`(服务端/埋点可识别 App 访问)
+
+### 2. 深链(`fmode://`)
+
+| 链接 | 落点 |
+|---|---|
+| `fmode://chat/<agentId>/<sessionId>` | `/mobile/chat/<agentId>/<sessionId>` |
+| `fmode://chat/<agentId>` | `/mobile/chat/<agentId>` |
+| `fmode://messages` `contacts` `workbench` `mine` | 对应移动端路由 |
+
+- `AndroidManifest.xml`:`VIEW` + `DEFAULT` + `BROWSABLE` + `scheme="fmode"`
+- `src/app/core/deep-link.service.ts`:解析 + 跳转,**复用既有 `mobile.routes.ts`,不新增路由**
+- 通过 `provideAppInitializer` 注册,并补了 **冷启动 `getLaunchUrl()`** 入口
+  (App 未运行时被分享/通知拉起,事件早于监听器注册,不补会丢首次跳转)
+
+**已验证打包进 APK**:`aapt2 dump xmltree` 确认 `scheme="fmode"` 存在。
+
+### 3. 本地通知(替代极光 JPush)
+
+`src/app/core/app-notification.service.ts`:
+- Android 13+ 运行时权限 `POST_NOTIFICATIONS`(**首次真正要发时才申请**,不在冷启动弹)
+- `notify({title, body, deepLink, key})`;同 `key` **覆盖**不叠加
+- 点击 → 读 `extra.deepLink` → 走 `DeepLinkService` 跳页
+- 已接到阶段 A 的 `TaskPulseService` 完成脉冲上
+
+**为什么不用极光**(2026-11-15 调研):
+
+| | 总监给的 `ahhxyz/jpush-capacitor-plugin` | 极光官方 `jpush/jpush-phonegap-plugin` |
+|---|---|---|
+| Star | 1 | **923** |
+| 最后更新 | 2025-04(只改 package.json) | **2026-10-29** |
+| 最新版本 | — | **v6.3.0** |
+
+极光官方的是 **Cordova 插件**,而 **Capacitor 8 仍支持 Cordova 插件**——
+所以「极光没有 Capacitor 支持」不准确,正确说法是「不发布 Capacitor 原生插件,
+但官方 Cordova 插件可用」。
+
+**真正的阻塞不在插件**:没有服务端推送触发链路时,极光的核心价值(离线推送)
+用不上。用户已裁决本期只做「前台/后台存活时通知」,本地通知完全覆盖这个范围。
+
+**升级路径**:将来服务端链路就绪 → 换极光官方 Cordova 插件 v6.3.0,
+`AppNotificationService` 的调用方(TaskPulse / 会话页)**一行不用改**。
+
+### 4. CORS 应对
+
+实测(2026-11-15):
+
+| 接口 | ACAO 条数 | 状态 |
+|---|---|---|
+| `/parse/**` | 1 | ✅ |
+| `/api/functions` | 1 | ✅ |
+| `/api/fmode/mobile` | 1 | ✅ |
+| **`/api/apig/message`** | **实测出现 3 条** | ❌ 随机 |
+
+处理:
+1. `PlatformProxyService` 在原生壳里**跳过探测**(Capacitor 里 `/api-platform/**` 必然不存在,
+   探测只会多一次失败请求 + 一条无意义警告)
+2. `parse-api.ts` 新增 `retryLoginRequest()`:登录两处接口最多重试 3 次(400/900ms),
+   **仅对网络类失败重试**,业务错误原样抛出
+3. 产出 `docs/03-interface/平台CORS多值修复需求-20261115.md`(治本方案)
+
+### 5. 顺手更正
+
+`attachment-fetch.ts`、`parse-api.ts` 里「s3.fmode.cn 没配 CORS」的注释**已过时**,
+2026-11-15 复测确认 OBS 已配任意 Origin 回显。**事实更新,兜底代码保留**。
+
+---
+
+## 二、构建踩坑
+
+| 坑 | 现象 | 解决 |
+|---|---|---|
+| Gradle wrapper 下载超时 | `SocketTimeoutException`,卡在 `services.gradle.org` | 本地已有 `gradle-8.14.3-bin` 完整缓存(`.ok`),而 wrapper 配的是 `-all`(半截 `.part`)。改用本地 gradle 可执行文件直接构建 |
+| 网络不稳 | `npm i` 首次 `ECONNRESET` | `--fetch-retries=5` 重试成功 |
+
+**建议**:把 `gradle-wrapper.properties` 的 `-all` 改 `-bin` 可省 ~100MB 下载,
+已验证可用。
+
+---
+
+## 三、验证结果
+
+| 项 | 结果 |
+|---|---|
+| `tsc` | ✅ 通过 |
+| `npm run build` | ✅ 594.53 kB(阶段 A 基线 591.87,+2.66 kB) |
+| `npm test`(core) | ✅ 1362 SUCCESS |
+| `npm test`(components,排除 PDF) | ✅ 103 SUCCESS |
+| PDF 预览测试 | ⚠️ **既有问题**——`git stash` 验证改动前同样失败(worker 崩溃),与本次无关 |
+| Web 端回归 | ✅ 87 个会话正常加载、能进会话、无 pageerror |
+| `gradlew assembleDebug` | ✅ **BUILD SUCCESSFUL**(3m48s,154 tasks) |
+| APK 包信息 | ✅ `cn.fmode.app` / targetSdk 36 / minSdk 24 / 6.8 MB |
+| 深链 intent-filter | ✅ `aapt2` 确认 `scheme="fmode"` |
+| 通知权限 | ✅ `POST_NOTIFICATIONS` 已声明 |
+
+---
+
+## 四、待办(下次接手)
+
+- [ ] **用户插真机**,执行真机验证清单(见方案文档 V1–V10)
+- [ ] 真机验证后,若键盘遮挡输入区 → 修 `m-dock` 的 visualViewport 适配
+- [ ] 向平台提交 CORS 修复需求
+- [ ] 外部分享接收(设计已出,实现约 4.5 人天,单独立项)
+- [ ] 推送升级时机:服务端链路就绪后再换极光
+
+---
+
+## 五、常用命令速查
+
+```bash
+# 一键:构建 Web + 同步到 Android 工程
+npm run cap:sync
+
+# 用 Android Studio 打开
+npm run cap:open
+
+# 出 debug APK(需先设置 ANDROID_HOME)
+export ANDROID_HOME="E:/coding-environment/AndroidSDK"
+npm run cap:apk
+
+# 安装到真机
+adb install -r android/app/build/outputs/apk/debug/app-debug.apk
+
+# 测深链
+adb shell am start -a android.intent.action.VIEW -d "fmode://messages"
+adb shell am start -a android.intent.action.VIEW -d "fmode://chat/<agentId>"
+```

+ 152 - 0
docs/02-design/plans/2026-11-15-外部分享接收设计.md

@@ -0,0 +1,152 @@
+# 外部分享接收设计(微信/企微 → App 会话)
+
+- **建立日期**:2026-11-15
+- **状态**:**仅设计,未实现**(用户 2026-11-15 裁决本期不做)
+- **相关**:`docs/02-design/plans/2026-11-14-手机端UX优化与Android打包方案.md`
+
+---
+
+## 一、要实现什么
+
+用户在**微信 / 企微**里收到一个文件,点「分享」→ 选择 Fmode App → App 打开 →
+选择一个会话 → 文件发送到该会话。
+
+这是用户明确提出的核心场景:
+> 「从微信到企微发文件到 APP 的某个会话,这个逻辑打通」
+
+---
+
+## 二、为什么没有现成插件
+
+调研结论(2026-11-15):
+
+| 方案 | 能收外部文件吗 |
+|---|---|
+| `@capacitor/share` 8.0.3(已装) | ❌ **只能往外发** |
+| 微信开放平台 SDK | ❌ 只能发到微信,且需 AppID 审核 + 包名/签名绑定 |
+| 企微 SDK | ❌ 同上,且仅企微 |
+| **Android `ACTION_SEND` intent-filter** | ✅ **唯一可行** |
+
+**结论**:必须写原生代码(Kotlin/Java)。没有捷径。
+
+---
+
+## 三、技术方案
+
+### 3.1 AndroidManifest 声明
+
+在 `MainActivity` 下新增(与现有 `fmode://` 深链并列):
+
+```xml
+<!-- 接收单个文件/文本 -->
+<intent-filter>
+    <action android:name="android.intent.action.SEND" />
+    <category android:name="android.intent.category.DEFAULT" />
+    <data android:mimeType="*/*" />
+</intent-filter>
+
+<!-- 接收多个文件(微信一次选多张图) -->
+<intent-filter>
+    <action android:name="android.intent.action.SEND_MULTIPLE" />
+    <category android:name="android.intent.category.DEFAULT" />
+    <data android:mimeType="*/*" />
+</intent-filter>
+```
+
+> ⚠️ `mimeType="*/*"` 会让 App 出现在「所有类型」的分享列表里,包括纯文本。
+> 如果只想接收文���,可拆成三个 intent-filter(`text/plain`、`image/*`、`application/*`)。
+> **建议先用 `*/*`**,再在 JS 侧过滤——否则微信某些文件类型会漏进来。
+
+### 3.2 读取 content:// URI
+
+微信/企微分享过来的是 `content://` URI,**不是文件路径**。
+
+| 步骤 | 做法 |
+|---|---|
+| ① 拿到 URI | 原生侧从 `intent.getParcelableExtra(Intent.EXTRA_STREAM)` |
+| ② 读取 | `contentResolver.openInputStream(uri)` |
+| ③ 拿文件名 | `contentResolver.query(uri, [OpenableColumns.DISPLAY_NAME, SIZE])` |
+| ④ 传给 JS | 写入 App 私有目录 → 把路径给 JS |
+
+**⚠️ Android 13+ 变化**:`getParcelableExtra` 废弃,需改用 `getParcelableExtra(key, Class)`。
+**⚠️ 需在 `AndroidManifest` 加**(Android 13+ 读取他人分享的媒体):
+
+```xml
+<uses-permission android:name="android.permission.READ_MEDIA_IMAGES" />
+<uses-permission android:name="android.permission.READ_MEDIA_VIDEO" />
+```
+
+> 但**通过 `ACTION_SEND` 收到的 URI 自带临时读权限**(`FLAG_GRANT_READ_URI_PERMISSION`),
+> 通常**不需要**申请媒体权限。加上反而会触发不必要的授权弹窗。
+> **实现时必须实测确认**,不要想当然。
+
+### 3.3 自定义 Capacitor 插件
+
+```
+android/app/src/main/java/cn/fmode/app/ShareReceiverPlugin.kt   ← 原生
+src/app/core/share-receive.service.ts                            ← JS 封装
+```
+
+原生方法:
+- `getPendingShare()` —— 冷启动时取一次(App 未运行时接收)
+- `addListener('shareReceived')` —— App 已运行时接收
+
+> 🔴 **冷启动是主要盲区**:用户在微信点分享时 App 通常**没在运行**,
+> 这时 Intent 会在 `onCreate` 里到达,而不是 `onNewIntent`。
+> 两个入口都要处理,否则「第一次分享永远失败」。
+
+### 3.4 JS 侧流程
+
+```
+微信点分享 → Android 唤起 App → 原生读文件到私有目录
+   → JS 收到 { files: [{path, name, size, mime}], text? }
+   → 显示「选择会话」界面(复用 m-new-chat 组件)
+   → 用户选容器/会话
+   → 调 chat.open(agentId, sessionId)
+   → 调 chat.stageFiles(File[])      ← 复用现有暂存逻辑
+   → 调 chat.sendText('')            ← 触发上传与发送
+```
+
+**关键点:上传链路完全复用现有的**(`chat.service.ts::stageFiles` + `sendText`),
+不新写上传逻辑。文件从私有目录读成 `File` 对象即可。
+
+### 3.5 文件体积
+
+现有上限 **800MB**(`core/upload-limits.ts`)。微信单文件上限 1GB,
+可能出现「微信能发但 App 拒收」的情况 —— 需给出明确提示。
+
+---
+
+## 四、工作量评估
+
+| 工作项 | 预估 |
+|---|---|
+| 原生插件(Kotlin,含冷启动/热启动双入口) | 1.5 天 |
+| 文件读取 + 私有目录写入 + 元数据 | 0.5 天 |
+| 「选择会话」界面(可复用 `m-new-chat`) | 0.5 天 |
+| JS 封装 + 接入现有上传链路 | 0.5 天 |
+| 真机联调(微信/企微各版本差异) | 1.5 天 |
+| **合计** | **约 4.5 人天** |
+
+---
+
+## 五、风险清单
+
+| # | 风险 | 影响 | 缓解 |
+|---|---|---|---|
+| R1 | **微信 URI 类型差异**:不同版本微信传的 MIME 类型不一致(`application/octet-stream` 居多) | 文件类型判断错误 | 按扩展名兜底判断 |
+| R2 | **企微可能不传 `EXTRA_STREAM`**,而是走它自己的分享协议 | 收不到文件 | 真机实测后再定 |
+| R3 | 冷启动 Intent 丢失 | 第一次分享失败 | 必须实现 `onCreate` 入口 |
+| R4 | 大文件复制耗时(1GB 文件) | ANR | 后台线程 + 进度提示 |
+| R5 | Android 13/14 存储权限收紧 | 读取失败 | 优先用 `content://` 直读,不落到公共目录 |
+
+---
+
+## 六、与推送的关系
+
+外部分享与推送是**两条独立的线**,不要合并:
+
+- 分享:外部 → App(用户主动触发)
+- 推送:App → 用户(服务端或本地触发)
+
+本期两者都只到「设计/地基」,真正实现需单独立项。

+ 114 - 0
docs/03-interface/平台CORS多值修复需求-20261115.md

@@ -0,0 +1,114 @@
+# 平台接口 CORS 多值修复需求(2026-11-15)
+
+> 提出方:fmode-app-v2 前端
+> 受理方:fmode 平台 / 网关维护方
+> 优先级:**中高**——直接影响 Android App 的登录成功率
+
+---
+
+## 一、问题
+
+`https://server.fmode.cn/api/apig/message`(**登录发送手机验证码**)的响应头里,
+`Access-Control-Allow-Origin` **不是稳定的一条**,实测会随机返回 **3 条**。
+
+### 实测记录(2026-11-15,curl 直连)
+
+```bash
+curl -s -o /dev/null -D - -X POST "https://server.fmode.cn/api/apig/message" \
+  -H "Origin: https://localhost" -H "Content-Type: application/json" -d '{}' \
+  | grep -i access-control-allow-origin
+```
+
+| 接口 | 第 1 次 | 第 2 次 | 第 3 次 | 状态 |
+|---|---|---|---|---|
+| `/parse/**` | 1 条 | 1 条 | 1 条 | ✅ 稳定 |
+| `/api/functions` | 1 条 | 1 条 | 1 条 | ✅ 稳定 |
+| `/api/fmode/mobile` | 1 条 | 1 条 | 1 条 | ✅ 稳定 |
+| **`/api/apig/message`** | **3 条** | 1 条 | 1 条 | ❌ **随机** |
+
+---
+
+## 二、为什么这是问题
+
+浏览器 / WebView 规范要求 `Access-Control-Allow-Origin` **只能有一个值**。
+返回 3 条时,等价于 `https://localhost, *, *`,浏览器直接判定为非法:
+
+```
+Access to fetch at 'https://server.fmode.cn/api/apig/message' from origin
+'https://localhost' has been blocked by CORS policy:
+The 'Access-Control-Allow-Origin' header contains multiple values, but only one is allowed.
+```
+
+**用户看到的**:点了「获取验证码」没反应,或报「网络错误」。
+**概率**:约 1/3(实测),重试可成功,但体验很差。
+
+### 这个问题在 Web 端为什么没暴露
+
+Web 端(`https://ai.fmode.cn`)走的是 nginx 同源代理 `/api-platform/**`,
+反代时**兜底清空了上游的 `Access-Control-*` 响应头**,所以多值被吃掉了。
+
+**但 Android App 没有这层代理**(Capacitor 的 origin 是 `https://localhost`,
+本地没有 nginx 可配),所以多值问题会**直接暴露给用户**。
+
+---
+
+## 三、请求的修复
+
+**只要求一件事**:让 `/api/apig/message`(以及 `/api/apig/**` 下其他接口)
+的响应里,`Access-Control-Allow-Origin` **只出现一次**。
+
+### 可能的根因(供排查参考,非结论)
+
+1. 网关某几个实例同时加了一层 ACAO 头,另一些没加(多实例配置不一致)
+2. `apig` 中间件与全局 CORS 中间件**各自都加了一次**
+3. 某层返回了 `*`,另一层回显了 Origin
+
+### 建议做法(任选其一)
+
+- **方案 A(推荐)**:网关在响应返回前**统一删除所有** `Access-Control-*` 头,再按统一策略加一条
+- **方案 B**:检查 `/api/apig/**` 路由链上是否有两处 CORS 中间件,去掉其中一处
+
+---
+
+## 四、验证方式
+
+修复后请执行:
+
+```bash
+# 连续 10 次,每次 ACAO 条数都必须为 1
+for i in $(seq 1 10); do
+  curl -s -o /dev/null -D - -X POST "https://server.fmode.cn/api/apig/message" \
+    -H "Origin: https://localhost" -H "Content-Type: application/json" -d '{}' \
+    | grep -ci "access-control-allow-origin"
+done
+```
+
+期望输出:10 行,**每行都是 `1`**。
+
+建议同时用 `https://localhost`、`https://ai.fmode.cn`、`capacitor://localhost`
+三个 Origin 各验一遍。
+
+---
+
+## 五、前端已做的临时应对
+
+在该问题修复前,App 侧已加**自动重试**(`src/app/core/api/parse-api.ts::retryLoginRequest`):
+
+- 最多 3 次,间隔 400ms / 900ms
+- 仅对网络类失败(`TypeError: Failed to fetch`)重试
+- 业务错误(手机号格式错、验证码错)不重试
+
+**这只降低概率,不能根治**。且重试会让「获取验证码」在失败时多等约 1.3 秒。
+
+---
+
+## 六、影响范围
+
+| 端 | 是否受影响 |
+|---|---|
+| Web(`https://ai.fmode.cn`) | 否(nginx 代理已清头) |
+| **Android App** | **是,登录可能随机失败** |
+| 本地开发(`ng serve`) | 否(proxy.conf.js 代理) |
+
+修复后,App 侧的 `retryLoginRequest` 可以保留(作为网络抖动的兜底),
+也可以在下个版本简化——**不会产生副作用**。

Datei-Diff unterdrückt, da er zu groß ist
+ 843 - 1
package-lock.json


+ 10 - 1
package.json

@@ -22,7 +22,10 @@
     "verify:upload": "node scripts/verify-upload.mjs",
     "ops:clear-deliverables": "node scripts/clear-deliverable-urls.mjs",
     "verify:deliverables": "node scripts/verify-deliverable-separation.mjs",
-    "verify:ticking": "node scripts/verify-elapsed-ticking.mjs"
+    "verify:ticking": "node scripts/verify-elapsed-ticking.mjs",
+    "cap:sync": "ng build --configuration production && cap sync android",
+    "cap:open": "cap open android",
+    "cap:apk": "cd android && ./gradlew assembleDebug"
   },
   "prettier": {
     "printWidth": 100,
@@ -44,6 +47,11 @@
     "@angular/forms": "^20.3.0",
     "@angular/platform-browser": "^20.3.0",
     "@angular/router": "^20.3.0",
+    "@capacitor/android": "^8.6.2",
+    "@capacitor/app": "^8.1.2",
+    "@capacitor/core": "^8.6.2",
+    "@capacitor/local-notifications": "^8.3.1",
+    "@capacitor/share": "^8.0.3",
     "@fortawesome/fontawesome-free": "^7.3.1",
     "@silurus/ooxml": "^0.66.3",
     "fmode-ng": "^0.0.246",
@@ -59,6 +67,7 @@
     "@angular/build": "^20.3.36",
     "@angular/cli": "^20.3.36",
     "@angular/compiler-cli": "^20.3.0",
+    "@capacitor/cli": "^8.6.2",
     "@types/jasmine": "~5.1.0",
     "autoprefixer": "^10.6.1",
     "jasmine-core": "~5.9.0",

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

@@ -1,6 +1,8 @@
 import {
   ApplicationConfig,
   ErrorHandler,
+  inject,
+  provideAppInitializer,
   provideBrowserGlobalErrorListeners,
   provideZoneChangeDetection,
 } from '@angular/core';
@@ -10,6 +12,7 @@ import { provideMarkdown } from 'ngx-markdown';
 
 import { provideFmodeApi } from './core/api/api.provider';
 import { AppErrorHandler } from './core/app-error-handler';
+import { DeepLinkService } from './core/deep-link.service';
 import { routes } from './app.routes';
 
 export const appConfig: ApplicationConfig = {
@@ -32,5 +35,13 @@ export const appConfig: ApplicationConfig = {
     // markdown 渲染(消息正文 / 附件预览)—— ngx-markdown 需要 HttpClient
     provideHttpClient(),
     provideMarkdown(),
+    /*
+     * 深链监听(2026-11-15):`fmode://` 唤起 App 时跳对应页面。
+     *
+     * 🔴 用 initializer 而不是在 App 组件里起监听:
+     *    冷启动时链接事件可能早于任何组件构造,等组件起来再注册会丢掉首次跳转。
+     *    `start()` 内部自己判断「非原生壳直接返回」,浏览器里零副作用。
+     */
+    provideAppInitializer(() => inject(DeepLinkService).start()),
   ],
 };

+ 48 - 6
src/app/core/api/parse-api.ts

@@ -87,11 +87,16 @@ const FN = {
   /**
    * 文件正文代理(2026-11-11 新建,Function.name = fmode-file-read)。
    *
-   * 🔴 为什么要它:云盘 s3.fmode.cn 是 public-read 但**没配 CORS**,
+   * 🔴 为什么要它:早期云盘 s3.fmode.cn 是 public-read 但没配 CORS,
    *    浏览器 fetch 必被拦(「加载失败:Failed to fetch」),
    *    .md / .txt 在页内根本渲染不出来。
    *    由服务端代取文本,浏览器只调本域(已配 ACAO:*)。
    *
+   * ⚠️ 2026-11-15 复测更正:`s3.fmode.cn` **已经配好 CORS**(任意 Origin 回显),
+   *    页内直读理论上可行。但本代理**保留不动** —— 它同时承担了
+   *    「不把大文件整个拉进页面内存」与「统一鉴权口径」两个职责,
+   *    删掉是另一个话题,不在打包这条线上顺手改。
+   *
    * ⚠️ 已部署(2026-11-11,`tocXyQjyh6`)。未部署时此 ID 为空,`readText` 会抛错,
    *    调用方回落到「在新窗口打开」。
    */
@@ -153,20 +158,57 @@ export class ParseApi extends FmodeApi {
 
   async sendMobileCode(mobile: string): Promise<void> {
     // R4:POST /api/apig/message {mobile, company};🔴 code === 1 才算成功
-    const res = await this.rawFetch(`${PARSE_BASE.replace('/parse', '')}/api/apig/message`, {
-      method: 'POST',
-      body: JSON.stringify({ mobile, company: LOGIN_COMPANY }),
-    });
+    const res = await this.retryLoginRequest(() =>
+      this.rawFetch(`${PARSE_BASE.replace('/parse', '')}/api/apig/message`, {
+        method: 'POST',
+        body: JSON.stringify({ mobile, company: LOGIN_COMPANY }),
+      }),
+    );
     const d = (await res.json()) as { code?: number; msg?: string; mess?: string };
     if (d.code !== 1) throw new Error(friendlySendCodeError(pickMessage(d), res.status));
   }
 
+  /**
+   * 登录类请求的**自动重试**(2026-11-15)
+   *
+   * 🔴 为什么需要:`/api/apig/message` 实测会**随机返回多条**
+   *    `Access-Control-Allow-Origin`(同一网关多实例,2026-11-15 复测 3 次里出现 1 次 3 条)。
+   *    浏览器规定 ACAO 只能有一个值,多值 = 整个请求被拦,用户看到「网络错误」。
+   *
+   *    这是**平台侧缺陷**,治本方案是让平台去掉重复头
+   *    (见 `docs/03-interface/平台CORS多值修复需求-20261115.md`)。
+   *    App 里没有代理可绕(Capacitor 的 origin 是 `https://localhost`,
+   *    没有 nginx 做同源转发),所以只能退而求其次:**失败重试**。
+   *
+   * 重试策略:最多 3 次,间隔 400ms / 900ms。
+   * 只对**网络类失败**重试 —— 业务错误(手机号格式、验证码错)重试没有意义,
+   * 反而会多发短信,所以那些错误原样抛出。
+   */
+  private async retryLoginRequest(send: () => Promise<Response>): Promise<Response> {
+    /** CORS 被拦时 `fetch` 抛的是 TypeError('Failed to fetch'),拿不到 Response */
+    const delays = [400, 900];
+    let lastErr: unknown;
+    for (let attempt = 0; attempt <= delays.length; attempt++) {
+      try {
+        return await send();
+      } catch (e) {
+        lastErr = e;
+        /* 只有「连响应都没拿到」才重试;拿到 Response 的(含 4xx/5xx)交给调用方判业务码 */
+        const isNetworkish = e instanceof TypeError || /failed to fetch|network/i.test(String(e));
+        if (!isNetworkish || attempt === delays.length) break;
+        await new Promise((r) => setTimeout(r, delays[attempt]));
+      }
+    }
+    throw lastErr;
+  }
+
   async loginWithMobile(mobile: string, code: string): Promise<AuthSession> {
     // R5:GET /api/fmode/mobile?mobile&code&company;🔴 code === 200 && data.token
     const url = `${PARSE_BASE.replace('/parse', '')}/api/fmode/mobile?mobile=${encodeURIComponent(
       mobile,
     )}&code=${encodeURIComponent(code)}&company=${LOGIN_COMPANY}`;
-    const res = await this.rawFetch(url, { method: 'GET' });
+    /* 与发验证码同样可能被多值 ACAO 拦 → 同样重试(2026-11-15) */
+    const res = await this.retryLoginRequest(() => this.rawFetch(url, { method: 'GET' }));
     const d = (await res.json()) as {
       code?: number;
       data?: { token?: string };

+ 48 - 0
src/app/core/api/platform-proxy.service.ts

@@ -81,6 +81,29 @@ export class PlatformProxyService {
    */
   async probe(): Promise<boolean> {
     if (this.usable !== null) return this.usable;
+
+    /*
+     * 🔴 Capacitor 环境直接判「代理不可用」,不发探测请求(2026-11-15)。
+     *
+     * 同源代理 `/api-platform/**` 是**部署在 Web 服务器(nginx/静态服务)**上的转发规则。
+     * 打包成 App 后,WebView 的 origin 是 `https://localhost` —— 这个「服务器」
+     * 就是 Capacitor 内置的本地资源服务,**根本不存在那条转发规则**,
+     * 探测必然失败。
+     *
+     * 不跳过的话每次启动都会:
+     *   ① 多发一个注定 404 的请求
+     *   ② 在控制台打一条 `[PlatformProxy] 未转发到平台…` 警告
+     * 两条对 App 用户毫无意义,只会污染现场、干扰真问题的排查。
+     *
+     * 跳过之后 `usable = false` → `resolve()` 原样返回 URL → 直连 `server.fmode.cn`。
+     * 实测该域名对 `https://localhost` 能正常回 CORS 头(见下方 CORS 说明),
+     * 所以直连是这里**正确且唯一**的路径。
+     */
+    if (isNativeApp()) {
+      this.usable = false;
+      return false;
+    }
+
     if (this.probing) return this.probing;
     this.probing = (async () => {
       const probeUrl = `${this.prefix}/api/zzz-fmode-probe`;
@@ -142,3 +165,28 @@ export class PlatformProxyService {
     return `${this.prefix}${rest}`;
   }
 }
+
+/**
+ * 当前是否运行在 Capacitor 原生壳里(2026-11-15)。
+ *
+ * 判据取 Capacitor 注入的全局标记:
+ *   · `window.Capacitor.isNativePlatform()` —— 官方 API,版本变化时最可靠
+ *   · 兜底看 `protocol` 是否为 `capacitor:` / `ionic:`,
+ *     以及自定义 UA 标记 `FmodeApp/`(见 `capacitor.config.ts` 的 appendUserAgent)
+ *
+ * 纯浏览器环境下三者都为假 → 返回 false,行为与改动前完全一致。
+ */
+export function isNativeApp(): boolean {
+  if (typeof window === 'undefined') return false;
+  const cap = (window as { Capacitor?: { isNativePlatform?: () => boolean } }).Capacitor;
+  if (cap?.isNativePlatform) {
+    try {
+      return cap.isNativePlatform();
+    } catch {
+      /* 结构不符时走下面的兜底判据 */
+    }
+  }
+  const proto = window.location?.protocol ?? '';
+  if (proto === 'capacitor:' || proto === 'ionic:') return true;
+  return /FmodeApp\//.test(navigator?.userAgent ?? '');
+}

+ 156 - 0
src/app/core/app-notification.service.ts

@@ -0,0 +1,156 @@
+import { Injectable, inject } from '@angular/core';
+import { isNativeApp } from './api/platform-proxy.service';
+import { DeepLinkService } from './deep-link.service';
+
+/** 一条要弹出的通知 */
+export interface AppNotice {
+  /** 标题(通知栏第一行) */
+  title: string;
+  /** 正文 */
+  body: string;
+  /**
+   * 点击后跳转的深链(如 `fmode://chat/<agentId>/<sessionId>`)。
+   * 省略则点击只打开 App,不跳页。
+   */
+  deepLink?: string;
+  /**
+   * 去重键:同一 key 的通知会**覆盖**而不是叠加。
+   * 用于「同一会话的新消息」= 一条通知持续更新,而不是刷屏。
+   */
+  key?: string;
+  /** 通知 id(同一 key 复用同一个 id 才能覆盖) */
+  id?: number;
+}
+
+/**
+ * 系统通知(Android 通知栏)—— 2026-11-15 建立
+ *
+ * ## 为什么是「本地通知」而不是极光 JPush
+ *
+ * 用户 2026-11-15 裁决:本期只做「App 前台/后台存活时的通知」。
+ * 在这个前提下极光 SDK 的核心价值(服务端远程推送、App 被杀也能收到)**用不上**,
+ * 而它的 Capacitor 生态只有第三方个人 fork 仓库(1 star、停更),引入即背债。
+ *
+ * 所以用 Capacitor 官方 `@capacitor/local-notifications`:零风险、无账号、
+ * 无厂商通道申请。将来服务端推送链路就绪时,把这里的 `schedule()` 换成
+ * 「接收极光消息」,**上层调用方(TaskPulse / Unread)一行不用改**。
+ *
+ * ## 🔴 必须持续披露的限制
+ *
+ * App 被系统杀掉(国产 ROM 后台 5–10 分钟即可能清理)后,**收不到任何通知**。
+ * 用户期望的「微信发完文件、切到 App 就弹提醒」属于「到达提醒」,
+ * 需要服务端推送链路,**本期不提供**。
+ *
+ * ## 权限
+ *
+ * Android 13+(API 33)起通知需要运行时权限 `POST_NOTIFICATIONS`。
+ * `request()` 只在**首次真正要发通知时**调用,不在启动时弹 —— 冷启动就弹权限框
+ * 的转化率极低,用户还没理解为什么要给。
+ */
+@Injectable({ providedIn: 'root' })
+export class AppNotificationService {
+  private readonly deepLink = inject(DeepLinkService);
+
+  /** 是否已尝试过初始化(避免每次都走一遍插件加载) */
+  private initialized = false;
+  /** 用户是否授予了通知权限(`null` = 还没问过) */
+  private granted: boolean | null = null;
+  /** key → 通知 id 的映射(覆盖而非叠加) */
+  private readonly idsByKey = new Map<string, number>();
+  private nextId = 1000;
+
+  /**
+   * 初始化:注册点击回跳处理器。
+   *
+   * 只需调用一次(`main.ts` 或 App 根组件),重复调用无副作用。
+   */
+  async init(): Promise<void> {
+    if (!isNativeApp() || this.initialized) return;
+    this.initialized = true;
+    try {
+      const { LocalNotifications } = await import('@capacitor/local-notifications');
+      await LocalNotifications.addListener('localNotificationActionPerformed', (action) => {
+        const link = action.notification.extra?.deepLink;
+        if (typeof link !== 'string' || !link) return;
+        const target = this.deepLink.toRoute(link);
+        if (target) void this.deepLink.navigate(target);
+      });
+    } catch {
+      /* 插件不可用:通知是增强能力,失败不能让 App 起不来 */
+    }
+  }
+
+  /**
+   * 申请通知权限。
+   *
+   * @returns 是否已授权(非原生环境 / 插件缺失恒为 false)
+   */
+  async request(): Promise<boolean> {
+    if (!isNativeApp()) return false;
+    if (this.granted !== null) return this.granted;
+    try {
+      const { LocalNotifications } = await import('@capacitor/local-notifications');
+      const cur = await LocalNotifications.checkPermissions();
+      if (cur.display === 'granted') {
+        this.granted = true;
+        return true;
+      }
+      const res = await LocalNotifications.requestPermissions();
+      this.granted = res.display === 'granted';
+      return this.granted;
+    } catch {
+      this.granted = false;
+      return false;
+    }
+  }
+
+  /**
+   * 弹一条通知。
+   *
+   * 权限未授予时会**先申请一次**,被拒则静默放弃(不打断用户操作)。
+   */
+  async notify(notice: AppNotice): Promise<void> {
+    if (!isNativeApp()) return;
+    await this.init();
+    if (!(await this.request())) return;
+
+    const key = notice.key ?? '';
+    const id = notice.id ?? (key ? this.idForKey(key) : this.freshId());
+
+    try {
+      const { LocalNotifications } = await import('@capacitor/local-notifications');
+      await LocalNotifications.schedule({
+        notifications: [
+          {
+            id,
+            title: notice.title,
+            body: notice.body,
+            /* 有深链才带 extra,点击处理器据此决定跳不跳 */
+            extra: notice.deepLink ? { deepLink: notice.deepLink } : undefined,
+            /* 点击后自动清除,不堆积 */
+            autoCancel: true,
+            smallIcon: 'ic_stat_icon_config_sample',
+            channelId: 'fmode-default',
+          },
+        ],
+      });
+    } catch {
+      /* 静默:通知失败不该影响业务 */
+    }
+  }
+
+  /** 同一 key 复用同一个 id → 新通知覆盖旧通知 */
+  private idForKey(key: string): number {
+    const hit = this.idsByKey.get(key);
+    if (hit !== undefined) return hit;
+    const id = this.freshId();
+    this.idsByKey.set(key, id);
+    return id;
+  }
+
+  private freshId(): number {
+    /* Android 通知 id 是 int32,别溢出 */
+    this.nextId = this.nextId >= 2_000_000_000 ? 1000 : this.nextId + 1;
+    return this.nextId;
+  }
+}

+ 17 - 4
src/app/core/attachment-fetch.ts

@@ -58,11 +58,24 @@ export interface BlobResult {
  * ## 为什么要能退回原地址
  *
  * 带 CSP 加固的预览必须读文本 → 必须 `fetch` → **跨域需要对方配 CORS**。
- * 实测 `s3.fmode.cn` **没有配 CORS 头**,所以云盘上的成果一律 fetch 失败
- * (浏览器报 `blocked by CORS policy`,用户看到「文件加载失败」)。
  *
- * 但这些对象是 `public-read`,**直接作为 iframe src 能正常显示** ——
- * iframe 不受 CORS 限制(只是偶尔无法读取内部内容)。
+ * ## 🔴 2026-11-15 实测更正:CORS 现在已经配好了
+ *
+ * 原文写的是「实测 `s3.fmode.cn` 没有配 CORS 头」——**该结论已过时**。
+ * 平台侧后来补上了 CORS,2026-11-15 复测(均为 `OPTIONS` 预检 + 正式请求):
+ *
+ * | Origin | `GET` | `PUT` + `x-obs-acl` |
+ * |---|---|---|
+ * | `https://localhost`(Capacitor 壳) | ✅ 回显该 Origin | ✅ 允许 |
+ * | `capacitor://localhost` | ✅ 回显该 Origin | ✅ 允许 |
+ * | `http://localhost:4200`(本地开发) | ✅ | ✅ |
+ * | `https://ai.fmode.cn`(线上) | ✅ | ✅ |
+ *
+ * OBS 配置的是**任意 Origin 回显**,不再有跨域断点。
+ *
+ * 但下面的降级逻辑**仍然保留**:① 老浏览器/内网环境仍可能被代理拦;
+ * ② 将来若平台收紧 CORS,这里能自动退回原 URL 而不是白屏。
+ * 一句话:**注释里的事实更新了,代码的兜底不删**。
  *
  * 所以:CORS 可用 → 走 blob(可注入 CSP,更安全);
  *       CORS 不可用 → **退回原 URL**(能看,但这一个 iframe 无法注入 CSP)。

+ 124 - 0
src/app/core/deep-link.service.ts

@@ -0,0 +1,124 @@
+import { Injectable, inject } from '@angular/core';
+import { Router } from '@angular/router';
+import { isNativeApp } from './api/platform-proxy.service';
+
+/**
+ * App 深链(deep link)—— `fmode://` scheme 的解析与跳转(2026-11-15)
+ *
+ * ## 它解决什么
+ *
+ * 打包成 App 后,「点一条通知跳到对应会话」必须能**从外部**唤起页面:
+ * 系统通知、微信里点开的链接、其他 App 的跳转。
+ * 浏览器里靠 URL 即可;原生壳里要靠自定义 scheme + Android `intent-filter`。
+ *
+ * ## 支持的链接
+ *
+ * | 链接 | 落到 |
+ * |---|---|
+ * | `fmode://chat/<agentId>/<sessionId>` | `/mobile/chat/<agentId>/<sessionId>` |
+ * | `fmode://chat/<agentId>` | `/mobile/chat/<agentId>` |
+ * | `fmode://messages` | `/mobile/messages` |
+ * | `fmode://contacts` | `/mobile/contacts` |
+ * | `fmode://workbench` | `/mobile/workbench` |
+ * | `fmode://mine` | `/mobile/mine` |
+ *
+ * 🔴 **不新增路由**:所有目标都落在既有的 `mobile.routes.ts` 上。
+ *    深链只是「外部 → 内部路径」的翻译层,页面自己不需要知道深链的存在。
+ *
+ * ⚠️ 强制走移动端路由(`/mobile/**`):App 是手机形态,没有桌面布局。
+ *    桌面端判定 `detectUiMode()` 在 WebView 里也可能返回 desktop(宽屏平板),
+ *    所以这里**显式写死 mobile 前缀**,不依赖自动判定。
+ */
+@Injectable({ providedIn: 'root' })
+export class DeepLinkService {
+  private readonly router = inject(Router);
+
+  /** 已注册监听(避免重复绑定) */
+  private registered = false;
+
+  /**
+   * 开始监听 `appUrlOpen`(App 被外部链接唤起时触发)。
+   *
+   * 只在原生壳里生效;浏览器里直接返回,不产生任何副作用。
+   */
+  async start(): Promise<void> {
+    if (!isNativeApp() || this.registered) return;
+    this.registered = true;
+    try {
+      const { App } = await import('@capacitor/app');
+      const handle = await App.addListener('appUrlOpen', (ev) => {
+        const target = this.toRoute(ev.url);
+        if (target) void this.router.navigateByUrl(target);
+      });
+      /*
+       * 冷启动:App 被链接直接拉起时,`appUrlOpen` 之外还要看
+       * `App.getLaunchUrl()` —— 监听器注册晚于启动事件的话会丢掉首次链接。
+       */
+      const launch = await App.getLaunchUrl();
+      if (launch?.url) {
+        const target = this.toRoute(launch.url);
+        if (target) void this.router.navigateByUrl(target);
+      }
+      /* 监听器生命周期跟随 App,无需手动解绑;保留引用便于将来需要时 remove() */
+      void handle;
+    } catch {
+      /*
+       * 插件缺失 / 非原生环境:静默降级。
+       * 深链是增强能力,失败不能让 App 起不来。
+       */
+    }
+  }
+
+  /**
+   * 直接跳到一个应用内路径(供通知点击等**已在应用内**的场景复用)。
+   *
+   * 与 `toRoute()` 分开:那个负责「深链 URL → 路径」的翻译,
+   * 这个只负责「路径 → 真的跳过去」,便于调用方在已经有路径时直接用。
+   */
+  navigate(path: string): Promise<boolean> {
+    return this.router.navigateByUrl(path);
+  }
+
+  /**
+   * 深链 URL → 应用内路由(纯函数,便于单测)。
+   *
+   * @returns 可导航的绝对路径;无法识别时返回 `null`(**不跳转**,避免把用户带到莫名其妙的地方)
+   */
+  toRoute(raw: string): string | null {
+    if (!raw) return null;
+    let u: URL;
+    try {
+      u = new URL(raw);
+    } catch {
+      return null;
+    }
+    /* `fmode://chat/a/b` 里 chat 是 host、a/b 是 pathname */
+    if (!/^fmode:?$/i.test(u.protocol)) return null;
+
+    const seg = [
+      u.hostname,
+      ...u.pathname.split('/').filter(Boolean),
+    ].map((s) => decodeURIComponent(s));
+
+    switch (seg[0]) {
+      case 'chat': {
+        const agentId = seg[1];
+        if (!agentId) return '/mobile/messages';
+        const sessionId = seg[2];
+        return sessionId
+          ? `/mobile/chat/${encodeURIComponent(agentId)}/${encodeURIComponent(sessionId)}`
+          : `/mobile/chat/${encodeURIComponent(agentId)}`;
+      }
+      case 'messages':
+        return '/mobile/messages';
+      case 'contacts':
+        return '/mobile/contacts';
+      case 'workbench':
+        return '/mobile/workbench';
+      case 'mine':
+        return '/mobile/mine';
+      default:
+        return null;
+    }
+  }
+}

+ 19 - 1
src/app/mobile/pages/chat/chat.ts

@@ -41,6 +41,7 @@ import { Message } from '../../../core/api/models';
 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 { fileIdentity } from '../../../core/s3-link';
 import { mergeConsecutiveBodies } from '../../../core/message-merge';
 import { ToolActivityComponent } from '../../../components/domain/tool-activity/tool-activity';
@@ -1060,6 +1061,8 @@ export class MbChatPage implements OnDestroy {
    * 容器已由 `app.ts` 全局挂载 `UiMToast`,直接注入即可。
    */
   private readonly toast = inject(UiMToastService);
+  /** 系统通知(2026-11-15):App 在后台时也能在通知栏留痕 */
+  private readonly notify = inject(AppNotificationService);
   private static readonly TIMEOUT_TITLE = '容器长时间未响应';
 
   /** 用时 → 人话(完成提醒专用;与 task-pulse 的 fmtDur 同口径,那边未导出) */
@@ -1416,7 +1419,22 @@ export class MbChatPage implements OnDestroy {
       if (latest.at <= seenPulseAt) return;
       seenPulseAt = latest.at;
       const spent = latest.durationMs ? `,用时 ${MbChatPage.durText(latest.durationMs)}` : '';
-      this.toast.success(`${latest.task ? latest.task + ' 已完成' : '任务已完成'}${spent}`);
+      const text = `${latest.task ? latest.task + ' 已完成' : '任务已完成'}${spent}`;
+      this.toast.success(text);
+      /*
+       * 同时发一条系统通知(2026-11-15)。
+       *
+       * 两者不重复劳动:toast 只在 App 在前台时看得见,用户切到别的 App 就错过;
+       * 系统通知会挂在通知栏,切走再回来还能看到。浏览器里 `notify()` 内部直接返回,
+       * 不影响 Web 端行为。
+       */
+      const sid = this.chat.currentSession()?.id;
+      void this.notify.notify({
+        title: '任务完成',
+        body: text,
+        deepLink: agentId && sid ? `fmode://chat/${agentId}/${sid}` : undefined,
+        key: `task-done:${agentId}`,
+      });
     });
 
     /*

Einige Dateien werden nicht angezeigt, da zu viele Dateien in diesem Diff geändert wurden.