Wächter-Kontrollsysteme: Security-Apps für Sicherheitsdienste
Case Studies

Wächter-Kontrollsysteme: Security-Apps für Sicherheitsdienste

Carola Schulte
12. Mai 2025
28 min

Wächter-Kontrollsysteme: So entwickeln Sie Security-Apps für Sicherheitsdienste

Der Wachmann scannt den QR-Code am Kontrollpunkt. Offline. Um 3 Uhr nachts. In einem Parkhaus ohne Netz. Sobald er wieder Empfang hat, synchronisiert die App automatisch - und der Disponent sieht den Rundgang. Echtzeit sobald online, Near-Realtime nach Sync.

Das ist keine Zukunftsmusik, sondern Standard in modernen Wächter-Kontrollsystemen. In diesem Artikel zeige ich Ihnen, wie solche Systeme technisch funktionieren - basierend auf Erfahrungen aus einem System mit 15.000+ aktiven Nutzern und über 55 Millionen Scannungen.


Architektur-Überblick

Wächter-Kontrollsystem Architektur: Client (SQLite Queue, Offline-First, NFC/QR/GPS, Totmann) → Sync API (Idempotent, Batch-Upload, Conflict-Free, Retry-Safe) → OPS (Monitoring, Alerts, Reports, Dashboard)


Was Wächter-Kontrollsysteme leisten müssen

Sicherheitsdienste haben spezielle Anforderungen, die sich von typischen Business-Apps unterscheiden:

AnforderungWarum kritisch
Offline-FähigkeitTiefgaragen, Keller, Industriegelände - oft kein Netz
Batterie-Effizienz8-12 Stunden Schicht mit einem Akku
Robuste HardwareOutdoor, Nacht, Regen, Kälte
Echtzeit für DisponentenWer ist wo? Ist der Rundgang vollständig?
Notfall-FunktionenTotmann-Alarm wenn Mitarbeiter nicht reagiert
RevisionssicherheitNachweis für Kunden und Versicherungen

Kontrollpunkt-Technologien im Vergleich

QR-Codes: Der Einstieg

┌─────────────────────────────────────────────────────┐
│  QR-Code                                            │
│  ════════                                           │
│  + Günstig (Aufkleber drucken)                     │
│  + Kein spezielles Lesegerät                       │
│  + Schnell austauschbar                             │
│  - Vandalismus-anfällig                            │
│  - Kann fotografiert/kopiert werden                 │
│  - Verblasst bei UV-Belastung                       │
└─────────────────────────────────────────────────────┘

NFC/RFID: Der Profi-Standard

┌─────────────────────────────────────────────────────┐
│  NFC/RFID Tags                                      │
│  ═══════════                                        │
│  + Manipulationssicher (eindeutige ID)              │
│  + Robust und langlebig                             │
│  + Beweist physische Anwesenheit                    │
│  - Teurer (0,50-15 EUR je nach Typ/Gehäuse)        │
│  - Nicht jedes Smartphone kann NFC                  │
│  - Installation aufwendiger                         │
└─────────────────────────────────────────────────────┘

GPS-Checkpoints: Virtuell

┌─────────────────────────────────────────────────────┐
│  Virtuelle Kontrollpunkte (GPS)                     │
│  ══════════════════════════════                     │
│  + Keine Hardware vor Ort nötig                    │
│  + Flexibel änderbar                               │
│  + Gut für Außengelände                          │
│  - GPS-Genauigkeit problematisch (Indoor)           │
│  - Kann "gefaked" werden                            │
│  - Batterie-intensiv                                │
└─────────────────────────────────────────────────────┘

Praxis-Empfehlung: Die meisten Kunden nutzen eine Kombination. NFC für kritische Punkte (Serverraum, Tresor), QR für Standard-Checkpoints, GPS für Außengelände.

NFC-Kosten im Detail: Einfache NTAG213-Aufkleber ~0,50 EUR. Mit Outdoor-Gehäuse (IP67, Metall-Montage) 3-8 EUR. Vandalismus-sichere Industrietags bis 15 EUR. Bei 50 Checkpoints: 25-750 EUR einmalig - das relativiert sich schnell.


