Compare commits

..

21 Commits

Author SHA1 Message Date
song 76c316783a fix: blacklist call-status noise (telecom / AI-call) via bl v3
Incoming calls are already handled by PhoneCallMonitor (pkg=phone_call);
com.android.server.telecom and com.xiaomi.aiasst.service emit redundant
in-call status notices. Add them to default blacklist (bl_version 3),
migrated into existing users' blacklist (add-only).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-08 15:59:51 +08:00
song c2fba02048 fix: retry transient push failures + blacklist noisy apps
Investigated 9 failed forwards on device: all "connection closed" to
api.day.app, while ping/curl to the server succeed -> not network-blocked,
but OkHttp transient connection drops that aren't retried (POST read-phase
connection close is not covered by retryOnConnectionFailure).

- BarkClient.push: manual retry up to 3x (600/1200ms backoff) on
  IOException (connection closed/timeout/TLS reset); HTTP 4xx/5xx not retried.
- AppRulesStore: add recorder/gallery/find-device/password-manager to default
  blacklist; versioned migration (bl_version) merges the new entries into
  existing users' blacklist (add-only).
- version 1.5.0 -> 1.5.1 (code 8 -> 9); CLAUDE.md synced.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-08 15:52:18 +08:00
song bd15424c5f docs: sync CLAUDE.md to v1.5.0
- version 1.2.0/code 3 -> 1.5.0/code 8 (was badly stale)
- package table: add PhoneCallMonitor/ConnectivityMonitor/CarrierDetector/
  CarrierBalanceQuery/SmsFallbackForwarder/UpdateChecker; fix A2iApp holders
- core data flow: add offline SMS-fallback branch + phone/update side paths
- pipeline: 12 -> 14 steps (sent-SMS filter, carrier-balance interception)
- release flow: actual python+gh-token.txt process (no gh CLI)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-08 15:21:59 +08:00
song 610ac094ed chore: gitignore gh-token.txt and *.token secrets
Local credential file used for release uploads; must never be committed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-08 14:49:39 +08:00
song f364ae55d3 feat: in-app update check + download + install
Auto-check GitHub Releases latest on launch (24h throttle), show an
update banner on the home screen, download the APK in-app with progress,
and trigger the system installer via FileProvider + REQUEST_INSTALL_PACKAGES.

- core/UpdateChecker: fetch releases/latest, semver compare, stream APK
  to externalFilesDir, install via FileProvider (handles MIUI unknown-sources).
- SettingsStore: updateCheckEnabled / lastUpdateCheckTime / ignoredVersion.
- HomeScreen: update banner + UpdateDialog.
- SettingsScreen: "关于与更新" card with auto-check toggle and manual check.
- manifest: REQUEST_INSTALL_PACKAGES + FileProvider; new res/xml/file_paths.xml.
- version 1.4.2 -> 1.5.0 (versionCode 7 -> 8).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-08 13:54:53 +08:00
song 036b4d3201 docs: 新增「自建 Bark 服务」章节(Vercel/Cloudflare/VPS 三方案)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-08 12:40:00 +08:00
song 392620e0f1 feat: add icons for Telegram, Alipay, DingTalk, Mijia, AITO
- org.telegram.messenger (Telegram)
- com.eg.android.AlipayGphone (支付宝)
- com.alibaba.android.rimet (钉钉)
- com.xiaomi.smarthome (米家)
- app.huawei.auto (鸿蒙智行/AITO 问界)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-08 12:30:20 +08:00
song 561d0460e3 feat: 清空日志弹窗选择范围(仅日志 / 日志+计数器)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-08 10:33:50 +08:00
song 15e27db04a fix: 清空日志时同步重置首页计数器
clearLogs() 同时归零 sent/filtered/failed 三个累计计数器
版本 1.4.1 → 1.4.2

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-08 10:30:01 +08:00
song d1afbd2425 feat: silent-filter ongoing notifications (no log entry)
持续性通知(音乐/下载/通话中等)不再写日志,静默丢弃
Decision 新增 silent 字段,NotifyListenerService 跳过静默过滤的日志
版本 1.4.0 → 1.4.1

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-08 10:17:28 +08:00
song c7efd8d2be docs: README 标题改为 A2i ·安到果(按倒过)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-08 09:09:48 +08:00
song e99da175b5 feat: offline SMS fallback for important notifications
断网时把验证码、来电用短信发到 iPhone:
- ConnectivityMonitor: NetworkCallback 监听在线状态
- CarrierDetector: SIM MCC+MNC 映射移动/联通/电信/广电
- CarrierBalanceQuery: 发免费查询短信拿套餐短信余量,解析回执
- SmsFallbackForwarder: 限频(5min)/余额(≤5挂起)/紧要性检查
- NotificationProcessor 拦截运营商回执(10086/10010/10001/10099)
- 设置页新增短信兜底卡片,首页余额耗尽警告

版本 1.3.0 → 1.4.0

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-08 08:54:37 +08:00
song 0bf759ae64 feat: phone call monitoring + SMS sent filter + icon prefix default
- 新增 PhoneCallMonitor:通过轮询 getCallState() 感知来电/未接
  (绕过 MIUI 对系统电话通知的限制,需 READ_PHONE_STATE 权限)
