feat: add Meow forwarding channel

This commit is contained in:
2026-07-10 10:29:30 +08:00
parent 336f49e4eb
commit 22ac5a31ff
9 changed files with 548 additions and 66 deletions
@@ -0,0 +1,145 @@
package com.a2i.forwarder.core
import com.a2i.forwarder.A2iApp
import com.a2i.forwarder.model.MeowServer
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.RequestBody.Companion.toRequestBody
import org.json.JSONObject
import java.io.IOException
import java.util.concurrent.TimeUnit
/**
* Meow for HarmonyOS push channel.
*
* Meow's public API shape is less standardized than Bark/ntfy in this app,
* so this sender accepts a user-provided endpoint and tries common JSON/GET forms.
*/
object MeowSender {
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,
deviceKey: String,
title: String,
body: String,
): Result<Unit> = withContext(Dispatchers.IO) {
runCatching {
val endpoint = server.trim().removeSuffix("/")
val key = deviceKey.trim()
require(endpoint.isNotBlank()) { "Meow 服务器地址未填" }
require(key.isNotBlank()) { "Meow Device Key 未填" }
val attempts = buildRequests(endpoint, key, title.take(200), body.take(4000))
var last: Throwable? = null
for (req in attempts) {
val r = runCatching { doPushWithRetry(req) }
if (r.isSuccess) return@runCatching
last = r.exceptionOrNull()
}
throw last ?: error("Meow 推送失败")
}
}
suspend fun pushToEnabled(configs: List<MeowServer>, title: String, body: String, stopOnFirst: Boolean): Result<Unit> = withContext(Dispatchers.IO) {
runCatching {
require(configs.any()) { "无 Meow 配置" }
var anyOk = false
var lastErr: Throwable? = null
for (c in configs) {
val r = send(c.server, c.deviceKey, title, body)
if (r.isSuccess) {
anyOk = true
if (stopOnFirst) return@runCatching
} else {
lastErr = r.exceptionOrNull()
}
}
if (!anyOk) throw lastErr ?: error("全部 Meow 推送失败")
}
}
fun trySend(app: A2iApp, title: String, body: String, scope: CoroutineScope) {
val s = app.settings
val configs = s.meowServers.value.filter { it.enabled && it.server.isNotBlank() && it.deviceKey.isNotBlank() }
if (configs.isEmpty()) return
scope.launch {
pushToEnabled(configs, title, body, s.meowStopOnFirst.value)
}
}
private fun buildRequests(endpoint: String, key: String, title: String, body: String): List<Request> {
val payload = JSONObject()
.put("deviceKey", key)
.put("device_key", key)
.put("key", key)
.put("title", title)
.put("content", body)
.put("message", body)
.put("body", body)
.toString()
val postTargets = listOf(endpoint, "$endpoint/push").distinct()
val postRequests = postTargets.map { url ->
Request.Builder()
.url(url)
.header("Authorization", "Bearer $key")
.header("X-Device-Key", key)
.post(payload.toRequestBody(JSON))
.build()
}
val getRequest = endpoint.toHttpUrlOrNull()?.newBuilder()
?.addQueryParameter("deviceKey", key)
?.addQueryParameter("key", key)
?.addQueryParameter("title", title)
?.addQueryParameter("content", body)
?.addQueryParameter("message", body)
?.build()
?.let { url ->
Request.Builder()
.url(url)
.header("Authorization", "Bearer $key")
.header("X-Device-Key", key)
.get()
.build()
}
return postRequests + listOfNotNull(getRequest)
}
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])
}
} catch (e: IllegalStateException) {
throw e
}
}
throw lastError ?: error("Meow 推送失败")
}
private val JSON = "application/json; charset=utf-8".toMediaType()
private const val MAX_ATTEMPTS = 3
private val BACKOFF_MS = longArrayOf(600L, 1200L)
}
@@ -127,6 +127,7 @@ class PhoneCallMonitor(private val context: Context) {
if (servers.isEmpty()) {
EmailSender.trySend(app, msg.title ?: logBody, msg.body ?: "", scope)
NtfySender.trySend(app, msg.title ?: logBody, msg.body ?: "", scope)
MeowSender.trySend(app, msg.title ?: logBody, msg.body ?: "", scope)
return@launch
}
val result = BarkSender.pushToEnabled(servers, msg, app.settings.barkStopOnFirst.value)
@@ -146,6 +147,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)
MeowSender.trySend(app, msg.title ?: logBody, msg.body ?: "", scope)
}
}
}
@@ -142,6 +142,9 @@ class UpdateChecker(private val context: Context) {
// ntfy
NtfySender.trySend(app, title, body, scope)
// Meow
MeowSender.trySend(app, title, body, scope)
// 电邮
EmailSender.trySend(app, title, body, scope)
}
@@ -24,6 +24,15 @@ data class NtfyServer(
val enabled: Boolean = true,
)
@Serializable
data class MeowServer(
val id: String = UUID.randomUUID().toString(),
val name: String,
val server: String = "",
val deviceKey: String = "",
val enabled: Boolean = true,
)
@Serializable
data class EmailServer(
val id: String = UUID.randomUUID().toString(),
@@ -6,6 +6,7 @@ import com.a2i.forwarder.A2iApp
import com.a2i.forwarder.core.BarkSender
import com.a2i.forwarder.core.EmailSender
import com.a2i.forwarder.core.ForwardLog
import com.a2i.forwarder.core.MeowSender
import com.a2i.forwarder.core.NotificationProcessor
import com.a2i.forwarder.core.NtfySender
import com.a2i.forwarder.core.PendingPush
@@ -65,6 +66,7 @@ class NotifyListenerService : NotificationListenerService() {
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)
MeowSender.trySend(app, msg.title ?: d.appLabel, msg.body, scope)
return
}
@@ -80,5 +82,6 @@ class NotifyListenerService : NotificationListenerService() {
}
EmailSender.trySend(app, msg.title ?: d.appLabel, msg.body, scope)
NtfySender.trySend(app, msg.title ?: d.appLabel, msg.body, scope)
MeowSender.trySend(app, msg.title ?: d.appLabel, msg.body, scope)
}
}
@@ -11,6 +11,7 @@ import androidx.datastore.preferences.core.stringPreferencesKey
import androidx.datastore.preferences.preferencesDataStore
import com.a2i.forwarder.model.BarkServer
import com.a2i.forwarder.model.EmailServer
import com.a2i.forwarder.model.MeowServer
import com.a2i.forwarder.model.NtfyServer
import com.a2i.forwarder.model.OFFICIAL_BARK_SERVER
import com.a2i.forwarder.model.SmsTarget
@@ -44,6 +45,7 @@ class SettingsStore(
// 各通道「发通一条止 / 全发」开关
val BARK_STOP_FIRST = booleanPreferencesKey("bark_stop_first")
val NTFY_STOP_FIRST = booleanPreferencesKey("ntfy_stop_first")
val MEOW_STOP_FIRST = booleanPreferencesKey("meow_stop_first")
val EMAIL_STOP_FIRST = booleanPreferencesKey("email_stop_first")
val SMS_STOP_FIRST = booleanPreferencesKey("sms_stop_first")
// 短信兜底(目标号已列表化;运营商/余额/限频保持全局)
@@ -58,6 +60,7 @@ class SettingsStore(
val EMAIL_ENABLED = booleanPreferencesKey("email_enabled")
val EMAIL_SERVERS = stringPreferencesKey("email_servers")
val NTFY_SERVERS = stringPreferencesKey("ntfy_servers")
val MEOW_SERVERS = stringPreferencesKey("meow_servers")
// 应用更新
val UPDATE_CHECK_AUTO = booleanPreferencesKey("update_check_auto")
val LAST_UPDATE_CHECK = longPreferencesKey("last_update_check")
@@ -87,6 +90,9 @@ class SettingsStore(
private val _ntfyServers = MutableStateFlow<List<NtfyServer>>(emptyList())
val ntfyServers = _ntfyServers.asStateFlow()
private val _meowServers = MutableStateFlow<List<MeowServer>>(emptyList())
val meowServers = _meowServers.asStateFlow()
private val _emailServers = MutableStateFlow<List<EmailServer>>(emptyList())
val emailServers = _emailServers.asStateFlow()
@@ -104,6 +110,7 @@ class SettingsStore(
// 各通道「发通一条止 / 全发」
val barkStopOnFirst = MutableStateFlow(true)
val ntfyStopOnFirst = MutableStateFlow(false)
val meowStopOnFirst = MutableStateFlow(false)
val emailStopOnFirst = MutableStateFlow(false)
val smsStopOnFirst = MutableStateFlow(false)
@@ -142,6 +149,7 @@ class SettingsStore(
// 各通道 stopOnFirst
barkStopOnFirst.value = p[K.BARK_STOP_FIRST] ?: true
ntfyStopOnFirst.value = p[K.NTFY_STOP_FIRST] ?: false
meowStopOnFirst.value = p[K.MEOW_STOP_FIRST] ?: false
emailStopOnFirst.value = p[K.EMAIL_STOP_FIRST] ?: false
smsStopOnFirst.value = p[K.SMS_STOP_FIRST] ?: false
// 短信兜底全局
@@ -161,6 +169,9 @@ class SettingsStore(
_ntfyServers.value = p[K.NTFY_SERVERS]
?.let { runCatching { json.decodeFromString<List<NtfyServer>>(it) }.getOrNull() }
?: emptyList()
_meowServers.value = p[K.MEOW_SERVERS]
?.let { runCatching { json.decodeFromString<List<MeowServer>>(it) }.getOrNull() }
?: emptyList()
_emailServers.value = p[K.EMAIL_SERVERS]
?.let { runCatching { json.decodeFromString<List<EmailServer>>(it) }.getOrNull() }
?: emptyList()
@@ -261,6 +272,22 @@ class SettingsStore(
}
suspend fun setNtfyEnabled(id: String, enabled: Boolean) = saveNtfyServers(_ntfyServers.value.map { if (it.id == id) it.copy(enabled = enabled) else it })
// ---- Meow 服务器 CRUD ----
private suspend fun saveMeowServers(list: List<MeowServer>) {
edit { it[K.MEOW_SERVERS] = json.encodeToString(list) }
_meowServers.value = list
}
suspend fun addMeow(s: MeowServer) { saveMeowServers(_meowServers.value + s) }
suspend fun updateMeow(s: MeowServer) = saveMeowServers(_meowServers.value.map { if (it.id == s.id) s else it })
suspend fun deleteMeow(id: String) = saveMeowServers(_meowServers.value.filterNot { it.id == id })
suspend fun moveMeow(id: String, up: Boolean) {
val list = _meowServers.value.toMutableList()
val i = list.indexOfFirst { it.id == id }
if (i in 1..list.lastIndex && up) { java.util.Collections.swap(list, i, i - 1); saveMeowServers(list) }
else if (i in 0 until list.lastIndex && !up) { java.util.Collections.swap(list, i, i + 1); saveMeowServers(list) }
}
suspend fun setMeowEnabled(id: String, enabled: Boolean) = saveMeowServers(_meowServers.value.map { if (it.id == id) it.copy(enabled = enabled) else it })
// ---- 电邮服务器 CRUD ----
private suspend fun saveEmailServers(list: List<EmailServer>) {
edit { it[K.EMAIL_SERVERS] = json.encodeToString(list) }
@@ -306,6 +333,7 @@ class SettingsStore(
suspend fun setBarkStopOnFirst(v: Boolean) { edit { it[K.BARK_STOP_FIRST] = v }; barkStopOnFirst.value = v }
suspend fun setNtfyStopOnFirst(v: Boolean) { edit { it[K.NTFY_STOP_FIRST] = v }; ntfyStopOnFirst.value = v }
suspend fun setMeowStopOnFirst(v: Boolean) { edit { it[K.MEOW_STOP_FIRST] = v }; meowStopOnFirst.value = v }
suspend fun setEmailStopOnFirst(v: Boolean) { edit { it[K.EMAIL_STOP_FIRST] = v }; emailStopOnFirst.value = v }
suspend fun setSmsStopOnFirst(v: Boolean) { edit { it[K.SMS_STOP_FIRST] = v }; smsStopOnFirst.value = v }
@@ -6,6 +6,7 @@ import android.net.Uri
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.clickable
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.foundation.layout.Arrangement
@@ -43,7 +44,6 @@ import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Surface
import androidx.compose.material3.Switch
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
@@ -66,10 +66,12 @@ import com.a2i.forwarder.A2iApp
import com.a2i.forwarder.core.BarkClient
import com.a2i.forwarder.core.EmailSender
import com.a2i.forwarder.core.IconExporter
import com.a2i.forwarder.core.MeowSender
import com.a2i.forwarder.core.NtfySender
import com.a2i.forwarder.model.BarkMessage
import com.a2i.forwarder.model.BarkServer
import com.a2i.forwarder.model.EmailServer
import com.a2i.forwarder.model.MeowServer
import com.a2i.forwarder.model.NtfyServer
import com.a2i.forwarder.model.OFFICIAL_BARK_SERVER
import com.a2i.forwarder.model.SmsTarget
@@ -99,10 +101,12 @@ fun SettingsScreen(onOpenLog: () -> Unit) {
val app = A2iApp.instance
val servers by app.settings.servers.collectAsState()
val ntfyServers by app.settings.ntfyServers.collectAsState()
val meowServers by app.settings.meowServers.collectAsState()
val emailServers by app.settings.emailServers.collectAsState()
val smsTargets by app.settings.smsTargets.collectAsState()
val barkStopFirst by app.settings.barkStopOnFirst.collectAsState()
val ntfyStopFirst by app.settings.ntfyStopOnFirst.collectAsState()
val meowStopFirst by app.settings.meowStopOnFirst.collectAsState()
val emailStopFirst by app.settings.emailStopOnFirst.collectAsState()
val smsStopFirst by app.settings.smsStopOnFirst.collectAsState()
val emailEnabled by app.settings.emailForwardingEnabled.collectAsState()
@@ -141,6 +145,9 @@ fun SettingsScreen(onOpenLog: () -> Unit) {
var showAddNtfy by remember { mutableStateOf(false) }
var showNtfyHelp by remember { mutableStateOf(false) }
var editingNtfy by remember { mutableStateOf<NtfyServer?>(null) }
var showAddMeow by remember { mutableStateOf(false) }
var showMeowHelp by remember { mutableStateOf(false) }
var editingMeow by remember { mutableStateOf<MeowServer?>(null) }
var showAddEmail by remember { mutableStateOf(false) }
var editingEmail by remember { mutableStateOf<EmailServer?>(null) }
var showAddSms by remember { mutableStateOf(false) }
@@ -154,21 +161,18 @@ fun SettingsScreen(onOpenLog: () -> Unit) {
// ===== Bark 服务器 =====
item {
SectionCard(
"Bark 服务器",
subtitle = "勾选的服务器参与转发;开关切换容错/广播",
headerAction = {
Row(verticalAlignment = Alignment.CenterVertically) {
IconButton(onClick = { showBarkHelp = true }) {
Icon(Icons.Filled.Info, "Bark 配置帮助", tint = Color(0xFFFF9800))
}
CompactStopOnFirstSwitch(
checked = barkStopFirst,
onChange = { v -> app.appScope.launch { app.settings.setBarkStopOnFirst(v) } },
)
}
},
) {
SectionCard {
ChannelSectionHeader(
title = "Bark 服务器",
summary = "iPhone / iPad 推送目标",
mark = "B",
markColor = Color(0xFFFF9800),
platforms = listOf("iOS"),
stopFirst = barkStopFirst,
onStopFirstChange = { v -> app.appScope.launch { app.settings.setBarkStopOnFirst(v) } },
onHelpClick = { showBarkHelp = true },
helpContentDescription = "Bark 配置帮助",
)
if (servers.isNotEmpty()) {
Spacer(Modifier.size(8.dp))
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
@@ -189,21 +193,18 @@ fun SettingsScreen(onOpenLog: () -> Unit) {
// ===== ntfy =====
item {
SectionCard(
"ntfy 转发",
subtitle = "勾选的 topic 参与转发;开关切换容错/广播",
headerAction = {
Row(verticalAlignment = Alignment.CenterVertically) {
IconButton(onClick = { showNtfyHelp = true }) {
Icon(Icons.Filled.Info, "ntfy 配置帮助", tint = Color(0xFFFF9800))
}
CompactStopOnFirstSwitch(
checked = ntfyStopFirst,
onChange = { v -> app.appScope.launch { app.settings.setNtfyStopOnFirst(v) } },
)
}
},
) {
SectionCard {
ChannelSectionHeader(
title = "ntfy 转发",
summary = "Android / iOS topic 推送",
mark = "n",
markColor = Color(0xFF43A047),
platforms = listOf("Android", "iOS"),
stopFirst = ntfyStopFirst,
onStopFirstChange = { v -> app.appScope.launch { app.settings.setNtfyStopOnFirst(v) } },
onHelpClick = { showNtfyHelp = true },
helpContentDescription = "ntfy 配置帮助",
)
if (ntfyServers.isNotEmpty()) {
Spacer(Modifier.size(8.dp))
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
@@ -225,18 +226,53 @@ fun SettingsScreen(onOpenLog: () -> Unit) {
}
}
// ===== Meow =====
item {
SectionCard {
ChannelSectionHeader(
title = "Meow 转发",
summary = "鸿蒙手机推送目标",
mark = "M",
markColor = Color(0xFF7E57C2),
platforms = listOf("HarmonyOS"),
stopFirst = meowStopFirst,
onStopFirstChange = { v -> app.appScope.launch { app.settings.setMeowStopOnFirst(v) } },
onHelpClick = { showMeowHelp = true },
helpContentDescription = "Meow 配置帮助",
)
if (meowServers.isNotEmpty()) {
Spacer(Modifier.size(8.dp))
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
meowServers.forEachIndexed { i, s ->
MeowServerRow(
server = s,
isFirst = i == 0,
isLast = i == meowServers.lastIndex,
onToggle = { v -> app.appScope.launch { app.settings.setMeowEnabled(s.id, v) } },
onEdit = { editingMeow = s },
onDelete = { app.appScope.launch { app.settings.deleteMeow(s.id) } },
onMoveUp = { app.appScope.launch { app.settings.moveMeow(s.id, true) } },
onMoveDown = { app.appScope.launch { app.settings.moveMeow(s.id, false) } },
)
}
}
}
AddButton { showAddMeow = true }
}
}
// ===== 电邮 =====
item {
SectionCard(
"电邮转发",
subtitle = "勾选的邮箱参与转发;开关切换容错/广播",
headerAction = {
CompactStopOnFirstSwitch(
checked = emailStopFirst,
onChange = { v -> app.appScope.launch { app.settings.setEmailStopOnFirst(v) } },
)
},
) {
SectionCard {
ChannelSectionHeader(
title = "电邮转发",
summary = "SMTP 邮箱推送目标",
mark = "@",
markColor = Color(0xFF1976D2),
platforms = listOf("Email"),
stopFirst = emailStopFirst,
onStopFirstChange = { v -> app.appScope.launch { app.settings.setEmailStopOnFirst(v) } },
)
if (emailServers.isNotEmpty()) {
Spacer(Modifier.size(8.dp))
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
@@ -260,16 +296,16 @@ fun SettingsScreen(onOpenLog: () -> Unit) {
// ===== 短信兜底 =====
item {
SectionCard(
"断网短信兜底",
subtitle = "勾选的目标号参与转发;开关切换容错/广播",
headerAction = {
CompactStopOnFirstSwitch(
checked = smsStopFirst,
onChange = { v -> app.appScope.launch { app.settings.setSmsStopOnFirst(v) } },
)
},
) {
SectionCard {
ChannelSectionHeader(
title = "断网短信兜底",
summary = "无网络时发送关键通知",
mark = "S",
markColor = Color(0xFF00897B),
platforms = listOf("SMS"),
stopFirst = smsStopFirst,
onStopFirstChange = { v -> app.appScope.launch { app.settings.setSmsStopOnFirst(v) } },
)
if (!hasSmsPerm) {
Spacer(Modifier.size(4.dp))
OutlinedButton(onClick = { smsPermLauncher.launch(android.Manifest.permission.SEND_SMS) }, modifier = Modifier.fillMaxWidth()) { Text("授予短信权限") }
@@ -357,10 +393,13 @@ fun SettingsScreen(onOpenLog: () -> Unit) {
// 弹窗
if (showBarkHelp) BarkHelpDialog(onDismiss = { showBarkHelp = false })
if (showNtfyHelp) NtfyHelpDialog(onDismiss = { showNtfyHelp = false })
if (showMeowHelp) MeowHelpDialog(onDismiss = { showMeowHelp = 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 })
editingNtfy?.let { srv -> NtfyConfigDialog(initial = srv, onDismiss = { editingNtfy = null }, onConfirm = { n, sv, t, tk -> app.appScope.launch { app.settings.updateNtfy(srv.copy(name = n, server = sv, topic = t, token = tk)) }; editingNtfy = null }) }
if (showAddMeow) MeowConfigDialog(onDismiss = { showAddMeow = false }, onConfirm = { n, sv, k -> app.appScope.launch { app.settings.addMeow(MeowServer(name = n, server = sv, deviceKey = k)) }; showAddMeow = false })
editingMeow?.let { srv -> MeowConfigDialog(initial = srv, onDismiss = { editingMeow = null }, onConfirm = { n, sv, k -> app.appScope.launch { app.settings.updateMeow(srv.copy(name = n, server = sv, deviceKey = k)) }; editingMeow = null }) }
if (showAddEmail) EmailConfigDialog(onDismiss = { showAddEmail = false }, onConfirm = { c -> app.appScope.launch { app.settings.addEmail(c) }; showAddEmail = false })
editingEmail?.let { srv -> EmailConfigDialog(initial = srv, onDismiss = { editingEmail = null }, onConfirm = { c -> app.appScope.launch { app.settings.updateEmail(c.copy(id = srv.id)) }; editingEmail = null }) }
if (showAddSms) SmsConfigDialog(onDismiss = { showAddSms = false }, onConfirm = { n, num -> app.appScope.launch { app.settings.addSmsTarget(SmsTarget(name = n, number = num)) }; showAddSms = false })
@@ -377,23 +416,174 @@ private fun AddButton(onClick: () -> Unit) {
}
@Composable
private fun CompactStopOnFirstSwitch(
checked: Boolean,
onChange: (Boolean) -> Unit,
private fun ChannelSectionHeader(
title: String,
summary: String,
mark: String,
markColor: Color,
platforms: List<String>,
stopFirst: Boolean,
onStopFirstChange: (Boolean) -> Unit,
onHelpClick: (() -> Unit)? = null,
helpContentDescription: String? = null,
) {
Row(
modifier = Modifier.padding(start = 4.dp),
verticalAlignment = Alignment.CenterVertically,
Column(
modifier = Modifier.fillMaxWidth().padding(bottom = 4.dp),
verticalArrangement = Arrangement.spacedBy(10.dp),
) {
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
) {
ChannelMark(mark = mark, tint = markColor)
Spacer(Modifier.width(10.dp))
Row(
modifier = Modifier.weight(1f),
verticalAlignment = Alignment.CenterVertically,
) {
Text(
title,
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f, fill = false),
)
if (platforms.isNotEmpty()) {
Spacer(Modifier.width(8.dp))
Row(
horizontalArrangement = Arrangement.spacedBy(5.dp),
verticalAlignment = Alignment.CenterVertically,
) {
platforms.forEach { PlatformBadge(it) }
}
}
}
if (onHelpClick != null) {
Spacer(Modifier.width(8.dp))
IconButton(onClick = onHelpClick, modifier = Modifier.size(40.dp)) {
Icon(
Icons.Filled.Info,
helpContentDescription,
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
) {
Text(
summary,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f),
)
Spacer(Modifier.width(8.dp))
DeliveryModeControl(
stopFirst = stopFirst,
onStopFirstChange = onStopFirstChange,
)
}
}
}
@Composable
private fun ChannelMark(mark: String, tint: Color) {
Surface(
modifier = Modifier.size(36.dp),
shape = RoundedCornerShape(10.dp),
color = tint.copy(alpha = 0.14f),
border = BorderStroke(1.dp, tint.copy(alpha = 0.28f)),
) {
Row(
modifier = Modifier.fillMaxSize(),
horizontalArrangement = Arrangement.Center,
verticalAlignment = Alignment.CenterVertically,
) {
Text(
mark,
style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.Bold,
color = tint,
maxLines = 1,
)
}
}
}
@Composable
private fun PlatformBadge(label: String) {
Surface(
shape = RoundedCornerShape(999.dp),
color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.72f),
border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant),
) {
Text(
"发通一条就停",
style = MaterialTheme.typography.labelMedium,
fontWeight = FontWeight.Medium,
label,
modifier = Modifier.padding(horizontal = 7.dp, vertical = 3.dp),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1,
)
}
}
@Composable
private fun DeliveryModeControl(
stopFirst: Boolean,
onStopFirstChange: (Boolean) -> Unit,
) {
Row(verticalAlignment = Alignment.CenterVertically) {
Text(
"模式",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1,
)
Spacer(Modifier.width(6.dp))
Switch(checked = checked, onCheckedChange = onChange)
DeliveryModeOption(
label = "容错",
selected = stopFirst,
shape = RoundedCornerShape(topStart = 8.dp, topEnd = 0.dp, bottomEnd = 0.dp, bottomStart = 8.dp),
onClick = { onStopFirstChange(true) },
)
DeliveryModeOption(
label = "广播",
selected = !stopFirst,
shape = RoundedCornerShape(topStart = 0.dp, topEnd = 8.dp, bottomEnd = 8.dp, bottomStart = 0.dp),
onClick = { onStopFirstChange(false) },
)
}
}
@Composable
private fun DeliveryModeOption(
label: String,
selected: Boolean,
shape: RoundedCornerShape,
onClick: () -> Unit,
) {
Surface(
modifier = Modifier.clickable(onClick = onClick),
shape = shape,
color = if (selected) MaterialTheme.colorScheme.primaryContainer else MaterialTheme.colorScheme.surface,
border = BorderStroke(
1.dp,
if (selected) MaterialTheme.colorScheme.primary.copy(alpha = 0.50f) else MaterialTheme.colorScheme.outlineVariant,
),
) {
Text(
label,
modifier = Modifier.padding(horizontal = 9.dp, vertical = 5.dp),
style = MaterialTheme.typography.labelMedium,
fontWeight = if (selected) FontWeight.SemiBold else FontWeight.Medium,
color = if (selected) MaterialTheme.colorScheme.onPrimaryContainer else MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1,
)
}
}
@@ -500,6 +690,45 @@ private fun NtfyHelpDialog(onDismiss: () -> Unit) {
)
}
@Composable
private fun MeowHelpDialog(onDismiss: () -> Unit) {
AlertDialog(
onDismissRequest = onDismiss,
icon = { Icon(Icons.Filled.Info, null, tint = Color(0xFFFF9800)) },
title = { Text("Meow 配置帮助") },
text = {
Column(
modifier = Modifier
.heightIn(max = 440.dp)
.verticalScroll(rememberScrollState()),
verticalArrangement = Arrangement.spacedBy(10.dp),
) {
Text(
"Meow 用来把 a2i 处理后的 Android 通知推送到鸿蒙手机。a2i 只负责按你填写的 Meow 接口地址和 Device Key 发出 HTTP 请求。",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
HelpStep("1", "在鸿蒙手机上安装并打开 Meow,先确认 Meow 自身可以正常接收推送。")
HelpStep("2", "在 Meow 里找到推送接口、服务器地址或 API 地址,同时复制对应的 Device Key / Token。")
HelpStep("3", "回到 a2i,点击本区域底部的「添加」。名称按你的鸿蒙设备填写,方便以后区分。")
HelpStep("4", "服务器地址可以填 Meow 给出的完整 API 地址;如果 Meow 只给了服务根地址,a2i 也会尝试常见的 /push 路径。")
HelpStep("5", "Device Key 填 Meow 给你的设备 Key 或 Token,不要填写鸿蒙手机锁屏密码、华为账号密码等敏感账号密码。")
HelpStep("6", "保存后点设备行里的发送按钮测试;鸿蒙手机收到「a2i 测试」就说明 Meow 通道已通。")
HelpStep("7", "后续只要这一行左侧保持勾选,它就会参与转发;取消勾选就是停用这个鸿蒙设备。")
Text(
"提示:如果测试失败,优先检查 Meow 里的接口地址是否完整、Key 是否复制完整,以及鸿蒙手机是否允许 Meow 通知。",
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,
@@ -568,6 +797,46 @@ private fun ConfigRow(
}
// ===== ntfy / Bark Row + Dialog =====
@Composable
private fun MeowServerRow(
server: MeowServer, 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 { "鸿蒙手机" }, 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 = testMeow(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.ifBlank { "服务器地址未填" }, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, maxLines = 2, overflow = TextOverflow.Ellipsis)
Text("device key: " + if (server.deviceKey.isBlank()) "未填" else "已填(${server.deviceKey.length} 位)", style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
}
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 NtfyServerRow(
server: NtfyServer, isFirst: Boolean, isLast: Boolean,
@@ -725,6 +994,20 @@ private fun NtfyConfigDialog(initial: NtfyServer? = null, onDismiss: () -> Unit,
}
}
@Composable
private fun MeowConfigDialog(initial: MeowServer? = null, onDismiss: () -> Unit, onConfirm: (String, String, String) -> Unit) {
var name by remember { mutableStateOf(initial?.name ?: "") }
var server by remember { mutableStateOf(initial?.server ?: "") }
var key by remember { mutableStateOf(initial?.deviceKey ?: "") }
ConfigDialogSkeleton(title = if (initial == null) "添加 Meow" else "编辑 Meow", onDismiss = onDismiss, onConfirm = { onConfirm(name.trim(), server.trim().removeSuffix("/"), key.trim()) }) {
OutlinedTextField(name, { name = it }, label = { Text("名称") }, singleLine = true, modifier = Modifier.fillMaxWidth())
Spacer(Modifier.size(8.dp))
OutlinedTextField(server, { server = it }, label = { Text("服务器 / API 地址") }, singleLine = true, modifier = Modifier.fillMaxWidth(), supportingText = { Text("填写 Meow 提供的完整 API 地址或服务根地址") })
Spacer(Modifier.size(8.dp))
OutlinedTextField(key, { key = it }, label = { Text("Device Key / Token") }, singleLine = true, modifier = Modifier.fillMaxWidth())
}
}
@Composable
private fun EmailConfigDialog(initial: EmailServer? = null, onDismiss: () -> Unit, onConfirm: (EmailServer) -> Unit) {
var name by remember { mutableStateOf(initial?.name ?: "") }
@@ -787,3 +1070,10 @@ private suspend fun testNtfy(server: NtfyServer): String {
val r = NtfySender.send(server.server, server.topic, server.token, "a2i 测试", "如果你在 ntfy 看到这条推送,说明配置成功")
return if (r.isSuccess) "已发送,请查看 ntfy 接收端" else "发送失败:${r.exceptionOrNull()?.message}"
}
private suspend fun testMeow(server: MeowServer): String {
if (server.server.isBlank()) return "未填写服务器地址"
if (server.deviceKey.isBlank()) return "未填写 Device Key"
val r = MeowSender.send(server.server, server.deviceKey, "a2i 测试", "如果你在鸿蒙 Meow 看到这条推送,说明配置成功")
return if (r.isSuccess) "已发送,请查看鸿蒙 Meow" else "发送失败:${r.exceptionOrNull()?.message}"
}