feat: multi-config lists for ntfy/email/sms + per-channel stopOnFirst

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) <noreply@anthropic.com>
This commit is contained in:
2026-07-09 21:16:23 +08:00
parent 9a6b933618
commit 39d3d735b0
10 changed files with 572 additions and 522 deletions
+2 -2
View File
@@ -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 {
@@ -10,6 +10,22 @@ import kotlinx.coroutines.withContext
* 全失败 → 返回最后一个错误;空列表 → "无可用服务器"。
*/
object BarkSender {
/** stopOnFirst=true:首个成功即止;false:全部发送,至少一个成功即算成功。 */
suspend fun pushToEnabled(servers: List<BarkServer>, msg: BarkMessage, stopOnFirst: Boolean): Result<Unit> = 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<BarkServer>, msg: BarkMessage): Result<Unit> = withContext(Dispatchers.IO) {
runCatching {
require(servers.any()) { "无可用服务器" }
@@ -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<EmailServer>, title: String, body: String, stopOnFirst: Boolean): Result<Unit> = 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)
}
}
}
@@ -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<NtfyServer>, title: String, body: String, stopOnFirst: Boolean): Result<Unit> = 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)
}
}
@@ -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(
@@ -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)
}
}
}
@@ -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,
)
@@ -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 {
@@ -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<List<NtfyServer>>(emptyList())
val ntfyServers = _ntfyServers.asStateFlow()
private val _emailServers = MutableStateFlow<List<EmailServer>>(emptyList())
val emailServers = _emailServers.asStateFlow()
private val _smsTargets = MutableStateFlow<List<SmsTarget>>(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<List<String>>(emptyList()) // 用户自定义关键词
val adKeywords = MutableStateFlow<List<String>>(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<List<String>>(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<List<NtfyServer>>(it) }.getOrNull() }
?: emptyList()
_emailServers.value = p[K.EMAIL_SERVERS]
?.let { runCatching { json.decodeFromString<List<EmailServer>>(it) }.getOrNull() }
?: emptyList()
_smsTargets.value = p[K.SMS_TARGETS]
?.let { runCatching { json.decodeFromString<List<SmsTarget>>(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<BarkServer>) {
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<NtfyServer>) {
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<EmailServer>) {
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<SmsTarget>) {
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()
@@ -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<Uri?>(prefs.getString("icon_tree", null)?.let { runCatching { Uri.parse(it) }.getOrNull() }) }
var exporting by remember { mutableStateOf(false) }
var exportResult by remember { mutableStateOf<String?>(null) }
var showAdd by remember { mutableStateOf(false) }
var editing by remember { mutableStateOf<BarkServer?>(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<String?>(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<BarkServer?>(null) }
var showAddNtfy by remember { mutableStateOf(false) }
var editingNtfy by remember { mutableStateOf<NtfyServer?>(null) }
var showAddEmail by remember { mutableStateOf(false) }
var editingEmail by remember { mutableStateOf<EmailServer?>(null) }
var showAddSms by remember { mutableStateOf(false) }
var editingSms by remember { mutableStateOf<SmsTarget?>(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<String?>(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}"
}
}