Skip to content

Commit 83607ff

Browse files
committed
feat(mobile): enhance developer settings with backup and recovery features
- Added functionality to encrypt and save backup files, including a new saveFile utility. - Implemented file upload for recovery of backup data. - Updated backup data structure to include ecash and nostr states. - Enhanced localization with new strings for backup and recovery actions. - Improved UI components for better user interaction during backup and recovery processes.
1 parent ae1a4ac commit 83607ff

4 files changed

Lines changed: 241 additions & 32 deletions

File tree

apps/mobile/app/(authenticated)/(tabs)/(signer,explorer,converter)/settings/developer.tsx

Lines changed: 136 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -21,10 +21,11 @@ import {
2121
import SSMainLayout from '@/layouts/SSMainLayout'
2222
import SSVStack from '@/layouts/SSVStack'
2323
import { t } from '@/locales'
24-
import { deleteItem, getKeySecret } from '@/storage/encrypted'
24+
import { deleteItem, getEcashMnemonic, getKeySecret } from '@/storage/encrypted'
2525
import { clearAllStorage } from '@/storage/mmkv'
2626
import { useAccountsStore } from '@/store/accounts'
2727
import { useAuthStore } from '@/store/auth'
28+
import { useEcashStore } from '@/store/ecash'
2829
import { useNostrStore } from '@/store/nostr'
2930
import { useSettingsStore } from '@/store/settings'
3031
import { useWalletsStore } from '@/store/wallets'
@@ -41,6 +42,7 @@ import {
4142
} from '@/utils/crypto'
4243
import { resetInstance as resetNostrSync } from '@/utils/nostrSyncService'
4344
import { performRecoverOverwrite } from '@/utils/recoverBackup'
45+
import { pickFile, saveFile } from '@/utils/filesystem'
4446

4547
export default function Developer() {
4648
const accounts = useAccountsStore((state) => state.accounts)
@@ -134,9 +136,39 @@ export default function Developer() {
134136
}))
135137
)
136138

139+
const nostrState = useNostrStore.getState()
140+
const ecashState = useEcashStore.getState()
141+
const ecashMnemonics = Object.fromEntries(
142+
await Promise.all(
143+
ecashState.accounts.map(async (account) => [
144+
account.id,
145+
await getEcashMnemonic(account.id)
146+
])
147+
)
148+
)
149+
137150
const backupData = {
138151
accounts: accountsWithSeeds,
152+
ecash: {
153+
accounts: ecashState.accounts,
154+
activeAccountId: ecashState.activeAccountId,
155+
counters: ecashState.counters,
156+
mints: ecashState.mints,
157+
mnemonics: ecashMnemonics,
158+
proofs: ecashState.proofs,
159+
quotes: ecashState.quotes,
160+
transactions: ecashState.transactions
161+
},
139162
exportedAt: new Date().toISOString(),
163+
nostr: {
164+
lastDataExchangeEOSE: nostrState.lastDataExchangeEOSE,
165+
lastProtocolEOSE: nostrState.lastProtocolEOSE,
166+
members: nostrState.members,
167+
processedEvents: nostrState.processedEvents,
168+
processedMessageIds: nostrState.processedMessageIds,
169+
profiles: nostrState.profiles,
170+
trustedDevices: nostrState.trustedDevices
171+
},
140172
settings: { currencyUnit, mnemonicWordList, useZeroPadding },
141173
version: 1
142174
}
@@ -194,6 +226,44 @@ export default function Developer() {
194226
}
195227
}
196228

