diff --git a/README.md b/README.md
index 408eb8b..ab3e636 100644
--- a/README.md
+++ b/README.md
@@ -18,6 +18,11 @@
- **失败自动重试**:发送失败的通知进入待重发队列,由 WorkManager 定期重试,也可手动触发。
- **本地日志**:保留最近 300 条处理记录,方便排查为什么某条通知被过滤或发送失败。
- **电话来电通知**:通过轮询 `getCallState()` 实时感知来电和未接来电(绕过 MIUI 对系统电话通知的限制,需授予"电话"权限)。
+- **断网短信兜底**:无网络时自动把验证码、来电用短信发到 iPhone。
+ - 自动识别运营商(移动/联通/电信/广电),发免费查询短信拿套餐内短信余量
+ - 余额 ≤5 条自动停用,下次打开 App 提醒
+ - 每 5 分钟最多发 1 条,避免刷屏
+ - 自动解析失败时回退手动额度计数
## 快速开始
@@ -51,6 +56,7 @@
- **通知没推过来**:先看「日志」页,每条通知(无论转发、过滤还是失败)都会记录原因。
- **想让通知带图标**:参考下方「应用图标」一节。
- **电话通知不工作**:MIUI 会把系统电话通知限制为不向第三方通知监听器分发,本 App 通过轮询 `getCallonyState()` 绕过此限制。需要授予「电话」权限(设置 → 应用 → a2i → 权限 → 电话)。
+- **断网收不到通知**:开启「设置 → 断网短信兜底」,填好 iPhone 号码、授予短信权限、设好手动额度。断网时验证码和来电会走短信(消耗套餐额度,5 分钟限 1 条)。
## 应用图标
diff --git a/app/build.gradle.kts b/app/build.gradle.kts
index 44a0ae2..5e69bfa 100644
--- a/app/build.gradle.kts
+++ b/app/build.gradle.kts
@@ -13,8 +13,8 @@ android {
applicationId = "com.a2i.forwarder"
minSdk = 34
targetSdk = 36
- versionCode = 4
- versionName = "1.3.0"
+ versionCode = 5
+ versionName = "1.4.0"
}
signingConfigs {
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index b5020bb..a76d173 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -5,6 +5,7 @@
+
diff --git a/app/src/main/java/com/a2i/forwarder/A2iApp.kt b/app/src/main/java/com/a2i/forwarder/A2iApp.kt
index ca88f89..d2a370d 100644
--- a/app/src/main/java/com/a2i/forwarder/A2iApp.kt
+++ b/app/src/main/java/com/a2i/forwarder/A2iApp.kt
@@ -1,8 +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
@@ -20,6 +24,12 @@ class A2iApp : Application() {
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()
@@ -29,6 +39,10 @@ class A2iApp : Application() {
logStore = LogStore(this, appScope)
phoneCallMonitor = PhoneCallMonitor(this)
phoneCallMonitor.start()
+ connectivityMonitor = ConnectivityMonitor(this)
+ connectivityMonitor.start()
+ carrierBalanceQuery = CarrierBalanceQuery(this)
+ smsForwarder = SmsFallbackForwarder(this)
}
companion object {
diff --git a/app/src/main/java/com/a2i/forwarder/core/CarrierBalanceQuery.kt b/app/src/main/java/com/a2i/forwarder/core/CarrierBalanceQuery.kt
new file mode 100644
index 0000000..30b6b04
--- /dev/null
+++ b/app/src/main/java/com/a2i/forwarder/core/CarrierBalanceQuery.kt
@@ -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)
+ }
+ }
+ }
+}
diff --git a/app/src/main/java/com/a2i/forwarder/core/CarrierDetector.kt b/app/src/main/java/com/a2i/forwarder/core/CarrierDetector.kt
new file mode 100644
index 0000000..f27a8c4
--- /dev/null
+++ b/app/src/main/java/com/a2i/forwarder/core/CarrierDetector.kt
@@ -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)
+ }
+}
diff --git a/app/src/main/java/com/a2i/forwarder/core/ConnectivityMonitor.kt b/app/src/main/java/com/a2i/forwarder/core/ConnectivityMonitor.kt
new file mode 100644
index 0000000..7efda32
--- /dev/null
+++ b/app/src/main/java/com/a2i/forwarder/core/ConnectivityMonitor.kt
@@ -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,断网时短信兜底转发器据此降级。
+ */
+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)
+ }
+}
diff --git a/app/src/main/java/com/a2i/forwarder/core/NotificationProcessor.kt b/app/src/main/java/com/a2i/forwarder/core/NotificationProcessor.kt
index 2298566..e043b62 100644
--- a/app/src/main/java/com/a2i/forwarder/core/NotificationProcessor.kt
+++ b/app/src/main/java/com/a2i/forwarder/core/NotificationProcessor.kt
@@ -126,6 +126,12 @@ class NotificationProcessor(private val context: Context) {
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]
@@ -220,4 +226,19 @@ class NotificationProcessor(private val context: Context) {
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
+ }
}
diff --git a/app/src/main/java/com/a2i/forwarder/core/SmsFallbackForwarder.kt b/app/src/main/java/com/a2i/forwarder/core/SmsFallbackForwarder.kt
new file mode 100644
index 0000000..d1822c2
--- /dev/null
+++ b/app/src/main/java/com/a2i/forwarder/core/SmsFallbackForwarder.kt
@@ -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)
+ )
+ }
+ }
+}
diff --git a/app/src/main/java/com/a2i/forwarder/service/NotifyListenerService.kt b/app/src/main/java/com/a2i/forwarder/service/NotifyListenerService.kt
index cdd8ac6..8c530b4 100644
--- a/app/src/main/java/com/a2i/forwarder/service/NotifyListenerService.kt
+++ b/app/src/main/java/com/a2i/forwarder/service/NotifyListenerService.kt
@@ -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"))
diff --git a/app/src/main/java/com/a2i/forwarder/store/SettingsStore.kt b/app/src/main/java/com/a2i/forwarder/store/SettingsStore.kt
index 4b608a2..2ac4a80 100644
--- a/app/src/main/java/com/a2i/forwarder/store/SettingsStore.kt
+++ b/app/src/main/java/com/a2i/forwarder/store/SettingsStore.kt
@@ -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(
@@ -57,6 +67,15 @@ class SettingsStore(
val skipOngoing = MutableStateFlow(true)
val adKeywords = MutableStateFlow>(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 ->
@@ -75,6 +94,14 @@ class SettingsStore(
adKeywords.value = p[K.AD_KW]
?.let { runCatching { json.decodeFromString>(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,6 +155,14 @@ 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 {
diff --git a/app/src/main/java/com/a2i/forwarder/ui/screens/HomeScreen.kt b/app/src/main/java/com/a2i/forwarder/ui/screens/HomeScreen.kt
index 7966d72..4574c4b 100644
--- a/app/src/main/java/com/a2i/forwarder/ui/screens/HomeScreen.kt
+++ b/app/src/main/java/com/a2i/forwarder/ui/screens/HomeScreen.kt
@@ -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) {
diff --git a/app/src/main/java/com/a2i/forwarder/ui/screens/SettingsScreen.kt b/app/src/main/java/com/a2i/forwarder/ui/screens/SettingsScreen.kt
index 1eff9b6..53d937b 100644
--- a/app/src/main/java/com/a2i/forwarder/ui/screens/SettingsScreen.kt
+++ b/app/src/main/java/com/a2i/forwarder/ui/screens/SettingsScreen.kt
@@ -80,6 +80,23 @@ fun SettingsScreen(onOpenLog: () -> Unit) {
var showAdd by remember { mutableStateOf(false) }
var editing by remember { mutableStateOf(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)