Compare commits

..

62 Commits

Author SHA1 Message Date
song b14ee02ef8 fix: prefer Gitea download URL when versions equal (China-reachable)
Same-version update was picking GitHub URL (crowded first) which
downloads fail in China. Now Gitea info is inserted before GitHub
so maxByOrNull takes the Gitea one on version tie.

version 1.9.0 -> 1.9.1 (code 29 -> 30)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-10 14:35:59 +08:00
song 0ea5e1b228 docs: update tagline
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-10 14:22:21 +08:00
song de4cb2f40c docs: add overview screenshot to README
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-10 14:19:57 +08:00
song 5ccf5bda6e docs: rename a2i -> GotMsg in README
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-10 14:15:19 +08:00
song 2506622d06 rename: a2i -> GotMsg (user-facing name only, package unchanged)
- App display name: "GotMsg 有消息"
- HomeScreen brand: "GotMsg"
- Test push titles: "GotMsg 测试"
- Update notifications: "GotMsg 新版本"
- UpdateChecker User-Agent: "gotmsg-android"
- README title: "# GotMsg ·有消息"
- Package name com.a2i.forwarder kept for compatibility
- version 1.8.11 -> 1.9.0 (code 28 -> 29)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-10 14:13:53 +08:00
song d28776b0ec feat: Meow official logo (extracted from brand image)
- Meow channel mark now uses the official cat-face logo (transparent
  background, cropped from brand image)
