diff --git a/package.json b/package.json index d9a3c8ba0..5be2de3df 100644 --- a/package.json +++ b/package.json @@ -77,7 +77,7 @@ }, { "path": "./build/releases/OneSignalSDK.page.es6.js", - "limit": "42.57 kB", + "limit": "42.65 kB", "gzip": true }, { diff --git a/src/onesignal/OneSignal.ts b/src/onesignal/OneSignal.ts index 7f0317afe..2af1f228b 100644 --- a/src/onesignal/OneSignal.ts +++ b/src/onesignal/OneSignal.ts @@ -1,7 +1,7 @@ import type Bell from 'src/page/bell/Bell'; import { getAppConfig } from 'src/shared/config/app'; import type { AppConfig, AppUserConfig } from 'src/shared/config/types'; -import { db, dbPromise } from 'src/shared/database/client'; +import { dbPromise } from 'src/shared/database/client'; import { getConsentGiven, isConsentRequiredButNotGiven } from 'src/shared/database/config'; import { getSubscription } from 'src/shared/database/subscription'; import { windowEnvString } from 'src/shared/environment/detect'; @@ -20,6 +20,7 @@ import { import { getConsentRequired, removeLegacySubscriptionOptions, + setConsentGiven as setStorageConsentGiven, setConsentRequired as setStorageConsentRequired, } from 'src/shared/helpers/localStorage'; import { checkAndTriggerNotificationPermissionChanged } from 'src/shared/helpers/main'; @@ -219,7 +220,7 @@ export default class OneSignal { // for quick access as to not wait for async operations / loading from DB OneSignal._consentGiven = consent; - await db.put('Options', { key: 'userConsent', value: consent }); + setStorageConsentGiven(consent); if (consent && OneSignal._pendingInit) await OneSignal._delayedInit(); } diff --git a/src/shared/database/config.test.ts b/src/shared/database/config.test.ts new file mode 100644 index 000000000..2804d5cec --- /dev/null +++ b/src/shared/database/config.test.ts @@ -0,0 +1,45 @@ +import { beforeEach, describe, expect, test } from 'vite-plus/test'; + +import { + getConsentGiven as getStorageConsentGiven, + setConsentGiven as setStorageConsentGiven, +} from '../helpers/localStorage'; +import { db } from './client'; +import { getConsentGiven } from './config'; + +describe('getConsentGiven', () => { + beforeEach(() => { + localStorage.clear(); + }); + + test('defaults to false when nothing is stored', async () => { + expect(await getConsentGiven()).toBe(false); + }); + + test('reads the value persisted in localStorage', async () => { + setStorageConsentGiven(true); + expect(await getConsentGiven()).toBe(true); + + setStorageConsentGiven(false); + expect(await getConsentGiven()).toBe(false); + }); + + test('migrates a legacy IndexedDB value into localStorage', async () => { + await db.put('Options', { key: 'userConsent', value: true }); + expect(getStorageConsentGiven()).toBeNull(); + + expect(await getConsentGiven()).toBe(true); + expect(getStorageConsentGiven()).toBe(true); + + // Once migrated, the value survives even if the IndexedDB row is gone -- + // a wedged PWA can no longer drop it. + await db.delete('Options', 'userConsent'); + expect(await getConsentGiven()).toBe(true); + }); + + test('prefers localStorage over a stale legacy IndexedDB value', async () => { + await db.put('Options', { key: 'userConsent', value: true }); + setStorageConsentGiven(false); + expect(await getConsentGiven()).toBe(false); + }); +}); diff --git a/src/shared/database/config.ts b/src/shared/database/config.ts index fc502d566..0cafa4cee 100644 --- a/src/shared/database/config.ts +++ b/src/shared/database/config.ts @@ -1,4 +1,8 @@ -import { getConsentRequired } from '../helpers/localStorage'; +import { + getConsentGiven as getStorageConsentGiven, + getConsentRequired, + setConsentGiven as setStorageConsentGiven, +} from '../helpers/localStorage'; import Log from '../libraries/Log'; import { db, getIdsValue, getOptionsValue } from './client'; import type { AppState } from './types'; @@ -69,9 +73,15 @@ export const setAppState = async (appState: AppState) => { }); }; -// make sure to also set OneSignal._consentGiven when updating 'userConsent' +// make sure to also set OneSignal._consentGiven when updating consent export const getConsentGiven = async () => { - return (await getOptionsValue('userConsent')) ?? false; + const stored = getStorageConsentGiven(); + if (stored !== null) return stored; + + // Migrate consent persisted by older SDK versions that wrote it to IndexedDB. + const legacy = (await getOptionsValue('userConsent')) ?? false; + setStorageConsentGiven(legacy); + return legacy; }; export const isConsentRequiredButNotGiven = () => { diff --git a/src/shared/helpers/localStorage.ts b/src/shared/helpers/localStorage.ts index fc1b62653..254e6b0c2 100644 --- a/src/shared/helpers/localStorage.ts +++ b/src/shared/helpers/localStorage.ts @@ -2,6 +2,7 @@ const IS_OPTED_OUT = 'isOptedOut'; const IS_PUSH_NOTIFICATIONS_ENABLED = 'isPushNotificationsEnabled'; const PAGE_VIEWS = 'os_pageViews'; const REQUIRES_PRIVACY_CONSENT = 'requiresPrivacyConsent'; +const USER_CONSENT = 'userConsent'; /** * Used in OneSignal initialization to dedupe local storage subscription options already being saved to IndexedDB. @@ -22,6 +23,20 @@ export function getConsentRequired(): boolean { return localStorage.getItem(REQUIRES_PRIVACY_CONSENT) === 'true' || requiresUserPrivacyConsent; } +// Persisted in localStorage rather than IndexedDB: it's a privacy/legal opt-out +// that isn't re-derivable from any other source, and on a wedged iOS Safari PWA +// an IndexedDB write can be silently dropped, losing a revocation across reloads. +export function setConsentGiven(value: boolean): void { + localStorage.setItem(USER_CONSENT, value.toString()); +} + +// Returns null when no value has been stored, so callers can fall back to the +// legacy IndexedDB row for one-time migration. +export function getConsentGiven(): boolean | null { + const value = localStorage.getItem(USER_CONSENT); + return value === null ? null : value === 'true'; +} + export function setLocalPageViewCount(count: number): void { localStorage.setItem(PAGE_VIEWS, count.toString()); }