feat(messaging): ungelesene Nachrichten als Badges anzeigen (#110)

- DB-Migration 7→8: is_read-Spalte in messages (default 1 für bestehende Rows)
- DAO: getUnreadCountsBySender, getTotalUnreadCount, markConversationAsRead
- Repository: totalUnreadCount Flow + getUnreadCountsBySender() + markConversationAsRead()
- ChatViewModel: markConversationAsRead beim Öffnen/Empfangen
- UserListViewModel: unreadCounts StateFlow
- UserListScreen: rote Badge-Anzeige pro Chat
- MainViewModel: totalUnreadCount StateFlow
- MainScreen: BadgedBox am Nachrichten-Icon in Bottom Nav
- NotificationHelper: updateBadgeCount() für Launcher-Badge
- Tests: 3 neue Fälle, FakeDao+FakeRepo aktualisiert (328 Tests grün)
This commit is contained in:
Jens Reinemann 2026-05-18 18:28:49 +02:00
parent 06fa017c04
commit 512829dd49
14 changed files with 208 additions and 22 deletions

View file

@ -19,11 +19,12 @@ import de.bollwerk.app.data.db.entity.SettingsEntity
@Database(
entities = [CategoryEntity::class, LocationEntity::class, ItemEntity::class, SettingsEntity::class, PendingSyncOpEntity::class, MessageEntity::class],
version = 7,
version = 8,
exportSchema = true,
autoMigrations = [
AutoMigration(from = 5, to = 6),
AutoMigration(from = 6, to = 7)
AutoMigration(from = 6, to = 7),
AutoMigration(from = 7, to = 8)
]
)
@TypeConverters(LocalDateConverter::class)

View file

@ -1,5 +1,6 @@
package de.bollwerk.app.data.db.dao
import androidx.room.ColumnInfo
import androidx.room.Dao
import androidx.room.Insert
import androidx.room.OnConflictStrategy
@ -8,6 +9,11 @@ import androidx.room.Upsert
import de.bollwerk.app.data.db.entity.MessageEntity
import kotlinx.coroutines.flow.Flow
internal data class UnreadCountBySender(
@ColumnInfo(name = "sender_id") val senderId: String,
@ColumnInfo(name = "count") val count: Int
)
@Dao
internal interface MessageDao {
@ -30,4 +36,13 @@ internal interface MessageDao {
@Query("UPDATE messages SET is_pending = 0 WHERE id = :id")
suspend fun markDelivered(id: String)
@Query("SELECT sender_id, COUNT(*) as count FROM messages WHERE receiver_id = :myId AND is_read = 0 GROUP BY sender_id")
fun getUnreadCountsBySender(myId: String): Flow<List<UnreadCountBySender>>
@Query("SELECT COUNT(*) FROM messages WHERE receiver_id = :myId AND is_read = 0")
fun getTotalUnreadCount(myId: String): Flow<Int>
@Query("UPDATE messages SET is_read = 1 WHERE receiver_id = :myId AND sender_id = :senderId")
suspend fun markConversationAsRead(myId: String, senderId: String)
}

View file

@ -12,5 +12,8 @@ internal data class MessageEntity(
@ColumnInfo(name = "receiver_id") val receiverId: String,
@ColumnInfo(name = "body") val body: String,
@ColumnInfo(name = "sent_at") val sentAt: Long,
@ColumnInfo(name = "is_pending") val isPending: Boolean
@ColumnInfo(name = "is_pending") val isPending: Boolean,
// defaultValue "1" = existing rows after migration are treated as already read;
// Kotlin default false = new objects start as unread (caller sets explicitly)
@ColumnInfo(name = "is_read", defaultValue = "1") val isRead: Boolean = false
)

View file

@ -28,7 +28,11 @@ import io.ktor.http.HttpStatusCode
import io.ktor.http.contentType
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import kotlinx.serialization.Serializable
@ -50,6 +54,7 @@ private data class SendMessageRequest(
@Serializable
private data class PublicKeyResponse(val publicKey: String)
@OptIn(ExperimentalCoroutinesApi::class)
@Singleton
internal class MessageRepositoryImpl @Inject constructor(
private val dao: MessageDao,
@ -84,7 +89,8 @@ internal class MessageRepositoryImpl @Inject constructor(
receiverId = msg.receiverId,
body = decryptedBody,
sentAt = msg.sentAt,
isPending = false
isPending = false,
isRead = false
)
)
notificationHelper.showNewMessageNotification(
@ -100,6 +106,9 @@ internal class MessageRepositoryImpl @Inject constructor(
}
}
}
scope.launch {
totalUnreadCount.collect { count -> notificationHelper.updateBadgeCount(count) }
}
}
override fun getConversation(myId: String, otherId: String): Flow<List<MessageEntity>> =
@ -126,7 +135,8 @@ internal class MessageRepositoryImpl @Inject constructor(
receiverId = recipientId,
body = body,
sentAt = sentAt,
isPending = true
isPending = true,
isRead = true
)
)
@ -143,7 +153,8 @@ internal class MessageRepositoryImpl @Inject constructor(
receiverId = myId,
body = systemMessage,
sentAt = System.currentTimeMillis(),
isPending = false
isPending = false,
isRead = true
)
)
}
@ -212,7 +223,8 @@ internal class MessageRepositoryImpl @Inject constructor(
receiverId = msg.senderId,
body = systemMessage,
sentAt = System.currentTimeMillis(),
isPending = false
isPending = false,
isRead = true
)
)
}
@ -313,6 +325,27 @@ internal class MessageRepositoryImpl @Inject constructor(
}
}
override val totalUnreadCount: Flow<Int>
get() = settingsRepository.observeString(StringKey.AuthUserId)
.flatMapLatest { myId ->
if (myId.isBlank()) flowOf(0)
else dao.getTotalUnreadCount(myId)
}
override fun getUnreadCountsBySender(): Flow<Map<String, Int>> =
settingsRepository.observeString(StringKey.AuthUserId)
.flatMapLatest { myId ->
if (myId.isBlank()) flowOf(emptyMap())
else dao.getUnreadCountsBySender(myId).map { list ->
list.associate { it.senderId to it.count }
}
}
override suspend fun markConversationAsRead(senderId: String) {
val myId = settingsRepository.getStringOrNull(StringKey.AuthUserId) ?: return
dao.markConversationAsRead(myId = myId, senderId = senderId)
}
private companion object {
const val TAG = "MessageRepository"
const val SYSTEM_SENDER_USERNAME = "⚙️ System"

View file

@ -10,4 +10,7 @@ internal interface MessageRepository {
suspend fun fetchUsers(): Result<List<UserListItemDto>>
suspend fun getMyUserId(): String?
suspend fun drainPendingMessages()
val totalUnreadCount: Flow<Int>
fun getUnreadCountsBySender(): Flow<Map<String, Int>>
suspend fun markConversationAsRead(senderId: String)
}

View file

@ -191,6 +191,42 @@ internal class NotificationHelper @Inject constructor(
}
}
/// Aktualisiert den Launcher-Badge-Zähler für ungelesene Nachrichten.
fun updateBadgeCount(count: Int) {
val notificationManager = NotificationManagerCompat.from(context)
if (count <= 0) {
notificationManager.cancel(SUMMARY_NOTIFICATION_ID)
return
}
// If per-sender notifications are active, the summary notification is already informative.
val hasSenderNotifications = synchronized(activeSenderNotificationIds) {
activeSenderNotificationIds.isNotEmpty()
}
if (hasSenderNotifications) return
val messagesIntent = Intent(context, MainActivity::class.java).apply {
flags = Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_CLEAR_TOP
putExtra(EXTRA_OPEN_MESSAGES, true)
}
val pendingIntent = PendingIntent.getActivity(
context, SUMMARY_NOTIFICATION_ID, messagesIntent,
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
)
val badgeNotification = NotificationCompat.Builder(context, CHANNEL_ID)
.setSmallIcon(R.drawable.ic_notification_message)
.setContentTitle("Ungelesene Nachrichten")
.setContentText("$count ungelesene Nachrichten")
.setNumber(count)
.setGroup(GROUP_KEY)
.setGroupSummary(true)
.setAutoCancel(true)
.setOnlyAlertOnce(true)
.setContentIntent(pendingIntent)
.build()
try {
notificationManager.notify(SUMMARY_NOTIFICATION_ID, badgeNotification)
} catch (_: SecurityException) {}
}
fun cancelAllMessageNotifications() {
val notificationManager = NotificationManagerCompat.from(context)
val ids = synchronized(activeSenderNotificationIds) {

View file

@ -10,6 +10,8 @@ import androidx.compose.foundation.layout.windowInsetsPadding
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.SwapHoriz
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Badge
import androidx.compose.material3.BadgedBox
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
@ -107,6 +109,8 @@ internal fun MainScreen(notificationNavigationEvents: SharedFlow<NotificationNav
val updateViewModel: UpdateViewModel = hiltViewModel()
val updateState by updateViewModel.uiState.collectAsStateWithLifecycle()
val totalUnreadCount by mainViewModel.totalUnreadCount.collectAsStateWithLifecycle()
Scaffold(
topBar = {
if (showTopBar) {
@ -153,19 +157,43 @@ internal fun MainScreen(notificationNavigationEvents: SharedFlow<NotificationNav
}
}
) {
Icon(
imageVector = if (isSelected) {
destination.selectedIcon
} else {
destination.unselectedIcon
},
contentDescription = destination.label,
tint = if (isSelected) {
MaterialTheme.colorScheme.primary
} else {
MaterialTheme.colorScheme.onSurfaceVariant
if (destination == TopLevelDestination.MESSAGES && totalUnreadCount > 0) {
BadgedBox(
badge = {
Badge {
Text(if (totalUnreadCount > 99) "99+" else totalUnreadCount.toString())
}
}
) {
Icon(
imageVector = if (isSelected) {
destination.selectedIcon
} else {
destination.unselectedIcon
},
contentDescription = destination.label,
tint = if (isSelected) {
MaterialTheme.colorScheme.primary
} else {
MaterialTheme.colorScheme.onSurfaceVariant
}
)
}
)
} else {
Icon(
imageVector = if (isSelected) {
destination.selectedIcon
} else {
destination.unselectedIcon
},
contentDescription = destination.label,
tint = if (isSelected) {
MaterialTheme.colorScheme.primary
} else {
MaterialTheme.colorScheme.onSurfaceVariant
}
)
}
}
}
}

View file

@ -9,12 +9,16 @@ import de.bollwerk.app.data.sync.WebSocketEvent
import de.bollwerk.app.domain.AuthEventBus
import de.bollwerk.app.domain.model.SettingsKey.StringKey
import de.bollwerk.app.domain.repository.ImportExportRepository
import de.bollwerk.app.domain.repository.MessageRepository
import de.bollwerk.app.domain.repository.SettingsRepository
import de.bollwerk.app.domain.repository.SyncService
import de.bollwerk.app.notification.NotificationHelper
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import java.time.Instant
import javax.inject.Inject
@ -26,12 +30,16 @@ internal class MainViewModel @Inject constructor(
private val webSocketClient: WebSocketClient,
private val settingsRepository: SettingsRepository,
private val importExportRepository: ImportExportRepository,
private val notificationHelper: NotificationHelper
private val notificationHelper: NotificationHelper,
private val messageRepository: MessageRepository
) : ViewModel() {
private val _navigateToSettings = MutableSharedFlow<Unit>(extraBufferCapacity = 1)
val navigateToSettings: SharedFlow<Unit> = _navigateToSettings.asSharedFlow()
val totalUnreadCount: StateFlow<Int> = messageRepository.totalUnreadCount
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), 0)
init {
connectOnStartup()
observeLoginSuccess()

View file

@ -43,6 +43,7 @@ internal class ChatViewModel @Inject constructor(
if (myId.isNotEmpty()) {
messageRepository.getConversation(myId, recipientId).collect { messages ->
_uiState.update { it.copy(messages = messages) }
messageRepository.markConversationAsRead(recipientId)
}
}
}

View file

@ -10,6 +10,8 @@ import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.Person
import androidx.compose.material3.Badge
import androidx.compose.material3.BadgedBox
import androidx.compose.material3.Button
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api
@ -36,6 +38,7 @@ internal fun UserListScreen(
viewModel: UserListViewModel = hiltViewModel()
) {
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
val unreadCounts by viewModel.unreadCounts.collectAsStateWithLifecycle()
Scaffold(
topBar = {
@ -66,7 +69,11 @@ internal fun UserListScreen(
)
else -> LazyColumn {
items(uiState.users) { user ->
UserListItem(user = user, onClick = { onUserClick(user.id, user.username) })
UserListItem(
user = user,
unreadCount = unreadCounts[user.id] ?: 0,
onClick = { onUserClick(user.id, user.username) }
)
}
}
}
@ -75,12 +82,17 @@ internal fun UserListScreen(
}
@Composable
private fun UserListItem(user: UserListItemDto, onClick: () -> Unit) {
private fun UserListItem(user: UserListItemDto, unreadCount: Int, onClick: () -> Unit) {
ListItem(
headlineContent = { Text(user.username) },
leadingContent = {
Icon(imageVector = Icons.Outlined.Person, contentDescription = null)
},
trailingContent = {
if (unreadCount > 0) {
Badge { Text(if (unreadCount > 99) "99+" else unreadCount.toString()) }
}
},
modifier = Modifier.clickable(onClick = onClick)
)
HorizontalDivider()

View file

@ -6,7 +6,9 @@ import dagger.hilt.android.lifecycle.HiltViewModel
import de.bollwerk.app.domain.repository.MessageRepository
import de.bollwerk.shared.model.UserListItemDto
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import javax.inject.Inject
@ -24,6 +26,9 @@ internal class UserListViewModel @Inject constructor(
private val _uiState = MutableStateFlow(UserListUiState(isLoading = true))
val uiState: StateFlow<UserListUiState> = _uiState
val unreadCounts: StateFlow<Map<String, Int>> = messageRepository.getUnreadCountsBySender()
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptyMap())
init {
loadUsers()
}

View file

@ -1,6 +1,7 @@
package de.bollwerk.app.data.repository
import de.bollwerk.app.data.db.dao.MessageDao
import de.bollwerk.app.data.db.dao.UnreadCountBySender
import de.bollwerk.app.data.db.entity.MessageEntity
import de.bollwerk.app.data.security.E2EEKeyManager
import de.bollwerk.app.data.sync.WebSocketClient
@ -31,6 +32,7 @@ import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.advanceUntilIdle
import kotlinx.coroutines.test.runTest
@ -45,6 +47,7 @@ import org.junit.Test
private class FakeMessageDao : MessageDao {
val upserted = mutableListOf<MessageEntity>()
val delivered = mutableListOf<String>()
val markedAsRead = mutableListOf<Pair<String, String>>() // myId to senderId
override suspend fun upsert(message: MessageEntity) { upserted.add(message) }
override suspend fun insertIfNotExists(message: MessageEntity) { upserted.add(message) }
@ -60,6 +63,11 @@ private class FakeMessageDao : MessageDao {
val idx = upserted.indexOfFirst { it.id == id }
if (idx >= 0) upserted[idx] = upserted[idx].copy(isPending = false)
}
override fun getUnreadCountsBySender(myId: String): Flow<List<UnreadCountBySender>> = flowOf(emptyList())
override fun getTotalUnreadCount(myId: String): Flow<Int> = flowOf(0)
override suspend fun markConversationAsRead(myId: String, senderId: String) {
markedAsRead.add(myId to senderId)
}
}
private class FakeMessageSettingsRepository : SettingsRepository {
@ -277,6 +285,31 @@ class MessageRepositoryImplTest {
// Then decryptMessage is mocked to return plaintext as-is
assertTrue(dao.upserted.any { it.id == "m1" && !it.isPending })
assertTrue("New incoming message must be stored as unread",
dao.upserted.any { it.id == "m1" && !it.isRead })
}
@Test
fun test_markConversationAsRead_delegatesToDao() = runTest {
// Given
val dao = FakeMessageDao()
val settings = FakeMessageSettingsRepository().apply {
set(SettingsKeys.AUTH_USER_ID, "user1")
}
val repo = buildRepository(
dao = dao,
httpClient = createClient(MockEngine { respondError(HttpStatusCode.ServiceUnavailable) }),
settings = settings
)
// When
repo.markConversationAsRead(senderId = "user2")
// Then
assertTrue(
"markConversationAsRead must forward myId and senderId to DAO",
dao.markedAsRead.contains("user1" to "user2")
)
}
@Test

View file

@ -10,6 +10,7 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.test.StandardTestDispatcher
import kotlinx.coroutines.test.advanceUntilIdle
import kotlinx.coroutines.test.resetMain
@ -29,7 +30,10 @@ private class FakeChatMessageRepository(
var sendMessageCalled = false
var lastSentBody: String? = null
override val totalUnreadCount: Flow<Int> = flowOf(0)
override fun getConversation(myId: String, otherId: String): Flow<List<MessageEntity>> = conversation
override fun getUnreadCountsBySender(): Flow<Map<String, Int>> = flowOf(emptyMap())
override suspend fun markConversationAsRead(senderId: String) = Unit
override suspend fun sendMessage(recipientId: String, body: String) {
sendMessageCalled = true
lastSentBody = body

View file

@ -8,6 +8,7 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.test.StandardTestDispatcher
import kotlinx.coroutines.test.advanceUntilIdle
import kotlinx.coroutines.test.resetMain
@ -27,8 +28,11 @@ private class FakeUserListMessageRepository(
) : MessageRepository {
var fetchCount = 0
override val totalUnreadCount: Flow<Int> = flowOf(0)
override fun getConversation(myId: String, otherId: String): Flow<List<MessageEntity>> =
MutableStateFlow(emptyList())
override fun getUnreadCountsBySender(): Flow<Map<String, Int>> = flowOf(emptyMap())
override suspend fun markConversationAsRead(senderId: String) = Unit
override suspend fun sendMessage(recipientId: String, body: String) = Unit
override suspend fun fetchUsers(): Result<List<UserListItemDto>> {
fetchCount++