feat: email forwarding via SMTPS
Parallel to Bark: when email forwarding is enabled and a recipient is set, notifications are also sent via SMTP over SSL (port 465, SMTPS). Works standalone — no Bark server required. - core/EmailSender: SMTP over SSL (implicit TLS, port 465), AUTH LOGIN, UTF-8 subject/body via Base64/MIME; trySend() fire-and-forget helper - SettingsStore: 7 email settings (enabled, host, port, user, password, from, to) - NotifyListenerService + PhoneCallMonitor: call EmailSender.trySend in parallel with Bark forwarding; fires even if no Bark servers configured - UI: new "电邮转发" SectionCard in settings (toggle + host/port/user/ password/from/to fields + save button) - version 1.6.0 -> 1.6.1 (code 10 -> 11); CLAUDE.md synced Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -63,6 +63,7 @@ NotifyListenerService (系统通知入口)
|
||||
旁路(不经通知监听器):
|
||||
PhoneCallMonitor 轮询 getCallState() → 来电/未接构造 Decision 推送(绕过 MIUI 限制)
|
||||
UpdateChecker 查 Gitea Releases → 新版本横幅 → 应用内下载 + 系统安装器
|
||||
EmailSender 并联电邮转发(SMTP,fire-and-forget)
|
||||
```
|
||||
|
||||
### 包结构 `com.a2i.forwarder`
|
||||
@@ -77,6 +78,7 @@ NotifyListenerService (系统通知入口)
|
||||
| `core/AppParsers.kt` | 微信/QQ/Telegram 的 `clickUrl` 映射 + 重要消息关键词(@我、验证码、通话等)。 |
|
||||
| `core/BarkClient.kt` | OkHttp POST 到 `${server}/push`,超时 10/10/15s;body 用 `msg.copy(deviceKey=server.deviceKey)`;对 connection closed / 超时 / TLS 中断等瞬时 IOException 自动重试 3 次(退避 600/1200ms),HTTP 4xx/5xx 不重试。 |
|
||||
| `core/BarkSender.kt` | 多服务器容错推送:按列表顺序依次 `BarkClient.push`,首个成功即止(`pushToFirst`);NotifyListenerService / PhoneCallMonitor / RetryWorker 共用。 |
|
||||
| `core/EmailSender.kt` | SMTP 直连发送纯文本邮件(`send`)支持 465 SSL / 587 STARTTLS + AUTH LOGIN;`trySend` 便捷方法读取 SettingsStore 异步发(fire-and-forget)。 |
|
||||
| `core/RetryWorker.kt` | WorkManager 周期任务,每 15 分钟重发 `pending` 队列中的失败消息。 |
|
||||
| `core/LogStore.kt` | 最近 300 条日志 + 失败重发队列(≤100)。DataStore 持久化。 |
|
||||
| `core/IconExporter.kt` | 一键导出所有已安装 App 图标到用户选择的 SAF 目录(用于部署到 iOS Bark 图床)。 |
|
||||
@@ -86,7 +88,7 @@ NotifyListenerService (系统通知入口)
|
||||
| `core/CarrierBalanceQuery.kt` | 向运营商服务号发查询短信、解析回执余量、余额≤5 自动挂起。 |
|
||||
| `core/SmsFallbackForwarder.kt` | 断网时把紧要通知(验证码/来电)用短信发到 iPhone,5 分钟限频 + 余额检查。 |
|
||||
| `core/UpdateChecker.kt` | 查 Gitea Releases 最新版(`UPDATE_SOURCE_HIDDEN`,国内可达,24h 节流)→ 语义比较 → 下载 APK → FileProvider 调起系统安装器。 |
|
||||
| `store/SettingsStore.kt` | Bark 服务器列表(有序,`BarkServer.enabled` 勾选参与转发)、全局开关、广告关键词、图标前缀等。`MutableStateFlow` + DataStore。`moveServer` 调序、`setServerEnabled` 启停;转发按序依次试(见 `BarkSender`)。图标前缀默认 github raw,国内不稳时可手填自建 Gitea。 |
|
||||
| `store/SettingsStore.kt` | Bark 服务器列表(有序,`BarkServer.enabled` 勾选参与转发)、全局开关、广告关键词、图标前缀、电邮转发 SMTP 设置等。`MutableStateFlow` + DataStore。`moveServer` 调序、`setServerEnabled` 启停;转发按序依次试(见 `BarkSender`)。图标前缀默认 github raw,国内不稳时可手填自建 Gitea。 |
|
||||
| `store/AppRulesStore.kt` | 黑/白名单模式 + 默认黑名单(系统 App + 输入法 + 三星剪贴板/键盘 + GMS + 录音机/相册/查找/密码管理 + 通话管理/AI通话);默认黑名单带版本号 `bl_version`,升级时把新增噪音包合并进老用户黑名单(只增不删)。 |
|
||||
| `ui/nav/AppNav.kt` | 4 个底部 Tab + 日志页。 |
|
||||
| `ui/theme/*` | 固定品牌配色(`Color.kt`)、自定义 `Typography`(`Type.kt`),`A2iTheme` 关闭动态取色、统一深浅色方案(`Theme.kt`)。 |
|
||||
@@ -112,7 +114,7 @@ NotifyListenerService (系统通知入口)
|
||||
|
||||
## 版本与发布
|
||||
|
||||
- 当前版本:`versionCode = 10`、`versionName = "1.6.0"`(位于 `app/build.gradle.kts`)。**每次改动后同步递增 versionCode/versionName,并同步更新本节。**
|
||||
- 当前版本:`versionCode = 11`、`versionName = "1.6.1"`(位于 `app/build.gradle.kts`)。**每次改动后同步递增 versionCode/versionName,并同步更新本节。**
|
||||
- 远端仓库:`https://github.com/lsxf/a2i.git`,默认分支 `main`。
|
||||
- 提交策略:`.gitignore` 已排除 `app/build`、`.gradle`、`.kotlin`、`local.properties`、`.claude/`、`do.ps1`、`gh-token.txt`(发版用 token)、`*.token`、截图与临时文件,**不要**提交它们。
|
||||
- 发版流程(github + gitea 双发;UpdateChecker 查 Gitea 源,故 Gitea 必发):
|
||||
|
||||
@@ -13,8 +13,8 @@ android {
|
||||
applicationId = "com.a2i.forwarder"
|
||||
minSdk = 34
|
||||
targetSdk = 36
|
||||
versionCode = 10
|
||||
versionName = "1.6.0"
|
||||
versionCode = 11
|
||||
versionName = "1.6.1"
|
||||
}
|
||||
|
||||
signingConfigs {
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
package com.a2i.forwarder.core
|
||||
|
||||
import com.a2i.forwarder.A2iApp
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import java.io.BufferedReader
|
||||
import java.io.BufferedWriter
|
||||
import java.io.InputStreamReader
|
||||
import java.io.OutputStreamWriter
|
||||
import java.util.Base64
|
||||
import javax.net.ssl.SSLSocket
|
||||
import javax.net.ssl.SSLSocketFactory
|
||||
|
||||
/**
|
||||
* SMTP 直连发送纯文本邮件(SMTPS/隐式 TLS,端口 465)。
|
||||
* 在 [Dispatchers.IO] 上执行,不阻塞主线程。
|
||||
*/
|
||||
object EmailSender {
|
||||
suspend fun send(
|
||||
host: String, port: Int, user: String, password: String,
|
||||
from: String, to: String, subject: String, body: String,
|
||||
): Result<Unit> = withContext(Dispatchers.IO) {
|
||||
runCatching {
|
||||
val factory = SSLSocketFactory.getDefault()
|
||||
// 隐式 TLS:直接通过 SSLSocketFactory 创建 SSLSocket 连接 SMTP 服务器(推荐端口 465)
|
||||
val ssl = factory.createSocket(host, port) as SSLSocket
|
||||
ssl.soTimeout = 15_000
|
||||
ssl.startHandshake()
|
||||
val reader: BufferedReader = ssl.inputStream.bufferedReader()
|
||||
val writer: BufferedWriter = ssl.outputStream.bufferedWriter()
|
||||
|
||||
fun read(): String = reader.readLine() ?: error("连接关闭")
|
||||
fun cmd(c: String) { writer.write("$c\r\n"); writer.flush() }
|
||||
|
||||
fun expectEhlo(): String {
|
||||
cmd("EHLO a2i")
|
||||
var last = ""
|
||||
do { last = read() } while (last.startsWith("250-"))
|
||||
if (!last.startsWith("250 ")) error("EHLO: $last")
|
||||
return last
|
||||
}
|
||||
|
||||
var r = read()
|
||||
if (!r.startsWith("220")) error("SMTP: $r")
|
||||
expectEhlo()
|
||||
|
||||
if (user.isNotBlank() && password.isNotBlank()) {
|
||||
cmd("AUTH LOGIN")
|
||||
r = read()
|
||||
if (!r.startsWith("334")) error("AUTH: $r")
|
||||
cmd(Base64.getEncoder().encodeToString(user.toByteArray()))
|
||||
r = read()
|
||||
if (!r.startsWith("334")) error("AUTH user: $r")
|
||||
cmd(Base64.getEncoder().encodeToString(password.toByteArray()))
|
||||
r = read()
|
||||
if (!r.startsWith("235")) error("AUTH pass: $r")
|
||||
}
|
||||
|
||||
cmd("MAIL FROM:<$from>")
|
||||
r = read()
|
||||
if (!r.startsWith("250")) error("FROM: $r")
|
||||
|
||||
cmd("RCPT TO:<$to>")
|
||||
r = read()
|
||||
if (!r.startsWith("250")) error("TO: $r")
|
||||
|
||||
cmd("DATA")
|
||||
r = read()
|
||||
if (!r.startsWith("354")) error("DATA: $r")
|
||||
|
||||
writer.write(buildMime(from, to, subject, body))
|
||||
writer.write("\r\n.\r\n")
|
||||
writer.flush()
|
||||
r = read()
|
||||
if (!r.startsWith("250")) error("SEND: $r")
|
||||
|
||||
cmd("QUIT")
|
||||
runCatching { ssl.close() }
|
||||
Unit
|
||||
}
|
||||
}
|
||||
|
||||
private fun buildMime(from: String, to: String, subject: String, body: String): String {
|
||||
val subjB64 = Base64.getEncoder().encodeToString(subject.toByteArray(Charsets.UTF_8))
|
||||
val bodyB64 = Base64.getEncoder().encodeToString(body.toByteArray(Charsets.UTF_8))
|
||||
return buildString {
|
||||
append("From: <$from>\r\n")
|
||||
append("To: <$to>\r\n")
|
||||
append("Subject: =?UTF-8?B?$subjB64?=\r\n")
|
||||
append("MIME-Version: 1.0\r\n")
|
||||
append("Content-Type: text/plain; charset=UTF-8\r\n")
|
||||
append("Content-Transfer-Encoding: base64\r\n")
|
||||
append("\r\n")
|
||||
append(bodyB64)
|
||||
}
|
||||
}
|
||||
|
||||
/** 如果电邮转发已启用且收件人非空,异步发邮件(fire-and-forget,不阻塞主流程)。 */
|
||||
fun trySend(app: A2iApp, title: String, body: String, scope: CoroutineScope) {
|
||||
val s = app.settings
|
||||
if (!s.emailForwardingEnabled.value || s.emailTo.value.isBlank()) return
|
||||
scope.launch {
|
||||
EmailSender.send(s.smtpHost.value, s.smtpPort.value, s.smtpUser.value, s.smtpPassword.value,
|
||||
s.smtpFrom.value, s.emailTo.value, title, body)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -124,7 +124,10 @@ class PhoneCallMonitor(private val context: Context) {
|
||||
private fun pushToServers(msg: BarkMessage, app: A2iApp, logBody: String) {
|
||||
scope.launch {
|
||||
val servers = app.settings.servers.value.filter { it.enabled && it.deviceKey.isNotBlank() }
|
||||
if (servers.isEmpty()) return@launch
|
||||
if (servers.isEmpty()) {
|
||||
EmailSender.trySend(app, msg.title ?: logBody, msg.body ?: "", scope)
|
||||
return@launch
|
||||
}
|
||||
val result = BarkSender.pushToFirst(servers, msg)
|
||||
val now = System.currentTimeMillis()
|
||||
if (result.isSuccess) {
|
||||
@@ -140,6 +143,7 @@ class PhoneCallMonitor(private val context: Context) {
|
||||
PendingPush(UUID.randomUUID().toString(), now, servers.first().id, json.encodeToString(msg))
|
||||
)
|
||||
}
|
||||
EmailSender.trySend(app, msg.title ?: logBody, msg.body ?: "", scope)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import android.service.notification.NotificationListenerService
|
||||
import android.service.notification.StatusBarNotification
|
||||
import com.a2i.forwarder.A2iApp
|
||||
import com.a2i.forwarder.core.BarkSender
|
||||
import com.a2i.forwarder.core.EmailSender
|
||||
import com.a2i.forwarder.core.ForwardLog
|
||||
import com.a2i.forwarder.core.NotificationProcessor
|
||||
import com.a2i.forwarder.core.PendingPush
|
||||
@@ -61,6 +62,7 @@ class NotifyListenerService : NotificationListenerService() {
|
||||
val servers = app.settings.servers.value.filter { it.enabled && it.deviceKey.isNotBlank() }
|
||||
if (servers.isEmpty()) {
|
||||
app.logStore.addLog(ForwardLog(now, sbn.packageName, d.appLabel, msg.title, msg.body, "failed", "未配置可用 Bark 服务器"))
|
||||
EmailSender.trySend(app, msg.title ?: d.appLabel, msg.body, scope)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -74,5 +76,6 @@ class NotifyListenerService : NotificationListenerService() {
|
||||
PendingPush(UUID.randomUUID().toString(), now, servers.first().id, json.encodeToString(msg))
|
||||
)
|
||||
}
|
||||
EmailSender.trySend(app, msg.title ?: d.appLabel, msg.body, scope)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -46,6 +46,14 @@ class SettingsStore(
|
||||
val SMS_BALANCE = intPreferencesKey("sms_balance")
|
||||
val SMS_SUSPENDED = booleanPreferencesKey("sms_suspended")
|
||||
val SMS_LAST_SENT = longPreferencesKey("sms_last_sent")
|
||||
// 电邮转发
|
||||
val EMAIL_ENABLED = booleanPreferencesKey("email_enabled")
|
||||
val EMAIL_HOST = stringPreferencesKey("email_smtp_host")
|
||||
val EMAIL_PORT = intPreferencesKey("email_smtp_port")
|
||||
val EMAIL_USER = stringPreferencesKey("email_smtp_user")
|
||||
val EMAIL_PASS = stringPreferencesKey("email_smtp_pass")
|
||||
val EMAIL_FROM = stringPreferencesKey("email_from")
|
||||
val EMAIL_TO = stringPreferencesKey("email_to")
|
||||
// 应用更新
|
||||
val UPDATE_CHECK_AUTO = booleanPreferencesKey("update_check_auto")
|
||||
val LAST_UPDATE_CHECK = longPreferencesKey("last_update_check")
|
||||
@@ -81,6 +89,15 @@ class SettingsStore(
|
||||
val lastUpdateCheckTime = MutableStateFlow(0L) // 上次检查时间戳
|
||||
val ignoredVersion = MutableStateFlow("") // 用户忽略的版本号
|
||||
|
||||
// 电邮转发
|
||||
val emailForwardingEnabled = MutableStateFlow(false)
|
||||
val smtpHost = MutableStateFlow("")
|
||||
val smtpPort = MutableStateFlow(587)
|
||||
val smtpUser = MutableStateFlow("")
|
||||
val smtpPassword = MutableStateFlow("")
|
||||
val smtpFrom = MutableStateFlow("")
|
||||
val emailTo = MutableStateFlow("")
|
||||
|
||||
init {
|
||||
scope.launch {
|
||||
ds.data.collect { p ->
|
||||
@@ -109,6 +126,14 @@ class SettingsStore(
|
||||
updateCheckEnabled.value = p[K.UPDATE_CHECK_AUTO] ?: true
|
||||
lastUpdateCheckTime.value = p[K.LAST_UPDATE_CHECK] ?: 0L
|
||||
ignoredVersion.value = p[K.IGNORED_VERSION] ?: ""
|
||||
// 电邮转发
|
||||
emailForwardingEnabled.value = p[K.EMAIL_ENABLED] ?: false
|
||||
smtpHost.value = p[K.EMAIL_HOST] ?: ""
|
||||
smtpPort.value = p[K.EMAIL_PORT] ?: 587
|
||||
smtpUser.value = p[K.EMAIL_USER] ?: ""
|
||||
smtpPassword.value = p[K.EMAIL_PASS] ?: ""
|
||||
smtpFrom.value = p[K.EMAIL_FROM] ?: ""
|
||||
emailTo.value = p[K.EMAIL_TO] ?: ""
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -172,6 +197,14 @@ class SettingsStore(
|
||||
suspend fun setLastUpdateCheck(v: Long) { edit { it[K.LAST_UPDATE_CHECK] = v }; lastUpdateCheckTime.value = v }
|
||||
suspend fun setIgnoredVersion(v: String) { edit { it[K.IGNORED_VERSION] = v }; ignoredVersion.value = v }
|
||||
|
||||
suspend fun setEmailForwarding(v: Boolean) { edit { it[K.EMAIL_ENABLED] = v }; emailForwardingEnabled.value = v }
|
||||
suspend fun setSmtpHost(v: String) { edit { it[K.EMAIL_HOST] = v }; smtpHost.value = v }
|
||||
suspend fun setSmtpPort(v: Int) { edit { it[K.EMAIL_PORT] = v }; smtpPort.value = v }
|
||||
suspend fun setSmtpUser(v: String) { edit { it[K.EMAIL_USER] = v }; smtpUser.value = v }
|
||||
suspend fun setSmtpPassword(v: String) { edit { it[K.EMAIL_PASS] = v }; smtpPassword.value = v }
|
||||
suspend fun setSmtpFrom(v: String) { edit { it[K.EMAIL_FROM] = v }; smtpFrom.value = v }
|
||||
suspend fun setEmailTo(v: String) { edit { it[K.EMAIL_TO] = v }; emailTo.value = v }
|
||||
|
||||
suspend fun snapshot() = ds.data.first()
|
||||
|
||||
companion object {
|
||||
|
||||
@@ -100,6 +100,21 @@ fun SettingsScreen(onOpenLog: () -> Unit) {
|
||||
var smsQuotaInput by remember(smsQuota) { mutableStateOf(if (smsQuota == 0) "" else smsQuota.toString()) }
|
||||
val detectedCarrier = remember { com.a2i.forwarder.core.CarrierDetector.detect(context) }
|
||||
|
||||
// 电邮转发状态
|
||||
val emailEnabled by app.settings.emailForwardingEnabled.collectAsState()
|
||||
val smtpHost by app.settings.smtpHost.collectAsState()
|
||||
val smtpPort by app.settings.smtpPort.collectAsState()
|
||||
val smtpUser by app.settings.smtpUser.collectAsState()
|
||||
val smtpPassword by app.settings.smtpPassword.collectAsState()
|
||||
val smtpFrom by app.settings.smtpFrom.collectAsState()
|
||||
val emailTo by app.settings.emailTo.collectAsState()
|
||||
var smtpHostInput by remember(smtpHost) { mutableStateOf(smtpHost) }
|
||||
var smtpPortInput by remember(smtpPort) { mutableStateOf(if (smtpPort == 0) "" else smtpPort.toString()) }
|
||||
var smtpUserInput by remember(smtpUser) { mutableStateOf(smtpUser) }
|
||||
var smtpPasswordInput by remember(smtpPassword) { mutableStateOf(smtpPassword) }
|
||||
var smtpFromInput by remember(smtpFrom) { mutableStateOf(smtpFrom) }
|
||||
var emailToInput by remember(emailTo) { mutableStateOf(emailTo) }
|
||||
|
||||
val smsPermLauncher = rememberLauncherForActivityResult(ActivityResultContracts.RequestPermission()) { }
|
||||
val hasSmsPerm = remember {
|
||||
androidx.core.content.ContextCompat.checkSelfPermission(context, android.Manifest.permission.SEND_SMS) ==
|
||||
@@ -268,6 +283,76 @@ fun SettingsScreen(onOpenLog: () -> Unit) {
|
||||
}
|
||||
}
|
||||
|
||||
item {
|
||||
SectionCard("电邮转发", subtitle = "SMTPS 直发邮件(推荐端口 465);与 Bark 并行,互不依赖") {
|
||||
com.a2i.forwarder.ui.SwitchRow(
|
||||
title = "启用电邮转发",
|
||||
subtitle = if (emailEnabled) "同时转发通知到邮箱" else "当前关闭",
|
||||
checked = emailEnabled,
|
||||
) { v -> app.appScope.launch { app.settings.setEmailForwarding(v) } }
|
||||
|
||||
if (emailEnabled) {
|
||||
Spacer(Modifier.size(8.dp))
|
||||
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
OutlinedTextField(
|
||||
value = smtpHostInput, onValueChange = { smtpHostInput = it },
|
||||
label = { Text("SMTP 主机") },
|
||||
supportingText = { Text("如 smtp.qq.com") },
|
||||
modifier = Modifier.weight(1.5f), singleLine = true,
|
||||
)
|
||||
OutlinedTextField(
|
||||
value = smtpPortInput, onValueChange = { smtpPortInput = it.filter { c -> c.isDigit() } },
|
||||
label = { Text("端口") },
|
||||
supportingText = { Text("465") },
|
||||
modifier = Modifier.weight(1f), singleLine = true,
|
||||
)
|
||||
}
|
||||
Spacer(Modifier.size(4.dp))
|
||||
OutlinedTextField(
|
||||
value = smtpUserInput, onValueChange = { smtpUserInput = it },
|
||||
label = { Text("账号") },
|
||||
supportingText = { Text("SMTP 登录账号(通常即邮箱地址)") },
|
||||
modifier = Modifier.fillMaxWidth(), singleLine = true,
|
||||
)
|
||||
Spacer(Modifier.size(4.dp))
|
||||
OutlinedTextField(
|
||||
value = smtpPasswordInput, onValueChange = { smtpPasswordInput = it },
|
||||
label = { Text("授权码 / 密码") },
|
||||
supportingText = { Text("QQ/163/Gmail 需用授权码,非邮箱密码;QQ邮箱需开启SMTPS服务") },
|
||||
modifier = Modifier.fillMaxWidth(), singleLine = true,
|
||||
)
|
||||
Spacer(Modifier.size(4.dp))
|
||||
OutlinedTextField(
|
||||
value = smtpFromInput, onValueChange = { smtpFromInput = it },
|
||||
label = { Text("发件人地址") },
|
||||
supportingText = { Text("通常与账号相同") },
|
||||
modifier = Modifier.fillMaxWidth(), singleLine = true,
|
||||
)
|
||||
Spacer(Modifier.size(4.dp))
|
||||
OutlinedTextField(
|
||||
value = emailToInput, onValueChange = { emailToInput = it },
|
||||
label = { Text("收件人地址") },
|
||||
supportingText = { Text("转发到的目标邮箱") },
|
||||
modifier = Modifier.fillMaxWidth(), singleLine = true,
|
||||
)
|
||||
Spacer(Modifier.size(8.dp))
|
||||
Button(
|
||||
onClick = {
|
||||
app.appScope.launch {
|
||||
app.settings.setSmtpHost(smtpHostInput.trim())
|
||||
app.settings.setSmtpPort(smtpPortInput.trim().toIntOrNull() ?: 587)
|
||||
app.settings.setSmtpUser(smtpUserInput.trim())
|
||||
app.settings.setSmtpPassword(smtpPasswordInput.trim())
|
||||
app.settings.setSmtpFrom(smtpFromInput.trim())
|
||||
app.settings.setEmailTo(emailToInput.trim())
|
||||
}
|
||||
},
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) { Text("保存电邮设置") }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
item {
|
||||
SectionCard("关于与更新", subtitle = "版本检查(数据来自 GitHub Releases)") {
|
||||
com.a2i.forwarder.ui.SwitchRow(
|
||||
|
||||
Reference in New Issue
Block a user