import { useMemo, useState } from 'react'; import { ActivityIndicator, ScrollView, StyleSheet, View } from 'react-native'; import { useNavigation } from '@react-navigation/native'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; import { useI18n, type TranslationKey } from '@d3ro/i18n'; import { useAuth } from '../lib/auth-context'; import { useMobilePreferences } from '../lib/preferences-context'; import { ThemeButton, ThemeCard, ThemeText } from '../theme/themed-components'; import { exportAccountArchive, importAccountArchive, pickPortabilityFile, PortabilityError, prepareAccountJsonFile, prepareAccountSummaryFile, prepareDictionaryFile, prepareHistoryCsvFile, sharePreparedPortableFile, type PreparedPortableFile, } from '../features/data-portability'; type ExportKind = | 'account-json' | 'dictionary-csv' | 'dictionary-txt' | 'history-csv' | 'summary-txt'; type BusyAction = ExportKind | 'import' | null; const ERROR_KEYS: Record = { auth: 'mobile.portability.error.auth', cancelled: 'mobile.portability.error.cancelled', checksum: 'mobile.portability.error.checksum', conflict: 'mobile.portability.error.conflict', duplicate: 'mobile.portability.importDuplicate', 'file-read': 'mobile.portability.error.file', 'file-share': 'mobile.portability.error.file', format: 'mobile.portability.error.format', network: 'mobile.portability.error.network', owner: 'mobile.portability.error.owner', server: 'mobile.portability.error.server', size: 'mobile.portability.error.size', validation: 'mobile.portability.error.format', }; export default function DataPortabilityScreen(): React.ReactElement { const insets = useSafeAreaInsets(); const navigation = useNavigation(); const { t } = useI18n(); const { user } = useAuth(); const { palette } = useMobilePreferences(); const styles = useMemo(() => createStyles(palette), [palette]); const [busy, setBusy] = useState(null); const [error, setError] = useState(null); const [result, setResult] = useState(null); async function runExport(kind: ExportKind): Promise { if (user === null || busy !== null) return; setBusy(kind); setError(null); setResult(null); let file: PreparedPortableFile | null = null; try { const exported = await exportAccountArchive(user.id); if (kind === 'account-json') file = await prepareAccountJsonFile(exported.serialized); else if (kind === 'dictionary-csv') file = await prepareDictionaryFile(exported.payload, 'csv'); else if (kind === 'dictionary-txt') file = await prepareDictionaryFile(exported.payload, 'txt'); else if (kind === 'history-csv') file = await prepareHistoryCsvFile(exported.payload); else file = await prepareAccountSummaryFile(exported.payload); await sharePreparedPortableFile( file, t('mobile.portability.exportTitle'), ); setResult( t('mobile.portability.exportSuccess', { count: exported.rowCount }), ); // The OS may read a content URI after the chooser Promise resolves. The // export remains in the app cache for OS-managed cleanup instead of being // deleted while a receiving app is still opening it. file = null; } catch (caught) { setError(portabilityErrorMessage(caught, t)); } finally { if (file !== null) await file.dispose().catch(() => undefined); setBusy(null); } } async function runImport(): Promise { if (user === null || busy !== null) return; setBusy('import'); setError(null); setResult(null); let selected: Awaited> | null = null; try { selected = await pickPortabilityFile(); const imported = await importAccountArchive( user.id, selected.content, selected.fileName, selected.mimeType, ); setResult( imported.result.status === 'duplicate' ? t('mobile.portability.importDuplicate') : t('mobile.portability.importSuccess', { imported: imported.result.imported_rows, skipped: imported.result.skipped_rows, }), ); } catch (caught) { if ( !(caught instanceof PortabilityError && caught.code === 'cancelled') ) { setError(portabilityErrorMessage(caught, t)); } } finally { if (selected !== null) await selected.dispose().catch(() => undefined); setBusy(null); } } return ( navigation.goBack()} accessibilityLabel={t('common.back')} style={styles.backButton} testID="data-portability-back" /> {t('mobile.portability.title')} {t('mobile.portability.description')} {t('mobile.portability.securityTitle')} {t('mobile.portability.securityBody')} {t('mobile.portability.exclusions')} {user === null ? ( {t('mobile.portability.loginRequired')} ) : ( <> {t('mobile.portability.exportTitle')} { void runExport('account-json'); }} testID="data-portability-export-json" /> { void runExport('dictionary-csv'); }} style={styles.gridButton} testID="data-portability-export-dictionary-csv" /> { void runExport('dictionary-txt'); }} style={styles.gridButton} testID="data-portability-export-dictionary-txt" /> { void runExport('history-csv'); }} style={styles.gridButton} testID="data-portability-export-history-csv" /> { void runExport('summary-txt'); }} style={styles.gridButton} testID="data-portability-export-summary-txt" /> {t('mobile.portability.importTitle')} {t('mobile.portability.importBody')} { void runImport(); }} testID="data-portability-import-file" /> )} {busy !== null && ( {t('mobile.portability.working')} )} {result !== null && ( {result} )} {error !== null && ( {error} )} ); } type Translate = ReturnType['t']; function portabilityErrorMessage(error: unknown, t: Translate): string { if (!(error instanceof PortabilityError)) return t('mobile.portability.error.server'); return t(ERROR_KEYS[error.code]); } type Palette = ReturnType['palette']; function createStyles(palette: Palette): ReturnType { return StyleSheet.create({ container: { flex: 1, backgroundColor: palette.bg.app }, header: { minHeight: 112, paddingHorizontal: 14, paddingBottom: 14, flexDirection: 'row', alignItems: 'center', gap: 10, borderBottomWidth: 1, borderBottomColor: palette.border.subtle, }, backButton: { width: 48, paddingHorizontal: 0 }, headerCopy: { flex: 1, gap: 5 }, headerDescription: { lineHeight: 20 }, content: { padding: 18, gap: 14 }, securityCard: { gap: 10, borderColor: palette.accent.dim }, section: { gap: 12 }, readingText: { lineHeight: 22 }, buttonGrid: { flexDirection: 'row', gap: 10 }, gridButton: { flex: 1 }, statusRow: { minHeight: 56, flexDirection: 'row', alignItems: 'center', gap: 10, }, }); }