Compare commits
6 Commits
v1.9.4
...
c7efd8d2be
| Author | SHA1 | Date | |
|---|---|---|---|
| c7efd8d2be | |||
| e99da175b5 | |||
| 0bf759ae64 | |||
| 3ef69bc56f | |||
| ab113e5691 | |||
| bcc7ed298f |
@@ -1,8 +1,8 @@
|
||||
# a2i
|
||||
# A2i ·安到果(按倒过[呲牙])
|
||||
|
||||
把安卓手机上的通知(短信、验证码、微信、QQ、Telegram 等)实时转发到 iOS,通过 [Bark](https://github.com/Finb/Bark) 推送到 iPhone / iPad。在 iOS 上点击通知,还能一键跳转到对应的 App。
|
||||
把安卓手机上的通知(短信、验证码、电话“有电话进来这个通知,不是电话呼叫转移”、微信、QQ、Telegram 等)实时转发到 iOS,通过 [Bark](https://github.com/Finb/Bark) 推送到 iPhone / iPad。在 iOS 上点击通知,还能一键跳转到对应的 App。
|
||||
|
||||
适用于把主力安卓机的消息同步到 iOS 设备,不漏验证码、不漏重要聊天。
|
||||
适用于把安卓备机的消息同步到 iOS 设备,不漏验证码、不漏重要聊天。
|
||||
|
||||
## 主要功能
|
||||
|
||||
@@ -17,7 +17,12 @@
|
||||
- **应用级规则**:黑名单 / 白名单两种模式,可按 App 单独控制是否转发。
|
||||
- **失败自动重试**:发送失败的通知进入待重发队列,由 WorkManager 定期重试,也可手动触发。
|
||||
- **本地日志**:保留最近 300 条处理记录,方便排查为什么某条通知被过滤或发送失败。
|
||||
- **图标导出**:一键导出已安装 App 的图标,便于部署到 Bark 图床,让通知带上来源图标。
|
||||
- **电话来电通知**:通过轮询 `getCallState()` 实时感知来电和未接来电(绕过 MIUI 对系统电话通知的限制,需授予"电话"权限)。
|
||||
- **断网短信兜底**:无网络时自动把验证码、来电用短信发到 iPhone。
|
||||
- 自动识别运营商(移动/联通/电信/广电),发免费查询短信拿套餐内短信余量
|
||||
- 余额 ≤5 条自动停用,下次打开 App 提醒
|
||||
- 每 5 分钟最多发 1 条,避免刷屏
|
||||
- 自动解析失败时回退手动额度计数
|
||||
|
||||
## 快速开始
|
||||
|
||||
@@ -50,6 +55,8 @@
|
||||
- **想同步聊天**:保持黑名单模式(默认),在「过滤」页开启「微信 / QQ / Telegram 优化」。
|
||||
- **通知没推过来**:先看「日志」页,每条通知(无论转发、过滤还是失败)都会记录原因。
|
||||
- **想让通知带图标**:参考下方「应用图标」一节。
|
||||
- **电话通知不工作**:MIUI 会把系统电话通知限制为不向第三方通知监听器分发,本 App 通过轮询 `getCallonyState()` 绕过此限制。需要授予「电话」权限(设置 → 应用 → a2i → 权限 → 电话)。
|
||||
- **断网收不到通知**:开启「设置 → 断网短信兜底」,填好 iPhone 号码、授予短信权限、设好手动额度。断网时验证码和来电会走短信(消耗套餐额度,5 分钟限 1 条)。
|
||||
|
||||
## 应用图标
|
||||
|
||||
|
||||
@@ -13,8 +13,8 @@ android {
|
||||
applicationId = "com.a2i.forwarder"
|
||||
minSdk = 34
|
||||
targetSdk = 36
|
||||
versionCode = 3
|
||||
versionName = "1.2.0"
|
||||
versionCode = 5
|
||||
versionName = "1.4.0"
|
||||
}
|
||||
|
||||
signingConfigs {
|
||||
|
||||
@@ -4,6 +4,8 @@
|
||||
|
||||
<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.SEND_SMS" />
|
||||
<uses-permission android:name="android.permission.QUERY_ALL_PACKAGES"
|
||||
tools:ignore="QueryAllPackagesPermission" />
|
||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||
|
||||
@@ -1,7 +1,12 @@
|
||||
package com.a2i.forwarder
|
||||
|
||||
import android.app.Application
|
||||
import com.a2i.forwarder.core.CarrierBalanceQuery
|
||||
import com.a2i.forwarder.core.CarrierDetector
|
||||
import com.a2i.forwarder.core.ConnectivityMonitor
|
||||
import com.a2i.forwarder.core.LogStore
|
||||
import com.a2i.forwarder.core.PhoneCallMonitor
|
||||
import com.a2i.forwarder.core.SmsFallbackForwarder
|
||||
import com.a2i.forwarder.store.AppRulesStore
|
||||
import com.a2i.forwarder.store.SettingsStore
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
@@ -17,6 +22,14 @@ class A2iApp : Application() {
|
||||
private set
|
||||
lateinit var logStore: LogStore
|
||||
private set
|
||||
lateinit var phoneCallMonitor: PhoneCallMonitor
|
||||
private set
|
||||
lateinit var connectivityMonitor: ConnectivityMonitor
|
||||
private set
|
||||
lateinit var carrierBalanceQuery: CarrierBalanceQuery
|
||||
private set
|
||||
lateinit var smsForwarder: SmsFallbackForwarder
|
||||
private set
|
||||
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
@@ -24,6 +37,12 @@ class A2iApp : Application() {
|
||||
settings = SettingsStore(this, appScope)
|
||||
appRules = AppRulesStore(this, appScope)
|
||||
logStore = LogStore(this, appScope)
|
||||
phoneCallMonitor = PhoneCallMonitor(this)
|
||||
phoneCallMonitor.start()
|
||||
connectivityMonitor = ConnectivityMonitor(this)
|
||||
connectivityMonitor.start()
|
||||
carrierBalanceQuery = CarrierBalanceQuery(this)
|
||||
smsForwarder = SmsFallbackForwarder(this)
|
||||
}
|
||||
|
||||
companion object {
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
package com.a2i.forwarder.core
|
||||
|
||||
import android.Manifest
|
||||
import android.content.Context
|
||||
import android.content.pm.PackageManager
|
||||
import android.telephony.SmsManager
|
||||
import androidx.core.content.ContextCompat
|
||||
import com.a2i.forwarder.A2iApp
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
/**
|
||||
* 运营商短信余额查询:
|
||||
* - refresh():向运营商服务号发免费查询短信(若运营商支持),回执由 NotificationProcessor 解析。
|
||||
* - 手动模式:递减 smsManualQuota 作为余额。
|
||||
* - parseReply():从运营商回执短信正文中提取剩余短信条数,供 NotificationProcessor 调用。
|
||||
*/
|
||||
class CarrierBalanceQuery(private val context: Context) {
|
||||
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
||||
|
||||
private val settings get() = A2iApp.instance.settings
|
||||
|
||||
/** 触发一次余额查询(自动模式发短信,手动模式递减额度)。 */
|
||||
suspend fun refresh() {
|
||||
val carrier = currentCarrier()
|
||||
// 自动模式:发查询短信到运营商(免费,不走限频)
|
||||
if (carrier.serviceNumber != null && carrier.queryCmd != null) {
|
||||
sendSms(carrier.serviceNumber, carrier.queryCmd)
|
||||
return
|
||||
}
|
||||
// 手动模式:余额 = 手动额度,不额外查询
|
||||
}
|
||||
|
||||
/** 转发成功后扣减余额(手动模式 / 自动解析失败时)。 */
|
||||
suspend fun decrementManual() {
|
||||
val q = settings.smsManualQuota.value
|
||||
if (q > 0) {
|
||||
val newQ = q - 1
|
||||
settings.setSmsManualQuota(newQ)
|
||||
settings.setSmsBalance(newQ)
|
||||
checkSuspend(newQ)
|
||||
}
|
||||
}
|
||||
|
||||
/** 解析运营商回执,提取剩余短信条数。返回 null 表示解析失败。供 NotificationProcessor 调用。 */
|
||||
fun parseReply(text: String): Int? {
|
||||
// 匹配 "短信剩余XX条" / "短信:XX条" / "短信余量XX条" 等
|
||||
val patterns = listOf(
|
||||
Regex("""短信[^0-9]*(\d+)\s*条"""),
|
||||
Regex("""短信余[额度][^0-9]*(\d+)"""),
|
||||
)
|
||||
for (p in patterns) {
|
||||
val m = p.find(text) ?: continue
|
||||
val n = m.groupValues.getOrNull(1)?.toIntOrNull() ?: continue
|
||||
if (n in 0..9999) return n
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/** 回执解析成功后更新余额并检查是否需要挂起。 */
|
||||
suspend fun applyParsedBalance(count: Int) {
|
||||
settings.setSmsBalance(count)
|
||||
checkSuspend(count)
|
||||
}
|
||||
|
||||
private suspend fun checkSuspend(balance: Int) {
|
||||
if (balance in 0..5 && !settings.smsSuspended.value) {
|
||||
settings.setSmsSuspended(true)
|
||||
}
|
||||
}
|
||||
|
||||
fun currentCarrier(): CarrierDetector.Carrier {
|
||||
val stored = settings.smsCarrier.value
|
||||
return if (stored == "auto") CarrierDetector.detect(context)
|
||||
else CarrierDetector.Carrier.fromName(stored)
|
||||
}
|
||||
|
||||
private fun sendSms(dest: String, body: String) {
|
||||
if (ContextCompat.checkSelfPermission(context, Manifest.permission.SEND_SMS)
|
||||
!= PackageManager.PERMISSION_GRANTED
|
||||
) return
|
||||
scope.launch {
|
||||
runCatching {
|
||||
SmsManager.getDefault().sendTextMessage(dest, null, body, null, null)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
package com.a2i.forwarder.core
|
||||
|
||||
import android.content.Context
|
||||
import android.os.Build
|
||||
import android.telephony.TelephonyManager
|
||||
|
||||
/**
|
||||
* 运营商检测:根据 SIM 的 MCC+MNC 映射到国内运营商。
|
||||
* 提供余额查询用的服务号 + 查询指令。
|
||||
*/
|
||||
object CarrierDetector {
|
||||
|
||||
enum class Carrier(
|
||||
val displayName: String,
|
||||
val serviceNumber: String?, // 余额查询服务号(免费)
|
||||
val queryCmd: String?, // 查询指令(发送到此服务号)
|
||||
) {
|
||||
CHINA_MOBILE("中国移动", "10086", "CXDX"),
|
||||
CHINA_UNICOM("中国联通", "10010", "CXTC"),
|
||||
CHINA_TELECOM("中国电信", "10001", "108"),
|
||||
CHINA_BROADCASTING("中国广电", "10099", null), // 无统一查询指令
|
||||
UNKNOWN("未知运营商", null, null);
|
||||
|
||||
companion object {
|
||||
fun fromName(name: String?): Carrier =
|
||||
entries.firstOrNull { it.name == name } ?: UNKNOWN
|
||||
}
|
||||
}
|
||||
|
||||
// MCC+MNC → 运营商(中国大陆 460)
|
||||
private val mncMap = mapOf(
|
||||
// 中国移动
|
||||
"46000" to Carrier.CHINA_MOBILE, "46002" to Carrier.CHINA_MOBILE,
|
||||
"46004" to Carrier.CHINA_MOBILE, "46007" to Carrier.CHINA_MOBILE,
|
||||
// 中国联通
|
||||
"46001" to Carrier.CHINA_UNICOM, "46006" to Carrier.CHINA_UNICOM,
|
||||
"46009" to Carrier.CHINA_UNICOM,
|
||||
// 中国电信
|
||||
"46003" to Carrier.CHINA_TELECOM, "46005" to Carrier.CHINA_TELECOM,
|
||||
"46011" to Carrier.CHINA_TELECOM, "46012" to Carrier.CHINA_TELECOM,
|
||||
// 中国广电
|
||||
"46015" to Carrier.CHINA_BROADCASTING, "46018" to Carrier.CHINA_BROADCASTING,
|
||||
)
|
||||
|
||||
fun detect(context: Context): Carrier {
|
||||
return runCatching {
|
||||
val tm = context.getSystemService(Context.TELEPHONY_SERVICE) as? TelephonyManager
|
||||
?: return Carrier.UNKNOWN
|
||||
val simOperator = tm.simOperator // 形如 "46000"
|
||||
if (simOperator.isNullOrBlank() || simOperator.length < 5) return Carrier.UNKNOWN
|
||||
mncMap[simOperator] ?: Carrier.UNKNOWN
|
||||
}.getOrDefault(Carrier.UNKNOWN)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
package com.a2i.forwarder.core
|
||||
|
||||
import android.content.Context
|
||||
import android.net.ConnectivityManager
|
||||
import android.net.Network
|
||||
import android.net.NetworkCapabilities
|
||||
import android.net.NetworkRequest
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
|
||||
/**
|
||||
* 监听网络连通性(是否能访问互联网)。
|
||||
* 暴露 isOnline: StateFlow<Boolean>,断网时短信兜底转发器据此降级。
|
||||
*/
|
||||
class ConnectivityMonitor(private val context: Context) {
|
||||
private val _isOnline = MutableStateFlow(false)
|
||||
val isOnline = _isOnline.asStateFlow()
|
||||
|
||||
private var registered = false
|
||||
|
||||
private val callback = object : ConnectivityManager.NetworkCallback() {
|
||||
override fun onAvailable(network: Network) { refresh() }
|
||||
override fun onLost(network: Network) { refresh() }
|
||||
override fun onCapabilitiesChanged(network: Network, caps: NetworkCapabilities) { refresh() }
|
||||
}
|
||||
|
||||
fun start() {
|
||||
if (registered) return
|
||||
val cm = context.getSystemService(Context.CONNECTIVITY_SERVICE) as? ConnectivityManager
|
||||
?: return
|
||||
val request = NetworkRequest.Builder()
|
||||
.addCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)
|
||||
.build()
|
||||
runCatching { cm.registerNetworkCallback(request, callback) }
|
||||
registered = true
|
||||
refresh()
|
||||
}
|
||||
|
||||
fun stop() {
|
||||
if (!registered) return
|
||||
val cm = context.getSystemService(Context.CONNECTIVITY_SERVICE) as? ConnectivityManager
|
||||
runCatching { cm?.unregisterNetworkCallback(callback) }
|
||||
registered = false
|
||||
}
|
||||
|
||||
private fun refresh() {
|
||||
val cm = context.getSystemService(Context.CONNECTIVITY_SERVICE) as? ConnectivityManager ?: return
|
||||
val active = cm.activeNetwork
|
||||
val caps = active?.let { cm.getNetworkCapabilities(it) }
|
||||
_isOnline.value = caps != null &&
|
||||
caps.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) &&
|
||||
caps.hasCapability(NetworkCapabilities.NET_CAPABILITY_VALIDATED)
|
||||
}
|
||||
}
|
||||
@@ -122,6 +122,16 @@ 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 balanceParsed = tryParseCarrierBalance(pkg, title, content)
|
||||
if (balanceParsed != null) {
|
||||
return Decision(null, "运营商余额回执(${balanceParsed}条)", appLabel, null)
|
||||
}
|
||||
|
||||
// ---- 去重检查 ----
|
||||
val dedupKey = "$pkg|${title.take(80)}|${content.take(80)}"
|
||||
val lastTime = dedupCache[dedupKey]
|
||||
@@ -188,4 +198,47 @@ 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) }
|
||||
}
|
||||
|
||||
/**
|
||||
* 拦截运营商余额回执短信:title 是运营商服务号(10086/10010/10001/10099),
|
||||
* 解析正文中的剩余短信条数,写入设置并按"过滤"处理。
|
||||
* 返回解析到的条数(已处理),返回 null 表示不是回执或解析失败。
|
||||
*/
|
||||
private fun tryParseCarrierBalance(pkg: String, title: String, content: String): Int? {
|
||||
if (pkg !in smsPackages) return null
|
||||
val serviceNumbers = setOf("10086", "10010", "10001", "10099")
|
||||
if (title.trim() !in serviceNumbers) return null
|
||||
val app = A2iApp.instance
|
||||
val count = app.carrierBalanceQuery.parseReply("$title $content") ?: return null
|
||||
kotlinx.coroutines.runBlocking { app.carrierBalanceQuery.applyParsedBalance(count) }
|
||||
return count
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
package com.a2i.forwarder.core
|
||||
|
||||
import android.Manifest
|
||||
import android.content.Context
|
||||
import android.content.pm.PackageManager
|
||||
import android.telephony.SmsManager
|
||||
import androidx.core.content.ContextCompat
|
||||
import com.a2i.forwarder.A2iApp
|
||||
import com.a2i.forwarder.core.NotificationProcessor.Decision
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
/**
|
||||
* 短信兜底转发器:断网时把紧要通知(验证码、来电)用短信发到 iPhone。
|
||||
* 限频:每 5 分钟最多 1 条转发短信。余额 ≤5 时自动挂起。
|
||||
*/
|
||||
class SmsFallbackForwarder(private val context: Context) {
|
||||
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
||||
private val RATE_LIMIT_MS = 5 * 60 * 1000L
|
||||
|
||||
private val app get() = A2iApp.instance
|
||||
private val settings get() = app.settings
|
||||
|
||||
/** 判断该通知是否值得走短信(断网资源宝贵)。 */
|
||||
fun isSmsWorthy(d: Decision): Boolean {
|
||||
if (d.code != null) return true // 验证码
|
||||
val msg = d.message ?: return false
|
||||
if (msg.group == "phone_call") return true // 来电/未接来电
|
||||
val t = msg.title.orEmpty()
|
||||
return t.contains("来电") || t.contains("未接")
|
||||
}
|
||||
|
||||
/** 尝试用短信转发。返回 true 表示已处理(无论成功失败)。 */
|
||||
fun forward(d: Decision) {
|
||||
scope.launch {
|
||||
if (!settings.smsFallbackEnabled.value) return@launch
|
||||
if (settings.smsSuspended.value) {
|
||||
log(d, "failed", "短信已挂起(余额不足)")
|
||||
return@launch
|
||||
}
|
||||
if (!isSmsWorthy(d)) return@launch
|
||||
val target = settings.smsTargetNumber.value.trim()
|
||||
if (target.isBlank()) {
|
||||
log(d, "failed", "未设置短信目标号")
|
||||
return@launch
|
||||
}
|
||||
// 限频
|
||||
val now = System.currentTimeMillis()
|
||||
val elapsed = now - settings.lastSmsSentTime.value
|
||||
if (elapsed < RATE_LIMIT_MS) {
|
||||
log(d, "filtered", "限频(${RATE_LIMIT_MS - elapsed}ms 内)")
|
||||
return@launch
|
||||
}
|
||||
// 权限
|
||||
if (ContextCompat.checkSelfPermission(context, Manifest.permission.SEND_SMS)
|
||||
!= PackageManager.PERMISSION_GRANTED
|
||||
) {
|
||||
log(d, "failed", "无 SEND_SMS 权限")
|
||||
return@launch
|
||||
}
|
||||
|
||||
val body = compose(d).take(70)
|
||||
val result = runCatching {
|
||||
SmsManager.getDefault().sendTextMessage(target, null, body, null, null)
|
||||
}
|
||||
if (result.isSuccess) {
|
||||
settings.setLastSmsSentTime(now)
|
||||
log(d, "sent", "")
|
||||
// 转发后刷新余额:自动模式发查询短信,手动模式递减
|
||||
val carrier = app.carrierBalanceQuery.currentCarrier()
|
||||
if (carrier.serviceNumber != null && carrier.queryCmd != null) {
|
||||
app.carrierBalanceQuery.refresh()
|
||||
} else {
|
||||
app.carrierBalanceQuery.decrementManual()
|
||||
}
|
||||
} else {
|
||||
val reason = result.exceptionOrNull()?.message ?: "短信发送失败"
|
||||
log(d, "failed", reason)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun compose(d: Decision): String {
|
||||
val code = d.code
|
||||
return if (code != null) {
|
||||
"[验证码]${d.appLabel}: $code"
|
||||
} else {
|
||||
val m = d.message!!
|
||||
"${m.title.orEmpty().ifBlank { d.appLabel }}: ${m.body}".trim()
|
||||
}
|
||||
}
|
||||
|
||||
private fun log(d: Decision, status: String, reason: String) {
|
||||
scope.launch {
|
||||
val now = System.currentTimeMillis()
|
||||
val title = d.code?.let { "验证码" } ?: d.message?.title ?: d.appLabel
|
||||
val content = d.code ?: d.message?.body ?: ""
|
||||
app.logStore.addLog(
|
||||
ForwardLog(now, "sms_fallback", d.appLabel, title, content, status, reason)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -44,6 +44,17 @@ class NotifyListenerService : NotificationListenerService() {
|
||||
return
|
||||
}
|
||||
|
||||
// ---- 断网兜底:离线时紧要通知走短信,其余不转发避免离线堆积 ----
|
||||
val online = app.connectivityMonitor.isOnline.value
|
||||
if (!online) {
|
||||
if (app.smsForwarder.isSmsWorthy(d)) {
|
||||
app.smsForwarder.forward(d)
|
||||
} else {
|
||||
app.logStore.addLog(ForwardLog(now, sbn.packageName, d.appLabel, msg.title, msg.body, "filtered", "离线非紧要通知"))
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
val server = app.settings.currentServer.value
|
||||
if (server == null || server.deviceKey.isBlank()) {
|
||||
app.logStore.addLog(ForwardLog(now, sbn.packageName, d.appLabel, msg.title, msg.body, "failed", "未配置 Bark key"))
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -5,6 +5,8 @@ import androidx.datastore.core.DataStore
|
||||
import androidx.datastore.preferences.core.Preferences
|
||||
import androidx.datastore.preferences.core.booleanPreferencesKey
|
||||
import androidx.datastore.preferences.core.edit
|
||||
import androidx.datastore.preferences.core.intPreferencesKey
|
||||
import androidx.datastore.preferences.core.longPreferencesKey
|
||||
import androidx.datastore.preferences.core.stringPreferencesKey
|
||||
import androidx.datastore.preferences.preferencesDataStore
|
||||
import com.a2i.forwarder.model.BarkServer
|
||||
@@ -37,6 +39,14 @@ class SettingsStore(
|
||||
val SPECIAL = booleanPreferencesKey("special")
|
||||
val SKIP_ONGOING = booleanPreferencesKey("skip_ongoing")
|
||||
val AD_KW = stringPreferencesKey("ad_keywords")
|
||||
// 短信兜底
|
||||
val SMS_FALLBACK = booleanPreferencesKey("sms_fallback")
|
||||
val SMS_TARGET = stringPreferencesKey("sms_target")
|
||||
val SMS_CARRIER = stringPreferencesKey("sms_carrier")
|
||||
val SMS_QUOTA = intPreferencesKey("sms_quota")
|
||||
val SMS_BALANCE = intPreferencesKey("sms_balance")
|
||||
val SMS_SUSPENDED = booleanPreferencesKey("sms_suspended")
|
||||
val SMS_LAST_SENT = longPreferencesKey("sms_last_sent")
|
||||
}
|
||||
|
||||
private val defaultServers = listOf(
|
||||
@@ -49,7 +59,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)
|
||||
@@ -57,6 +67,15 @@ class SettingsStore(
|
||||
val skipOngoing = MutableStateFlow(true)
|
||||
val adKeywords = MutableStateFlow<List<String>>(emptyList()) // 用户自定义关键词
|
||||
|
||||
// 短信兜底
|
||||
val smsFallbackEnabled = MutableStateFlow(false)
|
||||
val smsTargetNumber = MutableStateFlow("")
|
||||
val smsCarrier = MutableStateFlow("auto") // "auto" 或 Carrier.name
|
||||
val smsManualQuota = MutableStateFlow(0) // 手动额度(自动解析失败时回退用)
|
||||
val smsBalance = MutableStateFlow(-1) // 缓存余额,-1=未知
|
||||
val smsSuspended = MutableStateFlow(false) // 余额 ≤5 自动挂起
|
||||
val lastSmsSentTime = MutableStateFlow(0L) // 限频时间戳
|
||||
|
||||
init {
|
||||
scope.launch {
|
||||
ds.data.collect { p ->
|
||||
@@ -66,7 +85,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
|
||||
@@ -75,6 +94,14 @@ class SettingsStore(
|
||||
adKeywords.value = p[K.AD_KW]
|
||||
?.let { runCatching { json.decodeFromString<List<String>>(it) }.getOrNull() }
|
||||
?: emptyList()
|
||||
// 短信兜底
|
||||
smsFallbackEnabled.value = p[K.SMS_FALLBACK] ?: false
|
||||
smsTargetNumber.value = p[K.SMS_TARGET] ?: ""
|
||||
smsCarrier.value = p[K.SMS_CARRIER] ?: "auto"
|
||||
smsManualQuota.value = p[K.SMS_QUOTA] ?: 0
|
||||
smsBalance.value = p[K.SMS_BALANCE] ?: -1
|
||||
smsSuspended.value = p[K.SMS_SUSPENDED] ?: false
|
||||
lastSmsSentTime.value = p[K.SMS_LAST_SENT] ?: 0L
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -128,9 +155,18 @@ class SettingsStore(
|
||||
adKeywords.value = list
|
||||
}
|
||||
|
||||
suspend fun setSmsFallback(v: Boolean) { edit { it[K.SMS_FALLBACK] = v }; smsFallbackEnabled.value = v }
|
||||
suspend fun setSmsTargetNumber(v: String) { edit { it[K.SMS_TARGET] = v }; smsTargetNumber.value = v }
|
||||
suspend fun setSmsCarrier(v: String) { edit { it[K.SMS_CARRIER] = v }; smsCarrier.value = v }
|
||||
suspend fun setSmsManualQuota(v: Int) { edit { it[K.SMS_QUOTA] = v }; smsManualQuota.value = v }
|
||||
suspend fun setSmsBalance(v: Int) { edit { it[K.SMS_BALANCE] = v }; smsBalance.value = v }
|
||||
suspend fun setSmsSuspended(v: Boolean) { edit { it[K.SMS_SUSPENDED] = v }; smsSuspended.value = v }
|
||||
suspend fun setLastSmsSentTime(v: Long) { edit { it[K.SMS_LAST_SENT] = v }; lastSmsSentTime.value = v }
|
||||
|
||||
suspend fun snapshot() = ds.data.first()
|
||||
|
||||
companion object {
|
||||
const val ID_OFFICIAL = "official"
|
||||
const val DEFAULT_ICON_PREFIX = "https://raw.githubusercontent.com/lsxf/a2i/main/icons/"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -65,6 +65,7 @@ fun HomeScreen(onOpenLog: () -> Unit, onOpenSettings: () -> Unit) {
|
||||
val sent by app.logStore.sentCount.collectAsState()
|
||||
val filtered by app.logStore.filteredCount.collectAsState()
|
||||
val failed by app.logStore.failedCount.collectAsState()
|
||||
val smsSuspended by app.settings.smsSuspended.collectAsState()
|
||||
|
||||
var granted by remember { mutableStateOf(isNotificationListenerEnabled(context)) }
|
||||
val lifecycleOwner = LocalLifecycleOwner.current
|
||||
@@ -89,6 +90,28 @@ fun HomeScreen(onOpenLog: () -> Unit, onOpenSettings: () -> Unit) {
|
||||
)
|
||||
}
|
||||
|
||||
if (smsSuspended) {
|
||||
item {
|
||||
SectionCard {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
IconBox(
|
||||
icon = Icons.Filled.WarningAmber,
|
||||
tint = MaterialTheme.colorScheme.error,
|
||||
)
|
||||
Spacer(Modifier.width(12.dp))
|
||||
Column(Modifier.weight(1f)) {
|
||||
Text("短信兜底已停用", style = MaterialTheme.typography.titleMedium)
|
||||
Text(
|
||||
"短信余额不足,断网转发已关闭。请在「设置 → 断网短信兜底」补充额度",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
item {
|
||||
SectionCard {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
|
||||
@@ -80,6 +80,23 @@ fun SettingsScreen(onOpenLog: () -> Unit) {
|
||||
var showAdd by remember { mutableStateOf(false) }
|
||||
var editing by remember { mutableStateOf<BarkServer?>(null) }
|
||||
|
||||
// 短信兜底状态
|
||||
val smsFallback by app.settings.smsFallbackEnabled.collectAsState()
|
||||
val smsTarget by app.settings.smsTargetNumber.collectAsState()
|
||||
val smsCarrier by app.settings.smsCarrier.collectAsState()
|
||||
val smsQuota by app.settings.smsManualQuota.collectAsState()
|
||||
val smsBalance by app.settings.smsBalance.collectAsState()
|
||||
val smsSuspended by app.settings.smsSuspended.collectAsState()
|
||||
var smsTargetInput by remember(smsTarget) { mutableStateOf(smsTarget) }
|
||||
var smsQuotaInput by remember(smsQuota) { mutableStateOf(if (smsQuota == 0) "" else smsQuota.toString()) }
|
||||
val detectedCarrier = remember { com.a2i.forwarder.core.CarrierDetector.detect(context) }
|
||||
|
||||
val smsPermLauncher = rememberLauncherForActivityResult(ActivityResultContracts.RequestPermission()) { }
|
||||
val hasSmsPerm = remember {
|
||||
androidx.core.content.ContextCompat.checkSelfPermission(context, android.Manifest.permission.SEND_SMS) ==
|
||||
android.content.pm.PackageManager.PERMISSION_GRANTED
|
||||
}
|
||||
|
||||
val treeLauncher = rememberLauncherForActivityResult(ActivityResultContracts.OpenDocumentTree()) { uri ->
|
||||
if (uri != null) {
|
||||
runCatching {
|
||||
@@ -197,6 +214,75 @@ fun SettingsScreen(onOpenLog: () -> Unit) {
|
||||
}
|
||||
}
|
||||
|
||||
item {
|
||||
SectionCard("断网短信兜底", subtitle = "无网络时把验证码、来电用短信发到 iPhone(每5分钟1条)") {
|
||||
com.a2i.forwarder.ui.SwitchRow(
|
||||
title = "启用短信兜底",
|
||||
subtitle = if (smsFallback) "断网时紧要通知走短信" else "当前关闭",
|
||||
checked = smsFallback,
|
||||
) { v -> app.appScope.launch { app.settings.setSmsFallback(v) } }
|
||||
|
||||
Spacer(Modifier.size(8.dp))
|
||||
OutlinedTextField(
|
||||
value = smsTargetInput,
|
||||
onValueChange = { smsTargetInput = it },
|
||||
label = { Text("iPhone 手机号") },
|
||||
supportingText = { Text("短信转发的目标号码") },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
singleLine = true,
|
||||
)
|
||||
Spacer(Modifier.size(4.dp))
|
||||
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
OutlinedButton(onClick = {
|
||||
app.appScope.launch { app.settings.setSmsTargetNumber(smsTargetInput.trim()) }
|
||||
}, modifier = Modifier.weight(1f)) { Text("保存号码") }
|
||||
if (!hasSmsPerm) {
|
||||
OutlinedButton(onClick = {
|
||||
smsPermLauncher.launch(android.Manifest.permission.SEND_SMS)
|
||||
}, modifier = Modifier.weight(1f)) { Text("授予短信权限") }
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(Modifier.size(8.dp))
|
||||
Text(
|
||||
"检测到运营商:${detectedCarrier.displayName}" +
|
||||
if (detectedCarrier.serviceNumber == null) "(无自动查询,将用手动额度)" else "(${detectedCarrier.serviceNumber})",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
Spacer(Modifier.size(8.dp))
|
||||
OutlinedTextField(
|
||||
value = smsQuotaInput,
|
||||
onValueChange = { smsQuotaInput = it.filter { c -> c.isDigit() } },
|
||||
label = { Text("手动短信额度") },
|
||||
supportingText = { Text("自动查询失败时按此递减;余额≤5自动停用") },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
singleLine = true,
|
||||
)
|
||||
Spacer(Modifier.size(4.dp))
|
||||
OutlinedButton(onClick = {
|
||||
val q = smsQuotaInput.trim().toIntOrNull() ?: 0
|
||||
app.appScope.launch {
|
||||
app.settings.setSmsManualQuota(q)
|
||||
app.settings.setSmsBalance(q)
|
||||
if (q > 5) app.settings.setSmsSuspended(false)
|
||||
}
|
||||
}) { Text("保存额度并解除挂起") }
|
||||
|
||||
Spacer(Modifier.size(8.dp))
|
||||
val balanceText = when {
|
||||
smsSuspended -> "已挂起(余额不足)"
|
||||
smsBalance < 0 -> "余额未知"
|
||||
else -> "剩余约 ${smsBalance} 条"
|
||||
}
|
||||
Text(
|
||||
"当前状态:$balanceText",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = if (smsSuspended) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
item {
|
||||
SectionCard("维护", subtitle = "日志查看和本地统计清理") {
|
||||
ClickRow("转发日志", "查看 / 清空最近记录", icon = Icons.Filled.History, onClick = onOpenLog)
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 6.8 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 31 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 7.1 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 2.0 KiB |
Reference in New Issue
Block a user