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>
This commit is contained in:
@@ -75,7 +75,7 @@ NotifyListenerService (系统通知入口)
|
|||||||
| `core/CodeExtractor.kt` | 三段正则:关键词+数字、数字+关键词、纯数字 4-8 位。检测到全 `*****` 时直接返回 null。 |
|
| `core/CodeExtractor.kt` | 三段正则:关键词+数字、数字+关键词、纯数字 4-8 位。检测到全 `*****` 时直接返回 null。 |
|
||||||
| `core/AdFilter.kt` | 内置营销关键词 + 用户自定义关键词(`settings.adKeywords`)。 |
|
| `core/AdFilter.kt` | 内置营销关键词 + 用户自定义关键词(`settings.adKeywords`)。 |
|
||||||
| `core/AppParsers.kt` | 微信/QQ/Telegram 的 `clickUrl` 映射 + 重要消息关键词(@我、验证码、通话等)。 |
|
| `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/RetryWorker.kt` | WorkManager 周期任务,每 15 分钟重发 `pending` 队列中的失败消息。 |
|
||||||
| `core/LogStore.kt` | 最近 300 条日志 + 失败重发队列(≤100)。DataStore 持久化。 |
|
| `core/LogStore.kt` | 最近 300 条日志 + 失败重发队列(≤100)。DataStore 持久化。 |
|
||||||
| `core/IconExporter.kt` | 一键导出所有已安装 App 图标到用户选择的 SAF 目录(用于部署到 iOS Bark 图床)。 |
|
| `core/IconExporter.kt` | 一键导出所有已安装 App 图标到用户选择的 SAF 目录(用于部署到 iOS Bark 图床)。 |
|
||||||
@@ -86,7 +86,7 @@ NotifyListenerService (系统通知入口)
|
|||||||
| `core/SmsFallbackForwarder.kt` | 断网时把紧要通知(验证码/来电)用短信发到 iPhone,5 分钟限频 + 余额检查。 |
|
| `core/SmsFallbackForwarder.kt` | 断网时把紧要通知(验证码/来电)用短信发到 iPhone,5 分钟限频 + 余额检查。 |
|
||||||
| `core/UpdateChecker.kt` | 查 GitHub Releases 最新版(24h 节流)→ 语义比较 → 下载 APK → FileProvider 调起系统安装器。 |
|
| `core/UpdateChecker.kt` | 查 GitHub Releases 最新版(24h 节流)→ 语义比较 → 下载 APK → FileProvider 调起系统安装器。 |
|
||||||
| `store/SettingsStore.kt` | Bark 服务器列表、全局开关、广告关键词等。`MutableStateFlow` + DataStore。 |
|
| `store/SettingsStore.kt` | Bark 服务器列表、全局开关、广告关键词等。`MutableStateFlow` + DataStore。 |
|
||||||
| `store/AppRulesStore.kt` | 黑/白名单模式 + 默认黑名单(系统 App + 三星剪贴板/键盘 + GMS)。 |
|
| `store/AppRulesStore.kt` | 黑/白名单模式 + 默认黑名单(系统 App + 输入法 + 三星剪贴板/键盘 + GMS + 录音机/相册/查找/密码管理);默认黑名单带版本号 `bl_version`,升级时把新增噪音包合并进老用户黑名单(只增不删)。 |
|
||||||
| `ui/nav/AppNav.kt` | 4 个底部 Tab + 日志页。 |
|
| `ui/nav/AppNav.kt` | 4 个底部 Tab + 日志页。 |
|
||||||
| `ui/theme/*` | 固定品牌配色(`Color.kt`)、自定义 `Typography`(`Type.kt`),`A2iTheme` 关闭动态取色、统一深浅色方案(`Theme.kt`)。 |
|
| `ui/theme/*` | 固定品牌配色(`Color.kt`)、自定义 `Typography`(`Type.kt`),`A2iTheme` 关闭动态取色、统一深浅色方案(`Theme.kt`)。 |
|
||||||
| `ui/screens/*.kt` | HomeScreen(品牌横幅 + 运行状态 + 统计 + 更新横幅 + 短信挂起提醒)、AppManageScreen、FilterScreen、SettingsScreen(Bark 服务 / 应用图标 / 断网短信兜底 / 关于与更新)、LogScreen、UpdateDialog(应用内更新弹窗)。 |
|
| `ui/screens/*.kt` | HomeScreen(品牌横幅 + 运行状态 + 统计 + 更新横幅 + 短信挂起提醒)、AppManageScreen、FilterScreen、SettingsScreen(Bark 服务 / 应用图标 / 断网短信兜底 / 关于与更新)、LogScreen、UpdateDialog(应用内更新弹窗)。 |
|
||||||
@@ -111,7 +111,7 @@ NotifyListenerService (系统通知入口)
|
|||||||
|
|
||||||
## 版本与发布
|
## 版本与发布
|
||||||
|
|
||||||
- 当前版本:`versionCode = 8`、`versionName = "1.5.0"`(位于 `app/build.gradle.kts`)。**每次改动后同步递增 versionCode/versionName,并同步更新本节。**
|
- 当前版本:`versionCode = 9`、`versionName = "1.5.1"`(位于 `app/build.gradle.kts`)。**每次改动后同步递增 versionCode/versionName,并同步更新本节。**
|
||||||
- 远端仓库:`https://github.com/lsxf/a2i.git`,默认分支 `main`。
|
- 远端仓库:`https://github.com/lsxf/a2i.git`,默认分支 `main`。
|
||||||
- 提交策略:`.gitignore` 已排除 `app/build`、`.gradle`、`.kotlin`、`local.properties`、`.claude/`、`do.ps1`、`gh-token.txt`(发版用 token)、`*.token`、截图与临时文件,**不要**提交它们。
|
- 提交策略:`.gitignore` 已排除 `app/build`、`.gradle`、`.kotlin`、`local.properties`、`.claude/`、`do.ps1`、`gh-token.txt`(发版用 token)、`*.token`、截图与临时文件,**不要**提交它们。
|
||||||
- 发版流程:
|
- 发版流程:
|
||||||
|
|||||||
@@ -13,8 +13,8 @@ android {
|
|||||||
applicationId = "com.a2i.forwarder"
|
applicationId = "com.a2i.forwarder"
|
||||||
minSdk = 34
|
minSdk = 34
|
||||||
targetSdk = 36
|
targetSdk = 36
|
||||||
versionCode = 8
|
versionCode = 9
|
||||||
versionName = "1.5.0"
|
versionName = "1.5.1"
|
||||||
}
|
}
|
||||||
|
|
||||||
signingConfigs {
|
signingConfigs {
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package com.a2i.forwarder.core
|
|||||||
import com.a2i.forwarder.model.BarkMessage
|
import com.a2i.forwarder.model.BarkMessage
|
||||||
import com.a2i.forwarder.model.BarkServer
|
import com.a2i.forwarder.model.BarkServer
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
import kotlinx.serialization.encodeToString
|
import kotlinx.serialization.encodeToString
|
||||||
import kotlinx.serialization.json.Json
|
import kotlinx.serialization.json.Json
|
||||||
@@ -10,6 +11,7 @@ import okhttp3.MediaType.Companion.toMediaType
|
|||||||
import okhttp3.OkHttpClient
|
import okhttp3.OkHttpClient
|
||||||
import okhttp3.Request
|
import okhttp3.Request
|
||||||
import okhttp3.RequestBody.Companion.toRequestBody
|
import okhttp3.RequestBody.Companion.toRequestBody
|
||||||
|
import java.io.IOException
|
||||||
import java.util.concurrent.TimeUnit
|
import java.util.concurrent.TimeUnit
|
||||||
|
|
||||||
class BarkClient(private val server: BarkServer) {
|
class BarkClient(private val server: BarkServer) {
|
||||||
@@ -24,16 +26,37 @@ class BarkClient(private val server: BarkServer) {
|
|||||||
suspend fun push(msg: BarkMessage): Result<Unit> = withContext(Dispatchers.IO) {
|
suspend fun push(msg: BarkMessage): Result<Unit> = withContext(Dispatchers.IO) {
|
||||||
runCatching {
|
runCatching {
|
||||||
require(server.deviceKey.isNotBlank()) { "device key 为空" }
|
require(server.deviceKey.isNotBlank()) { "device key 为空" }
|
||||||
val body = json.encodeToString(msg).toRequestBody(JSON)
|
doPushWithRetry(msg)
|
||||||
val url = server.server.trim().removeSuffix("/") + "/push"
|
}
|
||||||
val req = Request.Builder().url(url).post(body).build()
|
}
|
||||||
http.newCall(req).execute().use { res ->
|
|
||||||
if (!res.isSuccessful) error("HTTP ${res.code}")
|
/**
|
||||||
|
* 带重试的推送:对 connection closed / 超时 / TLS 中断等瞬时 IOException 自动重试。
|
||||||
|
* OkHttp 的 retryOnConnectionFailure 不覆盖 POST 读响应阶段的连接中断,故在此手动补。
|
||||||
|
* HTTP 4xx/5xx 是服务端拒绝,重试无意义,直接抛出。
|
||||||
|
*/
|
||||||
|
private suspend fun doPushWithRetry(msg: BarkMessage) {
|
||||||
|
val body = json.encodeToString(msg).toRequestBody(JSON)
|
||||||
|
val url = server.server.trim().removeSuffix("/") + "/push"
|
||||||
|
val req = Request.Builder().url(url).post(body).build()
|
||||||
|
var lastError: IOException? = null
|
||||||
|
repeat(MAX_ATTEMPTS) { attempt ->
|
||||||
|
try {
|
||||||
|
http.newCall(req).execute().use { res ->
|
||||||
|
if (res.isSuccessful) return
|
||||||
|
error("HTTP ${res.code}")
|
||||||
|
}
|
||||||
|
} catch (e: IOException) {
|
||||||
|
lastError = e
|
||||||
|
if (attempt < MAX_ATTEMPTS - 1) delay(BACKOFF_MS.getOrElse(attempt) { 1000L })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
throw lastError ?: error("推送失败")
|
||||||
}
|
}
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
private val JSON = "application/json; charset=utf-8".toMediaType()
|
private val JSON = "application/json; charset=utf-8".toMediaType()
|
||||||
|
private const val MAX_ATTEMPTS = 3
|
||||||
|
private val BACKOFF_MS = longArrayOf(600L, 1200L)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import android.content.Context
|
|||||||
import androidx.datastore.core.DataStore
|
import androidx.datastore.core.DataStore
|
||||||
import androidx.datastore.preferences.core.Preferences
|
import androidx.datastore.preferences.core.Preferences
|
||||||
import androidx.datastore.preferences.core.edit
|
import androidx.datastore.preferences.core.edit
|
||||||
|
import androidx.datastore.preferences.core.intPreferencesKey
|
||||||
import androidx.datastore.preferences.core.stringPreferencesKey
|
import androidx.datastore.preferences.core.stringPreferencesKey
|
||||||
import androidx.datastore.preferences.core.stringSetPreferencesKey
|
import androidx.datastore.preferences.core.stringSetPreferencesKey
|
||||||
import androidx.datastore.preferences.preferencesDataStore
|
import androidx.datastore.preferences.preferencesDataStore
|
||||||
@@ -25,6 +26,7 @@ class AppRulesStore(
|
|||||||
val MODE = stringPreferencesKey("mode")
|
val MODE = stringPreferencesKey("mode")
|
||||||
val BLACK = stringSetPreferencesKey("blacklist")
|
val BLACK = stringSetPreferencesKey("blacklist")
|
||||||
val WHITE = stringSetPreferencesKey("whitelist")
|
val WHITE = stringSetPreferencesKey("whitelist")
|
||||||
|
val BL_VERSION = intPreferencesKey("bl_version")
|
||||||
}
|
}
|
||||||
|
|
||||||
val mode = MutableStateFlow(RuleMode.BLACKLIST)
|
val mode = MutableStateFlow(RuleMode.BLACKLIST)
|
||||||
@@ -35,7 +37,14 @@ class AppRulesStore(
|
|||||||
scope.launch {
|
scope.launch {
|
||||||
ds.data.collect { p ->
|
ds.data.collect { p ->
|
||||||
mode.value = runCatching { RuleMode.valueOf(p[K.MODE] ?: "BLACKLIST") }.getOrDefault(RuleMode.BLACKLIST)
|
mode.value = runCatching { RuleMode.valueOf(p[K.MODE] ?: "BLACKLIST") }.getOrDefault(RuleMode.BLACKLIST)
|
||||||
blacklist.value = p[K.BLACK] ?: defaultBlacklist
|
// 默认黑名单版本迁移:把新增的噪音包补进老用户的黑名单(只增不删)
|
||||||
|
val ver = p[K.BL_VERSION] ?: 1
|
||||||
|
var black = p[K.BLACK] ?: defaultBlacklist
|
||||||
|
if (ver < DEFAULT_BL_VERSION) {
|
||||||
|
black = black + blAddedV2
|
||||||
|
ds.edit { it[K.BLACK] = black; it[K.BL_VERSION] = DEFAULT_BL_VERSION }
|
||||||
|
}
|
||||||
|
blacklist.value = black
|
||||||
whitelist.value = p[K.WHITE] ?: emptySet()
|
whitelist.value = p[K.WHITE] ?: emptySet()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -69,6 +78,16 @@ class AppRulesStore(
|
|||||||
}
|
}
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
|
private const val DEFAULT_BL_VERSION = 2
|
||||||
|
|
||||||
|
// 版本 2 新增的噪音包(用于老用户迁移,只补不删)
|
||||||
|
private val blAddedV2 = setOf(
|
||||||
|
"com.android.soundrecorder", // 录音机
|
||||||
|
"com.google.android.apps.photos", // 相册
|
||||||
|
"com.xiaomi.finddevice", // 查找手机
|
||||||
|
"com.miui.passwords", // 密码管理
|
||||||
|
)
|
||||||
|
|
||||||
val defaultBlacklist = setOf(
|
val defaultBlacklist = setOf(
|
||||||
// Android 系统
|
// Android 系统
|
||||||
"com.android.systemui",
|
"com.android.systemui",
|
||||||
@@ -91,6 +110,11 @@ class AppRulesStore(
|
|||||||
"com.google.android.gms.persistent",
|
"com.google.android.gms.persistent",
|
||||||
// a2i 自身
|
// a2i 自身
|
||||||
"com.a2i.forwarder",
|
"com.a2i.forwarder",
|
||||||
|
// [v2] 纯噪音 App:转发无意义,默认屏蔽
|
||||||
|
"com.android.soundrecorder", // 录音机
|
||||||
|
"com.google.android.apps.photos", // 相册
|
||||||
|
"com.xiaomi.finddevice", // 查找手机
|
||||||
|
"com.miui.passwords", // 密码管理
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user