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() =>