269 lines
7.8 KiB
TypeScript
269 lines
7.8 KiB
TypeScript
import React, { createContext, useContext, useState, useEffect, useRef } from 'react';
|
|
import { Platform, AppState, AppStateStatus } from 'react-native';
|
|
import { storage } from '../utils/storage';
|
|
import { ApiService } from '../services/api';
|
|
import NetInfo from '@react-native-community/netinfo';
|
|
import { DebugLogger } from '../utils/logger';
|
|
|
|
interface AppConfig {
|
|
branding: {
|
|
app_name?: string;
|
|
app_tagline?: string;
|
|
app_icon_url?: string;
|
|
logo_url?: string;
|
|
splash_image_url?: string;
|
|
brand_color?: string;
|
|
theme_color_primary?: string;
|
|
theme_color_secondary?: string;
|
|
primary_font_family?: string;
|
|
};
|
|
control_center: {
|
|
kill_switch_active?: boolean;
|
|
kill_switch_message?: string;
|
|
maintenance_start_at?: string;
|
|
maintenance_end_at?: string;
|
|
maintenance_bypass_ips?: string;
|
|
announcement_enabled?: boolean;
|
|
announcement_text?: string;
|
|
announcement_type?: 'info' | 'warning' | 'danger';
|
|
};
|
|
app_updates: {
|
|
app_version?: string;
|
|
min_app_version?: string;
|
|
onboarding_version?: string;
|
|
store_url_android?: string;
|
|
store_url_ios?: string;
|
|
store_url_huawei?: string;
|
|
};
|
|
features: {
|
|
enable_registration?: boolean;
|
|
enable_guest_mode?: boolean;
|
|
require_otp_registration?: boolean;
|
|
enable_biometrics?: boolean;
|
|
enable_remember_me?: boolean;
|
|
review_prompt_enabled?: boolean;
|
|
min_actions_before_review?: number;
|
|
region_lock_enabled?: boolean;
|
|
dashboard_categories?: string;
|
|
};
|
|
security_auth: {
|
|
login_title?: string;
|
|
login_subtitle?: string;
|
|
token_ttl_minutes?: number;
|
|
session_max_age?: number;
|
|
login_max_attempts?: number;
|
|
biometric_auth_type?: string;
|
|
oauth_google_enabled?: boolean;
|
|
oauth_apple_enabled?: boolean;
|
|
oauth_facebook_enabled?: boolean;
|
|
};
|
|
connectivity: {
|
|
api_base_url?: string;
|
|
api_version?: string;
|
|
api_timeout_ms?: number;
|
|
api_retry_count?: number;
|
|
request_cache_ttl?: number;
|
|
sync_interval_ms?: number;
|
|
enable_ssl_pinning?: boolean;
|
|
ssl_pinning_hash?: string;
|
|
environment_selector?: string;
|
|
};
|
|
notifications: {
|
|
enable_push_notifications?: boolean;
|
|
fcm_topic_default?: string;
|
|
default_channel_id?: string;
|
|
notification_sound_enabled?: boolean;
|
|
badge_count_enabled?: boolean;
|
|
priority_level?: string;
|
|
};
|
|
support_social: {
|
|
support_email?: string;
|
|
support_whatsapp?: string;
|
|
live_chat_url?: string;
|
|
faq_url?: string;
|
|
privacy_policy_url?: string;
|
|
social_instagram_url?: string;
|
|
social_twitter_url?: string;
|
|
social_facebook_url?: string;
|
|
social_youtube_url?: string;
|
|
faq_json?: any[];
|
|
help_topics_json?: any[];
|
|
};
|
|
analytics_system: {
|
|
crashlytics_enabled?: boolean;
|
|
log_level?: string;
|
|
event_sampling_rate?: string;
|
|
google_analytics_id?: string;
|
|
gdpr_compliance_enabled?: boolean;
|
|
target_sdk_version?: string;
|
|
system_timezone?: string;
|
|
default_locale?: string;
|
|
};
|
|
localization?: {
|
|
[lang: string]: Record<string, string>;
|
|
};
|
|
}
|
|
|
|
interface ConfigContextType {
|
|
config: AppConfig | null;
|
|
isLoading: boolean;
|
|
isSyncing: boolean;
|
|
isConnected: boolean;
|
|
syncConfig: () => Promise<void>;
|
|
}
|
|
|
|
const ConfigContext = createContext<ConfigContextType | undefined>(undefined);
|
|
|
|
const STORAGE_KEY = 'cached_mobile_config';
|
|
const ETAG_KEY = 'cached_config_etag';
|
|
|
|
// Minimum sync interval: 3 seconds to feel "instant" while avoiding excessive load
|
|
const MIN_SYNC_INTERVAL_MS = 3_000;
|
|
const DEFAULT_SYNC_INTERVAL_MS = 5_000;
|
|
|
|
export function ConfigProvider({ children }: { children: React.ReactNode }) {
|
|
const [config, setConfig] = useState<AppConfig | null>(null);
|
|
const [isLoading, setIsLoading] = useState(true);
|
|
const [isConnected, setIsConnected] = useState(true);
|
|
const [isSyncing, setIsSyncing] = useState(false);
|
|
const isSyncingRef = useRef(false);
|
|
const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
|
const prevConnected = useRef<boolean | null>(null);
|
|
|
|
useEffect(() => {
|
|
// 1. Monitor network connection
|
|
const unsubscribeNet = NetInfo.addEventListener(state => {
|
|
const connected = !!state.isConnected;
|
|
|
|
if (prevConnected.current !== null && prevConnected.current !== connected) {
|
|
DebugLogger.log(`Network status changed: ${connected ? 'Online' : 'Offline'}`, connected ? 'success' : 'error');
|
|
if (connected) syncConfig();
|
|
}
|
|
|
|
setIsConnected(connected);
|
|
prevConnected.current = connected;
|
|
});
|
|
|
|
// 2. Monitor AppState (Sync on foreground)
|
|
const subscription = AppState.addEventListener('change', (nextAppState) => {
|
|
if (nextAppState === 'active') {
|
|
syncConfig();
|
|
}
|
|
});
|
|
|
|
loadInitialConfig();
|
|
|
|
return () => {
|
|
unsubscribeNet();
|
|
subscription.remove();
|
|
if (intervalRef.current) clearInterval(intervalRef.current);
|
|
};
|
|
}, []);
|
|
|
|
// Re-setup interval when config changes (for dynamic sync_interval_ms)
|
|
useEffect(() => {
|
|
if (intervalRef.current) clearInterval(intervalRef.current);
|
|
|
|
const rawInterval = config?.connectivity?.sync_interval_ms ?? DEFAULT_SYNC_INTERVAL_MS;
|
|
// Clamp to minimum to avoid hammering server
|
|
const safeInterval = Math.max(rawInterval, MIN_SYNC_INTERVAL_MS);
|
|
|
|
intervalRef.current = setInterval(() => {
|
|
syncConfig();
|
|
}, safeInterval);
|
|
|
|
return () => {
|
|
if (intervalRef.current) clearInterval(intervalRef.current);
|
|
};
|
|
}, [config?.connectivity?.sync_interval_ms]);
|
|
|
|
const loadInitialConfig = async () => {
|
|
try {
|
|
// 1. Load from cache first (offline-first approach)
|
|
const str = await storage.get(STORAGE_KEY);
|
|
if (str) {
|
|
try {
|
|
const cached = JSON.parse(str);
|
|
setConfig(cached);
|
|
setIsLoading(false);
|
|
} catch {
|
|
// Cache is corrupted, remove it
|
|
await storage.remove(STORAGE_KEY);
|
|
}
|
|
}
|
|
|
|
// 2. Fetch fresh config in background (non-blocking)
|
|
await syncConfig();
|
|
} catch (error) {
|
|
console.warn('[Config] Initialization error:', error);
|
|
} finally {
|
|
setIsLoading(false);
|
|
}
|
|
};
|
|
|
|
const syncConfig = async () => {
|
|
if (isSyncingRef.current) return;
|
|
isSyncingRef.current = true;
|
|
setIsSyncing(true);
|
|
|
|
try {
|
|
const etag = await storage.get(ETAG_KEY);
|
|
const response = await ApiService.syncMobileConfig(etag);
|
|
|
|
if (response?.status === 'not_modified') {
|
|
DebugLogger.log('Config is up to date (ETag match)', 'sync');
|
|
return;
|
|
}
|
|
|
|
if (response?.status === 'success' && response?.data) {
|
|
const freshConfig = response.data;
|
|
setConfig(freshConfig);
|
|
|
|
DebugLogger.log(`Config updated with ETag: ${response.etag?.substring(0, 8)}...`, 'sync');
|
|
// Persist to cache
|
|
await storage.save(STORAGE_KEY, JSON.stringify(freshConfig));
|
|
if (response.etag) {
|
|
await storage.save(ETAG_KEY, response.etag);
|
|
}
|
|
}
|
|
} catch (error: any) {
|
|
DebugLogger.log(`Sync error: ${error.message}`, 'error');
|
|
console.warn('[Config] Sync failed (offline?):', error);
|
|
} finally {
|
|
isSyncingRef.current = false;
|
|
setIsSyncing(false);
|
|
setIsLoading(false);
|
|
}
|
|
};
|
|
|
|
return (
|
|
<ConfigContext.Provider value={{ config, isLoading, isSyncing, isConnected, syncConfig }}>
|
|
{children}
|
|
</ConfigContext.Provider>
|
|
);
|
|
}
|
|
|
|
export function useAppConfig() {
|
|
const context = useContext(ConfigContext);
|
|
if (context === undefined) {
|
|
return {
|
|
config: {
|
|
branding: { app_name: 'biiproject' },
|
|
control_center: {},
|
|
app_updates: {},
|
|
features: {},
|
|
security_auth: {},
|
|
connectivity: {},
|
|
notifications: {},
|
|
support_social: {},
|
|
analytics_system: {}
|
|
},
|
|
isLoading: false,
|
|
isSyncing: false,
|
|
isConnected: true,
|
|
syncConfig: async () => {}
|
|
} as any;
|
|
}
|
|
return context;
|
|
}
|