2 Commits

Author SHA1 Message Date
song ce2f07a810 feat: 全链路转发遥测埋点 + bump to 1.10.1
各通道(bark/email/ntfy/meow/sms)转发结果上报 forward_* 事件

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-11 13:07:11 +08:00
song b0719b9a90 feat: SMS fallback refactor - user-controllable filters/limits/balance
New user options:
- Content filter: important-only (default) or forward-all
- Rate limit: toggle on/off, adjustable minutes
- Balance check: toggle on/off (off = no suspend, no decrement)

UI: SMS section now has 3 SwitchRows under target list, with
balance/carrier/quota status + "解除挂起" button shown when
balance check is on.

Logic: SmsFallbackForwarder reads new flags to skip checks;
CarrierBalanceQuery guards decrement/suspend on flag.

version 1.9.9 -> 1.10.0 (code 38 -> 39)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-11 09:57:03 +08:00
13 changed files with 217 additions and 87 deletions
+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 = 38 versionCode = 40
versionName = "1.9.9" versionName = "1.10.1"
} }
signingConfigs { signingConfigs {
@@ -43,6 +43,8 @@ class A2iApp : Application() {
settings = SettingsStore(this, appScope) settings = SettingsStore(this, appScope)
appRules = AppRulesStore(this, appScope) appRules = AppRulesStore(this, appScope)
logStore = LogStore(this, appScope) logStore = LogStore(this, appScope)
telemetry = Telemetry(this, appScope)
telemetry.start()
phoneCallMonitor = PhoneCallMonitor(this) phoneCallMonitor = PhoneCallMonitor(this)
phoneCallMonitor.start() phoneCallMonitor.start()
connectivityMonitor = ConnectivityMonitor(this) connectivityMonitor = ConnectivityMonitor(this)
@@ -51,8 +53,6 @@ class A2iApp : Application() {
smsForwarder = SmsFallbackForwarder(this) smsForwarder = SmsFallbackForwarder(this)
updateChecker = UpdateChecker(this) updateChecker = UpdateChecker(this)
updateChecker.start() updateChecker.start()
telemetry = Telemetry(this, appScope)
telemetry.start()
} }
companion object { companion object {
@@ -35,6 +35,7 @@ class CarrierBalanceQuery(private val context: Context) {
/** 转发成功后扣减余额(手动模式 / 自动解析失败时)。 */ /** 转发成功后扣减余额(手动模式 / 自动解析失败时)。 */
suspend fun decrementManual() { suspend fun decrementManual() {
if (!settings.smsBalanceCheck.value) return // 余额检查关闭时不递减
val q = settings.smsManualQuota.value val q = settings.smsManualQuota.value
if (q > 0) { if (q > 0) {
val newQ = q - 1 val newQ = q - 1
@@ -66,6 +67,7 @@ class CarrierBalanceQuery(private val context: Context) {
} }
private suspend fun checkSuspend(balance: Int) { private suspend fun checkSuspend(balance: Int) {
if (!settings.smsBalanceCheck.value) return // 余额检查关闭时不挂起
if (balance in 0..5 && !settings.smsSuspended.value) { if (balance in 0..5 && !settings.smsSuspended.value) {
settings.setSmsSuspended(true) settings.setSmsSuspended(true)
} }
@@ -123,7 +123,12 @@ object EmailSender {
val configs = s.emailServers.value.filter { it.enabled && it.to.isNotBlank() && it.host.isNotBlank() } val configs = s.emailServers.value.filter { it.enabled && it.to.isNotBlank() && it.host.isNotBlank() }
if (configs.isEmpty()) return if (configs.isEmpty()) return
scope.launch { scope.launch {
pushToEnabled(configs, title, body, s.emailStopOnFirst.value) val result = pushToEnabled(configs, title, body, s.emailStopOnFirst.value)
app.telemetry.trackForward(
channel = "email",
status = if (result.isSuccess) "success" else "failed",
reason = result.exceptionOrNull()?.message.orEmpty(),
)
} }
} }
} }
@@ -75,7 +75,12 @@ object MeowSender {
val configs = s.meowServers.value.filter { it.enabled && it.server.isNotBlank() && it.deviceKey.isNotBlank() } val configs = s.meowServers.value.filter { it.enabled && it.server.isNotBlank() && it.deviceKey.isNotBlank() }
if (configs.isEmpty()) return if (configs.isEmpty()) return
scope.launch { scope.launch {
pushToEnabled(configs, title, body, s.meowStopOnFirst.value) val result = pushToEnabled(configs, title, body, s.meowStopOnFirst.value)
app.telemetry.trackForward(
channel = "meow",
status = if (result.isSuccess) "success" else "failed",
reason = result.exceptionOrNull()?.message.orEmpty(),
)
} }
} }
@@ -92,7 +92,12 @@ object NtfySender {
val configs = s.ntfyServers.value.filter { it.enabled && it.topic.isNotBlank() } val configs = s.ntfyServers.value.filter { it.enabled && it.topic.isNotBlank() }
if (configs.isEmpty()) return if (configs.isEmpty()) return
scope.launch { scope.launch {
pushToEnabled(configs, title, body, s.ntfyStopOnFirst.value) val result = pushToEnabled(configs, title, body, s.ntfyStopOnFirst.value)
app.telemetry.trackForward(
channel = "ntfy",
status = if (result.isSuccess) "success" else "failed",
reason = result.exceptionOrNull()?.message.orEmpty(),
)
} }
} }
@@ -18,11 +18,8 @@ import kotlinx.serialization.json.Json
import java.util.UUID import java.util.UUID
/** /**
* 通过轮询 TelephonyManager.getCallState() 监听来电状态。 * 轮询 TelephonyManager.getCallState() 监听来电状态。
* MIUI 不会把系统电话通知分发给第三方监听器,TelephonyCallback 也不生效, * 这里绕过第三方监听器不稳定的限制,直接在轮询状态变化时转发通知。
* 所以这里用最原始但最可靠的方式:每 800ms 读一次 call state。
*
* getCallState() 只是读内存中的 int,不涉及硬件操作,耗电极低。
*/ */
class PhoneCallMonitor(private val context: Context) { class PhoneCallMonitor(private val context: Context) {
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO) private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
@@ -32,15 +29,11 @@ class PhoneCallMonitor(private val context: Context) {
private var ringing = false private var ringing = false
private var offhook = false private var offhook = false
private var running = false private var running = false
/** 上一次推送"来电"的时间,防止重复推送 */
private var lastRingTime = 0L private var lastRingTime = 0L
fun start() { fun start() {
if (running) return if (running) return
if (ContextCompat.checkSelfPermission(context, Manifest.permission.READ_PHONE_STATE) if (ContextCompat.checkSelfPermission(context, Manifest.permission.READ_PHONE_STATE) != PackageManager.PERMISSION_GRANTED) return
!= PackageManager.PERMISSION_GRANTED
) return
running = true running = true
lastState = TelephonyManager.CALL_STATE_IDLE lastState = TelephonyManager.CALL_STATE_IDLE
@@ -49,7 +42,10 @@ class PhoneCallMonitor(private val context: Context) {
scope.launch { scope.launch {
val tm = context.getSystemService(Context.TELEPHONY_SERVICE) as? TelephonyManager val tm = context.getSystemService(Context.TELEPHONY_SERVICE) as? TelephonyManager
if (tm == null) { running = false; return@launch } if (tm == null) {
running = false
return@launch
}
while (isActive && running) { while (isActive && running) {
val state = tm.callState val state = tm.callState
@@ -72,7 +68,6 @@ class PhoneCallMonitor(private val context: Context) {
if (!settings.forwardingEnabled.value) return if (!settings.forwardingEnabled.value) return
when { when {
// IDLE → RINGING:来电
from == TelephonyManager.CALL_STATE_IDLE && to == TelephonyManager.CALL_STATE_RINGING -> { from == TelephonyManager.CALL_STATE_IDLE && to == TelephonyManager.CALL_STATE_RINGING -> {
ringing = true ringing = true
offhook = false offhook = false
@@ -91,12 +86,10 @@ class PhoneCallMonitor(private val context: Context) {
} }
} }
// RINGING → OFFHOOK:接听
from == TelephonyManager.CALL_STATE_RINGING && to == TelephonyManager.CALL_STATE_OFFHOOK -> { from == TelephonyManager.CALL_STATE_RINGING && to == TelephonyManager.CALL_STATE_OFFHOOK -> {
offhook = true offhook = true
} }
// RINGING → IDLE:未接(响了但没接)
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(
@@ -113,7 +106,6 @@ class PhoneCallMonitor(private val context: Context) {
offhook = false offhook = false
} }
// OFFHOOK → IDLE:正常挂断,不推送
from == TelephonyManager.CALL_STATE_OFFHOOK && to == TelephonyManager.CALL_STATE_IDLE -> { from == TelephonyManager.CALL_STATE_OFFHOOK && to == TelephonyManager.CALL_STATE_IDLE -> {
ringing = false ringing = false
offhook = false offhook = false
@@ -124,27 +116,35 @@ class PhoneCallMonitor(private val context: Context) {
private fun pushToServers(msg: BarkMessage, app: A2iApp, logBody: String) { private fun pushToServers(msg: BarkMessage, app: A2iApp, logBody: String) {
scope.launch { scope.launch {
val servers = app.settings.servers.value.filter { it.enabled && it.deviceKey.isNotBlank() } val servers = app.settings.servers.value.filter { it.enabled && it.deviceKey.isNotBlank() }
val now = System.currentTimeMillis()
if (servers.isEmpty()) { if (servers.isEmpty()) {
val reason = "未配置可用 Bark 服务器"
app.logStore.addLog(ForwardLog(now, "phone_call", "电话", msg.title, logBody, "failed", reason))
app.telemetry.trackForward("bark", "failed", reason)
EmailSender.trySend(app, msg.title ?: logBody, msg.body ?: "", scope) EmailSender.trySend(app, msg.title ?: logBody, msg.body ?: "", scope)
NtfySender.trySend(app, msg.title ?: logBody, msg.body ?: "", scope) NtfySender.trySend(app, msg.title ?: logBody, msg.body ?: "", scope)
MeowSender.trySend(app, msg.title ?: logBody, msg.body ?: "", scope) MeowSender.trySend(app, msg.title ?: logBody, msg.body ?: "", scope)
return@launch return@launch
} }
val result = BarkSender.pushToEnabled(servers, msg, app.settings.barkStopOnFirst.value) val result = BarkSender.pushToEnabled(servers, msg, app.settings.barkStopOnFirst.value)
val now = System.currentTimeMillis()
if (result.isSuccess) { if (result.isSuccess) {
app.logStore.addLog( app.logStore.addLog(
ForwardLog(now, "phone_call", "电话", msg.title, logBody, "sent", "") ForwardLog(now, "phone_call", "电话", msg.title, logBody, "sent", "")
) )
app.telemetry.trackForward("bark", "success")
} else { } else {
val reason = result.exceptionOrNull()?.message ?: "错误" val reason = result.exceptionOrNull()?.message ?: "错误"
app.logStore.addLog( app.logStore.addLog(
ForwardLog(now, "phone_call", "电话", msg.title, logBody, "failed", reason) ForwardLog(now, "phone_call", "电话", msg.title, logBody, "failed", reason)
) )
app.telemetry.trackForward("bark", "failed", reason)
app.logStore.addPending( app.logStore.addPending(
PendingPush(UUID.randomUUID().toString(), now, servers.first().id, json.encodeToString(msg)) PendingPush(UUID.randomUUID().toString(), now, servers.first().id, json.encodeToString(msg))
) )
} }
EmailSender.trySend(app, msg.title ?: logBody, msg.body ?: "", scope) EmailSender.trySend(app, msg.title ?: logBody, msg.body ?: "", scope)
NtfySender.trySend(app, msg.title ?: logBody, msg.body ?: "", scope) NtfySender.trySend(app, msg.title ?: logBody, msg.body ?: "", scope)
MeowSender.trySend(app, msg.title ?: logBody, msg.body ?: "", scope) MeowSender.trySend(app, msg.title ?: logBody, msg.body ?: "", scope)
@@ -29,7 +29,13 @@ class RetryWorker(ctx: Context, params: WorkerParameters) : CoroutineWorker(ctx,
if (servers.isEmpty()) { allOk = false; break } if (servers.isEmpty()) { allOk = false; break }
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 = BarkSender.pushToFirst(servers, msg) val r = BarkSender.pushToFirst(servers, msg)
if (r.isSuccess) store.removePending(p.id) else allOk = false if (r.isSuccess) {
store.removePending(p.id)
app.telemetry.trackForward("bark", "success")
} else {
allOk = false
app.telemetry.trackForward("bark", "failed", r.exceptionOrNull()?.message.orEmpty())
}
} }
return if (allOk) Result.success() else Result.retry() return if (allOk) Result.success() else Result.retry()
} }
@@ -13,61 +13,63 @@ import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
/** /**
* 短信兜底转发器:断网时把紧要通知(验证码、来电)用短信发到 iPhone * 离线时把紧要通知走短信兜底。
* 限频:每 5 分钟最多 1 条转发短信。余额 ≤5 时自动挂起。
*/ */
class SmsFallbackForwarder(private val context: Context) { class SmsFallbackForwarder(private val context: Context) {
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO) private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
private val RATE_LIMIT_MS = 5 * 60 * 1000L
private val app get() = A2iApp.instance private val app get() = A2iApp.instance
private val settings get() = app.settings private val settings get() = app.settings
/** 判断该通知是否值得走短信(断网资源宝贵)。 */
fun isSmsWorthy(d: Decision): Boolean { fun isSmsWorthy(d: Decision): Boolean {
if (d.code != null) return true // 验证码 if (settings.smsFilterAll.value) return true
if (d.code != null) return true
val msg = d.message ?: return false val msg = d.message ?: return false
if (msg.group == "phone_call") return true // 来电/未接来电 if (msg.group == "phone_call") return true
val t = msg.title.orEmpty() val title = msg.title.orEmpty()
return t.contains("来电") || t.contains("未接") return title.contains("来电") || title.contains("未接")
} }
/** 尝试用短信转发(遍历所有启用的目标号,按 stopOnFirst 决定发通一条止还是全发)。 */
fun forward(d: Decision) { fun forward(d: Decision) {
scope.launch { scope.launch {
if (settings.smsSuspended.value) { val s = settings
if (s.smsBalanceCheck.value && s.smsSuspended.value) {
log(d, "failed", "短信已挂起(余额不足)") log(d, "failed", "短信已挂起(余额不足)")
return@launch return@launch
} }
if (!isSmsWorthy(d)) return@launch if (!isSmsWorthy(d)) return@launch
val targets = settings.smsTargets.value.filter { it.enabled && it.number.isNotBlank() }
val targets = s.smsTargets.value.filter { it.enabled && it.number.isNotBlank() }
if (targets.isEmpty()) { if (targets.isEmpty()) {
log(d, "failed", "未设置短信目标号") log(d, "failed", "未设置短信目标号")
return@launch return@launch
} }
// 限频
val now = System.currentTimeMillis() val now = System.currentTimeMillis()
val elapsed = now - settings.lastSmsSentTime.value if (s.smsRateLimitEnabled.value) {
if (elapsed < RATE_LIMIT_MS) { val limitMs = s.smsRateLimitMin.value.coerceIn(1, 60) * 60 * 1000L
log(d, "filtered", "限频(${RATE_LIMIT_MS - elapsed}ms 内)") val elapsed = now - s.lastSmsSentTime.value
if (elapsed < limitMs) {
log(d, "filtered", "限频(${limitMs - elapsed}ms 内)")
return@launch return@launch
} }
// 权限 }
if (ContextCompat.checkSelfPermission(context, Manifest.permission.SEND_SMS)
!= PackageManager.PERMISSION_GRANTED if (ContextCompat.checkSelfPermission(context, Manifest.permission.SEND_SMS) != PackageManager.PERMISSION_GRANTED) {
) { log(d, "failed", "缺少 SEND_SMS 权限")
log(d, "failed", "无 SEND_SMS 权限")
return@launch return@launch
} }
val body = compose(d).take(70) val body = compose(d).take(70)
val stopOnFirst = settings.smsStopOnFirst.value val stopOnFirst = s.smsStopOnFirst.value
var anyOk = false var anyOk = false
var sentCount = 0 var sentCount = 0
var lastReason = "短信发送失败" var lastReason = "短信发送失败"
for (t in targets) { for (target in targets) {
val result = runCatching { val result = runCatching {
SmsManager.getDefault().sendTextMessage(t.number.trim(), null, body, null, null) SmsManager.getDefault().sendTextMessage(target.number.trim(), null, body, null, null)
} }
if (result.isSuccess) { if (result.isSuccess) {
anyOk = true anyOk = true
@@ -77,16 +79,18 @@ class SmsFallbackForwarder(private val context: Context) {
lastReason = result.exceptionOrNull()?.message ?: "短信发送失败" lastReason = result.exceptionOrNull()?.message ?: "短信发送失败"
} }
} }
if (anyOk) { if (anyOk) {
settings.setLastSmsSentTime(now) s.setLastSmsSentTime(now)
log(d, "sent", "") log(d, "sent", "")
// 余额递减:按成功发送条数;自动模式发查询短信,手动模式递减 if (s.smsBalanceCheck.value) {
val carrier = app.carrierBalanceQuery.currentCarrier() val carrier = app.carrierBalanceQuery.currentCarrier()
if (carrier.serviceNumber != null && carrier.queryCmd != null) { if (carrier.serviceNumber != null && carrier.queryCmd != null) {
app.carrierBalanceQuery.refresh() app.carrierBalanceQuery.refresh()
} else { } else {
repeat(sentCount) { runCatching { app.carrierBalanceQuery.decrementManual() } } repeat(sentCount) { runCatching { app.carrierBalanceQuery.decrementManual() } }
} }
}
} else { } else {
log(d, "failed", lastReason) log(d, "failed", lastReason)
} }
@@ -98,8 +102,8 @@ class SmsFallbackForwarder(private val context: Context) {
return if (code != null) { return if (code != null) {
"[验证码]${d.appLabel}: $code" "[验证码]${d.appLabel}: $code"
} else { } else {
val m = d.message!! val message = d.message!!
"${m.title.orEmpty().ifBlank { d.appLabel }}: ${m.body}".trim() "${message.title.orEmpty().ifBlank { d.appLabel }}: ${message.body}".trim()
} }
} }
@@ -111,6 +115,10 @@ class SmsFallbackForwarder(private val context: Context) {
app.logStore.addLog( app.logStore.addLog(
ForwardLog(now, "sms_fallback", d.appLabel, title, content, status, reason) ForwardLog(now, "sms_fallback", d.appLabel, title, content, status, reason)
) )
when (status) {
"sent" -> app.telemetry.trackForward("sms", "success")
"failed" -> app.telemetry.trackForward("sms", "failed", reason)
}
} }
} }
} }
@@ -43,13 +43,51 @@ class Telemetry(
} }
} }
fun trackForward(channel: String, status: String, reason: String = "") {
val normalizedChannel = channel.trim().lowercase(Locale.ROOT)
val normalizedStatus = status.trim().lowercase(Locale.ROOT)
if (normalizedChannel.isBlank() || normalizedStatus.isBlank()) return
scope.launch(Dispatchers.IO) {
send(
event = "forward_$normalizedChannel",
fields = mapOf(
"channel" to normalizedChannel,
"status" to normalizedStatus,
"reason" to reason.trim().take(500),
),
)
}
}
private suspend fun send(event: String): Result<Unit> = withContext(Dispatchers.IO) { private suspend fun send(event: String): Result<Unit> = withContext(Dispatchers.IO) {
runCatching { runCatching {
val payload = basePayload(event).toString().toRequestBody(JSON) val payload = basePayload(event)
val request = Request.Builder() val request = Request.Builder()
.url(EVENTS_URL) .url(EVENTS_URL)
.header("User-Agent", userAgent()) .header("User-Agent", userAgent())
.post(payload) .post(payload.toString().toRequestBody(JSON))
.build()
http.newCall(request).execute().use { response ->
if (!response.isSuccessful) error("HTTP ${response.code}")
}
}
}
private suspend fun send(event: String, fields: Map<String, Any?>): Result<Unit> = withContext(Dispatchers.IO) {
runCatching {
val payload = basePayload(event)
fields.forEach { (key, value) ->
if (value == null) return@forEach
when (value) {
is String -> if (value.isNotBlank()) payload.put(key, value)
is Number, is Boolean -> payload.put(key, value)
else -> payload.put(key, value)
}
}
val request = Request.Builder()
.url(EVENTS_URL)
.header("User-Agent", userAgent())
.post(payload.toString().toRequestBody(JSON))
.build() .build()
http.newCall(request).execute().use { response -> http.newCall(request).execute().use { response ->
if (!response.isSuccessful) error("HTTP ${response.code}") if (!response.isSuccessful) error("HTTP ${response.code}")
@@ -39,49 +39,55 @@ class NotifyListenerService : NotificationListenerService() {
private suspend fun handle(sbn: StatusBarNotification) { private suspend fun handle(sbn: StatusBarNotification) {
val app = A2iApp.instance val app = A2iApp.instance
val now = System.currentTimeMillis() val now = System.currentTimeMillis()
val d = processor.process(sbn) val decision = processor.process(sbn)
val msg = d.message val msg = decision.message
if (msg == null) { if (msg == null) {
// 静默过滤(如持续性通知)不写日志 if (!decision.silent) {
if (!d.silent) { app.logStore.addLog(
app.logStore.addLog(ForwardLog(now, sbn.packageName, d.appLabel, "", "", "filtered", d.reason)) ForwardLog(now, sbn.packageName, decision.appLabel, "", "", "filtered", decision.reason)
)
} }
return return
} }
// ---- 断网兜底:离线时紧要通知走短信,其余不转发避免离线堆积 ---- if (!app.connectivityMonitor.isOnline.value) {
val online = app.connectivityMonitor.isOnline.value if (app.smsForwarder.isSmsWorthy(decision)) {
if (!online) { app.smsForwarder.forward(decision)
if (app.smsForwarder.isSmsWorthy(d)) {
app.smsForwarder.forward(d)
} else { } else {
app.logStore.addLog(ForwardLog(now, sbn.packageName, d.appLabel, msg.title, msg.body, "filtered", "离线非紧要通知")) app.logStore.addLog(
ForwardLog(now, sbn.packageName, decision.appLabel, msg.title, msg.body, "filtered", "离线非紧要通知")
)
} }
return return
} }
val servers = app.settings.servers.value.filter { it.enabled && it.deviceKey.isNotBlank() } val servers = app.settings.servers.value.filter { it.enabled && it.deviceKey.isNotBlank() }
if (servers.isEmpty()) { if (servers.isEmpty()) {
app.logStore.addLog(ForwardLog(now, sbn.packageName, d.appLabel, msg.title, msg.body, "failed", "未配置可用 Bark 服务器")) val reason = "未配置可用 Bark 服务器"
EmailSender.trySend(app, msg.title ?: d.appLabel, msg.body, scope) app.logStore.addLog(ForwardLog(now, sbn.packageName, decision.appLabel, msg.title, msg.body, "failed", reason))
NtfySender.trySend(app, msg.title ?: d.appLabel, msg.body, scope) app.telemetry.trackForward("bark", "failed", reason)
MeowSender.trySend(app, msg.title ?: d.appLabel, msg.body, scope) EmailSender.trySend(app, msg.title ?: decision.appLabel, msg.body, scope)
NtfySender.trySend(app, msg.title ?: decision.appLabel, msg.body, scope)
MeowSender.trySend(app, msg.title ?: decision.appLabel, msg.body, scope)
return return
} }
val result = BarkSender.pushToEnabled(servers, msg, app.settings.barkStopOnFirst.value) val result = BarkSender.pushToEnabled(servers, msg, app.settings.barkStopOnFirst.value)
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, decision.appLabel, msg.title, msg.body, "sent", ""))
app.telemetry.trackForward("bark", "success")
} 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, decision.appLabel, msg.title, msg.body, "failed", reason))
app.telemetry.trackForward("bark", "failed", reason)
app.logStore.addPending( app.logStore.addPending(
PendingPush(UUID.randomUUID().toString(), now, servers.first().id, json.encodeToString(msg)) PendingPush(UUID.randomUUID().toString(), now, servers.first().id, json.encodeToString(msg))
) )
} }
EmailSender.trySend(app, msg.title ?: d.appLabel, msg.body, scope)
NtfySender.trySend(app, msg.title ?: d.appLabel, msg.body, scope) EmailSender.trySend(app, msg.title ?: decision.appLabel, msg.body, scope)
MeowSender.trySend(app, msg.title ?: d.appLabel, msg.body, scope) NtfySender.trySend(app, msg.title ?: decision.appLabel, msg.body, scope)
MeowSender.trySend(app, msg.title ?: decision.appLabel, msg.body, scope)
} }
} }
@@ -56,6 +56,10 @@ class SettingsStore(
val SMS_BALANCE = intPreferencesKey("sms_balance") val SMS_BALANCE = intPreferencesKey("sms_balance")
val SMS_SUSPENDED = booleanPreferencesKey("sms_suspended") val SMS_SUSPENDED = booleanPreferencesKey("sms_suspended")
val SMS_LAST_SENT = longPreferencesKey("sms_last_sent") val SMS_LAST_SENT = longPreferencesKey("sms_last_sent")
val SMS_FILTER_ALL = booleanPreferencesKey("sms_filter_all") // true=全部转发 false=仅紧要
val SMS_RATE_LIMIT_ENABLED = booleanPreferencesKey("sms_rate_limit_en")
val SMS_RATE_LIMIT_MIN = intPreferencesKey("sms_rate_limit_min")
val SMS_BALANCE_CHECK = booleanPreferencesKey("sms_balance_check")
// 电邮转发 // 电邮转发
val EMAIL_ENABLED = booleanPreferencesKey("email_enabled") val EMAIL_ENABLED = booleanPreferencesKey("email_enabled")
val EMAIL_SERVERS = stringPreferencesKey("email_servers") val EMAIL_SERVERS = stringPreferencesKey("email_servers")
@@ -121,6 +125,10 @@ class SettingsStore(
val smsBalance = MutableStateFlow(-1) val smsBalance = MutableStateFlow(-1)
val smsSuspended = MutableStateFlow(false) val smsSuspended = MutableStateFlow(false)
val lastSmsSentTime = MutableStateFlow(0L) val lastSmsSentTime = MutableStateFlow(0L)
val smsFilterAll = MutableStateFlow(false) // true=全部转发 false=仅紧要
val smsRateLimitEnabled = MutableStateFlow(true)
val smsRateLimitMin = MutableStateFlow(5)
val smsBalanceCheck = MutableStateFlow(true)
// 电邮总开关 // 电邮总开关
val emailForwardingEnabled = MutableStateFlow(false) val emailForwardingEnabled = MutableStateFlow(false)
@@ -159,6 +167,10 @@ class SettingsStore(
smsBalance.value = p[K.SMS_BALANCE] ?: -1 smsBalance.value = p[K.SMS_BALANCE] ?: -1
smsSuspended.value = p[K.SMS_SUSPENDED] ?: false smsSuspended.value = p[K.SMS_SUSPENDED] ?: false
lastSmsSentTime.value = p[K.SMS_LAST_SENT] ?: 0L lastSmsSentTime.value = p[K.SMS_LAST_SENT] ?: 0L
smsFilterAll.value = p[K.SMS_FILTER_ALL] ?: false
smsRateLimitEnabled.value = p[K.SMS_RATE_LIMIT_ENABLED] ?: true
smsRateLimitMin.value = p[K.SMS_RATE_LIMIT_MIN] ?: 5
smsBalanceCheck.value = p[K.SMS_BALANCE_CHECK] ?: true
// 电邮总开关 // 电邮总开关
emailForwardingEnabled.value = p[K.EMAIL_ENABLED] ?: false emailForwardingEnabled.value = p[K.EMAIL_ENABLED] ?: false
// 应用更新 // 应用更新
@@ -343,6 +355,10 @@ class SettingsStore(
suspend fun setSmsBalance(v: Int) { edit { it[K.SMS_BALANCE] = v }; smsBalance.value = v } suspend fun setSmsBalance(v: Int) { edit { it[K.SMS_BALANCE] = v }; smsBalance.value = v }
suspend fun setSmsSuspended(v: Boolean) { edit { it[K.SMS_SUSPENDED] = v }; smsSuspended.value = v } suspend fun setSmsSuspended(v: Boolean) { edit { it[K.SMS_SUSPENDED] = v }; smsSuspended.value = v }
suspend fun setLastSmsSentTime(v: Long) { edit { it[K.SMS_LAST_SENT] = v }; lastSmsSentTime.value = v } suspend fun setLastSmsSentTime(v: Long) { edit { it[K.SMS_LAST_SENT] = v }; lastSmsSentTime.value = v }
suspend fun setSmsFilterAll(v: Boolean) { edit { it[K.SMS_FILTER_ALL] = v }; smsFilterAll.value = v }
suspend fun setSmsRateLimitEnabled(v: Boolean) { edit { it[K.SMS_RATE_LIMIT_ENABLED] = v }; smsRateLimitEnabled.value = v }
suspend fun setSmsRateLimitMin(v: Int) { edit { it[K.SMS_RATE_LIMIT_MIN] = v }; smsRateLimitMin.value = v }
suspend fun setSmsBalanceCheck(v: Boolean) { edit { it[K.SMS_BALANCE_CHECK] = v }; smsBalanceCheck.value = v }
suspend fun setUpdateCheckEnabled(v: Boolean) { edit { it[K.UPDATE_CHECK_AUTO] = v }; updateCheckEnabled.value = v } suspend fun setUpdateCheckEnabled(v: Boolean) { edit { it[K.UPDATE_CHECK_AUTO] = v }; updateCheckEnabled.value = v }
suspend fun setLastUpdateCheck(v: Long) { edit { it[K.LAST_UPDATE_CHECK] = v }; lastUpdateCheckTime.value = v } suspend fun setLastUpdateCheck(v: Long) { edit { it[K.LAST_UPDATE_CHECK] = v }; lastUpdateCheckTime.value = v }
@@ -113,6 +113,10 @@ fun SettingsScreen(onOpenLog: () -> Unit) {
val meowServers by app.settings.meowServers.collectAsState() val meowServers by app.settings.meowServers.collectAsState()
val emailServers by app.settings.emailServers.collectAsState() val emailServers by app.settings.emailServers.collectAsState()
val smsTargets by app.settings.smsTargets.collectAsState() val smsTargets by app.settings.smsTargets.collectAsState()
val smsFilterAll by app.settings.smsFilterAll.collectAsState()
val smsRateLimitEnabled by app.settings.smsRateLimitEnabled.collectAsState()
val smsRateLimitMin by app.settings.smsRateLimitMin.collectAsState()
val smsBalanceCheck by app.settings.smsBalanceCheck.collectAsState()
val barkStopFirst by app.settings.barkStopOnFirst.collectAsState() val barkStopFirst by app.settings.barkStopOnFirst.collectAsState()
val ntfyStopFirst by app.settings.ntfyStopOnFirst.collectAsState() val ntfyStopFirst by app.settings.ntfyStopOnFirst.collectAsState()
val meowStopFirst by app.settings.meowStopOnFirst.collectAsState() val meowStopFirst by app.settings.meowStopOnFirst.collectAsState()
@@ -312,7 +316,7 @@ fun SettingsScreen(onOpenLog: () -> Unit) {
SectionCard { SectionCard {
ChannelSectionHeader( ChannelSectionHeader(
title = "断网短信兜底", title = "断网短信兜底",
summary = "无网络时发送关键通知", summary = "无网络时用短信发送通知",
mark = "S", mark = "S",
markColor = Color(0xFF00897B), markColor = Color(0xFF00897B),
markIcon = Icons.Filled.Sms, markIcon = Icons.Filled.Sms,
@@ -342,13 +346,48 @@ fun SettingsScreen(onOpenLog: () -> Unit) {
} }
} }
AddButton { showAddSms = true } AddButton { showAddSms = true }
// ── 高级设置 ──
Spacer(Modifier.size(12.dp))
SwitchRow(
title = "仅紧要通知",
subtitle = if (smsFilterAll) "当前:全部转发(不限内容)" else "当前:仅验证码 + 来电",
checked = !smsFilterAll,
icon = Icons.Filled.Settings,
) { v -> app.appScope.launch { app.settings.setSmsFilterAll(!v) } }
SwitchRow(
title = "限频",
subtitle = if (smsRateLimitEnabled) "${smsRateLimitMin} 分钟最多 1 条" else "已关闭(来一条发一条)",
checked = smsRateLimitEnabled,
) { v -> app.appScope.launch { app.settings.setSmsRateLimitEnabled(v) } }
SwitchRow(
title = "余额检查",
subtitle = if (smsBalanceCheck) "余额 ≤5 自动挂起" else "已关闭(不限余额)",
checked = smsBalanceCheck,
) { v -> app.appScope.launch { app.settings.setSmsBalanceCheck(v) } }
// 余额检查开启时显示运营商/额度/挂起状态
if (smsBalanceCheck) {
Spacer(Modifier.size(8.dp)) Spacer(Modifier.size(8.dp))
val balanceText = when { val balanceText = when {
smsSuspended -> "已挂起(余额不足)" smsSuspended -> "已挂起(余额不足)"
smsBalance < 0 -> "余额未知" smsBalance < 0 -> "余额未知"
else -> "剩余约 $smsBalance" else -> "剩余约 $smsBalance"
} }
Text("运营商:${detectedCarrier.displayName} · 余额:$balanceText · 手动额度:$smsManualQuota", style = MaterialTheme.typography.bodySmall, color = if (smsSuspended) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.onSurfaceVariant) Text("运营商:${detectedCarrier.displayName} · $balanceText · 手动额度:$smsManualQuota", style = MaterialTheme.typography.bodySmall, color = if (smsSuspended) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.onSurfaceVariant)
if (smsSuspended) {
Spacer(Modifier.size(4.dp))
OutlinedButton(onClick = {
app.appScope.launch {
app.settings.setSmsSuspended(false)
val q = smsManualQuota
if (q > 0) app.settings.setSmsBalance(q)
}
}, modifier = Modifier.fillMaxWidth()) { Text("解除挂起") }
}
}
} }
} }