38 Commits

Author SHA1 Message Date
song 6b718cac8c feat: multi-config lists for ntfy/email/sms + per-channel stopOnFirst
All four forwarding channels now support multiple configurations (add/
edit/delete/reorder/enable per item, matching Bark server UX):

- model: NtfyServer, EmailServer, SmsTarget data classes
- SettingsStore: ntfyServers/emailServers/smsTargets as List; full CRUD
  (add/update/delete/move/setEnabled); legacy single-config auto-migrated
  to list via config_version; 4 stopOnFirst toggles (bark=true, others
  false)
- BarkSender/NtfySender/EmailSender: pushToEnabled(configs, stopOnFirst)
  — stopOnFirst=true: first success stops; false: send to all
- SmsFallbackForwarder: forward iterates smsTargets per stopOnFirst
- UI: each channel SectionCard has toggle + stopOnFirst switch + config
  list + add button; ConfigRow + ConfigDialog reusable components
- version 1.7.3 -> 1.8.0 (code 16 -> 17)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-09 21:16:23 +08:00
song 9985719f87 fix: startup always checks + dual-source (Gitea+GitHub) take highest
- start(): check on every launch (no 24h throttle) so the banner shows
  after restart even if checked manually moments ago; plus a 24h loop
  while app is resident
- check both Gitea and GitHub release APIs, skip whichever is
  unreachable, notify the higher version
- version 1.7.2 -> 1.7.3 (code 15 -> 16)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-09 20:48:16 +08:00
song 94422fbd76 fix: ntfy use JSON publish API (raw body was treated as attachment)
ntfy.sh now treats any request body (PUT/POST, text/plain) as a file
attachment, so messages never showed in the app. Switch to ntfy's JSON
publish API: POST / with {topic,title,message}.

version 1.7.1 -> 1.7.2 (code 14 -> 15)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-09 20:32:26 +08:00
song 0238b5dd8d feat: eye-catching update banner + cross-channel notify + periodic check
- HomeScreen update banner: gradient background (tertiary→primary), white
  text, large version number, rocket emoji CTA button
- UpdateChecker now auto-notifies via all enabled channels (Bark/ntfy/
  email) when a new version is discovered, with version/size/notes
- Periodic check every 24h via coroutine delay loop (replaces one-shot
  startup check only); also honors updateCheckEnabled toggle
- version 1.7.0 -> 1.7.1 (code 13 -> 14)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-09 18:03:42 +08:00
song 81dfdcb0bd feat: ntfy forwarding support
Parallel push channel alongside Bark and email: POST {server}/{topic}
with optional Bearer auth. Same fire-and-forget pattern as email.

- core/NtfySender: OkHttp PUT to ntfy; retry 3x on IOException
- SettingsStore: ntfyEnabled/server/topic/token (default server ntfy.sh)
- NotifyListenerService + PhoneCallMonitor: NtfySender.trySend after bark
- UI: "ntfy 转发" SectionCard (Bark → ntfy → Email → SMS)
- version 1.6.2 -> 1.7.0 (code 12 -> 13)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-09 17:49:15 +08:00
song 4536c82959 chore: gitignore CLAUDE.md — local reference only
CLAUDE.md is now regenerated from memory, no need to track in repo.
Remove from remote tracking.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-09 17:16:41 +08:00
song 72f5d256ae chore: bump to 1.6.2
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-09 17:04:53 +08:00
song 7eb2d71795 ui: collapse SMS fallback fields when disabled (matching email)
SMS fields (number / quota / carrier / balance) are now hidden when the
toggle is off. Previously entered values are preserved, just hidden.
Email forwarding already had this pattern. Settings page stays compact.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-09 17:02:58 +08:00
song 4c55366fec fix: SMTP default port 465 + corrupt port recovery + click feedback on save buttons
- default SMTP port 587 -> 465 (SMTPS implicit TLS)
- init-collector: auto-correct port < 25 to 465 (previous bug stored 3)
- save-email fallback 587 -> 465
- email / SMS-number / SMS-quota save buttons show "✓ 已保存" for 2s

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-09 16:54:26 +08:00
song 0c6b730115 ui: reorder & rename settings sections
Bark → E-mail → SMS-fallback → Icons → Update → Maintenance

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-09 16:34:23 +08:00
song d9308552a0 feat: email forwarding via SMTPS
Parallel to Bark: when email forwarding is enabled and a recipient is set,
notifications are also sent via SMTP over SSL (port 465, SMTPS). Works
standalone — no Bark server required.

- core/EmailSender: SMTP over SSL (implicit TLS, port 465), AUTH LOGIN,
  UTF-8 subject/body via Base64/MIME; trySend() fire-and-forget helper
- SettingsStore: 7 email settings (enabled, host, port, user, password,
  from, to)
- NotifyListenerService + PhoneCallMonitor: call EmailSender.trySend in
  parallel with Bark forwarding; fires even if no Bark servers configured
- UI: new "电邮转发" SectionCard in settings (toggle + host/port/user/
  password/from/to fields + save button)
- version 1.6.0 -> 1.6.1 (code 10 -> 11); CLAUDE.md synced

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-09 16:17:10 +08:00
song 6e09262538 ui: move server test button to top row, free address space
Row 1: checkbox + name + test/edit/delete. Row 2: full URL + key +
move-up/move-down only — more room for the link.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-09 15:16:33 +08:00
song c908634043 ui: two-line Bark server rows for full address
Row 1: checkbox + name + edit/delete. Row 2: full server URL + key
status + test/move-up/move-down. Stops the address/key from being
truncated and feels far less cramped.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-09 15:06:23 +08:00
song 795c50f329 fix: silence Milink (设备互联) UniversalClipboardService notices
com.milink.service posts empty/high-frequency cross-device clipboard
notices. Add to silentPackages so they're dropped silently (no forward,
no log entry). CLAUDE.md synced.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-09 14:59:04 +08:00
song 97cc6b8b11 feat: multi-server failover forwarding + reorder/enable
Bark servers now form an ordered failover chain: forwarding tries each
enabled server in list order, stopping at the first success.

- model: BarkServer.enabled (defaults true; legacy JSON compatible)
- core/BarkSender.pushToFirst: ordered try-until-success helper, shared
  by NotifyListenerService / PhoneCallMonitor / RetryWorker
- BarkClient.push uses msg.copy(deviceKey=server.deviceKey) so each
  server pushes its own key
- SettingsStore: drop currentServer/setCurrent; add moveServer +
  setServerEnabled; NotificationProcessor no longer sources deviceKey
- UI: ServerRow RadioButton -> Checkbox + per-row test/move-up/move-down;
  remove top "send test push" button; HomeScreen shows enabled count
- version 1.5.1 -> 1.6.0 (code 9 -> 10); CLAUDE.md synced

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-09 14:51:48 +08:00
song e7a7e1c2a5 chore: gitignore gitea-token.txt
Token for Gitea release API; never commit.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-09 14:08:49 +08:00
song 4fcafeaf0d feat: check updates from self-hosted Gitea (CN-reachable)
Switch UpdateChecker source from api.github.com (flaky in CN) to the
self-hosted Gitea at git.sunlunfan.com. Releases must now be published
to Gitea too (github stays as mirror). parseRelease unchanged — Gitea
API fields are GitHub-compatible.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-09 14:05:06 +08:00
song 5b54309124 revert: default icon prefix back to github raw
Prefer github as default; the self-hosted Gitea (now at
https://git.sunlunfan.com) stays available as a manually-entered
alternative. Drops the github->gitea migration added earlier.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-09 13:05:20 +08:00
song 510adfde4d feat: default icon prefix to self-hosted Gitea (CN-reachable)
Switch DEFAULT_ICON_PREFIX from github raw (flaky in CN) to the
self-hosted Gitea at 45.143.131.159:3000/song/a2i. Auto-migrate
existing users whose stored prefix is the old github default; custom
values kept. Icons are already mirrored to Gitea via every push.
CLAUDE.md synced.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-08 19:59:05 +08:00
song 23d218ca4a chore: add Gmail (com.google.android.gm) icon
Extracted from the app's product_logo_gmail_2026_launcher_color_48
(xxxhdpi, 192x192). Existing icons are 192x192, size matches.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-08 19:50:01 +08:00
song fe6390337f fix: silence Mijia "设备状态" notices, keep door-lock alerts
com.xiaomi.smarthome posts both generic status updates (title=米家,
content=设备状态) and concrete events (e.g. 智能门锁开锁). Silence only
the generic "设备状态" ones; door-lock/security alerts still forward.
Filtered silently before the blacklist. CLAUDE.md synced.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-08 19:37:20 +08:00
song fa2fa1dbb0 fix: silently filter VPN client persistent notifications
Surfboard (and other VPN/proxy clients) post a high-frequency persistent
notification showing up/down traffic. Once day.app direct-routing was
configured, these got forwarded to Bark and spammed the log (300 entries).

Add silentPackages (Surfboard / Clash Meta / Clash for Android / v2rayNG)
to NotificationProcessor, filtered silently (no log, no forward) right
after the ongoing-notification check. CLAUDE.md synced.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-08 19:12:41 +08:00
song 76c316783a fix: blacklist call-status noise (telecom / AI-call) via bl v3
Incoming calls are already handled by PhoneCallMonitor (pkg=phone_call);
com.android.server.telecom and com.xiaomi.aiasst.service emit redundant
in-call status notices. Add them to default blacklist (bl_version 3),
migrated into existing users' blacklist (add-only).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-08 15:59:51 +08:00
song c2fba02048 fix: retry transient push failures + blacklist noisy apps
Investigated 9 failed forwards on device: all "connection closed" to
api.day.app, while ping/curl to the server succeed -> not network-blocked,
but OkHttp transient connection drops that aren't retried (POST read-phase
connection close is not covered by retryOnConnectionFailure).

- BarkClient.push: manual retry up to 3x (600/1200ms backoff) on
  IOException (connection closed/timeout/TLS reset); HTTP 4xx/5xx not retried.
- AppRulesStore: add recorder/gallery/find-device/password-manager to default
  blacklist; versioned migration (bl_version) merges the new entries into
  existing users' blacklist (add-only).
- version 1.5.0 -> 1.5.1 (code 8 -> 9); CLAUDE.md synced.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-08 15:52:18 +08:00
song bd15424c5f docs: sync CLAUDE.md to v1.5.0
- version 1.2.0/code 3 -> 1.5.0/code 8 (was badly stale)
- package table: add PhoneCallMonitor/ConnectivityMonitor/CarrierDetector/
  CarrierBalanceQuery/SmsFallbackForwarder/UpdateChecker; fix A2iApp holders
- core data flow: add offline SMS-fallback branch + phone/update side paths
- pipeline: 12 -> 14 steps (sent-SMS filter, carrier-balance interception)
- release flow: actual python+gh-token.txt process (no gh CLI)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-08 15:21:59 +08:00
song 610ac094ed chore: gitignore gh-token.txt and *.token secrets
Local credential file used for release uploads; must never be committed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-08 14:49:39 +08:00
song f364ae55d3 feat: in-app update check + download + install
Auto-check GitHub Releases latest on launch (24h throttle), show an
update banner on the home screen, download the APK in-app with progress,
and trigger the system installer via FileProvider + REQUEST_INSTALL_PACKAGES.

- core/UpdateChecker: fetch releases/latest, semver compare, stream APK
  to externalFilesDir, install via FileProvider (handles MIUI unknown-sources).
