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 }