Compare commits
8 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| ce2f07a810 | |||
| b0719b9a90 | |||
| 70f9ba093f | |||
| f7adeda080 | |||
| 7f6fe4fc00 | |||
| 24626383b6 | |||
| 1f9f6e9934 | |||
| 38c2bcd819 |
@@ -1,127 +1,209 @@
|
||||
# GotMsg ·有消息
|
||||
|
||||
把安卓手机上的通知(短信、验证码、电话“有电话进来这个通知,不是电话呼叫转移”、微信、QQ、Telegram 等)实时转发到 iOS / Android / 鸿蒙等接收端。iOS 可通过 [Bark](https://github.com/Finb/Bark) 推送到 iPhone / iPad;Android 可用 ntfy;鸿蒙可用 Meow。在 iOS 上点击通知,还能一键跳转到对应的 App。
|
||||
把安卓手机上的通知(短信、验证码、电话、微信、QQ、Telegram)实时转发到 iOS / Android / 鸿蒙等接收端。
|
||||
|
||||
适用于把安卓备机的消息同步到 iPhone 或其他安卓 / 鸿蒙手机,不漏验证码、不漏重要聊天。
|
||||
|
||||

|
||||
|
||||
## 主要功能
|
||||
---
|
||||
|
||||
- **系统级通知监听**:接入 Android 通知监听服务,覆盖所有发出通知的 App。
|
||||
- **多通道推送**:支持 Bark、ntfy、Meow、电邮和短信兜底;每个通道可保存多个目标,勾选参与转发,并支持“发通一条就停”或全部广播。
|
||||
- **验证码自动提取**:自动识别通知中的验证码,收到即复制,也可点击复制。
|
||||
- **通知优化**:
|
||||
- 广告关键词过滤(内置 + 自定义)
|
||||
- 持续性通知过滤(音乐、下载、通话等 ongoing)
|
||||
- 内容脱敏与去重
|
||||
- 微信 / QQ / Telegram 专项优化(发送者作标题、按会话分组、重要消息提级、跳转 iOS 对应 App)
|
||||
- **应用级规则**:黑名单 / 白名单两种模式,可按 App 单独控制是否转发。
|
||||
- **失败自动重试**:发送失败的通知进入待重发队列,由 WorkManager 定期重试,也可手动触发。
|
||||
- **本地日志**:保留最近 300 条处理记录,方便排查为什么某条通知被过滤或发送失败。
|
||||
- **电话来电通知**:通过轮询 `getCallState()` 实时感知来电和未接来电(绕过 MIUI 对系统电话通知的限制,需授予"电话"权限)。
|
||||
- **断网短信兜底**:无网络时自动把验证码、来电用短信发到 iPhone。
|
||||
- 自动识别运营商(移动/联通/电信/广电),发免费查询短信拿套餐内短信余量
|
||||
- 余额 ≤5 条自动停用,下次打开 App 提醒
|
||||
- 每 5 分钟最多发 1 条,避免刷屏
|
||||
- 自动解析失败时回退手动额度计数
|
||||
- **应用内更新**:每天自动检查 GitHub 新版本,发现新版后一键下载并通过系统安装器升级,无需手动去网页下载。
|
||||
|
||||
## 快速开始
|
||||
## 一、五分钟上手
|
||||
|
||||
### 1. 安装
|
||||
从 [Releases](../../releases) 下载最新 APK,安装到安卓手机(需 Android 14 / API 34+)。
|
||||
|
||||
从 [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)。
|
||||
2. 打开 Bark,复制你的专属推送地址,形如 `https://api.day.app/你的Key`。
|
||||
3. 在 GotMsg 的「设置」中添加服务器:
|
||||
- 名称:随意,例如「我的 iPhone」
|
||||
- 服务器地址:`https://api.day.app`(自建则填自建地址)
|
||||
- Device Key:Bark 地址中的那段 Key
|
||||
|
||||
如果接收端是 Android 或鸿蒙手机,也可以在「设置」里配置 ntfy 或 Meow;对应区块右上角有帮助按钮,按弹窗步骤填写服务器地址、Topic / Device Key 后点测试发送。
|
||||
打开 GotMsg → 「设置」→ 选对应通道 → 点右上角 **i** 按钮 → 按弹窗步骤配 → 点该行「发送」测试。
|
||||
|
||||
### 3. 授权通知监听
|
||||
|
||||
打开 GotMsg,首页会提示「需要授权通知监听」。点击「前往系统授权」,在系统的通知访问设置中开启 GotMsg。
|
||||
|
||||
> 授权是一次性的,卸载后重新安装需要再次授权。
|
||||
首页会提示「需要授权通知监听」。点击「前往系统授权」,在系统的通知访问设置中开启 GotMsg。
|
||||
|
||||
### 4. 开启转发
|
||||
首页打开「通知转发」开关。安卓收到通知后按配置自动推送到各通道。
|
||||
|
||||
回到首页,打开「通知转发」开关。此时安卓收到的通知就会按规则推送到 iOS。
|
||||
---
|
||||
|
||||
## 使用建议
|
||||
## 二、推送通道配置详解
|
||||
|
||||
- **只想收验证码**:进入「过滤」页,开启「验证码自动提取」,把「广告过滤」等按需开启;再到「应用」页切到白名单模式,只勾选短信类 App。
|
||||
每条配置都可独立勾选/删除/排序/测试。点「添加」时建议每条都先「发送」测试一次。
|
||||
|
||||
### 2.1 Bark(iOS / 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 测试」即成功。
|
||||
|
||||
**为什么推荐 Bark**:iOS 上点击 Bark 通知可一键跳转到微信、QQ、Telegram 对应 App;iOS 14+ 通知体验最自然;Bark 服务器在中国大陆可直接访问。
|
||||
|
||||
### 2.2 ntfy(Android / 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 名**不要用**手机号、姓名、邮箱等可猜信息。
|
||||
|
||||
### 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 把通知发到邮箱(**适合无手机接收的场景**,如仅在桌面/平板查看)。
|
||||
|
||||
**配置步骤**:
|
||||
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)或 `587`(STARTTLS)
|
||||
- **账号**:完整邮箱地址
|
||||
- **授权码 / 密码**:上一步生成的授权码
|
||||
- **发件人**:通常与账号相同
|
||||
- **收件人**:接收通知的目标邮箱
|
||||
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 时,验证码不会丢,漏不了重要登录。
|
||||
|
||||
---
|
||||
|
||||
## 三、自建推送服务(可选)
|
||||
|
||||
所有通道都支持自建,更好、更私密、可控。
|
||||
|
||||
### 3.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。
|
||||
|
||||
### 3.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 照旧。
|
||||
|
||||
### 3.3 自建 Meow 服务器
|
||||
Meow 是鸿蒙客户端,服务端一般用 Meow 自带的。GotMsg 端无需配置服务端,只需填 Meow 给你的接口地址 + Key。
|
||||
|
||||
---
|
||||
|
||||
## 四、使用建议
|
||||
|
||||
- **只想收验证码**:进入「过滤」页,开启「验证码自动提取」和「广告过滤」;再到「应用」页切到白名单模式,只勾选短信类 App。
|
||||
- **想同步聊天**:保持黑名单模式(默认),在「过滤」页开启「微信 / QQ / Telegram 优化」。
|
||||
- **通知没推过来**:先看「日志」页,每条通知(无论转发、过滤还是失败)都会记录原因。
|
||||
- **想让通知带图标**:参考下方「应用图标」一节。
|
||||
- **电话通知不工作**:MIUI 会把系统电话通知限制为不向第三方通知监听器分发,本 App 通过轮询 `getCallonyState()` 绕过此限制。需要授予「电话」权限(设置 → 应用 → GotMsg → 权限 → 电话)。
|
||||
- **断网收不到通知**:开启「设置 → 断网短信兜底」,填好 iPhone 号码、授予短信权限、设好手动额度。断网时验证码和来电会走短信(消耗套餐额度,5 分钟限 1 条)。
|
||||
- **想让通知带图标**:见下方「应用图标」一节。
|
||||
- **电话通知不工作**:MIUI 不向第三方监听器分发系统电话通知,GotMsg 通过轮询 `getCallState()` 绕过此限制。需授予「电话」权限(设置 → 应用 → GotMsg → 权限 → 电话)。
|
||||
- **断网收不到通知**:开启「设置 → 短信兜底」并填好 iPhone 号码、授予短信权限。断网时验证码和来电走短信(消耗套餐额度,5 分钟限 1 条)。
|
||||
- **想全平台通知**:可同时启用多个通道(勾选每个通道的多个条目),所有启用的会同时收到。
|
||||
|
||||
## 应用图标
|
||||
---
|
||||
|
||||
Bark 的 `icon` 字段需要一个公网可访问的图片 URL。GotMsg 支持把已安装 App 的图标批量导出到你选择的目录,方便上传到你自己的图床。
|
||||
## 五、应用图标
|
||||
|
||||
Bark 和 ntfy 的通知 `icon` 字段需要公网可访问的图片 URL。GotMsg 支持把已安装 App 的图标批量导出,方便上传到你自己的图床。
|
||||
|
||||
规则:转发时 `icon = 图标 URL 前缀 + 安卓包名 + .png`。在「设置 → 应用图标」里:
|
||||
|
||||
1. 填写图标 URL 前缀,例如 `https://你的图床域名/icons/`。
|
||||
2. 选择一个目录,点「导出图标」,会把所有 App 图标以 `包名.png` 的形式导出。
|
||||
2. 选择一个目录,点「导出图标」,把该目录下所有 App 的图标以 `包名.png` 形式导出。
|
||||
3. 把导出的图片上传到你的图床对应目录。
|
||||
|
||||
留空前缀则不显示图标。
|
||||
|
||||
## 自建 Bark 服务(可选)
|
||||
---
|
||||
|
||||
GotMsg 默认用 Bark 官方服务器(`https://api.day.app`),免费够用。如果想完全自控、不限速、不依赖第三方,可以自建 Bark Server。下面是三种零成本方案:
|
||||
|
||||
### 方案 1:Vercel 云托管(最适合新手)
|
||||
|
||||
把 Bark 后端跑在 Vercel 的 Serverless 上,每次推送触发一个云函数,不挂进程、完全免费。
|
||||
|
||||
- 平台:[Vercel](https://vercel.com) · 代码:[GitHub](https://github.com)
|
||||
- 在 GitHub 搜索 `bark-server-vercel`,项目 README 里通常有 **Deploy with Vercel** 按钮,一键克隆部署
|
||||
|
||||
### 方案 2:Cloudflare Workers(最稳定)
|
||||
|
||||
把轻量 JS 转发代码部署到 Cloudflare 全球边缘节点,免费额度每天 10 万次请求,个人用属于「溢出」状态。
|
||||
|
||||
- 平台:[Cloudflare](https://www.cloudflare.com)
|
||||
- 在 GitHub 搜索 `bark-server-cloudflare` 或 `bark cloudflare worker`,在 Cloudflare 后台新建 Worker 粘贴脚本保存即可
|
||||
|
||||
### 方案 3:免费 VPS + Docker(最高自主权)
|
||||
|
||||
在永久免费的 Linux 虚拟机上用 Docker 跑官方 bark-server。
|
||||
|
||||
- 官方服务端:[finab/bark-server](https://github.com/finab/bark-server)
|
||||
- 反向代理 + 免费 HTTPS 证书:[Nginx Proxy Manager](https://nginxproxymanager.com)
|
||||
- 免费 VPS:[Oracle Cloud 永久免费层](https://www.oracle.com/cloud/free/)、[AWS 一年免费套餐](https://aws.amazon.com/free/)
|
||||
|
||||
### ⚠️ 防坑提示
|
||||
|
||||
国内网络下,Vercel 的 `*.vercel.app` 和 Cloudflare 的 `*.workers.dev` 二级域名可能间歇性无法访问。**终极方案**:注册一个便宜的个人域名(或找免费域名),解析托管到 Cloudflare,然后把 Vercel / Worker 绑到**自定义域名**上——既稳定又秒达。
|
||||
|
||||
自建完成后,在「设置 → Bark 服务器」里填自建地址即可。
|
||||
|
||||
## 技术栈
|
||||
## 六、技术栈
|
||||
|
||||
- Kotlin + Jetpack Compose(Material 3)
|
||||
- AndroidX DataStore(持久化设置与日志)
|
||||
- OkHttp(Bark / ntfy / Meow / 更新检查)
|
||||
- OkHttp(Bark / ntfy / Meow / 电邮 / 更新检查)
|
||||
- WorkManager(失败重试)
|
||||
- Navigation Compose(单 Activity 多页面)
|
||||
- 最低 Android 14(API 34),目标 Android 16(API 36)
|
||||
|
||||
## 本地构建
|
||||
---
|
||||
|
||||
## 七、构建与安装
|
||||
|
||||
需要 JDK 17 和 Android SDK。
|
||||
|
||||
```bash
|
||||
# 设置环境变量(按你的安装路径)
|
||||
# 设置环境变量
|
||||
export JAVA_HOME=/path/to/jdk-17
|
||||
export ANDROID_HOME=/path/to/android-sdk
|
||||
|
||||
@@ -132,18 +214,18 @@ export ANDROID_HOME=/path/to/android-sdk
|
||||
./gradlew assembleRelease
|
||||
```
|
||||
|
||||
产物位置:
|
||||
|
||||
产物:
|
||||
- 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
|
||||
```
|
||||
|
||||
## 界面截图
|
||||
---
|
||||
|
||||
## 八、界面截图
|
||||
|
||||
| 首页 | 应用管理 | 过滤设置 |
|
||||
|---|---|---|
|
||||
@@ -153,23 +235,29 @@ adb install -r app/build/outputs/apk/release/app-release.apk
|
||||
|---|---|
|
||||
|  |  |
|
||||
|
||||
## 工作原理
|
||||
---
|
||||
|
||||
## 九、工作原理
|
||||
|
||||
```
|
||||
Android 系统通知
|
||||
安卓系统通知
|
||||
└─ NotifyListenerService 接收
|
||||
└─ NotificationProcessor 处理流水线
|
||||
└─ NotificationProcessor 流水线
|
||||
├─ 全局开关 / ongoing 过滤 / 黑白名单
|
||||
├─ 空内容、脱敏、噪音、系统状态过滤
|
||||
├─ 广告关键词过滤
|
||||
├─ 去重(5 秒窗口)
|
||||
├─ 验证码提取
|
||||
└─ 微信 / QQ / Telegram 专项解析
|
||||
└─ BarkClient 推送到 Bark Server
|
||||
└─ 按配置推送到 Bark / ntfy / Meow / 电邮 / 短信
|
||||
└─ 失败 → 待重发队列 → RetryWorker 定期重试
|
||||
└─ 写入本地日志(最近 300 条)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## License
|
||||
|
||||
本项目仅供个人使用。
|
||||
|
||||
All Rights Reserved. 保留所有权利。
|
||||
|
||||
@@ -13,8 +13,8 @@ android {
|
||||
applicationId = "com.a2i.forwarder"
|
||||
minSdk = 34
|
||||
targetSdk = 36
|
||||
versionCode = 34
|
||||
versionName = "1.9.5"
|
||||
versionCode = 40
|
||||
versionName = "1.10.1"
|
||||
}
|
||||
|
||||
signingConfigs {
|
||||
|
||||
@@ -7,6 +7,7 @@ import com.a2i.forwarder.core.ConnectivityMonitor
|
||||
import com.a2i.forwarder.core.LogStore
|
||||
import com.a2i.forwarder.core.PhoneCallMonitor
|
||||
import com.a2i.forwarder.core.SmsFallbackForwarder
|
||||
import com.a2i.forwarder.core.Telemetry
|
||||
import com.a2i.forwarder.core.UpdateChecker
|
||||
import com.a2i.forwarder.store.AppRulesStore
|
||||
import com.a2i.forwarder.store.SettingsStore
|
||||
@@ -33,6 +34,8 @@ class A2iApp : Application() {
|
||||
private set
|
||||
lateinit var updateChecker: UpdateChecker
|
||||
private set
|
||||
lateinit var telemetry: Telemetry
|
||||
private set
|
||||
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
@@ -40,6 +43,8 @@ class A2iApp : Application() {
|
||||
settings = SettingsStore(this, appScope)
|
||||
appRules = AppRulesStore(this, appScope)
|
||||
logStore = LogStore(this, appScope)
|
||||
telemetry = Telemetry(this, appScope)
|
||||
telemetry.start()
|
||||
phoneCallMonitor = PhoneCallMonitor(this)
|
||||
phoneCallMonitor.start()
|
||||
connectivityMonitor = ConnectivityMonitor(this)
|
||||
|
||||
@@ -35,6 +35,7 @@ class CarrierBalanceQuery(private val context: Context) {
|
||||
|
||||
/** 转发成功后扣减余额(手动模式 / 自动解析失败时)。 */
|
||||
suspend fun decrementManual() {
|
||||
if (!settings.smsBalanceCheck.value) return // 余额检查关闭时不递减
|
||||
val q = settings.smsManualQuota.value
|
||||
if (q > 0) {
|
||||
val newQ = q - 1
|
||||
@@ -66,6 +67,7 @@ class CarrierBalanceQuery(private val context: Context) {
|
||||
}
|
||||
|
||||
private suspend fun checkSuspend(balance: Int) {
|
||||
if (!settings.smsBalanceCheck.value) return // 余额检查关闭时不挂起
|
||||
if (balance in 0..5 && !settings.smsSuspended.value) {
|
||||
settings.setSmsSuspended(true)
|
||||
}
|
||||
|
||||
@@ -123,7 +123,12 @@ object EmailSender {
|
||||
val configs = s.emailServers.value.filter { it.enabled && it.to.isNotBlank() && it.host.isNotBlank() }
|
||||
if (configs.isEmpty()) return
|
||||
scope.launch {
|
||||
pushToEnabled(configs, title, body, s.emailStopOnFirst.value)
|
||||
val result = pushToEnabled(configs, title, body, s.emailStopOnFirst.value)
|
||||
app.telemetry.trackForward(
|
||||
channel = "email",
|
||||
status = if (result.isSuccess) "success" else "failed",
|
||||
reason = result.exceptionOrNull()?.message.orEmpty(),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -75,7 +75,12 @@ object MeowSender {
|
||||
val configs = s.meowServers.value.filter { it.enabled && it.server.isNotBlank() && it.deviceKey.isNotBlank() }
|
||||
if (configs.isEmpty()) return
|
||||
scope.launch {
|
||||
pushToEnabled(configs, title, body, s.meowStopOnFirst.value)
|
||||
val result = pushToEnabled(configs, title, body, s.meowStopOnFirst.value)
|
||||
app.telemetry.trackForward(
|
||||
channel = "meow",
|
||||
status = if (result.isSuccess) "success" else "failed",
|
||||
reason = result.exceptionOrNull()?.message.orEmpty(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -92,7 +92,12 @@ object NtfySender {
|
||||
val configs = s.ntfyServers.value.filter { it.enabled && it.topic.isNotBlank() }
|
||||
if (configs.isEmpty()) return
|
||||
scope.launch {
|
||||
pushToEnabled(configs, title, body, s.ntfyStopOnFirst.value)
|
||||
val result = pushToEnabled(configs, title, body, s.ntfyStopOnFirst.value)
|
||||
app.telemetry.trackForward(
|
||||
channel = "ntfy",
|
||||
status = if (result.isSuccess) "success" else "failed",
|
||||
reason = result.exceptionOrNull()?.message.orEmpty(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -18,11 +18,8 @@ import kotlinx.serialization.json.Json
|
||||
import java.util.UUID
|
||||
|
||||
/**
|
||||
* 通过轮询 TelephonyManager.getCallState() 监听来电状态。
|
||||
* MIUI 不会把系统电话通知分发给第三方监听器,TelephonyCallback 也不生效,
|
||||
* 所以这里用最原始但最可靠的方式:每 800ms 读一次 call state。
|
||||
*
|
||||
* getCallState() 只是读内存中的 int,不涉及硬件操作,耗电极低。
|
||||
* 轮询 TelephonyManager.getCallState() 监听来电状态。
|
||||
* 这里绕过第三方监听器不稳定的限制,直接在轮询状态变化时转发通知。
|
||||
*/
|
||||
class PhoneCallMonitor(private val context: Context) {
|
||||
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
||||
@@ -32,15 +29,11 @@ class PhoneCallMonitor(private val context: Context) {
|
||||
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
|
||||
if (ContextCompat.checkSelfPermission(context, Manifest.permission.READ_PHONE_STATE) != PackageManager.PERMISSION_GRANTED) return
|
||||
|
||||
running = true
|
||||
lastState = TelephonyManager.CALL_STATE_IDLE
|
||||
@@ -49,7 +42,10 @@ class PhoneCallMonitor(private val context: Context) {
|
||||
|
||||
scope.launch {
|
||||
val tm = context.getSystemService(Context.TELEPHONY_SERVICE) as? TelephonyManager
|
||||
if (tm == null) { running = false; return@launch }
|
||||
if (tm == null) {
|
||||
running = false
|
||||
return@launch
|
||||
}
|
||||
|
||||
while (isActive && running) {
|
||||
val state = tm.callState
|
||||
@@ -72,7 +68,6 @@ class PhoneCallMonitor(private val context: Context) {
|
||||
if (!settings.forwardingEnabled.value) return
|
||||
|
||||
when {
|
||||
// IDLE → RINGING:来电
|
||||
from == TelephonyManager.CALL_STATE_IDLE && to == TelephonyManager.CALL_STATE_RINGING -> {
|
||||
ringing = true
|
||||
offhook = false
|
||||
@@ -91,12 +86,10 @@ class PhoneCallMonitor(private val context: Context) {
|
||||
}
|
||||
}
|
||||
|
||||
// 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(
|
||||
@@ -113,7 +106,6 @@ class PhoneCallMonitor(private val context: Context) {
|
||||
offhook = false
|
||||
}
|
||||
|
||||
// OFFHOOK → IDLE:正常挂断,不推送
|
||||
from == TelephonyManager.CALL_STATE_OFFHOOK && to == TelephonyManager.CALL_STATE_IDLE -> {
|
||||
ringing = false
|
||||
offhook = false
|
||||
@@ -124,27 +116,35 @@ class PhoneCallMonitor(private val context: Context) {
|
||||
private fun pushToServers(msg: BarkMessage, app: A2iApp, logBody: String) {
|
||||
scope.launch {
|
||||
val servers = app.settings.servers.value.filter { it.enabled && it.deviceKey.isNotBlank() }
|
||||
val now = System.currentTimeMillis()
|
||||
|
||||
if (servers.isEmpty()) {
|
||||
val reason = "未配置可用 Bark 服务器"
|
||||
app.logStore.addLog(ForwardLog(now, "phone_call", "电话", msg.title, logBody, "failed", reason))
|
||||
app.telemetry.trackForward("bark", "failed", reason)
|
||||
EmailSender.trySend(app, msg.title ?: logBody, msg.body ?: "", scope)
|
||||
NtfySender.trySend(app, msg.title ?: logBody, msg.body ?: "", scope)
|
||||
MeowSender.trySend(app, msg.title ?: logBody, msg.body ?: "", scope)
|
||||
return@launch
|
||||
}
|
||||
|
||||
val result = BarkSender.pushToEnabled(servers, msg, app.settings.barkStopOnFirst.value)
|
||||
val now = System.currentTimeMillis()
|
||||
if (result.isSuccess) {
|
||||
app.logStore.addLog(
|
||||
ForwardLog(now, "phone_call", "电话", msg.title, logBody, "sent", "")
|
||||
)
|
||||
app.telemetry.trackForward("bark", "success")
|
||||
} else {
|
||||
val reason = result.exceptionOrNull()?.message ?: "错误"
|
||||
app.logStore.addLog(
|
||||
ForwardLog(now, "phone_call", "电话", msg.title, logBody, "failed", reason)
|
||||
)
|
||||
app.telemetry.trackForward("bark", "failed", reason)
|
||||
app.logStore.addPending(
|
||||
PendingPush(UUID.randomUUID().toString(), now, servers.first().id, json.encodeToString(msg))
|
||||
)
|
||||
}
|
||||
|
||||
EmailSender.trySend(app, msg.title ?: logBody, msg.body ?: "", scope)
|
||||
NtfySender.trySend(app, msg.title ?: logBody, msg.body ?: "", scope)
|
||||
MeowSender.trySend(app, msg.title ?: logBody, msg.body ?: "", scope)
|
||||
|
||||
@@ -29,7 +29,13 @@ class RetryWorker(ctx: Context, params: WorkerParameters) : CoroutineWorker(ctx,
|
||||
if (servers.isEmpty()) { allOk = false; break }
|
||||
val msg = runCatching { json.decodeFromString<com.a2i.forwarder.model.BarkMessage>(p.messageJson) }.getOrNull() ?: continue
|
||||
val r = BarkSender.pushToFirst(servers, msg)
|
||||
if (r.isSuccess) store.removePending(p.id) else allOk = false
|
||||
if (r.isSuccess) {
|
||||
store.removePending(p.id)
|
||||
app.telemetry.trackForward("bark", "success")
|
||||
} else {
|
||||
allOk = false
|
||||
app.telemetry.trackForward("bark", "failed", r.exceptionOrNull()?.message.orEmpty())
|
||||
}
|
||||
}
|
||||
return if (allOk) Result.success() else Result.retry()
|
||||
}
|
||||
|
||||
@@ -13,61 +13,63 @@ 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 // 验证码
|
||||
if (settings.smsFilterAll.value) return true
|
||||
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("未接")
|
||||
if (msg.group == "phone_call") return true
|
||||
val title = msg.title.orEmpty()
|
||||
return title.contains("来电") || title.contains("未接")
|
||||
}
|
||||
|
||||
/** 尝试用短信转发(遍历所有启用的目标号,按 stopOnFirst 决定发通一条止还是全发)。 */
|
||||
fun forward(d: Decision) {
|
||||
scope.launch {
|
||||
if (settings.smsSuspended.value) {
|
||||
val s = settings
|
||||
|
||||
if (s.smsBalanceCheck.value && s.smsSuspended.value) {
|
||||
log(d, "failed", "短信已挂起(余额不足)")
|
||||
return@launch
|
||||
}
|
||||
|
||||
if (!isSmsWorthy(d)) return@launch
|
||||
val targets = settings.smsTargets.value.filter { it.enabled && it.number.isNotBlank() }
|
||||
|
||||
val targets = s.smsTargets.value.filter { it.enabled && it.number.isNotBlank() }
|
||||
if (targets.isEmpty()) {
|
||||
log(d, "failed", "未设置短信目标号")
|
||||
return@launch
|
||||
}
|
||||
// 限频
|
||||
|
||||
val now = System.currentTimeMillis()
|
||||
val elapsed = now - settings.lastSmsSentTime.value
|
||||
if (elapsed < RATE_LIMIT_MS) {
|
||||
log(d, "filtered", "限频(${RATE_LIMIT_MS - elapsed}ms 内)")
|
||||
if (s.smsRateLimitEnabled.value) {
|
||||
val limitMs = s.smsRateLimitMin.value.coerceIn(1, 60) * 60 * 1000L
|
||||
val elapsed = now - s.lastSmsSentTime.value
|
||||
if (elapsed < limitMs) {
|
||||
log(d, "filtered", "限频(${limitMs - elapsed}ms 内)")
|
||||
return@launch
|
||||
}
|
||||
// 权限
|
||||
if (ContextCompat.checkSelfPermission(context, Manifest.permission.SEND_SMS)
|
||||
!= PackageManager.PERMISSION_GRANTED
|
||||
) {
|
||||
log(d, "failed", "无 SEND_SMS 权限")
|
||||
}
|
||||
|
||||
if (ContextCompat.checkSelfPermission(context, Manifest.permission.SEND_SMS) != PackageManager.PERMISSION_GRANTED) {
|
||||
log(d, "failed", "缺少 SEND_SMS 权限")
|
||||
return@launch
|
||||
}
|
||||
|
||||
val body = compose(d).take(70)
|
||||
val stopOnFirst = settings.smsStopOnFirst.value
|
||||
val stopOnFirst = s.smsStopOnFirst.value
|
||||
var anyOk = false
|
||||
var sentCount = 0
|
||||
var lastReason = "短信发送失败"
|
||||
for (t in targets) {
|
||||
for (target in targets) {
|
||||
val result = runCatching {
|
||||
SmsManager.getDefault().sendTextMessage(t.number.trim(), null, body, null, null)
|
||||
SmsManager.getDefault().sendTextMessage(target.number.trim(), null, body, null, null)
|
||||
}
|
||||
if (result.isSuccess) {
|
||||
anyOk = true
|
||||
@@ -77,16 +79,18 @@ class SmsFallbackForwarder(private val context: Context) {
|
||||
lastReason = result.exceptionOrNull()?.message ?: "短信发送失败"
|
||||
}
|
||||
}
|
||||
|
||||
if (anyOk) {
|
||||
settings.setLastSmsSentTime(now)
|
||||
s.setLastSmsSentTime(now)
|
||||
log(d, "sent", "")
|
||||
// 余额递减:按成功发送条数;自动模式发查询短信,手动模式递减
|
||||
if (s.smsBalanceCheck.value) {
|
||||
val carrier = app.carrierBalanceQuery.currentCarrier()
|
||||
if (carrier.serviceNumber != null && carrier.queryCmd != null) {
|
||||
app.carrierBalanceQuery.refresh()
|
||||
} else {
|
||||
repeat(sentCount) { runCatching { app.carrierBalanceQuery.decrementManual() } }
|
||||
}
|
||||
}
|
||||
} else {
|
||||
log(d, "failed", lastReason)
|
||||
}
|
||||
@@ -98,8 +102,8 @@ class SmsFallbackForwarder(private val context: Context) {
|
||||
return if (code != null) {
|
||||
"[验证码]${d.appLabel}: $code"
|
||||
} else {
|
||||
val m = d.message!!
|
||||
"${m.title.orEmpty().ifBlank { d.appLabel }}: ${m.body}".trim()
|
||||
val message = d.message!!
|
||||
"${message.title.orEmpty().ifBlank { d.appLabel }}: ${message.body}".trim()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -111,6 +115,10 @@ class SmsFallbackForwarder(private val context: Context) {
|
||||
app.logStore.addLog(
|
||||
ForwardLog(now, "sms_fallback", d.appLabel, title, content, status, reason)
|
||||
)
|
||||
when (status) {
|
||||
"sent" -> app.telemetry.trackForward("sms", "success")
|
||||
"failed" -> app.telemetry.trackForward("sms", "failed", reason)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,178 @@
|
||||
package com.a2i.forwarder.core
|
||||
|
||||
import android.content.Context
|
||||
import android.net.ConnectivityManager
|
||||
import android.net.NetworkCapabilities
|
||||
import android.os.Build
|
||||
import android.provider.Settings
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import okhttp3.MediaType.Companion.toMediaType
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import okhttp3.RequestBody.Companion.toRequestBody
|
||||
import org.json.JSONObject
|
||||
import java.util.Locale
|
||||
import java.util.TimeZone
|
||||
import java.util.UUID
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
class Telemetry(
|
||||
private val context: Context,
|
||||
private val scope: CoroutineScope,
|
||||
) {
|
||||
private val prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
|
||||
private val http = OkHttpClient.Builder()
|
||||
.connectTimeout(8, TimeUnit.SECONDS)
|
||||
.writeTimeout(8, TimeUnit.SECONDS)
|
||||
.readTimeout(10, TimeUnit.SECONDS)
|
||||
.retryOnConnectionFailure(true)
|
||||
.build()
|
||||
private val sessionId = UUID.randomUUID().toString()
|
||||
|
||||
fun start() {
|
||||
scope.launch(Dispatchers.IO) {
|
||||
if (!prefs.getBoolean(KEY_INSTALL_SENT, false)) {
|
||||
send("install").onSuccess {
|
||||
prefs.edit().putBoolean(KEY_INSTALL_SENT, true).apply()
|
||||
}
|
||||
}
|
||||
send("launch")
|
||||
}
|
||||
}
|
||||
|
||||
fun trackForward(channel: String, status: String, reason: String = "") {
|
||||
val normalizedChannel = channel.trim().lowercase(Locale.ROOT)
|
||||
val normalizedStatus = status.trim().lowercase(Locale.ROOT)
|
||||
if (normalizedChannel.isBlank() || normalizedStatus.isBlank()) return
|
||||
scope.launch(Dispatchers.IO) {
|
||||
send(
|
||||
event = "forward_$normalizedChannel",
|
||||
fields = mapOf(
|
||||
"channel" to normalizedChannel,
|
||||
"status" to normalizedStatus,
|
||||
"reason" to reason.trim().take(500),
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun send(event: String): Result<Unit> = withContext(Dispatchers.IO) {
|
||||
runCatching {
|
||||
val payload = basePayload(event)
|
||||
val request = Request.Builder()
|
||||
.url(EVENTS_URL)
|
||||
.header("User-Agent", userAgent())
|
||||
.post(payload.toString().toRequestBody(JSON))
|
||||
.build()
|
||||
http.newCall(request).execute().use { response ->
|
||||
if (!response.isSuccessful) error("HTTP ${response.code}")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun send(event: String, fields: Map<String, Any?>): Result<Unit> = withContext(Dispatchers.IO) {
|
||||
runCatching {
|
||||
val payload = basePayload(event)
|
||||
fields.forEach { (key, value) ->
|
||||
if (value == null) return@forEach
|
||||
when (value) {
|
||||
is String -> if (value.isNotBlank()) payload.put(key, value)
|
||||
is Number, is Boolean -> payload.put(key, value)
|
||||
else -> payload.put(key, value)
|
||||
}
|
||||
}
|
||||
val request = Request.Builder()
|
||||
.url(EVENTS_URL)
|
||||
.header("User-Agent", userAgent())
|
||||
.post(payload.toString().toRequestBody(JSON))
|
||||
.build()
|
||||
http.newCall(request).execute().use { response ->
|
||||
if (!response.isSuccessful) error("HTTP ${response.code}")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun basePayload(event: String): JSONObject {
|
||||
val version = appVersion()
|
||||
val locale = Locale.getDefault()
|
||||
return JSONObject()
|
||||
.put("app_id", APP_ID)
|
||||
.put("app_name", APP_NAME)
|
||||
.put("event", event)
|
||||
.put("install_id", installId())
|
||||
.put("device_id", androidId())
|
||||
.put("session_id", sessionId)
|
||||
.put("platform", "android")
|
||||
.put("package_name", context.packageName)
|
||||
.put("app_version", version.name)
|
||||
.put("app_build", version.code)
|
||||
.put("os_version", "Android ${Build.VERSION.RELEASE} SDK ${Build.VERSION.SDK_INT}")
|
||||
.put("manufacturer", Build.MANUFACTURER.orEmpty())
|
||||
.put("model", Build.MODEL.orEmpty())
|
||||
.put("locale", locale.toLanguageTag())
|
||||
.put("timezone", TimeZone.getDefault().id)
|
||||
.put("country", locale.country.orEmpty())
|
||||
.put("network_type", networkType())
|
||||
.put("user_agent", userAgent())
|
||||
.put("source", "android-app")
|
||||
}
|
||||
|
||||
private fun installId(): String {
|
||||
prefs.getString(KEY_INSTALL_ID, null)?.let { return it }
|
||||
val id = UUID.randomUUID().toString()
|
||||
prefs.edit().putString(KEY_INSTALL_ID, id).apply()
|
||||
return id
|
||||
}
|
||||
|
||||
private fun androidId(): String =
|
||||
Settings.Secure.getString(context.contentResolver, Settings.Secure.ANDROID_ID).orEmpty()
|
||||
|
||||
private fun appVersion(): AppVersion {
|
||||
val info = context.packageManager.getPackageInfo(context.packageName, 0)
|
||||
return AppVersion(
|
||||
name = info.versionName ?: "0",
|
||||
code = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
|
||||
info.longVersionCode.toString()
|
||||
} else {
|
||||
@Suppress("DEPRECATION")
|
||||
info.versionCode.toString()
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
private fun networkType(): String {
|
||||
val manager = context.getSystemService(Context.CONNECTIVITY_SERVICE) as? ConnectivityManager ?: return "unknown"
|
||||
val network = manager.activeNetwork ?: return "none"
|
||||
val capabilities = manager.getNetworkCapabilities(network) ?: return "unknown"
|
||||
return when {
|
||||
capabilities.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) -> "wifi"
|
||||
capabilities.hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR) -> "cellular"
|
||||
capabilities.hasTransport(NetworkCapabilities.TRANSPORT_ETHERNET) -> "ethernet"
|
||||
capabilities.hasTransport(NetworkCapabilities.TRANSPORT_VPN) -> "vpn"
|
||||
else -> "other"
|
||||
}
|
||||
}
|
||||
|
||||
private fun userAgent(): String {
|
||||
val version = appVersion()
|
||||
return "GotMsg/${version.name} (${context.packageName}; Android ${Build.VERSION.SDK_INT}; ${Build.MANUFACTURER} ${Build.MODEL})"
|
||||
}
|
||||
|
||||
private data class AppVersion(
|
||||
val name: String,
|
||||
val code: String,
|
||||
)
|
||||
|
||||
companion object {
|
||||
private const val APP_ID = "gotmsg"
|
||||
private const val APP_NAME = "GotMsg"
|
||||
private const val EVENTS_URL = "https://stats.songer.eu.org/v1/events"
|
||||
private const val PREFS_NAME = "gotmsg_telemetry"
|
||||
private const val KEY_INSTALL_ID = "install_id"
|
||||
private const val KEY_INSTALL_SENT = "install_sent"
|
||||
private val JSON = "application/json; charset=utf-8".toMediaType()
|
||||
}
|
||||
}
|
||||
@@ -300,8 +300,8 @@ class UpdateChecker(private val context: Context) {
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val GITHUB_API = "https://api.github.com/repos/lsxf/a2i/releases/latest"
|
||||
const val GITEA_API = "https://gitea.songer.eu.org/api/v1/repos/song/a2i/releases/latest"
|
||||
const val GITHUB_API = "https://api.github.com/repos/lsxf/gotmsg/releases/latest"
|
||||
const val GITEA_API = "https://gitea.songer.eu.org/api/v1/repos/song/gotmsg/releases/latest"
|
||||
const val CHECK_INTERVAL_MS = 24L * 60 * 60 * 1000
|
||||
}
|
||||
|
||||
|
||||
@@ -39,49 +39,55 @@ class NotifyListenerService : NotificationListenerService() {
|
||||
private suspend fun handle(sbn: StatusBarNotification) {
|
||||
val app = A2iApp.instance
|
||||
val now = System.currentTimeMillis()
|
||||
val d = processor.process(sbn)
|
||||
val msg = d.message
|
||||
val decision = processor.process(sbn)
|
||||
val msg = decision.message
|
||||
|
||||
if (msg == null) {
|
||||
// 静默过滤(如持续性通知)不写日志
|
||||
if (!d.silent) {
|
||||
app.logStore.addLog(ForwardLog(now, sbn.packageName, d.appLabel, "", "", "filtered", d.reason))
|
||||
if (!decision.silent) {
|
||||
app.logStore.addLog(
|
||||
ForwardLog(now, sbn.packageName, decision.appLabel, "", "", "filtered", decision.reason)
|
||||
)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// ---- 断网兜底:离线时紧要通知走短信,其余不转发避免离线堆积 ----
|
||||
val online = app.connectivityMonitor.isOnline.value
|
||||
if (!online) {
|
||||
if (app.smsForwarder.isSmsWorthy(d)) {
|
||||
app.smsForwarder.forward(d)
|
||||
if (!app.connectivityMonitor.isOnline.value) {
|
||||
if (app.smsForwarder.isSmsWorthy(decision)) {
|
||||
app.smsForwarder.forward(decision)
|
||||
} else {
|
||||
app.logStore.addLog(ForwardLog(now, sbn.packageName, d.appLabel, msg.title, msg.body, "filtered", "离线非紧要通知"))
|
||||
app.logStore.addLog(
|
||||
ForwardLog(now, sbn.packageName, decision.appLabel, msg.title, msg.body, "filtered", "离线非紧要通知")
|
||||
)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
val servers = app.settings.servers.value.filter { it.enabled && it.deviceKey.isNotBlank() }
|
||||
if (servers.isEmpty()) {
|
||||
app.logStore.addLog(ForwardLog(now, sbn.packageName, d.appLabel, msg.title, msg.body, "failed", "未配置可用 Bark 服务器"))
|
||||
EmailSender.trySend(app, msg.title ?: d.appLabel, msg.body, scope)
|
||||
NtfySender.trySend(app, msg.title ?: d.appLabel, msg.body, scope)
|
||||
MeowSender.trySend(app, msg.title ?: d.appLabel, msg.body, scope)
|
||||
val reason = "未配置可用 Bark 服务器"
|
||||
app.logStore.addLog(ForwardLog(now, sbn.packageName, decision.appLabel, msg.title, msg.body, "failed", reason))
|
||||
app.telemetry.trackForward("bark", "failed", reason)
|
||||
EmailSender.trySend(app, msg.title ?: decision.appLabel, msg.body, scope)
|
||||
NtfySender.trySend(app, msg.title ?: decision.appLabel, msg.body, scope)
|
||||
MeowSender.trySend(app, msg.title ?: decision.appLabel, msg.body, scope)
|
||||
return
|
||||
}
|
||||
|
||||
val result = BarkSender.pushToEnabled(servers, msg, app.settings.barkStopOnFirst.value)
|
||||
if (result.isSuccess) {
|
||||
app.logStore.addLog(ForwardLog(now, sbn.packageName, d.appLabel, msg.title, msg.body, "sent", ""))
|
||||
app.logStore.addLog(ForwardLog(now, sbn.packageName, decision.appLabel, msg.title, msg.body, "sent", ""))
|
||||
app.telemetry.trackForward("bark", "success")
|
||||
} else {
|
||||
val reason = result.exceptionOrNull()?.message ?: "错误"
|
||||
app.logStore.addLog(ForwardLog(now, sbn.packageName, d.appLabel, msg.title, msg.body, "failed", reason))
|
||||
app.logStore.addLog(ForwardLog(now, sbn.packageName, decision.appLabel, msg.title, msg.body, "failed", reason))
|
||||
app.telemetry.trackForward("bark", "failed", reason)
|
||||
app.logStore.addPending(
|
||||
PendingPush(UUID.randomUUID().toString(), now, servers.first().id, json.encodeToString(msg))
|
||||
)
|
||||
}
|
||||
EmailSender.trySend(app, msg.title ?: d.appLabel, msg.body, scope)
|
||||
NtfySender.trySend(app, msg.title ?: d.appLabel, msg.body, scope)
|
||||
MeowSender.trySend(app, msg.title ?: d.appLabel, msg.body, scope)
|
||||
|
||||
EmailSender.trySend(app, msg.title ?: decision.appLabel, msg.body, scope)
|
||||
NtfySender.trySend(app, msg.title ?: decision.appLabel, msg.body, scope)
|
||||
MeowSender.trySend(app, msg.title ?: decision.appLabel, msg.body, scope)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -56,6 +56,10 @@ class SettingsStore(
|
||||
val SMS_BALANCE = intPreferencesKey("sms_balance")
|
||||
val SMS_SUSPENDED = booleanPreferencesKey("sms_suspended")
|
||||
val SMS_LAST_SENT = longPreferencesKey("sms_last_sent")
|
||||
val SMS_FILTER_ALL = booleanPreferencesKey("sms_filter_all") // true=全部转发 false=仅紧要
|
||||
val SMS_RATE_LIMIT_ENABLED = booleanPreferencesKey("sms_rate_limit_en")
|
||||
val SMS_RATE_LIMIT_MIN = intPreferencesKey("sms_rate_limit_min")
|
||||
val SMS_BALANCE_CHECK = booleanPreferencesKey("sms_balance_check")
|
||||
// 电邮转发
|
||||
val EMAIL_ENABLED = booleanPreferencesKey("email_enabled")
|
||||
val EMAIL_SERVERS = stringPreferencesKey("email_servers")
|
||||
@@ -121,6 +125,10 @@ class SettingsStore(
|
||||
val smsBalance = MutableStateFlow(-1)
|
||||
val smsSuspended = MutableStateFlow(false)
|
||||
val lastSmsSentTime = MutableStateFlow(0L)
|
||||
val smsFilterAll = MutableStateFlow(false) // true=全部转发 false=仅紧要
|
||||
val smsRateLimitEnabled = MutableStateFlow(true)
|
||||
val smsRateLimitMin = MutableStateFlow(5)
|
||||
val smsBalanceCheck = MutableStateFlow(true)
|
||||
|
||||
// 电邮总开关
|
||||
val emailForwardingEnabled = MutableStateFlow(false)
|
||||
@@ -159,6 +167,10 @@ class SettingsStore(
|
||||
smsBalance.value = p[K.SMS_BALANCE] ?: -1
|
||||
smsSuspended.value = p[K.SMS_SUSPENDED] ?: false
|
||||
lastSmsSentTime.value = p[K.SMS_LAST_SENT] ?: 0L
|
||||
smsFilterAll.value = p[K.SMS_FILTER_ALL] ?: false
|
||||
smsRateLimitEnabled.value = p[K.SMS_RATE_LIMIT_ENABLED] ?: true
|
||||
smsRateLimitMin.value = p[K.SMS_RATE_LIMIT_MIN] ?: 5
|
||||
smsBalanceCheck.value = p[K.SMS_BALANCE_CHECK] ?: true
|
||||
// 电邮总开关
|
||||
emailForwardingEnabled.value = p[K.EMAIL_ENABLED] ?: false
|
||||
// 应用更新
|
||||
@@ -343,6 +355,10 @@ class SettingsStore(
|
||||
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 setSmsFilterAll(v: Boolean) { edit { it[K.SMS_FILTER_ALL] = v }; smsFilterAll.value = v }
|
||||
suspend fun setSmsRateLimitEnabled(v: Boolean) { edit { it[K.SMS_RATE_LIMIT_ENABLED] = v }; smsRateLimitEnabled.value = v }
|
||||
suspend fun setSmsRateLimitMin(v: Int) { edit { it[K.SMS_RATE_LIMIT_MIN] = v }; smsRateLimitMin.value = v }
|
||||
suspend fun setSmsBalanceCheck(v: Boolean) { edit { it[K.SMS_BALANCE_CHECK] = v }; smsBalanceCheck.value = v }
|
||||
|
||||
suspend fun setUpdateCheckEnabled(v: Boolean) { edit { it[K.UPDATE_CHECK_AUTO] = v }; updateCheckEnabled.value = v }
|
||||
suspend fun setLastUpdateCheck(v: Long) { edit { it[K.LAST_UPDATE_CHECK] = v }; lastUpdateCheckTime.value = v }
|
||||
@@ -354,6 +370,6 @@ class SettingsStore(
|
||||
|
||||
companion object {
|
||||
const val ID_OFFICIAL = "official"
|
||||
const val DEFAULT_ICON_PREFIX = "https://raw.githubusercontent.com/lsxf/a2i/main/icons/"
|
||||
const val DEFAULT_ICON_PREFIX = "https://raw.githubusercontent.com/lsxf/gotmsg/main/icons/"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -113,6 +113,10 @@ fun SettingsScreen(onOpenLog: () -> Unit) {
|
||||
val meowServers by app.settings.meowServers.collectAsState()
|
||||
val emailServers by app.settings.emailServers.collectAsState()
|
||||
val smsTargets by app.settings.smsTargets.collectAsState()
|
||||
val smsFilterAll by app.settings.smsFilterAll.collectAsState()
|
||||
val smsRateLimitEnabled by app.settings.smsRateLimitEnabled.collectAsState()
|
||||
val smsRateLimitMin by app.settings.smsRateLimitMin.collectAsState()
|
||||
val smsBalanceCheck by app.settings.smsBalanceCheck.collectAsState()
|
||||
val barkStopFirst by app.settings.barkStopOnFirst.collectAsState()
|
||||
val ntfyStopFirst by app.settings.ntfyStopOnFirst.collectAsState()
|
||||
val meowStopFirst by app.settings.meowStopOnFirst.collectAsState()
|
||||
@@ -312,7 +316,7 @@ fun SettingsScreen(onOpenLog: () -> Unit) {
|
||||
SectionCard {
|
||||
ChannelSectionHeader(
|
||||
title = "断网短信兜底",
|
||||
summary = "无网络时发送关键通知",
|
||||
summary = "无网络时用短信发送通知",
|
||||
mark = "S",
|
||||
markColor = Color(0xFF00897B),
|
||||
markIcon = Icons.Filled.Sms,
|
||||
@@ -342,13 +346,48 @@ fun SettingsScreen(onOpenLog: () -> Unit) {
|
||||
}
|
||||
}
|
||||
AddButton { showAddSms = true }
|
||||
|
||||
// ── 高级设置 ──
|
||||
Spacer(Modifier.size(12.dp))
|
||||
SwitchRow(
|
||||
title = "仅紧要通知",
|
||||
subtitle = if (smsFilterAll) "当前:全部转发(不限内容)" else "当前:仅验证码 + 来电",
|
||||
checked = !smsFilterAll,
|
||||
icon = Icons.Filled.Settings,
|
||||
) { v -> app.appScope.launch { app.settings.setSmsFilterAll(!v) } }
|
||||
|
||||
SwitchRow(
|
||||
title = "限频",
|
||||
subtitle = if (smsRateLimitEnabled) "每 ${smsRateLimitMin} 分钟最多 1 条" else "已关闭(来一条发一条)",
|
||||
checked = smsRateLimitEnabled,
|
||||
) { v -> app.appScope.launch { app.settings.setSmsRateLimitEnabled(v) } }
|
||||
|
||||
SwitchRow(
|
||||
title = "余额检查",
|
||||
subtitle = if (smsBalanceCheck) "余额 ≤5 自动挂起" else "已关闭(不限余额)",
|
||||
checked = smsBalanceCheck,
|
||||
) { v -> app.appScope.launch { app.settings.setSmsBalanceCheck(v) } }
|
||||
|
||||
// 余额检查开启时显示运营商/额度/挂起状态
|
||||
if (smsBalanceCheck) {
|
||||
Spacer(Modifier.size(8.dp))
|
||||
val balanceText = when {
|
||||
smsSuspended -> "已挂起(余额不足)"
|
||||
smsBalance < 0 -> "余额未知"
|
||||
else -> "剩余约 $smsBalance 条"
|
||||
}
|
||||
Text("运营商:${detectedCarrier.displayName} · 余额:$balanceText · 手动额度:$smsManualQuota", style = MaterialTheme.typography.bodySmall, color = if (smsSuspended) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
Text("运营商:${detectedCarrier.displayName} · $balanceText · 手动额度:$smsManualQuota", style = MaterialTheme.typography.bodySmall, color = if (smsSuspended) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
if (smsSuspended) {
|
||||
Spacer(Modifier.size(4.dp))
|
||||
OutlinedButton(onClick = {
|
||||
app.appScope.launch {
|
||||
app.settings.setSmsSuspended(false)
|
||||
val q = smsManualQuota
|
||||
if (q > 0) app.settings.setSmsBalance(q)
|
||||
}
|
||||
}, modifier = Modifier.fillMaxWidth()) { Text("解除挂起") }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user