Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 28 additions & 0 deletions app/_layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -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;
}
Expand Down
9 changes: 9 additions & 0 deletions hooks/useConnectivity.ts
Original file line number Diff line number Diff line change
@@ -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 };
}
40 changes: 40 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
98 changes: 77 additions & 21 deletions services/api.ts
Original file line number Diff line number Diff line change
@@ -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<string, string>).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<string, unknown>) ?? {},
});

return Promise.reject({
__offline_queued: true,
__action: action,
config: req,
});
});

interface RetriableRequest extends AxiosRequestConfig {
Expand Down Expand Up @@ -45,34 +69,66 @@ async function performRefresh(): Promise<string | null> {
}

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<string, string>).Authorization = `Bearer ${newToken}`;
return api.request(original);
}

original.headers = original.headers ?? {};
(original.headers as Record<string, string>).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;
52 changes: 52 additions & 0 deletions src/offline/cache.ts
Original file line number Diff line number Diff line change
@@ -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<T> {
data: T;
timestamp: number;
ttl: number;
}

export async function getFromCache<T>(key: string): Promise<T | null> {
try {
const raw = await AsyncStorage.getItem(CACHE_PREFIX + key);
if (!raw) return null;
const entry: CacheEntry<T> = 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<T>(key: string, data: T, ttl?: number): Promise<void> {
try {
const entry: CacheEntry<T> = {
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<void> {
try {
await AsyncStorage.removeItem(CACHE_PREFIX + key);
} catch {}
}

export async function clearCache(): Promise<void> {
try {
const keys = await AsyncStorage.getAllKeys();
const cacheKeys = keys.filter((k) => k.startsWith(CACHE_PREFIX));
if (cacheKeys.length) {
await AsyncStorage.multiRemove(cacheKeys);
}
} catch {}
}
19 changes: 19 additions & 0 deletions src/offline/connectivity.store.ts
Original file line number Diff line number Diff line change
@@ -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<ConnectivityState>((set) => ({
isConnected: true,
isInternetReachable: null,
connectionType: null,
setConnected: (isConnected) => set({ isConnected }),
setInternetReachable: (isInternetReachable) => set({ isInternetReachable }),
setConnectionType: (connectionType) => set({ connectionType }),
}));
Loading