Compare commits
29 Commits
6ff3f306c4
...
v1.10.0
| Author | SHA1 | Date | |
|---|---|---|---|
| b0719b9a90 | |||
| 70f9ba093f | |||
| f7adeda080 | |||
| 7f6fe4fc00 | |||
| 24626383b6 | |||
| 1f9f6e9934 | |||
| 38c2bcd819 | |||
| 648c63bfbc | |||
| 00bdbe2d1e | |||
| a3f67a0a7c | |||
| 6e29c292ec | |||
| b14ee02ef8 | |||
| 0ea5e1b228 | |||
| de4cb2f40c | |||
| 5ccf5bda6e | |||
| 2506622d06 | |||
| d28776b0ec | |||
| 191a04a68b | |||
| 22ac5a31ff | |||
| 336f49e4eb | |||
| 8588da1f6b | |||
| b32b759695 | |||
| af27febf1f | |||
| a48ef78844 | |||
| ff9673ed11 | |||
| d666c7f5d4 | |||
| 2034257560 | |||
| 9a6c21b78d | |||
| e0fd115e55 |
@@ -1,123 +1,209 @@
|
|||||||
# A2i ·安到果(按倒过[呲牙])
|
# GotMsg ·有消息
|
||||||
|
|
||||||
把安卓手机上的通知(短信、验证码、电话“有电话进来这个通知,不是电话呼叫转移”、微信、QQ、Telegram 等)实时转发到 iOS,通过 [Bark](https://github.com/Finb/Bark) 推送到 iPhone / iPad。在 iOS 上点击通知,还能一键跳转到对应的 App。
|
把安卓手机上的通知(短信、验证码、电话、微信、QQ、Telegram)实时转发到 iOS / Android / 鸿蒙等接收端。
|
||||||
|
|
||||||
适用于把安卓备机的消息同步到 iOS 设备,不漏验证码、不漏重要聊天。
|
适用于把安卓备机的消息同步到 iPhone 或其他安卓 / 鸿蒙手机,不漏验证码、不漏重要聊天。
|
||||||
|
|
||||||
## 主要功能
|

|
||||||
|
|
||||||
- **系统级通知监听**:接入 Android 通知监听服务,覆盖所有发出通知的 App。
|
---
|
||||||
- **Bark 推送**:支持官方 Bark 服务和自建 Bark Server,可保存多个服务器并随时切换。
|
|
||||||
- **验证码自动提取**:自动识别通知中的验证码,收到即复制,也可点击复制。
|
|
||||||
- **通知优化**:
|
|
||||||
- 广告关键词过滤(内置 + 自定义)
|
|
||||||
- 持续性通知过滤(音乐、下载、通话等 ongoing)
|
|
||||||
- 内容脱敏与去重
|
|
||||||
- 微信 / QQ / Telegram 专项优化(发送者作标题、按会话分组、重要消息提级、跳转 iOS 对应 App)
|
|
||||||
- **应用级规则**:黑名单 / 白名单两种模式,可按 App 单独控制是否转发。
|
|
||||||
- **失败自动重试**:发送失败的通知进入待重发队列,由 WorkManager 定期重试,也可手动触发。
|
|
||||||
- **本地日志**:保留最近 300 条处理记录,方便排查为什么某条通知被过滤或发送失败。
|
|
||||||
- **电话来电通知**:通过轮询 `getCallState()` 实时感知来电和未接来电(绕过 MIUI 对系统电话通知的限制,需授予"电话"权限)。
|
|
||||||
- **断网短信兜底**:无网络时自动把验证码、来电用短信发到 iPhone。
|
|
||||||
- 自动识别运营商(移动/联通/电信/广电),发免费查询短信拿套餐内短信余量
|
|
||||||
- 余额 ≤5 条自动停用,下次打开 App 提醒
|
|
||||||
- 每 5 分钟最多发 1 条,避免刷屏
|
|
||||||
- 自动解析失败时回退手动额度计数
|
|
||||||
- **应用内更新**:每天自动检查 GitHub 新版本,发现新版后一键下载并通过系统安装器升级,无需手动去网页下载。
|
|
||||||
|
|
||||||
## 快速开始
|
## 一、五分钟上手
|
||||||
|
|
||||||
### 1. 安装
|
### 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)。
|
打开 GotMsg → 「设置」→ 选对应通道 → 点右上角 **i** 按钮 → 按弹窗步骤配 → 点该行「发送」测试。
|
||||||
2. 打开 Bark,复制你的专属推送地址,形如 `https://api.day.app/你的Key`。
|
|
||||||
3. 在 a2i 的「设置」中添加服务器:
|
|
||||||
- 名称:随意,例如「我的 iPhone」
|
|
||||||
- 服务器地址:`https://api.day.app`(自建则填自建地址)
|
|
||||||
- Device Key:Bark 地址中的那段 Key
|
|
||||||
|
|
||||||
### 3. 授权通知监听
|
### 3. 授权通知监听
|
||||||
|
首页会提示「需要授权通知监听」。点击「前往系统授权」,在系统的通知访问设置中开启 GotMsg。
|
||||||
打开 a2i,首页会提示「需要授权通知监听」。点击「前往系统授权」,在系统的通知访问设置中开启 a2i。
|
|
||||||
|
|
||||||
> 授权是一次性的,卸载后重新安装需要再次授权。
|
|
||||||
|
|
||||||
### 4. 开启转发
|
### 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 优化」。
|
- **想同步聊天**:保持黑名单模式(默认),在「过滤」页开启「微信 / QQ / Telegram 优化」。
|
||||||
- **通知没推过来**:先看「日志」页,每条通知(无论转发、过滤还是失败)都会记录原因。
|
- **通知没推过来**:先看「日志」页,每条通知(无论转发、过滤还是失败)都会记录原因。
|
||||||
- **想让通知带图标**:参考下方「应用图标」一节。
|
- **想让通知带图标**:见下方「应用图标」一节。
|
||||||
- **电话通知不工作**:MIUI 会把系统电话通知限制为不向第三方通知监听器分发,本 App 通过轮询 `getCallonyState()` 绕过此限制。需要授予「电话」权限(设置 → 应用 → a2i → 权限 → 电话)。
|
- **电话通知不工作**:MIUI 不向第三方监听器分发系统电话通知,GotMsg 通过轮询 `getCallState()` 绕过此限制。需授予「电话」权限(设置 → 应用 → GotMsg → 权限 → 电话)。
|
||||||
- **断网收不到通知**:开启「设置 → 断网短信兜底」,填好 iPhone 号码、授予短信权限、设好手动额度。断网时验证码和来电会走短信(消耗套餐额度,5 分钟限 1 条)。
|
- **断网收不到通知**:开启「设置 → 短信兜底」并填好 iPhone 号码、授予短信权限。断网时验证码和来电走短信(消耗套餐额度,5 分钟限 1 条)。
|
||||||
|
- **想全平台通知**:可同时启用多个通道(勾选每个通道的多个条目),所有启用的会同时收到。
|
||||||
|
|
||||||
## 应用图标
|
---
|
||||||
|
|
||||||
Bark 的 `icon` 字段需要一个公网可访问的图片 URL。a2i 支持把已安装 App 的图标批量导出到你选择的目录,方便上传到你自己的图床。
|
## 五、应用图标
|
||||||
|
|
||||||
|
Bark 和 ntfy 的通知 `icon` 字段需要公网可访问的图片 URL。GotMsg 支持把已安装 App 的图标批量导出,方便上传到你自己的图床。
|
||||||
|
|
||||||
规则:转发时 `icon = 图标 URL 前缀 + 安卓包名 + .png`。在「设置 → 应用图标」里:
|
规则:转发时 `icon = 图标 URL 前缀 + 安卓包名 + .png`。在「设置 → 应用图标」里:
|
||||||
|
|
||||||
1. 填写图标 URL 前缀,例如 `https://你的图床域名/icons/`。
|
1. 填写图标 URL 前缀,例如 `https://你的图床域名/icons/`。
|
||||||
2. 选择一个目录,点「导出图标」,会把所有 App 图标以 `包名.png` 的形式导出。
|
2. 选择一个目录,点「导出图标」,把该目录下所有 App 的图标以 `包名.png` 形式导出。
|
||||||
3. 把导出的图片上传到你的图床对应目录。
|
3. 把导出的图片上传到你的图床对应目录。
|
||||||
|
|
||||||
留空前缀则不显示图标。
|
留空前缀则不显示图标。
|
||||||
|
|
||||||
## 自建 Bark 服务(可选)
|
---
|
||||||
|
|
||||||
a2i 默认用 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)
|
- Kotlin + Jetpack Compose(Material 3)
|
||||||
- AndroidX DataStore(持久化设置与日志)
|
- AndroidX DataStore(持久化设置与日志)
|
||||||
- OkHttp(Bark 推送)
|
- OkHttp(Bark / ntfy / Meow / 电邮 / 更新检查)
|
||||||
- WorkManager(失败重试)
|
- WorkManager(失败重试)
|
||||||
- Navigation Compose(单 Activity 多页面)
|
- Navigation Compose(单 Activity 多页面)
|
||||||
- 最低 Android 14(API 34),目标 Android 16(API 36)
|
- 最低 Android 14(API 34),目标 Android 16(API 36)
|
||||||
|
|
||||||
## 本地构建
|
---
|
||||||
|
|
||||||
|
## 七、构建与安装
|
||||||
|
|
||||||
需要 JDK 17 和 Android SDK。
|
需要 JDK 17 和 Android SDK。
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# 设置环境变量(按你的安装路径)
|
# 设置环境变量
|
||||||
export JAVA_HOME=/path/to/jdk-17
|
export JAVA_HOME=/path/to/jdk-17
|
||||||
export ANDROID_HOME=/path/to/android-sdk
|
export ANDROID_HOME=/path/to/android-sdk
|
||||||
|
|
||||||
@@ -128,18 +214,18 @@ export ANDROID_HOME=/path/to/android-sdk
|
|||||||
./gradlew assembleRelease
|
./gradlew assembleRelease
|
||||||
```
|
```
|
||||||
|
|
||||||
产物位置:
|
产物:
|
||||||
|
|
||||||
- debug:`app/build/outputs/apk/debug/app-debug.apk`
|
- debug:`app/build/outputs/apk/debug/app-debug.apk`
|
||||||
- release:`app/build/outputs/apk/release/app-release.apk`
|
- release:`app/build/outputs/apk/release/app-release.apk`
|
||||||
|
|
||||||
安装到已连接的设备:
|
安装:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
adb install -r app/build/outputs/apk/release/app-release.apk
|
adb install -r app/build/outputs/apk/release/app-release.apk
|
||||||
```
|
```
|
||||||
|
|
||||||
## 界面截图
|
---
|
||||||
|
|
||||||
|
## 八、界面截图
|
||||||
|
|
||||||
| 首页 | 应用管理 | 过滤设置 |
|
| 首页 | 应用管理 | 过滤设置 |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
@@ -149,23 +235,29 @@ adb install -r app/build/outputs/apk/release/app-release.apk
|
|||||||
|---|---|
|
|---|---|
|
||||||
|  |  |
|
|  |  |
|
||||||
|
|
||||||
## 工作原理
|
---
|
||||||
|
|
||||||
|
## 九、工作原理
|
||||||
|
|
||||||
```
|
```
|
||||||
Android 系统通知
|
安卓系统通知
|
||||||
└─ NotifyListenerService 接收
|
└─ NotifyListenerService 接收
|
||||||
└─ NotificationProcessor 处理流水线
|
└─ NotificationProcessor 流水线
|
||||||
├─ 全局开关 / ongoing 过滤 / 黑白名单
|
├─ 全局开关 / ongoing 过滤 / 黑白名单
|
||||||
├─ 空内容、脱敏、噪音、系统状态过滤
|
├─ 空内容、脱敏、噪音、系统状态过滤
|
||||||
├─ 广告关键词过滤
|
├─ 广告关键词过滤
|
||||||
├─ 去重(5 秒窗口)
|
├─ 去重(5 秒窗口)
|
||||||
├─ 验证码提取
|
├─ 验证码提取
|
||||||
└─ 微信 / QQ / Telegram 专项解析
|
└─ 微信 / QQ / Telegram 专项解析
|
||||||
└─ BarkClient 推送到 Bark Server
|
└─ 按配置推送到 Bark / ntfy / Meow / 电邮 / 短信
|
||||||
└─ 失败 → 待重发队列 → RetryWorker 定期重试
|
└─ 失败 → 待重发队列 → RetryWorker 定期重试
|
||||||
└─ 写入本地日志(最近 300 条)
|
└─ 写入本地日志(最近 300 条)
|
||||||
```
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
本项目仅供个人使用。
|
本项目仅供个人使用。
|
||||||
|
|
||||||
|
All Rights Reserved. 保留所有权利。
|
||||||
|
|||||||
@@ -13,8 +13,8 @@ android {
|
|||||||
applicationId = "com.a2i.forwarder"
|
applicationId = "com.a2i.forwarder"
|
||||||
minSdk = 34
|
minSdk = 34
|
||||||
targetSdk = 36
|
targetSdk = 36
|
||||||
versionCode = 18
|
versionCode = 39
|
||||||
versionName = "1.8.1"
|
versionName = "1.10.0"
|
||||||
}
|
}
|
||||||
|
|
||||||
signingConfigs {
|
signingConfigs {
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import com.a2i.forwarder.core.ConnectivityMonitor
|
|||||||
import com.a2i.forwarder.core.LogStore
|
import com.a2i.forwarder.core.LogStore
|
||||||
import com.a2i.forwarder.core.PhoneCallMonitor
|
import com.a2i.forwarder.core.PhoneCallMonitor
|
||||||
import com.a2i.forwarder.core.SmsFallbackForwarder
|
import com.a2i.forwarder.core.SmsFallbackForwarder
|
||||||
|
import com.a2i.forwarder.core.Telemetry
|
||||||
import com.a2i.forwarder.core.UpdateChecker
|
import com.a2i.forwarder.core.UpdateChecker
|
||||||
import com.a2i.forwarder.store.AppRulesStore
|
import com.a2i.forwarder.store.AppRulesStore
|
||||||
import com.a2i.forwarder.store.SettingsStore
|
import com.a2i.forwarder.store.SettingsStore
|
||||||
@@ -33,6 +34,8 @@ class A2iApp : Application() {
|
|||||||
private set
|
private set
|
||||||
lateinit var updateChecker: UpdateChecker
|
lateinit var updateChecker: UpdateChecker
|
||||||
private set
|
private set
|
||||||
|
lateinit var telemetry: Telemetry
|
||||||
|
private set
|
||||||
|
|
||||||
override fun onCreate() {
|
override fun onCreate() {
|
||||||
super.onCreate()
|
super.onCreate()
|
||||||
@@ -48,6 +51,8 @@ class A2iApp : Application() {
|
|||||||
smsForwarder = SmsFallbackForwarder(this)
|
smsForwarder = SmsFallbackForwarder(this)
|
||||||
updateChecker = UpdateChecker(this)
|
updateChecker = UpdateChecker(this)
|
||||||
updateChecker.start()
|
updateChecker.start()
|
||||||
|
telemetry = Telemetry(this, appScope)
|
||||||
|
telemetry.start()
|
||||||
}
|
}
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ class CarrierBalanceQuery(private val context: Context) {
|
|||||||
|
|
||||||
/** 转发成功后扣减余额(手动模式 / 自动解析失败时)。 */
|
/** 转发成功后扣减余额(手动模式 / 自动解析失败时)。 */
|
||||||
suspend fun decrementManual() {
|
suspend fun decrementManual() {
|
||||||
|
if (!settings.smsBalanceCheck.value) return // 余额检查关闭时不递减
|
||||||
val q = settings.smsManualQuota.value
|
val q = settings.smsManualQuota.value
|
||||||
if (q > 0) {
|
if (q > 0) {
|
||||||
val newQ = q - 1
|
val newQ = q - 1
|
||||||
@@ -66,6 +67,7 @@ class CarrierBalanceQuery(private val context: Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun checkSuspend(balance: Int) {
|
private suspend fun checkSuspend(balance: Int) {
|
||||||
|
if (!settings.smsBalanceCheck.value) return // 余额检查关闭时不挂起
|
||||||
if (balance in 0..5 && !settings.smsSuspended.value) {
|
if (balance in 0..5 && !settings.smsSuspended.value) {
|
||||||
settings.setSmsSuspended(true)
|
settings.setSmsSuspended(true)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -120,7 +120,6 @@ object EmailSender {
|
|||||||
/** 如果电邮转发已启用且收件人非空,异步发邮件(fire-and-forget,不阻塞主流程)。 */
|
/** 如果电邮转发已启用且收件人非空,异步发邮件(fire-and-forget,不阻塞主流程)。 */
|
||||||
fun trySend(app: A2iApp, title: String, body: String, scope: CoroutineScope) {
|
fun trySend(app: A2iApp, title: String, body: String, scope: CoroutineScope) {
|
||||||
val s = app.settings
|
val s = app.settings
|
||||||
if (!s.emailForwardingEnabled.value) return
|
|
||||||
val configs = s.emailServers.value.filter { it.enabled && it.to.isNotBlank() && it.host.isNotBlank() }
|
val configs = s.emailServers.value.filter { it.enabled && it.to.isNotBlank() && it.host.isNotBlank() }
|
||||||
if (configs.isEmpty()) return
|
if (configs.isEmpty()) return
|
||||||
scope.launch {
|
scope.launch {
|
||||||
|
|||||||
@@ -0,0 +1,145 @@
|
|||||||
|
package com.a2i.forwarder.core
|
||||||
|
|
||||||
|
import com.a2i.forwarder.A2iApp
|
||||||
|
import com.a2i.forwarder.model.MeowServer
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
|
||||||
|
import okhttp3.MediaType.Companion.toMediaType
|
||||||
|
import okhttp3.OkHttpClient
|
||||||
|
import okhttp3.Request
|
||||||
|
import okhttp3.RequestBody.Companion.toRequestBody
|
||||||
|
import org.json.JSONObject
|
||||||
|
import java.io.IOException
|
||||||
|
import java.util.concurrent.TimeUnit
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Meow for HarmonyOS push channel.
|
||||||
|
*
|
||||||
|
* Meow's public API shape is less standardized than Bark/ntfy in this app,
|
||||||
|
* so this sender accepts a user-provided endpoint and tries common JSON/GET forms.
|
||||||
|
*/
|
||||||
|
object MeowSender {
|
||||||
|
private val http = OkHttpClient.Builder()
|
||||||
|
.connectTimeout(10, TimeUnit.SECONDS)
|
||||||
|
.writeTimeout(10, TimeUnit.SECONDS)
|
||||||
|
.readTimeout(15, TimeUnit.SECONDS)
|
||||||
|
.retryOnConnectionFailure(true)
|
||||||
|
.build()
|
||||||
|
|
||||||
|
suspend fun send(
|
||||||
|
server: String,
|
||||||
|
deviceKey: String,
|
||||||
|
title: String,
|
||||||
|
body: String,
|
||||||
|
): Result<Unit> = withContext(Dispatchers.IO) {
|
||||||
|
runCatching {
|
||||||
|
val endpoint = server.trim().removeSuffix("/")
|
||||||
|
val key = deviceKey.trim()
|
||||||
|
require(endpoint.isNotBlank()) { "Meow 服务器地址未填" }
|
||||||
|
require(key.isNotBlank()) { "Meow Device Key 未填" }
|
||||||
|
|
||||||
|
val attempts = buildRequests(endpoint, key, title.take(200), body.take(4000))
|
||||||
|
var last: Throwable? = null
|
||||||
|
for (req in attempts) {
|
||||||
|
val r = runCatching { doPushWithRetry(req) }
|
||||||
|
if (r.isSuccess) return@runCatching
|
||||||
|
last = r.exceptionOrNull()
|
||||||
|
}
|
||||||
|
throw last ?: error("Meow 推送失败")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun pushToEnabled(configs: List<MeowServer>, title: String, body: String, stopOnFirst: Boolean): Result<Unit> = withContext(Dispatchers.IO) {
|
||||||
|
runCatching {
|
||||||
|
require(configs.any()) { "无 Meow 配置" }
|
||||||
|
var anyOk = false
|
||||||
|
var lastErr: Throwable? = null
|
||||||
|
for (c in configs) {
|
||||||
|
val r = send(c.server, c.deviceKey, title, body)
|
||||||
|
if (r.isSuccess) {
|
||||||
|
anyOk = true
|
||||||
|
if (stopOnFirst) return@runCatching
|
||||||
|
} else {
|
||||||
|
lastErr = r.exceptionOrNull()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!anyOk) throw lastErr ?: error("全部 Meow 推送失败")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun trySend(app: A2iApp, title: String, body: String, scope: CoroutineScope) {
|
||||||
|
val s = app.settings
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun buildRequests(endpoint: String, key: String, title: String, body: String): List<Request> {
|
||||||
|
val payload = JSONObject()
|
||||||
|
.put("deviceKey", key)
|
||||||
|
.put("device_key", key)
|
||||||
|
.put("key", key)
|
||||||
|
.put("title", title)
|
||||||
|
.put("content", body)
|
||||||
|
.put("message", body)
|
||||||
|
.put("body", body)
|
||||||
|
.toString()
|
||||||
|
|
||||||
|
val postTargets = listOf(endpoint, "$endpoint/push").distinct()
|
||||||
|
val postRequests = postTargets.map { url ->
|
||||||
|
Request.Builder()
|
||||||
|
.url(url)
|
||||||
|
.header("Authorization", "Bearer $key")
|
||||||
|
.header("X-Device-Key", key)
|
||||||
|
.post(payload.toRequestBody(JSON))
|
||||||
|
.build()
|
||||||
|
}
|
||||||
|
|
||||||
|
val getRequest = endpoint.toHttpUrlOrNull()?.newBuilder()
|
||||||
|
?.addQueryParameter("deviceKey", key)
|
||||||
|
?.addQueryParameter("key", key)
|
||||||
|
?.addQueryParameter("title", title)
|
||||||
|
?.addQueryParameter("content", body)
|
||||||
|
?.addQueryParameter("message", body)
|
||||||
|
?.build()
|
||||||
|
?.let { url ->
|
||||||
|
Request.Builder()
|
||||||
|
.url(url)
|
||||||
|
.header("Authorization", "Bearer $key")
|
||||||
|
.header("X-Device-Key", key)
|
||||||
|
.get()
|
||||||
|
.build()
|
||||||
|
}
|
||||||
|
|
||||||
|
return postRequests + listOfNotNull(getRequest)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun doPushWithRetry(req: Request) {
|
||||||
|
var lastError: IOException? = null
|
||||||
|
repeat(MAX_ATTEMPTS) { attempt ->
|
||||||
|
try {
|
||||||
|
http.newCall(req).execute().use { res ->
|
||||||
|
if (res.isSuccessful) return
|
||||||
|
error("HTTP ${res.code}")
|
||||||
|
}
|
||||||
|
} catch (e: IOException) {
|
||||||
|
lastError = e
|
||||||
|
if (attempt < MAX_ATTEMPTS - 1) {
|
||||||
|
Thread.sleep(BACKOFF_MS[attempt])
|
||||||
|
}
|
||||||
|
} catch (e: IllegalStateException) {
|
||||||
|
throw e
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw lastError ?: error("Meow 推送失败")
|
||||||
|
}
|
||||||
|
|
||||||
|
private val JSON = "application/json; charset=utf-8".toMediaType()
|
||||||
|
private const val MAX_ATTEMPTS = 3
|
||||||
|
private val BACKOFF_MS = longArrayOf(600L, 1200L)
|
||||||
|
}
|
||||||
@@ -89,7 +89,6 @@ object NtfySender {
|
|||||||
|
|
||||||
fun trySend(app: A2iApp, title: String, body: String, scope: CoroutineScope) {
|
fun trySend(app: A2iApp, title: String, body: String, scope: CoroutineScope) {
|
||||||
val s = app.settings
|
val s = app.settings
|
||||||
if (!s.ntfyEnabled.value) return
|
|
||||||
val configs = s.ntfyServers.value.filter { it.enabled && it.topic.isNotBlank() }
|
val configs = s.ntfyServers.value.filter { it.enabled && it.topic.isNotBlank() }
|
||||||
if (configs.isEmpty()) return
|
if (configs.isEmpty()) return
|
||||||
scope.launch {
|
scope.launch {
|
||||||
|
|||||||
@@ -127,6 +127,7 @@ class PhoneCallMonitor(private val context: Context) {
|
|||||||
if (servers.isEmpty()) {
|
if (servers.isEmpty()) {
|
||||||
EmailSender.trySend(app, msg.title ?: logBody, msg.body ?: "", scope)
|
EmailSender.trySend(app, msg.title ?: logBody, msg.body ?: "", scope)
|
||||||
NtfySender.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
|
return@launch
|
||||||
}
|
}
|
||||||
val result = BarkSender.pushToEnabled(servers, msg, app.settings.barkStopOnFirst.value)
|
val result = BarkSender.pushToEnabled(servers, msg, app.settings.barkStopOnFirst.value)
|
||||||
@@ -146,6 +147,7 @@ class PhoneCallMonitor(private val context: Context) {
|
|||||||
}
|
}
|
||||||
EmailSender.trySend(app, msg.title ?: logBody, msg.body ?: "", scope)
|
EmailSender.trySend(app, msg.title ?: logBody, msg.body ?: "", scope)
|
||||||
NtfySender.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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,21 +13,21 @@ import kotlinx.coroutines.SupervisorJob
|
|||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 短信兜底转发器:断网时把紧要通知(验证码、来电)用短信发到 iPhone。
|
* 短信兜底转发器:断网时把通知用短信发到目标手机。
|
||||||
* 限频:每 5 分钟最多 1 条转发短信。余额 ≤5 时自动挂起。
|
* 用户可配置:内容过滤(仅紧要/全部)、限频(开关+分钟)、余额检查(开关)。
|
||||||
*/
|
*/
|
||||||
class SmsFallbackForwarder(private val context: Context) {
|
class SmsFallbackForwarder(private val context: Context) {
|
||||||
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
||||||
private val RATE_LIMIT_MS = 5 * 60 * 1000L
|
|
||||||
|
|
||||||
private val app get() = A2iApp.instance
|
private val app get() = A2iApp.instance
|
||||||
private val settings get() = app.settings
|
private val settings get() = app.settings
|
||||||
|
|
||||||
/** 判断该通知是否值得走短信(断网资源宝贵)。 */
|
/** 判断该通知是否值得走短信(受用户设置控制)。 */
|
||||||
fun isSmsWorthy(d: Decision): Boolean {
|
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
|
val msg = d.message ?: return false
|
||||||
if (msg.group == "phone_call") return true // 来电/未接来电
|
if (msg.group == "phone_call") return true // 来电/未接来电
|
||||||
val t = msg.title.orEmpty()
|
val t = msg.title.orEmpty()
|
||||||
return t.contains("来电") || t.contains("未接")
|
return t.contains("来电") || t.contains("未接")
|
||||||
}
|
}
|
||||||
@@ -35,25 +35,31 @@ class SmsFallbackForwarder(private val context: Context) {
|
|||||||
/** 尝试用短信转发(遍历所有启用的目标号,按 stopOnFirst 决定发通一条止还是全发)。 */
|
/** 尝试用短信转发(遍历所有启用的目标号,按 stopOnFirst 决定发通一条止还是全发)。 */
|
||||||
fun forward(d: Decision) {
|
fun forward(d: Decision) {
|
||||||
scope.launch {
|
scope.launch {
|
||||||
if (!settings.smsFallbackEnabled.value) return@launch
|
val s = settings
|
||||||
if (settings.smsSuspended.value) {
|
// ① 余额检查(用户可关)
|
||||||
|
if (s.smsBalanceCheck.value && s.smsSuspended.value) {
|
||||||
log(d, "failed", "短信已挂起(余额不足)")
|
log(d, "failed", "短信已挂起(余额不足)")
|
||||||
return@launch
|
return@launch
|
||||||
}
|
}
|
||||||
|
// ② 内容过滤(用户可关,关=全部转发)
|
||||||
if (!isSmsWorthy(d)) 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()) {
|
if (targets.isEmpty()) {
|
||||||
log(d, "failed", "未设置短信目标号")
|
log(d, "failed", "未设置短信目标号")
|
||||||
return@launch
|
return@launch
|
||||||
}
|
}
|
||||||
// 限频
|
// ④ 限频(用户可关)
|
||||||
val now = System.currentTimeMillis()
|
val now = System.currentTimeMillis()
|
||||||
val elapsed = now - settings.lastSmsSentTime.value
|
if (s.smsRateLimitEnabled.value) {
|
||||||
if (elapsed < RATE_LIMIT_MS) {
|
val limitMs = s.smsRateLimitMin.value.coerceIn(1, 60) * 60 * 1000L
|
||||||
log(d, "filtered", "限频(${RATE_LIMIT_MS - elapsed}ms 内)")
|
val elapsed = now - s.lastSmsSentTime.value
|
||||||
return@launch
|
if (elapsed < limitMs) {
|
||||||
|
log(d, "filtered", "限频(${limitMs - elapsed}ms 内)")
|
||||||
|
return@launch
|
||||||
|
}
|
||||||
}
|
}
|
||||||
// 权限
|
// ⑤ 权限(始终检查)
|
||||||
if (ContextCompat.checkSelfPermission(context, Manifest.permission.SEND_SMS)
|
if (ContextCompat.checkSelfPermission(context, Manifest.permission.SEND_SMS)
|
||||||
!= PackageManager.PERMISSION_GRANTED
|
!= PackageManager.PERMISSION_GRANTED
|
||||||
) {
|
) {
|
||||||
@@ -61,8 +67,9 @@ class SmsFallbackForwarder(private val context: Context) {
|
|||||||
return@launch
|
return@launch
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ⑥ 发送
|
||||||
val body = compose(d).take(70)
|
val body = compose(d).take(70)
|
||||||
val stopOnFirst = settings.smsStopOnFirst.value
|
val stopOnFirst = s.smsStopOnFirst.value
|
||||||
var anyOk = false
|
var anyOk = false
|
||||||
var sentCount = 0
|
var sentCount = 0
|
||||||
var lastReason = "短信发送失败"
|
var lastReason = "短信发送失败"
|
||||||
@@ -79,14 +86,16 @@ class SmsFallbackForwarder(private val context: Context) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (anyOk) {
|
if (anyOk) {
|
||||||
settings.setLastSmsSentTime(now)
|
s.setLastSmsSentTime(now)
|
||||||
log(d, "sent", "")
|
log(d, "sent", "")
|
||||||
// 余额递减:按成功发送条数;自动模式发查询短信,手动模式递减
|
// 余额递减(仅余额检查开启时)
|
||||||
val carrier = app.carrierBalanceQuery.currentCarrier()
|
if (s.smsBalanceCheck.value) {
|
||||||
if (carrier.serviceNumber != null && carrier.queryCmd != null) {
|
val carrier = app.carrierBalanceQuery.currentCarrier()
|
||||||
app.carrierBalanceQuery.refresh()
|
if (carrier.serviceNumber != null && carrier.queryCmd != null) {
|
||||||
} else {
|
app.carrierBalanceQuery.refresh()
|
||||||
repeat(sentCount) { runCatching { app.carrierBalanceQuery.decrementManual() } }
|
} else {
|
||||||
|
repeat(sentCount) { runCatching { app.carrierBalanceQuery.decrementManual() } }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
log(d, "failed", lastReason)
|
log(d, "failed", lastReason)
|
||||||
|
|||||||
@@ -0,0 +1,140 @@
|
|||||||
|
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")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun send(event: String): Result<Unit> = withContext(Dispatchers.IO) {
|
||||||
|
runCatching {
|
||||||
|
val payload = basePayload(event).toString().toRequestBody(JSON)
|
||||||
|
val request = Request.Builder()
|
||||||
|
.url(EVENTS_URL)
|
||||||
|
.header("User-Agent", userAgent())
|
||||||
|
.post(payload)
|
||||||
|
.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()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -26,6 +26,7 @@ data class UpdateInfo(
|
|||||||
val apkSize: Long,
|
val apkSize: Long,
|
||||||
val releaseNotes: String,
|
val releaseNotes: String,
|
||||||
val releasePageUrl: String,
|
val releasePageUrl: String,
|
||||||
|
val fallbackUrl: String? = null, // 主 URL 下载失败时尝试的备选链接
|
||||||
)
|
)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -44,6 +45,7 @@ class UpdateChecker(private val context: Context) {
|
|||||||
val updateInfo = MutableStateFlow<UpdateInfo?>(null)
|
val updateInfo = MutableStateFlow<UpdateInfo?>(null)
|
||||||
val downloading = MutableStateFlow(false)
|
val downloading = MutableStateFlow(false)
|
||||||
val downloadProgress = MutableStateFlow(0)
|
val downloadProgress = MutableStateFlow(0)
|
||||||
|
val downloadSource = MutableStateFlow<String?>(null) // "Gitea" / "GitHub" / null
|
||||||
val lastError = MutableStateFlow<String?>(null)
|
val lastError = MutableStateFlow<String?>(null)
|
||||||
private var lastNotifiedVersion: String? = null // 记录已推送的版本,避免重复通知
|
private var lastNotifiedVersion: String? = null // 记录已推送的版本,避免重复通知
|
||||||
|
|
||||||
@@ -68,15 +70,15 @@ class UpdateChecker(private val context: Context) {
|
|||||||
val now = System.currentTimeMillis()
|
val now = System.currentTimeMillis()
|
||||||
if (throttle && now - settings.lastUpdateCheckTime.value < CHECK_INTERVAL_MS) return@runCatching updateInfo.value
|
if (throttle && now - settings.lastUpdateCheckTime.value < CHECK_INTERVAL_MS) return@runCatching updateInfo.value
|
||||||
|
|
||||||
// 先查 GitHub(公开默认源),从其 release notes 动态发现额外源(如自建 Gitea),取最高版本
|
// 双源查询:Gitea(国内优先)+ GitHub(备用),取最高版本
|
||||||
|
// 同版本时 Gitea 优先(国内可达,GitHub 可能被墙导致下载失败)
|
||||||
val infos = mutableListOf<UpdateInfo>()
|
val infos = mutableListOf<UpdateInfo>()
|
||||||
val gh = runCatching { fetchRelease(GITHUB_API) }.getOrNull()
|
var ghInfo: UpdateInfo? = null
|
||||||
if (gh != null) {
|
var giteaInfo: UpdateInfo? = null
|
||||||
infos.add(gh)
|
giteaInfo = runCatching { fetchRelease(GITEA_API) }.getOrNull()
|
||||||
parseUpdateSource(gh.releaseNotes)?.let { src ->
|
if (giteaInfo != null) infos.add(giteaInfo)
|
||||||
runCatching { fetchRelease(src) }.getOrNull()?.let { infos.add(it) }
|
ghInfo = runCatching { fetchRelease(GITHUB_API) }.getOrNull()
|
||||||
}
|
if (ghInfo != null) infos.add(ghInfo)
|
||||||
}
|
|
||||||
settings.setLastUpdateCheck(now)
|
settings.setLastUpdateCheck(now)
|
||||||
if (infos.isEmpty()) {
|
if (infos.isEmpty()) {
|
||||||
lastError.value = "所有更新源不可达"
|
lastError.value = "所有更新源不可达"
|
||||||
@@ -91,7 +93,9 @@ class UpdateChecker(private val context: Context) {
|
|||||||
updateInfo.value = null
|
updateInfo.value = null
|
||||||
lastNotifiedVersion = null
|
lastNotifiedVersion = null
|
||||||
} else {
|
} else {
|
||||||
updateInfo.value = best
|
// 主 URL 下载失败时尝试另一个源的链接
|
||||||
|
val fallback = if (best == giteaInfo) ghInfo?.apkUrl else giteaInfo?.apkUrl
|
||||||
|
updateInfo.value = best.copy(fallbackUrl = fallback)
|
||||||
}
|
}
|
||||||
lastError.value = null
|
lastError.value = null
|
||||||
|
|
||||||
@@ -108,7 +112,7 @@ class UpdateChecker(private val context: Context) {
|
|||||||
val req = Request.Builder()
|
val req = Request.Builder()
|
||||||
.url(api)
|
.url(api)
|
||||||
.header("Accept", "application/json")
|
.header("Accept", "application/json")
|
||||||
.header("User-Agent", "a2i-android")
|
.header("User-Agent", "gotmsg-android")
|
||||||
.get()
|
.get()
|
||||||
.build()
|
.build()
|
||||||
return http.newCall(req).execute().use { res ->
|
return http.newCall(req).execute().use { res ->
|
||||||
@@ -123,7 +127,7 @@ class UpdateChecker(private val context: Context) {
|
|||||||
/** 发现新版后,自动通过所有已开启的推送通道通知用户 */
|
/** 发现新版后,自动通过所有已开启的推送通道通知用户 */
|
||||||
private fun notifyNewVersion(prev: UpdateInfo?, info: UpdateInfo) {
|
private fun notifyNewVersion(prev: UpdateInfo?, info: UpdateInfo) {
|
||||||
val s = app.settings
|
val s = app.settings
|
||||||
val title = "🆕 a2i 新版本 v${info.latestVersion}"
|
val title = "🆕 GotMsg 新版本 v${info.latestVersion}"
|
||||||
val body = "发现 v${info.latestVersion}(当前 v${currentVersionName()})\n${formatSize(info.apkSize)} · 点击更新\n\n${info.releaseNotes.take(300)}"
|
val body = "发现 v${info.latestVersion}(当前 v${currentVersionName()})\n${formatSize(info.apkSize)} · 点击更新\n\n${info.releaseNotes.take(300)}"
|
||||||
val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
||||||
|
|
||||||
@@ -142,6 +146,9 @@ class UpdateChecker(private val context: Context) {
|
|||||||
// ntfy
|
// ntfy
|
||||||
NtfySender.trySend(app, title, body, scope)
|
NtfySender.trySend(app, title, body, scope)
|
||||||
|
|
||||||
|
// Meow
|
||||||
|
MeowSender.trySend(app, title, body, scope)
|
||||||
|
|
||||||
// 电邮
|
// 电邮
|
||||||
EmailSender.trySend(app, title, body, scope)
|
EmailSender.trySend(app, title, body, scope)
|
||||||
}
|
}
|
||||||
@@ -174,18 +181,49 @@ class UpdateChecker(private val context: Context) {
|
|||||||
suspend fun download(info: UpdateInfo): File? = withContext(Dispatchers.IO) {
|
suspend fun download(info: UpdateInfo): File? = withContext(Dispatchers.IO) {
|
||||||
downloading.value = true
|
downloading.value = true
|
||||||
downloadProgress.value = 0
|
downloadProgress.value = 0
|
||||||
|
downloadSource.value = null
|
||||||
lastError.value = null
|
lastError.value = null
|
||||||
try {
|
// 主 URL (Gitea) 失败时自动尝试备选 URL (GitHub)
|
||||||
val req = Request.Builder().url(info.apkUrl).get().build()
|
val sources = listOf(
|
||||||
http.newCall(req).execute().use { res ->
|
info.apkUrl to sourceLabel(info.apkUrl),
|
||||||
if (!res.isSuccessful) { lastError.value = "HTTP ${res.code}"; return@use null }
|
) + (info.fallbackUrl?.let { listOf(it to sourceLabel(it)) } ?: emptyList())
|
||||||
val body = res.body ?: run { lastError.value = "空响应"; return@use null }
|
for ((idx, pair) in sources.withIndex()) {
|
||||||
|
val (url, label) = pair
|
||||||
|
if (idx > 0) { downloadProgress.value = 0; downloadSource.value = null }
|
||||||
|
downloadSource.value = label
|
||||||
|
val file = tryDownload(url, info.latestVersion, info.apkSize)
|
||||||
|
if (file != null) {
|
||||||
|
downloading.value = false
|
||||||
|
downloadSource.value = null
|
||||||
|
return@withContext file
|
||||||
|
}
|
||||||
|
}
|
||||||
|
downloading.value = false
|
||||||
|
downloadSource.value = null
|
||||||
|
null
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun sourceLabel(url: String): String =
|
||||||
|
if (url.contains("songer.eu.org") || url.contains("sunlunfan")) "Gitea" else "GitHub"
|
||||||
|
|
||||||
|
private suspend fun tryDownload(url: String, version: String, expectedSize: Long): File? {
|
||||||
|
// 专用下载 client:更长超时(120s),避免大文件慢网超时截断
|
||||||
|
val dlClient = OkHttpClient.Builder()
|
||||||
|
.connectTimeout(15, TimeUnit.SECONDS)
|
||||||
|
.readTimeout(120, TimeUnit.SECONDS)
|
||||||
|
.retryOnConnectionFailure(true)
|
||||||
|
.build()
|
||||||
|
return try {
|
||||||
|
val req = Request.Builder().url(url).get().build()
|
||||||
|
dlClient.newCall(req).execute().use { res ->
|
||||||
|
if (!res.isSuccessful) { lastError.value = "HTTP ${res.code}"; return null }
|
||||||
|
val body = res.body ?: run { lastError.value = "空响应"; return null }
|
||||||
val total = body.contentLength()
|
val total = body.contentLength()
|
||||||
val dir = context.getExternalFilesDir(null) ?: context.cacheDir
|
val dir = context.getExternalFilesDir(null) ?: context.cacheDir
|
||||||
val target = File(dir, "a2i-${info.latestVersion}.apk")
|
val target = File(dir, "gotmsg-${version}.apk")
|
||||||
target.outputStream().use { sink ->
|
target.outputStream().use { sink ->
|
||||||
val input = body.byteStream()
|
val input = body.byteStream()
|
||||||
val buf = ByteArray(8 * 1024)
|
val buf = ByteArray(64 * 1024) // 64KB buffer 提升大文件速度
|
||||||
var read = 0L
|
var read = 0L
|
||||||
while (true) {
|
while (true) {
|
||||||
val n = input.read(buf)
|
val n = input.read(buf)
|
||||||
@@ -195,14 +233,25 @@ class UpdateChecker(private val context: Context) {
|
|||||||
if (total > 0) downloadProgress.value = (read * 100 / total).toInt()
|
if (total > 0) downloadProgress.value = (read * 100 / total).toInt()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// 校验文件大小:Content-Length 或 release 声明的大小
|
||||||
|
val actualSize = target.length()
|
||||||
|
val refSize = if (total > 0) total else expectedSize
|
||||||
|
if (refSize > 0 && actualSize < refSize) {
|
||||||
|
lastError.value = "文件不完整(${actualSize}/${refSize} 字节),请重试"
|
||||||
|
target.delete()
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
if (actualSize < 1_000_000) {
|
||||||
|
lastError.value = "文件过小,可能下载了错误页面"
|
||||||
|
target.delete()
|
||||||
|
return null
|
||||||
|
}
|
||||||
downloadProgress.value = 100
|
downloadProgress.value = 100
|
||||||
target
|
target
|
||||||
}
|
}
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
lastError.value = e.message ?: "下载失败"
|
lastError.value = e.message ?: "下载失败"
|
||||||
null
|
null
|
||||||
} finally {
|
|
||||||
downloading.value = false
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -250,12 +299,9 @@ class UpdateChecker(private val context: Context) {
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 从 release notes 的 HTML 注释里解析额外更新源(如自建 Gitea)。 */
|
|
||||||
private fun parseUpdateSource(body: String): String? =
|
|
||||||
Regex("""update-source:\s*(\S+)""").find(body)?.groupValues?.getOrNull(1)
|
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
const val GITHUB_API = "https://api.github.com/repos/lsxf/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
|
const val CHECK_INTERVAL_MS = 24L * 60 * 60 * 1000
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -24,6 +24,15 @@ data class NtfyServer(
|
|||||||
val enabled: Boolean = true,
|
val enabled: Boolean = true,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class MeowServer(
|
||||||
|
val id: String = UUID.randomUUID().toString(),
|
||||||
|
val name: String,
|
||||||
|
val server: String = "",
|
||||||
|
val deviceKey: String = "",
|
||||||
|
val enabled: Boolean = true,
|
||||||
|
)
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
data class EmailServer(
|
data class EmailServer(
|
||||||
val id: String = UUID.randomUUID().toString(),
|
val id: String = UUID.randomUUID().toString(),
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import com.a2i.forwarder.A2iApp
|
|||||||
import com.a2i.forwarder.core.BarkSender
|
import com.a2i.forwarder.core.BarkSender
|
||||||
import com.a2i.forwarder.core.EmailSender
|
import com.a2i.forwarder.core.EmailSender
|
||||||
import com.a2i.forwarder.core.ForwardLog
|
import com.a2i.forwarder.core.ForwardLog
|
||||||
|
import com.a2i.forwarder.core.MeowSender
|
||||||
import com.a2i.forwarder.core.NotificationProcessor
|
import com.a2i.forwarder.core.NotificationProcessor
|
||||||
import com.a2i.forwarder.core.NtfySender
|
import com.a2i.forwarder.core.NtfySender
|
||||||
import com.a2i.forwarder.core.PendingPush
|
import com.a2i.forwarder.core.PendingPush
|
||||||
@@ -65,6 +66,7 @@ class NotifyListenerService : NotificationListenerService() {
|
|||||||
app.logStore.addLog(ForwardLog(now, sbn.packageName, d.appLabel, msg.title, msg.body, "failed", "未配置可用 Bark 服务器"))
|
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)
|
EmailSender.trySend(app, msg.title ?: d.appLabel, msg.body, scope)
|
||||||
NtfySender.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)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -80,5 +82,6 @@ class NotifyListenerService : NotificationListenerService() {
|
|||||||
}
|
}
|
||||||
EmailSender.trySend(app, msg.title ?: d.appLabel, msg.body, scope)
|
EmailSender.trySend(app, msg.title ?: d.appLabel, msg.body, scope)
|
||||||
NtfySender.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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import androidx.datastore.preferences.core.stringPreferencesKey
|
|||||||
import androidx.datastore.preferences.preferencesDataStore
|
import androidx.datastore.preferences.preferencesDataStore
|
||||||
import com.a2i.forwarder.model.BarkServer
|
import com.a2i.forwarder.model.BarkServer
|
||||||
import com.a2i.forwarder.model.EmailServer
|
import com.a2i.forwarder.model.EmailServer
|
||||||
|
import com.a2i.forwarder.model.MeowServer
|
||||||
import com.a2i.forwarder.model.NtfyServer
|
import com.a2i.forwarder.model.NtfyServer
|
||||||
import com.a2i.forwarder.model.OFFICIAL_BARK_SERVER
|
import com.a2i.forwarder.model.OFFICIAL_BARK_SERVER
|
||||||
import com.a2i.forwarder.model.SmsTarget
|
import com.a2i.forwarder.model.SmsTarget
|
||||||
@@ -44,6 +45,7 @@ class SettingsStore(
|
|||||||
// 各通道「发通一条止 / 全发」开关
|
// 各通道「发通一条止 / 全发」开关
|
||||||
val BARK_STOP_FIRST = booleanPreferencesKey("bark_stop_first")
|
val BARK_STOP_FIRST = booleanPreferencesKey("bark_stop_first")
|
||||||
val NTFY_STOP_FIRST = booleanPreferencesKey("ntfy_stop_first")
|
val NTFY_STOP_FIRST = booleanPreferencesKey("ntfy_stop_first")
|
||||||
|
val MEOW_STOP_FIRST = booleanPreferencesKey("meow_stop_first")
|
||||||
val EMAIL_STOP_FIRST = booleanPreferencesKey("email_stop_first")
|
val EMAIL_STOP_FIRST = booleanPreferencesKey("email_stop_first")
|
||||||
val SMS_STOP_FIRST = booleanPreferencesKey("sms_stop_first")
|
val SMS_STOP_FIRST = booleanPreferencesKey("sms_stop_first")
|
||||||
// 短信兜底(目标号已列表化;运营商/余额/限频保持全局)
|
// 短信兜底(目标号已列表化;运营商/余额/限频保持全局)
|
||||||
@@ -54,12 +56,15 @@ class SettingsStore(
|
|||||||
val SMS_BALANCE = intPreferencesKey("sms_balance")
|
val SMS_BALANCE = intPreferencesKey("sms_balance")
|
||||||
val SMS_SUSPENDED = booleanPreferencesKey("sms_suspended")
|
val SMS_SUSPENDED = booleanPreferencesKey("sms_suspended")
|
||||||
val SMS_LAST_SENT = longPreferencesKey("sms_last_sent")
|
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_ENABLED = booleanPreferencesKey("email_enabled")
|
||||||
val EMAIL_SERVERS = stringPreferencesKey("email_servers")
|
val EMAIL_SERVERS = stringPreferencesKey("email_servers")
|
||||||
// ntfy 转发
|
|
||||||
val NTFY_ENABLED = booleanPreferencesKey("ntfy_enabled")
|
|
||||||
val NTFY_SERVERS = stringPreferencesKey("ntfy_servers")
|
val NTFY_SERVERS = stringPreferencesKey("ntfy_servers")
|
||||||
|
val MEOW_SERVERS = stringPreferencesKey("meow_servers")
|
||||||
// 应用更新
|
// 应用更新
|
||||||
val UPDATE_CHECK_AUTO = booleanPreferencesKey("update_check_auto")
|
val UPDATE_CHECK_AUTO = booleanPreferencesKey("update_check_auto")
|
||||||
val LAST_UPDATE_CHECK = longPreferencesKey("last_update_check")
|
val LAST_UPDATE_CHECK = longPreferencesKey("last_update_check")
|
||||||
@@ -89,6 +94,9 @@ class SettingsStore(
|
|||||||
private val _ntfyServers = MutableStateFlow<List<NtfyServer>>(emptyList())
|
private val _ntfyServers = MutableStateFlow<List<NtfyServer>>(emptyList())
|
||||||
val ntfyServers = _ntfyServers.asStateFlow()
|
val ntfyServers = _ntfyServers.asStateFlow()
|
||||||
|
|
||||||
|
private val _meowServers = MutableStateFlow<List<MeowServer>>(emptyList())
|
||||||
|
val meowServers = _meowServers.asStateFlow()
|
||||||
|
|
||||||
private val _emailServers = MutableStateFlow<List<EmailServer>>(emptyList())
|
private val _emailServers = MutableStateFlow<List<EmailServer>>(emptyList())
|
||||||
val emailServers = _emailServers.asStateFlow()
|
val emailServers = _emailServers.asStateFlow()
|
||||||
|
|
||||||
@@ -106,6 +114,7 @@ class SettingsStore(
|
|||||||
// 各通道「发通一条止 / 全发」
|
// 各通道「发通一条止 / 全发」
|
||||||
val barkStopOnFirst = MutableStateFlow(true)
|
val barkStopOnFirst = MutableStateFlow(true)
|
||||||
val ntfyStopOnFirst = MutableStateFlow(false)
|
val ntfyStopOnFirst = MutableStateFlow(false)
|
||||||
|
val meowStopOnFirst = MutableStateFlow(false)
|
||||||
val emailStopOnFirst = MutableStateFlow(false)
|
val emailStopOnFirst = MutableStateFlow(false)
|
||||||
val smsStopOnFirst = MutableStateFlow(false)
|
val smsStopOnFirst = MutableStateFlow(false)
|
||||||
|
|
||||||
@@ -116,10 +125,13 @@ class SettingsStore(
|
|||||||
val smsBalance = MutableStateFlow(-1)
|
val smsBalance = MutableStateFlow(-1)
|
||||||
val smsSuspended = MutableStateFlow(false)
|
val smsSuspended = MutableStateFlow(false)
|
||||||
val lastSmsSentTime = MutableStateFlow(0L)
|
val lastSmsSentTime = MutableStateFlow(0L)
|
||||||
|
val smsFilterAll = MutableStateFlow(false) // true=全部转发 false=仅紧要
|
||||||
|
val smsRateLimitEnabled = MutableStateFlow(true)
|
||||||
|
val smsRateLimitMin = MutableStateFlow(5)
|
||||||
|
val smsBalanceCheck = MutableStateFlow(true)
|
||||||
|
|
||||||
// 电邮/ntfy 总开关
|
// 电邮总开关
|
||||||
val emailForwardingEnabled = MutableStateFlow(false)
|
val emailForwardingEnabled = MutableStateFlow(false)
|
||||||
val ntfyEnabled = MutableStateFlow(false)
|
|
||||||
|
|
||||||
// 应用更新
|
// 应用更新
|
||||||
val updateCheckEnabled = MutableStateFlow(true)
|
val updateCheckEnabled = MutableStateFlow(true)
|
||||||
@@ -145,6 +157,7 @@ class SettingsStore(
|
|||||||
// 各通道 stopOnFirst
|
// 各通道 stopOnFirst
|
||||||
barkStopOnFirst.value = p[K.BARK_STOP_FIRST] ?: true
|
barkStopOnFirst.value = p[K.BARK_STOP_FIRST] ?: true
|
||||||
ntfyStopOnFirst.value = p[K.NTFY_STOP_FIRST] ?: false
|
ntfyStopOnFirst.value = p[K.NTFY_STOP_FIRST] ?: false
|
||||||
|
meowStopOnFirst.value = p[K.MEOW_STOP_FIRST] ?: false
|
||||||
emailStopOnFirst.value = p[K.EMAIL_STOP_FIRST] ?: false
|
emailStopOnFirst.value = p[K.EMAIL_STOP_FIRST] ?: false
|
||||||
smsStopOnFirst.value = p[K.SMS_STOP_FIRST] ?: false
|
smsStopOnFirst.value = p[K.SMS_STOP_FIRST] ?: false
|
||||||
// 短信兜底全局
|
// 短信兜底全局
|
||||||
@@ -154,9 +167,12 @@ class SettingsStore(
|
|||||||
smsBalance.value = p[K.SMS_BALANCE] ?: -1
|
smsBalance.value = p[K.SMS_BALANCE] ?: -1
|
||||||
smsSuspended.value = p[K.SMS_SUSPENDED] ?: false
|
smsSuspended.value = p[K.SMS_SUSPENDED] ?: false
|
||||||
lastSmsSentTime.value = p[K.SMS_LAST_SENT] ?: 0L
|
lastSmsSentTime.value = p[K.SMS_LAST_SENT] ?: 0L
|
||||||
// 电邮/ntfy 总开关
|
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
|
emailForwardingEnabled.value = p[K.EMAIL_ENABLED] ?: false
|
||||||
ntfyEnabled.value = p[K.NTFY_ENABLED] ?: false
|
|
||||||
// 应用更新
|
// 应用更新
|
||||||
updateCheckEnabled.value = p[K.UPDATE_CHECK_AUTO] ?: true
|
updateCheckEnabled.value = p[K.UPDATE_CHECK_AUTO] ?: true
|
||||||
lastUpdateCheckTime.value = p[K.LAST_UPDATE_CHECK] ?: 0L
|
lastUpdateCheckTime.value = p[K.LAST_UPDATE_CHECK] ?: 0L
|
||||||
@@ -165,6 +181,9 @@ class SettingsStore(
|
|||||||
_ntfyServers.value = p[K.NTFY_SERVERS]
|
_ntfyServers.value = p[K.NTFY_SERVERS]
|
||||||
?.let { runCatching { json.decodeFromString<List<NtfyServer>>(it) }.getOrNull() }
|
?.let { runCatching { json.decodeFromString<List<NtfyServer>>(it) }.getOrNull() }
|
||||||
?: emptyList()
|
?: emptyList()
|
||||||
|
_meowServers.value = p[K.MEOW_SERVERS]
|
||||||
|
?.let { runCatching { json.decodeFromString<List<MeowServer>>(it) }.getOrNull() }
|
||||||
|
?: emptyList()
|
||||||
_emailServers.value = p[K.EMAIL_SERVERS]
|
_emailServers.value = p[K.EMAIL_SERVERS]
|
||||||
?.let { runCatching { json.decodeFromString<List<EmailServer>>(it) }.getOrNull() }
|
?.let { runCatching { json.decodeFromString<List<EmailServer>>(it) }.getOrNull() }
|
||||||
?: emptyList()
|
?: emptyList()
|
||||||
@@ -265,6 +284,22 @@ class SettingsStore(
|
|||||||
}
|
}
|
||||||
suspend fun setNtfyEnabled(id: String, enabled: Boolean) = saveNtfyServers(_ntfyServers.value.map { if (it.id == id) it.copy(enabled = enabled) else it })
|
suspend fun setNtfyEnabled(id: String, enabled: Boolean) = saveNtfyServers(_ntfyServers.value.map { if (it.id == id) it.copy(enabled = enabled) else it })
|
||||||
|
|
||||||
|
// ---- Meow 服务器 CRUD ----
|
||||||
|
private suspend fun saveMeowServers(list: List<MeowServer>) {
|
||||||
|
edit { it[K.MEOW_SERVERS] = json.encodeToString(list) }
|
||||||
|
_meowServers.value = list
|
||||||
|
}
|
||||||
|
suspend fun addMeow(s: MeowServer) { saveMeowServers(_meowServers.value + s) }
|
||||||
|
suspend fun updateMeow(s: MeowServer) = saveMeowServers(_meowServers.value.map { if (it.id == s.id) s else it })
|
||||||
|
suspend fun deleteMeow(id: String) = saveMeowServers(_meowServers.value.filterNot { it.id == id })
|
||||||
|
suspend fun moveMeow(id: String, up: Boolean) {
|
||||||
|
val list = _meowServers.value.toMutableList()
|
||||||
|
val i = list.indexOfFirst { it.id == id }
|
||||||
|
if (i in 1..list.lastIndex && up) { java.util.Collections.swap(list, i, i - 1); saveMeowServers(list) }
|
||||||
|
else if (i in 0 until list.lastIndex && !up) { java.util.Collections.swap(list, i, i + 1); saveMeowServers(list) }
|
||||||
|
}
|
||||||
|
suspend fun setMeowEnabled(id: String, enabled: Boolean) = saveMeowServers(_meowServers.value.map { if (it.id == id) it.copy(enabled = enabled) else it })
|
||||||
|
|
||||||
// ---- 电邮服务器 CRUD ----
|
// ---- 电邮服务器 CRUD ----
|
||||||
private suspend fun saveEmailServers(list: List<EmailServer>) {
|
private suspend fun saveEmailServers(list: List<EmailServer>) {
|
||||||
edit { it[K.EMAIL_SERVERS] = json.encodeToString(list) }
|
edit { it[K.EMAIL_SERVERS] = json.encodeToString(list) }
|
||||||
@@ -310,6 +345,7 @@ class SettingsStore(
|
|||||||
|
|
||||||
suspend fun setBarkStopOnFirst(v: Boolean) { edit { it[K.BARK_STOP_FIRST] = v }; barkStopOnFirst.value = v }
|
suspend fun setBarkStopOnFirst(v: Boolean) { edit { it[K.BARK_STOP_FIRST] = v }; barkStopOnFirst.value = v }
|
||||||
suspend fun setNtfyStopOnFirst(v: Boolean) { edit { it[K.NTFY_STOP_FIRST] = v }; ntfyStopOnFirst.value = v }
|
suspend fun setNtfyStopOnFirst(v: Boolean) { edit { it[K.NTFY_STOP_FIRST] = v }; ntfyStopOnFirst.value = v }
|
||||||
|
suspend fun setMeowStopOnFirst(v: Boolean) { edit { it[K.MEOW_STOP_FIRST] = v }; meowStopOnFirst.value = v }
|
||||||
suspend fun setEmailStopOnFirst(v: Boolean) { edit { it[K.EMAIL_STOP_FIRST] = v }; emailStopOnFirst.value = v }
|
suspend fun setEmailStopOnFirst(v: Boolean) { edit { it[K.EMAIL_STOP_FIRST] = v }; emailStopOnFirst.value = v }
|
||||||
suspend fun setSmsStopOnFirst(v: Boolean) { edit { it[K.SMS_STOP_FIRST] = v }; smsStopOnFirst.value = v }
|
suspend fun setSmsStopOnFirst(v: Boolean) { edit { it[K.SMS_STOP_FIRST] = v }; smsStopOnFirst.value = v }
|
||||||
|
|
||||||
@@ -319,18 +355,21 @@ class SettingsStore(
|
|||||||
suspend fun setSmsBalance(v: Int) { edit { it[K.SMS_BALANCE] = v }; smsBalance.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 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 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 setUpdateCheckEnabled(v: Boolean) { edit { it[K.UPDATE_CHECK_AUTO] = v }; updateCheckEnabled.value = v }
|
||||||
suspend fun setLastUpdateCheck(v: Long) { edit { it[K.LAST_UPDATE_CHECK] = v }; lastUpdateCheckTime.value = v }
|
suspend fun setLastUpdateCheck(v: Long) { edit { it[K.LAST_UPDATE_CHECK] = v }; lastUpdateCheckTime.value = v }
|
||||||
suspend fun setIgnoredVersion(v: String) { edit { it[K.IGNORED_VERSION] = v }; ignoredVersion.value = v }
|
suspend fun setIgnoredVersion(v: String) { edit { it[K.IGNORED_VERSION] = v }; ignoredVersion.value = v }
|
||||||
|
|
||||||
suspend fun setEmailForwarding(v: Boolean) { edit { it[K.EMAIL_ENABLED] = v }; emailForwardingEnabled.value = v }
|
suspend fun setEmailForwarding(v: Boolean) { edit { it[K.EMAIL_ENABLED] = v }; emailForwardingEnabled.value = v }
|
||||||
suspend fun setNtfyEnabled(v: Boolean) { edit { it[K.NTFY_ENABLED] = v }; ntfyEnabled.value = v }
|
|
||||||
|
|
||||||
suspend fun snapshot() = ds.data.first()
|
suspend fun snapshot() = ds.data.first()
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
const val ID_OFFICIAL = "official"
|
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/"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import android.graphics.drawable.BitmapDrawable
|
|||||||
import android.graphics.drawable.Drawable
|
import android.graphics.drawable.Drawable
|
||||||
import androidx.compose.foundation.BorderStroke
|
import androidx.compose.foundation.BorderStroke
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.Image
|
||||||
import androidx.compose.foundation.clickable
|
import androidx.compose.foundation.clickable
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
@@ -68,6 +69,9 @@ fun SectionCard(
|
|||||||
title: String? = null,
|
title: String? = null,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
subtitle: String? = null,
|
subtitle: String? = null,
|
||||||
|
titleIcon: ImageVector? = null,
|
||||||
|
titleIconTint: Color = MaterialTheme.colorScheme.primary,
|
||||||
|
headerAction: (@Composable () -> Unit)? = null,
|
||||||
content: @Composable ColumnScope.() -> Unit,
|
content: @Composable ColumnScope.() -> Unit,
|
||||||
) {
|
) {
|
||||||
Card(
|
Card(
|
||||||
@@ -79,19 +83,58 @@ fun SectionCard(
|
|||||||
) {
|
) {
|
||||||
Column(Modifier.padding(16.dp)) {
|
Column(Modifier.padding(16.dp)) {
|
||||||
if (title != null) {
|
if (title != null) {
|
||||||
Text(
|
if (headerAction == null) {
|
||||||
title,
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
style = MaterialTheme.typography.titleMedium,
|
if (titleIcon != null) {
|
||||||
fontWeight = FontWeight.SemiBold,
|
Icon(titleIcon, null, tint = titleIconTint, modifier = Modifier.size(22.dp))
|
||||||
modifier = Modifier.padding(bottom = if (subtitle == null) 10.dp else 2.dp),
|
Spacer(Modifier.width(8.dp))
|
||||||
)
|
}
|
||||||
if (subtitle != null) {
|
Text(
|
||||||
Text(
|
title,
|
||||||
subtitle,
|
style = MaterialTheme.typography.titleMedium,
|
||||||
style = MaterialTheme.typography.bodySmall,
|
fontWeight = FontWeight.SemiBold,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
modifier = Modifier.padding(bottom = if (subtitle == null) 10.dp else 2.dp),
|
||||||
modifier = Modifier.padding(bottom = 10.dp),
|
)
|
||||||
)
|
}
|
||||||
|
if (subtitle != null) {
|
||||||
|
Text(
|
||||||
|
subtitle,
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
modifier = Modifier.padding(bottom = 10.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(bottom = 10.dp),
|
||||||
|
verticalAlignment = Alignment.Top,
|
||||||
|
) {
|
||||||
|
Column(Modifier.weight(1f)) {
|
||||||
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
if (titleIcon != null) {
|
||||||
|
Icon(titleIcon, null, tint = titleIconTint, modifier = Modifier.size(22.dp))
|
||||||
|
Spacer(Modifier.width(8.dp))
|
||||||
|
}
|
||||||
|
Text(
|
||||||
|
title,
|
||||||
|
style = MaterialTheme.typography.titleMedium,
|
||||||
|
fontWeight = FontWeight.SemiBold,
|
||||||
|
modifier = Modifier.padding(bottom = if (subtitle == null) 0.dp else 2.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (subtitle != null) {
|
||||||
|
Text(
|
||||||
|
subtitle,
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Spacer(Modifier.width(12.dp))
|
||||||
|
headerAction()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
content()
|
content()
|
||||||
@@ -244,4 +287,4 @@ fun drawableToImageBitmap(d: Drawable): ImageBitmap {
|
|||||||
d.setBounds(0, 0, canvas.width, canvas.height)
|
d.setBounds(0, 0, canvas.width, canvas.height)
|
||||||
d.draw(canvas)
|
d.draw(canvas)
|
||||||
return bmp.asImageBitmap()
|
return bmp.asImageBitmap()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,20 @@
|
|||||||
package com.a2i.forwarder.ui.nav
|
package com.a2i.forwarder.ui.nav
|
||||||
|
|
||||||
|
import androidx.compose.animation.core.RepeatMode
|
||||||
|
import androidx.compose.animation.core.animateFloat
|
||||||
|
import androidx.compose.animation.core.infiniteRepeatable
|
||||||
|
import androidx.compose.animation.core.rememberInfiniteTransition
|
||||||
|
import androidx.compose.animation.core.tween
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.SystemUpdate
|
||||||
import androidx.compose.material.icons.outlined.Apps
|
import androidx.compose.material.icons.outlined.Apps
|
||||||
import androidx.compose.material.icons.outlined.FilterAlt
|
import androidx.compose.material.icons.outlined.FilterAlt
|
||||||
import androidx.compose.material.icons.outlined.Notifications
|
import androidx.compose.material.icons.outlined.Notifications
|
||||||
@@ -14,8 +27,15 @@ import androidx.compose.material3.NavigationBarItemDefaults
|
|||||||
import androidx.compose.material3.Scaffold
|
import androidx.compose.material3.Scaffold
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.collectAsState
|
||||||
import androidx.compose.runtime.getValue
|
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.Modifier
|
||||||
|
import androidx.compose.ui.draw.alpha
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.graphics.vector.ImageVector
|
import androidx.compose.ui.graphics.vector.ImageVector
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.navigation.NavGraph.Companion.findStartDestination
|
import androidx.navigation.NavGraph.Companion.findStartDestination
|
||||||
@@ -23,11 +43,13 @@ import androidx.navigation.compose.NavHost
|
|||||||
import androidx.navigation.compose.composable
|
import androidx.navigation.compose.composable
|
||||||
import androidx.navigation.compose.currentBackStackEntryAsState
|
import androidx.navigation.compose.currentBackStackEntryAsState
|
||||||
import androidx.navigation.compose.rememberNavController
|
import androidx.navigation.compose.rememberNavController
|
||||||
|
import com.a2i.forwarder.A2iApp
|
||||||
import com.a2i.forwarder.ui.screens.AppManageScreen
|
import com.a2i.forwarder.ui.screens.AppManageScreen
|
||||||
import com.a2i.forwarder.ui.screens.FilterScreen
|
import com.a2i.forwarder.ui.screens.FilterScreen
|
||||||
import com.a2i.forwarder.ui.screens.HomeScreen
|
import com.a2i.forwarder.ui.screens.HomeScreen
|
||||||
import com.a2i.forwarder.ui.screens.LogScreen
|
import com.a2i.forwarder.ui.screens.LogScreen
|
||||||
import com.a2i.forwarder.ui.screens.SettingsScreen
|
import com.a2i.forwarder.ui.screens.SettingsScreen
|
||||||
|
import com.a2i.forwarder.ui.screens.UpdateDialog
|
||||||
|
|
||||||
private sealed class Tab(val route: String, val label: String, val icon: ImageVector) {
|
private sealed class Tab(val route: String, val label: String, val icon: ImageVector) {
|
||||||
object Home : Tab("home", "首页", Icons.Outlined.Notifications)
|
object Home : Tab("home", "首页", Icons.Outlined.Notifications)
|
||||||
@@ -46,54 +68,104 @@ fun AppRoot() {
|
|||||||
val current = backStack?.destination?.route
|
val current = backStack?.destination?.route
|
||||||
val showBar = current in tabRoutes
|
val showBar = current in tabRoutes
|
||||||
|
|
||||||
Scaffold(
|
val app = A2iApp.instance
|
||||||
containerColor = MaterialTheme.colorScheme.background,
|
val updateInfo by app.updateChecker.updateInfo.collectAsState()
|
||||||
bottomBar = {
|
var showUpdateDialog by remember { mutableStateOf(false) }
|
||||||
if (showBar) {
|
|
||||||
NavigationBar(
|
Box {
|
||||||
containerColor = MaterialTheme.colorScheme.surface,
|
Scaffold(
|
||||||
tonalElevation = 0.dp,
|
containerColor = MaterialTheme.colorScheme.background,
|
||||||
) {
|
bottomBar = {
|
||||||
tabs.forEach { t ->
|
if (showBar) {
|
||||||
NavigationBarItem(
|
NavigationBar(
|
||||||
selected = current == t.route,
|
containerColor = MaterialTheme.colorScheme.surface,
|
||||||
onClick = {
|
tonalElevation = 0.dp,
|
||||||
nav.navigate(t.route) {
|
) {
|
||||||
popUpTo(nav.graph.findStartDestination().id) { saveState = true }
|
tabs.forEach { t ->
|
||||||
launchSingleTop = true
|
NavigationBarItem(
|
||||||
restoreState = true
|
selected = current == t.route,
|
||||||
}
|
onClick = {
|
||||||
},
|
nav.navigate(t.route) {
|
||||||
icon = { Icon(t.icon, t.label) },
|
popUpTo(nav.graph.findStartDestination().id) { saveState = true }
|
||||||
label = { Text(t.label) },
|
launchSingleTop = true
|
||||||
colors = NavigationBarItemDefaults.colors(
|
restoreState = true
|
||||||
selectedIconColor = MaterialTheme.colorScheme.primary,
|
}
|
||||||
selectedTextColor = MaterialTheme.colorScheme.primary,
|
},
|
||||||
indicatorColor = MaterialTheme.colorScheme.primaryContainer,
|
icon = { Icon(t.icon, t.label) },
|
||||||
unselectedIconColor = MaterialTheme.colorScheme.onSurfaceVariant,
|
label = { Text(t.label) },
|
||||||
unselectedTextColor = MaterialTheme.colorScheme.onSurfaceVariant,
|
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() }) }
|
||||||
}
|
}
|
||||||
},
|
}
|
||||||
) { inner ->
|
|
||||||
NavHost(
|
// 浮动更新提醒小图标(跨所有页面,缓慢闪烁)
|
||||||
navController = nav,
|
if (updateInfo != null) {
|
||||||
startDestination = Tab.Home.route,
|
UpdateBadge(
|
||||||
modifier = Modifier.padding(inner),
|
modifier = Modifier.align(Alignment.TopEnd),
|
||||||
) {
|
onClick = { showUpdateDialog = true },
|
||||||
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() }) }
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (showUpdateDialog && updateInfo != null) {
|
||||||
|
UpdateDialog(updateInfo!!, onDismiss = { showUpdateDialog = false })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 跨页面浮动更新提醒:右上角小圆点 + 缓慢闪烁动画。 */
|
||||||
|
@Composable
|
||||||
|
private fun UpdateBadge(modifier: Modifier = Modifier, onClick: () -> Unit) {
|
||||||
|
val transition = rememberInfiniteTransition(label = "updateBlink")
|
||||||
|
val pulseAlpha by transition.animateFloat(
|
||||||
|
initialValue = 0.35f,
|
||||||
|
targetValue = 1f,
|
||||||
|
animationSpec = infiniteRepeatable(
|
||||||
|
animation = tween(1200),
|
||||||
|
repeatMode = RepeatMode.Reverse,
|
||||||
|
),
|
||||||
|
label = "pulseAlpha",
|
||||||
|
)
|
||||||
|
|
||||||
|
Box(
|
||||||
|
modifier = modifier
|
||||||
|
.padding(top = 12.dp, end = 12.dp)
|
||||||
|
.size(40.dp)
|
||||||
|
.alpha(pulseAlpha)
|
||||||
|
.background(MaterialTheme.colorScheme.primary, CircleShape)
|
||||||
|
.clickable(onClick = onClick),
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
Icons.Filled.SystemUpdate,
|
||||||
|
contentDescription = "发现新版本,点击更新",
|
||||||
|
tint = Color.White,
|
||||||
|
modifier = Modifier.size(22.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -61,7 +61,7 @@ fun FilterScreen() {
|
|||||||
modifier = Modifier.fillMaxSize().padding(horizontal = 16.dp),
|
modifier = Modifier.fillMaxSize().padding(horizontal = 16.dp),
|
||||||
verticalArrangement = Arrangement.spacedBy(12.dp),
|
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||||
) {
|
) {
|
||||||
item { ScreenTitle("过滤与优化", "减少噪音,保留真正需要同步到 iOS 的通知") }
|
item { ScreenTitle("过滤与优化", "减少噪音,保留真正需要转发的通知") }
|
||||||
|
|
||||||
item {
|
item {
|
||||||
SectionCard("功能开关", subtitle = "规则按通知处理流水线顺序执行") {
|
SectionCard("功能开关", subtitle = "规则按通知处理流水线顺序执行") {
|
||||||
|
|||||||
@@ -178,7 +178,7 @@ fun HomeScreen(onOpenLog: () -> Unit, onOpenSettings: () -> Unit) {
|
|||||||
Column(Modifier.weight(1f)) {
|
Column(Modifier.weight(1f)) {
|
||||||
Text(if (granted) "通知监听已开启" else "需要授权通知监听", style = MaterialTheme.typography.titleMedium)
|
Text(if (granted) "通知监听已开启" else "需要授权通知监听", style = MaterialTheme.typography.titleMedium)
|
||||||
Text(
|
Text(
|
||||||
if (granted) "系统通知将按规则处理并推送到 Bark" else "授权后才能读取 Android 通知并转发",
|
if (granted) "系统通知将按规则处理并多通道转发" else "授权后才能读取 Android 通知并转发",
|
||||||
style = MaterialTheme.typography.bodySmall,
|
style = MaterialTheme.typography.bodySmall,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
)
|
)
|
||||||
@@ -202,7 +202,7 @@ fun HomeScreen(onOpenLog: () -> Unit, onOpenSettings: () -> Unit) {
|
|||||||
SectionCard("运行状态", subtitle = "转发能力、目标服务器和日志入口") {
|
SectionCard("运行状态", subtitle = "转发能力、目标服务器和日志入口") {
|
||||||
SwitchRow(
|
SwitchRow(
|
||||||
title = "通知转发",
|
title = "通知转发",
|
||||||
subtitle = if (forwarding) "新通知会继续转发到 Bark" else "当前已暂停所有转发",
|
subtitle = if (forwarding) "新通知会继续多通道转发" else "当前已暂停所有转发",
|
||||||
checked = forwarding,
|
checked = forwarding,
|
||||||
icon = Icons.Filled.PowerSettingsNew,
|
icon = Icons.Filled.PowerSettingsNew,
|
||||||
tint = if (forwarding) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant,
|
tint = if (forwarding) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
@@ -285,8 +285,8 @@ private fun HomeHero(
|
|||||||
}
|
}
|
||||||
Spacer(Modifier.width(12.dp))
|
Spacer(Modifier.width(12.dp))
|
||||||
Column(Modifier.weight(1f)) {
|
Column(Modifier.weight(1f)) {
|
||||||
Text("a2i", style = MaterialTheme.typography.displaySmall, color = Color.White)
|
Text("GotMsg", style = MaterialTheme.typography.displaySmall, color = Color.White)
|
||||||
Text("安卓通知转发到 iOS Bark", style = MaterialTheme.typography.bodyMedium, color = Color.White.copy(alpha = 0.82f))
|
Text("安卓通知多通道转发", style = MaterialTheme.typography.bodyMedium, color = Color.White.copy(alpha = 0.82f))
|
||||||
}
|
}
|
||||||
StatusBadge(
|
StatusBadge(
|
||||||
text = if (granted && forwarding) "运行中" else "待处理",
|
text = if (granted && forwarding) "运行中" else "待处理",
|
||||||
@@ -295,7 +295,7 @@ private fun HomeHero(
|
|||||||
}
|
}
|
||||||
Spacer(Modifier.size(14.dp))
|
Spacer(Modifier.size(14.dp))
|
||||||
Text(
|
Text(
|
||||||
if (granted && forwarding) "监听、过滤、提取验证码和推送链路已就绪。" else "完成授权并开启转发后,通知会按规则推送到 iOS。",
|
if (granted && forwarding) "监听、过滤、提取验证码和推送链路已就绪。" else "完成授权并开启转发后,通知会按规则转发到各通道。",
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
color = Color.White.copy(alpha = 0.86f),
|
color = Color.White.copy(alpha = 0.86f),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -6,12 +6,17 @@ import android.net.Uri
|
|||||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||||
import androidx.activity.result.contract.ActivityResultContracts
|
import androidx.activity.result.contract.ActivityResultContracts
|
||||||
import androidx.compose.foundation.BorderStroke
|
import androidx.compose.foundation.BorderStroke
|
||||||
|
import androidx.compose.foundation.Image
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.rememberScrollState
|
||||||
|
import androidx.compose.foundation.verticalScroll
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
import androidx.compose.foundation.layout.Spacer
|
import androidx.compose.foundation.layout.Spacer
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.heightIn
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.layout.size
|
import androidx.compose.foundation.layout.size
|
||||||
import androidx.compose.foundation.layout.width
|
import androidx.compose.foundation.layout.width
|
||||||
@@ -22,10 +27,15 @@ import androidx.compose.material.icons.automirrored.filled.Send
|
|||||||
import androidx.compose.material.icons.filled.Add
|
import androidx.compose.material.icons.filled.Add
|
||||||
import androidx.compose.material.icons.filled.Delete
|
import androidx.compose.material.icons.filled.Delete
|
||||||
import androidx.compose.material.icons.filled.Edit
|
import androidx.compose.material.icons.filled.Edit
|
||||||
|
import androidx.compose.material.icons.filled.Email
|
||||||
import androidx.compose.material.icons.filled.History
|
import androidx.compose.material.icons.filled.History
|
||||||
|
import androidx.compose.material.icons.filled.Info
|
||||||
import androidx.compose.material.icons.filled.KeyboardArrowDown
|
import androidx.compose.material.icons.filled.KeyboardArrowDown
|
||||||
import androidx.compose.material.icons.filled.KeyboardArrowUp
|
import androidx.compose.material.icons.filled.KeyboardArrowUp
|
||||||
|
import androidx.compose.material.icons.filled.Notifications
|
||||||
|
import androidx.compose.material.icons.filled.Pets
|
||||||
import androidx.compose.material.icons.filled.RestartAlt
|
import androidx.compose.material.icons.filled.RestartAlt
|
||||||
|
import androidx.compose.material.icons.filled.Sms
|
||||||
import androidx.compose.material.icons.filled.Settings
|
import androidx.compose.material.icons.filled.Settings
|
||||||
import androidx.compose.material.icons.filled.SystemUpdateAlt
|
import androidx.compose.material.icons.filled.SystemUpdateAlt
|
||||||
import androidx.compose.material.icons.filled.Update
|
import androidx.compose.material.icons.filled.Update
|
||||||
@@ -51,18 +61,26 @@ import androidx.compose.runtime.setValue
|
|||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.graphics.painter.Painter
|
||||||
|
import androidx.compose.ui.graphics.vector.ImageVector
|
||||||
|
import androidx.compose.ui.platform.LocalUriHandler
|
||||||
|
import androidx.compose.ui.res.painterResource
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.text.style.TextOverflow
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.documentfile.provider.DocumentFile
|
import androidx.documentfile.provider.DocumentFile
|
||||||
import com.a2i.forwarder.A2iApp
|
import com.a2i.forwarder.A2iApp
|
||||||
|
import com.a2i.forwarder.R
|
||||||
import com.a2i.forwarder.core.BarkClient
|
import com.a2i.forwarder.core.BarkClient
|
||||||
import com.a2i.forwarder.core.EmailSender
|
import com.a2i.forwarder.core.EmailSender
|
||||||
import com.a2i.forwarder.core.IconExporter
|
import com.a2i.forwarder.core.IconExporter
|
||||||
|
import com.a2i.forwarder.core.MeowSender
|
||||||
import com.a2i.forwarder.core.NtfySender
|
import com.a2i.forwarder.core.NtfySender
|
||||||
import com.a2i.forwarder.model.BarkMessage
|
import com.a2i.forwarder.model.BarkMessage
|
||||||
import com.a2i.forwarder.model.BarkServer
|
import com.a2i.forwarder.model.BarkServer
|
||||||
import com.a2i.forwarder.model.EmailServer
|
import com.a2i.forwarder.model.EmailServer
|
||||||
|
import com.a2i.forwarder.model.MeowServer
|
||||||
import com.a2i.forwarder.model.NtfyServer
|
import com.a2i.forwarder.model.NtfyServer
|
||||||
import com.a2i.forwarder.model.OFFICIAL_BARK_SERVER
|
import com.a2i.forwarder.model.OFFICIAL_BARK_SERVER
|
||||||
import com.a2i.forwarder.model.SmsTarget
|
import com.a2i.forwarder.model.SmsTarget
|
||||||
@@ -74,19 +92,36 @@ import com.a2i.forwarder.ui.SwitchRow
|
|||||||
import com.a2i.forwarder.ui.appVersionName
|
import com.a2i.forwarder.ui.appVersionName
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
|
private const val BARK_APP_STORE_URL = "https://apps.apple.com/app/bark-custom-notifications/id1403753865"
|
||||||
|
private const val BARK_DOCS_URL = "https://bark.day.app/#/"
|
||||||
|
private const val BARK_GITHUB_URL = "https://github.com/Finb/Bark"
|
||||||
|
private const val BARK_OFFICIAL_SERVER = "https://api.day.app"
|
||||||
|
private const val NTFY_OFFICIAL_SERVER = "https://ntfy.sh"
|
||||||
|
private const val NTFY_ANDROID_URL = "https://play.google.com/store/apps/details?id=io.heckel.ntfy"
|
||||||
|
private const val NTFY_IOS_URL = "https://apps.apple.com/us/app/ntfy/id1625396347"
|
||||||
|
private const val NTFY_FDROID_URL = "https://f-droid.org/en/packages/io.heckel.ntfy/"
|
||||||
|
private const val NTFY_DOCS_URL = "https://docs.ntfy.sh/subscribe/phone/"
|
||||||
|
private const val NTFY_PUBLISH_DOCS_URL = "https://docs.ntfy.sh/publish/"
|
||||||
|
private const val NTFY_GITHUB_URL = "https://github.com/binwiederhier/ntfy"
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun SettingsScreen(onOpenLog: () -> Unit) {
|
fun SettingsScreen(onOpenLog: () -> Unit) {
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
val app = A2iApp.instance
|
val app = A2iApp.instance
|
||||||
val servers by app.settings.servers.collectAsState()
|
val servers by app.settings.servers.collectAsState()
|
||||||
val ntfyServers by app.settings.ntfyServers.collectAsState()
|
val ntfyServers by app.settings.ntfyServers.collectAsState()
|
||||||
|
val meowServers by app.settings.meowServers.collectAsState()
|
||||||
val emailServers by app.settings.emailServers.collectAsState()
|
val emailServers by app.settings.emailServers.collectAsState()
|
||||||
val smsTargets by app.settings.smsTargets.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 barkStopFirst by app.settings.barkStopOnFirst.collectAsState()
|
||||||
val ntfyStopFirst by app.settings.ntfyStopOnFirst.collectAsState()
|
val ntfyStopFirst by app.settings.ntfyStopOnFirst.collectAsState()
|
||||||
|
val meowStopFirst by app.settings.meowStopOnFirst.collectAsState()
|
||||||
val emailStopFirst by app.settings.emailStopOnFirst.collectAsState()
|
val emailStopFirst by app.settings.emailStopOnFirst.collectAsState()
|
||||||
val smsStopFirst by app.settings.smsStopOnFirst.collectAsState()
|
val smsStopFirst by app.settings.smsStopOnFirst.collectAsState()
|
||||||
val ntfyEnabled by app.settings.ntfyEnabled.collectAsState()
|
|
||||||
val emailEnabled by app.settings.emailForwardingEnabled.collectAsState()
|
val emailEnabled by app.settings.emailForwardingEnabled.collectAsState()
|
||||||
val smsFallback by app.settings.smsFallbackEnabled.collectAsState()
|
val smsFallback by app.settings.smsFallbackEnabled.collectAsState()
|
||||||
val iconPrefix by app.settings.iconPrefix.collectAsState()
|
val iconPrefix by app.settings.iconPrefix.collectAsState()
|
||||||
@@ -118,9 +153,14 @@ fun SettingsScreen(onOpenLog: () -> Unit) {
|
|||||||
|
|
||||||
// 弹窗状态
|
// 弹窗状态
|
||||||
var showAddBark by remember { mutableStateOf(false) }
|
var showAddBark by remember { mutableStateOf(false) }
|
||||||
|
var showBarkHelp by remember { mutableStateOf(false) }
|
||||||
var editingBark by remember { mutableStateOf<BarkServer?>(null) }
|
var editingBark by remember { mutableStateOf<BarkServer?>(null) }
|
||||||
var showAddNtfy by remember { mutableStateOf(false) }
|
var showAddNtfy by remember { mutableStateOf(false) }
|
||||||
|
var showNtfyHelp by remember { mutableStateOf(false) }
|
||||||
var editingNtfy by remember { mutableStateOf<NtfyServer?>(null) }
|
var editingNtfy by remember { mutableStateOf<NtfyServer?>(null) }
|
||||||
|
var showAddMeow by remember { mutableStateOf(false) }
|
||||||
|
var showMeowHelp by remember { mutableStateOf(false) }
|
||||||
|
var editingMeow by remember { mutableStateOf<MeowServer?>(null) }
|
||||||
var showAddEmail by remember { mutableStateOf(false) }
|
var showAddEmail by remember { mutableStateOf(false) }
|
||||||
var editingEmail by remember { mutableStateOf<EmailServer?>(null) }
|
var editingEmail by remember { mutableStateOf<EmailServer?>(null) }
|
||||||
var showAddSms by remember { mutableStateOf(false) }
|
var showAddSms by remember { mutableStateOf(false) }
|
||||||
@@ -134,13 +174,19 @@ fun SettingsScreen(onOpenLog: () -> Unit) {
|
|||||||
|
|
||||||
// ===== Bark 服务器 =====
|
// ===== Bark 服务器 =====
|
||||||
item {
|
item {
|
||||||
SectionCard("Bark 服务器", subtitle = "勾选的服务器参与转发;开关切换容错/广播") {
|
SectionCard {
|
||||||
SwitchRow(
|
ChannelSectionHeader(
|
||||||
title = "发通一条就停",
|
title = "Bark 服务器",
|
||||||
subtitle = if (barkStopFirst) "容错模式:首个成功即止" else "广播模式:每个都发",
|
summary = "iPhone / iPad 推送目标",
|
||||||
checked = barkStopFirst,
|
mark = "B",
|
||||||
icon = Icons.Filled.Settings,
|
markColor = Color(0xFFFF9800),
|
||||||
) { v -> app.appScope.launch { app.settings.setBarkStopOnFirst(v) } }
|
markLogo = painterResource(R.drawable.ic_bark_logo),
|
||||||
|
platforms = listOf("iOS"),
|
||||||
|
stopFirst = barkStopFirst,
|
||||||
|
onStopFirstChange = { v -> app.appScope.launch { app.settings.setBarkStopOnFirst(v) } },
|
||||||
|
onHelpClick = { showBarkHelp = true },
|
||||||
|
helpContentDescription = "Bark 配置帮助",
|
||||||
|
)
|
||||||
if (servers.isNotEmpty()) {
|
if (servers.isNotEmpty()) {
|
||||||
Spacer(Modifier.size(8.dp))
|
Spacer(Modifier.size(8.dp))
|
||||||
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
@@ -161,95 +207,186 @@ fun SettingsScreen(onOpenLog: () -> Unit) {
|
|||||||
|
|
||||||
// ===== ntfy =====
|
// ===== ntfy =====
|
||||||
item {
|
item {
|
||||||
SectionCard("ntfy 转发", subtitle = "POST 推送到 ntfy 服务") {
|
SectionCard {
|
||||||
SwitchRow(title = "启用 ntfy 转发", subtitle = if (ntfyEnabled) "已开启" else "已关闭", checked = ntfyEnabled) { v -> app.appScope.launch { app.settings.setNtfyEnabled(v) } }
|
ChannelSectionHeader(
|
||||||
if (ntfyEnabled) {
|
title = "ntfy 转发",
|
||||||
SwitchRow(title = "发通一条就停", subtitle = if (ntfyStopFirst) "容错:首个成功止" else "广播:每个都发", checked = ntfyStopFirst) { v -> app.appScope.launch { app.settings.setNtfyStopOnFirst(v) } }
|
summary = "Android / iOS topic 推送",
|
||||||
if (ntfyServers.isNotEmpty()) {
|
mark = "n",
|
||||||
Spacer(Modifier.size(8.dp))
|
markColor = Color(0xFF43A047),
|
||||||
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
markLogo = painterResource(R.drawable.ic_ntfy_logo),
|
||||||
ntfyServers.forEachIndexed { i, s ->
|
platforms = listOf("Android", "iOS"),
|
||||||
ConfigRow(
|
stopFirst = ntfyStopFirst,
|
||||||
title = s.name.ifBlank { s.topic },
|
onStopFirstChange = { v -> app.appScope.launch { app.settings.setNtfyStopOnFirst(v) } },
|
||||||
subtitle = "${s.server} · topic: ${s.topic}${if (s.token.isNotBlank()) " · token已填" else ""}",
|
onHelpClick = { showNtfyHelp = true },
|
||||||
enabled = s.enabled, isFirst = i == 0, isLast = i == ntfyServers.lastIndex,
|
helpContentDescription = "ntfy 配置帮助",
|
||||||
onToggle = { v -> app.appScope.launch { app.settings.setNtfyEnabled(s.id, v) } },
|
)
|
||||||
onEdit = { editingNtfy = s }, onDelete = { app.appScope.launch { app.settings.deleteNtfy(s.id) } },
|
if (ntfyServers.isNotEmpty()) {
|
||||||
onMoveUp = { app.appScope.launch { app.settings.moveNtfy(s.id, true) } },
|
Spacer(Modifier.size(8.dp))
|
||||||
onMoveDown = { app.appScope.launch { app.settings.moveNtfy(s.id, false) } },
|
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
onTest = { app.appScope.launch { NtfySender.send(s.server, s.topic, s.token, "a2i测试", "ntfy配置测试") } },
|
ntfyServers.forEachIndexed { i, s ->
|
||||||
)
|
NtfyServerRow(
|
||||||
}
|
server = s,
|
||||||
|
isFirst = i == 0,
|
||||||
|
isLast = i == ntfyServers.lastIndex,
|
||||||
|
onToggle = { v -> app.appScope.launch { app.settings.setNtfyEnabled(s.id, v) } },
|
||||||
|
onEdit = { editingNtfy = s },
|
||||||
|
onDelete = { app.appScope.launch { app.settings.deleteNtfy(s.id) } },
|
||||||
|
onMoveUp = { app.appScope.launch { app.settings.moveNtfy(s.id, true) } },
|
||||||
|
onMoveDown = { app.appScope.launch { app.settings.moveNtfy(s.id, false) } },
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
AddButton { showAddNtfy = true }
|
|
||||||
}
|
}
|
||||||
|
AddButton { showAddNtfy = true }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== Meow =====
|
||||||
|
item {
|
||||||
|
SectionCard {
|
||||||
|
ChannelSectionHeader(
|
||||||
|
title = "Meow 转发",
|
||||||
|
summary = "鸿蒙手机推送目标",
|
||||||
|
mark = "M",
|
||||||
|
markColor = Color(0xFF7E57C2),
|
||||||
|
markLogo = painterResource(R.drawable.ic_meow_logo),
|
||||||
|
platforms = listOf("HarmonyOS"),
|
||||||
|
stopFirst = meowStopFirst,
|
||||||
|
onStopFirstChange = { v -> app.appScope.launch { app.settings.setMeowStopOnFirst(v) } },
|
||||||
|
onHelpClick = { showMeowHelp = true },
|
||||||
|
helpContentDescription = "Meow 配置帮助",
|
||||||
|
)
|
||||||
|
if (meowServers.isNotEmpty()) {
|
||||||
|
Spacer(Modifier.size(8.dp))
|
||||||
|
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
|
meowServers.forEachIndexed { i, s ->
|
||||||
|
MeowServerRow(
|
||||||
|
server = s,
|
||||||
|
isFirst = i == 0,
|
||||||
|
isLast = i == meowServers.lastIndex,
|
||||||
|
onToggle = { v -> app.appScope.launch { app.settings.setMeowEnabled(s.id, v) } },
|
||||||
|
onEdit = { editingMeow = s },
|
||||||
|
onDelete = { app.appScope.launch { app.settings.deleteMeow(s.id) } },
|
||||||
|
onMoveUp = { app.appScope.launch { app.settings.moveMeow(s.id, true) } },
|
||||||
|
onMoveDown = { app.appScope.launch { app.settings.moveMeow(s.id, false) } },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
AddButton { showAddMeow = true }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ===== 电邮 =====
|
// ===== 电邮 =====
|
||||||
item {
|
item {
|
||||||
SectionCard("电邮转发", subtitle = "SMTPS 直发邮件") {
|
SectionCard {
|
||||||
SwitchRow(title = "启用电邮转发", subtitle = if (emailEnabled) "已开启" else "已关闭", checked = emailEnabled) { v -> app.appScope.launch { app.settings.setEmailForwarding(v) } }
|
ChannelSectionHeader(
|
||||||
if (emailEnabled) {
|
title = "电邮转发",
|
||||||
SwitchRow(title = "发通一条就停", subtitle = if (emailStopFirst) "容错:首个成功止" else "广播:每个都发", checked = emailStopFirst) { v -> app.appScope.launch { app.settings.setEmailStopOnFirst(v) } }
|
summary = "SMTP 邮箱推送目标",
|
||||||
if (emailServers.isNotEmpty()) {
|
mark = "@",
|
||||||
Spacer(Modifier.size(8.dp))
|
markColor = Color(0xFF1976D2),
|
||||||
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
markIcon = Icons.Filled.Email,
|
||||||
emailServers.forEachIndexed { i, s ->
|
platforms = listOf("Email"),
|
||||||
ConfigRow(
|
stopFirst = emailStopFirst,
|
||||||
title = s.name.ifBlank { s.to },
|
onStopFirstChange = { v -> app.appScope.launch { app.settings.setEmailStopOnFirst(v) } },
|
||||||
subtitle = "${s.host}:${s.port} · ${s.user} → ${s.to}",
|
)
|
||||||
enabled = s.enabled, isFirst = i == 0, isLast = i == emailServers.lastIndex,
|
if (emailServers.isNotEmpty()) {
|
||||||
onToggle = { v -> app.appScope.launch { app.settings.setEmailEnabled(s.id, v) } },
|
Spacer(Modifier.size(8.dp))
|
||||||
onEdit = { editingEmail = s }, onDelete = { app.appScope.launch { app.settings.deleteEmail(s.id) } },
|
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
onMoveUp = { app.appScope.launch { app.settings.moveEmail(s.id, true) } },
|
emailServers.forEachIndexed { i, s ->
|
||||||
onMoveDown = { app.appScope.launch { app.settings.moveEmail(s.id, false) } },
|
EmailServerRow(
|
||||||
onTest = { app.appScope.launch { EmailSender.send(s.host, s.port, s.user, s.password, s.from, s.to, "a2i测试", "电邮配置测试") } },
|
server = s,
|
||||||
)
|
isFirst = i == 0,
|
||||||
}
|
isLast = i == emailServers.lastIndex,
|
||||||
|
onToggle = { v -> app.appScope.launch { app.settings.setEmailEnabled(s.id, v) } },
|
||||||
|
onEdit = { editingEmail = s },
|
||||||
|
onDelete = { app.appScope.launch { app.settings.deleteEmail(s.id) } },
|
||||||
|
onMoveUp = { app.appScope.launch { app.settings.moveEmail(s.id, true) } },
|
||||||
|
onMoveDown = { app.appScope.launch { app.settings.moveEmail(s.id, false) } },
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
AddButton { showAddEmail = true }
|
|
||||||
}
|
}
|
||||||
|
AddButton { showAddEmail = true }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ===== 短信兜底 =====
|
// ===== 短信兜底 =====
|
||||||
item {
|
item {
|
||||||
SectionCard("断网短信兜底", subtitle = "无网络时把验证码/来电用短信发出去") {
|
SectionCard {
|
||||||
SwitchRow(title = "启用短信兜底", subtitle = if (smsFallback) "已开启" else "已关闭", checked = smsFallback) { v -> app.appScope.launch { app.settings.setSmsFallback(v) } }
|
ChannelSectionHeader(
|
||||||
if (smsFallback) {
|
title = "断网短信兜底",
|
||||||
SwitchRow(title = "发通一条就停", subtitle = if (smsStopFirst) "容错:首个成功止" else "广播:每个都发", checked = smsStopFirst) { v -> app.appScope.launch { app.settings.setSmsStopOnFirst(v) } }
|
summary = "无网络时用短信发送通知",
|
||||||
if (!hasSmsPerm) {
|
mark = "S",
|
||||||
Spacer(Modifier.size(4.dp))
|
markColor = Color(0xFF00897B),
|
||||||
OutlinedButton(onClick = { smsPermLauncher.launch(android.Manifest.permission.SEND_SMS) }, modifier = Modifier.fillMaxWidth()) { Text("授予短信权限") }
|
markIcon = Icons.Filled.Sms,
|
||||||
}
|
platforms = listOf("SMS"),
|
||||||
if (smsTargets.isNotEmpty()) {
|
stopFirst = smsStopFirst,
|
||||||
Spacer(Modifier.size(8.dp))
|
onStopFirstChange = { v -> app.appScope.launch { app.settings.setSmsStopOnFirst(v) } },
|
||||||
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
)
|
||||||
smsTargets.forEachIndexed { i, s ->
|
if (!hasSmsPerm) {
|
||||||
ConfigRow(
|
Spacer(Modifier.size(4.dp))
|
||||||
title = s.name.ifBlank { s.number },
|
OutlinedButton(onClick = { smsPermLauncher.launch(android.Manifest.permission.SEND_SMS) }, modifier = Modifier.fillMaxWidth()) { Text("授予短信权限") }
|
||||||
subtitle = s.number,
|
}
|
||||||
enabled = s.enabled, isFirst = i == 0, isLast = i == smsTargets.lastIndex,
|
if (smsTargets.isNotEmpty()) {
|
||||||
onToggle = { v -> app.appScope.launch { app.settings.setSmsTargetEnabled(s.id, v) } },
|
Spacer(Modifier.size(8.dp))
|
||||||
onEdit = { editingSms = s }, onDelete = { app.appScope.launch { app.settings.deleteSmsTarget(s.id) } },
|
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
onMoveUp = { app.appScope.launch { app.settings.moveSmsTarget(s.id, true) } },
|
smsTargets.forEachIndexed { i, s ->
|
||||||
onMoveDown = { app.appScope.launch { app.settings.moveSmsTarget(s.id, false) } },
|
ConfigRow(
|
||||||
onTest = null,
|
title = s.name.ifBlank { s.number },
|
||||||
)
|
subtitle = s.number,
|
||||||
}
|
enabled = s.enabled, isFirst = i == 0, isLast = i == smsTargets.lastIndex,
|
||||||
|
onToggle = { v -> app.appScope.launch { app.settings.setSmsTargetEnabled(s.id, v) } },
|
||||||
|
onEdit = { editingSms = s }, onDelete = { app.appScope.launch { app.settings.deleteSmsTarget(s.id) } },
|
||||||
|
onMoveUp = { app.appScope.launch { app.settings.moveSmsTarget(s.id, true) } },
|
||||||
|
onMoveDown = { app.appScope.launch { app.settings.moveSmsTarget(s.id, false) } },
|
||||||
|
onTest = null,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
AddButton { showAddSms = true }
|
}
|
||||||
|
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))
|
Spacer(Modifier.size(8.dp))
|
||||||
val balanceText = when {
|
val balanceText = when {
|
||||||
smsSuspended -> "已挂起(余额不足)"
|
smsSuspended -> "已挂起(余额不足)"
|
||||||
smsBalance < 0 -> "余额未知"
|
smsBalance < 0 -> "余额未知"
|
||||||
else -> "剩余约 $smsBalance 条"
|
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("解除挂起") }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -307,10 +444,15 @@ fun SettingsScreen(onOpenLog: () -> Unit) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 弹窗
|
// 弹窗
|
||||||
|
if (showBarkHelp) BarkHelpDialog(onDismiss = { showBarkHelp = false })
|
||||||
|
if (showNtfyHelp) NtfyHelpDialog(onDismiss = { showNtfyHelp = false })
|
||||||
|
if (showMeowHelp) MeowHelpDialog(onDismiss = { showMeowHelp = false })
|
||||||
if (showAddBark) ServerDialog(onDismiss = { showAddBark = false }, onConfirm = { n, s, k -> app.appScope.launch { app.settings.addServer(n, s, k) }; showAddBark = false })
|
if (showAddBark) ServerDialog(onDismiss = { showAddBark = false }, onConfirm = { n, s, k -> app.appScope.launch { app.settings.addServer(n, s, k) }; showAddBark = false })
|
||||||
editingBark?.let { srv -> ServerDialog(initial = srv, onDismiss = { editingBark = null }, onConfirm = { n, s, k -> app.appScope.launch { app.settings.updateServer(srv.copy(name = n, server = s, deviceKey = k)) }; editingBark = null }) }
|
editingBark?.let { srv -> ServerDialog(initial = srv, onDismiss = { editingBark = null }, onConfirm = { n, s, k -> app.appScope.launch { app.settings.updateServer(srv.copy(name = n, server = s, deviceKey = k)) }; editingBark = null }) }
|
||||||
if (showAddNtfy) NtfyConfigDialog(onDismiss = { showAddNtfy = false }, onConfirm = { n, sv, t, tk -> app.appScope.launch { app.settings.addNtfy(NtfyServer(name = n, server = sv, topic = t, token = tk)) }; showAddNtfy = false })
|
if (showAddNtfy) NtfyConfigDialog(onDismiss = { showAddNtfy = false }, onConfirm = { n, sv, t, tk -> app.appScope.launch { app.settings.addNtfy(NtfyServer(name = n, server = sv, topic = t, token = tk)) }; showAddNtfy = false })
|
||||||
editingNtfy?.let { srv -> NtfyConfigDialog(initial = srv, onDismiss = { editingNtfy = null }, onConfirm = { n, sv, t, tk -> app.appScope.launch { app.settings.updateNtfy(srv.copy(name = n, server = sv, topic = t, token = tk)) }; editingNtfy = null }) }
|
editingNtfy?.let { srv -> NtfyConfigDialog(initial = srv, onDismiss = { editingNtfy = null }, onConfirm = { n, sv, t, tk -> app.appScope.launch { app.settings.updateNtfy(srv.copy(name = n, server = sv, topic = t, token = tk)) }; editingNtfy = null }) }
|
||||||
|
if (showAddMeow) MeowConfigDialog(onDismiss = { showAddMeow = false }, onConfirm = { n, sv, k -> app.appScope.launch { app.settings.addMeow(MeowServer(name = n, server = sv, deviceKey = k)) }; showAddMeow = false })
|
||||||
|
editingMeow?.let { srv -> MeowConfigDialog(initial = srv, onDismiss = { editingMeow = null }, onConfirm = { n, sv, k -> app.appScope.launch { app.settings.updateMeow(srv.copy(name = n, server = sv, deviceKey = k)) }; editingMeow = null }) }
|
||||||
if (showAddEmail) EmailConfigDialog(onDismiss = { showAddEmail = false }, onConfirm = { c -> app.appScope.launch { app.settings.addEmail(c) }; showAddEmail = false })
|
if (showAddEmail) EmailConfigDialog(onDismiss = { showAddEmail = false }, onConfirm = { c -> app.appScope.launch { app.settings.addEmail(c) }; showAddEmail = false })
|
||||||
editingEmail?.let { srv -> EmailConfigDialog(initial = srv, onDismiss = { editingEmail = null }, onConfirm = { c -> app.appScope.launch { app.settings.updateEmail(c.copy(id = srv.id)) }; editingEmail = null }) }
|
editingEmail?.let { srv -> EmailConfigDialog(initial = srv, onDismiss = { editingEmail = null }, onConfirm = { c -> app.appScope.launch { app.settings.updateEmail(c.copy(id = srv.id)) }; editingEmail = null }) }
|
||||||
if (showAddSms) SmsConfigDialog(onDismiss = { showAddSms = false }, onConfirm = { n, num -> app.appScope.launch { app.settings.addSmsTarget(SmsTarget(name = n, number = num)) }; showAddSms = false })
|
if (showAddSms) SmsConfigDialog(onDismiss = { showAddSms = false }, onConfirm = { n, num -> app.appScope.launch { app.settings.addSmsTarget(SmsTarget(name = n, number = num)) }; showAddSms = false })
|
||||||
@@ -326,6 +468,357 @@ private fun AddButton(onClick: () -> Unit) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun ChannelSectionHeader(
|
||||||
|
title: String,
|
||||||
|
summary: String,
|
||||||
|
mark: String,
|
||||||
|
markColor: Color,
|
||||||
|
markIcon: ImageVector? = null,
|
||||||
|
markLogo: Painter? = null,
|
||||||
|
platforms: List<String>,
|
||||||
|
stopFirst: Boolean,
|
||||||
|
onStopFirstChange: (Boolean) -> Unit,
|
||||||
|
onHelpClick: (() -> Unit)? = null,
|
||||||
|
helpContentDescription: String? = null,
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier.fillMaxWidth().padding(bottom = 4.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(10.dp),
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
ChannelMark(mark = mark, tint = markColor, icon = markIcon, logoPainter = markLogo)
|
||||||
|
Spacer(Modifier.width(10.dp))
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
title,
|
||||||
|
style = MaterialTheme.typography.titleMedium,
|
||||||
|
fontWeight = FontWeight.SemiBold,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
modifier = Modifier.weight(1f, fill = false),
|
||||||
|
)
|
||||||
|
if (platforms.isNotEmpty()) {
|
||||||
|
Spacer(Modifier.width(8.dp))
|
||||||
|
Row(
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(5.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
platforms.forEach { PlatformBadge(it) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (onHelpClick != null) {
|
||||||
|
Spacer(Modifier.width(8.dp))
|
||||||
|
IconButton(onClick = onHelpClick, modifier = Modifier.size(40.dp)) {
|
||||||
|
Icon(
|
||||||
|
Icons.Filled.Info,
|
||||||
|
helpContentDescription,
|
||||||
|
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
summary,
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
)
|
||||||
|
Spacer(Modifier.width(8.dp))
|
||||||
|
DeliveryModeControl(
|
||||||
|
stopFirst = stopFirst,
|
||||||
|
onStopFirstChange = onStopFirstChange,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun ChannelMark(mark: String, tint: Color, icon: ImageVector? = null, logoPainter: Painter? = null) {
|
||||||
|
Surface(
|
||||||
|
modifier = Modifier.size(36.dp),
|
||||||
|
shape = RoundedCornerShape(10.dp),
|
||||||
|
color = tint.copy(alpha = 0.14f),
|
||||||
|
border = BorderStroke(1.dp, tint.copy(alpha = 0.28f)),
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
horizontalArrangement = Arrangement.Center,
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
if (logoPainter != null) {
|
||||||
|
Image(logoPainter, null, modifier = Modifier.size(24.dp))
|
||||||
|
} else if (icon != null) {
|
||||||
|
Icon(icon, null, tint = tint, modifier = Modifier.size(20.dp))
|
||||||
|
} else {
|
||||||
|
Text(mark, style = MaterialTheme.typography.titleSmall, fontWeight = FontWeight.Bold, color = tint, maxLines = 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun PlatformBadge(label: String) {
|
||||||
|
Surface(
|
||||||
|
shape = RoundedCornerShape(999.dp),
|
||||||
|
color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.72f),
|
||||||
|
border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant),
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
label,
|
||||||
|
modifier = Modifier.padding(horizontal = 7.dp, vertical = 3.dp),
|
||||||
|
style = MaterialTheme.typography.labelSmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
maxLines = 1,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun DeliveryModeControl(
|
||||||
|
stopFirst: Boolean,
|
||||||
|
onStopFirstChange: (Boolean) -> Unit,
|
||||||
|
) {
|
||||||
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
Text(
|
||||||
|
"模式",
|
||||||
|
style = MaterialTheme.typography.labelSmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
maxLines = 1,
|
||||||
|
)
|
||||||
|
Spacer(Modifier.width(6.dp))
|
||||||
|
DeliveryModeOption(
|
||||||
|
label = "容错",
|
||||||
|
selected = stopFirst,
|
||||||
|
shape = RoundedCornerShape(topStart = 8.dp, topEnd = 0.dp, bottomEnd = 0.dp, bottomStart = 8.dp),
|
||||||
|
onClick = { onStopFirstChange(true) },
|
||||||
|
)
|
||||||
|
DeliveryModeOption(
|
||||||
|
label = "广播",
|
||||||
|
selected = !stopFirst,
|
||||||
|
shape = RoundedCornerShape(topStart = 0.dp, topEnd = 8.dp, bottomEnd = 8.dp, bottomStart = 0.dp),
|
||||||
|
onClick = { onStopFirstChange(false) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun DeliveryModeOption(
|
||||||
|
label: String,
|
||||||
|
selected: Boolean,
|
||||||
|
shape: RoundedCornerShape,
|
||||||
|
onClick: () -> Unit,
|
||||||
|
) {
|
||||||
|
Surface(
|
||||||
|
modifier = Modifier.clickable(onClick = onClick),
|
||||||
|
shape = shape,
|
||||||
|
color = if (selected) MaterialTheme.colorScheme.primaryContainer else MaterialTheme.colorScheme.surface,
|
||||||
|
border = BorderStroke(
|
||||||
|
1.dp,
|
||||||
|
if (selected) MaterialTheme.colorScheme.primary.copy(alpha = 0.50f) else MaterialTheme.colorScheme.outlineVariant,
|
||||||
|
),
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
label,
|
||||||
|
modifier = Modifier.padding(horizontal = 9.dp, vertical = 5.dp),
|
||||||
|
style = MaterialTheme.typography.labelMedium,
|
||||||
|
fontWeight = if (selected) FontWeight.SemiBold else FontWeight.Medium,
|
||||||
|
color = if (selected) MaterialTheme.colorScheme.onPrimaryContainer else MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
maxLines = 1,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun BarkHelpDialog(onDismiss: () -> Unit) {
|
||||||
|
val uriHandler = LocalUriHandler.current
|
||||||
|
|
||||||
|
AlertDialog(
|
||||||
|
onDismissRequest = onDismiss,
|
||||||
|
icon = { Icon(Icons.Filled.Info, null) },
|
||||||
|
title = { Text("Bark 配置帮助") },
|
||||||
|
text = {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.heightIn(max = 440.dp)
|
||||||
|
.verticalScroll(rememberScrollState()),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(10.dp),
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
"Bark 用来把 a2i 处理后的 Android 通知推送到 iPhone / iPad。第一次使用按下面顺序配置即可。",
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
|
||||||
|
HelpStep("1", "在 iPhone / iPad 上安装 Bark,首次打开时允许通知权限。")
|
||||||
|
HelpStep("2", "打开 Bark,复制首页显示的推送地址,通常类似 $BARK_OFFICIAL_SERVER/你的Key。")
|
||||||
|
HelpStep("3", "回到 a2i,点击本区域底部的「添加」。服务器地址填 $BARK_OFFICIAL_SERVER,自建服务则填你的自建地址。")
|
||||||
|
HelpStep("4", "Device Key 填推送地址最后一段 Key,不要把完整 URL 都填进去。")
|
||||||
|
HelpStep("5", "保存后点服务器行里的发送按钮测试;iPhone 收到「GotMsg 测试」就说明 Bark 通道已通。")
|
||||||
|
HelpStep("6", "最后回首页开启「通知转发」,并按提示授予系统通知监听权限。")
|
||||||
|
|
||||||
|
Text(
|
||||||
|
"常用链接",
|
||||||
|
style = MaterialTheme.typography.titleSmall,
|
||||||
|
fontWeight = FontWeight.SemiBold,
|
||||||
|
modifier = Modifier.padding(top = 4.dp),
|
||||||
|
)
|
||||||
|
HelpLink("Bark App Store", BARK_APP_STORE_URL) { uriHandler.openUri(it) }
|
||||||
|
HelpLink("Bark 官方文档", BARK_DOCS_URL) { uriHandler.openUri(it) }
|
||||||
|
HelpLink("Bark GitHub", BARK_GITHUB_URL) { uriHandler.openUri(it) }
|
||||||
|
|
||||||
|
Text(
|
||||||
|
"提示:如果使用自建 Bark Server,a2i 里的服务器地址要换成自建地址,但 Device Key 仍然来自 Bark App。",
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
modifier = Modifier.padding(top = 2.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
confirmButton = { TextButton(onClick = onDismiss) { Text("知道了") } },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun NtfyHelpDialog(onDismiss: () -> Unit) {
|
||||||
|
val uriHandler = LocalUriHandler.current
|
||||||
|
|
||||||
|
AlertDialog(
|
||||||
|
onDismissRequest = onDismiss,
|
||||||
|
icon = { Icon(Icons.Filled.Info, null) },
|
||||||
|
title = { Text("ntfy 配置帮助") },
|
||||||
|
text = {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.heightIn(max = 440.dp)
|
||||||
|
.verticalScroll(rememberScrollState()),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(10.dp),
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
"ntfy 用来把 a2i 处理后的通知推送到 Android、iOS 或网页版 ntfy。它不需要先建项目,只要接收端和 a2i 使用同一个 server + topic 即可。",
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
|
||||||
|
HelpStep("1", "在接收端安装 ntfy。Android 可用 Google Play / F-Droid / GitHub APK,iPhone / iPad 可用 App Store。")
|
||||||
|
HelpStep("2", "打开 ntfy,添加订阅。服务器通常填 $NTFY_OFFICIAL_SERVER;Topic 建议用一串不容易猜到的随机名字,例如 a2i_xxxxxxxx。")
|
||||||
|
HelpStep("3", "回到 a2i,点击本区域底部的「添加」。服务器和 Topic 要与接收端订阅完全一致。")
|
||||||
|
HelpStep("4", "Token 是可选项。只有你在 ntfy 账号或自建服务里把 topic 设成需要鉴权时,才填写 access token。")
|
||||||
|
HelpStep("5", "保存后点 topic 行里的发送按钮测试;接收端收到「GotMsg 测试」就说明 ntfy 通道已通。")
|
||||||
|
HelpStep("6", "后续只要这一行左侧保持勾选,它就会参与转发;取消勾选就是停用这个 topic。")
|
||||||
|
|
||||||
|
Text(
|
||||||
|
"常用链接",
|
||||||
|
style = MaterialTheme.typography.titleSmall,
|
||||||
|
fontWeight = FontWeight.SemiBold,
|
||||||
|
modifier = Modifier.padding(top = 4.dp),
|
||||||
|
)
|
||||||
|
HelpLink("ntfy Android · Google Play", NTFY_ANDROID_URL) { uriHandler.openUri(it) }
|
||||||
|
HelpLink("ntfy Android · F-Droid", NTFY_FDROID_URL) { uriHandler.openUri(it) }
|
||||||
|
HelpLink("ntfy iOS · App Store", NTFY_IOS_URL) { uriHandler.openUri(it) }
|
||||||
|
HelpLink("ntfy 手机端文档", NTFY_DOCS_URL) { uriHandler.openUri(it) }
|
||||||
|
HelpLink("ntfy 发布 API 文档", NTFY_PUBLISH_DOCS_URL) { uriHandler.openUri(it) }
|
||||||
|
HelpLink("ntfy GitHub", NTFY_GITHUB_URL) { uriHandler.openUri(it) }
|
||||||
|
|
||||||
|
Text(
|
||||||
|
"提示:公共 topic 谁知道名字谁就可能订阅或发送,所以 topic 名不要用手机号、姓名、邮箱等可猜信息。",
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
modifier = Modifier.padding(top = 2.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
confirmButton = { TextButton(onClick = onDismiss) { Text("知道了") } },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun MeowHelpDialog(onDismiss: () -> Unit) {
|
||||||
|
AlertDialog(
|
||||||
|
onDismissRequest = onDismiss,
|
||||||
|
icon = { Icon(Icons.Filled.Info, null, tint = Color(0xFFFF9800)) },
|
||||||
|
title = { Text("Meow 配置帮助") },
|
||||||
|
text = {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.heightIn(max = 440.dp)
|
||||||
|
.verticalScroll(rememberScrollState()),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(10.dp),
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
"Meow 用来把 a2i 处理后的 Android 通知推送到鸿蒙手机。a2i 只负责按你填写的 Meow 接口地址和 Device Key 发出 HTTP 请求。",
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
|
||||||
|
HelpStep("1", "在鸿蒙手机上安装并打开 Meow,先确认 Meow 自身可以正常接收推送。")
|
||||||
|
HelpStep("2", "在 Meow 里找到推送接口、服务器地址或 API 地址,同时复制对应的 Device Key / Token。")
|
||||||
|
HelpStep("3", "回到 a2i,点击本区域底部的「添加」。名称按你的鸿蒙设备填写,方便以后区分。")
|
||||||
|
HelpStep("4", "服务器地址可以填 Meow 给出的完整 API 地址;如果 Meow 只给了服务根地址,a2i 也会尝试常见的 /push 路径。")
|
||||||
|
HelpStep("5", "Device Key 填 Meow 给你的设备 Key 或 Token,不要填写鸿蒙手机锁屏密码、华为账号密码等敏感账号密码。")
|
||||||
|
HelpStep("6", "保存后点设备行里的发送按钮测试;鸿蒙手机收到「GotMsg 测试」就说明 Meow 通道已通。")
|
||||||
|
HelpStep("7", "后续只要这一行左侧保持勾选,它就会参与转发;取消勾选就是停用这个鸿蒙设备。")
|
||||||
|
|
||||||
|
Text(
|
||||||
|
"提示:如果测试失败,优先检查 Meow 里的接口地址是否完整、Key 是否复制完整,以及鸿蒙手机是否允许 Meow 通知。",
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
modifier = Modifier.padding(top = 2.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
confirmButton = { TextButton(onClick = onDismiss) { Text("知道了") } },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun HelpStep(
|
||||||
|
index: String,
|
||||||
|
text: String,
|
||||||
|
) {
|
||||||
|
Row(verticalAlignment = Alignment.Top) {
|
||||||
|
Text(
|
||||||
|
"$index.",
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
fontWeight = FontWeight.SemiBold,
|
||||||
|
color = MaterialTheme.colorScheme.primary,
|
||||||
|
modifier = Modifier.width(24.dp),
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text,
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun HelpLink(
|
||||||
|
title: String,
|
||||||
|
url: String,
|
||||||
|
onOpen: (String) -> Unit,
|
||||||
|
) {
|
||||||
|
OutlinedButton(
|
||||||
|
onClick = { onOpen(url) },
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
) {
|
||||||
|
Text(title, maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/** 通用配置行(ntfy/电邮/短信 共用,仿 ServerRow 简化版)。 */
|
/** 通用配置行(ntfy/电邮/短信 共用,仿 ServerRow 简化版)。 */
|
||||||
@Composable
|
@Composable
|
||||||
private fun ConfigRow(
|
private fun ConfigRow(
|
||||||
@@ -358,7 +851,87 @@ private fun ConfigRow(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ===== Bark 服务器 Row + Dialog(保留原有) =====
|
// ===== ntfy / Bark Row + Dialog =====
|
||||||
|
@Composable
|
||||||
|
private fun MeowServerRow(
|
||||||
|
server: MeowServer, isFirst: Boolean, isLast: Boolean,
|
||||||
|
onToggle: (Boolean) -> Unit, onEdit: () -> Unit, onDelete: () -> Unit,
|
||||||
|
onMoveUp: () -> Unit, onMoveDown: () -> Unit,
|
||||||
|
) {
|
||||||
|
val app = A2iApp.instance
|
||||||
|
var testing by remember { mutableStateOf(false) }
|
||||||
|
var testMsg by remember { mutableStateOf<String?>(null) }
|
||||||
|
Surface(
|
||||||
|
modifier = Modifier.fillMaxWidth(), shape = RoundedCornerShape(8.dp),
|
||||||
|
color = if (server.enabled) MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.40f) else MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.36f),
|
||||||
|
border = BorderStroke(1.dp, if (server.enabled) MaterialTheme.colorScheme.primary.copy(alpha = 0.30f) else MaterialTheme.colorScheme.outlineVariant),
|
||||||
|
) {
|
||||||
|
Column(Modifier.padding(start = 4.dp, end = 4.dp, top = 2.dp, bottom = 6.dp)) {
|
||||||
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
Checkbox(checked = server.enabled, onCheckedChange = onToggle)
|
||||||
|
Row(Modifier.weight(1f), verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
Text(server.name.ifBlank { "鸿蒙手机" }, style = MaterialTheme.typography.bodyLarge, fontWeight = FontWeight.Medium, maxLines = 1, overflow = TextOverflow.Ellipsis, modifier = Modifier.weight(1f, fill = false))
|
||||||
|
if (!server.enabled) { Spacer(Modifier.width(8.dp)); StatusBadge("停用", MaterialTheme.colorScheme.onSurfaceVariant) }
|
||||||
|
}
|
||||||
|
IconButton(onClick = { if (!testing) app.appScope.launch { testing = true; testMsg = testMeow(server); testing = false } }, enabled = !testing) {
|
||||||
|
if (testing) CircularProgressIndicator(Modifier.size(18.dp), strokeWidth = 2.dp) else Icon(Icons.AutoMirrored.Filled.Send, "测试")
|
||||||
|
}
|
||||||
|
IconButton(onClick = onEdit) { Icon(Icons.Filled.Edit, "编辑") }
|
||||||
|
IconButton(onClick = onDelete) { Icon(Icons.Filled.Delete, "删除") }
|
||||||
|
}
|
||||||
|
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.padding(top = 2.dp)) {
|
||||||
|
Column(Modifier.weight(1f).padding(start = 36.dp)) {
|
||||||
|
Text(server.server.ifBlank { "服务器地址未填" }, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, maxLines = 2, overflow = TextOverflow.Ellipsis)
|
||||||
|
Text("device key: " + if (server.deviceKey.isBlank()) "未填" else "已填(${server.deviceKey.length} 位)", style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||||
|
}
|
||||||
|
IconButton(onClick = onMoveUp, enabled = !isFirst) { Icon(Icons.Filled.KeyboardArrowUp, "上移") }
|
||||||
|
IconButton(onClick = onMoveDown, enabled = !isLast) { Icon(Icons.Filled.KeyboardArrowDown, "下移") }
|
||||||
|
}
|
||||||
|
testMsg?.let { Text(it, style = MaterialTheme.typography.bodySmall, color = if (it.startsWith("已发送")) MaterialTheme.colorScheme.tertiary else MaterialTheme.colorScheme.error, modifier = Modifier.padding(start = 48.dp, top = 2.dp)) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun NtfyServerRow(
|
||||||
|
server: NtfyServer, isFirst: Boolean, isLast: Boolean,
|
||||||
|
onToggle: (Boolean) -> Unit, onEdit: () -> Unit, onDelete: () -> Unit,
|
||||||
|
onMoveUp: () -> Unit, onMoveDown: () -> Unit,
|
||||||
|
) {
|
||||||
|
val app = A2iApp.instance
|
||||||
|
var testing by remember { mutableStateOf(false) }
|
||||||
|
var testMsg by remember { mutableStateOf<String?>(null) }
|
||||||
|
Surface(
|
||||||
|
modifier = Modifier.fillMaxWidth(), shape = RoundedCornerShape(8.dp),
|
||||||
|
color = if (server.enabled) MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.40f) else MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.36f),
|
||||||
|
border = BorderStroke(1.dp, if (server.enabled) MaterialTheme.colorScheme.primary.copy(alpha = 0.30f) else MaterialTheme.colorScheme.outlineVariant),
|
||||||
|
) {
|
||||||
|
Column(Modifier.padding(start = 4.dp, end = 4.dp, top = 2.dp, bottom = 6.dp)) {
|
||||||
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
Checkbox(checked = server.enabled, onCheckedChange = onToggle)
|
||||||
|
Row(Modifier.weight(1f), verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
Text(server.name.ifBlank { server.topic.ifBlank { "未命名" } }, style = MaterialTheme.typography.bodyLarge, fontWeight = FontWeight.Medium, maxLines = 1, overflow = TextOverflow.Ellipsis, modifier = Modifier.weight(1f, fill = false))
|
||||||
|
if (!server.enabled) { Spacer(Modifier.width(8.dp)); StatusBadge("停用", MaterialTheme.colorScheme.onSurfaceVariant) }
|
||||||
|
}
|
||||||
|
IconButton(onClick = { if (!testing) app.appScope.launch { testing = true; testMsg = testNtfy(server); testing = false } }, enabled = !testing) {
|
||||||
|
if (testing) CircularProgressIndicator(Modifier.size(18.dp), strokeWidth = 2.dp) else Icon(Icons.AutoMirrored.Filled.Send, "测试")
|
||||||
|
}
|
||||||
|
IconButton(onClick = onEdit) { Icon(Icons.Filled.Edit, "编辑") }
|
||||||
|
IconButton(onClick = onDelete) { Icon(Icons.Filled.Delete, "删除") }
|
||||||
|
}
|
||||||
|
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.padding(top = 2.dp)) {
|
||||||
|
Column(Modifier.weight(1f).padding(start = 36.dp)) {
|
||||||
|
Text(server.server, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, maxLines = 2, overflow = TextOverflow.Ellipsis)
|
||||||
|
Text("topic: " + server.topic.ifBlank { "未填" } + if (server.token.isNotBlank()) " · token已填" else "", style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSurfaceVariant, maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||||
|
}
|
||||||
|
IconButton(onClick = onMoveUp, enabled = !isFirst) { Icon(Icons.Filled.KeyboardArrowUp, "上移") }
|
||||||
|
IconButton(onClick = onMoveDown, enabled = !isLast) { Icon(Icons.Filled.KeyboardArrowDown, "下移") }
|
||||||
|
}
|
||||||
|
testMsg?.let { Text(it, style = MaterialTheme.typography.bodySmall, color = if (it.startsWith("已发送")) MaterialTheme.colorScheme.tertiary else MaterialTheme.colorScheme.error, modifier = Modifier.padding(start = 48.dp, top = 2.dp)) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun ServerRow(
|
private fun ServerRow(
|
||||||
server: BarkServer, isFirst: Boolean, isLast: Boolean,
|
server: BarkServer, isFirst: Boolean, isLast: Boolean,
|
||||||
@@ -399,6 +972,52 @@ private fun ServerRow(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun EmailServerRow(
|
||||||
|
server: EmailServer, isFirst: Boolean, isLast: Boolean,
|
||||||
|
onToggle: (Boolean) -> Unit, onEdit: () -> Unit, onDelete: () -> Unit,
|
||||||
|
onMoveUp: () -> Unit, onMoveDown: () -> Unit,
|
||||||
|
) {
|
||||||
|
val app = A2iApp.instance
|
||||||
|
var testing by remember { mutableStateOf(false) }
|
||||||
|
var testMsg by remember { mutableStateOf<String?>(null) }
|
||||||
|
Surface(
|
||||||
|
modifier = Modifier.fillMaxWidth(), shape = RoundedCornerShape(8.dp),
|
||||||
|
color = if (server.enabled) MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.40f) else MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.36f),
|
||||||
|
border = BorderStroke(1.dp, if (server.enabled) MaterialTheme.colorScheme.primary.copy(alpha = 0.30f) else MaterialTheme.colorScheme.outlineVariant),
|
||||||
|
) {
|
||||||
|
Column(Modifier.padding(start = 4.dp, end = 4.dp, top = 2.dp, bottom = 6.dp)) {
|
||||||
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
Checkbox(checked = server.enabled, onCheckedChange = onToggle)
|
||||||
|
Row(Modifier.weight(1f), verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
Text(server.name.ifBlank { server.to.ifBlank { "未命名" } }, style = MaterialTheme.typography.bodyLarge, fontWeight = FontWeight.Medium, maxLines = 1, overflow = TextOverflow.Ellipsis, modifier = Modifier.weight(1f, fill = false))
|
||||||
|
if (!server.enabled) { Spacer(Modifier.width(8.dp)); StatusBadge("停用", MaterialTheme.colorScheme.onSurfaceVariant) }
|
||||||
|
}
|
||||||
|
IconButton(onClick = { if (!testing) app.appScope.launch { testing = true; testMsg = testEmail(server); testing = false } }, enabled = !testing) {
|
||||||
|
if (testing) CircularProgressIndicator(Modifier.size(18.dp), strokeWidth = 2.dp) else Icon(Icons.AutoMirrored.Filled.Send, "测试")
|
||||||
|
}
|
||||||
|
IconButton(onClick = onEdit) { Icon(Icons.Filled.Edit, "编辑") }
|
||||||
|
IconButton(onClick = onDelete) { Icon(Icons.Filled.Delete, "删除") }
|
||||||
|
}
|
||||||
|
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.padding(top = 2.dp)) {
|
||||||
|
Column(Modifier.weight(1f).padding(start = 36.dp)) {
|
||||||
|
Text("${server.host}:${server.port}", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||||
|
Text(server.to.ifBlank { "收件人未填" }, style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSurfaceVariant, maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||||
|
}
|
||||||
|
IconButton(onClick = onMoveUp, enabled = !isFirst) { Icon(Icons.Filled.KeyboardArrowUp, "上移") }
|
||||||
|
IconButton(onClick = onMoveDown, enabled = !isLast) { Icon(Icons.Filled.KeyboardArrowDown, "下移") }
|
||||||
|
}
|
||||||
|
testMsg?.let { Text(it, style = MaterialTheme.typography.bodySmall, color = if (it.startsWith("已发送")) MaterialTheme.colorScheme.tertiary else MaterialTheme.colorScheme.error, modifier = Modifier.padding(start = 48.dp, top = 2.dp)) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun testEmail(server: EmailServer): String {
|
||||||
|
if (server.host.isBlank() || server.to.isBlank()) return "SMTP 主机或收件人未填"
|
||||||
|
val r = EmailSender.send(server.host, server.port, server.user, server.password, server.from, server.to, "GotMsg 测试", "电邮配置测试")
|
||||||
|
return if (r.isSuccess) "已发送,请查看邮箱" else "发送失败:${r.exceptionOrNull()?.message}"
|
||||||
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun ServerDialog(initial: BarkServer? = null, onDismiss: () -> Unit, onConfirm: (String, String, String) -> Unit) {
|
private fun ServerDialog(initial: BarkServer? = null, onDismiss: () -> Unit, onConfirm: (String, String, String) -> Unit) {
|
||||||
var name by remember { mutableStateOf(initial?.name ?: "") }
|
var name by remember { mutableStateOf(initial?.name ?: "") }
|
||||||
@@ -416,7 +1035,7 @@ private fun ServerDialog(initial: BarkServer? = null, onDismiss: () -> Unit, onC
|
|||||||
@Composable
|
@Composable
|
||||||
private fun NtfyConfigDialog(initial: NtfyServer? = null, onDismiss: () -> Unit, onConfirm: (String, String, String, String) -> Unit) {
|
private fun NtfyConfigDialog(initial: NtfyServer? = null, onDismiss: () -> Unit, onConfirm: (String, String, String, String) -> Unit) {
|
||||||
var name by remember { mutableStateOf(initial?.name ?: "") }
|
var name by remember { mutableStateOf(initial?.name ?: "") }
|
||||||
var server by remember { mutableStateOf(initial?.server ?: "https://ntfy.sh") }
|
var server by remember { mutableStateOf(initial?.server ?: NTFY_OFFICIAL_SERVER) }
|
||||||
var topic by remember { mutableStateOf(initial?.topic ?: "") }
|
var topic by remember { mutableStateOf(initial?.topic ?: "") }
|
||||||
var token by remember { mutableStateOf(initial?.token ?: "") }
|
var token by remember { mutableStateOf(initial?.token ?: "") }
|
||||||
ConfigDialogSkeleton(title = if (initial == null) "添加 ntfy" else "编辑 ntfy", onDismiss = onDismiss, onConfirm = { onConfirm(name.trim(), server.trim().removeSuffix("/"), topic.trim(), token.trim()) }) {
|
ConfigDialogSkeleton(title = if (initial == null) "添加 ntfy" else "编辑 ntfy", onDismiss = onDismiss, onConfirm = { onConfirm(name.trim(), server.trim().removeSuffix("/"), topic.trim(), token.trim()) }) {
|
||||||
@@ -430,6 +1049,20 @@ private fun NtfyConfigDialog(initial: NtfyServer? = null, onDismiss: () -> Unit,
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun MeowConfigDialog(initial: MeowServer? = null, onDismiss: () -> Unit, onConfirm: (String, String, String) -> Unit) {
|
||||||
|
var name by remember { mutableStateOf(initial?.name ?: "") }
|
||||||
|
var server by remember { mutableStateOf(initial?.server ?: "") }
|
||||||
|
var key by remember { mutableStateOf(initial?.deviceKey ?: "") }
|
||||||
|
ConfigDialogSkeleton(title = if (initial == null) "添加 Meow" else "编辑 Meow", onDismiss = onDismiss, onConfirm = { onConfirm(name.trim(), server.trim().removeSuffix("/"), key.trim()) }) {
|
||||||
|
OutlinedTextField(name, { name = it }, label = { Text("名称") }, singleLine = true, modifier = Modifier.fillMaxWidth())
|
||||||
|
Spacer(Modifier.size(8.dp))
|
||||||
|
OutlinedTextField(server, { server = it }, label = { Text("服务器 / API 地址") }, singleLine = true, modifier = Modifier.fillMaxWidth(), supportingText = { Text("填写 Meow 提供的完整 API 地址或服务根地址") })
|
||||||
|
Spacer(Modifier.size(8.dp))
|
||||||
|
OutlinedTextField(key, { key = it }, label = { Text("Device Key / Token") }, singleLine = true, modifier = Modifier.fillMaxWidth())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun EmailConfigDialog(initial: EmailServer? = null, onDismiss: () -> Unit, onConfirm: (EmailServer) -> Unit) {
|
private fun EmailConfigDialog(initial: EmailServer? = null, onDismiss: () -> Unit, onConfirm: (EmailServer) -> Unit) {
|
||||||
var name by remember { mutableStateOf(initial?.name ?: "") }
|
var name by remember { mutableStateOf(initial?.name ?: "") }
|
||||||
@@ -482,7 +1115,20 @@ private fun ConfigDialogSkeleton(title: String, onDismiss: () -> Unit, onConfirm
|
|||||||
|
|
||||||
private suspend fun testPush(app: A2iApp, server: BarkServer): String {
|
private suspend fun testPush(app: A2iApp, server: BarkServer): String {
|
||||||
if (server.deviceKey.isBlank()) return "未填写 device key"
|
if (server.deviceKey.isBlank()) return "未填写 device key"
|
||||||
val msg = BarkMessage(deviceKey = server.deviceKey, title = "a2i 测试", body = "如果你在 iOS 看到这条推送,说明配置成功", level = "active")
|
val msg = BarkMessage(deviceKey = server.deviceKey, title = "GotMsg 测试", body = "如果你在 iOS 看到这条推送,说明配置成功", level = "active")
|
||||||
val r = BarkClient(server).push(msg)
|
val r = BarkClient(server).push(msg)
|
||||||
return if (r.isSuccess) "已发送,请查看 iOS 端" else "发送失败:${r.exceptionOrNull()?.message}"
|
return if (r.isSuccess) "已发送,请查看 iOS 端" else "发送失败:${r.exceptionOrNull()?.message}"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private suspend fun testNtfy(server: NtfyServer): String {
|
||||||
|
if (server.topic.isBlank()) return "未填写 topic"
|
||||||
|
val r = NtfySender.send(server.server, server.topic, server.token, "GotMsg 测试", "如果你在 ntfy 看到这条推送,说明配置成功")
|
||||||
|
return if (r.isSuccess) "已发送,请查看 ntfy 接收端" else "发送失败:${r.exceptionOrNull()?.message}"
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun testMeow(server: MeowServer): String {
|
||||||
|
if (server.server.isBlank()) return "未填写服务器地址"
|
||||||
|
if (server.deviceKey.isBlank()) return "未填写 Device Key"
|
||||||
|
val r = MeowSender.send(server.server, server.deviceKey, "GotMsg 测试", "如果你在鸿蒙 Meow 看到这条推送,说明配置成功")
|
||||||
|
return if (r.isSuccess) "已发送,请查看鸿蒙 Meow" else "发送失败:${r.exceptionOrNull()?.message}"
|
||||||
|
}
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ fun UpdateDialog(info: UpdateInfo, onDismiss: () -> Unit) {
|
|||||||
val app = A2iApp.instance
|
val app = A2iApp.instance
|
||||||
val downloading by app.updateChecker.downloading.collectAsState()
|
val downloading by app.updateChecker.downloading.collectAsState()
|
||||||
val progress by app.updateChecker.downloadProgress.collectAsState()
|
val progress by app.updateChecker.downloadProgress.collectAsState()
|
||||||
|
val downloadSource by app.updateChecker.downloadSource.collectAsState()
|
||||||
var downloadedFile by remember { mutableStateOf<File?>(null) }
|
var downloadedFile by remember { mutableStateOf<File?>(null) }
|
||||||
|
|
||||||
AlertDialog(
|
AlertDialog(
|
||||||
@@ -55,7 +56,11 @@ fun UpdateDialog(info: UpdateInfo, onDismiss: () -> Unit) {
|
|||||||
.heightIn(max = 240.dp)
|
.heightIn(max = 240.dp)
|
||||||
.verticalScroll(rememberScrollState()),
|
.verticalScroll(rememberScrollState()),
|
||||||
) {
|
) {
|
||||||
val notes = info.releaseNotes.ifBlank { "(作者未提供更新说明)" }
|
val notes = info.releaseNotes
|
||||||
|
.lineSequence()
|
||||||
|
.filterNot { it.contains("update-source") }
|
||||||
|
.joinToString("\n")
|
||||||
|
.ifBlank { "(作者未提供更新说明)" }
|
||||||
Text(notes, style = MaterialTheme.typography.bodyMedium)
|
Text(notes, style = MaterialTheme.typography.bodyMedium)
|
||||||
}
|
}
|
||||||
if (downloading) {
|
if (downloading) {
|
||||||
@@ -66,7 +71,7 @@ fun UpdateDialog(info: UpdateInfo, onDismiss: () -> Unit) {
|
|||||||
)
|
)
|
||||||
Spacer(Modifier.size(4.dp))
|
Spacer(Modifier.size(4.dp))
|
||||||
Text(
|
Text(
|
||||||
"$progress%",
|
"正在下载更新…${downloadSource?.let { "(来自 $it)" } ?: ""}",
|
||||||
style = MaterialTheme.typography.bodySmall,
|
style = MaterialTheme.typography.bodySmall,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
)
|
)
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 7.1 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 25 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 6.5 KiB |
@@ -1,3 +1,3 @@
|
|||||||
<resources>
|
<resources>
|
||||||
<string name="app_name">a2i</string>
|
<string name="app_name">GotMsg 有消息</string>
|
||||||
</resources>
|
</resources>
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 521 KiB |
Reference in New Issue
Block a user