diff --git a/cordova-plugin-moodleapp/plugin.xml b/cordova-plugin-moodleapp/plugin.xml index a1a93f296..bbce2501c 100644 --- a/cordova-plugin-moodleapp/plugin.xml +++ b/cordova-plugin-moodleapp/plugin.xml @@ -21,10 +21,6 @@ - - - - diff --git a/cordova-plugin-moodleapp/src/ts/plugins/Diagnostic.ts b/cordova-plugin-moodleapp/src/ts/plugins/Diagnostic.ts index df446b68f..9ac2f3aeb 100644 --- a/cordova-plugin-moodleapp/src/ts/plugins/Diagnostic.ts +++ b/cordova-plugin-moodleapp/src/ts/plugins/Diagnostic.ts @@ -39,7 +39,8 @@ // limitations under the License. /** - * Checks whether device hardware features are enabled or available to the app, e.g. camera, GPS, wifi + * Checks whether device hardware features are enabled or available to the app, e.g. camera, GPS, wifi. + * Most of this code was copied from https://github.com/dpa99c/cordova-diagnostic-plugin */ export class Diagnostic { @@ -56,37 +57,249 @@ export class Diagnostic { */ declare permission: typeof permission; + declare protected requestInProgress: boolean; + constructor() { this.permissionStatus = permissionStatus; this.permission = permission; + + this.requestInProgress = false; } + /** + * Checks if the device location setting is enabled. + * On iOS, returns true if Location Services is enabled. + * On Android, returns true if Location Mode is enabled and any mode is selected (e.g. Battery saving, Device only, ...). + * + * @returns True if location setting is enabled. + */ isLocationEnabled(): Promise { return new Promise((resolve, reject) => cordova.exec(resolve, reject, 'Diagnostic_Location', 'isLocationEnabled')); } + /** + * Android only. Switches to the Location page in the Settings app. + * + * @returns Promise resolved when done. + */ switchToLocationSettings(): Promise { + if (cordova.platformId !== 'android') { + return Promise.resolve(); + } + return new Promise((resolve, reject) => cordova.exec(resolve, reject, 'Diagnostic_Location', 'switchToLocationSettings')); } + /** + * Opens settings page for this app. + */ switchToSettings(): Promise { return new Promise((resolve, reject) => cordova.exec(resolve, reject, 'Diagnostic', 'switchToSettings')); } - getLocationAuthorizationStatus(): Promise { - return new Promise((resolve, reject) => - cordova.exec(resolve, reject, 'Diagnostic_Location', 'getLocationAuthorizationStatus')); + /** + * Returns the location authorization status for the application. + * + * @returns Authorization status. + */ + getLocationAuthorizationStatus(): Promise { + return new Promise((resolve, reject) => { + if (cordova.platformId === 'ios') { + cordova.exec(resolve, reject, 'Diagnostic_Location', 'getLocationAuthorizationStatus'); + + return; + } + + this.getPermissionsAuthorizationStatus([ + permission.accessCoarseLocation, + permission.accessFineLocation, + permission.accessBackgroundLocation, + ]).then(statuses => { + const backgroundStatus = typeof statuses[permission.accessBackgroundLocation] !== 'undefined' ? + statuses[permission.accessBackgroundLocation] : true; + + let status = this.combinePermissionStatuses({ + [permission.accessCoarseLocation]: statuses[permission.accessCoarseLocation], + [permission.accessFineLocation]: statuses[permission.accessFineLocation], + }); + if (status === permissionStatus.granted && backgroundStatus !== permissionStatus.granted) { + status = permissionStatus.grantedWhenInUse; + } + + resolve(status); + + return; + }).catch(reject); + }); } - requestLocationAuthorization(): Promise { - return new Promise((resolve, reject) => - cordova.exec(resolve, reject, 'Diagnostic_Location', 'requestLocationAuthorization')); - } - - requestMicrophoneAuthorization(): Promise { + /** + * Requests location authorization for the application. + * Authorization can be requested to use location either "when in use" (only foreground) or "always" (foreground & background). + * Should only be called if authorization status is NOT_REQUESTED. Calling it when in any other state will have no effect. + * + * @returns Permission status. + */ + requestLocationAuthorization(): Promise { return new Promise((resolve, reject) => - cordova.exec(resolve, reject, 'Diagnostic_Microphone', 'requestMicrophoneAuthorization')); + cordova.exec( + status => resolve(this.convertPermissionStatus(status)), + reject, + 'Diagnostic_Location', + 'requestLocationAuthorization', + [false, true], + )); + } + + /** + * Requests access to microphone if authorization was never granted nor denied, will only return access status otherwise. + * + * @returns Permission status. + */ + requestMicrophoneAuthorization(): Promise { + return new Promise((resolve, reject) => { + if (cordova.platformId === 'ios') { + cordova.exec( + (isGranted) => resolve(isGranted ? permissionStatus.granted : permissionStatus.deniedAlways), + reject, + 'Diagnostic_Microphone', + 'requestMicrophoneAuthorization', + ); + + return; + } + + this.requestRuntimePermission(permission.recordAudio).then(resolve).catch(reject); + }); + } + + /** + * Android only. Given a list of permissions, returns the status for each permission. + * + * @param permissions Permissions to check. + * @returns Status for each permission. + */ + protected getPermissionsAuthorizationStatus(permissions: string[]): Promise> { + return new Promise>((resolve, reject) => { + if (cordova.platformId !== 'android') { + resolve({}); + + return; + } + + cordova.exec( + (statuses) => { + for (const permission in statuses) { + statuses[permission] = this.convertPermissionStatus(statuses[permission]); + } + + resolve(statuses); + }, + reject, + 'Diagnostic', + 'getPermissionsAuthorizationStatus', + [permissions], + ); + }); + } + + /** + * Given a list of permissions and their statuses, returns the combined status. + * + * @param statuses Permissions with statuses. + * @returns Combined status. + */ + protected combinePermissionStatuses(statuses: Record): string { + if (this.anyStatusIs(statuses, permissionStatus.deniedAlways)) { + return permissionStatus.deniedAlways; + } + + if (this.anyStatusIs(statuses, permissionStatus.deniedOnce)) { + return permissionStatus.deniedOnce; + } + + if (this.anyStatusIs(statuses, permissionStatus.granted)) { + return permissionStatus.granted; + } + + return permissionStatus.notRequested; + } + + /** + * Check if any of the permissions in the statuses object has the specified status. + * + * @param statuses Permissions with status for each permission. + * @param status Status to check. + * @returns True if any permission has the specified status. + */ + protected anyStatusIs(statuses: Record, status: string): boolean { + for (const permission in statuses) { + if (statuses[permission] === status) { + return true; + } + } + + return false; + } + + /** + * Android only. Requests app to be granted authorisation for a runtime permission. + * + * @param permission Permissions to request. + * @returns Status for each permission. + */ + protected requestRuntimePermission(permission: string): Promise { + return new Promise((resolve, reject) => { + if (cordova.platformId !== 'android') { + resolve(permissionStatus.granted); + + return; + } + + if (this.requestInProgress) { + reject('A runtime permissions request is already in progress'); + } + + this.requestInProgress = true; + + cordova.exec( + (statuses) => { + this.requestInProgress = false; + resolve(this.convertPermissionStatus(statuses[permission])); + }, + (error) => { + this.requestInProgress = false; + reject(error); + }, + 'Diagnostic', + 'requestRuntimePermission', + [permission], + ); + }); + } + + /** + * Convert a permission status so it has the same value in all platforms. + * Each platform can return a different value for a status, e.g. a granted permission returns 'authorized' in iOS and + * 'GRANTED' in Android. This function will convert the status so it uses the iOS value in all platforms, unless it's an + * Android specific value. + * + * @param status Original status. + * @returns Converted status. + */ + protected convertPermissionStatus(status: string): string { + for (const name in androidPermissionStatus) { + const androidStatus = androidPermissionStatus[name as keyof typeof androidPermissionStatus]; + const iosStatus = iosPermissionStatus[name as keyof typeof iosPermissionStatus]; + + if (status === androidStatus && iosStatus !== undefined) { + // Always use the iOS status if the status exists both in Android and iOS. + return iosStatus; + } + } + + return status; } } @@ -116,18 +329,35 @@ const permission = { writeExternalStorage: 'WRITE_EXTERNAL_STORAGE', } as const; -const permissionStatus = { - // Android only - deniedOnce: 'DENIED_ONCE', - - // iOS only - restricted: 'restricted', - ephimeral: 'ephemeral', - provisional: 'provisional', - - // Both iOS and Android - granted: 'authorized' || 'GRANTED', +const androidPermissionStatus = { + // Location permission requested and + // app build SDK/user device is Android >10 and user granted background location ("all the time") permission, + // or app build SDK/user device is Android 6-9 and user granted location permission, + // or non-location permission requested + // and app build SDK/user device is Android >=6 and user granted permission + // or app build SDK/user device is Android <6 + granted: 'GRANTED', + // Location permission requested + // and app build SDK/user device is Android >10 + // and user granted background foreground location ("while-in-use") permission grantedWhenInUse: 'authorized_when_in_use', - notRequested: 'not_determined' || 'NOT_REQUESTED', - deniedAlways: 'denied_always' || 'DENIED_ALWAYS', + deniedOnce: 'DENIED_ONCE', // User denied access to this permission. + deniedAlways: 'DENIED_ALWAYS', // User denied access to this permission and checked "Never Ask Again" box. + notRequested: 'NOT_REQUESTED', // App has not yet requested access to this permission. +} as const; + +const iosPermissionStatus = { + notRequested: 'not_determined', // App has not yet requested this permission + deniedAlways: 'denied_always', // User denied access to this permission + restricted: 'restricted', // Permission is unavailable and user cannot enable it. For example, when parental controls are on. + granted: 'authorized', // User granted access to this permission. + grantedWhenInUse: 'authorized_when_in_use', // User granted access use location permission only when app is in use + ephimeral: 'ephemeral', // The app is authorized to schedule or receive notifications for a limited amount of time. + provisional: 'provisional', // The application is provisionally authorized to post non-interruptive user notifications. + limited: 'limited', // The app has limited access to the Photo Library. +} as const; + +const permissionStatus = { + ...androidPermissionStatus, + ...iosPermissionStatus, } as const; diff --git a/src/core/services/geolocation.ts b/src/core/services/geolocation.ts index 2f3df421b..5974f5de5 100644 --- a/src/core/services/geolocation.ts +++ b/src/core/services/geolocation.ts @@ -122,16 +122,16 @@ export class CoreGeolocationProvider { this.logger.log(`Authorize location: status ${authorizationStatus}`); switch (authorizationStatus) { - case diagnostic.permissionStatus.deniedOnce: - if (failOnDeniedOnce) { - throw new CoreGeolocationError(CoreGeolocationErrorReason.PERMISSION_DENIED); - } - case diagnostic.permissionStatus.granted: case diagnostic.permissionStatus.grantedWhenInUse: // Location is authorized. return; + case diagnostic.permissionStatus.deniedOnce: + if (failOnDeniedOnce) { + throw new CoreGeolocationError(CoreGeolocationErrorReason.PERMISSION_DENIED); + } + // Fall through. case diagnostic.permissionStatus.notRequested: this.logger.log('Request location authorization.');