fix(messaging): keyboard layout, message delivery ACK, background foreground service
This commit is contained in:
parent
a6cc4ca4bf
commit
37fd66a417
11 changed files with 157 additions and 4 deletions
|
|
@ -4,6 +4,8 @@
|
||||||
<uses-permission android:name="android.permission.INTERNET" />
|
<uses-permission android:name="android.permission.INTERNET" />
|
||||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||||
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
|
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
|
||||||
|
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
||||||
|
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
|
||||||
|
|
||||||
<application
|
<application
|
||||||
android:name=".BollwerkApp"
|
android:name=".BollwerkApp"
|
||||||
|
|
@ -35,6 +37,11 @@
|
||||||
android:resource="@xml/file_paths" />
|
android:resource="@xml/file_paths" />
|
||||||
</provider>
|
</provider>
|
||||||
|
|
||||||
|
<service
|
||||||
|
android:name=".notification.MessagingService"
|
||||||
|
android:foregroundServiceType="dataSync"
|
||||||
|
android:exported="false" />
|
||||||
|
|
||||||
</application>
|
</application>
|
||||||
|
|
||||||
</manifest>
|
</manifest>
|
||||||
|
|
|
||||||
|
|
@ -93,6 +93,7 @@ internal class MessageRepositoryImpl @Inject constructor(
|
||||||
isRead = false
|
isRead = false
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
webSocketClient.ackMessage(msg.id)
|
||||||
notificationHelper.showNewMessageNotification(
|
notificationHelper.showNewMessageNotification(
|
||||||
senderId = msg.senderId,
|
senderId = msg.senderId,
|
||||||
senderUsername = msg.senderUsername
|
senderUsername = msg.senderUsername
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@ internal interface WebSocketClient {
|
||||||
val connectionState: StateFlow<ConnectionState>
|
val connectionState: StateFlow<ConnectionState>
|
||||||
fun connect(serverUrl: String, accessToken: String)
|
fun connect(serverUrl: String, accessToken: String)
|
||||||
fun disconnect()
|
fun disconnect()
|
||||||
|
fun ackMessage(messageId: String)
|
||||||
}
|
}
|
||||||
|
|
||||||
internal sealed interface WebSocketEvent {
|
internal sealed interface WebSocketEvent {
|
||||||
|
|
|
||||||
|
|
@ -22,6 +22,7 @@ import kotlinx.coroutines.flow.SharedFlow
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
import kotlinx.coroutines.flow.asSharedFlow
|
import kotlinx.coroutines.flow.asSharedFlow
|
||||||
import kotlinx.coroutines.flow.asStateFlow
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
|
import kotlinx.coroutines.channels.Channel
|
||||||
import kotlinx.coroutines.isActive
|
import kotlinx.coroutines.isActive
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.serialization.Serializable
|
import kotlinx.serialization.Serializable
|
||||||
|
|
@ -42,6 +43,7 @@ internal class WebSocketClientImpl @Inject constructor() : WebSocketClient {
|
||||||
private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob())
|
private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob())
|
||||||
private var connectionJob: Job? = null
|
private var connectionJob: Job? = null
|
||||||
private var countdownJob: Job? = null
|
private var countdownJob: Job? = null
|
||||||
|
private val _ackChannel = Channel<String>(Channel.UNLIMITED)
|
||||||
|
|
||||||
private val wsHttpClient = HttpClient(OkHttp) {
|
private val wsHttpClient = HttpClient(OkHttp) {
|
||||||
install(WebSockets)
|
install(WebSockets)
|
||||||
|
|
@ -71,6 +73,15 @@ internal class WebSocketClientImpl @Inject constructor() : WebSocketClient {
|
||||||
_connectionState.value = ConnectionState.Connected
|
_connectionState.value = ConnectionState.Connected
|
||||||
Log.i(TAG, "WebSocket: Verbunden mit $wsUrl/ws/sync")
|
Log.i(TAG, "WebSocket: Verbunden mit $wsUrl/ws/sync")
|
||||||
_events.emit(WebSocketEvent.Connected)
|
_events.emit(WebSocketEvent.Connected)
|
||||||
|
val ackSenderJob = launch {
|
||||||
|
for (msgId in _ackChannel) {
|
||||||
|
try {
|
||||||
|
send(Frame.Text("""{"type":"ack","messageId":"$msgId"}"""))
|
||||||
|
} catch (_: Exception) {
|
||||||
|
// Connection closed – ACK will be retried on reconnect via undelivered queue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
for (frame in incoming) {
|
for (frame in incoming) {
|
||||||
if (frame is Frame.Text) {
|
if (frame is Frame.Text) {
|
||||||
val text = frame.readText()
|
val text = frame.readText()
|
||||||
|
|
@ -78,6 +89,7 @@ internal class WebSocketClientImpl @Inject constructor() : WebSocketClient {
|
||||||
handleFrame(text)
|
handleFrame(text)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
ackSenderJob.cancel()
|
||||||
val reason = closeReason.await()
|
val reason = closeReason.await()
|
||||||
val durationMs = System.currentTimeMillis() - connectedAt
|
val durationMs = System.currentTimeMillis() - connectedAt
|
||||||
Log.i(TAG, "WebSocket: Session beendet nach ${durationMs}ms – Reason: ${reason?.message}")
|
Log.i(TAG, "WebSocket: Session beendet nach ${durationMs}ms – Reason: ${reason?.message}")
|
||||||
|
|
@ -128,6 +140,10 @@ internal class WebSocketClientImpl @Inject constructor() : WebSocketClient {
|
||||||
scope.launch { _events.emit(WebSocketEvent.Disconnected) }
|
scope.launch { _events.emit(WebSocketEvent.Disconnected) }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override fun ackMessage(messageId: String) {
|
||||||
|
_ackChannel.trySend(messageId)
|
||||||
|
}
|
||||||
|
|
||||||
private fun startCountdown(totalDelayMs: Long) {
|
private fun startCountdown(totalDelayMs: Long) {
|
||||||
countdownJob?.cancel()
|
countdownJob?.cancel()
|
||||||
countdownJob = scope.launch {
|
countdownJob = scope.launch {
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,91 @@
|
||||||
|
package de.bollwerk.app.notification
|
||||||
|
|
||||||
|
import android.app.Notification
|
||||||
|
import android.app.PendingIntent
|
||||||
|
import android.app.Service
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
import android.os.Build
|
||||||
|
import android.os.IBinder
|
||||||
|
import androidx.core.app.NotificationCompat
|
||||||
|
import dagger.hilt.android.AndroidEntryPoint
|
||||||
|
import de.bollwerk.app.MainActivity
|
||||||
|
import de.bollwerk.app.R
|
||||||
|
import de.bollwerk.app.data.sync.WebSocketClient
|
||||||
|
import de.bollwerk.app.domain.model.SettingsKey.StringKey
|
||||||
|
import de.bollwerk.app.domain.repository.SettingsRepository
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.SupervisorJob
|
||||||
|
import kotlinx.coroutines.cancel
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import javax.inject.Inject
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Foreground service that keeps the app process alive so the WebSocket connection
|
||||||
|
* can receive incoming messages even when the app is not visible.
|
||||||
|
* The actual WebSocket reconnect logic lives in WebSocketClientImpl (singleton scope).
|
||||||
|
*/
|
||||||
|
@AndroidEntryPoint
|
||||||
|
internal class MessagingService : Service() {
|
||||||
|
|
||||||
|
@Inject lateinit var webSocketClient: WebSocketClient
|
||||||
|
@Inject lateinit var settingsRepository: SettingsRepository
|
||||||
|
|
||||||
|
private val serviceScope = CoroutineScope(Dispatchers.IO + SupervisorJob())
|
||||||
|
|
||||||
|
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
||||||
|
startForeground(NOTIFICATION_ID, buildForegroundNotification())
|
||||||
|
serviceScope.launch {
|
||||||
|
val token = settingsRepository.getStringOrNull(StringKey.AuthAccessToken)
|
||||||
|
val serverUrl = settingsRepository.getStringOrNull(StringKey.ServerUrl)
|
||||||
|
if (!token.isNullOrBlank() && !serverUrl.isNullOrBlank()) {
|
||||||
|
webSocketClient.connect(serverUrl, token)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return START_STICKY
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onDestroy() {
|
||||||
|
super.onDestroy()
|
||||||
|
serviceScope.cancel()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onBind(intent: Intent?): IBinder? = null
|
||||||
|
|
||||||
|
private fun buildForegroundNotification(): Notification {
|
||||||
|
val openAppIntent = PendingIntent.getActivity(
|
||||||
|
this, 0,
|
||||||
|
Intent(this, MainActivity::class.java).apply {
|
||||||
|
flags = Intent.FLAG_ACTIVITY_SINGLE_TOP
|
||||||
|
},
|
||||||
|
PendingIntent.FLAG_IMMUTABLE
|
||||||
|
)
|
||||||
|
return NotificationCompat.Builder(this, NotificationHelper.SERVICE_CHANNEL_ID)
|
||||||
|
.setContentTitle("Bollwerk")
|
||||||
|
.setContentText("Warten auf Nachrichten…")
|
||||||
|
.setSmallIcon(R.drawable.ic_notification_message)
|
||||||
|
.setOngoing(true)
|
||||||
|
.setSilent(true)
|
||||||
|
.setPriority(NotificationCompat.PRIORITY_MIN)
|
||||||
|
.setContentIntent(openAppIntent)
|
||||||
|
.build()
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private const val NOTIFICATION_ID = 9999
|
||||||
|
|
||||||
|
fun start(context: Context) {
|
||||||
|
val intent = Intent(context, MessagingService::class.java)
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||||
|
context.startForegroundService(intent)
|
||||||
|
} else {
|
||||||
|
context.startService(intent)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun stop(context: Context) {
|
||||||
|
context.stopService(Intent(context, MessagingService::class.java))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -75,8 +75,19 @@ internal class NotificationHelper @Inject constructor(
|
||||||
enableVibration(true)
|
enableVibration(true)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
val serviceChannel = NotificationChannel(
|
||||||
|
SERVICE_CHANNEL_ID,
|
||||||
|
SERVICE_CHANNEL_NAME,
|
||||||
|
NotificationManager.IMPORTANCE_MIN
|
||||||
|
).apply {
|
||||||
|
description = "Hintergrunddienst für Nachrichten"
|
||||||
|
setSound(null, null)
|
||||||
|
enableVibration(false)
|
||||||
|
}
|
||||||
|
|
||||||
val manager = context.getSystemService(NotificationManager::class.java)
|
val manager = context.getSystemService(NotificationManager::class.java)
|
||||||
manager.createNotificationChannel(channel)
|
manager.createNotificationChannel(channel)
|
||||||
|
manager.createNotificationChannel(serviceChannel)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Setzt die aktuell sichtbare Chat-Konversation (unterdrückt Notifications für diesen Partner).
|
/// Setzt die aktuell sichtbare Chat-Konversation (unterdrückt Notifications für diesen Partner).
|
||||||
|
|
@ -255,6 +266,8 @@ internal class NotificationHelper @Inject constructor(
|
||||||
companion object {
|
companion object {
|
||||||
const val CHANNEL_ID = "bollwerk_messages"
|
const val CHANNEL_ID = "bollwerk_messages"
|
||||||
private const val CHANNEL_NAME = "Chat-Nachrichten"
|
private const val CHANNEL_NAME = "Chat-Nachrichten"
|
||||||
|
const val SERVICE_CHANNEL_ID = "bollwerk_service"
|
||||||
|
private const val SERVICE_CHANNEL_NAME = "Nachrichtendienst"
|
||||||
private const val GROUP_KEY = "de.bollwerk.app.MESSAGES"
|
private const val GROUP_KEY = "de.bollwerk.app.MESSAGES"
|
||||||
private const val SUMMARY_NOTIFICATION_ID = 0
|
private const val SUMMARY_NOTIFICATION_ID = 0
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,11 @@
|
||||||
package de.bollwerk.app.ui
|
package de.bollwerk.app.ui
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
import androidx.lifecycle.ViewModel
|
import androidx.lifecycle.ViewModel
|
||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
|
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||||
import de.bollwerk.app.data.sync.WebSocketClient
|
import de.bollwerk.app.data.sync.WebSocketClient
|
||||||
import de.bollwerk.app.data.sync.WebSocketEvent
|
import de.bollwerk.app.data.sync.WebSocketEvent
|
||||||
import de.bollwerk.app.domain.AuthEventBus
|
import de.bollwerk.app.domain.AuthEventBus
|
||||||
|
|
@ -12,6 +14,7 @@ import de.bollwerk.app.domain.repository.ImportExportRepository
|
||||||
import de.bollwerk.app.domain.repository.MessageRepository
|
import de.bollwerk.app.domain.repository.MessageRepository
|
||||||
import de.bollwerk.app.domain.repository.SettingsRepository
|
import de.bollwerk.app.domain.repository.SettingsRepository
|
||||||
import de.bollwerk.app.domain.repository.SyncService
|
import de.bollwerk.app.domain.repository.SyncService
|
||||||
|
import de.bollwerk.app.notification.MessagingService
|
||||||
import de.bollwerk.app.notification.NotificationHelper
|
import de.bollwerk.app.notification.NotificationHelper
|
||||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||||
import kotlinx.coroutines.flow.SharedFlow
|
import kotlinx.coroutines.flow.SharedFlow
|
||||||
|
|
@ -25,6 +28,7 @@ import javax.inject.Inject
|
||||||
|
|
||||||
@HiltViewModel
|
@HiltViewModel
|
||||||
internal class MainViewModel @Inject constructor(
|
internal class MainViewModel @Inject constructor(
|
||||||
|
@ApplicationContext private val context: Context,
|
||||||
private val authEventBus: AuthEventBus,
|
private val authEventBus: AuthEventBus,
|
||||||
private val syncService: SyncService,
|
private val syncService: SyncService,
|
||||||
private val webSocketClient: WebSocketClient,
|
private val webSocketClient: WebSocketClient,
|
||||||
|
|
@ -58,6 +62,7 @@ internal class MainViewModel @Inject constructor(
|
||||||
val serverUrl = settingsRepository.getString(StringKey.ServerUrl)
|
val serverUrl = settingsRepository.getString(StringKey.ServerUrl)
|
||||||
if (token.isNotBlank() && serverUrl.isNotBlank()) {
|
if (token.isNotBlank() && serverUrl.isNotBlank()) {
|
||||||
Log.i(TAG, "App-Start: Token vorhanden – verbinde WebSocket")
|
Log.i(TAG, "App-Start: Token vorhanden – verbinde WebSocket")
|
||||||
|
MessagingService.start(context)
|
||||||
webSocketClient.connect(serverUrl, token)
|
webSocketClient.connect(serverUrl, token)
|
||||||
} else {
|
} else {
|
||||||
Log.d(TAG, "App-Start: Kein Token – kein WebSocket")
|
Log.d(TAG, "App-Start: Kein Token – kein WebSocket")
|
||||||
|
|
@ -70,6 +75,7 @@ internal class MainViewModel @Inject constructor(
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
authEventBus.loginSuccess.collect { (serverUrl, token) ->
|
authEventBus.loginSuccess.collect { (serverUrl, token) ->
|
||||||
Log.i(TAG, "Login erfolgreich – verbinde WebSocket")
|
Log.i(TAG, "Login erfolgreich – verbinde WebSocket")
|
||||||
|
MessagingService.start(context)
|
||||||
webSocketClient.connect(serverUrl, token)
|
webSocketClient.connect(serverUrl, token)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -82,6 +88,7 @@ internal class MainViewModel @Inject constructor(
|
||||||
Log.w(TAG, "Session abgelaufen – Forced-Logout")
|
Log.w(TAG, "Session abgelaufen – Forced-Logout")
|
||||||
syncService.logout()
|
syncService.logout()
|
||||||
webSocketClient.disconnect()
|
webSocketClient.disconnect()
|
||||||
|
MessagingService.stop(context)
|
||||||
_navigateToSettings.emit(Unit)
|
_navigateToSettings.emit(Unit)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@ import androidx.compose.foundation.layout.Row
|
||||||
import androidx.compose.foundation.layout.Spacer
|
import androidx.compose.foundation.layout.Spacer
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.imePadding
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.layout.width
|
import androidx.compose.foundation.layout.width
|
||||||
import androidx.compose.foundation.layout.widthIn
|
import androidx.compose.foundation.layout.widthIn
|
||||||
|
|
@ -74,6 +75,7 @@ internal fun ChatScreen(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
.padding(padding)
|
.padding(padding)
|
||||||
|
.imePadding()
|
||||||
) {
|
) {
|
||||||
LazyColumn(
|
LazyColumn(
|
||||||
modifier = Modifier.weight(1f),
|
modifier = Modifier.weight(1f),
|
||||||
|
|
|
||||||
|
|
@ -21,6 +21,7 @@ import de.bollwerk.app.domain.model.toJson
|
||||||
import de.bollwerk.app.domain.repository.ImportExportRepository
|
import de.bollwerk.app.domain.repository.ImportExportRepository
|
||||||
import de.bollwerk.app.domain.repository.SettingsRepository
|
import de.bollwerk.app.domain.repository.SettingsRepository
|
||||||
import de.bollwerk.app.domain.repository.SyncService
|
import de.bollwerk.app.domain.repository.SyncService
|
||||||
|
import de.bollwerk.app.notification.MessagingService
|
||||||
import kotlinx.coroutines.Job
|
import kotlinx.coroutines.Job
|
||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.delay
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
|
@ -230,6 +231,7 @@ internal class SettingsViewModel @Inject constructor(
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
syncService.logout()
|
syncService.logout()
|
||||||
webSocketClient.disconnect()
|
webSocketClient.disconnect()
|
||||||
|
MessagingService.stop(context)
|
||||||
_uiState.update {
|
_uiState.update {
|
||||||
it.copy(
|
it.copy(
|
||||||
isLoggedIn = false,
|
isLoggedIn = false,
|
||||||
|
|
|
||||||
|
|
@ -74,9 +74,6 @@ internal fun Route.messageRoutes(
|
||||||
sentAt = request.sentAt
|
sentAt = request.sentAt
|
||||||
)
|
)
|
||||||
wsManager.notifyNewMessage(request.receiverId, message)
|
wsManager.notifyNewMessage(request.receiverId, message)
|
||||||
if (wsManager.isOnline(request.receiverId)) {
|
|
||||||
messageRepository.markDelivered(msgId)
|
|
||||||
}
|
|
||||||
call.respond(HttpStatusCode.Created, SendMessageResponse(message = message, systemMessage = systemMessage))
|
call.respond(HttpStatusCode.Created, SendMessageResponse(message = message, systemMessage = systemMessage))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -6,11 +6,15 @@ import de.bollwerk.server.websocket.WebSocketManager
|
||||||
import io.ktor.server.routing.*
|
import io.ktor.server.routing.*
|
||||||
import io.ktor.server.websocket.*
|
import io.ktor.server.websocket.*
|
||||||
import io.ktor.websocket.*
|
import io.ktor.websocket.*
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
import kotlinx.serialization.encodeToString
|
import kotlinx.serialization.encodeToString
|
||||||
import kotlinx.serialization.json.Json
|
import kotlinx.serialization.json.Json
|
||||||
import kotlinx.serialization.json.buildJsonObject
|
import kotlinx.serialization.json.buildJsonObject
|
||||||
import kotlinx.serialization.json.put
|
import kotlinx.serialization.json.put
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
private data class ClientAckFrame(val type: String, val messageId: String? = null)
|
||||||
|
|
||||||
internal fun Route.webSocketRoutes(
|
internal fun Route.webSocketRoutes(
|
||||||
wsManager: WebSocketManager,
|
wsManager: WebSocketManager,
|
||||||
jwtService: JwtService,
|
jwtService: JwtService,
|
||||||
|
|
@ -49,10 +53,22 @@ internal fun Route.webSocketRoutes(
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
for (frame in incoming) {
|
for (frame in incoming) {
|
||||||
// Client frames are accepted but not processed (server push only)
|
if (frame is Frame.Text) {
|
||||||
|
val text = frame.readText()
|
||||||
|
try {
|
||||||
|
val ack = ackJson.decodeFromString<ClientAckFrame>(text)
|
||||||
|
if (ack.type == "ack" && ack.messageId != null) {
|
||||||
|
messageRepository.markDelivered(ack.messageId)
|
||||||
|
}
|
||||||
|
} catch (_: Exception) {
|
||||||
|
// Unknown frame type – ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
wsManager.removeSession(userId, this)
|
wsManager.removeSession(userId, this)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private val ackJson = Json { ignoreUnknownKeys = true }
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue