feat: Messaging-System mit Offline-First und WebSocket-Push (#58)

## Server
- Messages-Tabelle (id, sender_id, receiver_id, body, sent_at, delivered_at)
- MessageRepository: save/getUndelivered/getConversation/markDelivered (JOIN statt N+1)
- POST /api/messages, GET /api/messages/{userId}: Nachrichten senden/abrufen
- GET /api/users: User-Liste fuer authentifizierte User (ohne eigenen Account)
- WebSocketManager: notifyNewMessage() + isOnline()
- WebSocketRoutes: unzugestellte Nachrichten bei Reconnect pushen
- LoginResponse: userId + username ergaenzt
- Server-Dependency: kotlinx.serialization fuer shared

## App
- MessageEntity + MessageDao (Room, Migration 3->4)
- KrisenvorratDatabase v4, Migrations.MIGRATION_3_4
- MessageRepositoryImpl: Offline-First (isPending), drain bei WebSocket-Connect
- WebSocketEvent.NewMessage -> MessageDto aus shared
- WebSocketClientImpl: new_message-Event parsen
- AUTH_USER_ID in SettingsKeys, SyncServiceImpl speichert userId bei Login
- UserListScreen + UserListViewModel: User-Liste anzeigen
- ChatScreen + ChatViewModel: WhatsApp-Style Chat (links/rechts, Zeitstempel)
- Navigation: Screen.UserList, Screen.Chat, MESSAGES in Bottom-Nav
- RepositoryModule: MessageRepository gebunden

## Tests
- 234 Tests, 0 Fehler
This commit is contained in:
Jens Reinemann 2026-05-16 23:35:25 +02:00
parent 1d7a62448a
commit 56ac9b1425
39 changed files with 1814 additions and 22 deletions

View file

@ -112,24 +112,52 @@ services:
ports:
- '8080:8080'
environment:
- KRISENVORRAT_API_KEY=<key>
- KRISENVORRAT_JWT_SECRET=<secret>
volumes:
- ./data:/app/data
```
**Hinweis:** Den echten API-Key NICHT in Skill-Dateien oder Git speichern. Er liegt nur in der `docker-compose.yml` auf dem VPS.
**Hinweis:** Das JWT-Secret NICHT in Skill-Dateien oder Git speichern. Es liegt nur in der `docker-compose.yml` auf dem VPS.
---
## Authentifizierung
Der Server nutzt JWT-basierte Authentifizierung (kein API-Key mehr).
### Admin-Zugang
- **Admin-UI:** `http://195.246.231.210:8080/admin/`
- **Admin-User:** `admin`
- **Admin-Passwort:** Der User muss das Passwort selbst eingeben. Es ist NICHT gespeichert bei Bedarf den User fragen.
- Beim ersten Start ohne `KRISENVORRAT_ADMIN_PASSWORD` ENV wird ein zufälliges Passwort generiert und in die Logs geschrieben.
### Environment-Variablen
| Variable | Pflicht | Beschreibung |
| ------------------------------- | ------- | --------------------------------------------------- |
| `KRISENVORRAT_JWT_SECRET` | ja | Secret für JWT-Token-Signierung (mind. 32 Zeichen) |
| `KRISENVORRAT_ADMIN_PASSWORD` | nein | Admin-Passwort beim ersten Start (sonst auto-gen.) |
---
## Server-Endpunkte
| Endpunkt | Auth | Beschreibung |
| ----------------------- | ------- | ------------------ |
| `GET /api/health` | nein | Health-Check → "OK"|
| `GET /api/inventory` | API-Key | Inventar abrufen |
| `PUT /api/inventory` | API-Key | Inventar hochladen |
| ------------------------------ | ----- | ------------------------------------- |
| `GET /api/health` | nein | Health-Check → "OK" |
| `POST /api/auth/login` | nein | Login → JWT (Access + Refresh Token) |
| `POST /api/auth/refresh` | nein | Access-Token erneuern |
| `GET /api/inventory` | JWT | Inventar des Users abrufen |
| `PUT /api/inventory` | JWT | Inventar des Users hochladen |
| `PATCH /api/inventory/items/{id}` | JWT | Einzelnen Artikel updaten |
| `GET /api/admin/users` | Admin | Alle User auflisten |
| `POST /api/admin/users` | Admin | Neuen User anlegen |
| `PUT /api/admin/users/{id}` | Admin | Passwort ändern |
| `DELETE /api/admin/users/{id}` | Admin | User löschen |
| `WS /ws/sync` | JWT | WebSocket für Push-Benachrichtigungen |
API-Key wird als `Authorization: Bearer <key>` oder `X-API-Key: <key>` Header mitgeschickt.
JWT wird als `Authorization: Bearer <accessToken>` Header mitgeschickt.
---

View file

@ -0,0 +1,314 @@
{
"formatVersion": 1,
"database": {
"version": 4,
"identityHash": "1008ae2dd73e9444de995dcb89dcfa43",
"entities": [
{
"tableName": "categories",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL)",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "name",
"columnName": "name",
"affinity": "TEXT",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": true,
"columnNames": [
"id"
]
},
"indices": [],
"foreignKeys": []
},
{
"tableName": "locations",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL)",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "name",
"columnName": "name",
"affinity": "TEXT",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": true,
"columnNames": [
"id"
]
},
"indices": [],
"foreignKeys": []
},
{
"tableName": "items",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `name` TEXT NOT NULL, `category_id` INTEGER NOT NULL, `quantity` REAL NOT NULL, `unit` TEXT NOT NULL, `unit_price` REAL NOT NULL, `kcal_per_kg` INTEGER, `expiry_date` TEXT, `location_id` INTEGER NOT NULL, `notes` TEXT NOT NULL, `last_updated` INTEGER NOT NULL, PRIMARY KEY(`id`), FOREIGN KEY(`category_id`) REFERENCES `categories`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`location_id`) REFERENCES `locations`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "name",
"columnName": "name",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "categoryId",
"columnName": "category_id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "quantity",
"columnName": "quantity",
"affinity": "REAL",
"notNull": true
},
{
"fieldPath": "unit",
"columnName": "unit",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "unitPrice",
"columnName": "unit_price",
"affinity": "REAL",
"notNull": true
},
{
"fieldPath": "kcalPerKg",
"columnName": "kcal_per_kg",
"affinity": "INTEGER",
"notNull": false
},
{
"fieldPath": "expiryDate",
"columnName": "expiry_date",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "locationId",
"columnName": "location_id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "notes",
"columnName": "notes",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "lastUpdated",
"columnName": "last_updated",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"id"
]
},
"indices": [
{
"name": "index_items_category_id",
"unique": false,
"columnNames": [
"category_id"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_items_category_id` ON `${TABLE_NAME}` (`category_id`)"
},
{
"name": "index_items_location_id",
"unique": false,
"columnNames": [
"location_id"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_items_location_id` ON `${TABLE_NAME}` (`location_id`)"
}
],
"foreignKeys": [
{
"table": "categories",
"onDelete": "CASCADE",
"onUpdate": "NO ACTION",
"columns": [
"category_id"
],
"referencedColumns": [
"id"
]
},
{
"table": "locations",
"onDelete": "CASCADE",
"onUpdate": "NO ACTION",
"columns": [
"location_id"
],
"referencedColumns": [
"id"
]
}
]
},
{
"tableName": "settings",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`key` TEXT NOT NULL, `value` TEXT NOT NULL, PRIMARY KEY(`key`))",
"fields": [
{
"fieldPath": "key",
"columnName": "key",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "value",
"columnName": "value",
"affinity": "TEXT",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"key"
]
},
"indices": [],
"foreignKeys": []
},
{
"tableName": "pending_sync_ops",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `item_id` TEXT NOT NULL, `operation` TEXT NOT NULL, `payload` TEXT NOT NULL, `created_at` INTEGER NOT NULL, PRIMARY KEY(`id`))",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "itemId",
"columnName": "item_id",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "operation",
"columnName": "operation",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "payload",
"columnName": "payload",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "createdAt",
"columnName": "created_at",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"id"
]
},
"indices": [],
"foreignKeys": []
},
{
"tableName": "messages",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `sender_id` TEXT NOT NULL, `sender_username` TEXT NOT NULL, `receiver_id` TEXT NOT NULL, `body` TEXT NOT NULL, `sent_at` INTEGER NOT NULL, `is_pending` INTEGER NOT NULL, PRIMARY KEY(`id`))",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "senderId",
"columnName": "sender_id",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "senderUsername",
"columnName": "sender_username",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "receiverId",
"columnName": "receiver_id",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "body",
"columnName": "body",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "sentAt",
"columnName": "sent_at",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "isPending",
"columnName": "is_pending",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"id"
]
},
"indices": [],
"foreignKeys": []
}
],
"views": [],
"setupQueries": [
"CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
"INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '1008ae2dd73e9444de995dcb89dcfa43')"
]
}
}

