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:
@@ -5,6 +5,7 @@
|
||||
<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,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 {
|
||||
|
||||
@@ -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))
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"))
|
||||
|
||||
@@ -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<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 ->
|
||||
@@ -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,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 {
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user