- 短信仅收不发:过滤"发送中"/"已发送"等关键词
- 图标 URL 前缀默认值:GitHub raw 图床
- 从黑名单移除 com.android.phone/com.android.incallui
- 默认黑名单新增三星剪贴板/GMS 等噪音包名
- 版本 1.2.0 → 1.3.0

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-07 13:52:40 +08:00
song 3ef69bc56f feat: add app icons for WeChat, QQ, SMS, Phone
Icon prefix for a2i settings:
https://raw.githubusercontent.com/lsxf/a2i/main/icons/

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-07 09:25:51 +08:00
song ab113e5691 docs: a2i · 安到果
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-06 21:24:05 +08:00
SJZ bcc7ed298f Update README.md 2026-07-06 20:41:16 +08:00
song a814e4469b docs: add screenshots gallery to README
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-06 20:35:41 +08:00
song dd42b54294 docs: add app screenshots for all 5 pages
- home: brand hero + status panel + metrics
- apps: app management with blacklist/whitelist
- filter: ad filter + code extraction + ongoing toggle
- settings: bark server management + icon export
- log: forwarding history with retry queue

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-06 20:30:06 +08:00
song 82419d0cfb Update CLAUDE.md: UI component layer, theme, release build, version and publish notes 2026-07-06 19:45:43 +08:00
song 6e8fb52d1a Add README 2026-07-06 19:35:55 +08:00
song 93c4ed9b5b Initial Android notification forwarder 2026-07-06 18:57:40 +08:00
73 changed files with 4617 additions and 851 deletions
+33
View File
@@ -0,0 +1,33 @@
# Gradle / Android build output
.gradle/
.kotlin/
build/
app/build/
# Local machine configuration
local.properties
.do-session
# Secrets — never commit
gh-token.txt
*.token
# Android Studio / IntelliJ
.idea/
*.iml
# Generated artifacts
*.apk
*.aab
*.ap_
# Logs
*.log
# Local screenshots and throwaway files
shot*.png
shotApp.png
test.json
# Local assistant session helpers
.claude/
do.ps1
+121
View File
@@ -0,0 +1,121 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## 项目概述
**a2i** — 安卓通知转发到 iOS Bark。监听 Android 系统通知(短信、验证码、聊天等),通过 Bark Server 推送到 iOS 设备。iOS 端点击通知可跳转到对应 App(微信、QQ、Telegram)。
## 环境配置(本机)
环境变量需要在每次 shell 调用前显式导出:
```bash
export JAVA_HOME="D:/Dev/JDK-17"
export ANDROID_HOME="D:/Dev/Android/SDK"
```
- JDK 17: `D:\Dev\JDK-17`(项目使用 `sourceCompatibility = VERSION_17`)
- Android SDK: `D:\Dev\Android\SDK`
- Gradle 8.11.1(wrapper 内置)
- AGP 8.9.1 / Kotlin 2.1.0
`local.properties` 已在项目根(`sdk.dir=D:\Dev\Android\SDK`),**不要提交**。
## 构建与安装
```bash
# 编译 debug APK
export JAVA_HOME="D:/Dev/JDK-17" ANDROID_HOME="D:/Dev/Android/SDK"
./gradlew assembleDebug
# 产物位置
app/build/outputs/apk/debug/app-debug.apk
# 安装到已连接的设备
"D:/Dev/Android/SDK/platform-tools/adb.exe" install -r app/build/outputs/apk/debug/app-debug.apk
```
无 lint / 测试任务配置(项目未启用)。改完代码直接 `assembleDebug` 验证即可。
构建可分发的 release APK(已绑定 debug 签名,产出可直接安装):
```bash
export JAVA_HOME="D:/Dev/JDK-17" ANDROID_HOME="D:/Dev/Android/SDK"
./gradlew assembleRelease
# 产物:app/build/outputs/apk/release/app-release.apk
adb install -r app/build/outputs/apk/release/app-release.apk
```
> `release` 复用 `debug` 签名配置(`signingConfigs.getByName("debug")`),适合个人发布到 GitHub Release;正式商店上架前需替换为独立的 release keystore。
## 架构
### 核心数据流
```
NotifyListenerService (系统通知入口)
→ NotificationProcessor.process(sbn) → Decision(message?, reason, code, silent)
├─ 在线:BarkClient.push(msg) → OkHttp POST;失败入 pending → RetryWorker (15min 周期)
└─ 离线:紧要通知(验证码/来电) → SmsFallbackForwarder 走短信兜底
→ LogStore.addLog(...)(silent 的静默过滤不写日志)
旁路(不经通知监听器):
PhoneCallMonitor 轮询 getCallState() → 来电/未接构造 Decision 推送(绕过 MIUI 限制)
UpdateChecker 查 GitHub Releases → 新版本横幅 → 应用内下载 + 系统安装器
```
### 包结构 `com.a2i.forwarder`
| 文件 | 职责 |
|---|---|
| `A2iApp.kt` | `Application` 单例;持有全局 `appScope`(`SupervisorJob+Dispatchers.Default`)与全局组件:三个 Store(`settings`/`appRules`/`logStore`)+ 监听类(`phoneCallMonitor`/`connectivityMonitor`/`carrierBalanceQuery`/`smsForwarder`/`updateChecker`)。全部经 `A2iApp.instance.xxx` 访问,`onCreate` 构造并 `start()`。UI 通过 `collectAsState()` 订阅 Store 的 `StateFlow`。 |
| `service/NotifyListenerService.kt` | `NotificationListenerService` 实现;每条通知调 `processor.process()`,过滤后推送。 |
| `core/NotificationProcessor.kt` | **核心决策点**。包含 LRU 去重缓存(5s)、脱敏检测(`*****` → 回退到 `tickerText`)、系统状态消息过滤、广告过滤、验证码提取、特殊 App 解析。返回 `Decision(message?, reason, appLabel, code)`。 |
| `core/CodeExtractor.kt` | 三段正则:关键词+数字、数字+关键词、纯数字 4-8 位。检测到全 `*****` 时直接返回 null。 |
| `core/AdFilter.kt` | 内置营销关键词 + 用户自定义关键词(`settings.adKeywords`)。 |
| `core/AppParsers.kt` | 微信/QQ/Telegram 的 `clickUrl` 映射 + 重要消息关键词(@我、验证码、通话等)。 |
| `core/BarkClient.kt` | OkHttp POST 到 `${server}/push`,超时 10/10/15s;对 connection closed / 超时 / TLS 中断等瞬时 IOException 自动重试 3 次(退避 600/1200ms),HTTP 4xx/5xx 不重试。 |
| `core/RetryWorker.kt` | WorkManager 周期任务,每 15 分钟重发 `pending` 队列中的失败消息。 |
| `core/LogStore.kt` | 最近 300 条日志 + 失败重发队列(≤100)。DataStore 持久化。 |
| `core/IconExporter.kt` | 一键导出所有已安装 App 图标到用户选择的 SAF 目录(用于部署到 iOS Bark 图床)。 |
| `core/PhoneCallMonitor.kt` | 轮询 `TelephonyManager.getCallState()`(800ms)感知来电/未接来电,构造 Decision 推送。绕过 MIUI 不向第三方监听器分发系统电话通知的限制。 |
| `core/ConnectivityMonitor.kt` | `ConnectivityManager` + `NetworkCallback`,暴露 `isOnline: StateFlow<Boolean>`,供离线兜底判断。 |
| `core/CarrierDetector.kt` | SIM MCC+MNC 映射到运营商(移动/联通/电信/广电),供短信兜底发免费查询码。 |
| `core/CarrierBalanceQuery.kt` | 向运营商服务号发查询短信、解析回执余量、余额≤5 自动挂起。 |
| `core/SmsFallbackForwarder.kt` | 断网时把紧要通知(验证码/来电)用短信发到 iPhone,5 分钟限频 + 余额检查。 |
| `core/UpdateChecker.kt` | 查 GitHub Releases 最新版(24h 节流)→ 语义比较 → 下载 APK → FileProvider 调起系统安装器。 |
| `store/SettingsStore.kt` | Bark 服务器列表、全局开关、广告关键词等。`MutableStateFlow` + DataStore。 |
| `store/AppRulesStore.kt` | 黑/白名单模式 + 默认黑名单(系统 App + 输入法 + 三星剪贴板/键盘 + GMS + 录音机/相册/查找/密码管理 + 通话管理/AI通话);默认黑名单带版本号 `bl_version`,升级时把新增噪音包合并进老用户黑名单(只增不删)。 |
| `ui/nav/AppNav.kt` | 4 个底部 Tab + 日志页。 |
| `ui/theme/*` | 固定品牌配色(`Color.kt`)、自定义 `Typography`(`Type.kt`),`A2iTheme` 关闭动态取色、统一深浅色方案(`Theme.kt`)。 |
| `ui/screens/*.kt` | HomeScreen(品牌横幅 + 运行状态 + 统计 + 更新横幅 + 短信挂起提醒)、AppManageScreen、FilterScreen、SettingsScreen(Bark 服务 / 应用图标 / 断网短信兜底 / 关于与更新)、LogScreen、UpdateDialog(应用内更新弹窗)。 |
| `ui/Components.kt` | 共享 UI 组件层:`ScreenTitle`、`SectionCard`、`SwitchRow`、`ClickRow`、`IconBox`、`StatusBadge`、`MetricBlock`,以及 `appVersionName(context)`、`drawableToImageBitmap(d)`、`isNotificationListenerEnabled(context)` 工具函数。 |
### 重要约定
- **Store 全局单例**:所有 Store 通过 `A2iApp.instance.xxx` 访问。**不要**在 Compose 中创建新的 Store 实例。
- **协程作用域**:跨屏调用用 `app.appScope.launch { ... }`(Application 级别的 `SupervisorJob + Dispatchers.Default`)。
- **过滤流水线**(顺序很重要,在 `NotificationProcessor.process` 中):
1. 全局开关 → 2. ongoing 过滤(静默)→ 3. App 黑/白名单 → 4. 空内容/脱敏 → 5. 噪音包名 → 6. 系统状态消息 → 7. 近空内容 → 8. 广告过滤 → 9. 已发短信(仅收不发)→ 10. 运营商余额回执拦截 → **11. 去重检查(5s 窗口)** → 12. 验证码提取 → 13. 特殊 App 解析 → 14. 构造 BarkMessage
- **Bark API** 字段映射在 `BarkMessage.kt`(`@SerialName`):`device_key`/`autoCopy`/`isArchive` 用下划线/驼峰别名,其他用蛇形。
- **版本号**:`app/build.gradle.kts` 的 `versionCode`/`versionName`,运行时通过 `appVersionName(context)` 读取,首页底部居中显示 `v{version}`。**改完代码同步递增 versionCode/versionName,并更新本文档「版本与发布」一节的当前版本。**
### 通知监听授权
监听服务依赖 `Settings.ACTION_NOTIFICATION_LISTENER_SETTINGS` 授权。`HomeScreen` 检测授权状态,未授权时显示跳转按钮。授权是**一次性**的,卸载后需重新授权。
### 调试日志
`NotifyListenerService.handle()` 每次都会写一条 `ForwardLog` 到 `LogStore`(无论转发/过滤/失败),可在「日志」页查看最近 300 条。失败会自动加入 `pending` 队列,由 `RetryWorker` 后续重发。
## 版本与发布
- 当前版本:`versionCode = 9`、`versionName = "1.5.1"`(位于 `app/build.gradle.kts`)。**每次改动后同步递增 versionCode/versionName,并同步更新本节。**
- 远端仓库:`https://github.com/lsxf/a2i.git`,默认分支 `main`。
- 提交策略:`.gitignore` 已排除 `app/build`、`.gradle`、`.kotlin`、`local.properties`、`.claude/`、`do.ps1`、`gh-token.txt`(发版用 token)、`*.token`、截图与临时文件,**不要**提交它们。
- 发版流程:
1. 递增 `versionCode`/`versionName` → `./gradlew assembleRelease`(产物 `app/build/outputs/apk/release/app-release.apk`)
2. `git tag -a v{versionName} -m "..."` → `git push origin v{versionName}`
3. 本机无 `gh` CLI:用 python(urllib) 调 GitHub API 创建 Release + 上传 APK。token 放仓库根 `gh-token.txt`(已 gitignore),脚本里 `open('gh-token.txt')` 读取,**勿**把 token 写进命令参数。注意 Git Bash 的 `/tmp` 与 Windows python 路径不一致,脚本内一律用项目相对路径。
- UI 现状:统一品牌科技感主题(固定品牌色 + 自定义 Typography + 共享组件层),深浅色双方案,关闭动态取色;新组件统一通过 `ui/Components.kt` 复用,避免各页面各拼一套样式。
+115 -750
View File
@@ -1,806 +1,171 @@
# GotMsg · 有消息 # A2i ·安到果(按倒过[呲牙])
<p align="center">
<img src="screenshots/home-latest.png" alt="GotMsg 1.18.6 首页" width="360">
</p>
<p align="center">
<img src="https://img.shields.io/badge/Android-14%2B-3DDC84?logo=android&logoColor=white" alt="Android 14+">
<img src="https://img.shields.io/badge/Release-v1.18.6-2563EB" alt="Release v1.18.6">
<img src="https://img.shields.io/badge/Bark-支持-FF6B57" alt="Bark">
<img src="https://img.shields.io/badge/ntfy-支持-338574" alt="ntfy">
<img src="https://img.shields.io/badge/Telegram-支持-229ED9" alt="Telegram">
<img src="https://img.shields.io/badge/飞书-支持-00B96B" alt="飞书">
<img src="https://img.shields.io/badge/钉钉-支持-1677FF" alt="钉钉">
<img src="https://img.shields.io/badge/Meow-支持-FF9800" alt="Meow">
<img src="https://img.shields.io/badge/License-All%20Rights%20Reserved-E5484D" alt="License">
</p>
GotMsg 安装在一台 Android 手机上,读取这台手机收到的系统通知,再转发到 **iPhone、iPad、另一台 Android、鸿蒙手机、邮箱或指定用户的私聊机器人**。短信验证码、来电、微信、QQ、Telegram 和普通 App 通知都可以进入转发链路。
除了“看通知”,GotMsg 还支持从 Bark / ntfy 通知打开一次性网页,把回复送回原 Android 手机:优先使用系统快捷回复,QQ 等 App 可走无障碍兼容回复,微信可走 Shizuku。
原手机主动开启并逐项授权后,还可以登录 [网页远程控制台](https://r.gotmsg.pp.ua/control) 查看设备状态、找手机、管理通知与短信,并执行已授权的设备操作;敏感动作默认不开放。
🌐 **官方网站**:[gotmsg.pp.ua](https://gotmsg.pp.ua)
> [!IMPORTANT]
> GotMsg 不是聊天软件,也不会登录微信或 QQ。它处理的是 Android 通知;原 App 没有产生通知、通知内容被隐藏,或原手机被系统彻底休眠时,GotMsg 就拿不到对应内容。
## 功能一览
| 功能 | 状态 | 说明 |
|---|---:|---|
| 多通道转发 | 🟢 | Bark、ntfy、Meow、SMTP 电邮、Telegram 私聊、飞书企业应用私聊、钉钉企业机器人私聊,可同时启用多条配置 |
| 断网短信兜底 | 🟢 | 原手机断网时,用 SIM 卡短信转发验证码、来电等紧要通知 |
| 验证码提取 | 🟢 | 识别 4–8 位验证码;锁屏通知被星号遮挡时可用 Shizuku 恢复原文,否则安全过滤 |
| App 黑白名单 | 🟢 | 默认转发全部或只转发指定 App;规则加载失败时自动暂停转发 |
| 广告与噪音过滤 | 🟢 | 内置规则、自定义关键词、常驻通知过滤和 5 秒去重 |
| 来电与未接来电 | 🟢 | 兼容 MIUI / HyperOS;有权限时补充号码和联系人姓名 |
| 远程回复 | 🟢 | Android `RemoteInput`、无障碍兼容回复、微信 Shizuku 回复 |
| 网页远程控制 | 🟡 可选 | 原手机主动开启并逐项授权;支持状态查询、找手机、短信与通知管理、设备操作 |
| 锁屏 PIN 自动解锁 | 🟡 可选 | 仅 Shizuku 已运行时;PIN 用 Android Keystore 加密保存,每次最多尝试一次 |
| 自动更新 | 🟢 | 默认开启 Shizuku 静默更新;Gitea 优先、GitHub 备用,自动选择本机适配 APK |
| 权限健康检查 | 🟢 | 启动时按已启用功能检查必要权限,异常时从首页直达权限说明和系统设置 |
| 日志与失败重试 | 🟢 | 最近 300 条处理记录;发送失败进入待重发队列 |
| 配置备份与导入 | 🟢 | 导出全部转发配置为 JSON 文件;新装/重装时一键导入还原,导入前自动备份当前配置 |
| 测试版更新 | 🟡 可选 | 「测试版更新」开关开启后额外检查测试仓库,取版本号更高的更新 |
## 目录
- [先看懂两台手机的角色](#先看懂两台手机的角色)
- [先选择适合自己的接收方式](#先选择适合自己的接收方式)
- [需要安装哪些 App](#需要安装哪些-app)
- [十分钟上手](#十分钟上手)
- [推送通道详细配置](#推送通道详细配置)
- [远程回复详细教程](#远程回复详细教程)
- [权限和后台保活](#权限和后台保活)
- [过滤、来电、图标和更新](#过滤来电图标和更新)
- [常见问题排查](#常见问题排查)
- [界面截图](#界面截图)
- [隐私与安全](#隐私与安全)
--- 把安卓手机上的通知(短信、验证码、电话“有电话进来这个通知,不是电话呼叫转移”、微信、QQ、Telegram 等)实时转发到 iOS,通过 [Bark](https://github.com/Finb/Bark) 推送到 iPhone / iPad。在 iOS 上点击通知,还能一键跳转到对应的 App。
## 先看懂两台手机的角色 适用于把安卓备机的消息同步到 iOS 设备,不漏验证码、不漏重要聊天。
| 名称 | 是哪台设备 | 要做什么 | ## 主要功能
|---|---|---|
| **原手机 / 发送端** | 收到短信、微信、QQ 等通知的 Android 手机 | 安装 GotMsg,授予通知读取权限,保持联网和后台运行 |
| **接收端** | 你随身使用的 iPhone、iPad、Android、鸿蒙手机或电脑 | 根据通道安装 Bark、ntfy、Meow、Telegram、飞书或钉钉,或直接收邮件 / 短信 |
举例:一台放在家里的小米 Android 手机插着银行卡手机号,你日常使用 iPhone。GotMsg 安装在小米手机,Bark 安装在 iPhone。 - **系统级通知监听**:接入 Android 通知监听服务,覆盖所有发出通知的 App。
- **Bark 推送**:支持官方 Bark 服务和自建 Bark Server,可保存多个服务器并随时切换。
- **验证码自动提取**:自动识别通知中的验证码,收到即复制,也可点击复制。
- **通知优化**:
- 广告关键词过滤(内置 + 自定义)
- 持续性通知过滤(音乐、下载、通话等 ongoing)
- 内容脱敏与去重
- 微信 / QQ / Telegram 专项优化(发送者作标题、按会话分组、重要消息提级、跳转 iOS 对应 App)
- **应用级规则**:黑名单 / 白名单两种模式,可按 App 单独控制是否转发。
- **失败自动重试**:发送失败的通知进入待重发队列,由 WorkManager 定期重试,也可手动触发。
- **本地日志**:保留最近 300 条处理记录,方便排查为什么某条通知被过滤或发送失败。
- **电话来电通知**:通过轮询 `getCallState()` 实时感知来电和未接来电(绕过 MIUI 对系统电话通知的限制,需授予"电话"权限)。
- **断网短信兜底**:无网络时自动把验证码、来电用短信发到 iPhone。
- 自动识别运营商(移动/联通/电信/广电),发免费查询短信拿套餐内短信余量
- 余额 ≤5 条自动停用,下次打开 App 提醒
- 每 5 分钟最多发 1 条,避免刷屏
- 自动解析失败时回退手动额度计数
- **应用内更新**:每天自动检查 GitHub 新版本,发现新版后一键下载并通过系统安装器升级,无需手动去网页下载。
> [!WARNING] ## 快速开始
> 不要把 GotMsg 只安装在接收端。GotMsg 必须安装在“实际收到原始通知”的 Android 手机上。
### 文档里的按钮和设备怎么认 ### 1. 安装
- 写成 **设置 → Bark → 添加**,表示先打开 GotMsg,点底部“设置”,再依次点“Bark”和“添加”。 从 [Releases](../../releases) 下载最新 APK,安装到安卓手机(需 Android 14 / API 34 及以上)。
- 写成“原手机”,始终指安装 GotMsg、实际收到短信或聊天通知的 Android 手机。
- 写成“接收端”,指最终查看转发通知的 iPhone、Android、鸿蒙手机、平板或电脑。
- 文档中的 Token、Key、Secret、授权码都属于密码。可以粘贴到 GotMsg 对应输入框,不要发到群里、截图分享或填进不明网页。
## 先选择适合自己的接收方式 ### 2. 配置 Bark
第一次使用只配置一个通道,测试成功后再添加其它通道。不要一开始把所有通道都打开,否则失败时很难判断是哪一项填错。 1. 在 iOS 上安装 [Bark](https://apps.apple.com/us/app/bark-customed-push/id1613210986)。
2. 打开 Bark,复制你的专属推送地址,形如 `https://api.day.app/你的Key`。
3. 在 a2i 的「设置」中添加服务器:
- 名称:随意,例如「我的 iPhone」
- 服务器地址:`https://api.day.app`(自建则填自建地址)
- Device Key:Bark 地址中的那段 Key
| 你的接收设备 / 账号 | 建议通道 | 难度 | 开始前需要准备 | ### 3. 授权通知监听
|---|---|---:|---|
| iPhone / iPad | **Bark** | 最简单 | 接收端安装 Bark |
| 另一台 Android | **ntfy** | 简单 | 接收端安装 ntfy,并创建一个随机 Topic |
| 鸿蒙手机 | **Meow** | 取决于服务方 | 已能正常使用的 Meow 客户端、接口地址和 Device Key |
| 普通邮箱 | **SMTP 电邮** | 中等 | 邮箱已开启 SMTP,并取得授权码 / 应用专用密码 |
| Telegram | **Telegram 私聊机器人** | 中等 | Telegram 账号、Bot Token 和私人 Chat ID |
| 飞书企业成员 | **飞书企业应用机器人** | 较难 | 企业自建应用权限;通常需要企业管理员或开发者权限 |
| 钉钉企业成员 | **钉钉企业内部机器人** | 较难 | 企业内部应用权限;通常需要企业管理员或开发者权限 |
| 原手机可能长期断网 | **短信兜底** | 中等 | 原手机插有可发送短信的 SIM 卡和短信套餐 |
> [!TIP] 打开 a2i,首页会提示「需要授权通知监听」。点击「前往系统授权」,在系统的通知访问设置中开启 a2i。
> 只想尽快用起来:iPhone 选 Bark,Android 选 ntfy。飞书和钉钉不是“复制一个群机器人 Webhook”就能使用,它们需要企业内部应用;没有企业管理权限时应选其它通道。
## 需要安装哪些 App > 授权是一次性的,卸载后重新安装需要再次授权。
### 原 Android 手机上 ### 4. 开启转发
| App | 是否必须 | 用途 | 安装来源 | 回到首页,打开「通知转发」开关。此时安卓收到的通知就会按规则推送到 iOS。
|---|---:|---|---|
| **GotMsg** | 必须 | 读取、过滤和转发通知 | [本仓库 Releases](../../releases) |
| **Shizuku** | 微信回复、PIN 自动解锁、自动静默更新需要;锁屏仍隐藏验证码时可选 | 以 ADB shell 权限操作微信、锁屏、静默安装新版,并在短信通知被系统脱敏时读取对应短信原文 | [Shizuku 官网](https://shizuku.rikka.app/download/) / [GitHub Releases](https://github.com/RikkaApps/Shizuku/releases) |
QQ、支付宝、淘宝和钉钉的兼容回复依赖 Android 系统自带的**无障碍服务**,不需要另外安装无障碍 App。 ## 使用建议
### 接收端 - **只想收验证码**:进入「过滤」页,开启「验证码自动提取」,把「广告过滤」等按需开启;再到「应用」页切到白名单模式,只勾选短信类 App。
- **想同步聊天**:保持黑名单模式(默认),在「过滤」页开启「微信 / QQ / Telegram 优化」。
- **通知没推过来**:先看「日志」页,每条通知(无论转发、过滤还是失败)都会记录原因。
- **想让通知带图标**:参考下方「应用图标」一节。
- **电话通知不工作**:MIUI 会把系统电话通知限制为不向第三方通知监听器分发,本 App 通过轮询 `getCallonyState()` 绕过此限制。需要授予「电话」权限(设置 → 应用 → a2i → 权限 → 电话)。
- **断网收不到通知**:开启「设置 → 断网短信兜底」,填好 iPhone 号码、授予短信权限、设好手动额度。断网时验证码和来电会走短信(消耗套餐额度,5 分钟限 1 条)。
| 接收端 | 推荐方案 | 需要安装 | ## 应用图标
|---|---|---|
| iPhone / iPad | **Bark** | [App Store](https://apps.apple.com/app/bark-custom-notifications/id1403753865) |
| Android | **ntfy** | [Google Play](https://play.google.com/store/apps/details?id=io.heckel.ntfy) / [F-Droid](https://f-droid.org/packages/io.heckel.ntfy/) / [GitHub](https://github.com/binwiederhier/ntfy/releases) |
| iPhone(也想用 ntfy) | ntfy | [App Store](https://apps.apple.com/us/app/ntfy/id1625396347) |
| Telegram 用户 | Telegram 私聊机器人 | Telegram 客户端;机器人必须先由该用户打开并发送 `/start` |
| 飞书企业成员 | 飞书企业自建应用机器人 | 飞书客户端;应用需对该成员可见并取得发消息权限 |
| 钉钉企业成员 | 钉钉企业内部应用机器人 | 钉钉客户端;应用需启用人与机器人单聊能力 |
| 鸿蒙 / HarmonyOS | **Meow** | 从你使用的 Meow 项目或服务提供方安装;需要能取得推送接口和 Device Key / Token |
| 电脑、平板 | 电邮 | 不需要专用 App,使用现有邮件客户端即可 |
| 任意手机 | 断网短信兜底 | 不需要额外 App,使用系统短信应用接收 |
打开远程回复网页只需要普通浏览器,不需要 iOS 快捷指令,也不需要额外的 `_reply` topic。 Bark 的 `icon` 字段需要一个公网可访问的图片 URL。a2i 支持把已安装 App 的图标批量导出到你选择的目录,方便上传到你自己的图床。
--- 规则:转发时 `icon = 图标 URL 前缀 + 安卓包名 + .png`。在「设置 → 应用图标」里:
## 十分钟上手 1. 填写图标 URL 前缀,例如 `https://你的图床域名/icons/`。
2. 选择一个目录,点「导出图标」,会把所有 App 图标以 `包名.png` 的形式导出。
3. 把导出的图片上传到你的图床对应目录。
### 第 1 步:下载并安装 GotMsg 留空前缀则不显示图标。
1. 在原 Android 手机上打开 [Releases](../../releases)。 ## 自建 Bark 服务(可选)
2. 不知道手机架构时,下载文件名带 **`universal`** 的 APK,兼容性最好。
3. 大多数近年的 Android 手机是 ARM64,也可以下载 **`arm64-v8a`**,体积略小。
4. 浏览器提示“禁止安装未知应用”时,进入系统提示页,只允许当前浏览器或文件管理器完成这一次安装。
5. 安装完成后打开 GotMsg。
<details> a2i 默认用 Bark 官方服务器(`https://api.day.app`),免费够用。如果想完全自控、不限速、不依赖第三方,可以自建 Bark Server。下面是三种零成本方案:
<summary><strong>手机提示“有风险”“不允许安装”怎么办</strong></summary>
1. 确认 APK 来自本仓库 Releases,不要安装聊天群里来源不明的改包。 ### 方案 1:Vercel 云托管(最适合新手)
2. 点系统提示中的“设置”,允许当前浏览器或文件管理器“安装未知应用”。
3. 返回上一页重新点 APK 安装。
4. 安装完成后可以把刚才的“允许安装未知应用”重新关闭。
5. 如果系统提示“与现有应用签名不一致”,不要卸载覆盖。先确认安装包来源和当前 GotMsg 是否为官方版本;卸载会清除全部配置。
</details> 把 Bark 后端跑在 Vercel 的 Serverless 上,每次推送触发一个云函数,不挂进程、完全免费。
系统要求:**Android 14 / API 34 或更高版本**。低于 Android 14 无法安装。 - 平台:[Vercel](https://vercel.com) · 代码:[GitHub](https://github.com)
- 在 GitHub 搜索 `bark-server-vercel`,项目 README 里通常有 **Deploy with Vercel** 按钮,一键克隆部署
### 第 2 步:先配置一个接收通道 ### 方案 2:Cloudflare Workers(最稳定)
第一次使用最推荐: 把轻量 JS 转发代码部署到 Cloudflare 全球边缘节点,免费额度每天 10 万次请求,个人用属于「溢出」状态。
- iPhone / iPad:选 **Bark**。 - 平台:[Cloudflare](https://www.cloudflare.com)
- Android:选 **ntfy**。 - 在 GitHub 搜索 `bark-server-cloudflare` 或 `bark cloudflare worker`,在 Cloudflare 后台新建 Worker 粘贴脚本保存即可
- 鸿蒙:选 **Meow**。
- 只想在电脑查看:选 **电邮**。
进入 GotMsg 底部 **设置**,找到相应通道,先按下方教程添加配置,再点击该配置右侧的发送按钮测试。接收端收到“GotMsg 测试”才算配置成功。 ### 方案 3:免费 VPS + Docker(最高自主权)
配置页每一行右侧常用按钮含义:开关用于启停该目标;纸飞机 / 发送按钮用于测试;铅笔用于修改;垃圾桶用于删除;上下箭头用于调整顺序。“首个成功即止”只影响同一通道内的多条配置,第一次使用保持默认即可。 在永久免费的 Linux 虚拟机上用 Docker 跑官方 bark-server。
### 第 3 步:授予通知读取权限 - 官方服务端:[finab/bark-server](https://github.com/finab/bark-server)
- 反向代理 + 免费 HTTPS 证书:[Nginx Proxy Manager](https://nginxproxymanager.com)
- 免费 VPS:[Oracle Cloud 永久免费层](https://www.oracle.com/cloud/free/)、[AWS 一年免费套餐](https://aws.amazon.com/free/)
1. 回到 GotMsg **首页**。 ### ⚠️ 防坑提示
2. 点击“需要授权通知监听”或“前往系统授权”。
3. 在系统“设备和应用通知 / 通知使用权 / 通知访问”页面找到 GotMsg。
4. 打开允许开关并确认风险提示。
5. 返回 GotMsg,首页应显示 **“通知监听已开启 / 已授权”**。
这项权限是核心权限;普通“允许 GotMsg 自己弹通知”不能代替通知读取权限。 国内网络下,Vercel 的 `*.vercel.app` 和 Cloudflare 的 `*.workers.dev` 二级域名可能间歇性无法访问。**终极方案**:注册一个便宜的个人域名(或找免费域名),解析托管到 Cloudflare,然后把 Vercel / Worker 绑到**自定义域名**上——既稳定又秒达。
如果首页仍显示待授权,点 **设置 → 权限需求及说明**,下拉找到“通知使用权”,点击“去设置”再次确认。部分系统需要关闭后重新开启一次通知使用权,返回 GotMsg 后等待几秒刷新状态。 自建完成后,在「设置 → Bark 服务器」里填自建地址即可。
### 第 4 步:开启转发并实测 ## 技术栈
1. 首页打开 **通知转发**。 - Kotlin + Jetpack Compose(Material 3)
2. 用另一台手机给原手机发一条短信或聊天消息。 - AndroidX DataStore(持久化设置与日志)
3. 接收端应在几秒内收到对应通知。 - OkHttp(Bark 推送)
4. 如果没有收到,先打开 GotMsg **首页 → 查看转发日志**,日志会说明是已发送、被过滤还是发送失败。 - WorkManager(失败重试)
- Navigation Compose(单 Activity 多页面)
- 最低 Android 14(API 34),目标 Android 16(API 36)
### 第一次配置完成的判定标准 ## 本地构建
下面 5 项全部满足,才算基础配置完成: 需要 JDK 17 和 Android SDK。
- 接收通道的“GotMsg 测试”已收到。 ```bash
- 首页显示通知监听已经授权。 # 设置环境变量(按你的安装路径)
- 首页“通知转发”开关已经打开。 export JAVA_HOME=/path/to/jdk-17
- 用另一台设备制造真实短信或聊天通知后,接收端能收到转发。 export ANDROID_HOME=/path/to/android-sdk
- 锁屏原手机再测试一次,仍能收到转发。
测试时不要一直打开原聊天窗口。很多 App 在聊天页前台时不会产生系统通知,GotMsg 没有通知可读,自然不会转发。 # 构建 debug APK
./gradlew assembleDebug
### 第 5 步:设置后台运行 # 构建可分发的 release APK
./gradlew assembleRelease
```
完成基础测试后,务必按[权限和后台保活](#权限和后台保活)设置自启动、后台无限制和省电白名单,否则锁屏一段时间后可能停止转发。 产物位置:
--- - debug:`app/build/outputs/apk/debug/app-debug.apk`
- release:`app/build/outputs/apk/release/app-release.apk`
## 推送通道详细配置 安装到已连接的设备:
各通道都支持添加多条配置、单独启停、排序和单条测试。“首个成功即止”适合相同通道的备用目标;关闭后会向该通道所有已启用配置广播。不同通道之间彼此独立并行发送,只要任一已配置通道成功,整条通知就记为发送成功;没有配置 Bark 不会产生 Bark 失败记录。 ```bash
adb install -r app/build/outputs/apk/release/app-release.apk
### Bark:发送到 iPhone / iPad ```
**适合谁:** 原手机是 Android,接收端是 iPhone 或 iPad。这是 iPhone 用户最容易配置的方案。
1. 在接收端安装 [Bark](https://apps.apple.com/app/bark-custom-notifications/id1403753865)。
2. 第一次打开 Bark 时点“允许通知”。如果不允许,推送虽然到达 Bark,iPhone 通知栏也不会提醒。
3. Bark 首页会显示类似 `https://api.day.app/xxxxxxxx` 的地址,点地址旁的复制按钮。
4. 回到原 Android 手机,打开 GotMsg → **设置 → Bark → 添加**。
5. 把刚才的地址拆成“服务器地址”和“Device Key”:
| 字段 | 填写内容 |
|---|---|
| 名称 | 自定义,例如“我的 iPhone” |
| 服务器地址 | 官方服务填 `https://api.day.app`,不要带最后的 Device Key |
| Device Key | Bark 地址最后 `/` 后面的字符串 |
例如 Bark 地址是 `https://api.day.app/AbCd1234`,服务器地址填 `https://api.day.app`,Device Key 填 `AbCd1234`。
6. 点“保存”,再点这条配置右侧的发送按钮。
7. iPhone 收到“GotMsg 测试”后,再回到 GotMsg 首页打开“通知转发”,制造一条真实通知测试。
远程回复启用后,点击 Bark 通知会进入 GotMsg 回复网页。为避免重复,回复地址不再同时显示在通知正文里。
<details>
<summary><strong>Bark 测试收不到时逐项检查</strong></summary>
1. 打开 iPhone → 设置 → 通知 → Bark,确认“允许通知”已开启。
2. 检查 GotMsg 中的服务器地址只有 `https://api.day.app`,末尾不要填 `/push`,也不要重复 Device Key。
3. 检查 Device Key 前后没有空格、中文引号或漏字符。
4. 在 Bark App 内点自己的推送地址做 Bark 自带测试。自带测试也收不到时,先解决 Bark 或网络问题。
5. 暂时关闭原手机和 iPhone 上的 VPN、代理、私人 DNS后再试。
6. 打开 GotMsg → 设置 → 转发日志,查看 Bark 返回的具体错误。
</details>
### ntfy:发送到 Android / iOS / 网页
**适合谁:** 接收端是另一台 Android;也支持 iPhone、iPad和网页浏览器。
1. 在接收端安装 ntfy:Android 可用 [Google Play](https://play.google.com/store/apps/details?id=io.heckel.ntfy)、[F-Droid](https://f-droid.org/packages/io.heckel.ntfy/) 或 [GitHub Releases](https://github.com/binwiederhier/ntfy/releases);iPhone 使用 [App Store](https://apps.apple.com/us/app/ntfy/id1625396347)。
2. 第一次打开 ntfy 时允许通知,并允许它在后台运行。
3. 点“订阅 / Subscribe”或 `+`,服务器使用官方地址 `https://ntfy.sh`。
4. 自己创建一个不容易猜到的 Topic,例如 `gotmsg_7f3a9c2e`。Topic 相当于接收频道名称,不是账号,也不是手机号码。
5. 确认 ntfy 的订阅列表已经显示这个 Topic。
6. 回到原手机,打开 GotMsg → **设置 → ntfy → 添加**。
7. 按下面填写并保存:
| 字段 | 填写内容 |
|---|---|
| 名称 | 自定义,例如“随身 Android” |
| 服务器 | 与接收端完全相同,官方服务填 `https://ntfy.sh` |
| Topic | 与接收端逐字相同,区分大小写 |
| Token | 使用官方公共 Topic 时留空;只有自建 ntfy 开启鉴权后才填写 |
8. 点该配置右侧的发送按钮,接收端收到“GotMsg 测试”即成功。
> [!CAUTION]
> `ntfy.sh` 的公共 Topic 类似“知道名字就能进入的房间”。不要使用手机号、姓名、邮箱或 `test` 这类容易猜到的 Topic。需要更强隐私时请自建 ntfy 并启用访问控制。
远程回复启用后,ntfy 通知会带“回复”动作,点击通知也会进入回复页;正文不再重复附加相同链接。
<details>
<summary><strong>ntfy 测试收不到时逐项检查</strong></summary>
1. 对照两台手机,服务器和 Topic 必须完全一致,不能一台填 `ntfy.sh`、另一台填自建服务器。
2. Topic 不要带空格、斜杠或中文标点。
3. 打开接收端 ntfy,确认订阅没有暂停,系统通知权限和后台权限已开启。
4. 使用公共 `ntfy.sh` 时不要随便填写 Token;错误 Token 会导致鉴权失败。
5. 可在接收端浏览器打开 `https://ntfy.sh/你的Topic` 检查网页订阅。公共 Topic 的内容可能被知道名称的人看到。
6. 打开 GotMsg 转发日志查看 HTTP 状态或网络错误。
</details>
### Telegram:机器人私聊
**适合谁:** 希望在自己的 Telegram 私聊中接收通知。GotMsg 不支持群组、频道或群机器人。
1. 在接收端 Telegram 搜索并打开官方账号 `@BotFather`,核对账号带有认证标记。
2. 发送 `/newbot`。
3. 按提示输入机器人显示名称,例如 `我的 GotMsg`。
4. 再输入机器人用户名。用户名必须以 `bot` 结尾,例如 `my_gotmsg_notice_bot`。
5. BotFather 会返回一串 Bot Token,样式类似 `123456789:AA...`。长按复制并妥善保存。
6. 点 BotFather 消息中的机器人链接,打开你刚创建的机器人,点击“开始 / Start”或发送 `/start`。这一步不能省略,因为机器人不能主动发起首次私聊。
7. 按下面的折叠教程取得自己的私人 Chat ID。
8. 原手机打开 GotMsg → **设置 → Telegram → 添加**,填写名称、Bot Token 和私人 Chat ID。
9. 保存并发送测试。自己的 Telegram 私聊收到“GotMsg 测试”即成功。
<details>
<summary><strong>只用手机取得 Telegram 私人 Chat ID</strong></summary>
1. 确认你已经向新机器人发送过 `/start`。
2. 在手机浏览器打开无痕 / 隐私标签页。
3. 在地址栏输入下面的地址,把 `你的BotToken` 整段替换为 BotFather 给出的 Token,不要保留中文或尖括号:
`https://api.telegram.org/bot你的BotToken/getUpdates`
4. 页面会显示一段 JSON 文本。搜索或仔细查找类似下面的内容:
`"chat":{"id":123456789,"first_name":"你的名字","type":"private"}`
5. `id` 后面的正整数就是私人 Chat ID。只复制数字,不要复制逗号或引号。
6. 如果页面显示 `"result":[]`,回到 Telegram 再给机器人发一句普通文字,然后刷新网页。
7. 取得 ID 后关闭无痕标签页。Bot Token 出现在地址中,不要截图或把完整地址发给别人。
</details>
GotMsg 会拒绝负数群组 / 频道 Chat ID,也不提供群组 Topic。Bot Token 等同机器人控制凭据,不要发给他人。[Telegram Bot API](https://core.telegram.org/bots/api)
<details>
<summary><strong>Telegram 测试失败时逐项检查</strong></summary>
1. Chat ID 必须是正整数;以 `-` 开头的是群组或频道,GotMsg 会主动拒绝。
2. 确认使用的是新机器人对应的 Token,前后没有空格。
3. 确认接收用户已经打开机器人并发送 `/start`,且没有屏蔽机器人。
4. 若 BotFather 重新生成过 Token,旧 Token 会立即失效,需要在 GotMsg 中编辑更新。
5. 原手机网络需要能访问 `api.telegram.org`;所在网络无法访问 Telegram 时无法发送。
</details>
### 飞书:企业应用机器人私聊
**先确认账号条件:** 这不是飞书群里的“自定义机器人 Webhook”。你需要在自己所属企业创建“企业自建应用”,通常需要企业管理员或开发者权限。普通个人账号没有企业应用权限时无法完成,应改用 Bark、ntfy、Telegram 或电邮。
<details>
<summary><strong>飞书企业自建应用完整配置流程</strong></summary>
1. 在手机或电脑浏览器打开[飞书开放平台](https://open.feishu.cn/),登录接收通知所用的飞书企业账号。手机页面显示不完整时,在浏览器菜单中打开“桌面版网站”。
2. 进入“开发者后台”,选择“创建企业自建应用”。不要选择商店应用,也不要创建群自定义机器人。
3. 填写应用名称、描述和图标,例如名称填 `GotMsg 通知`,创建应用。
4. 打开应用左侧的 **应用能力 / 添加应用能力**,找到“机器人”并添加。
5. 打开 **权限管理**,搜索并开通“以应用身份发消息”,权限标识通常显示为 `im:message:send_as_bot`。GotMsg 只需要机器人主动向指定成员发文本,不需要配置群 Webhook。
6. 打开 **应用发布 → 版本管理与发布**,创建版本,填写版本号和说明。
7. 在“可用范围”中加入要接收通知的飞书成员。目标成员不在可用范围内时,即使 App ID 和 Secret 正确也无法收到。
8. 提交发布。如果企业要求管理员审核,需要等待管理员通过后再测试。
9. 打开 **凭证与基础信息**,复制 App ID 和 App Secret。App Secret 只会填入 GotMsg,不要发送给其他人。
10. 准备接收者标识。小白最推荐使用目标成员绑定在同一飞书企业中的工作邮箱:在 GotMsg 中把“ID 类型”选为 `email`,接收者 ID 填完整邮箱地址。没有可用企业邮箱时,请让企业管理员提供该成员的 `open_id`、`user_id` 或 `union_id`,并在 GotMsg 中选择完全对应的类型。
11. 原手机打开 GotMsg → **设置 → 飞书机器人 → 添加**,填写:
| GotMsg 字段 | 填写内容 |
|---|---|
| 名称 | 自定义,例如“我的飞书” |
| App ID | 飞书“凭证与基础信息”中的 App ID |
| App Secret | 同页的 App Secret |
| ID 类型 | 推荐 `email`;否则与管理员提供的 ID 类型一致 |
| 接收者 ID | 目标成员邮箱或对应用户 ID,只填一个人 |
12. 保存并点发送测试。目标成员与机器人私聊中收到“GotMsg 测试”即成功。
</details>
这里只调用企业应用消息接口,不接受 `chat_id`,也不支持飞书群机器人 Webhook。[飞书消息 API](https://open.feishu.cn/document/server-docs/im-v1/introduction)
<details>
<summary><strong>飞书测试失败时逐项检查</strong></summary>
1. 确认创建的是“企业自建应用”,且已经添加机器人能力,不是群自定义机器人。
2. 确认“以应用身份发消息”权限已经生效;仅在后台勾选但未发布 / 未审核时仍可能无效。
3. 确认目标成员属于同一企业,并在应用“可用范围”内。
4. ID 类型必须与内容一致:选 `email` 就填完整企业邮箱,选 `open_id` 就填 `ou_...`,不能混用。
5. App Secret 重新生成后,GotMsg 中保存的旧 Secret 会失效,需要编辑更新。
6. 打开 GotMsg 转发日志查看飞书返回的错误。鉴权失败通常检查 App ID / Secret;用户不可见通常检查可用范围和接收者 ID。
</details>
### 钉钉:企业内部机器人私聊
**先确认账号条件:** 这不是钉钉群里的“自定义机器人”。你需要创建企业内部应用,并取得应用和成员信息,通常需要组织管理员或开发者权限。个人钉钉账号没有企业应用权限时无法完成。
<details>
<summary><strong>钉钉企业内部机器人完整配置流程</strong></summary>
1. 在手机或电脑浏览器打开[钉钉开放平台](https://open.dingtalk.com/),使用接收通知所在企业的管理员 / 开发者账号登录。手机页面不完整时启用浏览器“桌面版网站”。
2. 进入应用开发后台,选择创建“企业内部应用”。不要复制群聊中的自定义机器人 Webhook。
3. 填写应用名称、描述和图标,例如名称填 `GotMsg 通知`。
4. 在应用能力中添加“机器人”能力,启用“人与机器人单聊”场景。
5. 在权限管理中搜索并申请“企业内机器人发送消息”相关权限。
6. 在应用版本 / 发布管理中创建并发布一个版本,把接收通知的成员加入应用可见或可用范围。企业要求审核时需等待管理员通过。
7. 打开应用的凭证页面,复制 **Client ID / AppKey** 和 **Client Secret / AppSecret**。
8. 取得接收成员的 **User ID / 员工 UserID**。它不是手机号、昵称或钉钉号。最稳妥的方法是让企业管理员在组织通讯录 / 成员详情中查看并提供;后台名称变化时可搜索“员工 UserID”。
9. 原手机打开 GotMsg → **设置 → 钉钉机器人 → 添加**,填写:
| GotMsg 字段 | 填写内容 |
|---|---|
| 名称 | 自定义,例如“我的钉钉” |
| Client ID / AppKey | 企业内部应用凭证页中的 AppKey |
| Client Secret / AppSecret | 同一应用的 AppSecret |
| 接收者 User ID | 目标成员的员工 UserID,只填一个人 |
10. 保存并点发送测试。目标成员与企业内部机器人的单聊收到“GotMsg 测试”即成功。
</details>
这里只调用企业内部应用机器人的单聊接口 `/v1.0/robot/oToMessages/batchSend`,不支持钉钉群自定义机器人 Webhook。[钉钉开放平台](https://open.dingtalk.com/)
<details>
<summary><strong>钉钉测试失败时逐项检查</strong></summary>
1. 确认创建的是企业内部应用机器人,而不是群设置中复制出来的 Webhook 机器人。
2. 确认机器人能力、人与机器人单聊场景和发送消息权限都已开启并随版本发布生效。
3. 确认接收成员在应用可用范围内。
4. 接收者必须填员工 UserID,不能填手机号、昵称、企业名称或群号。
5. AppKey 与 AppSecret 必须来自同一个应用;重新生成 Secret 后需要同步修改 GotMsg。
6. 打开转发日志查看错误。鉴权失败先检查 AppKey / Secret,成员无效先检查 UserID 和应用范围。
</details>
### Meow:发送到鸿蒙手机
Meow 不是 GotMsg 内置组件,需要先在鸿蒙手机上安装并完成 Meow 自身的推送注册。不同 Meow 服务的分发方式和接口格式可能不同,请以你所使用的 Meow 项目说明为准。
1. 按 Meow 项目或服务提供方的说明,在鸿蒙手机安装并注册 Meow。
2. 先使用 Meow 自带测试确认鸿蒙端能够收到消息。自带测试未成功前不要配置 GotMsg。
3. 在 Meow 中找到“推送地址 / API 地址 / 服务器地址”和“Device Key / Token”。不同版本叫法可能不同。
4. 原手机打开 GotMsg → **设置 → Meow → 添加**。
5. 名称填“我的鸿蒙”等容易识别的文字;服务器填写服务方给出的完整接口地址;Device Key 填对应设备的 Key / Token。
6. 保存后点发送测试,鸿蒙端收到“GotMsg 测试”即成功。
不要在 Device Key 中填写鸿蒙锁屏密码、华为账号密码或短信验证码。
<details>
<summary><strong>Meow 测试失败时怎么判断</strong></summary>
- Meow 自带测试也失败:问题在 Meow 注册、鸿蒙通知权限或服务方,先按 Meow 文档解决。
- Meow 自带测试成功、GotMsg 失败:重点检查是否把“完整 API 地址”误填成官网首页,以及 Device Key 是否属于当前设备。
- 服务地址要求额外参数、签名或特殊 JSON 格式时,当前 GotMsg 通用 Meow 配置可能不兼容,需要向 Meow 服务方确认接口格式。
</details>
### SMTP 电邮
GotMsg 直接连接 SMTP 服务器发送纯文本邮件。当前只支持 **SMTPS / 隐式 TLS**,通常使用端口 `465`;只提供 `587` / STARTTLS 的邮箱目前不能直接使用。邮箱需要先开启 SMTP,并生成**授权码 / 应用专用密码**,不要直接填写网页登录密码。
| 字段 | 示例 |
|---|---|
| SMTP 主机 | `smtp.qq.com`、`smtp.163.com`、`smtp.gmail.com` |
| 端口 | 填 `465`(隐式 SSL / TLS) |
| 账号 | 完整邮箱地址 |
| 授权码 / 密码 | 邮箱后台生成的 SMTP 授权码 |
| 发件人 | 通常与账号一致 |
| 收件人 | 实际接收通知的邮箱 |
完整步骤:
1. 登录用于发件的邮箱网页版或官方 App,进入账号安全设置。
2. 开启 SMTP 服务。QQ / 163 通常会生成单独的 SMTP 授权码;Gmail 通常需要先开启两步验证,再创建应用专用密码。
3. 原手机打开 GotMsg → **设置 → 电邮**,点“添加”,按上表填写。发件人通常必须与账号完全相同。
4. 保存后点发送测试,到收件箱和垃圾邮件中查找“GotMsg 测试”。
5. 测试成功后保留该配置的启用开关;每个 SMTP 配置独立控制是否参与转发。
<details>
<summary><strong>常见邮箱填写示例</strong></summary>
| 邮箱 | SMTP 主机 | 端口 | 密码栏填写 |
|---|---|---:|---|
| QQ 邮箱 | `smtp.qq.com` | `465` | QQ 邮箱设置中生成的 SMTP 授权码 |
| 163 邮箱 | `smtp.163.com` | `465` | 163 邮箱设置中生成的客户端授权密码 |
| Gmail | `smtp.gmail.com` | `465` | Google 账号两步验证下生成的应用专用密码 |
Outlook / Microsoft 365 常用 `587` + STARTTLS,当前 GotMsg 的 SMTP 发送器不支持这一连接方式,不要直接照搬 `587` 配置。
</details>
<details>
<summary><strong>邮件测试失败时逐项检查</strong></summary>
1. 确认使用授权码 / 应用专用密码,不是网页登录密码。
2. 确认端口是 `465`,服务器支持隐式 TLS。
3. 确认账号和发件人都是完整邮箱地址,且通常应保持一致。
4. 查看垃圾邮件;部分邮箱会把第一次自动通知归类为垃圾邮件。
5. “连接超时”通常是主机、端口、网络或运营商拦截;“AUTH”通常是账号或授权码错误;“FROM / TO”通常是发件人或收件人格式错误。
6. 企业邮箱还可能限制第三方 SMTP 登录,需要联系邮箱管理员。
</details>
### 断网短信兜底
该功能在原 Android 手机**没有网络**时,用原手机 SIM 卡发送短信,因此会消耗短信套餐。
1. GotMsg → **设置 → 断网短信兜底**,授予“发送短信”权限。
2. 添加接收手机号并启用。
3. 默认“仅紧要通知”只发送验证码和来电;关闭后会尝试发送全部通知。
4. 建议保持限频,例如每 5 分钟最多 1 条。
5. 开启余额检查后,余额低于或等于 5 时自动挂起;也可以使用手动短信额度。
> [!WARNING]
> 短信兜底不是普通在线转发通道。手机有网络时,通知仍走已启用的网络推送、私聊机器人或邮件通道。
---
## 远程回复详细教程
远程回复入口会自动附加到支持回复的 Bark、ntfy、Telegram、飞书、钉钉、Meow 和电邮通知中,**没有单独的“开启远程回复”总开关**。短信兜底也会在原手机成功联网注册回复会话后附带入口;原手机完全断网时无法创建一次性回复会话,因此那一条兜底短信仍然只能用于提醒。
第一次测试远程回复,建议先用 Telegram、WhatsApp、Signal 或其它通知本身带“回复”按钮的 App:
1. 确认准备接收回复入口的通道已经能够正常收到 GotMsg 测试消息。
2. 让另一位用户给原手机发一条聊天消息,保持原聊天 App 在后台,让系统产生通知。
3. 在接收端点击通知、“回复”按钮或正文中的回复链接。
4. 浏览器打开回复页后输入一句无关紧要的测试文字并提交。
5. 查看原手机聊天是否发出,并等待网页显示成功或明确错误。
原手机必须联网,GotMsg 必须仍有通知使用权并能在后台运行。划掉原手机上的原通知、强制停止 GotMsg、超过有效期或原 App 撤销回复入口后都可能无法执行。
### 回复链路
1. 原手机收到通知,注册一次性回复会话并转发到已启用通道。
2. 接收端点击通知或“回复”动作,打开 `https://r.gotmsg.pp.ua`。
3. 页面显示原通知的应用、标题和正文,输入回复内容并提交。
4. 原手机上的 GotMsg 拉取任务并执行回复。
5. 页面显示成功或具体失败原因。
链接从通知转发起 **10 分钟内有效**,并且只允许成功提交一次。Bark、ntfy、Telegram、飞书、钉钉和 Meow 使用通知点击地址、动作或按钮;电邮和短信显示可点击的纯文本链接。带回复入口的短信可能拆成两段并消耗两条短信额度。**短信 App 中识别出的验证码通知不添加回复链接**,因为验证码只需要读取或复制,不需要回复。如果其它通知既没有系统快捷回复,也不属于兼容白名单,页面会明确返回不支持,而不会盲目操作原 App。
### 三种回复方式
| 方式 | 典型 App | 原手机额外要求 | 是否打开聊天页 |
|---|---|---|---:|
| Android `RemoteInput` | Telegram、WhatsApp、Signal、部分短信和企业 IM;以该条通知实际提供的动作为准 | 无 | 否 |
| 无障碍兼容回复 | QQ、支付宝、淘宝、钉钉 | 开启 GotMsg 兼容回复无障碍服务 | 是 |
| Shizuku 回复 | 微信 | 安装并启动 Shizuku,授权 GotMsg | 是 |
同一个 App 并非所有通知都支持快捷回复:聊天通知可能支持,登录、营销、付款、订单和系统通知通常不支持。
### QQ、支付宝、淘宝、钉钉:开启无障碍兼容回复
只有通知本身没有 Android 快捷回复时才会使用这条兼容路径。它会实际打开原手机上的聊天页,因此原手机可能亮屏或切换前台应用。
1. GotMsg → **设置 → 通知回复与解锁**。
2. 打开 **无障碍兼容回复**。
3. 系统跳到无障碍页面后,找到 **GotMsg 兼容回复**。
4. 阅读系统提示并启用服务。
5. 返回 GotMsg,确认开关仍开启。
执行时 GotMsg 会打开原通知对应的聊天页,拒绝覆盖已有草稿,只点击文字明确匹配“发送”的控件。回复结束后:有 Shizuku 时切回 GotMsg;无 Shizuku 时用无障碍返回桌面,避免 QQ 等聊天页停留前台后不再弹新通知。
### 锁屏验证码显示为 `******`:优先调整系统设置
如果原手机锁屏时,短信通知只显示 `******`,可以先让系统在锁屏通知中显示完整内容。设置成功后,GotMsg 能直接从通知读取并转发真实验证码,**仅为了验证码时不需要安装 Shizuku**。
不同品牌的菜单名称会有差异,通常按下面顺序检查:
1. 系统设置 → **通知与状态栏 / 通知中心** → **锁屏通知**。
2. 选择 **显示通知和内容 / 显示所有通知内容**,关闭“隐藏敏感内容”。
3. 系统设置 → 应用 → 应用管理 → 你正在使用的**短信 App** → 通知管理。
4. 打开短信 App 的锁屏通知,并确认其锁屏显示方式也是“显示内容”。
5. 锁定手机后重新接收一条验证码短信测试;必须确认锁屏通知正文已经显示真实数字,而不是 `******`。
> [!WARNING]
> 开启后,拿到或看到原手机的人也可能直接在锁屏界面看到短信和验证码。原手机经常放在公共场所、包含银行验证码或对隐私要求较高时,建议继续隐藏锁屏内容,并使用 Shizuku 恢复;不要为了方便降低不符合自己使用场景的隐私保护。
### 微信:安装和启动 Shizuku
微信新版会屏蔽普通无障碍输入,因此微信回复必须使用 Shizuku。
<details>
<summary><strong>第一次安装、配对和授权 Shizuku</strong></summary>
1. 从 [Shizuku 官网](https://shizuku.rikka.app/download/) 或 [GitHub Releases](https://github.com/RikkaApps/Shizuku/releases) 安装 Shizuku。
2. 打开系统设置 → 关于手机,连续点击“版本号 / 系统版本”约 7 次,直到提示已经进入开发者模式。
3. 返回系统设置,搜索并打开“开发者选项”,再打开“无线调试”。原手机需要连接 Wi-Fi;这里不要求连接电脑。
4. 打开 Shizuku,选择“通过无线调试启动”→“配对”。
5. 按 Shizuku 页面提示打开系统“使用配对码配对设备”,把系统显示的 6 位配对码输入 Shizuku 通知或配对框。
6. 配对成功后回到 Shizuku,点击“启动”。首页应显示 Shizuku 正在运行。
3. 打开 GotMsg 首页,Shizuku 状态卡应显示 **“Shizuku 已就绪”**。
4. 如果显示“未授权”,点击 **授权 Shizuku**,并在 Shizuku 弹窗中允许 GotMsg。
5. GotMsg → **设置 → 通知回复与解锁**,打开“无障碍兼容回复”总开关。微信实际执行走 Shizuku;仅为了微信时可以不启用系统无障碍服务。
</details>
非 Root 方式启动的 Shizuku 在手机重启后通常需要重新启动。GotMsg 首页会直接显示当前状态。
Shizuku 也用于恢复锁屏时被系统短信 App 显示成 `******` 的验证码。GotMsg 只在检测到脱敏验证码通知时,按通知时间查找收件箱中对应的最近短信;确认能提取出真实 4–8 位验证码才转发。Shizuku 未就绪或无法确认对应短信时,该通知会直接过滤,不会把星号内容发到接收端。
### 可选:Shizuku PIN 自动解锁
适合原手机平时锁屏放置、希望收到远程回复后自动亮屏执行的场景。微信(Shizuku 输入)和 QQ(无障碍兼容回复)锁屏时收到回复,都会先自动亮屏、输入 PIN 解锁后再回复;自 v1.16.17 起 QQ 锁屏也支持自动解锁。
<details>
<summary><strong>配置锁屏 PIN 自动解锁</strong></summary>
1. 确认 Shizuku 已就绪。
2. GotMsg → **设置 → 通知回复与解锁**。
3. 输入原手机 4–16 位数字锁屏 PIN,点击“保存并启用”。
4. PIN 使用 Android Keystore AES-GCM 加密,仅保存在原手机,并排除云备份和设备迁移。
5. 每个回复任务最多提交一次 PIN;失败不会自动重试,避免触发系统锁定策略。
限制:手机重启后的第一次解锁仍必须手动完成;Shizuku 未运行时不能自动输入 PIN;图案、字母密码和生物识别不能代替这里的数字 PIN。
</details>
<details>
<summary><strong>回复页常见提示是什么意思</strong></summary>
| 页面提示 | 原因和处理 |
|---|---|
| 回复会话已过期 | 超过 10 分钟,等待一条新通知重新打开回复页 |
| 原通知已消失 | 原手机通知被划掉或 App 更新了通知,等待新消息 |
| 原手机需解锁 | 原通知要求身份验证;手动解锁,或配置 Shizuku PIN |
| 没有可用的快捷回复 | 原通知不带回复动作;支持的 App 可再启用兼容回复 |
| 无障碍兼容回复未启用 | 到“设置 → 通知回复与解锁”打开开关并授权系统无障碍 |
| Shizuku 未就绪 / 未授权 | 打开 Shizuku 启动服务,再回 GotMsg 授权 |
| 输入框已有草稿 | GotMsg 为避免覆盖文字主动停止,先在原手机处理草稿 |
</details>
完整技术说明和错误含义见 [通知远程回复文档](docs/notification-reply.md)。
---
## 权限和后台保活
### 权限用途
| 权限 / 系统服务 | 是否必须 | 用途 |
|---|---:|---|
| 通知使用权 / 通知访问 | **必须** | 读取原手机上的系统通知 |
| 网络 | **必须** | 发送到各在线通道、检查更新和远程回复 |
| GotMsg 自身通知 | 建议 | 显示状态和更新提醒;不能代替通知使用权 |
| 电话 | 使用来电功能时 | 读取电话状态 |
| 通话记录 | 使用未接来电时 | 通话结束后查找未接号码 |
| 联系人 | 希望显示联系人姓名时 | 用号码反查本地联系人 |
| 发送短信 | 使用断网兜底时 | 从原手机 SIM 卡发送短信 |
| 无障碍 | 使用 QQ 等兼容回复时 | 找输入框、写入回复、点击发送和返回桌面 |
| Shizuku | 使用微信回复、PIN 解锁、锁屏验证码恢复或自动静默更新时 | 以 ADB shell 权限执行输入、剪贴板、锁屏、静默安装,以及按通知时间读取对应短信原文 |
| 安装未知应用 | App 内更新时 | 下载完成后调起系统安装器 |
### 防止锁屏后停止转发
不同品牌名称不同,目标都是让 GotMsg 可以长期在后台运行:
1. 系统设置 → 应用 → GotMsg → 电池 / 耗电管理 → 选择**无限制 / 不优化**。
2. 在系统“自启动 / 后台启动 / 关联启动”里允许 GotMsg。
3. 最近任务界面如果支持“锁定应用”,锁定 GotMsg。
4. 不要使用一键清理、深度省电或第三方管家强制结束 GotMsg。
5. 修改权限或系统升级后,回到首页确认“通知监听已开启”。
小米 / HyperOS 常见路径:设置 → 应用设置 → 应用管理 → GotMsg → 省电策略 → 无限制,并允许自启动。三星常见路径:设置 → 电池 → 后台使用限制 → 从深度休眠应用中移除。
---
## 过滤、来电、图标和更新
### 应用黑白名单
- **黑名单模式**:默认转发所有 App,只排除你不想转发的 App;适合大多数人。
- **白名单模式**:默认不转发,只允许勾选的 App;适合只收短信验证码或少数聊天 App。
- GotMsg 启动时会先加载已保存规则;加载完成前或读取失败时按应用转发保持暂停,避免白名单配置短暂失效。
- VPN、代理和设备互联等高频通知来源默认位于黑名单中,需要时可以在应用页手动开启,不再使用无法覆盖的包名强制过滤。
### 过滤与验证码
“过滤”页可控制持续通知、验证码提取、广告过滤和特殊 App 优化。内置规则会过滤 VPN / 代理常驻通知、系统“正在运行”、近空内容和泛化“你有一条新消息”等噪音;用户可继续添加自定义广告关键词。
每条通知在 5 秒窗口内去重。微信、QQ、Telegram 会进行专门解析;验证码可自动复制,重要通知会提高推送级别。
### 来电和未接来电
MIUI / HyperOS 经常不把系统电话通知交给第三方监听器。GotMsg 会额外轮询电话状态:
- 来电时推送“有电话进来”;无障碍若能读取来电界面,可补充号码。
- 通话结束后读取通话记录,识别未接号码;有联系人权限时显示姓名。
### 应用图标
在 **设置 → 应用图标** 填写公网图标 URL 前缀,并把已安装 App 图标导出到选定目录。上传后,GotMsg 按 `URL 前缀 + 包名 + .png` 生成通知图标地址。留空则不附加自定义图标。
### 应用内更新
- **设置 → 更新 → 自动静默更新**默认开启。开启时每 3 小时检查一次;发现新版后自动选择本机 ABI,下载并通过已授权的 Shizuku 静默安装,安装完成后重新启动 GotMsg。
- 关闭“自动静默更新”后,每 6 小时检查一次;只显示更新提示,由用户在原有更新弹窗中选择安装包并手动安装。
- 两种模式都在 App 启动约 5 秒后检查,并使用 WorkManager 做后台周期兜底。Android 的省电和后台调度策略可能让实际执行时间略晚于 3 / 6 小时。
- 同时查询 Gitea 和 GitHub;同版本优先 Gitea,取两边可用的最高版本。
- 静默更新只在 Shizuku 正在运行且 GotMsg 已获授权时执行;Shizuku 不可用会安全退回普通更新提示,不会误报安装成功。两个发布源版本一致时才跨源下载兜底,APK 最终仍由 Android 校验应用签名。
- 升级弹窗只显示当前目标版本的发版说明,同时列出可用 ABI 安装包;系统会把本机适配版本排在第一位、标注“本机推荐”并默认选中,也可手动改选通用版或其它架构。
- App 自动选择当前手机 ABI,对应包不存在时使用 universal。
- 手动安装前 Android 会要求允许 GotMsg“安装未知应用”;Shizuku 静默安装不弹系统安装确认框。
- **测试版更新**:设置 → 软件更新 → 打开「测试版更新」开关后,检查更新时除正式仓库外还会查询 `gotmsg-release-test` 测试仓库,比较版本号后自动取更高版本。测试版与正式版使用相同签名,可直接覆盖升级;适合想提前试用新功能的用户。保持关闭则只检查正式版。
### GotMsg 官方公告
- GotMsg 每次回到前台时和后台定期访问项目自有的 `https://stats.gotmsg.pp.ua/v1/announcements/feed`,读取仍在有效期内且匹配当前 App 版本的官方公告。
- 公告会在原手机显示本地系统通知;打开 GotMsg 的全局转发开关后,还会直接转发到所有已启用网络通道。不经过通知监听器回收,因此不会形成自身通知循环,也不会生成回复链接。
- 本地通知和四类外部渠道分别记录发送状态:同一公告在同一渠道成功发送后不会因定时检查而重复推送;某一渠道发送失败只重试该渠道,不会重复发送已经成功的其它渠道。
- 首次使用需要允许 GotMsg 发送通知。未授权时不会把公告误记为已读,授权后会立即重新检查。
- 公告撤回只能阻止尚未获取的手机继续接收,无法远程删除已经显示在手机通知栏中的内容。
---
## 常见问题排查
| 现象 / 报错 | 处理方法 |
|---|---|
| 完全没有转发 | 首页确认“通知监听已开启”和“通知转发”已打开;再检查至少一个通道配置已启用 |
| 测试推送成功,锁屏后不再转发 | 设置电池无限制、自启动和最近任务锁定;检查系统是否撤销通知使用权 |
| 日志显示“已过滤” | 点开该条日志查看原因;检查黑白名单、广告关键词、持续通知和特殊 App 规则 |
| Bark 收不到 | 检查服务器地址不要带 Device Key;Device Key 不要漏字符;暂时关闭 VPN / 代理测试 |
| ntfy 收不到 | 接收端与 GotMsg 的服务器和 Topic 必须完全一致;确认 ntfy App 允许通知和后台运行 |
| Meow 收不到 | 先确认 Meow 自身能收测试;检查完整接口地址和 Device Key / Token |
| 邮件发送失败 | 使用 SMTP 授权码而不是网页登录密码;检查 465/SSL、发件人和网络限制 |
| 锁屏后收到的验证码变成 `******` | 优先把系统和短信 App 的锁屏通知改为“显示内容”;如果希望继续隐藏锁屏内容,再启动并授权 Shizuku。Shizuku 未安装、未运行或未授权时,GotMsg 不会转发星号正文,而会向所有已启用网络通道发送“验证码无法读取”提醒,请回到安装 GotMsg 的原手机查看 |
| 回复链接已过期 | 链接有效期 10 分钟;等待一条新通知再回复 |
| 原通知已消失 | 不要划掉原手机通知;等待新通知重新生成会话 |
| 微信提示 Shizuku 未就绪 | 打开 Shizuku 重新启动服务并授权 GotMsg;重启手机后通常需要重新启动 Shizuku |
| 微信未进入聊天页 | 确认通知点击本身能打开对应聊天,而不是联系人页、登录页或广告页 |
| QQ 找不到输入框 / 发送按钮 | 确认系统无障碍服务已开启;QQ 界面升级后可能需要重新适配,请保留 `A2iReply` 日志 |
| 原手机设置了 PIN | 配置 Shizuku PIN 自动解锁,或先手动解锁再回复;首次开机解锁不能自动完成 |
| 输入框已有草稿 | GotMsg 为避免覆盖原内容会主动停止;先在原手机处理草稿 |
| 下载后不能安装更新 | 系统设置 → 特殊应用权限 → 安装未知应用 → 允许 GotMsg |
日志页保存最近 300 条处理记录。发送失败会进入“待重发”,联网后由系统后台任务继续尝试。
---
## 界面截图 ## 界面截图
原 README 首图 `overview.png` 已移动到本节,作为多通道设置界面展示。 | 首页 | 应用管理 | 过滤设置 |
| 多通道设置 | 应用管理 | 过滤设置 |
|---|---|---| |---|---|---|
| ![多通道设置](screenshots/overview.png) | ![应用管理](screenshots/apps.png) | ![过滤设置](screenshots/filter.png) | | ![首页](screenshots/home.png) | ![应用管理](screenshots/apps.png) | ![过滤设置](screenshots/filter.png) |
| 设置总览 | 应用与支持 | 权限说明 | | 设置 | 转发日志 |
|---|---|---| |---|---|
| ![设置总览](screenshots/settings.png) | ![应用与支持](screenshots/settings-support.png) | ![权限说明](screenshots/permissions.png) | | ![设置](screenshots/settings.png) | ![转发日志](screenshots/log.png) |
| 转发日志 | ## 工作原理
|---|
| ![转发日志](screenshots/log.png) |
--- ```
Android 系统通知
## QQ 交流群 └─ NotifyListenerService 接收
└─ NotificationProcessor 处理流水线
配置、转发或远程回复遇到问题,可扫码加入 QQ 群。反馈回复问题时,建议同时提供 GotMsg 版本、手机型号、Android 版本、目标 App 版本和日志中的完整报错。 ├─ 全局开关 / ongoing 过滤 / 黑白名单
├─ 空内容、脱敏、噪音、系统状态过滤
<p align="center"> ├─ 广告关键词过滤
<img src="screenshots/qq-group.png" alt="GotMsg QQ 群二维码" width="360"> ├─ 去重(5 秒窗口)
</p> ├─ 验证码提取
└─ 微信 / QQ / Telegram 专项解析
--- └─ BarkClient 推送到 Bark Server
└─ 失败 → 待重发队列 → RetryWorker 定期重试
## 隐私与安全 └─ 写入本地日志(最近 300 条)
### 通知内容会经过哪里
- Bark、ntfy、Meow、Telegram、飞书、钉钉、SMTP 或短信服务需要接收你选择转发的通知内容;使用第三方服务前请阅读对应服务的隐私政策。
- 开启远程回复后,GotMsg 会把应用名、通知标题和正文注册到 `https://r.gotmsg.pp.ua`,用于回复页预览;回复正文也会经过该中继。
- 回复链接包含一次性能力令牌。令牌放在 URL fragment 中,首次网页请求不会把它写入服务器访问日志;不要把有效回复链接转发给别人。
- 中继保存设备鉴权哈希、通知预览、会话状态和短期回复任务;等待中的会话过期后清理,已处理状态和回复最多保留约 24 小时用于结果查询与排错。
### 技术统计
当前版本会向项目自有的 `https://stats.gotmsg.pp.ua/v1/events` 发送安装、启动和各通道成功 / 失败等技术统计,包括随机安装 ID、Android ID、会话 ID、App 版本、Android 版本、厂商型号、语言地区、时区、网络类型、通道、状态和精简失败原因。统计事件不包含通知标题、通知正文、回复正文、Bark Key、ntfy Token、邮箱授权码或锁屏 PIN。
GotMsg 会从 `https://stats.gotmsg.pp.ua/v1/announcements/feed` 获取官方公告。请求只携带固定 App ID 和当前版本号,不上传通知内容、推送密钥或手机联系人等个人数据。
### 本地敏感数据
- Bark Key、ntfy Token、SMTP 配置等保存在 Android 应用私有存储中,请保护原手机锁屏和系统账号。
- Shizuku 自动解锁 PIN 使用 Android Keystore AES-GCM 加密,且排除云备份与设备迁移;关闭功能后可在设置中删除 PIN。
- 无障碍和 Shizuku 权限能力较高,只应在你信任、由你控制的原 Android 手机上启用。
- 短信通知中的验证码被锁屏策略替换为星号时,GotMsg 会先尝试短信 App 提供的受控验证码字段;仍无法恢复时,仅在 Shizuku 已授权后按通知时间读取收件箱附近的短信,并只接受可确认的验证码原文。Shizuku 未安装、未运行或未授权时,GotMsg 会向所有已启用网络通道发送一条不含验证码及星号正文的安全提醒;Shizuku 已就绪但仍无法确认原文时直接过滤。不会另建短信数据库副本。
---
<details>
<summary><strong>高级用户:自建 Bark / ntfy</strong></summary>
### Bark Server
```bash
docker run -d --name bark --restart=unless-stopped \
-p 8080:8080 \
-v /var/lib/bark:/data \
finab/bark-server
``` ```
用 Nginx / Caddy 配置 HTTPS 后,GotMsg 的服务器地址填写站点根地址,不要追加 `/push`。详情见 [Bark 文档](https://bark.day.app/#/) 和 [Bark GitHub](https://github.com/Finb/Bark)。 ## License
### ntfy Server 本项目仅供个人使用。
```bash
docker run -d --name ntfy --restart=unless-stopped \
-p 80:80 \
-v /var/lib/ntfy:/var/lib/ntfy \
binwiederhier/ntfy serve
```
生产使用建议配置 HTTPS、缓存文件和访问控制。详情见 [ntfy 安装文档](https://docs.ntfy.sh/install/)。
</details>
## 发布与许可
- 官方网站:[gotmsg.pp.ua](https://gotmsg.pp.ua)
- 最新安装包:[Releases](../../releases)
- Android 最低版本:Android 14 / API 34
- 自 v1.10.2 起项目闭源,公开仓库保留用户文档、截图和 Release 安装包。
- 本项目仅供个人使用,**All Rights Reserved / 保留所有权利**。
+75
View File
@@ -0,0 +1,75 @@
plugins {
alias(libs.plugins.android.application)
alias(libs.plugins.kotlin.android)
alias(libs.plugins.kotlin.compose)
alias(libs.plugins.kotlin.serialization)
}
android {
namespace = "com.a2i.forwarder"
compileSdk = 36
defaultConfig {
applicationId = "com.a2i.forwarder"
minSdk = 34
targetSdk = 36
versionCode = 9
versionName = "1.5.1"
}
signingConfigs {
getByName("debug")
}
buildTypes {
debug {
isMinifyEnabled = false
}
release {
signingConfig = signingConfigs.getByName("debug")
isMinifyEnabled = false
proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro"
)
}
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
kotlinOptions {
jvmTarget = "17"
}
buildFeatures {
compose = true
}
}
dependencies {
implementation(libs.androidx.core.ktx)
implementation(libs.androidx.lifecycle.runtime.ktx)
implementation(libs.androidx.lifecycle.viewmodel.compose)
implementation(libs.androidx.lifecycle.runtime.compose)
implementation(libs.androidx.activity.compose)
implementation(platform(libs.androidx.compose.bom))
implementation(libs.androidx.ui)
implementation(libs.androidx.ui.graphics)
implementation(libs.androidx.ui.tooling.preview)
implementation(libs.androidx.material3)
implementation(libs.androidx.material.icons.extended)
implementation(libs.androidx.navigation.compose)
implementation(libs.androidx.datastore.preferences)
implementation(libs.kotlinx.serialization.json)
implementation(libs.okhttp)
implementation(libs.androidx.work.runtime.ktx)
implementation(libs.androidx.documentfile)
implementation(libs.material)
debugImplementation(libs.androidx.ui.tooling)
}
+2
View File
@@ -0,0 +1,2 @@
# Keep Bark message model fields used by kotlinx.serialization
-keepclassmembers class com.a2i.forwarder.model.** { *; }
+60
View File
@@ -0,0 +1,60 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.READ_PHONE_STATE" />
<uses-permission android:name="android.permission.SEND_SMS" />
<uses-permission android:name="android.permission.QUERY_ALL_PACKAGES"
tools:ignore="QueryAllPackagesPermission" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
<application
android:name=".A2iApp"
android:allowBackup="true"
android:dataExtractionRules="@xml/data_extraction_rules"
android:fullBackupContent="@xml/backup_rules"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true"
android:theme="@style/Theme.A2i"
tools:targetApi="31">
<activity
android:name=".MainActivity"
android:exported="true"
android:label="@string/app_name"
android:theme="@style/Theme.A2i">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
<service
android:name=".service.NotifyListenerService"
android:exported="false"
android:label="@string/app_name"
android:permission="android.permission.BIND_NOTIFICATION_LISTENER_SERVICE">
<intent-filter>
<action android:name="android.service.notification.NotificationListenerService" />
</intent-filter>
</service>
<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" />
</provider>
</application>
</manifest>
@@ -0,0 +1,57 @@
package com.a2i.forwarder
import android.app.Application
import com.a2i.forwarder.core.CarrierBalanceQuery
import com.a2i.forwarder.core.CarrierDetector
import com.a2i.forwarder.core.ConnectivityMonitor
import com.a2i.forwarder.core.LogStore
import com.a2i.forwarder.core.PhoneCallMonitor
import com.a2i.forwarder.core.SmsFallbackForwarder
import com.a2i.forwarder.core.UpdateChecker
import com.a2i.forwarder.store.AppRulesStore
import com.a2i.forwarder.store.SettingsStore
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
class A2iApp : Application() {
val appScope: CoroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
lateinit var settings: SettingsStore
private set
lateinit var appRules: AppRulesStore
private set
lateinit var logStore: LogStore
private set
lateinit var phoneCallMonitor: PhoneCallMonitor
private set
lateinit var connectivityMonitor: ConnectivityMonitor
private set
lateinit var carrierBalanceQuery: CarrierBalanceQuery
private set
lateinit var smsForwarder: SmsFallbackForwarder
private set
lateinit var updateChecker: UpdateChecker
private set
override fun onCreate() {
super.onCreate()
instance = this
settings = SettingsStore(this, appScope)
appRules = AppRulesStore(this, appScope)
logStore = LogStore(this, appScope)
phoneCallMonitor = PhoneCallMonitor(this)
phoneCallMonitor.start()
connectivityMonitor = ConnectivityMonitor(this)
connectivityMonitor.start()
carrierBalanceQuery = CarrierBalanceQuery(this)
smsForwarder = SmsFallbackForwarder(this)
updateChecker = UpdateChecker(this)
updateChecker.start()
}
companion object {
lateinit var instance: A2iApp
private set
}
}
@@ -0,0 +1,20 @@
package com.a2i.forwarder
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import com.a2i.forwarder.ui.nav.AppRoot
import com.a2i.forwarder.ui.theme.A2iTheme
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
enableEdgeToEdge()
setContent {
A2iTheme {
AppRoot()
}
}
}
}
@@ -0,0 +1,16 @@
package com.a2i.forwarder.core
object AdFilter {
val DEFAULT_KEYWORDS = listOf(
"广告", "推广", "优惠", "促销", "红包", "限时", "抽奖", "立即领取", "点击领取",
"赚取", "砍价", "秒杀", "爆款", "领券", "优惠券", "积分", "签到", "中奖",
"马上抢", "手慢无", "免费领", "赚钱", "返现", "特惠", "狂欢", "大促", "拼手速",
)
fun isAd(text: String, userKeywords: List<String>): Boolean {
val blob = text.lowercase()
for (k in DEFAULT_KEYWORDS) if (blob.contains(k.lowercase())) return true
for (k in userKeywords) if (k.isNotBlank() && blob.contains(k.lowercase())) return true
return false
}
}
@@ -0,0 +1,36 @@
package com.a2i.forwarder.core
object AppParsers {
data class Parsed(
val titleOverride: String? = null,
val groupKey: String? = null,
val clickUrl: String? = null,
val important: Boolean = false,
)
private val importantKeywords = listOf(
"@我", "@all", "@所有人", "语音", "视频", "通话", "邀请你加入", "验证码", "验证代码", "来电",
)
private val clickUrls = mapOf(
"com.tencent.mm" to "weixin://",
"com.tencent.mobileqq" to "mqq://",
"com.tencent.tim" to "mqq://",
"org.telegram.messenger" to "tg://",
"org.telegram.messenger.web" to "tg://",
"org.telegram.plus" to "tg://",
)
fun parse(packageName: String, title: String, text: String): Parsed {
val important = importantKeywords.any { text.contains(it) || title.contains(it) }
val url = clickUrls[packageName]
?: return Parsed(important = important)
val group = "$packageName:${title.ifBlank { "default" }}"
return Parsed(
titleOverride = title.takeIf { it.isNotBlank() },
groupKey = group,
clickUrl = url,
important = important,
)
}
}
@@ -0,0 +1,62 @@
package com.a2i.forwarder.core
import com.a2i.forwarder.model.BarkMessage
import com.a2i.forwarder.model.BarkServer
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.withContext
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.RequestBody.Companion.toRequestBody
import java.io.IOException
import java.util.concurrent.TimeUnit
class BarkClient(private val server: BarkServer) {
private val json = Json { encodeDefaults = false; ignoreUnknownKeys = true }
private val http = OkHttpClient.Builder()
.connectTimeout(10, TimeUnit.SECONDS)
.writeTimeout(10, TimeUnit.SECONDS)
.readTimeout(15, TimeUnit.SECONDS)
.retryOnConnectionFailure(true)
.build()
suspend fun push(msg: BarkMessage): Result<Unit> = withContext(Dispatchers.IO) {
runCatching {
require(server.deviceKey.isNotBlank()) { "device key 为空" }
doPushWithRetry(msg)
}
}
/**
* 带重试的推送:对 connection closed / 超时 / TLS 中断等瞬时 IOException 自动重试。
* OkHttp 的 retryOnConnectionFailure 不覆盖 POST 读响应阶段的连接中断,故在此手动补。
* HTTP 4xx/5xx 是服务端拒绝,重试无意义,直接抛出。
*/
private suspend fun doPushWithRetry(msg: BarkMessage) {
val body = json.encodeToString(msg).toRequestBody(JSON)
val url = server.server.trim().removeSuffix("/") + "/push"
val req = Request.Builder().url(url).post(body).build()
var lastError: IOException? = null
repeat(MAX_ATTEMPTS) { attempt ->
try {
http.newCall(req).execute().use { res ->
if (res.isSuccessful) return
error("HTTP ${res.code}")
}
} catch (e: IOException) {
lastError = e
if (attempt < MAX_ATTEMPTS - 1) delay(BACKOFF_MS.getOrElse(attempt) { 1000L })
}
}
throw lastError ?: error("推送失败")
}
companion object {
private val JSON = "application/json; charset=utf-8".toMediaType()
private const val MAX_ATTEMPTS = 3
private val BACKOFF_MS = longArrayOf(600L, 1200L)
}
}
@@ -0,0 +1,90 @@
package com.a2i.forwarder.core
import android.Manifest
import android.content.Context
import android.content.pm.PackageManager
import android.telephony.SmsManager
import androidx.core.content.ContextCompat
import com.a2i.forwarder.A2iApp
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.launch
/**
* 运营商短信余额查询:
* - refresh():向运营商服务号发免费查询短信(若运营商支持),回执由 NotificationProcessor 解析。
* - 手动模式:递减 smsManualQuota 作为余额。
* - parseReply():从运营商回执短信正文中提取剩余短信条数,供 NotificationProcessor 调用。
*/
class CarrierBalanceQuery(private val context: Context) {
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
private val settings get() = A2iApp.instance.settings
/** 触发一次余额查询(自动模式发短信,手动模式递减额度)。 */
suspend fun refresh() {
val carrier = currentCarrier()
// 自动模式:发查询短信到运营商(免费,不走限频)
if (carrier.serviceNumber != null && carrier.queryCmd != null) {
sendSms(carrier.serviceNumber, carrier.queryCmd)
return
}
// 手动模式:余额 = 手动额度,不额外查询
}
/** 转发成功后扣减余额(手动模式 / 自动解析失败时)。 */
suspend fun decrementManual() {
val q = settings.smsManualQuota.value
if (q > 0) {
val newQ = q - 1
settings.setSmsManualQuota(newQ)
settings.setSmsBalance(newQ)
checkSuspend(newQ)
}
}
/** 解析运营商回执,提取剩余短信条数。返回 null 表示解析失败。供 NotificationProcessor 调用。 */
fun parseReply(text: String): Int? {
// 匹配 "短信剩余XX条" / "短信:XX条" / "短信余量XX条" 等
val patterns = listOf(
Regex("""短信[^0-9]*(\d+)\s*条"""),
Regex("""短信余[额度][^0-9]*(\d+)"""),
)
for (p in patterns) {
val m = p.find(text) ?: continue
val n = m.groupValues.getOrNull(1)?.toIntOrNull() ?: continue
if (n in 0..9999) return n
}
return null
}
/** 回执解析成功后更新余额并检查是否需要挂起。 */
suspend fun applyParsedBalance(count: Int) {
settings.setSmsBalance(count)
checkSuspend(count)
}
private suspend fun checkSuspend(balance: Int) {
if (balance in 0..5 && !settings.smsSuspended.value) {
settings.setSmsSuspended(true)
}
}
fun currentCarrier(): CarrierDetector.Carrier {
val stored = settings.smsCarrier.value
return if (stored == "auto") CarrierDetector.detect(context)
else CarrierDetector.Carrier.fromName(stored)
}
private fun sendSms(dest: String, body: String) {
if (ContextCompat.checkSelfPermission(context, Manifest.permission.SEND_SMS)
!= PackageManager.PERMISSION_GRANTED
) return
scope.launch {
runCatching {
SmsManager.getDefault().sendTextMessage(dest, null, body, null, null)
}
}
}
}
@@ -0,0 +1,54 @@
package com.a2i.forwarder.core
import android.content.Context
import android.os.Build
import android.telephony.TelephonyManager
/**
* 运营商检测:根据 SIM 的 MCC+MNC 映射到国内运营商。
* 提供余额查询用的服务号 + 查询指令。
*/
object CarrierDetector {
enum class Carrier(
val displayName: String,
val serviceNumber: String?, // 余额查询服务号(免费)
val queryCmd: String?, // 查询指令(发送到此服务号)
) {
CHINA_MOBILE("中国移动", "10086", "CXDX"),
CHINA_UNICOM("中国联通", "10010", "CXTC"),
CHINA_TELECOM("中国电信", "10001", "108"),
CHINA_BROADCASTING("中国广电", "10099", null), // 无统一查询指令
UNKNOWN("未知运营商", null, null);
companion object {
fun fromName(name: String?): Carrier =
entries.firstOrNull { it.name == name } ?: UNKNOWN
}
}
// MCC+MNC → 运营商(中国大陆 460)
private val mncMap = mapOf(
// 中国移动
"46000" to Carrier.CHINA_MOBILE, "46002" to Carrier.CHINA_MOBILE,
"46004" to Carrier.CHINA_MOBILE, "46007" to Carrier.CHINA_MOBILE,
// 中国联通
"46001" to Carrier.CHINA_UNICOM, "46006" to Carrier.CHINA_UNICOM,
"46009" to Carrier.CHINA_UNICOM,
// 中国电信
"46003" to Carrier.CHINA_TELECOM, "46005" to Carrier.CHINA_TELECOM,
"46011" to Carrier.CHINA_TELECOM, "46012" to Carrier.CHINA_TELECOM,
// 中国广电
"46015" to Carrier.CHINA_BROADCASTING, "46018" to Carrier.CHINA_BROADCASTING,
)
fun detect(context: Context): Carrier {
return runCatching {
val tm = context.getSystemService(Context.TELEPHONY_SERVICE) as? TelephonyManager
?: return Carrier.UNKNOWN
val simOperator = tm.simOperator // 形如 "46000"
if (simOperator.isNullOrBlank() || simOperator.length < 5) return Carrier.UNKNOWN
mncMap[simOperator] ?: Carrier.UNKNOWN
}.getOrDefault(Carrier.UNKNOWN)
}
}
@@ -0,0 +1,25 @@
package com.a2i.forwarder.core
object CodeExtractor {
private val patterns = listOf(
Regex("""(?:验证码|校验码|动态码|验证代码|verification code|\bcode\b|密码|passcode|pin)\D{0,8}?(\d{4,8})""", RegexOption.IGNORE_CASE),
Regex("""(\d{4,8})\D{0,4}(?:验证码|校验码|动态码|验证代码|code)""", RegexOption.IGNORE_CASE),
Regex("""(\d{4,8})"""),
)
fun extract(text: String): String? {
// 如果文本被脱敏(全是 *****),跳过
if (text.isBlank() || isRedacted(text)) return null
for (p in patterns) {
val m = p.find(text) ?: continue
val code = m.groupValues.getOrNull(1) ?: continue
if (code.length in 4..8) return code
}
return null
}
private fun isRedacted(text: String): Boolean {
val asteriskCount = text.count { it == '*' }
return asteriskCount >= 3 && asteriskCount.toDouble() / text.length > 0.6
}
}
@@ -0,0 +1,54 @@
package com.a2i.forwarder.core
import android.content.Context
import android.net.ConnectivityManager
import android.net.Network
import android.net.NetworkCapabilities
import android.net.NetworkRequest
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
/**
* 监听网络连通性(是否能访问互联网)。
* 暴露 isOnline: StateFlow<Boolean>,断网时短信兜底转发器据此降级。
*/
class ConnectivityMonitor(private val context: Context) {
private val _isOnline = MutableStateFlow(false)
val isOnline = _isOnline.asStateFlow()
private var registered = false
private val callback = object : ConnectivityManager.NetworkCallback() {
override fun onAvailable(network: Network) { refresh() }
override fun onLost(network: Network) { refresh() }
override fun onCapabilitiesChanged(network: Network, caps: NetworkCapabilities) { refresh() }
}
fun start() {
if (registered) return
val cm = context.getSystemService(Context.CONNECTIVITY_SERVICE) as? ConnectivityManager
?: return
val request = NetworkRequest.Builder()
.addCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)
.build()
runCatching { cm.registerNetworkCallback(request, callback) }
registered = true
refresh()
}
fun stop() {
if (!registered) return
val cm = context.getSystemService(Context.CONNECTIVITY_SERVICE) as? ConnectivityManager
runCatching { cm?.unregisterNetworkCallback(callback) }
registered = false
}
private fun refresh() {
val cm = context.getSystemService(Context.CONNECTIVITY_SERVICE) as? ConnectivityManager ?: return
val active = cm.activeNetwork
val caps = active?.let { cm.getNetworkCapabilities(it) }
_isOnline.value = caps != null &&
caps.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) &&
caps.hasCapability(NetworkCapabilities.NET_CAPABILITY_VALIDATED)
}
}
@@ -0,0 +1,55 @@
package com.a2i.forwarder.core
import android.content.Context
import android.content.pm.PackageManager
import android.graphics.Bitmap
import android.graphics.Canvas
import android.graphics.drawable.BitmapDrawable
import android.graphics.drawable.Drawable
import android.net.Uri
import androidx.documentfile.provider.DocumentFile
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
class IconExporter(private val context: Context) {
data class ExportResult(val total: Int, val ok: Int, val fail: Int)
suspend fun exportAll(treeUri: Uri): ExportResult = withContext(Dispatchers.IO) {
val tree = DocumentFile.fromTreeUri(context, treeUri)
?: return@withContext ExportResult(0, 0, 0)
val pm = context.packageManager
val apps = pm.getInstalledApplications(PackageManager.GET_META_DATA)
var ok = 0
var fail = 0
for (app in apps) {
val name = app.packageName
try {
val drawable: Drawable = pm.getApplicationIcon(name)
val bmp = drawableToBitmap(drawable)
tree.findFile("$name.png")?.delete()
val file = tree.createFile("image/png", "$name.png")
if (file != null) {
context.contentResolver.openOutputStream(file.uri)?.use { out ->
bmp.compress(Bitmap.CompressFormat.PNG, 100, out)
}
ok++
} else {
fail++
}
} catch (e: Exception) {
fail++
}
}
ExportResult(apps.size, ok, fail)
}
private fun drawableToBitmap(d: Drawable): Bitmap {
if (d is BitmapDrawable && d.bitmap != null) return d.bitmap
val size = maxOf(d.intrinsicWidth, d.intrinsicHeight, 192).coerceAtLeast(1)
val bmp = Bitmap.createBitmap(size, size, Bitmap.Config.ARGB_8888)
val canvas = Canvas(bmp)
d.setBounds(0, 0, canvas.width, canvas.height)
d.draw(canvas)
return bmp
}
}
@@ -0,0 +1,101 @@
package com.a2i.forwarder.core
import android.content.Context
import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.intPreferencesKey
import androidx.datastore.preferences.core.stringPreferencesKey
import androidx.datastore.preferences.preferencesDataStore
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.launch
import kotlinx.serialization.Serializable
import kotlinx.serialization.decodeFromString
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
@Serializable
data class ForwardLog(
val time: Long,
val pkg: String,
val appLabel: String,
val title: String?,
val content: String,
val status: String, // sent / filtered / failed
val reason: String,
)
@Serializable
data class PendingPush(
val id: String,
val time: Long,
val serverId: String,
val messageJson: String,
)
private val Context.logDataStore: DataStore<Preferences> by preferencesDataStore(name = "a2i_logs")
class LogStore(private val context: Context, scope: CoroutineScope) {
private val ds get() = context.logDataStore
private val json = Json { ignoreUnknownKeys = true; encodeDefaults = true }
private object K {
val LOGS = stringPreferencesKey("logs")
val PENDING = stringPreferencesKey("pending")
val SENT = intPreferencesKey("sent")
val FILTERED = intPreferencesKey("filtered")
val FAILED = intPreferencesKey("failed")
}
val logs = MutableStateFlow<List<ForwardLog>>(emptyList())
val pending = MutableStateFlow<List<PendingPush>>(emptyList())
val sentCount = MutableStateFlow(0)
val filteredCount = MutableStateFlow(0)
val failedCount = MutableStateFlow(0)
init {
scope.launch {
ds.data.collect { p ->
logs.value = p[K.LOGS]?.let { runCatching { json.decodeFromString<List<ForwardLog>>(it) }.getOrNull() } ?: emptyList()
pending.value = p[K.PENDING]?.let { runCatching { json.decodeFromString<List<PendingPush>>(it) }.getOrNull() } ?: emptyList()
sentCount.value = p[K.SENT] ?: 0
filteredCount.value = p[K.FILTERED] ?: 0
failedCount.value = p[K.FAILED] ?: 0
}
}
}
suspend fun addLog(l: ForwardLog) {
val list = (listOf(l) + logs.value).take(300)
ds.edit { it[K.LOGS] = json.encodeToString(list) }
logs.value = list
when (l.status) {
"sent" -> ds.edit { it[K.SENT] = (sentCount.value + 1) }.also { sentCount.value += 1 }
"filtered" -> ds.edit { it[K.FILTERED] = (filteredCount.value + 1) }.also { filteredCount.value += 1 }
"failed" -> ds.edit { it[K.FAILED] = (failedCount.value + 1) }.also { failedCount.value += 1 }
}
}
suspend fun clearLogs() {
ds.edit { it.remove(K.LOGS) }
logs.value = emptyList()
}
suspend fun resetCounters() {
ds.edit { it[K.SENT] = 0; it[K.FILTERED] = 0; it[K.FAILED] = 0 }
sentCount.value = 0; filteredCount.value = 0; failedCount.value = 0
}
suspend fun addPending(p: PendingPush) {
val list = (pending.value + p).takeLast(100)
ds.edit { it[K.PENDING] = json.encodeToString(list) }
pending.value = list
}
suspend fun removePending(id: String) {
val list = pending.value.filterNot { it.id == id }
ds.edit { it[K.PENDING] = json.encodeToString(list) }
pending.value = list
}
}
@@ -0,0 +1,245 @@
package com.a2i.forwarder.core
import android.app.Notification
import android.content.Context
import android.service.notification.StatusBarNotification
import com.a2i.forwarder.A2iApp
import com.a2i.forwarder.model.BarkMessage
import java.util.LinkedHashMap
class NotificationProcessor(private val context: Context) {
private val settings get() = A2iApp.instance.settings
private val rules get() = A2iApp.instance.appRules
data class Decision(
val message: BarkMessage?,
val reason: String,
val appLabel: String,
val code: String?,
val silent: Boolean = false, // true = 静默过滤,不写日志
)
// ========== 去重缓存 ==========
// 防止同一条通知在短时间内被 Android 系统反复投递导致重复转发
// key = "${pkg}|${title}|${body}" 的 hash, value = 上次转发时间戳
private val dedupCache = object : LinkedHashMap<String, Long>(64, 0.75f, true) {
override fun removeEldestEntry(eldest: MutableMap.MutableEntry<String, Long>?): Boolean {
return size > 128
}
}
private val DEDUP_WINDOW_MS = 5_000L // 5 秒内相同内容不重复转发
// ========== 系统状态消息过滤模式 ==========
private val systemStatusPatterns = listOf(
Regex("""正在运行"""),
Regex("""is running""", RegexOption.IGNORE_CASE),
Regex("""运行中"""),
Regex("""服务(正在|已)启动"""),
Regex("""service (is )?running""", RegexOption.IGNORE_CASE),
Regex("""^.*(?:正在|is)\s*(?:运行|running|run).*$""", RegexOption.IGNORE_CASE),
)
// ========== 系统剪贴板/无用服务包名 ==========
private val noisePackages = setOf(
"com.samsung.android.honeyboard",
"com.samsung.android.app.clipboard",
"com.samsung.android.clipboard",
"com.android.systemui",
"com.google.android.inputmethod.latin",
"com.google.android.gms",
)
fun process(sbn: StatusBarNotification): Decision {
val pkg = sbn.packageName
val n = sbn.notification ?: return Decision(null, "无通知", pkg, null)
val extras = n.extras
// 提取标题和文本(尝试多个来源)
val title = extras.getCharSequence(Notification.EXTRA_TITLE)?.toString().orEmpty()
var text = extras.getCharSequence(Notification.EXTRA_TEXT)?.toString().orEmpty()
val big = extras.getCharSequence(Notification.EXTRA_BIG_TEXT)?.toString().orEmpty()
val sub = extras.getCharSequence(Notification.EXTRA_SUB_TEXT)?.toString().orEmpty()
// ---- 修复 ***** 脱敏问题 ----
// 部分 ROM 会把验证码通知的 EXTRA_TEXT 脱敏为 *****
// 尝试从 tickerText 和其他备用源获取真实内容
if (isRedacted(text)) {
// 优先用 bigText(通常未被脱敏)
if (!isRedacted(big) && big.isNotBlank()) {
text = big
} else {
// 尝试 tickerText(deprecated 但仍可能包含原文)
val ticker = n.tickerText?.toString().orEmpty()
if (!isRedacted(ticker) && ticker.isNotBlank()) {
text = ticker
}
}
}
// 确定最终 content:优先非空、非脱敏的内容
val content = listOf(text, big)
.firstOrNull { it.isNotBlank() && !isRedacted(it) }
?: text.ifBlank { big }
val appLabel = runCatching {
context.packageManager.getApplicationLabel(context.packageManager.getApplicationInfo(pkg, 0)).toString()
}.getOrDefault(pkg)
// ---- 全局开关 ----
if (!settings.forwardingEnabled.value)
return Decision(null, "全局转发已关", appLabel, null)
// ---- 持续性通知过滤(静默,不进日志)----
val isOngoing = n.flags and Notification.FLAG_ONGOING_EVENT != 0
if (settings.skipOngoing.value && isOngoing)
return Decision(null, "持续性通知", appLabel, null, silent = true)
// ---- App 黑白名单 ----
if (!rules.isForwardingOn(pkg))
return Decision(null, "此 App 已关", appLabel, null)
// ---- 空内容 / 脱敏内容过滤 ----
if (title.isBlank() && content.isBlank())
return Decision(null, "空内容", appLabel, null)
// 如果所有文本源都被脱敏了,不要转发无意义内容
if (isRedacted(content) && isRedacted(title))
return Decision(null, "内容已脱敏", appLabel, null)
// ---- 系统噪音包名过滤 ----
if (pkg in noisePackages && (content.isBlank() || content.contains("clipboard", true) || content.contains("剪贴", true)))
return Decision(null, "系统噪音通知", appLabel, null)
// ---- 系统状态消息过滤 ----
if (isSystemStatusMessage("$title $content $sub"))
return Decision(null, "系统状态消息", appLabel, null)
// ---- 近空内容过滤(纯符号、纯空格、仅 app 名等)----
val trimmedContent = content.trim()
if (trimmedContent.length < 2 && title.isBlank())
return Decision(null, "内容过短", appLabel, null)
// ---- 广告过滤 ----
if (settings.adFilterEnabled.value && AdFilter.isAd("$title $content $sub", settings.adKeywords.value))
return Decision(null, "广告过滤", appLabel, null)
// ---- 短信"发出"过滤:只保留收到的短信 ----
if (isSentSmsNotification(pkg, title, content))
return Decision(null, "已发短信(仅收不发)", appLabel, null)
// ---- 运营商余额回执拦截:解析短信余量,不转发 ----
val balanceParsed = tryParseCarrierBalance(pkg, title, content)
if (balanceParsed != null) {
return Decision(null, "运营商余额回执(${balanceParsed}条)", appLabel, null)
}
// ---- 去重检查 ----
val dedupKey = "$pkg|${title.take(80)}|${content.take(80)}"
val lastTime = dedupCache[dedupKey]
val now = System.currentTimeMillis()
if (lastTime != null && (now - lastTime) < DEDUP_WINDOW_MS) {
return Decision(null, "去重(${now - lastTime}ms 内重复)", appLabel, null)
}
dedupCache[dedupKey] = now
// ---- 验证码提取 ----
var code: String? = null
if (settings.codeEnabled.value) {
code = CodeExtractor.extract("$title $content")
}
// ---- 特殊 App 解析 ----
var msgTitle = title.ifBlank { appLabel }
var group: String = pkg
var clickUrl: String? = null
var important = false
if (settings.specialEnabled.value) {
val p = AppParsers.parse(pkg, title, content)
p.titleOverride?.let { msgTitle = it }
p.groupKey?.let { group = it }
clickUrl = p.clickUrl
important = p.important
}
if (code != null) important = true
if (code != null) {
msgTitle = title.ifBlank { "验证码" }
}
val iconPrefix = settings.iconPrefix.value.trim()
val icon = if (iconPrefix.isBlank()) null else iconPrefix.trimEnd('/') + "/" + pkg + ".png"
val level = if (important) "timeSensitive" else "active"
val msg = BarkMessage(
deviceKey = settings.currentServer.value?.deviceKey.orEmpty(),
title = msgTitle.take(100),
body = content.ifBlank { title }.take(500),
level = level,
icon = icon,
group = group,
url = clickUrl,
autoCopy = if (code != null) "1" else null,
copy = code,
isArchive = "1",
)
return Decision(msg, "转发", appLabel, code)
}
// ---- 辅助方法 ----
/** 检测文本是否被系统脱敏(全部或大部分是 * 号) */
private fun isRedacted(text: String): Boolean {
if (text.isBlank()) return false
val asteriskCount = text.count { it == '*' }
// 如果 * 占比超过 60%,认为是脱敏内容
return asteriskCount >= 3 && asteriskCount.toDouble() / text.length > 0.6
}
/** 检测是否为系统状态消息(如"短信正在运行") */
private fun isSystemStatusMessage(text: String): Boolean {
return systemStatusPatterns.any { it.containsMatchIn(text) }
}
/** 短信 App 列表 */
private val smsPackages = setOf(
"com.android.mms",
"com.google.android.apps.messaging",
"com.samsung.android.messaging",
"com.android.messaging",
)
/** 发出的短信通知关键词 */
private val sentSmsPatterns = listOf(
Regex("""发送中"""),
Regex("""正在发送"""),
Regex("""已发送"""),
Regex("""发送成功"""),
Regex("""短信已发出"""),
Regex("""消息已发出"""),
Regex("""sending""", RegexOption.IGNORE_CASE),
Regex("""message sent""", RegexOption.IGNORE_CASE),
Regex("""sent$""", RegexOption.IGNORE_CASE),
)
/** 判断是否为发出的短信通知(而非收到的) */
private fun isSentSmsNotification(pkg: String, title: String, content: String): Boolean {
if (pkg !in smsPackages) return false
val blob = "$title $content"
return sentSmsPatterns.any { it.containsMatchIn(blob) }
}
/**
* 拦截运营商余额回执短信:title 是运营商服务号(10086/10010/10001/10099),
* 解析正文中的剩余短信条数,写入设置并按"过滤"处理。
* 返回解析到的条数(已处理),返回 null 表示不是回执或解析失败。
*/
private fun tryParseCarrierBalance(pkg: String, title: String, content: String): Int? {
if (pkg !in smsPackages) return null
val serviceNumbers = setOf("10086", "10010", "10001", "10099")
if (title.trim() !in serviceNumbers) return null
val app = A2iApp.instance
val count = app.carrierBalanceQuery.parseReply("$title $content") ?: return null
kotlinx.coroutines.runBlocking { app.carrierBalanceQuery.applyParsedBalance(count) }
return count
}
}
@@ -0,0 +1,150 @@
package com.a2i.forwarder.core
import android.Manifest
import android.content.Context
import android.content.pm.PackageManager
import android.telephony.TelephonyManager
import androidx.core.content.ContextCompat
import com.a2i.forwarder.A2iApp
import com.a2i.forwarder.model.BarkMessage
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.delay
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
import java.util.UUID
/**
* 通过轮询 TelephonyManager.getCallState() 监听来电状态。
* MIUI 不会把系统电话通知分发给第三方监听器,TelephonyCallback 也不生效,
* 所以这里用最原始但最可靠的方式:每 800ms 读一次 call state。
*
* getCallState() 只是读内存中的 int,不涉及硬件操作,耗电极低。
*/
class PhoneCallMonitor(private val context: Context) {
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
private val json = Json { encodeDefaults = true; ignoreUnknownKeys = true }
private var lastState = TelephonyManager.CALL_STATE_IDLE
private var ringing = false
private var offhook = false
private var running = false
/** 上一次推送"来电"的时间,防止重复推送 */
private var lastRingTime = 0L
fun start() {
if (running) return
if (ContextCompat.checkSelfPermission(context, Manifest.permission.READ_PHONE_STATE)
!= PackageManager.PERMISSION_GRANTED
) return
running = true
lastState = TelephonyManager.CALL_STATE_IDLE
ringing = false
offhook = false
scope.launch {
val tm = context.getSystemService(Context.TELEPHONY_SERVICE) as? TelephonyManager
if (tm == null) { running = false; return@launch }
while (isActive && running) {
val state = tm.callState
if (state != lastState) {
handleTransition(lastState, state)
lastState = state
}
delay(800)
}
}
}
fun stop() {
running = false
}
private fun handleTransition(from: Int, to: Int) {
val app = A2iApp.instance
val settings = app.settings
if (!settings.forwardingEnabled.value) return
val server = settings.currentServer.value ?: return
if (server.deviceKey.isBlank()) return
when {
// IDLE → RINGING:来电
from == TelephonyManager.CALL_STATE_IDLE && to == TelephonyManager.CALL_STATE_RINGING -> {
ringing = true
offhook = false
val now = System.currentTimeMillis()
if (now - lastRingTime > 3000) {
lastRingTime = now
val msg = BarkMessage(
deviceKey = server.deviceKey,
title = "来电",
body = "有电话进来",
level = "timeSensitive",
group = "phone_call",
isArchive = "1",
)
pushToServer(server, msg, app, "来电")
}
}
// RINGING → OFFHOOK:接听
from == TelephonyManager.CALL_STATE_RINGING && to == TelephonyManager.CALL_STATE_OFFHOOK -> {
offhook = true
}
// RINGING → IDLE:未接(响了但没接)
from == TelephonyManager.CALL_STATE_RINGING && to == TelephonyManager.CALL_STATE_IDLE -> {
if (!offhook && ringing) {
val msg = BarkMessage(
deviceKey = server.deviceKey,
title = "未接来电",
body = "未接来电",
level = "timeSensitive",
group = "phone_call",
isArchive = "1",
)
pushToServer(server, msg, app, "未接来电")
}
ringing = false
offhook = false
}
// OFFHOOK → IDLE:正常挂断,不推送
from == TelephonyManager.CALL_STATE_OFFHOOK && to == TelephonyManager.CALL_STATE_IDLE -> {
ringing = false
offhook = false
}
}
}
private fun pushToServer(
server: com.a2i.forwarder.model.BarkServer,
msg: BarkMessage,
app: A2iApp,
logBody: String,
) {
scope.launch {
val result = BarkClient(server).push(msg)
val now = System.currentTimeMillis()
if (result.isSuccess) {
app.logStore.addLog(
ForwardLog(now, "phone_call", "电话", msg.title, logBody, "sent", "")
)
} else {
val reason = result.exceptionOrNull()?.message ?: "错误"
app.logStore.addLog(
ForwardLog(now, "phone_call", "电话", msg.title, logBody, "failed", reason)
)
app.logStore.addPending(
PendingPush(UUID.randomUUID().toString(), now, server.id, json.encodeToString(msg))
)
}
}
}
}
@@ -0,0 +1,52 @@
package com.a2i.forwarder.core
import android.content.Context
import androidx.work.Constraints
import androidx.work.CoroutineWorker
import androidx.work.ExistingPeriodicWorkPolicy
import androidx.work.NetworkType
import androidx.work.PeriodicWorkRequestBuilder
import androidx.work.WorkManager
import androidx.work.WorkerParameters
import com.a2i.forwarder.A2iApp
import kotlinx.serialization.decodeFromString
import kotlinx.serialization.json.Json
import java.util.concurrent.TimeUnit
class RetryWorker(ctx: Context, params: WorkerParameters) : CoroutineWorker(ctx, params) {
override suspend fun doWork(): Result {
val app = A2iApp.instance
val store = app.logStore
val settings = app.settings
val list = store.pending.value
if (list.isEmpty()) return Result.success()
var allOk = true
val json = Json { ignoreUnknownKeys = true }
for (p in list) {
val server = settings.servers.value.firstOrNull { it.id == p.serverId }
?: settings.currentServer.value
?: continue
if (server.deviceKey.isBlank()) { allOk = false; continue }
val msg = runCatching { json.decodeFromString<com.a2i.forwarder.model.BarkMessage>(p.messageJson) }.getOrNull() ?: continue
val r = BarkClient(server).push(msg)
if (r.isSuccess) store.removePending(p.id) else allOk = false
}
return if (allOk) Result.success() else Result.retry()
}
companion object {
private const val NAME = "a2i_retry"
fun schedule(context: Context) {
val req = PeriodicWorkRequestBuilder<RetryWorker>(15, TimeUnit.MINUTES)
.setConstraints(
Constraints.Builder().setRequiredNetworkType(NetworkType.CONNECTED).build()
)
.build()
WorkManager.getInstance(context)
.enqueueUniquePeriodicWork(NAME, ExistingPeriodicWorkPolicy.UPDATE, req)
}
}
}
@@ -0,0 +1,105 @@
package com.a2i.forwarder.core
import android.Manifest
import android.content.Context
import android.content.pm.PackageManager
import android.telephony.SmsManager
import androidx.core.content.ContextCompat
import com.a2i.forwarder.A2iApp
import com.a2i.forwarder.core.NotificationProcessor.Decision
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.launch
/**
* 短信兜底转发器:断网时把紧要通知(验证码、来电)用短信发到 iPhone。
* 限频:每 5 分钟最多 1 条转发短信。余额 ≤5 时自动挂起。
*/
class SmsFallbackForwarder(private val context: Context) {
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
private val RATE_LIMIT_MS = 5 * 60 * 1000L
private val app get() = A2iApp.instance
private val settings get() = app.settings
/** 判断该通知是否值得走短信(断网资源宝贵)。 */
fun isSmsWorthy(d: Decision): Boolean {
if (d.code != null) return true // 验证码
val msg = d.message ?: return false
if (msg.group == "phone_call") return true // 来电/未接来电
val t = msg.title.orEmpty()
return t.contains("来电") || t.contains("未接")
}
/** 尝试用短信转发。返回 true 表示已处理(无论成功失败)。 */
fun forward(d: Decision) {
scope.launch {
if (!settings.smsFallbackEnabled.value) return@launch
if (settings.smsSuspended.value) {
log(d, "failed", "短信已挂起(余额不足)")
return@launch
}
if (!isSmsWorthy(d)) return@launch
val target = settings.smsTargetNumber.value.trim()
if (target.isBlank()) {
log(d, "failed", "未设置短信目标号")
return@launch
}
// 限频
val now = System.currentTimeMillis()
val elapsed = now - settings.lastSmsSentTime.value
if (elapsed < RATE_LIMIT_MS) {
log(d, "filtered", "限频(${RATE_LIMIT_MS - elapsed}ms 内)")
return@launch
}
// 权限
if (ContextCompat.checkSelfPermission(context, Manifest.permission.SEND_SMS)
!= PackageManager.PERMISSION_GRANTED
) {
log(d, "failed", "无 SEND_SMS 权限")
return@launch
}
val body = compose(d).take(70)
val result = runCatching {
SmsManager.getDefault().sendTextMessage(target, null, body, null, null)
}
if (result.isSuccess) {
settings.setLastSmsSentTime(now)
log(d, "sent", "")
// 转发后刷新余额:自动模式发查询短信,手动模式递减
val carrier = app.carrierBalanceQuery.currentCarrier()
if (carrier.serviceNumber != null && carrier.queryCmd != null) {
app.carrierBalanceQuery.refresh()
} else {
app.carrierBalanceQuery.decrementManual()
}
} else {
val reason = result.exceptionOrNull()?.message ?: "短信发送失败"
log(d, "failed", reason)
}
}
}
private fun compose(d: Decision): String {
val code = d.code
return if (code != null) {
"[验证码]${d.appLabel}: $code"
} else {
val m = d.message!!
"${m.title.orEmpty().ifBlank { d.appLabel }}: ${m.body}".trim()
}
}
private fun log(d: Decision, status: String, reason: String) {
scope.launch {
val now = System.currentTimeMillis()
val title = d.code?.let { "验证码" } ?: d.message?.title ?: d.appLabel
val content = d.code ?: d.message?.body ?: ""
app.logStore.addLog(
ForwardLog(now, "sms_fallback", d.appLabel, title, content, status, reason)
)
}
}
}
@@ -0,0 +1,192 @@
package com.a2i.forwarder.core
import android.content.Context
import android.content.Intent
import android.net.Uri
import android.provider.Settings
import androidx.core.content.FileProvider
import com.a2i.forwarder.A2iApp
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import okhttp3.OkHttpClient
import okhttp3.Request
import org.json.JSONObject
import java.io.File
import java.util.concurrent.TimeUnit
data class UpdateInfo(
val latestVersion: String,
val apkUrl: String,
val apkSize: Long,
val releaseNotes: String,
val releasePageUrl: String,
)
/**
* 应用内更新:拉取 GitHub Releases 最新版 → 版本比较 → 下载 APK → 调起系统安装器。
* Android targetSdk≥24 须用 FileProvider,targetSdk≥26 安装未知来源 APK 须 REQUEST_INSTALL_PACKAGES。
*/
class UpdateChecker(private val context: Context) {
private val app get() = A2iApp.instance
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
private val http = OkHttpClient.Builder()
.connectTimeout(10, TimeUnit.SECONDS)
.readTimeout(30, TimeUnit.SECONDS)
.retryOnConnectionFailure(true)
.build()
val updateInfo = MutableStateFlow<UpdateInfo?>(null)
val downloading = MutableStateFlow(false)
val downloadProgress = MutableStateFlow(0) // 0..100
val lastError = MutableStateFlow<String?>(null)
fun start() {
scope.launch { check(force = false) }
}
suspend fun check(force: Boolean): Result<UpdateInfo?> = withContext(Dispatchers.IO) {
runCatching {
val settings = app.settings
val now = System.currentTimeMillis()
if (!force) {
// 自动检查被关闭或未满 24h:保持现状,不发请求
if (!settings.updateCheckEnabled.value) return@runCatching updateInfo.value
if (now - settings.lastUpdateCheckTime.value < CHECK_INTERVAL_MS) return@runCatching updateInfo.value
}
val req = Request.Builder()
.url(RELEASES_API)
.header("Accept", "application/vnd.github+json")
.header("User-Agent", "a2i-android")
.get()
.build()
val info = http.newCall(req).execute().use { res ->
if (!res.isSuccessful) error("HTTP ${res.code}")
val body = res.body?.string() ?: error("空响应")
parseRelease(body)
}
settings.setLastUpdateCheck(now)
val current = currentVersionName()
val available = info != null &&
isNewer(info.latestVersion, current) &&
info.latestVersion != settings.ignoredVersion.value
updateInfo.value = if (available) info else null
lastError.value = null
updateInfo.value
}.onFailure { lastError.value = it.message ?: "检查失败" }
}
private fun parseRelease(body: String): UpdateInfo? {
val json = JSONObject(body)
val tag = json.optString("tag_name").removePrefix("v").trim()
if (tag.isEmpty()) return null
val assets = json.optJSONArray("assets") ?: return null
var apkUrl = ""
var apkSize = 0L
for (i in 0 until assets.length()) {
val a = assets.optJSONObject(i) ?: continue
if (a.optString("name").endsWith(".apk", ignoreCase = true)) {
apkUrl = a.optString("browser_download_url")
apkSize = a.optLong("size")
break
}
}
if (apkUrl.isEmpty()) return null
return UpdateInfo(
latestVersion = tag,
apkUrl = apkUrl,
apkSize = apkSize,
releaseNotes = json.optString("body").orEmpty(),
releasePageUrl = json.optString("html_url").orEmpty(),
)
}
suspend fun download(info: UpdateInfo): File? = withContext(Dispatchers.IO) {
downloading.value = true
downloadProgress.value = 0
lastError.value = null
try {
val req = Request.Builder().url(info.apkUrl).get().build()
http.newCall(req).execute().use { res ->
if (!res.isSuccessful) { lastError.value = "HTTP ${res.code}"; return@use null }
val body = res.body ?: run { lastError.value = "空响应"; return@use null }
val total = body.contentLength()
val dir = context.getExternalFilesDir(null) ?: context.cacheDir
val target = File(dir, "a2i-${info.latestVersion}.apk")
target.outputStream().use { sink ->
val input = body.byteStream()
val buf = ByteArray(8 * 1024)
var read = 0L
while (true) {
val n = input.read(buf)
if (n <= 0) break
sink.write(buf, 0, n)
read += n
if (total > 0) downloadProgress.value = (read * 100 / total).toInt()
}
}
downloadProgress.value = 100
target
}
} catch (e: Exception) {
lastError.value = e.message ?: "下载失败"
null
} finally {
downloading.value = false
}
}
fun install(apk: File) {
// 国产 ROM 需用户先在系统设置开启「安装未知应用」
if (!context.packageManager.canRequestPackageInstalls()) {
val intent = Intent(Settings.ACTION_MANAGE_UNKNOWN_APP_SOURCES).apply {
data = Uri.parse("package:${context.packageName}")
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
}
runCatching { context.startActivity(intent) }
return
}
val uri = FileProvider.getUriForFile(context, "${context.packageName}.fileprovider", apk)
val intent = Intent(Intent.ACTION_VIEW).apply {
setDataAndType(uri, "application/vnd.android.package-archive")
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
}
runCatching { context.startActivity(intent) }
}
fun ignoreVersion(v: String) {
scope.launch {
app.settings.setIgnoredVersion(v)
updateInfo.value = null
}
}
private fun currentVersionName(): String {
return runCatching {
@Suppress("DEPRECATION")
context.packageManager.getPackageInfo(context.packageName, 0).versionName ?: "0"
}.getOrDefault("0")
}
/** 语义版本比较:按 "." 分段逐段比,段数不等时短的补 0。 */
fun isNewer(latest: String, current: String): Boolean {
val l = latest.split(".").map { it.substringBefore("-").toIntOrNull() ?: 0 }
val c = current.split(".").map { it.substringBefore("-").toIntOrNull() ?: 0 }
val n = maxOf(l.size, c.size)
for (i in 0 until n) {
val lv = l.getOrElse(i) { 0 }
val cv = c.getOrElse(i) { 0 }
if (lv != cv) return lv > cv
}
return false
}
companion object {
const val RELEASES_API = "https://api.github.com/repos/lsxf/a2i/releases/latest"
const val CHECK_INTERVAL_MS = 24L * 60 * 60 * 1000
}
}
@@ -0,0 +1,22 @@
package com.a2i.forwarder.model
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
@Serializable
data class BarkMessage(
@SerialName("device_key") val deviceKey: String,
val body: String,
val title: String? = null,
val subtitle: String? = null,
val level: String? = null, // active / timeSensitive / critical / passive
val badge: Int? = null,
@SerialName("autoCopy") val autoCopy: String? = null, // "1" = 收到即复制
val copy: String? = null, // 复制内容
val sound: String? = null,
val icon: String? = null, // 公网图片 URL
val group: String? = null,
val url: String? = null, // 点击跳转
@SerialName("isArchive") val isArchive: String? = null, // "1" = 归档
val action: String? = null,
)
@@ -0,0 +1,14 @@
package com.a2i.forwarder.model
import kotlinx.serialization.Serializable
import java.util.UUID
@Serializable
data class BarkServer(
val id: String = UUID.randomUUID().toString(),
val name: String,
val server: String,
val deviceKey: String,
)
const val OFFICIAL_BARK_SERVER = "https://api.day.app"
@@ -0,0 +1,78 @@
package com.a2i.forwarder.service
import android.service.notification.NotificationListenerService
import android.service.notification.StatusBarNotification
import com.a2i.forwarder.A2iApp
import com.a2i.forwarder.core.BarkClient
import com.a2i.forwarder.core.ForwardLog
import com.a2i.forwarder.core.NotificationProcessor
import com.a2i.forwarder.core.PendingPush
import com.a2i.forwarder.core.RetryWorker
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.launch
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
import java.util.UUID
class NotifyListenerService : NotificationListenerService() {
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
private val processor by lazy { NotificationProcessor(applicationContext) }
private val json = Json { encodeDefaults = true; ignoreUnknownKeys = true }
override fun onListenerConnected() {
super.onListenerConnected()
RetryWorker.schedule(applicationContext)
}
override fun onNotificationPosted(sbn: StatusBarNotification?) {
val posted = sbn ?: return
if (posted.packageName == packageName) return
scope.launch { handle(posted) }
}
private suspend fun handle(sbn: StatusBarNotification) {
val app = A2iApp.instance
val now = System.currentTimeMillis()
val d = processor.process(sbn)
val msg = d.message
if (msg == null) {
// 静默过滤(如持续性通知)不写日志
if (!d.silent) {
app.logStore.addLog(ForwardLog(now, sbn.packageName, d.appLabel, "", "", "filtered", d.reason))
}
return
}
// ---- 断网兜底:离线时紧要通知走短信,其余不转发避免离线堆积 ----
val online = app.connectivityMonitor.isOnline.value
if (!online) {
if (app.smsForwarder.isSmsWorthy(d)) {
app.smsForwarder.forward(d)
} else {
app.logStore.addLog(ForwardLog(now, sbn.packageName, d.appLabel, msg.title, msg.body, "filtered", "离线非紧要通知"))
}
return
}
val server = app.settings.currentServer.value
if (server == null || server.deviceKey.isBlank()) {
app.logStore.addLog(ForwardLog(now, sbn.packageName, d.appLabel, msg.title, msg.body, "failed", "未配置 Bark key"))
return
}
val result = BarkClient(server).push(msg)
if (result.isSuccess) {
app.logStore.addLog(ForwardLog(now, sbn.packageName, d.appLabel, msg.title, msg.body, "sent", ""))
} else {
val reason = result.exceptionOrNull()?.message ?: "错误"
app.logStore.addLog(ForwardLog(now, sbn.packageName, d.appLabel, msg.title, msg.body, "failed", reason))
app.logStore.addPending(
PendingPush(UUID.randomUUID().toString(), now, server.id, json.encodeToString(msg))
)
}
}
}
@@ -0,0 +1,127 @@
package com.a2i.forwarder.store
import android.content.Context
import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.intPreferencesKey
import androidx.datastore.preferences.core.stringPreferencesKey
import androidx.datastore.preferences.core.stringSetPreferencesKey
import androidx.datastore.preferences.preferencesDataStore
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.launch
private val Context.rulesDataStore: DataStore<Preferences> by preferencesDataStore(name = "a2i_rules")
enum class RuleMode { BLACKLIST, WHITELIST }
class AppRulesStore(
private val context: Context,
scope: CoroutineScope,
) {
private val ds get() = context.rulesDataStore
private object K {
val MODE = stringPreferencesKey("mode")
val BLACK = stringSetPreferencesKey("blacklist")
val WHITE = stringSetPreferencesKey("whitelist")
val BL_VERSION = intPreferencesKey("bl_version")
}
val mode = MutableStateFlow(RuleMode.BLACKLIST)
val blacklist = MutableStateFlow<Set<String>>(defaultBlacklist)
val whitelist = MutableStateFlow<Set<String>>(emptySet())
init {
scope.launch {
ds.data.collect { p ->
mode.value = runCatching { RuleMode.valueOf(p[K.MODE] ?: "BLACKLIST") }.getOrDefault(RuleMode.BLACKLIST)
// 默认黑名单版本迁移:把新增的噪音包补进老用户的黑名单(只增不删)
val ver = p[K.BL_VERSION] ?: 1
var black = p[K.BLACK] ?: defaultBlacklist
if (ver < DEFAULT_BL_VERSION) {
black = black + blAdded
ds.edit { it[K.BLACK] = black; it[K.BL_VERSION] = DEFAULT_BL_VERSION }
}
blacklist.value = black
whitelist.value = p[K.WHITE] ?: emptySet()
}
}
}
fun isForwardingOn(packageName: String): Boolean = when (mode.value) {
RuleMode.BLACKLIST -> packageName !in blacklist.value
RuleMode.WHITELIST -> packageName in whitelist.value
}
suspend fun setMode(m: RuleMode) {
ds.edit { it[K.MODE] = m.name }
mode.value = m
}
suspend fun setForwarding(packageName: String, on: Boolean) {
when (mode.value) {
RuleMode.BLACKLIST -> {
val s = blacklist.value.toMutableSet()
if (on) s.remove(packageName) else s.add(packageName)
ds.edit { it[K.BLACK] = s }
blacklist.value = s
}
RuleMode.WHITELIST -> {
val s = whitelist.value.toMutableSet()
if (on) s.add(packageName) else s.remove(packageName)
ds.edit { it[K.WHITE] = s }
whitelist.value = s
}
}
}
companion object {
private const val DEFAULT_BL_VERSION = 3
// 默认黑名单增量(累积,用于老用户版本迁移,只补不删)
private val blAdded = setOf(
// v2
"com.android.soundrecorder", // 录音机
"com.google.android.apps.photos", // 相册
"com.xiaomi.finddevice", // 查找手机
"com.miui.passwords", // 密码管理
// v3:通话过程中的系统状态通知,会与来电(PhoneCallMonitor)重复
"com.android.server.telecom", // 通话管理
"com.xiaomi.aiasst.service", // AI通话
)
val defaultBlacklist = setOf(
// Android 系统
"com.android.systemui",
"com.android.settings",
"com.android.permissioncontroller",
// 注意:com.android.phone / com.android.incallui 不在黑名单中,电话通知需要转发
// 输入法
"com.google.android.inputmethod",
"com.google.android.inputmethod.latin",
"com.iflytek.inputmethod",
"com.baidu.input",
"com.sohu.inputmethod.sogou",
// 三星剪贴板 / 键盘(经常弹出 UniversalClipboardService 等空通知)
"com.samsung.android.honeyboard",
"com.samsung.android.app.clipboard",
"com.samsung.android.clipboard",
"com.samsung.android.rubin.app",
// Google Play Services(经常发内部状态通知)
"com.google.android.gms",
"com.google.android.gms.persistent",
// a2i 自身
"com.a2i.forwarder",
// [v2] 纯噪音 App:转发无意义,默认屏蔽
"com.android.soundrecorder", // 录音机
"com.google.android.apps.photos", // 相册
"com.xiaomi.finddevice", // 查找手机
"com.miui.passwords", // 密码管理
// [v3] 通话状态噪音:来电由 PhoneCallMonitor 独立处理,这两个会重复
"com.android.server.telecom", // 通话管理
"com.xiaomi.aiasst.service", // AI通话
)
}
}
@@ -0,0 +1,189 @@
package com.a2i.forwarder.store
import android.content.Context
import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.booleanPreferencesKey
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.intPreferencesKey
import androidx.datastore.preferences.core.longPreferencesKey
import androidx.datastore.preferences.core.stringPreferencesKey
import androidx.datastore.preferences.preferencesDataStore
import com.a2i.forwarder.model.BarkServer
import com.a2i.forwarder.model.OFFICIAL_BARK_SERVER
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
import kotlinx.serialization.decodeFromString
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
private val Context.settingsDataStore: DataStore<Preferences> by preferencesDataStore(name = "a2i_settings")
class SettingsStore(
private val context: Context,
scope: CoroutineScope,
) {
private val ds get() = context.settingsDataStore
private val json = Json { ignoreUnknownKeys = true; encodeDefaults = true }
private object K {
val SERVERS = stringPreferencesKey("servers")
val CURRENT = stringPreferencesKey("current")
val ICON_PREFIX = stringPreferencesKey("icon_prefix")
val FWD = booleanPreferencesKey("forwarding")
val CODE = booleanPreferencesKey("code")
val AD = booleanPreferencesKey("ad_filter")
val SPECIAL = booleanPreferencesKey("special")
val SKIP_ONGOING = booleanPreferencesKey("skip_ongoing")
val AD_KW = stringPreferencesKey("ad_keywords")
// 短信兜底
val SMS_FALLBACK = booleanPreferencesKey("sms_fallback")
val SMS_TARGET = stringPreferencesKey("sms_target")
val SMS_CARRIER = stringPreferencesKey("sms_carrier")
val SMS_QUOTA = intPreferencesKey("sms_quota")
val SMS_BALANCE = intPreferencesKey("sms_balance")
val SMS_SUSPENDED = booleanPreferencesKey("sms_suspended")
val SMS_LAST_SENT = longPreferencesKey("sms_last_sent")
// 应用更新
val UPDATE_CHECK_AUTO = booleanPreferencesKey("update_check_auto")
val LAST_UPDATE_CHECK = longPreferencesKey("last_update_check")
val IGNORED_VERSION = stringPreferencesKey("ignored_version")
}
private val defaultServers = listOf(
BarkServer(id = ID_OFFICIAL, name = "Bark 官方", server = OFFICIAL_BARK_SERVER, deviceKey = "")
)
private val _servers = MutableStateFlow(defaultServers)
val servers = _servers.asStateFlow()
private val _currentServerId = MutableStateFlow(ID_OFFICIAL)
val currentServer = MutableStateFlow<BarkServer?>(defaultServers.first())
val iconPrefix = MutableStateFlow(DEFAULT_ICON_PREFIX)
val forwardingEnabled = MutableStateFlow(true)
val codeEnabled = MutableStateFlow(true)
val adFilterEnabled = MutableStateFlow(true)
val specialEnabled = MutableStateFlow(true)
val skipOngoing = MutableStateFlow(true)
val adKeywords = MutableStateFlow<List<String>>(emptyList()) // 用户自定义关键词
// 短信兜底
val smsFallbackEnabled = MutableStateFlow(false)
val smsTargetNumber = MutableStateFlow("")
val smsCarrier = MutableStateFlow("auto") // "auto" 或 Carrier.name
val smsManualQuota = MutableStateFlow(0) // 手动额度(自动解析失败时回退用)
val smsBalance = MutableStateFlow(-1) // 缓存余额,-1=未知
val smsSuspended = MutableStateFlow(false) // 余额 ≤5 自动挂起
val lastSmsSentTime = MutableStateFlow(0L) // 限频时间戳
// 应用更新
val updateCheckEnabled = MutableStateFlow(true) // 自动检查更新开关
val lastUpdateCheckTime = MutableStateFlow(0L) // 上次检查时间戳
val ignoredVersion = MutableStateFlow("") // 用户忽略的版本号
init {
scope.launch {
ds.data.collect { p ->
val list: List<BarkServer> = p[K.SERVERS]
?.let { runCatching { json.decodeFromString<List<BarkServer>>(it) }.getOrNull() }
?: defaultServers
_servers.value = list
_currentServerId.value = p[K.CURRENT] ?: ID_OFFICIAL
currentServer.value = list.firstOrNull { it.id == _currentServerId.value } ?: list.firstOrNull()
iconPrefix.value = p[K.ICON_PREFIX] ?: DEFAULT_ICON_PREFIX
forwardingEnabled.value = p[K.FWD] ?: true
codeEnabled.value = p[K.CODE] ?: true
adFilterEnabled.value = p[K.AD] ?: true
specialEnabled.value = p[K.SPECIAL] ?: true
skipOngoing.value = p[K.SKIP_ONGOING] ?: true
adKeywords.value = p[K.AD_KW]
?.let { runCatching { json.decodeFromString<List<String>>(it) }.getOrNull() }
?: emptyList()
// 短信兜底
smsFallbackEnabled.value = p[K.SMS_FALLBACK] ?: false
smsTargetNumber.value = p[K.SMS_TARGET] ?: ""
smsCarrier.value = p[K.SMS_CARRIER] ?: "auto"
smsManualQuota.value = p[K.SMS_QUOTA] ?: 0
smsBalance.value = p[K.SMS_BALANCE] ?: -1
smsSuspended.value = p[K.SMS_SUSPENDED] ?: false
lastSmsSentTime.value = p[K.SMS_LAST_SENT] ?: 0L
// 应用更新
updateCheckEnabled.value = p[K.UPDATE_CHECK_AUTO] ?: true
lastUpdateCheckTime.value = p[K.LAST_UPDATE_CHECK] ?: 0L
ignoredVersion.value = p[K.IGNORED_VERSION] ?: ""
}
}
}
private suspend fun edit(block: (androidx.datastore.preferences.core.MutablePreferences) -> Unit) =
ds.edit(block)
private suspend fun saveServers(list: List<BarkServer>) {
edit { it[K.SERVERS] = json.encodeToString(list) }
_servers.value = list
refreshCurrent()
}
suspend fun addServer(name: String, server: String, key: String): BarkServer {
val s = BarkServer(name = name.trim(), server = server.trim().removeSuffix("/"), deviceKey = key.trim())
saveServers(_servers.value + s)
if (_servers.value.count { it.id == s.id } == 1 && currentServer.value?.deviceKey.isNullOrBlank()) {
setCurrent(s.id)
}
return s
}
suspend fun updateServer(s: BarkServer) = saveServers(_servers.value.map { if (it.id == s.id) s.copy(server = s.server.trim().removeSuffix("/")) else it })
suspend fun deleteServer(id: String) {
val list = _servers.value.filterNot { it.id == id }
saveServers(list)
if (_currentServerId.value == id) {
setCurrent(if (list.isEmpty()) "" else list.first().id)
}
}
suspend fun setCurrent(id: String) {
edit { it[K.CURRENT] = id }
_currentServerId.value = id
refreshCurrent()
}
private fun refreshCurrent() {
currentServer.value = _servers.value.firstOrNull { it.id == _currentServerId.value } ?: _servers.value.firstOrNull()
}
suspend fun setIconPrefix(v: String) { edit { it[K.ICON_PREFIX] = v }; iconPrefix.value = v }
suspend fun setForwarding(v: Boolean) { edit { it[K.FWD] = v }; forwardingEnabled.value = v }
suspend fun setCode(v: Boolean) { edit { it[K.CODE] = v }; codeEnabled.value = v }
suspend fun setAdFilter(v: Boolean) { edit { it[K.AD] = v }; adFilterEnabled.value = v }
suspend fun setSpecial(v: Boolean) { edit { it[K.SPECIAL] = v }; specialEnabled.value = v }
suspend fun setSkipOngoing(v: Boolean) { edit { it[K.SKIP_ONGOING] = v }; skipOngoing.value = v }
suspend fun setAdKeywords(list: List<String>) {
edit { it[K.AD_KW] = json.encodeToString(list) }
adKeywords.value = list
}
suspend fun setSmsFallback(v: Boolean) { edit { it[K.SMS_FALLBACK] = v }; smsFallbackEnabled.value = v }
suspend fun setSmsTargetNumber(v: String) { edit { it[K.SMS_TARGET] = v }; smsTargetNumber.value = v }
suspend fun setSmsCarrier(v: String) { edit { it[K.SMS_CARRIER] = v }; smsCarrier.value = v }
suspend fun setSmsManualQuota(v: Int) { edit { it[K.SMS_QUOTA] = v }; smsManualQuota.value = v }
suspend fun setSmsBalance(v: Int) { edit { it[K.SMS_BALANCE] = v }; smsBalance.value = v }
suspend fun setSmsSuspended(v: Boolean) { edit { it[K.SMS_SUSPENDED] = v }; smsSuspended.value = v }
suspend fun setLastSmsSentTime(v: Long) { edit { it[K.SMS_LAST_SENT] = v }; lastSmsSentTime.value = v }
suspend fun setUpdateCheckEnabled(v: Boolean) { edit { it[K.UPDATE_CHECK_AUTO] = v }; updateCheckEnabled.value = v }
suspend fun setLastUpdateCheck(v: Long) { edit { it[K.LAST_UPDATE_CHECK] = v }; lastUpdateCheckTime.value = v }
suspend fun setIgnoredVersion(v: String) { edit { it[K.IGNORED_VERSION] = v }; ignoredVersion.value = v }
suspend fun snapshot() = ds.data.first()
companion object {
const val ID_OFFICIAL = "official"
const val DEFAULT_ICON_PREFIX = "https://raw.githubusercontent.com/lsxf/a2i/main/icons/"
}
}
@@ -0,0 +1,247 @@
package com.a2i.forwarder.ui
import android.content.Context
import android.graphics.Bitmap
import android.graphics.Canvas
import android.graphics.drawable.BitmapDrawable
import android.graphics.drawable.Drawable
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ChevronRight
import androidx.compose.material.icons.outlined.Circle
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Switch
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.ImageBitmap
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.core.app.NotificationManagerCompat
@Composable
fun ScreenTitle(
text: String,
subtitle: String? = null,
modifier: Modifier = Modifier,
) {
Column(modifier.padding(top = 18.dp, bottom = 14.dp)) {
Text(
text,
style = MaterialTheme.typography.headlineMedium,
color = MaterialTheme.colorScheme.onBackground,
)
if (!subtitle.isNullOrBlank()) {
Text(
subtitle,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(top = 4.dp),
)
}
}
}
@Composable
fun SectionCard(
title: String? = null,
modifier: Modifier = Modifier,
subtitle: String? = null,
content: @Composable ColumnScope.() -> Unit,
) {
Card(
modifier = modifier.fillMaxWidth(),
shape = RoundedCornerShape(8.dp),
border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant),
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface),
elevation = CardDefaults.cardElevation(defaultElevation = 0.dp),
) {
Column(Modifier.padding(16.dp)) {
if (title != null) {
Text(
title,
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold,
modifier = Modifier.padding(bottom = if (subtitle == null) 10.dp else 2.dp),
)
if (subtitle != null) {
Text(
subtitle,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(bottom = 10.dp),
)
}
}
content()
}
}
}
@Composable
fun SwitchRow(
title: String,
subtitle: String? = null,
checked: Boolean,
icon: ImageVector? = null,
tint: Color = MaterialTheme.colorScheme.primary,
onChange: (Boolean) -> Unit,
) {
Row(
Modifier
.fillMaxWidth()
.heightIn(min = 56.dp)
.padding(vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
if (icon != null) {
IconBox(icon = icon, tint = tint)
Spacer(Modifier.width(12.dp))
}
Column(Modifier.weight(1f)) {
Text(title, style = MaterialTheme.typography.bodyLarge, fontWeight = FontWeight.Medium)
if (!subtitle.isNullOrBlank()) {
Text(
subtitle,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
Spacer(Modifier.width(12.dp))
Switch(checked = checked, onCheckedChange = onChange)
}
}
@Composable
fun ClickRow(
title: String,
subtitle: String? = null,
icon: ImageVector? = null,
tint: Color = MaterialTheme.colorScheme.primary,
onClick: () -> Unit,
) {
Row(
Modifier
.fillMaxWidth()
.clickable(onClick = onClick)
.heightIn(min = 56.dp)
.padding(vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
if (icon != null) {
IconBox(icon = icon, tint = tint)
Spacer(Modifier.width(12.dp))
}
Column(Modifier.weight(1f)) {
Text(title, style = MaterialTheme.typography.bodyLarge, fontWeight = FontWeight.Medium)
if (!subtitle.isNullOrBlank()) {
Text(
subtitle,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
Icon(Icons.Filled.ChevronRight, contentDescription = null, tint = MaterialTheme.colorScheme.onSurfaceVariant)
}
}
@Composable
fun IconBox(
icon: ImageVector = Icons.Outlined.Circle,
tint: Color = MaterialTheme.colorScheme.primary,
modifier: Modifier = Modifier,
) {
Box(
modifier = modifier
.size(38.dp)
.background(tint.copy(alpha = 0.12f), RoundedCornerShape(8.dp)),
contentAlignment = Alignment.Center,
) {
Icon(icon, contentDescription = null, tint = tint, modifier = Modifier.size(20.dp))
}
}
@Composable
fun StatusBadge(
text: String,
tint: Color,
modifier: Modifier = Modifier,
) {
Surface(
modifier = modifier,
shape = RoundedCornerShape(6.dp),
color = tint.copy(alpha = 0.12f),
contentColor = tint,
) {
Text(
text,
style = MaterialTheme.typography.labelMedium,
modifier = Modifier.padding(horizontal = 9.dp, vertical = 5.dp),
)
}
}
@Composable
fun MetricBlock(
label: String,
value: String,
tint: Color,
modifier: Modifier = Modifier,
) {
Surface(
modifier = modifier,
shape = RoundedCornerShape(8.dp),
color = tint.copy(alpha = 0.10f),
border = BorderStroke(1.dp, tint.copy(alpha = 0.18f)),
) {
Column(Modifier.padding(12.dp)) {
Text(value, style = MaterialTheme.typography.titleLarge, color = tint, fontWeight = FontWeight.Bold)
Text(label, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
}
}
}
fun isNotificationListenerEnabled(context: Context): Boolean {
return NotificationManagerCompat.getEnabledListenerPackages(context).contains(context.packageName)
}
/** Reads the app versionName and returns fallback if PackageManager lookup fails. */
fun appVersionName(context: Context, fallback: String = "unknown"): String {
return runCatching {
@Suppress("DEPRECATION")
context.packageManager.getPackageInfo(context.packageName, 0).versionName ?: fallback
}.getOrDefault(fallback)
}
fun drawableToImageBitmap(d: Drawable): ImageBitmap {
if (d is BitmapDrawable && d.bitmap != null) return d.bitmap.asImageBitmap()
val size = maxOf(d.intrinsicWidth, d.intrinsicHeight, 96).coerceAtLeast(1)
val bmp = Bitmap.createBitmap(size, size, Bitmap.Config.ARGB_8888)
val canvas = Canvas(bmp)
d.setBounds(0, 0, canvas.width, canvas.height)
d.draw(canvas)
return bmp.asImageBitmap()
}
@@ -0,0 +1,99 @@
package com.a2i.forwarder.ui.nav
import androidx.compose.foundation.layout.padding
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.Apps
import androidx.compose.material.icons.outlined.FilterAlt
import androidx.compose.material.icons.outlined.Notifications
import androidx.compose.material.icons.outlined.Settings
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.NavigationBar
import androidx.compose.material3.NavigationBarItem
import androidx.compose.material3.NavigationBarItemDefaults
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.unit.dp
import androidx.navigation.NavGraph.Companion.findStartDestination
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
import androidx.navigation.compose.currentBackStackEntryAsState
import androidx.navigation.compose.rememberNavController
import com.a2i.forwarder.ui.screens.AppManageScreen
import com.a2i.forwarder.ui.screens.FilterScreen
import com.a2i.forwarder.ui.screens.HomeScreen
import com.a2i.forwarder.ui.screens.LogScreen
import com.a2i.forwarder.ui.screens.SettingsScreen
private sealed class Tab(val route: String, val label: String, val icon: ImageVector) {
object Home : Tab("home", "首页", Icons.Outlined.Notifications)
object Apps : Tab("apps", "应用", Icons.Outlined.Apps)
object Filter : Tab("filter", "过滤", Icons.Outlined.FilterAlt)
object Settings : Tab("settings", "设置", Icons.Outlined.Settings)
}
private val tabs = listOf(Tab.Home, Tab.Apps, Tab.Filter, Tab.Settings)
private val tabRoutes = tabs.map { it.route }.toSet()
@Composable
fun AppRoot() {
val nav = rememberNavController()
val backStack by nav.currentBackStackEntryAsState()
val current = backStack?.destination?.route
val showBar = current in tabRoutes
Scaffold(
containerColor = MaterialTheme.colorScheme.background,
bottomBar = {
if (showBar) {
NavigationBar(
containerColor = MaterialTheme.colorScheme.surface,
tonalElevation = 0.dp,
) {
tabs.forEach { t ->
NavigationBarItem(
selected = current == t.route,
onClick = {
nav.navigate(t.route) {
popUpTo(nav.graph.findStartDestination().id) { saveState = true }
launchSingleTop = true
restoreState = true
}
},
icon = { Icon(t.icon, t.label) },
label = { Text(t.label) },
colors = NavigationBarItemDefaults.colors(
selectedIconColor = MaterialTheme.colorScheme.primary,
selectedTextColor = MaterialTheme.colorScheme.primary,
indicatorColor = MaterialTheme.colorScheme.primaryContainer,
unselectedIconColor = MaterialTheme.colorScheme.onSurfaceVariant,
unselectedTextColor = MaterialTheme.colorScheme.onSurfaceVariant,
),
)
}
}
}
},
) { inner ->
NavHost(
navController = nav,
startDestination = Tab.Home.route,
modifier = Modifier.padding(inner),
) {
composable(Tab.Home.route) {
HomeScreen(
onOpenLog = { nav.navigate("log") },
onOpenSettings = { nav.navigate(Tab.Settings.route) },
)
}
composable(Tab.Apps.route) { AppManageScreen() }
composable(Tab.Filter.route) { FilterScreen() }
composable(Tab.Settings.route) { SettingsScreen(onOpenLog = { nav.navigate("log") }) }
composable("log") { LogScreen(onBack = { nav.popBackStack() }) }
}
}
}
@@ -0,0 +1,222 @@
package com.a2i.forwarder.ui.screens
import android.content.Context
import android.content.pm.ApplicationInfo
import android.content.pm.PackageManager
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Search
import androidx.compose.material.icons.filled.Tune
import androidx.compose.material.icons.outlined.Apps
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.FilterChip
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Surface
import androidx.compose.material3.Switch
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.ImageBitmap
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import com.a2i.forwarder.A2iApp
import com.a2i.forwarder.store.RuleMode
import com.a2i.forwarder.ui.IconBox
import com.a2i.forwarder.ui.ScreenTitle
import com.a2i.forwarder.ui.SectionCard
import com.a2i.forwarder.ui.StatusBadge
import com.a2i.forwarder.ui.SwitchRow
import com.a2i.forwarder.ui.drawableToImageBitmap
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
private data class AppInfo(val pkg: String, val label: String, val icon: ImageBitmap?, val system: Boolean)
@OptIn(ExperimentalLayoutApi::class)
@Composable
fun AppManageScreen() {
val context = LocalContext.current
val app = A2iApp.instance
val mode by app.appRules.mode.collectAsState()
val blacklist by app.appRules.blacklist.collectAsState()
val whitelist by app.appRules.whitelist.collectAsState()
var showSystem by remember { mutableStateOf(false) }
var query by remember { mutableStateOf("") }
var apps by remember { mutableStateOf<List<AppInfo>>(emptyList()) }
var loading by remember { mutableStateOf(true) }
LaunchedEffect(Unit) {
loading = true
apps = loadApps(context)
loading = false
}
val visible = apps.filter {
(showSystem || !it.system) &&
(query.isBlank() || it.label.contains(query, true) || it.pkg.contains(query, true))
}
LazyColumn(
modifier = Modifier.fillMaxSize().padding(horizontal = 16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
item { ScreenTitle("应用转发", "按应用控制通知是否进入转发链路") }
item {
SectionCard("规则模式", subtitle = "黑名单适合默认转发;白名单适合只转发少数 App") {
FlowRow(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
FilterChip(
selected = mode == RuleMode.BLACKLIST,
onClick = { app.appScope.launch { app.appRules.setMode(RuleMode.BLACKLIST) } },
label = { Text("黑名单模式") },
leadingIcon = { Icon(Icons.Filled.Tune, null, modifier = Modifier.size(18.dp)) },
)
FilterChip(
selected = mode == RuleMode.WHITELIST,
onClick = { app.appScope.launch { app.appRules.setMode(RuleMode.WHITELIST) } },
label = { Text("白名单模式") },
leadingIcon = { Icon(Icons.Outlined.Apps, null, modifier = Modifier.size(18.dp)) },
)
}
SwitchRow(
title = "显示系统应用",
subtitle = if (showSystem) "列表包含系统服务和预装组件" else "当前只显示用户安装的应用",
checked = showSystem,
icon = Icons.Outlined.Apps,
tint = MaterialTheme.colorScheme.secondary,
) { showSystem = it }
}
}
item {
OutlinedTextField(
value = query,
onValueChange = { query = it },
label = { Text("搜索应用或包名") },
leadingIcon = { Icon(Icons.Filled.Search, null) },
modifier = Modifier.fillMaxWidth(),
singleLine = true,
)
}
item {
Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
Text(
if (loading) "正在读取应用列表" else "显示 ${visible.size} / ${apps.size} 个应用",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.weight(1f),
)
StatusBadge(
text = if (mode == RuleMode.BLACKLIST) "默认转发" else "仅白名单",
tint = MaterialTheme.colorScheme.primary,
)
}
}
if (loading) {
item {
Box(Modifier.fillMaxWidth().padding(32.dp), contentAlignment = Alignment.Center) {
CircularProgressIndicator()
}
}
} else {
items(visible, key = { it.pkg }) { ai ->
val on = when (mode) {
RuleMode.BLACKLIST -> ai.pkg !in blacklist
RuleMode.WHITELIST -> ai.pkg in whitelist
}
AppRow(ai, on) { v -> app.appScope.launch { app.appRules.setForwarding(ai.pkg, v) } }
}
}
}
}
@Composable
private fun AppRow(ai: AppInfo, on: Boolean, onChange: (Boolean) -> Unit) {
Surface(
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(8.dp),
color = MaterialTheme.colorScheme.surface,
border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant),
) {
Row(
Modifier.fillMaxWidth().padding(12.dp),
verticalAlignment = Alignment.CenterVertically,
) {
if (ai.icon != null) {
Image(bitmap = ai.icon, contentDescription = null, modifier = Modifier.size(42.dp))
} else {
IconBox(icon = Icons.Outlined.Apps, tint = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.size(42.dp))
}
Spacer(Modifier.width(12.dp))
Column(Modifier.weight(1f)) {
Row(verticalAlignment = Alignment.CenterVertically) {
Text(
ai.label,
style = MaterialTheme.typography.bodyLarge,
fontWeight = FontWeight.Medium,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f),
)
if (ai.system) {
Spacer(Modifier.width(8.dp))
StatusBadge("系统", MaterialTheme.colorScheme.onSurfaceVariant)
}
}
Text(
ai.pkg,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
Spacer(Modifier.width(12.dp))
Switch(checked = on, onCheckedChange = onChange)
}
}
}
private suspend fun loadApps(context: Context): List<AppInfo> = withContext(Dispatchers.IO) {
val pm = context.packageManager
pm.getInstalledApplications(PackageManager.GET_META_DATA)
.map {
AppInfo(
pkg = it.packageName,
label = pm.getApplicationLabel(it).toString(),
icon = runCatching { drawableToImageBitmap(pm.getApplicationIcon(it)) }.getOrNull(),
system = it.flags and ApplicationInfo.FLAG_SYSTEM != 0,
)
}
.sortedBy { it.label.lowercase() }
}
@@ -0,0 +1,179 @@
package com.a2i.forwarder.ui.screens
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.Notifications
import androidx.compose.material.icons.filled.Settings
import androidx.compose.material.icons.outlined.Apps
import androidx.compose.material.icons.outlined.FilterAlt
import androidx.compose.material3.AssistChip
import androidx.compose.material3.Button
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import com.a2i.forwarder.A2iApp
import com.a2i.forwarder.core.AdFilter
import com.a2i.forwarder.ui.ScreenTitle
import com.a2i.forwarder.ui.SectionCard
import com.a2i.forwarder.ui.SwitchRow
import kotlinx.coroutines.launch
@OptIn(ExperimentalLayoutApi::class)
@Composable
fun FilterScreen() {
val app = A2iApp.instance
val adOn by app.settings.adFilterEnabled.collectAsState()
val codeOn by app.settings.codeEnabled.collectAsState()
val specialOn by app.settings.specialEnabled.collectAsState()
val skipOngoing by app.settings.skipOngoing.collectAsState()
val customKw by app.settings.adKeywords.collectAsState()
var newKw by remember { mutableStateOf("") }
LazyColumn(
modifier = Modifier.fillMaxSize().padding(horizontal = 16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
item { ScreenTitle("过滤与优化", "减少噪音,保留真正需要同步到 iOS 的通知") }
item {
SectionCard("功能开关", subtitle = "规则按通知处理流水线顺序执行") {
SwitchRow(
title = "广告过滤",
subtitle = "命中营销关键词的通知将被丢弃",
checked = adOn,
icon = Icons.Outlined.FilterAlt,
tint = MaterialTheme.colorScheme.primary,
) { app.appScope.launch { app.settings.setAdFilter(it) } }
SwitchRow(
title = "验证码自动提取",
subtitle = "自动提取验证码,收到即复制并可点击复制",
checked = codeOn,
icon = Icons.Filled.Notifications,
tint = MaterialTheme.colorScheme.secondary,
) { app.appScope.launch { app.settings.setCode(it) } }
SwitchRow(
title = "微信 / QQ / Telegram 优化",
subtitle = "发送者作标题、按会话分组、重要通知提级",
checked = specialOn,
icon = Icons.Outlined.Apps,
tint = MaterialTheme.colorScheme.tertiary,
) { app.appScope.launch { app.settings.setSpecial(it) } }
SwitchRow(
title = "过滤持续性通知",
subtitle = "跳过音乐、下载、通话等 ongoing 通知",
checked = skipOngoing,
icon = Icons.Filled.Settings,
tint = MaterialTheme.colorScheme.onSurfaceVariant,
) { app.appScope.launch { app.settings.setSkipOngoing(it) } }
}
}
item {
SectionCard("内置广告关键词", subtitle = "系统预置规则不可编辑") {
FlowRow(horizontalArrangement = Arrangement.spacedBy(6.dp), verticalArrangement = Arrangement.spacedBy(6.dp)) {
AdFilter.DEFAULT_KEYWORDS.forEach { kw ->
AssistChip(onClick = {}, label = { Text(kw) })
}
}
}
}
item {
SectionCard("自定义关键词", subtitle = "命中任意关键词的通知会被广告过滤拦截") {
if (customKw.isEmpty()) {
EmptyKeywordState()
} else {
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
customKw.forEach { kw ->
KeywordRow(kw) {
app.appScope.launch { app.settings.setAdKeywords(customKw - kw) }
}
}
}
}
Spacer(Modifier.size(12.dp))
Row(verticalAlignment = Alignment.CenterVertically) {
OutlinedTextField(
value = newKw,
onValueChange = { newKw = it },
label = { Text("添加关键词") },
modifier = Modifier.weight(1f),
singleLine = true,
)
Spacer(Modifier.width(8.dp))
Button(onClick = {
val t = newKw.trim()
if (t.isNotEmpty() && t !in customKw) {
app.appScope.launch { app.settings.setAdKeywords(customKw + t) }
newKw = ""
}
}) {
Icon(Icons.Filled.Add, null, modifier = Modifier.size(18.dp))
Spacer(Modifier.width(6.dp))
Text("添加")
}
}
}
}
}
}
@Composable
private fun EmptyKeywordState() {
Surface(
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(8.dp),
color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.45f),
border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant),
) {
Text(
"暂无自定义关键词",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(12.dp),
)
}
}
@Composable
private fun KeywordRow(keyword: String, onDelete: () -> Unit) {
Surface(
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(8.dp),
color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.45f),
border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant),
) {
Row(Modifier.fillMaxWidth().padding(start = 12.dp, end = 4.dp, top = 4.dp, bottom = 4.dp), verticalAlignment = Alignment.CenterVertically) {
Text(keyword, Modifier.weight(1f), style = MaterialTheme.typography.bodyMedium, fontWeight = FontWeight.Medium)
IconButton(onClick = onDelete) { Icon(Icons.Filled.Close, "删除") }
}
}
}
@@ -0,0 +1,288 @@
package com.a2i.forwarder.ui.screens
import android.content.Intent
import android.provider.Settings
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.CheckCircle
import androidx.compose.material.icons.filled.History
import androidx.compose.material.icons.filled.Notifications
import androidx.compose.material.icons.filled.PowerSettingsNew
import androidx.compose.material.icons.filled.Settings
import androidx.compose.material.icons.filled.WarningAmber
import androidx.compose.material.icons.filled.SystemUpdate
import androidx.compose.material3.Button
import androidx.compose.material3.FilledTonalButton
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver
import androidx.lifecycle.compose.LocalLifecycleOwner
import com.a2i.forwarder.A2iApp
import com.a2i.forwarder.ui.ClickRow
import com.a2i.forwarder.ui.IconBox
import com.a2i.forwarder.ui.MetricBlock
import com.a2i.forwarder.ui.SectionCard
import com.a2i.forwarder.ui.StatusBadge
import com.a2i.forwarder.ui.SwitchRow
import com.a2i.forwarder.ui.appVersionName
import com.a2i.forwarder.ui.isNotificationListenerEnabled
import kotlinx.coroutines.launch
@Composable
fun HomeScreen(onOpenLog: () -> Unit, onOpenSettings: () -> Unit) {
val context = LocalContext.current
val app = A2iApp.instance
val forwarding by app.settings.forwardingEnabled.collectAsState()
val current by app.settings.currentServer.collectAsState()
val sent by app.logStore.sentCount.collectAsState()
val filtered by app.logStore.filteredCount.collectAsState()
val failed by app.logStore.failedCount.collectAsState()
val smsSuspended by app.settings.smsSuspended.collectAsState()
val update by app.updateChecker.updateInfo.collectAsState()
var showUpdateDialog by remember { mutableStateOf(false) }
var granted by remember { mutableStateOf(isNotificationListenerEnabled(context)) }
val lifecycleOwner = LocalLifecycleOwner.current
DisposableEffect(lifecycleOwner) {
val obs = LifecycleEventObserver { _, e ->
if (e == Lifecycle.Event.ON_RESUME) granted = isNotificationListenerEnabled(context)
}
lifecycleOwner.lifecycle.addObserver(obs)
onDispose { lifecycleOwner.lifecycle.removeObserver(obs) }
}
LazyColumn(
modifier = Modifier.fillMaxSize().padding(horizontal = 16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
item {
HomeHero(
granted = granted,
forwarding = forwarding,
version = appVersionName(context),
modifier = Modifier.padding(top = 16.dp),
)
}
val info = update
if (info != null) {
item {
SectionCard {
Row(verticalAlignment = Alignment.CenterVertically) {
IconBox(icon = Icons.Filled.SystemUpdate, tint = MaterialTheme.colorScheme.primary)
Spacer(Modifier.width(12.dp))
Column(Modifier.weight(1f)) {
Text("发现新版本 v${info.latestVersion}", style = MaterialTheme.typography.titleMedium)
Text(
"安装包 ${formatSize(info.apkSize)} · 点击立即更新",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
StatusBadge(text = "新", tint = MaterialTheme.colorScheme.primary)
}
Spacer(Modifier.size(12.dp))
Button(onClick = { showUpdateDialog = true }, modifier = Modifier.fillMaxWidth()) { Text("立即更新") }
}
}
}
if (smsSuspended) {
item {
SectionCard {
Row(verticalAlignment = Alignment.CenterVertically) {
IconBox(
icon = Icons.Filled.WarningAmber,
tint = MaterialTheme.colorScheme.error,
)
Spacer(Modifier.width(12.dp))
Column(Modifier.weight(1f)) {
Text("短信兜底已停用", style = MaterialTheme.typography.titleMedium)
Text(
"短信余额不足,断网转发已关闭。请在「设置 → 断网短信兜底」补充额度",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
}
}
item {
SectionCard {
Row(verticalAlignment = Alignment.CenterVertically) {
IconBox(
icon = if (granted) Icons.Filled.CheckCircle else Icons.Filled.WarningAmber,
tint = if (granted) MaterialTheme.colorScheme.tertiary else MaterialTheme.colorScheme.error,
)
Spacer(Modifier.width(12.dp))
Column(Modifier.weight(1f)) {
Text(if (granted) "通知监听已开启" else "需要授权通知监听", style = MaterialTheme.typography.titleMedium)
Text(
if (granted) "系统通知将按规则处理并推送到 Bark" else "授权后才能读取 Android 通知并转发",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
StatusBadge(
text = if (granted) "已授权" else "未授权",
tint = if (granted) MaterialTheme.colorScheme.tertiary else MaterialTheme.colorScheme.error,
)
}
if (!granted) {
Spacer(Modifier.size(12.dp))
Button(
onClick = { context.startActivity(Intent(Settings.ACTION_NOTIFICATION_LISTENER_SETTINGS)) },
modifier = Modifier.fillMaxWidth(),
) { Text("前往系统授权") }
}
}
}
item {
SectionCard("运行状态", subtitle = "转发能力、目标服务器和日志入口") {
SwitchRow(
title = "通知转发",
subtitle = if (forwarding) "新通知会继续转发到 Bark" else "当前已暂停所有转发",
checked = forwarding,
icon = Icons.Filled.PowerSettingsNew,
tint = if (forwarding) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant,
) { v ->
app.appScope.launch { app.settings.setForwarding(v) }
}
ClickRow(
title = current?.name?.ifBlank { "未命名服务器" } ?: "未配置 Bark 服务器",
subtitle = current?.server ?: "进入设置添加官方或自建 Bark 服务",
icon = Icons.Filled.Settings,
tint = MaterialTheme.colorScheme.secondary,
onClick = onOpenSettings,
)
if (current?.deviceKey.isNullOrBlank()) {
Text(
"当前服务器未填写 device key",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.error,
modifier = Modifier.padding(start = 50.dp),
)
}
ClickRow(
title = "查看转发日志",
subtitle = "最近 300 条处理记录",
icon = Icons.Filled.History,
tint = MaterialTheme.colorScheme.primary,
onClick = onOpenLog,
)
}
}
item {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
MetricBlock("已转发", sent.toString(), MaterialTheme.colorScheme.primary, Modifier.weight(1f))
MetricBlock("已过滤", filtered.toString(), MaterialTheme.colorScheme.secondary, Modifier.weight(1f))
MetricBlock("失败", failed.toString(), MaterialTheme.colorScheme.error, Modifier.weight(1f))
}
}
item {
Text(
"v${appVersionName(context)}",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.fillMaxWidth().padding(top = 2.dp, bottom = 18.dp),
textAlign = TextAlign.Center,
)
}
}
if (showUpdateDialog) {
val info = update
if (info != null) UpdateDialog(info, onDismiss = { showUpdateDialog = false })
}
}
@Composable
private fun HomeHero(
granted: Boolean,
forwarding: Boolean,
version: String,
modifier: Modifier = Modifier,
) {
Box(
modifier = modifier
.fillMaxWidth()
.background(
brush = Brush.linearGradient(
colors = listOf(MaterialTheme.colorScheme.primary, MaterialTheme.colorScheme.secondary),
),
shape = RoundedCornerShape(12.dp),
)
.padding(18.dp),
) {
Column {
Row(verticalAlignment = Alignment.CenterVertically) {
Box(
modifier = Modifier
.size(44.dp)
.background(Color.White.copy(alpha = 0.18f), RoundedCornerShape(8.dp)),
contentAlignment = Alignment.Center,
) {
Icon(Icons.Filled.Notifications, null, tint = Color.White, modifier = Modifier.size(24.dp))
}
Spacer(Modifier.width(12.dp))
Column(Modifier.weight(1f)) {
Text("a2i", style = MaterialTheme.typography.displaySmall, color = Color.White)
Text("安卓通知转发到 iOS Bark", style = MaterialTheme.typography.bodyMedium, color = Color.White.copy(alpha = 0.82f))
}
StatusBadge(
text = if (granted && forwarding) "运行中" else "待处理",
tint = Color.White,
)
}
Spacer(Modifier.size(14.dp))
Text(
if (granted && forwarding) "监听、过滤、提取验证码和推送链路已就绪。" else "完成授权并开启转发后,通知会按规则推送到 iOS。",
style = MaterialTheme.typography.bodyMedium,
color = Color.White.copy(alpha = 0.86f),
)
Text(
"Build $version",
style = MaterialTheme.typography.bodySmall,
color = Color.White.copy(alpha = 0.68f),
modifier = Modifier.padding(top = 8.dp),
)
}
}
}
@@ -0,0 +1,193 @@
package com.a2i.forwarder.ui.screens
import android.content.Context
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.work.OneTimeWorkRequestBuilder
import androidx.work.WorkManager
import com.a2i.forwarder.A2iApp
import com.a2i.forwarder.core.ForwardLog
import com.a2i.forwarder.core.RetryWorker
import com.a2i.forwarder.ui.SectionCard
import com.a2i.forwarder.ui.StatusBadge
import kotlinx.coroutines.launch
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
@Composable
fun LogScreen(onBack: () -> Unit) {
val context = LocalContext.current
val app = A2iApp.instance
val logs by app.logStore.logs.collectAsState()
val pending by app.logStore.pending.collectAsState()
val df = remember { SimpleDateFormat("MM-dd HH:mm:ss", Locale.getDefault()) }
var showClearDialog by remember { mutableStateOf(false) }
Column(Modifier.fillMaxSize()) {
Surface(
color = MaterialTheme.colorScheme.surface,
border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant),
) {
Row(
Modifier.fillMaxWidth().padding(horizontal = 8.dp, vertical = 6.dp),
verticalAlignment = Alignment.CenterVertically,
) {
IconButton(onClick = onBack) { Icon(Icons.AutoMirrored.Filled.ArrowBack, "返回") }
Column(Modifier.weight(1f)) {
Text("转发日志", style = MaterialTheme.typography.titleLarge)
Text("最近 300 条通知处理记录", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
}
TextButton(onClick = { showClearDialog = true }) { Text("清空") }
}
}
LazyColumn(
modifier = Modifier.fillMaxSize().padding(horizontal = 16.dp, vertical = 12.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
if (pending.isNotEmpty()) {
item {
SectionCard("待重发(${pending.size})", subtitle = "发送失败的通知会在联网后自动重试") {
Text(
"也可以立即触发一次 WorkManager 重发任务。",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
TextButton(onClick = {
WorkManager.getInstance(context)
.enqueue(OneTimeWorkRequestBuilder<RetryWorker>().build())
}) { Text("立即全部重发") }
}
}
}
if (logs.isEmpty()) {
item {
Box(Modifier.fillMaxWidth().padding(32.dp), contentAlignment = Alignment.Center) {
Text("暂无记录", color = MaterialTheme.colorScheme.onSurfaceVariant)
}
}
}
items(logs) { log ->
LogItem(log, df)
}
}
}
if (showClearDialog) {
AlertDialog(
onDismissRequest = { showClearDialog = false },
title = { Text("清空日志") },
text = { Text("选择清空范围:") },
confirmButton = {
TextButton(onClick = {
showClearDialog = false
app.appScope.launch { app.logStore.clearLogs() }
}) { Text("仅清空日志") }
},
dismissButton = {
TextButton(onClick = {
showClearDialog = false
app.appScope.launch {
app.logStore.clearLogs()
app.logStore.resetCounters()
}
}) { Text("日志+计数器") }
},
)
}
}
@Composable
private fun LogItem(log: ForwardLog, df: SimpleDateFormat) {
val (tint, text) = when (log.status) {
"sent" -> MaterialTheme.colorScheme.tertiary to "已转发"
"filtered" -> MaterialTheme.colorScheme.secondary to "已过滤"
else -> MaterialTheme.colorScheme.error to "失败"
}
Surface(
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(8.dp),
color = MaterialTheme.colorScheme.surface,
border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant),
) {
Column(Modifier.padding(12.dp)) {
Row(verticalAlignment = Alignment.CenterVertically) {
StatusBadge(text, tint)
Spacer(Modifier.size(8.dp))
Text(
log.appLabel,
style = MaterialTheme.typography.bodyMedium,
fontWeight = FontWeight.Medium,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f),
)
Text(df.format(Date(log.time)), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
}
val title = log.title.orEmpty()
if (title.isNotBlank()) {
Text(
title,
style = MaterialTheme.typography.bodyMedium,
fontWeight = FontWeight.Medium,
modifier = Modifier.padding(top = 8.dp),
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
if (log.content.isNotBlank()) {
Text(
log.content,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.padding(top = if (title.isNotBlank()) 2.dp else 8.dp),
)
}
if (log.reason.isNotBlank()) {
Text(
"原因:${log.reason}",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.error,
modifier = Modifier.padding(top = 6.dp),
)
}
}
}
}
@@ -0,0 +1,469 @@
package com.a2i.forwarder.ui.screens
import android.content.Context
import android.content.Intent
import android.net.Uri
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.Send
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.Delete
import androidx.compose.material.icons.filled.Edit
import androidx.compose.material.icons.filled.History
import androidx.compose.material.icons.filled.RestartAlt
import androidx.compose.material.icons.filled.Settings
import androidx.compose.material.icons.filled.SystemUpdateAlt
import androidx.compose.material.icons.filled.Update
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.FilledTonalButton
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.RadioButton
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.documentfile.provider.DocumentFile
import com.a2i.forwarder.A2iApp
import com.a2i.forwarder.core.BarkClient
import com.a2i.forwarder.core.IconExporter
import com.a2i.forwarder.model.BarkMessage
import com.a2i.forwarder.model.BarkServer
import com.a2i.forwarder.model.OFFICIAL_BARK_SERVER
import com.a2i.forwarder.ui.ClickRow
import com.a2i.forwarder.ui.ScreenTitle
import com.a2i.forwarder.ui.appVersionName
import com.a2i.forwarder.ui.SectionCard
import com.a2i.forwarder.ui.StatusBadge
import kotlinx.coroutines.launch
@Composable
fun SettingsScreen(onOpenLog: () -> Unit) {
val context = LocalContext.current
val app = A2iApp.instance
val servers by app.settings.servers.collectAsState()
val current by app.settings.currentServer.collectAsState()
val iconPrefix by app.settings.iconPrefix.collectAsState()
val prefs = remember { context.getSharedPreferences("a2i_ui", Context.MODE_PRIVATE) }
var treeUri by remember { mutableStateOf<Uri?>(prefs.getString("icon_tree", null)?.let { runCatching { Uri.parse(it) }.getOrNull() }) }
var exporting by remember { mutableStateOf(false) }
var exportResult by remember { mutableStateOf<String?>(null) }
var testing by remember { mutableStateOf(false) }
var testMsg by remember { mutableStateOf<String?>(null) }
var showAdd by remember { mutableStateOf(false) }
var editing by remember { mutableStateOf<BarkServer?>(null) }
// 应用更新状态
val updateAuto by app.settings.updateCheckEnabled.collectAsState()
val updateInfo by app.updateChecker.updateInfo.collectAsState()
var checking by remember { mutableStateOf(false) }
var checkMsg by remember { mutableStateOf<String?>(null) }
// 短信兜底状态
val smsFallback by app.settings.smsFallbackEnabled.collectAsState()
val smsTarget by app.settings.smsTargetNumber.collectAsState()
val smsCarrier by app.settings.smsCarrier.collectAsState()
val smsQuota by app.settings.smsManualQuota.collectAsState()
val smsBalance by app.settings.smsBalance.collectAsState()
val smsSuspended by app.settings.smsSuspended.collectAsState()
var smsTargetInput by remember(smsTarget) { mutableStateOf(smsTarget) }
var smsQuotaInput by remember(smsQuota) { mutableStateOf(if (smsQuota == 0) "" else smsQuota.toString()) }
val detectedCarrier = remember { com.a2i.forwarder.core.CarrierDetector.detect(context) }
val smsPermLauncher = rememberLauncherForActivityResult(ActivityResultContracts.RequestPermission()) { }
val hasSmsPerm = remember {
androidx.core.content.ContextCompat.checkSelfPermission(context, android.Manifest.permission.SEND_SMS) ==
android.content.pm.PackageManager.PERMISSION_GRANTED
}
val treeLauncher = rememberLauncherForActivityResult(ActivityResultContracts.OpenDocumentTree()) { uri ->
if (uri != null) {
runCatching {
context.contentResolver.takePersistableUriPermission(
uri,
Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION,
)
}
prefs.edit().putString("icon_tree", uri.toString()).apply()
treeUri = uri
}
}
LazyColumn(
modifier = Modifier.fillMaxSize().padding(horizontal = 16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
item { ScreenTitle("设置", "管理 Bark 服务、图标资源和维护操作") }
item {
SectionCard("Bark 服务器", subtitle = "官方服务和自建服务可以并存,当前服务器用于所有推送") {
if (servers.isEmpty()) {
Text("暂无服务器,请先添加。", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
} else {
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
servers.forEach { s ->
ServerRow(
server = s,
selected = s.id == current?.id,
onSelect = { app.appScope.launch { app.settings.setCurrent(s.id) } },
onEdit = { editing = s },
onDelete = { app.appScope.launch { app.settings.deleteServer(s.id) } },
)
}
}
}
Button(
onClick = { showAdd = true },
modifier = Modifier.fillMaxWidth().padding(top = 12.dp),
) {
Icon(Icons.Filled.Add, null, modifier = Modifier.size(18.dp))
Spacer(Modifier.width(8.dp))
Text("添加服务器")
}
FilledTonalButton(
onClick = {
app.appScope.launch {
testing = true
testMsg = testPush(app)
testing = false
}
},
modifier = Modifier.fillMaxWidth().padding(top = 8.dp),
enabled = !testing,
) {
if (testing) {
CircularProgressIndicator(Modifier.size(18.dp), strokeWidth = 2.dp)
} else {
Icon(Icons.AutoMirrored.Filled.Send, null, modifier = Modifier.size(18.dp))
}
Spacer(Modifier.width(8.dp))
Text("发送测试推送")
}
testMsg?.let {
Text(
it,
style = MaterialTheme.typography.bodySmall,
color = if (it.startsWith("已发送")) MaterialTheme.colorScheme.tertiary else MaterialTheme.colorScheme.error,
modifier = Modifier.padding(top = 8.dp),
)
}
}
}
item {
SectionCard("应用图标", subtitle = "导出 App 图标后,Bark 通知可按包名显示图标") {
OutlinedTextField(
value = iconPrefix,
onValueChange = { app.appScope.launch { app.settings.setIconPrefix(it) } },
label = { Text("图标 URL 前缀") },
supportingText = { Text("转发时 icon = 前缀 + 包名 + .png;留空则不显示图标") },
modifier = Modifier.fillMaxWidth(),
singleLine = true,
)
Text(
"导出目录:${treeUri?.let { DocumentFile.fromTreeUri(context, it)?.name } ?: "未选择"}",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(top = 8.dp),
)
Row(
modifier = Modifier.fillMaxWidth().padding(top = 10.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
OutlinedButton(onClick = { treeLauncher.launch(null) }, modifier = Modifier.weight(1f)) { Text("选择目录") }
Button(
onClick = {
val u = treeUri ?: return@Button
app.appScope.launch {
exporting = true
val r = IconExporter(context).exportAll(u)
exporting = false
exportResult = "共 ${r.total} 个,成功 ${r.ok},失败 ${r.fail}"
}
},
enabled = treeUri != null && !exporting,
modifier = Modifier.weight(1f),
) {
if (exporting) CircularProgressIndicator(Modifier.size(18.dp), strokeWidth = 2.dp) else Text("导出图标")
}
}
exportResult?.let {
Text(it, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.primary, modifier = Modifier.padding(top = 8.dp))
}
}
}
item {
SectionCard("断网短信兜底", subtitle = "无网络时把验证码、来电用短信发到 iPhone(每5分钟1条)") {
com.a2i.forwarder.ui.SwitchRow(
title = "启用短信兜底",
subtitle = if (smsFallback) "断网时紧要通知走短信" else "当前关闭",
checked = smsFallback,
) { v -> app.appScope.launch { app.settings.setSmsFallback(v) } }
Spacer(Modifier.size(8.dp))
OutlinedTextField(
value = smsTargetInput,
onValueChange = { smsTargetInput = it },
label = { Text("iPhone 手机号") },
supportingText = { Text("短信转发的目标号码") },
modifier = Modifier.fillMaxWidth(),
singleLine = true,
)
Spacer(Modifier.size(4.dp))
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
OutlinedButton(onClick = {
app.appScope.launch { app.settings.setSmsTargetNumber(smsTargetInput.trim()) }
}, modifier = Modifier.weight(1f)) { Text("保存号码") }
if (!hasSmsPerm) {
OutlinedButton(onClick = {
smsPermLauncher.launch(android.Manifest.permission.SEND_SMS)
}, modifier = Modifier.weight(1f)) { Text("授予短信权限") }
}
}
Spacer(Modifier.size(8.dp))
Text(
"检测到运营商:${detectedCarrier.displayName}" +
if (detectedCarrier.serviceNumber == null) "(无自动查询,将用手动额度)" else "(${detectedCarrier.serviceNumber})",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Spacer(Modifier.size(8.dp))
OutlinedTextField(
value = smsQuotaInput,
onValueChange = { smsQuotaInput = it.filter { c -> c.isDigit() } },
label = { Text("手动短信额度") },
supportingText = { Text("自动查询失败时按此递减;余额≤5自动停用") },
modifier = Modifier.fillMaxWidth(),
singleLine = true,
)
Spacer(Modifier.size(4.dp))
OutlinedButton(onClick = {
val q = smsQuotaInput.trim().toIntOrNull() ?: 0
app.appScope.launch {
app.settings.setSmsManualQuota(q)
app.settings.setSmsBalance(q)
if (q > 5) app.settings.setSmsSuspended(false)
}
}) { Text("保存额度并解除挂起") }
Spacer(Modifier.size(8.dp))
val balanceText = when {
smsSuspended -> "已挂起(余额不足)"
smsBalance < 0 -> "余额未知"
else -> "剩余约 ${smsBalance} 条"
}
Text(
"当前状态:$balanceText",
style = MaterialTheme.typography.bodySmall,
color = if (smsSuspended) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
item {
SectionCard("关于与更新", subtitle = "版本检查(数据来自 GitHub Releases)") {
com.a2i.forwarder.ui.SwitchRow(
title = "自动检查更新",
subtitle = "启动时检查,且距上次满 24 小时再次检查",
checked = updateAuto,
icon = Icons.Filled.Update,
) { v -> app.appScope.launch { app.settings.setUpdateCheckEnabled(v) } }
ClickRow(
title = "检查更新",
subtitle = "当前 v${appVersionName(context)}" +
(updateInfo?.let { " · 已发现 v${it.latestVersion}" } ?: " · 点击立即检查"),
icon = Icons.Filled.SystemUpdateAlt,
onClick = {
app.appScope.launch {
checking = true
checkMsg = null
val r = app.updateChecker.check(force = true)
checking = false
checkMsg = when {
!r.isSuccess -> "检查失败:${r.exceptionOrNull()?.message}"
r.getOrNull() != null -> "发现新版本,请到首页更新"
else -> "已是最新版本"
}
}
},
)
if (checking) {
Row(
modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp),
verticalAlignment = Alignment.CenterVertically,
) {
CircularProgressIndicator(Modifier.size(16.dp), strokeWidth = 2.dp)
Spacer(Modifier.width(8.dp))
Text("正在检查…", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
}
}
checkMsg?.let {
Text(
it,
style = MaterialTheme.typography.bodySmall,
color = if (it.startsWith("已是")) MaterialTheme.colorScheme.tertiary else MaterialTheme.colorScheme.error,
modifier = Modifier.padding(start = 50.dp, top = 2.dp, bottom = 4.dp),
)
}
}
}
item {
SectionCard("维护", subtitle = "日志查看和本地统计清理") {
ClickRow("转发日志", "查看 / 清空最近记录", icon = Icons.Filled.History, onClick = onOpenLog)
ClickRow("重置计数器", "清零转发 / 过滤 / 失败统计", icon = Icons.Filled.RestartAlt, tint = MaterialTheme.colorScheme.error) {
app.appScope.launch { app.logStore.resetCounters() }
}
}
}
item {
Box(Modifier.fillMaxWidth().padding(top = 4.dp, bottom = 18.dp), contentAlignment = Alignment.Center) {
Text(
"a2i · 安卓通知转发到 iOS Bark\n图标需 iOS 15+;点击通知会打开 iOS 对应 App",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
if (showAdd) {
ServerDialog(
onDismiss = { showAdd = false },
onConfirm = { n, s, k ->
app.appScope.launch {
val ns = app.settings.addServer(n, s, k)
app.settings.setCurrent(ns.id)
}
showAdd = false
},
)
}
editing?.let { srv ->
ServerDialog(
initial = srv,
onDismiss = { editing = null },
onConfirm = { n, s, k ->
app.appScope.launch { app.settings.updateServer(srv.copy(name = n, server = s, deviceKey = k)) }
editing = null
},
)
}
}
@Composable
private fun ServerRow(
server: BarkServer,
selected: Boolean,
onSelect: () -> Unit,
onEdit: () -> Unit,
onDelete: () -> Unit,
) {
Surface(
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(8.dp),
color = if (selected) MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.55f) else MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.36f),
border = BorderStroke(1.dp, if (selected) MaterialTheme.colorScheme.primary.copy(alpha = 0.32f) else MaterialTheme.colorScheme.outlineVariant),
) {
Row(Modifier.fillMaxWidth().padding(8.dp), verticalAlignment = Alignment.CenterVertically) {
RadioButton(selected = selected, onClick = onSelect)
Column(Modifier.weight(1f)) {
Row(verticalAlignment = Alignment.CenterVertically) {
Text(
server.name.ifBlank { "未命名" },
style = MaterialTheme.typography.bodyLarge,
fontWeight = FontWeight.Medium,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f),
)
if (selected) StatusBadge("当前", MaterialTheme.colorScheme.primary)
}
Text(
"${server.server} · key: ${if (server.deviceKey.isBlank()) "未填" else "已填"}",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
IconButton(onClick = onEdit) { Icon(Icons.Filled.Edit, "编辑") }
IconButton(onClick = onDelete) { Icon(Icons.Filled.Delete, "删除") }
}
}
}
@Composable
private fun ServerDialog(
initial: BarkServer? = null,
onDismiss: () -> Unit,
onConfirm: (name: String, server: String, key: String) -> Unit,
) {
var name by remember { mutableStateOf(initial?.name ?: "") }
var server by remember { mutableStateOf(initial?.server ?: OFFICIAL_BARK_SERVER) }
var key by remember { mutableStateOf(initial?.deviceKey ?: "") }
AlertDialog(
onDismissRequest = onDismiss,
icon = { Icon(Icons.Filled.Settings, null) },
title = { Text(if (initial == null) "添加服务器" else "编辑服务器") },
text = {
Column {
OutlinedTextField(name, { name = it }, label = { Text("名称") }, singleLine = true, modifier = Modifier.fillMaxWidth())
Spacer(Modifier.size(8.dp))
OutlinedTextField(server, { server = it }, label = { Text("服务器地址") }, singleLine = true, modifier = Modifier.fillMaxWidth(), supportingText = { Text("官方:$OFFICIAL_BARK_SERVER") })
Spacer(Modifier.size(8.dp))
OutlinedTextField(key, { key = it }, label = { Text("Device Key") }, singleLine = true, modifier = Modifier.fillMaxWidth())
}
},
confirmButton = { TextButton(onClick = { onConfirm(name.trim(), server.trim(), key.trim()) }) { Text("保存") } },
dismissButton = { TextButton(onClick = onDismiss) { Text("取消") } },
)
}
private suspend fun testPush(app: A2iApp): String {
val s = app.settings.currentServer.value ?: return "未选择服务器"
if (s.deviceKey.isBlank()) return "未填写 device key"
val msg = BarkMessage(
deviceKey = s.deviceKey,
title = "a2i 测试",
body = "如果你在 iOS 看到这条推送,说明配置成功",
level = "active",
)
val r = BarkClient(s).push(msg)
return if (r.isSuccess) "已发送,请查看 iOS 端" else "发送失败:${r.exceptionOrNull()?.message}"
}
@@ -0,0 +1,121 @@
package com.a2i.forwarder.ui.screens
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import com.a2i.forwarder.A2iApp
import com.a2i.forwarder.core.UpdateInfo
import kotlinx.coroutines.launch
import java.io.File
import java.util.Locale
@Composable
fun UpdateDialog(info: UpdateInfo, onDismiss: () -> Unit) {
val app = A2iApp.instance
val downloading by app.updateChecker.downloading.collectAsState()
val progress by app.updateChecker.downloadProgress.collectAsState()
var downloadedFile by remember { mutableStateOf<File?>(null) }
AlertDialog(
onDismissRequest = { if (!downloading) onDismiss() },
title = { Text("发现新版本 v${info.latestVersion}") },
text = {
Column {
Text(
"安装包大小:${formatSize(info.apkSize)}",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Spacer(Modifier.size(8.dp))
Column(
modifier = Modifier
.heightIn(max = 240.dp)
.verticalScroll(rememberScrollState()),
) {
val notes = info.releaseNotes.ifBlank { "(作者未提供更新说明)" }
Text(notes, style = MaterialTheme.typography.bodyMedium)
}
if (downloading) {
Spacer(Modifier.size(12.dp))
LinearProgressIndicator(
progress = { progress / 100f },
modifier = Modifier.fillMaxWidth(),
)
Spacer(Modifier.size(4.dp))
Text(
"$progress%",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
downloadedFile?.let {
Spacer(Modifier.size(8.dp))
Text(
"下载完成,点击「安装」继续",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.tertiary,
)
}
}
},
confirmButton = {
if (downloadedFile == null) {
Button(
onClick = {
app.appScope.launch {
val f = app.updateChecker.download(info)
if (f != null) downloadedFile = f
}
},
enabled = !downloading,
) {
if (downloading) {
CircularProgressIndicator(Modifier.size(16.dp), strokeWidth = 2.dp)
} else {
Text("下载并安装")
}
}
} else {
Button(onClick = { app.updateChecker.install(downloadedFile!!) }) { Text("安装") }
}
},
dismissButton = {
Row {
TextButton(
onClick = { app.updateChecker.ignoreVersion(info.latestVersion); onDismiss() },
enabled = !downloading,
) { Text("忽略此版本") }
TextButton(onClick = onDismiss, enabled = !downloading) { Text("稍后") }
}
},
)
}
fun formatSize(bytes: Long): String {
if (bytes <= 0) return "未知"
val kb = bytes / 1024.0
return if (kb >= 1024) String.format(Locale.US, "%.1f MB", kb / 1024) else String.format(Locale.US, "%.0f KB", kb)
}
@@ -0,0 +1,21 @@
package com.a2i.forwarder.ui.theme
import androidx.compose.ui.graphics.Color
val BrandBlue = Color(0xFF246BFD)
val BrandCyan = Color(0xFF00A9C8)
val BrandEmerald = Color(0xFF18A058)
val BrandAmber = Color(0xFFF59E0B)
val BrandRed = Color(0xFFE5484D)
val LightBackground = Color(0xFFF5F7FB)
val LightSurface = Color(0xFFFFFFFF)
val LightSurfaceMuted = Color(0xFFEAF0FA)
val LightText = Color(0xFF111827)
val LightTextMuted = Color(0xFF64748B)
val DarkBackground = Color(0xFF0B1220)
val DarkSurface = Color(0xFF111B2E)
val DarkSurfaceMuted = Color(0xFF1A2740)
val DarkText = Color(0xFFE7EDF7)
val DarkTextMuted = Color(0xFF9AA8BC)
@@ -0,0 +1,64 @@
package com.a2i.forwarder.ui.theme
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.darkColorScheme
import androidx.compose.material3.lightColorScheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.graphics.Color
private val DarkColors = darkColorScheme(
primary = Color(0xFF89B4FF),
onPrimary = DarkBackground,
primaryContainer = Color(0xFF163968),
onPrimaryContainer = DarkText,
secondary = Color(0xFF6FD3E4),
onSecondary = DarkBackground,
secondaryContainer = Color(0xFF123D47),
onSecondaryContainer = DarkText,
tertiary = Color(0xFF7BDFA8),
error = Color(0xFFFF9EA2),
background = DarkBackground,
onBackground = DarkText,
surface = DarkSurface,
onSurface = DarkText,
surfaceVariant = DarkSurfaceMuted,
onSurfaceVariant = DarkTextMuted,
outline = Color(0xFF425169),
outlineVariant = Color(0xFF29374D),
)
private val LightColors = lightColorScheme(
primary = BrandBlue,
onPrimary = LightSurface,
primaryContainer = Color(0xFFE1EAFF),
onPrimaryContainer = LightText,
secondary = BrandCyan,
onSecondary = LightSurface,
secondaryContainer = Color(0xFFDDF8FC),
onSecondaryContainer = LightText,
tertiary = BrandEmerald,
error = BrandRed,
background = LightBackground,
onBackground = LightText,
surface = LightSurface,
onSurface = LightText,
surfaceVariant = LightSurfaceMuted,
onSurfaceVariant = LightTextMuted,
outline = Color(0xFFCAD5E4),
outlineVariant = Color(0xFFE0E7F1),
)
@Composable
fun A2iTheme(
darkTheme: Boolean = isSystemInDarkTheme(),
dynamicColor: Boolean = false,
content: @Composable () -> Unit,
) {
val colorScheme = if (darkTheme) DarkColors else LightColors
MaterialTheme(
colorScheme = colorScheme,
typography = Typography,
content = content,
)
}
@@ -0,0 +1,73 @@
package com.a2i.forwarder.ui.theme
import androidx.compose.material3.Typography
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.sp
val Typography = Typography(
displaySmall = TextStyle(
fontFamily = FontFamily.Default,
fontWeight = FontWeight.Bold,
fontSize = 34.sp,
lineHeight = 40.sp,
letterSpacing = 0.sp,
),
headlineMedium = TextStyle(
fontFamily = FontFamily.Default,
fontWeight = FontWeight.Bold,
fontSize = 26.sp,
lineHeight = 32.sp,
letterSpacing = 0.sp,
),
titleLarge = TextStyle(
fontFamily = FontFamily.Default,
fontWeight = FontWeight.SemiBold,
fontSize = 22.sp,
lineHeight = 28.sp,
letterSpacing = 0.sp,
),
titleMedium = TextStyle(
fontFamily = FontFamily.Default,
fontWeight = FontWeight.SemiBold,
fontSize = 17.sp,
lineHeight = 24.sp,
letterSpacing = 0.sp,
),
bodyLarge = TextStyle(
fontFamily = FontFamily.Default,
fontWeight = FontWeight.Normal,
fontSize = 16.sp,
lineHeight = 24.sp,
letterSpacing = 0.sp,
),
bodyMedium = TextStyle(
fontFamily = FontFamily.Default,
fontWeight = FontWeight.Normal,
fontSize = 14.sp,
lineHeight = 20.sp,
letterSpacing = 0.sp,
),
bodySmall = TextStyle(
fontFamily = FontFamily.Default,
fontWeight = FontWeight.Normal,
fontSize = 12.sp,
lineHeight = 16.sp,
letterSpacing = 0.sp,
),
labelLarge = TextStyle(
fontFamily = FontFamily.Default,
fontWeight = FontWeight.SemiBold,
fontSize = 14.sp,
lineHeight = 20.sp,
letterSpacing = 0.sp,
),
labelMedium = TextStyle(
fontFamily = FontFamily.Default,
fontWeight = FontWeight.SemiBold,
fontSize = 12.sp,
lineHeight = 16.sp,
letterSpacing = 0.sp,
),
)
@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="rectangle">
<gradient
android:startColor="#3B7CFF"
android:endColor="#6A5BFF"
android:angle="135" />
</shape>
@@ -0,0 +1,15 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<group
android:scaleX="2.6"
android:scaleY="2.6"
android:translateX="22.8"
android:translateY="22.8">
<path
android:fillColor="#FFFFFF"
android:pathData="M2.01,21L23,12 2.01,3 2,10l15,2 -15,2z" />
</group>
</vector>
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@drawable/ic_launcher_background" />
<foreground android:drawable="@drawable/ic_launcher_foreground" />
<monochrome android:drawable="@drawable/ic_launcher_foreground" />
</adaptive-icon>
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@drawable/ic_launcher_background" />
<foreground android:drawable="@drawable/ic_launcher_foreground" />
<monochrome android:drawable="@drawable/ic_launcher_foreground" />
</adaptive-icon>
+3
View File
@@ -0,0 +1,3 @@
<resources>
<color name="ic_launcher_bg">#3B7CFF</color>
</resources>
+3
View File
@@ -0,0 +1,3 @@
<resources>
<string name="app_name">a2i</string>
</resources>
+7
View File
@@ -0,0 +1,7 @@
<resources xmlns:tools="http://schemas.android.com/tools">
<style name="Theme.A2i" parent="Theme.Material3.DayNight.NoActionBar">
<item name="android:statusBarColor">@android:color/transparent</item>
<item name="android:navigationBarColor">@android:color/transparent</item>
<item name="android:windowLightStatusBar" tools:targetApi="m">true</item>
</style>
</resources>
+3
View File
@@ -0,0 +1,3 @@
<?xml version="1.0" encoding="utf-8"?>
<full-backup-content>
</full-backup-content>
@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<data-extraction-rules>
<cloud-backup>
</cloud-backup>
<device-transfer>
</device-transfer>
</data-extraction-rules>
+5
View File
@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<paths>
<external-files-path name="updates" path="." />
<cache-path name="cache" path="." />
</paths>
+6
View File
@@ -0,0 +1,6 @@
plugins {
alias(libs.plugins.android.application) apply false
alias(libs.plugins.kotlin.android) apply false
alias(libs.plugins.kotlin.compose) apply false
alias(libs.plugins.kotlin.serialization) apply false
}
-7
View File
@@ -1,7 +0,0 @@
# A2iReply 快捷指令迁移说明
GotMsg v1.11.0 已移除旧的 iOS 快捷指令和 ntfy `_reply` topic 方案。请删除原有的 `A2iReply` 快捷指令;GotMsg 不再订阅或使用 `_reply` topic。
新版本把一次性回复地址设置为 Bark / ntfy 通知的点击地址或“回复”动作,不会在通知正文中重复显示。iPhone 和 Android 都可直接打开网页输入回复,无需另外安装快捷指令或修改现有 ntfy topic。
完整的安装要求、Shizuku / 无障碍配置、PIN 自动解锁和故障排查见 [通知远程回复](notification-reply.md)。
-94
View File
@@ -1,94 +0,0 @@
# 通知远程回复
GotMsg 可以从 Bark、ntfy、Telegram、飞书、钉钉、Meow、电邮或短信兜底消息打开一次性回复网页,把回复送回运行 GotMsg 的原 Android 手机。系统会优先使用通知自带的 Android `RemoteInput`;QQ 等应用可使用无障碍兼容回复,微信使用 Shizuku 回复。
> 远程回复不是模拟登录,也不会接管微信或 QQ 账号。能否成功取决于原通知是否仍然存在、原手机是否在线,以及目标应用当前界面是否可识别。
## 开始前准备
1. 在原 Android 手机上安装 GotMsg,并授权“通知使用权”。
2. 至少启用一个支持接收回复入口的转发配置。短信兜底只有在原手机能够联网注册回复会话时才会附带入口。
3. 关闭针对 GotMsg 的省电限制,允许后台运行和自启动。
4. 根据要回复的应用完成下面的额外配置:
- 通知本身带快捷回复:无需额外配置。
- QQ、支付宝、淘宝、钉钉:打开 GotMsg“设置 → 通知回复 → 无障碍兼容回复”,并在系统无障碍设置中启用“GotMsg 兼容回复”。
- 微信:安装并启动 [Shizuku](https://shizuku.rikka.app/download/),授权 GotMsg。微信实际回复走 Shizuku,不依赖系统无障碍服务。
## 使用方法
1. 原手机收到通知后,GotMsg 注册一次性回复会话,并将通知转发到已启用通道。
2. 在接收端点击通知、“回复”按钮或正文中的回复链接。
3. 网页会显示应用名、通知标题和正文;输入内容并提交。
4. 原手机拉取任务并执行回复,网页等待最终结果;GotMsg“日志”页也会记录结果。
Bark、ntfy、Telegram、飞书和钉钉优先把回复入口作为点击地址、动作或按钮;Meow 同时发送原生 `url` 跳转字段和可见链接,电邮与短信使用可点击的纯文本链接。带回复入口的短信最长控制为两个分段,可能消耗两条短信额度。原手机完全断网时无法预先向中继注册一次性会话,因此那一条短信不会附带回复链接。短信 App 中识别出的验证码通知不会创建回复会话,也不会附加回复入口。接收端不需要 iOS 快捷指令,也不需要创建或订阅 `_reply` topic;现有 ntfy server、topic 和 token 无需改变。
## 三种执行方式
| 方式 | 适用范围 | 额外要求 | 是否会打开原应用 |
| --- | --- | --- | --- |
| Android `RemoteInput` | Telegram、WhatsApp、Signal、部分短信和企业 IM;以该条通知实际提供的动作为准 | 无 | 否 |
| 无障碍兼容回复 | QQ、支付宝、淘宝、钉钉 | 启用 GotMsg 无障碍服务 | 是 |
| Shizuku 回复 | 微信 | Shizuku 已启动并授权 GotMsg | 是 |
### Android `RemoteInput`
这是最稳定的方式。GotMsg 直接调用原通知提供的快捷回复动作,不需要打开原应用,也不要求原手机停留在聊天界面。原应用若没有为这条通知提供自由文本回复动作,则无法强制使用此方式。
### 无障碍兼容回复
GotMsg 会打开通知指向的会话,识别输入框、写入文本并点击文字明确匹配“发送”的控件。它只对白名单应用生效;输入框已有草稿时会主动停止,避免覆盖原内容。
执行结束后,有 Shizuku 时会把 GotMsg 切回前台;没有 Shizuku 时会通过无障碍返回桌面,避免 QQ 等聊天页持续停在前台后不再产生新消息通知。
### 微信 Shizuku 回复
微信新版会限制普通无障碍输入,因此微信回复必须使用 Shizuku。Android 11 及以上可按 Shizuku 页面提示通过“开发者选项 → 无线调试 → 使用配对码配对设备”启动;非 Root 启动通常会在手机重启后失效,需要重新启动 Shizuku。
GotMsg 首页应显示“Shizuku 已就绪”。若显示未授权,点击授权入口并在 Shizuku 弹窗中允许 GotMsg。
## 锁屏与 PIN 自动解锁
`RemoteInput` 通常可在锁屏状态执行;无障碍和 Shizuku 兼容回复需要进入目标应用,原手机必须先解锁。
已配置 Shizuku 的手机可在“设置 → 通知回复”中开启 PIN 自动解锁:
1. 输入原手机 4–16 位数字锁屏 PIN 并保存。
2. PIN 使用 Android Keystore AES-GCM 加密,只保存在原手机,并排除云备份和设备迁移。
3. 每个回复任务最多提交一次 PIN;失败不会自动重试,避免触发系统锁定策略。
4. 如果任务开始时手机处于锁定状态,回复完成后 GotMsg 会尝试重新锁屏。
手机重启后的第一次解锁仍必须手动完成;Shizuku 未运行时不能自动输入 PIN。图案、字母密码和生物识别不属于该功能支持范围。
## 生效条件与限制
- 回复链接从通知转发起 10 分钟内有效,并且只能成功提交一次。
- 原通知不能被划掉或被新通知替换;进程未重启时 GotMsg 会短暂缓存通知入口,降低通知更新导致的失败概率。
- 原 Android 手机必须联网,GotMsg 进程和通知监听服务需要正常工作。
- 短信 App 中识别出的验证码通知不生成回复入口;付款、订单、营销和系统通知通常也没有可回复的聊天输入框。
- 兼容回复要求通知点击后真正进入对应聊天页;联系人页、登录页、广告页等不会被当作聊天页。
- 某些 App 或定制系统不会向无障碍服务暴露聊天控件;界面更新后也可能需要重新适配。
## 常见失败
| 提示 | 处理方法 |
| --- | --- |
| `回复链接已过期` | 链接有效期为 10 分钟,等待一条新通知再试 |
| `原通知已消失` | 原通知已被清除或替换,等待新通知后再回复 |
| `请在 GotMsg 设置中开启无障碍兼容回复服务` | 同时检查应用内开关和系统“GotMsg 兼容回复”无障碍权限 |
| `Shizuku 未就绪` / `Shizuku 未授权` | 打开 Shizuku 重新启动服务,并授权 GotMsg |
| `原手机需解锁后才能回复` | 手动解锁,或配置 Shizuku PIN 自动解锁 |
| `微信未进入聊天页` | 检查点击原通知能否直接进入对应微信会话,并确认没有登录页或弹窗遮挡 |
| `未找到聊天输入框` / `未找到明确的发送按钮` | 确认进入聊天页、无障碍服务开启;目标 App 更新后可能需要重新适配 |
| `无法确认聊天输入框中的回复内容` | 常见于 QQ 等不公开输入框文本的界面;升级到最新版并保留 `A2iReply` 日志供排查 |
| `聊天输入框已有未发送内容` | GotMsg 为保护原手机草稿而停止;清空草稿后用新通知重试 |
| `原手机未确认回复` | 检查原手机网络、后台限制、通知使用权以及 GotMsg 是否仍在运行 |
## 安全说明
- 每个已创建的回复会话使用独立的 256 位随机回复令牌。令牌位于 URL fragment 中,不会随首次页面请求进入服务器访问日志。
- GotMsg 安装时生成独立设备 ID 和密钥;原手机与中继 API 之间的注册、拉取和确认请求均需设备鉴权。
- 中继只持久化设备密钥和回复令牌的 SHA-256 哈希。提交后的回复正文会短暂保存,处理完成后最多保留约 24 小时,用于状态查询和故障排查。
- 拿到有效回复链接的人可在有效期内提交一次回复,因此不要转发或公开该链接。
- 无障碍和 Shizuku 都是高权限能力,只应在你信任、由你控制的原 Android 手机上启用。
- 回复服务固定使用 `https://r.gotmsg.pp.ua`。自行部署或更换域名时,需要同步修改 `ReplyFeature.RELAY_BASE_URL` 并重新构建 APK。
+6
View File
@@ -0,0 +1,6 @@
org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
org.gradle.parallel=true
org.gradle.caching=true
android.useAndroidX=true
kotlin.code.style=official
android.nonTransitiveRClass=true
+41
View File
@@ -0,0 +1,41 @@
[versions]
agp = "8.9.1"
kotlin = "2.1.0"
coreKtx = "1.15.0"
lifecycle = "2.8.7"
activityCompose = "1.9.3"
composeBom = "2024.12.01"
navigationCompose = "2.8.5"
datastore = "1.1.1"
serializationJson = "1.7.3"
okhttp = "4.12.0"
work = "2.10.0"
documentfile = "1.0.1"
material = "1.12.0"
[libraries]
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
androidx-lifecycle-runtime-ktx = { group = "androidx.lifecycle", name = "lifecycle-runtime-ktx", version.ref = "lifecycle" }
androidx-lifecycle-viewmodel-compose = { group = "androidx.lifecycle", name = "lifecycle-viewmodel-compose", version.ref = "lifecycle" }
androidx-lifecycle-runtime-compose = { group = "androidx.lifecycle", name = "lifecycle-runtime-compose", version.ref = "lifecycle" }
androidx-activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activityCompose" }
androidx-compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "composeBom" }
androidx-ui = { group = "androidx.compose.ui", name = "ui" }
androidx-ui-graphics = { group = "androidx.compose.ui", name = "ui-graphics" }
androidx-ui-tooling = { group = "androidx.compose.ui", name = "ui-tooling" }
androidx-ui-tooling-preview = { group = "androidx.compose.ui", name = "ui-tooling-preview" }
androidx-material3 = { group = "androidx.compose.material3", name = "material3" }
androidx-material-icons-extended = { group = "androidx.compose.material", name = "material-icons-extended" }
androidx-navigation-compose = { group = "androidx.navigation", name = "navigation-compose", version.ref = "navigationCompose" }
androidx-datastore-preferences = { group = "androidx.datastore", name = "datastore-preferences", version.ref = "datastore" }
kotlinx-serialization-json = { group = "org.jetbrains.kotlinx", name = "kotlinx-serialization-json", version.ref = "serializationJson" }
okhttp = { group = "com.squareup.okhttp3", name = "okhttp", version.ref = "okhttp" }
androidx-work-runtime-ktx = { group = "androidx.work", name = "work-runtime-ktx", version.ref = "work" }
androidx-documentfile = { group = "androidx.documentfile", name = "documentfile", version.ref = "documentfile" }
material = { group = "com.google.android.material", name = "material", version.ref = "material" }
[plugins]
android-application = { id = "com.android.application", version.ref = "agp" }
kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" }
kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }
kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" }
Binary file not shown.
+7
View File
@@ -0,0 +1,7 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://mirrors.cloud.tencent.com/gradle/gradle-8.11.1-bin.zip
networkTimeout=120000
validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists
Vendored
+252
View File
@@ -0,0 +1,252 @@
#!/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
' "$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=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
# 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='-Dfile.encoding=UTF-8 "-Xmx64m" "-Xms64m"'
# Collect all arguments for the java command:
# * DEFAULT_JVM_OPTS, JAVA_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" \
org.gradle.wrapper.GradleWrapperMain \
"$@"
# 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" "$@"
Vendored
+94
View File
@@ -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=-Dfile.encoding=UTF-8 "-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=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
@rem Execute Gradle
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %*
: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
Binary file not shown.

After

Width:  |  Height:  |  Size: 4.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 521 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 521 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 609 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 624 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 336 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 562 KiB

After

Width:  |  Height:  |  Size: 290 KiB

+22
View File
@@ -0,0 +1,22 @@
pluginManagement {
repositories {
maven("https://maven.aliyun.com/repository/gradle-plugin")
maven("https://maven.aliyun.com/repository/google")
maven("https://maven.aliyun.com/repository/public")
google()
mavenCentral()
gradlePluginPortal()
}
}
dependencyResolutionManagement {
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
repositories {
maven("https://maven.aliyun.com/repository/google")
maven("https://maven.aliyun.com/repository/public")
google()
mavenCentral()
}
}
rootProject.name = "a2i"
include(":app")