8 Commits

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

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

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-10 21:44:37 +08:00
song 00bdbe2d1e chore: bump to 1.9.4 + Gitea URL -> gitea.songer.eu.org
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-10 20:47:54 +08:00
6 changed files with 332 additions and 119 deletions
+177 -89
View File
@@ -1,127 +1,209 @@
# GotMsg ·有消息
把安卓手机上的通知(短信、验证码、电话“有电话进来这个通知,不是电话呼叫转移”、微信、QQ、Telegram)实时转发到 iOS / Android / 鸿蒙等接收端。iOS 可通过 [Bark](https://github.com/Finb/Bark) 推送到 iPhone / iPadAndroid 可用 ntfy;鸿蒙可用 Meow。在 iOS 上点击通知,还能一键跳转到对应的 App。
把安卓手机上的通知(短信、验证码、电话、微信、QQ、Telegram)实时转发到 iOS / Android / 鸿蒙等接收端。
适用于把安卓备机的消息同步到 iPhone 或其他安卓 / 鸿蒙手机,不漏验证码、不漏重要聊天。
![GotMsg 设置页](screenshots/overview.png)
## 主要功能
---
- **系统级通知监听**:接入 Android 通知监听服务,覆盖所有发出通知的 App。
- **多通道推送**:支持 Bark、ntfy、Meow、电邮和短信兜底;每个通道可保存多个目标,勾选参与转发,并支持“发通一条就停”或全部广播。
- **验证码自动提取**:自动识别通知中的验证码,收到即复制,也可点击复制。
- **通知优化**
- 广告关键词过滤(内置 + 自定义)
- 持续性通知过滤(音乐、下载、通话等 ongoing)
- 内容脱敏与去重
- 微信 / QQ / Telegram 专项优化(发送者作标题、按会话分组、重要消息提级、跳转 iOS 对应 App)
- **应用级规则**:黑名单 / 白名单两种模式,可按 App 单独控制是否转发。
- **失败自动重试**:发送失败的通知进入待重发队列,由 WorkManager 定期重试,也可手动触发。
- **本地日志**:保留最近 300 条处理记录,方便排查为什么某条通知被过滤或发送失败。
- **电话来电通知**:通过轮询 `getCallState()` 实时感知来电和未接来电(绕过 MIUI 对系统电话通知的限制,需授予"电话"权限)。
- **断网短信兜底**:无网络时自动把验证码、来电用短信发到 iPhone。
- 自动识别运营商(移动/联通/电信/广电),发免费查询短信拿套餐内短信余量
- 余额 ≤5 条自动停用,下次打开 App 提醒
- 每 5 分钟最多发 1 条,避免刷屏
- 自动解析失败时回退手动额度计数
- **应用内更新**:每天自动检查 GitHub 新版本,发现新版后一键下载并通过系统安装器升级,无需手动去网页下载。
## 快速开始
## 一、五分钟上手
### 1. 安装
从 [Releases](../../releases) 下载最新 APK,安装到安卓手机(需 Android 14 / API 34+)。
从 [Releases](../../releases) 下载最新 APK,安装到安卓手机(需 Android 14 / API 34 及以上)。
### 2. 选一个推送通道
推荐三选一:
### 2. 配置 Bark
| 接收设备 | 推荐通道 | 难度 |
|---|---|---|
| iPhone / iPad | **Bark** | ⭐ 最简单 |
| 安卓手机 | **ntfy** | ⭐ 推荐开源 |
| 鸿蒙手机 | **Meow** | 鸿蒙专属 |
1. 在 iOS 上安装 [Bark](https://apps.apple.com/us/app/bark-customed-push/id1613210986)
2. 打开 Bark,复制你的专属推送地址,形如 `https://api.day.app/你的Key`
3. 在 GotMsg 的「设置」中添加服务器:
- 名称:随意,例如「我的 iPhone」
- 服务器地址:`https://api.day.app`(自建则填自建地址)
- Device KeyBark 地址中的那段 Key
如果接收端是 Android 或鸿蒙手机,也可以在「设置」里配置 ntfy 或 Meow;对应区块右上角有帮助按钮,按弹窗步骤填写服务器地址、Topic / Device Key 后点测试发送。
打开 GotMsg → 「设置」→ 选对应通道 → 点右上角 **i** 按钮 → 按弹窗步骤配 → 点该行「发送」测试
### 3. 授权通知监听
打开 GotMsg,首页会提示「需要授权通知监听」。点击「前往系统授权」,在系统的通知访问设置中开启 GotMsg。
> 授权是一次性的,卸载后重新安装需要再次授权。
首页会提示「需要授权通知监听」。点击「前往系统授权」,在系统的通知访问设置中开启 GotMsg。
### 4. 开启转发
首页打开「通知转发」开关。安卓收到通知后按配置自动推送到各通道。
回到首页,打开「通知转发」开关。此时安卓收到的通知就会按规则推送到 iOS。
---
## 使用建议
## 二、推送通道配置详解
- **只想收验证码**:进入「过滤」页,开启「验证码自动提取」,把「广告过滤」等按需开启;再到「应用」页切到白名单模式,只勾选短信类 App
每条配置都可独立勾选/删除/排序/测试。点「添加」时建议每条都先「发送」测试一次
### 2.1 BarkiOS / iPadOS
[Bark](https://github.com/Finb/Bark) 是 iOS 上最轻量的推送方案,复制地址即用,**推荐所有 iOS 用户首选**。
**配置步骤**
1. iPhone / iPad 上安装 Bark,首次打开允许通知权限。
2. 复制 Bark 首页的推送地址,形如 `https://api.day.app/你的Key`
3. GotMsg「设置 → Bark 服务器 → 添加」:
- **名称**:随意,例如「我的 iPhone」
- **服务器地址**`https://api.day.app`(自建则填自建地址)
- **Device Key**Bark 地址中 `/` 后面的那段
4. 保存后点「发送」测试;iPhone 收到「GotMsg 测试」即成功。
**为什么推荐 Bark**:iOS 上点击 Bark 通知可一键跳转到微信、QQ、Telegram 对应 AppiOS 14+ 通知体验最自然;Bark 服务器在中国大陆可直接访问。
### 2.2 ntfyAndroid / iOS / 网页)
[ntfy](https://ntfy.sh) 是开源的 HTTP pub-sub 通知服务,**支持 Android、iOS 和网页端**,无需注册项目。
**配置步骤**
1. 接收端安装 ntfy
- Android[Google Play](https://play.google.com/store/apps/details?id=io.heckel.ntfy) / [F-Droid](https://f-droid.org/en/packages/io.heckel.ntfy/) / [GitHub](https://github.com/binwiederhier/ntfy)
- iOS[App Store](https://apps.apple.com/us/app/ntfy/id1625396347)
2. 打开 ntfy → 添加订阅。服务器填 `https://ntfy.sh`(或自建地址);Topic 用一串**随机难猜的名字**(如 `gotmsg_a8f3d2`)。
3. GotMsg「设置 → ntfy 转发 → 添加」:
- **服务器**:与接收端完全一致
- **Topic**:与接收端完全一致
- **Token**:仅自建服务且启用了鉴权时填
4. 保存后点「发送」测试;接收端收到「GotMsg 测试」即成功。
> **安全提示**:公共 topic 谁知道名字就能订阅或发送。Topic 名**不要用**手机号、姓名、邮箱等可猜信息。
### 2.3 Meow(鸿蒙 / HarmonyOS
Meow 是鸿蒙手机的推送接收方案。GotMsg 按你填写的 Meow 接口地址和 Device Key 发出 HTTP 请求即可。
**配置步骤**
1. 鸿蒙手机上安装 Meow,确认 Meow 自身能正常接收推送。
2. 在 Meow 里找到**推送接口地址**和对应的 **Device Key / Token**
3. GotMsg「设置 → Meow 转发 → 添加」:
- **名称**:按鸿蒙设备填写(方便区分多台)
- **服务器地址**:Meow 给出的完整 API 地址
- **Device Key**Meow 给你的设备 Key(**不是**鸿蒙锁屏密码、华为账号密码)
4. 保存后点「发送」测试;鸿蒙手机收到「GotMsg 测试」即成功。
> 失败排查:检查 Meow 接口地址是否完整、Key 是否复制完整、鸿蒙手机是否允许 Meow 通知。
### 2.4 电邮转发
通过 SMTP 把通知发到邮箱(**适合无手机接收的场景**,如仅在桌面/平板查看)。
**配置步骤**
1. 准备一个 SMTP 邮箱(QQ/163/Gmail/Outlook 都可),**开启 SMTP 服务并生成授权码**(不是登录密码)。
- QQ:设置 → 账户 → POP3/IMAP/SMTP/Exchange/CardDAV/CalDAV服务 → 开启 SMTP → 生成授权码
- 163:设置 → POP3/SMTP/IMAP → 开启 SMTP → 设置授权码
- Gmail:账户 → 安全性 → 两步验证 → 应用专用密码
2. GotMsg「设置 → 电邮转发 → 添加」:
- **名称**:随意
- **服务器(SMTP 主机)**`smtp.qq.com` / `smtp.163.com` / `smtp.gmail.com`
- **端口**`465`(推荐,隐式 SSL)或 `587`STARTTLS
- **账号**:完整邮箱地址
- **授权码 / 密码**:上一步生成的授权码
- **发件人**:通常与账号相同
- **收件人**:接收通知的目标邮箱
3. 保存后点「发送」测试;收件人收到「GotMsg 测试」即成功。
> **国内邮箱推荐端口 465**QQ/163 均支持)。Gmail 需要应用专用密码而不是账户密码。
### 2.5 断网短信兜底
**当安卓手机没有网络时**,把重要通知(验证码、来电)通过 **SIM 卡短信** 发到另一台手机(通常是你的 iPhone)。需要安卓卡里还有短信套餐余量。
**配置步骤**
1. 在 GotMsg「设置 → 短信兜底」点「授予短信权限」(安卓系统权限)。
2. 点「添加」输入**目标手机号**(如 iPhone 号,接收端是另一台手机,**不是** GotMsg 本身)。
3. 选运营商:
- 选了「自动识别」:GotMsg 会发免费查询短信给运营商服务号,解析回执获取余量。
- 选了「手动额度」:在「手动短信额度」填入当前套餐短信条数,每次发短信后自动减 1。
4. 当余量 ≤ 5 时自动暂停,下次打开 App 提醒续费。
5. 频率限制:每 5 分钟最多发 1 条(避免刷屏),同一条通知 5 分钟内不重复。
> **适用场景**:安卓手机没 WiFi 时,验证码不会丢,漏不了重要登录。
---
## 三、自建推送服务(可选)
所有通道都支持自建,更好、更私密、可控。
### 3.1 自建 Bark 服务器
GotMsg 默认用 Bark 官方服务器(`https://api.day.app`),免费够用。自建适合追求完全控制。
**Docker 一键部署**(推荐):
```bash
docker run -d --name bark --restart=unless-stopped \
-p 8080:8080 \
-v /var/lib/bark:/data \
finab/bark-server
```
然后用 Nginx Proxy Manager 加 HTTPS 域名。详细三种方案对比请看 [Bark 官方文档](https://github.com/Finb/Bark)。
**GotMsg 配置**:服务器地址填 `https://你的域名.com`(不要 `/push` 后缀),Device Key 填你在自建 Bark 上创建的 Key。
### 3.2 自建 ntfy 服务器
[ntfy](https://ntfy.sh) 是为自建设计的,**比 Bark 还简单**——官方直接提供 Docker 一行启动:
```bash
docker run -d --name ntfy --restart=unless-stopped \
-p 80:80 \
-v /var/lib/ntfy:/var/lib/ntfy \
binwiederhier/ntfy serve
```
推荐加 Nginx 反代上 HTTPS 域名。完整文档:[docs.ntfy.sh](https://docs.ntfy.sh/install/)。
**GotMsg 配置**:服务器地址填 `https://ntfy.你的域名.com`Topic 照旧。
### 3.3 自建 Meow 服务器
Meow 是鸿蒙客户端,服务端一般用 Meow 自带的。GotMsg 端无需配置服务端,只需填 Meow 给你的接口地址 + Key。
---
## 四、使用建议
- **只想收验证码**:进入「过滤」页,开启「验证码自动提取」和「广告过滤」;再到「应用」页切到白名单模式,只勾选短信类 App。
- **想同步聊天**:保持黑名单模式(默认),在「过滤」页开启「微信 / QQ / Telegram 优化」。
- **通知没推过来**:先看「日志」页,每条通知(无论转发、过滤还是失败)都会记录原因。
- **想让通知带图标**参考下方「应用图标」一节。
- **电话通知不工作**MIUI 会把系统电话通知限制为不向第三方通知监听器分发,本 App 通过轮询 `getCallonyState()` 绕过此限制。需授予「电话」权限(设置 → 应用 → GotMsg → 权限 → 电话)。
- **断网收不到通知**:开启「设置 → 断网短信兜底」填好 iPhone 号码、授予短信权限、设好手动额度。断网时验证码和来电走短信(消耗套餐额度,5 分钟限 1 条)。
- **想让通知带图标**下方「应用图标」一节。
- **电话通知不工作**:MIUI 不向第三方监听器分发系统电话通知,GotMsg 通过轮询 `getCallState()` 绕过此限制。需授予「电话」权限(设置 → 应用 → GotMsg → 权限 → 电话)。
- **断网收不到通知**:开启「设置 → 短信兜底」填好 iPhone 号码、授予短信权限。断网时验证码和来电走短信(消耗套餐额度,5 分钟限 1 条)。
- **想全平台通知**:可同时启用多个通道(勾选每个通道的多个条目),所有启用的会同时收到。
## 应用图标
---
Bark 的 `icon` 字段需要一个公网可访问的图片 URL。GotMsg 支持把已安装 App 的图标批量导出到你选择的目录,方便上传到你自己的图床。
## 五、应用图标
Bark 和 ntfy 的通知 `icon` 字段需要公网可访问的图片 URL。GotMsg 支持把已安装 App 的图标批量导出,方便上传到你自己的图床。
规则:转发时 `icon = 图标 URL 前缀 + 安卓包名 + .png`。在「设置 → 应用图标」里:
1. 填写图标 URL 前缀,例如 `https://你的图床域名/icons/`
2. 选择一个目录,点「导出图标」,把所有 App 图标以 `包名.png` 形式导出。
2. 选择一个目录,点「导出图标」,把该目录下所有 App 图标以 `包名.png` 形式导出。
3. 把导出的图片上传到你的图床对应目录。
留空前缀则不显示图标。
## 自建 Bark 服务(可选)
---
GotMsg 默认用 Bark 官方服务器(`https://api.day.app`),免费够用。如果想完全自控、不限速、不依赖第三方,可以自建 Bark Server。下面是三种零成本方案:
### 方案 1:Vercel 云托管(最适合新手)
把 Bark 后端跑在 Vercel 的 Serverless 上,每次推送触发一个云函数,不挂进程、完全免费。
- 平台:[Vercel](https://vercel.com) · 代码:[GitHub](https://github.com)
- 在 GitHub 搜索 `bark-server-vercel`,项目 README 里通常有 **Deploy with Vercel** 按钮,一键克隆部署
### 方案 2Cloudflare Workers(最稳定)
把轻量 JS 转发代码部署到 Cloudflare 全球边缘节点,免费额度每天 10 万次请求,个人用属于「溢出」状态。
- 平台:[Cloudflare](https://www.cloudflare.com)
- 在 GitHub 搜索 `bark-server-cloudflare``bark cloudflare worker`,在 Cloudflare 后台新建 Worker 粘贴脚本保存即可
### 方案 3:免费 VPS + Docker(最高自主权)
在永久免费的 Linux 虚拟机上用 Docker 跑官方 bark-server。
- 官方服务端:[finab/bark-server](https://github.com/finab/bark-server)
- 反向代理 + 免费 HTTPS 证书:[Nginx Proxy Manager](https://nginxproxymanager.com)
- 免费 VPS[Oracle Cloud 永久免费层](https://www.oracle.com/cloud/free/)、[AWS 一年免费套餐](https://aws.amazon.com/free/)
### ⚠️ 防坑提示
国内网络下,Vercel 的 `*.vercel.app` 和 Cloudflare 的 `*.workers.dev` 二级域名可能间歇性无法访问。**终极方案**:注册一个便宜的个人域名(或找免费域名),解析托管到 Cloudflare,然后把 Vercel / Worker 绑到**自定义域名**上——既稳定又秒达。
自建完成后,在「设置 → Bark 服务器」里填自建地址即可。
## 技术栈
## 六、技术栈
- Kotlin + Jetpack ComposeMaterial 3
- AndroidX DataStore(持久化设置与日志)
- OkHttpBark / ntfy / Meow / 更新检查)
- OkHttpBark / ntfy / Meow / 电邮 / 更新检查)
- WorkManager(失败重试)
- Navigation Compose(单 Activity 多页面)
- 最低 Android 14API 34),目标 Android 16API 36
## 本地构建
---
## 七、构建与安装
需要 JDK 17 和 Android SDK。
```bash
# 设置环境变量(按你的安装路径)
# 设置环境变量
export JAVA_HOME=/path/to/jdk-17
export ANDROID_HOME=/path/to/android-sdk
@@ -132,18 +214,18 @@ export ANDROID_HOME=/path/to/android-sdk
./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
```
## 界面截图
---
## 八、界面截图
| 首页 | 应用管理 | 过滤设置 |
|---|---|---|
@@ -153,23 +235,29 @@ adb install -r app/build/outputs/apk/release/app-release.apk
|---|---|
| ![设置](screenshots/settings.png) | ![转发日志](screenshots/log.png) |
## 工作原理
---
## 九、工作原理
```
Android 系统通知
安卓系统通知
└─ NotifyListenerService 接收
└─ NotificationProcessor 处理流水线
└─ NotificationProcessor 流水线
├─ 全局开关 / ongoing 过滤 / 黑白名单
├─ 空内容、脱敏、噪音、系统状态过滤
├─ 广告关键词过滤
├─ 去重(5 秒窗口)
├─ 验证码提取
└─ 微信 / QQ / Telegram 专项解析
└─ BarkClient 推送到 Bark Server
└─ 按配置推送到 Bark / ntfy / Meow / 电邮 / 短信
└─ 失败 → 待重发队列 → RetryWorker 定期重试
└─ 写入本地日志(最近 300 条)
```
---
## License
本项目仅供个人使用。
All Rights Reserved. 保留所有权利。
+2 -2
View File
@@ -13,8 +13,8 @@ android {
applicationId = "com.a2i.forwarder"
minSdk = 34
targetSdk = 36
versionCode = 32
versionName = "1.9.3"
versionCode = 38
versionName = "1.9.9"
}
signingConfigs {
@@ -7,6 +7,7 @@ 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
@@ -33,6 +34,8 @@ class A2iApp : Application() {
private set
lateinit var updateChecker: UpdateChecker
private set
lateinit var telemetry: Telemetry
private set
override fun onCreate() {
super.onCreate()
@@ -48,6 +51,8 @@ class A2iApp : Application() {
smsForwarder = SmsFallbackForwarder(this)
updateChecker = UpdateChecker(this)
updateChecker.start()
telemetry = Telemetry(this, appScope)
telemetry.start()
}
companion object {
@@ -0,0 +1,140 @@
package com.a2i.forwarder.core
import android.content.Context
import android.net.ConnectivityManager
import android.net.NetworkCapabilities
import android.os.Build
import android.provider.Settings
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.RequestBody.Companion.toRequestBody
import org.json.JSONObject
import java.util.Locale
import java.util.TimeZone
import java.util.UUID
import java.util.concurrent.TimeUnit
class Telemetry(
private val context: Context,
private val scope: CoroutineScope,
) {
private val prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
private val http = OkHttpClient.Builder()
.connectTimeout(8, TimeUnit.SECONDS)
.writeTimeout(8, TimeUnit.SECONDS)
.readTimeout(10, TimeUnit.SECONDS)
.retryOnConnectionFailure(true)
.build()
private val sessionId = UUID.randomUUID().toString()
fun start() {
scope.launch(Dispatchers.IO) {
if (!prefs.getBoolean(KEY_INSTALL_SENT, false)) {
send("install").onSuccess {
prefs.edit().putBoolean(KEY_INSTALL_SENT, true).apply()
}
}
send("launch")
}
}
private suspend fun send(event: String): Result<Unit> = withContext(Dispatchers.IO) {
runCatching {
val payload = basePayload(event).toString().toRequestBody(JSON)
val request = Request.Builder()
.url(EVENTS_URL)
.header("User-Agent", userAgent())
.post(payload)
.build()
http.newCall(request).execute().use { response ->
if (!response.isSuccessful) error("HTTP ${response.code}")
}
}
}
private fun basePayload(event: String): JSONObject {
val version = appVersion()
val locale = Locale.getDefault()
return JSONObject()
.put("app_id", APP_ID)
.put("app_name", APP_NAME)
.put("event", event)
.put("install_id", installId())
.put("device_id", androidId())
.put("session_id", sessionId)
.put("platform", "android")
.put("package_name", context.packageName)
.put("app_version", version.name)
.put("app_build", version.code)
.put("os_version", "Android ${Build.VERSION.RELEASE} SDK ${Build.VERSION.SDK_INT}")
.put("manufacturer", Build.MANUFACTURER.orEmpty())
.put("model", Build.MODEL.orEmpty())
.put("locale", locale.toLanguageTag())
.put("timezone", TimeZone.getDefault().id)
.put("country", locale.country.orEmpty())
.put("network_type", networkType())
.put("user_agent", userAgent())
.put("source", "android-app")
}
private fun installId(): String {
prefs.getString(KEY_INSTALL_ID, null)?.let { return it }
val id = UUID.randomUUID().toString()
prefs.edit().putString(KEY_INSTALL_ID, id).apply()
return id
}
private fun androidId(): String =
Settings.Secure.getString(context.contentResolver, Settings.Secure.ANDROID_ID).orEmpty()
private fun appVersion(): AppVersion {
val info = context.packageManager.getPackageInfo(context.packageName, 0)
return AppVersion(
name = info.versionName ?: "0",
code = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
info.longVersionCode.toString()
} else {
@Suppress("DEPRECATION")
info.versionCode.toString()
},
)
}
private fun networkType(): String {
val manager = context.getSystemService(Context.CONNECTIVITY_SERVICE) as? ConnectivityManager ?: return "unknown"
val network = manager.activeNetwork ?: return "none"
val capabilities = manager.getNetworkCapabilities(network) ?: return "unknown"
return when {
capabilities.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) -> "wifi"
capabilities.hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR) -> "cellular"
capabilities.hasTransport(NetworkCapabilities.TRANSPORT_ETHERNET) -> "ethernet"
capabilities.hasTransport(NetworkCapabilities.TRANSPORT_VPN) -> "vpn"
else -> "other"
}
}
private fun userAgent(): String {
val version = appVersion()
return "GotMsg/${version.name} (${context.packageName}; Android ${Build.VERSION.SDK_INT}; ${Build.MANUFACTURER} ${Build.MODEL})"
}
private data class AppVersion(
val name: String,
val code: String,
)
companion object {
private const val APP_ID = "gotmsg"
private const val APP_NAME = "GotMsg"
private const val EVENTS_URL = "https://stats.songer.eu.org/v1/events"
private const val PREFS_NAME = "gotmsg_telemetry"
private const val KEY_INSTALL_ID = "install_id"
private const val KEY_INSTALL_SENT = "install_sent"
private val JSON = "application/json; charset=utf-8".toMediaType()
}
}
@@ -70,19 +70,15 @@ class UpdateChecker(private val context: Context) {
val now = System.currentTimeMillis()
if (throttle && now - settings.lastUpdateCheckTime.value < CHECK_INTERVAL_MS) return@runCatching updateInfo.value
// 先查 GitHub(公开默认源),从其 release notes 动态发现额外源(如自建 Gitea),取最高版本
// 双源查询:Gitea(国内优先)+ GitHub(备用),取最高版本
// 同版本时 Gitea 优先(国内可达,GitHub 可能被墙导致下载失败)
val infos = mutableListOf<UpdateInfo>()
var ghInfo: UpdateInfo? = null
var giteaInfo: UpdateInfo? = null
val gh = runCatching { fetchRelease(GITHUB_API) }.getOrNull()
if (gh != null) {
ghInfo = gh
giteaInfo = parseUpdateSource(gh.releaseNotes)?.let { runCatching { fetchRelease(it) }.getOrNull() }
// Gitea 放前面,同版本时优先用它下载(国内可达)
giteaInfo = runCatching { fetchRelease(GITEA_API) }.getOrNull()
if (giteaInfo != null) infos.add(giteaInfo)
infos.add(gh)
}
ghInfo = runCatching { fetchRelease(GITHUB_API) }.getOrNull()
if (ghInfo != null) infos.add(ghInfo)
settings.setLastUpdateCheck(now)
if (infos.isEmpty()) {
lastError.value = "所有更新源不可达"
@@ -303,26 +299,10 @@ class UpdateChecker(private val context: Context) {
return false
}
/** 从 release notes 的 HTML 注释里解析额外更新源(加扰编码,兼容 Base64/明文)。 */
private fun parseUpdateSource(body: String): String? {
val raw = Regex("""update-source:\s*(\S+)""").find(body)?.groupValues?.getOrNull(1) ?: return null
// 加扰格式:Base64(反转(XOR(明文, key)))。尝试完整解扰,失败则依次尝试 Base64/明文。
val b64 = runCatching { java.util.Base64.getDecoder().decode(raw) }.getOrNull() ?: return raw
val unscrambled = unscramble(b64)
return unscrambled ?: runCatching { String(b64, Charsets.UTF_8) }.getOrNull() ?: raw
}
/** 解扰:反转字节 → XOR 每字节 → UTF-8 字符串。 */
private fun unscramble(data: ByteArray): String? = runCatching {
val key = SCRAMBLE_KEY
val reversed = data.reversedArray()
String(ByteArray(reversed.size) { i -> ((reversed[i].toInt() and 0xFF) xor (key[i % key.size].toInt() and 0xFF)).toByte() }, Charsets.UTF_8)
}.getOrNull()
companion object {
const val GITHUB_API = "https://api.github.com/repos/lsxf/a2i/releases/latest"
const val 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
private val SCRAMBLE_KEY = byteArrayOf(0x4B, 0x6F, 0x72, 0x6E) // "Korn"
}
fun formatSize(bytes: Long): String {
@@ -354,6 +354,6 @@ class SettingsStore(
companion object {
const val ID_OFFICIAL = "official"
const val DEFAULT_ICON_PREFIX = "https://raw.githubusercontent.com/lsxf/a2i/main/icons/"
const val DEFAULT_ICON_PREFIX = "https://raw.githubusercontent.com/lsxf/gotmsg/main/icons/"
}
}