diff --git a/Gruntfile.js b/Gruntfile.js index a19bc21ea..3df615efc 100644 --- a/Gruntfile.js +++ b/Gruntfile.js @@ -112,6 +112,7 @@ module.exports = function (grunt) { esbuild.build(esbuildConfig.webConfig), esbuild.build(esbuildConfig.minifiedWebConfig), esbuild.build(esbuildConfig.modularConfig), + esbuild.build(esbuildConfig.pushPluginConfig), ]) .then(() => { console.log('esbuild succeeded'); diff --git a/ably.d.ts b/ably.d.ts index b98265c5f..bfeca338d 100644 --- a/ably.d.ts +++ b/ably.d.ts @@ -594,6 +594,11 @@ export interface ClientOptions extends AuthOptions { * @defaultValue 65536 */ maxMessageSize?: number; + + /** + * A URL pointing to a service worker script which is used as the target for web push notifications. + */ + pushServiceWorkerUrl?: string; } /** @@ -604,6 +609,11 @@ export interface CorePlugins { * A plugin capable of decoding `vcdiff`-encoded messages. For more information on how to configure a channel to use delta encoding, see the [documentation for the `@ably-forks/vcdiff-decoder` package](https://github.com/ably-forks/vcdiff-decoder#usage). */ vcdiff?: any; + + /** + * A plugin which allows the client to be the target of push notifications. + */ + push?: unknown; } /** @@ -1494,6 +1504,37 @@ export type recoverConnectionCallback = ( callback: recoverConnectionCompletionCallback, ) => void; +/** + * A standard callback format which is invoked upon completion of a task. + * + * @param err - An error object if the task failed. + * @param result - The result of the task, if any. + */ +type StandardCallback = (err: ErrorInfo | null, result?: T) => void; + +/** + * A function passed to {@link Push.activate} in order to override the default implementation to register a device for push activation. + * + * @param device - A DeviceDetails object representing the local device + * @param callback - A callback to be invoked when the registration is complete + */ +export type RegisterCallback = (device: DeviceDetails, callback: StandardCallback) => void; + +/** + * A function passed to {@link Push.activate} in order to override the default implementation to deregister a device for push activation. + * + * @param device - A DeviceDetails object representing the local device + * @param callback - A callback to be invoked when the deregistration is complete + */ +export type DeregisterCallback = (device: DeviceDetails, callback: StandardCallback) => void; + +/** + * A callback which returns only an error, or null, when complete. + * + * @param error - The error if the task failed, or null not. + */ +export type ErrorCallback = (error: ErrorInfo | null) => void; + // Internal Interfaces // To allow a uniform (callback) interface between on and once even in the @@ -1927,6 +1968,39 @@ export declare interface RealtimePresence { leaveClient(clientId: string, data?: any): Promise; } +/** + * Enables devices to subscribe to push notifications for a channel. + */ +export declare interface PushChannel { + /** + * Subscribes the device to push notifications for the channel. + */ + subscribeDevice(): Promise; + + /** + * Unsubscribes the device from receiving push notifications for the channel. + */ + unsubscribeDevice(): Promise; + + /** + * Subscribes all devices associated with the current device's `clientId` to push notifications for the channel. + */ + subscribeClient(): Promise; + + /** + * Unsubscribes all devices associated with the current device's `clientId` from receiving push notifications for the channel. + */ + unsubscribeClient(): Promise; + + /** + * Retrieves all push subscriptions for the channel. Subscriptions can be filtered using a params object. + * + * @param params - An object containing key-value pairs to filter subscriptions by. Can contain `clientId`, `deviceId` or a combination of both, and a `limit` on the number of subscriptions returned, up to 1,000. + * @returns a {@link PaginatedResult} object containing an array of {@link PushChannelSubscription} objects. + */ + listSubscriptions(params?: Record): Promise>; +} + /** * Enables messages to be published and historic messages to be retrieved for a channel. */ @@ -1940,6 +2014,10 @@ export declare interface Channel { * A {@link Presence} object. */ presence: Presence; + /** + * A {@link PushChannel} object. + */ + push: PushChannel; /** * Retrieves a {@link PaginatedResult} object, containing an array of historical {@link InboundMessage} objects for the channel. If the channel is configured to persist messages, then messages can be retrieved from history for up to 72 hours in the past. If not, messages can only be retrieved from history for up to two minutes in the past. * @@ -2540,6 +2618,21 @@ export declare interface Push { * A {@link PushAdmin} object. */ admin: PushAdmin; + + /** + * Activates the device for push notifications. Subsequently registers the device with Ably and stores the deviceIdentityToken in local storage. + * + * @param registerCallback - A function passed to override the default implementation to register the local device for push activation. + * @param updateFailedCallback - A callback to be invoked when the device registration failed to update. + */ + activate(registerCallback?: RegisterCallback, updateFailedCallback?: ErrorCallback): Promise; + + /** + * Deactivates the device from receiving push notifications. + * + * @param deregisterCallback - A function passed to override the default implementation to deregister the local device for push activation. + */ + deactivate(deregisterCallback: DeregisterCallback): Promise; } /** diff --git a/grunt/esbuild/build.js b/grunt/esbuild/build.js index 16018560d..7fc89e32a 100644 --- a/grunt/esbuild/build.js +++ b/grunt/esbuild/build.js @@ -54,9 +54,18 @@ const nodeConfig = { external: ['ws', 'got'], }; +const pushPluginConfig = { + ...createBaseConfig(), + entryPoints: ['src/plugins/push/index.ts'], + plugins: [umdWrapper.default({ libraryName: 'AblyPushPlugin', amdNamedModule: false })], + outfile: 'build/push.js', + external: ['ulid'], +}; + module.exports = { webConfig, minifiedWebConfig, modularConfig, nodeConfig, + pushPluginConfig, }; diff --git a/package.json b/package.json index 239f5b129..e65ddacbb 100644 --- a/package.json +++ b/package.json @@ -25,11 +25,16 @@ "./react": { "require": "./react/cjs/index.js", "import": "./react/mjs/index.js" + }, + "./push": { + "types": "./push.d.ts", + "import": "./build/push.js" } }, "files": [ "build/**", "ably.d.ts", + "push.d.ts", "modular.d.ts", "resources/**", "src/**", diff --git a/push.d.ts b/push.d.ts new file mode 100644 index 000000000..865aa88d1 --- /dev/null +++ b/push.d.ts @@ -0,0 +1,28 @@ +// The ESLint warning is triggered because we only use these types in a documentation comment. +/* eslint-disable no-unused-vars, @typescript-eslint/no-unused-vars */ +import { RealtimeClient, RestClient } from './ably'; +import { BaseRest, BaseRealtime, Rest } from './modular'; +/* eslint-enable no-unused-vars, @typescript-eslint/no-unused-vars */ + +/** + * Provides a {@link RestClient} or {@link RealtimeClient} instance with the ability to be activated as a target for push notifications. + * + * To create a client that includes this plugin, include it in the client options that you pass to the {@link RestClient.constructor} or {@link RealtimeClient.constructor}: + * + * ```javascript + * import { Realtime } from 'ably'; + * import Push from 'ably/push'; + * const realtime = new Realtime({ ...options, plugins: { Push } }); + * ``` + * + * The Push plugin can also be used with a {@link BaseRest} or {@link BaseRealtime} client, with the additional requirement that you must also use the {@link Rest} plugin + * + * ```javascript + * import { BaseRealtime, Rest, WebSocketTransport, FetchRequest } from 'ably/modular'; + * import Push from 'ably/push'; + * const realtime = new BaseRealtime({ ...options, plugins: { Rest, WebSocketTransport, FetchRequest, Push } }); + * ``` + */ +declare const Push: any; + +export = Push; diff --git a/scripts/moduleReport.ts b/scripts/moduleReport.ts index 6b0349192..60737bff8 100644 --- a/scripts/moduleReport.ts +++ b/scripts/moduleReport.ts @@ -6,7 +6,7 @@ import { gzip } from 'zlib'; import Table from 'cli-table'; // The maximum size we allow for a minimal useful Realtime bundle (i.e. one that can subscribe to a channel) -const minimalUsefulRealtimeBundleSizeThresholdsKiB = { raw: 96, gzip: 29 }; +const minimalUsefulRealtimeBundleSizeThresholdsKiB = { raw: 98, gzip: 30 }; const baseClientNames = ['BaseRest', 'BaseRealtime']; diff --git a/src/common/lib/client/baseclient.ts b/src/common/lib/client/baseclient.ts index 98a4720f0..0b37e1617 100644 --- a/src/common/lib/client/baseclient.ts +++ b/src/common/lib/client/baseclient.ts @@ -7,6 +7,7 @@ import Stats from '../types/stats'; import { Http, RequestParams } from '../../types/http'; import ClientOptions, { NormalisedClientOptions } from '../../types/ClientOptions'; import * as API from '../../../../ably'; +import * as Utils from '../util/utils'; import Platform from '../../platform'; import { Rest } from './rest'; @@ -15,6 +16,7 @@ import { throwMissingPluginError } from '../util/utils'; import { MsgPack } from 'common/types/msgpack'; import { HTTPRequestImplementations } from 'platform/web/lib/http/http'; import { FilteredSubscriptions } from './filteredsubscriptions'; +import type { LocalDevice } from 'plugins/push/pushactivation'; type BatchResult = API.BatchResult; type BatchPublishSpec = API.BatchPublishSpec; @@ -45,6 +47,7 @@ class BaseClient { readonly _additionalHTTPRequestImplementations: HTTPRequestImplementations | null; private readonly __FilteredSubscriptions: typeof FilteredSubscriptions | null; readonly logger: Logger; + _device?: LocalDevice; constructor(options: ClientOptions) { this._additionalHTTPRequestImplementations = options.plugins ?? null; @@ -119,6 +122,16 @@ class BaseClient { return this.rest.push; } + get device() { + if (!this.options.plugins?.Push || !this.push.LocalDevice) { + throwMissingPluginError('Push'); + } + if (!this._device) { + this._device = this.push.LocalDevice.load(this); + } + return this._device; + } + baseUri(host: string) { return Defaults.getHttpScheme(this.options) + host + ':' + Defaults.getPort(this.options, false); } @@ -157,6 +170,15 @@ class BaseClient { } static Platform = Platform; + + /** + * These exports are for use by UMD plugins; reason being so that constructors and static methods can be accessed by these plugins without needing to import the classes directly and result in the class existing in both the plugin and the core library. + */ + Platform = Platform; + ErrorInfo = ErrorInfo; + Logger = Logger; + Defaults = Defaults; + Utils = Utils; } export default BaseClient; diff --git a/src/common/lib/client/modularplugins.ts b/src/common/lib/client/modularplugins.ts index 28fd50b89..7bb05e30b 100644 --- a/src/common/lib/client/modularplugins.ts +++ b/src/common/lib/client/modularplugins.ts @@ -10,6 +10,7 @@ import { fromValuesArray as presenceMessagesFromValuesArray, } from '../types/presencemessage'; import { TransportCtor } from '../transport/transport'; +import * as PushPlugin from 'plugins/push'; export interface PresenceMessagePlugin { presenceMessageFromValues: typeof presenceMessageFromValues; @@ -30,6 +31,7 @@ export interface ModularPlugins { XHRRequest?: typeof XHRRequest; FetchRequest?: typeof fetchRequest; MessageInteractions?: typeof FilteredSubscriptions; + Push?: typeof PushPlugin; } export const allCommonModularPlugins: ModularPlugins = { Rest }; diff --git a/src/common/lib/client/push.ts b/src/common/lib/client/push.ts index 65c5f68cc..83632cb74 100644 --- a/src/common/lib/client/push.ts +++ b/src/common/lib/client/push.ts @@ -6,14 +6,83 @@ import ErrorInfo from '../types/errorinfo'; import PushChannelSubscription from '../types/pushchannelsubscription'; import BaseClient from './baseclient'; import Defaults from '../util/defaults'; +import type { + ActivationStateMachine, + DeregisterCallback, + LocalDeviceFactory, + RegisterCallback, +} from 'plugins/push/pushactivation'; +import Platform from 'common/platform'; +import type { ErrCallback } from 'common/types/utils'; class Push { client: BaseClient; admin: Admin; + stateMachine?: ActivationStateMachine; + LocalDevice?: LocalDeviceFactory; constructor(client: BaseClient) { this.client = client; this.admin = new Admin(client); + if (Platform.Config.push && client.options.plugins?.Push) { + this.stateMachine = new client.options.plugins.Push.ActivationStateMachine(client); + this.LocalDevice = client.options.plugins.Push.localDeviceFactory(DeviceDetails); + } + } + + async activate(registerCallback?: RegisterCallback, updateFailedCallback?: ErrCallback) { + await new Promise((resolve, reject) => { + if (!this.client.options.plugins?.Push) { + reject(Utils.createMissingPluginError('Push')); + return; + } + if (!this.stateMachine) { + reject(new ErrorInfo('This platform is not supported as a target of push notifications', 40000, 400)); + return; + } + if (this.stateMachine.activatedCallback) { + reject(new ErrorInfo('Activation already in progress', 40000, 400)); + return; + } + this.stateMachine.activatedCallback = (err: ErrorInfo) => { + if (err) { + reject(err); + return; + } + resolve(); + }; + this.stateMachine.updateFailedCallback = updateFailedCallback; + this.stateMachine.handleEvent( + new this.client.options.plugins.Push.CalledActivate(this.stateMachine, registerCallback), + ); + }); + } + + async deactivate(deregisterCallback: DeregisterCallback) { + await new Promise((resolve, reject) => { + if (!this.client.options.plugins?.Push) { + reject(Utils.createMissingPluginError('Push')); + return; + } + if (!this.stateMachine) { + reject(new ErrorInfo('This platform is not supported as a target of push notifications', 40000, 400)); + return; + } + if (this.stateMachine.deactivatedCallback) { + reject(new ErrorInfo('Deactivation already in progress', 40000, 400)); + return; + } + this.stateMachine.deactivatedCallback = (err: ErrorInfo) => { + if (err) { + reject(err); + return; + } + resolve(); + }; + this.stateMachine.handleEvent( + new this.client.options.plugins.Push.CalledDeactivate(this.stateMachine, deregisterCallback), + ); + }); } } diff --git a/src/common/lib/client/realtimechannel.ts b/src/common/lib/client/realtimechannel.ts index e0a561492..4639bfcc8 100644 --- a/src/common/lib/client/realtimechannel.ts +++ b/src/common/lib/client/realtimechannel.ts @@ -28,6 +28,7 @@ import BaseRealtime from './baserealtime'; import { ChannelOptions } from '../../types/channel'; import { normaliseChannelOptions } from '../util/defaults'; import { PaginatedResult } from './paginatedresource'; +import type { PushChannel } from 'plugins/push'; interface RealtimeHistoryParams { start?: number; @@ -97,6 +98,7 @@ class RealtimeChannel extends EventEmitter { stateTimer?: number | NodeJS.Timeout | null; retryTimer?: number | NodeJS.Timeout | null; retryCount: number = 0; + _push?: PushChannel; constructor(client: BaseRealtime, name: string, options?: API.ChannelOptions) { super(client.logger); @@ -131,6 +133,17 @@ class RealtimeChannel extends EventEmitter { /* Only differences between this and the public event emitter is that this emits an * update event for all ATTACHEDs, whether resumed or not */ this._allChannelChanges = new EventEmitter(this.logger); + + if (client.options.plugins?.Push) { + this._push = new client.options.plugins.Push.PushChannel(this); + } + } + + get push() { + if (!this._push) { + Utils.throwMissingPluginError('Push'); + } + return this._push; } invalidStateError(): ErrorInfo { diff --git a/src/common/lib/client/rest.ts b/src/common/lib/client/rest.ts index 89bdfd8ba..17af32401 100644 --- a/src/common/lib/client/rest.ts +++ b/src/common/lib/client/rest.ts @@ -16,6 +16,7 @@ import BaseClient from './baseclient'; import { useTokenAuth } from './auth'; import { RestChannelMixin } from './restchannelmixin'; import { RestPresenceMixin } from './restpresencemixin'; +import DeviceDetails from '../types/devicedetails'; type BatchResult = API.BatchResult; @@ -41,6 +42,10 @@ export class Rest { readonly channelMixin = RestChannelMixin; readonly presenceMixin = RestPresenceMixin; + // exposed for plugins but shouldn't be bundled with minimal realtime + Resource = Resource; + DeviceDetails = DeviceDetails; + constructor(client: BaseClient) { this.client = client; this.channels = new Channels(this.client); diff --git a/src/common/lib/client/restchannel.ts b/src/common/lib/client/restchannel.ts index ff086a9db..3138e8611 100644 --- a/src/common/lib/client/restchannel.ts +++ b/src/common/lib/client/restchannel.ts @@ -18,6 +18,7 @@ import * as API from '../../../../ably'; import Defaults, { normaliseChannelOptions } from '../util/defaults'; import { RestHistoryParams } from './restchannelmixin'; import { RequestBody } from 'common/types/http'; +import type { PushChannel } from 'plugins/push'; const MSG_ID_ENTROPY_BYTES = 9; @@ -32,6 +33,7 @@ class RestChannel { name: string; presence: RestPresence; channelOptions: ChannelOptions; + _push?: PushChannel; constructor(client: BaseRest, name: string, channelOptions?: ChannelOptions) { Logger.logAction(client.logger, Logger.LOG_MINOR, 'RestChannel()', 'started; name = ' + name); @@ -39,6 +41,16 @@ class RestChannel { this.client = client; this.presence = new RestPresence(this); this.channelOptions = normaliseChannelOptions(client._Crypto ?? null, this.logger, channelOptions); + if (client.options.plugins?.Push) { + this._push = new client.options.plugins.Push.PushChannel(this); + } + } + + get push() { + if (!this._push) { + Utils.throwMissingPluginError('Push'); + } + return this._push; } get logger(): Logger { diff --git a/src/common/lib/types/devicedetails.ts b/src/common/lib/types/devicedetails.ts index 24c64ed8a..10566d3da 100644 --- a/src/common/lib/types/devicedetails.ts +++ b/src/common/lib/types/devicedetails.ts @@ -1,8 +1,9 @@ import { MsgPack } from 'common/types/msgpack'; +import type { LocalDevice } from 'plugins/push/pushactivation'; import * as Utils from '../util/utils'; import ErrorInfo, { IConvertibleToErrorInfo } from './errorinfo'; -enum DeviceFormFactor { +export enum DeviceFormFactor { Phone = 'phone', Tablet = 'tablet', Desktop = 'desktop', @@ -13,7 +14,7 @@ enum DeviceFormFactor { Other = 'other', } -enum DevicePlatform { +export enum DevicePlatform { Android = 'android', IOS = 'ios', Browser = 'browser', @@ -21,9 +22,24 @@ enum DevicePlatform { type DevicePushState = 'ACTIVE' | 'FAILING' | 'FAILED'; -type DevicePushDetails = { +interface WebPushRecipient { + transportType: 'web'; + targetUrl: string; + encryptionKey: string; +} + +interface PushChannelRecipient { + transportType: 'ablyChannel'; + channel: string; + ablyKey: string; + ablyUrl: string; +} + +type PushRecipient = WebPushRecipient | PushChannelRecipient; + +export type DevicePushDetails = { error?: ErrorInfo; - recipient?: string; + recipient?: PushRecipient; state?: DevicePushState; metadata?: string; }; @@ -96,6 +112,10 @@ class DeviceDetails { return Object.assign(new DeviceDetails(), values); } + static fromLocalDevice(device: LocalDevice): DeviceDetails { + return Object.assign(new DeviceDetails(), device); + } + static fromValuesArray(values: Array>): DeviceDetails[] { const count = values.length, result = new Array(count); diff --git a/src/common/types/ClientOptions.ts b/src/common/types/ClientOptions.ts index e0a44064c..5930d2948 100644 --- a/src/common/types/ClientOptions.ts +++ b/src/common/types/ClientOptions.ts @@ -1,13 +1,14 @@ import { Modify } from './utils'; import * as API from '../../../ably'; import { ModularPlugins } from 'common/lib/client/modularplugins'; +import { StandardPlugins } from 'plugins'; export type RestAgentOptions = { keepAlive: boolean; maxSockets: number; }; -export default interface ClientOptions extends API.ClientOptions { +export default interface ClientOptions extends API.ClientOptions { restAgentOptions?: RestAgentOptions; pushFullWait?: boolean; agents?: Record; diff --git a/src/common/types/IPlatformConfig.d.ts b/src/common/types/IPlatformConfig.d.ts index 7ceeddc1c..255b948ef 100644 --- a/src/common/types/IPlatformConfig.d.ts +++ b/src/common/types/IPlatformConfig.d.ts @@ -1,3 +1,5 @@ +import { DeviceFormFactor, DevicePlatform } from 'common/lib/types/devicedetails'; + /** * Interface for common config properties shared between all platforms and that are relevant for all platforms. * @@ -15,6 +17,7 @@ export interface ICommonPlatformConfig { inspect: (value: unknown) => string; stringByteSize: Buffer.byteLength; getRandomArrayBuffer: (byteLength: number) => Promise; + push?: IPlatformPushConfig; } /** @@ -37,4 +40,17 @@ export interface ISpecificPlatformConfig { isWebworker?: boolean; } +export interface IPlatformPushStorage { + get(name: string): string; + set(name: string, value: string); + remove(name: string); +} + +export interface IPlatformPushConfig { + platform: DevicePlatform; + formFactor: DeviceFormFactor; + storage: IPlatformPushStorage; + getPushDeviceDetails?(machine: any); +} + export type IPlatformConfig = ICommonPlatformConfig & ISpecificPlatformConfig; diff --git a/src/plugins/index.d.ts b/src/plugins/index.d.ts new file mode 100644 index 000000000..c91240b56 --- /dev/null +++ b/src/plugins/index.d.ts @@ -0,0 +1,5 @@ +import Push from './push'; + +export interface StandardPlugins { + Push?: typeof Push; +} diff --git a/src/plugins/push/index.ts b/src/plugins/push/index.ts new file mode 100644 index 000000000..dd2d052d8 --- /dev/null +++ b/src/plugins/push/index.ts @@ -0,0 +1,18 @@ +import PushChannel from './pushchannel'; +import { ActivationStateMachine, CalledActivate, CalledDeactivate, localDeviceFactory } from './pushactivation'; + +export { + ActivationStateMachine, + localDeviceFactory, + CalledActivate, + CalledDeactivate, + PushChannel, +}; + +export default { + ActivationStateMachine, + localDeviceFactory, + CalledActivate, + CalledDeactivate, + PushChannel, +}; diff --git a/src/plugins/push/pushactivation.ts b/src/plugins/push/pushactivation.ts new file mode 100644 index 000000000..ab3064a18 --- /dev/null +++ b/src/plugins/push/pushactivation.ts @@ -0,0 +1,687 @@ +import * as API from '../../../ably'; +import { IPlatformPushConfig } from 'common/types/IPlatformConfig'; +import { ulid } from 'ulid'; +import type { ErrCallback, StandardCallback } from 'common/types/utils'; +import type ErrorInfo from 'common/lib/types/errorinfo'; +import DeviceDetails, { DevicePlatform, DevicePushDetails } from 'common/lib/types/devicedetails'; +import { getW3CPushDeviceDetails } from './getW3CDeviceDetails'; +import type BaseClient from 'common/lib/client/baseclient'; + +const persistKeys = { + deviceId: 'ably.push.deviceId', + deviceSecret: 'ably.push.deviceSecret', + deviceIdentityToken: 'ably.push.deviceIdentityToken', + pushRecipient: 'ably.push.pushRecipient', + activationState: 'ably.push.activationState', +}; + +type DeviceRegistration = Required<{ + [K in keyof DeviceDetails]: K extends 'deviceIdentityToken' ? API.TokenDetails : DeviceDetails[K]; +}>; + +export type RegisterCallback = (device: DeviceDetails, callback: StandardCallback) => void; +export type DeregisterCallback = (device: DeviceDetails, callback: StandardCallback) => void; + +export interface LocalDeviceAuthDetails { + headers: Record; + params: Record; +} + +export type LocalDeviceFactory = ReturnType; +export type LocalDevice = ReturnType; + +/** + * LocalDevice extends DeviceDetails, but DeviceDetails is part of core ably-js and LocalDevice is part of the Push plugin + * In order to avoid bundling the DeviceDetails class in both core ably-js and the plugin, the LocalDevice is exported as + * a factory, and the DeviceDetails constructor is used to create the class declaration for LocalDevice when the plugin is + * loaded. + */ +export function localDeviceFactory(deviceDetails: typeof DeviceDetails) { + return class LocalDevice extends deviceDetails { + rest: BaseClient; + push: DevicePushDetails; + + private constructor(rest: BaseClient) { + super(); + this.push = {}; + this.rest = rest; + } + + static load(rest: BaseClient) { + const device = new LocalDevice(rest); + device.loadPersisted(); + return device; + } + + loadPersisted() { + const Platform = this.rest.Platform; + if (!Platform.Config.push) { + throw new this.rest.ErrorInfo('Push activation is not available on this platform', 40000, 400); + } + this.platform = Platform.Config.push.platform; + this.clientId = this.rest.auth.clientId ?? undefined; + this.formFactor = Platform.Config.push.formFactor; + this.id = Platform.Config.push.storage.get(persistKeys.deviceId); + + if (this.id) { + this.deviceSecret = Platform.Config.push.storage.get(persistKeys.deviceSecret) || undefined; + this.deviceIdentityToken = JSON.parse( + Platform.Config.push.storage.get(persistKeys.deviceIdentityToken) || 'null', + ); + this.push.recipient = JSON.parse(Platform.Config.push.storage.get(persistKeys.pushRecipient) || 'null'); + } else { + this.resetId(); + } + } + + persist() { + const config = this.rest.Platform.Config; + if (!config.push) { + throw new this.rest.ErrorInfo('Push activation is not available on this platform', 40000, 400); + } + if (this.id) { + config.push.storage.set(persistKeys.deviceId, this.id); + } + if (this.deviceSecret) { + config.push.storage.set(persistKeys.deviceSecret, this.deviceSecret); + } + if (this.deviceIdentityToken) { + config.push.storage.set(persistKeys.deviceIdentityToken, JSON.stringify(this.deviceIdentityToken)); + } + if (this.push.recipient) { + config.push.storage.set(persistKeys.pushRecipient, JSON.stringify(this.push.recipient)); + } + } + + resetId() { + this.id = ulid(); + this.deviceSecret = ulid(); + this.persist(); + } + + getAuthDetails( + rest: BaseClient, + headers: Record, + params: Record, + ): LocalDeviceAuthDetails { + if (!this.deviceIdentityToken) { + throw new this.rest.ErrorInfo('Unable to update device registration; no deviceIdentityToken', 50000, 500); + } + if (this.rest.http.supportsAuthHeaders) { + return { + headers: rest.Utils.mixin( + { authorization: 'Bearer ' + rest.Utils.toBase64(this.deviceIdentityToken) }, + headers, + ) as Record, + params, + }; + } else { + return { headers, params: rest.Utils.mixin({ access_token: this.deviceIdentityToken }, params) }; + } + } + }; +} + +export class ActivationStateMachine { + client: BaseClient; + current: ActivationState; + pendingEvents: ActivationEvent[]; + handling: boolean; + deactivatedCallback?: ErrCallback; + activatedCallback?: ErrCallback; + _pushConfig?: IPlatformPushConfig; + registerCallback?: RegisterCallback; + deregisterCallback?: DeregisterCallback; + updateFailedCallback?: ErrCallback; + + // Used for testing + pushManager?: PushManager; + + // exported for testing + GettingPushDeviceDetailsFailed = GettingPushDeviceDetailsFailed; + GotPushDeviceDetails = GotPushDeviceDetails; + + constructor(rest: BaseClient) { + this.client = rest; + this._pushConfig = rest.Platform.Config.push; + this.current = new ActivationStates[ + (this.pushConfig.storage.get(persistKeys.activationState) as ActivationStateName) || 'NotActivated' + ](null); + this.pendingEvents = []; + this.handling = false; + } + + get pushConfig() { + if (!this._pushConfig) { + throw new this.client.ErrorInfo('This platform is not supported as a target of push notifications', 40000, 400); + } + return this._pushConfig; + } + + persist() { + if (isPersistentState(this.current)) { + this.pushConfig.storage.set(persistKeys.activationState, this.current.name); + } + } + + callUpdateRegistrationFailedCallback(reason: ErrorInfo) { + if (this.updateFailedCallback) { + this.updateFailedCallback(reason); + } else { + this.client.Logger.logAction( + this.client.logger, + this.client.Logger.LOG_ERROR, + 'UpdateRegistrationFailed', + 'Failed updating device push registration: ' + this.client.Utils.inspectError(reason), + ); + } + } + + callCustomRegisterer(device: LocalDevice, isNew: boolean) { + this.registerCallback?.(device, (error: ErrorInfo, deviceRegistration?: DeviceRegistration) => { + if (error) { + if (isNew) { + this.handleEvent(new GettingDeviceRegistrationFailed(error)); + } else { + this.handleEvent(new SyncRegistrationFailed(error)); + } + return; + } + + if (!deviceRegistration) { + this.handleEvent( + new GettingDeviceRegistrationFailed( + new this.client.ErrorInfo('registerCallback did not return deviceRegistration', 40000, 400), + ), + ); + } + + if (isNew) { + this.handleEvent(new GotDeviceRegistration(deviceRegistration as any)); + } else { + this.handleEvent(new RegistrationSynced()); + } + }); + } + + callCustomDeregisterer(device: LocalDevice) { + this.deregisterCallback?.(device, (err: ErrorInfo) => { + if (err) { + this.handleEvent(new DeregistrationFailed(err)); + return; + } + this.handleEvent(new Deregistered()); + }); + } + + async updateRegistration() { + const localDevice = this.client.device as LocalDevice; + if (this.registerCallback) { + this.callCustomRegisterer(localDevice, false); + } else { + const client = this.client; + const format = client.options.useBinaryProtocol + ? this.client.Utils.Format.msgpack + : this.client.Utils.Format.json, + body = client.rest.DeviceDetails.fromLocalDevice(localDevice), + headers = this.client.Defaults.defaultPostHeaders(this.client.options, { format }), + params = {}; + + if (client.options.headers) { + this.client.Utils.mixin(headers, client.options.headers); + } + + if (client.options.pushFullWait) { + this.client.Utils.mixin(params, { fullWait: 'true' }); + } + + const requestBody = this.client.Utils.encodeBody(body, client._MsgPack, format); + const authDetails = localDevice.getAuthDetails(client, headers, params); + try { + const response = await this.client.rest.Resource.patch( + client, + '/push/deviceRegistrations', + requestBody, + authDetails.headers, + authDetails.params, + format, + true, + ); + this.handleEvent(new GotDeviceRegistration(response.body as DeviceRegistration)); + } catch (err) { + this.handleEvent(new GettingDeviceRegistrationFailed(err as ErrorInfo)); + } + } + } + + async deregister() { + const device = this.client.device as LocalDevice; + if (this.deregisterCallback) { + this.callCustomDeregisterer(device); + } else { + const rest = this.client; + const format = rest.options.useBinaryProtocol ? this.client.Utils.Format.msgpack : this.client.Utils.Format.json, + headers = this.client.Defaults.defaultPostHeaders(rest.options, { format }), + params = { deviceId: device.id }; + + if (rest.options.headers) this.client.Utils.mixin(headers, rest.options.headers); + + if (rest.options.pushFullWait) this.client.Utils.mixin(params, { fullWait: 'true' }); + + try { + await this.client.rest.Resource.delete(rest, '/push/deviceRegistrations', headers, params, format, true); + this.handleEvent(new Deregistered()); + } catch (err) { + this.handleEvent(new DeregistrationFailed(err as ErrorInfo)); + } + } + } + + callActivatedCallback(err: ErrorInfo | null) { + this.activatedCallback?.(err); + delete this.activatedCallback; + } + + callDeactivatedCallback(err: ErrorInfo | null) { + this.deactivatedCallback?.(err); + delete this.deactivatedCallback; + } + + handleEvent(event: ActivationEvent) { + if (this.handling) { + this.client.Platform.Config.nextTick(() => { + this.handleEvent(event); + }); + return; + } + + this.handling = true; + this.client.Logger.logAction( + this.client.logger, + this.client.Logger.LOG_MAJOR, + 'Push.ActivationStateMachine.handleEvent()', + 'handling event ' + event.name + ' from ' + this.current.name, + ); + + let maybeNext = this.current.processEvent(this, event); + if (!maybeNext) { + this.client.Logger.logAction( + this.client.logger, + this.client.Logger.LOG_MAJOR, + 'Push.ActivationStateMachine.handleEvent()', + 'enqueing event: ' + event.name, + ); + this.pendingEvents.push(event); + this.handling = false; + return; + } + + this.client.Logger.logAction( + this.client.logger, + this.client.Logger.LOG_MAJOR, + 'Push.ActivationStateMachine.handleEvent()', + 'transition: ' + this.current.name + ' -(' + event.name + ')-> ' + maybeNext.name, + ); + this.current = maybeNext; + + while (this.pendingEvents.length > 0) { + const pending = this.pendingEvents[0]; + + this.client.Logger.logAction( + this.client.logger, + this.client.Logger.LOG_MAJOR, + 'Push.ActivationStateMachine.handleEvent()', + 'attempting to consume pending event: ' + pending.name, + ); + + maybeNext = this.current.processEvent(this, pending); + if (!maybeNext) { + break; + } + this.pendingEvents.splice(0, 1); + + this.client.Logger.logAction( + this.client.logger, + this.client.Logger.LOG_MAJOR, + 'Push.ActivationStateMachine.handleEvent()', + 'transition: ' + this.current.name + ' -(' + pending.name + ')-> ' + maybeNext.name, + ); + this.current = maybeNext; + } + + this.persist(); + this.handling = false; + } +} + +// Events +export class CalledActivate { + name = 'CalledActivate'; + + constructor(machine: ActivationStateMachine, registerCallback?: RegisterCallback) { + if (registerCallback) { + machine.registerCallback = registerCallback; + } + machine.persist(); + } +} + +export class CalledDeactivate { + name = 'CalledDeactivate'; + + constructor(machine: ActivationStateMachine, deregisterCallback?: DeregisterCallback) { + machine.deregisterCallback = deregisterCallback; + machine.persist(); + } +} + +export class GotPushDeviceDetails { + name = 'GotPushDeviceDetails'; +} + +export class GettingPushDeviceDetailsFailed { + name = 'GettingPushDeviceDetailsFailed'; + reason: ErrorInfo; + + constructor(reason: ErrorInfo) { + this.reason = reason; + } +} + +class GotDeviceRegistration { + name = 'GotDeviceRegistration'; + tokenDetails: API.TokenDetails; + + constructor(deviceRegistration: DeviceRegistration) { + this.tokenDetails = deviceRegistration.deviceIdentityToken; + } +} + +class GettingDeviceRegistrationFailed { + name = 'GettingDeviceRegistrationFailed'; + reason: ErrorInfo; + constructor(reason: ErrorInfo) { + this.reason = reason; + } +} + +class RegistrationSynced { + name = 'RegistrationSynced'; +} + +class SyncRegistrationFailed { + name = 'SyncRegistrationFailed'; + reason: ErrorInfo; + + constructor(reason: ErrorInfo) { + this.reason = reason; + } +} + +class Deregistered { + name = 'Deregistered'; +} + +class DeregistrationFailed { + name = 'DeregistrationFailed'; + reason: ErrorInfo; + constructor(reason: ErrorInfo) { + this.reason = reason; + } +} + +type ActivationEvent = + | CalledActivate + | CalledDeactivate + | GotPushDeviceDetails + | GettingPushDeviceDetailsFailed + | GotDeviceRegistration + | GettingDeviceRegistrationFailed + | RegistrationSynced + | SyncRegistrationFailed + | Deregistered + | DeregistrationFailed; + +// States +abstract class ActivationState { + name: ActivationStateName; + + constructor(name: ActivationStateName) { + this.name = name; + } + + abstract processEvent(machine: ActivationStateMachine, event: ActivationEvent): ActivationState | null; +} + +class NotActivated extends ActivationState { + constructor() { + super('NotActivated'); + } + + processEvent(machine: ActivationStateMachine, event: ActivationEvent): ActivationState | null { + if (event instanceof CalledDeactivate) { + machine.callDeactivatedCallback(null); + return new NotActivated(); + } else if (event instanceof CalledActivate) { + const device = machine.client.device as LocalDevice; + + if (device.deviceIdentityToken != null) { + if (device.clientId && device.clientId !== machine.client.auth.clientId) { + machine.handleEvent( + new SyncRegistrationFailed( + new machine.client.ErrorInfo('clientId not compatible with local device clientId', 61002, 400), + ), + ); + return null; + } + // Already registered. + machine.pendingEvents.push(event); + return new WaitingForNewPushDeviceDetails(); + } + + if (device.push.recipient) { + machine.pendingEvents.push(new GotPushDeviceDetails()); + } else { + if (machine.pushConfig.getPushDeviceDetails) { + machine.pushConfig.getPushDeviceDetails?.(machine); + } else if (machine.pushConfig.platform === DevicePlatform.Browser) { + getW3CPushDeviceDetails(machine); + } else { + machine.handleEvent( + new GettingPushDeviceDetailsFailed( + new machine.client.ErrorInfo('No available implementation to get push device details', 50000, 500), + ), + ); + } + } + + return new WaitingForPushDeviceDetails(); + } else if (event instanceof GotPushDeviceDetails) { + return new NotActivated(); + } + return null; + } +} + +class WaitingForPushDeviceDetails extends ActivationState { + constructor() { + super('WaitingForPushDeviceDetails'); + } + + processEvent(machine: ActivationStateMachine, event: ActivationEvent) { + if (event instanceof CalledActivate) { + return new WaitingForPushDeviceDetails(); + } else if (event instanceof CalledDeactivate) { + machine.callDeactivatedCallback(null); + return new NotActivated(); + } else if (event instanceof GotPushDeviceDetails) { + const client = machine.client; + const device = client.device as LocalDevice; + + if (machine.registerCallback) { + machine.callCustomRegisterer(device, true); + } else { + const format = client.options.useBinaryProtocol + ? machine.client.Utils.Format.msgpack + : machine.client.Utils.Format.json, + body = client.rest.DeviceDetails.fromLocalDevice(device), + headers = machine.client.Defaults.defaultPostHeaders(client.options, { format }), + params = {}; + + if (client.options.headers) machine.client.Utils.mixin(headers, client.options.headers); + + if (client.options.pushFullWait) machine.client.Utils.mixin(params, { fullWait: 'true' }); + + const requestBody = machine.client.Utils.encodeBody(body, client._MsgPack, format); + + machine.client.rest.Resource.post(client, '/push/deviceRegistrations', requestBody, headers, params, null, true) + .then((response) => { + const deviceDetails = response.unpacked + ? response.body + : client.rest.DeviceDetails.fromResponseBody(response.body as any, client._MsgPack, format); + machine.handleEvent(new GotDeviceRegistration(deviceDetails as DeviceRegistration)); + }) + .catch((err) => { + machine.handleEvent(new GettingDeviceRegistrationFailed(err as ErrorInfo)); + }); + } + + return new WaitingForDeviceRegistration(); + } else if (event instanceof GettingPushDeviceDetailsFailed) { + machine.callActivatedCallback(event.reason); + return new NotActivated(); + } + return null; + } +} + +class WaitingForDeviceRegistration extends ActivationState { + constructor() { + super('WaitingForDeviceRegistration'); + } + + processEvent(machine: ActivationStateMachine, event: ActivationEvent) { + if (event instanceof CalledActivate) { + return new WaitingForDeviceRegistration(); + } else if (event instanceof GotDeviceRegistration) { + const device = machine.client.device as LocalDevice; + device.deviceIdentityToken = event.tokenDetails.token; + device.persist(); + machine.callActivatedCallback(null); + return new WaitingForNewPushDeviceDetails(); + } else if (event instanceof GettingDeviceRegistrationFailed) { + machine.callActivatedCallback(event.reason); + return new NotActivated(); + } + return null; + } +} + +class WaitingForNewPushDeviceDetails extends ActivationState { + constructor() { + super('WaitingForNewPushDeviceDetails'); + } + + processEvent(machine: ActivationStateMachine, event: ActivationEvent) { + if (event instanceof CalledActivate) { + machine.callActivatedCallback(null); + return new WaitingForNewPushDeviceDetails(); + } else if (event instanceof CalledDeactivate) { + machine.deregister(); + return new WaitingForDeregistration(this); + } else if (event instanceof GotPushDeviceDetails) { + machine.updateRegistration(); + return new WaitingForRegistrationSync(); + } + return null; + } +} + +class WaitingForRegistrationSync extends ActivationState { + triggeredByCalledActivate: boolean | null; + + constructor(triggeredByCalledActivate: boolean | null = false) { + super('WaitingForRegistrationSync'); + this.triggeredByCalledActivate = triggeredByCalledActivate; + } + + processEvent(machine: ActivationStateMachine, event: ActivationEvent) { + if (event instanceof CalledActivate && !this.triggeredByCalledActivate) { + machine.callActivatedCallback(null); + return new WaitingForRegistrationSync(true); + } else if (event instanceof RegistrationSynced) { + return new WaitingForNewPushDeviceDetails(); + } else if (event instanceof SyncRegistrationFailed) { + machine.callUpdateRegistrationFailedCallback(event.reason); + return new AfterRegistrationSyncFailed(); + } + return null; + } +} + +class AfterRegistrationSyncFailed extends ActivationState { + constructor() { + super('AfterRegistrationSyncFailed'); + } + + processEvent(machine: ActivationStateMachine, event: ActivationEvent) { + if (event instanceof CalledActivate || event instanceof GotPushDeviceDetails) { + machine.updateRegistration(); + return new WaitingForRegistrationSync(event instanceof CalledActivate); + } else if (event instanceof CalledDeactivate) { + machine.deregister(); + return new WaitingForDeregistration(this); + } + return null; + } +} + +class WaitingForDeregistration extends ActivationState { + previousState: ActivationState | null; + + constructor(previousState: ActivationState | null) { + super('WaitingForDeregistration'); + this.previousState = previousState; + } + + processEvent(machine: ActivationStateMachine, event: ActivationEvent): ActivationState | null { + if (event instanceof CalledDeactivate) { + return new WaitingForDeregistration(this.previousState); + } else if (event instanceof Deregistered) { + const device = machine.client.device as LocalDevice; + delete device.deviceIdentityToken; + delete device.push.recipient; + device.resetId(); + device.persist(); + machine.callDeactivatedCallback(null); + return new NotActivated(); + } else if (event instanceof DeregistrationFailed) { + machine.callDeactivatedCallback(event.reason); + return this.previousState; + } + return null; + } +} + +type ActivationStateName = + | 'NotActivated' + | 'WaitingForPushDeviceDetails' + | 'WaitingForDeviceRegistration' + | 'WaitingForNewPushDeviceDetails' + | 'WaitingForRegistrationSync' + | 'AfterRegistrationSyncFailed' + | 'WaitingForDeregistration'; + +export const ActivationStates = { + NotActivated, + WaitingForPushDeviceDetails, + WaitingForDeviceRegistration, + WaitingForNewPushDeviceDetails, + WaitingForRegistrationSync, + AfterRegistrationSyncFailed, + WaitingForDeregistration, +}; + +function isPersistentState(state: ActivationState) { + return state.name == 'NotActivated' || state.name == 'WaitingForNewPushDeviceDetails'; +} diff --git a/src/plugins/push/pushchannel.ts b/src/plugins/push/pushchannel.ts new file mode 100644 index 000000000..22afb5257 --- /dev/null +++ b/src/plugins/push/pushchannel.ts @@ -0,0 +1,119 @@ +import type BaseClient from 'common/lib/client/baseclient'; +import type RealtimeChannel from 'common/lib/client/realtimechannel'; +import type RestChannel from 'common/lib/client/restchannel'; +import type { LocalDevice } from 'plugins/push/pushactivation'; + +class PushChannel { + client: BaseClient; + channel: RestChannel | RealtimeChannel; + + constructor(channel: RestChannel | RealtimeChannel) { + this.channel = channel; + this.client = channel.client; + } + + async subscribeDevice() { + const client = this.client; + const device = client.device as LocalDevice; + const format = client.options.useBinaryProtocol ? client.Utils.Format.msgpack : client.Utils.Format.json, + body = { deviceId: device.id, channel: this.channel.name }, + headers = client.Defaults.defaultPostHeaders(client.options, { format }); + + if (client.options.headers) client.Utils.mixin(headers, client.options.headers); + + client.Utils.mixin(headers, this._getPushAuthHeaders()); + + const requestBody = client.Utils.encodeBody(body, client._MsgPack, format); + await client.rest.Resource.post(client, '/push/channelSubscriptions', requestBody, headers, {}, format, true); + } + + async unsubscribeDevice() { + const client = this.client; + const device = client.device as LocalDevice; + const format = client.options.useBinaryProtocol ? client.Utils.Format.msgpack : client.Utils.Format.json, + headers = client.Defaults.defaultPostHeaders(client.options, { format }); + + if (client.options.headers) client.Utils.mixin(headers, client.options.headers); + + client.Utils.mixin(headers, this._getPushAuthHeaders()); + + await client.rest.Resource.delete( + client, + '/push/channelSubscriptions', + headers, + { deviceId: device.id, channel: this.channel.name }, + format, + true, + ); + } + + async subscribeClient() { + const client = this.client; + const clientId = this.client.auth.clientId; + if (!clientId) { + throw new this.client.ErrorInfo('Cannot subscribe from client without client ID', 50000, 500); + } + const format = client.options.useBinaryProtocol ? client.Utils.Format.msgpack : client.Utils.Format.json, + body = { clientId: clientId, channel: this.channel.name }, + headers = client.Defaults.defaultPostHeaders(client.options, { format }); + + if (client.options.headers) client.Utils.mixin(headers, client.options.headers); + + const requestBody = client.Utils.encodeBody(body, client._MsgPack, format); + await client.rest.Resource.post(client, '/push/channelSubscriptions', requestBody, headers, {}, format, true); + } + + async unsubscribeClient() { + const client = this.client; + + const clientId = this.client.auth.clientId; + if (!clientId) { + throw new this.client.ErrorInfo('Cannot unsubscribe from client without client ID', 50000, 500); + } + const format = client.options.useBinaryProtocol ? client.Utils.Format.msgpack : client.Utils.Format.json, + headers = client.Defaults.defaultPostHeaders(client.options, { format }); + + if (client.options.headers) client.Utils.mixin(headers, client.options.headers); + + await client.rest.Resource.delete( + client, + '/push/channelSubscriptions', + headers, + { clientId: clientId, channel: this.channel.name }, + format, + true, + ); + } + + async listSubscriptions(params?: Record) { + this.client.Logger.logAction( + this.client.logger, + this.client.Logger.LOG_MICRO, + 'PushChannel.listSubscriptions()', + 'channel = ' + this.channel.name, + ); + + return this.client.push.admin.channelSubscriptions.list({ + ...params, + channel: this.channel.name, + concatFilters: true, + }); + } + + private _getDeviceIdentityToken() { + const device = this.client.device as LocalDevice; + const deviceIdentityToken = device.deviceIdentityToken; + if (deviceIdentityToken) { + return deviceIdentityToken; + } else { + throw new this.client.ErrorInfo('Cannot subscribe from client without deviceIdentityToken', 50000, 500); + } + } + + private _getPushAuthHeaders() { + const deviceIdentityToken = this._getDeviceIdentityToken(); + return { 'X-Ably-DeviceToken': deviceIdentityToken }; + } +} + +export default PushChannel; diff --git a/test/common/globals/named_dependencies.js b/test/common/globals/named_dependencies.js index 0558b575e..d89cdbe7a 100644 --- a/test/common/globals/named_dependencies.js +++ b/test/common/globals/named_dependencies.js @@ -7,11 +7,16 @@ define(function () { browser: 'node_modules/@ably/vcdiff-decoder/dist/vcdiff-decoder', node: 'node_modules/@ably/vcdiff-decoder', }, + push: { + browser: 'build/push', + node: 'build/push', + }, // test modules globals: { browser: 'test/common/globals/environment', node: 'test/common/globals/environment' }, shared_helper: { browser: 'test/common/modules/shared_helper', node: 'test/common/modules/shared_helper' }, async: { browser: 'node_modules/async/lib/async' }, chai: { browser: 'node_modules/chai/chai', node: 'node_modules/chai/chai' }, + ulid: { browser: 'node_modules/ulid/dist/index.umd', node: 'node_modules/ulid/dist/index.umd' }, }); }); diff --git a/test/rest/push.test.js b/test/rest/push.test.js index 461f6ea85..5521e4c97 100644 --- a/test/rest/push.test.js +++ b/test/rest/push.test.js @@ -1,10 +1,27 @@ 'use strict'; -define(['ably', 'shared_helper', 'async', 'chai'], function (Ably, helper, async, chai) { +define(['ably', 'shared_helper', 'async', 'chai', 'test/support/push_channel_transport', 'push'], function ( + Ably, + helper, + async, + chai, + pushChannelTransport, + PushPlugin, +) { var Utils = helper.Utils; - var exports = {}; var expect = chai.expect; var closeAndFinish = helper.closeAndFinish; + var whenPromiseSettles = helper.whenPromiseSettles; + var originalPushConfig = Ably.Realtime.Platform.Config.push; + + function PushRealtime(options) { + return helper.AblyRealtime({ ...options, plugins: { Push: PushPlugin } }); + } + + function PushRest(options) { + return helper.AblyRest({ ...options, plugins: { Push: PushPlugin } }); + } + var testDevice = { id: 'testId', clientId: 'testClientId', @@ -34,11 +51,24 @@ define(['ably', 'shared_helper', 'async', 'chai'], function (Ably, helper, async this.timeout(60 * 1000); before(function (done) { - helper.setupApp(function () { + Ably.Realtime.Platform.Config.push = pushChannelTransport; + helper.setupApp(function (err) { + if (err) { + done(err); + return; + } done(); }); }); + beforeEach(() => { + Ably.Realtime.Platform.Config.push.storage.clear(); + }); + + after(() => { + Ably.Realtime.Platform.Config.push = originalPushConfig; + }); + it('Get subscriptions', async function () { var subscribes = []; var deletes = []; @@ -303,6 +333,340 @@ define(['ably', 'shared_helper', 'async', 'chai'], function (Ably, helper, async testIncludesUnordered(['pushenabled:listChannels1', 'pushenabled:listChannels2'], result.items); }); + describe('push activation', function () { + /* + * RSH2a + */ + it('push_activation_succeeds', async function () { + const rest = PushRealtime({ pushRecipientChannel: 'my_channel' }); + await rest.push.activate(); + expect(rest.device.deviceIdentityToken).to.be.ok; + }); + + // no spec item + it('device_push', function (done) { + const channelName = 'pushenabled:device_push'; + const realtime = PushRealtime({ pushRecipientChannel: channelName }); + + const pushPayload = { + notification: { title: 'Test message', body: 'Test message body' }, + data: { foo: 'bar' }, + }; + + const channel = realtime.channels.get(channelName); + + const baseUri = realtime.baseUri(Ably.Rest.Platform.Defaults.getHost(realtime.options)); + + const pushRecipient = { + transportType: 'ablyChannel', + channel: channelName, + ablyKey: realtime.options.key, + ablyUrl: baseUri, + }; + + channel + .subscribe('__ably_push__', function (msg) { + try { + const receivedPushPayload = JSON.parse(msg.data); + expect(receivedPushPayload.data).to.deep.equal(pushPayload.data); + expect(receivedPushPayload.notification.title).to.equal(pushPayload.notification.title); + expect(receivedPushPayload.notification.body).to.equal(pushPayload.notification.body); + closeAndFinish(done, realtime); + } catch (err) { + closeAndFinish(done, realtime, err); + } + }) + .then(() => { + whenPromiseSettles(realtime.push.admin.publish(pushRecipient, pushPayload), function (err) { + if (err) { + closeAndFinish(done, realtime, err); + } + }); + }); + }); + + /* + * RSH7b + */ + it('subscribe_client', async function () { + const clientId = 'me'; + const channelName = 'pushenabled:subscribe_client'; + const rest = PushRest({ clientId, pushRecipientChannel: channelName }); + const channel = rest.channels.get(channelName); + + await rest.push.activate(); + + await channel.push.subscribeClient(); + + const result = await channel.push.listSubscriptions(); + + const subscription = result.items[0]; + expect(subscription.channel).to.equal(channelName); + expect(subscription.clientId).to.equal(clientId); + }); + + /* + * RSH7b1 + */ + it('subscribe_client_without_clientId', async function () { + const channelName = 'pushenabled:subscribe_client_without_clientId'; + const rest = PushRest({ pushRecipientChannel: 'hello' }); + await rest.push.activate(); + const channel = rest.channels.get(channelName); + try { + await channel.push.subscribeClient(); + } catch (err) { + expect(err.code).to.equal(50000); + expect(err.statusCode).to.equal(500); + return; + } + expect.fail('expected channel.push.subscribeClient to throw exception'); + }); + + /* + * RSH7d + */ + it('unsubscribe_client', async function () { + const clientId = 'me'; + const channelName = 'pushenabled:unsubscribe_client'; + const rest = PushRest({ clientId, pushRecipientChannel: channelName }); + const channel = rest.channels.get(channelName); + + await rest.push.activate(); + + await channel.push.subscribeClient(); + await channel.push.unsubscribeClient(); + + const result = await channel.push.listSubscriptions(); + + const subscriptions = result.items; + expect(subscriptions.length).to.equal(0); + }); + + // no spec item + it('direct_publish_client_id', async function () { + const clientId = 'me2'; + const channelName = 'pushenabled:direct_publish_client_id'; + const rest = PushRest({ clientId, pushRecipientChannel: channelName }); + const realtime = PushRealtime(); + const rtChannel = realtime.channels.get(channelName); + const channel = rest.channels.get(channelName); + + await rest.push.activate(); + + const pushRecipient = { + clientId, + }; + + const pushPayload = { + notification: { title: 'Test message', body: 'Test message body' }, + data: { foo: 'bar' }, + }; + + await rtChannel.attach(); + const msg = await new Promise((resolve, reject) => { + rtChannel.subscribe('__ably_push__', (msg) => { + resolve(msg); + }); + rest.push.admin.publish(pushRecipient, pushPayload).catch(reject); + }); + + const receivedPushPayload = JSON.parse(msg.data); + expect(receivedPushPayload.data).to.deep.equal(pushPayload.data); + expect(receivedPushPayload.notification.title).to.equal(pushPayload.notification.title); + expect(receivedPushPayload.notification.body).to.equal(pushPayload.notification.body); + + realtime.close(); + }); + + /* + * RSH7a + */ + it('subscribe_device', async function () { + const channelName = 'pushenabled:subscribe_device'; + const rest = PushRest({ pushRecipientChannel: channelName }); + const channel = rest.channels.get(channelName); + + await rest.push.activate(); + + await channel.push.subscribeDevice(); + + const result = await channel.push.listSubscriptions(); + + const subscription = result.items[0]; + expect(subscription.channel).to.equal(channelName); + expect(subscription.deviceId).to.equal(rest.device.id); + }); + + /* + * RSH7c + */ + it('unsubscribe_device', async function () { + const channelName = 'pushenabled:unsubscribe_device'; + const rest = PushRest({ pushRecipientChannel: channelName }); + const channel = rest.channels.get(channelName); + + await rest.push.activate(); + + await channel.push.subscribeDevice(); + await channel.push.unsubscribeDevice(); + + const result = await channel.push.listSubscriptions(); + + const subscriptions = result.items; + expect(subscriptions.length).to.equal(0); + }); + + // no spec item + it('direct_publish_device_id', async function () { + const channelName = 'direct_publish_device_id'; + const rest = PushRest({ pushRecipientChannel: channelName }); + const realtime = PushRealtime(); + const rtChannel = realtime.channels.get(channelName); + const channel = rest.channels.get(channelName); + + await rest.push.activate(); + + const pushRecipient = { + deviceId: rest.device.id, + }; + + const pushPayload = { + notification: { title: 'Test message', body: 'Test message body' }, + data: { foo: 'bar' }, + }; + + await rtChannel.attach(); + const msg = await new Promise((resolve, reject) => { + rtChannel.subscribe('__ably_push__', (msg) => { + resolve(msg); + }); + rest.push.admin.publish(pushRecipient, pushPayload).catch(reject); + }); + + const receivedPushPayload = JSON.parse(msg.data); + expect(receivedPushPayload.data).to.deep.equal(pushPayload.data); + expect(receivedPushPayload.notification.title).to.equal(pushPayload.notification.title); + expect(receivedPushPayload.notification.body).to.equal(pushPayload.notification.body); + + realtime.close(); + }); + + // no spec item + it('push_channel_subscription_device_id', async function () { + const pushRecipientChannel = 'push_channel_subscription_device_id'; + const channelName = 'pushenabled:push_channel_subscription_device_id'; + const rest = PushRest({ pushRecipientChannel }); + const realtime = PushRealtime(); + const channel = rest.channels.get(channelName); + const rtChannel = realtime.channels.get(pushRecipientChannel); + + await rest.push.activate(); + + await channel.push.subscribeDevice(); + + const pushPayload = { + notification: { title: 'Test message', body: 'Test message body' }, + data: { foo: 'bar' }, + }; + + const message = { + name: 'foo', + data: 'bar', + extras: { + push: pushPayload, + }, + }; + + const msg = await new Promise((resolve, reject) => { + rtChannel.subscribe('__ably_push__', (msg) => { + resolve(msg); + }); + channel.publish(message).catch(reject); + }); + + const receivedPushPayload = JSON.parse(msg.data); + expect(receivedPushPayload.data).to.deep.equal(pushPayload.data); + expect(receivedPushPayload.notification.title).to.equal(pushPayload.notification.title); + expect(receivedPushPayload.notification.body).to.equal(pushPayload.notification.body); + + realtime.close(); + }); + + // no spec item + it('push_channel_subscription_client_id', async function () { + const pushRecipientChannel = 'push_channel_subscription_client_id'; + const channelName = 'pushenabled:push_channel_subscription_client_id'; + const rest = PushRest({ clientId: 'me', pushRecipientChannel }); + const realtime = PushRealtime(); + const channel = rest.channels.get(channelName); + const rtChannel = realtime.channels.get(pushRecipientChannel); + + await rest.push.activate(); + + await channel.push.subscribeClient(); + + const pushPayload = { + notification: { title: 'Test message', body: 'Test message body' }, + data: { foo: 'bar' }, + }; + + const message = { + name: 'foo', + data: 'bar', + extras: { + push: pushPayload, + }, + }; + + const msg = await new Promise((resolve, reject) => { + rtChannel.subscribe('__ably_push__', (msg) => { + resolve(msg); + }); + channel.publish(message).catch(reject); + }); + + const receivedPushPayload = JSON.parse(msg.data); + expect(receivedPushPayload.data).to.deep.equal(pushPayload.data); + expect(receivedPushPayload.notification.title).to.equal(pushPayload.notification.title); + expect(receivedPushPayload.notification.body).to.equal(pushPayload.notification.body); + + realtime.close(); + }); + + /* + * RSH8h + */ + it('failed_getting_device_details', async function () { + const rest = PushRest(); + try { + await rest.push.activate(); + } catch (err) { + expect(err.code).to.equal(40000); + expect(err.statusCode).to.equal(400); + return; + } + expect.fail('expect rest.push.activate() to throw'); + }); + + /* + * RSH3b3c + */ + it('failed_registration', async function () { + const pushRecipientChannel = 'failed_registration'; + const rest = PushRest({ pushRecipientChannel }); + rest.device.platform = 'not_a_real_platform'; + try { + await rest.push.activate(); + } catch (err) { + expect(err.code).to.equal(40000); + expect(err.statusCode).to.equal(400); + return; + } + expect.fail('expect rest.push.activate() to throw'); + }); + }); + function untyped(x) { return JSON.parse(JSON.stringify(x)); } diff --git a/test/support/push_channel_transport.js b/test/support/push_channel_transport.js new file mode 100644 index 000000000..78dbf84b7 --- /dev/null +++ b/test/support/push_channel_transport.js @@ -0,0 +1,57 @@ +define(['ably'], function (Ably) { + var Defaults = Ably.Realtime.Platform.Defaults; + var ErrorInfo = Ably.ErrorInfo; + return (module.exports = { + getPushDeviceDetails: function (machine) { + var channel = machine.client.options.pushRecipientChannel; + if (!channel) { + machine.handleEvent( + new machine.GettingPushDeviceDetailsFailed( + new ErrorInfo('Missing ClientOptions.pushRecipientChannel', 40000, 400), + ), + ); + return; + } + + var ablyKey = machine.client.options.pushAblyKey || machine.client.options.key; + if (!ablyKey) { + machine.handleEvent( + new machine.GettingPushDeviceDetailsFailed(new ErrorInfo('Missing options.pushAblyKey', 40000, 400)), + ); + return; + } + + var ablyUrl = machine.client.baseUri(Defaults.getHosts(machine.client.options)[0]); + + var device = machine.client.device; + device.push.recipient = { + transportType: 'ablyChannel', + channel: channel, + ablyKey: ablyKey, + ablyUrl: ablyUrl, + }; + device.persist(); + + machine.handleEvent(new machine.GotPushDeviceDetails()); + }, + storage: (function () { + var values = {}; + return { + set: function (name, value) { + values[name] = value; + }, + get: function (name) { + return values[name]; + }, + remove: function (name) { + delete values[name]; + }, + clear: function () { + values = {}; + }, + }; + })(), + platform: 'browser', + formFactor: 'desktop', + }); +});