An ATproto social media client -- with an independent Appview.

✅ 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>

authored by

Matthieu Sieben
Hailey
and committed by
GitHub
00a57df5 4458b031

+144 -47
+4 -2
src/lib/api/api-polyfill.ts
··· 1 - import {BskyAgent, stringifyLex, jsonToLex} from '@atproto/api' 2 1 import RNFS from 'react-native-fs' 2 + import {BskyAgent, jsonToLex, stringifyLex} from '@atproto/api' 3 3 4 4 const GET_TIMEOUT = 15e3 // 15s 5 5 const POST_TIMEOUT = 60e3 // 60s ··· 68 68 resBody = jsonToLex(await res.json()) 69 69 } else if (resMimeType.startsWith('text/')) { 70 70 resBody = await res.text() 71 + } else if (resMimeType === 'application/vnd.ipld.car') { 72 + resBody = await res.arrayBuffer() 71 73 } else { 72 - throw new Error('TODO: non-textual response body') 74 + throw new Error('Non-supported mime type') 73 75 } 74 76 } 75 77
+73 -1
src/lib/media/manip.ts
··· 1 1 import {Image as RNImage, Share as RNShare} from 'react-native' 2 2 import {Image} from 'react-native-image-crop-picker' 3 3 import uuid from 'react-native-uuid' 4 - import {cacheDirectory, copyAsync, deleteAsync} from 'expo-file-system' 4 + import { 5 + cacheDirectory, 6 + copyAsync, 7 + deleteAsync, 8 + documentDirectory, 9 + EncodingType, 10 + makeDirectoryAsync, 11 + StorageAccessFramework, 12 + writeAsStringAsync, 13 + } from 'expo-file-system' 5 14 import * as MediaLibrary from 'expo-media-library' 6 15 import * as Sharing from 'expo-sharing' 7 16 import ImageResizer from '@bam.tech/react-native-image-resizer' 17 + import {Buffer} from 'buffer' 8 18 import RNFetchBlob from 'rn-fetch-blob' 9 19 20 + import {logger} from '#/logger' 10 21 import {isAndroid, isIOS} from 'platform/detection' 11 22 import {Dimensions} from './types' 12 23 ··· 240 251 } 241 252 return str 242 253 } 254 + 255 + export async function saveBytesToDisk( 256 + filename: string, 257 + bytes: Uint8Array, 258 + type: string, 259 + ) { 260 + const encoded = Buffer.from(bytes).toString('base64') 261 + return await saveToDevice(filename, encoded, type) 262 + } 263 + 264 + export async function saveToDevice( 265 + filename: string, 266 + encoded: string, 267 + type: string, 268 + ) { 269 + try { 270 + if (isIOS) { 271 + const tmpFileUrl = await withTempFile(filename, encoded) 272 + await Sharing.shareAsync(tmpFileUrl, {UTI: type}) 273 + safeDeleteAsync(tmpFileUrl) 274 + return true 275 + } else { 276 + const permissions = 277 + await StorageAccessFramework.requestDirectoryPermissionsAsync() 278 + 279 + if (!permissions.granted) { 280 + return false 281 + } 282 + 283 + const fileUrl = await StorageAccessFramework.createFileAsync( 284 + permissions.directoryUri, 285 + filename, 286 + type, 287 + ) 288 + 289 + await writeAsStringAsync(fileUrl, encoded, { 290 + encoding: EncodingType.Base64, 291 + }) 292 + return true 293 + } 294 + } catch (e) { 295 + logger.error('Error occurred while saving file', {message: e}) 296 + return false 297 + } 298 + } 299 + 300 + async function withTempFile( 301 + filename: string, 302 + encoded: string, 303 + ): Promise<string> { 304 + // Using a directory so that the file name is not a random string 305 + // documentDirectory will always be available on native, so we assert as a string. 306 + const tmpDirUri = joinPath(documentDirectory as string, String(uuid.v4())) 307 + await makeDirectoryAsync(tmpDirUri, {intermediates: true}) 308 + 309 + const tmpFileUrl = joinPath(tmpDirUri, filename) 310 + await writeAsStringAsync(tmpFileUrl, encoded, { 311 + encoding: EncodingType.Base64, 312 + }) 313 + return tmpFileUrl 314 + }
+23 -2
src/lib/media/manip.web.ts
··· 1 + import {Image as RNImage} from 'react-native-image-crop-picker' 2 + 1 3 import {Dimensions} from './types' 2 - import {Image as RNImage} from 'react-native-image-crop-picker' 3 - import {getDataUriSize, blobToDataUri} from './util' 4 + import {blobToDataUri, getDataUriSize} from './util' 4 5 5 6 export async function compressIfNeeded( 6 7 img: RNImage, ··· 138 139 img.src = dataUri 139 140 }) 140 141 } 142 + 143 + export async function saveBytesToDisk( 144 + filename: string, 145 + bytes: Uint8Array, 146 + type: string, 147 + ) { 148 + const blob = new Blob([bytes], {type}) 149 + const url = URL.createObjectURL(blob) 150 + await downloadUrl(url, filename) 151 + // Firefox requires a small delay 152 + setTimeout(() => URL.revokeObjectURL(url), 100) 153 + return true 154 + } 155 + 156 + async function downloadUrl(href: string, filename: string) { 157 + const a = document.createElement('a') 158 + a.href = href 159 + a.download = filename 160 + a.click() 161 + }
+44 -42
src/view/screens/Settings/ExportCarDialog.tsx
··· 3 3 import {msg, Trans} from '@lingui/macro' 4 4 import {useLingui} from '@lingui/react' 5 5 6 - import {useAgent, useSession} from '#/state/session' 7 - import {atoms as a, useBreakpoints, useTheme} from '#/alf' 8 - import {Button, ButtonText} from '#/components/Button' 6 + import {saveBytesToDisk} from '#/lib/media/manip' 7 + import {logger} from '#/logger' 8 + import {useAgent} from '#/state/session' 9 + import * as Toast from '#/view/com/util/Toast' 10 + import {atoms as a, useTheme} from '#/alf' 11 + import {Button, ButtonIcon, ButtonText} from '#/components/Button' 9 12 import * as Dialog from '#/components/Dialog' 10 - import {InlineLinkText, Link} from '#/components/Link' 11 - import {P, Text} from '#/components/Typography' 13 + import {InlineLinkText} from '#/components/Link' 14 + import {Loader} from '#/components/Loader' 15 + import {Text} from '#/components/Typography' 12 16 13 17 export function ExportCarDialog({ 14 18 control, ··· 17 21 }) { 18 22 const {_} = useLingui() 19 23 const t = useTheme() 20 - const {gtMobile} = useBreakpoints() 21 - const {currentAccount} = useSession() 22 24 const {getAgent} = useAgent() 25 + const [loading, setLoading] = React.useState(false) 23 26 24 - const downloadUrl = React.useMemo(() => { 27 + const download = React.useCallback(async () => { 25 28 const agent = getAgent() 26 - if (!currentAccount || !agent.session) { 27 - return '' // shouldnt ever happen 29 + if (!agent.session) { 30 + return // shouldnt ever happen 28 31 } 29 - // eg: https://bsky.social/xrpc/com.atproto.sync.getRepo?did=did:plc:ewvi7nxzyoun6zhxrhs64oiz 30 - const url = new URL(agent.pdsUrl || agent.service) 31 - url.pathname = '/xrpc/com.atproto.sync.getRepo' 32 - url.searchParams.set('did', agent.session.did) 33 - return url.toString() 34 - }, [currentAccount, getAgent]) 32 + try { 33 + setLoading(true) 34 + const did = agent.session.did 35 + const downloadRes = await agent.com.atproto.sync.getRepo({did}) 36 + const saveRes = await saveBytesToDisk( 37 + 'repo.car', 38 + downloadRes.data, 39 + downloadRes.headers['content-type'], 40 + ) 41 + 42 + if (saveRes) { 43 + Toast.show(_(msg`File saved successfully!`)) 44 + } 45 + } catch (e) { 46 + logger.error('Error occurred while downloading CAR file', {message: e}) 47 + Toast.show(_(msg`Error occurred while saving file`)) 48 + } finally { 49 + setLoading(false) 50 + control.close() 51 + } 52 + }, [_, control, getAgent]) 35 53 36 54 return ( 37 55 <Dialog.Outer control={control}> ··· 40 58 <Dialog.ScrollableInner 41 59 accessibilityDescribedBy="dialog-description" 42 60 accessibilityLabelledBy="dialog-title"> 43 - <View style={[a.relative, a.gap_md, a.w_full]}> 61 + <View style={[a.relative, a.gap_lg, a.w_full]}> 44 62 <Text nativeID="dialog-title" style={[a.text_2xl, a.font_bold]}> 45 63 <Trans>Export My Data</Trans> 46 64 </Text> 47 - <P nativeID="dialog-description" style={[a.text_sm]}> 65 + <Text nativeID="dialog-description" style={[a.text_sm]}> 48 66 <Trans> 49 67 Your account repository, containing all public data records, can 50 68 be downloaded as a "CAR" file. This file does not include media 51 69 embeds, such as images, or your private data, which must be 52 70 fetched separately. 53 71 </Trans> 54 - </P> 72 + </Text> 55 73 56 - <Link 74 + <Button 57 75 variant="solid" 58 76 color="primary" 59 77 size="large" 60 78 label={_(msg`Download CAR file`)} 61 - to={downloadUrl} 62 - download="repo.car"> 79 + disabled={loading} 80 + onPress={download}> 63 81 <ButtonText> 64 82 <Trans>Download CAR file</Trans> 65 83 </ButtonText> 66 - </Link> 84 + {loading && <ButtonIcon icon={Loader} />} 85 + </Button> 67 86 68 - <P 87 + <Text 69 88 style={[ 70 - a.py_xs, 71 89 t.atoms.text_contrast_medium, 72 90 a.text_sm, 73 91 a.leading_snug, ··· 83 101 </InlineLinkText> 84 102 . 85 103 </Trans> 86 - </P> 87 - 88 - <View style={gtMobile && [a.flex_row, a.justify_end]}> 89 - <Button 90 - testID="doneBtn" 91 - variant="outline" 92 - color="primary" 93 - size={gtMobile ? 'small' : 'large'} 94 - onPress={() => control.close()} 95 - label={_(msg`Done`)}> 96 - <ButtonText> 97 - <Trans>Done</Trans> 98 - </ButtonText> 99 - </Button> 100 - </View> 101 - 102 - {!gtMobile && <View style={{height: 40}} />} 104 + </Text> 103 105 </View> 104 106 </Dialog.ScrollableInner> 105 107 </Dialog.Outer>