fix(messaging): keyboard layout, message delivery ACK, background foreground service

This commit is contained in:
Jens Reinemann 2026-05-18 19:26:27 +02:00
parent a6cc4ca4bf
commit 37fd66a417
11 changed files with 157 additions and 4 deletions

View file

@ -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>

View file

@ -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

View file

@ -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 {

View file

@ -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 {

View file

@ -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))
}
}
}

View file

@ -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

View file

@ -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)
} }
} }

View file

@ -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),

View file

@ -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,

View file

@ -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))
} }

View file

@ -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 }