Add adb controller and adb interfaces.
In order to support start recording a trace from android devices
directly from the UI, a new adb_record_controller has been added, with a
custom implementation of the consumer_port. Instead of the real ADB
implementation in typescript, an interface and mock implementation have
been used. This will be replaced with the real ADB as soon as it has
been tested enough.
Bug: 139536756
Change-Id: I1edb7a55a9a59278143de51e98ffe8ea52b35e07
diff --git a/ui/package-lock.json b/ui/package-lock.json
index 1d3b02d..ba10bac 100644
--- a/ui/package-lock.json
+++ b/ui/package-lock.json
@@ -178,6 +178,11 @@
"@types/node": "*"
}
},
+ "@types/w3c-web-usb": {
+ "version": "1.0.3",
+ "resolved": "https://registry.npmjs.org/@types/w3c-web-usb/-/w3c-web-usb-1.0.3.tgz",
+ "integrity": "sha512-XilIvno46lLRyOj/1G5z88M6iimYs3dr3LNdSN9UxSWifytKsBMmRtAi53X3j0ThrrqyVotqrSeaYv9a0I131w=="
+ },
"abab": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/abab/-/abab-2.0.0.tgz",
@@ -2608,8 +2613,7 @@
"ansi-regex": {
"version": "2.1.1",
"bundled": true,
- "dev": true,
- "optional": true
+ "dev": true
},
"aproba": {
"version": "1.2.0",
@@ -2630,14 +2634,12 @@
"balanced-match": {
"version": "1.0.0",
"bundled": true,
- "dev": true,
- "optional": true
+ "dev": true
},
"brace-expansion": {
"version": "1.1.11",
"bundled": true,
"dev": true,
- "optional": true,
"requires": {
"balanced-match": "^1.0.0",
"concat-map": "0.0.1"
@@ -2652,20 +2654,17 @@
"code-point-at": {
"version": "1.1.0",
"bundled": true,
- "dev": true,
- "optional": true
+ "dev": true
},
"concat-map": {
"version": "0.0.1",
"bundled": true,
- "dev": true,
- "optional": true
+ "dev": true
},
"console-control-strings": {
"version": "1.1.0",
"bundled": true,
- "dev": true,
- "optional": true
+ "dev": true
},
"core-util-is": {
"version": "1.0.2",
@@ -2782,8 +2781,7 @@
"inherits": {
"version": "2.0.3",
"bundled": true,
- "dev": true,
- "optional": true
+ "dev": true
},
"ini": {
"version": "1.3.5",
@@ -2795,7 +2793,6 @@
"version": "1.0.0",
"bundled": true,
"dev": true,
- "optional": true,
"requires": {
"number-is-nan": "^1.0.0"
}
@@ -2810,7 +2807,6 @@
"version": "3.0.4",
"bundled": true,
"dev": true,
- "optional": true,
"requires": {
"brace-expansion": "^1.1.7"
}
@@ -2818,14 +2814,12 @@
"minimist": {
"version": "0.0.8",
"bundled": true,
- "dev": true,
- "optional": true
+ "dev": true
},
"minipass": {
"version": "2.3.5",
"bundled": true,
"dev": true,
- "optional": true,
"requires": {
"safe-buffer": "^5.1.2",
"yallist": "^3.0.0"
@@ -2844,7 +2838,6 @@
"version": "0.5.1",
"bundled": true,
"dev": true,
- "optional": true,
"requires": {
"minimist": "0.0.8"
}
@@ -2925,8 +2918,7 @@
"number-is-nan": {
"version": "1.0.1",
"bundled": true,
- "dev": true,
- "optional": true
+ "dev": true
},
"object-assign": {
"version": "4.1.1",
@@ -2938,7 +2930,6 @@
"version": "1.4.0",
"bundled": true,
"dev": true,
- "optional": true,
"requires": {
"wrappy": "1"
}
@@ -3024,8 +3015,7 @@
"safe-buffer": {
"version": "5.1.2",
"bundled": true,
- "dev": true,
- "optional": true
+ "dev": true
},
"safer-buffer": {
"version": "2.1.2",
@@ -3061,7 +3051,6 @@
"version": "1.0.2",
"bundled": true,
"dev": true,
- "optional": true,
"requires": {
"code-point-at": "^1.0.0",
"is-fullwidth-code-point": "^1.0.0",
@@ -3081,7 +3070,6 @@
"version": "3.0.1",
"bundled": true,
"dev": true,
- "optional": true,
"requires": {
"ansi-regex": "^2.0.0"
}
@@ -3125,14 +3113,12 @@
"wrappy": {
"version": "1.0.2",
"bundled": true,
- "dev": true,
- "optional": true
+ "dev": true
},
"yallist": {
"version": "3.0.3",
"bundled": true,
- "dev": true,
- "optional": true
+ "dev": true
}
}
},
diff --git a/ui/package.json b/ui/package.json
index c1f09a8..da29ffa 100644
--- a/ui/package.json
+++ b/ui/package.json
@@ -16,6 +16,7 @@
"@types/mithril": "^1.1.16",
"@types/pako": "^1.0.1",
"@types/uuid": "^3.4.4",
+ "@types/w3c-web-usb": "^1.0.3",
"color-convert": "^2.0.0",
"devtools-protocol": "0.0.681549",
"events": "^3.0.0",
diff --git a/ui/src/chrome_extension/chrome_tracing_controller.ts b/ui/src/chrome_extension/chrome_tracing_controller.ts
index 0ba2314..c76df44 100644
--- a/ui/src/chrome_extension/chrome_tracing_controller.ts
+++ b/ui/src/chrome_extension/chrome_tracing_controller.ts
@@ -15,8 +15,10 @@
import {Protocol} from 'devtools-protocol';
import {ProtocolProxyApi} from 'devtools-protocol/types/protocol-proxy-api';
import * as rpc from 'noice-json-rpc';
+
import {TraceConfig} from '../common/protos';
import {
+ ConsumerPortResponse,
GetTraceStatsResponse,
ReadBuffersResponse
} from '../controller/consumer_port_types';
@@ -43,7 +45,7 @@
this.api.Tracing.on('bufferUsage', this.onBufferUsage.bind(this));
}
- sendMessage(message: object) {
+ sendMessage(message: ConsumerPortResponse) {
this.uiPort.postMessage(message);
}
diff --git a/ui/src/common/actions.ts b/ui/src/common/actions.ts
index f37ae0a..b42de67 100644
--- a/ui/src/common/actions.ts
+++ b/ui/src/common/actions.ts
@@ -413,6 +413,10 @@
state.bufferUsage = args.percentage;
},
+ setAndroidDevice(state: StateDraft, args: {serial: string}): void {
+ state.serialAndroidDeviceConnected = args.serial;
+ },
+
};
// When we are on the frontend side, we don't really want to execute the
diff --git a/ui/src/common/state.ts b/ui/src/common/state.ts
index 02845f1..ea0f465 100644
--- a/ui/src/common/state.ts
+++ b/ui/src/common/state.ts
@@ -169,6 +169,7 @@
*/
recordingInProgress: boolean;
extensionInstalled: boolean;
+ serialAndroidDeviceConnected: string|undefined;
}
export const defaultTraceTime = {
@@ -186,6 +187,10 @@
return ['Q', 'P', 'O'].includes(target);
}
+export function isChromeTarget(target: TargetOs) {
+ return target === 'C';
+}
+
export interface RecordConfig {
[key: string]: null|number|boolean|string|string[];
@@ -329,5 +334,6 @@
flagPauseEnabled: false,
recordingInProgress: false,
extensionInstalled: false,
+ serialAndroidDeviceConnected: undefined,
};
}
diff --git a/ui/src/controller/adb_interfaces.ts b/ui/src/controller/adb_interfaces.ts
new file mode 100644
index 0000000..91dd653
--- /dev/null
+++ b/ui/src/controller/adb_interfaces.ts
@@ -0,0 +1,54 @@
+// Copyright (C) 2019 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+
+export interface Adb {
+ connect(device: USBDevice): Promise<void>;
+ shell(cmd: string): Promise<AdbStream>;
+}
+
+export interface AdbStream {
+ onMessage(message: AdbMsg): void;
+ onData: (str: string, raw: Uint8Array) => void;
+ onConnect: VoidCallback;
+ onClose: VoidCallback;
+}
+
+export class MockAdb implements Adb {
+ connect(_: USBDevice): Promise<void> {
+ return Promise.resolve();
+ }
+ shell(_: string): Promise<AdbStream> {
+ return Promise.resolve(new MockAdbStream());
+ }
+}
+
+export class MockAdbStream implements AdbStream {
+ onData = (_: string, __: Uint8Array) => {};
+ onConnect = () => {};
+ onClose = () => {};
+ onMessage = (_: AdbMsg) => {};
+}
+
+export declare type CmdType =
+ 'CNXN' | 'AUTH' | 'CLSE' | 'OKAY' | 'WRTE' | 'OPEN';
+
+export interface AdbMsg {
+ cmd: CmdType;
+ arg0: number;
+ arg1: number;
+ data: Uint8Array;
+ dataLen: number;
+ dataChecksum: number;
+}
\ No newline at end of file
diff --git a/ui/src/controller/adb_record_controller.ts b/ui/src/controller/adb_record_controller.ts
new file mode 100644
index 0000000..55ec33b
--- /dev/null
+++ b/ui/src/controller/adb_record_controller.ts
@@ -0,0 +1,165 @@
+// Copyright (C) 2019 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+import {Adb, AdbStream} from './adb_interfaces';
+
+import {ConsumerPortResponse, ReadBuffersResponse} from './consumer_port_types';
+import {globals} from './globals';
+import {toPbtxt} from './record_controller';
+
+enum AdbState {
+ READY,
+ RECORDING,
+ FETCHING
+}
+const DEFAULT_DESTINATION_FILE = '/data/misc/perfetto-traces/trace';
+
+export class AdbRecordController {
+ // public for testing
+ traceDestFile = DEFAULT_DESTINATION_FILE;
+ private state = AdbState.READY;
+ private adb: Adb;
+ private device: USBDevice|undefined = undefined;
+ private mainControllerCallback: (_: {data: ConsumerPortResponse}) => void;
+
+ constructor(adb: Adb, mainControllerCallback: (_: {
+ data: ConsumerPortResponse
+ }) => void) {
+ this.mainControllerCallback = mainControllerCallback;
+ this.adb = adb;
+ }
+
+ sendMessage(message: ConsumerPortResponse) {
+ this.mainControllerCallback({data: message});
+ }
+
+ sendErrorMessage(msg: string) {
+ console.error('Error in adb record controller: ', msg);
+ }
+
+ handleCommand(method: string, params: Uint8Array) {
+ // TODO(nicomazz): after having implemented the connection to the consumer
+ // port socket through adb (on a real device), this class will be a simple
+ // proxy.
+ switch (method) {
+ case 'EnableTracing':
+ this.enableTracing(params);
+ break;
+ case 'ReadBuffers':
+ this.readBuffers();
+ break;
+ case 'FreeBuffers': // no-op
+ case 'GetTraceStats':
+ case 'DisableTracing':
+ break;
+ default:
+ this.sendErrorMessage(`Method not recognized: ${method}`);
+ break;
+ }
+ }
+
+ async enableTracing(configProto: Uint8Array) {
+ try {
+ if (this.state !== AdbState.READY) {
+ console.error('Current state of AdbRecordController is not READY');
+ return;
+ }
+ this.device = await this.findDevice();
+
+ if (this.device === undefined) {
+ this.sendErrorMessage('No device found');
+ return;
+ }
+
+ await this.adb.connect(this.device);
+ await this.startRecording(configProto);
+
+ } catch (e) {
+ this.sendErrorMessage(`Error in enableTracing: ${e.name} ${e.message}`);
+ }
+ }
+
+ async startRecording(configProto: Uint8Array) {
+ this.state = AdbState.RECORDING;
+ // TODO(nicomazz): Send in the command directly the config proto instead of
+ // the string, removing the option "--txt".
+ const recordCommand =
+ this.generateStartTracingCommand(toPbtxt(configProto));
+ const recordShell: AdbStream = await this.adb.shell(recordCommand);
+ let response = '';
+ recordShell.onData = (str, _) => response += str;
+ recordShell.onClose = () => {
+ if (!this.tracingEndedSuccessfully(response)) {
+ this.sendErrorMessage(`Error in enableTracing, output: ${response}`);
+ this.state = AdbState.READY;
+ return;
+ }
+ this.sendMessage({type: 'EnableTracingResponse'});
+ };
+ }
+
+ tracingEndedSuccessfully(response: string): boolean {
+ return response.includes('starting tracing') &&
+ !response.includes(' 0 ms') && response.includes('Wrote ');
+ }
+
+ async findDevice() {
+ const targetSerial = globals.state.serialAndroidDeviceConnected;
+ const devices = await navigator.usb.getDevices();
+ return devices.find(d => d.serialNumber === targetSerial);
+ }
+
+ async readBuffers() {
+ console.assert(this.state === AdbState.RECORDING);
+ this.state = AdbState.FETCHING;
+
+ const readTraceShell =
+ await this.adb.shell(this.generateReadTraceCommand());
+ let trace = '';
+ readTraceShell.onData = (str, _) => {
+ // TODO(nicomazz): Since we are using base64, we can't decode the chunks
+ // as they are received (without further base64 stream decoding
+ // implementations). After the investigation about why without base64
+ // things are not working, the chunks should be sent as they are received,
+ // like in the following line.
+ // this.sendMessage(this.generateChunkReadResponse(str));
+ trace += str;
+ };
+ readTraceShell.onClose = () => {
+ const decoded = atob(trace.replace(/\n/g, ''));
+
+ this.sendMessage(
+ this.generateChunkReadResponse(decoded, /* last */ true));
+ this.state = AdbState.READY;
+ };
+ }
+
+ generateChunkReadResponse(data: string, last = false): ReadBuffersResponse {
+ return {
+ type: 'ReadBuffersResponse',
+ slices: [{data: data as unknown as Uint8Array, lastSliceForPacket: last}]
+ };
+ }
+
+ generateReadTraceCommand(): string {
+ // TODO(nicomazz): Investigate why without base64 things break.
+ return `cat ${this.traceDestFile} | gzip | base64`;
+ }
+
+ generateStartTracingCommand(tracingConfigTxt: string) {
+ const perfettoCmd = `perfetto -c - --txt -o ${this.traceDestFile}`;
+ const base64Config = btoa(tracingConfigTxt);
+ return `echo '${base64Config}' | base64 -d | ${perfettoCmd}`;
+ }
+}
diff --git a/ui/src/controller/adb_record_controller_jsdomtest.ts b/ui/src/controller/adb_record_controller_jsdomtest.ts
new file mode 100644
index 0000000..297b769
--- /dev/null
+++ b/ui/src/controller/adb_record_controller_jsdomtest.ts
@@ -0,0 +1,107 @@
+// Copyright (C) 2019 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+import {dingus} from 'dingusjs';
+
+import {AdbStream, MockAdb, MockAdbStream} from './adb_interfaces';
+import {AdbRecordController} from './adb_record_controller';
+
+const mainCallback = jest.fn();
+const adbMock = new MockAdb();
+const adbController = new AdbRecordController(adbMock, mainCallback);
+const mockIntArray = new Uint8Array();
+
+test('handleCommand', () => {
+ adbController.findDevice = () => {
+ return Promise.resolve(dingus<USBDevice>());
+ };
+
+ const enableTracing = jest.fn();
+ adbController.enableTracing = enableTracing;
+ adbController.handleCommand('EnableTracing', mockIntArray);
+ expect(enableTracing).toHaveBeenCalledTimes(1);
+
+ const readBuffers = jest.fn();
+ adbController.readBuffers = readBuffers;
+ adbController.handleCommand('ReadBuffers', mockIntArray);
+ expect(readBuffers).toHaveBeenCalledTimes(1);
+
+ const sendErrorMessage = jest.fn();
+ adbController.sendErrorMessage = sendErrorMessage;
+ adbController.handleCommand('unknown', mockIntArray);
+ expect(sendErrorMessage).toBeCalledWith('Method not recognized: unknown');
+});
+
+test('enableTracing', async () => {
+ const mainCallback = jest.fn();
+ const adbMock = new MockAdb();
+ const adbController = new AdbRecordController(adbMock, mainCallback);
+
+ adbController.sendErrorMessage =
+ jest.fn().mockImplementation(s => console.error(s));
+
+ const findDevice = jest.fn().mockImplementation(() => {
+ return Promise.resolve({} as unknown as USBDevice);
+ });
+ adbController.findDevice = findDevice;
+
+ const connectToDevice =
+ jest.fn().mockImplementation((_: USBDevice) => Promise.resolve());
+ adbMock.connect = connectToDevice;
+
+ const stream: AdbStream = new MockAdbStream();
+ const adbShell =
+ jest.fn().mockImplementation((_: string) => Promise.resolve(stream));
+ adbMock.shell = adbShell;
+
+
+ const sendMessage = jest.fn();
+ adbController.sendMessage = sendMessage;
+
+ adbController.generateStartTracingCommand = (_) => 'CMD';
+
+ await adbController.enableTracing(mockIntArray);
+ stream.onData('starting tracing Wrote 123 bytes', mockIntArray);
+ stream.onClose();
+
+ expect(findDevice).toHaveBeenCalledTimes(1);
+ expect(connectToDevice).toHaveBeenCalledTimes(1);
+ expect(adbShell).toBeCalledWith('CMD');
+ expect(adbController.sendErrorMessage).toHaveBeenCalledTimes(0);
+ expect(sendMessage).toBeCalledWith({type: 'EnableTracingResponse'});
+});
+
+
+test('generateStartTracing', () => {
+ adbController.traceDestFile = 'DEST';
+ const generatedCmd = adbController.generateStartTracingCommand('TEST\nTEST');
+ expect(generatedCmd)
+ .toBe(`echo '${
+ btoa('TEST\nTEST')}' | base64 -d | perfetto -c - --txt -o DEST`);
+});
+
+test('tracingEndedSuccessfully', () => {
+ expect(adbController.tracingEndedSuccessfully(
+ 'Connected to the Perfetto traced service,\ starting tracing for \
+10000 ms\nWrote 564 bytes into /data/misc/perfetto-traces/trace'))
+ .toBe(true);
+ expect(adbController.tracingEndedSuccessfully(
+ 'Connected to the Perfetto traced service, starting tracing for \
+10000 ms'))
+ .toBe(false);
+ expect(adbController.tracingEndedSuccessfully(
+ 'Connected to the Perfetto traced service, starting tracing for \
+0 ms'))
+ .toBe(false);
+});
\ No newline at end of file
diff --git a/ui/src/controller/consumer_port_types.ts b/ui/src/controller/consumer_port_types.ts
index 0e9bc0c..9677be3 100644
--- a/ui/src/controller/consumer_port_types.ts
+++ b/ui/src/controller/consumer_port_types.ts
@@ -25,6 +25,8 @@
export interface GetTraceStatsResponse extends
Typed, perfetto.protos.IGetTraceStatsResponse {}
+export type ConsumerPortResponse =
+ EnableTracingResponse|ReadBuffersResponse|GetTraceStatsResponse;
export function isReadBuffersResponse(obj: Typed): obj is ReadBuffersResponse {
return obj.type === 'ReadBuffersResponse';
diff --git a/ui/src/controller/record_controller.ts b/ui/src/controller/record_controller.ts
index c73fa56..bccb50e 100644
--- a/ui/src/controller/record_controller.ts
+++ b/ui/src/controller/record_controller.ts
@@ -30,15 +30,21 @@
TraceConfig,
} from '../common/protos';
import {MeminfoCounters, VmstatCounters} from '../common/protos';
-import {MAX_TIME, RecordConfig} from '../common/state';
-
import {
- EnableTracingResponse,
+ isAndroidTarget,
+ isChromeTarget,
+ MAX_TIME,
+ RecordConfig
+} from '../common/state';
+
+import {MockAdb} from './adb_interfaces';
+import {AdbRecordController} from './adb_record_controller';
+import {
+ ConsumerPortResponse,
GetTraceStatsResponse,
isEnableTracingResponse,
isGetTraceStatsResponse,
isReadBuffersResponse,
- ReadBuffersResponse
} from './consumer_port_types';
import {Controller} from './controller';
import {App, globals} from './globals';
@@ -412,12 +418,16 @@
private traceBuffer = '';
private bufferUpdateInterval: ReturnType<typeof setTimeout>|undefined;
+ // TODO(nicomazz): Replace MockAdb with the true Adb implementation.
+ private adbRecordController = new AdbRecordController(
+ new MockAdb(), this.onConsumerPortMessage.bind(this));
+
constructor(args: {app: App, extensionPort: MessagePort}) {
super('main');
this.app = args.app;
this.consumerPort = ConsumerPort.create(this.rpcImpl.bind(this));
this.extensionPort = args.extensionPort;
- this.extensionPort.onmessage = this.onExtensionMessage.bind(this);
+ this.extensionPort.onmessage = this.onConsumerPortMessage.bind(this);
}
run() {
@@ -472,13 +482,10 @@
}
readBuffers() {
- if (this.extensionPort) this.consumerPort.readBuffers({});
+ this.consumerPort.readBuffers({});
}
- onExtensionMessage(message: {
- data: EnableTracingResponse|ReadBuffersResponse|GetTraceStatsResponse
- }) {
- const data = message.data;
+ onConsumerPortMessage({data}: {data: ConsumerPortResponse}) {
if (data === undefined) return;
// TODO(nicomazz): Add error handling.
@@ -525,15 +532,31 @@
return used / total;
}
- // This forwards the messages that have to be sent to the extension to the
- // frontend. This is necessary because this controller is running in a
- // separate worker, that can't directly send messages to the extension.
+ // Depending on the recording target, different implementation of the
+ // consumer_port will be used.
+ // - Chrome target: This forwards the messages that have to be sent
+ // to the extension to the frontend. This is necessary because this controller
+ // is running in a separate worker, that can't directly send messages to the
+ // extension.
+ // - Android device target: WebUSB is used to communicate using the adb
+ // protocol. Actually, there is no full consumer_port implementation, but only
+ // the support to start tracing and fetch the file.
private rpcImpl(
method: RPCImplMethod, requestData: Uint8Array,
_callback: RPCImplCallback) {
- if (method !== null && method.name !== null && this.config !== null) {
+ const target = this.app.state.recordConfig.targetOS;
+ if (isChromeTarget(target) && method !== null && method.name !== null &&
+ this.config !== null) {
this.extensionPort.postMessage(
{method: method.name, traceConfig: requestData});
+ } else if (isAndroidTarget(target)) {
+ // TODO(nicomazz): In theory requestData should contain the configuration
+ // proto, but in practice there are missing fields. As a temporary
+ // workaround I'm directly passing the configuration.
+ this.adbRecordController.handleCommand(
+ method.name, genConfigProto(this.config!));
+ } else {
+ console.error(`Target ${target} not supported!`);
}
}
}