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