Offline-First Architektur

Das ist der technisch anspruchsvollste Teil. Die App muss vollständig offline funktionieren - nicht “degraded mode”, sondern volle Funktionalität.

SQLite als lokale Datenbank

// Flutter/Dart - Lokale Scan-Speicherung
class LocalScanRepository {
  late Database _db;

  Future<void> init() async {
    final path = await getDatabasesPath();
    _db = await openDatabase(
      join(path, 'patrol.db'),
      version: 1,
      onCreate: (db, version) async {
        await db.execute('''
          CREATE TABLE scans (
            id INTEGER PRIMARY KEY AUTOINCREMENT,
            client_scan_id TEXT NOT NULL, -- UUID für Idempotenz
            checkpoint_id TEXT NOT NULL,
            scanned_at TEXT NOT NULL,
            latitude REAL,
            longitude REAL,
            scan_type TEXT NOT NULL,
            synced INTEGER DEFAULT 0,
            sync_attempts INTEGER DEFAULT 0,
            created_at TEXT DEFAULT CURRENT_TIMESTAMP
          )
        ''');

        await db.execute('''
          CREATE INDEX idx_scans_synced ON scans(synced)
        ''');

        await db.execute('''
          CREATE TABLE checkpoints (
            id TEXT PRIMARY KEY,
            name TEXT NOT NULL,
            location TEXT,
            nfc_id TEXT,
            qr_code TEXT,
            latitude REAL,
            longitude REAL,
            radius_meters INTEGER DEFAULT 50,
            updated_at TEXT
          )
        ''');
      },
    );
  }

  Future<int> saveScan(Scan scan) async {
    return await _db.insert('scans', {
      'client_scan_id': Uuid().v4(), // Eindeutige ID für Idempotenz
      'checkpoint_id': scan.checkpointId,
      'scanned_at': scan.scannedAt.toIso8601String(),
      'latitude': scan.latitude,
      'longitude': scan.longitude,
      'scan_type': scan.scanType.name, // qr, nfc, gps
      'synced': 0,
    });
  }

  Future<List<Scan>> getUnsyncedScans() async {
    final rows = await _db.query(
      'scans',
      where: 'synced = 0 AND sync_attempts < 5',
      orderBy: 'scanned_at ASC',
      limit: 100,
    );
    return rows.map((r) => Scan.fromMap(r)).toList();
  }

  Future<void> markAsSynced(List<int> ids) async {
    // Prepared Statement - nie IDs direkt konkatenieren!
    final placeholders = List.filled(ids.length, '?').join(',');
    await _db.update(
      'scans',
      {'synced': 1},
      where: 'id IN ($placeholders)',
      whereArgs: ids,
    );
  }
}

Sync-Strategie

class SyncService {
  final LocalScanRepository _local;
  final RemoteApiClient _api;
  final ConnectivityService _connectivity;

  // Automatischer Sync wenn online
  void startAutoSync() {
    _connectivity.onConnectivityChanged.listen((status) {
      if (status == ConnectivityStatus.online) {
        syncPendingScans();
      }
    });

    // Zusätzlich: Periodischer Sync alle 5 Minuten
    Timer.periodic(Duration(minutes: 5), (_) {
      if (_connectivity.isOnline) {
        syncPendingScans();
      }
    });
  }

  Future<SyncResult> syncPendingScans() async {
    final pending = await _local.getUnsyncedScans();

    if (pending.isEmpty) {
      return SyncResult.nothingToSync;
    }

    try {
      // Batch-Upload für Effizienz
      final response = await _api.uploadScans(pending);

      if (response.success) {
        await _local.markAsSynced(pending.map((s) => s.id!).toList());

        return SyncResult.success(
          synced: pending.length,
          serverTime: response.serverTime,
        );
      }

      // Partielle Erfolge behandeln
      if (response.partialSuccess) {
        await _local.markAsSynced(response.successfulIds);
        await _local.incrementSyncAttempts(response.failedIds);
      }

      return SyncResult.partial(
        synced: response.successfulIds.length,
        failed: response.failedIds.length,
      );

    } catch (e) {
      // Offline oder Server-Fehler - später erneut versuchen
      await _local.incrementSyncAttempts(pending.map((s) => s.id!).toList());
      return SyncResult.failed(error: e.toString());
    }
  }
}

