feat(messaging): push notifications for incoming messages (#104)

- Add NotificationHelper with channel creation, grouped notifications,
  and deep-link PendingIntent into chat
- Trigger notification from MessageRepositoryImpl on WebSocket NewMessage
- Active-chat suppression in ChatViewModel (no notification for current chat)
- Deep-link from notification tap: MainActivity handles intent extras,
  MainScreen navigates to correct Chat screen
- Add POST_NOTIFICATIONS permission to AndroidManifest
- Add notification icon drawable (ic_notification_message)
- Add unit tests for notification suppression logic
- Fix pre-existing test compilation (SyncServiceImplTest missing authEventBus)
This commit is contained in:
Jens Reinemann 2026-05-18 09:38:26 +02:00
parent 8e75798507
commit 24c6fac0f8
12 changed files with 283 additions and 17 deletions

View file

@ -2,6 +2,7 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
<application

View file

@ -2,6 +2,16 @@ package de.bollwerk.app
import android.app.Application
import dagger.hilt.android.HiltAndroidApp
import de.bollwerk.app.notification.NotificationHelper
import javax.inject.Inject
@HiltAndroidApp
class BollwerkApp : Application()
class BollwerkApp : Application() {
@Inject internal lateinit var notificationHelper: NotificationHelper
override fun onCreate() {
super.onCreate()
notificationHelper.createNotificationChannel()
}
}

View file

@ -1,5 +1,6 @@
package de.bollwerk.app
import android.content.Intent
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
@ -12,8 +13,12 @@ import androidx.lifecycle.lifecycleScope
import dagger.hilt.android.AndroidEntryPoint
import de.bollwerk.app.domain.usecase.EnsureKeyPairUseCase
import de.bollwerk.app.domain.usecase.SeedDatabaseUseCase
import de.bollwerk.app.notification.NotificationHelper
import de.bollwerk.app.ui.MainScreen
import de.bollwerk.app.ui.theme.BollwerkTheme
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.launch
import javax.inject.Inject
@ -23,19 +28,41 @@ class MainActivity : ComponentActivity() {
@Inject internal lateinit var seedDatabaseUseCase: SeedDatabaseUseCase
@Inject internal lateinit var ensureKeyPairUseCase: EnsureKeyPairUseCase
private val _chatNavigationEvents = MutableSharedFlow<ChatDeepLink>(extraBufferCapacity = 1)
val chatNavigationEvents: SharedFlow<ChatDeepLink> = _chatNavigationEvents.asSharedFlow()
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
lifecycleScope.launch { seedDatabaseUseCase() }
lifecycleScope.launch { ensureKeyPairUseCase.execute() }
enableEdgeToEdge()
handleChatDeepLink(intent)
setContent {
BollwerkTheme {
MainScreen()
MainScreen(chatNavigationEvents = chatNavigationEvents)
}
}
}
override fun onNewIntent(intent: Intent) {
super.onNewIntent(intent)
handleChatDeepLink(intent)
}
private fun handleChatDeepLink(intent: Intent?) {
val recipientId = intent?.getStringExtra(NotificationHelper.EXTRA_RECIPIENT_ID)
val recipientUsername = intent?.getStringExtra(NotificationHelper.EXTRA_RECIPIENT_USERNAME)
if (recipientId != null && recipientUsername != null) {
_chatNavigationEvents.tryEmit(ChatDeepLink(recipientId, recipientUsername))
// Clear extras to prevent re-navigation on config change
intent?.removeExtra(NotificationHelper.EXTRA_RECIPIENT_ID)
intent?.removeExtra(NotificationHelper.EXTRA_RECIPIENT_USERNAME)
}
}
}
data class ChatDeepLink(val recipientId: String, val recipientUsername: String)
@Preview(showBackground = true)
@Composable
fun DefaultPreview() {

View file

@ -14,6 +14,7 @@ import de.bollwerk.app.domain.model.SettingsKey.StringKey
import de.bollwerk.app.domain.model.SyncError
import de.bollwerk.app.domain.repository.MessageRepository
import de.bollwerk.app.domain.repository.SettingsRepository
import de.bollwerk.app.notification.NotificationHelper
import de.bollwerk.shared.model.SendMessageResponse
import de.bollwerk.shared.model.UserListItemDto
import io.ktor.client.HttpClient
@ -57,6 +58,7 @@ internal class MessageRepositoryImpl @Inject constructor(
private val webSocketClient: WebSocketClient,
private val e2eeKeyManager: E2EEKeyManager,
private val authEventBus: AuthEventBus,
private val notificationHelper: NotificationHelper,
@ApplicationScope private val scope: CoroutineScope
) : MessageRepository {
@ -85,6 +87,10 @@ internal class MessageRepositoryImpl @Inject constructor(
isPending = false
)
)
notificationHelper.showNewMessageNotification(
senderId = msg.senderId,
senderUsername = msg.senderUsername
)
}
is WebSocketEvent.KeyUpdated -> {
publicKeyCache[event.userId] = event.publicKey

View file

@ -0,0 +1,119 @@
package de.bollwerk.app.notification
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.content.Context
import android.content.Intent
import android.media.AudioAttributes
import android.media.RingtoneManager
import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat
import dagger.hilt.android.qualifiers.ApplicationContext
import de.bollwerk.app.MainActivity
import de.bollwerk.app.R
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
internal class NotificationHelper @Inject constructor(
@ApplicationContext private val context: Context
) {
@Volatile
private var activeChatPartnerId: String? = null
/// Erstellt den Notification Channel (ab API 26 erforderlich).
fun createNotificationChannel() {
val soundUri = RingtoneManager.getDefaultUri(RingtoneManager.TYPE_NOTIFICATION)
val audioAttributes = AudioAttributes.Builder()
.setContentType(AudioAttributes.CONTENT_TYPE_SONIFICATION)
.setUsage(AudioAttributes.USAGE_NOTIFICATION)
.build()
val channel = NotificationChannel(
CHANNEL_ID,
CHANNEL_NAME,
NotificationManager.IMPORTANCE_HIGH
).apply {
description = "Benachrichtigungen für eingehende Chat-Nachrichten"
setSound(soundUri, audioAttributes)
enableVibration(true)
}
val manager = context.getSystemService(NotificationManager::class.java)
manager.createNotificationChannel(channel)
}
/// Setzt die aktuell sichtbare Chat-Konversation (unterdrückt Notifications für diesen Partner).
fun setActiveChat(partnerId: String?) {
activeChatPartnerId = partnerId
}
/// Zeigt eine Benachrichtigung für eine eingehende Nachricht an.
/// Gibt false zurück, wenn die Notification unterdrückt wurde (Chat ist aktiv).
fun showNewMessageNotification(
senderId: String,
senderUsername: String
): Boolean {
if (senderId == activeChatPartnerId) return false
val intent = Intent(context, MainActivity::class.java).apply {
flags = Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_CLEAR_TOP
putExtra(EXTRA_RECIPIENT_ID, senderId)
putExtra(EXTRA_RECIPIENT_USERNAME, senderUsername)
}
val pendingIntent = PendingIntent.getActivity(
context,
senderId.hashCode(),
intent,
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
)
val notification = NotificationCompat.Builder(context, CHANNEL_ID)
.setSmallIcon(R.drawable.ic_notification_message)
.setContentTitle("Neue Nachricht")
.setContentText("$senderUsername hat etwas geschrieben")
.setPriority(NotificationCompat.PRIORITY_HIGH)
.setAutoCancel(true)
.setContentIntent(pendingIntent)
.setGroup(GROUP_KEY)
.build()
val summaryNotification = NotificationCompat.Builder(context, CHANNEL_ID)
.setSmallIcon(R.drawable.ic_notification_message)
.setContentTitle("Neue Nachrichten")
.setContentText("Du hast neue Nachrichten")
.setGroup(GROUP_KEY)
.setGroupSummary(true)
.setAutoCancel(true)
.build()
val notificationManager = NotificationManagerCompat.from(context)
try {
notificationManager.notify(senderId.hashCode(), notification)
notificationManager.notify(SUMMARY_NOTIFICATION_ID, summaryNotification)
} catch (_: SecurityException) {
// Permission not granted ignore silently
return false
}
return true
}
/// Entfernt Benachrichtigungen für einen bestimmten Absender (wenn der Chat geöffnet wird).
fun cancelNotificationForSender(senderId: String) {
val notificationManager = NotificationManagerCompat.from(context)
notificationManager.cancel(senderId.hashCode())
}
companion object {
const val CHANNEL_ID = "bollwerk_messages"
private const val CHANNEL_NAME = "Chat-Nachrichten"
private const val GROUP_KEY = "de.bollwerk.app.MESSAGES"
private const val SUMMARY_NOTIFICATION_ID = 0
const val EXTRA_RECIPIENT_ID = "notification_recipient_id"
const val EXTRA_RECIPIENT_USERNAME = "notification_recipient_username"
}
}

View file

@ -35,6 +35,7 @@ import androidx.navigation.NavDestination.Companion.hasRoute
import androidx.navigation.NavGraph.Companion.findStartDestination
import androidx.navigation.compose.currentBackStackEntryAsState
import androidx.navigation.compose.rememberNavController
import de.bollwerk.app.ChatDeepLink
import de.bollwerk.app.ui.inventory.InventoryPickerSheet
import de.bollwerk.app.ui.inventory.InventoryPickerViewModel
import de.bollwerk.app.ui.navigation.BollwerkNavGraph
@ -43,10 +44,11 @@ import de.bollwerk.app.ui.navigation.TopLevelDestination
import de.bollwerk.app.ui.update.UpdateBanner
import de.bollwerk.app.ui.update.UpdateStatus
import de.bollwerk.app.ui.update.UpdateViewModel
import kotlinx.coroutines.flow.SharedFlow
@OptIn(ExperimentalMaterial3Api::class)
@Composable
internal fun MainScreen() {
internal fun MainScreen(chatNavigationEvents: SharedFlow<ChatDeepLink>) {
val navController = rememberNavController()
val navBackStackEntry by navController.currentBackStackEntryAsState()
val currentDestination = navBackStackEntry?.destination
@ -65,6 +67,19 @@ internal fun MainScreen() {
}
}
LaunchedEffect(Unit) {
chatNavigationEvents.collect { deepLink ->
navController.navigate(
Screen.Chat(
recipientId = deepLink.recipientId,
recipientUsername = deepLink.recipientUsername
)
) {
launchSingleTop = true
}
}
}
val inventoryPickerViewModel: InventoryPickerViewModel = hiltViewModel()
val inventoryState by inventoryPickerViewModel.uiState.collectAsStateWithLifecycle()
var isInventoryPickerVisible by remember { mutableStateOf(false) }

View file

@ -6,6 +6,7 @@ import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import de.bollwerk.app.data.db.entity.MessageEntity
import de.bollwerk.app.domain.repository.MessageRepository
import de.bollwerk.app.notification.NotificationHelper
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.update
@ -23,7 +24,8 @@ internal data class ChatUiState(
@HiltViewModel
internal class ChatViewModel @Inject constructor(
savedStateHandle: SavedStateHandle,
private val messageRepository: MessageRepository
private val messageRepository: MessageRepository,
private val notificationHelper: NotificationHelper
) : ViewModel() {
private val recipientId: String = savedStateHandle.get<String>("recipientId") ?: ""
@ -33,6 +35,8 @@ internal class ChatViewModel @Inject constructor(
val uiState: StateFlow<ChatUiState> = _uiState
init {
notificationHelper.setActiveChat(recipientId)
notificationHelper.cancelNotificationForSender(recipientId)
viewModelScope.launch {
val myId = messageRepository.getMyUserId() ?: ""
_uiState.update { it.copy(myUserId = myId) }
@ -44,6 +48,11 @@ internal class ChatViewModel @Inject constructor(
}
}
override fun onCleared() {
super.onCleared()
notificationHelper.setActiveChat(null)
}
fun onInputChanged(text: String) {
_uiState.update { it.copy(inputText = text) }
}

View file

@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="@android:color/white"
android:pathData="M20,2L4,2c-1.1,0 -1.99,0.9 -1.99,2L2,22l4,-4h14c1.1,0 2,-0.9 2,-2L22,4c0,-1.1 -0.9,-2 -2,-2zM6,9h12v2L6,11L6,9zM14,14L6,14v-2h8v2zM18,8L6,8L6,6h12v2z"/>
</vector>

View file

@ -5,9 +5,11 @@ import de.bollwerk.app.data.db.entity.MessageEntity
import de.bollwerk.app.data.security.E2EEKeyManager
import de.bollwerk.app.data.sync.WebSocketClient
import de.bollwerk.app.data.sync.WebSocketEvent
import de.bollwerk.app.domain.AuthEventBus
import de.bollwerk.app.domain.model.SettingsKey
import de.bollwerk.app.domain.model.SettingsKeys
import de.bollwerk.app.domain.repository.SettingsRepository
import de.bollwerk.app.notification.NotificationHelper
import de.bollwerk.shared.model.MessageDto
import de.bollwerk.shared.model.UserListItemDto
import io.ktor.client.HttpClient
@ -124,6 +126,8 @@ class MessageRepositoryImplTest {
settingsRepository = settings,
webSocketClient = wsClient,
e2eeKeyManager = e2eeKeyManager,
authEventBus = AuthEventBus(),
notificationHelper = mockk(relaxed = true),
scope = testScope
)

View file

@ -1,5 +1,6 @@
package de.bollwerk.app.data.sync
package de.bollwerk.app.data.sync
import de.bollwerk.app.domain.AuthEventBus
import de.bollwerk.app.domain.model.SettingsKey.StringKey
import de.bollwerk.app.domain.model.SyncError
import de.bollwerk.app.domain.repository.SettingsRepository
@ -97,7 +98,7 @@ class SyncServiceImplTest {
)
}
httpClient = createClient(engine)
syncService = SyncServiceImpl(httpClient, settingsRepository)
syncService = SyncServiceImpl(httpClient, settingsRepository, AuthEventBus())
// When
val result = syncService.downloadInventory()
@ -115,7 +116,7 @@ class SyncServiceImplTest {
respondError(HttpStatusCode.Unauthorized)
}
httpClient = createClient(engine)
syncService = SyncServiceImpl(httpClient, settingsRepository)
syncService = SyncServiceImpl(httpClient, settingsRepository, AuthEventBus())
// When
val result = syncService.downloadInventory()
@ -133,7 +134,7 @@ class SyncServiceImplTest {
respondError(HttpStatusCode.InternalServerError)
}
httpClient = createClient(engine)
syncService = SyncServiceImpl(httpClient, settingsRepository)
syncService = SyncServiceImpl(httpClient, settingsRepository, AuthEventBus())
// When
val result = syncService.downloadInventory()
@ -150,7 +151,7 @@ class SyncServiceImplTest {
setupSettings(serverUrl = null)
val engine = MockEngine { respondError(HttpStatusCode.OK) }
httpClient = createClient(engine)
syncService = SyncServiceImpl(httpClient, settingsRepository)
syncService = SyncServiceImpl(httpClient, settingsRepository, AuthEventBus())
// When
val result = syncService.downloadInventory()
@ -166,7 +167,7 @@ class SyncServiceImplTest {
setupSettings(serverUrl = " ")
val engine = MockEngine { respondError(HttpStatusCode.OK) }
httpClient = createClient(engine)
syncService = SyncServiceImpl(httpClient, settingsRepository)
syncService = SyncServiceImpl(httpClient, settingsRepository, AuthEventBus())
// When
val result = syncService.downloadInventory()
@ -182,7 +183,7 @@ class SyncServiceImplTest {
setupSettings(accessToken = null)
val engine = MockEngine { respondError(HttpStatusCode.OK) }
httpClient = createClient(engine)
syncService = SyncServiceImpl(httpClient, settingsRepository)
syncService = SyncServiceImpl(httpClient, settingsRepository, AuthEventBus())
// When
val result = syncService.downloadInventory()
@ -206,7 +207,7 @@ class SyncServiceImplTest {
)
}
httpClient = createClient(engine)
syncService = SyncServiceImpl(httpClient, settingsRepository)
syncService = SyncServiceImpl(httpClient, settingsRepository, AuthEventBus())
// When
val result = syncService.downloadInventory(since = 1715000000L)
@ -228,7 +229,7 @@ class SyncServiceImplTest {
)
}
httpClient = createClient(engine)
syncService = SyncServiceImpl(httpClient, settingsRepository)
syncService = SyncServiceImpl(httpClient, settingsRepository, AuthEventBus())
// When
val result = syncService.downloadInventory(since = null)
@ -258,7 +259,7 @@ class SyncServiceImplTest {
)
}
httpClient = createClient(engine)
syncService = SyncServiceImpl(httpClient, settingsRepository)
syncService = SyncServiceImpl(httpClient, settingsRepository, AuthEventBus())
// When
val result = syncService.uploadInventory(testInventory)
@ -276,7 +277,7 @@ class SyncServiceImplTest {
respondError(HttpStatusCode.Unauthorized)
}
httpClient = createClient(engine)
syncService = SyncServiceImpl(httpClient, settingsRepository)
syncService = SyncServiceImpl(httpClient, settingsRepository, AuthEventBus())
// When
val result = syncService.uploadInventory(testInventory)
@ -299,7 +300,7 @@ class SyncServiceImplTest {
)
}
httpClient = createClient(engine)
syncService = SyncServiceImpl(httpClient, settingsRepository)
syncService = SyncServiceImpl(httpClient, settingsRepository, AuthEventBus())
// When
val result = syncService.uploadInventory(testInventory)

View file

@ -0,0 +1,63 @@
package de.bollwerk.app.notification
import android.app.NotificationManager
import android.content.Context
import io.mockk.every
import io.mockk.mockk
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNull
import org.junit.Before
import org.junit.Test
class NotificationHelperTest {
private lateinit var context: Context
private lateinit var notificationManager: NotificationManager
private lateinit var notificationHelper: NotificationHelper
@Before
fun setup() {
context = mockk(relaxed = true)
notificationManager = mockk(relaxed = true)
every { context.getSystemService(NotificationManager::class.java) } returns notificationManager
every { context.packageName } returns "de.bollwerk.app"
notificationHelper = NotificationHelper(context)
}
@Test
fun test_showNewMessageNotification_activeChatMatchesSender_returnsFalse() {
// Given
notificationHelper.setActiveChat("user-123")
// When
val result = notificationHelper.showNewMessageNotification(
senderId = "user-123",
senderUsername = "Bob"
)
// Then
assertFalse(result)
}
@Test
fun test_setActiveChat_setsAndClears() {
// Given
notificationHelper.setActiveChat("user-123")
// When message from same user is suppressed
val suppressed = notificationHelper.showNewMessageNotification(
senderId = "user-123",
senderUsername = "Bob"
)
// Then
assertFalse(suppressed)
// When clear active chat
notificationHelper.setActiveChat(null)
// Then message from same user is no longer suppressed at the guard
// (actual notification display needs Android runtime, tested via instrumentation)
}
}

View file

@ -3,7 +3,9 @@ package de.bollwerk.app.ui.messaging
import androidx.lifecycle.SavedStateHandle
import de.bollwerk.app.data.db.entity.MessageEntity
import de.bollwerk.app.domain.repository.MessageRepository
import de.bollwerk.app.notification.NotificationHelper
import de.bollwerk.shared.model.UserListItemDto
import io.mockk.mockk
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.Flow
@ -58,7 +60,7 @@ class ChatViewModelTest {
repo: MessageRepository = FakeChatMessageRepository()
): ChatViewModel {
val savedStateHandle = SavedStateHandle(mapOf("recipientId" to recipientId, "recipientUsername" to recipientUsername))
return ChatViewModel(savedStateHandle = savedStateHandle, messageRepository = repo)
return ChatViewModel(savedStateHandle = savedStateHandle, messageRepository = repo, notificationHelper = mockk(relaxed = true))
}
@Test