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.POST_NOTIFICATIONS" />
<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
android:name=".BollwerkApp"
@ -35,6 +37,11 @@
android:resource="@xml/file_paths" />
</provider>
<service
android:name=".notification.MessagingService"
android:foregroundServiceType="dataSync"
android:exported="false" />
</application>
</manifest>

View file

@ -93,6 +93,7 @@ internal class MessageRepositoryImpl @Inject constructor(
isRead = false
)
)
webSocketClient.ackMessage(msg.id)
notificationHelper.showNewMessageNotification(
senderId = msg.senderId,
senderUsername = msg.senderUsername

View file

@ -9,6 +9,7 @@ internal interface WebSocketClient {
val connectionState: StateFlow<ConnectionState>
fun connect(serverUrl: String, accessToken: String)
fun disconnect()
fun ackMessage(messageId: String)
}
internal sealed interface WebSocketEvent {

View file

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

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

View file

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

View file

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

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

View file

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

View file

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