From a991c0c3b0eab866da70ebca69b765d7c2aa7ef3 Mon Sep 17 00:00:00 2001 From: pt Date: Sat, 8 Aug 2026 10:30:29 +0200 Subject: [PATCH] fix(native): stop reporting a granted notification permission for a muted Android app INotifications asked only whether the app HELD POST_NOTIFICATIONS, and below API 33 -- where no such permission exists -- short-circuited to Granted outright. But the per-app notification toggle in Settings is independent of the permission, has existed since API 19, and turning it off makes NotificationManager.Notify a SILENT no-op. So a user who muted the app got Granted from PermissionAsync(), ShowAsync returned without throwing, and nothing ever appeared. Nothing in the API said otherwise. AreNotificationsEnabled is the one call that sees that toggle, and it exists from API 24 -- the android head's own SupportedOSPlatformVersion -- so it covers the whole supported range without a version guard. It is now checked first. A muted app reports Denied rather than Default: the way back is the Settings screen, not a prompt, which is exactly what Denied means in the web contract this backend mirrors ("blocked until the user changes the setting"). RequestPermissionAsync returns it too, instead of claiming a grant no prompt could produce -- it was answering Granted unconditionally below API 33, before it ever consulted the current state. Behaviour change: ShowAsync on a muted app now throws InvalidOperationException, like any other ungranted permission, where it previously returned and quietly showed nothing. That is the documented contract of this backend ("a denied permission throws"), and the silent version is what made the bug invisible. The equivalent gap in the IPermissions native backend was closed in #644; the comment there points here, so the two now agree about the same app. Android compiles only in CI's native jobs (no android workload locally), so those are the gate for this one. Closes #649. --- CHANGELOG.md | 14 ++++++ docs/apis/notifications.md | 8 ++++ .../Platforms/Android/NativeNotifications.cs | 44 +++++++++++++++---- 3 files changed, 58 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5e15c1bb..6a64f336 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -80,6 +80,20 @@ them until tagged releases begin. project the same way it already detects the database provider — by reading the project file — and the notes tell a WASM app to register `AddRaskBrowserSqlite`, create its schema at boot, and avoid the two build settings (`-p:WasmBuildNative=false`, `PublishTrimmed=true`) that each break it without an error. +- **`INotifications` no longer reports `Granted` on Android for an app whose notifications the user + switched off.** The check asked only whether the app *held* `POST_NOTIFICATIONS`, and short-circuited + to `Granted` outright below API 33 where no such permission exists. But the per-app notification + toggle in Settings is independent of the permission, exists on every supported version, and turning + it off makes `NotificationManager.Notify` a **silent** no-op — so `PermissionAsync()` said `Granted`, + `ShowAsync` returned without throwing, and nothing ever appeared. The one call that sees that toggle, + `AreNotificationsEnabled`, is now consulted first; it exists from API 24, the android head's own + minimum, so it needs no version guard. + A muted app reports **`Denied`** rather than `Default`, because the way back is the Settings screen + and not a prompt — which is what `Denied` means in the web contract this backend mirrors. + `RequestPermissionAsync()` returns it too instead of claiming a grant no prompt could produce (it was + answering `Granted` unconditionally below API 33). **Behaviour change:** `ShowAsync` on a muted app + now throws `InvalidOperationException` like any other ungranted permission, where it previously + returned and quietly showed nothing. ### Added - **`Mount` — give a component you built yourself the lifecycle it was missing.** A component normally diff --git a/docs/apis/notifications.md b/docs/apis/notifications.md index 5fd55471..e759cda7 100644 --- a/docs/apis/notifications.md +++ b/docs/apis/notifications.md @@ -13,6 +13,14 @@ native backend — a WebView has no `Notification` API. On Android 33+ this need permission (declared in the manifest + granted at runtime, as the sample/template activities do); `Tag` maps to the notification identifier so a same-tag notification replaces the previous one. +> **On Android, `PermissionAsync()` also reflects the app's notification toggle in Settings.** That switch is +> independent of `POST_NOTIFICATIONS` and exists on every supported version, including the pre-33 ones where +> there is no runtime permission at all — and with it off, posting a notification is a **silent** no-op rather +> than an error. So an app the user has muted reports `Denied` (not `Granted`, and not `Default`): the way back +> is the Settings screen, not a prompt, which is what `Denied` means here. `RequestPermissionAsync()` returns +> `Denied` for it rather than pretending a prompt would help, and `ShowAsync` throws as it does for any +> ungranted permission. + ## See also - Source: [`INotifications.cs`](../../src/Rask.Core/Browser/INotifications.cs) diff --git a/src/Rask.Native/Platforms/Android/NativeNotifications.cs b/src/Rask.Native/Platforms/Android/NativeNotifications.cs index 9cd62d55..c1aa7099 100644 --- a/src/Rask.Native/Platforms/Android/NativeNotifications.cs +++ b/src/Rask.Native/Platforms/Android/NativeNotifications.cs @@ -8,8 +8,10 @@ namespace Rask.Native; // Native Android backend for INotifications — the platform NotificationManager instead of the WebView's // Notification constructor (android.webkit.WebView doesn't support it). Registered by AndroidPlatform; the // framework resolves it over the JS default (native-first). Needs POST_NOTIFICATIONS (API 33+) in the manifest -// + a runtime grant. Icon/Badge URLs and RequireInteraction have no native equivalent here and are ignored; -// Silent routes to a no-sound channel. Matches the JS default's contract: a denied permission throws. +// + a runtime grant, AND the app's notifications left on in Settings — that toggle is independent of the +// permission and silences Notify() without an error, so it is checked too (see CurrentPermission). +// Icon/Badge URLs and RequireInteraction have no native equivalent here and are ignored; Silent routes to a +// no-sound channel. Matches the JS default's contract: a denied permission throws. internal sealed class NativeNotifications(Activity activity) : INotifications { private const string DefaultChannel = "rask_default"; @@ -24,8 +26,18 @@ internal sealed class NativeNotifications(Activity activity) : INotifications public async ValueTask RequestPermissionAsync() { + var current = CurrentPermission(); + + // Switched off in Settings: there is no prompt that can undo that, so report the block rather than + // claim a grant the request would never produce. Checked BEFORE the pre-33 short-circuit below, + // which would otherwise answer Granted for an app that cannot show a notification. + if (current == NotificationPermission.Denied) + { + return NotificationPermission.Denied; + } + // Before API 33 notifications need no runtime permission; already-granted needs no prompt. - if (!OperatingSystem.IsAndroidVersionAtLeast(33) || CurrentPermission() == NotificationPermission.Granted) + if (!OperatingSystem.IsAndroidVersionAtLeast(33) || current == NotificationPermission.Granted) { return NotificationPermission.Granted; } @@ -64,11 +76,27 @@ public ValueTask ShowAsync(string title, NotificationOptions? options = null) return default; } - private NotificationPermission CurrentPermission() => - !OperatingSystem.IsAndroidVersionAtLeast(33) - || activity.CheckSelfPermission(Android.Manifest.Permission.PostNotifications) == Permission.Granted - ? NotificationPermission.Granted - : NotificationPermission.Default; + private NotificationPermission CurrentPermission() + { + // Holding POST_NOTIFICATIONS is not the same as being allowed to show anything, and neither is + // running below API 33 where no such permission exists: the per-app notification toggle in Settings + // is independent of both, and turning it off makes Notify() a silent no-op rather than an error. + // AreNotificationsEnabled is the only call that sees it, and it exists from API 24 — the android + // head's own minimum — so it covers the whole supported range without a version guard. + // + // Denied, not Default: the way back is the Settings screen, not a prompt, which is exactly what + // Denied means in the web contract this backend mirrors ("blocked until the user changes the + // setting"). Reporting Default would tell a caller a request is still worth making. + if (!AndroidNotifications.Manager(activity).AreNotificationsEnabled()) + { + return NotificationPermission.Denied; + } + + return !OperatingSystem.IsAndroidVersionAtLeast(33) + || activity.CheckSelfPermission(Android.Manifest.Permission.PostNotifications) == Permission.Granted + ? NotificationPermission.Granted + : NotificationPermission.Default; + } [SupportedOSPlatform("android33.0")] private Task RequestPostNotificationsAsync() =>