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:
@@ -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 stopOnFirst = settings.smsStopOnFirst.value
|
||||
var anyOk = false
|
||||
var sentCount = 0
|
||||
var lastReason = "短信发送失败"
|
||||
for (t in targets) {
|
||||
val result = runCatching {
|
||||
SmsManager.getDefault().sendTextMessage(target, null, body, null, null)
|
||||
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 (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) {
|
||||
SwitchRow(title = "发通一条就停", subtitle = if (ntfyStopFirst) "容错:首个成功止" else "广播:每个都发", checked = ntfyStopFirst) { v -> app.appScope.launch { app.settings.setNtfyStopOnFirst(v) } }
|
||||
if (ntfyServers.isNotEmpty()) {
|
||||
Spacer(Modifier.size(8.dp))
|
||||
OutlinedTextField(
|
||||
value = ntfyServerInput, onValueChange = { ntfyServerInput = it },
|
||||
label = { Text("服务器地址") },
|
||||
supportingText = { Text("如 https://ntfy.sh;自建填自建地址") },
|
||||
modifier = Modifier.fillMaxWidth(), singleLine = true,
|
||||
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配置测试") } },
|
||||
)
|
||||
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())
|
||||
}
|
||||
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) {
|
||||
SwitchRow(title = "发通一条就停", subtitle = if (emailStopFirst) "容错:首个成功止" else "广播:每个都发", checked = emailStopFirst) { v -> app.appScope.launch { app.settings.setEmailStopOnFirst(v) } }
|
||||
if (emailServers.isNotEmpty()) {
|
||||
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,
|
||||
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测试", "电邮配置测试") } },
|
||||
)
|
||||
}
|
||||
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())
|
||||
}
|
||||
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 }
|
||||
}
|
||||
SwitchRow(title = "发通一条就停", subtitle = if (smsStopFirst) "容错:首个成功止" else "广播:每个都发", checked = smsStopFirst) { v -> app.appScope.launch { app.settings.setSmsStopOnFirst(v) } }
|
||||
if (!hasSmsPerm) {
|
||||
OutlinedButton(onClick = {
|
||||
smsPermLauncher.launch(android.Manifest.permission.SEND_SMS)
|
||||
}, modifier = Modifier.weight(1f)) { Text("授予短信权限") }
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
OutlinedButton(onClick = { smsPermLauncher.launch(android.Manifest.permission.SEND_SMS) }, modifier = Modifier.fillMaxWidth()) { Text("授予短信权限") }
|
||||
}
|
||||
smsQuotaSaved = true
|
||||
}) { Text(if (smsQuotaSaved) "✓ 已保存" else "保存额度并解除挂起") }
|
||||
if (smsQuotaSaved) {
|
||||
LaunchedEffect(Unit) { kotlinx.coroutines.delay(2000); smsQuotaSaved = false }
|
||||
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,
|
||||
)
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
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 = {
|
||||
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),
|
||||
) {
|
||||
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 ?: "") }
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
icon = { Icon(Icons.Filled.Settings, null) },
|
||||
title = { Text(if (initial == null) "添加服务器" else "编辑服务器") },
|
||||
text = {
|
||||
Column {
|
||||
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())
|
||||
}
|
||||
},
|
||||
confirmButton = { TextButton(onClick = { onConfirm(name.trim(), server.trim(), key.trim()) }) { Text("保存") } },
|
||||
}
|
||||
|
||||
@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(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}"
|
||||
}
|
||||
Reference in New Issue
Block a user