chahinebrini 63fae25531 fix(android-protection): explicit specialUse FGS type — Samsung/Android 16 crash loop
RebreakVpnService.onStartCommand crashed with SecurityException because Android 16's validateForegroundServiceType rejects the implicit 2-arg startForeground(). Now passes FOREGROUND_SERVICE_TYPE_SPECIAL_USE explicitly (Google's documented best practice) and guards the call so a failed foreground promotion stops the service cleanly instead of crashing the app. Verified vs reported Galaxy A54 / Android 16 signature (97% of crash events, 1-user crash loop).

Bundles pending working-tree work across native/marketing/locales/mac + graphify-out rebuild. gitignore: google-services.json + /screenshots/.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-10 22:33:28 +02:00

534 lines
21 KiB
TypeScript

import { useCallback, useEffect, useRef, useState } from 'react';
import { AppState, Platform, ScrollView, View, Alert, ActivityIndicator } from 'react-native';
import { useRouter } from 'expo-router';
import { useBottomTabBarHeight } from 'react-native-bottom-tabs';
import { useTranslation } from 'react-i18next';
import { AppHeader } from '../../components/AppHeader';
import { LayerSwitchCard } from '../../components/blocker/LayerSwitchCard';
import { ProtectionLockedCard } from '../../components/blocker/ProtectionLockedCard';
import { CooldownBanner } from '../../components/blocker/CooldownBanner';
import { AddDomainSheet } from '../../components/blocker/AddDomainSheet';
import { VipSwapSheet } from '../../components/blocker/VipSwapSheet';
import { MyFiltersList, VipDomainList } from '../../components/blocker/VipDomainList';
import { ProtectionDetailsSheet } from '../../components/blocker/ProtectionDetailsSheet';
import { DeactivationExplainerSheet } from '../../components/blocker/DeactivationExplainerSheet';
import { PermissionDeniedSheet } from '../../components/PermissionDeniedSheet';
import { ProtectionOffSheet } from '../../components/ProtectionOffSheet';
import { IosUnsupervisedSetupFlow, AndroidSetupFlow } from '../../components/blocker/SetupFlows';
import { useProtectionState } from '../../hooks/useProtectionState';
import { useCustomDomains } from '../../hooks/useCustomDomains';
import { useBlocklistSync } from '../../hooks/useBlocklistSync';
import { useDomainSubmissionRealtime } from '../../hooks/useDomainSubmissionRealtime';
import { protection, FAMILY_CONTROLS_AVAILABLE } from '../../lib/protection';
import { useColors } from '../../lib/theme';
import { useBlockerStatsStore } from '../../stores/blockerStats';
export default function BlockerScreen() {
const router = useRouter();
const { t } = useTranslation();
const colors = useColors();
// react-native-bottom-tabs Tab-Bar ist iOS-nativ + translucent → unsere Content-View
// erstreckt sich UNTER den Tab-Bar. Ohne diese Höhe würden FAB + Bottom-Padding
// hinterm Tab-Bar verschwinden.
const tabBarHeight = useBottomTabBarHeight();
const {
state,
loading,
cooldownRemainingFormatted,
mdmManaged,
refresh,
activateUrlFilter,
activateFamilyControls,
requestDeactivation,
cancelDeactivation,
} = useProtectionState();
const refreshBlockerStatsIfStale = useBlockerStatsStore((s) => s.refreshIfStale);
const refreshBlockerStats = useBlockerStatsStore((s) => s.refresh);
const plan = state?.plan ?? 'free';
const {
domains,
tier,
count: domainCount,
limit: domainLimit,
addDomain,
submitDomain,
submitVipSwap,
refresh: refreshDomains,
} = useCustomDomains(plan);
const { sync: syncBlocklist, syncWebContent } = useBlocklistSync();
// Realtime: Domain-Submission-Status (approved/rejected/in_review) live patchen.
const onDomainChange = useCallback(async () => {
await refreshDomains();
await refreshBlockerStats().catch(() => {});
if (urlFilterActiveRef.current) {
const sync = await syncBlocklist();
console.log('[blocker] resync after domain change:', sync);
await refresh();
}
}, [refreshDomains, refreshBlockerStats, syncBlocklist, refresh]);
useDomainSubmissionRealtime(onDomainChange, true);
// Stats fürs Info-Sheet früh laden, damit beim Öffnen kein Loader-Flicker entsteht.
useEffect(() => {
refreshBlockerStatsIfStale(120_000).catch(() => {});
}, [refreshBlockerStatsIfStale]);
const [vipOpen, setVipOpen] = useState(false);
const [addSheetOpen, setAddSheetOpen] = useState(false);
const [vipSwapOpen, setVipSwapOpen] = useState(false);
const [pendingNewDomainId, setPendingNewDomainId] = useState<string>('');
const [detailsOpen, setDetailsOpen] = useState(false);
const [explainerOpen, setExplainerOpen] = useState(false);
const [permissionDeniedOpen, setPermissionDeniedOpen] = useState(false);
const [familyControlsErrorOpen, setFamilyControlsErrorOpen] = useState(false);
const [protectionOffOpen, setProtectionOffOpen] = useState(false);
// Screen Time Passcode (iOS Layer 3)
const [screentimeCode, setScreentimeCode] = useState<string | null>(null);
const [screentimeConfirmed, setScreentimeConfirmed] = useState(false);
const [screentimeSaving, setScreentimeSaving] = useState(false);
// Load persisted screentime status on mount so the card stays hidden if already set
useEffect(() => {
if (Platform.OS !== 'ios') return;
protection.getScreenTimePasscode().then((p) => {
if (p) setScreentimeConfirmed(true);
}).catch(() => {});
}, []);
const urlFilterActive = state?.layers.urlFilter === true;
const familyControlsActive = state?.layers.familyControls === true;
const appDeletionLockActive = (state?.layers.appDeletionLock ?? familyControlsActive) === true;
const nefilterActive = state?.layers.nefilterActive === true;
const deviceAdminActive = state?.layers.deviceAdmin === true;
const batteryUnrestricted = state?.layers.batteryUnrestricted === 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
// müssen an sein damit der "Schutz aktiv"-Banner gezeigt wird.
// Ausnahmen:
// - !FAMILY_CONTROLS_AVAILABLE (Distribution-Build ohne FC-Entitlement) →
// es kann gar keinen App-Lock geben, URL-Filter allein reicht.
// - mdmManaged → der App-Lock wird MDM-seitig durch nicht-entfernbares
// 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'
// Akku-Ausnahme MUSS dabei sein — ohne sie schläfert Samsung & Co. den
// a11y-Service ein (Lock entbunden → Schutz fällt still aus). Sonst zeigt der
// Banner „geschützt" + versteckt den (offenen) Akku-Step. Konsistent mit der
// Onboarding-`allDone`.
? (urlFilterActive && appDeletionLockActive && deviceAdminActive && batteryUnrestricted)
: (nefilterActive || urlFilterActive) && (mdmManaged || nefilterActive || appDeletionLockActive || !FAMILY_CONTROLS_AVAILABLE);
const urlFilterActiveRef = useRef(urlFilterActive);
useEffect(() => { urlFilterActiveRef.current = urlFilterActive; }, [urlFilterActive]);
// Auto-Sync wenn URL-Filter oder NEFilter (MDM-Mode) beim Mount aktiv ist.
// Im MDM-Mode läuft NEFilter via System-Profil — urlFilterActive ist false (kein VPN),
// aber nefilterActive=true. Sync muss auch in diesem Fall laufen.
const syncedOnceRef = useRef(false);
useEffect(() => {
if (!urlFilterActive && !nefilterActive) return;
if (syncedOnceRef.current) return;
syncedOnceRef.current = true;
syncBlocklist().then((res) => {
console.log('[blocker] auto-sync on mount:', res);
if (res.ok) refresh();
});
}, [urlFilterActive, nefilterActive, syncBlocklist, refresh]);
// Layer 2 / VIP: webContent-Domain-Liste IMMER beim Mount syncen — ungated,
// da Layer 2 an Family Controls hängt, nicht am URL-Filter.
const webContentSyncedRef = useRef(false);
useEffect(() => {
if (webContentSyncedRef.current) return;
webContentSyncedRef.current = true;
syncWebContent();
}, [syncWebContent]);
// Wenn User aus System-Settings zurückkommt (z.B. nach a11y-Aktivierung) → State neu laden.
useEffect(() => {
const sub = AppState.addEventListener('change', (next) => {
if (next === 'active') {
refresh();
syncWebContent();
}
});
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 };
}
}
// ─── Akku-Ausnahme (gegen Samsung-Sleep, der den a11y-Lock entbindet) ──
async function handleRequestBattery() {
// System-Dialog „Akku-Optimierung ignorieren?" (ein Tap). Der State zieht via
// AppState-'active'-Refresh nach, wenn der User zurückkommt.
return protection.requestIgnoreBatteryOptimizations();
}
async function handleOpenAppDetails() {
// Samsung-Sonderweg: App-Detail → Akku „Uneingeschränkt" + raus aus
// „Schlafende Apps" (deckt der AOSP-Whitelist-Dialog nicht ab).
return protection.openAppDetailsSettings();
}
// ─── Activate-Handler pro Layer ──────────────────────────────────────
async function handleActivateUrlFilter() {
try {
const result = await activateUrlFilter();
console.log('[blocker] activateUrlFilter:', result);
if (!result.enabled) {
// iOS-spezifisch: NEFilterErrorDomain code 5 = User hat „Nicht erlauben"
// im System-Dialog getippt → iOS cached den Denied-State. Special-Sheet
// statt rohem Alert (Recovery via removeFromPreferences + Settings-Deep-Link).
const isPermissionDenied =
Platform.OS === 'ios' &&
typeof result.error === 'string' &&
/NEFilterErrorDomain:\s*5/i.test(result.error);
if (isPermissionDenied) {
setPermissionDeniedOpen(true);
return result;
}
Alert.alert(
t('blocker.activate_url_failed_title'),
result.error ?? t('blocker.activate_url_failed_msg'),
[
{ text: t('common.ok') },
{ text: t('blocker.activate_settings_btn'), onPress: () => protection.openSystemSettings() },
],
);
} else {
const sync = await syncBlocklist();
console.log('[blocker] post-activate sync:', sync);
if (sync.ok) {
await refresh();
} else {
Alert.alert(
t('blocker.sync_list_failed_title'),
sync.error ?? t('blocker.sync_list_failed_msg'),
);
}
}
return result;
} catch (e: any) {
console.error('[blocker] activateUrlFilter threw:', e);
Alert.alert(t('blocker.activation_failed_title'), e?.message ?? t('common.unknown_error'));
return { enabled: false };
}
}
async function handleActivateFamilyControls() {
try {
const result = await activateFamilyControls();
console.log('[blocker] activateFamilyControls:', result);
// `accessibility_pending` = a11y-Berechtigung fehlt noch → System-Settings wurden
// geöffnet. Kein Fehler-Modal (sonst Modal-Loop bei jedem Tap).
if (!result.enabled && result.error !== 'accessibility_pending') {
// iOS: NSCocoaErrorDomain code 4099 = XPC-Communication-Failure zum
// FamilyControls-Daemon (häufig nach „Don't Allow" oder bei iOS-Auth-
// Daemon-State-Issue). Recovery-Sheet statt rohem Alert — mit Anleitung
// (Restart Device / Settings / App reinstall).
const isXpcFailure =
Platform.OS === 'ios' &&
typeof result.error === 'string' &&
/NSCocoaErrorDomain:\s*4099/i.test(result.error);
if (isXpcFailure) {
setFamilyControlsErrorOpen(true);
return result;
}
Alert.alert(
t('blocker.activate_app_lock_failed_title'),
result.error ?? t('blocker.activate_app_lock_failed_msg'),
);
}
return result;
} catch (e: any) {
console.error('[blocker] activateFamilyControls threw:', e);
Alert.alert(t('blocker.activation_failed_title'), e?.message ?? t('common.unknown_error'));
}
return { enabled: false };
}
// ─── Screen Time Passcode (iOS Layer 3) ─────────────────────────────
function handleGenerateScreentimeCode() {
const code = Math.floor(1000 + Math.random() * 9000).toString();
setScreentimeCode(code);
setScreentimeConfirmed(false);
}
async function handleScreentimeConfirm() {
if (!screentimeCode) return;
setScreentimeSaving(true);
try {
await protection.saveScreenTimePasscode(screentimeCode);
setScreentimeConfirmed(true);
setScreentimeCode(null);
} catch (e: any) {
Alert.alert(t('common.error'), e?.message ?? t('common.unknown_error'));
} finally {
setScreentimeSaving(false);
}
}
// ─── 3-Click Cooldown-Trigger ────────────────────────────────────────
function openDetails() {
setDetailsOpen(true);
}
function fromDetailsToExplainer() {
setDetailsOpen(false);
setTimeout(() => setExplainerOpen(true), 250);
}
function deflectToLyra() {
setDetailsOpen(false);
setTimeout(() => router.push('/lyra' as any), 250);
}
function deflectToBreathe() {
setExplainerOpen(false);
setTimeout(() => router.push('/urge' as any), 250);
}
async function handleStartCooldown(reason: string) {
await requestDeactivation(reason);
}
async function handleCancelCooldown() {
try {
await cancelDeactivation();
} catch (e: any) {
Alert.alert(t('common.error'), e?.message ?? t('blocker.deactivation_cancel_failed'));
}
}
const bypassAlertShownRef = useRef(false);
useEffect(() => {
if (state?.phase !== 'recoveringFromBypass') {
bypassAlertShownRef.current = false;
return;
}
if (bypassAlertShownRef.current) return;
bypassAlertShownRef.current = true;
setProtectionOffOpen(true);
}, [state?.phase]);
// ─── Render ──────────────────────────────────────────────────────────
return (
<View className="flex-1 bg-neutral-50">
<AppHeader />
{loading && !state ? (
<View style={{ flex: 1, alignItems: 'center', justifyContent: 'center' }}>
<ActivityIndicator size="large" color="#737373" />
</View>
) : state ? (
<>
<ScrollView
contentContainerStyle={{
padding: 16,
paddingBottom: tabBarHeight + 80,
gap: 14,
}}
showsVerticalScrollIndicator={false}
>
{/* Locked-In Mode (FC / NEFilter aktiv) → NUR Schutz-Status + Cooldown-Pfad */}
{lockedIn ? (
<ProtectionLockedCard
state={state}
mdmManaged={mdmManaged}
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}
batteryUnrestricted={batteryUnrestricted}
onActivateVpn={handleActivateUrlFilter}
onActivateAccessibility={handleActivateFamilyControls}
onRequestDeviceAdmin={handleRequestDeviceAdmin}
onRequestBattery={handleRequestBattery}
onOpenAppDetails={handleOpenAppDetails}
colors={colors}
t={t}
/>
) : (
<View style={{ gap: 10 }}>
<LayerSwitchCard
icon="globe-outline"
title={t('blocker.layers_url_filter_title')}
subtitle={
urlFilterActive
? t('blocker.layers_url_filter_subtitle_active')
: t('blocker.layers_url_filter_subtitle_inactive')
}
active={urlFilterActive}
onActivate={handleActivateUrlFilter}
/>
</View>
)}
{/* CooldownBanner */}
{state.cooldown.active && (
<CooldownBanner
remainingFormatted={cooldownRemainingFormatted}
onCancel={handleCancelCooldown}
/>
)}
{/* Sektion 1: Meine Filter (unified web + mail_domain) */}
<MyFiltersList
domains={domains}
tier={tier}
totalCount={domainCount}
totalLimit={domainLimit}
globalBlocklistCount={state.blocklistCount}
onAddPress={() => setAddSheetOpen(true)}
onSubmitDomain={submitDomain}
colors={colors}
/>
{/* Sektion 2: VIP-Liste (Zweitschutz, collapsible) */}
<VipDomainList
domains={domains}
open={vipOpen}
onToggle={() => setVipOpen((v) => !v)}
colors={colors}
/>
</ScrollView>
{/* Sheets */}
<AddDomainSheet
visible={addSheetOpen}
tier={tier}
onClose={() => {
setAddSheetOpen(false);
refreshDomains();
}}
onAdd={async (pattern, kind, opts) => {
const result = await addDomain(pattern, kind, opts);
if (result.ok) {
syncWebContent();
const sync = await syncBlocklist();
if (sync.ok) refresh();
if (result.vipFull && result.newDomainId) {
setAddSheetOpen(false);
setPendingNewDomainId(result.newDomainId);
// AddDomainSheet erst zu Ende dismissen lassen, DANN den
// VipSwapSheet präsentieren — sonst verschluckt iOS das
// zweite Modal (gleiches Muster wie fromDetailsToExplainer).
setTimeout(() => setVipSwapOpen(true), 320);
}
}
return result;
}}
/>
<VipSwapSheet
visible={vipSwapOpen}
newDomainId={pendingNewDomainId}
candidates={domains}
onClose={() => {
setVipSwapOpen(false);
setPendingNewDomainId('');
}}
onSwap={async (newId, evictedId) => {
const result = await submitVipSwap(newId, evictedId);
if (result.ok) {
syncWebContent();
}
return result;
}}
/>
<ProtectionDetailsSheet
visible={detailsOpen}
state={state}
mdmManaged={mdmManaged}
onClose={() => setDetailsOpen(false)}
onRequestDeactivation={fromDetailsToExplainer}
onTalkToLyra={deflectToLyra}
/>
<DeactivationExplainerSheet
visible={explainerOpen}
onClose={() => setExplainerOpen(false)}
onBreathe={deflectToBreathe}
onStartCooldown={handleStartCooldown}
/>
<PermissionDeniedSheet
visible={permissionDeniedOpen}
onClose={() => setPermissionDeniedOpen(false)}
onRetry={async () => {
const res = await protection.resetUrlFilter();
if (res.enabled) {
await refresh();
}
return res;
}}
/>
<PermissionDeniedSheet
visible={familyControlsErrorOpen}
onClose={() => setFamilyControlsErrorOpen(false)}
variant="family_controls"
onRetry={async () => {
const res = await protection.activateFamilyControls();
if (res.enabled) {
await refresh();
}
return res;
}}
/>
<ProtectionOffSheet
visible={protectionOffOpen}
onClose={() => setProtectionOffOpen(false)}
onReactivate={() => handleActivateUrlFilter()}
/>
</>
) : null}
</View>
);
}