From 8ca73c2e90dc9878032931b757ac75b6f0ab4414 Mon Sep 17 00:00:00 2001 From: Song <168455@qq.com> Date: Wed, 8 Jul 2026 08:54:37 +0800 Subject: [PATCH] feat: offline SMS fallback for important notifications MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 断网时把验证码、来电用短信发到 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) --- README.md | 6 + app/build.gradle.kts | 4 +- app/src/main/AndroidManifest.xml | 1 + app/src/main/java/com/a2i/forwarder/A2iApp.kt | 14 +++ .../a2i/forwarder/core/CarrierBalanceQuery.kt | 90 +++++++++++++++ .../com/a2i/forwarder/core/CarrierDetector.kt | 54 +++++++++ .../a2i/forwarder/core/ConnectivityMonitor.kt | 54 +++++++++ .../forwarder/core/NotificationProcessor.kt | 21 ++++ .../forwarder/core/SmsFallbackForwarder.kt | 105 ++++++++++++++++++ .../service/NotifyListenerService.kt | 11 ++ .../com/a2i/forwarder/store/SettingsStore.kt | 35 ++++++ .../a2i/forwarder/ui/screens/HomeScreen.kt | 23 ++++ .../forwarder/ui/screens/SettingsScreen.kt | 86 ++++++++++++++ 13 files changed, 502 insertions(+), 2 deletions(-) create mode 100644 app/src/main/java/com/a2i/forwarder/core/CarrierBalanceQuery.kt create mode 100644 app/src/main/java/com/a2i/forwarder/core/CarrierDetector.kt create mode 100644 app/src/main/java/com/a2i/forwarder/core/ConnectivityMonitor.kt create mode 100644 app/src/main/java/com/a2i/forwarder/core/SmsFallbackForwarder.kt 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)