Page MenuHomePhabricator

Native Java code to request notifications permissions from JS on Android 13
ClosedPublic

Authored by marcin on Mar 20 2023, 12:12 PM.
Tags
None
Referenced Files
Unknown Object (File)
Sat, Apr 20, 7:05 PM
Unknown Object (File)
Thu, Apr 4, 5:28 AM
Unknown Object (File)
Thu, Mar 28, 2:47 PM
Unknown Object (File)
Thu, Mar 28, 2:47 PM
Unknown Object (File)
Thu, Mar 28, 2:47 PM
Unknown Object (File)
Thu, Mar 28, 2:47 PM
Unknown Object (File)
Thu, Mar 28, 2:47 PM
Unknown Object (File)
Thu, Mar 28, 2:47 PM
Subscribers

Details

Summary

This differential implements native methods to be exposed to JS to request notifications permissions.

Test Plan

Build the app on Android 13 device. Call CommAndroidNotifications.requestNotificationsPermission() from
ensurePushNotifsEnabled in push-handler.js. Ensure that prompt to grant permissions is visible ONLY after log in and that decision
passed to the prompt govoerns whether application can display notifs or not.

Diff Detail

Repository
rCOMM Comm
Lint
Lint Not Applicable
Unit
Tests Not Applicable

Event Timeline

marcin planned changes to this revision.EditedMar 21 2023, 1:52 AM

I will change JS-visible interface of those methods.

Mirror iOS behavior on Android 13

Note to reviewers: this is addressing an Urgent issue, so would be great to prioritize!

bartek requested changes to this revision.Mar 21 2023, 10:05 AM

Besides nits, some considerations about promise handling.

