Compare commits

..

7 Commits

Author SHA1 Message Date
song 9ceada798e docs: document SMS verification behavior 2026-07-17 17:25:50 +08:00
song 3157c694e5 docs: explain masked lock-screen codes 2026-07-17 17:11:17 +08:00
song fbc9b67b51 docs: rewrite beginner setup guide 2026-07-17 16:37:03 +08:00
song 4fdd393789 docs: add QQ group QR code 2026-07-17 10:07:34 +08:00
song cc678e0ad0 docs: 重写 README - 突出远程回复 / Shizuku / 应用内更新等 v1.12.x 新功能
- 新增「三种回复路径」章节:原生 RemoteInput / 无障碍兼容 / Shizuku(微信)
- 新增「应用内更新」章节:自动推送更新通知、ABI 自动匹配、双源下载
- 新增「来电与未接来电」独立章节:来电轮询 + CallLog 反查联系人姓名
- 新增「过滤与噪音控制」完整 17 步流水线
- 新增「构建与体积」(R8 + ABI splits)
- 新增「隐私与安全」独立章节
- 强调多通道并行、自动锁回、推送失败先查代理
- 配套链接 docs/notification-reply.md 与 docs/A2iReply-shortcut.md

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-15 19:41:22 +08:00
song 2988069bcd docs: 同步 README/docs(移除闭源声明 + 回复功能说明)
将公开仓库文档同步到最新:
- README 移除闭源声明引用块
- README 第四章更新为 Bark/ntfy 远程回复 + 无障碍兼容回复
- 新增 docs/notification-reply.md

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-15 13:43:22 +08:00
song f08696a793 chore: close source - README + releases only (v1.10.2) 2026-07-12 22:06:54 +08:00
73 changed files with 507 additions and 5381 deletions
-35
View File
@@ -1,35 +0,0 @@
# Gradle / Android build output
.gradle/
.kotlin/
build/
app/build/
# Local machine configuration
local.properties
.do-session
# Secrets — never commit
gh-token.txt
gitea-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
CLAUDE.md
+406 -112
View File
@@ -1,171 +1,465 @@
# A2i ·安到果(按倒过[呲牙]) # GotMsg · 有消息
把安卓手机上的通知(短信、验证码、电话“有电话进来这个通知,不是电话呼叫转移”、微信、QQ、Telegram 等)实时转发到 iOS,通过 [Bark](https://github.com/Finb/Bark) 推送到 iPhone / iPad。在 iOS 上点击通知,还能一键跳转到对应的 App。 <p align="center">
<img src="screenshots/home-latest.png" alt="GotMsg 1.12.19 首页" width="360">
</p>
适用于把安卓备机的消息同步到 iOS 设备,不漏验证码、不漏重要聊天。 <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.12.20-2563EB" alt="Release v1.12.20">
<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/License-All%20Rights%20Reserved-E5484D" alt="License">
</p>
## 主要功能 GotMsg 安装在一台 Android 手机上,读取这台手机收到的系统通知,再转发到 **iPhone、iPad、另一台 Android、鸿蒙手机或邮箱**。短信验证码、来电、微信、QQ、Telegram 和普通 App 通知都可以进入转发链路。
- **系统级通知监听**:接入 Android 通知监听服务,覆盖所有发出通知的 App。 除了“看通知”,GotMsg 还支持从 Bark / ntfy 通知打开一次性网页,把回复送回原 Android 手机:优先使用系统快捷回复,QQ 等 App 可走无障碍兼容回复,微信可走 Shizuku。
- **Bark 推送**:支持官方 Bark 服务和自建 Bark Server,可保存多个服务器并随时切换。
- **验证码自动提取**:自动识别通知中的验证码,收到即复制,也可点击复制。
- **通知优化**:
- 广告关键词过滤(内置 + 自定义)
- 持续性通知过滤(音乐、下载、通话等 ongoing)
- 内容脱敏与去重
- 微信 / QQ / Telegram 专项优化(发送者作标题、按会话分组、重要消息提级、跳转 iOS 对应 App)
- **应用级规则**:黑名单 / 白名单两种模式,可按 App 单独控制是否转发。
- **失败自动重试**:发送失败的通知进入待重发队列,由 WorkManager 定期重试,也可手动触发。
- **本地日志**:保留最近 300 条处理记录,方便排查为什么某条通知被过滤或发送失败。
- **电话来电通知**:通过轮询 `getCallState()` 实时感知来电和未接来电(绕过 MIUI 对系统电话通知的限制,需授予"电话"权限)。
- **断网短信兜底**:无网络时自动把验证码、来电用短信发到 iPhone。
- 自动识别运营商(移动/联通/电信/广电),发免费查询短信拿套餐内短信余量
- 余额 ≤5 条自动停用,下次打开 App 提醒
- 每 5 分钟最多发 1 条,避免刷屏
- 自动解析失败时回退手动额度计数
- **应用内更新**:每天自动检查 GitHub 新版本,发现新版后一键下载并通过系统安装器升级,无需手动去网页下载。
## 快速开始 > [!IMPORTANT]
> GotMsg 不是聊天软件,也不会登录微信或 QQ。它处理的是 Android 通知;原 App 没有产生通知、通知内容被隐藏,或原手机被系统彻底休眠时,GotMsg 就拿不到对应内容。
### 1. 安装 ## 功能一览
从 [Releases](../../releases) 下载最新 APK,安装到安卓手机(需 Android 14 / API 34 及以上)。 | 功能 | 状态 | 说明 |
|---|---:|---|
| 多通道转发 | 🟢 | Bark、ntfy、Meow、SMTP 电邮,可同时启用多条配置 |
| 断网短信兜底 | 🟢 | 原手机断网时,用 SIM 卡短信转发验证码、来电等紧要通知 |
| 验证码提取 | 🟢 | 识别 4–8 位验证码;锁屏通知被星号遮挡时可用 Shizuku 恢复原文,否则安全过滤 |
| App 黑白名单 | 🟢 | 默认转发全部或只转发指定 App |
| 广告与噪音过滤 | 🟢 | 内置规则、自定义关键词、常驻通知过滤和 5 秒去重 |
| 来电与未接来电 | 🟢 | 兼容 MIUI / HyperOS;有权限时补充号码和联系人姓名 |
| 远程回复 | 🟢 | Android `RemoteInput`、无障碍兼容回复、微信 Shizuku 回复 |
| 锁屏 PIN 自动解锁 | 🟡 可选 | 仅 Shizuku 已运行时;PIN 用 Android Keystore 加密保存,每次最多尝试一次 |
| 自动更新 | 🟢 | Gitea 优先、GitHub 备用;自动选择当前手机适配的 APK |
| 日志与失败重试 | 🟢 | 最近 300 条处理记录;发送失败进入待重发队列 |
### 2. 配置 Bark ## 目录
1. 在 iOS 上安装 [Bark](https://apps.apple.com/us/app/bark-customed-push/id1613210986)。 - [先看懂两台手机的角色](#先看懂两台手机的角色)
2. 打开 Bark,复制你的专属推送地址,形如 `https://api.day.app/你的Key`。 - [需要安装哪些 App](#需要安装哪些-app)
3. 在 a2i 的「设置」中添加服务器: - [十分钟上手](#十分钟上手)
- 名称:随意,例如「我的 iPhone」 - [推送通道详细配置](#推送通道详细配置)
- 服务器地址:`https://api.day.app`(自建则填自建地址) - [远程回复详细教程](#远程回复详细教程)
- Device Key:Bark 地址中的那段 Key - [权限和后台保活](#权限和后台保活)
- [过滤、来电、图标和更新](#过滤来电图标和更新)
- [常见问题排查](#常见问题排查)
- [界面截图](#界面截图)
- [隐私与安全](#隐私与安全)
### 3. 授权通知监听 ---
打开 a2i,首页会提示「需要授权通知监听」。点击「前往系统授权」,在系统的通知访问设置中开启 a2i。 ## 先看懂两台手机的角色
> 授权是一次性的,卸载后重新安装需要再次授权。 | 名称 | 是哪台设备 | 要做什么 |
|---|---|---|
| **原手机 / 发送端** | 收到短信、微信、QQ 等通知的 Android 手机 | 安装 GotMsg,授予通知读取权限,保持联网和后台运行 |
| **接收端** | 你随身使用的 iPhone、iPad、Android、鸿蒙手机或电脑 | 根据通道安装 Bark、ntfy、Meow,或直接收邮件 / 短信 |
### 4. 开启转发 举例:一台放在家里的小米 Android 手机插着银行卡手机号,你日常使用 iPhone。GotMsg 安装在小米手机,Bark 安装在 iPhone。
回到首页,打开「通知转发」开关。此时安卓收到的通知就会按规则推送到 iOS。 > [!WARNING]
> 不要把 GotMsg 只安装在接收端。GotMsg 必须安装在“实际收到原始通知”的 Android 手机上。
## 使用建议 ## 需要安装哪些 App
- **只想收验证码**:进入「过滤」页,开启「验证码自动提取」,把「广告过滤」等按需开启;再到「应用」页切到白名单模式,只勾选短信类 App。 ### 原 Android 手机上
- **想同步聊天**:保持黑名单模式(默认),在「过滤」页开启「微信 / QQ / Telegram 优化」。
- **通知没推过来**:先看「日志」页,每条通知(无论转发、过滤还是失败)都会记录原因。
- **想让通知带图标**:参考下方「应用图标」一节。
- **电话通知不工作**:MIUI 会把系统电话通知限制为不向第三方通知监听器分发,本 App 通过轮询 `getCallonyState()` 绕过此限制。需要授予「电话」权限(设置 → 应用 → a2i → 权限 → 电话)。
- **断网收不到通知**:开启「设置 → 断网短信兜底」,填好 iPhone 号码、授予短信权限、设好手动额度。断网时验证码和来电会走短信(消耗套餐额度,5 分钟限 1 条)。
## 应用图标 | App | 是否必须 | 用途 | 安装来源 |
|---|---:|---|---|
| **GotMsg** | 必须 | 读取、过滤和转发通知 | [本仓库 Releases](../../releases) |
| **Shizuku** | 微信回复 / PIN 自动解锁需要;锁屏仍隐藏验证码时可选 | 以 ADB shell 权限操作微信、锁屏,并在短信通知被系统脱敏时读取对应短信原文 | [Shizuku 官网](https://shizuku.rikka.app/download/) / [GitHub Releases](https://github.com/RikkaApps/Shizuku/releases) |
Bark 的 `icon` 字段需要一个公网可访问的图片 URL。a2i 支持把已安装 App 的图标批量导出到你选择的目录,方便上传到你自己的图床。 QQ、支付宝、淘宝和钉钉的兼容回复依赖 Android 系统自带的**无障碍服务**,不需要另外安装无障碍 App。
规则:转发时 `icon = 图标 URL 前缀 + 安卓包名 + .png`。在「设置 → 应用图标」里: ### 接收端
1. 填写图标 URL 前缀,例如 `https://你的图床域名/icons/`。 | 接收端 | 推荐方案 | 需要安装 |
2. 选择一个目录,点「导出图标」,会把所有 App 图标以 `包名.png` 的形式导出。 |---|---|---|
3. 把导出的图片上传到你的图床对应目录。 | 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) |
| 鸿蒙 / HarmonyOS | **Meow** | 从你使用的 Meow 项目或服务提供方安装;需要能取得推送接口和 Device Key / Token |
| 电脑、平板 | 电邮 | 不需要专用 App,使用现有邮件客户端即可 |
| 任意手机 | 断网短信兜底 | 不需要额外 App,使用系统短信应用接收 |
留空前缀则不显示图标。 打开远程回复网页只需要普通浏览器,不需要 iOS 快捷指令,也不需要额外的 `_reply` topic。
## 自建 Bark 服务(可选) ---
a2i 默认用 Bark 官方服务器(`https://api.day.app`),免费够用。如果想完全自控、不限速、不依赖第三方,可以自建 Bark Server。下面是三种零成本方案: ## 十分钟上手
### 方案 1:Vercel 云托管(最适合新手) ### 第 1 步:下载并安装 GotMsg
把 Bark 后端跑在 Vercel 的 Serverless 上,每次推送触发一个云函数,不挂进程、完全免费。 1. 在原 Android 手机上打开 [Releases](../../releases)。
2. 不知道手机架构时,下载文件名带 **`universal`** 的 APK,兼容性最好。
3. 大多数近年的 Android 手机是 ARM64,也可以下载 **`arm64-v8a`**,体积略小。
4. 浏览器提示“禁止安装未知应用”时,进入系统提示页,只允许当前浏览器或文件管理器完成这一次安装。
5. 安装完成后打开 GotMsg。
- 平台:[Vercel](https://vercel.com) · 代码:[GitHub](https://github.com) 系统要求:**Android 14 / API 34 或更高版本**。低于 Android 14 无法安装。
- 在 GitHub 搜索 `bark-server-vercel`,项目 README 里通常有 **Deploy with Vercel** 按钮,一键克隆部署
### 方案 2:Cloudflare Workers(最稳定) ### 第 2 步:先配置一个接收通道
把轻量 JS 转发代码部署到 Cloudflare 全球边缘节点,免费额度每天 10 万次请求,个人用属于「溢出」状态。 第一次使用最推荐:
- 平台:[Cloudflare](https://www.cloudflare.com) - iPhone / iPad:选 **Bark**。
- 在 GitHub 搜索 `bark-server-cloudflare` 或 `bark cloudflare worker`,在 Cloudflare 后台新建 Worker 粘贴脚本保存即可 - Android:选 **ntfy**。
- 鸿蒙:选 **Meow**。
- 只想在电脑查看:选 **电邮**。
### 方案 3:免费 VPS + Docker(最高自主权) 进入 GotMsg 底部 **设置**,找到相应通道,先按下方教程添加配置,再点击该配置右侧的发送按钮测试。接收端收到“GotMsg 测试”才算配置成功。
在永久免费的 Linux 虚拟机上用 Docker 跑官方 bark-server。 ### 第 3 步:授予通知读取权限
- 官方服务端:[finab/bark-server](https://github.com/finab/bark-server) 1. 回到 GotMsg **首页**。
- 反向代理 + 免费 HTTPS 证书:[Nginx Proxy Manager](https://nginxproxymanager.com) 2. 点击“需要授权通知监听”或“前往系统授权”。
- 免费 VPS:[Oracle Cloud 永久免费层](https://www.oracle.com/cloud/free/)、[AWS 一年免费套餐](https://aws.amazon.com/free/) 3. 在系统“设备和应用通知 / 通知使用权 / 通知访问”页面找到 GotMsg。
4. 打开允许开关并确认风险提示。
5. 返回 GotMsg,首页应显示 **“通知监听已开启 / 已授权”**。
### ⚠️ 防坑提示 这项权限是核心权限;普通“允许 GotMsg 自己弹通知”不能代替通知读取权限。
国内网络下,Vercel 的 `*.vercel.app` 和 Cloudflare 的 `*.workers.dev` 二级域名可能间歇性无法访问。**终极方案**:注册一个便宜的个人域名(或找免费域名),解析托管到 Cloudflare,然后把 Vercel / Worker 绑到**自定义域名**上——既稳定又秒达。 ### 第 4 步:开启转发并实测
自建完成后,在「设置 → Bark 服务器」里填自建地址即可。 1. 首页打开 **通知转发**。
2. 用另一台手机给原手机发一条短信或聊天消息。
3. 接收端应在几秒内收到对应通知。
4. 如果没有收到,先打开 GotMsg **首页 → 查看转发日志**,日志会说明是已发送、被过滤还是发送失败。
## 技术栈 ### 第 5 步:设置后台运行
- Kotlin + Jetpack Compose(Material 3) 完成基础测试后,务必按[权限和后台保活](#权限和后台保活)设置自启动、后台无限制和省电白名单,否则锁屏一段时间后可能停止转发。
- AndroidX DataStore(持久化设置与日志)
- OkHttp(Bark 推送)
- WorkManager(失败重试)
- Navigation Compose(单 Activity 多页面)
- 最低 Android 14(API 34),目标 Android 16(API 36)
## 本地构建 ---
需要 JDK 17 和 Android SDK。 ## 推送通道详细配置
```bash 各通道都支持添加多条配置、单独启停和排序;Bark、ntfy、Meow 和电邮配置还可单独发送测试。“首个成功即止”适合相同通道的备用目标;关闭后会向该通道所有已启用配置广播。
# 设置环境变量(按你的安装路径)
export JAVA_HOME=/path/to/jdk-17
export ANDROID_HOME=/path/to/android-sdk
# 构建 debug APK ### Bark:发送到 iPhone / iPad
./gradlew assembleDebug
# 构建可分发的 release APK 1. 在 iPhone / iPad 安装 [Bark](https://apps.apple.com/app/bark-custom-notifications/id1403753865)。
./gradlew assembleRelease 2. 第一次打开 Bark 时允许通知。
``` 3. Bark 首页会显示类似 `https://api.day.app/xxxxxxxx` 的推送地址,复制它。
4. 原 Android 手机打开 GotMsg → **设置 → Bark 服务器 → 添加**。
5. 按下面填写:
产物位置: | 字段 | 填写内容 |
|---|---|
| 名称 | 自定义,例如“我的 iPhone” |
| 服务器地址 | 官方服务填 `https://api.day.app`,不要带最后的 Device Key |
| Device Key | Bark 地址最后 `/` 后面的字符串 |
- debug:`app/build/outputs/apk/debug/app-debug.apk` 6. 保存后点击配置右侧的发送按钮。
- release:`app/build/outputs/apk/release/app-release.apk` 7. iPhone 收到“GotMsg 测试”即成功。
安装到已连接的设备: 远程回复启用后,点击 Bark 通知会进入 GotMsg 回复网页。为避免重复,回复地址不再同时显示在通知正文里。
```bash ### ntfy:发送到 Android / iOS / 网页
adb install -r app/build/outputs/apk/release/app-release.apk
``` 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. 官方服务器填写 `https://ntfy.sh`。
4. Topic 使用一串不容易猜到的随机名称,例如 `gotmsg_7f3a9c2e`。
5. 原 Android 手机打开 GotMsg → **设置 → ntfy 转发 → 添加**。
6. 服务器和 Topic 必须与接收端完全一致;Token 只有在自建 ntfy 并开启鉴权时才填写。
7. 保存并发送测试。
> [!CAUTION]
> `ntfy.sh` 的公共 Topic 类似“知道名字就能进入的房间”。不要使用手机号、姓名、邮箱或 `test` 这类容易猜到的 Topic。需要更强隐私时请自建 ntfy 并启用访问控制。
远程回复启用后,ntfy 通知会带“回复”动作,点击通知也会进入回复页;正文不再重复附加相同链接。
### Meow:发送到鸿蒙手机
Meow 不是 GotMsg 内置组件,需要先在鸿蒙手机上安装并完成 Meow 自身的推送注册。不同 Meow 服务的分发方式和接口格式可能不同,请以你所使用的 Meow 项目说明为准。
1. 在鸿蒙手机打开 Meow,确认它能正常接收测试推送。
2. 在 Meow 中找到完整推送接口 / 服务器地址和 Device Key / Token。
3. GotMsg → **设置 → Meow 转发 → 添加**。
4. 名称用于区分设备;服务器填写 Meow 给出的地址;Device Key 填 Meow 的设备 Key。
5. 保存后发送测试。
不要在 Device Key 中填写鸿蒙锁屏密码、华为账号密码或短信验证码。
### SMTP 电邮
GotMsg 直接连接 SMTP 服务器发送邮件。QQ 邮箱、163、Gmail、Outlook 等通常需要先在邮箱安全设置里开启 SMTP,并生成**授权码 / 应用专用密码**,不建议直接填写网页登录密码。
| 字段 | 示例 |
|---|---|
| SMTP 主机 | `smtp.qq.com`、`smtp.163.com`、`smtp.gmail.com` |
| 端口 | 国内邮箱通常用 `465`(隐式 SSL) |
| 账号 | 完整邮箱地址 |
| 授权码 / 密码 | 邮箱后台生成的 SMTP 授权码 |
| 发件人 | 通常与账号一致 |
| 收件人 | 实际接收通知的邮箱 |
保存后务必发送测试。如果超时,先检查端口、授权码、运营商网络是否拦截 SMTP。
### 断网短信兜底
该功能在原 Android 手机**没有网络**时,用原手机 SIM 卡发送短信,因此会消耗短信套餐。
1. GotMsg → **设置 → 断网短信兜底**,授予“发送短信”权限。
2. 添加接收手机号并启用。
3. 默认“仅紧要通知”只发送验证码和来电;关闭后会尝试发送全部通知。
4. 建议保持限频,例如每 5 分钟最多 1 条。
5. 开启余额检查后,余额低于或等于 5 时自动挂起;也可以使用手动短信额度。
> [!WARNING]
> 短信兜底不是普通在线转发通道。手机有网络时,通知仍走 Bark、ntfy、Meow 或邮件。
---
## 远程回复详细教程
### 回复链路
1. 原手机收到通知并转发到 Bark / ntfy。
2. 接收端点击通知或“回复”动作,打开 `https://r.gotmsg.pp.ua`。
3. 页面显示原通知的应用、标题和正文,输入回复内容并提交。
4. 原手机上的 GotMsg 拉取任务并执行回复。
5. 页面显示成功或具体失败原因。
链接从通知转发起 **10 分钟内有效**,并且只允许成功提交一次。Bark / ntfy 转发通知通常会生成回复入口;**短信 App 中识别出的验证码通知不添加回复链接**,因为验证码只需要读取或复制,不需要回复。如果其它通知既没有系统快捷回复,也不属于兼容白名单,页面会明确返回不支持,而不会盲目操作原 App。
### 三种回复方式
| 方式 | 典型 App | 原手机额外要求 | 是否打开聊天页 |
|---|---|---|---:|
| Android `RemoteInput` | Telegram、WhatsApp、Signal、部分短信和企业 IM;以该条通知实际提供的动作为准 | 无 | 否 |
| 无障碍兼容回复 | QQ、支付宝、淘宝、钉钉 | 开启 GotMsg 兼容回复无障碍服务 | 是 |
| Shizuku 回复 | 微信 | 安装并启动 Shizuku,授权 GotMsg | 是 |
同一个 App 并非所有通知都支持快捷回复:聊天通知可能支持,登录、营销、付款、订单和系统通知通常不支持。
### QQ、支付宝、淘宝、钉钉:开启无障碍兼容回复
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。
1. 从 [Shizuku 官网](https://shizuku.rikka.app/download/) 或 [GitHub Releases](https://github.com/RikkaApps/Shizuku/releases) 安装 Shizuku。
2. Android 11 及以上推荐在 Shizuku 内按提示使用“无线调试”启动:
- 系统设置 → 关于手机,连续点击版本号,打开开发者选项;
- 开发者选项 → 无线调试;
- 在 Shizuku 中选择配对,按页面提示输入系统配对码;
- 配对完成后回到 Shizuku 点击启动。
3. 打开 GotMsg 首页,Shizuku 状态卡应显示 **“Shizuku 已就绪”**。
4. 如果显示“未授权”,点击 **授权 Shizuku**,并在 Shizuku 弹窗中允许 GotMsg。
5. GotMsg → **设置 → 通知回复**,打开“无障碍兼容回复”总开关。微信实际执行走 Shizuku;仅为了微信时可以不启用系统无障碍服务。
非 Root 方式启动的 Shizuku 在手机重启后通常需要重新启动。GotMsg 首页会直接显示当前状态。
Shizuku 也用于恢复锁屏时被系统短信 App 显示成 `******` 的验证码。GotMsg 只在检测到脱敏验证码通知时,按通知时间查找收件箱中对应的最近短信;确认能提取出真实 4–8 位验证码才转发。Shizuku 未就绪或无法确认对应短信时,该通知会直接过滤,不会把星号内容发到接收端。
### 可选:Shizuku PIN 自动解锁
适合原手机平时锁屏放置、希望收到远程回复后自动亮屏执行的场景。
1. 确认 Shizuku 已就绪。
2. GotMsg → **设置 → 通知回复**。
3. 输入原手机 4–16 位数字锁屏 PIN,点击“保存并启用”。
4. PIN 使用 Android Keystore AES-GCM 加密,仅保存在原手机,并排除云备份和设备迁移。
5. 每个回复任务最多提交一次 PIN;失败不会自动重试,避免触发系统锁定策略。
限制:手机重启后的第一次解锁仍必须手动完成;Shizuku 未运行时不能自动输入 PIN;图案、字母密码和生物识别不能代替这里的数字 PIN。
完整技术说明和错误含义见 [通知远程回复文档](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。
### 过滤与验证码
“过滤”页可控制持续通知、验证码提取、广告过滤和特殊 App 优化。内置规则会过滤 VPN / 代理常驻通知、系统“正在运行”、近空内容和泛化“你有一条新消息”等噪音;用户可继续添加自定义广告关键词。
每条通知在 5 秒窗口内去重。微信、QQ、Telegram 会进行专门解析;验证码可自动复制,重要通知会提高推送级别。
### 来电和未接来电
MIUI / HyperOS 经常不把系统电话通知交给第三方监听器。GotMsg 会额外轮询电话状态:
- 来电时推送“有电话进来”;无障碍若能读取来电界面,可补充号码。
- 通话结束后读取通话记录,识别未接号码;有联系人权限时显示姓名。
### 应用图标
在 **设置 → 应用图标** 填写公网图标 URL 前缀,并把已安装 App 图标导出到选定目录。上传后,GotMsg 按 `URL 前缀 + 包名 + .png` 生成通知图标地址。留空则不附加自定义图标。
### 应用内更新
- 启动约 5 秒后检查一次,之后每 24 小时巡检。
- 同时查询 Gitea 和 GitHub;同版本优先 Gitea,取两边可用的最高版本。
- 升级弹窗只显示当前目标版本和本次发版说明,不再展示一长串 ABI 安装包。
- App 自动选择当前手机 ABI,对应包不存在时使用 universal。
- 安装前 Android 会要求允许 GotMsg“安装未知应用”。
---
## 常见问题排查
| 现象 / 报错 | 处理方法 |
|---|---|
| 完全没有转发 | 首页确认“通知监听已开启”和“通知转发”已打开;再检查至少一个通道配置已启用 |
| 测试推送成功,锁屏后不再转发 | 设置电池无限制、自启动和最近任务锁定;检查系统是否撤销通知使用权 |
| 日志显示“已过滤” | 点开该条日志查看原因;检查黑白名单、广告关键词、持续通知和特殊 App 规则 |
| Bark 收不到 | 检查服务器地址不要带 Device Key;Device Key 不要漏字符;暂时关闭 VPN / 代理测试 |
| ntfy 收不到 | 接收端与 GotMsg 的服务器和 Topic 必须完全一致;确认 ntfy App 允许通知和后台运行 |
| Meow 收不到 | 先确认 Meow 自身能收测试;检查完整接口地址和 Device Key / Token |
| 邮件发送失败 | 使用 SMTP 授权码而不是网页登录密码;检查 465/SSL、发件人和网络限制 |
| 锁屏后收到的验证码变成 `******` | 优先把系统和短信 App 的锁屏通知改为“显示内容”;如果希望继续隐藏锁屏内容,再启动并授权 Shizuku。无法确认真实原文时 GotMsg 会安全过滤 |
| 回复链接已过期 | 链接有效期 10 分钟;等待一条新通知再回复 |
| 原通知已消失 | 不要划掉原手机通知;等待新通知重新生成会话 |
| 微信提示 Shizuku 未就绪 | 打开 Shizuku 重新启动服务并授权 GotMsg;重启手机后通常需要重新启动 Shizuku |
| 微信未进入聊天页 | 确认通知点击本身能打开对应聊天,而不是联系人页、登录页或广告页 |
| QQ 找不到输入框 / 发送按钮 | 确认系统无障碍服务已开启;QQ 界面升级后可能需要重新适配,请保留 `A2iReply` 日志 |
| 原手机设置了 PIN | 配置 Shizuku PIN 自动解锁,或先手动解锁再回复;首次开机解锁不能自动完成 |
| 输入框已有草稿 | GotMsg 为避免覆盖原内容会主动停止;先在原手机处理草稿 |
| 下载后不能安装更新 | 系统设置 → 特殊应用权限 → 安装未知应用 → 允许 GotMsg |
日志页保存最近 300 条处理记录。发送失败会进入“待重发”,联网后由系统后台任务继续尝试。
---
## 界面截图 ## 界面截图
| 首页 | 应用管理 | 过滤设置 | 原 README 首图 `overview.png` 已移动到本节,作为多通道设置界面展示。
| 多通道设置 | 应用管理 | 过滤设置 |
|---|---|---| |---|---|---|
| ![首页](screenshots/home.png) | ![应用管理](screenshots/apps.png) | ![过滤设置](screenshots/filter.png) | | ![多通道设置](screenshots/overview.png) | ![应用管理](screenshots/apps.png) | ![过滤设置](screenshots/filter.png) |
| 设置 | 转发日志 | | 设置 | 转发日志 |
|---|---| |---|---|
| ![设置](screenshots/settings.png) | ![转发日志](screenshots/log.png) | | ![设置](screenshots/settings.png) | ![转发日志](screenshots/log.png) |
## 工作原理 ---
``` ## QQ 交流群
Android 系统通知
└─ NotifyListenerService 接收 配置、转发或远程回复遇到问题,可扫码加入 QQ 群。反馈回复问题时,建议同时提供 GotMsg 版本、手机型号、Android 版本、目标 App 版本和日志中的完整报错。
└─ NotificationProcessor 处理流水线
├─ 全局开关 / ongoing 过滤 / 黑白名单 <p align="center">
├─ 空内容、脱敏、噪音、系统状态过滤 <img src="screenshots/qq-group.png" alt="GotMsg QQ 群二维码" width="360">
├─ 广告关键词过滤 </p>
├─ 去重(5 秒窗口)
├─ 验证码提取 ---
└─ 微信 / QQ / Telegram 专项解析
└─ BarkClient 推送到 Bark Server ## 隐私与安全
└─ 失败 → 待重发队列 → RetryWorker 定期重试
└─ 写入本地日志(最近 300 条) ### 通知内容会经过哪里
- Bark、ntfy、Meow、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。
### 本地敏感数据
- Bark Key、ntfy Token、SMTP 配置等保存在 Android 应用私有存储中,请保护原手机锁屏和系统账号。
- Shizuku 自动解锁 PIN 使用 Android Keystore AES-GCM 加密,且排除云备份与设备迁移;关闭功能后可在设置中删除 PIN。
- 无障碍和 Shizuku 权限能力较高,只应在你信任、由你控制的原 Android 手机上启用。
- 短信通知中的验证码被锁屏策略替换为星号时,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
``` ```
## License 用 Nginx / Caddy 配置 HTTPS 后,GotMsg 的服务器地址填写站点根地址,不要追加 `/push`。详情见 [Bark 文档](https://bark.day.app/#/) 和 [Bark GitHub](https://github.com/Finb/Bark)。
本项目仅供个人使用。 ### 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>
## 发布与许可
- 最新安装包:[Releases](../../releases)
- Android 最低版本:Android 14 / API 34
- 自 v1.10.2 起项目闭源,公开仓库保留用户文档、截图和 Release 安装包。
- 本项目仅供个人使用,**All Rights Reserved / 保留所有权利**。
-75
View File
@@ -1,75 +0,0 @@
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 = 21
versionName = "1.8.4"
}
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
@@ -1,2 +0,0 @@
# Keep Bark message model fields used by kotlinx.serialization
-keepclassmembers class com.a2i.forwarder.model.** { *; }
-60
View File
@@ -1,60 +0,0 @@
<?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>
@@ -1,57 +0,0 @@
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
}
}
@@ -1,20 +0,0 @@
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()
}
}
}
}
@@ -1,16 +0,0 @@
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
}
}
@@ -1,36 +0,0 @@
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,
)
}
}
@@ -1,62 +0,0 @@
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.copy(deviceKey = server.deviceKey)).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)
}
}
@@ -1,41 +0,0 @@
package com.a2i.forwarder.core
import com.a2i.forwarder.model.BarkMessage
import com.a2i.forwarder.model.BarkServer
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
/**
* 多服务器容错推送:按列表顺序依次尝试,首个成功即止。
* 全失败 → 返回最后一个错误;空列表 → "无可用服务器"。
*/
object BarkSender {
/** stopOnFirst=true:首个成功即止;false:全部发送,至少一个成功即算成功。 */
suspend fun pushToEnabled(servers: List<BarkServer>, msg: BarkMessage, stopOnFirst: Boolean): Result<Unit> = withContext(Dispatchers.IO) {
runCatching {
require(servers.any()) { "无可用服务器" }
if (stopOnFirst) return@runCatching pushToFirst(servers, msg).getOrThrow()
var anyOk = false
var lastErr: Throwable? = null
for (s in servers) {
val r = BarkClient(s).push(msg)
if (r.isSuccess) anyOk = true else lastErr = r.exceptionOrNull()
}
if (!anyOk) throw lastErr ?: error("全部推送失败")
}
}
/** 首个成功即止(stopOnFirst=true 的实现)。 */
suspend fun pushToFirst(servers: List<BarkServer>, msg: BarkMessage): Result<Unit> = withContext(Dispatchers.IO) {
runCatching {
require(servers.any()) { "无可用服务器" }
var lastErr: Throwable? = null
for (s in servers) {
val r = BarkClient(s).push(msg)
if (r.isSuccess) return@runCatching
lastErr = r.exceptionOrNull()
}
throw lastErr ?: error("推送失败")
}
}
}
@@ -1,90 +0,0 @@
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)
}
}
}
}
@@ -1,54 +0,0 @@
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)
}
}
@@ -1,25 +0,0 @@
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
}
}
@@ -1,54 +0,0 @@
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)
}
}
@@ -1,130 +0,0 @@
package com.a2i.forwarder.core
import com.a2i.forwarder.A2iApp
import com.a2i.forwarder.model.EmailServer
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import java.io.BufferedReader
import java.io.BufferedWriter
import java.io.InputStreamReader
import java.io.OutputStreamWriter
import java.util.Base64
import javax.net.ssl.SSLSocket
import javax.net.ssl.SSLSocketFactory
/**
* SMTP 直连发送纯文本邮件(SMTPS/隐式 TLS,端口 465)。
* 在 [Dispatchers.IO] 上执行,不阻塞主线程。
*/
object EmailSender {
suspend fun send(
host: String, port: Int, user: String, password: String,
from: String, to: String, subject: String, body: String,
): Result<Unit> = withContext(Dispatchers.IO) {
runCatching {
val factory = SSLSocketFactory.getDefault()
// 隐式 TLS:直接通过 SSLSocketFactory 创建 SSLSocket 连接 SMTP 服务器(推荐端口 465)
val ssl = factory.createSocket(host, port) as SSLSocket
ssl.soTimeout = 15_000
ssl.startHandshake()
val reader: BufferedReader = ssl.inputStream.bufferedReader()
val writer: BufferedWriter = ssl.outputStream.bufferedWriter()
fun read(): String = reader.readLine() ?: error("连接关闭")
fun cmd(c: String) { writer.write("$c\r\n"); writer.flush() }
fun expectEhlo(): String {
cmd("EHLO a2i")
var last = ""
do { last = read() } while (last.startsWith("250-"))
if (!last.startsWith("250 ")) error("EHLO: $last")
return last
}
var r = read()
if (!r.startsWith("220")) error("SMTP: $r")
expectEhlo()
if (user.isNotBlank() && password.isNotBlank()) {
cmd("AUTH LOGIN")
r = read()
if (!r.startsWith("334")) error("AUTH: $r")
cmd(Base64.getEncoder().encodeToString(user.toByteArray()))
r = read()
if (!r.startsWith("334")) error("AUTH user: $r")
cmd(Base64.getEncoder().encodeToString(password.toByteArray()))
r = read()
if (!r.startsWith("235")) error("AUTH pass: $r")
}
cmd("MAIL FROM:<$from>")
r = read()
if (!r.startsWith("250")) error("FROM: $r")
cmd("RCPT TO:<$to>")
r = read()
if (!r.startsWith("250")) error("TO: $r")
cmd("DATA")
r = read()
if (!r.startsWith("354")) error("DATA: $r")
writer.write(buildMime(from, to, subject, body))
writer.write("\r\n.\r\n")
writer.flush()
r = read()
if (!r.startsWith("250")) error("SEND: $r")
cmd("QUIT")
runCatching { ssl.close() }
Unit
}
}
private fun buildMime(from: String, to: String, subject: String, body: String): String {
val subjB64 = Base64.getEncoder().encodeToString(subject.toByteArray(Charsets.UTF_8))
val bodyB64 = Base64.getEncoder().encodeToString(body.toByteArray(Charsets.UTF_8))
return buildString {
append("From: <$from>\r\n")
append("To: <$to>\r\n")
append("Subject: =?UTF-8?B?$subjB64?=\r\n")
append("MIME-Version: 1.0\r\n")
append("Content-Type: text/plain; charset=UTF-8\r\n")
append("Content-Transfer-Encoding: base64\r\n")
append("\r\n")
append(bodyB64)
}
}
/** 多配置推送:stopOnFirst=true 首个成功止;false 全部发送,至少一个成功即算成功。 */
suspend fun pushToEnabled(configs: List<EmailServer>, title: String, body: String, stopOnFirst: Boolean): Result<Unit> = withContext(Dispatchers.IO) {
runCatching {
require(configs.any()) { "无邮箱配置" }
var anyOk = false
var lastErr: Throwable? = null
for (c in configs) {
val r = send(c.host, c.port, c.user, c.password, c.from, c.to, title, body)
if (r.isSuccess) {
anyOk = true
if (stopOnFirst) return@runCatching
} else {
lastErr = r.exceptionOrNull()
}
}
if (!anyOk) throw lastErr ?: error("全部邮件发送失败")
}
}
/** 如果电邮转发已启用且收件人非空,异步发邮件(fire-and-forget,不阻塞主流程)。 */
fun trySend(app: A2iApp, title: String, body: String, scope: CoroutineScope) {
val s = app.settings
if (!s.emailForwardingEnabled.value) return
val configs = s.emailServers.value.filter { it.enabled && it.to.isNotBlank() && it.host.isNotBlank() }
if (configs.isEmpty()) return
scope.launch {
pushToEnabled(configs, title, body, s.emailStopOnFirst.value)
}
}
}
@@ -1,55 +0,0 @@
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
}
}
@@ -1,101 +0,0 @@
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
}
}
@@ -1,262 +0,0 @@
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",
)
// 静默过滤的包名:代理/VPN 客户端的常驻流量通知,高频且无意义,过滤不写日志
private val silentPackages = setOf(
"com.getsurfboard", // Surfboard
"com.github.metacubex.clash.meta", // Clash Meta for Android
"com.github.kr328.clash", // Clash for Android
"com.v2ray.ang", // v2rayNG
"com.milink.service", // 小米设备互联(UniversalClipboardService 跨设备剪贴板等常驻通知)
)
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)
// ---- 静默包名过滤(VPN/代理客户端的常驻流量通知,高频无意义,不写日志)----
if (pkg in silentPackages)
return Decision(null, "常驻通知静默", appLabel, null, silent = true)
// ---- 米家泛化状态通知静默("设备状态"等概览;门锁/告警等具体事件保留)----
if (pkg == "com.xiaomi.smarthome" && content.contains("设备状态"))
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 = "",
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
}
}
@@ -1,102 +0,0 @@
package com.a2i.forwarder.core
import com.a2i.forwarder.A2iApp
import com.a2i.forwarder.model.NtfyServer
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.RequestBody.Companion.toRequestBody
import org.json.JSONObject
import java.io.IOException
import java.util.concurrent.TimeUnit
/**
* ntfy 推送:用 JSON publish API(POST / with {topic,title,message})。
* 注:raw body 会被 ntfy 当附件,必须用 JSON API 或 Message header。
* 对瞬时 IOException 自动重试 3 次(退避 600/1200ms)。
*/
object NtfySender {
private val http = OkHttpClient.Builder()
.connectTimeout(10, TimeUnit.SECONDS)
.writeTimeout(10, TimeUnit.SECONDS)
.readTimeout(15, TimeUnit.SECONDS)
.retryOnConnectionFailure(true)
.build()
suspend fun send(
server: String, topic: String, token: String,
title: String, body: String,
): Result<Unit> = withContext(Dispatchers.IO) {
runCatching {
val payload = JSONObject()
.put("topic", topic.trim())
.put("title", title.take(200))
.put("message", body.take(4000))
.toString()
val req = Request.Builder()
.url(server.trim().removeSuffix("/"))
.apply { if (token.isNotBlank()) header("Authorization", "Bearer $token") }
.post(payload.toRequestBody(JSON))
.build()
doPushWithRetry(req)
}
}
/** 多配置推送:stopOnFirst=true 首个成功止;false 全部发送,至少一个成功即算成功。 */
suspend fun pushToEnabled(configs: List<NtfyServer>, title: String, body: String, stopOnFirst: Boolean): Result<Unit> = withContext(Dispatchers.IO) {
runCatching {
require(configs.any()) { "无 ntfy 配置" }
var anyOk = false
var lastErr: Throwable? = null
for (c in configs) {
val r = send(c.server, c.topic, c.token, title, body)
if (r.isSuccess) {
anyOk = true
if (stopOnFirst) return@runCatching
} else {
lastErr = r.exceptionOrNull()
}
}
if (!anyOk) throw lastErr ?: error("全部 ntfy 推送失败")
}
}
private fun doPushWithRetry(req: Request) {
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) {
Thread.sleep(BACKOFF_MS[attempt])
}
} catch (e: IllegalStateException) {
// HTTP 错误码(error() 抛 IllegalStateException),不重试
throw e
}
}
throw lastError ?: error("ntfy 推送失败")
}
fun trySend(app: A2iApp, title: String, body: String, scope: CoroutineScope) {
val s = app.settings
val configs = s.ntfyServers.value.filter { it.enabled && it.topic.isNotBlank() }
if (configs.isEmpty()) return
scope.launch {
pushToEnabled(configs, title, body, s.ntfyStopOnFirst.value)
}
}
private val JSON = "application/json; charset=utf-8".toMediaType()
private const val MAX_ATTEMPTS = 3
private val BACKOFF_MS = longArrayOf(600L, 1200L)
}
@@ -1,151 +0,0 @@
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
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 = "",
title = "来电",
body = "有电话进来",
level = "timeSensitive",
group = "phone_call",
isArchive = "1",
)
pushToServers(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 = "",
title = "未接来电",
body = "未接来电",
level = "timeSensitive",
group = "phone_call",
isArchive = "1",
)
pushToServers(msg, app, "未接来电")
}
ringing = false
offhook = false
}
// OFFHOOK → IDLE:正常挂断,不推送
from == TelephonyManager.CALL_STATE_OFFHOOK && to == TelephonyManager.CALL_STATE_IDLE -> {
ringing = false
offhook = false
}
}
}
private fun pushToServers(msg: BarkMessage, app: A2iApp, logBody: String) {
scope.launch {
val servers = app.settings.servers.value.filter { it.enabled && it.deviceKey.isNotBlank() }
if (servers.isEmpty()) {
EmailSender.trySend(app, msg.title ?: logBody, msg.body ?: "", scope)
NtfySender.trySend(app, msg.title ?: logBody, msg.body ?: "", scope)
return@launch
}
val result = BarkSender.pushToEnabled(servers, msg, app.settings.barkStopOnFirst.value)
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, servers.first().id, json.encodeToString(msg))
)
}
EmailSender.trySend(app, msg.title ?: logBody, msg.body ?: "", scope)
NtfySender.trySend(app, msg.title ?: logBody, msg.body ?: "", scope)
}
}
}
@@ -1,50 +0,0 @@
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 }
val servers = settings.servers.value.filter { it.enabled && it.deviceKey.isNotBlank() }
for (p in list) {
if (servers.isEmpty()) { allOk = false; break }
val msg = runCatching { json.decodeFromString<com.a2i.forwarder.model.BarkMessage>(p.messageJson) }.getOrNull() ?: continue
val r = BarkSender.pushToFirst(servers, 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)
}
}
}
@@ -1,117 +0,0 @@
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("未接")
}
/** 尝试用短信转发(遍历所有启用的目标号,按 stopOnFirst 决定发通一条止还是全发)。 */
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 targets = settings.smsTargets.value.filter { it.enabled && it.number.isNotBlank() }
if (targets.isEmpty()) {
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 stopOnFirst = settings.smsStopOnFirst.value
var anyOk = false
var sentCount = 0
var lastReason = "短信发送失败"
for (t in targets) {
val result = runCatching {
SmsManager.getDefault().sendTextMessage(t.number.trim(), null, body, null, null)
}
if (result.isSuccess) {
anyOk = true
sentCount++
if (stopOnFirst) break
} else {
lastReason = result.exceptionOrNull()?.message ?: "短信发送失败"
}
}
if (anyOk) {
settings.setLastSmsSentTime(now)
log(d, "sent", "")
// 余额递减:按成功发送条数;自动模式发查询短信,手动模式递减
val carrier = app.carrierBalanceQuery.currentCarrier()
if (carrier.serviceNumber != null && carrier.queryCmd != null) {
app.carrierBalanceQuery.refresh()
} else {
repeat(sentCount) { runCatching { app.carrierBalanceQuery.decrementManual() } }
}
} else {
log(d, "failed", lastReason)
}
}
}
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)
)
}
}
}
@@ -1,280 +0,0 @@
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.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.isActive
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,
)
/**
* 应用内更新:定时巡检 Gitea 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)
val lastError = MutableStateFlow<String?>(null)
private var lastNotifiedVersion: String? = null // 记录已推送的版本,避免重复通知
fun start() {
scope.launch {
delay(5000) // 启动 5 秒后首检,给网络/应用初始化留时间
doCheck(throttle = false, toggle = true) // 启动必查(无视节流,但尊重开关)
while (isActive) {
delay(CHECK_INTERVAL_MS) // 常驻期间每 24h 巡检一次
doCheck(throttle = true, toggle = true)
}
}
}
/** 手动检查:无视节流和开关(用户主动)。 */
suspend fun check(force: Boolean): Result<UpdateInfo?> = doCheck(throttle = !force, toggle = !force)
private suspend fun doCheck(throttle: Boolean, toggle: Boolean): Result<UpdateInfo?> = withContext(Dispatchers.IO) {
runCatching {
val settings = app.settings
if (toggle && !settings.updateCheckEnabled.value) return@runCatching updateInfo.value
val now = System.currentTimeMillis()
if (throttle && now - settings.lastUpdateCheckTime.value < CHECK_INTERVAL_MS) return@runCatching updateInfo.value
// 先查 GitHub(公开默认源),从其 release notes 动态发现额外源(如自建 Gitea),取最高版本
val infos = mutableListOf<UpdateInfo>()
val gh = runCatching { fetchRelease(GITHUB_API) }.getOrNull()
if (gh != null) {
infos.add(gh)
parseUpdateSource(gh.releaseNotes)?.let { src ->
runCatching { fetchRelease(src) }.getOrNull()?.let { infos.add(it) }
}
}
settings.setLastUpdateCheck(now)
if (infos.isEmpty()) {
lastError.value = "所有更新源不可达"
return@runCatching updateInfo.value
}
val current = currentVersionName()
val best = infos
.filter { isNewer(it.latestVersion, current) && it.latestVersion != settings.ignoredVersion.value }
.maxByOrNull { versionKey(it.latestVersion) }
val prev = updateInfo.value
if (best == null) {
updateInfo.value = null
lastNotifiedVersion = null
} else {
updateInfo.value = best
}
lastError.value = null
// 发现新版且之前没推过通知 → 自动推送到所有已开启通道
if (best != null && best.latestVersion != lastNotifiedVersion) {
lastNotifiedVersion = best.latestVersion
notifyNewVersion(prev, best)
}
updateInfo.value
}.onFailure { lastError.value = it.message ?: "检查失败" }
}
private fun fetchRelease(api: String): UpdateInfo? {
val req = Request.Builder()
.url(api)
.header("Accept", "application/json")
.header("User-Agent", "a2i-android")
.get()
.build()
return http.newCall(req).execute().use { res ->
if (!res.isSuccessful) error("HTTP ${res.code}")
parseRelease(res.body?.string() ?: error("空响应"))
}
}
private fun versionKey(v: String): String =
v.split(".").joinToString(".") { (it.substringBefore("-").toIntOrNull() ?: 0).toString().padStart(4, '0') }
/** 发现新版后,自动通过所有已开启的推送通道通知用户 */
private fun notifyNewVersion(prev: UpdateInfo?, info: UpdateInfo) {
val s = app.settings
val title = "🆕 a2i 新版本 v${info.latestVersion}"
val body = "发现 v${info.latestVersion}(当前 v${currentVersionName()})\n${formatSize(info.apkSize)} · 点击更新\n\n${info.releaseNotes.take(300)}"
val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
// Bark:通过 BarkSender 推送给第一个启用的服务器
val barkServers = s.servers.value.filter { it.enabled && it.deviceKey.isNotBlank() }
if (barkServers.isNotEmpty()) {
scope.launch {
val msg = com.a2i.forwarder.model.BarkMessage(
deviceKey = "", title = title, body = body, level = "timeSensitive",
group = "a2i_update", isArchive = "1",
)
BarkSender.pushToFirst(barkServers, msg)
}
}
// ntfy
NtfySender.trySend(app, title, body, scope)
// 电邮
EmailSender.trySend(app, title, body, scope)
}
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) {
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")
}
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
}
/** 从 release notes 的 HTML 注释里解析额外更新源(加扰编码,兼容 Base64/明文)。 */
private fun parseUpdateSource(body: String): String? {
val raw = Regex("""update-source:\s*(\S+)""").find(body)?.groupValues?.getOrNull(1) ?: return null
// 加扰格式:Base64(反转(XOR(明文, key)))。尝试完整解扰,失败则依次尝试 Base64/明文。
val b64 = runCatching { java.util.Base64.getDecoder().decode(raw) }.getOrNull() ?: return raw
val unscrambled = unscramble(b64)
return unscrambled ?: runCatching { String(b64, Charsets.UTF_8) }.getOrNull() ?: raw
}
/** 解扰:反转字节 → XOR 每字节 → UTF-8 字符串。 */
private fun unscramble(data: ByteArray): String? = runCatching {
val key = SCRAMBLE_KEY
val reversed = data.reversedArray()
String(ByteArray(reversed.size) { i -> ((reversed[i].toInt() and 0xFF) xor (key[i % key.size].toInt() and 0xFF)).toByte() }, Charsets.UTF_8)
}.getOrNull()
companion object {
const val GITHUB_API = "https://api.github.com/repos/lsxf/a2i/releases/latest"
const val CHECK_INTERVAL_MS = 24L * 60 * 60 * 1000
private val SCRAMBLE_KEY = byteArrayOf(0x4B, 0x6F, 0x72, 0x6E) // "Korn"
}
fun formatSize(bytes: Long): String {
if (bytes <= 0) return ""
val kb = bytes / 1024.0
return if (kb >= 1024) String.format("%.1f MB", kb / 1024) else String.format("%.0f KB", kb)
}
}
@@ -1,22 +0,0 @@
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,
)
@@ -1,46 +0,0 @@
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,
val enabled: Boolean = true,
)
const val OFFICIAL_BARK_SERVER = "https://api.day.app"
@Serializable
data class NtfyServer(
val id: String = UUID.randomUUID().toString(),
val name: String,
val server: String = "https://ntfy.sh",
val topic: String = "",
val token: String = "",
val enabled: Boolean = true,
)
@Serializable
data class EmailServer(
val id: String = UUID.randomUUID().toString(),
val name: String,
val host: String = "",
val port: Int = 465,
val user: String = "",
val password: String = "",
val from: String = "",
val to: String = "",
val enabled: Boolean = true,
)
@Serializable
data class SmsTarget(
val id: String = UUID.randomUUID().toString(),
val name: String,
val number: String = "",
val enabled: Boolean = true,
)
@@ -1,84 +0,0 @@
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.BarkSender
import com.a2i.forwarder.core.EmailSender
import com.a2i.forwarder.core.ForwardLog
import com.a2i.forwarder.core.NotificationProcessor
import com.a2i.forwarder.core.NtfySender
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 servers = app.settings.servers.value.filter { it.enabled && it.deviceKey.isNotBlank() }
if (servers.isEmpty()) {
app.logStore.addLog(ForwardLog(now, sbn.packageName, d.appLabel, msg.title, msg.body, "failed", "未配置可用 Bark 服务器"))
EmailSender.trySend(app, msg.title ?: d.appLabel, msg.body, scope)
NtfySender.trySend(app, msg.title ?: d.appLabel, msg.body, scope)
return
}
val result = BarkSender.pushToEnabled(servers, msg, app.settings.barkStopOnFirst.value)
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, servers.first().id, json.encodeToString(msg))
)
}
EmailSender.trySend(app, msg.title ?: d.appLabel, msg.body, scope)
NtfySender.trySend(app, msg.title ?: d.appLabel, msg.body, scope)
}
}
@@ -1,127 +0,0 @@
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通话
)
}
}
@@ -1,331 +0,0 @@
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.EmailServer
import com.a2i.forwarder.model.NtfyServer
import com.a2i.forwarder.model.OFFICIAL_BARK_SERVER
import com.a2i.forwarder.model.SmsTarget
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 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 BARK_STOP_FIRST = booleanPreferencesKey("bark_stop_first")
val NTFY_STOP_FIRST = booleanPreferencesKey("ntfy_stop_first")
val EMAIL_STOP_FIRST = booleanPreferencesKey("email_stop_first")
val SMS_STOP_FIRST = booleanPreferencesKey("sms_stop_first")
// 短信兜底(目标号已列表化;运营商/余额/限频保持全局)
val SMS_FALLBACK = booleanPreferencesKey("sms_fallback")
val SMS_TARGETS = stringPreferencesKey("sms_targets")
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 EMAIL_ENABLED = booleanPreferencesKey("email_enabled")
val EMAIL_SERVERS = stringPreferencesKey("email_servers")
val NTFY_SERVERS = stringPreferencesKey("ntfy_servers")
// 应用更新
val UPDATE_CHECK_AUTO = booleanPreferencesKey("update_check_auto")
val LAST_UPDATE_CHECK = longPreferencesKey("last_update_check")
val IGNORED_VERSION = stringPreferencesKey("ignored_version")
// 配置版本(用于旧单组配置迁移)
val CONFIG_VERSION = intPreferencesKey("config_version")
// ---- 旧单组键(仅迁移用,迁移后清理)----
val SMS_TARGET = stringPreferencesKey("sms_target")
val EMAIL_HOST = stringPreferencesKey("email_smtp_host")
val EMAIL_PORT = intPreferencesKey("email_smtp_port")
val EMAIL_USER = stringPreferencesKey("email_smtp_user")
val EMAIL_PASS = stringPreferencesKey("email_smtp_pass")
val EMAIL_FROM = stringPreferencesKey("email_from")
val EMAIL_TO = stringPreferencesKey("email_to")
val NTFY_SERVER = stringPreferencesKey("ntfy_server")
val NTFY_TOPIC = stringPreferencesKey("ntfy_topic")
val NTFY_TOKEN = stringPreferencesKey("ntfy_token")
}
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 _ntfyServers = MutableStateFlow<List<NtfyServer>>(emptyList())
val ntfyServers = _ntfyServers.asStateFlow()
private val _emailServers = MutableStateFlow<List<EmailServer>>(emptyList())
val emailServers = _emailServers.asStateFlow()
private val _smsTargets = MutableStateFlow<List<SmsTarget>>(emptyList())
val smsTargets = _smsTargets.asStateFlow()
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 barkStopOnFirst = MutableStateFlow(true)
val ntfyStopOnFirst = MutableStateFlow(false)
val emailStopOnFirst = MutableStateFlow(false)
val smsStopOnFirst = MutableStateFlow(false)
// 短信兜底(全局)
val smsFallbackEnabled = MutableStateFlow(false)
val smsCarrier = MutableStateFlow("auto")
val smsManualQuota = MutableStateFlow(0)
val smsBalance = MutableStateFlow(-1)
val smsSuspended = MutableStateFlow(false)
val lastSmsSentTime = MutableStateFlow(0L)
// 电邮总开关
val emailForwardingEnabled = MutableStateFlow(false)
// 应用更新
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
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()
// 各通道 stopOnFirst
barkStopOnFirst.value = p[K.BARK_STOP_FIRST] ?: true
ntfyStopOnFirst.value = p[K.NTFY_STOP_FIRST] ?: false
emailStopOnFirst.value = p[K.EMAIL_STOP_FIRST] ?: false
smsStopOnFirst.value = p[K.SMS_STOP_FIRST] ?: false
// 短信兜底全局
smsFallbackEnabled.value = p[K.SMS_FALLBACK] ?: false
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
// 电邮总开关
emailForwardingEnabled.value = p[K.EMAIL_ENABLED] ?: false
// 应用更新
updateCheckEnabled.value = p[K.UPDATE_CHECK_AUTO] ?: true
lastUpdateCheckTime.value = p[K.LAST_UPDATE_CHECK] ?: 0L
ignoredVersion.value = p[K.IGNORED_VERSION] ?: ""
// 列表配置
_ntfyServers.value = p[K.NTFY_SERVERS]
?.let { runCatching { json.decodeFromString<List<NtfyServer>>(it) }.getOrNull() }
?: emptyList()
_emailServers.value = p[K.EMAIL_SERVERS]
?.let { runCatching { json.decodeFromString<List<EmailServer>>(it) }.getOrNull() }
?: emptyList()
_smsTargets.value = p[K.SMS_TARGETS]
?.let { runCatching { json.decodeFromString<List<SmsTarget>>(it) }.getOrNull() }
?: emptyList()
// ---- 旧单组配置迁移(config_version < 2)----
val cfgVer = p[K.CONFIG_VERSION] ?: 1
if (cfgVer < 2) {
migrateLegacyConfigs(p)
}
}
}
}
/** 把旧的单组 ntfy/email/sms 配置转成列表的一条。 */
private suspend fun migrateLegacyConfigs(p: Preferences) {
var ntfyList = _ntfyServers.value
var emailList = _emailServers.value
var smsList = _smsTargets.value
val nTopic = p[K.NTFY_TOPIC]
if (!nTopic.isNullOrBlank() && ntfyList.isEmpty()) {
ntfyList = listOf(NtfyServer(name = "ntfy", server = p[K.NTFY_SERVER] ?: "https://ntfy.sh", topic = nTopic, token = p[K.NTFY_TOKEN] ?: ""))
}
val eHost = p[K.EMAIL_HOST]
val eTo = p[K.EMAIL_TO]
if (!eHost.isNullOrBlank() && emailList.isEmpty()) {
emailList = listOf(EmailServer(
name = "邮箱", host = eHost,
port = (p[K.EMAIL_PORT] ?: 465).let { if (it < 25) 465 else it },
user = p[K.EMAIL_USER] ?: "", password = p[K.EMAIL_PASS] ?: "",
from = p[K.EMAIL_FROM] ?: "", to = eTo ?: "",
))
}
val sTarget = p[K.SMS_TARGET]
if (!sTarget.isNullOrBlank() && smsList.isEmpty()) {
smsList = listOf(SmsTarget(name = "iPhone", number = sTarget))
}
ds.edit { m ->
if (ntfyList.isNotEmpty()) {
m[K.NTFY_SERVERS] = json.encodeToString(ntfyList)
m.remove(K.NTFY_SERVER); m.remove(K.NTFY_TOPIC); m.remove(K.NTFY_TOKEN)
}
if (emailList.isNotEmpty()) {
m[K.EMAIL_SERVERS] = json.encodeToString(emailList)
listOf(K.EMAIL_HOST, K.EMAIL_PORT, K.EMAIL_USER, K.EMAIL_PASS, K.EMAIL_FROM, K.EMAIL_TO).forEach { m.remove(it) }
}
if (smsList.isNotEmpty()) {
m[K.SMS_TARGETS] = json.encodeToString(smsList)
m.remove(K.SMS_TARGET)
}
m[K.CONFIG_VERSION] = 2
}
_ntfyServers.value = ntfyList
_emailServers.value = emailList
_smsTargets.value = smsList
}
private suspend fun edit(block: (androidx.datastore.preferences.core.MutablePreferences) -> Unit) =
ds.edit(block)
// ---- Bark 服务器 CRUD ----
private suspend fun saveServers(list: List<BarkServer>) {
edit { it[K.SERVERS] = json.encodeToString(list) }
_servers.value = list
}
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)
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) = saveServers(_servers.value.filterNot { it.id == id })
suspend fun moveServer(id: String, up: Boolean) {
val list = _servers.value.toMutableList()
val i = list.indexOfFirst { it.id == id }
if (i in 1..list.lastIndex && up) { java.util.Collections.swap(list, i, i - 1); saveServers(list) }
else if (i in 0 until list.lastIndex && !up) { java.util.Collections.swap(list, i, i + 1); saveServers(list) }
}
suspend fun setServerEnabled(id: String, enabled: Boolean) = saveServers(_servers.value.map { if (it.id == id) it.copy(enabled = enabled) else it })
// ---- ntfy 服务器 CRUD ----
private suspend fun saveNtfyServers(list: List<NtfyServer>) {
edit { it[K.NTFY_SERVERS] = json.encodeToString(list) }
_ntfyServers.value = list
}
suspend fun addNtfy(s: NtfyServer) { saveNtfyServers(_ntfyServers.value + s) }
suspend fun updateNtfy(s: NtfyServer) = saveNtfyServers(_ntfyServers.value.map { if (it.id == s.id) s else it })
suspend fun deleteNtfy(id: String) = saveNtfyServers(_ntfyServers.value.filterNot { it.id == id })
suspend fun moveNtfy(id: String, up: Boolean) {
val list = _ntfyServers.value.toMutableList()
val i = list.indexOfFirst { it.id == id }
if (i in 1..list.lastIndex && up) { java.util.Collections.swap(list, i, i - 1); saveNtfyServers(list) }
else if (i in 0 until list.lastIndex && !up) { java.util.Collections.swap(list, i, i + 1); saveNtfyServers(list) }
}
suspend fun setNtfyEnabled(id: String, enabled: Boolean) = saveNtfyServers(_ntfyServers.value.map { if (it.id == id) it.copy(enabled = enabled) else it })
// ---- 电邮服务器 CRUD ----
private suspend fun saveEmailServers(list: List<EmailServer>) {
edit { it[K.EMAIL_SERVERS] = json.encodeToString(list) }
_emailServers.value = list
}
suspend fun addEmail(s: EmailServer) { saveEmailServers(_emailServers.value + s) }
suspend fun updateEmail(s: EmailServer) = saveEmailServers(_emailServers.value.map { if (it.id == s.id) s else it })
suspend fun deleteEmail(id: String) = saveEmailServers(_emailServers.value.filterNot { it.id == id })
suspend fun moveEmail(id: String, up: Boolean) {
val list = _emailServers.value.toMutableList()
val i = list.indexOfFirst { it.id == id }
if (i in 1..list.lastIndex && up) { java.util.Collections.swap(list, i, i - 1); saveEmailServers(list) }
else if (i in 0 until list.lastIndex && !up) { java.util.Collections.swap(list, i, i + 1); saveEmailServers(list) }
}
suspend fun setEmailEnabled(id: String, enabled: Boolean) = saveEmailServers(_emailServers.value.map { if (it.id == id) it.copy(enabled = enabled) else it })
// ---- 短信目标 CRUD ----
private suspend fun saveSmsTargets(list: List<SmsTarget>) {
edit { it[K.SMS_TARGETS] = json.encodeToString(list) }
_smsTargets.value = list
}
suspend fun addSmsTarget(s: SmsTarget) { saveSmsTargets(_smsTargets.value + s) }
suspend fun updateSmsTarget(s: SmsTarget) = saveSmsTargets(_smsTargets.value.map { if (it.id == s.id) s else it })
suspend fun deleteSmsTarget(id: String) = saveSmsTargets(_smsTargets.value.filterNot { it.id == id })
suspend fun moveSmsTarget(id: String, up: Boolean) {
val list = _smsTargets.value.toMutableList()
val i = list.indexOfFirst { it.id == id }
if (i in 1..list.lastIndex && up) { java.util.Collections.swap(list, i, i - 1); saveSmsTargets(list) }
else if (i in 0 until list.lastIndex && !up) { java.util.Collections.swap(list, i, i + 1); saveSmsTargets(list) }
}
suspend fun setSmsTargetEnabled(id: String, enabled: Boolean) = saveSmsTargets(_smsTargets.value.map { if (it.id == id) it.copy(enabled = enabled) else it })
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 setBarkStopOnFirst(v: Boolean) { edit { it[K.BARK_STOP_FIRST] = v }; barkStopOnFirst.value = v }
suspend fun setNtfyStopOnFirst(v: Boolean) { edit { it[K.NTFY_STOP_FIRST] = v }; ntfyStopOnFirst.value = v }
suspend fun setEmailStopOnFirst(v: Boolean) { edit { it[K.EMAIL_STOP_FIRST] = v }; emailStopOnFirst.value = v }
suspend fun setSmsStopOnFirst(v: Boolean) { edit { it[K.SMS_STOP_FIRST] = v }; smsStopOnFirst.value = v }
suspend fun setSmsFallback(v: Boolean) { edit { it[K.SMS_FALLBACK] = v }; smsFallbackEnabled.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 setEmailForwarding(v: Boolean) { edit { it[K.EMAIL_ENABLED] = v }; emailForwardingEnabled.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/"
}
}
@@ -1,275 +0,0 @@
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,
headerAction: (@Composable () -> Unit)? = 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) {
if (headerAction == 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),
)
}
} else {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(bottom = 10.dp),
verticalAlignment = Alignment.Top,
) {
Column(Modifier.weight(1f)) {
Text(
title,
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold,
modifier = Modifier.padding(bottom = if (subtitle == null) 0.dp else 2.dp),
)
if (subtitle != null) {
Text(
subtitle,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
Spacer(Modifier.width(12.dp))
headerAction()
}
}
}
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()
}
@@ -1,99 +0,0 @@
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() }) }
}
}
}
@@ -1,222 +0,0 @@
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() }
}
@@ -1,179 +0,0 @@
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, "删除") }
}
}
}
@@ -1,310 +0,0 @@
package com.a2i.forwarder.ui.screens
import android.content.Intent
import android.provider.Settings
import androidx.compose.foundation.BorderStroke
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.ButtonDefaults
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 servers by app.settings.servers.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 {
Box(modifier = Modifier
.fillMaxWidth()
.background(
brush = Brush.linearGradient(
colors = listOf(MaterialTheme.colorScheme.tertiary, MaterialTheme.colorScheme.primary),
),
shape = RoundedCornerShape(8.dp),
)
.padding(16.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.SystemUpdate, null, tint = Color.White, modifier = Modifier.size(26.dp))
}
Spacer(Modifier.width(12.dp))
Column(Modifier.weight(1f)) {
Text("发现新版本", style = MaterialTheme.typography.titleLarge, color = Color.White, fontWeight = FontWeight.Bold)
Text(
"v${info.latestVersion} · ${formatSize(info.apkSize)} · 点击立即更新",
style = MaterialTheme.typography.bodyMedium,
color = Color.White.copy(alpha = 0.88f),
)
}
StatusBadge("🔄", tint = Color.White)
}
Spacer(Modifier.size(12.dp))
Button(
onClick = { showUpdateDialog = true },
modifier = Modifier.fillMaxWidth(),
colors = ButtonDefaults.buttonColors(
containerColor = Color.White,
contentColor = MaterialTheme.colorScheme.primary,
),
) { Text("🚀 立即更新到 v${info.latestVersion}", fontWeight = FontWeight.Bold) }
}
}
}
}
}
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) }
}
val enabledServers = servers.filter { it.enabled && it.deviceKey.isNotBlank() }
ClickRow(
title = if (enabledServers.isEmpty()) "未启用 Bark 服务器" else "Bark 服务器(${enabledServers.size} 个启用)",
subtitle = enabledServers.firstOrNull()?.server ?: "进入设置勾选要转发的服务器",
icon = Icons.Filled.Settings,
tint = MaterialTheme.colorScheme.secondary,
onClick = onOpenSettings,
)
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),
)
}
}
}
@@ -1,193 +0,0 @@
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),
)
}
}
}
}
@@ -1,732 +0,0 @@
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.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.foundation.layout.Arrangement
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.heightIn
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.Info
import androidx.compose.material.icons.filled.KeyboardArrowDown
import androidx.compose.material.icons.filled.KeyboardArrowUp
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.Checkbox
import androidx.compose.material3.CircularProgressIndicator
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.Surface
import androidx.compose.material3.Switch
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
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.platform.LocalContext
import androidx.compose.ui.platform.LocalUriHandler
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.EmailSender
import com.a2i.forwarder.core.IconExporter
import com.a2i.forwarder.core.NtfySender
import com.a2i.forwarder.model.BarkMessage
import com.a2i.forwarder.model.BarkServer
import com.a2i.forwarder.model.EmailServer
import com.a2i.forwarder.model.NtfyServer
import com.a2i.forwarder.model.OFFICIAL_BARK_SERVER
import com.a2i.forwarder.model.SmsTarget
import com.a2i.forwarder.ui.ClickRow
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.appVersionName
import kotlinx.coroutines.launch
private const val BARK_APP_STORE_URL = "https://apps.apple.com/app/bark-custom-notifications/id1403753865"
private const val BARK_DOCS_URL = "https://bark.day.app/#/"
private const val BARK_GITHUB_URL = "https://github.com/Finb/Bark"
private const val BARK_OFFICIAL_SERVER = "https://api.day.app"
private const val NTFY_OFFICIAL_SERVER = "https://ntfy.sh"
private const val NTFY_ANDROID_URL = "https://play.google.com/store/apps/details?id=io.heckel.ntfy"
private const val NTFY_IOS_URL = "https://apps.apple.com/us/app/ntfy/id1625396347"
private const val NTFY_FDROID_URL = "https://f-droid.org/en/packages/io.heckel.ntfy/"
private const val NTFY_DOCS_URL = "https://docs.ntfy.sh/subscribe/phone/"
private const val NTFY_PUBLISH_DOCS_URL = "https://docs.ntfy.sh/publish/"
private const val NTFY_GITHUB_URL = "https://github.com/binwiederhier/ntfy"
@Composable
fun SettingsScreen(onOpenLog: () -> Unit) {
val context = LocalContext.current
val app = A2iApp.instance
val servers by app.settings.servers.collectAsState()
val ntfyServers by app.settings.ntfyServers.collectAsState()
val emailServers by app.settings.emailServers.collectAsState()
val smsTargets by app.settings.smsTargets.collectAsState()
val barkStopFirst by app.settings.barkStopOnFirst.collectAsState()
val ntfyStopFirst by app.settings.ntfyStopOnFirst.collectAsState()
val emailStopFirst by app.settings.emailStopOnFirst.collectAsState()
val smsStopFirst by app.settings.smsStopOnFirst.collectAsState()
val emailEnabled by app.settings.emailForwardingEnabled.collectAsState()
val smsFallback by app.settings.smsFallbackEnabled.collectAsState()
val iconPrefix by app.settings.iconPrefix.collectAsState()
val smsBalance by app.settings.smsBalance.collectAsState()
val smsSuspended by app.settings.smsSuspended.collectAsState()
val smsManualQuota by app.settings.smsManualQuota.collectAsState()
val updateAuto by app.settings.updateCheckEnabled.collectAsState()
val updateInfo by app.updateChecker.updateInfo.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 checking by remember { mutableStateOf(false) }
var checkMsg by remember { mutableStateOf<String?>(null) }
val detectedCarrier = remember { com.a2i.forwarder.core.CarrierDetector.detect(context) }
val hasSmsPerm = remember {
androidx.core.content.ContextCompat.checkSelfPermission(context, android.Manifest.permission.SEND_SMS) ==
android.content.pm.PackageManager.PERMISSION_GRANTED
}
val smsPermLauncher = rememberLauncherForActivityResult(ActivityResultContracts.RequestPermission()) { }
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
}
}
// 弹窗状态
var showAddBark by remember { mutableStateOf(false) }
var showBarkHelp by remember { mutableStateOf(false) }
var editingBark by remember { mutableStateOf<BarkServer?>(null) }
var showAddNtfy by remember { mutableStateOf(false) }
var showNtfyHelp by remember { mutableStateOf(false) }
var editingNtfy by remember { mutableStateOf<NtfyServer?>(null) }
var showAddEmail by remember { mutableStateOf(false) }
var editingEmail by remember { mutableStateOf<EmailServer?>(null) }
var showAddSms by remember { mutableStateOf(false) }
var editingSms by remember { mutableStateOf<SmsTarget?>(null) }
LazyColumn(
modifier = Modifier.fillMaxSize().padding(horizontal = 16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
item { ScreenTitle("设置", "管理各转发通道、图标和维护") }
// ===== Bark 服务器 =====
item {
SectionCard(
"Bark 服务器",
subtitle = "勾选的服务器参与转发;开关切换容错/广播",
headerAction = {
Row(verticalAlignment = Alignment.CenterVertically) {
IconButton(onClick = { showBarkHelp = true }) {
Icon(Icons.Filled.Info, "Bark 配置帮助")
}
CompactStopOnFirstSwitch(
checked = barkStopFirst,
onChange = { v -> app.appScope.launch { app.settings.setBarkStopOnFirst(v) } },
)
}
},
) {
if (servers.isNotEmpty()) {
Spacer(Modifier.size(8.dp))
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
servers.forEachIndexed { i, s ->
ServerRow(s, i == 0, i == servers.lastIndex,
onToggle = { v -> app.appScope.launch { app.settings.setServerEnabled(s.id, v) } },
onEdit = { editingBark = s },
onDelete = { app.appScope.launch { app.settings.deleteServer(s.id) } },
onMoveUp = { app.appScope.launch { app.settings.moveServer(s.id, true) } },
onMoveDown = { app.appScope.launch { app.settings.moveServer(s.id, false) } },
)
}
}
}
AddButton { showAddBark = true }
}
}
// ===== ntfy =====
item {
SectionCard(
"ntfy 转发",
subtitle = "勾选的 topic 参与转发;开关切换容错/广播",
headerAction = {
Row(verticalAlignment = Alignment.CenterVertically) {
IconButton(onClick = { showNtfyHelp = true }) {
Icon(Icons.Filled.Info, "ntfy 配置帮助")
}
CompactStopOnFirstSwitch(
checked = ntfyStopFirst,
onChange = { v -> app.appScope.launch { app.settings.setNtfyStopOnFirst(v) } },
)
}
},
) {
if (ntfyServers.isNotEmpty()) {
Spacer(Modifier.size(8.dp))
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
ntfyServers.forEachIndexed { i, s ->
NtfyServerRow(
server = s,
isFirst = i == 0,
isLast = i == ntfyServers.lastIndex,
onToggle = { v -> app.appScope.launch { app.settings.setNtfyEnabled(s.id, v) } },
onEdit = { editingNtfy = s },
onDelete = { app.appScope.launch { app.settings.deleteNtfy(s.id) } },
onMoveUp = { app.appScope.launch { app.settings.moveNtfy(s.id, true) } },
onMoveDown = { app.appScope.launch { app.settings.moveNtfy(s.id, false) } },
)
}
}
}
AddButton { showAddNtfy = true }
}
}
// ===== 电邮 =====
item {
SectionCard("电邮转发", subtitle = "SMTPS 直发邮件") {
SwitchRow(title = "启用电邮转发", subtitle = if (emailEnabled) "已开启" else "已关闭", checked = emailEnabled) { v -> app.appScope.launch { app.settings.setEmailForwarding(v) } }
if (emailEnabled) {
SwitchRow(title = "发通一条就停", subtitle = if (emailStopFirst) "容错:首个成功止" else "广播:每个都发", checked = emailStopFirst) { v -> app.appScope.launch { app.settings.setEmailStopOnFirst(v) } }
if (emailServers.isNotEmpty()) {
Spacer(Modifier.size(8.dp))
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
emailServers.forEachIndexed { i, s ->
ConfigRow(
title = s.name.ifBlank { s.to },
subtitle = "${s.host}:${s.port} · ${s.user} → ${s.to}",
enabled = s.enabled, isFirst = i == 0, isLast = i == emailServers.lastIndex,
onToggle = { v -> app.appScope.launch { app.settings.setEmailEnabled(s.id, v) } },
onEdit = { editingEmail = s }, onDelete = { app.appScope.launch { app.settings.deleteEmail(s.id) } },
onMoveUp = { app.appScope.launch { app.settings.moveEmail(s.id, true) } },
onMoveDown = { app.appScope.launch { app.settings.moveEmail(s.id, false) } },
onTest = { app.appScope.launch { EmailSender.send(s.host, s.port, s.user, s.password, s.from, s.to, "a2i测试", "电邮配置测试") } },
)
}
}
}
AddButton { showAddEmail = true }
}
}
}
// ===== 短信兜底 =====
item {
SectionCard("断网短信兜底", subtitle = "无网络时把验证码/来电用短信发出去") {
SwitchRow(title = "启用短信兜底", subtitle = if (smsFallback) "已开启" else "已关闭", checked = smsFallback) { v -> app.appScope.launch { app.settings.setSmsFallback(v) } }
if (smsFallback) {
SwitchRow(title = "发通一条就停", subtitle = if (smsStopFirst) "容错:首个成功止" else "广播:每个都发", checked = smsStopFirst) { v -> app.appScope.launch { app.settings.setSmsStopOnFirst(v) } }
if (!hasSmsPerm) {
Spacer(Modifier.size(4.dp))
OutlinedButton(onClick = { smsPermLauncher.launch(android.Manifest.permission.SEND_SMS) }, modifier = Modifier.fillMaxWidth()) { Text("授予短信权限") }
}
if (smsTargets.isNotEmpty()) {
Spacer(Modifier.size(8.dp))
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
smsTargets.forEachIndexed { i, s ->
ConfigRow(
title = s.name.ifBlank { s.number },
subtitle = s.number,
enabled = s.enabled, isFirst = i == 0, isLast = i == smsTargets.lastIndex,
onToggle = { v -> app.appScope.launch { app.settings.setSmsTargetEnabled(s.id, v) } },
onEdit = { editingSms = s }, onDelete = { app.appScope.launch { app.settings.deleteSmsTarget(s.id) } },
onMoveUp = { app.appScope.launch { app.settings.moveSmsTarget(s.id, true) } },
onMoveDown = { app.appScope.launch { app.settings.moveSmsTarget(s.id, false) } },
onTest = null,
)
}
}
}
AddButton { showAddSms = true }
Spacer(Modifier.size(8.dp))
val balanceText = when {
smsSuspended -> "已挂起(余额不足)"
smsBalance < 0 -> "余额未知"
else -> "剩余约 $smsBalance 条"
}
Text("运营商:${detectedCarrier.displayName} · 余额:$balanceText · 手动额度:$smsManualQuota", style = MaterialTheme.typography.bodySmall, color = if (smsSuspended) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.onSurfaceVariant)
}
}
}
// ===== 应用图标 =====
item {
SectionCard("应用图标", subtitle = "导出 App 图标后,Bark 通知可按包名显示图标") {
OutlinedTextField(value = iconPrefix, onValueChange = { app.appScope.launch { app.settings.setIconPrefix(it) } }, label = { Text("图标 URL 前缀") }, 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 = "版本检查(数据来自 Gitea + GitHub 双源)") {
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)) }
}
}
// ===== 维护 =====
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 {
Spacer(Modifier.padding(top = 4.dp, bottom = 18.dp))
}
}
// 弹窗
if (showBarkHelp) BarkHelpDialog(onDismiss = { showBarkHelp = false })
if (showNtfyHelp) NtfyHelpDialog(onDismiss = { showNtfyHelp = false })
if (showAddBark) ServerDialog(onDismiss = { showAddBark = false }, onConfirm = { n, s, k -> app.appScope.launch { app.settings.addServer(n, s, k) }; showAddBark = false })
editingBark?.let { srv -> ServerDialog(initial = srv, onDismiss = { editingBark = null }, onConfirm = { n, s, k -> app.appScope.launch { app.settings.updateServer(srv.copy(name = n, server = s, deviceKey = k)) }; editingBark = null }) }
if (showAddNtfy) NtfyConfigDialog(onDismiss = { showAddNtfy = false }, onConfirm = { n, sv, t, tk -> app.appScope.launch { app.settings.addNtfy(NtfyServer(name = n, server = sv, topic = t, token = tk)) }; showAddNtfy = false })
editingNtfy?.let { srv -> NtfyConfigDialog(initial = srv, onDismiss = { editingNtfy = null }, onConfirm = { n, sv, t, tk -> app.appScope.launch { app.settings.updateNtfy(srv.copy(name = n, server = sv, topic = t, token = tk)) }; editingNtfy = null }) }
if (showAddEmail) EmailConfigDialog(onDismiss = { showAddEmail = false }, onConfirm = { c -> app.appScope.launch { app.settings.addEmail(c) }; showAddEmail = false })
editingEmail?.let { srv -> EmailConfigDialog(initial = srv, onDismiss = { editingEmail = null }, onConfirm = { c -> app.appScope.launch { app.settings.updateEmail(c.copy(id = srv.id)) }; editingEmail = null }) }
if (showAddSms) SmsConfigDialog(onDismiss = { showAddSms = false }, onConfirm = { n, num -> app.appScope.launch { app.settings.addSmsTarget(SmsTarget(name = n, number = num)) }; showAddSms = false })
editingSms?.let { srv -> SmsConfigDialog(initial = srv, onDismiss = { editingSms = null }, onConfirm = { n, num -> app.appScope.launch { app.settings.updateSmsTarget(srv.copy(name = n, number = num)) }; editingSms = null }) }
}
@Composable
private fun AddButton(onClick: () -> Unit) {
Button(onClick = onClick, modifier = Modifier.fillMaxWidth().padding(top = 12.dp)) {
Icon(Icons.Filled.Add, null, modifier = Modifier.size(18.dp))
Spacer(Modifier.width(8.dp))
Text("添加")
}
}
@Composable
private fun CompactStopOnFirstSwitch(
checked: Boolean,
onChange: (Boolean) -> Unit,
) {
Row(
modifier = Modifier.padding(start = 4.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Text(
"发通一条就停",
style = MaterialTheme.typography.labelMedium,
fontWeight = FontWeight.Medium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1,
)
Spacer(Modifier.width(6.dp))
Switch(checked = checked, onCheckedChange = onChange)
}
}
@Composable
private fun BarkHelpDialog(onDismiss: () -> Unit) {
val uriHandler = LocalUriHandler.current
AlertDialog(
onDismissRequest = onDismiss,
icon = { Icon(Icons.Filled.Info, null) },
title = { Text("Bark 配置帮助") },
text = {
Column(
modifier = Modifier
.heightIn(max = 440.dp)
.verticalScroll(rememberScrollState()),
verticalArrangement = Arrangement.spacedBy(10.dp),
) {
Text(
"Bark 用来把 a2i 处理后的 Android 通知推送到 iPhone / iPad。第一次使用按下面顺序配置即可。",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
HelpStep("1", "在 iPhone / iPad 上安装 Bark,首次打开时允许通知权限。")
HelpStep("2", "打开 Bark,复制首页显示的推送地址,通常类似 $BARK_OFFICIAL_SERVER/你的Key。")
HelpStep("3", "回到 a2i,点击本区域底部的「添加」。服务器地址填 $BARK_OFFICIAL_SERVER,自建服务则填你的自建地址。")
HelpStep("4", "Device Key 填推送地址最后一段 Key,不要把完整 URL 都填进去。")
HelpStep("5", "保存后点服务器行里的发送按钮测试;iPhone 收到「a2i 测试」就说明 Bark 通道已通。")
HelpStep("6", "最后回首页开启「通知转发」,并按提示授予系统通知监听权限。")
Text(
"常用链接",
style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.SemiBold,
modifier = Modifier.padding(top = 4.dp),
)
HelpLink("Bark App Store", BARK_APP_STORE_URL) { uriHandler.openUri(it) }
HelpLink("Bark 官方文档", BARK_DOCS_URL) { uriHandler.openUri(it) }
HelpLink("Bark GitHub", BARK_GITHUB_URL) { uriHandler.openUri(it) }
Text(
"提示:如果使用自建 Bark Server,a2i 里的服务器地址要换成自建地址,但 Device Key 仍然来自 Bark App。",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(top = 2.dp),
)
}
},
confirmButton = { TextButton(onClick = onDismiss) { Text("知道了") } },
)
}
@Composable
private fun NtfyHelpDialog(onDismiss: () -> Unit) {
val uriHandler = LocalUriHandler.current
AlertDialog(
onDismissRequest = onDismiss,
icon = { Icon(Icons.Filled.Info, null) },
title = { Text("ntfy 配置帮助") },
text = {
Column(
modifier = Modifier
.heightIn(max = 440.dp)
.verticalScroll(rememberScrollState()),
verticalArrangement = Arrangement.spacedBy(10.dp),
) {
Text(
"ntfy 用来把 a2i 处理后的通知推送到 Android、iOS 或网页版 ntfy。它不需要先建项目,只要接收端和 a2i 使用同一个 server + topic 即可。",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
HelpStep("1", "在接收端安装 ntfy。Android 可用 Google Play / F-Droid / GitHub APK,iPhone / iPad 可用 App Store。")
HelpStep("2", "打开 ntfy,添加订阅。服务器通常填 $NTFY_OFFICIAL_SERVER;Topic 建议用一串不容易猜到的随机名字,例如 a2i_xxxxxxxx。")
HelpStep("3", "回到 a2i,点击本区域底部的「添加」。服务器和 Topic 要与接收端订阅完全一致。")
HelpStep("4", "Token 是可选项。只有你在 ntfy 账号或自建服务里把 topic 设成需要鉴权时,才填写 access token。")
HelpStep("5", "保存后点 topic 行里的发送按钮测试;接收端收到「a2i 测试」就说明 ntfy 通道已通。")
HelpStep("6", "后续只要这一行左侧保持勾选,它就会参与转发;取消勾选就是停用这个 topic。")
Text(
"常用链接",
style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.SemiBold,
modifier = Modifier.padding(top = 4.dp),
)
HelpLink("ntfy Android · Google Play", NTFY_ANDROID_URL) { uriHandler.openUri(it) }
HelpLink("ntfy Android · F-Droid", NTFY_FDROID_URL) { uriHandler.openUri(it) }
HelpLink("ntfy iOS · App Store", NTFY_IOS_URL) { uriHandler.openUri(it) }
HelpLink("ntfy 手机端文档", NTFY_DOCS_URL) { uriHandler.openUri(it) }
HelpLink("ntfy 发布 API 文档", NTFY_PUBLISH_DOCS_URL) { uriHandler.openUri(it) }
HelpLink("ntfy GitHub", NTFY_GITHUB_URL) { uriHandler.openUri(it) }
Text(
"提示:公共 topic 谁知道名字谁就可能订阅或发送,所以 topic 名不要用手机号、姓名、邮箱等可猜信息。",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(top = 2.dp),
)
}
},
confirmButton = { TextButton(onClick = onDismiss) { Text("知道了") } },
)
}
@Composable
private fun HelpStep(
index: String,
text: String,
) {
Row(verticalAlignment = Alignment.Top) {
Text(
"$index.",
style = MaterialTheme.typography.bodyMedium,
fontWeight = FontWeight.SemiBold,
color = MaterialTheme.colorScheme.primary,
modifier = Modifier.width(24.dp),
)
Text(
text,
style = MaterialTheme.typography.bodyMedium,
modifier = Modifier.weight(1f),
)
}
}
@Composable
private fun HelpLink(
title: String,
url: String,
onOpen: (String) -> Unit,
) {
OutlinedButton(
onClick = { onOpen(url) },
modifier = Modifier.fillMaxWidth(),
) {
Text(title, maxLines = 1, overflow = TextOverflow.Ellipsis)
}
}
/** 通用配置行(ntfy/电邮/短信 共用,仿 ServerRow 简化版)。 */
@Composable
private fun ConfigRow(
title: String, subtitle: String, enabled: Boolean,
isFirst: Boolean, isLast: Boolean,
onToggle: (Boolean) -> Unit, onEdit: () -> Unit, onDelete: () -> Unit,
onMoveUp: () -> Unit, onMoveDown: () -> Unit, onTest: (() -> Unit)?,
) {
Surface(
modifier = Modifier.fillMaxWidth(), shape = RoundedCornerShape(8.dp),
color = if (enabled) MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.40f) else MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.36f),
border = BorderStroke(1.dp, if (enabled) MaterialTheme.colorScheme.primary.copy(alpha = 0.30f) else MaterialTheme.colorScheme.outlineVariant),
) {
Column(Modifier.padding(start = 4.dp, end = 4.dp, top = 2.dp, bottom = 4.dp)) {
Row(verticalAlignment = Alignment.CenterVertically) {
Checkbox(checked = enabled, onCheckedChange = onToggle)
Column(Modifier.weight(1f)) {
Text(title.ifBlank { "未命名" }, style = MaterialTheme.typography.bodyLarge, fontWeight = FontWeight.Medium, maxLines = 1, overflow = TextOverflow.Ellipsis)
Text(subtitle, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, maxLines = 2, overflow = TextOverflow.Ellipsis, modifier = Modifier.padding(start = 0.dp))
}
if (onTest != null) {
IconButton(onClick = onTest) { Icon(Icons.AutoMirrored.Filled.Send, "测试") }
}
IconButton(onClick = onMoveUp, enabled = !isFirst) { Icon(Icons.Filled.KeyboardArrowUp, "上移") }
IconButton(onClick = onMoveDown, enabled = !isLast) { Icon(Icons.Filled.KeyboardArrowDown, "下移") }
IconButton(onClick = onEdit) { Icon(Icons.Filled.Edit, "编辑") }
IconButton(onClick = onDelete) { Icon(Icons.Filled.Delete, "删除") }
}
}
}
}
// ===== ntfy / Bark Row + Dialog =====
@Composable
private fun NtfyServerRow(
server: NtfyServer, isFirst: Boolean, isLast: Boolean,
onToggle: (Boolean) -> Unit, onEdit: () -> Unit, onDelete: () -> Unit,
onMoveUp: () -> Unit, onMoveDown: () -> Unit,
) {
val app = A2iApp.instance
var testing by remember { mutableStateOf(false) }
var testMsg by remember { mutableStateOf<String?>(null) }
Surface(
modifier = Modifier.fillMaxWidth(), shape = RoundedCornerShape(8.dp),
color = if (server.enabled) MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.40f) else MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.36f),
border = BorderStroke(1.dp, if (server.enabled) MaterialTheme.colorScheme.primary.copy(alpha = 0.30f) else MaterialTheme.colorScheme.outlineVariant),
) {
Column(Modifier.padding(start = 4.dp, end = 4.dp, top = 2.dp, bottom = 6.dp)) {
Row(verticalAlignment = Alignment.CenterVertically) {
Checkbox(checked = server.enabled, onCheckedChange = onToggle)
Row(Modifier.weight(1f), verticalAlignment = Alignment.CenterVertically) {
Text(server.name.ifBlank { server.topic.ifBlank { "未命名" } }, style = MaterialTheme.typography.bodyLarge, fontWeight = FontWeight.Medium, maxLines = 1, overflow = TextOverflow.Ellipsis, modifier = Modifier.weight(1f, fill = false))
if (!server.enabled) { Spacer(Modifier.width(8.dp)); StatusBadge("停用", MaterialTheme.colorScheme.onSurfaceVariant) }
}
IconButton(onClick = { if (!testing) app.appScope.launch { testing = true; testMsg = testNtfy(server); testing = false } }, enabled = !testing) {
if (testing) CircularProgressIndicator(Modifier.size(18.dp), strokeWidth = 2.dp) else Icon(Icons.AutoMirrored.Filled.Send, "测试")
}
IconButton(onClick = onEdit) { Icon(Icons.Filled.Edit, "编辑") }
IconButton(onClick = onDelete) { Icon(Icons.Filled.Delete, "删除") }
}
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.padding(top = 2.dp)) {
Column(Modifier.weight(1f).padding(start = 36.dp)) {
Text(server.server, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, maxLines = 2, overflow = TextOverflow.Ellipsis)
Text("topic: " + server.topic.ifBlank { "未填" } + if (server.token.isNotBlank()) " · token已填" else "", style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSurfaceVariant, maxLines = 1, overflow = TextOverflow.Ellipsis)
}
IconButton(onClick = onMoveUp, enabled = !isFirst) { Icon(Icons.Filled.KeyboardArrowUp, "上移") }
IconButton(onClick = onMoveDown, enabled = !isLast) { Icon(Icons.Filled.KeyboardArrowDown, "下移") }
}
testMsg?.let { Text(it, style = MaterialTheme.typography.bodySmall, color = if (it.startsWith("已发送")) MaterialTheme.colorScheme.tertiary else MaterialTheme.colorScheme.error, modifier = Modifier.padding(start = 48.dp, top = 2.dp)) }
}
}
}
@Composable
private fun ServerRow(
server: BarkServer, isFirst: Boolean, isLast: Boolean,
onToggle: (Boolean) -> Unit, onEdit: () -> Unit, onDelete: () -> Unit,
onMoveUp: () -> Unit, onMoveDown: () -> Unit,
) {
val app = A2iApp.instance
var testing by remember { mutableStateOf(false) }
var testMsg by remember { mutableStateOf<String?>(null) }
Surface(
modifier = Modifier.fillMaxWidth(), shape = RoundedCornerShape(8.dp),
color = if (server.enabled) MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.40f) else MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.36f),
border = BorderStroke(1.dp, if (server.enabled) MaterialTheme.colorScheme.primary.copy(alpha = 0.30f) else MaterialTheme.colorScheme.outlineVariant),
) {
Column(Modifier.padding(start = 4.dp, end = 4.dp, top = 2.dp, bottom = 6.dp)) {
Row(verticalAlignment = Alignment.CenterVertically) {
Checkbox(checked = server.enabled, onCheckedChange = onToggle)
Row(Modifier.weight(1f), verticalAlignment = Alignment.CenterVertically) {
Text(server.name.ifBlank { "未命名" }, style = MaterialTheme.typography.bodyLarge, fontWeight = FontWeight.Medium, maxLines = 1, overflow = TextOverflow.Ellipsis, modifier = Modifier.weight(1f, fill = false))
if (!server.enabled) { Spacer(Modifier.width(8.dp)); StatusBadge("停用", MaterialTheme.colorScheme.onSurfaceVariant) }
}
IconButton(onClick = { if (!testing) app.appScope.launch { testing = true; testMsg = testPush(app, server); testing = false } }, enabled = !testing) {
if (testing) CircularProgressIndicator(Modifier.size(18.dp), strokeWidth = 2.dp) else Icon(Icons.AutoMirrored.Filled.Send, "测试")
}
IconButton(onClick = onEdit) { Icon(Icons.Filled.Edit, "编辑") }
IconButton(onClick = onDelete) { Icon(Icons.Filled.Delete, "删除") }
}
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.padding(top = 2.dp)) {
Column(Modifier.weight(1f).padding(start = 36.dp)) {
Text(server.server, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, maxLines = 2, overflow = TextOverflow.Ellipsis)
Text("device key: " + if (server.deviceKey.isBlank()) "未填" else "已填(${server.deviceKey.length} 位)", style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
}
IconButton(onClick = onMoveUp, enabled = !isFirst) { Icon(Icons.Filled.KeyboardArrowUp, "上移") }
IconButton(onClick = onMoveDown, enabled = !isLast) { Icon(Icons.Filled.KeyboardArrowDown, "下移") }
}
testMsg?.let { Text(it, style = MaterialTheme.typography.bodySmall, color = if (it.startsWith("已发送")) MaterialTheme.colorScheme.tertiary else MaterialTheme.colorScheme.error, modifier = Modifier.padding(start = 48.dp, top = 2.dp)) }
}
}
}
@Composable
private fun ServerDialog(initial: BarkServer? = null, onDismiss: () -> Unit, onConfirm: (String, String, 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 ?: "") }
ConfigDialogSkeleton(title = if (initial == null) "添加 Bark 服务器" else "编辑服务器", onDismiss = onDismiss, onConfirm = { onConfirm(name.trim(), server.trim().removeSuffix("/"), key.trim()) }) {
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())
}
}
@Composable
private fun NtfyConfigDialog(initial: NtfyServer? = null, onDismiss: () -> Unit, onConfirm: (String, String, String, String) -> Unit) {
var name by remember { mutableStateOf(initial?.name ?: "") }
var server by remember { mutableStateOf(initial?.server ?: NTFY_OFFICIAL_SERVER) }
var topic by remember { mutableStateOf(initial?.topic ?: "") }
var token by remember { mutableStateOf(initial?.token ?: "") }
ConfigDialogSkeleton(title = if (initial == null) "添加 ntfy" else "编辑 ntfy", onDismiss = onDismiss, onConfirm = { onConfirm(name.trim(), server.trim().removeSuffix("/"), topic.trim(), token.trim()) }) {
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())
Spacer(Modifier.size(8.dp))
OutlinedTextField(topic, { topic = it }, label = { Text("Topic(必填)") }, singleLine = true, modifier = Modifier.fillMaxWidth())
Spacer(Modifier.size(8.dp))
OutlinedTextField(token, { token = it }, label = { Text("Token(可选)") }, singleLine = true, modifier = Modifier.fillMaxWidth())
}
}
@Composable
private fun EmailConfigDialog(initial: EmailServer? = null, onDismiss: () -> Unit, onConfirm: (EmailServer) -> Unit) {
var name by remember { mutableStateOf(initial?.name ?: "") }
var host by remember { mutableStateOf(initial?.host ?: "") }
var port by remember { mutableStateOf((initial?.port ?: 465).toString()) }
var user by remember { mutableStateOf(initial?.user ?: "") }
var password by remember { mutableStateOf(initial?.password ?: "") }
var from by remember { mutableStateOf(initial?.from ?: "") }
var to by remember { mutableStateOf(initial?.to ?: "") }
ConfigDialogSkeleton(title = if (initial == null) "添加邮箱" else "编辑邮箱", onDismiss = onDismiss, onConfirm = { onConfirm(EmailServer(name = name.trim(), host = host.trim(), port = port.trim().toIntOrNull() ?: 465, user = user.trim(), password = password.trim(), from = from.trim(), to = to.trim())) }) {
OutlinedTextField(name, { name = it }, label = { Text("名称") }, singleLine = true, modifier = Modifier.fillMaxWidth())
Spacer(Modifier.size(8.dp))
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
OutlinedTextField(host, { host = it }, label = { Text("SMTP 主机") }, singleLine = true, modifier = Modifier.weight(1.5f))
OutlinedTextField(port, { port = it.filter { c -> c.isDigit() } }, label = { Text("端口") }, singleLine = true, modifier = Modifier.weight(1f))
}
Spacer(Modifier.size(8.dp))
OutlinedTextField(user, { user = it }, label = { Text("账号") }, singleLine = true, modifier = Modifier.fillMaxWidth())
Spacer(Modifier.size(8.dp))
OutlinedTextField(password, { password = it }, label = { Text("授权码/密码") }, singleLine = true, modifier = Modifier.fillMaxWidth())
Spacer(Modifier.size(8.dp))
OutlinedTextField(from, { from = it }, label = { Text("发件人") }, singleLine = true, modifier = Modifier.fillMaxWidth())
Spacer(Modifier.size(8.dp))
OutlinedTextField(to, { to = it }, label = { Text("收件人") }, singleLine = true, modifier = Modifier.fillMaxWidth())
}
}
@Composable
private fun SmsConfigDialog(initial: SmsTarget? = null, onDismiss: () -> Unit, onConfirm: (String, String) -> Unit) {
var name by remember { mutableStateOf(initial?.name ?: "") }
var number by remember { mutableStateOf(initial?.number ?: "") }
ConfigDialogSkeleton(title = if (initial == null) "添加目标号" else "编辑目标号", onDismiss = onDismiss, onConfirm = { onConfirm(name.trim(), number.trim()) }) {
OutlinedTextField(name, { name = it }, label = { Text("名称") }, singleLine = true, modifier = Modifier.fillMaxWidth())
Spacer(Modifier.size(8.dp))
OutlinedTextField(number, { number = it }, label = { Text("手机号") }, singleLine = true, modifier = Modifier.fillMaxWidth())
}
}
@Composable
private fun ConfigDialogSkeleton(title: String, onDismiss: () -> Unit, onConfirm: () -> Unit, content: @Composable () -> Unit) {
AlertDialog(
onDismissRequest = onDismiss,
icon = { Icon(Icons.Filled.Settings, null) },
title = { Text(title) },
text = { Column { content() } },
confirmButton = { TextButton(onClick = onConfirm) { Text("保存") } },
dismissButton = { TextButton(onClick = onDismiss) { Text("取消") } },
)
}
private suspend fun testPush(app: A2iApp, server: BarkServer): String {
if (server.deviceKey.isBlank()) return "未填写 device key"
val msg = BarkMessage(deviceKey = server.deviceKey, title = "a2i 测试", body = "如果你在 iOS 看到这条推送,说明配置成功", level = "active")
val r = BarkClient(server).push(msg)
return if (r.isSuccess) "已发送,请查看 iOS 端" else "发送失败:${r.exceptionOrNull()?.message}"
}
private suspend fun testNtfy(server: NtfyServer): String {
if (server.topic.isBlank()) return "未填写 topic"
val r = NtfySender.send(server.server, server.topic, server.token, "a2i 测试", "如果你在 ntfy 看到这条推送,说明配置成功")
return if (r.isSuccess) "已发送,请查看 ntfy 接收端" else "发送失败:${r.exceptionOrNull()?.message}"
}
@@ -1,125 +0,0 @@
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
.lineSequence()
.filterNot { it.contains("update-source") }
.joinToString("\n")
.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(
"正在下载更新…",
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)
}
@@ -1,21 +0,0 @@
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)
@@ -1,64 +0,0 @@
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,
)
}
@@ -1,73 +0,0 @@
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,
),
)
@@ -1,8 +0,0 @@
<?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>
@@ -1,15 +0,0 @@
<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>
@@ -1,6 +0,0 @@
<?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>
@@ -1,6 +0,0 @@
<?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
@@ -1,3 +0,0 @@
<resources>
<color name="ic_launcher_bg">#3B7CFF</color>
</resources>
-3
View File
@@ -1,3 +0,0 @@
<resources>
<string name="app_name">a2i</string>
</resources>
-7
View File
@@ -1,7 +0,0 @@
<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
@@ -1,3 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<full-backup-content>
</full-backup-content>
@@ -1,7 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<data-extraction-rules>
<cloud-backup>
</cloud-backup>
<device-transfer>
</device-transfer>
</data-extraction-rules>
-5
View File
@@ -1,5 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<paths>
<external-files-path name="updates" path="." />
<cache-path name="cache" path="." />
</paths>
-6
View File
@@ -1,6 +0,0 @@
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
@@ -0,0 +1,7 @@
# 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
@@ -0,0 +1,94 @@
# 通知远程回复
GotMsg 可以从 Bark / ntfy 通知打开一次性回复网页,把回复送回运行 GotMsg 的原 Android 手机。系统会优先使用通知自带的 Android `RemoteInput`;QQ 等应用可使用无障碍兼容回复,微信使用 Shizuku 回复。
> 远程回复不是模拟登录,也不会接管微信或 QQ 账号。能否成功取决于原通知是否仍然存在、原手机是否在线,以及目标应用当前界面是否可识别。
## 开始前准备
1. 在原 Android 手机上安装 GotMsg,并授权“通知使用权”。
2. 至少启用一个 Bark 或 ntfy 转发配置;邮件和短信通道不提供回复入口。
3. 关闭针对 GotMsg 的省电限制,允许后台运行和自启动。
4. 根据要回复的应用完成下面的额外配置:
- 通知本身带快捷回复:无需额外配置。
- QQ、支付宝、淘宝、钉钉:打开 GotMsg“设置 → 通知回复 → 无障碍兼容回复”,并在系统无障碍设置中启用“GotMsg 兼容回复”。
- 微信:安装并启动 [Shizuku](https://shizuku.rikka.app/download/),授权 GotMsg。微信实际回复走 Shizuku,不依赖系统无障碍服务。
## 使用方法
1. 原手机收到通知后,GotMsg 将它转发到 Bark / ntfy。
2. 在接收手机上点击 Bark 通知,或点击 ntfy 通知及其“回复”动作。
3. 网页会显示应用名、通知标题和正文;输入内容并提交。
4. 原手机拉取任务并执行回复,网页等待最终结果;GotMsg“日志”页也会记录结果。
回复入口只作为通知的点击地址或动作地址,不会在通知正文中重复显示。短信 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
@@ -1,6 +0,0 @@
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
@@ -1,41 +0,0 @@
[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
@@ -1,7 +0,0 @@
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
@@ -1,252 +0,0 @@
#!/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
@@ -1,94 +0,0 @@
@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.

Before

Width:  |  Height:  |  Size: 4.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 622 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 521 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 624 KiB

-22
View File
@@ -1,22 +0,0 @@
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")