From 31ac5aeb11b1753970ec66d7e7185289ca895d79 Mon Sep 17 00:00:00 2001 From: Renato Alcara Date: Mon, 2 Mar 2026 12:02:08 -0300 Subject: [PATCH] feat: add Android browser preset for companion device registration (#248) * feat: add Android browser preset for companion device registration Add Browsers.android(apiLevel) preset that identifies as an Android companion device (SMB_ANDROID platform, ANDROID_PHONE device type). Validated against Frida captures of real WhatsApp Android protocol. * feat: support BAILEYS_BROWSER env var for browser selection Allows selecting Android companion mode via environment variable without code changes. Set BAILEYS_BROWSER=android (or android:15 for a specific API level) to register as an Android device. * fix: auto-detect Android browser in pair code and fall back to Chrome Pair code companion registration fails with Android browser because the WA server rejects the ANDROID_PHONE companion_platform_id over the web Noise protocol. When Android is detected, requestPairingCode() now transparently uses Chrome/macOS platform values for the pair code stanza while QR code pairing continues to work as Android (SMB_ANDROID). * docs: add android to isValidBrowserPreset JSDoc --- src/Defaults/index.ts | 18 +++++++++++++++++- src/Socket/socket.ts | 14 +++++++++++--- src/Types/index.ts | 1 + src/Utils/browser-utils.ts | 23 ++++++++++++++++++++--- src/Utils/validate-connection.ts | 19 ++++++++++++++----- 5 files changed, 63 insertions(+), 12 deletions(-) diff --git a/src/Defaults/index.ts b/src/Defaults/index.ts index 192b24b1..3611d3d1 100644 --- a/src/Defaults/index.ts +++ b/src/Defaults/index.ts @@ -56,10 +56,26 @@ export const PROCESSABLE_HISTORY_TYPES = [ // 6 hours in milliseconds const SIX_HOURS_MS = 6 * 60 * 60 * 1000 +/** + * Resolves the default browser tuple from the BAILEYS_BROWSER env var. + * 'android' → Browsers.android('14') + * 'android:15' → Browsers.android('15') + * unset / other → Browsers.macOS('Chrome') + */ +const resolveDefaultBrowser = (): [string, string, string] => { + const env = process.env.BAILEYS_BROWSER?.trim().toLowerCase() + if (env?.startsWith('android')) { + const apiLevel = env.split(':')[1] || '14' + return Browsers.android(apiLevel) + } + + return Browsers.macOS('Chrome') +} + export const DEFAULT_CONNECTION_CONFIG: SocketConfig = { version: version as WAVersion, versionCheckIntervalMs: SIX_HOURS_MS, - browser: Browsers.macOS('Chrome'), + browser: resolveDefaultBrowser(), waWebSocketUrl: 'wss://web.whatsapp.com/ws/chat', connectTimeoutMs: 20_000, keepAliveIntervalMs: 15_000, diff --git a/src/Socket/socket.ts b/src/Socket/socket.ts index aa26ec98..93e298b1 100644 --- a/src/Socket/socket.ts +++ b/src/Socket/socket.ts @@ -38,7 +38,7 @@ import { signedKeyPair, xmppSignedPreKey } from '../Utils' -import { getPlatformId } from '../Utils/browser-utils' +import { getPlatformId, isAndroidBrowser } from '../Utils/browser-utils' import { CircuitBreaker, CircuitOpenError, @@ -1352,6 +1352,14 @@ export const makeSocket = (config: SocketConfig) => { id: jidEncode(phoneNumber, 's.whatsapp.net'), name: '~' } + + // Pair code with Android browser doesn't work (WA server rejects the + // companion registration). When Android is detected, fall back to the + // Chrome/macOS platform values that are known to work. + const isAndroid = isAndroidBrowser(browser) + const pairPlatformId = isAndroid ? getPlatformId('Chrome') : getPlatformId(browser[1]) + const pairPlatformDisplay = isAndroid ? 'Chrome (Mac OS)' : `${browser[1]} (${browser[0]})` + ev.emit('creds.update', authState.creds) await sendNode({ tag: 'iq', @@ -1384,12 +1392,12 @@ export const makeSocket = (config: SocketConfig) => { { tag: 'companion_platform_id', attrs: {}, - content: getPlatformId(browser[1]) + content: pairPlatformId }, { tag: 'companion_platform_display', attrs: {}, - content: `${browser[1]} (${browser[0]})` + content: pairPlatformDisplay }, { tag: 'link_code_pairing_nonce', diff --git a/src/Types/index.ts b/src/Types/index.ts index 13f09aea..a54510e9 100644 --- a/src/Types/index.ts +++ b/src/Types/index.ts @@ -23,6 +23,7 @@ export type BrowsersMap = { baileys(browser: string): [string, string, string] windows(browser: string): [string, string, string] appropriate(browser: string): [string, string, string] + android(apiLevel: string): [string, string, string] } export enum DisconnectReason { diff --git a/src/Utils/browser-utils.ts b/src/Utils/browser-utils.ts index 82102fd8..1ec4b117 100644 --- a/src/Utils/browser-utils.ts +++ b/src/Utils/browser-utils.ts @@ -94,7 +94,13 @@ const BROWSER_TO_PLATFORM_ID: ReadonlyMap = (() => { } } - return new Map(entries) + const map = new Map(entries) + // ANDROID → ANDROID_PHONE alias (DeviceProps.PlatformType has ANDROID_PHONE but not ANDROID) + if (!map.has('ANDROID') && map.has('ANDROID_PHONE')) { + map.set('ANDROID', map.get('ANDROID_PHONE')!) + } + + return map })() // ============================================================ @@ -331,7 +337,8 @@ export const Browsers: BrowsersMap = { macOS: (browser: string): [string, string, string] => ['Mac OS', browser, OS_VERSIONS.macOS], windows: (browser: string): [string, string, string] => ['Windows', browser, OS_VERSIONS.windows], baileys: (browser: string): [string, string, string] => ['Baileys', browser, OS_VERSIONS.baileys], - appropriate: (browser: string): [string, string, string] => [getPlatformName(), browser, getAppropriateVersion()] + appropriate: (browser: string): [string, string, string] => [getPlatformName(), browser, getAppropriateVersion()], + android: (apiLevel: string): [string, string, string] => [apiLevel, 'Android', ''] } as const /** @@ -378,7 +385,7 @@ export const getPlatformId = (browser: unknown): string => { * properties like 'toString' or 'constructor'. * * @param value - Value to check - * @returns True if value is a valid browser preset key ('ubuntu', 'macOS', 'windows', 'baileys', 'appropriate') + * @returns True if value is a valid browser preset key ('ubuntu', 'macOS', 'windows', 'baileys', 'appropriate', 'android') * * @example * isValidBrowserPreset('ubuntu') // true @@ -391,6 +398,16 @@ export const getPlatformId = (browser: unknown): string => { * const config = Browsers[userInput]('MyApp') * } */ +/** + * Checks if the browser tuple represents an Android companion device. + * + * @param browser - Browser tuple [os, platform, version] + * @returns True if platform is 'Android' (case-insensitive) + */ +export const isAndroidBrowser = (browser: [string, string, string]): boolean => { + return browser[1]?.toUpperCase() === 'ANDROID' +} + export const isValidBrowserPreset = (value: unknown): value is keyof BrowsersMap => { return typeof value === 'string' && Object.prototype.hasOwnProperty.call(Browsers, value) } diff --git a/src/Utils/validate-connection.ts b/src/Utils/validate-connection.ts index 4db17464..2091cc56 100644 --- a/src/Utils/validate-connection.ts +++ b/src/Utils/validate-connection.ts @@ -9,24 +9,27 @@ import { } from '../Defaults' import type { AuthenticationCreds, SignalCreds, SocketConfig } from '../Types' import { type BinaryNode, getBinaryNodeChild, jidDecode, S_WHATSAPP_NET } from '../WABinary' +import { isAndroidBrowser } from './browser-utils' import { Curve, hmacSign } from './crypto' import { encodeBigEndian } from './generics' import { createSignalIdentity } from './signal' const getUserAgent = (config: SocketConfig): proto.ClientPayload.IUserAgent => { + const isAndroid = isAndroidBrowser(config.browser) return { appVersion: { primary: config.version[0], secondary: config.version[1], tertiary: config.version[2] }, - platform: proto.ClientPayload.UserAgent.Platform.MACOS, + platform: isAndroid + ? proto.ClientPayload.UserAgent.Platform.SMB_ANDROID + : proto.ClientPayload.UserAgent.Platform.MACOS, releaseChannel: proto.ClientPayload.UserAgent.ReleaseChannel.RELEASE, - osVersion: '0.1', - device: 'Desktop', + osVersion: isAndroid ? config.browser[0] : '0.1', + device: isAndroid ? 'Android' : 'Desktop', osBuildNumber: '0.1', localeLanguageIso6391: 'en', - mnc: '000', mcc: '000', localeCountryIso31661Alpha2: config.countryCode @@ -58,7 +61,9 @@ const getClientPayload = (config: SocketConfig) => { userAgent: getUserAgent(config) } - payload.webInfo = getWebInfo(config) + if (!isAndroidBrowser(config.browser)) { + payload.webInfo = getWebInfo(config) + } return payload } @@ -84,6 +89,10 @@ export const generateLoginNode = (userJid: string, config: SocketConfig): proto. const getPlatformType = (platform: string): proto.DeviceProps.PlatformType => { const platformType = platform.toUpperCase() + if (platformType === 'ANDROID') { + return proto.DeviceProps.PlatformType.ANDROID_PHONE + } + return ( proto.DeviceProps.PlatformType[platformType as keyof typeof proto.DeviceProps.PlatformType] || proto.DeviceProps.PlatformType.CHROME