67 Commits

Author SHA1 Message Date
song 70f9ba093f feat: add telemetry + bump to 1.9.9
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-11 08:43:03 +08:00
song f7adeda080 docs: add All Rights Reserved to License
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-11 07:29:35 +08:00
song 7f6fe4fc00 docs: restructure README with all 5 channels + self-hosting guides
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>"
git tag -a v1.9.8 -m "v1.9.8: README restructure + all channels documented"
GIT_TERMINAL_PROMPT=0 git push origin main 2>&1 | tail -4
GIT_TERMINAL_PROMPT=0 git push origin v1.9.8 2>&1 | tail -4
2026-07-11 07:13:15 +08:00
song 24626383b6 docs: add ntfy and Meow setup guides to README
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-10 23:02:41 +08:00
song 1f9f6e9934 chore: rename repos a2i -> gotmsg (GitHub + Gitea)
- GitHub: lsxf/a2i -> lsxf/gotmsg
- Gitea: song/a2i -> song/gotmsg
- Updated GITHUB_API, GITEA_API, DEFAULT_ICON_PREFIX
- Updated git remote URLs
- version 1.9.6 -> 1.9.7 (code 35 -> 36)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-10 22:22:41 +08:00
song 38c2bcd819 chore: bump to 1.9.6
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-10 22:08:35 +08:00
song 648c63bfbc refactor: use plaintext Gitea URL instead of XOR+Base64 scramble
- Gitea URL hardcoded as GITEA_API constant (no more release-notes discovery)
- Removed parseUpdateSource / unscramble / SCRAMBLE_KEY
- Release notes no longer need update-source comment
- version 1.9.4 -> 1.9.5 (code 33 -> 34)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-10 21:44:37 +08:00
song 00bdbe2d1e chore: bump to 1.9.4 + Gitea URL -> gitea.songer.eu.org
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-10 20:47:54 +08:00
song a3f67a0a7c fix: download timeout + file validation + source indicator + stale APK
Root cause of "install doesn't update": gradle cached old APK, release
uploaded stale version. Fixes:
- Dedicated download OkHttpClient with 120s readTimeout (was 30s)
- 64KB buffer (was 8KB) for faster large-file download
- Post-download file size validation (Content-Length / release declared)
- Reject files < 1MB (likely HTML error pages)
- downloadSource StateFlow shows "Gitea" or "GitHub" during download
- UpdateDialog shows "正在下载更新…(来自 Gitea)"
- version 1.9.2 -> 1.9.3 (code 31 -> 32)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-10 19:17:53 +08:00
song 6e29c292ec fix: download fallback URL when primary fails
- Gitea URL used as primary (same-version tie-break), GitHub as fallback
- download() tries base url first; if it fails, automatically retries
  the fallback URL