- All five channels now have proper brand/themed icons
- version 1.8.10 -> 1.8.11 (code 27 -> 28)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-10 13:40:15 +08:00
song 191a04a68b feat: brand logos for Bark/ntfy channel marks + icons for email/SMS
- Bark: official logo from Finb/Bark repo (ic_bark_logo.png)
- ntfy: official logo from binwiederhier/ntfy repo (ic_ntfy_logo.png)
- Meow: Material Pets icon (paw) — no official logo found
- Email: Material Email icon
- SMS: Material Sms icon
- ChannelMark now supports both ImageVector and Painter (PNG logo)
- SectionCard gains optional titleIcon parameter
- version 1.8.9 -> 1.8.10 (code 26 -> 27)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-10 13:23:05 +08:00
song 22ac5a31ff feat: add Meow forwarding channel 2026-07-10 10:29:30 +08:00
song 336f49e4eb ui: orange info icons + cleanup old releases
- Bark/ntfy help (info) icons now bright orange (#FF9800)
- Deleted all releases below v1.8.0 from GitHub and Gitea
- version 1.8.7 -> 1.8.8 (code 24 -> 25)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-10 09:49:20 +08:00
song 8588da1f6b ui: update text + email two-line row
- Replace "转发到 iOS Bark" with "多通道转发" across HomeScreen/FilterScreen
- Email config items now use EmailServerRow (two-line layout matching
  Bark/ntfy): name + test/edit/delete on row 1, host:port + recipient
  on row 2 with up/down buttons
- version 1.8.6 -> 1.8.7 (code 23 -> 24)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-10 09:31:20 +08:00
song b32b759695 feat: floating update badge across all pages
Small pulsing circle icon (top-right) that persists across all tabs,
blinks slowly (alpha 0.35↔1.0, 1.2s cycle). Tapping opens UpdateDialog.

version 1.8.5 -> 1.8.6 (code 22 -> 23)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-10 09:20:11 +08:00
song af27febf1f feat: unify email/SMS settings with Bark style (no total toggle)
- Remove emailForwardingEnabled / smsFallbackEnabled total toggles
- Email/SMS SectionCards now use headerAction + CompactStopOnFirstSwitch
  (matching Bark and ntfy)
- Sender logic: email/sms participation controlled solely by per-item
  enabled checkboxes (empty list = channel inactive)
- version 1.8.4 -> 1.8.5 (code 21 -> 22)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-10 09:07:30 +08:00
song a48ef78844 chore: bump to 1.8.4
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-10 08:53:59 +08:00
song ff9673ed11 feat: sync ntfy settings with bark 2026-07-10 08:51:26 +08:00
song d666c7f5d4 chore: bump to 1.8.3
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-10 08:37:19 +08:00
song 2034257560 feat: add Bark setup help to settings 2026-07-10 08:35:00 +08:00
song 9a6c21b78d fix: scramble update-source (XOR+reverse+Base64) + hide in UI
- update-source in release notes now XOR-scrambled (not plain Base64)
- UpdateDialog filters out update-source comment line from notes
- Download progress shows "正在下载更新…" instead of percentage/source
- version 1.8.1 -> 1.8.2 (code 18 -> 19)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-10 08:01:27 +08:00
song e0fd115e55 fix: parse Base64-encoded update-source in release notes
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-09 21:53:36 +08:00
song 6ff3f306c4 fix: discover Gitea update source from release notes (no hardcode)
Gitea URL removed from source code. UpdateChecker now only hardcodes
GitHub API; the Gitea mirror URL is embedded in release notes as an
HTML comment (<!-- update-source: ... -->) and discovered at runtime.
Keeps self-hosted server address out of the public codebase.

version 1.8.0 -> 1.8.1 (code 17 -> 18)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-09 21:39:23 +08:00
song 39d3d735b0 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 9a6b933618 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 3718ac705a 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 7bd3dfc102 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 2470360bbe 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 7383fbe48d 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 b7d74f03b6 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 2744bcdb33 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 d93f0294b9 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 6b65dfbbe2 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 fa50e56c97 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 223d9103b6 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 74f5e45568 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 0a7a12fa2f 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 dcdfb7e9f9 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 4899716e4f 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 8bb3543ea6 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 134f0461e8 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 076643ea46 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 ae4392c04e 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 21e8440863 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 00ed6f8962 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 be26c9aac5 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 502c2bbc4f 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 6e67ed6707 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 48ae88e2e7 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 1d65d4ffc7 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 25d9287a77 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 787f4b8697 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 67fb9ad87c feat: 清空日志弹窗选择范围(仅日志 / 日志+计数器)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-08 10:33:50 +08:00
song 6c0f7ff081 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 04090d3929 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 f4fef401cd docs: README 标题改为 A2i ·安到果(按倒过)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-08 09:09:48 +08:00
song 8ca73c2e90 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 e1204df177 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 681c6a9f6e 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 417b7165c2 docs: a2i · 安到果
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-06 21:24:05 +08:00
SJZ 6f1780fee4 Update README.md 2026-07-06 20:41:16 +08:00
song a814e4469b docs: add screenshots gallery to README
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-06 20:35:41 +08:00
song dd42b54294 docs: add app screenshots for all 5 pages
- home: brand hero + status panel + metrics
- apps: app management with blacklist/whitelist
- filter: ad filter + code extraction + ongoing toggle
- settings: bark server management + icon export
- log: forwarding history with retry queue

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-06 20:30:06 +08:00
song 82419d0cfb Update CLAUDE.md: UI component layer, theme, release build, version and publish notes 2026-07-06 19:45:43 +08:00
song 6e8fb52d1a Add README 2026-07-06 19:35:55 +08:00
song 93c4ed9b5b Initial Android notification forwarder 2026-07-06 18:57:40 +08:00
80 changed files with 6025 additions and 856 deletions
+35
View File
@@ -0,0 +1,35 @@
# Gradle / Android build output
.gradle/
.kotlin/
build/
app/build/
# Local machine configuration
local.properties
.do-session
# Secrets — never commit
gh-token.txt
gitea-token.txt
*.token
# Android Studio / IntelliJ
.idea/
*.iml
# Generated artifacts
*.apk
*.aab
*.ap_
# Logs
*.log
# Local screenshots and throwaway files
shot*.png
shotApp.png
test.json
# Local assistant session helpers
.claude/
do.ps1
CLAUDE.md
+117 -755
View File
@@ -1,813 +1,175 @@
# GotMsg · 有消息
<p align="center">
<img src="screenshots/home-latest.png" alt="GotMsg 1.16.9 首页" width="360">
</p>
# GotMsg ·有消息
<p align="center">
<img src="https://img.shields.io/badge/Android-14%2B-3DDC84?logo=android&logoColor=white" alt="Android 14+">
<img src="https://img.shields.io/badge/Release-v1.16.17-2563EB" alt="Release v1.16.17">
<img src="https://img.shields.io/badge/Bark-支持-FF6B57" alt="Bark">
<img src="https://img.shields.io/badge/ntfy-支持-338574" alt="ntfy">
<img src="https://img.shields.io/badge/License-All%20Rights%20Reserved-E5484D" alt="License">
</p>
GotMsg 安装在一台 Android 手机上,读取这台手机收到的系统通知,再转发到 **iPhone、iPad、另一台 Android、鸿蒙手机、邮箱或指定用户的私聊机器人**。短信验证码、来电、微信、QQ、Telegram 和普通 App 通知都可以进入转发链路。
除了“看通知”,GotMsg 还支持从 Bark / ntfy 通知打开一次性网页,把回复送回原 Android 手机:优先使用系统快捷回复,QQ 等 App 可走无障碍兼容回复,微信可走 Shizuku。
🌐 **官方网站**:[gotmsg.pp.ua](https://gotmsg.pp.ua)
> [!IMPORTANT]
> GotMsg 不是聊天软件,也不会登录微信或 QQ。它处理的是 Android 通知;原 App 没有产生通知、通知内容被隐藏,或原手机被系统彻底休眠时,GotMsg 就拿不到对应内容。
## 功能一览
| 功能 | 状态 | 说明 |
|---|---:|---|
| 多通道转发 | 🟢 | Bark、ntfy、Meow、SMTP 电邮、Telegram 私聊、飞书企业应用私聊、钉钉企业机器人私聊,可同时启用多条配置 |
| 断网短信兜底 | 🟢 | 原手机断网时,用 SIM 卡短信转发验证码、来电等紧要通知 |
| 验证码提取 | 🟢 | 识别 4–8 位验证码;锁屏通知被星号遮挡时可用 Shizuku 恢复原文,否则安全过滤 |
| App 黑白名单 | 🟢 | 默认转发全部或只转发指定 App;规则加载失败时自动暂停转发 |
| 广告与噪音过滤 | 🟢 | 内置规则、自定义关键词、常驻通知过滤和 5 秒去重 |
| 来电与未接来电 | 🟢 | 兼容 MIUI / HyperOS;有权限时补充号码和联系人姓名 |
| 远程回复 | 🟢 | Android `RemoteInput`、无障碍兼容回复、微信 Shizuku 回复 |
| 锁屏 PIN 自动解锁 | 🟡 可选 | 仅 Shizuku 已运行时;PIN 用 Android Keystore 加密保存,每次最多尝试一次 |
| 自动更新 | 🟢 | 默认开启 Shizuku 静默更新;Gitea 优先、GitHub 备用,自动选择本机适配 APK |
| 权限健康检查 | 🟢 | 启动时按已启用功能检查必要权限,异常时从首页直达权限说明和系统设置 |
| 日志与失败重试 | 🟢 | 最近 300 条处理记录;发送失败进入待重发队列 |
| 配置备份与导入 | 🟢 | 导出全部转发配置为 JSON 文件;新装/重装时一键导入还原,导入前自动备份当前配置 |
| 测试版更新 | 🟡 可选 | 「测试版更新」开关开启后额外检查测试仓库,取版本号更高的更新 |
## 目录
把安卓手机上的通知(短信、验证码、电话“有电话进来这个通知,不是电话呼叫转移”、微信、QQ、Telegram 等)实时转发到 iOS / Android / 鸿蒙等接收端。iOS 可通过 [Bark](https://github.com/Finb/Bark) 推送到 iPhone / iPad;Android 可用 ntfy;鸿蒙可用 Meow。在 iOS 上点击通知,还能一键跳转到对应的 App。
- [先看懂两台手机的角色](#先看懂两台手机的角色)
- [先选择适合自己的接收方式](#先选择适合自己的接收方式)
- [需要安装哪些 App](#需要安装哪些-app)
- [十分钟上手](#十分钟上手)
- [推送通道详细配置](#推送通道详细配置)
- [远程回复详细教程](#远程回复详细教程)
- [权限和后台保活](#权限和后台保活)
- [过滤、来电、图标和更新](#过滤来电图标和更新)
- [常见问题排查](#常见问题排查)
- [界面截图](#界面截图)
- [赞赏支持](#赞赏支持)
- [隐私与安全](#隐私与安全)
适用于把安卓备机的消息同步到 iPhone 或其他安卓 / 鸿蒙手机,不漏验证码、不漏重要聊天。
---
![GotMsg 设置页](screenshots/overview.png)
## 先看懂两台手机的角色
## 主要功能
| 名称 | 是哪台设备 | 要做什么 |
|---|---|---|
| **原手机 / 发送端** | 收到短信、微信、QQ 等通知的 Android 手机 | 安装 GotMsg,授予通知读取权限,保持联网和后台运行 |
| **接收端** | 你随身使用的 iPhone、iPad、Android、鸿蒙手机或电脑 | 根据通道安装 Bark、ntfy、Meow、Telegram、飞书或钉钉,或直接收邮件 / 短信 |
- **系统级通知监听**:接入 Android 通知监听服务,覆盖所有发出通知的 App。
- **多通道推送**:支持 Bark、ntfy、Meow、电邮和短信兜底;每个通道可保存多个目标,勾选参与转发,并支持“发通一条就停”或全部广播。
- **验证码自动提取**:自动识别通知中的验证码,收到即复制,也可点击复制。
- **通知优化**:
- 广告关键词过滤(内置 + 自定义)
- 持续性通知过滤(音乐、下载、通话等 ongoing)
- 内容脱敏与去重
- 微信 / QQ / Telegram 专项优化(发送者作标题、按会话分组、重要消息提级、跳转 iOS 对应 App)
- **应用级规则**:黑名单 / 白名单两种模式,可按 App 单独控制是否转发。
- **失败自动重试**:发送失败的通知进入待重发队列,由 WorkManager 定期重试,也可手动触发。
- **本地日志**:保留最近 300 条处理记录,方便排查为什么某条通知被过滤或发送失败。
- **电话来电通知**:通过轮询 `getCallState()` 实时感知来电和未接来电(绕过 MIUI 对系统电话通知的限制,需授予"电话"权限)。
- **断网短信兜底**:无网络时自动把验证码、来电用短信发到 iPhone。
- 自动识别运营商(移动/联通/电信/广电),发免费查询短信拿套餐内短信余量
- 余额 ≤5 条自动停用,下次打开 App 提醒
- 每 5 分钟最多发 1 条,避免刷屏
- 自动解析失败时回退手动额度计数
- **应用内更新**:每天自动检查 GitHub 新版本,发现新版后一键下载并通过系统安装器升级,无需手动去网页下载。
举例:一台放在家里的小米 Android 手机插着银行卡手机号,你日常使用 iPhone。GotMsg 安装在小米手机,Bark 安装在 iPhone。
## 快速开始
> [!WARNING]
> 不要把 GotMsg 只安装在接收端。GotMsg 必须安装在“实际收到原始通知”的 Android 手机上。
### 1. 安装
### 文档里的按钮和设备怎么认
从 [Releases](../../releases) 下载最新 APK,安装到安卓手机(需 Android 14 / API 34 及以上)。
- 写成 **设置 → Bark → 添加**,表示先打开 GotMsg,点底部“设置”,再依次点“Bark”和“添加”。
- 写成“原手机”,始终指安装 GotMsg、实际收到短信或聊天通知的 Android 手机。
- 写成“接收端”,指最终查看转发通知的 iPhone、Android、鸿蒙手机、平板或电脑。
- 文档中的 Token、Key、Secret、授权码都属于密码。可以粘贴到 GotMsg 对应输入框,不要发到群里、截图分享或填进不明网页。
### 2. 配置 Bark
## 先选择适合自己的接收方式
1. 在 iOS 上安装 [Bark](https://apps.apple.com/us/app/bark-customed-push/id1613210986)。
2. 打开 Bark,复制你的专属推送地址,形如 `https://api.day.app/你的Key`。
3. 在 GotMsg 的「设置」中添加服务器:
- 名称:随意,例如「我的 iPhone」
- 服务器地址:`https://api.day.app`(自建则填自建地址)
- Device Key:Bark 地址中的那段 Key
第一次使用只配置一个通道,测试成功后再添加其它通道。不要一开始把所有通道都打开,否则失败时很难判断是哪一项填错。
如果接收端是 Android 或鸿蒙手机,也可以在「设置」里配置 ntfy 或 Meow;对应区块右上角有帮助按钮,按弹窗步骤填写服务器地址、Topic / Device Key 后点测试发送。
| 你的接收设备 / 账号 | 建议通道 | 难度 | 开始前需要准备 |
|---|---|---:|---|
| iPhone / iPad | **Bark** | 最简单 | 接收端安装 Bark |
| 另一台 Android | **ntfy** | 简单 | 接收端安装 ntfy,并创建一个随机 Topic |
| 鸿蒙手机 | **Meow** | 取决于服务方 | 已能正常使用的 Meow 客户端、接口地址和 Device Key |
| 普通邮箱 | **SMTP 电邮** | 中等 | 邮箱已开启 SMTP,并取得授权码 / 应用专用密码 |
| Telegram | **Telegram 私聊机器人** | 中等 | Telegram 账号、Bot Token 和私人 Chat ID |
| 飞书企业成员 | **飞书企业应用机器人** | 较难 | 企业自建应用权限;通常需要企业管理员或开发者权限 |
| 钉钉企业成员 | **钉钉企业内部机器人** | 较难 | 企业内部应用权限;通常需要企业管理员或开发者权限 |
| 原手机可能长期断网 | **短信兜底** | 中等 | 原手机插有可发送短信的 SIM 卡和短信套餐 |
### 3. 授权通知监听
> [!TIP]
> 只想尽快用起来:iPhone 选 Bark,Android 选 ntfy。飞书和钉钉不是“复制一个群机器人 Webhook”就能使用,它们需要企业内部应用;没有企业管理权限时应选其它通道。
打开 GotMsg,首页会提示「需要授权通知监听」。点击「前往系统授权」,在系统的通知访问设置中开启 GotMsg。
## 需要安装哪些 App
> 授权是一次性的,卸载后重新安装需要再次授权。
### 原 Android 手机上
### 4. 开启转发
| App | 是否必须 | 用途 | 安装来源 |
|---|---:|---|---|
| **GotMsg** | 必须 | 读取、过滤和转发通知 | [本仓库 Releases](../../releases) |
| **Shizuku** | 微信回复、PIN 自动解锁、自动静默更新需要;锁屏仍隐藏验证码时可选 | 以 ADB shell 权限操作微信、锁屏、静默安装新版,并在短信通知被系统脱敏时读取对应短信原文 | [Shizuku 官网](https://shizuku.rikka.app/download/) / [GitHub Releases](https://github.com/RikkaApps/Shizuku/releases) |
回到首页,打开「通知转发」开关。此时安卓收到的通知就会按规则推送到 iOS。
QQ、支付宝、淘宝和钉钉的兼容回复依赖 Android 系统自带的**无障碍服务**,不需要另外安装无障碍 App。
## 使用建议
### 接收端
- **只想收验证码**:进入「过滤」页,开启「验证码自动提取」,把「广告过滤」等按需开启;再到「应用」页切到白名单模式,只勾选短信类 App。
- **想同步聊天**:保持黑名单模式(默认),在「过滤」页开启「微信 / QQ / Telegram 优化」。
- **通知没推过来**:先看「日志」页,每条通知(无论转发、过滤还是失败)都会记录原因。
- **想让通知带图标**:参考下方「应用图标」一节。
- **电话通知不工作**:MIUI 会把系统电话通知限制为不向第三方通知监听器分发,本 App 通过轮询 `getCallonyState()` 绕过此限制。需要授予「电话」权限(设置 → 应用 → GotMsg → 权限 → 电话)。
- **断网收不到通知**:开启「设置 → 断网短信兜底」,填好 iPhone 号码、授予短信权限、设好手动额度。断网时验证码和来电会走短信(消耗套餐额度,5 分钟限 1 条)。
| 接收端 | 推荐方案 | 需要安装 |
|---|---|---|
| iPhone / iPad | **Bark** | [App Store](https://apps.apple.com/app/bark-custom-notifications/id1403753865) |
| Android | **ntfy** | [Google Play](https://play.google.com/store/apps/details?id=io.heckel.ntfy) / [F-Droid](https://f-droid.org/packages/io.heckel.ntfy/) / [GitHub](https://github.com/binwiederhier/ntfy/releases) |
| iPhone(也想用 ntfy) | ntfy | [App Store](https://apps.apple.com/us/app/ntfy/id1625396347) |
| Telegram 用户 | Telegram 私聊机器人 | Telegram 客户端;机器人必须先由该用户打开并发送 `/start` |
| 飞书企业成员 | 飞书企业自建应用机器人 | 飞书客户端;应用需对该成员可见并取得发消息权限 |
| 钉钉企业成员 | 钉钉企业内部应用机器人 | 钉钉客户端;应用需启用人与机器人单聊能力 |
| 鸿蒙 / HarmonyOS | **Meow** | 从你使用的 Meow 项目或服务提供方安装;需要能取得推送接口和 Device Key / Token |
| 电脑、平板 | 电邮 | 不需要专用 App,使用现有邮件客户端即可 |
| 任意手机 | 断网短信兜底 | 不需要额外 App,使用系统短信应用接收 |
## 应用图标
打开远程回复网页只需要普通浏览器,不需要 iOS 快捷指令,也不需要额外的 `_reply` topic。
Bark 的 `icon` 字段需要一个公网可访问的图片 URL。GotMsg 支持把已安装 App 的图标批量导出到你选择的目录,方便上传到你自己的图床。
---
规则:转发时 `icon = 图标 URL 前缀 + 安卓包名 + .png`。在「设置 → 应用图标」里:
## 十分钟上手
1. 填写图标 URL 前缀,例如 `https://你的图床域名/icons/`。
2. 选择一个目录,点「导出图标」,会把所有 App 图标以 `包名.png` 的形式导出。
3. 把导出的图片上传到你的图床对应目录。
### 第 1 步:下载并安装 GotMsg
留空前缀则不显示图标。
1. 在原 Android 手机上打开 [Releases](../../releases)。
2. 不知道手机架构时,下载文件名带 **`universal`** 的 APK,兼容性最好。
3. 大多数近年的 Android 手机是 ARM64,也可以下载 **`arm64-v8a`**,体积略小。
4. 浏览器提示“禁止安装未知应用”时,进入系统提示页,只允许当前浏览器或文件管理器完成这一次安装。
5. 安装完成后打开 GotMsg。
## 自建 Bark 服务(可选)
<details>
<summary><strong>手机提示“有风险”“不允许安装”怎么办</strong></summary>
GotMsg 默认用 Bark 官方服务器(`https://api.day.app`),免费够用。如果想完全自控、不限速、不依赖第三方,可以自建 Bark Server。下面是三种零成本方案:
1. 确认 APK 来自本仓库 Releases,不要安装聊天群里来源不明的改包。
2. 点系统提示中的“设置”,允许当前浏览器或文件管理器“安装未知应用”。
3. 返回上一页重新点 APK 安装。
4. 安装完成后可以把刚才的“允许安装未知应用”重新关闭。
5. 如果系统提示“与现有应用签名不一致”,不要卸载覆盖。先确认安装包来源和当前 GotMsg 是否为官方版本;卸载会清除全部配置。
### 方案 1:Vercel 云托管(最适合新手)
</details>
把 Bark 后端跑在 Vercel 的 Serverless 上,每次推送触发一个云函数,不挂进程、完全免费。
系统要求:**Android 14 / API 34 或更高版本**。低于 Android 14 无法安装。
- 平台:[Vercel](https://vercel.com) · 代码:[GitHub](https://github.com)
- 在 GitHub 搜索 `bark-server-vercel`,项目 README 里通常有 **Deploy with Vercel** 按钮,一键克隆部署
### 第 2 步:先配置一个接收通道
### 方案 2:Cloudflare Workers(最稳定)
第一次使用最推荐:
把轻量 JS 转发代码部署到 Cloudflare 全球边缘节点,免费额度每天 10 万次请求,个人用属于「溢出」状态。
- iPhone / iPad:选 **Bark**。
- Android:选 **ntfy**。
- 鸿蒙:选 **Meow**。
- 只想在电脑查看:选 **电邮**。
- 平台:[Cloudflare](https://www.cloudflare.com)
- 在 GitHub 搜索 `bark-server-cloudflare` 或 `bark cloudflare worker`,在 Cloudflare 后台新建 Worker 粘贴脚本保存即可
进入 GotMsg 底部 **设置**,找到相应通道,先按下方教程添加配置,再点击该配置右侧的发送按钮测试。接收端收到“GotMsg 测试”才算配置成功。
### 方案 3:免费 VPS + Docker(最高自主权)
配置页每一行右侧常用按钮含义:开关用于启停该目标;纸飞机 / 发送按钮用于测试;铅笔用于修改;垃圾桶用于删除;上下箭头用于调整顺序。“首个成功即止”只影响同一通道内的多条配置,第一次使用保持默认即可。
在永久免费的 Linux 虚拟机上用 Docker 跑官方 bark-server。
### 第 3 步:授予通知读取权限
- 官方服务端:[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/)
1. 回到 GotMsg **首页**。
2. 点击“需要授权通知监听”或“前往系统授权”。
3. 在系统“设备和应用通知 / 通知使用权 / 通知访问”页面找到 GotMsg。
4. 打开允许开关并确认风险提示。
5. 返回 GotMsg,首页应显示 **“通知监听已开启 / 已授权”**。
### ⚠️ 防坑提示
这项权限是核心权限;普通“允许 GotMsg 自己弹通知”不能代替通知读取权限。
国内网络下,Vercel 的 `*.vercel.app` 和 Cloudflare 的 `*.workers.dev` 二级域名可能间歇性无法访问。**终极方案**:注册一个便宜的个人域名(或找免费域名),解析托管到 Cloudflare,然后把 Vercel / Worker 绑到**自定义域名**上——既稳定又秒达。
如果首页仍显示待授权,点 **设置 → 权限需求说明**,下拉找到“通知使用权”,点击“去设置”再次确认。部分系统需要关闭后重新开启一次通知使用权,返回 GotMsg 后等待几秒刷新状态。
自建完成后,在「设置 → Bark 服务器」里填自建地址即可。
### 第 4 步:开启转发并实测
## 技术栈
1. 首页打开 **通知转发**。
2. 用另一台手机给原手机发一条短信或聊天消息。
3. 接收端应在几秒内收到对应通知。
4. 如果没有收到,先打开 GotMsg **首页 → 查看转发日志**,日志会说明是已发送、被过滤还是发送失败。
- Kotlin + Jetpack Compose(Material 3)
- AndroidX DataStore(持久化设置与日志)
- OkHttp(Bark / ntfy / Meow / 更新检查)
- WorkManager(失败重试)
- Navigation Compose(单 Activity 多页面)
- 最低 Android 14(API 34),目标 Android 16(API 36)
### 第一次配置完成的判定标准
## 本地构建
下面 5 项全部满足,才算基础配置完成:
需要 JDK 17 和 Android SDK。
- 接收通道的“GotMsg 测试”已收到。
- 首页显示通知监听已经授权。
- 首页“通知转发”开关已经打开。
- 用另一台设备制造真实短信或聊天通知后,接收端能收到转发。
- 锁屏原手机再测试一次,仍能收到转发。
```bash
# 设置环境变量(按你的安装路径)
export JAVA_HOME=/path/to/jdk-17
export ANDROID_HOME=/path/to/android-sdk
测试时不要一直打开原聊天窗口。很多 App 在聊天页前台时不会产生系统通知,GotMsg 没有通知可读,自然不会转发。
# 构建 debug APK
./gradlew assembleDebug
### 第 5 步:设置后台运行
# 构建可分发的 release APK
./gradlew assembleRelease
```
完成基础测试后,务必按[权限和后台保活](#权限和后台保活)设置自启动、后台无限制和省电白名单,否则锁屏一段时间后可能停止转发。
产物位置:
---
- debug:`app/build/outputs/apk/debug/app-debug.apk`
- release:`app/build/outputs/apk/release/app-release.apk`
## 推送通道详细配置
安装到已连接的设备:
各通道都支持添加多条配置、单独启停、排序和单条测试。“首个成功即止”适合相同通道的备用目标;关闭后会向该通道所有已启用配置广播。不同通道之间彼此独立并行发送,只要任一已配置通道成功,整条通知就记为发送成功;没有配置 Bark 不会产生 Bark 失败记录。
### Bark:发送到 iPhone / iPad
**适合谁:** 原手机是 Android,接收端是 iPhone 或 iPad。这是 iPhone 用户最容易配置的方案。
1. 在接收端安装 [Bark](https://apps.apple.com/app/bark-custom-notifications/id1403753865)。
2. 第一次打开 Bark 时点“允许通知”。如果不允许,推送虽然到达 Bark,iPhone 通知栏也不会提醒。
3. Bark 首页会显示类似 `https://api.day.app/xxxxxxxx` 的地址,点地址旁的复制按钮。
4. 回到原 Android 手机,打开 GotMsg → **设置 → Bark → 添加**。
5. 把刚才的地址拆成“服务器地址”和“Device Key”:
| 字段 | 填写内容 |
|---|---|
| 名称 | 自定义,例如“我的 iPhone” |
| 服务器地址 | 官方服务填 `https://api.day.app`,不要带最后的 Device Key |
| Device Key | Bark 地址最后 `/` 后面的字符串 |
例如 Bark 地址是 `https://api.day.app/AbCd1234`,服务器地址填 `https://api.day.app`,Device Key 填 `AbCd1234`。
6. 点“保存”,再点这条配置右侧的发送按钮。
7. iPhone 收到“GotMsg 测试”后,再回到 GotMsg 首页打开“通知转发”,制造一条真实通知测试。
远程回复启用后,点击 Bark 通知会进入 GotMsg 回复网页。为避免重复,回复地址不再同时显示在通知正文里。
<details>
<summary><strong>Bark 测试收不到时逐项检查</strong></summary>
1. 打开 iPhone → 设置 → 通知 → Bark,确认“允许通知”已开启。
2. 检查 GotMsg 中的服务器地址只有 `https://api.day.app`,末尾不要填 `/push`,也不要重复 Device Key。
3. 检查 Device Key 前后没有空格、中文引号或漏字符。
4. 在 Bark App 内点自己的推送地址做 Bark 自带测试。自带测试也收不到时,先解决 Bark 或网络问题。
5. 暂时关闭原手机和 iPhone 上的 VPN、代理、私人 DNS后再试。
6. 打开 GotMsg → 设置 → 转发日志,查看 Bark 返回的具体错误。
</details>
### ntfy:发送到 Android / iOS / 网页
**适合谁:** 接收端是另一台 Android;也支持 iPhone、iPad和网页浏览器。
1. 在接收端安装 ntfy:Android 可用 [Google Play](https://play.google.com/store/apps/details?id=io.heckel.ntfy)、[F-Droid](https://f-droid.org/packages/io.heckel.ntfy/) 或 [GitHub Releases](https://github.com/binwiederhier/ntfy/releases);iPhone 使用 [App Store](https://apps.apple.com/us/app/ntfy/id1625396347)。
2. 第一次打开 ntfy 时允许通知,并允许它在后台运行。
3. 点“订阅 / Subscribe”或 `+`,服务器使用官方地址 `https://ntfy.sh`。
4. 自己创建一个不容易猜到的 Topic,例如 `gotmsg_7f3a9c2e`。Topic 相当于接收频道名称,不是账号,也不是手机号码。
5. 确认 ntfy 的订阅列表已经显示这个 Topic。
6. 回到原手机,打开 GotMsg → **设置 → ntfy → 添加**。
7. 按下面填写并保存:
| 字段 | 填写内容 |
|---|---|
| 名称 | 自定义,例如“随身 Android” |
| 服务器 | 与接收端完全相同,官方服务填 `https://ntfy.sh` |
| Topic | 与接收端逐字相同,区分大小写 |
| Token | 使用官方公共 Topic 时留空;只有自建 ntfy 开启鉴权后才填写 |
8. 点该配置右侧的发送按钮,接收端收到“GotMsg 测试”即成功。
> [!CAUTION]
> `ntfy.sh` 的公共 Topic 类似“知道名字就能进入的房间”。不要使用手机号、姓名、邮箱或 `test` 这类容易猜到的 Topic。需要更强隐私时请自建 ntfy 并启用访问控制。
远程回复启用后,ntfy 通知会带“回复”动作,点击通知也会进入回复页;正文不再重复附加相同链接。
<details>
<summary><strong>ntfy 测试收不到时逐项检查</strong></summary>
1. 对照两台手机,服务器和 Topic 必须完全一致,不能一台填 `ntfy.sh`、另一台填自建服务器。
2. Topic 不要带空格、斜杠或中文标点。
3. 打开接收端 ntfy,确认订阅没有暂停,系统通知权限和后台权限已开启。
4. 使用公共 `ntfy.sh` 时不要随便填写 Token;错误 Token 会导致鉴权失败。
5. 可在接收端浏览器打开 `https://ntfy.sh/你的Topic` 检查网页订阅。公共 Topic 的内容可能被知道名称的人看到。
6. 打开 GotMsg 转发日志查看 HTTP 状态或网络错误。
</details>
### Telegram:机器人私聊
**适合谁:** 希望在自己的 Telegram 私聊中接收通知。GotMsg 不支持群组、频道或群机器人。
1. 在接收端 Telegram 搜索并打开官方账号 `@BotFather`,核对账号带有认证标记。
2. 发送 `/newbot`。
3. 按提示输入机器人显示名称,例如 `我的 GotMsg`。
4. 再输入机器人用户名。用户名必须以 `bot` 结尾,例如 `my_gotmsg_notice_bot`。
5. BotFather 会返回一串 Bot Token,样式类似 `123456789:AA...`。长按复制并妥善保存。
6. 点 BotFather 消息中的机器人链接,打开你刚创建的机器人,点击“开始 / Start”或发送 `/start`。这一步不能省略,因为机器人不能主动发起首次私聊。
7. 按下面的折叠教程取得自己的私人 Chat ID。
8. 原手机打开 GotMsg → **设置 → Telegram → 添加**,填写名称、Bot Token 和私人 Chat ID。
9. 保存并发送测试。自己的 Telegram 私聊收到“GotMsg 测试”即成功。
<details>
<summary><strong>只用手机取得 Telegram 私人 Chat ID</strong></summary>
1. 确认你已经向新机器人发送过 `/start`。
2. 在手机浏览器打开无痕 / 隐私标签页。
3. 在地址栏输入下面的地址,把 `你的BotToken` 整段替换为 BotFather 给出的 Token,不要保留中文或尖括号:
`https://api.telegram.org/bot你的BotToken/getUpdates`
4. 页面会显示一段 JSON 文本。搜索或仔细查找类似下面的内容:
`"chat":{"id":123456789,"first_name":"你的名字","type":"private"}`
5. `id` 后面的正整数就是私人 Chat ID。只复制数字,不要复制逗号或引号。
6. 如果页面显示 `"result":[]`,回到 Telegram 再给机器人发一句普通文字,然后刷新网页。
7. 取得 ID 后关闭无痕标签页。Bot Token 出现在地址中,不要截图或把完整地址发给别人。
</details>
GotMsg 会拒绝负数群组 / 频道 Chat ID,也不提供群组 Topic。Bot Token 等同机器人控制凭据,不要发给他人。[Telegram Bot API](https://core.telegram.org/bots/api)
<details>
<summary><strong>Telegram 测试失败时逐项检查</strong></summary>
1. Chat ID 必须是正整数;以 `-` 开头的是群组或频道,GotMsg 会主动拒绝。
2. 确认使用的是新机器人对应的 Token,前后没有空格。
3. 确认接收用户已经打开机器人并发送 `/start`,且没有屏蔽机器人。
4. 若 BotFather 重新生成过 Token,旧 Token 会立即失效,需要在 GotMsg 中编辑更新。
5. 原手机网络需要能访问 `api.telegram.org`;所在网络无法访问 Telegram 时无法发送。
</details>
### 飞书:企业应用机器人私聊
**先确认账号条件:** 这不是飞书群里的“自定义机器人 Webhook”。你需要在自己所属企业创建“企业自建应用”,通常需要企业管理员或开发者权限。普通个人账号没有企业应用权限时无法完成,应改用 Bark、ntfy、Telegram 或电邮。
<details>
<summary><strong>飞书企业自建应用完整配置流程</strong></summary>
1. 在手机或电脑浏览器打开[飞书开放平台](https://open.feishu.cn/),登录接收通知所用的飞书企业账号。手机页面显示不完整时,在浏览器菜单中打开“桌面版网站”。
2. 进入“开发者后台”,选择“创建企业自建应用”。不要选择商店应用,也不要创建群自定义机器人。
3. 填写应用名称、描述和图标,例如名称填 `GotMsg 通知`,创建应用。
4. 打开应用左侧的 **应用能力 / 添加应用能力**,找到“机器人”并添加。
5. 打开 **权限管理**,搜索并开通“以应用身份发消息”,权限标识通常显示为 `im:message:send_as_bot`。GotMsg 只需要机器人主动向指定成员发文本,不需要配置群 Webhook。
6. 打开 **应用发布 → 版本管理与发布**,创建版本,填写版本号和说明。
7. 在“可用范围”中加入要接收通知的飞书成员。目标成员不在可用范围内时,即使 App ID 和 Secret 正确也无法收到。
8. 提交发布。如果企业要求管理员审核,需要等待管理员通过后再测试。
9. 打开 **凭证与基础信息**,复制 App ID 和 App Secret。App Secret 只会填入 GotMsg,不要发送给其他人。
10. 准备接收者标识。小白最推荐使用目标成员绑定在同一飞书企业中的工作邮箱:在 GotMsg 中把“ID 类型”选为 `email`,接收者 ID 填完整邮箱地址。没有可用企业邮箱时,请让企业管理员提供该成员的 `open_id`、`user_id` 或 `union_id`,并在 GotMsg 中选择完全对应的类型。
11. 原手机打开 GotMsg → **设置 → 飞书机器人 → 添加**,填写:
| GotMsg 字段 | 填写内容 |
|---|---|
| 名称 | 自定义,例如“我的飞书” |
| App ID | 飞书“凭证与基础信息”中的 App ID |
| App Secret | 同页的 App Secret |
| ID 类型 | 推荐 `email`;否则与管理员提供的 ID 类型一致 |
| 接收者 ID | 目标成员邮箱或对应用户 ID,只填一个人 |
12. 保存并点发送测试。目标成员与机器人私聊中收到“GotMsg 测试”即成功。
</details>
这里只调用企业应用消息接口,不接受 `chat_id`,也不支持飞书群机器人 Webhook。[飞书消息 API](https://open.feishu.cn/document/server-docs/im-v1/introduction)
<details>
<summary><strong>飞书测试失败时逐项检查</strong></summary>
1. 确认创建的是“企业自建应用”,且已经添加机器人能力,不是群自定义机器人。
2. 确认“以应用身份发消息”权限已经生效;仅在后台勾选但未发布 / 未审核时仍可能无效。
3. 确认目标成员属于同一企业,并在应用“可用范围”内。
4. ID 类型必须与内容一致:选 `email` 就填完整企业邮箱,选 `open_id` 就填 `ou_...`,不能混用。
5. App Secret 重新生成后,GotMsg 中保存的旧 Secret 会失效,需要编辑更新。
6. 打开 GotMsg 转发日志查看飞书返回的错误。鉴权失败通常检查 App ID / Secret;用户不可见通常检查可用范围和接收者 ID。
</details>
### 钉钉:企业内部机器人私聊
**先确认账号条件:** 这不是钉钉群里的“自定义机器人”。你需要创建企业内部应用,并取得应用和成员信息,通常需要组织管理员或开发者权限。个人钉钉账号没有企业应用权限时无法完成。
<details>
<summary><strong>钉钉企业内部机器人完整配置流程</strong></summary>
1. 在手机或电脑浏览器打开[钉钉开放平台](https://open.dingtalk.com/),使用接收通知所在企业的管理员 / 开发者账号登录。手机页面不完整时启用浏览器“桌面版网站”。
2. 进入应用开发后台,选择创建“企业内部应用”。不要复制群聊中的自定义机器人 Webhook。
3. 填写应用名称、描述和图标,例如名称填 `GotMsg 通知`。
4. 在应用能力中添加“机器人”能力,启用“人与机器人单聊”场景。
5. 在权限管理中搜索并申请“企业内机器人发送消息”相关权限。
6. 在应用版本 / 发布管理中创建并发布一个版本,把接收通知的成员加入应用可见或可用范围。企业要求审核时需等待管理员通过。
7. 打开应用的凭证页面,复制 **Client ID / AppKey** 和 **Client Secret / AppSecret**。
8. 取得接收成员的 **User ID / 员工 UserID**。它不是手机号、昵称或钉钉号。最稳妥的方法是让企业管理员在组织通讯录 / 成员详情中查看并提供;后台名称变化时可搜索“员工 UserID”。
9. 原手机打开 GotMsg → **设置 → 钉钉机器人 → 添加**,填写:
| GotMsg 字段 | 填写内容 |
|---|---|
| 名称 | 自定义,例如“我的钉钉” |
| Client ID / AppKey | 企业内部应用凭证页中的 AppKey |
| Client Secret / AppSecret | 同一应用的 AppSecret |
| 接收者 User ID | 目标成员的员工 UserID,只填一个人 |
10. 保存并点发送测试。目标成员与企业内部机器人的单聊收到“GotMsg 测试”即成功。
</details>
这里只调用企业内部应用机器人的单聊接口 `/v1.0/robot/oToMessages/batchSend`,不支持钉钉群自定义机器人 Webhook。[钉钉开放平台](https://open.dingtalk.com/)
<details>
<summary><strong>钉钉测试失败时逐项检查</strong></summary>
1. 确认创建的是企业内部应用机器人,而不是群设置中复制出来的 Webhook 机器人。
2. 确认机器人能力、人与机器人单聊场景和发送消息权限都已开启并随版本发布生效。
3. 确认接收成员在应用可用范围内。
4. 接收者必须填员工 UserID,不能填手机号、昵称、企业名称或群号。
5. AppKey 与 AppSecret 必须来自同一个应用;重新生成 Secret 后需要同步修改 GotMsg。
6. 打开转发日志查看错误。鉴权失败先检查 AppKey / Secret,成员无效先检查 UserID 和应用范围。
</details>
### Meow:发送到鸿蒙手机
Meow 不是 GotMsg 内置组件,需要先在鸿蒙手机上安装并完成 Meow 自身的推送注册。不同 Meow 服务的分发方式和接口格式可能不同,请以你所使用的 Meow 项目说明为准。
1. 按 Meow 项目或服务提供方的说明,在鸿蒙手机安装并注册 Meow。
2. 先使用 Meow 自带测试确认鸿蒙端能够收到消息。自带测试未成功前不要配置 GotMsg。
3. 在 Meow 中找到“推送地址 / API 地址 / 服务器地址”和“Device Key / Token”。不同版本叫法可能不同。
4. 原手机打开 GotMsg → **设置 → Meow → 添加**。
5. 名称填“我的鸿蒙”等容易识别的文字;服务器填写服务方给出的完整接口地址;Device Key 填对应设备的 Key / Token。
6. 保存后点发送测试,鸿蒙端收到“GotMsg 测试”即成功。
不要在 Device Key 中填写鸿蒙锁屏密码、华为账号密码或短信验证码。
<details>
<summary><strong>Meow 测试失败时怎么判断</strong></summary>
- Meow 自带测试也失败:问题在 Meow 注册、鸿蒙通知权限或服务方,先按 Meow 文档解决。
- Meow 自带测试成功、GotMsg 失败:重点检查是否把“完整 API 地址”误填成官网首页,以及 Device Key 是否属于当前设备。
- 服务地址要求额外参数、签名或特殊 JSON 格式时,当前 GotMsg 通用 Meow 配置可能不兼容,需要向 Meow 服务方确认接口格式。
</details>
### SMTP 电邮
GotMsg 直接连接 SMTP 服务器发送纯文本邮件。当前只支持 **SMTPS / 隐式 TLS**,通常使用端口 `465`;只提供 `587` / STARTTLS 的邮箱目前不能直接使用。邮箱需要先开启 SMTP,并生成**授权码 / 应用专用密码**,不要直接填写网页登录密码。
| 字段 | 示例 |
|---|---|
| SMTP 主机 | `smtp.qq.com`、`smtp.163.com`、`smtp.gmail.com` |
| 端口 | 填 `465`(隐式 SSL / TLS) |
| 账号 | 完整邮箱地址 |
| 授权码 / 密码 | 邮箱后台生成的 SMTP 授权码 |
| 发件人 | 通常与账号一致 |
| 收件人 | 实际接收通知的邮箱 |
完整步骤:
1. 登录用于发件的邮箱网页版或官方 App,进入账号安全设置。
2. 开启 SMTP 服务。QQ / 163 通常会生成单独的 SMTP 授权码;Gmail 通常需要先开启两步验证,再创建应用专用密码。
3. 原手机打开 GotMsg → **设置 → 电邮**,先打开“启用电邮转发”。
4. 点“添加”,按上表填写。发件人通常必须与账号完全相同。
5. 保存后点发送测试,到收件箱和垃圾邮件中查找“GotMsg 测试”。
6. 测试成功后保留该配置的启用开关。
<details>
<summary><strong>常见邮箱填写示例</strong></summary>
| 邮箱 | SMTP 主机 | 端口 | 密码栏填写 |
|---|---|---:|---|
| QQ 邮箱 | `smtp.qq.com` | `465` | QQ 邮箱设置中生成的 SMTP 授权码 |
| 163 邮箱 | `smtp.163.com` | `465` | 163 邮箱设置中生成的客户端授权密码 |
| Gmail | `smtp.gmail.com` | `465` | Google 账号两步验证下生成的应用专用密码 |
Outlook / Microsoft 365 常用 `587` + STARTTLS,当前 GotMsg 的 SMTP 发送器不支持这一连接方式,不要直接照搬 `587` 配置。
</details>
<details>
<summary><strong>邮件测试失败时逐项检查</strong></summary>
1. 确认使用授权码 / 应用专用密码,不是网页登录密码。
2. 确认端口是 `465`,服务器支持隐式 TLS。
3. 确认账号和发件人都是完整邮箱地址,且通常应保持一致。
4. 查看垃圾邮件;部分邮箱会把第一次自动通知归类为垃圾邮件。
5. “连接超时”通常是主机、端口、网络或运营商拦截;“AUTH”通常是账号或授权码错误;“FROM / TO”通常是发件人或收件人格式错误。
6. 企业邮箱还可能限制第三方 SMTP 登录,需要联系邮箱管理员。
</details>
### 断网短信兜底
该功能在原 Android 手机**没有网络**时,用原手机 SIM 卡发送短信,因此会消耗短信套餐。
1. GotMsg → **设置 → 断网短信兜底**,授予“发送短信”权限。
2. 添加接收手机号并启用。
3. 默认“仅紧要通知”只发送验证码和来电;关闭后会尝试发送全部通知。
4. 建议保持限频,例如每 5 分钟最多 1 条。
5. 开启余额检查后,余额低于或等于 5 时自动挂起;也可以使用手动短信额度。
> [!WARNING]
> 短信兜底不是普通在线转发通道。手机有网络时,通知仍走已启用的网络推送、私聊机器人或邮件通道。
---
## 远程回复详细教程
远程回复入口会自动附加到支持回复的 Bark / ntfy 转发通知中,**没有单独的“开启远程回复”总开关**。Telegram、飞书、钉钉、Meow、电邮和短信只接收转发内容,不会生成 GotMsg 回复入口。
第一次测试远程回复,建议先用 Telegram、WhatsApp、Signal 或其它通知本身带“回复”按钮的 App:
1. 确认 Bark 或 ntfy 基础转发已经成功。
2. 让另一位用户给原手机发一条聊天消息,保持原聊天 App 在后台,让系统产生通知。
3. 在接收端点 Bark 通知,或点 ntfy 通知中的“回复”。
4. 浏览器打开回复页后输入一句无关紧要的测试文字并提交。
5. 查看原手机聊天是否发出,并等待网页显示成功或明确错误。
原手机必须联网,GotMsg 必须仍有通知使用权并能在后台运行。划掉原手机上的原通知、强制停止 GotMsg、超过有效期或原 App 撤销回复入口后都可能无法执行。
### 回复链路
1. 原手机收到通知并转发到 Bark / ntfy。
2. 接收端点击通知或“回复”动作,打开 `https://r.gotmsg.pp.ua`。
3. 页面显示原通知的应用、标题和正文,输入回复内容并提交。
4. 原手机上的 GotMsg 拉取任务并执行回复。
5. 页面显示成功或具体失败原因。
链接从通知转发起 **10 分钟内有效**,并且只允许成功提交一次。Bark / ntfy 转发通知通常会生成回复入口;**短信 App 中识别出的验证码通知不添加回复链接**,因为验证码只需要读取或复制,不需要回复。如果其它通知既没有系统快捷回复,也不属于兼容白名单,页面会明确返回不支持,而不会盲目操作原 App。
### 三种回复方式
| 方式 | 典型 App | 原手机额外要求 | 是否打开聊天页 |
|---|---|---|---:|
| Android `RemoteInput` | Telegram、WhatsApp、Signal、部分短信和企业 IM;以该条通知实际提供的动作为准 | 无 | 否 |
| 无障碍兼容回复 | QQ、支付宝、淘宝、钉钉 | 开启 GotMsg 兼容回复无障碍服务 | 是 |
| Shizuku 回复 | 微信 | 安装并启动 Shizuku,授权 GotMsg | 是 |
同一个 App 并非所有通知都支持快捷回复:聊天通知可能支持,登录、营销、付款、订单和系统通知通常不支持。
### QQ、支付宝、淘宝、钉钉:开启无障碍兼容回复
只有通知本身没有 Android 快捷回复时才会使用这条兼容路径。它会实际打开原手机上的聊天页,因此原手机可能亮屏或切换前台应用。
1. GotMsg → **设置 → 通知回复与解锁**。
2. 打开 **无障碍兼容回复**。
3. 系统跳到无障碍页面后,找到 **GotMsg 兼容回复**。
4. 阅读系统提示并启用服务。
5. 返回 GotMsg,确认开关仍开启。
执行时 GotMsg 会打开原通知对应的聊天页,拒绝覆盖已有草稿,只点击文字明确匹配“发送”的控件。回复结束后:有 Shizuku 时切回 GotMsg;无 Shizuku 时用无障碍返回桌面,避免 QQ 等聊天页停留前台后不再弹新通知。
### 锁屏验证码显示为 `******`:优先调整系统设置
如果原手机锁屏时,短信通知只显示 `******`,可以先让系统在锁屏通知中显示完整内容。设置成功后,GotMsg 能直接从通知读取并转发真实验证码,**仅为了验证码时不需要安装 Shizuku**。
不同品牌的菜单名称会有差异,通常按下面顺序检查:
1. 系统设置 → **通知与状态栏 / 通知中心** → **锁屏通知**。
2. 选择 **显示通知和内容 / 显示所有通知内容**,关闭“隐藏敏感内容”。
3. 系统设置 → 应用 → 应用管理 → 你正在使用的**短信 App** → 通知管理。
4. 打开短信 App 的锁屏通知,并确认其锁屏显示方式也是“显示内容”。
5. 锁定手机后重新接收一条验证码短信测试;必须确认锁屏通知正文已经显示真实数字,而不是 `******`。
> [!WARNING]
> 开启后,拿到或看到原手机的人也可能直接在锁屏界面看到短信和验证码。原手机经常放在公共场所、包含银行验证码或对隐私要求较高时,建议继续隐藏锁屏内容,并使用 Shizuku 恢复;不要为了方便降低不符合自己使用场景的隐私保护。
### 微信:安装和启动 Shizuku
微信新版会屏蔽普通无障碍输入,因此微信回复必须使用 Shizuku。
<details>
<summary><strong>第一次安装、配对和授权 Shizuku</strong></summary>
1. 从 [Shizuku 官网](https://shizuku.rikka.app/download/) 或 [GitHub Releases](https://github.com/RikkaApps/Shizuku/releases) 安装 Shizuku。
2. 打开系统设置 → 关于手机,连续点击“版本号 / 系统版本”约 7 次,直到提示已经进入开发者模式。
3. 返回系统设置,搜索并打开“开发者选项”,再打开“无线调试”。原手机需要连接 Wi-Fi;这里不要求连接电脑。
4. 打开 Shizuku,选择“通过无线调试启动”→“配对”。
5. 按 Shizuku 页面提示打开系统“使用配对码配对设备”,把系统显示的 6 位配对码输入 Shizuku 通知或配对框。
6. 配对成功后回到 Shizuku,点击“启动”。首页应显示 Shizuku 正在运行。
3. 打开 GotMsg 首页,Shizuku 状态卡应显示 **“Shizuku 已就绪”**。
4. 如果显示“未授权”,点击 **授权 Shizuku**,并在 Shizuku 弹窗中允许 GotMsg。
5. GotMsg → **设置 → 通知回复与解锁**,打开“无障碍兼容回复”总开关。微信实际执行走 Shizuku;仅为了微信时可以不启用系统无障碍服务。
</details>
非 Root 方式启动的 Shizuku 在手机重启后通常需要重新启动。GotMsg 首页会直接显示当前状态。
Shizuku 也用于恢复锁屏时被系统短信 App 显示成 `******` 的验证码。GotMsg 只在检测到脱敏验证码通知时,按通知时间查找收件箱中对应的最近短信;确认能提取出真实 4–8 位验证码才转发。Shizuku 未就绪或无法确认对应短信时,该通知会直接过滤,不会把星号内容发到接收端。
### 可选:Shizuku PIN 自动解锁
适合原手机平时锁屏放置、希望收到远程回复后自动亮屏执行的场景。微信(Shizuku 输入)和 QQ(无障碍兼容回复)锁屏时收到回复,都会先自动亮屏、输入 PIN 解锁后再回复;自 v1.16.17 起 QQ 锁屏也支持自动解锁。
<details>
<summary><strong>配置锁屏 PIN 自动解锁</strong></summary>
1. 确认 Shizuku 已就绪。
2. GotMsg → **设置 → 通知回复与解锁**。
3. 输入原手机 4–16 位数字锁屏 PIN,点击“保存并启用”。
4. PIN 使用 Android Keystore AES-GCM 加密,仅保存在原手机,并排除云备份和设备迁移。
5. 每个回复任务最多提交一次 PIN;失败不会自动重试,避免触发系统锁定策略。
限制:手机重启后的第一次解锁仍必须手动完成;Shizuku 未运行时不能自动输入 PIN;图案、字母密码和生物识别不能代替这里的数字 PIN。
</details>
<details>
<summary><strong>回复页常见提示是什么意思</strong></summary>
| 页面提示 | 原因和处理 |
|---|---|
| 回复会话已过期 | 超过 10 分钟,等待一条新通知重新打开回复页 |
| 原通知已消失 | 原手机通知被划掉或 App 更新了通知,等待新消息 |
| 原手机需解锁 | 原通知要求身份验证;手动解锁,或配置 Shizuku PIN |
| 没有可用的快捷回复 | 原通知不带回复动作;支持的 App 可再启用兼容回复 |
| 无障碍兼容回复未启用 | 到“设置 → 通知回复与解锁”打开开关并授权系统无障碍 |
| Shizuku 未就绪 / 未授权 | 打开 Shizuku 启动服务,再回 GotMsg 授权 |
| 输入框已有草稿 | GotMsg 为避免覆盖文字主动停止,先在原手机处理草稿 |
</details>
完整技术说明和错误含义见 [通知远程回复文档](docs/notification-reply.md)。
---
## 权限和后台保活
### 权限用途
| 权限 / 系统服务 | 是否必须 | 用途 |
|---|---:|---|
| 通知使用权 / 通知访问 | **必须** | 读取原手机上的系统通知 |
| 网络 | **必须** | 发送到各在线通道、检查更新和远程回复 |
| GotMsg 自身通知 | 建议 | 显示状态和更新提醒;不能代替通知使用权 |
| 电话 | 使用来电功能时 | 读取电话状态 |
| 通话记录 | 使用未接来电时 | 通话结束后查找未接号码 |
| 联系人 | 希望显示联系人姓名时 | 用号码反查本地联系人 |
| 发送短信 | 使用断网兜底时 | 从原手机 SIM 卡发送短信 |
| 无障碍 | 使用 QQ 等兼容回复时 | 找输入框、写入回复、点击发送和返回桌面 |
| Shizuku | 使用微信回复、PIN 解锁、锁屏验证码恢复或自动静默更新时 | 以 ADB shell 权限执行输入、剪贴板、锁屏、静默安装,以及按通知时间读取对应短信原文 |
| 安装未知应用 | App 内更新时 | 下载完成后调起系统安装器 |
### 防止锁屏后停止转发
不同品牌名称不同,目标都是让 GotMsg 可以长期在后台运行:
1. 系统设置 → 应用 → GotMsg → 电池 / 耗电管理 → 选择**无限制 / 不优化**。
2. 在系统“自启动 / 后台启动 / 关联启动”里允许 GotMsg。
3. 最近任务界面如果支持“锁定应用”,锁定 GotMsg。
4. 不要使用一键清理、深度省电或第三方管家强制结束 GotMsg。
5. 修改权限或系统升级后,回到首页确认“通知监听已开启”。
小米 / HyperOS 常见路径:设置 → 应用设置 → 应用管理 → GotMsg → 省电策略 → 无限制,并允许自启动。三星常见路径:设置 → 电池 → 后台使用限制 → 从深度休眠应用中移除。
---
## 过滤、来电、图标和更新
### 应用黑白名单
- **黑名单模式**:默认转发所有 App,只排除你不想转发的 App;适合大多数人。
- **白名单模式**:默认不转发,只允许勾选的 App;适合只收短信验证码或少数聊天 App。
- GotMsg 启动时会先加载已保存规则;加载完成前或读取失败时按应用转发保持暂停,避免白名单配置短暂失效。
- VPN、代理和设备互联等高频通知来源默认位于黑名单中,需要时可以在应用页手动开启,不再使用无法覆盖的包名强制过滤。
### 过滤与验证码
“过滤”页可控制持续通知、验证码提取、广告过滤和特殊 App 优化。内置规则会过滤 VPN / 代理常驻通知、系统“正在运行”、近空内容和泛化“你有一条新消息”等噪音;用户可继续添加自定义广告关键词。
每条通知在 5 秒窗口内去重。微信、QQ、Telegram 会进行专门解析;验证码可自动复制,重要通知会提高推送级别。
### 来电和未接来电
MIUI / HyperOS 经常不把系统电话通知交给第三方监听器。GotMsg 会额外轮询电话状态:
- 来电时推送“有电话进来”;无障碍若能读取来电界面,可补充号码。
- 通话结束后读取通话记录,识别未接号码;有联系人权限时显示姓名。
### 应用图标
在 **设置 → 应用图标** 填写公网图标 URL 前缀,并把已安装 App 图标导出到选定目录。上传后,GotMsg 按 `URL 前缀 + 包名 + .png` 生成通知图标地址。留空则不附加自定义图标。
### 应用内更新
- **设置 → 更新 → 自动静默更新**默认开启。开启时每 3 小时检查一次;发现新版后自动选择本机 ABI,下载并通过已授权的 Shizuku 静默安装,安装完成后重新启动 GotMsg。
- 关闭“自动静默更新”后,每 6 小时检查一次;只显示更新提示,由用户在原有更新弹窗中选择安装包并手动安装。
- 两种模式都在 App 启动约 5 秒后检查,并使用 WorkManager 做后台周期兜底。Android 的省电和后台调度策略可能让实际执行时间略晚于 3 / 6 小时。
- 同时查询 Gitea 和 GitHub;同版本优先 Gitea,取两边可用的最高版本。
- 静默更新只在 Shizuku 正在运行且 GotMsg 已获授权时执行;Shizuku 不可用会安全退回普通更新提示,不会误报安装成功。两个发布源版本一致时才跨源下载兜底,APK 最终仍由 Android 校验应用签名。
- 升级弹窗只显示当前目标版本的发版说明,同时列出可用 ABI 安装包;系统会把本机适配版本排在第一位、标注“本机推荐”并默认选中,也可手动改选通用版或其它架构。
- App 自动选择当前手机 ABI,对应包不存在时使用 universal。
- 手动安装前 Android 会要求允许 GotMsg“安装未知应用”;Shizuku 静默安装不弹系统安装确认框。
- **测试版更新**:设置 → 软件更新 → 打开「测试版更新」开关后,检查更新时除正式仓库外还会查询 `gotmsg-release-test` 测试仓库,比较版本号后自动取更高版本。测试版与正式版使用相同签名,可直接覆盖升级;适合想提前试用新功能的用户。保持关闭则只检查正式版。
### GotMsg 官方公告
- GotMsg 每次回到前台时和后台定期访问项目自有的 `https://stats.gotmsg.pp.ua/v1/announcements/feed`,读取仍在有效期内且匹配当前 App 版本的官方公告。
- 公告会在原手机显示本地系统通知;打开 GotMsg 的全局转发开关后,还会直接转发到所有已启用网络通道。不经过通知监听器回收,因此不会形成自身通知循环,也不会生成回复链接。
- 本地通知和四类外部渠道分别记录发送状态:同一公告在同一渠道成功发送后不会因定时检查而重复推送;某一渠道发送失败只重试该渠道,不会重复发送已经成功的其它渠道。
- 首次使用需要允许 GotMsg 发送通知。未授权时不会把公告误记为已读,授权后会立即重新检查。
- 公告撤回只能阻止尚未获取的手机继续接收,无法远程删除已经显示在手机通知栏中的内容。
---
## 常见问题排查
| 现象 / 报错 | 处理方法 |
|---|---|
| 完全没有转发 | 首页确认“通知监听已开启”和“通知转发”已打开;再检查至少一个通道配置已启用 |
| 测试推送成功,锁屏后不再转发 | 设置电池无限制、自启动和最近任务锁定;检查系统是否撤销通知使用权 |
| 日志显示“已过滤” | 点开该条日志查看原因;检查黑白名单、广告关键词、持续通知和特殊 App 规则 |
| Bark 收不到 | 检查服务器地址不要带 Device Key;Device Key 不要漏字符;暂时关闭 VPN / 代理测试 |
| ntfy 收不到 | 接收端与 GotMsg 的服务器和 Topic 必须完全一致;确认 ntfy App 允许通知和后台运行 |
| Meow 收不到 | 先确认 Meow 自身能收测试;检查完整接口地址和 Device Key / Token |
| 邮件发送失败 | 使用 SMTP 授权码而不是网页登录密码;检查 465/SSL、发件人和网络限制 |
| 锁屏后收到的验证码变成 `******` | 优先把系统和短信 App 的锁屏通知改为“显示内容”;如果希望继续隐藏锁屏内容,再启动并授权 Shizuku。Shizuku 未安装、未运行或未授权时,GotMsg 不会转发星号正文,而会向所有已启用网络通道发送“验证码无法读取”提醒,请回到安装 GotMsg 的原手机查看 |
| 回复链接已过期 | 链接有效期 10 分钟;等待一条新通知再回复 |
| 原通知已消失 | 不要划掉原手机通知;等待新通知重新生成会话 |
| 微信提示 Shizuku 未就绪 | 打开 Shizuku 重新启动服务并授权 GotMsg;重启手机后通常需要重新启动 Shizuku |
| 微信未进入聊天页 | 确认通知点击本身能打开对应聊天,而不是联系人页、登录页或广告页 |
| QQ 找不到输入框 / 发送按钮 | 确认系统无障碍服务已开启;QQ 界面升级后可能需要重新适配,请保留 `A2iReply` 日志 |
| 原手机设置了 PIN | 配置 Shizuku PIN 自动解锁,或先手动解锁再回复;首次开机解锁不能自动完成 |
| 输入框已有草稿 | GotMsg 为避免覆盖原内容会主动停止;先在原手机处理草稿 |
| 下载后不能安装更新 | 系统设置 → 特殊应用权限 → 安装未知应用 → 允许 GotMsg |
日志页保存最近 300 条处理记录。发送失败会进入“待重发”,联网后由系统后台任务继续尝试。
---
```bash
adb install -r app/build/outputs/apk/release/app-release.apk
```
## 界面截图
原 README 首图 `overview.png` 已移动到本节,作为多通道设置界面展示。
| 多通道设置 | 应用管理 | 过滤设置 |
| 首页 | 应用管理 | 过滤设置 |
|---|---|---|
| ![多通道设置](screenshots/overview.png) | ![应用管理](screenshots/apps.png) | ![过滤设置](screenshots/filter.png) |
| ![首页](screenshots/home.png) | ![应用管理](screenshots/apps.png) | ![过滤设置](screenshots/filter.png) |
| 设置总览 | 应用与支持 | 权限说明 |
|---|---|---|
| ![设置总览](screenshots/settings.png) | ![应用与支持](screenshots/settings-support.png) | ![权限说明](screenshots/permissions.png) |
| 设置 | 转发日志 |
|---|---|
| ![设置](screenshots/settings.png) | ![转发日志](screenshots/log.png) |
| 转发日志 |
|---|
| ![转发日志](screenshots/log.png) |
## 工作原理
---
## QQ 交流群
配置、转发或远程回复遇到问题,可扫码加入 QQ 群。反馈回复问题时,建议同时提供 GotMsg 版本、手机型号、Android 版本、目标 App 版本和日志中的完整报错。
<p align="center">
<img src="screenshots/qq-group.png" alt="GotMsg QQ 群二维码" width="360">
</p>
---
## 赞赏支持
如果 GotMsg 帮你节省了盯通知、找验证码或跨设备回复的时间,欢迎通过微信赞赏支持持续维护。
赞赏完全自愿,不影响任何功能使用。
<p align="center">
<img src="appreciate.jpg" alt="GotMsg 微信赞赏码" width="320">
</p>
---
## 隐私与安全
### 通知内容会经过哪里
- Bark、ntfy、Meow、Telegram、飞书、钉钉、SMTP 或短信服务需要接收你选择转发的通知内容;使用第三方服务前请阅读对应服务的隐私政策。
- 开启远程回复后,GotMsg 会把应用名、通知标题和正文注册到 `https://r.gotmsg.pp.ua`,用于回复页预览;回复正文也会经过该中继。
- 回复链接包含一次性能力令牌。令牌放在 URL fragment 中,首次网页请求不会把它写入服务器访问日志;不要把有效回复链接转发给别人。
- 中继保存设备鉴权哈希、通知预览、会话状态和短期回复任务;等待中的会话过期后清理,已处理状态和回复最多保留约 24 小时用于结果查询与排错。
### 技术统计
当前版本会向项目自有的 `https://stats.gotmsg.pp.ua/v1/events` 发送安装、启动和各通道成功 / 失败等技术统计,包括随机安装 ID、Android ID、会话 ID、App 版本、Android 版本、厂商型号、语言地区、时区、网络类型、通道、状态和精简失败原因。统计事件不包含通知标题、通知正文、回复正文、Bark Key、ntfy Token、邮箱授权码或锁屏 PIN。
GotMsg 会从 `https://stats.gotmsg.pp.ua/v1/announcements/feed` 获取官方公告。请求只携带固定 App ID 和当前版本号,不上传通知内容、推送密钥或手机联系人等个人数据。
### 本地敏感数据
- Bark Key、ntfy Token、SMTP 配置等保存在 Android 应用私有存储中,请保护原手机锁屏和系统账号。
- Shizuku 自动解锁 PIN 使用 Android Keystore AES-GCM 加密,且排除云备份与设备迁移;关闭功能后可在设置中删除 PIN。
- 无障碍和 Shizuku 权限能力较高,只应在你信任、由你控制的原 Android 手机上启用。
- 短信通知中的验证码被锁屏策略替换为星号时,GotMsg 会先尝试短信 App 提供的受控验证码字段;仍无法恢复时,仅在 Shizuku 已授权后按通知时间读取收件箱附近的短信,并只接受可确认的验证码原文。Shizuku 未安装、未运行或未授权时,GotMsg 会向所有已启用网络通道发送一条不含验证码及星号正文的安全提醒;Shizuku 已就绪但仍无法确认原文时直接过滤。不会另建短信数据库副本。
---
<details>
<summary><strong>高级用户:自建 Bark / ntfy</strong></summary>
### Bark Server
```bash
docker run -d --name bark --restart=unless-stopped \
-p 8080:8080 \
-v /var/lib/bark:/data \
finab/bark-server
```
Android 系统通知
└─ NotifyListenerService 接收
└─ NotificationProcessor 处理流水线
├─ 全局开关 / ongoing 过滤 / 黑白名单
├─ 空内容、脱敏、噪音、系统状态过滤
├─ 广告关键词过滤
├─ 去重(5 秒窗口)
├─ 验证码提取
└─ 微信 / QQ / Telegram 专项解析
└─ BarkClient 推送到 Bark Server
└─ 失败 → 待重发队列 → RetryWorker 定期重试
└─ 写入本地日志(最近 300 条)
```
用 Nginx / Caddy 配置 HTTPS 后,GotMsg 的服务器地址填写站点根地址,不要追加 `/push`。详情见 [Bark 文档](https://bark.day.app/#/) 和 [Bark GitHub](https://github.com/Finb/Bark)。
## License
### ntfy Server
```bash
docker run -d --name ntfy --restart=unless-stopped \
-p 80:80 \
-v /var/lib/ntfy:/var/lib/ntfy \
binwiederhier/ntfy serve
```
生产使用建议配置 HTTPS、缓存文件和访问控制。详情见 [ntfy 安装文档](https://docs.ntfy.sh/install/)。
</details>
## 发布与许可
- 官方网站:[gotmsg.pp.ua](https://gotmsg.pp.ua)
- 最新安装包:[Releases](../../releases)
- Android 最低版本:Android 14 / API 34
- 自 v1.10.2 起项目闭源,公开仓库保留用户文档、截图和 Release 安装包。
- 本项目仅供个人使用,**All Rights Reserved / 保留所有权利**。
本项目仅供个人使用。
+75
View File
@@ -0,0 +1,75 @@
plugins {
alias(libs.plugins.android.application)
alias(libs.plugins.kotlin.android)
alias(libs.plugins.kotlin.compose)
alias(libs.plugins.kotlin.serialization)
}
android {
namespace = "com.a2i.forwarder"
compileSdk = 36
defaultConfig {
applicationId = "com.a2i.forwarder"
minSdk = 34
targetSdk = 36
versionCode = 29
versionName = "1.9.0"
}
signingConfigs {
getByName("debug")
}
buildTypes {
debug {
isMinifyEnabled = false
}
release {
signingConfig = signingConfigs.getByName("debug")
isMinifyEnabled = false
proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro"
)
}
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
kotlinOptions {
jvmTarget = "17"
}
buildFeatures {
compose = true
}
}
dependencies {
implementation(libs.androidx.core.ktx)
implementation(libs.androidx.lifecycle.runtime.ktx)
implementation(libs.androidx.lifecycle.viewmodel.compose)
implementation(libs.androidx.lifecycle.runtime.compose)
implementation(libs.androidx.activity.compose)
implementation(platform(libs.androidx.compose.bom))
implementation(libs.androidx.ui)
implementation(libs.androidx.ui.graphics)
implementation(libs.androidx.ui.tooling.preview)
implementation(libs.androidx.material3)
implementation(libs.androidx.material.icons.extended)
implementation(libs.androidx.navigation.compose)
implementation(libs.androidx.datastore.preferences)
implementation(libs.kotlinx.serialization.json)
implementation(libs.okhttp)
implementation(libs.androidx.work.runtime.ktx)
implementation(libs.androidx.documentfile)
implementation(libs.material)
debugImplementation(libs.androidx.ui.tooling)
}
+2
View File
@@ -0,0 +1,2 @@
# Keep Bark message model fields used by kotlinx.serialization
-keepclassmembers class com.a2i.forwarder.model.** { *; }
+60
View File
@@ -0,0 +1,60 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.READ_PHONE_STATE" />
<uses-permission android:name="android.permission.SEND_SMS" />
<uses-permission android:name="android.permission.QUERY_ALL_PACKAGES"
tools:ignore="QueryAllPackagesPermission" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
<application
android:name=".A2iApp"
android:allowBackup="true"
android:dataExtractionRules="@xml/data_extraction_rules"
android:fullBackupContent="@xml/backup_rules"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true"
android:theme="@style/Theme.A2i"
tools:targetApi="31">
<activity
android:name=".MainActivity"
android:exported="true"
android:label="@string/app_name"
android:theme="@style/Theme.A2i">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
<service
android:name=".service.NotifyListenerService"
android:exported="false"
android:label="@string/app_name"
android:permission="android.permission.BIND_NOTIFICATION_LISTENER_SERVICE">
<intent-filter>
<action android:name="android.service.notification.NotificationListenerService" />
</intent-filter>
</service>
<provider
android:name="androidx.core.content.FileProvider"
android:authorities="${applicationId}.fileprovider"
android:exported="false"
android:grantUriPermissions="true">
<meta-data
android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/file_paths" />
</provider>
</application>
</manifest>
@@ -0,0 +1,57 @@
package com.a2i.forwarder
import android.app.Application
import com.a2i.forwarder.core.CarrierBalanceQuery
import com.a2i.forwarder.core.CarrierDetector
import com.a2i.forwarder.core.ConnectivityMonitor
import com.a2i.forwarder.core.LogStore
import com.a2i.forwarder.core.PhoneCallMonitor
import com.a2i.forwarder.core.SmsFallbackForwarder
import com.a2i.forwarder.core.UpdateChecker
import com.a2i.forwarder.store.AppRulesStore
import com.a2i.forwarder.store.SettingsStore
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
class A2iApp : Application() {
val appScope: CoroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
lateinit var settings: SettingsStore
private set
lateinit var appRules: AppRulesStore
private set
lateinit var logStore: LogStore
private set
lateinit var phoneCallMonitor: PhoneCallMonitor
private set
lateinit var connectivityMonitor: ConnectivityMonitor
private set
lateinit var carrierBalanceQuery: CarrierBalanceQuery
private set
lateinit var smsForwarder: SmsFallbackForwarder
private set
lateinit var updateChecker: UpdateChecker
private set
override fun onCreate() {
super.onCreate()
instance = this
settings = SettingsStore(this, appScope)
appRules = AppRulesStore(this, appScope)
logStore = LogStore(this, appScope)
phoneCallMonitor = PhoneCallMonitor(this)
phoneCallMonitor.start()
connectivityMonitor = ConnectivityMonitor(this)
connectivityMonitor.start()
carrierBalanceQuery = CarrierBalanceQuery(this)
smsForwarder = SmsFallbackForwarder(this)
updateChecker = UpdateChecker(this)
updateChecker.start()
}
companion object {
lateinit var instance: A2iApp
private set
}
}
@@ -0,0 +1,20 @@
package com.a2i.forwarder
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import com.a2i.forwarder.ui.nav.AppRoot
import com.a2i.forwarder.ui.theme.A2iTheme
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
enableEdgeToEdge()
setContent {
A2iTheme {
AppRoot()
}
}
}
}
@@ -0,0 +1,16 @@
package com.a2i.forwarder.core
object AdFilter {
val DEFAULT_KEYWORDS = listOf(
"广告", "推广", "优惠", "促销", "红包", "限时", "抽奖", "立即领取", "点击领取",
"赚取", "砍价", "秒杀", "爆款", "领券", "优惠券", "积分", "签到", "中奖",
"马上抢", "手慢无", "免费领", "赚钱", "返现", "特惠", "狂欢", "大促", "拼手速",
)
fun isAd(text: String, userKeywords: List<String>): Boolean {
val blob = text.lowercase()
for (k in DEFAULT_KEYWORDS) if (blob.contains(k.lowercase())) return true
for (k in userKeywords) if (k.isNotBlank() && blob.contains(k.lowercase())) return true
return false
}
}
@@ -0,0 +1,36 @@
package com.a2i.forwarder.core
object AppParsers {
data class Parsed(
val titleOverride: String? = null,
val groupKey: String? = null,
val clickUrl: String? = null,
val important: Boolean = false,
)
private val importantKeywords = listOf(
"@我", "@all", "@所有人", "语音", "视频", "通话", "邀请你加入", "验证码", "验证代码", "来电",
)
private val clickUrls = mapOf(
"com.tencent.mm" to "weixin://",
"com.tencent.mobileqq" to "mqq://",
"com.tencent.tim" to "mqq://",
"org.telegram.messenger" to "tg://",
"org.telegram.messenger.web" to "tg://",
"org.telegram.plus" to "tg://",
)
fun parse(packageName: String, title: String, text: String): Parsed {
val important = importantKeywords.any { text.contains(it) || title.contains(it) }
val url = clickUrls[packageName]
?: return Parsed(important = important)
val group = "$packageName:${title.ifBlank { "default" }}"
return Parsed(
titleOverride = title.takeIf { it.isNotBlank() },
groupKey = group,
clickUrl = url,
important = important,
)
}
}
@@ -0,0 +1,62 @@
package com.a2i.forwarder.core
import com.a2i.forwarder.model.BarkMessage
import com.a2i.forwarder.model.BarkServer
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.withContext
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.RequestBody.Companion.toRequestBody
import java.io.IOException
import java.util.concurrent.TimeUnit
class BarkClient(private val server: BarkServer) {
private val json = Json { encodeDefaults = false; ignoreUnknownKeys = true }
private val http = OkHttpClient.Builder()
.connectTimeout(10, TimeUnit.SECONDS)
.writeTimeout(10, TimeUnit.SECONDS)
.readTimeout(15, TimeUnit.SECONDS)
.retryOnConnectionFailure(true)
.build()
suspend fun push(msg: BarkMessage): Result<Unit> = withContext(Dispatchers.IO) {
runCatching {
require(server.deviceKey.isNotBlank()) { "device key 为空" }
doPushWithRetry(msg)
}
}
/**
* 带重试的推送:对 connection closed / 超时 / TLS 中断等瞬时 IOException 自动重试。
* OkHttp 的 retryOnConnectionFailure 不覆盖 POST 读响应阶段的连接中断,故在此手动补。
* HTTP 4xx/5xx 是服务端拒绝,重试无意义,直接抛出。
*/
private suspend fun doPushWithRetry(msg: BarkMessage) {
val body = json.encodeToString(msg.copy(deviceKey = server.deviceKey)).toRequestBody(JSON)
val url = server.server.trim().removeSuffix("/") + "/push"
val req = Request.Builder().url(url).post(body).build()
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) delay(BACKOFF_MS.getOrElse(attempt) { 1000L })
}
}
throw lastError ?: error("推送失败")
}
companion object {
private val JSON = "application/json; charset=utf-8".toMediaType()
private const val MAX_ATTEMPTS = 3
private val BACKOFF_MS = longArrayOf(600L, 1200L)
}
}
@@ -0,0 +1,41 @@
package com.a2i.forwarder.core
import com.a2i.forwarder.model.BarkMessage
import com.a2i.forwarder.model.BarkServer
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
/**
* 多服务器容错推送:按列表顺序依次尝试,首个成功即止。
* 全失败 → 返回最后一个错误;空列表 → "无可用服务器"。
*/
object BarkSender {
/** stopOnFirst=true:首个成功即止;false:全部发送,至少一个成功即算成功。 */
suspend fun pushToEnabled(servers: List<BarkServer>, msg: BarkMessage, stopOnFirst: Boolean): Result<Unit> = withContext(Dispatchers.IO) {
runCatching {
require(servers.any()) { "无可用服务器" }
if (stopOnFirst) return@runCatching pushToFirst(servers, msg).getOrThrow()
var anyOk = false
var lastErr: Throwable? = null
for (s in servers) {
val r = BarkClient(s).push(msg)
if (r.isSuccess) anyOk = true else lastErr = r.exceptionOrNull()
}
if (!anyOk) throw lastErr ?: error("全部推送失败")
}
}
/** 首个成功即止(stopOnFirst=true 的实现)。 */
suspend fun pushToFirst(servers: List<BarkServer>, msg: BarkMessage): Result<Unit> = withContext(Dispatchers.IO) {
runCatching {
require(servers.any()) { "无可用服务器" }
var lastErr: Throwable? = null
for (s in servers) {
val r = BarkClient(s).push(msg)
if (r.isSuccess) return@runCatching
lastErr = r.exceptionOrNull()
}
throw lastErr ?: error("推送失败")
}
}
}
@@ -0,0 +1,90 @@
package com.a2i.forwarder.core
import android.Manifest
import android.content.Context
import android.content.pm.PackageManager
import android.telephony.SmsManager
import androidx.core.content.ContextCompat
import com.a2i.forwarder.A2iApp
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.launch
/**
* 运营商短信余额查询:
* - refresh():向运营商服务号发免费查询短信(若运营商支持),回执由 NotificationProcessor 解析。
* - 手动模式:递减 smsManualQuota 作为余额。
* - parseReply():从运营商回执短信正文中提取剩余短信条数,供 NotificationProcessor 调用。
*/
class CarrierBalanceQuery(private val context: Context) {
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
private val settings get() = A2iApp.instance.settings
/** 触发一次余额查询(自动模式发短信,手动模式递减额度)。 */
suspend fun refresh() {
val carrier = currentCarrier()
// 自动模式:发查询短信到运营商(免费,不走限频)
if (carrier.serviceNumber != null && carrier.queryCmd != null) {
sendSms(carrier.serviceNumber, carrier.queryCmd)
return
}
// 手动模式:余额 = 手动额度,不额外查询
}
/** 转发成功后扣减余额(手动模式 / 自动解析失败时)。 */
suspend fun decrementManual() {
val q = settings.smsManualQuota.value
if (q > 0) {
val newQ = q - 1
settings.setSmsManualQuota(newQ)
settings.setSmsBalance(newQ)
checkSuspend(newQ)
}
}
/** 解析运营商回执,提取剩余短信条数。返回 null 表示解析失败。供 NotificationProcessor 调用。 */
fun parseReply(text: String): Int? {
// 匹配 "短信剩余XX条" / "短信:XX条" / "短信余量XX条" 等
val patterns = listOf(
Regex("""短信[^0-9]*(\d+)\s*条"""),
Regex("""短信余[额度][^0-9]*(\d+)"""),
)
for (p in patterns) {
val m = p.find(text) ?: continue
val n = m.groupValues.getOrNull(1)?.toIntOrNull() ?: continue
if (n in 0..9999) return n
}
return null
}
/** 回执解析成功后更新余额并检查是否需要挂起。 */
suspend fun applyParsedBalance(count: Int) {
settings.setSmsBalance(count)
checkSuspend(count)
}
private suspend fun checkSuspend(balance: Int) {
if (balance in 0..5 && !settings.smsSuspended.value) {
settings.setSmsSuspended(true)
}
}
fun currentCarrier(): CarrierDetector.Carrier {
val stored = settings.smsCarrier.value
return if (stored == "auto") CarrierDetector.detect(context)
else CarrierDetector.Carrier.fromName(stored)
}
private fun sendSms(dest: String, body: String) {
if (ContextCompat.checkSelfPermission(context, Manifest.permission.SEND_SMS)
!= PackageManager.PERMISSION_GRANTED
) return
scope.launch {
runCatching {
SmsManager.getDefault().sendTextMessage(dest, null, body, null, null)
}
}
}
}
@@ -0,0 +1,54 @@
package com.a2i.forwarder.core
import android.content.Context
import android.os.Build
import android.telephony.TelephonyManager
/**
* 运营商检测:根据 SIM 的 MCC+MNC 映射到国内运营商。
* 提供余额查询用的服务号 + 查询指令。
*/
object CarrierDetector {
enum class Carrier(
val displayName: String,
val serviceNumber: String?, // 余额查询服务号(免费)
val queryCmd: String?, // 查询指令(发送到此服务号)
) {
CHINA_MOBILE("中国移动", "10086", "CXDX"),
CHINA_UNICOM("中国联通", "10010", "CXTC"),
CHINA_TELECOM("中国电信", "10001", "108"),
CHINA_BROADCASTING("中国广电", "10099", null), // 无统一查询指令
UNKNOWN("未知运营商", null, null);
companion object {
fun fromName(name: String?): Carrier =
entries.firstOrNull { it.name == name } ?: UNKNOWN
}
}
// MCC+MNC → 运营商(中国大陆 460)
private val mncMap = mapOf(
// 中国移动
"46000" to Carrier.CHINA_MOBILE, "46002" to Carrier.CHINA_MOBILE,
"46004" to Carrier.CHINA_MOBILE, "46007" to Carrier.CHINA_MOBILE,
// 中国联通
"46001" to Carrier.CHINA_UNICOM, "46006" to Carrier.CHINA_UNICOM,
"46009" to Carrier.CHINA_UNICOM,
// 中国电信
"46003" to Carrier.CHINA_TELECOM, "46005" to Carrier.CHINA_TELECOM,
"46011" to Carrier.CHINA_TELECOM, "46012" to Carrier.CHINA_TELECOM,
// 中国广电
"46015" to Carrier.CHINA_BROADCASTING, "46018" to Carrier.CHINA_BROADCASTING,
)
fun detect(context: Context): Carrier {
return runCatching {
val tm = context.getSystemService(Context.TELEPHONY_SERVICE) as? TelephonyManager
?: return Carrier.UNKNOWN
val simOperator = tm.simOperator // 形如 "46000"
if (simOperator.isNullOrBlank() || simOperator.length < 5) return Carrier.UNKNOWN
mncMap[simOperator] ?: Carrier.UNKNOWN
}.getOrDefault(Carrier.UNKNOWN)
}
}
@@ -0,0 +1,25 @@
package com.a2i.forwarder.core
object CodeExtractor {
private val patterns = listOf(
Regex("""(?:验证码|校验码|动态码|验证代码|verification code|\bcode\b|密码|passcode|pin)\D{0,8}?(\d{4,8})""", RegexOption.IGNORE_CASE),
Regex("""(\d{4,8})\D{0,4}(?:验证码|校验码|动态码|验证代码|code)""", RegexOption.IGNORE_CASE),
Regex("""(\d{4,8})"""),
)
fun extract(text: String): String? {
// 如果文本被脱敏(全是 *****),跳过
if (text.isBlank() || isRedacted(text)) return null
for (p in patterns) {
val m = p.find(text) ?: continue
val code = m.groupValues.getOrNull(1) ?: continue
if (code.length in 4..8) return code
}
return null
}
private fun isRedacted(text: String): Boolean {
val asteriskCount = text.count { it == '*' }
return asteriskCount >= 3 && asteriskCount.toDouble() / text.length > 0.6
}
}
@@ -0,0 +1,54 @@
package com.a2i.forwarder.core
import android.content.Context
import android.net.ConnectivityManager
import android.net.Network
import android.net.NetworkCapabilities
import android.net.NetworkRequest
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
/**
* 监听网络连通性(是否能访问互联网)。
* 暴露 isOnline: StateFlow<Boolean>,断网时短信兜底转发器据此降级。
*/
class ConnectivityMonitor(private val context: Context) {
private val _isOnline = MutableStateFlow(false)
val isOnline = _isOnline.asStateFlow()
private var registered = false
private val callback = object : ConnectivityManager.NetworkCallback() {
override fun onAvailable(network: Network) { refresh() }
override fun onLost(network: Network) { refresh() }
override fun onCapabilitiesChanged(network: Network, caps: NetworkCapabilities) { refresh() }
}
fun start() {
if (registered) return
val cm = context.getSystemService(Context.CONNECTIVITY_SERVICE) as? ConnectivityManager
?: return
val request = NetworkRequest.Builder()
.addCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)
.build()
runCatching { cm.registerNetworkCallback(request, callback) }
registered = true
refresh()
}
fun stop() {
if (!registered) return
val cm = context.getSystemService(Context.CONNECTIVITY_SERVICE) as? ConnectivityManager
runCatching { cm?.unregisterNetworkCallback(callback) }
registered = false
}
private fun refresh() {
val cm = context.getSystemService(Context.CONNECTIVITY_SERVICE) as? ConnectivityManager ?: return
val active = cm.activeNetwork
val caps = active?.let { cm.getNetworkCapabilities(it) }
_isOnline.value = caps != null &&
caps.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) &&
caps.hasCapability(NetworkCapabilities.NET_CAPABILITY_VALIDATED)
}
}
@@ -0,0 +1,129 @@
package com.a2i.forwarder.core
import com.a2i.forwarder.A2iApp
import com.a2i.forwarder.model.EmailServer
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import java.io.BufferedReader
import java.io.BufferedWriter
import java.io.InputStreamReader
import java.io.OutputStreamWriter
import java.util.Base64
import javax.net.ssl.SSLSocket
import javax.net.ssl.SSLSocketFactory
/**
* SMTP 直连发送纯文本邮件(SMTPS/隐式 TLS,端口 465)。
* 在 [Dispatchers.IO] 上执行,不阻塞主线程。
*/
object EmailSender {
suspend fun send(
host: String, port: Int, user: String, password: String,
from: String, to: String, subject: String, body: String,
): Result<Unit> = withContext(Dispatchers.IO) {
runCatching {
val factory = SSLSocketFactory.getDefault()
// 隐式 TLS:直接通过 SSLSocketFactory 创建 SSLSocket 连接 SMTP 服务器(推荐端口 465)
val ssl = factory.createSocket(host, port) as SSLSocket
ssl.soTimeout = 15_000
ssl.startHandshake()
val reader: BufferedReader = ssl.inputStream.bufferedReader()
val writer: BufferedWriter = ssl.outputStream.bufferedWriter()
fun read(): String = reader.readLine() ?: error("连接关闭")
fun cmd(c: String) { writer.write("$c\r\n"); writer.flush() }
fun expectEhlo(): String {
cmd("EHLO a2i")
var last = ""
do { last = read() } while (last.startsWith("250-"))
if (!last.startsWith("250 ")) error("EHLO: $last")
return last
}
var r = read()
if (!r.startsWith("220")) error("SMTP: $r")
expectEhlo()
if (user.isNotBlank() && password.isNotBlank()) {
cmd("AUTH LOGIN")
r = read()
if (!r.startsWith("334")) error("AUTH: $r")
cmd(Base64.getEncoder().encodeToString(user.toByteArray()))
r = read()
if (!r.startsWith("334")) error("AUTH user: $r")
cmd(Base64.getEncoder().encodeToString(password.toByteArray()))
r = read()
if (!r.startsWith("235")) error("AUTH pass: $r")
}
cmd("MAIL FROM:<$from>")
r = read()
if (!r.startsWith("250")) error("FROM: $r")
cmd("RCPT TO:<$to>")
r = read()
if (!r.startsWith("250")) error("TO: $r")
cmd("DATA")
r = read()
if (!r.startsWith("354")) error("DATA: $r")
writer.write(buildMime(from, to, subject, body))
writer.write("\r\n.\r\n")
writer.flush()
r = read()
if (!r.startsWith("250")) error("SEND: $r")
cmd("QUIT")
runCatching { ssl.close() }
Unit
}
}
private fun buildMime(from: String, to: String, subject: String, body: String): String {
val subjB64 = Base64.getEncoder().encodeToString(subject.toByteArray(Charsets.UTF_8))
val bodyB64 = Base64.getEncoder().encodeToString(body.toByteArray(Charsets.UTF_8))
return buildString {
append("From: <$from>\r\n")
append("To: <$to>\r\n")
append("Subject: =?UTF-8?B?$subjB64?=\r\n")
append("MIME-Version: 1.0\r\n")
append("Content-Type: text/plain; charset=UTF-8\r\n")
append("Content-Transfer-Encoding: base64\r\n")
append("\r\n")
append(bodyB64)
}
}
/** 多配置推送:stopOnFirst=true 首个成功止;false 全部发送,至少一个成功即算成功。 */
suspend fun pushToEnabled(configs: List<EmailServer>, title: String, body: String, stopOnFirst: Boolean): Result<Unit> = withContext(Dispatchers.IO) {
runCatching {
require(configs.any()) { "无邮箱配置" }
var anyOk = false
var lastErr: Throwable? = null
for (c in configs) {
val r = send(c.host, c.port, c.user, c.password, c.from, c.to, title, body)
if (r.isSuccess) {
anyOk = true
if (stopOnFirst) return@runCatching
} else {
lastErr = r.exceptionOrNull()
}
}
if (!anyOk) throw lastErr ?: error("全部邮件发送失败")
}
}
/** 如果电邮转发已启用且收件人非空,异步发邮件(fire-and-forget,不阻塞主流程)。 */
fun trySend(app: A2iApp, title: String, body: String, scope: CoroutineScope) {
val s = app.settings
val configs = s.emailServers.value.filter { it.enabled && it.to.isNotBlank() && it.host.isNotBlank() }
if (configs.isEmpty()) return
scope.launch {
pushToEnabled(configs, title, body, s.emailStopOnFirst.value)
}
}
}
@@ -0,0 +1,55 @@
package com.a2i.forwarder.core
import android.content.Context
import android.content.pm.PackageManager
import android.graphics.Bitmap
import android.graphics.Canvas
import android.graphics.drawable.BitmapDrawable
import android.graphics.drawable.Drawable
import android.net.Uri
import androidx.documentfile.provider.DocumentFile
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
class IconExporter(private val context: Context) {
data class ExportResult(val total: Int, val ok: Int, val fail: Int)
suspend fun exportAll(treeUri: Uri): ExportResult = withContext(Dispatchers.IO) {
val tree = DocumentFile.fromTreeUri(context, treeUri)
?: return@withContext ExportResult(0, 0, 0)
val pm = context.packageManager
val apps = pm.getInstalledApplications(PackageManager.GET_META_DATA)
var ok = 0
var fail = 0
for (app in apps) {
val name = app.packageName
try {
val drawable: Drawable = pm.getApplicationIcon(name)
val bmp = drawableToBitmap(drawable)
tree.findFile("$name.png")?.delete()
val file = tree.createFile("image/png", "$name.png")
if (file != null) {
context.contentResolver.openOutputStream(file.uri)?.use { out ->
bmp.compress(Bitmap.CompressFormat.PNG, 100, out)
}
ok++
} else {
fail++
}
} catch (e: Exception) {
fail++
}
}
ExportResult(apps.size, ok, fail)
}
private fun drawableToBitmap(d: Drawable): Bitmap {
if (d is BitmapDrawable && d.bitmap != null) return d.bitmap
val size = maxOf(d.intrinsicWidth, d.intrinsicHeight, 192).coerceAtLeast(1)
val bmp = Bitmap.createBitmap(size, size, Bitmap.Config.ARGB_8888)
val canvas = Canvas(bmp)
d.setBounds(0, 0, canvas.width, canvas.height)
d.draw(canvas)
return bmp
}
}
@@ -0,0 +1,101 @@
package com.a2i.forwarder.core
import android.content.Context
import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.intPreferencesKey
import androidx.datastore.preferences.core.stringPreferencesKey
import androidx.datastore.preferences.preferencesDataStore
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.launch
import kotlinx.serialization.Serializable
import kotlinx.serialization.decodeFromString
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
@Serializable
data class ForwardLog(
val time: Long,
val pkg: String,
val appLabel: String,
val title: String?,
val content: String,
val status: String, // sent / filtered / failed
val reason: String,
)
@Serializable
data class PendingPush(
val id: String,
val time: Long,
val serverId: String,
val messageJson: String,
)
private val Context.logDataStore: DataStore<Preferences> by preferencesDataStore(name = "a2i_logs")
class LogStore(private val context: Context, scope: CoroutineScope) {
private val ds get() = context.logDataStore
private val json = Json { ignoreUnknownKeys = true; encodeDefaults = true }
private object K {
val LOGS = stringPreferencesKey("logs")
val PENDING = stringPreferencesKey("pending")
val SENT = intPreferencesKey("sent")
val FILTERED = intPreferencesKey("filtered")
val FAILED = intPreferencesKey("failed")
}
val logs = MutableStateFlow<List<ForwardLog>>(emptyList())
val pending = MutableStateFlow<List<PendingPush>>(emptyList())
val sentCount = MutableStateFlow(0)
val filteredCount = MutableStateFlow(0)
val failedCount = MutableStateFlow(0)
init {
scope.launch {
ds.data.collect { p ->
logs.value = p[K.LOGS]?.let { runCatching { json.decodeFromString<List<ForwardLog>>(it) }.getOrNull() } ?: emptyList()
pending.value = p[K.PENDING]?.let { runCatching { json.decodeFromString<List<PendingPush>>(it) }.getOrNull() } ?: emptyList()
sentCount.value = p[K.SENT] ?: 0
filteredCount.value = p[K.FILTERED] ?: 0
failedCount.value = p[K.FAILED] ?: 0
}
}
}
suspend fun addLog(l: ForwardLog) {
val list = (listOf(l) + logs.value).take(300)
ds.edit { it[K.LOGS] = json.encodeToString(list) }
logs.value = list
when (l.status) {
"sent" -> ds.edit { it[K.SENT] = (sentCount.value + 1) }.also { sentCount.value += 1 }
"filtered" -> ds.edit { it[K.FILTERED] = (filteredCount.value + 1) }.also { filteredCount.value += 1 }
"failed" -> ds.edit { it[K.FAILED] = (failedCount.value + 1) }.also { failedCount.value += 1 }
}
}
suspend fun clearLogs() {
ds.edit { it.remove(K.LOGS) }
logs.value = emptyList()
}
suspend fun resetCounters() {
ds.edit { it[K.SENT] = 0; it[K.FILTERED] = 0; it[K.FAILED] = 0 }
sentCount.value = 0; filteredCount.value = 0; failedCount.value = 0
}
suspend fun addPending(p: PendingPush) {
val list = (pending.value + p).takeLast(100)
ds.edit { it[K.PENDING] = json.encodeToString(list) }
pending.value = list
}
suspend fun removePending(id: String) {
val list = pending.value.filterNot { it.id == id }
ds.edit { it[K.PENDING] = json.encodeToString(list) }
pending.value = list
}
}
@@ -0,0 +1,145 @@
package com.a2i.forwarder.core
import com.a2i.forwarder.A2iApp
import com.a2i.forwarder.model.MeowServer
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.RequestBody.Companion.toRequestBody
import org.json.JSONObject
import java.io.IOException
import java.util.concurrent.TimeUnit
/**
* Meow for HarmonyOS push channel.
*
* Meow's public API shape is less standardized than Bark/ntfy in this app,
* so this sender accepts a user-provided endpoint and tries common JSON/GET forms.
*/
object MeowSender {
private val http = OkHttpClient.Builder()
.connectTimeout(10, TimeUnit.SECONDS)
.writeTimeout(10, TimeUnit.SECONDS)
.readTimeout(15, TimeUnit.SECONDS)
.retryOnConnectionFailure(true)
.build()
suspend fun send(
server: String,
deviceKey: String,
title: String,
body: String,
): Result<Unit> = withContext(Dispatchers.IO) {
runCatching {
val endpoint = server.trim().removeSuffix("/")
val key = deviceKey.trim()
require(endpoint.isNotBlank()) { "Meow 服务器地址未填" }
require(key.isNotBlank()) { "Meow Device Key 未填" }
val attempts = buildRequests(endpoint, key, title.take(200), body.take(4000))
var last: Throwable? = null
for (req in attempts) {
val r = runCatching { doPushWithRetry(req) }
if (r.isSuccess) return@runCatching
last = r.exceptionOrNull()
}
throw last ?: error("Meow 推送失败")
}
}
suspend fun pushToEnabled(configs: List<MeowServer>, title: String, body: String, stopOnFirst: Boolean): Result<Unit> = withContext(Dispatchers.IO) {
runCatching {
require(configs.any()) { "无 Meow 配置" }
var anyOk = false
var lastErr: Throwable? = null
for (c in configs) {
val r = send(c.server, c.deviceKey, title, body)
if (r.isSuccess) {
anyOk = true
if (stopOnFirst) return@runCatching
} else {
lastErr = r.exceptionOrNull()
}
}
if (!anyOk) throw lastErr ?: error("全部 Meow 推送失败")
}
}
fun trySend(app: A2iApp, title: String, body: String, scope: CoroutineScope) {
val s = app.settings
val configs = s.meowServers.value.filter { it.enabled && it.server.isNotBlank() && it.deviceKey.isNotBlank() }
if (configs.isEmpty()) return
scope.launch {
pushToEnabled(configs, title, body, s.meowStopOnFirst.value)
}
}
private fun buildRequests(endpoint: String, key: String, title: String, body: String): List<Request> {
val payload = JSONObject()
.put("deviceKey", key)
.put("device_key", key)
.put("key", key)
.put("title", title)
.put("content", body)
.put("message", body)
.put("body", body)
.toString()
val postTargets = listOf(endpoint, "$endpoint/push").distinct()
val postRequests = postTargets.map { url ->
Request.Builder()
.url(url)
.header("Authorization", "Bearer $key")
.header("X-Device-Key", key)
.post(payload.toRequestBody(JSON))
.build()
}
val getRequest = endpoint.toHttpUrlOrNull()?.newBuilder()
?.addQueryParameter("deviceKey", key)
?.addQueryParameter("key", key)
?.addQueryParameter("title", title)
?.addQueryParameter("content", body)
?.addQueryParameter("message", body)
?.build()
?.let { url ->
Request.Builder()
.url(url)
.header("Authorization", "Bearer $key")
.header("X-Device-Key", key)
.get()
.build()
}
return postRequests + listOfNotNull(getRequest)
}
private fun doPushWithRetry(req: Request) {
var lastError: IOException? = null
repeat(MAX_ATTEMPTS) { attempt ->
try {
http.newCall(req).execute().use { res ->
if (res.isSuccessful) return
error("HTTP ${res.code}")
}
} catch (e: IOException) {
lastError = e
if (attempt < MAX_ATTEMPTS - 1) {
Thread.sleep(BACKOFF_MS[attempt])
}
} catch (e: IllegalStateException) {
throw e
}
}
throw lastError ?: error("Meow 推送失败")
}
private val JSON = "application/json; charset=utf-8".toMediaType()
private const val MAX_ATTEMPTS = 3
private val BACKOFF_MS = longArrayOf(600L, 1200L)
}
@@ -0,0 +1,262 @@
package com.a2i.forwarder.core
import android.app.Notification
import android.content.Context
import android.service.notification.StatusBarNotification
import com.a2i.forwarder.A2iApp
import com.a2i.forwarder.model.BarkMessage
import java.util.LinkedHashMap
class NotificationProcessor(private val context: Context) {
private val settings get() = A2iApp.instance.settings
private val rules get() = A2iApp.instance.appRules
data class Decision(
val message: BarkMessage?,
val reason: String,
val appLabel: String,
val code: String?,
val silent: Boolean = false, // true = 静默过滤,不写日志
)
// ========== 去重缓存 ==========
// 防止同一条通知在短时间内被 Android 系统反复投递导致重复转发
// key = "${pkg}|${title}|${body}" 的 hash, value = 上次转发时间戳
private val dedupCache = object : LinkedHashMap<String, Long>(64, 0.75f, true) {
override fun removeEldestEntry(eldest: MutableMap.MutableEntry<String, Long>?): Boolean {
return size > 128
}
}
private val DEDUP_WINDOW_MS = 5_000L // 5 秒内相同内容不重复转发
// ========== 系统状态消息过滤模式 ==========
private val systemStatusPatterns = listOf(
Regex("""正在运行"""),
Regex("""is running""", RegexOption.IGNORE_CASE),
Regex("""运行中"""),
Regex("""服务(正在|已)启动"""),
Regex("""service (is )?running""", RegexOption.IGNORE_CASE),
Regex("""^.*(?:正在|is)\s*(?:运行|running|run).*$""", RegexOption.IGNORE_CASE),
)
// ========== 系统剪贴板/无用服务包名 ==========
private val noisePackages = setOf(
"com.samsung.android.honeyboard",
"com.samsung.android.app.clipboard",
"com.samsung.android.clipboard",
"com.android.systemui",
"com.google.android.inputmethod.latin",
"com.google.android.gms",
)
// 静默过滤的包名:代理/VPN 客户端的常驻流量通知,高频且无意义,过滤不写日志
private val silentPackages = setOf(
"com.getsurfboard", // Surfboard
"com.github.metacubex.clash.meta", // Clash Meta for Android
"com.github.kr328.clash", // Clash for Android
"com.v2ray.ang", // v2rayNG
"com.milink.service", // 小米设备互联(UniversalClipboardService 跨设备剪贴板等常驻通知)
)
fun process(sbn: StatusBarNotification): Decision {
val pkg = sbn.packageName
val n = sbn.notification ?: return Decision(null, "无通知", pkg, null)
val extras = n.extras
// 提取标题和文本(尝试多个来源)
val title = extras.getCharSequence(Notification.EXTRA_TITLE)?.toString().orEmpty()
var text = extras.getCharSequence(Notification.EXTRA_TEXT)?.toString().orEmpty()
val big = extras.getCharSequence(Notification.EXTRA_BIG_TEXT)?.toString().orEmpty()
val sub = extras.getCharSequence(Notification.EXTRA_SUB_TEXT)?.toString().orEmpty()
// ---- 修复 ***** 脱敏问题 ----
// 部分 ROM 会把验证码通知的 EXTRA_TEXT 脱敏为 *****
// 尝试从 tickerText 和其他备用源获取真实内容
if (isRedacted(text)) {
// 优先用 bigText(通常未被脱敏)
if (!isRedacted(big) && big.isNotBlank()) {
text = big
} else {
// 尝试 tickerText(deprecated 但仍可能包含原文)
val ticker = n.tickerText?.toString().orEmpty()
if (!isRedacted(ticker) && ticker.isNotBlank()) {
text = ticker
}
}
}
// 确定最终 content:优先非空、非脱敏的内容
val content = listOf(text, big)
.firstOrNull { it.isNotBlank() && !isRedacted(it) }
?: text.ifBlank { big }
val appLabel = runCatching {
context.packageManager.getApplicationLabel(context.packageManager.getApplicationInfo(pkg, 0)).toString()
}.getOrDefault(pkg)
// ---- 全局开关 ----
if (!settings.forwardingEnabled.value)
return Decision(null, "全局转发已关", appLabel, null)
// ---- 持续性通知过滤(静默,不进日志)----
val isOngoing = n.flags and Notification.FLAG_ONGOING_EVENT != 0
if (settings.skipOngoing.value && isOngoing)
return Decision(null, "持续性通知", appLabel, null, silent = true)
// ---- 静默包名过滤(VPN/代理客户端的常驻流量通知,高频无意义,不写日志)----
if (pkg in silentPackages)
return Decision(null, "常驻通知静默", appLabel, null, silent = true)
// ---- 米家泛化状态通知静默("设备状态"等概览;门锁/告警等具体事件保留)----
if (pkg == "com.xiaomi.smarthome" && content.contains("设备状态"))
return Decision(null, "米家状态通知静默", appLabel, null, silent = true)
// ---- App 黑白名单 ----
if (!rules.isForwardingOn(pkg))
return Decision(null, "此 App 已关", appLabel, null)
// ---- 空内容 / 脱敏内容过滤 ----
if (title.isBlank() && content.isBlank())
return Decision(null, "空内容", appLabel, null)
// 如果所有文本源都被脱敏了,不要转发无意义内容
if (isRedacted(content) && isRedacted(title))
return Decision(null, "内容已脱敏", appLabel, null)
// ---- 系统噪音包名过滤 ----
if (pkg in noisePackages && (content.isBlank() || content.contains("clipboard", true) || content.contains("剪贴", true)))
return Decision(null, "系统噪音通知", appLabel, null)
// ---- 系统状态消息过滤 ----
if (isSystemStatusMessage("$title $content $sub"))
return Decision(null, "系统状态消息", appLabel, null)
// ---- 近空内容过滤(纯符号、纯空格、仅 app 名等)----
val trimmedContent = content.trim()
if (trimmedContent.length < 2 && title.isBlank())
return Decision(null, "内容过短", appLabel, null)
// ---- 广告过滤 ----
if (settings.adFilterEnabled.value && AdFilter.isAd("$title $content $sub", settings.adKeywords.value))
return Decision(null, "广告过滤", appLabel, null)
// ---- 短信"发出"过滤:只保留收到的短信 ----
if (isSentSmsNotification(pkg, title, content))
return Decision(null, "已发短信(仅收不发)", appLabel, null)
// ---- 运营商余额回执拦截:解析短信余量,不转发 ----
val balanceParsed = tryParseCarrierBalance(pkg, title, content)
if (balanceParsed != null) {
return Decision(null, "运营商余额回执(${balanceParsed}条)", appLabel, null)
}
// ---- 去重检查 ----
val dedupKey = "$pkg|${title.take(80)}|${content.take(80)}"
val lastTime = dedupCache[dedupKey]
val now = System.currentTimeMillis()
if (lastTime != null && (now - lastTime) < DEDUP_WINDOW_MS) {
return Decision(null, "去重(${now - lastTime}ms 内重复)", appLabel, null)
}
dedupCache[dedupKey] = now
// ---- 验证码提取 ----
var code: String? = null
if (settings.codeEnabled.value) {
code = CodeExtractor.extract("$title $content")
}
// ---- 特殊 App 解析 ----
var msgTitle = title.ifBlank { appLabel }
var group: String = pkg
var clickUrl: String? = null
var important = false
if (settings.specialEnabled.value) {
val p = AppParsers.parse(pkg, title, content)
p.titleOverride?.let { msgTitle = it }
p.groupKey?.let { group = it }
clickUrl = p.clickUrl
important = p.important
}
if (code != null) important = true
if (code != null) {
msgTitle = title.ifBlank { "验证码" }
}
val iconPrefix = settings.iconPrefix.value.trim()
val icon = if (iconPrefix.isBlank()) null else iconPrefix.trimEnd('/') + "/" + pkg + ".png"
val level = if (important) "timeSensitive" else "active"
val msg = BarkMessage(
deviceKey = "",
title = msgTitle.take(100),
body = content.ifBlank { title }.take(500),
level = level,
icon = icon,
group = group,
url = clickUrl,
autoCopy = if (code != null) "1" else null,
copy = code,
isArchive = "1",
)
return Decision(msg, "转发", appLabel, code)
}
// ---- 辅助方法 ----
/** 检测文本是否被系统脱敏(全部或大部分是 * 号) */
private fun isRedacted(text: String): Boolean {
if (text.isBlank()) return false
val asteriskCount = text.count { it == '*' }
// 如果 * 占比超过 60%,认为是脱敏内容
return asteriskCount >= 3 && asteriskCount.toDouble() / text.length > 0.6
}
/** 检测是否为系统状态消息(如"短信正在运行") */
private fun isSystemStatusMessage(text: String): Boolean {
return systemStatusPatterns.any { it.containsMatchIn(text) }
}
/** 短信 App 列表 */
private val smsPackages = setOf(
"com.android.mms",
"com.google.android.apps.messaging",
"com.samsung.android.messaging",
"com.android.messaging",
)
/** 发出的短信通知关键词 */
private val sentSmsPatterns = listOf(
Regex("""发送中"""),
Regex("""正在发送"""),
Regex("""已发送"""),
Regex("""发送成功"""),
Regex("""短信已发出"""),
Regex("""消息已发出"""),
Regex("""sending""", RegexOption.IGNORE_CASE),
Regex("""message sent""", RegexOption.IGNORE_CASE),
Regex("""sent$""", RegexOption.IGNORE_CASE),
)
/** 判断是否为发出的短信通知(而非收到的) */
private fun isSentSmsNotification(pkg: String, title: String, content: String): Boolean {
if (pkg !in smsPackages) return false
val blob = "$title $content"
return sentSmsPatterns.any { it.containsMatchIn(blob) }
}
/**
* 拦截运营商余额回执短信:title 是运营商服务号(10086/10010/10001/10099),
* 解析正文中的剩余短信条数,写入设置并按"过滤"处理。
* 返回解析到的条数(已处理),返回 null 表示不是回执或解析失败。
*/
private fun tryParseCarrierBalance(pkg: String, title: String, content: String): Int? {
if (pkg !in smsPackages) return null
val serviceNumbers = setOf("10086", "10010", "10001", "10099")
if (title.trim() !in serviceNumbers) return null
val app = A2iApp.instance
val count = app.carrierBalanceQuery.parseReply("$title $content") ?: return null
kotlinx.coroutines.runBlocking { app.carrierBalanceQuery.applyParsedBalance(count) }
return count
}
}
@@ -0,0 +1,102 @@
package com.a2i.forwarder.core
import com.a2i.forwarder.A2iApp
import com.a2i.forwarder.model.NtfyServer
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.RequestBody.Companion.toRequestBody
import org.json.JSONObject
import java.io.IOException
import java.util.concurrent.TimeUnit
/**
* ntfy 推送:用 JSON publish API(POST / with {topic,title,message})。
* 注:raw body 会被 ntfy 当附件,必须用 JSON API 或 Message header。
* 对瞬时 IOException 自动重试 3 次(退避 600/1200ms)。
*/
object NtfySender {
private val http = OkHttpClient.Builder()
.connectTimeout(10, TimeUnit.SECONDS)
.writeTimeout(10, TimeUnit.SECONDS)
.readTimeout(15, TimeUnit.SECONDS)
.retryOnConnectionFailure(true)
.build()
suspend fun send(
server: String, topic: String, token: String,
title: String, body: String,
): Result<Unit> = withContext(Dispatchers.IO) {
runCatching {
val payload = JSONObject()
.put("topic", topic.trim())
.put("title", title.take(200))
.put("message", body.take(4000))
.toString()
val req = Request.Builder()
.url(server.trim().removeSuffix("/"))
.apply { if (token.isNotBlank()) header("Authorization", "Bearer $token") }
.post(payload.toRequestBody(JSON))
.build()
doPushWithRetry(req)
}
}
/** 多配置推送:stopOnFirst=true 首个成功止;false 全部发送,至少一个成功即算成功。 */
suspend fun pushToEnabled(configs: List<NtfyServer>, title: String, body: String, stopOnFirst: Boolean): Result<Unit> = withContext(Dispatchers.IO) {
runCatching {
require(configs.any()) { "无 ntfy 配置" }
var anyOk = false
var lastErr: Throwable? = null
for (c in configs) {
val r = send(c.server, c.topic, c.token, title, body)
if (r.isSuccess) {
anyOk = true
if (stopOnFirst) return@runCatching
} else {
lastErr = r.exceptionOrNull()
}
}
if (!anyOk) throw lastErr ?: error("全部 ntfy 推送失败")
}
}
private fun doPushWithRetry(req: Request) {
var lastError: IOException? = null
repeat(MAX_ATTEMPTS) { attempt ->
try {
http.newCall(req).execute().use { res ->
if (res.isSuccessful) return
error("HTTP ${res.code}")
}
} catch (e: IOException) {
lastError = e
if (attempt < MAX_ATTEMPTS - 1) {
Thread.sleep(BACKOFF_MS[attempt])
}
} catch (e: IllegalStateException) {
// HTTP 错误码(error() 抛 IllegalStateException),不重试
throw e
}
}
throw lastError ?: error("ntfy 推送失败")
}
fun trySend(app: A2iApp, title: String, body: String, scope: CoroutineScope) {
val s = app.settings
val configs = s.ntfyServers.value.filter { it.enabled && it.topic.isNotBlank() }
if (configs.isEmpty()) return
scope.launch {
pushToEnabled(configs, title, body, s.ntfyStopOnFirst.value)
}
}
private val JSON = "application/json; charset=utf-8".toMediaType()
private const val MAX_ATTEMPTS = 3
private val BACKOFF_MS = longArrayOf(600L, 1200L)
}
@@ -0,0 +1,153 @@
package com.a2i.forwarder.core
import android.Manifest
import android.content.Context
import android.content.pm.PackageManager
import android.telephony.TelephonyManager
import androidx.core.content.ContextCompat
import com.a2i.forwarder.A2iApp
import com.a2i.forwarder.model.BarkMessage
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.delay
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
import java.util.UUID
/**
* 通过轮询 TelephonyManager.getCallState() 监听来电状态。
* MIUI 不会把系统电话通知分发给第三方监听器,TelephonyCallback 也不生效,
* 所以这里用最原始但最可靠的方式:每 800ms 读一次 call state。
*
* getCallState() 只是读内存中的 int,不涉及硬件操作,耗电极低。
*/
class PhoneCallMonitor(private val context: Context) {
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
private val json = Json { encodeDefaults = true; ignoreUnknownKeys = true }
private var lastState = TelephonyManager.CALL_STATE_IDLE
private var ringing = false
private var offhook = false
private var running = false
/** 上一次推送"来电"的时间,防止重复推送 */
private var lastRingTime = 0L
fun start() {
if (running) return
if (ContextCompat.checkSelfPermission(context, Manifest.permission.READ_PHONE_STATE)
!= PackageManager.PERMISSION_GRANTED
) return
running = true
lastState = TelephonyManager.CALL_STATE_IDLE
ringing = false
offhook = false
scope.launch {
val tm = context.getSystemService(Context.TELEPHONY_SERVICE) as? TelephonyManager
if (tm == null) { running = false; return@launch }
while (isActive && running) {
val state = tm.callState
if (state != lastState) {
handleTransition(lastState, state)
lastState = state
}
delay(800)
}
}
}
fun stop() {
running = false
}
private fun handleTransition(from: Int, to: Int) {
val app = A2iApp.instance
val settings = app.settings
if (!settings.forwardingEnabled.value) return
when {
// IDLE → RINGING:来电
from == TelephonyManager.CALL_STATE_IDLE && to == TelephonyManager.CALL_STATE_RINGING -> {
ringing = true
offhook = false
val now = System.currentTimeMillis()
if (now - lastRingTime > 3000) {
lastRingTime = now
val msg = BarkMessage(
deviceKey = "",
title = "来电",
body = "有电话进来",
level = "timeSensitive",
group = "phone_call",
isArchive = "1",
)
pushToServers(msg, app, "来电")
}
}
// RINGING → OFFHOOK:接听
from == TelephonyManager.CALL_STATE_RINGING && to == TelephonyManager.CALL_STATE_OFFHOOK -> {
offhook = true
}
// RINGING → IDLE:未接(响了但没接)
from == TelephonyManager.CALL_STATE_RINGING && to == TelephonyManager.CALL_STATE_IDLE -> {
if (!offhook && ringing) {
val msg = BarkMessage(
deviceKey = "",
title = "未接来电",
body = "未接来电",
level = "timeSensitive",
group = "phone_call",
isArchive = "1",
)
pushToServers(msg, app, "未接来电")
}
ringing = false
offhook = false
}
// OFFHOOK → IDLE:正常挂断,不推送
from == TelephonyManager.CALL_STATE_OFFHOOK && to == TelephonyManager.CALL_STATE_IDLE -> {
ringing = false
offhook = false
}
}
}
private fun pushToServers(msg: BarkMessage, app: A2iApp, logBody: String) {
scope.launch {
val servers = app.settings.servers.value.filter { it.enabled && it.deviceKey.isNotBlank() }
if (servers.isEmpty()) {
EmailSender.trySend(app, msg.title ?: logBody, msg.body ?: "", scope)
NtfySender.trySend(app, msg.title ?: logBody, msg.body ?: "", scope)
MeowSender.trySend(app, msg.title ?: logBody, msg.body ?: "", scope)
return@launch
}
val result = BarkSender.pushToEnabled(servers, msg, app.settings.barkStopOnFirst.value)
val now = System.currentTimeMillis()
if (result.isSuccess) {
app.logStore.addLog(
ForwardLog(now, "phone_call", "电话", msg.title, logBody, "sent", "")
)
} else {
val reason = result.exceptionOrNull()?.message ?: "错误"
app.logStore.addLog(
ForwardLog(now, "phone_call", "电话", msg.title, logBody, "failed", reason)
)
app.logStore.addPending(
PendingPush(UUID.randomUUID().toString(), now, servers.first().id, json.encodeToString(msg))
)
}
EmailSender.trySend(app, msg.title ?: logBody, msg.body ?: "", scope)
NtfySender.trySend(app, msg.title ?: logBody, msg.body ?: "", scope)
MeowSender.trySend(app, msg.title ?: logBody, msg.body ?: "", scope)
}
}
}
@@ -0,0 +1,50 @@
package com.a2i.forwarder.core
import android.content.Context
import androidx.work.Constraints
import androidx.work.CoroutineWorker
import androidx.work.ExistingPeriodicWorkPolicy
import androidx.work.NetworkType
import androidx.work.PeriodicWorkRequestBuilder
import androidx.work.WorkManager
import androidx.work.WorkerParameters
import com.a2i.forwarder.A2iApp
import kotlinx.serialization.decodeFromString
import kotlinx.serialization.json.Json
import java.util.concurrent.TimeUnit
class RetryWorker(ctx: Context, params: WorkerParameters) : CoroutineWorker(ctx, params) {
override suspend fun doWork(): Result {
val app = A2iApp.instance
val store = app.logStore
val settings = app.settings
val list = store.pending.value
if (list.isEmpty()) return Result.success()
var allOk = true
val json = Json { ignoreUnknownKeys = true }
val servers = settings.servers.value.filter { it.enabled && it.deviceKey.isNotBlank() }
for (p in list) {
if (servers.isEmpty()) { allOk = false; break }
val msg = runCatching { json.decodeFromString<com.a2i.forwarder.model.BarkMessage>(p.messageJson) }.getOrNull() ?: continue
val r = BarkSender.pushToFirst(servers, msg)
if (r.isSuccess) store.removePending(p.id) else allOk = false
}
return if (allOk) Result.success() else Result.retry()
}
companion object {
private const val NAME = "a2i_retry"
fun schedule(context: Context) {
val req = PeriodicWorkRequestBuilder<RetryWorker>(15, TimeUnit.MINUTES)
.setConstraints(
Constraints.Builder().setRequiredNetworkType(NetworkType.CONNECTED).build()
)
.build()
WorkManager.getInstance(context)
.enqueueUniquePeriodicWork(NAME, ExistingPeriodicWorkPolicy.UPDATE, req)
}
}
}
@@ -0,0 +1,116 @@
package com.a2i.forwarder.core
import android.Manifest
import android.content.Context
import android.content.pm.PackageManager
import android.telephony.SmsManager
import androidx.core.content.ContextCompat
import com.a2i.forwarder.A2iApp
import com.a2i.forwarder.core.NotificationProcessor.Decision
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.launch
/**
* 短信兜底转发器:断网时把紧要通知(验证码、来电)用短信发到 iPhone。
* 限频:每 5 分钟最多 1 条转发短信。余额 ≤5 时自动挂起。
*/
class SmsFallbackForwarder(private val context: Context) {
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
private val RATE_LIMIT_MS = 5 * 60 * 1000L
private val app get() = A2iApp.instance
private val settings get() = app.settings
/** 判断该通知是否值得走短信(断网资源宝贵)。 */
fun isSmsWorthy(d: Decision): Boolean {
if (d.code != null) return true // 验证码
val msg = d.message ?: return false
if (msg.group == "phone_call") return true // 来电/未接来电
val t = msg.title.orEmpty()
return t.contains("来电") || t.contains("未接")
}
/** 尝试用短信转发(遍历所有启用的目标号,按 stopOnFirst 决定发通一条止还是全发)。 */
fun forward(d: Decision) {
scope.launch {
if (settings.smsSuspended.value) {
log(d, "failed", "短信已挂起(余额不足)")
return@launch
}
if (!isSmsWorthy(d)) return@launch
val targets = settings.smsTargets.value.filter { it.enabled && it.number.isNotBlank() }
if (targets.isEmpty()) {
log(d, "failed", "未设置短信目标号")
return@launch
}
// 限频
val now = System.currentTimeMillis()
val elapsed = now - settings.lastSmsSentTime.value
if (elapsed < RATE_LIMIT_MS) {
log(d, "filtered", "限频(${RATE_LIMIT_MS - elapsed}ms 内)")
return@launch
}
// 权限
if (ContextCompat.checkSelfPermission(context, Manifest.permission.SEND_SMS)
!= PackageManager.PERMISSION_GRANTED
) {
log(d, "failed", "无 SEND_SMS 权限")
return@launch
}
val body = compose(d).take(70)
val stopOnFirst = settings.smsStopOnFirst.value
var anyOk = false
var sentCount = 0
var lastReason = "短信发送失败"
for (t in targets) {
val result = runCatching {
SmsManager.getDefault().sendTextMessage(t.number.trim(), null, body, null, null)
}
if (result.isSuccess) {
anyOk = true
sentCount++
if (stopOnFirst) break
} else {
lastReason = result.exceptionOrNull()?.message ?: "短信发送失败"
}
}
if (anyOk) {
settings.setLastSmsSentTime(now)
log(d, "sent", "")
// 余额递减:按成功发送条数;自动模式发查询短信,手动模式递减
val carrier = app.carrierBalanceQuery.currentCarrier()
if (carrier.serviceNumber != null && carrier.queryCmd != null) {
app.carrierBalanceQuery.refresh()
} else {
repeat(sentCount) { runCatching { app.carrierBalanceQuery.decrementManual() } }
}
} else {
log(d, "failed", lastReason)
}
}
}
private fun compose(d: Decision): String {
val code = d.code
return if (code != null) {
"[验证码]${d.appLabel}: $code"
} else {
val m = d.message!!
"${m.title.orEmpty().ifBlank { d.appLabel }}: ${m.body}".trim()
}
}
private fun log(d: Decision, status: String, reason: String) {
scope.launch {
val now = System.currentTimeMillis()
val title = d.code?.let { "验证码" } ?: d.message?.title ?: d.appLabel
val content = d.code ?: d.message?.body ?: ""
app.logStore.addLog(
ForwardLog(now, "sms_fallback", d.appLabel, title, content, status, reason)
)
}
}
}
@@ -0,0 +1,284 @@
package com.a2i.forwarder.core
import android.content.Context
import android.content.Intent
import android.net.Uri
import android.provider.Settings
import androidx.core.content.FileProvider
import com.a2i.forwarder.A2iApp
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import okhttp3.OkHttpClient
import okhttp3.Request
import org.json.JSONObject
import java.io.File
import java.util.concurrent.TimeUnit
data class UpdateInfo(
val latestVersion: String,
val apkUrl: String,
val apkSize: Long,
val releaseNotes: String,
val releasePageUrl: String,
)
/**
* 应用内更新:定时巡检 Gitea Releases 最新版 → 版本比较 → 醒目横幅 + 自动推送通知 → 下载 APK → 调起系统安装器。
* Android targetSdk≥24 须用 FileProvider,targetSdk≥26 安装未知来源 APK 须 REQUEST_INSTALL_PACKAGES。
*/
class UpdateChecker(private val context: Context) {
private val app get() = A2iApp.instance
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
private val http = OkHttpClient.Builder()
.connectTimeout(10, TimeUnit.SECONDS)
.readTimeout(30, TimeUnit.SECONDS)
.retryOnConnectionFailure(true)
.build()
val updateInfo = MutableStateFlow<UpdateInfo?>(null)
val downloading = MutableStateFlow(false)
val downloadProgress = MutableStateFlow(0)
val lastError = MutableStateFlow<String?>(null)
private var lastNotifiedVersion: String? = null // 记录已推送的版本,避免重复通知
fun start() {
scope.launch {
delay(5000) // 启动 5 秒后首检,给网络/应用初始化留时间
doCheck(throttle = false, toggle = true) // 启动必查(无视节流,但尊重开关)
while (isActive) {
delay(CHECK_INTERVAL_MS) // 常驻期间每 24h 巡检一次
doCheck(throttle = true, toggle = true)
}
}
}
/** 手动检查:无视节流和开关(用户主动)。 */
suspend fun check(force: Boolean): Result<UpdateInfo?> = doCheck(throttle = !force, toggle = !force)
private suspend fun doCheck(throttle: Boolean, toggle: Boolean): Result<UpdateInfo?> = withContext(Dispatchers.IO) {
runCatching {
val settings = app.settings
if (toggle && !settings.updateCheckEnabled.value) return@runCatching updateInfo.value
val now = System.currentTimeMillis()
if (throttle && now - settings.lastUpdateCheckTime.value < CHECK_INTERVAL_MS) return@runCatching updateInfo.value
// 先查 GitHub(公开默认源),从其 release notes 动态发现额外源(如自建 Gitea),取最高版本
// 同版本时 Gitea 优先(国内可达,GitHub 可能被墙导致下载失败)
val infos = mutableListOf<UpdateInfo>()
val gh = runCatching { fetchRelease(GITHUB_API) }.getOrNull()
if (gh != null) {
val giteaInfo = parseUpdateSource(gh.releaseNotes)?.let { runCatching { fetchRelease(it) }.getOrNull() }
// Gitea 放前面,同版本时优先用它下载(国内可达)
if (giteaInfo != null) infos.add(giteaInfo)
infos.add(gh)
}
settings.setLastUpdateCheck(now)
if (infos.isEmpty()) {
lastError.value = "所有更新源不可达"
return@runCatching updateInfo.value
}
val current = currentVersionName()
val best = infos
.filter { isNewer(it.latestVersion, current) && it.latestVersion != settings.ignoredVersion.value }
.maxByOrNull { versionKey(it.latestVersion) }
val prev = updateInfo.value
if (best == null) {
updateInfo.value = null
lastNotifiedVersion = null
} else {
updateInfo.value = best
}
lastError.value = null
// 发现新版且之前没推过通知 → 自动推送到所有已开启通道
if (best != null && best.latestVersion != lastNotifiedVersion) {
lastNotifiedVersion = best.latestVersion
notifyNewVersion(prev, best)
}
updateInfo.value
}.onFailure { lastError.value = it.message ?: "检查失败" }
}
private fun fetchRelease(api: String): UpdateInfo? {
val req = Request.Builder()
.url(api)
.header("Accept", "application/json")
.header("User-Agent", "gotmsg-android")
.get()
.build()
return http.newCall(req).execute().use { res ->
if (!res.isSuccessful) error("HTTP ${res.code}")
parseRelease(res.body?.string() ?: error("空响应"))
}
}
private fun versionKey(v: String): String =
v.split(".").joinToString(".") { (it.substringBefore("-").toIntOrNull() ?: 0).toString().padStart(4, '0') }
/** 发现新版后,自动通过所有已开启的推送通道通知用户 */
private fun notifyNewVersion(prev: UpdateInfo?, info: UpdateInfo) {
val s = app.settings
val title = "🆕 GotMsg 新版本 v${info.latestVersion}"
val body = "发现 v${info.latestVersion}(当前 v${currentVersionName()})\n${formatSize(info.apkSize)} · 点击更新\n\n${info.releaseNotes.take(300)}"
val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
// Bark:通过 BarkSender 推送给第一个启用的服务器
val barkServers = s.servers.value.filter { it.enabled && it.deviceKey.isNotBlank() }
if (barkServers.isNotEmpty()) {
scope.launch {
val msg = com.a2i.forwarder.model.BarkMessage(
deviceKey = "", title = title, body = body, level = "timeSensitive",
group = "a2i_update", isArchive = "1",
)
BarkSender.pushToFirst(barkServers, msg)
}
}
// ntfy
NtfySender.trySend(app, title, body, scope)
// Meow
MeowSender.trySend(app, title, body, scope)
// 电邮
EmailSender.trySend(app, title, body, scope)
}
private fun parseRelease(body: String): UpdateInfo? {
val json = JSONObject(body)
val tag = json.optString("tag_name").removePrefix("v").trim()
if (tag.isEmpty()) return null
val assets = json.optJSONArray("assets") ?: return null
var apkUrl = ""
var apkSize = 0L
for (i in 0 until assets.length()) {
val a = assets.optJSONObject(i) ?: continue
if (a.optString("name").endsWith(".apk", ignoreCase = true)) {
apkUrl = a.optString("browser_download_url")
apkSize = a.optLong("size")
break
}
}
if (apkUrl.isEmpty()) return null
return UpdateInfo(
latestVersion = tag,
apkUrl = apkUrl,
apkSize = apkSize,
releaseNotes = json.optString("body").orEmpty(),
releasePageUrl = json.optString("html_url").orEmpty(),
)
}
suspend fun download(info: UpdateInfo): File? = withContext(Dispatchers.IO) {
downloading.value = true
downloadProgress.value = 0
lastError.value = null
try {
val req = Request.Builder().url(info.apkUrl).get().build()
http.newCall(req).execute().use { res ->
if (!res.isSuccessful) { lastError.value = "HTTP ${res.code}"; return@use null }
val body = res.body ?: run { lastError.value = "空响应"; return@use null }
val total = body.contentLength()
val dir = context.getExternalFilesDir(null) ?: context.cacheDir
val target = File(dir, "gotmsg-${info.latestVersion}.apk")
target.outputStream().use { sink ->
val input = body.byteStream()
val buf = ByteArray(8 * 1024)
var read = 0L
while (true) {
val n = input.read(buf)
if (n <= 0) break
sink.write(buf, 0, n)
read += n
if (total > 0) downloadProgress.value = (read * 100 / total).toInt()
}
}
downloadProgress.value = 100
target
}
} catch (e: Exception) {
lastError.value = e.message ?: "下载失败"
null
} finally {
downloading.value = false
}
}
fun install(apk: File) {
if (!context.packageManager.canRequestPackageInstalls()) {
val intent = Intent(Settings.ACTION_MANAGE_UNKNOWN_APP_SOURCES).apply {
data = Uri.parse("package:${context.packageName}")
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
}
runCatching { context.startActivity(intent) }
return
}
val uri = FileProvider.getUriForFile(context, "${context.packageName}.fileprovider", apk)
val intent = Intent(Intent.ACTION_VIEW).apply {
setDataAndType(uri, "application/vnd.android.package-archive")
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
}
runCatching { context.startActivity(intent) }
}
fun ignoreVersion(v: String) {
scope.launch {
app.settings.setIgnoredVersion(v)
updateInfo.value = null
}
}
private fun currentVersionName(): String {
return runCatching {
@Suppress("DEPRECATION")
context.packageManager.getPackageInfo(context.packageName, 0).versionName ?: "0"
}.getOrDefault("0")
}
fun isNewer(latest: String, current: String): Boolean {
val l = latest.split(".").map { it.substringBefore("-").toIntOrNull() ?: 0 }
val c = current.split(".").map { it.substringBefore("-").toIntOrNull() ?: 0 }
val n = maxOf(l.size, c.size)
for (i in 0 until n) {
val lv = l.getOrElse(i) { 0 }
val cv = c.getOrElse(i) { 0 }
if (lv != cv) return lv > cv
}
return false
}
/** 从 release notes 的 HTML 注释里解析额外更新源(加扰编码,兼容 Base64/明文)。 */
private fun parseUpdateSource(body: String): String? {
val raw = Regex("""update-source:\s*(\S+)""").find(body)?.groupValues?.getOrNull(1) ?: return null
// 加扰格式:Base64(反转(XOR(明文, key)))。尝试完整解扰,失败则依次尝试 Base64/明文。
val b64 = runCatching { java.util.Base64.getDecoder().decode(raw) }.getOrNull() ?: return raw
val unscrambled = unscramble(b64)
return unscrambled ?: runCatching { String(b64, Charsets.UTF_8) }.getOrNull() ?: raw
}
/** 解扰:反转字节 → XOR 每字节 → UTF-8 字符串。 */
private fun unscramble(data: ByteArray): String? = runCatching {
val key = SCRAMBLE_KEY
val reversed = data.reversedArray()
String(ByteArray(reversed.size) { i -> ((reversed[i].toInt() and 0xFF) xor (key[i % key.size].toInt() and 0xFF)).toByte() }, Charsets.UTF_8)
}.getOrNull()
companion object {
const val GITHUB_API = "https://api.github.com/repos/lsxf/a2i/releases/latest"
const val CHECK_INTERVAL_MS = 24L * 60 * 60 * 1000
private val SCRAMBLE_KEY = byteArrayOf(0x4B, 0x6F, 0x72, 0x6E) // "Korn"
}
fun formatSize(bytes: Long): String {
if (bytes <= 0) return ""
val kb = bytes / 1024.0
return if (kb >= 1024) String.format("%.1f MB", kb / 1024) else String.format("%.0f KB", kb)
}
}
@@ -0,0 +1,22 @@
package com.a2i.forwarder.model
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
@Serializable
data class BarkMessage(
@SerialName("device_key") val deviceKey: String,
val body: String,
val title: String? = null,
val subtitle: String? = null,
val level: String? = null, // active / timeSensitive / critical / passive
val badge: Int? = null,
@SerialName("autoCopy") val autoCopy: String? = null, // "1" = 收到即复制
val copy: String? = null, // 复制内容
val sound: String? = null,
val icon: String? = null, // 公网图片 URL
val group: String? = null,
val url: String? = null, // 点击跳转
@SerialName("isArchive") val isArchive: String? = null, // "1" = 归档
val action: String? = null,
)
@@ -0,0 +1,55 @@
package com.a2i.forwarder.model
import kotlinx.serialization.Serializable
import java.util.UUID
@Serializable
data class BarkServer(
val id: String = UUID.randomUUID().toString(),
val name: String,
val server: String,
val deviceKey: String,
val enabled: Boolean = true,
)
const val OFFICIAL_BARK_SERVER = "https://api.day.app"
@Serializable
data class NtfyServer(
val id: String = UUID.randomUUID().toString(),
val name: String,
val server: String = "https://ntfy.sh",
val topic: String = "",
val token: String = "",
val enabled: Boolean = true,
)
@Serializable
data class MeowServer(
val id: String = UUID.randomUUID().toString(),
val name: String,
val server: String = "",
val deviceKey: String = "",
val enabled: Boolean = true,
)
@Serializable
data class EmailServer(
val id: String = UUID.randomUUID().toString(),
val name: String,
val host: String = "",
val port: Int = 465,
val user: String = "",
val password: String = "",
val from: String = "",
val to: String = "",
val enabled: Boolean = true,
)
@Serializable
data class SmsTarget(
val id: String = UUID.randomUUID().toString(),
val name: String,
val number: String = "",
val enabled: Boolean = true,
)
@@ -0,0 +1,87 @@
package com.a2i.forwarder.service
import android.service.notification.NotificationListenerService
import android.service.notification.StatusBarNotification
import com.a2i.forwarder.A2iApp
import com.a2i.forwarder.core.BarkSender
import com.a2i.forwarder.core.EmailSender
import com.a2i.forwarder.core.ForwardLog
import com.a2i.forwarder.core.MeowSender
import com.a2i.forwarder.core.NotificationProcessor
import com.a2i.forwarder.core.NtfySender
import com.a2i.forwarder.core.PendingPush
import com.a2i.forwarder.core.RetryWorker
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.launch
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
import java.util.UUID
class NotifyListenerService : NotificationListenerService() {
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
private val processor by lazy { NotificationProcessor(applicationContext) }
private val json = Json { encodeDefaults = true; ignoreUnknownKeys = true }
override fun onListenerConnected() {
super.onListenerConnected()
RetryWorker.schedule(applicationContext)
}
override fun onNotificationPosted(sbn: StatusBarNotification?) {
val posted = sbn ?: return
if (posted.packageName == packageName) return
scope.launch { handle(posted) }
}
private suspend fun handle(sbn: StatusBarNotification) {
val app = A2iApp.instance
val now = System.currentTimeMillis()
val d = processor.process(sbn)
val msg = d.message
if (msg == null) {
// 静默过滤(如持续性通知)不写日志
if (!d.silent) {
app.logStore.addLog(ForwardLog(now, sbn.packageName, d.appLabel, "", "", "filtered", d.reason))
}
return
}
// ---- 断网兜底:离线时紧要通知走短信,其余不转发避免离线堆积 ----
val online = app.connectivityMonitor.isOnline.value
if (!online) {
if (app.smsForwarder.isSmsWorthy(d)) {
app.smsForwarder.forward(d)
} else {
app.logStore.addLog(ForwardLog(now, sbn.packageName, d.appLabel, msg.title, msg.body, "filtered", "离线非紧要通知"))
}
return
}
val servers = app.settings.servers.value.filter { it.enabled && it.deviceKey.isNotBlank() }
if (servers.isEmpty()) {
app.logStore.addLog(ForwardLog(now, sbn.packageName, d.appLabel, msg.title, msg.body, "failed", "未配置可用 Bark 服务器"))
EmailSender.trySend(app, msg.title ?: d.appLabel, msg.body, scope)
NtfySender.trySend(app, msg.title ?: d.appLabel, msg.body, scope)
MeowSender.trySend(app, msg.title ?: d.appLabel, msg.body, scope)
return
}
val result = BarkSender.pushToEnabled(servers, msg, app.settings.barkStopOnFirst.value)
if (result.isSuccess) {
app.logStore.addLog(ForwardLog(now, sbn.packageName, d.appLabel, msg.title, msg.body, "sent", ""))
} else {
val reason = result.exceptionOrNull()?.message ?: "错误"
app.logStore.addLog(ForwardLog(now, sbn.packageName, d.appLabel, msg.title, msg.body, "failed", reason))
app.logStore.addPending(
PendingPush(UUID.randomUUID().toString(), now, servers.first().id, json.encodeToString(msg))
)
}
EmailSender.trySend(app, msg.title ?: d.appLabel, msg.body, scope)
NtfySender.trySend(app, msg.title ?: d.appLabel, msg.body, scope)
MeowSender.trySend(app, msg.title ?: d.appLabel, msg.body, scope)
}
}
@@ -0,0 +1,127 @@
package com.a2i.forwarder.store
import android.content.Context
import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.intPreferencesKey
import androidx.datastore.preferences.core.stringPreferencesKey
import androidx.datastore.preferences.core.stringSetPreferencesKey
import androidx.datastore.preferences.preferencesDataStore
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.launch
private val Context.rulesDataStore: DataStore<Preferences> by preferencesDataStore(name = "a2i_rules")
enum class RuleMode { BLACKLIST, WHITELIST }
class AppRulesStore(
private val context: Context,
scope: CoroutineScope,
) {
private val ds get() = context.rulesDataStore
private object K {
val MODE = stringPreferencesKey("mode")
val BLACK = stringSetPreferencesKey("blacklist")
val WHITE = stringSetPreferencesKey("whitelist")
val BL_VERSION = intPreferencesKey("bl_version")
}
val mode = MutableStateFlow(RuleMode.BLACKLIST)
val blacklist = MutableStateFlow<Set<String>>(defaultBlacklist)
val whitelist = MutableStateFlow<Set<String>>(emptySet())
init {
scope.launch {
ds.data.collect { p ->
mode.value = runCatching { RuleMode.valueOf(p[K.MODE] ?: "BLACKLIST") }.getOrDefault(RuleMode.BLACKLIST)
// 默认黑名单版本迁移:把新增的噪音包补进老用户的黑名单(只增不删)
val ver = p[K.BL_VERSION] ?: 1
var black = p[K.BLACK] ?: defaultBlacklist
if (ver < DEFAULT_BL_VERSION) {
black = black + blAdded
ds.edit { it[K.BLACK] = black; it[K.BL_VERSION] = DEFAULT_BL_VERSION }
}
blacklist.value = black
whitelist.value = p[K.WHITE] ?: emptySet()
}
}
}
fun isForwardingOn(packageName: String): Boolean = when (mode.value) {
RuleMode.BLACKLIST -> packageName !in blacklist.value
RuleMode.WHITELIST -> packageName in whitelist.value
}
suspend fun setMode(m: RuleMode) {
ds.edit { it[K.MODE] = m.name }
mode.value = m
}
suspend fun setForwarding(packageName: String, on: Boolean) {
when (mode.value) {
RuleMode.BLACKLIST -> {
val s = blacklist.value.toMutableSet()
if (on) s.remove(packageName) else s.add(packageName)
ds.edit { it[K.BLACK] = s }
blacklist.value = s
}
RuleMode.WHITELIST -> {
val s = whitelist.value.toMutableSet()
if (on) s.add(packageName) else s.remove(packageName)
ds.edit { it[K.WHITE] = s }
whitelist.value = s
}
}
}
companion object {
private const val DEFAULT_BL_VERSION = 3
// 默认黑名单增量(累积,用于老用户版本迁移,只补不删)
private val blAdded = setOf(
// v2
"com.android.soundrecorder", // 录音机
"com.google.android.apps.photos", // 相册
"com.xiaomi.finddevice", // 查找手机
"com.miui.passwords", // 密码管理
// v3:通话过程中的系统状态通知,会与来电(PhoneCallMonitor)重复
"com.android.server.telecom", // 通话管理
"com.xiaomi.aiasst.service", // AI通话
)
val defaultBlacklist = setOf(
// Android 系统
"com.android.systemui",
"com.android.settings",
"com.android.permissioncontroller",
// 注意:com.android.phone / com.android.incallui 不在黑名单中,电话通知需要转发
// 输入法
"com.google.android.inputmethod",
"com.google.android.inputmethod.latin",
"com.iflytek.inputmethod",
"com.baidu.input",
"com.sohu.inputmethod.sogou",
// 三星剪贴板 / 键盘(经常弹出 UniversalClipboardService 等空通知)
"com.samsung.android.honeyboard",
"com.samsung.android.app.clipboard",
"com.samsung.android.clipboard",
"com.samsung.android.rubin.app",
// Google Play Services(经常发内部状态通知)
"com.google.android.gms",
"com.google.android.gms.persistent",
// a2i 自身
"com.a2i.forwarder",
// [v2] 纯噪音 App:转发无意义,默认屏蔽
"com.android.soundrecorder", // 录音机
"com.google.android.apps.photos", // 相册
"com.xiaomi.finddevice", // 查找手机
"com.miui.passwords", // 密码管理
// [v3] 通话状态噪音:来电由 PhoneCallMonitor 独立处理,这两个会重复
"com.android.server.telecom", // 通话管理
"com.xiaomi.aiasst.service", // AI通话
)
}
}
@@ -0,0 +1,359 @@
package com.a2i.forwarder.store
import android.content.Context
import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.booleanPreferencesKey
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.intPreferencesKey
import androidx.datastore.preferences.core.longPreferencesKey
import androidx.datastore.preferences.core.stringPreferencesKey
import androidx.datastore.preferences.preferencesDataStore
import com.a2i.forwarder.model.BarkServer
import com.a2i.forwarder.model.EmailServer
import com.a2i.forwarder.model.MeowServer
import com.a2i.forwarder.model.NtfyServer
import com.a2i.forwarder.model.OFFICIAL_BARK_SERVER
import com.a2i.forwarder.model.SmsTarget
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
import kotlinx.serialization.decodeFromString
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
private val Context.settingsDataStore: DataStore<Preferences> by preferencesDataStore(name = "a2i_settings")
class SettingsStore(
private val context: Context,
scope: CoroutineScope,
) {
private val ds get() = context.settingsDataStore
private val json = Json { ignoreUnknownKeys = true; encodeDefaults = true }
private object K {
val SERVERS = stringPreferencesKey("servers")
val ICON_PREFIX = stringPreferencesKey("icon_prefix")
val FWD = booleanPreferencesKey("forwarding")
val CODE = booleanPreferencesKey("code")
val AD = booleanPreferencesKey("ad_filter")
val SPECIAL = booleanPreferencesKey("special")
val SKIP_ONGOING = booleanPreferencesKey("skip_ongoing")
val AD_KW = stringPreferencesKey("ad_keywords")
// 各通道「发通一条止 / 全发」开关
val BARK_STOP_FIRST = booleanPreferencesKey("bark_stop_first")
val NTFY_STOP_FIRST = booleanPreferencesKey("ntfy_stop_first")
val MEOW_STOP_FIRST = booleanPreferencesKey("meow_stop_first")
val EMAIL_STOP_FIRST = booleanPreferencesKey("email_stop_first")
val SMS_STOP_FIRST = booleanPreferencesKey("sms_stop_first")
// 短信兜底(目标号已列表化;运营商/余额/限频保持全局)
val SMS_FALLBACK = booleanPreferencesKey("sms_fallback")
val SMS_TARGETS = stringPreferencesKey("sms_targets")
val SMS_CARRIER = stringPreferencesKey("sms_carrier")
val SMS_QUOTA = intPreferencesKey("sms_quota")
val SMS_BALANCE = intPreferencesKey("sms_balance")
val SMS_SUSPENDED = booleanPreferencesKey("sms_suspended")
val SMS_LAST_SENT = longPreferencesKey("sms_last_sent")
// 电邮转发
val EMAIL_ENABLED = booleanPreferencesKey("email_enabled")
val EMAIL_SERVERS = stringPreferencesKey("email_servers")
val NTFY_SERVERS = stringPreferencesKey("ntfy_servers")
val MEOW_SERVERS = stringPreferencesKey("meow_servers")
// 应用更新
val UPDATE_CHECK_AUTO = booleanPreferencesKey("update_check_auto")
val LAST_UPDATE_CHECK = longPreferencesKey("last_update_check")
val IGNORED_VERSION = stringPreferencesKey("ignored_version")
// 配置版本(用于旧单组配置迁移)
val CONFIG_VERSION = intPreferencesKey("config_version")
// ---- 旧单组键(仅迁移用,迁移后清理)----
val SMS_TARGET = stringPreferencesKey("sms_target")
val EMAIL_HOST = stringPreferencesKey("email_smtp_host")
val EMAIL_PORT = intPreferencesKey("email_smtp_port")
val EMAIL_USER = stringPreferencesKey("email_smtp_user")
val EMAIL_PASS = stringPreferencesKey("email_smtp_pass")
val EMAIL_FROM = stringPreferencesKey("email_from")
val EMAIL_TO = stringPreferencesKey("email_to")
val NTFY_SERVER = stringPreferencesKey("ntfy_server")
val NTFY_TOPIC = stringPreferencesKey("ntfy_topic")
val NTFY_TOKEN = stringPreferencesKey("ntfy_token")
}
private val defaultServers = listOf(
BarkServer(id = ID_OFFICIAL, name = "Bark 官方", server = OFFICIAL_BARK_SERVER, deviceKey = "")
)
private val _servers = MutableStateFlow(defaultServers)
val servers = _servers.asStateFlow()
private val _ntfyServers = MutableStateFlow<List<NtfyServer>>(emptyList())
val ntfyServers = _ntfyServers.asStateFlow()
private val _meowServers = MutableStateFlow<List<MeowServer>>(emptyList())
val meowServers = _meowServers.asStateFlow()
private val _emailServers = MutableStateFlow<List<EmailServer>>(emptyList())
val emailServers = _emailServers.asStateFlow()
private val _smsTargets = MutableStateFlow<List<SmsTarget>>(emptyList())
val smsTargets = _smsTargets.asStateFlow()
val iconPrefix = MutableStateFlow(DEFAULT_ICON_PREFIX)
val forwardingEnabled = MutableStateFlow(true)
val codeEnabled = MutableStateFlow(true)
val adFilterEnabled = MutableStateFlow(true)
val specialEnabled = MutableStateFlow(true)
val skipOngoing = MutableStateFlow(true)
val adKeywords = MutableStateFlow<List<String>>(emptyList())
// 各通道「发通一条止 / 全发」
val barkStopOnFirst = MutableStateFlow(true)
val ntfyStopOnFirst = MutableStateFlow(false)
val meowStopOnFirst = MutableStateFlow(false)
val emailStopOnFirst = MutableStateFlow(false)
val smsStopOnFirst = MutableStateFlow(false)
// 短信兜底(全局)
val smsFallbackEnabled = MutableStateFlow(false)
val smsCarrier = MutableStateFlow("auto")
val smsManualQuota = MutableStateFlow(0)
val smsBalance = MutableStateFlow(-1)
val smsSuspended = MutableStateFlow(false)
val lastSmsSentTime = MutableStateFlow(0L)
// 电邮总开关
val emailForwardingEnabled = MutableStateFlow(false)
// 应用更新
val updateCheckEnabled = MutableStateFlow(true)
val lastUpdateCheckTime = MutableStateFlow(0L)
val ignoredVersion = MutableStateFlow("")
init {
scope.launch {
ds.data.collect { p ->
val list: List<BarkServer> = p[K.SERVERS]
?.let { runCatching { json.decodeFromString<List<BarkServer>>(it) }.getOrNull() }
?: defaultServers
_servers.value = list
iconPrefix.value = p[K.ICON_PREFIX] ?: DEFAULT_ICON_PREFIX
forwardingEnabled.value = p[K.FWD] ?: true
codeEnabled.value = p[K.CODE] ?: true
adFilterEnabled.value = p[K.AD] ?: true
specialEnabled.value = p[K.SPECIAL] ?: true
skipOngoing.value = p[K.SKIP_ONGOING] ?: true
adKeywords.value = p[K.AD_KW]
?.let { runCatching { json.decodeFromString<List<String>>(it) }.getOrNull() }
?: emptyList()
// 各通道 stopOnFirst
barkStopOnFirst.value = p[K.BARK_STOP_FIRST] ?: true
ntfyStopOnFirst.value = p[K.NTFY_STOP_FIRST] ?: false
meowStopOnFirst.value = p[K.MEOW_STOP_FIRST] ?: false
emailStopOnFirst.value = p[K.EMAIL_STOP_FIRST] ?: false
smsStopOnFirst.value = p[K.SMS_STOP_FIRST] ?: false
// 短信兜底全局
smsFallbackEnabled.value = p[K.SMS_FALLBACK] ?: false
smsCarrier.value = p[K.SMS_CARRIER] ?: "auto"
smsManualQuota.value = p[K.SMS_QUOTA] ?: 0
smsBalance.value = p[K.SMS_BALANCE] ?: -1
smsSuspended.value = p[K.SMS_SUSPENDED] ?: false
lastSmsSentTime.value = p[K.SMS_LAST_SENT] ?: 0L
// 电邮总开关
emailForwardingEnabled.value = p[K.EMAIL_ENABLED] ?: false
// 应用更新
updateCheckEnabled.value = p[K.UPDATE_CHECK_AUTO] ?: true
lastUpdateCheckTime.value = p[K.LAST_UPDATE_CHECK] ?: 0L
ignoredVersion.value = p[K.IGNORED_VERSION] ?: ""
// 列表配置
_ntfyServers.value = p[K.NTFY_SERVERS]
?.let { runCatching { json.decodeFromString<List<NtfyServer>>(it) }.getOrNull() }
?: emptyList()
_meowServers.value = p[K.MEOW_SERVERS]
?.let { runCatching { json.decodeFromString<List<MeowServer>>(it) }.getOrNull() }
?: emptyList()
_emailServers.value = p[K.EMAIL_SERVERS]
?.let { runCatching { json.decodeFromString<List<EmailServer>>(it) }.getOrNull() }
?: emptyList()
_smsTargets.value = p[K.SMS_TARGETS]
?.let { runCatching { json.decodeFromString<List<SmsTarget>>(it) }.getOrNull() }
?: emptyList()
// ---- 旧单组配置迁移(config_version < 2)----
val cfgVer = p[K.CONFIG_VERSION] ?: 1
if (cfgVer < 2) {
migrateLegacyConfigs(p)
}
}
}
}
/** 把旧的单组 ntfy/email/sms 配置转成列表的一条。 */
private suspend fun migrateLegacyConfigs(p: Preferences) {
var ntfyList = _ntfyServers.value
var emailList = _emailServers.value
var smsList = _smsTargets.value
val nTopic = p[K.NTFY_TOPIC]
if (!nTopic.isNullOrBlank() && ntfyList.isEmpty()) {
ntfyList = listOf(NtfyServer(name = "ntfy", server = p[K.NTFY_SERVER] ?: "https://ntfy.sh", topic = nTopic, token = p[K.NTFY_TOKEN] ?: ""))
}
val eHost = p[K.EMAIL_HOST]
val eTo = p[K.EMAIL_TO]
if (!eHost.isNullOrBlank() && emailList.isEmpty()) {
emailList = listOf(EmailServer(
name = "邮箱", host = eHost,
port = (p[K.EMAIL_PORT] ?: 465).let { if (it < 25) 465 else it },
user = p[K.EMAIL_USER] ?: "", password = p[K.EMAIL_PASS] ?: "",
from = p[K.EMAIL_FROM] ?: "", to = eTo ?: "",
))
}
val sTarget = p[K.SMS_TARGET]
if (!sTarget.isNullOrBlank() && smsList.isEmpty()) {
smsList = listOf(SmsTarget(name = "iPhone", number = sTarget))
}
ds.edit { m ->
if (ntfyList.isNotEmpty()) {
m[K.NTFY_SERVERS] = json.encodeToString(ntfyList)
m.remove(K.NTFY_SERVER); m.remove(K.NTFY_TOPIC); m.remove(K.NTFY_TOKEN)
}
if (emailList.isNotEmpty()) {
m[K.EMAIL_SERVERS] = json.encodeToString(emailList)
listOf(K.EMAIL_HOST, K.EMAIL_PORT, K.EMAIL_USER, K.EMAIL_PASS, K.EMAIL_FROM, K.EMAIL_TO).forEach { m.remove(it) }
}
if (smsList.isNotEmpty()) {
m[K.SMS_TARGETS] = json.encodeToString(smsList)
m.remove(K.SMS_TARGET)
}
m[K.CONFIG_VERSION] = 2
}
_ntfyServers.value = ntfyList
_emailServers.value = emailList
_smsTargets.value = smsList
}
private suspend fun edit(block: (androidx.datastore.preferences.core.MutablePreferences) -> Unit) =
ds.edit(block)
// ---- Bark 服务器 CRUD ----
private suspend fun saveServers(list: List<BarkServer>) {
edit { it[K.SERVERS] = json.encodeToString(list) }
_servers.value = list
}
suspend fun addServer(name: String, server: String, key: String): BarkServer {
val s = BarkServer(name = name.trim(), server = server.trim().removeSuffix("/"), deviceKey = key.trim())
saveServers(_servers.value + s)
return s
}
suspend fun updateServer(s: BarkServer) = saveServers(_servers.value.map { if (it.id == s.id) s.copy(server = s.server.trim().removeSuffix("/")) else it })
suspend fun deleteServer(id: String) = saveServers(_servers.value.filterNot { it.id == id })
suspend fun moveServer(id: String, up: Boolean) {
val list = _servers.value.toMutableList()
val i = list.indexOfFirst { it.id == id }
if (i in 1..list.lastIndex && up) { java.util.Collections.swap(list, i, i - 1); saveServers(list) }
else if (i in 0 until list.lastIndex && !up) { java.util.Collections.swap(list, i, i + 1); saveServers(list) }
}
suspend fun setServerEnabled(id: String, enabled: Boolean) = saveServers(_servers.value.map { if (it.id == id) it.copy(enabled = enabled) else it })
// ---- ntfy 服务器 CRUD ----
private suspend fun saveNtfyServers(list: List<NtfyServer>) {
edit { it[K.NTFY_SERVERS] = json.encodeToString(list) }
_ntfyServers.value = list
}
suspend fun addNtfy(s: NtfyServer) { saveNtfyServers(_ntfyServers.value + s) }
suspend fun updateNtfy(s: NtfyServer) = saveNtfyServers(_ntfyServers.value.map { if (it.id == s.id) s else it })
suspend fun deleteNtfy(id: String) = saveNtfyServers(_ntfyServers.value.filterNot { it.id == id })
suspend fun moveNtfy(id: String, up: Boolean) {
val list = _ntfyServers.value.toMutableList()
val i = list.indexOfFirst { it.id == id }
if (i in 1..list.lastIndex && up) { java.util.Collections.swap(list, i, i - 1); saveNtfyServers(list) }
else if (i in 0 until list.lastIndex && !up) { java.util.Collections.swap(list, i, i + 1); saveNtfyServers(list) }
}
suspend fun setNtfyEnabled(id: String, enabled: Boolean) = saveNtfyServers(_ntfyServers.value.map { if (it.id == id) it.copy(enabled = enabled) else it })
// ---- Meow 服务器 CRUD ----
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 ----
private suspend fun saveEmailServers(list: List<EmailServer>) {
edit { it[K.EMAIL_SERVERS] = json.encodeToString(list) }
_emailServers.value = list
}
suspend fun addEmail(s: EmailServer) { saveEmailServers(_emailServers.value + s) }
suspend fun updateEmail(s: EmailServer) = saveEmailServers(_emailServers.value.map { if (it.id == s.id) s else it })
suspend fun deleteEmail(id: String) = saveEmailServers(_emailServers.value.filterNot { it.id == id })
suspend fun moveEmail(id: String, up: Boolean) {
val list = _emailServers.value.toMutableList()
val i = list.indexOfFirst { it.id == id }
if (i in 1..list.lastIndex && up) { java.util.Collections.swap(list, i, i - 1); saveEmailServers(list) }
else if (i in 0 until list.lastIndex && !up) { java.util.Collections.swap(list, i, i + 1); saveEmailServers(list) }
}
suspend fun setEmailEnabled(id: String, enabled: Boolean) = saveEmailServers(_emailServers.value.map { if (it.id == id) it.copy(enabled = enabled) else it })
// ---- 短信目标 CRUD ----
private suspend fun saveSmsTargets(list: List<SmsTarget>) {
edit { it[K.SMS_TARGETS] = json.encodeToString(list) }
_smsTargets.value = list
}
suspend fun addSmsTarget(s: SmsTarget) { saveSmsTargets(_smsTargets.value + s) }
suspend fun updateSmsTarget(s: SmsTarget) = saveSmsTargets(_smsTargets.value.map { if (it.id == s.id) s else it })
suspend fun deleteSmsTarget(id: String) = saveSmsTargets(_smsTargets.value.filterNot { it.id == id })
suspend fun moveSmsTarget(id: String, up: Boolean) {
val list = _smsTargets.value.toMutableList()
val i = list.indexOfFirst { it.id == id }
if (i in 1..list.lastIndex && up) { java.util.Collections.swap(list, i, i - 1); saveSmsTargets(list) }
else if (i in 0 until list.lastIndex && !up) { java.util.Collections.swap(list, i, i + 1); saveSmsTargets(list) }
}
suspend fun setSmsTargetEnabled(id: String, enabled: Boolean) = saveSmsTargets(_smsTargets.value.map { if (it.id == id) it.copy(enabled = enabled) else it })
suspend fun setIconPrefix(v: String) { edit { it[K.ICON_PREFIX] = v }; iconPrefix.value = v }
suspend fun setForwarding(v: Boolean) { edit { it[K.FWD] = v }; forwardingEnabled.value = v }
suspend fun setCode(v: Boolean) { edit { it[K.CODE] = v }; codeEnabled.value = v }
suspend fun setAdFilter(v: Boolean) { edit { it[K.AD] = v }; adFilterEnabled.value = v }
suspend fun setSpecial(v: Boolean) { edit { it[K.SPECIAL] = v }; specialEnabled.value = v }
suspend fun setSkipOngoing(v: Boolean) { edit { it[K.SKIP_ONGOING] = v }; skipOngoing.value = v }
suspend fun setAdKeywords(list: List<String>) {
edit { it[K.AD_KW] = json.encodeToString(list) }
adKeywords.value = list
}
suspend fun setBarkStopOnFirst(v: Boolean) { edit { it[K.BARK_STOP_FIRST] = v }; barkStopOnFirst.value = v }
suspend fun setNtfyStopOnFirst(v: Boolean) { edit { it[K.NTFY_STOP_FIRST] = v }; ntfyStopOnFirst.value = v }
suspend fun setMeowStopOnFirst(v: Boolean) { edit { it[K.MEOW_STOP_FIRST] = v }; meowStopOnFirst.value = v }
suspend fun setEmailStopOnFirst(v: Boolean) { edit { it[K.EMAIL_STOP_FIRST] = v }; emailStopOnFirst.value = v }
suspend fun setSmsStopOnFirst(v: Boolean) { edit { it[K.SMS_STOP_FIRST] = v }; smsStopOnFirst.value = v }
suspend fun setSmsFallback(v: Boolean) { edit { it[K.SMS_FALLBACK] = v }; smsFallbackEnabled.value = v }
suspend fun setSmsCarrier(v: String) { edit { it[K.SMS_CARRIER] = v }; smsCarrier.value = v }
suspend fun setSmsManualQuota(v: Int) { edit { it[K.SMS_QUOTA] = v }; smsManualQuota.value = v }
suspend fun setSmsBalance(v: Int) { edit { it[K.SMS_BALANCE] = v }; smsBalance.value = v }
suspend fun setSmsSuspended(v: Boolean) { edit { it[K.SMS_SUSPENDED] = v }; smsSuspended.value = v }
suspend fun setLastSmsSentTime(v: Long) { edit { it[K.SMS_LAST_SENT] = v }; lastSmsSentTime.value = v }
suspend fun setUpdateCheckEnabled(v: Boolean) { edit { it[K.UPDATE_CHECK_AUTO] = v }; updateCheckEnabled.value = v }
suspend fun setLastUpdateCheck(v: Long) { edit { it[K.LAST_UPDATE_CHECK] = v }; lastUpdateCheckTime.value = v }
suspend fun setIgnoredVersion(v: String) { edit { it[K.IGNORED_VERSION] = v }; ignoredVersion.value = v }
suspend fun setEmailForwarding(v: Boolean) { edit { it[K.EMAIL_ENABLED] = v }; emailForwardingEnabled.value = v }
suspend fun snapshot() = ds.data.first()
companion object {
const val ID_OFFICIAL = "official"
const val DEFAULT_ICON_PREFIX = "https://raw.githubusercontent.com/lsxf/a2i/main/icons/"
}
}
@@ -0,0 +1,290 @@
package com.a2i.forwarder.ui
import android.content.Context
import android.graphics.Bitmap
import android.graphics.Canvas
import android.graphics.drawable.BitmapDrawable
import android.graphics.drawable.Drawable
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.background
import androidx.compose.foundation.Image
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ChevronRight
import androidx.compose.material.icons.outlined.Circle
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Switch
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.ImageBitmap
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.core.app.NotificationManagerCompat
@Composable
fun ScreenTitle(
text: String,
subtitle: String? = null,
modifier: Modifier = Modifier,
) {
Column(modifier.padding(top = 18.dp, bottom = 14.dp)) {
Text(
text,
style = MaterialTheme.typography.headlineMedium,
color = MaterialTheme.colorScheme.onBackground,
)
if (!subtitle.isNullOrBlank()) {
Text(
subtitle,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(top = 4.dp),
)
}
}
}
@Composable
fun SectionCard(
title: String? = null,
modifier: Modifier = Modifier,
subtitle: String? = null,
titleIcon: ImageVector? = null,
titleIconTint: Color = MaterialTheme.colorScheme.primary,
headerAction: (@Composable () -> Unit)? = null,
content: @Composable ColumnScope.() -> Unit,
) {
Card(
modifier = modifier.fillMaxWidth(),
shape = RoundedCornerShape(8.dp),
border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant),
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface),
elevation = CardDefaults.cardElevation(defaultElevation = 0.dp),
) {
Column(Modifier.padding(16.dp)) {
if (title != null) {
if (headerAction == null) {
Row(verticalAlignment = Alignment.CenterVertically) {
if (titleIcon != null) {
Icon(titleIcon, null, tint = titleIconTint, modifier = Modifier.size(22.dp))
Spacer(Modifier.width(8.dp))
}
Text(
title,
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold,
modifier = Modifier.padding(bottom = if (subtitle == null) 10.dp else 2.dp),
)
}
if (subtitle != null) {
Text(
subtitle,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(bottom = 10.dp),
)
}
} 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()
}
}
}
@Composable
fun SwitchRow(
title: String,
subtitle: String? = null,
checked: Boolean,
icon: ImageVector? = null,
tint: Color = MaterialTheme.colorScheme.primary,
onChange: (Boolean) -> Unit,
) {
Row(
Modifier
.fillMaxWidth()
.heightIn(min = 56.dp)
.padding(vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
if (icon != null) {
IconBox(icon = icon, tint = tint)
Spacer(Modifier.width(12.dp))
}
Column(Modifier.weight(1f)) {
Text(title, style = MaterialTheme.typography.bodyLarge, fontWeight = FontWeight.Medium)
if (!subtitle.isNullOrBlank()) {
Text(
subtitle,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
Spacer(Modifier.width(12.dp))
Switch(checked = checked, onCheckedChange = onChange)
}
}
@Composable
fun ClickRow(
title: String,
subtitle: String? = null,
icon: ImageVector? = null,
tint: Color = MaterialTheme.colorScheme.primary,
onClick: () -> Unit,
) {
Row(
Modifier
.fillMaxWidth()
.clickable(onClick = onClick)
.heightIn(min = 56.dp)
.padding(vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
if (icon != null) {
IconBox(icon = icon, tint = tint)
Spacer(Modifier.width(12.dp))
}
Column(Modifier.weight(1f)) {
Text(title, style = MaterialTheme.typography.bodyLarge, fontWeight = FontWeight.Medium)
if (!subtitle.isNullOrBlank()) {
Text(
subtitle,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
Icon(Icons.Filled.ChevronRight, contentDescription = null, tint = MaterialTheme.colorScheme.onSurfaceVariant)
}
}
@Composable
fun IconBox(
icon: ImageVector = Icons.Outlined.Circle,
tint: Color = MaterialTheme.colorScheme.primary,
modifier: Modifier = Modifier,
) {
Box(
modifier = modifier
.size(38.dp)
.background(tint.copy(alpha = 0.12f), RoundedCornerShape(8.dp)),
contentAlignment = Alignment.Center,
) {
Icon(icon, contentDescription = null, tint = tint, modifier = Modifier.size(20.dp))
}
}
@Composable
fun StatusBadge(
text: String,
tint: Color,
modifier: Modifier = Modifier,
) {
Surface(
modifier = modifier,
shape = RoundedCornerShape(6.dp),
color = tint.copy(alpha = 0.12f),
contentColor = tint,
) {
Text(
text,
style = MaterialTheme.typography.labelMedium,
modifier = Modifier.padding(horizontal = 9.dp, vertical = 5.dp),
)
}
}
@Composable
fun MetricBlock(
label: String,
value: String,
tint: Color,
modifier: Modifier = Modifier,
) {
Surface(
modifier = modifier,
shape = RoundedCornerShape(8.dp),
color = tint.copy(alpha = 0.10f),
border = BorderStroke(1.dp, tint.copy(alpha = 0.18f)),
) {
Column(Modifier.padding(12.dp)) {
Text(value, style = MaterialTheme.typography.titleLarge, color = tint, fontWeight = FontWeight.Bold)
Text(label, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
}
}
}
fun isNotificationListenerEnabled(context: Context): Boolean {
return NotificationManagerCompat.getEnabledListenerPackages(context).contains(context.packageName)
}
/** Reads the app versionName and returns fallback if PackageManager lookup fails. */
fun appVersionName(context: Context, fallback: String = "unknown"): String {
return runCatching {
@Suppress("DEPRECATION")
context.packageManager.getPackageInfo(context.packageName, 0).versionName ?: fallback
}.getOrDefault(fallback)
}
fun drawableToImageBitmap(d: Drawable): ImageBitmap {
if (d is BitmapDrawable && d.bitmap != null) return d.bitmap.asImageBitmap()
val size = maxOf(d.intrinsicWidth, d.intrinsicHeight, 96).coerceAtLeast(1)
val bmp = Bitmap.createBitmap(size, size, Bitmap.Config.ARGB_8888)
val canvas = Canvas(bmp)
d.setBounds(0, 0, canvas.width, canvas.height)
d.draw(canvas)
return bmp.asImageBitmap()
}
@@ -0,0 +1,171 @@
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.size
import androidx.compose.foundation.shape.CircleShape
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.FilterAlt
import androidx.compose.material.icons.outlined.Notifications
import androidx.compose.material.icons.outlined.Settings
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.NavigationBar
import androidx.compose.material3.NavigationBarItem
import androidx.compose.material3.NavigationBarItemDefaults
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.unit.dp
import androidx.navigation.NavGraph.Companion.findStartDestination
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
import androidx.navigation.compose.currentBackStackEntryAsState
import androidx.navigation.compose.rememberNavController
import com.a2i.forwarder.A2iApp
import com.a2i.forwarder.ui.screens.AppManageScreen
import com.a2i.forwarder.ui.screens.FilterScreen
import com.a2i.forwarder.ui.screens.HomeScreen
import com.a2i.forwarder.ui.screens.LogScreen
import com.a2i.forwarder.ui.screens.SettingsScreen
import com.a2i.forwarder.ui.screens.UpdateDialog
private sealed class Tab(val route: String, val label: String, val icon: ImageVector) {
object Home : Tab("home", "首页", Icons.Outlined.Notifications)
object Apps : Tab("apps", "应用", Icons.Outlined.Apps)
object Filter : Tab("filter", "过滤", Icons.Outlined.FilterAlt)
object Settings : Tab("settings", "设置", Icons.Outlined.Settings)
}
private val tabs = listOf(Tab.Home, Tab.Apps, Tab.Filter, Tab.Settings)
private val tabRoutes = tabs.map { it.route }.toSet()
@Composable
fun AppRoot() {
val nav = rememberNavController()
val backStack by nav.currentBackStackEntryAsState()
val current = backStack?.destination?.route
val showBar = current in tabRoutes
val app = A2iApp.instance
val updateInfo by app.updateChecker.updateInfo.collectAsState()
var showUpdateDialog by remember { mutableStateOf(false) }
Box {
Scaffold(
containerColor = MaterialTheme.colorScheme.background,
bottomBar = {
if (showBar) {
NavigationBar(
containerColor = MaterialTheme.colorScheme.surface,
tonalElevation = 0.dp,
) {
tabs.forEach { t ->
NavigationBarItem(
selected = current == t.route,
onClick = {
nav.navigate(t.route) {
popUpTo(nav.graph.findStartDestination().id) { saveState = true }
launchSingleTop = true
restoreState = true
}
},
icon = { Icon(t.icon, t.label) },
label = { Text(t.label) },
colors = NavigationBarItemDefaults.colors(
selectedIconColor = MaterialTheme.colorScheme.primary,
selectedTextColor = MaterialTheme.colorScheme.primary,
indicatorColor = MaterialTheme.colorScheme.primaryContainer,
unselectedIconColor = MaterialTheme.colorScheme.onSurfaceVariant,
unselectedTextColor = MaterialTheme.colorScheme.onSurfaceVariant,
),
)
}
}
}
},
) { inner ->
NavHost(
navController = nav,
startDestination = Tab.Home.route,
modifier = Modifier.padding(inner),
) {
composable(Tab.Home.route) {
HomeScreen(
onOpenLog = { nav.navigate("log") },
onOpenSettings = { nav.navigate(Tab.Settings.route) },
)
}
composable(Tab.Apps.route) { AppManageScreen() }
composable(Tab.Filter.route) { FilterScreen() }
composable(Tab.Settings.route) { SettingsScreen(onOpenLog = { nav.navigate("log") }) }
composable("log") { LogScreen(onBack = { nav.popBackStack() }) }
}
}
// 浮动更新提醒小图标(跨所有页面,缓慢闪烁)
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),
)
}
}
@@ -0,0 +1,222 @@
package com.a2i.forwarder.ui.screens
import android.content.Context
import android.content.pm.ApplicationInfo
import android.content.pm.PackageManager
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Search
import androidx.compose.material.icons.filled.Tune
import androidx.compose.material.icons.outlined.Apps
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.FilterChip
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Surface
import androidx.compose.material3.Switch
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.ImageBitmap
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import com.a2i.forwarder.A2iApp
import com.a2i.forwarder.store.RuleMode
import com.a2i.forwarder.ui.IconBox
import com.a2i.forwarder.ui.ScreenTitle
import com.a2i.forwarder.ui.SectionCard
import com.a2i.forwarder.ui.StatusBadge
import com.a2i.forwarder.ui.SwitchRow
import com.a2i.forwarder.ui.drawableToImageBitmap
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
private data class AppInfo(val pkg: String, val label: String, val icon: ImageBitmap?, val system: Boolean)
@OptIn(ExperimentalLayoutApi::class)
@Composable
fun AppManageScreen() {
val context = LocalContext.current
val app = A2iApp.instance
val mode by app.appRules.mode.collectAsState()
val blacklist by app.appRules.blacklist.collectAsState()
val whitelist by app.appRules.whitelist.collectAsState()
var showSystem by remember { mutableStateOf(false) }
var query by remember { mutableStateOf("") }
var apps by remember { mutableStateOf<List<AppInfo>>(emptyList()) }
var loading by remember { mutableStateOf(true) }
LaunchedEffect(Unit) {
loading = true
apps = loadApps(context)
loading = false
}
val visible = apps.filter {
(showSystem || !it.system) &&
(query.isBlank() || it.label.contains(query, true) || it.pkg.contains(query, true))
}
LazyColumn(
modifier = Modifier.fillMaxSize().padding(horizontal = 16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
item { ScreenTitle("应用转发", "按应用控制通知是否进入转发链路") }
item {
SectionCard("规则模式", subtitle = "黑名单适合默认转发;白名单适合只转发少数 App") {
FlowRow(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
FilterChip(
selected = mode == RuleMode.BLACKLIST,
onClick = { app.appScope.launch { app.appRules.setMode(RuleMode.BLACKLIST) } },
label = { Text("黑名单模式") },
leadingIcon = { Icon(Icons.Filled.Tune, null, modifier = Modifier.size(18.dp)) },
)
FilterChip(
selected = mode == RuleMode.WHITELIST,
onClick = { app.appScope.launch { app.appRules.setMode(RuleMode.WHITELIST) } },
label = { Text("白名单模式") },
leadingIcon = { Icon(Icons.Outlined.Apps, null, modifier = Modifier.size(18.dp)) },
)
}
SwitchRow(
title = "显示系统应用",
subtitle = if (showSystem) "列表包含系统服务和预装组件" else "当前只显示用户安装的应用",
checked = showSystem,
icon = Icons.Outlined.Apps,
tint = MaterialTheme.colorScheme.secondary,
) { showSystem = it }
}
}
item {
OutlinedTextField(
value = query,
onValueChange = { query = it },
label = { Text("搜索应用或包名") },
leadingIcon = { Icon(Icons.Filled.Search, null) },
modifier = Modifier.fillMaxWidth(),
singleLine = true,
)
}
item {
Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
Text(
if (loading) "正在读取应用列表" else "显示 ${visible.size} / ${apps.size} 个应用",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.weight(1f),
)
StatusBadge(
text = if (mode == RuleMode.BLACKLIST) "默认转发" else "仅白名单",
tint = MaterialTheme.colorScheme.primary,
)
}
}
if (loading) {
item {
Box(Modifier.fillMaxWidth().padding(32.dp), contentAlignment = Alignment.Center) {
CircularProgressIndicator()
}
}
} else {
items(visible, key = { it.pkg }) { ai ->
val on = when (mode) {
RuleMode.BLACKLIST -> ai.pkg !in blacklist
RuleMode.WHITELIST -> ai.pkg in whitelist
}
AppRow(ai, on) { v -> app.appScope.launch { app.appRules.setForwarding(ai.pkg, v) } }
}
}
}
}
@Composable
private fun AppRow(ai: AppInfo, on: Boolean, onChange: (Boolean) -> Unit) {
Surface(
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(8.dp),
color = MaterialTheme.colorScheme.surface,
border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant),
) {
Row(
Modifier.fillMaxWidth().padding(12.dp),
verticalAlignment = Alignment.CenterVertically,
) {
if (ai.icon != null) {
Image(bitmap = ai.icon, contentDescription = null, modifier = Modifier.size(42.dp))
} else {
IconBox(icon = Icons.Outlined.Apps, tint = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.size(42.dp))
}
Spacer(Modifier.width(12.dp))
Column(Modifier.weight(1f)) {
Row(verticalAlignment = Alignment.CenterVertically) {
Text(
ai.label,
style = MaterialTheme.typography.bodyLarge,
fontWeight = FontWeight.Medium,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f),
)
if (ai.system) {
Spacer(Modifier.width(8.dp))
StatusBadge("系统", MaterialTheme.colorScheme.onSurfaceVariant)
}
}
Text(
ai.pkg,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
Spacer(Modifier.width(12.dp))
Switch(checked = on, onCheckedChange = onChange)
}
}
}
private suspend fun loadApps(context: Context): List<AppInfo> = withContext(Dispatchers.IO) {
val pm = context.packageManager
pm.getInstalledApplications(PackageManager.GET_META_DATA)
.map {
AppInfo(
pkg = it.packageName,
label = pm.getApplicationLabel(it).toString(),
icon = runCatching { drawableToImageBitmap(pm.getApplicationIcon(it)) }.getOrNull(),
system = it.flags and ApplicationInfo.FLAG_SYSTEM != 0,
)
}
.sortedBy { it.label.lowercase() }
}
@@ -0,0 +1,179 @@
package com.a2i.forwarder.ui.screens
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.Notifications
import androidx.compose.material.icons.filled.Settings
import androidx.compose.material.icons.outlined.Apps
import androidx.compose.material.icons.outlined.FilterAlt
import androidx.compose.material3.AssistChip
import androidx.compose.material3.Button
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import com.a2i.forwarder.A2iApp
import com.a2i.forwarder.core.AdFilter
import com.a2i.forwarder.ui.ScreenTitle
import com.a2i.forwarder.ui.SectionCard
import com.a2i.forwarder.ui.SwitchRow
import kotlinx.coroutines.launch
@OptIn(ExperimentalLayoutApi::class)
@Composable
fun FilterScreen() {
val app = A2iApp.instance
val adOn by app.settings.adFilterEnabled.collectAsState()
val codeOn by app.settings.codeEnabled.collectAsState()
val specialOn by app.settings.specialEnabled.collectAsState()
val skipOngoing by app.settings.skipOngoing.collectAsState()
val customKw by app.settings.adKeywords.collectAsState()
var newKw by remember { mutableStateOf("") }
LazyColumn(
modifier = Modifier.fillMaxSize().padding(horizontal = 16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
item { ScreenTitle("过滤与优化", "减少噪音,保留真正需要转发的通知") }
item {
SectionCard("功能开关", subtitle = "规则按通知处理流水线顺序执行") {
SwitchRow(
title = "广告过滤",
subtitle = "命中营销关键词的通知将被丢弃",
checked = adOn,
icon = Icons.Outlined.FilterAlt,
tint = MaterialTheme.colorScheme.primary,
) { app.appScope.launch { app.settings.setAdFilter(it) } }
SwitchRow(
title = "验证码自动提取",
subtitle = "自动提取验证码,收到即复制并可点击复制",
checked = codeOn,
icon = Icons.Filled.Notifications,
tint = MaterialTheme.colorScheme.secondary,
) { app.appScope.launch { app.settings.setCode(it) } }
SwitchRow(
title = "微信 / QQ / Telegram 优化",
subtitle = "发送者作标题、按会话分组、重要通知提级",
checked = specialOn,
icon = Icons.Outlined.Apps,
tint = MaterialTheme.colorScheme.tertiary,
) { app.appScope.launch { app.settings.setSpecial(it) } }
SwitchRow(
title = "过滤持续性通知",
subtitle = "跳过音乐、下载、通话等 ongoing 通知",
checked = skipOngoing,
icon = Icons.Filled.Settings,
tint = MaterialTheme.colorScheme.onSurfaceVariant,
) { app.appScope.launch { app.settings.setSkipOngoing(it) } }
}
}
item {
SectionCard("内置广告关键词", subtitle = "系统预置规则不可编辑") {
FlowRow(horizontalArrangement = Arrangement.spacedBy(6.dp), verticalArrangement = Arrangement.spacedBy(6.dp)) {
AdFilter.DEFAULT_KEYWORDS.forEach { kw ->
AssistChip(onClick = {}, label = { Text(kw) })
}
}
}
}
item {
SectionCard("自定义关键词", subtitle = "命中任意关键词的通知会被广告过滤拦截") {
if (customKw.isEmpty()) {
EmptyKeywordState()
} else {
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
customKw.forEach { kw ->
KeywordRow(kw) {
app.appScope.launch { app.settings.setAdKeywords(customKw - kw) }
}
}
}
}
Spacer(Modifier.size(12.dp))
Row(verticalAlignment = Alignment.CenterVertically) {
OutlinedTextField(
value = newKw,
onValueChange = { newKw = it },
label = { Text("添加关键词") },
modifier = Modifier.weight(1f),
singleLine = true,
)
Spacer(Modifier.width(8.dp))
Button(onClick = {
val t = newKw.trim()
if (t.isNotEmpty() && t !in customKw) {
app.appScope.launch { app.settings.setAdKeywords(customKw + t) }
newKw = ""
}
}) {
Icon(Icons.Filled.Add, null, modifier = Modifier.size(18.dp))
Spacer(Modifier.width(6.dp))
Text("添加")
}
}
}
}
}
}
@Composable
private fun EmptyKeywordState() {
Surface(
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(8.dp),
color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.45f),
border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant),
) {
Text(
"暂无自定义关键词",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(12.dp),
)
}
}
@Composable
private fun KeywordRow(keyword: String, onDelete: () -> Unit) {
Surface(
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(8.dp),
color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.45f),
border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant),
) {
Row(Modifier.fillMaxWidth().padding(start = 12.dp, end = 4.dp, top = 4.dp, bottom = 4.dp), verticalAlignment = Alignment.CenterVertically) {
Text(keyword, Modifier.weight(1f), style = MaterialTheme.typography.bodyMedium, fontWeight = FontWeight.Medium)
IconButton(onClick = onDelete) { Icon(Icons.Filled.Close, "删除") }
}
}
}
@@ -0,0 +1,310 @@
package com.a2i.forwarder.ui.screens
import android.content.Intent
import android.provider.Settings
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.CheckCircle
import androidx.compose.material.icons.filled.History
import androidx.compose.material.icons.filled.Notifications
import androidx.compose.material.icons.filled.PowerSettingsNew
import androidx.compose.material.icons.filled.Settings
import androidx.compose.material.icons.filled.WarningAmber
import androidx.compose.material.icons.filled.SystemUpdate
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.FilledTonalButton
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver
import androidx.lifecycle.compose.LocalLifecycleOwner
import com.a2i.forwarder.A2iApp
import com.a2i.forwarder.ui.ClickRow
import com.a2i.forwarder.ui.IconBox
import com.a2i.forwarder.ui.MetricBlock
import com.a2i.forwarder.ui.SectionCard
import com.a2i.forwarder.ui.StatusBadge
import com.a2i.forwarder.ui.SwitchRow
import com.a2i.forwarder.ui.appVersionName
import com.a2i.forwarder.ui.isNotificationListenerEnabled
import kotlinx.coroutines.launch
@Composable
fun HomeScreen(onOpenLog: () -> Unit, onOpenSettings: () -> Unit) {
val context = LocalContext.current
val app = A2iApp.instance
val forwarding by app.settings.forwardingEnabled.collectAsState()
val servers by app.settings.servers.collectAsState()
val sent by app.logStore.sentCount.collectAsState()
val filtered by app.logStore.filteredCount.collectAsState()
val failed by app.logStore.failedCount.collectAsState()
val smsSuspended by app.settings.smsSuspended.collectAsState()
val update by app.updateChecker.updateInfo.collectAsState()
var showUpdateDialog by remember { mutableStateOf(false) }
var granted by remember { mutableStateOf(isNotificationListenerEnabled(context)) }
val lifecycleOwner = LocalLifecycleOwner.current
DisposableEffect(lifecycleOwner) {
val obs = LifecycleEventObserver { _, e ->
if (e == Lifecycle.Event.ON_RESUME) granted = isNotificationListenerEnabled(context)
}
lifecycleOwner.lifecycle.addObserver(obs)
onDispose { lifecycleOwner.lifecycle.removeObserver(obs) }
}
LazyColumn(
modifier = Modifier.fillMaxSize().padding(horizontal = 16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
item {
HomeHero(
granted = granted,
forwarding = forwarding,
version = appVersionName(context),
modifier = Modifier.padding(top = 16.dp),
)
}
val info = update
if (info != null) {
item {
SectionCard {
Box(modifier = Modifier
.fillMaxWidth()
.background(
brush = Brush.linearGradient(
colors = listOf(MaterialTheme.colorScheme.tertiary, MaterialTheme.colorScheme.primary),
),
shape = RoundedCornerShape(8.dp),
)
.padding(16.dp)
) {
Column {
Row(verticalAlignment = Alignment.CenterVertically) {
Box(
modifier = Modifier
.size(44.dp)
.background(Color.White.copy(alpha = 0.18f), RoundedCornerShape(8.dp)),
contentAlignment = Alignment.Center,
) {
Icon(Icons.Filled.SystemUpdate, null, tint = Color.White, modifier = Modifier.size(26.dp))
}
Spacer(Modifier.width(12.dp))
Column(Modifier.weight(1f)) {
Text("发现新版本", style = MaterialTheme.typography.titleLarge, color = Color.White, fontWeight = FontWeight.Bold)
Text(
"v${info.latestVersion} · ${formatSize(info.apkSize)} · 点击立即更新",
style = MaterialTheme.typography.bodyMedium,
color = Color.White.copy(alpha = 0.88f),
)
}
StatusBadge("🔄", tint = Color.White)
}
Spacer(Modifier.size(12.dp))
Button(
onClick = { showUpdateDialog = true },
modifier = Modifier.fillMaxWidth(),
colors = ButtonDefaults.buttonColors(
containerColor = Color.White,
contentColor = MaterialTheme.colorScheme.primary,
),
) { Text("🚀 立即更新到 v${info.latestVersion}", fontWeight = FontWeight.Bold) }
}
}
}
}
}
if (smsSuspended) {
item {
SectionCard {
Row(verticalAlignment = Alignment.CenterVertically) {
IconBox(
icon = Icons.Filled.WarningAmber,
tint = MaterialTheme.colorScheme.error,
)
Spacer(Modifier.width(12.dp))
Column(Modifier.weight(1f)) {
Text("短信兜底已停用", style = MaterialTheme.typography.titleMedium)
Text(
"短信余额不足,断网转发已关闭。请在「设置 → 断网短信兜底」补充额度",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
}
}
item {
SectionCard {
Row(verticalAlignment = Alignment.CenterVertically) {
IconBox(
icon = if (granted) Icons.Filled.CheckCircle else Icons.Filled.WarningAmber,
tint = if (granted) MaterialTheme.colorScheme.tertiary else MaterialTheme.colorScheme.error,
)
Spacer(Modifier.width(12.dp))
Column(Modifier.weight(1f)) {
Text(if (granted) "通知监听已开启" else "需要授权通知监听", style = MaterialTheme.typography.titleMedium)
Text(
if (granted) "系统通知将按规则处理并多通道转发" else "授权后才能读取 Android 通知并转发",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
StatusBadge(
text = if (granted) "已授权" else "未授权",
tint = if (granted) MaterialTheme.colorScheme.tertiary else MaterialTheme.colorScheme.error,
)
}
if (!granted) {
Spacer(Modifier.size(12.dp))
Button(
onClick = { context.startActivity(Intent(Settings.ACTION_NOTIFICATION_LISTENER_SETTINGS)) },
modifier = Modifier.fillMaxWidth(),
) { Text("前往系统授权") }
}
}
}
item {
SectionCard("运行状态", subtitle = "转发能力、目标服务器和日志入口") {
SwitchRow(
title = "通知转发",
subtitle = if (forwarding) "新通知会继续多通道转发" else "当前已暂停所有转发",
checked = forwarding,
icon = Icons.Filled.PowerSettingsNew,
tint = if (forwarding) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant,
) { v ->
app.appScope.launch { app.settings.setForwarding(v) }
}
val enabledServers = servers.filter { it.enabled && it.deviceKey.isNotBlank() }
ClickRow(
title = if (enabledServers.isEmpty()) "未启用 Bark 服务器" else "Bark 服务器(${enabledServers.size} 个启用)",
subtitle = enabledServers.firstOrNull()?.server ?: "进入设置勾选要转发的服务器",
icon = Icons.Filled.Settings,
tint = MaterialTheme.colorScheme.secondary,
onClick = onOpenSettings,
)
ClickRow(
title = "查看转发日志",
subtitle = "最近 300 条处理记录",
icon = Icons.Filled.History,
tint = MaterialTheme.colorScheme.primary,
onClick = onOpenLog,
)
}
}
item {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
MetricBlock("已转发", sent.toString(), MaterialTheme.colorScheme.primary, Modifier.weight(1f))
MetricBlock("已过滤", filtered.toString(), MaterialTheme.colorScheme.secondary, Modifier.weight(1f))
MetricBlock("失败", failed.toString(), MaterialTheme.colorScheme.error, Modifier.weight(1f))
}
}
item {
Text(
"v${appVersionName(context)}",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.fillMaxWidth().padding(top = 2.dp, bottom = 18.dp),
textAlign = TextAlign.Center,
)
}
}
if (showUpdateDialog) {
val info = update
if (info != null) UpdateDialog(info, onDismiss = { showUpdateDialog = false })
}
}
@Composable
private fun HomeHero(
granted: Boolean,
forwarding: Boolean,
version: String,
modifier: Modifier = Modifier,
) {
Box(
modifier = modifier
.fillMaxWidth()
.background(
brush = Brush.linearGradient(
colors = listOf(MaterialTheme.colorScheme.primary, MaterialTheme.colorScheme.secondary),
),
shape = RoundedCornerShape(12.dp),
)
.padding(18.dp),
) {
Column {
Row(verticalAlignment = Alignment.CenterVertically) {
Box(
modifier = Modifier
.size(44.dp)
.background(Color.White.copy(alpha = 0.18f), RoundedCornerShape(8.dp)),
contentAlignment = Alignment.Center,
) {
Icon(Icons.Filled.Notifications, null, tint = Color.White, modifier = Modifier.size(24.dp))
}
Spacer(Modifier.width(12.dp))
Column(Modifier.weight(1f)) {
Text("GotMsg", style = MaterialTheme.typography.displaySmall, color = Color.White)
Text("安卓通知多通道转发", style = MaterialTheme.typography.bodyMedium, color = Color.White.copy(alpha = 0.82f))
}
StatusBadge(
text = if (granted && forwarding) "运行中" else "待处理",
tint = Color.White,
)
}
Spacer(Modifier.size(14.dp))
Text(
if (granted && forwarding) "监听、过滤、提取验证码和推送链路已就绪。" else "完成授权并开启转发后,通知会按规则转发到各通道。",
style = MaterialTheme.typography.bodyMedium,
color = Color.White.copy(alpha = 0.86f),
)
Text(
"Build $version",
style = MaterialTheme.typography.bodySmall,
color = Color.White.copy(alpha = 0.68f),
modifier = Modifier.padding(top = 8.dp),
)
}
}
}
@@ -0,0 +1,193 @@
package com.a2i.forwarder.ui.screens
import android.content.Context
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.work.OneTimeWorkRequestBuilder
import androidx.work.WorkManager
import com.a2i.forwarder.A2iApp
import com.a2i.forwarder.core.ForwardLog
import com.a2i.forwarder.core.RetryWorker
import com.a2i.forwarder.ui.SectionCard
import com.a2i.forwarder.ui.StatusBadge
import kotlinx.coroutines.launch
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
@Composable
fun LogScreen(onBack: () -> Unit) {
val context = LocalContext.current
val app = A2iApp.instance
val logs by app.logStore.logs.collectAsState()
val pending by app.logStore.pending.collectAsState()
val df = remember { SimpleDateFormat("MM-dd HH:mm:ss", Locale.getDefault()) }
var showClearDialog by remember { mutableStateOf(false) }
Column(Modifier.fillMaxSize()) {
Surface(
color = MaterialTheme.colorScheme.surface,
border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant),
) {
Row(
Modifier.fillMaxWidth().padding(horizontal = 8.dp, vertical = 6.dp),
verticalAlignment = Alignment.CenterVertically,
) {
IconButton(onClick = onBack) { Icon(Icons.AutoMirrored.Filled.ArrowBack, "返回") }
Column(Modifier.weight(1f)) {
Text("转发日志", style = MaterialTheme.typography.titleLarge)
Text("最近 300 条通知处理记录", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
}
TextButton(onClick = { showClearDialog = true }) { Text("清空") }
}
}
LazyColumn(
modifier = Modifier.fillMaxSize().padding(horizontal = 16.dp, vertical = 12.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
if (pending.isNotEmpty()) {
item {
SectionCard("待重发(${pending.size})", subtitle = "发送失败的通知会在联网后自动重试") {
Text(
"也可以立即触发一次 WorkManager 重发任务。",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
TextButton(onClick = {
WorkManager.getInstance(context)
.enqueue(OneTimeWorkRequestBuilder<RetryWorker>().build())
}) { Text("立即全部重发") }
}
}
}
if (logs.isEmpty()) {
item {
Box(Modifier.fillMaxWidth().padding(32.dp), contentAlignment = Alignment.Center) {
Text("暂无记录", color = MaterialTheme.colorScheme.onSurfaceVariant)
}
}
}
items(logs) { log ->
LogItem(log, df)
}
}
}
if (showClearDialog) {
AlertDialog(
onDismissRequest = { showClearDialog = false },
title = { Text("清空日志") },
text = { Text("选择清空范围:") },
confirmButton = {
TextButton(onClick = {
showClearDialog = false
app.appScope.launch { app.logStore.clearLogs() }
}) { Text("仅清空日志") }
},
dismissButton = {
TextButton(onClick = {
showClearDialog = false
app.appScope.launch {
app.logStore.clearLogs()
app.logStore.resetCounters()
}
}) { Text("日志+计数器") }
},
)
}
}
@Composable
private fun LogItem(log: ForwardLog, df: SimpleDateFormat) {
val (tint, text) = when (log.status) {
"sent" -> MaterialTheme.colorScheme.tertiary to "已转发"
"filtered" -> MaterialTheme.colorScheme.secondary to "已过滤"
else -> MaterialTheme.colorScheme.error to "失败"
}
Surface(
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(8.dp),
color = MaterialTheme.colorScheme.surface,
border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant),
) {
Column(Modifier.padding(12.dp)) {
Row(verticalAlignment = Alignment.CenterVertically) {
StatusBadge(text, tint)
Spacer(Modifier.size(8.dp))
Text(
log.appLabel,
style = MaterialTheme.typography.bodyMedium,
fontWeight = FontWeight.Medium,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f),
)
Text(df.format(Date(log.time)), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
}
val title = log.title.orEmpty()
if (title.isNotBlank()) {
Text(
title,
style = MaterialTheme.typography.bodyMedium,
fontWeight = FontWeight.Medium,
modifier = Modifier.padding(top = 8.dp),
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
if (log.content.isNotBlank()) {
Text(
log.content,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.padding(top = if (title.isNotBlank()) 2.dp else 8.dp),
)
}
if (log.reason.isNotBlank()) {
Text(
"原因:${log.reason}",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.error,
modifier = Modifier.padding(top = 6.dp),
)
}
}
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,125 @@
package com.a2i.forwarder.ui.screens
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import com.a2i.forwarder.A2iApp
import com.a2i.forwarder.core.UpdateInfo
import kotlinx.coroutines.launch
import java.io.File
import java.util.Locale
@Composable
fun UpdateDialog(info: UpdateInfo, onDismiss: () -> Unit) {
val app = A2iApp.instance
val downloading by app.updateChecker.downloading.collectAsState()
val progress by app.updateChecker.downloadProgress.collectAsState()
var downloadedFile by remember { mutableStateOf<File?>(null) }
AlertDialog(
onDismissRequest = { if (!downloading) onDismiss() },
title = { Text("发现新版本 v${info.latestVersion}") },
text = {
Column {
Text(
"安装包大小:${formatSize(info.apkSize)}",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Spacer(Modifier.size(8.dp))
Column(
modifier = Modifier
.heightIn(max = 240.dp)
.verticalScroll(rememberScrollState()),
) {
val notes = info.releaseNotes
.lineSequence()
.filterNot { it.contains("update-source") }
.joinToString("\n")
.ifBlank { "(作者未提供更新说明)" }
Text(notes, style = MaterialTheme.typography.bodyMedium)
}
if (downloading) {
Spacer(Modifier.size(12.dp))
LinearProgressIndicator(
progress = { progress / 100f },
modifier = Modifier.fillMaxWidth(),
)
Spacer(Modifier.size(4.dp))
Text(
"正在下载更新…",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
downloadedFile?.let {
Spacer(Modifier.size(8.dp))
Text(
"下载完成,点击「安装」继续",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.tertiary,
)
}
}
},
confirmButton = {
if (downloadedFile == null) {
Button(
onClick = {
app.appScope.launch {
val f = app.updateChecker.download(info)
if (f != null) downloadedFile = f
}
},
enabled = !downloading,
) {
if (downloading) {
CircularProgressIndicator(Modifier.size(16.dp), strokeWidth = 2.dp)
} else {
Text("下载并安装")
}
}
} else {
Button(onClick = { app.updateChecker.install(downloadedFile!!) }) { Text("安装") }
}
},
dismissButton = {
Row {
TextButton(
onClick = { app.updateChecker.ignoreVersion(info.latestVersion); onDismiss() },
enabled = !downloading,
) { Text("忽略此版本") }
TextButton(onClick = onDismiss, enabled = !downloading) { Text("稍后") }
}
},
)
}
fun formatSize(bytes: Long): String {
if (bytes <= 0) return "未知"
val kb = bytes / 1024.0
return if (kb >= 1024) String.format(Locale.US, "%.1f MB", kb / 1024) else String.format(Locale.US, "%.0f KB", kb)
}
@@ -0,0 +1,21 @@
package com.a2i.forwarder.ui.theme
import androidx.compose.ui.graphics.Color
val BrandBlue = Color(0xFF246BFD)
val BrandCyan = Color(0xFF00A9C8)
val BrandEmerald = Color(0xFF18A058)
val BrandAmber = Color(0xFFF59E0B)
val BrandRed = Color(0xFFE5484D)
val LightBackground = Color(0xFFF5F7FB)
val LightSurface = Color(0xFFFFFFFF)
val LightSurfaceMuted = Color(0xFFEAF0FA)
val LightText = Color(0xFF111827)
val LightTextMuted = Color(0xFF64748B)
val DarkBackground = Color(0xFF0B1220)
val DarkSurface = Color(0xFF111B2E)
val DarkSurfaceMuted = Color(0xFF1A2740)
val DarkText = Color(0xFFE7EDF7)
val DarkTextMuted = Color(0xFF9AA8BC)
@@ -0,0 +1,64 @@
package com.a2i.forwarder.ui.theme
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.darkColorScheme
import androidx.compose.material3.lightColorScheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.graphics.Color
private val DarkColors = darkColorScheme(
primary = Color(0xFF89B4FF),
onPrimary = DarkBackground,
primaryContainer = Color(0xFF163968),
onPrimaryContainer = DarkText,
secondary = Color(0xFF6FD3E4),
onSecondary = DarkBackground,
secondaryContainer = Color(0xFF123D47),
onSecondaryContainer = DarkText,
tertiary = Color(0xFF7BDFA8),
error = Color(0xFFFF9EA2),
background = DarkBackground,
onBackground = DarkText,
surface = DarkSurface,
onSurface = DarkText,
surfaceVariant = DarkSurfaceMuted,
onSurfaceVariant = DarkTextMuted,
outline = Color(0xFF425169),
outlineVariant = Color(0xFF29374D),
)
private val LightColors = lightColorScheme(
primary = BrandBlue,
onPrimary = LightSurface,
primaryContainer = Color(0xFFE1EAFF),
onPrimaryContainer = LightText,
secondary = BrandCyan,
onSecondary = LightSurface,
secondaryContainer = Color(0xFFDDF8FC),
onSecondaryContainer = LightText,
tertiary = BrandEmerald,
error = BrandRed,
background = LightBackground,
onBackground = LightText,
surface = LightSurface,
onSurface = LightText,
surfaceVariant = LightSurfaceMuted,
onSurfaceVariant = LightTextMuted,
outline = Color(0xFFCAD5E4),
outlineVariant = Color(0xFFE0E7F1),
)
@Composable
fun A2iTheme(
darkTheme: Boolean = isSystemInDarkTheme(),
dynamicColor: Boolean = false,
content: @Composable () -> Unit,
) {
val colorScheme = if (darkTheme) DarkColors else LightColors
MaterialTheme(
colorScheme = colorScheme,
typography = Typography,
content = content,
)
}
@@ -0,0 +1,73 @@
package com.a2i.forwarder.ui.theme
import androidx.compose.material3.Typography
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.sp
val Typography = Typography(
displaySmall = TextStyle(
fontFamily = FontFamily.Default,
fontWeight = FontWeight.Bold,
fontSize = 34.sp,
lineHeight = 40.sp,
letterSpacing = 0.sp,
),
headlineMedium = TextStyle(
fontFamily = FontFamily.Default,
fontWeight = FontWeight.Bold,
fontSize = 26.sp,
lineHeight = 32.sp,
letterSpacing = 0.sp,
),
titleLarge = TextStyle(
fontFamily = FontFamily.Default,
fontWeight = FontWeight.SemiBold,
fontSize = 22.sp,
lineHeight = 28.sp,
letterSpacing = 0.sp,
),
titleMedium = TextStyle(
fontFamily = FontFamily.Default,
fontWeight = FontWeight.SemiBold,
fontSize = 17.sp,
lineHeight = 24.sp,
letterSpacing = 0.sp,
),
bodyLarge = TextStyle(
fontFamily = FontFamily.Default,
fontWeight = FontWeight.Normal,
fontSize = 16.sp,
lineHeight = 24.sp,
letterSpacing = 0.sp,
),
bodyMedium = TextStyle(
fontFamily = FontFamily.Default,
fontWeight = FontWeight.Normal,
fontSize = 14.sp,
lineHeight = 20.sp,
letterSpacing = 0.sp,
),
bodySmall = TextStyle(
fontFamily = FontFamily.Default,
fontWeight = FontWeight.Normal,
fontSize = 12.sp,
lineHeight = 16.sp,
letterSpacing = 0.sp,
),
labelLarge = TextStyle(
fontFamily = FontFamily.Default,
fontWeight = FontWeight.SemiBold,
fontSize = 14.sp,
lineHeight = 20.sp,
letterSpacing = 0.sp,
),
labelMedium = TextStyle(
fontFamily = FontFamily.Default,
fontWeight = FontWeight.SemiBold,
fontSize = 12.sp,
lineHeight = 16.sp,
letterSpacing = 0.sp,
),
)
Binary file not shown.

After

Width:  |  Height:  |  Size: 7.1 KiB

@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="rectangle">
<gradient
android:startColor="#3B7CFF"
android:endColor="#6A5BFF"
android:angle="135" />
</shape>
@@ -0,0 +1,15 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<group
android:scaleX="2.6"
android:scaleY="2.6"
android:translateX="22.8"
android:translateY="22.8">
<path
android:fillColor="#FFFFFF"
android:pathData="M2.01,21L23,12 2.01,3 2,10l15,2 -15,2z" />
</group>
</vector>
Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.5 KiB

@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@drawable/ic_launcher_background" />
<foreground android:drawable="@drawable/ic_launcher_foreground" />
<monochrome android:drawable="@drawable/ic_launcher_foreground" />
</adaptive-icon>
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@drawable/ic_launcher_background" />
<foreground android:drawable="@drawable/ic_launcher_foreground" />
<monochrome android:drawable="@drawable/ic_launcher_foreground" />
</adaptive-icon>
+3
View File
@@ -0,0 +1,3 @@
<resources>
<color name="ic_launcher_bg">#3B7CFF</color>
</resources>
+3
View File
@@ -0,0 +1,3 @@
<resources>
<string name="app_name">GotMsg 有消息</string>
</resources>
+7
View File
@@ -0,0 +1,7 @@
<resources xmlns:tools="http://schemas.android.com/tools">
<style name="Theme.A2i" parent="Theme.Material3.DayNight.NoActionBar">
<item name="android:statusBarColor">@android:color/transparent</item>
<item name="android:navigationBarColor">@android:color/transparent</item>
<item name="android:windowLightStatusBar" tools:targetApi="m">true</item>
</style>
</resources>
+3
View File
@@ -0,0 +1,3 @@
<?xml version="1.0" encoding="utf-8"?>
<full-backup-content>
</full-backup-content>
@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<data-extraction-rules>
<cloud-backup>
</cloud-backup>
<device-transfer>
</device-transfer>
</data-extraction-rules>
+5
View File
@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<paths>
<external-files-path name="updates" path="." />
<cache-path name="cache" path="." />
</paths>
BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 120 KiB

+6
View File
@@ -0,0 +1,6 @@
plugins {
alias(libs.plugins.android.application) apply false
alias(libs.plugins.kotlin.android) apply false
alias(libs.plugins.kotlin.compose) apply false
alias(libs.plugins.kotlin.serialization) apply false
}
-7
View File
@@ -1,7 +0,0 @@
# A2iReply 快捷指令迁移说明
GotMsg v1.11.0 已移除旧的 iOS 快捷指令和 ntfy `_reply` topic 方案。请删除原有的 `A2iReply` 快捷指令;GotMsg 不再订阅或使用 `_reply` topic。
新版本把一次性回复地址设置为 Bark / ntfy 通知的点击地址或“回复”动作,不会在通知正文中重复显示。iPhone 和 Android 都可直接打开网页输入回复,无需另外安装快捷指令或修改现有 ntfy topic。
完整的安装要求、Shizuku / 无障碍配置、PIN 自动解锁和故障排查见 [通知远程回复](notification-reply.md)。
-94
View File
@@ -1,94 +0,0 @@
# 通知远程回复
GotMsg 可以从 Bark / ntfy 通知打开一次性回复网页,把回复送回运行 GotMsg 的原 Android 手机。系统会优先使用通知自带的 Android `RemoteInput`;QQ 等应用可使用无障碍兼容回复,微信使用 Shizuku 回复。
> 远程回复不是模拟登录,也不会接管微信或 QQ 账号。能否成功取决于原通知是否仍然存在、原手机是否在线,以及目标应用当前界面是否可识别。
## 开始前准备
1. 在原 Android 手机上安装 GotMsg,并授权“通知使用权”。
2. 至少启用一个 Bark 或 ntfy 转发配置;邮件和短信通道不提供回复入口。
3. 关闭针对 GotMsg 的省电限制,允许后台运行和自启动。
4. 根据要回复的应用完成下面的额外配置:
- 通知本身带快捷回复:无需额外配置。
- QQ、支付宝、淘宝、钉钉:打开 GotMsg“设置 → 通知回复 → 无障碍兼容回复”,并在系统无障碍设置中启用“GotMsg 兼容回复”。
- 微信:安装并启动 [Shizuku](https://shizuku.rikka.app/download/),授权 GotMsg。微信实际回复走 Shizuku,不依赖系统无障碍服务。
## 使用方法
1. 原手机收到通知后,GotMsg 将它转发到 Bark / ntfy。
2. 在接收手机上点击 Bark 通知,或点击 ntfy 通知及其“回复”动作。
3. 网页会显示应用名、通知标题和正文;输入内容并提交。
4. 原手机拉取任务并执行回复,网页等待最终结果;GotMsg“日志”页也会记录结果。
回复入口只作为通知的点击地址或动作地址,不会在通知正文中重复显示。短信 App 中识别出的验证码通知不会创建回复会话,也不会附加回复入口。接收端不需要 iOS 快捷指令,也不需要创建或订阅 `_reply` topic;现有 ntfy server、topic 和 token 无需改变。
## 三种执行方式
| 方式 | 适用范围 | 额外要求 | 是否会打开原应用 |
| --- | --- | --- | --- |
| Android `RemoteInput` | Telegram、WhatsApp、Signal、部分短信和企业 IM;以该条通知实际提供的动作为准 | 无 | 否 |
| 无障碍兼容回复 | QQ、支付宝、淘宝、钉钉 | 启用 GotMsg 无障碍服务 | 是 |
| Shizuku 回复 | 微信 | Shizuku 已启动并授权 GotMsg | 是 |
### Android `RemoteInput`
这是最稳定的方式。GotMsg 直接调用原通知提供的快捷回复动作,不需要打开原应用,也不要求原手机停留在聊天界面。原应用若没有为这条通知提供自由文本回复动作,则无法强制使用此方式。
### 无障碍兼容回复
GotMsg 会打开通知指向的会话,识别输入框、写入文本并点击文字明确匹配“发送”的控件。它只对白名单应用生效;输入框已有草稿时会主动停止,避免覆盖原内容。
执行结束后,有 Shizuku 时会把 GotMsg 切回前台;没有 Shizuku 时会通过无障碍返回桌面,避免 QQ 等聊天页持续停在前台后不再产生新消息通知。
### 微信 Shizuku 回复
微信新版会限制普通无障碍输入,因此微信回复必须使用 Shizuku。Android 11 及以上可按 Shizuku 页面提示通过“开发者选项 → 无线调试 → 使用配对码配对设备”启动;非 Root 启动通常会在手机重启后失效,需要重新启动 Shizuku。
GotMsg 首页应显示“Shizuku 已就绪”。若显示未授权,点击授权入口并在 Shizuku 弹窗中允许 GotMsg。
## 锁屏与 PIN 自动解锁
`RemoteInput` 通常可在锁屏状态执行;无障碍和 Shizuku 兼容回复需要进入目标应用,原手机必须先解锁。
已配置 Shizuku 的手机可在“设置 → 通知回复”中开启 PIN 自动解锁:
1. 输入原手机 4–16 位数字锁屏 PIN 并保存。
2. PIN 使用 Android Keystore AES-GCM 加密,只保存在原手机,并排除云备份和设备迁移。
3. 每个回复任务最多提交一次 PIN;失败不会自动重试,避免触发系统锁定策略。
4. 如果任务开始时手机处于锁定状态,回复完成后 GotMsg 会尝试重新锁屏。
手机重启后的第一次解锁仍必须手动完成;Shizuku 未运行时不能自动输入 PIN。图案、字母密码和生物识别不属于该功能支持范围。
## 生效条件与限制
- 回复链接从通知转发起 10 分钟内有效,并且只能成功提交一次。
- 原通知不能被划掉或被新通知替换;进程未重启时 GotMsg 会短暂缓存通知入口,降低通知更新导致的失败概率。
- 原 Android 手机必须联网,GotMsg 进程和通知监听服务需要正常工作。
- 短信 App 中识别出的验证码通知不生成回复入口;付款、订单、营销和系统通知通常也没有可回复的聊天输入框。
- 兼容回复要求通知点击后真正进入对应聊天页;联系人页、登录页、广告页等不会被当作聊天页。
- 某些 App 或定制系统不会向无障碍服务暴露聊天控件;界面更新后也可能需要重新适配。
## 常见失败
| 提示 | 处理方法 |
| --- | --- |
| `回复链接已过期` | 链接有效期为 10 分钟,等待一条新通知再试 |
| `原通知已消失` | 原通知已被清除或替换,等待新通知后再回复 |
| `请在 GotMsg 设置中开启无障碍兼容回复服务` | 同时检查应用内开关和系统“GotMsg 兼容回复”无障碍权限 |
| `Shizuku 未就绪` / `Shizuku 未授权` | 打开 Shizuku 重新启动服务,并授权 GotMsg |
| `原手机需解锁后才能回复` | 手动解锁,或配置 Shizuku PIN 自动解锁 |
| `微信未进入聊天页` | 检查点击原通知能否直接进入对应微信会话,并确认没有登录页或弹窗遮挡 |
| `未找到聊天输入框` / `未找到明确的发送按钮` | 确认进入聊天页、无障碍服务开启;目标 App 更新后可能需要重新适配 |
| `无法确认聊天输入框中的回复内容` | 常见于 QQ 等不公开输入框文本的界面;升级到最新版并保留 `A2iReply` 日志供排查 |
| `聊天输入框已有未发送内容` | GotMsg 为保护原手机草稿而停止;清空草稿后用新通知重试 |
| `原手机未确认回复` | 检查原手机网络、后台限制、通知使用权以及 GotMsg 是否仍在运行 |
## 安全说明
- 每个已创建的回复会话使用独立的 256 位随机回复令牌。令牌位于 URL fragment 中,不会随首次页面请求进入服务器访问日志。
- GotMsg 安装时生成独立设备 ID 和密钥;原手机与中继 API 之间的注册、拉取和确认请求均需设备鉴权。
- 中继只持久化设备密钥和回复令牌的 SHA-256 哈希。提交后的回复正文会短暂保存,处理完成后最多保留约 24 小时,用于状态查询和故障排查。
- 拿到有效回复链接的人可在有效期内提交一次回复,因此不要转发或公开该链接。
- 无障碍和 Shizuku 都是高权限能力,只应在你信任、由你控制的原 Android 手机上启用。
- 回复服务固定使用 `https://r.gotmsg.pp.ua`。自行部署或更换域名时,需要同步修改 `ReplyFeature.RELAY_BASE_URL` 并重新构建 APK。
+6
View File
@@ -0,0 +1,6 @@
org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
org.gradle.parallel=true
org.gradle.caching=true
android.useAndroidX=true
kotlin.code.style=official
android.nonTransitiveRClass=true
+41
View File
@@ -0,0 +1,41 @@
[versions]
agp = "8.9.1"
kotlin = "2.1.0"
coreKtx = "1.15.0"
lifecycle = "2.8.7"
activityCompose = "1.9.3"
composeBom = "2024.12.01"
navigationCompose = "2.8.5"
datastore = "1.1.1"
serializationJson = "1.7.3"
okhttp = "4.12.0"
work = "2.10.0"
documentfile = "1.0.1"
material = "1.12.0"
[libraries]
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
androidx-lifecycle-runtime-ktx = { group = "androidx.lifecycle", name = "lifecycle-runtime-ktx", version.ref = "lifecycle" }
androidx-lifecycle-viewmodel-compose = { group = "androidx.lifecycle", name = "lifecycle-viewmodel-compose", version.ref = "lifecycle" }
androidx-lifecycle-runtime-compose = { group = "androidx.lifecycle", name = "lifecycle-runtime-compose", version.ref = "lifecycle" }
androidx-activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activityCompose" }
androidx-compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "composeBom" }
androidx-ui = { group = "androidx.compose.ui", name = "ui" }
androidx-ui-graphics = { group = "androidx.compose.ui", name = "ui-graphics" }
androidx-ui-tooling = { group = "androidx.compose.ui", name = "ui-tooling" }
androidx-ui-tooling-preview = { group = "androidx.compose.ui", name = "ui-tooling-preview" }
androidx-material3 = { group = "androidx.compose.material3", name = "material3" }
androidx-material-icons-extended = { group = "androidx.compose.material", name = "material-icons-extended" }
androidx-navigation-compose = { group = "androidx.navigation", name = "navigation-compose", version.ref = "navigationCompose" }
androidx-datastore-preferences = { group = "androidx.datastore", name = "datastore-preferences", version.ref = "datastore" }
kotlinx-serialization-json = { group = "org.jetbrains.kotlinx", name = "kotlinx-serialization-json", version.ref = "serializationJson" }
okhttp = { group = "com.squareup.okhttp3", name = "okhttp", version.ref = "okhttp" }
androidx-work-runtime-ktx = { group = "androidx.work", name = "work-runtime-ktx", version.ref = "work" }
androidx-documentfile = { group = "androidx.documentfile", name = "documentfile", version.ref = "documentfile" }
material = { group = "com.google.android.material", name = "material", version.ref = "material" }
[plugins]
android-application = { id = "com.android.application", version.ref = "agp" }
kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" }
kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }
kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" }
Binary file not shown.
+7
View File
@@ -0,0 +1,7 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://mirrors.cloud.tencent.com/gradle/gradle-8.11.1-bin.zip
networkTimeout=120000
validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists
Vendored
+252
View File
@@ -0,0 +1,252 @@
#!/bin/sh
#
# Copyright © 2015-2021 the original authors.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# https://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#
# SPDX-License-Identifier: Apache-2.0
#
##############################################################################
#
# Gradle start up script for POSIX generated by Gradle.
#
# Important for running:
#
# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
# noncompliant, but you have some other compliant shell such as ksh or
# bash, then to run this script, type that shell name before the whole
# command line, like:
#
# ksh Gradle
#
# Busybox and similar reduced shells will NOT work, because this script
# requires all of these POSIX shell features:
# * functions;
# * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
# «${var#prefix}», «${var%suffix}», and «$( cmd )»;
# * compound commands having a testable exit status, especially «case»;
# * various built-in commands including «command», «set», and «ulimit».
#
# Important for patching:
#
# (2) This script targets any POSIX shell, so it avoids extensions provided
# by Bash, Ksh, etc; in particular arrays are avoided.
#
# The "traditional" practice of packing multiple parameters into a
# space-separated string is a well documented source of bugs and security
# problems, so this is (mostly) avoided, by progressively accumulating
# options in "$@", and eventually passing that to Java.
#
# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
# see the in-line comments for details.
#
# There are tweaks for specific operating systems such as AIX, CygWin,
# Darwin, MinGW, and NonStop.
#
# (3) This script is generated from the Groovy template
# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
# within the Gradle project.
#
# You can find Gradle at https://github.com/gradle/gradle/.
#
##############################################################################
# Attempt to set APP_HOME
# Resolve links: $0 may be a link
app_path=$0
# Need this for daisy-chained symlinks.
while
APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
[ -h "$app_path" ]
do
ls=$( ls -ld "$app_path" )
link=${ls#*' -> '}
case $link in #(
/*) app_path=$link ;; #(
*) app_path=$APP_HOME$link ;;
esac
done
# This is normally unused
# shellcheck disable=SC2034
APP_BASE_NAME=${0##*/}
# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s
' "$PWD" ) || exit
# Use the maximum available, or set MAX_FD != -1 to use that value.
MAX_FD=maximum
warn () {
echo "$*"
} >&2
die () {
echo
echo "$*"
echo
exit 1
} >&2
# OS specific support (must be 'true' or 'false').
cygwin=false
msys=false
darwin=false
nonstop=false
case "$( uname )" in #(
CYGWIN* ) cygwin=true ;; #(
Darwin* ) darwin=true ;; #(
MSYS* | MINGW* ) msys=true ;; #(
NONSTOP* ) nonstop=true ;;
esac
CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
# Determine the Java command to use to start the JVM.
if [ -n "$JAVA_HOME" ] ; then
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
# IBM's JDK on AIX uses strange locations for the executables
JAVACMD=$JAVA_HOME/jre/sh/java
else
JAVACMD=$JAVA_HOME/bin/java
fi
if [ ! -x "$JAVACMD" ] ; then
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
else
JAVACMD=java
if ! command -v java >/dev/null 2>&1
then
die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
fi
# Increase the maximum file descriptors if we can.
if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
case $MAX_FD in #(
max*)
# In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC2039,SC3045
MAX_FD=$( ulimit -H -n ) ||
warn "Could not query maximum file descriptor limit"
esac
case $MAX_FD in #(
'' | soft) :;; #(
*)
# In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC2039,SC3045
ulimit -n "$MAX_FD" ||
warn "Could not set maximum file descriptor limit to $MAX_FD"
esac
fi
# Collect all arguments for the java command, stacking in reverse order:
# * args from the command line
# * the main class name
# * -classpath
# * -D...appname settings
# * --module-path (only if needed)
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
# For Cygwin or MSYS, switch paths to Windows format before running java
if "$cygwin" || "$msys" ; then
APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" )
JAVACMD=$( cygpath --unix "$JAVACMD" )
# Now convert the arguments - kludge to limit ourselves to /bin/sh
for arg do
if
case $arg in #(
-*) false ;; # don't mess with options #(
/?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
[ -e "$t" ] ;; #(
*) false ;;
esac
then
arg=$( cygpath --path --ignore --mixed "$arg" )
fi
# Roll the args list around exactly as many times as the number of
# args, so each arg winds up back in the position where it started, but
# possibly modified.
#
# NB: a `for` loop captures its iteration list before it begins, so
# changing the positional parameters here affects neither the number of
# iterations, nor the values presented in `arg`.
shift # remove old arg
set -- "$@" "$arg" # push replacement arg
done
fi
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
DEFAULT_JVM_OPTS='-Dfile.encoding=UTF-8 "-Xmx64m" "-Xms64m"'
# Collect all arguments for the java command:
# * DEFAULT_JVM_OPTS, JAVA_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
# and any embedded shellness will be escaped.
# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be
# treated as '${Hostname}' itself on the command line.
set -- \
"-Dorg.gradle.appname=$APP_BASE_NAME" \
-classpath "$CLASSPATH" \
org.gradle.wrapper.GradleWrapperMain \
"$@"
# Stop when "xargs" is not available.
if ! command -v xargs >/dev/null 2>&1
then
die "xargs is not available"
fi
# Use "xargs" to parse quoted args.
#
# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
#
# In Bash we could simply go:
#
# readarray ARGS < <( xargs -n1 <<<"$var" ) &&
# set -- "${ARGS[@]}" "$@"
#
# but POSIX shell has neither arrays nor command substitution, so instead we
# post-process each arg (as a line of input to sed) to backslash-escape any
# character that might be a shell metacharacter, then use eval to reverse
# that process (while maintaining the separation between arguments), and wrap
# the whole thing up as a single "set" statement.
#
# This will of course break if any of these variables contains a newline or
# an unmatched quote.
#
eval "set -- $(
printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
xargs -n1 |
sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
tr '\n' ' '
)" '"$@"'
exec "$JAVACMD" "$@"
Vendored
+94
View File
@@ -0,0 +1,94 @@
@rem
@rem Copyright 2015 the original author or authors.
@rem
@rem Licensed under the Apache License, Version 2.0 (the "License");
@rem you may not use this file except in compliance with the License.
@rem You may obtain a copy of the License at
@rem
@rem https://www.apache.org/licenses/LICENSE-2.0
@rem
@rem Unless required by applicable law or agreed to in writing, software
@rem distributed under the License is distributed on an "AS IS" BASIS,
@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
@rem See the License for the specific language governing permissions and
@rem limitations under the License.
@rem
@rem SPDX-License-Identifier: Apache-2.0
@rem
@if "%DEBUG%"=="" @echo off
@rem ##########################################################################
@rem
@rem Gradle startup script for Windows
@rem
@rem ##########################################################################
@rem Set local scope for the variables with windows NT shell
if "%OS%"=="Windows_NT" setlocal
set DIRNAME=%~dp0
if "%DIRNAME%"=="" set DIRNAME=.
@rem This is normally unused
set APP_BASE_NAME=%~n0
set APP_HOME=%DIRNAME%
@rem Resolve any "." and ".." in APP_HOME to make it shorter.
for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
set DEFAULT_JVM_OPTS=-Dfile.encoding=UTF-8 "-Xmx64m" "-Xms64m"
@rem Find java.exe
if defined JAVA_HOME goto findJavaFromJavaHome
set JAVA_EXE=java.exe
%JAVA_EXE% -version >NUL 2>&1
if %ERRORLEVEL% equ 0 goto execute
echo. 1>&2
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2
echo. 1>&2
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
echo location of your Java installation. 1>&2
goto fail
:findJavaFromJavaHome
set JAVA_HOME=%JAVA_HOME:"=%
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
if exist "%JAVA_EXE%" goto execute
echo. 1>&2
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2
echo. 1>&2
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
echo location of your Java installation. 1>&2
goto fail
:execute
@rem Setup the command line
set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
@rem Execute Gradle
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %*
:end
@rem End local scope for the variables with windows NT shell
if %ERRORLEVEL% equ 0 goto mainEnd
:fail
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
rem the _cmd.exe /c_ return code!
set EXIT_CODE=%ERRORLEVEL%
if %EXIT_CODE% equ 0 set EXIT_CODE=1
if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
exit /b %EXIT_CODE%
:mainEnd
if "%OS%"=="Windows_NT" endlocal
:omega
Binary file not shown.

After

Width:  |  Height:  |  Size: 4.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 512 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 609 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 624 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 336 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 562 KiB

After

Width:  |  Height:  |  Size: 290 KiB

+22
View File
@@ -0,0 +1,22 @@
pluginManagement {
repositories {
maven("https://maven.aliyun.com/repository/gradle-plugin")
maven("https://maven.aliyun.com/repository/google")
maven("https://maven.aliyun.com/repository/public")
google()
mavenCentral()
gradlePluginPortal()
}
}
dependencyResolutionManagement {
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
repositories {
maven("https://maven.aliyun.com/repository/google")
maven("https://maven.aliyun.com/repository/public")
google()
mavenCentral()
}
}
rootProject.name = "a2i"
include(":app")