diff --git a/app/build.gradle.kts b/app/build.gradle.kts index cbad127..e8285da 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 = 38 - versionName = "1.9.9" + versionCode = 39 + versionName = "1.10.0" } signingConfigs { diff --git a/app/src/main/java/com/a2i/forwarder/core/CarrierBalanceQuery.kt b/app/src/main/java/com/a2i/forwarder/core/CarrierBalanceQuery.kt index 30b6b04..d1d7793 100644 --- a/app/src/main/java/com/a2i/forwarder/core/CarrierBalanceQuery.kt +++ b/app/src/main/java/com/a2i/forwarder/core/CarrierBalanceQuery.kt @@ -35,6 +35,7 @@ class CarrierBalanceQuery(private val context: Context) { /** 转发成功后扣减余额(手动模式 / 自动解析失败时)。 */ suspend fun decrementManual() { + if (!settings.smsBalanceCheck.value) return // 余额检查关闭时不递减 val q = settings.smsManualQuota.value if (q > 0) { val newQ = q - 1 @@ -66,6 +67,7 @@ class CarrierBalanceQuery(private val context: Context) { } private suspend fun checkSuspend(balance: Int) { + if (!settings.smsBalanceCheck.value) return // 余额检查关闭时不挂起 if (balance in 0..5 && !settings.smsSuspended.value) { settings.setSmsSuspended(true) } diff --git a/app/src/main/java/com/a2i/forwarder/core/SmsFallbackForwarder.kt b/app/src/main/java/com/a2i/forwarder/core/SmsFallbackForwarder.kt index dbcdd9e..ba0eb64 100644 --- a/app/src/main/java/com/a2i/forwarder/core/SmsFallbackForwarder.kt +++ b/app/src/main/java/com/a2i/forwarder/core/SmsFallbackForwarder.kt @@ -13,21 +13,21 @@ 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 // 验证码 + if (settings.smsFilterAll.value) return true // 全部转发模式 + if (d.code != null) return true // 验证码 val msg = d.message ?: return false - if (msg.group == "phone_call") return true // 来电/未接来电 + if (msg.group == "phone_call") return true // 来电/未接来电 val t = msg.title.orEmpty() return t.contains("来电") || t.contains("未接") } @@ -35,24 +35,31 @@ class SmsFallbackForwarder(private val context: Context) { /** 尝试用短信转发(遍历所有启用的目标号,按 stopOnFirst 决定发通一条止还是全发)。 */ fun forward(d: Decision) { scope.launch { - if (settings.smsSuspended.value) { + val s = settings + // ① 余额检查(用户可关) + if (s.smsBalanceCheck.value && s.smsSuspended.value) { log(d, "failed", "短信已挂起(余额不足)") return@launch } + // ② 内容过滤(用户可关,关=全部转发) if (!isSmsWorthy(d)) return@launch - val targets = settings.smsTargets.value.filter { it.enabled && it.number.isNotBlank() } + // ③ 目标号 + val targets = s.smsTargets.value.filter { it.enabled && it.number.isNotBlank() } if (targets.isEmpty()) { 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 (s.smsRateLimitEnabled.value) { + val limitMs = s.smsRateLimitMin.value.coerceIn(1, 60) * 60 * 1000L + val elapsed = now - s.lastSmsSentTime.value + if (elapsed < limitMs) { + log(d, "filtered", "限频(${limitMs - elapsed}ms 内)") + return@launch + } } - // 权限 + // ⑤ 权限(始终检查) if (ContextCompat.checkSelfPermission(context, Manifest.permission.SEND_SMS) != PackageManager.PERMISSION_GRANTED ) { @@ -60,8 +67,9 @@ class SmsFallbackForwarder(private val context: Context) { return@launch } + // ⑥ 发送 val body = compose(d).take(70) - val stopOnFirst = settings.smsStopOnFirst.value + val stopOnFirst = s.smsStopOnFirst.value var anyOk = false var sentCount = 0 var lastReason = "短信发送失败" @@ -78,14 +86,16 @@ class SmsFallbackForwarder(private val context: Context) { } } if (anyOk) { - settings.setLastSmsSentTime(now) + s.setLastSmsSentTime(now) log(d, "sent", "") - // 余额递减:按成功发送条数;自动模式发查询短信,手动模式递减 - val carrier = app.carrierBalanceQuery.currentCarrier() - if (carrier.serviceNumber != null && carrier.queryCmd != null) { - app.carrierBalanceQuery.refresh() - } else { - repeat(sentCount) { runCatching { app.carrierBalanceQuery.decrementManual() } } + // 余额递减(仅余额检查开启时) + if (s.smsBalanceCheck.value) { + val carrier = app.carrierBalanceQuery.currentCarrier() + if (carrier.serviceNumber != null && carrier.queryCmd != null) { + app.carrierBalanceQuery.refresh() + } else { + repeat(sentCount) { runCatching { app.carrierBalanceQuery.decrementManual() } } + } } } else { log(d, "failed", lastReason) 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 f0229e8..647abe8 100644 --- a/app/src/main/java/com/a2i/forwarder/store/SettingsStore.kt +++ b/app/src/main/java/com/a2i/forwarder/store/SettingsStore.kt @@ -56,6 +56,10 @@ class SettingsStore( val SMS_BALANCE = intPreferencesKey("sms_balance") val SMS_SUSPENDED = booleanPreferencesKey("sms_suspended") val SMS_LAST_SENT = longPreferencesKey("sms_last_sent") + val SMS_FILTER_ALL = booleanPreferencesKey("sms_filter_all") // true=全部转发 false=仅紧要 + val SMS_RATE_LIMIT_ENABLED = booleanPreferencesKey("sms_rate_limit_en") + val SMS_RATE_LIMIT_MIN = intPreferencesKey("sms_rate_limit_min") + val SMS_BALANCE_CHECK = booleanPreferencesKey("sms_balance_check") // 电邮转发 val EMAIL_ENABLED = booleanPreferencesKey("email_enabled") val EMAIL_SERVERS = stringPreferencesKey("email_servers") @@ -121,6 +125,10 @@ class SettingsStore( val smsBalance = MutableStateFlow(-1) val smsSuspended = MutableStateFlow(false) val lastSmsSentTime = MutableStateFlow(0L) + val smsFilterAll = MutableStateFlow(false) // true=全部转发 false=仅紧要 + val smsRateLimitEnabled = MutableStateFlow(true) + val smsRateLimitMin = MutableStateFlow(5) + val smsBalanceCheck = MutableStateFlow(true) // 电邮总开关 val emailForwardingEnabled = MutableStateFlow(false) @@ -159,6 +167,10 @@ class SettingsStore( smsBalance.value = p[K.SMS_BALANCE] ?: -1 smsSuspended.value = p[K.SMS_SUSPENDED] ?: false lastSmsSentTime.value = p[K.SMS_LAST_SENT] ?: 0L + smsFilterAll.value = p[K.SMS_FILTER_ALL] ?: false + smsRateLimitEnabled.value = p[K.SMS_RATE_LIMIT_ENABLED] ?: true + smsRateLimitMin.value = p[K.SMS_RATE_LIMIT_MIN] ?: 5 + smsBalanceCheck.value = p[K.SMS_BALANCE_CHECK] ?: true // 电邮总开关 emailForwardingEnabled.value = p[K.EMAIL_ENABLED] ?: false // 应用更新 @@ -343,6 +355,10 @@ class SettingsStore( 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 setSmsFilterAll(v: Boolean) { edit { it[K.SMS_FILTER_ALL] = v }; smsFilterAll.value = v } + suspend fun setSmsRateLimitEnabled(v: Boolean) { edit { it[K.SMS_RATE_LIMIT_ENABLED] = v }; smsRateLimitEnabled.value = v } + suspend fun setSmsRateLimitMin(v: Int) { edit { it[K.SMS_RATE_LIMIT_MIN] = v }; smsRateLimitMin.value = v } + suspend fun setSmsBalanceCheck(v: Boolean) { edit { it[K.SMS_BALANCE_CHECK] = v }; smsBalanceCheck.value = v } suspend fun setUpdateCheckEnabled(v: Boolean) { edit { it[K.UPDATE_CHECK_AUTO] = v }; updateCheckEnabled.value = v } suspend fun setLastUpdateCheck(v: Long) { edit { it[K.LAST_UPDATE_CHECK] = v }; lastUpdateCheckTime.value = v } 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 5ea3ffb..f6da9c1 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 @@ -113,6 +113,10 @@ fun SettingsScreen(onOpenLog: () -> Unit) { val meowServers by app.settings.meowServers.collectAsState() val emailServers by app.settings.emailServers.collectAsState() val smsTargets by app.settings.smsTargets.collectAsState() + val smsFilterAll by app.settings.smsFilterAll.collectAsState() + val smsRateLimitEnabled by app.settings.smsRateLimitEnabled.collectAsState() + val smsRateLimitMin by app.settings.smsRateLimitMin.collectAsState() + val smsBalanceCheck by app.settings.smsBalanceCheck.collectAsState() val barkStopFirst by app.settings.barkStopOnFirst.collectAsState() val ntfyStopFirst by app.settings.ntfyStopOnFirst.collectAsState() val meowStopFirst by app.settings.meowStopOnFirst.collectAsState() @@ -312,7 +316,7 @@ fun SettingsScreen(onOpenLog: () -> Unit) { SectionCard { ChannelSectionHeader( title = "断网短信兜底", - summary = "无网络时发送关键通知", + summary = "无网络时用短信发送通知", mark = "S", markColor = Color(0xFF00897B), markIcon = Icons.Filled.Sms, @@ -342,13 +346,48 @@ fun SettingsScreen(onOpenLog: () -> Unit) { } } AddButton { showAddSms = true } - Spacer(Modifier.size(8.dp)) - val balanceText = when { - smsSuspended -> "已挂起(余额不足)" - smsBalance < 0 -> "余额未知" - else -> "剩余约 $smsBalance 条" + + // ── 高级设置 ── + Spacer(Modifier.size(12.dp)) + SwitchRow( + title = "仅紧要通知", + subtitle = if (smsFilterAll) "当前:全部转发(不限内容)" else "当前:仅验证码 + 来电", + checked = !smsFilterAll, + icon = Icons.Filled.Settings, + ) { v -> app.appScope.launch { app.settings.setSmsFilterAll(!v) } } + + SwitchRow( + title = "限频", + subtitle = if (smsRateLimitEnabled) "每 ${smsRateLimitMin} 分钟最多 1 条" else "已关闭(来一条发一条)", + checked = smsRateLimitEnabled, + ) { v -> app.appScope.launch { app.settings.setSmsRateLimitEnabled(v) } } + + SwitchRow( + title = "余额检查", + subtitle = if (smsBalanceCheck) "余额 ≤5 自动挂起" else "已关闭(不限余额)", + checked = smsBalanceCheck, + ) { v -> app.appScope.launch { app.settings.setSmsBalanceCheck(v) } } + + // 余额检查开启时显示运营商/额度/挂起状态 + if (smsBalanceCheck) { + Spacer(Modifier.size(8.dp)) + val balanceText = when { + smsSuspended -> "已挂起(余额不足)" + smsBalance < 0 -> "余额未知" + else -> "剩余约 $smsBalance 条" + } + Text("运营商:${detectedCarrier.displayName} · $balanceText · 手动额度:$smsManualQuota", style = MaterialTheme.typography.bodySmall, color = if (smsSuspended) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.onSurfaceVariant) + if (smsSuspended) { + Spacer(Modifier.size(4.dp)) + OutlinedButton(onClick = { + app.appScope.launch { + app.settings.setSmsSuspended(false) + val q = smsManualQuota + if (q > 0) app.settings.setSmsBalance(q) + } + }, modifier = Modifier.fillMaxWidth()) { Text("解除挂起") } + } } - Text("运营商:${detectedCarrier.displayName} · 余额:$balanceText · 手动额度:$smsManualQuota", style = MaterialTheme.typography.bodySmall, color = if (smsSuspended) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.onSurfaceVariant) } }