Skip to content

fix: handle manually disabling notifications pre-Tiramisu - #21400

Open
ericli3690 wants to merge 7 commits into
ankidroid:mainfrom
ericli3690:ericli3690-pre-tiramisu-notifs
Open

fix: handle manually disabling notifications pre-Tiramisu#21400
ericli3690 wants to merge 7 commits into
ankidroid:mainfrom
ericli3690:ericli3690-pre-tiramisu-notifs

Conversation

@ericli3690

@ericli3690 ericli3690 commented Jul 27, 2026

Copy link
Copy Markdown
Member

Note

Assisted-by: Claude Opus 5

Purpose / Description

I discovered that the function we were using for checking if notification permissions have been granted is flawed below API 33. Recall that it was:

Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU ||
ContextCompat.checkSelfPermission(context, Manifest.permission.POST_NOTIFICATIONS) ==
PackageManager.PERMISSION_GRANTED

However, it is actually NOT the case that devices below API 33 unconditionally allow notifications to fire. It is possible to enter the settings and manually disable the ability for an app to fire notifications, meaning the above was returning a false positive. The correct API call is NotificationManagerCompat.from(context).areNotificationsEnabled(), which is equivalent to the old check at and above API 33, but which also handles pre-Tiramisu devices correctly.

Below: Google Pixel, API 25, OS settings page for notifications:
image

The "new" UI is simply to handle the legacy notification case. No visual changes:
image
image

Fixes

  • Fixes a false positive on the review reminder troubleshooting page that stated reminders were working on pre-Tiramisu devices when they were not.

Approach

See commit messages.

How Has This Been Tested?

  • Initial install and launch works
  • Unit tests
  • Manually granting and revoking permissions is updated accordingly
  • Buttons within review reminders UI and troubleshooting UI for enabling and revoking permissions works
  • BottomSheet shows correctly both before and after API 33
  • Permission explanation screen works correctly both before and after API 33
  • Tested on a physical Lenovo Tab M11, API 35
  • Tested on a physical Samsung S23, API 36
  • Tested on an emulated Pixel, API 25
  • Tested on an emulated Pixel 6 Pro, API 31

Learning

  • Discovered while working on feat(reminders): linking to OS notification settings #21398
  • For notes on why I decided to create a separate pref for the BottomSheet, see the new pref's docstring
  • I'm kind of unhappy that I had to create a new dummy fake permission string for legacy notifications to enable openAppSettingsScreenForPermission, but I can't see any other way to do it. It doesn't cause any problems in the codebase right now, but it's kind of hacky. Being able to use singleOrNull in PermissionItems is nice, though. If anyone has a better idea, please let me know.

Checklist

  • You have a descriptive commit message with a short title (first line, max 50 chars).
  • You have commented your code, particularly in hard-to-understand areas
  • You have performed a self-review of your own code

@ericli3690 ericli3690 self-assigned this Jul 27, 2026
@github-actions

github-actions Bot commented Jul 27, 2026

Copy link
Copy Markdown
Contributor

Snapshot diff report vs main. Open screenshot-diff for diffs.

  • PreferencesScreenshotTest: 1 change
  • ReviewRemindersScreenshotTest: 11 changes
All 12 changed screenshots

PreferencesScreenshotTest

  • ScheduleRemindersFragment_compare.png

ReviewRemindersScreenshotTest

  • settingsHostTablet_scheduleReminders_compare.png
  • settingsHostTablet_troubleshooting_compare.png
  • settingsHost_scheduleReminders_compare.png
  • settingsHost_scheduleReminders_scrolled_compare.png
  • settingsHost_troubleshooting_compare.png
  • standaloneActivityHost_scheduleReminders_compare.png
  • standaloneActivityHost_troubleshooting_compare.png
  • studyOptionsFragmentHost_scheduleReminders_compare.png
  • studyOptionsFragmentHost_troubleshooting_compare.png
  • studyOptionsFrameHost_scheduleReminders_compare.png
  • studyOptionsFrameHost_troubleshooting_compare.png

* should be used to request optional permissions from the user, and can be launched as the user gradually
* encounters features that require permissions rather than being shoved in the face of every first-time user.
*/
@RequiresApi(Build.VERSION_CODES.TIRAMISU)

@ericli3690 ericli3690 Jul 27, 2026

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Removed because this is no longer Tiramisu-specific

* Permissions screen for requesting permissions until API 29.
*
* Requested permissions:
* 1. Storage access: [Permissions.legacyStorageAccessPermissions].

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This name was out of date and the IDE was screaming at me to fix it haha

