react-native-permissions icon indicating copy to clipboard operation
react-native-permissions copied to clipboard

Handle upgrading from location when in use to location always on iOS

Open tallpants opened this issue 3 years ago • 17 comments

Summary

  • Closes https://github.com/zoontek/react-native-permissions/issues/490
  • Fixes an issue on iOS where you could not ask for LOCATION_ALWAYS permission if you already had LOCATION_WHEN_IN_USE permission.
  • This only impacts the LocationAlways pod.

Implementation Changes

  • For check:
    • If the current authorization status is AuthorizedWhenInUse, and we have not requested LOCATION_ALWAYS in the past, we return NotDetermined.
    • If the current authorization status is AuthorizedWhenInUse and we have requested LOCATION_ALWAYS in the past, we return Denied, since we're not allowed to prompt for this permission twice.
  • For request:
    • If the current authorization status is AuthorizedWhenInUse and we have not requested LOCATION_ALWAYS in the past, we call requestAlwaysAuthorization and flag the permission as requested.
    • If the current authorization status is AuthorizedWhenInUse and we have requested LOCATION_ALWAYS in the past, then we return the current authorization result, since we're not allowed to prompt for this permission twice.

Test Plan

  • Request and grant LOCATION_WHEN_IN_USE permission.
  • Then request LOCATION_ALWAYS permission.
Before After
Before After

Compatibility

OS Implemented
iOS
Android N/A

Checklist

  • [X] I have tested this on a device and a simulator
  • [X] I added the documentation in README.md
  • [X] I added a sample use of the API in the example project (example/App.tsx)

tallpants avatar Sep 16 '22 05:09 tallpants

It looks like the promise resolve to early: Also, it doesn't seems that applicationDidBecameActive is called (at least, on iOS 16).

https://user-images.githubusercontent.com/1902323/191220250-7a79fc18-9071-47a2-9e79-e4595931c421.MP4

https://user-images.githubusercontent.com/1902323/191220759-a91890ec-4fd6-4a89-bc0d-9339e45d418e.MP4

zoontek avatar Sep 20 '22 09:09 zoontek

@zoontek that led me down a rabbit hole -- and the solution was a bit unorthodox but from my testing this new commit seems to cover every situation!

tallpants avatar Sep 20 '22 17:09 tallpants

Hey @zoontek just following up if you had any more thoughts on this!

tallpants avatar Sep 23 '22 18:09 tallpants

Unfortunately, it still fails in some cases. Allowing once, then requesting always doesn't resolve the first time:

https://user-images.githubusercontent.com/1902323/194712865-be084088-866a-47ef-b209-14c206a1c383.MP4

zoontek avatar Oct 08 '22 14:10 zoontek

@zoontek if you're talking about requesting LOCATION_ALWAYS, selecting "Allow Once", and then requesting again -- that appears to be the intended behaviour.

Here's what I see:

  1. On requesting LOCATION_ALWAYS, and selecting "Allow Once" -- LOCATION_WHEN_IN_USE is granted until the app is closed and opened again, at which point any location permission is removed, but is requestable again:

