SEL-496: Add Firebase Remote Config and dev feature flag screen (#735)

* feat: add remote config support

* update lock

* tweak config logic. add feature flag viewing screen

* add tests

* allow for local overriding of feature flags

* save local override work

* save wip

* clean up ui

* update screen to handle multi value types

* fix tests

* cr feedback and fix tests

* remote config upates. fix tests, codex feedback
This commit is contained in:
Justin Hernandez
2025-07-05 17:29:26 -07:00
committed by GitHub
parent a865da7fd3
commit f98beea498
13 changed files with 1235 additions and 9 deletions

View File

@@ -12,6 +12,7 @@ import { AuthProvider } from './src/providers/authProvider';
import { DatabaseProvider } from './src/providers/databaseProvider';
import { NotificationTrackingProvider } from './src/providers/notificationTrackingProvider';
import { PassportProvider } from './src/providers/passportDataProvider';
import { RemoteConfigProvider } from './src/providers/remoteConfigProvider';
import { initSentry, wrapWithSentry } from './src/Sentry';
initSentry();
@@ -22,15 +23,17 @@ function App(): React.JSX.Element {
return (
<ErrorBoundary>
<YStack f={1} h="100%" w="100%">
<AuthProvider>
<PassportProvider>
<DatabaseProvider>
<NotificationTrackingProvider>
<AppNavigation />
</NotificationTrackingProvider>
</DatabaseProvider>
</PassportProvider>
</AuthProvider>
<RemoteConfigProvider>
<AuthProvider>
<PassportProvider>
<DatabaseProvider>
<NotificationTrackingProvider>
<AppNavigation />
</NotificationTrackingProvider>
</DatabaseProvider>
</PassportProvider>
</AuthProvider>
</RemoteConfigProvider>
</YStack>
</ErrorBoundary>
);

View File

@@ -18,6 +18,11 @@ PODS:
- Firebase/Messaging (10.24.0):
- Firebase/CoreOnly
- FirebaseMessaging (~> 10.24.0)
- Firebase/RemoteConfig (10.24.0):
- Firebase/CoreOnly
- FirebaseRemoteConfig (~> 10.24.0)
- FirebaseABTesting (10.29.0):
- FirebaseCore (~> 10.0)
- FirebaseAnalytics (10.24.0):
- FirebaseAnalytics/AdIdSupport (= 10.24.0)
- FirebaseCore (~> 10.0)
@@ -58,6 +63,16 @@ PODS:
- GoogleUtilities/Reachability (~> 7.8)
- GoogleUtilities/UserDefaults (~> 7.8)
- nanopb (< 2.30911.0, >= 2.30908.0)
- FirebaseRemoteConfig (10.24.0):
- FirebaseABTesting (~> 10.0)
- FirebaseCore (~> 10.0)
- FirebaseInstallations (~> 10.0)
- FirebaseRemoteConfigInterop (~> 10.23)
- FirebaseSharedSwift (~> 10.0)
- GoogleUtilities/Environment (~> 7.8)
- "GoogleUtilities/NSData+zlib (~> 7.8)"
- FirebaseRemoteConfigInterop (10.29.0)
- FirebaseSharedSwift (10.29.0)
- fmt (9.1.0)
- glog (0.3.5)
- GoogleAppMeasurement (10.24.0):
@@ -1689,6 +1704,10 @@ PODS:
- FirebaseCoreExtension
- React-Core
- RNFBApp
- RNFBRemoteConfig (19.3.0):
- Firebase/RemoteConfig (= 10.24.0)
- React-Core
- RNFBApp
- RNGestureHandler (2.24.0):
- DoubleConversion
- glog
@@ -1901,6 +1920,7 @@ DEPENDENCIES:
- RNDeviceInfo (from `../../node_modules/react-native-device-info`)
- "RNFBApp (from `../../node_modules/@react-native-firebase/app`)"
- "RNFBMessaging (from `../../node_modules/@react-native-firebase/messaging`)"
- "RNFBRemoteConfig (from `../../node_modules/@react-native-firebase/remote-config`)"
- RNGestureHandler (from `../../node_modules/react-native-gesture-handler`)
- RNKeychain (from `../../node_modules/react-native-keychain`)
- RNLocalize (from `../../node_modules/react-native-localize`)
@@ -1919,12 +1939,16 @@ SPEC REPOS:
trunk:
- AppAuth
- Firebase
- FirebaseABTesting
- FirebaseAnalytics
- FirebaseCore
- FirebaseCoreExtension
- FirebaseCoreInternal
- FirebaseInstallations
- FirebaseMessaging
- FirebaseRemoteConfig
- FirebaseRemoteConfigInterop
- FirebaseSharedSwift
- GoogleAppMeasurement
- GoogleDataTransport
- GoogleUtilities
@@ -2092,6 +2116,8 @@ EXTERNAL SOURCES:
:path: "../../node_modules/@react-native-firebase/app"
RNFBMessaging:
:path: "../../node_modules/@react-native-firebase/messaging"
RNFBRemoteConfig:
:path: "../../node_modules/@react-native-firebase/remote-config"
RNGestureHandler:
:path: "../../node_modules/react-native-gesture-handler"
RNKeychain:
@@ -2129,12 +2155,16 @@ SPEC CHECKSUMS:
DoubleConversion: 76ab83afb40bddeeee456813d9c04f67f78771b5
FBLazyVector: 430e10366de01d1e3d57374500b1b150fe482e6d
Firebase: 91fefd38712feb9186ea8996af6cbdef41473442
FirebaseABTesting: d87f56707159bae64e269757a6e963d490f2eebe
FirebaseAnalytics: b5efc493eb0f40ec560b04a472e3e1a15d39ca13
FirebaseCore: 11dc8a16dfb7c5e3c3f45ba0e191a33ac4f50894
FirebaseCoreExtension: 705ca5b14bf71d2564a0ddc677df1fc86ffa600f
FirebaseCoreInternal: df84dd300b561c27d5571684f389bf60b0a5c934
FirebaseInstallations: 913cf60d0400ebd5d6b63a28b290372ab44590dd
FirebaseMessaging: 4d52717dd820707cc4eadec5eb981b4832ec8d5d
FirebaseRemoteConfig: 95dddc50496b37eef199dadce850d5652b534b43
FirebaseRemoteConfigInterop: 6efda51fb5e2f15b16585197e26eaa09574e8a4d
FirebaseSharedSwift: 20530f495084b8d840f78a100d8c5ee613375f6e
fmt: 4c2741a687cc09f0634a2e2c72a838b99f1ff120
glog: 69ef571f3de08433d766d614c73a9838a06bf7eb
GoogleAppMeasurement: f3abf08495ef2cba7829f15318c373b8d9226491
@@ -2217,6 +2247,7 @@ SPEC CHECKSUMS:
RNDeviceInfo: d863506092aef7e7af3a1c350c913d867d795047
RNFBApp: 4097f75673f8b42a7cd1ba17e6ea85a94b45e4d1
RNFBMessaging: 92325b0d5619ac90ef023a23cfd16fd3b91d0a88
RNFBRemoteConfig: a569bacaa410acfcaba769370e53a787f80fd13b
RNGestureHandler: 9c3877d98d4584891b69d16ebca855ac46507f4d
RNKeychain: 4990d9be2916c60f9ed4f8c484fcd7ced4828b86
RNLocalize: 15463c4d79c7da45230064b4adcf5e9bb984667e

View File

@@ -32,6 +32,17 @@ jest.mock('@react-native-firebase/messaging', () => {
});
});
jest.mock('@react-native-firebase/remote-config', () => {
const mockValue = { asBoolean: jest.fn(() => false) };
const mockConfig = {
setDefaults: jest.fn(),
setConfigSettings: jest.fn(),
fetchAndActivate: jest.fn(() => Promise.resolve(true)),
getValue: jest.fn(() => mockValue),
};
return () => mockConfig;
});
// Mock react-native-haptic-feedback
jest.mock('react-native-haptic-feedback', () => ({
trigger: jest.fn(),

View File

@@ -59,6 +59,7 @@
"@react-native-community/netinfo": "^11.4.1",
"@react-native-firebase/app": "^19.0.1",
"@react-native-firebase/messaging": "^19.0.1",
"@react-native-firebase/remote-config": "^19.0.1",
"@react-navigation/native": "^7.0.14",
"@react-navigation/native-stack": "^7.2.0",
"@robinbobin/react-native-google-drive-api-wrapper": "^2.2.3",

230
app/src/RemoteConfig.ts Normal file
View File

@@ -0,0 +1,230 @@
// SPDX-License-Identifier: BUSL-1.1; Copyright (c) 2025 Social Connect Labs, Inc.; Licensed under BUSL-1.1 (see LICENSE); Apache-2.0 from 2029-06-11
import AsyncStorage from '@react-native-async-storage/async-storage';
import remoteConfig from '@react-native-firebase/remote-config';
export type FeatureFlagValue = string | boolean | number;
interface LocalOverride {
[key: string]: FeatureFlagValue;
}
const LOCAL_OVERRIDES_KEY = 'feature_flag_overrides';
const defaultFlags: Record<string, FeatureFlagValue> = {
aesop: false,
};
// Helper function to detect and parse remote config values
const getRemoteConfigValue = (
key: string,
defaultValue: FeatureFlagValue,
): FeatureFlagValue => {
const configValue = remoteConfig().getValue(key);
if (typeof defaultValue === 'boolean') {
return configValue.asBoolean();
} else if (typeof defaultValue === 'number') {
return configValue.asNumber();
} else if (typeof defaultValue === 'string') {
return configValue.asString();
}
// Fallback: try to infer type from the remote config value
const stringValue = configValue.asString();
if (stringValue === 'true' || stringValue === 'false') {
return configValue.asBoolean();
}
if (!Number.isNaN(Number(stringValue)) && stringValue !== '') {
return configValue.asNumber();
}
return stringValue;
};
// Local override management
export const getLocalOverrides = async (): Promise<LocalOverride> => {
try {
const overrides = await AsyncStorage.getItem(LOCAL_OVERRIDES_KEY);
if (!overrides) {
return {};
}
return JSON.parse(overrides);
} catch (error) {
console.error('Failed to get local overrides:', error);
// If JSON parsing fails, clear the corrupt data
if (error instanceof SyntaxError) {
try {
await AsyncStorage.removeItem(LOCAL_OVERRIDES_KEY);
} catch (removeError) {
console.error('Failed to clear corrupt local overrides:', removeError);
}
}
return {};
}
};
export const setLocalOverride = async (
flag: string,
value: FeatureFlagValue,
): Promise<void> => {
try {
const overrides = await getLocalOverrides();
overrides[flag] = value;
await AsyncStorage.setItem(LOCAL_OVERRIDES_KEY, JSON.stringify(overrides));
} catch (error) {
console.error('Failed to set local override:', error);
}
};
export const clearLocalOverride = async (flag: string): Promise<void> => {
try {
const overrides = await getLocalOverrides();
delete overrides[flag];
await AsyncStorage.setItem(LOCAL_OVERRIDES_KEY, JSON.stringify(overrides));
} catch (error) {
console.error('Failed to clear local override:', error);
}
};
export const clearAllLocalOverrides = async (): Promise<void> => {
try {
await AsyncStorage.removeItem(LOCAL_OVERRIDES_KEY);
} catch (error) {
console.error('Failed to clear all local overrides:', error);
}
};
export const initRemoteConfig = async () => {
await remoteConfig().setDefaults(defaultFlags);
await remoteConfig().setConfigSettings({
minimumFetchIntervalMillis: __DEV__ ? 0 : 3600000,
});
try {
await remoteConfig().fetchAndActivate();
} catch (err) {
console.log('Remote config fetch failed', err);
}
};
export const getFeatureFlag = async <T extends FeatureFlagValue>(
flag: string,
defaultValue: T,
): Promise<T> => {
try {
// Check local overrides first
const localOverrides = await getLocalOverrides();
if (Object.prototype.hasOwnProperty.call(localOverrides, flag)) {
return localOverrides[flag] as T;
}
// Return default value for string flags
if (typeof defaultValue === 'string') {
return defaultValue;
}
// Fall back to remote config for number and boolean flags
return getRemoteConfigValue(flag, defaultValue) as T;
} catch (error) {
console.error('Failed to get feature flag:', error);
return defaultValue;
}
};
export const getAllFeatureFlags = async (): Promise<
Array<{
key: string;
remoteValue?: FeatureFlagValue;
overrideValue?: FeatureFlagValue;
value: FeatureFlagValue;
source: string;
type: 'boolean' | 'string' | 'number';
}>
> => {
try {
const keys = remoteConfig().getAll();
const localOverrides = await getLocalOverrides();
// Get all remote/default flags
const remoteFlags = Object.keys(keys).map(key => {
const configValue = keys[key];
// Try to determine the type from default flags or infer from value
const defaultValue = defaultFlags[key];
const remoteVal =
defaultValue !== undefined
? getRemoteConfigValue(key, defaultValue)
: configValue.asString(); // Default to string if no default defined
const hasLocalOverride = Object.prototype.hasOwnProperty.call(
localOverrides,
key,
);
const overrideVal = hasLocalOverride ? localOverrides[key] : undefined;
const effectiveVal = hasLocalOverride ? overrideVal! : remoteVal;
// Determine type
const type =
typeof effectiveVal === 'boolean'
? 'boolean'
: typeof effectiveVal === 'number'
? 'number'
: 'string';
return {
key,
remoteValue: remoteVal,
overrideValue: overrideVal,
value: effectiveVal,
type: type as 'boolean' | 'string' | 'number',
source: hasLocalOverride
? 'Local Override'
: configValue.getSource() === 'remote'
? 'Remote Config'
: configValue.getSource() === 'default'
? 'Default'
: configValue.getSource() === 'static'
? 'Static'
: 'Unknown',
};
});
// Add any local overrides that don't exist in remote config
const localOnlyFlags = Object.keys(localOverrides)
.filter(key => !Object.prototype.hasOwnProperty.call(keys, key))
.map(key => {
const value = localOverrides[key];
const type =
typeof value === 'boolean'
? 'boolean'
: typeof value === 'number'
? 'number'
: 'string';
return {
key,
remoteValue: undefined,
overrideValue: value,
value: value,
type: type as 'boolean' | 'string' | 'number',
source: 'Local Override',
};
});
return [...remoteFlags, ...localOnlyFlags].sort((a, b) =>
a.key.localeCompare(b.key),
);
} catch (error) {
console.error('Failed to get all feature flags:', error);
return [];
}
};
export const refreshRemoteConfig = async () => {
try {
await remoteConfig().fetchAndActivate();
} catch (err) {
console.log('Remote config refresh failed', err);
}
};

View File

@@ -2,6 +2,7 @@
import { NativeStackNavigationOptions } from '@react-navigation/native-stack';
import DevFeatureFlagsScreen from '../screens/dev/DevFeatureFlagsScreen';
import DevHapticFeedbackScreen from '../screens/dev/DevHapticFeedback';
import DevSettingsScreen from '../screens/dev/DevSettingsScreen';
import MockDataScreen from '../screens/dev/MockDataScreen';
@@ -36,6 +37,15 @@ const devScreens = {
},
} as NativeStackNavigationOptions,
},
DevFeatureFlags: {
screen: DevFeatureFlagsScreen,
options: {
title: 'Feature Flags',
headerStyle: {
backgroundColor: white,
},
} as NativeStackNavigationOptions,
},
};
export default devScreens;

