From 00a57df5b16bc946c50079914962cc2819011e80 Mon Sep 17 00:00:00 2001 From: Matthieu Sieben Date: Sun, 12 May 2024 23:18:42 +0200 Subject: [PATCH] =?UTF-8?q?=E2=9C=85=20Fix=20"Download=20CAR=20file"=20on?= =?UTF-8?q?=20mobile=20(#3816)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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 --- src/lib/api/api-polyfill.ts | 6 +- src/lib/media/manip.ts | 74 +++++++++++++++- src/lib/media/manip.web.ts | 25 +++++- src/view/screens/Settings/ExportCarDialog.tsx | 86 ++++++++++--------- 4 files changed, 144 insertions(+), 47 deletions(-) diff --git a/src/lib/api/api-polyfill.ts b/src/lib/api/api-polyfill.ts index ea1d9759..e3aec763 100644 --- a/src/lib/api/api-polyfill.ts +++ b/src/lib/api/api-polyfill.ts @@ -1,5 +1,5 @@ -import {BskyAgent, stringifyLex, jsonToLex} from '@atproto/api' import RNFS from 'react-native-fs' +import {BskyAgent, jsonToLex, stringifyLex} from '@atproto/api' const GET_TIMEOUT = 15e3 // 15s const POST_TIMEOUT = 60e3 // 60s @@ -68,8 +68,10 @@ async function fetchHandler( resBody = jsonToLex(await res.json()) } else if (resMimeType.startsWith('text/')) { resBody = await res.text() + } else if (resMimeType === 'application/vnd.ipld.car') { + resBody = await res.arrayBuffer() } else { - throw new Error('TODO: non-textual response body') + throw new Error('Non-supported mime type') } } diff --git a/src/lib/media/manip.ts b/src/lib/media/manip.ts index 9cd4abc6..71d5c701 100644 --- a/src/lib/media/manip.ts +++ b/src/lib/media/manip.ts @@ -1,12 +1,23 @@ import {Image as RNImage, Share as RNShare} from 'react-native' import {Image} from 'react-native-image-crop-picker' 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 Sharing from 'expo-sharing' import ImageResizer from '@bam.tech/react-native-image-resizer' +import {Buffer} from 'buffer' import RNFetchBlob from 'rn-fetch-blob' +import {logger} from '#/logger' import {isAndroid, isIOS} from 'platform/detection' import {Dimensions} from './types' @@ -240,3 +251,64 @@ function normalizePath(str: string, allPlatforms = false): string { } 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 { + // 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 +} diff --git a/src/lib/media/manip.web.ts b/src/lib/media/manip.web.ts index 522aa2e5..25315ebb 100644 --- a/src/lib/media/manip.web.ts +++ b/src/lib/media/manip.web.ts @@ -1,6 +1,7 @@ -import {Dimensions} from './types' 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( img: RNImage, @@ -138,3 +139,23 @@ function createResizedImage( 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() +} diff --git a/src/view/screens/Settings/ExportCarDialog.tsx b/src/view/screens/Settings/ExportCarDialog.tsx index 1b8d430b..af835cb6 100644 --- a/src/view/screens/Settings/ExportCarDialog.tsx +++ b/src/view/screens/Settings/ExportCarDialog.tsx @@ -3,12 +3,16 @@ import {View} from 'react-native' import {msg, Trans} from '@lingui/macro' import {useLingui} from '@lingui/react' -import {useAgent, useSession} from '#/state/session' -import {atoms as a, useBreakpoints, useTheme} from '#/alf' -import {Button, ButtonText} from '#/components/Button' +import {saveBytesToDisk} from '#/lib/media/manip' +import {logger} from '#/logger' +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 {InlineLinkText, Link} from '#/components/Link' -import {P, Text} from '#/components/Typography' +import {InlineLinkText} from '#/components/Link' +import {Loader} from '#/components/Loader' +import {Text} from '#/components/Typography' export function ExportCarDialog({ control, @@ -17,21 +21,35 @@ export function ExportCarDialog({ }) { const {_} = useLingui() const t = useTheme() - const {gtMobile} = useBreakpoints() - const {currentAccount} = useSession() const {getAgent} = useAgent() + const [loading, setLoading] = React.useState(false) - const downloadUrl = React.useMemo(() => { + const download = React.useCallback(async () => { const agent = getAgent() - if (!currentAccount || !agent.session) { - return '' // shouldnt ever happen + if (!agent.session) { + return // shouldnt ever happen } - // eg: https://bsky.social/xrpc/com.atproto.sync.getRepo?did=did:plc:ewvi7nxzyoun6zhxrhs64oiz - const url = new URL(agent.pdsUrl || agent.service) - url.pathname = '/xrpc/com.atproto.sync.getRepo' - url.searchParams.set('did', agent.session.did) - return url.toString() - }, [currentAccount, getAgent]) + try { + setLoading(true) + const did = agent.session.did + const downloadRes = await agent.com.atproto.sync.getRepo({did}) + const saveRes = await saveBytesToDisk( + '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 ( @@ -40,34 +58,34 @@ export function ExportCarDialog({ - + Export My Data -

+ Your account repository, containing all public data records, can be downloaded as a "CAR" file. This file does not include media embeds, such as images, or your private data, which must be fetched separately. -

+ - + disabled={loading} + onPress={download}> Download CAR file - + {loading && } + -

. -

- - - - - - {!gtMobile && } +