Konflikt-Behandlung

Bei Wächterkontrollen gibt es selten echte Konflikte - ein Scan ist ein Scan. Aber Stammdaten-Sync braucht Regeln:

class CheckpointSyncStrategy {
  // Server gewinnt immer bei Stammdaten
  // (Checkpoint-Namen, Positionen, etc.)
  Future<void> syncCheckpoints() async {
    final serverCheckpoints = await _api.getCheckpoints();
    final localCheckpoints = await _local.getAllCheckpoints();

    // Server-Version ist autoritativ
    for (final server in serverCheckpoints) {
      final local = localCheckpoints.firstWhereOrNull(
        (l) => l.id == server.id
      );

      if (local == null || server.updatedAt.isAfter(local.updatedAt)) {
        await _local.upsertCheckpoint(server);
      }
    }

    // Gelöschte Checkpoints entfernen
    final serverIds = serverCheckpoints.map((c) => c.id).toSet();
    for (final local in localCheckpoints) {
      if (!serverIds.contains(local.id)) {
        await _local.deleteCheckpoint(local.id);
      }
    }
  }
}

Totmann-Alarm: Leben schützen

Der Totmann-Alarm ist keine nette Zusatzfunktion - er kann Leben retten. Wenn ein Wachmann bewusstlos wird oder angegriffen wird, muss das System automatisch Alarm schlagen.

Accelerometer-basierte Erkennung

class DeadManService {
  final AccelerometerService _accelerometer;
  final LocationService _location;
  final AlertService _alert;

  Timer? _inactivityTimer;
  DateTime _lastMovement = DateTime.now();

  // Schwellwert ist pro Standort konfigurierbar!
  // Büro: niedriger (wenig Bewegung normal)
  // Patrouille: höher (ständige Bewegung erwartet)
  double _movementThreshold = 0.5; // m/s²
  Duration _inactivityTimeout = Duration(minutes: 5);

  void configure({
    required double threshold,
    required Duration timeout,
  }) {
    _movementThreshold = threshold;
    _inactivityTimeout = timeout;
  }

  void startMonitoring() {
    _accelerometer.events.listen((event) {
      final magnitude = sqrt(
        event.x * event.x +
        event.y * event.y +
        event.z * event.z
      );

      // Erdbeschleunigung abziehen (~9.8 m/s²)
      final movement = (magnitude - 9.8).abs();

      if (movement > _movementThreshold) {
        _lastMovement = DateTime.now();
        _resetTimer();
      }
    });

    _startTimer();
  }

  void _startTimer() {
    _inactivityTimer = Timer(_inactivityTimeout, () {
      _triggerPreAlarm();
    });
  }

  void _resetTimer() {
    _inactivityTimer?.cancel();
    _startTimer();
  }

  Future<void> _triggerPreAlarm() async {
    // Erst Voralarm: Vibration + Sound + Bildschirm
    // User hat 30 Sekunden zum Bestätigen
    final confirmed = await _alert.showPreAlarm(
      timeout: Duration(seconds: 30),
    );

    if (!confirmed) {
      await _triggerFullAlarm();
    }
  }

  Future<void> _triggerFullAlarm() async {
    final position = await _location.getCurrentPosition();

    // Parallel: SMS, Email, API-Call
    await Future.wait([
      _alert.sendSms(
        to: _emergencyContacts,
        message: 'TOTMANN-ALARM: Keine Reaktion von ${_userName}. '
                 'Position: ${position.latitude}, ${position.longitude}',
      ),
      _alert.sendEmail(
        subject: 'NOTFALL: Totmann-Alarm ausgelöst',
        body: _buildAlarmEmail(position),
      ),
      _api.reportEmergency(
        type: EmergencyType.deadMan,
        position: position,
        lastMovement: _lastMovement,
      ),
    ]);
  }
}

Praxis-Tipp: Der Schwellwert muss pro Einsatzort konfigurierbar sein. Ein Wachmann am Empfang bewegt sich anders als einer auf Patrouille. Zu sensibel = Fehlalarme = System wird ignoriert.

