Bluesky app fork with some witchin' additions 馃挮
at readme-update 453 lines 12 kB view raw
1import {Image as RNImage} from 'react-native' 2import uuid from 'react-native-uuid' 3import { 4 cacheDirectory, 5 copyAsync, 6 createDownloadResumable, 7 deleteAsync, 8 EncodingType, 9 getInfoAsync, 10 makeDirectoryAsync, 11 StorageAccessFramework, 12 writeAsStringAsync, 13} from 'expo-file-system/legacy' 14import {manipulateAsync, SaveFormat} from 'expo-image-manipulator' 15import * as MediaLibrary from 'expo-media-library' 16import * as Sharing from 'expo-sharing' 17import {Buffer} from 'buffer' 18 19import {POST_IMG_MAX} from '#/lib/constants' 20import {logger} from '#/logger' 21import {IS_ANDROID, IS_IOS} from '#/env' 22import {type PickerImage} from './picker.shared' 23import {type Dimensions} from './types' 24import {mimeToExt} from './video/util' 25 26export async function compressIfNeeded( 27 img: PickerImage, 28 maxSize: number = POST_IMG_MAX.size, 29): Promise<PickerImage> { 30 if (img.size < maxSize) { 31 return img 32 } 33 const resizedImage = await doResize(normalizePath(img.path), { 34 width: img.width, 35 height: img.height, 36 mode: 'stretch', 37 maxSize, 38 }) 39 const finalImageMovedPath = await moveToPermanentPath( 40 resizedImage.path, 41 '.jpg', 42 ) 43 const finalImg = { 44 ...resizedImage, 45 path: finalImageMovedPath, 46 } 47 return finalImg 48} 49 50export interface DownloadAndResizeOpts { 51 uri: string 52 width: number 53 height: number 54 mode: 'contain' | 'cover' | 'stretch' 55 maxSize: number 56 timeout: number 57} 58 59export async function downloadAndResize(opts: DownloadAndResizeOpts) { 60 let appendExt = 'jpeg' 61 try { 62 const urip = new URL(opts.uri) 63 const ext = urip.pathname.split('.').pop() 64 if (ext === 'png') { 65 appendExt = 'png' 66 } 67 } catch (e: any) { 68 console.error('Invalid URI', opts.uri, e) 69 return 70 } 71 72 const path = createPath(appendExt) 73 74 try { 75 await downloadImage(opts.uri, path, opts.timeout) 76 return await doResize(path, opts) 77 } finally { 78 safeDeleteAsync(path) 79 } 80} 81 82export async function shareImageModal({uri}: {uri: string}) { 83 if (!(await Sharing.isAvailableAsync())) { 84 // TODO might need to give an error to the user in this case -prf 85 return 86 } 87 88 // we're currently relying on the fact our CDN only serves jpegs 89 // -prf 90 const imageUri = await downloadImage(uri, createPath('jpg'), 15e3) 91 const imagePath = await moveToPermanentPath(imageUri, '.jpg') 92 safeDeleteAsync(imageUri) 93 await Sharing.shareAsync(imagePath, { 94 mimeType: 'image/jpeg', 95 UTI: 'image/jpeg', 96 }) 97} 98 99const ALBUM_NAME = 'Bluesky' 100 101export async function saveImageToMediaLibrary({uri}: {uri: string}) { 102 // download the file to cache 103 // NOTE 104 // assuming JPEG 105 // we're currently relying on the fact our CDN only serves jpegs 106 // -prf 107 const imageUri = await downloadImage(uri, createPath('jpg'), 15e3) 108 const imagePath = await moveToPermanentPath(imageUri, '.jpg') 109 110 // save 111 try { 112 if (IS_ANDROID) { 113 // android triggers an annoying permission prompt if you try and move an image 114 // between albums. therefore, we need to either create the album with the image 115 // as the starting image, or put it directly into the album 116 const album = await MediaLibrary.getAlbumAsync(ALBUM_NAME) 117 if (album) { 118 // try and migrate if needed 119 try { 120 if (await MediaLibrary.albumNeedsMigrationAsync(album)) { 121 await MediaLibrary.migrateAlbumIfNeededAsync(album) 122 } 123 } catch (err) { 124 logger.info('Attempted and failed to migrate album', { 125 safeMessage: err, 126 }) 127 } 128 129 try { 130 // if album exists, put the image straight in there 131 await MediaLibrary.createAssetAsync(imagePath, album) 132 } catch (err) { 133 logger.info('Failed to create asset', {safeMessage: err}) 134 // however, it's possible that we don't have write permission to the album 135 // try making a new one! 136 try { 137 await MediaLibrary.createAlbumAsync( 138 ALBUM_NAME, 139 undefined, 140 undefined, 141 imagePath, 142 ) 143 } catch (err2) { 144 logger.info('Failed to create asset in a fresh album', { 145 safeMessage: err2, 146 }) 147 // ... and if all else fails, just put it in DCIM 148 await MediaLibrary.createAssetAsync(imagePath) 149 } 150 } 151 } else { 152 // otherwise, create album with asset (albums must always have at least one asset) 153 await MediaLibrary.createAlbumAsync( 154 ALBUM_NAME, 155 undefined, 156 undefined, 157 imagePath, 158 ) 159 } 160 } else { 161 await MediaLibrary.saveToLibraryAsync(imagePath) 162 } 163 } catch (err) { 164 logger.error(err instanceof Error ? err : String(err), { 165 message: 'Failed to save image to media library', 166 }) 167 throw err 168 } finally { 169 safeDeleteAsync(imagePath) 170 } 171} 172 173export async function saveVideoToMediaLibrary({uri}: {uri: string}) { 174 // download the file to cache 175 const downloadResponse = await RNFetchBlob.config({ 176 fileCache: true, 177 }) 178 .fetch('GET', uri) 179 .catch(() => null) 180 if (downloadResponse == null) return false 181 let videoPath = downloadResponse.path() 182 let extension = mimeToExt(downloadResponse.respInfo.headers['content-type']) 183 videoPath = normalizePath( 184 await moveToPermanentPath(videoPath, '.' + extension), 185 true, 186 ) 187 188 // save 189 await MediaLibrary.createAssetAsync(videoPath) 190 safeDeleteAsync(videoPath) 191 return true 192} 193 194export function getImageDim(path: string): Promise<Dimensions> { 195 return new Promise((resolve, reject) => { 196 RNImage.getSize( 197 path, 198 (width, height) => { 199 resolve({width, height}) 200 }, 201 reject, 202 ) 203 }) 204} 205 206// internal methods 207// = 208 209interface DoResizeOpts { 210 width: number 211 height: number 212 mode: 'contain' | 'cover' | 'stretch' 213 maxSize: number 214} 215 216async function doResize( 217 localUri: string, 218 opts: DoResizeOpts, 219): Promise<PickerImage> { 220 // We need to get the dimensions of the image before we resize it. Previously, the library we used allowed us to enter 221 // a "max size", and it would do the "best possible size" calculation for us. 222 // Now instead, we have to supply the final dimensions to the manipulation function instead. 223 // Performing an "empty" manipulation lets us get the dimensions of the original image. React Native's Image.getSize() 224 // does not work for local files... 225 const imageRes = await manipulateAsync(localUri, [], {}) 226 const newDimensions = getResizedDimensions({ 227 width: imageRes.width, 228 height: imageRes.height, 229 }) 230 231 let minQualityPercentage = 0 232 let maxQualityPercentage = 101 // exclusive 233 let newDataUri 234 const intermediateUris = [] 235 236 while (maxQualityPercentage - minQualityPercentage > 1) { 237 const qualityPercentage = Math.round( 238 (maxQualityPercentage + minQualityPercentage) / 2, 239 ) 240 const resizeRes = await manipulateAsync( 241 localUri, 242 [{resize: newDimensions}], 243 { 244 format: SaveFormat.JPEG, 245 compress: qualityPercentage / 100, 246 }, 247 ) 248 249 intermediateUris.push(resizeRes.uri) 250 251 const fileInfo = await getInfoAsync(resizeRes.uri) 252 if (!fileInfo.exists) { 253 throw new Error( 254 'The image manipulation library failed to create a new image.', 255 ) 256 } 257 258 if (fileInfo.size < opts.maxSize) { 259 minQualityPercentage = qualityPercentage 260 newDataUri = { 261 path: normalizePath(resizeRes.uri), 262 mime: 'image/jpeg', 263 size: fileInfo.size, 264 width: resizeRes.width, 265 height: resizeRes.height, 266 } 267 } else { 268 maxQualityPercentage = qualityPercentage 269 } 270 } 271 272 for (const intermediateUri of intermediateUris) { 273 if (newDataUri?.path !== normalizePath(intermediateUri)) { 274 safeDeleteAsync(intermediateUri) 275 } 276 } 277 278 if (newDataUri) { 279 safeDeleteAsync(imageRes.uri) 280 return newDataUri 281 } 282 283 throw new Error( 284 `This image is too big! We couldn't compress it down to ${opts.maxSize} bytes`, 285 ) 286} 287 288async function moveToPermanentPath(path: string, ext: string): Promise<string> { 289 /* 290 Since this package stores images in a temp directory, we need to move the file to a permanent location. 291 Relevant: IOS bug when trying to open a second time: 292 https://github.com/ivpusic/react-native-image-crop-picker/issues/1199 293 */ 294 const filename = uuid.v4() 295 296 // cacheDirectory will not ever be null on native, but it could be on web. This function only ever gets called on 297 // native so we assert as a string. 298 const destinationPath = joinPath(cacheDirectory as string, filename + ext) 299 await copyAsync({ 300 from: normalizePath(path), 301 to: normalizePath(destinationPath), 302 }) 303 safeDeleteAsync(path) 304 return normalizePath(destinationPath) 305} 306 307export async function safeDeleteAsync(path: string) { 308 // Normalize is necessary for Android, otherwise it doesn't delete. 309 const normalizedPath = normalizePath(path) 310 try { 311 await deleteAsync(normalizedPath, {idempotent: true}) 312 } catch (e) { 313 console.error('Failed to delete file', e) 314 } 315} 316 317function joinPath(a: string, b: string) { 318 if (a.endsWith('/')) { 319 if (b.startsWith('/')) { 320 return a.slice(0, -1) + b 321 } 322 return a + b 323 } else if (b.startsWith('/')) { 324 return a + b 325 } 326 return a + '/' + b 327} 328 329function normalizePath(str: string, allPlatforms = false): string { 330 if (IS_ANDROID || allPlatforms) { 331 if (!str.startsWith('file://')) { 332 return `file://${str}` 333 } 334 } 335 return str 336} 337 338export async function saveBytesToDisk( 339 filename: string, 340 bytes: Uint8Array, 341 type: string, 342) { 343 const encoded = Buffer.from(bytes).toString('base64') 344 return await saveToDevice(filename, encoded, type) 345} 346 347export async function saveToDevice( 348 filename: string, 349 encoded: string, 350 type: string, 351) { 352 try { 353 if (IS_IOS) { 354 await withTempFile(filename, encoded, async tmpFileUrl => { 355 await Sharing.shareAsync(tmpFileUrl, {UTI: type}) 356 }) 357 return true 358 } else { 359 const permissions = 360 await StorageAccessFramework.requestDirectoryPermissionsAsync() 361 362 if (!permissions.granted) { 363 return false 364 } 365 366 const fileUrl = await StorageAccessFramework.createFileAsync( 367 permissions.directoryUri, 368 filename, 369 type, 370 ) 371 372 await writeAsStringAsync(fileUrl, encoded, { 373 encoding: EncodingType.Base64, 374 }) 375 return true 376 } 377 } catch (e) { 378 logger.error('Error occurred while saving file', {message: e}) 379 return false 380 } 381} 382 383async function withTempFile<T>( 384 filename: string, 385 encoded: string, 386 cb: (url: string) => T | Promise<T>, 387): Promise<T> { 388 // cacheDirectory will not ever be null so we assert as a string. 389 // Using a directory so that the file name is not a random string 390 const tmpDirUri = joinPath(cacheDirectory as string, String(uuid.v4())) 391 await makeDirectoryAsync(tmpDirUri, {intermediates: true}) 392 393 try { 394 const tmpFileUrl = joinPath(tmpDirUri, filename) 395 await writeAsStringAsync(tmpFileUrl, encoded, { 396 encoding: EncodingType.Base64, 397 }) 398 399 return await cb(tmpFileUrl) 400 } finally { 401 safeDeleteAsync(tmpDirUri) 402 } 403} 404 405export function getResizedDimensions(originalDims: { 406 width: number 407 height: number 408}) { 409 if ( 410 originalDims.width <= POST_IMG_MAX.width && 411 originalDims.height <= POST_IMG_MAX.height 412 ) { 413 return originalDims 414 } 415 416 const ratio = Math.min( 417 POST_IMG_MAX.width / originalDims.width, 418 POST_IMG_MAX.height / originalDims.height, 419 ) 420 421 return { 422 width: Math.round(originalDims.width * ratio), 423 height: Math.round(originalDims.height * ratio), 424 } 425} 426 427function createPath(ext: string) { 428 // cacheDirectory will never be null on native, so the null check here is not necessary except for typescript. 429 // we use a web-only function for downloadAndResize on web 430 return `${cacheDirectory ?? ''}/${uuid.v4()}.${ext}` 431} 432 433async function downloadImage(uri: string, path: string, timeout: number) { 434 const dlResumable = createDownloadResumable(uri, path, {cache: true}) 435 let timedOut = false 436 const to1 = setTimeout(() => { 437 timedOut = true 438 dlResumable.cancelAsync() 439 }, timeout) 440 441 const dlRes = await dlResumable.downloadAsync() 442 clearTimeout(to1) 443 444 if (!dlRes?.uri) { 445 if (timedOut) { 446 throw new Error('Failed to download image - timed out') 447 } else { 448 throw new Error('Failed to download image - dlRes is undefined') 449 } 450 } 451 452 return normalizePath(dlRes.uri) 453}