feat: 导出可分享的用户数据文件

Co-Authored-By: Codex <noreply@anthropic.com>
This commit is contained in:
2026-08-17 12:22:47 +08:00
co-authored by Codex
parent 3671432460
commit 81d106e967
9 changed files with 178 additions and 17 deletions
+46
View File
@@ -0,0 +1,46 @@
import { FileSystem } from 'react-native-file-access';
import {
createDataExport,
removeDataExportFile,
writeDataExportFile,
} from '@/services/dataExport';
import { createInitialState, useAppStore } from '@/store/appStore';
const mockFileSystem = FileSystem as jest.Mocked<typeof FileSystem>;
beforeEach(() => {
jest.clearAllMocks();
useAppStore.setState({ ...createInitialState(), hydrated: true });
});
describe('data export', () => {
it('writes a named JSON file with durable user data and removes it afterward', async () => {
const now = new Date('2026-08-17T08:09:10.123Z');
const data = createDataExport(useAppStore.getState(), now);
expect(data).toMatchObject({
formatVersion: 1,
schemaVersion: 5,
exportedAt: now.toISOString(),
replacementUsed: false,
language: 'zh-Hans',
});
expect(data).not.toHaveProperty('hydrated');
expect(data).not.toHaveProperty('resetAllData');
const file = await writeDataExportFile(data);
expect(file).toEqual({
path: '/tmp/life-companion-cache/life-companion-data-2026-08-17T08-09-10-123Z.json',
url: 'file:///tmp/life-companion-cache/life-companion-data-2026-08-17T08-09-10-123Z.json',
});
expect(mockFileSystem.writeFile).toHaveBeenCalledWith(
file.path,
`${JSON.stringify(data, null, 2)}\n`,
'utf8',
);
await removeDataExportFile(file.path);
expect(mockFileSystem.unlink).toHaveBeenCalledWith(file.path);
});
});
+1
View File
@@ -10,6 +10,7 @@
<key>NSPrivacyAccessedAPITypeReasons</key>
<array>
<string>C617.1</string>
<string>0A2A.1</string>
</array>
</dict>
<dict>
+33
View File
@@ -1949,6 +1949,29 @@ PODS:
- React-utils (= 0.85.3)
- ReactNativeDependencies
- ReactNativeDependencies (0.85.3)
- ReactNativeFileAccess (4.0.3):
- hermes-engine
- RCTRequired
- RCTTypeSafety
- React-Core
- React-Core-prebuilt
- React-debug
- React-Fabric
- React-featureflags
- React-graphics
- React-ImageManager
- React-jsi
- React-NativeModulesApple
- React-RCTFabric
- React-renderercss
- React-rendererdebug
- React-utils
- ReactCodegen
- ReactCommon/turbomodule/bridging
- ReactCommon/turbomodule/core
- ReactNativeDependencies
- Yoga
- ZIPFoundation
- ReactNativeHealthkit (14.0.2):
- hermes-engine
- NitroModules
@@ -2286,6 +2309,7 @@ PODS:
- ReactNativeDependencies
- Yoga
- Yoga (0.0.0)
- ZIPFoundation (0.9.20)
DEPENDENCIES:
- "AsyncStorage (from `../node_modules/@react-native-async-storage/async-storage`)"
@@ -2368,6 +2392,7 @@ DEPENDENCIES:
- ReactCodegen (from `build/generated/ios/ReactCodegen`)
- ReactCommon/turbomodule/core (from `../node_modules/react-native/ReactCommon`)
- ReactNativeDependencies (from `../node_modules/react-native/third-party-podspecs/ReactNativeDependencies.podspec`)
- ReactNativeFileAccess (from `../node_modules/react-native-file-access`)
- "ReactNativeHealthkit (from `../node_modules/@kingstinct/react-native-healthkit`)"
- RNGestureHandler (from `../node_modules/react-native-gesture-handler`)
- "RNNotifee (from `../node_modules/@notifee/react-native`)"
@@ -2378,6 +2403,10 @@ DEPENDENCIES:
- RNWorklets (from `../node_modules/react-native-worklets`)
- Yoga (from `../node_modules/react-native/ReactCommon/yoga`)
SPEC REPOS:
trunk:
- ZIPFoundation
EXTERNAL SOURCES:
AsyncStorage:
:path: "../node_modules/@react-native-async-storage/async-storage"
@@ -2538,6 +2567,8 @@ EXTERNAL SOURCES:
:path: "../node_modules/react-native/ReactCommon"
ReactNativeDependencies:
:podspec: "../node_modules/react-native/third-party-podspecs/ReactNativeDependencies.podspec"
ReactNativeFileAccess:
:path: "../node_modules/react-native-file-access"
ReactNativeHealthkit:
:path: "../node_modules/@kingstinct/react-native-healthkit"
RNGestureHandler:
@@ -2637,6 +2668,7 @@ SPEC CHECKSUMS:
ReactCodegen: c8f81e6c6f762dcf442a6203a1fb58f7dafc8014
ReactCommon: 7dfc3250793bf36cf221096ff59e1179e13eef7f
ReactNativeDependencies: 0971be63e0c8a8575d5d6890580557753aa266b9
ReactNativeFileAccess: dbe9f5df50816b3174b201fa9afca38b2c787aae
ReactNativeHealthkit: af20857324ceab69398bfb08924bb136b77a70a4
RNGestureHandler: 0cd8fb4d4d2eea71ef48ee0e52656138a9ff7343
RNNotifee: 5e3b271e8ea7456a36eec994085543c9adca9168
@@ -2646,6 +2678,7 @@ SPEC CHECKSUMS:
RNSVG: 0e52210d4d43165e7e2cf9c890a9848b27e513ac
RNWorklets: 0000755d812f23d0fc703319e2b1f65d9204c954
Yoga: 77dfa8673de2874e1855002ae59c68b8be9b007b
ZIPFoundation: dfd3d681c4053ff7e2f7350bc4e53b5dba3f5351
PODFILE CHECKSUM: b5d87abfe7c353eca26b13959872cd86e0d22cc2
+9
View File
@@ -63,3 +63,12 @@ jest.mock('react-native-health-connect', () => ({
jest.mock('react-native-view-shot', () => ({
captureRef: jest.fn(() => Promise.resolve('file:///tmp/life-companion-share.png')),
}));
jest.mock('react-native-file-access', () => ({
Dirs: { CacheDir: '/tmp/life-companion-cache' },
FileSystem: {
exists: jest.fn(() => Promise.resolve(true)),
unlink: jest.fn(() => Promise.resolve()),
writeFile: jest.fn(() => Promise.resolve()),
},
}));
+21
View File
@@ -21,6 +21,7 @@
"react": "19.2.3",
"react-i18next": "^17.0.8",
"react-native": "0.85.3",
"react-native-file-access": "^4.0.3",
"react-native-gesture-handler": "^3.2.1",
"react-native-haptic-feedback": "^3.0.0",
"react-native-health-connect": "^4.1.3",
@@ -11155,6 +11156,26 @@
}
}
},
"node_modules/react-native-file-access": {
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/react-native-file-access/-/react-native-file-access-4.0.3.tgz",
"integrity": "sha512-7xiUcjMhicMM+WmWbO2U2iutXcoYxi3IxScB08kHle6XfBomAV9e2MtszCR7aoSfYNSblpKKqSefra6zFKXxTw==",
"license": "MIT",
"workspaces": [
"example",
"exampleMacos"
],
"peerDependencies": {
"react": "*",
"react-native": "*",
"react-native-macos": "*"
},
"peerDependenciesMeta": {
"react-native-macos": {
"optional": true
}
}
},
"node_modules/react-native-gesture-handler": {
"version": "3.2.1",
"resolved": "https://registry.npmjs.org/react-native-gesture-handler/-/react-native-gesture-handler-3.2.1.tgz",
+1
View File
@@ -24,6 +24,7 @@
"react": "19.2.3",
"react-i18next": "^17.0.8",
"react-native": "0.85.3",
"react-native-file-access": "^4.0.3",
"react-native-gesture-handler": "^3.2.1",
"react-native-haptic-feedback": "^3.0.0",
"react-native-health-connect": "^4.1.3",
+2 -2
View File
@@ -242,7 +242,7 @@ const zhHans = {
},
},
motion: { title: '动效与触感', reduce: '减少动态效果', haptics: '触感反馈', environment: '环境活动', noSound: '当前版本不包含声音。' },
privacy: { title: '隐私与数据', localTitle: '本地优先', localBody: '伙伴、生活事件、纠错和发现物当前保存在这台设备。原始健康样本不会复制进 App 数据库。', export: '导出我的数据', reset: '重置本地数据', deleteTitle: '删除全部本地数据?', deleteBody: '伙伴、记录、发现物和设置都会从这台设备移除。此操作无法撤销。', delete: '确认删除' },
privacy: { title: '隐私与数据', localTitle: '本地优先', localBody: '伙伴、生活事件、纠错和发现物当前保存在这台设备。原始健康样本不会复制进 App 数据库。', export: '导出我的数据', exportTitle: 'Life Companion 数据导出', exportError: '暂时无法生成导出文件,请稍后重试。', reset: '重置本地数据', deleteTitle: '删除全部本地数据?', deleteBody: '伙伴、记录、发现物和设置都会从这台设备移除。此操作无法撤销。', delete: '确认删除' },
customSignal: { title: '添加自己的习惯', name: '习惯名称', direction: '它会塑造哪个方向', source: '记录方式', once: '轻点一次', timed: '计时', action: '添加习惯' },
},
};
@@ -409,7 +409,7 @@ const en = {
},
},
motion: { title: 'Motion and haptics', reduce: 'Reduce motion', haptics: 'Haptic feedback', environment: 'Environment motion', noSound: 'This version contains no sound.' },
privacy: { title: 'Privacy and data', localTitle: 'Local first', localBody: 'Companion, life events, corrections, and discoveries are stored on this device. Raw health samples are not copied into the app database.', export: 'Export my data', reset: 'Reset local data', deleteTitle: 'Delete all local data?', deleteBody: 'The companion, records, discoveries, and settings will be removed from this device. This cannot be undone.', delete: 'Confirm deletion' },
privacy: { title: 'Privacy and data', localTitle: 'Local first', localBody: 'Companion, life events, corrections, and discoveries are stored on this device. Raw health samples are not copied into the app database.', export: 'Export my data', exportTitle: 'Life Companion data export', exportError: 'The export file could not be created. Please try again.', reset: 'Reset local data', deleteTitle: 'Delete all local data?', deleteBody: 'The companion, records, discoveries, and settings will be removed from this device. This cannot be undone.', delete: 'Confirm deletion' },
customSignal: { title: 'Add your own habit', name: 'Habit name', direction: 'Direction it shapes', source: 'Recording method', once: 'One tap', timed: 'Timer', action: 'Add habit' },
},
};
+26 -15
View File
@@ -8,6 +8,11 @@ import { AppScreen } from '@/components/AppScreen';
import { Button, IconButton } from '@/components/Controls';
import { ROUTES } from '@/navigation/routes';
import type { RootStackParamList } from '@/navigation/types';
import {
createDataExport,
removeDataExportFile,
writeDataExportFile,
} from '@/services/dataExport';
import { useAppStore } from '@/store/appStore';
import { colors, radius, space, type } from '@/theme/tokens';
@@ -16,24 +21,28 @@ type Props = NativeStackScreenProps<RootStackParamList, typeof ROUTES.PRIVACY>;
export function PrivacyScreen({ navigation }: Props) {
const { t } = useTranslation();
const [confirming, setConfirming] = useState(false);
const [exporting, setExporting] = useState(false);
const [exportError, setExportError] = useState('');
const resetAllData = useAppStore(state => state.resetAllData);
const exportData = async () => {
setExporting(true);
setExportError('');
const state = useAppStore.getState();
const data = {
schemaVersion: state.schemaVersion,
companion: state.companion,
signals: state.signals,
events: state.events,
discoveries: state.discoveries,
journeyChoices: state.journeyChoices,
habitatSlots: state.habitatSlots,
motion: state.motion,
notifications: state.notifications,
notificationReceipts: state.notificationReceipts,
healthConnection: state.healthConnection,
};
await Share.share({ message: JSON.stringify(data, null, 2), title: 'Life Companion data export' });
let path: string | undefined;
try {
const file = await writeDataExportFile(createDataExport(state));
path = file.path;
await Share.share(
{ url: file.url, title: t('privacy.exportTitle') },
{ subject: t('privacy.exportTitle') },
);
} catch {
setExportError(t('privacy.exportError'));
} finally {
if (path) await removeDataExportFile(path).catch(() => undefined);
setExporting(false);
}
};
const deleteData = () => {
@@ -51,9 +60,10 @@ export function PrivacyScreen({ navigation }: Props) {
<Text style={styles.body}>{t('privacy.localBody')}</Text>
</View>
<View style={styles.actions}>
<Button label={t('privacy.export')} icon={Download} variant="secondary" onPress={() => { exportData().catch(() => undefined); }} />
<Button label={t('privacy.export')} icon={Download} loading={exporting} variant="secondary" onPress={() => { exportData().catch(() => setExporting(false)); }} />
<Button label={t('privacy.reset')} icon={Trash2} variant="danger" onPress={() => setConfirming(true)} />
</View>
{exportError ? <Text accessibilityLiveRegion="polite" style={styles.error}>{exportError}</Text> : null}
{confirming ? (
<View accessibilityViewIsModal style={styles.confirmation}>
<Text style={styles.confirmTitle}>{t('privacy.deleteTitle')}</Text>
@@ -75,4 +85,5 @@ const styles = StyleSheet.create({
actions: { gap: space.sm, marginTop: space.xl },
confirmation: { marginTop: space.xl, borderRadius: radius.md, borderWidth: 1, borderColor: colors.danger, backgroundColor: colors.dangerSoft, padding: space.lg },
confirmTitle: { ...type.titleSmall, color: colors.danger },
error: { ...type.body, color: colors.danger, marginTop: space.md },
});
+39
View File
@@ -0,0 +1,39 @@
import { Dirs, FileSystem } from 'react-native-file-access';
import type { AppState } from '@/store/appStore';
export function createDataExport(state: AppState, now = new Date()) {
return {
formatVersion: 1,
schemaVersion: state.schemaVersion,
exportedAt: now.toISOString(),
companion: state.companion,
replacementUsed: state.replacementUsed,
signals: state.signals,
events: state.events,
discoveries: state.discoveries,
journeyChoices: state.journeyChoices,
habitatSlots: state.habitatSlots,
activeTimer: state.activeTimer,
motion: state.motion,
notifications: state.notifications,
notificationReceipts: state.notificationReceipts,
healthConnection: state.healthConnection,
language: state.language,
};
}
export async function writeDataExportFile(
data: ReturnType<typeof createDataExport>,
): Promise<{ path: string; url: string }> {
const timestamp = data.exportedAt.replace(/[:.]/g, '-');
const path = `${Dirs.CacheDir}/life-companion-data-${timestamp}.json`;
await FileSystem.writeFile(path, `${JSON.stringify(data, null, 2)}\n`, 'utf8');
return { path, url: encodeURI(`file://${path}`) };
}
export async function removeDataExportFile(path: string): Promise<void> {
if (await FileSystem.exists(path)) {
await FileSystem.unlink(path);
}
}