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
5 changes: 5 additions & 0 deletions packages/notification-services-controller/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Added

- Add `registerPushNotifications` to `NotificationServicesControllerEnableNotificationsOptions` so clients can enable MetaMask notifications without registering push notifications. ([#8782](https://github.com/MetaMask/core/pull/8782))
- Add optional mobile OS and app version metadata to push token registrations so clients can provide Firebase error attribution data. ([#8782](https://github.com/MetaMask/core/pull/8782))

## [24.0.0]

### Added
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@ export type NotificationServicesControllerSetFeatureAnnouncementsEnabledAction =
* Used only during initialization to seed marketing push notifications.
* @param opts.productAnnouncementEnabled - The user's product-announcement flag.
* Used only during initialization to seed marketing in-app notifications.
* @param opts.registerPushNotifications - Whether to attempt FCM/device push registration.
* @returns The updated or newly created user storage.
* @throws {Error} Throws an error if unauthenticated or from other operations.
*/
Expand All @@ -72,7 +73,7 @@ export type NotificationServicesControllerCreateOnChainTriggersAction = {
* Enables all MetaMask notifications for the user.
* This is identical flow when initializing notifications for the first time.
*
* @param opts - Optional settings for first-time AUS notification preferences initialization.
* @param opts - Optional options to mutate this functionality.
* @throws {Error} If there is an error during the process of enabling notifications.
*/
export type NotificationServicesControllerEnableMetamaskNotificationsAction = {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -579,6 +579,45 @@ describe('NotificationServicesController', () => {
]);
});

it('skips push registration when registerPushNotifications is false', async () => {
const {
messenger,
mockEnablePushNotifications,
mockGetConfig,
mockUpdateNotifications,
mockKeyringControllerGetState,
} = arrangeMocks({
configurePrefs: (mock) => mock.mockResolvedValueOnce(null),
});

mockKeyringControllerGetState.mockReturnValue({
isUnlocked: true,
keyrings: [
{
accounts: [ADDRESS_1],
type: KeyringTypes.hd,
metadata: { id: 'srp-1', name: 'SRP 1' },
},
],
});
const mockTriggerQuery = mockGetOnChainNotificationsConfig();

const controller = new NotificationServicesController({
messenger,
env: { featureAnnouncements: featureAnnouncementsEnv },
});

await controller.createOnChainTriggers({
registerPushNotifications: false,
});

expect(mockGetConfig).toHaveBeenCalled();
expect(mockTriggerQuery.isDone()).toBe(true);
expect(mockUpdateNotifications).toHaveBeenCalled();
expect(controller.state.isNotificationServicesEnabled).toBe(true);
expect(mockEnablePushNotifications).not.toHaveBeenCalled();
});

it('enables all wallet-activity accounts when Trigger API has no enabled accounts for first-time setup', async () => {
const {
messenger,
Expand Down Expand Up @@ -1447,6 +1486,28 @@ describe('NotificationServicesController', () => {
expect(mocks.mockUpdateNotifications).toHaveBeenCalled();
});

it('forwards registerPushNotifications false when enabling MetaMask notifications', async () => {
const mocks = arrangeMocks({
configurePrefs: (mock) => mock.mockResolvedValueOnce(null),
});
const mockTriggerQuery = mockGetOnChainNotificationsConfig();

const controller = new NotificationServicesController({
messenger: mocks.messenger,
env: { featureAnnouncements: featureAnnouncementsEnv },
});

await controller.enableMetamaskNotifications({
registerPushNotifications: false,
});

expect(mocks.mockGetConfig).toHaveBeenCalled();
expect(mockTriggerQuery.isDone()).toBe(true);
expect(mocks.mockUpdateNotifications).toHaveBeenCalled();
expect(controller.state.isNotificationServicesEnabled).toBe(true);
expect(mocks.mockEnablePushNotifications).not.toHaveBeenCalled();
});

it('should not create new notification subscriptions when enabling an account that already has notifications', async () => {
const mocks = arrangeMocks({
// Mock fully-initialized existing notifications
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -204,8 +204,22 @@ export type NotificationServicesControllerEnableNotificationsOptions = {
* in-app notifications.
*/
productAnnouncementEnabled?: boolean;
/**
* Whether to attempt FCM/device push registration after notification
* preferences are initialized or refreshed. This does not request OS push
* permission.
*
* @default true
*/
registerPushNotifications?: boolean;
};

export type NotificationServicesControllerCreateOnChainTriggersOptions =
NotificationServicesControllerEnableNotificationsOptions;

export type NotificationServicesControllerEnableMetamaskNotificationsOptions =
NotificationServicesControllerEnableNotificationsOptions;

const locallyPersistedNotificationTypes = new Set<TRIGGER_TYPES>([
TRIGGER_TYPES.SNAP,
]);
Expand Down Expand Up @@ -1016,11 +1030,12 @@ export class NotificationServicesController extends BaseController<
* Used only during initialization to seed marketing push notifications.
* @param opts.productAnnouncementEnabled - The user's product-announcement flag.
* Used only during initialization to seed marketing in-app notifications.
* @param opts.registerPushNotifications - Whether to attempt FCM/device push registration.
* @returns The updated or newly created user storage.
* @throws {Error} Throws an error if unauthenticated or from other operations.
*/
public async createOnChainTriggers(
opts?: NotificationServicesControllerEnableNotificationsOptions,
opts: NotificationServicesControllerCreateOnChainTriggersOptions = {},
): Promise<void> {
try {
this.#setIsUpdatingMetamaskNotifications(true);
Expand Down Expand Up @@ -1070,12 +1085,14 @@ export class NotificationServicesController extends BaseController<
.filter((account) => account.enabled)
.map((account) => account.address);

// 2. Lazily enable push notifications (FCM may take some time, so keeps UI unblocked)
this.#pushNotifications
.enablePushNotifications(accountsWithNotifications)
.catch(() => {
// Do Nothing
});
if (opts.registerPushNotifications ?? true) {
// Attempt FCM/device registration only; clients must request OS permission separately.
this.#pushNotifications
.enablePushNotifications(accountsWithNotifications)
.catch(() => {
// Do Nothing
});
}

// Update the state of the controller
this.update((state) => {
Expand All @@ -1102,11 +1119,11 @@ export class NotificationServicesController extends BaseController<
* Enables all MetaMask notifications for the user.
* This is identical flow when initializing notifications for the first time.
*
* @param opts - Optional settings for first-time AUS notification preferences initialization.
* @param opts - Optional options to mutate this functionality.
* @throws {Error} If there is an error during the process of enabling notifications.
*/
public async enableMetamaskNotifications(
opts?: NotificationServicesControllerEnableNotificationsOptions,
opts: NotificationServicesControllerEnableMetamaskNotificationsOptions = {},
): Promise<void> {
try {
this.#setIsUpdatingMetamaskNotifications(true);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -124,6 +124,33 @@ describe('NotificationServicesPushController', () => {
});
});

it('should call activatePushNotifications with mobile OS and app version metadata', async () => {
const mocks = arrangeServicesMocks();
const { controller, messenger } = arrangeMockMessenger({
platform: 'mobile',
os: 'android',
appVersion: '7.42.0',
});
mockAuthBearerTokenCall(messenger);

await controller.enablePushNotifications(MOCK_ADDRESSES);

expect(mocks.activatePushNotificationsMock).toHaveBeenCalledWith({
bearerToken: MOCK_JWT,
addresses: MOCK_ADDRESSES,
env: expect.any(Object),
createRegToken: expect.any(Function),
regToken: {
platform: 'mobile',
locale: 'en',
oldToken: '',
os: 'android',
appVersion: '7.42.0',
},
controllerEnv: 'prd',
});
});

it('should not activate push notifications triggers if there is no auth bearer token', async () => {
const mocks = arrangeServicesMocks();
const { controller, messenger } = arrangeMockMessenger();
Expand Down Expand Up @@ -384,6 +411,37 @@ describe('NotificationServicesPushController', () => {
expect(result).toBe(true);
});

it('should call updateLinksAPI with mobile OS and app version metadata', async () => {
const mocks = arrangeServicesMocks();
const { controller, messenger } = arrangeMockMessenger({
platform: 'mobile',
os: 'ios',
appVersion: '7.42.0',
state: {
fcmToken: MOCK_FCM_TOKEN,
isPushEnabled: true,
isUpdatingFCMToken: false,
},
});
mockAuthBearerTokenCall(messenger);

const result = await controller.addPushNotificationLinks(MOCK_ADDRESSES);

expect(mocks.updateLinksAPIMock).toHaveBeenCalledWith({
bearerToken: MOCK_JWT,
addresses: MOCK_ADDRESSES,
regToken: {
token: MOCK_FCM_TOKEN,
platform: 'mobile',
locale: 'en',
os: 'ios',
appVersion: '7.42.0',
},
env: 'prd',
});
expect(result).toBe(true);
});

it('should return false when push feature is disabled', async () => {
const mocks = arrangeServicesMocks();
const { controller } = arrangeMockMessenger({
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import log from 'loglevel';
import type { Types } from '../NotificationServicesController';
import type { NotificationServicesPushControllerMethodActions } from './NotificationServicesPushController-method-action-types';
import type { ENV } from './services/endpoints';
import type { RegToken } from './services/services';
import {
activatePushNotifications,
deleteLinksAPI,
Expand Down Expand Up @@ -120,6 +121,11 @@ export type ControllerConfig = {
*/
getLocale?: () => string;

/**
* App or extension version to include when registering push tokens.
*/
appVersion?: string;

/**
* Global switch to determine to use push notifications
* Allows us to control Builds on extension (MV2 vs MV3)
Expand All @@ -131,6 +137,11 @@ export type ControllerConfig = {
*/
platform: 'extension' | 'mobile';

/**
* Mobile operating system to include when registering push tokens.
*/
os?: 'android' | 'ios';

/**
* Push Service Interface
* - create reg token
Expand All @@ -147,6 +158,11 @@ type StateCommand =
| { type: 'disable' }
| { type: 'update'; fcmToken: string };

type RegistrationTokenMetadata = Pick<
RegToken,
'appVersion' | 'locale' | 'os' | 'platform'
>;

/**
* Manages push notifications for the application, including enabling, disabling, and updating triggers for push notifications.
* This controller integrates with Firebase Cloud Messaging (FCM) to handle the registration and management of push notifications.
Expand Down Expand Up @@ -239,6 +255,23 @@ export class NotificationServicesPushController extends BaseController<
}
}

#getRegistrationTokenMetadata(): RegistrationTokenMetadata {
const tokenMetadata: RegistrationTokenMetadata = {
platform: this.#config.platform,
locale: this.#config.getLocale?.() ?? 'en',
};

if (this.#config.os) {
tokenMetadata.os = this.#config.os;
}

if (this.#config.appVersion) {
tokenMetadata.appVersion = this.#config.appVersion;
}

return tokenMetadata;
}

public async subscribeToPushNotifications(): Promise<void> {
if (!this.#config.isPushFeatureEnabled) {
return;
Expand Down Expand Up @@ -293,8 +326,7 @@ export class NotificationServicesPushController extends BaseController<
env: this.#env,
createRegToken: this.#config.pushService.createRegToken,
regToken: {
platform: this.#config.platform,
locale: this.#config.getLocale?.() ?? 'en',
...this.#getRegistrationTokenMetadata(),
oldToken: this.state.fcmToken,
},
controllerEnv: this.#config.env ?? 'prd',
Expand Down Expand Up @@ -383,8 +415,7 @@ export class NotificationServicesPushController extends BaseController<
addresses,
regToken: {
token: this.state.fcmToken,
platform: this.#config.platform,
locale: this.#config.getLocale?.() ?? 'en',
...this.#getRegistrationTokenMetadata(),
},
env: this.#config.env ?? 'prd',
});
Expand Down Expand Up @@ -453,8 +484,7 @@ export class NotificationServicesPushController extends BaseController<
env: this.#env,
createRegToken: this.#config.pushService.createRegToken,
regToken: {
platform: this.#config.platform,
locale: this.#config.getLocale?.() ?? 'en',
...this.#getRegistrationTokenMetadata(),
oldToken: this.state.fcmToken,
},
controllerEnv: this.#config.env ?? 'prd',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,16 +12,21 @@ type MockReply = {

export const mockEndpointUpdatePushNotificationLinks = (
mockReply?: MockReply,
requestBody?: nock.RequestBodyMatcher,
): nock.Scope => {
const mockResponse = getMockUpdatePushNotificationLinksResponse();
const reply = mockReply ?? {
status: 204,
body: mockResponse.response,
};

const mockEndpoint = nock(mockResponse.url).post('').reply(reply.status);
const endpoint = nock(mockResponse.url);
const mockEndpoint =
requestBody === undefined
? endpoint.post('')
: endpoint.post('', requestBody);

return mockEndpoint;
return mockEndpoint.reply(reply.status);
};

export const mockEndpointDeletePushNotificationLinks = (
Expand Down
Loading
Loading