Edge Cases beachten

Accelerometer-basierte Erkennung hat Tücken:

  • Telefon auf Tisch → Totmann triggert, obwohl Mitarbeiter daneben sitzt
  • Fahrt im Fahrzeug → Vibration suggeriert Bewegung, obwohl Fahrer bewusstlos
  • Aufzug/Rolltreppe → Gleichmäßige Bewegung ohne Nutzer-Input

Lösung: Totmann ist immer Secondary. Primary ist der manuelle SOS-Button (prominent in der App). Der Accelerometer-basierte Alarm ist ein Fallback für den Fall, dass der Mitarbeiter nicht mehr selbst Alarm auslösen kann.

// SOS-Button: Primary Alarm
void onSosButtonPressed() async {
  // Sofort Alarm - kein Pre-Alarm, keine Verzögerung
  await _triggerFullAlarm(type: EmergencyType.manualSos);
}

// Totmann: Secondary (Fallback)
// Längerer Timeout, Pre-Alarm mit Bestätigung

GPS-Tracking: Batterie vs. Genauigkeit

GPS ist der größte Batterie-Killer. Die Kunst liegt im Balancieren:

Strategie 1: Intervall-basiertes Tracking

class IntervalTrackingService {
  // Konfigurierbar pro Kunde/Objekt
  Duration _trackingInterval = Duration(minutes: 5);

  void startTracking() {
    Timer.periodic(_trackingInterval, (_) async {
      if (!_isOnDuty) return;

      final position = await _location.getCurrentPosition(
        desiredAccuracy: LocationAccuracy.balanced,
        timeLimit: Duration(seconds: 10),
      );

      await _savePosition(position);
    });
  }
}

Strategie 2: Geofencing (Batterie-schonend)

class GeofenceTrackingService {
  final List<Geofence> _geofences = [];

  Future<void> setupGeofences(List<Checkpoint> checkpoints) async {
    for (final cp in checkpoints) {
      if (cp.latitude != null && cp.longitude != null) {
        await Geofencing.registerGeofence(
          Geofence(
            id: cp.id,
            latitude: cp.latitude!,
            longitude: cp.longitude!,
            radius: cp.radiusMeters.toDouble(),
            triggers: [
              GeofenceTrigger.enter,
              GeofenceTrigger.exit,
            ],
          ),
        );
      }
    }
  }

  void onGeofenceEvent(GeofenceEvent event) {
    // Nur bei Betreten/Verlassen tracken - spart massiv Batterie!
    _saveGeofenceEvent(
      checkpointId: event.geofenceId,
      action: event.trigger == GeofenceTrigger.enter ? 'enter' : 'exit',
      timestamp: DateTime.now(),
    );
  }
}

Hybrid-Ansatz (Empfehlung)

class HybridTrackingService {
  TrackingMode _mode = TrackingMode.geofence;

  void setMode(TrackingMode mode) {
    _mode = mode;

    switch (mode) {
      case TrackingMode.geofence:
        // Batterie-schonend: Nur Geofence-Events
        _intervalTracking.stop();
        _geofenceTracking.start();
        break;

      case TrackingMode.interval:
        // Mittel: Alle X Minuten
        _geofenceTracking.stop();
        _intervalTracking.start(interval: Duration(minutes: 5));
        break;

      case TrackingMode.continuous:
        // Batterie-intensiv: Echtzeit (nur wenn nötig!)
        _geofenceTracking.stop();
        _intervalTracking.start(interval: Duration(seconds: 30));
        break;
    }
  }
}

Erfahrungswert: 90% der Kunden sind mit Geofencing + 5-Minuten-Intervall zufrieden. Nur bei VIP-Schutz oder kritischen Objekten braucht man Echtzeit.

OS-Restriktionen beachten

iOS killt Background-Apps aggressiv. Geofencing funktioniert, aber Callbacks können verzögert sein. Android OEMs (Samsung, Xiaomi, Huawei) haben eigene Battery-Saver, die Location-Updates unterdrücken.

