diff --git a/app/build.gradle.kts b/app/build.gradle.kts index e8285da..8e83f27 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -13,8 +13,8 @@ android { applicationId = "com.a2i.forwarder" minSdk = 34 targetSdk = 36 - versionCode = 39 - versionName = "1.10.0" + versionCode = 40 + versionName = "1.10.1" } signingConfigs { diff --git a/app/src/main/java/com/a2i/forwarder/A2iApp.kt b/app/src/main/java/com/a2i/forwarder/A2iApp.kt index 9a45ac2..7b10635 100644 --- a/app/src/main/java/com/a2i/forwarder/A2iApp.kt +++ b/app/src/main/java/com/a2i/forwarder/A2iApp.kt @@ -43,6 +43,8 @@ class A2iApp : Application() { settings = SettingsStore(this, appScope) appRules = AppRulesStore(this, appScope) logStore = LogStore(this, appScope) + telemetry = Telemetry(this, appScope) + telemetry.start() phoneCallMonitor = PhoneCallMonitor(this) phoneCallMonitor.start() connectivityMonitor = ConnectivityMonitor(this) @@ -51,8 +53,6 @@ class A2iApp : Application() { smsForwarder = SmsFallbackForwarder(this) updateChecker = UpdateChecker(this) updateChecker.start() - telemetry = Telemetry(this, appScope) - telemetry.start() } companion object { diff --git a/app/src/main/java/com/a2i/forwarder/core/EmailSender.kt b/app/src/main/java/com/a2i/forwarder/core/EmailSender.kt index 33e044f..202d3e6 100644 --- a/app/src/main/java/com/a2i/forwarder/core/EmailSender.kt +++ b/app/src/main/java/com/a2i/forwarder/core/EmailSender.kt @@ -123,7 +123,12 @@ object EmailSender { val configs = s.emailServers.value.filter { it.enabled && it.to.isNotBlank() && it.host.isNotBlank() } if (configs.isEmpty()) return 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(), + ) } } -} \ No newline at end of file +} diff --git a/app/src/main/java/com/a2i/forwarder/core/MeowSender.kt b/app/src/main/java/com/a2i/forwarder/core/MeowSender.kt index 8f3b713..ba53f37 100644 --- a/app/src/main/java/com/a2i/forwarder/core/MeowSender.kt +++ b/app/src/main/java/com/a2i/forwarder/core/MeowSender.kt @@ -75,7 +75,12 @@ object MeowSender { val configs = s.meowServers.value.filter { it.enabled && it.server.isNotBlank() && it.deviceKey.isNotBlank() } if (configs.isEmpty()) return 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(), + ) } } diff --git a/app/src/main/java/com/a2i/forwarder/core/NtfySender.kt b/app/src/main/java/com/a2i/forwarder/core/NtfySender.kt index 8ab02aa..eb140f4 100644 --- a/app/src/main/java/com/a2i/forwarder/core/NtfySender.kt +++ b/app/src/main/java/com/a2i/forwarder/core/NtfySender.kt @@ -92,7 +92,12 @@ object NtfySender { val configs = s.ntfyServers.value.filter { it.enabled && it.topic.isNotBlank() } if (configs.isEmpty()) return 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(), + ) } } diff --git a/app/src/main/java/com/a2i/forwarder/core/PhoneCallMonitor.kt b/app/src/main/java/com/a2i/forwarder/core/PhoneCallMonitor.kt index f2477f4..764e26b 100644 --- a/app/src/main/java/com/a2i/forwarder/core/PhoneCallMonitor.kt +++ b/app/src/main/java/com/a2i/forwarder/core/PhoneCallMonitor.kt @@ -18,11 +18,8 @@ import kotlinx.serialization.json.Json import java.util.UUID /** - * 通过轮询 TelephonyManager.getCallState() 监听来电状态。 - * MIUI 不会把系统电话通知分发给第三方监听器,TelephonyCallback 也不生效, - * 所以这里用最原始但最可靠的方式:每 800ms 读一次 call state。 - * - * getCallState() 只是读内存中的 int,不涉及硬件操作,耗电极低。 + * 轮询 TelephonyManager.getCallState() 监听来电状态。 + * 这里绕过第三方监听器不稳定的限制,直接在轮询状态变化时转发通知。 */ class PhoneCallMonitor(private val context: Context) { private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO) @@ -32,15 +29,11 @@ class PhoneCallMonitor(private val context: Context) { private var ringing = false private var offhook = false private var running = false - - /** 上一次推送"来电"的时间,防止重复推送 */ private var lastRingTime = 0L fun start() { if (running) return - if (ContextCompat.checkSelfPermission(context, Manifest.permission.READ_PHONE_STATE) - != PackageManager.PERMISSION_GRANTED - ) return + if (ContextCompat.checkSelfPermission(context, Manifest.permission.READ_PHONE_STATE) != PackageManager.PERMISSION_GRANTED) return running = true lastState = TelephonyManager.CALL_STATE_IDLE @@ -49,7 +42,10 @@ class PhoneCallMonitor(private val context: Context) { scope.launch { 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) { val state = tm.callState @@ -72,7 +68,6 @@ class PhoneCallMonitor(private val context: Context) { if (!settings.forwardingEnabled.value) return when { - // IDLE → RINGING:来电 from == TelephonyManager.CALL_STATE_IDLE && to == TelephonyManager.CALL_STATE_RINGING -> { ringing = true offhook = false @@ -91,12 +86,10 @@ class PhoneCallMonitor(private val context: Context) { } } - // RINGING → OFFHOOK:接听 from == TelephonyManager.CALL_STATE_RINGING && to == TelephonyManager.CALL_STATE_OFFHOOK -> { offhook = true } - // RINGING → IDLE:未接(响了但没接) from == TelephonyManager.CALL_STATE_RINGING && to == TelephonyManager.CALL_STATE_IDLE -> { if (!offhook && ringing) { val msg = BarkMessage( @@ -113,7 +106,6 @@ class PhoneCallMonitor(private val context: Context) { offhook = false } - // OFFHOOK → IDLE:正常挂断,不推送 from == TelephonyManager.CALL_STATE_OFFHOOK && to == TelephonyManager.CALL_STATE_IDLE -> { ringing = false offhook = false @@ -124,27 +116,35 @@ class PhoneCallMonitor(private val context: Context) { private fun pushToServers(msg: BarkMessage, app: A2iApp, logBody: String) { scope.launch { val servers = app.settings.servers.value.filter { it.enabled && it.deviceKey.isNotBlank() } + val now = System.currentTimeMillis() + 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) NtfySender.trySend(app, msg.title ?: logBody, msg.body ?: "", scope) MeowSender.trySend(app, msg.title ?: logBody, msg.body ?: "", scope) return@launch } + val result = BarkSender.pushToEnabled(servers, msg, app.settings.barkStopOnFirst.value) - val now = System.currentTimeMillis() if (result.isSuccess) { app.logStore.addLog( ForwardLog(now, "phone_call", "电话", msg.title, logBody, "sent", "") ) + app.telemetry.trackForward("bark", "success") } else { val reason = result.exceptionOrNull()?.message ?: "错误" app.logStore.addLog( ForwardLog(now, "phone_call", "电话", msg.title, logBody, "failed", reason) ) + app.telemetry.trackForward("bark", "failed", reason) app.logStore.addPending( PendingPush(UUID.randomUUID().toString(), now, servers.first().id, json.encodeToString(msg)) ) } + EmailSender.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) diff --git a/app/src/main/java/com/a2i/forwarder/core/RetryWorker.kt b/app/src/main/java/com/a2i/forwarder/core/RetryWorker.kt index 9568b5a..cdc1d79 100644 --- a/app/src/main/java/com/a2i/forwarder/core/RetryWorker.kt +++ b/app/src/main/java/com/a2i/forwarder/core/RetryWorker.kt @@ -29,7 +29,13 @@ class RetryWorker(ctx: Context, params: WorkerParameters) : CoroutineWorker(ctx, if (servers.isEmpty()) { allOk = false; break } val msg = runCatching { json.decodeFromString(p.messageJson) }.getOrNull() ?: continue 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() } diff --git a/app/src/main/java/com/a2i/forwarder/core/SmsFallbackForwarder.kt b/app/src/main/java/com/a2i/forwarder/core/SmsFallbackForwarder.kt index ba0eb64..2abe1b7 100644 --- a/app/src/main/java/com/a2i/forwarder/core/SmsFallbackForwarder.kt +++ b/app/src/main/java/com/a2i/forwarder/core/SmsFallbackForwarder.kt @@ -13,8 +13,7 @@ import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.launch /** - * 短信兜底转发器:断网时把通知用短信发到目标手机。 - * 用户可配置:内容过滤(仅紧要/全部)、限频(开关+分钟)、余额检查(开关)。 + * 离线时把紧要通知走短信兜底。 */ class SmsFallbackForwarder(private val context: Context) { 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 settings get() = app.settings - /** 判断该通知是否值得走短信(受用户设置控制)。 */ fun isSmsWorthy(d: Decision): Boolean { - if (settings.smsFilterAll.value) return true // 全部转发模式 - if (d.code != null) return true // 验证码 + if (settings.smsFilterAll.value) return true + if (d.code != null) return true val msg = d.message ?: return false - if (msg.group == "phone_call") return true // 来电/未接来电 - val t = msg.title.orEmpty() - return t.contains("来电") || t.contains("未接") + if (msg.group == "phone_call") return true + val title = msg.title.orEmpty() + return title.contains("来电") || title.contains("未接") } - /** 尝试用短信转发(遍历所有启用的目标号,按 stopOnFirst 决定发通一条止还是全发)。 */ fun forward(d: Decision) { scope.launch { val s = settings - // ① 余额检查(用户可关) + if (s.smsBalanceCheck.value && s.smsSuspended.value) { log(d, "failed", "短信已挂起(余额不足)") return@launch } - // ② 内容过滤(用户可关,关=全部转发) + if (!isSmsWorthy(d)) return@launch - // ③ 目标号 + val targets = s.smsTargets.value.filter { it.enabled && it.number.isNotBlank() } if (targets.isEmpty()) { log(d, "failed", "未设置短信目标号") return@launch } - // ④ 限频(用户可关) + val now = System.currentTimeMillis() if (s.smsRateLimitEnabled.value) { val limitMs = s.smsRateLimitMin.value.coerceIn(1, 60) * 60 * 1000L @@ -59,23 +56,20 @@ class SmsFallbackForwarder(private val context: Context) { return@launch } } - // ⑤ 权限(始终检查) - if (ContextCompat.checkSelfPermission(context, Manifest.permission.SEND_SMS) - != PackageManager.PERMISSION_GRANTED - ) { - log(d, "failed", "无 SEND_SMS 权限") + + if (ContextCompat.checkSelfPermission(context, Manifest.permission.SEND_SMS) != PackageManager.PERMISSION_GRANTED) { + log(d, "failed", "缺少 SEND_SMS 权限") return@launch } - // ⑥ 发送 val body = compose(d).take(70) val stopOnFirst = s.smsStopOnFirst.value var anyOk = false var sentCount = 0 var lastReason = "短信发送失败" - for (t in targets) { + for (target in targets) { 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) { anyOk = true @@ -85,10 +79,10 @@ class SmsFallbackForwarder(private val context: Context) { lastReason = result.exceptionOrNull()?.message ?: "短信发送失败" } } + if (anyOk) { s.setLastSmsSentTime(now) log(d, "sent", "") - // 余额递减(仅余额检查开启时) if (s.smsBalanceCheck.value) { val carrier = app.carrierBalanceQuery.currentCarrier() if (carrier.serviceNumber != null && carrier.queryCmd != null) { @@ -108,8 +102,8 @@ class SmsFallbackForwarder(private val context: Context) { return if (code != null) { "[验证码]${d.appLabel}: $code" } else { - val m = d.message!! - "${m.title.orEmpty().ifBlank { d.appLabel }}: ${m.body}".trim() + val message = d.message!! + "${message.title.orEmpty().ifBlank { d.appLabel }}: ${message.body}".trim() } } @@ -121,6 +115,10 @@ class SmsFallbackForwarder(private val context: Context) { app.logStore.addLog( 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) + } } } } diff --git a/app/src/main/java/com/a2i/forwarder/core/Telemetry.kt b/app/src/main/java/com/a2i/forwarder/core/Telemetry.kt index 3f8e361..6421659 100644 --- a/app/src/main/java/com/a2i/forwarder/core/Telemetry.kt +++ b/app/src/main/java/com/a2i/forwarder/core/Telemetry.kt @@ -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 = withContext(Dispatchers.IO) { runCatching { - val payload = basePayload(event).toString().toRequestBody(JSON) + val payload = basePayload(event) val request = Request.Builder() .url(EVENTS_URL) .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): Result = 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() http.newCall(request).execute().use { response -> if (!response.isSuccessful) error("HTTP ${response.code}") diff --git a/app/src/main/java/com/a2i/forwarder/service/NotifyListenerService.kt b/app/src/main/java/com/a2i/forwarder/service/NotifyListenerService.kt index af39d0c..a2b66f9 100644 --- a/app/src/main/java/com/a2i/forwarder/service/NotifyListenerService.kt +++ b/app/src/main/java/com/a2i/forwarder/service/NotifyListenerService.kt @@ -39,49 +39,55 @@ class NotifyListenerService : NotificationListenerService() { private suspend fun handle(sbn: StatusBarNotification) { val app = A2iApp.instance val now = System.currentTimeMillis() - val d = processor.process(sbn) - val msg = d.message + val decision = processor.process(sbn) + val msg = decision.message if (msg == null) { - // 静默过滤(如持续性通知)不写日志 - if (!d.silent) { - app.logStore.addLog(ForwardLog(now, sbn.packageName, d.appLabel, "", "", "filtered", d.reason)) + if (!decision.silent) { + app.logStore.addLog( + ForwardLog(now, sbn.packageName, decision.appLabel, "", "", "filtered", decision.reason) + ) } return } - // ---- 断网兜底:离线时紧要通知走短信,其余不转发避免离线堆积 ---- - val online = app.connectivityMonitor.isOnline.value - if (!online) { - if (app.smsForwarder.isSmsWorthy(d)) { - app.smsForwarder.forward(d) + if (!app.connectivityMonitor.isOnline.value) { + if (app.smsForwarder.isSmsWorthy(decision)) { + app.smsForwarder.forward(decision) } 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 } 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 服务器")) - EmailSender.trySend(app, msg.title ?: d.appLabel, msg.body, scope) - NtfySender.trySend(app, msg.title ?: d.appLabel, msg.body, scope) - MeowSender.trySend(app, msg.title ?: d.appLabel, msg.body, scope) + val reason = "未配置可用 Bark 服务器" + app.logStore.addLog(ForwardLog(now, sbn.packageName, decision.appLabel, msg.title, msg.body, "failed", reason)) + app.telemetry.trackForward("bark", "failed", reason) + 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 } val result = BarkSender.pushToEnabled(servers, msg, app.settings.barkStopOnFirst.value) 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 { 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( 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) - 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) } }