Technisches Lastenheft · Phase 1
v1.0 · 2026-03Dating-Notruf-App · Offline-Keyword-Erkennung · GPS-Handshake
Anforderungsübersicht · Phase 1
Offline-Keyword-Erkennung Android
TFLite + MFCC, ≥85% Konfidenz, kein Internet nötig
Offline-Keyword-Erkennung iOS
CoreML + SoundAnalysis, ANE-beschleunigt
Background-Service Persistence
START_STICKY, Duty-Cycle Akku-Management
GPS-Handshake verschlüsselt
ECDH P-384 + AES-256-GCM + HMAC-SHA256
Adaptive Akku-Modi
Normal / Eco / Ultra-Eco nach Ladezustand
OTA-Modell-Updates
Keyword-Modell ohne App-Store-Release aktualisierbar
Keyword-Latenz ≤ 500ms
Von Trigger-Wort bis SOS-Alarm-Auslösung
GPS-Update Latenz ≤ 200ms
Verschlüsseln + Senden + Server-ACK
Akku-Verbrauch ≤ 25mW (Eco)
Monitoring ohne merklichen Akku-Drain
85%
Min. Konfidenz
40
MFCC-Koeffizienten
50ms
Hop-Size
Android: TFLite Interpreter mit NNAPI-Delegate und XNNPACK — 3–5x schneller als CPU-only.
iOS: CoreML mit ANE (Apple Neural Engine) — minimaler CPU-Einsatz, maximale Effizienz.
Keywords: "hilfe", "notruf", "sos", "hilf mir" — mehrsprachig erweiterbar über Modell-Update.
// ═══════════════════════════════════════════════════════════════
// ANDROID · Offline-Keyword-Erkennung · TFLite + MFCC Pipeline
// ═══════════════════════════════════════════════════════════════
// ── Klasse 1: KeywordDetectionEngine ────────────────────────────
// Verantwortlich für On-Device Inferenz via TensorFlow Lite
class KeywordDetectionEngine(private val context: Context) {
// ── Konstanten ────────────────────────────────────────────
companion object {
const val SAMPLE_RATE_HZ = 16000 // 16kHz Mono
const val WINDOW_SIZE_MS = 1000 // 1s gleitendes Fenster
const val HOP_SIZE_MS = 50 // 50ms Overlap (Effizienz)
const val NUM_MFCC_COEFF = 40 // MFCC-Koeffizientenanzahl
const val DETECTION_THRESHOLD = 0.85f // Min. Konfidenz für Trigger
const val MODEL_FILE = "secsi_keyword_v3.tflite"
val TRIGGER_KEYWORDS = listOf("hilfe", "notruf", "sos", "hilf mir")
}
// ── State ────────────────────────────────────────────────
private var interpreter: Interpreter? = null
private var isRunning: Boolean = false
private var detectionScope: CoroutineScope = CoroutineScope(Dispatchers.Default)
// ── Eingabe/Ausgabe-Tensoren ─────────────────────────────
private val inputBuffer: Array<Array<FloatArray>> = Array(1) {
Array(40) { FloatArray(32) } // [batch, mfcc_coeff, time_frames]
}
private val outputBuffer: Array<FloatArray> = Array(1) {
FloatArray(TRIGGER_KEYWORDS.size + 1) // +1 für "background"
}
// ── Lifecycle ────────────────────────────────────────────
fun initialize() {
val modelFile = loadModelFromAssets(MODEL_FILE)
val options = Interpreter.Options().apply {
numThreads = 2 // Max. 2 Threads = Akku-schonend
useNNAPI = true // Android Neural Networks API
useXNNPACK = true // Optimierter Math-Kernel
}
interpreter = Interpreter(modelFile, options)
}
fun startDetection(onKeywordDetected: (keyword: String, confidence: Float) -> Unit) {
isRunning = true
detectionScope.launch {
while (isRunning) {
// Nicht-blockierendes Warten auf nächsten Audio-Frame
val audioFrame = AudioCaptureService.getNextFrame()
val mfccFeatures = MFCCExtractor.extract(audioFrame)
val result = runInference(mfccFeatures)
if (result.confidence >= DETECTION_THRESHOLD) {
withContext(Dispatchers.Main) {
onKeywordDetected(result.keyword, result.confidence)
}
}
}
}
}
fun stopDetection() {
isRunning = false
detectionScope.cancel()
interpreter?.close()
}
private fun runInference(features: Array<FloatArray>): DetectionResult {
copyFeaturesToBuffer(features)
interpreter!!.run(inputBuffer, outputBuffer)
val scores = outputBuffer[0]
val maxIdx = scores.indices.maxByOrNull { scores[it] } ?: 0
return DetectionResult(
keyword = if (maxIdx < TRIGGER_KEYWORDS.size) TRIGGER_KEYWORDS[maxIdx] else "background",
confidence = scores[maxIdx],
isKeyword = maxIdx < TRIGGER_KEYWORDS.size && scores[maxIdx] >= DETECTION_THRESHOLD
)
}
private fun loadModelFromAssets(filename: String): MappedByteBuffer {
val fd = context.assets.openFd(filename)
return FileInputStream(fd.fileDescriptor).channel
.map(FileChannel.MapMode.READ_ONLY, fd.startOffset, fd.declaredLength)
}
}
// ── Datenklassen ─────────────────────────────────────────────────
data class DetectionResult(
val keyword: String,
val confidence: Float,
val isKeyword: Boolean
)
data class MFCCFeatures(
val coefficients: Array<FloatArray>, // [num_coeff, time_frames]
val timestamp: Long,
val energyLevel: Float // RMS-Energie → Stille-Erkennung
)
// ── Klasse 2: MFCCExtractor ───────────────────────────────────────
// On-Device Feature-Extraktion ohne externe Libraries
class MFCCExtractor {
companion object {
fun extract(audioFrame: ShortArray): Array<FloatArray> {
val floatSamples = audioFrame.map { it / 32768.0f }.toFloatArray()
val windowed = applyHammingWindow(floatSamples)
val fftResult = FFTProcessor.compute(windowed)
val melFilters = MelFilterbank.apply(fftResult, numFilters = 40)
val logMel = melFilters.map { ln(it.coerceAtLeast(1e-10f)) }.toFloatArray()
return DCTProcessor.compute(logMel, numCoefficients = 40)
}
}
}BaseSecSIService
Abstract base — Foreground-Service-Boilerplate, unsichtbare Notification
SafetyMonitoringService
Haupt-Service — orchestriert alle Sub-Jobs via Coroutines + SupervisorJob
BatteryAwareAudioCapture
Duty-Cycle-Steuerung — 3 Modi je nach Akku-Stand
// ═══════════════════════════════════════════════════════════════
// BACKGROUND SERVICE · Klassen-Hierarchie · Android + iOS
// Ziel: Mikrofon-Monitoring mit minimalem Akku-Verbrauch
// ═══════════════════════════════════════════════════════════════
// ─────────────────────────────────────────────────────────────────
// ANDROID: Klassen-Hierarchie
// ─────────────────────────────────────────────────────────────────
abstract class BaseSecSIService : Service() {
abstract val serviceId: Int
abstract val channelId: String
abstract fun onSessionActive(): Boolean
protected fun startAsForeground(label: String, icon: Int) {
createNotificationChannel()
startForeground(serviceId, buildMinimalNotification(label, icon))
}
private fun buildMinimalNotification(label: String, icon: Int): Notification =
NotificationCompat.Builder(this, channelId)
.setSmallIcon(icon)
.setContentTitle("") // Leer für Unauffälligkeit
.setOngoing(true)
.setPriority(NotificationCompat.PRIORITY_MIN)
.setShowWhen(false)
.setVisibility(NotificationCompat.VISIBILITY_SECRET) // Nicht auf Lockscreen
.build()
}
// ── Haupt-Service: SafetyMonitoringService ───────────────────────
class SafetyMonitoringService : BaseSecSIService() {
override val serviceId = 1337
override val channelId = "secsi_monitoring"
override fun onSessionActive() = sessionRepository.hasActiveSession()
// Sub-Services als Coroutine-Jobs
private val serviceJobs = mutableMapOf<String, Job>()
private val serviceScope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
// Komponenten (Dependency Injection via Hilt)
@Inject lateinit var keywordEngine: KeywordDetectionEngine
@Inject lateinit var audioCapturer: BatteryAwareAudioCapture
@Inject lateinit var locationTracker: AdaptiveLocationTracker
@Inject lateinit var alarmDispatcher: AlarmDispatcher
@Inject lateinit var sessionRepo: SessionRepository
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
startAsForeground("", R.drawable.ic_secsi_minimal)
when (intent?.action) {
ACTION_START_SESSION -> startAllMonitors()
ACTION_STOP_SESSION -> stopAllMonitors()
ACTION_KEYWORD_ONLY -> startKeywordOnly() // Leichtgewichtig
}
return START_STICKY
}
private fun startAllMonitors() {
serviceJobs["keyword"] = serviceScope.launch { runKeywordDetection() }
serviceJobs["location"] = serviceScope.launch { runLocationTracking() }
serviceJobs["health"] = serviceScope.launch { runHealthChecks() }
}
private suspend fun runKeywordDetection() {
keywordEngine.initialize()
audioCapturer.startCapture { frame ->
keywordEngine.processFrame(frame)
?.let { alarmDispatcher.dispatchKeywordAlert(it) }
}
}
private fun stopAllMonitors() {
serviceJobs.values.forEach { it.cancel() }
keywordEngine.stopDetection()
audioCapturer.stop()
locationTracker.stop()
stopSelf()
}
}
// ── BatteryAwareAudioCapture ──────────────────────────────────────
// Passt Audio-Qualität dynamisch an Akku-Stand an
class BatteryAwareAudioCapture @Inject constructor(
private val context: Context,
private val batteryMonitor: BatteryMonitor
) {
companion object {
// Volle Qualität (Akku > 30%)
const val FULL_SAMPLE_RATE = 16000
const val FULL_BUFFER_SIZE = 1600 // 100ms @ 16kHz
// Eco-Modus (Akku ≤ 30%)
const val ECO_SAMPLE_RATE = 8000
const val ECO_BUFFER_SIZE = 800 // 100ms @ 8kHz
const val ECO_DUTY_CYCLE_MS = 500 // 500ms hören, 500ms pausieren
// Ultra-Eco (Akku ≤ 15%)
const val UECO_SAMPLE_RATE = 8000
const val UECO_DUTY_CYCLE_MS = 200 // 200ms hören, 800ms pausieren
}
private var audioRecord: AudioRecord? = null
private var isCapturing: Boolean = false
private val captureScope = CoroutineScope(Dispatchers.IO)
fun startCapture(onFrame: (ShortArray) -> Unit) {
isCapturing = true
captureScope.launch {
while (isCapturing) {
val batteryLevel = batteryMonitor.getCurrentLevel()
val config = selectConfig(batteryLevel)
captureFrame(config, onFrame)
// Duty-Cycle-Pause (Akku-Schonung)
if (config.hasDutyCycle) delay(config.pauseMs)
}
}
}
private fun selectConfig(batteryLevel: Int): CaptureConfig = when {
batteryLevel > 30 -> CaptureConfig.FULL
batteryLevel > 15 -> CaptureConfig.ECO
else -> CaptureConfig.ULTRA_ECO
}
private suspend fun captureFrame(config: CaptureConfig, onFrame: (ShortArray) -> Unit) {
initAudioRecord(config)
val buffer = ShortArray(config.bufferSize)
val read = audioRecord!!.read(buffer, 0, config.bufferSize)
if (read > 0) onFrame(buffer.copyOf(read))
if (!config.continuous) releaseAudioRecord() // Eco: AudioRecord freigeben
}
}
enum class CaptureConfig(
val sampleRate: Int,
val bufferSize: Int,
val continuous: Boolean,
val hasDutyCycle:Boolean,
val pauseMs: Long
) {
FULL (16000, 1600, true, false, 0),
ECO (8000, 800, false, true, 500),
ULTRA_ECO (8000, 800, false, true, 800)
}
// ── AdaptiveLocationTracker ───────────────────────────────────────
class AdaptiveLocationTracker @Inject constructor(
private val fusedLocationClient: FusedLocationProviderClient
) {
// Intervall dynamisch anpassen: Gefahr = häufiger, Ruhephase = seltener
private val dangerRequest = LocationRequest.Builder(Priority.HIGH_ACCURACY, 5_000).build()
private val idleRequest = LocationRequest.Builder(Priority.BALANCED_POWER, 30_000).build()
fun setEscalationLevel(level: EscalationLevel) {
val request = if (level == EscalationLevel.INTERVENTION) dangerRequest else idleRequest
fusedLocationClient.requestLocationUpdates(request, locationCallback, Looper.getMainLooper())
}
}
// ─────────────────────────────────────────────────────────────────
// iOS: Klassen-Hierarchie
// ─────────────────────────────────────────────────────────────────
// ── SafetyMonitoringCoordinator (iOS-Äquivalent zum Android-Service)
class SafetyMonitoringCoordinator {
static let shared = SafetyMonitoringCoordinator()
// Sub-Koordinatoren
let keywordEngine = KeywordDetectionEngine()
let audioCapture = BatteryAwareAudioCapture()
let locationTracker = AdaptiveLocationTracker()
let alarmDispatcher = AlarmDispatcher()
var backgroundTask: UIBackgroundTaskIdentifier = .invalid
func startSession(type: SessionType) throws {
// Background-Task registrieren (bis zu 30s bei App-Hintergrundwechsel)
backgroundTask = UIApplication.shared.beginBackgroundTask {
self.extendBackgroundExecution()
}
try keywordEngine.startDetection { [weak self] keyword, confidence in
self?.alarmDispatcher.dispatch(keyword: keyword, confidence: confidence)
}
locationTracker.startAdaptiveTracking()
}
// BGProcessingTask für längere Background-Arbeit (iOS 13+)
func scheduleBackgroundRefresh() {
let request = BGProcessingTaskRequest(identifier: "de.sec-si.monitoring")
request.requiresNetworkConnectivity = false
request.requiresExternalPower = false
request.earliestBeginDate = Date(timeIntervalSinceNow: 60)
try? BGTaskScheduler.shared.submit(request)
}
}Normal (Akku > 30%)
55–92 mW
~16h Restlaufzeit
Eco (Akku ≤ 30%)
15–25 mW
~10h Restlaufzeit
Ultra-Eco (Akku ≤ 15%)
6–12 mW
~10h Restlaufzeit
// ═══════════════════════════════════════════════════════════════ // AKKU-BUDGET · Spezifikation pro Monitoring-Modus // Messungen auf: Pixel 7 (Android) / iPhone 15 (iOS) // ═══════════════════════════════════════════════════════════════ AKKU_BUDGET_SPEZIFIKATION: // ── NORMAL-MODUS (Akku > 30%) ──────────────────────────────── Keyword-Engine (TFLite NNAPI): 15–25 mW AudioRecord 16kHz Mono: 8–12 mW GPS FusedLocation (5s Intervall): 10–20 mW WebSocket Heartbeat (5s): 2–5 mW Hintergrund-App-Overhead: 20–30 mW ───────────────────────────────────────────── GESAMT NORMAL: 55–92 mW Laufzeit (3000mAh Akku @ 55mW): ~ 16 Stunden // ── ECO-MODUS (Akku ≤ 30%) ─────────────────────────────────── Keyword-Engine (Duty-Cycle 50%): 8–12 mW AudioRecord 8kHz Duty-Cycle: 3–5 mW GPS (30s Intervall, BALANCED): 3–6 mW WebSocket Heartbeat (15s): 1–2 mW ───────────────────────────────────────────── GESAMT ECO: 15–25 mW Laufzeit (restliche 30% = 900mAh): ~ 10 Stunden // ── ULTRA-ECO-MODUS (Akku ≤ 15%) ──────────────────────────── Keyword-Engine (Duty-Cycle 20%): 4–6 mW AudioRecord 8kHz Duty-Cycle 20%: 1–2 mW GPS (60s Intervall, LOW_POWER): 1–3 mW WebSocket Heartbeat (30s): 0–1 mW ───────────────────────────────────────────── GESAMT ULTRA-ECO: 6–12 mW Laufzeit (restliche 15% = 450mAh): ~ 10 Stunden // ── AKKU-OPTIMIERUNGEN ────────────────────────────────────── 1. NNAPI / ANE nutzen (3–5x effizienter als CPU-only TFLite) 2. Duty-Cycle AudioRecord: Release AudioRecord in Pause → GC 3. GPS-Provider: FusedLocation (nicht raw GPS) → batterieschonend 4. Batch-GPS-Uploads: Nicht jede GPS-Position sofort senden 5. WakeLock: PARTIAL_WAKE_LOCK statt FULL_WAKE_LOCK 6. JobScheduler für nicht-zeitkritische Tasks (GPS-Batch-Upload)
ECDH P-384 Key-Exchange
Ephemeral Keys → Perfect Forward Secrecy
HKDF Key-Derivation
Shared Secret → AES-256-GCM Session Key
AES-256-GCM Verschlüsselung
Pro GPS-Paket: neuer IV + Auth-Tag
HMAC-SHA256 Signatur
Transport-Integrität über gesamtes Paket
Server-Verifikation
HMAC + Auth-Tag + Seq-Nr. geprüft
// ═══════════════════════════════════════════════════════════════
// GPS-HANDSHAKE SCHEMA · Ende-zu-Ende verschlüsselt
// Protokoll: ECDH P-384 + AES-256-GCM + HMAC-SHA256
// ═══════════════════════════════════════════════════════════════
// ─────────────────────────────────────────────────────────────────
// SCHRITT 1: Session-Key-Derivation (einmalig beim Session-Start)
// ─────────────────────────────────────────────────────────────────
GPS_SESSION_KEY_EXCHANGE:
CLIENT → SERVER:
POST /api/v2/session/gps-key-init
Content-Type: application/json
Authorization: Bearer {access_token}
{
"session_id": "ses_abc123",
"client_ecdh_pub": "BASE64(P-384 Public Key)", // Ephemeral!
"device_id_hash": "SHA256(device_fingerprint)",
"timestamp": "2026-03-11T22:00:00.000Z",
"nonce": "BASE64(32 random bytes)"
}
SERVER → CLIENT:
HTTP 200
{
"session_gps_key_id": "gpskey_xyz789",
"server_ecdh_pub": "BASE64(P-384 Public Key)",
"server_nonce": "BASE64(32 random bytes)",
"key_valid_until": "2026-03-11T23:00:00.000Z", // 1h Gültigkeit
"algorithm": "ECDH-P384+HKDF+AES-256-GCM"
}
// BEIDE SEITEN leiten jetzt denselben GPS_SESSION_KEY ab:
shared_secret = ECDH(client_priv_key, server_pub_key)
GPS_SESSION_KEY = HKDF(
inputKeyMaterial: shared_secret,
salt: SHA256(client_nonce + server_nonce),
info: "secsi-gps-v1-" + session_id,
length: 32 // 256 Bit AES-Key
)
// Server hat den Key, Client hat den Key — NIEMAND SONST
// ─────────────────────────────────────────────────────────────────
// SCHRITT 2: GPS-Update Payload (jede Übertragung)
// ─────────────────────────────────────────────────────────────────
GPS_UPDATE_PACKET:
// ── Plaintext GPS-Daten (vor Verschlüsselung) ────────────────
GPS_PLAINTEXT = {
"lat": 48.137154, // Breitengrad
"lng": 11.576124, // Längengrad
"alt_m": 519.0, // Höhe über NN
"accuracy_m": 8.3, // Horizontale Genauigkeit
"speed_ms": 1.2, // Geschwindigkeit m/s
"bearing_deg": 247.5, // Himmelsrichtung
"provider": "fused", // GPS / network / fused
"seq": 42, // Sequenznummer (Replay-Schutz)
"ts": "2026-03-11T22:14:33.123Z"
}
// ── Verschlüsselung ──────────────────────────────────────────
IV = crypto.randomBytes(12) // 96-Bit IV — EINMALIG pro Paket
CIPHERTEXT, AUTH_TAG = AES_256_GCM.encrypt(
key: GPS_SESSION_KEY,
iv: IV,
plaintext: JSON.encode(GPS_PLAINTEXT),
aad: JSON.encode({ // Authenticated Additional Data
session_id: "ses_abc123",
gps_key_id: "gpskey_xyz789",
seq: 42 // Muss mit plaintext.seq übereinstimmen
})
)
// ── HMAC-Signatur (Integritätsschutz auf Transport-Ebene) ────
PACKET_HMAC = HMAC_SHA256(
key: GPS_SESSION_KEY, // Gleicher Key → Effizienz
data: IV + CIPHERTEXT + AUTH_TAG + JSON.encode(aad)
)
// ── Finales Payload (an Server gesendet) ─────────────────────
CLIENT → SERVER:
POST /api/v2/session/gps-update
Authorization: Bearer {access_token}
{
"session_id": "ses_abc123",
"gps_key_id": "gpskey_xyz789",
"iv": "BASE64(IV)", // 12 Bytes
"ciphertext": "BASE64(CIPHERTEXT)",
"auth_tag": "BASE64(AUTH_TAG)", // 16 Bytes GCM Auth-Tag
"aad": { "session_id": "...", "gps_key_id": "...", "seq": 42 },
"hmac": "BASE64(PACKET_HMAC)"
}
SERVER → CLIENT:
HTTP 200
{
"ack": true,
"seq_received": 42,
"server_ts": "2026-03-11T22:14:33.201Z",
"latency_ms": 18
}
// ─────────────────────────────────────────────────────────────────
// SCHRITT 3: Server-seitige Verifikation (Pseudocode)
// ─────────────────────────────────────────────────────────────────
SERVER GpsUpdateHandler.handle(request):
// 1. JWT validieren
user = JWT.verify(request.headers.authorization)
// 2. Session laden
session = SessionStore.get(request.body.session_id)
REQUIRE session.user_id == user.id
// 3. GPS-Key laden (nie im Klartext gespeichert — nur in HSM)
gpsKey = HSM.retrieveSessionKey(request.body.gps_key_id)
// 4. HMAC verifizieren (Manipulation auf Transport-Ebene)
expectedHmac = HMAC_SHA256(gpsKey, request.body.iv + request.body.ciphertext + ...)
REQUIRE crypto.timingSafeEqual(expectedHmac, request.body.hmac)
// 5. AES-256-GCM entschlüsseln (Auth-Tag wird automatisch geprüft)
plaintext = AES_256_GCM.decrypt(
key: gpsKey,
iv: BASE64.decode(request.body.iv),
input: BASE64.decode(request.body.ciphertext),
authTag: BASE64.decode(request.body.auth_tag),
aad: JSON.encode(request.body.aad)
)
// Wenn Auth-Tag falsch → Exception → 401 zurück
// 6. Replay-Schutz: Sequenznummer prüfen
gpsData = JSON.parse(plaintext)
REQUIRE gpsData.seq > session.last_gps_seq
session.last_gps_seq = gpsData.seq
// 7. GPS in DB persistieren (verschlüsselt mit DB-Key)
GpsLog.insert({
session_id: session.id,
encrypted: DBEncryption.encrypt(plaintext), // Doppelt verschlüsselt
ts: gpsData.ts
})
// 8. Guardian-Push (nur Metadaten — kein Klartext-GPS an Dritte!)
GuardianSocket.push({
type: "gps_update",
session_id: session.id,
accuracy_m: gpsData.accuracy_m, // Genauigkeit ok (kein Klartext-GPS!)
ts: gpsData.ts
// GPS-Klartext nur bei aktivem Alarm freigegeben
})
RETURN { ack: true, seq_received: gpsData.seq }
// ─────────────────────────────────────────────────────────────────
// SICHERHEITS-EIGENSCHAFTEN DES SCHEMAS
// ─────────────────────────────────────────────────────────────────
//
// ✅ Perfect Forward Secrecy (PFS):
// Ephemeral ECDH-Keys → altes GPS nicht entschlüsselbar bei Key-Kompromiss
//
// ✅ Replay-Schutz:
// Sequenznummer + Timestamp + einmaliger IV
//
// ✅ Manipulationsschutz:
// AES-GCM Auth-Tag + HMAC-SHA256 (doppelt gesichert)
//
// ✅ Zero-Knowledge GPS:
// Guardian sieht nur Metadaten — Klartext-GPS nur bei aktivem Alarm
//
// ✅ Key-Rotation:
// GPS_SESSION_KEY läuft nach 1h ab → automatische Neugenerierung