- SettingsStore: updateCheckEnabled / lastUpdateCheckTime / ignoredVersion.
- HomeScreen: update banner + UpdateDialog.
- SettingsScreen: "关于与更新" card with auto-check toggle and manual check.
- manifest: REQUEST_INSTALL_PACKAGES + FileProvider; new res/xml/file_paths.xml.
- version 1.4.2 -> 1.5.0 (versionCode 7 -> 8).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-08 13:54:53 +08:00
song 036b4d3201 docs: 新增「自建 Bark 服务」章节(Vercel/Cloudflare/VPS 三方案)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-08 12:40:00 +08:00
song 392620e0f1 feat: add icons for Telegram, Alipay, DingTalk, Mijia, AITO
- org.telegram.messenger (Telegram)
- com.eg.android.AlipayGphone (支付宝)
- com.alibaba.android.rimet (钉钉)
- com.xiaomi.smarthome (米家)
- app.huawei.auto (鸿蒙智行/AITO 问界)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-08 12:30:20 +08:00
song 561d0460e3 feat: 清空日志弹窗选择范围(仅日志 / 日志+计数器)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-08 10:33:50 +08:00
song 15e27db04a fix: 清空日志时同步重置首页计数器
clearLogs() 同时归零 sent/filtered/failed 三个累计计数器
版本 1.4.1 → 1.4.2

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-08 10:30:01 +08:00
song d1afbd2425 feat: silent-filter ongoing notifications (no log entry)
持续性通知(音乐/下载/通话中等)不再写日志,静默丢弃
Decision 新增 silent 字段,NotifyListenerService 跳过静默过滤的日志
版本 1.4.0 → 1.4.1

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-08 10:17:28 +08:00
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
25 changed files with 288 additions and 1549 deletions
+88 -180
View File
@@ -1,209 +1,123 @@
# GotMsg ·有消息 # A2i ·安到果(按倒过[呲牙]
把安卓手机上的通知(短信、验证码、电话、微信、QQ、Telegram)实时转发到 iOS / Android / 鸿蒙等接收端 把安卓手机上的通知(短信、验证码、电话“有电话进来这个通知,不是电话呼叫转移”、微信、QQ、Telegram)实时转发到 iOS,通过 [Bark](https://github.com/Finb/Bark) 推送到 iPhone / iPad。在 iOS 上点击通知,还能一键跳转到对应的 App
适用于把安卓备机的消息同步到 iPhone 或其他安卓 / 鸿蒙手机,不漏验证码、不漏重要聊天。 适用于把安卓备机的消息同步到 iOS 设备,不漏验证码、不漏重要聊天。
![GotMsg 设置页](screenshots/overview.png) ## 主要功能
--- - **系统级通知监听**:接入 Android 通知监听服务,覆盖所有发出通知的 App。
- **Bark 推送**:支持官方 Bark 服务和自建 Bark Server,可保存多个服务器并随时切换。
- **验证码自动提取**:自动识别通知中的验证码,收到即复制,也可点击复制。
- **通知优化**
- 广告关键词过滤(内置 + 自定义)
- 持续性通知过滤(音乐、下载、通话等 ongoing)
- 内容脱敏与去重
- 微信 / QQ / Telegram 专项优化(发送者作标题、按会话分组、重要消息提级、跳转 iOS 对应 App)
- **应用级规则**:黑名单 / 白名单两种模式,可按 App 单独控制是否转发。
- **失败自动重试**:发送失败的通知进入待重发队列,由 WorkManager 定期重试,也可手动触发。
- **本地日志**:保留最近 300 条处理记录,方便排查为什么某条通知被过滤或发送失败。
- **电话来电通知**:通过轮询 `getCallState()` 实时感知来电和未接来电(绕过 MIUI 对系统电话通知的限制,需授予"电话"权限)。
- **断网短信兜底**:无网络时自动把验证码、来电用短信发到 iPhone。
- 自动识别运营商(移动/联通/电信/广电),发免费查询短信拿套餐内短信余量
- 余额 ≤5 条自动停用,下次打开 App 提醒
- 每 5 分钟最多发 1 条,避免刷屏
- 自动解析失败时回退手动额度计数
- **应用内更新**:每天自动检查 GitHub 新版本,发现新版后一键下载并通过系统安装器升级,无需手动去网页下载。
## 一、五分钟上手 ## 快速开始
### 1. 安装 ### 1. 安装
从 [Releases](../../releases) 下载最新 APK,安装到安卓手机(需 Android 14 / API 34+)。
### 2. 选一个推送通道 从 [Releases](../../releases) 下载最新 APK,安装到安卓手机(需 Android 14 / API 34 及以上)。
推荐三选一:
| 接收设备 | 推荐通道 | 难度 | ### 2. 配置 Bark
|---|---|---|
| iPhone / iPad | **Bark** | ⭐ 最简单 |
| 安卓手机 | **ntfy** | ⭐ 推荐开源 |
| 鸿蒙手机 | **Meow** | 鸿蒙专属 |
打开 GotMsg → 「设置」→ 选对应通道 → 点右上角 **i** 按钮 → 按弹窗步骤配 → 点该行「发送」测试 1. 在 iOS 上安装 [Bark](https://apps.apple.com/us/app/bark-customed-push/id1613210986)
2. 打开 Bark,复制你的专属推送地址,形如 `https://api.day.app/你的Key`
3. 在 a2i 的「设置」中添加服务器:
- 名称:随意,例如「我的 iPhone」
- 服务器地址:`https://api.day.app`(自建则填自建地址)
- Device KeyBark 地址中的那段 Key
### 3. 授权通知监听 ### 3. 授权通知监听
首页会提示「需要授权通知监听」。点击「前往系统授权」,在系统的通知访问设置中开启 GotMsg。
打开 a2i,首页会提示「需要授权通知监听」。点击「前往系统授权」,在系统的通知访问设置中开启 a2i。
> 授权是一次性的,卸载后重新安装需要再次授权。
### 4. 开启转发 ### 4. 开启转发
首页打开「通知转发」开关。安卓收到通知后按配置自动推送到各通道。
--- 回到首页,打开「通知转发」开关。此时安卓收到的通知就会按规则推送到 iOS。
## 二、推送通道配置详解 ## 使用建议
每条配置都可独立勾选/删除/排序/测试。点「添加」时建议每条都先「发送」测试一次 - **只想收验证码**:进入「过滤」页,开启「验证码自动提取」,把「广告过滤」等按需开启;再到「应用」页切到白名单模式,只勾选短信类 App
### 2.1 BarkiOS / iPadOS
[Bark](https://github.com/Finb/Bark) 是 iOS 上最轻量的推送方案,复制地址即用,**推荐所有 iOS 用户首选**。
**配置步骤**
1. iPhone / iPad 上安装 Bark,首次打开允许通知权限。
2. 复制 Bark 首页的推送地址,形如 `https://api.day.app/你的Key`
3. GotMsg「设置 → Bark 服务器 → 添加」:
- **名称**:随意,例如「我的 iPhone」
- **服务器地址**`https://api.day.app`(自建则填自建地址)
- **Device Key**Bark 地址中 `/` 后面的那段
4. 保存后点「发送」测试;iPhone 收到「GotMsg 测试」即成功。
**为什么推荐 Bark**:iOS 上点击 Bark 通知可一键跳转到微信、QQ、Telegram 对应 AppiOS 14+ 通知体验最自然;Bark 服务器在中国大陆可直接访问。
### 2.2 ntfyAndroid / iOS / 网页)
[ntfy](https://ntfy.sh) 是开源的 HTTP pub-sub 通知服务,**支持 Android、iOS 和网页端**,无需注册项目。
**配置步骤**
1. 接收端安装 ntfy
- Android[Google Play](https://play.google.com/store/apps/details?id=io.heckel.ntfy) / [F-Droid](https://f-droid.org/en/packages/io.heckel.ntfy/) / [GitHub](https://github.com/binwiederhier/ntfy)
- iOS[App Store](https://apps.apple.com/us/app/ntfy/id1625396347)
2. 打开 ntfy → 添加订阅。服务器填 `https://ntfy.sh`(或自建地址);Topic 用一串**随机难猜的名字**(如 `gotmsg_a8f3d2`)。
3. GotMsg「设置 → ntfy 转发 → 添加」:
- **服务器**:与接收端完全一致
- **Topic**:与接收端完全一致
- **Token**:仅自建服务且启用了鉴权时填
4. 保存后点「发送」测试;接收端收到「GotMsg 测试」即成功。
> **安全提示**:公共 topic 谁知道名字就能订阅或发送。Topic 名**不要用**手机号、姓名、邮箱等可猜信息。
### 2.3 Meow(鸿蒙 / HarmonyOS
Meow 是鸿蒙手机的推送接收方案。GotMsg 按你填写的 Meow 接口地址和 Device Key 发出 HTTP 请求即可。
**配置步骤**
1. 鸿蒙手机上安装 Meow,确认 Meow 自身能正常接收推送。
2. 在 Meow 里找到**推送接口地址**和对应的 **Device Key / Token**
3. GotMsg「设置 → Meow 转发 → 添加」:
- **名称**:按鸿蒙设备填写(方便区分多台)
- **服务器地址**:Meow 给出的完整 API 地址
- **Device Key**Meow 给你的设备 Key(**不是**鸿蒙锁屏密码、华为账号密码)
4. 保存后点「发送」测试;鸿蒙手机收到「GotMsg 测试」即成功。
> 失败排查:检查 Meow 接口地址是否完整、Key 是否复制完整、鸿蒙手机是否允许 Meow 通知。
### 2.4 电邮转发
通过 SMTP 把通知发到邮箱(**适合无手机接收的场景**,如仅在桌面/平板查看)。
**配置步骤**
1. 准备一个 SMTP 邮箱(QQ/163/Gmail/Outlook 都可),**开启 SMTP 服务并生成授权码**(不是登录密码)。
- QQ:设置 → 账户 → POP3/IMAP/SMTP/Exchange/CardDAV/CalDAV服务 → 开启 SMTP → 生成授权码
- 163:设置 → POP3/SMTP/IMAP → 开启 SMTP → 设置授权码
- Gmail:账户 → 安全性 → 两步验证 → 应用专用密码
2. GotMsg「设置 → 电邮转发 → 添加」:
- **名称**:随意
- **服务器(SMTP 主机)**`smtp.qq.com` / `smtp.163.com` / `smtp.gmail.com`
- **端口**`465`(推荐,隐式 SSL)或 `587`STARTTLS
- **账号**:完整邮箱地址
- **授权码 / 密码**:上一步生成的授权码
- **发件人**:通常与账号相同
- **收件人**:接收通知的目标邮箱
3. 保存后点「发送」测试;收件人收到「GotMsg 测试」即成功。
> **国内邮箱推荐端口 465**QQ/163 均支持)。Gmail 需要应用专用密码而不是账户密码。
### 2.5 断网短信兜底
**当安卓手机没有网络时**,把重要通知(验证码、来电)通过 **SIM 卡短信** 发到另一台手机(通常是你的 iPhone)。需要安卓卡里还有短信套餐余量。
**配置步骤**
1. 在 GotMsg「设置 → 短信兜底」点「授予短信权限」(安卓系统权限)。
2. 点「添加」输入**目标手机号**(如 iPhone 号,接收端是另一台手机,**不是** GotMsg 本身)。
3. 选运营商:
- 选了「自动识别」:GotMsg 会发免费查询短信给运营商服务号,解析回执获取余量。
- 选了「手动额度」:在「手动短信额度」填入当前套餐短信条数,每次发短信后自动减 1。
4. 当余量 ≤ 5 时自动暂停,下次打开 App 提醒续费。
5. 频率限制:每 5 分钟最多发 1 条(避免刷屏),同一条通知 5 分钟内不重复。
> **适用场景**:安卓手机没 WiFi 时,验证码不会丢,漏不了重要登录。
---
## 三、自建推送服务(可选)
所有通道都支持自建,更好、更私密、可控。
### 3.1 自建 Bark 服务器
GotMsg 默认用 Bark 官方服务器(`https://api.day.app`),免费够用。自建适合追求完全控制。
**Docker 一键部署**(推荐):
```bash
docker run -d --name bark --restart=unless-stopped \
-p 8080:8080 \
-v /var/lib/bark:/data \
finab/bark-server
```
然后用 Nginx Proxy Manager 加 HTTPS 域名。详细三种方案对比请看 [Bark 官方文档](https://github.com/Finb/Bark)。
**GotMsg 配置**:服务器地址填 `https://你的域名.com`(不要 `/push` 后缀),Device Key 填你在自建 Bark 上创建的 Key。
### 3.2 自建 ntfy 服务器
[ntfy](https://ntfy.sh) 是为自建设计的,**比 Bark 还简单**——官方直接提供 Docker 一行启动:
```bash
docker run -d --name ntfy --restart=unless-stopped \
-p 80:80 \
-v /var/lib/ntfy:/var/lib/ntfy \
binwiederhier/ntfy serve
```
推荐加 Nginx 反代上 HTTPS 域名。完整文档:[docs.ntfy.sh](https://docs.ntfy.sh/install/)。
**GotMsg 配置**:服务器地址填 `https://ntfy.你的域名.com`Topic 照旧。
### 3.3 自建 Meow 服务器
Meow 是鸿蒙客户端,服务端一般用 Meow 自带的。GotMsg 端无需配置服务端,只需填 Meow 给你的接口地址 + Key。
---
## 四、使用建议
- **只想收验证码**:进入「过滤」页,开启「验证码自动提取」和「广告过滤」;再到「应用」页切到白名单模式,只勾选短信类 App。
- **想同步聊天**:保持黑名单模式(默认),在「过滤」页开启「微信 / QQ / Telegram 优化」。 - **想同步聊天**:保持黑名单模式(默认),在「过滤」页开启「微信 / QQ / Telegram 优化」。
- **通知没推过来**:先看「日志」页,每条通知(无论转发、过滤还是失败)都会记录原因。 - **通知没推过来**:先看「日志」页,每条通知(无论转发、过滤还是失败)都会记录原因。
- **想让通知带图标**下方「应用图标」一节。 - **想让通知带图标**参考下方「应用图标」一节。
- **电话通知不工作**:MIUI 不向第三方监听器分发系统电话通知,GotMsg 通过轮询 `getCallState()` 绕过此限制。需授予「电话」权限(设置 → 应用 → GotMsg → 权限 → 电话)。 - **电话通知不工作**MIUI 会把系统电话通知限制为不向第三方通知监听器分发,本 App 通过轮询 `getCallonyState()` 绕过此限制。需授予「电话」权限(设置 → 应用 → a2i → 权限 → 电话)。
- **断网收不到通知**:开启「设置 → 短信兜底」填好 iPhone 号码、授予短信权限。断网时验证码和来电走短信(消耗套餐额度,5 分钟限 1 条)。 - **断网收不到通知**:开启「设置 → 断网短信兜底」填好 iPhone 号码、授予短信权限、设好手动额度。断网时验证码和来电走短信(消耗套餐额度,5 分钟限 1 条)。
- **想全平台通知**:可同时启用多个通道(勾选每个通道的多个条目),所有启用的会同时收到。
--- ## 应用图标
## 五、应用图标 Bark 的 `icon` 字段需要一个公网可访问的图片 URL。a2i 支持把已安装 App 的图标批量导出到你选择的目录,方便上传到你自己的图床。
Bark 和 ntfy 的通知 `icon` 字段需要公网可访问的图片 URL。GotMsg 支持把已安装 App 的图标批量导出,方便上传到你自己的图床。
规则:转发时 `icon = 图标 URL 前缀 + 安卓包名 + .png`。在「设置 → 应用图标」里: 规则:转发时 `icon = 图标 URL 前缀 + 安卓包名 + .png`。在「设置 → 应用图标」里:
1. 填写图标 URL 前缀,例如 `https://你的图床域名/icons/` 1. 填写图标 URL 前缀,例如 `https://你的图床域名/icons/`
2. 选择一个目录,点「导出图标」,把该目录下所有 App 图标以 `包名.png` 形式导出。 2. 选择一个目录,点「导出图标」,把所有 App 图标以 `包名.png` 形式导出。
3. 把导出的图片上传到你的图床对应目录。 3. 把导出的图片上传到你的图床对应目录。
留空前缀则不显示图标。 留空前缀则不显示图标。
--- ## 自建 Bark 服务(可选)
## 六、技术栈 a2i 默认用 Bark 官方服务器(`https://api.day.app`),免费够用。如果想完全自控、不限速、不依赖第三方,可以自建 Bark Server。下面是三种零成本方案:
### 方案 1:Vercel 云托管(最适合新手)
把 Bark 后端跑在 Vercel 的 Serverless 上,每次推送触发一个云函数,不挂进程、完全免费。
- 平台:[Vercel](https://vercel.com) · 代码:[GitHub](https://github.com)
- 在 GitHub 搜索 `bark-server-vercel`,项目 README 里通常有 **Deploy with Vercel** 按钮,一键克隆部署
### 方案 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
--- ## 本地构建
## 七、构建与安装
需要 JDK 17 和 Android SDK。 需要 JDK 17 和 Android SDK。
```bash ```bash
# 设置环境变量 # 设置环境变量(按你的安装路径)
export JAVA_HOME=/path/to/jdk-17 export JAVA_HOME=/path/to/jdk-17
export ANDROID_HOME=/path/to/android-sdk export ANDROID_HOME=/path/to/android-sdk
@@ -214,18 +128,18 @@ export ANDROID_HOME=/path/to/android-sdk
./gradlew assembleRelease ./gradlew assembleRelease
``` ```
产物: 产物位置
- debug`app/build/outputs/apk/debug/app-debug.apk` - debug`app/build/outputs/apk/debug/app-debug.apk`
- release`app/build/outputs/apk/release/app-release.apk` - release`app/build/outputs/apk/release/app-release.apk`
安装: 安装到已连接的设备
```bash ```bash
adb install -r app/build/outputs/apk/release/app-release.apk adb install -r app/build/outputs/apk/release/app-release.apk
``` ```
--- ## 界面截图
## 八、界面截图
| 首页 | 应用管理 | 过滤设置 | | 首页 | 应用管理 | 过滤设置 |
|---|---|---| |---|---|---|
@@ -235,29 +149,23 @@ adb install -r app/build/outputs/apk/release/app-release.apk
|---|---| |---|---|
| ![设置](screenshots/settings.png) | ![转发日志](screenshots/log.png) | | ![设置](screenshots/settings.png) | ![转发日志](screenshots/log.png) |
--- ## 工作原理
## 九、工作原理
``` ```
安卓系统通知 Android 系统通知
└─ NotifyListenerService 接收 └─ NotifyListenerService 接收
└─ NotificationProcessor 流水线 └─ NotificationProcessor 处理流水线
├─ 全局开关 / ongoing 过滤 / 黑白名单 ├─ 全局开关 / ongoing 过滤 / 黑白名单
├─ 空内容、脱敏、噪音、系统状态过滤 ├─ 空内容、脱敏、噪音、系统状态过滤
├─ 广告关键词过滤 ├─ 广告关键词过滤
├─ 去重(5 秒窗口) ├─ 去重(5 秒窗口)
├─ 验证码提取 ├─ 验证码提取
└─ 微信 / QQ / Telegram 专项解析 └─ 微信 / QQ / Telegram 专项解析
└─ 按配置推送到 Bark / ntfy / Meow / 电邮 / 短信 └─ BarkClient 推送到 Bark Server
└─ 失败 → 待重发队列 → RetryWorker 定期重试 └─ 失败 → 待重发队列 → RetryWorker 定期重试
└─ 写入本地日志(最近 300 条) └─ 写入本地日志(最近 300 条)
``` ```
---
## License ## License
本项目仅供个人使用。 本项目仅供个人使用。
All Rights Reserved. 保留所有权利。
+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 = 39 versionCode = 17
versionName = "1.10.0" versionName = "1.8.0"
} }
signingConfigs { signingConfigs {
@@ -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.Telemetry
import com.a2i.forwarder.core.UpdateChecker import com.a2i.forwarder.core.UpdateChecker
import com.a2i.forwarder.store.AppRulesStore import com.a2i.forwarder.store.AppRulesStore
import com.a2i.forwarder.store.SettingsStore import com.a2i.forwarder.store.SettingsStore
@@ -34,8 +33,6 @@ class A2iApp : Application() {
private set private set
lateinit var updateChecker: UpdateChecker lateinit var updateChecker: UpdateChecker
private set private set
lateinit var telemetry: Telemetry
private set
override fun onCreate() { override fun onCreate() {
super.onCreate() super.onCreate()
@@ -51,8 +48,6 @@ class A2iApp : Application() {
smsForwarder = SmsFallbackForwarder(this) smsForwarder = SmsFallbackForwarder(this)
updateChecker = UpdateChecker(this) updateChecker = UpdateChecker(this)
updateChecker.start() updateChecker.start()
telemetry = Telemetry(this, appScope)
telemetry.start()
} }
companion object { companion object {
@@ -35,7 +35,6 @@ class CarrierBalanceQuery(private val context: Context) {
/** 转发成功后扣减余额(手动模式 / 自动解析失败时)。 */ /** 转发成功后扣减余额(手动模式 / 自动解析失败时)。 */
suspend fun decrementManual() { suspend fun decrementManual() {
if (!settings.smsBalanceCheck.value) return // 余额检查关闭时不递减
val q = settings.smsManualQuota.value val q = settings.smsManualQuota.value
if (q > 0) { if (q > 0) {
val newQ = q - 1 val newQ = q - 1
@@ -67,7 +66,6 @@ class CarrierBalanceQuery(private val context: Context) {
} }
private suspend fun checkSuspend(balance: Int) { private suspend fun checkSuspend(balance: Int) {
if (!settings.smsBalanceCheck.value) return // 余额检查关闭时不挂起
if (balance in 0..5 && !settings.smsSuspended.value) { if (balance in 0..5 && !settings.smsSuspended.value) {
settings.setSmsSuspended(true) settings.setSmsSuspended(true)
} }
@@ -120,6 +120,7 @@ object EmailSender {
/** 如果电邮转发已启用且收件人非空,异步发邮件(fire-and-forget,不阻塞主流程)。 */ /** 如果电邮转发已启用且收件人非空,异步发邮件(fire-and-forget,不阻塞主流程)。 */
fun trySend(app: A2iApp, title: String, body: String, scope: CoroutineScope) { fun trySend(app: A2iApp, title: String, body: String, scope: CoroutineScope) {
val s = app.settings val s = app.settings
if (!s.emailForwardingEnabled.value) return
val configs = s.emailServers.value.filter { it.enabled && it.to.isNotBlank() && it.host.isNotBlank() } val configs = s.emailServers.value.filter { it.enabled && it.to.isNotBlank() && it.host.isNotBlank() }
if (configs.isEmpty()) return if (configs.isEmpty()) return
scope.launch { scope.launch {
@@ -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)
}
@@ -89,6 +89,7 @@ object NtfySender {
fun trySend(app: A2iApp, title: String, body: String, scope: CoroutineScope) { fun trySend(app: A2iApp, title: String, body: String, scope: CoroutineScope) {
val s = app.settings val s = app.settings
if (!s.ntfyEnabled.value) return
val configs = s.ntfyServers.value.filter { it.enabled && it.topic.isNotBlank() } val configs = s.ntfyServers.value.filter { it.enabled && it.topic.isNotBlank() }
if (configs.isEmpty()) return if (configs.isEmpty()) return
scope.launch { scope.launch {
@@ -127,7 +127,6 @@ class PhoneCallMonitor(private val context: Context) {
if (servers.isEmpty()) { if (servers.isEmpty()) {
EmailSender.trySend(app, msg.title ?: logBody, msg.body ?: "", scope) EmailSender.trySend(app, msg.title ?: logBody, msg.body ?: "", scope)
NtfySender.trySend(app, msg.title ?: logBody, msg.body ?: "", scope) NtfySender.trySend(app, msg.title ?: logBody, msg.body ?: "", scope)
MeowSender.trySend(app, msg.title ?: logBody, msg.body ?: "", scope)
return@launch return@launch
} }
val result = BarkSender.pushToEnabled(servers, msg, app.settings.barkStopOnFirst.value) val result = BarkSender.pushToEnabled(servers, msg, app.settings.barkStopOnFirst.value)
@@ -147,7 +146,6 @@ class PhoneCallMonitor(private val context: Context) {
} }
EmailSender.trySend(app, msg.title ?: logBody, msg.body ?: "", scope) EmailSender.trySend(app, msg.title ?: logBody, msg.body ?: "", scope)
NtfySender.trySend(app, msg.title ?: logBody, msg.body ?: "", scope) NtfySender.trySend(app, msg.title ?: logBody, msg.body ?: "", scope)
MeowSender.trySend(app, msg.title ?: logBody, msg.body ?: "", scope)
} }
} }
} }
@@ -13,21 +13,21 @@ import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
/** /**
* 短信兜底转发器:断网时把通知用短信发到目标手机 * 短信兜底转发器:断网时把紧要通知(验证码、来电)用短信发到 iPhone
* 用户可配置:内容过滤(仅紧要/全部)、限频(开关+分钟)、余额检查(开关) * 限频:每 5 分钟最多 1 条转发短信。余额 ≤5 时自动挂起
*/ */
class SmsFallbackForwarder(private val context: Context) { class SmsFallbackForwarder(private val context: Context) {
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO) private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
private val RATE_LIMIT_MS = 5 * 60 * 1000L
private val app get() = A2iApp.instance private val app get() = A2iApp.instance
private val settings get() = app.settings private val settings get() = app.settings
/** 判断该通知是否值得走短信(受用户设置控制)。 */ /** 判断该通知是否值得走短信(断网资源宝贵)。 */
fun isSmsWorthy(d: Decision): Boolean { fun isSmsWorthy(d: Decision): Boolean {
if (settings.smsFilterAll.value) return true // 全部转发模式 if (d.code != null) return true // 验证码
if (d.code != null) return true // 验证码
val msg = d.message ?: return false val msg = d.message ?: return false
if (msg.group == "phone_call") return true // 来电/未接来电 if (msg.group == "phone_call") return true // 来电/未接来电
val t = msg.title.orEmpty() val t = msg.title.orEmpty()
return t.contains("来电") || t.contains("未接") return t.contains("来电") || t.contains("未接")
} }
@@ -35,31 +35,25 @@ class SmsFallbackForwarder(private val context: Context) {
/** 尝试用短信转发(遍历所有启用的目标号,按 stopOnFirst 决定发通一条止还是全发)。 */ /** 尝试用短信转发(遍历所有启用的目标号,按 stopOnFirst 决定发通一条止还是全发)。 */
fun forward(d: Decision) { fun forward(d: Decision) {
scope.launch { scope.launch {
val s = settings if (!settings.smsFallbackEnabled.value) return@launch
// ① 余额检查(用户可关) if (settings.smsSuspended.value) {
if (s.smsBalanceCheck.value && s.smsSuspended.value) {
log(d, "failed", "短信已挂起(余额不足)") log(d, "failed", "短信已挂起(余额不足)")
return@launch return@launch
} }
// ② 内容过滤(用户可关,关=全部转发)
if (!isSmsWorthy(d)) return@launch if (!isSmsWorthy(d)) return@launch
// ③ 目标号 val targets = settings.smsTargets.value.filter { it.enabled && it.number.isNotBlank() }
val targets = s.smsTargets.value.filter { it.enabled && it.number.isNotBlank() }
if (targets.isEmpty()) { if (targets.isEmpty()) {
log(d, "failed", "未设置短信目标号") log(d, "failed", "未设置短信目标号")
return@launch return@launch
} }
// ④ 限频(用户可关) // 限频
val now = System.currentTimeMillis() val now = System.currentTimeMillis()
if (s.smsRateLimitEnabled.value) { val elapsed = now - settings.lastSmsSentTime.value
val limitMs = s.smsRateLimitMin.value.coerceIn(1, 60) * 60 * 1000L if (elapsed < RATE_LIMIT_MS) {
val elapsed = now - s.lastSmsSentTime.value log(d, "filtered", "限频(${RATE_LIMIT_MS - elapsed}ms 内)")
if (elapsed < limitMs) { return@launch
log(d, "filtered", "限频(${limitMs - elapsed}ms 内)")
return@launch
}
} }
// ⑤ 权限(始终检查) // 权限
if (ContextCompat.checkSelfPermission(context, Manifest.permission.SEND_SMS) if (ContextCompat.checkSelfPermission(context, Manifest.permission.SEND_SMS)
!= PackageManager.PERMISSION_GRANTED != PackageManager.PERMISSION_GRANTED
) { ) {
@@ -67,9 +61,8 @@ class SmsFallbackForwarder(private val context: Context) {
return@launch return@launch
} }
// ⑥ 发送
val body = compose(d).take(70) val body = compose(d).take(70)
val stopOnFirst = s.smsStopOnFirst.value val stopOnFirst = settings.smsStopOnFirst.value
var anyOk = false var anyOk = false
var sentCount = 0 var sentCount = 0
var lastReason = "短信发送失败" var lastReason = "短信发送失败"
@@ -86,16 +79,14 @@ class SmsFallbackForwarder(private val context: Context) {
} }
} }
if (anyOk) { if (anyOk) {
s.setLastSmsSentTime(now) settings.setLastSmsSentTime(now)
log(d, "sent", "") log(d, "sent", "")
// 余额递减(仅余额检查开启时) // 余额递减:按成功发送条数;自动模式发查询短信,手动模式递减
if (s.smsBalanceCheck.value) { 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() } }
repeat(sentCount) { runCatching { app.carrierBalanceQuery.decrementManual() } }
}
} }
} else { } else {
log(d, "failed", lastReason) log(d, "failed", lastReason)
@@ -1,140 +0,0 @@
package com.a2i.forwarder.core
import android.content.Context
import android.net.ConnectivityManager
import android.net.NetworkCapabilities
import android.os.Build
import android.provider.Settings
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.RequestBody.Companion.toRequestBody
import org.json.JSONObject
import java.util.Locale
import java.util.TimeZone
import java.util.UUID
import java.util.concurrent.TimeUnit
class Telemetry(
private val context: Context,
private val scope: CoroutineScope,
) {
private val prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
private val http = OkHttpClient.Builder()
.connectTimeout(8, TimeUnit.SECONDS)
.writeTimeout(8, TimeUnit.SECONDS)
.readTimeout(10, TimeUnit.SECONDS)
.retryOnConnectionFailure(true)
.build()
private val sessionId = UUID.randomUUID().toString()
fun start() {
scope.launch(Dispatchers.IO) {
if (!prefs.getBoolean(KEY_INSTALL_SENT, false)) {
send("install").onSuccess {
prefs.edit().putBoolean(KEY_INSTALL_SENT, true).apply()
}
}
send("launch")
}
}
private suspend fun send(event: String): Result<Unit> = withContext(Dispatchers.IO) {
runCatching {
val payload = basePayload(event).toString().toRequestBody(JSON)
val request = Request.Builder()
.url(EVENTS_URL)
.header("User-Agent", userAgent())
.post(payload)
.build()
http.newCall(request).execute().use { response ->
if (!response.isSuccessful) error("HTTP ${response.code}")
}
}
}
private fun basePayload(event: String): JSONObject {
val version = appVersion()
val locale = Locale.getDefault()
return JSONObject()
.put("app_id", APP_ID)
.put("app_name", APP_NAME)
.put("event", event)
.put("install_id", installId())
.put("device_id", androidId())
.put("session_id", sessionId)
.put("platform", "android")
.put("package_name", context.packageName)
.put("app_version", version.name)
.put("app_build", version.code)
.put("os_version", "Android ${Build.VERSION.RELEASE} SDK ${Build.VERSION.SDK_INT}")
.put("manufacturer", Build.MANUFACTURER.orEmpty())
.put("model", Build.MODEL.orEmpty())
.put("locale", locale.toLanguageTag())
.put("timezone", TimeZone.getDefault().id)
.put("country", locale.country.orEmpty())
.put("network_type", networkType())
.put("user_agent", userAgent())
.put("source", "android-app")
}
private fun installId(): String {
prefs.getString(KEY_INSTALL_ID, null)?.let { return it }
val id = UUID.randomUUID().toString()
prefs.edit().putString(KEY_INSTALL_ID, id).apply()
return id
}
private fun androidId(): String =
Settings.Secure.getString(context.contentResolver, Settings.Secure.ANDROID_ID).orEmpty()
private fun appVersion(): AppVersion {
val info = context.packageManager.getPackageInfo(context.packageName, 0)
return AppVersion(
name = info.versionName ?: "0",
code = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
info.longVersionCode.toString()
} else {
@Suppress("DEPRECATION")
info.versionCode.toString()
},
)
}
private fun networkType(): String {
val manager = context.getSystemService(Context.CONNECTIVITY_SERVICE) as? ConnectivityManager ?: return "unknown"
val network = manager.activeNetwork ?: return "none"
val capabilities = manager.getNetworkCapabilities(network) ?: return "unknown"
return when {
capabilities.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) -> "wifi"
capabilities.hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR) -> "cellular"
capabilities.hasTransport(NetworkCapabilities.TRANSPORT_ETHERNET) -> "ethernet"
capabilities.hasTransport(NetworkCapabilities.TRANSPORT_VPN) -> "vpn"
else -> "other"
}
}
private fun userAgent(): String {
val version = appVersion()
return "GotMsg/${version.name} (${context.packageName}; Android ${Build.VERSION.SDK_INT}; ${Build.MANUFACTURER} ${Build.MODEL})"
}
private data class AppVersion(
val name: String,
val code: String,
)
companion object {
private const val APP_ID = "gotmsg"
private const val APP_NAME = "GotMsg"
private const val EVENTS_URL = "https://stats.songer.eu.org/v1/events"
private const val PREFS_NAME = "gotmsg_telemetry"
private const val KEY_INSTALL_ID = "install_id"
private const val KEY_INSTALL_SENT = "install_sent"
private val JSON = "application/json; charset=utf-8".toMediaType()
}
}
@@ -26,7 +26,6 @@ data class UpdateInfo(
val apkSize: Long, val apkSize: Long,
val releaseNotes: String, val releaseNotes: String,
val releasePageUrl: String, val releasePageUrl: String,
val fallbackUrl: String? = null, // 主 URL 下载失败时尝试的备选链接
) )
/** /**
@@ -45,7 +44,6 @@ class UpdateChecker(private val context: Context) {
val updateInfo = MutableStateFlow<UpdateInfo?>(null) val updateInfo = MutableStateFlow<UpdateInfo?>(null)
val downloading = MutableStateFlow(false) val downloading = MutableStateFlow(false)
val downloadProgress = MutableStateFlow(0) val downloadProgress = MutableStateFlow(0)
val downloadSource = MutableStateFlow<String?>(null) // "Gitea" / "GitHub" / null
val lastError = MutableStateFlow<String?>(null) val lastError = MutableStateFlow<String?>(null)
private var lastNotifiedVersion: String? = null // 记录已推送的版本,避免重复通知 private var lastNotifiedVersion: String? = null // 记录已推送的版本,避免重复通知
@@ -70,15 +68,10 @@ class UpdateChecker(private val context: Context) {
val now = System.currentTimeMillis() val now = System.currentTimeMillis()
if (throttle && now - settings.lastUpdateCheckTime.value < CHECK_INTERVAL_MS) return@runCatching updateInfo.value if (throttle && now - settings.lastUpdateCheckTime.value < CHECK_INTERVAL_MS) return@runCatching updateInfo.value
// 双源查询Gitea国内优先+ GitHub备用),取最高版本 // 双源查询Gitea 国内优先 + GitHub 备用),任一不通跳过,取最高版本
// 同版本时 Gitea 优先(国内可达,GitHub 可能被墙导致下载失败) val infos = RELEASES_APIS.mapNotNull { api ->
val infos = mutableListOf<UpdateInfo>() runCatching { fetchRelease(api) }.getOrNull()
var ghInfo: UpdateInfo? = null }
var giteaInfo: UpdateInfo? = null
giteaInfo = runCatching { fetchRelease(GITEA_API) }.getOrNull()
if (giteaInfo != null) infos.add(giteaInfo)
ghInfo = runCatching { fetchRelease(GITHUB_API) }.getOrNull()
if (ghInfo != null) infos.add(ghInfo)
settings.setLastUpdateCheck(now) settings.setLastUpdateCheck(now)
if (infos.isEmpty()) { if (infos.isEmpty()) {
lastError.value = "所有更新源不可达" lastError.value = "所有更新源不可达"
@@ -93,9 +86,7 @@ class UpdateChecker(private val context: Context) {
updateInfo.value = null updateInfo.value = null
lastNotifiedVersion = null lastNotifiedVersion = null
} else { } else {
// 主 URL 下载失败时尝试另一个源的链接 updateInfo.value = best
val fallback = if (best == giteaInfo) ghInfo?.apkUrl else giteaInfo?.apkUrl
updateInfo.value = best.copy(fallbackUrl = fallback)
} }
lastError.value = null lastError.value = null
@@ -112,7 +103,7 @@ class UpdateChecker(private val context: Context) {
val req = Request.Builder() val req = Request.Builder()
.url(api) .url(api)
.header("Accept", "application/json") .header("Accept", "application/json")
.header("User-Agent", "gotmsg-android") .header("User-Agent", "a2i-android")
.get() .get()
.build() .build()
return http.newCall(req).execute().use { res -> return http.newCall(req).execute().use { res ->
@@ -127,7 +118,7 @@ class UpdateChecker(private val context: Context) {
/** 发现新版后,自动通过所有已开启的推送通道通知用户 */ /** 发现新版后,自动通过所有已开启的推送通道通知用户 */
private fun notifyNewVersion(prev: UpdateInfo?, info: UpdateInfo) { private fun notifyNewVersion(prev: UpdateInfo?, info: UpdateInfo) {
val s = app.settings val s = app.settings
val title = "🆕 GotMsg 新版本 v${info.latestVersion}" val title = "🆕 a2i 新版本 v${info.latestVersion}"
val body = "发现 v${info.latestVersion}(当前 v${currentVersionName()}\n${formatSize(info.apkSize)} · 点击更新\n\n${info.releaseNotes.take(300)}" val body = "发现 v${info.latestVersion}(当前 v${currentVersionName()}\n${formatSize(info.apkSize)} · 点击更新\n\n${info.releaseNotes.take(300)}"
val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO) val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
@@ -146,9 +137,6 @@ class UpdateChecker(private val context: Context) {
// ntfy // ntfy
NtfySender.trySend(app, title, body, scope) NtfySender.trySend(app, title, body, scope)
// Meow
MeowSender.trySend(app, title, body, scope)
// 电邮 // 电邮
EmailSender.trySend(app, title, body, scope) EmailSender.trySend(app, title, body, scope)
} }
@@ -181,49 +169,18 @@ class UpdateChecker(private val context: Context) {
suspend fun download(info: UpdateInfo): File? = withContext(Dispatchers.IO) { suspend fun download(info: UpdateInfo): File? = withContext(Dispatchers.IO) {
downloading.value = true downloading.value = true
downloadProgress.value = 0 downloadProgress.value = 0
downloadSource.value = null
lastError.value = null lastError.value = null
// 主 URL (Gitea) 失败时自动尝试备选 URL (GitHub) try {
val sources = listOf( val req = Request.Builder().url(info.apkUrl).get().build()
info.apkUrl to sourceLabel(info.apkUrl), http.newCall(req).execute().use { res ->
) + (info.fallbackUrl?.let { listOf(it to sourceLabel(it)) } ?: emptyList()) if (!res.isSuccessful) { lastError.value = "HTTP ${res.code}"; return@use null }
for ((idx, pair) in sources.withIndex()) { val body = res.body ?: run { lastError.value = "空响应"; return@use null }
val (url, label) = pair
if (idx > 0) { downloadProgress.value = 0; downloadSource.value = null }
downloadSource.value = label
val file = tryDownload(url, info.latestVersion, info.apkSize)
if (file != null) {
downloading.value = false
downloadSource.value = null
return@withContext file
}
}
downloading.value = false
downloadSource.value = null
null
}
private fun sourceLabel(url: String): String =
if (url.contains("songer.eu.org") || url.contains("sunlunfan")) "Gitea" else "GitHub"
private suspend fun tryDownload(url: String, version: String, expectedSize: Long): File? {
// 专用下载 client:更长超时(120s),避免大文件慢网超时截断
val dlClient = OkHttpClient.Builder()
.connectTimeout(15, TimeUnit.SECONDS)
.readTimeout(120, TimeUnit.SECONDS)
.retryOnConnectionFailure(true)
.build()
return try {
val req = Request.Builder().url(url).get().build()
dlClient.newCall(req).execute().use { res ->
if (!res.isSuccessful) { lastError.value = "HTTP ${res.code}"; return null }
val body = res.body ?: run { lastError.value = "空响应"; return null }
val total = body.contentLength() val total = body.contentLength()
val dir = context.getExternalFilesDir(null) ?: context.cacheDir val dir = context.getExternalFilesDir(null) ?: context.cacheDir
val target = File(dir, "gotmsg-${version}.apk") val target = File(dir, "a2i-${info.latestVersion}.apk")
target.outputStream().use { sink -> target.outputStream().use { sink ->
val input = body.byteStream() val input = body.byteStream()
val buf = ByteArray(64 * 1024) // 64KB buffer 提升大文件速度 val buf = ByteArray(8 * 1024)
var read = 0L var read = 0L
while (true) { while (true) {
val n = input.read(buf) val n = input.read(buf)
@@ -233,25 +190,14 @@ class UpdateChecker(private val context: Context) {
if (total > 0) downloadProgress.value = (read * 100 / total).toInt() if (total > 0) downloadProgress.value = (read * 100 / total).toInt()
} }
} }
// 校验文件大小:Content-Length 或 release 声明的大小
val actualSize = target.length()
val refSize = if (total > 0) total else expectedSize
if (refSize > 0 && actualSize < refSize) {
lastError.value = "文件不完整(${actualSize}/${refSize} 字节),请重试"
target.delete()
return null
}
if (actualSize < 1_000_000) {
lastError.value = "文件过小,可能下载了错误页面"
target.delete()
return null
}
downloadProgress.value = 100 downloadProgress.value = 100
target target
} }
} catch (e: Exception) { } catch (e: Exception) {
lastError.value = e.message ?: "下载失败" lastError.value = e.message ?: "下载失败"
null null
} finally {
downloading.value = false
} }
} }
@@ -300,8 +246,11 @@ class UpdateChecker(private val context: Context) {
} }
companion object { companion object {
const val GITHUB_API = "https://api.github.com/repos/lsxf/gotmsg/releases/latest" // 双源:Gitea(国内优先)+ GitHub(备用),任一不通跳过,取最高版本
const val GITEA_API = "https://gitea.songer.eu.org/api/v1/repos/song/gotmsg/releases/latest" val RELEASES_APIS = listOf(
"https://git.sunlunfan.com/api/v1/repos/song/a2i/releases/latest",
"https://api.github.com/repos/lsxf/a2i/releases/latest",
)
const val CHECK_INTERVAL_MS = 24L * 60 * 60 * 1000 const val CHECK_INTERVAL_MS = 24L * 60 * 60 * 1000
} }
@@ -24,15 +24,6 @@ data class NtfyServer(
val enabled: Boolean = true, val enabled: Boolean = true,
) )
@Serializable
data class MeowServer(
val id: String = UUID.randomUUID().toString(),
val name: String,
val server: String = "",
val deviceKey: String = "",
val enabled: Boolean = true,
)
@Serializable @Serializable
data class EmailServer( data class EmailServer(
val id: String = UUID.randomUUID().toString(), val id: String = UUID.randomUUID().toString(),
@@ -6,7 +6,6 @@ import com.a2i.forwarder.A2iApp
import com.a2i.forwarder.core.BarkSender import com.a2i.forwarder.core.BarkSender
import com.a2i.forwarder.core.EmailSender import com.a2i.forwarder.core.EmailSender
import com.a2i.forwarder.core.ForwardLog import com.a2i.forwarder.core.ForwardLog
import com.a2i.forwarder.core.MeowSender
import com.a2i.forwarder.core.NotificationProcessor import com.a2i.forwarder.core.NotificationProcessor
import com.a2i.forwarder.core.NtfySender import com.a2i.forwarder.core.NtfySender
import com.a2i.forwarder.core.PendingPush import com.a2i.forwarder.core.PendingPush
@@ -66,7 +65,6 @@ class NotifyListenerService : NotificationListenerService() {
app.logStore.addLog(ForwardLog(now, sbn.packageName, d.appLabel, msg.title, msg.body, "failed", "未配置可用 Bark 服务器")) app.logStore.addLog(ForwardLog(now, sbn.packageName, d.appLabel, msg.title, msg.body, "failed", "未配置可用 Bark 服务器"))
EmailSender.trySend(app, msg.title ?: d.appLabel, msg.body, scope) EmailSender.trySend(app, msg.title ?: d.appLabel, msg.body, scope)
NtfySender.trySend(app, msg.title ?: d.appLabel, msg.body, scope) NtfySender.trySend(app, msg.title ?: d.appLabel, msg.body, scope)
MeowSender.trySend(app, msg.title ?: d.appLabel, msg.body, scope)
return return
} }
@@ -82,6 +80,5 @@ class NotifyListenerService : NotificationListenerService() {
} }
EmailSender.trySend(app, msg.title ?: d.appLabel, msg.body, scope) EmailSender.trySend(app, msg.title ?: d.appLabel, msg.body, scope)
NtfySender.trySend(app, msg.title ?: d.appLabel, msg.body, scope) NtfySender.trySend(app, msg.title ?: d.appLabel, msg.body, scope)
MeowSender.trySend(app, msg.title ?: d.appLabel, msg.body, scope)
} }
} }
@@ -11,7 +11,6 @@ import androidx.datastore.preferences.core.stringPreferencesKey
import androidx.datastore.preferences.preferencesDataStore import androidx.datastore.preferences.preferencesDataStore
import com.a2i.forwarder.model.BarkServer import com.a2i.forwarder.model.BarkServer
import com.a2i.forwarder.model.EmailServer import com.a2i.forwarder.model.EmailServer
import com.a2i.forwarder.model.MeowServer
import com.a2i.forwarder.model.NtfyServer import com.a2i.forwarder.model.NtfyServer
import com.a2i.forwarder.model.OFFICIAL_BARK_SERVER import com.a2i.forwarder.model.OFFICIAL_BARK_SERVER
import com.a2i.forwarder.model.SmsTarget import com.a2i.forwarder.model.SmsTarget
@@ -45,7 +44,6 @@ class SettingsStore(
// 各通道「发通一条止 / 全发」开关 // 各通道「发通一条止 / 全发」开关
val BARK_STOP_FIRST = booleanPreferencesKey("bark_stop_first") val BARK_STOP_FIRST = booleanPreferencesKey("bark_stop_first")
val NTFY_STOP_FIRST = booleanPreferencesKey("ntfy_stop_first") val NTFY_STOP_FIRST = booleanPreferencesKey("ntfy_stop_first")
val MEOW_STOP_FIRST = booleanPreferencesKey("meow_stop_first")
val EMAIL_STOP_FIRST = booleanPreferencesKey("email_stop_first") val EMAIL_STOP_FIRST = booleanPreferencesKey("email_stop_first")
val SMS_STOP_FIRST = booleanPreferencesKey("sms_stop_first") val SMS_STOP_FIRST = booleanPreferencesKey("sms_stop_first")
// 短信兜底(目标号已列表化;运营商/余额/限频保持全局) // 短信兜底(目标号已列表化;运营商/余额/限频保持全局)
@@ -56,15 +54,12 @@ class SettingsStore(
val SMS_BALANCE = intPreferencesKey("sms_balance") val SMS_BALANCE = intPreferencesKey("sms_balance")
val SMS_SUSPENDED = booleanPreferencesKey("sms_suspended") val SMS_SUSPENDED = booleanPreferencesKey("sms_suspended")
val SMS_LAST_SENT = longPreferencesKey("sms_last_sent") val SMS_LAST_SENT = longPreferencesKey("sms_last_sent")
val SMS_FILTER_ALL = booleanPreferencesKey("sms_filter_all") // true=全部转发 false=仅紧要
val SMS_RATE_LIMIT_ENABLED = booleanPreferencesKey("sms_rate_limit_en")
val SMS_RATE_LIMIT_MIN = intPreferencesKey("sms_rate_limit_min")
val SMS_BALANCE_CHECK = booleanPreferencesKey("sms_balance_check")
// 电邮转发 // 电邮转发
val EMAIL_ENABLED = booleanPreferencesKey("email_enabled") val EMAIL_ENABLED = booleanPreferencesKey("email_enabled")
val EMAIL_SERVERS = stringPreferencesKey("email_servers") val EMAIL_SERVERS = stringPreferencesKey("email_servers")
// ntfy 转发
val NTFY_ENABLED = booleanPreferencesKey("ntfy_enabled")
val NTFY_SERVERS = stringPreferencesKey("ntfy_servers") val NTFY_SERVERS = stringPreferencesKey("ntfy_servers")
val MEOW_SERVERS = stringPreferencesKey("meow_servers")
// 应用更新 // 应用更新
val UPDATE_CHECK_AUTO = booleanPreferencesKey("update_check_auto") val UPDATE_CHECK_AUTO = booleanPreferencesKey("update_check_auto")
val LAST_UPDATE_CHECK = longPreferencesKey("last_update_check") val LAST_UPDATE_CHECK = longPreferencesKey("last_update_check")
@@ -94,9 +89,6 @@ class SettingsStore(
private val _ntfyServers = MutableStateFlow<List<NtfyServer>>(emptyList()) private val _ntfyServers = MutableStateFlow<List<NtfyServer>>(emptyList())
val ntfyServers = _ntfyServers.asStateFlow() val ntfyServers = _ntfyServers.asStateFlow()
private val _meowServers = MutableStateFlow<List<MeowServer>>(emptyList())
val meowServers = _meowServers.asStateFlow()
private val _emailServers = MutableStateFlow<List<EmailServer>>(emptyList()) private val _emailServers = MutableStateFlow<List<EmailServer>>(emptyList())
val emailServers = _emailServers.asStateFlow() val emailServers = _emailServers.asStateFlow()
@@ -114,7 +106,6 @@ class SettingsStore(
// 各通道「发通一条止 / 全发」 // 各通道「发通一条止 / 全发」
val barkStopOnFirst = MutableStateFlow(true) val barkStopOnFirst = MutableStateFlow(true)
val ntfyStopOnFirst = MutableStateFlow(false) val ntfyStopOnFirst = MutableStateFlow(false)
val meowStopOnFirst = MutableStateFlow(false)
val emailStopOnFirst = MutableStateFlow(false) val emailStopOnFirst = MutableStateFlow(false)
val smsStopOnFirst = MutableStateFlow(false) val smsStopOnFirst = MutableStateFlow(false)
@@ -125,13 +116,10 @@ class SettingsStore(
val smsBalance = MutableStateFlow(-1) val smsBalance = MutableStateFlow(-1)
val smsSuspended = MutableStateFlow(false) val smsSuspended = MutableStateFlow(false)
val lastSmsSentTime = MutableStateFlow(0L) val lastSmsSentTime = MutableStateFlow(0L)
val smsFilterAll = MutableStateFlow(false) // true=全部转发 false=仅紧要
val smsRateLimitEnabled = MutableStateFlow(true)
val smsRateLimitMin = MutableStateFlow(5)
val smsBalanceCheck = MutableStateFlow(true)
// 电邮总开关 // 电邮/ntfy 总开关
val emailForwardingEnabled = MutableStateFlow(false) val emailForwardingEnabled = MutableStateFlow(false)
val ntfyEnabled = MutableStateFlow(false)
// 应用更新 // 应用更新
val updateCheckEnabled = MutableStateFlow(true) val updateCheckEnabled = MutableStateFlow(true)
@@ -157,7 +145,6 @@ class SettingsStore(
// 各通道 stopOnFirst // 各通道 stopOnFirst
barkStopOnFirst.value = p[K.BARK_STOP_FIRST] ?: true barkStopOnFirst.value = p[K.BARK_STOP_FIRST] ?: true
ntfyStopOnFirst.value = p[K.NTFY_STOP_FIRST] ?: false ntfyStopOnFirst.value = p[K.NTFY_STOP_FIRST] ?: false
meowStopOnFirst.value = p[K.MEOW_STOP_FIRST] ?: false
emailStopOnFirst.value = p[K.EMAIL_STOP_FIRST] ?: false emailStopOnFirst.value = p[K.EMAIL_STOP_FIRST] ?: false
smsStopOnFirst.value = p[K.SMS_STOP_FIRST] ?: false smsStopOnFirst.value = p[K.SMS_STOP_FIRST] ?: false
// 短信兜底全局 // 短信兜底全局
@@ -167,12 +154,9 @@ class SettingsStore(
smsBalance.value = p[K.SMS_BALANCE] ?: -1 smsBalance.value = p[K.SMS_BALANCE] ?: -1
smsSuspended.value = p[K.SMS_SUSPENDED] ?: false smsSuspended.value = p[K.SMS_SUSPENDED] ?: false
lastSmsSentTime.value = p[K.SMS_LAST_SENT] ?: 0L lastSmsSentTime.value = p[K.SMS_LAST_SENT] ?: 0L
smsFilterAll.value = p[K.SMS_FILTER_ALL] ?: false // 电邮/ntfy 总开关
smsRateLimitEnabled.value = p[K.SMS_RATE_LIMIT_ENABLED] ?: true
smsRateLimitMin.value = p[K.SMS_RATE_LIMIT_MIN] ?: 5
smsBalanceCheck.value = p[K.SMS_BALANCE_CHECK] ?: true
// 电邮总开关
emailForwardingEnabled.value = p[K.EMAIL_ENABLED] ?: false emailForwardingEnabled.value = p[K.EMAIL_ENABLED] ?: false
ntfyEnabled.value = p[K.NTFY_ENABLED] ?: false
// 应用更新 // 应用更新
updateCheckEnabled.value = p[K.UPDATE_CHECK_AUTO] ?: true updateCheckEnabled.value = p[K.UPDATE_CHECK_AUTO] ?: true
lastUpdateCheckTime.value = p[K.LAST_UPDATE_CHECK] ?: 0L lastUpdateCheckTime.value = p[K.LAST_UPDATE_CHECK] ?: 0L
@@ -181,9 +165,6 @@ class SettingsStore(
_ntfyServers.value = p[K.NTFY_SERVERS] _ntfyServers.value = p[K.NTFY_SERVERS]
?.let { runCatching { json.decodeFromString<List<NtfyServer>>(it) }.getOrNull() } ?.let { runCatching { json.decodeFromString<List<NtfyServer>>(it) }.getOrNull() }
?: emptyList() ?: emptyList()
_meowServers.value = p[K.MEOW_SERVERS]
?.let { runCatching { json.decodeFromString<List<MeowServer>>(it) }.getOrNull() }
?: emptyList()
_emailServers.value = p[K.EMAIL_SERVERS] _emailServers.value = p[K.EMAIL_SERVERS]
?.let { runCatching { json.decodeFromString<List<EmailServer>>(it) }.getOrNull() } ?.let { runCatching { json.decodeFromString<List<EmailServer>>(it) }.getOrNull() }
?: emptyList() ?: emptyList()
@@ -284,22 +265,6 @@ class SettingsStore(
} }
suspend fun setNtfyEnabled(id: String, enabled: Boolean) = saveNtfyServers(_ntfyServers.value.map { if (it.id == id) it.copy(enabled = enabled) else it }) suspend fun setNtfyEnabled(id: String, enabled: Boolean) = saveNtfyServers(_ntfyServers.value.map { if (it.id == id) it.copy(enabled = enabled) else it })
// ---- Meow 服务器 CRUD ----
private suspend fun saveMeowServers(list: List<MeowServer>) {
edit { it[K.MEOW_SERVERS] = json.encodeToString(list) }
_meowServers.value = list
}
suspend fun addMeow(s: MeowServer) { saveMeowServers(_meowServers.value + s) }
suspend fun updateMeow(s: MeowServer) = saveMeowServers(_meowServers.value.map { if (it.id == s.id) s else it })
suspend fun deleteMeow(id: String) = saveMeowServers(_meowServers.value.filterNot { it.id == id })
suspend fun moveMeow(id: String, up: Boolean) {
val list = _meowServers.value.toMutableList()
val i = list.indexOfFirst { it.id == id }
if (i in 1..list.lastIndex && up) { java.util.Collections.swap(list, i, i - 1); saveMeowServers(list) }
else if (i in 0 until list.lastIndex && !up) { java.util.Collections.swap(list, i, i + 1); saveMeowServers(list) }
}
suspend fun setMeowEnabled(id: String, enabled: Boolean) = saveMeowServers(_meowServers.value.map { if (it.id == id) it.copy(enabled = enabled) else it })
// ---- 电邮服务器 CRUD ---- // ---- 电邮服务器 CRUD ----
private suspend fun saveEmailServers(list: List<EmailServer>) { private suspend fun saveEmailServers(list: List<EmailServer>) {
edit { it[K.EMAIL_SERVERS] = json.encodeToString(list) } edit { it[K.EMAIL_SERVERS] = json.encodeToString(list) }
@@ -345,7 +310,6 @@ class SettingsStore(
suspend fun setBarkStopOnFirst(v: Boolean) { edit { it[K.BARK_STOP_FIRST] = v }; barkStopOnFirst.value = v } suspend fun setBarkStopOnFirst(v: Boolean) { edit { it[K.BARK_STOP_FIRST] = v }; barkStopOnFirst.value = v }
suspend fun setNtfyStopOnFirst(v: Boolean) { edit { it[K.NTFY_STOP_FIRST] = v }; ntfyStopOnFirst.value = v } suspend fun setNtfyStopOnFirst(v: Boolean) { edit { it[K.NTFY_STOP_FIRST] = v }; ntfyStopOnFirst.value = v }
suspend fun setMeowStopOnFirst(v: Boolean) { edit { it[K.MEOW_STOP_FIRST] = v }; meowStopOnFirst.value = v }
suspend fun setEmailStopOnFirst(v: Boolean) { edit { it[K.EMAIL_STOP_FIRST] = v }; emailStopOnFirst.value = v } suspend fun setEmailStopOnFirst(v: Boolean) { edit { it[K.EMAIL_STOP_FIRST] = v }; emailStopOnFirst.value = v }
suspend fun setSmsStopOnFirst(v: Boolean) { edit { it[K.SMS_STOP_FIRST] = v }; smsStopOnFirst.value = v } suspend fun setSmsStopOnFirst(v: Boolean) { edit { it[K.SMS_STOP_FIRST] = v }; smsStopOnFirst.value = v }
@@ -355,21 +319,18 @@ class SettingsStore(
suspend fun setSmsBalance(v: Int) { edit { it[K.SMS_BALANCE] = v }; smsBalance.value = v } suspend fun setSmsBalance(v: Int) { edit { it[K.SMS_BALANCE] = v }; smsBalance.value = v }
suspend fun setSmsSuspended(v: Boolean) { edit { it[K.SMS_SUSPENDED] = v }; smsSuspended.value = v } suspend fun setSmsSuspended(v: Boolean) { edit { it[K.SMS_SUSPENDED] = v }; smsSuspended.value = v }
suspend fun setLastSmsSentTime(v: Long) { edit { it[K.SMS_LAST_SENT] = v }; lastSmsSentTime.value = v } suspend fun setLastSmsSentTime(v: Long) { edit { it[K.SMS_LAST_SENT] = v }; lastSmsSentTime.value = v }
suspend fun setSmsFilterAll(v: Boolean) { edit { it[K.SMS_FILTER_ALL] = v }; smsFilterAll.value = v }
suspend fun setSmsRateLimitEnabled(v: Boolean) { edit { it[K.SMS_RATE_LIMIT_ENABLED] = v }; smsRateLimitEnabled.value = v }
suspend fun setSmsRateLimitMin(v: Int) { edit { it[K.SMS_RATE_LIMIT_MIN] = v }; smsRateLimitMin.value = v }
suspend fun setSmsBalanceCheck(v: Boolean) { edit { it[K.SMS_BALANCE_CHECK] = v }; smsBalanceCheck.value = v }
suspend fun setUpdateCheckEnabled(v: Boolean) { edit { it[K.UPDATE_CHECK_AUTO] = v }; updateCheckEnabled.value = v } suspend fun setUpdateCheckEnabled(v: Boolean) { edit { it[K.UPDATE_CHECK_AUTO] = v }; updateCheckEnabled.value = v }
suspend fun setLastUpdateCheck(v: Long) { edit { it[K.LAST_UPDATE_CHECK] = v }; lastUpdateCheckTime.value = v } suspend fun setLastUpdateCheck(v: Long) { edit { it[K.LAST_UPDATE_CHECK] = v }; lastUpdateCheckTime.value = v }
suspend fun setIgnoredVersion(v: String) { edit { it[K.IGNORED_VERSION] = v }; ignoredVersion.value = v } suspend fun setIgnoredVersion(v: String) { edit { it[K.IGNORED_VERSION] = v }; ignoredVersion.value = v }
suspend fun setEmailForwarding(v: Boolean) { edit { it[K.EMAIL_ENABLED] = v }; emailForwardingEnabled.value = v } suspend fun setEmailForwarding(v: Boolean) { edit { it[K.EMAIL_ENABLED] = v }; emailForwardingEnabled.value = v }
suspend fun setNtfyEnabled(v: Boolean) { edit { it[K.NTFY_ENABLED] = v }; ntfyEnabled.value = v }
suspend fun snapshot() = ds.data.first() suspend fun snapshot() = ds.data.first()
companion object { companion object {
const val ID_OFFICIAL = "official" const val ID_OFFICIAL = "official"
const val DEFAULT_ICON_PREFIX = "https://raw.githubusercontent.com/lsxf/gotmsg/main/icons/" const val DEFAULT_ICON_PREFIX = "https://raw.githubusercontent.com/lsxf/a2i/main/icons/"
} }
} }
@@ -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,58 +79,19 @@ fun SectionCard(
) { ) {
Column(Modifier.padding(16.dp)) { Column(Modifier.padding(16.dp)) {
if (title != null) { if (title != null) {
if (headerAction == null) { Text(
Row(verticalAlignment = Alignment.CenterVertically) { title,
if (titleIcon != null) { style = MaterialTheme.typography.titleMedium,
Icon(titleIcon, null, tint = titleIconTint, modifier = Modifier.size(22.dp)) fontWeight = FontWeight.SemiBold,
Spacer(Modifier.width(8.dp)) modifier = Modifier.padding(bottom = if (subtitle == null) 10.dp else 2.dp),
} )
Text( if (subtitle != null) {
title, Text(
style = MaterialTheme.typography.titleMedium, subtitle,
fontWeight = FontWeight.SemiBold, style = MaterialTheme.typography.bodySmall,
modifier = Modifier.padding(bottom = if (subtitle == null) 10.dp else 2.dp), color = MaterialTheme.colorScheme.onSurfaceVariant,
) modifier = Modifier.padding(bottom = 10.dp),
} )
if (subtitle != null) {
Text(
subtitle,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(bottom = 10.dp),
)
}
} else {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(bottom = 10.dp),
verticalAlignment = Alignment.Top,
) {
Column(Modifier.weight(1f)) {
Row(verticalAlignment = Alignment.CenterVertically) {
if (titleIcon != null) {
Icon(titleIcon, null, tint = titleIconTint, modifier = Modifier.size(22.dp))
Spacer(Modifier.width(8.dp))
}
Text(
title,
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold,
modifier = Modifier.padding(bottom = if (subtitle == null) 0.dp else 2.dp),
)
}
if (subtitle != null) {
Text(
subtitle,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
Spacer(Modifier.width(12.dp))
headerAction()
}
} }
} }
content() content()
@@ -287,4 +244,4 @@ fun drawableToImageBitmap(d: Drawable): ImageBitmap {
d.setBounds(0, 0, canvas.width, canvas.height) d.setBounds(0, 0, canvas.width, canvas.height)
d.draw(canvas) d.draw(canvas)
return bmp.asImageBitmap() return bmp.asImageBitmap()
} }
@@ -1,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,104 +46,54 @@ 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 Scaffold(
val updateInfo by app.updateChecker.updateInfo.collectAsState() containerColor = MaterialTheme.colorScheme.background,
var showUpdateDialog by remember { mutableStateOf(false) } bottomBar = {
if (showBar) {
Box { NavigationBar(
Scaffold( containerColor = MaterialTheme.colorScheme.surface,
containerColor = MaterialTheme.colorScheme.background, tonalElevation = 0.dp,
bottomBar = { ) {
if (showBar) { tabs.forEach { t ->
NavigationBar( NavigationBarItem(
containerColor = MaterialTheme.colorScheme.surface, selected = current == t.route,
tonalElevation = 0.dp, onClick = {
) { nav.navigate(t.route) {
tabs.forEach { t -> popUpTo(nav.graph.findStartDestination().id) { saveState = true }
NavigationBarItem( launchSingleTop = true
selected = current == t.route, restoreState = true
onClick = { }
nav.navigate(t.route) { },
popUpTo(nav.graph.findStartDestination().id) { saveState = true } icon = { Icon(t.icon, t.label) },
launchSingleTop = true label = { Text(t.label) },
restoreState = true colors = NavigationBarItemDefaults.colors(
} selectedIconColor = MaterialTheme.colorScheme.primary,
}, selectedTextColor = MaterialTheme.colorScheme.primary,
icon = { Icon(t.icon, t.label) }, indicatorColor = MaterialTheme.colorScheme.primaryContainer,
label = { Text(t.label) }, unselectedIconColor = MaterialTheme.colorScheme.onSurfaceVariant,
colors = NavigationBarItemDefaults.colors( unselectedTextColor = MaterialTheme.colorScheme.onSurfaceVariant,
selectedIconColor = MaterialTheme.colorScheme.primary, ),
selectedTextColor = MaterialTheme.colorScheme.primary, )
indicatorColor = MaterialTheme.colorScheme.primaryContainer,
unselectedIconColor = MaterialTheme.colorScheme.onSurfaceVariant,
unselectedTextColor = MaterialTheme.colorScheme.onSurfaceVariant,
),
)
}
} }
} }
},
) { inner ->
NavHost(
navController = nav,
startDestination = Tab.Home.route,
modifier = Modifier.padding(inner),
) {
composable(Tab.Home.route) {
HomeScreen(
onOpenLog = { nav.navigate("log") },
onOpenSettings = { nav.navigate(Tab.Settings.route) },
)
}
composable(Tab.Apps.route) { AppManageScreen() }
composable(Tab.Filter.route) { FilterScreen() }
composable(Tab.Settings.route) { SettingsScreen(onOpenLog = { nav.navigate("log") }) }
composable("log") { LogScreen(onBack = { nav.popBackStack() }) }
} }
},
) { inner ->
NavHost(
navController = nav,
startDestination = Tab.Home.route,
modifier = Modifier.padding(inner),
) {
composable(Tab.Home.route) {
HomeScreen(
onOpenLog = { nav.navigate("log") },
onOpenSettings = { nav.navigate(Tab.Settings.route) },
)
}
composable(Tab.Apps.route) { AppManageScreen() }
composable(Tab.Filter.route) { FilterScreen() }
composable(Tab.Settings.route) { SettingsScreen(onOpenLog = { nav.navigate("log") }) }
composable("log") { LogScreen(onBack = { nav.popBackStack() }) }
} }
// 浮动更新提醒小图标(跨所有页面,缓慢闪烁)
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 = "规则按通知处理流水线顺序执行") {
@@ -178,7 +178,7 @@ fun HomeScreen(onOpenLog: () -> Unit, onOpenSettings: () -> Unit) {
Column(Modifier.weight(1f)) { Column(Modifier.weight(1f)) {
Text(if (granted) "通知监听已开启" else "需要授权通知监听", style = MaterialTheme.typography.titleMedium) Text(if (granted) "通知监听已开启" else "需要授权通知监听", style = MaterialTheme.typography.titleMedium)
Text( Text(
if (granted) "系统通知将按规则处理并多通道转发" else "授权后才能读取 Android 通知并转发", if (granted) "系统通知将按规则处理并推送到 Bark" else "授权后才能读取 Android 通知并转发",
style = MaterialTheme.typography.bodySmall, style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant, color = MaterialTheme.colorScheme.onSurfaceVariant,
) )
@@ -202,7 +202,7 @@ fun HomeScreen(onOpenLog: () -> Unit, onOpenSettings: () -> Unit) {
SectionCard("运行状态", subtitle = "转发能力、目标服务器和日志入口") { SectionCard("运行状态", subtitle = "转发能力、目标服务器和日志入口") {
SwitchRow( SwitchRow(
title = "通知转发", title = "通知转发",
subtitle = if (forwarding) "新通知会继续多通道转发" 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,
@@ -285,8 +285,8 @@ private fun HomeHero(
} }
Spacer(Modifier.width(12.dp)) Spacer(Modifier.width(12.dp))
Column(Modifier.weight(1f)) { Column(Modifier.weight(1f)) {
Text("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 +295,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),
) )
@@ -6,17 +6,12 @@ import android.net.Uri
import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.Image
import androidx.compose.foundation.clickable
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.width
@@ -27,15 +22,10 @@ import androidx.compose.material.icons.automirrored.filled.Send
import androidx.compose.material.icons.filled.Add import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.Delete import androidx.compose.material.icons.filled.Delete
import androidx.compose.material.icons.filled.Edit import androidx.compose.material.icons.filled.Edit
import androidx.compose.material.icons.filled.Email
import androidx.compose.material.icons.filled.History import androidx.compose.material.icons.filled.History
import androidx.compose.material.icons.filled.Info
import androidx.compose.material.icons.filled.KeyboardArrowDown import androidx.compose.material.icons.filled.KeyboardArrowDown
import androidx.compose.material.icons.filled.KeyboardArrowUp import androidx.compose.material.icons.filled.KeyboardArrowUp
import androidx.compose.material.icons.filled.Notifications
import androidx.compose.material.icons.filled.Pets
import androidx.compose.material.icons.filled.RestartAlt import androidx.compose.material.icons.filled.RestartAlt
import androidx.compose.material.icons.filled.Sms
import androidx.compose.material.icons.filled.Settings import androidx.compose.material.icons.filled.Settings
import androidx.compose.material.icons.filled.SystemUpdateAlt import androidx.compose.material.icons.filled.SystemUpdateAlt
import androidx.compose.material.icons.filled.Update import androidx.compose.material.icons.filled.Update
@@ -61,26 +51,18 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.painter.Painter
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.platform.LocalUriHandler
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.documentfile.provider.DocumentFile import androidx.documentfile.provider.DocumentFile
import com.a2i.forwarder.A2iApp import com.a2i.forwarder.A2iApp
import com.a2i.forwarder.R
import com.a2i.forwarder.core.BarkClient import com.a2i.forwarder.core.BarkClient
import com.a2i.forwarder.core.EmailSender import com.a2i.forwarder.core.EmailSender
import com.a2i.forwarder.core.IconExporter import com.a2i.forwarder.core.IconExporter
import com.a2i.forwarder.core.MeowSender
import com.a2i.forwarder.core.NtfySender import com.a2i.forwarder.core.NtfySender
import com.a2i.forwarder.model.BarkMessage import com.a2i.forwarder.model.BarkMessage
import com.a2i.forwarder.model.BarkServer import com.a2i.forwarder.model.BarkServer
import com.a2i.forwarder.model.EmailServer import com.a2i.forwarder.model.EmailServer
import com.a2i.forwarder.model.MeowServer
import com.a2i.forwarder.model.NtfyServer import com.a2i.forwarder.model.NtfyServer
import com.a2i.forwarder.model.OFFICIAL_BARK_SERVER import com.a2i.forwarder.model.OFFICIAL_BARK_SERVER
import com.a2i.forwarder.model.SmsTarget import com.a2i.forwarder.model.SmsTarget
@@ -92,36 +74,19 @@ import com.a2i.forwarder.ui.SwitchRow
import com.a2i.forwarder.ui.appVersionName import com.a2i.forwarder.ui.appVersionName
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
private const val BARK_APP_STORE_URL = "https://apps.apple.com/app/bark-custom-notifications/id1403753865"
private const val BARK_DOCS_URL = "https://bark.day.app/#/"
private const val BARK_GITHUB_URL = "https://github.com/Finb/Bark"
private const val BARK_OFFICIAL_SERVER = "https://api.day.app"
private const val NTFY_OFFICIAL_SERVER = "https://ntfy.sh"
private const val NTFY_ANDROID_URL = "https://play.google.com/store/apps/details?id=io.heckel.ntfy"
private const val NTFY_IOS_URL = "https://apps.apple.com/us/app/ntfy/id1625396347"
private const val NTFY_FDROID_URL = "https://f-droid.org/en/packages/io.heckel.ntfy/"
private const val NTFY_DOCS_URL = "https://docs.ntfy.sh/subscribe/phone/"
private const val NTFY_PUBLISH_DOCS_URL = "https://docs.ntfy.sh/publish/"
private const val NTFY_GITHUB_URL = "https://github.com/binwiederhier/ntfy"
@Composable @Composable
fun SettingsScreen(onOpenLog: () -> Unit) { fun SettingsScreen(onOpenLog: () -> Unit) {
val context = LocalContext.current val context = LocalContext.current
val app = A2iApp.instance val app = A2iApp.instance
val servers by app.settings.servers.collectAsState() val servers by app.settings.servers.collectAsState()
val ntfyServers by app.settings.ntfyServers.collectAsState() val ntfyServers by app.settings.ntfyServers.collectAsState()
val meowServers by app.settings.meowServers.collectAsState()
val emailServers by app.settings.emailServers.collectAsState() val emailServers by app.settings.emailServers.collectAsState()
val smsTargets by app.settings.smsTargets.collectAsState() val smsTargets by app.settings.smsTargets.collectAsState()
val smsFilterAll by app.settings.smsFilterAll.collectAsState()
val smsRateLimitEnabled by app.settings.smsRateLimitEnabled.collectAsState()
val smsRateLimitMin by app.settings.smsRateLimitMin.collectAsState()
val smsBalanceCheck by app.settings.smsBalanceCheck.collectAsState()
val barkStopFirst by app.settings.barkStopOnFirst.collectAsState() val barkStopFirst by app.settings.barkStopOnFirst.collectAsState()
val ntfyStopFirst by app.settings.ntfyStopOnFirst.collectAsState() val ntfyStopFirst by app.settings.ntfyStopOnFirst.collectAsState()
val meowStopFirst by app.settings.meowStopOnFirst.collectAsState()
val emailStopFirst by app.settings.emailStopOnFirst.collectAsState() val emailStopFirst by app.settings.emailStopOnFirst.collectAsState()
val smsStopFirst by app.settings.smsStopOnFirst.collectAsState() val smsStopFirst by app.settings.smsStopOnFirst.collectAsState()
val ntfyEnabled by app.settings.ntfyEnabled.collectAsState()
val emailEnabled by app.settings.emailForwardingEnabled.collectAsState() val emailEnabled by app.settings.emailForwardingEnabled.collectAsState()
val smsFallback by app.settings.smsFallbackEnabled.collectAsState() val smsFallback by app.settings.smsFallbackEnabled.collectAsState()
val iconPrefix by app.settings.iconPrefix.collectAsState() val iconPrefix by app.settings.iconPrefix.collectAsState()
@@ -153,14 +118,9 @@ fun SettingsScreen(onOpenLog: () -> Unit) {
// 弹窗状态 // 弹窗状态
var showAddBark by remember { mutableStateOf(false) } var showAddBark by remember { mutableStateOf(false) }
var showBarkHelp by remember { mutableStateOf(false) }
var editingBark by remember { mutableStateOf<BarkServer?>(null) } var editingBark by remember { mutableStateOf<BarkServer?>(null) }
var showAddNtfy by remember { mutableStateOf(false) } var showAddNtfy by remember { mutableStateOf(false) }
var showNtfyHelp by remember { mutableStateOf(false) }
var editingNtfy by remember { mutableStateOf<NtfyServer?>(null) } var editingNtfy by remember { mutableStateOf<NtfyServer?>(null) }
var showAddMeow by remember { mutableStateOf(false) }
var showMeowHelp by remember { mutableStateOf(false) }
var editingMeow by remember { mutableStateOf<MeowServer?>(null) }
var showAddEmail by remember { mutableStateOf(false) } var showAddEmail by remember { mutableStateOf(false) }
var editingEmail by remember { mutableStateOf<EmailServer?>(null) } var editingEmail by remember { mutableStateOf<EmailServer?>(null) }
var showAddSms by remember { mutableStateOf(false) } var showAddSms by remember { mutableStateOf(false) }
@@ -174,19 +134,13 @@ fun SettingsScreen(onOpenLog: () -> Unit) {
// ===== Bark 服务器 ===== // ===== Bark 服务器 =====
item { item {
SectionCard { SectionCard("Bark 服务器", subtitle = "勾选的服务器参与转发;开关切换容错/广播") {
ChannelSectionHeader( SwitchRow(
title = "Bark 服务器", title = "发通一条就停",
summary = "iPhone / iPad 推送目标", subtitle = if (barkStopFirst) "容错模式:首个成功即止" else "广播模式:每个都发",
mark = "B", checked = barkStopFirst,
markColor = Color(0xFFFF9800), icon = Icons.Filled.Settings,
markLogo = painterResource(R.drawable.ic_bark_logo), ) { v -> app.appScope.launch { app.settings.setBarkStopOnFirst(v) } }
platforms = listOf("iOS"),
stopFirst = barkStopFirst,
onStopFirstChange = { v -> app.appScope.launch { app.settings.setBarkStopOnFirst(v) } },
onHelpClick = { showBarkHelp = true },
helpContentDescription = "Bark 配置帮助",
)
if (servers.isNotEmpty()) { if (servers.isNotEmpty()) {
Spacer(Modifier.size(8.dp)) Spacer(Modifier.size(8.dp))
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
@@ -207,186 +161,95 @@ fun SettingsScreen(onOpenLog: () -> Unit) {
// ===== ntfy ===== // ===== ntfy =====
item { item {
SectionCard { SectionCard("ntfy 转发", subtitle = "POST 推送到 ntfy 服务") {
ChannelSectionHeader( SwitchRow(title = "启用 ntfy 转发", subtitle = if (ntfyEnabled) "已开启" else "已关闭", checked = ntfyEnabled) { v -> app.appScope.launch { app.settings.setNtfyEnabled(v) } }
title = "ntfy 转发", if (ntfyEnabled) {
summary = "Android / iOS topic 推送", SwitchRow(title = "发通一条就停", subtitle = if (ntfyStopFirst) "容错:首个成功止" else "广播:每个都发", checked = ntfyStopFirst) { v -> app.appScope.launch { app.settings.setNtfyStopOnFirst(v) } }
mark = "n", if (ntfyServers.isNotEmpty()) {
markColor = Color(0xFF43A047), Spacer(Modifier.size(8.dp))
markLogo = painterResource(R.drawable.ic_ntfy_logo), Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
platforms = listOf("Android", "iOS"), ntfyServers.forEachIndexed { i, s ->
stopFirst = ntfyStopFirst, ConfigRow(
onStopFirstChange = { v -> app.appScope.launch { app.settings.setNtfyStopOnFirst(v) } }, title = s.name.ifBlank { s.topic },
onHelpClick = { showNtfyHelp = true }, subtitle = "${s.server} · topic: ${s.topic}${if (s.token.isNotBlank()) " · token已填" else ""}",
helpContentDescription = "ntfy 配置帮助", enabled = s.enabled, isFirst = i == 0, isLast = i == ntfyServers.lastIndex,
) onToggle = { v -> app.appScope.launch { app.settings.setNtfyEnabled(s.id, v) } },
if (ntfyServers.isNotEmpty()) { onEdit = { editingNtfy = s }, onDelete = { app.appScope.launch { app.settings.deleteNtfy(s.id) } },
Spacer(Modifier.size(8.dp)) onMoveUp = { app.appScope.launch { app.settings.moveNtfy(s.id, true) } },
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { onMoveDown = { app.appScope.launch { app.settings.moveNtfy(s.id, false) } },
ntfyServers.forEachIndexed { i, s -> onTest = { app.appScope.launch { NtfySender.send(s.server, s.topic, s.token, "a2i测试", "ntfy配置测试") } },
NtfyServerRow( )
server = s, }
isFirst = i == 0,
isLast = i == ntfyServers.lastIndex,
onToggle = { v -> app.appScope.launch { app.settings.setNtfyEnabled(s.id, v) } },
onEdit = { editingNtfy = s },
onDelete = { app.appScope.launch { app.settings.deleteNtfy(s.id) } },
onMoveUp = { app.appScope.launch { app.settings.moveNtfy(s.id, true) } },
onMoveDown = { app.appScope.launch { app.settings.moveNtfy(s.id, false) } },
)
} }
} }
AddButton { showAddNtfy = true }
} }
AddButton { showAddNtfy = true }
}
}
// ===== Meow =====
item {
SectionCard {
ChannelSectionHeader(
title = "Meow 转发",
summary = "鸿蒙手机推送目标",
mark = "M",
markColor = Color(0xFF7E57C2),
markLogo = painterResource(R.drawable.ic_meow_logo),
platforms = listOf("HarmonyOS"),
stopFirst = meowStopFirst,
onStopFirstChange = { v -> app.appScope.launch { app.settings.setMeowStopOnFirst(v) } },
onHelpClick = { showMeowHelp = true },
helpContentDescription = "Meow 配置帮助",
)
if (meowServers.isNotEmpty()) {
Spacer(Modifier.size(8.dp))
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
meowServers.forEachIndexed { i, s ->
MeowServerRow(
server = s,
isFirst = i == 0,
isLast = i == meowServers.lastIndex,
onToggle = { v -> app.appScope.launch { app.settings.setMeowEnabled(s.id, v) } },
onEdit = { editingMeow = s },
onDelete = { app.appScope.launch { app.settings.deleteMeow(s.id) } },
onMoveUp = { app.appScope.launch { app.settings.moveMeow(s.id, true) } },
onMoveDown = { app.appScope.launch { app.settings.moveMeow(s.id, false) } },
)
}
}
}
AddButton { showAddMeow = true }
} }
} }
// ===== 电邮 ===== // ===== 电邮 =====
item { item {
SectionCard { SectionCard("电邮转发", subtitle = "SMTPS 直发邮件") {
ChannelSectionHeader( SwitchRow(title = "启用电邮转发", subtitle = if (emailEnabled) "已开启" else "已关闭", checked = emailEnabled) { v -> app.appScope.launch { app.settings.setEmailForwarding(v) } }
title = "电邮转发", if (emailEnabled) {
summary = "SMTP 邮箱推送目标", SwitchRow(title = "发通一条就停", subtitle = if (emailStopFirst) "容错:首个成功止" else "广播:每个都发", checked = emailStopFirst) { v -> app.appScope.launch { app.settings.setEmailStopOnFirst(v) } }
mark = "@", if (emailServers.isNotEmpty()) {
markColor = Color(0xFF1976D2), Spacer(Modifier.size(8.dp))
markIcon = Icons.Filled.Email, Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
platforms = listOf("Email"), emailServers.forEachIndexed { i, s ->
stopFirst = emailStopFirst, ConfigRow(
onStopFirstChange = { v -> app.appScope.launch { app.settings.setEmailStopOnFirst(v) } }, title = s.name.ifBlank { s.to },
) subtitle = "${s.host}:${s.port} · ${s.user}${s.to}",
if (emailServers.isNotEmpty()) { enabled = s.enabled, isFirst = i == 0, isLast = i == emailServers.lastIndex,
Spacer(Modifier.size(8.dp)) onToggle = { v -> app.appScope.launch { app.settings.setEmailEnabled(s.id, v) } },
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { onEdit = { editingEmail = s }, onDelete = { app.appScope.launch { app.settings.deleteEmail(s.id) } },
emailServers.forEachIndexed { i, s -> onMoveUp = { app.appScope.launch { app.settings.moveEmail(s.id, true) } },
EmailServerRow( onMoveDown = { app.appScope.launch { app.settings.moveEmail(s.id, false) } },
server = s, onTest = { app.appScope.launch { EmailSender.send(s.host, s.port, s.user, s.password, s.from, s.to, "a2i测试", "电邮配置测试") } },
isFirst = i == 0, )
isLast = i == emailServers.lastIndex, }
onToggle = { v -> app.appScope.launch { app.settings.setEmailEnabled(s.id, v) } },
onEdit = { editingEmail = s },
onDelete = { app.appScope.launch { app.settings.deleteEmail(s.id) } },
onMoveUp = { app.appScope.launch { app.settings.moveEmail(s.id, true) } },
onMoveDown = { app.appScope.launch { app.settings.moveEmail(s.id, false) } },
)
} }
} }
AddButton { showAddEmail = true }
} }
AddButton { showAddEmail = true }
} }
} }
// ===== 短信兜底 ===== // ===== 短信兜底 =====
item { item {
SectionCard { SectionCard("断网短信兜底", subtitle = "无网络时把验证码/来电用短信发出去") {
ChannelSectionHeader( SwitchRow(title = "启用短信兜底", subtitle = if (smsFallback) "已开启" else "已关闭", checked = smsFallback) { v -> app.appScope.launch { app.settings.setSmsFallback(v) } }
title = "断网短信兜底", if (smsFallback) {
summary = "无网络时用短信发送通知", SwitchRow(title = "发通一条就停", subtitle = if (smsStopFirst) "容错:首个成功止" else "广播:每个都发", checked = smsStopFirst) { v -> app.appScope.launch { app.settings.setSmsStopOnFirst(v) } }
mark = "S", if (!hasSmsPerm) {
markColor = Color(0xFF00897B), Spacer(Modifier.size(4.dp))
markIcon = Icons.Filled.Sms, OutlinedButton(onClick = { smsPermLauncher.launch(android.Manifest.permission.SEND_SMS) }, modifier = Modifier.fillMaxWidth()) { Text("授予短信权限") }
platforms = listOf("SMS"), }
stopFirst = smsStopFirst, if (smsTargets.isNotEmpty()) {
onStopFirstChange = { v -> app.appScope.launch { app.settings.setSmsStopOnFirst(v) } }, Spacer(Modifier.size(8.dp))
) Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
if (!hasSmsPerm) { smsTargets.forEachIndexed { i, s ->
Spacer(Modifier.size(4.dp)) ConfigRow(
OutlinedButton(onClick = { smsPermLauncher.launch(android.Manifest.permission.SEND_SMS) }, modifier = Modifier.fillMaxWidth()) { Text("授予短信权限") } title = s.name.ifBlank { s.number },
} subtitle = s.number,
if (smsTargets.isNotEmpty()) { enabled = s.enabled, isFirst = i == 0, isLast = i == smsTargets.lastIndex,
Spacer(Modifier.size(8.dp)) onToggle = { v -> app.appScope.launch { app.settings.setSmsTargetEnabled(s.id, v) } },
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { onEdit = { editingSms = s }, onDelete = { app.appScope.launch { app.settings.deleteSmsTarget(s.id) } },
smsTargets.forEachIndexed { i, s -> onMoveUp = { app.appScope.launch { app.settings.moveSmsTarget(s.id, true) } },
ConfigRow( onMoveDown = { app.appScope.launch { app.settings.moveSmsTarget(s.id, false) } },
title = s.name.ifBlank { s.number }, onTest = null,
subtitle = s.number, )
enabled = s.enabled, isFirst = i == 0, isLast = i == smsTargets.lastIndex, }
onToggle = { v -> app.appScope.launch { app.settings.setSmsTargetEnabled(s.id, v) } },
onEdit = { editingSms = s }, onDelete = { app.appScope.launch { app.settings.deleteSmsTarget(s.id) } },
onMoveUp = { app.appScope.launch { app.settings.moveSmsTarget(s.id, true) } },
onMoveDown = { app.appScope.launch { app.settings.moveSmsTarget(s.id, false) } },
onTest = null,
)
} }
} }
} AddButton { showAddSms = true }
AddButton { showAddSms = true }
// ── 高级设置 ──
Spacer(Modifier.size(12.dp))
SwitchRow(
title = "仅紧要通知",
subtitle = if (smsFilterAll) "当前:全部转发(不限内容)" else "当前:仅验证码 + 来电",
checked = !smsFilterAll,
icon = Icons.Filled.Settings,
) { v -> app.appScope.launch { app.settings.setSmsFilterAll(!v) } }
SwitchRow(
title = "限频",
subtitle = if (smsRateLimitEnabled) "${smsRateLimitMin} 分钟最多 1 条" else "已关闭(来一条发一条)",
checked = smsRateLimitEnabled,
) { v -> app.appScope.launch { app.settings.setSmsRateLimitEnabled(v) } }
SwitchRow(
title = "余额检查",
subtitle = if (smsBalanceCheck) "余额 ≤5 自动挂起" else "已关闭(不限余额)",
checked = smsBalanceCheck,
) { v -> app.appScope.launch { app.settings.setSmsBalanceCheck(v) } }
// 余额检查开启时显示运营商/额度/挂起状态
if (smsBalanceCheck) {
Spacer(Modifier.size(8.dp)) Spacer(Modifier.size(8.dp))
val balanceText = when { val balanceText = when {
smsSuspended -> "已挂起(余额不足)" smsSuspended -> "已挂起(余额不足)"
smsBalance < 0 -> "余额未知" smsBalance < 0 -> "余额未知"
else -> "剩余约 $smsBalance" else -> "剩余约 $smsBalance"
} }
Text("运营商:${detectedCarrier.displayName} · $balanceText · 手动额度:$smsManualQuota", style = MaterialTheme.typography.bodySmall, color = if (smsSuspended) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.onSurfaceVariant) Text("运营商:${detectedCarrier.displayName} · 余额:$balanceText · 手动额度:$smsManualQuota", style = MaterialTheme.typography.bodySmall, color = if (smsSuspended) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.onSurfaceVariant)
if (smsSuspended) {
Spacer(Modifier.size(4.dp))
OutlinedButton(onClick = {
app.appScope.launch {
app.settings.setSmsSuspended(false)
val q = smsManualQuota
if (q > 0) app.settings.setSmsBalance(q)
}
}, modifier = Modifier.fillMaxWidth()) { Text("解除挂起") }
}
} }
} }
} }
@@ -444,15 +307,10 @@ fun SettingsScreen(onOpenLog: () -> Unit) {
} }
// 弹窗 // 弹窗
if (showBarkHelp) BarkHelpDialog(onDismiss = { showBarkHelp = false })
if (showNtfyHelp) NtfyHelpDialog(onDismiss = { showNtfyHelp = false })
if (showMeowHelp) MeowHelpDialog(onDismiss = { showMeowHelp = false })
if (showAddBark) ServerDialog(onDismiss = { showAddBark = false }, onConfirm = { n, s, k -> app.appScope.launch { app.settings.addServer(n, s, k) }; showAddBark = false }) if (showAddBark) ServerDialog(onDismiss = { showAddBark = false }, onConfirm = { n, s, k -> app.appScope.launch { app.settings.addServer(n, s, k) }; showAddBark = false })
editingBark?.let { srv -> ServerDialog(initial = srv, onDismiss = { editingBark = null }, onConfirm = { n, s, k -> app.appScope.launch { app.settings.updateServer(srv.copy(name = n, server = s, deviceKey = k)) }; editingBark = null }) } editingBark?.let { srv -> ServerDialog(initial = srv, onDismiss = { editingBark = null }, onConfirm = { n, s, k -> app.appScope.launch { app.settings.updateServer(srv.copy(name = n, server = s, deviceKey = k)) }; editingBark = null }) }
if (showAddNtfy) NtfyConfigDialog(onDismiss = { showAddNtfy = false }, onConfirm = { n, sv, t, tk -> app.appScope.launch { app.settings.addNtfy(NtfyServer(name = n, server = sv, topic = t, token = tk)) }; showAddNtfy = false }) if (showAddNtfy) NtfyConfigDialog(onDismiss = { showAddNtfy = false }, onConfirm = { n, sv, t, tk -> app.appScope.launch { app.settings.addNtfy(NtfyServer(name = n, server = sv, topic = t, token = tk)) }; showAddNtfy = false })
editingNtfy?.let { srv -> NtfyConfigDialog(initial = srv, onDismiss = { editingNtfy = null }, onConfirm = { n, sv, t, tk -> app.appScope.launch { app.settings.updateNtfy(srv.copy(name = n, server = sv, topic = t, token = tk)) }; editingNtfy = null }) } editingNtfy?.let { srv -> NtfyConfigDialog(initial = srv, onDismiss = { editingNtfy = null }, onConfirm = { n, sv, t, tk -> app.appScope.launch { app.settings.updateNtfy(srv.copy(name = n, server = sv, topic = t, token = tk)) }; editingNtfy = null }) }
if (showAddMeow) MeowConfigDialog(onDismiss = { showAddMeow = false }, onConfirm = { n, sv, k -> app.appScope.launch { app.settings.addMeow(MeowServer(name = n, server = sv, deviceKey = k)) }; showAddMeow = false })
editingMeow?.let { srv -> MeowConfigDialog(initial = srv, onDismiss = { editingMeow = null }, onConfirm = { n, sv, k -> app.appScope.launch { app.settings.updateMeow(srv.copy(name = n, server = sv, deviceKey = k)) }; editingMeow = null }) }
if (showAddEmail) EmailConfigDialog(onDismiss = { showAddEmail = false }, onConfirm = { c -> app.appScope.launch { app.settings.addEmail(c) }; showAddEmail = false }) if (showAddEmail) EmailConfigDialog(onDismiss = { showAddEmail = false }, onConfirm = { c -> app.appScope.launch { app.settings.addEmail(c) }; showAddEmail = false })
editingEmail?.let { srv -> EmailConfigDialog(initial = srv, onDismiss = { editingEmail = null }, onConfirm = { c -> app.appScope.launch { app.settings.updateEmail(c.copy(id = srv.id)) }; editingEmail = null }) } editingEmail?.let { srv -> EmailConfigDialog(initial = srv, onDismiss = { editingEmail = null }, onConfirm = { c -> app.appScope.launch { app.settings.updateEmail(c.copy(id = srv.id)) }; editingEmail = null }) }
if (showAddSms) SmsConfigDialog(onDismiss = { showAddSms = false }, onConfirm = { n, num -> app.appScope.launch { app.settings.addSmsTarget(SmsTarget(name = n, number = num)) }; showAddSms = false }) if (showAddSms) SmsConfigDialog(onDismiss = { showAddSms = false }, onConfirm = { n, num -> app.appScope.launch { app.settings.addSmsTarget(SmsTarget(name = n, number = num)) }; showAddSms = false })
@@ -468,357 +326,6 @@ private fun AddButton(onClick: () -> Unit) {
} }
} }
@Composable
private fun ChannelSectionHeader(
title: String,
summary: String,
mark: String,
markColor: Color,
markIcon: ImageVector? = null,
markLogo: Painter? = null,
platforms: List<String>,
stopFirst: Boolean,
onStopFirstChange: (Boolean) -> Unit,
onHelpClick: (() -> Unit)? = null,
helpContentDescription: String? = null,
) {
Column(
modifier = Modifier.fillMaxWidth().padding(bottom = 4.dp),
verticalArrangement = Arrangement.spacedBy(10.dp),
) {
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
) {
ChannelMark(mark = mark, tint = markColor, icon = markIcon, logoPainter = markLogo)
Spacer(Modifier.width(10.dp))
Row(
modifier = Modifier.weight(1f),
verticalAlignment = Alignment.CenterVertically,
) {
Text(
title,
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f, fill = false),
)
if (platforms.isNotEmpty()) {
Spacer(Modifier.width(8.dp))
Row(
horizontalArrangement = Arrangement.spacedBy(5.dp),
verticalAlignment = Alignment.CenterVertically,
) {
platforms.forEach { PlatformBadge(it) }
}
}
}
if (onHelpClick != null) {
Spacer(Modifier.width(8.dp))
IconButton(onClick = onHelpClick, modifier = Modifier.size(40.dp)) {
Icon(
Icons.Filled.Info,
helpContentDescription,
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
) {
Text(
summary,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f),
)
Spacer(Modifier.width(8.dp))
DeliveryModeControl(
stopFirst = stopFirst,
onStopFirstChange = onStopFirstChange,
)
}
}
}
@Composable
private fun ChannelMark(mark: String, tint: Color, icon: ImageVector? = null, logoPainter: Painter? = null) {
Surface(
modifier = Modifier.size(36.dp),
shape = RoundedCornerShape(10.dp),
color = tint.copy(alpha = 0.14f),
border = BorderStroke(1.dp, tint.copy(alpha = 0.28f)),
) {
Row(
modifier = Modifier.fillMaxSize(),
horizontalArrangement = Arrangement.Center,
verticalAlignment = Alignment.CenterVertically,
) {
if (logoPainter != null) {
Image(logoPainter, null, modifier = Modifier.size(24.dp))
} else if (icon != null) {
Icon(icon, null, tint = tint, modifier = Modifier.size(20.dp))
} else {
Text(mark, style = MaterialTheme.typography.titleSmall, fontWeight = FontWeight.Bold, color = tint, maxLines = 1)
}
}
}
}
@Composable
private fun PlatformBadge(label: String) {
Surface(
shape = RoundedCornerShape(999.dp),
color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.72f),
border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant),
) {
Text(
label,
modifier = Modifier.padding(horizontal = 7.dp, vertical = 3.dp),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1,
)
}
}
@Composable
private fun DeliveryModeControl(
stopFirst: Boolean,
onStopFirstChange: (Boolean) -> Unit,
) {
Row(verticalAlignment = Alignment.CenterVertically) {
Text(
"模式",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1,
)
Spacer(Modifier.width(6.dp))
DeliveryModeOption(
label = "容错",
selected = stopFirst,
shape = RoundedCornerShape(topStart = 8.dp, topEnd = 0.dp, bottomEnd = 0.dp, bottomStart = 8.dp),
onClick = { onStopFirstChange(true) },
)
DeliveryModeOption(
label = "广播",
selected = !stopFirst,
shape = RoundedCornerShape(topStart = 0.dp, topEnd = 8.dp, bottomEnd = 8.dp, bottomStart = 0.dp),
onClick = { onStopFirstChange(false) },
)
}
}
@Composable
private fun DeliveryModeOption(
label: String,
selected: Boolean,
shape: RoundedCornerShape,
onClick: () -> Unit,
) {
Surface(
modifier = Modifier.clickable(onClick = onClick),
shape = shape,
color = if (selected) MaterialTheme.colorScheme.primaryContainer else MaterialTheme.colorScheme.surface,
border = BorderStroke(
1.dp,
if (selected) MaterialTheme.colorScheme.primary.copy(alpha = 0.50f) else MaterialTheme.colorScheme.outlineVariant,
),
) {
Text(
label,
modifier = Modifier.padding(horizontal = 9.dp, vertical = 5.dp),
style = MaterialTheme.typography.labelMedium,
fontWeight = if (selected) FontWeight.SemiBold else FontWeight.Medium,
color = if (selected) MaterialTheme.colorScheme.onPrimaryContainer else MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1,
)
}
}
@Composable
private fun BarkHelpDialog(onDismiss: () -> Unit) {
val uriHandler = LocalUriHandler.current
AlertDialog(
onDismissRequest = onDismiss,
icon = { Icon(Icons.Filled.Info, null) },
title = { Text("Bark 配置帮助") },
text = {
Column(
modifier = Modifier
.heightIn(max = 440.dp)
.verticalScroll(rememberScrollState()),
verticalArrangement = Arrangement.spacedBy(10.dp),
) {
Text(
"Bark 用来把 a2i 处理后的 Android 通知推送到 iPhone / iPad。第一次使用按下面顺序配置即可。",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
HelpStep("1", "在 iPhone / iPad 上安装 Bark,首次打开时允许通知权限。")
HelpStep("2", "打开 Bark,复制首页显示的推送地址,通常类似 $BARK_OFFICIAL_SERVER/你的Key。")
HelpStep("3", "回到 a2i,点击本区域底部的「添加」。服务器地址填 $BARK_OFFICIAL_SERVER,自建服务则填你的自建地址。")
HelpStep("4", "Device Key 填推送地址最后一段 Key,不要把完整 URL 都填进去。")
HelpStep("5", "保存后点服务器行里的发送按钮测试;iPhone 收到「GotMsg 测试」就说明 Bark 通道已通。")
HelpStep("6", "最后回首页开启「通知转发」,并按提示授予系统通知监听权限。")
Text(
"常用链接",
style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.SemiBold,
modifier = Modifier.padding(top = 4.dp),
)
HelpLink("Bark App Store", BARK_APP_STORE_URL) { uriHandler.openUri(it) }
HelpLink("Bark 官方文档", BARK_DOCS_URL) { uriHandler.openUri(it) }
HelpLink("Bark GitHub", BARK_GITHUB_URL) { uriHandler.openUri(it) }
Text(
"提示:如果使用自建 Bark Server,a2i 里的服务器地址要换成自建地址,但 Device Key 仍然来自 Bark App。",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(top = 2.dp),
)
}
},
confirmButton = { TextButton(onClick = onDismiss) { Text("知道了") } },
)
}
@Composable
private fun NtfyHelpDialog(onDismiss: () -> Unit) {
val uriHandler = LocalUriHandler.current
AlertDialog(
onDismissRequest = onDismiss,
icon = { Icon(Icons.Filled.Info, null) },
title = { Text("ntfy 配置帮助") },
text = {
Column(
modifier = Modifier
.heightIn(max = 440.dp)
.verticalScroll(rememberScrollState()),
verticalArrangement = Arrangement.spacedBy(10.dp),
) {
Text(
"ntfy 用来把 a2i 处理后的通知推送到 Android、iOS 或网页版 ntfy。它不需要先建项目,只要接收端和 a2i 使用同一个 server + topic 即可。",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
HelpStep("1", "在接收端安装 ntfy。Android 可用 Google Play / F-Droid / GitHub APKiPhone / iPad 可用 App Store。")
HelpStep("2", "打开 ntfy,添加订阅。服务器通常填 $NTFY_OFFICIAL_SERVERTopic 建议用一串不容易猜到的随机名字,例如 a2i_xxxxxxxx。")
HelpStep("3", "回到 a2i,点击本区域底部的「添加」。服务器和 Topic 要与接收端订阅完全一致。")
HelpStep("4", "Token 是可选项。只有你在 ntfy 账号或自建服务里把 topic 设成需要鉴权时,才填写 access token。")
HelpStep("5", "保存后点 topic 行里的发送按钮测试;接收端收到「GotMsg 测试」就说明 ntfy 通道已通。")
HelpStep("6", "后续只要这一行左侧保持勾选,它就会参与转发;取消勾选就是停用这个 topic。")
Text(
"常用链接",
style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.SemiBold,
modifier = Modifier.padding(top = 4.dp),
)
HelpLink("ntfy Android · Google Play", NTFY_ANDROID_URL) { uriHandler.openUri(it) }
HelpLink("ntfy Android · F-Droid", NTFY_FDROID_URL) { uriHandler.openUri(it) }
HelpLink("ntfy iOS · App Store", NTFY_IOS_URL) { uriHandler.openUri(it) }
HelpLink("ntfy 手机端文档", NTFY_DOCS_URL) { uriHandler.openUri(it) }
HelpLink("ntfy 发布 API 文档", NTFY_PUBLISH_DOCS_URL) { uriHandler.openUri(it) }
HelpLink("ntfy GitHub", NTFY_GITHUB_URL) { uriHandler.openUri(it) }
Text(
"提示:公共 topic 谁知道名字谁就可能订阅或发送,所以 topic 名不要用手机号、姓名、邮箱等可猜信息。",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(top = 2.dp),
)
}
},
confirmButton = { TextButton(onClick = onDismiss) { Text("知道了") } },
)
}
@Composable
private fun MeowHelpDialog(onDismiss: () -> Unit) {
AlertDialog(
onDismissRequest = onDismiss,
icon = { Icon(Icons.Filled.Info, null, tint = Color(0xFFFF9800)) },
title = { Text("Meow 配置帮助") },
text = {
Column(
modifier = Modifier
.heightIn(max = 440.dp)
.verticalScroll(rememberScrollState()),
verticalArrangement = Arrangement.spacedBy(10.dp),
) {
Text(
"Meow 用来把 a2i 处理后的 Android 通知推送到鸿蒙手机。a2i 只负责按你填写的 Meow 接口地址和 Device Key 发出 HTTP 请求。",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
HelpStep("1", "在鸿蒙手机上安装并打开 Meow,先确认 Meow 自身可以正常接收推送。")
HelpStep("2", "在 Meow 里找到推送接口、服务器地址或 API 地址,同时复制对应的 Device Key / Token。")
HelpStep("3", "回到 a2i,点击本区域底部的「添加」。名称按你的鸿蒙设备填写,方便以后区分。")
HelpStep("4", "服务器地址可以填 Meow 给出的完整 API 地址;如果 Meow 只给了服务根地址,a2i 也会尝试常见的 /push 路径。")
HelpStep("5", "Device Key 填 Meow 给你的设备 Key 或 Token,不要填写鸿蒙手机锁屏密码、华为账号密码等敏感账号密码。")
HelpStep("6", "保存后点设备行里的发送按钮测试;鸿蒙手机收到「GotMsg 测试」就说明 Meow 通道已通。")
HelpStep("7", "后续只要这一行左侧保持勾选,它就会参与转发;取消勾选就是停用这个鸿蒙设备。")
Text(
"提示:如果测试失败,优先检查 Meow 里的接口地址是否完整、Key 是否复制完整,以及鸿蒙手机是否允许 Meow 通知。",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(top = 2.dp),
)
}
},
confirmButton = { TextButton(onClick = onDismiss) { Text("知道了") } },
)
}
@Composable
private fun HelpStep(
index: String,
text: String,
) {
Row(verticalAlignment = Alignment.Top) {
Text(
"$index.",
style = MaterialTheme.typography.bodyMedium,
fontWeight = FontWeight.SemiBold,
color = MaterialTheme.colorScheme.primary,
modifier = Modifier.width(24.dp),
)
Text(
text,
style = MaterialTheme.typography.bodyMedium,
modifier = Modifier.weight(1f),
)
}
}
@Composable
private fun HelpLink(
title: String,
url: String,
onOpen: (String) -> Unit,
) {
OutlinedButton(
onClick = { onOpen(url) },
modifier = Modifier.fillMaxWidth(),
) {
Text(title, maxLines = 1, overflow = TextOverflow.Ellipsis)
}
}
/** 通用配置行(ntfy/电邮/短信 共用,仿 ServerRow 简化版)。 */ /** 通用配置行(ntfy/电邮/短信 共用,仿 ServerRow 简化版)。 */
@Composable @Composable
private fun ConfigRow( private fun ConfigRow(
@@ -851,87 +358,7 @@ private fun ConfigRow(
} }
} }
// ===== ntfy / Bark Row + Dialog ===== // ===== Bark 服务器 Row + Dialog(保留原有) =====
@Composable
private fun MeowServerRow(
server: MeowServer, isFirst: Boolean, isLast: Boolean,
onToggle: (Boolean) -> Unit, onEdit: () -> Unit, onDelete: () -> Unit,
onMoveUp: () -> Unit, onMoveDown: () -> Unit,
) {
val app = A2iApp.instance
var testing by remember { mutableStateOf(false) }
var testMsg by remember { mutableStateOf<String?>(null) }
Surface(
modifier = Modifier.fillMaxWidth(), shape = RoundedCornerShape(8.dp),
color = if (server.enabled) MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.40f) else MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.36f),
border = BorderStroke(1.dp, if (server.enabled) MaterialTheme.colorScheme.primary.copy(alpha = 0.30f) else MaterialTheme.colorScheme.outlineVariant),
) {
Column(Modifier.padding(start = 4.dp, end = 4.dp, top = 2.dp, bottom = 6.dp)) {
Row(verticalAlignment = Alignment.CenterVertically) {
Checkbox(checked = server.enabled, onCheckedChange = onToggle)
Row(Modifier.weight(1f), verticalAlignment = Alignment.CenterVertically) {
Text(server.name.ifBlank { "鸿蒙手机" }, style = MaterialTheme.typography.bodyLarge, fontWeight = FontWeight.Medium, maxLines = 1, overflow = TextOverflow.Ellipsis, modifier = Modifier.weight(1f, fill = false))
if (!server.enabled) { Spacer(Modifier.width(8.dp)); StatusBadge("停用", MaterialTheme.colorScheme.onSurfaceVariant) }
}
IconButton(onClick = { if (!testing) app.appScope.launch { testing = true; testMsg = testMeow(server); testing = false } }, enabled = !testing) {
if (testing) CircularProgressIndicator(Modifier.size(18.dp), strokeWidth = 2.dp) else Icon(Icons.AutoMirrored.Filled.Send, "测试")
}
IconButton(onClick = onEdit) { Icon(Icons.Filled.Edit, "编辑") }
IconButton(onClick = onDelete) { Icon(Icons.Filled.Delete, "删除") }
}
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.padding(top = 2.dp)) {
Column(Modifier.weight(1f).padding(start = 36.dp)) {
Text(server.server.ifBlank { "服务器地址未填" }, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, maxLines = 2, overflow = TextOverflow.Ellipsis)
Text("device key: " + if (server.deviceKey.isBlank()) "未填" else "已填(${server.deviceKey.length} 位)", style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
}
IconButton(onClick = onMoveUp, enabled = !isFirst) { Icon(Icons.Filled.KeyboardArrowUp, "上移") }
IconButton(onClick = onMoveDown, enabled = !isLast) { Icon(Icons.Filled.KeyboardArrowDown, "下移") }
}
testMsg?.let { Text(it, style = MaterialTheme.typography.bodySmall, color = if (it.startsWith("已发送")) MaterialTheme.colorScheme.tertiary else MaterialTheme.colorScheme.error, modifier = Modifier.padding(start = 48.dp, top = 2.dp)) }
}
}
}
@Composable
private fun NtfyServerRow(
server: NtfyServer, isFirst: Boolean, isLast: Boolean,
onToggle: (Boolean) -> Unit, onEdit: () -> Unit, onDelete: () -> Unit,
onMoveUp: () -> Unit, onMoveDown: () -> Unit,
) {
val app = A2iApp.instance
var testing by remember { mutableStateOf(false) }
var testMsg by remember { mutableStateOf<String?>(null) }
Surface(
modifier = Modifier.fillMaxWidth(), shape = RoundedCornerShape(8.dp),
color = if (server.enabled) MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.40f) else MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.36f),
border = BorderStroke(1.dp, if (server.enabled) MaterialTheme.colorScheme.primary.copy(alpha = 0.30f) else MaterialTheme.colorScheme.outlineVariant),
) {
Column(Modifier.padding(start = 4.dp, end = 4.dp, top = 2.dp, bottom = 6.dp)) {
Row(verticalAlignment = Alignment.CenterVertically) {
Checkbox(checked = server.enabled, onCheckedChange = onToggle)
Row(Modifier.weight(1f), verticalAlignment = Alignment.CenterVertically) {
Text(server.name.ifBlank { server.topic.ifBlank { "未命名" } }, style = MaterialTheme.typography.bodyLarge, fontWeight = FontWeight.Medium, maxLines = 1, overflow = TextOverflow.Ellipsis, modifier = Modifier.weight(1f, fill = false))
if (!server.enabled) { Spacer(Modifier.width(8.dp)); StatusBadge("停用", MaterialTheme.colorScheme.onSurfaceVariant) }
}
IconButton(onClick = { if (!testing) app.appScope.launch { testing = true; testMsg = testNtfy(server); testing = false } }, enabled = !testing) {
if (testing) CircularProgressIndicator(Modifier.size(18.dp), strokeWidth = 2.dp) else Icon(Icons.AutoMirrored.Filled.Send, "测试")
}
IconButton(onClick = onEdit) { Icon(Icons.Filled.Edit, "编辑") }
IconButton(onClick = onDelete) { Icon(Icons.Filled.Delete, "删除") }
}
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.padding(top = 2.dp)) {
Column(Modifier.weight(1f).padding(start = 36.dp)) {
Text(server.server, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, maxLines = 2, overflow = TextOverflow.Ellipsis)
Text("topic: " + server.topic.ifBlank { "未填" } + if (server.token.isNotBlank()) " · token已填" else "", style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSurfaceVariant, maxLines = 1, overflow = TextOverflow.Ellipsis)
}
IconButton(onClick = onMoveUp, enabled = !isFirst) { Icon(Icons.Filled.KeyboardArrowUp, "上移") }
IconButton(onClick = onMoveDown, enabled = !isLast) { Icon(Icons.Filled.KeyboardArrowDown, "下移") }
}
testMsg?.let { Text(it, style = MaterialTheme.typography.bodySmall, color = if (it.startsWith("已发送")) MaterialTheme.colorScheme.tertiary else MaterialTheme.colorScheme.error, modifier = Modifier.padding(start = 48.dp, top = 2.dp)) }
}
}
}
@Composable @Composable
private fun ServerRow( private fun ServerRow(
server: BarkServer, isFirst: Boolean, isLast: Boolean, server: BarkServer, isFirst: Boolean, isLast: Boolean,
@@ -972,52 +399,6 @@ private fun ServerRow(
} }
} }
@Composable
private fun EmailServerRow(
server: EmailServer, isFirst: Boolean, isLast: Boolean,
onToggle: (Boolean) -> Unit, onEdit: () -> Unit, onDelete: () -> Unit,
onMoveUp: () -> Unit, onMoveDown: () -> Unit,
) {
val app = A2iApp.instance
var testing by remember { mutableStateOf(false) }
var testMsg by remember { mutableStateOf<String?>(null) }
Surface(
modifier = Modifier.fillMaxWidth(), shape = RoundedCornerShape(8.dp),
color = if (server.enabled) MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.40f) else MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.36f),
border = BorderStroke(1.dp, if (server.enabled) MaterialTheme.colorScheme.primary.copy(alpha = 0.30f) else MaterialTheme.colorScheme.outlineVariant),
) {
Column(Modifier.padding(start = 4.dp, end = 4.dp, top = 2.dp, bottom = 6.dp)) {
Row(verticalAlignment = Alignment.CenterVertically) {
Checkbox(checked = server.enabled, onCheckedChange = onToggle)
Row(Modifier.weight(1f), verticalAlignment = Alignment.CenterVertically) {
Text(server.name.ifBlank { server.to.ifBlank { "未命名" } }, style = MaterialTheme.typography.bodyLarge, fontWeight = FontWeight.Medium, maxLines = 1, overflow = TextOverflow.Ellipsis, modifier = Modifier.weight(1f, fill = false))
if (!server.enabled) { Spacer(Modifier.width(8.dp)); StatusBadge("停用", MaterialTheme.colorScheme.onSurfaceVariant) }
}
IconButton(onClick = { if (!testing) app.appScope.launch { testing = true; testMsg = testEmail(server); testing = false } }, enabled = !testing) {
if (testing) CircularProgressIndicator(Modifier.size(18.dp), strokeWidth = 2.dp) else Icon(Icons.AutoMirrored.Filled.Send, "测试")
}
IconButton(onClick = onEdit) { Icon(Icons.Filled.Edit, "编辑") }
IconButton(onClick = onDelete) { Icon(Icons.Filled.Delete, "删除") }
}
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.padding(top = 2.dp)) {
Column(Modifier.weight(1f).padding(start = 36.dp)) {
Text("${server.host}:${server.port}", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, maxLines = 1, overflow = TextOverflow.Ellipsis)
Text(server.to.ifBlank { "收件人未填" }, style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSurfaceVariant, maxLines = 1, overflow = TextOverflow.Ellipsis)
}
IconButton(onClick = onMoveUp, enabled = !isFirst) { Icon(Icons.Filled.KeyboardArrowUp, "上移") }
IconButton(onClick = onMoveDown, enabled = !isLast) { Icon(Icons.Filled.KeyboardArrowDown, "下移") }
}
testMsg?.let { Text(it, style = MaterialTheme.typography.bodySmall, color = if (it.startsWith("已发送")) MaterialTheme.colorScheme.tertiary else MaterialTheme.colorScheme.error, modifier = Modifier.padding(start = 48.dp, top = 2.dp)) }
}
}
}
private suspend fun testEmail(server: EmailServer): String {
if (server.host.isBlank() || server.to.isBlank()) return "SMTP 主机或收件人未填"
val r = EmailSender.send(server.host, server.port, server.user, server.password, server.from, server.to, "GotMsg 测试", "电邮配置测试")
return if (r.isSuccess) "已发送,请查看邮箱" else "发送失败:${r.exceptionOrNull()?.message}"
}
@Composable @Composable
private fun ServerDialog(initial: BarkServer? = null, onDismiss: () -> Unit, onConfirm: (String, String, String) -> Unit) { private fun ServerDialog(initial: BarkServer? = null, onDismiss: () -> Unit, onConfirm: (String, String, String) -> Unit) {
var name by remember { mutableStateOf(initial?.name ?: "") } var name by remember { mutableStateOf(initial?.name ?: "") }
@@ -1035,7 +416,7 @@ private fun ServerDialog(initial: BarkServer? = null, onDismiss: () -> Unit, onC
@Composable @Composable
private fun NtfyConfigDialog(initial: NtfyServer? = null, onDismiss: () -> Unit, onConfirm: (String, String, String, String) -> Unit) { private fun NtfyConfigDialog(initial: NtfyServer? = null, onDismiss: () -> Unit, onConfirm: (String, String, String, String) -> Unit) {
var name by remember { mutableStateOf(initial?.name ?: "") } var name by remember { mutableStateOf(initial?.name ?: "") }
var server by remember { mutableStateOf(initial?.server ?: NTFY_OFFICIAL_SERVER) } var server by remember { mutableStateOf(initial?.server ?: "https://ntfy.sh") }
var topic by remember { mutableStateOf(initial?.topic ?: "") } var topic by remember { mutableStateOf(initial?.topic ?: "") }
var token by remember { mutableStateOf(initial?.token ?: "") } var token by remember { mutableStateOf(initial?.token ?: "") }
ConfigDialogSkeleton(title = if (initial == null) "添加 ntfy" else "编辑 ntfy", onDismiss = onDismiss, onConfirm = { onConfirm(name.trim(), server.trim().removeSuffix("/"), topic.trim(), token.trim()) }) { ConfigDialogSkeleton(title = if (initial == null) "添加 ntfy" else "编辑 ntfy", onDismiss = onDismiss, onConfirm = { onConfirm(name.trim(), server.trim().removeSuffix("/"), topic.trim(), token.trim()) }) {
@@ -1049,20 +430,6 @@ private fun NtfyConfigDialog(initial: NtfyServer? = null, onDismiss: () -> Unit,
} }
} }
@Composable
private fun MeowConfigDialog(initial: MeowServer? = null, onDismiss: () -> Unit, onConfirm: (String, String, String) -> Unit) {
var name by remember { mutableStateOf(initial?.name ?: "") }
var server by remember { mutableStateOf(initial?.server ?: "") }
var key by remember { mutableStateOf(initial?.deviceKey ?: "") }
ConfigDialogSkeleton(title = if (initial == null) "添加 Meow" else "编辑 Meow", onDismiss = onDismiss, onConfirm = { onConfirm(name.trim(), server.trim().removeSuffix("/"), key.trim()) }) {
OutlinedTextField(name, { name = it }, label = { Text("名称") }, singleLine = true, modifier = Modifier.fillMaxWidth())
Spacer(Modifier.size(8.dp))
OutlinedTextField(server, { server = it }, label = { Text("服务器 / API 地址") }, singleLine = true, modifier = Modifier.fillMaxWidth(), supportingText = { Text("填写 Meow 提供的完整 API 地址或服务根地址") })
Spacer(Modifier.size(8.dp))
OutlinedTextField(key, { key = it }, label = { Text("Device Key / Token") }, singleLine = true, modifier = Modifier.fillMaxWidth())
}
}
@Composable @Composable
private fun EmailConfigDialog(initial: EmailServer? = null, onDismiss: () -> Unit, onConfirm: (EmailServer) -> Unit) { private fun EmailConfigDialog(initial: EmailServer? = null, onDismiss: () -> Unit, onConfirm: (EmailServer) -> Unit) {
var name by remember { mutableStateOf(initial?.name ?: "") } var name by remember { mutableStateOf(initial?.name ?: "") }
@@ -1115,20 +482,7 @@ private fun ConfigDialogSkeleton(title: String, onDismiss: () -> Unit, onConfirm
private suspend fun testPush(app: A2iApp, server: BarkServer): String { private suspend fun testPush(app: A2iApp, server: BarkServer): String {
if (server.deviceKey.isBlank()) return "未填写 device key" if (server.deviceKey.isBlank()) return "未填写 device key"
val msg = BarkMessage(deviceKey = server.deviceKey, title = "GotMsg 测试", body = "如果你在 iOS 看到这条推送,说明配置成功", level = "active") val msg = BarkMessage(deviceKey = server.deviceKey, title = "a2i 测试", body = "如果你在 iOS 看到这条推送,说明配置成功", level = "active")
val r = BarkClient(server).push(msg) val r = BarkClient(server).push(msg)
return if (r.isSuccess) "已发送,请查看 iOS 端" else "发送失败:${r.exceptionOrNull()?.message}" return if (r.isSuccess) "已发送,请查看 iOS 端" else "发送失败:${r.exceptionOrNull()?.message}"
} }
private suspend fun testNtfy(server: NtfyServer): String {
if (server.topic.isBlank()) return "未填写 topic"
val r = NtfySender.send(server.server, server.topic, server.token, "GotMsg 测试", "如果你在 ntfy 看到这条推送,说明配置成功")
return if (r.isSuccess) "已发送,请查看 ntfy 接收端" else "发送失败:${r.exceptionOrNull()?.message}"
}
private suspend fun testMeow(server: MeowServer): String {
if (server.server.isBlank()) return "未填写服务器地址"
if (server.deviceKey.isBlank()) return "未填写 Device Key"
val r = MeowSender.send(server.server, server.deviceKey, "GotMsg 测试", "如果你在鸿蒙 Meow 看到这条推送,说明配置成功")
return if (r.isSuccess) "已发送,请查看鸿蒙 Meow" else "发送失败:${r.exceptionOrNull()?.message}"
}
@@ -37,7 +37,6 @@ fun UpdateDialog(info: UpdateInfo, onDismiss: () -> Unit) {
val app = A2iApp.instance val app = A2iApp.instance
val downloading by app.updateChecker.downloading.collectAsState() val downloading by app.updateChecker.downloading.collectAsState()
val progress by app.updateChecker.downloadProgress.collectAsState() val progress by app.updateChecker.downloadProgress.collectAsState()
val downloadSource by app.updateChecker.downloadSource.collectAsState()
var downloadedFile by remember { mutableStateOf<File?>(null) } var downloadedFile by remember { mutableStateOf<File?>(null) }
AlertDialog( AlertDialog(
@@ -56,11 +55,7 @@ fun UpdateDialog(info: UpdateInfo, onDismiss: () -> Unit) {
.heightIn(max = 240.dp) .heightIn(max = 240.dp)
.verticalScroll(rememberScrollState()), .verticalScroll(rememberScrollState()),
) { ) {
val notes = info.releaseNotes val notes = info.releaseNotes.ifBlank { "(作者未提供更新说明)" }
.lineSequence()
.filterNot { it.contains("update-source") }
.joinToString("\n")
.ifBlank { "(作者未提供更新说明)" }
Text(notes, style = MaterialTheme.typography.bodyMedium) Text(notes, style = MaterialTheme.typography.bodyMedium)
} }
if (downloading) { if (downloading) {
@@ -71,7 +66,7 @@ fun UpdateDialog(info: UpdateInfo, onDismiss: () -> Unit) {
) )
Spacer(Modifier.size(4.dp)) Spacer(Modifier.size(4.dp))
Text( Text(
"正在下载更新…${downloadSource?.let { "(来自 $it" } ?: ""}", "$progress%",
style = MaterialTheme.typography.bodySmall, style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant, color = MaterialTheme.colorScheme.onSurfaceVariant,
) )
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>
Binary file not shown.

Before

Width:  |  Height:  |  Size: 521 KiB