@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -10,6 +10,7 @@
|
||||
<key>NSPrivacyAccessedAPITypeReasons</key>
|
||||
<array>
|
||||
<string>C617.1</string>
|
||||
<string>0A2A.1</string>
|
||||
</array>
|
||||
</dict>
|
||||
<dict>
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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()),
|
||||
},
|
||||
}));
|
||||
|
||||
Generated
+21
@@ -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",
|
||||
|
||||
@@ -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
@@ -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' },
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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 },
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user