/** * Copyright Microsoft Corporation. All rights reserved. * * 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 * as debug from 'debug'; import * as types from '../types'; import { EventEmitter } from 'events'; import * as fs from 'fs'; import * as stream from 'stream'; import * as util from 'util'; import * as ws from 'ws'; import { createGuid, makeWaitForNextTask } from '../../utils/utils'; import { BrowserOptions, BrowserProcess } from '../browser'; import { BrowserContext, validateBrowserContextOptions } from '../browserContext'; import { ProgressController } from '../progress'; import { CRBrowser } from '../chromium/crBrowser'; import { helper } from '../helper'; import { Transport } from '../../protocol/transport'; import { RecentLogsCollector } from '../../utils/debugLogger'; const readFileAsync = util.promisify(fs.readFile); export interface Backend { devices(): Promise; } export interface DeviceBackend { serial: string; close(): Promise; init(): Promise; runCommand(command: string): Promise; open(command: string): Promise; } export interface SocketBackend extends EventEmitter { write(data: Buffer): Promise; close(): Promise; } export class Android { private _backend: Backend; constructor(backend: Backend) { this._backend = backend; } async devices(): Promise { const devices = await this._backend.devices(); return await Promise.all(devices.map(d => AndroidDevice.create(d))); } } export class AndroidDevice { readonly _backend: DeviceBackend; readonly model: string; readonly serial: string; private _driverPromise: Promise | undefined; private _lastId = 0; private _callbacks = new Map void, reject: (error: Error) => void }>(); constructor(backend: DeviceBackend, model: string) { this._backend = backend; this.model = model; this.serial = backend.serial; } static async create(backend: DeviceBackend): Promise { await backend.init(); const model = await backend.runCommand('shell:getprop ro.product.model'); return new AndroidDevice(backend, model); } async shell(command: string): Promise { return await this._backend.runCommand(`shell:${command}`); } private async _driver(): Promise { if (this._driverPromise) return this._driverPromise; let callback: any; this._driverPromise = new Promise(f => callback = f); debug('pw:android')('Stopping the old driver'); await this.shell(`am force-stop com.microsoft.playwright.androiddriver`); debug('pw:android')('Uninstalling the old driver'); await this.shell(`cmd package uninstall com.microsoft.playwright.androiddriver`); await this.shell(`cmd package uninstall com.microsoft.playwright.androiddriver.test`); debug('pw:android')('Installing the new driver'); for (const file of ['android-driver.apk', 'android-driver-target.apk']) { const driverFile = await readFileAsync(require.resolve(`../../../bin/${file}`)); const installSocket = await this._backend.open(`shell:cmd package install -r -t -S ${driverFile.length}`); debug('pw:android')('Writing driver bytes: ' + driverFile.length); await installSocket.write(driverFile); const success = await new Promise(f => installSocket.on('data', f)); debug('pw:android')('Written driver bytes: ' + success); } debug('pw:android')('Starting the new driver'); this.shell(`am instrument -w com.microsoft.playwright.androiddriver.test/androidx.test.runner.AndroidJUnitRunner`); debug('pw:android')('Polling the socket'); let socket; while (!socket) { try { socket = await this._backend.open(`localabstract:playwright_android_driver_socket`); } catch (e) { await new Promise(f => setTimeout(f, 100)); } } debug('pw:android')('Connected to driver'); const transport = new Transport(socket, socket, socket, 'be'); transport.onmessage = message => { const response = JSON.parse(message); const { id, result, error } = response; const callback = this._callbacks.get(id); if (!callback) return; if (error) callback.reject(new Error(error)); else callback.fulfill(result); this._callbacks.delete(id); }; callback(transport); return this._driverPromise; } async send(method: string, params: any): Promise { const driver = await this._driver(); const id = ++this._lastId; const result = new Promise((fulfill, reject) => this._callbacks.set(id, { fulfill, reject })); driver.send(JSON.stringify({ id, method, params })); return result; } async close() { const driver = await this._driver(); driver.close(); await this._backend.close(); } async launchBrowser(packageName: string = 'com.android.chrome', options: types.BrowserContextOptions = {}): Promise { debug('pw:android')('Force-stopping', packageName); await this._backend.runCommand(`shell:am force-stop ${packageName}`); const socketName = createGuid(); const commandLine = `_ --disable-fre --no-default-browser-check --no-first-run --remote-debugging-socket-name=${socketName}`; debug('pw:android')('Starting', packageName, commandLine); await this._backend.runCommand(`shell:echo "${commandLine}" > /data/local/tmp/chrome-command-line`); await this._backend.runCommand(`shell:am start -n ${packageName}/com.google.android.apps.chrome.Main about:blank`); debug('pw:android')('Polling for socket', socketName); while (true) { const net = await this._backend.runCommand(`shell:cat /proc/net/unix | grep ${socketName}$`); if (net) break; await new Promise(f => setTimeout(f, 100)); } debug('pw:android')('Got the socket, connecting'); const androidBrowser = new AndroidBrowser(this, packageName, socketName); await androidBrowser._open(); const browserOptions: BrowserOptions = { name: 'clank', slowMo: 0, persistent: { ...options, noDefaultViewport: true }, downloadsPath: undefined, browserProcess: new ClankBrowserProcess(androidBrowser), proxy: options.proxy, protocolLogger: helper.debugProtocolLogger(), browserLogsCollector: new RecentLogsCollector() }; validateBrowserContextOptions(options, browserOptions); const browser = await CRBrowser.connect(androidBrowser, browserOptions); const controller = new ProgressController(); await controller.run(async progress => { await browser._defaultContext!._loadDefaultContext(progress); }); return browser._defaultContext!; } } class AndroidBrowser extends EventEmitter { readonly device: AndroidDevice; readonly socketName: string; private _socket: SocketBackend | undefined; private _receiver: stream.Writable; private _waitForNextTask = makeWaitForNextTask(); onmessage?: (message: any) => void; onclose?: () => void; private _packageName: string; constructor(device: AndroidDevice, packageName: string, socketName: string) { super(); this._packageName = packageName; this.device = device; this.socketName = socketName; this._receiver = new (ws as any).Receiver() as stream.Writable; this._receiver.on('message', message => { this._waitForNextTask(() => { if (this.onmessage) this.onmessage(JSON.parse(message)); }); }); } async _open() { this._socket = await this.device._backend.open(`localabstract:${this.socketName}`); this._socket.on('close', () => { this._waitForNextTask(() => { if (this.onclose) this.onclose(); }); }); await this._socket.write(Buffer.from(`GET /devtools/browser HTTP/1.1\r Upgrade: WebSocket\r Connection: Upgrade\r Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==\r Sec-WebSocket-Version: 13\r \r `)); // HTTP Upgrade response. await new Promise(f => this._socket!.once('data', f)); // Start sending web frame to receiver. this._socket.on('data', data => this._receiver._write(data, 'binary', () => {})); } async send(s: any) { await this._socket!.write(encodeWebFrame(JSON.stringify(s))); } async close() { await this._socket!.close(); await this.device._backend.runCommand(`shell:am force-stop ${this._packageName}`); } } function encodeWebFrame(data: string): Buffer { return (ws as any).Sender.frame(Buffer.from(data), { opcode: 1, mask: true, fin: true, readOnly: true })[0]; } class ClankBrowserProcess implements BrowserProcess { private _browser: AndroidBrowser; constructor(browser: AndroidBrowser) { this._browser = browser; } onclose: ((exitCode: number | null, signal: string | null) => void) | undefined; async kill(): Promise { } async close(): Promise { await this._browser.close(); } }