74 Commits

Author SHA1 Message Date
song ce2f07a810 feat: 全链路转发遥测埋点 + bump to 1.10.1
各通道(bark/email/ntfy/meow/sms)转发结果上报 forward_* 事件

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-11 13:07:11 +08:00
song b0719b9a90 feat: SMS fallback refactor - user-controllable filters/limits/balance
New user options:
- Content filter: important-only (default) or forward-all
- Rate limit: toggle on/off, adjustable minutes
- Balance check: toggle on/off (off = no suspend, no decrement)

UI: SMS section now has 3 SwitchRows under target list, with
balance/carrier/quota status + "解除挂起" button shown when
balance check is on.

Logic: SmsFallbackForwarder reads new flags to skip checks;
CarrierBalanceQuery guards decrement/suspend on flag.

version 1.9.9 -> 1.10.0 (code 38 -> 39)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-11 09:57:03 +08:00
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
song a814e4469b docs: add screenshots gallery to README
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-06 20:35:41 +08:00
song dd42b54294 docs: add app screenshots for all 5 pages
- home: brand hero + status panel + metrics
- apps: app management with blacklist/whitelist
- filter: ad filter + code extraction + ongoing toggle
- settings: bark server management + icon export
- log: forwarding history with retry queue

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-06 20:30:06 +08:00
song 82419d0cfb Update CLAUDE.md: UI component layer, theme, release build, version and publish notes 2026-07-06 19:45:43 +08:00
song 6e8fb52d1a Add README 2026-07-06 19:35:55 +08:00
song 93c4ed9b5b Initial Android notification forwarder 2026-07-06 18:57:40 +08:00
75 changed files with 6296 additions and 188 deletions
+35
View File
@@ -0,0 +1,35 @@
# Gradle / Android build output
.gradle/
.kotlin/
build/
app/build/
# Local machine configuration
local.properties
.do-session
# Secrets — never commit
gh-token.txt
gitea-token.txt
*.token
# Android Studio / IntelliJ
.idea/
*.iml
# Generated artifacts
*.apk
*.aab
*.ap_
# Logs
*.log
# Local screenshots and throwaway files
shot*.png
shotApp.png
test.json
# Local assistant session helpers
.claude/
do.ps1
CLAUDE.md
+82 -137
View File
@@ -1,8 +1,8 @@
# GotMsg · 有消息 # GotMsg ·有消息
把安卓手机上的系统通知(短信、验证码、电、微信、QQ、Telegram、企业微信、App 推送等)实时转发到 **iPhone、iPad、其它安卓机、鸿蒙机、桌面邮箱**,并在接收端直接**远程回复**原安卓手机 把安卓手机上的通知(短信、验证码、电、微信、QQ、Telegram)实时转发到 iOS / Android / 鸿蒙等接收端
适用于把安卓备机的消息同步到主力机,不漏验证码、不漏重要聊天、不断网仍能收到关键通知 适用于把安卓备机的消息同步到 iPhone 或其他安卓 / 鸿蒙手机,不漏验证码、不漏重要聊天。
![GotMsg 设置页](screenshots/overview.png) ![GotMsg 设置页](screenshots/overview.png)
@@ -11,7 +11,7 @@
## 一、五分钟上手 ## 一、五分钟上手
### 1. 安装 ### 1. 安装
从 [Releases](../../releases) 下载最新 APK,安装到**需要被转发的安卓手机**(需 Android 14 / API 34+)。Release 同时提供 universal 全架构包和 arm64-v8a / armeabi-v7a / x86 / x86_64 单架构包,App 内更新页会默认选中与你设备最匹配的版本。 从 [Releases](../../releases) 下载最新 APK,安装到安卓手机(需 Android 14 / API 34+)。
### 2. 选一个推送通道 ### 2. 选一个推送通道
推荐三选一: 推荐三选一:
@@ -21,15 +21,14 @@
| iPhone / iPad | **Bark** | ⭐ 最简单 | | iPhone / iPad | **Bark** | ⭐ 最简单 |
| 安卓手机 | **ntfy** | ⭐ 推荐开源 | | 安卓手机 | **ntfy** | ⭐ 推荐开源 |
| 鸿蒙手机 | **Meow** | 鸿蒙专属 | | 鸿蒙手机 | **Meow** | 鸿蒙专属 |
| 任何能收邮件的设备 | **电邮** | 桌面/平板可用 |
打开 GotMsg -> 「设置」-> 选对应通道 -> 点右上角 **i** 按钮 -> 按弹窗步骤配 -> 点该行「发送」测试。可以**同时启用多个通道**,所有勾选的会并行收到。 打开 GotMsg 「设置」 选对应通道 点右上角 **i** 按钮 按弹窗步骤配 点该行「发送」测试。
### 3. 授权通知监听 ### 3. 授权通知监听
首页会提示「需要授权通知监听」。点击「前往系统授权」,在系统的通知访问设置中开启 GotMsg。 首页会提示「需要授权通知监听」。点击「前往系统授权」,在系统的通知访问设置中开启 GotMsg。
### 4. 开启转发 ### 4. 开启转发
首页打开「通知转发」开关。安卓收到通知后按配置自动推送到各通道。**发现新版时也会自动把更新通知推给所有已启用的通道**,不用进 App 也能升级。 首页打开「通知转发」开关。安卓收到通知后按配置自动推送到各通道。
--- ---
@@ -44,14 +43,13 @@
**配置步骤** **配置步骤**
1. iPhone / iPad 上安装 Bark,首次打开允许通知权限。 1. iPhone / iPad 上安装 Bark,首次打开允许通知权限。
2. 复制 Bark 首页的推送地址,形如 `https://api.day.app/你的Key` 2. 复制 Bark 首页的推送地址,形如 `https://api.day.app/你的Key`
3. GotMsg「设置 -> Bark 服务器 -> 添加」: 3. GotMsg「设置 Bark 服务器 添加」:
- **名称**:随意,例如「我的 iPhone」 - **名称**:随意,例如「我的 iPhone」
- **服务器地址**`https://api.day.app`(自建则填自建地址) - **服务器地址**`https://api.day.app`(自建则填自建地址)
- **Device Key**Bark 地址中 `/` 后面的那段 - **Device Key**Bark 地址中 `/` 后面的那段
4. 保存后点「发送」测试;iPhone 收到「GotMsg 测试」即成功。 4. 保存后点「发送」测试;iPhone 收到「GotMsg 测试」即成功。
5. 多台 iOS 设备:可继续添加多条 Bark 配置,每条独立勾选;列表顺序即推送顺序,可开启「首个成功即止」或全部广播。
**为什么推荐 Bark**iOS 上点击 Bark 通知体验最自然;通知附带 GotMsg 回复链接,可从 iPhone 直接回复原安卓;Bark 服务器在中国大陆可直接访问。 **为什么推荐 Bark**iOS 上点击 Bark 通知可一键跳转到微信、QQ、Telegram 对应 AppiOS 14+ 通知体验最自然;Bark 服务器在中国大陆可直接访问。
### 2.2 ntfyAndroid / iOS / 网页) ### 2.2 ntfyAndroid / iOS / 网页)
@@ -61,16 +59,14 @@
1. 接收端安装 ntfy 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) - 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) - iOS[App Store](https://apps.apple.com/us/app/ntfy/id1625396347)
2. 打开 ntfy -> 添加订阅。服务器填 `https://ntfy.sh`(或自建地址);Topic 用一串**随机难猜的名字**(如 `gotmsg_a8f3d2`)。 2. 打开 ntfy 添加订阅。服务器填 `https://ntfy.sh`(或自建地址);Topic 用一串**随机难猜的名字**(如 `gotmsg_a8f3d2`)。
3. GotMsg「设置 -> ntfy 转发 -> 添加」: 3. GotMsg「设置 ntfy 转发 添加」:
- **服务器**:与接收端完全一致 - **服务器**:与接收端完全一致
- **Topic**:与接收端完全一致 - **Topic**:与接收端完全一致
- **Token**:仅自建服务且启用了鉴权时填 - **Token**:仅自建服务且启用了鉴权时填
4. 保存后点「发送」测试;接收端收到「GotMsg 测试」即成功。 4. 保存后点「发送」测试;接收端收到「GotMsg 测试」即成功。
> **安全提示**:公共 topic 谁知道名字就能订阅或发送。Topic 名**不要用**手机号、姓名、邮箱等可猜信息。 > **安全提示**:公共 topic 谁知道名字就能订阅或发送。Topic 名**不要用**手机号、姓名、邮箱等可猜信息。
>
> **远程回复**:GotMsg 在转发聊天类通知时,会**自动给 ntfy 通知挂上「View」动作按钮和正文中的回复链接**。iOS 上点通知本体只能进 ntfy App,要回复请点动作按钮或正文里的回复链接。
### 2.3 Meow(鸿蒙 / HarmonyOS ### 2.3 Meow(鸿蒙 / HarmonyOS
@@ -79,7 +75,7 @@ Meow 是鸿蒙手机的推送接收方案。GotMsg 按你填写的 Meow 接口
**配置步骤** **配置步骤**
1. 鸿蒙手机上安装 Meow,确认 Meow 自身能正常接收推送。 1. 鸿蒙手机上安装 Meow,确认 Meow 自身能正常接收推送。
2. 在 Meow 里找到**推送接口地址**和对应的 **Device Key / Token** 2. 在 Meow 里找到**推送接口地址**和对应的 **Device Key / Token**
3. GotMsg「设置 -> Meow 转发 -> 添加」: 3. GotMsg「设置 Meow 转发 添加」:
- **名称**:按鸿蒙设备填写(方便区分多台) - **名称**:按鸿蒙设备填写(方便区分多台)
- **服务器地址**:Meow 给出的完整 API 地址 - **服务器地址**:Meow 给出的完整 API 地址
- **Device Key**Meow 给你的设备 Key(**不是**鸿蒙锁屏密码、华为账号密码) - **Device Key**Meow 给你的设备 Key(**不是**鸿蒙锁屏密码、华为账号密码)
@@ -89,16 +85,17 @@ Meow 是鸿蒙手机的推送接收方案。GotMsg 按你填写的 Meow 接口
### 2.4 电邮转发 ### 2.4 电邮转发
通过 SMTP 把通知发到邮箱(**适合无手机接收的场景**,如仅在桌面/平板查看)。GotMsg **直连 SMTP**,不依赖外部邮件服务。 通过 SMTP 把通知发到邮箱(**适合无手机接收的场景**,如仅在桌面/平板查看)。
**配置步骤** **配置步骤**
1. 准备一个 SMTP 邮箱(QQ/163/Gmail/Outlook 都可),**开启 SMTP 服务并生成授权码**(不是登录密码)。 1. 准备一个 SMTP 邮箱(QQ/163/Gmail/Outlook 都可),**开启 SMTP 服务并生成授权码**(不是登录密码)。
- QQ:设置 -> 账户 -> POP3/IMAP/SMTP/Exchange/CardDAV/CalDAV服务 -> 开启 SMTP -> 生成授权码 - QQ:设置 账户 POP3/IMAP/SMTP/Exchange/CardDAV/CalDAV服务 开启 SMTP 生成授权码
- 163:设置 -> POP3/SMTP/IMAP -> 开启 SMTP -> 设置授权码 - 163:设置 POP3/SMTP/IMAP 开启 SMTP 设置授权码
- Gmail:账户 -> 安全性 -> 两步验证 -> 应用专用密码 - Gmail:账户 安全性 两步验证 应用专用密码
2. GotMsg「设置 -> 电邮转发 -> 添加」: 2. GotMsg「设置 电邮转发 添加」:
- **名称**:随意
- **服务器(SMTP 主机)**`smtp.qq.com` / `smtp.163.com` / `smtp.gmail.com` - **服务器(SMTP 主机)**`smtp.qq.com` / `smtp.163.com` / `smtp.gmail.com`
- **端口**`465`(推荐,隐式 SSL - **端口**`465`(推荐,隐式 SSL`587`STARTTLS
- **账号**:完整邮箱地址 - **账号**:完整邮箱地址
- **授权码 / 密码**:上一步生成的授权码 - **授权码 / 密码**:上一步生成的授权码
- **发件人**:通常与账号相同 - **发件人**:通常与账号相同
@@ -112,10 +109,10 @@ Meow 是鸿蒙手机的推送接收方案。GotMsg 按你填写的 Meow 接口
**当安卓手机没有网络时**,把重要通知(验证码、来电)通过 **SIM 卡短信** 发到另一台手机(通常是你的 iPhone)。需要安卓卡里还有短信套餐余量。 **当安卓手机没有网络时**,把重要通知(验证码、来电)通过 **SIM 卡短信** 发到另一台手机(通常是你的 iPhone)。需要安卓卡里还有短信套餐余量。
**配置步骤** **配置步骤**
1. 在 GotMsg「设置 -> 短信兜底」点「授予短信权限」(安卓系统权限)。 1. 在 GotMsg「设置 短信兜底」点「授予短信权限」(安卓系统权限)。
2. 点「添加」输入**目标手机号**(如 iPhone 号,接收端是另一台手机,**不是** GotMsg 本身)。 2. 点「添加」输入**目标手机号**(如 iPhone 号,接收端是另一台手机,**不是** GotMsg 本身)。
3. 选运营商: 3. 选运营商:
- 选了「自动识别」:GotMsg 会发免费查询短信给运营商服务号,解析回执获取余量。回执短信本身会被识别并拦截,不转发。 - 选了「自动识别」:GotMsg 会发免费查询短信给运营商服务号,解析回执获取余量。
- 选了「手动额度」:在「手动短信额度」填入当前套餐短信条数,每次发短信后自动减 1。 - 选了「手动额度」:在「手动短信额度」填入当前套餐短信条数,每次发短信后自动减 1。
4. 当余量 ≤ 5 时自动暂停,下次打开 App 提醒续费。 4. 当余量 ≤ 5 时自动暂停,下次打开 App 提醒续费。
5. 频率限制:每 5 分钟最多发 1 条(避免刷屏),同一条通知 5 分钟内不重复。 5. 频率限制:每 5 分钟最多发 1 条(避免刷屏),同一条通知 5 分钟内不重复。
@@ -124,42 +121,11 @@ Meow 是鸿蒙手机的推送接收方案。GotMsg 按你填写的 Meow 接口
--- ---
## 三、通知远程回复(Bark / ntfy ## 三、自建推送服务(可选
GotMsg v1.11.0 起,Bark 和 ntfy 转发通知会附带回复入口,接收端在 iPhone 或 Android 上打开回复页输入内容后,回复会经 **GotMsg 中继(`https://r.gotmsg.pp.ua`)送回原 Android 手机**。GotMsg 优先使用原通知的 Android `RemoteInput` 快捷回复能力;微信等没有 `RemoteInput` 的 App 走无障碍兼容回复;微信 8.0.49+ 进一步屏蔽无障碍时,可启用 **Shizuku 路径**直接调用 `input` 命令绕过。
### 3.1 三种回复路径
| 路径 | 适用 | 是否需要解锁原手机 | 额外授权 |
|---|---|---|---|
| **原生 RemoteInput** | 短信、WhatsApp、企业 IM、Telegram 等暴露系统回复动作的 App | 否(后台执行) | 无 |
| **无障碍兼容回复** | QQ、支付宝、淘宝、钉钉 | 是 | 「GotMsg 兼容回复」无障碍服务 |
| **Shizuku 兼容回复(仅微信)** | 微信(绕过无障碍屏蔽) | 是 | Shizuku 已启动并授予 GotMsg 权限 |
### 3.2 启用方式
1. **Bark / ntfy 接收端**:无需任何额外配置。GotMsg 自动在通知正文或 ntfy 动作按钮里附上回复链接。
2. **原 Android 手机**
- 默认即可使用 `RemoteInput`(无需任何操作)。
- 想在没有 `RemoteInput` 的 App 上回复,进入「设置 -> 通知回复」打开「无障碍兼容回复」,再到系统无障碍设置里启用「GotMsg 兼容回复」服务。
- 想在最新版微信上回复,启用 **Shizuku**(在 GitHub Releases 下载并启动),GotMsg 首页会显示 Shizuku 状态卡片;点击「授权 Shizuku」即可。微信路径会自动以 Shizuku 身份模拟 `input tap` 聚焦输入框、写剪贴板、`KEYCODE_PASTE` 注入任意 Unicode,并通过截图像素扫描识别微信绿色发送按钮。
### 3.3 使用流程
- Bark:通知点击目标会优先变成回复页,正文也会附带回复链接;普通消息不会再跳微信/QQ 等原 App。
- ntfy:通知附带 View 动作按钮和正文中的回复链接;iOS 上点通知本体只进入 ntfy App,请点动作按钮或正文里的回复链接。
完整使用方法与限制见 [docs/notification-reply.md](docs/notification-reply.md)。接收端不需要安装 iOS 快捷指令,也不需要订阅额外的 `_reply` topic(旧版方案已移除,见 [docs/A2iReply-shortcut.md](docs/A2iReply-shortcut.md))。
> 链接 10 分钟内有效,只能成功提交一次。原生快捷回复可在后台执行;无障碍 / Shizuku 兼容回复会亮屏并打开原 App;自动解锁仅对**无密码滑动锁**有效,密码 / PIN / 指纹锁需手动解锁一次;完成后会**自动重新锁屏**。付款、订单、营销等非聊天通知不能回复。
---
## 四、自建推送服务(可选)
所有通道都支持自建,更好、更私密、可控。 所有通道都支持自建,更好、更私密、可控。
### 4.1 自建 Bark 服务器 ### 3.1 自建 Bark 服务器
GotMsg 默认用 Bark 官方服务器(`https://api.day.app`),免费够用。自建适合追求完全控制。 GotMsg 默认用 Bark 官方服务器(`https://api.day.app`),免费够用。自建适合追求完全控制。
@@ -175,7 +141,7 @@ docker run -d --name bark --restart=unless-stopped \
**GotMsg 配置**:服务器地址填 `https://你的域名.com`(不要 `/push` 后缀),Device Key 填你在自建 Bark 上创建的 Key。 **GotMsg 配置**:服务器地址填 `https://你的域名.com`(不要 `/push` 后缀),Device Key 填你在自建 Bark 上创建的 Key。
### 4.2 自建 ntfy 服务器 ### 3.2 自建 ntfy 服务器
[ntfy](https://ntfy.sh) 是为自建设计的,**比 Bark 还简单**——官方直接提供 Docker 一行启动: [ntfy](https://ntfy.sh) 是为自建设计的,**比 Bark 还简单**——官方直接提供 Docker 一行启动:
@@ -190,78 +156,28 @@ docker run -d --name ntfy --restart=unless-stopped \
**GotMsg 配置**:服务器地址填 `https://ntfy.你的域名.com`Topic 照旧。 **GotMsg 配置**:服务器地址填 `https://ntfy.你的域名.com`Topic 照旧。
### 4.3 自建 Meow 服务器 ### 3.3 自建 Meow 服务器
Meow 是鸿蒙客户端,服务端一般用 Meow 自带的。GotMsg 端无需配置服务端,只需填 Meow 给你的接口地址 + Key。 Meow 是鸿蒙客户端,服务端一般用 Meow 自带的。GotMsg 端无需配置服务端,只需填 Meow 给你的接口地址 + Key。
--- ---
## 五、应用内更新 ## 四、使用建议
GotMsg 内置更新检测:定期拉取 Gitea + GitHub Releases**发现新版时自动向所有已启用通道推送一条更新通知**(带当前版本号、目标版本号、变更摘要、APK 大小)。首页显示醒目横幅,可选「立即下载」或「忽略此版本」。
下载页会**默认选中匹配你设备 ABI 的 APK**universal / arm64-v8a / armeabi-v7a / x86_64 / x86),也可手动切换架构;下载完成后通过 `FileProvider` 调起系统安装器。
Gitea 源为主(国内可达),GitHub 为兜底;主源下载失败会自动尝试另一个源的同版本链接。
> 取消自动检查:在「设置 -> 应用内更新」关闭「更新检查」即可,手动检查仍可用。
---
## 六、来电与未接来电
MIUI / HyperOS 不向第三方监听器分发系统电话通知。GotMsg 通过 **轮询 `TelephonyManager.getCallState()`** 绕过此限制:
- **来电**:构造「有电话进来」推送(Android 14+ 禁止第三方读取实时号码);如无障碍服务读到号码则展示真实号码。
- **未接来电**:通话结束后查系统 `CallLog` 获取号码,并通过 `ContactsContract.PhoneLookup` 反查**联系人姓名**,推送形如「张三\n138xxxx」。
需要在系统设置中授予「电话」权限(设置 -> 应用 -> GotMsg -> 权限 -> 电话)。
---
## 七、过滤与噪音控制
通知处理流水线(在 `NotificationProcessor` 中按顺序执行):
1. 全局开关
2. 持续性通知(ongoing)静默
3. VPN / 代理客户端(Surfboard、Clash Meta、Clash for Android、v2rayNG、小米设备互联)常驻通知静默
4. 米家「设备状态」泛化通知静默(门锁 / 告警等具体事件仍正常转发)
5. App 黑/白名单
6. 空内容 / 脱敏内容
7. 噪音包名(三星剪贴板 / 键盘、GMS、SystemUI、Google 输入法)
8. 系统状态消息("正在运行"等)
9. **泛化无意义通知**(标题「新消息」+ 正文「你有一条新消息」等占位文案)静默
10. 近空内容(纯符号 / 纯空格 / 仅 App 名)
11. 广告过滤(内置营销关键词 + 用户自定义关键词)
12. 已发短信(仅收不发)
13. 运营商余额回执拦截(解析余量后不转发)
14. 去重检查(5 秒窗口)
15. 验证码提取(关键词 + 数字 / 数字 + 关键词 / 纯数字 4–8 位)
16. 特殊 App 解析(微信 / QQ / Telegram 优化 + 重要消息关键词标记)
17. 构造 BarkMessage 并推送
验证码开启自动复制后会被复制到剪贴板;推送级别在重要消息上自动提升到 `timeSensitive` 以点亮 iOS 焦点通知。
---
## 八、使用建议
- **只想收验证码**:进入「过滤」页,开启「验证码自动提取」和「广告过滤」;再到「应用」页切到白名单模式,只勾选短信类 App。 - **只想收验证码**:进入「过滤」页,开启「验证码自动提取」和「广告过滤」;再到「应用」页切到白名单模式,只勾选短信类 App。
- **想同步聊天**:保持黑名单模式(默认),在「过滤」页开启「微信 / QQ / Telegram 优化」。 - **想同步聊天**:保持黑名单模式(默认),在「过滤」页开启「微信 / QQ / Telegram 优化」。
- **通知没推过来**:先看「日志」页,每条通知(无论转发、过滤还是失败)都会记录原因。失败会自动加入 pending 队列,由 WorkManager 每 15 分钟重发。 - **通知没推过来**:先看「日志」页,每条通知(无论转发、过滤还是失败)都会记录原因。
- **想让通知带图标**:见下方「应用图标」一节。 - **想让通知带图标**:见下方「应用图标」一节。
- **电话通知不工作**:MIUI 不向第三方监听器分发系统电话通知,GotMsg 通过轮询 `getCallState()` 绕过此限制。需授予「电话」权限。 - **电话通知不工作**:MIUI 不向第三方监听器分发系统电话通知,GotMsg 通过轮询 `getCallState()` 绕过此限制。需授予「电话」权限(设置 → 应用 → GotMsg → 权限 → 电话)
- **断网收不到通知**:开启「设置 -> 短信兜底」并填好 iPhone 号码、授予短信权限。断网时验证码和来电走短信(消耗套餐额度,5 分钟限 1 条)。 - **断网收不到通知**:开启「设置 短信兜底」并填好 iPhone 号码、授予短信权限。断网时验证码和来电走短信(消耗套餐额度,5 分钟限 1 条)。
- **想全平台通知**:可同时启用多个通道(每个通道的多个条目独立勾选),所有启用的会同时收到。 - **想全平台通知**:可同时启用多个通道(勾选每个通道的多个条目),所有启用的会同时收到。
- **推送报 connection closed**:通常不是被墙,而是手机上的代理 / VPN 客户端干扰 Bark 服务器连接;先关代理或换自建 Bark 验证。
--- ---
## 、应用图标 ## 、应用图标
Bark 和 ntfy 的通知 `icon` 字段需要公网可访问的图片 URL。GotMsg 支持把已安装 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` 形式导出。
@@ -271,26 +187,45 @@ Bark 和 ntfy 的通知 `icon` 字段需要公网可访问的图片 URL。GotMsg
--- ---
## 十、构建与体积 ## 六、技术栈
Release 构建开启 **R8 + 资源压缩**universal APK 约 5 MB;同时输出 ABI splits,每个架构单独的 APK 体积更小。 - Kotlin + Jetpack ComposeMaterial 3
- AndroidX DataStore(持久化设置与日志)
构建: - OkHttpBark / ntfy / Meow / 电邮 / 更新检查)
```bash - WorkManager(失败重试)
export JAVA_HOME="D:/Dev/JDK-17" ANDROID_HOME="D:/Dev/Android/SDK" - Navigation Compose(单 Activity 多页面)
./gradlew assembleRelease - 最低 Android 14API 34),目标 Android 16API 36
# 产物:app/build/outputs/apk/release/app-universal-release.apk
# app/build/outputs/apk/release/app-arm64-v8a-release.apk
# app/build/outputs/apk/release/app-armeabi-v7a-release.apk
# app/build/outputs/apk/release/app-x86-release.apk
# app/build/outputs/apk/release/app-x86_64-release.apk
```
`release` 复用 `debug` 签名配置(`signingConfigs.getByName("debug")`),适合个人发布到 GitHub / Gitea Release;正式商店上架前需替换为独立的 release keystore。
--- ---
## 十一、界面截图 ## 七、构建与安装
需要 JDK 17 和 Android SDK。
```bash
# 设置环境变量
export JAVA_HOME=/path/to/jdk-17
export ANDROID_HOME=/path/to/android-sdk
# 构建 debug APK
./gradlew assembleDebug
# 构建可分发的 release APK
./gradlew assembleRelease
```
产物:
- debug`app/build/outputs/apk/debug/app-debug.apk`
- release`app/build/outputs/apk/release/app-release.apk`
安装:
```bash
adb install -r app/build/outputs/apk/release/app-release.apk
```
---
## 八、界面截图
| 首页 | 应用管理 | 过滤设置 | | 首页 | 应用管理 | 过滤设置 |
|---|---|---| |---|---|---|
@@ -302,17 +237,27 @@ export JAVA_HOME="D:/Dev/JDK-17" ANDROID_HOME="D:/Dev/Android/SDK"
--- ---
## 十二、隐私与安全 ## 九、工作原理
- GotMsg **不收集任何遥测**到第三方;本地仅记录最少必要的设备 ID / 密钥(用于回复中继鉴权,256 位随机,备份规则明确排除)。 ```
- 回复中继 `https://r.gotmsg.pp.ua` 只持久化**设备密钥 + 回复令牌的 SHA-256 哈希**,正文 24 小时后清除;回复链接放在 URL fragment 中,不会进入服务器访问日志。 安卓系统通知
- 拿到回复链接的人可在 10 分钟有效期内提交一次回复,因此不要转发或公开该链接。 └─ NotifyListenerService 接收
- 无障碍 / Shizuku 兼容回复仅在收到有效的一次性回复事件时运行,严格校验目标包名;Shizuku 路径只点击经像素扫描确认的微信绿色发送按钮,避免误操作。 └─ NotificationProcessor 流水线
├─ 全局开关 / ongoing 过滤 / 黑白名单
├─ 空内容、脱敏、噪音、系统状态过滤
├─ 广告关键词过滤
├─ 去重(5 秒窗口)
├─ 验证码提取
└─ 微信 / QQ / Telegram 专项解析
└─ 按配置推送到 Bark / ntfy / Meow / 电邮 / 短信
└─ 失败 → 待重发队列 → RetryWorker 定期重试
└─ 写入本地日志(最近 300 条)
```
--- ---
## License ## License
本项目仅供个人使用。自 v1.10.2 起闭源。 本项目仅供个人使用。
All Rights Reserved. 保留所有权利。 All Rights Reserved. 保留所有权利。
+75
View File
@@ -0,0 +1,75 @@
plugins {
alias(libs.plugins.android.application)
alias(libs.plugins.kotlin.android)
alias(libs.plugins.kotlin.compose)
alias(libs.plugins.kotlin.serialization)
}
android {
namespace = "com.a2i.forwarder"
compileSdk = 36
defaultConfig {
applicationId = "com.a2i.forwarder"
minSdk = 34
targetSdk = 36
versionCode = 40
versionName = "1.10.1"
}
signingConfigs {
getByName("debug")
}
buildTypes {
debug {
isMinifyEnabled = false
}
release {
signingConfig = signingConfigs.getByName("debug")
isMinifyEnabled = false
proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro"
)
}
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
kotlinOptions {
jvmTarget = "17"
}
buildFeatures {
compose = true
}
}
dependencies {
implementation(libs.androidx.core.ktx)
implementation(libs.androidx.lifecycle.runtime.ktx)
implementation(libs.androidx.lifecycle.viewmodel.compose)
implementation(libs.androidx.lifecycle.runtime.compose)
implementation(libs.androidx.activity.compose)
implementation(platform(libs.androidx.compose.bom))
implementation(libs.androidx.ui)
implementation(libs.androidx.ui.graphics)
implementation(libs.androidx.ui.tooling.preview)
implementation(libs.androidx.material3)
implementation(libs.androidx.material.icons.extended)
implementation(libs.androidx.navigation.compose)
implementation(libs.androidx.datastore.preferences)
implementation(libs.kotlinx.serialization.json)
implementation(libs.okhttp)
implementation(libs.androidx.work.runtime.ktx)
implementation(libs.androidx.documentfile)
implementation(libs.material)
debugImplementation(libs.androidx.ui.tooling)
}
+2
View File
@@ -0,0 +1,2 @@
# Keep Bark message model fields used by kotlinx.serialization
-keepclassmembers class com.a2i.forwarder.model.** { *; }
+60
View File
@@ -0,0 +1,60 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.READ_PHONE_STATE" />
<uses-permission android:name="android.permission.SEND_SMS" />
<uses-permission android:name="android.permission.QUERY_ALL_PACKAGES"
tools:ignore="QueryAllPackagesPermission" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
<application
android:name=".A2iApp"
android:allowBackup="true"
android:dataExtractionRules="@xml/data_extraction_rules"
android:fullBackupContent="@xml/backup_rules"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true"
android:theme="@style/Theme.A2i"
tools:targetApi="31">
<activity
android:name=".MainActivity"
android:exported="true"
android:label="@string/app_name"
android:theme="@style/Theme.A2i">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
<service
android:name=".service.NotifyListenerService"
android:exported="false"
android:label="@string/app_name"
android:permission="android.permission.BIND_NOTIFICATION_LISTENER_SERVICE">
<intent-filter>
<action android:name="android.service.notification.NotificationListenerService" />
</intent-filter>
</service>
<provider
android:name="androidx.core.content.FileProvider"
android:authorities="${applicationId}.fileprovider"
android:exported="false"
android:grantUriPermissions="true">
<meta-data
android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/file_paths" />
</provider>
</application>
</manifest>
@@ -0,0 +1,62 @@
package com.a2i.forwarder
import android.app.Application
import com.a2i.forwarder.core.CarrierBalanceQuery
import com.a2i.forwarder.core.CarrierDetector
import com.a2i.forwarder.core.ConnectivityMonitor
import com.a2i.forwarder.core.LogStore
import com.a2i.forwarder.core.PhoneCallMonitor
import com.a2i.forwarder.core.SmsFallbackForwarder
import com.a2i.forwarder.core.Telemetry
import com.a2i.forwarder.core.UpdateChecker
import com.a2i.forwarder.store.AppRulesStore
import com.a2i.forwarder.store.SettingsStore
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
class A2iApp : Application() {
val appScope: CoroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
lateinit var settings: SettingsStore
private set
lateinit var appRules: AppRulesStore
private set
lateinit var logStore: LogStore
private set
lateinit var phoneCallMonitor: PhoneCallMonitor
private set
lateinit var connectivityMonitor: ConnectivityMonitor
private set
lateinit var carrierBalanceQuery: CarrierBalanceQuery
private set
lateinit var smsForwarder: SmsFallbackForwarder
private set
lateinit var updateChecker: UpdateChecker
private set
lateinit var telemetry: Telemetry
private set
override fun onCreate() {
super.onCreate()
instance = this
settings = SettingsStore(this, appScope)
appRules = AppRulesStore(this, appScope)
logStore = LogStore(this, appScope)
telemetry = Telemetry(this, appScope)
telemetry.start()
phoneCallMonitor = PhoneCallMonitor(this)
phoneCallMonitor.start()
connectivityMonitor = ConnectivityMonitor(this)
connectivityMonitor.start()
carrierBalanceQuery = CarrierBalanceQuery(this)
smsForwarder = SmsFallbackForwarder(this)
updateChecker = UpdateChecker(this)
updateChecker.start()
}
companion object {
lateinit var instance: A2iApp
private set
}
}
@@ -0,0 +1,20 @@
package com.a2i.forwarder
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import com.a2i.forwarder.ui.nav.AppRoot
import com.a2i.forwarder.ui.theme.A2iTheme
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
enableEdgeToEdge()
setContent {
A2iTheme {
AppRoot()
}
}
}
}
@@ -0,0 +1,16 @@
package com.a2i.forwarder.core
object AdFilter {
val DEFAULT_KEYWORDS = listOf(
"广告", "推广", "优惠", "促销", "红包", "限时", "抽奖", "立即领取", "点击领取",
"赚取", "砍价", "秒杀", "爆款", "领券", "优惠券", "积分", "签到", "中奖",
"马上抢", "手慢无", "免费领", "赚钱", "返现", "特惠", "狂欢", "大促", "拼手速",
)
fun isAd(text: String, userKeywords: List<String>): Boolean {
val blob = text.lowercase()
for (k in DEFAULT_KEYWORDS) if (blob.contains(k.lowercase())) return true
for (k in userKeywords) if (k.isNotBlank() && blob.contains(k.lowercase())) return true
return false
}
}
@@ -0,0 +1,36 @@
package com.a2i.forwarder.core
object AppParsers {
data class Parsed(
val titleOverride: String? = null,
val groupKey: String? = null,
val clickUrl: String? = null,
val important: Boolean = false,
)
private val importantKeywords = listOf(
"@我", "@all", "@所有人", "语音", "视频", "通话", "邀请你加入", "验证码", "验证代码", "来电",
)
private val clickUrls = mapOf(
"com.tencent.mm" to "weixin://",
"com.tencent.mobileqq" to "mqq://",
"com.tencent.tim" to "mqq://",
"org.telegram.messenger" to "tg://",
"org.telegram.messenger.web" to "tg://",
"org.telegram.plus" to "tg://",
)
fun parse(packageName: String, title: String, text: String): Parsed {
val important = importantKeywords.any { text.contains(it) || title.contains(it) }
val url = clickUrls[packageName]
?: return Parsed(important = important)
val group = "$packageName:${title.ifBlank { "default" }}"
return Parsed(
titleOverride = title.takeIf { it.isNotBlank() },
groupKey = group,
clickUrl = url,
important = important,
)
}
}
@@ -0,0 +1,62 @@
package com.a2i.forwarder.core
import com.a2i.forwarder.model.BarkMessage
import com.a2i.forwarder.model.BarkServer
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.withContext
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.RequestBody.Companion.toRequestBody
import java.io.IOException
import java.util.concurrent.TimeUnit
class BarkClient(private val server: BarkServer) {
private val json = Json { encodeDefaults = false; ignoreUnknownKeys = true }
private val http = OkHttpClient.Builder()
.connectTimeout(10, TimeUnit.SECONDS)
.writeTimeout(10, TimeUnit.SECONDS)
.readTimeout(15, TimeUnit.SECONDS)
.retryOnConnectionFailure(true)
.build()
suspend fun push(msg: BarkMessage): Result<Unit> = withContext(Dispatchers.IO) {
runCatching {
require(server.deviceKey.isNotBlank()) { "device key 为空" }
doPushWithRetry(msg)
}
}
/**
* 带重试的推送:对 connection closed / 超时 / TLS 中断等瞬时 IOException 自动重试。
* OkHttp 的 retryOnConnectionFailure 不覆盖 POST 读响应阶段的连接中断,故在此手动补。
* HTTP 4xx/5xx 是服务端拒绝,重试无意义,直接抛出。
*/
private suspend fun doPushWithRetry(msg: BarkMessage) {
val body = json.encodeToString(msg.copy(deviceKey = server.deviceKey)).toRequestBody(JSON)
val url = server.server.trim().removeSuffix("/") + "/push"
val req = Request.Builder().url(url).post(body).build()
var lastError: IOException? = null
repeat(MAX_ATTEMPTS) { attempt ->
try {
http.newCall(req).execute().use { res ->
if (res.isSuccessful) return
error("HTTP ${res.code}")
}
} catch (e: IOException) {
lastError = e
if (attempt < MAX_ATTEMPTS - 1) delay(BACKOFF_MS.getOrElse(attempt) { 1000L })
}
}
throw lastError ?: error("推送失败")
}
companion object {
private val JSON = "application/json; charset=utf-8".toMediaType()
private const val MAX_ATTEMPTS = 3
private val BACKOFF_MS = longArrayOf(600L, 1200L)
}
}
@@ -0,0 +1,41 @@
package com.a2i.forwarder.core
import com.a2i.forwarder.model.BarkMessage
import com.a2i.forwarder.model.BarkServer
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
/**
* 多服务器容错推送:按列表顺序依次尝试,首个成功即止。
* 全失败 → 返回最后一个错误;空列表 → "无可用服务器"。
*/
object BarkSender {
/** stopOnFirst=true:首个成功即止;false:全部发送,至少一个成功即算成功。 */
suspend fun pushToEnabled(servers: List<BarkServer>, msg: BarkMessage, stopOnFirst: Boolean): Result<Unit> = withContext(Dispatchers.IO) {
runCatching {
require(servers.any()) { "无可用服务器" }
if (stopOnFirst) return@runCatching pushToFirst(servers, msg).getOrThrow()
var anyOk = false
var lastErr: Throwable? = null
for (s in servers) {
val r = BarkClient(s).push(msg)
if (r.isSuccess) anyOk = true else lastErr = r.exceptionOrNull()
}
if (!anyOk) throw lastErr ?: error("全部推送失败")
}
}
/** 首个成功即止(stopOnFirst=true 的实现)。 */
suspend fun pushToFirst(servers: List<BarkServer>, msg: BarkMessage): Result<Unit> = withContext(Dispatchers.IO) {
runCatching {
require(servers.any()) { "无可用服务器" }
var lastErr: Throwable? = null
for (s in servers) {
val r = BarkClient(s).push(msg)
if (r.isSuccess) return@runCatching
lastErr = r.exceptionOrNull()
}
throw lastErr ?: error("推送失败")
}
}
}
@@ -0,0 +1,92 @@
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() {
if (!settings.smsBalanceCheck.value) return // 余额检查关闭时不递减
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 (!settings.smsBalanceCheck.value) return // 余额检查关闭时不挂起
if (balance in 0..5 && !settings.smsSuspended.value) {
settings.setSmsSuspended(true)
}
}
fun currentCarrier(): CarrierDetector.Carrier {
val stored = settings.smsCarrier.value
return if (stored == "auto") CarrierDetector.detect(context)
else CarrierDetector.Carrier.fromName(stored)
}
private fun sendSms(dest: String, body: String) {
if (ContextCompat.checkSelfPermission(context, Manifest.permission.SEND_SMS)
!= PackageManager.PERMISSION_GRANTED
) return
scope.launch {
runCatching {
SmsManager.getDefault().sendTextMessage(dest, null, body, null, null)
}
}
}
}
@@ -0,0 +1,54 @@
package com.a2i.forwarder.core
import android.content.Context
import android.os.Build
import android.telephony.TelephonyManager
/**
* 运营商检测:根据 SIM 的 MCC+MNC 映射到国内运营商。
* 提供余额查询用的服务号 + 查询指令。
*/
object CarrierDetector {
enum class Carrier(
val displayName: String,
val serviceNumber: String?, // 余额查询服务号(免费)
val queryCmd: String?, // 查询指令(发送到此服务号)
) {
CHINA_MOBILE("中国移动", "10086", "CXDX"),
CHINA_UNICOM("中国联通", "10010", "CXTC"),
CHINA_TELECOM("中国电信", "10001", "108"),
CHINA_BROADCASTING("中国广电", "10099", null), // 无统一查询指令
UNKNOWN("未知运营商", null, null);
companion object {
fun fromName(name: String?): Carrier =
entries.firstOrNull { it.name == name } ?: UNKNOWN
}
}
// MCC+MNC → 运营商(中国大陆 460)
private val mncMap = mapOf(
// 中国移动
"46000" to Carrier.CHINA_MOBILE, "46002" to Carrier.CHINA_MOBILE,
"46004" to Carrier.CHINA_MOBILE, "46007" to Carrier.CHINA_MOBILE,
// 中国联通
"46001" to Carrier.CHINA_UNICOM, "46006" to Carrier.CHINA_UNICOM,
"46009" to Carrier.CHINA_UNICOM,
// 中国电信
"46003" to Carrier.CHINA_TELECOM, "46005" to Carrier.CHINA_TELECOM,
"46011" to Carrier.CHINA_TELECOM, "46012" to Carrier.CHINA_TELECOM,
// 中国广电
"46015" to Carrier.CHINA_BROADCASTING, "46018" to Carrier.CHINA_BROADCASTING,
)
fun detect(context: Context): Carrier {
return runCatching {
val tm = context.getSystemService(Context.TELEPHONY_SERVICE) as? TelephonyManager
?: return Carrier.UNKNOWN
val simOperator = tm.simOperator // 形如 "46000"
if (simOperator.isNullOrBlank() || simOperator.length < 5) return Carrier.UNKNOWN
mncMap[simOperator] ?: Carrier.UNKNOWN
}.getOrDefault(Carrier.UNKNOWN)
}
}
@@ -0,0 +1,25 @@
package com.a2i.forwarder.core
object CodeExtractor {
private val patterns = listOf(
Regex("""(?:验证码|校验码|动态码|验证代码|verification code|\bcode\b|密码|passcode|pin)\D{0,8}?(\d{4,8})""", RegexOption.IGNORE_CASE),
Regex("""(\d{4,8})\D{0,4}(?:验证码|校验码|动态码|验证代码|code)""", RegexOption.IGNORE_CASE),
Regex("""(\d{4,8})"""),
)
fun extract(text: String): String? {
// 如果文本被脱敏(全是 *****),跳过
if (text.isBlank() || isRedacted(text)) return null
for (p in patterns) {
val m = p.find(text) ?: continue
val code = m.groupValues.getOrNull(1) ?: continue
if (code.length in 4..8) return code
}
return null
}
private fun isRedacted(text: String): Boolean {
val asteriskCount = text.count { it == '*' }
return asteriskCount >= 3 && asteriskCount.toDouble() / text.length > 0.6
}
}
@@ -0,0 +1,54 @@
package com.a2i.forwarder.core
import android.content.Context
import android.net.ConnectivityManager
import android.net.Network
import android.net.NetworkCapabilities
import android.net.NetworkRequest
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
/**
* 监听网络连通性(是否能访问互联网)。
* 暴露 isOnline: StateFlow<Boolean>,断网时短信兜底转发器据此降级。
*/
class ConnectivityMonitor(private val context: Context) {
private val _isOnline = MutableStateFlow(false)
val isOnline = _isOnline.asStateFlow()
private var registered = false
private val callback = object : ConnectivityManager.NetworkCallback() {
override fun onAvailable(network: Network) { refresh() }
override fun onLost(network: Network) { refresh() }
override fun onCapabilitiesChanged(network: Network, caps: NetworkCapabilities) { refresh() }
}
fun start() {
if (registered) return
val cm = context.getSystemService(Context.CONNECTIVITY_SERVICE) as? ConnectivityManager
?: return
val request = NetworkRequest.Builder()
.addCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)
.build()
runCatching { cm.registerNetworkCallback(request, callback) }
registered = true
refresh()
}
fun stop() {
if (!registered) return
val cm = context.getSystemService(Context.CONNECTIVITY_SERVICE) as? ConnectivityManager
runCatching { cm?.unregisterNetworkCallback(callback) }
registered = false
}
private fun refresh() {
val cm = context.getSystemService(Context.CONNECTIVITY_SERVICE) as? ConnectivityManager ?: return
val active = cm.activeNetwork
val caps = active?.let { cm.getNetworkCapabilities(it) }
_isOnline.value = caps != null &&
caps.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) &&
caps.hasCapability(NetworkCapabilities.NET_CAPABILITY_VALIDATED)
}
}
@@ -0,0 +1,134 @@
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 {
val result = pushToEnabled(configs, title, body, s.emailStopOnFirst.value)
app.telemetry.trackForward(
channel = "email",
status = if (result.isSuccess) "success" else "failed",
reason = result.exceptionOrNull()?.message.orEmpty(),
)
}
}
}
@@ -0,0 +1,55 @@
package com.a2i.forwarder.core
import android.content.Context
import android.content.pm.PackageManager
import android.graphics.Bitmap
import android.graphics.Canvas
import android.graphics.drawable.BitmapDrawable
import android.graphics.drawable.Drawable
import android.net.Uri
import androidx.documentfile.provider.DocumentFile
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
class IconExporter(private val context: Context) {
data class ExportResult(val total: Int, val ok: Int, val fail: Int)
suspend fun exportAll(treeUri: Uri): ExportResult = withContext(Dispatchers.IO) {
val tree = DocumentFile.fromTreeUri(context, treeUri)
?: return@withContext ExportResult(0, 0, 0)
val pm = context.packageManager
val apps = pm.getInstalledApplications(PackageManager.GET_META_DATA)
var ok = 0
var fail = 0
for (app in apps) {
val name = app.packageName
try {
val drawable: Drawable = pm.getApplicationIcon(name)
val bmp = drawableToBitmap(drawable)
tree.findFile("$name.png")?.delete()
val file = tree.createFile("image/png", "$name.png")
if (file != null) {
context.contentResolver.openOutputStream(file.uri)?.use { out ->
bmp.compress(Bitmap.CompressFormat.PNG, 100, out)
}
ok++
} else {
fail++
}
} catch (e: Exception) {
fail++
}
}
ExportResult(apps.size, ok, fail)
}
private fun drawableToBitmap(d: Drawable): Bitmap {
if (d is BitmapDrawable && d.bitmap != null) return d.bitmap
val size = maxOf(d.intrinsicWidth, d.intrinsicHeight, 192).coerceAtLeast(1)
val bmp = Bitmap.createBitmap(size, size, Bitmap.Config.ARGB_8888)
val canvas = Canvas(bmp)
d.setBounds(0, 0, canvas.width, canvas.height)
d.draw(canvas)
return bmp
}
}
@@ -0,0 +1,101 @@
package com.a2i.forwarder.core
import android.content.Context
import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.intPreferencesKey
import androidx.datastore.preferences.core.stringPreferencesKey
import androidx.datastore.preferences.preferencesDataStore
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.launch
import kotlinx.serialization.Serializable
import kotlinx.serialization.decodeFromString
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
@Serializable
data class ForwardLog(
val time: Long,
val pkg: String,
val appLabel: String,
val title: String?,
val content: String,
val status: String, // sent / filtered / failed
val reason: String,
)
@Serializable
data class PendingPush(
val id: String,
val time: Long,
val serverId: String,
val messageJson: String,
)
private val Context.logDataStore: DataStore<Preferences> by preferencesDataStore(name = "a2i_logs")
class LogStore(private val context: Context, scope: CoroutineScope) {
private val ds get() = context.logDataStore
private val json = Json { ignoreUnknownKeys = true; encodeDefaults = true }
private object K {
val LOGS = stringPreferencesKey("logs")
val PENDING = stringPreferencesKey("pending")
val SENT = intPreferencesKey("sent")
val FILTERED = intPreferencesKey("filtered")
val FAILED = intPreferencesKey("failed")
}
val logs = MutableStateFlow<List<ForwardLog>>(emptyList())
val pending = MutableStateFlow<List<PendingPush>>(emptyList())
val sentCount = MutableStateFlow(0)
val filteredCount = MutableStateFlow(0)
val failedCount = MutableStateFlow(0)
init {
scope.launch {
ds.data.collect { p ->
logs.value = p[K.LOGS]?.let { runCatching { json.decodeFromString<List<ForwardLog>>(it) }.getOrNull() } ?: emptyList()
pending.value = p[K.PENDING]?.let { runCatching { json.decodeFromString<List<PendingPush>>(it) }.getOrNull() } ?: emptyList()
sentCount.value = p[K.SENT] ?: 0
filteredCount.value = p[K.FILTERED] ?: 0
failedCount.value = p[K.FAILED] ?: 0
}
}
}
suspend fun addLog(l: ForwardLog) {
val list = (listOf(l) + logs.value).take(300)
ds.edit { it[K.LOGS] = json.encodeToString(list) }
logs.value = list
when (l.status) {
"sent" -> ds.edit { it[K.SENT] = (sentCount.value + 1) }.also { sentCount.value += 1 }
"filtered" -> ds.edit { it[K.FILTERED] = (filteredCount.value + 1) }.also { filteredCount.value += 1 }
"failed" -> ds.edit { it[K.FAILED] = (failedCount.value + 1) }.also { failedCount.value += 1 }
}
}
suspend fun clearLogs() {
ds.edit { it.remove(K.LOGS) }
logs.value = emptyList()
}
suspend fun resetCounters() {
ds.edit { it[K.SENT] = 0; it[K.FILTERED] = 0; it[K.FAILED] = 0 }
sentCount.value = 0; filteredCount.value = 0; failedCount.value = 0
}
suspend fun addPending(p: PendingPush) {
val list = (pending.value + p).takeLast(100)
ds.edit { it[K.PENDING] = json.encodeToString(list) }
pending.value = list
}
suspend fun removePending(id: String) {
val list = pending.value.filterNot { it.id == id }
ds.edit { it[K.PENDING] = json.encodeToString(list) }
pending.value = list
}
}
@@ -0,0 +1,150 @@
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 {
val result = pushToEnabled(configs, title, body, s.meowStopOnFirst.value)
app.telemetry.trackForward(
channel = "meow",
status = if (result.isSuccess) "success" else "failed",
reason = result.exceptionOrNull()?.message.orEmpty(),
)
}
}
private fun buildRequests(endpoint: String, key: String, title: String, body: String): List<Request> {
val payload = JSONObject()
.put("deviceKey", key)
.put("device_key", key)
.put("key", key)
.put("title", title)
.put("content", body)
.put("message", body)
.put("body", body)
.toString()
val postTargets = listOf(endpoint, "$endpoint/push").distinct()
val postRequests = postTargets.map { url ->
Request.Builder()
.url(url)
.header("Authorization", "Bearer $key")
.header("X-Device-Key", key)
.post(payload.toRequestBody(JSON))
.build()
}
val getRequest = endpoint.toHttpUrlOrNull()?.newBuilder()
?.addQueryParameter("deviceKey", key)
?.addQueryParameter("key", key)
?.addQueryParameter("title", title)
?.addQueryParameter("content", body)
?.addQueryParameter("message", body)
?.build()
?.let { url ->
Request.Builder()
.url(url)
.header("Authorization", "Bearer $key")
.header("X-Device-Key", key)
.get()
.build()
}
return postRequests + listOfNotNull(getRequest)
}
private fun doPushWithRetry(req: Request) {
var lastError: IOException? = null
repeat(MAX_ATTEMPTS) { attempt ->
try {
http.newCall(req).execute().use { res ->
if (res.isSuccessful) return
error("HTTP ${res.code}")
}
} catch (e: IOException) {
lastError = e
if (attempt < MAX_ATTEMPTS - 1) {
Thread.sleep(BACKOFF_MS[attempt])
}
} catch (e: IllegalStateException) {
throw e
}
}
throw lastError ?: error("Meow 推送失败")
}
private val JSON = "application/json; charset=utf-8".toMediaType()
private const val MAX_ATTEMPTS = 3
private val BACKOFF_MS = longArrayOf(600L, 1200L)
}
@@ -0,0 +1,262 @@
package com.a2i.forwarder.core
import android.app.Notification
import android.content.Context
import android.service.notification.StatusBarNotification
import com.a2i.forwarder.A2iApp
import com.a2i.forwarder.model.BarkMessage
import java.util.LinkedHashMap
class NotificationProcessor(private val context: Context) {
private val settings get() = A2iApp.instance.settings
private val rules get() = A2iApp.instance.appRules
data class Decision(
val message: BarkMessage?,
val reason: String,
val appLabel: String,
val code: String?,
val silent: Boolean = false, // true = 静默过滤,不写日志
)
// ========== 去重缓存 ==========
// 防止同一条通知在短时间内被 Android 系统反复投递导致重复转发
// key = "${pkg}|${title}|${body}" 的 hash, value = 上次转发时间戳
private val dedupCache = object : LinkedHashMap<String, Long>(64, 0.75f, true) {
override fun removeEldestEntry(eldest: MutableMap.MutableEntry<String, Long>?): Boolean {
return size > 128
}
}
private val DEDUP_WINDOW_MS = 5_000L // 5 秒内相同内容不重复转发
// ========== 系统状态消息过滤模式 ==========
private val systemStatusPatterns = listOf(
Regex("""正在运行"""),
Regex("""is running""", RegexOption.IGNORE_CASE),
Regex("""运行中"""),
Regex("""服务(正在|已)启动"""),
Regex("""service (is )?running""", RegexOption.IGNORE_CASE),
Regex("""^.*(?:正在|is)\s*(?:运行|running|run).*$""", RegexOption.IGNORE_CASE),
)
// ========== 系统剪贴板/无用服务包名 ==========
private val noisePackages = setOf(
"com.samsung.android.honeyboard",
"com.samsung.android.app.clipboard",
"com.samsung.android.clipboard",
"com.android.systemui",
"com.google.android.inputmethod.latin",
"com.google.android.gms",
)
// 静默过滤的包名:代理/VPN 客户端的常驻流量通知,高频且无意义,过滤不写日志
private val silentPackages = setOf(
"com.getsurfboard", // Surfboard
"com.github.metacubex.clash.meta", // Clash Meta for Android
"com.github.kr328.clash", // Clash for Android
"com.v2ray.ang", // v2rayNG
"com.milink.service", // 小米设备互联(UniversalClipboardService 跨设备剪贴板等常驻通知)
)
fun process(sbn: StatusBarNotification): Decision {
val pkg = sbn.packageName
val n = sbn.notification ?: return Decision(null, "无通知", pkg, null)
val extras = n.extras
// 提取标题和文本(尝试多个来源)
val title = extras.getCharSequence(Notification.EXTRA_TITLE)?.toString().orEmpty()
var text = extras.getCharSequence(Notification.EXTRA_TEXT)?.toString().orEmpty()
val big = extras.getCharSequence(Notification.EXTRA_BIG_TEXT)?.toString().orEmpty()
val sub = extras.getCharSequence(Notification.EXTRA_SUB_TEXT)?.toString().orEmpty()
// ---- 修复 ***** 脱敏问题 ----
// 部分 ROM 会把验证码通知的 EXTRA_TEXT 脱敏为 *****
// 尝试从 tickerText 和其他备用源获取真实内容
if (isRedacted(text)) {
// 优先用 bigText(通常未被脱敏)
if (!isRedacted(big) && big.isNotBlank()) {
text = big
} else {
// 尝试 tickerTextdeprecated 但仍可能包含原文)
val ticker = n.tickerText?.toString().orEmpty()
if (!isRedacted(ticker) && ticker.isNotBlank()) {
text = ticker
}
}
}
// 确定最终 content:优先非空、非脱敏的内容
val content = listOf(text, big)
.firstOrNull { it.isNotBlank() && !isRedacted(it) }
?: text.ifBlank { big }
val appLabel = runCatching {
context.packageManager.getApplicationLabel(context.packageManager.getApplicationInfo(pkg, 0)).toString()
}.getOrDefault(pkg)
// ---- 全局开关 ----
if (!settings.forwardingEnabled.value)
return Decision(null, "全局转发已关", appLabel, null)
// ---- 持续性通知过滤(静默,不进日志)----
val isOngoing = n.flags and Notification.FLAG_ONGOING_EVENT != 0
if (settings.skipOngoing.value && isOngoing)
return Decision(null, "持续性通知", appLabel, null, silent = true)
// ---- 静默包名过滤(VPN/代理客户端的常驻流量通知,高频无意义,不写日志)----
if (pkg in silentPackages)
return Decision(null, "常驻通知静默", appLabel, null, silent = true)
// ---- 米家泛化状态通知静默("设备状态"等概览;门锁/告警等具体事件保留)----
if (pkg == "com.xiaomi.smarthome" && content.contains("设备状态"))
return Decision(null, "米家状态通知静默", appLabel, null, silent = true)
// ---- App 黑白名单 ----
if (!rules.isForwardingOn(pkg))
return Decision(null, "此 App 已关", appLabel, null)
// ---- 空内容 / 脱敏内容过滤 ----
if (title.isBlank() && content.isBlank())
return Decision(null, "空内容", appLabel, null)
// 如果所有文本源都被脱敏了,不要转发无意义内容
if (isRedacted(content) && isRedacted(title))
return Decision(null, "内容已脱敏", appLabel, null)
// ---- 系统噪音包名过滤 ----
if (pkg in noisePackages && (content.isBlank() || content.contains("clipboard", true) || content.contains("剪贴", true)))
return Decision(null, "系统噪音通知", appLabel, null)
// ---- 系统状态消息过滤 ----
if (isSystemStatusMessage("$title $content $sub"))
return Decision(null, "系统状态消息", appLabel, null)
// ---- 近空内容过滤(纯符号、纯空格、仅 app 名等)----
val trimmedContent = content.trim()
if (trimmedContent.length < 2 && title.isBlank())
return Decision(null, "内容过短", appLabel, null)
// ---- 广告过滤 ----
if (settings.adFilterEnabled.value && AdFilter.isAd("$title $content $sub", settings.adKeywords.value))
return Decision(null, "广告过滤", appLabel, null)
// ---- 短信"发出"过滤:只保留收到的短信 ----
if (isSentSmsNotification(pkg, title, content))
return Decision(null, "已发短信(仅收不发)", appLabel, null)
// ---- 运营商余额回执拦截:解析短信余量,不转发 ----
val balanceParsed = tryParseCarrierBalance(pkg, title, content)
if (balanceParsed != null) {
return Decision(null, "运营商余额回执(${balanceParsed}条)", appLabel, null)
}
// ---- 去重检查 ----
val dedupKey = "$pkg|${title.take(80)}|${content.take(80)}"
val lastTime = dedupCache[dedupKey]
val now = System.currentTimeMillis()
if (lastTime != null && (now - lastTime) < DEDUP_WINDOW_MS) {
return Decision(null, "去重(${now - lastTime}ms 内重复)", appLabel, null)
}
dedupCache[dedupKey] = now
// ---- 验证码提取 ----
var code: String? = null
if (settings.codeEnabled.value) {
code = CodeExtractor.extract("$title $content")
}
// ---- 特殊 App 解析 ----
var msgTitle = title.ifBlank { appLabel }
var group: String = pkg
var clickUrl: String? = null
var important = false
if (settings.specialEnabled.value) {
val p = AppParsers.parse(pkg, title, content)
p.titleOverride?.let { msgTitle = it }
p.groupKey?.let { group = it }
clickUrl = p.clickUrl
important = p.important
}
if (code != null) important = true
if (code != null) {
msgTitle = title.ifBlank { "验证码" }
}
val iconPrefix = settings.iconPrefix.value.trim()
val icon = if (iconPrefix.isBlank()) null else iconPrefix.trimEnd('/') + "/" + pkg + ".png"
val level = if (important) "timeSensitive" else "active"
val msg = BarkMessage(
deviceKey = "",
title = msgTitle.take(100),
body = content.ifBlank { title }.take(500),
level = level,
icon = icon,
group = group,
url = clickUrl,
autoCopy = if (code != null) "1" else null,
copy = code,
isArchive = "1",
)
return Decision(msg, "转发", appLabel, code)
}
// ---- 辅助方法 ----
/** 检测文本是否被系统脱敏(全部或大部分是 * 号) */
private fun isRedacted(text: String): Boolean {
if (text.isBlank()) return false
val asteriskCount = text.count { it == '*' }
// 如果 * 占比超过 60%,认为是脱敏内容
return asteriskCount >= 3 && asteriskCount.toDouble() / text.length > 0.6
}
/** 检测是否为系统状态消息(如"短信正在运行" */
private fun isSystemStatusMessage(text: String): Boolean {
return systemStatusPatterns.any { it.containsMatchIn(text) }
}
/** 短信 App 列表 */
private val smsPackages = setOf(
"com.android.mms",
"com.google.android.apps.messaging",
"com.samsung.android.messaging",
"com.android.messaging",
)
/** 发出的短信通知关键词 */
private val sentSmsPatterns = listOf(
Regex("""发送中"""),
Regex("""正在发送"""),
Regex("""已发送"""),
Regex("""发送成功"""),
Regex("""短信已发出"""),
Regex("""消息已发出"""),
Regex("""sending""", RegexOption.IGNORE_CASE),
Regex("""message sent""", RegexOption.IGNORE_CASE),
Regex("""sent$""", RegexOption.IGNORE_CASE),
)
/** 判断是否为发出的短信通知(而非收到的) */
private fun isSentSmsNotification(pkg: String, title: String, content: String): Boolean {
if (pkg !in smsPackages) return false
val blob = "$title $content"
return sentSmsPatterns.any { it.containsMatchIn(blob) }
}
/**
* 拦截运营商余额回执短信:title 是运营商服务号(10086/10010/10001/10099),
* 解析正文中的剩余短信条数,写入设置并按"过滤"处理。
* 返回解析到的条数(已处理),返回 null 表示不是回执或解析失败。
*/
private fun tryParseCarrierBalance(pkg: String, title: String, content: String): Int? {
if (pkg !in smsPackages) return null
val serviceNumbers = setOf("10086", "10010", "10001", "10099")
if (title.trim() !in serviceNumbers) return null
val app = A2iApp.instance
val count = app.carrierBalanceQuery.parseReply("$title $content") ?: return null
kotlinx.coroutines.runBlocking { app.carrierBalanceQuery.applyParsedBalance(count) }
return count
}
}
@@ -0,0 +1,107 @@
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 {
val result = pushToEnabled(configs, title, body, s.ntfyStopOnFirst.value)
app.telemetry.trackForward(
channel = "ntfy",
status = if (result.isSuccess) "success" else "failed",
reason = result.exceptionOrNull()?.message.orEmpty(),
)
}
}
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() 监听来电状态。
* 这里绕过第三方监听器不稳定的限制,直接在轮询状态变化时转发通知。
*/
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 {
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, "来电")
}
}
from == TelephonyManager.CALL_STATE_RINGING && to == TelephonyManager.CALL_STATE_OFFHOOK -> {
offhook = true
}
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
}
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() }
val now = System.currentTimeMillis()
if (servers.isEmpty()) {
val reason = "未配置可用 Bark 服务器"
app.logStore.addLog(ForwardLog(now, "phone_call", "电话", msg.title, logBody, "failed", reason))
app.telemetry.trackForward("bark", "failed", reason)
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)
if (result.isSuccess) {
app.logStore.addLog(
ForwardLog(now, "phone_call", "电话", msg.title, logBody, "sent", "")
)
app.telemetry.trackForward("bark", "success")
} else {
val reason = result.exceptionOrNull()?.message ?: "错误"
app.logStore.addLog(
ForwardLog(now, "phone_call", "电话", msg.title, logBody, "failed", reason)
)
app.telemetry.trackForward("bark", "failed", reason)
app.logStore.addPending(
PendingPush(UUID.randomUUID().toString(), now, servers.first().id, json.encodeToString(msg))
)
}
EmailSender.trySend(app, msg.title ?: logBody, msg.body ?: "", scope)
NtfySender.trySend(app, msg.title ?: logBody, msg.body ?: "", scope)
MeowSender.trySend(app, msg.title ?: logBody, msg.body ?: "", scope)
}
}
}
@@ -0,0 +1,56 @@
package com.a2i.forwarder.core
import android.content.Context
import androidx.work.Constraints
import androidx.work.CoroutineWorker
import androidx.work.ExistingPeriodicWorkPolicy
import androidx.work.NetworkType
import androidx.work.PeriodicWorkRequestBuilder
import androidx.work.WorkManager
import androidx.work.WorkerParameters
import com.a2i.forwarder.A2iApp
import kotlinx.serialization.decodeFromString
import kotlinx.serialization.json.Json
import java.util.concurrent.TimeUnit
class RetryWorker(ctx: Context, params: WorkerParameters) : CoroutineWorker(ctx, params) {
override suspend fun doWork(): Result {
val app = A2iApp.instance
val store = app.logStore
val settings = app.settings
val list = store.pending.value
if (list.isEmpty()) return Result.success()
var allOk = true
val json = Json { ignoreUnknownKeys = true }
val servers = settings.servers.value.filter { it.enabled && it.deviceKey.isNotBlank() }
for (p in list) {
if (servers.isEmpty()) { allOk = false; break }
val msg = runCatching { json.decodeFromString<com.a2i.forwarder.model.BarkMessage>(p.messageJson) }.getOrNull() ?: continue
val r = BarkSender.pushToFirst(servers, msg)
if (r.isSuccess) {
store.removePending(p.id)
app.telemetry.trackForward("bark", "success")
} else {
allOk = false
app.telemetry.trackForward("bark", "failed", r.exceptionOrNull()?.message.orEmpty())
}
}
return if (allOk) Result.success() else Result.retry()
}
companion object {
private const val NAME = "a2i_retry"
fun schedule(context: Context) {
val req = PeriodicWorkRequestBuilder<RetryWorker>(15, TimeUnit.MINUTES)
.setConstraints(
Constraints.Builder().setRequiredNetworkType(NetworkType.CONNECTED).build()
)
.build()
WorkManager.getInstance(context)
.enqueueUniquePeriodicWork(NAME, ExistingPeriodicWorkPolicy.UPDATE, req)
}
}
}
@@ -0,0 +1,124 @@
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
/**
* 离线时把紧要通知走短信兜底。
*/
class SmsFallbackForwarder(private val context: Context) {
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
private val app get() = A2iApp.instance
private val settings get() = app.settings
fun isSmsWorthy(d: Decision): Boolean {
if (settings.smsFilterAll.value) return true
if (d.code != null) return true
val msg = d.message ?: return false
if (msg.group == "phone_call") return true
val title = msg.title.orEmpty()
return title.contains("来电") || title.contains("未接")
}
fun forward(d: Decision) {
scope.launch {
val s = settings
if (s.smsBalanceCheck.value && s.smsSuspended.value) {
log(d, "failed", "短信已挂起(余额不足)")
return@launch
}
if (!isSmsWorthy(d)) return@launch
val targets = s.smsTargets.value.filter { it.enabled && it.number.isNotBlank() }
if (targets.isEmpty()) {
log(d, "failed", "未设置短信目标号")
return@launch
}
val now = System.currentTimeMillis()
if (s.smsRateLimitEnabled.value) {
val limitMs = s.smsRateLimitMin.value.coerceIn(1, 60) * 60 * 1000L
val elapsed = now - s.lastSmsSentTime.value
if (elapsed < limitMs) {
log(d, "filtered", "限频(${limitMs - 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 = s.smsStopOnFirst.value
var anyOk = false
var sentCount = 0
var lastReason = "短信发送失败"
for (target in targets) {
val result = runCatching {
SmsManager.getDefault().sendTextMessage(target.number.trim(), null, body, null, null)
}
if (result.isSuccess) {
anyOk = true
sentCount++
if (stopOnFirst) break
} else {
lastReason = result.exceptionOrNull()?.message ?: "短信发送失败"
}
}
if (anyOk) {
s.setLastSmsSentTime(now)
log(d, "sent", "")
if (s.smsBalanceCheck.value) {
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 message = d.message!!
"${message.title.orEmpty().ifBlank { d.appLabel }}: ${message.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)
)
when (status) {
"sent" -> app.telemetry.trackForward("sms", "success")
"failed" -> app.telemetry.trackForward("sms", "failed", reason)
}
}
}
}
@@ -0,0 +1,178 @@
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")
}
}
fun trackForward(channel: String, status: String, reason: String = "") {
val normalizedChannel = channel.trim().lowercase(Locale.ROOT)
val normalizedStatus = status.trim().lowercase(Locale.ROOT)
if (normalizedChannel.isBlank() || normalizedStatus.isBlank()) return
scope.launch(Dispatchers.IO) {
send(
event = "forward_$normalizedChannel",
fields = mapOf(
"channel" to normalizedChannel,
"status" to normalizedStatus,
"reason" to reason.trim().take(500),
),
)
}
}
private suspend fun send(event: String): Result<Unit> = withContext(Dispatchers.IO) {
runCatching {
val payload = basePayload(event)
val request = Request.Builder()
.url(EVENTS_URL)
.header("User-Agent", userAgent())
.post(payload.toString().toRequestBody(JSON))
.build()
http.newCall(request).execute().use { response ->
if (!response.isSuccessful) error("HTTP ${response.code}")
}
}
}
private suspend fun send(event: String, fields: Map<String, Any?>): Result<Unit> = withContext(Dispatchers.IO) {
runCatching {
val payload = basePayload(event)
fields.forEach { (key, value) ->
if (value == null) return@forEach
when (value) {
is String -> if (value.isNotBlank()) payload.put(key, value)
is Number, is Boolean -> payload.put(key, value)
else -> payload.put(key, value)
}
}
val request = Request.Builder()
.url(EVENTS_URL)
.header("User-Agent", userAgent())
.post(payload.toString().toRequestBody(JSON))
.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)
}
}
@@ -0,0 +1,22 @@
package com.a2i.forwarder.model
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
@Serializable
data class BarkMessage(
@SerialName("device_key") val deviceKey: String,
val body: String,
val title: String? = null,
val subtitle: String? = null,
val level: String? = null, // active / timeSensitive / critical / passive
val badge: Int? = null,
@SerialName("autoCopy") val autoCopy: String? = null, // "1" = 收到即复制
val copy: String? = null, // 复制内容
val sound: String? = null,
val icon: String? = null, // 公网图片 URL
val group: String? = null,
val url: String? = null, // 点击跳转
@SerialName("isArchive") val isArchive: String? = null, // "1" = 归档
val action: String? = null,
)
@@ -0,0 +1,55 @@
package com.a2i.forwarder.model
import kotlinx.serialization.Serializable
import java.util.UUID
@Serializable
data class BarkServer(
val id: String = UUID.randomUUID().toString(),
val name: String,
val server: String,
val deviceKey: String,
val enabled: Boolean = true,
)
const val OFFICIAL_BARK_SERVER = "https://api.day.app"
@Serializable
data class NtfyServer(
val id: String = UUID.randomUUID().toString(),
val name: String,
val server: String = "https://ntfy.sh",
val topic: String = "",
val token: String = "",
val enabled: Boolean = true,
)
@Serializable
data class MeowServer(
val id: String = UUID.randomUUID().toString(),
val name: String,
val server: String = "",
val deviceKey: String = "",
val enabled: Boolean = true,
)
@Serializable
data class EmailServer(
val id: String = UUID.randomUUID().toString(),
val name: String,
val host: String = "",
val port: Int = 465,
val user: String = "",
val password: String = "",
val from: String = "",
val to: String = "",
val enabled: Boolean = true,
)
@Serializable
data class SmsTarget(
val id: String = UUID.randomUUID().toString(),
val name: String,
val number: String = "",
val enabled: Boolean = true,
)
@@ -0,0 +1,93 @@
package com.a2i.forwarder.service
import android.service.notification.NotificationListenerService
import android.service.notification.StatusBarNotification
import com.a2i.forwarder.A2iApp
import com.a2i.forwarder.core.BarkSender
import com.a2i.forwarder.core.EmailSender
import com.a2i.forwarder.core.ForwardLog
import com.a2i.forwarder.core.MeowSender
import com.a2i.forwarder.core.NotificationProcessor
import com.a2i.forwarder.core.NtfySender
import com.a2i.forwarder.core.PendingPush
import com.a2i.forwarder.core.RetryWorker
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.launch
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
import java.util.UUID
class NotifyListenerService : NotificationListenerService() {
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
private val processor by lazy { NotificationProcessor(applicationContext) }
private val json = Json { encodeDefaults = true; ignoreUnknownKeys = true }
override fun onListenerConnected() {
super.onListenerConnected()
RetryWorker.schedule(applicationContext)
}
override fun onNotificationPosted(sbn: StatusBarNotification?) {
val posted = sbn ?: return
if (posted.packageName == packageName) return
scope.launch { handle(posted) }
}
private suspend fun handle(sbn: StatusBarNotification) {
val app = A2iApp.instance
val now = System.currentTimeMillis()
val decision = processor.process(sbn)
val msg = decision.message
if (msg == null) {
if (!decision.silent) {
app.logStore.addLog(
ForwardLog(now, sbn.packageName, decision.appLabel, "", "", "filtered", decision.reason)
)
}
return
}
if (!app.connectivityMonitor.isOnline.value) {
if (app.smsForwarder.isSmsWorthy(decision)) {
app.smsForwarder.forward(decision)
} else {
app.logStore.addLog(
ForwardLog(now, sbn.packageName, decision.appLabel, msg.title, msg.body, "filtered", "离线非紧要通知")
)
}
return
}
val servers = app.settings.servers.value.filter { it.enabled && it.deviceKey.isNotBlank() }
if (servers.isEmpty()) {
val reason = "未配置可用 Bark 服务器"
app.logStore.addLog(ForwardLog(now, sbn.packageName, decision.appLabel, msg.title, msg.body, "failed", reason))
app.telemetry.trackForward("bark", "failed", reason)
EmailSender.trySend(app, msg.title ?: decision.appLabel, msg.body, scope)
NtfySender.trySend(app, msg.title ?: decision.appLabel, msg.body, scope)
MeowSender.trySend(app, msg.title ?: decision.appLabel, msg.body, scope)
return
}
val result = BarkSender.pushToEnabled(servers, msg, app.settings.barkStopOnFirst.value)
if (result.isSuccess) {
app.logStore.addLog(ForwardLog(now, sbn.packageName, decision.appLabel, msg.title, msg.body, "sent", ""))
app.telemetry.trackForward("bark", "success")
} else {
val reason = result.exceptionOrNull()?.message ?: "错误"
app.logStore.addLog(ForwardLog(now, sbn.packageName, decision.appLabel, msg.title, msg.body, "failed", reason))
app.telemetry.trackForward("bark", "failed", reason)
app.logStore.addPending(
PendingPush(UUID.randomUUID().toString(), now, servers.first().id, json.encodeToString(msg))
)
}
EmailSender.trySend(app, msg.title ?: decision.appLabel, msg.body, scope)
NtfySender.trySend(app, msg.title ?: decision.appLabel, msg.body, scope)
MeowSender.trySend(app, msg.title ?: decision.appLabel, msg.body, scope)
}
}
@@ -0,0 +1,127 @@
package com.a2i.forwarder.store
import android.content.Context
import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.intPreferencesKey
import androidx.datastore.preferences.core.stringPreferencesKey
import androidx.datastore.preferences.core.stringSetPreferencesKey
import androidx.datastore.preferences.preferencesDataStore
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.launch
private val Context.rulesDataStore: DataStore<Preferences> by preferencesDataStore(name = "a2i_rules")
enum class RuleMode { BLACKLIST, WHITELIST }
class AppRulesStore(
private val context: Context,
scope: CoroutineScope,
) {
private val ds get() = context.rulesDataStore
private object K {
val MODE = stringPreferencesKey("mode")
val BLACK = stringSetPreferencesKey("blacklist")
val WHITE = stringSetPreferencesKey("whitelist")
val BL_VERSION = intPreferencesKey("bl_version")
}
val mode = MutableStateFlow(RuleMode.BLACKLIST)
val blacklist = MutableStateFlow<Set<String>>(defaultBlacklist)
val whitelist = MutableStateFlow<Set<String>>(emptySet())
init {
scope.launch {
ds.data.collect { p ->
mode.value = runCatching { RuleMode.valueOf(p[K.MODE] ?: "BLACKLIST") }.getOrDefault(RuleMode.BLACKLIST)
// 默认黑名单版本迁移:把新增的噪音包补进老用户的黑名单(只增不删)
val ver = p[K.BL_VERSION] ?: 1
var black = p[K.BLACK] ?: defaultBlacklist
if (ver < DEFAULT_BL_VERSION) {
black = black + blAdded
ds.edit { it[K.BLACK] = black; it[K.BL_VERSION] = DEFAULT_BL_VERSION }
}
blacklist.value = black
whitelist.value = p[K.WHITE] ?: emptySet()
}
}
}
fun isForwardingOn(packageName: String): Boolean = when (mode.value) {
RuleMode.BLACKLIST -> packageName !in blacklist.value
RuleMode.WHITELIST -> packageName in whitelist.value
}
suspend fun setMode(m: RuleMode) {
ds.edit { it[K.MODE] = m.name }
mode.value = m
}
suspend fun setForwarding(packageName: String, on: Boolean) {
when (mode.value) {
RuleMode.BLACKLIST -> {
val s = blacklist.value.toMutableSet()
if (on) s.remove(packageName) else s.add(packageName)
ds.edit { it[K.BLACK] = s }
blacklist.value = s
}
RuleMode.WHITELIST -> {
val s = whitelist.value.toMutableSet()
if (on) s.add(packageName) else s.remove(packageName)
ds.edit { it[K.WHITE] = s }
whitelist.value = s
}
}
}
companion object {
private const val DEFAULT_BL_VERSION = 3
// 默认黑名单增量(累积,用于老用户版本迁移,只补不删)
private val blAdded = setOf(
// v2
"com.android.soundrecorder", // 录音机
"com.google.android.apps.photos", // 相册
"com.xiaomi.finddevice", // 查找手机
"com.miui.passwords", // 密码管理
// v3:通话过程中的系统状态通知,会与来电(PhoneCallMonitor)重复
"com.android.server.telecom", // 通话管理
"com.xiaomi.aiasst.service", // AI通话
)
val defaultBlacklist = setOf(
// Android 系统
"com.android.systemui",
"com.android.settings",
"com.android.permissioncontroller",
// 注意:com.android.phone / com.android.incallui 不在黑名单中,电话通知需要转发
// 输入法
"com.google.android.inputmethod",
"com.google.android.inputmethod.latin",
"com.iflytek.inputmethod",
"com.baidu.input",
"com.sohu.inputmethod.sogou",
// 三星剪贴板 / 键盘(经常弹出 UniversalClipboardService 等空通知)
"com.samsung.android.honeyboard",
"com.samsung.android.app.clipboard",
"com.samsung.android.clipboard",
"com.samsung.android.rubin.app",
// Google Play Services(经常发内部状态通知)
"com.google.android.gms",
"com.google.android.gms.persistent",
// a2i 自身
"com.a2i.forwarder",
// [v2] 纯噪音 App:转发无意义,默认屏蔽
"com.android.soundrecorder", // 录音机
"com.google.android.apps.photos", // 相册
"com.xiaomi.finddevice", // 查找手机
"com.miui.passwords", // 密码管理
// [v3] 通话状态噪音:来电由 PhoneCallMonitor 独立处理,这两个会重复
"com.android.server.telecom", // 通话管理
"com.xiaomi.aiasst.service", // AI通话
)
}
}
@@ -0,0 +1,375 @@
package com.a2i.forwarder.store
import android.content.Context
import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.booleanPreferencesKey
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.intPreferencesKey
import androidx.datastore.preferences.core.longPreferencesKey
import androidx.datastore.preferences.core.stringPreferencesKey
import androidx.datastore.preferences.preferencesDataStore
import com.a2i.forwarder.model.BarkServer
import com.a2i.forwarder.model.EmailServer
import com.a2i.forwarder.model.MeowServer
import com.a2i.forwarder.model.NtfyServer
import com.a2i.forwarder.model.OFFICIAL_BARK_SERVER
import com.a2i.forwarder.model.SmsTarget
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
import kotlinx.serialization.decodeFromString
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
private val Context.settingsDataStore: DataStore<Preferences> by preferencesDataStore(name = "a2i_settings")
class SettingsStore(
private val context: Context,
scope: CoroutineScope,
) {
private val ds get() = context.settingsDataStore
private val json = Json { ignoreUnknownKeys = true; encodeDefaults = true }
private object K {
val SERVERS = stringPreferencesKey("servers")
val ICON_PREFIX = stringPreferencesKey("icon_prefix")
val FWD = booleanPreferencesKey("forwarding")
val CODE = booleanPreferencesKey("code")
val AD = booleanPreferencesKey("ad_filter")
val SPECIAL = booleanPreferencesKey("special")
val SKIP_ONGOING = booleanPreferencesKey("skip_ongoing")
val AD_KW = stringPreferencesKey("ad_keywords")
// 各通道「发通一条止 / 全发」开关
val BARK_STOP_FIRST = booleanPreferencesKey("bark_stop_first")
val NTFY_STOP_FIRST = booleanPreferencesKey("ntfy_stop_first")
val MEOW_STOP_FIRST = booleanPreferencesKey("meow_stop_first")
val EMAIL_STOP_FIRST = booleanPreferencesKey("email_stop_first")
val SMS_STOP_FIRST = booleanPreferencesKey("sms_stop_first")
// 短信兜底(目标号已列表化;运营商/余额/限频保持全局)
val SMS_FALLBACK = booleanPreferencesKey("sms_fallback")
val SMS_TARGETS = stringPreferencesKey("sms_targets")
val SMS_CARRIER = stringPreferencesKey("sms_carrier")
val SMS_QUOTA = intPreferencesKey("sms_quota")
val SMS_BALANCE = intPreferencesKey("sms_balance")
val SMS_SUSPENDED = booleanPreferencesKey("sms_suspended")
val SMS_LAST_SENT = longPreferencesKey("sms_last_sent")
val SMS_FILTER_ALL = booleanPreferencesKey("sms_filter_all") // true=全部转发 false=仅紧要
val SMS_RATE_LIMIT_ENABLED = booleanPreferencesKey("sms_rate_limit_en")
val SMS_RATE_LIMIT_MIN = intPreferencesKey("sms_rate_limit_min")
val SMS_BALANCE_CHECK = booleanPreferencesKey("sms_balance_check")
// 电邮转发
val EMAIL_ENABLED = booleanPreferencesKey("email_enabled")
val EMAIL_SERVERS = stringPreferencesKey("email_servers")
val NTFY_SERVERS = stringPreferencesKey("ntfy_servers")
val MEOW_SERVERS = stringPreferencesKey("meow_servers")
// 应用更新
val UPDATE_CHECK_AUTO = booleanPreferencesKey("update_check_auto")
val LAST_UPDATE_CHECK = longPreferencesKey("last_update_check")
val IGNORED_VERSION = stringPreferencesKey("ignored_version")
// 配置版本(用于旧单组配置迁移)
val CONFIG_VERSION = intPreferencesKey("config_version")
// ---- 旧单组键(仅迁移用,迁移后清理)----
val SMS_TARGET = stringPreferencesKey("sms_target")
val EMAIL_HOST = stringPreferencesKey("email_smtp_host")
val EMAIL_PORT = intPreferencesKey("email_smtp_port")
val EMAIL_USER = stringPreferencesKey("email_smtp_user")
val EMAIL_PASS = stringPreferencesKey("email_smtp_pass")
val EMAIL_FROM = stringPreferencesKey("email_from")
val EMAIL_TO = stringPreferencesKey("email_to")
val NTFY_SERVER = stringPreferencesKey("ntfy_server")
val NTFY_TOPIC = stringPreferencesKey("ntfy_topic")
val NTFY_TOKEN = stringPreferencesKey("ntfy_token")
}
private val defaultServers = listOf(
BarkServer(id = ID_OFFICIAL, name = "Bark 官方", server = OFFICIAL_BARK_SERVER, deviceKey = "")
)
private val _servers = MutableStateFlow(defaultServers)
val servers = _servers.asStateFlow()
private val _ntfyServers = MutableStateFlow<List<NtfyServer>>(emptyList())
val ntfyServers = _ntfyServers.asStateFlow()
private val _meowServers = MutableStateFlow<List<MeowServer>>(emptyList())
val meowServers = _meowServers.asStateFlow()
private val _emailServers = MutableStateFlow<List<EmailServer>>(emptyList())
val emailServers = _emailServers.asStateFlow()
private val _smsTargets = MutableStateFlow<List<SmsTarget>>(emptyList())
val smsTargets = _smsTargets.asStateFlow()
val iconPrefix = MutableStateFlow(DEFAULT_ICON_PREFIX)
val forwardingEnabled = MutableStateFlow(true)
val codeEnabled = MutableStateFlow(true)
val adFilterEnabled = MutableStateFlow(true)
val specialEnabled = MutableStateFlow(true)
val skipOngoing = MutableStateFlow(true)
val adKeywords = MutableStateFlow<List<String>>(emptyList())
// 各通道「发通一条止 / 全发」
val barkStopOnFirst = MutableStateFlow(true)
val ntfyStopOnFirst = MutableStateFlow(false)
val meowStopOnFirst = MutableStateFlow(false)
val emailStopOnFirst = MutableStateFlow(false)
val smsStopOnFirst = MutableStateFlow(false)
// 短信兜底(全局)
val smsFallbackEnabled = MutableStateFlow(false)
val smsCarrier = MutableStateFlow("auto")
val smsManualQuota = MutableStateFlow(0)
val smsBalance = MutableStateFlow(-1)
val smsSuspended = MutableStateFlow(false)
val lastSmsSentTime = MutableStateFlow(0L)
val smsFilterAll = MutableStateFlow(false) // true=全部转发 false=仅紧要
val smsRateLimitEnabled = MutableStateFlow(true)
val smsRateLimitMin = MutableStateFlow(5)
val smsBalanceCheck = MutableStateFlow(true)
// 电邮总开关
val emailForwardingEnabled = MutableStateFlow(false)
// 应用更新
val updateCheckEnabled = MutableStateFlow(true)
val lastUpdateCheckTime = MutableStateFlow(0L)
val ignoredVersion = MutableStateFlow("")
init {
scope.launch {
ds.data.collect { p ->
val list: List<BarkServer> = p[K.SERVERS]
?.let { runCatching { json.decodeFromString<List<BarkServer>>(it) }.getOrNull() }
?: defaultServers
_servers.value = list
iconPrefix.value = p[K.ICON_PREFIX] ?: DEFAULT_ICON_PREFIX
forwardingEnabled.value = p[K.FWD] ?: true
codeEnabled.value = p[K.CODE] ?: true
adFilterEnabled.value = p[K.AD] ?: true
specialEnabled.value = p[K.SPECIAL] ?: true
skipOngoing.value = p[K.SKIP_ONGOING] ?: true
adKeywords.value = p[K.AD_KW]
?.let { runCatching { json.decodeFromString<List<String>>(it) }.getOrNull() }
?: emptyList()
// 各通道 stopOnFirst
barkStopOnFirst.value = p[K.BARK_STOP_FIRST] ?: true
ntfyStopOnFirst.value = p[K.NTFY_STOP_FIRST] ?: false
meowStopOnFirst.value = p[K.MEOW_STOP_FIRST] ?: false
emailStopOnFirst.value = p[K.EMAIL_STOP_FIRST] ?: false
smsStopOnFirst.value = p[K.SMS_STOP_FIRST] ?: false
// 短信兜底全局
smsFallbackEnabled.value = p[K.SMS_FALLBACK] ?: false
smsCarrier.value = p[K.SMS_CARRIER] ?: "auto"
smsManualQuota.value = p[K.SMS_QUOTA] ?: 0
smsBalance.value = p[K.SMS_BALANCE] ?: -1
smsSuspended.value = p[K.SMS_SUSPENDED] ?: false
lastSmsSentTime.value = p[K.SMS_LAST_SENT] ?: 0L
smsFilterAll.value = p[K.SMS_FILTER_ALL] ?: false
smsRateLimitEnabled.value = p[K.SMS_RATE_LIMIT_ENABLED] ?: true
smsRateLimitMin.value = p[K.SMS_RATE_LIMIT_MIN] ?: 5
smsBalanceCheck.value = p[K.SMS_BALANCE_CHECK] ?: true
// 电邮总开关
emailForwardingEnabled.value = p[K.EMAIL_ENABLED] ?: false
// 应用更新
updateCheckEnabled.value = p[K.UPDATE_CHECK_AUTO] ?: true
lastUpdateCheckTime.value = p[K.LAST_UPDATE_CHECK] ?: 0L
ignoredVersion.value = p[K.IGNORED_VERSION] ?: ""
// 列表配置
_ntfyServers.value = p[K.NTFY_SERVERS]
?.let { runCatching { json.decodeFromString<List<NtfyServer>>(it) }.getOrNull() }
?: emptyList()
_meowServers.value = p[K.MEOW_SERVERS]
?.let { runCatching { json.decodeFromString<List<MeowServer>>(it) }.getOrNull() }
?: emptyList()
_emailServers.value = p[K.EMAIL_SERVERS]
?.let { runCatching { json.decodeFromString<List<EmailServer>>(it) }.getOrNull() }
?: emptyList()
_smsTargets.value = p[K.SMS_TARGETS]
?.let { runCatching { json.decodeFromString<List<SmsTarget>>(it) }.getOrNull() }
?: emptyList()
// ---- 旧单组配置迁移(config_version < 2----
val cfgVer = p[K.CONFIG_VERSION] ?: 1
if (cfgVer < 2) {
migrateLegacyConfigs(p)
}
}
}
}
/** 把旧的单组 ntfy/email/sms 配置转成列表的一条。 */
private suspend fun migrateLegacyConfigs(p: Preferences) {
var ntfyList = _ntfyServers.value
var emailList = _emailServers.value
var smsList = _smsTargets.value
val nTopic = p[K.NTFY_TOPIC]
if (!nTopic.isNullOrBlank() && ntfyList.isEmpty()) {
ntfyList = listOf(NtfyServer(name = "ntfy", server = p[K.NTFY_SERVER] ?: "https://ntfy.sh", topic = nTopic, token = p[K.NTFY_TOKEN] ?: ""))
}
val eHost = p[K.EMAIL_HOST]
val eTo = p[K.EMAIL_TO]
if (!eHost.isNullOrBlank() && emailList.isEmpty()) {
emailList = listOf(EmailServer(
name = "邮箱", host = eHost,
port = (p[K.EMAIL_PORT] ?: 465).let { if (it < 25) 465 else it },
user = p[K.EMAIL_USER] ?: "", password = p[K.EMAIL_PASS] ?: "",
from = p[K.EMAIL_FROM] ?: "", to = eTo ?: "",
))
}
val sTarget = p[K.SMS_TARGET]
if (!sTarget.isNullOrBlank() && smsList.isEmpty()) {
smsList = listOf(SmsTarget(name = "iPhone", number = sTarget))
}
ds.edit { m ->
if (ntfyList.isNotEmpty()) {
m[K.NTFY_SERVERS] = json.encodeToString(ntfyList)
m.remove(K.NTFY_SERVER); m.remove(K.NTFY_TOPIC); m.remove(K.NTFY_TOKEN)
}
if (emailList.isNotEmpty()) {
m[K.EMAIL_SERVERS] = json.encodeToString(emailList)
listOf(K.EMAIL_HOST, K.EMAIL_PORT, K.EMAIL_USER, K.EMAIL_PASS, K.EMAIL_FROM, K.EMAIL_TO).forEach { m.remove(it) }
}
if (smsList.isNotEmpty()) {
m[K.SMS_TARGETS] = json.encodeToString(smsList)
m.remove(K.SMS_TARGET)
}
m[K.CONFIG_VERSION] = 2
}
_ntfyServers.value = ntfyList
_emailServers.value = emailList
_smsTargets.value = smsList
}
private suspend fun edit(block: (androidx.datastore.preferences.core.MutablePreferences) -> Unit) =
ds.edit(block)
// ---- Bark 服务器 CRUD ----
private suspend fun saveServers(list: List<BarkServer>) {
edit { it[K.SERVERS] = json.encodeToString(list) }
_servers.value = list
}
suspend fun addServer(name: String, server: String, key: String): BarkServer {
val s = BarkServer(name = name.trim(), server = server.trim().removeSuffix("/"), deviceKey = key.trim())
saveServers(_servers.value + s)
return s
}
suspend fun updateServer(s: BarkServer) = saveServers(_servers.value.map { if (it.id == s.id) s.copy(server = s.server.trim().removeSuffix("/")) else it })
suspend fun deleteServer(id: String) = saveServers(_servers.value.filterNot { it.id == id })
suspend fun moveServer(id: String, up: Boolean) {
val list = _servers.value.toMutableList()
val i = list.indexOfFirst { it.id == id }
if (i in 1..list.lastIndex && up) { java.util.Collections.swap(list, i, i - 1); saveServers(list) }
else if (i in 0 until list.lastIndex && !up) { java.util.Collections.swap(list, i, i + 1); saveServers(list) }
}
suspend fun setServerEnabled(id: String, enabled: Boolean) = saveServers(_servers.value.map { if (it.id == id) it.copy(enabled = enabled) else it })
// ---- ntfy 服务器 CRUD ----
private suspend fun saveNtfyServers(list: List<NtfyServer>) {
edit { it[K.NTFY_SERVERS] = json.encodeToString(list) }
_ntfyServers.value = list
}
suspend fun addNtfy(s: NtfyServer) { saveNtfyServers(_ntfyServers.value + s) }
suspend fun updateNtfy(s: NtfyServer) = saveNtfyServers(_ntfyServers.value.map { if (it.id == s.id) s else it })
suspend fun deleteNtfy(id: String) = saveNtfyServers(_ntfyServers.value.filterNot { it.id == id })
suspend fun moveNtfy(id: String, up: Boolean) {
val list = _ntfyServers.value.toMutableList()
val i = list.indexOfFirst { it.id == id }
if (i in 1..list.lastIndex && up) { java.util.Collections.swap(list, i, i - 1); saveNtfyServers(list) }
else if (i in 0 until list.lastIndex && !up) { java.util.Collections.swap(list, i, i + 1); saveNtfyServers(list) }
}
suspend fun setNtfyEnabled(id: String, enabled: Boolean) = saveNtfyServers(_ntfyServers.value.map { if (it.id == id) it.copy(enabled = enabled) else it })
// ---- Meow 服务器 CRUD ----
private suspend fun saveMeowServers(list: List<MeowServer>) {
edit { it[K.MEOW_SERVERS] = json.encodeToString(list) }
_meowServers.value = list
}
suspend fun addMeow(s: MeowServer) { saveMeowServers(_meowServers.value + s) }
suspend fun updateMeow(s: MeowServer) = saveMeowServers(_meowServers.value.map { if (it.id == s.id) s else it })
suspend fun deleteMeow(id: String) = saveMeowServers(_meowServers.value.filterNot { it.id == id })
suspend fun moveMeow(id: String, up: Boolean) {
val list = _meowServers.value.toMutableList()
val i = list.indexOfFirst { it.id == id }
if (i in 1..list.lastIndex && up) { java.util.Collections.swap(list, i, i - 1); saveMeowServers(list) }
else if (i in 0 until list.lastIndex && !up) { java.util.Collections.swap(list, i, i + 1); saveMeowServers(list) }
}
suspend fun setMeowEnabled(id: String, enabled: Boolean) = saveMeowServers(_meowServers.value.map { if (it.id == id) it.copy(enabled = enabled) else it })
// ---- 电邮服务器 CRUD ----
private suspend fun saveEmailServers(list: List<EmailServer>) {
edit { it[K.EMAIL_SERVERS] = json.encodeToString(list) }
_emailServers.value = list
}
suspend fun addEmail(s: EmailServer) { saveEmailServers(_emailServers.value + s) }
suspend fun updateEmail(s: EmailServer) = saveEmailServers(_emailServers.value.map { if (it.id == s.id) s else it })
suspend fun deleteEmail(id: String) = saveEmailServers(_emailServers.value.filterNot { it.id == id })
suspend fun moveEmail(id: String, up: Boolean) {
val list = _emailServers.value.toMutableList()
val i = list.indexOfFirst { it.id == id }
if (i in 1..list.lastIndex && up) { java.util.Collections.swap(list, i, i - 1); saveEmailServers(list) }
else if (i in 0 until list.lastIndex && !up) { java.util.Collections.swap(list, i, i + 1); saveEmailServers(list) }
}
suspend fun setEmailEnabled(id: String, enabled: Boolean) = saveEmailServers(_emailServers.value.map { if (it.id == id) it.copy(enabled = enabled) else it })
// ---- 短信目标 CRUD ----
private suspend fun saveSmsTargets(list: List<SmsTarget>) {
edit { it[K.SMS_TARGETS] = json.encodeToString(list) }
_smsTargets.value = list
}
suspend fun addSmsTarget(s: SmsTarget) { saveSmsTargets(_smsTargets.value + s) }
suspend fun updateSmsTarget(s: SmsTarget) = saveSmsTargets(_smsTargets.value.map { if (it.id == s.id) s else it })
suspend fun deleteSmsTarget(id: String) = saveSmsTargets(_smsTargets.value.filterNot { it.id == id })
suspend fun moveSmsTarget(id: String, up: Boolean) {
val list = _smsTargets.value.toMutableList()
val i = list.indexOfFirst { it.id == id }
if (i in 1..list.lastIndex && up) { java.util.Collections.swap(list, i, i - 1); saveSmsTargets(list) }
else if (i in 0 until list.lastIndex && !up) { java.util.Collections.swap(list, i, i + 1); saveSmsTargets(list) }
}
suspend fun setSmsTargetEnabled(id: String, enabled: Boolean) = saveSmsTargets(_smsTargets.value.map { if (it.id == id) it.copy(enabled = enabled) else it })
suspend fun setIconPrefix(v: String) { edit { it[K.ICON_PREFIX] = v }; iconPrefix.value = v }
suspend fun setForwarding(v: Boolean) { edit { it[K.FWD] = v }; forwardingEnabled.value = v }
suspend fun setCode(v: Boolean) { edit { it[K.CODE] = v }; codeEnabled.value = v }
suspend fun setAdFilter(v: Boolean) { edit { it[K.AD] = v }; adFilterEnabled.value = v }
suspend fun setSpecial(v: Boolean) { edit { it[K.SPECIAL] = v }; specialEnabled.value = v }
suspend fun setSkipOngoing(v: Boolean) { edit { it[K.SKIP_ONGOING] = v }; skipOngoing.value = v }
suspend fun setAdKeywords(list: List<String>) {
edit { it[K.AD_KW] = json.encodeToString(list) }
adKeywords.value = list
}
suspend fun setBarkStopOnFirst(v: Boolean) { edit { it[K.BARK_STOP_FIRST] = v }; barkStopOnFirst.value = v }
suspend fun setNtfyStopOnFirst(v: Boolean) { edit { it[K.NTFY_STOP_FIRST] = v }; ntfyStopOnFirst.value = v }
suspend fun setMeowStopOnFirst(v: Boolean) { edit { it[K.MEOW_STOP_FIRST] = v }; meowStopOnFirst.value = v }
suspend fun setEmailStopOnFirst(v: Boolean) { edit { it[K.EMAIL_STOP_FIRST] = v }; emailStopOnFirst.value = v }
suspend fun setSmsStopOnFirst(v: Boolean) { edit { it[K.SMS_STOP_FIRST] = v }; smsStopOnFirst.value = v }
suspend fun setSmsFallback(v: Boolean) { edit { it[K.SMS_FALLBACK] = v }; smsFallbackEnabled.value = v }
suspend fun setSmsCarrier(v: String) { edit { it[K.SMS_CARRIER] = v }; smsCarrier.value = v }
suspend fun setSmsManualQuota(v: Int) { edit { it[K.SMS_QUOTA] = v }; smsManualQuota.value = v }
suspend fun setSmsBalance(v: Int) { edit { it[K.SMS_BALANCE] = v }; smsBalance.value = v }
suspend fun setSmsSuspended(v: Boolean) { edit { it[K.SMS_SUSPENDED] = v }; smsSuspended.value = v }
suspend fun setLastSmsSentTime(v: Long) { edit { it[K.SMS_LAST_SENT] = v }; lastSmsSentTime.value = v }
suspend fun setSmsFilterAll(v: Boolean) { edit { it[K.SMS_FILTER_ALL] = v }; smsFilterAll.value = v }
suspend fun setSmsRateLimitEnabled(v: Boolean) { edit { it[K.SMS_RATE_LIMIT_ENABLED] = v }; smsRateLimitEnabled.value = v }
suspend fun setSmsRateLimitMin(v: Int) { edit { it[K.SMS_RATE_LIMIT_MIN] = v }; smsRateLimitMin.value = v }
suspend fun setSmsBalanceCheck(v: Boolean) { edit { it[K.SMS_BALANCE_CHECK] = v }; smsBalanceCheck.value = v }
suspend fun setUpdateCheckEnabled(v: Boolean) { edit { it[K.UPDATE_CHECK_AUTO] = v }; updateCheckEnabled.value = v }
suspend fun setLastUpdateCheck(v: Long) { edit { it[K.LAST_UPDATE_CHECK] = v }; lastUpdateCheckTime.value = v }
suspend fun setIgnoredVersion(v: String) { edit { it[K.IGNORED_VERSION] = v }; ignoredVersion.value = v }
suspend fun setEmailForwarding(v: Boolean) { edit { it[K.EMAIL_ENABLED] = v }; emailForwardingEnabled.value = v }
suspend fun snapshot() = ds.data.first()
companion object {
const val ID_OFFICIAL = "official"
const val DEFAULT_ICON_PREFIX = "https://raw.githubusercontent.com/lsxf/gotmsg/main/icons/"
}
}
@@ -0,0 +1,290 @@
package com.a2i.forwarder.ui
import android.content.Context
import android.graphics.Bitmap
import android.graphics.Canvas
import android.graphics.drawable.BitmapDrawable
import android.graphics.drawable.Drawable
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.background
import androidx.compose.foundation.Image
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ChevronRight
import androidx.compose.material.icons.outlined.Circle
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Switch
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.ImageBitmap
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.core.app.NotificationManagerCompat
@Composable
fun ScreenTitle(
text: String,
subtitle: String? = null,
modifier: Modifier = Modifier,
) {
Column(modifier.padding(top = 18.dp, bottom = 14.dp)) {
Text(
text,
style = MaterialTheme.typography.headlineMedium,
color = MaterialTheme.colorScheme.onBackground,
)
if (!subtitle.isNullOrBlank()) {
Text(
subtitle,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(top = 4.dp),
)
}
}
}
@Composable
fun SectionCard(
title: String? = null,
modifier: Modifier = Modifier,
subtitle: String? = null,
titleIcon: ImageVector? = null,
titleIconTint: Color = MaterialTheme.colorScheme.primary,
headerAction: (@Composable () -> Unit)? = null,
content: @Composable ColumnScope.() -> Unit,
) {
Card(
modifier = modifier.fillMaxWidth(),
shape = RoundedCornerShape(8.dp),
border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant),
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface),
elevation = CardDefaults.cardElevation(defaultElevation = 0.dp),
) {
Column(Modifier.padding(16.dp)) {
if (title != null) {
if (headerAction == null) {
Row(verticalAlignment = Alignment.CenterVertically) {
if (titleIcon != null) {
Icon(titleIcon, null, tint = titleIconTint, modifier = Modifier.size(22.dp))
Spacer(Modifier.width(8.dp))
}
Text(
title,
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold,
modifier = Modifier.padding(bottom = if (subtitle == null) 10.dp else 2.dp),
)
}
if (subtitle != null) {
Text(
subtitle,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(bottom = 10.dp),
)
}
} else {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(bottom = 10.dp),
verticalAlignment = Alignment.Top,
) {
Column(Modifier.weight(1f)) {
Row(verticalAlignment = Alignment.CenterVertically) {
if (titleIcon != null) {
Icon(titleIcon, null, tint = titleIconTint, modifier = Modifier.size(22.dp))
Spacer(Modifier.width(8.dp))
}
Text(
title,
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold,
modifier = Modifier.padding(bottom = if (subtitle == null) 0.dp else 2.dp),
)
}
if (subtitle != null) {
Text(
subtitle,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
Spacer(Modifier.width(12.dp))
headerAction()
}
}
}
content()
}
}
}
@Composable
fun SwitchRow(
title: String,
subtitle: String? = null,
checked: Boolean,
icon: ImageVector? = null,
tint: Color = MaterialTheme.colorScheme.primary,
onChange: (Boolean) -> Unit,
) {
Row(
Modifier
.fillMaxWidth()
.heightIn(min = 56.dp)
.padding(vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
if (icon != null) {
IconBox(icon = icon, tint = tint)
Spacer(Modifier.width(12.dp))
}
Column(Modifier.weight(1f)) {
Text(title, style = MaterialTheme.typography.bodyLarge, fontWeight = FontWeight.Medium)
if (!subtitle.isNullOrBlank()) {
Text(
subtitle,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
Spacer(Modifier.width(12.dp))
Switch(checked = checked, onCheckedChange = onChange)
}
}
@Composable
fun ClickRow(
title: String,
subtitle: String? = null,
icon: ImageVector? = null,
tint: Color = MaterialTheme.colorScheme.primary,
onClick: () -> Unit,
) {
Row(
Modifier
.fillMaxWidth()
.clickable(onClick = onClick)
.heightIn(min = 56.dp)
.padding(vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
if (icon != null) {
IconBox(icon = icon, tint = tint)
Spacer(Modifier.width(12.dp))
}
Column(Modifier.weight(1f)) {
Text(title, style = MaterialTheme.typography.bodyLarge, fontWeight = FontWeight.Medium)
if (!subtitle.isNullOrBlank()) {
Text(
subtitle,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
Icon(Icons.Filled.ChevronRight, contentDescription = null, tint = MaterialTheme.colorScheme.onSurfaceVariant)
}
}
@Composable
fun IconBox(
icon: ImageVector = Icons.Outlined.Circle,
tint: Color = MaterialTheme.colorScheme.primary,
modifier: Modifier = Modifier,
) {
Box(
modifier = modifier
.size(38.dp)
.background(tint.copy(alpha = 0.12f), RoundedCornerShape(8.dp)),
contentAlignment = Alignment.Center,
) {
Icon(icon, contentDescription = null, tint = tint, modifier = Modifier.size(20.dp))
}
}
@Composable
fun StatusBadge(
text: String,
tint: Color,
modifier: Modifier = Modifier,
) {
Surface(
modifier = modifier,
shape = RoundedCornerShape(6.dp),
color = tint.copy(alpha = 0.12f),
contentColor = tint,
) {
Text(
text,
style = MaterialTheme.typography.labelMedium,
modifier = Modifier.padding(horizontal = 9.dp, vertical = 5.dp),
)
}
}
@Composable
fun MetricBlock(
label: String,
value: String,
tint: Color,
modifier: Modifier = Modifier,
) {
Surface(
modifier = modifier,
shape = RoundedCornerShape(8.dp),
color = tint.copy(alpha = 0.10f),
border = BorderStroke(1.dp, tint.copy(alpha = 0.18f)),
) {
Column(Modifier.padding(12.dp)) {
Text(value, style = MaterialTheme.typography.titleLarge, color = tint, fontWeight = FontWeight.Bold)
Text(label, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
}
}
}
fun isNotificationListenerEnabled(context: Context): Boolean {
return NotificationManagerCompat.getEnabledListenerPackages(context).contains(context.packageName)
}
/** Reads the app versionName and returns fallback if PackageManager lookup fails. */
fun appVersionName(context: Context, fallback: String = "unknown"): String {
return runCatching {
@Suppress("DEPRECATION")
context.packageManager.getPackageInfo(context.packageName, 0).versionName ?: fallback
}.getOrDefault(fallback)
}
fun drawableToImageBitmap(d: Drawable): ImageBitmap {
if (d is BitmapDrawable && d.bitmap != null) return d.bitmap.asImageBitmap()
val size = maxOf(d.intrinsicWidth, d.intrinsicHeight, 96).coerceAtLeast(1)
val bmp = Bitmap.createBitmap(size, size, Bitmap.Config.ARGB_8888)
val canvas = Canvas(bmp)
d.setBounds(0, 0, canvas.width, canvas.height)
d.draw(canvas)
return bmp.asImageBitmap()
}
@@ -0,0 +1,171 @@
package com.a2i.forwarder.ui.nav
import androidx.compose.animation.core.RepeatMode
import androidx.compose.animation.core.animateFloat
import androidx.compose.animation.core.infiniteRepeatable
import androidx.compose.animation.core.rememberInfiniteTransition
import androidx.compose.animation.core.tween
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.SystemUpdate
import androidx.compose.material.icons.outlined.Apps
import androidx.compose.material.icons.outlined.FilterAlt
import androidx.compose.material.icons.outlined.Notifications
import androidx.compose.material.icons.outlined.Settings
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.NavigationBar
import androidx.compose.material3.NavigationBarItem
import androidx.compose.material3.NavigationBarItemDefaults
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.unit.dp
import androidx.navigation.NavGraph.Companion.findStartDestination
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
import androidx.navigation.compose.currentBackStackEntryAsState
import androidx.navigation.compose.rememberNavController
import com.a2i.forwarder.A2iApp
import com.a2i.forwarder.ui.screens.AppManageScreen
import com.a2i.forwarder.ui.screens.FilterScreen
import com.a2i.forwarder.ui.screens.HomeScreen
import com.a2i.forwarder.ui.screens.LogScreen
import com.a2i.forwarder.ui.screens.SettingsScreen
import com.a2i.forwarder.ui.screens.UpdateDialog
private sealed class Tab(val route: String, val label: String, val icon: ImageVector) {
object Home : Tab("home", "首页", Icons.Outlined.Notifications)
object Apps : Tab("apps", "应用", Icons.Outlined.Apps)
object Filter : Tab("filter", "过滤", Icons.Outlined.FilterAlt)
object Settings : Tab("settings", "设置", Icons.Outlined.Settings)
}
private val tabs = listOf(Tab.Home, Tab.Apps, Tab.Filter, Tab.Settings)
private val tabRoutes = tabs.map { it.route }.toSet()
@Composable
fun AppRoot() {
val nav = rememberNavController()
val backStack by nav.currentBackStackEntryAsState()
val current = backStack?.destination?.route
val showBar = current in tabRoutes
val app = A2iApp.instance
val updateInfo by app.updateChecker.updateInfo.collectAsState()
var showUpdateDialog by remember { mutableStateOf(false) }
Box {
Scaffold(
containerColor = MaterialTheme.colorScheme.background,
bottomBar = {
if (showBar) {
NavigationBar(
containerColor = MaterialTheme.colorScheme.surface,
tonalElevation = 0.dp,
) {
tabs.forEach { t ->
NavigationBarItem(
selected = current == t.route,
onClick = {
nav.navigate(t.route) {
popUpTo(nav.graph.findStartDestination().id) { saveState = true }
launchSingleTop = true
restoreState = true
}
},
icon = { Icon(t.icon, t.label) },
label = { Text(t.label) },
colors = NavigationBarItemDefaults.colors(
selectedIconColor = MaterialTheme.colorScheme.primary,
selectedTextColor = MaterialTheme.colorScheme.primary,
indicatorColor = MaterialTheme.colorScheme.primaryContainer,
unselectedIconColor = MaterialTheme.colorScheme.onSurfaceVariant,
unselectedTextColor = MaterialTheme.colorScheme.onSurfaceVariant,
),
)
}
}
}
},
) { inner ->
NavHost(
navController = nav,
startDestination = Tab.Home.route,
modifier = Modifier.padding(inner),
) {
composable(Tab.Home.route) {
HomeScreen(
onOpenLog = { nav.navigate("log") },
onOpenSettings = { nav.navigate(Tab.Settings.route) },
)
}
composable(Tab.Apps.route) { AppManageScreen() }
composable(Tab.Filter.route) { FilterScreen() }
composable(Tab.Settings.route) { SettingsScreen(onOpenLog = { nav.navigate("log") }) }
composable("log") { LogScreen(onBack = { nav.popBackStack() }) }
}
}
// 浮动更新提醒小图标(跨所有页面,缓慢闪烁)
if (updateInfo != null) {
UpdateBadge(
modifier = Modifier.align(Alignment.TopEnd),
onClick = { showUpdateDialog = true },
)
}
}
if (showUpdateDialog && updateInfo != null) {
UpdateDialog(updateInfo!!, onDismiss = { showUpdateDialog = false })
}
}
/** 跨页面浮动更新提醒:右上角小圆点 + 缓慢闪烁动画。 */
@Composable
private fun UpdateBadge(modifier: Modifier = Modifier, onClick: () -> Unit) {
val transition = rememberInfiniteTransition(label = "updateBlink")
val pulseAlpha by transition.animateFloat(
initialValue = 0.35f,
targetValue = 1f,
animationSpec = infiniteRepeatable(
animation = tween(1200),
repeatMode = RepeatMode.Reverse,
),
label = "pulseAlpha",
)
Box(
modifier = modifier
.padding(top = 12.dp, end = 12.dp)
.size(40.dp)
.alpha(pulseAlpha)
.background(MaterialTheme.colorScheme.primary, CircleShape)
.clickable(onClick = onClick),
contentAlignment = Alignment.Center,
) {
Icon(
Icons.Filled.SystemUpdate,
contentDescription = "发现新版本,点击更新",
tint = Color.White,
modifier = Modifier.size(22.dp),
)
}
}
@@ -0,0 +1,222 @@
package com.a2i.forwarder.ui.screens
import android.content.Context
import android.content.pm.ApplicationInfo
import android.content.pm.PackageManager
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Search
import androidx.compose.material.icons.filled.Tune
import androidx.compose.material.icons.outlined.Apps
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.FilterChip
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Surface
import androidx.compose.material3.Switch
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.ImageBitmap
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import com.a2i.forwarder.A2iApp
import com.a2i.forwarder.store.RuleMode
import com.a2i.forwarder.ui.IconBox
import com.a2i.forwarder.ui.ScreenTitle
import com.a2i.forwarder.ui.SectionCard
import com.a2i.forwarder.ui.StatusBadge
import com.a2i.forwarder.ui.SwitchRow
import com.a2i.forwarder.ui.drawableToImageBitmap
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
private data class AppInfo(val pkg: String, val label: String, val icon: ImageBitmap?, val system: Boolean)
@OptIn(ExperimentalLayoutApi::class)
@Composable
fun AppManageScreen() {
val context = LocalContext.current
val app = A2iApp.instance
val mode by app.appRules.mode.collectAsState()
val blacklist by app.appRules.blacklist.collectAsState()
val whitelist by app.appRules.whitelist.collectAsState()
var showSystem by remember { mutableStateOf(false) }
var query by remember { mutableStateOf("") }
var apps by remember { mutableStateOf<List<AppInfo>>(emptyList()) }
var loading by remember { mutableStateOf(true) }
LaunchedEffect(Unit) {
loading = true
apps = loadApps(context)
loading = false
}
val visible = apps.filter {
(showSystem || !it.system) &&
(query.isBlank() || it.label.contains(query, true) || it.pkg.contains(query, true))
}
LazyColumn(
modifier = Modifier.fillMaxSize().padding(horizontal = 16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
item { ScreenTitle("应用转发", "按应用控制通知是否进入转发链路") }
item {
SectionCard("规则模式", subtitle = "黑名单适合默认转发;白名单适合只转发少数 App") {
FlowRow(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
FilterChip(
selected = mode == RuleMode.BLACKLIST,
onClick = { app.appScope.launch { app.appRules.setMode(RuleMode.BLACKLIST) } },
label = { Text("黑名单模式") },
leadingIcon = { Icon(Icons.Filled.Tune, null, modifier = Modifier.size(18.dp)) },
)
FilterChip(
selected = mode == RuleMode.WHITELIST,
onClick = { app.appScope.launch { app.appRules.setMode(RuleMode.WHITELIST) } },
label = { Text("白名单模式") },
leadingIcon = { Icon(Icons.Outlined.Apps, null, modifier = Modifier.size(18.dp)) },
)
}
SwitchRow(
title = "显示系统应用",
subtitle = if (showSystem) "列表包含系统服务和预装组件" else "当前只显示用户安装的应用",
checked = showSystem,
icon = Icons.Outlined.Apps,
tint = MaterialTheme.colorScheme.secondary,
) { showSystem = it }
}
}
item {
OutlinedTextField(
value = query,
onValueChange = { query = it },
label = { Text("搜索应用或包名") },
leadingIcon = { Icon(Icons.Filled.Search, null) },
modifier = Modifier.fillMaxWidth(),
singleLine = true,
)
}
item {
Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
Text(
if (loading) "正在读取应用列表" else "显示 ${visible.size} / ${apps.size} 个应用",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.weight(1f),
)
StatusBadge(
text = if (mode == RuleMode.BLACKLIST) "默认转发" else "仅白名单",
tint = MaterialTheme.colorScheme.primary,
)
}
}
if (loading) {
item {
Box(Modifier.fillMaxWidth().padding(32.dp), contentAlignment = Alignment.Center) {
CircularProgressIndicator()
}
}
} else {
items(visible, key = { it.pkg }) { ai ->
val on = when (mode) {
RuleMode.BLACKLIST -> ai.pkg !in blacklist
RuleMode.WHITELIST -> ai.pkg in whitelist
}
AppRow(ai, on) { v -> app.appScope.launch { app.appRules.setForwarding(ai.pkg, v) } }
}
}
}
}
@Composable
private fun AppRow(ai: AppInfo, on: Boolean, onChange: (Boolean) -> Unit) {
Surface(
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(8.dp),
color = MaterialTheme.colorScheme.surface,
border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant),
) {
Row(
Modifier.fillMaxWidth().padding(12.dp),
verticalAlignment = Alignment.CenterVertically,
) {
if (ai.icon != null) {
Image(bitmap = ai.icon, contentDescription = null, modifier = Modifier.size(42.dp))
} else {
IconBox(icon = Icons.Outlined.Apps, tint = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.size(42.dp))
}
Spacer(Modifier.width(12.dp))
Column(Modifier.weight(1f)) {
Row(verticalAlignment = Alignment.CenterVertically) {
Text(
ai.label,
style = MaterialTheme.typography.bodyLarge,
fontWeight = FontWeight.Medium,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f),
)
if (ai.system) {
Spacer(Modifier.width(8.dp))
StatusBadge("系统", MaterialTheme.colorScheme.onSurfaceVariant)
}
}
Text(
ai.pkg,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
Spacer(Modifier.width(12.dp))
Switch(checked = on, onCheckedChange = onChange)
}
}
}
private suspend fun loadApps(context: Context): List<AppInfo> = withContext(Dispatchers.IO) {
val pm = context.packageManager
pm.getInstalledApplications(PackageManager.GET_META_DATA)
.map {
AppInfo(
pkg = it.packageName,
label = pm.getApplicationLabel(it).toString(),
icon = runCatching { drawableToImageBitmap(pm.getApplicationIcon(it)) }.getOrNull(),
system = it.flags and ApplicationInfo.FLAG_SYSTEM != 0,
)
}
.sortedBy { it.label.lowercase() }
}
@@ -0,0 +1,179 @@
package com.a2i.forwarder.ui.screens
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.Notifications
import androidx.compose.material.icons.filled.Settings
import androidx.compose.material.icons.outlined.Apps
import androidx.compose.material.icons.outlined.FilterAlt
import androidx.compose.material3.AssistChip
import androidx.compose.material3.Button
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import com.a2i.forwarder.A2iApp
import com.a2i.forwarder.core.AdFilter
import com.a2i.forwarder.ui.ScreenTitle
import com.a2i.forwarder.ui.SectionCard
import com.a2i.forwarder.ui.SwitchRow
import kotlinx.coroutines.launch
@OptIn(ExperimentalLayoutApi::class)
@Composable
fun FilterScreen() {
val app = A2iApp.instance
val adOn by app.settings.adFilterEnabled.collectAsState()
val codeOn by app.settings.codeEnabled.collectAsState()
val specialOn by app.settings.specialEnabled.collectAsState()
val skipOngoing by app.settings.skipOngoing.collectAsState()
val customKw by app.settings.adKeywords.collectAsState()
var newKw by remember { mutableStateOf("") }
LazyColumn(
modifier = Modifier.fillMaxSize().padding(horizontal = 16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
item { ScreenTitle("过滤与优化", "减少噪音,保留真正需要转发的通知") }
item {
SectionCard("功能开关", subtitle = "规则按通知处理流水线顺序执行") {
SwitchRow(
title = "广告过滤",
subtitle = "命中营销关键词的通知将被丢弃",
checked = adOn,
icon = Icons.Outlined.FilterAlt,
tint = MaterialTheme.colorScheme.primary,
) { app.appScope.launch { app.settings.setAdFilter(it) } }
SwitchRow(
title = "验证码自动提取",
subtitle = "自动提取验证码,收到即复制并可点击复制",
checked = codeOn,
icon = Icons.Filled.Notifications,
tint = MaterialTheme.colorScheme.secondary,
) { app.appScope.launch { app.settings.setCode(it) } }
SwitchRow(
title = "微信 / QQ / Telegram 优化",
subtitle = "发送者作标题、按会话分组、重要通知提级",
checked = specialOn,
icon = Icons.Outlined.Apps,
tint = MaterialTheme.colorScheme.tertiary,
) { app.appScope.launch { app.settings.setSpecial(it) } }
SwitchRow(
title = "过滤持续性通知",
subtitle = "跳过音乐、下载、通话等 ongoing 通知",
checked = skipOngoing,
icon = Icons.Filled.Settings,
tint = MaterialTheme.colorScheme.onSurfaceVariant,
) { app.appScope.launch { app.settings.setSkipOngoing(it) } }
}
}
item {
SectionCard("内置广告关键词", subtitle = "系统预置规则不可编辑") {
FlowRow(horizontalArrangement = Arrangement.spacedBy(6.dp), verticalArrangement = Arrangement.spacedBy(6.dp)) {
AdFilter.DEFAULT_KEYWORDS.forEach { kw ->
AssistChip(onClick = {}, label = { Text(kw) })
}
}
}
}
item {
SectionCard("自定义关键词", subtitle = "命中任意关键词的通知会被广告过滤拦截") {
if (customKw.isEmpty()) {
EmptyKeywordState()
} else {
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
customKw.forEach { kw ->
KeywordRow(kw) {
app.appScope.launch { app.settings.setAdKeywords(customKw - kw) }
}
}
}
}
Spacer(Modifier.size(12.dp))
Row(verticalAlignment = Alignment.CenterVertically) {
OutlinedTextField(
value = newKw,
onValueChange = { newKw = it },
label = { Text("添加关键词") },
modifier = Modifier.weight(1f),
singleLine = true,
)
Spacer(Modifier.width(8.dp))
Button(onClick = {
val t = newKw.trim()
if (t.isNotEmpty() && t !in customKw) {
app.appScope.launch { app.settings.setAdKeywords(customKw + t) }
newKw = ""
}
}) {
Icon(Icons.Filled.Add, null, modifier = Modifier.size(18.dp))
Spacer(Modifier.width(6.dp))
Text("添加")
}
}
}
}
}
}
@Composable
private fun EmptyKeywordState() {
Surface(
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(8.dp),
color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.45f),
border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant),
) {
Text(
"暂无自定义关键词",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(12.dp),
)
}
}
@Composable
private fun KeywordRow(keyword: String, onDelete: () -> Unit) {
Surface(
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(8.dp),
color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.45f),
border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant),
) {
Row(Modifier.fillMaxWidth().padding(start = 12.dp, end = 4.dp, top = 4.dp, bottom = 4.dp), verticalAlignment = Alignment.CenterVertically) {
Text(keyword, Modifier.weight(1f), style = MaterialTheme.typography.bodyMedium, fontWeight = FontWeight.Medium)
IconButton(onClick = onDelete) { Icon(Icons.Filled.Close, "删除") }
}
}
}
@@ -0,0 +1,310 @@
package com.a2i.forwarder.ui.screens
import android.content.Intent
import android.provider.Settings
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.CheckCircle
import androidx.compose.material.icons.filled.History
import androidx.compose.material.icons.filled.Notifications
import androidx.compose.material.icons.filled.PowerSettingsNew
import androidx.compose.material.icons.filled.Settings
import androidx.compose.material.icons.filled.WarningAmber
import androidx.compose.material.icons.filled.SystemUpdate
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.FilledTonalButton
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver
import androidx.lifecycle.compose.LocalLifecycleOwner
import com.a2i.forwarder.A2iApp
import com.a2i.forwarder.ui.ClickRow
import com.a2i.forwarder.ui.IconBox
import com.a2i.forwarder.ui.MetricBlock
import com.a2i.forwarder.ui.SectionCard
import com.a2i.forwarder.ui.StatusBadge
import com.a2i.forwarder.ui.SwitchRow
import com.a2i.forwarder.ui.appVersionName
import com.a2i.forwarder.ui.isNotificationListenerEnabled
import kotlinx.coroutines.launch
@Composable
fun HomeScreen(onOpenLog: () -> Unit, onOpenSettings: () -> Unit) {
val context = LocalContext.current
val app = A2iApp.instance
val forwarding by app.settings.forwardingEnabled.collectAsState()
val servers by app.settings.servers.collectAsState()
val sent by app.logStore.sentCount.collectAsState()
val filtered by app.logStore.filteredCount.collectAsState()
val failed by app.logStore.failedCount.collectAsState()
val smsSuspended by app.settings.smsSuspended.collectAsState()
val update by app.updateChecker.updateInfo.collectAsState()
var showUpdateDialog by remember { mutableStateOf(false) }
var granted by remember { mutableStateOf(isNotificationListenerEnabled(context)) }
val lifecycleOwner = LocalLifecycleOwner.current
DisposableEffect(lifecycleOwner) {
val obs = LifecycleEventObserver { _, e ->
if (e == Lifecycle.Event.ON_RESUME) granted = isNotificationListenerEnabled(context)
}
lifecycleOwner.lifecycle.addObserver(obs)
onDispose { lifecycleOwner.lifecycle.removeObserver(obs) }
}
LazyColumn(
modifier = Modifier.fillMaxSize().padding(horizontal = 16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
item {
HomeHero(
granted = granted,
forwarding = forwarding,
version = appVersionName(context),
modifier = Modifier.padding(top = 16.dp),
)
}
val info = update
if (info != null) {
item {
SectionCard {
Box(modifier = Modifier
.fillMaxWidth()
.background(
brush = Brush.linearGradient(
colors = listOf(MaterialTheme.colorScheme.tertiary, MaterialTheme.colorScheme.primary),
),
shape = RoundedCornerShape(8.dp),
)
.padding(16.dp)
) {
Column {
Row(verticalAlignment = Alignment.CenterVertically) {
Box(
modifier = Modifier
.size(44.dp)
.background(Color.White.copy(alpha = 0.18f), RoundedCornerShape(8.dp)),
contentAlignment = Alignment.Center,
) {
Icon(Icons.Filled.SystemUpdate, null, tint = Color.White, modifier = Modifier.size(26.dp))
}
Spacer(Modifier.width(12.dp))
Column(Modifier.weight(1f)) {
Text("发现新版本", style = MaterialTheme.typography.titleLarge, color = Color.White, fontWeight = FontWeight.Bold)
Text(
"v${info.latestVersion} · ${formatSize(info.apkSize)} · 点击立即更新",
style = MaterialTheme.typography.bodyMedium,
color = Color.White.copy(alpha = 0.88f),
)
}
StatusBadge("🔄", tint = Color.White)
}
Spacer(Modifier.size(12.dp))
Button(
onClick = { showUpdateDialog = true },
modifier = Modifier.fillMaxWidth(),
colors = ButtonDefaults.buttonColors(
containerColor = Color.White,
contentColor = MaterialTheme.colorScheme.primary,
),
) { Text("🚀 立即更新到 v${info.latestVersion}", fontWeight = FontWeight.Bold) }
}
}
}
}
}
if (smsSuspended) {
item {
SectionCard {
Row(verticalAlignment = Alignment.CenterVertically) {
IconBox(
icon = Icons.Filled.WarningAmber,
tint = MaterialTheme.colorScheme.error,
)
Spacer(Modifier.width(12.dp))
Column(Modifier.weight(1f)) {
Text("短信兜底已停用", style = MaterialTheme.typography.titleMedium)
Text(
"短信余额不足,断网转发已关闭。请在「设置 → 断网短信兜底」补充额度",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
}
}
item {
SectionCard {
Row(verticalAlignment = Alignment.CenterVertically) {
IconBox(
icon = if (granted) Icons.Filled.CheckCircle else Icons.Filled.WarningAmber,
tint = if (granted) MaterialTheme.colorScheme.tertiary else MaterialTheme.colorScheme.error,
)
Spacer(Modifier.width(12.dp))
Column(Modifier.weight(1f)) {
Text(if (granted) "通知监听已开启" else "需要授权通知监听", style = MaterialTheme.typography.titleMedium)
Text(
if (granted) "系统通知将按规则处理并多通道转发" else "授权后才能读取 Android 通知并转发",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
StatusBadge(
text = if (granted) "已授权" else "未授权",
tint = if (granted) MaterialTheme.colorScheme.tertiary else MaterialTheme.colorScheme.error,
)
}
if (!granted) {
Spacer(Modifier.size(12.dp))
Button(
onClick = { context.startActivity(Intent(Settings.ACTION_NOTIFICATION_LISTENER_SETTINGS)) },
modifier = Modifier.fillMaxWidth(),
) { Text("前往系统授权") }
}
}
}
item {
SectionCard("运行状态", subtitle = "转发能力、目标服务器和日志入口") {
SwitchRow(
title = "通知转发",
subtitle = if (forwarding) "新通知会继续多通道转发" else "当前已暂停所有转发",
checked = forwarding,
icon = Icons.Filled.PowerSettingsNew,
tint = if (forwarding) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant,
) { v ->
app.appScope.launch { app.settings.setForwarding(v) }
}
val enabledServers = servers.filter { it.enabled && it.deviceKey.isNotBlank() }
ClickRow(
title = if (enabledServers.isEmpty()) "未启用 Bark 服务器" else "Bark 服务器(${enabledServers.size} 个启用)",
subtitle = enabledServers.firstOrNull()?.server ?: "进入设置勾选要转发的服务器",
icon = Icons.Filled.Settings,
tint = MaterialTheme.colorScheme.secondary,
onClick = onOpenSettings,
)
ClickRow(
title = "查看转发日志",
subtitle = "最近 300 条处理记录",
icon = Icons.Filled.History,
tint = MaterialTheme.colorScheme.primary,
onClick = onOpenLog,
)
}
}
item {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
MetricBlock("已转发", sent.toString(), MaterialTheme.colorScheme.primary, Modifier.weight(1f))
MetricBlock("已过滤", filtered.toString(), MaterialTheme.colorScheme.secondary, Modifier.weight(1f))
MetricBlock("失败", failed.toString(), MaterialTheme.colorScheme.error, Modifier.weight(1f))
}
}
item {
Text(
"v${appVersionName(context)}",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.fillMaxWidth().padding(top = 2.dp, bottom = 18.dp),
textAlign = TextAlign.Center,
)
}
}
if (showUpdateDialog) {
val info = update
if (info != null) UpdateDialog(info, onDismiss = { showUpdateDialog = false })
}
}
@Composable
private fun HomeHero(
granted: Boolean,
forwarding: Boolean,
version: String,
modifier: Modifier = Modifier,
) {
Box(
modifier = modifier
.fillMaxWidth()
.background(
brush = Brush.linearGradient(
colors = listOf(MaterialTheme.colorScheme.primary, MaterialTheme.colorScheme.secondary),
),
shape = RoundedCornerShape(12.dp),
)
.padding(18.dp),
) {
Column {
Row(verticalAlignment = Alignment.CenterVertically) {
Box(
modifier = Modifier
.size(44.dp)
.background(Color.White.copy(alpha = 0.18f), RoundedCornerShape(8.dp)),
contentAlignment = Alignment.Center,
) {
Icon(Icons.Filled.Notifications, null, tint = Color.White, modifier = Modifier.size(24.dp))
}
Spacer(Modifier.width(12.dp))
Column(Modifier.weight(1f)) {
Text("GotMsg", style = MaterialTheme.typography.displaySmall, color = Color.White)
Text("安卓通知多通道转发", style = MaterialTheme.typography.bodyMedium, color = Color.White.copy(alpha = 0.82f))
}
StatusBadge(
text = if (granted && forwarding) "运行中" else "待处理",
tint = Color.White,
)
}
Spacer(Modifier.size(14.dp))
Text(
if (granted && forwarding) "监听、过滤、提取验证码和推送链路已就绪。" else "完成授权并开启转发后,通知会按规则转发到各通道。",
style = MaterialTheme.typography.bodyMedium,
color = Color.White.copy(alpha = 0.86f),
)
Text(
"Build $version",
style = MaterialTheme.typography.bodySmall,
color = Color.White.copy(alpha = 0.68f),
modifier = Modifier.padding(top = 8.dp),
)
}
}
}
@@ -0,0 +1,193 @@
package com.a2i.forwarder.ui.screens
import android.content.Context
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.work.OneTimeWorkRequestBuilder
import androidx.work.WorkManager
import com.a2i.forwarder.A2iApp
import com.a2i.forwarder.core.ForwardLog
import com.a2i.forwarder.core.RetryWorker
import com.a2i.forwarder.ui.SectionCard
import com.a2i.forwarder.ui.StatusBadge
import kotlinx.coroutines.launch
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
@Composable
fun LogScreen(onBack: () -> Unit) {
val context = LocalContext.current
val app = A2iApp.instance
val logs by app.logStore.logs.collectAsState()
val pending by app.logStore.pending.collectAsState()
val df = remember { SimpleDateFormat("MM-dd HH:mm:ss", Locale.getDefault()) }
var showClearDialog by remember { mutableStateOf(false) }
Column(Modifier.fillMaxSize()) {
Surface(
color = MaterialTheme.colorScheme.surface,
border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant),
) {
Row(
Modifier.fillMaxWidth().padding(horizontal = 8.dp, vertical = 6.dp),
verticalAlignment = Alignment.CenterVertically,
) {
IconButton(onClick = onBack) { Icon(Icons.AutoMirrored.Filled.ArrowBack, "返回") }
Column(Modifier.weight(1f)) {
Text("转发日志", style = MaterialTheme.typography.titleLarge)
Text("最近 300 条通知处理记录", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
}
TextButton(onClick = { showClearDialog = true }) { Text("清空") }
}
}
LazyColumn(
modifier = Modifier.fillMaxSize().padding(horizontal = 16.dp, vertical = 12.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
if (pending.isNotEmpty()) {
item {
SectionCard("待重发(${pending.size}", subtitle = "发送失败的通知会在联网后自动重试") {
Text(
"也可以立即触发一次 WorkManager 重发任务。",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
TextButton(onClick = {
WorkManager.getInstance(context)
.enqueue(OneTimeWorkRequestBuilder<RetryWorker>().build())
}) { Text("立即全部重发") }
}
}
}
if (logs.isEmpty()) {
item {
Box(Modifier.fillMaxWidth().padding(32.dp), contentAlignment = Alignment.Center) {
Text("暂无记录", color = MaterialTheme.colorScheme.onSurfaceVariant)
}
}
}
items(logs) { log ->
LogItem(log, df)
}
}
}
if (showClearDialog) {
AlertDialog(
onDismissRequest = { showClearDialog = false },
title = { Text("清空日志") },
text = { Text("选择清空范围:") },
confirmButton = {
TextButton(onClick = {
showClearDialog = false
app.appScope.launch { app.logStore.clearLogs() }
}) { Text("仅清空日志") }
},
dismissButton = {
TextButton(onClick = {
showClearDialog = false
app.appScope.launch {
app.logStore.clearLogs()
app.logStore.resetCounters()
}
}) { Text("日志+计数器") }
},
)
}
}
@Composable
private fun LogItem(log: ForwardLog, df: SimpleDateFormat) {
val (tint, text) = when (log.status) {
"sent" -> MaterialTheme.colorScheme.tertiary to "已转发"
"filtered" -> MaterialTheme.colorScheme.secondary to "已过滤"
else -> MaterialTheme.colorScheme.error to "失败"
}
Surface(
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(8.dp),
color = MaterialTheme.colorScheme.surface,
border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant),
) {
Column(Modifier.padding(12.dp)) {
Row(verticalAlignment = Alignment.CenterVertically) {
StatusBadge(text, tint)
Spacer(Modifier.size(8.dp))
Text(
log.appLabel,
style = MaterialTheme.typography.bodyMedium,
fontWeight = FontWeight.Medium,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f),
)
Text(df.format(Date(log.time)), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
}
val title = log.title.orEmpty()
if (title.isNotBlank()) {
Text(
title,
style = MaterialTheme.typography.bodyMedium,
fontWeight = FontWeight.Medium,
modifier = Modifier.padding(top = 8.dp),
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
if (log.content.isNotBlank()) {
Text(
log.content,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.padding(top = if (title.isNotBlank()) 2.dp else 8.dp),
)
}
if (log.reason.isNotBlank()) {
Text(
"原因:${log.reason}",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.error,
modifier = Modifier.padding(top = 6.dp),
)
}
}
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,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)
}
@@ -0,0 +1,21 @@
package com.a2i.forwarder.ui.theme
import androidx.compose.ui.graphics.Color
val BrandBlue = Color(0xFF246BFD)
val BrandCyan = Color(0xFF00A9C8)
val BrandEmerald = Color(0xFF18A058)
val BrandAmber = Color(0xFFF59E0B)
val BrandRed = Color(0xFFE5484D)
val LightBackground = Color(0xFFF5F7FB)
val LightSurface = Color(0xFFFFFFFF)
val LightSurfaceMuted = Color(0xFFEAF0FA)
val LightText = Color(0xFF111827)
val LightTextMuted = Color(0xFF64748B)
val DarkBackground = Color(0xFF0B1220)
val DarkSurface = Color(0xFF111B2E)
val DarkSurfaceMuted = Color(0xFF1A2740)
val DarkText = Color(0xFFE7EDF7)
val DarkTextMuted = Color(0xFF9AA8BC)
@@ -0,0 +1,64 @@
package com.a2i.forwarder.ui.theme
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.darkColorScheme
import androidx.compose.material3.lightColorScheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.graphics.Color
private val DarkColors = darkColorScheme(
primary = Color(0xFF89B4FF),
onPrimary = DarkBackground,
primaryContainer = Color(0xFF163968),
onPrimaryContainer = DarkText,
secondary = Color(0xFF6FD3E4),
onSecondary = DarkBackground,
secondaryContainer = Color(0xFF123D47),
onSecondaryContainer = DarkText,
tertiary = Color(0xFF7BDFA8),
error = Color(0xFFFF9EA2),
background = DarkBackground,
onBackground = DarkText,
surface = DarkSurface,
onSurface = DarkText,
surfaceVariant = DarkSurfaceMuted,
onSurfaceVariant = DarkTextMuted,
outline = Color(0xFF425169),
outlineVariant = Color(0xFF29374D),
)
private val LightColors = lightColorScheme(
primary = BrandBlue,
onPrimary = LightSurface,
primaryContainer = Color(0xFFE1EAFF),
onPrimaryContainer = LightText,
secondary = BrandCyan,
onSecondary = LightSurface,
secondaryContainer = Color(0xFFDDF8FC),
onSecondaryContainer = LightText,
tertiary = BrandEmerald,
error = BrandRed,
background = LightBackground,
onBackground = LightText,
surface = LightSurface,
onSurface = LightText,
surfaceVariant = LightSurfaceMuted,
onSurfaceVariant = LightTextMuted,
outline = Color(0xFFCAD5E4),
outlineVariant = Color(0xFFE0E7F1),
)
@Composable
fun A2iTheme(
darkTheme: Boolean = isSystemInDarkTheme(),
dynamicColor: Boolean = false,
content: @Composable () -> Unit,
) {
val colorScheme = if (darkTheme) DarkColors else LightColors
MaterialTheme(
colorScheme = colorScheme,
typography = Typography,
content = content,
)
}
@@ -0,0 +1,73 @@
package com.a2i.forwarder.ui.theme
import androidx.compose.material3.Typography
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.sp
val Typography = Typography(
displaySmall = TextStyle(
fontFamily = FontFamily.Default,
fontWeight = FontWeight.Bold,
fontSize = 34.sp,
lineHeight = 40.sp,
letterSpacing = 0.sp,
),
headlineMedium = TextStyle(
fontFamily = FontFamily.Default,
fontWeight = FontWeight.Bold,
fontSize = 26.sp,
lineHeight = 32.sp,
letterSpacing = 0.sp,
),
titleLarge = TextStyle(
fontFamily = FontFamily.Default,
fontWeight = FontWeight.SemiBold,
fontSize = 22.sp,
lineHeight = 28.sp,
letterSpacing = 0.sp,
),
titleMedium = TextStyle(
fontFamily = FontFamily.Default,
fontWeight = FontWeight.SemiBold,
fontSize = 17.sp,
lineHeight = 24.sp,
letterSpacing = 0.sp,
),
bodyLarge = TextStyle(
fontFamily = FontFamily.Default,
fontWeight = FontWeight.Normal,
fontSize = 16.sp,
lineHeight = 24.sp,
letterSpacing = 0.sp,
),
bodyMedium = TextStyle(
fontFamily = FontFamily.Default,
fontWeight = FontWeight.Normal,
fontSize = 14.sp,
lineHeight = 20.sp,
letterSpacing = 0.sp,
),
bodySmall = TextStyle(
fontFamily = FontFamily.Default,
fontWeight = FontWeight.Normal,
fontSize = 12.sp,
lineHeight = 16.sp,
letterSpacing = 0.sp,
),
labelLarge = TextStyle(
fontFamily = FontFamily.Default,
fontWeight = FontWeight.SemiBold,
fontSize = 14.sp,
lineHeight = 20.sp,
letterSpacing = 0.sp,
),
labelMedium = TextStyle(
fontFamily = FontFamily.Default,
fontWeight = FontWeight.SemiBold,
fontSize = 12.sp,
lineHeight = 16.sp,
letterSpacing = 0.sp,
),
)
Binary file not shown.

After

Width:  |  Height:  |  Size: 7.1 KiB

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

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.5 KiB

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

After

Width:  |  Height:  |  Size: 4.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

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