Deep Dive into Android Notifications Permission

Yevhenii Smirnov
Yevhenii Smirnov
Sep 16 2022
Posted in Best Practices

A detailed review of how you could handle different scenarios

Deep Dive into Android Notifications Permission

It’s been a while since we did a blog post about new features coming with Android 13 and some details about them. If you need a refresh, feel free to look at it here.

Notifications Permission vs Android Version

Before we dive into the technical part of the notifications permission, let’s resume what’s the big change and case scenarios that Android 13 brought.

Scenario 1: Your app targets Android 12 or lower, and the device OS is Android 12 or lower.

Your app will run as expected without changes.

Scenario 2: Your app targets Android 12 or lower, and the device OS is Android 13 or higher.

If the user already had your application installed and updated to Android 13, notifications permission will keep the same state and will not be affected.

In case your app is freshly installed, by default, notifications will be OFF. That’s the scenario you should be aware of. If your application uses notifications, users will receive a prompt to allow or deny them when the notification channel is created. This is typically done during the application launch. If users click Don’t Allow, they will not be prompted again until they re-install the app or manually change it in the OS Settings.

Scenario 3: Your app is targeting Android 13 or higher, and the device is running Android 12 or lower.

Suppose you have some kind of intro, welcome flow, or something similar, where you ask for the notifications permission. In that case, the user won’t get any prompt for permission as it will be automatically granted.

Scenario 4: Your app is targeting Android 13 or higher, and the device is running Android 13 or higher.

This will be the new flow, where you have complete control over permission requests and handling of user responses.

In summary, updating your application to target Android 13 is highly recommended to keep a great user experience and app productivity. And that is the case we will focus on for the rest of this blog post.

Permission Request

In general, it should be handled the same way you have been doing for other permissions. Additionally, on this page you can find a guide from Google which describes some best practices.

Permission Provided

As developers, we all wish this was the only case scenario and, as one of my colleagues used to joke, “My job here is done!”. But, the most important here is the user experience, and the user has the right to choose to grant or deny any permissions for your app. You should cover all these scenarios.

Permission Denied

That’s where things become interesting, and you can adapt that to other permissions, such as location or camera.

Let’s take a look at these scenarios:

Scenario 1: Permission requested for the first time

As mentioned before and recommended by Google, the usual flow here should be done in some sort of intro, where you explain why you need this permission. So usually, if that is the case, you are sure that this is the first time your application is requesting this permission.

Scenario 2: Permission is requested for the second time

Typically, you will assume the request is made from another part of your application, such as a Settings view, and because it has been done before during the intro, it would not be the first request. Well, theoretically, that’s true because you made that request before, and the permission was not granted, and that’s why it is requested again. But practically, the 1st request may not have been denied. The user might have dismissed it. It is important to mention that in both cases the callback from ActivityResultContracts.StartActivityForResult() is false.

In case the prompt was dismissed the first time, permission will be requested directly, without showing the rationale text. Otherwise, the rationale should be shown, and the permission should be requested.

Scenario 3: Permission is denied permanently

In that case, most probably, you would like to redirect the user to the device's Settings, but when is it permanently denied?

It is tricky to discover if permissions, like notifications, are denied permanently or not. You have some methods to check if it’s denied, but no way to check if it’s permanently denied.

You could go with a shared preferences solution, having some sort of conditionals, and save a reference, so you will know if it's permanently denied. Eventually, it could be a solution, but since the user can change the permission at any time in the device's settings, it turns out the value saved in shared preferences may not be accurate.

Next, we are going to explore one approach I came out with when dealing with this situation.

Let’s start with a simple fragment where we have a toggle button, to turn ON or OFF notifications. When the button is switched ON, we’re going to handle the permission check, request if needed and enable remote notifications. In case it is switched OFF, we will disable remote notifications. The button state will be determined with the help of an existing method in our SDK.

So, here is our basic setup for the Settings fragment:

class SettingsFragment : Fragment() {
    private val viewModel: SettingsViewModel by viewModels()
    private lateinit var binding: FragmentSettingsBinding

    private val notificationsPermissionLauncher = registerForActivityResult(
        ActivityResultContracts.RequestPermission()
    ) { granted ->
        if (granted) {
            viewModel.changeRemoteNotifications(enabled = true)
            return@registerForActivityResult
        }

        binding.notificationsCard.notificationsSwitch.isChecked = false
    }

