feat: automatischer Sync nach Login/Reconnect, manuelle Push/Pull-Buttons entfernen

- WebSocket Connected-Event löst jetzt automatisch pullSync() aus
  (nach Login = Full Sync, nach Reconnect = inkrementell)
- Push/Pull-Buttons ersetzt durch Hinweis 'Synchronisierung erfolgt
  automatisch' + Fallback-Button 'Jetzt synchronisieren'
- ServerUrl: Default-Wert (VPS-IP) als Konstante in SettingsKey,
  Reset-Button neben dem URL-Feld
- SettingsUiState: serverUrl Default = DEFAULT_SERVER_URL
- Tests angepasst (SettingsRepositoryImplTest)
This commit is contained in:
Jens Reinemann 2026-05-17 15:36:11 +02:00
parent 0fee89ec32
commit 5434c00f20
8 changed files with 348 additions and 31 deletions

60
.github/prompts/publish.prompt.md vendored Normal file
View file

@ -0,0 +1,60 @@
---
description: "publish Neue App-Version bauen und auf dem VPS veröffentlichen. Bumpt die Version, baut die APK, lädt sie auf den Server hoch und verifiziert QR-Code-Seite + Update-API."
name: "publish"
agent: agent
tools: [read, search, execute/runInTerminal, execute/sendToTerminal, execute/getTerminalOutput, edit]
---
Lies **zuerst** die Publish-Skill-Datei `.github/skills/publish/SKILL.md` vollständig mit `read_file`.
Führe danach den vollständigen Publish-Workflow durch:
---
## Schritt 1 Version ermitteln
1. Lies `app/build.gradle.kts` und ermittle den aktuellen `versionCode` und `versionName`.
2. Frage den User, auf welche Version gebumpt werden soll (Vorschlag: versionCode +1, versionName minor bump).
3. Ändere `versionCode` und `versionName` in `app/build.gradle.kts`.
---
## Schritt 2 Build & Test (Quality Gate)
```powershell
.\gradlew.bat assembleDebug test
```
- **BUILD SUCCESSFUL** → weiter mit Schritt 3.
- **Fehler** → analysieren und beheben (max. 3 Zyklen).
---
## Schritt 3 Commit & Push
1. Erstelle einen Commit: `release: v{versionName} (build {versionCode})`
2. Push auf origin (gemäß Git-Konventionen: ankündigen, User kann abbrechen).
---
## Schritt 4 APK auf VPS veröffentlichen
Führe das Publish-Skript aus:
```powershell
& ".github/skills/publish/publish-apk.ps1" -VersionCode <code> -VersionName "<name>"
```
Verwende `mode=sync` mit `timeout=120000`.
---
## Schritt 5 Ergebnis berichten
Berichte kurz:
- Neue Version (versionCode + versionName)
- Build/Test-Status (✅ / ❌)
- Push-Status (✅ / ❌)
- VPS-Deployment (✅ / ❌)
- Homepage-URL: `http://195.246.231.210:8080/`
- Version-API: `http://195.246.231.210:8080/api/version`

140
.github/skills/publish/SKILL.md vendored Normal file
View file