229+
async function handleEncryptAndSaveFile() {
230+
if (!backupPreviewPayload) {
231+
return
232+
}
233+
if (
234+
backupPassphrase.length === 0 ||
235+
!PASSPHRASE_ALLOWED_REGEX.test(backupPassphrase)
236+
) {
237+
toast.error(t('settings.developer.backupPassphraseInvalid'))
238+
return
239+
}
240+
const filename = `satsigner-backup-${Date.now()}.json`
241+
try {
242+
const salt = await generateSalt()
243+
const key = await pbkdf2Encrypt(backupPassphrase, salt)
244+
const iv = randomIv()
245+
const cipher = await aesEncrypt(backupPreviewPayload, key, iv)
246+
const encryptedPayload = JSON.stringify({
247+
cipher,
248+
iv,
249+
salt,
250+
v: 1
251+
})
252+
await saveFile({
253+
dialogTitle: t('settings.developer.backupData'),
254+
fileContent: encryptedPayload,
255+
filename,
256+
mimeType: 'application/json'
257+
})
258+
toast.success(t('settings.developer.backupSuccess'))
259+
setBackupPreviewVisible(false)
260+
setBackupPreviewPayload(null)
261+
setBackupPassphrase('')
262+
} catch {
263+
toast.error(t('settings.developer.backupError'))
264+
}
265+
}
266+
197267
async function handleRecoverPaste() {
198268
try {
199269
const text = await Clipboard.getStringAsync()
@@ -208,6 +278,19 @@ export default function Developer() {
208278
}
209279
}
210280

281+
async function handleRecoverUploadFile() {
282+
try {
283+
const text = await pickFile({ type: 'application/json' })
284+
if (!text) {
285+
return
286+
}
287+
setRecoverEncryptedInput(text)
288+
toast.success(t('common.success.dataPasted'))
289+
} catch {
290+
toast.error(t('settings.developer.recoverUploadError'))
291+
}
292+
}
293+
211294
async function handleRecoverDecrypt() {
212295
const raw = recoverEncryptedInput.trim()
213296
if (!raw || !recoverPassphrase.trim()) {
@@ -439,7 +522,7 @@ export default function Developer() {
439522
closeButtonVariant="ghost"
440523
fullOpacity
441524
>
442-
<SSVStack gap="lg" style={styles.backupPreviewModal}>
525+
<SSVStack gap="lg" widthFull style={styles.backupPreviewModal}>
443526
<SSText center size="lg" uppercase>
444527
{t('settings.developer.backupModalTitle')}
445528
</SSText>
@@ -457,7 +540,7 @@ export default function Developer() {
457540
value={backupPreviewPayload ?? ''}
458541
/>
459542
</ScrollView>
460-
<SSVStack gap="xs">
543+
<SSVStack gap="xs" widthFull>
461544
<SSText color="muted" size="sm">
462545
{t('settings.developer.backupPassphraseLabel')}
463546
</SSText>
@@ -475,12 +558,17 @@ export default function Developer() {
475558
<SSText color="muted" size="xs">
476559
{t('settings.developer.backupEncryptionNote')}
477560
</SSText>
478-
<SSVStack gap="sm">
561+
<SSVStack gap="sm" widthFull>
479562
<SSButton
480563
label={t('settings.developer.backupEncryptShare')}
481564
onPress={handleEncryptAndShare}
482565
variant="default"
483566
/>
567+
<SSButton
568+
label={t('settings.developer.backupEncryptSaveFile')}
569+
onPress={handleEncryptAndSaveFile}
570+
variant="secondary"
571+
/>
484572
</SSVStack>
485573
</SSVStack>
486574
</SSModal>
@@ -497,13 +585,13 @@ export default function Developer() {
497585
closeButtonVariant="ghost"
498586
fullOpacity
499587
>
500-
<SSVStack gap="lg" style={styles.recoverModal}>
588+
<SSVStack gap="lg" widthFull style={styles.recoverModal}>
501589
<SSText center size="lg" uppercase>
502590
{t('settings.developer.recoverTitle')}
503591
</SSText>
504592
{recoverDecrypted === null ? (
505593
<>
506-
<SSVStack gap="xs">
594+
<SSVStack gap="xs" widthFull>
507595
<SSText color="muted" size="sm">
508596
{t('settings.developer.recoverEncryptedLabel')}
509597
</SSText>
@@ -521,13 +609,20 @@ export default function Developer() {
521609
onChangeText={setRecoverEncryptedInput}
522610
/>
523611
</ScrollView>
524-
<SSButton
525-
label={t('common.paste')}
526-
onPress={handleRecoverPaste}
527-
variant="secondary"
528-
/>
612+
<SSVStack gap="sm" widthFull>
613+
<SSButton
614+
label={t('common.paste')}
615+
onPress={handleRecoverPaste}
616+
variant="secondary"
617+
/>
618+
<SSButton
619+
label={t('settings.developer.recoverUploadFile')}
620+
onPress={handleRecoverUploadFile}
621+
variant="secondary"
622+
/>
623+
</SSVStack>
529624
</SSVStack>
530-
<SSVStack gap="xs">
625+
<SSVStack gap="xs" widthFull>
531626
<SSText color="muted" size="sm">
532627
{t('settings.developer.backupPassphraseLabel')}
533628
</SSText>
@@ -541,15 +636,17 @@ export default function Developer() {
541636
onChangeText={setRecoverPassphrase}
542637
/>
543638
</SSVStack>
544-
<SSButton
545-
label={t('settings.developer.recoverDecrypt')}
546-
onPress={handleRecoverDecrypt}
547-
variant="secondary"
548-
/>
639+
<SSVStack widthFull>
640+
<SSButton
641+
label={t('settings.developer.recoverDecrypt')}
642+
onPress={handleRecoverDecrypt}
643+
variant="secondary"
644+
/>
645+
</SSVStack>
549646
</>
550647
) : (
551648
<>
552-
<SSVStack gap="xs">
649+
<SSVStack gap="xs" widthFull>
553650
<SSText color="muted" size="sm">
554651
{t('settings.developer.recoverDecryptedLabel')}
555652
</SSText>
@@ -565,17 +662,21 @@ export default function Developer() {
565662
/>
566663
</ScrollView>
567664
</SSVStack>
568-
<SSButton
569-
label={t('settings.developer.recoverOverwrite')}
570-
onPress={() => setRecoverConfirmOverwrite(true)}
571-
variant="secondary"
572-
/>
573-
{recoverConfirmOverwrite && (
665+
<SSVStack widthFull>
574666
<SSButton
575-
label={t('settings.developer.recoverImSure')}
576-
onPress={handleRecoverImSure}
577-
variant="danger"
667+
label={t('settings.developer.recoverOverwrite')}
668+
onPress={() => setRecoverConfirmOverwrite(true)}
669+
variant="secondary"
578670
/>
671+
</SSVStack>
672+
{recoverConfirmOverwrite && (
673+
<SSVStack widthFull>
674+
<SSButton
675+
label={t('settings.developer.recoverImSure')}
676+
onPress={handleRecoverImSure}
677+
variant="danger"
678+
/>
679+
</SSVStack>
579680
)}
580681
</>
581682
)}
@@ -588,7 +689,8 @@ export default function Developer() {
588689
const styles = StyleSheet.create({
589690
backupPreviewModal: {
590691
maxHeight: '80%',
591-
paddingVertical: 8
692+
paddingVertical: 8,
693+
width: '100%'
592694
},
593695
backupPreviewText: {
594696
color: Colors.gray['200'],
@@ -600,7 +702,8 @@ const styles = StyleSheet.create({
600702
borderColor: Colors.gray[500],
601703
borderRadius: 4,
602704
borderWidth: 1,
603-
maxHeight: 320
705+
maxHeight: 320,
706+
width: '100%'
604707
},
605708
modalTextAreaScrollContent: {
606709
paddingBottom: 16
@@ -610,10 +713,12 @@ const styles = StyleSheet.create({
610713
borderRadius: 4,
611714
borderWidth: 1,
612715
color: Colors.gray['200'],
613-
padding: 12
716+
padding: 12,
717+
width: '100%'
614718
},
615719
recoverModal: {
616720
maxHeight: '85%',
617-
paddingVertical: 8
721+
paddingVertical: 8,
722+
width: '100%'
618723
}
619724
})

apps/mobile/locales/en.json

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1911,6 +1911,7 @@
19111911
"about.title": "About",
19121912
"developer.accountsDeleted": "Accounts deleted",
19131913
"developer.backupData": "Backup data",
1914+
"developer.backupEncryptSaveFile": "Encrypt and save file",
19141915
"developer.backupEncryptShare": "Encrypt and share",
19151916
"developer.backupEncryptionNote": "Encryption: PBKDF2-SHA256 (10,000 iterations) + AES-256-CBC. Payload is JSON with v, salt, iv, cipher. Derive key from passphrase + salt, then decrypt cipher with key + iv.",
19161917
"developer.backupError": "Failed to backup data",
@@ -1941,6 +1942,8 @@
19411942
"developer.recoverOverwriteError": "Failed to restore backup",
19421943
"developer.recoverPassphrasePlaceholder": "Enter passphrase to decrypt",
19431944
"developer.recoverTitle": "Recover data",
1945+
"developer.recoverUploadError": "Failed to load backup file",
1946+
"developer.recoverUploadFile": "Upload backup file",
19441947
"developer.skipPin": "Skip PIN",
19451948
"developer.storageClearFailed": "Failed to clear storage",
19461949
"developer.storageCleared": "Storage cleared",

apps/mobile/utils/filesystem.ts

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import * as DocumentPicker from 'expo-document-picker'
22
import * as FileSystem from 'expo-file-system/legacy'
33
import * as Sharing from 'expo-sharing'
4+
import { Platform } from 'react-native'
45

56
type ShareFileProps = {
67
filename: string
@@ -21,6 +22,31 @@ export async function shareFile({
2122
await Sharing.shareAsync(fileUri, { dialogTitle, mimeType })
2223
}
2324

25+
export async function saveFile({
26+
filename,
27+
fileContent,
28+
dialogTitle,
29+
mimeType
30+
}: ShareFileProps) {
31+
if (Platform.OS === 'android') {
32+
const permissions =
33+
await FileSystem.StorageAccessFramework.requestDirectoryPermissionsAsync()
34+
if (permissions.granted && permissions.directoryUri) {
35+
const destinationUri = await FileSystem.StorageAccessFramework.createFileAsync(
36+
permissions.directoryUri,
37+
filename,
38+
mimeType
39+
)
40+
await FileSystem.writeAsStringAsync(destinationUri, fileContent, {
41+
encoding: FileSystem.EncodingType.UTF8
42+
})
43+
return
44+
}
45+
}
46+
47+
await shareFile({ dialogTitle, fileContent, filename, mimeType })
48+
}
49+
2450
export type PickFileProps = {
2551
type: 'application/json' | 'text/csv' | 'text/plain' | '*/*'
2652
encodingOrOptions?: FileSystem.ReadingOptions

0 commit comments

Comments
 (0)