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:
@@ -13,8 +13,8 @@ android {
|
|||||||
applicationId = "com.a2i.forwarder"
|
applicationId = "com.a2i.forwarder"
|
||||||
minSdk = 34
|
minSdk = 34
|
||||||
targetSdk = 36
|
targetSdk = 36
|
||||||
versionCode = 12
|
versionCode = 13
|
||||||
versionName = "1.6.2"
|
versionName = "1.7.0"
|
||||||
}
|
}
|
||||||
|
|
||||||
signingConfigs {
|
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() }
|
val servers = app.settings.servers.value.filter { it.enabled && it.deviceKey.isNotBlank() }
|
||||||
if (servers.isEmpty()) {
|
if (servers.isEmpty()) {
|
||||||
EmailSender.trySend(app, msg.title ?: logBody, msg.body ?: "", scope)
|
EmailSender.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.pushToFirst(servers, msg)
|
||||||
@@ -144,6 +145,7 @@ class PhoneCallMonitor(private val context: Context) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
EmailSender.trySend(app, msg.title ?: logBody, msg.body ?: "", scope)
|
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.EmailSender
|
||||||
import com.a2i.forwarder.core.ForwardLog
|
import com.a2i.forwarder.core.ForwardLog
|
||||||
import com.a2i.forwarder.core.NotificationProcessor
|
import com.a2i.forwarder.core.NotificationProcessor
|
||||||
|
import com.a2i.forwarder.core.NtfySender
|
||||||
import com.a2i.forwarder.core.PendingPush
|
import com.a2i.forwarder.core.PendingPush
|
||||||
import com.a2i.forwarder.core.RetryWorker
|
import com.a2i.forwarder.core.RetryWorker
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
@@ -63,6 +64,7 @@ class NotifyListenerService : NotificationListenerService() {
|
|||||||
if (servers.isEmpty()) {
|
if (servers.isEmpty()) {
|
||||||
app.logStore.addLog(ForwardLog(now, sbn.packageName, d.appLabel, msg.title, msg.body, "failed", "未配置可用 Bark 服务器"))
|
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)
|
EmailSender.trySend(app, msg.title ?: d.appLabel, msg.body, scope)
|
||||||
|
NtfySender.trySend(app, msg.title ?: d.appLabel, msg.body, scope)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -77,5 +79,6 @@ class NotifyListenerService : NotificationListenerService() {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
EmailSender.trySend(app, msg.title ?: d.appLabel, msg.body, scope)
|
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_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_TOPIC = stringPreferencesKey("ntfy_topic")
|
||||||
|
val NTFY_TOKEN = stringPreferencesKey("ntfy_token")
|
||||||
// 应用更新
|
// 应用更新
|
||||||
val UPDATE_CHECK_AUTO = booleanPreferencesKey("update_check_auto")
|
val UPDATE_CHECK_AUTO = booleanPreferencesKey("update_check_auto")
|
||||||
val LAST_UPDATE_CHECK = longPreferencesKey("last_update_check")
|
val LAST_UPDATE_CHECK = longPreferencesKey("last_update_check")
|
||||||
@@ -98,6 +103,12 @@ class SettingsStore(
|
|||||||
val smtpFrom = MutableStateFlow("")
|
val smtpFrom = MutableStateFlow("")
|
||||||
val emailTo = 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 {
|
||||||
ds.data.collect { p ->
|
ds.data.collect { p ->
|
||||||
@@ -134,6 +145,11 @@ class SettingsStore(
|
|||||||
smtpPassword.value = p[K.EMAIL_PASS] ?: ""
|
smtpPassword.value = p[K.EMAIL_PASS] ?: ""
|
||||||
smtpFrom.value = p[K.EMAIL_FROM] ?: ""
|
smtpFrom.value = p[K.EMAIL_FROM] ?: ""
|
||||||
emailTo.value = p[K.EMAIL_TO] ?: ""
|
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 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 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()
|
suspend fun snapshot() = ds.data.first()
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
|
|||||||
@@ -116,6 +116,15 @@ fun SettingsScreen(onOpenLog: () -> Unit) {
|
|||||||
var smtpFromInput by remember(smtpFrom) { mutableStateOf(smtpFrom) }
|
var smtpFromInput by remember(smtpFrom) { mutableStateOf(smtpFrom) }
|
||||||
var emailToInput by remember(emailTo) { mutableStateOf(emailTo) }
|
var emailToInput by remember(emailTo) { mutableStateOf(emailTo) }
|
||||||
var emailSaved by remember { mutableStateOf(false) }
|
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 smsNumSaved by remember { mutableStateOf(false) }
|
||||||
var smsQuotaSaved 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 {
|
item {
|
||||||
SectionCard("电邮转发", subtitle = "SMTPS 直发邮件(推荐端口 465);与 Bark 并行,互不依赖") {
|
SectionCard("电邮转发", subtitle = "SMTPS 直发邮件(推荐端口 465);与 Bark 并行,互不依赖") {
|
||||||
com.a2i.forwarder.ui.SwitchRow(
|
com.a2i.forwarder.ui.SwitchRow(
|
||||||
|
|||||||
Reference in New Issue
Block a user