diff --git a/src/lib/api/upload-blob.ts b/src/lib/api/upload-blob.ts index 260ba770b..8daf7e912 100644 --- a/src/lib/api/upload-blob.ts +++ b/src/lib/api/upload-blob.ts @@ -1,7 +1,14 @@ -import {copyAsync} from 'expo-file-system/legacy' +import { + cacheDirectory, + copyAsync, + EncodingType, + writeAsStringAsync, +} from 'expo-file-system/legacy' import {type BskyAgent, type ComAtprotoRepoUploadBlob} from '@atproto/api' +import {nanoid} from 'nanoid/non-secure' import {safeDeleteAsync} from '#/lib/media/manip' +import {IS_NATIVE} from '#/env' /** * @param encoding Allows overriding the blob's type @@ -22,7 +29,9 @@ export async function uploadBlob( } if (typeof input === 'string' && input.startsWith('data:')) { - const blob = await fetch(input).then(r => r.blob()) + const blob = IS_NATIVE + ? await dataUriToBlobOnNative(input) + : await dataUriToBlobViaFetch(input) return agent.uploadBlob(blob, {encoding}) } @@ -35,23 +44,84 @@ export async function uploadBlob( async function asBlob(uri: string): Promise { return withSafeFile(uri, async safeUri => { - // Note - // Android does not support `fetch()` on `file://` URIs. for this reason, we - // use XMLHttpRequest instead of simply calling: - - // return fetch(safeUri.replace('file:///', 'file:/')).then(r => r.blob()) - - return await new Promise((resolve, reject) => { + // Android does not support fetch() on file:// URIs; use XHR. RN sometimes + // fires onerror or omits status even when the blob is readable, so we use + // onloadend and only check the response. + let xhrUrl = safeUri + try { + xhrUrl = decodeURI(safeUri) + } catch { + /* keep safeUri */ + } + return await new Promise((resolve, reject) => { const xhr = new XMLHttpRequest() - xhr.onload = () => resolve(xhr.response) - xhr.onerror = () => reject(new Error('Failed to load blob')) xhr.responseType = 'blob' - xhr.open('GET', safeUri, true) - xhr.send(null) + xhr.onloadend = () => { + const blob = xhr.response + if (blob instanceof Blob && blob.size > 0) { + resolve(blob) + } else { + // Message deliberately avoids substrings matched by isNetworkError() + reject(new Error('Failed to read local image file for upload.')) + } + } + xhr.open('GET', xhrUrl, true) + xhr.send() }) }) } +/** + * RN fetch() on large data: URIs (e.g. full-screen PNG screenshots) often fails; + * DCIM JPEGs more often use the file path after compression. Write to cache and + * reuse XHR like other local files. + */ +async function dataUriToBlobOnNative(dataUri: string): Promise { + const comma = dataUri.indexOf(',') + if (comma === -1 || !dataUri.startsWith('data:')) { + throw new Error('Failed to read image data for upload.') + } + const header = dataUri.slice(5, comma) + const payload = dataUri.slice(comma + 1) + const isBase64 = /(^|;)\s*base64\s*(;|$)/i.test(header) + if (!isBase64) { + return dataUriToBlobViaFetch(dataUri) + } + const baseDir = cacheDirectory + if (!baseDir) { + return dataUriToBlobViaFetch(dataUri) + } + const tmpUri = joinPath(baseDir, `upload-blob-${nanoid()}.bin`) + try { + await writeAsStringAsync(tmpUri, payload, { + encoding: EncodingType.Base64, + }) + return await asBlob(tmpUri) + } finally { + await safeDeleteAsync(tmpUri) + } +} + +function joinPath(dir: string, name: string) { + if (dir.endsWith('/')) { + return name.startsWith('/') ? dir.slice(0, -1) + name : dir + name + } + return name.startsWith('/') ? dir + name : `${dir}/${name}` +} + +async function dataUriToBlobViaFetch(dataUri: string): Promise { + try { + const response = await fetch(dataUri) + const blob = await response.blob() + if (!(blob instanceof Blob) || blob.size === 0) { + throw new Error('empty') + } + return blob + } catch { + throw new Error('Failed to read image data for upload.') + } +} + // HACK // React native has a bug that inflates the size of jpegs on upload // we get around that by renaming the file ext to .bin