Lösung: Hybrid-Ansatz mit Fallback. Geofencing als Primär, aber periodische “Heartbeat”-Checks um zu verifizieren, dass die App noch läuft. User-Onboarding muss Battery-Optimization-Ausnahmen erklären.


Backend-Architektur für 55 Millionen Scans

Datenbank-Schema (PostgreSQL)

-- Multi-Tenant: Tenant-ID in jeder Tabelle
CREATE TABLE tenants (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    name VARCHAR(255) NOT NULL,
    settings JSONB DEFAULT '{}',
    created_at TIMESTAMPTZ DEFAULT NOW()
);

-- Kontrollpunkte
CREATE TABLE checkpoints (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    tenant_id UUID NOT NULL REFERENCES tenants(id),
    name VARCHAR(255) NOT NULL,
    location_description TEXT,
    nfc_tag_id VARCHAR(100),
    qr_code VARCHAR(100),
    latitude DECIMAL(10, 8),
    longitude DECIMAL(11, 8),
    radius_meters INTEGER DEFAULT 50,
    is_active BOOLEAN DEFAULT true,
    created_at TIMESTAMPTZ DEFAULT NOW(),

    UNIQUE(tenant_id, nfc_tag_id),
    UNIQUE(tenant_id, qr_code)
);

CREATE INDEX idx_checkpoints_tenant ON checkpoints(tenant_id);

-- Scans: Die große Tabelle
CREATE TABLE scans (
    id BIGSERIAL PRIMARY KEY,
    tenant_id UUID NOT NULL,
    client_scan_id UUID NOT NULL, -- Client-generierte ID für Idempotenz!
    checkpoint_id UUID NOT NULL,
    user_id UUID NOT NULL,
    scanned_at TIMESTAMPTZ NOT NULL,
    received_at TIMESTAMPTZ DEFAULT NOW(),
    scan_type VARCHAR(20) NOT NULL, -- 'nfc', 'qr', 'gps'
    latitude DECIMAL(10, 8),
    longitude DECIMAL(11, 8),
    device_id VARCHAR(100),
    app_version VARCHAR(20),

    -- KRITISCH: Idempotenz für Offline-Retry!
    -- Ohne das: Batch-Retry = doppelte Scans
    UNIQUE(tenant_id, client_scan_id)
) PARTITION BY RANGE (scanned_at);

-- Monatliche Partitionen für Performance
CREATE TABLE scans_2025_01 PARTITION OF scans
    FOR VALUES FROM ('2025-01-01') TO ('2025-02-01');
CREATE TABLE scans_2025_02 PARTITION OF scans
    FOR VALUES FROM ('2025-02-01') TO ('2025-03-01');
-- ... automatisiert per Cron erstellen

-- Indices auf Partitionen
CREATE INDEX idx_scans_2025_01_tenant_time
    ON scans_2025_01(tenant_id, scanned_at DESC);
CREATE INDEX idx_scans_2025_01_user_time
    ON scans_2025_01(user_id, scanned_at DESC);

Partitionierung automatisieren

<?php

class PartitionManager
{
    public function __construct(
        private readonly PDO $db
    ) {}

    // Monatlich per Cron ausführen
    public function createUpcomingPartitions(int $monthsAhead = 3): void
    {
        $current = new DateTimeImmutable('first day of this month');

        for ($i = 0; $i < $monthsAhead; $i++) {
            $month = $current->modify("+{$i} months");
            $this->createPartitionIfNotExists($month);
        }
    }

