diff --git a/packages/camera/README.md b/packages/camera/README.md index de782836..6b3940e4 100644 --- a/packages/camera/README.md +++ b/packages/camera/README.md @@ -18,6 +18,10 @@ A plugin that allows you to take a picture and optionally save it on the device storage. +**Note: Version 7 contains breaking changes:** +* New behavior on requesting permissions, detailed in [Request for user permissions](#request-for-user-permissions). + + ## Installation To install the plugin, run the following command in the root directory of your project: @@ -54,17 +58,38 @@ To prompt the user to grant or deny your app access to their camera and photo ga ```TypeScript import { requestPermissions } from '@nativescript/camera'; -requestPermissions().then( - function success() { - // permission request accepted or already granted - // ... call camera.takePicture here ... - }, - function failure() { - // permission request rejected - // ... tell the user ... - } -); +const perms = await camera.requestPermissions(); + +if (perms.Success) { + // permission request accepted or already granted + // ... call camera.takePicture here ... +} else { + // permission request rejected + // ... tell the user ... + const cameraPermissionSuccess = perms.Details.Camera.Success; + const photoPermissionSuccess = perms.Details.Photo.Success +} + ``` + +If specifying the `saveToGallery = false` option, you can call the `requestCameraPermissions` method. + +```TypeScript +import { requestPermissions } from '@nativescript/camera'; + +const perms = await camera.requestCameraPermissions(); + +if (perms.Success) { + // permission request accepted or already granted + // ... call camera.takePicture here ... +} else { + // permission request rejected + // ... tell the user ... + +} + +``` + > **Note:** (**for Android**) Older versions of Android that don't use a request permissions popup won't be affected by the usage of the `requestPermissions()` method. > **Note**: (**for iOS**) If the user rejects permissions from the iOS popup, the app is not allowed to ask again. You can instruct the user to go to app settings and enable the camera permission manually from there. diff --git a/packages/camera/common.ts b/packages/camera/common.ts index 358fb740..6f3b51a0 100644 --- a/packages/camera/common.ts +++ b/packages/camera/common.ts @@ -1,11 +1,81 @@ +import * as permissions from '@nativescript-community/perms'; export function getAspectSafeDimensions(sourceWidth, sourceHeight, reqWidth, reqHeight) { - let widthCoef = sourceWidth / reqWidth; - let heightCoef = sourceHeight / reqHeight; + let widthCoef = sourceWidth / reqWidth; + let heightCoef = sourceHeight / reqHeight; - let aspectCoef = widthCoef > heightCoef ? widthCoef : heightCoef; + let aspectCoef = widthCoef > heightCoef ? widthCoef : heightCoef; - return { - width: Math.floor(sourceWidth / aspectCoef), - height: Math.floor(sourceHeight / aspectCoef) - }; -} \ No newline at end of file + return { + width: Math.floor(sourceWidth / aspectCoef), + height: Math.floor(sourceHeight / aspectCoef), + }; +} +function mapStatus(result: permissions.Result): Status { + let status = Status.unknown; + if (result && result.length > 1) { + if (Object.keys(Status).findIndex((k) => k === result[0]) >= 0) { + status = Status[result[0]]; + } + } + return status; +} +export function mapError(e): PermissionResult { + return { + Success: false, + Details: Status.error, + Error: e, + }; +} +export function mapCameraPermissionStatus(permission: permissions.Result): PermissionResult { + const status = mapStatus(permission); + const result = { + Success: status === Status.authorized, + Details: status, + }; + return result; +} + +export function mapPhotoPermissionStatus(permission: permissions.Result): PermissionResult { + const status = mapStatus(permission); + const result = { + Success: status === Status.authorized || status === Status.limited, + Details: status, + }; + return result; +} + +export function combineCamerPhotoPermissions(cameraPermissions: PermissionResult, photoPermissions: PermissionResult): PermissionsResult { + const result = { + Success: cameraPermissions.Success && photoPermissions.Success, + Details: { + Camera: cameraPermissions, + Photo: photoPermissions, + }, + }; + return result; +} + +export enum Status { + authorized = 'authorized', + denied = 'denied', + limited = 'limited', + restricted = 'restricted', + undetermined = 'undetermined', + never_ask_again = 'never_ask_again', + unknown = 'unknown', + error = 'error', +} + +export interface PermissionsResult { + Success: boolean; + Details: { + Camera?: PermissionResult; + Photo?: PermissionResult; + }; +} + +export interface PermissionResult { + Success: boolean; + Details: Status; + Error?: any; +} diff --git a/packages/camera/index.android.ts b/packages/camera/index.android.ts index 5832e027..54bbb1fe 100644 --- a/packages/camera/index.android.ts +++ b/packages/camera/index.android.ts @@ -1,6 +1,7 @@ import { Utils, Application, Device, Trace, ImageAsset } from '@nativescript/core'; import * as permissions from '@nativescript-community/perms'; import { CameraOptions } from '.'; +import { combineCamerPhotoPermissions, mapCameraPermissionStatus, mapPhotoPermissionStatus, PermissionResult, PermissionsResult, Status } from './common'; const REQUEST_IMAGE_CAPTURE = 3453; declare let global: any; @@ -32,7 +33,6 @@ export const takePicture = function (options?: CameraOptions): Promise { reqHeight = options.height ? options.height * density : reqWidth; shouldKeepAspectRatio = Utils.isNullOrUndefined(options.keepAspectRatio) ? shouldKeepAspectRatio : options.keepAspectRatio; } - const hasStoragePerm = await permissions.check(android.Manifest.permission.WRITE_EXTERNAL_STORAGE); if (!api30() && !hasStoragePerm[1]) { saveToGallery = false; @@ -174,25 +174,25 @@ function api30(): boolean { return (android).os.Build.VERSION.SDK_INT >= 30 && Utils.ad.getApplicationContext().getApplicationInfo().targetSdkVersion >= 30; } -export async function requestPermissions() { +export async function requestPermissions(): Promise { + return requestCameraPermissions().then((cameraPermissions) => requestPhotosPermissions().then((photoPermissions) => combineCamerPhotoPermissions(cameraPermissions, photoPermissions))); +} + +export async function requestPhotosPermissions(): Promise { if (api30()) { - const hasPerm = await permissions.request('android.permission.CAMERA'); - return hasPerm[1]; + return { + Success: true, + Details: Status.authorized, + }; } else { - const hasPerm1 = await permissions.request('android.permission.WRITE_EXTERNAL_STORAGE'); - const hasPerm2 = await permissions.request('android.permission.CAMERA'); - return hasPerm1[1] && hasPerm2[1]; + const hasPerm = await permissions.request('android.permission.WRITE_EXTERNAL_STORAGE'); + return mapPhotoPermissionStatus(hasPerm); } } -export async function requestPhotosPermissions() { - const hasPerm = await permissions.request('android.permission.WRITE_EXTERNAL_STORAGE'); - return hasPerm[1]; -} - -export async function requestCameraPermissions() { +export async function requestCameraPermissions(): Promise { const hasPerm = await permissions.request('android.permission.CAMERA'); - return hasPerm[1]; + return mapCameraPermissionStatus(hasPerm); } const createDateTimeStamp = function () { diff --git a/packages/camera/index.d.ts b/packages/camera/index.d.ts index 626e6f87..4d87996d 100644 --- a/packages/camera/index.d.ts +++ b/packages/camera/index.d.ts @@ -1,5 +1,5 @@ import { ImageAsset } from '@nativescript/core'; - +import { PermissionsResult, PermissionResult } from './common'; /** * Take a photo using the camera. * @param options - Optional parameter for setting different camera options. @@ -9,9 +9,9 @@ export function takePicture(options?: CameraOptions): Promise; /** * Check required permissions for using device camera. */ -export function requestPermissions(): Promise; -export function requestCameraPermissions(): Promise; -export function requestPhotosPermissions(): Promise; +export function requestPermissions(): Promise; +export function requestCameraPermissions(): Promise; +export function requestPhotosPermissions(): Promise; /** * Is the camera available to use @@ -19,46 +19,45 @@ export function requestPhotosPermissions(): Promise; export function isAvailable(): Boolean; export interface CameraOptions { - /** - * Defines the desired width (in device independent pixels) of the taken image. It should be used with height property. - * If `keepAspectRatio` actual image width could be different in order to keep the aspect ratio of the original camera image. - * The actual image width will be greater than requested if the display density of the device is higher (than 1) (full HD+ resolutions). - */ - width?: number; - - /** - * Defines the desired height (in device independent pixels) of the taken image. It should be used with width property. - * If `keepAspectRatio` actual image width could be different in order to keep the aspect ratio of the original camera image. - * The actual image height will be greater than requested if the display density of the device is higher (than 1) (full HD+ resolutions). - */ - height?: number; - - /** - * Defines if camera picture aspect ratio should be kept during picture resizing. - * This property could affect width or height return values. - */ - keepAspectRatio?: boolean; - - /** - * Defines if camera picture should be copied to photo Gallery (Android) or Photos (iOS) - */ - saveToGallery?: boolean; - - /** - * iOS Only - * Defines if camera "Retake" or "Use Photo" screen forces user to crop camera picture to a square and optionally lets them zoom in. - */ - allowsEditing?: boolean; - - /** - * The initial camera. Default "rear". - * The current implementation doesn't work on all Android devices, in which case it falls back to the default behavior. - */ - cameraFacing?: "front" | "rear"; - - - /** - * (iOS Only) Specify a custom UIModalPresentationStyle (Defaults to UIModalPresentationStyle.FullScreen) - */ - modalPresentationStyle?: number; + /** + * Defines the desired width (in device independent pixels) of the taken image. It should be used with height property. + * If `keepAspectRatio` actual image width could be different in order to keep the aspect ratio of the original camera image. + * The actual image width will be greater than requested if the display density of the device is higher (than 1) (full HD+ resolutions). + */ + width?: number; + + /** + * Defines the desired height (in device independent pixels) of the taken image. It should be used with width property. + * If `keepAspectRatio` actual image width could be different in order to keep the aspect ratio of the original camera image. + * The actual image height will be greater than requested if the display density of the device is higher (than 1) (full HD+ resolutions). + */ + height?: number; + + /** + * Defines if camera picture aspect ratio should be kept during picture resizing. + * This property could affect width or height return values. + */ + keepAspectRatio?: boolean; + + /** + * Defines if camera picture should be copied to photo Gallery (Android) or Photos (iOS) + */ + saveToGallery?: boolean; + + /** + * iOS Only + * Defines if camera "Retake" or "Use Photo" screen forces user to crop camera picture to a square and optionally lets them zoom in. + */ + allowsEditing?: boolean; + + /** + * The initial camera. Default "rear". + * The current implementation doesn't work on all Android devices, in which case it falls back to the default behavior. + */ + cameraFacing?: 'front' | 'rear'; + + /** + * (iOS Only) Specify a custom UIModalPresentationStyle (Defaults to UIModalPresentationStyle.FullScreen) + */ + modalPresentationStyle?: number; } diff --git a/packages/camera/index.ios.ts b/packages/camera/index.ios.ts index 70cb756d..a99ad817 100644 --- a/packages/camera/index.ios.ts +++ b/packages/camera/index.ios.ts @@ -1,6 +1,9 @@ -import { Frame, ImageAsset, ImageSource, Trace, Utils } from '@nativescript/core'; +import { Device, Frame, ImageAsset, ImageSource, Trace, Utils } from '@nativescript/core'; import { CameraOptions } from '.'; +import * as permissions from '@nativescript-community/perms'; +import { combineCamerPhotoPermissions, mapCameraPermissionStatus, mapError, mapPhotoPermissionStatus, PermissionResult, PermissionsResult } from './common'; + @NativeClass() class UIImagePickerControllerDelegateImpl extends NSObject implements UIImagePickerControllerDelegate { public static ObjCProtocols = [UIImagePickerControllerDelegate]; @@ -78,7 +81,7 @@ class UIImagePickerControllerDelegateImpl extends NSObject implements UIImagePic } else { Trace.write('An error ocurred while saving image to gallery: ' + err, Trace.categories.Error, Trace.messageType.error); } - }, + } ); } else { imageAsset = new ImageAsset(imageSourceResult.ios); @@ -141,8 +144,13 @@ export let takePicture = function (options: CameraOptions): Promise { modalPresentationStyle = options.modalPresentationStyle; } } + let authStatus: PHAuthorizationStatus; + if (parseFloat(Device.osVersion) >= 14) { + authStatus = PHPhotoLibrary.authorizationStatusForAccessLevel(PHAccessLevel.ReadWrite); + } else { + authStatus = PHPhotoLibrary.authorizationStatus(); + } - let authStatus = PHPhotoLibrary.authorizationStatus(); if (authStatus !== PHAuthorizationStatus.Authorized && authStatus !== PHAuthorizationStatus.Limited) { saveToGallery = false; } @@ -194,93 +202,20 @@ export let isAvailable = function () { return UIImagePickerController.isSourceTypeAvailable(UIImagePickerControllerSourceType.Camera); }; -export let requestPermissions = function () { - return new Promise(function (resolve, reject) { - // Even if we don't have photo access we may want to get camera access. - const requestCamera = () => requestCameraPermissions().then(resolve, reject); - requestPhotosPermissions().then(requestCamera, requestCamera); - }); +export let requestPermissions = function (): Promise { + return requestCameraPermissions().then((cameraPermissions) => requestPhotosPermissions().then((photoPermissions) => combineCamerPhotoPermissions(cameraPermissions, photoPermissions))); }; -export let requestPhotosPermissions = function () { - return new Promise(function (resolve, reject) { - let authStatus: PHAuthorizationStatus; - if (Utils.SDK_VERSION >= 14) { - authStatus = PHPhotoLibrary.authorizationStatusForAccessLevel(PHAccessLevel.ReadWrite); - } else { - authStatus = PHPhotoLibrary.authorizationStatus(); - } - switch (authStatus) { - case PHAuthorizationStatus.NotDetermined: { - const handler = (auth) => { - if (auth === PHAuthorizationStatus.Authorized) { - if (Trace.isEnabled()) { - Trace.write('Application can access photo library assets.', Trace.categories.Debug); - } - resolve(); - } else { - reject(); - } - }; - if (Utils.SDK_VERSION >= 14) { - PHPhotoLibrary.requestAuthorizationForAccessLevelHandler(PHAccessLevel.ReadWrite, handler); - } else { - PHPhotoLibrary.requestAuthorization(handler); - } - break; - } - case PHAuthorizationStatus.Limited: - case PHAuthorizationStatus.Authorized: { - if (Trace.isEnabled()) { - Trace.write('Application can access photo library assets.', Trace.categories.Debug); - } - resolve(); - break; - } - case PHAuthorizationStatus.Restricted: - case PHAuthorizationStatus.Denied: { - if (Trace.isEnabled()) { - Trace.write('Application can not access photo library assets.', Trace.categories.Debug); - } - reject(); - break; - } - default: - ((_: never) => {})(authStatus); - break; - } - }); +export let requestPhotosPermissions = function (): Promise { + return permissions + .request('photo') + .then((photoPermissions) => mapPhotoPermissionStatus(photoPermissions)) + .catch((e) => mapError(e)); }; -export let requestCameraPermissions = function () { - return new Promise(function (resolve, reject) { - let cameraStatus = AVCaptureDevice.authorizationStatusForMediaType(AVMediaTypeVideo); - switch (cameraStatus) { - case AVAuthorizationStatus.NotDetermined: { - AVCaptureDevice.requestAccessForMediaTypeCompletionHandler(AVMediaTypeVideo, (granted) => { - if (granted) { - resolve(); - } else { - reject(); - } - }); - break; - } - case AVAuthorizationStatus.Authorized: { - resolve(); - break; - } - case AVAuthorizationStatus.Restricted: - case AVAuthorizationStatus.Denied: { - if (Trace.isEnabled()) { - Trace.write('Application can not access Camera assets.', Trace.categories.Debug); - } - reject(); - break; - } - default: - ((_: never) => {})(cameraStatus); - break; - } - }); +export let requestCameraPermissions = function (): Promise { + return permissions + .request('camera') + .then((photoPermissions) => mapCameraPermissionStatus(photoPermissions)) + .catch((e) => mapError(e)); }; diff --git a/tools/assets/App_Resources/Android/src/main/AndroidManifest.xml b/tools/assets/App_Resources/Android/src/main/AndroidManifest.xml index 8eb0c7cb..e19a80aa 100644 --- a/tools/assets/App_Resources/Android/src/main/AndroidManifest.xml +++ b/tools/assets/App_Resources/Android/src/main/AndroidManifest.xml @@ -10,7 +10,7 @@ android:normalScreens="true" android:largeScreens="true" android:xlargeScreens="true"/> - + diff --git a/tools/demo/camera/index.ts b/tools/demo/camera/index.ts index cd092681..60545389 100644 --- a/tools/demo/camera/index.ts +++ b/tools/demo/camera/index.ts @@ -5,15 +5,15 @@ import { DemoSharedBase } from '../utils'; export class DemoSharedCamera extends DemoSharedBase { picturePath: string; cameraImage: any; - saveToGallery: false; - allowsEditing: false; - keepAspectRatio: true; - width: 320; - height: 240; + saveToGallery = false; + allowsEditing = false; + keepAspectRatio = true; + width = 320; + height = 240; onTakePictureTap(args: EventData) { - requestPermissions().then( - () => { + requestPermissions().then((perms) => { + if (perms.Success) { takePicture({ width: this.width, height: this.height, keepAspectRatio: this.keepAspectRatio, saveToGallery: this.saveToGallery, allowsEditing: this.allowsEditing }).then( (imageAsset: ImageAsset) => { this.set('cameraImage', imageAsset); @@ -41,8 +41,9 @@ export class DemoSharedCamera extends DemoSharedBase { console.log('Error -> ' + err.message); } ); - }, - () => Dialogs.alert('permissions rejected') - ); + } else { + Dialogs.alert(`permissions rejected: camera ${perms.Details.Camera?.Success}, photo ${perms.Details.Photo?.Success}`); + } + }); } }