Fix photo & camera permission management (#140)

* Check photo & camera perms and alert the user if not available (close #64)

- Adds perms checks with a prompt to update settings if needed
- Moves initial access of photos in the composer so that the initial prompt
  occurs at an intuitive time.

* Add react-native-permissions test mock

* Fix issue causing multiple access requests

* Use longer var names

* Update podfile.lock

* Lint fix

* Move photo perm request in composer to the gallery btn instead of when the carousel is opened
This commit is contained in:
Paul Frazee
2023-02-02 18:29:26 -06:00
committed by GitHub
parent 9e71a50a1d
commit 8b310d91f0
11 changed files with 213 additions and 82 deletions
+5
View File
@@ -44,6 +44,11 @@ target 'app' do
:app_path => "#{Pod::Config.instance.installation_root}/.." :app_path => "#{Pod::Config.instance.installation_root}/.."
) )
# react-native-permissions settings
permissions_path = '../node_modules/react-native-permissions/ios'
pod 'Permission-Camera', :path => "#{permissions_path}/Camera"
pod 'Permission-PhotoLibrary', :path => "#{permissions_path}/PhotoLibrary"
target 'appTests' do target 'appTests' do
inherit! :complete inherit! :complete
# Pods for testing # Pods for testing
+19 -1
View File
@@ -26,6 +26,10 @@ PODS:
- libwebp/mux (1.2.4): - libwebp/mux (1.2.4):
- libwebp/demux - libwebp/demux
- libwebp/webp (1.2.4) - libwebp/webp (1.2.4)
- Permission-Camera (3.6.1):
- RNPermissions
- Permission-PhotoLibrary (3.6.1):
- RNPermissions
- RCT-Folly (2021.07.22.00): - RCT-Folly (2021.07.22.00):
- boost - boost
- DoubleConversion - DoubleConversion
@@ -379,6 +383,8 @@ PODS:
- RNNotifee/NotifeeCore (= 7.4.0) - RNNotifee/NotifeeCore (= 7.4.0)
- RNNotifee/NotifeeCore (7.4.0): - RNNotifee/NotifeeCore (7.4.0):
- React-Core - React-Core
- RNPermissions (3.6.1):
- React-Core
- RNReactNativeHapticFeedback (1.14.0): - RNReactNativeHapticFeedback (1.14.0):
- React-Core - React-Core
- RNReanimated (2.13.0): - RNReanimated (2.13.0):
@@ -437,6 +443,8 @@ DEPENDENCIES:
- glog (from `../node_modules/react-native/third-party-podspecs/glog.podspec`) - glog (from `../node_modules/react-native/third-party-podspecs/glog.podspec`)
- hermes-engine (from `../node_modules/react-native/sdks/hermes-engine/hermes-engine.podspec`) - hermes-engine (from `../node_modules/react-native/sdks/hermes-engine/hermes-engine.podspec`)
- libevent (~> 2.1.12) - libevent (~> 2.1.12)
- Permission-Camera (from `../node_modules/react-native-permissions/ios/Camera`)
- Permission-PhotoLibrary (from `../node_modules/react-native-permissions/ios/PhotoLibrary`)
- RCT-Folly (from `../node_modules/react-native/third-party-podspecs/RCT-Folly.podspec`) - RCT-Folly (from `../node_modules/react-native/third-party-podspecs/RCT-Folly.podspec`)
- RCTRequired (from `../node_modules/react-native/Libraries/RCTRequired`) - RCTRequired (from `../node_modules/react-native/Libraries/RCTRequired`)
- RCTTypeSafety (from `../node_modules/react-native/Libraries/TypeSafety`) - RCTTypeSafety (from `../node_modules/react-native/Libraries/TypeSafety`)
@@ -483,6 +491,7 @@ DEPENDENCIES:
- RNImageCropPicker (from `../node_modules/react-native-image-crop-picker`) - RNImageCropPicker (from `../node_modules/react-native-image-crop-picker`)
- RNInAppBrowser (from `../node_modules/react-native-inappbrowser-reborn`) - RNInAppBrowser (from `../node_modules/react-native-inappbrowser-reborn`)
- "RNNotifee (from `../node_modules/@notifee/react-native`)" - "RNNotifee (from `../node_modules/@notifee/react-native`)"
- RNPermissions (from `../node_modules/react-native-permissions`)
- RNReactNativeHapticFeedback (from `../node_modules/react-native-haptic-feedback`) - RNReactNativeHapticFeedback (from `../node_modules/react-native-haptic-feedback`)
- RNReanimated (from `../node_modules/react-native-reanimated`) - RNReanimated (from `../node_modules/react-native-reanimated`)
- RNScreens (from `../node_modules/react-native-screens`) - RNScreens (from `../node_modules/react-native-screens`)
@@ -516,6 +525,10 @@ EXTERNAL SOURCES:
:podspec: "../node_modules/react-native/third-party-podspecs/glog.podspec" :podspec: "../node_modules/react-native/third-party-podspecs/glog.podspec"
hermes-engine: hermes-engine:
:podspec: "../node_modules/react-native/sdks/hermes-engine/hermes-engine.podspec" :podspec: "../node_modules/react-native/sdks/hermes-engine/hermes-engine.podspec"
Permission-Camera:
:path: "../node_modules/react-native-permissions/ios/Camera"
Permission-PhotoLibrary:
:path: "../node_modules/react-native-permissions/ios/PhotoLibrary"
RCT-Folly: RCT-Folly:
:podspec: "../node_modules/react-native/third-party-podspecs/RCT-Folly.podspec" :podspec: "../node_modules/react-native/third-party-podspecs/RCT-Folly.podspec"
RCTRequired: RCTRequired:
@@ -606,6 +619,8 @@ EXTERNAL SOURCES:
:path: "../node_modules/react-native-inappbrowser-reborn" :path: "../node_modules/react-native-inappbrowser-reborn"
RNNotifee: RNNotifee:
:path: "../node_modules/@notifee/react-native" :path: "../node_modules/@notifee/react-native"
RNPermissions:
:path: "../node_modules/react-native-permissions"
RNReactNativeHapticFeedback: RNReactNativeHapticFeedback:
:path: "../node_modules/react-native-haptic-feedback" :path: "../node_modules/react-native-haptic-feedback"
RNReanimated: RNReanimated:
@@ -632,6 +647,8 @@ SPEC CHECKSUMS:
hermes-engine: f6e715aa6c8bd38de6c13bc85e07b0a337edaa89 hermes-engine: f6e715aa6c8bd38de6c13bc85e07b0a337edaa89
libevent: 4049cae6c81cdb3654a443be001fb9bdceff7913 libevent: 4049cae6c81cdb3654a443be001fb9bdceff7913
libwebp: f62cb61d0a484ba548448a4bd52aabf150ff6eef libwebp: f62cb61d0a484ba548448a4bd52aabf150ff6eef
Permission-Camera: bf6791b17c7f614b6826019fcfdcc286d3a107f6
Permission-PhotoLibrary: 5b34ca67279f7201ae109cef36f9806a6596002d
RCT-Folly: 424b8c9a7a0b9ab2886ffe9c3b041ef628fd4fb1 RCT-Folly: 424b8c9a7a0b9ab2886ffe9c3b041ef628fd4fb1
RCTRequired: fd4d923b964658aa0c4091a32c8b2004c6d9e3a6 RCTRequired: fd4d923b964658aa0c4091a32c8b2004c6d9e3a6
RCTTypeSafety: c276d85975bde3d8448907235c70bf0da257adfd RCTTypeSafety: c276d85975bde3d8448907235c70bf0da257adfd
@@ -677,6 +694,7 @@ SPEC CHECKSUMS:
RNImageCropPicker: 648356d68fbf9911a1016b3e3723885d28373eda RNImageCropPicker: 648356d68fbf9911a1016b3e3723885d28373eda
RNInAppBrowser: e36d6935517101ccba0e875bac8ad7b0cb655364 RNInAppBrowser: e36d6935517101ccba0e875bac8ad7b0cb655364
RNNotifee: da8dcf09f079ea22f46e239d7c406e10d4525a5f RNNotifee: da8dcf09f079ea22f46e239d7c406e10d4525a5f
RNPermissions: dcdb7b99796bbeda6975a6e79ad519c41b251b1c
RNReactNativeHapticFeedback: 1e3efeca9628ff9876ee7cdd9edec1b336913f8c RNReactNativeHapticFeedback: 1e3efeca9628ff9876ee7cdd9edec1b336913f8c
RNReanimated: d8d9d3d3801bda5e35e85cdffc871577d044dc2e RNReanimated: d8d9d3d3801bda5e35e85cdffc871577d044dc2e
RNScreens: 34cc502acf1b916c582c60003dc3089fa01dc66d RNScreens: 34cc502acf1b916c582c60003dc3089fa01dc66d
@@ -689,6 +707,6 @@ SPEC CHECKSUMS:
TOCropViewController: edfd4f25713d56905ad1e0b9f5be3fbe0f59c863 TOCropViewController: edfd4f25713d56905ad1e0b9f5be3fbe0f59c863
Yoga: 921eb014669cf9c718ada68b08d362517d564e0c Yoga: 921eb014669cf9c718ada68b08d362517d564e0c
PODFILE CHECKSUM: 32d3f4ec61d2c786d46fd58000f53917922dbb8a PODFILE CHECKSUM: 95c7fde1130d862b561348cca2b3fb7f9bd84bfb
COCOAPODS: 1.11.3 COCOAPODS: 1.11.3
+4
View File
@@ -55,3 +55,7 @@ jest.mock('@segment/analytics-react-native', () => ({
flush: jest.fn(), flush: jest.fn(),
}), }),
})) }))
jest.mock('react-native-permissions', () =>
require('react-native-permissions/mock'),
)
+1
View File
@@ -56,6 +56,7 @@
"react-native-inappbrowser-reborn": "^3.6.3", "react-native-inappbrowser-reborn": "^3.6.3",
"react-native-linear-gradient": "^2.6.2", "react-native-linear-gradient": "^2.6.2",
"react-native-pager-view": "^6.0.2", "react-native-pager-view": "^6.0.2",
"react-native-permissions": "^3.6.1",
"react-native-progress": "^5.0.0", "react-native-progress": "^5.0.0",
"react-native-reanimated": "^2.9.1", "react-native-reanimated": "^2.9.1",
"react-native-root-siblings": "^4.1.1", "react-native-root-siblings": "^4.1.1",
+61
View File
@@ -0,0 +1,61 @@
import {Alert} from 'react-native'
import {
check,
openSettings,
Permission,
PermissionStatus,
PERMISSIONS,
RESULTS,
} from 'react-native-permissions'
export const PHOTO_LIBRARY = PERMISSIONS.IOS.PHOTO_LIBRARY
export const CAMERA = PERMISSIONS.IOS.CAMERA
/**
* Returns `true` if the user has granted permission or hasn't made
* a decision yet. Returns `false` if unavailable or not granted.
*/
export async function hasAccess(perm: Permission): Promise<boolean> {
const status = await check(perm)
return isntANo(status)
}
export async function requestAccessIfNeeded(
perm: Permission,
): Promise<boolean> {
if (await hasAccess(perm)) {
return true
}
let permDescription
if (perm === PHOTO_LIBRARY) {
permDescription = 'photo library'
} else if (perm === CAMERA) {
permDescription = 'camera'
} else {
return false
}
Alert.alert(
'Permission needed',
`Bluesky does not have permission to access your ${permDescription}.`,
[
{
text: 'Cancel',
style: 'cancel',
},
{text: 'Open Settings', onPress: () => openSettings()},
],
)
return false
}
export async function requestPhotoAccessIfNeeded() {
return requestAccessIfNeeded(PHOTO_LIBRARY)
}
export async function requestCameraAccessIfNeeded() {
return requestAccessIfNeeded(CAMERA)
}
function isntANo(status: PermissionStatus): boolean {
return status !== RESULTS.UNAVAILABLE && status !== RESULTS.BLOCKED
}
+1 -1
View File
@@ -20,7 +20,7 @@ export class UserLocalPhotosModel {
} }
private async _getPhotos() { private async _getPhotos() {
CameraRoll.getPhotos({first: 20}).then(r => { return CameraRoll.getPhotos({first: 20}).then(r => {
runInAction(() => { runInAction(() => {
this.photos = r.edges this.photos = r.edges
}) })
+2 -11
View File
@@ -43,7 +43,6 @@ import {
} from '../../../lib/strings' } from '../../../lib/strings'
import {getLinkMeta} from '../../../lib/link-meta' import {getLinkMeta} from '../../../lib/link-meta'
import {downloadAndResize} from '../../../lib/images' import {downloadAndResize} from '../../../lib/images'
import {UserLocalPhotosModel} from '../../../state/models/user-local-photos'
import {getMentionAt, insertMentionAt} from '../../../lib/strings/mention-manip' import {getMentionAt, insertMentionAt} from '../../../lib/strings/mention-manip'
import {PhotoCarouselPicker, cropPhoto} from './PhotoCarouselPicker' import {PhotoCarouselPicker, cropPhoto} from './PhotoCarouselPicker'
import {SelectedPhoto} from './SelectedPhoto' import {SelectedPhoto} from './SelectedPhoto'
@@ -94,10 +93,6 @@ export const ComposePost = observer(function ComposePost({
() => new UserAutocompleteViewModel(store), () => new UserAutocompleteViewModel(store),
[store], [store],
) )
const localPhotos = React.useMemo<UserLocalPhotosModel>(
() => new UserLocalPhotosModel(store),
[store],
)
// HACK // HACK
// there's a bug with @mattermost/react-native-paste-input where if the input // there's a bug with @mattermost/react-native-paste-input where if the input
@@ -112,8 +107,7 @@ export const ComposePost = observer(function ComposePost({
// initial setup // initial setup
useEffect(() => { useEffect(() => {
autocompleteView.setup() autocompleteView.setup()
localPhotos.setup() }, [autocompleteView])
}, [autocompleteView, localPhotos])
// external link metadata-fetch flow // external link metadata-fetch flow
useEffect(() => { useEffect(() => {
@@ -436,13 +430,10 @@ export const ComposePost = observer(function ComposePost({
/> />
)} )}
</ScrollView> </ScrollView>
{isSelectingPhotos && {isSelectingPhotos && selectedPhotos.length < 4 ? (
localPhotos.photos != null &&
selectedPhotos.length < 4 ? (
<PhotoCarouselPicker <PhotoCarouselPicker
selectedPhotos={selectedPhotos} selectedPhotos={selectedPhotos}
onSelectPhotos={onSelectPhotos} onSelectPhotos={onSelectPhotos}
localPhotos={localPhotos}
/> />
) : !extLink && ) : !extLink &&
selectedPhotos.length === 0 && selectedPhotos.length === 0 &&
+57 -35
View File
@@ -12,6 +12,10 @@ import {
UserLocalPhotosModel, UserLocalPhotosModel,
PhotoIdentifier, PhotoIdentifier,
} from '../../../state/models/user-local-photos' } from '../../../state/models/user-local-photos'
import {
requestPhotoAccessIfNeeded,
requestCameraAccessIfNeeded,
} from '../../../lib/permissions'
import {compressIfNeeded, scaleDownDimensions} from '../../../lib/images' import {compressIfNeeded, scaleDownDimensions} from '../../../lib/images'
import {usePalette} from '../../lib/hooks/usePalette' import {usePalette} from '../../lib/hooks/usePalette'
import {useStores} from '../../../state' import {useStores} from '../../../state'
@@ -67,16 +71,31 @@ export async function cropPhoto(
export const PhotoCarouselPicker = ({ export const PhotoCarouselPicker = ({
selectedPhotos, selectedPhotos,
onSelectPhotos, onSelectPhotos,
localPhotos,
}: { }: {
selectedPhotos: string[] selectedPhotos: string[]
onSelectPhotos: (v: string[]) => void onSelectPhotos: (v: string[]) => void
localPhotos: UserLocalPhotosModel
}) => { }) => {
const pal = usePalette('default') const pal = usePalette('default')
const store = useStores() const store = useStores()
const [isSetup, setIsSetup] = React.useState<boolean>(false)
const localPhotos = React.useMemo<UserLocalPhotosModel>(
() => new UserLocalPhotosModel(store),
[store],
)
React.useEffect(() => {
// initial setup
localPhotos.setup().then(() => {
setIsSetup(true)
})
}, [localPhotos])
const handleOpenCamera = useCallback(async () => { const handleOpenCamera = useCallback(async () => {
try { try {
if (!(await requestCameraAccessIfNeeded())) {
return
}
const cameraRes = await openCamera({ const cameraRes = await openCamera({
mediaType: 'photo', mediaType: 'photo',
cropping: true, cropping: true,
@@ -107,34 +126,36 @@ export const PhotoCarouselPicker = ({
[store.log, selectedPhotos, onSelectPhotos], [store.log, selectedPhotos, onSelectPhotos],
) )
const handleOpenGallery = useCallback(() => { const handleOpenGallery = useCallback(async () => {
openPicker({ if (!(await requestPhotoAccessIfNeeded())) {
return
}
const items = await openPicker({
multiple: true, multiple: true,
maxFiles: 4 - selectedPhotos.length, maxFiles: 4 - selectedPhotos.length,
mediaType: 'photo', mediaType: 'photo',
}).then(async items => {
const result = []
for (const image of items) {
// choose target dimensions based on the original
// this causes the photo cropper to start with the full image "selected"
const {width, height} = scaleDownDimensions(
{width: image.width, height: image.height},
{width: MAX_WIDTH, height: MAX_HEIGHT},
)
const cropperRes = await openCropper({
mediaType: 'photo',
path: image.path,
...IMAGE_PARAMS,
width,
height,
})
const finalImg = await compressIfNeeded(cropperRes, MAX_SIZE)
const permanentPath = await moveToPremanantPath(finalImg.path)
result.push(permanentPath)
}
onSelectPhotos([...selectedPhotos, ...result])
}) })
const result = []
for (const image of items) {
// choose target dimensions based on the original
// this causes the photo cropper to start with the full image "selected"
const {width, height} = scaleDownDimensions(
{width: image.width, height: image.height},
{width: MAX_WIDTH, height: MAX_HEIGHT},
)
const cropperRes = await openCropper({
mediaType: 'photo',
path: image.path,
...IMAGE_PARAMS,
width,
height,
})
const finalImg = await compressIfNeeded(cropperRes, MAX_SIZE)
const permanentPath = await moveToPremanantPath(finalImg.path)
result.push(permanentPath)
}
onSelectPhotos([...selectedPhotos, ...result])
}, [selectedPhotos, onSelectPhotos]) }, [selectedPhotos, onSelectPhotos])
return ( return (
@@ -156,15 +177,16 @@ export const PhotoCarouselPicker = ({
onPress={handleOpenGallery}> onPress={handleOpenGallery}>
<FontAwesomeIcon icon="image" style={pal.link} size={24} /> <FontAwesomeIcon icon="image" style={pal.link} size={24} />
</TouchableOpacity> </TouchableOpacity>
{localPhotos.photos.map((item: PhotoIdentifier, index: number) => ( {isSetup &&
<TouchableOpacity localPhotos.photos.map((item: PhotoIdentifier, index: number) => (
testID="openSelectPhotoButton" <TouchableOpacity
key={`local-image-${index}`} testID="openSelectPhotoButton"
style={[pal.border, styles.photoButton]} key={`local-image-${index}`}
onPress={() => handleSelectPhoto(item)}> style={[pal.border, styles.photoButton]}
<Image style={styles.photo} source={{uri: item.node.image.uri}} /> onPress={() => handleSelectPhoto(item)}>
</TouchableOpacity> <Image style={styles.photo} source={{uri: item.node.image.uri}} />
))} </TouchableOpacity>
))}
</ScrollView> </ScrollView>
) )
} }
+28 -16
View File
@@ -9,6 +9,10 @@ import {
openPicker, openPicker,
Image as PickedImage, Image as PickedImage,
} from 'react-native-image-crop-picker' } from 'react-native-image-crop-picker'
import {
requestPhotoAccessIfNeeded,
requestCameraAccessIfNeeded,
} from '../../../lib/permissions'
import {colors, gradients} from '../../lib/styles' import {colors, gradients} from '../../lib/styles'
import {DropdownButton} from './forms/DropdownButton' import {DropdownButton} from './forms/DropdownButton'
import {usePalette} from '../../lib/hooks/usePalette' import {usePalette} from '../../lib/hooks/usePalette'
@@ -53,26 +57,34 @@ export function UserAvatar({
{ {
label: 'Camera', label: 'Camera',
icon: 'camera', icon: 'camera',
// TODO: dark mode icon onPress: async () => {
onPress: () => { if (!(await requestCameraAccessIfNeeded())) {
openCamera({ return
mediaType: 'photo', }
cropping: true, onSelectNewAvatar?.(
width: 2000, await openCamera({
height: 2000, mediaType: 'photo',
cropperCircleOverlay: true, cropping: true,
forceJpg: true, // ios only width: 2000,
compressImageQuality: 1, height: 2000,
}).then(onSelectNewAvatar) cropperCircleOverlay: true,
forceJpg: true, // ios only
compressImageQuality: 1,
}),
)
}, },
}, },
{ {
label: 'Library', label: 'Library',
icon: 'image', icon: 'image',
onPress: () => { onPress: async () => {
openPicker({ if (!(await requestPhotoAccessIfNeeded())) {
return
}
const item = await openPicker({
mediaType: 'photo', mediaType: 'photo',
}).then(async item => { })
onSelectNewAvatar?.(
await openCropper({ await openCropper({
mediaType: 'photo', mediaType: 'photo',
path: item.path, path: item.path,
@@ -81,8 +93,8 @@ export function UserAvatar({
cropperCircleOverlay: true, cropperCircleOverlay: true,
forceJpg: true, // ios only forceJpg: true, // ios only
compressImageQuality: 1, compressImageQuality: 1,
}).then(onSelectNewAvatar) }),
}) )
}, },
}, },
// TODO: Remove avatar https://github.com/bluesky-social/social-app/issues/122 // TODO: Remove avatar https://github.com/bluesky-social/social-app/issues/122
+30 -18
View File
@@ -10,6 +10,10 @@ import {
openCropper, openCropper,
openPicker, openPicker,
} from 'react-native-image-crop-picker' } from 'react-native-image-crop-picker'
import {
requestPhotoAccessIfNeeded,
requestCameraAccessIfNeeded,
} from '../../../lib/permissions'
import {DropdownButton} from './forms/DropdownButton' import {DropdownButton} from './forms/DropdownButton'
import {usePalette} from '../../lib/hooks/usePalette' import {usePalette} from '../../lib/hooks/usePalette'
@@ -25,28 +29,36 @@ export function UserBanner({
{ {
label: 'Camera', label: 'Camera',
icon: 'camera', icon: 'camera',
// TODO: Add darkmode support https://github.com/bluesky-social/social-app/issues/78 onPress: async () => {
onPress: () => { if (!(await requestCameraAccessIfNeeded())) {
openCamera({ return
mediaType: 'photo', }
cropping: true, onSelectNewBanner?.(
compressImageMaxWidth: 6000, await openCamera({
width: 6000, mediaType: 'photo',
compressImageMaxHeight: 2000, cropping: true,
height: 2000, compressImageMaxWidth: 6000,
forceJpg: true, // ios only width: 6000,
compressImageQuality: 1, compressImageMaxHeight: 2000,
includeExif: true, height: 2000,
}).then(onSelectNewBanner) forceJpg: true, // ios only
compressImageQuality: 1,
includeExif: true,
}),
)
}, },
}, },
{ {
label: 'Library', label: 'Library',
icon: 'image', icon: 'image',
onPress: () => { onPress: async () => {
openPicker({ if (!(await requestPhotoAccessIfNeeded())) {
return
}
const item = await openPicker({
mediaType: 'photo', mediaType: 'photo',
}).then(async item => { })
onSelectNewBanner?.(
await openCropper({ await openCropper({
mediaType: 'photo', mediaType: 'photo',
path: item.path, path: item.path,
@@ -57,8 +69,8 @@ export function UserBanner({
forceJpg: true, // ios only forceJpg: true, // ios only
compressImageQuality: 1, compressImageQuality: 1,
includeExif: true, includeExif: true,
}).then(onSelectNewBanner) }),
}) )
}, },
}, },
// TODO: Remove banner https://github.com/bluesky-social/social-app/issues/122 // TODO: Remove banner https://github.com/bluesky-social/social-app/issues/122
+5
View File
@@ -11133,6 +11133,11 @@ react-native-pager-view@^6.0.2:
resolved "https://registry.yarnpkg.com/react-native-pager-view/-/react-native-pager-view-6.1.2.tgz#3522079b9a9d6634ca5e8d153bc0b4d660254552" resolved "https://registry.yarnpkg.com/react-native-pager-view/-/react-native-pager-view-6.1.2.tgz#3522079b9a9d6634ca5e8d153bc0b4d660254552"
integrity sha512-qs2KSFc+7N7B+UZ6SG2sTvCkppagm5fVyRclv1KFKc7lDtrhXLzN59tXJw575LDP/dRJoXsNwqUAhZJdws6ABQ== integrity sha512-qs2KSFc+7N7B+UZ6SG2sTvCkppagm5fVyRclv1KFKc7lDtrhXLzN59tXJw575LDP/dRJoXsNwqUAhZJdws6ABQ==
react-native-permissions@^3.6.1:
version "3.6.1"
resolved "https://registry.yarnpkg.com/react-native-permissions/-/react-native-permissions-3.6.1.tgz#73adcc1cef8cd57a9ef167b4507405f4ff5749c4"
integrity sha512-fzPpPQXeD34inUccqtoResSwYubfrwUguP4qrVUUv8+KSMjYSaHGoS5HaIJLZHlN9gO+TvLJZ2L5ZljTsb6qnQ==
react-native-progress@^5.0.0: react-native-progress@^5.0.0:
version "5.0.0" version "5.0.0"
resolved "https://registry.yarnpkg.com/react-native-progress/-/react-native-progress-5.0.0.tgz#f5ac6ceaeee27f184c660b00f29419e82a9d0ab0" resolved "https://registry.yarnpkg.com/react-native-progress/-/react-native-progress-5.0.0.tgz#f5ac6ceaeee27f184c660b00f29419e82a9d0ab0"