@ -16,7 +16,6 @@ import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat
import androidx.core.app.NotificationManagerCompat
import androidx.core.content.ContextCompat
import androidx.core.content.ContextCompat
import com.onlinemsg.client.MainActivity
import com.onlinemsg.client.MainActivity
import com.onlinemsg.client.R
import com.onlinemsg.client.data.crypto.RsaCryptoManager
import com.onlinemsg.client.data.crypto.RsaCryptoManager
import com.onlinemsg.client.data.local.ChatDatabase
import com.onlinemsg.client.data.local.ChatDatabase
import com.onlinemsg.client.data.local.ChatHistoryRepository
import com.onlinemsg.client.data.local.ChatHistoryRepository
@ -32,6 +31,7 @@ import com.onlinemsg.client.data.protocol.SignedPayloadDto
import com.onlinemsg.client.data.protocol.asPayloadText
import com.onlinemsg.client.data.protocol.asPayloadText
import com.onlinemsg.client.service.ChatForegroundService
import com.onlinemsg.client.service.ChatForegroundService
import com.onlinemsg.client.util.LanguageManager
import com.onlinemsg.client.util.LanguageManager
import com.onlinemsg.client.util.NotificationSoundCatalog
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.Job
@ -184,7 +184,9 @@ object ChatSessionManager {
_uiState . update {
_uiState . update {
it . copy (
it . copy (
status = ConnectionStatus . ERROR ,
status = ConnectionStatus . ERROR ,
statusHint = t ( " session.hint.connection_error_retrying " )
statusHint = t ( " session.hint.connection_error_retrying " ) ,
isSwitchingServer = false ,
switchingServerLabel = " "
)
)
}
}
scheduleReconnect ( t ( " session.reason.connection_error " ) )
scheduleReconnect ( t ( " session.reason.connection_error " ) )
@ -207,7 +209,7 @@ object ChatSessionManager {
scope . launch {
scope . launch {
val pref = preferencesRepository . preferencesFlow . first ( )
val pref = preferencesRepository . preferencesFlow . first ( )
val historyMessages = withContext ( Dispatchers . IO ) {
val historyMessages = withContext ( Dispatchers . IO ) {
historyRepository . loadMessages ( MAX_MESSAGES )
historyRepository . loadMessages ( serverKeyFor( pref . currentServerUrl ) , MAX_MESSAGES )
}
}
keepAliveRequested = pref . shouldAutoReconnect
keepAliveRequested = pref . shouldAutoReconnect
ensureMessageNotificationChannel ( pref . notificationSound )
ensureMessageNotificationChannel ( pref . notificationSound )
@ -299,6 +301,20 @@ object ChatSessionManager {
_uiState . update { it . copy ( serverUrl = value ) }
_uiState . update { it . copy ( serverUrl = value ) }
}
}
/ * *
* 选择历史服务器并切换会话 。
* /
fun selectServerUrl ( value : String ) {
val normalized = ServerUrlFormatter . normalize ( value )
if ( normalized . isBlank ( ) ) return
switchServer (
normalized = normalized ,
nextUrls = _uiState . value . serverUrls ,
statusHint = null ,
persist = { preferencesRepository . setCurrentServerUrl ( normalized ) }
)
}
/ * *
/ * *
* 更新私聊目标公钥 。
* 更新私聊目标公钥 。
* @param value 公钥字符串
* @param value 公钥字符串
@ -345,7 +361,7 @@ object ChatSessionManager {
_uiState . update { it . copy ( messages = emptyList ( ) ) }
_uiState . update { it . copy ( messages = emptyList ( ) ) }
scope . launch ( Dispatchers . IO ) {
scope . launch ( Dispatchers . IO ) {
runCatching {
runCatching {
historyRepository . clearAll ( )
historyRepository . clearAll ( currentServerKey ( ) )
}
}
}
}
}
}
@ -363,18 +379,13 @@ object ChatSessionManager {
}
}
val nextUrls = ServerUrlFormatter . append ( _uiState . value . serverUrls , normalized )
val nextUrls = ServerUrlFormatter . append ( _uiState . value . serverUrls , normalized )
_uiState . update {
switchServer (
it . copy (
normalized = normalized ,
serverUrl = normalized ,
nextUrls = nextUrls ,
serverUrls = nextUrls ,
statusHint = t ( " session.hint.server_saved " ) ,
statusHint = t ( " session.hint.server_saved " )
persist = { preferencesRepository . saveCurrentServerUrl ( normalized ) } ,
)
snackbarMessage = t ( " session.snackbar.server_saved " )
}
)
scope . launch {
preferencesRepository . saveCurrentServerUrl ( normalized )
_events . emit ( UiEvent . ShowSnackbar ( t ( " session.snackbar.server_saved " ) ) )
}
}
}
/ * *
/ * *
@ -392,22 +403,17 @@ object ChatSessionManager {
filtered
filtered
}
}
_uiState . update {
switchServer (
it . copy (
normalized = nextUrls . first ( ) ,
serverUrls = nextUrls ,
nextUrls = nextUrls ,
serverUrl = nextUrls . first ( ) ,
statusHint = if ( filtered . isEmpty ( ) ) {
statusHint = if ( filtered . isEmpty ( ) ) {
t ( " session.hint.server_restored_default " )
t ( " session.hint.server_restored_default " )
} else {
} else {
t ( " session.hint.server_removed " )
t ( " session.hint.server_removed " )
} ,
}
persist = { preferencesRepository . removeCurrentServerUrl ( normalized ) } ,
)
snackbarMessage = t ( " session.snackbar.server_list_updated " )
}
)
scope . launch {
preferencesRepository . removeCurrentServerUrl ( normalized )
_events . emit ( UiEvent . ShowSnackbar ( t ( " session.snackbar.server_list_updated " ) ) )
}
}
}
/ * *
/ * *
@ -450,17 +456,23 @@ object ChatSessionManager {
* 内部连接逻辑 , 区分自动恢复和手动连接 。
* 内部连接逻辑 , 区分自动恢复和手动连接 。
* @param isAutoRestore 是否为应用启动时的自动恢复连接
* @param isAutoRestore 是否为应用启动时的自动恢复连接
* /
* /
private fun connectInternal ( isAutoRestore : Boolean ) {
private fun connectInternal (
isAutoRestore : Boolean ,
overrideUrl : String ? = null ,
forceReconnect : Boolean = false
) {
if ( !in itialized ) return
if ( !in itialized ) return
val state = _uiState . value
val state = _uiState . value
if ( ! state . canConnect ) return
if ( ! forceReconnect && ! state. canConnect ) return
val normalized = ServerUrlFormatter . normalize ( state. serverUrl )
val normalized = ServerUrlFormatter . normalize ( overrideUrl ?: state. serverUrl )
if ( normalized . isBlank ( ) ) {
if ( normalized . isBlank ( ) ) {
_uiState . update {
_uiState . update {
it . copy (
it . copy (
status = ConnectionStatus . ERROR ,
status = ConnectionStatus . ERROR ,
statusHint = t ( " session.hint.fill_valid_server " )
statusHint = t ( " session.hint.fill_valid_server " ) ,
isSwitchingServer = false ,
switchingServerLabel = " "
)
)
}
}
return
return
@ -511,7 +523,9 @@ object ChatSessionManager {
_uiState . update {
_uiState . update {
it . copy (
it . copy (
status = ConnectionStatus . IDLE ,
status = ConnectionStatus . IDLE ,
statusHint = t ( " session.hint.connection_closed " )
statusHint = t ( " session.hint.connection_closed " ) ,
isSwitchingServer = false ,
switchingServerLabel = " "
)
)
}
}
autoReconnectTriggered = false
autoReconnectTriggered = false
@ -690,7 +704,9 @@ object ChatSessionManager {
_uiState . update {
_uiState . update {
it . copy (
it . copy (
status = ConnectionStatus . ERROR ,
status = ConnectionStatus . ERROR ,
statusHint = t ( " session.hint.handshake_incomplete_response " )
statusHint = t ( " session.hint.handshake_incomplete_response " ) ,
isSwitchingServer = false ,
switchingServerLabel = " "
)
)
}
}
return
return
@ -752,7 +768,9 @@ object ChatSessionManager {
_uiState . update {
_uiState . update {
it . copy (
it . copy (
status = ConnectionStatus . ERROR ,
status = ConnectionStatus . ERROR ,
statusHint = t ( " session.hint.connection_timeout_retry " )
statusHint = t ( " session.hint.connection_timeout_retry " ) ,
isSwitchingServer = false ,
switchingServerLabel = " "
)
)
}
}
addSystemMessage ( t ( " session.msg.auth_timeout " ) )
addSystemMessage ( t ( " session.msg.auth_timeout " ) )
@ -769,7 +787,9 @@ object ChatSessionManager {
_uiState . update {
_uiState . update {
it . copy (
it . copy (
status = ConnectionStatus . ERROR ,
status = ConnectionStatus . ERROR ,
statusHint = t ( " session.hint.auth_failed " )
statusHint = t ( " session.hint.auth_failed " ) ,
isSwitchingServer = false ,
switchingServerLabel = " "
)
)
}
}
addSystemMessage (
addSystemMessage (
@ -846,7 +866,9 @@ object ChatSessionManager {
_uiState . update {
_uiState . update {
it . copy (
it . copy (
status = ConnectionStatus . READY ,
status = ConnectionStatus . READY ,
statusHint = t ( " session.hint.ready_to_chat " )
statusHint = t ( " session.hint.ready_to_chat " ) ,
isSwitchingServer = false ,
switchingServerLabel = " "
)
)
}
}
addSystemMessage ( t ( " session.msg.ready " ) )
addSystemMessage ( t ( " session.msg.ready " ) )
@ -955,7 +977,9 @@ object ChatSessionManager {
statusHint = tf (
statusHint = tf (
" session.hint.server_rejected " ,
" session.hint.server_rejected " ,
reason . ifBlank { t ( " session.text.policy_restriction " ) }
reason . ifBlank { t ( " session.text.policy_restriction " ) }
)
) ,
isSwitchingServer = false ,
switchingServerLabel = " "
)
)
}
}
addSystemMessage (
addSystemMessage (
@ -998,7 +1022,9 @@ object ChatSessionManager {
_uiState . update {
_uiState . update {
it . copy (
it . copy (
status = ConnectionStatus . ERROR ,
status = ConnectionStatus . ERROR ,
statusHint = t ( " session.hint.connection_interrupted_retry " )
statusHint = t ( " session.hint.connection_interrupted_retry " ) ,
isSwitchingServer = false ,
switchingServerLabel = " "
)
)
}
}
addSystemMessage (
addSystemMessage (
@ -1284,11 +1310,78 @@ object ChatSessionManager {
if ( message . role == MessageRole . SYSTEM ) return
if ( message . role == MessageRole . SYSTEM ) return
scope . launch ( Dispatchers . IO ) {
scope . launch ( Dispatchers . IO ) {
runCatching {
runCatching {
historyRepository . appendMessage ( message , MAX _MESSAGES )
historyRepository . appendMessage ( currentServerKey ( ) , message , MAX _MESSAGES )
}
}
}
private fun switchServer (
normalized : String ,
nextUrls : List < String > ,
statusHint : String ? ,
persist : suspend ( ) -> Unit ,
snackbarMessage : String ? = null
) {
val targetServerKey = serverKeyFor ( normalized )
val previousServerKey = currentServerKey ( )
val shouldReconnect = previousServerKey != targetServerKey || _uiState . value . status != ConnectionStatus . READY
val switchingLabel = summarizeServerLabel ( normalized )
scope . launch {
_uiState . update {
it . copy (
serverUrl = normalized ,
serverUrls = nextUrls ,
isSwitchingServer = shouldReconnect ,
switchingServerLabel = if ( shouldReconnect ) switchingLabel else " " ,
statusHint = statusHint ?: it . statusHint
)
}
persist ( )
val historyMessages = withContext ( Dispatchers . IO ) {
historyRepository . loadMessages ( targetServerKey , MAX _MESSAGES )
}
_uiState . update {
it . copy (
serverUrl = normalized ,
serverUrls = nextUrls ,
messages = historyMessages ,
certFingerprint = if ( previousServerKey == targetServerKey ) it . certFingerprint else " " ,
statusHint = statusHint ?: it . statusHint
)
}
if ( shouldReconnect ) {
connectInternal (
isAutoRestore = false ,
overrideUrl = normalized ,
forceReconnect = ! _uiState . value . canConnect
)
}
if ( ! snackbarMessage . isNullOrBlank ( ) ) {
_events . emit ( UiEvent . ShowSnackbar ( snackbarMessage ) )
}
}
}
}
}
}
private fun currentServerKey ( ) : String = serverKeyFor ( _uiState . value . serverUrl )
private fun serverKeyFor ( rawUrl : String ) : String {
return ServerUrlFormatter . normalize ( rawUrl ) . ifBlank { ServerUrlFormatter . defaultServerUrl }
}
private fun summarizeServerLabel ( rawUrl : String ) : String {
val normalized = serverKeyFor ( rawUrl )
val parsed = runCatching { Uri . parse ( normalized ) } . getOrNull ( )
val host = parsed ?. host
val port = parsed ?. port ?. takeIf { it > 0 }
val path = parsed ?. encodedPath ?. takeIf { ! it . isNullOrBlank ( ) && it != " / " }
return buildString {
append ( host ?: normalized )
if ( port != null ) append ( " : $port " )
if ( path != null ) append ( path )
} . ifBlank { normalized }
}
/ * *
/ * *
* 取消认证超时任务 。
* 取消认证超时任务 。
* /
* /
@ -1392,7 +1485,9 @@ object ChatSessionManager {
_uiState . update {
_uiState . update {
it . copy (
it . copy (
status = ConnectionStatus . ERROR ,
status = ConnectionStatus . ERROR ,
statusHint = t ( " session.hint.handshake_timeout " )
statusHint = t ( " session.hint.handshake_timeout " ) ,
isSwitchingServer = false ,
switchingServerLabel = " "
)
)
}
}
addSystemMessage ( tf ( " session.msg.handshake_timeout_with_url " , currentUrl ) )
addSystemMessage ( tf ( " session.msg.handshake_timeout_with_url " , currentUrl ) )
@ -1485,7 +1580,7 @@ object ChatSessionManager {
private fun ensureMessageNotificationChannel ( soundCode : String = " default " ) {
private fun ensureMessageNotificationChannel ( soundCode : String = " default " ) {
if ( Build . VERSION . SDK _INT < Build . VERSION_CODES . O ) return
if ( Build . VERSION . SDK _INT < Build . VERSION_CODES . O ) return
val manager = app . getSystemService ( Context . NOTIFICATION _SERVICE ) as NotificationManager
val manager = app . getSystemService ( Context . NOTIFICATION _SERVICE ) as NotificationManager
val channelId = " ${MESSAGE_CHANNEL_ID} _ $soundCode "
val channelId = NotificationSoundCatalog . channelId ( soundCode )
if ( manager . getNotificationChannel ( channelId ) != null ) return
if ( manager . getNotificationChannel ( channelId ) != null ) return
val channel = NotificationChannel (
val channel = NotificationChannel (
@ -1508,13 +1603,7 @@ object ChatSessionManager {
}
}
private fun getSoundUri ( code : String ) : Uri ? {
private fun getSoundUri ( code : String ) : Uri ? {
val resId = when ( code ) {
val resId = NotificationSoundCatalog . resId ( code ) ?: return null
" 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 " )
return Uri . parse ( " ${ContentResolver.SCHEME_ANDROID_RESOURCE} :// ${app.packageName} / $resId " )
}
}
@ -1540,7 +1629,7 @@ object ChatSessionManager {
launchIntent ,
launchIntent ,
PendingIntent . FLAG _UPDATE _CURRENT or PendingIntent . FLAG _IMMUTABLE
PendingIntent . FLAG _UPDATE _CURRENT or PendingIntent . FLAG _IMMUTABLE
)
)
val channelId = " ${MESSAGE_CHANNEL_ID} _ ${_uiState.value.notificationSound} "
val channelId = NotificationSoundCatalog . channelId ( _uiState . value . notificationSound )
ensureMessageNotificationChannel ( _uiState . value . notificationSound )
ensureMessageNotificationChannel ( _uiState . value . notificationSound )
val notification = NotificationCompat . Builder ( app , channelId )
val notification = NotificationCompat . Builder ( app , channelId )
@ -1589,7 +1678,6 @@ object ChatSessionManager {
private const val MAX _MESSAGES = 500
private const val MAX _MESSAGES = 500
private const val MAX _RECONNECT _DELAY _SECONDS = 30
private const val MAX _RECONNECT _DELAY _SECONDS = 30
private const val SYSTEM _MESSAGE _TTL _MS = 1 _000L
private const val SYSTEM _MESSAGE _TTL _MS = 1 _000L
private const val MESSAGE _CHANNEL _ID = " onlinemsg_messages "
private const val AUDIO _MESSAGE _PREFIX = " [[OMS_AUDIO_V1]] "
private const val AUDIO _MESSAGE _PREFIX = " [[OMS_AUDIO_V1]] "
private const val AUDIO _CHUNK _MESSAGE _PREFIX = " [[OMS_AUDIO_CHUNK_V1]] "
private const val AUDIO _CHUNK _MESSAGE _PREFIX = " [[OMS_AUDIO_CHUNK_V1]] "
private const val AUDIO _CHUNK _BASE64 _SIZE = 20 _000
private const val AUDIO _CHUNK _BASE64 _SIZE = 20 _000