* There is no formal permission to request, but it is possible for the user to manually disable notifications in Settings.
* If they want notifications, we need to direct the user to the OS settings via the [NotificationsPermissionFragment] so they can re-enable them.
*/
LEGACY_NOTIFICATIONS(listOf(LEGACY_POST_NOTIFICATIONS), NotificationsPermissionFragment::class.java),

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Note that the permissions field of both NOTIFICATIONS and LEGACY_NOTIFICATIONS is not actually used anywhere... that field is only read for the external storage permission sets via hasRequiredPermissions.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thus theoretically I could roll these into one entry in PermissionSet... but that would require removing Permissions.notificationsPermission from the list and would require more documentation

Comment on lines -53 to -57
val tiramisuPhotosAndVideosPermissions =
listOf(
Manifest.permission.READ_MEDIA_IMAGES,
Manifest.permission.READ_MEDIA_VIDEO,
)

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Unused and it was bothering me

Comment on lines -62 to -67
val postNotification =
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
Manifest.permission.POST_NOTIFICATIONS
} else {
null
}

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

As stated in my commit message, this fancy if was causing more problems than it was solving and the type checker was not smart enough to consider it, leading to duplicate checks that the SDK was >= Tiramisu

Comment on lines -175 to -176
@RequiresApi(Build.VERSION_CODES.TIRAMISU)
val tiramisuAudioPermission = Manifest.permission.READ_MEDIA_AUDIO

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not used

Comment on lines -254 to -256
fun canPostNotifications(context: Context): Boolean =
Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU ||
ContextCompat.checkSelfPermission(context, Manifest.permission.POST_NOTIFICATIONS) == PackageManager.PERMISSION_GRANTED

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Moved to common

Comment on lines 41 to +63
<com.ichi2.anki.ui.windows.permissions.PermissionsItem
android:id="@+id/post_notification_permission_item"
android:layout_width="match_parent"
android:layout_height="wrap_content"
app:permissionTitle="@string/notification_pref"
app:permissionSummary="@string/notifications_permission_explanation"
app:permissionIcon="@drawable/ic_notifications"
app:permission="@string/post_notification_permission"
android:visibility="gone"
tools:visibility="visible"
/>

<com.ichi2.anki.ui.windows.permissions.PermissionsItem
android:id="@+id/legacy_post_notification_permission_item"
android:layout_width="match_parent"
android:layout_height="wrap_content"
app:permissionTitle="@string/notification_pref"
app:permissionSummary="@string/notifications_permission_explanation"
app:permissionIcon="@drawable/ic_notifications"
app:permission="@string/legacy_post_notification_permission"
android:visibility="gone"
tools:visibility="visible"
/>

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We need one for API 33+ and one for API <33. The "duplicate" is necessary because revokeIfGrantedOnClickElse will need to manually open the OS settings when a permission is revoked, but in order to open the notifications settings subpage on pre-Tiramisu devices, we need to pass in the proper app:permission. No UI change results from this, we conditionally render in AllPermissionsExplanationFragment.

