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.
- Korrekt: `/opt/bollwerk/`, `bollwerk-server`, `bollwerk.online`
- Falsch: `/opt/krisenvorrat/`, `krisenvorrat`
---
## Versionierung (PFLICHT)
- `versionName` muss immer strikt dem Muster `x.y.z` folgen (z. B. `1.7.13`).
- Das dritte Segment (`z`) muss dem `versionCode` entsprechen.
- Formate wie `1.7 (build 13)` sind verboten.
- Bei Release-Commit-Messages darf kein Klammer-`build`-Format verwendet werden.

View file

@ -53,7 +53,8 @@ scp → /opt/bollwerk/data/ └── GET /static/* → Dateie
In `app/build.gradle.kts`:
- `versionCode` um 1 erhöhen
- `versionName` passend anpassen (z.B. "1.2" → "1.3")
- `versionName` **immer strikt im Format `x.y.z`** führen (z.B. `1.7.13`)
- Das dritte Segment (`z`) muss dem `versionCode` entsprechen
### Schritt 2 Build & Test
@ -112,11 +113,11 @@ Invoke-WebRequest -Uri "https://bollwerk.online/" -UseBasicParsing | Select-Obje
Das Skript `publish-apk.py` in diesem Skill-Ordner automatisiert den **gesamten** Release-Workflow (Version bumpen, bauen, hochladen, API-Call, verifizieren, committen):
```powershell
# Alles automatisch (versionCode +1, versionName bleibt):
# Alles automatisch (versionCode +1, versionName wird auf x.y.<versionCode> gesetzt):
python ".github/skills/publish/publish-apk.py"
# Mit neuem versionName:
python ".github/skills/publish/publish-apk.py" --version-name "2.0"
python ".github/skills/publish/publish-apk.py" --version-name "2.0.42"
# Nur hochladen (APK bereits gebaut, kein Push):
python ".github/skills/publish/publish-apk.py" --skip-build --skip-push
@ -131,7 +132,7 @@ python ".github/skills/publish/publish-apk.py" --skip-build --skip-push
| Parameter | Pflicht | Default | Beschreibung |
| ---------------- | ------- | ----------- | ------------------------------------------------- |
| `--version-name` | nein | aktueller | Neuer versionName (z.B. "2.0"); sonst unverändert |
| `--version-name` | nein | aus current major.minor + code | Neuer versionName im Format `x.y.z` (z.B. "2.0.42") |
| `--version-code` | nein | aktuell + 1 | Expliziter versionCode; sonst automatisch +1 |
| `--apk-path` | nein | debug-APK | Pfad zur APK-Datei |
| `--skip-build` | nein | false | Gradle-Build überspringen |

View file

@ -15,7 +15,7 @@
- Fehler nach Upload (API) APK liegt schon oben; kein Rollback, Recovery-Hinweis
- Fehler bei Verify/Git Deployment war erfolgreich; nur Warnung, kein Abbruch
.PARAMETER VersionName
Neue versionName (z.B. "1.8"). Wenn weggelassen, bleibt die aktuelle.
Neue versionName im Format x.y.z (z.B. "1.7.14").
.PARAMETER VersionCode
Expliziter versionCode. Wenn weggelassen, wird der aktuelle automatisch um 1 erhöht.
.PARAMETER ApkPath
@ -72,6 +72,20 @@ function Write-OK { param([string]$Text); Write-Host "[OK] $Text" -ForegroundC
function Write-Skip { param([string]$Text); Write-Host "[--] $Text" -ForegroundColor DarkGray }
function Write-Fail { param([string]$Text); Write-Host "[!!] $Text" -ForegroundColor Red }
function Get-MajorMinor {
param([string]$VersionName)
$m = [regex]::Match($VersionName, '^(\d+)\.(\d+)(?:\.(\d+))?$')
if (-not $m.Success) { return $null }
return @($m.Groups[1].Value, $m.Groups[2].Value)
}
function Get-ThreePartVersion {
param([string]$VersionName)
$m = [regex]::Match($VersionName, '^(\d+)\.(\d+)\.(\d+)$')
if (-not $m.Success) { return $null }
return @([int]$m.Groups[1].Value, [int]$m.Groups[2].Value, [int]$m.Groups[3].Value)
}
# ─────────────────────────────────────────────────────────────────────────────
# Pre-Checks (nichts wird verändert)
# ─────────────────────────────────────────────────────────────────────────────
@ -105,7 +119,26 @@ if (-not $nameMatch.Success) {
$currentCode = [int]$codeMatch.Groups[1].Value
$currentName = $nameMatch.Groups[1].Value
$newCode = if ($VersionCode -gt 0) { $VersionCode } else { $currentCode + 1 }
$newName = if ($VersionName) { $VersionName } else { $currentName }
if ($VersionName) {
$parts = Get-ThreePartVersion $VersionName
if (-not $parts) {
Write-Fail "-VersionName muss strikt im Format x.y.z angegeben werden (z.B. 1.7.14)."
exit 1
}
if ($parts[2] -ne $newCode) {
Write-Fail "Patch-Segment der versionName ($($parts[2])) muss dem versionCode ($newCode) entsprechen."
exit 1
}
$newName = $VersionName
} else {
$majorMinor = Get-MajorMinor $currentName
if (-not $majorMinor) {
Write-Fail "Aktuelle versionName '$currentName' hat kein gueltiges Schema. Erlaubt: x.y oder x.y.z"
exit 1
}
$newName = "$($majorMinor[0]).$($majorMinor[1]).$newCode"
}
if ($newCode -le $currentCode) {
Write-Fail "Neuer versionCode ($newCode) muss groesser als aktueller ($currentCode) sein."
@ -121,16 +154,14 @@ if ($LASTEXITCODE -ne 0) {
Write-OK "Pre-Checks bestanden (Token, Gradle-Parsing, SSH-Agent)"
Write-Host ""
Write-Host "=== Publish APK v$newName (build $newCode) ===" -ForegroundColor Cyan
Write-Host " $currentName ($currentCode) → $newName ($newCode)" -ForegroundColor Gray
Write-Host "=== Publish APK v$newName ===" -ForegroundColor Cyan
Write-Host " $currentName [$currentCode] → $newName [$newCode]" -ForegroundColor Gray
# ─────────────────────────────────────────────────────────────────────────────
# Schritt 0: build.gradle.kts patchen
# ─────────────────────────────────────────────────────────────────────────────
$patchedContent = $originalContent -replace "versionCode\s*=\s*$currentCode\b", "versionCode = $newCode"
if ($VersionName) {
$patchedContent = $patchedContent -replace 'versionName\s*=\s*"[^"]+"', "versionName = `"$newName`""
}
$patchedContent = $patchedContent -replace 'versionName\s*=\s*"[^"]+"', "versionName = `"$newName`""
try {
[System.IO.File]::WriteAllText((Resolve-Path $BuildGradle).Path, $patchedContent)
@ -224,7 +255,7 @@ for ($attempt = 1; $attempt -le $maxRetries; $attempt++) {
}
if ($apiSuccess) {
Write-OK "Version gesetzt: $newName (build $newCode)"
Write-OK "Version gesetzt: $newName"
} else {
# Deployment war teilweise erfolgreich → trotzdem committen, aber warnen
Write-Host ""
@ -264,10 +295,10 @@ if (-not $apiSuccess -or $SkipVerify) {
Write-Step "[Git] Version-Bump committen..."
try {
git add $BuildGradle
git commit -m "chore: release v$newName ($newCode)"
git commit -m "chore: release v$newName"
} catch {
Write-Host "[??] git commit fehlgeschlagen: $_" -ForegroundColor DarkYellow
Write-Host " Manuell: git add $BuildGradle ; git commit -m 'chore: release v$newName ($newCode)'" -ForegroundColor DarkGray
Write-Host " Manuell: git add $BuildGradle ; git commit -m 'chore: release v$newName'" -ForegroundColor DarkGray
}
if (-not $SkipPush) {
@ -287,6 +318,7 @@ if (-not $SkipPush) {
# ─────────────────────────────────────────────────────────────────────────────
Write-Host ""
Write-Host "=== Publish abgeschlossen ===" -ForegroundColor Cyan
Write-Host " Version : $newName (build $newCode)"
Write-Host " Version : $newName"
Write-Host " Code : $newCode"
Write-Host " Homepage: $ServerUrl/"
Write-Host " API : $ServerUrl/api/version"

View file

@ -17,7 +17,7 @@ Rollback-Strategie:
Verwendung:
python publish-apk.py
python publish-apk.py --version-name 2.0
python publish-apk.py --version-name 2.0.42
python publish-apk.py --skip-build --skip-push
"""
@ -108,13 +108,73 @@ def get_status(url: str, timeout: int = 10):
with urllib.request.urlopen(req, timeout=timeout) as resp:
return resp.status
def parse_major_minor(version_name: str):
"""Extrahiert major/minor aus x.y oder x.y.z."""
m = re.match(r"^(\d+)\.(\d+)(?:\.(\d+))?$", version_name.strip())
if not m:
return None
return m.group(1), m.group(2)
def validate_three_part_version(version_name: str):
"""Validiert strikt x.y.z (nur numerische Segmente)."""
m = re.match(r"^(\d+)\.(\d+)\.(\d+)$", version_name.strip())
if not m:
return None
return int(m.group(1)), int(m.group(2)), int(m.group(3))
def _extract_admin_token(raw_text: str) -> str:
"""Extrahiert BOLLWERK_ADMIN_TOKEN aus docker-compose/.env-Inhalten."""
if not raw_text:
return ""
patterns = [
r"(?m)^\s*BOLLWERK_ADMIN_TOKEN\s*[:=]\s*['\"]?([^'\"\s#]+)",
r"(?m)^\s*-\s*BOLLWERK_ADMIN_TOKEN\s*[:=]\s*['\"]?([^'\"\s#]+)",
r"(?m)^\s*-\s*BOLLWERK_ADMIN_TOKEN\s*=\s*['\"]?([^'\"\s#]+)",
]
for pattern in patterns:
match = re.search(pattern, raw_text)
if match:
return match.group(1).strip()
return ""
def get_admin_token_from_vps() -> str:
"""Holt den Admin-Token direkt vom VPS (Dateien, dann Container-Env)."""
ssh_opts = "-o BatchMode=yes -o ConnectTimeout=10 -o StrictHostKeyChecking=no"
probe_commands = [
(
"compose+env files",
f'ssh {ssh_opts} {VPS} '
f'"cd {REMOTE_DIR}; '
f'[ -f docker-compose.yml ] && cat docker-compose.yml || true; '
f'echo __ENV_SPLIT__; '
f'[ -f .env ] && cat .env || true"',
),
(
"docker inspect env",
f'ssh {ssh_opts} {VPS} "docker inspect $(docker ps -q) 2>/dev/null || true"',
),
]
for _, cmd in probe_commands:
result = subprocess.run(cmd, shell=True, capture_output=True, text=True)
token = _extract_admin_token(result.stdout or "")
if token:
return token
return ""
# ---------------------------------------------------------------------------
# Argument-Parsing
# ---------------------------------------------------------------------------
def parse_args():
parser = argparse.ArgumentParser(description="Bollwerk APK Publish-Workflow")
parser.add_argument("--version-name", default="", help="Neue versionName (z.B. '1.8')")
parser.add_argument("--version-name", default="", help="Neue versionName im Format x.y.z (z.B. '1.7.14')")
parser.add_argument("--version-code", type=int, default=0, help="Expliziter versionCode")
parser.add_argument("--apk-path", default="app/build/outputs/apk/debug/app-debug.apk")
parser.add_argument("--skip-build", action="store_true")
@ -129,13 +189,6 @@ def parse_args():
def main():
args = parse_args()
# --- Pre-Check: Admin-Token ---
admin_token = os.environ.get("BOLLWERK_ADMIN_TOKEN", "")
if not admin_token:
fail("BOLLWERK_ADMIN_TOKEN ist nicht gesetzt.")
info("Bitte setzen: $env:BOLLWERK_ADMIN_TOKEN = 'dein-token'")
sys.exit(1)
# --- Pre-Check: build.gradle.kts lesen ---
if not os.path.exists(BUILD_GRADLE):
fail(f"Datei nicht gefunden: {BUILD_GRADLE}")
@ -156,7 +209,22 @@ def main():
current_code = int(code_match.group(1))
current_name = name_match.group(1)
new_code = args.version_code if args.version_code > 0 else current_code + 1
new_name = args.version_name if args.version_name else current_name
if args.version_name:
parsed = validate_three_part_version(args.version_name)
if not parsed:
fail("--version-name muss strikt im Format x.y.z angegeben werden (z. B. 1.7.14).")
sys.exit(1)
if parsed[2] != new_code:
fail(f"Patch-Segment der versionName ({parsed[2]}) muss dem versionCode ({new_code}) entsprechen.")
sys.exit(1)
new_name = args.version_name.strip()
else:
major_minor = parse_major_minor(current_name)
if not major_minor:
fail(f"Aktuelle versionName '{current_name}' hat kein gueltiges Schema. Erlaubt: x.y oder x.y.z")
sys.exit(1)
new_name = f"{major_minor[0]}.{major_minor[1]}.{new_code}"
if new_code <= current_code:
fail(f"Neuer versionCode ({new_code}) muss groesser als aktueller ({current_code}) sein.")
@ -168,10 +236,20 @@ def main():
fail("SSH-Agent hat keinen Key. Bitte ausfuehren: ssh-add C:\\Users\\JensR\\.ssh\\id_ed25519")
sys.exit(1)
# --- Pre-Check: Admin-Token direkt vom VPS holen ---
step("[Preflight] Lade BOLLWERK_ADMIN_TOKEN direkt vom VPS...")
admin_token = get_admin_token_from_vps()
if admin_token:
ok("Admin-Token vom VPS geladen")
else:
fail("BOLLWERK_ADMIN_TOKEN konnte auf dem VPS nicht ermittelt werden.")
info(f"Pruefe auf {REMOTE_DIR}/docker-compose.yml, {REMOTE_DIR}/.env oder Container-Env")
sys.exit(1)
ok("Pre-Checks bestanden (Token, Gradle-Parsing, SSH-Agent)")
print(f"\n{CYAN}=== Publish APK v{new_name} (build {new_code}) ==={RESET}", flush=True)
print(f"{GRAY} {current_name} ({current_code}) -> {new_name} ({new_code}){RESET}", flush=True)
print(f"\n{CYAN}=== Publish APK v{new_name} ==={RESET}", flush=True)
print(f"{GRAY} {current_name} [{current_code}] -> {new_name} [{new_code}]{RESET}", flush=True)
# -------------------------------------------------------------------------
# Schritt 0: build.gradle.kts patchen
@ -181,7 +259,6 @@ def main():
f'versionCode = {new_code}',
original_content,
)
if args.version_name:
patched = re.sub(
r'versionName\s*=\s*"[^"]+"',
f'versionName = "{new_name}"',
@ -270,7 +347,7 @@ def main():
info(f" -d '{json.dumps(api_body)}'")
if api_success:
ok(f"Version gesetzt: {new_name} (build {new_code})")
ok(f"Version gesetzt: {new_name}")
else:
warn("VPS-Version nicht aktualisiert. build.gradle.kts wird trotzdem committet.")
@ -304,10 +381,10 @@ def main():
step("[Git] Version-Bump committen...")
try:
run(f'git add "{BUILD_GRADLE}"')
run(f'git commit -m "chore: release v{new_name} ({new_code})"')
run(f'git commit -m "chore: release v{new_name}"')
except Exception as e:
warn(f"git commit fehlgeschlagen: {e}")
info(f'Manuell: git add "{BUILD_GRADLE}" ; git commit -m "chore: release v{new_name} ({new_code})"')
info(f'Manuell: git add "{BUILD_GRADLE}" ; git commit -m "chore: release v{new_name}"')
if not args.skip_push:
try:
@ -323,7 +400,8 @@ def main():
# Zusammenfassung
# -------------------------------------------------------------------------
print(f"\n{CYAN}=== Publish abgeschlossen ==={RESET}", flush=True)
print(f" Version : {new_name} (build {new_code})", flush=True)
print(f" Version : {new_name}", flush=True)
print(f" Code : {new_code}", flush=True)
print(f" Homepage: {SERVER_URL}/", flush=True)
print(f" API : {SERVER_URL}/api/version", flush=True)

View file

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

View file

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

View file

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

View file

@ -22,6 +22,10 @@ internal class NotificationHelper @Inject constructor(
@Volatile
private var activeChatPartnerId: String? = null
@Volatile
private var isMessagingAreaVisible: Boolean = false
private val activeSenderNotificationIds = mutableSetOf<Int>()
private val activeSenderNamesById = mutableMapOf<Int, String>()
/// Erstellt den Notification Channel (ab API 26 erforderlich).
fun createNotificationChannel() {
@ -50,24 +54,41 @@ internal class NotificationHelper @Inject constructor(
activeChatPartnerId = partnerId
}
/// Setzt, ob der Nutzer aktuell im Nachrichtenbereich ist (User-Liste oder Chat).
fun setMessagingAreaVisible(isVisible: Boolean) {
isMessagingAreaVisible = isVisible
}
/// Zeigt eine Benachrichtigung für eine eingehende Nachricht an.
/// Gibt false zurück, wenn die Notification unterdrückt wurde (Chat ist aktiv).
fun showNewMessageNotification(
senderId: String,
senderUsername: String
): Boolean {
if (senderId == activeChatPartnerId) return false
if (isMessagingAreaVisible || senderId == activeChatPartnerId) return false
val intent = Intent(context, MainActivity::class.java).apply {
val chatIntent = Intent(context, MainActivity::class.java).apply {
flags = Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_CLEAR_TOP
putExtra(EXTRA_RECIPIENT_ID, senderId)
putExtra(EXTRA_RECIPIENT_USERNAME, senderUsername)
}
val pendingIntent = PendingIntent.getActivity(
val chatPendingIntent = PendingIntent.getActivity(
context,
senderId.hashCode(),
intent,
chatIntent,
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
)
val messagesIntent = Intent(context, MainActivity::class.java).apply {
flags = Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_CLEAR_TOP
putExtra(EXTRA_OPEN_MESSAGES, true)
}
val messagesPendingIntent = PendingIntent.getActivity(
context,
SUMMARY_NOTIFICATION_ID,
messagesIntent,
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
)
@ -77,23 +98,38 @@ internal class NotificationHelper @Inject constructor(
.setContentText("$senderUsername hat etwas geschrieben")
.setPriority(NotificationCompat.PRIORITY_HIGH)
.setAutoCancel(true)
.setContentIntent(pendingIntent)
.setContentIntent(chatPendingIntent)
.setGroup(GROUP_KEY)
.build()
val 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)
.setSmallIcon(R.drawable.ic_notification_message)
.setContentTitle("Neue Nachrichten")
.setContentText("Du hast neue Nachrichten")
.setContentText(summaryText)
.setGroup(GROUP_KEY)
.setGroupSummary(true)
.setAutoCancel(true)
.setContentIntent(messagesPendingIntent)
.build()
val notificationManager = NotificationManagerCompat.from(context)
try {
notificationManager.notify(senderId.hashCode(), notification)
notificationManager.notify(senderNotificationId, notification)
if (shouldShowSummary) {
notificationManager.notify(SUMMARY_NOTIFICATION_ID, summaryNotification)
} else {
notificationManager.cancel(SUMMARY_NOTIFICATION_ID)
}
} catch (_: SecurityException) {
// Permission not granted ignore silently
return false
@ -104,7 +140,41 @@ internal class NotificationHelper @Inject constructor(
/// Entfernt Benachrichtigungen für einen bestimmten Absender (wenn der Chat geöffnet wird).
fun cancelNotificationForSender(senderId: String) {
val notificationManager = NotificationManagerCompat.from(context)
notificationManager.cancel(senderId.hashCode())
val senderNotificationId = senderId.hashCode()
notificationManager.cancel(senderNotificationId)
val hasMoreSenders = synchronized(activeSenderNotificationIds) {
activeSenderNotificationIds.remove(senderNotificationId)
activeSenderNamesById.remove(senderNotificationId)
activeSenderNotificationIds.isNotEmpty()
}
if (!hasMoreSenders) {
notificationManager.cancel(SUMMARY_NOTIFICATION_ID)
}
}
fun cancelAllMessageNotifications() {
val notificationManager = NotificationManagerCompat.from(context)
val ids = synchronized(activeSenderNotificationIds) {
val copy = activeSenderNotificationIds.toList()
activeSenderNotificationIds.clear()
activeSenderNamesById.clear()
copy
}
ids.forEach { notificationManager.cancel(it) }
notificationManager.cancel(SUMMARY_NOTIFICATION_ID)
}
private fun buildSummaryText(senderNames: List<String>): String {
val uniqueSenders = senderNames.distinct()
return when (uniqueSenders.size) {
0 -> "Neue Nachrichten"
1 -> "Neue Nachricht von ${uniqueSenders[0]}"
2 -> "Neue Nachrichten von ${uniqueSenders[0]} und ${uniqueSenders[1]}"
else -> {
val remaining = uniqueSenders.size - 2
"Neue Nachrichten von ${uniqueSenders[0]}, ${uniqueSenders[1]} und $remaining weiteren"
}
}
}
companion object {
@ -115,5 +185,6 @@ internal class NotificationHelper @Inject constructor(
const val EXTRA_RECIPIENT_ID = "notification_recipient_id"
const val EXTRA_RECIPIENT_USERNAME = "notification_recipient_username"
const val EXTRA_OPEN_MESSAGES = "notification_open_messages"
}
}

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

View file

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

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

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