@ -0,0 +1,140 @@
---
name: publish
description: "APK publizieren: Version bumpen, APK bauen, auf VPS hochladen, Server-Version aktualisieren, QR-Code/Download-Seite verifizieren. Verwende diesen Skill immer wenn eine neue App-Version veröffentlicht werden soll auch wenn der User nur 'publish', 'release', 'veröffentlichen', 'neue Version', 'APK hochladen', 'APK deployen', 'Update bereitstellen' oder 'QR-Code aktualisieren' sagt. Trigger-Phrasen: 'publish', 'release', 'veröffentlichen', 'APK hochladen', 'neue Version', 'Update ausrollen', 'QR-Code'."
---
# Skill: Publish (APK auf VPS veröffentlichen)
Dieser Skill deckt den vollständigen Workflow ab, um eine neue App-Version auf dem VPS bereitzustellen, sodass:
1. Die **Homepage** (QR-Code + Download-Link) die aktuelle APK anbietet
2. Die **Update-Prüfung in der App** die neue Version erkennt und zum Download anbietet
---
## Architektur-Überblick
```
App (lokal) VPS (195.246.231.210)
───────────── ─────────────────────
app/build.gradle.kts /opt/krisenvorrat/
versionCode / versionName ├── docker-compose.yml (KRISENVORRAT_APP_VERSION_CODE/NAME)
├── data/app-latest.apk (statisch ausgeliefert)
./gradlew assembleDebug └── Server-Container (Ktor)
→ app-debug.apk ├── GET / → Homepage mit QR-Code
├── GET /api/version → JSON {versionCode, versionName, apkUrl}
scp → /opt/krisenvorrat/data/ └── GET /static/* → Dateien aus data/
```
### Wie die Update-Prüfung funktioniert
1. App ruft `GET {serverUrl}/api/version` auf → erhält `VersionInfo(versionCode, versionName, apkUrl)`
2. `CheckForUpdateUseCase` vergleicht `versionInfo.versionCode > BuildConfig.VERSION_CODE`
3. Falls neuer → zeigt Download-Button, lädt APK herunter, installiert via `ApkInstaller`
### Wie die Homepage funktioniert
- `GET /` liefert HTML mit QR-Code (via qrcodejs) + Download-Link auf `/static/app-latest.apk`
- Version wird aus `KRISENVORRAT_APP_VERSION_CODE` / `KRISENVORRAT_APP_VERSION_NAME` Env-Vars gelesen
---
## Publish-Workflow (Schritte)
### Schritt 1 Version bumpen
In `app/build.gradle.kts`:
- `versionCode` um 1 erhöhen
- `versionName` passend anpassen (z.B. "1.2" → "1.3")
### Schritt 2 Build & Test
```powershell
.\gradlew.bat assembleDebug test
```
Die APK liegt danach unter: `app/build/outputs/apk/debug/app-debug.apk`
### Schritt 3 APK auf VPS hochladen
```powershell
scp app/build/outputs/apk/debug/app-debug.apk root@195.246.231.210:/opt/krisenvorrat/data/app-latest.apk
```
**Voraussetzung:** SSH-Agent muss laufen und Key geladen sein (siehe vps-deploy Skill).
### Schritt 4 Server-Version aktualisieren
Die Version wird über Environment-Variablen in der `docker-compose.yml` auf dem VPS gesetzt:
```powershell
# Neue Werte per sed in docker-compose.yml eintragen
ssh root@195.246.231.210 "cd /opt/krisenvorrat && sed -i 's/KRISENVORRAT_APP_VERSION_CODE=.*/KRISENVORRAT_APP_VERSION_CODE=<neuer_code>/' docker-compose.yml && sed -i 's/KRISENVORRAT_APP_VERSION_NAME=.*/KRISENVORRAT_APP_VERSION_NAME=<neuer_name>/' docker-compose.yml"
```
Falls die Env-Vars noch nicht in der docker-compose.yml stehen, müssen sie einmalig hinzugefügt werden:
```powershell
ssh root@195.246.231.210 "cd /opt/krisenvorrat && sed -i '/KRISENVORRAT_JWT_SECRET/a\ - KRISENVORRAT_APP_VERSION_CODE=<code>\n - KRISENVORRAT_APP_VERSION_NAME=<name>' docker-compose.yml"
```
### Schritt 5 Server neustarten
```powershell
ssh root@195.246.231.210 "cd /opt/krisenvorrat && docker compose up -d"
```
### Schritt 6 Verifizieren
```powershell
# Version-API prüfen
Invoke-WebRequest -Uri "http://195.246.231.210:8080/api/version" -UseBasicParsing | Select-Object -ExpandProperty Content
# Homepage prüfen (QR-Code-Seite)
Invoke-WebRequest -Uri "http://195.246.231.210:8080/" -UseBasicParsing | Select-Object StatusCode, StatusDescription
```
Erwartete Ausgabe von `/api/version`:
```json
{"versionCode":<neuer_code>,"versionName":"<neuer_name>","apkUrl":"http://195.246.231.210:8080/static/app-latest.apk"}
```
---
## Automatisiertes Skript
Das Skript `publish-apk.ps1` in diesem Skill-Ordner automatisiert Schritte 36:
```powershell
& ".github/skills/publish/publish-apk.ps1" -VersionCode 4 -VersionName "1.3"
```
**Parameter:**
| Parameter | Pflicht | Beschreibung |
| -------------- | ------- | --------------------------------------------- |
| `-VersionCode` | ja | Neuer versionCode (Integer) |
| `-VersionName` | ja | Neuer versionName (String, z.B. "1.3") |
| `-ApkPath` | nein | Pfad zur APK (Default: debug-APK) |
| `-SkipVerify` | nein | Verifizierung überspringen |
---
## Wichtige Dateien
| Datei | Beschreibung |
|-------|-------------|
| `app/build.gradle.kts` (L18-19) | `versionCode` / `versionName` |
| `server/src/main/resources/application.conf` (L18-21) | Server-Version-Defaults + Env-Var-Override |
| `server/src/main/kotlin/.../routes/VersionRoutes.kt` | Homepage + `/api/version` Endpoint |
| `server/src/main/kotlin/.../plugins/Routing.kt` | `staticFiles("/static", File("data"))` |
| `app/src/main/java/.../usecase/CheckForUpdateUseCase.kt` | Update-Prüfung in der App |
| `app/src/main/java/.../ui/update/UpdateViewModel.kt` | Update-UI + Download-Logik |
---
## Bekannte Lücken / TODOs
- **Kein Release-Build:** Aktuell wird `assembleDebug` verwendet. Für Produktion → Signing-Config + `assembleRelease` einrichten.
- **Kein HTTPS:** Server läuft auf HTTP. QR-Code zeigt `http://`-URL. Für Play Store / Sicherheit → Caddy als Reverse Proxy.
- **Manuelle Version-Env-Vars:** Beim ersten Publish müssen die Env-Vars in die VPS docker-compose eingefügt werden. Das Skript erledigt das automatisch.

