diff --git a/apps/rebreak-native/app.config.ts b/apps/rebreak-native/app.config.ts
index 04c68c5..b21cf95 100644
--- a/apps/rebreak-native/app.config.ts
+++ b/apps/rebreak-native/app.config.ts
@@ -36,7 +36,7 @@ export default ({ config }: ConfigContext): ExpoConfig => ({
ios: {
supportsTablet: true,
bundleIdentifier: MAIN_BUNDLE,
- buildNumber: "76",
+ buildNumber: "84",
// Apple Sign-In Entitlement — Pflicht für expo-apple-authentication nativen
// signInAsync()-Flow. Ohne flag generiert Expo's prebuild den
// com.apple.developer.applesignin-Entitlement nicht in die .entitlements.
@@ -62,7 +62,13 @@ export default ({ config }: ConfigContext): ExpoConfig => ({
android: {
package: "org.rebreak.app",
- versionCode: 59,
+ versionCode: 64,
+ // Firebase / FCM-v1-Credentials: Pflicht ab Expo SDK 53 für Android-Push.
+ // Enthält client-config für beide Packages (org.rebreak.app + .dev) und
+ // ist NICHT geheim (API-Key per Package-Signing-Fingerprint restricted) —
+ // darf laut Firebase-Docs ins Repo. Datei wird via auto-config-plugin in
+ // android/app/build.gradle als com.google.gms.google-services applied.
+ googleServicesFile: "./google-services.json",
adaptiveIcon: {
// Foreground muss in der ~66%-Safe-Zone bleiben (Launcher-Mask clippt den
// Außenring) → adaptive-foreground.png ist das Logo auf transparentem
diff --git a/apps/rebreak-native/app/(app)/_layout.tsx b/apps/rebreak-native/app/(app)/_layout.tsx
index 7d2f995..1894986 100644
--- a/apps/rebreak-native/app/(app)/_layout.tsx
+++ b/apps/rebreak-native/app/(app)/_layout.tsx
@@ -108,7 +108,9 @@ export default function AppLayout() {
await AsyncStorage.setItem(ONBOARDING_COMPLETED_KEY, '1');
return;
}
- setOnboardingVisible(true);
+ // Auto-Popup-Onboarding-Modal entfernt (verwirrte den User, Duplikat zum
+ // Blocker-Stepper). Setup läuft jetzt ausschließlich über die Blocker-Seite.
+ // Die Self-heal-Re-Arm-Logik oben bleibt aktiv.
}, []);
useEffect(() => {
@@ -224,8 +226,21 @@ export default function AppLayout() {
async function enforceProtection() {
if (cancelled || rearmInFlightRef.current) return;
try {
- // Self-Heal: wenn der Schutz an sein soll der VpnService aber tot ist
- // (Reinstall / OS-Kill) → neu starten, bevor wir den State lesen.
+ // Erst State lesen OHNE reconcile. reconcileVpn() kann das VPN-Profil neu
+ // erstellen (→ iOS-VPN-Dialog) und lief bisher bei JEDEM App-Start/Foreground,
+ // auch während des Setups → aggressiver Dialog. Jetzt: reconcile/re-assert NUR
+ // wenn der Schutz aktiv sein SOLL, aber gerade umgangen wurde
+ // (phase === 'recoveringFromBypass'). Im Setup-/Aus-Zustand: nichts tun → kein Dialog.
+ const pre = await protection.getCombinedState();
+ if (cancelled) return;
+ if (pre.phase !== 'recoveringFromBypass') {
+ bypassNotifiedRef.current = false;
+ return;
+ }
+ // Schutz soll an sein, VPN aber tot/gelöscht → jetzt self-healen. War es nur
+ // OS-gekillt (Profil existiert), startet reconcile still neu (kein Dialog).
+ // Hat der User das Profil GELÖSCHT, wird es neu erstellt (Dialog) — genau
+ // dann soll er auch kommen.
await protection.reconcileVpn();
if (cancelled) return;
const state = await protection.getCombinedState();
@@ -309,13 +324,6 @@ export default function AppLayout() {
onConsented={markConsented}
/>
)}
- {Platform.OS === 'android' && (
-
- )}
sub.remove();
}, [refresh, syncWebContent]);
+ // ─── Android Device-Admin ────────────────────────────────────────────
+
+ async function handleRequestDeviceAdmin() {
+ try {
+ const result = await protection.requestDeviceAdmin();
+ if (!result.launched) {
+ Alert.alert(t('blocker.android_admin_failed_title'), t('blocker.android_admin_failed_msg'));
+ } else {
+ await refresh();
+ }
+ return result;
+ } catch (e: any) {
+ Alert.alert(t('blocker.activation_failed_title'), e?.message ?? t('common.unknown_error'));
+ return { launched: false };
+ }
+ }
+
// ─── Activate-Handler pro Layer ──────────────────────────────────────
async function handleActivateUrlFilter() {
@@ -325,6 +343,32 @@ export default function BlockerScreen() {
nefilterActive={nefilterActive}
onPressSettings={openDetails}
/>
+ ) : Platform.OS === 'ios' && FAMILY_CONTROLS_AVAILABLE && !mdmManaged && !nefilterActive ? (
+ /* iOS unsupervised: geführter 3-Schritt-Setup-Flow */
+
+ ) : Platform.OS === 'android' ? (
+
) : (
- {Platform.OS === 'android' ? (
-
- ) : FAMILY_CONTROLS_AVAILABLE && !mdmManaged ? (
- /* iOS App-Lock nur zeigen wenn (a) das Family-Controls-
- Entitlement im Build aktiv ist (Distribution-Builds ohne
- Apple-Approval → ausblenden statt sandbox-blockiertes
- Feature, NSCocoaErrorDomain:4099) UND (b) wir nicht
- MDM-managed sind (dann ist der per-App-FC-Authorization-
- Toggle UI-irrelevant — Schutz läuft via MDM-VPN, App-Lock
- wird MDM-seitig durch nicht-entfernbares Profile enforced). */
-
- ) : null}
-
)}
- {/* iOS Layer 3 — Screen Time Passcode: nur für unsupervised (VPN+FC), NICHT für MDM/NEFilter-Pfad */}
- {Platform.OS === 'ios' && FAMILY_CONTROLS_AVAILABLE && !mdmManaged && !nefilterActive && (lockedIn || appDeletionLockActive) && !screentimeConfirmed && (
-
- // iOS hat keinen Deep-Link zum Passcode-Dialog — wir öffnen Screen-Time-Hauptseite.
- // Beide URL-Formate probieren (iOS-Versionen variieren).
- Linking.openURL('App-Prefs:SCREEN_TIME')
- .catch(() => Linking.openURL('App-Prefs:root=SCREEN_TIME'))
- .catch(() => Linking.openSettings())
- }
- onConfirm={handleScreentimeConfirm}
- colors={colors}
- t={t}
- />
- )}
-
{/* CooldownBanner */}
{state.cooldown.active && (
void;
- onOpenSettings: () => void;
- onConfirm: () => void;
+type SetupFlowProps = {
+ familyControlsActive: boolean;
+ screentimeCode: string | null;
+ screentimeConfirmed: boolean;
+ screentimeSaving: boolean;
+ urlFilterActive: boolean;
+ onActivateFamilyControls: () => Promise<{ enabled: boolean; error?: string }>;
+ onGenerateScreentimeCode: () => void;
+ onConfirmScreentime: () => void;
+ onActivateUrlFilter: () => Promise<{ enabled: boolean; error?: string }>;
colors: ReturnType;
t: ReturnType['t'];
-}) {
- if (confirmed) {
- return (
-
-
- 🔐
-
- {t('blocker.screentime_confirmed_title')}
-
-
-
- {t('blocker.screentime_confirmed_desc')}
-
-
- );
- }
+};
+
+function IosUnsupervisedSetupFlow({
+ familyControlsActive,
+ screentimeCode,
+ screentimeConfirmed,
+ screentimeSaving,
+ urlFilterActive,
+ onActivateFamilyControls,
+ onGenerateScreentimeCode,
+ onConfirmScreentime,
+ onActivateUrlFilter,
+ colors,
+ t,
+}: SetupFlowProps) {
+ const step1Done = familyControlsActive;
+ const step2Done = screentimeConfirmed;
+ const step3Done = urlFilterActive;
return (
-
-
- 🔒
-
- {t('blocker.screentime_title')}
-
-
-
- {t('blocker.screentime_desc')}
-
-
- {!code ? (
-
-
- {t('blocker.screentime_generate_cta')}
-
-
- ) : (
-
- {/* Code display */}
-
-
- {t('blocker.screentime_code_label')}
-
-
- {code}
-
-
-
- {/* Step-by-step instructions */}
-
- {[
- t('blocker.screentime_step1'),
- t('blocker.screentime_step2'),
- t('blocker.screentime_step3'),
- ].map((step, i) => (
-
- {step}
-
- ))}
-
- {t('blocker.screentime_step_note')}
-
-
-
- {/* Open Settings */}
-
-
- {t('blocker.screentime_open_settings_cta')}
-
-
-
- {/* Confirm */}
-
- {saving
- ?
- : {t('blocker.screentime_confirm_cta')}
- }
-
-
- )}
+
+
+
+
+
+ );
+}
+
+function SetupStep1({
+ done,
+ onActivate,
+ colors,
+ t,
+}: {
+ done: boolean;
+ onActivate: () => Promise<{ enabled: boolean; error?: string }>;
+ colors: ReturnType;
+ t: ReturnType['t'];
+}) {
+ const [busy, setBusy] = useState(false);
+
+ async function handlePress() {
+ if (done || busy) return;
+ setBusy(true);
+ try { await onActivate(); } finally { setBusy(false); }
+ }
+
+ return (
+
+ {!done && (
+
+ {busy
+ ?
+ : {t('blocker.setup_step1_cta')}
+ }
+
+ )}
+
+ );
+}
+
+function SetupStep2({
+ unlocked,
+ code,
+ confirmed,
+ saving,
+ onGenerate,
+ onConfirm,
+ colors,
+ t,
+}: {
+ unlocked: boolean;
+ code: string | null;
+ confirmed: boolean;
+ saving: boolean;
+ onGenerate: () => void;
+ onConfirm: () => void;
+ colors: ReturnType;
+ t: ReturnType['t'];
+}) {
+ return (
+
+ {unlocked && !confirmed && (
+
+ {!code ? (
+
+
+ {t('blocker.screentime_generate_cta')}
+
+
+ ) : (
+
+
+
+ {t('blocker.screentime_code_label')}
+
+
+ {code}
+
+
+
+ {[
+ t('blocker.screentime_step1'),
+ t('blocker.screentime_step2'),
+ t('blocker.screentime_step3'),
+ ].map((step, i) => (
+
+ {step}
+
+ ))}
+
+ {t('blocker.screentime_step_note')}
+
+
+
+ {saving
+ ?
+ : {t('blocker.screentime_confirm_cta')}
+ }
+
+
+ )}
+
+ )}
+
+ );
+}
+
+function SetupStep3({
+ unlocked,
+ done,
+ onActivate,
+ colors,
+ t,
+}: {
+ unlocked: boolean;
+ done: boolean;
+ onActivate: () => Promise<{ enabled: boolean; error?: string }>;
+ colors: ReturnType;
+ t: ReturnType['t'];
+}) {
+ const [busy, setBusy] = useState(false);
+
+ async function handlePress() {
+ if (done || busy) return;
+ setBusy(true);
+ try { await onActivate(); } finally { setBusy(false); }
+ }
+
+ return (
+
+ {unlocked && !done && (
+
+
+
+
+ {t('blocker.setup_step3_warning')}
+
+
+
+ {busy
+ ?
+ : {t('blocker.setup_step3_cta')}
+ }
+
+
+ )}
+
+ );
+}
+
+// ─── Android 3-Step Setup Flow ───────────────────────────────────────────────
+
+type AndroidSetupFlowProps = {
+ vpnActive: boolean;
+ // = a11y-Service enabled UND Tamper-Lock armed (appDeletionLock). Nur "enabled"
+ // reicht NICHT — der a11y-Service ist ohne armed-Flag komplett passiv.
+ accessibilityLocked: boolean;
+ deviceAdminActive: boolean;
+ onActivateVpn: () => Promise<{ enabled: boolean; error?: string }>;
+ onActivateAccessibility: () => Promise;
+ onRequestDeviceAdmin: () => Promise<{ launched: boolean }>;
+ colors: ReturnType;
+ t: ReturnType['t'];
+};
+
+function AndroidSetupFlow({
+ vpnActive,
+ accessibilityLocked,
+ deviceAdminActive,
+ onActivateVpn,
+ onActivateAccessibility,
+ onRequestDeviceAdmin,
+ colors,
+ t,
+}: AndroidSetupFlowProps) {
+ // Reihenfolge KRITISCH: VPN → Geräteadmin → a11y. a11y MUSS zuletzt, weil der
+ // Tamper-Lock (sobald armed) die Geräteadmin-Seite blockt — sonst kann der
+ // User den Admin gar nicht mehr aktivieren.
+ const vpnDone = vpnActive;
+ const adminDone = deviceAdminActive;
+ const a11yDone = accessibilityLocked;
+
+ return (
+
+
+ {/* Display-Step 2 = Geräteadmin (Komponente AndroidStep3, i18n android_step3_*). */}
+
+ {/* Display-Step 3 = a11y / ReBreak-Schutz (Komponente AndroidStep2, i18n android_step2_*). */}
+
+
+ );
+}
+
+function AndroidStep1({
+ done,
+ onActivate,
+ colors,
+ t,
+}: {
+ done: boolean;
+ onActivate: () => Promise<{ enabled: boolean; error?: string }>;
+ colors: ReturnType;
+ t: ReturnType['t'];
+}) {
+ const [busy, setBusy] = useState(false);
+
+ async function handlePress() {
+ if (done || busy) return;
+ setBusy(true);
+ try { await onActivate(); } finally { setBusy(false); }
+ }
+
+ return (
+
+ {!done && (
+
+ {busy
+ ?
+ : {t('blocker.android_step1_cta')}
+ }
+
+ )}
+
+ );
+}
+
+function AndroidStep2({
+ unlocked,
+ done,
+ onActivate,
+ colors,
+ t,
+}: {
+ unlocked: boolean;
+ done: boolean;
+ onActivate: () => Promise;
+ colors: ReturnType;
+ t: ReturnType['t'];
+}) {
+ const [busy, setBusy] = useState(false);
+
+ async function handlePress() {
+ if (busy) return;
+ setBusy(true);
+ try { await onActivate(); } finally { setBusy(false); }
+ }
+
+ return (
+
+ {unlocked && !done && (
+
+
+ {[
+ t('blocker.android_step2_instruction1'),
+ t('blocker.android_step2_instruction2'),
+ t('blocker.android_step2_instruction3'),
+ ].map((line, i) => (
+
+ {line}
+
+ ))}
+
+
+ {busy
+ ?
+ : {t('blocker.android_step2_cta')}
+ }
+
+
+ {t('blocker.android_step2_note')}
+
+
+ )}
+
+ );
+}
+
+function AndroidStep3({
+ unlocked,
+ done,
+ onRequestAdmin,
+ colors,
+ t,
+}: {
+ unlocked: boolean;
+ done: boolean;
+ onRequestAdmin: () => Promise<{ launched: boolean }>;
+ colors: ReturnType;
+ t: ReturnType['t'];
+}) {
+ const [busy, setBusy] = useState(false);
+
+ async function handlePress() {
+ if (done || busy) return;
+ setBusy(true);
+ try { await onRequestAdmin(); } finally { setBusy(false); }
+ }
+
+ return (
+
+ {unlocked && !done && (
+
+
+
+
+ {t('blocker.android_step3_warning')}
+
+
+
+ {busy
+ ?
+ : {t('blocker.android_step3_cta')}
+ }
+
+
+ )}
+
+ );
+}
+
+function SetupStepCard({
+ stepNumber,
+ title,
+ subtitle,
+ done,
+ unlocked,
+ lockedHint,
+ colors,
+ children,
+}: {
+ stepNumber: number;
+ title: string;
+ subtitle: string;
+ done: boolean;
+ unlocked: boolean;
+ lockedHint?: string;
+ colors: ReturnType;
+ children?: ReactNode;
+}) {
+ const borderColor = done ? '#86efac' : unlocked ? colors.border : colors.border;
+ const cardBg = done ? '#f0fdf4' : colors.surface;
+ const numberBg = done ? '#dcfce7' : unlocked ? colors.surfaceElevated : colors.surfaceElevated;
+ const numberColor = done ? colors.success : unlocked ? colors.text : colors.textMuted;
+ const titleColor = done ? colors.success : unlocked ? colors.text : colors.textMuted;
+
+ return (
+
+
+
+ {done
+ ?
+ : {stepNumber}
+ }
+
+
+ {title}
+ {!!subtitle && (
+ {subtitle}
+ )}
+
+ {done && }
+
+ {lockedHint && !done && (
+
+
+ {lockedHint}
+
+ )}
+ {children}
);
}
diff --git a/apps/rebreak-native/app/_layout.tsx b/apps/rebreak-native/app/_layout.tsx
index f4f636c..0f3de1f 100644
--- a/apps/rebreak-native/app/_layout.tsx
+++ b/apps/rebreak-native/app/_layout.tsx
@@ -274,14 +274,6 @@ function RootLayoutInner() {
animation: 'slide_from_right',
}}
/>
-
= 30 && !bannerDismissed;
+ const showDigaBanner = (coverage?.protectedDays ?? 0) >= 30 && !bannerDismissed;
const demoComplete = !withdrawnAt && isDemographicsComplete(demographics);
function scrollToDemographics() {
diff --git a/apps/rebreak-native/assets/clarity--avatar-line.svg b/apps/rebreak-native/assets/clarity--avatar-line.svg
new file mode 100644
index 0000000..be1c7dd
--- /dev/null
+++ b/apps/rebreak-native/assets/clarity--avatar-line.svg
@@ -0,0 +1,6 @@
+
diff --git a/apps/rebreak-native/components/AppHeader.tsx b/apps/rebreak-native/components/AppHeader.tsx
index 08989ac..0806061 100644
--- a/apps/rebreak-native/components/AppHeader.tsx
+++ b/apps/rebreak-native/components/AppHeader.tsx
@@ -121,7 +121,7 @@ export function AppHeader({ notifCount, showBack, title }: Props = {}) {
alignItems: 'center',
justifyContent: 'center',
overflow: 'hidden',
- backgroundColor: showAvatarImage ? colors.surfaceElevated : colors.brandOrange,
+ backgroundColor: showAvatarImage ? colors.surfaceElevated : colors.avatarPlaceholder,
}}
>
{showAvatarImage ? (
diff --git a/apps/rebreak-native/components/DiGaMilestoneModal.tsx b/apps/rebreak-native/components/DiGaMilestoneModal.tsx
index ec43bcd..9dd8474 100644
--- a/apps/rebreak-native/components/DiGaMilestoneModal.tsx
+++ b/apps/rebreak-native/components/DiGaMilestoneModal.tsx
@@ -6,6 +6,7 @@ import { Ionicons } from '@expo/vector-icons';
import { useQuery } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
import { useMe } from '../hooks/useMe';
+import { useProtectionCoverage } from '../hooks/useProfileData';
import { apiFetch } from '../lib/api';
import { FormSheet } from './FormSheet';
import { useColors } from '../lib/theme';
@@ -23,6 +24,7 @@ export function DiGaMilestoneModal() {
const colors = useColors();
const router = useRouter();
const { me } = useMe();
+ const { coverage } = useProtectionCoverage();
const [milestone, setMilestone] = useState(null);
const scaleAnim = useRef(new Animated.Value(0.8)).current;
@@ -36,7 +38,11 @@ export function DiGaMilestoneModal() {
useEffect(() => {
if (!me || demo === undefined) return;
- const streak = me.streak ?? 0;
+ // Kumulative Schutz-Tage (fällt NIE auf 0 zurück, anders als die
+ // zusammenhängende Streak-Phase). So erreicht jeder engagierte User
+ // irgendwann einen Milestone — auch rückfall-anfällige, deren DiGA-Daten
+ // am wertvollsten sind.
+ const protectedDays = coverage?.protectedDays ?? 0;
const demographicsComplete = !!(demo?.birthYear);
if (demographicsComplete) return; // already filled → never show
@@ -44,7 +50,7 @@ export function DiGaMilestoneModal() {
// Find highest milestone reached and not yet shown
for (let i = MILESTONES.length - 1; i >= 0; i--) {
const m = MILESTONES[i];
- if (streak < m) continue;
+ if (protectedDays < m) continue;
const shown = await AsyncStorage.getItem(storageKey(me.id, m));
if (!shown) {
setMilestone(m);
@@ -52,7 +58,7 @@ export function DiGaMilestoneModal() {
}
}
})();
- }, [me?.id, me?.streak, demo]);
+ }, [me?.id, coverage?.protectedDays, demo]);
useEffect(() => {
if (milestone !== null) {
diff --git a/apps/rebreak-native/components/UserAvatar.tsx b/apps/rebreak-native/components/UserAvatar.tsx
index ba6469c..077161e 100644
--- a/apps/rebreak-native/components/UserAvatar.tsx
+++ b/apps/rebreak-native/components/UserAvatar.tsx
@@ -1,10 +1,17 @@
import { useState } from 'react';
import { View, Text } from 'react-native';
import { Image } from 'expo-image';
+import { SvgXml } from 'react-native-svg';
import { useOnlineUsers } from '../hooks/useOnlineUsers';
import { resolveAvatar } from '../lib/resolveAvatar';
import { useColors } from '../lib/theme';
+// clarity--avatar-line (assets/clarity--avatar-line.svg) als Inline-XML — kein
+// svg-transformer im Projekt, daher via . currentColor wird über die
+// `color`-Prop getintet.
+const AVATAR_PLACEHOLDER_SVG =
+ '';
+
type Size = 'sm' | 'md' | 'lg' | 'xl';
type Props = {
@@ -103,20 +110,17 @@ export function UserAvatar({
width: s.avatar,
height: s.avatar,
borderRadius: radius,
- backgroundColor: colors.brandOrange,
+ backgroundColor: colors.avatarPlaceholder,
alignItems: 'center',
justifyContent: 'center',
}}
>
-
- {initials}
-
+
)}
diff --git a/apps/rebreak-native/components/profile/ProfileHeader.tsx b/apps/rebreak-native/components/profile/ProfileHeader.tsx
index 14b89fe..48aec54 100644
--- a/apps/rebreak-native/components/profile/ProfileHeader.tsx
+++ b/apps/rebreak-native/components/profile/ProfileHeader.tsx
@@ -100,7 +100,7 @@ export function ProfileHeader({
alignItems: 'center',
justifyContent: 'center',
overflow: 'hidden',
- backgroundColor: showImage ? colors.surfaceElevated : colors.brandOrange,
+ backgroundColor: showImage ? colors.surfaceElevated : colors.avatarPlaceholder,
}}
>
{showImage ? (
diff --git a/apps/rebreak-native/hooks/useCallKeepEvents.ts b/apps/rebreak-native/hooks/useCallKeepEvents.ts
index a29d9dc..2fb6cfb 100644
--- a/apps/rebreak-native/hooks/useCallKeepEvents.ts
+++ b/apps/rebreak-native/hooks/useCallKeepEvents.ts
@@ -10,9 +10,11 @@
import { useEffect } from 'react';
import { AppState, Platform } from 'react-native';
import { useRouter } from 'expo-router';
-import RNCallKeep from 'react-native-callkeep';
import { useCallStore } from '../stores/call';
-import { setupCallKeep } from '../lib/callkit';
+// RNCallKeep kommt aus lib/callkit (guarded — null wenn das native Modul fehlt,
+// z.B. im Dev-Build). NICHT direkt aus 'react-native-callkeep' importieren, sonst
+// crasht der Import. Alle Aufrufe hier per optional chaining absichern.
+import { setupCallKeep, RNCallKeep } from '../lib/callkit';
// VoIP-PushKit (iOS only) — Payload-Empfang um peer-Info in den Store zu
// hydrieren, BEVOR User in der CallKit-UI auf "Annehmen" tippt.
@@ -31,6 +33,23 @@ export function useCallKeepEvents() {
useEffect(() => {
void setupCallKeep();
+ // Zombie-Cleanup: verzögert + BEDINGT. Ein Phantom-Call aus einer Vorsession
+ // (CallKit ohne sauberes endCall) soll beim Launch verschwinden — ABER bei
+ // einem VoIP-Cold-Start startet der eingehende Call selbst die App. Dann
+ // hydriert onVoipNotification/receiveIncoming den Store binnen ~1-2s auf
+ // 'incoming' — diesen legitimen Call dürfen wir NICHT beenden (sonst
+ // Auto-Reject bei Force-Quit). Darum erst nach Grace-Period aufräumen und NUR
+ // wenn der Store keinen echten Call kennt (idle/ended).
+ const zombieCleanup = setTimeout(() => {
+ const st = useCallStore.getState().status;
+ if (st === 'idle' || st === 'ended') {
+ console.log('[callkeep] launch zombie-cleanup → endAllCalls (no live call in store)');
+ try { RNCallKeep.endAllCalls(); } catch {}
+ } else {
+ console.log('[callkeep] launch zombie-cleanup SKIPPED (store status=' + st + ')');
+ }
+ }, 5000);
+
// VoIP-Push-Payload (iOS) → Store hydrieren mit caller-Info, damit
// acceptCall() den richtigen peer kennt. Der CallKit-UI-Show ist bereits
// in AppDelegate.swift (reportNewIncomingCall) erfolgt.
@@ -108,16 +127,17 @@ export function useCallKeepEvents() {
if (st.muted !== muted) st.toggleMute();
};
- RNCallKeep.addEventListener('answerCall', onAnswer);
- RNCallKeep.addEventListener('endCall', onEnd);
- RNCallKeep.addEventListener('didPerformSetMutedCallAction', onMuted);
+ RNCallKeep?.addEventListener('answerCall', onAnswer);
+ RNCallKeep?.addEventListener('endCall', onEnd);
+ RNCallKeep?.addEventListener('didPerformSetMutedCallAction', onMuted);
// didActivateAudioSession kommt nach CallKit-Audio-Activation — wir nutzen
// das (noch) nicht aktiv, weil WebRTC + InCallManager das selber regeln.
return () => {
- RNCallKeep.removeEventListener('answerCall');
- RNCallKeep.removeEventListener('endCall');
- RNCallKeep.removeEventListener('didPerformSetMutedCallAction');
+ clearTimeout(zombieCleanup);
+ RNCallKeep?.removeEventListener('answerCall');
+ RNCallKeep?.removeEventListener('endCall');
+ RNCallKeep?.removeEventListener('didPerformSetMutedCallAction');
if (RNVoipPushNotification) {
RNVoipPushNotification.removeEventListener('notification');
RNVoipPushNotification.removeEventListener('didLoadWithEvents');
diff --git a/apps/rebreak-native/lib/callkit.ts b/apps/rebreak-native/lib/callkit.ts
index fe6277d..fa6a80c 100644
--- a/apps/rebreak-native/lib/callkit.ts
+++ b/apps/rebreak-native/lib/callkit.ts
@@ -12,14 +12,35 @@
* - handle = userId (Email-Type) → keine Telefonnummern-Style-Anzeige
* - appName "ReBreak-Audio" → erscheint im Lockscreen-Banner
*/
-import { Platform, PermissionsAndroid } from 'react-native';
-import RNCallKeep, { CONSTANTS as CK_CONSTANTS } from 'react-native-callkeep';
+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 {
- if (didSetup) return;
+ if (didSetup || !RNCallKeep) return;
try {
// Stelle sicher dass die User-Prefs aus AsyncStorage geladen sind, bevor
// wir CXProviderConfiguration einfrieren. CallKit liest includesCallsInRecents
@@ -68,11 +89,12 @@ export async function setupCallKeep(): Promise {
);
} catch {}
}
- // Zombie-Cleanup beim App-Start: Ein vorheriger Call der nie sauber via
- // endCall beendet wurde (z.B. Phantom nach Accept-ohne-Join) bleibt sonst in
- // iOS-CallKit ewig „aktiv". setupCallKeep läuft nur einmal pro Launch
- // (didSetup-Guard), daher kein Risiko einen legitimen Live-Call zu killen.
- try { RNCallKeep.endAllCalls(); } 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);
diff --git a/apps/rebreak-native/lib/protection.ts b/apps/rebreak-native/lib/protection.ts
index 1d62a4f..266bd0a 100644
--- a/apps/rebreak-native/lib/protection.ts
+++ b/apps/rebreak-native/lib/protection.ts
@@ -234,6 +234,31 @@ export const protection = {
return RebreakProtection.activateFamilyControls();
},
+ /**
+ * Android-only: löst den System-Dialog zum Aktivieren des Geräteadministrators aus.
+ * Gibt {launched:true} zurück wenn der Dialog gestartet wurde. Das tatsächliche
+ * Ergebnis (accept/deny) liest die UI via AppState-Return + `isDeviceAdminActive`.
+ */
+ async requestDeviceAdmin(): Promise<{ launched: boolean }> {
+ if (Platform.OS !== "android") return { launched: false };
+ try {
+ return await RebreakProtection.requestDeviceAdmin();
+ } catch (e) {
+ console.warn("[protection] requestDeviceAdmin failed:", e);
+ return { launched: false };
+ }
+ },
+
+ /** Android-only: Deaktiviert den Device-Admin-Receiver. Wird im Cooldown-Resolve aufgerufen. */
+ async removeDeviceAdmin(): Promise {
+ if (Platform.OS !== "android") return;
+ try {
+ await RebreakProtection.removeDeviceAdmin();
+ } catch (e) {
+ console.warn("[protection] removeDeviceAdmin failed:", e);
+ }
+ },
+
/** Schaltet alle Layer ab + disarmed den Tamper-Lock. NUR aufrufen wenn JS-Layer Cooldown verifiziert. */
async forceDisable() {
console.log("[protection] forceDisable() — disarm tamper + native disable");
@@ -246,6 +271,16 @@ export const protection = {
} catch (e) {
console.warn("[protection] disarmTamperLock failed:", e);
}
+ // Device-Admin MUSS vor disable() entfernt werden — sonst kann die App nach dem
+ // Cooldown nicht deinstalliert werden (aktiver Device-Admin blockt Deinstallation).
+ // Das ist die Safety-Auflage: legitimer Ausstieg via Cooldown MUSS funktionieren.
+ if (Platform.OS === "android") {
+ try {
+ await RebreakProtection.removeDeviceAdmin();
+ } catch (e) {
+ console.warn("[protection] removeDeviceAdmin in forceDisable failed:", e);
+ }
+ }
const res = await RebreakProtection.disable();
console.log("[protection] native disable returned:", res);
return res;
diff --git a/apps/rebreak-native/lib/theme.ts b/apps/rebreak-native/lib/theme.ts
index 5a9f25f..04bacf7 100644
--- a/apps/rebreak-native/lib/theme.ts
+++ b/apps/rebreak-native/lib/theme.ts
@@ -23,6 +23,9 @@ export type ColorScheme = {
border: string;
text: string;
textMuted: string;
+ /** Neutraler Grauton für Initialen-Avatare ohne Foto (iOS-Kontakt-Look:
+ graue Scheibe, weiße Initialen). Bewusst NICHT brandOrange (=#007AFF, blau). */
+ avatarPlaceholder: string;
brandOrange: string;
brandBlue: string;
success: string;
@@ -39,6 +42,7 @@ const light: ColorScheme = {
border: '#e5e5e5',
text: '#0a0a0a',
textMuted: '#737373',
+ avatarPlaceholder: '#8E8E93',
brandOrange: '#007AFF',
brandBlue: '#0e1f3a',
success: '#16a34a',
@@ -55,6 +59,7 @@ const dark: ColorScheme = {
border: '#38383a',
text: '#ffffff',
textMuted: '#8e8e93',
+ avatarPlaceholder: '#48484A',
brandOrange: '#007AFF',
brandBlue: '#0e1f3a',
success: '#30d158',
diff --git a/apps/rebreak-native/locales/de.json b/apps/rebreak-native/locales/de.json
index c50cd69..e5ab0fa 100644
--- a/apps/rebreak-native/locales/de.json
+++ b/apps/rebreak-native/locales/de.json
@@ -333,7 +333,7 @@
"screentime_generate_cta": "Code generieren",
"screentime_code_label": "Dein Code — merke ihn dir",
"screentime_step1": "① Tippe 'Bildschirmzeit öffnen'",
- "screentime_step2": "② Tippe auf 'Code festlegen'",
+ "screentime_step2": "② Ganz nach unten scrollen, dann 'Bildschirmzeit-Code verwenden' tippen",
"screentime_step3": "③ Gib den Code oben ein",
"screentime_step_note": "Noch nicht aktiv? Erst 'Bildschirmzeit aktivieren' tippen.",
"screentime_open_settings_cta": "Bildschirmzeit öffnen ↗",
@@ -458,7 +458,42 @@
"protection_stat_method_mdm": "MDM",
"mdm_info_hint": "Im MDM-Modus läuft der Schutz dauerhaft. Bei Bedarf bitte deinen Trustee kontaktieren oder via Apple Configurator (USB) deaktivieren.",
"mdm_deactivate_title": "MDM-Modus: Deaktivierung extern",
- "mdm_deactivate_body": "Im MDM-Modus kann der Schutz nur über deinen Trustee oder via Apple Configurator (USB) deaktiviert werden. Der Cooldown-Pfad steht in diesem Modus nicht zur Verfügung."
+ "mdm_deactivate_body": "Im MDM-Modus kann der Schutz nur über deinen Trustee oder via Apple Configurator (USB) deaktiviert werden. Der Cooldown-Pfad steht in diesem Modus nicht zur Verfügung.",
+ "setup_progress_label": "Schritt %{current} von 3",
+ "setup_step1_title": "App-Sperre aktivieren",
+ "setup_step1_subtitle_pending": "Verhindert, dass du ReBreak oder den Filter im Impuls abschaltest",
+ "setup_step1_subtitle_done": "App-Sperre ist aktiv",
+ "setup_step1_cta": "App-Sperre aktivieren",
+ "setup_step2_title": "Bildschirmzeit-Code setzen",
+ "setup_step2_subtitle_pending": "Sichert den App-Lock gegen Deinstallation ab",
+ "setup_step2_subtitle_done": "Bildschirmzeit ist gesperrt",
+ "setup_step3_title": "URL-Filter aktivieren",
+ "setup_step3_subtitle_pending": "Blockt 208.000+ Gambling-Seiten system-weit",
+ "setup_step3_subtitle_done": "Filter läuft, du bist geschützt",
+ "setup_step3_cta": "URL-Filter aktivieren",
+ "setup_step3_warning": "Sobald aktiv kannst du den Schutz nur über einen 24-Stunden-Cooldown abschalten. Das ist gewollt.",
+ "setup_complete_title": "Schutz vollständig aktiv",
+ "setup_complete_subtitle": "Alle drei Schutz-Ebenen sind eingerichtet.",
+ "setup_step_locked_hint": "Erst Schritt %{step} abschließen",
+ "android_step1_title": "VPN aktivieren",
+ "android_step1_subtitle_pending": "Blockt 208.000+ Gambling-Seiten system-weit via DNS-Filter",
+ "android_step1_subtitle_done": "VPN-Filter läuft",
+ "android_step1_cta": "VPN aktivieren",
+ "android_step2_title": "ReBreak - Schutz",
+ "android_step2_subtitle_pending": "",
+ "android_step2_subtitle_done": "Bedienungshilfe aktiv",
+ "android_step2_instruction1": "① Tippe unten auf „Bedienungshilfen öffnen“",
+ "android_step2_instruction2": "② Wähle ReBreak in der Liste",
+ "android_step2_instruction3": "③ Schalte den Regler ein",
+ "android_step2_cta": "Bedienungshilfen öffnen",
+ "android_step2_note": "Wähle ReBreak in der Liste und schalte den Regler ein. Tippe danach erneut auf den Button, um den Schutz zu aktivieren.",
+ "android_step3_title": "Geräteadministrator aktivieren",
+ "android_step3_subtitle_pending": "Schließt die Boot-Lücke: Schutz ist sofort nach Neustart aktiv",
+ "android_step3_subtitle_done": "Geräteadministrator aktiv — Schutz vollständig",
+ "android_step3_cta": "Geräteadministrator aktivieren",
+ "android_step3_warning": "Sobald aktiv kannst du den Schutz nur über einen 24-Stunden-Cooldown abschalten. Das ist gewollt.",
+ "android_admin_failed_title": "Geräteadministrator konnte nicht aktiviert werden",
+ "android_admin_failed_msg": "Bitte den Dialog bestätigen wenn er erscheint."
},
"onboarding": {
"lyra": {
diff --git a/apps/rebreak-native/locales/en.json b/apps/rebreak-native/locales/en.json
index d0a18a3..332ddfd 100644
--- a/apps/rebreak-native/locales/en.json
+++ b/apps/rebreak-native/locales/en.json
@@ -333,7 +333,7 @@
"screentime_generate_cta": "Generate code",
"screentime_code_label": "Your code — remember it",
"screentime_step1": "① Tap \"Open Screen Time\" below",
- "screentime_step2": "② Tap \"Use Screen Time Passcode\"",
+ "screentime_step2": "② Scroll to the bottom, then tap \"Use Screen Time Passcode\"",
"screentime_step3": "③ Enter the code shown above",
"screentime_step_note": "Not set up yet? Tap \"Turn On Screen Time\" first.",
"screentime_open_settings_cta": "Open Screen Time ↗",
@@ -458,7 +458,42 @@
"protection_stat_method_mdm": "MDM",
"mdm_info_hint": "In MDM mode protection runs permanently. To disable, contact your trustee or use Apple Configurator (USB).",
"mdm_deactivate_title": "MDM mode: deactivation via external means",
- "mdm_deactivate_body": "In MDM mode protection can only be disabled by your trustee or via Apple Configurator (USB). The cooldown flow is not available in this mode."
+ "mdm_deactivate_body": "In MDM mode protection can only be disabled by your trustee or via Apple Configurator (USB). The cooldown flow is not available in this mode.",
+ "setup_progress_label": "Step %{current} of 3",
+ "setup_step1_title": "Activate app lock",
+ "setup_step1_subtitle_pending": "Stops you from switching off ReBreak or the filter on impulse",
+ "setup_step1_subtitle_done": "App lock is active",
+ "setup_step1_cta": "Activate app lock",
+ "setup_step2_title": "Set Screen Time passcode",
+ "setup_step2_subtitle_pending": "Prevents uninstall by locking Screen Time",
+ "setup_step2_subtitle_done": "Screen Time is locked",
+ "setup_step3_title": "Activate URL filter",
+ "setup_step3_subtitle_pending": "Blocks 208,000+ gambling sites system-wide",
+ "setup_step3_subtitle_done": "Filter running, you are protected",
+ "setup_step3_cta": "Activate URL filter",
+ "setup_step3_warning": "Once active, you can only disable protection through a 24-hour cooldown. That's by design.",
+ "setup_complete_title": "Protection fully active",
+ "setup_complete_subtitle": "All three protection layers are set up.",
+ "setup_step_locked_hint": "Complete step %{step} first",
+ "android_step1_title": "Activate VPN",
+ "android_step1_subtitle_pending": "Blocks 208,000+ gambling sites system-wide via DNS filter",
+ "android_step1_subtitle_done": "VPN filter running",
+ "android_step1_cta": "Activate VPN",
+ "android_step2_title": "ReBreak Protection",
+ "android_step2_subtitle_pending": "",
+ "android_step2_subtitle_done": "Accessibility service active",
+ "android_step2_instruction1": "① Tap \"Open accessibility settings\" below",
+ "android_step2_instruction2": "② Select ReBreak in the list",
+ "android_step2_instruction3": "③ Switch the toggle on",
+ "android_step2_cta": "Open accessibility settings",
+ "android_step2_note": "Find ReBreak in the list and switch it on. Then tap the button again to finish activating the protection.",
+ "android_step3_title": "Activate device administrator",
+ "android_step3_subtitle_pending": "Closes the boot gap: protection is active immediately after restart",
+ "android_step3_subtitle_done": "Device administrator active — protection complete",
+ "android_step3_cta": "Activate device administrator",
+ "android_step3_warning": "Once active, you can only disable protection through a 24-hour cooldown. That's by design.",
+ "android_admin_failed_title": "Device administrator could not be activated",
+ "android_admin_failed_msg": "Please confirm the dialog when it appears."
},
"onboarding": {
"lyra": {
diff --git a/apps/rebreak-native/modules/rebreak-protection/android/src/main/java/expo/modules/rebreakprotection/RebreakProtectionModule.kt b/apps/rebreak-native/modules/rebreak-protection/android/src/main/java/expo/modules/rebreakprotection/RebreakProtectionModule.kt
index 5b9d8e8..fa90af9 100644
--- a/apps/rebreak-native/modules/rebreak-protection/android/src/main/java/expo/modules/rebreakprotection/RebreakProtectionModule.kt
+++ b/apps/rebreak-native/modules/rebreak-protection/android/src/main/java/expo/modules/rebreakprotection/RebreakProtectionModule.kt
@@ -2,6 +2,7 @@ package expo.modules.rebreakprotection
import android.accessibilityservice.AccessibilityServiceInfo
import android.app.Activity
+import android.app.admin.DevicePolicyManager
import android.content.ComponentName
import android.content.Context
import android.content.Intent
@@ -19,6 +20,7 @@ import android.view.accessibility.AccessibilityManager
import android.widget.Button
import android.widget.LinearLayout
import android.widget.TextView
+import expo.modules.rebreakprotection.admin.RebreakDeviceAdminReceiver
import expo.modules.kotlin.Promise
import expo.modules.kotlin.exception.CodedException
import expo.modules.kotlin.modules.Module
@@ -412,6 +414,61 @@ class RebreakProtectionModule : Module() {
)
}
+ // ─── Device-Admin ──────────────────────────────────────────────────────────
+
+ /**
+ * Loest den System-Dialog aus, der den User fragt ob er ReBreak als
+ * Geraeteadministrator aktivieren will. Der Dialog laeuft asynchron —
+ * nach Return hat der User accept/deny noch nicht getippt. JS prueft
+ * das Ergebnis via AppState-Return + isDeviceAdminActive.
+ *
+ * Gibt {launched:true} zurueck wenn der Intent gestartet wurde, unabhaengig
+ * vom User-Entscheid. {launched:false} wenn keine Activity verfuegbar war.
+ */
+ AsyncFunction("requestDeviceAdmin") { promise: Promise ->
+ val activity = appContext.currentActivity
+ ?: return@AsyncFunction promise.reject(
+ CodedException("no_activity", "Activity nicht verfuegbar", null)
+ )
+ val adminComponent = ComponentName(activity, RebreakDeviceAdminReceiver::class.java)
+ val intent = Intent(DevicePolicyManager.ACTION_ADD_DEVICE_ADMIN).apply {
+ putExtra(DevicePolicyManager.EXTRA_DEVICE_ADMIN, adminComponent)
+ putExtra(
+ DevicePolicyManager.EXTRA_ADD_EXPLANATION,
+ activity.getString(R.string.device_admin_explanation),
+ )
+ }
+ return@AsyncFunction if (intent.resolveActivity(activity.packageManager) != null) {
+ activity.startActivity(intent)
+ promise.resolve(mapOf("launched" to true))
+ } else {
+ promise.resolve(mapOf("launched" to false))
+ }
+ }
+
+ AsyncFunction("isDeviceAdminActive") {
+ val ctx = requireContext()
+ mapOf("active" to isDeviceAdminEnabled(ctx))
+ }
+
+ /**
+ * Entfernt den Device-Admin-Receiver. MUSS im Cooldown-Resolve-Pfad (forceDisable)
+ * aufgerufen werden, damit der User nach abgelaufenem Cooldown die App
+ * deinstallieren kann — ein aktiver Device-Admin blockiert Deinstallation.
+ * Safety-Auflage: legitimer Ausstieg MUSS immer moeglich sein.
+ */
+ AsyncFunction("removeDeviceAdmin") {
+ val ctx = requireContext()
+ val dpm = ctx.getSystemService(Context.DEVICE_POLICY_SERVICE) as DevicePolicyManager
+ val adminComponent = ComponentName(ctx, RebreakDeviceAdminReceiver::class.java)
+ if (dpm.isAdminActive(adminComponent)) {
+ dpm.removeActiveAdmin(adminComponent)
+ Log.i(TAG, "Device-Admin entfernt")
+ }
+ sendLayerChange()
+ mapOf("removed" to true)
+ }
+
/**
* Reconcile: Wenn `filter_enabled == true` (User WILL Schutz) der VpnService
* aber nicht läuft (z.B. nach App-Reinstall oder Low-Memory-Kill den
@@ -756,11 +813,22 @@ class RebreakProtectionModule : Module() {
// zeigt die UI „verriegelt" ohne dass der User je rauskommt (Desync-Fall:
// `tamper_armed` noch true, aber `filter_enabled` schon false).
"tamperLock" to (isTamperLockArmed(ctx) && isEnabledFlag(ctx)),
+ "deviceAdmin" to isDeviceAdminEnabled(ctx),
"blocklistCount" to count,
"blocklistLastSyncAt" to lastSyncAt,
)
}
+ private fun isDeviceAdminEnabled(ctx: Context): Boolean {
+ val dpm = ctx.getSystemService(Context.DEVICE_POLICY_SERVICE) as DevicePolicyManager
+ val adminComponent = ComponentName(ctx, RebreakDeviceAdminReceiver::class.java)
+ return try {
+ dpm.isAdminActive(adminComponent)
+ } catch (_: Exception) {
+ false
+ }
+ }
+
private fun activateSuccessResult(): Map = mapOf(
"allLayersOn" to false,
"missingLayers" to listOf("accessibility", "tamperLock"),
diff --git a/apps/rebreak-native/modules/rebreak-protection/android/src/main/java/expo/modules/rebreakprotection/accessibility/RebreakAccessibilityService.kt b/apps/rebreak-native/modules/rebreak-protection/android/src/main/java/expo/modules/rebreakprotection/accessibility/RebreakAccessibilityService.kt
index 58cbfdf..db06166 100644
--- a/apps/rebreak-native/modules/rebreak-protection/android/src/main/java/expo/modules/rebreakprotection/accessibility/RebreakAccessibilityService.kt
+++ b/apps/rebreak-native/modules/rebreak-protection/android/src/main/java/expo/modules/rebreakprotection/accessibility/RebreakAccessibilityService.kt
@@ -386,7 +386,7 @@ class RebreakAccessibilityService : AccessibilityService() {
* Müssen lowercase sein (Text wird vor Match lowercased).
*/
val HIGH_CONFIDENCE_KEYWORDS = listOf(
- "rebreak \u2014 schutz", // DE-Summary "ReBreak — Schutz"
+ "rebreak schutz", "rebreak \u2014 schutz", // DE-Summary "ReBreak — Schutz"
"rebreak \u2014 protection", // EN/FR-Summary "ReBreak — Protection"
"rebreak \u2014 الحماية", // AR-Summary "ReBreak — الحماية"
"sichert den schutz", // legacy DE-Summary
@@ -395,6 +395,10 @@ class RebreakAccessibilityService : AccessibilityService() {
"rebreak deinstallieren",
"rebreak entfernen",
"rebreak löschen",
+ // Geräteadmin-Deaktivierungs-Seite (Detail zeigt unsere Beschreibung)
+ "rebreak administrator",
+ "rebreak geräteadministrator",
+ "rebreak device administrator",
)
val DANGEROUS_ACTIVITY_PATTERNS = listOf(
@@ -408,6 +412,12 @@ class RebreakAccessibilityService : AccessibilityService() {
// App-Deinstallieren-Dialoge + App-Info-Pages
"Uninstaller", // com.android.packageinstaller.UninstallerActivity
"InstalledAppDetails", // App-Info-Page (kann zu uninstall führen)
+ // Geräteadmin-Seite: User könnte Admin-Recht entziehen um Uninstall-
+ // Schutz zu umgehen. Class-Match greift (Samsung nutzt AOSP-Klasse
+ // com.android.settings...deviceadmin.DeviceAdminAdd — per Logcat belegt).
+ "DeviceAdminSettings",
+ "DeviceAdminAdd",
+ "ActiveAdmin",
"ApplicationDetails", // AOSP
"ApplicationDetail", // Samsung OneUI: ApplicationDetailActivity (kein 's')
@@ -468,10 +478,11 @@ class RebreakAccessibilityService : AccessibilityService() {
val DANGER_ACTION_KEYWORDS_UNINSTALL = listOf(
"deinstallieren",
"uninstall",
- "entfernen",
- "remove",
- "löschen",
- "delete",
+ // "entfernen"/"remove"/"löschen"/"delete" RAUS: viel zu generisch.
+ // "löschen" (Cache/Daten/Verlauf löschen) steht auf zig Settings-Seiten,
+ // die auch "rebreak" führen (ReBreak taucht in Sicherheit/Apps/VPN auf)
+ // → blockte halbe Settings (reason=uninstall:löschen). Uninstall-Schutz
+ // läuft ohnehin OS-seitig über den Device-Admin.
"erzwingen",
"force stop",
)
diff --git a/apps/rebreak-native/modules/rebreak-protection/android/src/main/java/expo/modules/rebreakprotection/admin/RebreakDeviceAdminReceiver.kt b/apps/rebreak-native/modules/rebreak-protection/android/src/main/java/expo/modules/rebreakprotection/admin/RebreakDeviceAdminReceiver.kt
new file mode 100644
index 0000000..382bfeb
--- /dev/null
+++ b/apps/rebreak-native/modules/rebreak-protection/android/src/main/java/expo/modules/rebreakprotection/admin/RebreakDeviceAdminReceiver.kt
@@ -0,0 +1,33 @@
+package expo.modules.rebreakprotection.admin
+
+import android.app.admin.DeviceAdminReceiver
+import android.content.Context
+import android.content.Intent
+import android.util.Log
+
+/**
+ * Minimal Device-Admin-Receiver.
+ *
+ * Zweck: Allein das Vorhandensein eines aktiven Device-Admins verhindert, dass
+ * das System die App direkt deinstallieren kann. Keine weiteren Policies werden
+ * angewendet — kein Passwort-Zwang, kein Wipe, kein Kamera-Block.
+ *
+ * Deaktivierung geschieht ausschliesslich via `removeDeviceAdmin()` im
+ * RebreakProtectionModule, das aus dem JS-Cooldown-Resolve-Pfad (forceDisable)
+ * aufgerufen wird. Dadurch ist sichergestellt, dass der User nach einem
+ * abgelaufenen Cooldown die App deinstallieren kann.
+ */
+class RebreakDeviceAdminReceiver : DeviceAdminReceiver() {
+
+ override fun onEnabled(context: Context, intent: Intent) {
+ Log.i(TAG, "Device-Admin aktiviert")
+ }
+
+ override fun onDisabled(context: Context, intent: Intent) {
+ Log.i(TAG, "Device-Admin deaktiviert")
+ }
+
+ companion object {
+ private const val TAG = "RebreakDeviceAdmin"
+ }
+}
diff --git a/apps/rebreak-native/modules/rebreak-protection/android/src/main/res/values-ar/strings.xml b/apps/rebreak-native/modules/rebreak-protection/android/src/main/res/values-ar/strings.xml
index 69fb6e9..aa93b0d 100644
--- a/apps/rebreak-native/modules/rebreak-protection/android/src/main/res/values-ar/strings.xml
+++ b/apps/rebreak-native/modules/rebreak-protection/android/src/main/res/values-ar/strings.xml
@@ -12,4 +12,5 @@
اضغط على «ReBreak — الحماية».
فعّل المفتاح العلوي.
اضغط على «سماح».
+ يحتاج ReBreak إلى صلاحيات مسؤول الجهاز لضمان حماية التطبيق فور إعادة التشغيل. لا تُطلب صلاحيات إضافية (لا إجبار على كلمة مرور، ولا مسح عن بُعد). يمكنك إيقاف الحماية دائماً عبر فترة التهدئة في التطبيق.
diff --git a/apps/rebreak-native/modules/rebreak-protection/android/src/main/res/values-en/strings.xml b/apps/rebreak-native/modules/rebreak-protection/android/src/main/res/values-en/strings.xml
index 3680992..b8e35ea 100644
--- a/apps/rebreak-native/modules/rebreak-protection/android/src/main/res/values-en/strings.xml
+++ b/apps/rebreak-native/modules/rebreak-protection/android/src/main/res/values-en/strings.xml
@@ -12,4 +12,5 @@
Tap \u201CReBreak \u2014 Protection\u201D.
Turn on the top switch.
Tap \u201CAllow\u201D.
+ ReBreak needs device administrator rights so the app is protected immediately after a restart \u2014 before other protection mechanisms start up. No additional admin rights are requested (no password enforcement, remote wipe, etc.). You can always end protection via the 24h cooldown in the app.
diff --git a/apps/rebreak-native/modules/rebreak-protection/android/src/main/res/values-fr/strings.xml b/apps/rebreak-native/modules/rebreak-protection/android/src/main/res/values-fr/strings.xml
index e67b44e..86d364c 100644
--- a/apps/rebreak-native/modules/rebreak-protection/android/src/main/res/values-fr/strings.xml
+++ b/apps/rebreak-native/modules/rebreak-protection/android/src/main/res/values-fr/strings.xml
@@ -12,4 +12,5 @@
Touche \u00AB\u00A0ReBreak \u2014 Protection\u00A0\u00BB.
Active l\'interrupteur du haut.
Touche \u00AB\u00A0Autoriser\u00A0\u00BB.
+ ReBreak a besoin des droits d\'administrateur pour que l\'application soit prot\u00E9g\u00E9e imm\u00E9diatement apr\u00E8s un red\u00E9marrage. Aucun droit suppl\u00E9mentaire n\'est demand\u00E9 (pas de mot de passe forc\u00E9, pas d\'effacement \u00E0 distance, etc.). Tu peux toujours arr\u00EAter la protection via la p\u00E9riode de refroidissement dans l\'app.
diff --git a/apps/rebreak-native/modules/rebreak-protection/android/src/main/res/values/strings.xml b/apps/rebreak-native/modules/rebreak-protection/android/src/main/res/values/strings.xml
index 3e63468..fc29513 100644
--- a/apps/rebreak-native/modules/rebreak-protection/android/src/main/res/values/strings.xml
+++ b/apps/rebreak-native/modules/rebreak-protection/android/src/main/res/values/strings.xml
@@ -1,7 +1,7 @@
Sichert deinen ReBreak-Schutz gegen impulsives Abschalten ab: Solange der Schutz aktiv ist, kann das ReBreak-VPN nicht in den Einstellungen deaktiviert und die App nicht deinstalliert werden. Das Blockieren von Glücksspielseiten selbst übernimmt das VPN — diese Berechtigung sichert es nur. Du kannst den Schutz jederzeit über die Abkühlphase in der App beenden.
- ReBreak \u2014 Schutz
+ ReBreak Schutz
ReBreak Schritt-für-Schritt
Zurück
Weiter
@@ -9,7 +9,8 @@
Schließen
Aktiviere "Über anderen Apps einblenden" für ReBreak.
Tippe auf \u201EInstallierte Dienste\u201C.
- Tippe auf \u201EReBreak \u2014 Schutz\u201C.
+ Tippe auf \u201EReBreak Schutz\u201C.
Schalte den oberen Schalter ein.
Tippe auf \u201EZulassen\u201C.
+ ReBreak wird als Ger\u00E4teadministrator ben\u00F6tigt, damit die App nach einem Neustart sofort gesch\u00FCtzt ist \u2014 bevor andere Schutzmechanismen hochfahren. Es werden keine weiteren Administrator-Rechte (Passwort-Zwang, Fernl\u00F6schung etc.) beantragt. Du kannst den Schutz jederzeit \u00FCber die 24h-Abk\u00FChlphase in der App beenden.
diff --git a/apps/rebreak-native/modules/rebreak-protection/android/src/main/res/xml/device_admin.xml b/apps/rebreak-native/modules/rebreak-protection/android/src/main/res/xml/device_admin.xml
new file mode 100644
index 0000000..a1a87db
--- /dev/null
+++ b/apps/rebreak-native/modules/rebreak-protection/android/src/main/res/xml/device_admin.xml
@@ -0,0 +1,11 @@
+
+
+
+
+
diff --git a/apps/rebreak-native/modules/rebreak-protection/ios/RebreakContentFilter/Info.plist b/apps/rebreak-native/modules/rebreak-protection/ios/RebreakContentFilter/Info.plist
index a744651..d8a154d 100644
--- a/apps/rebreak-native/modules/rebreak-protection/ios/RebreakContentFilter/Info.plist
+++ b/apps/rebreak-native/modules/rebreak-protection/ios/RebreakContentFilter/Info.plist
@@ -19,7 +19,7 @@
CFBundleShortVersionString
0.3.13
CFBundleVersion
- 76
+ 84
NSExtension
NSExtensionPointIdentifier
diff --git a/apps/rebreak-native/modules/rebreak-protection/ios/RebreakPacketTunnelExtension/Info.plist b/apps/rebreak-native/modules/rebreak-protection/ios/RebreakPacketTunnelExtension/Info.plist
index e89310d..45e7eb3 100644
--- a/apps/rebreak-native/modules/rebreak-protection/ios/RebreakPacketTunnelExtension/Info.plist
+++ b/apps/rebreak-native/modules/rebreak-protection/ios/RebreakPacketTunnelExtension/Info.plist
@@ -19,7 +19,7 @@
CFBundleShortVersionString
0.3.13
CFBundleVersion
- 76
+ 84
NSExtension
NSExtensionPointIdentifier
diff --git a/apps/rebreak-native/modules/rebreak-protection/ios/RebreakURLFilterExtension/Info.plist b/apps/rebreak-native/modules/rebreak-protection/ios/RebreakURLFilterExtension/Info.plist
index d7ab593..4f0530e 100644
--- a/apps/rebreak-native/modules/rebreak-protection/ios/RebreakURLFilterExtension/Info.plist
+++ b/apps/rebreak-native/modules/rebreak-protection/ios/RebreakURLFilterExtension/Info.plist
@@ -19,7 +19,7 @@
CFBundleShortVersionString
0.3.13
CFBundleVersion
- 76
+ 84
EXAppExtensionAttributes
EXExtensionPointIdentifier
diff --git a/apps/rebreak-native/modules/rebreak-protection/src/RebreakProtection.types.ts b/apps/rebreak-native/modules/rebreak-protection/src/RebreakProtection.types.ts
index bc49e5e..4dd31dc 100644
--- a/apps/rebreak-native/modules/rebreak-protection/src/RebreakProtection.types.ts
+++ b/apps/rebreak-native/modules/rebreak-protection/src/RebreakProtection.types.ts
@@ -38,6 +38,10 @@ export type DeviceLayers = {
vpn?: boolean;
accessibility?: boolean;
tamperLock?: boolean;
+ /** Android-only. True wenn RebreakDeviceAdminReceiver aktiver Geraeteadministrator ist.
+ * Schliesst die Boot-Luecke: ohne aktiven Admin hat die App nach Neustart
+ * kurz kein Schutz-Lock bis der AccessibilityService startet. */
+ deviceAdmin?: boolean;
// Shared
blocklistCount: number;
blocklistLastSyncAt: string | null;
diff --git a/apps/rebreak-native/modules/rebreak-protection/src/RebreakProtectionModule.ts b/apps/rebreak-native/modules/rebreak-protection/src/RebreakProtectionModule.ts
index c513c13..2cfdc42 100644
--- a/apps/rebreak-native/modules/rebreak-protection/src/RebreakProtectionModule.ts
+++ b/apps/rebreak-native/modules/rebreak-protection/src/RebreakProtectionModule.ts
@@ -234,6 +234,23 @@ declare class RebreakProtectionModule extends NativeModule;
+
+ /**
+ * Android: loest den System-Dialog aus der den User fragt ob er ReBreak
+ * als Geraeteadministrator aktivieren will. Dialog laeuft asynchron.
+ * Ergebnis via AppState-Return + isDeviceAdminActive pruefen.
+ */
+ requestDeviceAdmin(): Promise<{ launched: boolean }>;
+
+ /** Android: prueft ob der DeviceAdminReceiver aktuell aktiver Admin ist. */
+ isDeviceAdminActive(): Promise<{ active: boolean }>;
+
+ /**
+ * Android: entfernt den Device-Admin-Receiver. MUSS im Cooldown-Resolve-Pfad
+ * aufgerufen werden, damit der User nach Cooldown-Ablauf die App deinstallieren
+ * kann. Safety-Auflage: legitimer Ausstieg MUSS immer moeglich sein.
+ */
+ removeDeviceAdmin(): Promise<{ removed: boolean }>;
}
export default requireNativeModule('RebreakProtection');
diff --git a/apps/rebreak-native/modules/rebreak-protection/src/RebreakProtectionModule.web.ts b/apps/rebreak-native/modules/rebreak-protection/src/RebreakProtectionModule.web.ts
index 271d029..46ce024 100644
--- a/apps/rebreak-native/modules/rebreak-protection/src/RebreakProtectionModule.web.ts
+++ b/apps/rebreak-native/modules/rebreak-protection/src/RebreakProtectionModule.web.ts
@@ -89,6 +89,15 @@ class RebreakProtectionModuleWeb extends NativeModule {
async reconcileUrlFilter() {
return { recreated: false };
}
+ async requestDeviceAdmin() {
+ return { launched: false };
+ }
+ async isDeviceAdminActive() {
+ return { active: false };
+ }
+ async removeDeviceAdmin() {
+ return { removed: false };
+ }
async probeContentFilter() {
return { enabled: false, error: 'web_stub' };
diff --git a/apps/rebreak-native/plugins/with-rebreak-protection-android.js b/apps/rebreak-native/plugins/with-rebreak-protection-android.js
index 3c14747..09d4153 100644
--- a/apps/rebreak-native/plugins/with-rebreak-protection-android.js
+++ b/apps/rebreak-native/plugins/with-rebreak-protection-android.js
@@ -38,6 +38,10 @@ const VPN_SERVICE_CLASS =
'expo.modules.rebreakprotection.vpn.RebreakVpnService';
const A11Y_SERVICE_CLASS =
'expo.modules.rebreakprotection.accessibility.RebreakAccessibilityService';
+const ADMIN_RECEIVER_CLASS =
+ 'expo.modules.rebreakprotection.admin.RebreakDeviceAdminReceiver';
+const BOOT_RECEIVER_CLASS =
+ 'expo.modules.rebreakprotection.vpn.RebreakVpnBootReceiver';
// ─── 1) tools-Namespace auf ──────────────────────────────────────
@@ -117,12 +121,95 @@ function ensureAccessibilityService(manifest) {
});
}
+// ─── 3b) RECEIVE_BOOT_COMPLETED permission ──────────────────────────────────
+
+function ensureBootPermission(manifest) {
+ if (!manifest.manifest['uses-permission']) {
+ manifest.manifest['uses-permission'] = [];
+ }
+ const PERM = 'android.permission.RECEIVE_BOOT_COMPLETED';
+ const exists = manifest.manifest['uses-permission'].some(
+ (p) => p.$ && p.$['android:name'] === PERM,
+ );
+ if (!exists) {
+ manifest.manifest['uses-permission'].push({ $: { 'android:name': PERM } });
+ }
+}
+
+// ─── 3c) Device-Admin- + Boot-Receiver ──────────────────────────────────────
+// Device-Admin: macht die App OS-seitig nicht-direkt-deinstallierbar — greift ab
+// Boot, ohne dass Prozess/a11y laufen müssen. Deaktivierung nur via 24h-Cooldown
+// im App-Code (removeDeviceAdmin).
+// Boot-Receiver: startet VPN+a11y nach Reboot/Package-Replace neu, damit der
+// Tamper-Lock nicht erst nach manuellem App-Start hochkommt.
+
+function ensureReceivers(manifest) {
+ const application = AndroidConfig.Manifest.getMainApplicationOrThrow(manifest);
+ if (!application.receiver) application.receiver = [];
+
+ if (
+ !application.receiver.some(
+ (r) => r.$ && r.$['android:name'] === ADMIN_RECEIVER_CLASS,
+ )
+ ) {
+ application.receiver.push({
+ $: {
+ 'android:name': ADMIN_RECEIVER_CLASS,
+ 'android:permission': 'android.permission.BIND_DEVICE_ADMIN',
+ 'android:exported': 'true',
+ },
+ 'meta-data': [
+ {
+ $: {
+ 'android:name': 'android.app.device_admin',
+ 'android:resource': '@xml/device_admin',
+ },
+ },
+ ],
+ 'intent-filter': [
+ {
+ action: [
+ {
+ $: { 'android:name': 'android.app.action.DEVICE_ADMIN_ENABLED' },
+ },
+ ],
+ },
+ ],
+ });
+ }
+
+ if (
+ !application.receiver.some(
+ (r) => r.$ && r.$['android:name'] === BOOT_RECEIVER_CLASS,
+ )
+ ) {
+ application.receiver.push({
+ $: {
+ 'android:name': BOOT_RECEIVER_CLASS,
+ 'android:enabled': 'true',
+ 'android:exported': 'true',
+ },
+ 'intent-filter': [
+ {
+ $: { 'android:priority': '999' },
+ action: [
+ { $: { 'android:name': 'android.intent.action.BOOT_COMPLETED' } },
+ { $: { 'android:name': 'android.intent.action.QUICKBOOT_POWERON' } },
+ { $: { 'android:name': 'com.htc.intent.action.QUICKBOOT_POWERON' } },
+ { $: { 'android:name': 'android.intent.action.MY_PACKAGE_REPLACED' } },
+ ],
+ },
+ ],
+ });
+ }
+}
+
// ─── 4) String resources für a11y-service ───────────────────────────────────
const A11Y_DESCRIPTION_TEXT =
'Sichert deinen Schutz gegen impulsives Abschalten ab: Solange App-Lock aktiv ist, kann das ReBreak-VPN nicht in den Einstellungen deaktiviert und die App nicht deinstalliert werden. Das Blockieren von Glücksspielseiten selbst übernimmt das VPN — diese Berechtigung sichert es nur. Du kannst den Schutz jederzeit über die Abkühlphase in der App beenden.';
const A11Y_SUMMARY_TEXT =
- 'Sichert den Schutz gegen Abschalten ab';
+ 'ReBreak Schutz';
function withA11yStringResource(config) {
return withStringsXml(config, (cfg) => {
@@ -175,17 +262,45 @@ function withA11yConfigXml(config) {
]);
}
+// ─── 5b) XML-config für Device-Admin (@xml/device_admin) ────────────────────
+
+const MODULE_DEVICE_ADMIN_XML = path.resolve(
+ __dirname,
+ '../modules/rebreak-protection/android/src/main/res/xml/device_admin.xml',
+);
+
+function withDeviceAdminXml(config) {
+ return withDangerousMod(config, [
+ 'android',
+ async (cfg) => {
+ const xmlDir = path.join(
+ cfg.modRequest.platformProjectRoot,
+ 'app/src/main/res/xml',
+ );
+ fs.mkdirSync(xmlDir, { recursive: true });
+ fs.copyFileSync(
+ MODULE_DEVICE_ADMIN_XML,
+ path.join(xmlDir, 'device_admin.xml'),
+ );
+ return cfg;
+ },
+ ]);
+}
+
// ─── Composition ────────────────────────────────────────────────────────────
function withRebreakProtectionAndroid(config) {
config = withAndroidManifest(config, (cfg) => {
ensureToolsNamespace(cfg.modResults);
+ ensureBootPermission(cfg.modResults);
ensureVpnService(cfg.modResults);
ensureAccessibilityService(cfg.modResults);
+ ensureReceivers(cfg.modResults);
return cfg;
});
config = withA11yStringResource(config);
config = withA11yConfigXml(config);
+ config = withDeviceAdminXml(config);
return config;
}
diff --git a/apps/rebreak-native/plugins/with-voip-pushkit-ios.js b/apps/rebreak-native/plugins/with-voip-pushkit-ios.js
index 6cf1279..279ef0f 100644
--- a/apps/rebreak-native/plugins/with-voip-pushkit-ios.js
+++ b/apps/rebreak-native/plugins/with-voip-pushkit-ios.js
@@ -47,13 +47,25 @@ const REGISTRY_INIT = `
${MARKER}
// PushKit-Registry für VoIP-Push (CallKit). MUSS in didFinishLaunching
// initialisiert werden, sonst kommt der erste Push nach App-Cold-Start nicht
- // an.
- let voipRegistry = PKPushRegistry(queue: nil)
- voipRegistry.desiredPushTypes = [.voIP]
- voipRegistry.delegate = self
+ // an. Als Property gehalten (self.voipRegistry, siehe Property-Deklaration
+ // unter dem class-Header) damit sie nicht out-of-scope dealloziert wird —
+ // sonst kommt die Token-Registration noch durch, aber didReceiveIncomingPush
+ // feuert NIE → iPhone wacht im Background nicht auf.
+ let registry = PKPushRegistry(queue: .main)
+ registry.delegate = self
+ registry.desiredPushTypes = [.voIP]
+ self.voipRegistry = registry
${MARKER}
`;
+// Class-Property für den Registry-Halter — muss innerhalb der AppDelegate-Klasse
+// stehen. Wird direkt nach `var reactNativeFactory:` eingefügt.
+const CLASS_PROPERTY = `
+ ${MARKER}
+ var voipRegistry: PKPushRegistry?
+ ${MARKER}
+`;
+
// PKPushRegistryDelegate-Extension am Ende der Datei.
const DELEGATE_EXTENSION = `
@@ -67,6 +79,8 @@ extension AppDelegate: PKPushRegistryDelegate {
didUpdate pushCredentials: PKPushCredentials,
for type: PKPushType
) {
+ let hex = pushCredentials.token.map { String(format: "%02hhx", $0) }.joined()
+ NSLog("[VoIP] didUpdate token (len=%d) prefix=%@", pushCredentials.token.count, String(hex.prefix(16)))
RNVoipPushNotificationManager.didUpdate(pushCredentials, forType: type.rawValue)
}
@@ -75,7 +89,7 @@ extension AppDelegate: PKPushRegistryDelegate {
_ registry: PKPushRegistry,
didInvalidatePushTokenFor type: PKPushType
) {
- // nothing
+ NSLog("[VoIP] didInvalidatePushTokenFor type=%@", type.rawValue)
}
// Eingehender VoIP-Push. MUSS auf iOS 13+ in derselben run-loop
@@ -91,11 +105,18 @@ extension AppDelegate: PKPushRegistryDelegate {
let callId = (dict["callId"] as? String) ?? UUID().uuidString
let callerName = (dict["callerName"] as? String) ?? "ReBreak"
let handle = (dict["handle"] as? String) ?? callerName
+ // callId (z.B. "1780632453911-8jeln4xg") ist KEIN valides UUID. CXProvider.
+ // reportNewIncomingCall parst den String via NSUUID → nil → NSInvalidArgument
+ // Exception → App-Crash → iOS-VoIP-Drossel. Deterministisch konvertieren wie
+ // der JS-Layer (lib/callkit.ts:callIdToUuid), damit CallKit + JS dieselbe UUID
+ // nutzen (accept/end matchen sonst nicht).
+ let callUUID = AppDelegate.callIdToUuid(callId)
+ NSLog("[VoIP] didReceiveIncomingPush callId=%@ uuid=%@ callerName=%@", callId, callUUID, callerName)
// 1) Sofort an CallKit melden (iOS-Pflicht). uuid MUSS deterministisch sein,
// damit der JS-Layer den Call mit demselben UUID antworten/auflegen kann.
RNCallKeep.reportNewIncomingCall(
- callId,
+ callUUID,
handle: handle,
handleType: "generic",
hasVideo: false,
@@ -113,6 +134,25 @@ extension AppDelegate: PKPushRegistryDelegate {
// didLoadWithEvents reagieren (Auto-Navigation /call).
RNVoipPushNotificationManager.didReceiveIncomingPush(with: payload, forType: type.rawValue)
}
+
+ // Deterministische callId→UUID-Abbildung. MUSS exakt lib/callkit.ts:callIdToUuid
+ // entsprechen, sonst nutzen CallKit (nativ) und der JS-Layer verschiedene UUIDs
+ // und accept/end greifen ins Leere.
+ static func callIdToUuid(_ callId: String) -> String {
+ var hex = ""
+ for scalar in callId.unicodeScalars {
+ if hex.count >= 32 { break }
+ let code = Int(scalar.value)
+ hex.append(String((code >> 4) & 0xf, radix: 16))
+ if hex.count < 32 { hex.append(String(code & 0xf, radix: 16)) }
+ }
+ while hex.count < 32 { hex.append("0") }
+ let c = Array(hex.prefix(32))
+ func sub(_ a: Int, _ b: Int) -> String { String(c[a..