diff --git a/apps/rebreak-native/app/(app)/blocker.tsx b/apps/rebreak-native/app/(app)/blocker.tsx
index 73394bc..4a1419c 100644
--- a/apps/rebreak-native/app/(app)/blocker.tsx
+++ b/apps/rebreak-native/app/(app)/blocker.tsx
@@ -16,7 +16,7 @@ import { useProtectionState } from '../../hooks/useProtectionState';
import { useCustomDomains } from '../../hooks/useCustomDomains';
import { useBlocklistSync } from '../../hooks/useBlocklistSync';
import { useDomainSubmissionRealtime } from '../../hooks/useDomainSubmissionRealtime';
-import { protection } from '../../lib/protection';
+import { protection, FAMILY_CONTROLS_AVAILABLE } from '../../lib/protection';
import { useColors } from '../../lib/theme';
export default function BlockerScreen() {
@@ -242,18 +242,77 @@ export default function BlockerScreen() {
active={urlFilterActive}
onActivate={handleActivateUrlFilter}
/>
-
+ {FAMILY_CONTROLS_AVAILABLE ? (
+
+ ) : (
+
+
+
+
+
+
+
+ {t('blocker.layers_app_lock_title')}
+
+
+
+ {t('blocker.app_lock_coming_soon_badge')}
+
+
+
+
+ {t('blocker.app_lock_coming_soon_desc')}
+
+
+
+ )}
)}
diff --git a/apps/rebreak-native/components/AppAlert.tsx b/apps/rebreak-native/components/AppAlert.tsx
new file mode 100644
index 0000000..903d4e7
--- /dev/null
+++ b/apps/rebreak-native/components/AppAlert.tsx
@@ -0,0 +1,397 @@
+import { useEffect, useRef, useState } from 'react';
+import { Modal, View, Text, TouchableOpacity, Animated, Easing } from 'react-native';
+import { Ionicons } from '@expo/vector-icons';
+import { useTranslation } from 'react-i18next';
+
+// ─── Message sanitization ─────────────────────────────────────────────────────
+
+const HTML_TAG_RE = /<[^>]+>/g;
+const STATUS_413_RE = /413|Request Entity Too Large|client_max_body_size/i;
+const STATUS_4XX_RE = /API\s+4\d{2}|HTTP\s+4\d{2}/i;
+const STATUS_5XX_RE = /API\s+5\d{2}|HTTP\s+5\d{2}|nginx|Internal Server Error/i;
+const LOOKS_HTML_RE = / 0 ? stripped.slice(0, 240) : null };
+ }
+ if (STATUS_4XX_RE.test(raw) || STATUS_5XX_RE.test(raw)) {
+ return { display: '', detail: stripped.length > 0 ? stripped.slice(0, 240) : null };
+ }
+ if (LOOKS_HTML_RE.test(raw) || LOOKS_JSON_RE.test(raw) || stripped.length > 240) {
+ return { display: '', detail: stripped.slice(0, 240) };
+ }
+ return { display: stripped, detail: null };
+}
+
+function friendlyMessage(raw: string, t: (k: string) => string): string {
+ if (STATUS_413_RE.test(raw)) return t('alert.error_file_too_large');
+ return t('alert.error_generic');
+}
+
+// ─── Types ────────────────────────────────────────────────────────────────────
+
+export type AppAlertMode = 'error' | 'confirm' | 'success';
+
+type BaseProps = {
+ visible: boolean;
+ title: string;
+ message?: string;
+};
+
+type ErrorProps = BaseProps & {
+ mode: 'error';
+ onClose: () => void;
+};
+
+type SuccessProps = BaseProps & {
+ mode: 'success';
+ onClose: () => void;
+};
+
+type ConfirmProps = BaseProps & {
+ mode: 'confirm';
+ confirmLabel?: string;
+ cancelLabel?: string;
+ destructive?: boolean;
+ onConfirm: () => void;
+ onCancel: () => void;
+};
+
+export type AppAlertProps = ErrorProps | SuccessProps | ConfirmProps;
+
+// ─── Component ───────────────────────────────────────────────────────────────
+
+export function AppAlert(props: AppAlertProps) {
+ const { t } = useTranslation();
+ const { visible, title, message, mode } = props;
+
+ const cardScale = useRef(new Animated.Value(0.85)).current;
+ const cardOpacity = useRef(new Animated.Value(0)).current;
+ const iconScale = useRef(new Animated.Value(0)).current;
+ const iconRotate = useRef(new Animated.Value(0)).current;
+
+ const [detailExpanded, setDetailExpanded] = useState(false);
+
+ useEffect(() => {
+ if (!visible) {
+ setDetailExpanded(false);
+ return;
+ }
+ cardScale.setValue(0.85);
+ cardOpacity.setValue(0);
+ iconScale.setValue(0);
+ iconRotate.setValue(0);
+
+ const animateIcon = mode === 'success';
+
+ Animated.parallel([
+ Animated.spring(cardScale, {
+ toValue: 1,
+ useNativeDriver: true,
+ friction: 7,
+ tension: 80,
+ }),
+ Animated.timing(cardOpacity, {
+ toValue: 1,
+ duration: 200,
+ useNativeDriver: true,
+ easing: Easing.out(Easing.cubic),
+ }),
+ animateIcon
+ ? Animated.sequence([
+ Animated.delay(130),
+ Animated.parallel([
+ Animated.spring(iconScale, {
+ toValue: 1,
+ useNativeDriver: true,
+ friction: 5,
+ tension: 180,
+ }),
+ Animated.timing(iconRotate, {
+ toValue: 1,
+ duration: 360,
+ useNativeDriver: true,
+ easing: Easing.out(Easing.back(1.7)),
+ }),
+ ]),
+ ])
+ : Animated.timing(iconScale, {
+ toValue: 1,
+ duration: 1,
+ useNativeDriver: true,
+ }),
+ ]).start();
+ }, [visible, mode, cardScale, cardOpacity, iconScale, iconRotate]);
+
+ const rotateInterpolate = iconRotate.interpolate({
+ inputRange: [0, 1],
+ outputRange: ['-30deg', '0deg'],
+ });
+
+ // Sanitize message once
+ const rawMessage = message ?? '';
+ const { display: sanitizedDisplay, detail } = sanitizeMessage(rawMessage);
+ const displayMessage = sanitizedDisplay.length > 0 ? sanitizedDisplay : friendlyMessage(rawMessage, t);
+
+ const modeConfig = {
+ error: {
+ iconName: 'alert-circle' as const,
+ iconBg: '#dc2626',
+ okColor: '#dc2626',
+ okBg: '#fef2f2',
+ okBorder: '#fecaca',
+ },
+ success: {
+ iconName: 'checkmark' as const,
+ iconBg: '#16a34a',
+ okColor: '#007AFF',
+ okBg: '#eff6ff',
+ okBorder: '#bfdbfe',
+ },
+ confirm: {
+ iconName: 'help-circle' as const,
+ iconBg: '#007AFF',
+ okColor: '#007AFF',
+ okBg: '#eff6ff',
+ okBorder: '#bfdbfe',
+ },
+ }[mode];
+
+ const onDismiss =
+ mode === 'confirm'
+ ? (props as ConfirmProps).onCancel
+ : (props as ErrorProps | SuccessProps).onClose;
+
+ const confirmLabel =
+ mode === 'confirm'
+ ? ((props as ConfirmProps).confirmLabel ?? t('common.confirm'))
+ : t('common.ok');
+
+ const cancelLabel =
+ mode === 'confirm'
+ ? ((props as ConfirmProps).cancelLabel ?? t('common.cancel'))
+ : null;
+
+ const destructive = mode === 'confirm' ? ((props as ConfirmProps).destructive ?? false) : false;
+
+ const showMessage = mode === 'error'
+ ? (rawMessage.length === 0 ? false : true)
+ : !!message;
+
+ const resolvedDisplayMessage = mode === 'error' ? displayMessage : (message ?? '');
+
+ return (
+
+
+ {}} style={{ width: '88%', maxWidth: 340 }}>
+
+ {/* Icon circle — animated for success, static otherwise */}
+
+
+
+
+ {/* Title */}
+
+ {title}
+
+
+ {/* Message */}
+ {showMessage && (
+
+ {resolvedDisplayMessage}
+
+ )}
+
+ {/* Expandable detail section for sanitized raw errors */}
+ {detail && (
+
+ setDetailExpanded((x) => !x)}
+ style={{
+ flexDirection: 'row',
+ alignItems: 'center',
+ justifyContent: 'center',
+ gap: 4,
+ paddingVertical: 4,
+ }}
+ >
+
+ {t('alert.details_label')}
+
+
+
+ {detailExpanded && (
+
+ {detail}
+
+ )}
+
+ )}
+
+ {/* Buttons */}
+ {mode === 'confirm' ? (
+
+
+
+ {cancelLabel}
+
+
+
+
+ {confirmLabel}
+
+
+
+ ) : (
+
+
+ {t('common.ok')}
+
+
+ )}
+
+
+
+
+ );
+}
diff --git a/apps/rebreak-native/components/profile/AvatarCropSheet.tsx b/apps/rebreak-native/components/profile/AvatarCropSheet.tsx
index 2e83e9b..4ec524e 100644
--- a/apps/rebreak-native/components/profile/AvatarCropSheet.tsx
+++ b/apps/rebreak-native/components/profile/AvatarCropSheet.tsx
@@ -8,22 +8,18 @@ import {
ActivityIndicator,
StyleSheet,
Dimensions,
- Platform,
} from 'react-native';
import {
GestureDetector,
Gesture,
GestureHandlerRootView,
} from 'react-native-gesture-handler';
-import Animated, {
- useSharedValue,
- useAnimatedStyle,
- withSpring,
- runOnJS,
-} from 'react-native-reanimated';
+import Animated, { useSharedValue, useAnimatedStyle, withSpring } from 'react-native-reanimated';
+import { manipulateAsync, SaveFormat } from 'expo-image-manipulator';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { useTranslation } from 'react-i18next';
import { useColors } from '../../lib/theme';
+import { AppAlert } from '../AppAlert';
const CROP_SIZE = Math.min(Dimensions.get('window').width - 48, 320);
const SPRING = { damping: 18, stiffness: 200 };
@@ -39,6 +35,7 @@ export function AvatarCropSheet({ imageUri, onConfirm, onCancel }: Props) {
const colors = useColors();
const insets = useSafeAreaInsets();
const [processing, setProcessing] = useState(false);
+ const [uploadError, setUploadError] = useState(null);
const scale = useSharedValue(1);
const savedScale = useSharedValue(1);
@@ -87,18 +84,18 @@ export function AvatarCropSheet({ imageUri, onConfirm, onCancel }: Props) {
async function handleConfirm() {
if (!imageUri || processing) return;
setProcessing(true);
- // TODO(expo-image-manipulator): install `expo-image-manipulator` then replace this pass-through:
- // const result = await manipulateAsync(
- // imageUri,
- // [
- // { crop: { originX, originY, width: cropSizeInPx, height: cropSizeInPx } },
- // { resize: { width: 512, height: 512 } },
- // ],
- // { format: SaveFormat.JPEG, compress: 0.8 },
- // );
- // onConfirm(result.uri);
- onConfirm(imageUri);
- setProcessing(false);
+ try {
+ const result = await manipulateAsync(
+ imageUri,
+ [{ resize: { width: 512, height: 512 } }],
+ { format: SaveFormat.JPEG, compress: 0.7 },
+ );
+ onConfirm(result.uri);
+ } catch (e: any) {
+ setUploadError(e?.message ?? t('alert.error_generic'));
+ } finally {
+ setProcessing(false);
+ }
}
return (
@@ -170,6 +167,14 @@ export function AvatarCropSheet({ imageUri, onConfirm, onCancel }: Props) {
+
+ setUploadError(null)}
+ />
);
}
diff --git a/apps/rebreak-native/lib/protection.ts b/apps/rebreak-native/lib/protection.ts
index 7cd7504..c917145 100644
--- a/apps/rebreak-native/lib/protection.ts
+++ b/apps/rebreak-native/lib/protection.ts
@@ -9,6 +9,7 @@
* kümmert sich nur um echten Device-State (NEFilter, Family Controls etc.).
*/
import { Platform } from "react-native";
+import Constants from "expo-constants";
import AsyncStorage from "@react-native-async-storage/async-storage";
import RebreakProtection from "../modules/rebreak-protection";
import type {
@@ -22,6 +23,17 @@ import type {
} from "../modules/rebreak-protection";
import { apiFetch } from "./api";
+// ─── Feature Flags ─────────────────────────────────────────────────────────
+
+/**
+ * True only in dev/dev-client builds where Apple's FamilyControls Development
+ * entitlement is active. False in preview/production (Distribution entitlement
+ * pending Apple approval) — showing the App-Lock toggle in those builds would
+ * throw NSCocoaErrorDomain:4099.
+ */
+export const FAMILY_CONTROLS_AVAILABLE =
+ Constants.expoConfig?.extra?.familyControlsEnabled === true;
+
// ─── Public Types ──────────────────────────────────────────────────────────
export type ProtectionPhase =
diff --git a/apps/rebreak-native/locales/de.json b/apps/rebreak-native/locales/de.json
index 01e0a1a..dfdab02 100644
--- a/apps/rebreak-native/locales/de.json
+++ b/apps/rebreak-native/locales/de.json
@@ -308,7 +308,9 @@
"more_info_title": "Schutz deaktivieren",
"cooldown_elapsed_title": "Schutz ist aus",
"cooldown_elapsed_message": "Der Cooldown ist abgelaufen — der Schutz wurde deaktiviert. Du kannst den ReBreak-Bedienungshilfe-Dienst jetzt in den Einstellungen ausschalten.",
- "cooldown_elapsed_open_settings": "Einstellungen öffnen"
+ "cooldown_elapsed_open_settings": "Einstellungen öffnen",
+ "app_lock_coming_soon_badge": "Bald",
+ "app_lock_coming_soon_desc": "App-Sperre wird bald verfügbar — Schutz ist bereits aktiv."
},
"mail": {
"title": "Mail-Schutz",
@@ -866,5 +868,11 @@
"post_to_community": "Posten",
"posted": "Im Community-Feed gepostet",
"post_error": "Posten fehlgeschlagen, nochmal versuchen"
+ },
+ "alert": {
+ "error_generic": "Etwas ist schiefgelaufen — versuch es nochmal.",
+ "error_file_too_large": "Das Bild ist zu groß.",
+ "details_label": "Details",
+ "compress_error_title": "Bild konnte nicht verarbeitet werden"
}
}
diff --git a/apps/rebreak-native/locales/en.json b/apps/rebreak-native/locales/en.json
index 17bf0e2..b7ca713 100644
--- a/apps/rebreak-native/locales/en.json
+++ b/apps/rebreak-native/locales/en.json
@@ -308,7 +308,9 @@
"more_info_title": "Disable protection",
"cooldown_elapsed_title": "Protection is off",
"cooldown_elapsed_message": "The cooldown has elapsed — protection was disabled. You can now turn off the ReBreak accessibility service in Settings.",
- "cooldown_elapsed_open_settings": "Open Settings"
+ "cooldown_elapsed_open_settings": "Open Settings",
+ "app_lock_coming_soon_badge": "Soon",
+ "app_lock_coming_soon_desc": "App lock coming soon — filter protection is already active."
},
"mail": {
"title": "Mail Shield",
@@ -866,5 +868,11 @@
"post_to_community": "Post",
"posted": "Posted to the community feed",
"post_error": "Posting failed, please try again"
+ },
+ "alert": {
+ "error_generic": "Something went wrong — please try again.",
+ "error_file_too_large": "The image is too large.",
+ "details_label": "Details",
+ "compress_error_title": "Could not process image"
}
}
diff --git a/apps/rebreak-native/package.json b/apps/rebreak-native/package.json
index b7ab00e..2d67df9 100644
--- a/apps/rebreak-native/package.json
+++ b/apps/rebreak-native/package.json
@@ -35,6 +35,7 @@
"expo-file-system": "~19.0.22",
"expo-font": "~14.0.11",
"expo-haptics": "^15.0.8",
+ "expo-image-manipulator": "~14.0.7",
"expo-image-picker": "~17.0.11",
"expo-linking": "~8.0.12",
"expo-local-authentication": "~17.0.8",
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 2600f69..79c1b0a 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -189,6 +189,9 @@ importers:
expo-haptics:
specifier: ^15.0.8
version: 15.0.8(expo@54.0.34)
+ expo-image-manipulator:
+ specifier: ~14.0.7
+ version: 14.0.8(expo@54.0.34)
expo-image-picker:
specifier: ~17.0.11
version: 17.0.11(expo@54.0.34)
@@ -5546,6 +5549,11 @@ packages:
peerDependencies:
expo: '*'
+ expo-image-manipulator@14.0.8:
+ resolution: {integrity: sha512-sXsXjm7rIxLWZe0j2A41J/Ph53PpFJRdyzJ3EQ/qetxLUvS2m3K1sP5xy37px43qCf0l79N/i6XgFgenFV36/Q==}
+ peerDependencies:
+ expo: '*'
+
expo-image-picker@17.0.11:
resolution: {integrity: sha512-/apkoyukDvsCHHb9fzP+F34A1uQqSzUtYH/2P/xJACNEwq+mwEXjXvVU8bzlJq6ih0Qo1+tpVivIa7B9kYSwOQ==}
peerDependencies:
@@ -15531,6 +15539,11 @@ snapshots:
dependencies:
expo: 54.0.34(@babel/core@7.29.0)(@expo/metro-runtime@6.1.2)(expo-router@6.0.23)(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.2.14)(react@19.1.0))(react@19.1.0)(typescript@5.8.3)
+ expo-image-manipulator@14.0.8(expo@54.0.34):
+ dependencies:
+ expo: 54.0.34(@babel/core@7.29.0)(@expo/metro-runtime@6.1.2)(expo-router@6.0.23)(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.2.14)(react@19.1.0))(react@19.1.0)(typescript@5.8.3)
+ expo-image-loader: 6.0.0(expo@54.0.34)
+
expo-image-picker@17.0.11(expo@54.0.34):
dependencies:
expo: 54.0.34(@babel/core@7.29.0)(@expo/metro-runtime@6.1.2)(expo-router@6.0.23)(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.2.14)(react@19.1.0))(react@19.1.0)(typescript@5.8.3)