98
.github/skills/publish/publish-apk.ps1 vendored Normal file
View file

@ -0,0 +1,98 @@
<#
.SYNOPSIS
Publiziert eine APK auf den Krisenvorrat VPS.
.DESCRIPTION
Lädt die APK auf den VPS hoch, aktualisiert die Version in der
docker-compose.yml und startet den Server-Container neu.
.PARAMETER VersionCode
Neuer versionCode (Integer, muss > aktueller sein).
.PARAMETER VersionName
Neuer versionName (z.B. "1.3").
.PARAMETER ApkPath
Pfad zur APK-Datei. Default: app/build/outputs/apk/debug/app-debug.apk
.PARAMETER SkipVerify
Verifizierung nach dem Deploy überspringen.
#>
param(
[Parameter(Mandatory)] [int] $VersionCode,
[Parameter(Mandatory)] [string] $VersionName,
[string] $ApkPath = "app/build/outputs/apk/debug/app-debug.apk",
[switch] $SkipVerify
)
$ErrorActionPreference = "Stop"
$VPS = "root@195.246.231.210"
$RemoteDir = "/opt/krisenvorrat"
# --- Preflight ---
Write-Host "=== Publish APK v$VersionName (build $VersionCode) ===" -ForegroundColor Cyan
if (-not (Test-Path $ApkPath)) {
Write-Error "APK nicht gefunden: $ApkPath bitte zuerst './gradlew assembleDebug' ausfuehren."
exit 1
}
# SSH-Agent prüfen
$sshKeys = ssh-add -l 2>&1
if ($LASTEXITCODE -ne 0) {
Write-Error "SSH-Agent hat keinen Key geladen. Bitte 'ssh-add' ausfuehren."
exit 1
}
Write-Host "[OK] SSH-Agent aktiv" -ForegroundColor Green
# --- Schritt 1: APK hochladen ---
Write-Host "`n[1/4] APK hochladen..." -ForegroundColor Yellow
ssh $VPS "mkdir -p $RemoteDir/data"
scp $ApkPath "${VPS}:${RemoteDir}/data/app-latest.apk"
if ($LASTEXITCODE -ne 0) { Write-Error "APK-Upload fehlgeschlagen."; exit 1 }
Write-Host "[OK] APK hochgeladen" -ForegroundColor Green
# --- Schritt 2: Version in docker-compose.yml aktualisieren ---
Write-Host "`n[2/4] Version in docker-compose.yml aktualisieren..." -ForegroundColor Yellow
# Prüfen ob Env-Vars bereits vorhanden sind
$hasVersionCode = ssh $VPS "grep -c 'KRISENVORRAT_APP_VERSION_CODE' $RemoteDir/docker-compose.yml 2>/dev/null || echo 0"
$hasVersionCode = [int]$hasVersionCode.Trim()
if ($hasVersionCode -gt 0) {
# Update bestehende Einträge
ssh $VPS "cd $RemoteDir && sed -i 's/KRISENVORRAT_APP_VERSION_CODE=.*/KRISENVORRAT_APP_VERSION_CODE=$VersionCode/' docker-compose.yml && sed -i 's/KRISENVORRAT_APP_VERSION_NAME=.*/KRISENVORRAT_APP_VERSION_NAME=$VersionName/' docker-compose.yml"
} else {
# Erstmalig hinzufügen (nach JWT_SECRET-Zeile)
ssh $VPS "cd $RemoteDir && sed -i '/KRISENVORRAT_JWT_SECRET/a\ - KRISENVORRAT_APP_VERSION_CODE=$VersionCode\n - KRISENVORRAT_APP_VERSION_NAME=$VersionName' docker-compose.yml"
}
if ($LASTEXITCODE -ne 0) { Write-Error "Version-Update fehlgeschlagen."; exit 1 }
Write-Host "[OK] Version gesetzt: $VersionName (build $VersionCode)" -ForegroundColor Green
# --- Schritt 3: Server neustarten ---
Write-Host "`n[3/4] Server-Container neustarten..." -ForegroundColor Yellow
ssh $VPS "cd $RemoteDir && docker compose up -d"
if ($LASTEXITCODE -ne 0) { Write-Error "Container-Neustart fehlgeschlagen."; exit 1 }
Write-Host "[OK] Container neu gestartet" -ForegroundColor Green
# --- Schritt 4: Verifizieren ---
if ($SkipVerify) {
Write-Host "`n[4/4] Verifizierung uebersprungen." -ForegroundColor DarkGray
} else {
Write-Host "`n[4/4] Verifizieren (warte 5s auf Server-Start)..." -ForegroundColor Yellow
Start-Sleep -Seconds 5
try {
$response = Invoke-WebRequest -Uri "http://195.246.231.210:8080/api/version" -UseBasicParsing -TimeoutSec 10
$versionJson = $response.Content | ConvertFrom-Json
if ($versionJson.versionCode -eq $VersionCode -and $versionJson.versionName -eq $VersionName) {
Write-Host "[OK] Server meldet Version $($versionJson.versionName) (build $($versionJson.versionCode))" -ForegroundColor Green
Write-Host " APK-URL: $($versionJson.apkUrl)" -ForegroundColor DarkGray
} else {
Write-Warning "Server meldet unerwartete Version: $($versionJson | ConvertTo-Json -Compress)"
}
} catch {
Write-Warning "Verifizierung fehlgeschlagen: $_"
Write-Host "Manuell pruefen: http://195.246.231.210:8080/api/version" -ForegroundColor DarkGray
}
}
Write-Host "`n=== Publish abgeschlossen ===" -ForegroundColor Cyan
Write-Host "Homepage: http://195.246.231.210:8080/" -ForegroundColor DarkGray
Write-Host "API: http://195.246.231.210:8080/api/version" -ForegroundColor DarkGray

