diff --git a/CLAUDE.md b/CLAUDE.md index 7a212d7..74ba3dc 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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 最新版(`git.sunlunfan.com`,国内可达,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 必发): diff --git a/app/build.gradle.kts b/app/build.gradle.kts index abe248c..b4dfd41 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -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 { diff --git a/app/src/main/java/com/a2i/forwarder/core/EmailSender.kt b/app/src/main/java/com/a2i/forwarder/core/EmailSender.kt new file mode 100644 index 0000000..d417188 --- /dev/null +++ b/app/src/main/java/com/a2i/forwarder/core/EmailSender.kt @@ -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 = 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) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/a2i/forwarder/core/PhoneCallMonitor.kt b/app/src/main/java/com/a2i/forwarder/core/PhoneCallMonitor.kt index 1a98e88..b6a7674 100644 --- a/app/src/main/java/com/a2i/forwarder/core/PhoneCallMonitor.kt +++ b/app/src/main/java/com/a2i/forwarder/core/PhoneCallMonitor.kt @@ -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) } } } diff --git a/app/src/main/java/com/a2i/forwarder/service/NotifyListenerService.kt b/app/src/main/java/com/a2i/forwarder/service/NotifyListenerService.kt index 07336de..18ebb41 100644 --- a/app/src/main/java/com/a2i/forwarder/service/NotifyListenerService.kt +++ b/app/src/main/java/com/a2i/forwarder/service/NotifyListenerService.kt @@ -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) } } diff --git a/app/src/main/java/com/a2i/forwarder/store/SettingsStore.kt b/app/src/main/java/com/a2i/forwarder/store/SettingsStore.kt index 64fd88a..5d2d42f 100644 --- a/app/src/main/java/com/a2i/forwarder/store/SettingsStore.kt +++ b/app/src/main/java/com/a2i/forwarder/store/SettingsStore.kt @@ -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 { diff --git a/app/src/main/java/com/a2i/forwarder/ui/screens/SettingsScreen.kt b/app/src/main/java/com/a2i/forwarder/ui/screens/SettingsScreen.kt index 017284f..b6856c6 100644 --- a/app/src/main/java/com/a2i/forwarder/ui/screens/SettingsScreen.kt +++ b/app/src/main/java/com/a2i/forwarder/ui/screens/SettingsScreen.kt @@ -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(