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:
parent
8e75798507
commit
24c6fac0f8
12 changed files with 283 additions and 17 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
@ -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) }
|
||||
|
|
|
|||
|
|
@ -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) }
|
||||
}
|
||||
|
|
|
|||
9
app/src/main/res/drawable/ic_notification_message.xml
Normal file
9
app/src/main/res/drawable/ic_notification_message.xml
Normal 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>
|
||||
|
|
@ -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
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in a new issue