1// Copyright (C) 2022 The Android Open Source Project 2// 3// Licensed under the Apache License, Version 2.0 (the "License"); 4// you may not use this file except in compliance with the License. 5// You may obtain a copy of the License at 6// 7// http://www.apache.org/licenses/LICENSE-2.0 8// 9// Unless required by applicable law or agreed to in writing, software 10// distributed under the License is distributed on an "AS IS" BASIS, 11// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12// See the License for the specific language governing permissions and 13// limitations under the License. 14 15import {RECORDING_V2_FLAG} from '../../../core/feature_flags'; 16import { 17 OnTargetChangeCallback, 18 RecordingTargetV2, 19 TargetFactory, 20} from '../recording_interfaces_v2'; 21import { 22 buildAbdWebsocketCommand, 23 WEBSOCKET_CLOSED_ABNORMALLY_CODE, 24} from '../recording_utils'; 25import {targetFactoryRegistry} from '../target_factory_registry'; 26import {AndroidWebsocketTarget} from '../targets/android_websocket_target'; 27 28export const ANDROID_WEBSOCKET_TARGET_FACTORY = 'AndroidWebsocketTargetFactory'; 29 30// https://cs.android.com/android/platform/superproject/+/main:packages/ 31// modules/adb/SERVICES.TXT;l=135 32const PREFIX_LENGTH = 4; 33 34// information received over the websocket regarding a device 35// Ex: "${serialNumber} authorized" 36interface ListedDevice { 37 serialNumber: string; 38 // Full list of connection states can be seen at: 39 // go/codesearch/android/packages/modules/adb/adb.cpp;l=115-139 40 connectionState: string; 41} 42 43// Contains the result of parsing a message received over websocket. 44interface ParsingResult { 45 listedDevices: ListedDevice[]; 46 messageRemainder: string; 47} 48 49// We issue the command 'track-devices' which will encode the short form 50// of the device: 51// see go/codesearch/android/packages/modules/adb/services.cpp;l=244-245 52// and go/codesearch/android/packages/modules/adb/transport.cpp;l=1417-1420 53// Therefore a line will contain solely the device serial number and the 54// connectionState (and no other properties). 55function parseListedDevice(line: string): ListedDevice | undefined { 56 const parts = line.split('\t'); 57 if (parts.length === 2) { 58 return { 59 serialNumber: parts[0], 60 connectionState: parts[1], 61 }; 62 } 63 return undefined; 64} 65 66export function parseWebsocketResponse(message: string): ParsingResult { 67 // A response we receive on the websocket contains multiple messages: 68 // "{m1.length}{m1.payload}{m2.length}{m2.payload}..." 69 // where m1, m2 are messages 70 // Each message has the form: 71 // "{message.length}SN1\t${connectionState1}\nSN2\t${connectionState2}\n..." 72 // where SN1, SN2 are device serial numbers 73 // and connectionState1, connectionState2 are adb connection states, created 74 // here: go/codesearch/android/packages/modules/adb/adb.cpp;l=115-139 75 const latestStatusByDevice: Map<string, string> = new Map(); 76 while (message.length >= PREFIX_LENGTH) { 77 const payloadLength = parseInt(message.substring(0, PREFIX_LENGTH), 16); 78 const prefixAndPayloadLength = PREFIX_LENGTH + payloadLength; 79 if (message.length < prefixAndPayloadLength) { 80 break; 81 } 82 83 const payload = message.substring(PREFIX_LENGTH, prefixAndPayloadLength); 84 for (const line of payload.split('\n')) { 85 const listedDevice = parseListedDevice(line); 86 if (listedDevice) { 87 // We overwrite previous states for the same serial number. 88 latestStatusByDevice.set( 89 listedDevice.serialNumber, 90 listedDevice.connectionState, 91 ); 92 } 93 } 94 message = message.substring(prefixAndPayloadLength); 95 } 96 const listedDevices: ListedDevice[] = []; 97 for (const [ 98 serialNumber, 99 connectionState, 100 ] of latestStatusByDevice.entries()) { 101 listedDevices.push({serialNumber, connectionState}); 102 } 103 return {listedDevices, messageRemainder: message}; 104} 105 106export class WebsocketConnection { 107 private targets: Map<string, AndroidWebsocketTarget> = new Map< 108 string, 109 AndroidWebsocketTarget 110 >(); 111 private pendingData: string = ''; 112 113 constructor( 114 private websocket: WebSocket, 115 private maybeClearConnection: (connection: WebsocketConnection) => void, 116 private onTargetChange: OnTargetChangeCallback, 117 ) { 118 this.initWebsocket(); 119 } 120 121 listTargets(): RecordingTargetV2[] { 122 return Array.from(this.targets.values()); 123 } 124 125 // Setup websocket callbacks. 126 initWebsocket(): void { 127 this.websocket.onclose = (ev: CloseEvent) => { 128 if (ev.code === WEBSOCKET_CLOSED_ABNORMALLY_CODE) { 129 console.info( 130 `It's safe to ignore the 'WebSocket connection to ${this.websocket.url} error above, if present. It occurs when ` + 131 'checking the connection to the local Websocket server.', 132 ); 133 } 134 this.maybeClearConnection(this); 135 this.close(); 136 }; 137 138 // once the websocket is open, we start tracking the devices 139 this.websocket.onopen = () => { 140 this.websocket.send(buildAbdWebsocketCommand('host:track-devices')); 141 }; 142 143 this.websocket.onmessage = async (evt: MessageEvent) => { 144 let resp = await evt.data.text(); 145 if (resp.substr(0, 4) === 'OKAY') { 146 resp = resp.substr(4); 147 } 148 const parsingResult = parseWebsocketResponse(this.pendingData + resp); 149 this.pendingData = parsingResult.messageRemainder; 150 this.trackDevices(parsingResult.listedDevices); 151 }; 152 } 153 154 close() { 155 // The websocket connection may have already been closed by the websocket 156 // server. 157 if (this.websocket.readyState === this.websocket.OPEN) { 158 this.websocket.close(); 159 } 160 // Disconnect all the targets, to release all the websocket connections that 161 // they hold and end their tracing sessions. 162 for (const target of this.targets.values()) { 163 target.disconnect(); 164 } 165 this.targets.clear(); 166 167 // eslint-disable-next-line @typescript-eslint/strict-boolean-expressions 168 if (this.onTargetChange) { 169 this.onTargetChange(); 170 } 171 } 172 173 getUrl() { 174 return this.websocket.url; 175 } 176 177 // Handle messages received over the websocket regarding devices connecting 178 // or disconnecting. 179 private trackDevices(listedDevices: ListedDevice[]) { 180 // When a SN becomes offline, we should remove it from the list 181 // of targets. Otherwise, we should check if it maps to a target. If the 182 // SN does not map to a target, we should create one for it. 183 let targetsUpdated = false; 184 for (const listedDevice of listedDevices) { 185 if (['offline', 'unknown'].includes(listedDevice.connectionState)) { 186 const target = this.targets.get(listedDevice.serialNumber); 187 if (target === undefined) { 188 continue; 189 } 190 target.disconnect(); 191 this.targets.delete(listedDevice.serialNumber); 192 targetsUpdated = true; 193 } else if (!this.targets.has(listedDevice.serialNumber)) { 194 this.targets.set( 195 listedDevice.serialNumber, 196 new AndroidWebsocketTarget( 197 listedDevice.serialNumber, 198 this.websocket.url, 199 this.onTargetChange, 200 ), 201 ); 202 targetsUpdated = true; 203 } 204 } 205 206 // Notify the calling code that the list of targets has been updated. 207 if (targetsUpdated) { 208 this.onTargetChange(); 209 } 210 } 211} 212 213export class AndroidWebsocketTargetFactory implements TargetFactory { 214 readonly kind = ANDROID_WEBSOCKET_TARGET_FACTORY; 215 private onTargetChange: OnTargetChangeCallback = () => {}; 216 private websocketConnection?: WebsocketConnection; 217 218 getName() { 219 return 'Android Websocket'; 220 } 221 222 listTargets(): RecordingTargetV2[] { 223 return this.websocketConnection 224 ? this.websocketConnection.listTargets() 225 : []; 226 } 227 228 listRecordingProblems(): string[] { 229 return []; 230 } 231 232 // This interface method can not return anything because a websocket target 233 // can not be created on user input. It can only be created when the websocket 234 // server detects a new target. 235 connectNewTarget(): Promise<RecordingTargetV2> { 236 return Promise.reject( 237 new Error( 238 'The websocket can only automatically connect targets ' + 239 'when they become available.', 240 ), 241 ); 242 } 243 244 tryEstablishWebsocket(websocketUrl: string) { 245 if (this.websocketConnection) { 246 if (this.websocketConnection.getUrl() === websocketUrl) { 247 return; 248 } else { 249 this.websocketConnection.close(); 250 } 251 } 252 253 const websocket = new WebSocket(websocketUrl); 254 this.websocketConnection = new WebsocketConnection( 255 websocket, 256 this.maybeClearConnection, 257 this.onTargetChange, 258 ); 259 } 260 261 maybeClearConnection(connection: WebsocketConnection): void { 262 if (this.websocketConnection === connection) { 263 this.websocketConnection = undefined; 264 } 265 } 266 267 setOnTargetChange(onTargetChange: OnTargetChangeCallback) { 268 this.onTargetChange = onTargetChange; 269 } 270} 271 272// We only want to instantiate this class if Recording V2 is enabled. 273if (RECORDING_V2_FLAG.get()) { 274 targetFactoryRegistry.register(new AndroidWebsocketTargetFactory()); 275} 276