Compare commits

...

1 Commits

Author SHA1 Message Date
Renato Alcara ef365246ea feat: add Android browser preset for companion device registration
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
2026-03-02 22:28:01 -03:00
4 changed files with 48 additions and 6 deletions
+12 -3
View File
@@ -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',
+1
View File
@@ -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 {
+25 -2
View File
@@ -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
}
+10 -1
View File
@@ -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