diff --git a/native/android/app/src/main/java/app/comm/android/MainActivity.java b/native/android/app/src/main/java/app/comm/android/MainActivity.java index 69952d025..0ca01aa9d 100644 --- a/native/android/app/src/main/java/app/comm/android/MainActivity.java +++ b/native/android/app/src/main/java/app/comm/android/MainActivity.java @@ -1,69 +1,94 @@ package app.comm.android; +import android.Manifest; import android.content.Intent; +import android.content.pm.PackageManager; import android.os.Bundle; +import androidx.core.app.ActivityCompat; +import app.comm.android.notifications.CommAndroidNotifications; import com.facebook.react.ReactActivity; import com.facebook.react.ReactActivityDelegate; import com.facebook.react.ReactRootView; import expo.modules.ReactActivityDelegateWrapper; -public class MainActivity extends ReactActivity { +public class MainActivity extends ReactActivity + implements ActivityCompat.OnRequestPermissionsResultCallback { /** * Returns the name of the main component registered from JavaScript. * This is used to schedule rendering of the component. */ @Override protected String getMainComponentName() { return "Comm"; } @Override protected void onCreate(Bundle savedInstanceState) { super.onCreate(null); } @Override public void onNewIntent(Intent intent) { super.onNewIntent(intent); setIntent(intent); } /** * Returns the instance of the {@link ReactActivityDelegate}. There the * RootView is created and you can specify the renderer you wish to use - the * new renderer (Fabric) or the old renderer (Paper). */ @Override protected ReactActivityDelegate createReactActivityDelegate() { return new ReactActivityDelegateWrapper( this, new MainActivityDelegate(this, getMainComponentName())); } public static class MainActivityDelegate extends ReactActivityDelegate { public MainActivityDelegate( ReactActivity activity, String mainComponentName) { super(activity, mainComponentName); } @Override protected ReactRootView createRootView() { ReactRootView reactRootView = new ReactRootView(getContext()); // If you opted-in for the New Architecture, we enable the Fabric // Renderer. reactRootView.setIsFabric(BuildConfig.IS_NEW_ARCHITECTURE_ENABLED); return reactRootView; } @Override protected boolean isConcurrentRootEnabled() { // If you opted-in for the New Architecture, we enable Concurrent Root // (i.e. React 18). More on this on // https://reactjs.org/blog/2022/03/29/react-v18.html return BuildConfig.IS_NEW_ARCHITECTURE_ENABLED; } } @Override public void invokeDefaultOnBackPressed() { moveTaskToBack(true); } + + @Override + public void onRequestPermissionsResult( + int requestCode, + String[] permissions, + int[] grantResults) { + + for (int permissionIndex = 0; permissionIndex < grantResults.length; + permissionIndex++) { + String permissionName = permissions[permissionIndex]; + if (requestCode == + CommAndroidNotifications + .COMM_ANDROID_NOTIFICATIONS_REQUEST_CODE && + permissionName.equals(Manifest.permission.POST_NOTIFICATIONS)) { + CommAndroidNotifications.resolveNotificationsPermissionRequestPromise( + this, + grantResults[permissionIndex] == PackageManager.PERMISSION_GRANTED); + } + } + } } diff --git a/native/android/app/src/main/java/app/comm/android/notifications/CommAndroidNotifications.java b/native/android/app/src/main/java/app/comm/android/notifications/CommAndroidNotifications.java index 05f04c686..bbae66e77 100644 --- a/native/android/app/src/main/java/app/comm/android/notifications/CommAndroidNotifications.java +++ b/native/android/app/src/main/java/app/comm/android/notifications/CommAndroidNotifications.java @@ -1,132 +1,205 @@ package app.comm.android.notifications; +import android.Manifest; +import android.app.Activity; import android.app.NotificationChannel; import android.app.NotificationManager; import android.content.Context; import android.os.Bundle; import android.service.notification.StatusBarNotification; +import androidx.core.app.ActivityCompat; import androidx.core.app.NotificationManagerCompat; import com.facebook.react.bridge.NativeModule; import com.facebook.react.bridge.Promise; import com.facebook.react.bridge.ReactApplicationContext; import com.facebook.react.bridge.ReactContext; import com.facebook.react.bridge.ReactContextBaseJavaModule; import com.facebook.react.bridge.ReactMethod; import com.facebook.react.bridge.WritableMap; import com.google.firebase.messaging.FirebaseMessaging; import com.google.firebase.messaging.RemoteMessage; import java.util.HashMap; import java.util.Map; +import java.util.concurrent.atomic.AtomicReference; import me.leolin.shortcutbadger.ShortcutBadger; public class CommAndroidNotifications extends ReactContextBaseJavaModule { + private static AtomicReference + notificationsPermissionRequestPromise = new AtomicReference(null); + private NotificationManager notificationManager; + // Application can request multiple permission, but all + // will be resolved via `onRequestPermissionsResult` callback + // in MainActivity. Therefore we use custom request codes + // to differentiate between different permission requests + public static final int COMM_ANDROID_NOTIFICATIONS_REQUEST_CODE = 11111; + CommAndroidNotifications(ReactApplicationContext reactContext) { super(reactContext); Context context = reactContext.getApplicationContext(); this.notificationManager = (NotificationManager)context.getSystemService( Context.NOTIFICATION_SERVICE); } @Override public String getName() { return "CommAndroidNotifications"; } @ReactMethod public void removeAllActiveNotificationsForThread(String threadID) { if (android.os.Build.VERSION.SDK_INT < android.os.Build.VERSION_CODES.M) { // Method used below appeared in API level 23. Details: // https://developer.android.com/reference/android/app/NotificationManager#getActiveNotifications() // https://developer.android.com/reference/android/os/Build.VERSION_CODES#M return; } for (StatusBarNotification notification : notificationManager.getActiveNotifications()) { Bundle data = notification.getNotification().extras; if (data == null) { continue; } String notificationThreadID = data.getString("threadID"); if (notificationThreadID != null && notificationThreadID.equals(threadID)) { notificationManager.cancel(notification.getTag(), notification.getId()); } } } @ReactMethod public void getInitialNotification(Promise promise) { RemoteMessage initialNotification = getCurrentActivity().getIntent().getParcelableExtra("message"); if (initialNotification == null) { promise.resolve(null); return; } WritableMap jsReadableNotification = CommAndroidNotificationParser.parseRemoteMessageToJSForegroundMessage( initialNotification); promise.resolve(jsReadableNotification); } @ReactMethod public void createChannel( String channelID, String name, int importance, String description) { if (android.os.Build.VERSION.SDK_INT < android.os.Build.VERSION_CODES.O) { // Method used below appeared in API level 26. Details: // https://developer.android.com/develop/ui/views/notifications/channels#CreateChannel return; } NotificationChannel channel = new NotificationChannel(channelID, name, importance); if (description != null) { channel.setDescription(description); } notificationManager.createNotificationChannel(channel); } @ReactMethod public void setBadge(int count) { if (count == 0) { ShortcutBadger.removeCount(this.getReactApplicationContext()); } else { ShortcutBadger.applyCount(this.getReactApplicationContext(), count); } } @ReactMethod public void removeAllDeliveredNotifications() { notificationManager.cancelAll(); } @ReactMethod public void hasPermission(Promise promise) { - boolean enabled = - NotificationManagerCompat.from(getReactApplicationContext()) - .areNotificationsEnabled(); - promise.resolve(enabled); + promise.resolve(hasPermissionInternal()); } @ReactMethod public void getToken(Promise promise) { FirebaseMessaging.getInstance().getToken().addOnCompleteListener(task -> { if (task.isSuccessful()) { promise.resolve(task.getResult()); } else { promise.reject(task.getException()); } }); } + @ReactMethod + public void canRequestNotificationsPermissionFromUser(Promise promise) { + promise.resolve(canRequestNotificationsPermissionInternal()); + } + + @ReactMethod + public void requestNotificationsPermission(Promise promise) { + if (!canRequestNotificationsPermissionInternal()) { + // If this method is erroneously called on older Android we should + // resolve to the value from notifications settings. + promise.resolve(hasPermissionInternal()); + return; + } + + if (ActivityCompat.shouldShowRequestPermissionRationale( + getCurrentActivity(), Manifest.permission.POST_NOTIFICATIONS)) { + // Since Android 13 user has to deny notifications permission + // twice to remove possibility to show the prompt again. Details: + // https://developer.android.com/training/permissions/requesting#handle-denial + // On iOS user has to deny notifications permission once to disable the + // prompt. We use the method above to mimic iOS behavior on Android. + // Details: + // https://developer.android.com/topic/performance/vitals/permissions#explain + promise.resolve(hasPermissionInternal()); + return; + } + + if (!notificationsPermissionRequestPromise.compareAndSet(null, promise)) { + promise.reject( + "Programmer error: permission request already in progress."); + return; + } + + ActivityCompat.requestPermissions( + getCurrentActivity(), + new String[] {Manifest.permission.POST_NOTIFICATIONS}, + CommAndroidNotifications.COMM_ANDROID_NOTIFICATIONS_REQUEST_CODE); + } + @Override public Map getConstants() { final Map constants = new HashMap<>(); constants.put( "NOTIFICATIONS_IMPORTANCE_HIGH", NotificationManager.IMPORTANCE_HIGH); return constants; } + + public static void resolveNotificationsPermissionRequestPromise( + Activity mainActivity, + boolean isPermissionGranted) { + if (notificationsPermissionRequestPromise.get() == null) { + return; + } + + notificationsPermissionRequestPromise.getAndSet(null).resolve( + isPermissionGranted); + } + + private boolean hasPermissionInternal() { + return NotificationManagerCompat.from(getReactApplicationContext()) + .areNotificationsEnabled(); + } + + private boolean canRequestNotificationsPermissionInternal() { + // 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 android.os.Build.VERSION.SDK_INT >= + android.os.Build.VERSION_CODES.TIRAMISU; + } } diff --git a/native/push/android.js b/native/push/android.js index 8fae27f7e..e0ba86b95 100644 --- a/native/push/android.js +++ b/native/push/android.js @@ -1,70 +1,72 @@ // @flow import { NativeModules, NativeEventEmitter } from 'react-native'; import { mergePrefixIntoBody } from 'lib/shared/notif-utils.js'; type CommAndroidNotificationsModuleType = { +removeAllActiveNotificationsForThread: (threadID: string) => void, +getInitialNotification: () => Promise, +createChannel: ( channelID: string, name: string, importance: number, description: ?string, ) => void, +getConstants: () => { +NOTIFICATIONS_IMPORTANCE_HIGH: number, ... }, +setBadge: (count: number) => void, +removeAllDeliveredNotifications: () => void, +hasPermission: () => Promise, +getToken: () => Promise, + +requestNotificationsPermission: () => Promise, + +canRequestNotificationsPermissionFromUser: () => Promise, +NOTIFICATIONS_IMPORTANCE_HIGH: string, }; export type AndroidForegroundMessage = { +body: string, +title: string, +threadID: string, +prefix?: string, +messageInfos: ?string, }; const { CommAndroidNotificationsEventEmitter } = NativeModules; const CommAndroidNotifications: CommAndroidNotificationsModuleType = NativeModules.CommAndroidNotifications; const androidNotificationChannelID = 'default'; function handleAndroidMessage( message: AndroidForegroundMessage, updatesCurrentAsOf: number, handleIfActive?: ( threadID: string, texts: { body: string, title: ?string }, ) => boolean, ) { const { title, prefix, threadID } = message; let { body } = message; ({ body } = mergePrefixIntoBody({ body, title, prefix })); if (handleIfActive) { const texts = { title, body }; const isActive = handleIfActive(threadID, texts); if (isActive) { return; } } } function getCommAndroidNotificationsEventEmitter(): NativeEventEmitter<{ commAndroidNotificationsToken: [string], commAndroidNotificationsForegroundMessage: [AndroidForegroundMessage], commAndroidNotificationsNotificationOpened: [AndroidForegroundMessage], }> { return new NativeEventEmitter(CommAndroidNotificationsEventEmitter); } export { androidNotificationChannelID, handleAndroidMessage, getCommAndroidNotificationsEventEmitter, CommAndroidNotifications, };