import { Platform } from 'react-native'; import * as SecureStore from 'expo-secure-store'; import Constants from 'expo-constants'; import { storage } from '../utils/storage'; /** * Dynamic API Base URL */ /** * Dynamic API Base URL * * PRIORITY: * 1. MANUAL_API_IP (Explicit override for development) * 2. Cached Remote Config (Production backend-controlled) * 3. Expo Constants / app.json extra.apiUrl * 4. Auto-detected Host IP (Expo Go) * 5. Platform-specific defaults */ const MANUAL_API_IP = ''; // Set this to '192.168.x.x' to override const getBaseUrl = async () => { const version = 'v1'; const apiPath = `/api/${version}`; // 1. Manual Override (Highest Priority for specific IP needs) if (MANUAL_API_IP) { return `http://${MANUAL_API_IP}:8000${apiPath}`; } // 2. Try to get from cached config (Backend-controlled) const cachedConfigStr = await storage.get('cached_mobile_config'); if (cachedConfigStr && !__DEV__) { try { const config = JSON.parse(cachedConfigStr); const remoteBase = config?.connectivity?.api_base_url; if (remoteBase && !remoteBase.includes('biiproject.com')) { return `${remoteBase}${apiPath}`; } } catch {} } // 3. Try Expo Constants / app.json extra (Developer-controlled) const extraApiUrl = Constants.expoConfig?.extra?.apiUrl; if (extraApiUrl) { const cleanBase = extraApiUrl.endsWith('/') ? extraApiUrl.slice(0, -1) : extraApiUrl; return cleanBase.includes('/api/') ? cleanBase : `${cleanBase}${apiPath}`; } // 4. Auto-detect IP from Expo Go (Highest Priority for Local Development) const hostUri = Constants.expoConfig?.hostUri || (Constants as any).manifest2?.extra?.expoGoConfig?.debuggerHost || (Constants as any).manifest?.debuggerHost; if (hostUri) { const ip = hostUri.split(':')[0]; if (ip && ip !== 'localhost' && ip !== '127.0.0.1' && !ip.startsWith('10.0.2.2')) { if (!ip.includes('exp.direct') && !ip.includes('expo.dev')) { return `http://${ip}:8000${apiPath}`; } } } // 5. Fallback for Web if (Platform.OS === 'web') { if (typeof window !== 'undefined' && window.location) { const hostname = (window.location.hostname === 'localhost' || window.location.hostname === '127.0.0.1') ? 'localhost' : window.location.hostname; return `http://${hostname}:8000${apiPath}`; } } // 6. Absolute Fallback (Laravel Sail default tunnel or localhost) return `http://zqfwerzr7b.laravel-sail.site:8080${apiPath}`; }; const getUrl = async (path: string) => { const baseUrl = await getBaseUrl(); const cleanPath = path.startsWith('/') ? path : `/${path}`; const finalUrl = `${baseUrl}${cleanPath}`; if (__DEV__) { console.log(`[API] Req: ${finalUrl}`); } return finalUrl; }; // Helper for authorized requests const getAuthHeaders = async () => { const token = await storage.get('user_token'); return { 'Content-Type': 'application/json', 'Accept': 'application/json', 'Authorization': `Bearer ${token}`, }; }; // Helper for fetching with timeout and retry const fetchWithTimeout = async (url: string, options: any = {}) => { const cachedConfigStr = await storage.get('cached_mobile_config'); let config: any = null; try { config = cachedConfigStr ? JSON.parse(cachedConfigStr) : null; } catch {} let timeout = config?.connectivity?.api_timeout_ms || 30000; let retryCount = config?.connectivity?.api_retry_count || 0; let lastError: any; for (let i = 0; i <= retryCount; i++) { const controller = new AbortController(); const id = setTimeout(() => controller.abort(), timeout); try { const response = await fetch(url, { ...options, signal: controller.signal, }); clearTimeout(id); if (response.status >= 500 && i < retryCount) { throw new Error(`Server Error ${response.status}`); } return response; } catch (error: any) { clearTimeout(id); lastError = error; // Only retry on network errors or 5xx server errors const isNetworkError = error.message === 'Network request failed' || error.name === 'AbortError'; const isServerError = error.message.includes('Server Error'); if (i < retryCount && (isNetworkError || isServerError)) { // Exponential backoff: 1s, 2s, 4s... const delay = Math.pow(2, i) * 1000; await new Promise(resolve => setTimeout(resolve, delay)); continue; } if (error.name === 'AbortError') { throw new Error('Request timed out. Please check your connection.'); } if (error.message === 'Network request failed') { throw new Error('Cannot connect to server. Please check your internet or VPN.'); } throw error; } } throw lastError; }; export const ApiService = { login: async (email: string, pass: string) => { const response = await fetchWithTimeout(await getUrl('/login'), { method: 'POST', headers: { 'Content-Type': 'application/json', 'Accept': 'application/json' }, body: JSON.stringify({ email, password: pass }), }); const data = await response.json(); if (!response.ok) throw new Error(data.message || 'Login failed'); return data; }, forgotPassword: async (email: string) => { const response = await fetchWithTimeout(await getUrl('/forgot-password'), { method: 'POST', headers: { 'Content-Type': 'application/json', 'Accept': 'application/json' }, body: JSON.stringify({ email }), }); const data = await response.json(); if (!response.ok) throw new Error(data.message || 'Request failed'); return data; }, register: async (name: string, email: string, pass: string) => { const response = await fetchWithTimeout(await getUrl('/register'), { method: 'POST', headers: { 'Content-Type': 'application/json', 'Accept': 'application/json' }, body: JSON.stringify({ name, email, password: pass }), }); const data = await response.json(); if (!response.ok) throw new Error(data.message || 'Registration failed'); return data; }, getUser: async () => { const headers = await getAuthHeaders(); const response = await fetchWithTimeout(await getUrl('/user'), { method: 'GET', headers, }); const data = await response.json(); if (!response.ok) throw new Error(data.message || 'Failed to fetch user'); return data; }, updateAvatar: async (uri: string) => { const headers = await getAuthHeaders(); // FormData needs special handling for Content-Type delete (headers as any)['Content-Type']; const formData = new FormData(); // Ensure URI is properly formatted for Android const photoUri = Platform.OS === 'android' ? (uri.startsWith('file://') ? uri : `file://${uri}`) : uri; const filename = uri.split('/').pop() || 'avatar.jpg'; const match = /\.(\w+)$/.exec(filename); const type = match ? `image/${match[1]}` : `image/jpeg`; formData.append('avatar', { uri: photoUri, name: filename, type, } as any); const response = await fetchWithTimeout(await getUrl('/profile/avatar'), { method: 'POST', headers, body: formData, }); const data = await response.json(); if (!response.ok) throw new Error(data.message || 'Avatar update failed'); return data; }, updateProfile: async (name: string, email: string) => { const headers = await getAuthHeaders(); const response = await fetchWithTimeout(await getUrl('/profile/update'), { method: 'POST', headers, body: JSON.stringify({ name, email }), }); const data = await response.json(); if (!response.ok) throw new Error(data.message || 'Update failed'); return data; }, updatePassword: async (current: string, newPass: string, confirmPass: string) => { const headers = await getAuthHeaders(); const response = await fetchWithTimeout(await getUrl('/profile/password'), { method: 'POST', headers, body: JSON.stringify({ current_password: current, password: newPass, password_confirmation: confirmPass }), }); const data = await response.json(); if (!response.ok) throw new Error(data.message || 'Password update failed'); return data; }, deleteAccount: async () => { const headers = await getAuthHeaders(); const response = await fetchWithTimeout(await getUrl('/profile/delete'), { method: 'DELETE', headers, }); const data = await response.json(); if (!response.ok) throw new Error(data.message || 'Account deletion failed'); return data; }, getDashboardData: async () => { const headers = await getAuthHeaders(); const response = await fetchWithTimeout(await getUrl('/dashboard'), { method: 'GET', headers, }); const data = await response.json(); if (!response.ok) throw new Error(data.message || 'Failed to fetch dashboard'); return data; }, getAppConfig: async () => { const response = await fetchWithTimeout(await getUrl('/app-config'), { method: 'GET', headers: { 'Accept': 'application/json' }, }); const data = await response.json(); if (!response.ok) throw new Error(data.message || 'Failed to fetch config'); return data; }, syncMobileConfig: async (etag?: string | null) => { const platform = Platform.OS; let version = '2.0.0'; try { const cachedConfigStr = await storage.get('cached_mobile_config'); if (cachedConfigStr) { const cached = JSON.parse(cachedConfigStr); if (cached?.app_updates?.app_version) version = cached.app_updates.app_version; } } catch {} const headers: any = { 'Accept': 'application/json' }; if (etag) headers['If-None-Match'] = etag; const response = await fetchWithTimeout(`${await getUrl('/mobile/sync')}?p=${platform}&v=${version}`, { method: 'GET', headers, }); if (response.status === 304) { return { status: 'not_modified' }; } const data = await response.json(); if (!response.ok) throw new Error(data.message || 'Failed to sync template'); return { ...data, etag: response.headers.get('ETag') }; }, reportError: async (message: string, level: string = 'error', context: any = {}) => { try { const cachedConfigStr = await storage.get('cached_mobile_config'); if (cachedConfigStr) { const config = JSON.parse(cachedConfigStr); // 1. Check if reporting is enabled if (config.analytics_system?.crashlytics_enabled === false) return false; // 2. Check Log Level (Priority: critical > error > warning > info > debug) const levels: Record = { debug: 0, info: 1, warning: 2, error: 3, critical: 4 }; const minLevel = config.analytics_system?.log_level || 'error'; if ((levels[level] || 0) < (levels[minLevel] || 3)) return false; } const token = await storage.get('user_token'); const headers: any = { 'Content-Type': 'application/json', 'Accept': 'application/json', }; if (token) headers['Authorization'] = `Bearer ${token}`; const url = await getUrl('/mobile/log'); fetch(url, { method: 'POST', headers, body: JSON.stringify({ level, message, context }), }).catch(() => {}); } catch {} return true; }, registerDeviceToken: async (token: string, platform: string) => { const headers = await getAuthHeaders(); const response = await fetchWithTimeout(await getUrl('/devices/register'), { method: 'POST', headers, body: JSON.stringify({ token, platform, type: 'expo' }), }); const data = await response.json(); if (!response.ok) throw new Error(data.message || 'Token registration failed'); return data; }, };