feat: ntfy forwarding support

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) <noreply@anthropic.com>
This commit is contained in:
2026-07-09 17:49:15 +08:00
parent 4536c82959
commit 81dfdcb0bd
6 changed files with 160 additions and 2 deletions
+2 -2
View File
@@ -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 {
@@ -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<Unit> = 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)
}
@@ -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)
}
}
}
@@ -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)
}
}
@@ -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 {
@@ -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(