https://user-images.githubusercontent.com/15325890/194762856-3dc50e03-3ec8-44b1-b489-a2514c8b3b06.mp4

  1. On requesting LOCATION_ALWAYS and selecting "Allow When In Use" -- the app receives provisional always authorization (described here: https://developer.apple.com/videos/play/wwdc2019/705/?time=195):

https://user-images.githubusercontent.com/15325890/194763064-d666e72c-02c6-43e4-a93a-bff0413e8b3e.mp4

  1. On requesting LOCATION_WHEN_IN_USE, granting it, and then requesting an LOCATION_ALWAYS -- the app correctly prompts the user to upgrade the when-in-use permission to always:

https://user-images.githubusercontent.com/15325890/194763170-350bd4d7-82c1-40f1-a685-ecf90e832df8.mp4

In all cases, LOCATION_ALWAYS can only be requested once as described here: https://developer.apple.com/documentation/bundleresources/information_property_list/protected_resources/choosing_the_location_services_authorization_to_request?language=objc

CleanShot 2022-10-09 at 10 46 16@2x

I'm also not seeing the case where allowing once, then requesting always does not resolve. Here it is on the simulator:

https://user-images.githubusercontent.com/15325890/194763450-85806259-7b1d-495e-a9dd-48ce56332a81.mp4

And on a real device:

https://user-images.githubusercontent.com/15325890/194763731-9d7506db-5a3f-4f2b-9be8-dabf880e8381.MP4

tallpants avatar Oct 09 '22 14:10 tallpants

I am new to using the library, but if it helps at all, I was able to test this successfully on an Iphone SE 2020 running iOS 15.6 and on simulator running iOS 16. So far it has resolved on the first time as expected, I'll be testing more today and tomorrow though. React native version 0.67.2

jimfoambox avatar Oct 20 '22 14:10 jimfoambox

I was able to get this to work on iOS 15 (simulator) and 16 (real device). However, for iOS 14.5 and 12.4 on the simulator, the always prompt briefly appears and then disappears without user action.

https://user-images.githubusercontent.com/1326873/199366773-bcc6e40b-315a-40a0-9fb2-dff2f1df699b.mp4

ddarren avatar Nov 02 '22 00:11 ddarren

Just a message to say that we are experiencing the same issue.

I also join @tallpants opinion that iOS allows us asking for LOCATION_ALWAYS only once, so asking twice for the permission will not work, and that's the expected behaviour. (from user experience point of view, this can be enhanced by checking the status and explaining why it will not prompt for the permission.)

ag-drivequant avatar Nov 10 '22 13:11 ag-drivequant

@ag-drivequant @ddarren @tallpants @zoontek to fix this issue with iOS < 15 you just need to modify/add this code (Many Thanks to @rformato for the contribution):

CLLocationManager *locationManager;

...

locationManager = [CLLocationManager new];
[locationManager requestAlwaysAuthorization];

The fix is explained here https://stackoverflow.com/a/9474095

Any chance to merge this PR?

alessioemireni avatar Mar 10 '23 10:03 alessioemireni

any updates about merging this PR?

sayurimizuguchi avatar Aug 29 '23 18:08 sayurimizuguchi

can we get this one merged? I tried a patch but build is failing :/

  34 |         return resolve(RNPermissionStatusRestricted);
  35 |       case kCLAuthorizationStatusAuthorizedWhenInUse: {
> 36 |         BOOL requestedBefore = [RNPermissions isFlaggedAsRequested:[[self class] handlerUniqueId]];
     |                                 ^ use of undeclared identifier 'RNPermissions'
  37 |         if (requestedBefore) {
  38 |           return resolve(RNPermissionStatusDenied);
  39 |         }

woodybury avatar Oct 04 '23 19:10 woodybury

@woodybury The current state of this feature doesn't work correctly, so I can't merge it (never resolving Promise)

But good news, I can work on it (see https://github.com/zoontek/react-native-permissions/pull/808)! If your company really needs it, contact me 🙂

zoontek avatar Oct 04 '23 20:10 zoontek

thanks @zoontek got it to compile and "work" (meaning I can request user to change to always) with this simple patch:

diff --git a/node_modules/react-native-permissions/ios/LocationAlways/RNPermissionHandlerLocationAlways.m b/node_modules/react-native-permissions/ios/LocationAlways/RNPermissionHandlerLocationAlways.m
index 719e6be..241134d 100644
--- a/node_modules/react-native-permissions/ios/LocationAlways/RNPermissionHandlerLocationAlways.m
+++ b/node_modules/react-native-permissions/ios/LocationAlways/RNPermissionHandlerLocationAlways.m
@@ -48,9 +48,10 @@ - (void)requestWithResolver:(void (^ _Nonnull)(RNPermissionStatus))resolve
     if (![CLLocationManager locationServicesEnabled]) {
       return resolve(RNPermissionStatusNotAvailable);
     }
-    if ([CLLocationManager authorizationStatus] != kCLAuthorizationStatusNotDetermined) {
-      return [self checkWithResolver:resolve rejecter:reject];
-    }
+// Removing the authorizationStatus check here
+//     if ([CLLocationManager authorizationStatus] != kCLAuthorizationStatusNotDetermined) {
+//       return [self checkWithResolver:resolve rejecter:reject];
+//     }
 
     self->_resolve = resolve;
     self->_reject = reject;

but obviously I need the checks to work too :/

definitely +1 for this feature request. If I have the capacity to fix the checks I'll open a PR. Keep me posted - thanks!

woodybury avatar Oct 04 '23 23:10 woodybury

@zoontek @woodybury I have created a patch to handle this case:

index e20d4fe..56a986e 100644
--- a/node_modules/react-native-permissions/ios/LocationAlways/RNPermissionHandlerLocationAlways.m
+++ b/node_modules/react-native-permissions/ios/LocationAlways/RNPermissionHandlerLocationAlways.m
@@ -3,9 +3,8 @@
 @import CoreLocation;
 @import UIKit;
 
-@interface RNPermissionHandlerLocationAlways() <CLLocationManagerDelegate>
+@interface RNPermissionHandlerLocationAlways()
 
-@property (nonatomic, strong) CLLocationManager *locationManager;
 @property (nonatomic, strong) void (^resolve)(RNPermissionStatus status);
 @property (nonatomic, strong) void (^reject)(NSError *error);
 
@@ -13,6 +12,9 @@ @interface RNPermissionHandlerLocationAlways() <CLLocationManagerDelegate>
 
 @implementation RNPermissionHandlerLocationAlways
 
+static NSString* SETTING_KEY = @"@RNPermissions:Requested";
+CLLocationManager *locationManager;
+
 + (NSArray<NSString *> * _Nonnull)usageDescriptionKeys {
   return @[@"NSLocationAlwaysAndWhenInUseUsageDescription"];
 }
@@ -28,7 +30,13 @@ - (void)checkWithResolver:(void (^ _Nonnull)(RNPermissionStatus))resolve
       return resolve(RNPermissionStatusNotDetermined);
     case kCLAuthorizationStatusRestricted:
       return resolve(RNPermissionStatusRestricted);
-    case kCLAuthorizationStatusAuthorizedWhenInUse:
+      case kCLAuthorizationStatusAuthorizedWhenInUse: {
+          BOOL requestedBefore = [self isFlaggedAsRequested:[[self class] handlerUniqueId]];
+          if (requestedBefore) {
+              return resolve(RNPermissionStatusDenied);
+          }
+          return resolve(RNPermissionStatusNotDetermined);
+      }
     case kCLAuthorizationStatusDenied:
       return resolve(RNPermissionStatusDenied);
     case kCLAuthorizationStatusAuthorizedAlways:
@@ -38,22 +46,67 @@ - (void)checkWithResolver:(void (^ _Nonnull)(RNPermissionStatus))resolve
 
 - (void)requestWithResolver:(void (^ _Nonnull)(RNPermissionStatus))resolve
                    rejecter:(void (^ _Nonnull)(NSError * _Nonnull))reject {
-  if ([CLLocationManager authorizationStatus] != kCLAuthorizationStatusNotDetermined) {
-    return [self checkWithResolver:resolve rejecter:reject];
-  }
+    CLAuthorizationStatus authorizationStatus = [CLLocationManager authorizationStatus];
+    BOOL requestedBefore = [self isFlaggedAsRequested:[[self class] handlerUniqueId]];
+    if (authorizationStatus != kCLAuthorizationStatusNotDetermined && !(authorizationStatus == kCLAuthorizationStatusAuthorizedWhenInUse && !requestedBefore)) {
+        return [self checkWithResolver:resolve rejecter:reject];
+    }
+    
+    _resolve = resolve;
+    _reject = reject;
     
-  _resolve = resolve;
-  _reject = reject;
+    // When we request location always permission, if the user selects "Keep Only While Using", iOS
+    // won't trigger the locationManager:didChangeAuthorizationStatus: delegate method. This means we
+    // can't know when the user has responded to the permission prompt directly.
+    //
+    // We can get around this by listening for the UIApplicationDidBecomeActiveNotification event which posts
+    // when the application regains focus from the permission prompt. When this happens we'll
+    // trigger the applicationDidBecomeActive method on this class, and we'll check the authorization status and
+    // resolve the promise there -- letting us stay consistent with our promise-based API.
+    //
+    // References:
+    // ===========
+    // CLLocationManager requestAlwaysAuthorization:
+    // https://developer.apple.com/documentation/corelocation/cllocationmanager/1620551-requestalwaysauthorization?language=objc
+    //
+    // NSNotificationCenter addObserver:
+    // https://developer.apple.com/documentation/foundation/nsnotificationcenter/1415360-addobserver
+    //
+    // UIApplicationDidBecomeActiveNotification:
+    // https://developer.apple.com/documentation/uikit/uiapplicationdidbecomeactivenotification
+    [[NSNotificationCenter defaultCenter] addObserver:self
+                                             selector:@selector(applicationDidBecomeActive)
+                                                 name:UIApplicationDidBecomeActiveNotification
+                                               object:nil];
     
-  _locationManager = [CLLocationManager new];
-  [_locationManager setDelegate:self];
-  [_locationManager requestAlwaysAuthorization];
+    locationManager = [CLLocationManager new];
+    [locationManager requestAlwaysAuthorization];
+    [self flagAsRequested:[[self class] handlerUniqueId]];
 }
 
-- (void)locationManager:(CLLocationManager *)manager didChangeAuthorizationStatus:(CLAuthorizationStatus)status {
-  if (status != kCLAuthorizationStatusNotDetermined) {
-    [_locationManager setDelegate:nil];
-    [self checkWithResolver:_resolve rejecter:_reject];
+- (void)applicationDidBecomeActive {
+  [self checkWithResolver:_resolve rejecter:_reject];
+  [[NSNotificationCenter defaultCenter] removeObserver:self
+                                                  name:UIApplicationDidBecomeActiveNotification
+                                                object:nil];}
+
+- (bool)isFlaggedAsRequested:(NSString * _Nonnull)handlerId {
+  NSArray<NSString *> *requested = [[NSUserDefaults standardUserDefaults] arrayForKey:SETTING_KEY];
+  return requested == nil ? false : [requested containsObject:handlerId];
+}
+
+- (void)flagAsRequested:(NSString * _Nonnull)handlerId {
+  NSUserDefaults *userDefaults = [NSUserDefaults standardUserDefaults];
+  NSMutableArray *requested = [[userDefaults arrayForKey:SETTING_KEY] mutableCopy];
+
+  if (requested == nil) {
+    requested = [NSMutableArray new];
+  }
+
+  if (![requested containsObject:handlerId]) {
+    [requested addObject:handlerId];
+    [userDefaults setObject:requested forKey:SETTING_KEY];
+    [userDefaults synchronize];
   }
 }
 

alessioemireni avatar Feb 05 '24 11:02 alessioemireni

@zoontek, First off thank you for your hard work on this project. After reading through the discussion, it seems like this PR would not be an acceptable approach according to your criteria because of the observer's possible never ending promise. I have done surface level testing with @tallpants / @alessioemireni's patch and it works!

Seems like you are expecting a different approach than to listen to the notification change. If so, should we close this PR? Or are you willing to consider this approach. From my bit of testing, the observer seems work well because the native popup can't be dismissed unless the phone is locked which seems to set it "Keep While Using app", so I don't see a case where the promise would be never ending (I could be completely wrong, but just trying to learn more native code). @tallpants @alessioemireni please chime in. I would love your feedback and thoughts. Thank you for your contributions.

alexkev avatar Feb 26 '24 15:02 alexkev

So after my testing of @alessioemireni's code. I had some great results! However, I did find one condition where if the user had selected "Allow once" then "Allow always" was requested the notification would not be posted and the promise would hang. No worries, I have a solution. I created a listener to track if the notification is posted (via UIApplicationWillResignActiveNotification). If after 0.25 seconds the notification has not been posted, then checkWithResolver is called and the appropriate response is resolved.

Would love to get some feedback. @zoontek @tallpants @alessioemireni

diff --git a/node_modules/react-native-permissions/ios/.DS_Store b/node_modules/react-native-permissions/ios/.DS_Store
new file mode 100644
index 0000000..85e3a0c
Binary files /dev/null and b/node_modules/react-native-permissions/ios/.DS_Store differ
diff --git a/node_modules/react-native-permissions/ios/LocationAlways/RNPermissionHandlerLocationAlways.m b/node_modules/react-native-permissions/ios/LocationAlways/RNPermissionHandlerLocationAlways.m
index e20d4fe..f706695 100644
--- a/node_modules/react-native-permissions/ios/LocationAlways/RNPermissionHandlerLocationAlways.m
+++ b/node_modules/react-native-permissions/ios/LocationAlways/RNPermissionHandlerLocationAlways.m
@@ -3,9 +3,11 @@
 @import CoreLocation;
 @import UIKit;
 
-@interface RNPermissionHandlerLocationAlways() <CLLocationManagerDelegate>
+@interface RNPermissionHandlerLocationAlways()
+{
+  BOOL notified;
+}
 
-@property (nonatomic, strong) CLLocationManager *locationManager;
 @property (nonatomic, strong) void (^resolve)(RNPermissionStatus status);
 @property (nonatomic, strong) void (^reject)(NSError *error);
 
@@ -13,6 +15,9 @@ @interface RNPermissionHandlerLocationAlways() <CLLocationManagerDelegate>
 
 @implementation RNPermissionHandlerLocationAlways
 
+static NSString* SETTING_KEY = @"@RNPermissions:Requested";
+CLLocationManager *locationManager;
+
 + (NSArray<NSString *> * _Nonnull)usageDescriptionKeys {
   return @[@"NSLocationAlwaysAndWhenInUseUsageDescription"];
 }
@@ -28,7 +33,13 @@ - (void)checkWithResolver:(void (^ _Nonnull)(RNPermissionStatus))resolve
       return resolve(RNPermissionStatusNotDetermined);
     case kCLAuthorizationStatusRestricted:
       return resolve(RNPermissionStatusRestricted);
-    case kCLAuthorizationStatusAuthorizedWhenInUse:
+      case kCLAuthorizationStatusAuthorizedWhenInUse: {
+          BOOL requestedBefore = [self isFlaggedAsRequested:[[self class] handlerUniqueId]];
+          if (requestedBefore) {
+              return resolve(RNPermissionStatusDenied);
+          }
+          return resolve(RNPermissionStatusNotDetermined);
+      }
     case kCLAuthorizationStatusDenied:
       return resolve(RNPermissionStatusDenied);
     case kCLAuthorizationStatusAuthorizedAlways:
@@ -38,21 +49,92 @@ - (void)checkWithResolver:(void (^ _Nonnull)(RNPermissionStatus))resolve
 
 - (void)requestWithResolver:(void (^ _Nonnull)(RNPermissionStatus))resolve
                    rejecter:(void (^ _Nonnull)(NSError * _Nonnull))reject {
-  if ([CLLocationManager authorizationStatus] != kCLAuthorizationStatusNotDetermined) {
-    return [self checkWithResolver:resolve rejecter:reject];
-  }
+    CLAuthorizationStatus authorizationStatus = [CLLocationManager authorizationStatus];
+    BOOL requestedBefore = [self isFlaggedAsRequested:[[self class] handlerUniqueId]];
+    if (authorizationStatus != kCLAuthorizationStatusNotDetermined && !(authorizationStatus == kCLAuthorizationStatusAuthorizedWhenInUse && !requestedBefore)) {
+        return [self checkWithResolver:resolve rejecter:reject];
+    }
+    
+    _resolve = resolve;
+    _reject = reject;
 
-  _resolve = resolve;
-  _reject = reject;
+    // When we request location always permission, if the user selects "Keep Only While Using", iOS
+    // won't trigger the locationManager:didChangeAuthorizationStatus: delegate method. This means we
+    // can't know when the user has responded to the permission prompt directly.
+    //
+    // We can get around this by listening for the UIApplicationDidBecomeActiveNotification event which posts
+    // when the application regains focus from the permission prompt. When this happens we'll
+    // trigger the applicationDidBecomeActive method on this class, and we'll check the authorization status and
+    // resolve the promise there -- letting us stay consistent with our promise-based API.
+    //
+    // In addition, we'll also set a timeout of 0.25 seconds to resolve the promise if the notification fails to occur. 
+    // We check by listening to UIApplicationWillResignActiveNotification and setting a flag if the notification occurs. 
+    // This is to handle the case where the user has selected "Allow once" and cannot be prompted to "Allow Always"
+    // which results in no notification being posted.
+    //
+    // References:
+    // ===========
+    // CLLocationManager requestAlwaysAuthorization:
+    // https://developer.apple.com/documentation/corelocation/cllocationmanager/1620551-requestalwaysauthorization?language=objc
+    //
+    // NSNotificationCenter addObserver:
+    // https://developer.apple.com/documentation/foundation/nsnotificationcenter/1415360-addobserver
+    //
+    // UIApplicationDidBecomeActiveNotification:
+    // https://developer.apple.com/documentation/uikit/uiapplicationdidbecomeactivenotification
+    //
+    // UIApplicationWillResignActiveNotification:
+    // https://developer.apple.com/documentation/uikit/uiapplicationwillresignactivenotification
+    [[NSNotificationCenter defaultCenter] addObserver:self
+                                             selector:@selector(notificationOccurred:)
+                                                 name:UIApplicationWillResignActiveNotification
+                                               object:nil];
+    [[NSNotificationCenter defaultCenter] addObserver:self
+                                             selector:@selector(applicationDidBecomeActive)
+                                                 name:UIApplicationDidBecomeActiveNotification
+                                               object:nil];
+    [self performSelector:@selector(onRequestTimeout) withObject:nil afterDelay:0.25];
+
+    locationManager = [CLLocationManager new];
+    [locationManager requestAlwaysAuthorization];
+    [self flagAsRequested:[[self class] handlerUniqueId]];
+}
+
+- (void)notificationOccurred:(NSNotification *)notification {
+  notified = YES;
+}
 
-  _locationManager = [CLLocationManager new];
-  [_locationManager setDelegate:self];
-  [_locationManager requestAlwaysAuthorization];
+- (void)applicationDidBecomeActive {
+  [self checkWithResolver:_resolve rejecter:_reject];
+  [[NSNotificationCenter defaultCenter] removeObserver:self
+                                                  name:UIApplicationDidBecomeActiveNotification
+                                                object:nil];}
+
+- (bool)isFlaggedAsRequested:(NSString * _Nonnull)handlerId {
+  NSArray<NSString *> *requested = [[NSUserDefaults standardUserDefaults] arrayForKey:SETTING_KEY];
+  return requested == nil ? false : [requested containsObject:handlerId];
+}
+
+- (void)flagAsRequested:(NSString * _Nonnull)handlerId {
+  NSUserDefaults *userDefaults = [NSUserDefaults standardUserDefaults];
+  NSMutableArray *requested = [[userDefaults arrayForKey:SETTING_KEY] mutableCopy];
+
+  if (requested == nil) {
+    requested = [NSMutableArray new];
+  }
+
+  if (![requested containsObject:handlerId]) {
+    [requested addObject:handlerId];
+    [userDefaults setObject:requested forKey:SETTING_KEY];
+    [userDefaults synchronize];
+  }
 }
 
-- (void)locationManager:(CLLocationManager *)manager didChangeAuthorizationStatus:(CLAuthorizationStatus)status {
-  if (status != kCLAuthorizationStatusNotDetermined) {
-    [_locationManager setDelegate:nil];
+- (void)onRequestTimeout {
+  if (!notified) {
+    [[NSNotificationCenter defaultCenter] removeObserver:self 
+                                                    name:UIApplicationDidBecomeActiveNotification 
+                                                  object:nil];
     [self checkWithResolver:_resolve rejecter:_reject];
   }
 }

alexkev avatar Jun 01 '24 02:06 alexkev

@alexkev This might be a good solution 👍

I'm currently working on next major version (as iOS 18 and Android 15 are around the corner, I doubt they will not add breaking changes to permissions again 😅), if it works correctly I will include it in the beta.

zoontek avatar Jun 01 '24 09:06 zoontek