native/android/app/src/main/java/app/comm/android/MainActivity.java
84–86 ↗(On Diff #23924)

You could do the request code equality check before the for loop and exit early.

native/android/app/src/main/java/app/comm/android/notifications/CommAndroidNotifications.java
28 ↗(On Diff #23924)

Nit: I know it's Java but this name is too long and difficult to read ;)
IMO the promise is more tied to the request itself rather than its result. The "result" is passed to the resolve() method.

Also, the default value is null so it can be omitted (up to you)

158 ↗(On Diff #23924)

If this function is accidentally called twice from JS before the first promise is resolved, we have a state where one of them is never resolved, because it'd be overwritten here.
One common practice when opening native interfaces is to reject early if another promise has already been set:

if (notificationsPermissionRequestResultPromise.get() != null) {
  promise.reject(new RuntimeException("another permission request is already pending");
}
notificationsPermissionRequestResultPromise.set(promise);
176–177 ↗(On Diff #23924)

Doesn't this throw NPE if the promise is somehow null? I know this shouldn't happen, but it's a good practice

This revision now requires changes to proceed.Mar 21 2023, 10:05 AM
native/android/app/src/main/java/app/comm/android/MainActivity.java
84–86 ↗(On Diff #23924)

I understand, but I implemented it this way with purpose. Perhaps in future we might use requestPermission for another type of runtime permission. In this case it will be easy to modify this callback by adding new if statement. If I add early exit now, we might need to remove it in future.

native/android/app/src/main/java/app/comm/android/notifications/CommAndroidNotifications.java
158 ↗(On Diff #23924)

If it is a common practice to reject then I will stick to the conversion. I was thinking about this actually how to handle already ongoing request. I was inspired by solution adopted by Notifee library: https://github.com/invertase/notifee/pull/523/commits/273ff30f99c0ee17d30c62da18ed0cce93fd7731. After reading through their code I understand they substitute already existing promise with new one. On the other hand, they don't use any synchronization primitives - they just keep raw reference to the promise. It just doesn't sound right to me so I am not strongly inclined to consider their implementation as a gold standard.

Reject promise if there is ongoing one already. Check for null promise to avoid NPE.

Use getAndSet() sicne it is safer and cleaner

Use compareAndSet since it is safer and cleaner

Use compareAndSet sicne it is safer and cleaner

👍 for this change

native/android/app/src/main/java/app/comm/android/MainActivity.java
84–86 ↗(On Diff #23924)

Ok, that's fair

This revision is now accepted and ready to land.Mar 22 2023, 2:10 AM
ashoat requested changes to this revision.Mar 22 2023, 11:28 AM
ashoat added inline comments.
native/android/app/src/main/java/app/comm/android/notifications/CommAndroidNotifications.java
32 ↗(On Diff #23975)

Can you add a code comment above this line explaining what 11111 means, or linking to somewhere that does?

116 ↗(On Diff #23975)

I think it's confusing that this method has the same name as the one on line 189

132 ↗(On Diff #23975)

Same concern about overloading the method name

160 ↗(On Diff #23975)

Is it possible to get here through a programmer error on the JS side?

195–202 ↗(On Diff #23975)

I don't like the if () { return true } else { return false } pattern... I think it is more simply laid out this way:

return android.os.Build.VERSION.SDK_INT >=
  android.os.Build.VERSION_CODES.TIRAMISU;
This revision now requires changes to proceed.Mar 22 2023, 11:28 AM
native/android/app/src/main/java/app/comm/android/notifications/CommAndroidNotifications.java
32 ↗(On Diff #23975)

This code can be any number, it's used to ensure that onRequestPermissionsResult() is triggered as a response to our request.
(multiple permission requests could be sent simultaneously, but there's only one callback)

160 ↗(On Diff #23975)

Yes, that was discussed offline and this rejection addresses my comment above: https://phab.comm.dev/D7106?id=23924#inline-46661

This rejection doesn't have to be explicitly handled on JS side, it's just a kind of 'invariant' note that this was accidentally called twice.

native/android/app/src/main/java/app/comm/android/notifications/CommAndroidNotifications.java
32 ↗(On Diff #23975)

Got it – would be good to explain this in a code comment

160 ↗(On Diff #23975)

Ah, so you're saying it's possible for this line to be triggered as a result of JS programmer error? Can you clarify what sort of error the JS programmer would have to make? Eg. is there an expectation that the JS programmer will not call requestNotificationsPermission before the last requestNotificationsPermission is complete?

native/android/app/src/main/java/app/comm/android/notifications/CommAndroidNotifications.java
32 ↗(On Diff #23975)

I will add a comment. And yes - @bartek explanation is exactly the reason this request code is hardcoded. Additionally I chose the number 11111 since the same number was used by Notifee library in their PR that migrates to Android 13: https://github.com/invertase/notifee/blob/main/android/src/main/java/app/notifee/core/Notifee.java#L49

116 ↗(On Diff #23975)

Why is it confusing? I want to use the same functionality both in this Java class and in JS via exposed ReactMethod. I wanted to avoid repeating code that calls NotificationManagerCompat api/check for SDK_INT.

160 ↗(On Diff #23975)

I think this is exactly the case @bartek wanted to handle. We expect this method not to be called twice in JS before the first completes. If JS programmer make this error they will see an exception that will force them to fix it. However if it is not acceptable to enforce this on the JS side I can work on another approach to handle this case.

195–202 ↗(On Diff #23975)

Sure - I will change it.

native/android/app/src/main/java/app/comm/android/notifications/CommAndroidNotifications.java
116 ↗(On Diff #23975)

My understanding is that these are two different methods, just with the same method name. Java lets you do this because it allows method name overloading. I think it's confusing because these two methods are doing different things.

I'm not sure what you mean when you say "I want to use the same functionality". I don't understand how renaming your method to be more clear affects your ability to use functionality, or causes code repetition. Can you clarify what code you think will need to be repeated if you rename the methods on line 189 and 194?

160 ↗(On Diff #23975)

Are we really prepared to guarantee that on the JS side? I suspect that this API will be triggered by a React state transition (eg. in a React.useEffect or in componentDidUpdate), which means that the JS side has to cache the promise in order to guarantee that it will only be executed once.

We can do this in JS, but there are two downsides in comparison to doing it in Java:

  1. This requirement (API can only be called once at a time) is not very discoverable, and might not come up in testing. As a result, the JS programmer could make the mistake of not considering it.
  2. If we do it in Java, we make it impossible for the API to be used incorrectly. We need to handle this "synchronization" somewhere, so it seems better to handle it in such a way that does not introduce the possibility of programmer error.

On the other hand, this synchronization might be harder to do in Java. I don't have context on why we opted not to do it in Java – can you please share if there are any reasons?

native/android/app/src/main/java/app/comm/android/notifications/CommAndroidNotifications.java
160 ↗(On Diff #23975)

I don't understand why we're wasting so much review cycle time on such a minor thing. I'm starting to wonder if it'd be better to revert my suggestion just to avoid all that fuss.

Seriously, it's just a good practice to reject subsequent promises if one is still in progress. That's all. This is a way to prevent accidentally opening two native permission/image-picker/whatever interfaces at once. I've never seen that anybody ever cared about this that much, moreover I haven't seen anybody explicitly handling this in JS. It's obvious that you want to display it once (= call it once at a time = don't call it again until awaited). So in a typical scenario, this will never throw.

native/android/app/src/main/java/app/comm/android/notifications/CommAndroidNotifications.java
160 ↗(On Diff #23975)

It is less about synchronization but more about what should we do if we issue a second call to this method before promise from the first call completes. My first idea was to atomically substitute the first promise with the second one. However this would leave this the first promise unresolved. Therefore @bartek requested to change this behaviour by rejecting the second promise. Alternatively we can just resolve the second promise to the value returned by hasPermission method. Even if the second promise resolves to invalid value (since the interaction between user and notifications permission prompt is still ongoing and may change hasPermission value) the the first will resolve to the correct value and will fix the state.

cc @ashoat , @bartek what do you think?

native/android/app/src/main/java/app/comm/android/notifications/CommAndroidNotifications.java
116 ↗(On Diff #23975)

I wanted not to repeat those fragments of code:

if (android.os.Build.VERSION.SDK_INT >=
       android.os.Build.VERSION_CODES.TIRAMISU) {
     // Application has to explicitly request notifications permission from
     // user since Android 13. Older versions grant them by default. Details:
     // https://developer.android.com/develop/ui/views/notifications/notification-permission
     return true;
   }
   return false;

and

return NotificationManagerCompat.from(getReactApplicationContext())
        .areNotificationsEnabled();

I needed them both in Java code and in JS-exported methods, so I decided to extract them to private Java methods. I used overloading to name those methods since it was natural for me.
I will add Internal suffixes to names of those methods.

native/android/app/src/main/java/app/comm/android/notifications/CommAndroidNotifications.java
160 ↗(On Diff #23975)

It is less about synchronization but more about what should we do if we issue a second call to this method before promise from the first call completes. My first idea was to atomically substitute the first promise with the second one. However this would leave this the first promise unresolved.

Exactly!

Alternatively we can just resolve the second promise to the value returned by hasPermission method. Even if the second promise resolves to invalid value (since the interaction between user and notifications permission prompt is still ongoing and may change hasPermission value) the the first will resolve to the correct value and will fix the state.

Order of resolving them is not guaranteed so we could have a race condition here, I'd just leave it as is

native/android/app/src/main/java/app/comm/android/notifications/CommAndroidNotifications.java
160 ↗(On Diff #23975)

After gathering the discussion above my statement on this is that:

  1. I would follow @bartek approach since as he stated it is a common practice he encountered in open source projects.
  2. If there is no acceptance to this approach I will make this method resolve not to two but to three values: false, true, null. The promise resolved to null will be interpreted in JS code that the request is already in progress and another promise will shortly resolve to some meaningful value we can react to (by querying for device token or setting it to null). It will probably be an early return in JS. Alternatively to resolving to ?boolean we can resolve to string constants such as GRANTED, DENIED and REQUEST_ALREADY_IN_PROGRESS.
  1. Replace method overloading with different names.
  2. Simplify check for Android OS version.
  3. Add comment to explain hardcoded request code for notifications permission.
ashoat requested changes to this revision.EditedMar 24 2023, 1:34 PM

I'm starting to wonder if it'd be better to revert my suggestion just to avoid all that fuss.

Reverting your suggestion will not avoid any "fuss" at this point. The fuss is not about your suggestion (which is better than nothing), but rather about the requirement that this API be called one-at-a-time.

This comes across as trying to hide issues from a reviewer in order to avoid having to address feedback.

Seriously, it's just a good practice to reject subsequent promises if one is still in progress.

In my experience, if you have something async that can only be run one-at-a-time, the "good practice" is to return an existing promise ("dedup promises") rather than reject subsequent promises.

I've never seen that anybody ever cared about this that much

I would appreciate if you could engage with the discussion instead of making personal comments.

moreover I haven't seen anybody explicitly handling this in JS

That's interesting. Deduping promises is a common idiom in JS, and is basically the reason that Promise.resolve exists. Here's an example from our codebase.

It's obvious that you want to display it once (= call it once at a time = don't call it again until awaited). So in a typical scenario, this will never throw.

I am not clear on what a "typical scenario" is, but as mentioned above I suspect that this API will be triggered by a React state transition, eg. a React.useEffect. It is not at all crazy to imagine somebody adding a new parameter to the dep list of such an effect, causing the effect to trigger again whenever that new parameter changes.

In my experience, relying on a React state transition to only trigger "one at a time" is a recipe for disaster.

I don't understand why we're wasting so much review cycle time on such a minor thing.

I don't think it needs to be all that complicated, and I agree we are spending too much time on review here. Here are some easy solutions I would be open to:

  1. Leave this Java code as-is, but add code in JavaScript to dedup promises (assign promise to a variable, if it already exists return it, otherwise create a new one)
  2. Instead of using 11111 every time, assign a counter variable in Java, and increment it for every call. (I assume the reuse of 11111 is the concern here – let me know if I am misunderstanding)
  3. Replacing notificationsPermissionRequestPromise with a collection of promises. If the collection is non-empty when requestNotificationsPermission is called, then the promise will be added to the collection without calling ActivityCompat.requestPermissions
  4. Using a React.useRef on the JS side to guarantee that the effect only runs one-at-a-time
This revision now requires changes to proceed.Mar 24 2023, 1:34 PM

After gathering the discussion above my statement on this is that:

  1. I would follow @bartek approach since as he stated it is a common practice he encountered in open source projects.
  2. If there is no acceptance to this approach I will make this method resolve not to two but to three values: false, true, null. The promise resolved to null will be interpreted in JS code that the request is already in progress and another promise will shortly resolve to some meaningful value we can react to (by querying for device token or setting it to null). It will probably be an early return in JS. Alternatively to resolving to ?boolean we can resolve to string constants such as GRANTED, DENIED and REQUEST_ALREADY_IN_PROGRESS.

If we do want to take the approach of "handle it in JS" (I'm open to this), I think my preference is option 1 from my comment above:

  1. Leave this Java code as-is, but add code in JavaScript to dedup promises (assign promise to a variable, if it already exists return it, otherwise create a new one)

If @marcin is down for that, I think we can move forward with this diff (Java side) as-is, and follow-up on the JavaScript side in D7120. Otherwise if you want to take a Java-based approach, feel free to re-request review and in D7120.

This revision is now accepted and ready to land.Mar 24 2023, 6:48 PM

First of all, I'm sorry that my previous message sounds personal to you, I didn't mean it in any case.

I'll try to clarify my point in other words:

  • By a "typical scenario" of API called once-at-a-time I meant opening a modal interface (permission request, opening a picker, etc) - you don't want to open it again if it's already open or you'd end up with glitchy UX
  • In JS, a promise should be always resolved or rejected, so even if two "open modal and wait for result" promises are pending, we still want only one modal = one of the promises should be rejected. From Java perspective, they're always two separate promises (they ''promise'' to always either resolve or reject). You cannot dedupe them here.
  • That rejection message means "you're trying to open this permission modal again but it's already open. change your code to not do this" - this means that the JS call to this API should be revisited (e,g, react effect).

Taking the above considerations and possible suggestions, I'd go with the JS-only options as well.
1st one (dedupe promises in JS) is the most reasonable to me, especially when this API is called in an effect.
2 . (request code counter) doesn't seem possible without 3.
and 3 (queue in Java) is in my opinion an overkill for such a simple API.
I don't have an opinion on 4.

But I'll let @marcin decide on the best approach here.

Thanks @bartek, your explanation makes sense... I agree now that doing something on the Java side doesn't make any sense. I think we can move the discussion to D7120 for the JS side and safely land this diff as-is