chore: update publish tooling and Android messaging integration
This commit is contained in:
parent
87a8deb83c
commit
e3bcddac70
13 changed files with 696 additions and 176 deletions
9
.github/copilot-instructions.md
vendored
9
.github/copilot-instructions.md
vendored
|
|
@ -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.
|
||||||
|
|
|
||||||
9
.github/skills/publish/SKILL.md
vendored
9
.github/skills/publish/SKILL.md
vendored
|
|
@ -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 |
|
||||||
|
|
|
||||||
54
.github/skills/publish/publish-apk.ps1
vendored
54
.github/skills/publish/publish-apk.ps1
vendored
|
|
@ -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"
|
||||||
|
|
|
||||||
112
.github/skills/publish/publish-apk.py
vendored
112
.github/skills/publish/publish-apk.py
vendored
|
|
@ -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)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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")
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -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()
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
394
knowledge-conduit/kc_generaltest.py
Normal file
394
knowledge-conduit/kc_generaltest.py
Normal 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())
|
||||||
Loading…
Reference in a new issue