|
|
|
|
@ -5,12 +5,9 @@ import android.app.Application
|
|
|
|
|
import android.app.NotificationChannel
|
|
|
|
|
import android.app.NotificationManager
|
|
|
|
|
import android.app.PendingIntent
|
|
|
|
|
import android.content.ContentResolver
|
|
|
|
|
import android.content.Context
|
|
|
|
|
import android.content.Intent
|
|
|
|
|
import android.content.pm.PackageManager
|
|
|
|
|
import android.media.AudioAttributes
|
|
|
|
|
import android.net.Uri
|
|
|
|
|
import android.os.Build
|
|
|
|
|
import androidx.core.app.NotificationCompat
|
|
|
|
|
import androidx.core.app.NotificationManagerCompat
|
|
|
|
|
@ -51,10 +48,10 @@ import kotlinx.serialization.json.JsonElement
|
|
|
|
|
import kotlinx.serialization.json.JsonObject
|
|
|
|
|
import kotlinx.serialization.json.decodeFromJsonElement
|
|
|
|
|
import kotlinx.serialization.json.encodeToJsonElement
|
|
|
|
|
import com.onlinemsg.client.util.LanguageManager
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 单例管理类,负责整个聊天会话的生命周期、网络连接、消息收发、状态维护和持久化。
|
|
|
|
|
* 所有公开方法均通过 ViewModel 代理调用,内部使用协程处理异步操作。
|
|
|
|
|
*/
|
|
|
|
|
object ChatSessionManager {
|
|
|
|
|
|
|
|
|
|
@ -83,29 +80,28 @@ object ChatSessionManager {
|
|
|
|
|
private var identity: RsaCryptoManager.Identity? = null
|
|
|
|
|
|
|
|
|
|
// 连接相关内部状态
|
|
|
|
|
private var manualClose = false
|
|
|
|
|
private var fallbackTried = false
|
|
|
|
|
private var connectedUrl = ""
|
|
|
|
|
private var serverPublicKey = ""
|
|
|
|
|
private var helloTimeoutJob: Job? = null
|
|
|
|
|
private var authTimeoutJob: Job? = null
|
|
|
|
|
private var reconnectJob: Job? = null
|
|
|
|
|
private var reconnectAttempt: Int = 0
|
|
|
|
|
private val systemMessageExpiryJobs: MutableMap<String, Job> = mutableMapOf()
|
|
|
|
|
private var manualClose = false // 是否为手动断开
|
|
|
|
|
private var fallbackTried = false // 是否已尝试切换 ws/wss
|
|
|
|
|
private var connectedUrl = "" // 当前连接的服务器地址
|
|
|
|
|
private var serverPublicKey = "" // 服务端公钥(握手后获得)
|
|
|
|
|
private var helloTimeoutJob: Job? = null // 握手超时任务
|
|
|
|
|
private var authTimeoutJob: Job? = null // 认证超时任务
|
|
|
|
|
private var reconnectJob: Job? = null // 自动重连任务
|
|
|
|
|
private var reconnectAttempt: Int = 0 // 当前重连尝试次数
|
|
|
|
|
private val systemMessageExpiryJobs: MutableMap<String, Job> = mutableMapOf() // 系统消息自动过期任务
|
|
|
|
|
private var autoReconnectTriggered = false
|
|
|
|
|
@Volatile
|
|
|
|
|
private var keepAliveRequested = false
|
|
|
|
|
private var keepAliveRequested = false // 是否应保活(前台服务标志)
|
|
|
|
|
private var notificationIdSeed = 2000
|
|
|
|
|
|
|
|
|
|
// WebSocket 事件监听器
|
|
|
|
|
private val socketListener = object : OnlineMsgSocketClient.Listener {
|
|
|
|
|
override fun onOpen() {
|
|
|
|
|
scope.launch {
|
|
|
|
|
val lang = _uiState.value.language
|
|
|
|
|
_uiState.update {
|
|
|
|
|
it.copy(
|
|
|
|
|
status = ConnectionStatus.HANDSHAKING,
|
|
|
|
|
statusHint = LanguageManager.getString("status_hint.handshaking", lang)
|
|
|
|
|
statusHint = "已连接,正在准备聊天..."
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
addSystemMessage("连接已建立")
|
|
|
|
|
@ -126,8 +122,7 @@ object ChatSessionManager {
|
|
|
|
|
override fun onBinaryMessage(payload: ByteArray) {
|
|
|
|
|
scope.launch {
|
|
|
|
|
if (_uiState.value.status == ConnectionStatus.HANDSHAKING) {
|
|
|
|
|
val lang = _uiState.value.language
|
|
|
|
|
_uiState.update { it.copy(statusHint = LanguageManager.getString("status_hint.received_binary_handshake", lang)) }
|
|
|
|
|
_uiState.update { it.copy(statusHint = "收到二进制握手帧,正在尝试解析...") }
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
val utf8 = runCatching { String(payload, StandardCharsets.UTF_8) }.getOrNull().orEmpty()
|
|
|
|
|
@ -157,11 +152,10 @@ object ChatSessionManager {
|
|
|
|
|
if (manualClose) return@launch
|
|
|
|
|
val message = throwable.message?.takeIf { it.isNotBlank() } ?: "unknown"
|
|
|
|
|
addSystemMessage("连接异常:$message")
|
|
|
|
|
val lang = _uiState.value.language
|
|
|
|
|
_uiState.update {
|
|
|
|
|
it.copy(
|
|
|
|
|
status = ConnectionStatus.ERROR,
|
|
|
|
|
statusHint = LanguageManager.getString("status_hint.connection_error_retrying", lang)
|
|
|
|
|
statusHint = "连接异常,正在重试"
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
scheduleReconnect("连接异常")
|
|
|
|
|
@ -169,6 +163,10 @@ object ChatSessionManager {
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 初始化管理器,必须在应用启动时调用一次。
|
|
|
|
|
* @param application Application 实例
|
|
|
|
|
*/
|
|
|
|
|
@Synchronized
|
|
|
|
|
fun initialize(application: Application) {
|
|
|
|
|
if (initialized) return
|
|
|
|
|
@ -177,6 +175,7 @@ object ChatSessionManager {
|
|
|
|
|
preferencesRepository = UserPreferencesRepository(application, json)
|
|
|
|
|
cryptoManager = RsaCryptoManager(application)
|
|
|
|
|
historyRepository = ChatHistoryRepository(ChatDatabase.getInstance(application).chatMessageDao())
|
|
|
|
|
ensureMessageNotificationChannel()
|
|
|
|
|
|
|
|
|
|
scope.launch {
|
|
|
|
|
val pref = preferencesRepository.preferencesFlow.first()
|
|
|
|
|
@ -194,14 +193,10 @@ object ChatSessionManager {
|
|
|
|
|
themeId = pref.themeId,
|
|
|
|
|
useDynamicColor = pref.useDynamicColor,
|
|
|
|
|
language = pref.language,
|
|
|
|
|
notificationSound = pref.notificationSound,
|
|
|
|
|
messages = historyMessages,
|
|
|
|
|
statusHint = LanguageManager.getString("status_hint.click_to_connect", pref.language)
|
|
|
|
|
messages = historyMessages
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
ensureMessageNotificationChannel(pref.notificationSound)
|
|
|
|
|
|
|
|
|
|
// 如果上次会话启用了自动重连,则自动恢复连接
|
|
|
|
|
if (pref.shouldAutoReconnect && !autoReconnectTriggered) {
|
|
|
|
|
autoReconnectTriggered = true
|
|
|
|
|
ChatForegroundService.start(application)
|
|
|
|
|
@ -210,14 +205,10 @@ object ChatSessionManager {
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fun updateNotificationSound(sound: String) {
|
|
|
|
|
_uiState.update { it.copy(notificationSound = sound) }
|
|
|
|
|
scope.launch {
|
|
|
|
|
preferencesRepository.setNotificationSound(sound)
|
|
|
|
|
ensureMessageNotificationChannel(sound)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 更新主题
|
|
|
|
|
* @param themeId 主题名
|
|
|
|
|
*/
|
|
|
|
|
fun updateTheme(themeId: String) {
|
|
|
|
|
_uiState.update { it.copy(themeId = themeId) }
|
|
|
|
|
scope.launch {
|
|
|
|
|
@ -225,6 +216,10 @@ object ChatSessionManager {
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 更新语言
|
|
|
|
|
* @param language 语言代码
|
|
|
|
|
*/
|
|
|
|
|
fun updateLanguage(language: String) {
|
|
|
|
|
_uiState.update { it.copy(language = language) }
|
|
|
|
|
scope.launch {
|
|
|
|
|
@ -232,6 +227,10 @@ object ChatSessionManager {
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 更改使用动态颜色
|
|
|
|
|
* @param enabled 主题名
|
|
|
|
|
*/
|
|
|
|
|
fun updateUseDynamicColor(enabled: Boolean) {
|
|
|
|
|
_uiState.update { it.copy(useDynamicColor = enabled) }
|
|
|
|
|
scope.launch {
|
|
|
|
|
@ -239,6 +238,10 @@ object ChatSessionManager {
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 更新显示名称并持久化。
|
|
|
|
|
* @param value 新名称(自动截断至 64 字符)
|
|
|
|
|
*/
|
|
|
|
|
fun updateDisplayName(value: String) {
|
|
|
|
|
val displayName = value.take(64)
|
|
|
|
|
_uiState.update { it.copy(displayName = displayName) }
|
|
|
|
|
@ -247,18 +250,34 @@ object ChatSessionManager {
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 更新当前输入的服务器地址(不持久化)。
|
|
|
|
|
* @param value 新地址
|
|
|
|
|
*/
|
|
|
|
|
fun updateServerUrl(value: String) {
|
|
|
|
|
_uiState.update { it.copy(serverUrl = value) }
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 更新私聊目标公钥。
|
|
|
|
|
* @param value 公钥字符串
|
|
|
|
|
*/
|
|
|
|
|
fun updateTargetKey(value: String) {
|
|
|
|
|
_uiState.update { it.copy(targetKey = value) }
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 更新消息草稿。
|
|
|
|
|
* @param value 草稿内容
|
|
|
|
|
*/
|
|
|
|
|
fun updateDraft(value: String) {
|
|
|
|
|
_uiState.update { it.copy(draft = value) }
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 切换广播/私聊模式并持久化。
|
|
|
|
|
* @param enabled true 为私聊模式
|
|
|
|
|
*/
|
|
|
|
|
fun toggleDirectMode(enabled: Boolean) {
|
|
|
|
|
_uiState.update { it.copy(directMode = enabled) }
|
|
|
|
|
scope.launch {
|
|
|
|
|
@ -266,6 +285,10 @@ object ChatSessionManager {
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 切换是否显示系统消息并持久化。
|
|
|
|
|
* @param show true 显示
|
|
|
|
|
*/
|
|
|
|
|
fun toggleShowSystemMessages(show: Boolean) {
|
|
|
|
|
_uiState.update { it.copy(showSystemMessages = show) }
|
|
|
|
|
scope.launch {
|
|
|
|
|
@ -273,6 +296,9 @@ object ChatSessionManager {
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 清空所有消息,并取消系统消息的过期任务。
|
|
|
|
|
*/
|
|
|
|
|
fun clearMessages() {
|
|
|
|
|
cancelSystemMessageExpiryJobs()
|
|
|
|
|
_uiState.update { it.copy(messages = emptyList()) }
|
|
|
|
|
@ -283,6 +309,9 @@ object ChatSessionManager {
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 保存当前服务器地址到历史列表并持久化。
|
|
|
|
|
*/
|
|
|
|
|
fun saveCurrentServerUrl() {
|
|
|
|
|
val normalized = ServerUrlFormatter.normalize(_uiState.value.serverUrl)
|
|
|
|
|
if (normalized.isBlank()) {
|
|
|
|
|
@ -293,12 +322,11 @@ object ChatSessionManager {
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
val nextUrls = ServerUrlFormatter.append(_uiState.value.serverUrls, normalized)
|
|
|
|
|
val lang = _uiState.value.language
|
|
|
|
|
_uiState.update {
|
|
|
|
|
it.copy(
|
|
|
|
|
serverUrl = normalized,
|
|
|
|
|
serverUrls = nextUrls,
|
|
|
|
|
statusHint = LanguageManager.getString("status_hint.server_saved", lang)
|
|
|
|
|
statusHint = "服务器地址已保存"
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
@ -308,6 +336,10 @@ object ChatSessionManager {
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 从历史列表中移除当前服务器地址。
|
|
|
|
|
* 如果列表清空则恢复默认地址。
|
|
|
|
|
*/
|
|
|
|
|
fun removeCurrentServerUrl() {
|
|
|
|
|
val normalized = ServerUrlFormatter.normalize(_uiState.value.serverUrl)
|
|
|
|
|
if (normalized.isBlank()) return
|
|
|
|
|
@ -319,16 +351,11 @@ object ChatSessionManager {
|
|
|
|
|
filtered
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
val lang = _uiState.value.language
|
|
|
|
|
_uiState.update {
|
|
|
|
|
it.copy(
|
|
|
|
|
serverUrls = nextUrls,
|
|
|
|
|
serverUrl = nextUrls.first(),
|
|
|
|
|
statusHint = if (filtered.isEmpty()) {
|
|
|
|
|
LanguageManager.getString("status_hint.server_removed_default", lang)
|
|
|
|
|
} else {
|
|
|
|
|
LanguageManager.getString("status_hint.server_removed", lang)
|
|
|
|
|
}
|
|
|
|
|
statusHint = if (filtered.isEmpty()) "已恢复默认服务器地址" else "已移除当前服务器地址"
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
@ -338,6 +365,9 @@ object ChatSessionManager {
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 加载或生成本地身份密钥对,并将公钥显示到 UI。
|
|
|
|
|
*/
|
|
|
|
|
fun revealMyPublicKey() {
|
|
|
|
|
scope.launch {
|
|
|
|
|
_uiState.update { it.copy(loadingPublicKey = true) }
|
|
|
|
|
@ -357,10 +387,17 @@ object ChatSessionManager {
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 主动连接服务器(由用户点击连接触发)。
|
|
|
|
|
*/
|
|
|
|
|
fun connect() {
|
|
|
|
|
connectInternal(isAutoRestore = false)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 内部连接逻辑,区分自动恢复和手动连接。
|
|
|
|
|
* @param isAutoRestore 是否为应用启动时的自动恢复连接
|
|
|
|
|
*/
|
|
|
|
|
private fun connectInternal(isAutoRestore: Boolean) {
|
|
|
|
|
if (!initialized) return
|
|
|
|
|
val state = _uiState.value
|
|
|
|
|
@ -368,11 +405,10 @@ object ChatSessionManager {
|
|
|
|
|
|
|
|
|
|
val normalized = ServerUrlFormatter.normalize(state.serverUrl)
|
|
|
|
|
if (normalized.isBlank()) {
|
|
|
|
|
val lang = _uiState.value.language
|
|
|
|
|
_uiState.update {
|
|
|
|
|
it.copy(
|
|
|
|
|
status = ConnectionStatus.ERROR,
|
|
|
|
|
statusHint = LanguageManager.getString("status_hint.invalid_server_url", lang)
|
|
|
|
|
statusHint = "请填写有效服务器地址"
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
return
|
|
|
|
|
@ -388,11 +424,10 @@ object ChatSessionManager {
|
|
|
|
|
cancelHelloTimeout()
|
|
|
|
|
cancelAuthTimeout()
|
|
|
|
|
|
|
|
|
|
val lang = _uiState.value.language
|
|
|
|
|
_uiState.update {
|
|
|
|
|
it.copy(
|
|
|
|
|
status = ConnectionStatus.CONNECTING,
|
|
|
|
|
statusHint = LanguageManager.getString("status_hint.connecting", lang),
|
|
|
|
|
statusHint = "正在连接服务器...",
|
|
|
|
|
serverUrl = normalized,
|
|
|
|
|
certFingerprint = ""
|
|
|
|
|
)
|
|
|
|
|
@ -411,17 +446,20 @@ object ChatSessionManager {
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 主动断开连接。
|
|
|
|
|
* @param stopService 是否同时停止前台服务(默认 true)
|
|
|
|
|
*/
|
|
|
|
|
fun disconnect(stopService: Boolean = true) {
|
|
|
|
|
manualClose = true
|
|
|
|
|
cancelReconnect()
|
|
|
|
|
cancelHelloTimeout()
|
|
|
|
|
cancelAuthTimeout()
|
|
|
|
|
socketClient.close(1000, "manual_close")
|
|
|
|
|
val lang = _uiState.value.language
|
|
|
|
|
_uiState.update {
|
|
|
|
|
it.copy(
|
|
|
|
|
status = ConnectionStatus.IDLE,
|
|
|
|
|
statusHint = LanguageManager.getString("status_hint.disconnected", lang)
|
|
|
|
|
statusHint = "连接已关闭"
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
autoReconnectTriggered = false
|
|
|
|
|
@ -435,6 +473,10 @@ object ChatSessionManager {
|
|
|
|
|
addSystemMessage("已断开连接")
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 发送消息(广播或私聊)。
|
|
|
|
|
* 执行签名、加密并发送。
|
|
|
|
|
*/
|
|
|
|
|
fun sendMessage() {
|
|
|
|
|
val current = _uiState.value
|
|
|
|
|
if (!current.canSend) return
|
|
|
|
|
@ -445,8 +487,7 @@ object ChatSessionManager {
|
|
|
|
|
|
|
|
|
|
val key = if (_uiState.value.directMode) _uiState.value.targetKey.trim() else ""
|
|
|
|
|
if (_uiState.value.directMode && key.isBlank()) {
|
|
|
|
|
val lang = _uiState.value.language
|
|
|
|
|
_uiState.update { it.copy(statusHint = LanguageManager.getString("status_hint.target_key_required", lang)) }
|
|
|
|
|
_uiState.update { it.copy(statusHint = "请先填写目标公钥,再发送私聊消息") }
|
|
|
|
|
return@launch
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
@ -493,12 +534,19 @@ object ChatSessionManager {
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 消息复制成功后的回调,显示“已复制”提示。
|
|
|
|
|
*/
|
|
|
|
|
fun onMessageCopied() {
|
|
|
|
|
scope.launch {
|
|
|
|
|
_events.emit(UiEvent.ShowSnackbar("已复制"))
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 确保本地身份已加载 or 创建。
|
|
|
|
|
* @return 本地身份对象
|
|
|
|
|
*/
|
|
|
|
|
private suspend fun ensureIdentity(): RsaCryptoManager.Identity {
|
|
|
|
|
return identityMutex.withLock {
|
|
|
|
|
identity ?: withContext(Dispatchers.Default) {
|
|
|
|
|
@ -509,10 +557,13 @@ object ChatSessionManager {
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 处理收到的原始文本消息(可能是握手包 or 加密消息)。
|
|
|
|
|
* @param rawText 原始文本
|
|
|
|
|
*/
|
|
|
|
|
private suspend fun handleIncomingMessage(rawText: String) {
|
|
|
|
|
if (_uiState.value.status == ConnectionStatus.HANDSHAKING) {
|
|
|
|
|
val lang = _uiState.value.language
|
|
|
|
|
_uiState.update { it.copy(statusHint = LanguageManager.getString("status_hint.hello_received", lang)) }
|
|
|
|
|
_uiState.update { it.copy(statusHint = "已收到握手数据,正在解析...") }
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
val normalizedText = extractJsonCandidate(rawText)
|
|
|
|
|
@ -520,6 +571,7 @@ object ChatSessionManager {
|
|
|
|
|
json.decodeFromString<JsonElement>(normalizedText) as? JsonObject
|
|
|
|
|
}.getOrNull()
|
|
|
|
|
|
|
|
|
|
// 尝试直接解析为 HelloDataDto(某些服务器可能直接发送,不带外层)
|
|
|
|
|
val directHello = rootObject?.let { obj ->
|
|
|
|
|
val hasPublicKey = obj["publicKey"] != null
|
|
|
|
|
val hasChallenge = obj["authChallenge"] != null
|
|
|
|
|
@ -535,6 +587,7 @@ object ChatSessionManager {
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 尝试解析为带外层的 EnvelopeDto
|
|
|
|
|
val plain = runCatching { json.decodeFromString<EnvelopeDto>(normalizedText) }.getOrNull()
|
|
|
|
|
if (plain?.type == "publickey") {
|
|
|
|
|
cancelHelloTimeout()
|
|
|
|
|
@ -542,11 +595,10 @@ object ChatSessionManager {
|
|
|
|
|
runCatching { json.decodeFromJsonElement<HelloDataDto>(it) }.getOrNull()
|
|
|
|
|
}
|
|
|
|
|
if (hello == null || hello.publicKey.isBlank() || hello.authChallenge.isBlank()) {
|
|
|
|
|
val lang = _uiState.value.language
|
|
|
|
|
_uiState.update {
|
|
|
|
|
it.copy(
|
|
|
|
|
status = ConnectionStatus.ERROR,
|
|
|
|
|
statusHint = LanguageManager.getString("status_hint.handshake_failed_incomplete", lang)
|
|
|
|
|
statusHint = "握手失败:服务端响应不完整"
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
return
|
|
|
|
|
@ -555,17 +607,20 @@ object ChatSessionManager {
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 握手阶段收到非预期消息则报错
|
|
|
|
|
if (_uiState.value.status == ConnectionStatus.HANDSHAKING && plain != null) {
|
|
|
|
|
val lang = _uiState.value.language
|
|
|
|
|
_uiState.update { it.copy(statusHint = LanguageManager.getString("status_hint.handshake_failed_unexpected", lang)) }
|
|
|
|
|
_uiState.update { it.copy(statusHint = "握手失败:收到非预期消息") }
|
|
|
|
|
addSystemMessage("握手阶段收到非预期消息类型:${plain.type}")
|
|
|
|
|
} else if (_uiState.value.status == ConnectionStatus.HANDSHAKING && plain == null) {
|
|
|
|
|
val lang = _uiState.value.language
|
|
|
|
|
val preview = rawText.replace("\n", " ").replace("\r", " ").take(80)
|
|
|
|
|
_uiState.update { it.copy(statusHint = LanguageManager.getString("status_hint.handshake_failed_parse", lang)) }
|
|
|
|
|
val preview = rawText
|
|
|
|
|
.replace("\n", " ")
|
|
|
|
|
.replace("\r", " ")
|
|
|
|
|
.take(80)
|
|
|
|
|
_uiState.update { it.copy(statusHint = "握手失败:首包解析失败") }
|
|
|
|
|
addSystemMessage("握手包解析失败:$preview")
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 尝试解密(若已握手完成,收到的应是加密消息)
|
|
|
|
|
val id = ensureIdentity()
|
|
|
|
|
val decrypted = runCatching {
|
|
|
|
|
withContext(Dispatchers.Default) {
|
|
|
|
|
@ -583,14 +638,17 @@ object ChatSessionManager {
|
|
|
|
|
handleSecureMessage(secure)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 处理服务端发来的握手 Hello 数据。
|
|
|
|
|
* @param hello 服务端公钥和挑战
|
|
|
|
|
*/
|
|
|
|
|
private suspend fun handleServerHello(hello: HelloDataDto) {
|
|
|
|
|
cancelHelloTimeout()
|
|
|
|
|
serverPublicKey = hello.publicKey
|
|
|
|
|
val lang = _uiState.value.language
|
|
|
|
|
_uiState.update {
|
|
|
|
|
it.copy(
|
|
|
|
|
status = ConnectionStatus.AUTHENTICATING,
|
|
|
|
|
statusHint = LanguageManager.getString("status_hint.authenticating", lang),
|
|
|
|
|
statusHint = "正在完成身份验证...",
|
|
|
|
|
certFingerprint = hello.certFingerprintSha256.orEmpty()
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
@ -599,11 +657,10 @@ object ChatSessionManager {
|
|
|
|
|
authTimeoutJob = scope.launch {
|
|
|
|
|
delay(AUTH_TIMEOUT_MS)
|
|
|
|
|
if (_uiState.value.status == ConnectionStatus.AUTHENTICATING) {
|
|
|
|
|
val lang = _uiState.value.language
|
|
|
|
|
_uiState.update {
|
|
|
|
|
it.copy(
|
|
|
|
|
status = ConnectionStatus.ERROR,
|
|
|
|
|
statusHint = LanguageManager.getString("status_hint.auth_timeout", lang)
|
|
|
|
|
statusHint = "连接超时,请重试"
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
addSystemMessage("认证超时,请检查网络后重试")
|
|
|
|
|
@ -617,11 +674,10 @@ object ChatSessionManager {
|
|
|
|
|
addSystemMessage("已发送认证请求")
|
|
|
|
|
}.onFailure { error ->
|
|
|
|
|
cancelAuthTimeout()
|
|
|
|
|
val lang = _uiState.value.language
|
|
|
|
|
_uiState.update {
|
|
|
|
|
it.copy(
|
|
|
|
|
status = ConnectionStatus.ERROR,
|
|
|
|
|
statusHint = LanguageManager.getString("status_hint.auth_failed", lang)
|
|
|
|
|
statusHint = "认证失败"
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
addSystemMessage("认证发送失败:${error.message ?: "unknown"}")
|
|
|
|
|
@ -629,6 +685,10 @@ object ChatSessionManager {
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 发送认证消息(包含签名后的身份信息)。
|
|
|
|
|
* @param challenge 服务端提供的挑战值
|
|
|
|
|
*/
|
|
|
|
|
private suspend fun sendAuth(challenge: String) {
|
|
|
|
|
val id = ensureIdentity()
|
|
|
|
|
val displayName = _uiState.value.displayName.trim().ifBlank { createGuestName() }
|
|
|
|
|
@ -672,17 +732,20 @@ object ChatSessionManager {
|
|
|
|
|
check(socketClient.send(cipher)) { "连接不可用" }
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 处理安全通道建立后的业务消息(广播、私聊、认证结果等)。
|
|
|
|
|
* @param message 解密后的 EnvelopeDto
|
|
|
|
|
*/
|
|
|
|
|
private fun handleSecureMessage(message: EnvelopeDto) {
|
|
|
|
|
when (message.type) {
|
|
|
|
|
"auth_ok" -> {
|
|
|
|
|
cancelAuthTimeout()
|
|
|
|
|
cancelReconnect()
|
|
|
|
|
reconnectAttempt = 0
|
|
|
|
|
val lang = _uiState.value.language
|
|
|
|
|
_uiState.update {
|
|
|
|
|
it.copy(
|
|
|
|
|
status = ConnectionStatus.READY,
|
|
|
|
|
statusHint = LanguageManager.getString("status_hint.ready", lang)
|
|
|
|
|
statusHint = "已连接,可以开始聊天"
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
addSystemMessage("连接准备完成")
|
|
|
|
|
@ -712,25 +775,39 @@ object ChatSessionManager {
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 处理 WebSocket 连接关闭事件。
|
|
|
|
|
* @param code 关闭状态码
|
|
|
|
|
* @param reason 关闭原因
|
|
|
|
|
*/
|
|
|
|
|
private fun handleSocketClosed(code: Int, reason: String) {
|
|
|
|
|
cancelHelloTimeout()
|
|
|
|
|
cancelAuthTimeout()
|
|
|
|
|
|
|
|
|
|
if (manualClose || reason == "reconnect" || reconnectJob?.isActive == true) return
|
|
|
|
|
if (manualClose) {
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
if (reason == "reconnect") {
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
if (reconnectJob?.isActive == true) {
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
val currentStatus = _uiState.value.status
|
|
|
|
|
|
|
|
|
|
val allowFallback = !fallbackTried && currentStatus != ConnectionStatus.READY
|
|
|
|
|
|
|
|
|
|
// 尝试切换 ws/wss 协议重试(仅限非就绪状态)
|
|
|
|
|
if (allowFallback) {
|
|
|
|
|
val fallbackUrl = ServerUrlFormatter.toggleWsProtocol(connectedUrl)
|
|
|
|
|
if (fallbackUrl.isNotBlank()) {
|
|
|
|
|
fallbackTried = true
|
|
|
|
|
connectedUrl = fallbackUrl
|
|
|
|
|
val lang = _uiState.value.language
|
|
|
|
|
_uiState.update {
|
|
|
|
|
it.copy(
|
|
|
|
|
status = ConnectionStatus.CONNECTING,
|
|
|
|
|
statusHint = LanguageManager.getString("status_hint.reconnecting", lang),
|
|
|
|
|
statusHint = "正在自动重试连接...",
|
|
|
|
|
serverUrl = fallbackUrl
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
@ -740,17 +817,20 @@ object ChatSessionManager {
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
val lang = _uiState.value.language
|
|
|
|
|
_uiState.update {
|
|
|
|
|
it.copy(
|
|
|
|
|
status = ConnectionStatus.ERROR,
|
|
|
|
|
statusHint = LanguageManager.getString("status_hint.connection_interrupted_retrying", lang)
|
|
|
|
|
statusHint = "连接已中断,正在重试"
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
addSystemMessage("连接关闭 ($code):${reason.ifBlank { "连接中断" }}")
|
|
|
|
|
scheduleReconnect("连接已中断")
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 添加一条系统消息(自动按 TTL 过期)。
|
|
|
|
|
* @param content 消息内容
|
|
|
|
|
*/
|
|
|
|
|
private fun addSystemMessage(content: String) {
|
|
|
|
|
val message = UiMessage(
|
|
|
|
|
role = MessageRole.SYSTEM,
|
|
|
|
|
@ -763,6 +843,13 @@ object ChatSessionManager {
|
|
|
|
|
scheduleSystemMessageExpiry(message.id)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 添加一条接收到的用户消息。
|
|
|
|
|
* @param sender 发送者名称
|
|
|
|
|
* @param subtitle 附加说明(如私聊来源)
|
|
|
|
|
* @param content 消息内容
|
|
|
|
|
* @param channel 消息通道(广播/私聊)
|
|
|
|
|
*/
|
|
|
|
|
private fun addIncomingMessage(
|
|
|
|
|
sender: String,
|
|
|
|
|
subtitle: String,
|
|
|
|
|
@ -784,6 +871,12 @@ object ChatSessionManager {
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 添加一条发出的消息。
|
|
|
|
|
* @param content 消息内容
|
|
|
|
|
* @param subtitle 附加说明(如私聊目标)
|
|
|
|
|
* @param channel 消息通道
|
|
|
|
|
*/
|
|
|
|
|
private fun addOutgoingMessage(
|
|
|
|
|
content: String,
|
|
|
|
|
subtitle: String,
|
|
|
|
|
@ -800,11 +893,15 @@ object ChatSessionManager {
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 将消息追加到列表尾部,并清理超出数量限制的消息。
|
|
|
|
|
* @param message 要追加的消息
|
|
|
|
|
*/
|
|
|
|
|
private fun appendMessage(message: UiMessage) {
|
|
|
|
|
_uiState.update { current ->
|
|
|
|
|
val next = (current.messages + message).takeLast(MAX_MESSAGES)
|
|
|
|
|
val aliveIds = next.asSequence().map { it.id }.toSet()
|
|
|
|
|
val removedIds = systemMessageExpiryJobs.keys.filterNot { id -> id in aliveIds }
|
|
|
|
|
val removedIds = systemMessageExpiryJobs.keys.filterNot { it in aliveIds }
|
|
|
|
|
removedIds.forEach { id ->
|
|
|
|
|
systemMessageExpiryJobs.remove(id)?.cancel()
|
|
|
|
|
}
|
|
|
|
|
@ -818,28 +915,30 @@ object ChatSessionManager {
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 取消认证超时任务。
|
|
|
|
|
*/
|
|
|
|
|
private fun cancelAuthTimeout() {
|
|
|
|
|
authTimeoutJob?.cancel()
|
|
|
|
|
authTimeoutJob = null
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 安排自动重连(指数退避)。
|
|
|
|
|
* @param reason 触发重连的原因
|
|
|
|
|
*/
|
|
|
|
|
private fun scheduleReconnect(reason: String) {
|
|
|
|
|
if (manualClose || reconnectJob?.isActive == true) return
|
|
|
|
|
if (manualClose) return
|
|
|
|
|
if (reconnectJob?.isActive == true) return
|
|
|
|
|
|
|
|
|
|
reconnectAttempt += 1
|
|
|
|
|
val exponential = 1 shl minOf(reconnectAttempt - 1, 5)
|
|
|
|
|
val delaySeconds = minOf(MAX_RECONNECT_DELAY_SECONDS, exponential)
|
|
|
|
|
addSystemMessage("$reason,${delaySeconds}s 后自动重连(第 $reconnectAttempt 次)")
|
|
|
|
|
val lang = _uiState.value.language
|
|
|
|
|
val hint = String.format(
|
|
|
|
|
LanguageManager.getString("status_hint.reconnect_countdown", lang),
|
|
|
|
|
delaySeconds,
|
|
|
|
|
reconnectAttempt
|
|
|
|
|
)
|
|
|
|
|
_uiState.update {
|
|
|
|
|
it.copy(
|
|
|
|
|
status = ConnectionStatus.ERROR,
|
|
|
|
|
statusHint = hint
|
|
|
|
|
statusHint = "${delaySeconds}s 后自动重连(第 $reconnectAttempt 次)"
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
@ -851,11 +950,10 @@ object ChatSessionManager {
|
|
|
|
|
ServerUrlFormatter.normalize(_uiState.value.serverUrl)
|
|
|
|
|
}
|
|
|
|
|
if (target.isBlank()) {
|
|
|
|
|
val lang = _uiState.value.language
|
|
|
|
|
_uiState.update {
|
|
|
|
|
it.copy(
|
|
|
|
|
status = ConnectionStatus.ERROR,
|
|
|
|
|
statusHint = LanguageManager.getString("status_hint.reconnect_failed_invalid_url", lang)
|
|
|
|
|
statusHint = "重连失败:服务器地址无效"
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
return@launch
|
|
|
|
|
@ -876,11 +974,18 @@ object ChatSessionManager {
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 取消自动重连任务。
|
|
|
|
|
*/
|
|
|
|
|
private fun cancelReconnect() {
|
|
|
|
|
reconnectJob?.cancel()
|
|
|
|
|
reconnectJob = null
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 为系统消息安排过期自动删除。
|
|
|
|
|
* @param messageId 消息唯一 ID
|
|
|
|
|
*/
|
|
|
|
|
private fun scheduleSystemMessageExpiry(messageId: String) {
|
|
|
|
|
systemMessageExpiryJobs.remove(messageId)?.cancel()
|
|
|
|
|
systemMessageExpiryJobs[messageId] = scope.launch {
|
|
|
|
|
@ -893,22 +998,27 @@ object ChatSessionManager {
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 取消所有系统消息的过期任务。
|
|
|
|
|
*/
|
|
|
|
|
private fun cancelSystemMessageExpiryJobs() {
|
|
|
|
|
systemMessageExpiryJobs.values.forEach { it.cancel() }
|
|
|
|
|
systemMessageExpiryJobs.clear()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 启动握手超时计时器。
|
|
|
|
|
*/
|
|
|
|
|
private fun startHelloTimeout() {
|
|
|
|
|
cancelHelloTimeout()
|
|
|
|
|
helloTimeoutJob = scope.launch {
|
|
|
|
|
delay(HELLO_TIMEOUT_MS)
|
|
|
|
|
if (_uiState.value.status == ConnectionStatus.HANDSHAKING) {
|
|
|
|
|
val currentUrl = connectedUrl.ifBlank { "unknown" }
|
|
|
|
|
val lang = _uiState.value.language
|
|
|
|
|
_uiState.update {
|
|
|
|
|
it.copy(
|
|
|
|
|
status = ConnectionStatus.ERROR,
|
|
|
|
|
statusHint = LanguageManager.getString("status_hint.hello_timeout", lang)
|
|
|
|
|
statusHint = "握手超时,请检查地址路径与反向代理"
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
addSystemMessage("握手超时:未收到服务端 publickey 首包(当前地址:$currentUrl)")
|
|
|
|
|
@ -917,29 +1027,56 @@ object ChatSessionManager {
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 取消握手超时任务。
|
|
|
|
|
*/
|
|
|
|
|
private fun cancelHelloTimeout() {
|
|
|
|
|
helloTimeoutJob?.cancel()
|
|
|
|
|
helloTimeoutJob = null
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 缩写显示公钥(取前后各8字符)。
|
|
|
|
|
* @param key 完整公钥
|
|
|
|
|
* @return 缩写字符串
|
|
|
|
|
*/
|
|
|
|
|
private fun summarizeKey(key: String): String {
|
|
|
|
|
if (key.length <= 16) return key
|
|
|
|
|
return "${key.take(8)}...${key.takeLast(8)}"
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 生成访客名称(如 guest-123456)。
|
|
|
|
|
* @return 随机名称
|
|
|
|
|
*/
|
|
|
|
|
private fun createGuestName(): String {
|
|
|
|
|
val rand = (100000..999999).random()
|
|
|
|
|
return "guest-$rand"
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 从可能包含前缀的原始文本中提取 JSON 对象部分。
|
|
|
|
|
* @param rawText 原始文本
|
|
|
|
|
* @return 最外层的 JSON 字符串
|
|
|
|
|
*/
|
|
|
|
|
private fun extractJsonCandidate(rawText: String): String {
|
|
|
|
|
val trimmed = rawText.trim()
|
|
|
|
|
if (trimmed.startsWith("{") && trimmed.endsWith("}")) return trimmed
|
|
|
|
|
if (trimmed.startsWith("{") && trimmed.endsWith("}")) {
|
|
|
|
|
return trimmed
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
val start = rawText.indexOf('{')
|
|
|
|
|
val end = rawText.lastIndexOf('}')
|
|
|
|
|
return if (start in 0 until end) rawText.substring(start, end + 1) else rawText
|
|
|
|
|
return if (start in 0 until end) {
|
|
|
|
|
rawText.substring(start, end + 1)
|
|
|
|
|
} else {
|
|
|
|
|
rawText
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 关闭所有资源(用于应用退出时)。
|
|
|
|
|
*/
|
|
|
|
|
fun shutdownAll() {
|
|
|
|
|
cancelSystemMessageExpiryJobs()
|
|
|
|
|
cancelReconnect()
|
|
|
|
|
@ -948,6 +1085,9 @@ object ChatSessionManager {
|
|
|
|
|
socketClient.shutdown()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 前台服务停止时的回调。
|
|
|
|
|
*/
|
|
|
|
|
fun onForegroundServiceStopped() {
|
|
|
|
|
keepAliveRequested = false
|
|
|
|
|
if (_uiState.value.status != ConnectionStatus.IDLE) {
|
|
|
|
|
@ -959,57 +1099,52 @@ object ChatSessionManager {
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 判断前台服务是否应该运行。
|
|
|
|
|
* @return true 表示应保持服务运行
|
|
|
|
|
*/
|
|
|
|
|
fun shouldForegroundServiceRun(): Boolean = keepAliveRequested
|
|
|
|
|
|
|
|
|
|
private fun ensureMessageNotificationChannel(soundCode: String = "default") {
|
|
|
|
|
/**
|
|
|
|
|
* 创建消息通知渠道(Android O+)。
|
|
|
|
|
*/
|
|
|
|
|
private fun ensureMessageNotificationChannel() {
|
|
|
|
|
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return
|
|
|
|
|
val manager = app.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
|
|
|
|
|
val channelId = "${MESSAGE_CHANNEL_ID}_$soundCode"
|
|
|
|
|
if (manager.getNotificationChannel(channelId) != null) return
|
|
|
|
|
|
|
|
|
|
val soundUri = getSoundUri(soundCode)
|
|
|
|
|
val channel = NotificationChannel(
|
|
|
|
|
channelId,
|
|
|
|
|
MESSAGE_CHANNEL_ID,
|
|
|
|
|
"OnlineMsg 消息提醒",
|
|
|
|
|
NotificationManager.IMPORTANCE_DEFAULT
|
|
|
|
|
).apply {
|
|
|
|
|
description = "收到服务器新消息时提醒"
|
|
|
|
|
if (soundUri != null) {
|
|
|
|
|
setSound(soundUri, AudioAttributes.Builder()
|
|
|
|
|
.setUsage(AudioAttributes.USAGE_NOTIFICATION)
|
|
|
|
|
.setContentType(AudioAttributes.CONTENT_TYPE_SONIFICATION)
|
|
|
|
|
.build())
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
manager.createNotificationChannel(channel)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private fun getSoundUri(code: String): Uri? {
|
|
|
|
|
val resId = when(code) {
|
|
|
|
|
"ding" -> R.raw.load
|
|
|
|
|
"nameit5" -> R.raw.nameit5
|
|
|
|
|
"wind_chime" -> R.raw.notification_sound_effects
|
|
|
|
|
"default" -> R.raw.default_sound
|
|
|
|
|
else -> return null
|
|
|
|
|
}
|
|
|
|
|
return Uri.parse(ContentResolver.SCHEME_ANDROID_RESOURCE + "://" + app.packageName + "/" + resId)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 显示新消息到达的通知。
|
|
|
|
|
* @param title 通知标题
|
|
|
|
|
* @param body 通知正文
|
|
|
|
|
*/
|
|
|
|
|
private fun showIncomingNotification(title: String, body: String) {
|
|
|
|
|
if (!initialized) return
|
|
|
|
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU &&
|
|
|
|
|
ContextCompat.checkSelfPermission(app, Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED
|
|
|
|
|
) return
|
|
|
|
|
) {
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
val launchIntent = Intent(app, MainActivity::class.java).apply {
|
|
|
|
|
flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP
|
|
|
|
|
}
|
|
|
|
|
val pendingIntent = PendingIntent.getActivity(app, 0, launchIntent, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE)
|
|
|
|
|
|
|
|
|
|
val soundCode = _uiState.value.notificationSound
|
|
|
|
|
val channelId = "${MESSAGE_CHANNEL_ID}_$soundCode"
|
|
|
|
|
val pendingIntent = PendingIntent.getActivity(
|
|
|
|
|
app,
|
|
|
|
|
0,
|
|
|
|
|
launchIntent,
|
|
|
|
|
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
val notification = NotificationCompat.Builder(app, channelId)
|
|
|
|
|
val notification = NotificationCompat.Builder(app, MESSAGE_CHANNEL_ID)
|
|
|
|
|
.setSmallIcon(android.R.drawable.stat_notify_chat)
|
|
|
|
|
.setContentTitle(title.ifBlank { "OnlineMsg" })
|
|
|
|
|
.setContentText(body.take(120))
|
|
|
|
|
@ -1022,12 +1157,17 @@ object ChatSessionManager {
|
|
|
|
|
NotificationManagerCompat.from(app).notify(nextMessageNotificationId(), notification)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 生成下一个通知 ID(线程安全递增)。
|
|
|
|
|
* @return 新的通知 ID
|
|
|
|
|
*/
|
|
|
|
|
@Synchronized
|
|
|
|
|
private fun nextMessageNotificationId(): Int {
|
|
|
|
|
notificationIdSeed += 1
|
|
|
|
|
return notificationIdSeed
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 常量定义
|
|
|
|
|
private const val HELLO_TIMEOUT_MS = 12_000L
|
|
|
|
|
private const val AUTH_TIMEOUT_MS = 20_000L
|
|
|
|
|
private const val MAX_MESSAGES = 500
|
|
|
|
|
|