chore: update publish tooling and Android messaging integration

This commit is contained in:
Jens Reinemann 2026-05-18 15:13:49 +02:00
parent 87a8deb83c
commit e3bcddac70
13 changed files with 696 additions and 176 deletions

View file

@ -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. - 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` - Korrekt: `/opt/bollwerk/`, `bollwerk-server`, `bollwerk.online`
- Falsch: `/opt/krisenvorrat/`, `krisenvorrat` - 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.

View file

@ -53,7 +53,8 @@ scp → /opt/bollwerk/data/ └── GET /static/* → Dateie
In `app/build.gradle.kts`: In `app/build.gradle.kts`:
- `versionCode` um 1 erhöhen - `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 ### 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): Das Skript `publish-apk.py` in diesem Skill-Ordner automatisiert den **gesamten** Release-Workflow (Version bumpen, bauen, hochladen, API-Call, verifizieren, committen):
```powershell ```powershell
# Alles automatisch (versionCode +1, versionName bleibt): # Alles automatisch (versionCode +1, versionName wird auf x.y.<versionCode> gesetzt):
python ".github/skills/publish/publish-apk.py" python ".github/skills/publish/publish-apk.py"
# Mit neuem versionName: # 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): # Nur hochladen (APK bereits gebaut, kein Push):
python ".github/skills/publish/publish-apk.py" --skip-build --skip-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 | | 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 | | `--version-code` | nein | aktuell + 1 | Expliziter versionCode; sonst automatisch +1 |
| `--apk-path` | nein | debug-APK | Pfad zur APK-Datei | | `--apk-path` | nein | debug-APK | Pfad zur APK-Datei |
| `--skip-build` | nein | false | Gradle-Build überspringen | | `--skip-build` | nein | false | Gradle-Build überspringen |

View file

