diff --git a/app/_layout.tsx b/app/_layout.tsx index 9355f9d..3c630f5 100644 --- a/app/_layout.tsx +++ b/app/_layout.tsx @@ -2,10 +2,13 @@ import { Stack, useRouter, useSegments } from 'expo-router'; import { useEffect, useRef, useCallback, useState } from 'react'; import { AppState, View } from 'react-native'; import { SafeAreaProvider } from 'react-native-safe-area-context'; +import NetInfo from '@react-native-community/netinfo'; import { useAuthStore } from '../stores/auth.store'; import { useSecurityStore } from '../src/security/security.store'; import { biometricService } from '../src/security/biometric.service'; import { BiometricGate } from '../src/components/BiometricGate'; +import { useConnectivityStore } from '../src/offline/connectivity.store'; +import { processQueue } from '../src/offline/offline-sync'; import { initPromise } from '../src/locales/i18n'; import '../global.css'; @@ -107,6 +110,31 @@ export default function RootLayout() { } }, [isLocked, isAuthenticated, startIdleTimer]); + const prevConnectedRef = useRef(true); + + useEffect(() => { + NetInfo.fetch().then((state) => { + useConnectivityStore.getState().setConnected(state.isConnected ?? false); + useConnectivityStore.getState().setConnectionType(state.type); + useConnectivityStore.getState().setInternetReachable(state.isInternetReachable); + prevConnectedRef.current = state.isConnected ?? false; + }); + + const unsubscribe = NetInfo.addEventListener((state) => { + const connected = state.isConnected ?? false; + useConnectivityStore.getState().setConnected(connected); + useConnectivityStore.getState().setConnectionType(state.type); + useConnectivityStore.getState().setInternetReachable(state.isInternetReachable); + + if (!prevConnectedRef.current && connected) { + processQueue().catch(() => {}); + } + prevConnectedRef.current = connected; + }); + + return () => unsubscribe(); + }, []); + if (isLoading || !i18nReady) { return null; } diff --git a/hooks/useConnectivity.ts b/hooks/useConnectivity.ts new file mode 100644 index 0000000..1d3a04c --- /dev/null +++ b/hooks/useConnectivity.ts @@ -0,0 +1,9 @@ +import { useConnectivityStore } from '../src/offline/connectivity.store'; + +export function useConnectivity() { + const isConnected = useConnectivityStore((s) => s.isConnected); + const connectionType = useConnectivityStore((s) => s.connectionType); + const isInternetReachable = useConnectivityStore((s) => s.isInternetReachable); + + return { isConnected, connectionType, isInternetReachable }; +} diff --git a/package-lock.json b/package-lock.json index 262dd28..73fc7a3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,6 +9,8 @@ "version": "1.0.0", "dependencies": { "@expo/vector-icons": "^15.0.3", + "@react-native-async-storage/async-storage": "^2.2.0", + "@react-native-community/netinfo": "^11.4.1", "@react-navigation/native": "^7.1.8", "@walletconnect/sign-client": "^2.23.9", "@walletconnect/types": "^2.23.9", @@ -3412,6 +3414,25 @@ } } }, + "node_modules/@react-native-async-storage/async-storage": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@react-native-async-storage/async-storage/-/async-storage-2.2.0.tgz", + "integrity": "sha512-gvRvjR5JAaUZF8tv2Kcq/Gbt3JHwbKFYfmb445rhOj6NUMx3qPLixmDx5pZAyb9at1bYvJ4/eTUipU5aki45xw==", + "dependencies": { + "merge-options": "^3.0.4" + }, + "peerDependencies": { + "react-native": "^0.0.0-0 || >=0.65 <1.0" + } + }, + "node_modules/@react-native-community/netinfo": { + "version": "11.4.1", + "resolved": "https://registry.npmjs.org/@react-native-community/netinfo/-/netinfo-11.4.1.tgz", + "integrity": "sha512-B0BYAkghz3Q2V09BF88RA601XursIEA111tnc2JOaN7axJWmNefmfjZqw/KdSxKZp7CZUuPpjBmz/WCR9uaHYg==", + "peerDependencies": { + "react-native": ">=0.59" + } + }, "node_modules/@react-native/assets-registry": { "version": "0.81.5", "resolved": "https://registry.npmjs.org/@react-native/assets-registry/-/assets-registry-0.81.5.tgz", @@ -10044,6 +10065,14 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/is-plain-obj": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-2.1.0.tgz", + "integrity": "sha512-YWnfyRwxL/+SsrWYfOpUtz5b3YD+nyfkHvjbcanzk8zgyO4ASD67uVMRt8k5bM4lLMDnXfriRhOpemw+NfT1eA==", + "engines": { + "node": ">=8" + } + }, "node_modules/is-potential-custom-element-name": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", @@ -12029,6 +12058,17 @@ "integrity": "sha512-zYiwtZUcYyXKo/np96AGZAckk+FWWsUdJ3cHGGmld7+AhvcWmQyGCYUh1hc4Q/pkOhb65dQR/pqCyK0cOaHz4Q==", "license": "MIT" }, + "node_modules/merge-options": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/merge-options/-/merge-options-3.0.4.tgz", + "integrity": "sha512-2Sug1+knBjkaMsMgf1ctR1Ujx+Ayku4EdJN4Z+C2+JzoeF7A3OZ9KM2GY0CpQS51NR61LTurMJrRKPhSs3ZRTQ==", + "dependencies": { + "is-plain-obj": "^2.1.0" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/merge-stream": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz", diff --git a/package.json b/package.json index f5f8989..659534b 100644 --- a/package.json +++ b/package.json @@ -13,6 +13,8 @@ }, "dependencies": { "@expo/vector-icons": "^15.0.3", + "@react-native-async-storage/async-storage": "^2.2.0", + "@react-native-community/netinfo": "^11.4.1", "@react-navigation/native": "^7.1.8", "@walletconnect/sign-client": "^2.23.9", "@walletconnect/types": "^2.23.9", diff --git a/services/api.ts b/services/api.ts index 039d64a..45b6075 100644 --- a/services/api.ts +++ b/services/api.ts @@ -1,20 +1,44 @@ -import axios, { AxiosError, AxiosRequestConfig } from 'axios'; +import axios, { AxiosRequestConfig } from 'axios'; import { router } from 'expo-router'; import { config } from '../constants/config'; import { useAuthStore } from '../stores/auth.store'; +import { getFromCache, setToCache } from '../src/offline/cache'; +import { useConnectivityStore } from '../src/offline/connectivity.store'; +import { enqueueAction } from '../src/offline/offline-queue'; +import type { QueueAction, QueueActionType } from '../src/offline/offline-queue'; const api = axios.create({ baseURL: config.API_BASE_URL, timeout: 10000, }); -api.interceptors.request.use((req) => { +api.interceptors.request.use(async (req) => { const { accessToken } = useAuthStore.getState(); if (accessToken) { req.headers = req.headers ?? {}; (req.headers as Record).Authorization = `Bearer ${accessToken}`; } - return req; + + const method = req.method?.toLowerCase(); + if (!method || !['post', 'put', 'patch', 'delete'].includes(method)) { + return req; + } + + const { isConnected } = useConnectivityStore.getState(); + if (isConnected) return req; + + const action = await enqueueAction({ + type: getActionType(req.url ?? '', req.method ?? 'POST'), + endpoint: req.url ?? '', + method: req.method?.toUpperCase() as QueueAction['method'], + data: (req.data as Record) ?? {}, + }); + + return Promise.reject({ + __offline_queued: true, + __action: action, + config: req, + }); }); interface RetriableRequest extends AxiosRequestConfig { @@ -45,34 +69,66 @@ async function performRefresh(): Promise { } api.interceptors.response.use( - (res) => res, - async (error: AxiosError) => { + (res) => { + const method = res.config?.method?.toLowerCase(); + if (method === 'get' && res.config?.url) { + setToCache(`GET:${res.config.url}`, res.data).catch(() => {}); + } + return res; + }, + async (error) => { + if (error?.__offline_queued) { + return { + data: { queued: true, actionId: error.__action.id, unsignedXdr: '' }, + status: 202, + statusText: 'Accepted (queued offline)', + headers: {}, + config: error.config, + }; + } + const original = error.config as RetriableRequest | undefined; const status = error.response?.status; - if (status !== 401 || !original || original._retry) { - return Promise.reject(error); - } + if (status === 401 && original && !original._retry) { + original._retry = true; - original._retry = true; + if (!refreshInFlight) { + refreshInFlight = performRefresh().finally(() => { + refreshInFlight = null; + }); + } - if (!refreshInFlight) { - refreshInFlight = performRefresh().finally(() => { - refreshInFlight = null; - }); - } + const newToken = await refreshInFlight; - const newToken = await refreshInFlight; + if (!newToken) { + router.replace('/(auth)/sign-in'); + return Promise.reject(error); + } - if (!newToken) { - router.replace('/(auth)/sign-in'); - return Promise.reject(error); + original.headers = original.headers ?? {}; + (original.headers as Record).Authorization = `Bearer ${newToken}`; + return api.request(original); } - original.headers = original.headers ?? {}; - (original.headers as Record).Authorization = `Bearer ${newToken}`; - return api.request(original); + if (original?.method?.toLowerCase() === 'get' && original?.url) { + const cached = await getFromCache(`GET:${original.url}`); + if (cached !== null) { + return { data: cached, status: 200, statusText: 'OK (cached)', headers: {}, config: original }; + } + } + + return Promise.reject(error); }, ); +function getActionType(url: string, method: string): QueueActionType { + if (url.includes('/repay-installment')) return 'REPAY_INSTALLMENT'; + if (url.includes('/loans/create')) return 'CREATE_LOAN'; + if (url.includes('/vouches/submit')) return 'SUBMIT_VOUCH'; + if (url.includes('/liquidity/deposit')) return 'DEPOSIT'; + if (url.includes('/transactions/submit')) return 'SUBMIT_SIGNED_XDR'; + return 'SUBMIT_SIGNED_XDR'; +} + export default api; diff --git a/src/offline/cache.ts b/src/offline/cache.ts new file mode 100644 index 0000000..4e808d9 --- /dev/null +++ b/src/offline/cache.ts @@ -0,0 +1,52 @@ +import AsyncStorage from '@react-native-async-storage/async-storage'; + +const CACHE_PREFIX = '@stepfi/cache/'; +const DEFAULT_TTL_MS = 5 * 60 * 1000; + +interface CacheEntry { + data: T; + timestamp: number; + ttl: number; +} + +export async function getFromCache(key: string): Promise { + try { + const raw = await AsyncStorage.getItem(CACHE_PREFIX + key); + if (!raw) return null; + const entry: CacheEntry = JSON.parse(raw); + if (Date.now() - entry.timestamp > entry.ttl) { + await AsyncStorage.removeItem(CACHE_PREFIX + key); + return null; + } + return entry.data; + } catch { + return null; + } +} + +export async function setToCache(key: string, data: T, ttl?: number): Promise { + try { + const entry: CacheEntry = { + data, + timestamp: Date.now(), + ttl: ttl ?? DEFAULT_TTL_MS, + }; + await AsyncStorage.setItem(CACHE_PREFIX + key, JSON.stringify(entry)); + } catch {} +} + +export async function removeFromCache(key: string): Promise { + try { + await AsyncStorage.removeItem(CACHE_PREFIX + key); + } catch {} +} + +export async function clearCache(): Promise { + try { + const keys = await AsyncStorage.getAllKeys(); + const cacheKeys = keys.filter((k) => k.startsWith(CACHE_PREFIX)); + if (cacheKeys.length) { + await AsyncStorage.multiRemove(cacheKeys); + } + } catch {} +} diff --git a/src/offline/connectivity.store.ts b/src/offline/connectivity.store.ts new file mode 100644 index 0000000..57fa6b9 --- /dev/null +++ b/src/offline/connectivity.store.ts @@ -0,0 +1,19 @@ +import { create } from 'zustand'; + +interface ConnectivityState { + isConnected: boolean; + isInternetReachable: boolean | null; + connectionType: string | null; + setConnected: (connected: boolean) => void; + setInternetReachable: (reachable: boolean | null) => void; + setConnectionType: (type: string | null) => void; +} + +export const useConnectivityStore = create((set) => ({ + isConnected: true, + isInternetReachable: null, + connectionType: null, + setConnected: (isConnected) => set({ isConnected }), + setInternetReachable: (isInternetReachable) => set({ isInternetReachable }), + setConnectionType: (connectionType) => set({ connectionType }), +})); diff --git a/src/offline/offline-queue.ts b/src/offline/offline-queue.ts new file mode 100644 index 0000000..b3b9f7d --- /dev/null +++ b/src/offline/offline-queue.ts @@ -0,0 +1,61 @@ +import AsyncStorage from '@react-native-async-storage/async-storage'; + +const QUEUE_KEY = '@stepfi/offline-queue'; + +export type QueueActionType = + | 'REPAY_INSTALLMENT' + | 'SUBMIT_VOUCH' + | 'DEPOSIT' + | 'CREATE_LOAN' + | 'SUBMIT_SIGNED_XDR'; + +export interface QueueAction { + id: string; + type: QueueActionType; + endpoint: string; + method: 'POST' | 'PUT' | 'PATCH' | 'DELETE'; + data: Record; + timestamp: number; +} + +function generateId(): string { + return Date.now().toString(36) + Math.random().toString(36).substring(2, 11); +} + +export async function getQueue(): Promise { + try { + const raw = await AsyncStorage.getItem(QUEUE_KEY); + return raw ? JSON.parse(raw) : []; + } catch { + return []; + } +} + +export async function enqueueAction( + action: Omit, +): Promise { + const queue = await getQueue(); + const newAction: QueueAction = { + ...action, + id: generateId(), + timestamp: Date.now(), + }; + queue.push(newAction); + await AsyncStorage.setItem(QUEUE_KEY, JSON.stringify(queue)); + return newAction; +} + +export async function dequeueAction(id: string): Promise { + const queue = await getQueue(); + const filtered = queue.filter((a) => a.id !== id); + await AsyncStorage.setItem(QUEUE_KEY, JSON.stringify(filtered)); +} + +export async function clearQueue(): Promise { + await AsyncStorage.setItem(QUEUE_KEY, JSON.stringify([])); +} + +export async function getQueueLength(): Promise { + const queue = await getQueue(); + return queue.length; +} diff --git a/src/offline/offline-sync.ts b/src/offline/offline-sync.ts new file mode 100644 index 0000000..f681b1c --- /dev/null +++ b/src/offline/offline-sync.ts @@ -0,0 +1,42 @@ +import api from '../../services/api'; +import { getQueue, dequeueAction } from './offline-queue'; +import type { QueueAction } from './offline-queue'; + +export type SyncEventType = 'sync:start' | 'sync:complete' | 'sync:error' | 'sync:progress'; + +type SyncListener = (event: SyncEventType, action?: QueueAction, error?: Error) => void; + +const listeners = new Set(); + +export function onSyncEvent(fn: SyncListener): () => void { + listeners.add(fn); + return () => listeners.delete(fn); +} + +function emit(event: SyncEventType, action?: QueueAction, error?: Error) { + listeners.forEach((fn) => fn(event, action, error)); +} + +export async function processQueue(): Promise { + const queue = await getQueue(); + if (queue.length === 0) return; + + emit('sync:start'); + + for (const action of queue) { + try { + emit('sync:progress', action); + await api.request({ + method: action.method, + url: action.endpoint, + data: action.data, + headers: { 'X-Offline-Sync': 'true' }, + }); + await dequeueAction(action.id); + } catch (error) { + emit('sync:error', action, error as Error); + } + } + + emit('sync:complete'); +}