-
Notifications
You must be signed in to change notification settings - Fork 2.8k
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
feat: Import onyx state #49255
feat: Import onyx state #49255
Changes from 53 commits
f1d2200
6381821
f8e758b
3b33ef2
69b1767
caa2573
1fbe276
b9e7373
7d403da
5c4d6ab
28937eb
0fe5895
47926c0
024b4a2
02059bf
df69d08
cc26c13
f2c80c8
1588340
fb3666b
179bdf3
71db305
291402a
077432c
65983c0
3397526
b6f375a
21205ae
ce1ae7e
dbc414a
fc84004
d8102f9
2dd1e91
e756a8b
9a7a330
eba5ae0
35fd5f1
072afdd
e55113b
62be7da
778934a
c3853c5
29e7857
8d91eb8
10bbee3
80d11b3
6f7ce2a
aeb02c3
601085b
8ed35d9
ca55510
619414d
7265c23
f41d5c5
b28e3f2
e152883
c6ff8e3
6bdc95f
3e305c3
2790ba4
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,59 @@ | ||
import React from 'react'; | ||
import type {FileObject} from '@components/AttachmentModal'; | ||
import AttachmentPicker from '@components/AttachmentPicker'; | ||
import DecisionModal from '@components/DecisionModal'; | ||
import * as Expensicons from '@components/Icon/Expensicons'; | ||
import MenuItem from '@components/MenuItem'; | ||
import useLocalize from '@hooks/useLocalize'; | ||
import useResponsiveLayout from '@hooks/useResponsiveLayout'; | ||
import useThemeStyles from '@hooks/useThemeStyles'; | ||
|
||
function BaseImportOnyxState({ | ||
onFileRead, | ||
isErrorModalVisible, | ||
setIsErrorModalVisible, | ||
}: { | ||
onFileRead: (file: FileObject) => void; | ||
isErrorModalVisible: boolean; | ||
setIsErrorModalVisible: (value: boolean) => void; | ||
}) { | ||
const {translate} = useLocalize(); | ||
const styles = useThemeStyles(); | ||
const {isSmallScreenWidth} = useResponsiveLayout(); | ||
|
||
return ( | ||
<> | ||
<AttachmentPicker | ||
acceptedFileTypes={['text']} | ||
shouldHideCameraOption | ||
shouldHideGalleryOption | ||
> | ||
{({openPicker}) => { | ||
return ( | ||
<MenuItem | ||
icon={Expensicons.Upload} | ||
title={translate('initialSettingsPage.troubleshoot.importOnyxState')} | ||
wrapperStyle={[styles.sectionMenuItemTopDescription]} | ||
onPress={() => { | ||
openPicker({ | ||
onPicked: onFileRead, | ||
}); | ||
}} | ||
/> | ||
); | ||
}} | ||
</AttachmentPicker> | ||
<DecisionModal | ||
title={translate('initialSettingsPage.troubleshoot.invalidFile')} | ||
prompt={translate('initialSettingsPage.troubleshoot.invalidFileDescription')} | ||
isSmallScreenWidth={isSmallScreenWidth} | ||
onSecondOptionSubmit={() => setIsErrorModalVisible(false)} | ||
secondOptionText={translate('common.ok')} | ||
isVisible={isErrorModalVisible} | ||
onClose={() => setIsErrorModalVisible(false)} | ||
/> | ||
</> | ||
); | ||
} | ||
|
||
export default BaseImportOnyxState; |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,105 @@ | ||
import React, {useState} from 'react'; | ||
import RNFS from 'react-native-fs'; | ||
import Onyx from 'react-native-onyx'; | ||
import type {FileObject} from '@components/AttachmentModal'; | ||
import {KEYS_TO_PRESERVE, setIsUsingImportedState} from '@libs/actions/App'; | ||
import {setShouldForceOffline} from '@libs/actions/Network'; | ||
import Navigation from '@libs/Navigation/Navigation'; | ||
import type {OnyxValues} from '@src/ONYXKEYS'; | ||
import ROUTES from '@src/ROUTES'; | ||
import BaseImportOnyxState from './BaseImportOnyxState'; | ||
import type ImportOnyxStateProps from './types'; | ||
import {cleanAndTransformState} from './utils'; | ||
|
||
const CHUNK_SIZE = 100; | ||
|
||
function readFileInChunks(fileUri: string, chunkSize = 1024 * 1024) { | ||
const filePath = decodeURIComponent(fileUri.replace('file://', '')); | ||
|
||
return RNFS.exists(filePath) | ||
.then((exists) => { | ||
if (!exists) { | ||
throw new Error('File does not exist'); | ||
} | ||
return RNFS.stat(filePath); | ||
}) | ||
.then((fileStats) => { | ||
const fileSize = fileStats.size; | ||
let fileContent = ''; | ||
const promises = []; | ||
|
||
// Chunk the file into smaller parts to avoid memory issues | ||
for (let i = 0; i < fileSize; i += chunkSize) { | ||
promises.push(RNFS.read(filePath, chunkSize, i, 'utf8').then((chunk) => chunk)); | ||
} | ||
|
||
// After all chunks have been read, join them together | ||
return Promise.all(promises).then((chunks) => { | ||
fileContent = chunks.join(''); | ||
|
||
return fileContent; | ||
}); | ||
}); | ||
} | ||
|
||
function chunkArray<T>(array: T[], size: number): T[][] { | ||
const result = []; | ||
for (let i = 0; i < array.length; i += size) { | ||
result.push(array.slice(i, i + size)); | ||
} | ||
return result; | ||
} | ||
|
||
function applyStateInChunks(state: OnyxValues) { | ||
const entries = Object.entries(state); | ||
const chunks = chunkArray(entries, CHUNK_SIZE); | ||
|
||
let promise = Promise.resolve(); | ||
chunks.forEach((chunk) => { | ||
const partialOnyxState = Object.fromEntries(chunk) as Partial<OnyxValues>; | ||
promise = promise.then(() => Onyx.multiSet(partialOnyxState)); | ||
}); | ||
|
||
return promise; | ||
} | ||
|
||
export default function ImportOnyxState({setIsLoading, isLoading}: ImportOnyxStateProps) { | ||
const [isErrorModalVisible, setIsErrorModalVisible] = useState(false); | ||
|
||
const handleFileRead = (file: FileObject) => { | ||
if (!file.uri) { | ||
return; | ||
} | ||
|
||
setIsLoading(true); | ||
readFileInChunks(file.uri) | ||
.then((fileContent) => { | ||
const transformedState = cleanAndTransformState<OnyxValues>(fileContent); | ||
setShouldForceOffline(true); | ||
Onyx.clear(KEYS_TO_PRESERVE).then(() => { | ||
applyStateInChunks(transformedState).then(() => { | ||
setIsUsingImportedState(true); | ||
Navigation.navigate(ROUTES.HOME); | ||
}); | ||
}); | ||
}) | ||
.catch(() => { | ||
setIsErrorModalVisible(true); | ||
}) | ||
.finally(() => { | ||
setIsLoading(false); | ||
}); | ||
|
||
if (isLoading) { | ||
setIsLoading(false); | ||
} | ||
}; | ||
|
||
return ( | ||
<BaseImportOnyxState | ||
onFileRead={handleFileRead} | ||
isErrorModalVisible={isErrorModalVisible} | ||
setIsErrorModalVisible={setIsErrorModalVisible} | ||
/> | ||
); | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,59 @@ | ||
import React, {useState} from 'react'; | ||
import Onyx from 'react-native-onyx'; | ||
import type {FileObject} from '@components/AttachmentModal'; | ||
import {KEYS_TO_PRESERVE, setIsUsingImportedState} from '@libs/actions/App'; | ||
import {setShouldForceOffline} from '@libs/actions/Network'; | ||
import Navigation from '@libs/Navigation/Navigation'; | ||
import type {OnyxValues} from '@src/ONYXKEYS'; | ||
import ROUTES from '@src/ROUTES'; | ||
import BaseImportOnyxState from './BaseImportOnyxState'; | ||
import type ImportOnyxStateProps from './types'; | ||
import {cleanAndTransformState} from './utils'; | ||
|
||
export default function ImportOnyxState({setIsLoading, isLoading}: ImportOnyxStateProps) { | ||
const [isErrorModalVisible, setIsErrorModalVisible] = useState(false); | ||
|
||
const handleFileRead = (file: FileObject) => { | ||
if (!file.uri) { | ||
return; | ||
} | ||
|
||
setIsLoading(true); | ||
const blob = new Blob([file as BlobPart]); | ||
const response = new Response(blob); | ||
|
||
response | ||
.text() | ||
.then((text) => { | ||
const fileContent = text; | ||
const transformedState = cleanAndTransformState<OnyxValues>(fileContent); | ||
setShouldForceOffline(true); | ||
Onyx.clear(KEYS_TO_PRESERVE).then(() => { | ||
Onyx.multiSet(transformedState) | ||
.then(() => { | ||
setIsUsingImportedState(true); | ||
Navigation.navigate(ROUTES.HOME); | ||
}) | ||
.finally(() => { | ||
setIsLoading(false); | ||
}); | ||
}); | ||
}) | ||
.catch(() => { | ||
setIsErrorModalVisible(true); | ||
setIsLoading(false); | ||
}); | ||
|
||
if (isLoading) { | ||
setIsLoading(false); | ||
} | ||
}; | ||
|
||
return ( | ||
<BaseImportOnyxState | ||
onFileRead={handleFileRead} | ||
isErrorModalVisible={isErrorModalVisible} | ||
setIsErrorModalVisible={setIsErrorModalVisible} | ||
/> | ||
); | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,6 @@ | ||
type ImportOnyxStateProps = { | ||
isLoading: boolean; | ||
setIsLoading: (isLoading: boolean) => void; | ||
}; | ||
|
||
export default ImportOnyxStateProps; |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,53 @@ | ||
import cloneDeep from 'lodash/cloneDeep'; | ||
import type {UnknownRecord} from 'type-fest'; | ||
import ONYXKEYS from '@src/ONYXKEYS'; | ||
|
||
// List of Onyx keys from the .txt file we want to keep for the local override | ||
const keysToOmit = [ONYXKEYS.ACTIVE_CLIENTS, ONYXKEYS.BETAS, ONYXKEYS.FREQUENTLY_USED_EMOJIS, ONYXKEYS.NETWORK, ONYXKEYS.CREDENTIALS, ONYXKEYS.SESSION, ONYXKEYS.PREFERRED_THEME]; | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I think it would make sense to export betas as well, since having or not having a beta may be the source of a bug, was this discussed somewhere? There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. can betas differ across different accounts? If yes, let's definitely import them. I was checking it on a few accounts of mine and each one of them had the same array of betas, that's why my initial thought was that it's safe to omit it There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Ah right, it can definitely be different sometimes |
||
|
||
function isRecord(value: unknown): value is Record<string, unknown> { | ||
return typeof value === 'object' && !Array.isArray(value) && value !== null; | ||
} | ||
|
||
function transformNumericKeysToArray(data: UnknownRecord): UnknownRecord | unknown[] { | ||
const dataCopy = cloneDeep(data); | ||
if (!isRecord(dataCopy)) { | ||
return Array.isArray(dataCopy) ? (dataCopy as UnknownRecord[]).map(transformNumericKeysToArray) : (dataCopy as UnknownRecord); | ||
} | ||
|
||
const keys = Object.keys(dataCopy); | ||
|
||
if (keys.length === 0) { | ||
return dataCopy; | ||
} | ||
const allKeysAreNumeric = keys.every((key) => !Number.isNaN(Number(key))); | ||
const keysAreSequential = keys.every((key, index) => parseInt(key, 10) === index); | ||
if (allKeysAreNumeric && keysAreSequential) { | ||
return keys.map((key) => transformNumericKeysToArray(dataCopy[key] as UnknownRecord)); | ||
} | ||
|
||
for (const key in dataCopy) { | ||
if (key in dataCopy) { | ||
dataCopy[key] = transformNumericKeysToArray(dataCopy[key] as UnknownRecord); | ||
} | ||
} | ||
|
||
return dataCopy; | ||
} | ||
|
||
function cleanAndTransformState<T>(state: string): T { | ||
const parsedState = JSON.parse(state) as UnknownRecord; | ||
|
||
Object.keys(parsedState).forEach((key) => { | ||
const shouldOmit = keysToOmit.some((onyxKey) => key.startsWith(onyxKey)); | ||
|
||
if (shouldOmit) { | ||
delete parsedState[key]; | ||
} | ||
}); | ||
|
||
const transformedState = transformNumericKeysToArray(parsedState) as T; | ||
return transformedState; | ||
} | ||
|
||
export {transformNumericKeysToArray, cleanAndTransformState}; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Correct me if I am wrong but it looks like we are overriding the promise from previous chunk with a promise from next chunk. Is this desired behaviour?
What happens with promises generated from chunks before the very last one?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
it creates a new promise that waits for the previous promise to resolve before applying the next chunk, creating a chain of promises. This ensures that the
Onyx.multiSet
operation for each chunk will execute in sequence, waiting for the previous operation to completeThere was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Oh right, that's nice!
Thanks for explanation ❤️