diff --git a/injected/entry-points/android-adsjs.js b/injected/entry-points/android-adsjs.js new file mode 100644 index 0000000000..a6d4e5d271 --- /dev/null +++ b/injected/entry-points/android-adsjs.js @@ -0,0 +1,38 @@ +/** + * @module Android AdsJS integration + */ +import { load, init } from '../src/content-scope-features.js'; +import { processConfig } from './../src/utils'; +import { AndroidAdsjsMessagingConfig } from '../../messaging/index.js'; + +function initCode() { + // @ts-expect-error https://app.asana.com/0/1201614831475344/1203979574128023/f + const config = $CONTENT_SCOPE$; + // @ts-expect-error https://app.asana.com/0/1201614831475344/1203979574128023/f + const userUnprotectedDomains = $USER_UNPROTECTED_DOMAINS$; + // @ts-expect-error https://app.asana.com/0/1201614831475344/1203979574128023/f + const userPreferences = $USER_PREFERENCES$; + + const processedConfig = processConfig(config, userUnprotectedDomains, userPreferences); + + const configConstruct = processedConfig; + const objectName = configConstruct.objectName || 'contentScopeAdsjs'; + + processedConfig.messagingConfig = new AndroidAdsjsMessagingConfig({ + objectName, + target: globalThis, + debug: processedConfig.debug, + }); + + load({ + platform: processedConfig.platform, + site: processedConfig.site, + bundledConfig: processedConfig.bundledConfig, + messagingConfig: processedConfig.messagingConfig, + messageSecret: processedConfig.messageSecret, + }); + + init(processedConfig); +} + +initCode(); diff --git a/injected/scripts/entry-points.js b/injected/scripts/entry-points.js index f85aecc3f3..651d81f256 100644 --- a/injected/scripts/entry-points.js +++ b/injected/scripts/entry-points.js @@ -35,6 +35,10 @@ const builds = { input: 'entry-points/android.js', output: ['../build/android/autofillPasswordImport.js'], }, + 'android-adsjs': { + input: 'entry-points/android-adsjs.js', + output: ['../build/android/adsjsContentScope.js'], + }, windows: { input: 'entry-points/windows.js', output: ['../build/windows/contentScope.js'], diff --git a/injected/src/features.js b/injected/src/features.js index 12cea3f4be..eee7a1fa49 100644 --- a/injected/src/features.js +++ b/injected/src/features.js @@ -49,6 +49,16 @@ export const platformSupport = { android: [...baseFeatures, 'webCompat', 'breakageReporting', 'duckPlayer', 'messageBridge'], 'android-broker-protection': ['brokerProtection'], 'android-autofill-password-import': ['autofillPasswordImport'], + 'android-adsjs': [ + 'webCompat', + 'fingerprintingHardware', + 'fingerprintingScreenSize', + 'fingerprintingTemporaryStorage', + 'fingerprintingAudio', + 'fingerprintingBattery', + 'gpc', + 'breakageReporting', + ], windows: [ 'cookie', ...baseFeatures, diff --git a/injected/src/features/web-compat.js b/injected/src/features/web-compat.js index 615ca1f122..d02dc60b84 100644 --- a/injected/src/features/web-compat.js +++ b/injected/src/features/web-compat.js @@ -37,7 +37,6 @@ function canShare(data) { return false; } } - if (window !== window.top) return false; // Not supported in iframes return true; } diff --git a/injected/src/globals.d.ts b/injected/src/globals.d.ts index 25fc8b81bb..ff925ba034 100644 --- a/injected/src/globals.d.ts +++ b/injected/src/globals.d.ts @@ -20,7 +20,8 @@ interface ImportMeta { | 'integration' | 'chrome-mv3' | 'android-broker-protection' - | 'android-autofill-password-import'; + | 'android-autofill-password-import' + | 'android-adsjs'; trackerLookup?: Record; pageName?: string; } diff --git a/injected/unit-test/verify-artifacts.js b/injected/unit-test/verify-artifacts.js index 484228c184..586555d3e3 100644 --- a/injected/unit-test/verify-artifacts.js +++ b/injected/unit-test/verify-artifacts.js @@ -7,7 +7,7 @@ const ROOT = join(cwd(import.meta.url), '..', '..'); console.log(ROOT); const BUILD = join(ROOT, 'build'); -let CSS_OUTPUT_SIZE = 770_000; +let CSS_OUTPUT_SIZE = 780_000; if (process.platform === 'win32') { CSS_OUTPUT_SIZE = CSS_OUTPUT_SIZE * 1.1; // 10% larger for Windows due to line endings } diff --git a/messaging/index.js b/messaging/index.js index 299495fb98..db602a3225 100644 --- a/messaging/index.js +++ b/messaging/index.js @@ -30,6 +30,7 @@ import { import { WebkitMessagingConfig, WebkitMessagingTransport } from './lib/webkit.js'; import { NotificationMessage, RequestMessage, Subscription, MessageResponse, MessageError, SubscriptionEvent } from './schema.js'; import { AndroidMessagingConfig, AndroidMessagingTransport } from './lib/android.js'; +import { AndroidAdsjsMessagingConfig, AndroidAdsjsMessagingTransport } from './lib/android-adsjs.js'; import { createTypedMessages } from './lib/typed-messages.js'; /** @@ -51,7 +52,7 @@ export class MessagingContext { } /** - * @typedef {WebkitMessagingConfig | WindowsMessagingConfig | AndroidMessagingConfig | TestTransportConfig} MessagingConfig + * @typedef {WebkitMessagingConfig | WindowsMessagingConfig | AndroidMessagingConfig | AndroidAdsjsMessagingConfig | TestTransportConfig} MessagingConfig */ /** @@ -215,7 +216,7 @@ export class TestTransport { } /** - * @param {WebkitMessagingConfig | WindowsMessagingConfig | AndroidMessagingConfig | TestTransportConfig} config + * @param {WebkitMessagingConfig | WindowsMessagingConfig | AndroidMessagingConfig | AndroidAdsjsMessagingConfig | TestTransportConfig} config * @param {MessagingContext} messagingContext * @returns {MessagingTransport} */ @@ -229,6 +230,9 @@ function getTransport(config, messagingContext) { if (config instanceof AndroidMessagingConfig) { return new AndroidMessagingTransport(config, messagingContext); } + if (config instanceof AndroidAdsjsMessagingConfig) { + return new AndroidAdsjsMessagingTransport(config, messagingContext); + } if (config instanceof TestTransportConfig) { return new TestTransport(config, messagingContext); } @@ -268,5 +272,7 @@ export { WindowsRequestMessage, AndroidMessagingConfig, AndroidMessagingTransport, + AndroidAdsjsMessagingConfig, + AndroidAdsjsMessagingTransport, createTypedMessages, }; diff --git a/messaging/lib/android-adsjs.js b/messaging/lib/android-adsjs.js new file mode 100644 index 0000000000..530e4fb98b --- /dev/null +++ b/messaging/lib/android-adsjs.js @@ -0,0 +1,355 @@ +/** + * + * A wrapper for messaging on Android using addWebMessageListener API. + * + * This transport uses the Android WebView addWebMessageListener API for communication + * between JavaScript and native Android code. + * + */ +// eslint-disable-next-line @typescript-eslint/no-unused-vars +import { MessagingTransport, MessageResponse, SubscriptionEvent } from '../index.js'; +import { isResponseFor, isSubscriptionEventFor, RequestMessage } from '../schema.js'; +import { isBeingFramed } from '../../injected/src/utils.js'; + +/** + * @typedef {import('../index.js').Subscription} Subscription + * @typedef {import('../index.js').MessagingContext} MessagingContext + * @typedef {import('../index.js').NotificationMessage} NotificationMessage + */ + +/** + * An implementation of {@link MessagingTransport} for Android using addWebMessageListener + * + * All messages go through the Android WebView addWebMessageListener API + * + * @implements {MessagingTransport} + */ +export class AndroidAdsjsMessagingTransport { + /** + * @param {AndroidAdsjsMessagingConfig} config + * @param {MessagingContext} messagingContext + * @internal + */ + constructor(config, messagingContext) { + this.messagingContext = messagingContext; + this.config = config; + + // Send initial ping when transport is first created. + this.config.sendInitialPing(messagingContext); + } + + /** + * @param {NotificationMessage} msg + */ + notify(msg) { + try { + this.config.sendMessageThrows?.(msg); + } catch (e) { + console.error('.notify failed', e); + } + } + + /** + * @param {RequestMessage} msg + * @return {Promise} + */ + request(msg) { + return new Promise((resolve, reject) => { + // subscribe early + const unsub = this.config.subscribe(msg.id, handler); + + try { + this.config.sendMessageThrows?.(msg); + } catch (e) { + unsub(); + reject(new Error('request failed to send: ' + e.message || 'unknown error')); + } + + function handler(data) { + if (isResponseFor(msg, data)) { + // success case, forward .result only + if (data.result) { + resolve(data.result || {}); + return unsub(); + } + + // error case, forward the error as a regular promise rejection + if (data.error) { + reject(new Error(data.error.message)); + return unsub(); + } + + // getting here is undefined behavior + unsub(); + throw new Error('unreachable: must have `result` or `error` key by this point'); + } + } + }); + } + + /** + * @param {Subscription} msg + * @param {(value: unknown | undefined) => void} callback + */ + subscribe(msg, callback) { + const unsub = this.config.subscribe(msg.subscriptionName, (data) => { + if (isSubscriptionEventFor(msg, data)) { + callback(data.params || {}); + } + }); + return () => { + unsub(); + }; + } +} + +/** + * Android shared messaging configuration for addWebMessageListener API. + * This class should be constructed once and then shared between features. + * + * The following example shows all the fields that are required to be passed in: + * + * ```js + * const config = new AndroidAdsjsMessagingConfig({ + * // a value that native has injected into the script + * messageSecret: 'abc', + * + * // the object name that will be used for addWebMessageListener + * objectName: "androidAdsjs", + * + * // the global object where methods will be registered + * target: globalThis + * }); + * ``` + * + * ## Native integration + * + * The native Android code should use addWebMessageListener to listen for messages: + * + * ```java + * WebViewCompat.addWebMessageListener( + * webView, + * "androidAdsjs", + * Set.of("*"), + * new WebMessageListener() { + * @Override + * public void onPostMessage(WebView view, WebMessageCompat message, Uri sourceOrigin, boolean isMainFrame, JavaScriptReplyProxy replyProxy) { + * // Handle the message here + * String data = message.getData(); + * // Process the message and send response via replyProxy.postMessage() + * } + * } + * ); + * ``` + * + * The JavaScript side uses postMessage() to send messages, which the native side receives + * through the WebMessageListener. Responses from the native side are delivered through + * addEventListener on the captured handler. + */ +export class AndroidAdsjsMessagingConfig { + /** @type {{ + * postMessage: (message: string) => void, + * addEventListener: (type: string, listener: (event: MessageEvent) => void) => void, + * } | null} */ + _capturedHandler; + + /** + * @param {object} params + * @param {Record} params.target + * @param {boolean} params.debug + * @param {string} params.objectName - the object name for addWebMessageListener + */ + constructor(params) { + this.target = params.target; + this.debug = params.debug; + this.objectName = params.objectName; + + /** + * @type {Map void>} + * @internal + */ + this.listeners = new globalThis.Map(); + + /** + * Capture the global handler and remove it from the global object. + */ + this._captureGlobalHandler(); + + /** + * Set up event listener for incoming messages. + */ + this._setupEventListener(); + } + + /** + * The transport can call this to transmit a JSON payload along with a secret + * to the native Android handler via postMessage. + * + * Note: This can throw - it's up to the transport to handle the error. + * + * @type {(json: object) => void} + * @throws + * @internal + */ + sendMessageThrows(message) { + if (!this.objectName) { + throw new Error('Object name not set for WebMessageListener'); + } + + // Use postMessage to send to the native side + // The native Android code will have set up addWebMessageListener to receive this + if (this._capturedHandler && this._capturedHandler.postMessage) { + this._capturedHandler.postMessage(JSON.stringify(message)); + } else { + throw new Error('postMessage not available'); + } + } + + /** + * A subscription on Android is just a named listener. All messages from + * android -> are delivered through a single function, and this mapping is used + * to route the messages to the correct listener. + * + * Note: Use this to implement request->response by unsubscribing after the first + * response. + * + * @param {string} id + * @param {(msg: MessageResponse | SubscriptionEvent) => void} callback + * @returns {() => void} + * @internal + */ + subscribe(id, callback) { + this.listeners.set(id, callback); + return () => { + this.listeners.delete(id); + }; + } + + /** + * Accept incoming messages and try to deliver it to a registered listener. + * + * This code is defensive to prevent any single handler from affecting another if + * it throws (producer interference). + * + * @param {MessageResponse | SubscriptionEvent} payload + * @internal + */ + _dispatch(payload) { + // do nothing if the response is empty + // this prevents the next `in` checks from throwing in test/debug scenarios + if (!payload) return this._log('no response'); + + // if the payload has an 'id' field, then it's a message response + if ('id' in payload) { + if (this.listeners.has(payload.id)) { + this._tryCatch(() => this.listeners.get(payload.id)?.(payload)); + } else { + this._log('no listeners for ', payload); + } + } + + // if the payload has an 'subscriptionName' field, then it's a push event + if ('subscriptionName' in payload) { + if (this.listeners.has(payload.subscriptionName)) { + this._tryCatch(() => this.listeners.get(payload.subscriptionName)?.(payload)); + } else { + this._log('no subscription listeners for ', payload); + } + } + } + + /** + * + * @param {(...args: any[]) => any} fn + * @param {string} [context] + */ + _tryCatch(fn, context = 'none') { + try { + return fn(); + } catch (e) { + if (this.debug) { + console.error('AndroidAdsjsMessagingConfig error:', context); + console.error(e); + } + } + } + + /** + * @param {...any} args + */ + _log(...args) { + if (this.debug) { + console.log('AndroidAdsjsMessagingConfig', ...args); + } + } + + /** + * Capture the global handler and remove it from the global object. + */ + _captureGlobalHandler() { + const { target, objectName } = this; + + if (Object.prototype.hasOwnProperty.call(target, objectName)) { + this._capturedHandler = target[objectName]; + delete target[objectName]; + } else { + this._capturedHandler = null; + this._log('Android adsjs messaging interface not available', objectName); + } + } + + /** + * Set up event listener for incoming messages from the captured handler. + */ + _setupEventListener() { + if (!this._capturedHandler || !this._capturedHandler.addEventListener) { + this._log('No event listener support available'); + return; + } + + this._capturedHandler.addEventListener('message', (event) => { + try { + const data = /** @type {MessageEvent} */ (event).data; + if (typeof data === 'string') { + const parsedData = JSON.parse(data); + + // Dispatch the message + this._dispatch(parsedData); + } + } catch (e) { + this._log('Error processing incoming message:', e); + } + }); + } + + /** + * Send an initial ping message to the platform to establish communication. + * This is a fire-and-forget notification that signals the JavaScript side is ready. + * Only sends in top context (not in frames) and if the messaging interface is available. + * + * @param {MessagingContext} messagingContext + * @returns {boolean} true if ping was sent, false if in frame or interface not ready + */ + sendInitialPing(messagingContext) { + // Only send ping in top context, not in frames + if (isBeingFramed()) { + this._log('Skipping initial ping - running in frame context'); + return false; + } + + try { + const message = new RequestMessage({ + id: 'initialPing', + context: messagingContext.context, + featureName: 'messaging', + method: 'initialPing', + }); + this.sendMessageThrows(message); + this._log('Initial ping sent successfully'); + return true; + } catch (e) { + this._log('Failed to send initial ping:', e); + return false; + } + } +} diff --git a/typedoc.js b/typedoc.js index efc432c7db..0c8fc91555 100644 --- a/typedoc.js +++ b/typedoc.js @@ -44,7 +44,7 @@ const config = { treatWarningsAsErrors: true, searchInComments: true, modifierTags: [...OptionDefaults.modifierTags, '@implements'], - highlightLanguages: [...OptionDefaults.highlightLanguages, 'mermaid'], + highlightLanguages: [...OptionDefaults.highlightLanguages, 'mermaid', 'java'], }; export default config;