Skip to content

Commit faf7c76

Browse files
committed
feat(mobile): persist state + serve a cached fleet on launch
The storage seam was an in-memory stub, so nothing survived a restart — the app minted a NEW keypair every launch (losing the on-chain entitlement) and re-discovered the whole fleet from cold every time. Wire real persistence and a discovery cache so the app opens straight into a usable state. - Add @react-native-async-storage/async-storage (3.1.1, exact) and back the whole storage seam with it: keypair, prefs, favorites now persist across restarts. (Private key is plain in AsyncStorage — already far better than the previous regenerate-every-launch; a later pass should move it to the Keychain/Keystore.) - Fleet cache: persist the last good discovery (gateways + latency) via saveFleet, and on launch paint it immediately (loadFleet) before running live discovery in the background. Warm launches skip the splash entirely and show servers instantly; the cache is also a fallback when discovery is slow/fails. - Surface the background refresh: a subtle "Updating servers…" line on Connect while cache is served, and the picker's spinner now reflects auto-discovery. - Jest: mock AsyncStorage (jest.setup.js) + fleet-cache round-trip tests.
1 parent 6dad962 commit faf7c76

10 files changed

Lines changed: 218 additions & 42 deletions

File tree

clients/mobile/App.tsx

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,7 @@ function App(): React.JSX.Element {
6060
onSelect={(code) => void vpn.selectCountry(code)}
6161
onClose={() => setRoute('connect')}
6262
onRefresh={() => vpn.refresh()}
63+
discovering={vpn.discovering}
6364
favorites={vpn.favorites}
6465
onToggleFavorite={(code) => void vpn.toggleFavorite(code)}
6566
/>
@@ -70,6 +71,7 @@ function App(): React.JSX.Element {
7071
onSelect={(code) => void vpn.selectEntryCountry(code)}
7172
onClose={() => setRoute('connect')}
7273
onRefresh={() => vpn.refresh()}
74+
discovering={vpn.discovering}
7375
favorites={vpn.favorites}
7476
onToggleFavorite={(code) => void vpn.toggleFavorite(code)}
7577
/>
@@ -80,6 +82,7 @@ function App(): React.JSX.Element {
8082
onSelect={(code) => void vpn.selectExitCountry(code)}
8183
onClose={() => setRoute('connect')}
8284
onRefresh={() => vpn.refresh()}
85+
discovering={vpn.discovering}
8386
favorites={vpn.favorites}
8487
onToggleFavorite={(code) => void vpn.toggleFavorite(code)}
8588
/>

clients/mobile/jest.config.js

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,4 +23,6 @@ module.exports = {
2323
},
2424
testMatch: ['**/src/**/*.test.ts', '**/src/**/*.test.tsx'],
2525
moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx', 'json', 'node'],
26+
// Mock the native AsyncStorage module (see jest.setup.js).
27+
setupFiles: ['<rootDir>/jest.setup.js'],
2628
};

clients/mobile/jest.setup.js

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
/**
2+
* Jest setup. Replaces the native AsyncStorage module with a tiny in-memory
3+
* implementation so `storage.ts` runs under Node without a device bridge.
4+
*
5+
* We inline the mock (rather than require the library's own) because its mock
6+
* subpath isn't a stable package export across versions — a self-contained fake
7+
* of the handful of methods `storage.ts` uses is simpler and version-proof.
8+
*/
9+
jest.mock('@react-native-async-storage/async-storage', () => {
10+
let store = {};
11+
return {
12+
__esModule: true,
13+
default: {
14+
getItem: jest.fn(async (key) => (key in store ? store[key] : null)),
15+
setItem: jest.fn(async (key, value) => {
16+
store[key] = value;
17+
}),
18+
removeItem: jest.fn(async (key) => {
19+
delete store[key];
20+
}),
21+
clear: jest.fn(async () => {
22+
store = {};
23+
}),
24+
getAllKeys: jest.fn(async () => Object.keys(store)),
25+
},
26+
};
27+
});

clients/mobile/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
},
1717
"dependencies": {
1818
"@cumulusvpn/core": "file:../core-ts",
19+
"@react-native-async-storage/async-storage": "3.1.1",
1920
"react": "19.2.7",
2021
"react-native": "0.86.0",
2122
"react-native-get-random-values": "1.11.0",

clients/mobile/src/screens/ConnectScreen.tsx

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,15 @@
55
* plus live down/up/ping stats when connected. Everything is driven by `useVpn`.
66
*/
77
import { useEffect, useState } from 'react';
8-
import { Platform, Pressable, ScrollView, StyleSheet, Text, View } from 'react-native';
8+
import {
9+
ActivityIndicator,
10+
Platform,
11+
Pressable,
12+
ScrollView,
13+
StyleSheet,
14+
Text,
15+
View,
16+
} from 'react-native';
917
import type { RouteStyle } from '@cumulusvpn/core';
1018
import type { Country } from '../lib/gateways';
1119
import type { PaymentIdentity, VpnActions, VpnModel } from '../state/useVpn';
@@ -116,6 +124,14 @@ export function ConnectScreen({
116124
/>
117125
</View>
118126
) : null}
127+
128+
{/* Serving the cached fleet while a background refresh fetches fresh. */}
129+
{vpn.discovering && vpn.countries.length > 0 ? (
130+
<View style={styles.updating}>
131+
<ActivityIndicator size="small" color={color.inkFaint} />
132+
<Text style={styles.updatingText}>Updating servers…</Text>
133+
</View>
134+
) : null}
119135
</View>
120136

121137
{/* Fast / Multi-hop toggle — multi-hop is OFF by default (docs/11 §UX). */}
@@ -465,6 +481,8 @@ const styles = StyleSheet.create({
465481
paddingVertical: space.md,
466482
},
467483
loc: { alignItems: 'center' },
484+
updating: { flexDirection: 'row', alignItems: 'center', gap: 6 },
485+
updatingText: { color: color.inkFaint, fontSize: 11.5 },
468486
flag: { fontSize: 30, lineHeight: 34 },
469487
country: { fontSize: 21, fontWeight: '700', color: color.ink, marginTop: space.xs },
470488
ip: { fontFamily: font.mono, fontSize: 11.5, color: color.inkDim, marginTop: 3 },

clients/mobile/src/screens/CountryPickerScreen.tsx

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,8 @@ interface Props {
3333
readonly onClose: () => void;
3434
/** Re-run discovery + an active latency re-test of the fleet. */
3535
readonly onRefresh: () => Promise<void>;
36+
/** True while an automatic background discovery is in flight. */
37+
readonly discovering?: boolean;
3638
/** Favorited country codes (surfaced first). */
3739
readonly favorites: readonly string[];
3840
/** Pin/unpin a country. */
@@ -45,11 +47,14 @@ export function CountryPickerScreen({
4547
onSelect,
4648
onClose,
4749
onRefresh,
50+
discovering = false,
4851
favorites,
4952
onToggleFavorite,
5053
}: Props): React.JSX.Element {
5154
const [query, setQuery] = useState('');
5255
const [refreshing, setRefreshing] = useState(false);
56+
// Spin for either a manual re-test or an automatic background refresh.
57+
const busy = refreshing || discovering;
5358

5459
const doRefresh = async (): Promise<void> => {
5560
setRefreshing(true);
@@ -79,11 +84,11 @@ export function CountryPickerScreen({
7984
<Text style={styles.title}>Choose location</Text>
8085
<View style={styles.headerRight}>
8186
<Pressable
82-
onPress={refreshing ? undefined : () => void doRefresh()}
87+
onPress={busy ? undefined : () => void doRefresh()}
8388
accessibilityRole="button"
8489
hitSlop={10}
8590
>
86-
{refreshing ? (
91+
{busy ? (
8792
<ActivityIndicator size="small" color={color.cyan} />
8893
) : (
8994
<Text style={styles.retest}>↻ Re-test</Text>

clients/mobile/src/state/storage.test.ts

Lines changed: 41 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,11 @@
11
/**
2-
* Unit tests for the persistence seam.
3-
*
4-
* The POC uses an in-memory Map behind the real async signatures, so these
5-
* tests exercise the observable contract every screen relies on: keypair
6-
* round-trips, route-style validation/defaulting, and the null-clears-auto-pick
7-
* behaviour of the entry/exit country setters.
2+
* Unit tests for the persistence seam (AsyncStorage-backed; the native module is
3+
* swapped for its in-memory jest mock in jest.setup.js). These exercise the
4+
* observable contract every screen relies on: keypair round-trips, route-style
5+
* validation/defaulting, the null-clears-auto-pick behaviour of the entry/exit
6+
* setters, and the discovery (fleet) cache round-trip + corruption guard.
87
*/
9-
import type { Keypair } from '@cumulusvpn/core';
8+
import type { GatewayInfo, Keypair } from '@cumulusvpn/core';
109
import {
1110
loadKeypair,
1211
saveKeypair,
@@ -18,10 +17,26 @@ import {
1817
saveEntryCountry,
1918
loadExitCountry,
2019
saveExitCountry,
20+
loadFleet,
21+
saveFleet,
2122
} from './storage';
2223

2324
const KP: Keypair = { publicKey: 'pub-abc', privateKey: 'priv-xyz' };
2425

26+
const GW: GatewayInfo = {
27+
ip: '1.2.3.4',
28+
controlUrl: 'http://1.2.3.4:51821',
29+
country: 'DE',
30+
region: 'EU',
31+
city: 'Frankfurt',
32+
load: 0.1,
33+
capacity: 90,
34+
version: '0.1.0',
35+
min_client_version: '0.1.0',
36+
server_pubkey: 'srv-pub',
37+
sign_pubkey: 'sign-pub',
38+
};
39+
2540
describe('keypair persistence', () => {
2641
it('returns null before anything is saved', async () => {
2742
// Reset the shared in-memory store by clearing the selected key too.
@@ -70,3 +85,22 @@ describe('multi-hop entry/exit country', () => {
7085
await expect(loadExitCountry()).resolves.toBeNull();
7186
});
7287
});
88+
89+
describe('fleet cache', () => {
90+
it('is null before anything is cached', async () => {
91+
await expect(loadFleet()).resolves.toBeNull();
92+
});
93+
94+
it('round-trips a saved snapshot with gateways + latency', async () => {
95+
await saveFleet([GW], { [GW.ip]: 42 }, 1_700_000_000_000);
96+
const got = await loadFleet();
97+
expect(got?.gateways).toEqual([GW]);
98+
expect(got?.latencyByIp).toEqual({ [GW.ip]: 42 });
99+
expect(got?.savedAt).toBe(1_700_000_000_000);
100+
});
101+
102+
it('treats an empty gateway list as no cache', async () => {
103+
await saveFleet([], {}, 1_700_000_000_000);
104+
await expect(loadFleet()).resolves.toBeNull();
105+
});
106+
});

0 commit comments

Comments
 (0)