feat(android-protection): device-admin uninstall-block + boot-receiver + config plugin
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>
This commit is contained in:
parent
6a3c1e13da
commit
c7fc237dfd
@ -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
|
||||
|
||||
@ -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' && (
|
||||
<ProtectionOnboardingSheet
|
||||
visible={onboardingVisible}
|
||||
onComplete={handleOnboardingComplete}
|
||||
onSkip={handleOnboardingSkip}
|
||||
/>
|
||||
)}
|
||||
<NativeTabs
|
||||
sidebarAdaptable
|
||||
hapticFeedbackEnabled
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { AppState, Linking, Platform, ScrollView, Text, TouchableOpacity, View, Alert, ActivityIndicator } from 'react-native';
|
||||
import { useCallback, useEffect, useRef, useState, type ReactNode } from 'react';
|
||||
import { AppState, Platform, ScrollView, Text, TouchableOpacity, View, Alert, ActivityIndicator } from 'react-native';
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
import { useRouter } from 'expo-router';
|
||||
import { useBottomTabBarHeight } from 'react-native-bottom-tabs';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
@ -101,7 +102,7 @@ export default function BlockerScreen() {
|
||||
const familyControlsActive = state?.layers.familyControls === true;
|
||||
const appDeletionLockActive = (state?.layers.appDeletionLock ?? familyControlsActive) === true;
|
||||
const nefilterActive = state?.layers.nefilterActive === true;
|
||||
const accessibilityActive = state?.layers.accessibility === true;
|
||||
const deviceAdminActive = state?.layers.deviceAdmin === true;
|
||||
// "lockedIn" = beide Layer aktiv: URL-Filter (echter Schutz) UND App-Lock
|
||||
// (Hardening). Family-Controls ALLEINE = kein Schutz, nur denyAppRemoval —
|
||||
// ohne URL-Filter sieht der User trotzdem Glücksspielseiten. Daher BEIDE
|
||||
@ -113,7 +114,7 @@ export default function BlockerScreen() {
|
||||
// Profile + non-removable App enforced, FC-Toggle ist irrelevant.
|
||||
// nefilterActive → Schutz via System-Profil, kein VPN-Toggle nötig → locked-in
|
||||
const lockedIn = Platform.OS === 'android'
|
||||
? (urlFilterActive && accessibilityActive)
|
||||
? (urlFilterActive && appDeletionLockActive && deviceAdminActive)
|
||||
: (nefilterActive || urlFilterActive) && (mdmManaged || nefilterActive || appDeletionLockActive || !FAMILY_CONTROLS_AVAILABLE);
|
||||
|
||||
const urlFilterActiveRef = useRef(urlFilterActive);
|
||||
@ -153,6 +154,23 @@ export default function BlockerScreen() {
|
||||
return () => 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 */
|
||||
<IosUnsupervisedSetupFlow
|
||||
familyControlsActive={appDeletionLockActive}
|
||||
screentimeCode={screentimeCode}
|
||||
screentimeConfirmed={screentimeConfirmed}
|
||||
screentimeSaving={screentimeSaving}
|
||||
urlFilterActive={urlFilterActive}
|
||||
onActivateFamilyControls={handleActivateFamilyControls}
|
||||
onGenerateScreentimeCode={handleGenerateScreentimeCode}
|
||||
onConfirmScreentime={handleScreentimeConfirm}
|
||||
onActivateUrlFilter={handleActivateUrlFilter}
|
||||
colors={colors}
|
||||
t={t}
|
||||
/>
|
||||
) : Platform.OS === 'android' ? (
|
||||
<AndroidSetupFlow
|
||||
vpnActive={urlFilterActive}
|
||||
accessibilityLocked={appDeletionLockActive}
|
||||
deviceAdminActive={deviceAdminActive}
|
||||
onActivateVpn={handleActivateUrlFilter}
|
||||
onActivateAccessibility={handleActivateFamilyControls}
|
||||
onRequestDeviceAdmin={handleRequestDeviceAdmin}
|
||||
colors={colors}
|
||||
t={t}
|
||||
/>
|
||||
) : (
|
||||
<View style={{ gap: 10 }}>
|
||||
<LayerSwitchCard
|
||||
@ -338,65 +382,9 @@ export default function BlockerScreen() {
|
||||
active={urlFilterActive}
|
||||
onActivate={handleActivateUrlFilter}
|
||||
/>
|
||||
{Platform.OS === 'android' ? (
|
||||
<LayerSwitchCard
|
||||
icon="lock-closed-outline"
|
||||
title={t('blocker.layers_app_lock_title')}
|
||||
subtitle={
|
||||
state.layers.accessibility === true
|
||||
? t('blocker.layers_a11y_subtitle_active')
|
||||
: t('blocker.layers_a11y_subtitle_inactive')
|
||||
}
|
||||
active={state.layers.accessibility === true}
|
||||
onActivate={handleActivateFamilyControls}
|
||||
warning={t('blocker.layers_app_lock_warning')}
|
||||
/>
|
||||
) : 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). */
|
||||
<LayerSwitchCard
|
||||
icon="lock-closed-outline"
|
||||
title={t('blocker.layers_app_lock_title')}
|
||||
subtitle={
|
||||
appDeletionLockActive
|
||||
? t('blocker.layers_app_lock_subtitle_active')
|
||||
: t('blocker.layers_app_lock_subtitle_inactive')
|
||||
}
|
||||
active={appDeletionLockActive}
|
||||
onActivate={handleActivateFamilyControls}
|
||||
warning={t('blocker.layers_app_lock_warning')}
|
||||
lockedHint={t('blocker.layers_app_lock_locked_hint')}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* 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 && (
|
||||
<ScreentimePasscodeCard
|
||||
code={screentimeCode}
|
||||
confirmed={screentimeConfirmed}
|
||||
saving={screentimeSaving}
|
||||
onGenerate={handleGenerateScreentimeCode}
|
||||
onOpenSettings={() =>
|
||||
// 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 && (
|
||||
<CooldownBanner
|
||||
@ -523,55 +511,143 @@ export default function BlockerScreen() {
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Screen Time Passcode Card (iOS Layer 3) ──────────────────────────────────
|
||||
// ─── iOS Unsupervised 3-Step Setup Flow ──────────────────────────────────────
|
||||
|
||||
function ScreentimePasscodeCard({
|
||||
code,
|
||||
confirmed,
|
||||
saving,
|
||||
onGenerate,
|
||||
onOpenSettings,
|
||||
onConfirm,
|
||||
colors,
|
||||
t,
|
||||
}: {
|
||||
code: string | null;
|
||||
confirmed: boolean;
|
||||
saving: boolean;
|
||||
onGenerate: () => 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<typeof import('../../lib/theme').useColors>;
|
||||
t: ReturnType<typeof import('react-i18next').useTranslation>['t'];
|
||||
}) {
|
||||
if (confirmed) {
|
||||
};
|
||||
|
||||
function IosUnsupervisedSetupFlow({
|
||||
familyControlsActive,
|
||||
screentimeCode,
|
||||
screentimeConfirmed,
|
||||
screentimeSaving,
|
||||
urlFilterActive,
|
||||
onActivateFamilyControls,
|
||||
onGenerateScreentimeCode,
|
||||
onConfirmScreentime,
|
||||
onActivateUrlFilter,
|
||||
colors,
|
||||
t,
|
||||
}: SetupFlowProps) {
|
||||
const step1Done = familyControlsActive;
|
||||
const step2Done = screentimeConfirmed;
|
||||
const step3Done = urlFilterActive;
|
||||
|
||||
return (
|
||||
<View style={{ backgroundColor: colors.surface, borderRadius: 16, borderWidth: 1, borderColor: '#16a34a40', padding: 14, gap: 6 }}>
|
||||
<View style={{ flexDirection: 'row', alignItems: 'center', gap: 8 }}>
|
||||
<Text style={{ fontSize: 16 }}>🔐</Text>
|
||||
<Text style={{ fontSize: 14, fontFamily: 'Nunito_700Bold', color: colors.text, flex: 1 }}>
|
||||
{t('blocker.screentime_confirmed_title')}
|
||||
</Text>
|
||||
</View>
|
||||
<Text style={{ fontSize: 12, fontFamily: 'Nunito_400Regular', color: colors.textMuted, lineHeight: 17 }}>
|
||||
{t('blocker.screentime_confirmed_desc')}
|
||||
</Text>
|
||||
<View style={{ gap: 10 }}>
|
||||
<SetupStep1
|
||||
done={step1Done}
|
||||
onActivate={onActivateFamilyControls}
|
||||
colors={colors}
|
||||
t={t}
|
||||
/>
|
||||
<SetupStep2
|
||||
unlocked={step1Done}
|
||||
code={screentimeCode}
|
||||
confirmed={step2Done}
|
||||
saving={screentimeSaving}
|
||||
onGenerate={onGenerateScreentimeCode}
|
||||
onConfirm={onConfirmScreentime}
|
||||
colors={colors}
|
||||
t={t}
|
||||
/>
|
||||
<SetupStep3
|
||||
unlocked={step2Done}
|
||||
done={step3Done}
|
||||
onActivate={onActivateUrlFilter}
|
||||
colors={colors}
|
||||
t={t}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<View style={{ backgroundColor: colors.surface, borderRadius: 16, borderWidth: 1, borderColor: colors.border, padding: 14, gap: 10 }}>
|
||||
<View style={{ flexDirection: 'row', alignItems: 'center', gap: 8 }}>
|
||||
<Text style={{ fontSize: 16 }}>🔒</Text>
|
||||
<Text style={{ fontSize: 14, fontFamily: 'Nunito_700Bold', color: colors.text, flex: 1 }}>
|
||||
{t('blocker.screentime_title')}
|
||||
</Text>
|
||||
</View>
|
||||
<Text style={{ fontSize: 12, fontFamily: 'Nunito_400Regular', color: colors.textMuted, lineHeight: 17 }}>
|
||||
{t('blocker.screentime_desc')}
|
||||
</Text>
|
||||
function SetupStep1({
|
||||
done,
|
||||
onActivate,
|
||||
colors,
|
||||
t,
|
||||
}: {
|
||||
done: boolean;
|
||||
onActivate: () => Promise<{ enabled: boolean; error?: string }>;
|
||||
colors: ReturnType<typeof import('../../lib/theme').useColors>;
|
||||
t: ReturnType<typeof import('react-i18next').useTranslation>['t'];
|
||||
}) {
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
async function handlePress() {
|
||||
if (done || busy) return;
|
||||
setBusy(true);
|
||||
try { await onActivate(); } finally { setBusy(false); }
|
||||
}
|
||||
|
||||
return (
|
||||
<SetupStepCard
|
||||
stepNumber={1}
|
||||
title={t('blocker.setup_step1_title')}
|
||||
subtitle={done ? t('blocker.setup_step1_subtitle_done') : t('blocker.setup_step1_subtitle_pending')}
|
||||
done={done}
|
||||
unlocked
|
||||
colors={colors}
|
||||
>
|
||||
{!done && (
|
||||
<TouchableOpacity
|
||||
onPress={handlePress}
|
||||
activeOpacity={0.8}
|
||||
style={{ backgroundColor: colors.brandOrange, borderRadius: 10, paddingVertical: 10, alignItems: 'center', marginTop: 10 }}
|
||||
>
|
||||
{busy
|
||||
? <ActivityIndicator size="small" color="#fff" />
|
||||
: <Text style={{ fontSize: 14, fontFamily: 'Nunito_700Bold', color: '#fff' }}>{t('blocker.setup_step1_cta')}</Text>
|
||||
}
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
</SetupStepCard>
|
||||
);
|
||||
}
|
||||
|
||||
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<typeof import('../../lib/theme').useColors>;
|
||||
t: ReturnType<typeof import('react-i18next').useTranslation>['t'];
|
||||
}) {
|
||||
return (
|
||||
<SetupStepCard
|
||||
stepNumber={2}
|
||||
title={t('blocker.setup_step2_title')}
|
||||
subtitle={confirmed ? t('blocker.setup_step2_subtitle_done') : t('blocker.setup_step2_subtitle_pending')}
|
||||
done={confirmed}
|
||||
unlocked={unlocked}
|
||||
lockedHint={unlocked ? undefined : t('blocker.setup_step_locked_hint', { step: 1 })}
|
||||
colors={colors}
|
||||
>
|
||||
{unlocked && !confirmed && (
|
||||
<View style={{ gap: 10, marginTop: 10 }}>
|
||||
{!code ? (
|
||||
<TouchableOpacity
|
||||
onPress={onGenerate}
|
||||
@ -584,7 +660,6 @@ function ScreentimePasscodeCard({
|
||||
</TouchableOpacity>
|
||||
) : (
|
||||
<View style={{ gap: 10 }}>
|
||||
{/* Code display */}
|
||||
<View style={{ backgroundColor: colors.surfaceElevated, borderRadius: 12, padding: 16, alignItems: 'center', gap: 4 }}>
|
||||
<Text style={{ fontSize: 11, fontFamily: 'Nunito_600SemiBold', color: colors.textMuted }}>
|
||||
{t('blocker.screentime_code_label')}
|
||||
@ -593,15 +668,21 @@ function ScreentimePasscodeCard({
|
||||
{code}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
{/* Step-by-step instructions */}
|
||||
<View style={{ backgroundColor: colors.surfaceElevated, borderRadius: 12, padding: 12, gap: 6 }}>
|
||||
{[
|
||||
t('blocker.screentime_step1'),
|
||||
t('blocker.screentime_step2'),
|
||||
t('blocker.screentime_step3'),
|
||||
].map((step, i) => (
|
||||
<Text key={i} style={{ fontSize: 13, fontFamily: i === 0 ? 'Nunito_700Bold' : 'Nunito_600SemiBold', color: i === 0 ? colors.brandOrange : colors.text, lineHeight: 18 }}>
|
||||
<Text
|
||||
key={i}
|
||||
style={{
|
||||
fontSize: 13,
|
||||
fontFamily: i === 0 ? 'Nunito_700Bold' : 'Nunito_600SemiBold',
|
||||
color: i === 0 ? colors.brandOrange : colors.text,
|
||||
lineHeight: 18,
|
||||
}}
|
||||
>
|
||||
{step}
|
||||
</Text>
|
||||
))}
|
||||
@ -609,19 +690,6 @@ function ScreentimePasscodeCard({
|
||||
{t('blocker.screentime_step_note')}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
{/* Open Settings */}
|
||||
<TouchableOpacity
|
||||
onPress={onOpenSettings}
|
||||
activeOpacity={0.8}
|
||||
style={{ backgroundColor: '#007AFF', borderRadius: 10, paddingVertical: 10, alignItems: 'center' }}
|
||||
>
|
||||
<Text style={{ fontSize: 14, fontFamily: 'Nunito_700Bold', color: '#fff' }}>
|
||||
{t('blocker.screentime_open_settings_cta')}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
|
||||
{/* Confirm */}
|
||||
<TouchableOpacity
|
||||
onPress={onConfirm}
|
||||
disabled={saving}
|
||||
@ -636,6 +704,346 @@ function ScreentimePasscodeCard({
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
)}
|
||||
</SetupStepCard>
|
||||
);
|
||||
}
|
||||
|
||||
function SetupStep3({
|
||||
unlocked,
|
||||
done,
|
||||
onActivate,
|
||||
colors,
|
||||
t,
|
||||
}: {
|
||||
unlocked: boolean;
|
||||
done: boolean;
|
||||
onActivate: () => Promise<{ enabled: boolean; error?: string }>;
|
||||
colors: ReturnType<typeof import('../../lib/theme').useColors>;
|
||||
t: ReturnType<typeof import('react-i18next').useTranslation>['t'];
|
||||
}) {
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
async function handlePress() {
|
||||
if (done || busy) return;
|
||||
setBusy(true);
|
||||
try { await onActivate(); } finally { setBusy(false); }
|
||||
}
|
||||
|
||||
return (
|
||||
<SetupStepCard
|
||||
stepNumber={3}
|
||||
title={t('blocker.setup_step3_title')}
|
||||
subtitle={done ? t('blocker.setup_step3_subtitle_done') : t('blocker.setup_step3_subtitle_pending')}
|
||||
done={done}
|
||||
unlocked={unlocked}
|
||||
lockedHint={unlocked ? undefined : t('blocker.setup_step_locked_hint', { step: 2 })}
|
||||
colors={colors}
|
||||
>
|
||||
{unlocked && !done && (
|
||||
<View style={{ gap: 10, marginTop: 10 }}>
|
||||
<View style={{ backgroundColor: colors.surfaceElevated, borderRadius: 10, padding: 10, flexDirection: 'row', gap: 8 }}>
|
||||
<Ionicons name="information-circle-outline" size={15} color={colors.textMuted} style={{ marginTop: 1 }} />
|
||||
<Text style={{ flex: 1, fontSize: 12, fontFamily: 'Nunito_400Regular', color: colors.textMuted, lineHeight: 17 }}>
|
||||
{t('blocker.setup_step3_warning')}
|
||||
</Text>
|
||||
</View>
|
||||
<TouchableOpacity
|
||||
onPress={handlePress}
|
||||
activeOpacity={0.8}
|
||||
style={{ backgroundColor: colors.success, borderRadius: 10, paddingVertical: 10, alignItems: 'center' }}
|
||||
>
|
||||
{busy
|
||||
? <ActivityIndicator size="small" color="#fff" />
|
||||
: <Text style={{ fontSize: 14, fontFamily: 'Nunito_700Bold', color: '#fff' }}>{t('blocker.setup_step3_cta')}</Text>
|
||||
}
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
)}
|
||||
</SetupStepCard>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── 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<unknown>;
|
||||
onRequestDeviceAdmin: () => Promise<{ launched: boolean }>;
|
||||
colors: ReturnType<typeof import('../../lib/theme').useColors>;
|
||||
t: ReturnType<typeof import('react-i18next').useTranslation>['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 (
|
||||
<View style={{ gap: 10 }}>
|
||||
<AndroidStep1
|
||||
done={vpnDone}
|
||||
onActivate={onActivateVpn}
|
||||
colors={colors}
|
||||
t={t}
|
||||
/>
|
||||
{/* Display-Step 2 = Geräteadmin (Komponente AndroidStep3, i18n android_step3_*). */}
|
||||
<AndroidStep3
|
||||
unlocked={vpnDone}
|
||||
done={adminDone}
|
||||
onRequestAdmin={onRequestDeviceAdmin}
|
||||
colors={colors}
|
||||
t={t}
|
||||
/>
|
||||
{/* Display-Step 3 = a11y / ReBreak-Schutz (Komponente AndroidStep2, i18n android_step2_*). */}
|
||||
<AndroidStep2
|
||||
unlocked={adminDone}
|
||||
done={a11yDone}
|
||||
onActivate={onActivateAccessibility}
|
||||
colors={colors}
|
||||
t={t}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
function AndroidStep1({
|
||||
done,
|
||||
onActivate,
|
||||
colors,
|
||||
t,
|
||||
}: {
|
||||
done: boolean;
|
||||
onActivate: () => Promise<{ enabled: boolean; error?: string }>;
|
||||
colors: ReturnType<typeof import('../../lib/theme').useColors>;
|
||||
t: ReturnType<typeof import('react-i18next').useTranslation>['t'];
|
||||
}) {
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
async function handlePress() {
|
||||
if (done || busy) return;
|
||||
setBusy(true);
|
||||
try { await onActivate(); } finally { setBusy(false); }
|
||||
}
|
||||
|
||||
return (
|
||||
<SetupStepCard
|
||||
stepNumber={1}
|
||||
title={t('blocker.android_step1_title')}
|
||||
subtitle={done ? t('blocker.android_step1_subtitle_done') : t('blocker.android_step1_subtitle_pending')}
|
||||
done={done}
|
||||
unlocked
|
||||
colors={colors}
|
||||
>
|
||||
{!done && (
|
||||
<TouchableOpacity
|
||||
onPress={handlePress}
|
||||
activeOpacity={0.8}
|
||||
style={{ backgroundColor: colors.brandOrange, borderRadius: 10, paddingVertical: 10, alignItems: 'center', marginTop: 10 }}
|
||||
>
|
||||
{busy
|
||||
? <ActivityIndicator size="small" color="#fff" />
|
||||
: <Text style={{ fontSize: 14, fontFamily: 'Nunito_700Bold', color: '#fff' }}>{t('blocker.android_step1_cta')}</Text>
|
||||
}
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
</SetupStepCard>
|
||||
);
|
||||
}
|
||||
|
||||
function AndroidStep2({
|
||||
unlocked,
|
||||
done,
|
||||
onActivate,
|
||||
colors,
|
||||
t,
|
||||
}: {
|
||||
unlocked: boolean;
|
||||
done: boolean;
|
||||
onActivate: () => Promise<unknown>;
|
||||
colors: ReturnType<typeof import('../../lib/theme').useColors>;
|
||||
t: ReturnType<typeof import('react-i18next').useTranslation>['t'];
|
||||
}) {
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
async function handlePress() {
|
||||
if (busy) return;
|
||||
setBusy(true);
|
||||
try { await onActivate(); } finally { setBusy(false); }
|
||||
}
|
||||
|
||||
return (
|
||||
<SetupStepCard
|
||||
stepNumber={3}
|
||||
title={t('blocker.android_step2_title')}
|
||||
subtitle={done ? t('blocker.android_step2_subtitle_done') : t('blocker.android_step2_subtitle_pending')}
|
||||
done={done}
|
||||
unlocked={unlocked}
|
||||
lockedHint={unlocked ? undefined : t('blocker.setup_step_locked_hint', { step: 2 })}
|
||||
colors={colors}
|
||||
>
|
||||
{unlocked && !done && (
|
||||
<View style={{ gap: 8, marginTop: 10 }}>
|
||||
<View style={{ backgroundColor: colors.surfaceElevated, borderRadius: 12, padding: 12, gap: 6 }}>
|
||||
{[
|
||||
t('blocker.android_step2_instruction1'),
|
||||
t('blocker.android_step2_instruction2'),
|
||||
t('blocker.android_step2_instruction3'),
|
||||
].map((line, i) => (
|
||||
<Text
|
||||
key={i}
|
||||
style={{
|
||||
fontSize: 13,
|
||||
fontFamily: i === 0 ? 'Nunito_700Bold' : 'Nunito_600SemiBold',
|
||||
color: i === 0 ? colors.brandOrange : colors.text,
|
||||
lineHeight: 18,
|
||||
}}
|
||||
>
|
||||
{line}
|
||||
</Text>
|
||||
))}
|
||||
</View>
|
||||
<TouchableOpacity
|
||||
onPress={handlePress}
|
||||
activeOpacity={0.8}
|
||||
style={{ backgroundColor: colors.brandOrange, borderRadius: 10, paddingVertical: 10, alignItems: 'center', marginTop: 2 }}
|
||||
>
|
||||
{busy
|
||||
? <ActivityIndicator size="small" color="#fff" />
|
||||
: <Text style={{ fontSize: 14, fontFamily: 'Nunito_700Bold', color: '#fff' }}>{t('blocker.android_step2_cta')}</Text>
|
||||
}
|
||||
</TouchableOpacity>
|
||||
<Text style={{ fontSize: 11, fontFamily: 'Nunito_400Regular', color: colors.textMuted, lineHeight: 16 }}>
|
||||
{t('blocker.android_step2_note')}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
</SetupStepCard>
|
||||
);
|
||||
}
|
||||
|
||||
function AndroidStep3({
|
||||
unlocked,
|
||||
done,
|
||||
onRequestAdmin,
|
||||
colors,
|
||||
t,
|
||||
}: {
|
||||
unlocked: boolean;
|
||||
done: boolean;
|
||||
onRequestAdmin: () => Promise<{ launched: boolean }>;
|
||||
colors: ReturnType<typeof import('../../lib/theme').useColors>;
|
||||
t: ReturnType<typeof import('react-i18next').useTranslation>['t'];
|
||||
}) {
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
async function handlePress() {
|
||||
if (done || busy) return;
|
||||
setBusy(true);
|
||||
try { await onRequestAdmin(); } finally { setBusy(false); }
|
||||
}
|
||||
|
||||
return (
|
||||
<SetupStepCard
|
||||
stepNumber={2}
|
||||
title={t('blocker.android_step3_title')}
|
||||
subtitle={done ? t('blocker.android_step3_subtitle_done') : t('blocker.android_step3_subtitle_pending')}
|
||||
done={done}
|
||||
unlocked={unlocked}
|
||||
lockedHint={unlocked ? undefined : t('blocker.setup_step_locked_hint', { step: 1 })}
|
||||
colors={colors}
|
||||
>
|
||||
{unlocked && !done && (
|
||||
<View style={{ gap: 10, marginTop: 10 }}>
|
||||
<View style={{ backgroundColor: colors.surfaceElevated, borderRadius: 10, padding: 10, flexDirection: 'row', gap: 8 }}>
|
||||
<Ionicons name="information-circle-outline" size={15} color={colors.textMuted} style={{ marginTop: 1 }} />
|
||||
<Text style={{ flex: 1, fontSize: 12, fontFamily: 'Nunito_400Regular', color: colors.textMuted, lineHeight: 17 }}>
|
||||
{t('blocker.android_step3_warning')}
|
||||
</Text>
|
||||
</View>
|
||||
<TouchableOpacity
|
||||
onPress={handlePress}
|
||||
activeOpacity={0.8}
|
||||
style={{ backgroundColor: colors.success, borderRadius: 10, paddingVertical: 10, alignItems: 'center' }}
|
||||
>
|
||||
{busy
|
||||
? <ActivityIndicator size="small" color="#fff" />
|
||||
: <Text style={{ fontSize: 14, fontFamily: 'Nunito_700Bold', color: '#fff' }}>{t('blocker.android_step3_cta')}</Text>
|
||||
}
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
)}
|
||||
</SetupStepCard>
|
||||
);
|
||||
}
|
||||
|
||||
function SetupStepCard({
|
||||
stepNumber,
|
||||
title,
|
||||
subtitle,
|
||||
done,
|
||||
unlocked,
|
||||
lockedHint,
|
||||
colors,
|
||||
children,
|
||||
}: {
|
||||
stepNumber: number;
|
||||
title: string;
|
||||
subtitle: string;
|
||||
done: boolean;
|
||||
unlocked: boolean;
|
||||
lockedHint?: string;
|
||||
colors: ReturnType<typeof import('../../lib/theme').useColors>;
|
||||
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 (
|
||||
<View style={{ backgroundColor: cardBg, borderWidth: 1, borderColor, borderRadius: 16, padding: 14 }}>
|
||||
<View style={{ flexDirection: 'row', alignItems: 'center', gap: 12 }}>
|
||||
<View style={{ width: 36, height: 36, borderRadius: 18, backgroundColor: numberBg, alignItems: 'center', justifyContent: 'center' }}>
|
||||
{done
|
||||
? <Ionicons name="checkmark" size={18} color={colors.success} />
|
||||
: <Text style={{ fontSize: 14, fontFamily: 'Nunito_700Bold', color: numberColor }}>{stepNumber}</Text>
|
||||
}
|
||||
</View>
|
||||
<View style={{ flex: 1 }}>
|
||||
<Text style={{ fontSize: 14, fontFamily: 'Nunito_700Bold', color: titleColor }}>{title}</Text>
|
||||
{!!subtitle && (
|
||||
<Text style={{ fontSize: 12, fontFamily: 'Nunito_400Regular', color: colors.textMuted, marginTop: 2, lineHeight: 17 }}>{subtitle}</Text>
|
||||
)}
|
||||
</View>
|
||||
{done && <Ionicons name="checkmark-circle" size={24} color={colors.success} />}
|
||||
</View>
|
||||
{lockedHint && !done && (
|
||||
<View style={{ marginTop: 8, flexDirection: 'row', gap: 6, alignItems: 'center' }}>
|
||||
<Ionicons name="lock-closed-outline" size={13} color={colors.textMuted} />
|
||||
<Text style={{ fontSize: 11, fontFamily: 'Nunito_400Regular', color: colors.textMuted }}>{lockedHint}</Text>
|
||||
</View>
|
||||
)}
|
||||
{children}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@ -274,14 +274,6 @@ function RootLayoutInner() {
|
||||
animation: 'slide_from_right',
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="magic"
|
||||
options={{
|
||||
headerShown: false,
|
||||
presentation: 'card',
|
||||
animation: 'slide_from_right',
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="onboarding/index"
|
||||
options={{
|
||||
|
||||
@ -175,7 +175,7 @@ export default function ProfileScreen() {
|
||||
provider,
|
||||
};
|
||||
|
||||
const showDigaBanner = (coverage?.currentStreakDays ?? 0) >= 30 && !bannerDismissed;
|
||||
const showDigaBanner = (coverage?.protectedDays ?? 0) >= 30 && !bannerDismissed;
|
||||
const demoComplete = !withdrawnAt && isDemographicsComplete(demographics);
|
||||
|
||||
function scrollToDemographics() {
|
||||
|
||||
6
apps/rebreak-native/assets/clarity--avatar-line.svg
Normal file
6
apps/rebreak-native/assets/clarity--avatar-line.svg
Normal file
@ -0,0 +1,6 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 36 36">
|
||||
<path d="M0 0h36v36H0z" fill="none" />
|
||||
<path fill="currentColor" d="M18 17a7 7 0 1 0-7-7a7 7 0 0 0 7 7m0-12a5 5 0 1 1-5 5a5 5 0 0 1 5-5" class="clr-i-outline clr-i-outline-path-1" />
|
||||
<path fill="currentColor" d="M30.47 24.37a17.16 17.16 0 0 0-24.93 0A2 2 0 0 0 5 25.74V31a2 2 0 0 0 2 2h22a2 2 0 0 0 2-2v-5.26a2 2 0 0 0-.53-1.37M29 31H7v-5.27a15.17 15.17 0 0 1 22 0Z" class="clr-i-outline clr-i-outline-path-2" />
|
||||
<path fill="none" d="M0 0h36v36H0z" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 549 B |
@ -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 ? (
|
||||
|
||||
@ -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<number | null>(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) {
|
||||
|
||||
@ -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 <SvgXml>. currentColor wird über die
|
||||
// `color`-Prop getintet.
|
||||
const AVATAR_PLACEHOLDER_SVG =
|
||||
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 36 36"><path fill="currentColor" d="M18 17a7 7 0 1 0-7-7a7 7 0 0 0 7 7m0-12a5 5 0 1 1-5 5a5 5 0 0 1 5-5"/><path fill="currentColor" d="M30.47 24.37a17.16 17.16 0 0 0-24.93 0A2 2 0 0 0 5 25.74V31a2 2 0 0 0 2 2h22a2 2 0 0 0 2-2v-5.26a2 2 0 0 0-.53-1.37M29 31H7v-5.27a15.17 15.17 0 0 1 22 0Z"/></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',
|
||||
}}
|
||||
>
|
||||
<Text
|
||||
style={{
|
||||
color: '#ffffff',
|
||||
fontSize: s.font,
|
||||
fontFamily: 'Nunito_700Bold',
|
||||
}}
|
||||
>
|
||||
{initials}
|
||||
</Text>
|
||||
<SvgXml
|
||||
xml={AVATAR_PLACEHOLDER_SVG}
|
||||
width={s.avatar * 0.66}
|
||||
height={s.avatar * 0.66}
|
||||
color="#ffffff"
|
||||
/>
|
||||
</View>
|
||||
)}
|
||||
|
||||
|
||||
@ -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 ? (
|
||||
|
||||
@ -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');
|
||||
|
||||
@ -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<void> {
|
||||
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<void> {
|
||||
);
|
||||
} 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);
|
||||
|
||||
@ -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<void> {
|
||||
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;
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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": {
|
||||
|
||||
@ -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": {
|
||||
|
||||
@ -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<String, Any?> = mapOf(
|
||||
"allLayersOn" to false,
|
||||
"missingLayers" to listOf("accessibility", "tamperLock"),
|
||||
|
||||
@ -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",
|
||||
)
|
||||
|
||||
@ -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"
|
||||
}
|
||||
}
|
||||
@ -12,4 +12,5 @@
|
||||
<string name="a11y_hint_step_select_rebreak">اضغط على «ReBreak — الحماية».</string>
|
||||
<string name="a11y_hint_step_enable_toggle">فعّل المفتاح العلوي.</string>
|
||||
<string name="a11y_hint_step_allow_confirm">اضغط على «سماح».</string>
|
||||
<string name="device_admin_explanation">يحتاج ReBreak إلى صلاحيات مسؤول الجهاز لضمان حماية التطبيق فور إعادة التشغيل. لا تُطلب صلاحيات إضافية (لا إجبار على كلمة مرور، ولا مسح عن بُعد). يمكنك إيقاف الحماية دائماً عبر فترة التهدئة في التطبيق.</string>
|
||||
</resources>
|
||||
|
||||
@ -12,4 +12,5 @@
|
||||
<string name="a11y_hint_step_select_rebreak">Tap \u201CReBreak \u2014 Protection\u201D.</string>
|
||||
<string name="a11y_hint_step_enable_toggle">Turn on the top switch.</string>
|
||||
<string name="a11y_hint_step_allow_confirm">Tap \u201CAllow\u201D.</string>
|
||||
<string name="device_admin_explanation">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.</string>
|
||||
</resources>
|
||||
|
||||
@ -12,4 +12,5 @@
|
||||
<string name="a11y_hint_step_select_rebreak">Touche \u00AB\u00A0ReBreak \u2014 Protection\u00A0\u00BB.</string>
|
||||
<string name="a11y_hint_step_enable_toggle">Active l\'interrupteur du haut.</string>
|
||||
<string name="a11y_hint_step_allow_confirm">Touche \u00AB\u00A0Autoriser\u00A0\u00BB.</string>
|
||||
<string name="device_admin_explanation">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.</string>
|
||||
</resources>
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<string name="accessibility_service_description">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.</string>
|
||||
<string name="accessibility_service_summary">ReBreak \u2014 Schutz</string>
|
||||
<string name="accessibility_service_summary">ReBreak Schutz</string>
|
||||
<string name="a11y_guide_title">ReBreak Schritt-für-Schritt</string>
|
||||
<string name="a11y_guide_btn_prev">Zurück</string>
|
||||
<string name="a11y_guide_btn_next">Weiter</string>
|
||||
@ -9,7 +9,8 @@
|
||||
<string name="a11y_guide_btn_close">Schließen</string>
|
||||
<string name="a11y_overlay_permission_required">Aktiviere "Über anderen Apps einblenden" für ReBreak.</string>
|
||||
<string name="a11y_hint_step_open_installed">Tippe auf \u201EInstallierte Dienste\u201C.</string>
|
||||
<string name="a11y_hint_step_select_rebreak">Tippe auf \u201EReBreak \u2014 Schutz\u201C.</string>
|
||||
<string name="a11y_hint_step_select_rebreak">Tippe auf \u201EReBreak Schutz\u201C.</string>
|
||||
<string name="a11y_hint_step_enable_toggle">Schalte den oberen Schalter ein.</string>
|
||||
<string name="a11y_hint_step_allow_confirm">Tippe auf \u201EZulassen\u201C.</string>
|
||||
<string name="device_admin_explanation">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.</string>
|
||||
</resources>
|
||||
|
||||
@ -0,0 +1,11 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!--
|
||||
Device-Admin-Policy fuer RebreakDeviceAdminReceiver.
|
||||
|
||||
Scope MINIMAL: Keine uses-policies — das alleinige Registrieren als aktiver
|
||||
Device-Admin genuegt um eine direkte Deinstallation zu verhindern. Weitere
|
||||
Policies (Passwort, Wipe, Kamera) werden bewusst NICHT beantragt.
|
||||
-->
|
||||
<device-admin xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<uses-policies />
|
||||
</device-admin>
|
||||
@ -19,7 +19,7 @@
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>0.3.13</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>76</string>
|
||||
<string>84</string>
|
||||
<key>NSExtension</key>
|
||||
<dict>
|
||||
<key>NSExtensionPointIdentifier</key>
|
||||
|
||||
@ -19,7 +19,7 @@
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>0.3.13</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>76</string>
|
||||
<string>84</string>
|
||||
<key>NSExtension</key>
|
||||
<dict>
|
||||
<key>NSExtensionPointIdentifier</key>
|
||||
|
||||
@ -19,7 +19,7 @@
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>0.3.13</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>76</string>
|
||||
<string>84</string>
|
||||
<key>EXAppExtensionAttributes</key>
|
||||
<dict>
|
||||
<key>EXExtensionPointIdentifier</key>
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -234,6 +234,23 @@ declare class RebreakProtectionModule extends NativeModule<RebreakProtectionEven
|
||||
* aber nicht läuft (Reinstall / Low-Mem-Kill) → neu starten. Bei App-Start /
|
||||
* Foreground aufrufen, damit der State nicht „an aber tot" bleibt. */
|
||||
reconcileVpn(): Promise<{ restarted: boolean; needsConsent?: boolean }>;
|
||||
|
||||
/**
|
||||
* 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<RebreakProtectionModule>('RebreakProtection');
|
||||
|
||||
@ -89,6 +89,15 @@ class RebreakProtectionModuleWeb extends NativeModule<RebreakProtectionEvents> {
|
||||
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' };
|
||||
|
||||
@ -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 <manifest> ──────────────────────────────────────
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
|
||||
@ -47,10 +47,22 @@ 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}
|
||||
`;
|
||||
|
||||
@ -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..<b]) }
|
||||
// UUID v4 Layout: xxxxxxxx-xxxx-4xxx-8xxx-xxxxxxxxxxxx (Index 12 + 16 werden
|
||||
// durch die Literale '4'/'8' ersetzt — exakt wie im JS-Original).
|
||||
return "\\(sub(0, 8))-\\(sub(8, 12))-4\\(sub(13, 16))-8\\(sub(17, 20))-\\(sub(20, 32))"
|
||||
}
|
||||
}
|
||||
${MARKER}
|
||||
`;
|
||||
@ -172,7 +212,15 @@ function patchAppDelegate(iosRoot) {
|
||||
src = src.replace(/(import React\n)/, `$1${IMPORT_PATCH}`);
|
||||
}
|
||||
|
||||
// 2) didFinishLaunching: PKPushRegistry direkt vor dem return-super injizieren.
|
||||
// 2a) Class-Property `var voipRegistry: PKPushRegistry?` einfügen \u2014 muss
|
||||
// gehalten werden, sonst dealloziert iOS die Registry nach
|
||||
// didFinishLaunching und Pushes erreichen den Delegate nie.
|
||||
const classPropRe = /(\n\s+var reactNativeFactory:[^\n]+\n)/;
|
||||
if (classPropRe.test(src) && !src.includes('var voipRegistry')) {
|
||||
src = src.replace(classPropRe, `$1${CLASS_PROPERTY}`);
|
||||
}
|
||||
|
||||
// 2b) didFinishLaunching: PKPushRegistry direkt vor dem return-super injizieren.
|
||||
const returnSuperRe = /(\n\s+return super\.application\(application, didFinishLaunchingWithOptions: launchOptions\))/;
|
||||
if (returnSuperRe.test(src)) {
|
||||
src = src.replace(returnSuperRe, `${REGISTRY_INIT}$1`);
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user