Android self-bind protection auf nahezu MDM-Niveau ohne Device-Owner: - Device-Admin (RebreakDeviceAdminReceiver) blockt Uninstall OS-seitig, aktiv ab Boot ohne Prozess/a11y. Deaktivierung nur via 24h-Cooldown (removeDeviceAdmin in forceDisable). a11y blockt die DeviceAdminAdd-Settings-Seite (Class-Match, auf Samsung One UI per Logcat verifiziert). - Boot-Receiver (RebreakVpnBootReceiver) startet VPN+a11y nach Reboot, damit der Tamper-Lock ohne manuellen App-Start hochkommt. - Manifest-Wiring (Device-Admin-Receiver, Boot-Receiver, RECEIVE_BOOT_COMPLETED, device_admin.xml) ins with-rebreak-protection-android Config-Plugin verlagert → ueberlebt 'expo prebuild' (android/ ist gitignored). - a11y-Detection zurueck auf die funktionierende Version: zu breites 'loeschen'- Uninstall-Keyword raus (blockte halbe Settings); a11y-Label jetzt 'ReBreak Schutz'. - a11y-Deeplink behaelt den Samsung-Step-Guide (openAccessibilitySettings). Session-Frontend in diesem Batch: - Avatar-Placeholder: neutrales clarity-avatar-line SVG statt dominantem Blau. - DiGA-Milestone folgt kumulativen protectedDays (erreicht rueckfall-anfaellige User). - Dev-Build crasht nicht mehr ohne CallKit-Native-Modul. - VPN-Permission-Dialog nur noch im Bypass-Fall. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
192 lines
7.8 KiB
TypeScript
192 lines
7.8 KiB
TypeScript
/**
|
|
* CallKit (iOS) / ConnectionService (Android) Wrapper.
|
|
*
|
|
* Zentralisiert react-native-callkeep so dass stores/call.ts platform-agnostic
|
|
* bleibt. Drei Verantwortungen:
|
|
* 1. setup() einmal beim App-Start (Permission-Prompt auf Android)
|
|
* 2. displayIncomingCall() wenn Push/Realtime ein Ring signalisiert
|
|
* 3. startCall() wenn der User selbst einen Anruf initiiert
|
|
*
|
|
* Privacy für DiGA (sensible Userbasis, Art. 9 DSGVO):
|
|
* - includesCallsInRecents: false → KEIN iCloud-Sync der Anrufliste
|
|
* - handle = userId (Email-Type) → keine Telefonnummern-Style-Anzeige
|
|
* - appName "ReBreak-Audio" → erscheint im Lockscreen-Banner
|
|
*/
|
|
import { Platform, PermissionsAndroid, NativeModules } from 'react-native';
|
|
import { useNotificationPrefsStore } from '../stores/notificationPrefs';
|
|
|
|
// react-native-callkeep ist ein natives Modul. In Builds OHNE das Modul (z.B. der
|
|
// Dev-Build) crasht bereits der IMPORT, weil RNCallKeep beim Laden
|
|
// `new NativeEventEmitter(nativeModule)` mit null aufruft (Invariant Violation →
|
|
// die ganze App startet nicht). Darum laden wir das JS-Wrapper-Modul nur, wenn das
|
|
// native Modul `RNCallKeep` wirklich vorhanden ist. Sonst sind alle Call-Funktionen
|
|
// hier no-ops (Calls in diesem Build deaktiviert, App läuft trotzdem).
|
|
export const CALLKEEP_AVAILABLE = !!(NativeModules as { RNCallKeep?: unknown }).RNCallKeep;
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
let RNCallKeep: any = null;
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
let CK_CONSTANTS: any = null;
|
|
if (CALLKEEP_AVAILABLE) {
|
|
// eslint-disable-next-line @typescript-eslint/no-var-requires, global-require
|
|
const mod = require('react-native-callkeep');
|
|
RNCallKeep = mod.default ?? mod;
|
|
CK_CONSTANTS = mod.CONSTANTS;
|
|
}
|
|
|
|
export function isCallKeepAvailable(): boolean {
|
|
return CALLKEEP_AVAILABLE;
|
|
}
|
|
|
|
let didSetup = false;
|
|
|
|
export async function setupCallKeep(): Promise<void> {
|
|
if (didSetup || !RNCallKeep) return;
|
|
try {
|
|
// Stelle sicher dass die User-Prefs aus AsyncStorage geladen sind, bevor
|
|
// wir CXProviderConfiguration einfrieren. CallKit liest includesCallsInRecents
|
|
// nur beim provider-init \u2014 nachtr\u00e4gliche \u00c4nderungen erfordern App-Restart.
|
|
try {
|
|
await useNotificationPrefsStore.getState().init();
|
|
} catch {}
|
|
const callsInRecents =
|
|
useNotificationPrefsStore.getState().callsInRecents ?? false;
|
|
|
|
await RNCallKeep.setup({
|
|
ios: {
|
|
appName: 'ReBreak-Audio',
|
|
// KEIN imageName \u2192 Default-Avatar von CallKit; wir setzen den echten
|
|
// Caller-Namen via displayIncomingCall(localizedCallerName)
|
|
supportsVideo: false,
|
|
maximumCallGroups: '1',
|
|
maximumCallsPerCallGroup: '1',
|
|
// Privacy-Default: KEINE Anrufliste in iOS-Recents (= kein iCloud-Sync).
|
|
// F\u00fcr DiGA mit Suchterkrankungs-Zielgruppe Default. User kann
|
|
// in Settings opt-in (\u2192 dann erscheint ReBreak in der nativen
|
|
// \"Anrufe\"-App wie WhatsApp).
|
|
includesCallsInRecents: callsInRecents,
|
|
},
|
|
android: {
|
|
alertTitle: 'Anrufe in ReBreak zulassen',
|
|
alertDescription:
|
|
'ReBreak braucht Telefon-Konto-Berechtigung, um eingehende Sprach-Anrufe wie in Telefon-Apps anzuzeigen.',
|
|
cancelButton: 'Nicht jetzt',
|
|
okButton: 'Erlauben',
|
|
// Foreground-Service für Android 11+ Mic-Background-Access
|
|
foregroundService: {
|
|
channelId: 'org.rebreak.app.calls',
|
|
channelName: 'ReBreak-Anrufe',
|
|
notificationTitle: 'ReBreak: Anruf aktiv',
|
|
},
|
|
},
|
|
});
|
|
if (Platform.OS === 'android') {
|
|
RNCallKeep.setAvailable(true);
|
|
// Android 14+: Full-Screen-Intent für Lockscreen-Call-UI braucht extra Permission
|
|
try {
|
|
await PermissionsAndroid.request(
|
|
// @ts-ignore — RN-Types haben USE_FULL_SCREEN_INTENT evtl. noch nicht
|
|
'android.permission.USE_FULL_SCREEN_INTENT' as any,
|
|
);
|
|
} catch {}
|
|
}
|
|
// HINWEIS: KEIN endAllCalls() hier. Bei einem VoIP-Cold-Start (App wird
|
|
// DURCH den eingehenden Call gestartet) läuft setupCallKeep im selben Launch
|
|
// wie der gerade reportete Incoming-Call → endAllCalls() würde genau diesen
|
|
// legitimen Anruf killen (= Auto-Reject bei Force-Quit). Der Zombie-Cleanup
|
|
// passiert stattdessen verzögert + bedingt in useCallKeepEvents (nur wenn der
|
|
// Store nach einer Grace-Period keinen echten Call kennt).
|
|
didSetup = true;
|
|
} catch (e: any) {
|
|
console.warn('[callkeep] setup failed', e?.message ?? e);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* UUID-Generator — CallKit braucht lowercase UUID (Apple-Quirk).
|
|
* Wir mappen 1:1 callId ↔ callUUID (deterministic) damit beide Apps dieselbe ID nutzen.
|
|
*/
|
|
export function callIdToUuid(callId: string): string {
|
|
// CallId hat Format: "<timestamp>-<random8>", z.B. "1717491600000-abc12345"
|
|
// CallKit/CXProvider braucht **valides Hex-UUID** (0-9a-f) — sonst native
|
|
// crash beim startCall(). Wir mappen jedes Zeichen der callId auf einen
|
|
// Hex-Digit via charCode-modulo. Deterministic.
|
|
const hex: string[] = [];
|
|
for (let i = 0; i < callId.length && hex.length < 32; i++) {
|
|
const code = callId.charCodeAt(i);
|
|
// 2 Hex-Digits pro Zeichen → genug Material für 32 Hex-Zeichen
|
|
hex.push(((code >> 4) & 0xf).toString(16));
|
|
if (hex.length < 32) hex.push((code & 0xf).toString(16));
|
|
}
|
|
while (hex.length < 32) hex.push('0');
|
|
const clean = hex.join('').slice(0, 32);
|
|
// UUID v4 Format: xxxxxxxx-xxxx-4xxx-Yxxx-xxxxxxxxxxxx (Y ∈ {8,9,a,b})
|
|
return `${clean.slice(0, 8)}-${clean.slice(8, 12)}-4${clean.slice(13, 16)}-8${clean.slice(17, 20)}-${clean.slice(20, 32)}`;
|
|
}
|
|
|
|
export function displayIncomingCall(callId: string, callerName: string): void {
|
|
try {
|
|
const uuid = callIdToUuid(callId);
|
|
RNCallKeep.displayIncomingCall(
|
|
uuid,
|
|
callerName, // handle — wird im iOS-Lockscreen als Untertitel angezeigt
|
|
callerName, // localizedCallerName — als Titel
|
|
'generic', // handleType: kein Phone-Number-Format
|
|
false, // hasVideo
|
|
);
|
|
} catch (e: any) {
|
|
console.warn('[callkeep] displayIncomingCall failed', e?.message ?? e);
|
|
}
|
|
}
|
|
|
|
export function startOutgoingCall(callId: string, calleeName: string): void {
|
|
// Android: NICHT aufrufen \u2014 `RNCallKeep.startCall` triggert
|
|
// `telecomManager.placeCall` was die System-Telefon-Wahl-UI \u00f6ffnet. F\u00fcr
|
|
// In-App-WebRTC-Calls v\u00f6llig falsch (User sieht "Telefon w\u00e4hlt\u2026" statt
|
|
// unserem /call-Screen). iOS-CallKit macht das richtig (Audio-Session
|
|
// setup, keine native UI weil supportsHolding=false und wir keine
|
|
// CXEndCallAction triggern bis User auflegt).
|
|
if (Platform.OS !== 'ios') return;
|
|
try {
|
|
const uuid = callIdToUuid(callId);
|
|
RNCallKeep.startCall(uuid, calleeName, calleeName, 'generic', false);
|
|
} catch (e: any) {
|
|
console.warn('[callkeep] startCall failed', e?.message ?? e);
|
|
}
|
|
}
|
|
|
|
export function reportConnected(callId: string): void {
|
|
try {
|
|
const uuid = callIdToUuid(callId);
|
|
if (Platform.OS === 'android') {
|
|
RNCallKeep.setCurrentCallActive(uuid);
|
|
}
|
|
} catch {}
|
|
}
|
|
|
|
export function endCall(callId: string): void {
|
|
try {
|
|
const uuid = callIdToUuid(callId);
|
|
RNCallKeep.endCall(uuid);
|
|
} catch {}
|
|
}
|
|
|
|
export function reportEnded(
|
|
callId: string,
|
|
reason: 'failed' | 'declined' | 'unanswered' | 'ended' = 'ended',
|
|
): void {
|
|
try {
|
|
const uuid = callIdToUuid(callId);
|
|
const reasonCode =
|
|
reason === 'failed'
|
|
? CK_CONSTANTS.END_CALL_REASONS.FAILED
|
|
: reason === 'unanswered'
|
|
? CK_CONSTANTS.END_CALL_REASONS.UNANSWERED
|
|
: reason === 'declined'
|
|
? CK_CONSTANTS.END_CALL_REASONS.MISSED
|
|
: CK_CONSTANTS.END_CALL_REASONS.REMOTE_ENDED;
|
|
RNCallKeep.reportEndCallWithUUID(uuid, reasonCode);
|
|
} catch {}
|
|
}
|
|
|
|
export { RNCallKeep };
|