feat: foreground service for background message notifications

This commit is contained in:
Jens Reinemann 2026-05-18 13:45:06 +02:00
parent 38394c6350
commit c39bc5e485
4 changed files with 104 additions and 0 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"
@ -34,6 +36,11 @@
android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/file_paths" />
</provider>
<service
android:name=".service.MessagingService"
android:exported="false"
android:foregroundServiceType="dataSync" />
</application>
</manifest>

View file

@ -0,0 +1,88 @@
package de.bollwerk.app.service
import android.app.Notification
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.app.Service
import android.content.Context
import android.content.Intent
import android.content.pm.ServiceInfo
import android.os.IBinder
import androidx.core.app.NotificationCompat
import de.bollwerk.app.MainActivity
import de.bollwerk.app.R
/**
* Foreground Service der den Prozess am Leben hält, damit die WebSocket-Verbindung
* auch im Hintergrund bestehen bleibt und Nachrichten empfangen werden können.
*/
class MessagingService : Service() {
override fun onCreate() {
super.onCreate()
createServiceChannel()
}
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
when (intent?.action) {
ACTION_STOP -> {
stopForeground(STOP_FOREGROUND_REMOVE)
stopSelf()
return START_NOT_STICKY
}
}
startForeground(NOTIFICATION_ID, buildNotification(), ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC)
return START_STICKY
}
override fun onBind(intent: Intent?): IBinder? = null
private fun createServiceChannel() {
val channel = NotificationChannel(
CHANNEL_ID,
"Verbindung",
NotificationManager.IMPORTANCE_LOW
).apply {
description = "Hält die Verbindung zum Server für Nachrichten aufrecht"
setShowBadge(false)
}
val manager = getSystemService(NotificationManager::class.java)
manager.createNotificationChannel(channel)
}
private fun buildNotification(): Notification {
val openIntent = PendingIntent.getActivity(
this,
0,
Intent(this, MainActivity::class.java),
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
)
return NotificationCompat.Builder(this, CHANNEL_ID)
.setSmallIcon(R.drawable.ic_notification_message)
.setContentTitle("Bollwerk")
.setContentText("Verbunden Nachrichten werden empfangen")
.setOngoing(true)
.setContentIntent(openIntent)
.build()
}
companion object {
private const val CHANNEL_ID = "bollwerk_service"
private const val NOTIFICATION_ID = 9001
const val ACTION_STOP = "de.bollwerk.app.STOP_MESSAGING_SERVICE"
fun start(context: Context) {
val intent = Intent(context, MessagingService::class.java)
context.startForegroundService(intent)
}
fun stop(context: Context) {
val intent = Intent(context, MessagingService::class.java).apply {
action = ACTION_STOP
}
context.startService(intent)
}
}
}

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
@ -11,6 +13,7 @@ import de.bollwerk.app.domain.model.SettingsKey.StringKey
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.service.MessagingService
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.asSharedFlow
@ -25,6 +28,7 @@ internal class MainViewModel @Inject constructor(
private val webSocketClient: WebSocketClient,
private val settingsRepository: SettingsRepository,
private val importExportRepository: ImportExportRepository,
@ApplicationContext private val context: Context
) : ViewModel() {
private val _navigateToSettings = MutableSharedFlow<Unit>(extraBufferCapacity = 1)
@ -45,6 +49,7 @@ internal class MainViewModel @Inject constructor(
if (token.isNotBlank() && serverUrl.isNotBlank()) {
Log.i(TAG, "App-Start: Token vorhanden verbinde WebSocket")
webSocketClient.connect(serverUrl, token)
MessagingService.start(context)
} else {
Log.d(TAG, "App-Start: Kein Token kein WebSocket")
}
@ -57,6 +62,7 @@ internal class MainViewModel @Inject constructor(
authEventBus.loginSuccess.collect { (serverUrl, token) ->
Log.i(TAG, "Login erfolgreich verbinde WebSocket")
webSocketClient.connect(serverUrl, token)
MessagingService.start(context)
}
}
}
@ -68,6 +74,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

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