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
This commit is contained in:
Johannes Millan 2026-04-29 14:55:06 +02:00
parent bd34a20ec9
commit 766ecf2271
2 changed files with 91 additions and 1 deletions

View file

@ -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<CapacitorPlatformService>;

View file

@ -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();
}
}