View file

@ -6,17 +6,19 @@ import androidx.room.TypeConverters
import de.krisenvorrat.app.data.db.dao.CategoryDao
import de.krisenvorrat.app.data.db.dao.ItemDao
import de.krisenvorrat.app.data.db.dao.LocationDao
import de.krisenvorrat.app.data.db.dao.MessageDao
import de.krisenvorrat.app.data.db.dao.PendingSyncOpDao
import de.krisenvorrat.app.data.db.dao.SettingsDao
import de.krisenvorrat.app.data.db.entity.CategoryEntity
import de.krisenvorrat.app.data.db.entity.ItemEntity
import de.krisenvorrat.app.data.db.entity.LocationEntity
import de.krisenvorrat.app.data.db.entity.MessageEntity
import de.krisenvorrat.app.data.db.entity.PendingSyncOpEntity
import de.krisenvorrat.app.data.db.entity.SettingsEntity
@Database(
entities = [CategoryEntity::class, LocationEntity::class, ItemEntity::class, SettingsEntity::class, PendingSyncOpEntity::class],
version = 3,
entities = [CategoryEntity::class, LocationEntity::class, ItemEntity::class, SettingsEntity::class, PendingSyncOpEntity::class, MessageEntity::class],
version = 4,
exportSchema = true
)
@TypeConverters(LocalDateConverter::class)
@ -26,4 +28,5 @@ internal abstract class KrisenvorratDatabase : RoomDatabase() {
abstract fun itemDao(): ItemDao
abstract fun settingsDao(): SettingsDao
abstract fun pendingSyncOpDao(): PendingSyncOpDao
abstract fun messageDao(): MessageDao
}

View file

@ -85,4 +85,23 @@ internal object Migrations {
)
}
}
val MIGRATION_3_4 = object : Migration(3, 4) {
override fun migrate(db: SupportSQLiteDatabase) {
db.execSQL(
"""
CREATE TABLE IF NOT EXISTS `messages` (
`id` TEXT NOT NULL,
`sender_id` TEXT NOT NULL,
`sender_username` TEXT NOT NULL,
`receiver_id` TEXT NOT NULL,
`body` TEXT NOT NULL,
`sent_at` INTEGER NOT NULL,
`is_pending` INTEGER NOT NULL,
PRIMARY KEY(`id`)
)
""".trimIndent()
)
}
}
}

View file

@ -0,0 +1,33 @@
package de.krisenvorrat.app.data.db.dao
import androidx.room.Dao
import androidx.room.Insert
import androidx.room.OnConflictStrategy
import androidx.room.Query
import androidx.room.Upsert
import de.krisenvorrat.app.data.db.entity.MessageEntity
import kotlinx.coroutines.flow.Flow
@Dao
internal interface MessageDao {
@Upsert
suspend fun upsert(message: MessageEntity)
@Insert(onConflict = OnConflictStrategy.IGNORE)
suspend fun insertIfNotExists(message: MessageEntity)
@Query("""
SELECT * FROM messages
WHERE (sender_id = :myId AND receiver_id = :otherId)
OR (sender_id = :otherId AND receiver_id = :myId)
ORDER BY sent_at ASC
""")
fun getConversation(myId: String, otherId: String): Flow<List<MessageEntity>>
@Query("SELECT * FROM messages WHERE is_pending = 1 ORDER BY sent_at ASC")
suspend fun getPendingMessages(): List<MessageEntity>
@Query("UPDATE messages SET is_pending = 0 WHERE id = :id")
suspend fun markDelivered(id: String)
}

View file

@ -0,0 +1,16 @@
package de.krisenvorrat.app.data.db.entity
import androidx.room.ColumnInfo
import androidx.room.Entity
import androidx.room.PrimaryKey
@Entity(tableName = "messages")
internal data class MessageEntity(
@PrimaryKey val id: String,
@ColumnInfo(name = "sender_id") val senderId: String,
@ColumnInfo(name = "sender_username") val senderUsername: String,
@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
)

View file

@ -0,0 +1,169 @@
package de.krisenvorrat.app.data.repository
import de.krisenvorrat.app.data.db.dao.MessageDao
import de.krisenvorrat.app.data.db.entity.MessageEntity
import de.krisenvorrat.app.data.sync.WebSocketClient
import de.krisenvorrat.app.data.sync.WebSocketEvent
import de.krisenvorrat.app.di.ApplicationScope
import de.krisenvorrat.app.domain.model.SettingsKeys
import de.krisenvorrat.app.domain.model.SyncError
import de.krisenvorrat.app.domain.repository.MessageRepository
import de.krisenvorrat.app.domain.repository.SettingsRepository
import de.krisenvorrat.shared.model.UserListItemDto
import io.ktor.client.HttpClient
import io.ktor.client.call.body
import io.ktor.client.request.get
import io.ktor.client.request.header
import io.ktor.client.request.post
import io.ktor.client.request.setBody
import io.ktor.http.ContentType
import io.ktor.http.HttpStatusCode
import io.ktor.http.contentType
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import kotlinx.serialization.Serializable
import java.net.ConnectException
import java.net.SocketTimeoutException
import java.util.UUID
import javax.inject.Inject
import javax.inject.Singleton
@Serializable
private data class SendMessageRequest(
val id: String,
val receiverId: String,
val body: String,
val sentAt: Long
)
@Singleton
internal class MessageRepositoryImpl @Inject constructor(
private val dao: MessageDao,
private val httpClient: HttpClient,
private val settingsRepository: SettingsRepository,
private val webSocketClient: WebSocketClient,
@ApplicationScope private val scope: CoroutineScope
) : MessageRepository {
init {
scope.launch {
webSocketClient.events.collect { event ->
when (event) {
is WebSocketEvent.NewMessage -> {
val msg = event.message
dao.upsert(
MessageEntity(
id = msg.id,
senderId = msg.senderId,
senderUsername = msg.senderUsername,
receiverId = msg.receiverId,
body = msg.body,
sentAt = msg.sentAt,
isPending = false
)
)
}
is WebSocketEvent.Connected -> drainPendingMessages()
else -> Unit
}
}
}
}
override fun getConversation(myId: String, otherId: String): Flow<List<MessageEntity>> =
dao.getConversation(myId, otherId)
override suspend fun sendMessage(recipientId: String, body: String) {
val myId = settingsRepository.getValue(SettingsKeys.AUTH_USER_ID) ?: return
val myUsername = settingsRepository.getValue(SettingsKeys.AUTH_USERNAME) ?: ""
val localId = UUID.randomUUID().toString()
val sentAt = System.currentTimeMillis()
dao.upsert(
MessageEntity(
id = localId,
senderId = myId,
senderUsername = myUsername,
receiverId = recipientId,
body = body,
sentAt = sentAt,
isPending = true
)
)
val result = attemptSendToServer(localId, recipientId, body, sentAt)
if (result.isSuccess) {
dao.markDelivered(localId)
}
}
override suspend fun fetchUsers(): Result<List<UserListItemDto>> =
withContext(Dispatchers.IO) {
val serverUrl = settingsRepository.getValue(SettingsKeys.SERVER_URL)
?: return@withContext Result.failure(SyncError.NotConfigured("Server-URL nicht gesetzt"))
val token = settingsRepository.getValue(SettingsKeys.AUTH_ACCESS_TOKEN)
?: return@withContext Result.failure(SyncError.NotConfigured("Nicht angemeldet"))
try {
val response = httpClient.get("${serverUrl.trimEnd('/')}/api/users") {
header("Authorization", "Bearer $token")
}
if (response.status == HttpStatusCode.OK) {
Result.success(response.body<List<UserListItemDto>>())
} else {
Result.failure(SyncError.ServerError(response.status.value, response.status.description))
}
} catch (e: SocketTimeoutException) {
Result.failure(SyncError.Timeout(e))
} catch (e: ConnectException) {
Result.failure(SyncError.ConnectionError(e))
} catch (e: Exception) {
Result.failure(SyncError.Unknown(e))
}
}
override suspend fun getMyUserId(): String? =
settingsRepository.getValue(SettingsKeys.AUTH_USER_ID)
override suspend fun drainPendingMessages() {
val pending = withContext(Dispatchers.IO) { dao.getPendingMessages() }
for (msg in pending) {
val result = attemptSendToServer(msg.id, msg.receiverId, msg.body, msg.sentAt)
if (result.isSuccess) {
withContext(Dispatchers.IO) { dao.markDelivered(msg.id) }
}
}
}
private suspend fun attemptSendToServer(
id: String,
recipientId: String,
body: String,
sentAt: Long
): Result<Unit> = withContext(Dispatchers.IO) {
val serverUrl = settingsRepository.getValue(SettingsKeys.SERVER_URL)
?: return@withContext Result.failure(SyncError.NotConfigured("Server-URL nicht gesetzt"))
val token = settingsRepository.getValue(SettingsKeys.AUTH_ACCESS_TOKEN)
?: return@withContext Result.failure(SyncError.NotConfigured("Nicht angemeldet"))
try {
val response = httpClient.post("${serverUrl.trimEnd('/')}/api/messages") {
header("Authorization", "Bearer $token")
contentType(ContentType.Application.Json)
setBody(SendMessageRequest(id = id, receiverId = recipientId, body = body, sentAt = sentAt))
}
if (response.status == HttpStatusCode.Created || response.status == HttpStatusCode.OK) {
Result.success(Unit)
} else {
Result.failure(SyncError.ServerError(response.status.value, response.status.description))
}
} catch (e: SocketTimeoutException) {
Result.failure(SyncError.Timeout(e))
} catch (e: ConnectException) {
Result.failure(SyncError.ConnectionError(e))
} catch (e: Exception) {
Result.failure(SyncError.Unknown(e))
}
}
}

