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/"
}
}