diff --git a/ads/_a4a-config.js b/ads/_a4a-config.js
index 9d7aab2fadea..84bbffbe029c 100644
--- a/ads/_a4a-config.js
+++ b/ads/_a4a-config.js
@@ -30,6 +30,7 @@ export function getA4ARegistry() {
'mgid': (win, adTag) =>
!adTag.hasAttribute('data-container') &&
!adTag.hasAttribute('data-website'),
+ 'insurads': () => true,
'nws': () => true,
'smartadserver': () => true,
'valueimpression': () => true,
diff --git a/build-system/compile/bundles.config.extensions.json b/build-system/compile/bundles.config.extensions.json
index fe6527cef57f..382369466483 100644
--- a/build-system/compile/bundles.config.extensions.json
+++ b/build-system/compile/bundles.config.extensions.json
@@ -108,6 +108,11 @@
"version": "0.1",
"latestVersion": "0.1"
},
+ {
+ "name": "amp-ad-network-insurads-impl",
+ "version": "0.1",
+ "latestVersion": "0.1"
+ },
{
"name": "amp-ad-network-mgid-impl",
"version": "0.1",
diff --git a/build-system/compile/bundles.legacy-latest-versions.jsonc b/build-system/compile/bundles.legacy-latest-versions.jsonc
index 5a67246d25f0..c106f487f972 100644
--- a/build-system/compile/bundles.legacy-latest-versions.jsonc
+++ b/build-system/compile/bundles.legacy-latest-versions.jsonc
@@ -20,6 +20,7 @@
"amp-ad-network-dianomi-impl": "0.1",
"amp-ad-network-doubleclick-impl": "0.1",
"amp-ad-network-fake-impl": "0.1",
+ "amp-ad-network-insurads-impl": "0.1",
"amp-ad-network-nws-impl": "0.1",
"amp-ad-network-valueimpression-impl": "0.1",
"amp-ad": "0.1",
diff --git a/build-system/test-configs/dep-check-config.js b/build-system/test-configs/dep-check-config.js
index e961ec1f23ed..cd9e635ec64a 100644
--- a/build-system/test-configs/dep-check-config.js
+++ b/build-system/test-configs/dep-check-config.js
@@ -141,6 +141,7 @@ exports.rules = [
// a4a ads depend on a4a.
'extensions/amp-ad-network-nws-impl/0.1/amp-ad-network-nws-impl.js->extensions/amp-a4a/0.1/amp-a4a.js',
'extensions/amp-ad-network-fake-impl/0.1/amp-ad-network-fake-impl.js->extensions/amp-a4a/0.1/amp-a4a.js',
+ 'extensions/amp-ad-network-insurads-impl/0.1/amp-ad-network-insurads-impl.js->extensions/amp-a4a/0.1/amp-a4a.js',
'extensions/amp-ad-network-adzerk-impl/0.1/amp-ad-network-adzerk-impl.js->extensions/amp-a4a/0.1/amp-a4a.js',
'extensions/amp-ad-network-smartads-impl/0.1/amp-ad-network-smartads-impl.js->extensions/amp-a4a/0.1/amp-a4a.js',
'extensions/amp-ad-network-doubleclick-impl/0.1/sra-utils.js->extensions/amp-a4a/0.1/amp-a4a.js',
@@ -163,6 +164,9 @@ exports.rules = [
'extensions/amp-ad-network-doubleclick-impl/0.1/amp-ad-network-doubleclick-impl.js->extensions/amp-a4a/0.1/refresh-manager.js',
'extensions/amp-ad-network-valueimpression-impl/0.1/amp-ad-network-valueimpression-impl.js->extensions/amp-a4a/0.1/refresh-manager.js',
+ // Depends on DoubleClick
+ 'extensions/amp-ad-network-insurads-impl/0.1/doubleclick-helper.js->extensions/amp-ad-network-doubleclick-impl/0.1/amp-ad-network-doubleclick-impl.js',
+
// AMP access depends on AMP access
'extensions/amp-access-scroll/0.1/scroll-impl.js->extensions/amp-access/0.1/amp-access-client.js',
@@ -536,6 +540,10 @@ exports.rules = [
'extensions/amp-ad-network-doubleclick-impl/0.1/amp-ad-network-doubleclick-impl.js',
'extensions/amp-ad-network-adsense-impl/0.1/amp-ad-network-adsense-impl.js',
],
+ allowlist: [
+ // Depends on DoubleClick
+ 'extensions/amp-ad-network-insurads-impl/0.1/doubleclick-helper.js->extensions/amp-ad-network-doubleclick-impl/0.1/amp-ad-network-doubleclick-impl.js',
+ ],
},
// Delayed fetch for Doubleclick will be deprecated on March 29, 2018.
diff --git a/examples/amp-ad/a4a.amp.html b/examples/amp-ad/a4a.amp.html
index 30bef5344703..4faad77f71da 100644
--- a/examples/amp-ad/a4a.amp.html
+++ b/examples/amp-ad/a4a.amp.html
@@ -42,6 +42,14 @@
A4A Examples
+ Insurads
+
+
Fake ad network
+
+
+
+
+ InsurAds example - AMP News Example
+
+
+
+
+
+
+
+
+
+
+
+
InsurAds example
+
Today's Headlines
+
+
+
Breaking News
+
Stay updated with the latest news from around the world. Fast, reliable, and always up to date.
+
+
+
+
+
+
+
+
+
World
+
Global leaders meet to discuss climate change initiatives and future cooperation.
+
+
+
Technology
+
New smartphone release sets the bar for innovation and user experience.
+
+
+
Sports
+
Local team wins championship in a thrilling final match.
+
+
+
+
+
+
diff --git a/extensions/amp-ad-network-insurads-impl/0.1/amp-ad-network-insurads-impl.js b/extensions/amp-ad-network-insurads-impl/0.1/amp-ad-network-insurads-impl.js
new file mode 100644
index 000000000000..865376d4870b
--- /dev/null
+++ b/extensions/amp-ad-network-insurads-impl/0.1/amp-ad-network-insurads-impl.js
@@ -0,0 +1,752 @@
+import {CONSENT_POLICY_STATE} from '#core/constants/consent-state';
+import {Deferred} from '#core/data-structures/promise';
+import {tryParseJson} from '#core/types/object/json';
+
+import {Services} from '#service';
+
+import {user} from '#utils/log';
+
+import {
+ getConsentMetadata,
+ getConsentPolicyInfo,
+ getConsentPolicySharedData,
+ getConsentPolicyState,
+} from 'src/consent';
+
+import {Core} from './core';
+import {DoubleClickHelper} from './doubleclick-helper';
+import {ExtensionCommunication} from './extension';
+import {CryptoUtils} from './utilities';
+import {VisibilityTracker} from './visibility-tracking';
+import {Waterfall} from './waterfall';
+
+import {AmpA4A, hasStorageConsent} from '../../amp-a4a/0.1/amp-a4a';
+
+/** @type {string} */
+const TAG = 'amp-ad-network-insurads-impl';
+
+export class AmpAdNetworkInsuradsImpl extends AmpA4A {
+ /**
+ * @param {!Element} element
+ */
+ constructor(element) {
+ super(element);
+
+ this.element.setAttribute('data-enable-refresh', 'false');
+
+ /** @private {number} */
+ this.unitId_ = 0;
+
+ /** @private {?Object} */
+ this.adResponseData_ = null;
+ /** @private {?Array>} */
+ this.sizes_ = null;
+
+ /** @private {number} */
+ this.parentMawId_ = 0;
+
+ /** @private {string} */
+ this.unitCode_ = CryptoUtils.generateCode();
+ /** @private {string} */
+ this.path_ = this.element.getAttribute('data-slot');
+ /** @private {!Object} */
+ this.requiredKeyValues_ = {};
+ /** @private {?Object} */
+ this.originalRtcConfig_ = tryParseJson(
+ this.element.getAttribute('rtc-config')
+ );
+
+ /** @private {boolean} */
+ this.isViewable_ = false;
+
+ /** @private {?Object} */
+ this.iabTaxonomy_ = {};
+
+ /** @private {boolean} */
+ this.appEnabled_ = false;
+ /** @private @const {!Deferred} */
+ this.appReadyDeferred_ = new Deferred();
+
+ /** @private {?ExtensionCommunication} */
+ this.extension_ = null;
+ /** @private @const {!Deferred} */
+ this.extensionReadyDeferred_ = new Deferred();
+
+ /** @private {?Waterfall} */
+ this.waterfall_ = null;
+
+ /** @public {?DoubleClickHelper} */
+ this.dCHelper = new DoubleClickHelper(this);
+ this.dCHelper.callMethod('constructor', element);
+
+ const publicId = this.element.getAttribute('data-public-id');
+ const {canonicalUrl} = Services.documentInfoForDoc(this.element);
+
+ this.getConsent_().then((consent) => {
+ this.initializeWithConsent_(consent, canonicalUrl, publicId);
+ });
+ }
+
+ /** @override */
+ buildCallback() {
+ this.dCHelper.callMethod('buildCallback');
+ }
+
+ /** @override */
+ onCreativeRender(creativeMetaData, opt_onLoadPromise) {
+ this.isRefreshing = false;
+ this.dCHelper.callMethod(
+ 'onCreativeRender',
+ creativeMetaData,
+ opt_onLoadPromise
+ );
+ }
+
+ /** @override */
+ refresh(refreshEndCallback) {
+ if (this.isRefreshing) {
+ return;
+ }
+ this.refreshCount_++;
+
+ return super.refresh(refreshEndCallback);
+ }
+
+ /** @override */
+ extractSize(responseHeaders) {
+ this.adResponseData_ = {
+ lineItemId: responseHeaders.get('google-lineitem-id') || '-1',
+ creativeId: responseHeaders.get('google-creative-id') || '-1',
+ servedSize: responseHeaders.get('google-size') || '',
+ };
+
+ this.appReadyDeferred_.promise.then(() => {
+ this.sendUnitInit_();
+ });
+
+ this.extensionReadyDeferred_.promise.then(() => {
+ if (this.extension_) {
+ const entry = this.waterfall_
+ ? this.waterfall_.getCurrentEntry()
+ : null;
+
+ this.extension_.bannerChanged({
+ unitId: this.getUnitId_(),
+ shortId: this.unitId_,
+ impressionId: CryptoUtils.generateImpressionId(),
+ provider: entry ? entry.provider : '',
+ width: this.adResponseData_.servedSize.width,
+ height: this.adResponseData_.servedSize.height,
+ });
+ }
+ });
+
+ return this.dCHelper.callMethod('extractSize', responseHeaders);
+ }
+
+ /** @override */
+ getAdUrl(opt_consentTuple, opt_rtcResponsesPromise, opt_serveNpaSignal) {
+ this.getAdUrlDeferred = new Deferred();
+ this.getAdUrlInsurAdsDeferred = new Deferred();
+ const self = this;
+ this.dCHelper.callMethod(
+ 'getAdUrl',
+ opt_consentTuple,
+ opt_rtcResponsesPromise,
+ opt_serveNpaSignal
+ );
+ this.getAdUrlDeferred.promise.then((doubleClickUrl) => {
+ const augmentedAdUrl = this.augmentAdUrl_(doubleClickUrl);
+ self.getAdUrlInsurAdsDeferred.resolve(augmentedAdUrl);
+ });
+ return this.getAdUrlInsurAdsDeferred.promise;
+ }
+
+ /** @override */
+ tearDownSlot() {
+ this.dCHelper.callMethod('tearDownSlot');
+ }
+
+ /** @override */
+ forceCollapse() {
+ if (this.refreshCount_ === 0) {
+ super.forceCollapse();
+ this.destroy_();
+ } else {
+ this.triggerImmediateRefresh_();
+ }
+ }
+
+ /**
+ * Initializes the InsurAds instance with consent data.
+ * @param {string=} consent - The consent data string
+ * @param {string} canonicalUrl - The canonical URL of the document
+ * @param {string} publicId - The public ID for the ad
+ * @private
+ */
+ initializeWithConsent_(consent, canonicalUrl, publicId) {
+ const consentTuple = consent ? this.parseConsent_(consent) : null;
+ const storageConsent = hasStorageConsent(consentTuple);
+
+ this.core_ = Core.start(this.win, canonicalUrl, publicId, storageConsent);
+ this.core_.registerUnit(this.unitCode_, this.handleReconnect_.bind(this), {
+ appInitHandler: (message) => this.handleAppInit_(message),
+ unitInitHandler: (message) => this.handleUnitInit_(message),
+ waterfallHandler: (message) => this.handleWaterfall_(message),
+ });
+ }
+
+ /**
+ * Appends InsurAds URL parameters for ad requests.
+ * @param {string} adUrl
+ * @return {string} The augmented URL with InsurAds parameters
+ * @private
+ */
+ augmentAdUrl_(adUrl) {
+ if (
+ !this.appEnabled_ ||
+ !this.waterfall_ ||
+ this.api_.getRefreshCount() === 0
+ ) {
+ // If app is not enabled or no waterfall, return the original ad URL
+ console /*OK*/
+ .log(
+ 'InsurAds: App not enabled or no waterfall, returning original ad URL'
+ );
+ const url = new URL(adUrl); // TODO: return adUrl original
+ const params = url.searchParams;
+ params.set('iat', 'not-enabled');
+ return url.toString();
+ }
+
+ const url = new URL(adUrl);
+ const params = url.searchParams;
+ const entry = this.waterfall_.getCurrentEntry();
+
+ if (entry.path) {
+ params.set('iu', entry.path);
+ }
+
+ this.mergeKeyValuesWithParams_(params, entry);
+ this.addUserSignalsToParams_(params, entry);
+ this.parseSizesFromParams_(params);
+
+ return url.toString();
+ }
+
+ /**
+ * Merges entry key values with existing URL parameters
+ * @param {!URLSearchParams} params - The URL parameters
+ * @param {!Object} entry - The waterfall entry
+ * @private
+ */
+ mergeKeyValuesWithParams_(params, entry) {
+ if (!params || !entry) {
+ return;
+ }
+ const existingKeyValues = params.get('scp');
+ const allKeyValues = [];
+
+ if (entry.keyValues) {
+ allKeyValues.push(...entry.keyValues);
+ }
+ if (entry.commonKeyValues) {
+ allKeyValues.push(...entry.commonKeyValues);
+ }
+
+ if (allKeyValues.length === 0) {
+ return;
+ }
+
+ const serializedKeyValues = this.serializeKeyValueArray_(allKeyValues);
+ const mergedKeyValues = existingKeyValues
+ ? `${existingKeyValues}&${serializedKeyValues}`
+ : serializedKeyValues;
+
+ params.set('scp', mergedKeyValues);
+ }
+
+ /**
+ * Adds IAB taxonomy user signals to URL parameters if conditions are met
+ * @param {!URLSearchParams} params - The URL parameters to modify
+ * @param {!Object} entry - The waterfall entry
+ * @private
+ */
+ addUserSignalsToParams_(params, entry) {
+ if (!params || !entry) {
+ return;
+ }
+
+ if (!this.iabTaxonomy_ || !entry.isHouseDemand) {
+ return;
+ }
+
+ try {
+ const userSignals = this.convertToUserSignals_(this.iabTaxonomy_);
+ const encodedSignals = this.encodeUserSignals_(userSignals);
+ params.set('ppsj', encodedSignals);
+ } catch (error) {
+ console /*Ok*/
+ .error('Failed to encode user signals:', error);
+ }
+ }
+
+ /**
+ * Encodes user signals for URL transmission
+ * @param {!Object} userSignals - The user signals object
+ * @return {string} Base64 encoded and URI encoded signals
+ * @private
+ */
+ encodeUserSignals_(userSignals) {
+ const jsonString = JSON.stringify(userSignals);
+ const base64Encoded = btoa(jsonString);
+ return encodeURIComponent(base64Encoded);
+ }
+
+ /**
+ * Parses and stores ad sizes from URL parameters
+ * @param {!URLSearchParams} params - The URL parameters
+ * @private
+ */
+ parseSizesFromParams_(params) {
+ const sizesString = params.get('sz');
+
+ if (!sizesString) {
+ this.sizes_ = [];
+ return;
+ }
+
+ try {
+ this.sizes_ = this.parseSizeString_(sizesString);
+ } catch (error) {
+ this.sizes_ = [];
+ }
+ }
+
+ /**
+ * refreshEndCallback
+ * @private
+ */
+ refreshEndCallback_() {
+ console /*OK*/
+ .log('Refresh End Callback');
+ }
+
+ /**
+ * Triggers an immediate refresh of the ad.
+ * This can be called when receiving realtime messages that require a refresh
+ * or an Extension Refresh message.
+ * @return {boolean}
+ * @private
+ */
+ triggerImmediateRefresh_() {
+ if (!this.appEnabled_) {
+ this.destroy_();
+ return false;
+ }
+
+ if (this.isRefreshing) {
+ return false;
+ }
+
+ if (!this.iframe) {
+ return false;
+ }
+
+ const nextEntry = this.waterfall_.getNextEntry();
+
+ if (!nextEntry) {
+ return false;
+ }
+
+ this.updateRtcConfig_(nextEntry);
+
+ this.refresh(this.refreshEndCallback_);
+ }
+
+ /**
+ * Handles reconnection to InsurAds
+ * This is called when the WebSocket connection is lost and needs to be re-established.
+ * @private
+ * */
+ handleReconnect_() {
+ this.sendUnitInit_(true);
+ }
+
+ /**
+ * Handles app initialization messages
+ * @param {!Object} message - The app initialization message
+ * @private
+ */
+ handleAppInit_(message) {
+ if (message.status !== undefined) {
+ this.appEnabled_ = message.status > 0 ? true : false;
+
+ if (!this.appEnabled_) {
+ this.destroy_();
+ return;
+ }
+
+ if (!this.appReadyDeferred_.isDone()) {
+ this.appReadyDeferred_.resolve();
+ }
+
+ this.populateRequiredKeysAndValues_(message.requiredKeys);
+ }
+
+ if (message.iabTaxonomy !== undefined) {
+ this.iabTaxonomy_ = message.iabTaxonomy;
+ }
+
+ if (!this.visibilityTracker) {
+ this.visibilityTracker = new VisibilityTracker(
+ this.win,
+ this.element,
+ this.onVisibilityChange_.bind(this)
+ );
+ }
+ }
+
+ /**
+ * Handles unit initialization messages
+ * @param {!Object} message - The unit initialization message
+ * @private
+ */
+ handleUnitInit_(message) {
+ this.unitId_ = message.unitId;
+ this.element.setAttribute('tg-zone', this.getUnitId_());
+
+ if (window.frames['TG-listener'] && !this.extension_) {
+ this.extension_ = ExtensionCommunication.start(
+ this.getUnitId_(),
+ this.handlerExtensionMessages_.bind(this)
+ );
+ }
+
+ const {height, width} = this.creativeSize_ || this.initialSize_;
+
+ if (!this.extensionReadyDeferred_.isDone()) {
+ if (this.extension_) {
+ this.extension_.unitCreated({
+ unitId: this.getUnitId_(),
+ shortId: message.unitId,
+ sizes: this.sizes_,
+ rotation: message.rotation ? message.rotation : false,
+ visible: this.isViewable_,
+ width,
+ height,
+ });
+ }
+
+ this.extensionReadyDeferred_.resolve();
+ }
+ }
+
+ /**
+ * Handles unit waterfall messages
+ * @param {!Object} message - The app initialization message
+ * @private
+ */
+ handleWaterfall_(message) {
+ if (message.unitCode !== this.unitCode_) {
+ return;
+ }
+
+ this.waterfall_ = Waterfall.fromWaterfallMessage(message);
+ this.triggerImmediateRefresh_();
+ }
+
+ /**
+ * Handle incoming messages from the extension
+ * @param {MessageEvent} msg - The message event
+ * @private
+ */
+ handlerExtensionMessages_(msg) {
+ if (msg.data.unitId !== this.getUnitId_()) {
+ return;
+ }
+
+ switch (msg.data.action) {
+ case 'changeBanner':
+ this.sendUnitInit_(false, true);
+ break;
+ }
+ }
+
+ /**
+ * Handles visibility changes
+ * @param {!Object} visibilityData - Visibility data object
+ * @private
+ */
+ onVisibilityChange_(visibilityData) {
+ if (this.isViewable_ !== visibilityData.isViewable && this.appEnabled_) {
+ this.core_.sendUnitSnapshot(this.unitCode_, visibilityData.isViewable);
+ }
+ this.isViewable_ = visibilityData.isViewable;
+ }
+
+ /**
+ * Sends the unit initialization message
+ * @param {boolean=} reconnect - Whether this is a reconnect
+ * @param {boolean=} passback - Whether this is a passback
+ * @private
+ */
+ sendUnitInit_(reconnect = false, passback = false) {
+ if (this.appEnabled_) {
+ const entry = this.waterfall_ ? this.waterfall_.getCurrentEntry() : null;
+ const unitInit = {
+ unitCode: this.unitCode_,
+ keyValues: this.requiredKeyValues_,
+ path: entry ? entry.path : this.path_,
+ lineItemId: this.adResponseData_.lineItemId,
+ creativeId: this.adResponseData_.creativeId,
+ servedSize: this.adResponseData_.servedSize,
+ isHouseDemand: entry ? entry.isHouseDemand : false,
+ position: entry ? entry.position : undefined,
+ parentMawId: this.parentMawId_,
+ sizes: this.sizes_,
+ };
+
+ this.core_.sendUnitInit(unitInit, reconnect, passback);
+ }
+ }
+
+ /**
+ * Return the Full Unit Id with the slot index
+ * @return {string}
+ * @private
+ */
+ getUnitId_() {
+ const unitId =
+ this.unitId_ + '.' + this.element.getAttribute('data-amp-slot-index');
+ return unitId;
+ }
+
+ /**
+ * Destroy implementation
+ * This is called when the ad is removed from the DOM or refreshed
+ * @private
+ */
+ destroy_() {
+ if (this.visibilityTracker) {
+ this.visibilityTracker.destroy();
+ this.visibilityTracker = null;
+ }
+
+ if (this.extension_) {
+ this.extension_.unitRemoved(this.getUnitId_());
+ this.extension_ = null;
+ }
+ }
+
+ /**
+ * Serializes an array of [key, value] pairs into a query string.
+ * @param {!Array|string)>>} pairs
+ * @return {string}
+ * @private
+ */
+ serializeKeyValueArray_(pairs) {
+ return pairs
+ .map(([key, value]) => this.serializeItem_(key, value))
+ .join('&');
+ }
+
+ /**
+ * @param {string} key
+ * @param {(!Array|string)} value
+ * @return {string}
+ * @private
+ */
+ serializeItem_(key, value) {
+ const serializedValue = (Array.isArray(value) ? value : [value])
+ .map(encodeURIComponent)
+ .join();
+ return `${encodeURIComponent(key)}=${serializedValue}`;
+ }
+
+ /**
+ * Convert the IabTaxonomy to Google required format
+ * @param {object} data - IabTaxonomy data.
+ * @return {object} - Formatted signals
+ * @private
+ */
+ convertToUserSignals_(data) {
+ const taxonomyMap = {
+ content: 'IAB_CONTENT',
+ audience: 'IAB_AUDIENCE',
+ };
+
+ const PublisherProvidedTaxonomySignals = [];
+
+ for (const [type, taxonomies] of Object.entries(data)) {
+ const prefix = taxonomyMap[type];
+ if (!prefix) {
+ continue;
+ }
+
+ for (const [version, items] of Object.entries(taxonomies)) {
+ const values = items
+ .map((item) => item.id)
+ .filter((id) => id !== undefined);
+
+ if (values.length > 0) {
+ PublisherProvidedTaxonomySignals.push({
+ taxonomy: `${prefix}_${version.replace(/\./g, '_')}`,
+ values,
+ });
+ }
+ }
+ }
+
+ return {PublisherProvidedTaxonomySignals};
+ }
+
+ /**
+ * Updates the rtc-config attribute based on the next entry / original rtc config.
+ * @param {!Object} entry
+ * @private
+ */
+ updateRtcConfig_(entry) {
+ let rtcConfigStr = null;
+
+ if (
+ entry.isHouseDemand &&
+ entry.vendors &&
+ Object.keys(entry.vendors).length > 0
+ ) {
+ rtcConfigStr = JSON.stringify({
+ vendors: entry.vendors,
+ timeoutMillis: 750,
+ });
+ } else if (
+ this.originalRtcConfig_ &&
+ Object.keys(this.originalRtcConfig_).length > 0
+ ) {
+ rtcConfigStr = JSON.stringify(this.originalRtcConfig_);
+ }
+
+ if (rtcConfigStr) {
+ const currentRtcConfig = this.element.getAttribute('rtc-config');
+ if (currentRtcConfig !== rtcConfigStr) {
+ this.element.setAttribute('rtc-config', rtcConfigStr);
+ }
+ } else {
+ this.element.removeAttribute('rtc-config');
+ }
+ }
+
+ /**
+ * Populates requiredKeys_ and requiredKeyValues_ from requiredKeys and element targeting.
+ * @param {!Array} requiredKeys
+ * @private
+ */
+ populateRequiredKeysAndValues_(requiredKeys) {
+ if (!Array.isArray(requiredKeys) || requiredKeys.length === 0) {
+ return;
+ }
+
+ const jsonTargeting = tryParseJson(this.element.getAttribute('json')) || {};
+ const {targeting} = jsonTargeting;
+ requiredKeys.forEach((key) => {
+ if (targeting && targeting[key]) {
+ this.requiredKeyValues_[key] = targeting[key];
+ }
+ });
+ }
+
+ /**
+ * Get Consent
+ * @return {!Promise>} - Resolves with consent state, string, metadata, and shared data, or undefined if no policy ID
+ * @private
+ */
+ getConsent_() {
+ const consentPolicyId = super.getConsentPolicy();
+
+ if (consentPolicyId) {
+ const consentStatePromise = getConsentPolicyState(
+ this.element,
+ consentPolicyId
+ ).catch((err) => {
+ user().error(TAG, 'Error determining consent state', err);
+ return CONSENT_POLICY_STATE.UNKNOWN;
+ });
+
+ const consentStringPromise = getConsentPolicyInfo(
+ this.element,
+ consentPolicyId
+ ).catch((err) => {
+ user().error(TAG, 'Error determining consent string', err);
+ return null;
+ });
+
+ const consentMetadataPromise = getConsentMetadata(
+ this.element,
+ consentPolicyId
+ ).catch((err) => {
+ user().error(TAG, 'Error determining consent metadata', err);
+ return null;
+ });
+
+ const consentSharedDataPromise = getConsentPolicySharedData(
+ this.element,
+ consentPolicyId
+ ).catch((err) => {
+ user().error(TAG, 'Error determining consent shared data', err);
+ return null;
+ });
+
+ return Promise.all([
+ consentStatePromise,
+ consentStringPromise,
+ consentMetadataPromise,
+ consentSharedDataPromise,
+ ]);
+ }
+
+ return Promise.resolve(null);
+ }
+
+ /**
+ * Parses the consent tuple into a structured object
+ * @param {Array} consentResponse - The consent response array
+ * @return {?ConsentTupleDef} The parsed consent object
+ * @private
+ */
+ parseConsent_(consentResponse) {
+ const consentState = consentResponse[0];
+ const consentString = consentResponse[1];
+ const consentMetadata = consentResponse[2];
+ const consentSharedData = consentResponse[3];
+
+ const gdprApplies = consentMetadata
+ ? consentMetadata['gdprApplies']
+ : consentMetadata;
+ const additionalConsent = consentMetadata
+ ? consentMetadata['additionalConsent']
+ : consentMetadata;
+ const consentStringType = consentMetadata
+ ? consentMetadata['consentStringType']
+ : consentMetadata;
+ const purposeOne = consentMetadata
+ ? consentMetadata['purposeOne']
+ : consentMetadata;
+ const gppSectionId = consentMetadata
+ ? consentMetadata['gppSectionId']
+ : consentMetadata;
+
+ return {
+ consentState,
+ consentString,
+ consentStringType,
+ gdprApplies,
+ additionalConsent,
+ consentSharedData,
+ purposeOne,
+ gppSectionId,
+ };
+ }
+}
+
+AMP.extension(TAG, '0.1', (AMP) => {
+ AMP.registerElement(TAG, AmpAdNetworkInsuradsImpl);
+});
diff --git a/extensions/amp-ad-network-insurads-impl/0.1/cookie.js b/extensions/amp-ad-network-insurads-impl/0.1/cookie.js
new file mode 100644
index 000000000000..08a4e09096db
--- /dev/null
+++ b/extensions/amp-ad-network-insurads-impl/0.1/cookie.js
@@ -0,0 +1,175 @@
+import {getCookie, setCookie} from 'src/cookies';
+
+import {CryptoUtils} from './utilities';
+
+/** @type {string} */
+const SCN = '___iat_ses';
+/** @type {string} */
+const VCN = '___iat_vis';
+/** @type {number} */
+const SCD = 30 * 60; // 30 minutes
+/** @type {number} */
+const VCD = 6 * 30 * 24 * 60 * 60; // 6 months
+
+export class Cookie {
+ /**
+ * Cookie constructor`
+ * @param {Window} win
+ * @param {boolean} allowStorage
+ */
+ constructor(win, allowStorage) {
+ /** @private {!Document} */
+ this.win_ = win;
+
+ /** @private {boolean} */
+ this.cookies_ = true;
+ /** @private {boolean} */
+ this.consent_ = allowStorage;
+
+ // Generate a random session ID
+ /** @private {string|null} */
+ this.sessionId_ = CryptoUtils.generateSessionId();
+
+ // Read and update session cookie
+ /** @private {string|null} */
+ this.sessionCookie_ = this.getCookie_(SCN);
+ /** @private {boolean} */
+ this.newVisitor_ = this.sessionCookie_ ? false : true;
+ /** @private {boolean|null} */
+ this.cookiesEnabled_ = this.writeAndTestCookie_(SCN, SCD, this.sessionId_);
+
+ // Read visitor cookie
+ /** @private {string|null} */
+ this.visitCookie_ = this.getCookie_(VCN);
+ }
+
+ /**
+ * Get Session Cookie
+ * @return {string|null}
+ */
+ getSessionCookie() {
+ return this.sessionCookie_;
+ }
+
+ /**
+ * Get Visit Cookie
+ * @return {string|null}
+ */
+ getVisitCookie() {
+ return this.visitCookie_;
+ }
+
+ /**
+ * Get Last Time Stamp
+ * @return {number} - The last timestamp from the visit cookie or the current timestamp
+ */
+ getLastTimeStamp() {
+ if (this.visitCookie_) {
+ const parts = this.visitCookie_.split('.');
+ return parts.length > 2 ? parseInt(parts[2], 10) : 0;
+ }
+ return 0;
+ }
+
+ /**
+ * Get Cookies Enabled
+ * @return {boolean}
+ */
+ isCookiesEnabled() {
+ return this.cookiesEnabled_;
+ }
+
+ /**
+ * Get New Visitor
+ * @return {boolean}
+ */
+ isNewVisitor() {
+ return this.newVisitor_;
+ }
+
+ /**
+ * Update Visitor Cookie
+ * @param {string} lockedId
+ * @param {number} ts - The server timestamp
+ */
+ updateVisitCookie(lockedId, ts) {
+ // Update visitor cookie with current server timestamp, plus all IatId stuff
+ this.writeCookie_(VCN, VCD, this.prepareVisitorCookie_(lockedId, ts));
+ }
+
+ /**
+ * Get Cookie
+ * @param {string} cookieName
+ * @return {string|undefined} - The value of the cookie or undefined if not found
+ */
+ getCookie_(cookieName) {
+ return getCookie(this.win_, cookieName);
+ }
+
+ /**
+ * Write Cookie
+ * @param {string} cookieName
+ * @param {number} cookieDuration
+ * @param {string} cookieValue
+ */
+ writeCookie_(cookieName, cookieDuration, cookieValue) {
+ const expires = Date.now() + (this.cookies_ ? cookieDuration : -1) * 1000;
+ const options = {
+ highestAvailableDomain: true,
+ };
+
+ setCookie(this.win_, cookieName, cookieValue, expires, options);
+ }
+
+ /**
+ * Write and test Cookie
+ * @param {string} cookieName
+ * @param {number} cookieDuration
+ * @param {string} cookieValue
+ * @return {boolean} - True if the cookie was successfully written and tested
+ */
+ writeAndTestCookie_(cookieName, cookieDuration, cookieValue) {
+ // Write cookie as usual and test it
+ this.writeCookie_(cookieName, cookieDuration, cookieValue);
+ const content = this.getCookie_(cookieName);
+
+ // If consent was not granted, set cookies as not supported from now on
+ // and delete the cookie previously created
+ if (!this.consent_) {
+ this.cookies_ = false;
+ this.writeCookie_(cookieName, cookieDuration, cookieValue);
+ }
+
+ // True if domain cookies are supported
+ return !!content;
+ }
+
+ /**
+ * Prepare visitor cookie
+ * @param {string} lockedId
+ * @param {number} ts - The server timestamp
+ * @return {string} - The formatted visitor cookie string
+ */
+ prepareVisitorCookie_(lockedId, ts) {
+ const visitCookieParts = (this.visitCookie_ || '').split('.');
+
+ let retVal =
+ this.sessionId_ +
+ '.' +
+ lockedId +
+ '.' +
+ ts +
+ '.' +
+ (visitCookieParts[3] || '') +
+ '.' +
+ (visitCookieParts[4] || '') +
+ '.' +
+ (visitCookieParts[5] || '') +
+ '.' +
+ (visitCookieParts[6] || '');
+
+ // If available, store the new server choice for later decisions, otherwise keep current value
+ retVal += '.' + (visitCookieParts[7] || ''); // (c.lockedId || locked.lLockedId) - not available at the moment, use existing
+ return retVal;
+ }
+}
diff --git a/extensions/amp-ad-network-insurads-impl/0.1/core.js b/extensions/amp-ad-network-insurads-impl/0.1/core.js
new file mode 100644
index 000000000000..7d2965fe4e1d
--- /dev/null
+++ b/extensions/amp-ad-network-insurads-impl/0.1/core.js
@@ -0,0 +1,401 @@
+import {Cookie} from './cookie';
+import {EngagementTracker} from './engagement-tracking';
+import {ExtensionCommunication} from './extension';
+import {
+ AppInitMessage,
+ HandshakeMessage,
+ MessageFactory,
+ MessageHandler,
+ PageStatusMessage,
+ UnitInitMessage,
+ UnitSnapshotMessage,
+} from './messages';
+import {RealtimeManager} from './realtime-manager';
+import {LockedId} from './utilities';
+/**
+ * Insurads Core
+ */
+export class Core {
+ /** @private {?Core} */
+ static instance_ = null;
+
+ /** @private {!Object} */
+ unitHandlerMap = {};
+
+ /** @private {!EngagementTracker} */
+ engagement_ = null;
+
+ /** @private {?AppInitResponseMessage} */
+ appInitResponse_ = null;
+
+ /**
+ * Constructs the Core instance.
+ * @param {Window} win
+ * @param {string} canonicalUrl - Canonical URL
+ * @param {string} publicId - The public ID
+ * @param {boolean} hasStorageConsent - If Consent was given or does not apply
+ */
+ constructor(win, canonicalUrl, publicId, hasStorageConsent) {
+ this.win_ = win;
+ this.canonicalUrl_ = canonicalUrl;
+ this.publicId_ = publicId;
+ this.consent_ = hasStorageConsent;
+
+ /** @private {!LockedId} */
+ this.lockedData_ = new LockedId().getLockedIdData(this.consent_);
+ /** @private {!ExtensionCommunication} */
+ this.extension_ = win.frames['TG-listener']
+ ? new ExtensionCommunication()
+ : null;
+ /** @private {!Cookie} */
+ this.cookies_ = new Cookie(this.win_, this.consent_);
+ }
+
+ /**
+ * Returns the singleton instance of Core.
+ * @param {Window} win - The window object
+ * @param {string} canonicalUrl - The canonical URL
+ * @param {string} publicId - The public ID
+ * @param {boolean} hasStorageConsent - If Consent was given or does not apply
+ * @return {!Core}
+ * @public
+ */
+ static start(win, canonicalUrl, publicId, hasStorageConsent) {
+ if (!Core.instance_) {
+ Core.instance_ = new Core(win, canonicalUrl, publicId, hasStorageConsent);
+ Core.instance_.setupRealtimeConnection_();
+ }
+
+ return Core.instance_;
+ }
+
+ /**
+ * Registers a new ad unit with the Core service.
+ * Each ad unit instance on the page should call this.
+ * @param {string} unitCode - The unique code for the ad unit.
+ * @param {function()} reconnectHandler - Handler for reconnection logic.
+ * @param {Object=} handlers - Message handlers for this specific ad unit.
+ */
+ registerUnit(unitCode, reconnectHandler, handlers = {}) {
+ this.unitHandlerMap[unitCode] = new UnitHandlers(
+ reconnectHandler,
+ new MessageHandler(handlers)
+ );
+
+ // If the app is already initialized, immediately send the config to the new ad unit.
+ if (this.appInitResponse_) {
+ this.unitHandlerMap[unitCode].messageHandlers.processMessage(
+ this.appInitResponse_
+ );
+ }
+ }
+
+ /**
+ * Sets up the realtime connection and event handlers
+ * @param {boolean} reconnect
+ * @private
+ */
+ setupRealtimeConnection_(reconnect = false) {
+ /** @private {!RealtimeManager} */
+ this.realtimeManager_ = RealtimeManager.start(
+ this.publicId_,
+ this.canonicalUrl_
+ );
+
+ if (this.realtimeManager_) {
+ this.realtimeManager_.onReceiveMessage = this.dispatchMessage_.bind(this);
+ this.realtimeManager_.onConnect = this.onRealtimeConnect_.bind(
+ this,
+ reconnect
+ );
+ this.realtimeManager_.onDisconnect = (event) => {
+ if (event.code !== 1000) {
+ this.destroy();
+ }
+ };
+ }
+ }
+
+ /**
+ * Sends a handshake message
+ * @private
+ */
+ sendHandshake_() {
+ const handshake = new HandshakeMessage();
+ this.realtimeManager_.sendHandshake(handshake.serialize());
+ }
+
+ /**
+ * Sends an app initialization message
+ * @param {boolean=} reconnect - Reconnect flag
+ * @private
+ */
+ sendAppInit_(reconnect = false) {
+ const appInit = new AppInitMessage({
+ lockedId: this.lockedData_,
+ newVisitor: this.cookies_.isNewVisitor(),
+ lastTimestamp: this.cookies_.getLastTimeStamp(),
+ extension: !!this.extension_,
+ reconnect,
+ });
+ this.realtimeManager_.send(appInit.serialize());
+ }
+
+ /**
+ * Sends an ad unit initialization message
+ * @param {{
+ * unitCode: string,
+ * creativeId: (string|undefined),
+ * isHouseDemand: (boolean|undefined),
+ * keyValues: (Array|undefined),
+ * lineItemId: (string|undefined),
+ * parentMawId: (number|undefined),
+ * path: (string|undefined),
+ * position: (number|undefined),
+ * servedSize: (string|undefined)
+ * sizes: (Array|undefined)
+ * }} unitInfo - Ad unit information object.
+ * @param {boolean=} reconnect - Reconnect flag
+ * @param {boolean=} passback - Passback flag
+ */
+ sendUnitInit(unitInfo, reconnect = false, passback = false) {
+ const info = {
+ ...unitInfo,
+ reconnect,
+ passback,
+ };
+ const unitInit = new UnitInitMessage(info);
+ this.realtimeManager_.send(unitInit.serialize());
+ }
+
+ /**
+ * Sends an ad unit visibility snapshot
+ * @param {string} unitCode - Ad unit code
+ * @param {boolean} visible - Visibility
+ */
+ sendUnitSnapshot(unitCode, visible) {
+ const snapshot = new UnitSnapshotMessage(unitCode, visible);
+ this.realtimeManager_.send(snapshot.serialize());
+ }
+
+ /**
+ * Sends a page status update
+ * @param {!Object} state - Engagement state object
+ * @private
+ */
+ sendPageStatus_(state) {
+ if (
+ state.isEngaged &&
+ this.realtimeManager_ &&
+ !this.realtimeManager_.isConnected()
+ ) {
+ this.setupRealtimeConnection_(true);
+ }
+
+ const status = new PageStatusMessage(state.isEngaged, state.isVisible);
+ this.realtimeManager_.send(status.serialize());
+ }
+
+ /**
+ * Disconnects the WebSocket connection
+ * @param {boolean} clearQueue - Whether to clear the message queue on disconnect (default: true)
+ * @param {number=} code - Optional close code (default: 1000 - normal closure)
+ * @param {string=} reason - Optional reason for closing
+ * @return {boolean} Whether disconnection was successful
+ * @public
+ */
+ disconnect(clearQueue = true, code = 1000, reason = 'AMP is going away') {
+ if (!this.realtimeManager_) {
+ return false;
+ }
+
+ try {
+ this.realtimeManager_.disconnect(clearQueue, code, reason);
+ return true;
+ } catch (e) {
+ return false;
+ }
+ }
+
+ /**
+ * Handles logic to run when the realtime connection is established.
+ * @param {boolean} reconnect
+ * @private
+ */
+ onRealtimeConnect_(reconnect) {
+ this.sendHandshake_();
+ this.sendAppInit_(reconnect);
+ if (reconnect) {
+ for (const unitCode in this.unitHandlerMap) {
+ this.unitHandlerMap[unitCode].reconnectHandler();
+ }
+ }
+ }
+
+ /**
+ * Central dispatcher for all incoming WebSocket messages.
+ * Routes messages to the correct ad unit handler.
+ * Broadcasts global messages
+ * @param {string} raw The raw message string from the WebSocket.
+ * @private
+ */
+ dispatchMessage_(raw) {
+ const messages = raw.split('\u001e').filter(Boolean);
+
+ messages.forEach((rawMessage) => {
+ try {
+ // TODO: Investigate the need for the double parsing,
+ // maybe this can be improved on server/client
+ const messageData = JSON.parse(rawMessage);
+ const action = messageData.arguments[0];
+ const data = JSON.parse(messageData.arguments[1]);
+
+ const parsedMessage = MessageFactory.createMessage(action, data);
+ if (!parsedMessage) {
+ return;
+ }
+
+ if (action === 'app-init-response') {
+ // Global message, should broadcast to all units
+ for (const unitCode in this.unitHandlerMap) {
+ this.unitHandlerMap[unitCode].messageHandlers.processMessage(
+ parsedMessage
+ );
+ }
+
+ this.processAppInitResponse_(parsedMessage);
+ return;
+ }
+
+ const {unitCode} = parsedMessage.message;
+
+ if (unitCode && this.unitHandlerMap[unitCode]) {
+ const unitHandlers = this.unitHandlerMap[unitCode];
+ unitHandlers.messageHandlers.processMessage(parsedMessage);
+ }
+ } catch (e) {}
+ });
+ }
+
+ /**
+ * Handles app initialization messages
+ * @param {!AppInitResponseMessage} appInitMessage - The app initialization message
+ * @private
+ */
+ processAppInitResponse_(appInitMessage) {
+ const {message} = appInitMessage;
+
+ // Merge the app init response with the existing one
+ // This allows us to accumulate configuration data
+ // and avoid overwriting previous responses.
+ if (!this.appInitResponse_) {
+ this.appInitResponse_ = appInitMessage;
+ } else {
+ const existingPayload = this.appInitResponse_.message;
+ const newPayload = appInitMessage.message;
+ const mergedPayload = {...existingPayload, ...newPayload};
+ this.appInitResponse_.message = mergedPayload;
+ }
+
+ // It is an app init response with general configuration,
+ // Set app status, start engagement and extension if exists.
+ if (message.status !== undefined) {
+ this.status = message.status;
+ this.appEnabled = message.status > 0 ? true : false;
+
+ if (!this.appEnabled) {
+ this.destroy();
+ return;
+ }
+
+ if (!this.engagement_) {
+ const config = {
+ ivm: message.ivm,
+ };
+ this.engagement_ = new EngagementTracker(this.win_);
+ this.engagement_.init(config);
+ this.unlistenEngagement_ = this.engagement_.registerListener(
+ this.updateEngagementStatus_.bind(this)
+ );
+ }
+
+ if (this.extension_) {
+ this.extension_.setup({
+ applicationId: message.applicationId,
+ country: message.countryCode,
+ section: message.sectionId,
+ sessionId: this.cookies_.getSessionCookie(),
+ ivm: message.ivm,
+ state: this.engagement_.isEngaged() ? 1 : 0,
+ });
+ }
+
+ this.cookies_.updateVisitCookie(
+ message.lockedId,
+ message.serverTimestamp
+ );
+ }
+ }
+
+ /**
+ * Handles user engagement changes
+ * @param {!Object} state - Engagement state object
+ * @private
+ */
+ updateEngagementStatus_(state) {
+ this.sendPageStatus_(state);
+
+ if (this.extension_) {
+ this.extension_.engagementStatus({
+ index: state.isEngaged ? 1 : 0,
+ name: state.isEngaged ? 'Active' : 'Inactive',
+ });
+ }
+ }
+
+ /**
+ * Destroy implementation
+ * This is called when the ad is removed from the DOM or refreshed
+ * @public
+ */
+ destroy() {
+ if (this.unlistenEngagement_) {
+ this.unlistenEngagement_();
+ this.unlistenEngagement_ = null;
+ }
+
+ if (this.realtimeManager_) {
+ this.realtimeManager_.disconnect(true, 1000, 'Core is being destroyed');
+ this.realtimeManager_.destroy();
+ this.realtimeManager_ = null;
+ }
+
+ if (this.engagement_) {
+ this.engagement_.destroy();
+ this.engagement_ = null;
+ }
+
+ if (this.extension_) {
+ this.extension_.destroy();
+ this.extension_ = null;
+ }
+
+ this.unitHandlerMap = {};
+ }
+}
+
+class UnitHandlers {
+ /** @public {?function()} */
+ reconnectHandler = null;
+ /** @public {?MessageHandler} */
+ messageHandlers = null;
+
+ /**
+ * @param {function()} reconnectHandler - Handler for reconnection logic
+ * @param {Object=} handlers - Message handlers
+ */
+ constructor(reconnectHandler, handlers = {}) {
+ this.reconnectHandler = reconnectHandler;
+ this.messageHandlers = new MessageHandler(handlers);
+ }
+}
diff --git a/extensions/amp-ad-network-insurads-impl/0.1/doubleclick-helper.js b/extensions/amp-ad-network-insurads-impl/0.1/doubleclick-helper.js
new file mode 100644
index 000000000000..642b4f51a10c
--- /dev/null
+++ b/extensions/amp-ad-network-insurads-impl/0.1/doubleclick-helper.js
@@ -0,0 +1,80 @@
+import {AmpAdNetworkDoubleclickImpl} from '../../amp-ad-network-doubleclick-impl/0.1/amp-ad-network-doubleclick-impl';
+
+/**
+ * Helper class to manage DoubleClick implementation integration
+ */
+export class DoubleClickHelper {
+ /**
+ * @param {!Object} impl - The AmpAdNetworkInsuradsImpl instance
+ */
+ constructor(impl) {
+ /** @private {!Object} */
+ this.impl_ = impl;
+
+ /** @private {string} */
+ this.prefix_ = 'doubleClick';
+
+ /** @private {Array} */
+ this.exceptions_ = [
+ 'constructor',
+ 'buildCallback',
+ 'onCreativeRender',
+ 'refresh',
+ 'extractSize',
+ 'getAdUrl',
+ 'tearDownSlot',
+ ];
+
+ this.initializeDoubleClickMethods_();
+ }
+
+ /**
+ * Initializes the DoubleClick methods on the implementation
+ * @private
+ */
+ initializeDoubleClickMethods_() {
+ const implProto = this.impl_.constructor.prototype;
+ const dblProto = AmpAdNetworkDoubleclickImpl.prototype;
+
+ this.exceptions_.forEach((methodName) => {
+ implProto[this.getCapitalizedMethodWithPrefix_(methodName)] =
+ dblProto[methodName];
+ });
+
+ for (const methodName in dblProto) {
+ if (!this.exceptions_.includes(methodName)) {
+ implProto[methodName] = dblProto[methodName];
+ }
+ }
+ }
+
+ /**
+ * Returns capitalized method name with prefix
+ * @param {string} methodName - The method name
+ * @return {string} The prefixed method name
+ * @private
+ */
+ getCapitalizedMethodWithPrefix_(methodName) {
+ return (
+ this.prefix_ + methodName.charAt(0).toUpperCase() + methodName.slice(1)
+ );
+ }
+
+ /**
+ * Calls a DoubleClick implementation method
+ * @param {string} methodName - The name of the method to call
+ * @param {...*} args - Arguments to pass to the method
+ * @return {*} Result of the method call
+ */
+ callMethod(methodName, ...args) {
+ const prefixedName = this.getCapitalizedMethodWithPrefix_(methodName);
+
+ if (typeof this.impl_[prefixedName] === 'function') {
+ try {
+ return this.impl_[prefixedName](...args);
+ } catch (error) {}
+ }
+
+ return null;
+ }
+}
diff --git a/extensions/amp-ad-network-insurads-impl/0.1/engagement-tracking.js b/extensions/amp-ad-network-insurads-impl/0.1/engagement-tracking.js
new file mode 100644
index 000000000000..c84c09c2b638
--- /dev/null
+++ b/extensions/amp-ad-network-insurads-impl/0.1/engagement-tracking.js
@@ -0,0 +1,283 @@
+import {isDocumentHidden} from '#core/document/visibility';
+
+import {listen} from '#utils/event-helper';
+
+/**
+ * Enum for browser/user activity states
+ * @enum {number}
+ */
+export const BrowserState = {
+ UNKNOWN: -1,
+ INACTIVE: 0,
+ ACTIVE: 1,
+ IDLE: 2,
+};
+
+/**
+ * @typedef {{
+ * idleTimer: (number|undefined),
+ * ivm: (boolean|undefined)
+ * }} EngagementConfig
+ */
+
+/**
+ * Array of event types which will be listened for on the document to indicate
+ * activity. Other activities are also observed on the AmpDoc and Viewport
+ * objects.
+ * @private @const {Array}
+ */
+const ACTIVE_EVENT_TYPES = [
+ 'mousedown',
+ 'mouseup',
+ 'mousemove',
+ 'keydown',
+ 'keyup',
+];
+
+/**
+ * A singleton tracker for user engagement across all ad units.
+ * This tracks focus, visibility, and page state to determine if the user is engaged.
+ */
+export class EngagementTracker {
+ /**
+ * @param {!Window} win - Window object
+ */
+ constructor(win) {
+ /** @private {!Window} */
+ this.win_ = win;
+
+ /** @private {Array} */
+ this.listeners_ = [];
+
+ /** @private {Array} */
+ this.unlisteners_ = [];
+
+ /** @private {number} */
+ this.idleTimeout_ = 21000; // Default to 21 seconds
+
+ /** @private {?number} */
+ this.idleTimer_ = null;
+
+ /** @private {boolean} */
+ this.ivm_ = false;
+
+ /** @private {BrowserState} */
+ this.currentState_ = BrowserState.UNKNOWN;
+
+ /** @private {boolean} */
+ this.isFocused_ = false;
+
+ /** @private {boolean} */
+ this.isVisible_ = false;
+
+ /** @private {boolean} */
+ this.isOpen_ = false;
+
+ /** @private {boolean} */
+ this.isEngaged_ = false;
+
+ /** @private {boolean} */
+ this.isIdle_ = false;
+
+ /** @private {boolean} */
+ this.initialized_ = false;
+ }
+
+ /**
+ * Initialize event listeners
+ * @param {EngagementConfig=} config
+ */
+ init(config = {}) {
+ if (this.initialized_) {
+ return;
+ }
+ this.initialized_ = true;
+
+ if (config.idleTimer !== undefined) {
+ this.idleTimeout_ = config.idleTimer * 1000;
+ }
+
+ if (config.ivm !== undefined) {
+ this.ivm_ = config.ivm;
+ }
+
+ this.isFocused_ = this.win_.document.hasFocus();
+ this.isVisible_ = !isDocumentHidden(this.win_.document);
+ this.isOpen_ = true;
+ this.isEngaged_ = this.calculateEngaged_();
+ this.isIdle_ = false;
+
+ const onFocus = () => {
+ this.isFocused_ = true;
+ this.updateEngagement_();
+ };
+ const onBlur = () => {
+ this.isFocused_ = false;
+ this.updateEngagement_();
+ };
+ const onPageShow = () => {
+ this.isOpen_ = true;
+ this.updateEngagement_();
+ };
+ const onPageHide = () => {
+ this.isOpen_ = false;
+ this.updateEngagement_();
+ };
+ const onVisibilityChange = () => {
+ this.isVisible_ = !isDocumentHidden(this.win_.document);
+ this.updateEngagement_();
+ };
+
+ this.unlisteners_.push(
+ listen(this.win_, 'focus', onFocus),
+ listen(this.win_, 'blur', onBlur),
+ listen(this.win_, 'pageshow', onPageShow),
+ listen(this.win_, 'pagehide', onPageHide),
+ listen(this.win_.document, 'visibilitychange', onVisibilityChange)
+ );
+
+ this.setUpListenersFromArray_(
+ this.win_.document,
+ ACTIVE_EVENT_TYPES,
+ () => {
+ this.isFocused_ = true;
+ this.isVisible_ = true;
+ this.isOpen_ = true;
+ this.isIdle_ = false;
+ this.updateEngagement_();
+ }
+ );
+ }
+
+ /**
+ * Calculate the current engagement status
+ * @return {boolean} Whether the user is currently engaged
+ * @private
+ */
+ calculateEngaged_() {
+ return this.isOpen_ && this.isVisible_ && this.isFocused_;
+ }
+
+ /**
+ * Update engagement status and notify listeners if changed
+ * @private
+ */
+ updateEngagement_() {
+ const isEngaged = this.calculateEngaged_();
+
+ if (isEngaged !== this.isEngaged_) {
+ this.isEngaged_ = isEngaged;
+ this.currentState_ = isEngaged
+ ? BrowserState.ACTIVE
+ : BrowserState.INACTIVE;
+ this.notifyListeners_();
+ }
+
+ if (this.currentState_ === BrowserState.ACTIVE) {
+ this.restartIdleTimer_();
+ } else if (this.currentState_ === BrowserState.INACTIVE) {
+ clearTimeout(this.idleTimer_);
+ }
+ }
+
+ /**
+ * Notify all listeners of the current page state
+ * @private
+ */
+ notifyListeners_() {
+ this.listeners_.forEach((listener) => {
+ try {
+ listener(this.getState());
+ } catch (e) {}
+ });
+ }
+
+ /**
+ * Resets the time until the user state changes to idle
+ * @private
+ */
+ restartIdleTimer_() {
+ if (this.ivm_) {
+ return;
+ }
+
+ clearTimeout(this.idleTimer_);
+
+ this.idleTimer_ = setTimeout(() => {
+ this.isIdle_ = true;
+ this.currentState_ = BrowserState.IDLE;
+ }, this.idleTimeout_);
+ }
+
+ /**
+ * @private
+ * @param {!EventTarget} target
+ * @param {Array} events
+ * @param {function()} listener
+ */
+ setUpListenersFromArray_(target, events, listener) {
+ for (let i = 0; i < events.length; i++) {
+ this.unlisteners_.push(listen(target, events[i], listener));
+ }
+ }
+
+ /**
+ * Add a listener for engagement changes
+ * @param {function(!Object)} listener - Function called when engagement changes
+ * @return {function()} Function to remove the listener
+ */
+ registerListener(listener) {
+ this.listeners_.push(listener);
+
+ try {
+ const state = this.getState();
+ listener(state);
+ } catch (e) {}
+
+ return () => {
+ const index = this.listeners_.indexOf(listener);
+ if (index !== -1) {
+ this.listeners_.splice(index, 1);
+ }
+ };
+ }
+
+ /**
+ * Get current engagement state
+ * @return {boolean}
+ */
+ isEngaged() {
+ return this.isEngaged_;
+ }
+
+ /**
+ * Get detailed engagement state
+ * @return {!Object}
+ */
+ getState() {
+ return {
+ currentState: this.currentState_,
+ isEngaged: this.isEngaged_,
+ isFocused: this.isFocused_,
+ isVisible: this.isVisible_,
+ isOpen: this.isOpen_,
+ isIdle: this.isIdle_,
+ };
+ }
+
+ /**
+ * Clean up resources and reset the singleton
+ */
+ destroy() {
+ this.unlisteners_.forEach((unlisten) => {
+ try {
+ unlisten();
+ } catch (e) {}
+ });
+ this.unlisteners_ = [];
+ this.listeners_ = [];
+ clearTimeout(this.idleTimer_);
+ this.idleTimer_ = null;
+ this.initialized_ = false;
+ }
+}
diff --git a/extensions/amp-ad-network-insurads-impl/0.1/extension.js b/extensions/amp-ad-network-insurads-impl/0.1/extension.js
new file mode 100644
index 000000000000..14f678bc3e95
--- /dev/null
+++ b/extensions/amp-ad-network-insurads-impl/0.1/extension.js
@@ -0,0 +1,200 @@
+import {BrowserState} from './engagement-tracking';
+
+/**
+ * Handles communication between the AMP ad and the extension iframe
+ */
+export class ExtensionCommunication {
+ /** @private {?ExtensionCommunication} */
+ static instance_ = null;
+
+ /** @type {Object} */
+ unitHandlerMap_ = {};
+
+ /** @type {Array