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