    private function createPartitionIfNotExists(DateTimeImmutable $month): void
    {
        $tableName = 'scans_' . $month->format('Y_m');
        $startDate = $month->format('Y-m-01');
        $endDate = $month->modify('+1 month')->format('Y-m-01');

        // Prüfen ob Partition existiert
        $exists = $this->db->query(
            "SELECT 1 FROM pg_tables WHERE tablename = '{$tableName}'"
        )->fetchColumn();

        if ($exists) {
            return;
        }

        $this->db->exec("
            CREATE TABLE {$tableName} PARTITION OF scans
            FOR VALUES FROM ('{$startDate}') TO ('{$endDate}')
        ");

        // Indices anlegen
        $this->db->exec("
            CREATE INDEX idx_{$tableName}_tenant_time
            ON {$tableName}(tenant_id, scanned_at DESC)
        ");

        $this->db->exec("
            CREATE INDEX idx_{$tableName}_user_time
            ON {$tableName}(user_id, scanned_at DESC)
        ");
    }

    // Alte Partitionen archivieren
    public function archiveOldPartitions(int $keepMonths = 24): void
    {
        $cutoff = (new DateTimeImmutable())
            ->modify("-{$keepMonths} months")
            ->format('Y_m');

        // Partitionen älter als cutoff -> detach + compress/archive
        // Details je nach Archivierungsstrategie
    }
}

API-Endpoint für Batch-Upload

<?php

class ScanUploadController
{
    public function upload(Request $request): JsonResponse
    {
        $scans = $request->json('scans');

        if (count($scans) > 1000) {
            return response()->json([
                'error' => 'Max 1000 scans per request'
            ], 400);
        }

        $tenantId = $request->user()->tenant_id;
        $results = ['successful' => [], 'failed' => []];

        // Checkpoints vorab laden (1 Query statt N)
        $checkpointIds = array_unique(array_column($scans, 'checkpoint_id'));
        $validCheckpoints = $this->checkpointRepo
            ->findByIds($tenantId, $checkpointIds);
        $validIds = array_flip(array_column($validCheckpoints, 'id'));

        $this->db->beginTransaction();

        try {
            // ON CONFLICT für Idempotenz - Retry ist safe!
            $stmt = $this->db->prepare(
                'INSERT INTO scans
                 (tenant_id, client_scan_id, checkpoint_id, user_id, scanned_at,
                  scan_type, latitude, longitude, device_id, app_version)
                 VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
                 ON CONFLICT (tenant_id, client_scan_id) DO NOTHING'
            );

            foreach ($scans as $index => $scan) {
                // Validierung
                if (!isset($validIds[$scan['checkpoint_id']])) {
                    $results['failed'][] = [
                        'index' => $index,
                        'error' => 'Invalid checkpoint'
                    ];
                    continue;
                }

                $stmt->execute([
                    $tenantId,
                    $scan['client_scan_id'], // UUID vom Client generiert
                    $scan['checkpoint_id'],
                    $request->user()->id,
                    $scan['scanned_at'],
                    $scan['scan_type'],
                    $scan['latitude'] ?? null,
                    $scan['longitude'] ?? null,
                    $scan['device_id'] ?? null,
                    $scan['app_version'] ?? null,
                ]);

                $results['successful'][] = $index;
            }

            $this->db->commit();

        } catch (\Throwable $e) {
            $this->db->rollBack();
            throw $e;
        }

        return response()->json([
            'success' => count($results['failed']) === 0,
            'partial_success' => count($results['successful']) > 0
                               && count($results['failed']) > 0,
            'successful_indices' => $results['successful'],
            'failed' => $results['failed'],
            'server_time' => now()->toIso8601String(),
        ]);
    }
}

Reporting: Automatische Berichte

Kunden wollen tägliche/wöchentliche Reports per Email:

<?php

class PatrolReportGenerator
{
    public function generateDailyReport(
        string $tenantId,
        DateTimeImmutable $date
    ): PatrolReport {
        $startOfDay = $date->setTime(0, 0, 0);
        $endOfDay = $date->setTime(23, 59, 59);

        // Alle Scans des Tages
        $scans = $this->scanRepo->findByDateRange(
            $tenantId,
            $startOfDay,
            $endOfDay
        );

        // Erwartete vs. tatsächliche Rundgänge
        $expectedRounds = $this->scheduleRepo->getExpectedRounds(
            $tenantId,
            $date
        );

        $completedRounds = $this->analyzeCompletedRounds($scans, $expectedRounds);

        // Auffälligkeiten
        $anomalies = $this->detectAnomalies($scans, $expectedRounds);

        return new PatrolReport(
            date: $date,
            totalScans: count($scans),
            expectedRounds: count($expectedRounds),
            completedRounds: $completedRounds,
            completionRate: $this->calculateCompletionRate(
                $completedRounds,
                count($expectedRounds)
            ),
            anomalies: $anomalies,
            scansByHour: $this->groupByHour($scans),
            scansByCheckpoint: $this->groupByCheckpoint($scans),
        );
    }

    private function detectAnomalies(array $scans, array $expected): array
    {
        $anomalies = [];

        // 1. Verpasste Checkpoints
        $scannedCheckpoints = array_unique(
            array_column($scans, 'checkpoint_id')
        );
        foreach ($expected as $round) {
            foreach ($round->requiredCheckpoints as $cp) {
                if (!in_array($cp, $scannedCheckpoints)) {
                    $anomalies[] = new Anomaly(
                        type: 'missed_checkpoint',
                        severity: 'warning',
                        message: "Checkpoint '{$cp}' nicht gescannt",
                    );
                }
            }
        }

        // 2. Ungewöhnliche Zeiten
        foreach ($scans as $scan) {
            $hour = (int)$scan->scannedAt->format('H');
            if ($hour >= 6 && $hour <= 18 && $scan->shift === 'night') {
                $anomalies[] = new Anomaly(
                    type: 'wrong_time',
                    severity: 'info',
                    message: "Scan außerhalb der Schichtzeit",
                );
            }
        }

        // 3. Zu schnelle Rundgänge (unrealistisch)
        // ... weitere Checks

        return $anomalies;
    }
}

Flutter vs. Native: Warum Cross-Platform

Nach Jahren mit separaten Android/iOS-Apps die Entscheidung für Flutter:

KriteriumNative (vorher)Flutter (jetzt)
Codebase2x (Java + Swift)1x (Dart)
Feature-ParitätOft Wochen versetztGleichzeitig
NFC-SupportJaJa (via Plugin)
Offline-DBSQLiteSQLite (sqflite)
BatterieOptimalFast gleich
Entwicklungszeit2x1x
// NFC-Scanning in Flutter
class NfcScanService {
  Future<String?> scanNfcTag() async {
    if (!await NfcManager.instance.isAvailable()) {
      return null;
    }

    final completer = Completer<String?>();

    NfcManager.instance.startSession(
      onDiscovered: (NfcTag tag) async {
        // Tag-ID extrahieren (je nach Tag-Typ)
        final nfcA = NfcA.from(tag);
        final nfcB = NfcB.from(tag);
        final nfcF = NfcF.from(tag);

        String? identifier;
        if (nfcA != null) {
          identifier = nfcA.identifier
              .map((b) => b.toRadixString(16).padLeft(2, '0'))
              .join(':');
        } else if (nfcB != null) {
          identifier = nfcB.identifier
              .map((b) => b.toRadixString(16).padLeft(2, '0'))
              .join(':');
        }
        // ... weitere Tag-Typen

        NfcManager.instance.stopSession();
        completer.complete(identifier);
      },
      onError: (error) {
        NfcManager.instance.stopSession();
        completer.complete(null);
      },
    );

    return completer.future;
  }
}

Sicherheits-Aspekte

Tamper-Evidence (nicht Tamper-Proof!)

Realitätscheck: Ein Secret im Client-Code ist nicht sicher. Root/Jailbreak + Reverse Engineering = Key extrahierbar. HMAC-Signaturen verhindern nur “casual tampering” - nicht motivierte Angreifer.

class ScanIntegrityService {
  final String _secretKey;

  // Signatur macht Manipulation *erkennbar*, nicht *unmöglich*
  String signScan(Scan scan) {
    final payload = '${scan.checkpointId}|'
                    '${scan.scannedAt.toIso8601String()}|'
                    '${scan.latitude}|${scan.longitude}|'
                    '${scan.deviceId}';

    final hmac = Hmac(sha256, utf8.encode(_secretKey));
    final digest = hmac.convert(utf8.encode(payload));

    return digest.toString();
  }
}

Mehrschichtige Verteidigung:

  • HMAC-Signatur: Tamper-evident
  • Server-seitige Plausibilität: GPS im erwarteten Bereich? Zeit realistisch?
  • Device Attestation (optional): SafetyNet/Play Integrity (Android), DeviceCheck (iOS)
  • Anomalie-Erkennung: Pattern-Analyse über Zeit

GPS-Spoofing erkennen

class LocationVerificationService {
  bool isLocationPlausible(Position position, Position? lastPosition) {
    // 1. Mock-Location Flag prüfen (Android)
    if (position.isMocked == true) {
      return false;
    }

    // 2. Unrealistische Geschwindigkeit
    if (lastPosition != null) {
      final distance = Geolocator.distanceBetween(
        lastPosition.latitude, lastPosition.longitude,
        position.latitude, position.longitude,
      );

      final timeDiff = position.timestamp!
          .difference(lastPosition.timestamp!)
          .inSeconds;

      if (timeDiff > 0) {
        final speedMps = distance / timeDiff;
        // >100 m/s = >360 km/h = unrealistisch für Fusspatrouille
        if (speedMps > 100) {
          return false;
        }
      }
    }

    // 3. Accuracy zu schlecht
    if (position.accuracy > 100) { // >100m ungenau
      // Warnen, aber nicht blockieren
    }

    return true;
  }
}

Checkliste: Wächter-Kontrollsystem entwickeln

MVP (Phase 1):

  • Offline-fähige Scan-Erfassung (QR + optional NFC)
  • SQLite für lokale Speicherung
  • Auto-Sync bei Netzwerk-Verfügbarkeit
  • Einfaches Backend mit Scan-Upload API
  • Basis-Reporting (Scans pro Tag/Checkpoint)

Erweiterung (Phase 2):

  • GPS-Tracking (Intervall-basiert)
  • Totmann-Alarm mit konfigurierbarem Threshold
  • Automatische Email-Reports
  • Multi-Tenant Architektur
  • Dispositions-Dashboard (Echtzeit)

Enterprise (Phase 3):

  • Geofencing für Batterie-Optimierung
  • Schichtplanung mit Soll/Ist-Vergleich
  • Anomalie-Erkennung
  • API für Kundensysteme
  • Revisionssichere Archivierung

Typische Fehler vermeiden

FehlerBesser
Nur Online-ModusOffline-First von Anfang an
Keine Idempotenzclient_scan_id + UNIQUE Constraint
GPS permanent anGeofencing + Intervall kombinieren
Totmann als einziger NotfallSOS-Button primary, Totmann secondary
Alle Scans in einer TabellePartitionierung nach Monat
Separate iOS/Android AppsFlutter oder React Native
QR-Codes ohne PrüfungKombination QR + GPS oder NFC
HMAC = “sicher”Tamper-evident + Server-Plausibilität

Fazit

Wächter-Kontrollsysteme sind technisch anspruchsvoll, weil sie unter widrigen Bedingungen zuverlässig funktionieren müssen: Offline, nachts, bei schlechtem Wetter, mit begrenzter Batterie.

Die wichtigsten Learnings aus 55 Millionen Scans:

  1. Offline-First ist nicht optional - es ist die Grundvoraussetzung
  2. Idempotenz von Tag 1 - client_scan_id + UNIQUE verhindert Duplikate bei Retry
  3. Batterie-Management entscheidet über Akzeptanz im Feld
  4. Totmann = Secondary, SOS-Button = Primary - Accelerometer hat zu viele Edge Cases
  5. Partitionierung früh einplanen - später migrieren ist schmerzhaft
  6. Flutter spart 50% Entwicklungszeit bei vergleichbarer Performance

Die Technik ist gelöst. Die echte Herausforderung ist das Verständnis der Branche: Wie arbeiten Sicherheitsdienste wirklich? Was sind ihre Pain Points? Darauf aufbauend lässt sich ein System entwickeln, das nicht nur funktioniert, sondern tatsächlich genutzt wird.

Carola Schulte

Über Carola Schulte

Software-Architektin mit 25+ Jahren Erfahrung. Spezialisiert auf robuste Business-Apps mit PHP/PostgreSQL, Security-by-Design und DSGVO-konforme Systeme. 1,8M+ Lines of Code in Produktion.

Projekt im Kopf?

Lassen Sie uns besprechen, wie ich Ihre Anforderungen umsetzen kann – kostenlos und unverbindlich.

Kostenloses Erstgespräch