Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
8 changes: 8 additions & 0 deletions docs/apis/notifications.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
44 changes: 36 additions & 8 deletions src/Rask.Native/Platforms/Android/NativeNotifications.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -24,8 +26,18 @@ internal sealed class NativeNotifications(Activity activity) : INotifications

public async ValueTask<NotificationPermission> 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;
}
Expand Down Expand Up @@ -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<bool> RequestPostNotificationsAsync() =>
Expand Down