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'
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}