From 39d3d735b08ef57edef5f5904d9f39624f365d6d Mon Sep 17 00:00:00 2001 From: Song <168455@qq.com> Date: Thu, 9 Jul 2026 21:16:23 +0800 Subject: [PATCH] feat: multi-config lists for ntfy/email/sms + per-channel stopOnFirst MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit All four forwarding channels now support multiple configurations (add/ edit/delete/reorder/enable per item, matching Bark server UX): - model: NtfyServer, EmailServer, SmsTarget data classes - SettingsStore: ntfyServers/emailServers/smsTargets as List; full CRUD (add/update/delete/move/setEnabled); legacy single-config auto-migrated to list via config_version; 4 stopOnFirst toggles (bark=true, others false) - BarkSender/NtfySender/EmailSender: pushToEnabled(configs, stopOnFirst) — stopOnFirst=true: first success stops; false: send to all - SmsFallbackForwarder: forward iterates smsTargets per stopOnFirst - UI: each channel SectionCard has toggle + stopOnFirst switch + config list + add button; ConfigRow + ConfigDialog reusable components - version 1.7.3 -> 1.8.0 (code 16 -> 17) Co-Authored-By: Claude Opus 4.8 (1M context) --- app/build.gradle.kts | 4 +- .../java/com/a2i/forwarder/core/BarkSender.kt | 16 + .../com/a2i/forwarder/core/EmailSender.kt | 27 +- .../java/com/a2i/forwarder/core/NtfySender.kt | 29 +- .../a2i/forwarder/core/PhoneCallMonitor.kt | 2 +- .../forwarder/core/SmsFallbackForwarder.kt | 32 +- .../java/com/a2i/forwarder/model/Models.kt | 31 + .../service/NotifyListenerService.kt | 2 +- .../com/a2i/forwarder/store/SettingsStore.kt | 255 +++++-- .../forwarder/ui/screens/SettingsScreen.kt | 696 +++++++----------- 10 files changed, 572 insertions(+), 522 deletions(-) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 2266b6c..f4d7c0d 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 = 16 - versionName = "1.7.3" + versionCode = 17 + versionName = "1.8.0" } signingConfigs { diff --git a/app/src/main/java/com/a2i/forwarder/core/BarkSender.kt b/app/src/main/java/com/a2i/forwarder/core/BarkSender.kt index f5e06d6..9ae1bdf 100644 --- a/app/src/main/java/com/a2i/forwarder/core/BarkSender.kt +++ b/app/src/main/java/com/a2i/forwarder/core/BarkSender.kt @@ -10,6 +10,22 @@ import kotlinx.coroutines.withContext * 全失败 → 返回最后一个错误;空列表 → "无可用服务器"。 */ object BarkSender { + /** stopOnFirst=true:首个成功即止;false:全部发送,至少一个成功即算成功。 */ + suspend fun pushToEnabled(servers: List, msg: BarkMessage, stopOnFirst: Boolean): Result = withContext(Dispatchers.IO) { + runCatching { + require(servers.any()) { "无可用服务器" } + if (stopOnFirst) return@runCatching pushToFirst(servers, msg).getOrThrow() + var anyOk = false + var lastErr: Throwable? = null + for (s in servers) { + val r = BarkClient(s).push(msg) + if (r.isSuccess) anyOk = true else lastErr = r.exceptionOrNull() + } + if (!anyOk) throw lastErr ?: error("全部推送失败") + } + } + + /** 首个成功即止(stopOnFirst=true 的实现)。 */ suspend fun pushToFirst(servers: List, msg: BarkMessage): Result = withContext(Dispatchers.IO) { runCatching { require(servers.any()) { "无可用服务器" } diff --git a/app/src/main/java/com/a2i/forwarder/core/EmailSender.kt b/app/src/main/java/com/a2i/forwarder/core/EmailSender.kt index d417188..4a85ef2 100644 --- a/app/src/main/java/com/a2i/forwarder/core/EmailSender.kt +++ b/app/src/main/java/com/a2i/forwarder/core/EmailSender.kt @@ -1,6 +1,7 @@ package com.a2i.forwarder.core import com.a2i.forwarder.A2iApp +import com.a2i.forwarder.model.EmailServer import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch @@ -97,13 +98,33 @@ object EmailSender { } } + /** 多配置推送:stopOnFirst=true 首个成功止;false 全部发送,至少一个成功即算成功。 */ + suspend fun pushToEnabled(configs: List, title: String, body: String, stopOnFirst: Boolean): Result = withContext(Dispatchers.IO) { + runCatching { + require(configs.any()) { "无邮箱配置" } + var anyOk = false + var lastErr: Throwable? = null + for (c in configs) { + val r = send(c.host, c.port, c.user, c.password, c.from, c.to, title, body) + if (r.isSuccess) { + anyOk = true + if (stopOnFirst) return@runCatching + } else { + lastErr = r.exceptionOrNull() + } + } + if (!anyOk) throw lastErr ?: error("全部邮件发送失败") + } + } + /** 如果电邮转发已启用且收件人非空,异步发邮件(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 + if (!s.emailForwardingEnabled.value) return + val configs = s.emailServers.value.filter { it.enabled && it.to.isNotBlank() && it.host.isNotBlank() } + if (configs.isEmpty()) 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) + pushToEnabled(configs, title, body, s.emailStopOnFirst.value) } } } \ No newline at end of file diff --git a/app/src/main/java/com/a2i/forwarder/core/NtfySender.kt b/app/src/main/java/com/a2i/forwarder/core/NtfySender.kt index eff4dbd..0f1389f 100644 --- a/app/src/main/java/com/a2i/forwarder/core/NtfySender.kt +++ b/app/src/main/java/com/a2i/forwarder/core/NtfySender.kt @@ -1,6 +1,7 @@ package com.a2i.forwarder.core import com.a2i.forwarder.A2iApp +import com.a2i.forwarder.model.NtfyServer import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.delay @@ -46,6 +47,25 @@ object NtfySender { } } + /** 多配置推送:stopOnFirst=true 首个成功止;false 全部发送,至少一个成功即算成功。 */ + suspend fun pushToEnabled(configs: List, title: String, body: String, stopOnFirst: Boolean): Result = withContext(Dispatchers.IO) { + runCatching { + require(configs.any()) { "无 ntfy 配置" } + var anyOk = false + var lastErr: Throwable? = null + for (c in configs) { + val r = send(c.server, c.topic, c.token, title, body) + if (r.isSuccess) { + anyOk = true + if (stopOnFirst) return@runCatching + } else { + lastErr = r.exceptionOrNull() + } + } + if (!anyOk) throw lastErr ?: error("全部 ntfy 推送失败") + } + } + private fun doPushWithRetry(req: Request) { var lastError: IOException? = null repeat(MAX_ATTEMPTS) { attempt -> @@ -59,6 +79,9 @@ object NtfySender { if (attempt < MAX_ATTEMPTS - 1) { Thread.sleep(BACKOFF_MS[attempt]) } + } catch (e: IllegalStateException) { + // HTTP 错误码(error() 抛 IllegalStateException),不重试 + throw e } } throw lastError ?: error("ntfy 推送失败") @@ -66,9 +89,11 @@ object NtfySender { fun trySend(app: A2iApp, title: String, body: String, scope: CoroutineScope) { val s = app.settings - if (!s.ntfyEnabled.value || s.ntfyTopic.value.isBlank()) return + if (!s.ntfyEnabled.value) return + val configs = s.ntfyServers.value.filter { it.enabled && it.topic.isNotBlank() } + if (configs.isEmpty()) return scope.launch { - NtfySender.send(s.ntfyServer.value, s.ntfyTopic.value, s.ntfyToken.value, title, body) + pushToEnabled(configs, title, body, s.ntfyStopOnFirst.value) } } 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 ad0872d..df8e29e 100644 --- a/app/src/main/java/com/a2i/forwarder/core/PhoneCallMonitor.kt +++ b/app/src/main/java/com/a2i/forwarder/core/PhoneCallMonitor.kt @@ -129,7 +129,7 @@ class PhoneCallMonitor(private val context: Context) { NtfySender.trySend(app, msg.title ?: logBody, msg.body ?: "", scope) return@launch } - val result = BarkSender.pushToFirst(servers, msg) + val result = BarkSender.pushToEnabled(servers, msg, app.settings.barkStopOnFirst.value) val now = System.currentTimeMillis() if (result.isSuccess) { app.logStore.addLog( diff --git a/app/src/main/java/com/a2i/forwarder/core/SmsFallbackForwarder.kt b/app/src/main/java/com/a2i/forwarder/core/SmsFallbackForwarder.kt index d1822c2..b7d15e2 100644 --- a/app/src/main/java/com/a2i/forwarder/core/SmsFallbackForwarder.kt +++ b/app/src/main/java/com/a2i/forwarder/core/SmsFallbackForwarder.kt @@ -32,7 +32,7 @@ class SmsFallbackForwarder(private val context: Context) { return t.contains("来电") || t.contains("未接") } - /** 尝试用短信转发。返回 true 表示已处理(无论成功失败)。 */ + /** 尝试用短信转发(遍历所有启用的目标号,按 stopOnFirst 决定发通一条止还是全发)。 */ fun forward(d: Decision) { scope.launch { if (!settings.smsFallbackEnabled.value) return@launch @@ -41,8 +41,8 @@ class SmsFallbackForwarder(private val context: Context) { return@launch } if (!isSmsWorthy(d)) return@launch - val target = settings.smsTargetNumber.value.trim() - if (target.isBlank()) { + val targets = settings.smsTargets.value.filter { it.enabled && it.number.isNotBlank() } + if (targets.isEmpty()) { log(d, "failed", "未设置短信目标号") return@launch } @@ -62,22 +62,34 @@ class SmsFallbackForwarder(private val context: Context) { } val body = compose(d).take(70) - val result = runCatching { - SmsManager.getDefault().sendTextMessage(target, null, body, null, null) + val stopOnFirst = settings.smsStopOnFirst.value + var anyOk = false + var sentCount = 0 + var lastReason = "短信发送失败" + for (t in targets) { + val result = runCatching { + SmsManager.getDefault().sendTextMessage(t.number.trim(), null, body, null, null) + } + if (result.isSuccess) { + anyOk = true + sentCount++ + if (stopOnFirst) break + } else { + lastReason = result.exceptionOrNull()?.message ?: "短信发送失败" + } } - if (result.isSuccess) { + if (anyOk) { settings.setLastSmsSentTime(now) log(d, "sent", "") - // 转发后刷新余额:自动模式发查询短信,手动模式递减 + // 余额递减:按成功发送条数;自动模式发查询短信,手动模式递减 val carrier = app.carrierBalanceQuery.currentCarrier() if (carrier.serviceNumber != null && carrier.queryCmd != null) { app.carrierBalanceQuery.refresh() } else { - app.carrierBalanceQuery.decrementManual() + repeat(sentCount) { runCatching { app.carrierBalanceQuery.decrementManual() } } } } else { - val reason = result.exceptionOrNull()?.message ?: "短信发送失败" - log(d, "failed", reason) + log(d, "failed", lastReason) } } } diff --git a/app/src/main/java/com/a2i/forwarder/model/Models.kt b/app/src/main/java/com/a2i/forwarder/model/Models.kt index 20d5822..519cee9 100644 --- a/app/src/main/java/com/a2i/forwarder/model/Models.kt +++ b/app/src/main/java/com/a2i/forwarder/model/Models.kt @@ -13,3 +13,34 @@ data class BarkServer( ) const val OFFICIAL_BARK_SERVER = "https://api.day.app" + +@Serializable +data class NtfyServer( + val id: String = UUID.randomUUID().toString(), + val name: String, + val server: String = "https://ntfy.sh", + val topic: String = "", + val token: String = "", + val enabled: Boolean = true, +) + +@Serializable +data class EmailServer( + val id: String = UUID.randomUUID().toString(), + val name: String, + val host: String = "", + val port: Int = 465, + val user: String = "", + val password: String = "", + val from: String = "", + val to: String = "", + val enabled: Boolean = true, +) + +@Serializable +data class SmsTarget( + val id: String = UUID.randomUUID().toString(), + val name: String, + val number: String = "", + val enabled: Boolean = true, +) 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 9de572c..2f47c4a 100644 --- a/app/src/main/java/com/a2i/forwarder/service/NotifyListenerService.kt +++ b/app/src/main/java/com/a2i/forwarder/service/NotifyListenerService.kt @@ -68,7 +68,7 @@ class NotifyListenerService : NotificationListenerService() { return } - val result = BarkSender.pushToFirst(servers, msg) + val result = BarkSender.pushToEnabled(servers, msg, app.settings.barkStopOnFirst.value) if (result.isSuccess) { app.logStore.addLog(ForwardLog(now, sbn.packageName, d.appLabel, msg.title, msg.body, "sent", "")) } else { 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 5206cee..7a3fa09 100644 --- a/app/src/main/java/com/a2i/forwarder/store/SettingsStore.kt +++ b/app/src/main/java/com/a2i/forwarder/store/SettingsStore.kt @@ -10,7 +10,10 @@ import androidx.datastore.preferences.core.longPreferencesKey import androidx.datastore.preferences.core.stringPreferencesKey import androidx.datastore.preferences.preferencesDataStore import com.a2i.forwarder.model.BarkServer +import com.a2i.forwarder.model.EmailServer +import com.a2i.forwarder.model.NtfyServer import com.a2i.forwarder.model.OFFICIAL_BARK_SERVER +import com.a2i.forwarder.model.SmsTarget import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asStateFlow @@ -38,9 +41,14 @@ class SettingsStore( val SPECIAL = booleanPreferencesKey("special") val SKIP_ONGOING = booleanPreferencesKey("skip_ongoing") val AD_KW = stringPreferencesKey("ad_keywords") - // 短信兜底 + // 各通道「发通一条止 / 全发」开关 + val BARK_STOP_FIRST = booleanPreferencesKey("bark_stop_first") + val NTFY_STOP_FIRST = booleanPreferencesKey("ntfy_stop_first") + val EMAIL_STOP_FIRST = booleanPreferencesKey("email_stop_first") + val SMS_STOP_FIRST = booleanPreferencesKey("sms_stop_first") + // 短信兜底(目标号已列表化;运营商/余额/限频保持全局) val SMS_FALLBACK = booleanPreferencesKey("sms_fallback") - val SMS_TARGET = stringPreferencesKey("sms_target") + val SMS_TARGETS = stringPreferencesKey("sms_targets") val SMS_CARRIER = stringPreferencesKey("sms_carrier") val SMS_QUOTA = intPreferencesKey("sms_quota") val SMS_BALANCE = intPreferencesKey("sms_balance") @@ -48,21 +56,27 @@ class SettingsStore( val SMS_LAST_SENT = longPreferencesKey("sms_last_sent") // 电邮转发 val EMAIL_ENABLED = booleanPreferencesKey("email_enabled") + val EMAIL_SERVERS = stringPreferencesKey("email_servers") + // ntfy 转发 + val NTFY_ENABLED = booleanPreferencesKey("ntfy_enabled") + val NTFY_SERVERS = stringPreferencesKey("ntfy_servers") + // 应用更新 + val UPDATE_CHECK_AUTO = booleanPreferencesKey("update_check_auto") + val LAST_UPDATE_CHECK = longPreferencesKey("last_update_check") + val IGNORED_VERSION = stringPreferencesKey("ignored_version") + // 配置版本(用于旧单组配置迁移) + val CONFIG_VERSION = intPreferencesKey("config_version") + // ---- 旧单组键(仅迁移用,迁移后清理)---- + val SMS_TARGET = stringPreferencesKey("sms_target") 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") - // ntfy 转发 - val NTFY_ENABLED = booleanPreferencesKey("ntfy_enabled") val NTFY_SERVER = stringPreferencesKey("ntfy_server") val NTFY_TOPIC = stringPreferencesKey("ntfy_topic") val NTFY_TOKEN = stringPreferencesKey("ntfy_token") - // 应用更新 - val UPDATE_CHECK_AUTO = booleanPreferencesKey("update_check_auto") - val LAST_UPDATE_CHECK = longPreferencesKey("last_update_check") - val IGNORED_VERSION = stringPreferencesKey("ignored_version") } private val defaultServers = listOf( @@ -72,42 +86,45 @@ class SettingsStore( private val _servers = MutableStateFlow(defaultServers) val servers = _servers.asStateFlow() + private val _ntfyServers = MutableStateFlow>(emptyList()) + val ntfyServers = _ntfyServers.asStateFlow() + + private val _emailServers = MutableStateFlow>(emptyList()) + val emailServers = _emailServers.asStateFlow() + + private val _smsTargets = MutableStateFlow>(emptyList()) + val smsTargets = _smsTargets.asStateFlow() + val iconPrefix = MutableStateFlow(DEFAULT_ICON_PREFIX) val forwardingEnabled = MutableStateFlow(true) val codeEnabled = MutableStateFlow(true) val adFilterEnabled = MutableStateFlow(true) val specialEnabled = MutableStateFlow(true) val skipOngoing = MutableStateFlow(true) - val adKeywords = MutableStateFlow>(emptyList()) // 用户自定义关键词 + val adKeywords = MutableStateFlow>(emptyList()) - // 短信兜底 + // 各通道「发通一条止 / 全发」 + val barkStopOnFirst = MutableStateFlow(true) + val ntfyStopOnFirst = MutableStateFlow(false) + val emailStopOnFirst = MutableStateFlow(false) + val smsStopOnFirst = MutableStateFlow(false) + + // 短信兜底(全局) val smsFallbackEnabled = MutableStateFlow(false) - val smsTargetNumber = MutableStateFlow("") - val smsCarrier = MutableStateFlow("auto") // "auto" 或 Carrier.name - val smsManualQuota = MutableStateFlow(0) // 手动额度(自动解析失败时回退用) - val smsBalance = MutableStateFlow(-1) // 缓存余额,-1=未知 - val smsSuspended = MutableStateFlow(false) // 余额 ≤5 自动挂起 - val lastSmsSentTime = MutableStateFlow(0L) // 限频时间戳 + val smsCarrier = MutableStateFlow("auto") + val smsManualQuota = MutableStateFlow(0) + val smsBalance = MutableStateFlow(-1) + val smsSuspended = MutableStateFlow(false) + val lastSmsSentTime = MutableStateFlow(0L) + + // 电邮/ntfy 总开关 + val emailForwardingEnabled = MutableStateFlow(false) + val ntfyEnabled = MutableStateFlow(false) // 应用更新 - val updateCheckEnabled = MutableStateFlow(true) // 自动检查更新开关 - val lastUpdateCheckTime = MutableStateFlow(0L) // 上次检查时间戳 - val ignoredVersion = MutableStateFlow("") // 用户忽略的版本号 - - // 电邮转发 - val emailForwardingEnabled = MutableStateFlow(false) - val smtpHost = MutableStateFlow("") - val smtpPort = MutableStateFlow(465) - val smtpUser = MutableStateFlow("") - val smtpPassword = MutableStateFlow("") - val smtpFrom = MutableStateFlow("") - val emailTo = MutableStateFlow("") - - // ntfy 转发 - val ntfyEnabled = MutableStateFlow(false) - val ntfyServer = MutableStateFlow("https://ntfy.sh") - val ntfyTopic = MutableStateFlow("") - val ntfyToken = MutableStateFlow("") + val updateCheckEnabled = MutableStateFlow(true) + val lastUpdateCheckTime = MutableStateFlow(0L) + val ignoredVersion = MutableStateFlow("") init { scope.launch { @@ -125,70 +142,160 @@ class SettingsStore( adKeywords.value = p[K.AD_KW] ?.let { runCatching { json.decodeFromString>(it) }.getOrNull() } ?: emptyList() - // 短信兜底 + // 各通道 stopOnFirst + barkStopOnFirst.value = p[K.BARK_STOP_FIRST] ?: true + ntfyStopOnFirst.value = p[K.NTFY_STOP_FIRST] ?: false + emailStopOnFirst.value = p[K.EMAIL_STOP_FIRST] ?: false + smsStopOnFirst.value = p[K.SMS_STOP_FIRST] ?: false + // 短信兜底全局 smsFallbackEnabled.value = p[K.SMS_FALLBACK] ?: false - smsTargetNumber.value = p[K.SMS_TARGET] ?: "" smsCarrier.value = p[K.SMS_CARRIER] ?: "auto" smsManualQuota.value = p[K.SMS_QUOTA] ?: 0 smsBalance.value = p[K.SMS_BALANCE] ?: -1 smsSuspended.value = p[K.SMS_SUSPENDED] ?: false lastSmsSentTime.value = p[K.SMS_LAST_SENT] ?: 0L + // 电邮/ntfy 总开关 + emailForwardingEnabled.value = p[K.EMAIL_ENABLED] ?: false + ntfyEnabled.value = p[K.NTFY_ENABLED] ?: false // 应用更新 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]?.let { if (it < 25) 465 else it } ?: 465 - smtpUser.value = p[K.EMAIL_USER] ?: "" - smtpPassword.value = p[K.EMAIL_PASS] ?: "" - smtpFrom.value = p[K.EMAIL_FROM] ?: "" - emailTo.value = p[K.EMAIL_TO] ?: "" - // ntfy 转发 - ntfyEnabled.value = p[K.NTFY_ENABLED] ?: false - ntfyServer.value = p[K.NTFY_SERVER] ?: "https://ntfy.sh" - ntfyTopic.value = p[K.NTFY_TOPIC] ?: "" - ntfyToken.value = p[K.NTFY_TOKEN] ?: "" + // 列表配置 + _ntfyServers.value = p[K.NTFY_SERVERS] + ?.let { runCatching { json.decodeFromString>(it) }.getOrNull() } + ?: emptyList() + _emailServers.value = p[K.EMAIL_SERVERS] + ?.let { runCatching { json.decodeFromString>(it) }.getOrNull() } + ?: emptyList() + _smsTargets.value = p[K.SMS_TARGETS] + ?.let { runCatching { json.decodeFromString>(it) }.getOrNull() } + ?: emptyList() + + // ---- 旧单组配置迁移(config_version < 2)---- + val cfgVer = p[K.CONFIG_VERSION] ?: 1 + if (cfgVer < 2) { + migrateLegacyConfigs(p) + } } } } + /** 把旧的单组 ntfy/email/sms 配置转成列表的一条。 */ + private suspend fun migrateLegacyConfigs(p: Preferences) { + var ntfyList = _ntfyServers.value + var emailList = _emailServers.value + var smsList = _smsTargets.value + + val nTopic = p[K.NTFY_TOPIC] + if (!nTopic.isNullOrBlank() && ntfyList.isEmpty()) { + ntfyList = listOf(NtfyServer(name = "ntfy", server = p[K.NTFY_SERVER] ?: "https://ntfy.sh", topic = nTopic, token = p[K.NTFY_TOKEN] ?: "")) + } + val eHost = p[K.EMAIL_HOST] + val eTo = p[K.EMAIL_TO] + if (!eHost.isNullOrBlank() && emailList.isEmpty()) { + emailList = listOf(EmailServer( + name = "邮箱", host = eHost, + port = (p[K.EMAIL_PORT] ?: 465).let { if (it < 25) 465 else it }, + user = p[K.EMAIL_USER] ?: "", password = p[K.EMAIL_PASS] ?: "", + from = p[K.EMAIL_FROM] ?: "", to = eTo ?: "", + )) + } + val sTarget = p[K.SMS_TARGET] + if (!sTarget.isNullOrBlank() && smsList.isEmpty()) { + smsList = listOf(SmsTarget(name = "iPhone", number = sTarget)) + } + + ds.edit { m -> + if (ntfyList.isNotEmpty()) { + m[K.NTFY_SERVERS] = json.encodeToString(ntfyList) + m.remove(K.NTFY_SERVER); m.remove(K.NTFY_TOPIC); m.remove(K.NTFY_TOKEN) + } + if (emailList.isNotEmpty()) { + m[K.EMAIL_SERVERS] = json.encodeToString(emailList) + listOf(K.EMAIL_HOST, K.EMAIL_PORT, K.EMAIL_USER, K.EMAIL_PASS, K.EMAIL_FROM, K.EMAIL_TO).forEach { m.remove(it) } + } + if (smsList.isNotEmpty()) { + m[K.SMS_TARGETS] = json.encodeToString(smsList) + m.remove(K.SMS_TARGET) + } + m[K.CONFIG_VERSION] = 2 + } + _ntfyServers.value = ntfyList + _emailServers.value = emailList + _smsTargets.value = smsList + } + private suspend fun edit(block: (androidx.datastore.preferences.core.MutablePreferences) -> Unit) = ds.edit(block) + // ---- Bark 服务器 CRUD ---- private suspend fun saveServers(list: List) { edit { it[K.SERVERS] = json.encodeToString(list) } _servers.value = list } - suspend fun addServer(name: String, server: String, key: String): BarkServer { val s = BarkServer(name = name.trim(), server = server.trim().removeSuffix("/"), deviceKey = key.trim()) saveServers(_servers.value + s) return s } - suspend fun updateServer(s: BarkServer) = saveServers(_servers.value.map { if (it.id == s.id) s.copy(server = s.server.trim().removeSuffix("/")) else it }) - - suspend fun deleteServer(id: String) { - val list = _servers.value.filterNot { it.id == id } - saveServers(list) - } - - /** 上移/下移服务器(改变转发顺序) */ + suspend fun deleteServer(id: String) = saveServers(_servers.value.filterNot { it.id == id }) suspend fun moveServer(id: String, up: Boolean) { val list = _servers.value.toMutableList() - val idx = list.indexOfFirst { it.id == id } - if (idx < 0) return - val target = if (up) idx - 1 else idx + 1 - if (target < 0 || target >= list.size) return - java.util.Collections.swap(list, idx, target) - saveServers(list) + val i = list.indexOfFirst { it.id == id } + if (i in 1..list.lastIndex && up) { java.util.Collections.swap(list, i, i - 1); saveServers(list) } + else if (i in 0 until list.lastIndex && !up) { java.util.Collections.swap(list, i, i + 1); saveServers(list) } } + suspend fun setServerEnabled(id: String, enabled: Boolean) = saveServers(_servers.value.map { if (it.id == id) it.copy(enabled = enabled) else it }) - suspend fun setServerEnabled(id: String, enabled: Boolean) { - saveServers(_servers.value.map { if (it.id == id) it.copy(enabled = enabled) else it }) + // ---- ntfy 服务器 CRUD ---- + private suspend fun saveNtfyServers(list: List) { + edit { it[K.NTFY_SERVERS] = json.encodeToString(list) } + _ntfyServers.value = list } + suspend fun addNtfy(s: NtfyServer) { saveNtfyServers(_ntfyServers.value + s) } + suspend fun updateNtfy(s: NtfyServer) = saveNtfyServers(_ntfyServers.value.map { if (it.id == s.id) s else it }) + suspend fun deleteNtfy(id: String) = saveNtfyServers(_ntfyServers.value.filterNot { it.id == id }) + suspend fun moveNtfy(id: String, up: Boolean) { + val list = _ntfyServers.value.toMutableList() + val i = list.indexOfFirst { it.id == id } + if (i in 1..list.lastIndex && up) { java.util.Collections.swap(list, i, i - 1); saveNtfyServers(list) } + else if (i in 0 until list.lastIndex && !up) { java.util.Collections.swap(list, i, i + 1); saveNtfyServers(list) } + } + suspend fun setNtfyEnabled(id: String, enabled: Boolean) = saveNtfyServers(_ntfyServers.value.map { if (it.id == id) it.copy(enabled = enabled) else it }) + + // ---- 电邮服务器 CRUD ---- + private suspend fun saveEmailServers(list: List) { + edit { it[K.EMAIL_SERVERS] = json.encodeToString(list) } + _emailServers.value = list + } + suspend fun addEmail(s: EmailServer) { saveEmailServers(_emailServers.value + s) } + suspend fun updateEmail(s: EmailServer) = saveEmailServers(_emailServers.value.map { if (it.id == s.id) s else it }) + suspend fun deleteEmail(id: String) = saveEmailServers(_emailServers.value.filterNot { it.id == id }) + suspend fun moveEmail(id: String, up: Boolean) { + val list = _emailServers.value.toMutableList() + val i = list.indexOfFirst { it.id == id } + if (i in 1..list.lastIndex && up) { java.util.Collections.swap(list, i, i - 1); saveEmailServers(list) } + else if (i in 0 until list.lastIndex && !up) { java.util.Collections.swap(list, i, i + 1); saveEmailServers(list) } + } + suspend fun setEmailEnabled(id: String, enabled: Boolean) = saveEmailServers(_emailServers.value.map { if (it.id == id) it.copy(enabled = enabled) else it }) + + // ---- 短信目标 CRUD ---- + private suspend fun saveSmsTargets(list: List) { + edit { it[K.SMS_TARGETS] = json.encodeToString(list) } + _smsTargets.value = list + } + suspend fun addSmsTarget(s: SmsTarget) { saveSmsTargets(_smsTargets.value + s) } + suspend fun updateSmsTarget(s: SmsTarget) = saveSmsTargets(_smsTargets.value.map { if (it.id == s.id) s else it }) + suspend fun deleteSmsTarget(id: String) = saveSmsTargets(_smsTargets.value.filterNot { it.id == id }) + suspend fun moveSmsTarget(id: String, up: Boolean) { + val list = _smsTargets.value.toMutableList() + val i = list.indexOfFirst { it.id == id } + if (i in 1..list.lastIndex && up) { java.util.Collections.swap(list, i, i - 1); saveSmsTargets(list) } + else if (i in 0 until list.lastIndex && !up) { java.util.Collections.swap(list, i, i + 1); saveSmsTargets(list) } + } + suspend fun setSmsTargetEnabled(id: String, enabled: Boolean) = saveSmsTargets(_smsTargets.value.map { if (it.id == id) it.copy(enabled = enabled) else it }) suspend fun setIconPrefix(v: String) { edit { it[K.ICON_PREFIX] = v }; iconPrefix.value = v } suspend fun setForwarding(v: Boolean) { edit { it[K.FWD] = v }; forwardingEnabled.value = v } @@ -201,8 +308,12 @@ class SettingsStore( adKeywords.value = list } + suspend fun setBarkStopOnFirst(v: Boolean) { edit { it[K.BARK_STOP_FIRST] = v }; barkStopOnFirst.value = v } + suspend fun setNtfyStopOnFirst(v: Boolean) { edit { it[K.NTFY_STOP_FIRST] = v }; ntfyStopOnFirst.value = v } + suspend fun setEmailStopOnFirst(v: Boolean) { edit { it[K.EMAIL_STOP_FIRST] = v }; emailStopOnFirst.value = v } + suspend fun setSmsStopOnFirst(v: Boolean) { edit { it[K.SMS_STOP_FIRST] = v }; smsStopOnFirst.value = v } + suspend fun setSmsFallback(v: Boolean) { edit { it[K.SMS_FALLBACK] = v }; smsFallbackEnabled.value = v } - suspend fun setSmsTargetNumber(v: String) { edit { it[K.SMS_TARGET] = v }; smsTargetNumber.value = v } suspend fun setSmsCarrier(v: String) { edit { it[K.SMS_CARRIER] = v }; smsCarrier.value = v } suspend fun setSmsManualQuota(v: Int) { edit { it[K.SMS_QUOTA] = v }; smsManualQuota.value = v } suspend fun setSmsBalance(v: Int) { edit { it[K.SMS_BALANCE] = v }; smsBalance.value = v } @@ -214,17 +325,7 @@ class SettingsStore( 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 setNtfyEnabled(v: Boolean) { edit { it[K.NTFY_ENABLED] = v }; ntfyEnabled.value = v } - suspend fun setNtfyServer(v: String) { edit { it[K.NTFY_SERVER] = v }; ntfyServer.value = v } - suspend fun setNtfyTopic(v: String) { edit { it[K.NTFY_TOPIC] = v }; ntfyTopic.value = v } - suspend fun setNtfyToken(v: String) { edit { it[K.NTFY_TOKEN] = v }; ntfyToken.value = v } suspend fun snapshot() = ds.data.first() 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 103707f..4bfd825 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 @@ -7,7 +7,6 @@ import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer @@ -34,13 +33,11 @@ import androidx.compose.material3.AlertDialog import androidx.compose.material3.Button import androidx.compose.material3.Checkbox import androidx.compose.material3.CircularProgressIndicator -import androidx.compose.material3.FilledTonalButton import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedButton import androidx.compose.material3.OutlinedTextField -import androidx.compose.material3.RadioButton import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.material3.TextButton @@ -60,15 +57,21 @@ import androidx.compose.ui.unit.dp import androidx.documentfile.provider.DocumentFile import com.a2i.forwarder.A2iApp import com.a2i.forwarder.core.BarkClient +import com.a2i.forwarder.core.EmailSender import com.a2i.forwarder.core.IconExporter +import com.a2i.forwarder.core.NtfySender import com.a2i.forwarder.model.BarkMessage import com.a2i.forwarder.model.BarkServer +import com.a2i.forwarder.model.EmailServer +import com.a2i.forwarder.model.NtfyServer import com.a2i.forwarder.model.OFFICIAL_BARK_SERVER +import com.a2i.forwarder.model.SmsTarget import com.a2i.forwarder.ui.ClickRow import com.a2i.forwarder.ui.ScreenTitle -import com.a2i.forwarder.ui.appVersionName import com.a2i.forwarder.ui.SectionCard import com.a2i.forwarder.ui.StatusBadge +import com.a2i.forwarder.ui.SwitchRow +import com.a2i.forwarder.ui.appVersionName import kotlinx.coroutines.launch @Composable @@ -76,569 +79,410 @@ fun SettingsScreen(onOpenLog: () -> Unit) { val context = LocalContext.current val app = A2iApp.instance val servers by app.settings.servers.collectAsState() + val ntfyServers by app.settings.ntfyServers.collectAsState() + val emailServers by app.settings.emailServers.collectAsState() + val smsTargets by app.settings.smsTargets.collectAsState() + val barkStopFirst by app.settings.barkStopOnFirst.collectAsState() + val ntfyStopFirst by app.settings.ntfyStopOnFirst.collectAsState() + val emailStopFirst by app.settings.emailStopOnFirst.collectAsState() + val smsStopFirst by app.settings.smsStopOnFirst.collectAsState() + val ntfyEnabled by app.settings.ntfyEnabled.collectAsState() + val emailEnabled by app.settings.emailForwardingEnabled.collectAsState() + val smsFallback by app.settings.smsFallbackEnabled.collectAsState() val iconPrefix by app.settings.iconPrefix.collectAsState() + val smsBalance by app.settings.smsBalance.collectAsState() + val smsSuspended by app.settings.smsSuspended.collectAsState() + val smsManualQuota by app.settings.smsManualQuota.collectAsState() + val updateAuto by app.settings.updateCheckEnabled.collectAsState() + val updateInfo by app.updateChecker.updateInfo.collectAsState() + val prefs = remember { context.getSharedPreferences("a2i_ui", Context.MODE_PRIVATE) } var treeUri by remember { mutableStateOf(prefs.getString("icon_tree", null)?.let { runCatching { Uri.parse(it) }.getOrNull() }) } var exporting by remember { mutableStateOf(false) } var exportResult by remember { mutableStateOf(null) } - var showAdd by remember { mutableStateOf(false) } - var editing by remember { mutableStateOf(null) } - - // 应用更新状态 - val updateAuto by app.settings.updateCheckEnabled.collectAsState() - val updateInfo by app.updateChecker.updateInfo.collectAsState() var checking by remember { mutableStateOf(false) } var checkMsg by remember { mutableStateOf(null) } - - // 短信兜底状态 - val smsFallback by app.settings.smsFallbackEnabled.collectAsState() - val smsTarget by app.settings.smsTargetNumber.collectAsState() - val smsCarrier by app.settings.smsCarrier.collectAsState() - val smsQuota by app.settings.smsManualQuota.collectAsState() - val smsBalance by app.settings.smsBalance.collectAsState() - val smsSuspended by app.settings.smsSuspended.collectAsState() - var smsTargetInput by remember(smsTarget) { mutableStateOf(smsTarget) } - 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) } - var emailSaved by remember { mutableStateOf(false) } - // ntfy 转发状态 - val ntfyEnabled by app.settings.ntfyEnabled.collectAsState() - val ntfyServer by app.settings.ntfyServer.collectAsState() - val ntfyTopic by app.settings.ntfyTopic.collectAsState() - val ntfyToken by app.settings.ntfyToken.collectAsState() - var ntfyServerInput by remember(ntfyServer) { mutableStateOf(ntfyServer) } - var ntfyTopicInput by remember(ntfyTopic) { mutableStateOf(ntfyTopic) } - var ntfyTokenInput by remember(ntfyToken) { mutableStateOf(ntfyToken) } - var ntfySaved by remember { mutableStateOf(false) } - var smsNumSaved by remember { mutableStateOf(false) } - var smsQuotaSaved by remember { mutableStateOf(false) } - - val smsPermLauncher = rememberLauncherForActivityResult(ActivityResultContracts.RequestPermission()) { } val hasSmsPerm = remember { androidx.core.content.ContextCompat.checkSelfPermission(context, android.Manifest.permission.SEND_SMS) == android.content.pm.PackageManager.PERMISSION_GRANTED } - + val smsPermLauncher = rememberLauncherForActivityResult(ActivityResultContracts.RequestPermission()) { } val treeLauncher = rememberLauncherForActivityResult(ActivityResultContracts.OpenDocumentTree()) { uri -> if (uri != null) { - runCatching { - context.contentResolver.takePersistableUriPermission( - uri, - Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION, - ) - } + runCatching { context.contentResolver.takePersistableUriPermission(uri, Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION) } prefs.edit().putString("icon_tree", uri.toString()).apply() treeUri = uri } } + // 弹窗状态 + var showAddBark by remember { mutableStateOf(false) } + var editingBark by remember { mutableStateOf(null) } + var showAddNtfy by remember { mutableStateOf(false) } + var editingNtfy by remember { mutableStateOf(null) } + var showAddEmail by remember { mutableStateOf(false) } + var editingEmail by remember { mutableStateOf(null) } + var showAddSms by remember { mutableStateOf(false) } + var editingSms by remember { mutableStateOf(null) } + LazyColumn( modifier = Modifier.fillMaxSize().padding(horizontal = 16.dp), verticalArrangement = Arrangement.spacedBy(12.dp), ) { - item { ScreenTitle("设置", "管理 Bark 服务、图标资源和维护操作") } + item { ScreenTitle("设置", "管理各转发通道、图标和维护") } + // ===== Bark 服务器 ===== item { - SectionCard("Bark 服务器", subtitle = "勾选的服务器按列表顺序依次尝试转发,首个成功即止") { - if (servers.isEmpty()) { - Text("暂无服务器,请先添加。", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant) - } else { + SectionCard("Bark 服务器", subtitle = "勾选的服务器参与转发;开关切换容错/广播") { + SwitchRow( + title = "发通一条就停", + subtitle = if (barkStopFirst) "容错模式:首个成功即止" else "广播模式:每个都发", + checked = barkStopFirst, + icon = Icons.Filled.Settings, + ) { v -> app.appScope.launch { app.settings.setBarkStopOnFirst(v) } } + if (servers.isNotEmpty()) { + Spacer(Modifier.size(8.dp)) Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { - servers.forEachIndexed { index, s -> - ServerRow( - server = s, - isFirst = index == 0, - isLast = index == servers.lastIndex, + servers.forEachIndexed { i, s -> + ServerRow(s, i == 0, i == servers.lastIndex, onToggle = { v -> app.appScope.launch { app.settings.setServerEnabled(s.id, v) } }, - onEdit = { editing = s }, + onEdit = { editingBark = s }, onDelete = { app.appScope.launch { app.settings.deleteServer(s.id) } }, - onMoveUp = { app.appScope.launch { app.settings.moveServer(s.id, up = true) } }, - onMoveDown = { app.appScope.launch { app.settings.moveServer(s.id, up = false) } }, + onMoveUp = { app.appScope.launch { app.settings.moveServer(s.id, true) } }, + onMoveDown = { app.appScope.launch { app.settings.moveServer(s.id, false) } }, ) } } } - Button( - onClick = { showAdd = true }, - modifier = Modifier.fillMaxWidth().padding(top = 12.dp), - ) { - Icon(Icons.Filled.Add, null, modifier = Modifier.size(18.dp)) - Spacer(Modifier.width(8.dp)) - Text("添加服务器") - } + AddButton { showAddBark = true } } } + // ===== ntfy ===== item { - SectionCard("ntfy 转发", subtitle = "POST 推送到 ntfy 服务;与 Bark 并行,互不依赖") { - com.a2i.forwarder.ui.SwitchRow( - title = "启用 ntfy 转发", - subtitle = if (ntfyEnabled) "同时推送通知到 ntfy" else "当前关闭", - checked = ntfyEnabled, - ) { v -> app.appScope.launch { app.settings.setNtfyEnabled(v) } } - + SectionCard("ntfy 转发", subtitle = "POST 推送到 ntfy 服务") { + SwitchRow(title = "启用 ntfy 转发", subtitle = if (ntfyEnabled) "已开启" else "已关闭", checked = ntfyEnabled) { v -> app.appScope.launch { app.settings.setNtfyEnabled(v) } } if (ntfyEnabled) { - Spacer(Modifier.size(8.dp)) - OutlinedTextField( - value = ntfyServerInput, onValueChange = { ntfyServerInput = it }, - label = { Text("服务器地址") }, - supportingText = { Text("如 https://ntfy.sh;自建填自建地址") }, - modifier = Modifier.fillMaxWidth(), singleLine = true, - ) - Spacer(Modifier.size(4.dp)) - OutlinedTextField( - value = ntfyTopicInput, onValueChange = { ntfyTopicInput = it }, - label = { Text("Topic") }, - supportingText = { Text("推送目标 topic(必填)") }, - modifier = Modifier.fillMaxWidth(), singleLine = true, - ) - Spacer(Modifier.size(4.dp)) - OutlinedTextField( - value = ntfyTokenInput, onValueChange = { ntfyTokenInput = it }, - label = { Text("Token(可选)") }, - supportingText = { Text("私有 topic 需填 Bearer token;公开 topic 留空") }, - modifier = Modifier.fillMaxWidth(), singleLine = true, - ) - Spacer(Modifier.size(8.dp)) - Button( - onClick = { - app.appScope.launch { - app.settings.setNtfyServer(ntfyServerInput.trim()) - app.settings.setNtfyTopic(ntfyTopicInput.trim()) - app.settings.setNtfyToken(ntfyTokenInput.trim()) + SwitchRow(title = "发通一条就停", subtitle = if (ntfyStopFirst) "容错:首个成功止" else "广播:每个都发", checked = ntfyStopFirst) { v -> app.appScope.launch { app.settings.setNtfyStopOnFirst(v) } } + if (ntfyServers.isNotEmpty()) { + Spacer(Modifier.size(8.dp)) + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + ntfyServers.forEachIndexed { i, s -> + ConfigRow( + title = s.name.ifBlank { s.topic }, + subtitle = "${s.server} · topic: ${s.topic}${if (s.token.isNotBlank()) " · token已填" else ""}", + enabled = s.enabled, isFirst = i == 0, isLast = i == ntfyServers.lastIndex, + onToggle = { v -> app.appScope.launch { app.settings.setNtfyEnabled(s.id, v) } }, + onEdit = { editingNtfy = s }, onDelete = { app.appScope.launch { app.settings.deleteNtfy(s.id) } }, + onMoveUp = { app.appScope.launch { app.settings.moveNtfy(s.id, true) } }, + onMoveDown = { app.appScope.launch { app.settings.moveNtfy(s.id, false) } }, + onTest = { app.appScope.launch { NtfySender.send(s.server, s.topic, s.token, "a2i测试", "ntfy配置测试") } }, + ) } - ntfySaved = true - }, - modifier = Modifier.fillMaxWidth(), - ) { Text(if (ntfySaved) "✓ 已保存" else "保存 ntfy 设置") } - if (ntfySaved) { - LaunchedEffect(Unit) { kotlinx.coroutines.delay(2000); ntfySaved = false } + } } + AddButton { showAddNtfy = true } } } } + // ===== 电邮 ===== 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) } } - + SectionCard("电邮转发", subtitle = "SMTPS 直发邮件") { + 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() ?: 465) - app.settings.setSmtpUser(smtpUserInput.trim()) - app.settings.setSmtpPassword(smtpPasswordInput.trim()) - app.settings.setSmtpFrom(smtpFromInput.trim()) - app.settings.setEmailTo(emailToInput.trim()) + SwitchRow(title = "发通一条就停", subtitle = if (emailStopFirst) "容错:首个成功止" else "广播:每个都发", checked = emailStopFirst) { v -> app.appScope.launch { app.settings.setEmailStopOnFirst(v) } } + if (emailServers.isNotEmpty()) { + Spacer(Modifier.size(8.dp)) + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + emailServers.forEachIndexed { i, s -> + ConfigRow( + title = s.name.ifBlank { s.to }, + subtitle = "${s.host}:${s.port} · ${s.user} → ${s.to}", + enabled = s.enabled, isFirst = i == 0, isLast = i == emailServers.lastIndex, + onToggle = { v -> app.appScope.launch { app.settings.setEmailEnabled(s.id, v) } }, + onEdit = { editingEmail = s }, onDelete = { app.appScope.launch { app.settings.deleteEmail(s.id) } }, + onMoveUp = { app.appScope.launch { app.settings.moveEmail(s.id, true) } }, + onMoveDown = { app.appScope.launch { app.settings.moveEmail(s.id, false) } }, + onTest = { app.appScope.launch { EmailSender.send(s.host, s.port, s.user, s.password, s.from, s.to, "a2i测试", "电邮配置测试") } }, + ) } - emailSaved = true - }, - modifier = Modifier.fillMaxWidth(), - ) { Text(if (emailSaved) "✓ 已保存" else "保存电邮设置") } - if (emailSaved) { - LaunchedEffect(Unit) { - kotlinx.coroutines.delay(2000) - emailSaved = false } } + AddButton { showAddEmail = true } } } } + // ===== 短信兜底 ===== item { - SectionCard("断网短信兜底", subtitle = "无网络时把验证码、来电用短信发到 iPhone(每5分钟1条)") { - com.a2i.forwarder.ui.SwitchRow( - title = "启用短信兜底", - subtitle = if (smsFallback) "断网时紧要通知走短信" else "当前关闭", - checked = smsFallback, - ) { v -> app.appScope.launch { app.settings.setSmsFallback(v) } } - + SectionCard("断网短信兜底", subtitle = "无网络时把验证码/来电用短信发出去") { + SwitchRow(title = "启用短信兜底", subtitle = if (smsFallback) "已开启" else "已关闭", checked = smsFallback) { v -> app.appScope.launch { app.settings.setSmsFallback(v) } } if (smsFallback) { - Spacer(Modifier.size(8.dp)) - OutlinedTextField( - value = smsTargetInput, - onValueChange = { smsTargetInput = it }, - label = { Text("iPhone 手机号") }, - supportingText = { Text("短信转发的目标号码") }, - modifier = Modifier.fillMaxWidth(), - singleLine = true, - ) - Spacer(Modifier.size(4.dp)) - Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) { - OutlinedButton(onClick = { - app.appScope.launch { app.settings.setSmsTargetNumber(smsTargetInput.trim()) } - smsNumSaved = true - }, modifier = Modifier.weight(1f)) { Text(if (smsNumSaved) "✓ 已保存" else "保存号码") } - if (smsNumSaved) { - LaunchedEffect(Unit) { kotlinx.coroutines.delay(2000); smsNumSaved = false } - } - if (!hasSmsPerm) { - OutlinedButton(onClick = { - smsPermLauncher.launch(android.Manifest.permission.SEND_SMS) - }, modifier = Modifier.weight(1f)) { Text("授予短信权限") } + SwitchRow(title = "发通一条就停", subtitle = if (smsStopFirst) "容错:首个成功止" else "广播:每个都发", checked = smsStopFirst) { v -> app.appScope.launch { app.settings.setSmsStopOnFirst(v) } } + if (!hasSmsPerm) { + Spacer(Modifier.size(4.dp)) + OutlinedButton(onClick = { smsPermLauncher.launch(android.Manifest.permission.SEND_SMS) }, modifier = Modifier.fillMaxWidth()) { Text("授予短信权限") } + } + if (smsTargets.isNotEmpty()) { + Spacer(Modifier.size(8.dp)) + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + smsTargets.forEachIndexed { i, s -> + ConfigRow( + title = s.name.ifBlank { s.number }, + subtitle = s.number, + enabled = s.enabled, isFirst = i == 0, isLast = i == smsTargets.lastIndex, + onToggle = { v -> app.appScope.launch { app.settings.setSmsTargetEnabled(s.id, v) } }, + onEdit = { editingSms = s }, onDelete = { app.appScope.launch { app.settings.deleteSmsTarget(s.id) } }, + onMoveUp = { app.appScope.launch { app.settings.moveSmsTarget(s.id, true) } }, + onMoveDown = { app.appScope.launch { app.settings.moveSmsTarget(s.id, false) } }, + onTest = null, + ) + } } } - - Spacer(Modifier.size(8.dp)) - Text( - "检测到运营商:${detectedCarrier.displayName}" + - if (detectedCarrier.serviceNumber == null) "(无自动查询,将用手动额度)" else "(${detectedCarrier.serviceNumber})", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - Spacer(Modifier.size(8.dp)) - OutlinedTextField( - value = smsQuotaInput, - onValueChange = { smsQuotaInput = it.filter { c -> c.isDigit() } }, - label = { Text("手动短信额度") }, - supportingText = { Text("自动查询失败时按此递减;余额≤5自动停用") }, - modifier = Modifier.fillMaxWidth(), - singleLine = true, - ) - Spacer(Modifier.size(4.dp)) - OutlinedButton(onClick = { - val q = smsQuotaInput.trim().toIntOrNull() ?: 0 - app.appScope.launch { - app.settings.setSmsManualQuota(q) - app.settings.setSmsBalance(q) - if (q > 5) app.settings.setSmsSuspended(false) - } - smsQuotaSaved = true - }) { Text(if (smsQuotaSaved) "✓ 已保存" else "保存额度并解除挂起") } - if (smsQuotaSaved) { - LaunchedEffect(Unit) { kotlinx.coroutines.delay(2000); smsQuotaSaved = false } - } - + AddButton { showAddSms = true } Spacer(Modifier.size(8.dp)) val balanceText = when { smsSuspended -> "已挂起(余额不足)" smsBalance < 0 -> "余额未知" - else -> "剩余约 ${smsBalance} 条" + else -> "剩余约 $smsBalance 条" } - Text( - "当前状态:$balanceText", - 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) } } } + // ===== 应用图标 ===== item { SectionCard("应用图标", subtitle = "导出 App 图标后,Bark 通知可按包名显示图标") { - OutlinedTextField( - value = iconPrefix, - onValueChange = { app.appScope.launch { app.settings.setIconPrefix(it) } }, - label = { Text("图标 URL 前缀") }, - supportingText = { Text("转发时 icon = 前缀 + 包名 + .png;留空则不显示图标") }, - modifier = Modifier.fillMaxWidth(), - singleLine = true, - ) - Text( - "导出目录:${treeUri?.let { DocumentFile.fromTreeUri(context, it)?.name } ?: "未选择"}", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.padding(top = 8.dp), - ) - Row( - modifier = Modifier.fillMaxWidth().padding(top = 10.dp), - horizontalArrangement = Arrangement.spacedBy(8.dp), - ) { + OutlinedTextField(value = iconPrefix, onValueChange = { app.appScope.launch { app.settings.setIconPrefix(it) } }, label = { Text("图标 URL 前缀") }, modifier = Modifier.fillMaxWidth(), singleLine = true) + Text("导出目录:${treeUri?.let { DocumentFile.fromTreeUri(context, it)?.name } ?: "未选择"}", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.padding(top = 8.dp)) + Row(modifier = Modifier.fillMaxWidth().padding(top = 10.dp), horizontalArrangement = Arrangement.spacedBy(8.dp)) { OutlinedButton(onClick = { treeLauncher.launch(null) }, modifier = Modifier.weight(1f)) { Text("选择目录") } - Button( - onClick = { - val u = treeUri ?: return@Button - app.appScope.launch { - exporting = true - val r = IconExporter(context).exportAll(u) - exporting = false - exportResult = "共 ${r.total} 个,成功 ${r.ok},失败 ${r.fail}" - } - }, - enabled = treeUri != null && !exporting, - modifier = Modifier.weight(1f), - ) { + Button(onClick = { + val u = treeUri ?: return@Button + app.appScope.launch { exporting = true; val r = IconExporter(context).exportAll(u); exporting = false; exportResult = "共 ${r.total} 个,成功 ${r.ok},失败 ${r.fail}" } + }, enabled = treeUri != null && !exporting, modifier = Modifier.weight(1f)) { if (exporting) CircularProgressIndicator(Modifier.size(18.dp), strokeWidth = 2.dp) else Text("导出图标") } } - exportResult?.let { - Text(it, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.primary, modifier = Modifier.padding(top = 8.dp)) - } + exportResult?.let { Text(it, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.primary, modifier = Modifier.padding(top = 8.dp)) } } } + // ===== 更新 ===== item { - SectionCard("更新", subtitle = "版本检查(数据来自 Gitea Releases)") { - com.a2i.forwarder.ui.SwitchRow( - title = "自动检查更新", - subtitle = "启动时检查,且距上次满 24 小时再次检查", - checked = updateAuto, - icon = Icons.Filled.Update, - ) { v -> app.appScope.launch { app.settings.setUpdateCheckEnabled(v) } } - + SectionCard("更新", subtitle = "版本检查(数据来自 Gitea + GitHub 双源)") { + SwitchRow(title = "自动检查更新", subtitle = "启动时检查 + 每 24 小时巡检", checked = updateAuto, icon = Icons.Filled.Update) { v -> app.appScope.launch { app.settings.setUpdateCheckEnabled(v) } } ClickRow( title = "检查更新", - subtitle = "当前 v${appVersionName(context)}" + - (updateInfo?.let { " · 已发现 v${it.latestVersion}" } ?: " · 点击立即检查"), + subtitle = "当前 v${appVersionName(context)}" + (updateInfo?.let { " · 已发现 v${it.latestVersion}" } ?: " · 点击立即检查"), icon = Icons.Filled.SystemUpdateAlt, - onClick = { - app.appScope.launch { - checking = true - checkMsg = null - val r = app.updateChecker.check(force = true) - checking = false - checkMsg = when { - !r.isSuccess -> "检查失败:${r.exceptionOrNull()?.message}" - r.getOrNull() != null -> "发现新版本,请到首页更新" - else -> "已是最新版本" - } - } - }, + onClick = { app.appScope.launch { checking = true; checkMsg = null; val r = app.updateChecker.check(force = true); checking = false; checkMsg = when { !r.isSuccess -> "检查失败:${r.exceptionOrNull()?.message}"; r.getOrNull() != null -> "发现新版本,请到首页更新"; else -> "已是最新版本" } } }, ) - if (checking) { - Row( - modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp), - verticalAlignment = Alignment.CenterVertically, - ) { + Row(modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp), verticalAlignment = Alignment.CenterVertically) { CircularProgressIndicator(Modifier.size(16.dp), strokeWidth = 2.dp) Spacer(Modifier.width(8.dp)) Text("正在检查…", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant) } } - checkMsg?.let { - Text( - it, - style = MaterialTheme.typography.bodySmall, - color = if (it.startsWith("已是")) MaterialTheme.colorScheme.tertiary else MaterialTheme.colorScheme.error, - modifier = Modifier.padding(start = 50.dp, top = 2.dp, bottom = 4.dp), - ) - } + checkMsg?.let { Text(it, style = MaterialTheme.typography.bodySmall, color = if (it.startsWith("已是")) MaterialTheme.colorScheme.tertiary else MaterialTheme.colorScheme.error, modifier = Modifier.padding(start = 50.dp, top = 2.dp)) } } } + // ===== 维护 ===== item { SectionCard("维护", subtitle = "日志查看和本地统计清理") { ClickRow("转发日志", "查看 / 清空最近记录", icon = Icons.Filled.History, onClick = onOpenLog) - ClickRow("重置计数器", "清零转发 / 过滤 / 失败统计", icon = Icons.Filled.RestartAlt, tint = MaterialTheme.colorScheme.error) { - app.appScope.launch { app.logStore.resetCounters() } - } + ClickRow("重置计数器", "清零转发 / 过滤 / 失败统计", icon = Icons.Filled.RestartAlt, tint = MaterialTheme.colorScheme.error) { app.appScope.launch { app.logStore.resetCounters() } } } } item { - Box(Modifier.fillMaxWidth().padding(top = 4.dp, bottom = 18.dp), contentAlignment = Alignment.Center) { - Text( - "a2i · 安卓通知转发到 iOS Bark\n图标需 iOS 15+;点击通知会打开 iOS 对应 App", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } + Spacer(Modifier.padding(top = 4.dp, bottom = 18.dp)) } } - if (showAdd) { - ServerDialog( - onDismiss = { showAdd = false }, - onConfirm = { n, s, k -> - app.appScope.launch { - app.settings.addServer(n, s, k) - } - showAdd = false - }, - ) - } - editing?.let { srv -> - ServerDialog( - initial = srv, - onDismiss = { editing = null }, - onConfirm = { n, s, k -> - app.appScope.launch { app.settings.updateServer(srv.copy(name = n, server = s, deviceKey = k)) } - editing = null - }, - ) - } + // 弹窗 + if (showAddBark) ServerDialog(onDismiss = { showAddBark = false }, onConfirm = { n, s, k -> app.appScope.launch { app.settings.addServer(n, s, k) }; showAddBark = false }) + editingBark?.let { srv -> ServerDialog(initial = srv, onDismiss = { editingBark = null }, onConfirm = { n, s, k -> app.appScope.launch { app.settings.updateServer(srv.copy(name = n, server = s, deviceKey = k)) }; editingBark = null }) } + if (showAddNtfy) NtfyConfigDialog(onDismiss = { showAddNtfy = false }, onConfirm = { n, sv, t, tk -> app.appScope.launch { app.settings.addNtfy(NtfyServer(name = n, server = sv, topic = t, token = tk)) }; showAddNtfy = false }) + editingNtfy?.let { srv -> NtfyConfigDialog(initial = srv, onDismiss = { editingNtfy = null }, onConfirm = { n, sv, t, tk -> app.appScope.launch { app.settings.updateNtfy(srv.copy(name = n, server = sv, topic = t, token = tk)) }; editingNtfy = null }) } + if (showAddEmail) EmailConfigDialog(onDismiss = { showAddEmail = false }, onConfirm = { c -> app.appScope.launch { app.settings.addEmail(c) }; showAddEmail = false }) + editingEmail?.let { srv -> EmailConfigDialog(initial = srv, onDismiss = { editingEmail = null }, onConfirm = { c -> app.appScope.launch { app.settings.updateEmail(c.copy(id = srv.id)) }; editingEmail = null }) } + if (showAddSms) SmsConfigDialog(onDismiss = { showAddSms = false }, onConfirm = { n, num -> app.appScope.launch { app.settings.addSmsTarget(SmsTarget(name = n, number = num)) }; showAddSms = false }) + editingSms?.let { srv -> SmsConfigDialog(initial = srv, onDismiss = { editingSms = null }, onConfirm = { n, num -> app.appScope.launch { app.settings.updateSmsTarget(srv.copy(name = n, number = num)) }; editingSms = null }) } } +@Composable +private fun AddButton(onClick: () -> Unit) { + Button(onClick = onClick, modifier = Modifier.fillMaxWidth().padding(top = 12.dp)) { + Icon(Icons.Filled.Add, null, modifier = Modifier.size(18.dp)) + Spacer(Modifier.width(8.dp)) + Text("添加") + } +} + +/** 通用配置行(ntfy/电邮/短信 共用,仿 ServerRow 简化版)。 */ +@Composable +private fun ConfigRow( + title: String, subtitle: String, enabled: Boolean, + isFirst: Boolean, isLast: Boolean, + onToggle: (Boolean) -> Unit, onEdit: () -> Unit, onDelete: () -> Unit, + onMoveUp: () -> Unit, onMoveDown: () -> Unit, onTest: (() -> Unit)?, +) { + Surface( + modifier = Modifier.fillMaxWidth(), shape = RoundedCornerShape(8.dp), + color = if (enabled) MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.40f) else MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.36f), + border = BorderStroke(1.dp, if (enabled) MaterialTheme.colorScheme.primary.copy(alpha = 0.30f) else MaterialTheme.colorScheme.outlineVariant), + ) { + Column(Modifier.padding(start = 4.dp, end = 4.dp, top = 2.dp, bottom = 4.dp)) { + Row(verticalAlignment = Alignment.CenterVertically) { + Checkbox(checked = enabled, onCheckedChange = onToggle) + Column(Modifier.weight(1f)) { + Text(title.ifBlank { "未命名" }, style = MaterialTheme.typography.bodyLarge, fontWeight = FontWeight.Medium, maxLines = 1, overflow = TextOverflow.Ellipsis) + Text(subtitle, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, maxLines = 2, overflow = TextOverflow.Ellipsis, modifier = Modifier.padding(start = 0.dp)) + } + if (onTest != null) { + IconButton(onClick = onTest) { Icon(Icons.AutoMirrored.Filled.Send, "测试") } + } + IconButton(onClick = onMoveUp, enabled = !isFirst) { Icon(Icons.Filled.KeyboardArrowUp, "上移") } + IconButton(onClick = onMoveDown, enabled = !isLast) { Icon(Icons.Filled.KeyboardArrowDown, "下移") } + IconButton(onClick = onEdit) { Icon(Icons.Filled.Edit, "编辑") } + IconButton(onClick = onDelete) { Icon(Icons.Filled.Delete, "删除") } + } + } + } +} + +// ===== Bark 服务器 Row + Dialog(保留原有) ===== @Composable private fun ServerRow( - server: BarkServer, - isFirst: Boolean, - isLast: Boolean, - onToggle: (Boolean) -> Unit, - onEdit: () -> Unit, - onDelete: () -> Unit, - onMoveUp: () -> Unit, - onMoveDown: () -> Unit, + server: BarkServer, isFirst: Boolean, isLast: Boolean, + onToggle: (Boolean) -> Unit, onEdit: () -> Unit, onDelete: () -> Unit, + onMoveUp: () -> Unit, onMoveDown: () -> Unit, ) { val app = A2iApp.instance var testing by remember { mutableStateOf(false) } var testMsg by remember { mutableStateOf(null) } Surface( - modifier = Modifier.fillMaxWidth(), - shape = RoundedCornerShape(8.dp), + modifier = Modifier.fillMaxWidth(), shape = RoundedCornerShape(8.dp), color = if (server.enabled) MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.40f) else MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.36f), border = BorderStroke(1.dp, if (server.enabled) MaterialTheme.colorScheme.primary.copy(alpha = 0.30f) else MaterialTheme.colorScheme.outlineVariant), ) { Column(Modifier.padding(start = 4.dp, end = 4.dp, top = 2.dp, bottom = 6.dp)) { - // 第一行:勾选 + 名称 + 编辑/删除 Row(verticalAlignment = Alignment.CenterVertically) { Checkbox(checked = server.enabled, onCheckedChange = onToggle) Row(Modifier.weight(1f), verticalAlignment = Alignment.CenterVertically) { - Text( - server.name.ifBlank { "未命名" }, - style = MaterialTheme.typography.bodyLarge, - fontWeight = FontWeight.Medium, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - modifier = Modifier.weight(1f, fill = false), - ) - if (!server.enabled) { - Spacer(Modifier.width(8.dp)) - StatusBadge("停用", MaterialTheme.colorScheme.onSurfaceVariant) - } + Text(server.name.ifBlank { "未命名" }, style = MaterialTheme.typography.bodyLarge, fontWeight = FontWeight.Medium, maxLines = 1, overflow = TextOverflow.Ellipsis, modifier = Modifier.weight(1f, fill = false)) + if (!server.enabled) { Spacer(Modifier.width(8.dp)); StatusBadge("停用", MaterialTheme.colorScheme.onSurfaceVariant) } } - IconButton( - onClick = { if (!testing) app.appScope.launch { testing = true; testMsg = testPush(app, server); testing = false } }, - enabled = !testing, - ) { - if (testing) CircularProgressIndicator(Modifier.size(18.dp), strokeWidth = 2.dp) - else Icon(Icons.AutoMirrored.Filled.Send, "测试") + IconButton(onClick = { if (!testing) app.appScope.launch { testing = true; testMsg = testPush(app, server); testing = false } }, enabled = !testing) { + if (testing) CircularProgressIndicator(Modifier.size(18.dp), strokeWidth = 2.dp) else Icon(Icons.AutoMirrored.Filled.Send, "测试") } IconButton(onClick = onEdit) { Icon(Icons.Filled.Edit, "编辑") } IconButton(onClick = onDelete) { Icon(Icons.Filled.Delete, "删除") } } - // 第二行:地址 + key + 上移/下移 Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.padding(top = 2.dp)) { Column(Modifier.weight(1f).padding(start = 36.dp)) { - Text( - server.server, - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - maxLines = 2, - overflow = TextOverflow.Ellipsis, - ) - Text( - "device key: " + if (server.deviceKey.isBlank()) "未填" else "已填(${server.deviceKey.length} 位)", - style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) + Text(server.server, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, maxLines = 2, overflow = TextOverflow.Ellipsis) + Text("device key: " + if (server.deviceKey.isBlank()) "未填" else "已填(${server.deviceKey.length} 位)", style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSurfaceVariant) } IconButton(onClick = onMoveUp, enabled = !isFirst) { Icon(Icons.Filled.KeyboardArrowUp, "上移") } IconButton(onClick = onMoveDown, enabled = !isLast) { Icon(Icons.Filled.KeyboardArrowDown, "下移") } } - testMsg?.let { - Text( - it, - style = MaterialTheme.typography.bodySmall, - color = if (it.startsWith("已发送")) MaterialTheme.colorScheme.tertiary else MaterialTheme.colorScheme.error, - modifier = Modifier.padding(start = 48.dp, top = 2.dp), - ) - } + testMsg?.let { Text(it, style = MaterialTheme.typography.bodySmall, color = if (it.startsWith("已发送")) MaterialTheme.colorScheme.tertiary else MaterialTheme.colorScheme.error, modifier = Modifier.padding(start = 48.dp, top = 2.dp)) } } } } @Composable -private fun ServerDialog( - initial: BarkServer? = null, - onDismiss: () -> Unit, - onConfirm: (name: String, server: String, key: String) -> Unit, -) { +private fun ServerDialog(initial: BarkServer? = null, onDismiss: () -> Unit, onConfirm: (String, String, String) -> Unit) { var name by remember { mutableStateOf(initial?.name ?: "") } var server by remember { mutableStateOf(initial?.server ?: OFFICIAL_BARK_SERVER) } var key by remember { mutableStateOf(initial?.deviceKey ?: "") } + ConfigDialogSkeleton(title = if (initial == null) "添加 Bark 服务器" else "编辑服务器", onDismiss = onDismiss, onConfirm = { onConfirm(name.trim(), server.trim().removeSuffix("/"), key.trim()) }) { + OutlinedTextField(name, { name = it }, label = { Text("名称") }, singleLine = true, modifier = Modifier.fillMaxWidth()) + Spacer(Modifier.size(8.dp)) + OutlinedTextField(server, { server = it }, label = { Text("服务器地址") }, singleLine = true, modifier = Modifier.fillMaxWidth(), supportingText = { Text("官方:$OFFICIAL_BARK_SERVER") }) + Spacer(Modifier.size(8.dp)) + OutlinedTextField(key, { key = it }, label = { Text("Device Key") }, singleLine = true, modifier = Modifier.fillMaxWidth()) + } +} + +@Composable +private fun NtfyConfigDialog(initial: NtfyServer? = null, onDismiss: () -> Unit, onConfirm: (String, String, String, String) -> Unit) { + var name by remember { mutableStateOf(initial?.name ?: "") } + var server by remember { mutableStateOf(initial?.server ?: "https://ntfy.sh") } + var topic by remember { mutableStateOf(initial?.topic ?: "") } + var token by remember { mutableStateOf(initial?.token ?: "") } + ConfigDialogSkeleton(title = if (initial == null) "添加 ntfy" else "编辑 ntfy", onDismiss = onDismiss, onConfirm = { onConfirm(name.trim(), server.trim().removeSuffix("/"), topic.trim(), token.trim()) }) { + OutlinedTextField(name, { name = it }, label = { Text("名称") }, singleLine = true, modifier = Modifier.fillMaxWidth()) + Spacer(Modifier.size(8.dp)) + OutlinedTextField(server, { server = it }, label = { Text("服务器") }, singleLine = true, modifier = Modifier.fillMaxWidth()) + Spacer(Modifier.size(8.dp)) + OutlinedTextField(topic, { topic = it }, label = { Text("Topic(必填)") }, singleLine = true, modifier = Modifier.fillMaxWidth()) + Spacer(Modifier.size(8.dp)) + OutlinedTextField(token, { token = it }, label = { Text("Token(可选)") }, singleLine = true, modifier = Modifier.fillMaxWidth()) + } +} + +@Composable +private fun EmailConfigDialog(initial: EmailServer? = null, onDismiss: () -> Unit, onConfirm: (EmailServer) -> Unit) { + var name by remember { mutableStateOf(initial?.name ?: "") } + var host by remember { mutableStateOf(initial?.host ?: "") } + var port by remember { mutableStateOf((initial?.port ?: 465).toString()) } + var user by remember { mutableStateOf(initial?.user ?: "") } + var password by remember { mutableStateOf(initial?.password ?: "") } + var from by remember { mutableStateOf(initial?.from ?: "") } + var to by remember { mutableStateOf(initial?.to ?: "") } + ConfigDialogSkeleton(title = if (initial == null) "添加邮箱" else "编辑邮箱", onDismiss = onDismiss, onConfirm = { onConfirm(EmailServer(name = name.trim(), host = host.trim(), port = port.trim().toIntOrNull() ?: 465, user = user.trim(), password = password.trim(), from = from.trim(), to = to.trim())) }) { + OutlinedTextField(name, { name = it }, label = { Text("名称") }, singleLine = true, modifier = Modifier.fillMaxWidth()) + Spacer(Modifier.size(8.dp)) + Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) { + OutlinedTextField(host, { host = it }, label = { Text("SMTP 主机") }, singleLine = true, modifier = Modifier.weight(1.5f)) + OutlinedTextField(port, { port = it.filter { c -> c.isDigit() } }, label = { Text("端口") }, singleLine = true, modifier = Modifier.weight(1f)) + } + Spacer(Modifier.size(8.dp)) + OutlinedTextField(user, { user = it }, label = { Text("账号") }, singleLine = true, modifier = Modifier.fillMaxWidth()) + Spacer(Modifier.size(8.dp)) + OutlinedTextField(password, { password = it }, label = { Text("授权码/密码") }, singleLine = true, modifier = Modifier.fillMaxWidth()) + Spacer(Modifier.size(8.dp)) + OutlinedTextField(from, { from = it }, label = { Text("发件人") }, singleLine = true, modifier = Modifier.fillMaxWidth()) + Spacer(Modifier.size(8.dp)) + OutlinedTextField(to, { to = it }, label = { Text("收件人") }, singleLine = true, modifier = Modifier.fillMaxWidth()) + } +} + +@Composable +private fun SmsConfigDialog(initial: SmsTarget? = null, onDismiss: () -> Unit, onConfirm: (String, String) -> Unit) { + var name by remember { mutableStateOf(initial?.name ?: "") } + var number by remember { mutableStateOf(initial?.number ?: "") } + ConfigDialogSkeleton(title = if (initial == null) "添加目标号" else "编辑目标号", onDismiss = onDismiss, onConfirm = { onConfirm(name.trim(), number.trim()) }) { + OutlinedTextField(name, { name = it }, label = { Text("名称") }, singleLine = true, modifier = Modifier.fillMaxWidth()) + Spacer(Modifier.size(8.dp)) + OutlinedTextField(number, { number = it }, label = { Text("手机号") }, singleLine = true, modifier = Modifier.fillMaxWidth()) + } +} + +@Composable +private fun ConfigDialogSkeleton(title: String, onDismiss: () -> Unit, onConfirm: () -> Unit, content: @Composable () -> Unit) { AlertDialog( onDismissRequest = onDismiss, icon = { Icon(Icons.Filled.Settings, null) }, - title = { Text(if (initial == null) "添加服务器" else "编辑服务器") }, - text = { - Column { - OutlinedTextField(name, { name = it }, label = { Text("名称") }, singleLine = true, modifier = Modifier.fillMaxWidth()) - Spacer(Modifier.size(8.dp)) - OutlinedTextField(server, { server = it }, label = { Text("服务器地址") }, singleLine = true, modifier = Modifier.fillMaxWidth(), supportingText = { Text("官方:$OFFICIAL_BARK_SERVER") }) - Spacer(Modifier.size(8.dp)) - OutlinedTextField(key, { key = it }, label = { Text("Device Key") }, singleLine = true, modifier = Modifier.fillMaxWidth()) - } - }, - confirmButton = { TextButton(onClick = { onConfirm(name.trim(), server.trim(), key.trim()) }) { Text("保存") } }, + title = { Text(title) }, + text = { Column { content() } }, + confirmButton = { TextButton(onClick = onConfirm) { Text("保存") } }, dismissButton = { TextButton(onClick = onDismiss) { Text("取消") } }, ) } private suspend fun testPush(app: A2iApp, server: BarkServer): String { if (server.deviceKey.isBlank()) return "未填写 device key" - val msg = BarkMessage( - deviceKey = server.deviceKey, - title = "a2i 测试", - body = "如果你在 iOS 看到这条推送,说明配置成功", - level = "active", - ) + val msg = BarkMessage(deviceKey = server.deviceKey, title = "a2i 测试", body = "如果你在 iOS 看到这条推送,说明配置成功", level = "active") val r = BarkClient(server).push(msg) return if (r.isSuccess) "已发送,请查看 iOS 端" else "发送失败:${r.exceptionOrNull()?.message}" -} \ No newline at end of file +}