From 37fd66a4173deb06b1dbaa0242b63eb10545c816 Mon Sep 17 00:00:00 2001 From: Jens Reinemann Date: Mon, 18 May 2026 19:26:27 +0200 Subject: [PATCH] fix(messaging): keyboard layout, message delivery ACK, background foreground service --- app/src/main/AndroidManifest.xml | 7 ++ .../data/repository/MessageRepositoryImpl.kt | 1 + .../bollwerk/app/data/sync/WebSocketClient.kt | 1 + .../app/data/sync/WebSocketClientImpl.kt | 16 ++++ .../app/notification/MessagingService.kt | 91 +++++++++++++++++++ .../app/notification/NotificationHelper.kt | 13 +++ .../java/de/bollwerk/app/ui/MainViewModel.kt | 7 ++ .../bollwerk/app/ui/messaging/ChatScreen.kt | 2 + .../app/ui/settings/SettingsViewModel.kt | 2 + .../bollwerk/server/routes/MessageRoutes.kt | 3 - .../bollwerk/server/routes/WebSocketRoutes.kt | 18 +++- 11 files changed, 157 insertions(+), 4 deletions(-) create mode 100644 app/src/main/java/de/bollwerk/app/notification/MessagingService.kt diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index f4a8039..adf0443 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -4,6 +4,8 @@ + + + + diff --git a/app/src/main/java/de/bollwerk/app/data/repository/MessageRepositoryImpl.kt b/app/src/main/java/de/bollwerk/app/data/repository/MessageRepositoryImpl.kt index 7aebb8b..eb7775a 100644 --- a/app/src/main/java/de/bollwerk/app/data/repository/MessageRepositoryImpl.kt +++ b/app/src/main/java/de/bollwerk/app/data/repository/MessageRepositoryImpl.kt @@ -93,6 +93,7 @@ internal class MessageRepositoryImpl @Inject constructor( isRead = false ) ) + webSocketClient.ackMessage(msg.id) notificationHelper.showNewMessageNotification( senderId = msg.senderId, senderUsername = msg.senderUsername diff --git a/app/src/main/java/de/bollwerk/app/data/sync/WebSocketClient.kt b/app/src/main/java/de/bollwerk/app/data/sync/WebSocketClient.kt index b681bc2..a5b5e7f 100644 --- a/app/src/main/java/de/bollwerk/app/data/sync/WebSocketClient.kt +++ b/app/src/main/java/de/bollwerk/app/data/sync/WebSocketClient.kt @@ -9,6 +9,7 @@ internal interface WebSocketClient { val connectionState: StateFlow fun connect(serverUrl: String, accessToken: String) fun disconnect() + fun ackMessage(messageId: String) } internal sealed interface WebSocketEvent { diff --git a/app/src/main/java/de/bollwerk/app/data/sync/WebSocketClientImpl.kt b/app/src/main/java/de/bollwerk/app/data/sync/WebSocketClientImpl.kt index 8c9c298..18e429f 100644 --- a/app/src/main/java/de/bollwerk/app/data/sync/WebSocketClientImpl.kt +++ b/app/src/main/java/de/bollwerk/app/data/sync/WebSocketClientImpl.kt @@ -22,6 +22,7 @@ import kotlinx.coroutines.flow.SharedFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.isActive import kotlinx.coroutines.launch import kotlinx.serialization.Serializable @@ -42,6 +43,7 @@ internal class WebSocketClientImpl @Inject constructor() : WebSocketClient { private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob()) private var connectionJob: Job? = null private var countdownJob: Job? = null + private val _ackChannel = Channel(Channel.UNLIMITED) private val wsHttpClient = HttpClient(OkHttp) { install(WebSockets) @@ -71,6 +73,15 @@ internal class WebSocketClientImpl @Inject constructor() : WebSocketClient { _connectionState.value = ConnectionState.Connected Log.i(TAG, "WebSocket: Verbunden mit $wsUrl/ws/sync") _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) { if (frame is Frame.Text) { val text = frame.readText() @@ -78,6 +89,7 @@ internal class WebSocketClientImpl @Inject constructor() : WebSocketClient { handleFrame(text) } } + ackSenderJob.cancel() val reason = closeReason.await() val durationMs = System.currentTimeMillis() - connectedAt 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) } } + override fun ackMessage(messageId: String) { + _ackChannel.trySend(messageId) + } + private fun startCountdown(totalDelayMs: Long) { countdownJob?.cancel() countdownJob = scope.launch { diff --git a/app/src/main/java/de/bollwerk/app/notification/MessagingService.kt b/app/src/main/java/de/bollwerk/app/notification/MessagingService.kt new file mode 100644 index 0000000..489a247 --- /dev/null +++ b/app/src/main/java/de/bollwerk/app/notification/MessagingService.kt @@ -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)) + } + } +} diff --git a/app/src/main/java/de/bollwerk/app/notification/NotificationHelper.kt b/app/src/main/java/de/bollwerk/app/notification/NotificationHelper.kt index 65a02ac..9d03843 100644 --- a/app/src/main/java/de/bollwerk/app/notification/NotificationHelper.kt +++ b/app/src/main/java/de/bollwerk/app/notification/NotificationHelper.kt @@ -75,8 +75,19 @@ internal class NotificationHelper @Inject constructor( 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) manager.createNotificationChannel(channel) + manager.createNotificationChannel(serviceChannel) } /// Setzt die aktuell sichtbare Chat-Konversation (unterdrückt Notifications für diesen Partner). @@ -255,6 +266,8 @@ internal class NotificationHelper @Inject constructor( companion object { const val CHANNEL_ID = "bollwerk_messages" 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 SUMMARY_NOTIFICATION_ID = 0 diff --git a/app/src/main/java/de/bollwerk/app/ui/MainViewModel.kt b/app/src/main/java/de/bollwerk/app/ui/MainViewModel.kt index 215c20c..d7515e3 100644 --- a/app/src/main/java/de/bollwerk/app/ui/MainViewModel.kt +++ b/app/src/main/java/de/bollwerk/app/ui/MainViewModel.kt @@ -1,9 +1,11 @@ package de.bollwerk.app.ui +import android.content.Context import android.util.Log import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope 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.WebSocketEvent 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.SettingsRepository import de.bollwerk.app.domain.repository.SyncService +import de.bollwerk.app.notification.MessagingService import de.bollwerk.app.notification.NotificationHelper import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.SharedFlow @@ -25,6 +28,7 @@ import javax.inject.Inject @HiltViewModel internal class MainViewModel @Inject constructor( + @ApplicationContext private val context: Context, private val authEventBus: AuthEventBus, private val syncService: SyncService, private val webSocketClient: WebSocketClient, @@ -58,6 +62,7 @@ internal class MainViewModel @Inject constructor( val serverUrl = settingsRepository.getString(StringKey.ServerUrl) if (token.isNotBlank() && serverUrl.isNotBlank()) { Log.i(TAG, "App-Start: Token vorhanden – verbinde WebSocket") + MessagingService.start(context) webSocketClient.connect(serverUrl, token) } else { Log.d(TAG, "App-Start: Kein Token – kein WebSocket") @@ -70,6 +75,7 @@ internal class MainViewModel @Inject constructor( viewModelScope.launch { authEventBus.loginSuccess.collect { (serverUrl, token) -> Log.i(TAG, "Login erfolgreich – verbinde WebSocket") + MessagingService.start(context) webSocketClient.connect(serverUrl, token) } } @@ -82,6 +88,7 @@ internal class MainViewModel @Inject constructor( Log.w(TAG, "Session abgelaufen – Forced-Logout") syncService.logout() webSocketClient.disconnect() + MessagingService.stop(context) _navigateToSettings.emit(Unit) } } diff --git a/app/src/main/java/de/bollwerk/app/ui/messaging/ChatScreen.kt b/app/src/main/java/de/bollwerk/app/ui/messaging/ChatScreen.kt index 0ff3f24..a3bf70c 100644 --- a/app/src/main/java/de/bollwerk/app/ui/messaging/ChatScreen.kt +++ b/app/src/main/java/de/bollwerk/app/ui/messaging/ChatScreen.kt @@ -7,6 +7,7 @@ import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.imePadding import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.widthIn @@ -74,6 +75,7 @@ internal fun ChatScreen( modifier = Modifier .fillMaxSize() .padding(padding) + .imePadding() ) { LazyColumn( modifier = Modifier.weight(1f), diff --git a/app/src/main/java/de/bollwerk/app/ui/settings/SettingsViewModel.kt b/app/src/main/java/de/bollwerk/app/ui/settings/SettingsViewModel.kt index 5e52629..63ce673 100644 --- a/app/src/main/java/de/bollwerk/app/ui/settings/SettingsViewModel.kt +++ b/app/src/main/java/de/bollwerk/app/ui/settings/SettingsViewModel.kt @@ -21,6 +21,7 @@ import de.bollwerk.app.domain.model.toJson import de.bollwerk.app.domain.repository.ImportExportRepository import de.bollwerk.app.domain.repository.SettingsRepository import de.bollwerk.app.domain.repository.SyncService +import de.bollwerk.app.notification.MessagingService import kotlinx.coroutines.Job import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow @@ -230,6 +231,7 @@ internal class SettingsViewModel @Inject constructor( viewModelScope.launch { syncService.logout() webSocketClient.disconnect() + MessagingService.stop(context) _uiState.update { it.copy( isLoggedIn = false, diff --git a/server/src/main/kotlin/de/bollwerk/server/routes/MessageRoutes.kt b/server/src/main/kotlin/de/bollwerk/server/routes/MessageRoutes.kt index 7e6b519..1dac82b 100644 --- a/server/src/main/kotlin/de/bollwerk/server/routes/MessageRoutes.kt +++ b/server/src/main/kotlin/de/bollwerk/server/routes/MessageRoutes.kt @@ -74,9 +74,6 @@ internal fun Route.messageRoutes( sentAt = request.sentAt ) wsManager.notifyNewMessage(request.receiverId, message) - if (wsManager.isOnline(request.receiverId)) { - messageRepository.markDelivered(msgId) - } call.respond(HttpStatusCode.Created, SendMessageResponse(message = message, systemMessage = systemMessage)) } diff --git a/server/src/main/kotlin/de/bollwerk/server/routes/WebSocketRoutes.kt b/server/src/main/kotlin/de/bollwerk/server/routes/WebSocketRoutes.kt index ebe6beb..eef6727 100644 --- a/server/src/main/kotlin/de/bollwerk/server/routes/WebSocketRoutes.kt +++ b/server/src/main/kotlin/de/bollwerk/server/routes/WebSocketRoutes.kt @@ -6,11 +6,15 @@ import de.bollwerk.server.websocket.WebSocketManager import io.ktor.server.routing.* import io.ktor.server.websocket.* import io.ktor.websocket.* +import kotlinx.serialization.Serializable import kotlinx.serialization.encodeToString import kotlinx.serialization.json.Json import kotlinx.serialization.json.buildJsonObject import kotlinx.serialization.json.put +@Serializable +private data class ClientAckFrame(val type: String, val messageId: String? = null) + internal fun Route.webSocketRoutes( wsManager: WebSocketManager, jwtService: JwtService, @@ -49,10 +53,22 @@ internal fun Route.webSocketRoutes( } try { 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(text) + if (ack.type == "ack" && ack.messageId != null) { + messageRepository.markDelivered(ack.messageId) + } + } catch (_: Exception) { + // Unknown frame type – ignore + } + } } } finally { wsManager.removeSession(userId, this) } } } + +private val ackJson = Json { ignoreUnknownKeys = true }