diff --git a/build-system/tasks/compile.js b/build-system/tasks/compile.js index 6d255a0af3df..f407bdd4e58e 100644 --- a/build-system/tasks/compile.js +++ b/build-system/tasks/compile.js @@ -239,6 +239,8 @@ function compile(entryModuleFilenames, outputDir, outputFilename, options) { 'extensions/amp-consent/**/*.js', // Needed to access AmpGeo type for service locator 'extensions/amp-geo/**/*.js', + // Needed for AmpViewerAssistanceService + 'extensions/amp-viewer-assistance/**/*.js', // Needed for AmpViewerIntegrationVariableService 'extensions/amp-viewer-integration/**/*.js', 'src/*.js', diff --git a/build-system/tasks/presubmit-checks.js b/build-system/tasks/presubmit-checks.js index 3a5b7764bee2..4285b50c3511 100644 --- a/build-system/tasks/presubmit-checks.js +++ b/build-system/tasks/presubmit-checks.js @@ -169,6 +169,7 @@ const forbiddenTerms = { 'src/service/action-impl.js', 'extensions/amp-access/0.1/amp-access.js', 'extensions/amp-form/0.1/amp-form.js', + 'extensions/amp-viewer-assistance/0.1/amp-viewer-assistance.js', ], }, 'installActivityService': { @@ -404,6 +405,7 @@ const forbiddenTerms = { 'src/inabox/inabox-viewer.js', 'src/service/cid-impl.js', 'src/impression.js', + 'extensions/amp-viewer-assistance/0.1/amp-viewer-assistance.js', ], }, 'eval\\(': { diff --git a/bundles.config.js b/bundles.config.js index e558f0a499bb..0393853ceafe 100644 --- a/bundles.config.js +++ b/bundles.config.js @@ -394,6 +394,7 @@ exports.extensionBundles = [ ], }, {name: 'amp-google-vrview-image', version: '0.1', type: TYPES.MISC}, + {name: 'amp-viewer-assistance', version: '0.1', type: TYPES.MISC}, { name: 'amp-viewer-integration', version: '0.1', diff --git a/extensions/amp-viewer-assistance/0.1/amp-viewer-assistance.js b/extensions/amp-viewer-assistance/0.1/amp-viewer-assistance.js new file mode 100644 index 000000000000..ac91c8dd35ca --- /dev/null +++ b/extensions/amp-viewer-assistance/0.1/amp-viewer-assistance.js @@ -0,0 +1,190 @@ +/** + * Copyright 2019 The AMP HTML Authors. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS-IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import {ActionTrust} from '../../../src/action-constants'; +import {Services} from '../../../src/services'; +import {dev, user} from '../../../src/log'; +import {dict} from '../../../src/utils/object'; +import {isExperimentOn} from '../../../src/experiments'; +import {tryParseJson} from '../../../src/json'; + + +/** @const {string} */ +const TAG = 'amp-viewer-assistance'; + +/** @const {string} */ +const GSI_TOKEN_PROVIDER = 'actions-on-google-gsi'; + +export class AmpViewerAssistance { + /** + * @param {!../../../src/service/ampdoc-impl.AmpDoc} ampdoc + */ + constructor(ampdoc) { + const assistanceElement = ampdoc.getElementById('amp-viewer-assistance'); + + /** @private {boolean} */ + this.enabled_ = !!assistanceElement; + if (!this.enabled_) { + return; + } + + /** @const @private */ + this.ampdoc_ = ampdoc; + + /** @const @private {!Element} */ + this.assistanceElement_ = dev().assertElement(assistanceElement); + + /** @const @private {JsonObject|null|undefined} */ + this.configJson_ = tryParseJson(this.assistanceElement_.textContent, e => { + throw user().createError( + 'Failed to parse "amp-viewer-assistance" JSON: ' + e); + }); + + /** @private @const {!../../../src/service/viewer-impl.Viewer} */ + this.viewer_ = Services.viewerForDoc(ampdoc); + + /** @private @const {!../../../src/service/action-impl.ActionService} */ + this.action_ = Services.actionServiceForDoc(this.assistanceElement_); + + /** @private @const {!../../../src/service/vsync-impl.Vsync} */ + this.vsync_ = Services.vsyncFor(ampdoc.win); + } + + /** + * @param {!../../../src/service/action-impl.ActionInvocation} invocation + * @return {?Promise} + * @private + */ + actionHandler_(invocation) { + const {method, args} = invocation; + if (method == 'updateActionState' && !!args) { + this.viewer_./*OK*/sendMessageAwaitResponse(method, args).catch(error => { + user().error(TAG, error.toString()); + }); + } else if (method == 'signIn') { + this.requestSignIn_(); + } + + return null; + } + + /** + * @private + * @restricted + * @return {!AmpViewerAssistance|Promise} + */ + start_() { + if (!this.enabled_) { + user().error(TAG, 'Could not find #amp-viewer-assistance element.'); + return this; + } + return this.viewer_.isTrustedViewer().then(isTrustedViewer => { + if (!isTrustedViewer && + !isExperimentOn(this.ampdoc_.win, 'amp-viewer-assistance-untrusted')) { + this.enabled_ = false; + user().error(TAG, + 'amp-viewer-assistance is currently only supported on trusted' + + ' viewers.'); + return this; + } + this.action_.installActionHandler( + this.assistanceElement_, this.actionHandler_.bind(this), + ActionTrust.HIGH); + + this.getIdTokenPromise(); + + this.viewer_./*OK*/sendMessage('viewerAssistanceConfig',dict({ + 'config': this.configJson_, + })); + return this; + }); + } + + /** + * @return {!Promise} + */ + getIdTokenPromise() { + return this.viewer_./*OK*/sendMessageAwaitResponse('getAccessTokenPassive', + dict({ + // For now there's only 1 provider option, so we just hard code it + 'providers': [GSI_TOKEN_PROVIDER], + })) + .then(token => { + this.setIdTokenStatus_(Boolean(!!token)); + return token; + }).catch(() => { + this.setIdTokenStatus_(/*available=*/false); + }); + } + + /** + * @private + */ + requestSignIn_() { + this.viewer_./*OK*/sendMessageAwaitResponse('requestSignIn', dict({ + 'providers': [GSI_TOKEN_PROVIDER], + })).then(token => { + user().info(TAG, 'Token: ' + token); + if (token) { + this.setIdTokenStatus_(/*available=*/true); + this.action_.trigger( + this.assistanceElement_, 'signedIn', null, ActionTrust.HIGH); + } else { + this.setIdTokenStatus_(/*available=*/false); + } + }); + } + + /** + * Toggles the CSS classes related to the status of the identity token. + * @private + * @param {boolean} available + */ + setIdTokenStatus_(available) { + this.toggleTopClass_( + 'amp-viewer-assistance-identity-available', available); + } + + /** + * Gets the root element of the AMP doc. + * @return {!Element} + * @private + */ + getRootElement_() { + const root = this.ampdoc_.getRootNode(); + return dev().assertElement(root.documentElement || root.body || root); + } + + /** + * Toggles a class on the root element of the AMP doc. + * @param {string} className + * @param {boolean} on + * @private + */ + toggleTopClass_(className, on) { + this.vsync_.mutate(() => { + this.getRootElement_().classList.toggle(className, on); + }); + + } +} + +// Register the extension services. +AMP.extension(TAG, '0.1', function(AMP) { + AMP.registerServiceForDoc('amp-viewer-assistance', function(ampdoc) { + return new AmpViewerAssistance(ampdoc).start_(); + }); +}); diff --git a/extensions/amp-viewer-assistance/0.1/test/test-amp-viewer-assistance.js b/extensions/amp-viewer-assistance/0.1/test/test-amp-viewer-assistance.js new file mode 100644 index 000000000000..ccfe13bd8453 --- /dev/null +++ b/extensions/amp-viewer-assistance/0.1/test/test-amp-viewer-assistance.js @@ -0,0 +1,184 @@ +/** + * Copyright 2019 The AMP HTML Authors. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS-IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import {ActionInvocation} from '../../../../src/service/action-impl'; +import {AmpViewerAssistance} from '../amp-viewer-assistance'; +import {mockServiceForDoc} from '../../../../testing/test-helper'; + +describes.fakeWin('AmpViewerAssistance', { + amp: true, + location: 'https://pub.com/doc1', +}, env => { + let document; + let ampdoc; + let element; + let viewerMock; + + beforeEach(() => { + ampdoc = env.ampdoc; + document = env.win.document; + viewerMock = mockServiceForDoc(env.sandbox, env.ampdoc, 'viewer', [ + 'isTrustedViewer', + 'sendMessage', + 'sendMessageAwaitResponse', + ]); + viewerMock.isTrustedViewer.returns(Promise.resolve(true)); + viewerMock.sendMessageAwaitResponse.returns(Promise.resolve('idToken')); + + element = document.createElement('script'); + element.setAttribute('id', 'amp-viewer-assistance'); + element.setAttribute('type', 'application/json'); + document.body.appendChild(element); + }); + + it('should disable service when no config', () => { + document.body.removeChild(element); + const service = new AmpViewerAssistance(ampdoc); + expect(service.enabled_).to.be.false; + expect(service.assistanceElement_).to.be.undefined; + }); + + it('should disable service when the viewer is not trusted', () => { + expectAsyncConsoleError('[amp-viewer-assistance] amp-viewer-assistance is' + + ' currently only supported on trusted viewers.'); + viewerMock.isTrustedViewer.returns(Promise.resolve(false)); + const config = { + 'providerId': 'foo-bar', + }; + element.textContent = JSON.stringify(config); + const service = new AmpViewerAssistance(ampdoc); + return service.start_().then(() => { + expect(service.enabled_).to.be.false; + }); + }); + + it('should fail if config is malformed', () => { + expect(() => { + new AmpViewerAssistance(ampdoc); + }).to.throw(Error); + }); + + it('should send the config to the viewer', () => { + const config = { + 'providerId': 'foo-bar', + }; + element.textContent = JSON.stringify(config); + const service = new AmpViewerAssistance(ampdoc); + expect(service.enabled_).to.be.true; + expect(service.assistanceElement_).to.equal(element); + const sendMessageStub = service.viewer_.sendMessage; + return service.start_().then(() => { + expect(sendMessageStub).to.be.calledOnce; + expect(sendMessageStub.firstCall.args[0]).to + .equal('viewerAssistanceConfig'); + expect(sendMessageStub.firstCall.args[1]).to.deep.equal({ + 'config': config, + }); + }); + }); + + it('should send updateActionState to the viewer', () => { + const config = { + 'providerId': 'foo-bar', + }; + element.textContent = JSON.stringify(config); + const service = new AmpViewerAssistance(ampdoc); + const sendMessageStub = service.viewer_.sendMessageAwaitResponse; + const invocationArgs = { + 'foo': 'bar', + }; + return service.start_().then(() => { + sendMessageStub.resetHistory(); + const invocation = new ActionInvocation( + element, 'updateActionState', invocationArgs); + service.actionHandler_(invocation); + expect(sendMessageStub).to.be.calledOnce; + expect(sendMessageStub.firstCall.args[0]).to.equal('updateActionState'); + expect(sendMessageStub.firstCall.args[1]).to.deep.equal(invocationArgs); + }); + }); + + it('should fail to send updateActionState if args are missing', () => { + const config = { + 'providerId': 'foo-bar', + }; + element.textContent = JSON.stringify(config); + const service = new AmpViewerAssistance(ampdoc); + const sendMessageStub = service.viewer_.sendMessage; + return service.start_().then(() => { + sendMessageStub.reset(); + const invocation = new ActionInvocation(element, 'updateActionState'); + service.actionHandler_(invocation); + expect(sendMessageStub).to.not.be.called; + }); + }); + + it('should send handle the signIn action', () => { + const config = { + 'providerId': 'foo-bar', + }; + element.textContent = JSON.stringify(config); + const service = new AmpViewerAssistance(ampdoc); + const sendMessageStub = service.viewer_.sendMessageAwaitResponse; + return service.start_().then(() => { + sendMessageStub.resetHistory(); + sendMessageStub.returns(Promise.reject()); + const invocation = new ActionInvocation(element, 'signIn'); + service.actionHandler_(invocation); + expect(sendMessageStub).to.be.calledOnce; + expect(sendMessageStub.firstCall.args[0]).to.equal('requestSignIn'); + expect(sendMessageStub.firstCall.args[1]).to.deep.equal({ + providers: ['actions-on-google-gsi'], + }); + }); + }); + + it('should make IDENTITY_TOKEN available through a promise', () => { + const config = { + 'providerId': 'foo-bar', + }; + element.textContent = JSON.stringify(config); + const service = new AmpViewerAssistance(ampdoc); + return service.start_() + .then(() => service.getIdTokenPromise()) + .then(token => expect(token).to.equal('idToken')); + }); + + it('should set a css class if IDENTITY_TOKEN is available', () => { + const config = { + 'providerId': 'foo-bar', + }; + element.textContent = JSON.stringify(config); + const service = new AmpViewerAssistance(ampdoc); + service.vsync_ = { + mutate: callback => { + callback(); + }, + }; + const sendMessageStub = service.viewer_.sendMessageAwaitResponse; + sendMessageStub.returns(Promise.resolve('idToken')); + return service.getIdTokenPromise().then(() => { + expect(sendMessageStub).to.be.calledOnce; + expect(sendMessageStub.firstCall.args[0]).to.equal( + 'getAccessTokenPassive'); + expect(sendMessageStub.firstCall.args[1]).to.deep.equal({ + providers: ['actions-on-google-gsi'], + }); + expect(document.documentElement).to.have.class( + 'amp-viewer-assistance-identity-available'); + }); + }); +}); diff --git a/extensions/amp-viewer-assistance/amp-viewer-assistance.md b/extensions/amp-viewer-assistance/amp-viewer-assistance.md new file mode 100644 index 000000000000..b2d836c90c9a --- /dev/null +++ b/extensions/amp-viewer-assistance/amp-viewer-assistance.md @@ -0,0 +1,175 @@ +# amp-viewer-assistance + +[TOC] + + + + + + + + + + + + + + + + + + + +
DescriptionAmp-viewer-assistance provides assistive behaviors on AMP pages facilitated by a viewer. Messages are passed between the amp-viewer-assistance extension and the external viewer as outlined below.
AvailabilityStable
Required Script +
+ <script async custom-element="amp-viewer-assistance" src="https://cdn.ampproject.org/v0/amp-viewer-assistance-0.1.js"></script> +
+
Required Element + <script id="amp-viewer-assistance" type="application/json"></script> +
+ +## Supported Functions + +The `amp-viewer-assistance` extension currently has two functions that can be invoked via AMP expressions. For example, you may invoke the signIn function on a button tap: + +```html + +``` + + + + + + + + + + + + + + +
Function NameDescription
signInThe amp-viewer-assistance extension sends a message to the viewer to sign in expecting an identity token back.
updateActionStateA function to send a message to the outer viewer representing a state change. Should contain an argument of the resulting state change.
+ +## Messages Sent + +There are several messages that can be sent from the amp-viewer-assistance extension to the external viewer. + + + + + + + + + + + + + + + + + + + + + + +
Message NameDescription
viewerAssistanceConfigA message containing the initial json config within the amp-viewer-assistance element. This message is sent during the extension's initialization.
requestSignInRequests for a sign in from the viewer expecting a string identity token back from the viewer. Passes a json argument with a property providers containing an array of identity providers. Currently only supports actions-on-google-gsi.
getAccessTokenPassiveSimilar to requestSignIn however, only requests for the identity token instead of a fresh sign in. Passes a json argument with a property providers containing an array of identity providers. Currently only supports actions-on-google-gsi.
updateActionStateA message representing a change in state. Can be sent with an argument payload.
+ +## Events Triggered + +In order to act upon a successful sign in from the viewer assistance, a `signedIn` event is emitted from the `amp-viewer-assistance` script element. On the element, an expression can be attached via the `on` attribute. In this example, we are showing a success message after a user has signed in: + +```html + + +``` + + + + + + + + + + + + +
Event NameEmitting ElementDescription
signedInamp-viewer-assistance An action emitted upon a successful signIn by the external viewer.
+ +## Identity Class + +Upon a successful sign in or identity token retrieval, a `amp-viewer-assistance-identity-available` class will be attached to the root element of the AMP document. This can be used to manipulate elements with compound CSS classes. + +In this example, we have a message telling the user they are signed out. If the identity is available through the extension, the message's `display` attribute will be overwritten to `display:none`. + +```css +.signedOutMessage { + display: block; +} + +.amp-viewer-assistance-identity-available .signedOutMessage { + display: none; +} +``` + +## Example + +Wrapping up the above, here is an example implementation of a page utilizing sign in, as well as an `updateActionState` after a form submission. + +```html + + + +
+ Signed Out! +
+ +
+
+ +``` diff --git a/src/url.js b/src/url.js index 7f9d9aee19dc..fa051897e843 100644 --- a/src/url.js +++ b/src/url.js @@ -36,6 +36,8 @@ const SERVING_TYPE_PREFIX = dict({ 'a': true, // Ad 'ad': true, + // Actions viewer + 'action': true, }); /** diff --git a/test/unit/test-url.js b/test/unit/test-url.js index 2282eb68dd1d..daf0ab414c69 100644 --- a/test/unit/test-url.js +++ b/test/unit/test-url.js @@ -683,6 +683,12 @@ describe('getSourceOrigin/Url', () => { testOrigin( 'https://cdn.ampproject.org/ad/www.origin.com/foo/?f=0#h', 'http://www.origin.com/foo/?f=0#h'); + testOrigin( + 'https://cdn.ampproject.org/action/www.origin.com/foo/?f=0#h', + 'http://www.origin.com/foo/?f=0#h'); + testOrigin( + 'https://cdn.ampproject.org/action/s/www.origin.com/foo/?f=0#h', + 'https://www.origin.com/foo/?f=0#h'); // Prefixed CDN