From ef365246ea33156b94e1827c30f3333658a06bff Mon Sep 17 00:00:00 2001 From: Renato Alcara Date: Mon, 2 Mar 2026 22:28:01 -0300 Subject: [PATCH] feat: add Android browser preset for companion device registration MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds `Browsers.android(apiLevel)` preset that registers the companion device as Android in WhatsApp's "Linked Devices" list, enabling view-once messages and Android-specific features. Inspired by PR #2201 (WhiskeySockets/Baileys#2201) which introduced the concept, but with critical fixes discovered through extensive reverse engineering and real-device testing. ## Usage const sock = makeWASocket({ browser: Browsers.android('14'), auth: state }) The device appears as "Android (14)" in the phone's Linked Devices list. ## Investigation and Findings ### Two different platform fields WhatsApp uses two separate platform identifiers that serve different purposes: 1. `companion_platform_id` (pair code stanza) — validated by the server BEFORE showing the confirmation prompt on the phone. Controls whether the server accepts the pairing request. 2. `DeviceProps.PlatformType` (registration node) — sent AFTER pairing confirmation. Determines the display name in "Linked Devices". These are independent: you can pair with Chrome (1) as companion but register with ANDROID_PHONE (16) in DeviceProps, and the device will correctly appear as "Android" in linked devices. ### Pair code platform testing results Tested with real WhatsApp server via web protocol (WA\x06\x03): | companion_platform_id | Result | |-----------------------|-------------------------------------------| | Chrome (1) | WORKS — phone shows "connect device?" prompt | | ANDROID_PHONE (16) | TIMEOUT — server silently ignores stanza | | UWP (21) | REJECTED — "cannot connect device" error | When we sent `companion_platform_id: "16"` (ANDROID_PHONE), the server did not respond at all — the request timed out after 30s with no `link_code_pairing_ref` response. With Chrome ("1"), it responds immediately. This confirms: ANDROID_PHONE(16) is a primary device type, not a companion type. The server expects companions with this type to use the native Android protocol (WAM\x05 with key_attestation TEE certificates). Since Baileys uses the web protocol (WA\x06\x03), the server rejects it. ### UserAgent.Platform testing results The original PR #2201 used `UserAgent.Platform = SMB_ANDROID` for Android browser. Testing revealed this breaks pair code registration: - With `SMB_ANDROID`: pair code generates, phone shows confirmation, user clicks "Yes", but registration FAILS with "cannot connect device" error. The mismatch between web protocol handshake (WA\x06\x03) and Android UserAgent causes the server to reject the registration. - With `MACOS`: pair code works end-to-end. Device connects successfully and appears as "Android (14)" in linked devices. Additionally, the upstream default `UserAgent.Platform = WEB` causes 405 connection failures on some server endpoints. Using `MACOS` (24) resolves this. ### WhatsApp Desktop Windows investigation (Frida + binary analysis) To understand how official WhatsApp clients handle platform identity, we reverse-engineered WhatsApp Desktop for Windows: - WhatsApp Desktop is a WebView2 wrapper loading `web.whatsapp.com?windows=1` — NOT a native client - Uses the same web protocol (WA\x06\x03) as Chrome - Registers with `DeviceProps.PlatformType = UWP (21)` to appear as "Windows" in linked devices - Always shows as "Windows" regardless of QR or pair code connection This confirmed our approach: the display name comes from DeviceProps, not from the pair code stanza. ### Relevant protocol enums ClientPayload.UserAgent.Platform (connection identity): MACOS = 24 (what we use — web-compatible) SMB_ANDROID = 10 (breaks pair code — DO NOT USE) WEB = 15 (causes 405 errors) DeviceProps.PlatformType (linked device display name): CHROME = 1, FIREFOX = 2, SAFARI = 5, EDGE = 6 DESKTOP = 7, CATALINA = 12, ANDROID_PHONE = 16, UWP = 21 ## Changes - Types/index.ts: add `android(apiLevel)` to BrowsersMap type - browser-utils.ts: add `Browsers.android()` preset, `isAndroidBrowser()` helper, fix `getPlatformId()` to resolve ANDROID -> ANDROID_PHONE (16) since the enum only has ANDROID_PHONE, not ANDROID - validate-connection.ts: use MACOS platform in UserAgent (fixes 405 and pair code), map ANDROID -> ANDROID_PHONE in getPlatformType() - socket.ts: auto-detect Android browser in pair code and fall back to Chrome (1) as companion_platform_id. Non-Android browsers are unaffected and continue using their native platform ID. ## How it works With `Browsers.android('14')`: - `getUserAgent()` -> platform: MACOS, device: Desktop (web identity) - `getClientPayload()` -> includes webInfo as normal - `getPlatformType('Android')` -> ANDROID_PHONE (in DeviceProps) - Pair code -> companion_platform_id: '1' (Chrome fallback) - QR code -> no override needed, works directly - Result: device appears as "Android (14)" in Linked Devices --- src/Socket/socket.ts | 15 ++++++++++++--- src/Types/index.ts | 1 + src/Utils/browser-utils.ts | 27 +++++++++++++++++++++++++-- src/Utils/validate-connection.ts | 11 ++++++++++- 4 files changed, 48 insertions(+), 6 deletions(-) diff --git a/src/Socket/socket.ts b/src/Socket/socket.ts index cd39f13a..a44c6c60 100644 --- a/src/Socket/socket.ts +++ b/src/Socket/socket.ts @@ -36,7 +36,7 @@ import { signedKeyPair, xmppSignedPreKey } from '../Utils' -import { getPlatformId } from '../Utils/browser-utils' +import { getPlatformId, isAndroidBrowser } from '../Utils/browser-utils' import { assertNodeErrorFree, type BinaryNode, @@ -758,6 +758,15 @@ export const makeSocket = (config: SocketConfig) => { id: jidEncode(phoneNumber, 's.whatsapp.net'), name: '~' } + // Pair code companion_platform_id must be Chrome (1) when using Android + // browser preset. ANDROID_PHONE (16) causes silent timeout, UWP (21) + // causes rejection. Only Chrome (1) works for pair code via web protocol. + // The device still appears as "Android" in linked devices because + // DeviceProps.platformType=ANDROID_PHONE is set in the registration node. + 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', @@ -790,12 +799,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 0fec86b2..bf8e1549 100644 --- a/src/Types/index.ts +++ b/src/Types/index.ts @@ -22,6 +22,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 ee2e42dc..d8972085 100644 --- a/src/Utils/browser-utils.ts +++ b/src/Utils/browser-utils.ts @@ -22,10 +22,33 @@ export const Browsers: BrowsersMap = { baileys: browser => ['Baileys', browser, '6.5.0'], windows: browser => ['Windows', browser, '10.0.22631'], /** The appropriate browser based on your OS & release */ - appropriate: browser => [PLATFORM_MAP[platform()] || 'Ubuntu', browser, release()] + appropriate: browser => [PLATFORM_MAP[platform()] || 'Ubuntu', browser, release()], + /** Android companion device. apiLevel is the Android API level (e.g. '14') */ + android: (apiLevel: string) => [apiLevel, 'Android', ''] +} + +/** + * 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 getPlatformId = (browser: string) => { const platformType = proto.DeviceProps.PlatformType[browser.toUpperCase() as any] - return platformType ? platformType.toString() : '1' //chrome + if (platformType !== undefined) { + return platformType.toString() + } + + // 'ANDROID' is not in the PlatformType enum — map to ANDROID_PHONE + if (browser.toUpperCase() === 'ANDROID') { + const androidPhone = proto.DeviceProps.PlatformType['ANDROID_PHONE' as any] + if (androidPhone !== undefined) { + return androidPhone.toString() + } + } + + return '1' // Chrome } diff --git a/src/Utils/validate-connection.ts b/src/Utils/validate-connection.ts index ba130da3..ce75918b 100644 --- a/src/Utils/validate-connection.ts +++ b/src/Utils/validate-connection.ts @@ -14,13 +14,17 @@ import { encodeBigEndian } from './generics' import { createSignalIdentity } from './signal' const getUserAgent = (config: SocketConfig): proto.ClientPayload.IUserAgent => { + // Always use MACOS platform for UserAgent — we connect via web protocol + // (WA\x06\x03) so the server expects a web-compatible identity. + // Using WEB causes 405 errors; using SMB_ANDROID breaks pair code. + // Android identity is only set in DeviceProps (registration node). return { appVersion: { primary: config.version[0], secondary: config.version[1], tertiary: config.version[2] }, - platform: proto.ClientPayload.UserAgent.Platform.WEB, + platform: proto.ClientPayload.UserAgent.Platform.MACOS, releaseChannel: proto.ClientPayload.UserAgent.ReleaseChannel.RELEASE, osVersion: '0.1', device: 'Desktop', @@ -79,6 +83,11 @@ export const generateLoginNode = (userJid: string, config: SocketConfig): proto. const getPlatformType = (platform: string): proto.DeviceProps.PlatformType => { const platformType = platform.toUpperCase() + // 'ANDROID' is not in PlatformType enum — map to ANDROID_PHONE + if (platformType === 'ANDROID') { + return proto.DeviceProps.PlatformType.ANDROID_PHONE + } + return ( proto.DeviceProps.PlatformType[platformType as keyof typeof proto.DeviceProps.PlatformType] || proto.DeviceProps.PlatformType.CHROME