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 '../../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/+/master: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, listedDevice.connectionState); 90 } 91 } 92 message = message.substring(prefixAndPayloadLength); 93 } 94 const listedDevices: ListedDevice[] = []; 95 for (const [serialNumber, connectionState] of latestStatusByDevice 96 .entries()) { 97 listedDevices.push({serialNumber, connectionState}); 98 } 99 return {listedDevices, messageRemainder: message}; 100} 101 102export class WebsocketConnection { 103 private targets: Map<string, AndroidWebsocketTarget> = 104 new Map<string, AndroidWebsocketTarget>(); 105 private pendingData: string = ''; 106 107 constructor( 108 private websocket: WebSocket, 109 private maybeClearConnection: (connection: WebsocketConnection) => void, 110 private onTargetChange: OnTargetChangeCallback) { 111 this.initWebsocket(); 112 } 113 114 listTargets(): RecordingTargetV2[] { 115 return Array.from(this.targets.values()); 116 } 117 118 // Setup websocket callbacks. 119 initWebsocket(): void { 120 this.websocket.onclose = (ev: CloseEvent) => { 121 if (ev.code === WEBSOCKET_CLOSED_ABNORMALLY_CODE) { 122 console.info( 123 `It's safe to ignore the 'WebSocket connection to ${ 124 this.websocket.url} error above, if present. It occurs when ` + 125 'checking the connection to the local Websocket server.'); 126 } 127 this.maybeClearConnection(this); 128 this.close(); 129 }; 130 131 // once the websocket is open, we start tracking the devices 132 this.websocket.onopen = () => { 133 this.websocket.send(buildAbdWebsocketCommand('host:track-devices')); 134 }; 135 136 this.websocket.onmessage = async (evt: MessageEvent) => { 137 let resp = await evt.data.text(); 138 if (resp.substr(0, 4) === 'OKAY') { 139 resp = resp.substr(4); 140 } 141 const parsingResult = parseWebsocketResponse(this.pendingData + resp); 142 this.pendingData = parsingResult.messageRemainder; 143 this.trackDevices(parsingResult.listedDevices); 144 }; 145 } 146 147 close() { 148 // The websocket connection may have already been closed by the websocket 149 // server. 150 if (this.websocket.readyState === this.websocket.OPEN) { 151 this.websocket.close(); 152 } 153 // Disconnect all the targets, to release all the websocket connections that 154 // they hold and end their tracing sessions. 155 for (const target of this.targets.values()) { 156 target.disconnect(); 157 } 158 this.targets.clear(); 159 if (this.onTargetChange) { 160 this.onTargetChange(); 161 } 162 } 163 164 getUrl() { 165 return this.websocket.url; 166 } 167 168 // Handle messages received over the websocket regarding devices connecting 169 // or disconnecting. 170 private trackDevices(listedDevices: ListedDevice[]) { 171 // When a SN becomes offline, we should remove it from the list 172 // of targets. Otherwise, we should check if it maps to a target. If the 173 // SN does not map to a target, we should create one for it. 174 let targetsUpdated = false; 175 for (const listedDevice of listedDevices) { 176 if (['offline', 'unknown'].includes(listedDevice.connectionState)) { 177 const target = this.targets.get(listedDevice.serialNumber); 178 if (target === undefined) { 179 continue; 180 } 181 target.disconnect(); 182 this.targets.delete(listedDevice.serialNumber); 183 targetsUpdated = true; 184 } else if (!this.targets.has(listedDevice.serialNumber)) { 185 this.targets.set( 186 listedDevice.serialNumber, 187 new AndroidWebsocketTarget( 188 listedDevice.serialNumber, 189 this.websocket.url, 190 this.onTargetChange)); 191 targetsUpdated = true; 192 } 193 } 194 195 // Notify the calling code that the list of targets has been updated. 196 if (targetsUpdated) { 197 this.onTargetChange(); 198 } 199 } 200} 201 202export class AndroidWebsocketTargetFactory implements TargetFactory { 203 readonly kind = ANDROID_WEBSOCKET_TARGET_FACTORY; 204 private onTargetChange: OnTargetChangeCallback = () => {}; 205 private websocketConnection?: WebsocketConnection; 206 207 getName() { 208 return 'Android Websocket'; 209 } 210 211 listTargets(): RecordingTargetV2[] { 212 return this.websocketConnection ? this.websocketConnection.listTargets() : 213 []; 214 } 215 216 listRecordingProblems(): string[] { 217 return []; 218 } 219 220 // This interface method can not return anything because a websocket target 221 // can not be created on user input. It can only be created when the websocket 222 // server detects a new target. 223 connectNewTarget(): Promise<RecordingTargetV2> { 224 return Promise.reject(new Error( 225 'The websocket can only automatically connect targets ' + 226 'when they become available.')); 227 } 228 229 tryEstablishWebsocket(websocketUrl: string) { 230 if (this.websocketConnection) { 231 if (this.websocketConnection.getUrl() === websocketUrl) { 232 return; 233 } else { 234 this.websocketConnection.close(); 235 } 236 } 237 238 const websocket = new WebSocket(websocketUrl); 239 this.websocketConnection = new WebsocketConnection( 240 websocket, this.maybeClearConnection, this.onTargetChange); 241 } 242 243 maybeClearConnection(connection: WebsocketConnection): void { 244 if (this.websocketConnection === connection) { 245 this.websocketConnection = undefined; 246 } 247 } 248 249 setOnTargetChange(onTargetChange: OnTargetChangeCallback) { 250 this.onTargetChange = onTargetChange; 251 } 252} 253 254// We only want to instantiate this class if Recording V2 is enabled. 255if (RECORDING_V2_FLAG.get()) { 256 targetFactoryRegistry.register(new AndroidWebsocketTargetFactory()); 257} 258