- version 1.9.1 -> 1.9.2 (code 30 -> 31)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-10 14:42:51 +08:00
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
47 changed files with 3415 additions and 477 deletions
+6
View File
@@ -8,6 +8,11 @@ app/build/
local.properties local.properties
.do-session .do-session
# Secrets — never commit
gh-token.txt
gitea-token.txt
*.token
# Android Studio / IntelliJ # Android Studio / IntelliJ
.idea/ .idea/
*.iml *.iml
@@ -27,3 +32,4 @@ test.json
# Local assistant session helpers # Local assistant session helpers
.claude/ .claude/
do.ps1 do.ps1
CLAUDE.md
-108
View File
@@ -1,108 +0,0 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## 项目概述
**a2i** — 安卓通知转发到 iOS Bark。监听 Android 系统通知(短信、验证码、聊天等),通过 Bark Server 推送到 iOS 设备。iOS 端点击通知可跳转到对应 App(微信、QQ、Telegram)。
## 环境配置(本机)
环境变量需要在每次 shell 调用前显式导出:
```bash
export JAVA_HOME="D:/Dev/JDK-17"
export ANDROID_HOME="D:/Dev/Android/SDK"
```
- JDK 17: `D:\Dev\JDK-17`(项目使用 `sourceCompatibility = VERSION_17`
- Android SDK: `D:\Dev\Android\SDK`
- Gradle 8.11.1wrapper 内置)
- AGP 8.9.1 / Kotlin 2.1.0
`local.properties` 已在项目根(`sdk.dir=D:\Dev\Android\SDK`),**不要提交**。
## 构建与安装
```bash
# 编译 debug APK
export JAVA_HOME="D:/Dev/JDK-17" ANDROID_HOME="D:/Dev/Android/SDK"
./gradlew assembleDebug
# 产物位置
app/build/outputs/apk/debug/app-debug.apk
# 安装到已连接的设备
"D:/Dev/Android/SDK/platform-tools/adb.exe" install -r app/build/outputs/apk/debug/app-debug.apk
```
无 lint / 测试任务配置(项目未启用)。改完代码直接 `assembleDebug` 验证即可。
构建可分发的 release APK(已绑定 debug 签名,产出可直接安装):
```bash
export JAVA_HOME="D:/Dev/JDK-17" ANDROID_HOME="D:/Dev/Android/SDK"
./gradlew assembleRelease
# 产物:app/build/outputs/apk/release/app-release.apk
adb install -r app/build/outputs/apk/release/app-release.apk
```
> `release` 复用 `debug` 签名配置(`signingConfigs.getByName("debug")`),适合个人发布到 GitHub Release;正式商店上架前需替换为独立的 release keystore。
## 架构
### 核心数据流
```
NotifyListenerService (系统通知入口)
→ NotificationProcessor.process(sbn) → Decision(message, reason, code)
→ BarkClient.push(msg) → OkHttp POST
→ LogStore.addLog(...)
失败 → 加入 pending → RetryWorker (WorkManager, 15min 周期)
```
### 包结构 `com.a2i.forwarder`
| 文件 | 职责 |
|---|---|
| `A2iApp.kt` | `Application` 单例;持有全局 `appScope` 和三个 Store`settings``appRules``logStore`)。Store 都用 `MutableStateFlow`UI 通过 `collectAsState()` 订阅。 |
| `service/NotifyListenerService.kt` | `NotificationListenerService` 实现;每条通知调 `processor.process()`,过滤后推送。 |
| `core/NotificationProcessor.kt` | **核心决策点**。包含 LRU 去重缓存(5s)、脱敏检测(`*****` → 回退到 `tickerText`)、系统状态消息过滤、广告过滤、验证码提取、特殊 App 解析。返回 `Decision(message?, reason, appLabel, code)`。 |
| `core/CodeExtractor.kt` | 三段正则:关键词+数字、数字+关键词、纯数字 4-8 位。检测到全 `*****` 时直接返回 null。 |
| `core/AdFilter.kt` | 内置营销关键词 + 用户自定义关键词(`settings.adKeywords`)。 |
| `core/AppParsers.kt` | 微信/QQ/Telegram 的 `clickUrl` 映射 + 重要消息关键词(@我、验证码、通话等)。 |
| `core/BarkClient.kt` | OkHttp POST 到 `${server}/push`,超时 10/10/15s。 |
| `core/RetryWorker.kt` | WorkManager 周期任务,每 15 分钟重发 `pending` 队列中的失败消息。 |
| `core/LogStore.kt` | 最近 300 条日志 + 失败重发队列(≤100)。DataStore 持久化。 |
| `core/IconExporter.kt` | 一键导出所有已安装 App 图标到用户选择的 SAF 目录(用于部署到 iOS Bark 图床)。 |
| `store/SettingsStore.kt` | Bark 服务器列表、全局开关、广告关键词等。`MutableStateFlow` + DataStore。 |
| `store/AppRulesStore.kt` | 黑/白名单模式 + 默认黑名单(系统 App + 三星剪贴板/键盘 + GMS)。 |
| `ui/nav/AppNav.kt` | 4 个底部 Tab + 日志页。 |
| `ui/theme/*` | 固定品牌配色(`Color.kt`)、自定义 `Typography``Type.kt`),`A2iTheme` 关闭动态取色、统一深浅色方案(`Theme.kt`)。 |
| `ui/screens/*.kt` | HomeScreen(品牌横幅 + 运行状态面板 + 统计指标)、AppManageScreen、FilterScreen、SettingsScreen、LogScreen。 |
| `ui/Components.kt` | 共享 UI 组件层:`ScreenTitle``SectionCard``SwitchRow``ClickRow``IconBox``StatusBadge``MetricBlock`,以及 `appVersionName(context)``drawableToImageBitmap(d)``isNotificationListenerEnabled(context)` 工具函数。 |
### 重要约定
- **Store 全局单例**:所有 Store 通过 `A2iApp.instance.xxx` 访问。**不要**在 Compose 中创建新的 Store 实例。
- **协程作用域**:跨屏调用用 `app.appScope.launch { ... }`Application 级别的 `SupervisorJob + Dispatchers.Default`)。
- **过滤流水线**(顺序很重要,在 `NotificationProcessor.process` 中):
1. 全局开关 → 2. ongoing 过滤 → 3. App 黑/白名单 → 4. 空内容/脱敏 → 5. 噪音包名 → 6. 系统状态消息 → 7. 近空内容 → 8. 广告过滤 → **9. 去重检查(5s 窗口)** → 10. 验证码提取 → 11. 特殊 App 解析 → 12. 构造 BarkMessage
- **Bark API** 字段映射在 `BarkMessage.kt``@SerialName`):`device_key`/`autoCopy`/`isArchive` 用下划线/驼峰别名,其他用蛇形。
- **版本号**`app/build.gradle.kts``versionCode`/`versionName`,运行时通过 `appVersionName(context)` 读取,首页底部居中显示 `v{version}`。改完代码记得同步递增。
### 通知监听授权
监听服务依赖 `Settings.ACTION_NOTIFICATION_LISTENER_SETTINGS` 授权。`HomeScreen` 检测授权状态,未授权时显示跳转按钮。授权是**一次性**的,卸载后需重新授权。
### 调试日志
`NotifyListenerService.handle()` 每次都会写一条 `ForwardLog``LogStore`(无论转发/过滤/失败),可在「日志」页查看最近 300 条。失败会自动加入 `pending` 队列,由 `RetryWorker` 后续重发。
## 版本与发布
- 当前版本:`versionCode = 3``versionName = "1.2.0"`(位于 `app/build.gradle.kts`)。改完代码记得同步递增。
- 远端仓库:`https://github.com/lsxf/a2i.git`,默认分支 `main`
- 提交策略:`.gitignore` 已排除 `app/build``.gradle``.kotlin``local.properties``.claude/``do.ps1`、截图与临时文件,**不要**提交它们。
- 发版流程:本地打 tag`git tag -a v1.2.0 -m "..."`)→ `git push origin v1.2.0` → 在 GitHub Releases 基于 tag 发布,并上传 `app-release.apk` 作为二进制附件。
- UI 现状:统一品牌科技感主题(固定品牌色 + 自定义 Typography + 共享组件层),深浅色双方案,关闭动态取色;新组件统一通过 `ui/Components.kt` 复用,避免各页面各拼一套样式。
+181 -49
View File
@@ -1,83 +1,209 @@
# a2i # GotMsg ·有消息
把安卓手机上的通知(短信、验证码、微信、QQ、Telegram)实时转发到 iOS,通过 [Bark](https://github.com/Finb/Bark) 推送到 iPhone / iPad。在 iOS 上点击通知,还能一键跳转到对应的 App 把安卓手机上的通知(短信、验证码、电话、微信、QQ、Telegram)实时转发到 iOS / Android / 鸿蒙等接收端
适用于把主力安卓机的消息同步到 iOS 设备,不漏验证码、不漏重要聊天。 适用于把安卓机的消息同步到 iPhone 或其他安卓 / 鸿蒙手机,不漏验证码、不漏重要聊天。
## 主要功能 ![GotMsg 设置页](screenshots/overview.png)
- **系统级通知监听**:接入 Android 通知监听服务,覆盖所有发出通知的 App。 ---
- **Bark 推送**:支持官方 Bark 服务和自建 Bark Server,可保存多个服务器并随时切换。
- **验证码自动提取**:自动识别通知中的验证码,收到即复制,也可点击复制。
- **通知优化**
- 广告关键词过滤(内置 + 自定义)
- 持续性通知过滤(音乐、下载、通话等 ongoing)
- 内容脱敏与去重
- 微信 / QQ / Telegram 专项优化(发送者作标题、按会话分组、重要消息提级、跳转 iOS 对应 App)
- **应用级规则**:黑名单 / 白名单两种模式,可按 App 单独控制是否转发。
- **失败自动重试**:发送失败的通知进入待重发队列,由 WorkManager 定期重试,也可手动触发。
- **本地日志**:保留最近 300 条处理记录,方便排查为什么某条通知被过滤或发送失败。
- **图标导出**:一键导出已安装 App 的图标,便于部署到 Bark 图床,让通知带上来源图标。
## 快速开始 ## 一、五分钟上手
### 1. 安装 ### 1. 安装
从 [Releases](../../releases) 下载最新 APK,安装到安卓手机(需 Android 14 / API 34+)。
从 [Releases](../../releases) 下载最新 APK,安装到安卓手机(需 Android 14 / API 34 及以上)。 ### 2. 选一个推送通道
推荐三选一:
### 2. 配置 Bark | 接收设备 | 推荐通道 | 难度 |
|---|---|---|
| iPhone / iPad | **Bark** | ⭐ 最简单 |
| 安卓手机 | **ntfy** | ⭐ 推荐开源 |
| 鸿蒙手机 | **Meow** | 鸿蒙专属 |
1. 在 iOS 上安装 [Bark](https://apps.apple.com/us/app/bark-customed-push/id1613210986) 打开 GotMsg → 「设置」→ 选对应通道 → 点右上角 **i** 按钮 → 按弹窗步骤配 → 点该行「发送」测试
2. 打开 Bark,复制你的专属推送地址,形如 `https://api.day.app/你的Key`
3. 在 a2i 的「设置」中添加服务器:
- 名称:随意,例如「我的 iPhone」
- 服务器地址:`https://api.day.app`(自建则填自建地址)
- Device KeyBark 地址中的那段 Key
### 3. 授权通知监听 ### 3. 授权通知监听
首页会提示「需要授权通知监听」。点击「前往系统授权」,在系统的通知访问设置中开启 GotMsg。
打开 a2i,首页会提示「需要授权通知监听」。点击「前往系统授权」,在系统的通知访问设置中开启 a2i。
> 授权是一次性的,卸载后重新安装需要再次授权。
### 4. 开启转发 ### 4. 开启转发
首页打开「通知转发」开关。安卓收到通知后按配置自动推送到各通道。
回到首页,打开「通知转发」开关。此时安卓收到的通知就会按规则推送到 iOS。 ---
## 使用建议 ## 二、推送通道配置详解
- **只想收验证码**:进入「过滤」页,开启「验证码自动提取」,把「广告过滤」等按需开启;再到「应用」页切到白名单模式,只勾选短信类 App 每条配置都可独立勾选/删除/排序/测试。点「添加」时建议每条都先「发送」测试一次
### 2.1 BarkiOS / iPadOS
[Bark](https://github.com/Finb/Bark) 是 iOS 上最轻量的推送方案,复制地址即用,**推荐所有 iOS 用户首选**。
**配置步骤**
1. iPhone / iPad 上安装 Bark,首次打开允许通知权限。
2. 复制 Bark 首页的推送地址,形如 `https://api.day.app/你的Key`
3. GotMsg「设置 → Bark 服务器 → 添加」:
- **名称**:随意,例如「我的 iPhone」
- **服务器地址**`https://api.day.app`(自建则填自建地址)
- **Device Key**Bark 地址中 `/` 后面的那段
4. 保存后点「发送」测试;iPhone 收到「GotMsg 测试」即成功。
**为什么推荐 Bark**:iOS 上点击 Bark 通知可一键跳转到微信、QQ、Telegram 对应 AppiOS 14+ 通知体验最自然;Bark 服务器在中国大陆可直接访问。
### 2.2 ntfyAndroid / iOS / 网页)
[ntfy](https://ntfy.sh) 是开源的 HTTP pub-sub 通知服务,**支持 Android、iOS 和网页端**,无需注册项目。
**配置步骤**
1. 接收端安装 ntfy
- Android[Google Play](https://play.google.com/store/apps/details?id=io.heckel.ntfy) / [F-Droid](https://f-droid.org/en/packages/io.heckel.ntfy/) / [GitHub](https://github.com/binwiederhier/ntfy)
- iOS[App Store](https://apps.apple.com/us/app/ntfy/id1625396347)
2. 打开 ntfy → 添加订阅。服务器填 `https://ntfy.sh`(或自建地址);Topic 用一串**随机难猜的名字**(如 `gotmsg_a8f3d2`)。
3. GotMsg「设置 → ntfy 转发 → 添加」:
- **服务器**:与接收端完全一致
- **Topic**:与接收端完全一致
- **Token**:仅自建服务且启用了鉴权时填
4. 保存后点「发送」测试;接收端收到「GotMsg 测试」即成功。
> **安全提示**:公共 topic 谁知道名字就能订阅或发送。Topic 名**不要用**手机号、姓名、邮箱等可猜信息。
### 2.3 Meow(鸿蒙 / HarmonyOS
Meow 是鸿蒙手机的推送接收方案。GotMsg 按你填写的 Meow 接口地址和 Device Key 发出 HTTP 请求即可。
**配置步骤**
1. 鸿蒙手机上安装 Meow,确认 Meow 自身能正常接收推送。
2. 在 Meow 里找到**推送接口地址**和对应的 **Device Key / Token**
3. GotMsg「设置 → Meow 转发 → 添加」:
- **名称**:按鸿蒙设备填写(方便区分多台)
- **服务器地址**:Meow 给出的完整 API 地址
- **Device Key**Meow 给你的设备 Key(**不是**鸿蒙锁屏密码、华为账号密码)
4. 保存后点「发送」测试;鸿蒙手机收到「GotMsg 测试」即成功。
> 失败排查:检查 Meow 接口地址是否完整、Key 是否复制完整、鸿蒙手机是否允许 Meow 通知。
### 2.4 电邮转发
通过 SMTP 把通知发到邮箱(**适合无手机接收的场景**,如仅在桌面/平板查看)。
**配置步骤**
1. 准备一个 SMTP 邮箱(QQ/163/Gmail/Outlook 都可),**开启 SMTP 服务并生成授权码**(不是登录密码)。
- QQ:设置 → 账户 → POP3/IMAP/SMTP/Exchange/CardDAV/CalDAV服务 → 开启 SMTP → 生成授权码
- 163:设置 → POP3/SMTP/IMAP → 开启 SMTP → 设置授权码
- Gmail:账户 → 安全性 → 两步验证 → 应用专用密码
2. GotMsg「设置 → 电邮转发 → 添加」:
- **名称**:随意
- **服务器(SMTP 主机)**`smtp.qq.com` / `smtp.163.com` / `smtp.gmail.com`
- **端口**`465`(推荐,隐式 SSL)或 `587`STARTTLS
- **账号**:完整邮箱地址
- **授权码 / 密码**:上一步生成的授权码
- **发件人**:通常与账号相同
- **收件人**:接收通知的目标邮箱
3. 保存后点「发送」测试;收件人收到「GotMsg 测试」即成功。
> **国内邮箱推荐端口 465**QQ/163 均支持)。Gmail 需要应用专用密码而不是账户密码。
### 2.5 断网短信兜底
**当安卓手机没有网络时**,把重要通知(验证码、来电)通过 **SIM 卡短信** 发到另一台手机(通常是你的 iPhone)。需要安卓卡里还有短信套餐余量。
**配置步骤**
1. 在 GotMsg「设置 → 短信兜底」点「授予短信权限」(安卓系统权限)。
2. 点「添加」输入**目标手机号**(如 iPhone 号,接收端是另一台手机,**不是** GotMsg 本身)。
3. 选运营商:
- 选了「自动识别」:GotMsg 会发免费查询短信给运营商服务号,解析回执获取余量。
- 选了「手动额度」:在「手动短信额度」填入当前套餐短信条数,每次发短信后自动减 1。
4. 当余量 ≤ 5 时自动暂停,下次打开 App 提醒续费。
5. 频率限制:每 5 分钟最多发 1 条(避免刷屏),同一条通知 5 分钟内不重复。
> **适用场景**:安卓手机没 WiFi 时,验证码不会丢,漏不了重要登录。
---
## 三、自建推送服务(可选)
所有通道都支持自建,更好、更私密、可控。
### 3.1 自建 Bark 服务器
GotMsg 默认用 Bark 官方服务器(`https://api.day.app`),免费够用。自建适合追求完全控制。
**Docker 一键部署**(推荐):
```bash
docker run -d --name bark --restart=unless-stopped \
-p 8080:8080 \
-v /var/lib/bark:/data \
finab/bark-server
```
然后用 Nginx Proxy Manager 加 HTTPS 域名。详细三种方案对比请看 [Bark 官方文档](https://github.com/Finb/Bark)。
**GotMsg 配置**:服务器地址填 `https://你的域名.com`(不要 `/push` 后缀),Device Key 填你在自建 Bark 上创建的 Key。
### 3.2 自建 ntfy 服务器
[ntfy](https://ntfy.sh) 是为自建设计的,**比 Bark 还简单**——官方直接提供 Docker 一行启动:
```bash
docker run -d --name ntfy --restart=unless-stopped \
-p 80:80 \
-v /var/lib/ntfy:/var/lib/ntfy \
binwiederhier/ntfy serve
```
推荐加 Nginx 反代上 HTTPS 域名。完整文档:[docs.ntfy.sh](https://docs.ntfy.sh/install/)。
**GotMsg 配置**:服务器地址填 `https://ntfy.你的域名.com`Topic 照旧。
### 3.3 自建 Meow 服务器
Meow 是鸿蒙客户端,服务端一般用 Meow 自带的。GotMsg 端无需配置服务端,只需填 Meow 给你的接口地址 + Key。
---
## 四、使用建议
- **只想收验证码**:进入「过滤」页,开启「验证码自动提取」和「广告过滤」;再到「应用」页切到白名单模式,只勾选短信类 App。
- **想同步聊天**:保持黑名单模式(默认),在「过滤」页开启「微信 / QQ / Telegram 优化」。 - **想同步聊天**:保持黑名单模式(默认),在「过滤」页开启「微信 / QQ / Telegram 优化」。
- **通知没推过来**:先看「日志」页,每条通知(无论转发、过滤还是失败)都会记录原因。 - **通知没推过来**:先看「日志」页,每条通知(无论转发、过滤还是失败)都会记录原因。
- **想让通知带图标**参考下方「应用图标」一节。 - **想让通知带图标**下方「应用图标」一节。
- **电话通知不工作**:MIUI 不向第三方监听器分发系统电话通知,GotMsg 通过轮询 `getCallState()` 绕过此限制。需授予「电话」权限(设置 → 应用 → GotMsg → 权限 → 电话)。
- **断网收不到通知**:开启「设置 → 短信兜底」并填好 iPhone 号码、授予短信权限。断网时验证码和来电走短信(消耗套餐额度,5 分钟限 1 条)。
- **想全平台通知**:可同时启用多个通道(勾选每个通道的多个条目),所有启用的会同时收到。
## 应用图标 ---
Bark 的 `icon` 字段需要一个公网可访问的图片 URL。a2i 支持把已安装 App 的图标批量导出到你选择的目录,方便上传到你自己的图床。 ## 五、应用图标
Bark 和 ntfy 的通知 `icon` 字段需要公网可访问的图片 URL。GotMsg 支持把已安装 App 的图标批量导出,方便上传到你自己的图床。
规则:转发时 `icon = 图标 URL 前缀 + 安卓包名 + .png`。在「设置 → 应用图标」里: 规则:转发时 `icon = 图标 URL 前缀 + 安卓包名 + .png`。在「设置 → 应用图标」里:
1. 填写图标 URL 前缀,例如 `https://你的图床域名/icons/` 1. 填写图标 URL 前缀,例如 `https://你的图床域名/icons/`
2. 选择一个目录,点「导出图标」,把所有 App 图标以 `包名.png` 形式导出。 2. 选择一个目录,点「导出图标」,把该目录下所有 App 图标以 `包名.png` 形式导出。
3. 把导出的图片上传到你的图床对应目录。 3. 把导出的图片上传到你的图床对应目录。
留空前缀则不显示图标。 留空前缀则不显示图标。
## 技术栈 ---
## 六、技术栈
- Kotlin + Jetpack ComposeMaterial 3 - Kotlin + Jetpack ComposeMaterial 3
- AndroidX DataStore(持久化设置与日志) - AndroidX DataStore(持久化设置与日志)
- OkHttpBark 推送 - OkHttpBark / ntfy / Meow / 电邮 / 更新检查
- WorkManager(失败重试) - WorkManager(失败重试)
- Navigation Compose(单 Activity 多页面) - Navigation Compose(单 Activity 多页面)
- 最低 Android 14API 34),目标 Android 16API 36 - 最低 Android 14API 34),目标 Android 16API 36
## 本地构建 ---
## 七、构建与安装
需要 JDK 17 和 Android SDK。 需要 JDK 17 和 Android SDK。
```bash ```bash
# 设置环境变量(按你的安装路径) # 设置环境变量
export JAVA_HOME=/path/to/jdk-17 export JAVA_HOME=/path/to/jdk-17
export ANDROID_HOME=/path/to/android-sdk export ANDROID_HOME=/path/to/android-sdk
@@ -88,18 +214,18 @@ export ANDROID_HOME=/path/to/android-sdk
./gradlew assembleRelease ./gradlew assembleRelease
``` ```
产物位置 产物:
- debug`app/build/outputs/apk/debug/app-debug.apk` - debug`app/build/outputs/apk/debug/app-debug.apk`
- release`app/build/outputs/apk/release/app-release.apk` - release`app/build/outputs/apk/release/app-release.apk`
安装到已连接的设备 安装:
```bash ```bash
adb install -r app/build/outputs/apk/release/app-release.apk adb install -r app/build/outputs/apk/release/app-release.apk
``` ```
## 界面截图 ---
## 八、界面截图
| 首页 | 应用管理 | 过滤设置 | | 首页 | 应用管理 | 过滤设置 |
|---|---|---| |---|---|---|
@@ -109,23 +235,29 @@ adb install -r app/build/outputs/apk/release/app-release.apk
|---|---| |---|---|
| ![设置](screenshots/settings.png) | ![转发日志](screenshots/log.png) | | ![设置](screenshots/settings.png) | ![转发日志](screenshots/log.png) |
## 工作原理 ---
## 九、工作原理
``` ```
Android 系统通知 安卓系统通知
└─ NotifyListenerService 接收 └─ NotifyListenerService 接收
└─ NotificationProcessor 处理流水线 └─ NotificationProcessor 流水线
├─ 全局开关 / ongoing 过滤 / 黑白名单 ├─ 全局开关 / ongoing 过滤 / 黑白名单
├─ 空内容、脱敏、噪音、系统状态过滤 ├─ 空内容、脱敏、噪音、系统状态过滤
├─ 广告关键词过滤 ├─ 广告关键词过滤
├─ 去重(5 秒窗口) ├─ 去重(5 秒窗口)
├─ 验证码提取 ├─ 验证码提取
└─ 微信 / QQ / Telegram 专项解析 └─ 微信 / QQ / Telegram 专项解析
└─ BarkClient 推送到 Bark Server └─ 按配置推送到 Bark / ntfy / Meow / 电邮 / 短信
└─ 失败 → 待重发队列 → RetryWorker 定期重试 └─ 失败 → 待重发队列 → RetryWorker 定期重试
└─ 写入本地日志(最近 300 条) └─ 写入本地日志(最近 300 条)
``` ```
---
## License ## License
本项目仅供个人使用。 本项目仅供个人使用。
All Rights Reserved. 保留所有权利。
+2 -2
View File
@@ -13,8 +13,8 @@ android {
applicationId = "com.a2i.forwarder" applicationId = "com.a2i.forwarder"
minSdk = 34 minSdk = 34
targetSdk = 36 targetSdk = 36
versionCode = 3 versionCode = 38
versionName = "1.2.0" versionName = "1.9.9"
} }
signingConfigs { signingConfigs {
+13
View File
@@ -4,11 +4,14 @@
<uses-permission android:name="android.permission.INTERNET" /> <uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" /> <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" <uses-permission android:name="android.permission.QUERY_ALL_PACKAGES"
tools:ignore="QueryAllPackagesPermission" /> tools:ignore="QueryAllPackagesPermission" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" /> <uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" /> <uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" /> <uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
<application <application
android:name=".A2iApp" android:name=".A2iApp"
@@ -43,5 +46,15 @@
</intent-filter> </intent-filter>
</service> </service>
<provider
android:name="androidx.core.content.FileProvider"
android:authorities="${applicationId}.fileprovider"
android:exported="false"
android:grantUriPermissions="true">
<meta-data
android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/file_paths" />
</provider>
</application> </application>
</manifest> </manifest>
@@ -1,7 +1,14 @@
package com.a2i.forwarder package com.a2i.forwarder
import android.app.Application 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.LogStore
import com.a2i.forwarder.core.PhoneCallMonitor
import com.a2i.forwarder.core.SmsFallbackForwarder
import com.a2i.forwarder.core.Telemetry
import com.a2i.forwarder.core.UpdateChecker
import com.a2i.forwarder.store.AppRulesStore import com.a2i.forwarder.store.AppRulesStore
import com.a2i.forwarder.store.SettingsStore import com.a2i.forwarder.store.SettingsStore
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
@@ -17,6 +24,18 @@ class A2iApp : Application() {
private set private set
lateinit var logStore: LogStore lateinit var logStore: LogStore
private set 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
lateinit var telemetry: Telemetry
private set
override fun onCreate() { override fun onCreate() {
super.onCreate() super.onCreate()
@@ -24,6 +43,16 @@ class A2iApp : Application() {
settings = SettingsStore(this, appScope) settings = SettingsStore(this, appScope)
appRules = AppRulesStore(this, appScope) appRules = AppRulesStore(this, appScope)
logStore = LogStore(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()
telemetry = Telemetry(this, appScope)
telemetry.start()
} }
companion object { companion object {
@@ -3,6 +3,7 @@ package com.a2i.forwarder.core
import com.a2i.forwarder.model.BarkMessage import com.a2i.forwarder.model.BarkMessage
import com.a2i.forwarder.model.BarkServer import com.a2i.forwarder.model.BarkServer
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import kotlinx.serialization.encodeToString import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json import kotlinx.serialization.json.Json
@@ -10,6 +11,7 @@ import okhttp3.MediaType.Companion.toMediaType
import okhttp3.OkHttpClient import okhttp3.OkHttpClient
import okhttp3.Request import okhttp3.Request
import okhttp3.RequestBody.Companion.toRequestBody import okhttp3.RequestBody.Companion.toRequestBody
import java.io.IOException
import java.util.concurrent.TimeUnit import java.util.concurrent.TimeUnit
class BarkClient(private val server: BarkServer) { class BarkClient(private val server: BarkServer) {
@@ -24,16 +26,37 @@ class BarkClient(private val server: BarkServer) {
suspend fun push(msg: BarkMessage): Result<Unit> = withContext(Dispatchers.IO) { suspend fun push(msg: BarkMessage): Result<Unit> = withContext(Dispatchers.IO) {
runCatching { runCatching {
require(server.deviceKey.isNotBlank()) { "device key 为空" } require(server.deviceKey.isNotBlank()) { "device key 为空" }
val body = json.encodeToString(msg).toRequestBody(JSON) doPushWithRetry(msg)
val url = server.server.trim().removeSuffix("/") + "/push" }
val req = Request.Builder().url(url).post(body).build() }
http.newCall(req).execute().use { res ->
if (!res.isSuccessful) error("HTTP ${res.code}") /**
* 带重试的推送:对 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 { companion object {
private val JSON = "application/json; charset=utf-8".toMediaType() private val JSON = "application/json; charset=utf-8".toMediaType()
private const val MAX_ATTEMPTS = 3
private val BACKOFF_MS = longArrayOf(600L, 1200L)
} }
} }
@@ -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,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,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)
}
@@ -16,6 +16,7 @@ class NotificationProcessor(private val context: Context) {
val reason: String, val reason: String,
val appLabel: String, val appLabel: String,
val code: String?, val code: String?,
val silent: Boolean = false, // true = 静默过滤,不写日志
) )
// ========== 去重缓存 ========== // ========== 去重缓存 ==========
@@ -48,6 +49,15 @@ class NotificationProcessor(private val context: Context) {
"com.google.android.gms", "com.google.android.gms",
) )
// 静默过滤的包名:代理/VPN 客户端的常驻流量通知,高频且无意义,过滤不写日志
private val silentPackages = setOf(
"com.getsurfboard", // Surfboard
"com.github.metacubex.clash.meta", // Clash Meta for Android
"com.github.kr328.clash", // Clash for Android
"com.v2ray.ang", // v2rayNG
"com.milink.service", // 小米设备互联(UniversalClipboardService 跨设备剪贴板等常驻通知)
)
fun process(sbn: StatusBarNotification): Decision { fun process(sbn: StatusBarNotification): Decision {
val pkg = sbn.packageName val pkg = sbn.packageName
val n = sbn.notification ?: return Decision(null, "无通知", pkg, null) val n = sbn.notification ?: return Decision(null, "无通知", pkg, null)
@@ -88,10 +98,18 @@ class NotificationProcessor(private val context: Context) {
if (!settings.forwardingEnabled.value) if (!settings.forwardingEnabled.value)
return Decision(null, "全局转发已关", appLabel, null) return Decision(null, "全局转发已关", appLabel, null)
// ---- 持续性通知过滤 ---- // ---- 持续性通知过滤(静默,不进日志)----
val isOngoing = n.flags and Notification.FLAG_ONGOING_EVENT != 0 val isOngoing = n.flags and Notification.FLAG_ONGOING_EVENT != 0
if (settings.skipOngoing.value && isOngoing) if (settings.skipOngoing.value && isOngoing)
return Decision(null, "持续性通知", appLabel, null) 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 黑白名单 ---- // ---- App 黑白名单 ----
if (!rules.isForwardingOn(pkg)) if (!rules.isForwardingOn(pkg))
@@ -122,6 +140,16 @@ class NotificationProcessor(private val context: Context) {
if (settings.adFilterEnabled.value && AdFilter.isAd("$title $content $sub", settings.adKeywords.value)) if (settings.adFilterEnabled.value && AdFilter.isAd("$title $content $sub", settings.adKeywords.value))
return Decision(null, "广告过滤", appLabel, null) 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 dedupKey = "$pkg|${title.take(80)}|${content.take(80)}"
val lastTime = dedupCache[dedupKey] val lastTime = dedupCache[dedupKey]
@@ -160,7 +188,7 @@ class NotificationProcessor(private val context: Context) {
val level = if (important) "timeSensitive" else "active" val level = if (important) "timeSensitive" else "active"
val msg = BarkMessage( val msg = BarkMessage(
deviceKey = settings.currentServer.value?.deviceKey.orEmpty(), deviceKey = "",
title = msgTitle.take(100), title = msgTitle.take(100),
body = content.ifBlank { title }.take(500), body = content.ifBlank { title }.take(500),
level = level, level = level,
@@ -188,4 +216,47 @@ class NotificationProcessor(private val context: Context) {
private fun isSystemStatusMessage(text: String): Boolean { private fun isSystemStatusMessage(text: String): Boolean {
return systemStatusPatterns.any { it.containsMatchIn(text) } 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 APIPOST / with {topic,title,message})。
* 注:raw body 会被 ntfy 当附件,必须用 JSON API 或 Message header。
* 对瞬时 IOException 自动重试 3 次(退避 600/1200ms)。
*/
object NtfySender {
private val http = OkHttpClient.Builder()
.connectTimeout(10, TimeUnit.SECONDS)
.writeTimeout(10, TimeUnit.SECONDS)
.readTimeout(15, TimeUnit.SECONDS)
.retryOnConnectionFailure(true)
.build()
suspend fun send(
server: String, topic: String, token: String,
title: String, body: String,
): Result<Unit> = withContext(Dispatchers.IO) {
runCatching {
val payload = JSONObject()
.put("topic", topic.trim())
.put("title", title.take(200))
.put("message", body.take(4000))
.toString()
val req = Request.Builder()
.url(server.trim().removeSuffix("/"))
.apply { if (token.isNotBlank()) header("Authorization", "Bearer $token") }
.post(payload.toRequestBody(JSON))
.build()
doPushWithRetry(req)
}
}
/** 多配置推送:stopOnFirst=true 首个成功止;false 全部发送,至少一个成功即算成功。 */
suspend fun pushToEnabled(configs: List<NtfyServer>, title: String, body: String, stopOnFirst: Boolean): Result<Unit> = withContext(Dispatchers.IO) {
runCatching {
require(configs.any()) { "无 ntfy 配置" }
var anyOk = false
var lastErr: Throwable? = null
for (c in configs) {
val r = send(c.server, c.topic, c.token, title, body)
if (r.isSuccess) {
anyOk = true
if (stopOnFirst) return@runCatching
} else {
lastErr = r.exceptionOrNull()
}
}
if (!anyOk) throw lastErr ?: error("全部 ntfy 推送失败")
}
}
private fun doPushWithRetry(req: Request) {
var lastError: IOException? = null
repeat(MAX_ATTEMPTS) { attempt ->
try {
http.newCall(req).execute().use { res ->
if (res.isSuccessful) return
error("HTTP ${res.code}")
}
} catch (e: IOException) {
lastError = e
if (attempt < MAX_ATTEMPTS - 1) {
Thread.sleep(BACKOFF_MS[attempt])
}
} catch (e: IllegalStateException) {
// HTTP 错误码(error() 抛 IllegalStateException),不重试
throw e
}
}
throw lastError ?: error("ntfy 推送失败")
}
fun trySend(app: A2iApp, title: String, body: String, scope: CoroutineScope) {
val s = app.settings
val configs = s.ntfyServers.value.filter { it.enabled && it.topic.isNotBlank() }
if (configs.isEmpty()) return
scope.launch {
pushToEnabled(configs, title, body, s.ntfyStopOnFirst.value)
}
}
private val JSON = "application/json; charset=utf-8".toMediaType()
private const val MAX_ATTEMPTS = 3
private val BACKOFF_MS = longArrayOf(600L, 1200L)
}
@@ -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)
}
}
}
@@ -24,13 +24,11 @@ class RetryWorker(ctx: Context, params: WorkerParameters) : CoroutineWorker(ctx,
var allOk = true var allOk = true
val json = Json { ignoreUnknownKeys = true } val json = Json { ignoreUnknownKeys = true }
val servers = settings.servers.value.filter { it.enabled && it.deviceKey.isNotBlank() }
for (p in list) { for (p in list) {
val server = settings.servers.value.firstOrNull { it.id == p.serverId } if (servers.isEmpty()) { allOk = false; break }
?: settings.currentServer.value
?: continue
if (server.deviceKey.isBlank()) { allOk = false; continue }
val msg = runCatching { json.decodeFromString<com.a2i.forwarder.model.BarkMessage>(p.messageJson) }.getOrNull() ?: continue val msg = runCatching { json.decodeFromString<com.a2i.forwarder.model.BarkMessage>(p.messageJson) }.getOrNull() ?: continue
val r = BarkClient(server).push(msg) val r = BarkSender.pushToFirst(servers, msg)
if (r.isSuccess) store.removePending(p.id) else allOk = false if (r.isSuccess) store.removePending(p.id) else allOk = false
} }
return if (allOk) Result.success() else Result.retry() return if (allOk) Result.success() else Result.retry()
@@ -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,140 @@
package com.a2i.forwarder.core
import android.content.Context
import android.net.ConnectivityManager
import android.net.NetworkCapabilities
import android.os.Build
import android.provider.Settings
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.RequestBody.Companion.toRequestBody
import org.json.JSONObject
import java.util.Locale
import java.util.TimeZone
import java.util.UUID
import java.util.concurrent.TimeUnit
class Telemetry(
private val context: Context,
private val scope: CoroutineScope,
) {
private val prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
private val http = OkHttpClient.Builder()
.connectTimeout(8, TimeUnit.SECONDS)
.writeTimeout(8, TimeUnit.SECONDS)
.readTimeout(10, TimeUnit.SECONDS)
.retryOnConnectionFailure(true)
.build()
private val sessionId = UUID.randomUUID().toString()
fun start() {
scope.launch(Dispatchers.IO) {
if (!prefs.getBoolean(KEY_INSTALL_SENT, false)) {
send("install").onSuccess {
prefs.edit().putBoolean(KEY_INSTALL_SENT, true).apply()
}
}
send("launch")
}
}
private suspend fun send(event: String): Result<Unit> = withContext(Dispatchers.IO) {
runCatching {
val payload = basePayload(event).toString().toRequestBody(JSON)
val request = Request.Builder()
.url(EVENTS_URL)
.header("User-Agent", userAgent())
.post(payload)
.build()
http.newCall(request).execute().use { response ->
if (!response.isSuccessful) error("HTTP ${response.code}")
}
}
}
private fun basePayload(event: String): JSONObject {
val version = appVersion()
val locale = Locale.getDefault()
return JSONObject()
.put("app_id", APP_ID)
.put("app_name", APP_NAME)
.put("event", event)
.put("install_id", installId())
.put("device_id", androidId())
.put("session_id", sessionId)
.put("platform", "android")
.put("package_name", context.packageName)
.put("app_version", version.name)
.put("app_build", version.code)
.put("os_version", "Android ${Build.VERSION.RELEASE} SDK ${Build.VERSION.SDK_INT}")
.put("manufacturer", Build.MANUFACTURER.orEmpty())
.put("model", Build.MODEL.orEmpty())
.put("locale", locale.toLanguageTag())
.put("timezone", TimeZone.getDefault().id)
.put("country", locale.country.orEmpty())
.put("network_type", networkType())
.put("user_agent", userAgent())
.put("source", "android-app")
}
private fun installId(): String {
prefs.getString(KEY_INSTALL_ID, null)?.let { return it }
val id = UUID.randomUUID().toString()
prefs.edit().putString(KEY_INSTALL_ID, id).apply()
return id
}
private fun androidId(): String =
Settings.Secure.getString(context.contentResolver, Settings.Secure.ANDROID_ID).orEmpty()
private fun appVersion(): AppVersion {
val info = context.packageManager.getPackageInfo(context.packageName, 0)
return AppVersion(
name = info.versionName ?: "0",
code = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
info.longVersionCode.toString()
} else {
@Suppress("DEPRECATION")
info.versionCode.toString()
},
)
}
private fun networkType(): String {
val manager = context.getSystemService(Context.CONNECTIVITY_SERVICE) as? ConnectivityManager ?: return "unknown"
val network = manager.activeNetwork ?: return "none"
val capabilities = manager.getNetworkCapabilities(network) ?: return "unknown"
return when {
capabilities.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) -> "wifi"
capabilities.hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR) -> "cellular"
capabilities.hasTransport(NetworkCapabilities.TRANSPORT_ETHERNET) -> "ethernet"
capabilities.hasTransport(NetworkCapabilities.TRANSPORT_VPN) -> "vpn"
else -> "other"
}
}
private fun userAgent(): String {
val version = appVersion()
return "GotMsg/${version.name} (${context.packageName}; Android ${Build.VERSION.SDK_INT}; ${Build.MANUFACTURER} ${Build.MODEL})"
}
private data class AppVersion(
val name: String,
val code: String,
)
companion object {
private const val APP_ID = "gotmsg"
private const val APP_NAME = "GotMsg"
private const val EVENTS_URL = "https://stats.songer.eu.org/v1/events"
private const val PREFS_NAME = "gotmsg_telemetry"
private const val KEY_INSTALL_ID = "install_id"
private const val KEY_INSTALL_SENT = "install_sent"
private val JSON = "application/json; charset=utf-8".toMediaType()
}
}
@@ -0,0 +1,313 @@
package com.a2i.forwarder.core
import android.content.Context
import android.content.Intent
import android.net.Uri
import android.provider.Settings
import androidx.core.content.FileProvider
import com.a2i.forwarder.A2iApp
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import okhttp3.OkHttpClient
import okhttp3.Request
import org.json.JSONObject
import java.io.File
import java.util.concurrent.TimeUnit
data class UpdateInfo(
val latestVersion: String,
val apkUrl: String,
val apkSize: Long,
val releaseNotes: String,
val releasePageUrl: String,
val fallbackUrl: String? = null, // 主 URL 下载失败时尝试的备选链接
)
/**
* 应用内更新:定时巡检 Gitea Releases 最新版 → 版本比较 → 醒目横幅 + 自动推送通知 → 下载 APK → 调起系统安装器。
* Android targetSdk≥24 须用 FileProvidertargetSdk≥26 安装未知来源 APK 须 REQUEST_INSTALL_PACKAGES。
*/
class UpdateChecker(private val context: Context) {
private val app get() = A2iApp.instance
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
private val http = OkHttpClient.Builder()
.connectTimeout(10, TimeUnit.SECONDS)
.readTimeout(30, TimeUnit.SECONDS)
.retryOnConnectionFailure(true)
.build()
val updateInfo = MutableStateFlow<UpdateInfo?>(null)
val downloading = MutableStateFlow(false)
val downloadProgress = MutableStateFlow(0)
val downloadSource = MutableStateFlow<String?>(null) // "Gitea" / "GitHub" / null
val lastError = MutableStateFlow<String?>(null)
private var lastNotifiedVersion: String? = null // 记录已推送的版本,避免重复通知
fun start() {
scope.launch {
delay(5000) // 启动 5 秒后首检,给网络/应用初始化留时间
doCheck(throttle = false, toggle = true) // 启动必查(无视节流,但尊重开关)
while (isActive) {
delay(CHECK_INTERVAL_MS) // 常驻期间每 24h 巡检一次
doCheck(throttle = true, toggle = true)
}
}
}
/** 手动检查:无视节流和开关(用户主动)。 */
suspend fun check(force: Boolean): Result<UpdateInfo?> = doCheck(throttle = !force, toggle = !force)
private suspend fun doCheck(throttle: Boolean, toggle: Boolean): Result<UpdateInfo?> = withContext(Dispatchers.IO) {
runCatching {
val settings = app.settings
if (toggle && !settings.updateCheckEnabled.value) return@runCatching updateInfo.value
val now = System.currentTimeMillis()
if (throttle && now - settings.lastUpdateCheckTime.value < CHECK_INTERVAL_MS) return@runCatching updateInfo.value
// 双源查询:Gitea(国内优先)+ GitHub(备用),取最高版本
// 同版本时 Gitea 优先(国内可达,GitHub 可能被墙导致下载失败)
val infos = mutableListOf<UpdateInfo>()
var ghInfo: UpdateInfo? = null
var giteaInfo: UpdateInfo? = null
giteaInfo = runCatching { fetchRelease(GITEA_API) }.getOrNull()
if (giteaInfo != null) infos.add(giteaInfo)
ghInfo = runCatching { fetchRelease(GITHUB_API) }.getOrNull()
if (ghInfo != null) infos.add(ghInfo)
settings.setLastUpdateCheck(now)
if (infos.isEmpty()) {
lastError.value = "所有更新源不可达"
return@runCatching updateInfo.value
}
val current = currentVersionName()
val best = infos
.filter { isNewer(it.latestVersion, current) && it.latestVersion != settings.ignoredVersion.value }
.maxByOrNull { versionKey(it.latestVersion) }
val prev = updateInfo.value
if (best == null) {
updateInfo.value = null
lastNotifiedVersion = null
} else {
// 主 URL 下载失败时尝试另一个源的链接
val fallback = if (best == giteaInfo) ghInfo?.apkUrl else giteaInfo?.apkUrl
updateInfo.value = best.copy(fallbackUrl = fallback)
}
lastError.value = null
// 发现新版且之前没推过通知 → 自动推送到所有已开启通道
if (best != null && best.latestVersion != lastNotifiedVersion) {
lastNotifiedVersion = best.latestVersion
notifyNewVersion(prev, best)
}
updateInfo.value
}.onFailure { lastError.value = it.message ?: "检查失败" }
}
private fun fetchRelease(api: String): UpdateInfo? {
val req = Request.Builder()
.url(api)
.header("Accept", "application/json")
.header("User-Agent", "gotmsg-android")
.get()
.build()
return http.newCall(req).execute().use { res ->
if (!res.isSuccessful) error("HTTP ${res.code}")
parseRelease(res.body?.string() ?: error("空响应"))
}
}
private fun versionKey(v: String): String =
v.split(".").joinToString(".") { (it.substringBefore("-").toIntOrNull() ?: 0).toString().padStart(4, '0') }
/** 发现新版后,自动通过所有已开启的推送通道通知用户 */
private fun notifyNewVersion(prev: UpdateInfo?, info: UpdateInfo) {
val s = app.settings
val title = "🆕 GotMsg 新版本 v${info.latestVersion}"
val body = "发现 v${info.latestVersion}(当前 v${currentVersionName()}\n${formatSize(info.apkSize)} · 点击更新\n\n${info.releaseNotes.take(300)}"
val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
// Bark:通过 BarkSender 推送给第一个启用的服务器
val barkServers = s.servers.value.filter { it.enabled && it.deviceKey.isNotBlank() }
if (barkServers.isNotEmpty()) {
scope.launch {
val msg = com.a2i.forwarder.model.BarkMessage(
deviceKey = "", title = title, body = body, level = "timeSensitive",
group = "a2i_update", isArchive = "1",
)
BarkSender.pushToFirst(barkServers, msg)
}
}
// ntfy
NtfySender.trySend(app, title, body, scope)
// Meow
MeowSender.trySend(app, title, body, scope)
// 电邮
EmailSender.trySend(app, title, body, scope)
}
private fun parseRelease(body: String): UpdateInfo? {
val json = JSONObject(body)
val tag = json.optString("tag_name").removePrefix("v").trim()
if (tag.isEmpty()) return null
val assets = json.optJSONArray("assets") ?: return null
var apkUrl = ""
var apkSize = 0L
for (i in 0 until assets.length()) {
val a = assets.optJSONObject(i) ?: continue
if (a.optString("name").endsWith(".apk", ignoreCase = true)) {
apkUrl = a.optString("browser_download_url")
apkSize = a.optLong("size")
break
}
}
if (apkUrl.isEmpty()) return null
return UpdateInfo(
latestVersion = tag,
apkUrl = apkUrl,
apkSize = apkSize,
releaseNotes = json.optString("body").orEmpty(),
releasePageUrl = json.optString("html_url").orEmpty(),
)
}
suspend fun download(info: UpdateInfo): File? = withContext(Dispatchers.IO) {
downloading.value = true
downloadProgress.value = 0
downloadSource.value = null
lastError.value = null
// 主 URL (Gitea) 失败时自动尝试备选 URL (GitHub)
val sources = listOf(
info.apkUrl to sourceLabel(info.apkUrl),
) + (info.fallbackUrl?.let { listOf(it to sourceLabel(it)) } ?: emptyList())
for ((idx, pair) in sources.withIndex()) {
val (url, label) = pair
if (idx > 0) { downloadProgress.value = 0; downloadSource.value = null }
downloadSource.value = label
val file = tryDownload(url, info.latestVersion, info.apkSize)
if (file != null) {
downloading.value = false
downloadSource.value = null
return@withContext file
}
}
downloading.value = false
downloadSource.value = null
null
}
private fun sourceLabel(url: String): String =
if (url.contains("songer.eu.org") || url.contains("sunlunfan")) "Gitea" else "GitHub"
private suspend fun tryDownload(url: String, version: String, expectedSize: Long): File? {
// 专用下载 client:更长超时(120s),避免大文件慢网超时截断
val dlClient = OkHttpClient.Builder()
.connectTimeout(15, TimeUnit.SECONDS)
.readTimeout(120, TimeUnit.SECONDS)
.retryOnConnectionFailure(true)
.build()
return try {
val req = Request.Builder().url(url).get().build()
dlClient.newCall(req).execute().use { res ->
if (!res.isSuccessful) { lastError.value = "HTTP ${res.code}"; return null }
val body = res.body ?: run { lastError.value = "空响应"; return null }
val total = body.contentLength()
val dir = context.getExternalFilesDir(null) ?: context.cacheDir
val target = File(dir, "gotmsg-${version}.apk")
target.outputStream().use { sink ->
val input = body.byteStream()
val buf = ByteArray(64 * 1024) // 64KB buffer 提升大文件速度
var read = 0L
while (true) {
val n = input.read(buf)
if (n <= 0) break
sink.write(buf, 0, n)
read += n
if (total > 0) downloadProgress.value = (read * 100 / total).toInt()
}
}
// 校验文件大小:Content-Length 或 release 声明的大小
val actualSize = target.length()
val refSize = if (total > 0) total else expectedSize
if (refSize > 0 && actualSize < refSize) {
lastError.value = "文件不完整(${actualSize}/${refSize} 字节),请重试"
target.delete()
return null
}
if (actualSize < 1_000_000) {
lastError.value = "文件过小,可能下载了错误页面"
target.delete()
return null
}
downloadProgress.value = 100
target
}
} catch (e: Exception) {
lastError.value = e.message ?: "下载失败"
null
}
}
fun install(apk: File) {
if (!context.packageManager.canRequestPackageInstalls()) {
val intent = Intent(Settings.ACTION_MANAGE_UNKNOWN_APP_SOURCES).apply {
data = Uri.parse("package:${context.packageName}")
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
}
runCatching { context.startActivity(intent) }
return
}
val uri = FileProvider.getUriForFile(context, "${context.packageName}.fileprovider", apk)
val intent = Intent(Intent.ACTION_VIEW).apply {
setDataAndType(uri, "application/vnd.android.package-archive")
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
}
runCatching { context.startActivity(intent) }
}
fun ignoreVersion(v: String) {
scope.launch {
app.settings.setIgnoredVersion(v)
updateInfo.value = null
}
}
private fun currentVersionName(): String {
return runCatching {
@Suppress("DEPRECATION")
context.packageManager.getPackageInfo(context.packageName, 0).versionName ?: "0"
}.getOrDefault("0")
}
fun isNewer(latest: String, current: String): Boolean {
val l = latest.split(".").map { it.substringBefore("-").toIntOrNull() ?: 0 }
val c = current.split(".").map { it.substringBefore("-").toIntOrNull() ?: 0 }
val n = maxOf(l.size, c.size)
for (i in 0 until n) {
val lv = l.getOrElse(i) { 0 }
val cv = c.getOrElse(i) { 0 }
if (lv != cv) return lv > cv
}
return false
}
companion object {
const val GITHUB_API = "https://api.github.com/repos/lsxf/gotmsg/releases/latest"
const val GITEA_API = "https://gitea.songer.eu.org/api/v1/repos/song/gotmsg/releases/latest"
const val CHECK_INTERVAL_MS = 24L * 60 * 60 * 1000
}
fun formatSize(bytes: Long): String {
if (bytes <= 0) return ""
val kb = bytes / 1024.0
return if (kb >= 1024) String.format("%.1f MB", kb / 1024) else String.format("%.0f KB", kb)
}
}
@@ -9,6 +9,47 @@ data class BarkServer(
val name: String, val name: String,
val server: String, val server: String,
val deviceKey: String, val deviceKey: String,
val enabled: Boolean = true,
) )
const val OFFICIAL_BARK_SERVER = "https://api.day.app" const val OFFICIAL_BARK_SERVER = "https://api.day.app"
@Serializable
data class NtfyServer(
val id: String = UUID.randomUUID().toString(),
val name: String,
val server: String = "https://ntfy.sh",
val topic: String = "",
val token: String = "",
val enabled: Boolean = true,
)
@Serializable
data class MeowServer(
val id: String = UUID.randomUUID().toString(),
val name: String,
val server: String = "",
val deviceKey: String = "",
val enabled: Boolean = true,
)
@Serializable
data class EmailServer(
val id: String = UUID.randomUUID().toString(),
val name: String,
val host: String = "",
val port: Int = 465,
val user: String = "",
val password: String = "",
val from: String = "",
val to: String = "",
val enabled: Boolean = true,
)
@Serializable
data class SmsTarget(
val id: String = UUID.randomUUID().toString(),
val name: String,
val number: String = "",
val enabled: Boolean = true,
)
@@ -3,9 +3,12 @@ package com.a2i.forwarder.service
import android.service.notification.NotificationListenerService import android.service.notification.NotificationListenerService
import android.service.notification.StatusBarNotification import android.service.notification.StatusBarNotification
import com.a2i.forwarder.A2iApp import com.a2i.forwarder.A2iApp
import com.a2i.forwarder.core.BarkClient import com.a2i.forwarder.core.BarkSender
import com.a2i.forwarder.core.EmailSender
import com.a2i.forwarder.core.ForwardLog import com.a2i.forwarder.core.ForwardLog
import com.a2i.forwarder.core.MeowSender
import com.a2i.forwarder.core.NotificationProcessor import com.a2i.forwarder.core.NotificationProcessor
import com.a2i.forwarder.core.NtfySender
import com.a2i.forwarder.core.PendingPush import com.a2i.forwarder.core.PendingPush
import com.a2i.forwarder.core.RetryWorker import com.a2i.forwarder.core.RetryWorker
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
@@ -40,25 +43,45 @@ class NotifyListenerService : NotificationListenerService() {
val msg = d.message val msg = d.message
if (msg == null) { if (msg == null) {
app.logStore.addLog(ForwardLog(now, sbn.packageName, d.appLabel, "", "", "filtered", d.reason)) // 静默过滤(如持续性通知)不写日志
if (!d.silent) {
app.logStore.addLog(ForwardLog(now, sbn.packageName, d.appLabel, "", "", "filtered", d.reason))
}
return return
} }
val server = app.settings.currentServer.value // ---- 断网兜底:离线时紧要通知走短信,其余不转发避免离线堆积 ----
if (server == null || server.deviceKey.isBlank()) { val online = app.connectivityMonitor.isOnline.value
app.logStore.addLog(ForwardLog(now, sbn.packageName, d.appLabel, msg.title, msg.body, "failed", "未配置 Bark key")) 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 return
} }
val result = BarkClient(server).push(msg) 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) { if (result.isSuccess) {
app.logStore.addLog(ForwardLog(now, sbn.packageName, d.appLabel, msg.title, msg.body, "sent", "")) app.logStore.addLog(ForwardLog(now, sbn.packageName, d.appLabel, msg.title, msg.body, "sent", ""))
} else { } else {
val reason = result.exceptionOrNull()?.message ?: "错误" val reason = result.exceptionOrNull()?.message ?: "错误"
app.logStore.addLog(ForwardLog(now, sbn.packageName, d.appLabel, msg.title, msg.body, "failed", reason)) app.logStore.addLog(ForwardLog(now, sbn.packageName, d.appLabel, msg.title, msg.body, "failed", reason))
app.logStore.addPending( app.logStore.addPending(
PendingPush(UUID.randomUUID().toString(), now, server.id, json.encodeToString(msg)) 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)
} }
} }
@@ -4,6 +4,7 @@ import android.content.Context
import androidx.datastore.core.DataStore import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.Preferences import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.edit import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.intPreferencesKey
import androidx.datastore.preferences.core.stringPreferencesKey import androidx.datastore.preferences.core.stringPreferencesKey
import androidx.datastore.preferences.core.stringSetPreferencesKey import androidx.datastore.preferences.core.stringSetPreferencesKey
import androidx.datastore.preferences.preferencesDataStore import androidx.datastore.preferences.preferencesDataStore
@@ -25,6 +26,7 @@ class AppRulesStore(
val MODE = stringPreferencesKey("mode") val MODE = stringPreferencesKey("mode")
val BLACK = stringSetPreferencesKey("blacklist") val BLACK = stringSetPreferencesKey("blacklist")
val WHITE = stringSetPreferencesKey("whitelist") val WHITE = stringSetPreferencesKey("whitelist")
val BL_VERSION = intPreferencesKey("bl_version")
} }
val mode = MutableStateFlow(RuleMode.BLACKLIST) val mode = MutableStateFlow(RuleMode.BLACKLIST)
@@ -35,7 +37,14 @@ class AppRulesStore(
scope.launch { scope.launch {
ds.data.collect { p -> ds.data.collect { p ->
mode.value = runCatching { RuleMode.valueOf(p[K.MODE] ?: "BLACKLIST") }.getOrDefault(RuleMode.BLACKLIST) mode.value = runCatching { RuleMode.valueOf(p[K.MODE] ?: "BLACKLIST") }.getOrDefault(RuleMode.BLACKLIST)
blacklist.value = p[K.BLACK] ?: defaultBlacklist // 默认黑名单版本迁移:把新增的噪音包补进老用户的黑名单(只增不删)
val ver = p[K.BL_VERSION] ?: 1
var black = p[K.BLACK] ?: defaultBlacklist
if (ver < DEFAULT_BL_VERSION) {
black = black + blAdded
ds.edit { it[K.BLACK] = black; it[K.BL_VERSION] = DEFAULT_BL_VERSION }
}
blacklist.value = black
whitelist.value = p[K.WHITE] ?: emptySet() whitelist.value = p[K.WHITE] ?: emptySet()
} }
} }
@@ -69,13 +78,26 @@ class AppRulesStore(
} }
companion object { companion object {
private const val DEFAULT_BL_VERSION = 3
// 默认黑名单增量(累积,用于老用户版本迁移,只补不删)
private val blAdded = setOf(
// v2
"com.android.soundrecorder", // 录音机
"com.google.android.apps.photos", // 相册
"com.xiaomi.finddevice", // 查找手机
"com.miui.passwords", // 密码管理
// v3:通话过程中的系统状态通知,会与来电(PhoneCallMonitor)重复
"com.android.server.telecom", // 通话管理
"com.xiaomi.aiasst.service", // AI通话
)
val defaultBlacklist = setOf( val defaultBlacklist = setOf(
// Android 系统 // Android 系统
"com.android.systemui", "com.android.systemui",
"com.android.settings", "com.android.settings",
"com.android.permissioncontroller", "com.android.permissioncontroller",
"com.android.phone", // 注意:com.android.phone / com.android.incallui 不在黑名单中,电话通知需要转发
"com.android.incallui",
// 输入法 // 输入法
"com.google.android.inputmethod", "com.google.android.inputmethod",
"com.google.android.inputmethod.latin", "com.google.android.inputmethod.latin",
@@ -92,6 +114,14 @@ class AppRulesStore(
"com.google.android.gms.persistent", "com.google.android.gms.persistent",
// a2i 自身 // a2i 自身
"com.a2i.forwarder", "com.a2i.forwarder",
// [v2] 纯噪音 App:转发无意义,默认屏蔽
"com.android.soundrecorder", // 录音机
"com.google.android.apps.photos", // 相册
"com.xiaomi.finddevice", // 查找手机
"com.miui.passwords", // 密码管理
// [v3] 通话状态噪音:来电由 PhoneCallMonitor 独立处理,这两个会重复
"com.android.server.telecom", // 通话管理
"com.xiaomi.aiasst.service", // AI通话
) )
} }
} }
@@ -5,10 +5,16 @@ import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.Preferences import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.booleanPreferencesKey import androidx.datastore.preferences.core.booleanPreferencesKey
import androidx.datastore.preferences.core.edit 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.core.stringPreferencesKey
import androidx.datastore.preferences.preferencesDataStore import androidx.datastore.preferences.preferencesDataStore
import com.a2i.forwarder.model.BarkServer import com.a2i.forwarder.model.BarkServer
import com.a2i.forwarder.model.EmailServer
import com.a2i.forwarder.model.MeowServer
import com.a2i.forwarder.model.NtfyServer
import com.a2i.forwarder.model.OFFICIAL_BARK_SERVER import com.a2i.forwarder.model.OFFICIAL_BARK_SERVER
import com.a2i.forwarder.model.SmsTarget
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
@@ -29,7 +35,6 @@ class SettingsStore(
private object K { private object K {
val SERVERS = stringPreferencesKey("servers") val SERVERS = stringPreferencesKey("servers")
val CURRENT = stringPreferencesKey("current")
val ICON_PREFIX = stringPreferencesKey("icon_prefix") val ICON_PREFIX = stringPreferencesKey("icon_prefix")
val FWD = booleanPreferencesKey("forwarding") val FWD = booleanPreferencesKey("forwarding")
val CODE = booleanPreferencesKey("code") val CODE = booleanPreferencesKey("code")
@@ -37,6 +42,42 @@ class SettingsStore(
val SPECIAL = booleanPreferencesKey("special") val SPECIAL = booleanPreferencesKey("special")
val SKIP_ONGOING = booleanPreferencesKey("skip_ongoing") val SKIP_ONGOING = booleanPreferencesKey("skip_ongoing")
val AD_KW = stringPreferencesKey("ad_keywords") val AD_KW = stringPreferencesKey("ad_keywords")
// 各通道「发通一条止 / 全发」开关
val BARK_STOP_FIRST = booleanPreferencesKey("bark_stop_first")
val NTFY_STOP_FIRST = booleanPreferencesKey("ntfy_stop_first")
val MEOW_STOP_FIRST = booleanPreferencesKey("meow_stop_first")
val EMAIL_STOP_FIRST = booleanPreferencesKey("email_stop_first")
val SMS_STOP_FIRST = booleanPreferencesKey("sms_stop_first")
// 短信兜底(目标号已列表化;运营商/余额/限频保持全局)
val SMS_FALLBACK = booleanPreferencesKey("sms_fallback")
val SMS_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( private val defaultServers = listOf(
@@ -46,16 +87,48 @@ class SettingsStore(
private val _servers = MutableStateFlow(defaultServers) private val _servers = MutableStateFlow(defaultServers)
val servers = _servers.asStateFlow() val servers = _servers.asStateFlow()
private val _currentServerId = MutableStateFlow(ID_OFFICIAL) private val _ntfyServers = MutableStateFlow<List<NtfyServer>>(emptyList())
val currentServer = MutableStateFlow<BarkServer?>(defaultServers.first()) val ntfyServers = _ntfyServers.asStateFlow()
val iconPrefix = MutableStateFlow("") 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 forwardingEnabled = MutableStateFlow(true)
val codeEnabled = MutableStateFlow(true) val codeEnabled = MutableStateFlow(true)
val adFilterEnabled = MutableStateFlow(true) val adFilterEnabled = MutableStateFlow(true)
val specialEnabled = MutableStateFlow(true) val specialEnabled = MutableStateFlow(true)
val skipOngoing = MutableStateFlow(true) val skipOngoing = MutableStateFlow(true)
val adKeywords = MutableStateFlow<List<String>>(emptyList()) // 用户自定义关键词 val adKeywords = MutableStateFlow<List<String>>(emptyList())
// 各通道「发通一条止 / 全发」
val barkStopOnFirst = MutableStateFlow(true)
val ntfyStopOnFirst = MutableStateFlow(false)
val meowStopOnFirst = MutableStateFlow(false)
val emailStopOnFirst = MutableStateFlow(false)
val smsStopOnFirst = MutableStateFlow(false)
// 短信兜底(全局)
val smsFallbackEnabled = MutableStateFlow(false)
val 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 { init {
scope.launch { scope.launch {
@@ -64,9 +137,7 @@ class SettingsStore(
?.let { runCatching { json.decodeFromString<List<BarkServer>>(it) }.getOrNull() } ?.let { runCatching { json.decodeFromString<List<BarkServer>>(it) }.getOrNull() }
?: defaultServers ?: defaultServers
_servers.value = list _servers.value = list
_currentServerId.value = p[K.CURRENT] ?: ID_OFFICIAL iconPrefix.value = p[K.ICON_PREFIX] ?: DEFAULT_ICON_PREFIX
currentServer.value = list.firstOrNull { it.id == _currentServerId.value } ?: list.firstOrNull()
iconPrefix.value = p[K.ICON_PREFIX] ?: ""
forwardingEnabled.value = p[K.FWD] ?: true forwardingEnabled.value = p[K.FWD] ?: true
codeEnabled.value = p[K.CODE] ?: true codeEnabled.value = p[K.CODE] ?: true
adFilterEnabled.value = p[K.AD] ?: true adFilterEnabled.value = p[K.AD] ?: true
@@ -75,47 +146,179 @@ class SettingsStore(
adKeywords.value = p[K.AD_KW] adKeywords.value = p[K.AD_KW]
?.let { runCatching { json.decodeFromString<List<String>>(it) }.getOrNull() } ?.let { runCatching { json.decodeFromString<List<String>>(it) }.getOrNull() }
?: emptyList() ?: emptyList()
// 各通道 stopOnFirst
barkStopOnFirst.value = p[K.BARK_STOP_FIRST] ?: true
ntfyStopOnFirst.value = p[K.NTFY_STOP_FIRST] ?: false
meowStopOnFirst.value = p[K.MEOW_STOP_FIRST] ?: false
emailStopOnFirst.value = p[K.EMAIL_STOP_FIRST] ?: false
smsStopOnFirst.value = p[K.SMS_STOP_FIRST] ?: false
// 短信兜底全局
smsFallbackEnabled.value = p[K.SMS_FALLBACK] ?: false
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) = private suspend fun edit(block: (androidx.datastore.preferences.core.MutablePreferences) -> Unit) =
ds.edit(block) ds.edit(block)
// ---- Bark 服务器 CRUD ----
private suspend fun saveServers(list: List<BarkServer>) { private suspend fun saveServers(list: List<BarkServer>) {
edit { it[K.SERVERS] = json.encodeToString(list) } edit { it[K.SERVERS] = json.encodeToString(list) }
_servers.value = list _servers.value = list
refreshCurrent()
} }
suspend fun addServer(name: String, server: String, key: String): BarkServer { suspend fun addServer(name: String, server: String, key: String): BarkServer {
val s = BarkServer(name = name.trim(), server = server.trim().removeSuffix("/"), deviceKey = key.trim()) val s = BarkServer(name = name.trim(), server = server.trim().removeSuffix("/"), deviceKey = key.trim())
saveServers(_servers.value + s) saveServers(_servers.value + s)
if (_servers.value.count { it.id == s.id } == 1 && currentServer.value?.deviceKey.isNullOrBlank()) {
setCurrent(s.id)
}
return s return s
} }
suspend fun updateServer(s: BarkServer) = saveServers(_servers.value.map { if (it.id == s.id) s.copy(server = s.server.trim().removeSuffix("/")) else it }) suspend fun updateServer(s: BarkServer) = saveServers(_servers.value.map { if (it.id == s.id) s.copy(server = s.server.trim().removeSuffix("/")) else it })
suspend fun deleteServer(id: String) = saveServers(_servers.value.filterNot { it.id == id })
suspend fun deleteServer(id: String) { suspend fun moveServer(id: String, up: Boolean) {
val list = _servers.value.filterNot { it.id == id } val list = _servers.value.toMutableList()
saveServers(list) val i = list.indexOfFirst { it.id == id }
if (_currentServerId.value == id) { if (i in 1..list.lastIndex && up) { java.util.Collections.swap(list, i, i - 1); saveServers(list) }
setCurrent(if (list.isEmpty()) "" else list.first().id) 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 })
suspend fun setCurrent(id: String) { // ---- ntfy 服务器 CRUD ----
edit { it[K.CURRENT] = id } private suspend fun saveNtfyServers(list: List<NtfyServer>) {
_currentServerId.value = id edit { it[K.NTFY_SERVERS] = json.encodeToString(list) }
refreshCurrent() _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 })
private fun refreshCurrent() { // ---- Meow 服务器 CRUD ----
currentServer.value = _servers.value.firstOrNull { it.id == _currentServerId.value } ?: _servers.value.firstOrNull() 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 setIconPrefix(v: String) { edit { it[K.ICON_PREFIX] = v }; iconPrefix.value = v }
suspend fun setForwarding(v: Boolean) { edit { it[K.FWD] = v }; forwardingEnabled.value = v } suspend fun setForwarding(v: Boolean) { edit { it[K.FWD] = v }; forwardingEnabled.value = v }
@@ -128,9 +331,29 @@ class SettingsStore(
adKeywords.value = list adKeywords.value = list
} }
suspend fun setBarkStopOnFirst(v: Boolean) { edit { it[K.BARK_STOP_FIRST] = v }; barkStopOnFirst.value = v }
suspend fun setNtfyStopOnFirst(v: Boolean) { edit { it[K.NTFY_STOP_FIRST] = v }; ntfyStopOnFirst.value = v }
suspend fun setMeowStopOnFirst(v: Boolean) { edit { it[K.MEOW_STOP_FIRST] = v }; meowStopOnFirst.value = v }
suspend fun setEmailStopOnFirst(v: Boolean) { edit { it[K.EMAIL_STOP_FIRST] = v }; emailStopOnFirst.value = v }
suspend fun setSmsStopOnFirst(v: Boolean) { edit { it[K.SMS_STOP_FIRST] = v }; smsStopOnFirst.value = v }
suspend fun setSmsFallback(v: Boolean) { edit { it[K.SMS_FALLBACK] = v }; smsFallbackEnabled.value = v }
suspend fun 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() suspend fun snapshot() = ds.data.first()
companion object { companion object {
const val ID_OFFICIAL = "official" const val ID_OFFICIAL = "official"
const val DEFAULT_ICON_PREFIX = "https://raw.githubusercontent.com/lsxf/gotmsg/main/icons/"
} }
} }
@@ -7,6 +7,7 @@ import android.graphics.drawable.BitmapDrawable
import android.graphics.drawable.Drawable import android.graphics.drawable.Drawable
import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.Image
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
@@ -68,6 +69,9 @@ fun SectionCard(
title: String? = null, title: String? = null,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
subtitle: String? = null, subtitle: String? = null,
titleIcon: ImageVector? = null,
titleIconTint: Color = MaterialTheme.colorScheme.primary,
headerAction: (@Composable () -> Unit)? = null,
content: @Composable ColumnScope.() -> Unit, content: @Composable ColumnScope.() -> Unit,
) { ) {
Card( Card(
@@ -79,19 +83,58 @@ fun SectionCard(
) { ) {
Column(Modifier.padding(16.dp)) { Column(Modifier.padding(16.dp)) {
if (title != null) { if (title != null) {
Text( if (headerAction == null) {
title, Row(verticalAlignment = Alignment.CenterVertically) {
style = MaterialTheme.typography.titleMedium, if (titleIcon != null) {
fontWeight = FontWeight.SemiBold, Icon(titleIcon, null, tint = titleIconTint, modifier = Modifier.size(22.dp))
modifier = Modifier.padding(bottom = if (subtitle == null) 10.dp else 2.dp), Spacer(Modifier.width(8.dp))
) }
if (subtitle != null) { Text(
Text( title,
subtitle, style = MaterialTheme.typography.titleMedium,
style = MaterialTheme.typography.bodySmall, fontWeight = FontWeight.SemiBold,
color = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.padding(bottom = if (subtitle == null) 10.dp else 2.dp),
modifier = Modifier.padding(bottom = 10.dp), )
) }
if (subtitle != null) {
Text(
subtitle,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(bottom = 10.dp),
)
}
} else {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(bottom = 10.dp),
verticalAlignment = Alignment.Top,
) {
Column(Modifier.weight(1f)) {
Row(verticalAlignment = Alignment.CenterVertically) {
if (titleIcon != null) {
Icon(titleIcon, null, tint = titleIconTint, modifier = Modifier.size(22.dp))
Spacer(Modifier.width(8.dp))
}
Text(
title,
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold,
modifier = Modifier.padding(bottom = if (subtitle == null) 0.dp else 2.dp),
)
}
if (subtitle != null) {
Text(
subtitle,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
Spacer(Modifier.width(12.dp))
headerAction()
}
} }
} }
content() content()
@@ -1,7 +1,20 @@
package com.a2i.forwarder.ui.nav package com.a2i.forwarder.ui.nav
import androidx.compose.animation.core.RepeatMode
import androidx.compose.animation.core.animateFloat
import androidx.compose.animation.core.infiniteRepeatable
import androidx.compose.animation.core.rememberInfiniteTransition
import androidx.compose.animation.core.tween
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.SystemUpdate
import androidx.compose.material.icons.outlined.Apps import androidx.compose.material.icons.outlined.Apps
import androidx.compose.material.icons.outlined.FilterAlt import androidx.compose.material.icons.outlined.FilterAlt
import androidx.compose.material.icons.outlined.Notifications import androidx.compose.material.icons.outlined.Notifications
@@ -14,8 +27,15 @@ import androidx.compose.material3.NavigationBarItemDefaults
import androidx.compose.material3.Scaffold import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.navigation.NavGraph.Companion.findStartDestination import androidx.navigation.NavGraph.Companion.findStartDestination
@@ -23,11 +43,13 @@ import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable import androidx.navigation.compose.composable
import androidx.navigation.compose.currentBackStackEntryAsState import androidx.navigation.compose.currentBackStackEntryAsState
import androidx.navigation.compose.rememberNavController import androidx.navigation.compose.rememberNavController
import com.a2i.forwarder.A2iApp
import com.a2i.forwarder.ui.screens.AppManageScreen import com.a2i.forwarder.ui.screens.AppManageScreen
import com.a2i.forwarder.ui.screens.FilterScreen import com.a2i.forwarder.ui.screens.FilterScreen
import com.a2i.forwarder.ui.screens.HomeScreen import com.a2i.forwarder.ui.screens.HomeScreen
import com.a2i.forwarder.ui.screens.LogScreen import com.a2i.forwarder.ui.screens.LogScreen
import com.a2i.forwarder.ui.screens.SettingsScreen import com.a2i.forwarder.ui.screens.SettingsScreen
import com.a2i.forwarder.ui.screens.UpdateDialog
private sealed class Tab(val route: String, val label: String, val icon: ImageVector) { private sealed class Tab(val route: String, val label: String, val icon: ImageVector) {
object Home : Tab("home", "首页", Icons.Outlined.Notifications) object Home : Tab("home", "首页", Icons.Outlined.Notifications)
@@ -46,54 +68,104 @@ fun AppRoot() {
val current = backStack?.destination?.route val current = backStack?.destination?.route
val showBar = current in tabRoutes val showBar = current in tabRoutes
Scaffold( val app = A2iApp.instance
containerColor = MaterialTheme.colorScheme.background, val updateInfo by app.updateChecker.updateInfo.collectAsState()
bottomBar = { var showUpdateDialog by remember { mutableStateOf(false) }
if (showBar) {
NavigationBar( Box {
containerColor = MaterialTheme.colorScheme.surface, Scaffold(
tonalElevation = 0.dp, containerColor = MaterialTheme.colorScheme.background,
) { bottomBar = {
tabs.forEach { t -> if (showBar) {
NavigationBarItem( NavigationBar(
selected = current == t.route, containerColor = MaterialTheme.colorScheme.surface,
onClick = { tonalElevation = 0.dp,
nav.navigate(t.route) { ) {
popUpTo(nav.graph.findStartDestination().id) { saveState = true } tabs.forEach { t ->
launchSingleTop = true NavigationBarItem(
restoreState = true selected = current == t.route,
} onClick = {
}, nav.navigate(t.route) {
icon = { Icon(t.icon, t.label) }, popUpTo(nav.graph.findStartDestination().id) { saveState = true }
label = { Text(t.label) }, launchSingleTop = true
colors = NavigationBarItemDefaults.colors( restoreState = true
selectedIconColor = MaterialTheme.colorScheme.primary, }
selectedTextColor = MaterialTheme.colorScheme.primary, },
indicatorColor = MaterialTheme.colorScheme.primaryContainer, icon = { Icon(t.icon, t.label) },
unselectedIconColor = MaterialTheme.colorScheme.onSurfaceVariant, label = { Text(t.label) },
unselectedTextColor = MaterialTheme.colorScheme.onSurfaceVariant, colors = NavigationBarItemDefaults.colors(
), selectedIconColor = MaterialTheme.colorScheme.primary,
) selectedTextColor = MaterialTheme.colorScheme.primary,
indicatorColor = MaterialTheme.colorScheme.primaryContainer,
unselectedIconColor = MaterialTheme.colorScheme.onSurfaceVariant,
unselectedTextColor = MaterialTheme.colorScheme.onSurfaceVariant,
),
)
}
} }
} }
},
) { inner ->
NavHost(
navController = nav,
startDestination = Tab.Home.route,
modifier = Modifier.padding(inner),
) {
composable(Tab.Home.route) {
HomeScreen(
onOpenLog = { nav.navigate("log") },
onOpenSettings = { nav.navigate(Tab.Settings.route) },
)
}
composable(Tab.Apps.route) { AppManageScreen() }
composable(Tab.Filter.route) { FilterScreen() }
composable(Tab.Settings.route) { SettingsScreen(onOpenLog = { nav.navigate("log") }) }
composable("log") { LogScreen(onBack = { nav.popBackStack() }) }
} }
}, }
) { inner ->
NavHost( // 浮动更新提醒小图标(跨所有页面,缓慢闪烁)
navController = nav, if (updateInfo != null) {
startDestination = Tab.Home.route, UpdateBadge(
modifier = Modifier.padding(inner), modifier = Modifier.align(Alignment.TopEnd),
) { onClick = { showUpdateDialog = true },
composable(Tab.Home.route) { )
HomeScreen(
onOpenLog = { nav.navigate("log") },
onOpenSettings = { nav.navigate(Tab.Settings.route) },
)
}
composable(Tab.Apps.route) { AppManageScreen() }
composable(Tab.Filter.route) { FilterScreen() }
composable(Tab.Settings.route) { SettingsScreen(onOpenLog = { nav.navigate("log") }) }
composable("log") { LogScreen(onBack = { nav.popBackStack() }) }
} }
} }
if (showUpdateDialog && updateInfo != null) {
UpdateDialog(updateInfo!!, onDismiss = { showUpdateDialog = false })
}
}
/** 跨页面浮动更新提醒:右上角小圆点 + 缓慢闪烁动画。 */
@Composable
private fun UpdateBadge(modifier: Modifier = Modifier, onClick: () -> Unit) {
val transition = rememberInfiniteTransition(label = "updateBlink")
val pulseAlpha by transition.animateFloat(
initialValue = 0.35f,
targetValue = 1f,
animationSpec = infiniteRepeatable(
animation = tween(1200),
repeatMode = RepeatMode.Reverse,
),
label = "pulseAlpha",
)
Box(
modifier = modifier
.padding(top = 12.dp, end = 12.dp)
.size(40.dp)
.alpha(pulseAlpha)
.background(MaterialTheme.colorScheme.primary, CircleShape)
.clickable(onClick = onClick),
contentAlignment = Alignment.Center,
) {
Icon(
Icons.Filled.SystemUpdate,
contentDescription = "发现新版本,点击更新",
tint = Color.White,
modifier = Modifier.size(22.dp),
)
}
} }
@@ -61,7 +61,7 @@ fun FilterScreen() {
modifier = Modifier.fillMaxSize().padding(horizontal = 16.dp), modifier = Modifier.fillMaxSize().padding(horizontal = 16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp), verticalArrangement = Arrangement.spacedBy(12.dp),
) { ) {
item { ScreenTitle("过滤与优化", "减少噪音,保留真正需要同步到 iOS 的通知") } item { ScreenTitle("过滤与优化", "减少噪音,保留真正需要转发的通知") }
item { item {
SectionCard("功能开关", subtitle = "规则按通知处理流水线顺序执行") { SectionCard("功能开关", subtitle = "规则按通知处理流水线顺序执行") {
@@ -2,6 +2,7 @@ package com.a2i.forwarder.ui.screens
import android.content.Intent import android.content.Intent
import android.provider.Settings import android.provider.Settings
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
@@ -22,7 +23,9 @@ import androidx.compose.material.icons.filled.Notifications
import androidx.compose.material.icons.filled.PowerSettingsNew import androidx.compose.material.icons.filled.PowerSettingsNew
import androidx.compose.material.icons.filled.Settings import androidx.compose.material.icons.filled.Settings
import androidx.compose.material.icons.filled.WarningAmber import androidx.compose.material.icons.filled.WarningAmber
import androidx.compose.material.icons.filled.SystemUpdate
import androidx.compose.material3.Button import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.FilledTonalButton import androidx.compose.material3.FilledTonalButton
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
@@ -61,10 +64,13 @@ fun HomeScreen(onOpenLog: () -> Unit, onOpenSettings: () -> Unit) {
val context = LocalContext.current val context = LocalContext.current
val app = A2iApp.instance val app = A2iApp.instance
val forwarding by app.settings.forwardingEnabled.collectAsState() val forwarding by app.settings.forwardingEnabled.collectAsState()
val current by app.settings.currentServer.collectAsState() val servers by app.settings.servers.collectAsState()
val sent by app.logStore.sentCount.collectAsState() val sent by app.logStore.sentCount.collectAsState()
val filtered by app.logStore.filteredCount.collectAsState() val filtered by app.logStore.filteredCount.collectAsState()
val failed by app.logStore.failedCount.collectAsState() val failed by app.logStore.failedCount.collectAsState()
val smsSuspended by app.settings.smsSuspended.collectAsState()
val update by app.updateChecker.updateInfo.collectAsState()
var showUpdateDialog by remember { mutableStateOf(false) }
var granted by remember { mutableStateOf(isNotificationListenerEnabled(context)) } var granted by remember { mutableStateOf(isNotificationListenerEnabled(context)) }
val lifecycleOwner = LocalLifecycleOwner.current val lifecycleOwner = LocalLifecycleOwner.current
@@ -89,6 +95,78 @@ fun HomeScreen(onOpenLog: () -> Unit, onOpenSettings: () -> Unit) {
) )
} }
val info = update
if (info != null) {
item {
SectionCard {
Box(modifier = Modifier
.fillMaxWidth()
.background(
brush = Brush.linearGradient(
colors = listOf(MaterialTheme.colorScheme.tertiary, MaterialTheme.colorScheme.primary),
),
shape = RoundedCornerShape(8.dp),
)
.padding(16.dp)
) {
Column {
Row(verticalAlignment = Alignment.CenterVertically) {
Box(
modifier = Modifier
.size(44.dp)
.background(Color.White.copy(alpha = 0.18f), RoundedCornerShape(8.dp)),
contentAlignment = Alignment.Center,
) {
Icon(Icons.Filled.SystemUpdate, null, tint = Color.White, modifier = Modifier.size(26.dp))
}
Spacer(Modifier.width(12.dp))
Column(Modifier.weight(1f)) {
Text("发现新版本", style = MaterialTheme.typography.titleLarge, color = Color.White, fontWeight = FontWeight.Bold)
Text(
"v${info.latestVersion} · ${formatSize(info.apkSize)} · 点击立即更新",
style = MaterialTheme.typography.bodyMedium,
color = Color.White.copy(alpha = 0.88f),
)
}
StatusBadge("🔄", tint = Color.White)
}
Spacer(Modifier.size(12.dp))
Button(
onClick = { showUpdateDialog = true },
modifier = Modifier.fillMaxWidth(),
colors = ButtonDefaults.buttonColors(
containerColor = Color.White,
contentColor = MaterialTheme.colorScheme.primary,
),
) { Text("🚀 立即更新到 v${info.latestVersion}", fontWeight = FontWeight.Bold) }
}
}
}
}
}
if (smsSuspended) {
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 { item {
SectionCard { SectionCard {
Row(verticalAlignment = Alignment.CenterVertically) { Row(verticalAlignment = Alignment.CenterVertically) {
@@ -100,7 +178,7 @@ fun HomeScreen(onOpenLog: () -> Unit, onOpenSettings: () -> Unit) {
Column(Modifier.weight(1f)) { Column(Modifier.weight(1f)) {
Text(if (granted) "通知监听已开启" else "需要授权通知监听", style = MaterialTheme.typography.titleMedium) Text(if (granted) "通知监听已开启" else "需要授权通知监听", style = MaterialTheme.typography.titleMedium)
Text( Text(
if (granted) "系统通知将按规则处理并推送到 Bark" else "授权后才能读取 Android 通知并转发", if (granted) "系统通知将按规则处理并多通道转发" else "授权后才能读取 Android 通知并转发",
style = MaterialTheme.typography.bodySmall, style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant, color = MaterialTheme.colorScheme.onSurfaceVariant,
) )
@@ -124,28 +202,21 @@ fun HomeScreen(onOpenLog: () -> Unit, onOpenSettings: () -> Unit) {
SectionCard("运行状态", subtitle = "转发能力、目标服务器和日志入口") { SectionCard("运行状态", subtitle = "转发能力、目标服务器和日志入口") {
SwitchRow( SwitchRow(
title = "通知转发", title = "通知转发",
subtitle = if (forwarding) "新通知会继续转发到 Bark" else "当前已暂停所有转发", subtitle = if (forwarding) "新通知会继续多通道转发" else "当前已暂停所有转发",
checked = forwarding, checked = forwarding,
icon = Icons.Filled.PowerSettingsNew, icon = Icons.Filled.PowerSettingsNew,
tint = if (forwarding) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant, tint = if (forwarding) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant,
) { v -> ) { v ->
app.appScope.launch { app.settings.setForwarding(v) } app.appScope.launch { app.settings.setForwarding(v) }
} }
val enabledServers = servers.filter { it.enabled && it.deviceKey.isNotBlank() }
ClickRow( ClickRow(
title = current?.name?.ifBlank { "未命名服务器" } ?: "未配置 Bark 服务器", title = if (enabledServers.isEmpty()) "未启用 Bark 服务器" else "Bark 服务器${enabledServers.size} 个启用)",
subtitle = current?.server ?: "进入设置添加官方或自建 Bark 服务", subtitle = enabledServers.firstOrNull()?.server ?: "进入设置勾选要转发的服务",
icon = Icons.Filled.Settings, icon = Icons.Filled.Settings,
tint = MaterialTheme.colorScheme.secondary, tint = MaterialTheme.colorScheme.secondary,
onClick = onOpenSettings, onClick = onOpenSettings,
) )
if (current?.deviceKey.isNullOrBlank()) {
Text(
"当前服务器未填写 device key",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.error,
modifier = Modifier.padding(start = 50.dp),
)
}
ClickRow( ClickRow(
title = "查看转发日志", title = "查看转发日志",
subtitle = "最近 300 条处理记录", subtitle = "最近 300 条处理记录",
@@ -177,6 +248,11 @@ fun HomeScreen(onOpenLog: () -> Unit, onOpenSettings: () -> Unit) {
) )
} }
} }
if (showUpdateDialog) {
val info = update
if (info != null) UpdateDialog(info, onDismiss = { showUpdateDialog = false })
}
} }
@Composable @Composable
@@ -209,8 +285,8 @@ private fun HomeHero(
} }
Spacer(Modifier.width(12.dp)) Spacer(Modifier.width(12.dp))
Column(Modifier.weight(1f)) { Column(Modifier.weight(1f)) {
Text("a2i", style = MaterialTheme.typography.displaySmall, color = Color.White) Text("GotMsg", style = MaterialTheme.typography.displaySmall, color = Color.White)
Text("安卓通知转发到 iOS Bark", style = MaterialTheme.typography.bodyMedium, color = Color.White.copy(alpha = 0.82f)) Text("安卓通知多通道转发", style = MaterialTheme.typography.bodyMedium, color = Color.White.copy(alpha = 0.82f))
} }
StatusBadge( StatusBadge(
text = if (granted && forwarding) "运行中" else "待处理", text = if (granted && forwarding) "运行中" else "待处理",
@@ -219,7 +295,7 @@ private fun HomeHero(
} }
Spacer(Modifier.size(14.dp)) Spacer(Modifier.size(14.dp))
Text( Text(
if (granted && forwarding) "监听、过滤、提取验证码和推送链路已就绪。" else "完成授权并开启转发后,通知会按规则推送到 iOS", if (granted && forwarding) "监听、过滤、提取验证码和推送链路已就绪。" else "完成授权并开启转发后,通知会按规则转发到各通道",
style = MaterialTheme.typography.bodyMedium, style = MaterialTheme.typography.bodyMedium,
color = Color.White.copy(alpha = 0.86f), color = Color.White.copy(alpha = 0.86f),
) )
@@ -16,6 +16,7 @@ import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
@@ -25,7 +26,9 @@ import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
@@ -51,6 +54,7 @@ fun LogScreen(onBack: () -> Unit) {
val logs by app.logStore.logs.collectAsState() val logs by app.logStore.logs.collectAsState()
val pending by app.logStore.pending.collectAsState() val pending by app.logStore.pending.collectAsState()
val df = remember { SimpleDateFormat("MM-dd HH:mm:ss", Locale.getDefault()) } val df = remember { SimpleDateFormat("MM-dd HH:mm:ss", Locale.getDefault()) }
var showClearDialog by remember { mutableStateOf(false) }
Column(Modifier.fillMaxSize()) { Column(Modifier.fillMaxSize()) {
Surface( Surface(
@@ -66,7 +70,7 @@ fun LogScreen(onBack: () -> Unit) {
Text("转发日志", style = MaterialTheme.typography.titleLarge) Text("转发日志", style = MaterialTheme.typography.titleLarge)
Text("最近 300 条通知处理记录", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant) Text("最近 300 条通知处理记录", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
} }
TextButton(onClick = { app.appScope.launch { app.logStore.clearLogs() } }) { Text("清空") } TextButton(onClick = { showClearDialog = true }) { Text("清空") }
} }
} }
@@ -103,6 +107,29 @@ fun LogScreen(onBack: () -> Unit) {
} }
} }
} }
if (showClearDialog) {
AlertDialog(
onDismissRequest = { showClearDialog = false },
title = { Text("清空日志") },
text = { Text("选择清空范围:") },
confirmButton = {
TextButton(onClick = {
showClearDialog = false
app.appScope.launch { app.logStore.clearLogs() }
}) { Text("仅清空日志") }
},
dismissButton = {
TextButton(onClick = {
showClearDialog = false
app.appScope.launch {
app.logStore.clearLogs()
app.logStore.resetCounters()
}
}) { Text("日志+计数器") }
},
)
}
} }
@Composable @Composable
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,126 @@
package com.a2i.forwarder.ui.screens
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import com.a2i.forwarder.A2iApp
import com.a2i.forwarder.core.UpdateInfo
import kotlinx.coroutines.launch
import java.io.File
import java.util.Locale
@Composable
fun UpdateDialog(info: UpdateInfo, onDismiss: () -> Unit) {
val app = A2iApp.instance
val downloading by app.updateChecker.downloading.collectAsState()
val progress by app.updateChecker.downloadProgress.collectAsState()
val downloadSource by app.updateChecker.downloadSource.collectAsState()
var downloadedFile by remember { mutableStateOf<File?>(null) }
AlertDialog(
onDismissRequest = { if (!downloading) onDismiss() },
title = { Text("发现新版本 v${info.latestVersion}") },
text = {
Column {
Text(
"安装包大小:${formatSize(info.apkSize)}",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Spacer(Modifier.size(8.dp))
Column(
modifier = Modifier
.heightIn(max = 240.dp)
.verticalScroll(rememberScrollState()),
) {
val notes = info.releaseNotes
.lineSequence()
.filterNot { it.contains("update-source") }
.joinToString("\n")
.ifBlank { "(作者未提供更新说明)" }
Text(notes, style = MaterialTheme.typography.bodyMedium)
}
if (downloading) {
Spacer(Modifier.size(12.dp))
LinearProgressIndicator(
progress = { progress / 100f },
modifier = Modifier.fillMaxWidth(),
)
Spacer(Modifier.size(4.dp))
Text(
"正在下载更新…${downloadSource?.let { "(来自 $it" } ?: ""}",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
downloadedFile?.let {
Spacer(Modifier.size(8.dp))
Text(
"下载完成,点击「安装」继续",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.tertiary,
)
}
}
},
confirmButton = {
if (downloadedFile == null) {
Button(
onClick = {
app.appScope.launch {
val f = app.updateChecker.download(info)
if (f != null) downloadedFile = f
}
},
enabled = !downloading,
) {
if (downloading) {
CircularProgressIndicator(Modifier.size(16.dp), strokeWidth = 2.dp)
} else {
Text("下载并安装")
}
}
} else {
Button(onClick = { app.updateChecker.install(downloadedFile!!) }) { Text("安装") }
}
},
dismissButton = {
Row {
TextButton(
onClick = { app.updateChecker.ignoreVersion(info.latestVersion); onDismiss() },
enabled = !downloading,
) { Text("忽略此版本") }
TextButton(onClick = onDismiss, enabled = !downloading) { Text("稍后") }
}
},
)
}
fun formatSize(bytes: Long): String {
if (bytes <= 0) return "未知"
val kb = bytes / 1024.0
return if (kb >= 1024) String.format(Locale.US, "%.1f MB", kb / 1024) else String.format(Locale.US, "%.0f KB", kb)
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 7.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.5 KiB

+1 -1
View File
@@ -1,3 +1,3 @@
<resources> <resources>
<string name="app_name">a2i</string> <string name="app_name">GotMsg 有消息</string>
</resources> </resources>
+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>
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.

After

Width:  |  Height:  |  Size: 521 KiB