diff --git a/app/components/Nav/Main/MainNavigator.js b/app/components/Nav/Main/MainNavigator.js index a43daffd15e3..7f5a12c14081 100644 --- a/app/components/Nav/Main/MainNavigator.js +++ b/app/components/Nav/Main/MainNavigator.js @@ -21,6 +21,7 @@ import OptIn from '../../Views/Notifications/OptIn'; import AppInformation from '../../Views/Settings/AppInformation'; import DeveloperOptions from '../../Views/Settings/DeveloperOptions'; import Contacts from '../../Views/Settings/Contacts'; +import FeatureFlagOverride from '../../Views/FeatureFlagOverride'; import Wallet from '../../Views/Wallet'; import Asset from '../../Views/Asset'; import AssetDetails from '../../Views/AssetDetails'; @@ -1124,6 +1125,13 @@ const MainNavigator = () => { ...GeneralSettings.navigationOptions, }} /> + void; +} + +const FeatureFlagRow: React.FC = ({ flag, onToggle }) => { + const tw = useTailwind(); + const theme = useTheme(); + const [localValue, setLocalValue] = useState(flag.value); + + const handleResetOverride = () => { + setLocalValue(flag.originalValue); + onToggle(flag.key, null); // null indicates removal of override + }; + + const renderValueEditor = () => { + switch (flag.type) { + case 'boolean with minimumVersion': + return ( + + { + setLocalValue({ ...localValue, enabled: newValue }); + onToggle(flag.key, newValue); + }} + trackColor={{ + true: theme.colors.primary.default, + false: theme.colors.border.muted, + }} + thumbColor={theme.brandColors.white} + ios_backgroundColor={theme.colors.border.muted} + /> + + Minimum Version: {localValue.minimumVersion} + + + ); + case 'boolean': + return ( + { + setLocalValue(newValue); + onToggle(flag.key, newValue); + }} + trackColor={{ + true: theme.colors.primary.default, + false: theme.colors.border.muted, + }} + thumbColor={theme.brandColors.white} + ios_backgroundColor={theme.colors.border.muted} + /> + ); + case 'string': + case 'number': + return ( + + { + const newValue = + flag.type === 'number' ? Number(text) || 0 : text; + setLocalValue(newValue); + }} + onEndEditing={() => onToggle(flag.key, localValue)} + style={[ + tw.style('border rounded p-2 text-sm'), + { + borderColor: theme.colors.border.default, + color: theme.colors.text.default, + backgroundColor: theme.colors.background.default, + }, + ]} + placeholder={`Enter ${flag.type} value`} + placeholderTextColor={theme.colors.text.muted} + keyboardType={flag.type === 'number' ? 'numeric' : 'default'} + /> + + ); + + case 'object': + return ( + + {Object.keys(localValue).map((itemKey: any) => ( + + {itemKey}: {JSON.stringify(localValue[itemKey])} + + ))} + + ); + case 'array': + return ( + + ); + + default: + return ( + + {String(localValue)} + + ); + } + }; + + return ( + + + + + {flag.key} + + + Type: {flag.type} + {flag.description && ` • ${flag.description}`} + + {flag.isOverridden && flag.originalValue !== undefined && ( + + Original: {JSON.stringify(flag.originalValue)} + + )} + {flag.type === 'object' && renderValueEditor()} + + + {flag.type !== 'object' && renderValueEditor()} + {flag.isOverridden && ( + + + OVERRIDDEN + + + )} + + {flag.isOverridden && ( + + )} + + + + + ); +}; + +const FeatureFlagOverride: React.FC = () => { + const navigation = useNavigation(); + const theme = useTheme(); + const tw = useTailwind(); + + const flagStats = useFeatureFlagStats(); + const { setOverride, removeOverride, clearAllOverrides, featureFlagsList } = + useFeatureFlagOverride(); + + const [searchQuery, setSearchQuery] = useState(''); + const [typeFilter, setTypeFilter] = useState<'all' | 'boolean'>('all'); + + // Filter flags based on search query and type filter + const filteredFlags = useMemo(() => { + let flags = featureFlagsList; + + // Apply type filter + if (typeFilter === 'boolean') { + flags = flags.filter( + (flag) => + flag.type === 'boolean' || + flag.type === 'boolean with minimumVersion', + ); + } + + // Apply search filter + if (searchQuery.trim()) { + flags = flags.filter( + (flag) => + flag.key.toLowerCase().includes(searchQuery.toLowerCase()) || + flag.description?.toLowerCase().includes(searchQuery.toLowerCase()), + ); + } + + return flags; + }, [featureFlagsList, searchQuery, typeFilter]); + + // Set up navigation header + useEffect(() => { + navigation.setOptions( + getNavigationOptionsTitle( + 'Feature Flag Override', + navigation, + false, + theme.colors, + null, + ), + ); + }, [navigation, theme.colors]); + + const handleToggleFlag = useCallback( + (key: string, newValue: any) => { + try { + if (newValue === null) { + // Remove override + removeOverride(key); + } else { + // Set override + setOverride(key, newValue); + } + } catch (error) { + Alert.alert( + 'Error', + `Failed to update feature flag: ${ + error instanceof Error ? error.message : 'Unknown error' + }`, + ); + } + }, + [setOverride, removeOverride], + ); + + const handleClearAllOverrides = useCallback(() => { + Alert.alert( + 'Clear All Overrides', + 'Are you sure you want to clear all feature flag overrides? This action cannot be undone.', + [ + { text: 'Cancel', style: 'cancel' }, + { + text: 'Clear All', + style: 'destructive', + onPress: () => { + try { + clearAllOverrides(); + } catch (error) { + Alert.alert( + 'Error', + `Failed to clear overrides: ${ + error instanceof Error ? error.message : 'Unknown error' + }`, + ); + } + }, + }, + ], + ); + }, [clearAllOverrides]); + + return ( + + {/* Header with stats */} + + + Feature Flag Statistics + {(typeFilter !== 'all' || searchQuery) && ( + + Showing: {filteredFlags.length} flags + + )} + + + + Total: {flagStats.total} + + + Boolean: {flagStats.boolean} + + + Object: {flagStats.object} + + + String: {flagStats.string} + + + + + {/* Search and controls */} + + + + + {/* Filter Buttons */} + + + + + {/* Clear All Button */} + + + + + {/* Feature flags list */} + + {filteredFlags.length === 0 ? ( + + + {searchQuery && typeFilter !== 'all' + ? `No ${typeFilter} feature flags match your search.` + : searchQuery + ? 'No feature flags match your search.' + : typeFilter !== 'all' + ? `No ${typeFilter} feature flags available.` + : 'No feature flags available.'} + + + ) : ( + filteredFlags.map((flag) => ( + + )) + )} + + + ); +}; + +export default FeatureFlagOverride; diff --git a/app/components/Views/FeatureFlagOverride/index.ts b/app/components/Views/FeatureFlagOverride/index.ts new file mode 100644 index 000000000000..f1a140923216 --- /dev/null +++ b/app/components/Views/FeatureFlagOverride/index.ts @@ -0,0 +1 @@ +export { default } from './FeatureFlagOverride'; diff --git a/app/components/Views/Root/index.tsx b/app/components/Views/Root/index.tsx index b53d21df4e77..913b7c7ad52b 100644 --- a/app/components/Views/Root/index.tsx +++ b/app/components/Views/Root/index.tsx @@ -15,6 +15,7 @@ import NavigationProvider from '../../Nav/NavigationProvider'; import ControllersGate from '../../Nav/ControllersGate'; import { isTest } from '../../../util/test/utils'; import FontLoadingGate from './FontLoadingGate'; +import { FeatureFlagOverrideProvider } from '../../../contexts/FeatureFlagOverrideContext'; ///: BEGIN:ONLY_INCLUDE_IF(preinstalled-snaps,external-snaps) import { SnapsExecutionWebView } from '../../../lib/snaps'; ///: END:ONLY_INCLUDE_IF @@ -73,20 +74,22 @@ const Root = ({ foxCode }: RootProps) => { ///: END:ONLY_INCLUDE_IF } - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + diff --git a/app/components/Views/Settings/index.tsx b/app/components/Views/Settings/index.tsx index 8a352ab22bff..88696c0e9cc2 100644 --- a/app/components/Views/Settings/index.tsx +++ b/app/components/Views/Settings/index.tsx @@ -135,6 +135,9 @@ const Settings = () => { const onPressDeveloperOptions = () => { navigation.navigate('DeveloperOptions'); }; + const onPressFeatureFlagOverride = () => { + navigation.navigate(Routes.FEATURE_FLAG_OVERRIDE); + }; const goToManagePermissions = () => { navigation.navigate('PermissionsManager'); @@ -336,6 +339,15 @@ const Settings = () => { onPress={onPressDeveloperOptions} /> )} + {process.env.MM_ENABLE_SETTINGS_PAGE_DEV_OPTIONS === 'true' && ( + + )} FeatureFlagInfo; + featureFlagsList: FeatureFlagInfo[]; + overrides: FeatureFlagOverrides; + setOverride: (key: string, value: any) => void; + removeOverride: (key: string) => void; + clearAllOverrides: () => void; + hasOverride: (key: string) => boolean; + getOverride: (key: string) => any; + getAllOverrides: () => FeatureFlagOverrides; + applyOverrides: (originalFlags: FeatureFlagOverrides) => FeatureFlagOverrides; + getOverrideCount: () => number; +} + +const FeatureFlagOverrideContext = createContext< + FeatureFlagOverrideContextType | undefined +>(undefined); + +interface FeatureFlagOverrideProviderProps { + children: ReactNode; +} + +export const FeatureFlagOverrideProvider: React.FC< + FeatureFlagOverrideProviderProps +> = ({ children }) => { + // Get the initial feature flags from Redux + const rawFeatureFlags = useSelector(selectRemoteFeatureFlags); + + // Local state for overrides + const [overrides, setOverrides] = useState({}); + + const setOverride = useCallback((key: string, value: any) => { + setOverrides((prev) => ({ + ...prev, + [key]: value, + })); + }, []); + + const removeOverride = useCallback((key: string) => { + setOverrides((prev) => { + const newOverrides = { ...prev }; + delete newOverrides[key]; + return newOverrides; + }); + }, []); + + const clearAllOverrides = useCallback(() => { + setOverrides({}); + }, []); + + const hasOverride = useCallback( + (key: string): boolean => key in overrides, + [overrides], + ); + + const getOverride = useCallback( + (key: string): any => overrides[key], + [overrides], + ); + + const getAllOverrides = useCallback((): FeatureFlagOverrides => ({ ...overrides }), [overrides]); + + const applyOverrides = useCallback( + (originalFlags: FeatureFlagOverrides): FeatureFlagOverrides => ({ + ...originalFlags, + ...overrides, + }), + [overrides], + ); + + const featureFlagsWithOverrides = useMemo(() => applyOverrides(rawFeatureFlags), [rawFeatureFlags, applyOverrides]); + + const featureFlags = useMemo(() => { + // Get all unique keys from both raw and overridden flags + const allKeys = new Set([ + ...Object.keys(rawFeatureFlags), + ...Object.keys(featureFlagsWithOverrides), + ...Object.keys(getAllOverrides()), + ]); + const allFlags: { [key: string]: FeatureFlagInfo } = {}; + + // Process all feature flags and return flat list + Array.from(allKeys).forEach((key: string) => { + const originalValue = rawFeatureFlags[key]; + const currentValue = featureFlagsWithOverrides[key]; + const isOverridden = hasOverride(key); + + const flagValue = { + key, + value: currentValue, + originalValue, + type: getFeatureFlagType(currentValue ?? originalValue), + description: getFeatureFlagDescription(key), + isOverridden, + }; + allFlags[key] = flagValue; + }); + return allFlags; + }, [ + rawFeatureFlags, + featureFlagsWithOverrides, + hasOverride, + getAllOverrides, + ]); + + const featureFlagsList = Object.values(featureFlags).sort((a, b) => + a.key.localeCompare(b.key), + ); + + /** + * get a specific feature flag value with overrides applied + */ + const getFeatureFlag = (key: string) => featureFlags[key]; + + const getOverrideCount = useCallback((): number => Object.keys(overrides).length, [overrides]); + + const contextValue: FeatureFlagOverrideContextType = { + featureFlags, + originalFlags: rawFeatureFlags, + getFeatureFlag, + featureFlagsList, + overrides, + setOverride, + removeOverride, + clearAllOverrides, + hasOverride, + getOverride, + getAllOverrides, + applyOverrides, + getOverrideCount, + }; + + return ( + + {children} + + ); +}; + +export const useFeatureFlagOverride = (): FeatureFlagOverrideContextType => { + const context = useContext(FeatureFlagOverrideContext); + if (context === undefined) { + throw new Error( + 'useFeatureFlagOverride must be used within a FeatureFlagOverrideProvider', + ); + } + return context; +}; + +export default FeatureFlagOverrideContext; diff --git a/app/contexts/README.md b/app/contexts/README.md new file mode 100644 index 000000000000..1deb75064aa9 --- /dev/null +++ b/app/contexts/README.md @@ -0,0 +1,133 @@ +# Feature Flag Override Context + +## Overview + +The `FeatureFlagOverrideContext` provides a non-persistent, runtime override system for feature flags using React Context. This allows developers to test different feature flag combinations without modifying the stored configuration. + +## Features + +- ✨ **Non-persistent**: Overrides exist only during the current app session +- 🎯 **Runtime Control**: Toggle flags on-the-fly without app restart +- 📱 **Context-based**: Uses React Context for efficient state management +- 🔄 **Real-time Updates**: Changes are immediately reflected across the app +- 🧪 **Development Friendly**: Perfect for testing and debugging + +## Usage + +### Basic Hook Usage + +```tsx +import { + useFeatureFlagOverride, + useFeatureFlag, + featureFlags, +} from '../contexts/FeatureFlagOverrideContext'; + +const MyComponent = () => { + // Access override methods and values + const { + setOverride, + removeOverride, + clearAllOverrides, + getFeatureFlag, + featureFlags, + } = useFeatureFlagOverride(); + + // Get a specific feature flag + const isNewUIEnabled = getFeatureFlag('newUIEnabled'); + + const handleToggleFlag = () => { + setOverride('newUIEnabled', !isNewUIEnabled); + }; + + return ( + + New UI: {isNewUIEnabled ? 'Enabled' : 'Disabled'} +