From e3bcddac707d59c468081a32a1662c240100a623 Mon Sep 17 00:00:00 2001 From: Jens Reinemann Date: Mon, 18 May 2026 15:13:49 +0200 Subject: [PATCH] chore: update publish tooling and Android messaging integration --- .github/copilot-instructions.md | 9 + .github/skills/publish/SKILL.md | 9 +- .github/skills/publish/publish-apk.ps1 | 54 ++- .github/skills/publish/publish-apk.py | 122 +++++- app/build.gradle.kts | 2 +- app/src/main/AndroidManifest.xml | 6 - .../main/java/de/bollwerk/app/MainActivity.kt | 31 +- .../app/notification/NotificationHelper.kt | 105 ++++- .../bollwerk/app/service/MessagingService.kt | 88 ---- .../java/de/bollwerk/app/ui/MainScreen.kt | 37 +- .../java/de/bollwerk/app/ui/MainViewModel.kt | 13 +- .../app/ui/settings/SettingsViewModel.kt | 2 - knowledge-conduit/kc_generaltest.py | 394 ++++++++++++++++++ 13 files changed, 696 insertions(+), 176 deletions(-) delete mode 100644 app/src/main/java/de/bollwerk/app/service/MessagingService.kt create mode 100644 knowledge-conduit/kc_generaltest.py diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 0dea8c1..f4b4d52 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -38,3 +38,12 @@ - Der String `krisenvorrat` ist vollständig veraltet und darf **nirgendwo** verwendet werden: nicht in Pfaden, nicht in Namen, nicht in Kommentaren, nicht in Befehlen. - Korrekt: `/opt/bollwerk/`, `bollwerk-server`, `bollwerk.online` - Falsch: `/opt/krisenvorrat/`, `krisenvorrat` + +--- + +## Versionierung (PFLICHT) + +- `versionName` muss immer strikt dem Muster `x.y.z` folgen (z. B. `1.7.13`). +- Das dritte Segment (`z`) muss dem `versionCode` entsprechen. +- Formate wie `1.7 (build 13)` sind verboten. +- Bei Release-Commit-Messages darf kein Klammer-`build`-Format verwendet werden. diff --git a/.github/skills/publish/SKILL.md b/.github/skills/publish/SKILL.md index b0f2560..53d5aac 100644 --- a/.github/skills/publish/SKILL.md +++ b/.github/skills/publish/SKILL.md @@ -53,7 +53,8 @@ scp → /opt/bollwerk/data/ └── GET /static/* → Dateie In `app/build.gradle.kts`: - `versionCode` um 1 erhöhen -- `versionName` passend anpassen (z.B. "1.2" → "1.3") +- `versionName` **immer strikt im Format `x.y.z`** führen (z.B. `1.7.13`) +- Das dritte Segment (`z`) muss dem `versionCode` entsprechen ### Schritt 2 – Build & Test @@ -112,11 +113,11 @@ Invoke-WebRequest -Uri "https://bollwerk.online/" -UseBasicParsing | Select-Obje Das Skript `publish-apk.py` in diesem Skill-Ordner automatisiert den **gesamten** Release-Workflow (Version bumpen, bauen, hochladen, API-Call, verifizieren, committen): ```powershell -# Alles automatisch (versionCode +1, versionName bleibt): +# Alles automatisch (versionCode +1, versionName wird auf x.y. gesetzt): python ".github/skills/publish/publish-apk.py" # Mit neuem versionName: -python ".github/skills/publish/publish-apk.py" --version-name "2.0" +python ".github/skills/publish/publish-apk.py" --version-name "2.0.42" # Nur hochladen (APK bereits gebaut, kein Push): python ".github/skills/publish/publish-apk.py" --skip-build --skip-push @@ -131,7 +132,7 @@ python ".github/skills/publish/publish-apk.py" --skip-build --skip-push | Parameter | Pflicht | Default | Beschreibung | | ---------------- | ------- | ----------- | ------------------------------------------------- | -| `--version-name` | nein | aktueller | Neuer versionName (z.B. "2.0"); sonst unverändert | +| `--version-name` | nein | aus current major.minor + code | Neuer versionName im Format `x.y.z` (z.B. "2.0.42") | | `--version-code` | nein | aktuell + 1 | Expliziter versionCode; sonst automatisch +1 | | `--apk-path` | nein | debug-APK | Pfad zur APK-Datei | | `--skip-build` | nein | false | Gradle-Build überspringen | diff --git a/.github/skills/publish/publish-apk.ps1 b/.github/skills/publish/publish-apk.ps1 index b4b1288..f7e84b5 100644 --- a/.github/skills/publish/publish-apk.ps1 +++ b/.github/skills/publish/publish-apk.ps1 @@ -15,7 +15,7 @@ - Fehler nach Upload (API) → APK liegt schon oben; kein Rollback, Recovery-Hinweis - Fehler bei Verify/Git → Deployment war erfolgreich; nur Warnung, kein Abbruch .PARAMETER VersionName - Neue versionName (z.B. "1.8"). Wenn weggelassen, bleibt die aktuelle. + Neue versionName im Format x.y.z (z.B. "1.7.14"). .PARAMETER VersionCode Expliziter versionCode. Wenn weggelassen, wird der aktuelle automatisch um 1 erhöht. .PARAMETER ApkPath @@ -72,6 +72,20 @@ function Write-OK { param([string]$Text); Write-Host "[OK] $Text" -ForegroundC function Write-Skip { param([string]$Text); Write-Host "[--] $Text" -ForegroundColor DarkGray } function Write-Fail { param([string]$Text); Write-Host "[!!] $Text" -ForegroundColor Red } +function Get-MajorMinor { + param([string]$VersionName) + $m = [regex]::Match($VersionName, '^(\d+)\.(\d+)(?:\.(\d+))?$') + if (-not $m.Success) { return $null } + return @($m.Groups[1].Value, $m.Groups[2].Value) +} + +function Get-ThreePartVersion { + param([string]$VersionName) + $m = [regex]::Match($VersionName, '^(\d+)\.(\d+)\.(\d+)$') + if (-not $m.Success) { return $null } + return @([int]$m.Groups[1].Value, [int]$m.Groups[2].Value, [int]$m.Groups[3].Value) +} + # ───────────────────────────────────────────────────────────────────────────── # Pre-Checks (nichts wird verändert) # ───────────────────────────────────────────────────────────────────────────── @@ -105,7 +119,26 @@ if (-not $nameMatch.Success) { $currentCode = [int]$codeMatch.Groups[1].Value $currentName = $nameMatch.Groups[1].Value $newCode = if ($VersionCode -gt 0) { $VersionCode } else { $currentCode + 1 } -$newName = if ($VersionName) { $VersionName } else { $currentName } + +if ($VersionName) { + $parts = Get-ThreePartVersion $VersionName + if (-not $parts) { + Write-Fail "-VersionName muss strikt im Format x.y.z angegeben werden (z.B. 1.7.14)." + exit 1 + } + if ($parts[2] -ne $newCode) { + Write-Fail "Patch-Segment der versionName ($($parts[2])) muss dem versionCode ($newCode) entsprechen." + exit 1 + } + $newName = $VersionName +} else { + $majorMinor = Get-MajorMinor $currentName + if (-not $majorMinor) { + Write-Fail "Aktuelle versionName '$currentName' hat kein gueltiges Schema. Erlaubt: x.y oder x.y.z" + exit 1 + } + $newName = "$($majorMinor[0]).$($majorMinor[1]).$newCode" +} if ($newCode -le $currentCode) { Write-Fail "Neuer versionCode ($newCode) muss groesser als aktueller ($currentCode) sein." @@ -121,16 +154,14 @@ if ($LASTEXITCODE -ne 0) { Write-OK "Pre-Checks bestanden (Token, Gradle-Parsing, SSH-Agent)" Write-Host "" -Write-Host "=== Publish APK v$newName (build $newCode) ===" -ForegroundColor Cyan -Write-Host " $currentName ($currentCode) → $newName ($newCode)" -ForegroundColor Gray +Write-Host "=== Publish APK v$newName ===" -ForegroundColor Cyan +Write-Host " $currentName [$currentCode] → $newName [$newCode]" -ForegroundColor Gray # ───────────────────────────────────────────────────────────────────────────── # Schritt 0: build.gradle.kts patchen # ───────────────────────────────────────────────────────────────────────────── $patchedContent = $originalContent -replace "versionCode\s*=\s*$currentCode\b", "versionCode = $newCode" -if ($VersionName) { - $patchedContent = $patchedContent -replace 'versionName\s*=\s*"[^"]+"', "versionName = `"$newName`"" -} +$patchedContent = $patchedContent -replace 'versionName\s*=\s*"[^"]+"', "versionName = `"$newName`"" try { [System.IO.File]::WriteAllText((Resolve-Path $BuildGradle).Path, $patchedContent) @@ -224,7 +255,7 @@ for ($attempt = 1; $attempt -le $maxRetries; $attempt++) { } if ($apiSuccess) { - Write-OK "Version gesetzt: $newName (build $newCode)" + Write-OK "Version gesetzt: $newName" } else { # Deployment war teilweise erfolgreich → trotzdem committen, aber warnen Write-Host "" @@ -264,10 +295,10 @@ if (-not $apiSuccess -or $SkipVerify) { Write-Step "[Git] Version-Bump committen..." try { git add $BuildGradle - git commit -m "chore: release v$newName ($newCode)" + git commit -m "chore: release v$newName" } catch { Write-Host "[??] git commit fehlgeschlagen: $_" -ForegroundColor DarkYellow - Write-Host " Manuell: git add $BuildGradle ; git commit -m 'chore: release v$newName ($newCode)'" -ForegroundColor DarkGray + Write-Host " Manuell: git add $BuildGradle ; git commit -m 'chore: release v$newName'" -ForegroundColor DarkGray } if (-not $SkipPush) { @@ -287,6 +318,7 @@ if (-not $SkipPush) { # ───────────────────────────────────────────────────────────────────────────── Write-Host "" Write-Host "=== Publish abgeschlossen ===" -ForegroundColor Cyan -Write-Host " Version : $newName (build $newCode)" +Write-Host " Version : $newName" +Write-Host " Code : $newCode" Write-Host " Homepage: $ServerUrl/" Write-Host " API : $ServerUrl/api/version" diff --git a/.github/skills/publish/publish-apk.py b/.github/skills/publish/publish-apk.py index 078379a..37289a5 100644 --- a/.github/skills/publish/publish-apk.py +++ b/.github/skills/publish/publish-apk.py @@ -17,7 +17,7 @@ Rollback-Strategie: Verwendung: python publish-apk.py - python publish-apk.py --version-name 2.0 + python publish-apk.py --version-name 2.0.42 python publish-apk.py --skip-build --skip-push """ @@ -108,13 +108,73 @@ def get_status(url: str, timeout: int = 10): with urllib.request.urlopen(req, timeout=timeout) as resp: return resp.status + +def parse_major_minor(version_name: str): + """Extrahiert major/minor aus x.y oder x.y.z.""" + m = re.match(r"^(\d+)\.(\d+)(?:\.(\d+))?$", version_name.strip()) + if not m: + return None + return m.group(1), m.group(2) + + +def validate_three_part_version(version_name: str): + """Validiert strikt x.y.z (nur numerische Segmente).""" + m = re.match(r"^(\d+)\.(\d+)\.(\d+)$", version_name.strip()) + if not m: + return None + return int(m.group(1)), int(m.group(2)), int(m.group(3)) + + +def _extract_admin_token(raw_text: str) -> str: + """Extrahiert BOLLWERK_ADMIN_TOKEN aus docker-compose/.env-Inhalten.""" + if not raw_text: + return "" + + patterns = [ + r"(?m)^\s*BOLLWERK_ADMIN_TOKEN\s*[:=]\s*['\"]?([^'\"\s#]+)", + r"(?m)^\s*-\s*BOLLWERK_ADMIN_TOKEN\s*[:=]\s*['\"]?([^'\"\s#]+)", + r"(?m)^\s*-\s*BOLLWERK_ADMIN_TOKEN\s*=\s*['\"]?([^'\"\s#]+)", + ] + for pattern in patterns: + match = re.search(pattern, raw_text) + if match: + return match.group(1).strip() + return "" + + +def get_admin_token_from_vps() -> str: + """Holt den Admin-Token direkt vom VPS (Dateien, dann Container-Env).""" + ssh_opts = "-o BatchMode=yes -o ConnectTimeout=10 -o StrictHostKeyChecking=no" + + probe_commands = [ + ( + "compose+env files", + f'ssh {ssh_opts} {VPS} ' + f'"cd {REMOTE_DIR}; ' + f'[ -f docker-compose.yml ] && cat docker-compose.yml || true; ' + f'echo __ENV_SPLIT__; ' + f'[ -f .env ] && cat .env || true"', + ), + ( + "docker inspect env", + f'ssh {ssh_opts} {VPS} "docker inspect $(docker ps -q) 2>/dev/null || true"', + ), + ] + + for _, cmd in probe_commands: + result = subprocess.run(cmd, shell=True, capture_output=True, text=True) + token = _extract_admin_token(result.stdout or "") + if token: + return token + return "" + # --------------------------------------------------------------------------- # Argument-Parsing # --------------------------------------------------------------------------- def parse_args(): parser = argparse.ArgumentParser(description="Bollwerk APK Publish-Workflow") - parser.add_argument("--version-name", default="", help="Neue versionName (z.B. '1.8')") + parser.add_argument("--version-name", default="", help="Neue versionName im Format x.y.z (z.B. '1.7.14')") parser.add_argument("--version-code", type=int, default=0, help="Expliziter versionCode") parser.add_argument("--apk-path", default="app/build/outputs/apk/debug/app-debug.apk") parser.add_argument("--skip-build", action="store_true") @@ -129,13 +189,6 @@ def parse_args(): def main(): args = parse_args() - # --- Pre-Check: Admin-Token --- - admin_token = os.environ.get("BOLLWERK_ADMIN_TOKEN", "") - if not admin_token: - fail("BOLLWERK_ADMIN_TOKEN ist nicht gesetzt.") - info("Bitte setzen: $env:BOLLWERK_ADMIN_TOKEN = 'dein-token'") - sys.exit(1) - # --- Pre-Check: build.gradle.kts lesen --- if not os.path.exists(BUILD_GRADLE): fail(f"Datei nicht gefunden: {BUILD_GRADLE}") @@ -156,7 +209,22 @@ def main(): current_code = int(code_match.group(1)) current_name = name_match.group(1) new_code = args.version_code if args.version_code > 0 else current_code + 1 - new_name = args.version_name if args.version_name else current_name + + if args.version_name: + parsed = validate_three_part_version(args.version_name) + if not parsed: + fail("--version-name muss strikt im Format x.y.z angegeben werden (z. B. 1.7.14).") + sys.exit(1) + if parsed[2] != new_code: + fail(f"Patch-Segment der versionName ({parsed[2]}) muss dem versionCode ({new_code}) entsprechen.") + sys.exit(1) + new_name = args.version_name.strip() + else: + major_minor = parse_major_minor(current_name) + if not major_minor: + fail(f"Aktuelle versionName '{current_name}' hat kein gueltiges Schema. Erlaubt: x.y oder x.y.z") + sys.exit(1) + new_name = f"{major_minor[0]}.{major_minor[1]}.{new_code}" if new_code <= current_code: fail(f"Neuer versionCode ({new_code}) muss groesser als aktueller ({current_code}) sein.") @@ -168,10 +236,20 @@ def main(): fail("SSH-Agent hat keinen Key. Bitte ausfuehren: ssh-add C:\\Users\\JensR\\.ssh\\id_ed25519") sys.exit(1) + # --- Pre-Check: Admin-Token direkt vom VPS holen --- + step("[Preflight] Lade BOLLWERK_ADMIN_TOKEN direkt vom VPS...") + admin_token = get_admin_token_from_vps() + if admin_token: + ok("Admin-Token vom VPS geladen") + else: + fail("BOLLWERK_ADMIN_TOKEN konnte auf dem VPS nicht ermittelt werden.") + info(f"Pruefe auf {REMOTE_DIR}/docker-compose.yml, {REMOTE_DIR}/.env oder Container-Env") + sys.exit(1) + ok("Pre-Checks bestanden (Token, Gradle-Parsing, SSH-Agent)") - print(f"\n{CYAN}=== Publish APK v{new_name} (build {new_code}) ==={RESET}", flush=True) - print(f"{GRAY} {current_name} ({current_code}) -> {new_name} ({new_code}){RESET}", flush=True) + print(f"\n{CYAN}=== Publish APK v{new_name} ==={RESET}", flush=True) + print(f"{GRAY} {current_name} [{current_code}] -> {new_name} [{new_code}]{RESET}", flush=True) # ------------------------------------------------------------------------- # Schritt 0: build.gradle.kts patchen @@ -181,12 +259,11 @@ def main(): f'versionCode = {new_code}', original_content, ) - if args.version_name: - patched = re.sub( - r'versionName\s*=\s*"[^"]+"', - f'versionName = "{new_name}"', - patched, - ) + patched = re.sub( + r'versionName\s*=\s*"[^"]+"', + f'versionName = "{new_name}"', + patched, + ) try: with open(BUILD_GRADLE, "w", encoding="utf-8") as f: @@ -270,7 +347,7 @@ def main(): info(f" -d '{json.dumps(api_body)}'") if api_success: - ok(f"Version gesetzt: {new_name} (build {new_code})") + ok(f"Version gesetzt: {new_name}") else: warn("VPS-Version nicht aktualisiert. build.gradle.kts wird trotzdem committet.") @@ -304,10 +381,10 @@ def main(): step("[Git] Version-Bump committen...") try: run(f'git add "{BUILD_GRADLE}"') - run(f'git commit -m "chore: release v{new_name} ({new_code})"') + run(f'git commit -m "chore: release v{new_name}"') except Exception as e: warn(f"git commit fehlgeschlagen: {e}") - info(f'Manuell: git add "{BUILD_GRADLE}" ; git commit -m "chore: release v{new_name} ({new_code})"') + info(f'Manuell: git add "{BUILD_GRADLE}" ; git commit -m "chore: release v{new_name}"') if not args.skip_push: try: @@ -323,7 +400,8 @@ def main(): # Zusammenfassung # ------------------------------------------------------------------------- print(f"\n{CYAN}=== Publish abgeschlossen ==={RESET}", flush=True) - print(f" Version : {new_name} (build {new_code})", flush=True) + print(f" Version : {new_name}", flush=True) + print(f" Code : {new_code}", flush=True) print(f" Homepage: {SERVER_URL}/", flush=True) print(f" API : {SERVER_URL}/api/version", flush=True) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 644b850..a8a3a33 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -16,7 +16,7 @@ android { minSdk = 26 targetSdk = 35 versionCode = 13 - versionName = "1.7" + versionName = "1.7.13" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" buildConfigField("boolean", "FEATURE_CAMERA_ENABLED", "false") diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index a6ea6c8..f4a8039 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -4,8 +4,6 @@ - - - diff --git a/app/src/main/java/de/bollwerk/app/MainActivity.kt b/app/src/main/java/de/bollwerk/app/MainActivity.kt index 0060d50..593a68c 100644 --- a/app/src/main/java/de/bollwerk/app/MainActivity.kt +++ b/app/src/main/java/de/bollwerk/app/MainActivity.kt @@ -32,9 +32,12 @@ class MainActivity : ComponentActivity() { @Inject internal lateinit var seedDatabaseUseCase: SeedDatabaseUseCase @Inject internal lateinit var ensureKeyPairUseCase: EnsureKeyPairUseCase + @Inject internal lateinit var notificationHelper: NotificationHelper - private val _chatNavigationEvents = MutableSharedFlow(extraBufferCapacity = 1) - val chatNavigationEvents: SharedFlow = _chatNavigationEvents.asSharedFlow() + private val _notificationNavigationEvents = + MutableSharedFlow(extraBufferCapacity = 1) + private val notificationNavigationEvents: SharedFlow = + _notificationNavigationEvents.asSharedFlow() private val notificationPermissionLauncher = registerForActivityResult( ActivityResultContracts.RequestPermission() @@ -46,10 +49,10 @@ class MainActivity : ComponentActivity() { lifecycleScope.launch { ensureKeyPairUseCase.execute() } enableEdgeToEdge() requestNotificationPermissionIfNeeded() - handleChatDeepLink(intent) + handleNotificationDeepLink(intent) setContent { BollwerkTheme { - MainScreen(chatNavigationEvents = chatNavigationEvents) + MainScreen(notificationNavigationEvents = notificationNavigationEvents) } } } @@ -66,22 +69,34 @@ class MainActivity : ComponentActivity() { override fun onNewIntent(intent: Intent) { super.onNewIntent(intent) - handleChatDeepLink(intent) + handleNotificationDeepLink(intent) } - private fun handleChatDeepLink(intent: Intent?) { + private fun handleNotificationDeepLink(intent: Intent?) { val recipientId = intent?.getStringExtra(NotificationHelper.EXTRA_RECIPIENT_ID) val recipientUsername = intent?.getStringExtra(NotificationHelper.EXTRA_RECIPIENT_USERNAME) if (recipientId != null && recipientUsername != null) { - _chatNavigationEvents.tryEmit(ChatDeepLink(recipientId, recipientUsername)) + _notificationNavigationEvents.tryEmit( + NotificationNavigationEvent.OpenChat(recipientId, recipientUsername) + ) // Clear extras to prevent re-navigation on config change intent?.removeExtra(NotificationHelper.EXTRA_RECIPIENT_ID) intent?.removeExtra(NotificationHelper.EXTRA_RECIPIENT_USERNAME) + return + } + + if (intent?.getBooleanExtra(NotificationHelper.EXTRA_OPEN_MESSAGES, false) == true) { + notificationHelper.cancelAllMessageNotifications() + _notificationNavigationEvents.tryEmit(NotificationNavigationEvent.OpenMessages) + intent.removeExtra(NotificationHelper.EXTRA_OPEN_MESSAGES) } } } -data class ChatDeepLink(val recipientId: String, val recipientUsername: String) +internal sealed interface NotificationNavigationEvent { + data class OpenChat(val recipientId: String, val recipientUsername: String) : NotificationNavigationEvent + data object OpenMessages : NotificationNavigationEvent +} @Preview(showBackground = true) @Composable diff --git a/app/src/main/java/de/bollwerk/app/notification/NotificationHelper.kt b/app/src/main/java/de/bollwerk/app/notification/NotificationHelper.kt index 454df7c..9fab60e 100644 --- a/app/src/main/java/de/bollwerk/app/notification/NotificationHelper.kt +++ b/app/src/main/java/de/bollwerk/app/notification/NotificationHelper.kt @@ -22,6 +22,10 @@ internal class NotificationHelper @Inject constructor( @Volatile private var activeChatPartnerId: String? = null + @Volatile + private var isMessagingAreaVisible: Boolean = false + private val activeSenderNotificationIds = mutableSetOf() + private val activeSenderNamesById = mutableMapOf() /// Erstellt den Notification Channel (ab API 26 erforderlich). fun createNotificationChannel() { @@ -50,24 +54,41 @@ internal class NotificationHelper @Inject constructor( activeChatPartnerId = partnerId } + /// Setzt, ob der Nutzer aktuell im Nachrichtenbereich ist (User-Liste oder Chat). + fun setMessagingAreaVisible(isVisible: Boolean) { + isMessagingAreaVisible = isVisible + } + /// Zeigt eine Benachrichtigung für eine eingehende Nachricht an. /// Gibt false zurück, wenn die Notification unterdrückt wurde (Chat ist aktiv). fun showNewMessageNotification( senderId: String, senderUsername: String ): Boolean { - if (senderId == activeChatPartnerId) return false + if (isMessagingAreaVisible || senderId == activeChatPartnerId) return false - val intent = Intent(context, MainActivity::class.java).apply { + val chatIntent = Intent(context, MainActivity::class.java).apply { flags = Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_CLEAR_TOP putExtra(EXTRA_RECIPIENT_ID, senderId) putExtra(EXTRA_RECIPIENT_USERNAME, senderUsername) } - val pendingIntent = PendingIntent.getActivity( + val chatPendingIntent = PendingIntent.getActivity( context, senderId.hashCode(), - intent, + chatIntent, + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE + ) + + val messagesIntent = Intent(context, MainActivity::class.java).apply { + flags = Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_CLEAR_TOP + putExtra(EXTRA_OPEN_MESSAGES, true) + } + + val messagesPendingIntent = PendingIntent.getActivity( + context, + SUMMARY_NOTIFICATION_ID, + messagesIntent, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE ) @@ -77,23 +98,38 @@ internal class NotificationHelper @Inject constructor( .setContentText("$senderUsername hat etwas geschrieben") .setPriority(NotificationCompat.PRIORITY_HIGH) .setAutoCancel(true) - .setContentIntent(pendingIntent) + .setContentIntent(chatPendingIntent) .setGroup(GROUP_KEY) .build() - val summaryNotification = NotificationCompat.Builder(context, CHANNEL_ID) - .setSmallIcon(R.drawable.ic_notification_message) - .setContentTitle("Neue Nachrichten") - .setContentText("Du hast neue Nachrichten") - .setGroup(GROUP_KEY) - .setGroupSummary(true) - .setAutoCancel(true) - .build() - + val senderNotificationId = senderId.hashCode() val notificationManager = NotificationManagerCompat.from(context) try { - notificationManager.notify(senderId.hashCode(), notification) - notificationManager.notify(SUMMARY_NOTIFICATION_ID, summaryNotification) + val (shouldShowSummary, summaryText) = synchronized(activeSenderNotificationIds) { + activeSenderNotificationIds.add(senderNotificationId) + activeSenderNamesById[senderNotificationId] = senderUsername + Pair( + activeSenderNotificationIds.size > 1, + buildSummaryText(activeSenderNamesById.values.toList()) + ) + } + + val summaryNotification = NotificationCompat.Builder(context, CHANNEL_ID) + .setSmallIcon(R.drawable.ic_notification_message) + .setContentTitle("Neue Nachrichten") + .setContentText(summaryText) + .setGroup(GROUP_KEY) + .setGroupSummary(true) + .setAutoCancel(true) + .setContentIntent(messagesPendingIntent) + .build() + + notificationManager.notify(senderNotificationId, notification) + if (shouldShowSummary) { + notificationManager.notify(SUMMARY_NOTIFICATION_ID, summaryNotification) + } else { + notificationManager.cancel(SUMMARY_NOTIFICATION_ID) + } } catch (_: SecurityException) { // Permission not granted – ignore silently return false @@ -104,7 +140,41 @@ internal class NotificationHelper @Inject constructor( /// Entfernt Benachrichtigungen für einen bestimmten Absender (wenn der Chat geöffnet wird). fun cancelNotificationForSender(senderId: String) { val notificationManager = NotificationManagerCompat.from(context) - notificationManager.cancel(senderId.hashCode()) + val senderNotificationId = senderId.hashCode() + notificationManager.cancel(senderNotificationId) + val hasMoreSenders = synchronized(activeSenderNotificationIds) { + activeSenderNotificationIds.remove(senderNotificationId) + activeSenderNamesById.remove(senderNotificationId) + activeSenderNotificationIds.isNotEmpty() + } + if (!hasMoreSenders) { + notificationManager.cancel(SUMMARY_NOTIFICATION_ID) + } + } + + fun cancelAllMessageNotifications() { + val notificationManager = NotificationManagerCompat.from(context) + val ids = synchronized(activeSenderNotificationIds) { + val copy = activeSenderNotificationIds.toList() + activeSenderNotificationIds.clear() + activeSenderNamesById.clear() + copy + } + ids.forEach { notificationManager.cancel(it) } + notificationManager.cancel(SUMMARY_NOTIFICATION_ID) + } + + private fun buildSummaryText(senderNames: List): String { + val uniqueSenders = senderNames.distinct() + return when (uniqueSenders.size) { + 0 -> "Neue Nachrichten" + 1 -> "Neue Nachricht von ${uniqueSenders[0]}" + 2 -> "Neue Nachrichten von ${uniqueSenders[0]} und ${uniqueSenders[1]}" + else -> { + val remaining = uniqueSenders.size - 2 + "Neue Nachrichten von ${uniqueSenders[0]}, ${uniqueSenders[1]} und $remaining weiteren" + } + } } companion object { @@ -115,5 +185,6 @@ internal class NotificationHelper @Inject constructor( const val EXTRA_RECIPIENT_ID = "notification_recipient_id" const val EXTRA_RECIPIENT_USERNAME = "notification_recipient_username" + const val EXTRA_OPEN_MESSAGES = "notification_open_messages" } } diff --git a/app/src/main/java/de/bollwerk/app/service/MessagingService.kt b/app/src/main/java/de/bollwerk/app/service/MessagingService.kt deleted file mode 100644 index f24643f..0000000 --- a/app/src/main/java/de/bollwerk/app/service/MessagingService.kt +++ /dev/null @@ -1,88 +0,0 @@ -package de.bollwerk.app.service - -import android.app.Notification -import android.app.NotificationChannel -import android.app.NotificationManager -import android.app.PendingIntent -import android.app.Service -import android.content.Context -import android.content.Intent -import android.content.pm.ServiceInfo -import android.os.IBinder -import androidx.core.app.NotificationCompat -import de.bollwerk.app.MainActivity -import de.bollwerk.app.R - -/** - * Foreground Service der den Prozess am Leben hält, damit die WebSocket-Verbindung - * auch im Hintergrund bestehen bleibt und Nachrichten empfangen werden können. - */ -class MessagingService : Service() { - - override fun onCreate() { - super.onCreate() - createServiceChannel() - } - - override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { - when (intent?.action) { - ACTION_STOP -> { - stopForeground(STOP_FOREGROUND_REMOVE) - stopSelf() - return START_NOT_STICKY - } - } - startForeground(NOTIFICATION_ID, buildNotification(), ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC) - return START_STICKY - } - - override fun onBind(intent: Intent?): IBinder? = null - - private fun createServiceChannel() { - val channel = NotificationChannel( - CHANNEL_ID, - "Verbindung", - NotificationManager.IMPORTANCE_LOW - ).apply { - description = "Hält die Verbindung zum Server für Nachrichten aufrecht" - setShowBadge(false) - } - val manager = getSystemService(NotificationManager::class.java) - manager.createNotificationChannel(channel) - } - - private fun buildNotification(): Notification { - val openIntent = PendingIntent.getActivity( - this, - 0, - Intent(this, MainActivity::class.java), - PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE - ) - - return NotificationCompat.Builder(this, CHANNEL_ID) - .setSmallIcon(R.drawable.ic_notification_message) - .setContentTitle("Bollwerk") - .setContentText("Verbunden – Nachrichten werden empfangen") - .setOngoing(true) - .setContentIntent(openIntent) - .build() - } - - companion object { - private const val CHANNEL_ID = "bollwerk_service" - private const val NOTIFICATION_ID = 9001 - const val ACTION_STOP = "de.bollwerk.app.STOP_MESSAGING_SERVICE" - - fun start(context: Context) { - val intent = Intent(context, MessagingService::class.java) - context.startForegroundService(intent) - } - - fun stop(context: Context) { - val intent = Intent(context, MessagingService::class.java).apply { - action = ACTION_STOP - } - context.startService(intent) - } - } -} diff --git a/app/src/main/java/de/bollwerk/app/ui/MainScreen.kt b/app/src/main/java/de/bollwerk/app/ui/MainScreen.kt index d633669..9c70f9f 100644 --- a/app/src/main/java/de/bollwerk/app/ui/MainScreen.kt +++ b/app/src/main/java/de/bollwerk/app/ui/MainScreen.kt @@ -35,7 +35,7 @@ import androidx.navigation.NavDestination.Companion.hasRoute import androidx.navigation.NavGraph.Companion.findStartDestination import androidx.navigation.compose.currentBackStackEntryAsState import androidx.navigation.compose.rememberNavController -import de.bollwerk.app.ChatDeepLink +import de.bollwerk.app.NotificationNavigationEvent import de.bollwerk.app.ui.inventory.InventoryPickerSheet import de.bollwerk.app.ui.inventory.InventoryPickerViewModel import de.bollwerk.app.ui.navigation.BollwerkNavGraph @@ -48,7 +48,7 @@ import kotlinx.coroutines.flow.SharedFlow @OptIn(ExperimentalMaterial3Api::class) @Composable -internal fun MainScreen(chatNavigationEvents: SharedFlow) { +internal fun MainScreen(notificationNavigationEvents: SharedFlow) { val navController = rememberNavController() val navBackStackEntry by navController.currentBackStackEntryAsState() val currentDestination = navBackStackEntry?.destination @@ -67,15 +67,32 @@ internal fun MainScreen(chatNavigationEvents: SharedFlow) { } } + val isInMessagingArea = + currentDestination?.hasRoute(Screen.UserList::class) == true || + currentDestination?.hasRoute(Screen.Chat::class) == true + LaunchedEffect(isInMessagingArea) { + mainViewModel.setMessagingAreaVisible(isInMessagingArea) + } + LaunchedEffect(Unit) { - chatNavigationEvents.collect { deepLink -> - navController.navigate( - Screen.Chat( - recipientId = deepLink.recipientId, - recipientUsername = deepLink.recipientUsername - ) - ) { - launchSingleTop = true + notificationNavigationEvents.collect { event -> + when (event) { + is NotificationNavigationEvent.OpenChat -> { + navController.navigate( + Screen.Chat( + recipientId = event.recipientId, + recipientUsername = event.recipientUsername + ) + ) { + launchSingleTop = true + } + } + + NotificationNavigationEvent.OpenMessages -> { + navController.navigate(Screen.UserList) { + launchSingleTop = true + } + } } } } diff --git a/app/src/main/java/de/bollwerk/app/ui/MainViewModel.kt b/app/src/main/java/de/bollwerk/app/ui/MainViewModel.kt index 323aa4d..737a3ff 100644 --- a/app/src/main/java/de/bollwerk/app/ui/MainViewModel.kt +++ b/app/src/main/java/de/bollwerk/app/ui/MainViewModel.kt @@ -1,11 +1,9 @@ package de.bollwerk.app.ui -import android.content.Context import android.util.Log import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import dagger.hilt.android.lifecycle.HiltViewModel -import dagger.hilt.android.qualifiers.ApplicationContext import de.bollwerk.app.data.sync.WebSocketClient import de.bollwerk.app.data.sync.WebSocketEvent import de.bollwerk.app.domain.AuthEventBus @@ -13,7 +11,7 @@ import de.bollwerk.app.domain.model.SettingsKey.StringKey import de.bollwerk.app.domain.repository.ImportExportRepository import de.bollwerk.app.domain.repository.SettingsRepository import de.bollwerk.app.domain.repository.SyncService -import de.bollwerk.app.service.MessagingService +import de.bollwerk.app.notification.NotificationHelper import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.SharedFlow import kotlinx.coroutines.flow.asSharedFlow @@ -28,7 +26,7 @@ internal class MainViewModel @Inject constructor( private val webSocketClient: WebSocketClient, private val settingsRepository: SettingsRepository, private val importExportRepository: ImportExportRepository, - @ApplicationContext private val context: Context + private val notificationHelper: NotificationHelper ) : ViewModel() { private val _navigateToSettings = MutableSharedFlow(extraBufferCapacity = 1) @@ -41,6 +39,10 @@ internal class MainViewModel @Inject constructor( observeWebSocketEvents() } + fun setMessagingAreaVisible(isVisible: Boolean) { + notificationHelper.setMessagingAreaVisible(isVisible) + } + /** Beim App-Start: wenn Token + Server-URL vorhanden → WebSocket verbinden */ private fun connectOnStartup() { viewModelScope.launch { @@ -49,7 +51,6 @@ internal class MainViewModel @Inject constructor( if (token.isNotBlank() && serverUrl.isNotBlank()) { Log.i(TAG, "App-Start: Token vorhanden – verbinde WebSocket") webSocketClient.connect(serverUrl, token) - MessagingService.start(context) } else { Log.d(TAG, "App-Start: Kein Token – kein WebSocket") } @@ -62,7 +63,6 @@ internal class MainViewModel @Inject constructor( authEventBus.loginSuccess.collect { (serverUrl, token) -> Log.i(TAG, "Login erfolgreich – verbinde WebSocket") webSocketClient.connect(serverUrl, token) - MessagingService.start(context) } } } @@ -74,7 +74,6 @@ internal class MainViewModel @Inject constructor( Log.w(TAG, "Session abgelaufen – Forced-Logout") syncService.logout() webSocketClient.disconnect() - MessagingService.stop(context) _navigateToSettings.emit(Unit) } } diff --git a/app/src/main/java/de/bollwerk/app/ui/settings/SettingsViewModel.kt b/app/src/main/java/de/bollwerk/app/ui/settings/SettingsViewModel.kt index 7d4d7b9..5e52629 100644 --- a/app/src/main/java/de/bollwerk/app/ui/settings/SettingsViewModel.kt +++ b/app/src/main/java/de/bollwerk/app/ui/settings/SettingsViewModel.kt @@ -21,7 +21,6 @@ import de.bollwerk.app.domain.model.toJson import de.bollwerk.app.domain.repository.ImportExportRepository import de.bollwerk.app.domain.repository.SettingsRepository import de.bollwerk.app.domain.repository.SyncService -import de.bollwerk.app.service.MessagingService import kotlinx.coroutines.Job import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow @@ -231,7 +230,6 @@ internal class SettingsViewModel @Inject constructor( viewModelScope.launch { syncService.logout() webSocketClient.disconnect() - MessagingService.stop(context) _uiState.update { it.copy( isLoggedIn = false, diff --git a/knowledge-conduit/kc_generaltest.py b/knowledge-conduit/kc_generaltest.py new file mode 100644 index 0000000..7cf56b8 --- /dev/null +++ b/knowledge-conduit/kc_generaltest.py @@ -0,0 +1,394 @@ +#!/usr/bin/env python3 +"""Knowledge-Conduit Generaltest Harness. + +Ziele: +- kleine/grosse Aenderungen +- mit/ohne Noise +- 1 oder 4 Capabilities gleichzeitig +- zusaetzliche Mutationstypen fuer realistische Drifts +- deterministischer Seed je Szenario +- Gold-Expected-Files und Metriken +""" + +from __future__ import annotations + +import argparse +import hashlib +import itertools +import json +import random +import shutil +import time +from dataclasses import asdict, dataclass +from pathlib import Path +from typing import Dict, List, Tuple + + +SCRIPT_DIR = Path(__file__).resolve().parent +FIXTURES_DIR = SCRIPT_DIR / "fixtures" +DEFAULT_OUT_DIR = SCRIPT_DIR / "artifacts" + +REPOS = ["repo-alpha", "repo-beta"] +SKILLS = [ + Path(".github/skills/kc-dataset-cleaner/SKILL.md"), + Path(".github/skills/kc-api-smoke/SKILL.md"), + Path(".github/skills/kc-release-notes/SKILL.md"), +] + +BASE_CAPABILITIES = [ + "input-validation", + "response-schema-check", + "retry-backoff", + "latency-budget", + "auth-guard", + "idempotency-check", +] + +IMPROVED_CAPABILITIES = [ + "strict-input-validation", + "response-contract-check", + "retry-jitter-backoff", + "p95-latency-budget", + "token-scope-guard", + "idempotency-key-replay-check", +] + +TRIGGER_SYNONYMS = { + "Bereinige den Datensatz": "Bereinige den Input-Bestand", + "Normalisiere diese CSV": "Standardisiere diese CSV", + "Finde Dubletten und fehlende Felder": "Erkenne Duplikate und Null-Felder", + "Starte API-Smoke-Test": "Starte API-Basischeck", + "Pruefe Health- und Auth-Endpunkte": "Validiere Health- und Auth-Routen", + "Validiere Basis-Responses": "Pruefe Grundantworten", + "Schreibe Release Notes": "Erstelle Release Notes", +} + + +@dataclass +class Scenario: + scenario_id: str + repo: str + skill: str + size: str + noise: bool + capabilities_to_change: int + change_kind: str + seed: int + + +def scenario_seed(material: str) -> int: + digest = hashlib.sha256(material.encode("utf-8")).hexdigest() + return int(digest[:8], 16) + + +def ensure_capability_section(text: str) -> str: + if "## Capabilities" in text: + return text + + marker = "## Checkliste" + section = ["## Capabilities", ""] + for cap in BASE_CAPABILITIES: + section.append(f"- {cap}") + section.append("") + insertion = "\n".join(section) + + if marker in text: + return text.replace(marker, f"{insertion}\n{marker}", 1) + return text.rstrip() + "\n\n" + insertion + "\n" + + +def mutate_capabilities(text: str, count: int) -> Tuple[str, int]: + text = ensure_capability_section(text) + lines = text.splitlines() + + cap_start = None + for i, line in enumerate(lines): + if line.strip() == "## Capabilities": + cap_start = i + 1 + break + if cap_start is None: + return text, 0 + + bullets: List[int] = [] + for i in range(cap_start, len(lines)): + striped = lines[i].strip() + if striped.startswith("## "): + break + if striped.startswith("- "): + bullets.append(i) + + replace_count = min(count, len(bullets), len(IMPROVED_CAPABILITIES)) + for idx in range(replace_count): + lines[bullets[idx]] = f"- {IMPROVED_CAPABILITIES[idx]}" + + return "\n".join(lines) + "\n", replace_count + + +def add_content_or_structure_hint(text: str, change_kind: str) -> str: + if change_kind == "structure-tune": + return text.replace("## Zweck", "## Zweck\n\nHinweis: Struktur-Tune aktiv.", 1) + return text.replace("## Zweck", "## Zweck\n\nHinweis: Content-Tune aktiv.", 1) + + +def reorder_sections(text: str) -> str: + if "## Trigger-Phrasen" not in text or "## Checkliste" not in text: + return text + start_trigger = text.index("## Trigger-Phrasen") + start_check = text.index("## Checkliste") + if start_check < start_trigger: + return text + head = text[:start_trigger] + trigger_part = text[start_trigger:start_check] + rest = text[start_check:] + if "\n## " in rest: + check_part = rest.split("\n## ", 1)[0] + tail = "\n## " + rest.split("\n## ", 1)[1] + else: + check_part = rest + tail = "" + return head + check_part.rstrip() + "\n\n" + trigger_part.strip() + "\n" + tail + + +def apply_trigger_synonyms(text: str) -> str: + for old, new in TRIGGER_SYNONYMS.items(): + text = text.replace(old, new) + return text + + +def inject_noise(repo_dir: Path, scenario_id: str, seed: int) -> None: + rnd = random.Random(seed) + noise_dir = repo_dir / "noise" / scenario_id + noise_dir.mkdir(parents=True, exist_ok=True) + (noise_dir / "notes.txt").write_text("noise payload\nignore me\n", encoding="utf-8") + payload = { + "scenario": scenario_id, + "kind": "noise", + "seed": seed, + "checksum_hint": rnd.randint(1000, 9999), + } + (noise_dir / "payload.json").write_text(json.dumps(payload, indent=2) + "\n", encoding="utf-8") + + +def build_scenarios() -> List[Scenario]: + sizes = ["small", "large"] + noises = [False, True] + capability_counts = [1, 4] + change_kinds = [ + "content-tune", + "structure-tune", + "rename-skill", + "reorder-sections", + "trigger-synonyms", + ] + + scenarios: List[Scenario] = [] + index = 1 + for repo, skill, size, noise, cap_count, kind in itertools.product( + REPOS, SKILLS, sizes, noises, capability_counts, change_kinds + ): + material = "|".join([repo, str(skill), size, str(noise), str(cap_count), kind]) + scenarios.append( + Scenario( + scenario_id=f"KC-{index:03d}", + repo=repo, + skill=str(skill).replace("\\", "/"), + size=size, + noise=noise, + capabilities_to_change=cap_count, + change_kind=kind, + seed=scenario_seed(material), + ) + ) + index += 1 + return scenarios + + +def error_signature(exc: Exception) -> str: + text = f"{type(exc).__name__}: {exc}" + return text[:160] + + +def apply_scenarios( + scenarios: List[Scenario], out_dir: Path, fixtures_dir: Path +) -> Dict[str, object]: + runs_dir = out_dir / "runs" + expected_dir = out_dir / "expected" + if runs_dir.exists(): + shutil.rmtree(runs_dir) + if expected_dir.exists(): + shutil.rmtree(expected_dir) + runs_dir.mkdir(parents=True, exist_ok=True) + expected_dir.mkdir(parents=True, exist_ok=True) + + results: List[Dict[str, object]] = [] + errors: List[Dict[str, str]] = [] + runtime_by_class: Dict[str, List[float]] = {} + + for scenario in scenarios: + start = time.perf_counter() + class_key = "|".join( + [ + scenario.size, + "noise" if scenario.noise else "clean", + f"cap{scenario.capabilities_to_change}", + scenario.change_kind, + ] + ) + + try: + source_repo = fixtures_dir / scenario.repo + if not source_repo.exists(): + raise FileNotFoundError(f"fixture repo missing: {source_repo}") + + scenario_root = runs_dir / scenario.scenario_id + target_repo = scenario_root / scenario.repo + shutil.copytree(source_repo, target_repo) + + skill_path = target_repo / Path(scenario.skill) + if not skill_path.exists(): + raise FileNotFoundError(f"skill missing: {skill_path}") + + original = skill_path.read_text(encoding="utf-8") + changed, replaced_count = mutate_capabilities(original, scenario.capabilities_to_change) + + if scenario.change_kind in ("content-tune", "structure-tune"): + changed = add_content_or_structure_hint(changed, scenario.change_kind) + elif scenario.change_kind == "reorder-sections": + changed = reorder_sections(changed) + elif scenario.change_kind == "trigger-synonyms": + changed = apply_trigger_synonyms(changed) + + final_skill_path = skill_path + if scenario.change_kind == "rename-skill": + final_skill_path = skill_path.with_name("SKILL_RENAMED.md") + skill_path.unlink() + + if scenario.size == "large": + changed += ( + "\n## Erweiterte Testnotiz\n" + "- Fuehre den gleichen Vorgang mit 3 Umgebungen aus.\n" + "- Vergleiche Diff, Laufzeit und Fehlersignaturen.\n" + ) + + final_skill_path.write_text(changed, encoding="utf-8") + + if scenario.noise: + inject_noise(target_repo, scenario.scenario_id, scenario.seed) + + runtime_ms = round((time.perf_counter() - start) * 1000, 3) + runtime_by_class.setdefault(class_key, []).append(runtime_ms) + + expected_payload = { + "scenario_id": scenario.scenario_id, + "seed": scenario.seed, + "repo": scenario.repo, + "skill_input": scenario.skill, + "skill_output": str(final_skill_path.relative_to(target_repo)).replace("\\", "/"), + "change_kind": scenario.change_kind, + "size": scenario.size, + "noise": scenario.noise, + "expected_capability_replacements": replaced_count, + "capabilities_requested": scenario.capabilities_to_change, + "status": "ok", + } + (expected_dir / f"{scenario.scenario_id}.expected.json").write_text( + json.dumps(expected_payload, indent=2) + "\n", encoding="utf-8" + ) + + results.append( + { + "scenario_id": scenario.scenario_id, + "runtime_ms": runtime_ms, + "class_key": class_key, + "status": "ok", + } + ) + except Exception as exc: + runtime_ms = round((time.perf_counter() - start) * 1000, 3) + runtime_by_class.setdefault(class_key, []).append(runtime_ms) + signature = error_signature(exc) + errors.append({"scenario_id": scenario.scenario_id, "signature": signature}) + results.append( + { + "scenario_id": scenario.scenario_id, + "runtime_ms": runtime_ms, + "class_key": class_key, + "status": "error", + "error_signature": signature, + } + ) + + class_metrics = {} + for key, values in runtime_by_class.items(): + ordered = sorted(values) + p95_idx = max(0, min(len(ordered) - 1, int((len(ordered) - 1) * 0.95))) + class_metrics[key] = { + "count": len(ordered), + "avg_runtime_ms": round(sum(ordered) / len(ordered), 3), + "p95_runtime_ms": ordered[p95_idx], + } + + signature_counts: Dict[str, int] = {} + for item in errors: + signature_counts[item["signature"]] = signature_counts.get(item["signature"], 0) + 1 + + metrics = { + "total_scenarios": len(scenarios), + "ok": len([r for r in results if r["status"] == "ok"]), + "error": len([r for r in results if r["status"] == "error"]), + "class_metrics": class_metrics, + "error_signatures": signature_counts, + } + (out_dir / "metrics.json").write_text(json.dumps(metrics, indent=2) + "\n", encoding="utf-8") + (out_dir / "scenario-results.json").write_text(json.dumps(results, indent=2) + "\n", encoding="utf-8") + + return metrics + + +def main() -> int: + parser = argparse.ArgumentParser(description="Knowledge-Conduit Generaltest") + parser.add_argument( + "--mode", + choices=["plan", "apply"], + default="plan", + help="plan: nur Szenarien schreiben; apply: Szenarien materialisieren", + ) + parser.add_argument( + "--fixtures", + default=str(FIXTURES_DIR), + help="Verzeichnis mit repo-alpha und repo-beta Fixtures", + ) + parser.add_argument( + "--out", + default=str(DEFAULT_OUT_DIR), + help="Ausgabeverzeichnis fuer Plan und Artefakte", + ) + args = parser.parse_args() + + fixtures_dir = Path(args.fixtures) + + out_dir = Path(args.out) + out_dir.mkdir(parents=True, exist_ok=True) + + scenarios = build_scenarios() + plan_path = out_dir / "scenario-plan.json" + plan_path.write_text(json.dumps([asdict(s) for s in scenarios], indent=2) + "\n", encoding="utf-8") + + metrics = None + if args.mode == "apply": + metrics = apply_scenarios(scenarios, out_dir, fixtures_dir) + + summary = { + "scenario_count": len(scenarios), + "mode": args.mode, + "fixtures": str(fixtures_dir), + "output": str(out_dir), + "plan": str(plan_path), + "metrics": metrics, + } + print(json.dumps(summary, indent=2)) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main())