From 44d887dd6c8fe844295844c12a97fe494917f9c6 Mon Sep 17 00:00:00 2001 From: Dave Holoway Date: Thu, 23 Apr 2020 13:28:03 +0100 Subject: [PATCH] Support attaching to running app (#85) * add support for timeout on adb socket reads * add debugger support for attaching to a process * add new launch configuration and support for picking an Android process ID * initial support for attaching to android process * display enhanced quick pick list with pids and names * add flag to prevent disconnect messages when not connected * Retrieve all loaded classes during startup. This allows us to identify breakpoints in anonymous classes that are already loaded. * correct name of process picker command * make PickAndroidProcess command private * selectAndroidProcessID always returns an object * make breakpoint setup a loop instead of recursive * tidy some labels and error messages * use a more consistent command for retrieving process names * show pid list sorted by pid instead of name * refactor some Android and ADB-specific functions Check ANDROID_SDK as replacement for ANDROID_HOME * tidy up logcat launch and refactor target device selection * fix logcat not displaying * filter duplicates and blanks from logcat output --- .vscode/launch.json | 6 +- extension.js | 7 + package.json | 63 +++++++- src/adbclient.js | 121 ++++++++++---- src/debugMain.js | 300 +++++++++++++++++++++++++---------- src/debugger-types.js | 4 +- src/debugger.js | 135 ++++++++-------- src/jdwp.js | 2 +- src/logcat.js | 121 ++++++-------- src/process-attach.js | 83 ++++++++++ src/sockets/adbsocket.js | 7 +- src/sockets/androidsocket.js | 47 +++++- src/utils/android.js | 87 ++++++++++ src/utils/device.js | 55 +++++++ src/utils/print.js | 21 ++- 15 files changed, 781 insertions(+), 278 deletions(-) create mode 100644 src/process-attach.js create mode 100644 src/utils/android.js create mode 100644 src/utils/device.js diff --git a/.vscode/launch.json b/.vscode/launch.json index adff66a..1a994a6 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -14,7 +14,7 @@ ] }, { - "name": "Server", + "name": "Debugger Server", "type": "node", "request": "launch", "cwd": "${workspaceRoot}", @@ -35,8 +35,8 @@ ], "compounds": [ { - "name": "Extension + Server", - "configurations": [ "Launch Extension", "Server" ] + "name": "Extension + Debugger", + "configurations": [ "Launch Extension", "Debugger Server" ] } ] } \ No newline at end of file diff --git a/extension.js b/extension.js index fb54fc0..8cc94c1 100644 --- a/extension.js +++ b/extension.js @@ -3,6 +3,7 @@ const vscode = require('vscode'); const { AndroidContentProvider } = require('./src/contentprovider'); const { openLogcatWindow } = require('./src/logcat'); +const { selectAndroidProcessID } = require('./src/process-attach'); // this method is called when your extension is activated // your extension is activated the very first time the command is executed @@ -17,6 +18,12 @@ function activate(context) { vscode.commands.registerCommand('android-dev-ext.view_logcat', () => { openLogcatWindow(vscode); }), + // add the process picker handler - used to choose a PID to attach to + vscode.commands.registerCommand('PickAndroidProcess', async () => { + const o = await selectAndroidProcessID(vscode); + // the debugger requires a string value to be returned + return JSON.stringify(o); + }), ]; context.subscriptions.splice(context.subscriptions.length, 0, ...disposables); diff --git a/package.json b/package.json index d70f1b5..ddde565 100644 --- a/package.json +++ b/package.json @@ -18,7 +18,8 @@ "theme": "dark" }, "activationEvents": [ - "onCommand:android-dev-ext.view_logcat" + "onCommand:android-dev-ext.view_logcat", + "onCommand:PickAndroidProcess" ], "repository": { "type": "git", @@ -128,30 +129,84 @@ "default": false } } + }, + "attach": { + "required": [ + "appSrcRoot", + "adbPort", + "processId" + ], + "properties": { + "appSrcRoot": { + "type": "string", + "description": "Location of the App source files. This value must point to the root of your App source tree (containing AndroidManifest.xml)", + "default": "${workspaceRoot}/app/src/main" + }, + "adbPort": { + "type": "integer", + "description": "Port number to connect to the local ADB (Android Debug Bridge) instance. Default: 5037", + "default": 5037 + }, + "processId": { + "type": "string", + "description": "PID of process to attach to.\n\"${command:PickAndroidProcess}\" will display a list of debuggable PIDs to choose from during launch.", + "default": "${command:PickAndroidProcess}" + }, + "targetDevice": { + "type": "string", + "description": "Target Device ID (as indicated by 'adb devices'). Use this to specify which device is used when multiple devices are connected.", + "default": "" + }, + "trace": { + "type": "boolean", + "description": "Set to true to output debugging logs for diagnostics", + "default": false + } + } } }, "initialConfigurations": [ { "type": "android", - "name": "Android", "request": "launch", + "name": "Android launch", "appSrcRoot": "${workspaceRoot}/app/src/main", "apkFile": "${workspaceRoot}/app/build/outputs/apk/debug/app-debug.apk", "adbPort": 5037 + }, + { + "type": "android", + "request": "attach", + "name": "Android attach", + "appSrcRoot": "${workspaceRoot}/app/src/main", + "adbPort": 5037, + "processId": "${command:PickAndroidProcess}" } ], "configurationSnippets": [ { - "label": "Android: Launch Configuration", + "label": "Android: Launch Application", "description": "A new configuration for launching an Android app debugging session", "body": { "type": "android", "request": "launch", - "name": "${2:Launch App}", + "name": "${2:Android Launch}", "appSrcRoot": "^\"\\${workspaceRoot}/app/src/main\"", "apkFile": "^\"\\${workspaceRoot}/app/build/outputs/apk/debug/app-debug.apk\"", "adbPort": 5037 } + }, + { + "label": "Android: Attach to Process", + "description": "A new configuration for attaching to a running Android app process", + "body": { + "type": "android", + "request": "attach", + "name": "${2:Android Attach}", + "appSrcRoot": "^\"\\${workspaceRoot}/app/src/main\"", + "adbPort": 5037, + "processId": "^\"\\${command:PickAndroidProcess}\"" + } } ], "variables": {} diff --git a/src/adbclient.js b/src/adbclient.js index 5794fe4..a2c51d0 100644 --- a/src/adbclient.js +++ b/src/adbclient.js @@ -68,14 +68,63 @@ class ADBClient { } /** - * Return a list of debuggable pids from the device + * Return a list of debuggable pids from the device. + * + * The `adb jdwp` command never terminates - it just posts each debuggable PID + * as it comes online. Normally we just perform a single read of stdout + * and terminate the connection, but if there are no pids available, the command + * will wait forever. + * @param {number} [timeout_ms] time to wait before we abort reading (and return an empty list). */ - async jdwp_list() { + async jdwp_list(timeout_ms) { await this.connect_to_adb(); await this.adbsocket.cmd_and_status(`host:transport:${this.deviceid}`); - const stdout = await this.adbsocket.cmd_and_read_stdout('jdwp'); + /** @type {string} */ + let stdout; + try { + stdout = await this.adbsocket.cmd_and_read_stdout('jdwp', timeout_ms); + } catch { + // timeout or socket closed + stdout = ''; + } await this.disconnect_from_adb(); - return stdout.trim().split(/\r?\n|\r/); + // do not sort the pid list - the debugger needs to pick the last one in the list. + return stdout.trim().split(/\s+/).filter(x => x).map(s => parseInt(s, 10)); + } + + async named_jdwp_list(timeout_ms) { + const named_pids = (await this.jdwp_list(timeout_ms)) + .map(pid => ({ + pid, + name: '', + })) + if (!named_pids.length) + return []; + + // retrieve the list of process names from the device + const command = `for pid in ${named_pids.map(np => np.pid).join(' ')}; do cat /proc/$pid/cmdline;echo " $pid"; done`; + const stdout = await this.shell_cmd({ + command, + untilclosed: true, + }); + // output should look something like... + // com.example.somepkg 32721 + const lines = stdout.replace(/\0+/g,'').split(/\r?\n|\r/g); + + // scan the list looking for pids to match names with... + for (let i = 0; i < lines.length; i++) { + let entries = lines[i].match(/^\s*(.*)\s+(\d+)$/); + if (!entries) { + continue; + } + const pid = parseInt(entries[2], 10); + const named_pid = named_pids.find(x => x.pid === pid); + if (named_pid) { + named_pid.name = entries[1]; + } + } + + return named_pids; } /** @@ -132,12 +181,14 @@ class ADBClient { /** * Run a shell command on the connected device - * @param {{command:string}} o + * @param {{command:string, untilclosed?:boolean}} o + * @param {number} [timeout_ms] + * @returns {Promise} */ - async shell_cmd(o) { + async shell_cmd(o, timeout_ms) { await this.connect_to_adb(); await this.adbsocket.cmd_and_status(`host:transport:${this.deviceid}`); - const stdout = await this.adbsocket.cmd_and_read_stdout(`shell:${o.command}`); + const stdout = await this.adbsocket.cmd_and_read_stdout(`shell:${o.command}`, timeout_ms, o.untilclosed); await this.disconnect_from_adb(); return stdout; } @@ -145,7 +196,7 @@ class ADBClient { /** * Starts the Logcat monitor. * Logcat lines are passed back via onlog callback. If the device disconnects, onclose is called. - * @param {{onlog:(e)=>void, onclose:()=>void}} o + * @param {{onlog:(e)=>void, onclose:(err)=>void}} o */ async startLogcatMonitor(o) { // onlog:function(e) @@ -157,37 +208,41 @@ class ADBClient { if (!o.onlog) { const logcatbuffer = await this.adbsocket.read_stdout(); await this.disconnect_from_adb(); - return logcatbuffer; + return logcatbuffer.toString(); } // start the logcat monitor - let logcatbuffer = Buffer.alloc(0); const next_logcat_lines = async () => { - // read the next data from ADB + let logcatbuffer = Buffer.alloc(0); let next_data; - try{ - next_data = await this.adbsocket.read_stdout(null); - } catch(e) { - o.onclose(); - return; + for (;;) { + // read the next data from ADB + try { + next_data = await this.adbsocket.read_stdout(); + } catch(e) { + o.onclose(e); + return; + } + logcatbuffer = Buffer.concat([logcatbuffer, next_data]); + const last_newline_index = logcatbuffer.lastIndexOf(10) + 1; + if (last_newline_index === 0) { + // wait for a whole line + next_logcat_lines(); + return; + } + // split into lines, sort and remove duplicates and blanks + const logs = logcatbuffer.slice(0, last_newline_index).toString() + .split(/\r\n?|\n/) + .sort() + .filter((line,idx,arr) => line && line !== arr[idx-1]); + + logcatbuffer = logcatbuffer.slice(last_newline_index); + const e = { + adbclient: this, + logs, + }; + o.onlog(e); } - logcatbuffer = Buffer.concat([logcatbuffer, next_data]); - const last_newline_index = logcatbuffer.lastIndexOf(10) + 1; - if (last_newline_index === 0) { - // wait for a whole line - next_logcat_lines(); - return; - } - // split into lines - const logs = logcatbuffer.slice(0, last_newline_index).toString().split(/\r\n?|\n/); - logcatbuffer = logcatbuffer.slice(last_newline_index); - - const e = { - adbclient: this, - logs, - }; - o.onlog(e); - next_logcat_lines(); } next_logcat_lines(); } diff --git a/src/debugMain.js b/src/debugMain.js index d0e8072..eeffcfd 100644 --- a/src/debugMain.js +++ b/src/debugMain.js @@ -4,7 +4,6 @@ const { Thread, StackFrame, Scope, Source, Breakpoint } = require('vscode-debugadapter'); // node and external modules -const fs = require('fs'); const os = require('os'); const path = require('path'); @@ -17,7 +16,8 @@ const { evaluate } = require('./expression/evaluate'); const { PackageInfo } = require('./package-searcher'); const ADBSocket = require('./sockets/adbsocket'); const { AndroidThread } = require('./threads'); -const { D, onMessagePrint } = require('./utils/print'); +const { checkADBStarted, getAndroidSourcesFolder } = require('./utils/android'); +const { D, initLogToClient, onMessagePrint } = require('./utils/print'); const { hasValidSourceFileExtension } = require('./utils/source-file'); const { VariableManager } = require('./variable-manager'); @@ -33,13 +33,6 @@ class AndroidDebugSession extends DebugSession { // the base folder of the app (where AndroidManifest.xml and source files should be) this.app_src_root = ''; - // the filepathname of the built apk - this.apk_fpn = ''; - /** - * the file info, hash and manifest data of the apk - * @type {APKFileInfo} - */ - this.apk_file_info = null; // packages we found in the source tree this.src_packages = { last_src_modified: 0, @@ -50,8 +43,17 @@ class AndroidDebugSession extends DebugSession { this._device = null; // the API level of the device we are debugging this.device_api_level = ''; + + // the full file path name of the AndroidManifest.xml, taken from the manifestFile launch property this.manifest_fpn = ''; + // the filepathname of the built apk + this.apk_fpn = ''; + /** + * the file info, hash and manifest data of the apk + * @type {APKFileInfo} + */ + this.apk_file_info = null; /** * array of custom arguments to pass to `pm install` @@ -94,9 +96,20 @@ class AndroidDebugSession extends DebugSession { // trace flag for printing diagnostic messages to the client Output Window this.trace = false; + // set to true if we've connected to the device + this.debuggerAttached = false; + + /** + * @type {'launch'|'attach'} + */ + this.debug_mode = null; + // this debugger uses one-based lines and columns this.setDebuggerLinesStartAt1(true); - this.setDebuggerColumnsStartAt1(true); + this.setDebuggerColumnsStartAt1(true); + + // override the log function to output to the client Debug Console + initLogToClient(this.LOG.bind(this)); } /** @@ -264,13 +277,145 @@ class AndroidDebugSession extends DebugSession { }) } - async launchRequest(response/*: DebugProtocol.LaunchResponse*/, args/*: LaunchRequestArguments*/) { + /** + * @param {*} obj + */ + extractPidAndTargetDevice(obj) { + let x, pid, serial = '', status; + try { + x = JSON.parse(`${obj}`); + } catch { + } + if (typeof x === 'number') { + pid = x; + } else if (typeof x === 'object') { + // object passed from PickAndroidProcess in the extension + ({ pid, serial, status } = x); + if (status !== 'ok') { + return null; + } + } + if (typeof pid !== "number" || (pid < 0)) { + this.LOG(`Attach failed: "processId" property in launch.json is not valid`); + return null; + } + return { + processId: pid, + targetDevice: `${serial}`, + } + } + + async attachRequest(response, args) { + this.debug_mode = 'attach'; if (args && args.trace) { this.trace = args.trace; onMessagePrint(this.LOG.bind(this)); } + D(`Attach: ${JSON.stringify(args)}`); + + if (!args.processId) { + this.LOG(`Attach failed: Missing "processId" property in launch.json`); + this.sendEvent(new TerminatedEvent(false)); + return; + } + + // the processId passed in args can be: + // - a fixed id defined in launch.json (should be a string, but we allow a number), + // - a JSON object returned from the process picker (contains the target device and process ID), + let attach_info = this.extractPidAndTargetDevice(args.processId); + if (!attach_info) { + this.sendEvent(new TerminatedEvent(false)); + return; + } + + try { + // app_src_root must end in a path-separator for correct validation of sub-paths + this.app_src_root = ensure_path_end_slash(args.appSrcRoot); + // start by scanning the source folder for stuff we need to know about (packages, manifest, etc) + this.src_packages = PackageInfo.scanSourceSync(this.app_src_root); + // warn if we couldn't find any packages (-> no source -> cannot debug anything) + if (this.src_packages.packages.size === 0) + this.WARN('No source files found. Check the "appSrcRoot" setting in launch.json'); + + } catch(err) { + // wow, we really didn't make it very far... + this.LOG(err.message); + this.LOG('Check the "appSrcRoot" entries in launch.json'); + this.sendEvent(new TerminatedEvent(false)); + return; + } + + try { + let { processId, targetDevice } = attach_info; + if (!targetDevice) { + targetDevice = args.targetDevice; + } + // make sure ADB exists and is started and look for a connected device + await checkADBStarted(args.autoStartADB !== false); + this._device = await this.findSuitableDevice(targetDevice, args.trace); + this._device.adbclient = new ADBClient(this._device.serial); + + // try and determine the relevant path for the API sources (based upon the API level of the connected device) + await this.configureAPISourcePath(); + + const build = new BuildInfo(null, new Map(this.src_packages.packages), null); + this.LOG(`Attaching to pid ${processId} on device ${this._device.serial} [API:${this.device_api_level||'?'}]`); + + // try and attach to the specified pid + await this.dbgr.attachToProcess(build, processId, this._device.serial); + + this.debuggerAttached = true; + + // if we get this far, the debugger is connected and waiting for the resume command + // - set up some events... + this.dbgr.on('bpstatechange', e => this.onBreakpointStateChange(e)) + .on('bphit', e => this.onBreakpointHit(e)) + .on('step', e => this.onStep(e)) + .on('exception', e => this.onException(e)) + .on('threadchange', e => this.onThreadChange(e)) + .on('disconnect', () => this.onDebuggerDisconnect()); + + // - tell the client we're initialised and ready for breakpoint info, etc + this.sendEvent(new InitializedEvent()); + await new Promise(resolve => this.waitForConfigurationDone = resolve); + + // get the debugger to tell us about any thread creations/terminations + await this.dbgr.setThreadNotify(); + + // config is done - we're all set and ready to go! + this.sendResponse(response); + + this.LOG(`Debugger attached`); + await this.dbgr.resume(); + + } catch(e) { + //this.performDisconnect(); + // exceptions use message, adbclient uses msg + this.LOG('Attach failed: '+(e.message||e.msg||'No additional information is available')); + // more info for adb connect errors + if (/^ADB server is not running/.test(e.msg)) { + this.LOG('Make sure the Android SDK Platform Tools are installed and run:'); + this.LOG(' adb start-server'); + this.LOG('If you are running ADB on a non-default port, also make sure the adbPort value in your launch.json is correct.'); + } + // tell the client we're done + this.sendEvent(new TerminatedEvent(false)); + } + } + + /** + * The entry point to the debugger + * @param {*} response + * @param {*} args + */ + async launchRequest(response/*: DebugProtocol.LaunchResponse*/, args/*: LaunchRequestArguments*/) { + this.debug_mode = 'launch'; + if (args && args.trace) { + this.trace = args.trace; + onMessagePrint(this.LOG.bind(this)); + } + D(`Launch: ${JSON.stringify(args)}`); - D(`Launching: ${JSON.stringify(args)}`); // app_src_root must end in a path-separator for correct validation of sub-paths this.app_src_root = ensure_path_end_slash(args.appSrcRoot); this.apk_fpn = args.apkFile; @@ -319,8 +464,8 @@ class AndroidDebugSession extends DebugSession { throw new Error('No valid launch activity found in AndroidManifest.xml or launch.json'); // make sure ADB exists and is started and look for a device to install on - await this.checkADBStarted(args.autoStartADB !== false); - this._device = await this.findSuitableDevice(args.targetDevice); + await checkADBStarted(args.autoStartADB !== false); + this._device = await this.findSuitableDevice(args.targetDevice, true); this._device.adbclient = new ADBClient(this._device.serial); // install the APK we are going to debug @@ -336,6 +481,8 @@ class AndroidDebugSession extends DebugSession { // launch the app await this.startLaunchActivity(args.launchActivity); + this.debuggerAttached = true; + // if we get this far, the debugger is connected and waiting for the resume command // - set up some events... this.dbgr.on('bpstatechange', e => this.onBreakpointStateChange(e)) @@ -372,20 +519,6 @@ class AndroidDebugSession extends DebugSession { } } - async checkADBStarted(autoStartADB) { - const err = await new ADBClient().test_adb_connection(); - // if adb is not running, see if we can start it ourselves using ANDROID_HOME (and a sensible port number) - if (err && autoStartADB && process.env.ANDROID_HOME) { - const adbpath = path.join(process.env.ANDROID_HOME, 'platform-tools', /^win/.test(process.platform)?'adb.exe':'adb'); - const adbargs = ['-P',`${ADBSocket.ADBPort}`,'start-server']; - try { - this.LOG([adbpath, ...adbargs].join(' ')); - const stdout = require('child_process').execFileSync(adbpath, adbargs, {cwd:process.env.ANDROID_HOME, encoding:'utf8'}); - this.LOG(stdout); - } catch (ex) {} // if we fail, it doesn't matter - the device query will fail and the user will have to work it out themselves - } - } - checkBuildIsUpToDate(staleBuild) { // check if any source file was modified after the apk if (this.src_packages.last_src_modified >= this.apk_file_info.app_modified) { @@ -422,13 +555,7 @@ class AndroidDebugSession extends DebugSession { const apilevel = await this.getDeviceAPILevel(); // look for the android sources folder appropriate for this device - if (process.env.ANDROID_HOME && apilevel) { - const sources_path = path.join(process.env.ANDROID_HOME,'sources',`android-${apilevel}`); - fs.stat(sources_path, (err,stat) => { - if (!err && stat && stat.isDirectory()) - this._android_sources_path = sources_path; - }); - } + this._android_sources_path = getAndroidSourcesFolder(apilevel, true); } async getDeviceAPILevel() { @@ -491,11 +618,13 @@ class AndroidDebugSession extends DebugSession { /** * @param {string} target_deviceid + * @param {boolean} show_progress */ - async findSuitableDevice(target_deviceid) { - this.LOG('Searching for devices...'); + async findSuitableDevice(target_deviceid, show_progress) { + show_progress && this.LOG('Searching for devices...'); const devices = await this.dbgr.listConnectedDevices() - this.LOG(`Found ${devices.length} device${devices.length===1?'':'s'}`); + show_progress && this.LOG(`Found ${devices.length} device${devices.length===1?'':'s'}`); + let reject; if (devices.length === 0) { reject = 'No devices are connected'; @@ -543,11 +672,17 @@ class AndroidDebugSession extends DebugSession { async disconnectRequest(response/*, args*/) { D('disconnectRequest'); this._isDisconnecting = true; - try { - await this.dbgr.forceStop(); - await this.dbgr.disconnect(); - this.LOG(`Debugger stopped`); - } catch (e) { + if (this.debuggerAttached) { + try { + if (this.debug_mode === 'launch') { + await this.dbgr.forceStop(); + this.LOG(`Debugger stopped`); + } else { + await this.dbgr.disconnect(); + this.LOG(`Debugger detached`); + } + } catch (e) { + } } this.sendResponse(response); } @@ -635,10 +770,10 @@ class AndroidDebugSession extends DebugSession { const bp_queue_len = this._set_breakpoints_queue.push({args,response,relative_fpn}); if (bp_queue_len === 1) { do { - const next_bp = this._set_breakpoints_queue[0]; - const javabp_arr = await this._setup_breakpoints(next_bp); + const { args, relative_fpn, response } = this._set_breakpoints_queue[0]; + const javabp_arr = await this.setupBreakpointsInFile(args.breakpoints, relative_fpn); // send back the VS Breakpoint instances - sendBPResponse(next_bp.response, javabp_arr.map(javabp => javabp.vsbp)); + sendBPResponse(response, javabp_arr.map(javabp => javabp.vsbp)); // .. and do the next one this._set_breakpoints_queue.shift(); } while (this._set_breakpoints_queue.length); @@ -646,43 +781,42 @@ class AndroidDebugSession extends DebugSession { } /** - * @param {*} o - * @param {number} idx - * @param {*[]} javabp_arr + * + * @param {*[]} breakpoints + * @param {string} relative_fpn */ - async _setup_breakpoints(o, idx = 0, javabp_arr = []) { - const src_bp = o.args.breakpoints[idx]; - if (!src_bp) { - // end of list - return javabp_arr; - } - const dbgline = this.convertClientLineToDebugger(src_bp.line); - const options = new BreakpointOptions(); - if (src_bp.hitCondition) { - // the hit condition is an expression that requires evaluation - // until we get more comprehensive evaluation support, just allow integer literals - const m = src_bp.hitCondition.match(/^\s*(?:0x([0-9a-f]+)|0b([01]+)|0*(\d+([e]\+?\d+)?))\s*$/i); - if (m) { - const hitcount = m[3] ? parseFloat(m[3]) : m[2] ? parseInt(m[2],2) : parseInt(m[1],16); - if ((hitcount > 0) && (hitcount <= 0x7fffffff)) { - options.hitcount = hitcount; + async setupBreakpointsInFile(breakpoints, relative_fpn) { + const java_breakpoints = []; + for (let idx = 0; idx < breakpoints.length; idx++) { + const src_bp = breakpoints[idx]; + const dbgline = this.convertClientLineToDebugger(src_bp.line); + const options = new BreakpointOptions(); + if (src_bp.hitCondition) { + // the hit condition is an expression that requires evaluation + // until we get more comprehensive evaluation support, just allow integer literals + const m = src_bp.hitCondition.match(/^\s*(?:0x([0-9a-f]+)|0b([01]+)|0*(\d+([e]\+?\d+)?))\s*$/i); + if (m) { + const hitcount = m[3] ? parseFloat(m[3]) : m[2] ? parseInt(m[2],2) : parseInt(m[1],16); + if ((hitcount > 0) && (hitcount <= 0x7fffffff)) { + options.hitcount = hitcount; + } } } + const javabp = await this.dbgr.setBreakpoint(relative_fpn, dbgline, options); + if (!javabp.vsbp) { + // state is one of: set,notloaded,enabled,removed + const verified = !!javabp.state.match(/set|enabled/); + const bp = new Breakpoint(verified, this.convertDebuggerLineToClient(dbgline)); + // the breakpoint *must* have an id field or it won't update properly + bp['id'] = ++this._breakpointId; + if (javabp.state === 'notloaded') + bp['message'] = 'The runtime hasn\'t loaded this code location'; + javabp.vsbp = bp; + } + javabp.vsbp.order = idx; + java_breakpoints.push(javabp); } - const javabp = await this.dbgr.setBreakpoint(o.relative_fpn, dbgline, options); - if (!javabp.vsbp) { - // state is one of: set,notloaded,enabled,removed - const verified = !!javabp.state.match(/set|enabled/); - const bp = new Breakpoint(verified, this.convertDebuggerLineToClient(dbgline)); - // the breakpoint *must* have an id field or it won't update properly - bp['id'] = ++this._breakpointId; - if (javabp.state === 'notloaded') - bp['message'] = 'The runtime hasn\'t loaded this code location'; - javabp.vsbp = bp; - } - javabp.vsbp.order = idx; - javabp_arr.push(javabp); - return this._setup_breakpoints(o, ++idx, javabp_arr); + return java_breakpoints; }; async setExceptionBreakPointsRequest(response /*: SetExceptionBreakpointsResponse*/, args /*: SetExceptionBreakpointsArguments*/) { @@ -847,13 +981,7 @@ class AndroidDebugSession extends DebugSession { `; // don't actually attempt to load the file here - just recheck to see if the sources // path is valid yet. - if (process.env.ANDROID_HOME && this.device_api_level) { - const sources_path = path.join(process.env.ANDROID_HOME,'sources','android-'+this.device_api_level); - fs.stat(sources_path, (err,stat) => { - if (!err && stat && stat.isDirectory()) - this._android_sources_path = sources_path; - }); - } + this._android_sources_path = getAndroidSourcesFolder(this.device_api_level, true); response.body = { content }; this.sendResponse(response); diff --git a/src/debugger-types.js b/src/debugger-types.js index d5ae11b..409ca55 100644 --- a/src/debugger-types.js +++ b/src/debugger-types.js @@ -82,10 +82,10 @@ class DebugSession { this.classPrepareFilters = new Set(); /** - * The set of class signatures already prepared + * The set of class signatures loaded by the runtime * @type {Set} */ - this.preparedClasses = new Set(); + this.loadedClasses = new Set(); /** * Enabled step JDWP IDs for each thread diff --git a/src/debugger.js b/src/debugger.js index 23d15d4..89d17b1 100644 --- a/src/debugger.js +++ b/src/debugger.js @@ -75,11 +75,17 @@ class Debugger extends EventEmitter { * @param {string} deviceid */ async startDebugSession(build, deviceid) { + if (this.status() !== 'disconnected') { + throw new Error('startDebugSession: session already active'); + } this.session = new DebugSession(build, deviceid); const stdout = await Debugger.runApp(deviceid, build.startCommandArgs, build.postLaunchPause); // retrieve the list of debuggable processes - const pids = await this.getDebuggablePIDs(this.session.deviceid); + const pids = await Debugger.getDebuggablePIDs(this.session.deviceid, 10e3); + if (pids.length === 0) { + throw new Error(`startDebugSession: No debuggable processes after app launch.`); + } // choose the last pid in the list const pid = pids[pids.length - 1]; // after connect(), the caller must call resume() to begin @@ -87,6 +93,20 @@ class Debugger extends EventEmitter { return stdout; } + /** + * @param {BuildInfo} build + * @param {number} pid process ID to connect to + * @param {string} deviceid device ID to connect to + */ + async attachToProcess(build, pid, deviceid) { + if (this.status() !== 'disconnected') { + throw new Error('attachToProcess: session already active') + } + this.session = new DebugSession(build, deviceid); + // after connect(), the caller must call resume() to begin + await this.connect(pid); +} + /** * @param {string} deviceid Device ID to connect to * @param {string[]} launch_cmd_args Array of arguments to pass to 'am start' @@ -127,54 +147,21 @@ class Debugger extends EventEmitter { /** * Retrieve a list of debuggable process IDs from a device + * @param {string} deviceid + * @param {number} timeout_ms */ - getDebuggablePIDs(deviceid) { - return new ADBClient(deviceid).jdwp_list(); + static getDebuggablePIDs(deviceid, timeout_ms) { + return new ADBClient(deviceid).jdwp_list(timeout_ms); } - async getDebuggableProcesses(deviceid) { - const adbclient = new ADBClient(deviceid); - const info = { - debugger: this, - jdwps: null, - }; - const jdwps = await info.adbclient.jdwp_list(); - if (!jdwps.length) - return null; - info.jdwps = jdwps; - // retrieve the ps list from the device - const stdout = await adbclient.shell_cmd({ - command: 'ps', - }); - // output should look something like... - // USER PID PPID VSIZE RSS WCHAN PC NAME - // u0_a153 32721 1452 1506500 37916 ffffffff 00000000 S com.example.somepkg - // but we cope with variations so long as PID and NAME exist - const lines = stdout.split(/\r?\n|\r/g); - const hdrs = (lines.shift() || '').trim().toUpperCase().split(/\s+/); - const pidindex = hdrs.indexOf('PID'); - const nameindex = hdrs.indexOf('NAME'); - if (pidindex < 0 || nameindex < 0) - return []; - const result = []; - // scan the list looking for matching pids... - for (let i = 0; i < lines.length; i++) { - const entries = lines[i].trim().replace(/ [S] /, ' ').split(/\s+/); - if (entries.length !== hdrs.length) { - continue; - } - const jdwpidx = info.jdwps.indexOf(entries[pidindex]); - if (jdwpidx < 0) { - continue; - } - // we found a match - const entry = { - jdwp: entries[pidindex], - name: entries[nameindex], - }; - result.push(entry); - } - return result; + /** + * Retrieve a list of debuggable process IDs with process names from a device. + * For Android, the process name is usually the package name. + * @param {string} deviceid + * @param {number} timeout_ms + */ + static getDebuggableProcesses(deviceid, timeout_ms) { + return new ADBClient(deviceid).named_jdwp_list(timeout_ms); } /** @@ -227,6 +214,7 @@ class Debugger extends EventEmitter { async performConnectionTasks() { // setup port forwarding + // note that this call generally succeeds - even if the JDWP pid is invalid await new ADBClient(this.session.deviceid).jdwp_forward({ localport: this.connection.localport, jdwp: this.connection.jdwp, @@ -236,13 +224,21 @@ class Debugger extends EventEmitter { // after this, the client keeps an open connection until // jdwp_disconnect() is called this.session.adbclient = new ADBClient(this.session.deviceid); - await this.session.adbclient.jdwp_connect({ - localport: this.connection.localport, - onreply: data => this._onJDWPMessage(data), - ondisconnect: () => this._onJDWPDisconnect(), - }); + try { + // if the JDWP pid is invalid (doesn't exist, not debuggable, etc) ,this + // is where it will fail... + await this.session.adbclient.jdwp_connect({ + localport: this.connection.localport, + onreply: data => this._onJDWPMessage(data), + ondisconnect: () => this._onJDWPDisconnect(), + }); + } catch (e) { + // provide a slightly more meaningful message than a socket error + throw new Error(`A debugger connection to pid ${this.connection.jdwp} could not be established. ${e.message}`) + } // handshake has completed this.connection.connected = true; + // call suspend first - we shouldn't really need to do this (as the debugger // is already suspended and will not resume until we tell it), but if we // don't do this, it logs a complaint... @@ -258,6 +254,12 @@ class Debugger extends EventEmitter { // set the class loader event notifier so we can enable breakpoints when the // runtime loads the classes await this.initClassPrepareForBreakpoints(); + + // some types have already been loaded (so we won't receive class-prepare notifications). + // we can't map breakpoint source locations to already-loaded anonymous types, so we just retrieve + // a list of all classes for now. + const all_classes = await this.getAllClasses(); + this.session.loadedClasses = new Set(all_classes.map(x => x.signature)); } /** @@ -337,17 +339,20 @@ class Debugger extends EventEmitter { // reset the breakpoint states this.resetBreakpoints(); - this.emit('disconnect'); + + // clear the session + const adbclient = this.session.adbclient; + this.session = null; // perform the JDWP disconnect if (connection.connected) { - await this.session.adbclient.jdwp_disconnect(); + await adbclient.jdwp_disconnect(); } // undo the portforwarding // todo: replace remove_all with remove_port if (connection.portforwarding) { - await new ADBClient(this.session.deviceid).forward_remove_all(); + await adbclient.forward_remove_all(); } // mark the port as freed @@ -355,8 +360,7 @@ class Debugger extends EventEmitter { Debugger.portManager.freeport(connection.localport); } - // clear the session - this.session = null; + this.emit('disconnect'); return previous_state; } @@ -539,17 +543,12 @@ class Debugger extends EventEmitter { */ async initialiseBreakpoint(bp) { // try and load the class - if the runtime hasn't loaded it yet, this will just return a TypeNotAvailable instance - let classes = [await this.loadClassInfo(`L${bp.qtype};`)]; + let classes = await Promise.all( + [...this.session.loadedClasses] + .filter(signature => bp.sigpattern.test(signature)) + .map(signature => this.loadClassInfo(signature)) + ); let bploc = Debugger.findBreakpointLocation(classes, bp); - if (!bploc) { - // the required location may be inside a nested class (anonymous or named) - // Since Android doesn't support the NestedTypes JDWP call (ffs), all we can do here - // is look for existing (cached) loaded types matching inner type signatures - classes = this.session.classList - .filter(c => bp.sigpattern.test(c.type.signature)); - // try again - bploc = Debugger.findBreakpointLocation(classes, bp); - } if (!bploc) { // we couldn't identify a matching location - either the class is not yet loaded or the // location doesn't correspond to any code. In case it's the former, make sure we are notified @@ -1492,10 +1491,10 @@ class Debugger extends EventEmitter { // if the class prepare events have overlapping packages (mypackage.*, mypackage.another.*), we will get // multiple notifications (which duplicates breakpoints, etc) const signature = prepared_class.type.signature; - if (this.session.preparedClasses.has(signature)) { + if (this.session.loadedClasses.has(signature)) { return; // we already know about this } - this.session.preparedClasses.add(signature); + this.session.loadedClasses.add(signature); D('Prepared: ' + signature); if (!/^L(.*);$/.test(signature)) { // unrecognised type signature - ignore it diff --git a/src/jdwp.js b/src/jdwp.js index f0b4af5..2670ba0 100644 --- a/src/jdwp.js +++ b/src/jdwp.js @@ -1534,7 +1534,7 @@ class JDWP { const res = []; let arrlen = DataCoder.decodeInt(o); while (--arrlen >= 0) { - res.push(DataCoder.decodeList(o, [{reftype:'reftype'},{typeid:'tref'},{type:'signature'},{genericSignature:'string'},{status:'status'}])); + res.push(DataCoder.decodeList(o, [{reftype:'reftype'},{typeid:'tref'},{signature:'string'},{genericSignature:'string'},{status:'status'}])); } return res; } diff --git a/src/logcat.js b/src/logcat.js index bed8b9f..cf05b26 100644 --- a/src/logcat.js +++ b/src/logcat.js @@ -7,6 +7,8 @@ const WebSocketServer = require('ws').Server; // our stuff const { ADBClient } = require('./adbclient'); const { AndroidContentProvider } = require('./contentprovider'); +const { checkADBStarted } = require('./utils/android'); +const { selectTargetDevice } = require('./utils/device'); const { D } = require('./utils/print'); /** @@ -292,83 +294,58 @@ function onWebSocketClientConnection(client, req) { client._socket && typeof(client._socket.setNoDelay)==='function' && client._socket.setNoDelay(true); } -function getADBPort() { - const defaultPort = 5037; - const adbPort = AndroidContentProvider.getLaunchConfigSetting('adbPort', defaultPort); - if (typeof adbPort === 'number' && adbPort === (adbPort|0)) - return adbPort; - return defaultPort; +/** + * @param {import('vscode')} vscode + * @param {*} target_device + */ +function openWebviewLogcatWindow(vscode, target_device) { + const panel = vscode.window.createWebviewPanel( + 'androidlogcat', // Identifies the type of the webview. Used internally + `logcat-${target_device.serial}`, // Title of the panel displayed to the user + vscode.ViewColumn.Two, // Editor column to show the new webview panel in. + { + enableScripts: true, // we use embedded scripts to relay logcat info over a websocket + } + ); + const logcat = new LogcatContent(target_device.serial); + logcat.content().then(html => { + panel.webview.html = html; + }); } -function openLogcatWindow(vscode) { - new ADBClient().test_adb_connection() - .then(err => { - // if adb is not running, see if we can start it ourselves using ANDROID_HOME (and a sensible port number) - const adbport = getADBPort(); +/** + * @param {import('vscode')} vscode + * @param {*} target_device + */ +function openPreviewHtmlLogcatWindow(vscode, target_device) { + const uri = AndroidContentProvider.getReadLogcatUri(target_device.serial); + vscode.commands.executeCommand("vscode.previewHtml", uri, vscode.ViewColumn.Two); +} + +/** + * @param {import('vscode')} vscode + */ +async function openLogcatWindow(vscode) { + try { + // if adb is not running, see if we can start it ourselves const autoStartADB = AndroidContentProvider.getLaunchConfigSetting('autoStartADB', true); - if (err && autoStartADB!==false && process.env.ANDROID_HOME && typeof adbport === 'number' && adbport > 0 && adbport < 65536) { - const adbpath = path.join(process.env.ANDROID_HOME, 'platform-tools', /^win/.test(process.platform)?'adb.exe':'adb'); - const adbargs = ['-P',''+adbport,'start-server']; - try { - /*const stdout = */require('child_process').execFileSync(adbpath, adbargs, {cwd:process.env.ANDROID_HOME, encoding:'utf8'}); - } catch (ex) {} // if we fail, it doesn't matter - the device query will fail and the user will have to work it out themselves + await checkADBStarted(autoStartADB); + + let target_device = await selectTargetDevice(vscode, "Logcat display"); + if (!target_device) { + return; } - }) - .then(() => new ADBClient().list_devices()) - .then(devices => { - switch(devices.length) { - case 0: - vscode.window.showInformationMessage('Logcat cannot be displayed. No Android devices are currently connected'); - return null; - case 1: - return devices; // only one device - just show it + + if (vscode.window.createWebviewPanel) { + // newer versions of vscode use WebviewPanels + openWebviewLogcatWindow(vscode, target_device); + } else { + // older versions of vscode use previewHtml + openPreviewHtmlLogcatWindow(vscode, target_device); } - const prefix = 'Android: View Logcat - ', all = '[ Display All ]'; - const devicelist = devices.map(d => prefix + d.serial); - //devicelist.push(prefix + all); - return vscode.window.showQuickPick(devicelist) - .then(which => { - if (!which) return; // user cancelled - which = which.slice(prefix.length); - return new ADBClient().list_devices() - .then(devices => { - if (which === all) { - return devices - } - const found = devices.find(d => d.serial === which); - if (found) { - return [found]; - } - vscode.window.showInformationMessage('Logcat cannot be displayed. The device is disconnected'); - return null; - }); - }, () => null); - }) - .then(devices => { - if (!Array.isArray(devices)) return; // user cancelled (or no devices connected) - devices.forEach(device => { - if (vscode.window.createWebviewPanel) { - const panel = vscode.window.createWebviewPanel( - 'androidlogcat', // Identifies the type of the webview. Used internally - `logcat-${device.serial}`, // Title of the panel displayed to the user - vscode.ViewColumn.One, // Editor column to show the new webview panel in. - { - enableScripts: true, - } - ); - const logcat = new LogcatContent(device.serial); - logcat.content().then(html => { - panel.webview.html = html; - }); - return; - } - const uri = AndroidContentProvider.getReadLogcatUri(device.serial); - vscode.commands.executeCommand("vscode.previewHtml",uri,vscode.ViewColumn.Two); - }); - }) - .catch((/*e*/) => { - vscode.window.showInformationMessage('Logcat cannot be displayed. Querying the connected devices list failed. Is ADB running?'); - }); + } catch (e) { + vscode.window.showInformationMessage(`Logcat cannot be displayed. ${e.message}`); + } } module.exports = { diff --git a/src/process-attach.js b/src/process-attach.js new file mode 100644 index 0000000..910b6f9 --- /dev/null +++ b/src/process-attach.js @@ -0,0 +1,83 @@ +const os = require('os'); +const { ADBClient } = require('./adbclient'); +const { selectTargetDevice } = require('./utils/device'); + +/** + * @param {import('vscode')} vscode + * @param {{pid:number,name:string}[]} pids + */ +async function showPIDPicker(vscode, pids) { + // sort by PID (the user can type the package name to search) + const sorted_pids = pids.slice().sort((a,b) => a.pid - b.pid); + + /** @type {import('vscode').QuickPickItem[]} */ + const device_pick_items = sorted_pids + .map(x => ({ + label: `${x.pid}`, + description: x.name, + })); + + /** @type {import('vscode').QuickPickOptions} */ + const device_pick_options = { + matchOnDescription: true, + canPickMany: false, + placeHolder: 'Choose the Android process to attach to', + }; + + const chosen_option = await vscode.window.showQuickPick(device_pick_items, device_pick_options); + return sorted_pids[device_pick_items.indexOf(chosen_option)] || null; +} + +/** + * @param {import('vscode')} vscode + */ +async function selectAndroidProcessID(vscode) { + const res = { + /** @type {string|'ok'|'cancelled'|'failed'} */ + status: 'failed', + pid: 0, + serial: '', + } + const err = await new ADBClient().test_adb_connection() + if (err) { + vscode.window.showWarningMessage('Attach failed. ADB is not running.'); + return res; + } + + const device = await selectTargetDevice(vscode, 'Attach'); + if (!device) { + // user cancelled picker + res.status = 'cancelled'; + return res; + } + + let named_pids = await new ADBClient(device.serial).named_jdwp_list(5000); + if (named_pids.length === 0) { + vscode.window.showWarningMessage( + 'Attach failed. No debuggable processes are running on the device.' + + `${os.EOL}${os.EOL}` + + `To allow a debugger to attach, the app must have the "android:debuggable=true" attribute present in AndroidManifest.xml and be running on the device.` + + `${os.EOL}` + + `See https://developer.android.com/guide/topics/manifest/application-element#debug` + ); + return res; + } + + // always show the pid picker - even if there's only one + const named_pid = await showPIDPicker(vscode, named_pids); + if (named_pid === null) { + // user cancelled picker + res.status = 'cancelled'; + return res; + } + + res.pid = named_pid.pid; + res.serial = device.serial; + res.status = 'ok'; + + return res; +} + +module.exports = { + selectAndroidProcessID, +} diff --git a/src/sockets/adbsocket.js b/src/sockets/adbsocket.js index 7bb0150..3d81744 100644 --- a/src/sockets/adbsocket.js +++ b/src/sockets/adbsocket.js @@ -73,10 +73,13 @@ class ADBSocket extends AndroidSocket { /** * Sends an ADB command, checks the returned status and then reads raw data from the socket * @param {string} command + * @param {number} timeout_ms + * @param {boolean} [until_closed] */ - async cmd_and_read_stdout(command) { + async cmd_and_read_stdout(command, timeout_ms, until_closed) { await this.cmd_and_status(command); - return this.read_stdout(); + const buf = await this.read_stdout(timeout_ms, until_closed); + return buf.toString('latin1'); } /** diff --git a/src/sockets/androidsocket.js b/src/sockets/androidsocket.js index 384d861..bec256e 100644 --- a/src/sockets/androidsocket.js +++ b/src/sockets/androidsocket.js @@ -64,8 +64,9 @@ class AndroidSocket extends EventEmitter { * * @param {number|'length+data'|undefined} length * @param {string} [format] + * @param {number} [timeout_ms] */ - async read_bytes(length, format) { + async read_bytes(length, format, timeout_ms) { //D(`reading ${length} bytes`); let actual_length = length; if (typeof actual_length === 'undefined') { @@ -95,25 +96,40 @@ class AndroidSocket extends EventEmitter { return Promise.resolve(data); } // wait for the socket to update and then retry the read - await this.wait_for_socket_data(); + await this.wait_for_socket_data(timeout_ms); return this.read_bytes(length, format); } - wait_for_socket_data() { + /** + * + * @param {number} [timeout_ms] + */ + wait_for_socket_data(timeout_ms) { return new Promise((resolve, reject) => { - let done = 0; + let done = 0, timer = null; let onDataChanged = () => { if ((done += 1) !== 1) return; this.off('socket-ended', onSocketEnded); + clearTimeout(timer); resolve(); } let onSocketEnded = () => { if ((done += 1) !== 1) return; this.off('data-changed', onDataChanged); + clearTimeout(timer); reject(new Error(`${this.which} socket read failed. Socket closed.`)); } + let onTimerExpired = () => { + if ((done += 1) !== 1) return; + this.off('socket-ended', onSocketEnded); + this.off('data-changed', onDataChanged); + reject(new Error(`${this.which} socket read failed. Read timeout.`)); + } this.once('data-changed', onDataChanged); this.once('socket-ended', onSocketEnded); + if (typeof timeout_ms === 'number' && timeout_ms >= 0) { + timer = setTimeout(onTimerExpired, timeout_ms); + } }); } @@ -122,8 +138,26 @@ class AndroidSocket extends EventEmitter { return this.read_bytes(len.readUInt32LE(0), format); } - read_stdout(format = 'latin1') { - return this.read_bytes(undefined, format); + /** + * + * @param {number} [timeout_ms] + * @param {boolean} [until_closed] + * @returns {Promise} + */ + async read_stdout(timeout_ms, until_closed) { + let buf = await this.read_bytes(undefined, null, timeout_ms); + if (!until_closed) { + return buf; + } + const parts = [buf]; + try { + for (;;) { + buf = await this.read_bytes(undefined, null); + parts.push(buf); + } + } catch { + } + return Buffer.concat(parts); } /** @@ -134,6 +168,7 @@ class AndroidSocket extends EventEmitter { return new Promise((resolve, reject) => { this.check_socket_active('write'); try { + // @ts-ignore const flushed = this.socket.write(bytes, () => { flushed ? resolve() : this.socket.once('drain', resolve); }); diff --git a/src/utils/android.js b/src/utils/android.js new file mode 100644 index 0000000..32926be --- /dev/null +++ b/src/utils/android.js @@ -0,0 +1,87 @@ +const fs = require('fs'); +const path = require('path'); + +const { ADBClient } = require('../adbclient'); +const ADBSocket = require('../sockets/adbsocket'); +const { LOG } = require('../utils/print'); + +function getAndroidSDKFolder() { + // ANDROID_HOME is deprecated + return process.env.ANDROID_HOME || process.env.ANDROID_SDK; +} + +/** + * @param {string} api_level + * @param {boolean} check_is_dir + */ +function getAndroidSourcesFolder(api_level, check_is_dir) { + const android_sdk = getAndroidSDKFolder(); + if (!android_sdk) { + return null; + } + const sources_path = path.join(android_sdk,'sources',`android-${api_level}`); + if (check_is_dir) { + try { + const stat = fs.statSync(sources_path); + if (!stat || !stat.isDirectory()) { + return null; + } + } catch { + return null; + } + } + return sources_path; +} + +function getADBPathName() { + const android_sdk = getAndroidSDKFolder(); + if (!android_sdk) { + return ''; + } + return path.join(android_sdk, 'platform-tools', /^win/.test(process.platform)?'adb.exe':'adb'); +} + +/** + * @param {number} port + */ +function startADBServer(port) { + if (typeof port !== 'number' || port <= 0 || port >= 65536) { + return false; + } + + const adb_exe_path = getADBPathName(); + if (!adb_exe_path) { + return false; + } + const adb_start_server_args = ['-P',`${port}`,'start-server']; + try { + LOG([adb_exe_path, ...adb_start_server_args].join(' ')); + const stdout = require('child_process').execFileSync(adb_exe_path, adb_start_server_args, { + cwd: getAndroidSDKFolder(), + encoding:'utf8', + }); + LOG(stdout); + return true; + } catch (ex) {} // if we fail, it doesn't matter - the device query will fail and the user will have to work it out themselves + return false +} + +/** + * @param {boolean} auto_start + */ +async function checkADBStarted(auto_start) { + const err = await new ADBClient().test_adb_connection(); + // if adb is not running, see if we can start it ourselves using ANDROID_HOME (and a sensible port number) + if (err && auto_start) { + return startADBServer(ADBSocket.ADBPort); + } + return !err; +} + +module.exports = { + checkADBStarted, + getADBPathName, + getAndroidSDKFolder, + getAndroidSourcesFolder, + startADBServer, +} diff --git a/src/utils/device.js b/src/utils/device.js new file mode 100644 index 0000000..01d81e5 --- /dev/null +++ b/src/utils/device.js @@ -0,0 +1,55 @@ +const { ADBClient } = require('../adbclient'); + +/** + * @param {import('vscode')} vscode + * @param {{serial:string}[]} devices + */ +async function showDevicePicker(vscode, devices) { + const sorted_devices = devices.slice().sort((a,b) => a.serial.localeCompare(b.serial, undefined, {sensitivity: 'base'})); + + /** @type {import('vscode').QuickPickItem[]} */ + const quick_pick_items = sorted_devices + .map(device => ({ + label: `${device.serial}`, + })); + + /** @type {import('vscode').QuickPickOptions} */ + const quick_pick_options = { + canPickMany: false, + placeHolder: 'Choose an Android device', + }; + + const chosen_option = await vscode.window.showQuickPick(quick_pick_items, quick_pick_options); + return sorted_devices[quick_pick_items.indexOf(chosen_option)] || null; +} + +/** + * + * @param {import('vscode')} vscode + * @param {'Attach'|'Logcat display'} action + */ +async function selectTargetDevice(vscode, action) { + const devices = await new ADBClient().list_devices(); + let device; + switch(devices.length) { + case 0: + vscode.window.showWarningMessage(`${action} failed. No Android devices are connected.`); + return null; + case 1: + return devices[0]; // only one device - just use it + } + device = await showDevicePicker(vscode, devices); + // the user might take a while to choose the device, so once + // chosen, recheck it exists + const current_devices = await new ADBClient().list_devices(); + if (!current_devices.find(d => d.serial === device.serial)) { + vscode.window.showInformationMessage(`${action} failed. The target device is disconnected`); + return null; + } + return device; +} + +module.exports = { + selectTargetDevice, + showDevicePicker, +} diff --git a/src/utils/print.js b/src/utils/print.js index 668b46d..d6f4b67 100644 --- a/src/utils/print.js +++ b/src/utils/print.js @@ -35,6 +35,23 @@ function W(...args) { callMessagePrintCallbacks(args); } +let printLogToClient; +function initLogToClient(fn) { + printLogToClient = fn; +} + +/** + * Print a log message + * @param {*} msg + */ +function LOG(msg) { + if (printLogToClient) { + printLogToClient(msg); + } else { + D(msg); + } +} + /** * Adds a callback to be called when any message is output * @param {Function} cb @@ -45,7 +62,9 @@ function onMessagePrint(cb) { module.exports = { D, - E, + E, + initLogToClient, + LOG, W, onMessagePrint, }