Wächter-Kontrollsysteme: Security-Apps für Sicherheitsdienste
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
Was Wächter-Kontrollsysteme leisten müssen
Sicherheitsdienste haben spezielle Anforderungen, die sich von typischen Business-Apps unterscheiden:
| Anforderung | Warum kritisch |
|---|---|
| Offline-Fähigkeit | Tiefgaragen, Keller, Industriegelände - oft kein Netz |
| Batterie-Effizienz | 8-12 Stunden Schicht mit einem Akku |
| Robuste Hardware | Outdoor, Nacht, Regen, Kälte |
| Echtzeit für Disponenten | Wer ist wo? Ist der Rundgang vollständig? |
| Notfall-Funktionen | Totmann-Alarm wenn Mitarbeiter nicht reagiert |
| Revisionssicherheit | Nachweis 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:
| Kriterium | Native (vorher) | Flutter (jetzt) |
|---|---|---|
| Codebase | 2x (Java + Swift) | 1x (Dart) |
| Feature-Parität | Oft Wochen versetzt | Gleichzeitig |
| NFC-Support | Ja | Ja (via Plugin) |
| Offline-DB | SQLite | SQLite (sqflite) |
| Batterie | Optimal | Fast gleich |
| Entwicklungszeit | 2x | 1x |
// 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
| Fehler | Besser |
|---|---|
| Nur Online-Modus | Offline-First von Anfang an |
| Keine Idempotenz | client_scan_id + UNIQUE Constraint |
| GPS permanent an | Geofencing + Intervall kombinieren |
| Totmann als einziger Notfall | SOS-Button primary, Totmann secondary |
| Alle Scans in einer Tabelle | Partitionierung nach Monat |
| Separate iOS/Android Apps | Flutter oder React Native |
| QR-Codes ohne Prüfung | Kombination 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:
- Offline-First ist nicht optional - es ist die Grundvoraussetzung
- Idempotenz von Tag 1 - client_scan_id + UNIQUE verhindert Duplikate bei Retry
- Batterie-Management entscheidet über Akzeptanz im Feld
- Totmann = Secondary, SOS-Button = Primary - Accelerometer hat zu viele Edge Cases
- Partitionierung früh einplanen - später migrieren ist schmerzhaft
- 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.
Ü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