diff --git a/README.md b/README.md index 0bd8093..9df6202 100644 --- a/README.md +++ b/README.md @@ -1,13 +1,13 @@ # A2i ·安到果(按倒过[呲牙]) -把安卓手机上的通知(短信、验证码、电话“有电话进来这个通知,不是电话呼叫转移”、微信、QQ、Telegram 等)实时转发到 iOS,通过 [Bark](https://github.com/Finb/Bark) 推送到 iPhone / iPad。在 iOS 上点击通知,还能一键跳转到对应的 App。 +把安卓手机上的通知(短信、验证码、电话“有电话进来这个通知,不是电话呼叫转移”、微信、QQ、Telegram 等)实时转发到 iOS / Android / 鸿蒙等接收端。iOS 可通过 [Bark](https://github.com/Finb/Bark) 推送到 iPhone / iPad;Android 可用 ntfy;鸿蒙可用 Meow。在 iOS 上点击通知,还能一键跳转到对应的 App。 适用于把安卓备机的消息同步到 iOS 设备,不漏验证码、不漏重要聊天。 ## 主要功能 - **系统级通知监听**:接入 Android 通知监听服务,覆盖所有发出通知的 App。 -- **Bark 推送**:支持官方 Bark 服务和自建 Bark Server,可保存多个服务器并随时切换。 +- **多通道推送**:支持 Bark、ntfy、Meow、电邮和短信兜底;每个通道可保存多个目标,勾选参与转发,并支持“发通一条就停”或全部广播。 - **验证码自动提取**:自动识别通知中的验证码,收到即复制,也可点击复制。 - **通知优化**: - 广告关键词过滤(内置 + 自定义) @@ -40,6 +40,8 @@ - 服务器地址:`https://api.day.app`(自建则填自建地址) - Device Key:Bark 地址中的那段 Key +如果接收端是 Android 或鸿蒙手机,也可以在「设置」里配置 ntfy 或 Meow;对应区块右上角有帮助按钮,按弹窗步骤填写服务器地址、Topic / Device Key 后点测试发送。 + ### 3. 授权通知监听 打开 a2i,首页会提示「需要授权通知监听」。点击「前往系统授权」,在系统的通知访问设置中开启 a2i。 @@ -107,7 +109,7 @@ a2i 默认用 Bark 官方服务器(`https://api.day.app`),免费够用。 - Kotlin + Jetpack Compose(Material 3) - AndroidX DataStore(持久化设置与日志) -- OkHttp(Bark 推送) +- OkHttp(Bark / ntfy / Meow / 更新检查) - WorkManager(失败重试) - Navigation Compose(单 Activity 多页面) - 最低 Android 14(API 34),目标 Android 16(API 36) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 8ce30c9..27caee4 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 = 25 - versionName = "1.8.8" + versionCode = 26 + versionName = "1.8.9" } signingConfigs { diff --git a/app/src/main/java/com/a2i/forwarder/core/MeowSender.kt b/app/src/main/java/com/a2i/forwarder/core/MeowSender.kt new file mode 100644 index 0000000..8f3b713 --- /dev/null +++ b/app/src/main/java/com/a2i/forwarder/core/MeowSender.kt @@ -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 = 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, title: String, body: String, stopOnFirst: Boolean): Result = 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 { + 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) +} 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 df8e29e..f2477f4 100644 --- a/app/src/main/java/com/a2i/forwarder/core/PhoneCallMonitor.kt +++ b/app/src/main/java/com/a2i/forwarder/core/PhoneCallMonitor.kt @@ -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) } } } diff --git a/app/src/main/java/com/a2i/forwarder/core/UpdateChecker.kt b/app/src/main/java/com/a2i/forwarder/core/UpdateChecker.kt index 368b24e..7bf254f 100644 --- a/app/src/main/java/com/a2i/forwarder/core/UpdateChecker.kt +++ b/app/src/main/java/com/a2i/forwarder/core/UpdateChecker.kt @@ -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) } diff --git a/app/src/main/java/com/a2i/forwarder/model/Models.kt b/app/src/main/java/com/a2i/forwarder/model/Models.kt index 519cee9..1017177 100644 --- a/app/src/main/java/com/a2i/forwarder/model/Models.kt +++ b/app/src/main/java/com/a2i/forwarder/model/Models.kt @@ -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(), 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 2f47c4a..af39d0c 100644 --- a/app/src/main/java/com/a2i/forwarder/service/NotifyListenerService.kt +++ b/app/src/main/java/com/a2i/forwarder/service/NotifyListenerService.kt @@ -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) } } 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 071da46..a14b833 100644 --- a/app/src/main/java/com/a2i/forwarder/store/SettingsStore.kt +++ b/app/src/main/java/com/a2i/forwarder/store/SettingsStore.kt @@ -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>(emptyList()) val ntfyServers = _ntfyServers.asStateFlow() + private val _meowServers = MutableStateFlow>(emptyList()) + val meowServers = _meowServers.asStateFlow() + private val _emailServers = MutableStateFlow>(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>(it) }.getOrNull() } ?: emptyList() + _meowServers.value = p[K.MEOW_SERVERS] + ?.let { runCatching { json.decodeFromString>(it) }.getOrNull() } + ?: emptyList() _emailServers.value = p[K.EMAIL_SERVERS] ?.let { runCatching { json.decodeFromString>(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) { + 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) { 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 } 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 2b9b37f..9369f7f 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 @@ -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(null) } + var showAddMeow by remember { mutableStateOf(false) } + var showMeowHelp by remember { mutableStateOf(false) } + var editingMeow by remember { mutableStateOf(null) } var showAddEmail by remember { mutableStateOf(false) } var editingEmail by remember { mutableStateOf(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, + 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(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}" +}