@ -15,7 +15,7 @@
- Fehler nach Upload (API) APK liegt schon oben; kein Rollback, Recovery-Hinweis - Fehler nach Upload (API) APK liegt schon oben; kein Rollback, Recovery-Hinweis
- Fehler bei Verify/Git Deployment war erfolgreich; nur Warnung, kein Abbruch - Fehler bei Verify/Git Deployment war erfolgreich; nur Warnung, kein Abbruch
.PARAMETER VersionName .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 .PARAMETER VersionCode
Expliziter versionCode. Wenn weggelassen, wird der aktuelle automatisch um 1 erhöht. Expliziter versionCode. Wenn weggelassen, wird der aktuelle automatisch um 1 erhöht.
.PARAMETER ApkPath .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-Skip { param([string]$Text); Write-Host "[--] $Text" -ForegroundColor DarkGray }
function Write-Fail { param([string]$Text); Write-Host "[!!] $Text" -ForegroundColor Red } 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) # Pre-Checks (nichts wird verändert)
# ───────────────────────────────────────────────────────────────────────────── # ─────────────────────────────────────────────────────────────────────────────
@ -105,7 +119,26 @@ if (-not $nameMatch.Success) {
$currentCode = [int]$codeMatch.Groups[1].Value $currentCode = [int]$codeMatch.Groups[1].Value
$currentName = $nameMatch.Groups[1].Value $currentName = $nameMatch.Groups[1].Value
$newCode = if ($VersionCode -gt 0) { $VersionCode } else { $currentCode + 1 } $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) { if ($newCode -le $currentCode) {
Write-Fail "Neuer versionCode ($newCode) muss groesser als aktueller ($currentCode) sein." 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-OK "Pre-Checks bestanden (Token, Gradle-Parsing, SSH-Agent)"
Write-Host "" Write-Host ""
Write-Host "=== Publish APK v$newName (build $newCode) ===" -ForegroundColor Cyan Write-Host "=== Publish APK v$newName ===" -ForegroundColor Cyan
Write-Host " $currentName ($currentCode) → $newName ($newCode)" -ForegroundColor Gray Write-Host " $currentName [$currentCode] → $newName [$newCode]" -ForegroundColor Gray
# ───────────────────────────────────────────────────────────────────────────── # ─────────────────────────────────────────────────────────────────────────────
# Schritt 0: build.gradle.kts patchen # Schritt 0: build.gradle.kts patchen
# ───────────────────────────────────────────────────────────────────────────── # ─────────────────────────────────────────────────────────────────────────────
$patchedContent = $originalContent -replace "versionCode\s*=\s*$currentCode\b", "versionCode = $newCode" $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 { try {
[System.IO.File]::WriteAllText((Resolve-Path $BuildGradle).Path, $patchedContent) [System.IO.File]::WriteAllText((Resolve-Path $BuildGradle).Path, $patchedContent)
@ -224,7 +255,7 @@ for ($attempt = 1; $attempt -le $maxRetries; $attempt++) {
} }
if ($apiSuccess) { if ($apiSuccess) {
Write-OK "Version gesetzt: $newName (build $newCode)" Write-OK "Version gesetzt: $newName"
} else { } else {
# Deployment war teilweise erfolgreich → trotzdem committen, aber warnen # Deployment war teilweise erfolgreich → trotzdem committen, aber warnen
Write-Host "" Write-Host ""
@ -264,10 +295,10 @@ if (-not $apiSuccess -or $SkipVerify) {
Write-Step "[Git] Version-Bump committen..." Write-Step "[Git] Version-Bump committen..."
try { try {
git add $BuildGradle git add $BuildGradle
git commit -m "chore: release v$newName ($newCode)" git commit -m "chore: release v$newName"
} catch { } catch {
Write-Host "[??] git commit fehlgeschlagen: $_" -ForegroundColor DarkYellow 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) { if (-not $SkipPush) {
@ -287,6 +318,7 @@ if (-not $SkipPush) {
# ───────────────────────────────────────────────────────────────────────────── # ─────────────────────────────────────────────────────────────────────────────
Write-Host "" Write-Host ""
Write-Host "=== Publish abgeschlossen ===" -ForegroundColor Cyan 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 " Homepage: $ServerUrl/"
Write-Host " API : $ServerUrl/api/version" Write-Host " API : $ServerUrl/api/version"

View file

@ -17,7 +17,7 @@ Rollback-Strategie:
Verwendung: Verwendung:
python publish-apk.py 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 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: with urllib.request.urlopen(req, timeout=timeout) as resp:
return resp.status 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 # Argument-Parsing
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
def parse_args(): def parse_args():
parser = argparse.ArgumentParser(description="Bollwerk APK Publish-Workflow") 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("--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("--apk-path", default="app/build/outputs/apk/debug/app-debug.apk")
parser.add_argument("--skip-build", action="store_true") parser.add_argument("--skip-build", action="store_true")
@ -129,13 +189,6 @@ def parse_args():
def main(): def main():
args = parse_args() 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 --- # --- Pre-Check: build.gradle.kts lesen ---
if not os.path.exists(BUILD_GRADLE): if not os.path.exists(BUILD_GRADLE):
fail(f"Datei nicht gefunden: {BUILD_GRADLE}") fail(f"Datei nicht gefunden: {BUILD_GRADLE}")
@ -156,7 +209,22 @@ def main():
current_code = int(code_match.group(1)) current_code = int(code_match.group(1))
current_name = name_match.group(1) current_name = name_match.group(1)
new_code = args.version_code if args.version_code > 0 else current_code + 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: if new_code <= current_code:
fail(f"Neuer versionCode ({new_code}) muss groesser als aktueller ({current_code}) sein.") 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") fail("SSH-Agent hat keinen Key. Bitte ausfuehren: ssh-add C:\\Users\\JensR\\.ssh\\id_ed25519")
sys.exit(1) 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)") 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"\n{CYAN}=== Publish APK v{new_name} ==={RESET}", flush=True)
print(f"{GRAY} {current_name} ({current_code}) -> {new_name} ({new_code}){RESET}", flush=True) print(f"{GRAY} {current_name} [{current_code}] -> {new_name} [{new_code}]{RESET}", flush=True)
# ------------------------------------------------------------------------- # -------------------------------------------------------------------------
# Schritt 0: build.gradle.kts patchen # Schritt 0: build.gradle.kts patchen
@ -181,7 +259,6 @@ def main():
f'versionCode = {new_code}', f'versionCode = {new_code}',
original_content, original_content,
) )
if args.version_name:
patched = re.sub( patched = re.sub(
r'versionName\s*=\s*"[^"]+"', r'versionName\s*=\s*"[^"]+"',
f'versionName = "{new_name}"', f'versionName = "{new_name}"',
@ -270,7 +347,7 @@ def main():
info(f" -d '{json.dumps(api_body)}'") info(f" -d '{json.dumps(api_body)}'")
if api_success: if api_success:
ok(f"Version gesetzt: {new_name} (build {new_code})") ok(f"Version gesetzt: {new_name}")
else: else:
warn("VPS-Version nicht aktualisiert. build.gradle.kts wird trotzdem committet.") warn("VPS-Version nicht aktualisiert. build.gradle.kts wird trotzdem committet.")
@ -304,10 +381,10 @@ def main():
step("[Git] Version-Bump committen...") step("[Git] Version-Bump committen...")
try: try:
run(f'git add "{BUILD_GRADLE}"') 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: except Exception as e:
warn(f"git commit fehlgeschlagen: {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: if not args.skip_push:
try: try:
@ -323,7 +400,8 @@ def main():
# Zusammenfassung # Zusammenfassung
# ------------------------------------------------------------------------- # -------------------------------------------------------------------------
print(f"\n{CYAN}=== Publish abgeschlossen ==={RESET}", flush=True) 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" Homepage: {SERVER_URL}/", flush=True)
print(f" API : {SERVER_URL}/api/version", flush=True) print(f" API : {SERVER_URL}/api/version", flush=True)

View file

@ -16,7 +16,7 @@ android {
minSdk = 26 minSdk = 26
targetSdk = 35 targetSdk = 35
versionCode = 13 versionCode = 13
versionName = "1.7" versionName = "1.7.13"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
buildConfigField("boolean", "FEATURE_CAMERA_ENABLED", "false") buildConfigField("boolean", "FEATURE_CAMERA_ENABLED", "false")

View file

@ -4,8 +4,6 @@
<uses-permission android:name="android.permission.INTERNET" /> <uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" /> <uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" /> <uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
<application <application
android:name=".BollwerkApp" android:name=".BollwerkApp"
@ -37,10 +35,6 @@
android:resource="@xml/file_paths" /> android:resource="@xml/file_paths" />
</provider> </provider>
<service
android:name=".service.MessagingService"
android:exported="false"
android:foregroundServiceType="dataSync" />
</application> </application>
</manifest> </manifest>

View file

@ -32,9 +32,12 @@ class MainActivity : ComponentActivity() {
@Inject internal lateinit var seedDatabaseUseCase: SeedDatabaseUseCase @Inject internal lateinit var seedDatabaseUseCase: SeedDatabaseUseCase
@Inject internal lateinit var ensureKeyPairUseCase: EnsureKeyPairUseCase @Inject internal lateinit var ensureKeyPairUseCase: EnsureKeyPairUseCase
@Inject internal lateinit var notificationHelper: NotificationHelper
private val _chatNavigationEvents = MutableSharedFlow<ChatDeepLink>(extraBufferCapacity = 1) private val _notificationNavigationEvents =
val chatNavigationEvents: SharedFlow<ChatDeepLink> = _chatNavigationEvents.asSharedFlow() MutableSharedFlow<NotificationNavigationEvent>(extraBufferCapacity = 1)
private val notificationNavigationEvents: SharedFlow<NotificationNavigationEvent> =
_notificationNavigationEvents.asSharedFlow()
private val notificationPermissionLauncher = registerForActivityResult( private val notificationPermissionLauncher = registerForActivityResult(
ActivityResultContracts.RequestPermission() ActivityResultContracts.RequestPermission()
@ -46,10 +49,10 @@ class MainActivity : ComponentActivity() {
lifecycleScope.launch { ensureKeyPairUseCase.execute() } lifecycleScope.launch { ensureKeyPairUseCase.execute() }
enableEdgeToEdge() enableEdgeToEdge()
requestNotificationPermissionIfNeeded() requestNotificationPermissionIfNeeded()
handleChatDeepLink(intent) handleNotificationDeepLink(intent)
setContent { setContent {
BollwerkTheme { BollwerkTheme {
MainScreen(chatNavigationEvents = chatNavigationEvents) MainScreen(notificationNavigationEvents = notificationNavigationEvents)
} }
} }
} }
@ -66,22 +69,34 @@ class MainActivity : ComponentActivity() {
override fun onNewIntent(intent: Intent) { override fun onNewIntent(intent: Intent) {
super.onNewIntent(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 recipientId = intent?.getStringExtra(NotificationHelper.EXTRA_RECIPIENT_ID)
val recipientUsername = intent?.getStringExtra(NotificationHelper.EXTRA_RECIPIENT_USERNAME) val recipientUsername = intent?.getStringExtra(NotificationHelper.EXTRA_RECIPIENT_USERNAME)
if (recipientId != null && recipientUsername != null) { 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 // Clear extras to prevent re-navigation on config change
intent?.removeExtra(NotificationHelper.EXTRA_RECIPIENT_ID) intent?.removeExtra(NotificationHelper.EXTRA_RECIPIENT_ID)
intent?.removeExtra(NotificationHelper.EXTRA_RECIPIENT_USERNAME) 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) @Preview(showBackground = true)
@Composable @Composable

View file

@ -22,6 +22,10 @@ internal class NotificationHelper @Inject constructor(
@Volatile @Volatile
private var activeChatPartnerId: String? = null private var activeChatPartnerId: String? = null
@Volatile
private var isMessagingAreaVisible: Boolean = false
private val activeSenderNotificationIds = mutableSetOf<Int>()
private val activeSenderNamesById = mutableMapOf<Int, String>()
/// Erstellt den Notification Channel (ab API 26 erforderlich). /// Erstellt den Notification Channel (ab API 26 erforderlich).
fun createNotificationChannel() { fun createNotificationChannel() {
@ -50,24 +54,41 @@ internal class NotificationHelper @Inject constructor(
activeChatPartnerId = partnerId 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. /// Zeigt eine Benachrichtigung für eine eingehende Nachricht an.
/// Gibt false zurück, wenn die Notification unterdrückt wurde (Chat ist aktiv). /// Gibt false zurück, wenn die Notification unterdrückt wurde (Chat ist aktiv).
fun showNewMessageNotification( fun showNewMessageNotification(
senderId: String, senderId: String,
senderUsername: String senderUsername: String
): Boolean { ): 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 flags = Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_CLEAR_TOP
putExtra(EXTRA_RECIPIENT_ID, senderId) putExtra(EXTRA_RECIPIENT_ID, senderId)
putExtra(EXTRA_RECIPIENT_USERNAME, senderUsername) putExtra(EXTRA_RECIPIENT_USERNAME, senderUsername)
} }
val pendingIntent = PendingIntent.getActivity( val chatPendingIntent = PendingIntent.getActivity(
context, context,
senderId.hashCode(), 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 PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
) )
@ -77,23 +98,38 @@ internal class NotificationHelper @Inject constructor(
.setContentText("$senderUsername hat etwas geschrieben") .setContentText("$senderUsername hat etwas geschrieben")
.setPriority(NotificationCompat.PRIORITY_HIGH) .setPriority(NotificationCompat.PRIORITY_HIGH)
.setAutoCancel(true) .setAutoCancel(true)
.setContentIntent(pendingIntent) .setContentIntent(chatPendingIntent)
.setGroup(GROUP_KEY) .setGroup(GROUP_KEY)
.build() .build()
val senderNotificationId = senderId.hashCode()
val notificationManager = NotificationManagerCompat.from(context)
try {
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) val summaryNotification = NotificationCompat.Builder(context, CHANNEL_ID)
.setSmallIcon(R.drawable.ic_notification_message) .setSmallIcon(R.drawable.ic_notification_message)
.setContentTitle("Neue Nachrichten") .setContentTitle("Neue Nachrichten")
.setContentText("Du hast neue Nachrichten") .setContentText(summaryText)
.setGroup(GROUP_KEY) .setGroup(GROUP_KEY)
.setGroupSummary(true) .setGroupSummary(true)
.setAutoCancel(true) .setAutoCancel(true)
.setContentIntent(messagesPendingIntent)
.build() .build()
val notificationManager = NotificationManagerCompat.from(context) notificationManager.notify(senderNotificationId, notification)
try { if (shouldShowSummary) {
notificationManager.notify(senderId.hashCode(), notification)
notificationManager.notify(SUMMARY_NOTIFICATION_ID, summaryNotification) notificationManager.notify(SUMMARY_NOTIFICATION_ID, summaryNotification)
} else {
notificationManager.cancel(SUMMARY_NOTIFICATION_ID)
}
} catch (_: SecurityException) { } catch (_: SecurityException) {
// Permission not granted ignore silently // Permission not granted ignore silently
return false return false
@ -104,7 +140,41 @@ internal class NotificationHelper @Inject constructor(
/// Entfernt Benachrichtigungen für einen bestimmten Absender (wenn der Chat geöffnet wird). /// Entfernt Benachrichtigungen für einen bestimmten Absender (wenn der Chat geöffnet wird).
fun cancelNotificationForSender(senderId: String) { fun cancelNotificationForSender(senderId: String) {
val notificationManager = NotificationManagerCompat.from(context) 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>): 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 { companion object {
@ -115,5 +185,6 @@ internal class NotificationHelper @Inject constructor(
const val EXTRA_RECIPIENT_ID = "notification_recipient_id" const val EXTRA_RECIPIENT_ID = "notification_recipient_id"
const val EXTRA_RECIPIENT_USERNAME = "notification_recipient_username" const val EXTRA_RECIPIENT_USERNAME = "notification_recipient_username"
const val EXTRA_OPEN_MESSAGES = "notification_open_messages"
} }
} }

View file

@ -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)
}
}
}

View file

@ -35,7 +35,7 @@ import androidx.navigation.NavDestination.Companion.hasRoute
import androidx.navigation.NavGraph.Companion.findStartDestination import androidx.navigation.NavGraph.Companion.findStartDestination
import androidx.navigation.compose.currentBackStackEntryAsState import androidx.navigation.compose.currentBackStackEntryAsState
import androidx.navigation.compose.rememberNavController 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.InventoryPickerSheet
import de.bollwerk.app.ui.inventory.InventoryPickerViewModel import de.bollwerk.app.ui.inventory.InventoryPickerViewModel
import de.bollwerk.app.ui.navigation.BollwerkNavGraph import de.bollwerk.app.ui.navigation.BollwerkNavGraph
@ -48,7 +48,7 @@ import kotlinx.coroutines.flow.SharedFlow
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
internal fun MainScreen(chatNavigationEvents: SharedFlow<ChatDeepLink>) { internal fun MainScreen(notificationNavigationEvents: SharedFlow<NotificationNavigationEvent>) {
val navController = rememberNavController() val navController = rememberNavController()
val navBackStackEntry by navController.currentBackStackEntryAsState() val navBackStackEntry by navController.currentBackStackEntryAsState()
val currentDestination = navBackStackEntry?.destination val currentDestination = navBackStackEntry?.destination
@ -67,17 +67,34 @@ internal fun MainScreen(chatNavigationEvents: SharedFlow<ChatDeepLink>) {
} }
} }
val isInMessagingArea =
currentDestination?.hasRoute(Screen.UserList::class) == true ||
currentDestination?.hasRoute(Screen.Chat::class) == true
LaunchedEffect(isInMessagingArea) {
mainViewModel.setMessagingAreaVisible(isInMessagingArea)
}
LaunchedEffect(Unit) { LaunchedEffect(Unit) {
chatNavigationEvents.collect { deepLink -> notificationNavigationEvents.collect { event ->
when (event) {
is NotificationNavigationEvent.OpenChat -> {
navController.navigate( navController.navigate(
Screen.Chat( Screen.Chat(
recipientId = deepLink.recipientId, recipientId = event.recipientId,
recipientUsername = deepLink.recipientUsername recipientUsername = event.recipientUsername
) )
) { ) {
launchSingleTop = true launchSingleTop = true
} }
} }
NotificationNavigationEvent.OpenMessages -> {
navController.navigate(Screen.UserList) {
launchSingleTop = true
}
}
}
}
} }
val inventoryPickerViewModel: InventoryPickerViewModel = hiltViewModel() val inventoryPickerViewModel: InventoryPickerViewModel = hiltViewModel()

View file

@ -1,11 +1,9 @@
package de.bollwerk.app.ui package de.bollwerk.app.ui
import android.content.Context
import android.util.Log import android.util.Log
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel 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.WebSocketClient
import de.bollwerk.app.data.sync.WebSocketEvent import de.bollwerk.app.data.sync.WebSocketEvent
import de.bollwerk.app.domain.AuthEventBus 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.ImportExportRepository
import de.bollwerk.app.domain.repository.SettingsRepository import de.bollwerk.app.domain.repository.SettingsRepository
import de.bollwerk.app.domain.repository.SyncService 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.MutableSharedFlow
import kotlinx.coroutines.flow.SharedFlow import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.flow.asSharedFlow
@ -28,7 +26,7 @@ internal class MainViewModel @Inject constructor(
private val webSocketClient: WebSocketClient, private val webSocketClient: WebSocketClient,
private val settingsRepository: SettingsRepository, private val settingsRepository: SettingsRepository,
private val importExportRepository: ImportExportRepository, private val importExportRepository: ImportExportRepository,
@ApplicationContext private val context: Context private val notificationHelper: NotificationHelper
) : ViewModel() { ) : ViewModel() {
private val _navigateToSettings = MutableSharedFlow<Unit>(extraBufferCapacity = 1) private val _navigateToSettings = MutableSharedFlow<Unit>(extraBufferCapacity = 1)
@ -41,6 +39,10 @@ internal class MainViewModel @Inject constructor(
observeWebSocketEvents() observeWebSocketEvents()
} }
fun setMessagingAreaVisible(isVisible: Boolean) {
notificationHelper.setMessagingAreaVisible(isVisible)
}
/** Beim App-Start: wenn Token + Server-URL vorhanden → WebSocket verbinden */ /** Beim App-Start: wenn Token + Server-URL vorhanden → WebSocket verbinden */
private fun connectOnStartup() { private fun connectOnStartup() {
viewModelScope.launch { viewModelScope.launch {
@ -49,7 +51,6 @@ internal class MainViewModel @Inject constructor(
if (token.isNotBlank() && serverUrl.isNotBlank()) { if (token.isNotBlank() && serverUrl.isNotBlank()) {
Log.i(TAG, "App-Start: Token vorhanden verbinde WebSocket") Log.i(TAG, "App-Start: Token vorhanden verbinde WebSocket")
webSocketClient.connect(serverUrl, token) webSocketClient.connect(serverUrl, token)
MessagingService.start(context)
} else { } else {
Log.d(TAG, "App-Start: Kein Token kein WebSocket") Log.d(TAG, "App-Start: Kein Token kein WebSocket")
} }
@ -62,7 +63,6 @@ internal class MainViewModel @Inject constructor(
authEventBus.loginSuccess.collect { (serverUrl, token) -> authEventBus.loginSuccess.collect { (serverUrl, token) ->
Log.i(TAG, "Login erfolgreich verbinde WebSocket") Log.i(TAG, "Login erfolgreich verbinde WebSocket")
webSocketClient.connect(serverUrl, token) webSocketClient.connect(serverUrl, token)
MessagingService.start(context)
} }
} }
} }
@ -74,7 +74,6 @@ internal class MainViewModel @Inject constructor(
Log.w(TAG, "Session abgelaufen Forced-Logout") Log.w(TAG, "Session abgelaufen Forced-Logout")
syncService.logout() syncService.logout()
webSocketClient.disconnect() webSocketClient.disconnect()
MessagingService.stop(context)
_navigateToSettings.emit(Unit) _navigateToSettings.emit(Unit)
} }
} }

View file

@ -21,7 +21,6 @@ import de.bollwerk.app.domain.model.toJson
import de.bollwerk.app.domain.repository.ImportExportRepository import de.bollwerk.app.domain.repository.ImportExportRepository
import de.bollwerk.app.domain.repository.SettingsRepository import de.bollwerk.app.domain.repository.SettingsRepository
import de.bollwerk.app.domain.repository.SyncService import de.bollwerk.app.domain.repository.SyncService
import de.bollwerk.app.service.MessagingService
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
@ -231,7 +230,6 @@ internal class SettingsViewModel @Inject constructor(
viewModelScope.launch { viewModelScope.launch {
syncService.logout() syncService.logout()
webSocketClient.disconnect() webSocketClient.disconnect()
MessagingService.stop(context)
_uiState.update { _uiState.update {
it.copy( it.copy(
isLoggedIn = false, isLoggedIn = false,

View file

@ -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())