@ericli3690
ericli3690 marked this pull request as draft July 27, 2026 06:55
@ericli3690
ericli3690 force-pushed the ericli3690-pre-tiramisu-notifs branch from 6270040 to f217d22 Compare July 27, 2026 07:38
* Used to view and cancel sync progress.
* Used for review reminder notifications.
*/
class LegacyNotificationsPermissionFragment : PermissionsFragment(R.layout.fragment_legacy_notifications_permission) {

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can be triggered pre-Tiramisu by, for example, freshly installing the app, disabling notifications manually in settings, then trying to create a review reminder.

@ericli3690

Copy link
Copy Markdown
Member Author

Note screenshot tests now display the permission-granted state rather than the permission-not-granted state due to funky logic with Robolectric interacting with our switch from ContextCompat.checkSelfPermission to NotificationManagerCompat.from(context).areNotificationsEnabled().

@ericli3690
ericli3690 marked this pull request as ready for review July 27, 2026 07:56

@BrayanDSO BrayanDSO left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

2nd commit implements too many things, which is noted on its description. Splitting the commit would ease the reviewing process

@ericli3690

Copy link
Copy Markdown
Member Author

Fair point, sorry about that. Will break it up.

@ericli3690
ericli3690 marked this pull request as draft July 27, 2026 19:52
@ericli3690
ericli3690 force-pushed the ericli3690-pre-tiramisu-notifs branch from f217d22 to 4400761 Compare July 28, 2026 04:00
@ericli3690
ericli3690 marked this pull request as ready for review July 28, 2026 04:25
@ericli3690

Copy link
Copy Markdown
Member Author

@BrayanDSO Broken up into separate commits! Ready for review.

Lint is failing but it's also failing on main... I think something weird got into the main branch.

`Permissions.postNotification` was declared as a nullable value: `Manifest.permission.POST_NOTIFICATIONS` at and above API 33, and `null` below it. Every call site had to unwrap it with `?.let`. Several call sites then went on to check `Build.VERSION.SDK_INT` anyway because static analysis thought that API 33 was not enforced, so the API level check was duplicated rather than avoided.

This commit simplifies the logic and replaces `postNotification` with `Permissions.notificationsPermission`, a non-null `@RequiresApi(TIRAMISU)` constant.

No behaviour change.

Assisted-by: Claude Opus 5
When a permission can no longer be requested through the OS dialog, we send the user into the system settings to grant it by hand. Where the OS provides one, we should land them on the settings subscreen for that specific permission rather than on the generic app info page, which requires them to hunt for the right toggle. This should be the default interface function for opening the OS for a permission. Further branches can be added to the `when` block in the future.

Assisted-by: Claude Opus 5
Below API 33 there is no `POST_NOTIFICATIONS` permission to request, but the user can still turn notifications off for the app in the system settings, so there is still something for us to represent and to send the user off to fix. Our permission machinery is keyed on permission strings throughout (`PermissionSet` holds a list of them, `PermissionsItem` takes one via the `app:permission` layout attribute, `openAppSettingsScreenForPermission` switches on one) so representing "notifications, below API 33" means we need a string to key on.

Adds `LEGACY_POST_NOTIFICATIONS`, a dummy sentinel string, alongside a matching `legacy_post_notification_permission` string resource so layouts can refer to it too, and teaches `openAppSettingsScreenForPermission` to route it to the notification settings screen.

The sentinel is not a real permission, so handing it to `ContextCompat.checkSelfPermission` would always report it as denied. `hasPermission` therefore resolves it through `canPostNotifications` before it can reach `checkSelfPermission`. Nothing passes the sentinel to `hasPermission` today, but `PermissionSet` and `PermissionsItem` both funnel into it. That resolution is also why `canPostNotifications` moves into `common` next to `hasPermission`.

Assisted-by: Claude Opus 5
"Get notifications turned on for this app" means two different things depending on the API level. At and above API 33 we request `POST_NOTIFICATIONS` through the system dialog, falling back to the notification settings screen once the OS stops letting us ask. Below API 33 there is no permission to request at all, so the only thing we can do is send the user to the notification settings screen directly.

A small utility function is created. Currently, this is only used in one place, but a little bit of extra abstraction is not too harmful especially since it allows for some unit tests. The one place this is currently used is ReminderTroubleshootingFragment, where "Grant permission" action previously returned `null` below API 33, meaning the check could report a problem while offering no way to act on it.

Assisted-by: Claude Opus 5
The check we were using to decide whether notifications can be posted was flawed below API 33. It was:

```kotlin
Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU || ContextCompat.checkSelfPermission(context, Manifest.permission.POST_NOTIFICATIONS) == PackageManager.PERMISSION_GRANTED
```

That short-circuit assumes devices below API 33 unconditionally allow notifications to fire. That is not the case: on any API level the user can go into the system settings and switch notifications off for the app entirely. So on older devices the check returned a false positive, and we would happily report that notifications were fine when in fact nothing we posted would ever be shown.

`NotificationManagerCompat.from(context).areNotificationsEnabled()` is equivalent to the old check at and above API 33 and also handles pre-Tiramisu devices correctly, so we should use that instead.

Assisted-by: Claude Opus 5
Both of the places where we ask the user for notifications were gated behind API 33, on the assumption that there was nothing to ask for below it. Now that we detect notifications being switched off by the user on older devices, both are useful there as well. They just have to send the user to the system settings rather than request a permission, since there is no permission to request.

This needs a way to avoid spam. At and above API 33 we keep showing the sheet until the OS tells us to stop, via `shouldShowRequestPermissionRationale`. Below API 33 there is no such signal, so we have to remember for ourselves whether the sheet has been shown, which is what the new `notificationsBottomSheetShownBelowAPI33` pref is for. We could technically have reused `notificationsPermissionRequested`, but that would give it two jobs and blur its definition as "has the OS system dialog for requesting notifications been presented to the user before". Below API 33 that dialog does not exist, so `notificationsPermissionRequested` should always be false there.

Assisted-by: Claude Opus 5
@ericli3690
ericli3690 force-pushed the ericli3690-pre-tiramisu-notifs branch from 4400761 to 36d7d7e Compare July 29, 2026 19:15
@ericli3690

Copy link
Copy Markdown
Member Author

Rebased, CI passing. Thanks to lukstbit for fixing the lint issues on main. Ready for review.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants