From 766ecf2271b87ca2ea3df65657cb730ab8f5e87e Mon Sep 17 00:00:00 2001 From: Johannes Millan Date: Wed, 29 Apr 2026 14:55:06 +0200 Subject: [PATCH] fix(reminder): skip Capacitor permission checks on legacy Android WebView The legacy `FullscreenActivity` shell exposes the SUPAndroid bridge but hosts no Capacitor bridge, so `LocalNotifications.checkPermissions()` and `checkExactNotificationSetting()` fall back to their web implementations. Web Notifications API is unimplemented in Android WebView (Chromium issue #434712), so `Notification.permission` reads 'default' regardless of the OS POST_NOTIFICATIONS state. That produced two false-positive cold-start snackbars and silently bailed out of the reminder/due-date scheduling effects. Reminders on this path go through AlarmManager via `androidInterface.scheduleNativeReminder`, which the OS gates with POST_NOTIFICATIONS at fire time. Trust it: short-circuit `ensurePermissions()` and `ensureExactAlarmPermission()` to true on the legacy WebView. Trade-off: a user with OS notifications actually disabled gets no in-app warning, but the existing snackbar didn't deep-link to settings anyway. Inject `IS_ANDROID_WEB_VIEW_TOKEN` so the discriminator is overrideable via standard Angular DI in tests, matching the pattern in `task-reminder.effects.ts`. Refs #7408 --- .../capacitor-reminder.service.spec.ts | 51 +++++++++++++++++++ .../platform/capacitor-reminder.service.ts | 41 ++++++++++++++- 2 files changed, 91 insertions(+), 1 deletion(-) diff --git a/src/app/core/platform/capacitor-reminder.service.spec.ts b/src/app/core/platform/capacitor-reminder.service.spec.ts index 2958f85bbf..3158b34ac9 100644 --- a/src/app/core/platform/capacitor-reminder.service.spec.ts +++ b/src/app/core/platform/capacitor-reminder.service.spec.ts @@ -4,6 +4,7 @@ import { LocalNotificationsWeb } from '@capacitor/local-notifications/dist/esm/w import { CapacitorReminderService } from './capacitor-reminder.service'; import { CapacitorPlatformService } from './capacitor-platform.service'; import { CapacitorNotificationService } from './capacitor-notification.service'; +import { IS_ANDROID_WEB_VIEW_TOKEN } from '../../util/is-android-web-view'; describe('CapacitorReminderService', () => { let service: CapacitorReminderService; @@ -132,6 +133,56 @@ describe('CapacitorReminderService', () => { }); }); + describe('legacy Android WebView path (issue #7408)', () => { + let legacyService: CapacitorReminderService; + let checkExactAlarmSpy: jasmine.Spy; + + beforeEach(() => { + const nativePlatformSpy = jasmine.createSpyObj( + 'CapacitorPlatformService', + ['hasCapability', 'isIOS'], + { + platform: 'android', + isNative: true, + isMobile: true, + capabilities: { scheduledNotifications: true }, + }, + ); + checkExactAlarmSpy = spyOn( + LocalNotificationsWeb.prototype, + 'checkExactNotificationSetting', + ); + TestBed.resetTestingModule(); + TestBed.configureTestingModule({ + providers: [ + CapacitorReminderService, + provideMockStore(), + { provide: CapacitorPlatformService, useValue: nativePlatformSpy }, + { provide: CapacitorNotificationService, useValue: notificationServiceSpy }, + // Override the WebView discriminator via DI; `Capacitor.isNativePlatform()` + // is naturally false in the test environment, so the legacy-path + // condition `_isAndroidWebView && !Capacitor.isNativePlatform()` evaluates true. + { provide: IS_ANDROID_WEB_VIEW_TOKEN, useValue: true }, + ], + }); + legacyService = TestBed.inject(CapacitorReminderService); + }); + + it('ensurePermissions short-circuits without consulting Capacitor', async () => { + // The whole point of issue #7408: avoid the broken Web Notifications API + // fallback. We must NOT delegate to _notificationService here. + const result = await legacyService.ensurePermissions(); + expect(result).toBe(true); + expect(notificationServiceSpy.ensurePermissions).not.toHaveBeenCalled(); + }); + + it('ensureExactAlarmPermission short-circuits without consulting Capacitor', async () => { + const result = await legacyService.ensureExactAlarmPermission(); + expect(result).toBe(true); + expect(checkExactAlarmSpy).not.toHaveBeenCalled(); + }); + }); + describe('with native platform', () => { let nativeService: CapacitorReminderService; let nativePlatformSpy: jasmine.SpyObj; diff --git a/src/app/core/platform/capacitor-reminder.service.ts b/src/app/core/platform/capacitor-reminder.service.ts index cf1fc9c00b..3cf7916fc3 100644 --- a/src/app/core/platform/capacitor-reminder.service.ts +++ b/src/app/core/platform/capacitor-reminder.service.ts @@ -1,4 +1,5 @@ import { inject, Injectable } from '@angular/core'; +import { Capacitor } from '@capacitor/core'; import { LocalNotifications } from '@capacitor/local-notifications'; import { Log } from '../log'; import { CapacitorPlatformService } from './capacitor-platform.service'; @@ -7,7 +8,10 @@ import { NotificationActionEvent, REMINDER_ACTION_TYPE_ID, } from './capacitor-notification.service'; -import { IS_ANDROID_WEB_VIEW } from '../../util/is-android-web-view'; +import { + IS_ANDROID_WEB_VIEW, + IS_ANDROID_WEB_VIEW_TOKEN, +} from '../../util/is-android-web-view'; import { androidInterface } from '../../features/android/android-interface'; import { Observable } from 'rxjs'; import { GlobalConfigService } from '../../features/config/global-config.service'; @@ -52,6 +56,9 @@ export class CapacitorReminderService { private _platformService = inject(CapacitorPlatformService); private _notificationService = inject(CapacitorNotificationService); private _globalConfigService = inject(GlobalConfigService); + // Injected (vs reading IS_ANDROID_WEB_VIEW directly) so tests can override + // it via DI — matches the pattern in `task-reminder.effects.ts`. + private _isAndroidWebView = inject(IS_ANDROID_WEB_VIEW_TOKEN); /** * Observable that emits when a notification action is performed (iOS). @@ -274,6 +281,16 @@ export class CapacitorReminderService { return false; } + if (this._isLegacyAndroidWebView()) { + // No Capacitor bridge → `LocalNotifications.checkPermissions()` would + // fall back to `Notification.permission`, which Android WebView leaves + // at 'default' regardless of OS POST_NOTIFICATIONS state (issue #7408). + // Reminders here go through AlarmManager via the SUPAndroid bridge; the + // OS enforces permission at fire time, so we trust it and skip the + // upfront check. + return true; + } + const hasPermission = await this._notificationService.ensurePermissions(); if (!hasPermission) { return false; @@ -296,6 +313,15 @@ export class CapacitorReminderService { return true; } + if (this._isLegacyAndroidWebView()) { + // Same reasoning as ensurePermissions(): no Capacitor bridge in the + // legacy WebView, so the LocalNotifications exact-alarm helpers throw + // or return stale data. AlarmManager will fall back to inexact + // delivery if SCHEDULE_EXACT_ALARM is denied — acceptable for + // reminder UX. Skip the check. + return true; + } + try { const exactAlarmStatus = await LocalNotifications.checkExactNotificationSetting(); if (exactAlarmStatus?.exact_alarm !== 'granted') { @@ -310,4 +336,17 @@ export class CapacitorReminderService { return false; } } + + /** + * True on the legacy `FullscreenActivity`, which exposes the SUPAndroid + * bridge but does NOT host a Capacitor bridge. Capacitor plugin calls fall + * back to their web implementations there, and the Web Notifications API + * is unimplemented in Android WebView — making any LocalNotifications + * permission/exact-alarm check unreliable. Reminder scheduling on this + * path goes through AlarmManager via `androidInterface.scheduleNativeReminder`, + * which the OS gates with POST_NOTIFICATIONS at fire time. See issue #7408. + */ + private _isLegacyAndroidWebView(): boolean { + return this._isAndroidWebView && !Capacitor.isNativePlatform(); + } }