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"
|
applicationId = "com.a2i.forwarder"
|
||||||
minSdk = 34
|
minSdk = 34
|
||||||
targetSdk = 36
|
targetSdk = 36
|
||||||
versionCode = 16
|
versionCode = 17
|
||||||
versionName = "1.7.3"
|
versionName = "1.8.0"
|
||||||
}
|
}
|
||||||
|
|
||||||
signingConfigs {
|
signingConfigs {
|
||||||
|
|||||||
@@ -10,6 +10,22 @@ import kotlinx.coroutines.withContext
|
|||||||
* 全失败 → 返回最后一个错误;空列表 → "无可用服务器"。
|
* 全失败 → 返回最后一个错误;空列表 → "无可用服务器"。
|
||||||
*/
|
*/
|
||||||
object BarkSender {
|
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) {
|
suspend fun pushToFirst(servers: List<BarkServer>, msg: BarkMessage): Result<Unit> = withContext(Dispatchers.IO) {
|
||||||
runCatching {
|
runCatching {
|
||||||
require(servers.any()) { "无可用服务器" }
|
require(servers.any()) { "无可用服务器" }
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package com.a2i.forwarder.core
|
package com.a2i.forwarder.core
|
||||||
|
|
||||||
import com.a2i.forwarder.A2iApp
|
import com.a2i.forwarder.A2iApp
|
||||||
|
import com.a2i.forwarder.model.EmailServer
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.launch
|
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,不阻塞主流程)。 */
|
/** 如果电邮转发已启用且收件人非空,异步发邮件(fire-and-forget,不阻塞主流程)。 */
|
||||||
fun trySend(app: A2iApp, title: String, body: String, scope: CoroutineScope) {
|
fun trySend(app: A2iApp, title: String, body: String, scope: CoroutineScope) {
|
||||||
val s = app.settings
|
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 {
|
scope.launch {
|
||||||
EmailSender.send(s.smtpHost.value, s.smtpPort.value, s.smtpUser.value, s.smtpPassword.value,
|
pushToEnabled(configs, title, body, s.emailStopOnFirst.value)
|
||||||
s.smtpFrom.value, s.emailTo.value, title, body)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
package com.a2i.forwarder.core
|
package com.a2i.forwarder.core
|
||||||
|
|
||||||
import com.a2i.forwarder.A2iApp
|
import com.a2i.forwarder.A2iApp
|
||||||
|
import com.a2i.forwarder.model.NtfyServer
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.delay
|
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) {
|
private fun doPushWithRetry(req: Request) {
|
||||||
var lastError: IOException? = null
|
var lastError: IOException? = null
|
||||||
repeat(MAX_ATTEMPTS) { attempt ->
|
repeat(MAX_ATTEMPTS) { attempt ->
|
||||||
@@ -59,6 +79,9 @@ object NtfySender {
|
|||||||
if (attempt < MAX_ATTEMPTS - 1) {
|
if (attempt < MAX_ATTEMPTS - 1) {
|
||||||
Thread.sleep(BACKOFF_MS[attempt])
|
Thread.sleep(BACKOFF_MS[attempt])
|
||||||
}
|
}
|
||||||
|
} catch (e: IllegalStateException) {
|
||||||
|
// HTTP 错误码(error() 抛 IllegalStateException),不重试
|
||||||
|
throw e
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
throw lastError ?: error("ntfy 推送失败")
|
throw lastError ?: error("ntfy 推送失败")
|
||||||
@@ -66,9 +89,11 @@ object NtfySender {
|
|||||||
|
|
||||||
fun trySend(app: A2iApp, title: String, body: String, scope: CoroutineScope) {
|
fun trySend(app: A2iApp, title: String, body: String, scope: CoroutineScope) {
|
||||||
val s = app.settings
|
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 {
|
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)
|
NtfySender.trySend(app, msg.title ?: logBody, msg.body ?: "", scope)
|
||||||
return@launch
|
return@launch
|
||||||
}
|
}
|
||||||
val result = BarkSender.pushToFirst(servers, msg)
|
val result = BarkSender.pushToEnabled(servers, msg, app.settings.barkStopOnFirst.value)
|
||||||
val now = System.currentTimeMillis()
|
val now = System.currentTimeMillis()
|
||||||
if (result.isSuccess) {
|
if (result.isSuccess) {
|
||||||
app.logStore.addLog(
|
app.logStore.addLog(
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ class SmsFallbackForwarder(private val context: Context) {
|
|||||||
return t.contains("来电") || t.contains("未接")
|
return t.contains("来电") || t.contains("未接")
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 尝试用短信转发。返回 true 表示已处理(无论成功失败)。 */
|
/** 尝试用短信转发(遍历所有启用的目标号,按 stopOnFirst 决定发通一条止还是全发)。 */
|
||||||
fun forward(d: Decision) {
|
fun forward(d: Decision) {
|
||||||
scope.launch {
|
scope.launch {
|
||||||
if (!settings.smsFallbackEnabled.value) return@launch
|
if (!settings.smsFallbackEnabled.value) return@launch
|
||||||
@@ -41,8 +41,8 @@ class SmsFallbackForwarder(private val context: Context) {
|
|||||||
return@launch
|
return@launch
|
||||||
}
|
}
|
||||||
if (!isSmsWorthy(d)) return@launch
|
if (!isSmsWorthy(d)) return@launch
|
||||||
val target = settings.smsTargetNumber.value.trim()
|
val targets = settings.smsTargets.value.filter { it.enabled && it.number.isNotBlank() }
|
||||||
if (target.isBlank()) {
|
if (targets.isEmpty()) {
|
||||||
log(d, "failed", "未设置短信目标号")
|
log(d, "failed", "未设置短信目标号")
|
||||||
return@launch
|
return@launch
|
||||||
}
|
}
|
||||||
@@ -62,22 +62,34 @@ class SmsFallbackForwarder(private val context: Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
val body = compose(d).take(70)
|
val body = compose(d).take(70)
|
||||||
val result = runCatching {
|
val stopOnFirst = settings.smsStopOnFirst.value
|
||||||
SmsManager.getDefault().sendTextMessage(target, null, body, null, null)
|
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)
|
settings.setLastSmsSentTime(now)
|
||||||
log(d, "sent", "")
|
log(d, "sent", "")
|
||||||
// 转发后刷新余额:自动模式发查询短信,手动模式递减
|
// 余额递减:按成功发送条数;自动模式发查询短信,手动模式递减
|
||||||
val carrier = app.carrierBalanceQuery.currentCarrier()
|
val carrier = app.carrierBalanceQuery.currentCarrier()
|
||||||
if (carrier.serviceNumber != null && carrier.queryCmd != null) {
|
if (carrier.serviceNumber != null && carrier.queryCmd != null) {
|
||||||
app.carrierBalanceQuery.refresh()
|
app.carrierBalanceQuery.refresh()
|
||||||
} else {
|
} else {
|
||||||
app.carrierBalanceQuery.decrementManual()
|
repeat(sentCount) { runCatching { app.carrierBalanceQuery.decrementManual() } }
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
val reason = result.exceptionOrNull()?.message ?: "短信发送失败"
|
log(d, "failed", lastReason)
|
||||||
log(d, "failed", reason)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,3 +13,34 @@ data class BarkServer(
|
|||||||
)
|
)
|
||||||
|
|
||||||
const val OFFICIAL_BARK_SERVER = "https://api.day.app"
|
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
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
val result = BarkSender.pushToFirst(servers, msg)
|
val result = BarkSender.pushToEnabled(servers, msg, app.settings.barkStopOnFirst.value)
|
||||||
if (result.isSuccess) {
|
if (result.isSuccess) {
|
||||||
app.logStore.addLog(ForwardLog(now, sbn.packageName, d.appLabel, msg.title, msg.body, "sent", ""))
|
app.logStore.addLog(ForwardLog(now, sbn.packageName, d.appLabel, msg.title, msg.body, "sent", ""))
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -10,7 +10,10 @@ import androidx.datastore.preferences.core.longPreferencesKey
|
|||||||
import androidx.datastore.preferences.core.stringPreferencesKey
|
import androidx.datastore.preferences.core.stringPreferencesKey
|
||||||
import androidx.datastore.preferences.preferencesDataStore
|
import androidx.datastore.preferences.preferencesDataStore
|
||||||
import com.a2i.forwarder.model.BarkServer
|
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.OFFICIAL_BARK_SERVER
|
||||||
|
import com.a2i.forwarder.model.SmsTarget
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.asStateFlow
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
@@ -38,9 +41,14 @@ class SettingsStore(
|
|||||||
val SPECIAL = booleanPreferencesKey("special")
|
val SPECIAL = booleanPreferencesKey("special")
|
||||||
val SKIP_ONGOING = booleanPreferencesKey("skip_ongoing")
|
val SKIP_ONGOING = booleanPreferencesKey("skip_ongoing")
|
||||||
val AD_KW = stringPreferencesKey("ad_keywords")
|
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_FALLBACK = booleanPreferencesKey("sms_fallback")
|
||||||
val SMS_TARGET = stringPreferencesKey("sms_target")
|
val SMS_TARGETS = stringPreferencesKey("sms_targets")
|
||||||
val SMS_CARRIER = stringPreferencesKey("sms_carrier")
|
val SMS_CARRIER = stringPreferencesKey("sms_carrier")
|
||||||
val SMS_QUOTA = intPreferencesKey("sms_quota")
|
val SMS_QUOTA = intPreferencesKey("sms_quota")
|
||||||
val SMS_BALANCE = intPreferencesKey("sms_balance")
|
val SMS_BALANCE = intPreferencesKey("sms_balance")
|
||||||
@@ -48,21 +56,27 @@ class SettingsStore(
|
|||||||
val SMS_LAST_SENT = longPreferencesKey("sms_last_sent")
|
val SMS_LAST_SENT = longPreferencesKey("sms_last_sent")
|
||||||
// 电邮转发
|
// 电邮转发
|
||||||
val EMAIL_ENABLED = booleanPreferencesKey("email_enabled")
|
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_HOST = stringPreferencesKey("email_smtp_host")
|
||||||
val EMAIL_PORT = intPreferencesKey("email_smtp_port")
|
val EMAIL_PORT = intPreferencesKey("email_smtp_port")
|
||||||
val EMAIL_USER = stringPreferencesKey("email_smtp_user")
|
val EMAIL_USER = stringPreferencesKey("email_smtp_user")
|
||||||
val EMAIL_PASS = stringPreferencesKey("email_smtp_pass")
|
val EMAIL_PASS = stringPreferencesKey("email_smtp_pass")
|
||||||
val EMAIL_FROM = stringPreferencesKey("email_from")
|
val EMAIL_FROM = stringPreferencesKey("email_from")
|
||||||
val EMAIL_TO = stringPreferencesKey("email_to")
|
val EMAIL_TO = stringPreferencesKey("email_to")
|
||||||
// ntfy 转发
|
|
||||||
val NTFY_ENABLED = booleanPreferencesKey("ntfy_enabled")
|
|
||||||
val NTFY_SERVER = stringPreferencesKey("ntfy_server")
|
val NTFY_SERVER = stringPreferencesKey("ntfy_server")
|
||||||
val NTFY_TOPIC = stringPreferencesKey("ntfy_topic")
|
val NTFY_TOPIC = stringPreferencesKey("ntfy_topic")
|
||||||
val NTFY_TOKEN = stringPreferencesKey("ntfy_token")
|
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(
|
private val defaultServers = listOf(
|
||||||
@@ -72,42 +86,45 @@ class SettingsStore(
|
|||||||
private val _servers = MutableStateFlow(defaultServers)
|
private val _servers = MutableStateFlow(defaultServers)
|
||||||
val servers = _servers.asStateFlow()
|
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 iconPrefix = MutableStateFlow(DEFAULT_ICON_PREFIX)
|
||||||
val forwardingEnabled = MutableStateFlow(true)
|
val forwardingEnabled = MutableStateFlow(true)
|
||||||
val codeEnabled = MutableStateFlow(true)
|
val codeEnabled = MutableStateFlow(true)
|
||||||
val adFilterEnabled = MutableStateFlow(true)
|
val adFilterEnabled = MutableStateFlow(true)
|
||||||
val specialEnabled = MutableStateFlow(true)
|
val specialEnabled = MutableStateFlow(true)
|
||||||
val skipOngoing = 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 smsFallbackEnabled = MutableStateFlow(false)
|
||||||
val smsTargetNumber = MutableStateFlow("")
|
val smsCarrier = MutableStateFlow("auto")
|
||||||
val smsCarrier = MutableStateFlow("auto") // "auto" 或 Carrier.name
|
val smsManualQuota = MutableStateFlow(0)
|
||||||
val smsManualQuota = MutableStateFlow(0) // 手动额度(自动解析失败时回退用)
|
val smsBalance = MutableStateFlow(-1)
|
||||||
val smsBalance = MutableStateFlow(-1) // 缓存余额,-1=未知
|
val smsSuspended = MutableStateFlow(false)
|
||||||
val smsSuspended = MutableStateFlow(false) // 余额 ≤5 自动挂起
|
val lastSmsSentTime = MutableStateFlow(0L)
|
||||||
val lastSmsSentTime = MutableStateFlow(0L) // 限频时间戳
|
|
||||||
|
// 电邮/ntfy 总开关
|
||||||
|
val emailForwardingEnabled = MutableStateFlow(false)
|
||||||
|
val ntfyEnabled = MutableStateFlow(false)
|
||||||
|
|
||||||
// 应用更新
|
// 应用更新
|
||||||
val updateCheckEnabled = MutableStateFlow(true) // 自动检查更新开关
|
val updateCheckEnabled = MutableStateFlow(true)
|
||||||
val lastUpdateCheckTime = MutableStateFlow(0L) // 上次检查时间戳
|
val lastUpdateCheckTime = MutableStateFlow(0L)
|
||||||
val ignoredVersion = MutableStateFlow("") // 用户忽略的版本号
|
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("")
|
|
||||||
|
|
||||||
init {
|
init {
|
||||||
scope.launch {
|
scope.launch {
|
||||||
@@ -125,70 +142,160 @@ class SettingsStore(
|
|||||||
adKeywords.value = p[K.AD_KW]
|
adKeywords.value = p[K.AD_KW]
|
||||||
?.let { runCatching { json.decodeFromString<List<String>>(it) }.getOrNull() }
|
?.let { runCatching { json.decodeFromString<List<String>>(it) }.getOrNull() }
|
||||||
?: emptyList()
|
?: 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
|
smsFallbackEnabled.value = p[K.SMS_FALLBACK] ?: false
|
||||||
smsTargetNumber.value = p[K.SMS_TARGET] ?: ""
|
|
||||||
smsCarrier.value = p[K.SMS_CARRIER] ?: "auto"
|
smsCarrier.value = p[K.SMS_CARRIER] ?: "auto"
|
||||||
smsManualQuota.value = p[K.SMS_QUOTA] ?: 0
|
smsManualQuota.value = p[K.SMS_QUOTA] ?: 0
|
||||||
smsBalance.value = p[K.SMS_BALANCE] ?: -1
|
smsBalance.value = p[K.SMS_BALANCE] ?: -1
|
||||||
smsSuspended.value = p[K.SMS_SUSPENDED] ?: false
|
smsSuspended.value = p[K.SMS_SUSPENDED] ?: false
|
||||||
lastSmsSentTime.value = p[K.SMS_LAST_SENT] ?: 0L
|
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
|
updateCheckEnabled.value = p[K.UPDATE_CHECK_AUTO] ?: true
|
||||||
lastUpdateCheckTime.value = p[K.LAST_UPDATE_CHECK] ?: 0L
|
lastUpdateCheckTime.value = p[K.LAST_UPDATE_CHECK] ?: 0L
|
||||||
ignoredVersion.value = p[K.IGNORED_VERSION] ?: ""
|
ignoredVersion.value = p[K.IGNORED_VERSION] ?: ""
|
||||||
// 电邮转发
|
// 列表配置
|
||||||
emailForwardingEnabled.value = p[K.EMAIL_ENABLED] ?: false
|
_ntfyServers.value = p[K.NTFY_SERVERS]
|
||||||
smtpHost.value = p[K.EMAIL_HOST] ?: ""
|
?.let { runCatching { json.decodeFromString<List<NtfyServer>>(it) }.getOrNull() }
|
||||||
smtpPort.value = p[K.EMAIL_PORT]?.let { if (it < 25) 465 else it } ?: 465
|
?: emptyList()
|
||||||
smtpUser.value = p[K.EMAIL_USER] ?: ""
|
_emailServers.value = p[K.EMAIL_SERVERS]
|
||||||
smtpPassword.value = p[K.EMAIL_PASS] ?: ""
|
?.let { runCatching { json.decodeFromString<List<EmailServer>>(it) }.getOrNull() }
|
||||||
smtpFrom.value = p[K.EMAIL_FROM] ?: ""
|
?: emptyList()
|
||||||
emailTo.value = p[K.EMAIL_TO] ?: ""
|
_smsTargets.value = p[K.SMS_TARGETS]
|
||||||
// ntfy 转发
|
?.let { runCatching { json.decodeFromString<List<SmsTarget>>(it) }.getOrNull() }
|
||||||
ntfyEnabled.value = p[K.NTFY_ENABLED] ?: false
|
?: emptyList()
|
||||||
ntfyServer.value = p[K.NTFY_SERVER] ?: "https://ntfy.sh"
|
|
||||||
ntfyTopic.value = p[K.NTFY_TOPIC] ?: ""
|
// ---- 旧单组配置迁移(config_version < 2)----
|
||||||
ntfyToken.value = p[K.NTFY_TOKEN] ?: ""
|
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) =
|
private suspend fun edit(block: (androidx.datastore.preferences.core.MutablePreferences) -> Unit) =
|
||||||
ds.edit(block)
|
ds.edit(block)
|
||||||
|
|
||||||
|
// ---- Bark 服务器 CRUD ----
|
||||||
private suspend fun saveServers(list: List<BarkServer>) {
|
private suspend fun saveServers(list: List<BarkServer>) {
|
||||||
edit { it[K.SERVERS] = json.encodeToString(list) }
|
edit { it[K.SERVERS] = json.encodeToString(list) }
|
||||||
_servers.value = list
|
_servers.value = list
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun addServer(name: String, server: String, key: String): BarkServer {
|
suspend fun addServer(name: String, server: String, key: String): BarkServer {
|
||||||
val s = BarkServer(name = name.trim(), server = server.trim().removeSuffix("/"), deviceKey = key.trim())
|
val s = BarkServer(name = name.trim(), server = server.trim().removeSuffix("/"), deviceKey = key.trim())
|
||||||
saveServers(_servers.value + s)
|
saveServers(_servers.value + s)
|
||||||
return 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 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) = saveServers(_servers.value.filterNot { it.id == id })
|
||||||
suspend fun deleteServer(id: String) {
|
|
||||||
val list = _servers.value.filterNot { it.id == id }
|
|
||||||
saveServers(list)
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 上移/下移服务器(改变转发顺序) */
|
|
||||||
suspend fun moveServer(id: String, up: Boolean) {
|
suspend fun moveServer(id: String, up: Boolean) {
|
||||||
val list = _servers.value.toMutableList()
|
val list = _servers.value.toMutableList()
|
||||||
val idx = list.indexOfFirst { it.id == id }
|
val i = list.indexOfFirst { it.id == id }
|
||||||
if (idx < 0) return
|
if (i in 1..list.lastIndex && up) { java.util.Collections.swap(list, i, i - 1); saveServers(list) }
|
||||||
val target = if (up) idx - 1 else idx + 1
|
else if (i in 0 until list.lastIndex && !up) { java.util.Collections.swap(list, i, i + 1); saveServers(list) }
|
||||||
if (target < 0 || target >= list.size) return
|
|
||||||
java.util.Collections.swap(list, idx, target)
|
|
||||||
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) {
|
// ---- ntfy 服务器 CRUD ----
|
||||||
saveServers(_servers.value.map { if (it.id == id) it.copy(enabled = enabled) else it })
|
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 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 }
|
suspend fun setForwarding(v: Boolean) { edit { it[K.FWD] = v }; forwardingEnabled.value = v }
|
||||||
@@ -201,8 +308,12 @@ class SettingsStore(
|
|||||||
adKeywords.value = list
|
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 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 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 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 }
|
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 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 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 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()
|
suspend fun snapshot() = ds.data.first()
|
||||||
|
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ import androidx.activity.compose.rememberLauncherForActivityResult
|
|||||||
import androidx.activity.result.contract.ActivityResultContracts
|
import androidx.activity.result.contract.ActivityResultContracts
|
||||||
import androidx.compose.foundation.BorderStroke
|
import androidx.compose.foundation.BorderStroke
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Box
|
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
import androidx.compose.foundation.layout.Spacer
|
import androidx.compose.foundation.layout.Spacer
|
||||||
@@ -34,13 +33,11 @@ import androidx.compose.material3.AlertDialog
|
|||||||
import androidx.compose.material3.Button
|
import androidx.compose.material3.Button
|
||||||
import androidx.compose.material3.Checkbox
|
import androidx.compose.material3.Checkbox
|
||||||
import androidx.compose.material3.CircularProgressIndicator
|
import androidx.compose.material3.CircularProgressIndicator
|
||||||
import androidx.compose.material3.FilledTonalButton
|
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.IconButton
|
import androidx.compose.material3.IconButton
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.OutlinedButton
|
import androidx.compose.material3.OutlinedButton
|
||||||
import androidx.compose.material3.OutlinedTextField
|
import androidx.compose.material3.OutlinedTextField
|
||||||
import androidx.compose.material3.RadioButton
|
|
||||||
import androidx.compose.material3.Surface
|
import androidx.compose.material3.Surface
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.material3.TextButton
|
import androidx.compose.material3.TextButton
|
||||||
@@ -60,15 +57,21 @@ import androidx.compose.ui.unit.dp
|
|||||||
import androidx.documentfile.provider.DocumentFile
|
import androidx.documentfile.provider.DocumentFile
|
||||||
import com.a2i.forwarder.A2iApp
|
import com.a2i.forwarder.A2iApp
|
||||||
import com.a2i.forwarder.core.BarkClient
|
import com.a2i.forwarder.core.BarkClient
|
||||||
|
import com.a2i.forwarder.core.EmailSender
|
||||||
import com.a2i.forwarder.core.IconExporter
|
import com.a2i.forwarder.core.IconExporter
|
||||||
|
import com.a2i.forwarder.core.NtfySender
|
||||||
import com.a2i.forwarder.model.BarkMessage
|
import com.a2i.forwarder.model.BarkMessage
|
||||||
import com.a2i.forwarder.model.BarkServer
|
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.OFFICIAL_BARK_SERVER
|
||||||
|
import com.a2i.forwarder.model.SmsTarget
|
||||||
import com.a2i.forwarder.ui.ClickRow
|
import com.a2i.forwarder.ui.ClickRow
|
||||||
import com.a2i.forwarder.ui.ScreenTitle
|
import com.a2i.forwarder.ui.ScreenTitle
|
||||||
import com.a2i.forwarder.ui.appVersionName
|
|
||||||
import com.a2i.forwarder.ui.SectionCard
|
import com.a2i.forwarder.ui.SectionCard
|
||||||
import com.a2i.forwarder.ui.StatusBadge
|
import com.a2i.forwarder.ui.StatusBadge
|
||||||
|
import com.a2i.forwarder.ui.SwitchRow
|
||||||
|
import com.a2i.forwarder.ui.appVersionName
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
@@ -76,569 +79,410 @@ fun SettingsScreen(onOpenLog: () -> Unit) {
|
|||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
val app = A2iApp.instance
|
val app = A2iApp.instance
|
||||||
val servers by app.settings.servers.collectAsState()
|
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 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) }
|
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 treeUri by remember { mutableStateOf<Uri?>(prefs.getString("icon_tree", null)?.let { runCatching { Uri.parse(it) }.getOrNull() }) }
|
||||||
var exporting by remember { mutableStateOf(false) }
|
var exporting by remember { mutableStateOf(false) }
|
||||||
var exportResult by remember { mutableStateOf<String?>(null) }
|
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 checking by remember { mutableStateOf(false) }
|
||||||
var checkMsg by remember { mutableStateOf<String?>(null) }
|
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 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 {
|
val hasSmsPerm = remember {
|
||||||
androidx.core.content.ContextCompat.checkSelfPermission(context, android.Manifest.permission.SEND_SMS) ==
|
androidx.core.content.ContextCompat.checkSelfPermission(context, android.Manifest.permission.SEND_SMS) ==
|
||||||
android.content.pm.PackageManager.PERMISSION_GRANTED
|
android.content.pm.PackageManager.PERMISSION_GRANTED
|
||||||
}
|
}
|
||||||
|
val smsPermLauncher = rememberLauncherForActivityResult(ActivityResultContracts.RequestPermission()) { }
|
||||||
val treeLauncher = rememberLauncherForActivityResult(ActivityResultContracts.OpenDocumentTree()) { uri ->
|
val treeLauncher = rememberLauncherForActivityResult(ActivityResultContracts.OpenDocumentTree()) { uri ->
|
||||||
if (uri != null) {
|
if (uri != null) {
|
||||||
runCatching {
|
runCatching { context.contentResolver.takePersistableUriPermission(uri, Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION) }
|
||||||
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()
|
prefs.edit().putString("icon_tree", uri.toString()).apply()
|
||||||
treeUri = uri
|
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(
|
LazyColumn(
|
||||||
modifier = Modifier.fillMaxSize().padding(horizontal = 16.dp),
|
modifier = Modifier.fillMaxSize().padding(horizontal = 16.dp),
|
||||||
verticalArrangement = Arrangement.spacedBy(12.dp),
|
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||||
) {
|
) {
|
||||||
item { ScreenTitle("设置", "管理 Bark 服务、图标资源和维护操作") }
|
item { ScreenTitle("设置", "管理各转发通道、图标和维护") }
|
||||||
|
|
||||||
|
// ===== Bark 服务器 =====
|
||||||
item {
|
item {
|
||||||
SectionCard("Bark 服务器", subtitle = "勾选的服务器按列表顺序依次尝试转发,首个成功即止") {
|
SectionCard("Bark 服务器", subtitle = "勾选的服务器参与转发;开关切换容错/广播") {
|
||||||
if (servers.isEmpty()) {
|
SwitchRow(
|
||||||
Text("暂无服务器,请先添加。", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
title = "发通一条就停",
|
||||||
} else {
|
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)) {
|
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
servers.forEachIndexed { index, s ->
|
servers.forEachIndexed { i, s ->
|
||||||
ServerRow(
|
ServerRow(s, i == 0, i == servers.lastIndex,
|
||||||
server = s,
|
|
||||||
isFirst = index == 0,
|
|
||||||
isLast = index == servers.lastIndex,
|
|
||||||
onToggle = { v -> app.appScope.launch { app.settings.setServerEnabled(s.id, v) } },
|
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) } },
|
onDelete = { app.appScope.launch { app.settings.deleteServer(s.id) } },
|
||||||
onMoveUp = { app.appScope.launch { app.settings.moveServer(s.id, up = true) } },
|
onMoveUp = { app.appScope.launch { app.settings.moveServer(s.id, true) } },
|
||||||
onMoveDown = { app.appScope.launch { app.settings.moveServer(s.id, up = false) } },
|
onMoveDown = { app.appScope.launch { app.settings.moveServer(s.id, false) } },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Button(
|
AddButton { showAddBark = true }
|
||||||
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("添加服务器")
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ===== ntfy =====
|
||||||
item {
|
item {
|
||||||
SectionCard("ntfy 转发", subtitle = "POST 推送到 ntfy 服务;与 Bark 并行,互不依赖") {
|
SectionCard("ntfy 转发", subtitle = "POST 推送到 ntfy 服务") {
|
||||||
com.a2i.forwarder.ui.SwitchRow(
|
SwitchRow(title = "启用 ntfy 转发", subtitle = if (ntfyEnabled) "已开启" else "已关闭", checked = ntfyEnabled) { v -> app.appScope.launch { app.settings.setNtfyEnabled(v) } }
|
||||||
title = "启用 ntfy 转发",
|
|
||||||
subtitle = if (ntfyEnabled) "同时推送通知到 ntfy" else "当前关闭",
|
|
||||||
checked = ntfyEnabled,
|
|
||||||
) { v -> app.appScope.launch { app.settings.setNtfyEnabled(v) } }
|
|
||||||
|
|
||||||
if (ntfyEnabled) {
|
if (ntfyEnabled) {
|
||||||
Spacer(Modifier.size(8.dp))
|
SwitchRow(title = "发通一条就停", subtitle = if (ntfyStopFirst) "容错:首个成功止" else "广播:每个都发", checked = ntfyStopFirst) { v -> app.appScope.launch { app.settings.setNtfyStopOnFirst(v) } }
|
||||||
OutlinedTextField(
|
if (ntfyServers.isNotEmpty()) {
|
||||||
value = ntfyServerInput, onValueChange = { ntfyServerInput = it },
|
Spacer(Modifier.size(8.dp))
|
||||||
label = { Text("服务器地址") },
|
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
supportingText = { Text("如 https://ntfy.sh;自建填自建地址") },
|
ntfyServers.forEachIndexed { i, s ->
|
||||||
modifier = Modifier.fillMaxWidth(), singleLine = true,
|
ConfigRow(
|
||||||
)
|
title = s.name.ifBlank { s.topic },
|
||||||
Spacer(Modifier.size(4.dp))
|
subtitle = "${s.server} · topic: ${s.topic}${if (s.token.isNotBlank()) " · token已填" else ""}",
|
||||||
OutlinedTextField(
|
enabled = s.enabled, isFirst = i == 0, isLast = i == ntfyServers.lastIndex,
|
||||||
value = ntfyTopicInput, onValueChange = { ntfyTopicInput = it },
|
onToggle = { v -> app.appScope.launch { app.settings.setNtfyEnabled(s.id, v) } },
|
||||||
label = { Text("Topic") },
|
onEdit = { editingNtfy = s }, onDelete = { app.appScope.launch { app.settings.deleteNtfy(s.id) } },
|
||||||
supportingText = { Text("推送目标 topic(必填)") },
|
onMoveUp = { app.appScope.launch { app.settings.moveNtfy(s.id, true) } },
|
||||||
modifier = Modifier.fillMaxWidth(), singleLine = 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 = 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 {
|
item {
|
||||||
SectionCard("电邮转发", subtitle = "SMTPS 直发邮件(推荐端口 465);与 Bark 并行,互不依赖") {
|
SectionCard("电邮转发", subtitle = "SMTPS 直发邮件") {
|
||||||
com.a2i.forwarder.ui.SwitchRow(
|
SwitchRow(title = "启用电邮转发", subtitle = if (emailEnabled) "已开启" else "已关闭", checked = emailEnabled) { v -> app.appScope.launch { app.settings.setEmailForwarding(v) } }
|
||||||
title = "启用电邮转发",
|
|
||||||
subtitle = if (emailEnabled) "同时转发通知到邮箱" else "当前关闭",
|
|
||||||
checked = emailEnabled,
|
|
||||||
) { v -> app.appScope.launch { app.settings.setEmailForwarding(v) } }
|
|
||||||
|
|
||||||
if (emailEnabled) {
|
if (emailEnabled) {
|
||||||
Spacer(Modifier.size(8.dp))
|
SwitchRow(title = "发通一条就停", subtitle = if (emailStopFirst) "容错:首个成功止" else "广播:每个都发", checked = emailStopFirst) { v -> app.appScope.launch { app.settings.setEmailStopOnFirst(v) } }
|
||||||
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
if (emailServers.isNotEmpty()) {
|
||||||
OutlinedTextField(
|
Spacer(Modifier.size(8.dp))
|
||||||
value = smtpHostInput, onValueChange = { smtpHostInput = it },
|
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
label = { Text("SMTP 主机") },
|
emailServers.forEachIndexed { i, s ->
|
||||||
supportingText = { Text("如 smtp.qq.com") },
|
ConfigRow(
|
||||||
modifier = Modifier.weight(1.5f), singleLine = true,
|
title = s.name.ifBlank { s.to },
|
||||||
)
|
subtitle = "${s.host}:${s.port} · ${s.user} → ${s.to}",
|
||||||
OutlinedTextField(
|
enabled = s.enabled, isFirst = i == 0, isLast = i == emailServers.lastIndex,
|
||||||
value = smtpPortInput, onValueChange = { smtpPortInput = it.filter { c -> c.isDigit() } },
|
onToggle = { v -> app.appScope.launch { app.settings.setEmailEnabled(s.id, v) } },
|
||||||
label = { Text("端口") },
|
onEdit = { editingEmail = s }, onDelete = { app.appScope.launch { app.settings.deleteEmail(s.id) } },
|
||||||
supportingText = { Text("465") },
|
onMoveUp = { app.appScope.launch { app.settings.moveEmail(s.id, true) } },
|
||||||
modifier = Modifier.weight(1f), singleLine = 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 {
|
item {
|
||||||
SectionCard("断网短信兜底", subtitle = "无网络时把验证码、来电用短信发到 iPhone(每5分钟1条)") {
|
SectionCard("断网短信兜底", subtitle = "无网络时把验证码/来电用短信发出去") {
|
||||||
com.a2i.forwarder.ui.SwitchRow(
|
SwitchRow(title = "启用短信兜底", subtitle = if (smsFallback) "已开启" else "已关闭", checked = smsFallback) { v -> app.appScope.launch { app.settings.setSmsFallback(v) } }
|
||||||
title = "启用短信兜底",
|
|
||||||
subtitle = if (smsFallback) "断网时紧要通知走短信" else "当前关闭",
|
|
||||||
checked = smsFallback,
|
|
||||||
) { v -> app.appScope.launch { app.settings.setSmsFallback(v) } }
|
|
||||||
|
|
||||||
if (smsFallback) {
|
if (smsFallback) {
|
||||||
Spacer(Modifier.size(8.dp))
|
SwitchRow(title = "发通一条就停", subtitle = if (smsStopFirst) "容错:首个成功止" else "广播:每个都发", checked = smsStopFirst) { v -> app.appScope.launch { app.settings.setSmsStopOnFirst(v) } }
|
||||||
OutlinedTextField(
|
if (!hasSmsPerm) {
|
||||||
value = smsTargetInput,
|
Spacer(Modifier.size(4.dp))
|
||||||
onValueChange = { smsTargetInput = it },
|
OutlinedButton(onClick = { smsPermLauncher.launch(android.Manifest.permission.SEND_SMS) }, modifier = Modifier.fillMaxWidth()) { Text("授予短信权限") }
|
||||||
label = { Text("iPhone 手机号") },
|
}
|
||||||
supportingText = { Text("短信转发的目标号码") },
|
if (smsTargets.isNotEmpty()) {
|
||||||
modifier = Modifier.fillMaxWidth(),
|
Spacer(Modifier.size(8.dp))
|
||||||
singleLine = true,
|
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
)
|
smsTargets.forEachIndexed { i, s ->
|
||||||
Spacer(Modifier.size(4.dp))
|
ConfigRow(
|
||||||
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
title = s.name.ifBlank { s.number },
|
||||||
OutlinedButton(onClick = {
|
subtitle = s.number,
|
||||||
app.appScope.launch { app.settings.setSmsTargetNumber(smsTargetInput.trim()) }
|
enabled = s.enabled, isFirst = i == 0, isLast = i == smsTargets.lastIndex,
|
||||||
smsNumSaved = true
|
onToggle = { v -> app.appScope.launch { app.settings.setSmsTargetEnabled(s.id, v) } },
|
||||||
}, modifier = Modifier.weight(1f)) { Text(if (smsNumSaved) "✓ 已保存" else "保存号码") }
|
onEdit = { editingSms = s }, onDelete = { app.appScope.launch { app.settings.deleteSmsTarget(s.id) } },
|
||||||
if (smsNumSaved) {
|
onMoveUp = { app.appScope.launch { app.settings.moveSmsTarget(s.id, true) } },
|
||||||
LaunchedEffect(Unit) { kotlinx.coroutines.delay(2000); smsNumSaved = false }
|
onMoveDown = { app.appScope.launch { app.settings.moveSmsTarget(s.id, false) } },
|
||||||
}
|
onTest = null,
|
||||||
if (!hasSmsPerm) {
|
)
|
||||||
OutlinedButton(onClick = {
|
}
|
||||||
smsPermLauncher.launch(android.Manifest.permission.SEND_SMS)
|
|
||||||
}, modifier = Modifier.weight(1f)) { Text("授予短信权限") }
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
AddButton { showAddSms = true }
|
||||||
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 }
|
|
||||||
}
|
|
||||||
|
|
||||||
Spacer(Modifier.size(8.dp))
|
Spacer(Modifier.size(8.dp))
|
||||||
val balanceText = when {
|
val balanceText = when {
|
||||||
smsSuspended -> "已挂起(余额不足)"
|
smsSuspended -> "已挂起(余额不足)"
|
||||||
smsBalance < 0 -> "余额未知"
|
smsBalance < 0 -> "余额未知"
|
||||||
else -> "剩余约 ${smsBalance} 条"
|
else -> "剩余约 $smsBalance 条"
|
||||||
}
|
}
|
||||||
Text(
|
Text("运营商:${detectedCarrier.displayName} · 余额:$balanceText · 手动额度:$smsManualQuota", style = MaterialTheme.typography.bodySmall, color = if (smsSuspended) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.onSurfaceVariant)
|
||||||
"当前状态:$balanceText",
|
|
||||||
style = MaterialTheme.typography.bodySmall,
|
|
||||||
color = if (smsSuspended) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.onSurfaceVariant,
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ===== 应用图标 =====
|
||||||
item {
|
item {
|
||||||
SectionCard("应用图标", subtitle = "导出 App 图标后,Bark 通知可按包名显示图标") {
|
SectionCard("应用图标", subtitle = "导出 App 图标后,Bark 通知可按包名显示图标") {
|
||||||
OutlinedTextField(
|
OutlinedTextField(value = iconPrefix, onValueChange = { app.appScope.launch { app.settings.setIconPrefix(it) } }, label = { Text("图标 URL 前缀") }, modifier = Modifier.fillMaxWidth(), singleLine = true)
|
||||||
value = iconPrefix,
|
Text("导出目录:${treeUri?.let { DocumentFile.fromTreeUri(context, it)?.name } ?: "未选择"}", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.padding(top = 8.dp))
|
||||||
onValueChange = { app.appScope.launch { app.settings.setIconPrefix(it) } },
|
Row(modifier = Modifier.fillMaxWidth().padding(top = 10.dp), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
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),
|
|
||||||
) {
|
|
||||||
OutlinedButton(onClick = { treeLauncher.launch(null) }, modifier = Modifier.weight(1f)) { Text("选择目录") }
|
OutlinedButton(onClick = { treeLauncher.launch(null) }, modifier = Modifier.weight(1f)) { Text("选择目录") }
|
||||||
Button(
|
Button(onClick = {
|
||||||
onClick = {
|
val u = treeUri ?: return@Button
|
||||||
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}" }
|
||||||
app.appScope.launch {
|
}, enabled = treeUri != null && !exporting, modifier = Modifier.weight(1f)) {
|
||||||
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("导出图标")
|
if (exporting) CircularProgressIndicator(Modifier.size(18.dp), strokeWidth = 2.dp) else Text("导出图标")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
exportResult?.let {
|
exportResult?.let { Text(it, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.primary, modifier = Modifier.padding(top = 8.dp)) }
|
||||||
Text(it, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.primary, modifier = Modifier.padding(top = 8.dp))
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ===== 更新 =====
|
||||||
item {
|
item {
|
||||||
SectionCard("更新", subtitle = "版本检查(数据来自 Gitea Releases)") {
|
SectionCard("更新", subtitle = "版本检查(数据来自 Gitea + GitHub 双源)") {
|
||||||
com.a2i.forwarder.ui.SwitchRow(
|
SwitchRow(title = "自动检查更新", subtitle = "启动时检查 + 每 24 小时巡检", checked = updateAuto, icon = Icons.Filled.Update) { v -> app.appScope.launch { app.settings.setUpdateCheckEnabled(v) } }
|
||||||
title = "自动检查更新",
|
|
||||||
subtitle = "启动时检查,且距上次满 24 小时再次检查",
|
|
||||||
checked = updateAuto,
|
|
||||||
icon = Icons.Filled.Update,
|
|
||||||
) { v -> app.appScope.launch { app.settings.setUpdateCheckEnabled(v) } }
|
|
||||||
|
|
||||||
ClickRow(
|
ClickRow(
|
||||||
title = "检查更新",
|
title = "检查更新",
|
||||||
subtitle = "当前 v${appVersionName(context)}" +
|
subtitle = "当前 v${appVersionName(context)}" + (updateInfo?.let { " · 已发现 v${it.latestVersion}" } ?: " · 点击立即检查"),
|
||||||
(updateInfo?.let { " · 已发现 v${it.latestVersion}" } ?: " · 点击立即检查"),
|
|
||||||
icon = Icons.Filled.SystemUpdateAlt,
|
icon = Icons.Filled.SystemUpdateAlt,
|
||||||
onClick = {
|
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 -> "已是最新版本" } } },
|
||||||
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) {
|
if (checking) {
|
||||||
Row(
|
Row(modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp), verticalAlignment = Alignment.CenterVertically) {
|
||||||
modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp),
|
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
|
||||||
) {
|
|
||||||
CircularProgressIndicator(Modifier.size(16.dp), strokeWidth = 2.dp)
|
CircularProgressIndicator(Modifier.size(16.dp), strokeWidth = 2.dp)
|
||||||
Spacer(Modifier.width(8.dp))
|
Spacer(Modifier.width(8.dp))
|
||||||
Text("正在检查…", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
Text("正在检查…", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
checkMsg?.let {
|
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)) }
|
||||||
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),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ===== 维护 =====
|
||||||
item {
|
item {
|
||||||
SectionCard("维护", subtitle = "日志查看和本地统计清理") {
|
SectionCard("维护", subtitle = "日志查看和本地统计清理") {
|
||||||
ClickRow("转发日志", "查看 / 清空最近记录", icon = Icons.Filled.History, onClick = onOpenLog)
|
ClickRow("转发日志", "查看 / 清空最近记录", icon = Icons.Filled.History, onClick = onOpenLog)
|
||||||
ClickRow("重置计数器", "清零转发 / 过滤 / 失败统计", icon = Icons.Filled.RestartAlt, tint = MaterialTheme.colorScheme.error) {
|
ClickRow("重置计数器", "清零转发 / 过滤 / 失败统计", icon = Icons.Filled.RestartAlt, tint = MaterialTheme.colorScheme.error) { app.appScope.launch { app.logStore.resetCounters() } }
|
||||||
app.appScope.launch { app.logStore.resetCounters() }
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
item {
|
item {
|
||||||
Box(Modifier.fillMaxWidth().padding(top = 4.dp, bottom = 18.dp), contentAlignment = Alignment.Center) {
|
Spacer(Modifier.padding(top = 4.dp, bottom = 18.dp))
|
||||||
Text(
|
|
||||||
"a2i · 安卓通知转发到 iOS Bark\n图标需 iOS 15+;点击通知会打开 iOS 对应 App",
|
|
||||||
style = MaterialTheme.typography.bodySmall,
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (showAdd) {
|
// 弹窗
|
||||||
ServerDialog(
|
if (showAddBark) ServerDialog(onDismiss = { showAddBark = false }, onConfirm = { n, s, k -> app.appScope.launch { app.settings.addServer(n, s, k) }; showAddBark = false })
|
||||||
onDismiss = { showAdd = 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 }) }
|
||||||
onConfirm = { n, s, k ->
|
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 })
|
||||||
app.appScope.launch {
|
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 }) }
|
||||||
app.settings.addServer(n, s, k)
|
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 }) }
|
||||||
showAdd = false
|
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 }) }
|
||||||
)
|
|
||||||
}
|
|
||||||
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
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@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
|
@Composable
|
||||||
private fun ServerRow(
|
private fun ServerRow(
|
||||||
server: BarkServer,
|
server: BarkServer, isFirst: Boolean, isLast: Boolean,
|
||||||
isFirst: Boolean,
|
onToggle: (Boolean) -> Unit, onEdit: () -> Unit, onDelete: () -> Unit,
|
||||||
isLast: Boolean,
|
onMoveUp: () -> Unit, onMoveDown: () -> Unit,
|
||||||
onToggle: (Boolean) -> Unit,
|
|
||||||
onEdit: () -> Unit,
|
|
||||||
onDelete: () -> Unit,
|
|
||||||
onMoveUp: () -> Unit,
|
|
||||||
onMoveDown: () -> Unit,
|
|
||||||
) {
|
) {
|
||||||
val app = A2iApp.instance
|
val app = A2iApp.instance
|
||||||
var testing by remember { mutableStateOf(false) }
|
var testing by remember { mutableStateOf(false) }
|
||||||
var testMsg by remember { mutableStateOf<String?>(null) }
|
var testMsg by remember { mutableStateOf<String?>(null) }
|
||||||
Surface(
|
Surface(
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(), shape = RoundedCornerShape(8.dp),
|
||||||
shape = RoundedCornerShape(8.dp),
|
|
||||||
color = if (server.enabled) MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.40f) else MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.36f),
|
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),
|
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)) {
|
Column(Modifier.padding(start = 4.dp, end = 4.dp, top = 2.dp, bottom = 6.dp)) {
|
||||||
// 第一行:勾选 + 名称 + 编辑/删除
|
|
||||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
Checkbox(checked = server.enabled, onCheckedChange = onToggle)
|
Checkbox(checked = server.enabled, onCheckedChange = onToggle)
|
||||||
Row(Modifier.weight(1f), verticalAlignment = Alignment.CenterVertically) {
|
Row(Modifier.weight(1f), verticalAlignment = Alignment.CenterVertically) {
|
||||||
Text(
|
Text(server.name.ifBlank { "未命名" }, style = MaterialTheme.typography.bodyLarge, fontWeight = FontWeight.Medium, maxLines = 1, overflow = TextOverflow.Ellipsis, modifier = Modifier.weight(1f, fill = false))
|
||||||
server.name.ifBlank { "未命名" },
|
if (!server.enabled) { Spacer(Modifier.width(8.dp)); StatusBadge("停用", MaterialTheme.colorScheme.onSurfaceVariant) }
|
||||||
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(
|
IconButton(onClick = { if (!testing) app.appScope.launch { testing = true; testMsg = testPush(app, server); testing = false } }, enabled = !testing) {
|
||||||
onClick = { if (!testing) app.appScope.launch { testing = true; testMsg = testPush(app, server); testing = false } },
|
if (testing) CircularProgressIndicator(Modifier.size(18.dp), strokeWidth = 2.dp) else Icon(Icons.AutoMirrored.Filled.Send, "测试")
|
||||||
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 = onEdit) { Icon(Icons.Filled.Edit, "编辑") }
|
||||||
IconButton(onClick = onDelete) { Icon(Icons.Filled.Delete, "删除") }
|
IconButton(onClick = onDelete) { Icon(Icons.Filled.Delete, "删除") }
|
||||||
}
|
}
|
||||||
// 第二行:地址 + key + 上移/下移
|
|
||||||
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.padding(top = 2.dp)) {
|
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.padding(top = 2.dp)) {
|
||||||
Column(Modifier.weight(1f).padding(start = 36.dp)) {
|
Column(Modifier.weight(1f).padding(start = 36.dp)) {
|
||||||
Text(
|
Text(server.server, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, maxLines = 2, overflow = TextOverflow.Ellipsis)
|
||||||
server.server,
|
Text("device key: " + if (server.deviceKey.isBlank()) "未填" else "已填(${server.deviceKey.length} 位)", style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||||
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 = onMoveUp, enabled = !isFirst) { Icon(Icons.Filled.KeyboardArrowUp, "上移") }
|
||||||
IconButton(onClick = onMoveDown, enabled = !isLast) { Icon(Icons.Filled.KeyboardArrowDown, "下移") }
|
IconButton(onClick = onMoveDown, enabled = !isLast) { Icon(Icons.Filled.KeyboardArrowDown, "下移") }
|
||||||
}
|
}
|
||||||
testMsg?.let {
|
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)) }
|
||||||
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
|
@Composable
|
||||||
private fun ServerDialog(
|
private fun ServerDialog(initial: BarkServer? = null, onDismiss: () -> Unit, onConfirm: (String, String, String) -> Unit) {
|
||||||
initial: BarkServer? = null,
|
|
||||||
onDismiss: () -> Unit,
|
|
||||||
onConfirm: (name: String, server: String, key: String) -> Unit,
|
|
||||||
) {
|
|
||||||
var name by remember { mutableStateOf(initial?.name ?: "") }
|
var name by remember { mutableStateOf(initial?.name ?: "") }
|
||||||
var server by remember { mutableStateOf(initial?.server ?: OFFICIAL_BARK_SERVER) }
|
var server by remember { mutableStateOf(initial?.server ?: OFFICIAL_BARK_SERVER) }
|
||||||
var key by remember { mutableStateOf(initial?.deviceKey ?: "") }
|
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(
|
AlertDialog(
|
||||||
onDismissRequest = onDismiss,
|
onDismissRequest = onDismiss,
|
||||||
icon = { Icon(Icons.Filled.Settings, null) },
|
icon = { Icon(Icons.Filled.Settings, null) },
|
||||||
title = { Text(if (initial == null) "添加服务器" else "编辑服务器") },
|
title = { Text(title) },
|
||||||
text = {
|
text = { Column { content() } },
|
||||||
Column {
|
confirmButton = { TextButton(onClick = onConfirm) { Text("保存") } },
|
||||||
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("保存") } },
|
|
||||||
dismissButton = { TextButton(onClick = onDismiss) { Text("取消") } },
|
dismissButton = { TextButton(onClick = onDismiss) { Text("取消") } },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun testPush(app: A2iApp, server: BarkServer): String {
|
private suspend fun testPush(app: A2iApp, server: BarkServer): String {
|
||||||
if (server.deviceKey.isBlank()) return "未填写 device key"
|
if (server.deviceKey.isBlank()) return "未填写 device key"
|
||||||
val msg = BarkMessage(
|
val msg = BarkMessage(deviceKey = server.deviceKey, title = "a2i 测试", body = "如果你在 iOS 看到这条推送,说明配置成功", level = "active")
|
||||||
deviceKey = server.deviceKey,
|
|
||||||
title = "a2i 测试",
|
|
||||||
body = "如果你在 iOS 看到这条推送,说明配置成功",
|
|
||||||
level = "active",
|
|
||||||
)
|
|
||||||
val r = BarkClient(server).push(msg)
|
val r = BarkClient(server).push(msg)
|
||||||
return if (r.isSuccess) "已发送,请查看 iOS 端" else "发送失败:${r.exceptionOrNull()?.message}"
|
return if (r.isSuccess) "已发送,请查看 iOS 端" else "发送失败:${r.exceptionOrNull()?.message}"
|
||||||
}
|
}
|
||||||
Reference in New Issue
Block a user