    override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
        super.onViewCreated(view, savedInstanceState)

        setupListeners()
        setupObservers()
    }

    private fun setupListeners() {
        binding.notificationsCard.notificationsSwitch.setOnCheckedChangeListener { _, checked ->
            if (checked == viewModel.notificationsEnabled.value) return@setOnCheckedChangeListener

            if (checked) {
                enableRemoteNotifications()
            } else {
                viewModel.changeRemoteNotifications(enabled = false)
            }
        }
    }

    private fun setupObservers() {
        viewModel.notificationsEnabled.observe(viewLifecycleOwner) { enabled ->
            binding.notificationsCard.notificationsSwitch.isChecked = enabled
            binding.dndCard.root.isVisible = enabled
        }
    }

    private fun enableRemoteNotifications() {
        if (!ensureNotificationsPermission()) return
        viewModel.changeRemoteNotifications(enabled = true)
    }

    private fun ensureNotificationsPermission(): Boolean {
        if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) return true

        val permission = Manifest.permission.POST_NOTIFICATIONS
        val granted = ContextCompat.checkSelfPermission(
            requireContext(), permission
        ) == PackageManager.PERMISSION_GRANTED

        if (granted) return true

        if (shouldShowRequestPermissionRationale(permission)) {
            AlertDialog.Builder(requireContext()).setTitle(R.string.app_name)
                .setMessage(R.string.permission_notifications_rationale).setCancelable(false)
                .setPositiveButton(android.R.string.ok) { _, _ ->
                    Timber.d("Requesting notifications permission.")
                    notificationsPermissionLauncher.launch(permission)
                }.setNegativeButton(R.string.dialog_cancel_button) { _, _ ->
                    Timber.d("Notifications permission rationale cancelled.")
                    binding.notificationsCard.notificationsSwitch.isChecked = false
                }.show()

            return false
        }

        Timber.d("Requesting notifications permission.")
        notificationsPermissionLauncher.launch(permission)

        return false
    }
}

And let's not forget about the ViewModel:

class SettingsViewModel : ViewModel(), DefaultLifecycleObserver {
    private val _notificationsEnabled = MutableLiveData(hasNotificationsEnabled)
    val notificationsEnabled: LiveData<Boolean> = _notificationsEnabled

    private val hasNotificationsEnabled: Boolean
        get() = Notificare.push().hasRemoteNotificationsEnabled && Notificare.push().allowedUI

    init {
        viewModelScope.launch {
            Notificare.push().observableAllowedUI
                .asFlow()
                .distinctUntilChanged()
                .collect { enabled ->
                    _notificationsEnabled.postValue(enabled)
                }
        }
    }

    fun changeRemoteNotifications(enabled: Boolean) {
        if (enabled) {
            Notificare.push().enableRemoteNotifications()
        } else {
            Notificare.push().disableRemoteNotifications()
        }
    }
}

At this point, we have a functional Fragment with a ViewModel that handles notification's permission and enables/disables notifications. But, it would be great to handle all case scenarios, and most importantly, the worst-case scenario, which is permanently denied. In that case, you could show some rationale text and provide an option for the user to go directly to the device's settings.

A good place to do that check would be on the callback from the permission request. So when we’re getting FALSE that could mean 3 things:

  • Permission denied, and rationale should be shown on the next request
  • Permission denied permanently
  • Permission was dismissed

Let’s add a variable to our fragment to help us filter those scenarios. I’m using a MutableList, but a boolean should be enough if you have only one permission request.

class SettingsFragment : Fragment() {
    private val pendingRationales = mutableListOf<PermissionType>()

    // Previous code

    enum class PermissionType {
        NOTIFICATIONS,
        LOCATION
    }
}

Add the value to our MutableList inside of AlertDialog after the shouldShowRequestPermissionRationale(permission) check:

.setPositiveButton(android.R.string.ok) { _, _ ->
    pendingRationales.add(PermissionType.NOTIFICATIONS)

    // Rest of code
}

Now we can implement our logic. We will use shouldShowRequestPermissionRationale(permission), to discard the 1st denied request, and with the help of the variable we've previously added, we will discard the second denied request. This way, we will avoid showing the option to open the device's settings.

