10 Commits

Author SHA1 Message Date
song 76c316783a fix: blacklist call-status noise (telecom / AI-call) via bl v3
Incoming calls are already handled by PhoneCallMonitor (pkg=phone_call);
com.android.server.telecom and com.xiaomi.aiasst.service emit redundant
in-call status notices. Add them to default blacklist (bl_version 3),
migrated into existing users' blacklist (add-only).

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-08 10:17:28 +08:00
22 changed files with 601 additions and 25 deletions
+4
View File
@@ -8,6 +8,10 @@ app/build/
local.properties
.do-session
# Secrets — never commit
gh-token.txt
*.token
# Android Studio / IntelliJ
.idea/
*.iml
+26 -13
View File
@@ -55,31 +55,41 @@ adb install -r app/build/outputs/apk/release/app-release.apk
```
NotifyListenerService (系统通知入口)
→ NotificationProcessor.process(sbn) → Decision(message, reason, code)
BarkClient.push(msg) → OkHttp POST
→ LogStore.addLog(...)
失败 → 加入 pending → RetryWorker (WorkManager, 15min 周期)
→ NotificationProcessor.process(sbn) → Decision(message?, reason, code, silent)
├─ 在线:BarkClient.push(msg) → OkHttp POST;失败入 pending → RetryWorker (15min 周期)
└─ 离线:紧要通知(验证码/来电) → SmsFallbackForwarder 走短信兜底
→ LogStore.addLog(...)silent 的静默过滤不写日志)
旁路(不经通知监听器):
PhoneCallMonitor 轮询 getCallState() → 来电/未接构造 Decision 推送(绕过 MIUI 限制)
UpdateChecker 查 GitHub Releases → 新版本横幅 → 应用内下载 + 系统安装器
```
### 包结构 `com.a2i.forwarder`
| 文件 | 职责 |
|---|---|
| `A2iApp.kt` | `Application` 单例;持有全局 `appScope`三个 Store`settings``appRules``logStore`。Store 都用 `MutableStateFlow`UI 通过 `collectAsState()` 订阅。 |
| `A2iApp.kt` | `Application` 单例;持有全局 `appScope``SupervisorJob+Dispatchers.Default`)与全局组件:三个 Store`settings`/`appRules`/`logStore`+ 监听类(`phoneCallMonitor`/`connectivityMonitor`/`carrierBalanceQuery`/`smsForwarder`/`updateChecker`)。全部经 `A2iApp.instance.xxx` 访问,`onCreate` 构造并 `start()`UI 通过 `collectAsState()` 订阅 Store 的 `StateFlow`。 |
| `service/NotifyListenerService.kt` | `NotificationListenerService` 实现;每条通知调 `processor.process()`,过滤后推送。 |
| `core/NotificationProcessor.kt` | **核心决策点**。包含 LRU 去重缓存(5s)、脱敏检测(`*****` → 回退到 `tickerText`)、系统状态消息过滤、广告过滤、验证码提取、特殊 App 解析。返回 `Decision(message?, reason, appLabel, code)`。 |
| `core/CodeExtractor.kt` | 三段正则:关键词+数字、数字+关键词、纯数字 4-8 位。检测到全 `*****` 时直接返回 null。 |
| `core/AdFilter.kt` | 内置营销关键词 + 用户自定义关键词(`settings.adKeywords`)。 |
| `core/AppParsers.kt` | 微信/QQ/Telegram 的 `clickUrl` 映射 + 重要消息关键词(@我、验证码、通话等)。 |
| `core/BarkClient.kt` | OkHttp POST 到 `${server}/push`,超时 10/10/15s。 |
| `core/BarkClient.kt` | OkHttp POST 到 `${server}/push`,超时 10/10/15s;对 connection closed / 超时 / TLS 中断等瞬时 IOException 自动重试 3 次(退避 600/1200ms),HTTP 4xx/5xx 不重试。 |
| `core/RetryWorker.kt` | WorkManager 周期任务,每 15 分钟重发 `pending` 队列中的失败消息。 |
| `core/LogStore.kt` | 最近 300 条日志 + 失败重发队列(≤100)。DataStore 持久化。 |
| `core/IconExporter.kt` | 一键导出所有已安装 App 图标到用户选择的 SAF 目录(用于部署到 iOS Bark 图床)。 |
| `core/PhoneCallMonitor.kt` | 轮询 `TelephonyManager.getCallState()`(800ms)感知来电/未接来电,构造 Decision 推送。绕过 MIUI 不向第三方监听器分发系统电话通知的限制。 |
| `core/ConnectivityMonitor.kt` | `ConnectivityManager` + `NetworkCallback`,暴露 `isOnline: StateFlow<Boolean>`,供离线兜底判断。 |
| `core/CarrierDetector.kt` | SIM MCC+MNC 映射到运营商(移动/联通/电信/广电),供短信兜底发免费查询码。 |
| `core/CarrierBalanceQuery.kt` | 向运营商服务号发查询短信、解析回执余量、余额≤5 自动挂起。 |
| `core/SmsFallbackForwarder.kt` | 断网时把紧要通知(验证码/来电)用短信发到 iPhone,5 分钟限频 + 余额检查。 |
| `core/UpdateChecker.kt` | 查 GitHub Releases 最新版(24h 节流)→ 语义比较 → 下载 APK → FileProvider 调起系统安装器。 |
| `store/SettingsStore.kt` | Bark 服务器列表、全局开关、广告关键词等。`MutableStateFlow` + DataStore。 |
| `store/AppRulesStore.kt` | 黑/白名单模式 + 默认黑名单(系统 App + 三星剪贴板/键盘 + GMS)。 |
| `store/AppRulesStore.kt` | 黑/白名单模式 + 默认黑名单(系统 App + 输入法 + 三星剪贴板/键盘 + GMS + 录音机/相册/查找/密码管理 + 通话管理/AI通话);默认黑名单带版本号 `bl_version`,升级时把新增噪音包合并进老用户黑名单(只增不删)。 |
| `ui/nav/AppNav.kt` | 4 个底部 Tab + 日志页。 |
| `ui/theme/*` | 固定品牌配色(`Color.kt`)、自定义 `Typography``Type.kt`),`A2iTheme` 关闭动态取色、统一深浅色方案(`Theme.kt`)。 |
| `ui/screens/*.kt` | HomeScreen(品牌横幅 + 运行状态面板 + 统计指标)、AppManageScreen、FilterScreen、SettingsScreen、LogScreen。 |
| `ui/screens/*.kt` | HomeScreen(品牌横幅 + 运行状态 + 统计 + 更新横幅 + 短信挂起提醒)、AppManageScreen、FilterScreen、SettingsScreen(Bark 服务 / 应用图标 / 断网短信兜底 / 关于与更新)、LogScreen、UpdateDialog(应用内更新弹窗)。 |
| `ui/Components.kt` | 共享 UI 组件层:`ScreenTitle``SectionCard``SwitchRow``ClickRow``IconBox``StatusBadge``MetricBlock`,以及 `appVersionName(context)``drawableToImageBitmap(d)``isNotificationListenerEnabled(context)` 工具函数。 |
### 重要约定
@@ -87,9 +97,9 @@ NotifyListenerService (系统通知入口)
- **Store 全局单例**:所有 Store 通过 `A2iApp.instance.xxx` 访问。**不要**在 Compose 中创建新的 Store 实例。
- **协程作用域**:跨屏调用用 `app.appScope.launch { ... }`Application 级别的 `SupervisorJob + Dispatchers.Default`)。
- **过滤流水线**(顺序很重要,在 `NotificationProcessor.process` 中):
1. 全局开关 → 2. ongoing 过滤 → 3. App 黑/白名单 → 4. 空内容/脱敏 → 5. 噪音包名 → 6. 系统状态消息 → 7. 近空内容 → 8. 广告过滤 → **9. 去重检查(5s 窗口)** → 10. 验证码提取 → 11. 特殊 App 解析 → 12. 构造 BarkMessage
1. 全局开关 → 2. ongoing 过滤(静默)→ 3. App 黑/白名单 → 4. 空内容/脱敏 → 5. 噪音包名 → 6. 系统状态消息 → 7. 近空内容 → 8. 广告过滤 → 9. 已发短信(仅收不发)→ 10. 运营商余额回执拦截 → **11. 去重检查(5s 窗口)** → 12. 验证码提取 → 13. 特殊 App 解析 → 14. 构造 BarkMessage
- **Bark API** 字段映射在 `BarkMessage.kt``@SerialName`):`device_key`/`autoCopy`/`isArchive` 用下划线/驼峰别名,其他用蛇形。
- **版本号**`app/build.gradle.kts``versionCode`/`versionName`,运行时通过 `appVersionName(context)` 读取,首页底部居中显示 `v{version}`。改完代码记得同步递增
- **版本号**`app/build.gradle.kts``versionCode`/`versionName`,运行时通过 `appVersionName(context)` 读取,首页底部居中显示 `v{version}`**改完代码同步递增 versionCode/versionName,并更新本文档「版本与发布」一节的当前版本。**
### 通知监听授权
@@ -101,8 +111,11 @@ NotifyListenerService (系统通知入口)
## 版本与发布
- 当前版本:`versionCode = 3``versionName = "1.2.0"`(位于 `app/build.gradle.kts`)。改完代码记得同步递增。
- 当前版本:`versionCode = 9``versionName = "1.5.1"`(位于 `app/build.gradle.kts`)。**每次改动后同步递增 versionCode/versionName,并同步更新本节。**
- 远端仓库:`https://github.com/lsxf/a2i.git`,默认分支 `main`
- 提交策略:`.gitignore` 已排除 `app/build``.gradle``.kotlin``local.properties``.claude/``do.ps1`、截图与临时文件,**不要**提交它们。
- 发版流程:本地打 tag`git tag -a v1.2.0 -m "..."`)→ `git push origin v1.2.0` → 在 GitHub Releases 基于 tag 发布,并上传 `app-release.apk` 作为二进制附件。
- 提交策略:`.gitignore` 已排除 `app/build``.gradle``.kotlin``local.properties``.claude/``do.ps1``gh-token.txt`(发版用 token)、`*.token`截图与临时文件,**不要**提交它们。
- 发版流程:
1. 递增 `versionCode`/`versionName``./gradlew assembleRelease`(产物 `app/build/outputs/apk/release/app-release.apk`
2. `git tag -a v{versionName} -m "..."``git push origin v{versionName}`
3. 本机无 `gh` CLI:用 python(urllib) 调 GitHub API 创建 Release + 上传 APK。token 放仓库根 `gh-token.txt`(已 gitignore),脚本里 `open('gh-token.txt')` 读取,**勿**把 token 写进命令参数。注意 Git Bash 的 `/tmp` 与 Windows python 路径不一致,脚本内一律用项目相对路径。
- UI 现状:统一品牌科技感主题(固定品牌色 + 自定义 Typography + 共享组件层),深浅色双方案,关闭动态取色;新组件统一通过 `ui/Components.kt` 复用,避免各页面各拼一套样式。
+33
View File
@@ -23,6 +23,7 @@
- 余额 ≤5 条自动停用,下次打开 App 提醒
- 每 5 分钟最多发 1 条,避免刷屏
- 自动解析失败时回退手动额度计数
- **应用内更新**:每天自动检查 GitHub 新版本,发现新版后一键下载并通过系统安装器升级,无需手动去网页下载。
## 快速开始
@@ -70,6 +71,38 @@ Bark 的 `icon` 字段需要一个公网可访问的图片 URL。a2i 支持把
留空前缀则不显示图标。
## 自建 Bark 服务(可选)
a2i 默认用 Bark 官方服务器(`https://api.day.app`),免费够用。如果想完全自控、不限速、不依赖第三方,可以自建 Bark Server。下面是三种零成本方案:
### 方案 1:Vercel 云托管(最适合新手)
把 Bark 后端跑在 Vercel 的 Serverless 上,每次推送触发一个云函数,不挂进程、完全免费。
- 平台:[Vercel](https://vercel.com) · 代码:[GitHub](https://github.com)
- 在 GitHub 搜索 `bark-server-vercel`,项目 README 里通常有 **Deploy with Vercel** 按钮,一键克隆部署
### 方案 2Cloudflare Workers(最稳定)
把轻量 JS 转发代码部署到 Cloudflare 全球边缘节点,免费额度每天 10 万次请求,个人用属于「溢出」状态。
- 平台:[Cloudflare](https://www.cloudflare.com)
- 在 GitHub 搜索 `bark-server-cloudflare``bark cloudflare worker`,在 Cloudflare 后台新建 Worker 粘贴脚本保存即可
### 方案 3:免费 VPS + Docker(最高自主权)
在永久免费的 Linux 虚拟机上用 Docker 跑官方 bark-server。
- 官方服务端:[finab/bark-server](https://github.com/finab/bark-server)
- 反向代理 + 免费 HTTPS 证书:[Nginx Proxy Manager](https://nginxproxymanager.com)
- 免费 VPS[Oracle Cloud 永久免费层](https://www.oracle.com/cloud/free/)、[AWS 一年免费套餐](https://aws.amazon.com/free/)
### ⚠️ 防坑提示
国内网络下,Vercel 的 `*.vercel.app` 和 Cloudflare 的 `*.workers.dev` 二级域名可能间歇性无法访问。**终极方案**:注册一个便宜的个人域名(或找免费域名),解析托管到 Cloudflare,然后把 Vercel / Worker 绑到**自定义域名**上——既稳定又秒达。
自建完成后,在「设置 → Bark 服务器」里填自建地址即可。
## 技术栈
- Kotlin + Jetpack ComposeMaterial 3
+2 -2
View File
@@ -13,8 +13,8 @@ android {
applicationId = "com.a2i.forwarder"
minSdk = 34
targetSdk = 36
versionCode = 5
versionName = "1.4.0"
versionCode = 9
versionName = "1.5.1"
}
signingConfigs {
+11
View File
@@ -11,6 +11,7 @@
<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"
@@ -45,5 +46,15 @@
</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>
@@ -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.UpdateChecker
import com.a2i.forwarder.store.AppRulesStore
import com.a2i.forwarder.store.SettingsStore
import kotlinx.coroutines.CoroutineScope
@@ -30,6 +31,8 @@ class A2iApp : Application() {
private set
lateinit var smsForwarder: SmsFallbackForwarder
private set
lateinit var updateChecker: UpdateChecker
private set
override fun onCreate() {
super.onCreate()
@@ -43,6 +46,8 @@ class A2iApp : Application() {
connectivityMonitor.start()
carrierBalanceQuery = CarrierBalanceQuery(this)
smsForwarder = SmsFallbackForwarder(this)
updateChecker = UpdateChecker(this)
updateChecker.start()
}
companion object {
@@ -3,6 +3,7 @@ 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
@@ -10,6 +11,7 @@ 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) {
@@ -24,16 +26,37 @@ class BarkClient(private val server: BarkServer) {
suspend fun push(msg: BarkMessage): Result<Unit> = withContext(Dispatchers.IO) {
runCatching {
require(server.deviceKey.isNotBlank()) { "device key 为空" }
val body = json.encodeToString(msg).toRequestBody(JSON)
val url = server.server.trim().removeSuffix("/") + "/push"
val req = Request.Builder().url(url).post(body).build()
http.newCall(req).execute().use { res ->
if (!res.isSuccessful) error("HTTP ${res.code}")
doPushWithRetry(msg)
}
}
/**
* 带重试的推送:对 connection closed / 超时 / TLS 中断等瞬时 IOException 自动重试。
* OkHttp 的 retryOnConnectionFailure 不覆盖 POST 读响应阶段的连接中断,故在此手动补。
* HTTP 4xx/5xx 是服务端拒绝,重试无意义,直接抛出。
*/
private suspend fun doPushWithRetry(msg: BarkMessage) {
val body = json.encodeToString(msg).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)
}
}
@@ -16,6 +16,7 @@ class NotificationProcessor(private val context: Context) {
val reason: String,
val appLabel: String,
val code: String?,
val silent: Boolean = false, // true = 静默过滤,不写日志
)
// ========== 去重缓存 ==========
@@ -88,10 +89,10 @@ class NotificationProcessor(private val context: Context) {
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)
return Decision(null, "持续性通知", appLabel, null, silent = true)
// ---- App 黑白名单 ----
if (!rules.isForwardingOn(pkg))
@@ -0,0 +1,192 @@
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.flow.MutableStateFlow
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,
)
/**
* 应用内更新:拉取 GitHub 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) // 0..100
val lastError = MutableStateFlow<String?>(null)
fun start() {
scope.launch { check(force = false) }
}
suspend fun check(force: Boolean): Result<UpdateInfo?> = withContext(Dispatchers.IO) {
runCatching {
val settings = app.settings
val now = System.currentTimeMillis()
if (!force) {
// 自动检查被关闭或未满 24h:保持现状,不发请求
if (!settings.updateCheckEnabled.value) return@runCatching updateInfo.value
if (now - settings.lastUpdateCheckTime.value < CHECK_INTERVAL_MS) return@runCatching updateInfo.value
}
val req = Request.Builder()
.url(RELEASES_API)
.header("Accept", "application/vnd.github+json")
.header("User-Agent", "a2i-android")
.get()
.build()
val info = http.newCall(req).execute().use { res ->
if (!res.isSuccessful) error("HTTP ${res.code}")
val body = res.body?.string() ?: error("空响应")
parseRelease(body)
}
settings.setLastUpdateCheck(now)
val current = currentVersionName()
val available = info != null &&
isNewer(info.latestVersion, current) &&
info.latestVersion != settings.ignoredVersion.value
updateInfo.value = if (available) info else null
lastError.value = null
updateInfo.value
}.onFailure { lastError.value = it.message ?: "检查失败" }
}
private fun parseRelease(body: String): UpdateInfo? {
val json = JSONObject(body)
val tag = json.optString("tag_name").removePrefix("v").trim()
if (tag.isEmpty()) return null
val assets = json.optJSONArray("assets") ?: return null
var apkUrl = ""
var apkSize = 0L
for (i in 0 until assets.length()) {
val a = assets.optJSONObject(i) ?: continue
if (a.optString("name").endsWith(".apk", ignoreCase = true)) {
apkUrl = a.optString("browser_download_url")
apkSize = a.optLong("size")
break
}
}
if (apkUrl.isEmpty()) return null
return UpdateInfo(
latestVersion = tag,
apkUrl = apkUrl,
apkSize = apkSize,
releaseNotes = json.optString("body").orEmpty(),
releasePageUrl = json.optString("html_url").orEmpty(),
)
}
suspend fun download(info: UpdateInfo): File? = withContext(Dispatchers.IO) {
downloading.value = true
downloadProgress.value = 0
lastError.value = null
try {
val req = Request.Builder().url(info.apkUrl).get().build()
http.newCall(req).execute().use { res ->
if (!res.isSuccessful) { lastError.value = "HTTP ${res.code}"; return@use null }
val body = res.body ?: run { lastError.value = "空响应"; return@use null }
val total = body.contentLength()
val dir = context.getExternalFilesDir(null) ?: context.cacheDir
val target = File(dir, "a2i-${info.latestVersion}.apk")
target.outputStream().use { sink ->
val input = body.byteStream()
val buf = ByteArray(8 * 1024)
var read = 0L
while (true) {
val n = input.read(buf)
if (n <= 0) break
sink.write(buf, 0, n)
read += n
if (total > 0) downloadProgress.value = (read * 100 / total).toInt()
}
}
downloadProgress.value = 100
target
}
} catch (e: Exception) {
lastError.value = e.message ?: "下载失败"
null
} finally {
downloading.value = false
}
}
fun install(apk: File) {
// 国产 ROM 需用户先在系统设置开启「安装未知应用」
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")
}
/** 语义版本比较:按 "." 分段逐段比,段数不等时短的补 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 RELEASES_API = "https://api.github.com/repos/lsxf/a2i/releases/latest"
const val CHECK_INTERVAL_MS = 24L * 60 * 60 * 1000
}
}
@@ -40,7 +40,10 @@ class NotifyListenerService : NotificationListenerService() {
val msg = d.message
if (msg == null) {
app.logStore.addLog(ForwardLog(now, sbn.packageName, d.appLabel, "", "", "filtered", d.reason))
// 静默过滤(如持续性通知)不写日志
if (!d.silent) {
app.logStore.addLog(ForwardLog(now, sbn.packageName, d.appLabel, "", "", "filtered", d.reason))
}
return
}
@@ -4,6 +4,7 @@ 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
@@ -25,6 +26,7 @@ class AppRulesStore(
val MODE = stringPreferencesKey("mode")
val BLACK = stringSetPreferencesKey("blacklist")
val WHITE = stringSetPreferencesKey("whitelist")
val BL_VERSION = intPreferencesKey("bl_version")
}
val mode = MutableStateFlow(RuleMode.BLACKLIST)
@@ -35,7 +37,14 @@ class AppRulesStore(
scope.launch {
ds.data.collect { p ->
mode.value = runCatching { RuleMode.valueOf(p[K.MODE] ?: "BLACKLIST") }.getOrDefault(RuleMode.BLACKLIST)
blacklist.value = p[K.BLACK] ?: defaultBlacklist
// 默认黑名单版本迁移:把新增的噪音包补进老用户的黑名单(只增不删)
val ver = p[K.BL_VERSION] ?: 1
var black = p[K.BLACK] ?: defaultBlacklist
if (ver < DEFAULT_BL_VERSION) {
black = black + blAdded
ds.edit { it[K.BLACK] = black; it[K.BL_VERSION] = DEFAULT_BL_VERSION }
}
blacklist.value = black
whitelist.value = p[K.WHITE] ?: emptySet()
}
}
@@ -69,6 +78,20 @@ class AppRulesStore(
}
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",
@@ -91,6 +114,14 @@ class AppRulesStore(
"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通话
)
}
}
@@ -47,6 +47,10 @@ class SettingsStore(
val SMS_BALANCE = intPreferencesKey("sms_balance")
val SMS_SUSPENDED = booleanPreferencesKey("sms_suspended")
val SMS_LAST_SENT = longPreferencesKey("sms_last_sent")
// 应用更新
val UPDATE_CHECK_AUTO = booleanPreferencesKey("update_check_auto")
val LAST_UPDATE_CHECK = longPreferencesKey("last_update_check")
val IGNORED_VERSION = stringPreferencesKey("ignored_version")
}
private val defaultServers = listOf(
@@ -76,6 +80,11 @@ class SettingsStore(
val smsSuspended = MutableStateFlow(false) // 余额 ≤5 自动挂起
val lastSmsSentTime = MutableStateFlow(0L) // 限频时间戳
// 应用更新
val updateCheckEnabled = MutableStateFlow(true) // 自动检查更新开关
val lastUpdateCheckTime = MutableStateFlow(0L) // 上次检查时间戳
val ignoredVersion = MutableStateFlow("") // 用户忽略的版本号
init {
scope.launch {
ds.data.collect { p ->
@@ -102,6 +111,10 @@ class SettingsStore(
smsBalance.value = p[K.SMS_BALANCE] ?: -1
smsSuspended.value = p[K.SMS_SUSPENDED] ?: false
lastSmsSentTime.value = p[K.SMS_LAST_SENT] ?: 0L
// 应用更新
updateCheckEnabled.value = p[K.UPDATE_CHECK_AUTO] ?: true
lastUpdateCheckTime.value = p[K.LAST_UPDATE_CHECK] ?: 0L
ignoredVersion.value = p[K.IGNORED_VERSION] ?: ""
}
}
}
@@ -163,6 +176,10 @@ class SettingsStore(
suspend fun setSmsSuspended(v: Boolean) { edit { it[K.SMS_SUSPENDED] = v }; smsSuspended.value = v }
suspend fun setLastSmsSentTime(v: Long) { edit { it[K.SMS_LAST_SENT] = v }; lastSmsSentTime.value = v }
suspend fun setUpdateCheckEnabled(v: Boolean) { edit { it[K.UPDATE_CHECK_AUTO] = v }; updateCheckEnabled.value = v }
suspend fun setLastUpdateCheck(v: Long) { edit { it[K.LAST_UPDATE_CHECK] = v }; lastUpdateCheckTime.value = v }
suspend fun setIgnoredVersion(v: String) { edit { it[K.IGNORED_VERSION] = v }; ignoredVersion.value = v }
suspend fun snapshot() = ds.data.first()
companion object {
@@ -22,6 +22,7 @@ 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.FilledTonalButton
import androidx.compose.material3.Icon
@@ -66,6 +67,8 @@ fun HomeScreen(onOpenLog: () -> Unit, onOpenSettings: () -> Unit) {
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
@@ -90,6 +93,29 @@ fun HomeScreen(onOpenLog: () -> Unit, onOpenSettings: () -> Unit) {
)
}
val info = update
if (info != null) {
item {
SectionCard {
Row(verticalAlignment = Alignment.CenterVertically) {
IconBox(icon = Icons.Filled.SystemUpdate, tint = MaterialTheme.colorScheme.primary)
Spacer(Modifier.width(12.dp))
Column(Modifier.weight(1f)) {
Text("发现新版本 v${info.latestVersion}", style = MaterialTheme.typography.titleMedium)
Text(
"安装包 ${formatSize(info.apkSize)} · 点击立即更新",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
StatusBadge(text = "", tint = MaterialTheme.colorScheme.primary)
}
Spacer(Modifier.size(12.dp))
Button(onClick = { showUpdateDialog = true }, modifier = Modifier.fillMaxWidth()) { Text("立即更新") }
}
}
}
if (smsSuspended) {
item {
SectionCard {
@@ -200,6 +226,11 @@ fun HomeScreen(onOpenLog: () -> Unit, onOpenSettings: () -> Unit) {
)
}
}
if (showUpdateDialog) {
val info = update
if (info != null) UpdateDialog(info, onDismiss = { showUpdateDialog = false })
}
}
@Composable
@@ -16,6 +16,7 @@ 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
@@ -25,7 +26,9 @@ 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
@@ -51,6 +54,7 @@ fun LogScreen(onBack: () -> Unit) {
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(
@@ -66,7 +70,7 @@ fun LogScreen(onBack: () -> Unit) {
Text("转发日志", style = MaterialTheme.typography.titleLarge)
Text("最近 300 条通知处理记录", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
}
TextButton(onClick = { app.appScope.launch { app.logStore.clearLogs() } }) { Text("清空") }
TextButton(onClick = { showClearDialog = true }) { Text("清空") }
}
}
@@ -103,6 +107,29 @@ fun LogScreen(onBack: () -> Unit) {
}
}
}
if (showClearDialog) {
AlertDialog(
onDismissRequest = { showClearDialog = false },
title = { Text("清空日志") },
text = { Text("选择清空范围:") },
confirmButton = {
TextButton(onClick = {
showClearDialog = false
app.appScope.launch { app.logStore.clearLogs() }
}) { Text("仅清空日志") }
},
dismissButton = {
TextButton(onClick = {
showClearDialog = false
app.appScope.launch {
app.logStore.clearLogs()
app.logStore.resetCounters()
}
}) { Text("日志+计数器") }
},
)
}
}
@Composable
@@ -26,6 +26,8 @@ import androidx.compose.material.icons.filled.Edit
import androidx.compose.material.icons.filled.History
import androidx.compose.material.icons.filled.RestartAlt
import androidx.compose.material.icons.filled.Settings
import androidx.compose.material.icons.filled.SystemUpdateAlt
import androidx.compose.material.icons.filled.Update
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button
import androidx.compose.material3.CircularProgressIndicator
@@ -60,6 +62,7 @@ import com.a2i.forwarder.model.BarkServer
import com.a2i.forwarder.model.OFFICIAL_BARK_SERVER
import com.a2i.forwarder.ui.ClickRow
import com.a2i.forwarder.ui.ScreenTitle
import com.a2i.forwarder.ui.appVersionName
import com.a2i.forwarder.ui.SectionCard
import com.a2i.forwarder.ui.StatusBadge
import kotlinx.coroutines.launch
@@ -80,6 +83,12 @@ fun SettingsScreen(onOpenLog: () -> Unit) {
var showAdd by remember { mutableStateOf(false) }
var editing by remember { mutableStateOf<BarkServer?>(null) }
// 应用更新状态
val updateAuto by app.settings.updateCheckEnabled.collectAsState()
val updateInfo by app.updateChecker.updateInfo.collectAsState()
var checking by remember { mutableStateOf(false) }
var checkMsg by remember { mutableStateOf<String?>(null) }
// 短信兜底状态
val smsFallback by app.settings.smsFallbackEnabled.collectAsState()
val smsTarget by app.settings.smsTargetNumber.collectAsState()
@@ -283,6 +292,56 @@ fun SettingsScreen(onOpenLog: () -> Unit) {
}
}
item {
SectionCard("关于与更新", subtitle = "版本检查(数据来自 GitHub Releases") {
com.a2i.forwarder.ui.SwitchRow(
title = "自动检查更新",
subtitle = "启动时检查,且距上次满 24 小时再次检查",
checked = updateAuto,
icon = Icons.Filled.Update,
) { v -> app.appScope.launch { app.settings.setUpdateCheckEnabled(v) } }
ClickRow(
title = "检查更新",
subtitle = "当前 v${appVersionName(context)}" +
(updateInfo?.let { " · 已发现 v${it.latestVersion}" } ?: " · 点击立即检查"),
icon = Icons.Filled.SystemUpdateAlt,
onClick = {
app.appScope.launch {
checking = true
checkMsg = null
val r = app.updateChecker.check(force = true)
checking = false
checkMsg = when {
!r.isSuccess -> "检查失败:${r.exceptionOrNull()?.message}"
r.getOrNull() != null -> "发现新版本,请到首页更新"
else -> "已是最新版本"
}
}
},
)
if (checking) {
Row(
modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp),
verticalAlignment = Alignment.CenterVertically,
) {
CircularProgressIndicator(Modifier.size(16.dp), strokeWidth = 2.dp)
Spacer(Modifier.width(8.dp))
Text("正在检查…", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
}
}
checkMsg?.let {
Text(
it,
style = MaterialTheme.typography.bodySmall,
color = if (it.startsWith("已是")) MaterialTheme.colorScheme.tertiary else MaterialTheme.colorScheme.error,
modifier = Modifier.padding(start = 50.dp, top = 2.dp, bottom = 4.dp),
)
}
}
}
item {
SectionCard("维护", subtitle = "日志查看和本地统计清理") {
ClickRow("转发日志", "查看 / 清空最近记录", icon = Icons.Filled.History, onClick = onOpenLog)
@@ -0,0 +1,121 @@
package com.a2i.forwarder.ui.screens
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import com.a2i.forwarder.A2iApp
import com.a2i.forwarder.core.UpdateInfo
import kotlinx.coroutines.launch
import java.io.File
import java.util.Locale
@Composable
fun UpdateDialog(info: UpdateInfo, onDismiss: () -> Unit) {
val app = A2iApp.instance
val downloading by app.updateChecker.downloading.collectAsState()
val progress by app.updateChecker.downloadProgress.collectAsState()
var downloadedFile by remember { mutableStateOf<File?>(null) }
AlertDialog(
onDismissRequest = { if (!downloading) onDismiss() },
title = { Text("发现新版本 v${info.latestVersion}") },
text = {
Column {
Text(
"安装包大小:${formatSize(info.apkSize)}",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Spacer(Modifier.size(8.dp))
Column(
modifier = Modifier
.heightIn(max = 240.dp)
.verticalScroll(rememberScrollState()),
) {
val notes = info.releaseNotes.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(
"$progress%",
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)
}
+5
View File
@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<paths>
<external-files-path name="updates" path="." />
<cache-path name="cache" path="." />
</paths>
Binary file not shown.

After

Width:  |  Height:  |  Size: 4.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB