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>
This commit is contained in:
2026-07-09 14:51:48 +08:00
parent e7a7e1c2a5
commit 97cc6b8b11
12 changed files with 128 additions and 124 deletions
+6 -5
View File
@@ -56,13 +56,13 @@ adb install -r app/build/outputs/apk/release/app-release.apk
``` ```
NotifyListenerService (系统通知入口) NotifyListenerService (系统通知入口)
→ NotificationProcessor.process(sbn) → Decision(message?, reason, code, silent) → NotificationProcessor.process(sbn) → Decision(message?, reason, code, silent)
├─ 在线:BarkClient.push(msg) → OkHttp POST失败入 pending → RetryWorker (15min 周期) ├─ 在线:BarkSender.pushToFirst(enabledServers, msg) 按序依次试,首个成功止;全失败入 pending → RetryWorker (15min 周期)
└─ 离线:紧要通知(验证码/来电) → SmsFallbackForwarder 走短信兜底 └─ 离线:紧要通知(验证码/来电) → SmsFallbackForwarder 走短信兜底
→ LogStore.addLog(...)silent 的静默过滤不写日志) → LogStore.addLog(...)silent 的静默过滤不写日志)
旁路(不经通知监听器): 旁路(不经通知监听器):
PhoneCallMonitor 轮询 getCallState() → 来电/未接构造 Decision 推送(绕过 MIUI 限制) PhoneCallMonitor 轮询 getCallState() → 来电/未接构造 Decision 推送(绕过 MIUI 限制)
UpdateChecker 查 GitHub Releases → 新版本横幅 → 应用内下载 + 系统安装器 UpdateChecker 查 Gitea Releases → 新版本横幅 → 应用内下载 + 系统安装器
``` ```
### 包结构 `com.a2i.forwarder` ### 包结构 `com.a2i.forwarder`
@@ -75,7 +75,8 @@ 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;对 connection closed / 超时 / TLS 中断等瞬时 IOException 自动重试 3 次(退避 600/1200ms),HTTP 4xx/5xx 不重试。 | | `core/BarkClient.kt` | OkHttp POST 到 `${server}/push`,超时 10/10/15sbody 用 `msg.copy(deviceKey=server.deviceKey)`对 connection closed / 超时 / TLS 中断等瞬时 IOException 自动重试 3 次(退避 600/1200ms),HTTP 4xx/5xx 不重试。 |
| `core/BarkSender.kt` | 多服务器容错推送:按列表顺序依次 `BarkClient.push`,首个成功即止(`pushToFirst`);NotifyListenerService / PhoneCallMonitor / RetryWorker 共用。 |
| `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 图床)。 |
@@ -85,7 +86,7 @@ NotifyListenerService (系统通知入口)
| `core/CarrierBalanceQuery.kt` | 向运营商服务号发查询短信、解析回执余量、余额≤5 自动挂起。 | | `core/CarrierBalanceQuery.kt` | 向运营商服务号发查询短信、解析回执余量、余额≤5 自动挂起。 |
| `core/SmsFallbackForwarder.kt` | 断网时把紧要通知(验证码/来电)用短信发到 iPhone,5 分钟限频 + 余额检查。 | | `core/SmsFallbackForwarder.kt` | 断网时把紧要通知(验证码/来电)用短信发到 iPhone,5 分钟限频 + 余额检查。 |
| `core/UpdateChecker.kt` | 查 Gitea Releases 最新版(`git.sunlunfan.com`,国内可达,24h 节流)→ 语义比较 → 下载 APK → FileProvider 调起系统安装器。 | | `core/UpdateChecker.kt` | 查 Gitea Releases 最新版(`git.sunlunfan.com`,国内可达,24h 节流)→ 语义比较 → 下载 APK → FileProvider 调起系统安装器。 |
| `store/SettingsStore.kt` | Bark 服务器列表、全局开关、广告关键词、图标前缀等。`MutableStateFlow` + DataStore。图标前缀默认 github raw`DEFAULT_ICON_PREFIX`);国内不稳时可手填自建 Gitea `https://git.sunlunfan.com/song/a2i/raw/branch/main/icons/`。 | | `store/SettingsStore.kt` | Bark 服务器列表(有序,`BarkServer.enabled` 勾选参与转发)、全局开关、广告关键词、图标前缀等。`MutableStateFlow` + DataStore。`moveServer` 调序、`setServerEnabled` 启停;转发按序依次试(见 `BarkSender`)。图标前缀默认 github raw,国内不稳时可手填自建 Gitea。 |
| `store/AppRulesStore.kt` | 黑/白名单模式 + 默认黑名单(系统 App + 输入法 + 三星剪贴板/键盘 + GMS + 录音机/相册/查找/密码管理 + 通话管理/AI通话);默认黑名单带版本号 `bl_version`,升级时把新增噪音包合并进老用户黑名单(只增不删)。 | | `store/AppRulesStore.kt` | 黑/白名单模式 + 默认黑名单(系统 App + 输入法 + 三星剪贴板/键盘 + GMS + 录音机/相册/查找/密码管理 + 通话管理/AI通话);默认黑名单带版本号 `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`)。 |
@@ -111,7 +112,7 @@ NotifyListenerService (系统通知入口)
## 版本与发布 ## 版本与发布
- 当前版本:`versionCode = 9``versionName = "1.5.1"`(位于 `app/build.gradle.kts`)。**每次改动后同步递增 versionCode/versionName,并同步更新本节。** - 当前版本:`versionCode = 10``versionName = "1.6.0"`(位于 `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`、截图与临时文件,**不要**提交它们。
- 发版流程(github + gitea 双发;UpdateChecker 查 Gitea 源,故 Gitea 必发): - 发版流程(github + gitea 双发;UpdateChecker 查 Gitea 源,故 Gitea 必发):
+2 -2
View File
@@ -13,8 +13,8 @@ android {
applicationId = "com.a2i.forwarder" applicationId = "com.a2i.forwarder"
minSdk = 34 minSdk = 34
targetSdk = 36 targetSdk = 36
versionCode = 9 versionCode = 10
versionName = "1.5.1" versionName = "1.6.0"
} }
signingConfigs { signingConfigs {
@@ -36,7 +36,7 @@ class BarkClient(private val server: BarkServer) {
* HTTP 4xx/5xx 是服务端拒绝,重试无意义,直接抛出。 * HTTP 4xx/5xx 是服务端拒绝,重试无意义,直接抛出。
*/ */
private suspend fun doPushWithRetry(msg: BarkMessage) { private suspend fun doPushWithRetry(msg: BarkMessage) {
val body = json.encodeToString(msg).toRequestBody(JSON) val body = json.encodeToString(msg.copy(deviceKey = server.deviceKey)).toRequestBody(JSON)
val url = server.server.trim().removeSuffix("/") + "/push" val url = server.server.trim().removeSuffix("/") + "/push"
val req = Request.Builder().url(url).post(body).build() val req = Request.Builder().url(url).post(body).build()
var lastError: IOException? = null var lastError: IOException? = null
@@ -0,0 +1,25 @@
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 {
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("推送失败")
}
}
}
@@ -187,7 +187,7 @@ class NotificationProcessor(private val context: Context) {
val level = if (important) "timeSensitive" else "active" val level = if (important) "timeSensitive" else "active"
val msg = BarkMessage( val msg = BarkMessage(
deviceKey = settings.currentServer.value?.deviceKey.orEmpty(), deviceKey = "",
title = msgTitle.take(100), title = msgTitle.take(100),
body = content.ifBlank { title }.take(500), body = content.ifBlank { title }.take(500),
level = level, level = level,
@@ -70,8 +70,6 @@ class PhoneCallMonitor(private val context: Context) {
val app = A2iApp.instance val app = A2iApp.instance
val settings = app.settings val settings = app.settings
if (!settings.forwardingEnabled.value) return if (!settings.forwardingEnabled.value) return
val server = settings.currentServer.value ?: return
if (server.deviceKey.isBlank()) return
when { when {
// IDLE → RINGING:来电 // IDLE → RINGING:来电
@@ -82,14 +80,14 @@ class PhoneCallMonitor(private val context: Context) {
if (now - lastRingTime > 3000) { if (now - lastRingTime > 3000) {
lastRingTime = now lastRingTime = now
val msg = BarkMessage( val msg = BarkMessage(
deviceKey = server.deviceKey, deviceKey = "",
title = "来电", title = "来电",
body = "有电话进来", body = "有电话进来",
level = "timeSensitive", level = "timeSensitive",
group = "phone_call", group = "phone_call",
isArchive = "1", isArchive = "1",
) )
pushToServer(server, msg, app, "来电") pushToServers(msg, app, "来电")
} }
} }
@@ -102,14 +100,14 @@ class PhoneCallMonitor(private val context: Context) {
from == TelephonyManager.CALL_STATE_RINGING && to == TelephonyManager.CALL_STATE_IDLE -> { from == TelephonyManager.CALL_STATE_RINGING && to == TelephonyManager.CALL_STATE_IDLE -> {
if (!offhook && ringing) { if (!offhook && ringing) {
val msg = BarkMessage( val msg = BarkMessage(
deviceKey = server.deviceKey, deviceKey = "",
title = "未接来电", title = "未接来电",
body = "未接来电", body = "未接来电",
level = "timeSensitive", level = "timeSensitive",
group = "phone_call", group = "phone_call",
isArchive = "1", isArchive = "1",
) )
pushToServer(server, msg, app, "未接来电") pushToServers(msg, app, "未接来电")
} }
ringing = false ringing = false
offhook = false offhook = false
@@ -123,14 +121,11 @@ class PhoneCallMonitor(private val context: Context) {
} }
} }
private fun pushToServer( private fun pushToServers(msg: BarkMessage, app: A2iApp, logBody: String) {
server: com.a2i.forwarder.model.BarkServer,
msg: BarkMessage,
app: A2iApp,
logBody: String,
) {
scope.launch { scope.launch {
val result = BarkClient(server).push(msg) val servers = app.settings.servers.value.filter { it.enabled && it.deviceKey.isNotBlank() }
if (servers.isEmpty()) return@launch
val result = BarkSender.pushToFirst(servers, msg)
val now = System.currentTimeMillis() val now = System.currentTimeMillis()
if (result.isSuccess) { if (result.isSuccess) {
app.logStore.addLog( app.logStore.addLog(
@@ -142,7 +137,7 @@ class PhoneCallMonitor(private val context: Context) {
ForwardLog(now, "phone_call", "电话", msg.title, logBody, "failed", reason) ForwardLog(now, "phone_call", "电话", msg.title, logBody, "failed", reason)
) )
app.logStore.addPending( app.logStore.addPending(
PendingPush(UUID.randomUUID().toString(), now, server.id, json.encodeToString(msg)) PendingPush(UUID.randomUUID().toString(), now, servers.first().id, json.encodeToString(msg))
) )
} }
} }
@@ -24,13 +24,11 @@ class RetryWorker(ctx: Context, params: WorkerParameters) : CoroutineWorker(ctx,
var allOk = true var allOk = true
val json = Json { ignoreUnknownKeys = true } val json = Json { ignoreUnknownKeys = true }
val servers = settings.servers.value.filter { it.enabled && it.deviceKey.isNotBlank() }
for (p in list) { for (p in list) {
val server = settings.servers.value.firstOrNull { it.id == p.serverId } if (servers.isEmpty()) { allOk = false; break }
?: settings.currentServer.value
?: continue
if (server.deviceKey.isBlank()) { allOk = false; continue }
val msg = runCatching { json.decodeFromString<com.a2i.forwarder.model.BarkMessage>(p.messageJson) }.getOrNull() ?: continue val msg = runCatching { json.decodeFromString<com.a2i.forwarder.model.BarkMessage>(p.messageJson) }.getOrNull() ?: continue
val r = BarkClient(server).push(msg) val r = BarkSender.pushToFirst(servers, msg)
if (r.isSuccess) store.removePending(p.id) else allOk = false if (r.isSuccess) store.removePending(p.id) else allOk = false
} }
return if (allOk) Result.success() else Result.retry() return if (allOk) Result.success() else Result.retry()
@@ -9,6 +9,7 @@ data class BarkServer(
val name: String, val name: String,
val server: String, val server: String,
val deviceKey: String, val deviceKey: String,
val enabled: Boolean = true,
) )
const val OFFICIAL_BARK_SERVER = "https://api.day.app" const val OFFICIAL_BARK_SERVER = "https://api.day.app"
@@ -3,7 +3,7 @@ package com.a2i.forwarder.service
import android.service.notification.NotificationListenerService import android.service.notification.NotificationListenerService
import android.service.notification.StatusBarNotification import android.service.notification.StatusBarNotification
import com.a2i.forwarder.A2iApp import com.a2i.forwarder.A2iApp
import com.a2i.forwarder.core.BarkClient import com.a2i.forwarder.core.BarkSender
import com.a2i.forwarder.core.ForwardLog import com.a2i.forwarder.core.ForwardLog
import com.a2i.forwarder.core.NotificationProcessor import com.a2i.forwarder.core.NotificationProcessor
import com.a2i.forwarder.core.PendingPush import com.a2i.forwarder.core.PendingPush
@@ -58,20 +58,20 @@ class NotifyListenerService : NotificationListenerService() {
return return
} }
val server = app.settings.currentServer.value val servers = app.settings.servers.value.filter { it.enabled && it.deviceKey.isNotBlank() }
if (server == null || server.deviceKey.isBlank()) { if (servers.isEmpty()) {
app.logStore.addLog(ForwardLog(now, sbn.packageName, d.appLabel, msg.title, msg.body, "failed", "未配置 Bark key")) app.logStore.addLog(ForwardLog(now, sbn.packageName, d.appLabel, msg.title, msg.body, "failed", "未配置可用 Bark 服务器"))
return return
} }
val result = BarkClient(server).push(msg) val result = BarkSender.pushToFirst(servers, msg)
if (result.isSuccess) { if (result.isSuccess) {
app.logStore.addLog(ForwardLog(now, sbn.packageName, d.appLabel, msg.title, msg.body, "sent", "")) app.logStore.addLog(ForwardLog(now, sbn.packageName, d.appLabel, msg.title, msg.body, "sent", ""))
} else { } else {
val reason = result.exceptionOrNull()?.message ?: "错误" val reason = result.exceptionOrNull()?.message ?: "错误"
app.logStore.addLog(ForwardLog(now, sbn.packageName, d.appLabel, msg.title, msg.body, "failed", reason)) app.logStore.addLog(ForwardLog(now, sbn.packageName, d.appLabel, msg.title, msg.body, "failed", reason))
app.logStore.addPending( app.logStore.addPending(
PendingPush(UUID.randomUUID().toString(), now, server.id, json.encodeToString(msg)) PendingPush(UUID.randomUUID().toString(), now, servers.first().id, json.encodeToString(msg))
) )
} }
} }
@@ -31,7 +31,6 @@ class SettingsStore(
private object K { private object K {
val SERVERS = stringPreferencesKey("servers") val SERVERS = stringPreferencesKey("servers")
val CURRENT = stringPreferencesKey("current")
val ICON_PREFIX = stringPreferencesKey("icon_prefix") val ICON_PREFIX = stringPreferencesKey("icon_prefix")
val FWD = booleanPreferencesKey("forwarding") val FWD = booleanPreferencesKey("forwarding")
val CODE = booleanPreferencesKey("code") val CODE = booleanPreferencesKey("code")
@@ -60,9 +59,6 @@ class SettingsStore(
private val _servers = MutableStateFlow(defaultServers) private val _servers = MutableStateFlow(defaultServers)
val servers = _servers.asStateFlow() val servers = _servers.asStateFlow()
private val _currentServerId = MutableStateFlow(ID_OFFICIAL)
val currentServer = MutableStateFlow<BarkServer?>(defaultServers.first())
val iconPrefix = MutableStateFlow(DEFAULT_ICON_PREFIX) val iconPrefix = MutableStateFlow(DEFAULT_ICON_PREFIX)
val forwardingEnabled = MutableStateFlow(true) val forwardingEnabled = MutableStateFlow(true)
val codeEnabled = MutableStateFlow(true) val codeEnabled = MutableStateFlow(true)
@@ -92,8 +88,6 @@ class SettingsStore(
?.let { runCatching { json.decodeFromString<List<BarkServer>>(it) }.getOrNull() } ?.let { runCatching { json.decodeFromString<List<BarkServer>>(it) }.getOrNull() }
?: defaultServers ?: defaultServers
_servers.value = list _servers.value = list
_currentServerId.value = p[K.CURRENT] ?: ID_OFFICIAL
currentServer.value = list.firstOrNull { it.id == _currentServerId.value } ?: list.firstOrNull()
iconPrefix.value = p[K.ICON_PREFIX] ?: DEFAULT_ICON_PREFIX iconPrefix.value = p[K.ICON_PREFIX] ?: DEFAULT_ICON_PREFIX
forwardingEnabled.value = p[K.FWD] ?: true forwardingEnabled.value = p[K.FWD] ?: true
codeEnabled.value = p[K.CODE] ?: true codeEnabled.value = p[K.CODE] ?: true
@@ -125,15 +119,11 @@ class SettingsStore(
private suspend fun saveServers(list: List<BarkServer>) { private suspend fun saveServers(list: List<BarkServer>) {
edit { it[K.SERVERS] = json.encodeToString(list) } edit { it[K.SERVERS] = json.encodeToString(list) }
_servers.value = list _servers.value = list
refreshCurrent()
} }
suspend fun addServer(name: String, server: String, key: String): BarkServer { suspend fun addServer(name: String, server: String, key: String): BarkServer {
val s = BarkServer(name = name.trim(), server = server.trim().removeSuffix("/"), deviceKey = key.trim()) val s = BarkServer(name = name.trim(), server = server.trim().removeSuffix("/"), deviceKey = key.trim())
saveServers(_servers.value + s) saveServers(_servers.value + s)
if (_servers.value.count { it.id == s.id } == 1 && currentServer.value?.deviceKey.isNullOrBlank()) {
setCurrent(s.id)
}
return s return s
} }
@@ -142,19 +132,21 @@ class SettingsStore(
suspend fun deleteServer(id: String) { suspend fun deleteServer(id: String) {
val list = _servers.value.filterNot { it.id == id } val list = _servers.value.filterNot { it.id == id }
saveServers(list) saveServers(list)
if (_currentServerId.value == id) {
setCurrent(if (list.isEmpty()) "" else list.first().id)
}
} }
suspend fun setCurrent(id: String) { /** 上移/下移服务器(改变转发顺序) */
edit { it[K.CURRENT] = id } suspend fun moveServer(id: String, up: Boolean) {
_currentServerId.value = id val list = _servers.value.toMutableList()
refreshCurrent() val idx = list.indexOfFirst { it.id == id }
if (idx < 0) return
val target = if (up) idx - 1 else idx + 1
if (target < 0 || target >= list.size) return
java.util.Collections.swap(list, idx, target)
saveServers(list)
} }
private fun refreshCurrent() { suspend fun setServerEnabled(id: String, enabled: Boolean) {
currentServer.value = _servers.value.firstOrNull { it.id == _currentServerId.value } ?: _servers.value.firstOrNull() saveServers(_servers.value.map { if (it.id == id) it.copy(enabled = enabled) else it })
} }
suspend fun setIconPrefix(v: String) { edit { it[K.ICON_PREFIX] = v }; iconPrefix.value = v } suspend fun setIconPrefix(v: String) { edit { it[K.ICON_PREFIX] = v }; iconPrefix.value = v }
@@ -62,7 +62,7 @@ fun HomeScreen(onOpenLog: () -> Unit, onOpenSettings: () -> Unit) {
val context = LocalContext.current val context = LocalContext.current
val app = A2iApp.instance val app = A2iApp.instance
val forwarding by app.settings.forwardingEnabled.collectAsState() val forwarding by app.settings.forwardingEnabled.collectAsState()
val current by app.settings.currentServer.collectAsState() val servers by app.settings.servers.collectAsState()
val sent by app.logStore.sentCount.collectAsState() val sent by app.logStore.sentCount.collectAsState()
val filtered by app.logStore.filteredCount.collectAsState() val filtered by app.logStore.filteredCount.collectAsState()
val failed by app.logStore.failedCount.collectAsState() val failed by app.logStore.failedCount.collectAsState()
@@ -180,21 +180,14 @@ fun HomeScreen(onOpenLog: () -> Unit, onOpenSettings: () -> Unit) {
) { v -> ) { v ->
app.appScope.launch { app.settings.setForwarding(v) } app.appScope.launch { app.settings.setForwarding(v) }
} }
val enabledServers = servers.filter { it.enabled && it.deviceKey.isNotBlank() }
ClickRow( ClickRow(
title = current?.name?.ifBlank { "未命名服务器" } ?: "未配置 Bark 服务器", title = if (enabledServers.isEmpty()) "未启用 Bark 服务器" else "Bark 服务器${enabledServers.size} 个启用)",
subtitle = current?.server ?: "进入设置添加官方或自建 Bark 服务", subtitle = enabledServers.firstOrNull()?.server ?: "进入设置勾选要转发的服务",
icon = Icons.Filled.Settings, icon = Icons.Filled.Settings,
tint = MaterialTheme.colorScheme.secondary, tint = MaterialTheme.colorScheme.secondary,
onClick = onOpenSettings, onClick = onOpenSettings,
) )
if (current?.deviceKey.isNullOrBlank()) {
Text(
"当前服务器未填写 device key",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.error,
modifier = Modifier.padding(start = 50.dp),
)
}
ClickRow( ClickRow(
title = "查看转发日志", title = "查看转发日志",
subtitle = "最近 300 条处理记录", subtitle = "最近 300 条处理记录",
@@ -24,12 +24,15 @@ import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.Delete import androidx.compose.material.icons.filled.Delete
import androidx.compose.material.icons.filled.Edit import androidx.compose.material.icons.filled.Edit
import androidx.compose.material.icons.filled.History import androidx.compose.material.icons.filled.History
import androidx.compose.material.icons.filled.KeyboardArrowDown
import androidx.compose.material.icons.filled.KeyboardArrowUp
import androidx.compose.material.icons.filled.RestartAlt import androidx.compose.material.icons.filled.RestartAlt
import androidx.compose.material.icons.filled.Settings import androidx.compose.material.icons.filled.Settings
import androidx.compose.material.icons.filled.SystemUpdateAlt import androidx.compose.material.icons.filled.SystemUpdateAlt
import androidx.compose.material.icons.filled.Update import androidx.compose.material.icons.filled.Update
import androidx.compose.material3.AlertDialog import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button import androidx.compose.material3.Button
import androidx.compose.material3.Checkbox
import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.FilledTonalButton import androidx.compose.material3.FilledTonalButton
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
@@ -72,14 +75,11 @@ fun SettingsScreen(onOpenLog: () -> Unit) {
val context = LocalContext.current val context = LocalContext.current
val app = A2iApp.instance val app = A2iApp.instance
val servers by app.settings.servers.collectAsState() val servers by app.settings.servers.collectAsState()
val current by app.settings.currentServer.collectAsState()
val iconPrefix by app.settings.iconPrefix.collectAsState() val iconPrefix by app.settings.iconPrefix.collectAsState()
val prefs = remember { context.getSharedPreferences("a2i_ui", Context.MODE_PRIVATE) } val prefs = remember { context.getSharedPreferences("a2i_ui", Context.MODE_PRIVATE) }
var treeUri by remember { mutableStateOf<Uri?>(prefs.getString("icon_tree", null)?.let { runCatching { Uri.parse(it) }.getOrNull() }) } var treeUri by remember { mutableStateOf<Uri?>(prefs.getString("icon_tree", null)?.let { runCatching { Uri.parse(it) }.getOrNull() }) }
var exporting by remember { mutableStateOf(false) } var exporting by remember { mutableStateOf(false) }
var exportResult by remember { mutableStateOf<String?>(null) } var exportResult by remember { mutableStateOf<String?>(null) }
var testing by remember { mutableStateOf(false) }
var testMsg by remember { mutableStateOf<String?>(null) }
var showAdd by remember { mutableStateOf(false) } var showAdd by remember { mutableStateOf(false) }
var editing by remember { mutableStateOf<BarkServer?>(null) } var editing by remember { mutableStateOf<BarkServer?>(null) }
@@ -126,18 +126,21 @@ fun SettingsScreen(onOpenLog: () -> Unit) {
item { ScreenTitle("设置", "管理 Bark 服务、图标资源和维护操作") } item { ScreenTitle("设置", "管理 Bark 服务、图标资源和维护操作") }
item { item {
SectionCard("Bark 服务器", subtitle = "官方服务和自建服务可以并存,当前服务器用于所有推送") { SectionCard("Bark 服务器", subtitle = "勾选的服务器按列表顺序依次尝试转发,首个成功即止") {
if (servers.isEmpty()) { if (servers.isEmpty()) {
Text("暂无服务器,请先添加。", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant) Text("暂无服务器,请先添加。", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
} else { } else {
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
servers.forEach { s -> servers.forEachIndexed { index, s ->
ServerRow( ServerRow(
server = s, server = s,
selected = s.id == current?.id, isFirst = index == 0,
onSelect = { app.appScope.launch { app.settings.setCurrent(s.id) } }, isLast = index == servers.lastIndex,
onToggle = { v -> app.appScope.launch { app.settings.setServerEnabled(s.id, v) } },
onEdit = { editing = s }, onEdit = { editing = s },
onDelete = { app.appScope.launch { app.settings.deleteServer(s.id) } }, onDelete = { app.appScope.launch { app.settings.deleteServer(s.id) } },
onMoveUp = { app.appScope.launch { app.settings.moveServer(s.id, up = true) } },
onMoveDown = { app.appScope.launch { app.settings.moveServer(s.id, up = false) } },
) )
} }
} }
@@ -150,33 +153,6 @@ fun SettingsScreen(onOpenLog: () -> Unit) {
Spacer(Modifier.width(8.dp)) Spacer(Modifier.width(8.dp))
Text("添加服务器") Text("添加服务器")
} }
FilledTonalButton(
onClick = {
app.appScope.launch {
testing = true
testMsg = testPush(app)
testing = false
}
},
modifier = Modifier.fillMaxWidth().padding(top = 8.dp),
enabled = !testing,
) {
if (testing) {
CircularProgressIndicator(Modifier.size(18.dp), strokeWidth = 2.dp)
} else {
Icon(Icons.AutoMirrored.Filled.Send, null, modifier = Modifier.size(18.dp))
}
Spacer(Modifier.width(8.dp))
Text("发送测试推送")
}
testMsg?.let {
Text(
it,
style = MaterialTheme.typography.bodySmall,
color = if (it.startsWith("已发送")) MaterialTheme.colorScheme.tertiary else MaterialTheme.colorScheme.error,
modifier = Modifier.padding(top = 8.dp),
)
}
} }
} }
@@ -367,8 +343,7 @@ fun SettingsScreen(onOpenLog: () -> Unit) {
onDismiss = { showAdd = false }, onDismiss = { showAdd = false },
onConfirm = { n, s, k -> onConfirm = { n, s, k ->
app.appScope.launch { app.appScope.launch {
val ns = app.settings.addServer(n, s, k) app.settings.addServer(n, s, k)
app.settings.setCurrent(ns.id)
} }
showAdd = false showAdd = false
}, },
@@ -389,41 +364,66 @@ fun SettingsScreen(onOpenLog: () -> Unit) {
@Composable @Composable
private fun ServerRow( private fun ServerRow(
server: BarkServer, server: BarkServer,
selected: Boolean, isFirst: Boolean,
onSelect: () -> Unit, isLast: Boolean,
onToggle: (Boolean) -> Unit,
onEdit: () -> Unit, onEdit: () -> Unit,
onDelete: () -> Unit, onDelete: () -> Unit,
onMoveUp: () -> Unit,
onMoveDown: () -> Unit,
) { ) {
val app = A2iApp.instance
var testing by remember { mutableStateOf(false) }
var testMsg by remember { mutableStateOf<String?>(null) }
Surface( Surface(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(8.dp), shape = RoundedCornerShape(8.dp),
color = if (selected) MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.55f) else MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.36f), color = if (server.enabled) MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.40f) else MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.36f),
border = BorderStroke(1.dp, if (selected) MaterialTheme.colorScheme.primary.copy(alpha = 0.32f) else MaterialTheme.colorScheme.outlineVariant), border = BorderStroke(1.dp, if (server.enabled) MaterialTheme.colorScheme.primary.copy(alpha = 0.30f) else MaterialTheme.colorScheme.outlineVariant),
) { ) {
Row(Modifier.fillMaxWidth().padding(8.dp), verticalAlignment = Alignment.CenterVertically) { Column(Modifier.padding(8.dp)) {
RadioButton(selected = selected, onClick = onSelect) Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
Column(Modifier.weight(1f)) { Checkbox(checked = server.enabled, onCheckedChange = onToggle)
Row(verticalAlignment = Alignment.CenterVertically) { Column(Modifier.weight(1f)) {
Row(verticalAlignment = Alignment.CenterVertically) {
Text(
server.name.ifBlank { "未命名" },
style = MaterialTheme.typography.bodyLarge,
fontWeight = FontWeight.Medium,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f),
)
if (!server.enabled) StatusBadge("停用", MaterialTheme.colorScheme.onSurfaceVariant)
}
Text( Text(
server.name.ifBlank { "未命名" }, "${server.server} · key: ${if (server.deviceKey.isBlank()) "未填" else "已填"}",
style = MaterialTheme.typography.bodyLarge, style = MaterialTheme.typography.bodySmall,
fontWeight = FontWeight.Medium, color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1, maxLines = 1,
overflow = TextOverflow.Ellipsis, overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f),
) )
if (selected) StatusBadge("当前", MaterialTheme.colorScheme.primary)
} }
IconButton(
onClick = { if (!testing) app.appScope.launch { testing = true; testMsg = testPush(app, server); testing = false } },
enabled = !testing,
) {
if (testing) CircularProgressIndicator(Modifier.size(18.dp), strokeWidth = 2.dp)
else Icon(Icons.AutoMirrored.Filled.Send, "测试")
}
IconButton(onClick = onMoveUp, enabled = !isFirst) { Icon(Icons.Filled.KeyboardArrowUp, "上移") }
IconButton(onClick = onMoveDown, enabled = !isLast) { Icon(Icons.Filled.KeyboardArrowDown, "下移") }
IconButton(onClick = onEdit) { Icon(Icons.Filled.Edit, "编辑") }
IconButton(onClick = onDelete) { Icon(Icons.Filled.Delete, "删除") }
}
testMsg?.let {
Text( Text(
"${server.server} · key: ${if (server.deviceKey.isBlank()) "未填" else "已填"}", it,
style = MaterialTheme.typography.bodySmall, style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant, color = if (it.startsWith("已发送")) MaterialTheme.colorScheme.tertiary else MaterialTheme.colorScheme.error,
maxLines = 1, modifier = Modifier.padding(start = 48.dp, top = 2.dp),
overflow = TextOverflow.Ellipsis,
) )
} }
IconButton(onClick = onEdit) { Icon(Icons.Filled.Edit, "编辑") }
IconButton(onClick = onDelete) { Icon(Icons.Filled.Delete, "删除") }
} }
} }
} }
@@ -455,15 +455,14 @@ private fun ServerDialog(
) )
} }
private suspend fun testPush(app: A2iApp): String { private suspend fun testPush(app: A2iApp, server: BarkServer): String {
val s = app.settings.currentServer.value ?: return "未选择服务器" if (server.deviceKey.isBlank()) return "未填写 device key"
if (s.deviceKey.isBlank()) return "未填写 device key"
val msg = BarkMessage( val msg = BarkMessage(
deviceKey = s.deviceKey, deviceKey = server.deviceKey,
title = "a2i 测试", title = "a2i 测试",
body = "如果你在 iOS 看到这条推送,说明配置成功", body = "如果你在 iOS 看到这条推送,说明配置成功",
level = "active", level = "active",
) )
val r = BarkClient(s).push(msg) val r = BarkClient(server).push(msg)
return if (r.isSuccess) "已发送,请查看 iOS 端" else "发送失败:${r.exceptionOrNull()?.message}" return if (r.isSuccess) "已发送,请查看 iOS 端" else "发送失败:${r.exceptionOrNull()?.message}"
} }