private fun shouldOpenSettings(permissionType: PermissionType): Boolean {
    val permission = when (permissionType) {
        PermissionType.NOTIFICATIONS -> {
            if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
                Manifest.permission.POST_NOTIFICATIONS
            } else {
                null
            }
        }
        PermissionType.LOCATION -> Manifest.permission.ACCESS_FINE_LOCATION
    }

    if (permission != null) {
        if (!shouldShowRequestPermissionRationale(permission) && pendingRationales.contains(permissionType)) {
            pendingRationales.remove(permissionType)
            return false
        }

        if (shouldShowRequestPermissionRationale(permission) && pendingRationales.contains(permissionType)) {
            pendingRationales.remove(permissionType)
            return false
        }

        if (shouldShowRequestPermissionRationale(permission) && !pendingRationales.contains(permissionType)) {
            return false
        }
    }

    return true
}

private fun showSettingsPrompt(permissionType: PermissionType) {
    AlertDialog.Builder(requireContext()).setTitle(R.string.app_name)
        .setMessage(R.string.permission_open_os_settings_rationale)
        .setCancelable(false)
        .setPositiveButton(R.string.dialog_settings_button) { _, _ ->
            Timber.d("Opening OS Settings")
            openSettingsLauncher.launch(Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS).apply {
                data = Uri.fromParts("package", requireContext().packageName, null)
            })
        }
        .setNegativeButton(R.string.dialog_cancel_button) { _, _ ->
            Timber.d("Redirect to OS Settings cancelled")
            when (permissionType) {
                PermissionType.NOTIFICATIONS -> binding.notificationsCard.notificationsSwitch.isChecked = false
                PermissionType.LOCATION -> binding.locationCard.locationSwitch.isChecked = false
            }
        }
        .show()
}

Let's update our registerForActivityResult and handle our logic there:

private val notificationsPermissionLauncher = registerForActivityResult(
    ActivityResultContracts.RequestPermission()
) { granted ->
    if (granted) {
        viewModel.changeRemoteNotifications(enabled = true)
        return@registerForActivityResult
    }

    if (shouldOpenSettings(PermissionType.NOTIFICATIONS)) {
        showSettingsPrompt(PermissionType.NOTIFICATIONS)
        return@registerForActivityResult
    }

    binding.notificationsCard.notificationsSwitch.isChecked = false
}

Even when Android 12 (or older) defines notification permission granted as default, the user can always block them in the device's settings. So it would be a good option to check if that is the case. Let's update our enableRemoteNotifications() method accordingly:

private fun enableRemoteNotifications() {
    if (!ensureNotificationsPermission()) return

    if (!NotificationManagerCompat.from(requireContext().applicationContext).areNotificationsEnabled()) {
        // Only runs on Android 12 or lower.
        if (shouldOpenSettings(PermissionType.NOTIFICATIONS)) {
            showSettingsPrompt(PermissionType.NOTIFICATIONS)
        }

        return
    }

    viewModel.changeRemoteNotifications(enabled = true)
}

Last but not least, we want to know when the user comes back from the device's settings and check for any changes:

private val openSettingsLauncher = registerForActivityResult(
    ActivityResultContracts.StartActivityForResult()
) {
    if (binding.notificationsCard.notificationsSwitch.isChecked) {
        if (NotificationManagerCompat.from(requireContext().applicationContext).areNotificationsEnabled()) {
            if (!Notificare.push().hasRemoteNotificationsEnabled) {
                viewModel.changeRemoteNotifications(enabled = true)
            }
        } else {
            binding.notificationsCard.notificationsSwitch.isChecked = false
        }
    }
}

If you only have one permission request, you probably don’t need all the conditionals from the example above, as you would know which switch is checked because it would be the only way the user can open the device's settings from your app.

That's all?

Did we miss something? I think we missed a small detail. Wait, are there small details in programming?

If you were paying attention, you would probably question yourself about one specific behavior. If the notifications permission pop-up was dismissed during the intro, and after that it was also dismissed when we requested it from our Settings fragment, we will still see our AlertDialog.

That would be an edge case, but what can happen will happen, right? Although dismissing the pop-up twice would be a rare behavior, having our AlertDialog showing in that situation would not be that problematic. This is actually the behavior you also find in other apps installed by default on your Android device.

As always, we hope this post was helpful and feel free to share with us your ideas or feedback via our Support Channel.

Keep up-to-date with the latest news