feat: sync ntfy settings with bark

This commit is contained in:
2026-07-10 08:51:26 +08:00
parent d666c7f5d4
commit ff9673ed11
3 changed files with 153 additions and 42 deletions
@@ -89,7 +89,6 @@ object NtfySender {
fun trySend(app: A2iApp, title: String, body: String, scope: CoroutineScope) {
val s = app.settings
if (!s.ntfyEnabled.value) return
val configs = s.ntfyServers.value.filter { it.enabled && it.topic.isNotBlank() }
if (configs.isEmpty()) return
scope.launch {
@@ -57,8 +57,6 @@ class SettingsStore(
// 电邮转发
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")
@@ -117,9 +115,8 @@ class SettingsStore(
val smsSuspended = MutableStateFlow(false)
val lastSmsSentTime = MutableStateFlow(0L)
// 电邮/ntfy 总开关
// 电邮总开关
val emailForwardingEnabled = MutableStateFlow(false)
val ntfyEnabled = MutableStateFlow(false)
// 应用更新
val updateCheckEnabled = MutableStateFlow(true)
@@ -154,9 +151,8 @@ class SettingsStore(
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
@@ -325,7 +321,6 @@ 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 setNtfyEnabled(v: Boolean) { edit { it[K.NTFY_ENABLED] = v }; ntfyEnabled.value = v }
suspend fun snapshot() = ds.data.first()
@@ -84,6 +84,13 @@ private const val BARK_APP_STORE_URL = "https://apps.apple.com/app/bark-custom-n
private const val BARK_DOCS_URL = "https://bark.day.app/#/"
private const val BARK_GITHUB_URL = "https://github.com/Finb/Bark"
private const val BARK_OFFICIAL_SERVER = "https://api.day.app"
private const val NTFY_OFFICIAL_SERVER = "https://ntfy.sh"
private const val NTFY_ANDROID_URL = "https://play.google.com/store/apps/details?id=io.heckel.ntfy"
private const val NTFY_IOS_URL = "https://apps.apple.com/us/app/ntfy/id1625396347"
private const val NTFY_FDROID_URL = "https://f-droid.org/en/packages/io.heckel.ntfy/"
private const val NTFY_DOCS_URL = "https://docs.ntfy.sh/subscribe/phone/"
private const val NTFY_PUBLISH_DOCS_URL = "https://docs.ntfy.sh/publish/"
private const val NTFY_GITHUB_URL = "https://github.com/binwiederhier/ntfy"
@Composable
fun SettingsScreen(onOpenLog: () -> Unit) {
@@ -97,7 +104,6 @@ fun SettingsScreen(onOpenLog: () -> Unit) {
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()
@@ -132,6 +138,7 @@ fun SettingsScreen(onOpenLog: () -> Unit) {
var showBarkHelp by remember { mutableStateOf(false) }
var editingBark by remember { mutableStateOf<BarkServer?>(null) }
var showAddNtfy by remember { mutableStateOf(false) }
var showNtfyHelp by remember { mutableStateOf(false) }
var editingNtfy by remember { mutableStateOf<NtfyServer?>(null) }
var showAddEmail by remember { mutableStateOf(false) }
var editingEmail by remember { mutableStateOf<EmailServer?>(null) }
@@ -181,29 +188,39 @@ fun SettingsScreen(onOpenLog: () -> Unit) {
// ===== ntfy =====
item {
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))
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配置测试") } },
)
}
SectionCard(
"ntfy 转发",
subtitle = "勾选的 topic 参与转发;开关切换容错/广播",
headerAction = {
Row(verticalAlignment = Alignment.CenterVertically) {
IconButton(onClick = { showNtfyHelp = true }) {
Icon(Icons.Filled.Info, "ntfy 配置帮助")
}
CompactStopOnFirstSwitch(
checked = ntfyStopFirst,
onChange = { v -> app.appScope.launch { app.settings.setNtfyStopOnFirst(v) } },
)
}
},
) {
if (ntfyServers.isNotEmpty()) {
Spacer(Modifier.size(8.dp))
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
ntfyServers.forEachIndexed { i, s ->
NtfyServerRow(
server = s,
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) } },
)
}
}
AddButton { showAddNtfy = true }
}
AddButton { showAddNtfy = true }
}
}
@@ -328,6 +345,7 @@ fun SettingsScreen(onOpenLog: () -> Unit) {
// 弹窗
if (showBarkHelp) BarkHelpDialog(onDismiss = { showBarkHelp = false })
if (showNtfyHelp) NtfyHelpDialog(onDismiss = { showNtfyHelp = false })
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 })
@@ -389,12 +407,12 @@ private fun BarkHelpDialog(onDismiss: () -> Unit) {
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
BarkHelpStep("1", "在 iPhone / iPad 上安装 Bark,首次打开时允许通知权限。")
BarkHelpStep("2", "打开 Bark,复制首页显示的推送地址,通常类似 $BARK_OFFICIAL_SERVER/你的Key。")
BarkHelpStep("3", "回到 a2i,点击本区域底部的「添加」。服务器地址填 $BARK_OFFICIAL_SERVER,自建服务则填你的自建地址。")
BarkHelpStep("4", "Device Key 填推送地址最后一段 Key,不要把完整 URL 都填进去。")
BarkHelpStep("5", "保存后点服务器行里的发送按钮测试;iPhone 收到「a2i 测试」就说明 Bark 通道已通。")
BarkHelpStep("6", "最后回首页开启「通知转发」,并按提示授予系统通知监听权限。")
HelpStep("1", "在 iPhone / iPad 上安装 Bark,首次打开时允许通知权限。")
HelpStep("2", "打开 Bark,复制首页显示的推送地址,通常类似 $BARK_OFFICIAL_SERVER/你的Key。")
HelpStep("3", "回到 a2i,点击本区域底部的「添加」。服务器地址填 $BARK_OFFICIAL_SERVER,自建服务则填你的自建地址。")
HelpStep("4", "Device Key 填推送地址最后一段 Key,不要把完整 URL 都填进去。")
HelpStep("5", "保存后点服务器行里的发送按钮测试;iPhone 收到「a2i 测试」就说明 Bark 通道已通。")
HelpStep("6", "最后回首页开启「通知转发」,并按提示授予系统通知监听权限。")
Text(
"常用链接",
@@ -402,9 +420,9 @@ private fun BarkHelpDialog(onDismiss: () -> Unit) {
fontWeight = FontWeight.SemiBold,
modifier = Modifier.padding(top = 4.dp),
)
BarkHelpLink("Bark App Store", BARK_APP_STORE_URL) { uriHandler.openUri(it) }
BarkHelpLink("Bark 官方文档", BARK_DOCS_URL) { uriHandler.openUri(it) }
BarkHelpLink("Bark GitHub", BARK_GITHUB_URL) { uriHandler.openUri(it) }
HelpLink("Bark App Store", BARK_APP_STORE_URL) { uriHandler.openUri(it) }
HelpLink("Bark 官方文档", BARK_DOCS_URL) { uriHandler.openUri(it) }
HelpLink("Bark GitHub", BARK_GITHUB_URL) { uriHandler.openUri(it) }
Text(
"提示:如果使用自建 Bark Server,a2i 里的服务器地址要换成自建地址,但 Device Key 仍然来自 Bark App。",
@@ -419,7 +437,60 @@ private fun BarkHelpDialog(onDismiss: () -> Unit) {
}
@Composable
private fun BarkHelpStep(
private fun NtfyHelpDialog(onDismiss: () -> Unit) {
val uriHandler = LocalUriHandler.current
AlertDialog(
onDismissRequest = onDismiss,
icon = { Icon(Icons.Filled.Info, null) },
title = { Text("ntfy 配置帮助") },
text = {
Column(
modifier = Modifier
.heightIn(max = 440.dp)
.verticalScroll(rememberScrollState()),
verticalArrangement = Arrangement.spacedBy(10.dp),
) {
Text(
"ntfy 用来把 a2i 处理后的通知推送到 Android、iOS 或网页版 ntfy。它不需要先建项目,只要接收端和 a2i 使用同一个 server + topic 即可。",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
HelpStep("1", "在接收端安装 ntfy。Android 可用 Google Play / F-Droid / GitHub APKiPhone / iPad 可用 App Store。")
HelpStep("2", "打开 ntfy,添加订阅。服务器通常填 $NTFY_OFFICIAL_SERVERTopic 建议用一串不容易猜到的随机名字,例如 a2i_xxxxxxxx。")
HelpStep("3", "回到 a2i,点击本区域底部的「添加」。服务器和 Topic 要与接收端订阅完全一致。")
HelpStep("4", "Token 是可选项。只有你在 ntfy 账号或自建服务里把 topic 设成需要鉴权时,才填写 access token。")
HelpStep("5", "保存后点 topic 行里的发送按钮测试;接收端收到「a2i 测试」就说明 ntfy 通道已通。")
HelpStep("6", "后续只要这一行左侧保持勾选,它就会参与转发;取消勾选就是停用这个 topic。")
Text(
"常用链接",
style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.SemiBold,
modifier = Modifier.padding(top = 4.dp),
)
HelpLink("ntfy Android · Google Play", NTFY_ANDROID_URL) { uriHandler.openUri(it) }
HelpLink("ntfy Android · F-Droid", NTFY_FDROID_URL) { uriHandler.openUri(it) }
HelpLink("ntfy iOS · App Store", NTFY_IOS_URL) { uriHandler.openUri(it) }
HelpLink("ntfy 手机端文档", NTFY_DOCS_URL) { uriHandler.openUri(it) }
HelpLink("ntfy 发布 API 文档", NTFY_PUBLISH_DOCS_URL) { uriHandler.openUri(it) }
HelpLink("ntfy GitHub", NTFY_GITHUB_URL) { uriHandler.openUri(it) }
Text(
"提示:公共 topic 谁知道名字谁就可能订阅或发送,所以 topic 名不要用手机号、姓名、邮箱等可猜信息。",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(top = 2.dp),
)
}
},
confirmButton = { TextButton(onClick = onDismiss) { Text("知道了") } },
)
}
@Composable
private fun HelpStep(
index: String,
text: String,
) {
@@ -440,7 +511,7 @@ private fun BarkHelpStep(
}
@Composable
private fun BarkHelpLink(
private fun HelpLink(
title: String,
url: String,
onOpen: (String) -> Unit,
@@ -485,7 +556,47 @@ private fun ConfigRow(
}
}
// ===== Bark 服务器 Row + Dialog(保留原有) =====
// ===== ntfy / Bark Row + Dialog =====
@Composable
private fun NtfyServerRow(
server: NtfyServer, 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),
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 { server.topic.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 = testNtfy(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, "删除") }
}
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("topic: " + server.topic.ifBlank { "未填" } + if (server.token.isNotBlank()) " · token已填" else "", style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSurfaceVariant, maxLines = 1, overflow = TextOverflow.Ellipsis)
}
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)) }
}
}
}
@Composable
private fun ServerRow(
server: BarkServer, isFirst: Boolean, isLast: Boolean,
@@ -543,7 +654,7 @@ private fun ServerDialog(initial: BarkServer? = null, onDismiss: () -> Unit, onC
@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 server by remember { mutableStateOf(initial?.server ?: NTFY_OFFICIAL_SERVER) }
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()) }) {
@@ -613,3 +724,9 @@ private suspend fun testPush(app: A2iApp, server: BarkServer): String {
val r = BarkClient(server).push(msg)
return if (r.isSuccess) "已发送,请查看 iOS 端" else "发送失败:${r.exceptionOrNull()?.message}"
}
private suspend fun testNtfy(server: NtfyServer): String {
if (server.topic.isBlank()) return "未填写 topic"
val r = NtfySender.send(server.server, server.topic, server.token, "a2i 测试", "如果你在 ntfy 看到这条推送,说明配置成功")
return if (r.isSuccess) "已发送,请查看 ntfy 接收端" else "发送失败:${r.exceptionOrNull()?.message}"
}