From 0bf759ae64810b7dfd4a1d93b1eb7427173f6b73 Mon Sep 17 00:00:00 2001 From: Song <168455@qq.com> Date: Tue, 7 Jul 2026 13:52:40 +0800 Subject: [PATCH] feat: phone call monitoring + SMS sent filter + icon prefix default MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 PhoneCallMonitor:通过轮询 getCallState() 感知来电/未接 (绕过 MIUI 对系统电话通知的限制,需 READ_PHONE_STATE 权限) - 短信仅收不发:过滤"发送中"/"已发送"等关键词 - 图标 URL 前缀默认值:GitHub raw 图床 - 从黑名单移除 com.android.phone/com.android.incallui - 默认黑名单新增三星剪贴板/GMS 等噪音包名 - 版本 1.2.0 → 1.3.0 Co-Authored-By: Claude Opus 4.8 (1M context) --- README.md | 3 +- app/build.gradle.kts | 4 +- app/src/main/AndroidManifest.xml | 1 + app/src/main/java/com/a2i/forwarder/A2iApp.kt | 5 + .../forwarder/core/NotificationProcessor.kt | 32 ++++ .../a2i/forwarder/core/PhoneCallMonitor.kt | 150 ++++++++++++++++++ .../com/a2i/forwarder/store/AppRulesStore.kt | 3 +- .../com/a2i/forwarder/store/SettingsStore.kt | 5 +- 8 files changed, 196 insertions(+), 7 deletions(-) create mode 100644 app/src/main/java/com/a2i/forwarder/core/PhoneCallMonitor.kt diff --git a/README.md b/README.md index 4188219..408eb8b 100644 --- a/README.md +++ b/README.md @@ -17,7 +17,7 @@ - **应用级规则**:黑名单 / 白名单两种模式,可按 App 单独控制是否转发。 - **失败自动重试**:发送失败的通知进入待重发队列,由 WorkManager 定期重试,也可手动触发。 - **本地日志**:保留最近 300 条处理记录,方便排查为什么某条通知被过滤或发送失败。 -- **图标导出**:一键导出已安装 App 的图标,便于部署到 Bark 图床,让通知带上来源图标。 +- **电话来电通知**:通过轮询 `getCallState()` 实时感知来电和未接来电(绕过 MIUI 对系统电话通知的限制,需授予"电话"权限)。 ## 快速开始 @@ -50,6 +50,7 @@ - **想同步聊天**:保持黑名单模式(默认),在「过滤」页开启「微信 / QQ / Telegram 优化」。 - **通知没推过来**:先看「日志」页,每条通知(无论转发、过滤还是失败)都会记录原因。 - **想让通知带图标**:参考下方「应用图标」一节。 +- **电话通知不工作**:MIUI 会把系统电话通知限制为不向第三方通知监听器分发,本 App 通过轮询 `getCallonyState()` 绕过此限制。需要授予「电话」权限(设置 → 应用 → a2i → 权限 → 电话)。 ## 应用图标 diff --git a/app/build.gradle.kts b/app/build.gradle.kts index a02c703..44a0ae2 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 = 3 - versionName = "1.2.0" + versionCode = 4 + versionName = "1.3.0" } signingConfigs { diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index ae79a70..b5020bb 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -4,6 +4,7 @@ + diff --git a/app/src/main/java/com/a2i/forwarder/A2iApp.kt b/app/src/main/java/com/a2i/forwarder/A2iApp.kt index da42b61..ca88f89 100644 --- a/app/src/main/java/com/a2i/forwarder/A2iApp.kt +++ b/app/src/main/java/com/a2i/forwarder/A2iApp.kt @@ -2,6 +2,7 @@ package com.a2i.forwarder import android.app.Application import com.a2i.forwarder.core.LogStore +import com.a2i.forwarder.core.PhoneCallMonitor import com.a2i.forwarder.store.AppRulesStore import com.a2i.forwarder.store.SettingsStore import kotlinx.coroutines.CoroutineScope @@ -17,6 +18,8 @@ class A2iApp : Application() { private set lateinit var logStore: LogStore private set + lateinit var phoneCallMonitor: PhoneCallMonitor + private set override fun onCreate() { super.onCreate() @@ -24,6 +27,8 @@ class A2iApp : Application() { settings = SettingsStore(this, appScope) appRules = AppRulesStore(this, appScope) logStore = LogStore(this, appScope) + phoneCallMonitor = PhoneCallMonitor(this) + phoneCallMonitor.start() } companion object { diff --git a/app/src/main/java/com/a2i/forwarder/core/NotificationProcessor.kt b/app/src/main/java/com/a2i/forwarder/core/NotificationProcessor.kt index 47c9223..2298566 100644 --- a/app/src/main/java/com/a2i/forwarder/core/NotificationProcessor.kt +++ b/app/src/main/java/com/a2i/forwarder/core/NotificationProcessor.kt @@ -122,6 +122,10 @@ class NotificationProcessor(private val context: Context) { if (settings.adFilterEnabled.value && AdFilter.isAd("$title $content $sub", settings.adKeywords.value)) return Decision(null, "广告过滤", appLabel, null) + // ---- 短信"发出"过滤:只保留收到的短信 ---- + if (isSentSmsNotification(pkg, title, content)) + return Decision(null, "已发短信(仅收不发)", appLabel, null) + // ---- 去重检查 ---- val dedupKey = "$pkg|${title.take(80)}|${content.take(80)}" val lastTime = dedupCache[dedupKey] @@ -188,4 +192,32 @@ class NotificationProcessor(private val context: Context) { private fun isSystemStatusMessage(text: String): Boolean { return systemStatusPatterns.any { it.containsMatchIn(text) } } + + /** 短信 App 列表 */ + private val smsPackages = setOf( + "com.android.mms", + "com.google.android.apps.messaging", + "com.samsung.android.messaging", + "com.android.messaging", + ) + + /** 发出的短信通知关键词 */ + private val sentSmsPatterns = listOf( + Regex("""发送中"""), + Regex("""正在发送"""), + Regex("""已发送"""), + Regex("""发送成功"""), + Regex("""短信已发出"""), + Regex("""消息已发出"""), + Regex("""sending""", RegexOption.IGNORE_CASE), + Regex("""message sent""", RegexOption.IGNORE_CASE), + Regex("""sent$""", RegexOption.IGNORE_CASE), + ) + + /** 判断是否为发出的短信通知(而非收到的) */ + private fun isSentSmsNotification(pkg: String, title: String, content: String): Boolean { + if (pkg !in smsPackages) return false + val blob = "$title $content" + return sentSmsPatterns.any { it.containsMatchIn(blob) } + } } diff --git a/app/src/main/java/com/a2i/forwarder/core/PhoneCallMonitor.kt b/app/src/main/java/com/a2i/forwarder/core/PhoneCallMonitor.kt new file mode 100644 index 0000000..941d27a --- /dev/null +++ b/app/src/main/java/com/a2i/forwarder/core/PhoneCallMonitor.kt @@ -0,0 +1,150 @@ +package com.a2i.forwarder.core + +import android.Manifest +import android.content.Context +import android.content.pm.PackageManager +import android.telephony.TelephonyManager +import androidx.core.content.ContextCompat +import com.a2i.forwarder.A2iApp +import com.a2i.forwarder.model.BarkMessage +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.delay +import kotlinx.coroutines.isActive +import kotlinx.coroutines.launch +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json +import java.util.UUID + +/** + * 通过轮询 TelephonyManager.getCallState() 监听来电状态。 + * MIUI 不会把系统电话通知分发给第三方监听器,TelephonyCallback 也不生效, + * 所以这里用最原始但最可靠的方式:每 800ms 读一次 call state。 + * + * getCallState() 只是读内存中的 int,不涉及硬件操作,耗电极低。 + */ +class PhoneCallMonitor(private val context: Context) { + private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO) + private val json = Json { encodeDefaults = true; ignoreUnknownKeys = true } + + private var lastState = TelephonyManager.CALL_STATE_IDLE + 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 + + running = true + lastState = TelephonyManager.CALL_STATE_IDLE + ringing = false + offhook = false + + scope.launch { + val tm = context.getSystemService(Context.TELEPHONY_SERVICE) as? TelephonyManager + if (tm == null) { running = false; return@launch } + + while (isActive && running) { + val state = tm.callState + if (state != lastState) { + handleTransition(lastState, state) + lastState = state + } + delay(800) + } + } + } + + fun stop() { + running = false + } + + private fun handleTransition(from: Int, to: Int) { + val app = A2iApp.instance + val settings = app.settings + if (!settings.forwardingEnabled.value) return + val server = settings.currentServer.value ?: return + if (server.deviceKey.isBlank()) return + + when { + // IDLE → RINGING:来电 + from == TelephonyManager.CALL_STATE_IDLE && to == TelephonyManager.CALL_STATE_RINGING -> { + ringing = true + offhook = false + val now = System.currentTimeMillis() + if (now - lastRingTime > 3000) { + lastRingTime = now + val msg = BarkMessage( + deviceKey = server.deviceKey, + title = "来电", + body = "有电话进来", + level = "timeSensitive", + group = "phone_call", + isArchive = "1", + ) + pushToServer(server, msg, app, "来电") + } + } + + // 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( + deviceKey = server.deviceKey, + title = "未接来电", + body = "未接来电", + level = "timeSensitive", + group = "phone_call", + isArchive = "1", + ) + pushToServer(server, msg, app, "未接来电") + } + ringing = false + offhook = false + } + + // OFFHOOK → IDLE:正常挂断,不推送 + from == TelephonyManager.CALL_STATE_OFFHOOK && to == TelephonyManager.CALL_STATE_IDLE -> { + ringing = false + offhook = false + } + } + } + + private fun pushToServer( + server: com.a2i.forwarder.model.BarkServer, + msg: BarkMessage, + app: A2iApp, + logBody: String, + ) { + scope.launch { + val result = BarkClient(server).push(msg) + val now = System.currentTimeMillis() + if (result.isSuccess) { + app.logStore.addLog( + ForwardLog(now, "phone_call", "电话", msg.title, logBody, "sent", "") + ) + } else { + val reason = result.exceptionOrNull()?.message ?: "错误" + app.logStore.addLog( + ForwardLog(now, "phone_call", "电话", msg.title, logBody, "failed", reason) + ) + app.logStore.addPending( + PendingPush(UUID.randomUUID().toString(), now, server.id, json.encodeToString(msg)) + ) + } + } + } +} diff --git a/app/src/main/java/com/a2i/forwarder/store/AppRulesStore.kt b/app/src/main/java/com/a2i/forwarder/store/AppRulesStore.kt index 4937b24..b611186 100644 --- a/app/src/main/java/com/a2i/forwarder/store/AppRulesStore.kt +++ b/app/src/main/java/com/a2i/forwarder/store/AppRulesStore.kt @@ -74,8 +74,7 @@ class AppRulesStore( "com.android.systemui", "com.android.settings", "com.android.permissioncontroller", - "com.android.phone", - "com.android.incallui", + // 注意:com.android.phone / com.android.incallui 不在黑名单中,电话通知需要转发 // 输入法 "com.google.android.inputmethod", "com.google.android.inputmethod.latin", diff --git a/app/src/main/java/com/a2i/forwarder/store/SettingsStore.kt b/app/src/main/java/com/a2i/forwarder/store/SettingsStore.kt index 1eba961..4b608a2 100644 --- a/app/src/main/java/com/a2i/forwarder/store/SettingsStore.kt +++ b/app/src/main/java/com/a2i/forwarder/store/SettingsStore.kt @@ -49,7 +49,7 @@ class SettingsStore( private val _currentServerId = MutableStateFlow(ID_OFFICIAL) val currentServer = MutableStateFlow(defaultServers.first()) - val iconPrefix = MutableStateFlow("") + val iconPrefix = MutableStateFlow(DEFAULT_ICON_PREFIX) val forwardingEnabled = MutableStateFlow(true) val codeEnabled = MutableStateFlow(true) val adFilterEnabled = MutableStateFlow(true) @@ -66,7 +66,7 @@ class SettingsStore( _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] ?: "" + iconPrefix.value = p[K.ICON_PREFIX] ?: DEFAULT_ICON_PREFIX forwardingEnabled.value = p[K.FWD] ?: true codeEnabled.value = p[K.CODE] ?: true adFilterEnabled.value = p[K.AD] ?: true @@ -132,5 +132,6 @@ class SettingsStore( companion object { const val ID_OFFICIAL = "official" + const val DEFAULT_ICON_PREFIX = "https://raw.githubusercontent.com/lsxf/a2i/main/icons/" } }