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:
@@ -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 → 权限 → 电话)。
|
||||||
|
|
||||||
## 应用图标
|
## 应用图标
|
||||||
|
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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/"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user