View file

@ -11,7 +11,7 @@ internal sealed class SettingsKey<T>(val key: String, val defaultValue: T) {
data object HouseholdSize : StringKey("household_size")
data object DailyKcalPerPerson : StringKey("daily_kcal_per_person")
data object AgeGroups : StringKey("age_groups")
data object ServerUrl : StringKey("server_url")
data object ServerUrl : StringKey("server_url", DEFAULT_SERVER_URL)
data object AuthAccessToken : StringKey("auth_access_token")
data object AuthRefreshToken : StringKey("auth_refresh_token")
data object AuthUsername : StringKey("auth_username")
@ -23,6 +23,8 @@ internal sealed class SettingsKey<T>(val key: String, val defaultValue: T) {
}
companion object {
const val DEFAULT_SERVER_URL = "http://195.246.231.210:8080"
val SENSITIVE_KEYS: Set<StringKey> = setOf(
StringKey.AuthAccessToken,
StringKey.AuthRefreshToken,

View file

@ -17,11 +17,15 @@ import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Refresh
import androidx.compose.material3.AlertDialog
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.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.OutlinedTextField
@ -260,15 +264,27 @@ internal fun SettingsScreen(
Spacer(modifier = Modifier.height(12.dp))
OutlinedTextField(
value = uiState.serverUrl,
onValueChange = viewModel::onServerUrlChanged,
label = { Text("Server-URL") },
placeholder = { Text("https://example.com") },
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Uri),
singleLine = true,
modifier = Modifier.fillMaxWidth()
)
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically
) {
OutlinedTextField(
value = uiState.serverUrl,
onValueChange = viewModel::onServerUrlChanged,
label = { Text("Server-URL") },
placeholder = { Text("https://example.com") },
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Uri),
singleLine = true,
modifier = Modifier.weight(1f)
)
Spacer(modifier = Modifier.width(8.dp))
IconButton(onClick = viewModel::resetServerUrl) {
Icon(
imageVector = Icons.Default.Refresh,
contentDescription = "Standard-Server wiederherstellen"
)
}
}
Spacer(modifier = Modifier.height(12.dp))
@ -342,25 +358,19 @@ internal fun SettingsScreen(
uiState.serverUrl.isNotBlank() &&
uiState.syncStatus !is SyncStatus.Running
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
OutlinedButton(
onClick = viewModel::pushSync,
enabled = isSyncEnabled,
modifier = Modifier.weight(1f)
) {
Text("Push (Hochladen)")
}
Text(
text = "Synchronisierung erfolgt automatisch.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
OutlinedButton(
onClick = viewModel::pullSync,
enabled = isSyncEnabled,
modifier = Modifier.weight(1f)
) {
Text("Pull (Herunterladen)")
}
Spacer(modifier = Modifier.height(8.dp))
OutlinedButton(
onClick = viewModel::pullSync,
enabled = isSyncEnabled,
) {
Text("Jetzt synchronisieren")
}
when (val syncStatus = uiState.syncStatus) {

View file

@ -14,7 +14,7 @@ internal data class SettingsUiState(
val isImporting: Boolean = false,
val importResult: ImportResult? = null,
val pendingImportUri: Uri? = null,
val serverUrl: String = "",
val serverUrl: String = de.krisenvorrat.app.domain.model.SettingsKey.DEFAULT_SERVER_URL,
val isLoggedIn: Boolean = false,
val loggedInUsername: String = "",
val loginUsername: String = "",

View file

@ -11,6 +11,7 @@ import de.krisenvorrat.app.data.sync.WebSocketClient
import de.krisenvorrat.app.data.sync.WebSocketEvent
import de.krisenvorrat.app.domain.model.AgeGroup
import de.krisenvorrat.app.domain.model.AgeGroupEntry
import de.krisenvorrat.app.domain.model.SettingsKey
import de.krisenvorrat.app.domain.model.SettingsKey.StringKey
import de.krisenvorrat.app.domain.model.defaultAgeGroups
import de.krisenvorrat.app.domain.model.parseAgeGroupsFromJson
@ -58,6 +59,7 @@ internal class SettingsViewModel @Inject constructor(
}
is WebSocketEvent.Connected -> {
_uiState.update { it.copy(syncStatus = SyncStatus.Idle) }
pullSync(fullSync = false)
}
else -> {}
}
@ -70,6 +72,7 @@ internal class SettingsViewModel @Inject constructor(
try {
val ageGroups = loadAgeGroupsWithMigration()
val serverUrl = settingsRepository.getString(StringKey.ServerUrl)
.ifBlank { SettingsKey.DEFAULT_SERVER_URL }
val authUsername = settingsRepository.getString(StringKey.AuthUsername)
val accessToken = settingsRepository.getString(StringKey.AuthAccessToken)
val isLoggedIn = authUsername.isNotBlank() && accessToken.isNotBlank()
@ -150,6 +153,10 @@ internal class SettingsViewModel @Inject constructor(
_uiState.update { it.copy(serverUrl = value, isSaved = false) }
}
fun resetServerUrl() {
_uiState.update { it.copy(serverUrl = SettingsKey.DEFAULT_SERVER_URL, isSaved = false) }
}
fun onLoginUsernameChanged(value: String) {
_uiState.update { it.copy(loginUsername = value, loginError = null) }
}

View file

@ -168,7 +168,7 @@ class SettingsRepositoryImplTest {
val result = repository.getString(SettingsKey.StringKey.ServerUrl)
// Then
assertEquals("", result)
assertEquals(SettingsKey.DEFAULT_SERVER_URL, result)
}
@Test
@ -215,6 +215,6 @@ class SettingsRepositoryImplTest {
val result = repository.observeString(SettingsKey.StringKey.ServerUrl).first()
// Then
assertEquals("", result)
assertEquals(SettingsKey.DEFAULT_SERVER_URL, result)
}
}