View File

@@ -0,0 +1,46 @@
// SPDX-License-Identifier: BUSL-1.1; Copyright (c) 2025 Social Connect Labs, Inc.; Licensed under BUSL-1.1 (see LICENSE); Apache-2.0 from 2029-06-11
import React, { createContext, useContext, useEffect, useState } from 'react';
import { initRemoteConfig } from '../RemoteConfig';
interface RemoteConfigContextValue {
isInitialized: boolean;
error: string | null;
}
const RemoteConfigContext = createContext<RemoteConfigContextValue>({
isInitialized: false,
error: null,
});
export const useRemoteConfig = () => useContext(RemoteConfigContext);
export const RemoteConfigProvider: React.FC<{ children: React.ReactNode }> = ({
children,
}) => {
const [isInitialized, setIsInitialized] = useState(false);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
const init = async () => {
try {
await initRemoteConfig();
setIsInitialized(true);
} catch (err) {
console.error('Failed to initialize remote config:', err);
setError(err instanceof Error ? err.message : 'Unknown error');
// Still set as initialized to not block the app
setIsInitialized(true);
}
};
init();
}, []);
return (
<RemoteConfigContext.Provider value={{ isInitialized, error }}>
{children}
</RemoteConfigContext.Provider>
);
};

View File

@@ -0,0 +1,370 @@
// SPDX-License-Identifier: BUSL-1.1; Copyright (c) 2025 Social Connect Labs, Inc.; Licensed under BUSL-1.1 (see LICENSE); Apache-2.0 from 2029-06-11
import React, { useCallback, useEffect, useState } from 'react';
import {
Button,
Input,
ScrollView,
Switch,
Text,
XStack,
YStack,
} from 'tamagui';
import {
clearAllLocalOverrides,
FeatureFlagValue,
getAllFeatureFlags,
refreshRemoteConfig,
setLocalOverride,
} from '../../RemoteConfig';
import { textBlack } from '../../utils/colors';
interface FeatureFlag {
key: string;
value: FeatureFlagValue;
source: string;
type: 'boolean' | 'string' | 'number';
remoteValue?: FeatureFlagValue;
overrideValue?: FeatureFlagValue;
}
const DevFeatureFlagsScreen: React.FC = () => {
const [featureFlags, setFeatureFlags] = useState<FeatureFlag[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [isTogglingFlag, setIsTogglingFlag] = useState<string | null>(null);
const [lastRefresh, setLastRefresh] = useState<Date | null>(null);
const [textInputValues, setTextInputValues] = useState<
Record<string, string>
>({});
const [errorState, setErrorState] = useState<string | null>(null);
const [inputErrors, setInputErrors] = useState<Record<string, string>>({});
const [debounceTimers, setDebounceTimers] = useState<
Record<string, NodeJS.Timeout>
>({});
const loadFeatureFlags = useCallback(async () => {
try {
setErrorState(null);
const flags = await getAllFeatureFlags();
setFeatureFlags(flags);
setLastRefresh(new Date());
// Initialize text input values for non-boolean flags
const initialTextValues: Record<string, string> = {};
flags.forEach(flag => {
if (flag.type !== 'boolean') {
initialTextValues[flag.key] = String(flag.value);
}
});
setTextInputValues(initialTextValues);
} catch (error) {
console.error('Failed to load feature flags:', error);
setErrorState('Failed to load feature flags. Please try refreshing.');
}
}, []);
const handleRefresh = useCallback(async () => {
setIsLoading(true);
try {
await refreshRemoteConfig();
await loadFeatureFlags();
} catch (error) {
console.error('Failed to refresh feature flags:', error);
} finally {
setIsLoading(false);
}
}, [loadFeatureFlags]);
const handleToggleFlag = useCallback(
async (flagKey: string, currentValue: boolean) => {
setIsTogglingFlag(flagKey);
try {
await setLocalOverride(flagKey, !currentValue);
await loadFeatureFlags();
} catch (error) {
console.error('Failed to toggle flag:', error);
} finally {
setIsTogglingFlag(null);
}
},
[loadFeatureFlags],
);
const handleSaveTextFlag = useCallback(
async (flagKey: string, type: 'string' | 'number') => {
setIsTogglingFlag(flagKey);
try {
const rawValue = textInputValues[flagKey] || '';
let value: FeatureFlagValue;
if (type === 'number') {
value = Number(rawValue);
if (Number.isNaN(value)) {
setInputErrors(prev => ({
...prev,
[flagKey]: 'Please enter a valid number',
}));
return;
}
} else {
value = rawValue;
}
// Clear any previous error for this field
setInputErrors(prev => {
const newErrors = { ...prev };
delete newErrors[flagKey];
return newErrors;
});
await setLocalOverride(flagKey, value);
await loadFeatureFlags();
} catch (error) {
console.error('Failed to save flag:', error);
setInputErrors(prev => ({
...prev,
[flagKey]: 'Failed to save value',
}));
} finally {
setIsTogglingFlag(null);
}
},
[textInputValues, loadFeatureFlags],
);
const handleTextInputChange = useCallback(
(flagKey: string, value: string) => {
setTextInputValues(prev => ({
...prev,
[flagKey]: value,
}));
},
[],
);
const debouncedSave = useCallback(
(flagKey: string, type: 'string' | 'number') => {
// Clear existing timer for this flag if it exists
if (debounceTimers[flagKey]) {
clearTimeout(debounceTimers[flagKey]);
}
// Set a new timer
const timer = setTimeout(() => {
handleSaveTextFlag(flagKey, type);
setDebounceTimers(prev => {
const newTimers = { ...prev };
delete newTimers[flagKey];
return newTimers;
});
}, 500); // 500ms debounce delay
setDebounceTimers(prev => ({
...prev,
[flagKey]: timer,
}));
},
[debounceTimers, handleSaveTextFlag],
);
const handleClearAllOverrides = useCallback(async () => {
setIsLoading(true);
try {
await clearAllLocalOverrides();
await loadFeatureFlags();
} catch (error) {
console.error('Failed to clear all overrides:', error);
} finally {
setIsLoading(false);
}
}, [loadFeatureFlags]);
useEffect(() => {
loadFeatureFlags();
}, [loadFeatureFlags]);
// Cleanup debounce timers on unmount
useEffect(() => {
return () => {
Object.values(debounceTimers).forEach(timer => {
if (timer) {
clearTimeout(timer);
}
});
};
// only clean up on unmount
}, []);
const hasLocalOverrides = featureFlags.some(
flag => flag.source === 'Local Override',
);
const formatDisplayValue = (
value: FeatureFlagValue,
type: string,
): string => {
if (type === 'boolean') {
return value ? 'Enabled' : 'Disabled';
}
return String(value);
};
const renderFlagInput = (flag: FeatureFlag) => {
switch (flag.type) {
case 'boolean':
return (
<Switch
size="$4"
checked={flag.value as boolean}
onCheckedChange={() =>
handleToggleFlag(flag.key, flag.value as boolean)
}
disabled={isTogglingFlag === flag.key}
bg={flag.value ? '$green7Light' : '$gray4'}
style={{ minWidth: 48, minHeight: 36, alignSelf: 'flex-end' }}
>
<Switch.Thumb animation="quick" bc="$white" />
</Switch>
);
case 'string':
case 'number':
return (
<YStack f={1} gap="$1">
<Input
value={textInputValues[flag.key] || ''}
onChangeText={value => {
handleTextInputChange(flag.key, value);
// Debounced autosave
debouncedSave(flag.key, flag.type as 'string' | 'number');
}}
placeholder={
flag.type === 'number'
? 'Enter number value'
: 'Enter text value'
}
keyboardType={flag.type === 'number' ? 'numeric' : 'default'}
disabled={isTogglingFlag === flag.key}
borderRadius={12}
borderWidth={1}
borderColor={inputErrors[flag.key] ? '$red6' : '$gray6'}
bg="$gray2"
px="$3"
py="$2"
fontSize="$4"
style={{ minHeight: 36 }}
/>
{inputErrors[flag.key] && (
<Text color="$red11" fontSize="$2" pl="$2">
{inputErrors[flag.key]}
</Text>
)}
</YStack>
);
default:
return null;
}
};
return (
<YStack f={1} bg="white" px="$4" pt="$4">
<YStack mb="$4">
<XStack justifyContent="space-between" alignItems="center">
<XStack alignItems="center" gap="$2">
<Button size="$3" onPress={handleRefresh} disabled={isLoading}>
{isLoading ? 'Refreshing...' : 'Refresh'}
</Button>
{hasLocalOverrides && (
<Button
size="$3"
onPress={handleClearAllOverrides}
disabled={isLoading}
>
Reset
</Button>
)}
</XStack>
{lastRefresh && (
<Text fontSize="$2" color="$gray9">
Last updated: {lastRefresh.toLocaleTimeString()}
</Text>
)}
</XStack>
</YStack>
<ScrollView showsVerticalScrollIndicator={false} mt="$4">
<YStack gap="$3" pb="$8">
{errorState && (
<YStack
p="$4"
borderWidth={1}
borderColor="$red6"
borderRadius="$4"
bg="$red2"
alignItems="center"
gap="$2"
>
<Text color="$red11" fontSize="$4" textAlign="center">
{errorState}
</Text>
</YStack>
)}
{featureFlags.length === 0 ? (
<YStack
p="$4"
borderWidth={1}
borderColor="$gray6"
borderRadius="$4"
bg="$gray2"
alignItems="center"
gap="$2"
>
<Text color={textBlack} fontSize="$4" textAlign="center">
No feature flags found
</Text>
<Text
color={textBlack}
fontSize="$3"
textAlign="center"
opacity={0.7}
>
Feature flags will appear here once they are configured in
Firebase Remote Config
</Text>
</YStack>
) : (
featureFlags.map(flag => (
<YStack
key={flag.key}
p="$3"
borderWidth={1}
borderColor="$gray6"
borderRadius="$4"
mb="$2"
>
<XStack justifyContent="space-between" alignItems="center">
<YStack f={1} mr="$4">
<Text fontSize="$4" fontWeight="500">
{flag.key}
</Text>
{flag.remoteValue !== undefined && (
<Text fontSize="$2" color="$gray9" mt="$1">
Default:{' '}
{formatDisplayValue(flag.remoteValue, flag.type)}
</Text>
)}
</YStack>
<XStack alignItems="center" gap="$3" f={1} jc="flex-end">
{renderFlagInput(flag)}
</XStack>
</XStack>
</YStack>
))
)}
</YStack>
</ScrollView>
</YStack>
);
};
export default DevFeatureFlagsScreen;

View File

@@ -52,6 +52,7 @@ function SelectableText({ children, ...props }: DevSettingsScreenProps) {
const items = [
'DevSettings',
'DevFeatureFlags',
'DevHapticFeedback',
'Splash',
'Launch',

View File

@@ -0,0 +1,388 @@
// SPDX-License-Identifier: BUSL-1.1; Copyright (c) 2025 Social Connect Labs, Inc.; Licensed under BUSL-1.1 (see LICENSE); Apache-2.0 from 2029-06-11
import { jest } from '@jest/globals';
// Mock AsyncStorage with a default export
jest.mock('@react-native-async-storage/async-storage', () => ({
__esModule: true,
default: {
getItem: jest.fn(),
setItem: jest.fn(),
removeItem: jest.fn(),
},
}));
// Mock Firebase Remote Config with proper setup
const mockRemoteConfigInstance = {
setDefaults: jest.fn(),
setConfigSettings: jest.fn(),
fetchAndActivate: jest.fn(),
getValue: jest.fn(),
getAll: jest.fn(),
};
jest.mock('@react-native-firebase/remote-config', () => ({
__esModule: true,
default: () => mockRemoteConfigInstance,
}));
// Import the mocked AsyncStorage for test controls
import AsyncStorage from '@react-native-async-storage/async-storage';
// Get the mock instances
const mockAsyncStorage = AsyncStorage as jest.Mocked<typeof AsyncStorage>;
const mockRemoteConfig = mockRemoteConfigInstance as jest.Mocked<
typeof mockRemoteConfigInstance
>;
// Now import the module under test
import {
clearAllLocalOverrides,
clearLocalOverride,
getAllFeatureFlags,
getFeatureFlag,
getLocalOverrides,
setLocalOverride,
} from '../../src/RemoteConfig';
describe('RemoteConfig', () => {
beforeEach(() => {
jest.clearAllMocks();
mockAsyncStorage.getItem.mockResolvedValue('{}');
mockAsyncStorage.setItem.mockResolvedValue();
mockAsyncStorage.removeItem.mockResolvedValue();
});
// Suppress console errors during testing
beforeAll(() => {
jest.spyOn(console, 'error').mockImplementation(() => {});
});
afterAll(() => {
jest.restoreAllMocks();
});
describe('getFeatureFlag', () => {
it('should return default value when Firebase getValue fails', async () => {
mockRemoteConfig.getValue.mockImplementation(() => {
throw new Error('Firebase error');
});
const result = await getFeatureFlag('test_feature', true);
expect(result).toBe(true);
});
it('should return local override value when present', async () => {
const mockOverrides = {
testFlag: 'override value',
};
mockAsyncStorage.getItem.mockResolvedValue(JSON.stringify(mockOverrides));
const result = await getFeatureFlag('testFlag', 'default value');
expect(result).toBe('override value');
});
it('should return default value when no override exists', async () => {
mockAsyncStorage.getItem.mockResolvedValue('{}');
mockRemoteConfig.getValue.mockReturnValue({
asString: () => 'remote value',
asBoolean: () => false,
asNumber: () => 0,
getSource: () => 'remote',
});
const result = await getFeatureFlag('testFlag', 'default value');
expect(result).toBe('default value');
});
it('should preserve type for number flags', async () => {
mockAsyncStorage.getItem.mockResolvedValue('{}');
mockRemoteConfig.getValue.mockReturnValue({
asString: () => '42',
asBoolean: () => false,
asNumber: () => 42,
getSource: () => 'remote',
});
const result = await getFeatureFlag('testFlag', 42);
expect(result).toBe(42);
expect(typeof result).toBe('number');
});
it('should preserve type for boolean flags', async () => {
mockAsyncStorage.getItem.mockResolvedValue('{}');
mockRemoteConfig.getValue.mockReturnValue({
asString: () => 'true',
asBoolean: () => true,
asNumber: () => 1,
getSource: () => 'remote',
});
const result = await getFeatureFlag('testFlag', true);
expect(result).toBe(true);
expect(typeof result).toBe('boolean');
});
it('should prioritize local overrides over remote config', async () => {
const mockOverrides = {
testFlag: 'local override',
};
mockAsyncStorage.getItem.mockResolvedValue(JSON.stringify(mockOverrides));
mockRemoteConfig.getValue.mockReturnValue({
asString: () => 'remote value',
asBoolean: () => false,
asNumber: () => 0,
getSource: () => 'remote',
});
const result = await getFeatureFlag('testFlag', 'default value');
expect(result).toBe('local override');
// Remote config should not be called when local override exists
expect(mockRemoteConfig.getValue).not.toHaveBeenCalled();
});
});
describe('getAllFeatureFlags', () => {
it('should return empty array when Firebase getAll fails', async () => {
mockRemoteConfig.getAll.mockImplementation(() => {
throw new Error('Firebase error');
});
const result = await getAllFeatureFlags();
expect(result).toEqual([]);
});
it('should return complete feature flag structure', async () => {
// Reset all mocks to clean state
jest.clearAllMocks();
const mockRemoteFlags = {
testFlag: {
asString: () => 'test value',
asBoolean: () => false,
asNumber: () => 0,
getSource: () => 'remote' as const,
},
};
const mockLocalOverrides = {
testFlag: 'overridden value',
localOnlyFlag: 'local only',
};
// Configure mocks
mockRemoteConfig.getAll.mockReturnValue(mockRemoteFlags);
mockAsyncStorage.getItem.mockResolvedValue(
JSON.stringify(mockLocalOverrides),
);
const result = await getAllFeatureFlags();
// Check that the function returns an array
expect(Array.isArray(result)).toBe(true);
// Check that each flag has the expected structure
result.forEach(flag => {
expect(flag).toHaveProperty('key');
expect(flag).toHaveProperty('value');
expect(flag).toHaveProperty('type');
expect(flag).toHaveProperty('source');
expect(flag).toHaveProperty('remoteValue');
expect(flag).toHaveProperty('overrideValue');
expect(['boolean', 'string', 'number']).toContain(flag.type);
});
});
it('should return correct flag values with overrides', async () => {
const mockRemoteFlags = {
test_flag: {
asString: () => 'test value',
asBoolean: () => false,
asNumber: () => 0,
getSource: () => 'remote' as const,
},
};
const mockLocalOverrides = {};
// Configure mocks
mockRemoteConfig.getAll.mockReturnValue(mockRemoteFlags);
mockRemoteConfig.getValue.mockReturnValue(mockRemoteFlags.test_flag);
mockAsyncStorage.getItem.mockResolvedValue(
JSON.stringify(mockLocalOverrides),
);
const result = await getAllFeatureFlags();
expect(result).toEqual([
{
key: 'test_flag',
value: 'test value',
source: 'Remote Config',
type: 'string',
remoteValue: 'test value',
overrideValue: undefined,
},
]);
});
it('should return correct flag values with local overrides', async () => {
const mockRemoteFlags = {
test_flag: {
asString: () => 'true',
asBoolean: () => true,
asNumber: () => 1,
getSource: () => 'remote' as const,
},
};
const mockLocalOverrides = {
test_flag: false,
};
// Configure mocks
mockRemoteConfig.getAll.mockReturnValue(mockRemoteFlags);
mockRemoteConfig.getValue.mockReturnValue(mockRemoteFlags.test_flag);
mockAsyncStorage.getItem.mockResolvedValue(
JSON.stringify(mockLocalOverrides),
);
const result = await getAllFeatureFlags();
expect(result).toEqual([
{
key: 'test_flag',
value: false,
source: 'Local Override',
type: 'boolean',
remoteValue: 'true',
overrideValue: false,
},
]);
});
it('should handle local-only flags correctly', async () => {
const mockRemoteFlags = {};
const mockLocalOverrides = {
local_only_flag: 'local value',
};
// Configure mocks
mockRemoteConfig.getAll.mockReturnValue(mockRemoteFlags);
mockAsyncStorage.getItem.mockResolvedValue(
JSON.stringify(mockLocalOverrides),
);
const result = await getAllFeatureFlags();
expect(result).toEqual([
{
key: 'local_only_flag',
value: 'local value',
source: 'Local Override',
type: 'string',
remoteValue: undefined,
overrideValue: 'local value',
},
]);
});
});
describe('Local Override Management', () => {
it('should store and retrieve mixed types correctly', async () => {
const mockOverrides = {
stringFlag: 'hello world',
booleanFlag: true,
numberFlag: 42,
};
mockAsyncStorage.getItem.mockResolvedValue(JSON.stringify(mockOverrides));
const result = await getLocalOverrides();
expect(result).toEqual(mockOverrides);
});
it('should set local override for string values', async () => {
mockAsyncStorage.getItem.mockResolvedValue('{}');
await setLocalOverride('testString', 'hello world');
expect(mockAsyncStorage.setItem).toHaveBeenCalledWith(
'feature_flag_overrides',
JSON.stringify({ testString: 'hello world' }),
);
});
it('should set local override for number values', async () => {
mockAsyncStorage.getItem.mockResolvedValue('{}');
await setLocalOverride('testNumber', 123);
expect(mockAsyncStorage.setItem).toHaveBeenCalledWith(
'feature_flag_overrides',
JSON.stringify({ testNumber: 123 }),
);
});
it('should set local override for boolean values', async () => {
mockAsyncStorage.getItem.mockResolvedValue('{}');
await setLocalOverride('testBoolean', true);
expect(mockAsyncStorage.setItem).toHaveBeenCalledWith(
'feature_flag_overrides',
JSON.stringify({ testBoolean: true }),
);
});
it('should clear specific local override', async () => {
const mockOverrides = {
flag1: 'value1',
flag2: 'value2',
};
mockAsyncStorage.getItem.mockResolvedValue(JSON.stringify(mockOverrides));
await clearLocalOverride('flag1');
expect(mockAsyncStorage.setItem).toHaveBeenCalledWith(
'feature_flag_overrides',
JSON.stringify({ flag2: 'value2' }),
);
});
it('should clear all local overrides', async () => {
await clearAllLocalOverrides();
expect(mockAsyncStorage.removeItem).toHaveBeenCalledWith(
'feature_flag_overrides',
);
});
it('should handle AsyncStorage errors gracefully', async () => {
mockAsyncStorage.getItem.mockRejectedValue(new Error('Storage error'));
const result = await getLocalOverrides();
expect(result).toEqual({});
});
it('should clear AsyncStorage entry when JSON parsing fails', async () => {
// Mock AsyncStorage.getItem to return invalid JSON
mockAsyncStorage.getItem.mockResolvedValue('invalid JSON {');
const result = await getLocalOverrides();
// Should call removeItem to clear the corrupt data
expect(mockAsyncStorage.removeItem).toHaveBeenCalledWith(
'feature_flag_overrides',
);
// Should return empty object
expect(result).toEqual({});
});
});
});

View File

@@ -11,6 +11,7 @@ describe('navigation', () => {
'CloudBackupSettings',
'ConfirmBelongingScreen',
'CreateMock',
'DevFeatureFlags',
'DevHapticFeedback',
'DevSettings',
'Disclaimer',

View File

@@ -0,0 +1,123 @@
// SPDX-License-Identifier: BUSL-1.1; Copyright (c) 2025 Social Connect Labs, Inc.; Licensed under BUSL-1.1 (see LICENSE); Apache-2.0 from 2029-06-11
import { render, waitFor } from '@testing-library/react-native';
import React from 'react';
import { Text } from 'react-native';
import {
RemoteConfigProvider,
useRemoteConfig,
} from '../../../src/providers/remoteConfigProvider';
// Mock the RemoteConfig module
jest.mock('../../../src/RemoteConfig', () => ({
initRemoteConfig: jest.fn(),
}));
import { initRemoteConfig } from '../../../src/RemoteConfig';
const mockInitRemoteConfig = initRemoteConfig as jest.MockedFunction<
typeof initRemoteConfig
>;
// Test component that uses the hook
const TestComponent = () => {
const { isInitialized, error } = useRemoteConfig();
return (
<>
<Text testID="initialized">{isInitialized ? 'true' : 'false'}</Text>
<Text testID="error">{error || 'none'}</Text>
</>
);
};
describe('RemoteConfigProvider', () => {
beforeEach(() => {
jest.clearAllMocks();
console.error = jest.fn();
});
it('should initialize successfully and set isInitialized to true', async () => {
mockInitRemoteConfig.mockResolvedValue(undefined);
const { getByTestId } = render(
<RemoteConfigProvider>
<TestComponent />
</RemoteConfigProvider>,
);
// Initially should be false
expect(getByTestId('initialized')).toHaveTextContent('false');
expect(getByTestId('error')).toHaveTextContent('none');
// Wait for initialization to complete
await waitFor(() => {
expect(getByTestId('initialized')).toHaveTextContent('true');
});
expect(getByTestId('error')).toHaveTextContent('none');
expect(mockInitRemoteConfig).toHaveBeenCalledTimes(1);
});
it('should handle initialization errors gracefully', async () => {
const errorMessage = 'Firebase initialization failed';
mockInitRemoteConfig.mockRejectedValue(new Error(errorMessage));
const { getByTestId } = render(
<RemoteConfigProvider>
<TestComponent />
</RemoteConfigProvider>,
);
// Wait for initialization to complete (with error)
await waitFor(() => {
expect(getByTestId('initialized')).toHaveTextContent('true');
});
expect(getByTestId('error')).toHaveTextContent(errorMessage);
expect(console.error).toHaveBeenCalledWith(
'Failed to initialize remote config:',
expect.any(Error),
);
});
it('should handle non-Error rejection gracefully', async () => {
mockInitRemoteConfig.mockRejectedValue('String error');
const { getByTestId } = render(
<RemoteConfigProvider>
<TestComponent />
</RemoteConfigProvider>,
);
await waitFor(() => {
expect(getByTestId('initialized')).toHaveTextContent('true');
});
expect(getByTestId('error')).toHaveTextContent('Unknown error');
});
it('should only initialize once', async () => {
mockInitRemoteConfig.mockResolvedValue(undefined);
const { rerender } = render(
<RemoteConfigProvider>
<TestComponent />
</RemoteConfigProvider>,
);
await waitFor(() => {
expect(mockInitRemoteConfig).toHaveBeenCalledTimes(1);
});
// Re-render the provider
rerender(
<RemoteConfigProvider>
<TestComponent />
</RemoteConfigProvider>,
);
// Should still only be called once
expect(mockInitRemoteConfig).toHaveBeenCalledTimes(1);
});
});