Skip to content

Commit 2bacc49

Browse files
committed
fix: backup mmkv to filesystem and restore when mmkv file becomes corrupted
1 parent 675d672 commit 2bacc49

File tree

3 files changed

+238
-5
lines changed

3 files changed

+238
-5
lines changed

src/navigation/tabs/settings/about/screens/StorageUsage.tsx

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import {useTranslation} from 'react-i18next';
1515
import {LogActions} from '../../../../../store/log';
1616
import {Black, Feather, LightBlack, White} from '../../../../../styles/colors';
1717
import {useAppDispatch, useAppSelector} from '../../../../../utils/hooks';
18+
import {storage} from '../../../../../store';
1819

1920
const ScrollContainer = styled.ScrollView``;
2021

@@ -46,6 +47,8 @@ const StorageUsage: React.FC = () => {
4647
const [customTokenStorage, setCustomTokenStorage] = useState<string>('');
4748
const [contactStorage, setContactStorage] = useState<string>('');
4849
const [ratesStorage, setRatesStorage] = useState<string>('');
50+
const [backupStorage, setBackupStorage] = useState<string>('');
51+
const [shopCatalogStorage, setShopCatalogStorage] = useState<string>('');
4952

5053
const giftCards = useAppSelector(
5154
({APP, SHOP}) => SHOP.giftCards[APP.network],
@@ -95,6 +98,51 @@ const StorageUsage: React.FC = () => {
9598
dispatch(LogActions.error('[setAppSize] Error ', errStr));
9699
}
97100
};
101+
const _setShopCatalogStorage = async () => {
102+
try {
103+
const root = storage.getString('persist:root');
104+
if (root) {
105+
try {
106+
const parsed = JSON.parse(root);
107+
const data = parsed?.SHOP_CATALOG;
108+
const bytes = data ? JSON.stringify(data).length : 0;
109+
setShopCatalogStorage(formatBytes(bytes));
110+
} catch (_) {
111+
setShopCatalogStorage('0 Bytes');
112+
}
113+
} else {
114+
setShopCatalogStorage('0 Bytes');
115+
}
116+
} catch (err) {
117+
const errStr = err instanceof Error ? err.message : JSON.stringify(err);
118+
dispatch(LogActions.error('[setShopCatalogStorage] Error ', errStr));
119+
}
120+
};
121+
const _setBackupStorage = async () => {
122+
try {
123+
// Filesystem backup created by fs-backup.ts
124+
const baseDir = RNFS.CachesDirectoryPath + '/bitpay/redux';
125+
const finalFile = baseDir + '/persist-root.json';
126+
const bakFile = finalFile + '.bak';
127+
128+
let bytes = 0;
129+
const finalExists = await RNFS.exists(finalFile);
130+
if (finalExists) {
131+
const stat = await RNFS.stat(finalFile);
132+
bytes = Number(stat.size) || 0;
133+
} else {
134+
const bakExists = await RNFS.exists(bakFile);
135+
if (bakExists) {
136+
const stat = await RNFS.stat(bakFile);
137+
bytes = Number(stat.size) || 0;
138+
}
139+
}
140+
setBackupStorage(formatBytes(bytes));
141+
} catch (err) {
142+
const errStr = err instanceof Error ? err.message : JSON.stringify(err);
143+
dispatch(LogActions.error('[setBackupStorage] Error ', errStr));
144+
}
145+
};
98146
const _setDeviceStorage = async () => {
99147
try {
100148
// Device Storage
@@ -195,6 +243,8 @@ const StorageUsage: React.FC = () => {
195243
_setCustomTokensStorage();
196244
_setContactStorage();
197245
_setRatesStorage();
246+
_setBackupStorage();
247+
_setShopCatalogStorage();
198248
}, [dispatch]);
199249

200250
return (
@@ -270,6 +320,20 @@ const StorageUsage: React.FC = () => {
270320

271321
<Button buttonType="pill">{ratesStorage}</Button>
272322
</Setting>
323+
324+
<Hr />
325+
<Setting>
326+
<SettingTitle>{t('Shop Catalog')}</SettingTitle>
327+
328+
<Button buttonType="pill">{shopCatalogStorage}</Button>
329+
</Setting>
330+
331+
<Hr />
332+
<Setting>
333+
<SettingTitle>{t('Filesystem Backup')}</SettingTitle>
334+
335+
<Button buttonType="pill">{backupStorage}</Button>
336+
</Setting>
273337
</SettingsComponent>
274338
</ScrollContainer>
275339
</SettingsContainer>

src/store/backup/fs-backup.ts

Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,125 @@
1+
import RNFS from 'react-native-fs';
2+
import {LogActions} from '../../store/log';
3+
import * as initLogs from '../../store/log/initLogs';
4+
import {getErrorString} from '../../utils/helper-methods';
5+
6+
// Use cache directories (CachesDirectoryPath) so backups are NOT included in iCloud/Android Auto Backup
7+
const BASE_CACHE_DIR = RNFS.CachesDirectoryPath;
8+
const BASE_DIR = BASE_CACHE_DIR + '/bitpay/redux';
9+
const FINAL_FILE = BASE_DIR + '/persist-root.json';
10+
const BACKUP_FILE = BASE_DIR + '/persist-root.json.bak';
11+
const TEMP_FILE = BASE_DIR + '/persist-root.json.tmp';
12+
13+
async function ensureDir(): Promise<void> {
14+
try {
15+
const exists = await RNFS.exists(BASE_DIR);
16+
if (!exists) {
17+
await RNFS.mkdir(BASE_DIR);
18+
}
19+
} catch (err) {
20+
initLogs.add(
21+
LogActions.persistLog(
22+
LogActions.error(`Backup ensureDir failed - ${getErrorString(err)}`),
23+
),
24+
);
25+
}
26+
}
27+
28+
export async function backupPersistRoot(rawJson: string): Promise<void> {
29+
try {
30+
let filtered = rawJson;
31+
try {
32+
const parsed = JSON.parse(rawJson);
33+
delete parsed.RATE;
34+
delete parsed.SHOP_CATALOG;
35+
filtered = JSON.stringify(parsed);
36+
} catch (_) {
37+
// If parse fails, keep raw json — better to have a backup than none
38+
}
39+
40+
await ensureDir();
41+
42+
// Write to temp file first
43+
await RNFS.writeFile(TEMP_FILE, filtered, 'utf8');
44+
45+
// Rotate current to .bak if present
46+
const finalExists = await RNFS.exists(FINAL_FILE);
47+
if (finalExists) {
48+
try {
49+
// Remove old .bak if exists to keep only one rolling backup
50+
const bakExists = await RNFS.exists(BACKUP_FILE);
51+
if (bakExists) {
52+
await RNFS.unlink(BACKUP_FILE);
53+
}
54+
} catch (_) {}
55+
try {
56+
await RNFS.moveFile(FINAL_FILE, BACKUP_FILE);
57+
} catch (err) {
58+
initLogs.add(
59+
LogActions.persistLog(
60+
LogActions.error(`Backup rotate failed - ${getErrorString(err)}`),
61+
),
62+
);
63+
}
64+
}
65+
66+
// Atomically move temp to final
67+
await RNFS.moveFile(TEMP_FILE, FINAL_FILE);
68+
} catch (err) {
69+
// Best-effort logging; avoid throwing to not impact primary persist
70+
initLogs.add(
71+
LogActions.persistLog(
72+
LogActions.error(`Backup write failed - ${getErrorString(err)}`),
73+
),
74+
);
75+
// Cleanup temp if left behind
76+
try {
77+
const tmpExists = await RNFS.exists(TEMP_FILE);
78+
if (tmpExists) {
79+
await RNFS.unlink(TEMP_FILE);
80+
}
81+
} catch (_) {}
82+
}
83+
}
84+
85+
export async function readBackupPersistRoot(): Promise<string | null> {
86+
try {
87+
const finalExists = await RNFS.exists(FINAL_FILE);
88+
if (finalExists) {
89+
const data = await RNFS.readFile(FINAL_FILE, 'utf8');
90+
try {
91+
JSON.parse(data);
92+
return data;
93+
} catch (_) {
94+
// Fall through to backup
95+
}
96+
}
97+
} catch (err) {
98+
initLogs.add(
99+
LogActions.persistLog(
100+
LogActions.error(`Backup read final failed - ${getErrorString(err)}`),
101+
),
102+
);
103+
}
104+
105+
try {
106+
const bakExists = await RNFS.exists(BACKUP_FILE);
107+
if (bakExists) {
108+
const data = await RNFS.readFile(BACKUP_FILE, 'utf8');
109+
try {
110+
JSON.parse(data);
111+
return data;
112+
} catch (_) {
113+
return null;
114+
}
115+
}
116+
} catch (err) {
117+
initLogs.add(
118+
LogActions.persistLog(
119+
LogActions.error(`Backup read bak failed - ${getErrorString(err)}`),
120+
),
121+
);
122+
}
123+
124+
return null;
125+
}

src/store/index.ts

Lines changed: 49 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,6 @@ import {
77
combineReducers,
88
legacy_createStore as createStore,
99
Middleware,
10-
StoreEnhancer,
1110
} from 'redux';
1211
import {composeWithDevTools} from 'redux-devtools-extension';
1312
import {createLogger} from 'redux-logger'; // https://github.com/LogRocket/redux-logger
@@ -18,6 +17,7 @@ import autoMergeLevel2 from 'redux-persist/lib/stateReconciler/autoMergeLevel2';
1817
import {encryptTransform} from 'redux-persist-transform-encrypt'; // https://github.com/maxdeviant/redux-persist-transform-encrypt
1918
import thunkMiddleware, {ThunkAction} from 'redux-thunk'; // https://github.com/reduxjs/redux-thunk
2019
import {Selector} from 'reselect';
20+
import {backupPersistRoot, readBackupPersistRoot} from './backup/fs-backup';
2121
import {
2222
bindWalletKeys,
2323
transformContacts,
@@ -49,6 +49,10 @@ import {
4949
swapCryptoReducer,
5050
swapCryptoReduxPersistBlackList,
5151
} from './swap-crypto/swap-crypto.reducer';
52+
import {
53+
ZenledgerReduxPersistBlackList,
54+
zenledgerReducer,
55+
} from './zenledger/zenledger.reducer';
5256
import {
5357
walletReducer,
5458
walletReduxPersistBlackList,
@@ -74,10 +78,6 @@ import {Storage} from 'redux-persist';
7478
import {MMKV} from 'react-native-mmkv';
7579
import {getErrorString} from '../utils/helper-methods';
7680
import {AppDispatch} from '../utils/hooks';
77-
import {
78-
ZenledgerReduxPersistBlackList,
79-
zenledgerReducer,
80-
} from './zenledger/zenledger.reducer';
8181

8282
export const storage = new MMKV();
8383

@@ -94,6 +94,37 @@ const addLog = (log: AddLog) => {
9494
} catch (_) {}
9595
};
9696

97+
const restoreFromBackup = (reason: string): Promise<string | null> => {
98+
const startTs = Date.now();
99+
return readBackupPersistRoot()
100+
.then(restored => {
101+
if (restored) {
102+
try {
103+
// Write back to MMKV to repair store for future runs
104+
storage.set('persist:root', restored);
105+
addLog(
106+
LogActions.info(
107+
`MMKV persist:root ${reason} - restored from filesystem backup - durationMs:${
108+
Date.now() - startTs
109+
}`,
110+
),
111+
);
112+
} catch (err) {
113+
addLog(
114+
LogActions.persistLog(
115+
LogActions.error(
116+
`MMKV restore write-back failed - ${getErrorString(err)}`,
117+
),
118+
),
119+
);
120+
}
121+
return restored;
122+
}
123+
return null;
124+
})
125+
.catch(() => null);
126+
};
127+
97128
export const reduxStorage: Storage = {
98129
setItem: (key, value) => {
99130
try {
@@ -109,11 +140,20 @@ export const reduxStorage: Storage = {
109140
),
110141
);
111142
}
143+
try {
144+
if (key === 'persist:root' && typeof value === 'string') {
145+
backupPersistRoot(value);
146+
}
147+
} catch (_) {}
112148
return Promise.resolve();
113149
},
114150
getItem: key => {
115151
try {
116152
const value = storage.getString(key);
153+
if (value == null && key === 'persist:root') {
154+
// Attempt restore from backup if MMKV has been wiped or is missing
155+
return restoreFromBackup('missing');
156+
}
117157
return Promise.resolve(value);
118158
} catch (err) {
119159
addLog(
@@ -123,6 +163,10 @@ export const reduxStorage: Storage = {
123163
),
124164
),
125165
);
166+
if (key === 'persist:root') {
167+
// Try backup on MMKV get failure as well
168+
return restoreFromBackup('getItem error');
169+
}
126170
return Promise.resolve(null);
127171
}
128172
},

0 commit comments

Comments
 (0)