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">
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
|
||||||
<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.REQUEST_INSTALL_PACKAGES" />
|
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
|
||||||
|
|
||||||
<application
|
<application
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,16 @@ package de.bollwerk.app
|
||||||
|
|
||||||
import android.app.Application
|
import android.app.Application
|
||||||
import dagger.hilt.android.HiltAndroidApp
|
import dagger.hilt.android.HiltAndroidApp
|
||||||
|
import de.bollwerk.app.notification.NotificationHelper
|
||||||
|
import javax.inject.Inject
|
||||||
|
|
||||||
@HiltAndroidApp
|
@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
|
package de.bollwerk.app
|
||||||
|
|
||||||
|
import android.content.Intent
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
import androidx.activity.ComponentActivity
|
import androidx.activity.ComponentActivity
|
||||||
import androidx.activity.compose.setContent
|
import androidx.activity.compose.setContent
|
||||||
|
|
@ -12,8 +13,12 @@ import androidx.lifecycle.lifecycleScope
|
||||||
import dagger.hilt.android.AndroidEntryPoint
|
import dagger.hilt.android.AndroidEntryPoint
|
||||||
import de.bollwerk.app.domain.usecase.EnsureKeyPairUseCase
|
import de.bollwerk.app.domain.usecase.EnsureKeyPairUseCase
|
||||||
import de.bollwerk.app.domain.usecase.SeedDatabaseUseCase
|
import de.bollwerk.app.domain.usecase.SeedDatabaseUseCase
|
||||||
|
import de.bollwerk.app.notification.NotificationHelper
|
||||||
import de.bollwerk.app.ui.MainScreen
|
import de.bollwerk.app.ui.MainScreen
|
||||||
import de.bollwerk.app.ui.theme.BollwerkTheme
|
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 kotlinx.coroutines.launch
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
|
|
||||||
|
|
@ -23,19 +28,41 @@ class MainActivity : ComponentActivity() {
|
||||||
@Inject internal lateinit var seedDatabaseUseCase: SeedDatabaseUseCase
|
@Inject internal lateinit var seedDatabaseUseCase: SeedDatabaseUseCase
|
||||||
@Inject internal lateinit var ensureKeyPairUseCase: EnsureKeyPairUseCase
|
@Inject internal lateinit var ensureKeyPairUseCase: EnsureKeyPairUseCase
|
||||||
|
|
||||||
|
private val _chatNavigationEvents = MutableSharedFlow<ChatDeepLink>(extraBufferCapacity = 1)
|
||||||
|
val chatNavigationEvents: SharedFlow<ChatDeepLink> = _chatNavigationEvents.asSharedFlow()
|
||||||
|
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
super.onCreate(savedInstanceState)
|
super.onCreate(savedInstanceState)
|
||||||
lifecycleScope.launch { seedDatabaseUseCase() }
|
lifecycleScope.launch { seedDatabaseUseCase() }
|
||||||
lifecycleScope.launch { ensureKeyPairUseCase.execute() }
|
lifecycleScope.launch { ensureKeyPairUseCase.execute() }
|
||||||
enableEdgeToEdge()
|
enableEdgeToEdge()
|
||||||
|
handleChatDeepLink(intent)
|
||||||
setContent {
|
setContent {
|
||||||
BollwerkTheme {
|
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)
|
@Preview(showBackground = true)
|
||||||
@Composable
|
@Composable
|
||||||
fun DefaultPreview() {
|
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.model.SyncError
|
||||||
import de.bollwerk.app.domain.repository.MessageRepository
|
import de.bollwerk.app.domain.repository.MessageRepository
|
||||||
import de.bollwerk.app.domain.repository.SettingsRepository
|
import de.bollwerk.app.domain.repository.SettingsRepository
|
||||||
|
import de.bollwerk.app.notification.NotificationHelper
|
||||||
import de.bollwerk.shared.model.SendMessageResponse
|
import de.bollwerk.shared.model.SendMessageResponse
|
||||||
import de.bollwerk.shared.model.UserListItemDto
|
import de.bollwerk.shared.model.UserListItemDto
|
||||||
import io.ktor.client.HttpClient
|
import io.ktor.client.HttpClient
|
||||||
|
|
@ -57,6 +58,7 @@ internal class MessageRepositoryImpl @Inject constructor(
|
||||||
private val webSocketClient: WebSocketClient,
|
private val webSocketClient: WebSocketClient,
|
||||||
private val e2eeKeyManager: E2EEKeyManager,
|
private val e2eeKeyManager: E2EEKeyManager,
|
||||||
private val authEventBus: AuthEventBus,
|
private val authEventBus: AuthEventBus,
|
||||||
|
private val notificationHelper: NotificationHelper,
|
||||||
@ApplicationScope private val scope: CoroutineScope
|
@ApplicationScope private val scope: CoroutineScope
|
||||||
) : MessageRepository {
|
) : MessageRepository {
|
||||||
|
|
||||||
|
|
@ -85,6 +87,10 @@ internal class MessageRepositoryImpl @Inject constructor(
|
||||||
isPending = false
|
isPending = false
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
notificationHelper.showNewMessageNotification(
|
||||||
|
senderId = msg.senderId,
|
||||||
|
senderUsername = msg.senderUsername
|
||||||
|
)
|
||||||
}
|
}
|
||||||
is WebSocketEvent.KeyUpdated -> {
|
is WebSocketEvent.KeyUpdated -> {
|
||||||
publicKeyCache[event.userId] = event.publicKey
|
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.NavGraph.Companion.findStartDestination
|
||||||
import androidx.navigation.compose.currentBackStackEntryAsState
|
import androidx.navigation.compose.currentBackStackEntryAsState
|
||||||
import androidx.navigation.compose.rememberNavController
|
import androidx.navigation.compose.rememberNavController
|
||||||
|
import de.bollwerk.app.ChatDeepLink
|
||||||
import de.bollwerk.app.ui.inventory.InventoryPickerSheet
|
import de.bollwerk.app.ui.inventory.InventoryPickerSheet
|
||||||
import de.bollwerk.app.ui.inventory.InventoryPickerViewModel
|
import de.bollwerk.app.ui.inventory.InventoryPickerViewModel
|
||||||
import de.bollwerk.app.ui.navigation.BollwerkNavGraph
|
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.UpdateBanner
|
||||||
import de.bollwerk.app.ui.update.UpdateStatus
|
import de.bollwerk.app.ui.update.UpdateStatus
|
||||||
import de.bollwerk.app.ui.update.UpdateViewModel
|
import de.bollwerk.app.ui.update.UpdateViewModel
|
||||||
|
import kotlinx.coroutines.flow.SharedFlow
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
@Composable
|
@Composable
|
||||||
internal fun MainScreen() {
|
internal fun MainScreen(chatNavigationEvents: SharedFlow<ChatDeepLink>) {
|
||||||
val navController = rememberNavController()
|
val navController = rememberNavController()
|
||||||
val navBackStackEntry by navController.currentBackStackEntryAsState()
|
val navBackStackEntry by navController.currentBackStackEntryAsState()
|
||||||
val currentDestination = navBackStackEntry?.destination
|
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 inventoryPickerViewModel: InventoryPickerViewModel = hiltViewModel()
|
||||||
val inventoryState by inventoryPickerViewModel.uiState.collectAsStateWithLifecycle()
|
val inventoryState by inventoryPickerViewModel.uiState.collectAsStateWithLifecycle()
|
||||||
var isInventoryPickerVisible by remember { mutableStateOf(false) }
|
var isInventoryPickerVisible by remember { mutableStateOf(false) }
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ import androidx.lifecycle.viewModelScope
|
||||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
import de.bollwerk.app.data.db.entity.MessageEntity
|
import de.bollwerk.app.data.db.entity.MessageEntity
|
||||||
import de.bollwerk.app.domain.repository.MessageRepository
|
import de.bollwerk.app.domain.repository.MessageRepository
|
||||||
|
import de.bollwerk.app.notification.NotificationHelper
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
import kotlinx.coroutines.flow.update
|
import kotlinx.coroutines.flow.update
|
||||||
|
|
@ -23,7 +24,8 @@ internal data class ChatUiState(
|
||||||
@HiltViewModel
|
@HiltViewModel
|
||||||
internal class ChatViewModel @Inject constructor(
|
internal class ChatViewModel @Inject constructor(
|
||||||
savedStateHandle: SavedStateHandle,
|
savedStateHandle: SavedStateHandle,
|
||||||
private val messageRepository: MessageRepository
|
private val messageRepository: MessageRepository,
|
||||||
|
private val notificationHelper: NotificationHelper
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
|
|
||||||
private val recipientId: String = savedStateHandle.get<String>("recipientId") ?: ""
|
private val recipientId: String = savedStateHandle.get<String>("recipientId") ?: ""
|
||||||
|
|
@ -33,6 +35,8 @@ internal class ChatViewModel @Inject constructor(
|
||||||
val uiState: StateFlow<ChatUiState> = _uiState
|
val uiState: StateFlow<ChatUiState> = _uiState
|
||||||
|
|
||||||
init {
|
init {
|
||||||
|
notificationHelper.setActiveChat(recipientId)
|
||||||
|
notificationHelper.cancelNotificationForSender(recipientId)
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
val myId = messageRepository.getMyUserId() ?: ""
|
val myId = messageRepository.getMyUserId() ?: ""
|
||||||
_uiState.update { it.copy(myUserId = myId) }
|
_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) {
|
fun onInputChanged(text: String) {
|
||||||
_uiState.update { it.copy(inputText = text) }
|
_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.security.E2EEKeyManager
|
||||||
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.model.SettingsKey
|
import de.bollwerk.app.domain.model.SettingsKey
|
||||||
import de.bollwerk.app.domain.model.SettingsKeys
|
import de.bollwerk.app.domain.model.SettingsKeys
|
||||||
import de.bollwerk.app.domain.repository.SettingsRepository
|
import de.bollwerk.app.domain.repository.SettingsRepository
|
||||||
|
import de.bollwerk.app.notification.NotificationHelper
|
||||||
import de.bollwerk.shared.model.MessageDto
|
import de.bollwerk.shared.model.MessageDto
|
||||||
import de.bollwerk.shared.model.UserListItemDto
|
import de.bollwerk.shared.model.UserListItemDto
|
||||||
import io.ktor.client.HttpClient
|
import io.ktor.client.HttpClient
|
||||||
|
|
@ -124,6 +126,8 @@ class MessageRepositoryImplTest {
|
||||||
settingsRepository = settings,
|
settingsRepository = settings,
|
||||||
webSocketClient = wsClient,
|
webSocketClient = wsClient,
|
||||||
e2eeKeyManager = e2eeKeyManager,
|
e2eeKeyManager = e2eeKeyManager,
|
||||||
|
authEventBus = AuthEventBus(),
|
||||||
|
notificationHelper = mockk(relaxed = true),
|
||||||
scope = testScope
|
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.SettingsKey.StringKey
|
||||||
import de.bollwerk.app.domain.model.SyncError
|
import de.bollwerk.app.domain.model.SyncError
|
||||||
import de.bollwerk.app.domain.repository.SettingsRepository
|
import de.bollwerk.app.domain.repository.SettingsRepository
|
||||||
|
|
@ -97,7 +98,7 @@ class SyncServiceImplTest {
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
httpClient = createClient(engine)
|
httpClient = createClient(engine)
|
||||||
syncService = SyncServiceImpl(httpClient, settingsRepository)
|
syncService = SyncServiceImpl(httpClient, settingsRepository, AuthEventBus())
|
||||||
|
|
||||||
// When
|
// When
|
||||||
val result = syncService.downloadInventory()
|
val result = syncService.downloadInventory()
|
||||||
|
|
@ -115,7 +116,7 @@ class SyncServiceImplTest {
|
||||||
respondError(HttpStatusCode.Unauthorized)
|
respondError(HttpStatusCode.Unauthorized)
|
||||||
}
|
}
|
||||||
httpClient = createClient(engine)
|
httpClient = createClient(engine)
|
||||||
syncService = SyncServiceImpl(httpClient, settingsRepository)
|
syncService = SyncServiceImpl(httpClient, settingsRepository, AuthEventBus())
|
||||||
|
|
||||||
// When
|
// When
|
||||||
val result = syncService.downloadInventory()
|
val result = syncService.downloadInventory()
|
||||||
|
|
@ -133,7 +134,7 @@ class SyncServiceImplTest {
|
||||||
respondError(HttpStatusCode.InternalServerError)
|
respondError(HttpStatusCode.InternalServerError)
|
||||||
}
|
}
|
||||||
httpClient = createClient(engine)
|
httpClient = createClient(engine)
|
||||||
syncService = SyncServiceImpl(httpClient, settingsRepository)
|
syncService = SyncServiceImpl(httpClient, settingsRepository, AuthEventBus())
|
||||||
|
|
||||||
// When
|
// When
|
||||||
val result = syncService.downloadInventory()
|
val result = syncService.downloadInventory()
|
||||||
|
|
@ -150,7 +151,7 @@ class SyncServiceImplTest {
|
||||||
setupSettings(serverUrl = null)
|
setupSettings(serverUrl = null)
|
||||||
val engine = MockEngine { respondError(HttpStatusCode.OK) }
|
val engine = MockEngine { respondError(HttpStatusCode.OK) }
|
||||||
httpClient = createClient(engine)
|
httpClient = createClient(engine)
|
||||||
syncService = SyncServiceImpl(httpClient, settingsRepository)
|
syncService = SyncServiceImpl(httpClient, settingsRepository, AuthEventBus())
|
||||||
|
|
||||||
// When
|
// When
|
||||||
val result = syncService.downloadInventory()
|
val result = syncService.downloadInventory()
|
||||||
|
|
@ -166,7 +167,7 @@ class SyncServiceImplTest {
|
||||||
setupSettings(serverUrl = " ")
|
setupSettings(serverUrl = " ")
|
||||||
val engine = MockEngine { respondError(HttpStatusCode.OK) }
|
val engine = MockEngine { respondError(HttpStatusCode.OK) }
|
||||||
httpClient = createClient(engine)
|
httpClient = createClient(engine)
|
||||||
syncService = SyncServiceImpl(httpClient, settingsRepository)
|
syncService = SyncServiceImpl(httpClient, settingsRepository, AuthEventBus())
|
||||||
|
|
||||||
// When
|
// When
|
||||||
val result = syncService.downloadInventory()
|
val result = syncService.downloadInventory()
|
||||||
|
|
@ -182,7 +183,7 @@ class SyncServiceImplTest {
|
||||||
setupSettings(accessToken = null)
|
setupSettings(accessToken = null)
|
||||||
val engine = MockEngine { respondError(HttpStatusCode.OK) }
|
val engine = MockEngine { respondError(HttpStatusCode.OK) }
|
||||||
httpClient = createClient(engine)
|
httpClient = createClient(engine)
|
||||||
syncService = SyncServiceImpl(httpClient, settingsRepository)
|
syncService = SyncServiceImpl(httpClient, settingsRepository, AuthEventBus())
|
||||||
|
|
||||||
// When
|
// When
|
||||||
val result = syncService.downloadInventory()
|
val result = syncService.downloadInventory()
|
||||||
|
|
@ -206,7 +207,7 @@ class SyncServiceImplTest {
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
httpClient = createClient(engine)
|
httpClient = createClient(engine)
|
||||||
syncService = SyncServiceImpl(httpClient, settingsRepository)
|
syncService = SyncServiceImpl(httpClient, settingsRepository, AuthEventBus())
|
||||||
|
|
||||||
// When
|
// When
|
||||||
val result = syncService.downloadInventory(since = 1715000000L)
|
val result = syncService.downloadInventory(since = 1715000000L)
|
||||||
|
|
@ -228,7 +229,7 @@ class SyncServiceImplTest {
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
httpClient = createClient(engine)
|
httpClient = createClient(engine)
|
||||||
syncService = SyncServiceImpl(httpClient, settingsRepository)
|
syncService = SyncServiceImpl(httpClient, settingsRepository, AuthEventBus())
|
||||||
|
|
||||||
// When
|
// When
|
||||||
val result = syncService.downloadInventory(since = null)
|
val result = syncService.downloadInventory(since = null)
|
||||||
|
|
@ -258,7 +259,7 @@ class SyncServiceImplTest {
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
httpClient = createClient(engine)
|
httpClient = createClient(engine)
|
||||||
syncService = SyncServiceImpl(httpClient, settingsRepository)
|
syncService = SyncServiceImpl(httpClient, settingsRepository, AuthEventBus())
|
||||||
|
|
||||||
// When
|
// When
|
||||||
val result = syncService.uploadInventory(testInventory)
|
val result = syncService.uploadInventory(testInventory)
|
||||||
|
|
@ -276,7 +277,7 @@ class SyncServiceImplTest {
|
||||||
respondError(HttpStatusCode.Unauthorized)
|
respondError(HttpStatusCode.Unauthorized)
|
||||||
}
|
}
|
||||||
httpClient = createClient(engine)
|
httpClient = createClient(engine)
|
||||||
syncService = SyncServiceImpl(httpClient, settingsRepository)
|
syncService = SyncServiceImpl(httpClient, settingsRepository, AuthEventBus())
|
||||||
|
|
||||||
// When
|
// When
|
||||||
val result = syncService.uploadInventory(testInventory)
|
val result = syncService.uploadInventory(testInventory)
|
||||||
|
|
@ -299,7 +300,7 @@ class SyncServiceImplTest {
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
httpClient = createClient(engine)
|
httpClient = createClient(engine)
|
||||||
syncService = SyncServiceImpl(httpClient, settingsRepository)
|
syncService = SyncServiceImpl(httpClient, settingsRepository, AuthEventBus())
|
||||||
|
|
||||||
// When
|
// When
|
||||||
val result = syncService.uploadInventory(testInventory)
|
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 androidx.lifecycle.SavedStateHandle
|
||||||
import de.bollwerk.app.data.db.entity.MessageEntity
|
import de.bollwerk.app.data.db.entity.MessageEntity
|
||||||
import de.bollwerk.app.domain.repository.MessageRepository
|
import de.bollwerk.app.domain.repository.MessageRepository
|
||||||
|
import de.bollwerk.app.notification.NotificationHelper
|
||||||
import de.bollwerk.shared.model.UserListItemDto
|
import de.bollwerk.shared.model.UserListItemDto
|
||||||
|
import io.mockk.mockk
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||||
import kotlinx.coroutines.flow.Flow
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
|
@ -58,7 +60,7 @@ class ChatViewModelTest {
|
||||||
repo: MessageRepository = FakeChatMessageRepository()
|
repo: MessageRepository = FakeChatMessageRepository()
|
||||||
): ChatViewModel {
|
): ChatViewModel {
|
||||||
val savedStateHandle = SavedStateHandle(mapOf("recipientId" to recipientId, "recipientUsername" to recipientUsername))
|
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
|
@Test
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue