개발자가 사용자의 위치, 카메라, 사진, 파일 등의 데이터에 접근하기 위해서는 해당 접근에 대한 사용자 권한 승인이 필수적이다. 기기에 상관없이 모든 권한들이 통일되어있다면 더욱 좋았겠지만 애플과 안드로이드는 각자만의 권한 처리 방식을 사용한다. 특히 iOS 13버전/android API30에서 권한과 관련된 여러 업데이트들이 존재하는데, 몇가지 중요한 점들만 살펴본다면 아래와 같다.
다행히 react native에서도 권한 요청 및 확인을 할 수 있도록 하는 react-native-permissions라는 라이브러리가 존재한다. docs가 매우 자세하게 나와있기는 하지만, 영어보다 한국어가 편한 나 포함 사람들을 위해 사용방법을 코드예시와 함께 정리해보았다.
yarn add react-native-permissions
명령어를 통해 설치한다. (만약 react native 버전이 63 미만이라면 linking 설정이 추가로 필요하다. 이건 내가 안쓰니까 패스)
android/apps/src/main/AndroidManifest.xml에 사용할 권한을 아래와 같이 추가합니다. 앞서 말했듯이 ACCESS_FINE_LOCATION을 사용하기 위해서는 ACCESS_COARCE_LOCATION이 필수로 필요하다는 것을 잊지 않도록 한다.
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
package="com.myawesomeapp">
<!-- 🚨 Keep only the permissions used in your app 🚨 -->
<uses-permission android:name="android.permission.ACCEPT_HANDOVER" />
<uses-permission android:name="android.permission.ACCESS_BACKGROUND_LOCATION" />
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
<uses-permission android:name="android.permission.ACCESS_MEDIA_LOCATION" />
<uses-permission android:name="android.permission.ACTIVITY_RECOGNITION" />
<uses-permission android:name="android.permission.ANSWER_PHONE_CALLS" />
<uses-permission android:name="android.permission.BLUETOOTH_ADVERTISE" />
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT" />
<uses-permission android:name="android.permission.BLUETOOTH_SCAN" />
<uses-permission android:name="android.permission.BODY_SENSORS" />
<uses-permission android:name="android.permission.CALL_PHONE" />
<uses-permission android:name="android.permission.CAMERA" />
<uses-permission android:name="android.permission.GET_ACCOUNTS" />
<uses-permission android:name="android.permission.PROCESS_OUTGOING_CALLS" />
<uses-permission android:name="android.permission.READ_CALENDAR" />
<uses-permission android:name="android.permission.READ_CALL_LOG" />
<uses-permission android:name="android.permission.READ_CONTACTS" />
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" />
<uses-permission android:name="android.permission.READ_PHONE_NUMBERS" />
<uses-permission android:name="android.permission.READ_PHONE_STATE" />
<uses-permission android:name="android.permission.READ_SMS" />
<uses-permission android:name="android.permission.RECEIVE_MMS" />
<uses-permission android:name="android.permission.RECEIVE_SMS" />
<uses-permission android:name="android.permission.RECEIVE_WAP_PUSH" />
<uses-permission android:name="android.permission.RECORD_AUDIO" />
<uses-permission android:name="android.permission.SEND_SMS" />
<uses-permission android:name="android.permission.USE_SIP" />
<uses-permission android:name="android.permission.WRITE_CALENDAR" />
<uses-permission android:name="android.permission.WRITE_CALL_LOG" />
<uses-permission android:name="android.permission.WRITE_CONTACTS" />
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" />
<uses-permission android:name="com.android.voicemail.permission.ADD_VOICEMAIL" />
<!-- … -->
</manifest>
처음에는 iOS의 권한 handler가 아무것도 설치되어있지 않다. ios/Podfile에 아래와 같이 추가하려는 권한에 대한 permission handler을 추가하고 ios 폴더에서 pod install
명령어를 실행한다.
target 'YourAwesomeProject' do
# …
permissions_path = '../node_modules/react-native-permissions/ios'
pod 'Permission-AppTrackingTransparency', :path => "#{permissions_path}/AppTrackingTransparency"
pod 'Permission-BluetoothPeripheral', :path => "#{permissions_path}/BluetoothPeripheral"
pod 'Permission-Calendars', :path => "#{permissions_path}/Calendars"
pod 'Permission-Camera', :path => "#{permissions_path}/Camera"
pod 'Permission-Contacts', :path => "#{permissions_path}/Contacts"
pod 'Permission-FaceID', :path => "#{permissions_path}/FaceID"
pod 'Permission-LocationAccuracy', :path => "#{permissions_path}/LocationAccuracy"
pod 'Permission-LocationAlways', :path => "#{permissions_path}/LocationAlways"
pod 'Permission-LocationWhenInUse', :path => "#{permissions_path}/LocationWhenInUse"
pod 'Permission-MediaLibrary', :path => "#{permissions_path}/MediaLibrary"
pod 'Permission-Microphone', :path => "#{permissions_path}/Microphone"
pod 'Permission-Motion', :path => "#{permissions_path}/Motion"
pod 'Permission-Notifications', :path => "#{permissions_path}/Notifications"
pod 'Permission-PhotoLibrary', :path => "#{permissions_path}/PhotoLibrary"
pod 'Permission-PhotoLibraryAddOnly', :path => "#{permissions_path}/PhotoLibraryAddOnly"
pod 'Permission-Reminders', :path => "#{permissions_path}/Reminders"
pod 'Permission-Siri', :path => "#{permissions_path}/Siri"
pod 'Permission-SpeechRecognition', :path => "#{permissions_path}/SpeechRecognition"
pod 'Permission-StoreKit', :path => "#{permissions_path}/StoreKit"
end
이후 ios/프로젝트이름/Info.plist에 권한에 대한 설명을 추가한다. 해당 string은 시스템 권한 설정 모달의 메시지로서 사용된다. 복붙하다가 카메라 권한 요청 모달에 갤러리 접근 메시지가 뜨는 불상사가 없도록 하자. XCode에서 직접 설정도 가능하다.
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<!-- 🚨 Keep only the permissions used in your app 🚨 -->
<key>NSAppleMusicUsageDescription</key>
<string>YOUR TEXT</string>
<key>NSBluetoothAlwaysUsageDescription</key>
<string>YOUR TEXT</string>
<key>NSBluetoothPeripheralUsageDescription</key>
<string>YOUR TEXT</string>
<key>NSCalendarsUsageDescription</key>
<string>YOUR TEXT</string>
<key>NSCameraUsageDescription</key>
<string>YOUR TEXT</string>
<key>NSContactsUsageDescription</key>
<string>YOUR TEXT</string>
<key>NSFaceIDUsageDescription</key>
<string>YOUR TEXT</string>
<key>NSLocationAlwaysAndWhenInUseUsageDescription</key>
<string>YOUR TEXT</string>
<key>NSLocationAlwaysUsageDescription</key>
<string>YOUR TEXT</string>
<key>NSLocationTemporaryUsageDescriptionDictionary</key>
<dict>
<key>YOUR-PURPOSE-KEY</key>
<string>YOUR TEXT</string>
</dict>
<key>NSLocationWhenInUseUsageDescription</key>
<string>YOUR TEXT</string>
<key>NSMicrophoneUsageDescription</key>
<string>YOUR TEXT</string>
<key>NSMotionUsageDescription</key>
<string>YOUR TEXT</string>
<key>NSPhotoLibraryUsageDescription</key>
<string>YOUR TEXT</string>
<key>NSPhotoLibraryAddUsageDescription</key>
<string>YOUR TEXT</string>
<key>NSRemindersUsageDescription</key>
<string>YOUR TEXT</string>
<key>NSSpeechRecognitionUsageDescription</key>
<string>YOUR TEXT</string>
<key>NSSiriUsageDescription</key>
<string>YOUR TEXT</string>
<key>NSUserTrackingUsageDescription</key>
<string>YOUR TEXT</string>
<!-- … -->
</dict>
</plist>
react-native-permissions의 권한 체크/요청에 따른 결과는 아래 표를 따른다.
Return value | Notes |
---|---|
RESULTS.UNAVAILABLE | This feature is not available (on this device / in this context) |
RESULTS.DENIED | The permission has not been requested / is denied but requestable |
RESULTS.GRANTED | The permission is granted |
RESULTS.LIMITED | The permission is granted but with limitations |
RESULTS.BLOCKED | The permission is denied and not requestable anymore |
권한 체크/요청 플로우는 아래와 같다.
해당 플로우를 따라 아래와 같이 코드로 작성할 수 있다. check 결과가 DENIED일 때에 request를 실행했으며, 그 결과가 GRANTED가 아닐 경우 LIMITED, BLOCKED와 같이 취급하도록 하였다. (LIMITED의 경우 권한 종류에 따라 달라지는데, 해당 프로젝트에서는 앱 사용 중에만 위치 정보에 접근하기 때문에 따로 케이스를 분리하지는 않았다)
let requested: PermissionStatus;
const checked = await check(needPermission);
switch (checked) {
case RESULTS.UNAVAILABLE:
return handlePermissionError(
strings.PERMISSION_UNAVAILABLE,
essential
);
case RESULTS.GRANTED:
return handlePermissionSuccess();
case RESULTS.DENIED:
requested = await request(needPermission);
if (requested === RESULTS.GRANTED) {
return handlePermissionSuccess();
}
case RESULTS.LIMITED:
case RESULTS.BLOCKED:
default:
return handlePermissionError(strings.PERMISSION_BLOCKED, essential);
}
iOS와 Android 모두 권한 요청 시도가 있을 때에는, 위의 그림과 같이 사용자에게 해당 권한이 왜 필요하고 어떻게 사용되는지 전달하는 것을 권유하고있다. 그러나 모든 개별 권한에 맞춤형 페이지를 만드는 것도 어려웠고, 실제 그 기능을 사용할 때에 (보통 한 두개의) 권한에 대한 설명 메시지를 간략하게 보여주는 것 만으로도 충분히 사용자에게 권한 요청에 대한 이유를 설명할 수 있을 것이라고 생각했다. 아래는 dot slash dash라는 어플인데, 이런 방식으로 앞에 시스템 권한 요청 모달이 뜨면 뒤쪽에 해당 권한에 대한 설명이 보이도록 위의 함수를 업데이트했다.
react native 앱의 최상단에 모달을 삽입하고 해당 모달을 관리하는 mobx store을 사용하여, 앞에서 짰던 권한요청 함수가 실행될 때에 open 여부와 message를 변경하는 getPermission 함수를 작성하였다. 함수는 permission 이름을 받아, 기기가 iOS인지 Android인지를 확인하고 그에 맞는 권한을 체크/요청한다. 권한이 승인된 경우에는 인자로 받은 onSuccess 함수를 실행하고, 권한이 거절되면 (essential 여부에 따라 설정페이지로 이동하며) onFailed 함수를 실행하도록 구성하였다.
const androidPermissions: PermissionsPerOS = {
location: PERMISSIONS.ANDROID.ACCESS_FINE_LOCATION,
camera: PERMISSIONS.ANDROID.CAMERA,
photo: PERMISSIONS.ANDROID.READ_EXTERNAL_STORAGE,
};
const iosPermissions: PermissionsPerOS = {
location: PERMISSIONS.IOS.LOCATION_WHEN_IN_USE,
camera: PERMISSIONS.IOS.CAMERA,
photo: PERMISSIONS.IOS.PHOTO_LIBRARY,
};
const permissionsPerOS =
Platform.OS === strings.PLATFORM_IOS ? iosPermissions : androidPermissions;
const getPermission = async (
permission: PossiblePermission,
onSuccess?: () => void,
onFailed?: () => void,
essential = false
): Promise<boolean> => {
const needPermission = permissionsPerOS[permission];
permissionModalStore.setMessage(PERMISSION_REQUEST_MESSAGE[permission]);
permissionModalStore.setOpen(true);
const handlePermissionSuccess = () => {
if (onSuccess) onSuccess();
permissionModalStore.setOpen(false);
permissionModalStore.setMessage('');
return true;
};
const handlePermissionError = (message: string, openSetting = false) => {
if (openSetting) goToSettings(message);
if (onFailed) onFailed();
permissionModalStore.setOpen(false);
permissionModalStore.setMessage('');
return false;
};
let requested: PermissionStatus;
const checked = await check(needPermission);
switch (checked) {
case RESULTS.UNAVAILABLE:
return handlePermissionError(
strings.PERMISSION_UNAVAILABLE,
essential
);
case RESULTS.GRANTED:
return handlePermissionSuccess();
case RESULTS.DENIED:
requested = await request(needPermission);
if (requested === RESULTS.GRANTED) {
return handlePermissionSuccess();
}
case RESULTS.LIMITED:
case RESULTS.BLOCKED:
default:
return handlePermissionError(strings.PERMISSION_BLOCKED, essential);
}
};
앱을 사용하다가 사진을 첨부하거나, 근처 매장을 찾는 등의 경우에는 한 두개의 권한에만 승인 요청이 발생한다. 그러나 앱을 처음 깔거나, 앱을 실행할 때마다(앱에 따라서는..좋지 않은 플로우기는 하다) 앱에서 필요한 설정들을 설명하고 한번에 승인을 요청하기도 한다. 이러한 권한 요청들을 리스트로 받아, 리스트 순서대로 권한을 요청하는 getPermissions 함수를 작성해보자. Promise들을 순차적으로 실행 보장하기 위해 재귀적인 방법을 쓰기도 하지만 아래 함수에서는 reduce와 async/await를 사용하였다.
const getPermissions = async (
permissions: PossiblePermission[],
onSuccess?: () => void,
onFailed?: () => void,
essential = false
): Promise<void> => {
const permissionsResult = permissions.reduce(
async (previousPermission, currentPermission) => {
const previousPermissionResult = await previousPermission;
const currentPermissionResult = await getPermission(
currentPermission
);
previousPermissionResult.push(currentPermissionResult);
return previousPermissionResult;
},
Promise.resolve<boolean[]>([])
);
permissionsResult.then((result) => {
if (result.every(Boolean) && onSuccess) onSuccess();
if (!result.every(Boolean) && onFailed) {
if (essential) goToSettings(strings.PERMISSION_BLOCKED);
onFailed();
}
});
};
아래는 실제로 getPermissions(['location', 'camera', 'photo'])
함수를 실행했을 때 확인할 수 있는 결과이다.
이제는 안드로이드에 do not ask again도 안뜨던데요