3 Commits

Author SHA1 Message Date
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
60 changed files with 319 additions and 4048 deletions
-29
View File
@@ -1,29 +0,0 @@
# Gradle / Android build output
.gradle/
.kotlin/
build/
app/build/
# Local machine configuration
local.properties
.do-session
# 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
-108
View File
@@ -1,108 +0,0 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## 项目概述
**a2i** — 安卓通知转发到 iOS Bark。监听 Android 系统通知(短信、验证码、聊天等),通过 Bark Server 推送到 iOS 设备。iOS 端点击通知可跳转到对应 App(微信、QQ、Telegram)。
## 环境配置(本机)
环境变量需要在每次 shell 调用前显式导出:
```bash
export JAVA_HOME="D:/Dev/JDK-17"
export ANDROID_HOME="D:/Dev/Android/SDK"
```
- JDK 17: `D:\Dev\JDK-17`(项目使用 `sourceCompatibility = VERSION_17`
- Android SDK: `D:\Dev\Android\SDK`
- Gradle 8.11.1wrapper 内置)
- AGP 8.9.1 / Kotlin 2.1.0
`local.properties` 已在项目根(`sdk.dir=D:\Dev\Android\SDK`),**不要提交**。
## 构建与安装
```bash
# 编译 debug APK
export JAVA_HOME="D:/Dev/JDK-17" ANDROID_HOME="D:/Dev/Android/SDK"
./gradlew assembleDebug
# 产物位置
app/build/outputs/apk/debug/app-debug.apk
# 安装到已连接的设备
"D:/Dev/Android/SDK/platform-tools/adb.exe" install -r app/build/outputs/apk/debug/app-debug.apk
```
无 lint / 测试任务配置(项目未启用)。改完代码直接 `assembleDebug` 验证即可。
构建可分发的 release APK(已绑定 debug 签名,产出可直接安装):
```bash
export JAVA_HOME="D:/Dev/JDK-17" ANDROID_HOME="D:/Dev/Android/SDK"
./gradlew assembleRelease
# 产物:app/build/outputs/apk/release/app-release.apk
adb install -r app/build/outputs/apk/release/app-release.apk
```
> `release` 复用 `debug` 签名配置(`signingConfigs.getByName("debug")`),适合个人发布到 GitHub Release;正式商店上架前需替换为独立的 release keystore。
## 架构
### 核心数据流
```
NotifyListenerService (系统通知入口)
→ NotificationProcessor.process(sbn) → Decision(message, reason, code)
→ BarkClient.push(msg) → OkHttp POST
→ LogStore.addLog(...)
失败 → 加入 pending → RetryWorker (WorkManager, 15min 周期)
```
### 包结构 `com.a2i.forwarder`
| 文件 | 职责 |
|---|---|
| `A2iApp.kt` | `Application` 单例;持有全局 `appScope` 和三个 Store`settings``appRules``logStore`)。Store 都用 `MutableStateFlow`UI 通过 `collectAsState()` 订阅。 |
| `service/NotifyListenerService.kt` | `NotificationListenerService` 实现;每条通知调 `processor.process()`,过滤后推送。 |
| `core/NotificationProcessor.kt` | **核心决策点**。包含 LRU 去重缓存(5s)、脱敏检测(`*****` → 回退到 `tickerText`)、系统状态消息过滤、广告过滤、验证码提取、特殊 App 解析。返回 `Decision(message?, reason, appLabel, code)`。 |
| `core/CodeExtractor.kt` | 三段正则:关键词+数字、数字+关键词、纯数字 4-8 位。检测到全 `*****` 时直接返回 null。 |
| `core/AdFilter.kt` | 内置营销关键词 + 用户自定义关键词(`settings.adKeywords`)。 |
| `core/AppParsers.kt` | 微信/QQ/Telegram 的 `clickUrl` 映射 + 重要消息关键词(@我、验证码、通话等)。 |
| `core/BarkClient.kt` | OkHttp POST 到 `${server}/push`,超时 10/10/15s。 |
| `core/RetryWorker.kt` | WorkManager 周期任务,每 15 分钟重发 `pending` 队列中的失败消息。 |
| `core/LogStore.kt` | 最近 300 条日志 + 失败重发队列(≤100)。DataStore 持久化。 |
| `core/IconExporter.kt` | 一键导出所有已安装 App 图标到用户选择的 SAF 目录(用于部署到 iOS Bark 图床)。 |
| `store/SettingsStore.kt` | Bark 服务器列表、全局开关、广告关键词等。`MutableStateFlow` + DataStore。 |
| `store/AppRulesStore.kt` | 黑/白名单模式 + 默认黑名单(系统 App + 三星剪贴板/键盘 + GMS)。 |
| `ui/nav/AppNav.kt` | 4 个底部 Tab + 日志页。 |
| `ui/theme/*` | 固定品牌配色(`Color.kt`)、自定义 `Typography``Type.kt`),`A2iTheme` 关闭动态取色、统一深浅色方案(`Theme.kt`)。 |
| `ui/screens/*.kt` | HomeScreen(品牌横幅 + 运行状态面板 + 统计指标)、AppManageScreen、FilterScreen、SettingsScreen、LogScreen。 |
| `ui/Components.kt` | 共享 UI 组件层:`ScreenTitle``SectionCard``SwitchRow``ClickRow``IconBox``StatusBadge``MetricBlock`,以及 `appVersionName(context)``drawableToImageBitmap(d)``isNotificationListenerEnabled(context)` 工具函数。 |
### 重要约定
- **Store 全局单例**:所有 Store 通过 `A2iApp.instance.xxx` 访问。**不要**在 Compose 中创建新的 Store 实例。
- **协程作用域**:跨屏调用用 `app.appScope.launch { ... }`Application 级别的 `SupervisorJob + Dispatchers.Default`)。
- **过滤流水线**(顺序很重要,在 `NotificationProcessor.process` 中):
1. 全局开关 → 2. ongoing 过滤 → 3. App 黑/白名单 → 4. 空内容/脱敏 → 5. 噪音包名 → 6. 系统状态消息 → 7. 近空内容 → 8. 广告过滤 → **9. 去重检查(5s 窗口)** → 10. 验证码提取 → 11. 特殊 App 解析 → 12. 构造 BarkMessage
- **Bark API** 字段映射在 `BarkMessage.kt``@SerialName`):`device_key`/`autoCopy`/`isArchive` 用下划线/驼峰别名,其他用蛇形。
- **版本号**`app/build.gradle.kts``versionCode`/`versionName`,运行时通过 `appVersionName(context)` 读取,首页底部居中显示 `v{version}`。改完代码记得同步递增。
### 通知监听授权
监听服务依赖 `Settings.ACTION_NOTIFICATION_LISTENER_SETTINGS` 授权。`HomeScreen` 检测授权状态,未授权时显示跳转按钮。授权是**一次性**的,卸载后需重新授权。
### 调试日志
`NotifyListenerService.handle()` 每次都会写一条 `ForwardLog``LogStore`(无论转发/过滤/失败),可在「日志」页查看最近 300 条。失败会自动加入 `pending` 队列,由 `RetryWorker` 后续重发。
## 版本与发布
- 当前版本:`versionCode = 3``versionName = "1.2.0"`(位于 `app/build.gradle.kts`)。改完代码记得同步递增。
- 远端仓库:`https://github.com/lsxf/a2i.git`,默认分支 `main`
- 提交策略:`.gitignore` 已排除 `app/build``.gradle``.kotlin``local.properties``.claude/``do.ps1`、截图与临时文件,**不要**提交它们。
- 发版流程:本地打 tag`git tag -a v1.2.0 -m "..."`)→ `git push origin v1.2.0` → 在 GitHub Releases 基于 tag 发布,并上传 `app-release.apk` 作为二进制附件。
- UI 现状:统一品牌科技感主题(固定品牌色 + 自定义 Typography + 共享组件层),深浅色双方案,关闭动态取色;新组件统一通过 `ui/Components.kt` 复用,避免各页面各拼一套样式。
+269 -89
View File
@@ -1,112 +1,296 @@
# A2i ·安到果(按倒过[呲牙] # GotMsg · 有消息
把安卓手机上的通知(短信、验证码、电话“有电话进来这个通知,不是电话呼叫转移”、微信、QQ、Telegram 等)实时转发到 iOS,通过 [Bark](https://github.com/Finb/Bark) 推送到 iPhone / iPad。在 iOS 上点击通知,还能一键跳转到对应的 App 把安卓手机上的系统通知(短信、验证码、来电、微信、QQ、Telegram、企业微信、App 推送等)实时转发到 **iPhoneiPad、其它安卓机、鸿蒙机、桌面邮箱**,并在接收端直接**远程回复**原安卓手机
适用于把安卓备机的消息同步到 iOS 设备,不漏验证码、不漏重要聊天。 适用于把安卓备机的消息同步到主力机,不漏验证码、不漏重要聊天、不断网仍能收到关键通知
## 主要功能 ![GotMsg 设置页](screenshots/overview.png)
- **系统级通知监听**:接入 Android 通知监听服务,覆盖所有发出通知的 App。 ---
- **Bark 推送**:支持官方 Bark 服务和自建 Bark Server,可保存多个服务器并随时切换。
- **验证码自动提取**:自动识别通知中的验证码,收到即复制,也可点击复制。
- **通知优化**
- 广告关键词过滤(内置 + 自定义)
- 持续性通知过滤(音乐、下载、通话等 ongoing)
- 内容脱敏与去重
- 微信 / QQ / Telegram 专项优化(发送者作标题、按会话分组、重要消息提级、跳转 iOS 对应 App)
- **应用级规则**:黑名单 / 白名单两种模式,可按 App 单独控制是否转发。
- **失败自动重试**:发送失败的通知进入待重发队列,由 WorkManager 定期重试,也可手动触发。
- **本地日志**:保留最近 300 条处理记录,方便排查为什么某条通知被过滤或发送失败。
- **电话来电通知**:通过轮询 `getCallState()` 实时感知来电和未接来电(绕过 MIUI 对系统电话通知的限制,需授予"电话"权限)。
- **断网短信兜底**:无网络时自动把验证码、来电用短信发到 iPhone。
- 自动识别运营商(移动/联通/电信/广电),发免费查询短信拿套餐内短信余量
- 余额 ≤5 条自动停用,下次打开 App 提醒
- 每 5 分钟最多发 1 条,避免刷屏
- 自动解析失败时回退手动额度计数
## 快速开始 ## 一、五分钟上手
### 1. 安装 ### 1. 安装
从 [Releases](../../releases) 下载最新 APK,安装到**需要被转发的安卓手机**(需 Android 14 / API 34+)。Release 同时提供 universal 全架构包和 arm64-v8a / armeabi-v7a / x86 / x86_64 单架构包,App 内更新页会默认选中与你设备最匹配的版本。
从 [Releases](../../releases) 下载最新 APK,安装到安卓手机(需 Android 14 / API 34 及以上)。 ### 2. 选一个推送通道
推荐三选一:
### 2. 配置 Bark | 接收设备 | 推荐通道 | 难度 |
|---|---|---|
| iPhone / iPad | **Bark** | ⭐ 最简单 |
| 安卓手机 | **ntfy** | ⭐ 推荐开源 |
| 鸿蒙手机 | **Meow** | 鸿蒙专属 |
| 任何能收邮件的设备 | **电邮** | 桌面/平板可用 |
1. 在 iOS 上安装 [Bark](https://apps.apple.com/us/app/bark-customed-push/id1613210986) 打开 GotMsg -> 「设置」-> 选对应通道 -> 点右上角 **i** 按钮 -> 按弹窗步骤配 -> 点该行「发送」测试。可以**同时启用多个通道**,所有勾选的会并行收到
2. 打开 Bark,复制你的专属推送地址,形如 `https://api.day.app/你的Key`
3. 在 a2i 的「设置」中添加服务器:
- 名称:随意,例如「我的 iPhone」
- 服务器地址:`https://api.day.app`(自建则填自建地址)
- Device KeyBark 地址中的那段 Key
### 3. 授权通知监听 ### 3. 授权通知监听
首页会提示「需要授权通知监听」。点击「前往系统授权」,在系统的通知访问设置中开启 GotMsg。
打开 a2i,首页会提示「需要授权通知监听」。点击「前往系统授权」,在系统的通知访问设置中开启 a2i。
> 授权是一次性的,卸载后重新安装需要再次授权。
### 4. 开启转发 ### 4. 开启转发
首页打开「通知转发」开关。安卓收到通知后按配置自动推送到各通道。**发现新版时也会自动把更新通知推给所有已启用的通道**,不用进 App 也能升级。
回到首页,打开「通知转发」开关。此时安卓收到的通知就会按规则推送到 iOS。 ---
## 使用建议 ## 二、推送通道配置详解
- **只想收验证码**:进入「过滤」页,开启「验证码自动提取」,把「广告过滤」等按需开启;再到「应用」页切到白名单模式,只勾选短信类 App 每条配置都可独立勾选/删除/排序/测试。点「添加」时建议每条都先「发送」测试一次
### 2.1 BarkiOS / iPadOS
[Bark](https://github.com/Finb/Bark) 是 iOS 上最轻量的推送方案,复制地址即用,**推荐所有 iOS 用户首选**。
**配置步骤**
1. iPhone / iPad 上安装 Bark,首次打开允许通知权限。
2. 复制 Bark 首页的推送地址,形如 `https://api.day.app/你的Key`
3. GotMsg「设置 -> Bark 服务器 -> 添加」:
- **名称**:随意,例如「我的 iPhone」
- **服务器地址**`https://api.day.app`(自建则填自建地址)
- **Device Key**Bark 地址中 `/` 后面的那段
4. 保存后点「发送」测试;iPhone 收到「GotMsg 测试」即成功。
5. 多台 iOS 设备:可继续添加多条 Bark 配置,每条独立勾选;列表顺序即推送顺序,可开启「首个成功即止」或全部广播。
**为什么推荐 Bark**:iOS 上点击 Bark 通知体验最自然;通知附带 GotMsg 回复链接,可从 iPhone 直接回复原安卓;Bark 服务器在中国大陆可直接访问。
### 2.2 ntfyAndroid / iOS / 网页)
[ntfy](https://ntfy.sh) 是开源的 HTTP pub-sub 通知服务,**支持 Android、iOS 和网页端**,无需注册项目。
**配置步骤**
1. 接收端安装 ntfy
- Android[Google Play](https://play.google.com/store/apps/details?id=io.heckel.ntfy) / [F-Droid](https://f-droid.org/en/packages/io.heckel.ntfy/) / [GitHub](https://github.com/binwiederhier/ntfy)
- iOS[App Store](https://apps.apple.com/us/app/ntfy/id1625396347)
2. 打开 ntfy -> 添加订阅。服务器填 `https://ntfy.sh`(或自建地址);Topic 用一串**随机难猜的名字**(如 `gotmsg_a8f3d2`)。
3. GotMsg「设置 -> ntfy 转发 -> 添加」:
- **服务器**:与接收端完全一致
- **Topic**:与接收端完全一致
- **Token**:仅自建服务且启用了鉴权时填
4. 保存后点「发送」测试;接收端收到「GotMsg 测试」即成功。
> **安全提示**:公共 topic 谁知道名字就能订阅或发送。Topic 名**不要用**手机号、姓名、邮箱等可猜信息。
>
> **远程回复**:GotMsg 在转发聊天类通知时,会**自动给 ntfy 通知挂上「View」动作按钮和正文中的回复链接**。iOS 上点通知本体只能进 ntfy App,要回复请点动作按钮或正文里的回复链接。
### 2.3 Meow(鸿蒙 / HarmonyOS
Meow 是鸿蒙手机的推送接收方案。GotMsg 按你填写的 Meow 接口地址和 Device Key 发出 HTTP 请求即可。
**配置步骤**
1. 鸿蒙手机上安装 Meow,确认 Meow 自身能正常接收推送。
2. 在 Meow 里找到**推送接口地址**和对应的 **Device Key / Token**
3. GotMsg「设置 -> Meow 转发 -> 添加」:
- **名称**:按鸿蒙设备填写(方便区分多台)
- **服务器地址**:Meow 给出的完整 API 地址
- **Device Key**Meow 给你的设备 Key(**不是**鸿蒙锁屏密码、华为账号密码)
4. 保存后点「发送」测试;鸿蒙手机收到「GotMsg 测试」即成功。
> 失败排查:检查 Meow 接口地址是否完整、Key 是否复制完整、鸿蒙手机是否允许 Meow 通知。
### 2.4 电邮转发
通过 SMTP 把通知发到邮箱(**适合无手机接收的场景**,如仅在桌面/平板查看)。GotMsg **直连 SMTP**,不依赖外部邮件服务。
**配置步骤**
1. 准备一个 SMTP 邮箱(QQ/163/Gmail/Outlook 都可),**开启 SMTP 服务并生成授权码**(不是登录密码)。
- QQ:设置 -> 账户 -> POP3/IMAP/SMTP/Exchange/CardDAV/CalDAV服务 -> 开启 SMTP -> 生成授权码
- 163:设置 -> POP3/SMTP/IMAP -> 开启 SMTP -> 设置授权码
- Gmail:账户 -> 安全性 -> 两步验证 -> 应用专用密码
2. GotMsg「设置 -> 电邮转发 -> 添加」:
- **服务器(SMTP 主机)**`smtp.qq.com` / `smtp.163.com` / `smtp.gmail.com`
- **端口**`465`(推荐,隐式 SSL
- **账号**:完整邮箱地址
- **授权码 / 密码**:上一步生成的授权码
- **发件人**:通常与账号相同
- **收件人**:接收通知的目标邮箱
3. 保存后点「发送」测试;收件人收到「GotMsg 测试」即成功。
> **国内邮箱推荐端口 465**QQ/163 均支持)。Gmail 需要应用专用密码而不是账户密码。
### 2.5 断网短信兜底
**当安卓手机没有网络时**,把重要通知(验证码、来电)通过 **SIM 卡短信** 发到另一台手机(通常是你的 iPhone)。需要安卓卡里还有短信套餐余量。
**配置步骤**
1. 在 GotMsg「设置 -> 短信兜底」点「授予短信权限」(安卓系统权限)。
2. 点「添加」输入**目标手机号**(如 iPhone 号,接收端是另一台手机,**不是** GotMsg 本身)。
3. 选运营商:
- 选了「自动识别」:GotMsg 会发免费查询短信给运营商服务号,解析回执获取余量。回执短信本身会被识别并拦截,不转发。
- 选了「手动额度」:在「手动短信额度」填入当前套餐短信条数,每次发短信后自动减 1。
4. 当余量 ≤ 5 时自动暂停,下次打开 App 提醒续费。
5. 频率限制:每 5 分钟最多发 1 条(避免刷屏),同一条通知 5 分钟内不重复。
> **适用场景**:安卓手机没 WiFi 时,验证码不会丢,漏不了重要登录。
---
## 三、通知远程回复(Bark / ntfy)
GotMsg v1.11.0 起,Bark 和 ntfy 转发通知会附带回复入口,接收端在 iPhone 或 Android 上打开回复页输入内容后,回复会经 **GotMsg 中继(`https://r.gotmsg.pp.ua`)送回原 Android 手机**。GotMsg 优先使用原通知的 Android `RemoteInput` 快捷回复能力;微信等没有 `RemoteInput` 的 App 走无障碍兼容回复;微信 8.0.49+ 进一步屏蔽无障碍时,可启用 **Shizuku 路径**直接调用 `input` 命令绕过。
### 3.1 三种回复路径
| 路径 | 适用 | 是否需要解锁原手机 | 额外授权 |
|---|---|---|---|
| **原生 RemoteInput** | 短信、WhatsApp、企业 IM、Telegram 等暴露系统回复动作的 App | 否(后台执行) | 无 |
| **无障碍兼容回复** | QQ、支付宝、淘宝、钉钉 | 是 | 「GotMsg 兼容回复」无障碍服务 |
| **Shizuku 兼容回复(仅微信)** | 微信(绕过无障碍屏蔽) | 是 | Shizuku 已启动并授予 GotMsg 权限 |
### 3.2 启用方式
1. **Bark / ntfy 接收端**:无需任何额外配置。GotMsg 自动在通知正文或 ntfy 动作按钮里附上回复链接。
2. **原 Android 手机**
- 默认即可使用 `RemoteInput`(无需任何操作)。
- 想在没有 `RemoteInput` 的 App 上回复,进入「设置 -> 通知回复」打开「无障碍兼容回复」,再到系统无障碍设置里启用「GotMsg 兼容回复」服务。
- 想在最新版微信上回复,启用 **Shizuku**(在 GitHub Releases 下载并启动),GotMsg 首页会显示 Shizuku 状态卡片;点击「授权 Shizuku」即可。微信路径会自动以 Shizuku 身份模拟 `input tap` 聚焦输入框、写剪贴板、`KEYCODE_PASTE` 注入任意 Unicode,并通过截图像素扫描识别微信绿色发送按钮。
### 3.3 使用流程
- Bark:通知点击目标会优先变成回复页,正文也会附带回复链接;普通消息不会再跳微信/QQ 等原 App。
- ntfy:通知附带 View 动作按钮和正文中的回复链接;iOS 上点通知本体只进入 ntfy App,请点动作按钮或正文里的回复链接。
完整使用方法与限制见 [docs/notification-reply.md](docs/notification-reply.md)。接收端不需要安装 iOS 快捷指令,也不需要订阅额外的 `_reply` topic(旧版方案已移除,见 [docs/A2iReply-shortcut.md](docs/A2iReply-shortcut.md))。
> 链接 10 分钟内有效,只能成功提交一次。原生快捷回复可在后台执行;无障碍 / Shizuku 兼容回复会亮屏并打开原 App;自动解锁仅对**无密码滑动锁**有效,密码 / PIN / 指纹锁需手动解锁一次;完成后会**自动重新锁屏**。付款、订单、营销等非聊天通知不能回复。
---
## 四、自建推送服务(可选)
所有通道都支持自建,更好、更私密、可控。
### 4.1 自建 Bark 服务器
GotMsg 默认用 Bark 官方服务器(`https://api.day.app`),免费够用。自建适合追求完全控制。
**Docker 一键部署**(推荐):
```bash
docker run -d --name bark --restart=unless-stopped \
-p 8080:8080 \
-v /var/lib/bark:/data \
finab/bark-server
```
然后用 Nginx Proxy Manager 加 HTTPS 域名。详细三种方案对比请看 [Bark 官方文档](https://github.com/Finb/Bark)。
**GotMsg 配置**:服务器地址填 `https://你的域名.com`(不要 `/push` 后缀),Device Key 填你在自建 Bark 上创建的 Key。
### 4.2 自建 ntfy 服务器
[ntfy](https://ntfy.sh) 是为自建设计的,**比 Bark 还简单**——官方直接提供 Docker 一行启动:
```bash
docker run -d --name ntfy --restart=unless-stopped \
-p 80:80 \
-v /var/lib/ntfy:/var/lib/ntfy \
binwiederhier/ntfy serve
```
推荐加 Nginx 反代上 HTTPS 域名。完整文档:[docs.ntfy.sh](https://docs.ntfy.sh/install/)。
**GotMsg 配置**:服务器地址填 `https://ntfy.你的域名.com`Topic 照旧。
### 4.3 自建 Meow 服务器
Meow 是鸿蒙客户端,服务端一般用 Meow 自带的。GotMsg 端无需配置服务端,只需填 Meow 给你的接口地址 + Key。
---
## 五、应用内更新
GotMsg 内置更新检测:定期拉取 Gitea + GitHub Releases**发现新版时自动向所有已启用通道推送一条更新通知**(带当前版本号、目标版本号、变更摘要、APK 大小)。首页显示醒目横幅,可选「立即下载」或「忽略此版本」。
下载页会**默认选中匹配你设备 ABI 的 APK**universal / arm64-v8a / armeabi-v7a / x86_64 / x86),也可手动切换架构;下载完成后通过 `FileProvider` 调起系统安装器。
Gitea 源为主(国内可达),GitHub 为兜底;主源下载失败会自动尝试另一个源的同版本链接。
> 取消自动检查:在「设置 -> 应用内更新」关闭「更新检查」即可,手动检查仍可用。
---
## 六、来电与未接来电
MIUI / HyperOS 不向第三方监听器分发系统电话通知。GotMsg 通过 **轮询 `TelephonyManager.getCallState()`** 绕过此限制:
- **来电**:构造「有电话进来」推送(Android 14+ 禁止第三方读取实时号码);如无障碍服务读到号码则展示真实号码。
- **未接来电**:通话结束后查系统 `CallLog` 获取号码,并通过 `ContactsContract.PhoneLookup` 反查**联系人姓名**,推送形如「张三\n138xxxx」。
需要在系统设置中授予「电话」权限(设置 -> 应用 -> GotMsg -> 权限 -> 电话)。
---
## 七、过滤与噪音控制
通知处理流水线(在 `NotificationProcessor` 中按顺序执行):
1. 全局开关
2. 持续性通知(ongoing)静默
3. VPN / 代理客户端(Surfboard、Clash Meta、Clash for Android、v2rayNG、小米设备互联)常驻通知静默
4. 米家「设备状态」泛化通知静默(门锁 / 告警等具体事件仍正常转发)
5. App 黑/白名单
6. 空内容 / 脱敏内容
7. 噪音包名(三星剪贴板 / 键盘、GMS、SystemUI、Google 输入法)
8. 系统状态消息("正在运行"等)
9. **泛化无意义通知**(标题「新消息」+ 正文「你有一条新消息」等占位文案)静默
10. 近空内容(纯符号 / 纯空格 / 仅 App 名)
11. 广告过滤(内置营销关键词 + 用户自定义关键词)
12. 已发短信(仅收不发)
13. 运营商余额回执拦截(解析余量后不转发)
14. 去重检查(5 秒窗口)
15. 验证码提取(关键词 + 数字 / 数字 + 关键词 / 纯数字 4–8 位)
16. 特殊 App 解析(微信 / QQ / Telegram 优化 + 重要消息关键词标记)
17. 构造 BarkMessage 并推送
验证码开启自动复制后会被复制到剪贴板;推送级别在重要消息上自动提升到 `timeSensitive` 以点亮 iOS 焦点通知。
---
## 八、使用建议
- **只想收验证码**:进入「过滤」页,开启「验证码自动提取」和「广告过滤」;再到「应用」页切到白名单模式,只勾选短信类 App。
- **想同步聊天**:保持黑名单模式(默认),在「过滤」页开启「微信 / QQ / Telegram 优化」。 - **想同步聊天**:保持黑名单模式(默认),在「过滤」页开启「微信 / QQ / Telegram 优化」。
- **通知没推过来**:先看「日志」页,每条通知(无论转发、过滤还是失败)都会记录原因。 - **通知没推过来**:先看「日志」页,每条通知(无论转发、过滤还是失败)都会记录原因。失败会自动加入 pending 队列,由 WorkManager 每 15 分钟重发。
- **想让通知带图标**参考下方「应用图标」一节。 - **想让通知带图标**下方「应用图标」一节。
- **电话通知不工作**MIUI 会把系统电话通知限制为不向第三方通知监听器分发,本 App 通过轮询 `getCallonyState()` 绕过此限制。需授予「电话」权限(设置 → 应用 → a2i → 权限 → 电话) - **电话通知不工作**:MIUI 不向第三方监听器分发系统电话通知,GotMsg 通过轮询 `getCallState()` 绕过此限制。需授予「电话」权限。
- **断网收不到通知**:开启「设置 → 断网短信兜底」填好 iPhone 号码、授予短信权限、设好手动额度。断网时验证码和来电走短信(消耗套餐额度,5 分钟限 1 条)。 - **断网收不到通知**:开启「设置 -> 短信兜底」填好 iPhone 号码、授予短信权限。断网时验证码和来电走短信(消耗套餐额度,5 分钟限 1 条)。
- **想全平台通知**:可同时启用多个通道(每个通道的多个条目独立勾选),所有启用的会同时收到。
- **推送报 connection closed**:通常不是被墙,而是手机上的代理 / VPN 客户端干扰 Bark 服务器连接;先关代理或换自建 Bark 验证。
## 应用图标 ---
Bark 的 `icon` 字段需要一个公网可访问的图片 URL。a2i 支持把已安装 App 的图标批量导出到你选择的目录,方便上传到你自己的图床。 ## 九、应用图标
规则:转发时 `icon = 图标 URL 前缀 + 安卓包名 + .png`。在「设置 → 应用图标」里: Bark 和 ntfy 的通知 `icon` 字段需要公网可访问的图片 URL。GotMsg 支持把已安装 App 的图标批量导出,方便上传到你自己的图床。
规则:转发时 `icon = 图标 URL 前缀 + 安卓包名 + .png`。在「设置 -> 应用图标」里:
1. 填写图标 URL 前缀,例如 `https://你的图床域名/icons/` 1. 填写图标 URL 前缀,例如 `https://你的图床域名/icons/`
2. 选择一个目录,点「导出图标」,把所有 App 图标以 `包名.png` 形式导出。 2. 选择一个目录,点「导出图标」,把该目录下所有 App 图标以 `包名.png` 形式导出。
3. 把导出的图片上传到你的图床对应目录。 3. 把导出的图片上传到你的图床对应目录。
留空前缀则不显示图标。 留空前缀则不显示图标。
## 技术栈 ---
- Kotlin + Jetpack ComposeMaterial 3 ## 十、构建与体积
- AndroidX DataStore(持久化设置与日志)
- OkHttpBark 推送)
- WorkManager(失败重试)
- Navigation Compose(单 Activity 多页面)
- 最低 Android 14API 34),目标 Android 16API 36
## 本地构建 Release 构建开启 **R8 + 资源压缩**universal APK 约 5 MB;同时输出 ABI splits,每个架构单独的 APK 体积更小。
需要 JDK 17 和 Android SDK。
构建:
```bash ```bash
# 设置环境变量(按你的安装路径) export JAVA_HOME="D:/Dev/JDK-17" ANDROID_HOME="D:/Dev/Android/SDK"
export JAVA_HOME=/path/to/jdk-17
export ANDROID_HOME=/path/to/android-sdk
# 构建 debug APK
./gradlew assembleDebug
# 构建可分发的 release APK
./gradlew assembleRelease ./gradlew assembleRelease
# 产物:app/build/outputs/apk/release/app-universal-release.apk
# app/build/outputs/apk/release/app-arm64-v8a-release.apk
# app/build/outputs/apk/release/app-armeabi-v7a-release.apk
# app/build/outputs/apk/release/app-x86-release.apk
# app/build/outputs/apk/release/app-x86_64-release.apk
``` ```
产物位置: `release` 复用 `debug` 签名配置(`signingConfigs.getByName("debug")`),适合个人发布到 GitHub / Gitea Release;正式商店上架前需替换为独立的 release keystore。
- debug`app/build/outputs/apk/debug/app-debug.apk` ---
- release`app/build/outputs/apk/release/app-release.apk`
安装到已连接的设备: ## 十一、界面截图
```bash
adb install -r app/build/outputs/apk/release/app-release.apk
```
## 界面截图
| 首页 | 应用管理 | 过滤设置 | | 首页 | 应用管理 | 过滤设置 |
|---|---|---| |---|---|---|
@@ -116,23 +300,19 @@ adb install -r app/build/outputs/apk/release/app-release.apk
|---|---| |---|---|
| ![设置](screenshots/settings.png) | ![转发日志](screenshots/log.png) | | ![设置](screenshots/settings.png) | ![转发日志](screenshots/log.png) |
## 工作原理 ---
``` ## 十二、隐私与安全
Android 系统通知
└─ NotifyListenerService 接收 - GotMsg **不收集任何遥测**到第三方;本地仅记录最少必要的设备 ID / 密钥(用于回复中继鉴权,256 位随机,备份规则明确排除)。
└─ NotificationProcessor 处理流水线 - 回复中继 `https://r.gotmsg.pp.ua` 只持久化**设备密钥 + 回复令牌的 SHA-256 哈希**,正文 24 小时后清除;回复链接放在 URL fragment 中,不会进入服务器访问日志。
├─ 全局开关 / ongoing 过滤 / 黑白名单 - 拿到回复链接的人可在 10 分钟有效期内提交一次回复,因此不要转发或公开该链接。
├─ 空内容、脱敏、噪音、系统状态过滤 - 无障碍 / Shizuku 兼容回复仅在收到有效的一次性回复事件时运行,严格校验目标包名;Shizuku 路径只点击经像素扫描确认的微信绿色发送按钮,避免误操作。
├─ 广告关键词过滤
├─ 去重(5 秒窗口) ---
├─ 验证码提取
└─ 微信 / QQ / Telegram 专项解析
└─ BarkClient 推送到 Bark Server
└─ 失败 → 待重发队列 → RetryWorker 定期重试
└─ 写入本地日志(最近 300 条)
```
## License ## License
本项目仅供个人使用。 本项目仅供个人使用。自 v1.10.2 起闭源。
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 = 5
versionName = "1.4.0"
}
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.** { *; }
-49
View File
@@ -1,49 +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" />
<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>
</application>
</manifest>
@@ -1,52 +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.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
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)
}
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,39 +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
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.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 为空" }
val body = json.encodeToString(msg).toRequestBody(JSON)
val url = server.server.trim().removeSuffix("/") + "/push"
val req = Request.Builder().url(url).post(body).build()
http.newCall(req).execute().use { res ->
if (!res.isSuccessful) error("HTTP ${res.code}")
}
}
}
companion object {
private val JSON = "application/json; charset=utf-8".toMediaType()
}
}
@@ -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,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,244 +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?,
)
// ========== 去重缓存 ==========
// 防止同一条通知在短时间内被 Android 系统反复投递导致重复转发
// key = "${pkg}|${title}|${body}" 的 hash, value = 上次转发时间戳
private val dedupCache = object : LinkedHashMap<String, Long>(64, 0.75f, true) {
override fun removeEldestEntry(eldest: MutableMap.MutableEntry<String, Long>?): Boolean {
return size > 128
}
}
private val DEDUP_WINDOW_MS = 5_000L // 5 秒内相同内容不重复转发
// ========== 系统状态消息过滤模式 ==========
private val systemStatusPatterns = listOf(
Regex("""正在运行"""),
Regex("""is running""", RegexOption.IGNORE_CASE),
Regex("""运行中"""),
Regex("""服务(正在|已)启动"""),
Regex("""service (is )?running""", RegexOption.IGNORE_CASE),
Regex("""^.*(?:正在|is)\s*(?:运行|running|run).*$""", RegexOption.IGNORE_CASE),
)
// ========== 系统剪贴板/无用服务包名 ==========
private val noisePackages = setOf(
"com.samsung.android.honeyboard",
"com.samsung.android.app.clipboard",
"com.samsung.android.clipboard",
"com.android.systemui",
"com.google.android.inputmethod.latin",
"com.google.android.gms",
)
fun process(sbn: StatusBarNotification): Decision {
val pkg = sbn.packageName
val n = sbn.notification ?: return Decision(null, "无通知", pkg, null)
val extras = n.extras
// 提取标题和文本(尝试多个来源)
val title = extras.getCharSequence(Notification.EXTRA_TITLE)?.toString().orEmpty()
var text = extras.getCharSequence(Notification.EXTRA_TEXT)?.toString().orEmpty()
val big = extras.getCharSequence(Notification.EXTRA_BIG_TEXT)?.toString().orEmpty()
val sub = extras.getCharSequence(Notification.EXTRA_SUB_TEXT)?.toString().orEmpty()
// ---- 修复 ***** 脱敏问题 ----
// 部分 ROM 会把验证码通知的 EXTRA_TEXT 脱敏为 *****
// 尝试从 tickerText 和其他备用源获取真实内容
if (isRedacted(text)) {
// 优先用 bigText(通常未被脱敏)
if (!isRedacted(big) && big.isNotBlank()) {
text = big
} else {
// 尝试 tickerTextdeprecated 但仍可能包含原文)
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)
// ---- App 黑白名单 ----
if (!rules.isForwardingOn(pkg))
return Decision(null, "此 App 已关", appLabel, null)
// ---- 空内容 / 脱敏内容过滤 ----
if (title.isBlank() && content.isBlank())
return Decision(null, "空内容", appLabel, null)
// 如果所有文本源都被脱敏了,不要转发无意义内容
if (isRedacted(content) && isRedacted(title))
return Decision(null, "内容已脱敏", appLabel, null)
// ---- 系统噪音包名过滤 ----
if (pkg in noisePackages && (content.isBlank() || content.contains("clipboard", true) || content.contains("剪贴", true)))
return Decision(null, "系统噪音通知", appLabel, null)
// ---- 系统状态消息过滤 ----
if (isSystemStatusMessage("$title $content $sub"))
return Decision(null, "系统状态消息", appLabel, null)
// ---- 近空内容过滤(纯符号、纯空格、仅 app 名等)----
val trimmedContent = content.trim()
if (trimmedContent.length < 2 && title.isBlank())
return Decision(null, "内容过短", appLabel, null)
// ---- 广告过滤 ----
if (settings.adFilterEnabled.value && AdFilter.isAd("$title $content $sub", settings.adKeywords.value))
return Decision(null, "广告过滤", appLabel, null)
// ---- 短信"发出"过滤:只保留收到的短信 ----
if (isSentSmsNotification(pkg, title, content))
return Decision(null, "已发短信(仅收不发)", appLabel, null)
// ---- 运营商余额回执拦截:解析短信余量,不转发 ----
val balanceParsed = tryParseCarrierBalance(pkg, title, content)
if (balanceParsed != null) {
return Decision(null, "运营商余额回执(${balanceParsed}条)", appLabel, null)
}
// ---- 去重检查 ----
val dedupKey = "$pkg|${title.take(80)}|${content.take(80)}"
val lastTime = dedupCache[dedupKey]
val now = System.currentTimeMillis()
if (lastTime != null && (now - lastTime) < DEDUP_WINDOW_MS) {
return Decision(null, "去重(${now - lastTime}ms 内重复)", appLabel, null)
}
dedupCache[dedupKey] = now
// ---- 验证码提取 ----
var code: String? = null
if (settings.codeEnabled.value) {
code = CodeExtractor.extract("$title $content")
}
// ---- 特殊 App 解析 ----
var msgTitle = title.ifBlank { appLabel }
var group: String = pkg
var clickUrl: String? = null
var important = false
if (settings.specialEnabled.value) {
val p = AppParsers.parse(pkg, title, content)
p.titleOverride?.let { msgTitle = it }
p.groupKey?.let { group = it }
clickUrl = p.clickUrl
important = p.important
}
if (code != null) important = true
if (code != null) {
msgTitle = title.ifBlank { "验证码" }
}
val iconPrefix = settings.iconPrefix.value.trim()
val icon = if (iconPrefix.isBlank()) null else iconPrefix.trimEnd('/') + "/" + pkg + ".png"
val level = if (important) "timeSensitive" else "active"
val msg = BarkMessage(
deviceKey = settings.currentServer.value?.deviceKey.orEmpty(),
title = msgTitle.take(100),
body = content.ifBlank { title }.take(500),
level = level,
icon = icon,
group = group,
url = clickUrl,
autoCopy = if (code != null) "1" else null,
copy = code,
isArchive = "1",
)
return Decision(msg, "转发", appLabel, code)
}
// ---- 辅助方法 ----
/** 检测文本是否被系统脱敏(全部或大部分是 * 号) */
private fun isRedacted(text: String): Boolean {
if (text.isBlank()) return false
val asteriskCount = text.count { it == '*' }
// 如果 * 占比超过 60%,认为是脱敏内容
return asteriskCount >= 3 && asteriskCount.toDouble() / text.length > 0.6
}
/** 检测是否为系统状态消息(如"短信正在运行" */
private fun isSystemStatusMessage(text: String): Boolean {
return systemStatusPatterns.any { it.containsMatchIn(text) }
}
/** 短信 App 列表 */
private val smsPackages = setOf(
"com.android.mms",
"com.google.android.apps.messaging",
"com.samsung.android.messaging",
"com.android.messaging",
)
/** 发出的短信通知关键词 */
private val sentSmsPatterns = listOf(
Regex("""发送中"""),
Regex("""正在发送"""),
Regex("""已发送"""),
Regex("""发送成功"""),
Regex("""短信已发出"""),
Regex("""消息已发出"""),
Regex("""sending""", RegexOption.IGNORE_CASE),
Regex("""message sent""", RegexOption.IGNORE_CASE),
Regex("""sent$""", RegexOption.IGNORE_CASE),
)
/** 判断是否为发出的短信通知(而非收到的) */
private fun isSentSmsNotification(pkg: String, title: String, content: String): Boolean {
if (pkg !in smsPackages) return false
val blob = "$title $content"
return sentSmsPatterns.any { it.containsMatchIn(blob) }
}
/**
* 拦截运营商余额回执短信:title 是运营商服务号(10086/10010/10001/10099),
* 解析正文中的剩余短信条数,写入设置并按"过滤"处理。
* 返回解析到的条数(已处理),返回 null 表示不是回执或解析失败。
*/
private fun tryParseCarrierBalance(pkg: String, title: String, content: String): Int? {
if (pkg !in smsPackages) return null
val serviceNumbers = setOf("10086", "10010", "10001", "10099")
if (title.trim() !in serviceNumbers) return null
val app = A2iApp.instance
val count = app.carrierBalanceQuery.parseReply("$title $content") ?: return null
kotlinx.coroutines.runBlocking { app.carrierBalanceQuery.applyParsedBalance(count) }
return count
}
}
@@ -1,150 +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
val server = settings.currentServer.value ?: return
if (server.deviceKey.isBlank()) return
when {
// IDLE → RINGING:来电
from == TelephonyManager.CALL_STATE_IDLE && to == TelephonyManager.CALL_STATE_RINGING -> {
ringing = true
offhook = false
val now = System.currentTimeMillis()
if (now - lastRingTime > 3000) {
lastRingTime = now
val msg = BarkMessage(
deviceKey = server.deviceKey,
title = "来电",
body = "有电话进来",
level = "timeSensitive",
group = "phone_call",
isArchive = "1",
)
pushToServer(server, msg, app, "来电")
}
}
// RINGING → OFFHOOK:接听
from == TelephonyManager.CALL_STATE_RINGING && to == TelephonyManager.CALL_STATE_OFFHOOK -> {
offhook = true
}
// RINGING → IDLE:未接(响了但没接)
from == TelephonyManager.CALL_STATE_RINGING && to == TelephonyManager.CALL_STATE_IDLE -> {
if (!offhook && ringing) {
val msg = BarkMessage(
deviceKey = server.deviceKey,
title = "未接来电",
body = "未接来电",
level = "timeSensitive",
group = "phone_call",
isArchive = "1",
)
pushToServer(server, msg, app, "未接来电")
}
ringing = false
offhook = false
}
// OFFHOOK → IDLE:正常挂断,不推送
from == TelephonyManager.CALL_STATE_OFFHOOK && to == TelephonyManager.CALL_STATE_IDLE -> {
ringing = false
offhook = false
}
}
}
private fun pushToServer(
server: com.a2i.forwarder.model.BarkServer,
msg: BarkMessage,
app: A2iApp,
logBody: String,
) {
scope.launch {
val result = BarkClient(server).push(msg)
val now = System.currentTimeMillis()
if (result.isSuccess) {
app.logStore.addLog(
ForwardLog(now, "phone_call", "电话", msg.title, logBody, "sent", "")
)
} else {
val reason = result.exceptionOrNull()?.message ?: "错误"
app.logStore.addLog(
ForwardLog(now, "phone_call", "电话", msg.title, logBody, "failed", reason)
)
app.logStore.addPending(
PendingPush(UUID.randomUUID().toString(), now, server.id, json.encodeToString(msg))
)
}
}
}
}
@@ -1,52 +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 }
for (p in list) {
val server = settings.servers.value.firstOrNull { it.id == p.serverId }
?: settings.currentServer.value
?: continue
if (server.deviceKey.isBlank()) { allOk = false; continue }
val msg = runCatching { json.decodeFromString<com.a2i.forwarder.model.BarkMessage>(p.messageJson) }.getOrNull() ?: continue
val r = BarkClient(server).push(msg)
if (r.isSuccess) store.removePending(p.id) else allOk = false
}
return if (allOk) Result.success() else Result.retry()
}
companion object {
private const val NAME = "a2i_retry"
fun schedule(context: Context) {
val req = PeriodicWorkRequestBuilder<RetryWorker>(15, TimeUnit.MINUTES)
.setConstraints(
Constraints.Builder().setRequiredNetworkType(NetworkType.CONNECTED).build()
)
.build()
WorkManager.getInstance(context)
.enqueueUniquePeriodicWork(NAME, ExistingPeriodicWorkPolicy.UPDATE, req)
}
}
}
@@ -1,105 +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("未接")
}
/** 尝试用短信转发。返回 true 表示已处理(无论成功失败)。 */
fun forward(d: Decision) {
scope.launch {
if (!settings.smsFallbackEnabled.value) return@launch
if (settings.smsSuspended.value) {
log(d, "failed", "短信已挂起(余额不足)")
return@launch
}
if (!isSmsWorthy(d)) return@launch
val target = settings.smsTargetNumber.value.trim()
if (target.isBlank()) {
log(d, "failed", "未设置短信目标号")
return@launch
}
// 限频
val now = System.currentTimeMillis()
val elapsed = now - settings.lastSmsSentTime.value
if (elapsed < RATE_LIMIT_MS) {
log(d, "filtered", "限频(${RATE_LIMIT_MS - elapsed}ms 内)")
return@launch
}
// 权限
if (ContextCompat.checkSelfPermission(context, Manifest.permission.SEND_SMS)
!= PackageManager.PERMISSION_GRANTED
) {
log(d, "failed", "无 SEND_SMS 权限")
return@launch
}
val body = compose(d).take(70)
val result = runCatching {
SmsManager.getDefault().sendTextMessage(target, null, body, null, null)
}
if (result.isSuccess) {
settings.setLastSmsSentTime(now)
log(d, "sent", "")
// 转发后刷新余额:自动模式发查询短信,手动模式递减
val carrier = app.carrierBalanceQuery.currentCarrier()
if (carrier.serviceNumber != null && carrier.queryCmd != null) {
app.carrierBalanceQuery.refresh()
} else {
app.carrierBalanceQuery.decrementManual()
}
} else {
val reason = result.exceptionOrNull()?.message ?: "短信发送失败"
log(d, "failed", reason)
}
}
}
private fun compose(d: Decision): String {
val code = d.code
return if (code != null) {
"[验证码]${d.appLabel}: $code"
} else {
val m = d.message!!
"${m.title.orEmpty().ifBlank { d.appLabel }}: ${m.body}".trim()
}
}
private fun log(d: Decision, status: String, reason: String) {
scope.launch {
val now = System.currentTimeMillis()
val title = d.code?.let { "验证码" } ?: d.message?.title ?: d.appLabel
val content = d.code ?: d.message?.body ?: ""
app.logStore.addLog(
ForwardLog(now, "sms_fallback", d.appLabel, title, content, status, reason)
)
}
}
}
@@ -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,14 +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,
)
const val OFFICIAL_BARK_SERVER = "https://api.day.app"
@@ -1,75 +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.BarkClient
import com.a2i.forwarder.core.ForwardLog
import com.a2i.forwarder.core.NotificationProcessor
import com.a2i.forwarder.core.PendingPush
import com.a2i.forwarder.core.RetryWorker
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.launch
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
import java.util.UUID
class NotifyListenerService : NotificationListenerService() {
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
private val processor by lazy { NotificationProcessor(applicationContext) }
private val json = Json { encodeDefaults = true; ignoreUnknownKeys = true }
override fun onListenerConnected() {
super.onListenerConnected()
RetryWorker.schedule(applicationContext)
}
override fun onNotificationPosted(sbn: StatusBarNotification?) {
val posted = sbn ?: return
if (posted.packageName == packageName) return
scope.launch { handle(posted) }
}
private suspend fun handle(sbn: StatusBarNotification) {
val app = A2iApp.instance
val now = System.currentTimeMillis()
val d = processor.process(sbn)
val msg = d.message
if (msg == null) {
app.logStore.addLog(ForwardLog(now, sbn.packageName, d.appLabel, "", "", "filtered", d.reason))
return
}
// ---- 断网兜底:离线时紧要通知走短信,其余不转发避免离线堆积 ----
val online = app.connectivityMonitor.isOnline.value
if (!online) {
if (app.smsForwarder.isSmsWorthy(d)) {
app.smsForwarder.forward(d)
} else {
app.logStore.addLog(ForwardLog(now, sbn.packageName, d.appLabel, msg.title, msg.body, "filtered", "离线非紧要通知"))
}
return
}
val server = app.settings.currentServer.value
if (server == null || server.deviceKey.isBlank()) {
app.logStore.addLog(ForwardLog(now, sbn.packageName, d.appLabel, msg.title, msg.body, "failed", "未配置 Bark key"))
return
}
val result = BarkClient(server).push(msg)
if (result.isSuccess) {
app.logStore.addLog(ForwardLog(now, sbn.packageName, d.appLabel, msg.title, msg.body, "sent", ""))
} else {
val reason = result.exceptionOrNull()?.message ?: "错误"
app.logStore.addLog(ForwardLog(now, sbn.packageName, d.appLabel, msg.title, msg.body, "failed", reason))
app.logStore.addPending(
PendingPush(UUID.randomUUID().toString(), now, server.id, json.encodeToString(msg))
)
}
}
}
@@ -1,96 +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.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 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)
blacklist.value = p[K.BLACK] ?: defaultBlacklist
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 {
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",
)
}
}
@@ -1,172 +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.OFFICIAL_BARK_SERVER
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
import kotlinx.serialization.decodeFromString
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
private val Context.settingsDataStore: DataStore<Preferences> by preferencesDataStore(name = "a2i_settings")
class SettingsStore(
private val context: Context,
scope: CoroutineScope,
) {
private val ds get() = context.settingsDataStore
private val json = Json { ignoreUnknownKeys = true; encodeDefaults = true }
private object K {
val SERVERS = stringPreferencesKey("servers")
val CURRENT = stringPreferencesKey("current")
val ICON_PREFIX = stringPreferencesKey("icon_prefix")
val FWD = booleanPreferencesKey("forwarding")
val CODE = booleanPreferencesKey("code")
val AD = booleanPreferencesKey("ad_filter")
val SPECIAL = booleanPreferencesKey("special")
val SKIP_ONGOING = booleanPreferencesKey("skip_ongoing")
val AD_KW = stringPreferencesKey("ad_keywords")
// 短信兜底
val SMS_FALLBACK = booleanPreferencesKey("sms_fallback")
val SMS_TARGET = stringPreferencesKey("sms_target")
val SMS_CARRIER = stringPreferencesKey("sms_carrier")
val SMS_QUOTA = intPreferencesKey("sms_quota")
val SMS_BALANCE = intPreferencesKey("sms_balance")
val SMS_SUSPENDED = booleanPreferencesKey("sms_suspended")
val SMS_LAST_SENT = longPreferencesKey("sms_last_sent")
}
private val defaultServers = listOf(
BarkServer(id = ID_OFFICIAL, name = "Bark 官方", server = OFFICIAL_BARK_SERVER, deviceKey = "")
)
private val _servers = MutableStateFlow(defaultServers)
val servers = _servers.asStateFlow()
private val _currentServerId = MutableStateFlow(ID_OFFICIAL)
val currentServer = MutableStateFlow<BarkServer?>(defaultServers.first())
val iconPrefix = MutableStateFlow(DEFAULT_ICON_PREFIX)
val forwardingEnabled = MutableStateFlow(true)
val codeEnabled = MutableStateFlow(true)
val adFilterEnabled = MutableStateFlow(true)
val specialEnabled = MutableStateFlow(true)
val skipOngoing = MutableStateFlow(true)
val adKeywords = MutableStateFlow<List<String>>(emptyList()) // 用户自定义关键词
// 短信兜底
val smsFallbackEnabled = MutableStateFlow(false)
val smsTargetNumber = MutableStateFlow("")
val smsCarrier = MutableStateFlow("auto") // "auto" 或 Carrier.name
val smsManualQuota = MutableStateFlow(0) // 手动额度(自动解析失败时回退用)
val smsBalance = MutableStateFlow(-1) // 缓存余额,-1=未知
val smsSuspended = MutableStateFlow(false) // 余额 ≤5 自动挂起
val lastSmsSentTime = MutableStateFlow(0L) // 限频时间戳
init {
scope.launch {
ds.data.collect { p ->
val list: List<BarkServer> = p[K.SERVERS]
?.let { runCatching { json.decodeFromString<List<BarkServer>>(it) }.getOrNull() }
?: defaultServers
_servers.value = list
_currentServerId.value = p[K.CURRENT] ?: ID_OFFICIAL
currentServer.value = list.firstOrNull { it.id == _currentServerId.value } ?: list.firstOrNull()
iconPrefix.value = p[K.ICON_PREFIX] ?: DEFAULT_ICON_PREFIX
forwardingEnabled.value = p[K.FWD] ?: true
codeEnabled.value = p[K.CODE] ?: true
adFilterEnabled.value = p[K.AD] ?: true
specialEnabled.value = p[K.SPECIAL] ?: true
skipOngoing.value = p[K.SKIP_ONGOING] ?: true
adKeywords.value = p[K.AD_KW]
?.let { runCatching { json.decodeFromString<List<String>>(it) }.getOrNull() }
?: emptyList()
// 短信兜底
smsFallbackEnabled.value = p[K.SMS_FALLBACK] ?: false
smsTargetNumber.value = p[K.SMS_TARGET] ?: ""
smsCarrier.value = p[K.SMS_CARRIER] ?: "auto"
smsManualQuota.value = p[K.SMS_QUOTA] ?: 0
smsBalance.value = p[K.SMS_BALANCE] ?: -1
smsSuspended.value = p[K.SMS_SUSPENDED] ?: false
lastSmsSentTime.value = p[K.SMS_LAST_SENT] ?: 0L
}
}
}
private suspend fun edit(block: (androidx.datastore.preferences.core.MutablePreferences) -> Unit) =
ds.edit(block)
private suspend fun saveServers(list: List<BarkServer>) {
edit { it[K.SERVERS] = json.encodeToString(list) }
_servers.value = list
refreshCurrent()
}
suspend fun addServer(name: String, server: String, key: String): BarkServer {
val s = BarkServer(name = name.trim(), server = server.trim().removeSuffix("/"), deviceKey = key.trim())
saveServers(_servers.value + s)
if (_servers.value.count { it.id == s.id } == 1 && currentServer.value?.deviceKey.isNullOrBlank()) {
setCurrent(s.id)
}
return s
}
suspend fun updateServer(s: BarkServer) = saveServers(_servers.value.map { if (it.id == s.id) s.copy(server = s.server.trim().removeSuffix("/")) else it })
suspend fun deleteServer(id: String) {
val list = _servers.value.filterNot { it.id == id }
saveServers(list)
if (_currentServerId.value == id) {
setCurrent(if (list.isEmpty()) "" else list.first().id)
}
}
suspend fun setCurrent(id: String) {
edit { it[K.CURRENT] = id }
_currentServerId.value = id
refreshCurrent()
}
private fun refreshCurrent() {
currentServer.value = _servers.value.firstOrNull { it.id == _currentServerId.value } ?: _servers.value.firstOrNull()
}
suspend fun setIconPrefix(v: String) { edit { it[K.ICON_PREFIX] = v }; iconPrefix.value = v }
suspend fun setForwarding(v: Boolean) { edit { it[K.FWD] = v }; forwardingEnabled.value = v }
suspend fun setCode(v: Boolean) { edit { it[K.CODE] = v }; codeEnabled.value = v }
suspend fun setAdFilter(v: Boolean) { edit { it[K.AD] = v }; adFilterEnabled.value = v }
suspend fun setSpecial(v: Boolean) { edit { it[K.SPECIAL] = v }; specialEnabled.value = v }
suspend fun setSkipOngoing(v: Boolean) { edit { it[K.SKIP_ONGOING] = v }; skipOngoing.value = v }
suspend fun setAdKeywords(list: List<String>) {
edit { it[K.AD_KW] = json.encodeToString(list) }
adKeywords.value = list
}
suspend fun setSmsFallback(v: Boolean) { edit { it[K.SMS_FALLBACK] = v }; smsFallbackEnabled.value = v }
suspend fun setSmsTargetNumber(v: String) { edit { it[K.SMS_TARGET] = v }; smsTargetNumber.value = v }
suspend fun setSmsCarrier(v: String) { edit { it[K.SMS_CARRIER] = v }; smsCarrier.value = v }
suspend fun setSmsManualQuota(v: Int) { edit { it[K.SMS_QUOTA] = v }; smsManualQuota.value = v }
suspend fun setSmsBalance(v: Int) { edit { it[K.SMS_BALANCE] = v }; smsBalance.value = v }
suspend fun setSmsSuspended(v: Boolean) { edit { it[K.SMS_SUSPENDED] = v }; smsSuspended.value = v }
suspend fun setLastSmsSentTime(v: Long) { edit { it[K.SMS_LAST_SENT] = v }; lastSmsSentTime.value = v }
suspend fun 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,247 +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,
content: @Composable ColumnScope.() -> Unit,
) {
Card(
modifier = modifier.fillMaxWidth(),
shape = RoundedCornerShape(8.dp),
border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant),
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface),
elevation = CardDefaults.cardElevation(defaultElevation = 0.dp),
) {
Column(Modifier.padding(16.dp)) {
if (title != null) {
Text(
title,
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold,
modifier = Modifier.padding(bottom = if (subtitle == null) 10.dp else 2.dp),
)
if (subtitle != null) {
Text(
subtitle,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(bottom = 10.dp),
)
}
}
content()
}
}
}
@Composable
fun SwitchRow(
title: String,
subtitle: String? = null,
checked: Boolean,
icon: ImageVector? = null,
tint: Color = MaterialTheme.colorScheme.primary,
onChange: (Boolean) -> Unit,
) {
Row(
Modifier
.fillMaxWidth()
.heightIn(min = 56.dp)
.padding(vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
if (icon != null) {
IconBox(icon = icon, tint = tint)
Spacer(Modifier.width(12.dp))
}
Column(Modifier.weight(1f)) {
Text(title, style = MaterialTheme.typography.bodyLarge, fontWeight = FontWeight.Medium)
if (!subtitle.isNullOrBlank()) {
Text(
subtitle,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
Spacer(Modifier.width(12.dp))
Switch(checked = checked, onCheckedChange = onChange)
}
}
@Composable
fun ClickRow(
title: String,
subtitle: String? = null,
icon: ImageVector? = null,
tint: Color = MaterialTheme.colorScheme.primary,
onClick: () -> Unit,
) {
Row(
Modifier
.fillMaxWidth()
.clickable(onClick = onClick)
.heightIn(min = 56.dp)
.padding(vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
if (icon != null) {
IconBox(icon = icon, tint = tint)
Spacer(Modifier.width(12.dp))
}
Column(Modifier.weight(1f)) {
Text(title, style = MaterialTheme.typography.bodyLarge, fontWeight = FontWeight.Medium)
if (!subtitle.isNullOrBlank()) {
Text(
subtitle,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
Icon(Icons.Filled.ChevronRight, contentDescription = null, tint = MaterialTheme.colorScheme.onSurfaceVariant)
}
}
@Composable
fun IconBox(
icon: ImageVector = Icons.Outlined.Circle,
tint: Color = MaterialTheme.colorScheme.primary,
modifier: Modifier = Modifier,
) {
Box(
modifier = modifier
.size(38.dp)
.background(tint.copy(alpha = 0.12f), RoundedCornerShape(8.dp)),
contentAlignment = Alignment.Center,
) {
Icon(icon, contentDescription = null, tint = tint, modifier = Modifier.size(20.dp))
}
}
@Composable
fun StatusBadge(
text: String,
tint: Color,
modifier: Modifier = Modifier,
) {
Surface(
modifier = modifier,
shape = RoundedCornerShape(6.dp),
color = tint.copy(alpha = 0.12f),
contentColor = tint,
) {
Text(
text,
style = MaterialTheme.typography.labelMedium,
modifier = Modifier.padding(horizontal = 9.dp, vertical = 5.dp),
)
}
}
@Composable
fun MetricBlock(
label: String,
value: String,
tint: Color,
modifier: Modifier = Modifier,
) {
Surface(
modifier = modifier,
shape = RoundedCornerShape(8.dp),
color = tint.copy(alpha = 0.10f),
border = BorderStroke(1.dp, tint.copy(alpha = 0.18f)),
) {
Column(Modifier.padding(12.dp)) {
Text(value, style = MaterialTheme.typography.titleLarge, color = tint, fontWeight = FontWeight.Bold)
Text(label, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
}
}
}
fun isNotificationListenerEnabled(context: Context): Boolean {
return NotificationManagerCompat.getEnabledListenerPackages(context).contains(context.packageName)
}
/** Reads the app versionName and returns fallback if PackageManager lookup fails. */
fun appVersionName(context: Context, fallback: String = "unknown"): String {
return runCatching {
@Suppress("DEPRECATION")
context.packageManager.getPackageInfo(context.packageName, 0).versionName ?: fallback
}.getOrDefault(fallback)
}
fun drawableToImageBitmap(d: Drawable): ImageBitmap {
if (d is BitmapDrawable && d.bitmap != null) return d.bitmap.asImageBitmap()
val size = maxOf(d.intrinsicWidth, d.intrinsicHeight, 96).coerceAtLeast(1)
val bmp = Bitmap.createBitmap(size, size, Bitmap.Config.ARGB_8888)
val canvas = Canvas(bmp)
d.setBounds(0, 0, canvas.width, canvas.height)
d.draw(canvas)
return bmp.asImageBitmap()
}
@@ -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,257 +0,0 @@
package com.a2i.forwarder.ui.screens
import android.content.Intent
import android.provider.Settings
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.CheckCircle
import androidx.compose.material.icons.filled.History
import androidx.compose.material.icons.filled.Notifications
import androidx.compose.material.icons.filled.PowerSettingsNew
import androidx.compose.material.icons.filled.Settings
import androidx.compose.material.icons.filled.WarningAmber
import androidx.compose.material3.Button
import androidx.compose.material3.FilledTonalButton
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver
import androidx.lifecycle.compose.LocalLifecycleOwner
import com.a2i.forwarder.A2iApp
import com.a2i.forwarder.ui.ClickRow
import com.a2i.forwarder.ui.IconBox
import com.a2i.forwarder.ui.MetricBlock
import com.a2i.forwarder.ui.SectionCard
import com.a2i.forwarder.ui.StatusBadge
import com.a2i.forwarder.ui.SwitchRow
import com.a2i.forwarder.ui.appVersionName
import com.a2i.forwarder.ui.isNotificationListenerEnabled
import kotlinx.coroutines.launch
@Composable
fun HomeScreen(onOpenLog: () -> Unit, onOpenSettings: () -> Unit) {
val context = LocalContext.current
val app = A2iApp.instance
val forwarding by app.settings.forwardingEnabled.collectAsState()
val current by app.settings.currentServer.collectAsState()
val sent by app.logStore.sentCount.collectAsState()
val filtered by app.logStore.filteredCount.collectAsState()
val failed by app.logStore.failedCount.collectAsState()
val smsSuspended by app.settings.smsSuspended.collectAsState()
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),
)
}
if (smsSuspended) {
item {
SectionCard {
Row(verticalAlignment = Alignment.CenterVertically) {
IconBox(
icon = Icons.Filled.WarningAmber,
tint = MaterialTheme.colorScheme.error,
)
Spacer(Modifier.width(12.dp))
Column(Modifier.weight(1f)) {
Text("短信兜底已停用", style = MaterialTheme.typography.titleMedium)
Text(
"短信余额不足,断网转发已关闭。请在「设置 → 断网短信兜底」补充额度",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
}
}
item {
SectionCard {
Row(verticalAlignment = Alignment.CenterVertically) {
IconBox(
icon = if (granted) Icons.Filled.CheckCircle else Icons.Filled.WarningAmber,
tint = if (granted) MaterialTheme.colorScheme.tertiary else MaterialTheme.colorScheme.error,
)
Spacer(Modifier.width(12.dp))
Column(Modifier.weight(1f)) {
Text(if (granted) "通知监听已开启" else "需要授权通知监听", style = MaterialTheme.typography.titleMedium)
Text(
if (granted) "系统通知将按规则处理并推送到 Bark" else "授权后才能读取 Android 通知并转发",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
StatusBadge(
text = if (granted) "已授权" else "未授权",
tint = if (granted) MaterialTheme.colorScheme.tertiary else MaterialTheme.colorScheme.error,
)
}
if (!granted) {
Spacer(Modifier.size(12.dp))
Button(
onClick = { context.startActivity(Intent(Settings.ACTION_NOTIFICATION_LISTENER_SETTINGS)) },
modifier = Modifier.fillMaxWidth(),
) { Text("前往系统授权") }
}
}
}
item {
SectionCard("运行状态", subtitle = "转发能力、目标服务器和日志入口") {
SwitchRow(
title = "通知转发",
subtitle = if (forwarding) "新通知会继续转发到 Bark" else "当前已暂停所有转发",
checked = forwarding,
icon = Icons.Filled.PowerSettingsNew,
tint = if (forwarding) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant,
) { v ->
app.appScope.launch { app.settings.setForwarding(v) }
}
ClickRow(
title = current?.name?.ifBlank { "未命名服务器" } ?: "未配置 Bark 服务器",
subtitle = current?.server ?: "进入设置添加官方或自建 Bark 服务",
icon = Icons.Filled.Settings,
tint = MaterialTheme.colorScheme.secondary,
onClick = onOpenSettings,
)
if (current?.deviceKey.isNullOrBlank()) {
Text(
"当前服务器未填写 device key",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.error,
modifier = Modifier.padding(start = 50.dp),
)
}
ClickRow(
title = "查看转发日志",
subtitle = "最近 300 条处理记录",
icon = Icons.Filled.History,
tint = MaterialTheme.colorScheme.primary,
onClick = onOpenLog,
)
}
}
item {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
MetricBlock("已转发", sent.toString(), MaterialTheme.colorScheme.primary, Modifier.weight(1f))
MetricBlock("已过滤", filtered.toString(), MaterialTheme.colorScheme.secondary, Modifier.weight(1f))
MetricBlock("失败", failed.toString(), MaterialTheme.colorScheme.error, Modifier.weight(1f))
}
}
item {
Text(
"v${appVersionName(context)}",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.fillMaxWidth().padding(top = 2.dp, bottom = 18.dp),
textAlign = TextAlign.Center,
)
}
}
}
@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,166 +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.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.remember
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()) }
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 = { app.appScope.launch { app.logStore.clearLogs() } }) { 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)
}
}
}
}
@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,410 +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.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.Send
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.Delete
import androidx.compose.material.icons.filled.Edit
import androidx.compose.material.icons.filled.History
import androidx.compose.material.icons.filled.RestartAlt
import androidx.compose.material.icons.filled.Settings
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.FilledTonalButton
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.RadioButton
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.documentfile.provider.DocumentFile
import com.a2i.forwarder.A2iApp
import com.a2i.forwarder.core.BarkClient
import com.a2i.forwarder.core.IconExporter
import com.a2i.forwarder.model.BarkMessage
import com.a2i.forwarder.model.BarkServer
import com.a2i.forwarder.model.OFFICIAL_BARK_SERVER
import com.a2i.forwarder.ui.ClickRow
import com.a2i.forwarder.ui.ScreenTitle
import com.a2i.forwarder.ui.SectionCard
import com.a2i.forwarder.ui.StatusBadge
import kotlinx.coroutines.launch
@Composable
fun SettingsScreen(onOpenLog: () -> Unit) {
val context = LocalContext.current
val app = A2iApp.instance
val servers by app.settings.servers.collectAsState()
val current by app.settings.currentServer.collectAsState()
val iconPrefix by app.settings.iconPrefix.collectAsState()
val prefs = remember { context.getSharedPreferences("a2i_ui", Context.MODE_PRIVATE) }
var treeUri by remember { mutableStateOf<Uri?>(prefs.getString("icon_tree", null)?.let { runCatching { Uri.parse(it) }.getOrNull() }) }
var exporting by remember { mutableStateOf(false) }
var exportResult by remember { mutableStateOf<String?>(null) }
var testing by remember { mutableStateOf(false) }
var testMsg by remember { mutableStateOf<String?>(null) }
var showAdd by remember { mutableStateOf(false) }
var editing by remember { mutableStateOf<BarkServer?>(null) }
// 短信兜底状态
val smsFallback by app.settings.smsFallbackEnabled.collectAsState()
val smsTarget by app.settings.smsTargetNumber.collectAsState()
val smsCarrier by app.settings.smsCarrier.collectAsState()
val smsQuota by app.settings.smsManualQuota.collectAsState()
val smsBalance by app.settings.smsBalance.collectAsState()
val smsSuspended by app.settings.smsSuspended.collectAsState()
var smsTargetInput by remember(smsTarget) { mutableStateOf(smsTarget) }
var smsQuotaInput by remember(smsQuota) { mutableStateOf(if (smsQuota == 0) "" else smsQuota.toString()) }
val detectedCarrier = remember { com.a2i.forwarder.core.CarrierDetector.detect(context) }
val smsPermLauncher = rememberLauncherForActivityResult(ActivityResultContracts.RequestPermission()) { }
val hasSmsPerm = remember {
androidx.core.content.ContextCompat.checkSelfPermission(context, android.Manifest.permission.SEND_SMS) ==
android.content.pm.PackageManager.PERMISSION_GRANTED
}
val treeLauncher = rememberLauncherForActivityResult(ActivityResultContracts.OpenDocumentTree()) { uri ->
if (uri != null) {
runCatching {
context.contentResolver.takePersistableUriPermission(
uri,
Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION,
)
}
prefs.edit().putString("icon_tree", uri.toString()).apply()
treeUri = uri
}
}
LazyColumn(
modifier = Modifier.fillMaxSize().padding(horizontal = 16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
item { ScreenTitle("设置", "管理 Bark 服务、图标资源和维护操作") }
item {
SectionCard("Bark 服务器", subtitle = "官方服务和自建服务可以并存,当前服务器用于所有推送") {
if (servers.isEmpty()) {
Text("暂无服务器,请先添加。", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
} else {
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
servers.forEach { s ->
ServerRow(
server = s,
selected = s.id == current?.id,
onSelect = { app.appScope.launch { app.settings.setCurrent(s.id) } },
onEdit = { editing = s },
onDelete = { app.appScope.launch { app.settings.deleteServer(s.id) } },
)
}
}
}
Button(
onClick = { showAdd = true },
modifier = Modifier.fillMaxWidth().padding(top = 12.dp),
) {
Icon(Icons.Filled.Add, null, modifier = Modifier.size(18.dp))
Spacer(Modifier.width(8.dp))
Text("添加服务器")
}
FilledTonalButton(
onClick = {
app.appScope.launch {
testing = true
testMsg = testPush(app)
testing = false
}
},
modifier = Modifier.fillMaxWidth().padding(top = 8.dp),
enabled = !testing,
) {
if (testing) {
CircularProgressIndicator(Modifier.size(18.dp), strokeWidth = 2.dp)
} else {
Icon(Icons.AutoMirrored.Filled.Send, null, modifier = Modifier.size(18.dp))
}
Spacer(Modifier.width(8.dp))
Text("发送测试推送")
}
testMsg?.let {
Text(
it,
style = MaterialTheme.typography.bodySmall,
color = if (it.startsWith("已发送")) MaterialTheme.colorScheme.tertiary else MaterialTheme.colorScheme.error,
modifier = Modifier.padding(top = 8.dp),
)
}
}
}
item {
SectionCard("应用图标", subtitle = "导出 App 图标后,Bark 通知可按包名显示图标") {
OutlinedTextField(
value = iconPrefix,
onValueChange = { app.appScope.launch { app.settings.setIconPrefix(it) } },
label = { Text("图标 URL 前缀") },
supportingText = { Text("转发时 icon = 前缀 + 包名 + .png;留空则不显示图标") },
modifier = Modifier.fillMaxWidth(),
singleLine = true,
)
Text(
"导出目录:${treeUri?.let { DocumentFile.fromTreeUri(context, it)?.name } ?: "未选择"}",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(top = 8.dp),
)
Row(
modifier = Modifier.fillMaxWidth().padding(top = 10.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
OutlinedButton(onClick = { treeLauncher.launch(null) }, modifier = Modifier.weight(1f)) { Text("选择目录") }
Button(
onClick = {
val u = treeUri ?: return@Button
app.appScope.launch {
exporting = true
val r = IconExporter(context).exportAll(u)
exporting = false
exportResult = "${r.total} 个,成功 ${r.ok},失败 ${r.fail}"
}
},
enabled = treeUri != null && !exporting,
modifier = Modifier.weight(1f),
) {
if (exporting) CircularProgressIndicator(Modifier.size(18.dp), strokeWidth = 2.dp) else Text("导出图标")
}
}
exportResult?.let {
Text(it, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.primary, modifier = Modifier.padding(top = 8.dp))
}
}
}
item {
SectionCard("断网短信兜底", subtitle = "无网络时把验证码、来电用短信发到 iPhone(每5分钟1条)") {
com.a2i.forwarder.ui.SwitchRow(
title = "启用短信兜底",
subtitle = if (smsFallback) "断网时紧要通知走短信" else "当前关闭",
checked = smsFallback,
) { v -> app.appScope.launch { app.settings.setSmsFallback(v) } }
Spacer(Modifier.size(8.dp))
OutlinedTextField(
value = smsTargetInput,
onValueChange = { smsTargetInput = it },
label = { Text("iPhone 手机号") },
supportingText = { Text("短信转发的目标号码") },
modifier = Modifier.fillMaxWidth(),
singleLine = true,
)
Spacer(Modifier.size(4.dp))
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
OutlinedButton(onClick = {
app.appScope.launch { app.settings.setSmsTargetNumber(smsTargetInput.trim()) }
}, modifier = Modifier.weight(1f)) { Text("保存号码") }
if (!hasSmsPerm) {
OutlinedButton(onClick = {
smsPermLauncher.launch(android.Manifest.permission.SEND_SMS)
}, modifier = Modifier.weight(1f)) { Text("授予短信权限") }
}
}
Spacer(Modifier.size(8.dp))
Text(
"检测到运营商:${detectedCarrier.displayName}" +
if (detectedCarrier.serviceNumber == null) "(无自动查询,将用手动额度)" else "${detectedCarrier.serviceNumber}",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Spacer(Modifier.size(8.dp))
OutlinedTextField(
value = smsQuotaInput,
onValueChange = { smsQuotaInput = it.filter { c -> c.isDigit() } },
label = { Text("手动短信额度") },
supportingText = { Text("自动查询失败时按此递减;余额≤5自动停用") },
modifier = Modifier.fillMaxWidth(),
singleLine = true,
)
Spacer(Modifier.size(4.dp))
OutlinedButton(onClick = {
val q = smsQuotaInput.trim().toIntOrNull() ?: 0
app.appScope.launch {
app.settings.setSmsManualQuota(q)
app.settings.setSmsBalance(q)
if (q > 5) app.settings.setSmsSuspended(false)
}
}) { Text("保存额度并解除挂起") }
Spacer(Modifier.size(8.dp))
val balanceText = when {
smsSuspended -> "已挂起(余额不足)"
smsBalance < 0 -> "余额未知"
else -> "剩余约 ${smsBalance}"
}
Text(
"当前状态:$balanceText",
style = MaterialTheme.typography.bodySmall,
color = if (smsSuspended) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
item {
SectionCard("维护", subtitle = "日志查看和本地统计清理") {
ClickRow("转发日志", "查看 / 清空最近记录", icon = Icons.Filled.History, onClick = onOpenLog)
ClickRow("重置计数器", "清零转发 / 过滤 / 失败统计", icon = Icons.Filled.RestartAlt, tint = MaterialTheme.colorScheme.error) {
app.appScope.launch { app.logStore.resetCounters() }
}
}
}
item {
Box(Modifier.fillMaxWidth().padding(top = 4.dp, bottom = 18.dp), contentAlignment = Alignment.Center) {
Text(
"a2i · 安卓通知转发到 iOS Bark\n图标需 iOS 15+;点击通知会打开 iOS 对应 App",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
if (showAdd) {
ServerDialog(
onDismiss = { showAdd = false },
onConfirm = { n, s, k ->
app.appScope.launch {
val ns = app.settings.addServer(n, s, k)
app.settings.setCurrent(ns.id)
}
showAdd = false
},
)
}
editing?.let { srv ->
ServerDialog(
initial = srv,
onDismiss = { editing = null },
onConfirm = { n, s, k ->
app.appScope.launch { app.settings.updateServer(srv.copy(name = n, server = s, deviceKey = k)) }
editing = null
},
)
}
}
@Composable
private fun ServerRow(
server: BarkServer,
selected: Boolean,
onSelect: () -> Unit,
onEdit: () -> Unit,
onDelete: () -> Unit,
) {
Surface(
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(8.dp),
color = if (selected) MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.55f) else MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.36f),
border = BorderStroke(1.dp, if (selected) MaterialTheme.colorScheme.primary.copy(alpha = 0.32f) else MaterialTheme.colorScheme.outlineVariant),
) {
Row(Modifier.fillMaxWidth().padding(8.dp), verticalAlignment = Alignment.CenterVertically) {
RadioButton(selected = selected, onClick = onSelect)
Column(Modifier.weight(1f)) {
Row(verticalAlignment = Alignment.CenterVertically) {
Text(
server.name.ifBlank { "未命名" },
style = MaterialTheme.typography.bodyLarge,
fontWeight = FontWeight.Medium,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f),
)
if (selected) StatusBadge("当前", MaterialTheme.colorScheme.primary)
}
Text(
"${server.server} · key: ${if (server.deviceKey.isBlank()) "未填" else "已填"}",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
IconButton(onClick = onEdit) { Icon(Icons.Filled.Edit, "编辑") }
IconButton(onClick = onDelete) { Icon(Icons.Filled.Delete, "删除") }
}
}
}
@Composable
private fun ServerDialog(
initial: BarkServer? = null,
onDismiss: () -> Unit,
onConfirm: (name: String, server: String, key: String) -> Unit,
) {
var name by remember { mutableStateOf(initial?.name ?: "") }
var server by remember { mutableStateOf(initial?.server ?: OFFICIAL_BARK_SERVER) }
var key by remember { mutableStateOf(initial?.deviceKey ?: "") }
AlertDialog(
onDismissRequest = onDismiss,
icon = { Icon(Icons.Filled.Settings, null) },
title = { Text(if (initial == null) "添加服务器" else "编辑服务器") },
text = {
Column {
OutlinedTextField(name, { name = it }, label = { Text("名称") }, singleLine = true, modifier = Modifier.fillMaxWidth())
Spacer(Modifier.size(8.dp))
OutlinedTextField(server, { server = it }, label = { Text("服务器地址") }, singleLine = true, modifier = Modifier.fillMaxWidth(), supportingText = { Text("官方:$OFFICIAL_BARK_SERVER") })
Spacer(Modifier.size(8.dp))
OutlinedTextField(key, { key = it }, label = { Text("Device Key") }, singleLine = true, modifier = Modifier.fillMaxWidth())
}
},
confirmButton = { TextButton(onClick = { onConfirm(name.trim(), server.trim(), key.trim()) }) { Text("保存") } },
dismissButton = { TextButton(onClick = onDismiss) { Text("取消") } },
)
}
private suspend fun testPush(app: A2iApp): String {
val s = app.settings.currentServer.value ?: return "未选择服务器"
if (s.deviceKey.isBlank()) return "未填写 device key"
val msg = BarkMessage(
deviceKey = s.deviceKey,
title = "a2i 测试",
body = "如果你在 iOS 看到这条推送,说明配置成功",
level = "active",
)
val r = BarkClient(s).push(msg)
return if (r.isSuccess) "已发送,请查看 iOS 端" else "发送失败:${r.exceptionOrNull()?.message}"
}
@@ -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>
-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 快捷指令迁移说明
v1.11.0 已移除旧的 iOS 快捷指令和 ntfy `_reply` topic 方案。请删除原有的 `A2iReply` 快捷指令;GotMsg 不再订阅或使用 `_reply` topic。
新版本会在可回复的 ntfy 通知中直接附加一次性网页链接。iPhone 和 Android 都可点开网页输入回复,不需要额外配置。
完整说明见 [notification-reply.md](notification-reply.md)。
+43
View File
@@ -0,0 +1,43 @@
# 通知远程回复
GotMsg v1.11.0 起支持从 iPhone 或另一台 Android 设备打开 Bark / ntfy 通知里的回复链接。回复会回到运行 GotMsg 的原 Android 手机。v1.12.0 起,除了原生通知快捷回复,还可选择使用无障碍兼容回复处理微信、QQ 等没有提供系统回复动作的聊天通知。
## 使用方法
1. 在原 Android 手机的 GotMsg 中启用至少一个 Bark 或 ntfy 配置。
2. 保持 GotMsg 的通知读取权限,并让原手机联网。
3. 如需回复没有系统快捷回复入口的微信、QQ 等通知,在 GotMsg「设置 -> 通知回复」中开启「无障碍兼容回复」,再到系统无障碍设置中开启「GotMsg 兼容回复」。
4. 每条 Bark / ntfy 转发通知都会带回复入口。Bark 接收端直接点通知即可打开回复页,通知正文也会附带回复链接;普通消息不会再跳微信/QQ等原 App。ntfy 接收端可点通知、通知动作或正文中的回复链接;如果 iOS 点通知本体只进入 ntfy App,请点消息正文里的回复链接。
5. 网页提交后,原 Android 手机先尝试原通知的 `RemoteInput`;没有该动作时才使用已启用的无障碍兼容回复。GotMsg 的「日志」页会记录结果。
接收端无需安装 iOS 快捷指令,也无需创建或订阅 `_reply` topic。现有 ntfy server、topic 和 token 配置不需要改动。
## 生效条件
- 原生模式要求原 App 在该条通知上提供允许自由文本的 Android `RemoteInput` 动作,可以在后台完成。
- 无障碍兼容模式会使用通知的会话入口打开原 App,只对白名单中的微信、QQ、支付宝、淘宝、钉钉生效。页面必须是聊天页,并存在可识别的输入框和明确的「发送」按钮。
- 无障碍兼容模式要求原手机已解锁;执行时会亮屏、切换到对应 App。输入框已有草稿时会停止,不会覆盖草稿。
- 付款、订单、验证码、营销和系统通知通常没有聊天输入框,即使带回复链接也不能发送回复。
- 原通知不能被划掉或被新通知替换;进程未重启时 GotMsg 会短暂缓存通知入口,降低通知更新导致的失败概率。
- 回复链接从通知转发起 10 分钟内有效,并且只能成功提交一次。
- 原 Android 手机必须联网,GotMsg 进程和通知监听服务需要正常工作。
- 某些 App 或定制系统不会向无障碍服务暴露聊天控件;这种情况下网页会显示具体失败原因。
## 安全说明
- 每条通知使用独立的 256 位随机回复令牌。令牌放在 URL fragment 中,不会随首次页面请求进入服务器访问日志。
- GotMsg 安装时生成独立设备 ID 和密钥;原手机与中继 API 之间的注册、拉取和确认请求均需设备鉴权。
- 中继只持久化设备密钥和回复令牌的 SHA-256 哈希。提交后的回复正文会在中继中短暂保存,处理完成后最多保留 24 小时用于状态查询和故障排查。
- 拿到有效回复链接的人可在有效期内提交一次回复,因此不要转发或公开该链接。
- 无障碍兼容回复仅在收到有效的一次性回复事件时运行,严格校验目标包名,并且只点击文字明确匹配「发送」的控件。该权限能够读取当前窗口内容,只应在你信任的原 Android 手机上启用。
- 回复服务使用 `https://r.gotmsg.pp.ua`,不可在 App 设置中修改。部署或域名变更时需同步修改 `ReplyFeature.RELAY_BASE_URL` 并重新发布 APK。
## 常见失败
- `回复链接已过期`:通知转发已超过 10 分钟。
- `原通知已消失`:原 Android 手机上的通知已被清除或替换。
- `请在 GotMsg 设置中开启无障碍兼容回复服务`:应用内开关已打开,但系统无障碍权限尚未开启。
- `原手机需解锁后才能回复`:解锁原手机后,重新从一条新通知发起回复。
- `未找到聊天输入框` / `未找到明确的发送按钮`:通知没有进入可回复聊天页,或原 App 当前版本的界面需要重新适配。
- `聊天输入框已有未发送内容`:GotMsg 为避免覆盖原手机草稿而主动停止。
- `原手机未确认回复`:中继未在超时时间内收到原手机处理结果,检查原手机网络、后台限制和 GotMsg 通知读取权限。
-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: 6.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 31 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.

After

Width:  |  Height:  |  Size: 521 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")