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
+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() {
CameraRoll.getPhotos({first: 20}).then(r => {
return CameraRoll.getPhotos({first: 20}).then(r => {
runInAction(() => {
this.photos = r.edges
})
+2 -11
View File
@@ -43,7 +43,6 @@ import {
} from '../../../lib/strings'
import {getLinkMeta} from '../../../lib/link-meta'
import {downloadAndResize} from '../../../lib/images'
import {UserLocalPhotosModel} from '../../../state/models/user-local-photos'
import {getMentionAt, insertMentionAt} from '../../../lib/strings/mention-manip'
import {PhotoCarouselPicker, cropPhoto} from './PhotoCarouselPicker'
import {SelectedPhoto} from './SelectedPhoto'
@@ -94,10 +93,6 @@ export const ComposePost = observer(function ComposePost({
() => new UserAutocompleteViewModel(store),
[store],
)
const localPhotos = React.useMemo<UserLocalPhotosModel>(
() => new UserLocalPhotosModel(store),
[store],
)
// HACK
// 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
useEffect(() => {
autocompleteView.setup()
localPhotos.setup()
}, [autocompleteView, localPhotos])
}, [autocompleteView])
// external link metadata-fetch flow
useEffect(() => {
@@ -436,13 +430,10 @@ export const ComposePost = observer(function ComposePost({
/>
)}
</ScrollView>
{isSelectingPhotos &&
localPhotos.photos != null &&
selectedPhotos.length < 4 ? (
{isSelectingPhotos && selectedPhotos.length < 4 ? (
<PhotoCarouselPicker
selectedPhotos={selectedPhotos}
onSelectPhotos={onSelectPhotos}
localPhotos={localPhotos}
/>
) : !extLink &&
selectedPhotos.length === 0 &&
+57 -35
View File
@@ -12,6 +12,10 @@ import {
UserLocalPhotosModel,
PhotoIdentifier,
} from '../../../state/models/user-local-photos'
import {
requestPhotoAccessIfNeeded,
requestCameraAccessIfNeeded,
} from '../../../lib/permissions'
import {compressIfNeeded, scaleDownDimensions} from '../../../lib/images'
import {usePalette} from '../../lib/hooks/usePalette'
import {useStores} from '../../../state'
@@ -67,16 +71,31 @@ export async function cropPhoto(
export const PhotoCarouselPicker = ({
selectedPhotos,
onSelectPhotos,
localPhotos,
}: {
selectedPhotos: string[]
onSelectPhotos: (v: string[]) => void
localPhotos: UserLocalPhotosModel
}) => {
const pal = usePalette('default')
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 () => {
try {
if (!(await requestCameraAccessIfNeeded())) {
return
}
const cameraRes = await openCamera({
mediaType: 'photo',
cropping: true,
@@ -107,34 +126,36 @@ export const PhotoCarouselPicker = ({
[store.log, selectedPhotos, onSelectPhotos],
)
const handleOpenGallery = useCallback(() => {
openPicker({
const handleOpenGallery = useCallback(async () => {
if (!(await requestPhotoAccessIfNeeded())) {
return
}
const items = await openPicker({
multiple: true,
maxFiles: 4 - selectedPhotos.length,
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])
return (
@@ -156,15 +177,16 @@ export const PhotoCarouselPicker = ({
onPress={handleOpenGallery}>
<FontAwesomeIcon icon="image" style={pal.link} size={24} />
</TouchableOpacity>
{localPhotos.photos.map((item: PhotoIdentifier, index: number) => (
<TouchableOpacity
testID="openSelectPhotoButton"
key={`local-image-${index}`}
style={[pal.border, styles.photoButton]}
onPress={() => handleSelectPhoto(item)}>
<Image style={styles.photo} source={{uri: item.node.image.uri}} />
</TouchableOpacity>
))}
{isSetup &&
localPhotos.photos.map((item: PhotoIdentifier, index: number) => (
<TouchableOpacity
testID="openSelectPhotoButton"
key={`local-image-${index}`}
style={[pal.border, styles.photoButton]}
onPress={() => handleSelectPhoto(item)}>
<Image style={styles.photo} source={{uri: item.node.image.uri}} />
</TouchableOpacity>
))}
</ScrollView>
)
}
+28 -16
View File
@@ -9,6 +9,10 @@ import {
openPicker,
Image as PickedImage,
} from 'react-native-image-crop-picker'
import {
requestPhotoAccessIfNeeded,
requestCameraAccessIfNeeded,
} from '../../../lib/permissions'
import {colors, gradients} from '../../lib/styles'
import {DropdownButton} from './forms/DropdownButton'
import {usePalette} from '../../lib/hooks/usePalette'
@@ -53,26 +57,34 @@ export function UserAvatar({
{
label: 'Camera',
icon: 'camera',
// TODO: dark mode icon
onPress: () => {
openCamera({
mediaType: 'photo',
cropping: true,
width: 2000,
height: 2000,
cropperCircleOverlay: true,
forceJpg: true, // ios only
compressImageQuality: 1,
}).then(onSelectNewAvatar)
onPress: async () => {
if (!(await requestCameraAccessIfNeeded())) {
return
}
onSelectNewAvatar?.(
await openCamera({
mediaType: 'photo',
cropping: true,
width: 2000,
height: 2000,
cropperCircleOverlay: true,
forceJpg: true, // ios only
compressImageQuality: 1,
}),
)
},
},
{
label: 'Library',
icon: 'image',
onPress: () => {
openPicker({
onPress: async () => {
if (!(await requestPhotoAccessIfNeeded())) {
return
}
const item = await openPicker({
mediaType: 'photo',
}).then(async item => {
})
onSelectNewAvatar?.(
await openCropper({
mediaType: 'photo',
path: item.path,
@@ -81,8 +93,8 @@ export function UserAvatar({
cropperCircleOverlay: true,
forceJpg: true, // ios only
compressImageQuality: 1,
}).then(onSelectNewAvatar)
})
}),
)
},
},
// TODO: Remove avatar https://github.com/bluesky-social/social-app/issues/122
+30 -18
View File
@@ -10,6 +10,10 @@ import {
openCropper,
openPicker,
} from 'react-native-image-crop-picker'
import {
requestPhotoAccessIfNeeded,
requestCameraAccessIfNeeded,
} from '../../../lib/permissions'
import {DropdownButton} from './forms/DropdownButton'
import {usePalette} from '../../lib/hooks/usePalette'
@@ -25,28 +29,36 @@ export function UserBanner({
{
label: 'Camera',
icon: 'camera',
// TODO: Add darkmode support https://github.com/bluesky-social/social-app/issues/78
onPress: () => {
openCamera({
mediaType: 'photo',
cropping: true,
compressImageMaxWidth: 6000,
width: 6000,
compressImageMaxHeight: 2000,
height: 2000,
forceJpg: true, // ios only
compressImageQuality: 1,
includeExif: true,
}).then(onSelectNewBanner)
onPress: async () => {
if (!(await requestCameraAccessIfNeeded())) {
return
}
onSelectNewBanner?.(
await openCamera({
mediaType: 'photo',
cropping: true,
compressImageMaxWidth: 6000,
width: 6000,
compressImageMaxHeight: 2000,
height: 2000,
forceJpg: true, // ios only
compressImageQuality: 1,
includeExif: true,
}),
)
},
},
{
label: 'Library',
icon: 'image',
onPress: () => {
openPicker({
onPress: async () => {
if (!(await requestPhotoAccessIfNeeded())) {
return
}
const item = await openPicker({
mediaType: 'photo',
}).then(async item => {
})
onSelectNewBanner?.(
await openCropper({
mediaType: 'photo',
path: item.path,
@@ -57,8 +69,8 @@ export function UserBanner({
forceJpg: true, // ios only
compressImageQuality: 1,
includeExif: true,
}).then(onSelectNewBanner)
})
}),
)
},
},
// TODO: Remove banner https://github.com/bluesky-social/social-app/issues/122