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.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"
@ -34,6 +36,11 @@
android:name="android.support.FILE_PROVIDER_PATHS" android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/file_paths" /> android:resource="@xml/file_paths" />
</provider> </provider>
<service
android:name=".service.MessagingService"
android:exported="false"
android:foregroundServiceType="dataSync" />
</application> </application>
</manifest> </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 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
@ -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.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.service.MessagingService
import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.SharedFlow import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.flow.asSharedFlow
@ -25,6 +28,7 @@ internal class MainViewModel @Inject constructor(
private val webSocketClient: WebSocketClient, private val webSocketClient: WebSocketClient,
private val settingsRepository: SettingsRepository, private val settingsRepository: SettingsRepository,
private val importExportRepository: ImportExportRepository, private val importExportRepository: ImportExportRepository,
@ApplicationContext private val context: Context
) : ViewModel() { ) : ViewModel() {
private val _navigateToSettings = MutableSharedFlow<Unit>(extraBufferCapacity = 1) private val _navigateToSettings = MutableSharedFlow<Unit>(extraBufferCapacity = 1)
@ -45,6 +49,7 @@ internal class MainViewModel @Inject constructor(
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")
webSocketClient.connect(serverUrl, token) webSocketClient.connect(serverUrl, token)
MessagingService.start(context)
} else { } else {
Log.d(TAG, "App-Start: Kein Token kein WebSocket") Log.d(TAG, "App-Start: Kein Token kein WebSocket")
} }
@ -57,6 +62,7 @@ internal class MainViewModel @Inject constructor(
authEventBus.loginSuccess.collect { (serverUrl, token) -> authEventBus.loginSuccess.collect { (serverUrl, token) ->
Log.i(TAG, "Login erfolgreich verbinde WebSocket") Log.i(TAG, "Login erfolgreich verbinde WebSocket")
webSocketClient.connect(serverUrl, token) webSocketClient.connect(serverUrl, token)
MessagingService.start(context)
} }
} }
} }
@ -68,6 +74,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

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