4 Commits

Author SHA1 Message Date
song ce2f07a810 feat: 全链路转发遥测埋点 + bump to 1.10.1
各通道(bark/email/ntfy/meow/sms)转发结果上报 forward_* 事件

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-11 13:07:11 +08:00
song b0719b9a90 feat: SMS fallback refactor - user-controllable filters/limits/balance
New user options:
- Content filter: important-only (default) or forward-all
- Rate limit: toggle on/off, adjustable minutes
- Balance check: toggle on/off (off = no suspend, no decrement)

UI: SMS section now has 3 SwitchRows under target list, with
balance/carrier/quota status + "解除挂起" button shown when
balance check is on.

Logic: SmsFallbackForwarder reads new flags to skip checks;
CarrierBalanceQuery guards decrement/suspend on flag.

version 1.9.9 -> 1.10.0 (code 38 -> 39)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-11 09:57:03 +08:00
song 70f9ba093f feat: add telemetry + bump to 1.9.9
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-11 08:43:03 +08:00
song f7adeda080 docs: add All Rights Reserved to License
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-11 07:29:35 +08:00
14 changed files with 360 additions and 83 deletions
+2
View File
@@ -259,3 +259,5 @@ adb install -r app/build/outputs/apk/release/app-release.apk
## License ## License
本项目仅供个人使用。 本项目仅供个人使用。
All Rights Reserved. 保留所有权利。
+2 -2
View File
@@ -13,8 +13,8 @@ android {
applicationId = "com.a2i.forwarder" applicationId = "com.a2i.forwarder"
minSdk = 34 minSdk = 34
targetSdk = 36 targetSdk = 36
versionCode = 37 versionCode = 40
versionName = "1.9.8" versionName = "1.10.1"
} }
signingConfigs { signingConfigs {
@@ -7,6 +7,7 @@ import com.a2i.forwarder.core.ConnectivityMonitor
import com.a2i.forwarder.core.LogStore import com.a2i.forwarder.core.LogStore
import com.a2i.forwarder.core.PhoneCallMonitor import com.a2i.forwarder.core.PhoneCallMonitor
import com.a2i.forwarder.core.SmsFallbackForwarder import com.a2i.forwarder.core.SmsFallbackForwarder
import com.a2i.forwarder.core.Telemetry
import com.a2i.forwarder.core.UpdateChecker import com.a2i.forwarder.core.UpdateChecker
import com.a2i.forwarder.store.AppRulesStore import com.a2i.forwarder.store.AppRulesStore
import com.a2i.forwarder.store.SettingsStore import com.a2i.forwarder.store.SettingsStore
@@ -33,6 +34,8 @@ class A2iApp : Application() {
private set private set
lateinit var updateChecker: UpdateChecker lateinit var updateChecker: UpdateChecker
private set private set
lateinit var telemetry: Telemetry
private set
override fun onCreate() { override fun onCreate() {
super.onCreate() super.onCreate()
@@ -40,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)
@@ -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
if (elapsed < limitMs) {
log(d, "filtered", "限频(${limitMs - elapsed}ms 内)")
return@launch return@launch
} }
// 权限 }
if (ContextCompat.checkSelfPermission(context, Manifest.permission.SEND_SMS)
!= PackageManager.PERMISSION_GRANTED if (ContextCompat.checkSelfPermission(context, Manifest.permission.SEND_SMS) != 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,16 +79,18 @@ 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)
}
} }
} }
} }
@@ -0,0 +1,178 @@
package com.a2i.forwarder.core
import android.content.Context
import android.net.ConnectivityManager
import android.net.NetworkCapabilities
import android.os.Build
import android.provider.Settings
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.RequestBody.Companion.toRequestBody
import org.json.JSONObject
import java.util.Locale
import java.util.TimeZone
import java.util.UUID
import java.util.concurrent.TimeUnit
class Telemetry(
private val context: Context,
private val scope: CoroutineScope,
) {
private val prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
private val http = OkHttpClient.Builder()
.connectTimeout(8, TimeUnit.SECONDS)
.writeTimeout(8, TimeUnit.SECONDS)
.readTimeout(10, TimeUnit.SECONDS)
.retryOnConnectionFailure(true)
.build()
private val sessionId = UUID.randomUUID().toString()
fun start() {
scope.launch(Dispatchers.IO) {
if (!prefs.getBoolean(KEY_INSTALL_SENT, false)) {
send("install").onSuccess {
prefs.edit().putBoolean(KEY_INSTALL_SENT, true).apply()
}
}
send("launch")
}
}
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) {
runCatching {
val payload = basePayload(event)
val request = Request.Builder()
.url(EVENTS_URL)
.header("User-Agent", userAgent())
.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()
http.newCall(request).execute().use { response ->
if (!response.isSuccessful) error("HTTP ${response.code}")
}
}
}
private fun basePayload(event: String): JSONObject {
val version = appVersion()
val locale = Locale.getDefault()
return JSONObject()
.put("app_id", APP_ID)
.put("app_name", APP_NAME)
.put("event", event)
.put("install_id", installId())
.put("device_id", androidId())
.put("session_id", sessionId)
.put("platform", "android")
.put("package_name", context.packageName)
.put("app_version", version.name)
.put("app_build", version.code)
.put("os_version", "Android ${Build.VERSION.RELEASE} SDK ${Build.VERSION.SDK_INT}")
.put("manufacturer", Build.MANUFACTURER.orEmpty())
.put("model", Build.MODEL.orEmpty())
.put("locale", locale.toLanguageTag())
.put("timezone", TimeZone.getDefault().id)
.put("country", locale.country.orEmpty())
.put("network_type", networkType())
.put("user_agent", userAgent())
.put("source", "android-app")
}
private fun installId(): String {
prefs.getString(KEY_INSTALL_ID, null)?.let { return it }
val id = UUID.randomUUID().toString()
prefs.edit().putString(KEY_INSTALL_ID, id).apply()
return id
}
private fun androidId(): String =
Settings.Secure.getString(context.contentResolver, Settings.Secure.ANDROID_ID).orEmpty()
private fun appVersion(): AppVersion {
val info = context.packageManager.getPackageInfo(context.packageName, 0)
return AppVersion(
name = info.versionName ?: "0",
code = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
info.longVersionCode.toString()
} else {
@Suppress("DEPRECATION")
info.versionCode.toString()
},
)
}
private fun networkType(): String {
val manager = context.getSystemService(Context.CONNECTIVITY_SERVICE) as? ConnectivityManager ?: return "unknown"
val network = manager.activeNetwork ?: return "none"
val capabilities = manager.getNetworkCapabilities(network) ?: return "unknown"
return when {
capabilities.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) -> "wifi"
capabilities.hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR) -> "cellular"
capabilities.hasTransport(NetworkCapabilities.TRANSPORT_ETHERNET) -> "ethernet"
capabilities.hasTransport(NetworkCapabilities.TRANSPORT_VPN) -> "vpn"
else -> "other"
}
}
private fun userAgent(): String {
val version = appVersion()
return "GotMsg/${version.name} (${context.packageName}; Android ${Build.VERSION.SDK_INT}; ${Build.MANUFACTURER} ${Build.MODEL})"
}
private data class AppVersion(
val name: String,
val code: String,
)
companion object {
private const val APP_ID = "gotmsg"
private const val APP_NAME = "GotMsg"
private const val EVENTS_URL = "https://stats.songer.eu.org/v1/events"
private const val PREFS_NAME = "gotmsg_telemetry"
private const val KEY_INSTALL_ID = "install_id"
private const val KEY_INSTALL_SENT = "install_sent"
private val JSON = "application/json; charset=utf-8".toMediaType()
}
}
@@ -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(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)) Spacer(Modifier.size(8.dp))
val balanceText = when { val balanceText = when {
smsSuspended -> "已挂起(余额不足)" smsSuspended -> "已挂起(余额不足)"
smsBalance < 0 -> "余额未知" smsBalance < 0 -> "余额未知"
else -> "剩余约 $smsBalance" else -> "剩余约 $smsBalance"
} }
Text("运营商:${detectedCarrier.displayName} · 余额:$balanceText · 手动额度:$smsManualQuota", style = MaterialTheme.typography.bodySmall, color = if (smsSuspended) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.onSurfaceVariant) 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("解除挂起") }
}
}
} }
} }