Fix "Download CAR file" on mobile (#3816)

* download CAR file using AtpAgent instead of building URL

* add loader icon on download car button

* actually save to disk on android

* style nits

* bottom margin nit

* localize toast

* remove fallback so back button works correctly

* keep throwing an error if mime type isn't used

* be more explicit with toasts

* send errors to sentry when encountered

---------

Co-authored-by: Hailey <me@haileyok.com>
zio/stable
Matthieu Sieben 2024-05-12 23:18:42 +02:00 committed by GitHub
parent 4458b03173
commit 00a57df5b1
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 144 additions and 47 deletions

View File

@ -1,5 +1,5 @@
import {BskyAgent, stringifyLex, jsonToLex} from '@atproto/api'
import RNFS from 'react-native-fs' import RNFS from 'react-native-fs'
import {BskyAgent, jsonToLex, stringifyLex} from '@atproto/api'
const GET_TIMEOUT = 15e3 // 15s const GET_TIMEOUT = 15e3 // 15s
const POST_TIMEOUT = 60e3 // 60s const POST_TIMEOUT = 60e3 // 60s
@ -68,8 +68,10 @@ async function fetchHandler(
resBody = jsonToLex(await res.json()) resBody = jsonToLex(await res.json())
} else if (resMimeType.startsWith('text/')) { } else if (resMimeType.startsWith('text/')) {
resBody = await res.text() resBody = await res.text()
} else if (resMimeType === 'application/vnd.ipld.car') {
resBody = await res.arrayBuffer()
} else { } else {
throw new Error('TODO: non-textual response body') throw new Error('Non-supported mime type')
} }
} }

View File

@ -1,12 +1,23 @@
import {Image as RNImage, Share as RNShare} from 'react-native' import {Image as RNImage, Share as RNShare} from 'react-native'
import {Image} from 'react-native-image-crop-picker' import {Image} from 'react-native-image-crop-picker'
import uuid from 'react-native-uuid' import uuid from 'react-native-uuid'
import {cacheDirectory, copyAsync, deleteAsync} from 'expo-file-system' import {
cacheDirectory,
copyAsync,
deleteAsync,
documentDirectory,
EncodingType,
makeDirectoryAsync,
StorageAccessFramework,
writeAsStringAsync,
} from 'expo-file-system'
import * as MediaLibrary from 'expo-media-library' import * as MediaLibrary from 'expo-media-library'
import * as Sharing from 'expo-sharing' import * as Sharing from 'expo-sharing'
import ImageResizer from '@bam.tech/react-native-image-resizer' import ImageResizer from '@bam.tech/react-native-image-resizer'
import {Buffer} from 'buffer'
import RNFetchBlob from 'rn-fetch-blob' import RNFetchBlob from 'rn-fetch-blob'
import {logger} from '#/logger'
import {isAndroid, isIOS} from 'platform/detection' import {isAndroid, isIOS} from 'platform/detection'
import {Dimensions} from './types' import {Dimensions} from './types'
@ -240,3 +251,64 @@ function normalizePath(str: string, allPlatforms = false): string {
} }
return str return str
} }
export async function saveBytesToDisk(
filename: string,
bytes: Uint8Array,
type: string,
) {
const encoded = Buffer.from(bytes).toString('base64')
return await saveToDevice(filename, encoded, type)
}
export async function saveToDevice(
filename: string,
encoded: string,
type: string,
) {
try {
if (isIOS) {
const tmpFileUrl = await withTempFile(filename, encoded)
await Sharing.shareAsync(tmpFileUrl, {UTI: type})
safeDeleteAsync(tmpFileUrl)
return true
} else {
const permissions =
await StorageAccessFramework.requestDirectoryPermissionsAsync()
if (!permissions.granted) {
return false
}
const fileUrl = await StorageAccessFramework.createFileAsync(
permissions.directoryUri,
filename,
type,
)
await writeAsStringAsync(fileUrl, encoded, {
encoding: EncodingType.Base64,
})
return true
}
} catch (e) {
logger.error('Error occurred while saving file', {message: e})
return false
}
}
async function withTempFile(
filename: string,
encoded: string,
): Promise<string> {
// Using a directory so that the file name is not a random string
// documentDirectory will always be available on native, so we assert as a string.
const tmpDirUri = joinPath(documentDirectory as string, String(uuid.v4()))
await makeDirectoryAsync(tmpDirUri, {intermediates: true})
const tmpFileUrl = joinPath(tmpDirUri, filename)
await writeAsStringAsync(tmpFileUrl, encoded, {
encoding: EncodingType.Base64,
})
return tmpFileUrl
}

View File

@ -1,6 +1,7 @@
import {Dimensions} from './types'
import {Image as RNImage} from 'react-native-image-crop-picker' import {Image as RNImage} from 'react-native-image-crop-picker'
import {getDataUriSize, blobToDataUri} from './util'
import {Dimensions} from './types'
import {blobToDataUri, getDataUriSize} from './util'
export async function compressIfNeeded( export async function compressIfNeeded(
img: RNImage, img: RNImage,
@ -138,3 +139,23 @@ function createResizedImage(
img.src = dataUri img.src = dataUri
}) })
} }
export async function saveBytesToDisk(
filename: string,
bytes: Uint8Array,
type: string,
) {
const blob = new Blob([bytes], {type})
const url = URL.createObjectURL(blob)
await downloadUrl(url, filename)
// Firefox requires a small delay
setTimeout(() => URL.revokeObjectURL(url), 100)
return true
}
async function downloadUrl(href: string, filename: string) {
const a = document.createElement('a')
a.href = href
a.download = filename
a.click()
}

