6 Commits

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

版本 1.3.0 → 1.4.0

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

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

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-07 09:25:51 +08:00
song ab113e5691 docs: a2i · 安到果
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-06 21:24:05 +08:00
SJZ bcc7ed298f Update README.md 2026-07-06 20:41:16 +08:00
39 changed files with 534 additions and 2570 deletions
-6
View File
@@ -8,11 +8,6 @@ app/build/
local.properties local.properties
.do-session .do-session
# Secrets — never commit
gh-token.txt
gitea-token.txt
*.token
# Android Studio / IntelliJ # Android Studio / IntelliJ
.idea/ .idea/
*.iml *.iml
@@ -32,4 +27,3 @@ test.json
# Local assistant session helpers # Local assistant session helpers
.claude/ .claude/
do.ps1 do.ps1
CLAUDE.md
+108
View File
@@ -0,0 +1,108 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## 项目概述
**a2i** — 安卓通知转发到 iOS Bark。监听 Android 系统通知(短信、验证码、聊天等),通过 Bark Server 推送到 iOS 设备。iOS 端点击通知可跳转到对应 App(微信、QQ、Telegram)。
## 环境配置(本机)
环境变量需要在每次 shell 调用前显式导出:
```bash
export JAVA_HOME="D:/Dev/JDK-17"
export ANDROID_HOME="D:/Dev/Android/SDK"
```
- JDK 17: `D:\Dev\JDK-17`(项目使用 `sourceCompatibility = VERSION_17`
- Android SDK: `D:\Dev\Android\SDK`
- Gradle 8.11.1wrapper 内置)
- AGP 8.9.1 / Kotlin 2.1.0
`local.properties` 已在项目根(`sdk.dir=D:\Dev\Android\SDK`),**不要提交**。
## 构建与安装
```bash
# 编译 debug APK
export JAVA_HOME="D:/Dev/JDK-17" ANDROID_HOME="D:/Dev/Android/SDK"
./gradlew assembleDebug
# 产物位置
app/build/outputs/apk/debug/app-debug.apk
# 安装到已连接的设备
"D:/Dev/Android/SDK/platform-tools/adb.exe" install -r app/build/outputs/apk/debug/app-debug.apk
```
无 lint / 测试任务配置(项目未启用)。改完代码直接 `assembleDebug` 验证即可。
构建可分发的 release APK(已绑定 debug 签名,产出可直接安装):
```bash
export JAVA_HOME="D:/Dev/JDK-17" ANDROID_HOME="D:/Dev/Android/SDK"
./gradlew assembleRelease
# 产物:app/build/outputs/apk/release/app-release.apk
adb install -r app/build/outputs/apk/release/app-release.apk
```
> `release` 复用 `debug` 签名配置(`signingConfigs.getByName("debug")`),适合个人发布到 GitHub Release;正式商店上架前需替换为独立的 release keystore。
## 架构
### 核心数据流
```
NotifyListenerService (系统通知入口)
→ NotificationProcessor.process(sbn) → Decision(message, reason, code)
→ BarkClient.push(msg) → OkHttp POST
→ LogStore.addLog(...)
失败 → 加入 pending → RetryWorker (WorkManager, 15min 周期)
```
### 包结构 `com.a2i.forwarder`
| 文件 | 职责 |
|---|---|
| `A2iApp.kt` | `Application` 单例;持有全局 `appScope` 和三个 Store`settings``appRules``logStore`)。Store 都用 `MutableStateFlow`UI 通过 `collectAsState()` 订阅。 |
| `service/NotifyListenerService.kt` | `NotificationListenerService` 实现;每条通知调 `processor.process()`,过滤后推送。 |
| `core/NotificationProcessor.kt` | **核心决策点**。包含 LRU 去重缓存(5s)、脱敏检测(`*****` → 回退到 `tickerText`)、系统状态消息过滤、广告过滤、验证码提取、特殊 App 解析。返回 `Decision(message?, reason, appLabel, code)`。 |
| `core/CodeExtractor.kt` | 三段正则:关键词+数字、数字+关键词、纯数字 4-8 位。检测到全 `*****` 时直接返回 null。 |
| `core/AdFilter.kt` | 内置营销关键词 + 用户自定义关键词(`settings.adKeywords`)。 |
| `core/AppParsers.kt` | 微信/QQ/Telegram 的 `clickUrl` 映射 + 重要消息关键词(@我、验证码、通话等)。 |
| `core/BarkClient.kt` | OkHttp POST 到 `${server}/push`,超时 10/10/15s。 |
| `core/RetryWorker.kt` | WorkManager 周期任务,每 15 分钟重发 `pending` 队列中的失败消息。 |
| `core/LogStore.kt` | 最近 300 条日志 + 失败重发队列(≤100)。DataStore 持久化。 |
| `core/IconExporter.kt` | 一键导出所有已安装 App 图标到用户选择的 SAF 目录(用于部署到 iOS Bark 图床)。 |
| `store/SettingsStore.kt` | Bark 服务器列表、全局开关、广告关键词等。`MutableStateFlow` + DataStore。 |
| `store/AppRulesStore.kt` | 黑/白名单模式 + 默认黑名单(系统 App + 三星剪贴板/键盘 + GMS)。 |
| `ui/nav/AppNav.kt` | 4 个底部 Tab + 日志页。 |
| `ui/theme/*` | 固定品牌配色(`Color.kt`)、自定义 `Typography``Type.kt`),`A2iTheme` 关闭动态取色、统一深浅色方案(`Theme.kt`)。 |
| `ui/screens/*.kt` | HomeScreen(品牌横幅 + 运行状态面板 + 统计指标)、AppManageScreen、FilterScreen、SettingsScreen、LogScreen。 |
| `ui/Components.kt` | 共享 UI 组件层:`ScreenTitle``SectionCard``SwitchRow``ClickRow``IconBox``StatusBadge``MetricBlock`,以及 `appVersionName(context)``drawableToImageBitmap(d)``isNotificationListenerEnabled(context)` 工具函数。 |
### 重要约定
- **Store 全局单例**:所有 Store 通过 `A2iApp.instance.xxx` 访问。**不要**在 Compose 中创建新的 Store 实例。
- **协程作用域**:跨屏调用用 `app.appScope.launch { ... }`Application 级别的 `SupervisorJob + Dispatchers.Default`)。
- **过滤流水线**(顺序很重要,在 `NotificationProcessor.process` 中):
1. 全局开关 → 2. ongoing 过滤 → 3. App 黑/白名单 → 4. 空内容/脱敏 → 5. 噪音包名 → 6. 系统状态消息 → 7. 近空内容 → 8. 广告过滤 → **9. 去重检查(5s 窗口)** → 10. 验证码提取 → 11. 特殊 App 解析 → 12. 构造 BarkMessage
- **Bark API** 字段映射在 `BarkMessage.kt``@SerialName`):`device_key`/`autoCopy`/`isArchive` 用下划线/驼峰别名,其他用蛇形。
- **版本号**`app/build.gradle.kts``versionCode`/`versionName`,运行时通过 `appVersionName(context)` 读取,首页底部居中显示 `v{version}`。改完代码记得同步递增。
### 通知监听授权
监听服务依赖 `Settings.ACTION_NOTIFICATION_LISTENER_SETTINGS` 授权。`HomeScreen` 检测授权状态,未授权时显示跳转按钮。授权是**一次性**的,卸载后需重新授权。
### 调试日志
`NotifyListenerService.handle()` 每次都会写一条 `ForwardLog``LogStore`(无论转发/过滤/失败),可在「日志」页查看最近 300 条。失败会自动加入 `pending` 队列,由 `RetryWorker` 后续重发。
## 版本与发布
- 当前版本:`versionCode = 3``versionName = "1.2.0"`(位于 `app/build.gradle.kts`)。改完代码记得同步递增。
- 远端仓库:`https://github.com/lsxf/a2i.git`,默认分支 `main`
- 提交策略:`.gitignore` 已排除 `app/build``.gradle``.kotlin``local.properties``.claude/``do.ps1`、截图与临时文件,**不要**提交它们。
- 发版流程:本地打 tag`git tag -a v1.2.0 -m "..."`)→ `git push origin v1.2.0` → 在 GitHub Releases 基于 tag 发布,并上传 `app-release.apk` 作为二进制附件。
- UI 现状:统一品牌科技感主题(固定品牌色 + 自定义 Typography + 共享组件层),深浅色双方案,关闭动态取色;新组件统一通过 `ui/Components.kt` 复用,避免各页面各拼一套样式。
+9 -46
View File
@@ -1,15 +1,13 @@
# GotMsg ·有消息 # A2i ·安到果(按倒过[呲牙]
把安卓手机上的通知(短信、验证码、电话“有电话进来这个通知,不是电话呼叫转移”、微信、QQ、Telegram 等)实时转发到 iOS / Android / 鸿蒙等接收端。iOS 可通过 [Bark](https://github.com/Finb/Bark) 推送到 iPhone / iPadAndroid 可用 ntfy;鸿蒙可用 Meow。在 iOS 上点击通知,还能一键跳转到对应的 App。 把安卓手机上的通知(短信、验证码、电话“有电话进来这个通知,不是电话呼叫转移”、微信、QQ、Telegram 等)实时转发到 iOS通过 [Bark](https://github.com/Finb/Bark) 推送到 iPhone / iPad。在 iOS 上点击通知,还能一键跳转到对应的 App。
适用于把安卓备机的消息同步到 iPhone 或其他安卓 / 鸿蒙手机,不漏验证码、不漏重要聊天。 适用于把安卓备机的消息同步到 iOS 设备,不漏验证码、不漏重要聊天。
![GotMsg 设置页](screenshots/overview.png)
## 主要功能 ## 主要功能
- **系统级通知监听**:接入 Android 通知监听服务,覆盖所有发出通知的 App。 - **系统级通知监听**:接入 Android 通知监听服务,覆盖所有发出通知的 App。
- **多通道推送**:支持 Bark、ntfy、Meow、电邮和短信兜底;每个通道可保存多个目标,勾选参与转发,并支持“发通一条就停”或全部广播 - **Bark 推送**:支持官方 Bark 服务和自建 Bark Server,可保存多个服务器并随时切换
- **验证码自动提取**:自动识别通知中的验证码,收到即复制,也可点击复制。 - **验证码自动提取**:自动识别通知中的验证码,收到即复制,也可点击复制。
- **通知优化** - **通知优化**
- 广告关键词过滤(内置 + 自定义) - 广告关键词过滤(内置 + 自定义)
@@ -25,7 +23,6 @@
- 余额 ≤5 条自动停用,下次打开 App 提醒 - 余额 ≤5 条自动停用,下次打开 App 提醒
- 每 5 分钟最多发 1 条,避免刷屏 - 每 5 分钟最多发 1 条,避免刷屏
- 自动解析失败时回退手动额度计数 - 自动解析失败时回退手动额度计数
- **应用内更新**:每天自动检查 GitHub 新版本,发现新版后一键下载并通过系统安装器升级,无需手动去网页下载。
## 快速开始 ## 快速开始
@@ -37,16 +34,14 @@
1. 在 iOS 上安装 [Bark](https://apps.apple.com/us/app/bark-customed-push/id1613210986)。 1. 在 iOS 上安装 [Bark](https://apps.apple.com/us/app/bark-customed-push/id1613210986)。
2. 打开 Bark,复制你的专属推送地址,形如 `https://api.day.app/你的Key` 2. 打开 Bark,复制你的专属推送地址,形如 `https://api.day.app/你的Key`
3.GotMsg 的「设置」中添加服务器: 3.a2i 的「设置」中添加服务器:
- 名称:随意,例如「我的 iPhone」 - 名称:随意,例如「我的 iPhone」
- 服务器地址:`https://api.day.app`(自建则填自建地址) - 服务器地址:`https://api.day.app`(自建则填自建地址)
- Device KeyBark 地址中的那段 Key - Device KeyBark 地址中的那段 Key
如果接收端是 Android 或鸿蒙手机,也可以在「设置」里配置 ntfy 或 Meow;对应区块右上角有帮助按钮,按弹窗步骤填写服务器地址、Topic / Device Key 后点测试发送。
### 3. 授权通知监听 ### 3. 授权通知监听
打开 GotMsg,首页会提示「需要授权通知监听」。点击「前往系统授权」,在系统的通知访问设置中开启 GotMsg 打开 a2i,首页会提示「需要授权通知监听」。点击「前往系统授权」,在系统的通知访问设置中开启 a2i
> 授权是一次性的,卸载后重新安装需要再次授权。 > 授权是一次性的,卸载后重新安装需要再次授权。
@@ -60,12 +55,12 @@
- **想同步聊天**:保持黑名单模式(默认),在「过滤」页开启「微信 / QQ / Telegram 优化」。 - **想同步聊天**:保持黑名单模式(默认),在「过滤」页开启「微信 / QQ / Telegram 优化」。
- **通知没推过来**:先看「日志」页,每条通知(无论转发、过滤还是失败)都会记录原因。 - **通知没推过来**:先看「日志」页,每条通知(无论转发、过滤还是失败)都会记录原因。
- **想让通知带图标**:参考下方「应用图标」一节。 - **想让通知带图标**:参考下方「应用图标」一节。
- **电话通知不工作**:MIUI 会把系统电话通知限制为不向第三方通知监听器分发,本 App 通过轮询 `getCallonyState()` 绕过此限制。需要授予「电话」权限(设置 → 应用 → GotMsg → 权限 → 电话)。 - **电话通知不工作**:MIUI 会把系统电话通知限制为不向第三方通知监听器分发,本 App 通过轮询 `getCallonyState()` 绕过此限制。需要授予「电话」权限(设置 → 应用 → a2i → 权限 → 电话)。
- **断网收不到通知**:开启「设置 → 断网短信兜底」,填好 iPhone 号码、授予短信权限、设好手动额度。断网时验证码和来电会走短信(消耗套餐额度,5 分钟限 1 条)。 - **断网收不到通知**:开启「设置 → 断网短信兜底」,填好 iPhone 号码、授予短信权限、设好手动额度。断网时验证码和来电会走短信(消耗套餐额度,5 分钟限 1 条)。
## 应用图标 ## 应用图标
Bark 的 `icon` 字段需要一个公网可访问的图片 URL。GotMsg 支持把已安装 App 的图标批量导出到你选择的目录,方便上传到你自己的图床。 Bark 的 `icon` 字段需要一个公网可访问的图片 URL。a2i 支持把已安装 App 的图标批量导出到你选择的目录,方便上传到你自己的图床。
规则:转发时 `icon = 图标 URL 前缀 + 安卓包名 + .png`。在「设置 → 应用图标」里: 规则:转发时 `icon = 图标 URL 前缀 + 安卓包名 + .png`。在「设置 → 应用图标」里:
@@ -75,43 +70,11 @@ Bark 的 `icon` 字段需要一个公网可访问的图片 URL。GotMsg 支持
留空前缀则不显示图标。 留空前缀则不显示图标。
## 自建 Bark 服务(可选)
GotMsg 默认用 Bark 官方服务器(`https://api.day.app`),免费够用。如果想完全自控、不限速、不依赖第三方,可以自建 Bark Server。下面是三种零成本方案:
### 方案 1:Vercel 云托管(最适合新手)
把 Bark 后端跑在 Vercel 的 Serverless 上,每次推送触发一个云函数,不挂进程、完全免费。
- 平台:[Vercel](https://vercel.com) · 代码:[GitHub](https://github.com)
- 在 GitHub 搜索 `bark-server-vercel`,项目 README 里通常有 **Deploy with Vercel** 按钮,一键克隆部署
### 方案 2Cloudflare 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 ComposeMaterial 3 - Kotlin + Jetpack ComposeMaterial 3
- AndroidX DataStore(持久化设置与日志) - AndroidX DataStore(持久化设置与日志)
- OkHttpBark / ntfy / Meow / 更新检查 - OkHttpBark 推送
- WorkManager(失败重试) - WorkManager(失败重试)
- Navigation Compose(单 Activity 多页面) - Navigation Compose(单 Activity 多页面)
- 最低 Android 14API 34),目标 Android 16API 36 - 最低 Android 14API 34),目标 Android 16API 36
+2 -2
View File
@@ -13,8 +13,8 @@ android {
applicationId = "com.a2i.forwarder" applicationId = "com.a2i.forwarder"
minSdk = 34 minSdk = 34
targetSdk = 36 targetSdk = 36
versionCode = 32 versionCode = 5
versionName = "1.9.3" versionName = "1.4.0"
} }
signingConfigs { signingConfigs {
-11
View File
@@ -11,7 +11,6 @@
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" /> <uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" /> <uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" /> <uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
<application <application
android:name=".A2iApp" android:name=".A2iApp"
@@ -46,15 +45,5 @@
</intent-filter> </intent-filter>
</service> </service>
<provider
android:name="androidx.core.content.FileProvider"
android:authorities="${applicationId}.fileprovider"
android:exported="false"
android:grantUriPermissions="true">
<meta-data
android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/file_paths" />
</provider>
</application> </application>
</manifest> </manifest>
@@ -7,7 +7,6 @@ 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.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
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
@@ -31,8 +30,6 @@ class A2iApp : Application() {
private set private set
lateinit var smsForwarder: SmsFallbackForwarder lateinit var smsForwarder: SmsFallbackForwarder
private set private set
lateinit var updateChecker: UpdateChecker
private set
override fun onCreate() { override fun onCreate() {
super.onCreate() super.onCreate()
@@ -46,8 +43,6 @@ class A2iApp : Application() {
connectivityMonitor.start() connectivityMonitor.start()
carrierBalanceQuery = CarrierBalanceQuery(this) carrierBalanceQuery = CarrierBalanceQuery(this)
smsForwarder = SmsFallbackForwarder(this) smsForwarder = SmsFallbackForwarder(this)
updateChecker = UpdateChecker(this)
updateChecker.start()
} }
companion object { companion object {
@@ -3,7 +3,6 @@ package com.a2i.forwarder.core
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 kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import kotlinx.serialization.encodeToString import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json import kotlinx.serialization.json.Json
@@ -11,7 +10,6 @@ import okhttp3.MediaType.Companion.toMediaType
import okhttp3.OkHttpClient import okhttp3.OkHttpClient
import okhttp3.Request import okhttp3.Request
import okhttp3.RequestBody.Companion.toRequestBody import okhttp3.RequestBody.Companion.toRequestBody
import java.io.IOException
import java.util.concurrent.TimeUnit import java.util.concurrent.TimeUnit
class BarkClient(private val server: BarkServer) { class BarkClient(private val server: BarkServer) {
@@ -26,37 +24,16 @@ class BarkClient(private val server: BarkServer) {
suspend fun push(msg: BarkMessage): Result<Unit> = withContext(Dispatchers.IO) { suspend fun push(msg: BarkMessage): Result<Unit> = withContext(Dispatchers.IO) {
runCatching { runCatching {
require(server.deviceKey.isNotBlank()) { "device key 为空" } require(server.deviceKey.isNotBlank()) { "device key 为空" }
doPushWithRetry(msg) val body = json.encodeToString(msg).toRequestBody(JSON)
}
}
/**
* 带重试的推送:对 connection closed / 超时 / TLS 中断等瞬时 IOException 自动重试。
* OkHttp 的 retryOnConnectionFailure 不覆盖 POST 读响应阶段的连接中断,故在此手动补。
* HTTP 4xx/5xx 是服务端拒绝,重试无意义,直接抛出。
*/
private suspend fun doPushWithRetry(msg: BarkMessage) {
val body = json.encodeToString(msg.copy(deviceKey = server.deviceKey)).toRequestBody(JSON)
val url = server.server.trim().removeSuffix("/") + "/push" val url = server.server.trim().removeSuffix("/") + "/push"
val req = Request.Builder().url(url).post(body).build() val req = Request.Builder().url(url).post(body).build()
var lastError: IOException? = null
repeat(MAX_ATTEMPTS) { attempt ->
try {
http.newCall(req).execute().use { res -> http.newCall(req).execute().use { res ->
if (res.isSuccessful) return if (!res.isSuccessful) error("HTTP ${res.code}")
error("HTTP ${res.code}")
}
} catch (e: IOException) {
lastError = e
if (attempt < MAX_ATTEMPTS - 1) delay(BACKOFF_MS.getOrElse(attempt) { 1000L })
} }
} }
throw lastError ?: error("推送失败")
} }
companion object { companion object {
private val JSON = "application/json; charset=utf-8".toMediaType() private val JSON = "application/json; charset=utf-8".toMediaType()
private const val MAX_ATTEMPTS = 3
private val BACKOFF_MS = longArrayOf(600L, 1200L)
} }
} }
@@ -1,41 +0,0 @@
package com.a2i.forwarder.core
import com.a2i.forwarder.model.BarkMessage
import com.a2i.forwarder.model.BarkServer
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
/**
* 多服务器容错推送:按列表顺序依次尝试,首个成功即止。
* 全失败 → 返回最后一个错误;空列表 → "无可用服务器"。
*/
object BarkSender {
/** stopOnFirst=true:首个成功即止;false:全部发送,至少一个成功即算成功。 */
suspend fun pushToEnabled(servers: List<BarkServer>, msg: BarkMessage, stopOnFirst: Boolean): Result<Unit> = withContext(Dispatchers.IO) {
runCatching {
require(servers.any()) { "无可用服务器" }
if (stopOnFirst) return@runCatching pushToFirst(servers, msg).getOrThrow()
var anyOk = false
var lastErr: Throwable? = null
for (s in servers) {
val r = BarkClient(s).push(msg)
if (r.isSuccess) anyOk = true else lastErr = r.exceptionOrNull()
}
if (!anyOk) throw lastErr ?: error("全部推送失败")
}
}
/** 首个成功即止(stopOnFirst=true 的实现)。 */
suspend fun pushToFirst(servers: List<BarkServer>, msg: BarkMessage): Result<Unit> = withContext(Dispatchers.IO) {
runCatching {
require(servers.any()) { "无可用服务器" }
var lastErr: Throwable? = null
for (s in servers) {
val r = BarkClient(s).push(msg)
if (r.isSuccess) return@runCatching
lastErr = r.exceptionOrNull()
}
throw lastErr ?: error("推送失败")
}
}
}
@@ -1,129 +0,0 @@
package com.a2i.forwarder.core
import com.a2i.forwarder.A2iApp
import com.a2i.forwarder.model.EmailServer
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import java.io.BufferedReader
import java.io.BufferedWriter
import java.io.InputStreamReader
import java.io.OutputStreamWriter
import java.util.Base64
import javax.net.ssl.SSLSocket
import javax.net.ssl.SSLSocketFactory
/**
* SMTP 直连发送纯文本邮件(SMTPS/隐式 TLS,端口 465)。
* 在 [Dispatchers.IO] 上执行,不阻塞主线程。
*/
object EmailSender {
suspend fun send(
host: String, port: Int, user: String, password: String,
from: String, to: String, subject: String, body: String,
): Result<Unit> = withContext(Dispatchers.IO) {
runCatching {
val factory = SSLSocketFactory.getDefault()
// 隐式 TLS:直接通过 SSLSocketFactory 创建 SSLSocket 连接 SMTP 服务器(推荐端口 465)
val ssl = factory.createSocket(host, port) as SSLSocket
ssl.soTimeout = 15_000
ssl.startHandshake()
val reader: BufferedReader = ssl.inputStream.bufferedReader()
val writer: BufferedWriter = ssl.outputStream.bufferedWriter()
fun read(): String = reader.readLine() ?: error("连接关闭")
fun cmd(c: String) { writer.write("$c\r\n"); writer.flush() }
fun expectEhlo(): String {
cmd("EHLO a2i")
var last = ""
do { last = read() } while (last.startsWith("250-"))
if (!last.startsWith("250 ")) error("EHLO: $last")
return last
}
var r = read()
if (!r.startsWith("220")) error("SMTP: $r")
expectEhlo()
if (user.isNotBlank() && password.isNotBlank()) {
cmd("AUTH LOGIN")
r = read()
if (!r.startsWith("334")) error("AUTH: $r")
cmd(Base64.getEncoder().encodeToString(user.toByteArray()))
r = read()
if (!r.startsWith("334")) error("AUTH user: $r")
cmd(Base64.getEncoder().encodeToString(password.toByteArray()))
r = read()
if (!r.startsWith("235")) error("AUTH pass: $r")
}
cmd("MAIL FROM:<$from>")
r = read()
if (!r.startsWith("250")) error("FROM: $r")
cmd("RCPT TO:<$to>")
r = read()
if (!r.startsWith("250")) error("TO: $r")
cmd("DATA")
r = read()
if (!r.startsWith("354")) error("DATA: $r")
writer.write(buildMime(from, to, subject, body))
writer.write("\r\n.\r\n")
writer.flush()
r = read()
if (!r.startsWith("250")) error("SEND: $r")
cmd("QUIT")
runCatching { ssl.close() }
Unit
}
}
private fun buildMime(from: String, to: String, subject: String, body: String): String {
val subjB64 = Base64.getEncoder().encodeToString(subject.toByteArray(Charsets.UTF_8))
val bodyB64 = Base64.getEncoder().encodeToString(body.toByteArray(Charsets.UTF_8))
return buildString {
append("From: <$from>\r\n")
append("To: <$to>\r\n")
append("Subject: =?UTF-8?B?$subjB64?=\r\n")
append("MIME-Version: 1.0\r\n")
append("Content-Type: text/plain; charset=UTF-8\r\n")
append("Content-Transfer-Encoding: base64\r\n")
append("\r\n")
append(bodyB64)
}
}
/** 多配置推送:stopOnFirst=true 首个成功止;false 全部发送,至少一个成功即算成功。 */
suspend fun pushToEnabled(configs: List<EmailServer>, title: String, body: String, stopOnFirst: Boolean): Result<Unit> = withContext(Dispatchers.IO) {
runCatching {
require(configs.any()) { "无邮箱配置" }
var anyOk = false
var lastErr: Throwable? = null
for (c in configs) {
val r = send(c.host, c.port, c.user, c.password, c.from, c.to, title, body)
if (r.isSuccess) {
anyOk = true
if (stopOnFirst) return@runCatching
} else {
lastErr = r.exceptionOrNull()
}
}
if (!anyOk) throw lastErr ?: error("全部邮件发送失败")
}
}
/** 如果电邮转发已启用且收件人非空,异步发邮件(fire-and-forget,不阻塞主流程)。 */
fun trySend(app: A2iApp, title: String, body: String, scope: CoroutineScope) {
val s = app.settings
val configs = s.emailServers.value.filter { it.enabled && it.to.isNotBlank() && it.host.isNotBlank() }
if (configs.isEmpty()) return
scope.launch {
pushToEnabled(configs, title, body, s.emailStopOnFirst.value)
}
}
}
@@ -1,145 +0,0 @@
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)
}
@@ -16,7 +16,6 @@ class NotificationProcessor(private val context: Context) {
val reason: String, val reason: String,
val appLabel: String, val appLabel: String,
val code: String?, val code: String?,
val silent: Boolean = false, // true = 静默过滤,不写日志
) )
// ========== 去重缓存 ========== // ========== 去重缓存 ==========
@@ -49,15 +48,6 @@ class NotificationProcessor(private val context: Context) {
"com.google.android.gms", "com.google.android.gms",
) )
// 静默过滤的包名:代理/VPN 客户端的常驻流量通知,高频且无意义,过滤不写日志
private val silentPackages = setOf(
"com.getsurfboard", // Surfboard
"com.github.metacubex.clash.meta", // Clash Meta for Android
"com.github.kr328.clash", // Clash for Android
"com.v2ray.ang", // v2rayNG
"com.milink.service", // 小米设备互联(UniversalClipboardService 跨设备剪贴板等常驻通知)
)
fun process(sbn: StatusBarNotification): Decision { fun process(sbn: StatusBarNotification): Decision {
val pkg = sbn.packageName val pkg = sbn.packageName
val n = sbn.notification ?: return Decision(null, "无通知", pkg, null) val n = sbn.notification ?: return Decision(null, "无通知", pkg, null)
@@ -98,18 +88,10 @@ class NotificationProcessor(private val context: Context) {
if (!settings.forwardingEnabled.value) if (!settings.forwardingEnabled.value)
return Decision(null, "全局转发已关", appLabel, null) return Decision(null, "全局转发已关", appLabel, null)
// ---- 持续性通知过滤(静默,不进日志)---- // ---- 持续性通知过滤 ----
val isOngoing = n.flags and Notification.FLAG_ONGOING_EVENT != 0 val isOngoing = n.flags and Notification.FLAG_ONGOING_EVENT != 0
if (settings.skipOngoing.value && isOngoing) if (settings.skipOngoing.value && isOngoing)
return Decision(null, "持续性通知", appLabel, null, silent = true) return Decision(null, "持续性通知", appLabel, null)
// ---- 静默包名过滤(VPN/代理客户端的常驻流量通知,高频无意义,不写日志)----
if (pkg in silentPackages)
return Decision(null, "常驻通知静默", appLabel, null, silent = true)
// ---- 米家泛化状态通知静默("设备状态"等概览;门锁/告警等具体事件保留)----
if (pkg == "com.xiaomi.smarthome" && content.contains("设备状态"))
return Decision(null, "米家状态通知静默", appLabel, null, silent = true)
// ---- App 黑白名单 ---- // ---- App 黑白名单 ----
if (!rules.isForwardingOn(pkg)) if (!rules.isForwardingOn(pkg))
@@ -188,7 +170,7 @@ class NotificationProcessor(private val context: Context) {
val level = if (important) "timeSensitive" else "active" val level = if (important) "timeSensitive" else "active"
val msg = BarkMessage( val msg = BarkMessage(
deviceKey = "", deviceKey = settings.currentServer.value?.deviceKey.orEmpty(),
title = msgTitle.take(100), title = msgTitle.take(100),
body = content.ifBlank { title }.take(500), body = content.ifBlank { title }.take(500),
level = level, level = level,
@@ -1,102 +0,0 @@
package com.a2i.forwarder.core
import com.a2i.forwarder.A2iApp
import com.a2i.forwarder.model.NtfyServer
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.RequestBody.Companion.toRequestBody
import org.json.JSONObject
import java.io.IOException
import java.util.concurrent.TimeUnit
/**
* ntfy 推送:用 JSON publish APIPOST / with {topic,title,message})。
* 注:raw body 会被 ntfy 当附件,必须用 JSON API 或 Message header。
* 对瞬时 IOException 自动重试 3 次(退避 600/1200ms)。
*/
object NtfySender {
private val http = OkHttpClient.Builder()
.connectTimeout(10, TimeUnit.SECONDS)
.writeTimeout(10, TimeUnit.SECONDS)
.readTimeout(15, TimeUnit.SECONDS)
.retryOnConnectionFailure(true)
.build()
suspend fun send(
server: String, topic: String, token: String,
title: String, body: String,
): Result<Unit> = withContext(Dispatchers.IO) {
runCatching {
val payload = JSONObject()
.put("topic", topic.trim())
.put("title", title.take(200))
.put("message", body.take(4000))
.toString()
val req = Request.Builder()
.url(server.trim().removeSuffix("/"))
.apply { if (token.isNotBlank()) header("Authorization", "Bearer $token") }
.post(payload.toRequestBody(JSON))
.build()
doPushWithRetry(req)
}
}
/** 多配置推送:stopOnFirst=true 首个成功止;false 全部发送,至少一个成功即算成功。 */
suspend fun pushToEnabled(configs: List<NtfyServer>, title: String, body: String, stopOnFirst: Boolean): Result<Unit> = withContext(Dispatchers.IO) {
runCatching {
require(configs.any()) { "无 ntfy 配置" }
var anyOk = false
var lastErr: Throwable? = null
for (c in configs) {
val r = send(c.server, c.topic, c.token, title, body)
if (r.isSuccess) {
anyOk = true
if (stopOnFirst) return@runCatching
} else {
lastErr = r.exceptionOrNull()
}
}
if (!anyOk) throw lastErr ?: error("全部 ntfy 推送失败")
}
}
private fun doPushWithRetry(req: Request) {
var lastError: IOException? = null
repeat(MAX_ATTEMPTS) { attempt ->
try {
http.newCall(req).execute().use { res ->
if (res.isSuccessful) return
error("HTTP ${res.code}")
}
} catch (e: IOException) {
lastError = e
if (attempt < MAX_ATTEMPTS - 1) {
Thread.sleep(BACKOFF_MS[attempt])
}
} catch (e: IllegalStateException) {
// HTTP 错误码(error() 抛 IllegalStateException),不重试
throw e
}
}
throw lastError ?: error("ntfy 推送失败")
}
fun trySend(app: A2iApp, title: String, body: String, scope: CoroutineScope) {
val s = app.settings
val configs = s.ntfyServers.value.filter { it.enabled && it.topic.isNotBlank() }
if (configs.isEmpty()) return
scope.launch {
pushToEnabled(configs, title, body, s.ntfyStopOnFirst.value)
}
}
private val JSON = "application/json; charset=utf-8".toMediaType()
private const val MAX_ATTEMPTS = 3
private val BACKOFF_MS = longArrayOf(600L, 1200L)
}
@@ -70,6 +70,8 @@ class PhoneCallMonitor(private val context: Context) {
val app = A2iApp.instance val app = A2iApp.instance
val settings = app.settings val settings = app.settings
if (!settings.forwardingEnabled.value) return if (!settings.forwardingEnabled.value) return
val server = settings.currentServer.value ?: return
if (server.deviceKey.isBlank()) return
when { when {
// IDLE → RINGING:来电 // IDLE → RINGING:来电
@@ -80,14 +82,14 @@ class PhoneCallMonitor(private val context: Context) {
if (now - lastRingTime > 3000) { if (now - lastRingTime > 3000) {
lastRingTime = now lastRingTime = now
val msg = BarkMessage( val msg = BarkMessage(
deviceKey = "", deviceKey = server.deviceKey,
title = "来电", title = "来电",
body = "有电话进来", body = "有电话进来",
level = "timeSensitive", level = "timeSensitive",
group = "phone_call", group = "phone_call",
isArchive = "1", isArchive = "1",
) )
pushToServers(msg, app, "来电") pushToServer(server, msg, app, "来电")
} }
} }
@@ -100,14 +102,14 @@ class PhoneCallMonitor(private val context: Context) {
from == TelephonyManager.CALL_STATE_RINGING && to == TelephonyManager.CALL_STATE_IDLE -> { from == TelephonyManager.CALL_STATE_RINGING && to == TelephonyManager.CALL_STATE_IDLE -> {
if (!offhook && ringing) { if (!offhook && ringing) {
val msg = BarkMessage( val msg = BarkMessage(
deviceKey = "", deviceKey = server.deviceKey,
title = "未接来电", title = "未接来电",
body = "未接来电", body = "未接来电",
level = "timeSensitive", level = "timeSensitive",
group = "phone_call", group = "phone_call",
isArchive = "1", isArchive = "1",
) )
pushToServers(msg, app, "未接来电") pushToServer(server, msg, app, "未接来电")
} }
ringing = false ringing = false
offhook = false offhook = false
@@ -121,16 +123,14 @@ class PhoneCallMonitor(private val context: Context) {
} }
} }
private fun pushToServers(msg: BarkMessage, app: A2iApp, logBody: String) { private fun pushToServer(
server: com.a2i.forwarder.model.BarkServer,
msg: BarkMessage,
app: A2iApp,
logBody: String,
) {
scope.launch { scope.launch {
val servers = app.settings.servers.value.filter { it.enabled && it.deviceKey.isNotBlank() } val result = BarkClient(server).push(msg)
if (servers.isEmpty()) {
EmailSender.trySend(app, msg.title ?: logBody, msg.body ?: "", scope)
NtfySender.trySend(app, msg.title ?: logBody, msg.body ?: "", scope)
MeowSender.trySend(app, msg.title ?: logBody, msg.body ?: "", scope)
return@launch
}
val result = BarkSender.pushToEnabled(servers, msg, app.settings.barkStopOnFirst.value)
val now = System.currentTimeMillis() val now = System.currentTimeMillis()
if (result.isSuccess) { if (result.isSuccess) {
app.logStore.addLog( app.logStore.addLog(
@@ -142,12 +142,9 @@ class PhoneCallMonitor(private val context: Context) {
ForwardLog(now, "phone_call", "电话", msg.title, logBody, "failed", reason) ForwardLog(now, "phone_call", "电话", msg.title, logBody, "failed", reason)
) )
app.logStore.addPending( app.logStore.addPending(
PendingPush(UUID.randomUUID().toString(), now, servers.first().id, json.encodeToString(msg)) PendingPush(UUID.randomUUID().toString(), now, server.id, json.encodeToString(msg))
) )
} }
EmailSender.trySend(app, msg.title ?: logBody, msg.body ?: "", scope)
NtfySender.trySend(app, msg.title ?: logBody, msg.body ?: "", scope)
MeowSender.trySend(app, msg.title ?: logBody, msg.body ?: "", scope)
} }
} }
} }
@@ -24,11 +24,13 @@ class RetryWorker(ctx: Context, params: WorkerParameters) : CoroutineWorker(ctx,
var allOk = true var allOk = true
val json = Json { ignoreUnknownKeys = true } val json = Json { ignoreUnknownKeys = true }
val servers = settings.servers.value.filter { it.enabled && it.deviceKey.isNotBlank() }
for (p in list) { for (p in list) {
if (servers.isEmpty()) { allOk = false; break } val server = settings.servers.value.firstOrNull { it.id == p.serverId }
?: settings.currentServer.value
?: continue
if (server.deviceKey.isBlank()) { allOk = false; continue }
val msg = runCatching { json.decodeFromString<com.a2i.forwarder.model.BarkMessage>(p.messageJson) }.getOrNull() ?: continue val msg = runCatching { json.decodeFromString<com.a2i.forwarder.model.BarkMessage>(p.messageJson) }.getOrNull() ?: continue
val r = BarkSender.pushToFirst(servers, msg) val r = BarkClient(server).push(msg)
if (r.isSuccess) store.removePending(p.id) else allOk = false if (r.isSuccess) store.removePending(p.id) else allOk = false
} }
return if (allOk) Result.success() else Result.retry() return if (allOk) Result.success() else Result.retry()
@@ -32,16 +32,17 @@ class SmsFallbackForwarder(private val context: Context) {
return t.contains("来电") || t.contains("未接") return t.contains("来电") || t.contains("未接")
} }
/** 尝试用短信转发(遍历所有启用的目标号,按 stopOnFirst 决定发通一条止还是全发)。 */ /** 尝试用短信转发。返回 true 表示已处理(无论成功失败)。 */
fun forward(d: Decision) { fun forward(d: Decision) {
scope.launch { scope.launch {
if (!settings.smsFallbackEnabled.value) return@launch
if (settings.smsSuspended.value) { if (settings.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 target = settings.smsTargetNumber.value.trim()
if (targets.isEmpty()) { if (target.isBlank()) {
log(d, "failed", "未设置短信目标号") log(d, "failed", "未设置短信目标号")
return@launch return@launch
} }
@@ -61,34 +62,22 @@ class SmsFallbackForwarder(private val context: Context) {
} }
val body = compose(d).take(70) val body = compose(d).take(70)
val stopOnFirst = settings.smsStopOnFirst.value
var anyOk = false
var sentCount = 0
var lastReason = "短信发送失败"
for (t in targets) {
val result = runCatching { val result = runCatching {
SmsManager.getDefault().sendTextMessage(t.number.trim(), null, body, null, null) SmsManager.getDefault().sendTextMessage(target, null, body, null, null)
} }
if (result.isSuccess) { if (result.isSuccess) {
anyOk = true
sentCount++
if (stopOnFirst) break
} else {
lastReason = result.exceptionOrNull()?.message ?: "短信发送失败"
}
}
if (anyOk) {
settings.setLastSmsSentTime(now) settings.setLastSmsSentTime(now)
log(d, "sent", "") log(d, "sent", "")
// 余额递减:按成功发送条数;自动模式发查询短信,手动模式递减 // 转发后刷新余额:自动模式发查询短信,手动模式递减
val carrier = app.carrierBalanceQuery.currentCarrier() val carrier = app.carrierBalanceQuery.currentCarrier()
if (carrier.serviceNumber != null && carrier.queryCmd != null) { if (carrier.serviceNumber != null && carrier.queryCmd != null) {
app.carrierBalanceQuery.refresh() app.carrierBalanceQuery.refresh()
} else { } else {
repeat(sentCount) { runCatching { app.carrierBalanceQuery.decrementManual() } } app.carrierBalanceQuery.decrementManual()
} }
} else { } else {
log(d, "failed", lastReason) val reason = result.exceptionOrNull()?.message ?: "短信发送失败"
log(d, "failed", reason)
} }
} }
} }
@@ -1,333 +0,0 @@
package com.a2i.forwarder.core
import android.content.Context
import android.content.Intent
import android.net.Uri
import android.provider.Settings
import androidx.core.content.FileProvider
import com.a2i.forwarder.A2iApp
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import okhttp3.OkHttpClient
import okhttp3.Request
import org.json.JSONObject
import java.io.File
import java.util.concurrent.TimeUnit
data class UpdateInfo(
val latestVersion: String,
val apkUrl: String,
val apkSize: Long,
val releaseNotes: String,
val releasePageUrl: String,
val fallbackUrl: String? = null, // 主 URL 下载失败时尝试的备选链接
)
/**
* 应用内更新:定时巡检 Gitea Releases 最新版 → 版本比较 → 醒目横幅 + 自动推送通知 → 下载 APK → 调起系统安装器。
* Android targetSdk≥24 须用 FileProvidertargetSdk≥26 安装未知来源 APK 须 REQUEST_INSTALL_PACKAGES。
*/
class UpdateChecker(private val context: Context) {
private val app get() = A2iApp.instance
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
private val http = OkHttpClient.Builder()
.connectTimeout(10, TimeUnit.SECONDS)
.readTimeout(30, TimeUnit.SECONDS)
.retryOnConnectionFailure(true)
.build()
val updateInfo = MutableStateFlow<UpdateInfo?>(null)
val downloading = MutableStateFlow(false)
val downloadProgress = MutableStateFlow(0)
val downloadSource = MutableStateFlow<String?>(null) // "Gitea" / "GitHub" / null
val lastError = MutableStateFlow<String?>(null)
private var lastNotifiedVersion: String? = null // 记录已推送的版本,避免重复通知
fun start() {
scope.launch {
delay(5000) // 启动 5 秒后首检,给网络/应用初始化留时间
doCheck(throttle = false, toggle = true) // 启动必查(无视节流,但尊重开关)
while (isActive) {
delay(CHECK_INTERVAL_MS) // 常驻期间每 24h 巡检一次
doCheck(throttle = true, toggle = true)
}
}
}
/** 手动检查:无视节流和开关(用户主动)。 */
suspend fun check(force: Boolean): Result<UpdateInfo?> = doCheck(throttle = !force, toggle = !force)
private suspend fun doCheck(throttle: Boolean, toggle: Boolean): Result<UpdateInfo?> = withContext(Dispatchers.IO) {
runCatching {
val settings = app.settings
if (toggle && !settings.updateCheckEnabled.value) return@runCatching updateInfo.value
val now = System.currentTimeMillis()
if (throttle && now - settings.lastUpdateCheckTime.value < CHECK_INTERVAL_MS) return@runCatching updateInfo.value
// 先查 GitHub(公开默认源),从其 release notes 动态发现额外源(如自建 Gitea),取最高版本
// 同版本时 Gitea 优先(国内可达,GitHub 可能被墙导致下载失败)
val infos = mutableListOf<UpdateInfo>()
var ghInfo: UpdateInfo? = null
var giteaInfo: UpdateInfo? = null
val gh = runCatching { fetchRelease(GITHUB_API) }.getOrNull()
if (gh != null) {
ghInfo = gh
giteaInfo = parseUpdateSource(gh.releaseNotes)?.let { runCatching { fetchRelease(it) }.getOrNull() }
// Gitea 放前面,同版本时优先用它下载(国内可达)
if (giteaInfo != null) infos.add(giteaInfo)
infos.add(gh)
}
settings.setLastUpdateCheck(now)
if (infos.isEmpty()) {
lastError.value = "所有更新源不可达"
return@runCatching updateInfo.value
}
val current = currentVersionName()
val best = infos
.filter { isNewer(it.latestVersion, current) && it.latestVersion != settings.ignoredVersion.value }
.maxByOrNull { versionKey(it.latestVersion) }
val prev = updateInfo.value
if (best == null) {
updateInfo.value = null
lastNotifiedVersion = null
} else {
// 主 URL 下载失败时尝试另一个源的链接
val fallback = if (best == giteaInfo) ghInfo?.apkUrl else giteaInfo?.apkUrl
updateInfo.value = best.copy(fallbackUrl = fallback)
}
lastError.value = null
// 发现新版且之前没推过通知 → 自动推送到所有已开启通道
if (best != null && best.latestVersion != lastNotifiedVersion) {
lastNotifiedVersion = best.latestVersion
notifyNewVersion(prev, best)
}
updateInfo.value
}.onFailure { lastError.value = it.message ?: "检查失败" }
}
private fun fetchRelease(api: String): UpdateInfo? {
val req = Request.Builder()
.url(api)
.header("Accept", "application/json")
.header("User-Agent", "gotmsg-android")
.get()
.build()
return http.newCall(req).execute().use { res ->
if (!res.isSuccessful) error("HTTP ${res.code}")
parseRelease(res.body?.string() ?: error("空响应"))
}
}
private fun versionKey(v: String): String =
v.split(".").joinToString(".") { (it.substringBefore("-").toIntOrNull() ?: 0).toString().padStart(4, '0') }
/** 发现新版后,自动通过所有已开启的推送通道通知用户 */
private fun notifyNewVersion(prev: UpdateInfo?, info: UpdateInfo) {
val s = app.settings
val title = "🆕 GotMsg 新版本 v${info.latestVersion}"
val body = "发现 v${info.latestVersion}(当前 v${currentVersionName()}\n${formatSize(info.apkSize)} · 点击更新\n\n${info.releaseNotes.take(300)}"
val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
// Bark:通过 BarkSender 推送给第一个启用的服务器
val barkServers = s.servers.value.filter { it.enabled && it.deviceKey.isNotBlank() }
if (barkServers.isNotEmpty()) {
scope.launch {
val msg = com.a2i.forwarder.model.BarkMessage(
deviceKey = "", title = title, body = body, level = "timeSensitive",
group = "a2i_update", isArchive = "1",
)
BarkSender.pushToFirst(barkServers, msg)
}
}
// ntfy
NtfySender.trySend(app, title, body, scope)
// Meow
MeowSender.trySend(app, title, body, scope)
// 电邮
EmailSender.trySend(app, title, body, scope)
}
private fun parseRelease(body: String): UpdateInfo? {
val json = JSONObject(body)
val tag = json.optString("tag_name").removePrefix("v").trim()
if (tag.isEmpty()) return null
val assets = json.optJSONArray("assets") ?: return null
var apkUrl = ""
var apkSize = 0L
for (i in 0 until assets.length()) {
val a = assets.optJSONObject(i) ?: continue
if (a.optString("name").endsWith(".apk", ignoreCase = true)) {
apkUrl = a.optString("browser_download_url")
apkSize = a.optLong("size")
break
}
}
if (apkUrl.isEmpty()) return null
return UpdateInfo(
latestVersion = tag,
apkUrl = apkUrl,
apkSize = apkSize,
releaseNotes = json.optString("body").orEmpty(),
releasePageUrl = json.optString("html_url").orEmpty(),
)
}
suspend fun download(info: UpdateInfo): File? = withContext(Dispatchers.IO) {
downloading.value = true
downloadProgress.value = 0
downloadSource.value = null
lastError.value = null
// 主 URL (Gitea) 失败时自动尝试备选 URL (GitHub)
val sources = listOf(
info.apkUrl to sourceLabel(info.apkUrl),
) + (info.fallbackUrl?.let { listOf(it to sourceLabel(it)) } ?: emptyList())
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 dir = context.getExternalFilesDir(null) ?: context.cacheDir
val target = File(dir, "gotmsg-${version}.apk")
target.outputStream().use { sink ->
val input = body.byteStream()
val buf = ByteArray(64 * 1024) // 64KB buffer 提升大文件速度
var read = 0L
while (true) {
val n = input.read(buf)
if (n <= 0) break
sink.write(buf, 0, n)
read += n
if (total > 0) downloadProgress.value = (read * 100 / total).toInt()
}
}
// 校验文件大小: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
target
}
} catch (e: Exception) {
lastError.value = e.message ?: "下载失败"
null
}
}
fun install(apk: File) {
if (!context.packageManager.canRequestPackageInstalls()) {
val intent = Intent(Settings.ACTION_MANAGE_UNKNOWN_APP_SOURCES).apply {
data = Uri.parse("package:${context.packageName}")
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
}
runCatching { context.startActivity(intent) }
return
}
val uri = FileProvider.getUriForFile(context, "${context.packageName}.fileprovider", apk)
val intent = Intent(Intent.ACTION_VIEW).apply {
setDataAndType(uri, "application/vnd.android.package-archive")
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
}
runCatching { context.startActivity(intent) }
}
fun ignoreVersion(v: String) {
scope.launch {
app.settings.setIgnoredVersion(v)
updateInfo.value = null
}
}
private fun currentVersionName(): String {
return runCatching {
@Suppress("DEPRECATION")
context.packageManager.getPackageInfo(context.packageName, 0).versionName ?: "0"
}.getOrDefault("0")
}
fun isNewer(latest: String, current: String): Boolean {
val l = latest.split(".").map { it.substringBefore("-").toIntOrNull() ?: 0 }
val c = current.split(".").map { it.substringBefore("-").toIntOrNull() ?: 0 }
val n = maxOf(l.size, c.size)
for (i in 0 until n) {
val lv = l.getOrElse(i) { 0 }
val cv = c.getOrElse(i) { 0 }
if (lv != cv) return lv > cv
}
return false
}
/** 从 release notes 的 HTML 注释里解析额外更新源(加扰编码,兼容 Base64/明文)。 */
private fun parseUpdateSource(body: String): String? {
val raw = Regex("""update-source:\s*(\S+)""").find(body)?.groupValues?.getOrNull(1) ?: return null
// 加扰格式:Base64(反转(XOR(明文, key)))。尝试完整解扰,失败则依次尝试 Base64/明文。
val b64 = runCatching { java.util.Base64.getDecoder().decode(raw) }.getOrNull() ?: return raw
val unscrambled = unscramble(b64)
return unscrambled ?: runCatching { String(b64, Charsets.UTF_8) }.getOrNull() ?: raw
}
/** 解扰:反转字节 → XOR 每字节 → UTF-8 字符串。 */
private fun unscramble(data: ByteArray): String? = runCatching {
val key = SCRAMBLE_KEY
val reversed = data.reversedArray()
String(ByteArray(reversed.size) { i -> ((reversed[i].toInt() and 0xFF) xor (key[i % key.size].toInt() and 0xFF)).toByte() }, Charsets.UTF_8)
}.getOrNull()
companion object {
const val GITHUB_API = "https://api.github.com/repos/lsxf/a2i/releases/latest"
const val CHECK_INTERVAL_MS = 24L * 60 * 60 * 1000
private val SCRAMBLE_KEY = byteArrayOf(0x4B, 0x6F, 0x72, 0x6E) // "Korn"
}
fun formatSize(bytes: Long): String {
if (bytes <= 0) return ""
val kb = bytes / 1024.0
return if (kb >= 1024) String.format("%.1f MB", kb / 1024) else String.format("%.0f KB", kb)
}
}
@@ -9,47 +9,6 @@ data class BarkServer(
val name: String, val name: String,
val server: String, val server: String,
val deviceKey: String, val deviceKey: String,
val enabled: Boolean = true,
) )
const val OFFICIAL_BARK_SERVER = "https://api.day.app" const val OFFICIAL_BARK_SERVER = "https://api.day.app"
@Serializable
data class NtfyServer(
val id: String = UUID.randomUUID().toString(),
val name: String,
val server: String = "https://ntfy.sh",
val topic: String = "",
val token: String = "",
val enabled: Boolean = true,
)
@Serializable
data class MeowServer(
val id: String = UUID.randomUUID().toString(),
val name: String,
val server: String = "",
val deviceKey: String = "",
val enabled: Boolean = true,
)
@Serializable
data class EmailServer(
val id: String = UUID.randomUUID().toString(),
val name: String,
val host: String = "",
val port: Int = 465,
val user: String = "",
val password: String = "",
val from: String = "",
val to: String = "",
val enabled: Boolean = true,
)
@Serializable
data class SmsTarget(
val id: String = UUID.randomUUID().toString(),
val name: String,
val number: String = "",
val enabled: Boolean = true,
)
@@ -3,12 +3,9 @@ package com.a2i.forwarder.service
import android.service.notification.NotificationListenerService import android.service.notification.NotificationListenerService
import android.service.notification.StatusBarNotification import android.service.notification.StatusBarNotification
import com.a2i.forwarder.A2iApp import com.a2i.forwarder.A2iApp
import com.a2i.forwarder.core.BarkSender import com.a2i.forwarder.core.BarkClient
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.PendingPush import com.a2i.forwarder.core.PendingPush
import com.a2i.forwarder.core.RetryWorker import com.a2i.forwarder.core.RetryWorker
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
@@ -43,10 +40,7 @@ class NotifyListenerService : NotificationListenerService() {
val msg = d.message val msg = d.message
if (msg == null) { if (msg == null) {
// 静默过滤(如持续性通知)不写日志
if (!d.silent) {
app.logStore.addLog(ForwardLog(now, sbn.packageName, d.appLabel, "", "", "filtered", d.reason)) app.logStore.addLog(ForwardLog(now, sbn.packageName, d.appLabel, "", "", "filtered", d.reason))
}
return return
} }
@@ -61,27 +55,21 @@ class NotifyListenerService : NotificationListenerService() {
return return
} }
val servers = app.settings.servers.value.filter { it.enabled && it.deviceKey.isNotBlank() } val server = app.settings.currentServer.value
if (servers.isEmpty()) { if (server == null || server.deviceKey.isBlank()) {
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 key"))
EmailSender.trySend(app, msg.title ?: d.appLabel, msg.body, scope)
NtfySender.trySend(app, msg.title ?: d.appLabel, msg.body, scope)
MeowSender.trySend(app, msg.title ?: d.appLabel, msg.body, scope)
return return
} }
val result = BarkSender.pushToEnabled(servers, msg, app.settings.barkStopOnFirst.value) val result = BarkClient(server).push(msg)
if (result.isSuccess) { if (result.isSuccess) {
app.logStore.addLog(ForwardLog(now, sbn.packageName, d.appLabel, msg.title, msg.body, "sent", "")) app.logStore.addLog(ForwardLog(now, sbn.packageName, d.appLabel, msg.title, msg.body, "sent", ""))
} else { } else {
val reason = result.exceptionOrNull()?.message ?: "错误" val reason = result.exceptionOrNull()?.message ?: "错误"
app.logStore.addLog(ForwardLog(now, sbn.packageName, d.appLabel, msg.title, msg.body, "failed", reason)) app.logStore.addLog(ForwardLog(now, sbn.packageName, d.appLabel, msg.title, msg.body, "failed", reason))
app.logStore.addPending( app.logStore.addPending(
PendingPush(UUID.randomUUID().toString(), now, servers.first().id, json.encodeToString(msg)) PendingPush(UUID.randomUUID().toString(), now, server.id, json.encodeToString(msg))
) )
} }
EmailSender.trySend(app, msg.title ?: d.appLabel, msg.body, scope)
NtfySender.trySend(app, msg.title ?: d.appLabel, msg.body, scope)
MeowSender.trySend(app, msg.title ?: d.appLabel, msg.body, scope)
} }
} }
@@ -4,7 +4,6 @@ import android.content.Context
import androidx.datastore.core.DataStore import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.Preferences import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.edit import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.intPreferencesKey
import androidx.datastore.preferences.core.stringPreferencesKey import androidx.datastore.preferences.core.stringPreferencesKey
import androidx.datastore.preferences.core.stringSetPreferencesKey import androidx.datastore.preferences.core.stringSetPreferencesKey
import androidx.datastore.preferences.preferencesDataStore import androidx.datastore.preferences.preferencesDataStore
@@ -26,7 +25,6 @@ class AppRulesStore(
val MODE = stringPreferencesKey("mode") val MODE = stringPreferencesKey("mode")
val BLACK = stringSetPreferencesKey("blacklist") val BLACK = stringSetPreferencesKey("blacklist")
val WHITE = stringSetPreferencesKey("whitelist") val WHITE = stringSetPreferencesKey("whitelist")
val BL_VERSION = intPreferencesKey("bl_version")
} }
val mode = MutableStateFlow(RuleMode.BLACKLIST) val mode = MutableStateFlow(RuleMode.BLACKLIST)
@@ -37,14 +35,7 @@ class AppRulesStore(
scope.launch { scope.launch {
ds.data.collect { p -> ds.data.collect { p ->
mode.value = runCatching { RuleMode.valueOf(p[K.MODE] ?: "BLACKLIST") }.getOrDefault(RuleMode.BLACKLIST) mode.value = runCatching { RuleMode.valueOf(p[K.MODE] ?: "BLACKLIST") }.getOrDefault(RuleMode.BLACKLIST)
// 默认黑名单版本迁移:把新增的噪音包补进老用户的黑名单(只增不删) blacklist.value = p[K.BLACK] ?: defaultBlacklist
val ver = p[K.BL_VERSION] ?: 1
var black = p[K.BLACK] ?: defaultBlacklist
if (ver < DEFAULT_BL_VERSION) {
black = black + blAdded
ds.edit { it[K.BLACK] = black; it[K.BL_VERSION] = DEFAULT_BL_VERSION }
}
blacklist.value = black
whitelist.value = p[K.WHITE] ?: emptySet() whitelist.value = p[K.WHITE] ?: emptySet()
} }
} }
@@ -78,20 +69,6 @@ class AppRulesStore(
} }
companion object { companion object {
private const val DEFAULT_BL_VERSION = 3
// 默认黑名单增量(累积,用于老用户版本迁移,只补不删)
private val blAdded = setOf(
// v2
"com.android.soundrecorder", // 录音机
"com.google.android.apps.photos", // 相册
"com.xiaomi.finddevice", // 查找手机
"com.miui.passwords", // 密码管理
// v3:通话过程中的系统状态通知,会与来电(PhoneCallMonitor)重复
"com.android.server.telecom", // 通话管理
"com.xiaomi.aiasst.service", // AI通话
)
val defaultBlacklist = setOf( val defaultBlacklist = setOf(
// Android 系统 // Android 系统
"com.android.systemui", "com.android.systemui",
@@ -114,14 +91,6 @@ class AppRulesStore(
"com.google.android.gms.persistent", "com.google.android.gms.persistent",
// a2i 自身 // a2i 自身
"com.a2i.forwarder", "com.a2i.forwarder",
// [v2] 纯噪音 App:转发无意义,默认屏蔽
"com.android.soundrecorder", // 录音机
"com.google.android.apps.photos", // 相册
"com.xiaomi.finddevice", // 查找手机
"com.miui.passwords", // 密码管理
// [v3] 通话状态噪音:来电由 PhoneCallMonitor 独立处理,这两个会重复
"com.android.server.telecom", // 通话管理
"com.xiaomi.aiasst.service", // AI通话
) )
} }
} }
@@ -10,11 +10,7 @@ import androidx.datastore.preferences.core.longPreferencesKey
import androidx.datastore.preferences.core.stringPreferencesKey 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.MeowServer
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 kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
@@ -35,6 +31,7 @@ class SettingsStore(
private object K { private object K {
val SERVERS = stringPreferencesKey("servers") val SERVERS = stringPreferencesKey("servers")
val CURRENT = stringPreferencesKey("current")
val ICON_PREFIX = stringPreferencesKey("icon_prefix") val ICON_PREFIX = stringPreferencesKey("icon_prefix")
val FWD = booleanPreferencesKey("forwarding") val FWD = booleanPreferencesKey("forwarding")
val CODE = booleanPreferencesKey("code") val CODE = booleanPreferencesKey("code")
@@ -42,42 +39,14 @@ class SettingsStore(
val SPECIAL = booleanPreferencesKey("special") val SPECIAL = booleanPreferencesKey("special")
val SKIP_ONGOING = booleanPreferencesKey("skip_ongoing") val SKIP_ONGOING = booleanPreferencesKey("skip_ongoing")
val AD_KW = stringPreferencesKey("ad_keywords") val AD_KW = stringPreferencesKey("ad_keywords")
// 各通道「发通一条止 / 全发」开关 // 短信兜底
val BARK_STOP_FIRST = booleanPreferencesKey("bark_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 SMS_STOP_FIRST = booleanPreferencesKey("sms_stop_first")
// 短信兜底(目标号已列表化;运营商/余额/限频保持全局)
val SMS_FALLBACK = booleanPreferencesKey("sms_fallback") val SMS_FALLBACK = booleanPreferencesKey("sms_fallback")
val SMS_TARGETS = stringPreferencesKey("sms_targets") val SMS_TARGET = stringPreferencesKey("sms_target")
val SMS_CARRIER = stringPreferencesKey("sms_carrier") val SMS_CARRIER = stringPreferencesKey("sms_carrier")
val SMS_QUOTA = intPreferencesKey("sms_quota") val SMS_QUOTA = intPreferencesKey("sms_quota")
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 EMAIL_ENABLED = booleanPreferencesKey("email_enabled")
val EMAIL_SERVERS = stringPreferencesKey("email_servers")
val NTFY_SERVERS = stringPreferencesKey("ntfy_servers")
val MEOW_SERVERS = stringPreferencesKey("meow_servers")
// 应用更新
val UPDATE_CHECK_AUTO = booleanPreferencesKey("update_check_auto")
val LAST_UPDATE_CHECK = longPreferencesKey("last_update_check")
val IGNORED_VERSION = stringPreferencesKey("ignored_version")
// 配置版本(用于旧单组配置迁移)
val CONFIG_VERSION = intPreferencesKey("config_version")
// ---- 旧单组键(仅迁移用,迁移后清理)----
val SMS_TARGET = stringPreferencesKey("sms_target")
val EMAIL_HOST = stringPreferencesKey("email_smtp_host")
val EMAIL_PORT = intPreferencesKey("email_smtp_port")
val EMAIL_USER = stringPreferencesKey("email_smtp_user")
val EMAIL_PASS = stringPreferencesKey("email_smtp_pass")
val EMAIL_FROM = stringPreferencesKey("email_from")
val EMAIL_TO = stringPreferencesKey("email_to")
val NTFY_SERVER = stringPreferencesKey("ntfy_server")
val NTFY_TOPIC = stringPreferencesKey("ntfy_topic")
val NTFY_TOKEN = stringPreferencesKey("ntfy_token")
} }
private val defaultServers = listOf( private val defaultServers = listOf(
@@ -87,17 +56,8 @@ class SettingsStore(
private val _servers = MutableStateFlow(defaultServers) private val _servers = MutableStateFlow(defaultServers)
val servers = _servers.asStateFlow() val servers = _servers.asStateFlow()
private val _ntfyServers = MutableStateFlow<List<NtfyServer>>(emptyList()) private val _currentServerId = MutableStateFlow(ID_OFFICIAL)
val ntfyServers = _ntfyServers.asStateFlow() val currentServer = MutableStateFlow<BarkServer?>(defaultServers.first())
private val _meowServers = MutableStateFlow<List<MeowServer>>(emptyList())
val meowServers = _meowServers.asStateFlow()
private val _emailServers = MutableStateFlow<List<EmailServer>>(emptyList())
val emailServers = _emailServers.asStateFlow()
private val _smsTargets = MutableStateFlow<List<SmsTarget>>(emptyList())
val smsTargets = _smsTargets.asStateFlow()
val iconPrefix = MutableStateFlow(DEFAULT_ICON_PREFIX) val iconPrefix = MutableStateFlow(DEFAULT_ICON_PREFIX)
val forwardingEnabled = MutableStateFlow(true) val forwardingEnabled = MutableStateFlow(true)
@@ -105,30 +65,16 @@ class SettingsStore(
val adFilterEnabled = MutableStateFlow(true) val adFilterEnabled = MutableStateFlow(true)
val specialEnabled = MutableStateFlow(true) val specialEnabled = MutableStateFlow(true)
val skipOngoing = MutableStateFlow(true) val skipOngoing = MutableStateFlow(true)
val adKeywords = MutableStateFlow<List<String>>(emptyList()) val adKeywords = MutableStateFlow<List<String>>(emptyList()) // 用户自定义关键词
// 各通道「发通一条止 / 全发」 // 短信兜底
val barkStopOnFirst = MutableStateFlow(true)
val ntfyStopOnFirst = MutableStateFlow(false)
val meowStopOnFirst = MutableStateFlow(false)
val emailStopOnFirst = MutableStateFlow(false)
val smsStopOnFirst = MutableStateFlow(false)
// 短信兜底(全局)
val smsFallbackEnabled = MutableStateFlow(false) val smsFallbackEnabled = MutableStateFlow(false)
val smsCarrier = MutableStateFlow("auto") val smsTargetNumber = MutableStateFlow("")
val smsManualQuota = MutableStateFlow(0) val smsCarrier = MutableStateFlow("auto") // "auto" 或 Carrier.name
val smsBalance = MutableStateFlow(-1) val smsManualQuota = MutableStateFlow(0) // 手动额度(自动解析失败时回退用)
val smsSuspended = MutableStateFlow(false) val smsBalance = MutableStateFlow(-1) // 缓存余额,-1=未知
val lastSmsSentTime = MutableStateFlow(0L) val smsSuspended = MutableStateFlow(false) // 余额 ≤5 自动挂起
val lastSmsSentTime = MutableStateFlow(0L) // 限频时间戳
// 电邮总开关
val emailForwardingEnabled = MutableStateFlow(false)
// 应用更新
val updateCheckEnabled = MutableStateFlow(true)
val lastUpdateCheckTime = MutableStateFlow(0L)
val ignoredVersion = MutableStateFlow("")
init { init {
scope.launch { scope.launch {
@@ -137,6 +83,8 @@ class SettingsStore(
?.let { runCatching { json.decodeFromString<List<BarkServer>>(it) }.getOrNull() } ?.let { runCatching { json.decodeFromString<List<BarkServer>>(it) }.getOrNull() }
?: defaultServers ?: defaultServers
_servers.value = list _servers.value = list
_currentServerId.value = p[K.CURRENT] ?: ID_OFFICIAL
currentServer.value = list.firstOrNull { it.id == _currentServerId.value } ?: list.firstOrNull()
iconPrefix.value = p[K.ICON_PREFIX] ?: DEFAULT_ICON_PREFIX iconPrefix.value = p[K.ICON_PREFIX] ?: DEFAULT_ICON_PREFIX
forwardingEnabled.value = p[K.FWD] ?: true forwardingEnabled.value = p[K.FWD] ?: true
codeEnabled.value = p[K.CODE] ?: true codeEnabled.value = p[K.CODE] ?: true
@@ -146,179 +94,55 @@ class SettingsStore(
adKeywords.value = p[K.AD_KW] adKeywords.value = p[K.AD_KW]
?.let { runCatching { json.decodeFromString<List<String>>(it) }.getOrNull() } ?.let { runCatching { json.decodeFromString<List<String>>(it) }.getOrNull() }
?: emptyList() ?: emptyList()
// 各通道 stopOnFirst // 短信兜底
barkStopOnFirst.value = p[K.BARK_STOP_FIRST] ?: true
ntfyStopOnFirst.value = p[K.NTFY_STOP_FIRST] ?: false
meowStopOnFirst.value = p[K.MEOW_STOP_FIRST] ?: false
emailStopOnFirst.value = p[K.EMAIL_STOP_FIRST] ?: false
smsStopOnFirst.value = p[K.SMS_STOP_FIRST] ?: false
// 短信兜底全局
smsFallbackEnabled.value = p[K.SMS_FALLBACK] ?: false smsFallbackEnabled.value = p[K.SMS_FALLBACK] ?: false
smsTargetNumber.value = p[K.SMS_TARGET] ?: ""
smsCarrier.value = p[K.SMS_CARRIER] ?: "auto" smsCarrier.value = p[K.SMS_CARRIER] ?: "auto"
smsManualQuota.value = p[K.SMS_QUOTA] ?: 0 smsManualQuota.value = p[K.SMS_QUOTA] ?: 0
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
// 电邮总开关
emailForwardingEnabled.value = p[K.EMAIL_ENABLED] ?: false
// 应用更新
updateCheckEnabled.value = p[K.UPDATE_CHECK_AUTO] ?: true
lastUpdateCheckTime.value = p[K.LAST_UPDATE_CHECK] ?: 0L
ignoredVersion.value = p[K.IGNORED_VERSION] ?: ""
// 列表配置
_ntfyServers.value = p[K.NTFY_SERVERS]
?.let { runCatching { json.decodeFromString<List<NtfyServer>>(it) }.getOrNull() }
?: emptyList()
_meowServers.value = p[K.MEOW_SERVERS]
?.let { runCatching { json.decodeFromString<List<MeowServer>>(it) }.getOrNull() }
?: emptyList()
_emailServers.value = p[K.EMAIL_SERVERS]
?.let { runCatching { json.decodeFromString<List<EmailServer>>(it) }.getOrNull() }
?: emptyList()
_smsTargets.value = p[K.SMS_TARGETS]
?.let { runCatching { json.decodeFromString<List<SmsTarget>>(it) }.getOrNull() }
?: emptyList()
// ---- 旧单组配置迁移(config_version < 2----
val cfgVer = p[K.CONFIG_VERSION] ?: 1
if (cfgVer < 2) {
migrateLegacyConfigs(p)
} }
} }
} }
}
/** 把旧的单组 ntfy/email/sms 配置转成列表的一条。 */
private suspend fun migrateLegacyConfigs(p: Preferences) {
var ntfyList = _ntfyServers.value
var emailList = _emailServers.value
var smsList = _smsTargets.value
val nTopic = p[K.NTFY_TOPIC]
if (!nTopic.isNullOrBlank() && ntfyList.isEmpty()) {
ntfyList = listOf(NtfyServer(name = "ntfy", server = p[K.NTFY_SERVER] ?: "https://ntfy.sh", topic = nTopic, token = p[K.NTFY_TOKEN] ?: ""))
}
val eHost = p[K.EMAIL_HOST]
val eTo = p[K.EMAIL_TO]
if (!eHost.isNullOrBlank() && emailList.isEmpty()) {
emailList = listOf(EmailServer(
name = "邮箱", host = eHost,
port = (p[K.EMAIL_PORT] ?: 465).let { if (it < 25) 465 else it },
user = p[K.EMAIL_USER] ?: "", password = p[K.EMAIL_PASS] ?: "",
from = p[K.EMAIL_FROM] ?: "", to = eTo ?: "",
))
}
val sTarget = p[K.SMS_TARGET]
if (!sTarget.isNullOrBlank() && smsList.isEmpty()) {
smsList = listOf(SmsTarget(name = "iPhone", number = sTarget))
}
ds.edit { m ->
if (ntfyList.isNotEmpty()) {
m[K.NTFY_SERVERS] = json.encodeToString(ntfyList)
m.remove(K.NTFY_SERVER); m.remove(K.NTFY_TOPIC); m.remove(K.NTFY_TOKEN)
}
if (emailList.isNotEmpty()) {
m[K.EMAIL_SERVERS] = json.encodeToString(emailList)
listOf(K.EMAIL_HOST, K.EMAIL_PORT, K.EMAIL_USER, K.EMAIL_PASS, K.EMAIL_FROM, K.EMAIL_TO).forEach { m.remove(it) }
}
if (smsList.isNotEmpty()) {
m[K.SMS_TARGETS] = json.encodeToString(smsList)
m.remove(K.SMS_TARGET)
}
m[K.CONFIG_VERSION] = 2
}
_ntfyServers.value = ntfyList
_emailServers.value = emailList
_smsTargets.value = smsList
}
private suspend fun edit(block: (androidx.datastore.preferences.core.MutablePreferences) -> Unit) = private suspend fun edit(block: (androidx.datastore.preferences.core.MutablePreferences) -> Unit) =
ds.edit(block) ds.edit(block)
// ---- Bark 服务器 CRUD ----
private suspend fun saveServers(list: List<BarkServer>) { private suspend fun saveServers(list: List<BarkServer>) {
edit { it[K.SERVERS] = json.encodeToString(list) } edit { it[K.SERVERS] = json.encodeToString(list) }
_servers.value = list _servers.value = list
refreshCurrent()
} }
suspend fun addServer(name: String, server: String, key: String): BarkServer { suspend fun addServer(name: String, server: String, key: String): BarkServer {
val s = BarkServer(name = name.trim(), server = server.trim().removeSuffix("/"), deviceKey = key.trim()) val s = BarkServer(name = name.trim(), server = server.trim().removeSuffix("/"), deviceKey = key.trim())
saveServers(_servers.value + s) saveServers(_servers.value + s)
if (_servers.value.count { it.id == s.id } == 1 && currentServer.value?.deviceKey.isNullOrBlank()) {
setCurrent(s.id)
}
return s return s
} }
suspend fun updateServer(s: BarkServer) = saveServers(_servers.value.map { if (it.id == s.id) s.copy(server = s.server.trim().removeSuffix("/")) else it }) suspend fun updateServer(s: BarkServer) = saveServers(_servers.value.map { if (it.id == s.id) s.copy(server = s.server.trim().removeSuffix("/")) else it })
suspend fun deleteServer(id: String) = saveServers(_servers.value.filterNot { it.id == id })
suspend fun moveServer(id: String, up: Boolean) {
val list = _servers.value.toMutableList()
val i = list.indexOfFirst { it.id == id }
if (i in 1..list.lastIndex && up) { java.util.Collections.swap(list, i, i - 1); saveServers(list) }
else if (i in 0 until list.lastIndex && !up) { java.util.Collections.swap(list, i, i + 1); saveServers(list) }
}
suspend fun setServerEnabled(id: String, enabled: Boolean) = saveServers(_servers.value.map { if (it.id == id) it.copy(enabled = enabled) else it })
// ---- ntfy 服务器 CRUD ---- suspend fun deleteServer(id: String) {
private suspend fun saveNtfyServers(list: List<NtfyServer>) { val list = _servers.value.filterNot { it.id == id }
edit { it[K.NTFY_SERVERS] = json.encodeToString(list) } saveServers(list)
_ntfyServers.value = list if (_currentServerId.value == id) {
setCurrent(if (list.isEmpty()) "" else list.first().id)
} }
suspend fun addNtfy(s: NtfyServer) { saveNtfyServers(_ntfyServers.value + s) }
suspend fun updateNtfy(s: NtfyServer) = saveNtfyServers(_ntfyServers.value.map { if (it.id == s.id) s else it })
suspend fun deleteNtfy(id: String) = saveNtfyServers(_ntfyServers.value.filterNot { it.id == id })
suspend fun moveNtfy(id: String, up: Boolean) {
val list = _ntfyServers.value.toMutableList()
val i = list.indexOfFirst { it.id == id }
if (i in 1..list.lastIndex && up) { java.util.Collections.swap(list, i, i - 1); saveNtfyServers(list) }
else if (i in 0 until list.lastIndex && !up) { java.util.Collections.swap(list, i, i + 1); saveNtfyServers(list) }
} }
suspend fun setNtfyEnabled(id: String, enabled: Boolean) = saveNtfyServers(_ntfyServers.value.map { if (it.id == id) it.copy(enabled = enabled) else it })
// ---- Meow 服务器 CRUD ---- suspend fun setCurrent(id: String) {
private suspend fun saveMeowServers(list: List<MeowServer>) { edit { it[K.CURRENT] = id }
edit { it[K.MEOW_SERVERS] = json.encodeToString(list) } _currentServerId.value = id
_meowServers.value = list refreshCurrent()
} }
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 ---- private fun refreshCurrent() {
private suspend fun saveEmailServers(list: List<EmailServer>) { currentServer.value = _servers.value.firstOrNull { it.id == _currentServerId.value } ?: _servers.value.firstOrNull()
edit { it[K.EMAIL_SERVERS] = json.encodeToString(list) }
_emailServers.value = list
} }
suspend fun addEmail(s: EmailServer) { saveEmailServers(_emailServers.value + s) }
suspend fun updateEmail(s: EmailServer) = saveEmailServers(_emailServers.value.map { if (it.id == s.id) s else it })
suspend fun deleteEmail(id: String) = saveEmailServers(_emailServers.value.filterNot { it.id == id })
suspend fun moveEmail(id: String, up: Boolean) {
val list = _emailServers.value.toMutableList()
val i = list.indexOfFirst { it.id == id }
if (i in 1..list.lastIndex && up) { java.util.Collections.swap(list, i, i - 1); saveEmailServers(list) }
else if (i in 0 until list.lastIndex && !up) { java.util.Collections.swap(list, i, i + 1); saveEmailServers(list) }
}
suspend fun setEmailEnabled(id: String, enabled: Boolean) = saveEmailServers(_emailServers.value.map { if (it.id == id) it.copy(enabled = enabled) else it })
// ---- 短信目标 CRUD ----
private suspend fun saveSmsTargets(list: List<SmsTarget>) {
edit { it[K.SMS_TARGETS] = json.encodeToString(list) }
_smsTargets.value = list
}
suspend fun addSmsTarget(s: SmsTarget) { saveSmsTargets(_smsTargets.value + s) }
suspend fun updateSmsTarget(s: SmsTarget) = saveSmsTargets(_smsTargets.value.map { if (it.id == s.id) s else it })
suspend fun deleteSmsTarget(id: String) = saveSmsTargets(_smsTargets.value.filterNot { it.id == id })
suspend fun moveSmsTarget(id: String, up: Boolean) {
val list = _smsTargets.value.toMutableList()
val i = list.indexOfFirst { it.id == id }
if (i in 1..list.lastIndex && up) { java.util.Collections.swap(list, i, i - 1); saveSmsTargets(list) }
else if (i in 0 until list.lastIndex && !up) { java.util.Collections.swap(list, i, i + 1); saveSmsTargets(list) }
}
suspend fun setSmsTargetEnabled(id: String, enabled: Boolean) = saveSmsTargets(_smsTargets.value.map { if (it.id == id) it.copy(enabled = enabled) else it })
suspend fun setIconPrefix(v: String) { edit { it[K.ICON_PREFIX] = v }; iconPrefix.value = v } suspend fun setIconPrefix(v: String) { edit { it[K.ICON_PREFIX] = v }; iconPrefix.value = v }
suspend fun setForwarding(v: Boolean) { edit { it[K.FWD] = v }; forwardingEnabled.value = v } suspend fun setForwarding(v: Boolean) { edit { it[K.FWD] = v }; forwardingEnabled.value = v }
@@ -331,25 +155,14 @@ class SettingsStore(
adKeywords.value = list adKeywords.value = list
} }
suspend fun setBarkStopOnFirst(v: Boolean) { edit { it[K.BARK_STOP_FIRST] = v }; barkStopOnFirst.value = v }
suspend fun setNtfyStopOnFirst(v: Boolean) { edit { it[K.NTFY_STOP_FIRST] = v }; ntfyStopOnFirst.value = v }
suspend fun 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 setSmsStopOnFirst(v: Boolean) { edit { it[K.SMS_STOP_FIRST] = v }; smsStopOnFirst.value = v }
suspend fun setSmsFallback(v: Boolean) { edit { it[K.SMS_FALLBACK] = v }; smsFallbackEnabled.value = v } suspend fun setSmsFallback(v: Boolean) { edit { it[K.SMS_FALLBACK] = v }; smsFallbackEnabled.value = v }
suspend fun setSmsTargetNumber(v: String) { edit { it[K.SMS_TARGET] = v }; smsTargetNumber.value = v }
suspend fun setSmsCarrier(v: String) { edit { it[K.SMS_CARRIER] = v }; smsCarrier.value = v } suspend fun setSmsCarrier(v: String) { edit { it[K.SMS_CARRIER] = v }; smsCarrier.value = v }
suspend fun setSmsManualQuota(v: Int) { edit { it[K.SMS_QUOTA] = v }; smsManualQuota.value = v } suspend fun setSmsManualQuota(v: Int) { edit { it[K.SMS_QUOTA] = v }; smsManualQuota.value = v }
suspend fun setSmsBalance(v: Int) { edit { it[K.SMS_BALANCE] = v }; smsBalance.value = v } suspend fun 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 setUpdateCheckEnabled(v: Boolean) { edit { it[K.UPDATE_CHECK_AUTO] = v }; updateCheckEnabled.value = v }
suspend fun setLastUpdateCheck(v: Long) { edit { it[K.LAST_UPDATE_CHECK] = v }; lastUpdateCheckTime.value = v }
suspend fun setIgnoredVersion(v: String) { edit { it[K.IGNORED_VERSION] = v }; ignoredVersion.value = v }
suspend fun setEmailForwarding(v: Boolean) { edit { it[K.EMAIL_ENABLED] = v }; emailForwardingEnabled.value = v }
suspend fun snapshot() = ds.data.first() suspend fun snapshot() = ds.data.first()
companion object { companion object {
@@ -7,7 +7,6 @@ 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
@@ -69,9 +68,6 @@ 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(
@@ -83,19 +79,12 @@ fun SectionCard(
) { ) {
Column(Modifier.padding(16.dp)) { Column(Modifier.padding(16.dp)) {
if (title != null) { if (title != null) {
if (headerAction == null) {
Row(verticalAlignment = Alignment.CenterVertically) {
if (titleIcon != null) {
Icon(titleIcon, null, tint = titleIconTint, modifier = Modifier.size(22.dp))
Spacer(Modifier.width(8.dp))
}
Text( Text(
title, title,
style = MaterialTheme.typography.titleMedium, style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold, fontWeight = FontWeight.SemiBold,
modifier = Modifier.padding(bottom = if (subtitle == null) 10.dp else 2.dp), modifier = Modifier.padding(bottom = if (subtitle == null) 10.dp else 2.dp),
) )
}
if (subtitle != null) { if (subtitle != null) {
Text( Text(
subtitle, subtitle,
@@ -104,38 +93,6 @@ fun SectionCard(
modifier = Modifier.padding(bottom = 10.dp), 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()
} }
@@ -1,20 +1,7 @@
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
@@ -27,15 +14,8 @@ 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
@@ -43,13 +23,11 @@ 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)
@@ -68,11 +46,6 @@ fun AppRoot() {
val current = backStack?.destination?.route val current = backStack?.destination?.route
val showBar = current in tabRoutes val showBar = current in tabRoutes
val app = A2iApp.instance
val updateInfo by app.updateChecker.updateInfo.collectAsState()
var showUpdateDialog by remember { mutableStateOf(false) }
Box {
Scaffold( Scaffold(
containerColor = MaterialTheme.colorScheme.background, containerColor = MaterialTheme.colorScheme.background,
bottomBar = { bottomBar = {
@@ -123,49 +96,4 @@ fun AppRoot() {
composable("log") { LogScreen(onBack = { nav.popBackStack() }) } composable("log") { LogScreen(onBack = { nav.popBackStack() }) }
} }
} }
// 浮动更新提醒小图标(跨所有页面,缓慢闪烁)
if (updateInfo != null) {
UpdateBadge(
modifier = Modifier.align(Alignment.TopEnd),
onClick = { showUpdateDialog = true },
)
}
}
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("过滤与优化", "减少噪音,保留真正需要转发的通知") } item { ScreenTitle("过滤与优化", "减少噪音,保留真正需要同步到 iOS 的通知") }
item { item {
SectionCard("功能开关", subtitle = "规则按通知处理流水线顺序执行") { SectionCard("功能开关", subtitle = "规则按通知处理流水线顺序执行") {
@@ -2,7 +2,6 @@ package com.a2i.forwarder.ui.screens
import android.content.Intent import android.content.Intent
import android.provider.Settings import android.provider.Settings
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
@@ -23,9 +22,7 @@ import androidx.compose.material.icons.filled.Notifications
import androidx.compose.material.icons.filled.PowerSettingsNew import androidx.compose.material.icons.filled.PowerSettingsNew
import androidx.compose.material.icons.filled.Settings import androidx.compose.material.icons.filled.Settings
import androidx.compose.material.icons.filled.WarningAmber import androidx.compose.material.icons.filled.WarningAmber
import androidx.compose.material.icons.filled.SystemUpdate
import androidx.compose.material3.Button import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.FilledTonalButton import androidx.compose.material3.FilledTonalButton
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
@@ -64,13 +61,11 @@ fun HomeScreen(onOpenLog: () -> Unit, onOpenSettings: () -> Unit) {
val context = LocalContext.current val context = LocalContext.current
val app = A2iApp.instance val app = A2iApp.instance
val forwarding by app.settings.forwardingEnabled.collectAsState() val forwarding by app.settings.forwardingEnabled.collectAsState()
val servers by app.settings.servers.collectAsState() val current by app.settings.currentServer.collectAsState()
val sent by app.logStore.sentCount.collectAsState() val sent by app.logStore.sentCount.collectAsState()
val filtered by app.logStore.filteredCount.collectAsState() val filtered by app.logStore.filteredCount.collectAsState()
val failed by app.logStore.failedCount.collectAsState() val failed by app.logStore.failedCount.collectAsState()
val smsSuspended by app.settings.smsSuspended.collectAsState() val smsSuspended by app.settings.smsSuspended.collectAsState()
val update by app.updateChecker.updateInfo.collectAsState()
var showUpdateDialog by remember { mutableStateOf(false) }
var granted by remember { mutableStateOf(isNotificationListenerEnabled(context)) } var granted by remember { mutableStateOf(isNotificationListenerEnabled(context)) }
val lifecycleOwner = LocalLifecycleOwner.current val lifecycleOwner = LocalLifecycleOwner.current
@@ -95,56 +90,6 @@ fun HomeScreen(onOpenLog: () -> Unit, onOpenSettings: () -> Unit) {
) )
} }
val info = update
if (info != null) {
item {
SectionCard {
Box(modifier = Modifier
.fillMaxWidth()
.background(
brush = Brush.linearGradient(
colors = listOf(MaterialTheme.colorScheme.tertiary, MaterialTheme.colorScheme.primary),
),
shape = RoundedCornerShape(8.dp),
)
.padding(16.dp)
) {
Column {
Row(verticalAlignment = Alignment.CenterVertically) {
Box(
modifier = Modifier
.size(44.dp)
.background(Color.White.copy(alpha = 0.18f), RoundedCornerShape(8.dp)),
contentAlignment = Alignment.Center,
) {
Icon(Icons.Filled.SystemUpdate, null, tint = Color.White, modifier = Modifier.size(26.dp))
}
Spacer(Modifier.width(12.dp))
Column(Modifier.weight(1f)) {
Text("发现新版本", style = MaterialTheme.typography.titleLarge, color = Color.White, fontWeight = FontWeight.Bold)
Text(
"v${info.latestVersion} · ${formatSize(info.apkSize)} · 点击立即更新",
style = MaterialTheme.typography.bodyMedium,
color = Color.White.copy(alpha = 0.88f),
)
}
StatusBadge("🔄", tint = Color.White)
}
Spacer(Modifier.size(12.dp))
Button(
onClick = { showUpdateDialog = true },
modifier = Modifier.fillMaxWidth(),
colors = ButtonDefaults.buttonColors(
containerColor = Color.White,
contentColor = MaterialTheme.colorScheme.primary,
),
) { Text("🚀 立即更新到 v${info.latestVersion}", fontWeight = FontWeight.Bold) }
}
}
}
}
}
if (smsSuspended) { if (smsSuspended) {
item { item {
SectionCard { SectionCard {
@@ -178,7 +123,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) "系统通知将按规则处理并多通道转发" else "授权后才能读取 Android 通知并转发", if (granted) "系统通知将按规则处理并推送到 Bark" else "授权后才能读取 Android 通知并转发",
style = MaterialTheme.typography.bodySmall, style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant, color = MaterialTheme.colorScheme.onSurfaceVariant,
) )
@@ -202,21 +147,28 @@ fun HomeScreen(onOpenLog: () -> Unit, onOpenSettings: () -> Unit) {
SectionCard("运行状态", subtitle = "转发能力、目标服务器和日志入口") { SectionCard("运行状态", subtitle = "转发能力、目标服务器和日志入口") {
SwitchRow( SwitchRow(
title = "通知转发", title = "通知转发",
subtitle = if (forwarding) "新通知会继续多通道转发" else "当前已暂停所有转发", subtitle = if (forwarding) "新通知会继续转发到 Bark" 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,
) { v -> ) { v ->
app.appScope.launch { app.settings.setForwarding(v) } app.appScope.launch { app.settings.setForwarding(v) }
} }
val enabledServers = servers.filter { it.enabled && it.deviceKey.isNotBlank() }
ClickRow( ClickRow(
title = if (enabledServers.isEmpty()) "未启用 Bark 服务器" else "Bark 服务器${enabledServers.size} 个启用)", title = current?.name?.ifBlank { "未命名服务器" } ?: "未配置 Bark 服务器",
subtitle = enabledServers.firstOrNull()?.server ?: "进入设置勾选要转发的服务", subtitle = current?.server ?: "进入设置添加官方或自建 Bark 服务",
icon = Icons.Filled.Settings, icon = Icons.Filled.Settings,
tint = MaterialTheme.colorScheme.secondary, tint = MaterialTheme.colorScheme.secondary,
onClick = onOpenSettings, onClick = onOpenSettings,
) )
if (current?.deviceKey.isNullOrBlank()) {
Text(
"当前服务器未填写 device key",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.error,
modifier = Modifier.padding(start = 50.dp),
)
}
ClickRow( ClickRow(
title = "查看转发日志", title = "查看转发日志",
subtitle = "最近 300 条处理记录", subtitle = "最近 300 条处理记录",
@@ -248,11 +200,6 @@ fun HomeScreen(onOpenLog: () -> Unit, onOpenSettings: () -> Unit) {
) )
} }
} }
if (showUpdateDialog) {
val info = update
if (info != null) UpdateDialog(info, onDismiss = { showUpdateDialog = false })
}
} }
@Composable @Composable
@@ -285,8 +232,8 @@ private fun HomeHero(
} }
Spacer(Modifier.width(12.dp)) Spacer(Modifier.width(12.dp))
Column(Modifier.weight(1f)) { Column(Modifier.weight(1f)) {
Text("GotMsg", style = MaterialTheme.typography.displaySmall, color = Color.White) Text("a2i", style = MaterialTheme.typography.displaySmall, color = Color.White)
Text("安卓通知多通道转发", style = MaterialTheme.typography.bodyMedium, color = Color.White.copy(alpha = 0.82f)) Text("安卓通知转发到 iOS Bark", 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 +242,7 @@ private fun HomeHero(
} }
Spacer(Modifier.size(14.dp)) Spacer(Modifier.size(14.dp))
Text( Text(
if (granted && forwarding) "监听、过滤、提取验证码和推送链路已就绪。" else "完成授权并开启转发后,通知会按规则转发到各通道", if (granted && forwarding) "监听、过滤、提取验证码和推送链路已就绪。" else "完成授权并开启转发后,通知会按规则推送到 iOS",
style = MaterialTheme.typography.bodyMedium, style = MaterialTheme.typography.bodyMedium,
color = Color.White.copy(alpha = 0.86f), color = Color.White.copy(alpha = 0.86f),
) )
@@ -16,7 +16,6 @@ import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
@@ -26,9 +25,7 @@ import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState 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.remember
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
@@ -54,7 +51,6 @@ fun LogScreen(onBack: () -> Unit) {
val logs by app.logStore.logs.collectAsState() val logs by app.logStore.logs.collectAsState()
val pending by app.logStore.pending.collectAsState() val pending by app.logStore.pending.collectAsState()
val df = remember { SimpleDateFormat("MM-dd HH:mm:ss", Locale.getDefault()) } val df = remember { SimpleDateFormat("MM-dd HH:mm:ss", Locale.getDefault()) }
var showClearDialog by remember { mutableStateOf(false) }
Column(Modifier.fillMaxSize()) { Column(Modifier.fillMaxSize()) {
Surface( Surface(
@@ -70,7 +66,7 @@ fun LogScreen(onBack: () -> Unit) {
Text("转发日志", style = MaterialTheme.typography.titleLarge) Text("转发日志", style = MaterialTheme.typography.titleLarge)
Text("最近 300 条通知处理记录", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant) Text("最近 300 条通知处理记录", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
} }
TextButton(onClick = { showClearDialog = true }) { Text("清空") } TextButton(onClick = { app.appScope.launch { app.logStore.clearLogs() } }) { Text("清空") }
} }
} }
@@ -107,29 +103,6 @@ fun LogScreen(onBack: () -> Unit) {
} }
} }
} }
if (showClearDialog) {
AlertDialog(
onDismissRequest = { showClearDialog = false },
title = { Text("清空日志") },
text = { Text("选择清空范围:") },
confirmButton = {
TextButton(onClick = {
showClearDialog = false
app.appScope.launch { app.logStore.clearLogs() }
}) { Text("仅清空日志") }
},
dismissButton = {
TextButton(onClick = {
showClearDialog = false
app.appScope.launch {
app.logStore.clearLogs()
app.logStore.resetCounters()
}
}) { Text("日志+计数器") }
},
)
}
} }
@Composable @Composable
File diff suppressed because it is too large Load Diff
@@ -1,126 +0,0 @@
package com.a2i.forwarder.ui.screens
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import com.a2i.forwarder.A2iApp
import com.a2i.forwarder.core.UpdateInfo
import kotlinx.coroutines.launch
import java.io.File
import java.util.Locale
@Composable
fun UpdateDialog(info: UpdateInfo, onDismiss: () -> Unit) {
val app = A2iApp.instance
val downloading by app.updateChecker.downloading.collectAsState()
val progress by app.updateChecker.downloadProgress.collectAsState()
val downloadSource by app.updateChecker.downloadSource.collectAsState()
var downloadedFile by remember { mutableStateOf<File?>(null) }
AlertDialog(
onDismissRequest = { if (!downloading) onDismiss() },
title = { Text("发现新版本 v${info.latestVersion}") },
text = {
Column {
Text(
"安装包大小:${formatSize(info.apkSize)}",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Spacer(Modifier.size(8.dp))
Column(
modifier = Modifier
.heightIn(max = 240.dp)
.verticalScroll(rememberScrollState()),
) {
val notes = info.releaseNotes
.lineSequence()
.filterNot { it.contains("update-source") }
.joinToString("\n")
.ifBlank { "(作者未提供更新说明)" }
Text(notes, style = MaterialTheme.typography.bodyMedium)
}
if (downloading) {
Spacer(Modifier.size(12.dp))
LinearProgressIndicator(
progress = { progress / 100f },
modifier = Modifier.fillMaxWidth(),
)
Spacer(Modifier.size(4.dp))
Text(
"正在下载更新…${downloadSource?.let { "(来自 $it" } ?: ""}",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
downloadedFile?.let {
Spacer(Modifier.size(8.dp))
Text(
"下载完成,点击「安装」继续",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.tertiary,
)
}
}
},
confirmButton = {
if (downloadedFile == null) {
Button(
onClick = {
app.appScope.launch {
val f = app.updateChecker.download(info)
if (f != null) downloadedFile = f
}
},
enabled = !downloading,
) {
if (downloading) {
CircularProgressIndicator(Modifier.size(16.dp), strokeWidth = 2.dp)
} else {
Text("下载并安装")
}
}
} else {
Button(onClick = { app.updateChecker.install(downloadedFile!!) }) { Text("安装") }
}
},
dismissButton = {
Row {
TextButton(
onClick = { app.updateChecker.ignoreVersion(info.latestVersion); onDismiss() },
enabled = !downloading,
) { Text("忽略此版本") }
TextButton(onClick = onDismiss, enabled = !downloading) { Text("稍后") }
}
},
)
}
fun formatSize(bytes: Long): String {
if (bytes <= 0) return "未知"
val kb = bytes / 1024.0
return if (kb >= 1024) String.format(Locale.US, "%.1f MB", kb / 1024) else String.format(Locale.US, "%.0f KB", kb)
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.5 KiB

+1 -1
View File
@@ -1,3 +1,3 @@
<resources> <resources>
<string name="app_name">GotMsg 有消息</string> <string name="app_name">a2i</string>
</resources> </resources>
-5
View File
@@ -1,5 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<paths>
<external-files-path name="updates" path="." />
<cache-path name="cache" path="." />
</paths>
Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 521 KiB