Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| ce2f07a810 | |||
| b0719b9a90 |
@@ -13,8 +13,8 @@ android {
|
|||||||
applicationId = "com.a2i.forwarder"
|
applicationId = "com.a2i.forwarder"
|
||||||
minSdk = 34
|
minSdk = 34
|
||||||
targetSdk = 36
|
targetSdk = 36
|
||||||
versionCode = 38
|
versionCode = 40
|
||||||
versionName = "1.9.9"
|
versionName = "1.10.1"
|
||||||
}
|
}
|
||||||
|
|
||||||
signingConfigs {
|
signingConfigs {
|
||||||
|
|||||||
@@ -43,6 +43,8 @@ class A2iApp : Application() {
|
|||||||
settings = SettingsStore(this, appScope)
|
settings = SettingsStore(this, appScope)
|
||||||
appRules = AppRulesStore(this, appScope)
|
appRules = AppRulesStore(this, appScope)
|
||||||
logStore = LogStore(this, appScope)
|
logStore = LogStore(this, appScope)
|
||||||
|
telemetry = Telemetry(this, appScope)
|
||||||
|
telemetry.start()
|
||||||
phoneCallMonitor = PhoneCallMonitor(this)
|
phoneCallMonitor = PhoneCallMonitor(this)
|
||||||
phoneCallMonitor.start()
|
phoneCallMonitor.start()
|
||||||
connectivityMonitor = ConnectivityMonitor(this)
|
connectivityMonitor = ConnectivityMonitor(this)
|
||||||
@@ -51,8 +53,6 @@ class A2iApp : Application() {
|
|||||||
smsForwarder = SmsFallbackForwarder(this)
|
smsForwarder = SmsFallbackForwarder(this)
|
||||||
updateChecker = UpdateChecker(this)
|
updateChecker = UpdateChecker(this)
|
||||||
updateChecker.start()
|
updateChecker.start()
|
||||||
telemetry = Telemetry(this, appScope)
|
|
||||||
telemetry.start()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ class CarrierBalanceQuery(private val context: Context) {
|
|||||||
|
|
||||||
/** 转发成功后扣减余额(手动模式 / 自动解析失败时)。 */
|
/** 转发成功后扣减余额(手动模式 / 自动解析失败时)。 */
|
||||||
suspend fun decrementManual() {
|
suspend fun decrementManual() {
|
||||||
|
if (!settings.smsBalanceCheck.value) return // 余额检查关闭时不递减
|
||||||
val q = settings.smsManualQuota.value
|
val q = settings.smsManualQuota.value
|
||||||
if (q > 0) {
|
if (q > 0) {
|
||||||
val newQ = q - 1
|
val newQ = q - 1
|
||||||
@@ -66,6 +67,7 @@ class CarrierBalanceQuery(private val context: Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun checkSuspend(balance: Int) {
|
private suspend fun checkSuspend(balance: Int) {
|
||||||
|
if (!settings.smsBalanceCheck.value) return // 余额检查关闭时不挂起
|
||||||
if (balance in 0..5 && !settings.smsSuspended.value) {
|
if (balance in 0..5 && !settings.smsSuspended.value) {
|
||||||
settings.setSmsSuspended(true)
|
settings.setSmsSuspended(true)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -123,7 +123,12 @@ object EmailSender {
|
|||||||
val configs = s.emailServers.value.filter { it.enabled && it.to.isNotBlank() && it.host.isNotBlank() }
|
val configs = s.emailServers.value.filter { it.enabled && it.to.isNotBlank() && it.host.isNotBlank() }
|
||||||
if (configs.isEmpty()) return
|
if (configs.isEmpty()) return
|
||||||
scope.launch {
|
scope.launch {
|
||||||
pushToEnabled(configs, title, body, s.emailStopOnFirst.value)
|
val result = pushToEnabled(configs, title, body, s.emailStopOnFirst.value)
|
||||||
|
app.telemetry.trackForward(
|
||||||
|
channel = "email",
|
||||||
|
status = if (result.isSuccess) "success" else "failed",
|
||||||
|
reason = result.exceptionOrNull()?.message.orEmpty(),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -75,7 +75,12 @@ object MeowSender {
|
|||||||
val configs = s.meowServers.value.filter { it.enabled && it.server.isNotBlank() && it.deviceKey.isNotBlank() }
|
val configs = s.meowServers.value.filter { it.enabled && it.server.isNotBlank() && it.deviceKey.isNotBlank() }
|
||||||
if (configs.isEmpty()) return
|
if (configs.isEmpty()) return
|
||||||
scope.launch {
|
scope.launch {
|
||||||
pushToEnabled(configs, title, body, s.meowStopOnFirst.value)
|
val result = pushToEnabled(configs, title, body, s.meowStopOnFirst.value)
|
||||||
|
app.telemetry.trackForward(
|
||||||
|
channel = "meow",
|
||||||
|
status = if (result.isSuccess) "success" else "failed",
|
||||||
|
reason = result.exceptionOrNull()?.message.orEmpty(),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -92,7 +92,12 @@ object NtfySender {
|
|||||||
val configs = s.ntfyServers.value.filter { it.enabled && it.topic.isNotBlank() }
|
val configs = s.ntfyServers.value.filter { it.enabled && it.topic.isNotBlank() }
|
||||||
if (configs.isEmpty()) return
|
if (configs.isEmpty()) return
|
||||||
scope.launch {
|
scope.launch {
|
||||||
pushToEnabled(configs, title, body, s.ntfyStopOnFirst.value)
|
val result = pushToEnabled(configs, title, body, s.ntfyStopOnFirst.value)
|
||||||
|
app.telemetry.trackForward(
|
||||||
|
channel = "ntfy",
|
||||||
|
status = if (result.isSuccess) "success" else "failed",
|
||||||
|
reason = result.exceptionOrNull()?.message.orEmpty(),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -18,11 +18,8 @@ import kotlinx.serialization.json.Json
|
|||||||
import java.util.UUID
|
import java.util.UUID
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 通过轮询 TelephonyManager.getCallState() 监听来电状态。
|
* 轮询 TelephonyManager.getCallState() 监听来电状态。
|
||||||
* MIUI 不会把系统电话通知分发给第三方监听器,TelephonyCallback 也不生效,
|
* 这里绕过第三方监听器不稳定的限制,直接在轮询状态变化时转发通知。
|
||||||
* 所以这里用最原始但最可靠的方式:每 800ms 读一次 call state。
|
|
||||||
*
|
|
||||||
* getCallState() 只是读内存中的 int,不涉及硬件操作,耗电极低。
|
|
||||||
*/
|
*/
|
||||||
class PhoneCallMonitor(private val context: Context) {
|
class PhoneCallMonitor(private val context: Context) {
|
||||||
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
||||||
@@ -32,15 +29,11 @@ class PhoneCallMonitor(private val context: Context) {
|
|||||||
private var ringing = false
|
private var ringing = false
|
||||||
private var offhook = false
|
private var offhook = false
|
||||||
private var running = false
|
private var running = false
|
||||||
|
|
||||||
/** 上一次推送"来电"的时间,防止重复推送 */
|
|
||||||
private var lastRingTime = 0L
|
private var lastRingTime = 0L
|
||||||
|
|
||||||
fun start() {
|
fun start() {
|
||||||
if (running) return
|
if (running) return
|
||||||
if (ContextCompat.checkSelfPermission(context, Manifest.permission.READ_PHONE_STATE)
|
if (ContextCompat.checkSelfPermission(context, Manifest.permission.READ_PHONE_STATE) != PackageManager.PERMISSION_GRANTED) return
|
||||||
!= PackageManager.PERMISSION_GRANTED
|
|
||||||
) return
|
|
||||||
|
|
||||||
running = true
|
running = true
|
||||||
lastState = TelephonyManager.CALL_STATE_IDLE
|
lastState = TelephonyManager.CALL_STATE_IDLE
|
||||||
@@ -49,7 +42,10 @@ class PhoneCallMonitor(private val context: Context) {
|
|||||||
|
|
||||||
scope.launch {
|
scope.launch {
|
||||||
val tm = context.getSystemService(Context.TELEPHONY_SERVICE) as? TelephonyManager
|
val tm = context.getSystemService(Context.TELEPHONY_SERVICE) as? TelephonyManager
|
||||||
if (tm == null) { running = false; return@launch }
|
if (tm == null) {
|
||||||
|
running = false
|
||||||
|
return@launch
|
||||||
|
}
|
||||||
|
|
||||||
while (isActive && running) {
|
while (isActive && running) {
|
||||||
val state = tm.callState
|
val state = tm.callState
|
||||||
@@ -72,7 +68,6 @@ class PhoneCallMonitor(private val context: Context) {
|
|||||||
if (!settings.forwardingEnabled.value) return
|
if (!settings.forwardingEnabled.value) return
|
||||||
|
|
||||||
when {
|
when {
|
||||||
// IDLE → RINGING:来电
|
|
||||||
from == TelephonyManager.CALL_STATE_IDLE && to == TelephonyManager.CALL_STATE_RINGING -> {
|
from == TelephonyManager.CALL_STATE_IDLE && to == TelephonyManager.CALL_STATE_RINGING -> {
|
||||||
ringing = true
|
ringing = true
|
||||||
offhook = false
|
offhook = false
|
||||||
@@ -91,12 +86,10 @@ class PhoneCallMonitor(private val context: Context) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// RINGING → OFFHOOK:接听
|
|
||||||
from == TelephonyManager.CALL_STATE_RINGING && to == TelephonyManager.CALL_STATE_OFFHOOK -> {
|
from == TelephonyManager.CALL_STATE_RINGING && to == TelephonyManager.CALL_STATE_OFFHOOK -> {
|
||||||
offhook = true
|
offhook = true
|
||||||
}
|
}
|
||||||
|
|
||||||
// RINGING → IDLE:未接(响了但没接)
|
|
||||||
from == TelephonyManager.CALL_STATE_RINGING && to == TelephonyManager.CALL_STATE_IDLE -> {
|
from == TelephonyManager.CALL_STATE_RINGING && to == TelephonyManager.CALL_STATE_IDLE -> {
|
||||||
if (!offhook && ringing) {
|
if (!offhook && ringing) {
|
||||||
val msg = BarkMessage(
|
val msg = BarkMessage(
|
||||||
@@ -113,7 +106,6 @@ class PhoneCallMonitor(private val context: Context) {
|
|||||||
offhook = false
|
offhook = false
|
||||||
}
|
}
|
||||||
|
|
||||||
// OFFHOOK → IDLE:正常挂断,不推送
|
|
||||||
from == TelephonyManager.CALL_STATE_OFFHOOK && to == TelephonyManager.CALL_STATE_IDLE -> {
|
from == TelephonyManager.CALL_STATE_OFFHOOK && to == TelephonyManager.CALL_STATE_IDLE -> {
|
||||||
ringing = false
|
ringing = false
|
||||||
offhook = false
|
offhook = false
|
||||||
@@ -124,27 +116,35 @@ class PhoneCallMonitor(private val context: Context) {
|
|||||||
private fun pushToServers(msg: BarkMessage, app: A2iApp, logBody: String) {
|
private fun pushToServers(msg: BarkMessage, app: A2iApp, logBody: String) {
|
||||||
scope.launch {
|
scope.launch {
|
||||||
val servers = app.settings.servers.value.filter { it.enabled && it.deviceKey.isNotBlank() }
|
val servers = app.settings.servers.value.filter { it.enabled && it.deviceKey.isNotBlank() }
|
||||||
|
val now = System.currentTimeMillis()
|
||||||
|
|
||||||
if (servers.isEmpty()) {
|
if (servers.isEmpty()) {
|
||||||
|
val reason = "未配置可用 Bark 服务器"
|
||||||
|
app.logStore.addLog(ForwardLog(now, "phone_call", "电话", msg.title, logBody, "failed", reason))
|
||||||
|
app.telemetry.trackForward("bark", "failed", reason)
|
||||||
EmailSender.trySend(app, msg.title ?: logBody, msg.body ?: "", scope)
|
EmailSender.trySend(app, msg.title ?: logBody, msg.body ?: "", scope)
|
||||||
NtfySender.trySend(app, msg.title ?: logBody, msg.body ?: "", scope)
|
NtfySender.trySend(app, msg.title ?: logBody, msg.body ?: "", scope)
|
||||||
MeowSender.trySend(app, msg.title ?: logBody, msg.body ?: "", scope)
|
MeowSender.trySend(app, msg.title ?: logBody, msg.body ?: "", scope)
|
||||||
return@launch
|
return@launch
|
||||||
}
|
}
|
||||||
|
|
||||||
val result = BarkSender.pushToEnabled(servers, msg, app.settings.barkStopOnFirst.value)
|
val result = BarkSender.pushToEnabled(servers, msg, app.settings.barkStopOnFirst.value)
|
||||||
val now = System.currentTimeMillis()
|
|
||||||
if (result.isSuccess) {
|
if (result.isSuccess) {
|
||||||
app.logStore.addLog(
|
app.logStore.addLog(
|
||||||
ForwardLog(now, "phone_call", "电话", msg.title, logBody, "sent", "")
|
ForwardLog(now, "phone_call", "电话", msg.title, logBody, "sent", "")
|
||||||
)
|
)
|
||||||
|
app.telemetry.trackForward("bark", "success")
|
||||||
} else {
|
} else {
|
||||||
val reason = result.exceptionOrNull()?.message ?: "错误"
|
val reason = result.exceptionOrNull()?.message ?: "错误"
|
||||||
app.logStore.addLog(
|
app.logStore.addLog(
|
||||||
ForwardLog(now, "phone_call", "电话", msg.title, logBody, "failed", reason)
|
ForwardLog(now, "phone_call", "电话", msg.title, logBody, "failed", reason)
|
||||||
)
|
)
|
||||||
|
app.telemetry.trackForward("bark", "failed", reason)
|
||||||
app.logStore.addPending(
|
app.logStore.addPending(
|
||||||
PendingPush(UUID.randomUUID().toString(), now, servers.first().id, json.encodeToString(msg))
|
PendingPush(UUID.randomUUID().toString(), now, servers.first().id, json.encodeToString(msg))
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
EmailSender.trySend(app, msg.title ?: logBody, msg.body ?: "", scope)
|
EmailSender.trySend(app, msg.title ?: logBody, msg.body ?: "", scope)
|
||||||
NtfySender.trySend(app, msg.title ?: logBody, msg.body ?: "", scope)
|
NtfySender.trySend(app, msg.title ?: logBody, msg.body ?: "", scope)
|
||||||
MeowSender.trySend(app, msg.title ?: logBody, msg.body ?: "", scope)
|
MeowSender.trySend(app, msg.title ?: logBody, msg.body ?: "", scope)
|
||||||
|
|||||||
@@ -29,7 +29,13 @@ class RetryWorker(ctx: Context, params: WorkerParameters) : CoroutineWorker(ctx,
|
|||||||
if (servers.isEmpty()) { allOk = false; break }
|
if (servers.isEmpty()) { allOk = false; break }
|
||||||
val msg = runCatching { json.decodeFromString<com.a2i.forwarder.model.BarkMessage>(p.messageJson) }.getOrNull() ?: continue
|
val msg = runCatching { json.decodeFromString<com.a2i.forwarder.model.BarkMessage>(p.messageJson) }.getOrNull() ?: continue
|
||||||
val r = BarkSender.pushToFirst(servers, msg)
|
val r = BarkSender.pushToFirst(servers, msg)
|
||||||
if (r.isSuccess) store.removePending(p.id) else allOk = false
|
if (r.isSuccess) {
|
||||||
|
store.removePending(p.id)
|
||||||
|
app.telemetry.trackForward("bark", "success")
|
||||||
|
} else {
|
||||||
|
allOk = false
|
||||||
|
app.telemetry.trackForward("bark", "failed", r.exceptionOrNull()?.message.orEmpty())
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return if (allOk) Result.success() else Result.retry()
|
return if (allOk) Result.success() else Result.retry()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,61 +13,63 @@ import kotlinx.coroutines.SupervisorJob
|
|||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 短信兜底转发器:断网时把紧要通知(验证码、来电)用短信发到 iPhone。
|
* 离线时把紧要通知走短信兜底。
|
||||||
* 限频:每 5 分钟最多 1 条转发短信。余额 ≤5 时自动挂起。
|
|
||||||
*/
|
*/
|
||||||
class SmsFallbackForwarder(private val context: Context) {
|
class SmsFallbackForwarder(private val context: Context) {
|
||||||
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
||||||
private val RATE_LIMIT_MS = 5 * 60 * 1000L
|
|
||||||
|
|
||||||
private val app get() = A2iApp.instance
|
private val app get() = A2iApp.instance
|
||||||
private val settings get() = app.settings
|
private val settings get() = app.settings
|
||||||
|
|
||||||
/** 判断该通知是否值得走短信(断网资源宝贵)。 */
|
|
||||||
fun isSmsWorthy(d: Decision): Boolean {
|
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
|
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()
|
val title = msg.title.orEmpty()
|
||||||
return t.contains("来电") || t.contains("未接")
|
return title.contains("来电") || title.contains("未接")
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 尝试用短信转发(遍历所有启用的目标号,按 stopOnFirst 决定发通一条止还是全发)。 */
|
|
||||||
fun forward(d: Decision) {
|
fun forward(d: Decision) {
|
||||||
scope.launch {
|
scope.launch {
|
||||||
if (settings.smsSuspended.value) {
|
val s = settings
|
||||||
|
|
||||||
|
if (s.smsBalanceCheck.value && s.smsSuspended.value) {
|
||||||
log(d, "failed", "短信已挂起(余额不足)")
|
log(d, "failed", "短信已挂起(余额不足)")
|
||||||
return@launch
|
return@launch
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!isSmsWorthy(d)) 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()) {
|
if (targets.isEmpty()) {
|
||||||
log(d, "failed", "未设置短信目标号")
|
log(d, "failed", "未设置短信目标号")
|
||||||
return@launch
|
return@launch
|
||||||
}
|
}
|
||||||
// 限频
|
|
||||||
val now = System.currentTimeMillis()
|
val now = System.currentTimeMillis()
|
||||||
val elapsed = now - settings.lastSmsSentTime.value
|
if (s.smsRateLimitEnabled.value) {
|
||||||
if (elapsed < RATE_LIMIT_MS) {
|
val limitMs = s.smsRateLimitMin.value.coerceIn(1, 60) * 60 * 1000L
|
||||||
log(d, "filtered", "限频(${RATE_LIMIT_MS - elapsed}ms 内)")
|
val elapsed = now - s.lastSmsSentTime.value
|
||||||
return@launch
|
if (elapsed < limitMs) {
|
||||||
|
log(d, "filtered", "限频(${limitMs - elapsed}ms 内)")
|
||||||
|
return@launch
|
||||||
|
}
|
||||||
}
|
}
|
||||||
// 权限
|
|
||||||
if (ContextCompat.checkSelfPermission(context, Manifest.permission.SEND_SMS)
|
if (ContextCompat.checkSelfPermission(context, Manifest.permission.SEND_SMS) != PackageManager.PERMISSION_GRANTED) {
|
||||||
!= PackageManager.PERMISSION_GRANTED
|
log(d, "failed", "缺少 SEND_SMS 权限")
|
||||||
) {
|
|
||||||
log(d, "failed", "无 SEND_SMS 权限")
|
|
||||||
return@launch
|
return@launch
|
||||||
}
|
}
|
||||||
|
|
||||||
val body = compose(d).take(70)
|
val body = compose(d).take(70)
|
||||||
val stopOnFirst = settings.smsStopOnFirst.value
|
val stopOnFirst = s.smsStopOnFirst.value
|
||||||
var anyOk = false
|
var anyOk = false
|
||||||
var sentCount = 0
|
var sentCount = 0
|
||||||
var lastReason = "短信发送失败"
|
var lastReason = "短信发送失败"
|
||||||
for (t in targets) {
|
for (target in targets) {
|
||||||
val result = runCatching {
|
val result = runCatching {
|
||||||
SmsManager.getDefault().sendTextMessage(t.number.trim(), null, body, null, null)
|
SmsManager.getDefault().sendTextMessage(target.number.trim(), null, body, null, null)
|
||||||
}
|
}
|
||||||
if (result.isSuccess) {
|
if (result.isSuccess) {
|
||||||
anyOk = true
|
anyOk = true
|
||||||
@@ -77,15 +79,17 @@ class SmsFallbackForwarder(private val context: Context) {
|
|||||||
lastReason = result.exceptionOrNull()?.message ?: "短信发送失败"
|
lastReason = result.exceptionOrNull()?.message ?: "短信发送失败"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (anyOk) {
|
if (anyOk) {
|
||||||
settings.setLastSmsSentTime(now)
|
s.setLastSmsSentTime(now)
|
||||||
log(d, "sent", "")
|
log(d, "sent", "")
|
||||||
// 余额递减:按成功发送条数;自动模式发查询短信,手动模式递减
|
if (s.smsBalanceCheck.value) {
|
||||||
val carrier = app.carrierBalanceQuery.currentCarrier()
|
val carrier = app.carrierBalanceQuery.currentCarrier()
|
||||||
if (carrier.serviceNumber != null && carrier.queryCmd != null) {
|
if (carrier.serviceNumber != null && carrier.queryCmd != null) {
|
||||||
app.carrierBalanceQuery.refresh()
|
app.carrierBalanceQuery.refresh()
|
||||||
} else {
|
} else {
|
||||||
repeat(sentCount) { runCatching { app.carrierBalanceQuery.decrementManual() } }
|
repeat(sentCount) { runCatching { app.carrierBalanceQuery.decrementManual() } }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
log(d, "failed", lastReason)
|
log(d, "failed", lastReason)
|
||||||
@@ -98,8 +102,8 @@ class SmsFallbackForwarder(private val context: Context) {
|
|||||||
return if (code != null) {
|
return if (code != null) {
|
||||||
"[验证码]${d.appLabel}: $code"
|
"[验证码]${d.appLabel}: $code"
|
||||||
} else {
|
} else {
|
||||||
val m = d.message!!
|
val message = d.message!!
|
||||||
"${m.title.orEmpty().ifBlank { d.appLabel }}: ${m.body}".trim()
|
"${message.title.orEmpty().ifBlank { d.appLabel }}: ${message.body}".trim()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -111,6 +115,10 @@ class SmsFallbackForwarder(private val context: Context) {
|
|||||||
app.logStore.addLog(
|
app.logStore.addLog(
|
||||||
ForwardLog(now, "sms_fallback", d.appLabel, title, content, status, reason)
|
ForwardLog(now, "sms_fallback", d.appLabel, title, content, status, reason)
|
||||||
)
|
)
|
||||||
|
when (status) {
|
||||||
|
"sent" -> app.telemetry.trackForward("sms", "success")
|
||||||
|
"failed" -> app.telemetry.trackForward("sms", "failed", reason)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -43,13 +43,51 @@ class Telemetry(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun trackForward(channel: String, status: String, reason: String = "") {
|
||||||
|
val normalizedChannel = channel.trim().lowercase(Locale.ROOT)
|
||||||
|
val normalizedStatus = status.trim().lowercase(Locale.ROOT)
|
||||||
|
if (normalizedChannel.isBlank() || normalizedStatus.isBlank()) return
|
||||||
|
scope.launch(Dispatchers.IO) {
|
||||||
|
send(
|
||||||
|
event = "forward_$normalizedChannel",
|
||||||
|
fields = mapOf(
|
||||||
|
"channel" to normalizedChannel,
|
||||||
|
"status" to normalizedStatus,
|
||||||
|
"reason" to reason.trim().take(500),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private suspend fun send(event: String): Result<Unit> = withContext(Dispatchers.IO) {
|
private suspend fun send(event: String): Result<Unit> = withContext(Dispatchers.IO) {
|
||||||
runCatching {
|
runCatching {
|
||||||
val payload = basePayload(event).toString().toRequestBody(JSON)
|
val payload = basePayload(event)
|
||||||
val request = Request.Builder()
|
val request = Request.Builder()
|
||||||
.url(EVENTS_URL)
|
.url(EVENTS_URL)
|
||||||
.header("User-Agent", userAgent())
|
.header("User-Agent", userAgent())
|
||||||
.post(payload)
|
.post(payload.toString().toRequestBody(JSON))
|
||||||
|
.build()
|
||||||
|
http.newCall(request).execute().use { response ->
|
||||||
|
if (!response.isSuccessful) error("HTTP ${response.code}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun send(event: String, fields: Map<String, Any?>): Result<Unit> = withContext(Dispatchers.IO) {
|
||||||
|
runCatching {
|
||||||
|
val payload = basePayload(event)
|
||||||
|
fields.forEach { (key, value) ->
|
||||||
|
if (value == null) return@forEach
|
||||||
|
when (value) {
|
||||||
|
is String -> if (value.isNotBlank()) payload.put(key, value)
|
||||||
|
is Number, is Boolean -> payload.put(key, value)
|
||||||
|
else -> payload.put(key, value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val request = Request.Builder()
|
||||||
|
.url(EVENTS_URL)
|
||||||
|
.header("User-Agent", userAgent())
|
||||||
|
.post(payload.toString().toRequestBody(JSON))
|
||||||
.build()
|
.build()
|
||||||
http.newCall(request).execute().use { response ->
|
http.newCall(request).execute().use { response ->
|
||||||
if (!response.isSuccessful) error("HTTP ${response.code}")
|
if (!response.isSuccessful) error("HTTP ${response.code}")
|
||||||
|
|||||||
@@ -39,49 +39,55 @@ class NotifyListenerService : NotificationListenerService() {
|
|||||||
private suspend fun handle(sbn: StatusBarNotification) {
|
private suspend fun handle(sbn: StatusBarNotification) {
|
||||||
val app = A2iApp.instance
|
val app = A2iApp.instance
|
||||||
val now = System.currentTimeMillis()
|
val now = System.currentTimeMillis()
|
||||||
val d = processor.process(sbn)
|
val decision = processor.process(sbn)
|
||||||
val msg = d.message
|
val msg = decision.message
|
||||||
|
|
||||||
if (msg == null) {
|
if (msg == null) {
|
||||||
// 静默过滤(如持续性通知)不写日志
|
if (!decision.silent) {
|
||||||
if (!d.silent) {
|
app.logStore.addLog(
|
||||||
app.logStore.addLog(ForwardLog(now, sbn.packageName, d.appLabel, "", "", "filtered", d.reason))
|
ForwardLog(now, sbn.packageName, decision.appLabel, "", "", "filtered", decision.reason)
|
||||||
|
)
|
||||||
}
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- 断网兜底:离线时紧要通知走短信,其余不转发避免离线堆积 ----
|
if (!app.connectivityMonitor.isOnline.value) {
|
||||||
val online = app.connectivityMonitor.isOnline.value
|
if (app.smsForwarder.isSmsWorthy(decision)) {
|
||||||
if (!online) {
|
app.smsForwarder.forward(decision)
|
||||||
if (app.smsForwarder.isSmsWorthy(d)) {
|
|
||||||
app.smsForwarder.forward(d)
|
|
||||||
} else {
|
} else {
|
||||||
app.logStore.addLog(ForwardLog(now, sbn.packageName, d.appLabel, msg.title, msg.body, "filtered", "离线非紧要通知"))
|
app.logStore.addLog(
|
||||||
|
ForwardLog(now, sbn.packageName, decision.appLabel, msg.title, msg.body, "filtered", "离线非紧要通知")
|
||||||
|
)
|
||||||
}
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
val servers = app.settings.servers.value.filter { it.enabled && it.deviceKey.isNotBlank() }
|
val servers = app.settings.servers.value.filter { it.enabled && it.deviceKey.isNotBlank() }
|
||||||
if (servers.isEmpty()) {
|
if (servers.isEmpty()) {
|
||||||
app.logStore.addLog(ForwardLog(now, sbn.packageName, d.appLabel, msg.title, msg.body, "failed", "未配置可用 Bark 服务器"))
|
val reason = "未配置可用 Bark 服务器"
|
||||||
EmailSender.trySend(app, msg.title ?: d.appLabel, msg.body, scope)
|
app.logStore.addLog(ForwardLog(now, sbn.packageName, decision.appLabel, msg.title, msg.body, "failed", reason))
|
||||||
NtfySender.trySend(app, msg.title ?: d.appLabel, msg.body, scope)
|
app.telemetry.trackForward("bark", "failed", reason)
|
||||||
MeowSender.trySend(app, msg.title ?: d.appLabel, msg.body, scope)
|
EmailSender.trySend(app, msg.title ?: decision.appLabel, msg.body, scope)
|
||||||
|
NtfySender.trySend(app, msg.title ?: decision.appLabel, msg.body, scope)
|
||||||
|
MeowSender.trySend(app, msg.title ?: decision.appLabel, msg.body, scope)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
val result = BarkSender.pushToEnabled(servers, msg, app.settings.barkStopOnFirst.value)
|
val result = BarkSender.pushToEnabled(servers, msg, app.settings.barkStopOnFirst.value)
|
||||||
if (result.isSuccess) {
|
if (result.isSuccess) {
|
||||||
app.logStore.addLog(ForwardLog(now, sbn.packageName, d.appLabel, msg.title, msg.body, "sent", ""))
|
app.logStore.addLog(ForwardLog(now, sbn.packageName, decision.appLabel, msg.title, msg.body, "sent", ""))
|
||||||
|
app.telemetry.trackForward("bark", "success")
|
||||||
} else {
|
} else {
|
||||||
val reason = result.exceptionOrNull()?.message ?: "错误"
|
val reason = result.exceptionOrNull()?.message ?: "错误"
|
||||||
app.logStore.addLog(ForwardLog(now, sbn.packageName, d.appLabel, msg.title, msg.body, "failed", reason))
|
app.logStore.addLog(ForwardLog(now, sbn.packageName, decision.appLabel, msg.title, msg.body, "failed", reason))
|
||||||
|
app.telemetry.trackForward("bark", "failed", reason)
|
||||||
app.logStore.addPending(
|
app.logStore.addPending(
|
||||||
PendingPush(UUID.randomUUID().toString(), now, servers.first().id, json.encodeToString(msg))
|
PendingPush(UUID.randomUUID().toString(), now, servers.first().id, json.encodeToString(msg))
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
EmailSender.trySend(app, msg.title ?: d.appLabel, msg.body, scope)
|
|
||||||
NtfySender.trySend(app, msg.title ?: d.appLabel, msg.body, scope)
|
EmailSender.trySend(app, msg.title ?: decision.appLabel, msg.body, scope)
|
||||||
MeowSender.trySend(app, msg.title ?: d.appLabel, msg.body, scope)
|
NtfySender.trySend(app, msg.title ?: decision.appLabel, msg.body, scope)
|
||||||
|
MeowSender.trySend(app, msg.title ?: decision.appLabel, msg.body, scope)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -56,6 +56,10 @@ class SettingsStore(
|
|||||||
val SMS_BALANCE = intPreferencesKey("sms_balance")
|
val SMS_BALANCE = intPreferencesKey("sms_balance")
|
||||||
val SMS_SUSPENDED = booleanPreferencesKey("sms_suspended")
|
val SMS_SUSPENDED = booleanPreferencesKey("sms_suspended")
|
||||||
val SMS_LAST_SENT = longPreferencesKey("sms_last_sent")
|
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_ENABLED = booleanPreferencesKey("email_enabled")
|
||||||
val EMAIL_SERVERS = stringPreferencesKey("email_servers")
|
val EMAIL_SERVERS = stringPreferencesKey("email_servers")
|
||||||
@@ -121,6 +125,10 @@ class SettingsStore(
|
|||||||
val smsBalance = MutableStateFlow(-1)
|
val smsBalance = MutableStateFlow(-1)
|
||||||
val smsSuspended = MutableStateFlow(false)
|
val smsSuspended = MutableStateFlow(false)
|
||||||
val lastSmsSentTime = MutableStateFlow(0L)
|
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)
|
val emailForwardingEnabled = MutableStateFlow(false)
|
||||||
@@ -159,6 +167,10 @@ class SettingsStore(
|
|||||||
smsBalance.value = p[K.SMS_BALANCE] ?: -1
|
smsBalance.value = p[K.SMS_BALANCE] ?: -1
|
||||||
smsSuspended.value = p[K.SMS_SUSPENDED] ?: false
|
smsSuspended.value = p[K.SMS_SUSPENDED] ?: false
|
||||||
lastSmsSentTime.value = p[K.SMS_LAST_SENT] ?: 0L
|
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
|
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 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 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 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 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 }
|
suspend fun setLastUpdateCheck(v: Long) { edit { it[K.LAST_UPDATE_CHECK] = v }; lastUpdateCheckTime.value = v }
|
||||||
|
|||||||
@@ -113,6 +113,10 @@ fun SettingsScreen(onOpenLog: () -> Unit) {
|
|||||||
val meowServers by app.settings.meowServers.collectAsState()
|
val meowServers by app.settings.meowServers.collectAsState()
|
||||||
val emailServers by app.settings.emailServers.collectAsState()
|
val emailServers by app.settings.emailServers.collectAsState()
|
||||||
val smsTargets by app.settings.smsTargets.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 barkStopFirst by app.settings.barkStopOnFirst.collectAsState()
|
||||||
val ntfyStopFirst by app.settings.ntfyStopOnFirst.collectAsState()
|
val ntfyStopFirst by app.settings.ntfyStopOnFirst.collectAsState()
|
||||||
val meowStopFirst by app.settings.meowStopOnFirst.collectAsState()
|
val meowStopFirst by app.settings.meowStopOnFirst.collectAsState()
|
||||||
@@ -312,7 +316,7 @@ fun SettingsScreen(onOpenLog: () -> Unit) {
|
|||||||
SectionCard {
|
SectionCard {
|
||||||
ChannelSectionHeader(
|
ChannelSectionHeader(
|
||||||
title = "断网短信兜底",
|
title = "断网短信兜底",
|
||||||
summary = "无网络时发送关键通知",
|
summary = "无网络时用短信发送通知",
|
||||||
mark = "S",
|
mark = "S",
|
||||||
markColor = Color(0xFF00897B),
|
markColor = Color(0xFF00897B),
|
||||||
markIcon = Icons.Filled.Sms,
|
markIcon = Icons.Filled.Sms,
|
||||||
@@ -342,13 +346,48 @@ fun SettingsScreen(onOpenLog: () -> Unit) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
AddButton { showAddSms = true }
|
AddButton { showAddSms = true }
|
||||||
Spacer(Modifier.size(8.dp))
|
|
||||||
val balanceText = when {
|
// ── 高级设置 ──
|
||||||
smsSuspended -> "已挂起(余额不足)"
|
Spacer(Modifier.size(12.dp))
|
||||||
smsBalance < 0 -> "余额未知"
|
SwitchRow(
|
||||||
else -> "剩余约 $smsBalance 条"
|
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)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user