View File

@ -3,12 +3,16 @@ import {View} from 'react-native'
import {msg, Trans} from '@lingui/macro' import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react' import {useLingui} from '@lingui/react'
import {useAgent, useSession} from '#/state/session' import {saveBytesToDisk} from '#/lib/media/manip'
import {atoms as a, useBreakpoints, useTheme} from '#/alf' import {logger} from '#/logger'
import {Button, ButtonText} from '#/components/Button' import {useAgent} from '#/state/session'
import * as Toast from '#/view/com/util/Toast'
import {atoms as a, useTheme} from '#/alf'
import {Button, ButtonIcon, ButtonText} from '#/components/Button'
import * as Dialog from '#/components/Dialog' import * as Dialog from '#/components/Dialog'
import {InlineLinkText, Link} from '#/components/Link' import {InlineLinkText} from '#/components/Link'
import {P, Text} from '#/components/Typography' import {Loader} from '#/components/Loader'
import {Text} from '#/components/Typography'
export function ExportCarDialog({ export function ExportCarDialog({
control, control,
@ -17,21 +21,35 @@ export function ExportCarDialog({
}) { }) {
const {_} = useLingui() const {_} = useLingui()
const t = useTheme() const t = useTheme()
const {gtMobile} = useBreakpoints()
const {currentAccount} = useSession()
const {getAgent} = useAgent() const {getAgent} = useAgent()
const [loading, setLoading] = React.useState(false)
const downloadUrl = React.useMemo(() => { const download = React.useCallback(async () => {
const agent = getAgent() const agent = getAgent()
if (!currentAccount || !agent.session) { if (!agent.session) {
return '' // shouldnt ever happen return // shouldnt ever happen
} }
// eg: https://bsky.social/xrpc/com.atproto.sync.getRepo?did=did:plc:ewvi7nxzyoun6zhxrhs64oiz try {
const url = new URL(agent.pdsUrl || agent.service) setLoading(true)
url.pathname = '/xrpc/com.atproto.sync.getRepo' const did = agent.session.did
url.searchParams.set('did', agent.session.did) const downloadRes = await agent.com.atproto.sync.getRepo({did})
return url.toString() const saveRes = await saveBytesToDisk(
}, [currentAccount, getAgent]) 'repo.car',
downloadRes.data,
downloadRes.headers['content-type'],
)
if (saveRes) {
Toast.show(_(msg`File saved successfully!`))
}
} catch (e) {
logger.error('Error occurred while downloading CAR file', {message: e})
Toast.show(_(msg`Error occurred while saving file`))
} finally {
setLoading(false)
control.close()
}
}, [_, control, getAgent])
return ( return (
<Dialog.Outer control={control}> <Dialog.Outer control={control}>
@ -40,34 +58,34 @@ export function ExportCarDialog({
<Dialog.ScrollableInner <Dialog.ScrollableInner
accessibilityDescribedBy="dialog-description" accessibilityDescribedBy="dialog-description"
accessibilityLabelledBy="dialog-title"> accessibilityLabelledBy="dialog-title">
<View style={[a.relative, a.gap_md, a.w_full]}> <View style={[a.relative, a.gap_lg, a.w_full]}>
<Text nativeID="dialog-title" style={[a.text_2xl, a.font_bold]}> <Text nativeID="dialog-title" style={[a.text_2xl, a.font_bold]}>
<Trans>Export My Data</Trans> <Trans>Export My Data</Trans>
</Text> </Text>
<P nativeID="dialog-description" style={[a.text_sm]}> <Text nativeID="dialog-description" style={[a.text_sm]}>
<Trans> <Trans>
Your account repository, containing all public data records, can Your account repository, containing all public data records, can
be downloaded as a "CAR" file. This file does not include media be downloaded as a "CAR" file. This file does not include media
embeds, such as images, or your private data, which must be embeds, such as images, or your private data, which must be
fetched separately. fetched separately.
</Trans> </Trans>
</P> </Text>
<Link <Button
variant="solid" variant="solid"
color="primary" color="primary"
size="large" size="large"
label={_(msg`Download CAR file`)} label={_(msg`Download CAR file`)}
to={downloadUrl} disabled={loading}
download="repo.car"> onPress={download}>
<ButtonText> <ButtonText>
<Trans>Download CAR file</Trans> <Trans>Download CAR file</Trans>
</ButtonText> </ButtonText>
</Link> {loading && <ButtonIcon icon={Loader} />}
</Button>
<P <Text
style={[ style={[
a.py_xs,
t.atoms.text_contrast_medium, t.atoms.text_contrast_medium,
a.text_sm, a.text_sm,
a.leading_snug, a.leading_snug,
@ -83,23 +101,7 @@ export function ExportCarDialog({
</InlineLinkText> </InlineLinkText>
. .
</Trans> </Trans>
</P> </Text>
<View style={gtMobile && [a.flex_row, a.justify_end]}>
<Button
testID="doneBtn"
variant="outline"
color="primary"
size={gtMobile ? 'small' : 'large'}
onPress={() => control.close()}
label={_(msg`Done`)}>
<ButtonText>
<Trans>Done</Trans>
</ButtonText>
</Button>
</View>
{!gtMobile && <View style={{height: 40}} />}
</View> </View>
</Dialog.ScrollableInner> </Dialog.ScrollableInner>
</Dialog.Outer> </Dialog.Outer>