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
This commit is contained in:
Dave Holoway
2020-04-23 13:28:03 +01:00
committed by GitHub
parent 9aeca6b96b
commit 44d887dd6c
15 changed files with 781 additions and 278 deletions

6
.vscode/launch.json vendored
View File

@@ -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" ]
}
]
}

View File

@@ -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);

View File

@@ -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": {}

View File

@@ -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<string>}
*/
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,18 +208,19 @@ 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;
for (;;) {
// read the next data from ADB
try {
next_data = await this.adbsocket.read_stdout(null);
next_data = await this.adbsocket.read_stdout();
} catch(e) {
o.onclose();
o.onclose(e);
return;
}
logcatbuffer = Buffer.concat([logcatbuffer, next_data]);
@@ -178,16 +230,19 @@ class ADBClient {
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);
// 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);
next_logcat_lines();
}
}
next_logcat_lines();
}

View File

@@ -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 = '<no appSrcRoot>';
// 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);
// 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,12 +672,18 @@ class AndroidDebugSession extends DebugSession {
async disconnectRequest(response/*, args*/) {
D('disconnectRequest');
this._isDisconnecting = true;
if (this.debuggerAttached) {
try {
if (this.debug_mode === 'launch') {
await this.dbgr.forceStop();
await this.dbgr.disconnect();
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,16 +781,14 @@ 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;
}
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) {
@@ -669,7 +802,7 @@ class AndroidDebugSession extends DebugSession {
}
}
}
const javabp = await this.dbgr.setBreakpoint(o.relative_fpn, dbgline, options);
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/);
@@ -681,8 +814,9 @@ class AndroidDebugSession extends DebugSession {
javabp.vsbp = bp;
}
javabp.vsbp.order = idx;
javabp_arr.push(javabp);
return this._setup_breakpoints(o, ++idx, javabp_arr);
java_breakpoints.push(javabp);
}
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);

View File

@@ -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<string>}
*/
this.preparedClasses = new Set();
this.loadedClasses = new Set();
/**
* Enabled step JDWP IDs for each thread

View File

@@ -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);
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

View File

@@ -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;
}

View File

@@ -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;
}
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();
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
}
})
.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
}
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) {
/**
* @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-${device.serial}`, // Title of the panel displayed to the user
vscode.ViewColumn.One, // Editor column to show the new webview panel in.
`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,
enableScripts: true, // we use embedded scripts to relay logcat info over a websocket
}
);
const logcat = new LogcatContent(device.serial);
const logcat = new LogcatContent(target_device.serial);
logcat.content().then(html => {
panel.webview.html = html;
});
}
/**
* @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);
await checkADBStarted(autoStartADB);
let target_device = await selectTargetDevice(vscode, "Logcat display");
if (!target_device) {
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?');
});
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);
}
} catch (e) {
vscode.window.showInformationMessage(`Logcat cannot be displayed. ${e.message}`);
}
}
module.exports = {

83
src/process-attach.js Normal file
View File

@@ -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,
}

View File

@@ -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');
}
/**

View File

@@ -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<Buffer>}
*/
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);
});

87
src/utils/android.js Normal file
View File

@@ -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,
}

55
src/utils/device.js Normal file
View File

@@ -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,
}

View File

@@ -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
@@ -46,6 +63,8 @@ function onMessagePrint(cb) {
module.exports = {
D,
E,
initLogToClient,
LOG,
W,
onMessagePrint,
}