View file

@ -6,7 +6,12 @@ import kotlinx.serialization.Serializable
internal data class LoginRequest(val username: String, val password: String)
@Serializable
internal data class LoginResponse(val accessToken: String, val refreshToken: String)
internal data class LoginResponse(
val accessToken: String,
val refreshToken: String,
val userId: String,
val username: String
)
@Serializable
internal data class RefreshRequest(val refreshToken: String)

View file

@ -83,6 +83,7 @@ internal class SyncServiceImpl @Inject constructor(
settingsRepository.setValue(KEY_ACCESS_TOKEN, loginResponse.accessToken)
settingsRepository.setValue(KEY_REFRESH_TOKEN, loginResponse.refreshToken)
settingsRepository.setValue(KEY_AUTH_USERNAME, username)
settingsRepository.setValue(KEY_AUTH_USER_ID, loginResponse.userId)
Result.success(Unit)
}
HttpStatusCode.Unauthorized -> Result.failure(SyncError.AuthError())
@ -212,5 +213,6 @@ internal class SyncServiceImpl @Inject constructor(
val KEY_ACCESS_TOKEN = SettingsKeys.AUTH_ACCESS_TOKEN
val KEY_REFRESH_TOKEN = SettingsKeys.AUTH_REFRESH_TOKEN
val KEY_AUTH_USERNAME = SettingsKeys.AUTH_USERNAME
val KEY_AUTH_USER_ID = SettingsKeys.AUTH_USER_ID
}
}

View file

