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:
@@ -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/15s;body 用 `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 必发):
|
||||||
|
|||||||
@@ -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}"
|
||||||
}
|
}
|
||||||
Reference in New Issue
Block a user