From 2470360bbedcf6f2ff60c9fc6ecc059332c3e12b Mon Sep 17 00:00:00 2001 From: Song <168455@qq.com> Date: Thu, 9 Jul 2026 17:49:15 +0800 Subject: [PATCH] feat: ntfy forwarding support MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Parallel push channel alongside Bark and email: POST {server}/{topic} with optional Bearer auth. Same fire-and-forget pattern as email. - core/NtfySender: OkHttp PUT to ntfy; retry 3x on IOException - SettingsStore: ntfyEnabled/server/topic/token (default server ntfy.sh) - NotifyListenerService + PhoneCallMonitor: NtfySender.trySend after bark - UI: "ntfy 转发" SectionCard (Bark → ntfy → Email → SMS) - version 1.6.2 -> 1.7.0 (code 12 -> 13) Co-Authored-By: Claude Opus 4.8 (1M context) --- app/build.gradle.kts | 4 +- .../java/com/a2i/forwarder/core/NtfySender.kt | 74 +++++++++++++++++++ .../a2i/forwarder/core/PhoneCallMonitor.kt | 2 + .../service/NotifyListenerService.kt | 3 + .../com/a2i/forwarder/store/SettingsStore.kt | 21 ++++++ .../forwarder/ui/screens/SettingsScreen.kt | 58 +++++++++++++++ 6 files changed, 160 insertions(+), 2 deletions(-) create mode 100644 app/src/main/java/com/a2i/forwarder/core/NtfySender.kt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index e3e29dc..f50213d 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -13,8 +13,8 @@ android { applicationId = "com.a2i.forwarder" minSdk = 34 targetSdk = 36 - versionCode = 12 - versionName = "1.6.2" + versionCode = 13 + versionName = "1.7.0" } signingConfigs { diff --git a/app/src/main/java/com/a2i/forwarder/core/NtfySender.kt b/app/src/main/java/com/a2i/forwarder/core/NtfySender.kt new file mode 100644 index 0000000..1358404 --- /dev/null +++ b/app/src/main/java/com/a2i/forwarder/core/NtfySender.kt @@ -0,0 +1,74 @@ +package com.a2i.forwarder.core + +import com.a2i.forwarder.A2iApp +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import okhttp3.OkHttpClient +import okhttp3.Request +import okhttp3.MediaType.Companion.toMediaType +import okhttp3.RequestBody.Companion.toRequestBody +import java.io.IOException +import java.util.concurrent.TimeUnit + +/** + * ntfy 推送:POST {server}/{topic},可选 Bearer auth。 + * 对瞬时 IOException 自动重试 3 次(退避 600/1200ms)。 + */ +object NtfySender { + private val http = OkHttpClient.Builder() + .connectTimeout(10, TimeUnit.SECONDS) + .writeTimeout(10, TimeUnit.SECONDS) + .readTimeout(15, TimeUnit.SECONDS) + .retryOnConnectionFailure(true) + .build() + + suspend fun send( + server: String, topic: String, token: String, + title: String, body: String, + ): Result = withContext(Dispatchers.IO) { + runCatching { + val url = server.trim().removeSuffix("/") + "/" + topic.trim() + val req = Request.Builder().url(url) + .header("Title", title.take(200)) + .apply { + if (token.isNotBlank()) header("Authorization", "Bearer $token") + } + .put(body.take(4000).toRequestBody(TEXT_PLAIN)) + .build() + doPushWithRetry(req) + } + } + + private fun doPushWithRetry(req: Request) { + var lastError: IOException? = null + repeat(MAX_ATTEMPTS) { attempt -> + try { + http.newCall(req).execute().use { res -> + if (res.isSuccessful) return + error("HTTP ${res.code}") + } + } catch (e: IOException) { + lastError = e + if (attempt < MAX_ATTEMPTS - 1) { + Thread.sleep(BACKOFF_MS[attempt]) + } + } + } + throw lastError ?: error("ntfy 推送失败") + } + + fun trySend(app: A2iApp, title: String, body: String, scope: CoroutineScope) { + val s = app.settings + if (!s.ntfyEnabled.value || s.ntfyTopic.value.isBlank()) return + scope.launch { + NtfySender.send(s.ntfyServer.value, s.ntfyTopic.value, s.ntfyToken.value, title, body) + } + } + + private val TEXT_PLAIN = "text/plain; charset=utf-8".toMediaType() + private const val MAX_ATTEMPTS = 3 + private val BACKOFF_MS = longArrayOf(600L, 1200L) +} diff --git a/app/src/main/java/com/a2i/forwarder/core/PhoneCallMonitor.kt b/app/src/main/java/com/a2i/forwarder/core/PhoneCallMonitor.kt index b6a7674..ad0872d 100644 --- a/app/src/main/java/com/a2i/forwarder/core/PhoneCallMonitor.kt +++ b/app/src/main/java/com/a2i/forwarder/core/PhoneCallMonitor.kt @@ -126,6 +126,7 @@ class PhoneCallMonitor(private val context: Context) { val servers = app.settings.servers.value.filter { it.enabled && it.deviceKey.isNotBlank() } if (servers.isEmpty()) { EmailSender.trySend(app, msg.title ?: logBody, msg.body ?: "", scope) + NtfySender.trySend(app, msg.title ?: logBody, msg.body ?: "", scope) return@launch } val result = BarkSender.pushToFirst(servers, msg) @@ -144,6 +145,7 @@ class PhoneCallMonitor(private val context: Context) { ) } EmailSender.trySend(app, msg.title ?: logBody, msg.body ?: "", scope) + NtfySender.trySend(app, msg.title ?: logBody, msg.body ?: "", scope) } } } diff --git a/app/src/main/java/com/a2i/forwarder/service/NotifyListenerService.kt b/app/src/main/java/com/a2i/forwarder/service/NotifyListenerService.kt index 18ebb41..9de572c 100644 --- a/app/src/main/java/com/a2i/forwarder/service/NotifyListenerService.kt +++ b/app/src/main/java/com/a2i/forwarder/service/NotifyListenerService.kt @@ -7,6 +7,7 @@ import com.a2i.forwarder.core.BarkSender import com.a2i.forwarder.core.EmailSender import com.a2i.forwarder.core.ForwardLog import com.a2i.forwarder.core.NotificationProcessor +import com.a2i.forwarder.core.NtfySender import com.a2i.forwarder.core.PendingPush import com.a2i.forwarder.core.RetryWorker import kotlinx.coroutines.CoroutineScope @@ -63,6 +64,7 @@ class NotifyListenerService : NotificationListenerService() { if (servers.isEmpty()) { app.logStore.addLog(ForwardLog(now, sbn.packageName, d.appLabel, msg.title, msg.body, "failed", "未配置可用 Bark 服务器")) EmailSender.trySend(app, msg.title ?: d.appLabel, msg.body, scope) + NtfySender.trySend(app, msg.title ?: d.appLabel, msg.body, scope) return } @@ -77,5 +79,6 @@ class NotifyListenerService : NotificationListenerService() { ) } EmailSender.trySend(app, msg.title ?: d.appLabel, msg.body, scope) + NtfySender.trySend(app, msg.title ?: d.appLabel, msg.body, scope) } } 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 c0fd279..5206cee 100644 --- a/app/src/main/java/com/a2i/forwarder/store/SettingsStore.kt +++ b/app/src/main/java/com/a2i/forwarder/store/SettingsStore.kt @@ -54,6 +54,11 @@ class SettingsStore( val EMAIL_PASS = stringPreferencesKey("email_smtp_pass") val EMAIL_FROM = stringPreferencesKey("email_from") val EMAIL_TO = stringPreferencesKey("email_to") + // ntfy 转发 + val NTFY_ENABLED = booleanPreferencesKey("ntfy_enabled") + val NTFY_SERVER = stringPreferencesKey("ntfy_server") + val NTFY_TOPIC = stringPreferencesKey("ntfy_topic") + val NTFY_TOKEN = stringPreferencesKey("ntfy_token") // 应用更新 val UPDATE_CHECK_AUTO = booleanPreferencesKey("update_check_auto") val LAST_UPDATE_CHECK = longPreferencesKey("last_update_check") @@ -98,6 +103,12 @@ class SettingsStore( val smtpFrom = MutableStateFlow("") val emailTo = MutableStateFlow("") + // ntfy 转发 + val ntfyEnabled = MutableStateFlow(false) + val ntfyServer = MutableStateFlow("https://ntfy.sh") + val ntfyTopic = MutableStateFlow("") + val ntfyToken = MutableStateFlow("") + init { scope.launch { ds.data.collect { p -> @@ -134,6 +145,11 @@ class SettingsStore( smtpPassword.value = p[K.EMAIL_PASS] ?: "" smtpFrom.value = p[K.EMAIL_FROM] ?: "" emailTo.value = p[K.EMAIL_TO] ?: "" + // ntfy 转发 + ntfyEnabled.value = p[K.NTFY_ENABLED] ?: false + ntfyServer.value = p[K.NTFY_SERVER] ?: "https://ntfy.sh" + ntfyTopic.value = p[K.NTFY_TOPIC] ?: "" + ntfyToken.value = p[K.NTFY_TOKEN] ?: "" } } } @@ -205,6 +221,11 @@ class SettingsStore( suspend fun setSmtpFrom(v: String) { edit { it[K.EMAIL_FROM] = v }; smtpFrom.value = v } suspend fun setEmailTo(v: String) { edit { it[K.EMAIL_TO] = v }; emailTo.value = v } + suspend fun setNtfyEnabled(v: Boolean) { edit { it[K.NTFY_ENABLED] = v }; ntfyEnabled.value = v } + suspend fun setNtfyServer(v: String) { edit { it[K.NTFY_SERVER] = v }; ntfyServer.value = v } + suspend fun setNtfyTopic(v: String) { edit { it[K.NTFY_TOPIC] = v }; ntfyTopic.value = v } + suspend fun setNtfyToken(v: String) { edit { it[K.NTFY_TOKEN] = v }; ntfyToken.value = v } + suspend fun snapshot() = ds.data.first() companion object { 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 b6462cc..103707f 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 @@ -116,6 +116,15 @@ fun SettingsScreen(onOpenLog: () -> Unit) { 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) } @@ -175,6 +184,55 @@ fun SettingsScreen(onOpenLog: () -> Unit) { } } + item { + SectionCard("ntfy 转发", subtitle = "POST 推送到 ntfy 服务;与 Bark 并行,互不依赖") { + com.a2i.forwarder.ui.SwitchRow( + title = "启用 ntfy 转发", + subtitle = if (ntfyEnabled) "同时推送通知到 ntfy" else "当前关闭", + checked = ntfyEnabled, + ) { v -> app.appScope.launch { app.settings.setNtfyEnabled(v) } } + + if (ntfyEnabled) { + Spacer(Modifier.size(8.dp)) + OutlinedTextField( + value = ntfyServerInput, onValueChange = { ntfyServerInput = it }, + label = { Text("服务器地址") }, + supportingText = { Text("如 https://ntfy.sh;自建填自建地址") }, + modifier = Modifier.fillMaxWidth(), singleLine = true, + ) + Spacer(Modifier.size(4.dp)) + OutlinedTextField( + value = ntfyTopicInput, onValueChange = { ntfyTopicInput = it }, + label = { Text("Topic") }, + supportingText = { Text("推送目标 topic(必填)") }, + modifier = Modifier.fillMaxWidth(), singleLine = true, + ) + Spacer(Modifier.size(4.dp)) + OutlinedTextField( + value = ntfyTokenInput, onValueChange = { ntfyTokenInput = it }, + label = { Text("Token(可选)") }, + supportingText = { Text("私有 topic 需填 Bearer token;公开 topic 留空") }, + modifier = Modifier.fillMaxWidth(), singleLine = true, + ) + Spacer(Modifier.size(8.dp)) + Button( + onClick = { + app.appScope.launch { + app.settings.setNtfyServer(ntfyServerInput.trim()) + app.settings.setNtfyTopic(ntfyTopicInput.trim()) + app.settings.setNtfyToken(ntfyTokenInput.trim()) + } + ntfySaved = true + }, + modifier = Modifier.fillMaxWidth(), + ) { Text(if (ntfySaved) "✓ 已保存" else "保存 ntfy 设置") } + if (ntfySaved) { + LaunchedEffect(Unit) { kotlinx.coroutines.delay(2000); ntfySaved = false } + } + } + } + } + item { SectionCard("电邮转发", subtitle = "SMTPS 直发邮件(推荐端口 465);与 Bark 并行,互不依赖") { com.a2i.forwarder.ui.SwitchRow(