@ -1,5 +1,6 @@
package de.krisenvorrat.app.data.sync
import de.krisenvorrat.shared.model.MessageDto
import kotlinx.coroutines.flow.SharedFlow
internal interface WebSocketClient {
@ -13,4 +14,5 @@ internal sealed interface WebSocketEvent {
data object FullSyncRequired : WebSocketEvent
data object Connected : WebSocketEvent
data object Disconnected : WebSocketEvent
data class NewMessage(val message: MessageDto) : WebSocketEvent
}

View file

@ -79,6 +79,18 @@ internal class WebSocketClientImpl @Inject constructor() : WebSocketClient {
when (event.type) {
"inventoryUpdated" -> _events.emit(WebSocketEvent.InventoryUpdated(event.itemId ?: ""))
"fullSyncRequired" -> _events.emit(WebSocketEvent.FullSyncRequired)
"new_message" -> {
val msg = de.krisenvorrat.shared.model.MessageDto(
id = event.id ?: return,
senderId = event.senderId ?: return,
senderUsername = event.senderUsername ?: return,
receiverId = event.receiverId ?: return,
body = event.body ?: return,
sentAt = event.sentAt ?: return,
deliveredAt = null
)
_events.emit(WebSocketEvent.NewMessage(msg))
}
}
} catch (_: Exception) {
// ignore malformed events
@ -95,5 +107,11 @@ internal class WebSocketClientImpl @Inject constructor() : WebSocketClient {
@Serializable
private data class WsServerEvent(
val type: String,
val itemId: String? = null
val itemId: String? = null,
val id: String? = null,
val senderId: String? = null,
val senderUsername: String? = null,
val receiverId: String? = null,
val body: String? = null,
val sentAt: Long? = null
)

View file

@ -15,6 +15,7 @@ import de.krisenvorrat.app.data.db.Migrations
import de.krisenvorrat.app.data.db.dao.CategoryDao
import de.krisenvorrat.app.data.db.dao.ItemDao
import de.krisenvorrat.app.data.db.dao.LocationDao
import de.krisenvorrat.app.data.db.dao.MessageDao
import de.krisenvorrat.app.data.db.dao.PendingSyncOpDao
import de.krisenvorrat.app.data.db.dao.SettingsDao
import de.krisenvorrat.app.data.export.DatabaseTransaction
@ -29,7 +30,7 @@ internal object DatabaseModule {
fun provideDatabase(@ApplicationContext context: Context): KrisenvorratDatabase =
Room.databaseBuilder(context, KrisenvorratDatabase::class.java, "krisenvorrat.db")
.addCallback(DefaultDataCallback)
.addMigrations(Migrations.MIGRATION_1_2, Migrations.MIGRATION_2_3)
.addMigrations(Migrations.MIGRATION_1_2, Migrations.MIGRATION_2_3, Migrations.MIGRATION_3_4)
.build()
private object DefaultDataCallback : RoomDatabase.Callback() {
@ -65,6 +66,9 @@ internal object DatabaseModule {
@Provides
fun provideSettingsDao(db: KrisenvorratDatabase): SettingsDao = db.settingsDao()
@Provides
fun provideMessageDao(db: KrisenvorratDatabase): MessageDao = db.messageDao()
@Provides
@Singleton
fun provideDatabaseTransaction(db: KrisenvorratDatabase): DatabaseTransaction =

View file

@ -8,11 +8,13 @@ import de.krisenvorrat.app.data.export.ImportExportRepositoryImpl
import de.krisenvorrat.app.data.repository.CategoryRepositoryImpl
import de.krisenvorrat.app.data.repository.ItemRepositoryImpl
import de.krisenvorrat.app.data.repository.LocationRepositoryImpl
import de.krisenvorrat.app.data.repository.MessageRepositoryImpl
import de.krisenvorrat.app.data.repository.SettingsRepositoryImpl
import de.krisenvorrat.app.domain.repository.CategoryRepository
import de.krisenvorrat.app.domain.repository.ImportExportRepository
import de.krisenvorrat.app.domain.repository.ItemRepository
import de.krisenvorrat.app.domain.repository.LocationRepository
import de.krisenvorrat.app.domain.repository.MessageRepository
import de.krisenvorrat.app.domain.repository.SettingsRepository
import javax.inject.Singleton
@ -39,4 +41,8 @@ internal abstract class RepositoryModule {
@Binds
@Singleton
abstract fun bindImportExportRepository(impl: ImportExportRepositoryImpl): ImportExportRepository
@Binds
@Singleton
abstract fun bindMessageRepository(impl: MessageRepositoryImpl): MessageRepository
}

View file

@ -8,6 +8,7 @@ internal object SettingsKeys {
const val AUTH_ACCESS_TOKEN = "auth_access_token"
const val AUTH_REFRESH_TOKEN = "auth_refresh_token"
const val AUTH_USERNAME = "auth_username"
const val AUTH_USER_ID = "auth_user_id"
const val SYNC_LAST_TIMESTAMP = "sync_last_timestamp"
const val OPENAI_API_KEY = "openai_api_key"
}

View file

@ -0,0 +1,13 @@
package de.krisenvorrat.app.domain.repository
import de.krisenvorrat.app.data.db.entity.MessageEntity
import de.krisenvorrat.shared.model.UserListItemDto
import kotlinx.coroutines.flow.Flow
internal interface MessageRepository {
fun getConversation(myId: String, otherId: String): Flow<List<MessageEntity>>
suspend fun sendMessage(recipientId: String, body: String)
suspend fun fetchUsers(): Result<List<UserListItemDto>>
suspend fun getMyUserId(): String?
suspend fun drainPendingMessages()
}

View file

@ -0,0 +1,181 @@
package de.krisenvorrat.app.ui.messaging
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.automirrored.filled.Send
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import de.krisenvorrat.app.data.db.entity.MessageEntity
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
@OptIn(ExperimentalMaterial3Api::class)
@Composable
internal fun ChatScreen(
onNavigateBack: () -> Unit,
viewModel: ChatViewModel = hiltViewModel()
) {
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
val listState = rememberLazyListState()
LaunchedEffect(uiState.messages.size) {
if (uiState.messages.isNotEmpty()) {
listState.animateScrollToItem(uiState.messages.size - 1)
}
}
Scaffold(
topBar = {
TopAppBar(
title = { Text(uiState.recipientUsername) },
navigationIcon = {
IconButton(onClick = onNavigateBack) {
Icon(
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
contentDescription = "Zurück"
)
}
}
)
}
) { padding ->
Column(
modifier = Modifier
.fillMaxSize()
.padding(padding)
) {
LazyColumn(
modifier = Modifier.weight(1f),
state = listState,
contentPadding = PaddingValues(horizontal = 8.dp, vertical = 8.dp),
verticalArrangement = Arrangement.spacedBy(4.dp)
) {
items(uiState.messages, key = { it.id }) { message ->
MessageBubble(
message = message,
isMine = message.senderId == uiState.myUserId
)
}
}
MessageInputBar(
text = uiState.inputText,
onTextChange = { viewModel.onInputChanged(it) },
onSend = { viewModel.sendMessage() },
isSending = uiState.isSending
)
}
}
}
@Composable
private fun MessageBubble(message: MessageEntity, isMine: Boolean) {
val horizontalAlignment = if (isMine) Alignment.End else Alignment.Start
val bubbleColor = if (isMine) {
MaterialTheme.colorScheme.primaryContainer
} else {
MaterialTheme.colorScheme.surfaceVariant
}
val timeFormat = remember { SimpleDateFormat("HH:mm", Locale.getDefault()) }
Column(
modifier = Modifier.fillMaxWidth(),
horizontalAlignment = horizontalAlignment
) {
if (!isMine) {
Text(
text = message.senderUsername,
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(start = 4.dp, bottom = 2.dp)
)
}
Surface(
shape = RoundedCornerShape(
topStart = if (isMine) 12.dp else 2.dp,
topEnd = if (isMine) 2.dp else 12.dp,
bottomStart = 12.dp,
bottomEnd = 12.dp
),
color = bubbleColor,
modifier = Modifier.widthIn(max = 280.dp)
) {
Column(modifier = Modifier.padding(horizontal = 12.dp, vertical = 8.dp)) {
Text(
text = message.body,
style = MaterialTheme.typography.bodyMedium
)
Text(
text = timeFormat.format(Date(message.sentAt)),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.align(Alignment.End)
)
}
}
}
}
@Composable
private fun MessageInputBar(
text: String,
onTextChange: (String) -> Unit,
onSend: () -> Unit,
isSending: Boolean
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(8.dp),
verticalAlignment = Alignment.CenterVertically
) {
OutlinedTextField(
value = text,
onValueChange = onTextChange,
modifier = Modifier.weight(1f),
placeholder = { Text("Nachricht...") },
maxLines = 4,
enabled = !isSending
)
Spacer(modifier = Modifier.width(8.dp))
IconButton(
onClick = onSend,
enabled = text.isNotBlank() && !isSending
) {
Icon(
imageVector = Icons.AutoMirrored.Filled.Send,
contentDescription = "Senden"
)
}
}
}

View file

@ -0,0 +1,60 @@
package de.krisenvorrat.app.ui.messaging
import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import de.krisenvorrat.app.data.db.entity.MessageEntity
import de.krisenvorrat.app.domain.repository.MessageRepository
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import javax.inject.Inject
internal data class ChatUiState(
val messages: List<MessageEntity> = emptyList(),
val myUserId: String = "",
val recipientUsername: String = "",
val inputText: String = "",
val isSending: Boolean = false
)
@HiltViewModel
internal class ChatViewModel @Inject constructor(
savedStateHandle: SavedStateHandle,
private val messageRepository: MessageRepository
) : ViewModel() {
private val recipientId: String = savedStateHandle.get<String>("recipientId") ?: ""
private val recipientUsername: String = savedStateHandle.get<String>("recipientUsername") ?: ""
private val _uiState = MutableStateFlow(ChatUiState(recipientUsername = recipientUsername))
val uiState: StateFlow<ChatUiState> = _uiState
init {
viewModelScope.launch {
val myId = messageRepository.getMyUserId() ?: ""
_uiState.update { it.copy(myUserId = myId) }
if (myId.isNotEmpty()) {
messageRepository.getConversation(myId, recipientId).collect { messages ->
_uiState.update { it.copy(messages = messages) }
}
}
}
}
fun onInputChanged(text: String) {
_uiState.update { it.copy(inputText = text) }
}
fun sendMessage() {
val text = _uiState.value.inputText.trim()
if (text.isEmpty()) return
viewModelScope.launch {
_uiState.update { it.copy(isSending = true, inputText = "") }
messageRepository.sendMessage(recipientId = recipientId, body = text)
_uiState.update { it.copy(isSending = false) }
}
}
}

View file

@ -0,0 +1,87 @@
package de.krisenvorrat.app.ui.messaging
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
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.Button
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.ListItem
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import de.krisenvorrat.shared.model.UserListItemDto
@OptIn(ExperimentalMaterial3Api::class)
@Composable
internal fun UserListScreen(
onUserClick: (id: String, username: String) -> Unit,
viewModel: UserListViewModel = hiltViewModel()
) {
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
Scaffold(
topBar = {
TopAppBar(title = { Text("Nachrichten") })
}
) { padding ->
Box(
modifier = Modifier
.fillMaxSize()
.padding(padding)
) {
when {
uiState.isLoading -> CircularProgressIndicator(modifier = Modifier.align(Alignment.Center))
uiState.errorMessage != null -> Column(
modifier = Modifier.align(Alignment.Center),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
Text(
text = uiState.errorMessage ?: "Fehler",
style = MaterialTheme.typography.bodyMedium
)
Button(onClick = { viewModel.retry() }) { Text("Erneut versuchen") }
}
uiState.users.isEmpty() -> Text(
text = "Keine Benutzer gefunden",
modifier = Modifier.align(Alignment.Center)
)
else -> LazyColumn {
items(uiState.users) { user ->
UserListItem(user = user, onClick = { onUserClick(user.id, user.username) })
}
}
}
}
}
}
@Composable
private fun UserListItem(user: UserListItemDto, onClick: () -> Unit) {
ListItem(
headlineContent = { Text(user.username) },
leadingContent = {
Icon(imageVector = Icons.Outlined.Person, contentDescription = null)
},
modifier = Modifier.clickable(onClick = onClick)
)
HorizontalDivider()
}

View file

@ -0,0 +1,47 @@
package de.krisenvorrat.app.ui.messaging
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import de.krisenvorrat.app.domain.repository.MessageRepository
import de.krisenvorrat.shared.model.UserListItemDto
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch
import javax.inject.Inject
internal data class UserListUiState(
val users: List<UserListItemDto> = emptyList(),
val isLoading: Boolean = false,
val errorMessage: String? = null
)
@HiltViewModel
internal class UserListViewModel @Inject constructor(
private val messageRepository: MessageRepository
) : ViewModel() {
private val _uiState = MutableStateFlow(UserListUiState(isLoading = true))
val uiState: StateFlow<UserListUiState> = _uiState
init {
loadUsers()
}
private fun loadUsers() {
viewModelScope.launch {
_uiState.value = _uiState.value.copy(isLoading = true, errorMessage = null)
val result = messageRepository.fetchUsers()
_uiState.value = if (result.isSuccess) {
UserListUiState(users = result.getOrDefault(emptyList()), isLoading = false)
} else {
UserListUiState(
isLoading = false,
errorMessage = result.exceptionOrNull()?.message ?: "Fehler beim Laden"
)
}
}
}
fun retry() = loadUsers()
}

View file

@ -5,12 +5,14 @@ import androidx.compose.ui.Modifier
import androidx.navigation.NavHostController
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
import de.krisenvorrat.app.ui.category.CategoryListScreen
import de.krisenvorrat.app.ui.camera.CameraScreen
import de.krisenvorrat.app.ui.category.CategoryListScreen
import de.krisenvorrat.app.ui.dashboard.DashboardScreen
import de.krisenvorrat.app.ui.item.ItemFormScreen
import de.krisenvorrat.app.ui.item.ItemListScreen
import de.krisenvorrat.app.ui.location.LocationListScreen
import de.krisenvorrat.app.ui.messaging.ChatScreen
import de.krisenvorrat.app.ui.messaging.UserListScreen
import de.krisenvorrat.app.ui.settings.SettingsScreen
import de.krisenvorrat.app.ui.warnings.WarningsScreen
@ -92,5 +94,19 @@ internal fun KrisenvorratNavGraph(
composable<Screen.Settings> {
SettingsScreen()
}
composable<Screen.UserList> {
UserListScreen(
onUserClick = { id, username ->
navController.navigate(Screen.Chat(recipientId = id, recipientUsername = username))
}
)
}
composable<Screen.Chat> {
ChatScreen(
onNavigateBack = { navController.popBackStack() }
)
}
}
}

View file

@ -28,4 +28,10 @@ internal sealed interface Screen {
@Serializable
data object Settings : Screen
@Serializable
data object UserList : Screen
@Serializable
data class Chat(val recipientId: String, val recipientUsername: String) : Screen
}

View file

@ -1,6 +1,8 @@
package de.krisenvorrat.app.ui.navigation
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.Message
import androidx.compose.material.icons.automirrored.outlined.Message
import androidx.compose.material.icons.filled.Home
import androidx.compose.material.icons.filled.Settings
import androidx.compose.material.icons.filled.Warning
@ -34,6 +36,12 @@ internal enum class TopLevelDestination(
unselectedIcon = Icons.Outlined.Warning,
label = "Warnungen"
),
MESSAGES(
route = Screen.UserList,
selectedIcon = Icons.AutoMirrored.Filled.Message,
unselectedIcon = Icons.AutoMirrored.Outlined.Message,
label = "Nachrichten"
),
SETTINGS(
route = Screen.Settings,
selectedIcon = Icons.Filled.Settings,

View file

@ -0,0 +1,227 @@
package de.krisenvorrat.app.data.repository
import de.krisenvorrat.app.data.db.dao.MessageDao
import de.krisenvorrat.app.data.db.entity.MessageEntity
import de.krisenvorrat.app.data.sync.WebSocketClient
import de.krisenvorrat.app.data.sync.WebSocketEvent
import de.krisenvorrat.app.domain.model.SettingsKeys
import de.krisenvorrat.app.domain.repository.SettingsRepository
import de.krisenvorrat.shared.model.MessageDto
import de.krisenvorrat.shared.model.UserListItemDto
import io.ktor.client.HttpClient
import io.ktor.client.engine.mock.MockEngine
import io.ktor.client.engine.mock.respond
import io.ktor.client.engine.mock.respondError
import io.ktor.client.plugins.contentnegotiation.ContentNegotiation
import io.ktor.http.ContentType
import io.ktor.http.HttpHeaders
import io.ktor.http.HttpStatusCode
import io.ktor.http.headersOf
import io.ktor.serialization.kotlinx.json.json
import kotlinx.coroutines.cancel
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.advanceUntilIdle
import kotlinx.coroutines.test.runTest
import kotlinx.serialization.builtins.ListSerializer
import kotlinx.serialization.json.Json
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
private class FakeMessageDao : MessageDao {
val upserted = mutableListOf<MessageEntity>()
val delivered = mutableListOf<String>()
override suspend fun upsert(message: MessageEntity) { upserted.add(message) }
override suspend fun insertIfNotExists(message: MessageEntity) { upserted.add(message) }
override fun getConversation(myId: String, otherId: String): Flow<List<MessageEntity>> =
MutableStateFlow(upserted.filter {
(it.senderId == myId && it.receiverId == otherId) ||
(it.senderId == otherId && it.receiverId == myId)
})
override suspend fun getPendingMessages(): List<MessageEntity> =
upserted.filter { it.isPending }
override suspend fun markDelivered(id: String) {
delivered.add(id)
val idx = upserted.indexOfFirst { it.id == id }
if (idx >= 0) upserted[idx] = upserted[idx].copy(isPending = false)
}
}
private class FakeMessageSettingsRepository : SettingsRepository {
private val store = mutableMapOf<String, String>()
fun set(key: String, value: String) { store[key] = value }
override suspend fun getValue(key: String): String? = store[key]
override suspend fun setValue(key: String, value: String) { store[key] = value }
override fun observeValue(key: String): Flow<String?> = MutableStateFlow(store[key])
override fun getAll(): Flow<List<de.krisenvorrat.app.data.db.entity.SettingsEntity>> =
MutableStateFlow(emptyList())
}
private class FakeMessageWsClient : WebSocketClient {
val events2 = MutableSharedFlow<WebSocketEvent>(extraBufferCapacity = 16)
override val events: SharedFlow<WebSocketEvent> = events2.asSharedFlow()
override fun connect(serverUrl: String, accessToken: String) = Unit
override fun disconnect() = Unit
}
@OptIn(ExperimentalCoroutinesApi::class)
class MessageRepositoryImplTest {
private val jsonSerializer = Json { ignoreUnknownKeys = true; encodeDefaults = true }
private val testScope = TestScope()
@After
fun tearDown() {
testScope.cancel()
}
private fun createClient(engine: MockEngine): HttpClient = HttpClient(engine) {
install(ContentNegotiation) { json(jsonSerializer) }
}
private fun buildRepository(
dao: FakeMessageDao,
httpClient: HttpClient,
settings: FakeMessageSettingsRepository,
wsClient: FakeMessageWsClient = FakeMessageWsClient()
) = MessageRepositoryImpl(
dao = dao,
httpClient = httpClient,
settingsRepository = settings,
webSocketClient = wsClient,
scope = testScope
)
@Test
fun test_sendMessage_whenServerReachable_marksDelivered() = runTest {
// Given
val dao = FakeMessageDao()
val settings = FakeMessageSettingsRepository().apply {
set(SettingsKeys.SERVER_URL, "http://localhost:8080")
set(SettingsKeys.AUTH_ACCESS_TOKEN, "token")
set(SettingsKeys.AUTH_USER_ID, "user1")
set(SettingsKeys.AUTH_USERNAME, "Alice")
}
val engine = MockEngine {
respond(
content = """{"id":"x","senderId":"user1","senderUsername":"Alice","receiverId":"user2","body":"hi","sentAt":1}""",
status = HttpStatusCode.Created,
headers = headersOf(HttpHeaders.ContentType, ContentType.Application.Json.toString())
)
}
val repo = buildRepository(dao, createClient(engine), settings)
// When
repo.sendMessage("user2", "hi")
// Then
assertTrue(dao.delivered.isNotEmpty())
}
@Test
fun test_sendMessage_whenServerUnreachable_remainsPending() = runTest {
// Given
val dao = FakeMessageDao()
val settings = FakeMessageSettingsRepository().apply {
set(SettingsKeys.SERVER_URL, "http://localhost:8080")
set(SettingsKeys.AUTH_ACCESS_TOKEN, "token")
set(SettingsKeys.AUTH_USER_ID, "user1")
set(SettingsKeys.AUTH_USERNAME, "Alice")
}
val engine = MockEngine { respondError(HttpStatusCode.ServiceUnavailable) }
val repo = buildRepository(dao, createClient(engine), settings)
// When
repo.sendMessage("user2", "hi")
// Then
assertTrue(dao.delivered.isEmpty())
assertTrue(dao.upserted.any { it.isPending })
}
@Test
fun test_drainPendingMessages_sendsAllPending() = runTest {
// Given
val dao = FakeMessageDao()
dao.upserted.add(
MessageEntity("m1", "user1", "Alice", "user2", "hello", 1000L, isPending = true)
)
val settings = FakeMessageSettingsRepository().apply {
set(SettingsKeys.SERVER_URL, "http://localhost:8080")
set(SettingsKeys.AUTH_ACCESS_TOKEN, "token")
}
val engine = MockEngine {
respond(
content = """{"id":"m1","senderId":"user1","senderUsername":"Alice","receiverId":"user2","body":"hello","sentAt":1000}""",
status = HttpStatusCode.Created,
headers = headersOf(HttpHeaders.ContentType, ContentType.Application.Json.toString())
)
}
val repo = buildRepository(dao, createClient(engine), settings)
// When
repo.drainPendingMessages()
// Then
assertTrue(dao.delivered.contains("m1"))
}
@Test
fun test_fetchUsers_returnsUserList() = runTest {
// Given
val settings = FakeMessageSettingsRepository().apply {
set(SettingsKeys.SERVER_URL, "http://localhost:8080")
set(SettingsKeys.AUTH_ACCESS_TOKEN, "token")
}
val users = listOf(UserListItemDto("u2", "Bob"), UserListItemDto("u3", "Carol"))
val engine = MockEngine {
respond(
content = jsonSerializer.encodeToString(ListSerializer(UserListItemDto.serializer()), users),
status = HttpStatusCode.OK,
headers = headersOf(HttpHeaders.ContentType, ContentType.Application.Json.toString())
)
}
val repo = buildRepository(FakeMessageDao(), createClient(engine), settings)
// When
val result = repo.fetchUsers()
// Then
assertTrue(result.isSuccess)
assertEquals(2, result.getOrNull()?.size)
}
@Test
fun test_newMessageEvent_upsertedToDatabase() = runTest {
// Given
val dao = FakeMessageDao()
val wsClient = FakeMessageWsClient()
buildRepository(
dao = dao,
httpClient = createClient(MockEngine { respondError(HttpStatusCode.ServiceUnavailable) }),
settings = FakeMessageSettingsRepository(),
wsClient = wsClient
)
// Let init block start and subscribe before emitting
testScope.advanceUntilIdle()
val msg = MessageDto("m1", "u2", "Bob", "u1", "hey", 1000L)
// When
wsClient.events2.emit(WebSocketEvent.NewMessage(msg))
testScope.advanceUntilIdle()
// Then
assertTrue(dao.upserted.any { it.id == "m1" && !it.isPending })
}
}

View file

@ -0,0 +1,104 @@
package de.krisenvorrat.app.ui.messaging
import androidx.lifecycle.SavedStateHandle
import de.krisenvorrat.app.data.db.entity.MessageEntity
import de.krisenvorrat.app.domain.repository.MessageRepository
import de.krisenvorrat.shared.model.UserListItemDto
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.test.StandardTestDispatcher
import kotlinx.coroutines.test.advanceUntilIdle
import kotlinx.coroutines.test.resetMain
import kotlinx.coroutines.test.runTest
import kotlinx.coroutines.test.setMain
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
private class FakeChatMessageRepository(
private val myUserId: String = "user1",
private val conversation: MutableStateFlow<List<MessageEntity>> = MutableStateFlow(emptyList())
) : MessageRepository {
var sendMessageCalled = false
var lastSentBody: String? = null
override fun getConversation(myId: String, otherId: String): Flow<List<MessageEntity>> = conversation
override suspend fun sendMessage(recipientId: String, body: String) {
sendMessageCalled = true
lastSentBody = body
}
override suspend fun fetchUsers(): Result<List<UserListItemDto>> = Result.success(emptyList())
override suspend fun getMyUserId(): String = myUserId
override suspend fun drainPendingMessages() = Unit
}
@OptIn(ExperimentalCoroutinesApi::class)
class ChatViewModelTest {
private val testDispatcher = StandardTestDispatcher()
@Before
fun setup() {
Dispatchers.setMain(testDispatcher)
}
@After
fun tearDown() {
Dispatchers.resetMain()
}
private fun createViewModel(
recipientId: String = "user2",
recipientUsername: String = "Bob",
repo: MessageRepository = FakeChatMessageRepository()
): ChatViewModel {
val savedStateHandle = SavedStateHandle(mapOf("recipientId" to recipientId, "recipientUsername" to recipientUsername))
return ChatViewModel(savedStateHandle = savedStateHandle, messageRepository = repo)
}
@Test
fun test_uiState_showsCorrectRecipientUsername() = runTest(testDispatcher) {
// Given / When
val viewModel = createViewModel(recipientUsername = "Bob")
advanceUntilIdle()
// Then
assertEquals("Bob", viewModel.uiState.value.recipientUsername)
}
@Test
fun test_sendMessage_clearsInput() = runTest(testDispatcher) {
// Given
val viewModel = createViewModel()
advanceUntilIdle()
viewModel.onInputChanged("Hello")
// When
viewModel.sendMessage()
advanceUntilIdle()
// Then
assertEquals("", viewModel.uiState.value.inputText)
}
@Test
fun test_sendMessage_whenEmptyBody_doesNotSend() = runTest(testDispatcher) {
// Given
val fakeRepo = FakeChatMessageRepository()
val viewModel = createViewModel(repo = fakeRepo)
advanceUntilIdle()
viewModel.onInputChanged(" ")
// When
viewModel.sendMessage()
advanceUntilIdle()
// Then
assertFalse(fakeRepo.sendMessageCalled)
}
}

View file

@ -0,0 +1,104 @@
package de.krisenvorrat.app.ui.messaging
import de.krisenvorrat.app.data.db.entity.MessageEntity
import de.krisenvorrat.app.domain.model.SyncError
import de.krisenvorrat.app.domain.repository.MessageRepository
import de.krisenvorrat.shared.model.UserListItemDto
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.test.StandardTestDispatcher
import kotlinx.coroutines.test.advanceUntilIdle
import kotlinx.coroutines.test.resetMain
import kotlinx.coroutines.test.runTest
import kotlinx.coroutines.test.setMain
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
private class FakeUserListMessageRepository(
private val usersResult: Result<List<UserListItemDto>>
) : MessageRepository {
var fetchCount = 0
override fun getConversation(myId: String, otherId: String): Flow<List<MessageEntity>> =
MutableStateFlow(emptyList())
override suspend fun sendMessage(recipientId: String, body: String) = Unit
override suspend fun fetchUsers(): Result<List<UserListItemDto>> {
fetchCount++
return usersResult
}
override suspend fun getMyUserId(): String = ""
override suspend fun drainPendingMessages() = Unit
}
@OptIn(ExperimentalCoroutinesApi::class)
class UserListViewModelTest {
private val testDispatcher = StandardTestDispatcher()
@Before
fun setup() {
Dispatchers.setMain(testDispatcher)
}
@After
fun tearDown() {
Dispatchers.resetMain()
}
@Test
fun test_loadUsers_success_showsUsers() = runTest(testDispatcher) {
// Given
val users = listOf(UserListItemDto("u1", "Alice"), UserListItemDto("u2", "Bob"))
val repo = FakeUserListMessageRepository(Result.success(users))
val viewModel = UserListViewModel(repo)
// When
advanceUntilIdle()
// Then
assertEquals(2, viewModel.uiState.value.users.size)
assertFalse(viewModel.uiState.value.isLoading)
assertNull(viewModel.uiState.value.errorMessage)
}
@Test
fun test_loadUsers_failure_showsError() = runTest(testDispatcher) {
// Given
val repo = FakeUserListMessageRepository(
Result.failure(SyncError.ConnectionError())
)
val viewModel = UserListViewModel(repo)
// When
advanceUntilIdle()
// Then
assertFalse(viewModel.uiState.value.isLoading)
assertNotNull(viewModel.uiState.value.errorMessage)
assertTrue(viewModel.uiState.value.users.isEmpty())
}
@Test
fun test_retry_reloadsUsers() = runTest(testDispatcher) {
// Given
val repo = FakeUserListMessageRepository(Result.success(emptyList()))
val viewModel = UserListViewModel(repo)
advanceUntilIdle()
val countBefore = repo.fetchCount
// When
viewModel.retry()
advanceUntilIdle()
// Then
assertEquals(countBefore + 1, repo.fetchCount)
}
}

View file

@ -48,3 +48,9 @@ dependencies {
testImplementation(libs.junit)
testImplementation(libs.kotlinx.serialization.json)
}
tasks {
named("distZip") { dependsOn("shadowJar") }
named("distTar") { dependsOn("shadowJar") }
named("startScripts") { dependsOn("shadowJar") }
}

View file

@ -20,8 +20,8 @@ internal object DatabaseFactory {
) {
Database.connect(jdbcUrl, driver)
transaction {
SchemaUtils.create(Users, Categories, Locations, Items, Settings)
SchemaUtils.createMissingTablesAndColumns(Users, Categories, Locations, Items, Settings)
SchemaUtils.create(Users, Categories, Locations, Items, Settings, Messages)
SchemaUtils.createMissingTablesAndColumns(Users, Categories, Locations, Items, Settings, Messages)
}
seedAdmin(adminPassword)
}

View file

@ -55,3 +55,13 @@ internal object Settings : Table("settings") {
override val primaryKey = PrimaryKey(id)
}
internal object Messages : Table("messages") {
val id = varchar("id", 36)
val senderId = varchar("sender_id", 36)
val receiverId = varchar("receiver_id", 36)
val body = text("body")
val sentAt = long("sent_at")
val deliveredAt = long("delivered_at").nullable()
override val primaryKey = PrimaryKey(id)
}

View file

@ -6,7 +6,12 @@ import kotlinx.serialization.Serializable
internal data class LoginRequest(val username: String, val password: String)
@Serializable
internal data class LoginResponse(val accessToken: String, val refreshToken: String)
internal data class LoginResponse(
val accessToken: String,
val refreshToken: String,
val userId: String,
val username: String
)
@Serializable
internal data class RefreshRequest(val refreshToken: String)

View file

@ -1,10 +1,13 @@
package de.krisenvorrat.server.plugins
import de.krisenvorrat.server.repository.InventoryRepository
import de.krisenvorrat.server.repository.MessageRepository
import de.krisenvorrat.server.repository.UserRepository
import de.krisenvorrat.server.routes.adminRoutes
import de.krisenvorrat.server.routes.authRoutes
import de.krisenvorrat.server.routes.inventoryRoutes
import de.krisenvorrat.server.routes.messageRoutes
import de.krisenvorrat.server.routes.userRoutes
import de.krisenvorrat.server.routes.webSocketRoutes
import de.krisenvorrat.server.security.JwtService
import de.krisenvorrat.server.websocket.WebSocketManager
@ -20,6 +23,7 @@ import kotlin.time.Duration.Companion.seconds
internal fun Application.configureRouting(
inventoryRepository: InventoryRepository = InventoryRepository(),
userRepository: UserRepository = UserRepository(),
messageRepository: MessageRepository = MessageRepository(),
jwtService: JwtService = JwtService(environment.config),
wsManager: WebSocketManager = WebSocketManager()
) {
@ -36,14 +40,16 @@ internal fun Application.configureRouting(
// Public auth endpoints
authRoutes(userRepository, jwtService)
// Protected inventory + admin endpoints
// Protected endpoints
authenticate("auth-jwt") {
inventoryRoutes(inventoryRepository, wsManager)
adminRoutes(userRepository)
messageRoutes(messageRepository, userRepository, wsManager)
userRoutes(userRepository)
}
// WebSocket auth via query param ?token=
webSocketRoutes(wsManager, jwtService)
webSocketRoutes(wsManager, jwtService, messageRepository)
// Admin web UI (static)
staticResources("/admin", "static/admin")

View file

@ -0,0 +1,102 @@
package de.krisenvorrat.server.repository
import de.krisenvorrat.server.db.Messages
import de.krisenvorrat.server.db.Users
import de.krisenvorrat.shared.model.MessageDto
import org.jetbrains.exposed.sql.JoinType
import org.jetbrains.exposed.sql.SortOrder
import org.jetbrains.exposed.sql.SqlExpressionBuilder.eq
import org.jetbrains.exposed.sql.and
import org.jetbrains.exposed.sql.insert
import org.jetbrains.exposed.sql.or
import org.jetbrains.exposed.sql.selectAll
import org.jetbrains.exposed.sql.transactions.transaction
import org.jetbrains.exposed.sql.update
internal class MessageRepository {
fun save(
id: String,
senderId: String,
senderUsername: String,
receiverId: String,
body: String,
sentAt: Long
): MessageDto {
transaction {
Messages.insert {
it[Messages.id] = id
it[Messages.senderId] = senderId
it[Messages.receiverId] = receiverId
it[Messages.body] = body
it[Messages.sentAt] = sentAt
it[Messages.deliveredAt] = null
}
}
return MessageDto(
id = id,
senderId = senderId,
senderUsername = senderUsername,
receiverId = receiverId,
body = body,
sentAt = sentAt,
deliveredAt = null
)
}
fun getUndelivered(receiverId: String): List<MessageDto> = transaction {
Messages.join(Users, JoinType.LEFT, Messages.senderId, Users.id)
.selectAll()
.where { (Messages.receiverId eq receiverId) and Messages.deliveredAt.isNull() }
.map { row ->
MessageDto(
id = row[Messages.id],
senderId = row[Messages.senderId],
senderUsername = row.getOrNull(Users.username) ?: "",
receiverId = row[Messages.receiverId],
body = row[Messages.body],
sentAt = row[Messages.sentAt],
deliveredAt = row[Messages.deliveredAt]
)
}
}
fun markDelivered(messageId: String) {
transaction {
Messages.update({ Messages.id eq messageId }) {
it[deliveredAt] = System.currentTimeMillis()
}
}
}
fun markAllDeliveredForReceiver(receiverId: String) {
transaction {
Messages.update({
(Messages.receiverId eq receiverId) and Messages.deliveredAt.isNull()
}) {
it[deliveredAt] = System.currentTimeMillis()
}
}
}
fun getConversation(userId1: String, userId2: String): List<MessageDto> = transaction {
Messages.join(Users, JoinType.LEFT, Messages.senderId, Users.id)
.selectAll()
.where {
((Messages.senderId eq userId1) and (Messages.receiverId eq userId2)) or
((Messages.senderId eq userId2) and (Messages.receiverId eq userId1))
}
.orderBy(Messages.sentAt to SortOrder.ASC)
.map { row ->
MessageDto(
id = row[Messages.id],
senderId = row[Messages.senderId],
senderUsername = row.getOrNull(Users.username) ?: "",
receiverId = row[Messages.receiverId],
body = row[Messages.body],
sentAt = row[Messages.sentAt],
deliveredAt = row[Messages.deliveredAt]
)
}
}
}

View file

@ -26,7 +26,15 @@ internal fun Route.authRoutes(userRepository: UserRepository, jwtService: JwtSer
}
val accessToken = jwtService.generateAccessToken(user.id, user.username, user.isAdmin)
val refreshToken = jwtService.generateRefreshToken(user.id)
call.respond(HttpStatusCode.OK, LoginResponse(accessToken = accessToken, refreshToken = refreshToken))
call.respond(
HttpStatusCode.OK,
LoginResponse(
accessToken = accessToken,
refreshToken = refreshToken,
userId = user.id,
username = user.username
)
)
}
post("/refresh") {
@ -49,7 +57,15 @@ internal fun Route.authRoutes(userRepository: UserRepository, jwtService: JwtSer
}
val accessToken = jwtService.generateAccessToken(user.id, user.username, user.isAdmin)
val refreshToken = jwtService.generateRefreshToken(user.id)
call.respond(HttpStatusCode.OK, LoginResponse(accessToken = accessToken, refreshToken = refreshToken))
call.respond(
HttpStatusCode.OK,
LoginResponse(
accessToken = accessToken,
refreshToken = refreshToken,
userId = user.id,
username = user.username
)
)
}
}
}

View file

@ -0,0 +1,82 @@
package de.krisenvorrat.server.routes
import de.krisenvorrat.server.model.ErrorResponse
import de.krisenvorrat.server.repository.MessageRepository
import de.krisenvorrat.server.repository.UserRepository
import de.krisenvorrat.server.security.UserPrincipal
import de.krisenvorrat.server.websocket.WebSocketManager
import io.ktor.http.*
import io.ktor.server.auth.*
import io.ktor.server.request.*
import io.ktor.server.response.*
import io.ktor.server.routing.*
import kotlinx.serialization.Serializable
import java.util.UUID
@Serializable
internal data class SendMessageRequest(
val id: String? = null,
val receiverId: String,
val body: String,
val sentAt: Long
)
internal fun Route.messageRoutes(
messageRepository: MessageRepository,
userRepository: UserRepository,
wsManager: WebSocketManager
) {
route("/api/messages") {
post {
val principal = call.principal<UserPrincipal>()
?: return@post call.respond(
HttpStatusCode.Unauthorized,
ErrorResponse(status = 401, message = "Unauthorized")
)
val request = call.receive<SendMessageRequest>()
if (request.body.isBlank()) {
call.respond(
HttpStatusCode.BadRequest,
ErrorResponse(status = 400, message = "Body must not be empty")
)
return@post
}
if (userRepository.findById(request.receiverId) == null) {
call.respond(
HttpStatusCode.NotFound,
ErrorResponse(status = 404, message = "Receiver not found")
)
return@post
}
val msgId = request.id ?: UUID.randomUUID().toString()
val message = messageRepository.save(
id = msgId,
senderId = principal.userId,
senderUsername = principal.username,
receiverId = request.receiverId,
body = request.body,
sentAt = request.sentAt
)
wsManager.notifyNewMessage(request.receiverId, message)
if (wsManager.isOnline(request.receiverId)) {
messageRepository.markDelivered(msgId)
}
call.respond(HttpStatusCode.Created, message)
}
get("/{userId}") {
val myId = call.principal<UserPrincipal>()?.userId
?: return@get call.respond(
HttpStatusCode.Unauthorized,
ErrorResponse(status = 401, message = "Unauthorized")
)
val otherId = call.parameters["userId"]
?: return@get call.respond(
HttpStatusCode.BadRequest,
ErrorResponse(status = 400, message = "Missing userId")
)
val messages = messageRepository.getConversation(myId, otherId)
call.respond(HttpStatusCode.OK, messages)
}
}
}

View file

@ -0,0 +1,24 @@
package de.krisenvorrat.server.routes
import de.krisenvorrat.server.model.ErrorResponse
import de.krisenvorrat.server.repository.UserRepository
import de.krisenvorrat.server.security.UserPrincipal
import de.krisenvorrat.shared.model.UserListItemDto
import io.ktor.http.*
import io.ktor.server.auth.*
import io.ktor.server.response.*
import io.ktor.server.routing.*
internal fun Route.userRoutes(userRepository: UserRepository) {
get("/api/users") {
val principal = call.principal<UserPrincipal>()
?: return@get call.respond(
HttpStatusCode.Unauthorized,
ErrorResponse(status = 401, message = "Unauthorized")
)
val users = userRepository.listAll()
.filter { it.id != principal.userId }
.map { UserListItemDto(id = it.id, username = it.username) }
call.respond(HttpStatusCode.OK, users)
}
}

View file

@ -1,12 +1,21 @@
package de.krisenvorrat.server.routes
import de.krisenvorrat.server.repository.MessageRepository
import de.krisenvorrat.server.security.JwtService
import de.krisenvorrat.server.websocket.WebSocketManager
import io.ktor.server.routing.*
import io.ktor.server.websocket.*
import io.ktor.websocket.*
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.buildJsonObject
import kotlinx.serialization.json.put
internal fun Route.webSocketRoutes(wsManager: WebSocketManager, jwtService: JwtService) {
internal fun Route.webSocketRoutes(
wsManager: WebSocketManager,
jwtService: JwtService,
messageRepository: MessageRepository
) {
webSocket("/ws/sync") {
val token = call.request.queryParameters["token"]
if (token == null) {
@ -21,6 +30,22 @@ internal fun Route.webSocketRoutes(wsManager: WebSocketManager, jwtService: JwtS
return@webSocket
}
wsManager.addSession(userId, this)
val pending = messageRepository.getUndelivered(userId)
for (msg in pending) {
val payload = Json.encodeToString(buildJsonObject {
put("type", "new_message")
put("id", msg.id)
put("senderId", msg.senderId)
put("senderUsername", msg.senderUsername)
put("receiverId", msg.receiverId)
put("body", msg.body)
put("sentAt", msg.sentAt)
})
send(Frame.Text(payload))
}
if (pending.isNotEmpty()) {
messageRepository.markAllDeliveredForReceiver(userId)
}
try {
for (frame in incoming) {
// Client frames are accepted but not processed (server push only)

View file

@ -1,5 +1,6 @@
package de.krisenvorrat.server.websocket
import de.krisenvorrat.shared.model.MessageDto
import io.ktor.websocket.*
import kotlinx.coroutines.channels.ClosedSendChannelException
import kotlinx.serialization.encodeToString
@ -21,6 +22,8 @@ internal class WebSocketManager {
sessions[userId]?.remove(session)
}
fun isOnline(userId: String): Boolean = sessions[userId]?.isNotEmpty() == true
suspend fun notifyInventoryUpdated(userId: String, itemId: String) {
val payload = buildJsonObject {
put("type", "inventory_updated")
@ -38,6 +41,19 @@ internal class WebSocketManager {
broadcast(userId, Json.encodeToString(payload))
}
suspend fun notifyNewMessage(receiverId: String, message: MessageDto) {
val payload = buildJsonObject {
put("type", "new_message")
put("id", message.id)
put("senderId", message.senderId)
put("senderUsername", message.senderUsername)
put("receiverId", message.receiverId)
put("body", message.body)
put("sentAt", message.sentAt)
}
broadcast(receiverId, Json.encodeToString(payload))
}
private suspend fun broadcast(userId: String, message: String) {
val userSessions = sessions[userId] ?: return
val toRemove = mutableListOf<WebSocketSession>()

View file

@ -32,6 +32,7 @@
.modal { background: #fff; border-radius: 8px; padding: 28px; width: 360px; }
.modal h3 { margin-bottom: 16px; }
.modal-actions { display: flex; gap: 10px; justify-content: flex-end; margin-top: 16px; }
footer { text-align: center; padding: 24px 16px; margin-top: 40px; font-size: .8rem; color: #999; }
</style>
</head>
<body>
@ -253,6 +254,8 @@
function logout() {
accessToken = '';
sessionStorage.removeItem('accessToken');
document.getElementById('login-username').value = '';
document.getElementById('login-password').value = '';
document.getElementById('login-section').style.display = 'block';
document.getElementById('admin-section').style.display = 'none';
document.getElementById('logout-btn').style.display = 'none';
@ -260,5 +263,6 @@
document.getElementById('login-password').addEventListener('keydown', e => { if (e.key === 'Enter') login(); });
</script>
<footer>Krisenvorrat Server v0.2 &middot; &copy; 2026 faenocasul</footer>
</body>
</html>

View file

@ -0,0 +1,14 @@
package de.krisenvorrat.shared.model
import kotlinx.serialization.Serializable
@Serializable
data class MessageDto(
val id: String,
val senderId: String,
val senderUsername: String,
val receiverId: String,
val body: String,
val sentAt: Long,
val deliveredAt: Long? = null
)

View file

@ -0,0 +1,9 @@
package de.krisenvorrat.shared.model
import kotlinx.serialization.Serializable
@Serializable
data class UserListItemDto(
val id: String,
val username: String
)