diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..9a3c24a --- /dev/null +++ b/.dockerignore @@ -0,0 +1,12 @@ +.git +.github +.gradle +.idea +app/ +build/ +tmp/ +memories/ +Anforderungen/ +local.properties +*.apk +*.aab diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..483f219 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,19 @@ +# Stage 1: Build the fat JAR +FROM gradle:8.11.1-jdk21 AS builder +WORKDIR /app +COPY gradle/ gradle/ +COPY gradlew gradlew.bat build.gradle.kts settings.gradle.kts gradle.properties ./ +COPY shared/ shared/ +COPY server/ server/ +RUN gradle :server:buildFatJar --no-daemon + +# Stage 2: Run +FROM eclipse-temurin:21-jre-alpine +WORKDIR /app +COPY --from=builder /app/server/build/libs/server.jar server.jar + +ENV KRISENVORRAT_API_KEY="change-me-to-a-secure-key-at-least-32-chars" + +EXPOSE 8080 + +ENTRYPOINT ["java", "-jar", "server.jar"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..b0df32c --- /dev/null +++ b/README.md @@ -0,0 +1,143 @@ +# Krisenvorrat + +Android-App zur Verwaltung eines Krisenvorrats-Inventars mit lokaler Datenhaltung und Sync-Möglichkeit über einen LAN-Server. + +## Projektstruktur + +| Modul | Beschreibung | +|----------|-------------------------------------------------| +| `app` | Android-App (Kotlin, Jetpack Compose, Room) | +| `server` | Ktor REST-Server (H2-Datenbank, Fat-JAR) | +| `shared` | Gemeinsame DTOs (kotlinx.serialization) | + +## Voraussetzungen + +- **Android Studio** oder IntelliJ IDEA +- **JDK 21** (für Server) / JDK 11+ (für App) +- **Android SDK** (API Level 35) + +## App bauen + +```powershell +# Windows +.\gradlew assembleDebug + +# Linux/Mac +./gradlew assembleDebug +``` + +## Tests ausführen + +```bash +# Alle Tests (App + Server) +./gradlew test + +# Nur Server-Tests +./gradlew :server:test + +# Nur App-Tests +./gradlew :app:test +``` + +--- + +## Phase 2: Server-Setup (LAN-Sync) + +### Übersicht + +Der Sync-Server empfängt das Inventar der App als JSON und gibt es an andere App-Instanzen im selben LAN weiter. Die Kommunikation läuft über einfaches HTTP mit API-Key-Authentifizierung. + +``` +┌──────────┐ PUT /api/inventory ┌──────────┐ +│ App A │ ───────────────────────► │ Server │ +│ (Upload) │ │ :8080 │ +└──────────┘ └────┬─────┘ + │ +┌──────────┐ GET /api/inventory ┌────┴─────┐ +│ App B │ ◄─────────────────────── │ Server │ +│(Download)│ │ :8080 │ +└──────────┘ └──────────┘ +``` + +### Schnellstart + +#### Option A: Direkt starten (empfohlen für Entwicklung) + +```powershell +# Windows +.\start-server.ps1 + +# Linux/Mac +chmod +x start-server.sh +./start-server.sh +``` + +Das Skript baut automatisch das Fat-JAR und zeigt die LAN-IP an. + +#### Option B: Manuell mit Gradle + +```bash +./gradlew :server:run +``` + +#### Option C: Fat-JAR + +```bash +# JAR bauen +./gradlew :server:buildFatJar + +# Starten +java -jar server/build/libs/server.jar +``` + +#### Option D: Docker + +```bash +# Image bauen +docker build -t krisenvorrat-server . + +# Container starten +docker run -d \ + --name krisenvorrat \ + -p 8080:8080 \ + -e KRISENVORRAT_API_KEY="mein-sicherer-api-key" \ + -v krisenvorrat-data:/app/data \ + krisenvorrat-server +``` + +### LAN-Setup + +1. **Server-IP ermitteln:** + - Windows: `ipconfig` → IPv4-Adresse des WLAN/LAN-Adapters + - Linux/Mac: `ip addr` oder `ifconfig` + - Die Start-Skripte zeigen die IP automatisch an + +2. **Firewall-Regel:** Port **8080** (TCP eingehend) muss freigeschaltet sein: + - Windows: `netsh advfirewall firewall add rule name="Krisenvorrat" dir=in action=allow protocol=TCP localport=8080` + - Linux: `sudo ufw allow 8080/tcp` + +3. **App konfigurieren:** + - Einstellungen → Server-URL: `http://:8080` + - Einstellungen → API-Key: den gleichen Key wie beim Server-Start + +### API-Endpunkte + +| Methode | Pfad | Auth | Beschreibung | +|---------|-------------------|-----------|------------------------------------| +| GET | `/api/health` | – | Health-Check, gibt "OK" zurück | +| GET | `/api/inventory` | API-Key | Inventar vom Server laden (Pull) | +| PUT | `/api/inventory` | API-Key | Inventar auf Server speichern (Push)| + +**Authentifizierung:** API-Key als `X-API-Key`-Header oder `Bearer`-Token. + +### Umgebungsvariablen + +| Variable | Standard | Beschreibung | +|-----------------------|-------------------------------------------------|-------------------| +| `KRISENVORRAT_API_KEY` | `change-me-to-a-secure-key-at-least-32-chars` | API-Key für Auth | + +### Sicherheitshinweise + +- Der Dev-Server nutzt **HTTP** (kein HTTPS) – nur im vertrauenswürdigen LAN verwenden. +- Für Produktionseinsatz: HTTPS mit Reverse-Proxy (z.B. nginx/Caddy) konfigurieren. +- API-Key sollte mindestens 32 Zeichen lang sein. diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index f0d2bee..0753f88 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -10,6 +10,7 @@ android:label="@string/app_name" android:roundIcon="@mipmap/ic_launcher_round" android:supportsRtl="true" + android:usesCleartextTraffic="true" android:theme="@style/Theme.Krisenvorrat"> Unit) = testApplication { + environment { + config = MapApplicationConfig("krisenvorrat.apiKey" to API_KEY) + } + application { + DatabaseFactory.init( + jdbcUrl = "jdbc:h2:mem:e2e_test_${System.nanoTime()};DB_CLOSE_DELAY=-1", + driver = "org.h2.Driver" + ) + configurePlugins() + } + block() + } + + @Test + fun test_fullSyncCycle_pushThenPull_roundTripsCorrectly() = testApp { + // Given – "Client A" hat ein vollständiges Inventar + val clientAInventory = createFullInventory() + val body = json.encodeToString(InventoryDto.serializer(), clientAInventory) + + // When – Client A pusht (Upload) + val pushResponse = client.put("/api/inventory") { + header("X-API-Key", API_KEY) + contentType(ContentType.Application.Json) + setBody(body) + } + + // Then – Push erfolgreich + assertEquals(HttpStatusCode.OK, pushResponse.status) + + // When – "Client B" pullt (Download) + val pullResponse = client.get("/api/inventory") { + header("X-API-Key", API_KEY) + } + + // Then – Client B erhält exakt die Daten von Client A + assertEquals(HttpStatusCode.OK, pullResponse.status) + val pulledInventory = json.decodeFromString(pullResponse.bodyAsText()) + + assertEquals(clientAInventory.categories.size, pulledInventory.categories.size) + assertEquals(clientAInventory.locations.size, pulledInventory.locations.size) + assertEquals(clientAInventory.items.size, pulledInventory.items.size) + assertEquals(clientAInventory.settings.size, pulledInventory.settings.size) + + // Inhalte prüfen + assertEquals("Konserven", pulledInventory.categories[0].name) + assertEquals("Getränke", pulledInventory.categories[1].name) + assertEquals("Keller", pulledInventory.locations[0].name) + assertEquals("Dosenbrot", pulledInventory.items[0].name) + assertEquals(5.0, pulledInventory.items[0].quantity, 0.01) + assertEquals("dark", pulledInventory.settings[0].value) + } + + @Test + fun test_multiClientSync_secondClientOverwritesFirst() = testApp { + // Given – Client A pusht + val clientAInventory = createFullInventory() + client.put("/api/inventory") { + header("X-API-Key", API_KEY) + contentType(ContentType.Application.Json) + setBody(json.encodeToString(InventoryDto.serializer(), clientAInventory)) + } + + // When – Client B pusht ein anderes Inventar (überschreibt) + val clientBInventory = InventoryDto( + categories = listOf(CategoryDto(id = 10, name = "Hygiene")), + locations = listOf(LocationDto(id = 10, name = "Badezimmer")), + items = listOf( + ItemDto( + id = "item-b1", + name = "Seife", + categoryId = 10, + quantity = 3.0, + unit = "Stück", + unitPrice = 1.50, + kcalPer100g = null, + expiryDate = null, + locationId = 10, + minStock = 1.0, + notes = "", + lastUpdated = 1715100000L + ) + ), + settings = listOf(SettingDto(key = "lang", value = "de")) + ) + val pushResponse = client.put("/api/inventory") { + header("X-API-Key", API_KEY) + contentType(ContentType.Application.Json) + setBody(json.encodeToString(InventoryDto.serializer(), clientBInventory)) + } + assertEquals(HttpStatusCode.OK, pushResponse.status) + + // Then – Client C pullt und sieht nur Client B's Daten + val pullResponse = client.get("/api/inventory") { + header("X-API-Key", API_KEY) + } + val pulledInventory = json.decodeFromString(pullResponse.bodyAsText()) + + assertEquals(1, pulledInventory.categories.size) + assertEquals("Hygiene", pulledInventory.categories[0].name) + assertEquals(1, pulledInventory.locations.size) + assertEquals("Badezimmer", pulledInventory.locations[0].name) + assertEquals(1, pulledInventory.items.size) + assertEquals("Seife", pulledInventory.items[0].name) + } + + @Test + fun test_pullFromEmptyServer_returnsEmptyInventory() = testApp { + // When – Pull ohne vorherigen Push + val response = client.get("/api/inventory") { + header("X-API-Key", API_KEY) + } + + // Then + assertEquals(HttpStatusCode.OK, response.status) + val inventory = json.decodeFromString(response.bodyAsText()) + assertTrue(inventory.categories.isEmpty()) + assertTrue(inventory.locations.isEmpty()) + assertTrue(inventory.items.isEmpty()) + assertTrue(inventory.settings.isEmpty()) + } + + @Test + fun test_pushPullPushPull_multipleRoundTrips() = testApp { + // Round-Trip 1: Push + Pull + val inventory1 = createFullInventory() + client.put("/api/inventory") { + header("X-API-Key", API_KEY) + contentType(ContentType.Application.Json) + setBody(json.encodeToString(InventoryDto.serializer(), inventory1)) + } + val pull1 = client.get("/api/inventory") { header("X-API-Key", API_KEY) } + val result1 = json.decodeFromString(pull1.bodyAsText()) + assertEquals(2, result1.items.size) + + // Round-Trip 2: Aktualisiertes Inventar pushen + val inventory2 = InventoryDto( + categories = listOf(CategoryDto(id = 1, name = "Konserven")), + locations = listOf(LocationDto(id = 1, name = "Keller")), + items = listOf( + ItemDto( + id = "item-1", + name = "Dosenbrot", + categoryId = 1, + quantity = 10.0, + unit = "Stück", + unitPrice = 3.99, + kcalPer100g = 250, + expiryDate = "2027-06-15", + locationId = 1, + minStock = 2.0, + notes = "Menge erhöht", + lastUpdated = 1715200000L + ) + ), + settings = emptyList() + ) + client.put("/api/inventory") { + header("X-API-Key", API_KEY) + contentType(ContentType.Application.Json) + setBody(json.encodeToString(InventoryDto.serializer(), inventory2)) + } + val pull2 = client.get("/api/inventory") { header("X-API-Key", API_KEY) } + val result2 = json.decodeFromString(pull2.bodyAsText()) + + assertEquals(1, result2.items.size) + assertEquals(10.0, result2.items[0].quantity, 0.01) + assertEquals("Menge erhöht", result2.items[0].notes) + } + + @Test + fun test_healthEndpoint_alwaysAvailableWithoutAuth() = testApp { + // When + val response = client.get("/api/health") + + // Then – Health-Endpoint braucht keinen API-Key + assertEquals(HttpStatusCode.OK, response.status) + assertEquals("OK", response.bodyAsText()) + } + + @Test + fun test_pushWithoutAuth_returns401() = testApp { + // When – Push ohne API-Key + val response = client.put("/api/inventory") { + contentType(ContentType.Application.Json) + setBody(json.encodeToString(InventoryDto.serializer(), createFullInventory())) + } + + // Then + assertEquals(HttpStatusCode.Unauthorized, response.status) + } + + @Test + fun test_pullWithoutAuth_returns401() = testApp { + // When – Pull ohne API-Key + val response = client.get("/api/inventory") + + // Then + assertEquals(HttpStatusCode.Unauthorized, response.status) + } + + private fun createFullInventory(): InventoryDto = InventoryDto( + categories = listOf( + CategoryDto(id = 1, name = "Konserven"), + CategoryDto(id = 2, name = "Getränke") + ), + locations = listOf( + LocationDto(id = 1, name = "Keller"), + LocationDto(id = 2, name = "Speisekammer") + ), + items = listOf( + ItemDto( + id = "item-1", + name = "Dosenbrot", + categoryId = 1, + quantity = 5.0, + unit = "Stück", + unitPrice = 3.99, + kcalPer100g = 250, + expiryDate = "2027-06-15", + locationId = 1, + minStock = 2.0, + notes = "Vollkornbrot in der Dose", + lastUpdated = 1715000000L + ), + ItemDto( + id = "item-2", + name = "Mineralwasser", + categoryId = 2, + quantity = 24.0, + unit = "Liter", + unitPrice = 0.49, + kcalPer100g = 0, + expiryDate = "2028-01-01", + locationId = 2, + minStock = 12.0, + notes = "Stilles Wasser", + lastUpdated = 1715000000L + ) + ), + settings = listOf( + SettingDto(key = "theme", value = "dark"), + SettingDto(key = "household_size", value = "4") + ) + ) + + private companion object { + const val API_KEY = "e2e-test-api-key-for-sync" + } +} diff --git a/start-server.ps1 b/start-server.ps1 new file mode 100644 index 0000000..626a129 --- /dev/null +++ b/start-server.ps1 @@ -0,0 +1,54 @@ +<# +.SYNOPSIS + Startet den Krisenvorrat Dev-Server im LAN. +.DESCRIPTION + Baut das Fat-JAR (falls nötig) und startet den Server auf Port 8080. + Der Server ist unter der LAN-IP des Rechners erreichbar. +.PARAMETER Build + Erzwingt einen Neubau des Fat-JARs, auch wenn es bereits existiert. +.PARAMETER ApiKey + Setzt einen benutzerdefinierten API-Key. Standard: "dev-api-key-change-in-production" +.EXAMPLE + .\start-server.ps1 + .\start-server.ps1 -Build + .\start-server.ps1 -ApiKey "mein-geheimer-key" +#> +param( + [switch]$Build, + [string]$ApiKey = "dev-api-key-change-in-production" +) + +$ErrorActionPreference = "Stop" +Set-Location $PSScriptRoot + +$jarPath = "server/build/libs/server.jar" + +# Fat-JAR bauen falls nötig +if ($Build -or -not (Test-Path $jarPath)) { + Write-Host "Building server fat JAR..." -ForegroundColor Cyan + & .\gradlew.bat :server:buildFatJar 2>&1 | Out-String | Write-Host + if ($LASTEXITCODE -ne 0) { + Write-Error "Build failed with exit code $LASTEXITCODE" + exit 1 + } + Write-Host "Build successful." -ForegroundColor Green +} + +# LAN-IP ermitteln +$lanIp = (Get-NetIPAddress -AddressFamily IPv4 | + Where-Object { $_.InterfaceAlias -notmatch "Loopback" -and $_.IPAddress -notmatch "^169\." } | + Select-Object -First 1).IPAddress + +Write-Host "" +Write-Host "=== Krisenvorrat Dev-Server ===" -ForegroundColor Green +Write-Host "Local: http://localhost:8080" -ForegroundColor Yellow +if ($lanIp) { + Write-Host "LAN: http://${lanIp}:8080" -ForegroundColor Yellow +} +Write-Host "API-Key: $ApiKey" -ForegroundColor Yellow +Write-Host "Press Ctrl+C to stop" -ForegroundColor Gray +Write-Host "" + +# Server starten +$env:KRISENVORRAT_API_KEY = $ApiKey +java -jar $jarPath diff --git a/start-server.sh b/start-server.sh new file mode 100644 index 0000000..591c500 --- /dev/null +++ b/start-server.sh @@ -0,0 +1,62 @@ +#!/usr/bin/env bash +# +# Startet den Krisenvorrat Dev-Server im LAN. +# +# Verwendung: +# ./start-server.sh # Standard API-Key +# ./start-server.sh --build # Fat-JAR neu bauen +# ./start-server.sh --api-key "mein-key" # Eigener API-Key +# +set -euo pipefail +cd "$(dirname "$0")" + +API_KEY="dev-api-key-change-in-production" +FORCE_BUILD=false + +while [[ $# -gt 0 ]]; do + case "$1" in + --build) + FORCE_BUILD=true + shift + ;; + --api-key) + API_KEY="$2" + shift 2 + ;; + *) + echo "Unknown option: $1" >&2 + exit 1 + ;; + esac +done + +JAR_PATH="server/build/libs/server.jar" + +# Fat-JAR bauen falls nötig +if [ "$FORCE_BUILD" = true ] || [ ! -f "$JAR_PATH" ]; then + echo "Building server fat JAR..." + ./gradlew :server:buildFatJar + echo "Build successful." +fi + +# LAN-IP ermitteln +LAN_IP="" +if command -v ip &>/dev/null; then + LAN_IP=$(ip -4 addr show scope global | grep -oP 'inet \K[\d.]+' | head -1) +elif command -v ifconfig &>/dev/null; then + LAN_IP=$(ifconfig | grep 'inet ' | grep -v '127.0.0.1' | awk '{print $2}' | head -1) +fi + +echo "" +echo "=== Krisenvorrat Dev-Server ===" +echo "Local: http://localhost:8080" +if [ -n "$LAN_IP" ]; then + echo "LAN: http://${LAN_IP}:8080" +fi +echo "API-Key: $API_KEY" +echo "Press Ctrl+C to stop" +echo "" + +# Server starten +export KRISENVORRAT_API_KEY="$API_KEY" +java -jar "$JAR_PATH"