feat: offline SMS fallback for important notifications

断网时把验证码、来电用短信发到 iPhone:
- ConnectivityMonitor: NetworkCallback 监听在线状态
- CarrierDetector: SIM MCC+MNC 映射移动/联通/电信/广电
- CarrierBalanceQuery: 发免费查询短信拿套餐短信余量,解析回执
- SmsFallbackForwarder: 限频(5min)/余额(≤5挂起)/紧要性检查
- NotificationProcessor 拦截运营商回执(10086/10010/10001/10099)
- 设置页新增短信兜底卡片,首页余额耗尽警告

版本 1.3.0 → 1.4.0

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-08 08:54:37 +08:00
parent 0bf759ae64
commit e99da175b5
13 changed files with 502 additions and 2 deletions
+6
View File
@@ -18,6 +18,11 @@
- **失败自动重试**:发送失败的通知进入待重发队列,由 WorkManager 定期重试,也可手动触发。 - **失败自动重试**:发送失败的通知进入待重发队列,由 WorkManager 定期重试,也可手动触发。
- **本地日志**:保留最近 300 条处理记录,方便排查为什么某条通知被过滤或发送失败。 - **本地日志**:保留最近 300 条处理记录,方便排查为什么某条通知被过滤或发送失败。
- **电话来电通知**:通过轮询 `getCallState()` 实时感知来电和未接来电(绕过 MIUI 对系统电话通知的限制,需授予"电话"权限)。 - **电话来电通知**:通过轮询 `getCallState()` 实时感知来电和未接来电(绕过 MIUI 对系统电话通知的限制,需授予"电话"权限)。
- **断网短信兜底**:无网络时自动把验证码、来电用短信发到 iPhone。
- 自动识别运营商(移动/联通/电信/广电),发免费查询短信拿套餐内短信余量
- 余额 ≤5 条自动停用,下次打开 App 提醒
- 每 5 分钟最多发 1 条,避免刷屏
- 自动解析失败时回退手动额度计数
## 快速开始 ## 快速开始
@@ -51,6 +56,7 @@
- **通知没推过来**:先看「日志」页,每条通知(无论转发、过滤还是失败)都会记录原因。 - **通知没推过来**:先看「日志」页,每条通知(无论转发、过滤还是失败)都会记录原因。
- **想让通知带图标**:参考下方「应用图标」一节。 - **想让通知带图标**:参考下方「应用图标」一节。
- **电话通知不工作**:MIUI 会把系统电话通知限制为不向第三方通知监听器分发,本 App 通过轮询 `getCallonyState()` 绕过此限制。需要授予「电话」权限(设置 → 应用 → a2i → 权限 → 电话)。 - **电话通知不工作**:MIUI 会把系统电话通知限制为不向第三方通知监听器分发,本 App 通过轮询 `getCallonyState()` 绕过此限制。需要授予「电话」权限(设置 → 应用 → a2i → 权限 → 电话)。
- **断网收不到通知**:开启「设置 → 断网短信兜底」,填好 iPhone 号码、授予短信权限、设好手动额度。断网时验证码和来电会走短信(消耗套餐额度,5 分钟限 1 条)。
## 应用图标 ## 应用图标
+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 = 4 versionCode = 5
versionName = "1.3.0" versionName = "1.4.0"
} }
signingConfigs { signingConfigs {
+1
View File
@@ -5,6 +5,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.READ_PHONE_STATE" />
<uses-permission android:name="android.permission.SEND_SMS" />
<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" />
@@ -1,8 +1,12 @@
package com.a2i.forwarder package com.a2i.forwarder
import android.app.Application 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.LogStore
import com.a2i.forwarder.core.PhoneCallMonitor import com.a2i.forwarder.core.PhoneCallMonitor
import com.a2i.forwarder.core.SmsFallbackForwarder
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
@@ -20,6 +24,12 @@ class A2iApp : Application() {
private set private set
lateinit var phoneCallMonitor: PhoneCallMonitor lateinit var phoneCallMonitor: PhoneCallMonitor
private set private set
lateinit var connectivityMonitor: ConnectivityMonitor
private set
lateinit var carrierBalanceQuery: CarrierBalanceQuery
private set
lateinit var smsForwarder: SmsFallbackForwarder
private set
override fun onCreate() { override fun onCreate() {
super.onCreate() super.onCreate()
@@ -29,6 +39,10 @@ class A2iApp : Application() {
logStore = LogStore(this, appScope) logStore = LogStore(this, appScope)
phoneCallMonitor = PhoneCallMonitor(this) phoneCallMonitor = PhoneCallMonitor(this)
phoneCallMonitor.start() phoneCallMonitor.start()
connectivityMonitor = ConnectivityMonitor(this)
connectivityMonitor.start()
carrierBalanceQuery = CarrierBalanceQuery(this)
smsForwarder = SmsFallbackForwarder(this)
} }
companion object { 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)
}
}
@@ -126,6 +126,12 @@ class NotificationProcessor(private val context: Context) {
if (isSentSmsNotification(pkg, title, content)) if (isSentSmsNotification(pkg, title, content))
return Decision(null, "已发短信(仅收不发)", appLabel, null) 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 dedupKey = "$pkg|${title.take(80)}|${content.take(80)}"
val lastTime = dedupCache[dedupKey] val lastTime = dedupCache[dedupKey]
@@ -220,4 +226,19 @@ class NotificationProcessor(private val context: Context) {
val blob = "$title $content" val blob = "$title $content"
return sentSmsPatterns.any { it.containsMatchIn(blob) } 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,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 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 val server = app.settings.currentServer.value
if (server == null || server.deviceKey.isBlank()) { if (server == null || server.deviceKey.isBlank()) {
app.logStore.addLog(ForwardLog(now, sbn.packageName, d.appLabel, msg.title, msg.body, "failed", "未配置 Bark key")) app.logStore.addLog(ForwardLog(now, sbn.packageName, d.appLabel, msg.title, msg.body, "failed", "未配置 Bark key"))
@@ -5,6 +5,8 @@ import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.Preferences import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.booleanPreferencesKey import androidx.datastore.preferences.core.booleanPreferencesKey
import androidx.datastore.preferences.core.edit 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.core.stringPreferencesKey
import androidx.datastore.preferences.preferencesDataStore import androidx.datastore.preferences.preferencesDataStore
import com.a2i.forwarder.model.BarkServer import com.a2i.forwarder.model.BarkServer
@@ -37,6 +39,14 @@ class SettingsStore(
val SPECIAL = booleanPreferencesKey("special") val SPECIAL = booleanPreferencesKey("special")
val SKIP_ONGOING = booleanPreferencesKey("skip_ongoing") val SKIP_ONGOING = booleanPreferencesKey("skip_ongoing")
val AD_KW = stringPreferencesKey("ad_keywords") 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( private val defaultServers = listOf(
@@ -57,6 +67,15 @@ class SettingsStore(
val skipOngoing = MutableStateFlow(true) val skipOngoing = MutableStateFlow(true)
val adKeywords = MutableStateFlow<List<String>>(emptyList()) // 用户自定义关键词 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 { init {
scope.launch { scope.launch {
ds.data.collect { p -> ds.data.collect { p ->
@@ -75,6 +94,14 @@ class SettingsStore(
adKeywords.value = p[K.AD_KW] adKeywords.value = p[K.AD_KW]
?.let { runCatching { json.decodeFromString<List<String>>(it) }.getOrNull() } ?.let { runCatching { json.decodeFromString<List<String>>(it) }.getOrNull() }
?: emptyList() ?: 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,6 +155,14 @@ class SettingsStore(
adKeywords.value = list 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() suspend fun snapshot() = ds.data.first()
companion object { companion object {
@@ -65,6 +65,7 @@ fun HomeScreen(onOpenLog: () -> Unit, onOpenSettings: () -> Unit) {
val sent by app.logStore.sentCount.collectAsState() val sent by app.logStore.sentCount.collectAsState()
val filtered by app.logStore.filteredCount.collectAsState() val filtered by app.logStore.filteredCount.collectAsState()
val failed by app.logStore.failedCount.collectAsState() val failed by app.logStore.failedCount.collectAsState()
val smsSuspended by app.settings.smsSuspended.collectAsState()
var granted by remember { mutableStateOf(isNotificationListenerEnabled(context)) } var granted by remember { mutableStateOf(isNotificationListenerEnabled(context)) }
val lifecycleOwner = LocalLifecycleOwner.current 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 { item {
SectionCard { SectionCard {
Row(verticalAlignment = Alignment.CenterVertically) { Row(verticalAlignment = Alignment.CenterVertically) {
@@ -80,6 +80,23 @@ fun SettingsScreen(onOpenLog: () -> Unit) {
var showAdd by remember { mutableStateOf(false) } var showAdd by remember { mutableStateOf(false) }
var editing by remember { mutableStateOf<BarkServer?>(null) } 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 -> val treeLauncher = rememberLauncherForActivityResult(ActivityResultContracts.OpenDocumentTree()) { uri ->
if (uri != null) { if (uri != null) {
runCatching { 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 { item {
SectionCard("维护", subtitle = "日志查看和本地统计清理") { SectionCard("维护", subtitle = "日志查看和本地统计清理") {
ClickRow("转发日志", "查看 / 清空最近记录", icon = Icons.Filled.History, onClick = onOpenLog) ClickRow("转发日志", "查看 / 清空最近记录", icon = Icons.Filled.History, onClick = onOpenLog)