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
+2 -2
View File
@@ -13,8 +13,8 @@ android {
applicationId = "com.a2i.forwarder"
minSdk = 34
targetSdk = 36
versionCode = 9
versionName = "1.5.1"
versionCode = 10
versionName = "1.6.0"
}
signingConfigs {
@@ -36,7 +36,7 @@ class BarkClient(private val server: BarkServer) {
* HTTP 4xx/5xx 是服务端拒绝,重试无意义,直接抛出。
*/
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 req = Request.Builder().url(url).post(body).build()
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 msg = BarkMessage(
deviceKey = settings.currentServer.value?.deviceKey.orEmpty(),
deviceKey = "",
title = msgTitle.take(100),
body = content.ifBlank { title }.take(500),
level = level,
@@ -70,8 +70,6 @@ class PhoneCallMonitor(private val context: Context) {
val app = A2iApp.instance
val settings = app.settings
if (!settings.forwardingEnabled.value) return
val server = settings.currentServer.value ?: return
if (server.deviceKey.isBlank()) return
when {
// IDLE → RINGING:来电
@@ -82,14 +80,14 @@ class PhoneCallMonitor(private val context: Context) {
if (now - lastRingTime > 3000) {
lastRingTime = now
val msg = BarkMessage(
deviceKey = server.deviceKey,
deviceKey = "",
title = "来电",
body = "有电话进来",
level = "timeSensitive",
group = "phone_call",
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 -> {
if (!offhook && ringing) {
val msg = BarkMessage(
deviceKey = server.deviceKey,
deviceKey = "",
title = "未接来电",
body = "未接来电",
level = "timeSensitive",
group = "phone_call",
isArchive = "1",
)
pushToServer(server, msg, app, "未接来电")
pushToServers(msg, app, "未接来电")
}
ringing = false
offhook = false
@@ -123,14 +121,11 @@ class PhoneCallMonitor(private val context: Context) {
}
}
private fun pushToServer(
server: com.a2i.forwarder.model.BarkServer,
msg: BarkMessage,
app: A2iApp,
logBody: String,
) {
private fun pushToServers(msg: BarkMessage, app: A2iApp, logBody: String) {
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()
if (result.isSuccess) {
app.logStore.addLog(
@@ -142,7 +137,7 @@ class PhoneCallMonitor(private val context: Context) {
ForwardLog(now, "phone_call", "电话", msg.title, logBody, "failed", reason)
)
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
val json = Json { ignoreUnknownKeys = true }
val servers = settings.servers.value.filter { it.enabled && it.deviceKey.isNotBlank() }
for (p in list) {
val server = settings.servers.value.firstOrNull { it.id == p.serverId }
?: settings.currentServer.value
?: continue
if (server.deviceKey.isBlank()) { allOk = false; continue }
if (servers.isEmpty()) { allOk = false; break }
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
}
return if (allOk) Result.success() else Result.retry()
@@ -9,6 +9,7 @@ data class BarkServer(
val name: String,
val server: String,
val deviceKey: String,
val enabled: Boolean = true,
)
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.StatusBarNotification
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.NotificationProcessor
import com.a2i.forwarder.core.PendingPush
@@ -58,20 +58,20 @@ class NotifyListenerService : NotificationListenerService() {
return
}
val server = app.settings.currentServer.value
if (server == null || server.deviceKey.isBlank()) {
app.logStore.addLog(ForwardLog(now, sbn.packageName, d.appLabel, msg.title, msg.body, "failed", "未配置 Bark key"))
val servers = app.settings.servers.value.filter { it.enabled && it.deviceKey.isNotBlank() }
if (servers.isEmpty()) {
app.logStore.addLog(ForwardLog(now, sbn.packageName, d.appLabel, msg.title, msg.body, "failed", "未配置可用 Bark 服务器"))
return
}
val result = BarkClient(server).push(msg)
val result = BarkSender.pushToFirst(servers, msg)
if (result.isSuccess) {
app.logStore.addLog(ForwardLog(now, sbn.packageName, d.appLabel, msg.title, msg.body, "sent", ""))
} else {
val reason = result.exceptionOrNull()?.message ?: "错误"
app.logStore.addLog(ForwardLog(now, sbn.packageName, d.appLabel, msg.title, msg.body, "failed", reason))
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 {
val SERVERS = stringPreferencesKey("servers")
val CURRENT = stringPreferencesKey("current")
val ICON_PREFIX = stringPreferencesKey("icon_prefix")
val FWD = booleanPreferencesKey("forwarding")
val CODE = booleanPreferencesKey("code")
@@ -60,9 +59,6 @@ class SettingsStore(
private val _servers = MutableStateFlow(defaultServers)
val servers = _servers.asStateFlow()
private val _currentServerId = MutableStateFlow(ID_OFFICIAL)
val currentServer = MutableStateFlow<BarkServer?>(defaultServers.first())
val iconPrefix = MutableStateFlow(DEFAULT_ICON_PREFIX)
val forwardingEnabled = MutableStateFlow(true)
val codeEnabled = MutableStateFlow(true)
@@ -92,8 +88,6 @@ class SettingsStore(
?.let { runCatching { json.decodeFromString<List<BarkServer>>(it) }.getOrNull() }
?: defaultServers
_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
forwardingEnabled.value = p[K.FWD] ?: true
codeEnabled.value = p[K.CODE] ?: true
@@ -125,15 +119,11 @@ class SettingsStore(
private suspend fun saveServers(list: List<BarkServer>) {
edit { it[K.SERVERS] = json.encodeToString(list) }
_servers.value = list
refreshCurrent()
}
suspend fun addServer(name: String, server: String, key: String): BarkServer {
val s = BarkServer(name = name.trim(), server = server.trim().removeSuffix("/"), deviceKey = key.trim())
saveServers(_servers.value + s)
if (_servers.value.count { it.id == s.id } == 1 && currentServer.value?.deviceKey.isNullOrBlank()) {
setCurrent(s.id)
}
return s
}
@@ -142,19 +132,21 @@ class SettingsStore(
suspend fun deleteServer(id: String) {
val list = _servers.value.filterNot { it.id == id }
saveServers(list)
if (_currentServerId.value == id) {
setCurrent(if (list.isEmpty()) "" else list.first().id)
}
}
suspend fun setCurrent(id: String) {
edit { it[K.CURRENT] = id }
_currentServerId.value = id
refreshCurrent()
/** 上移/下移服务器(改变转发顺序) */
suspend fun moveServer(id: String, up: Boolean) {
val list = _servers.value.toMutableList()
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() {
currentServer.value = _servers.value.firstOrNull { it.id == _currentServerId.value } ?: _servers.value.firstOrNull()
suspend fun setServerEnabled(id: String, enabled: Boolean) {
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 }
@@ -62,7 +62,7 @@ fun HomeScreen(onOpenLog: () -> Unit, onOpenSettings: () -> Unit) {
val context = LocalContext.current
val app = A2iApp.instance
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 filtered by app.logStore.filteredCount.collectAsState()
val failed by app.logStore.failedCount.collectAsState()
@@ -180,21 +180,14 @@ fun HomeScreen(onOpenLog: () -> Unit, onOpenSettings: () -> Unit) {
) { v ->
app.appScope.launch { app.settings.setForwarding(v) }
}
val enabledServers = servers.filter { it.enabled && it.deviceKey.isNotBlank() }
ClickRow(
title = current?.name?.ifBlank { "未命名服务器" } ?: "未配置 Bark 服务器",
subtitle = current?.server ?: "进入设置添加官方或自建 Bark 服务",
title = if (enabledServers.isEmpty()) "未启用 Bark 服务器" else "Bark 服务器${enabledServers.size} 个启用)",
subtitle = enabledServers.firstOrNull()?.server ?: "进入设置勾选要转发的服务",
icon = Icons.Filled.Settings,
tint = MaterialTheme.colorScheme.secondary,
onClick = onOpenSettings,
)
if (current?.deviceKey.isNullOrBlank()) {
Text(
"当前服务器未填写 device key",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.error,
modifier = Modifier.padding(start = 50.dp),
)
}
ClickRow(
title = "查看转发日志",
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.Edit
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.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.Checkbox
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.FilledTonalButton
import androidx.compose.material3.Icon
@@ -72,14 +75,11 @@ fun SettingsScreen(onOpenLog: () -> Unit) {
val context = LocalContext.current
val app = A2iApp.instance
val servers by app.settings.servers.collectAsState()
val current by app.settings.currentServer.collectAsState()
val iconPrefix by app.settings.iconPrefix.collectAsState()
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 exporting by remember { mutableStateOf(false) }
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 editing by remember { mutableStateOf<BarkServer?>(null) }
@@ -126,18 +126,21 @@ fun SettingsScreen(onOpenLog: () -> Unit) {
item { ScreenTitle("设置", "管理 Bark 服务、图标资源和维护操作") }
item {
SectionCard("Bark 服务器", subtitle = "官方服务和自建服务可以并存,当前服务器用于所有推送") {
SectionCard("Bark 服务器", subtitle = "勾选的服务器按列表顺序依次尝试转发,首个成功即止") {
if (servers.isEmpty()) {
Text("暂无服务器,请先添加。", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
} else {
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
servers.forEach { s ->
servers.forEachIndexed { index, s ->
ServerRow(
server = s,
selected = s.id == current?.id,
onSelect = { app.appScope.launch { app.settings.setCurrent(s.id) } },
isFirst = index == 0,
isLast = index == servers.lastIndex,
onToggle = { v -> app.appScope.launch { app.settings.setServerEnabled(s.id, v) } },
onEdit = { editing = s },
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))
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 },
onConfirm = { n, s, k ->
app.appScope.launch {
val ns = app.settings.addServer(n, s, k)
app.settings.setCurrent(ns.id)
app.settings.addServer(n, s, k)
}
showAdd = false
},
@@ -389,41 +364,66 @@ fun SettingsScreen(onOpenLog: () -> Unit) {
@Composable
private fun ServerRow(
server: BarkServer,
selected: Boolean,
onSelect: () -> Unit,
isFirst: Boolean,
isLast: Boolean,
onToggle: (Boolean) -> Unit,
onEdit: () -> 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(
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(8.dp),
color = if (selected) MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.55f) 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),
color = if (server.enabled) MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.40f) else MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.36f),
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) {
RadioButton(selected = selected, onClick = onSelect)
Column(Modifier.weight(1f)) {
Row(verticalAlignment = Alignment.CenterVertically) {
Column(Modifier.padding(8.dp)) {
Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
Checkbox(checked = server.enabled, onCheckedChange = onToggle)
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(
server.name.ifBlank { "未命名" },
style = MaterialTheme.typography.bodyLarge,
fontWeight = FontWeight.Medium,
"${server.server} · key: ${if (server.deviceKey.isBlank()) "未填" else "已填"}",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1,
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(
"${server.server} · key: ${if (server.deviceKey.isBlank()) "未填" else "已填"}",
it,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
color = if (it.startsWith("已发送")) MaterialTheme.colorScheme.tertiary else MaterialTheme.colorScheme.error,
modifier = Modifier.padding(start = 48.dp, top = 2.dp),
)
}
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 {
val s = app.settings.currentServer.value ?: return "未选择服务器"
if (s.deviceKey.isBlank()) return "未填写 device key"
private suspend fun testPush(app: A2iApp, server: BarkServer): String {
if (server.deviceKey.isBlank()) return "未填写 device key"
val msg = BarkMessage(
deviceKey = s.deviceKey,
deviceKey = server.deviceKey,
title = "a2i 测试",
body = "如果你在 iOS 看到这条推送,说明配置成功",
level = "active",
)
val r = BarkClient(s).push(msg)
val r = BarkClient(server).push(msg)
return if (r.isSuccess) "已发送,请查看 iOS 端" else "发送失败:${r.exceptionOrNull()?.message}"
}