feat: 全链路转发遥测埋点 + bump to 1.10.1

各通道(bark/email/ntfy/meow/sms)转发结果上报 forward_* 事件

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-11 13:07:11 +08:00
parent b0719b9a90
commit ce2f07a810
10 changed files with 134 additions and 71 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 = 39 versionCode = 40
versionName = "1.10.0" 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 {
@@ -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,8 +13,7 @@ import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
/** /**
* 短信兜底转发器:断网时把通知短信发到目标手机 * 离线时把紧要通知短信兜底
* 用户可配置:内容过滤(仅紧要/全部)、限频(开关+分钟)、余额检查(开关)。
*/ */
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)
@@ -22,34 +21,32 @@ class SmsFallbackForwarder(private val context: Context) {
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 (settings.smsFilterAll.value) return true // 全部转发模式 if (settings.smsFilterAll.value) return true
if (d.code != null) 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 {
val s = settings val s = settings
// ① 余额检查(用户可关)
if (s.smsBalanceCheck.value && s.smsSuspended.value) { 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 = s.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()
if (s.smsRateLimitEnabled.value) { if (s.smsRateLimitEnabled.value) {
val limitMs = s.smsRateLimitMin.value.coerceIn(1, 60) * 60 * 1000L val limitMs = s.smsRateLimitMin.value.coerceIn(1, 60) * 60 * 1000L
@@ -59,23 +56,20 @@ class SmsFallbackForwarder(private val context: Context) {
return@launch return@launch
} }
} }
// ⑤ 权限(始终检查)
if (ContextCompat.checkSelfPermission(context, Manifest.permission.SEND_SMS) if (ContextCompat.checkSelfPermission(context, Manifest.permission.SEND_SMS) != PackageManager.PERMISSION_GRANTED) {
!= 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 = s.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
@@ -85,10 +79,10 @@ class SmsFallbackForwarder(private val context: Context) {
lastReason = result.exceptionOrNull()?.message ?: "短信发送失败" lastReason = result.exceptionOrNull()?.message ?: "短信发送失败"
} }
} }
if (anyOk) { if (anyOk) {
s.setLastSmsSentTime(now) s.setLastSmsSentTime(now)
log(d, "sent", "") log(d, "sent", "")
// 余额递减(仅余额检查开启时)
if (s.smsBalanceCheck.value) { 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) {
@@ -108,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()
} }
} }
@@ -121,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)
} }
} }