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:
@@ -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 {
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
<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"
|
||||
tools:ignore="QueryAllPackagesPermission" />
|
||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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) }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.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",
|
||||
|
||||
@@ -49,7 +49,7 @@ class SettingsStore(
|
||||
private val _currentServerId = MutableStateFlow(ID_OFFICIAL)
|
||||
val currentServer = MutableStateFlow<BarkServer?>(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/"
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user