feat: phone call monitoring + SMS sent filter + icon prefix default

- 新增 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) <noreply@anthropic.com>
This commit is contained in:
2026-07-07 13:52:40 +08:00
parent 3ef69bc56f
commit 0bf759ae64
8 changed files with 196 additions and 7 deletions
+2 -1
View File
@@ -17,7 +17,7 @@
- **应用级规则**:黑名单 / 白名单两种模式,可按 App 单独控制是否转发。 - **应用级规则**:黑名单 / 白名单两种模式,可按 App 单独控制是否转发。
- **失败自动重试**:发送失败的通知进入待重发队列,由 WorkManager 定期重试,也可手动触发。 - **失败自动重试**:发送失败的通知进入待重发队列,由 WorkManager 定期重试,也可手动触发。
- **本地日志**:保留最近 300 条处理记录,方便排查为什么某条通知被过滤或发送失败。 - **本地日志**:保留最近 300 条处理记录,方便排查为什么某条通知被过滤或发送失败。
- **图标导出**:一键导出已安装 App 的图标,便于部署到 Bark 图床,让通知带上来源图标 - **电话来电通知**:通过轮询 `getCallState()` 实时感知来电和未接来电(绕过 MIUI 对系统电话通知的限制,需授予"电话"权限)
## 快速开始 ## 快速开始
@@ -50,6 +50,7 @@
- **想同步聊天**:保持黑名单模式(默认),在「过滤」页开启「微信 / QQ / Telegram 优化」。 - **想同步聊天**:保持黑名单模式(默认),在「过滤」页开启「微信 / QQ / Telegram 优化」。
- **通知没推过来**:先看「日志」页,每条通知(无论转发、过滤还是失败)都会记录原因。 - **通知没推过来**:先看「日志」页,每条通知(无论转发、过滤还是失败)都会记录原因。
- **想让通知带图标**:参考下方「应用图标」一节。 - **想让通知带图标**:参考下方「应用图标」一节。
- **电话通知不工作**:MIUI 会把系统电话通知限制为不向第三方通知监听器分发,本 App 通过轮询 `getCallonyState()` 绕过此限制。需要授予「电话」权限(设置 → 应用 → a2i → 权限 → 电话)。
## 应用图标 ## 应用图标
+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 = 3 versionCode = 4
versionName = "1.2.0" versionName = "1.3.0"
} }
signingConfigs { signingConfigs {
+1
View File
@@ -4,6 +4,7 @@
<uses-permission android:name="android.permission.INTERNET" /> <uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" /> <uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.READ_PHONE_STATE" />
<uses-permission android:name="android.permission.QUERY_ALL_PACKAGES" <uses-permission android:name="android.permission.QUERY_ALL_PACKAGES"
tools:ignore="QueryAllPackagesPermission" /> tools:ignore="QueryAllPackagesPermission" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" /> <uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
@@ -2,6 +2,7 @@ package com.a2i.forwarder
import android.app.Application import android.app.Application
import com.a2i.forwarder.core.LogStore import com.a2i.forwarder.core.LogStore
import com.a2i.forwarder.core.PhoneCallMonitor
import com.a2i.forwarder.store.AppRulesStore import com.a2i.forwarder.store.AppRulesStore
import com.a2i.forwarder.store.SettingsStore import com.a2i.forwarder.store.SettingsStore
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
@@ -17,6 +18,8 @@ class A2iApp : Application() {
private set private set
lateinit var logStore: LogStore lateinit var logStore: LogStore
private set private set
lateinit var phoneCallMonitor: PhoneCallMonitor
private set
override fun onCreate() { override fun onCreate() {
super.onCreate() super.onCreate()
@@ -24,6 +27,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)
phoneCallMonitor = PhoneCallMonitor(this)
phoneCallMonitor.start()
} }
companion object { companion object {
@@ -122,6 +122,10 @@ class NotificationProcessor(private val context: Context) {
if (settings.adFilterEnabled.value && AdFilter.isAd("$title $content $sub", settings.adKeywords.value)) if (settings.adFilterEnabled.value && AdFilter.isAd("$title $content $sub", settings.adKeywords.value))
return Decision(null, "广告过滤", appLabel, null) 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 dedupKey = "$pkg|${title.take(80)}|${content.take(80)}"
val lastTime = dedupCache[dedupKey] val lastTime = dedupCache[dedupKey]
@@ -188,4 +192,32 @@ class NotificationProcessor(private val context: Context) {
private fun isSystemStatusMessage(text: String): Boolean { private fun isSystemStatusMessage(text: String): Boolean {
return systemStatusPatterns.any { it.containsMatchIn(text) } 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) }
}
} }
@@ -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))
)
}
}
}
}
@@ -74,8 +74,7 @@ class AppRulesStore(
"com.android.systemui", "com.android.systemui",
"com.android.settings", "com.android.settings",
"com.android.permissioncontroller", "com.android.permissioncontroller",
"com.android.phone", // 注意:com.android.phone / com.android.incallui 不在黑名单中,电话通知需要转发
"com.android.incallui",
// 输入法 // 输入法
"com.google.android.inputmethod", "com.google.android.inputmethod",
"com.google.android.inputmethod.latin", "com.google.android.inputmethod.latin",
@@ -49,7 +49,7 @@ class SettingsStore(
private val _currentServerId = MutableStateFlow(ID_OFFICIAL) private val _currentServerId = MutableStateFlow(ID_OFFICIAL)
val currentServer = MutableStateFlow<BarkServer?>(defaultServers.first()) val currentServer = MutableStateFlow<BarkServer?>(defaultServers.first())
val iconPrefix = MutableStateFlow("") val iconPrefix = MutableStateFlow(DEFAULT_ICON_PREFIX)
val forwardingEnabled = MutableStateFlow(true) val forwardingEnabled = MutableStateFlow(true)
val codeEnabled = MutableStateFlow(true) val codeEnabled = MutableStateFlow(true)
val adFilterEnabled = MutableStateFlow(true) val adFilterEnabled = MutableStateFlow(true)
@@ -66,7 +66,7 @@ class SettingsStore(
_servers.value = list _servers.value = list
_currentServerId.value = p[K.CURRENT] ?: ID_OFFICIAL _currentServerId.value = p[K.CURRENT] ?: ID_OFFICIAL
currentServer.value = list.firstOrNull { it.id == _currentServerId.value } ?: list.firstOrNull() 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 forwardingEnabled.value = p[K.FWD] ?: true
codeEnabled.value = p[K.CODE] ?: true codeEnabled.value = p[K.CODE] ?: true
adFilterEnabled.value = p[K.AD] ?: true adFilterEnabled.value = p[K.AD] ?: true
@@ -132,5 +132,6 @@ class SettingsStore(
companion object { companion object {
const val ID_OFFICIAL = "official" const val ID_OFFICIAL = "official"
const val DEFAULT_ICON_PREFIX = "https://raw.githubusercontent.com/lsxf/a2i/main/icons/"
} }
} }