forked from
jollywhoppers.com/witchsky.app
Bluesky app fork with some witchin' additions 馃挮
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}