Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| b0719b9a90 | |||
| 70f9ba093f | |||
| f7adeda080 |
@@ -259,3 +259,5 @@ adb install -r app/build/outputs/apk/release/app-release.apk
|
||||
## License
|
||||
|
||||
本项目仅供个人使用。
|
||||
|
||||
All Rights Reserved. 保留所有权利。
|
||||
|
||||
@@ -13,8 +13,8 @@ android {
|
||||
applicationId = "com.a2i.forwarder"
|
||||
minSdk = 34
|
||||
targetSdk = 36
|
||||
versionCode = 37
|
||||
versionName = "1.9.8"
|
||||
versionCode = 39
|
||||
versionName = "1.10.0"
|
||||
}
|
||||
|
||||
signingConfigs {
|
||||
|
||||
@@ -7,6 +7,7 @@ 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.core.Telemetry
|
||||
import com.a2i.forwarder.core.UpdateChecker
|
||||
import com.a2i.forwarder.store.AppRulesStore
|
||||
import com.a2i.forwarder.store.SettingsStore
|
||||
@@ -33,6 +34,8 @@ class A2iApp : Application() {
|
||||
private set
|
||||
lateinit var updateChecker: UpdateChecker
|
||||
private set
|
||||
lateinit var telemetry: Telemetry
|
||||
private set
|
||||
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
@@ -48,6 +51,8 @@ class A2iApp : Application() {
|
||||
smsForwarder = SmsFallbackForwarder(this)
|
||||
updateChecker = UpdateChecker(this)
|
||||
updateChecker.start()
|
||||
telemetry = Telemetry(this, appScope)
|
||||
telemetry.start()
|
||||
}
|
||||
|
||||
companion object {
|
||||
|
||||
@@ -35,6 +35,7 @@ class CarrierBalanceQuery(private val context: Context) {
|
||||
|
||||
/** 转发成功后扣减余额(手动模式 / 自动解析失败时)。 */
|
||||
suspend fun decrementManual() {
|
||||
if (!settings.smsBalanceCheck.value) return // 余额检查关闭时不递减
|
||||
val q = settings.smsManualQuota.value
|
||||
if (q > 0) {
|
||||
val newQ = q - 1
|
||||
@@ -66,6 +67,7 @@ class CarrierBalanceQuery(private val context: Context) {
|
||||
}
|
||||
|
||||
private suspend fun checkSuspend(balance: Int) {
|
||||
if (!settings.smsBalanceCheck.value) return // 余额检查关闭时不挂起
|
||||
if (balance in 0..5 && !settings.smsSuspended.value) {
|
||||
settings.setSmsSuspended(true)
|
||||
}
|
||||
|
||||
@@ -13,18 +13,18 @@ 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 (settings.smsFilterAll.value) return true // 全部转发模式
|
||||
if (d.code != null) return true // 验证码
|
||||
val msg = d.message ?: return false
|
||||
if (msg.group == "phone_call") return true // 来电/未接来电
|
||||
@@ -35,24 +35,31 @@ class SmsFallbackForwarder(private val context: Context) {
|
||||
/** 尝试用短信转发(遍历所有启用的目标号,按 stopOnFirst 决定发通一条止还是全发)。 */
|
||||
fun forward(d: Decision) {
|
||||
scope.launch {
|
||||
if (settings.smsSuspended.value) {
|
||||
val s = settings
|
||||
// ① 余额检查(用户可关)
|
||||
if (s.smsBalanceCheck.value && s.smsSuspended.value) {
|
||||
log(d, "failed", "短信已挂起(余额不足)")
|
||||
return@launch
|
||||
}
|
||||
// ② 内容过滤(用户可关,关=全部转发)
|
||||
if (!isSmsWorthy(d)) return@launch
|
||||
val targets = settings.smsTargets.value.filter { it.enabled && it.number.isNotBlank() }
|
||||
// ③ 目标号
|
||||
val targets = s.smsTargets.value.filter { it.enabled && it.number.isNotBlank() }
|
||||
if (targets.isEmpty()) {
|
||||
log(d, "failed", "未设置短信目标号")
|
||||
return@launch
|
||||
}
|
||||
// 限频
|
||||
// ④ 限频(用户可关)
|
||||
val now = System.currentTimeMillis()
|
||||
val elapsed = now - settings.lastSmsSentTime.value
|
||||
if (elapsed < RATE_LIMIT_MS) {
|
||||
log(d, "filtered", "限频(${RATE_LIMIT_MS - elapsed}ms 内)")
|
||||
if (s.smsRateLimitEnabled.value) {
|
||||
val limitMs = s.smsRateLimitMin.value.coerceIn(1, 60) * 60 * 1000L
|
||||
val elapsed = now - s.lastSmsSentTime.value
|
||||
if (elapsed < limitMs) {
|
||||
log(d, "filtered", "限频(${limitMs - elapsed}ms 内)")
|
||||
return@launch
|
||||
}
|
||||
// 权限
|
||||
}
|
||||
// ⑤ 权限(始终检查)
|
||||
if (ContextCompat.checkSelfPermission(context, Manifest.permission.SEND_SMS)
|
||||
!= PackageManager.PERMISSION_GRANTED
|
||||
) {
|
||||
@@ -60,8 +67,9 @@ class SmsFallbackForwarder(private val context: Context) {
|
||||
return@launch
|
||||
}
|
||||
|
||||
// ⑥ 发送
|
||||
val body = compose(d).take(70)
|
||||
val stopOnFirst = settings.smsStopOnFirst.value
|
||||
val stopOnFirst = s.smsStopOnFirst.value
|
||||
var anyOk = false
|
||||
var sentCount = 0
|
||||
var lastReason = "短信发送失败"
|
||||
@@ -78,15 +86,17 @@ class SmsFallbackForwarder(private val context: Context) {
|
||||
}
|
||||
}
|
||||
if (anyOk) {
|
||||
settings.setLastSmsSentTime(now)
|
||||
s.setLastSmsSentTime(now)
|
||||
log(d, "sent", "")
|
||||
// 余额递减:按成功发送条数;自动模式发查询短信,手动模式递减
|
||||
// 余额递减(仅余额检查开启时)
|
||||
if (s.smsBalanceCheck.value) {
|
||||
val carrier = app.carrierBalanceQuery.currentCarrier()
|
||||
if (carrier.serviceNumber != null && carrier.queryCmd != null) {
|
||||
app.carrierBalanceQuery.refresh()
|
||||
} else {
|
||||
repeat(sentCount) { runCatching { app.carrierBalanceQuery.decrementManual() } }
|
||||
}
|
||||
}
|
||||
} else {
|
||||
log(d, "failed", lastReason)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,140 @@
|
||||
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")
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun send(event: String): Result<Unit> = withContext(Dispatchers.IO) {
|
||||
runCatching {
|
||||
val payload = basePayload(event).toString().toRequestBody(JSON)
|
||||
val request = Request.Builder()
|
||||
.url(EVENTS_URL)
|
||||
.header("User-Agent", userAgent())
|
||||
.post(payload)
|
||||
.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()
|
||||
}
|
||||
}
|
||||
@@ -56,6 +56,10 @@ class SettingsStore(
|
||||
val SMS_BALANCE = intPreferencesKey("sms_balance")
|
||||
val SMS_SUSPENDED = booleanPreferencesKey("sms_suspended")
|
||||
val SMS_LAST_SENT = longPreferencesKey("sms_last_sent")
|
||||
val SMS_FILTER_ALL = booleanPreferencesKey("sms_filter_all") // true=全部转发 false=仅紧要
|
||||
val SMS_RATE_LIMIT_ENABLED = booleanPreferencesKey("sms_rate_limit_en")
|
||||
val SMS_RATE_LIMIT_MIN = intPreferencesKey("sms_rate_limit_min")
|
||||
val SMS_BALANCE_CHECK = booleanPreferencesKey("sms_balance_check")
|
||||
// 电邮转发
|
||||
val EMAIL_ENABLED = booleanPreferencesKey("email_enabled")
|
||||
val EMAIL_SERVERS = stringPreferencesKey("email_servers")
|
||||
@@ -121,6 +125,10 @@ class SettingsStore(
|
||||
val smsBalance = MutableStateFlow(-1)
|
||||
val smsSuspended = MutableStateFlow(false)
|
||||
val lastSmsSentTime = MutableStateFlow(0L)
|
||||
val smsFilterAll = MutableStateFlow(false) // true=全部转发 false=仅紧要
|
||||
val smsRateLimitEnabled = MutableStateFlow(true)
|
||||
val smsRateLimitMin = MutableStateFlow(5)
|
||||
val smsBalanceCheck = MutableStateFlow(true)
|
||||
|
||||
// 电邮总开关
|
||||
val emailForwardingEnabled = MutableStateFlow(false)
|
||||
@@ -159,6 +167,10 @@ class SettingsStore(
|
||||
smsBalance.value = p[K.SMS_BALANCE] ?: -1
|
||||
smsSuspended.value = p[K.SMS_SUSPENDED] ?: false
|
||||
lastSmsSentTime.value = p[K.SMS_LAST_SENT] ?: 0L
|
||||
smsFilterAll.value = p[K.SMS_FILTER_ALL] ?: false
|
||||
smsRateLimitEnabled.value = p[K.SMS_RATE_LIMIT_ENABLED] ?: true
|
||||
smsRateLimitMin.value = p[K.SMS_RATE_LIMIT_MIN] ?: 5
|
||||
smsBalanceCheck.value = p[K.SMS_BALANCE_CHECK] ?: true
|
||||
// 电邮总开关
|
||||
emailForwardingEnabled.value = p[K.EMAIL_ENABLED] ?: false
|
||||
// 应用更新
|
||||
@@ -343,6 +355,10 @@ class SettingsStore(
|
||||
suspend fun setSmsBalance(v: Int) { edit { it[K.SMS_BALANCE] = v }; smsBalance.value = v }
|
||||
suspend fun setSmsSuspended(v: Boolean) { edit { it[K.SMS_SUSPENDED] = v }; smsSuspended.value = v }
|
||||
suspend fun setLastSmsSentTime(v: Long) { edit { it[K.SMS_LAST_SENT] = v }; lastSmsSentTime.value = v }
|
||||
suspend fun setSmsFilterAll(v: Boolean) { edit { it[K.SMS_FILTER_ALL] = v }; smsFilterAll.value = v }
|
||||
suspend fun setSmsRateLimitEnabled(v: Boolean) { edit { it[K.SMS_RATE_LIMIT_ENABLED] = v }; smsRateLimitEnabled.value = v }
|
||||
suspend fun setSmsRateLimitMin(v: Int) { edit { it[K.SMS_RATE_LIMIT_MIN] = v }; smsRateLimitMin.value = v }
|
||||
suspend fun setSmsBalanceCheck(v: Boolean) { edit { it[K.SMS_BALANCE_CHECK] = v }; smsBalanceCheck.value = v }
|
||||
|
||||
suspend fun setUpdateCheckEnabled(v: Boolean) { edit { it[K.UPDATE_CHECK_AUTO] = v }; updateCheckEnabled.value = v }
|
||||
suspend fun setLastUpdateCheck(v: Long) { edit { it[K.LAST_UPDATE_CHECK] = v }; lastUpdateCheckTime.value = v }
|
||||
|
||||
@@ -113,6 +113,10 @@ fun SettingsScreen(onOpenLog: () -> Unit) {
|
||||
val meowServers by app.settings.meowServers.collectAsState()
|
||||
val emailServers by app.settings.emailServers.collectAsState()
|
||||
val smsTargets by app.settings.smsTargets.collectAsState()
|
||||
val smsFilterAll by app.settings.smsFilterAll.collectAsState()
|
||||
val smsRateLimitEnabled by app.settings.smsRateLimitEnabled.collectAsState()
|
||||
val smsRateLimitMin by app.settings.smsRateLimitMin.collectAsState()
|
||||
val smsBalanceCheck by app.settings.smsBalanceCheck.collectAsState()
|
||||
val barkStopFirst by app.settings.barkStopOnFirst.collectAsState()
|
||||
val ntfyStopFirst by app.settings.ntfyStopOnFirst.collectAsState()
|
||||
val meowStopFirst by app.settings.meowStopOnFirst.collectAsState()
|
||||
@@ -312,7 +316,7 @@ fun SettingsScreen(onOpenLog: () -> Unit) {
|
||||
SectionCard {
|
||||
ChannelSectionHeader(
|
||||
title = "断网短信兜底",
|
||||
summary = "无网络时发送关键通知",
|
||||
summary = "无网络时用短信发送通知",
|
||||
mark = "S",
|
||||
markColor = Color(0xFF00897B),
|
||||
markIcon = Icons.Filled.Sms,
|
||||
@@ -342,13 +346,48 @@ fun SettingsScreen(onOpenLog: () -> Unit) {
|
||||
}
|
||||
}
|
||||
AddButton { showAddSms = true }
|
||||
|
||||
// ── 高级设置 ──
|
||||
Spacer(Modifier.size(12.dp))
|
||||
SwitchRow(
|
||||
title = "仅紧要通知",
|
||||
subtitle = if (smsFilterAll) "当前:全部转发(不限内容)" else "当前:仅验证码 + 来电",
|
||||
checked = !smsFilterAll,
|
||||
icon = Icons.Filled.Settings,
|
||||
) { v -> app.appScope.launch { app.settings.setSmsFilterAll(!v) } }
|
||||
|
||||
SwitchRow(
|
||||
title = "限频",
|
||||
subtitle = if (smsRateLimitEnabled) "每 ${smsRateLimitMin} 分钟最多 1 条" else "已关闭(来一条发一条)",
|
||||
checked = smsRateLimitEnabled,
|
||||
) { v -> app.appScope.launch { app.settings.setSmsRateLimitEnabled(v) } }
|
||||
|
||||
SwitchRow(
|
||||
title = "余额检查",
|
||||
subtitle = if (smsBalanceCheck) "余额 ≤5 自动挂起" else "已关闭(不限余额)",
|
||||
checked = smsBalanceCheck,
|
||||
) { v -> app.appScope.launch { app.settings.setSmsBalanceCheck(v) } }
|
||||
|
||||
// 余额检查开启时显示运营商/额度/挂起状态
|
||||
if (smsBalanceCheck) {
|
||||
Spacer(Modifier.size(8.dp))
|
||||
val balanceText = when {
|
||||
smsSuspended -> "已挂起(余额不足)"
|
||||
smsBalance < 0 -> "余额未知"
|
||||
else -> "剩余约 $smsBalance 条"
|
||||
}
|
||||
Text("运营商:${detectedCarrier.displayName} · 余额:$balanceText · 手动额度:$smsManualQuota", style = MaterialTheme.typography.bodySmall, color = if (smsSuspended) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
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("解除挂起") }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user