Only ask for permission to add photos from cameras (#1869)

* Patch camera roll to not ask for readwrite after addonly granted
* Only request add permission from cameras
This commit is contained in:
Ken-ichi
2024-07-29 11:51:14 -07:00
committed by GitHub
parent 182196a205
commit 899076e67a
8 changed files with 123 additions and 47 deletions

View File

@@ -1,43 +1,47 @@
# frozen_string_literal: true
# setup instructions from https://www.npmjs.com/package/react-native-permissions
def node_require(script)
def node_require( script )
# Resolve script with node to allow for hoisting
require Pod::Executable.execute_command('node', ['-p',
"require.resolve(
'#{script}',
{paths: [process.argv[1]]},
)", __dir__]).strip
require Pod::Executable.execute_command(
"node",
[
"-p", "require.resolve('#{script}', {paths: [process.argv[1]]})",
__dir__
]
).strip
end
node_require('react-native/scripts/react_native_pods.rb')
node_require('react-native-permissions/scripts/setup.rb')
node_require( "react-native/scripts/react_native_pods.rb" )
node_require( "react-native-permissions/scripts/setup.rb" )
platform :ios, min_ios_version_supported
prepare_react_native_project!
# ⬇️ uncomment wanted permissions
setup_permissions([
# 'AppTrackingTransparency',
# 'BluetoothPeripheral',
# 'Calendars',
'Camera',
# 'Contacts',
# 'FaceID',
'LocationAccuracy',
'LocationAlways',
'LocationWhenInUse',
'MediaLibrary',
'Microphone',
# 'Motion',
# 'Notifications',
'PhotoLibrary',
# 'PhotoLibraryAddOnly',
# 'Reminders',
# 'Siri',
# 'SpeechRecognition',
# 'StoreKit',
])
setup_permissions(
[
# 'AppTrackingTransparency',
# 'BluetoothPeripheral',
# 'Calendars',
"Camera",
# 'Contacts',
# 'FaceID',
"LocationAccuracy",
"LocationAlways",
"LocationWhenInUse",
"MediaLibrary",
"Microphone",
# 'Motion',
# 'Notifications',
"PhotoLibraryAddOnly",
"PhotoLibrary"
# 'Reminders',
# 'Siri',
# 'SpeechRecognition',
# 'StoreKit',
]
)
# If you are using a `react-native-flipper` your iOS build will fail when `NO_FLIPPER=1` is set.
# because `react-native-flipper` depends on (FlipperKit,...) that will be excluded

View File

@@ -1562,7 +1562,7 @@ SPEC CHECKSUMS:
RNFS: 4ac0f0ea233904cb798630b3c077808c06931688
RNGestureHandler: bc2cdb2dc42facdf34992ae364b8a728e19a3686
RNLocalize: e8694475db034bf601e17bd3dfa8986565e769eb
RNPermissions: a123c47480a5f5d7a04d40637ad1f7360a41b465
RNPermissions: b3d6efca086546e29a2920cd649a0ab04ca77794
RNReanimated: 6cfa556540186ce7ae7a0b048f369236b1d86ebb
RNScreens: b6b64d956af3715adbfe84808694ae82d3fec74f
RNShareMenu: cb9dac548c8bf147d06f0bf07296ad51ea9f5fc3
@@ -1574,6 +1574,6 @@ SPEC CHECKSUMS:
VisionCameraPluginInatVision: 8480b3955bc608e913135d3bebaa57939911fb82
Yoga: c716aea2ee01df6258550c7505fa61b248145ced
PODFILE CHECKSUM: ebb6b37cf92e00a96e3123d4db14c5658b4e5929
PODFILE CHECKSUM: c4b9a5123afaeedac9558dd67c0e10f6cf9706b0
COCOAPODS: 1.15.2

View File

@@ -0,0 +1,31 @@
diff --git a/node_modules/@react-native-camera-roll/camera-roll/ios/RNCCameraRoll.mm b/node_modules/@react-native-camera-roll/camera-roll/ios/RNCCameraRoll.mm
index b8f2aa2..aa0df68 100644
--- a/node_modules/@react-native-camera-roll/camera-roll/ios/RNCCameraRoll.mm
+++ b/node_modules/@react-native-camera-roll/camera-roll/ios/RNCCameraRoll.mm
@@ -207,6 +207,26 @@ RCT_EXPORT_METHOD(saveToCameraRoll:(NSURLRequest *)request
}
} completionHandler:^(BOOL success, NSError *error) {
if (success) {
+ // If the write succeeded but we don't have readwrite permission, that
+ // means we have addonly permission and we cannot read the file we
+ // just created to construct a response
+ if (@available(iOS 14, *)) {
+ PHAuthorizationStatus readWriteAuthStatus = [PHPhotoLibrary authorizationStatusForAccessLevel:PHAccessLevelReadWrite];
+ if (readWriteAuthStatus != PHAuthorizationStatusAuthorized) {
+ NSDictionary *addOnlyResponse = @{
+ @"node": @{
+ @"id": placeholder.localIdentifier,
+ @"type": options[@"type"],
+ @"image": @{
+ @"uri": @"placeholder/readWritePermissionNotGranted"
+ }
+ }
+ };
+ resolve(addOnlyResponse);
+ return;
+ }
+ }
+
PHFetchOptions *options = [PHFetchOptions new];
options.includeHiddenAssets = YES;
options.includeAllBurstAssets = YES;

View File

@@ -1,6 +1,6 @@
import { useIsFocused, useNavigation } from "@react-navigation/native";
import PermissionGateContainer, {
READ_WRITE_MEDIA_PERMISSIONS
WRITE_MEDIA_PERMISSIONS
} from "components/SharedComponents/PermissionGateContainer.tsx";
import { View } from "components/styledComponents";
import React, {
@@ -166,7 +166,7 @@ const CameraWithDevice = ( {
testID="CameraWithDevice"
>
<PermissionGateContainer
permissions={READ_WRITE_MEDIA_PERMISSIONS}
permissions={WRITE_MEDIA_PERMISSIONS}
title={t( "Save-photos-to-your-gallery" )}
titleDenied={t( "Save-photos-to-your-gallery" )}
body={t( "iNaturalist-can-save-photos-you-take-in-the-app-to-your-devices-gallery" )}

View File

@@ -2,9 +2,14 @@
import { CameraRoll } from "@react-native-camera-roll/camera-roll";
import { useNavigation } from "@react-navigation/native";
import {
permissionResultFromMultiple,
READ_WRITE_MEDIA_PERMISSIONS
} from "components/SharedComponents/PermissionGateContainer.tsx";
import {
useCallback
} from "react";
import { checkMultiple, RESULTS } from "react-native-permissions";
import Observation from "realmModels/Observation";
import ObservationPhoto from "realmModels/ObservationPhoto";
import { log } from "sharedHelpers/logger";
@@ -32,15 +37,28 @@ export async function savePhotosToCameraGallery(
uris: [string],
onEachSuccess: Function
) {
// $FlowIgnore
const readWritePermissionResult = permissionResultFromMultiple(
await checkMultiple( READ_WRITE_MEDIA_PERMISSIONS )
);
uris.reduce(
async ( memo, uri ) => {
// logger.info( "saving rotated original camera photo: ", uri );
try {
const savedPhotoUri = await CameraRoll.save( uri, {
type: "photo",
album: "iNaturalist Next"
} );
let savedPhotoUri;
// One quirk of CameraRoll is that if you want to write ton album, you
// need readwrite permission, but we don't want to ask for that here
// b/c it might come immediately after asking for *add only*
// permission, so we're checking to see if we have that permission
// and skipping the album if we don't
if ( readWritePermissionResult === RESULTS.GRANTED ) {
savedPhotoUri = await CameraRoll.save( uri, {
type: "photo",
album: "iNaturalist Next"
} );
} else {
savedPhotoUri = await CameraRoll.save( uri );
}
// logger.info( "saved to camera roll: ", savedPhotoUri );
// Save these camera roll URIs, so later on observation editor can update
// the EXIF metadata of these photos, once we retrieve a location.
@@ -105,7 +123,7 @@ const usePrepareStoreAndNavigate = ( options: Options ): Function => {
local: true
} );
setObservations( [newObservation] );
if ( addPhotoPermissionResult !== "granted" ) return Promise.resolve( );
if ( addPhotoPermissionResult !== RESULTS.GRANTED ) return Promise.resolve( );
return savePhotosToCameraGallery( cameraUris, addCameraRollUri );
}, [
addCameraRollUri,

View File

@@ -1,7 +1,13 @@
import { useNavigation } from "@react-navigation/native";
import Modal from "components/SharedComponents/Modal.tsx";
import _ from "lodash";
import React, { useCallback, useEffect, useState } from "react";
import React, {
JSX,
PropsWithChildren,
useCallback,
useEffect,
useState
} from "react";
import { Platform } from "react-native";
import {
AndroidPermission,
@@ -38,6 +44,15 @@ if ( usesAndroid10Permissions ) {
];
}
// TODO does this really work for Android above 10?
let androidWritePermissions: AndroidPermission[] = [];
if ( usesAndroid10Permissions ) {
androidWritePermissions = [
...androidWritePermissions,
PERMISSIONS.ANDROID.WRITE_EXTERNAL_STORAGE
];
}
const androidCameraPermissions = usesAndroid10Permissions
? [PERMISSIONS.ANDROID.CAMERA, PERMISSIONS.ANDROID.WRITE_EXTERNAL_STORAGE]
: [PERMISSIONS.ANDROID.CAMERA];
@@ -54,16 +69,19 @@ export const READ_WRITE_MEDIA_PERMISSIONS = Platform.OS === "ios"
? [PERMISSIONS.IOS.PHOTO_LIBRARY]
: androidReadWritePermissions;
export const WRITE_MEDIA_PERMISSIONS = Platform.OS === "ios"
? [PERMISSIONS.IOS.PHOTO_LIBRARY_ADD_ONLY]
: androidWritePermissions;
export const LOCATION_PERMISSIONS = Platform.OS === "ios"
? [PERMISSIONS.IOS.LOCATION_WHEN_IN_USE]
: [PERMISSIONS.ANDROID.ACCESS_FINE_LOCATION];
type Props = {
interface Props extends PropsWithChildren {
blockedPrompt?: string;
body?: string;
body2?: string;
buttonText?: string;
children?: React.ReactNode,
icon: string;
image?: number;
onModalHide?: () => void;
@@ -76,7 +94,7 @@ type Props = {
title?: string;
titleDenied?: string;
withoutNavigation?: boolean;
};
}
interface MultiResult {
[permission: string]: PermissionStatus;
@@ -125,7 +143,7 @@ const PermissionGateContainer = ( {
title,
titleDenied,
withoutNavigation
}: Props ) => {
}: Props ): JSX.Element | null => {
const [result, setResult] = useState<PermissionStatus | null>( null );
const [modalShown, setModalShown] = useState( false );

View File

@@ -123,7 +123,12 @@ const createObservationFlowSlice = ( set, get ) => ( {
resetObservationFlowSlice: ( ) => set( DEFAULT_STATE ),
addCameraRollUri: uri => set( state => {
const savedUris = state.cameraRollUris;
savedUris.push( uri );
// A placeholder uri means we don't know the real URI, probably b/c we
// only had write permission so we were able to write the photo to the
// camera roll but not read anything about it. Keep in mind this is just
// a hack around a bug in CameraRoll. See
// patches/@react-native-camera-roll+camera-roll+7.5.2.patch
if ( uri && !uri.match( /placeholder/ ) ) savedUris.push( uri );
return ( {
cameraRollUris: savedUris,
savingPhoto: false

View File

@@ -4,14 +4,14 @@ import faker from "tests/helpers/faker";
describe( "userPrepareStoreAndNavigate", ( ) => {
describe( "savePhotosToCameraGallery", ( ) => {
it( "should call CameraRoll.save three times when given three uris", ( ) => {
it( "should call CameraRoll.save three times when given three uris", async ( ) => {
const uris = [
faker.system.filePath( ),
faker.system.filePath( ),
faker.system.filePath( )
];
const mockOnEachSuccess = jest.fn( );
savePhotosToCameraGallery( uris, mockOnEachSuccess );
await savePhotosToCameraGallery( uris, mockOnEachSuccess );
// This should test that CameraRoll.save was called once for each of the
// uris AND that it was called in the order of the uris array
// https://jestjs.io/docs/mock-functions#custom-matchers