Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

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

Closed
wants to merge 3 commits into from

Conversation

tallpants
Copy link

Summary

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

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

@zoontek
Copy link
Owner

zoontek commented Sep 20, 2022

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

RPReplay_Final1663665401.MP4
RPReplay_Final1663665784.MP4

@tallpants
Copy link
Author

tallpants commented Sep 20, 2022

@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
Copy link
Author

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

@zoontek
Copy link
Owner

zoontek commented Oct 8, 2022

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

RPReplay_Final1665238739.MP4

@tallpants
Copy link
Author

@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:
allow_once.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):
allow_when_in_use.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:
when_in_use_then_always.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:

allow_once_and_request_always_resolves.mp4

And on a real device:

RPReplay_Final1665327190.MP4

@jimfoambox
Copy link

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

@ddarren
Copy link

ddarren commented Nov 2, 2022

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.

location_permission_disappears.mp4

@ag-drivequant
Copy link

ag-drivequant commented Nov 10, 2022

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.)

@alessioemireni
Copy link

alessioemireni commented Mar 10, 2023

@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?

@sayurimizuguchi
Copy link

any updates about merging this PR?

@woodybury
Copy link

woodybury commented Oct 4, 2023

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 |         }

@zoontek
Copy link
Owner

zoontek commented Oct 4, 2023

@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 #808)! If your company really needs it, contact me 🙂

@woodybury
Copy link

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!

@alessioemireni
Copy link

alessioemireni commented Feb 5, 2024

@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];
   }
 }
 

@alexkev
Copy link

alexkev commented Feb 26, 2024

@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
Copy link

alexkev commented Jun 1, 2024

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];
   }
 }

@zoontek
Copy link
Owner

zoontek commented Jun 1, 2024

@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 zoontek mentioned this pull request Sep 3, 2024
@zoontek
Copy link
Owner

zoontek commented Sep 27, 2024

The feature landed in 5.0.0-beta.3, please try it (I personally checked all flows on my both iPhones - iOS 18 and iOS 15).

The idea is kinda similar to solutions proposed here, but without relying on storage (flagging permission as requested in [NSUserDefaults standardUserDefaults]). Check the code

It also comes with an improved documentation about the different flows

Screenshot 2024-09-27 at 11 00 54

@zoontek zoontek closed this in #890 Oct 12, 2024
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

iOS: Cannot request location always if we already have location when in use permission
9 participants