diff --git a/app/src/main/java/com/a2i/forwarder/core/NtfySender.kt b/app/src/main/java/com/a2i/forwarder/core/NtfySender.kt index 0f1389f..8ab02aa 100644 --- a/app/src/main/java/com/a2i/forwarder/core/NtfySender.kt +++ b/app/src/main/java/com/a2i/forwarder/core/NtfySender.kt @@ -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 { diff --git a/app/src/main/java/com/a2i/forwarder/store/SettingsStore.kt b/app/src/main/java/com/a2i/forwarder/store/SettingsStore.kt index 7a3fa09..071da46 100644 --- a/app/src/main/java/com/a2i/forwarder/store/SettingsStore.kt +++ b/app/src/main/java/com/a2i/forwarder/store/SettingsStore.kt @@ -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() diff --git a/app/src/main/java/com/a2i/forwarder/ui/screens/SettingsScreen.kt b/app/src/main/java/com/a2i/forwarder/ui/screens/SettingsScreen.kt index aa7b7aa..8814c2a 100644 --- a/app/src/main/java/com/a2i/forwarder/ui/screens/SettingsScreen.kt +++ b/app/src/main/java/com/a2i/forwarder/ui/screens/SettingsScreen.kt @@ -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(null) } var showAddNtfy by remember { mutableStateOf(false) } + var showNtfyHelp by remember { mutableStateOf(false) } var editingNtfy by remember { mutableStateOf(null) } var showAddEmail by remember { mutableStateOf(false) } var editingEmail by remember { mutableStateOf(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 APK,iPhone / iPad 可用 App Store。") + HelpStep("2", "打开 ntfy,添加订阅。服务器通常填 $NTFY_OFFICIAL_SERVER;Topic 建议用一串不容易猜到的随机名字,例如 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(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}" +}