1/* 2 * Copyright 2024, The Android Open Source Project 3 * 4 * Licensed under the Apache License, Version 2.0 (the "License"); 5 * you may not use this file except in compliance with the License. 6 * You may obtain a copy of the License at 7 * 8 * http://www.apache.org/licenses/LICENSE-2.0 9 * 10 * Unless required by applicable law or agreed to in writing, software 11 * distributed under the License is distributed on an "AS IS" BASIS, 12 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 * See the License for the specific language governing permissions and 14 * limitations under the License. 15 */ 16 17import {assertDefined} from 'common/assert_utils'; 18import {FunctionUtils} from 'common/function_utils'; 19import { 20 HttpRequest, 21 HttpRequestHeaderType, 22 HttpRequestStatus, 23 HttpResponse, 24} from 'common/http_request'; 25import {PersistentStore} from 'common/persistent_store'; 26import {TimeUtils} from 'common/time_utils'; 27import {UserNotifier} from 'common/user_notifier'; 28import {Analytics} from 'logging/analytics'; 29import { 30 ProxyTracingErrors, 31 ProxyTracingWarnings, 32} from 'messaging/user_warnings'; 33import {AdbConnection, OnRequestSuccessCallback} from './adb_connection'; 34import {AdbDevice} from './adb_device'; 35import {ConnectionState} from './connection_state'; 36import {ProxyEndpoint} from './proxy_endpoint'; 37import {TraceRequest} from './trace_request'; 38 39export class ProxyConnection extends AdbConnection { 40 static readonly VERSION = '4.0.8'; 41 static readonly WINSCOPE_PROXY_URL = 'http://localhost:5544'; 42 43 private static readonly MULTI_DISPLAY_SCREENRECORD_VERSION = '1.4'; 44 45 private readonly store = new PersistentStore(); 46 private readonly storeKeySecurityToken = 'adb.proxyKey'; 47 48 private state: ConnectionState = ConnectionState.CONNECTING; 49 private errorText = ''; 50 private securityToken = ''; 51 private devices: AdbDevice[] = []; 52 selectedDevice: AdbDevice | undefined; 53 private requestedTraces: TraceRequest[] = []; 54 private adbData: File[] = []; 55 private keepTraceAliveWorker: number | undefined; 56 private refreshDevicesWorker: number | undefined; 57 private detectStateChangeInUi: () => Promise<void> = 58 FunctionUtils.DO_NOTHING_ASYNC; 59 private availableTracesChangeCallback: (traces: string[]) => void = 60 FunctionUtils.DO_NOTHING; 61 private devicesChangeCallback: (devices: AdbDevice[]) => void = 62 FunctionUtils.DO_NOTHING; 63 64 async initialize( 65 detectStateChangeInUi: () => Promise<void>, 66 availableTracesChangeCallback: (traces: string[]) => void, 67 devicesChangeCallback: (devices: AdbDevice[]) => void, 68 ): Promise<void> { 69 this.detectStateChangeInUi = detectStateChangeInUi; 70 this.availableTracesChangeCallback = availableTracesChangeCallback; 71 this.devicesChangeCallback = devicesChangeCallback; 72 73 const urlParams = new URLSearchParams(window.location.search); 74 if (urlParams.has('token')) { 75 this.securityToken = assertDefined(urlParams.get('token')); 76 } else { 77 this.securityToken = this.store.get(this.storeKeySecurityToken) ?? ''; 78 } 79 await this.setState(ConnectionState.CONNECTING); 80 } 81 82 async restartConnection(): Promise<void> { 83 await this.setState(ConnectionState.CONNECTING); 84 } 85 86 setSecurityToken(token: string) { 87 if (token.length > 0) { 88 this.securityToken = token; 89 this.store.add(this.storeKeySecurityToken, token); 90 } 91 } 92 93 getDevices(): AdbDevice[] { 94 return this.devices; 95 } 96 97 getState(): ConnectionState { 98 return this.state; 99 } 100 101 getErrorText(): string { 102 return this.errorText; 103 } 104 105 onDestroy() { 106 window.clearInterval(this.refreshDevicesWorker); 107 this.refreshDevicesWorker = undefined; 108 window.clearInterval(this.keepTraceAliveWorker); 109 this.keepTraceAliveWorker = undefined; 110 } 111 112 async startTrace( 113 device: AdbDevice, 114 requestedTraces: TraceRequest[], 115 ): Promise<void> { 116 if (requestedTraces.length === 0) { 117 throw new Error('No traces requested'); 118 } 119 this.updateMediaBasedConfig(requestedTraces); 120 this.selectedDevice = device; 121 this.requestedTraces = requestedTraces; 122 await this.setState(ConnectionState.STARTING_TRACE); 123 } 124 125 async endTrace() { 126 if (this.requestedTraces.length === 0) { 127 throw new Error('Trace not started before stopping'); 128 } 129 await this.setState(ConnectionState.ENDING_TRACE); 130 this.requestedTraces = []; 131 } 132 133 async dumpState( 134 device: AdbDevice, 135 requestedDumps: TraceRequest[], 136 ): Promise<void> { 137 if (requestedDumps.length === 0) { 138 throw new Error('No dumps requested'); 139 } 140 this.selectedDevice = device; 141 this.updateMediaBasedConfig(requestedDumps); 142 this.requestedTraces = requestedDumps; 143 await this.setState(ConnectionState.DUMPING_STATE); 144 } 145 146 private updateMediaBasedConfig(requestedConfig: TraceRequest[]) { 147 requestedConfig.forEach((req) => { 148 const displayConfig = req.config.find((c) => c.key === 'displays'); 149 if (displayConfig?.value) { 150 if (Array.isArray(displayConfig.value)) { 151 displayConfig.value = displayConfig.value.map((display) => { 152 if (display[0] === '"') { 153 return display.split('"')[2].trim(); 154 } 155 return display; 156 }); 157 } else { 158 if (displayConfig.value[0] === '"') { 159 displayConfig.value = displayConfig.value.split('"')[2].trim(); 160 } 161 } 162 } 163 }); 164 } 165 166 async fetchLastTracingSessionData(device: AdbDevice): Promise<File[]> { 167 this.adbData = []; 168 this.selectedDevice = device; 169 await this.setState(ConnectionState.LOADING_DATA); 170 this.selectedDevice = undefined; 171 return this.adbData; 172 } 173 174 private async updateAdbData(device: AdbDevice) { 175 await this.getFromProxy( 176 `${ProxyEndpoint.FETCH}${device.id}/`, 177 this.onSuccessFetchFiles, 178 'arraybuffer', 179 ); 180 if (this.adbData.length === 0) { 181 Analytics.Proxy.logNoFilesFound(); 182 } 183 } 184 185 private async onConnectionStateChange(newState: ConnectionState) { 186 await this.detectStateChangeInUi(); 187 188 switch (newState) { 189 case ConnectionState.ERROR: 190 Analytics.Error.logProxyError(this.errorText); 191 return; 192 193 case ConnectionState.CONNECTING: 194 await this.requestDevices(); 195 return; 196 197 case ConnectionState.IDLE: 198 { 199 const isWaylandAvailable = await this.isWaylandAvailable(); 200 if (isWaylandAvailable) { 201 this.availableTracesChangeCallback(['wayland_trace']); 202 } 203 } 204 return; 205 206 case ConnectionState.STARTING_TRACE: 207 await this.postToProxy( 208 `${ProxyEndpoint.START_TRACE}${ 209 assertDefined(this.selectedDevice).id 210 }/`, 211 (response: HttpResponse) => { 212 this.tryProcessWarnings(response); 213 this.keepTraceAlive(); 214 }, 215 this.requestedTraces, 216 ); 217 // TODO(b/330118129): identify source of additional start latency that affects some traces 218 await TimeUtils.sleepMs(1000); // 1s timeout ensures SR fully started 219 if (this.getState() === ConnectionState.STARTING_TRACE) { 220 this.setState(ConnectionState.TRACING); 221 } 222 return; 223 224 case ConnectionState.ENDING_TRACE: 225 await this.postToProxy( 226 `${ProxyEndpoint.END_TRACE}${assertDefined(this.selectedDevice).id}/`, 227 (response: HttpResponse) => { 228 const errors = JSON.parse(response.body); 229 if (Array.isArray(errors) && errors.length > 0) { 230 const processedErrors: string[] = errors.map((error: string) => { 231 const processed = error 232 .replace("b'", "'") 233 .replace('\\n', '') 234 .replace( 235 'please check your display state', 236 'please check your display state (must be on at start of trace)', 237 ); 238 return processed; 239 }); 240 UserNotifier.add(new ProxyTracingErrors(processedErrors)); 241 } 242 }, 243 ); 244 return; 245 246 case ConnectionState.DUMPING_STATE: 247 await this.postToProxy( 248 `${ProxyEndpoint.DUMP}${assertDefined(this.selectedDevice).id}/`, 249 (response: HttpResponse) => this.tryProcessWarnings(response), 250 this.requestedTraces, 251 ); 252 return; 253 254 case ConnectionState.LOADING_DATA: 255 if (this.selectedDevice === undefined) { 256 throw new Error('No device selected'); 257 } 258 await this.updateAdbData(assertDefined(this.selectedDevice)); 259 return; 260 261 default: 262 // do nothing 263 } 264 } 265 266 private tryProcessWarnings(response: HttpResponse) { 267 try { 268 const warnings = JSON.parse(response.body); 269 if (Array.isArray(warnings) && warnings.length > 0) { 270 UserNotifier.add(new ProxyTracingWarnings(warnings)).notify(); 271 } 272 } catch { 273 // do nothing - warnings unavailable 274 } 275 } 276 277 private async keepTraceAlive() { 278 const state = this.getState(); 279 if ( 280 state !== ConnectionState.STARTING_TRACE && 281 state !== ConnectionState.TRACING 282 ) { 283 window.clearInterval(this.keepTraceAliveWorker); 284 this.keepTraceAliveWorker = undefined; 285 return; 286 } 287 288 await this.getFromProxy( 289 `${ProxyEndpoint.STATUS}${assertDefined(this.selectedDevice).id}/`, 290 async (request: HttpResponse) => { 291 if (request.text !== 'True') { 292 window.clearInterval(this.keepTraceAliveWorker); 293 this.keepTraceAliveWorker = undefined; 294 await this.endTrace(); 295 if (this.state === ConnectionState.ENDING_TRACE) { 296 await this.setState(ConnectionState.TRACE_TIMEOUT); 297 } 298 } else if (this.keepTraceAliveWorker === undefined) { 299 this.keepTraceAliveWorker = window.setInterval( 300 () => this.keepTraceAlive(), 301 1000, 302 ); 303 } 304 }, 305 ); 306 } 307 308 private async setState(state: ConnectionState, errorText = '') { 309 const connectedStates = [ 310 ConnectionState.IDLE, 311 ConnectionState.STARTING_TRACE, 312 ConnectionState.TRACING, 313 ConnectionState.ENDING_TRACE, 314 ConnectionState.DUMPING_STATE, 315 ConnectionState.LOADING_DATA, 316 ]; 317 if ( 318 state === ConnectionState.NOT_FOUND && 319 connectedStates.includes(this.state) 320 ) { 321 Analytics.Proxy.logServerNotFound(); 322 } 323 this.state = state; 324 this.errorText = errorText; 325 await this.onConnectionStateChange(state); 326 } 327 328 private async requestDevices() { 329 if ( 330 this.state !== ConnectionState.IDLE && 331 this.state !== ConnectionState.CONNECTING 332 ) { 333 if (this.refreshDevicesWorker !== undefined) { 334 window.clearInterval(this.refreshDevicesWorker); 335 this.refreshDevicesWorker = undefined; 336 } 337 return; 338 } 339 340 await this.getFromProxy(ProxyEndpoint.DEVICES, this.onSuccessGetDevices); 341 } 342 343 private onSuccessGetDevices: OnRequestSuccessCallback = async ( 344 resp: HttpResponse, 345 ) => { 346 try { 347 const devices = JSON.parse(resp.text); 348 this.devices = Object.keys(devices).map((deviceId) => { 349 return { 350 id: deviceId, 351 authorized: devices[deviceId].authorized, 352 model: devices[deviceId].model, 353 displays: devices[deviceId].displays.map((display: string) => { 354 const parts = display.split(' ').slice(1); 355 const displayNameStartIndex = parts.findIndex((part) => 356 part.includes('displayName'), 357 ); 358 if (displayNameStartIndex !== -1) { 359 const displayName = parts 360 .slice(displayNameStartIndex) 361 .join(' ') 362 .slice(12); 363 if (displayName.length > 2) { 364 return [displayName] 365 .concat(parts.slice(0, displayNameStartIndex)) 366 .join(' '); 367 } 368 } 369 return parts.join(' '); 370 }), 371 multiDisplayScreenRecordingAvailable: 372 devices[deviceId].screenrecord_version >= 373 ProxyConnection.MULTI_DISPLAY_SCREENRECORD_VERSION, 374 }; 375 }); 376 this.devicesChangeCallback(this.devices); 377 if (this.refreshDevicesWorker === undefined) { 378 this.refreshDevicesWorker = window.setInterval( 379 () => this.requestDevices(), 380 1000, 381 ); 382 } 383 if (this.state === ConnectionState.CONNECTING) { 384 this.setState(ConnectionState.IDLE); 385 } else if (this.state === ConnectionState.IDLE) { 386 this.detectStateChangeInUi(); 387 } 388 } catch (err) { 389 this.setState( 390 ConnectionState.ERROR, 391 `Could not find devices. Received:\n${resp.text}`, 392 ); 393 } 394 }; 395 396 private onSuccessFetchFiles: OnRequestSuccessCallback = async ( 397 httpResponse: HttpResponse, 398 ) => { 399 try { 400 const enc = new TextDecoder('utf-8'); 401 const resp = enc.decode(httpResponse.body); 402 const filesByType = JSON.parse(resp); 403 404 for (const filetype of Object.keys(filesByType)) { 405 const files = filesByType[filetype]; 406 for (const encodedFileBuffer of files) { 407 const buffer = Uint8Array.from(window.atob(encodedFileBuffer), (c) => 408 c.charCodeAt(0), 409 ); 410 const blob = new Blob([buffer]); 411 const newFile = new File([blob], filetype); 412 this.adbData.push(newFile); 413 } 414 } 415 } catch (error) { 416 this.setState( 417 ConnectionState.ERROR, 418 `Could not fetch files. Received:\n${httpResponse.text}`, 419 ); 420 } 421 }; 422 423 private isWaylandAvailable(): Promise<boolean> { 424 return new Promise((resolve) => { 425 this.getFromProxy( 426 ProxyEndpoint.CHECK_WAYLAND, 427 (request: HttpResponse) => { 428 resolve(request.text === 'true'); 429 }, 430 ); 431 }); 432 } 433 434 private async getFromProxy( 435 path: string, 436 onSuccess: OnRequestSuccessCallback, 437 type?: XMLHttpRequest['responseType'], 438 ) { 439 const response = await HttpRequest.get( 440 this.makeRequestPath(path), 441 this.getSecurityTokenHeader(), 442 type, 443 ); 444 await this.processProxyResponse(response, onSuccess); 445 } 446 447 private async postToProxy( 448 path: string, 449 onSuccess: OnRequestSuccessCallback, 450 jsonRequest?: object, 451 ) { 452 const response = await HttpRequest.post( 453 this.makeRequestPath(path), 454 this.getSecurityTokenHeader(), 455 jsonRequest, 456 ); 457 await this.processProxyResponse(response, onSuccess); 458 } 459 460 private async processProxyResponse( 461 response: HttpResponse, 462 onSuccess: OnRequestSuccessCallback, 463 ) { 464 if ( 465 response.status === HttpRequestStatus.SUCCESS && 466 !this.isVersionCompatible(response) 467 ) { 468 await this.setState(ConnectionState.INVALID_VERSION); 469 return; 470 } 471 const adbResponse = await this.processHttpResponse(response, onSuccess); 472 if (adbResponse !== undefined) { 473 await this.setState(adbResponse.newState, adbResponse.errorMsg); 474 } 475 } 476 477 private isVersionCompatible(req: HttpResponse): boolean { 478 const proxyVersion = req.getHeader('Winscope-Proxy-Version'); 479 if (!proxyVersion) return false; 480 const [proxyMajor, proxyMinor, proxyPatch] = proxyVersion 481 .split('.') 482 .map((s) => Number(s)); 483 const [clientMajor, clientMinor, clientPatch] = 484 ProxyConnection.VERSION.split('.').map((s) => Number(s)); 485 486 if (proxyMajor !== clientMajor) { 487 return false; 488 } 489 490 if (proxyMinor === clientMinor) { 491 // Check patch number to ensure user has deployed latest bug fixes 492 return proxyPatch >= clientPatch; 493 } 494 495 return proxyMinor > clientMinor; 496 } 497 498 private getSecurityTokenHeader(): HttpRequestHeaderType { 499 const lastKey = this.store.get(this.storeKeySecurityToken); 500 if (lastKey !== undefined) { 501 this.securityToken = lastKey; 502 } 503 return [['Winscope-Token', this.securityToken]]; 504 } 505 506 private makeRequestPath(path: string): string { 507 return ProxyConnection.WINSCOPE_PROXY_URL + path; 508 } 509} 510